From 74debaa20e0876f92df00e8e8822147f91c2c9dd Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 20 Oct 2026 22:42:57 +0200 Subject: [PATCH 01/36] Read __version__ from the package metadata (#15) --- CHANGELOG.md | 2 ++ src/simgeometry/__init__.py | 7 ++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 80ef8cb..7537ba3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,8 @@ format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed +- `__version__` is read from the installed package metadata (`importlib.metadata`), not hardcoded, + so it can no longer disagree with `pyproject.toml` (the 0.1.1 wheel reported 0.1.0). - One out-of-range rule for owners: per axis, positions below the lower bound belong to the first block and positions at or above the upper edge to the last (the block index is clipped). `owner_rank_for_position` now calls `owner_ranks`; positions below the lower bound used to go diff --git a/src/simgeometry/__init__.py b/src/simgeometry/__init__.py index 8173419..7aaa9bb 100644 --- a/src/simgeometry/__init__.py +++ b/src/simgeometry/__init__.py @@ -1,5 +1,7 @@ """Shapes, grids, and domains for numerical simulations.""" +from importlib.metadata import PackageNotFoundError, version + from simgeometry.collection import GeometryCollection from simgeometry.grid import Grid from simgeometry.objects import ( @@ -18,7 +20,10 @@ ) from simgeometry.simulation_domain import SimulationDomain -__version__ = "0.1.0" +try: + __version__ = version("simgeometry") +except PackageNotFoundError: # not installed, e.g. run from a source tree + __version__ = "0.0.0+unknown" __all__ = [ "GeometryCollection", "Grid", From 657cb48a0aa9974349fdc3ade7972a60a8a7d915 Mon Sep 17 00:00:00 2001 From: Max Date: Wed, 21 Oct 2026 22:29:48 +0200 Subject: [PATCH 02/36] Test PRs to devel against the max-models devel branches and PRs to main against PyPI (#16) --- .github/workflows/release.yml | 4 + .github/workflows/static_analysis.yml | 5 + .github/workflows/test_pytest.yml | 27 ++ .github/workflows/test_tutorials.yml | 5 + .gitlab-ci.yml | 8 +- .../content/docs/development/publishing.md | 10 + pyproject.toml | 7 + uv.lock | 279 +++++++++--------- 8 files changed, 199 insertions(+), 146 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d843a4d..0f2de5c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,6 +11,10 @@ concurrency: permissions: contents: read +env: + # The release (a push to main) builds against PyPI versions only, never [tool.uv.sources]. + UV_NO_SOURCES: true + jobs: release: runs-on: ubuntu-latest diff --git a/.github/workflows/static_analysis.yml b/.github/workflows/static_analysis.yml index 17666a8..60fd5c0 100644 --- a/.github/workflows/static_analysis.yml +++ b/.github/workflows/static_analysis.yml @@ -10,6 +10,11 @@ on: - main - devel +env: + # A PR to main or a push to main (the release) uses PyPI versions only; devel uses the + # devel branches from [tool.uv.sources]. + UV_NO_SOURCES: ${{ (github.base_ref || github.ref_name) == 'main' }} + jobs: ruff: runs-on: ubuntu-latest diff --git a/.github/workflows/test_pytest.yml b/.github/workflows/test_pytest.yml index a3b3dfe..d089924 100644 --- a/.github/workflows/test_pytest.yml +++ b/.github/workflows/test_pytest.yml @@ -10,6 +10,11 @@ on: - main - devel +env: + # A PR to main or a push to main (the release) uses PyPI versions only; devel uses the + # devel branches from [tool.uv.sources]. + UV_NO_SOURCES: ${{ (github.base_ref || github.ref_name) == 'main' }} + jobs: pytest: runs-on: ubuntu-latest @@ -41,3 +46,25 @@ jobs: files: coverage.xml flags: python-${{ matrix.python-version }} fail_ci_if_error: false + + # PyPI rejects a release whose metadata has a direct reference (name @ git+...), so a pull + # request to main fails here instead of the release after the merge. + no-direct-references: + if: (github.base_ref || github.ref_name) == 'main' + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Set up uv + uses: astral-sh/setup-uv@v7 + with: + python-version: "3.12" + + - name: Check that the wheel has no direct references (name @ git+...) + run: | + uv build --wheel -o dist + if unzip -p dist/*.whl '*.dist-info/METADATA' | grep -E '^Requires-Dist: [^;]*@'; then + echo "::error::Requires-Dist has a direct reference; use a version range and [tool.uv.sources]" + exit 1 + fi diff --git a/.github/workflows/test_tutorials.yml b/.github/workflows/test_tutorials.yml index a6dbc68..c111d2d 100644 --- a/.github/workflows/test_tutorials.yml +++ b/.github/workflows/test_tutorials.yml @@ -10,6 +10,11 @@ on: - main - devel +env: + # A PR to main or a push to main (the release) uses PyPI versions only; devel uses the + # devel branches from [tool.uv.sources]. + UV_NO_SOURCES: ${{ (github.base_ref || github.ref_name) == 'main' }} + jobs: tutorials: runs-on: ubuntu-latest diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 2ff09ec..2d2fa6d 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -18,7 +18,12 @@ cache: paths: - .cache/pip +# PyPI versions only when targeting main; otherwise the devel branches from [tool.uv.sources] +.uv-no-sources: &uv-no-sources + - export UV_NO_SOURCES=$([ "${CI_MERGE_REQUEST_TARGET_BRANCH_NAME:-$CI_COMMIT_BRANCH}" = main ] && echo true || echo false) + before_script: + - *uv-no-sources - python --version ; pip --version # For debugging - pip install uv - uv sync --extra dev @@ -39,7 +44,8 @@ build: pages: image: node:22 - before_script: [] + before_script: + - *uv-no-sources script: - apt-get update && apt-get install -y python3 python3-pip python3-venv - pip install uv && uv sync --extra docs diff --git a/docs/src/content/docs/development/publishing.md b/docs/src/content/docs/development/publishing.md index 037399c..bb242d0 100644 --- a/docs/src/content/docs/development/publishing.md +++ b/docs/src/content/docs/development/publishing.md @@ -33,6 +33,16 @@ to `CHANGELOG.md`. On every push to `main`, the `Release` workflow: 3. Optionally, in the GitHub repository settings create an environment named `pypi` whose deployment branches are limited to `main`. +### Development against the devel branches + +Pull requests to `devel` test against the devel branches of the max-models packages simgeometry +depends on (`cunumpy`, `maybempi` and `mpiarray`), pinned in `uv.lock` through +`[tool.uv.sources]`. Pull requests to `main` and the release use PyPI only (CI sets +`UV_NO_SOURCES`), and on them the `Test Pytest` workflow fails if the built wheel has a direct +reference (`name @ git+...`), which PyPI rejects. Refresh the pinned devel commits with `uv lock +--upgrade-package cunumpy`, and reproduce a run against PyPI with `UV_NO_SOURCES=true uv sync +--extra test` (it rewrites `uv.lock`, so `git restore uv.lock` afterwards). + ### Coverage The tests upload coverage to [Codecov](https://codecov.io/). Public repositories need no diff --git a/pyproject.toml b/pyproject.toml index b209c66..48490ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -88,3 +88,10 @@ ini_options.addopts = "-ra" run.omit = [ "*/tests/*" ] run.source = [ "simgeometry" ] report.show_missing = true + +# PRs to devel test against the devel branches of the max-models packages (pinned in +# uv.lock); PRs to main and the release use PyPI only (CI sets UV_NO_SOURCES). +[tool.uv.sources] +cunumpy = { git = "https://github.com/max-models/cunumpy", branch = "devel" } +maybempi = { git = "https://github.com/max-models/maybempi", branch = "devel" } +mpiarray = { git = "https://github.com/max-models/mpiarray", branch = "devel" } diff --git a/uv.lock b/uv.lock index 0400c1f..a537985 100644 --- a/uv.lock +++ b/uv.lock @@ -517,7 +517,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130, upload-time = "2025-04-15T17:47:53.79Z" } wheels = [ @@ -589,7 +589,7 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } wheels = [ @@ -679,7 +679,7 @@ resolution-markers = [ "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/83/5a/a55177dd22553a277388e8a1b3220e92de91bacb28356cdc73caa240121d/contourpy-1.4.0.tar.gz", hash = "sha256:20156f5a1ac4f8ce02656e39a61e82164a3d359796dc8026f75b062783d500e1", size = 13323726, upload-time = "2026-09-11T19:05:05.808Z" } wheels = [ @@ -897,8 +897,8 @@ wheels = [ [[package]] name = "cunumpy" -version = "0.6.3" -source = { registry = "https://pypi.org/simple" } +version = "0.7.1" +source = { git = "https://github.com/max-models/cunumpy.git?branch=devel#9fb918b130b08a2c159005997186efdb5edacb4f" } dependencies = [ { name = "array-api-compat" }, { name = "maybempi" }, @@ -906,10 +906,6 @@ dependencies = [ { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/eb/38/28b22557b8344bc9b9af6b78465f14754752e3935a62b1bd6bc268a2ab85/cunumpy-0.6.3.tar.gz", hash = "sha256:386a710dcea5c3ebecc0b9c469d592ad03af51ef11eac554ccee10aedbe8f9f8", size = 185601, upload-time = "2026-10-13T14:43:35.22Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d3/3d/754142eb322bd962b19a665de1e9ce2d175a6b7adcce6061d5a9bd1c62f9/cunumpy-0.6.3-py3-none-any.whl", hash = "sha256:10ad7416c933e480f75d0b9c8cc70d3bcdafe9fdbcc66c57f14e06e12c850e9c", size = 198833, upload-time = "2026-10-13T14:43:33.739Z" }, -] [[package]] name = "cupy-cuda12x" @@ -959,12 +955,12 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "attrs" }, - { name = "docstring-parser" }, - { name = "rich" }, - { name = "rich-rst" }, - { name = "tomli" }, - { name = "typing-extensions" }, + { name = "attrs", marker = "python_full_version < '3.11'" }, + { name = "docstring-parser", marker = "python_full_version < '3.11'" }, + { name = "rich", marker = "python_full_version < '3.11'" }, + { name = "rich-rst", marker = "python_full_version < '3.11'" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c1/08/a444f3cca86cb27bc256831fc8c42b578390f4087bf6631cccb5334bd9ed/cyclopts-4.25.3.tar.gz", hash = "sha256:7d3a067bc7699a1a65fe2ea0b092a925855bb711a05adc1e65c5b9ba307acea4", size = 202814, upload-time = "2026-09-17T16:25:16.892Z" } wheels = [ @@ -987,10 +983,10 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "attrs" }, - { name = "docstring-parser" }, - { name = "rich" }, - { name = "rich-rst" }, + { name = "attrs", marker = "python_full_version >= '3.11'" }, + { name = "docstring-parser", marker = "python_full_version >= '3.11'" }, + { name = "rich", marker = "python_full_version >= '3.11'" }, + { name = "rich-rst", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/28/c1/3debeeb6e0eb74a51d6f8cf1f273ed7c479e3321631cbf89fb9700e65e28/cyclopts-5.2.0.tar.gz", hash = "sha256:b63c1b1beaadf3ead19214385a0f90b990f152c4107c174e1724c45dc71e9541", size = 246542, upload-time = "2026-10-06T15:02:39.212Z" } wheels = [ @@ -1071,7 +1067,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -1364,8 +1360,8 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, - { name = "pillow" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "pillow", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/81/c8/2b56274457e4fadcfa86bf7c0fb93f19e124ab8f38f4bfa4372423052046/imageio-2.38.0.tar.gz", hash = "sha256:fe1d406862f6bc2930e8ec662d7bc40d4370cb76e3390dcd33819560d2708531", size = 403333, upload-time = "2026-09-28T07:44:08.101Z" } wheels = [ @@ -1388,9 +1384,9 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "pillow" }, + { name = "pillow", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f3/cd/69e4ac55b6dafdd2b5f32075236841a3945dea7323d0232d80f28c37cea8/imageio-2.38.1.tar.gz", hash = "sha256:6769f1f01c4dd46448307863a787c9a22fa4dbe11c0c88f525c6e410fdfd7983", size = 403667, upload-time = "2026-10-08T14:35:32.961Z" } wheels = [ @@ -1423,19 +1419,19 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "appnope", marker = "sys_platform == 'darwin'" }, - { name = "comm" }, - { name = "debugpy" }, - { name = "ipython", version = "8.39.0", source = { registry = "https://pypi.org/simple" } }, - { name = "jupyter-client" }, - { name = "jupyter-core" }, - { name = "matplotlib-inline" }, - { name = "nest-asyncio2" }, - { name = "packaging" }, - { name = "psutil" }, - { name = "pyzmq" }, - { name = "tornado" }, - { name = "traitlets" }, + { name = "appnope", marker = "python_full_version < '3.11' and sys_platform == 'darwin'" }, + { name = "comm", marker = "python_full_version < '3.11'" }, + { name = "debugpy", marker = "python_full_version < '3.11'" }, + { name = "ipython", version = "8.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "jupyter-client", marker = "python_full_version < '3.11'" }, + { name = "jupyter-core", marker = "python_full_version < '3.11'" }, + { name = "matplotlib-inline", marker = "python_full_version < '3.11'" }, + { name = "nest-asyncio2", marker = "python_full_version < '3.11'" }, + { name = "packaging", marker = "python_full_version < '3.11'" }, + { name = "psutil", marker = "python_full_version < '3.11'" }, + { name = "pyzmq", marker = "python_full_version < '3.11'" }, + { name = "tornado", marker = "python_full_version < '3.11'" }, + { name = "traitlets", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3d/c4/e4a38f579de4225a561305666f7541cdabb30075def2aa1ac17bd73c1fb5/ipykernel-7.3.0.tar.gz", hash = "sha256:9acaaaf97d16355166e4085afe9d225bfbdf2b7ef520f9df3be8f2b248275e09", size = 184899, upload-time = "2026-06-10T08:41:25.481Z" } wheels = [ @@ -1458,18 +1454,18 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "appnope", marker = "sys_platform == 'darwin'" }, - { name = "comm" }, - { name = "debugpy" }, - { name = "ipython", version = "9.17.1", source = { registry = "https://pypi.org/simple" } }, - { name = "jupyter-client" }, - { name = "jupyter-core" }, - { name = "matplotlib-inline" }, - { name = "nest-asyncio2" }, - { name = "packaging" }, - { name = "pyzmq" }, - { name = "tornado" }, - { name = "traitlets" }, + { name = "appnope", marker = "python_full_version >= '3.11' and sys_platform == 'darwin'" }, + { name = "comm", marker = "python_full_version >= '3.11'" }, + { name = "debugpy", marker = "python_full_version >= '3.11'" }, + { name = "ipython", version = "9.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "jupyter-client", marker = "python_full_version >= '3.11'" }, + { name = "jupyter-core", marker = "python_full_version >= '3.11'" }, + { name = "matplotlib-inline", marker = "python_full_version >= '3.11'" }, + { name = "nest-asyncio2", marker = "python_full_version >= '3.11'" }, + { name = "packaging", marker = "python_full_version >= '3.11'" }, + { name = "pyzmq", marker = "python_full_version >= '3.11'" }, + { name = "tornado", marker = "python_full_version >= '3.11'" }, + { name = "traitlets", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/05/e5499c8b762387d83926bf0389e8fff888c9bf6e7af30566a8527bc12f4a/ipykernel-7.4.0.tar.gz", hash = "sha256:4330114d22b9b33575b2c7c68753fc8faabbbcfbc60b0cdff9f14dd4ce63742f", size = 192731, upload-time = "2026-09-29T16:33:11.468Z" } wheels = [ @@ -1484,17 +1480,17 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "decorator" }, - { name = "exceptiongroup" }, - { name = "jedi" }, - { name = "matplotlib-inline" }, - { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit" }, - { name = "pygments" }, - { name = "stack-data" }, - { name = "traitlets" }, - { name = "typing-extensions" }, + { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, + { name = "decorator", marker = "python_full_version < '3.11'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "jedi", marker = "python_full_version < '3.11'" }, + { name = "matplotlib-inline", marker = "python_full_version < '3.11'" }, + { name = "pexpect", marker = "python_full_version < '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "python_full_version < '3.11'" }, + { name = "pygments", marker = "python_full_version < '3.11'" }, + { name = "stack-data", marker = "python_full_version < '3.11'" }, + { name = "traitlets", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/40/18/f8598d287006885e7136451fdea0755af4ebcbfe342836f24deefaed1164/ipython-8.39.0.tar.gz", hash = "sha256:4110ae96012c379b8b6db898a07e186c40a2a1ef5d57a7fa83166047d9da7624", size = 5513971, upload-time = "2026-03-27T10:02:13.94Z" } wheels = [ @@ -1517,17 +1513,17 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "ipython-pygments-lexers" }, - { name = "jedi" }, - { name = "matplotlib-inline" }, - { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit" }, - { name = "psutil", marker = "sys_platform != 'cygwin' and sys_platform != 'emscripten'" }, - { name = "pygments" }, - { name = "stack-data" }, - { name = "traitlets" }, - { name = "typing-extensions", marker = "python_full_version < '3.12'" }, + { name = "colorama", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" }, + { name = "ipython-pygments-lexers", marker = "python_full_version >= '3.11'" }, + { name = "jedi", marker = "python_full_version >= '3.11'" }, + { name = "matplotlib-inline", marker = "python_full_version >= '3.11'" }, + { name = "pexpect", marker = "python_full_version >= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "python_full_version >= '3.11'" }, + { name = "psutil", marker = "python_full_version >= '3.11' and sys_platform != 'cygwin' and sys_platform != 'emscripten'" }, + { name = "pygments", marker = "python_full_version >= '3.11'" }, + { name = "stack-data", marker = "python_full_version >= '3.11'" }, + { name = "traitlets", marker = "python_full_version >= '3.11'" }, + { name = "typing-extensions", marker = "python_full_version == '3.11.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b9/32/99451b1283ec5d92ad77073f12e1c667dc10775384d8f15c2914207149dd/ipython-9.17.1.tar.gz", hash = "sha256:8919be8c27f20a6f4423145028063f6637b42a03ce57665bb12015ee1f073529", size = 4539289, upload-time = "2026-09-01T08:29:32.6Z" } wheels = [ @@ -1539,7 +1535,7 @@ name = "ipython-pygments-lexers" version = "1.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pygments" }, + { name = "pygments", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } wheels = [ @@ -2145,15 +2141,15 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "contourpy", version = "1.3.2", source = { registry = "https://pypi.org/simple" } }, - { name = "cycler" }, - { name = "fonttools", version = "4.65.0", source = { registry = "https://pypi.org/simple" } }, - { name = "kiwisolver" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, - { name = "packaging" }, - { name = "pillow" }, - { name = "pyparsing" }, - { name = "python-dateutil" }, + { name = "contourpy", version = "1.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "cycler", marker = "python_full_version < '3.11'" }, + { name = "fonttools", version = "4.65.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "kiwisolver", marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "packaging", marker = "python_full_version < '3.11'" }, + { name = "pillow", marker = "python_full_version < '3.11'" }, + { name = "pyparsing", marker = "python_full_version < '3.11'" }, + { name = "python-dateutil", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/63/1b/4be5be87d43d327a0cf4de1a56e86f7f84c89312452406cf122efe2839e6/matplotlib-3.10.9.tar.gz", hash = "sha256:fd66508e8c6877d98e586654b608a0456db8d7e8a546eb1e2600efd957302358", size = 34811233, upload-time = "2026-04-24T00:14:13.539Z" } wheels = [ @@ -2229,17 +2225,17 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "contourpy", version = "1.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "contourpy", version = "1.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "contourpy", version = "1.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "cycler" }, - { name = "fonttools", version = "4.66.1", source = { registry = "https://pypi.org/simple" } }, - { name = "kiwisolver" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "cycler", marker = "python_full_version >= '3.11'" }, + { name = "fonttools", version = "4.66.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "kiwisolver", marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "packaging" }, - { name = "pillow" }, - { name = "pyparsing" }, - { name = "python-dateutil" }, + { name = "packaging", marker = "python_full_version >= '3.11'" }, + { name = "pillow", marker = "python_full_version >= '3.11'" }, + { name = "pyparsing", marker = "python_full_version >= '3.11'" }, + { name = "python-dateutil", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e7/c8/9aa712a0afb882649424dd8de8ad9aa6235e796e84c6052e8f6dc1598d0d/matplotlib-3.11.2.tar.gz", hash = "sha256:cec596316640f2b394b8f0daa0ea61a8eae82d017b620b9f202befb972a59ea4", size = 32660610, upload-time = "2026-09-11T19:05:31.214Z" } wheels = [ @@ -2312,11 +2308,7 @@ wheels = [ [[package]] name = "maybempi" version = "0.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c4/0d/ed782142085f56da45eb4511dd3fb2a7b1ed167fe5495dd429c61272774e/maybempi-0.1.2.tar.gz", hash = "sha256:0b0fadd2897281df50a2cf3f08ddf080d1ca00c9d2e4e5aee2af7cf5222ba04d", size = 42851, upload-time = "2026-10-07T05:38:48.594Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/88/d3/b161669937ee87dba4c61a6ac2be70bd1399269c76c66524e3f8be071839/maybempi-0.1.2-py3-none-any.whl", hash = "sha256:1fc9a2eb7e4f55a6b91da32e8e77bade35df157e6ce5de3b228cdf799e99970b", size = 47478, upload-time = "2026-10-07T05:38:47.046Z" }, -] +source = { git = "https://github.com/max-models/maybempi?branch=devel#0146f000137c58b84381c176e03ffd2b3e191ce7" } [[package]] name = "mdurl" @@ -2353,7 +2345,7 @@ name = "mlx" version = "0.32.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "mlx-metal" }, + { name = "mlx-metal", marker = "sys_platform == 'darwin'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/52/73/f2b18f4bbcfeacd6880e68f2854609d6d2d305e5676586451a6c26664061/mlx-0.32.4-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:60ba41564e8b5018c9d8a5f8ce7a5aaf70c96d8dd98c1a54b854cbada362d471", size = 671799, upload-time = "2026-10-14T21:15:37.06Z" }, @@ -2445,18 +2437,15 @@ wheels = [ [[package]] name = "mpiarray" -version = "0.1.0" -source = { registry = "https://pypi.org/simple" } +version = "0.2.1" +source = { git = "https://github.com/max-models/mpiarray?branch=devel#7d3490a566ebbc2489c5172d5cd09282fae87efe" } dependencies = [ { name = "cunumpy" }, + { name = "maybempi" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a2/7b/69a0f757f91d022c36df8a170b90d501f3fe9bfe88f65b30d69a51c9f81a/mpiarray-0.1.0.tar.gz", hash = "sha256:894491cf096a303cf3314821a076c7da7fb0f7ba98aa332eb8e18a75507e80b1", size = 60918, upload-time = "2026-10-06T16:06:45.033Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/47/a3c6152a4ec4f27be7e5fd39d4ec3602968368e319cf9897fd1e4bbc49af/mpiarray-0.1.0-py3-none-any.whl", hash = "sha256:3ed5623d8b78f8aba29fa4f21e9c0847e656c554e372a2125ac1b5df35e2e70f", size = 67554, upload-time = "2026-10-06T16:06:43.72Z" }, -] [[package]] name = "mpmath" @@ -2877,10 +2866,10 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, - { name = "python-dateutil" }, - { name = "pytz" }, - { name = "tzdata" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "python-dateutil", marker = "python_full_version < '3.11'" }, + { name = "pytz", marker = "python_full_version < '3.11'" }, + { name = "tzdata", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } wheels = [ @@ -2949,10 +2938,10 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "python-dateutil" }, - { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, + { name = "python-dateutil", marker = "python_full_version >= '3.11'" }, + { name = "tzdata", marker = "(python_full_version >= '3.11' and sys_platform == 'emscripten') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e2/17/d7b106e05bfa642e8694451e7d3d759c6a241c5386a5d962e4f66c047e06/pandas-3.0.6.tar.gz", hash = "sha256:66b07ef7315a31bfe1089cd3d71a7de781c9dca986762d0b4fe7c0ef17465d10", size = 4667686, upload-time = "2026-09-17T23:23:18.345Z" } wheels = [ @@ -3038,7 +3027,7 @@ name = "pexpect" version = "4.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ptyprocess" }, + { name = "ptyprocess", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') 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 = [ @@ -3313,7 +3302,7 @@ name = "pyobjc-framework-cocoa" version = "12.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, + { name = "pyobjc-core", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/75/76/49c6da2c6a831020b4854ba20079d5a1030474bffc776b7b73c2eeff8c15/pyobjc_framework_cocoa-12.2.2.tar.gz", hash = "sha256:c96c0ef69a71afbbb0e6a7d594b455c5fe47d62e0db376ee7a2b4b828c16ace9", size = 3132831, upload-time = "2026-08-11T19:44:02.288Z" } wheels = [ @@ -4066,14 +4055,14 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "imageio", version = "2.38.0", source = { registry = "https://pypi.org/simple" } }, - { name = "lazy-loader" }, - { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" } }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, - { name = "packaging" }, - { name = "pillow" }, - { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" } }, - { name = "tifffile", version = "2025.5.10", source = { registry = "https://pypi.org/simple" } }, + { name = "imageio", version = "2.38.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "lazy-loader", marker = "python_full_version < '3.11'" }, + { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "packaging", marker = "python_full_version < '3.11'" }, + { name = "pillow", marker = "python_full_version < '3.11'" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "tifffile", version = "2025.5.10", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c7/a8/3c0f256012b93dd2cb6fda9245e9f4bff7dc0486880b248005f15ea2255e/scikit_image-0.25.2.tar.gz", hash = "sha256:e5a37e6cd4d0c018a7a55b9d601357e3382826d3888c10d0213fc63bff977dde", size = 22693594, upload-time = "2025-02-18T18:05:24.538Z" } wheels = [ @@ -4116,17 +4105,17 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "imageio", version = "2.38.1", source = { registry = "https://pypi.org/simple" } }, - { name = "lazy-loader" }, - { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "imageio", version = "2.38.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "lazy-loader", marker = "python_full_version >= '3.11'" }, + { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "networkx", version = "3.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "packaging" }, - { name = "pillow" }, - { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "packaging", marker = "python_full_version >= '3.11'" }, + { name = "pillow", marker = "python_full_version >= '3.11'" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "scipy", version = "1.18.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "tifffile", version = "2026.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "tifffile", version = "2026.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "tifffile", version = "2026.9.20", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a1/b4/2528bb43c67d48053a7a649a9666432dc307d66ba02e3a6d5c40f46655df/scikit_image-0.26.0.tar.gz", hash = "sha256:f5f970ab04efad85c24714321fcc91613fcb64ef2a892a13167df2f3e59199fa", size = 22729739, upload-time = "2025-12-20T17:12:21.824Z" } @@ -4189,7 +4178,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" } wheels = [ @@ -4250,7 +4239,7 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } wheels = [ @@ -4329,7 +4318,7 @@ resolution-markers = [ "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7e/74/66de6258867beb2ef08f35f9f2ac017a52cacd5081714d239ff1a442d458/scipy-1.18.1.tar.gz", hash = "sha256:52c4b7422442aba924d03ad4019852b08a92e64ea187b933135687bfe2747307", size = 30781235, upload-time = "2026-08-21T23:28:50.599Z" } wheels = [ @@ -4421,7 +4410,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/4d/bc/0989043118a27cccb4e906a46b7565ce36ca7b57f5a18b78f4f1b0f72d9d/shapely-2.1.2.tar.gz", hash = "sha256:2ed4ecb28320a433db18a5bf029986aa8afcfd740745e78847e330d5d94922a9", size = 315489, upload-time = "2025-09-24T13:51:41.432Z" } wheels = [ @@ -4499,7 +4488,7 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f3/ab/924b6e202f796d270a3041a230151f7908db5ea48c74effe6f8023e9bd05/shapely-2.2.0.tar.gz", hash = "sha256:e8865e553d874a1ec4a032057ea81fca9def37b188cd8fb550af3b3480b3f88c", size = 380326, upload-time = "2026-10-07T09:18:01.001Z" } @@ -4571,7 +4560,7 @@ wheels = [ [[package]] name = "simgeometry" -version = "0.1.0" +version = "0.1.1" source = { editable = "." } dependencies = [ { name = "cunumpy" }, @@ -4672,18 +4661,18 @@ test = [ [package.metadata] requires-dist = [ - { name = "cunumpy", specifier = ">=0.6.2" }, + { name = "cunumpy", git = "https://github.com/max-models/cunumpy?branch=devel" }, { name = "cupy-cuda12x", marker = "extra == 'gpu'" }, { name = "griffe", marker = "extra == 'docs'" }, { name = "ipykernel", marker = "extra == 'docs'" }, { name = "jupyterlab", marker = "extra == 'dev'" }, { name = "matplotlib", marker = "extra == 'docs'", specifier = ">=3.10.9" }, { name = "matplotlib", marker = "extra == 'plot'", specifier = ">=3.10" }, - { name = "maybempi", specifier = ">=0.1" }, + { name = "maybempi", git = "https://github.com/max-models/maybempi?branch=devel" }, { name = "mlx", marker = "platform_machine == 'arm64' and sys_platform == 'darwin' and extra == 'metal'" }, { name = "mpi4py", marker = "extra == 'mpi'" }, { name = "mpi4py", marker = "extra == 'test'" }, - { name = "mpiarray", specifier = ">=0.1.0" }, + { name = "mpiarray", git = "https://github.com/max-models/mpiarray?branch=devel" }, { name = "nbclient", marker = "extra == 'docs'", specifier = ">=0.11" }, { name = "nbformat", marker = "extra == 'docs'", specifier = ">=5.11.1" }, { name = "numpy" }, @@ -4793,7 +4782,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/44/d0/18fed0fc0916578a4463f775b0fbd9c5fed2392152d039df2fb533bfdd5d/tifffile-2025.5.10.tar.gz", hash = "sha256:018335d34283aa3fd8c263bae5c3c2b661ebc45548fde31504016fcae7bf1103", size = 365290, upload-time = "2025-05-10T19:22:34.386Z" } wheels = [ @@ -4810,7 +4799,7 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c5/cb/2f6d79c7576e22c116352a801f4c3c8ace5957e9aced862012430b62e14f/tifffile-2026.3.3.tar.gz", hash = "sha256:d9a1266bed6f2ee1dd0abde2018a38b4f8b2935cb843df381d70ac4eac5458b7", size = 388745, upload-time = "2026-03-03T19:14:38.134Z" } wheels = [ @@ -4830,7 +4819,7 @@ resolution-markers = [ "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/92/66/634db78ebad513038d753830dd8815eea26278b5463ba0f43198b0c24c4e/tifffile-2026.9.20.tar.gz", hash = "sha256:30e145a7042ce7143ae50a50fe8b7221b0070aae22adab8f9e79a264be6b5cdc", size = 449417, upload-time = "2026-09-21T03:59:40.055Z" } wheels = [ @@ -5091,9 +5080,9 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, - { name = "packaging" }, - { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "packaging", marker = "python_full_version < '3.11'" }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/19/ec/e50d833518f10b0c24feb184b209bb6856f25b919ba8c1f89678b930b1cd/xarray-2025.6.1.tar.gz", hash = "sha256:a84f3f07544634a130d7dc615ae44175419f4c77957a7255161ed99c69c7c8b0", size = 3003185, upload-time = "2025-06-12T03:04:09.099Z" } wheels = [ @@ -5116,10 +5105,10 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "packaging" }, - { name = "pandas", version = "3.0.6", source = { registry = "https://pypi.org/simple" } }, + { name = "packaging", marker = "python_full_version >= '3.11'" }, + { name = "pandas", version = "3.0.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ff/f5/781c70b234f54e0401f4f7c4427a7486da38d50dbb138c7d7c55144b3d86/xarray-2026.9.0.tar.gz", hash = "sha256:6abc69694c22fa1f0fb2f357ff4e41d88beb4477ed71091f944b7dbf67ed54fe", size = 3176922, upload-time = "2026-09-29T23:06:25.807Z" } wheels = [ From 0ab63bcd9423ee0b79ed02aabbd763070bc26604 Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 22 Oct 2026 08:06:00 +0200 Subject: [PATCH 03/36] Opt-in scope-profiler regions, off by default (#17) * Add opt-in scope-profiler regions, off by default * Lock scope-profiler 0.7.5 from devel --- CHANGELOG.md | 4 ++ README.md | 30 +++++++-- README.qmd | 19 +++++- pyproject.toml | 2 + src/simgeometry/__init__.py | 6 ++ src/simgeometry/_profiling.py | 8 +++ src/simgeometry/crossings.py | 3 + src/simgeometry/cut_edges.py | 4 ++ src/simgeometry/evaluation.py | 3 + src/simgeometry/fractions.py | 3 + src/simgeometry/mesh.py | 3 + src/simgeometry/operations.py | 3 + src/simgeometry/queries.py | 4 ++ src/simgeometry/sampling.py | 4 ++ src/simgeometry/shapes.py | 3 + src/simgeometry/simulation_domain.py | 3 + src/simgeometry/tests/test_profiling.py | 32 ++++++++++ uv.lock | 84 +++++++++++++++++++++++++ 18 files changed, 211 insertions(+), 7 deletions(-) create mode 100644 src/simgeometry/_profiling.py create mode 100644 src/simgeometry/tests/test_profiling.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7537ba3..a90fcb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,10 @@ format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - `Aperture2D.slit(...)`: two rectangular plates. `exact_outline` and `closed` flags on 2D shapes. - The tutorial covers holes, polylines, panels and crossings. +- scope-profiler regions for the expensive operations (`simgeometry.node_mask`, + `simgeometry.crossings`, `simgeometry.to_mesh`, ...), off unless the application enables + the `simgeometry` tag (`sp.setup(enable_tags=["simgeometry"])`). scope-profiler is now a + runtime dependency. ### Changed diff --git a/README.md b/README.md index 728695f..569ad98 100644 --- a/README.md +++ b/README.md @@ -14,12 +14,12 @@ pip install simgeometry ``` For development, install this checkout with `pip install -e '.[test]'`. -NumPy, cunumpy, maybempi, and mpiarray are runtime dependencies. -Matplotlib is optional. Install `simgeometry[plot]` for plotting, -labelled evaluation and surface meshing, or `simgeometry[interactive]` -for interactive 3D clipping with PyVista. Install `simgeometry[mpi]` for -MPI, `simgeometry[pyccel]` for compiled CPU containment kernels, or -`simgeometry[gpu]` for CUDA. +NumPy, cunumpy, maybempi, mpiarray and scope-profiler are runtime +dependencies. Matplotlib is optional. Install `simgeometry[plot]` for +plotting, labelled evaluation and surface meshing, or +`simgeometry[interactive]` for interactive 3D clipping with PyVista. +Install `simgeometry[mpi]` for MPI, `simgeometry[pyccel]` for compiled +CPU containment kernels, or `simgeometry[gpu]` for CUDA. ## Example @@ -56,6 +56,24 @@ tutorials](https://max-models.github.io/simgeometry/tutorials/) and [API guide](https://max-models.github.io/simgeometry/getting-started/geometry-api/) for examples and the accuracy limits of sampled operations. +## Profiling + +simgeometry ships +[scope-profiler](https://github.com/max-models/scope-profiler) regions +for its expensive operations (`simgeometry.node_mask`, +`simgeometry.crossings`, `simgeometry.to_mesh`, …). They are off by +default and cost almost nothing. Turn them on in an application that +profiles itself with the `simgeometry` tag: + +``` python +import scope_profiler as sp + +sp.setup(enable_tags=["simgeometry"]) +``` + +or `SCOPE_PROFILER_ENABLE_TAGS=simgeometry`, or +`scope-profiler run --enable-tag simgeometry`. + ## Development ``` bash diff --git a/README.qmd b/README.qmd index 5117b4e..f702734 100644 --- a/README.qmd +++ b/README.qmd @@ -15,7 +15,7 @@ pip install simgeometry ``` For development, install this checkout with `pip install -e '.[test]'`. -NumPy, cunumpy, maybempi, and mpiarray are runtime dependencies. Matplotlib is +NumPy, cunumpy, maybempi, mpiarray and scope-profiler are runtime dependencies. Matplotlib is optional. Install `simgeometry[plot]` for plotting, labelled evaluation and surface meshing, or `simgeometry[interactive]` for interactive 3D clipping with PyVista. Install `simgeometry[mpi]` for MPI, `simgeometry[pyccel]` for compiled CPU @@ -54,6 +54,23 @@ fractions. See the [executed tutorials](https://max-models.github.io/simgeometry and [API guide](https://max-models.github.io/simgeometry/getting-started/geometry-api/) for examples and the accuracy limits of sampled operations. +## Profiling + +simgeometry ships [scope-profiler](https://github.com/max-models/scope-profiler) +regions for its expensive operations (`simgeometry.node_mask`, +`simgeometry.crossings`, `simgeometry.to_mesh`, ...). They are off by default and +cost almost nothing. Turn them on in an application that profiles itself with +the `simgeometry` tag: + +```python +import scope_profiler as sp + +sp.setup(enable_tags=["simgeometry"]) +``` + +or `SCOPE_PROFILER_ENABLE_TAGS=simgeometry`, or +`scope-profiler run --enable-tag simgeometry`. + ## Development ```bash diff --git a/pyproject.toml b/pyproject.toml index 48490ea..0c0c8ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ dependencies = [ "maybempi>=0.1", "mpiarray>=0.1.0", "numpy", + "scope-profiler>=0.7.5", ] optional-dependencies.dev = [ "jupyterlab", @@ -95,3 +96,4 @@ report.show_missing = true cunumpy = { git = "https://github.com/max-models/cunumpy", branch = "devel" } maybempi = { git = "https://github.com/max-models/maybempi", branch = "devel" } mpiarray = { git = "https://github.com/max-models/mpiarray", branch = "devel" } +scope-profiler = { git = "https://github.com/max-models/scope-profiler", branch = "devel" } diff --git a/src/simgeometry/__init__.py b/src/simgeometry/__init__.py index 7aaa9bb..b3af5a0 100644 --- a/src/simgeometry/__init__.py +++ b/src/simgeometry/__init__.py @@ -2,6 +2,9 @@ from importlib.metadata import PackageNotFoundError, version +import scope_profiler as sp + +from simgeometry._profiling import _PROFILE_TAGS from simgeometry.collection import GeometryCollection from simgeometry.grid import Grid from simgeometry.objects import ( @@ -20,6 +23,9 @@ ) from simgeometry.simulation_domain import SimulationDomain +# simgeometry's regions stay off unless the application enables the "simgeometry" tag. +sp.register_opt_in_tag(*_PROFILE_TAGS) + try: __version__ = version("simgeometry") except PackageNotFoundError: # not installed, e.g. run from a source tree diff --git a/src/simgeometry/_profiling.py b/src/simgeometry/_profiling.py new file mode 100644 index 0000000..9a6af37 --- /dev/null +++ b/src/simgeometry/_profiling.py @@ -0,0 +1,8 @@ +"""The scope-profiler tag of simgeometry's own profiling regions. + +``simgeometry/__init__.py`` registers the tag as opt-in, so these regions are off +unless an application enables ``"simgeometry"`` (see the README). +""" + +#: Tags of every ``simgeometry.`` region. +_PROFILE_TAGS = ("simgeometry",) diff --git a/src/simgeometry/crossings.py b/src/simgeometry/crossings.py index 2e70e55..ec126ca 100644 --- a/src/simgeometry/crossings.py +++ b/src/simgeometry/crossings.py @@ -27,7 +27,9 @@ import cunumpy as xp import numpy as np +import scope_profiler as sp +from simgeometry._profiling import _PROFILE_TAGS from simgeometry.shapes import _component_major #: Crossings closer than this fraction to the start of a step are ignored. @@ -55,6 +57,7 @@ def __len__(self) -> int: return int(self.hit.shape[0]) +@sp.profile("simgeometry.crossings", tags=_PROFILE_TAGS) def crossings(shape, start, end, *, samples: int = 64, iterations: int = 40): """Return the first boundary crossing of each segment ``start[:, i] -> end[:, i]``. diff --git a/src/simgeometry/cut_edges.py b/src/simgeometry/cut_edges.py index 1d77d24..9c72eb0 100644 --- a/src/simgeometry/cut_edges.py +++ b/src/simgeometry/cut_edges.py @@ -5,7 +5,9 @@ from dataclasses import dataclass import numpy as np +import scope_profiler as sp +from simgeometry._profiling import _PROFILE_TAGS from simgeometry.operations import mask from simgeometry.queries import intersection_fractions @@ -27,6 +29,7 @@ class BracketedEdgeGeometry: nodes: np.ndarray +@sp.profile("simgeometry.bracketed_edge_geometry", tags=_PROFILE_TAGS) def bracketed_edge_geometry( grid, objects, *, spacing=None, object_masks=None, periodic=None ): @@ -195,6 +198,7 @@ def show( return ax +@sp.profile("simgeometry.cut_edges", tags=_PROFILE_TAGS) def cut_edges(geometry, grid, *, samples=64, tolerance=1e-10): """Find all surface crossings, including objects between two fluid nodes. diff --git a/src/simgeometry/evaluation.py b/src/simgeometry/evaluation.py index ad9795f..ec2e002 100644 --- a/src/simgeometry/evaluation.py +++ b/src/simgeometry/evaluation.py @@ -3,7 +3,9 @@ from __future__ import annotations import numpy as np +import scope_profiler as sp +from simgeometry._profiling import _PROFILE_TAGS from simgeometry.operations import mask @@ -26,6 +28,7 @@ def sampling_axes(shape, resolution=128, bounds=None): return [np.linspace(a, b, int(n)) for a, b, n in zip(lower, upper, counts)] +@sp.profile("simgeometry.evaluate", tags=_PROFILE_TAGS) def evaluate( shape, *, diff --git a/src/simgeometry/fractions.py b/src/simgeometry/fractions.py index 406c237..b5207b0 100644 --- a/src/simgeometry/fractions.py +++ b/src/simgeometry/fractions.py @@ -4,7 +4,9 @@ from itertools import product import numpy as np +import scope_profiler as sp +from simgeometry._profiling import _PROFILE_TAGS from simgeometry.operations import mask @@ -23,6 +25,7 @@ class CellFractions: samples_per_axis: int +@sp.profile("simgeometry.cell_fractions", tags=_PROFILE_TAGS) def cell_fractions(geometry, grid, *, samples_per_axis=4): """Estimate cell and face occupancy using a regular subcell sample lattice.""" if geometry.ndim != grid.ndim: diff --git a/src/simgeometry/mesh.py b/src/simgeometry/mesh.py index dfd3e3b..9e77939 100644 --- a/src/simgeometry/mesh.py +++ b/src/simgeometry/mesh.py @@ -6,7 +6,9 @@ from pathlib import Path import numpy as np +import scope_profiler as sp +from simgeometry._profiling import _PROFILE_TAGS from simgeometry.evaluation import sampling_axes from simgeometry.operations import mask @@ -83,6 +85,7 @@ def save(self, path): return path +@sp.profile("simgeometry.to_mesh", tags=_PROFILE_TAGS) def to_mesh(shape, *, resolution=64, bounds=None): if shape.ndim not in (2, 3): raise ValueError("Surface meshing requires 2D or 3D geometry") diff --git a/src/simgeometry/operations.py b/src/simgeometry/operations.py index 8442ed0..441bff9 100644 --- a/src/simgeometry/operations.py +++ b/src/simgeometry/operations.py @@ -7,7 +7,9 @@ import cunumpy as xp import numpy as np +import scope_profiler as sp +from simgeometry._profiling import _PROFILE_TAGS from simgeometry.shapes import Shape, _component_major, _refine_loop, _signed_area @@ -56,6 +58,7 @@ def contains(self, coords): ] ) + @sp.profile("simgeometry.boolean_outlines", tags=_PROFILE_TAGS) def outlines(self, max_spacing=None): """The boundary loops of a 2D Boolean shape, as ``(2, n)`` host arrays. diff --git a/src/simgeometry/queries.py b/src/simgeometry/queries.py index 39f5907..b45ec10 100644 --- a/src/simgeometry/queries.py +++ b/src/simgeometry/queries.py @@ -10,7 +10,9 @@ import cunumpy as xp import numpy as np +import scope_profiler as sp +from simgeometry._profiling import _PROFILE_TAGS from simgeometry.objects import ( Circle, Cuboid, @@ -296,6 +298,7 @@ def boundary_points_1d(shape, *, resolution=64, bounds=None): return hits.points +@sp.profile("simgeometry.closest_point", tags=_PROFILE_TAGS) def closest_point(shape, points, *, resolution=64, bounds=None): p, single = points_array(points, shape.ndim) if isinstance(shape, BALLS): @@ -343,6 +346,7 @@ def closest_point(shape, points, *, resolution=64, bounds=None): return q[:, 0] if single else q +@sp.profile("simgeometry.signed_distance", tags=_PROFILE_TAGS) def signed_distance(shape, points, *, resolution=64, bounds=None): p, single = points_array(points, shape.ndim) q = closest_point(shape, p, resolution=resolution, bounds=bounds) diff --git a/src/simgeometry/sampling.py b/src/simgeometry/sampling.py index 394a96e..0eefd6a 100644 --- a/src/simgeometry/sampling.py +++ b/src/simgeometry/sampling.py @@ -1,7 +1,9 @@ """Reproducible interior and boundary sampling.""" import numpy as np +import scope_profiler as sp +from simgeometry._profiling import _PROFILE_TAGS from simgeometry.operations import mask @@ -11,6 +13,7 @@ def _count(count): return int(count) +@sp.profile("simgeometry.sample_interior", tags=_PROFILE_TAGS) def sample_interior(shape, count, *, rng=None, bounds=None, max_draws=1_000_000): count = _count(count) if shape.inverted and bounds is None: @@ -44,6 +47,7 @@ def sample_interior(shape, count, *, rng=None, bounds=None, max_draws=1_000_000) return np.concatenate(accepted, axis=1) if count else np.empty((shape.ndim, 0)) +@sp.profile("simgeometry.sample_surface", tags=_PROFILE_TAGS) def sample_surface(shape, count, *, rng=None, resolution=64, bounds=None): count = _count(count) if count == 0: diff --git a/src/simgeometry/shapes.py b/src/simgeometry/shapes.py index ed48df0..02dbd61 100644 --- a/src/simgeometry/shapes.py +++ b/src/simgeometry/shapes.py @@ -8,7 +8,9 @@ import cunumpy as xp import numpy as np +import scope_profiler as sp +from simgeometry._profiling import _PROFILE_TAGS from simgeometry._types import Array from simgeometry.api import GeometryAPI from simgeometry.boundingbox import BoundingBox @@ -157,6 +159,7 @@ def contains_points(self, points: Array) -> Array: points = _component_major(points, self.ndim) return xp.asarray([bool(self.contains(p)) for p in points.T], dtype=bool) + @sp.profile("simgeometry.node_mask", tags=_PROFILE_TAGS) def node_mask(self, grid: Grid) -> np.ndarray: """Return a host boolean array of the grid's shape, set at the nodes inside. diff --git a/src/simgeometry/simulation_domain.py b/src/simgeometry/simulation_domain.py index f137e87..c3fe7bb 100644 --- a/src/simgeometry/simulation_domain.py +++ b/src/simgeometry/simulation_domain.py @@ -5,9 +5,11 @@ from typing import Any import cunumpy as xp +import scope_profiler as sp from maybempi import MPI from simgeometry._particle_boundaries import apply_particle_boundaries +from simgeometry._profiling import _PROFILE_TAGS from simgeometry._types import Array, ConfigValue from simgeometry.boundingbox import BoundingBox from simgeometry.grid import Grid @@ -235,6 +237,7 @@ def _wrap_periodic(values: Array, lower: float, upper: float) -> Array: wrapped = lower + (values - lower) % (upper - lower) return xp.where(wrapped >= upper, lower, wrapped) + @sp.profile("simgeometry.apply_bc_arrays", tags=_PROFILE_TAGS) def apply_bc_arrays( self, positions: Array, diff --git a/src/simgeometry/tests/test_profiling.py b/src/simgeometry/tests/test_profiling.py new file mode 100644 index 0000000..1b999ca --- /dev/null +++ b/src/simgeometry/tests/test_profiling.py @@ -0,0 +1,32 @@ +"""simgeometry's scope-profiler regions are off unless the "simgeometry" tag is enabled.""" + +from __future__ import annotations + +import pytest +import scope_profiler as sp + +from simgeometry import Circle, Grid + + +@pytest.fixture(autouse=True) +def _fresh_profiler(monkeypatch): + monkeypatch.delenv("SCOPE_PROFILER_ENABLE_TAGS", raising=False) + sp.reset() + yield + sp.reset() + + +def _node_mask_regions(**setup) -> list[str]: + sp.setup(output=None, **setup) + grid = Grid(ndim=2, lower_bound=(0, 0), upper_bound=(1, 1), num_gridpoints=(16, 16)) + Circle(midpoint=(0.5, 0.5), radius=0.25).node_mask(grid) + results = sp.finalize(verbose=False, return_results=True) + return [name for name in results.region_names if name.startswith("simgeometry.")] + + +def test_regions_are_off_by_default() -> None: + assert _node_mask_regions() == [] + + +def test_enabling_the_tag_records_the_regions() -> None: + assert _node_mask_regions(enable_tags=["simgeometry"]) == ["simgeometry.node_mask"] diff --git a/uv.lock b/uv.lock index a537985..a1dffe0 100644 --- a/uv.lock +++ b/uv.lock @@ -1306,6 +1306,66 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "h5py" +version = "3.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/33/acd0ce6863b6c0d7735007df01815403f5589a21ff8c2e1ee2587a38f548/h5py-3.16.0.tar.gz", hash = "sha256:a0dbaad796840ccaa67a4c144a0d0c8080073c34c76d5a6941d6818678ef2738", size = 446526, upload-time = "2026-03-06T13:49:08.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/6b/231413e58a787a89b316bb0d1777da3c62257e4797e09afd8d17ad3549dc/h5py-3.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e06f864bedb2c8e7c1358e6c73af48519e317457c444d6f3d332bb4e8fa6d7d9", size = 3724137, upload-time = "2026-03-06T13:47:35.242Z" }, + { url = "https://files.pythonhosted.org/packages/74/f9/557ce3aad0fe8471fb5279bab0fc56ea473858a022c4ce8a0b8f303d64e9/h5py-3.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ec86d4fffd87a0f4cb3d5796ceb5a50123a2a6d99b43e616e5504e66a953eca3", size = 3090112, upload-time = "2026-03-06T13:47:37.634Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f5/e15b3d0dc8a18e56409a839e6468d6fb589bc5207c917399c2e0706eeb44/h5py-3.16.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:86385ea895508220b8a7e45efa428aeafaa586bd737c7af9ee04661d8d84a10d", size = 4844847, upload-time = "2026-03-06T13:47:39.811Z" }, + { url = "https://files.pythonhosted.org/packages/cb/92/a8851d936547efe30cc0ce5245feac01f3ec6171f7899bc3f775c72030b3/h5py-3.16.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:8975273c2c5921c25700193b408e28d6bdd0111c37468b2d4e25dcec4cd1d84d", size = 5065352, upload-time = "2026-03-06T13:47:41.489Z" }, + { url = "https://files.pythonhosted.org/packages/2b/ae/f2adc5d0ca9626db3277a3d87516e124cbc5d0eea0bd79bc085702d04f2c/h5py-3.16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1677ad48b703f44efc9ea0c3ab284527f81bc4f318386aaaebc5fede6bbae56f", size = 4839173, upload-time = "2026-03-06T13:47:43.586Z" }, + { url = "https://files.pythonhosted.org/packages/64/0b/e0c8c69da1d8838da023a50cd3080eae5d475691f7636b35eff20bb6ef20/h5py-3.16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c4dd4cf5f0a4e36083f73172f6cfc25a5710789269547f132a20975bfe2434c", size = 5076216, upload-time = "2026-03-06T13:47:45.315Z" }, + { url = "https://files.pythonhosted.org/packages/66/35/d88fd6718832133c885004c61ceeeb24dbd6397ef877dbed6b3a64d6a286/h5py-3.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:bdef06507725b455fccba9c16529121a5e1fbf56aa375f7d9713d9e8ff42454d", size = 3183639, upload-time = "2026-03-06T13:47:47.041Z" }, + { url = "https://files.pythonhosted.org/packages/ba/95/a825894f3e45cbac7554c4e97314ce886b233a20033787eda755ca8fecc7/h5py-3.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:719439d14b83f74eeb080e9650a6c7aa6d0d9ea0ca7f804347b05fac6fbf18af", size = 3721663, upload-time = "2026-03-06T13:47:49.599Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3b/38ff88b347c3e346cda1d3fc1b65a7aa75d40632228d8b8a5d7b58508c24/h5py-3.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c3f0a0e136f2e95dd0b67146abb6668af4f1a69c81ef8651a2d316e8e01de447", size = 3087630, upload-time = "2026-03-06T13:47:51.249Z" }, + { url = "https://files.pythonhosted.org/packages/98/a8/2594cef906aee761601eff842c7dc598bea2b394a3e1c00966832b8eeb7c/h5py-3.16.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:a6fbc5367d4046801f9b7db9191b31895f22f1c6df1f9987d667854cac493538", size = 4823472, upload-time = "2026-03-06T13:47:53.085Z" }, + { url = "https://files.pythonhosted.org/packages/52/a0/c1f604538ff6db22a0690be2dc44ab59178e115f63c917794e529356ab23/h5py-3.16.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:fb1720028d99040792bb2fb31facb8da44a6f29df7697e0b84f0d79aff2e9bd3", size = 5027150, upload-time = "2026-03-06T13:47:55.043Z" }, + { url = "https://files.pythonhosted.org/packages/2e/fd/301739083c2fc4fd89950f9bcfce75d6e14b40b0ca3d40e48a8993d1722c/h5py-3.16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:314b6054fe0b1051c2b0cb2df5cbdab15622fb05e80f202e3b6a5eee0d6fe365", size = 4814544, upload-time = "2026-03-06T13:47:56.893Z" }, + { url = "https://files.pythonhosted.org/packages/4c/42/2193ed41ccee78baba8fcc0cff2c925b8b9ee3793305b23e1f22c20bf4c7/h5py-3.16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ffbab2fedd6581f6aa31cf1639ca2cb86e02779de525667892ebf4cc9fd26434", size = 5034013, upload-time = "2026-03-06T13:47:59.01Z" }, + { url = "https://files.pythonhosted.org/packages/f7/20/e6c0ff62ca2ad1a396a34f4380bafccaaf8791ff8fccf3d995a1fc12d417/h5py-3.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:17d1f1630f92ad74494a9a7392ab25982ce2b469fc62da6074c0ce48366a2999", size = 3191673, upload-time = "2026-03-06T13:48:00.626Z" }, + { url = "https://files.pythonhosted.org/packages/f2/48/239cbe352ac4f2b8243a8e620fa1a2034635f633731493a7ff1ed71e8658/h5py-3.16.0-cp311-cp311-win_arm64.whl", hash = "sha256:85b9c49dd58dc44cf70af944784e2c2038b6f799665d0dcbbc812a26e0faa859", size = 2673834, upload-time = "2026-03-06T13:48:02.579Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c0/5d4119dba94093bbafede500d3defd2f5eab7897732998c04b54021e530b/h5py-3.16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c5313566f4643121a78503a473f0fb1e6dcc541d5115c44f05e037609c565c4d", size = 3685604, upload-time = "2026-03-06T13:48:04.198Z" }, + { url = "https://files.pythonhosted.org/packages/b0/42/c84efcc1d4caebafb1ecd8be4643f39c85c47a80fe254d92b8b43b1eadaf/h5py-3.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:42b012933a83e1a558c673176676a10ce2fd3759976a0fedee1e672d1e04fc9d", size = 3061940, upload-time = "2026-03-06T13:48:05.783Z" }, + { url = "https://files.pythonhosted.org/packages/89/84/06281c82d4d1686fde1ac6b0f307c50918f1c0151062445ab3b6fa5a921d/h5py-3.16.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:ff24039e2573297787c3063df64b60aab0591980ac898329a08b0320e0cf2527", size = 5198852, upload-time = "2026-03-06T13:48:07.482Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e9/1a19e42cd43cc1365e127db6aae85e1c671da1d9a5d746f4d34a50edb577/h5py-3.16.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:dfc21898ff025f1e8e67e194965a95a8d4754f452f83454538f98f8a3fcb207e", size = 5405250, upload-time = "2026-03-06T13:48:09.628Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8e/9790c1655eabeb85b92b1ecab7d7e62a2069e53baefd58c98f0909c7a948/h5py-3.16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:698dd69291272642ffda44a0ecd6cd3bda5faf9621452d255f57ce91487b9794", size = 5190108, upload-time = "2026-03-06T13:48:11.26Z" }, + { url = "https://files.pythonhosted.org/packages/51/d7/ab693274f1bd7e8c5f9fdd6c7003a88d59bedeaf8752716a55f532924fbb/h5py-3.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2b2c02b0a160faed5fb33f1ba8a264a37ee240b22e049ecc827345d0d9043074", size = 5419216, upload-time = "2026-03-06T13:48:13.322Z" }, + { url = "https://files.pythonhosted.org/packages/03/c1/0976b235cf29ead553e22f2fb6385a8252b533715e00d0ae52ed7b900582/h5py-3.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:96b422019a1c8975c2d5dadcf61d4ba6f01c31f92bbde6e4649607885fe502d6", size = 3182868, upload-time = "2026-03-06T13:48:15.759Z" }, + { url = "https://files.pythonhosted.org/packages/14/d9/866b7e570b39070f92d47b0ff1800f0f8239b6f9e45f02363d7112336c1f/h5py-3.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:39c2838fb1e8d97bcf1755e60ad1f3dd76a7b2a475928dc321672752678b96db", size = 2653286, upload-time = "2026-03-06T13:48:17.279Z" }, + { url = "https://files.pythonhosted.org/packages/0f/9e/6142ebfda0cb6e9349c091eae73c2e01a770b7659255248d637bec54a88b/h5py-3.16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:370a845f432c2c9619db8eed334d1e610c6015796122b0e57aa46312c22617d9", size = 3671808, upload-time = "2026-03-06T13:48:19.737Z" }, + { url = "https://files.pythonhosted.org/packages/b0/65/5e088a45d0f43cd814bc5bec521c051d42005a472e804b1a36c48dada09b/h5py-3.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42108e93326c50c2810025aade9eac9d6827524cdccc7d4b75a546e5ab308edb", size = 3045837, upload-time = "2026-03-06T13:48:21.854Z" }, + { url = "https://files.pythonhosted.org/packages/da/1e/6172269e18cc5a484e2913ced33339aad588e02ba407fafd00d369e22ef3/h5py-3.16.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:099f2525c9dcf28de366970a5fb34879aab20491589fa89ce2863a84218bb524", size = 5193860, upload-time = "2026-03-06T13:48:24.071Z" }, + { url = "https://files.pythonhosted.org/packages/bd/98/ef2b6fe2903e377cbe870c3b2800d62552f1e3dbe81ce49e1923c53d1c5c/h5py-3.16.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:9300ad32dea9dfc5171f94d5f6948e159ed93e4701280b0f508773b3f582f402", size = 5400417, upload-time = "2026-03-06T13:48:25.728Z" }, + { url = "https://files.pythonhosted.org/packages/bc/81/5b62d760039eed64348c98129d17061fdfc7839fc9c04eaaad6dee1004e4/h5py-3.16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:171038f23bccddfc23f344cadabdfc9917ff554db6a0d417180d2747fe4c75a7", size = 5185214, upload-time = "2026-03-06T13:48:27.436Z" }, + { url = "https://files.pythonhosted.org/packages/28/c4/532123bcd9080e250696779c927f2cb906c8bf3447df98f5ceb8dcded539/h5py-3.16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7e420b539fb6023a259a1b14d4c9f6df8cf50d7268f48e161169987a57b737ff", size = 5414598, upload-time = "2026-03-06T13:48:29.49Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d9/a27997f84341fc0dfcdd1fe4179b6ba6c32a7aa880fdb8c514d4dad6fba3/h5py-3.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:18f2bbcd545e6991412253b98727374c356d67caa920e68dc79eab36bf5fedad", size = 3175509, upload-time = "2026-03-06T13:48:31.131Z" }, + { url = "https://files.pythonhosted.org/packages/a5/23/bb8647521d4fd770c30a76cfc6cb6a2f5495868904054e92f2394c5a78ff/h5py-3.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:656f00e4d903199a1d58df06b711cf3ca632b874b4207b7dbec86185b5c8c7d4", size = 2647362, upload-time = "2026-03-06T13:48:33.411Z" }, + { url = "https://files.pythonhosted.org/packages/48/3c/7fcd9b4c9eed82e91fb15568992561019ae7a829d1f696b2c844355d95dd/h5py-3.16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9c9d307c0ef862d1cd5714f72ecfafe0a5d7529c44845afa8de9f46e5ba8bd65", size = 3678608, upload-time = "2026-03-06T13:48:35.183Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b7/9366ed44ced9b7ef357ab48c94205280276db9d7f064aa3012a97227e966/h5py-3.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8c1eff849cdd53cbc73c214c30ebdb6f1bb8b64790b4b4fc36acdb5e43570210", size = 3054773, upload-time = "2026-03-06T13:48:37.139Z" }, + { url = "https://files.pythonhosted.org/packages/58/a5/4964bc0e91e86340c2bbda83420225b2f770dcf1eb8a39464871ad769436/h5py-3.16.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:e2c04d129f180019e216ee5f9c40b78a418634091c8782e1f723a6ca3658b965", size = 5198886, upload-time = "2026-03-06T13:48:38.879Z" }, + { url = "https://files.pythonhosted.org/packages/f1/16/d905e7f53e661ce2c24686c38048d8e2b750ffc4350009d41c4e6c6c9826/h5py-3.16.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:e4360f15875a532bc7b98196c7592ed4fc92672a57c0a621355961cafb17a6dd", size = 5404883, upload-time = "2026-03-06T13:48:41.324Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f2/58f34cb74af46d39f4cd18ea20909a8514960c5a3e5b92fd06a28161e0a8/h5py-3.16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3fae9197390c325e62e0a1aa977f2f62d994aa87aab182abbea85479b791197c", size = 5192039, upload-time = "2026-03-06T13:48:43.117Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ca/934a39c24ce2e2db017268c08da0537c20fa0be7e1549be3e977313fc8f5/h5py-3.16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:43259303989ac8adacc9986695b31e35dba6fd1e297ff9c6a04b7da5542139cc", size = 5421526, upload-time = "2026-03-06T13:48:44.838Z" }, + { url = "https://files.pythonhosted.org/packages/3e/14/615a450205e1b56d16c6783f5ccd116cde05550faad70ae077c955654a75/h5py-3.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:fa48993a0b799737ba7fd21e2350fa0a60701e58180fae9f2de834bc39a147ab", size = 3183263, upload-time = "2026-03-06T13:48:47.117Z" }, + { url = "https://files.pythonhosted.org/packages/7b/48/a6faef5ed632cae0c65ac6b214a6614a0b510c3183532c521bdb0055e117/h5py-3.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:1897a771a7f40d05c262fc8f37376ec37873218544b70216872876c627640f63", size = 2663450, upload-time = "2026-03-06T13:48:48.707Z" }, + { url = "https://files.pythonhosted.org/packages/5d/32/0c8bb8aedb62c772cf7c1d427c7d1951477e8c2835f872bc0a13d1f85f86/h5py-3.16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:15922e485844f77c0b9d275396d435db3baa58292a9c2176a386e072e0cf2491", size = 3760693, upload-time = "2026-03-06T13:48:50.453Z" }, + { url = "https://files.pythonhosted.org/packages/1d/1f/fcc5977d32d6387c5c9a694afee716a5e20658ac08b3ff24fdec79fb05f2/h5py-3.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:df02dd29bd247f98674634dfe41f89fd7c16ba3d7de8695ec958f58404a4e618", size = 3181305, upload-time = "2026-03-06T13:48:52.221Z" }, + { url = "https://files.pythonhosted.org/packages/f5/a1/af87f64b9f986889884243643621ebbd4ac72472ba8ec8cec891ac8e2ca1/h5py-3.16.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:0f456f556e4e2cebeebd9d66adf8dc321770a42593494a0b6f0af54a7567b242", size = 5074061, upload-time = "2026-03-06T13:48:54.089Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d0/146f5eaff3dc246a9c7f6e5e4f42bd45cc613bce16693bcd4d1f7c958bf5/h5py-3.16.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:3e6cb3387c756de6a9492d601553dffea3fe11b5f22b443aac708c69f3f55e16", size = 5279216, upload-time = "2026-03-06T13:48:56.75Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9d/12a13424f1e604fc7df9497b73c0356fb78c2fb206abd7465ce47226e8fd/h5py-3.16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8389e13a1fd745ad2856873e8187fd10268b2d9677877bb667b41aebd771d8b7", size = 5070068, upload-time = "2026-03-06T13:48:59.169Z" }, + { url = "https://files.pythonhosted.org/packages/41/8c/bbe98f813722b4873818a8db3e15aa3e625b59278566905ac439725e8070/h5py-3.16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:346df559a0f7dcb31cf8e44805319e2ab24b8957c45e7708ce503b2ec79ba725", size = 5300253, upload-time = "2026-03-06T13:49:02.033Z" }, + { url = "https://files.pythonhosted.org/packages/32/9e/87e6705b4d6890e7cecdf876e2a7d3e40654a2ae37482d79a6f1b87f7b92/h5py-3.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4c6ab014ab704b4feaa719ae783b86522ed0bf1f82184704ed3c9e4e3228796e", size = 3381671, upload-time = "2026-03-06T13:49:04.351Z" }, + { url = "https://files.pythonhosted.org/packages/96/91/9fad90cfc5f9b2489c7c26ad897157bce82f0e9534a986a221b99760b23b/h5py-3.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:faca8fb4e4319c09d83337adc80b2ca7d5c5a343c2d6f1b6388f32cfecca13c1", size = 2740706, upload-time = "2026-03-06T13:49:06.347Z" }, +] + [[package]] name = "httpcore" version = "1.0.9" @@ -4393,6 +4453,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/32/e263e888bec1c30b40dd834ca8ea6227a9ca9d5c8091986ea0c8746f0305/scooby-0.12.0-py3-none-any.whl", hash = "sha256:65dea5b32006ef554aea9e945032c0c448eaacada96078187681e24f0a2fe8fa", size = 21732, upload-time = "2026-09-11T03:38:10.246Z" }, ] +[[package]] +name = "scope-profiler" +version = "0.7.5" +source = { git = "https://github.com/max-models/scope-profiler?branch=devel#2abb4ca4fe74166be554d30c3b78af8041f930ae" } +dependencies = [ + { name = "h5py" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "tabulate" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] + [[package]] name = "send2trash" version = "2.1.0" @@ -4569,6 +4642,7 @@ dependencies = [ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "scope-profiler" }, ] [package.optional-dependencies] @@ -4685,6 +4759,7 @@ requires-dist = [ { name = "pyvista", marker = "extra == 'interactive'", specifier = ">=0.44" }, { name = "ruff", marker = "extra == 'dev'" }, { name = "scikit-image", marker = "extra == 'mesh'", specifier = ">=0.22" }, + { name = "scope-profiler", git = "https://github.com/max-models/scope-profiler?branch=devel" }, { name = "shapely", marker = "extra == 'clip'", specifier = ">=2" }, { name = "simgeometry", extras = ["clip"], marker = "extra == 'test'" }, { name = "simgeometry", extras = ["clip", "plot"], marker = "extra == 'docs'" }, @@ -4739,6 +4814,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, ] +[[package]] +name = "tabulate" +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/58/8c37dea7bbf769b20d58e7ace7e5edfe65b849442b00ffcdd56be88697c6/tabulate-0.10.0.tar.gz", hash = "sha256:e2cfde8f79420f6deeffdeda9aaec3b6bc5abce947655d17ac662b126e48a60d", size = 91754, upload-time = "2026-03-04T18:55:34.402Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl", hash = "sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3", size = 39814, upload-time = "2026-03-04T18:55:31.284Z" }, +] + [[package]] name = "termcolor" version = "3.3.0" From d7b8eb8c96af781be81b4c7d6a58cc718ac48068 Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 22 Oct 2026 10:27:43 +0200 Subject: [PATCH 04/36] Level sets, axisymmetric solids, conductor voltages, interactive 3D views (#18) * Add level sets, axisymmetric solids, conductor voltages and plasma-plots 3D views * Lock plasma-plots 0.2.4 and starlight-theme v0.5.0 --- .github/workflows/docs.yml | 6 +- .github/workflows/test_tutorials.yml | 5 +- CHANGELOG.md | 32 + README.md | 24 + README.qmd | 23 + benchmarks/level_set_crossings.py | 114 ++ docs/package-lock.json | 6 +- docs/package.json | 2 +- .../docs/getting-started/geometry-api.md | 83 +- pyproject.toml | 7 +- src/simgeometry/README.md | 9 +- src/simgeometry/__init__.py | 8 + src/simgeometry/_level_set.py | 198 +++ src/simgeometry/api.py | 20 +- src/simgeometry/collection.py | 134 +- src/simgeometry/crossings.py | 70 +- src/simgeometry/evaluation.py | 37 +- src/simgeometry/mesh.py | 21 +- src/simgeometry/objects/__init__.py | 8 + src/simgeometry/objects/aperture2d.py | 13 + src/simgeometry/objects/axisymmetric.py | 202 +++ src/simgeometry/objects/circle.py | 13 +- src/simgeometry/objects/cone.py | 92 ++ src/simgeometry/objects/cuboid.py | 15 +- src/simgeometry/objects/cylinder.py | 94 ++ src/simgeometry/objects/function_object.py | 17 +- src/simgeometry/objects/line.py | 13 +- src/simgeometry/objects/polygon.py | 12 +- src/simgeometry/objects/rectangle.py | 14 +- src/simgeometry/objects/sphere.py | 13 +- src/simgeometry/objects/torus.py | 86 ++ src/simgeometry/objects/triangle.py | 11 +- src/simgeometry/objects/tube.py | 123 ++ src/simgeometry/operations.py | 70 +- src/simgeometry/queries.py | 96 +- src/simgeometry/shapes.py | 47 + src/simgeometry/tests/test_axisymmetric.py | 243 +++ src/simgeometry/tests/test_conductors.py | 107 ++ .../tests/test_inspection_plotting.py | 78 + src/simgeometry/tests/test_level_sets.py | 300 ++++ .../tests/test_level_sets_fake_cupy.py | 56 + src/simgeometry/visualization.py | 64 +- tutorials/conductors_and_level_sets.ipynb | 240 +++ tutorials/index.ipynb | 3 +- tutorials/inspecting_geometry.ipynb | 73 +- uv.lock | 1341 ++++++++++++++++- 46 files changed, 4100 insertions(+), 143 deletions(-) create mode 100644 benchmarks/level_set_crossings.py create mode 100644 src/simgeometry/_level_set.py create mode 100644 src/simgeometry/objects/axisymmetric.py create mode 100644 src/simgeometry/objects/cone.py create mode 100644 src/simgeometry/objects/cylinder.py create mode 100644 src/simgeometry/objects/torus.py create mode 100644 src/simgeometry/objects/tube.py create mode 100644 src/simgeometry/tests/test_axisymmetric.py create mode 100644 src/simgeometry/tests/test_conductors.py create mode 100644 src/simgeometry/tests/test_level_sets.py create mode 100644 src/simgeometry/tests/test_level_sets_fake_cupy.py create mode 100644 tutorials/conductors_and_level_sets.ipynb diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 0196d95..a9cacd6 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -22,8 +22,10 @@ concurrency: jobs: docs: - uses: max-models/starlight-theme/.github/workflows/docs.yml@v0.4.0 + uses: max-models/starlight-theme/.github/workflows/docs.yml@v0.5.0 with: + # the PyVista scenes in the tutorials render under a virtual display + xvfb: true smoke-paths: | getting-started/installation/index.html tutorials/index.html @@ -36,3 +38,5 @@ jobs: tutorials/shapes-and-node-masks/shapes_and_node_masks.ipynb tutorials/parallel-grids/parallel_grids.ipynb tutorials/shapes-and-node-masks/1.png + tutorials/inspecting-geometry/4.html + tutorials/conductors-and-level-sets/2.html diff --git a/.github/workflows/test_tutorials.yml b/.github/workflows/test_tutorials.yml index c111d2d..28da34a 100644 --- a/.github/workflows/test_tutorials.yml +++ b/.github/workflows/test_tutorials.yml @@ -40,6 +40,9 @@ jobs: cache: npm cache-dependency-path: docs/package-lock.json + - name: Install a virtual display for the PyVista scenes + run: sudo apt-get update && sudo apt-get install -y xvfb libgl1 libglx-mesa0 + - name: Install project run: | uv sync --extra docs @@ -48,4 +51,4 @@ jobs: - name: Execute tutorials working-directory: docs - run: DOCS_PYTHON="$(pwd)/../.venv/bin/python" npx starlight-notebooks execute --force + run: DOCS_PYTHON="$(pwd)/../.venv/bin/python" xvfb-run -a npx starlight-notebooks execute --force diff --git a/CHANGELOG.md b/CHANGELOG.md index a90fcb2..f2baca3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- The PyVista backend of `show_3d` draws the surfaces with plasma-plots' `pyvista_surfaces` + (plasma-plots is now part of the `interactive` extra); `color_by="voltage"` colours a + collection's conductors by their voltage at time `t`. +- `level_set_field()`: the level set on a grid in the layout of plasma-plots' 3D views, for + `field.plasma.plot.isosurface(...)`; `evaluate(quantity="level_set")`. +- Interactive 3D scenes in the tutorials "Inspecting geometry" and "Conductors and level + sets" (starlight-theme v0.5.0, `plasma_plots.scene`). - `DomainDecomposition.owner_ranks(positions, lower_bound, upper_bound, num_gridpoints, layout)` and `owned_mask(...)`: vectorised position-to-rank lookup for NumPy and CuPy arrays (`(ndim, n)` by default, `layout="particle_major"` for `(n, ndim)`). @@ -36,6 +43,21 @@ format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). `simgeometry.crossings`, `simgeometry.to_mesh`, ...), off unless the application enables the `simgeometry` tag (`sp.setup(enable_tags=["simgeometry"])`). scope-profiler is now a runtime dependency. +- `shape.level_set(points)`: an analytic level set, negative inside and positive outside like + `signed_distance`, with `has_level_set` and `level_set_exact` (the Euclidean signed + distance). Exact for lines, circles, spheres, rectangles, cuboids, polygons, triangles, + apertures and the new solids; Booleans combine level sets by min/max, transforms scale + them by their smallest stretch (exact for rigid motions and uniform scaling), sections and + collections pass them on. Function objects and `Aperture3D` have none. +- `Cylinder`, `Tube` (hollow cylinder), `Cone` (frustum, or cone with one radius 0) and + `Torus`, along any axis, with exact level sets, volumes, surface areas and tight bounding + boxes; `fillet=` rounds the edges of cylinders and tubes. +- Conductors on `GeometryCollection`: `voltages={name: value or function of t}`, + `with_voltages(...)` (same geometry, other voltages), `voltages_at(t)`, `conductors`, and + `dirichlet_values(grid or points, t=...)` with the label and voltage of each node + (`DirichletValues`; NaN outside conductors). +- Tutorial "Conductors and level sets": an Einzel lens of three ring electrodes with voltages. +- `benchmarks/level_set_crossings.py`: edge crossings on level sets against bisection. ### Changed @@ -50,6 +72,16 @@ format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). for `axis=0`. - 2D shapes have no `get_vertices()` any more; use `outlines()`, which returns `(2, n)` arrays. `PointList` and `Point` are gone. +- `intersection_fractions` (and so `bracketed_edge_geometry`) refines crossings of shapes with + a level set by linear interpolation and Illinois steps on it, about 8 evaluations per edge + instead of 50 (2.5 to 3.3 times faster on NumPy, equally accurate); edges without a sign + change of the level set are bisected as before. A level-set crossing is the root itself, not + the end of the last bisection interval inside the object. +- The sampled paths of `cut_edges`/`intersect_segment` and `crossings` refine on the level set + when there is one; `crossings` then takes its normal from the level-set gradient, as does + `surface_normal`. `signed_distance` returns an exact level set directly, and `to_mesh` + contours the level set of any shape that has one (it did so for balls and boxes only). +- The `FunctionObject3D` example is a rounded cube; use `Torus` for a torus. ### Removed diff --git a/README.md b/README.md index 569ad98..5914211 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,30 @@ tutorials](https://max-models.github.io/simgeometry/tutorials/) and [API guide](https://max-models.github.io/simgeometry/getting-started/geometry-api/) for examples and the accuracy limits of sampled operations. +## Level sets, solids of revolution and conductors + +``` python +from simgeometry import GeometryCollection, Tube + +ring = lambda z: Tube((0, 0, z - 0.4), (0, 0, z + 0.4), 1.0, 1.6, fillet=0.1) +lens = GeometryCollection( + {"entrance": ring(-1.2), "focus": ring(0.0), "exit": ring(1.2)}, + voltages={"entrance": 0.0, "focus": 10e3, "exit": 0.0}, +) +scan = lens.with_voltages(focus=lambda t: 10e3 * min(t / 1e-6, 1.0)) +values = scan.dirichlet_values(grid, t=0.5e-6) # labels and voltages per node +distance = lens["focus"].level_set(points) # exact signed distance +``` + +`Cylinder`, `Tube`, `Cone` and `Torus` take any axis. Analytic shapes +have a `level_set(points)`, negative inside, with `level_set_exact` +where it is the Euclidean signed distance; Booleans and transforms +propagate it. Edge crossings refine it with a few secant steps instead +of bisecting `contains`, and `signed_distance` returns an exact one +directly. The [conductors +tutorial](https://max-models.github.io/simgeometry/tutorials/conductors-and-level-sets/) +builds an Einzel lens. + ## Profiling simgeometry ships diff --git a/README.qmd b/README.qmd index f702734..4578dc8 100644 --- a/README.qmd +++ b/README.qmd @@ -54,6 +54,29 @@ fractions. See the [executed tutorials](https://max-models.github.io/simgeometry and [API guide](https://max-models.github.io/simgeometry/getting-started/geometry-api/) for examples and the accuracy limits of sampled operations. +## Level sets, solids of revolution and conductors + +```python +from simgeometry import GeometryCollection, Tube + +ring = lambda z: Tube((0, 0, z - 0.4), (0, 0, z + 0.4), 1.0, 1.6, fillet=0.1) +lens = GeometryCollection( + {"entrance": ring(-1.2), "focus": ring(0.0), "exit": ring(1.2)}, + voltages={"entrance": 0.0, "focus": 10e3, "exit": 0.0}, +) +scan = lens.with_voltages(focus=lambda t: 10e3 * min(t / 1e-6, 1.0)) +values = scan.dirichlet_values(grid, t=0.5e-6) # labels and voltages per node +distance = lens["focus"].level_set(points) # exact signed distance +``` + +`Cylinder`, `Tube`, `Cone` and `Torus` take any axis. Analytic shapes have a +`level_set(points)`, negative inside, with `level_set_exact` where it is the +Euclidean signed distance; Booleans and transforms propagate it. Edge crossings +refine it with a few secant steps instead of bisecting `contains`, and +`signed_distance` returns an exact one directly. The +[conductors tutorial](https://max-models.github.io/simgeometry/tutorials/conductors-and-level-sets/) +builds an Einzel lens. + ## Profiling simgeometry ships [scope-profiler](https://github.com/max-models/scope-profiler) diff --git a/benchmarks/level_set_crossings.py b/benchmarks/level_set_crossings.py new file mode 100644 index 0000000..fd8cd16 --- /dev/null +++ b/benchmarks/level_set_crossings.py @@ -0,0 +1,114 @@ +"""Edge crossings found on level sets against bisection of the membership test. + +For each shape, random grid edges that start outside and end inside the shape are +handed to ``intersection_fractions`` twice: once as they are (Illinois steps on the +level set) and once through a wrapper that hides the level set (48 bisection steps on +``contains``). The script prints the time of both, the number of shape evaluations, +and the largest distance of the found crossings from the surface (the exact level set +there, as all these shapes have one). Evaluations are counted per edge: one per +point at which ``contains`` or the level set is evaluated. + + uv run python benchmarks/level_set_crossings.py +""" + +from __future__ import annotations + +import time + +import numpy as np + +from simgeometry import Circle, Cylinder, Sphere, Torus, Tube +from simgeometry.queries import intersection_fractions +from simgeometry.shapes import Shape + +EDGES = 100_000 +SPACING = 0.01 +REPEATS = 5 + + +class Counted(Shape): + """A shape that counts evaluated points; ``level_set=False`` hides the level set.""" + + def __init__(self, source, *, level_set): + self.source = source + self.has_level_set = level_set and source.has_level_set + self.evaluations = 0 + super().__init__(source.ndim, source.lower_bound, source.upper_bound) + + def contains_points(self, points): + self.evaluations += points.shape[1] + return self.source.contains_points(points) + + def contains(self, coords): + return self.source.contains(coords) + + def level_set(self, points): + self.evaluations += points.shape[1] + return self.source.level_set(points) + + +def bracketing_edges(shape, rng): + """``EDGES`` outside-to-inside grid edges of length ``SPACING`` along random axes.""" + lower = np.asarray(shape.lower_bound) - SPACING + upper = np.asarray(shape.upper_bound) + SPACING + outside, inside, found = [], [], 0 + while found < EDGES: + start = rng.uniform(lower[:, None], upper[:, None], (shape.ndim, 20 * EDGES)) + end = start.copy() + end[rng.integers(0, shape.ndim, start.shape[1]), np.arange(start.shape[1])] += ( + SPACING + ) + keep = ~shape.contains_points(start) & shape.contains_points(end) + outside.append(start[:, keep]) + inside.append(end[:, keep]) + found += int(keep.sum()) + return ( + np.concatenate(outside, axis=1)[:, :EDGES], + np.concatenate(inside, axis=1)[:, :EDGES], + ) + + +def run(name, shape, rng): + outside, inside = bracketing_edges(shape, rng) + results = {} + for method, level_set in (("bisection", False), ("level set", True)): + counted = Counted(shape, level_set=level_set) + start = time.perf_counter() + for _ in range(REPEATS): + fractions = intersection_fractions(counted, outside, inside) + elapsed = (time.perf_counter() - start) / REPEATS + crossing = outside + fractions * (inside - outside) + error = float(np.max(np.abs(shape.level_set(crossing)))) + results[method] = (elapsed, counted.evaluations / (REPEATS * EDGES), error) + (t_b, n_b, e_b), (t_l, n_l, e_l) = results["bisection"], results["level set"] + print( + f"{name:24s} {t_b * 1e3:7.1f} ms {n_b:5.1f} evals {e_b:8.1e} | " + f"{t_l * 1e3:7.1f} ms {n_l:5.1f} evals {e_l:8.1e} | {t_b / t_l:4.1f}x" + ) + + +def main(): + rng = np.random.default_rng(0) + rings = Tube((0, 0, -0.1), (0, 0, 0.1), 0.5, 0.7) + for k in range(1, 8): + rings = rings | Tube((0, 0, 0.3 * k - 0.1), (0, 0, 0.3 * k + 0.1), 0.5, 0.7) + shapes = { + "Circle": Circle((0.1, 0.2), 1.0), + "Sphere": Sphere((0.1, 0.2, 0.3), 1.0), + "Cylinder (oblique axis)": Cylinder((0, 0, -1), (0.3, 0.2, 1), 0.5), + "Torus": Torus((0, 0, 0), 1.0, 0.3, axis=(1, 1, 0)), + "Sphere - Cylinder": Sphere((0, 0, 0), 1) + - Cylinder((0, 0, -2), (0, 0, 2), 0.4), + "union of 8 Tubes": rings, + } + print( + f"{EDGES} edges of length {SPACING}; time per call, shape evaluations and " + "the largest |level set| at the crossings\n" + f"{'':24s} {'bisection (48 steps)':>32s} | {'level set (Illinois)':>32s} | speedup" + ) + for name, shape in shapes.items(): + run(name, shape, rng) + + +if __name__ == "__main__": + main() diff --git a/docs/package-lock.json b/docs/package-lock.json index 5a1e034..622cf75 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.1", "dependencies": { "@astrojs/starlight": "^0.42.6", - "@max-models/starlight-theme": "github:max-models/starlight-theme#v0.4.0", + "@max-models/starlight-theme": "github:max-models/starlight-theme#v0.5.0", "astro": "^7.4.0", "sharp": "^0.35.3" }, @@ -1223,8 +1223,8 @@ "license": "MIT" }, "node_modules/@max-models/starlight-theme": { - "version": "0.4.0", - "resolved": "git+ssh://git@github.com/max-models/starlight-theme.git#786dad95026f1c3db4e771af5384c002044864fc", + "version": "0.5.0", + "resolved": "git+ssh://git@github.com/max-models/starlight-theme.git#03d08c323572c44f47e769f1a07a7833ac8ed9fc", "license": "MIT", "workspaces": [ "example/docs" diff --git a/docs/package.json b/docs/package.json index 4261225..7b8cd16 100644 --- a/docs/package.json +++ b/docs/package.json @@ -17,7 +17,7 @@ }, "dependencies": { "@astrojs/starlight": "^0.42.6", - "@max-models/starlight-theme": "github:max-models/starlight-theme#v0.4.0", + "@max-models/starlight-theme": "github:max-models/starlight-theme#v0.5.0", "astro": "^7.4.0", "sharp": "^0.35.3" } diff --git a/docs/src/content/docs/getting-started/geometry-api.md b/docs/src/content/docs/getting-started/geometry-api.md index 393ff08..c4769f4 100644 --- a/docs/src/content/docs/getting-started/geometry-api.md +++ b/docs/src/content/docs/getting-started/geometry-api.md @@ -4,7 +4,7 @@ description: Plotting, sections, labelled sampling, surface queries and cut-edge --- Install `simgeometry[plot]` for plotting, evaluation and surface meshing, or -`simgeometry[interactive]` for the optional PyVista viewer. The base package keeps +`simgeometry[interactive]` for the optional PyVista viewer, drawn with plasma-plots. The base package keeps NumPy, cunumpy, maybempi and mpiarray as its only runtime dependencies. ```python @@ -25,6 +25,7 @@ shell.slice(origin=(0, 0, 0), normal=(1, 1, 1)).show() | --- | --- | --- | | `contains(point)` | One coordinate vector | Boolean | | `contains_points(points)` | `(ndim, n)` array | Boolean array on the selected cunumpy backend | +| `level_set(points)` | `(ndim, n)` array | Signed level set on the selected cunumpy backend, negative inside | | `evaluate(x=..., y=..., z=...)` | Scalars, coordinate sequences or DataArrays | Host `xarray.DataArray` | | `node_mask(grid)` | Grid | Host boolean array with grid shape | | `slice(z=value)` | One fixed coordinate | Queryable section | @@ -35,6 +36,7 @@ shell.slice(origin=(0, 0, 0), normal=(1, 1, 1)).show() | `intersect_segment(start, end)` | Two coordinate vectors | Ordered hits, fractions, normals and crossing directions | | `cut_edges(grid)` | Grid | All detected edge hits with object identities | | `cell_fractions(grid)` | Grid | Approximate solid volume and face fractions | +| `collection.dirichlet_values(grid, t=...)` | Grid or `(ndim, n)` | Host labels and voltages; NaN outside conductors | | `to_mesh()` | Resolution and optional bounds | Vertices, connectivity, centres, normals and sampling spacing | Inspection and meshing run on the calling process and do not implicitly gather @@ -98,6 +100,80 @@ approximations; increase `resolution` to inspect small features. PyVista's [plane widget](https://docs.pyvista.org/api/plotting/_autosummary/pyvista.plotter.add_mesh_clip_plane) provides the interactive clipping behavior. +Without `clip=True`, wireframes or extra PyVista options, the PyVista backend draws the +surfaces with plasma-plots' `pyvista_surfaces`, one colour per object. For a collection +with voltages, `color_by="voltage"` colours each conductor by its voltage at time `t` +(other objects grey): + +```python +viewer = lens.show_3d(backend="pyvista", color_by="voltage", t=0.0) +``` + +`level_set_field(resolution=...)` samples the level set on a grid in the layout of +plasma-plots' 3D views (`eta1`, `eta2`, `eta3` with physical `X`, `Y`, `Z`), so any of +its levels can be drawn as an isosurface: + +```python +import plasma_plots + +field = shell.level_set_field(resolution=64) +field.plasma.plot.isosurface(values=[0.0, 0.1], show_domain=False) +``` + +In a notebook, `plasma_plots.scene(plotter)` shows a plotter as an interactive scene, +also on these docs pages. + +## Level sets + +Analytic shapes have `level_set(points)` (`has_level_set` is true): negative inside, +positive outside and zero on the boundary, with the sign of `signed_distance`, so it +flips for inverted shapes. Its magnitude never exceeds the distance to the boundary, +and where `level_set_exact` is true it is the Euclidean signed distance. + +| Shape | Level set | +| --- | --- | +| `Line`, `Circle`, `Sphere`, `Rectangle`, `Cuboid` | exact | +| `Polygon`, `Triangle` | exact: distance to the nearest edge, signed by `contains` (one pass per edge) | +| `Aperture2D` | exact while the plates do not touch | +| `Cylinder`, `Tube`, `Cone`, `Torus` (any axis, with or without fillets) | exact | +| `a \| b`, `a & b`, `a - b` | `min(a, b)`, `max(a, b)`, `max(a, -b)`: a bound, not exact | +| rotations, translations, reflections, uniform scaling | exact if the source is | +| nonuniform scaling, shears | the source's times the smallest stretch: a bound | +| sections, collections | the source's on the plane, the minimum over the objects: a bound | +| `FunctionObject2D/3D`, `Aperture3D`, `Polyline` | none | + +Where a shape has a level set, grid-edge crossings (`intersection_fractions`, +`bracketed_edge_geometry`, the sampled path of `cut_edges` and `crossings`) start from +the linear interpolation of the level set at the two ends of the edge and refine the +root with a few Illinois (modified regula falsi) steps, instead of bisecting `contains` +48 times. Membership still decides which edges are cut; an edge whose level-set values +do not change sign (a node exactly on the surface, a periodic seam) is bisected as +before. Crossings land on the surface to round-off after about 8 evaluations per edge +instead of 50. `signed_distance` returns an exact level set directly, surface normals +of other level-set shapes are its gradient, and `to_mesh` contours it, so meshes of +these shapes place their vertices between samples. + +`cell_fractions` keeps its subcell quadrature: interpolating corner level-set values +(AMReX-style) is more accurate in smooth cut cells but less accurate at corners and +misses features thinner than a cell, which the subcell samples catch. + +## Conductors + +`GeometryCollection(objects, voltages={"electrode": 1000.0, "wall": 0.0})` makes +named objects conductors. A voltage is a number or a function of the time `t`. +`collection.with_voltages(electrode=500.0)` returns the same objects, bounds and styles +with other voltages (a mapping works for any name; `None` removes a voltage), so a +parameter scan does not rebuild the geometry. `voltages_at(t)` evaluates the voltages +and `conductors` lists the names with one. + +`collection.dirichlet_values(grid, t=t)` returns `DirichletValues` with `labels` and +`voltages` per node (grid-shaped), or per point for `(ndim, n)` points. Labels follow +`labels()`: 0 outside every object, the last listed object winning in overlaps. The +voltage is NaN outside the conductors, including inside an object without a voltage; +`values.conductor` marks the nodes a field solver fixes. See the +[conductors tutorial](/simgeometry/tutorials/conductors-and-level-sets/) for an +Einzel lens. + ## Exact geometry and numerical approximations - Balls, axis-aligned boxes and simple polygons have analytic segment intersections @@ -112,10 +188,11 @@ provides the interactive clipping behavior. one incident face. A generic query away from a surface returns NaN. - Closest-point and distance queries on general shapes use a sampled contour or triangular mesh. `resolution` controls the surface sampling; no exact signed - distance is inferred from an arbitrary boolean function. + distance is inferred from an arbitrary boolean function. Shapes with an exact level + set return it from `signed_distance`. - Surface meshing uses scikit-image's [marching contours/cubes](https://scikit-image.org/docs/stable/api/skimage.measure.html). - Primitive balls and boxes use continuous implicit values; other shapes use + Shapes with a level set use it as continuous implicit values; other shapes use membership samples. Mesh spacing is metadata, not a certified error bound. A surface touching sampling bounds can be open. - `sample_interior()` uses uniform rejection sampling and a finite `max_draws`. diff --git a/pyproject.toml b/pyproject.toml index 0c0c8ad..eabc566 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,7 +38,9 @@ optional-dependencies.dev = [ ] optional-dependencies.docs = [ "griffe", - "simgeometry[clip,plot]", + "simgeometry[clip,interactive]", + # interactive 3-D scenes in the notebooks (plasma_plots.scene) + "plasma-plots[scene]>=0.2.4", "ipykernel", "matplotlib>=3.10.9", "nbclient>=0.11", @@ -48,7 +50,7 @@ optional-dependencies.clip = [ "shapely>=2" ] optional-dependencies.evaluate = [ "xarray" ] optional-dependencies.mesh = [ "scikit-image>=0.22" ] optional-dependencies.plot = [ "matplotlib>=3.10", "simgeometry[evaluate,mesh]" ] -optional-dependencies.interactive = [ "pyvista>=0.44", "simgeometry[plot]" ] +optional-dependencies.interactive = [ "plasma-plots>=0.2.4", "pyvista>=0.44", "simgeometry[plot]" ] optional-dependencies.mpi = [ "mpi4py" ] optional-dependencies.pyccel = [ "pyccel>2.0" ] optional-dependencies.gpu = [ "cupy-cuda12x" ] @@ -96,4 +98,5 @@ report.show_missing = true cunumpy = { git = "https://github.com/max-models/cunumpy", branch = "devel" } maybempi = { git = "https://github.com/max-models/maybempi", branch = "devel" } mpiarray = { git = "https://github.com/max-models/mpiarray", branch = "devel" } +plasma-plots = { git = "https://github.com/max-models/plasma-plots", branch = "devel" } scope-profiler = { git = "https://github.com/max-models/scope-profiler", branch = "devel" } diff --git a/src/simgeometry/README.md b/src/simgeometry/README.md index 9027998..839f9b7 100644 --- a/src/simgeometry/README.md +++ b/src/simgeometry/README.md @@ -28,15 +28,20 @@ values; applications can configure their own seeded stream. - `shapes.py` - Shapes helpers for geometry, grids, and simulation domains. - `simulation_domain.py` - Simulation domain helpers for geometry, grids, and simulation domains. - `utils.py` - Utils helpers for geometry, grids, and simulation domains. +- `_level_set.py` - Exact signed-distance formulas of the primitives and the Illinois root finding on level sets. +- `collection.py` - `GeometryCollection`: named objects, overlap precedence, styles, and conductor voltages (`with_voltages`, `voltages_at`, `dirichlet_values`). The shared `GeometryAPI` adds sections, transforms, Boolean operations, plotting, evaluation, surface queries, sampling and export. `GeometryCollection` assigns -names and overlap precedence. `cut_edges` owns solver-independent intersections; +names, overlap precedence and conductor voltages. Analytic shapes have a +`level_set` (exact where `level_set_exact`), which Booleans, transforms, sections and +collections propagate and the crossing and distance queries use when present. +`cut_edges` owns solver-independent intersections; `fractions` estimates cell/face occupancy. Picnic owns stencil coefficients and its minimum-distance regularization. Tutorials live in `tutorials/` and are executed into the documentation site. ## Subpackages -- `objects/` - Concrete geometric objects that can be embedded in a simulation domain. +- `objects/` - Concrete geometric objects that can be embedded in a simulation domain, including the solids of revolution `Cylinder`, `Tube`, `Cone` and `Torus` (`objects/axisymmetric.py` holds their common frame). - `tests/` - Test modules for `simgeometry`. diff --git a/src/simgeometry/__init__.py b/src/simgeometry/__init__.py index b3af5a0..fbe10f9 100644 --- a/src/simgeometry/__init__.py +++ b/src/simgeometry/__init__.py @@ -11,7 +11,9 @@ Aperture2D, Aperture3D, Circle, + Cone, Cuboid, + Cylinder, FunctionObject2D, FunctionObject3D, Line, @@ -19,7 +21,9 @@ Polyline, Rectangle, Sphere, + Torus, Triangle, + Tube, ) from simgeometry.simulation_domain import SimulationDomain @@ -37,7 +41,9 @@ "Aperture2D", "Aperture3D", "Circle", + "Cone", "Cuboid", + "Cylinder", "FunctionObject2D", "FunctionObject3D", "Line", @@ -45,5 +51,7 @@ "Polyline", "Rectangle", "Sphere", + "Torus", "Triangle", + "Tube", ] diff --git a/src/simgeometry/_level_set.py b/src/simgeometry/_level_set.py new file mode 100644 index 0000000..f8b472f --- /dev/null +++ b/src/simgeometry/_level_set.py @@ -0,0 +1,198 @@ +"""Signed-distance formulas of the primitives and root finding on level sets. + +The formulas are the standard exact signed distances of boxes, segments, capped +cones and tori (see Inigo Quilez's 2D and 3D distance functions), written for +component-major arrays. They use only ``xp`` operations, so they run on the active +cunumpy backend (NumPy, or CuPy on a GPU). +""" + +from __future__ import annotations + +from collections.abc import Callable + +import cunumpy as xp +import numpy as np + +#: Most Illinois (modified regula falsi) steps before a crossing falls back to +#: bisection of the membership test. +SECANT_STEPS = 12 +#: Largest number of (point, edge) pairs a polygon distance broadcasts at once. +MAX_PAIRS = 4_000_000 + + +def has_level_set(shape) -> bool: + """Whether ``shape`` provides :meth:`~simgeometry.shapes.Shape.level_set`.""" + return bool(getattr(shape, "has_level_set", False)) + + +def box_distance(q, half): + """Signed distance of the points ``q`` (``(ndim, n)``) to the centred box ``half``. + + ``half`` holds the half side lengths, one per row of ``q``. + """ + d = xp.abs(q) - half + outside = xp.sqrt(xp.sum(xp.maximum(d, 0.0) ** 2, axis=0)) + inside = xp.minimum(xp.max(d, axis=0), 0.0) + return outside + inside + + +def rectangle_distance(x, y, half_x, half_y): + """Signed distance of ``(x, y)`` to the rectangle ``|x| <= half_x, |y| <= half_y``.""" + dx = xp.abs(x) - half_x + dy = xp.abs(y) - half_y + outside = xp.sqrt(xp.maximum(dx, 0.0) ** 2 + xp.maximum(dy, 0.0) ** 2) + return outside + xp.minimum(xp.maximum(dx, dy), 0.0) + + +def capped_cone_distance(radial, axial, half_length, r_low, r_high): + """Signed distance of ``(radial, axial)`` to a frustum of revolution. + + The frustum spans ``-half_length <= axial <= half_length`` with radius ``r_low`` + at the low end and ``r_high`` at the high end; ``radial`` is the distance from + its axis. + """ + # distance to the nearer cap: the disc of radius r_low or r_high + cap_radius = xp.where(axial < 0.0, r_low, r_high) + cap_x = radial - xp.minimum(radial, cap_radius) + cap_y = xp.abs(axial) - half_length + # distance to the slanted side, the segment from (r_high, h) to (r_low, -h) + side_x, side_y = r_low - r_high, -2.0 * half_length + length2 = side_x * side_x + side_y * side_y + t = xp.clip( + ((radial - r_high) * side_x + (axial - half_length) * side_y) / length2, + 0.0, + 1.0, + ) + slant_x = radial - (r_high + t * side_x) + slant_y = axial - (half_length + t * side_y) + inside = (slant_x < 0.0) & (cap_y < 0.0) + distance = xp.sqrt( + xp.minimum(cap_x * cap_x + cap_y * cap_y, slant_x * slant_x + slant_y * slant_y) + ) + return xp.where(inside, -distance, distance) + + +def segment_distance(points, starts, ends): + """Unsigned distance of the ``(2, n)`` points to the nearest of the segments. + + ``starts`` and ``ends`` are ``(2, m)``. Points are processed in chunks so that + at most :data:`MAX_PAIRS` point-segment pairs are held at once. + """ + n, m = points.shape[1], starts.shape[1] + edge = ends - starts + length2 = xp.sum(edge * edge, axis=0) + safe = xp.where(length2 > 0, length2, 1.0) + result = xp.empty(n) + chunk = max(1, MAX_PAIRS // max(1, m)) + for lo in range(0, n, chunk): + p = points[:, lo : lo + chunk] + wx = p[0][:, None] - starts[0][None, :] + wy = p[1][:, None] - starts[1][None, :] + t = xp.clip((wx * edge[0][None, :] + wy * edge[1][None, :]) / safe, 0.0, 1.0) + dx = wx - t * edge[0][None, :] + dy = wy - t * edge[1][None, :] + result[lo : lo + chunk] = xp.sqrt(xp.min(dx * dx + dy * dy, axis=1)) + return result + + +def illinois_roots( + level: Callable, + start, + step, + t_a, + t_b, + f_a, + f_b, + tolerance: float, + steps: int = SECANT_STEPS, + array_xp=np, +): + """Roots of ``level(start + t * step)`` bracketed by ``t_a`` and ``t_b``. + + ``level`` maps ``(ndim, m)`` points to ``(m,)`` values; ``f_a`` and ``f_b`` are its + values at ``t_a`` and ``t_b`` and must have opposite signs. Each Illinois step + replaces the bracket end of the same sign as the new value by the secant root and + halves the other end's value if it was kept twice in a row, so both ends converge + (superlinearly for a smooth level set). An entry has converged when the secant + step or the bracket is shorter than ``tolerance``, or the level set is down to + the round-off of the coordinates (``eps * |x|``), below which no step can + improve the root; converged entries leave the working set. + + ``array_xp`` is the array module of the arguments: NumPy for host arrays, ``xp`` + for arrays on the active cunumpy backend. + + Returns ``(t, t_a, t_b, converged)``: the root estimates and the final brackets, + with ``t_a`` still on the side of the first ``f_a``. + """ + ta, tb = array_xp.array(t_a, dtype=float), array_xp.array(t_b, dtype=float) + fa, fb = array_xp.array(f_a, dtype=float), array_xp.array(f_b, dtype=float) + out_t, out_a, out_b = ta.copy(), ta.copy(), tb.copy() + done = array_xp.zeros(fa.shape, dtype=bool) + active = array_xp.arange(fa.shape[0]) + t = (ta * fb - tb * fa) / (fb - fa) + # the absolute round-off of level-set values near these points + noise = np.finfo(float).eps * ( + array_xp.max(array_xp.abs(start), axis=0) + + array_xp.max(array_xp.abs(step), axis=0) + ) + replaced_a = None # which end the previous step replaced + for _ in range(steps): + if active.shape[0] == 0: + break + f = level(start + t[None, :] * step) + replace_a = (f > 0) == (fa > 0) + if replaced_a is not None: + # Illinois: halve the value of an end kept for the second time in a row + fb = array_xp.where(replace_a & replaced_a, 0.5 * fb, fb) + fa = array_xp.where(~(replace_a | replaced_a), 0.5 * fa, fa) + ta = array_xp.where(replace_a, t, ta) + fa = array_xp.where(replace_a, f, fa) + tb = array_xp.where(replace_a, tb, t) + fb = array_xp.where(replace_a, fb, f) + replaced_a = replace_a + root = array_xp.abs(f) <= noise + t_next = array_xp.where(root, t, (ta * fb - tb * fa) / (fb - fa)) + converged = ( + root + | (array_xp.abs(t_next - t) <= tolerance) + | (array_xp.abs(tb - ta) <= tolerance) + ) + t = t_next + if bool(array_xp.any(converged)): + index = active[converged] + out_t[index], out_a[index], out_b[index] = ( + t[converged], + ta[converged], + tb[converged], + ) + done[index] = True + keep = ~converged + active, t, ta, tb, fa, fb, replaced_a, noise = ( + v[keep] for v in (active, t, ta, tb, fa, fb, replaced_a, noise) + ) + start, step = start[:, keep], step[:, keep] + out_t[active], out_a[active], out_b[active] = t, ta, tb + return out_t, out_a, out_b, done + + +def level_set_gradient(shape, points, spacing: float, array_xp=np): + """Central-difference gradient ``(ndim, n)`` of ``shape.level_set`` at the points. + + ``array_xp`` is the array module of ``points``, as in :func:`illinois_roots`. + """ + + def level(p): + values = shape.level_set(p) + return ( + np.asarray(xp.to_numpy(values), dtype=float) if array_xp is np else values + ) + + ndim = points.shape[0] + gradient = array_xp.empty(points.shape) + for axis in range(ndim): + offset = array_xp.zeros((ndim, 1)) + offset[axis] = spacing + gradient[axis] = (level(points + offset) - level(points - offset)) / ( + 2.0 * spacing + ) + return gradient diff --git a/src/simgeometry/api.py b/src/simgeometry/api.py index 64fc174..dc78eae 100644 --- a/src/simgeometry/api.py +++ b/src/simgeometry/api.py @@ -29,7 +29,8 @@ def evaluate( Scalars fix axes, sequences form a tensor product, and DataArrays with shared dimension names describe scattered points. Unspecified axes use grid nodes, or ``resolution`` samples across ``bounds``/the shape bounds. - ``quantity`` is occupancy, labels (collections), or signed_distance. + ``quantity`` is occupancy, labels (collections), signed_distance, or + level_set (the analytic level set, or the signed distance without one). This is a local host operation, with no implicit MPI collectives. """ from simgeometry.evaluation import evaluate @@ -45,6 +46,16 @@ def evaluate( quantity=quantity, ) + def level_set_field(self, *, resolution=64, bounds=None): + """The level set on a 3D grid, laid out for plasma-plots' 3D views. + + ``shape.level_set_field().plasma.plot.isosurface(values=[0])`` draws the + surface (requires the ``interactive`` extra and ``import plasma_plots``). + """ + from simgeometry.evaluation import level_set_field + + return level_set_field(self, resolution=resolution, bounds=bounds) + def slice(self, *, x=None, y=None, z=None, origin=None, normal=None): """Return a queryable section with local-to-world coordinate mappings. @@ -155,7 +166,8 @@ def crossings(self, start, end, *, samples=64, iterations=40): ``fraction``, ``point`` and ``normal`` (out of the region where ``contains`` is true) on the active array backend. Polygons, circles, spheres, boxes and their affine transforms are analytic; other shapes sample ``samples`` points per - segment and bisect ``iterations`` times. + segment and bisect ``iterations`` times, or refine on their level set (with its + gradient as the normal) if they have one. """ from simgeometry.crossings import crossings @@ -175,7 +187,9 @@ def closest_point(self, points, *, resolution=64, bounds=None): def signed_distance(self, points, *, resolution=64, bounds=None): """Distance to the boundary, negative inside, positive outside. - Exact for balls, boxes and simple polygons; otherwise distance to the + Exact for balls, boxes, simple polygons and every shape whose level set is + exact (``level_set_exact``, such as cylinders, tubes, cones, tori and their + rigid motions), which it returns directly; otherwise distance to the sampled surface mesh. A membership function is not itself a distance. """ from simgeometry.queries import signed_distance diff --git a/src/simgeometry/collection.py b/src/simgeometry/collection.py index 4837bc7..d3cad11 100644 --- a/src/simgeometry/collection.py +++ b/src/simgeometry/collection.py @@ -1,15 +1,42 @@ -"""Named collections with explicit overlap precedence and display styles.""" +"""Named collections with explicit overlap precedence, display styles and voltages.""" from __future__ import annotations -from collections.abc import Mapping +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from numbers import Real import cunumpy as xp import numpy as np +import scope_profiler as sp +from simgeometry._profiling import _PROFILE_TAGS from simgeometry.operations import mask from simgeometry.shapes import Shape, _component_major +#: A conductor voltage: a number, or a function of the time ``t`` returning one. +Voltage = float | Callable[[float], float] + + +@dataclass(frozen=True) +class DirichletValues: + """The object and the voltage at each point of :meth:`GeometryCollection.dirichlet_values`. + + ``labels`` are the collection's labels (0 outside every object, ``i`` inside + ``names[i - 1]``, the last listed object winning in overlaps). ``voltages`` is the + voltage of that object at the requested time, NaN outside every object and inside + objects without a voltage. Both have the shape of the grid, or ``(n,)`` for points. + """ + + labels: np.ndarray + voltages: np.ndarray + names: tuple[str, ...] + + @property + def conductor(self) -> np.ndarray: + """Where a point is inside a conductor, so its voltage is fixed.""" + return np.isfinite(self.voltages) + class GeometryCollection(Shape): """Named objects; last listed object wins labels in overlaps. @@ -18,9 +45,19 @@ class GeometryCollection(Shape): names to plotting keyword dictionaries. Explicit bounds also permit an empty collection and define the default viewing/sampling region, without clipping object membership. Collection objects are references, not snapshots. + + ``voltages`` makes named objects conductors: it maps names to a voltage, either a + number or a function of the time ``t`` (a ramped or RF electrode). Objects without + a voltage are not conductors. :meth:`with_voltages` returns the same geometry with + other voltages, :meth:`voltages_at` evaluates them at a time and + :meth:`dirichlet_values` gives the voltage at points or grid nodes. + + The level set, when every object has one, is the minimum of theirs (the union). """ - def __init__(self, objects, *, bounds=None, styles=None): + level_set_exact = False + + def __init__(self, objects, *, bounds=None, styles=None, voltages=None): if not isinstance(objects, Mapping): objects = {f"object_{i + 1}": obj for i, obj in enumerate(objects)} self.named_objects = dict(objects) @@ -31,6 +68,7 @@ def __init__(self, objects, *, bounds=None, styles=None): self.styles = dict(styles or {}) if set(self.styles) - set(self.names): raise ValueError("Style names must identify objects") + self.voltages = self._checked_voltages(voltages or {}) if bounds is None: if not self.objects: raise ValueError("An empty collection needs bounds") @@ -47,6 +85,79 @@ def __init__(self, objects, *, bounds=None, styles=None): def __getitem__(self, name): return self.named_objects[name] + def _checked_voltages(self, voltages): + """``voltages`` as a dict in object order, checking names and values.""" + unknown = set(voltages) - set(self.names) + if unknown: + raise ValueError(f"Voltages name unknown objects: {sorted(unknown)}") + for name, value in voltages.items(): + if isinstance(value, bool) or not ( + callable(value) or isinstance(value, Real) + ): + raise TypeError( + f"The voltage of {name!r} must be a number or a function of t, " + f"got {type(value).__name__}" + ) + return { + name: value if callable(value) else float(value) + for name in self.names + if (value := voltages.get(name)) is not None + } + + @property + def conductors(self): + """Names of the objects with a voltage, in insertion order.""" + return tuple(self.voltages) + + def with_voltages(self, voltages=None, /, **overrides): + """The same objects, bounds and styles with some voltages replaced. + + ``voltages`` (a mapping, for any name) and keyword arguments set the voltage of + those objects; ``None`` removes it. The geometry is shared, not rebuilt, so + each run of a parameter scan can set its own voltages cheaply:: + + lens = electrodes.with_voltages(focus=500.0) + """ + merged = {**self.voltages, **dict(voltages or {}), **overrides} + return GeometryCollection( + self.named_objects, + bounds=(self.lower_bound, self.upper_bound), + styles=self.styles, + voltages={name: v for name, v in merged.items() if v is not None}, + ) + + def voltages_at(self, t=0.0): + """The voltage of every conductor at time ``t``, as ``{name: float}``.""" + return { + name: float(value(t)) if callable(value) else value + for name, value in self.voltages.items() + } + + @sp.profile("simgeometry.dirichlet_values", tags=_PROFILE_TAGS) + def dirichlet_values(self, where, *, t=0.0): + """The label and the voltage of the object at each point, for Dirichlet nodes. + + ``where`` is a :class:`~simgeometry.grid.Grid` (results have its shape) or + ``(ndim, n)`` points. Returns :class:`DirichletValues`; a point outside every + conductor has a NaN voltage. Overlaps follow :meth:`labels`: the last listed + object wins, even if it is not a conductor. + """ + if hasattr(where, "node_points") and hasattr(where, "num_gridpoints"): + points = where.node_points() + shape = tuple(int(n) for n in where.num_gridpoints) + else: + points = where + shape = None + labels = self.labels(points) + table = np.full(len(self.names) + 1, np.nan) + voltages = self.voltages_at(t) + for label, name in enumerate(self.names, start=1): + table[label] = voltages.get(name, np.nan) + voltages = table[labels] + if shape is not None: + labels, voltages = labels.reshape(shape), voltages.reshape(shape) + return DirichletValues(labels, voltages, self.names) + def labels(self, points): """Integer object identities for (ndim, n) points; last object wins.""" points = np.asarray(xp.to_numpy(_component_major(points, self.ndim)), float) @@ -61,5 +172,22 @@ def contains_points(self, points): def contains(self, coords): return any(obj.contains(coords) for obj in self.objects) + @property + def has_level_set(self): + """Whether every object has a level set.""" + return bool(self.objects) and all( + getattr(obj, "has_level_set", False) for obj in self.objects + ) + + def level_set(self, points): + """The minimum of the objects' level sets: the level set of their union.""" + if not self.has_level_set: + raise NotImplementedError("Every collection object needs a level set") + points = _component_major(points, self.ndim) + values = self.objects[0].level_set(points) + for obj in self.objects[1:]: + values = xp.minimum(values, obj.level_set(points)) + return values + def evaluate(self, *args, quantity="labels", **kwargs): return super().evaluate(*args, quantity=quantity, **kwargs) diff --git a/src/simgeometry/crossings.py b/src/simgeometry/crossings.py index ec126ca..0c174f7 100644 --- a/src/simgeometry/crossings.py +++ b/src/simgeometry/crossings.py @@ -10,6 +10,9 @@ combinations, collections, function objects, ...) is sampled: membership is tested at ``samples`` points along each step and the first change is bisected, so features thinner than ``1 / samples`` of a step can be missed, and the normals are estimated. +For a shape with a level set (cylinders, tori, Booleans of primitives, ...) the first +change is refined by Illinois steps on the level set instead, and the normal is its +gradient. Conventions: fractions lie in ``(0, 1]``. A crossing at the very start of a step (fraction below :data:`START_TOLERANCE`) is not reported, so a particle that stops @@ -29,6 +32,7 @@ import numpy as np import scope_profiler as sp +from simgeometry._level_set import has_level_set, illinois_roots, level_set_gradient from simgeometry._profiling import _PROFILE_TAGS from simgeometry.shapes import _component_major @@ -258,14 +262,66 @@ def _sampled(shape, a, d, samples, iterations): lo = ts[index] hi = ts[index + 1] start_state = xp.take_along_axis(state, index[:, None], axis=1)[:, 0] - for _ in range(iterations): - mid = 0.5 * (lo + hi) - same = _contains(shape, a + mid[None, :] * d) == start_state - lo = xp.where(same, mid, lo) - hi = xp.where(same, hi, mid) - t = 0.5 * (lo + hi) + t = xp.full(n, xp.nan) + rest = xp.flatnonzero(has) # segments without a membership change need no search + level = has_level_set(shape) + if level: + t, rest = _level_set_refined(shape, a, d, has, lo, hi, iterations) + if rest.shape[0]: + lo, hi, start_state = lo[rest], hi[rest], start_state[rest] + a_rest, d_rest = a[:, rest], d[:, rest] + for _ in range(iterations): + mid = 0.5 * (lo + hi) + same = _contains(shape, a_rest + mid[None, :] * d_rest) == start_state + lo = xp.where(same, mid, lo) + hi = xp.where(same, hi, mid) + t[rest] = 0.5 * (lo + hi) t = xp.where(has & (t > START_TOLERANCE), t, xp.inf) - return t, _estimated_normal(shape, a + xp.where(has, t, 0.0)[None, :] * d) + point = a + xp.where(has, t, 0.0)[None, :] * d + if level: + return t, _level_set_normal(shape, point) + return t, _estimated_normal(shape, point) + + +def _level_set_refined(shape, a, d, has, lo, hi, iterations): + """Crossings ``(n,)`` in the sampled brackets where the level set changes sign. + + Returns the fractions (NaN where not refined) and the indices of the segments + left for bisection: those whose level set does not change sign over the bracket + and those the Illinois steps did not settle. + """ + n = a.shape[1] + t = xp.full(n, xp.nan) + f_lo = xp.asarray(shape.level_set(a + lo[None, :] * d), dtype=float) + f_hi = xp.asarray(shape.level_set(a + hi[None, :] * d), dtype=float) + secant = has & (f_lo * f_hi < 0) + chosen = xp.flatnonzero(secant) + if chosen.shape[0]: + roots, _, _, done = illinois_roots( + lambda p: xp.asarray(shape.level_set(p), dtype=float), + a[:, chosen], + d[:, chosen], + lo[chosen], + hi[chosen], + f_lo[chosen], + f_hi[chosen], + 2.0**-iterations, + array_xp=xp, + ) + t[chosen[done]] = roots[done] + secant[chosen[~done]] = False + return t, xp.flatnonzero(has & ~secant) + + +def _level_set_normal(shape, points): + """Unit gradient of the level set at the points: out of the contains-region.""" + extent = np.asarray(xp.to_numpy(shape.upper_bound), float) - np.asarray( + xp.to_numpy(shape.lower_bound), float + ) + scale = float(np.max(extent)) if np.all(np.isfinite(extent)) else 1.0 + gradient = level_set_gradient(shape, points, 1e-7 * scale, array_xp=xp) + length = xp.sqrt(xp.sum(gradient * gradient, axis=0)) + return gradient / xp.where(length > 0, length, xp.nan)[None, :] def _contains(shape, points): diff --git a/src/simgeometry/evaluation.py b/src/simgeometry/evaluation.py index ec2e002..cca28c7 100644 --- a/src/simgeometry/evaluation.py +++ b/src/simgeometry/evaluation.py @@ -90,8 +90,17 @@ def evaluate( ) elif quantity == "signed_distance": values = shape.signed_distance(points) + elif quantity == "level_set": + # Shapes without an analytic level set fall back to the sampled distance. + values = ( + shape.level_set(points) + if getattr(shape, "has_level_set", False) + else shape.signed_distance(points) + ) else: - raise ValueError("quantity must be occupancy, labels, or signed_distance") + raise ValueError( + "quantity must be occupancy, labels, signed_distance, or level_set" + ) result = xr.DataArray( np.asarray(values).reshape(broadcast[0].shape), dims=broadcast[0].dims, @@ -106,3 +115,29 @@ def evaluate( } result.attrs["sampling"] = "direct geometry queries; host arrays; no MPI gather" return result + + +def level_set_field(shape, *, resolution=64, bounds=None): + """The level set on a 3D grid, in the layout of plasma-plots' 3D views. + + plasma-plots draws fields with logical dimensions ``eta1, eta2, eta3`` and + physical coordinates ``X, Y, Z``; here the logical coordinates run from 0 to 1 + along the Cartesian axes. ``field.plasma.plot.isosurface(values=[0])`` then + draws the shape's surface. Negative inside, like ``signed_distance``. + """ + if shape.ndim != 3: + raise ValueError("level_set_field requires three-dimensional geometry") + field = evaluate(shape, resolution=resolution, bounds=bounds, quantity="level_set") + dims = dict(zip(field.dims, ("eta1", "eta2", "eta3"))) + physical = { + upper: (dims[name], field[name].values) + for name, upper in zip(field.dims, ("X", "Y", "Z")) + } + field = field.drop_vars(list(dims)).rename(dims) + logical = { + eta: np.linspace(0.0, 1.0, field.sizes[eta]) for eta in ("eta1", "eta2", "eta3") + } + field = field.assign_coords(logical).assign_coords(physical) + field.name = "level set" + field.attrs["label"] = "level set" + return field diff --git a/src/simgeometry/mesh.py b/src/simgeometry/mesh.py index 9e77939..6ba00ef 100644 --- a/src/simgeometry/mesh.py +++ b/src/simgeometry/mesh.py @@ -93,7 +93,7 @@ def to_mesh(shape, *, resolution=64, bounds=None): from skimage.measure import find_contours, marching_cubes except ImportError as exc: raise ImportError("Surface meshing requires simgeometry[mesh]") from exc - from simgeometry.queries import BALLS, BOXES + from simgeometry.queries import level_values if bounds is None: lower, upper = np.asarray(shape.lower_bound), np.asarray(shape.upper_bound) @@ -103,23 +103,12 @@ def to_mesh(shape, *, resolution=64, bounds=None): dimensions = tuple(len(a) for a in axes) spacing = tuple(float(a[1] - a[0]) for a in axes) points = np.stack([a.ravel() for a in np.meshgrid(*axes, indexing="ij")]) - # Continuous implicit functions improve primitive surface placement; generic - # membership masks have an uncertainty of roughly a sampling cell. - if isinstance(shape, BALLS): - values = shape.radius - np.linalg.norm( - points - np.asarray(shape.midpoint)[:, None], axis=0 - ) - elif isinstance(shape, BOXES): - centre = (np.asarray(shape.lower_bound) + shape.upper_bound) / 2 - half = (np.asarray(shape.upper_bound) - shape.lower_bound) / 2 - q = abs(points - centre[:, None]) - half[:, None] - values = -( - np.linalg.norm(np.maximum(q, 0), axis=0) + np.minimum(np.max(q, axis=0), 0) - ) + # A level set (continuous, positive inside once negated) places the surface + # between samples; a membership mask has an uncertainty of about a sampling cell. + if getattr(shape, "has_level_set", False): + values = -level_values(shape, points) else: values = mask(shape, points).astype(float) - 0.5 - if isinstance(shape, (*BALLS, *BOXES)) and shape.inverted: - values = -values values = values.reshape(dimensions) if not (values.min() < 0 < values.max()): return SurfaceMesh( diff --git a/src/simgeometry/objects/__init__.py b/src/simgeometry/objects/__init__.py index 4ffbcd5..a32c23c 100644 --- a/src/simgeometry/objects/__init__.py +++ b/src/simgeometry/objects/__init__.py @@ -5,7 +5,9 @@ from simgeometry.objects.aperture2d import Aperture2D from simgeometry.objects.aperture3d import Aperture3D from simgeometry.objects.circle import Circle +from simgeometry.objects.cone import Cone from simgeometry.objects.cuboid import Cuboid +from simgeometry.objects.cylinder import Cylinder from simgeometry.objects.function_object import FunctionObject2D, FunctionObject3D from simgeometry.objects.line import Line from simgeometry.objects.polygon import Polygon @@ -14,14 +16,18 @@ # 3D objects from simgeometry.objects.sphere import Sphere +from simgeometry.objects.torus import Torus from simgeometry.objects.triangle import Triangle +from simgeometry.objects.tube import Tube __all__ = [ # 2D objects "Aperture2D", "Aperture3D", "Circle", + "Cone", "Cuboid", + "Cylinder", "FunctionObject2D", "FunctionObject3D", # 1D objects @@ -31,5 +37,7 @@ "Rectangle", # 3D objects "Sphere", + "Torus", "Triangle", + "Tube", ] diff --git a/src/simgeometry/objects/aperture2d.py b/src/simgeometry/objects/aperture2d.py index 14b70f1..27c0282 100644 --- a/src/simgeometry/objects/aperture2d.py +++ b/src/simgeometry/objects/aperture2d.py @@ -19,9 +19,13 @@ class Aperture2D(Shape2D): the centre line. One plate is the polygon at ``position + radial``, the other its mirror image at ``position - radial``. :meth:`slit` builds the common rectangular plates. + + Its :meth:`level_set` is the signed distance to the plates' edges, exact while the + plates do not touch (every radial coordinate positive). """ exact_outline = True + has_level_set = True def __init__( self, @@ -155,6 +159,15 @@ def contains_points(self, points: ndarray) -> ndarray: ) | self._upper_polygon.contains_points(points) return ~inside if self.inverted else inside + def _level_set(self, points: ndarray) -> ndarray: + """Signed distance to the edges of both plates.""" + return self._edge_level_set(points) + + @property + def level_set_exact(self) -> bool: + """Whether the plates are apart, so that the level set is exact.""" + return all(float(v[1]) > 0 for v in self.vertices) + @property def vertices(self) -> tuple[tuple[float, float], ...]: """Return the plate profile as ``(axial, radial)`` vertices.""" diff --git a/src/simgeometry/objects/axisymmetric.py b/src/simgeometry/objects/axisymmetric.py new file mode 100644 index 0000000..4c6f389 --- /dev/null +++ b/src/simgeometry/objects/axisymmetric.py @@ -0,0 +1,202 @@ +"""The common frame of the solids of revolution: Cylinder, Tube, Cone and Torus.""" + +from __future__ import annotations + +from abc import abstractmethod + +import cunumpy as xp +import numpy as np + +from simgeometry._types import Array, BoundsLike +from simgeometry.shapes import Shape3D, _component_major + + +def _vector(value: BoundsLike, name: str) -> np.ndarray: + """``value`` as a finite 3-vector.""" + vector = np.asarray(xp.to_numpy(xp.asarray(value, dtype=float)), dtype=float) + if vector.shape != (3,) or not np.all(np.isfinite(vector)): + raise ValueError(f"{name} must be a finite 3-vector") + return vector + + +class AxisymmetricShape(Shape3D): + """A solid of revolution about an axis through ``midpoint`` along ``axis``. + + Subclasses describe their profile in the half-plane of the axial coordinate + ``s`` (along ``axis``, zero at ``midpoint``) and the distance ``rho`` from the + axis. The level set is the exact signed distance of the profile there, which is + the exact 3D distance of the solid. A frame along a coordinate axis takes a + cheaper path than an oblique one. + """ + + has_level_set = True + level_set_exact = True + + def _set_frame(self, midpoint: np.ndarray, axis: np.ndarray) -> None: + """Set the centre and the unit axis of the frame.""" + norm = float(np.linalg.norm(axis)) + if norm == 0: + raise ValueError("The axis must have a nonzero length") + self._midpoint = np.asarray(midpoint, dtype=float) + self._axis = np.asarray(axis, dtype=float) / norm + along = np.flatnonzero(self._axis) + self._axis_index = ( + int(along[0]) + if along.size == 1 and abs(self._axis[along[0]]) == 1.0 + else None + ) + + @property + def axis(self) -> tuple[float, float, float]: + """The unit vector of the symmetry axis.""" + return tuple(float(a) for a in self._axis) + + @property + def midpoint(self) -> tuple[float, float, float]: + """The centre of the solid on its axis.""" + return tuple(float(c) for c in self._midpoint) + + def _disc_extent(self, radius: float) -> np.ndarray: + """Half extent per coordinate of a disc of ``radius`` normal to the axis.""" + return radius * np.sqrt(np.clip(1.0 - self._axis**2, 0.0, None)) + + def _axial_radial(self, points: Array, *, squared: bool = False): + """Axial coordinate ``s`` and distance from the axis (or its square) of points.""" + if self._axis_index is not None: + k = self._axis_index + s = (points[k] - float(self._midpoint[k])) * float(self._axis[k]) + others = [i for i in range(3) if i != k] + d0 = points[others[0]] - float(self._midpoint[others[0]]) + d1 = points[others[1]] - float(self._midpoint[others[1]]) + rho2 = d0 * d0 + d1 * d1 + else: + relative = points - xp.asarray(self._midpoint)[:, None] + axis = xp.asarray(self._axis)[:, None] + s = xp.sum(relative * axis, axis=0) + across = relative - s[None, :] * axis + rho2 = xp.sum(across * across, axis=0) + return s, (rho2 if squared else xp.sqrt(rho2)) + + @abstractmethod + def _inside(self, s: Array, rho2: Array) -> Array: + """Membership of the geometry from ``s`` and the squared distance ``rho2``.""" + + @abstractmethod + def _profile_distance(self, s: Array, rho: Array) -> Array: + """Exact signed distance of the profile at ``(s, rho)``.""" + + @abstractmethod + def _bounds(self) -> tuple[tuple[float, ...], tuple[float, ...]]: + """The lower and upper corners of the bounding box.""" + + def _update_bounds(self) -> None: + """Recompute the bounding box after the frame moved.""" + self.set_bounding_box(*self._bounds()) + + def contains_points(self, points: Array) -> Array: + """Vectorised :meth:`contains`: a mask of the ``(3, n)`` points inside.""" + points = _component_major(points, 3) + s, rho2 = self._axial_radial(points, squared=True) + inside = self._inside(s, rho2) + return ~inside if self.inverted else inside + + def contains(self, coords: Array) -> bool: + """ + Check if the given coordinates are inside the solid. + + Parameters: + coords (list or tuple): Coordinates (x, y, z). + + Returns: + bool: True if the point is inside the object, False otherwise. + """ + point = xp.asarray(coords, dtype=float).reshape(3, 1) + return bool(xp.to_numpy(self.contains_points(point))[0]) + + def _level_set(self, points: Array) -> Array: + """Exact signed distance to the solid.""" + s, rho = self._axial_radial(points) + return self._profile_distance(s, rho) + + def translate(self, offset: tuple[float, float, float]) -> None: + """ + Translate the solid by the given offset. + + Parameters: + offset (tuple): (dx, dy, dz) translation vector. + """ + self._midpoint = self._midpoint + _vector(offset, "offset") + self._update_bounds() + + def mirror(self, axis: int, position: float) -> None: + """ + Mirror the solid across the plane where coordinate ``axis`` is ``position``. + + Parameters: + axis (int): Axis index (0 for x-axis, 1 for y-axis, 2 for z-axis). + position (float): Position along the axis to mirror. + """ + if axis < 0 or axis > 2: + raise ValueError("Axis must be 0 (x-axis), 1 (y-axis), or 2 (z-axis).") + midpoint, direction = self._midpoint.copy(), self._axis.copy() + midpoint[axis] = 2 * position - midpoint[axis] + direction[axis] = -direction[axis] + self._set_frame(midpoint, direction) + self._update_bounds() + + +class CappedAxisymmetricShape(AxisymmetricShape): + """A solid of revolution between the end points ``start`` and ``end``. + + The axis runs from ``start`` to ``end``; the solid is cut off by the planes + through them normal to the axis. + """ + + def _set_ends(self, start: BoundsLike, end: BoundsLike) -> None: + """Set the frame from the two end points of the axis.""" + start, end = _vector(start, "start"), _vector(end, "end") + length = float(np.linalg.norm(end - start)) + if length == 0: + raise ValueError("start and end must differ") + self._half_length = 0.5 * length + self._set_frame(0.5 * (start + end), end - start) + + @property + def start(self) -> tuple[float, float, float]: + """The centre of the end face at the start of the axis.""" + return tuple(float(c) for c in self._midpoint - self._half_length * self._axis) + + @property + def end(self) -> tuple[float, float, float]: + """The centre of the end face at the end of the axis.""" + return tuple(float(c) for c in self._midpoint + self._half_length * self._axis) + + @property + def length(self) -> float: + """The distance from ``start`` to ``end``.""" + return 2.0 * self._half_length + + @abstractmethod + def _end_radii(self) -> tuple[float, float]: + """The outer radius at ``start`` and at ``end``.""" + + def _bounds(self) -> tuple[tuple[float, ...], tuple[float, ...]]: + """The box around the two end discs, which encloses their convex hull. + + A rounded solid is the solid shrunk by its ``fillet``, widened by a ball of + that radius: the box of the shrunk end discs, widened by the fillet. + """ + fillet = float(getattr(self, "fillet", 0.0)) + r_start, r_end = (r - fillet for r in self._end_radii()) + start = np.asarray(self.start) + fillet * self._axis + end = np.asarray(self.end) - fillet * self._axis + lower = np.minimum( + start - self._disc_extent(r_start), end - self._disc_extent(r_end) + ) + upper = np.maximum( + start + self._disc_extent(r_start), end + self._disc_extent(r_end) + ) + return ( + tuple(float(v) for v in lower - fillet), + tuple(float(v) for v in upper + fillet), + ) diff --git a/src/simgeometry/objects/circle.py b/src/simgeometry/objects/circle.py index 412bcef..1bf5a92 100644 --- a/src/simgeometry/objects/circle.py +++ b/src/simgeometry/objects/circle.py @@ -10,7 +10,13 @@ class Circle(Shape2D): - """Represent a circular geometry object.""" + """Represent a circular geometry object. + + Its :meth:`level_set` is the exact signed distance ``|p - midpoint| - radius``. + """ + + has_level_set = True + level_set_exact = True def __init__( self, @@ -136,3 +142,8 @@ def contains_points(self, points: Array) -> Array: distance_squared = xp.sum((points - midpoint) ** 2, axis=0) inside = distance_squared <= self.radius**2 return ~inside if self.inverted else inside + + def _level_set(self, points: Array) -> Array: + """Exact signed distance to the circle.""" + midpoint = xp.asarray(self.midpoint, dtype=float)[:, None] + return xp.sqrt(xp.sum((points - midpoint) ** 2, axis=0)) - self.radius diff --git a/src/simgeometry/objects/cone.py b/src/simgeometry/objects/cone.py new file mode 100644 index 0000000..27e3005 --- /dev/null +++ b/src/simgeometry/objects/cone.py @@ -0,0 +1,92 @@ +"""Cones and frustums of revolution along any axis.""" + +from __future__ import annotations + +import cunumpy as xp +import numpy as np + +from simgeometry._level_set import capped_cone_distance +from simgeometry._types import Array, BoundsLike, ConfigValue +from simgeometry.objects.axisymmetric import CappedAxisymmetricShape + + +class Cone(CappedAxisymmetricShape): + """A frustum with radius ``start_radius`` at ``start`` and ``end_radius`` at ``end``. + + One radius may be zero, which gives a cone with its tip there. The + :meth:`level_set` is the exact signed distance. + + Example + ------- + A cone of base radius 0.5 at z = 0 with its tip at z = 1:: + + tip = Cone((0, 0, 0), (0, 0, 1), 0.5, 0.0) + """ + + def __init__( + self, + start: BoundsLike, + end: BoundsLike, + start_radius: float, + end_radius: float, + bc: ConfigValue = None, + inverted: bool = False, + ) -> None: + """ + Initialize a cone or frustum in 3D. + + Parameters: + start (list or tuple): Centre of the end face at the start of the axis. + end (list or tuple): Centre of the end face at the end of the axis. + start_radius (float): Radius at ``start``, zero or positive. + end_radius (float): Radius at ``end``, zero or positive. + bc (optional): Boundary condition information. + inverted (bool, optional): Cover everything except the cone. + """ + self._set_ends(start, end) + if not ( + start_radius >= 0 and end_radius >= 0 and start_radius + end_radius > 0 + ): + raise ValueError("A cone needs radii >= 0, at least one of them positive") + self.start_radius = float(start_radius) + self.end_radius = float(end_radius) + self._bc = bc + self._inverted = bool(inverted) + super().__init__(*self._bounds()) + + def _end_radii(self) -> tuple[float, float]: + return self.start_radius, self.end_radius + + def copy(self) -> Cone: + """Create a copy of the cone.""" + return Cone( + self.start, + self.end, + self.start_radius, + self.end_radius, + bc=self._bc, + inverted=self._inverted, + ) + + def _inside(self, s: Array, rho2: Array) -> Array: + h = self._half_length + radius = self.start_radius + (self.end_radius - self.start_radius) * ( + (s + h) / (2 * h) + ) + return (xp.abs(s) <= h) & (rho2 <= radius * radius) + + def _profile_distance(self, s: Array, rho: Array) -> Array: + return capped_cone_distance( + rho, s, self._half_length, self.start_radius, self.end_radius + ) + + def volume(self) -> float: + """Calculate the volume of the frustum.""" + r1, r2 = self.start_radius, self.end_radius + return float(np.pi * self.length / 3 * (r1**2 + r1 * r2 + r2**2)) + + def surface_area(self) -> float: + """Calculate the surface area of the frustum, with its end discs.""" + r1, r2 = self.start_radius, self.end_radius + slant = np.hypot(self.length, r1 - r2) + return float(np.pi * (r1 + r2) * slant + np.pi * (r1**2 + r2**2)) diff --git a/src/simgeometry/objects/cuboid.py b/src/simgeometry/objects/cuboid.py index 8dd00c0..0e36084 100644 --- a/src/simgeometry/objects/cuboid.py +++ b/src/simgeometry/objects/cuboid.py @@ -6,12 +6,19 @@ import cunumpy as xp +from simgeometry._level_set import box_distance from simgeometry._types import Array, BoundsLike, ConfigValue from simgeometry.shapes import Shape3D, _component_major class Cuboid(Shape3D): - """Represent a cuboid geometry object.""" + """Represent a cuboid geometry object. + + Its :meth:`level_set` is the exact signed distance to the box. + """ + + has_level_set = True + level_set_exact = True def __init__( self, @@ -159,6 +166,12 @@ def contains_points(self, points: Array) -> Array: inside = xp.all((points >= lower) & (points <= upper), axis=0) return ~inside if self.inverted else inside + def _level_set(self, points: Array) -> Array: + """Exact signed distance to the cuboid.""" + lower = xp.asarray(self.lower_bound, dtype=float)[:, None] + upper = xp.asarray(self.upper_bound, dtype=float)[:, None] + return box_distance(points - 0.5 * (lower + upper), 0.5 * (upper - lower)) + def volume(self) -> float: """Calculate the volume of the cuboid.""" return float(xp.prod(self.side_lengths)) diff --git a/src/simgeometry/objects/cylinder.py b/src/simgeometry/objects/cylinder.py new file mode 100644 index 0000000..d69cd7d --- /dev/null +++ b/src/simgeometry/objects/cylinder.py @@ -0,0 +1,94 @@ +"""Solid cylinders along any axis, optionally with rounded edges.""" + +from __future__ import annotations + +import cunumpy as xp +import numpy as np + +from simgeometry._level_set import rectangle_distance +from simgeometry._types import Array, BoundsLike, ConfigValue +from simgeometry.objects.axisymmetric import CappedAxisymmetricShape + + +class Cylinder(CappedAxisymmetricShape): + """A solid circular cylinder with its axis from ``start`` to ``end``. + + ``fillet`` rounds the two circular edges with that radius (at most the radius + and half the length). The :meth:`level_set` is the exact signed distance, with + or without the fillet. + + Example + ------- + A rod of radius 0.1 along z, from z = -1 to z = 1:: + + rod = Cylinder((0, 0, -1), (0, 0, 1), 0.1) + """ + + def __init__( + self, + start: BoundsLike, + end: BoundsLike, + radius: float, + bc: ConfigValue = None, + inverted: bool = False, + *, + fillet: float = 0.0, + ) -> None: + """ + Initialize a cylinder in 3D. + + Parameters: + start (list or tuple): Centre of the end face at the start of the axis. + end (list or tuple): Centre of the end face at the end of the axis. + radius (float): Radius of the cylinder. + bc (optional): Boundary condition information. + inverted (bool, optional): Cover everything except the cylinder. + fillet (float, optional): Radius of the rounded circular edges. + """ + self._set_ends(start, end) + if not radius > 0: + raise ValueError("Radius must be a positive number.") + if not 0 <= fillet <= min(radius, self._half_length): + raise ValueError("fillet must be between 0 and min(radius, length / 2)") + self.radius = float(radius) + self.fillet = float(fillet) + self._bc = bc + self._inverted = bool(inverted) + super().__init__(*self._bounds()) + + def _end_radii(self) -> tuple[float, float]: + return self.radius, self.radius + + def copy(self) -> Cylinder: + """Create a copy of the cylinder.""" + return Cylinder( + self.start, + self.end, + self.radius, + bc=self._bc, + inverted=self._inverted, + fillet=self.fillet, + ) + + def _inside(self, s: Array, rho2: Array) -> Array: + if self.fillet > 0: + return self._profile_distance(s, xp.sqrt(rho2)) <= 0 + return (xp.abs(s) <= self._half_length) & (rho2 <= self.radius**2) + + def _profile_distance(self, s: Array, rho: Array) -> Array: + f = self.fillet + return rectangle_distance(rho, s, self.radius - f, self._half_length - f) - f + + def volume(self) -> float: + """Calculate the volume of the cylinder, less the rounded edges.""" + r, h, f = self.radius, self._half_length, self.fillet + removed = 4 * np.pi * f**2 * ((1 - np.pi / 4) * (r - f) + f / 6) + return float(2 * np.pi * r**2 * h - removed) + + def surface_area(self) -> float: + """Calculate the surface area of the cylinder, with its rounded edges.""" + r, h, f = self.radius, self._half_length, self.fillet + caps = 2 * np.pi * (r - f) ** 2 + side = 4 * np.pi * r * (h - f) + rounds = 2 * np.pi**2 * f * (r - f) + 4 * np.pi * f**2 + return float(caps + side + rounds) diff --git a/src/simgeometry/objects/function_object.py b/src/simgeometry/objects/function_object.py index d9e7c56..a8d8ac6 100644 --- a/src/simgeometry/objects/function_object.py +++ b/src/simgeometry/objects/function_object.py @@ -114,19 +114,22 @@ class FunctionObject3D(Shape3D): returns ``True``. A bounding box must be supplied by the caller because it cannot in general be derived from an arbitrary function. + A function object has no level set, so crossings and distances are found by + sampling its membership. Prefer a primitive where one fits (a torus is + :class:`~simgeometry.Torus`, a ring electrode :class:`~simgeometry.Tube`). + Example ------- - Create a solid torus with major radius 3 and tube radius 1:: + Create a rounded cube, the superellipsoid ``x**4 + y**4 + z**4 <= 1``:: - def torus(coords): + def rounded_cube(coords): x, y, z = coords - r = (x**2 + y**2)**0.5 - return (r - 3.0)**2 + z**2 <= 1.0**2 + return x**4 + y**4 + z**4 <= 1.0 obj = FunctionObject3D( - func=torus, - lower_bound=(-4.0, -4.0, -1.0), - upper_bound=(4.0, 4.0, 1.0), + func=rounded_cube, + lower_bound=(-1.0, -1.0, -1.0), + upper_bound=(1.0, 1.0, 1.0), ) """ diff --git a/src/simgeometry/objects/line.py b/src/simgeometry/objects/line.py index 52dce0b..2cda1dc 100644 --- a/src/simgeometry/objects/line.py +++ b/src/simgeometry/objects/line.py @@ -9,7 +9,13 @@ class Line(Shape1D): - """Represent a line geometry object.""" + """Represent a line geometry object. + + Its :meth:`level_set` is the exact signed distance to the interval. + """ + + has_level_set = True + level_set_exact = True def __init__( self, @@ -125,3 +131,8 @@ def contains_points(self, points: Array) -> Array: points = _component_major(points, 1) inside = (self.lower_bound[0] <= points[0]) & (points[0] <= self.upper_bound[0]) return ~inside if self.inverted else inside + + def _level_set(self, points: Array) -> Array: + """Exact signed distance to the interval.""" + lower, upper = float(self.lower_bound[0]), float(self.upper_bound[0]) + return xp.abs(points[0] - 0.5 * (lower + upper)) - 0.5 * (upper - lower) diff --git a/src/simgeometry/objects/polygon.py b/src/simgeometry/objects/polygon.py index 2774d72..61ec3fa 100644 --- a/src/simgeometry/objects/polygon.py +++ b/src/simgeometry/objects/polygon.py @@ -12,9 +12,15 @@ class Polygon(Shape2D): - """Represent a polygon geometry object.""" + """Represent a polygon geometry object. + + Its :meth:`level_set` is the exact signed distance: the distance to the nearest + edge, negative where :meth:`contains_points` is true. It costs one pass per edge. + """ exact_outline = True + has_level_set = True + level_set_exact = True def __init__( self, @@ -180,3 +186,7 @@ def contains_points(self, points: ndarray) -> ndarray: inside &= (points[0] >= lower[0]) & (points[0] < upper[0]) inside &= (points[1] >= lower[1]) & (points[1] < upper[1]) return ~inside if self.inverted else inside + + def _level_set(self, points: ndarray) -> ndarray: + """Exact signed distance to the polygon's edges.""" + return self._edge_level_set(points) diff --git a/src/simgeometry/objects/rectangle.py b/src/simgeometry/objects/rectangle.py index d7b60e8..eff144e 100644 --- a/src/simgeometry/objects/rectangle.py +++ b/src/simgeometry/objects/rectangle.py @@ -7,14 +7,20 @@ import cunumpy as xp import numpy as np +from simgeometry._level_set import box_distance from simgeometry._types import Array, ConfigValue from simgeometry.shapes import Shape2D, _component_major class Rectangle(Shape2D): - """Represent a rectangular geometry object.""" + """Represent a rectangular geometry object. + + Its :meth:`level_set` is the exact signed distance to the box. + """ exact_outline = True + has_level_set = True + level_set_exact = True def __init__( self, @@ -128,3 +134,9 @@ def contains_points(self, points: Array) -> Array: upper = xp.asarray(self.bounding_box.upper_bound, dtype=float)[:, None] inside = xp.all((points >= lower) & (points < upper), axis=0) return ~inside if self.inverted else inside + + def _level_set(self, points: Array) -> Array: + """Exact signed distance to the rectangle.""" + lower = xp.asarray(self.bounding_box.lower_bound, dtype=float)[:, None] + upper = xp.asarray(self.bounding_box.upper_bound, dtype=float)[:, None] + return box_distance(points - 0.5 * (lower + upper), 0.5 * (upper - lower)) diff --git a/src/simgeometry/objects/sphere.py b/src/simgeometry/objects/sphere.py index 3c6c304..18b500a 100644 --- a/src/simgeometry/objects/sphere.py +++ b/src/simgeometry/objects/sphere.py @@ -9,7 +9,13 @@ class Sphere(Shape3D): - """Represent a spherical geometry object.""" + """Represent a spherical geometry object. + + Its :meth:`level_set` is the exact signed distance ``|p - midpoint| - radius``. + """ + + has_level_set = True + level_set_exact = True def __init__( self, @@ -172,6 +178,11 @@ def contains_points(self, points: Array) -> Array: inside = distance_squared <= self.radius**2 return ~inside if self.inverted else inside + def _level_set(self, points: Array) -> Array: + """Exact signed distance to the sphere.""" + midpoint = xp.asarray(self.midpoint, dtype=float)[:, None] + return xp.sqrt(xp.sum((points - midpoint) ** 2, axis=0)) - self.radius + def volume(self) -> float: """Calculate the volume of the sphere.""" return (4 / 3) * xp.pi * self.radius**3 diff --git a/src/simgeometry/objects/torus.py b/src/simgeometry/objects/torus.py new file mode 100644 index 0000000..a5980fe --- /dev/null +++ b/src/simgeometry/objects/torus.py @@ -0,0 +1,86 @@ +"""Solid tori about any axis.""" + +from __future__ import annotations + +import cunumpy as xp +import numpy as np + +from simgeometry._types import Array, BoundsLike, ConfigValue +from simgeometry.objects.axisymmetric import AxisymmetricShape, _vector + + +class Torus(AxisymmetricShape): + """A solid torus: the points within ``minor_radius`` of a circle of ``major_radius``. + + The circle is centred at ``midpoint`` in the plane normal to ``axis``. The + :meth:`level_set` is the exact signed distance. + + Example + ------- + A torus with major radius 3 and tube radius 1 about the z axis:: + + ring = Torus((0, 0, 0), 3.0, 1.0) + """ + + def __init__( + self, + midpoint: BoundsLike, + major_radius: float, + minor_radius: float, + axis: BoundsLike = (0.0, 0.0, 1.0), + bc: ConfigValue = None, + inverted: bool = False, + ) -> None: + """ + Initialize a torus in 3D. + + Parameters: + midpoint (list or tuple): Centre of the torus. + major_radius (float): Radius of the centre circle of the tube. + minor_radius (float): Radius of the tube, at most ``major_radius``. + axis (list or tuple, optional): Symmetry axis, normal to the centre circle. + bc (optional): Boundary condition information. + inverted (bool, optional): Cover everything except the torus. + """ + if not 0 < minor_radius <= major_radius: + raise ValueError("A torus needs 0 < minor_radius <= major_radius") + self._set_frame(_vector(midpoint, "midpoint"), _vector(axis, "axis")) + self.major_radius = float(major_radius) + self.minor_radius = float(minor_radius) + self._bc = bc + self._inverted = bool(inverted) + super().__init__(*self._bounds()) + + def _bounds(self) -> tuple[tuple[float, ...], tuple[float, ...]]: + """The centre circle's box, widened by the tube radius.""" + extent = self._disc_extent(self.major_radius) + self.minor_radius + lower = self._midpoint - extent + upper = self._midpoint + extent + return tuple(float(v) for v in lower), tuple(float(v) for v in upper) + + def copy(self) -> Torus: + """Create a copy of the torus.""" + return Torus( + self.midpoint, + self.major_radius, + self.minor_radius, + axis=self.axis, + bc=self._bc, + inverted=self._inverted, + ) + + def _inside(self, s: Array, rho2: Array) -> Array: + across = xp.sqrt(rho2) - self.major_radius + return across * across + s * s <= self.minor_radius**2 + + def _profile_distance(self, s: Array, rho: Array) -> Array: + across = rho - self.major_radius + return xp.sqrt(across * across + s * s) - self.minor_radius + + def volume(self) -> float: + """Calculate the volume of the torus.""" + return float(2 * np.pi**2 * self.major_radius * self.minor_radius**2) + + def surface_area(self) -> float: + """Calculate the surface area of the torus.""" + return float(4 * np.pi**2 * self.major_radius * self.minor_radius) diff --git a/src/simgeometry/objects/triangle.py b/src/simgeometry/objects/triangle.py index be46341..2ac2825 100644 --- a/src/simgeometry/objects/triangle.py +++ b/src/simgeometry/objects/triangle.py @@ -11,9 +11,14 @@ class Triangle(Shape2D): - """Represent a triangular geometry object.""" + """Represent a triangular geometry object. + + Its :meth:`level_set` is the exact signed distance to its edges. + """ exact_outline = True + has_level_set = True + level_set_exact = True def __init__( self, @@ -139,6 +144,10 @@ def contains_points(self, points: Array) -> Array: inside = in_box & (u >= 0) & (v >= 0) & (u + v <= 1) return ~inside if self.inverted else inside + def _level_set(self, points: Array) -> Array: + """Exact signed distance to the triangle's edges.""" + return self._edge_level_set(points) + def _corner_loops(self) -> list[np.ndarray]: """The three vertices as one ``(2, 3)`` loop.""" return [ diff --git a/src/simgeometry/objects/tube.py b/src/simgeometry/objects/tube.py new file mode 100644 index 0000000..856aad2 --- /dev/null +++ b/src/simgeometry/objects/tube.py @@ -0,0 +1,123 @@ +"""Hollow cylinders (tubes, ring electrodes) along any axis, optionally rounded.""" + +from __future__ import annotations + +import cunumpy as xp +import numpy as np + +from simgeometry._level_set import rectangle_distance +from simgeometry._types import Array, BoundsLike, ConfigValue +from simgeometry.objects.axisymmetric import CappedAxisymmetricShape + + +class Tube(CappedAxisymmetricShape): + """A hollow cylinder between ``inner_radius`` and ``outer_radius``, from ``start`` to ``end``. + + A short tube is a ring electrode; a long one a drift tube or a chamber wall. + ``fillet`` rounds its four circular edges (at most half the wall thickness and + half the length). The :meth:`level_set` is the exact signed distance, with or + without the fillet. + + Example + ------- + A ring of bore radius 1 and outer radius 1.5, 0.4 long, centred at z = 0:: + + ring = Tube((0, 0, -0.2), (0, 0, 0.2), 1.0, 1.5) + """ + + def __init__( + self, + start: BoundsLike, + end: BoundsLike, + inner_radius: float, + outer_radius: float, + bc: ConfigValue = None, + inverted: bool = False, + *, + fillet: float = 0.0, + ) -> None: + """ + Initialize a tube in 3D. + + Parameters: + start (list or tuple): Centre of the end face at the start of the axis. + end (list or tuple): Centre of the end face at the end of the axis. + inner_radius (float): Radius of the bore, positive. + outer_radius (float): Outer radius, larger than ``inner_radius``. + bc (optional): Boundary condition information. + inverted (bool, optional): Cover everything except the tube. + fillet (float, optional): Radius of the rounded circular edges. + """ + self._set_ends(start, end) + if not 0 < inner_radius < outer_radius: + raise ValueError("A tube needs 0 < inner_radius < outer_radius") + thickness = outer_radius - inner_radius + if not 0 <= fillet <= min(0.5 * thickness, self._half_length): + raise ValueError( + "fillet must be between 0 and min(wall thickness, length) / 2" + ) + self.inner_radius = float(inner_radius) + self.outer_radius = float(outer_radius) + self.fillet = float(fillet) + self._bc = bc + self._inverted = bool(inverted) + super().__init__(*self._bounds()) + + def _end_radii(self) -> tuple[float, float]: + return self.outer_radius, self.outer_radius + + def copy(self) -> Tube: + """Create a copy of the tube.""" + return Tube( + self.start, + self.end, + self.inner_radius, + self.outer_radius, + bc=self._bc, + inverted=self._inverted, + fillet=self.fillet, + ) + + def _inside(self, s: Array, rho2: Array) -> Array: + if self.fillet > 0: + return self._profile_distance(s, xp.sqrt(rho2)) <= 0 + return ( + (xp.abs(s) <= self._half_length) + & (rho2 >= self.inner_radius**2) + & (rho2 <= self.outer_radius**2) + ) + + def _profile_distance(self, s: Array, rho: Array) -> Array: + f = self.fillet + middle = 0.5 * (self.inner_radius + self.outer_radius) + half_thickness = 0.5 * (self.outer_radius - self.inner_radius) + return ( + rectangle_distance( + rho - middle, s, half_thickness - f, self._half_length - f + ) + - f + ) + + def volume(self) -> float: + """Calculate the volume of the tube, less the rounded edges.""" + ri, ro, h, f = ( + self.inner_radius, + self.outer_radius, + self._half_length, + self.fillet, + ) + removed = 4 * np.pi * f**2 * (1 - np.pi / 4) * (ro + ri) + return float(2 * np.pi * h * (ro**2 - ri**2) - removed) + + def surface_area(self) -> float: + """Calculate the surface area of the tube, inside and out, with its rounded edges.""" + ri, ro, h, f = ( + self.inner_radius, + self.outer_radius, + self._half_length, + self.fillet, + ) + sides = 4 * np.pi * (ro + ri) * (h - f) + caps = 2 * np.pi * ((ro - f) ** 2 - (ri + f) ** 2) + rounds = 2 * np.pi**2 * f * (ro + ri) + return float(sides + caps + rounds) diff --git a/src/simgeometry/operations.py b/src/simgeometry/operations.py index 441bff9..d00b9cf 100644 --- a/src/simgeometry/operations.py +++ b/src/simgeometry/operations.py @@ -26,8 +26,14 @@ class BooleanShape(Shape): Bounds conservatively enclose operands. ``inverted`` indicates the result may be unbounded, so sampling requires explicit bounds in that case. + + When both operands have a level set, so does the result: the minimum (union), + maximum (intersection) or ``max(a, -b)`` (difference) of theirs. That is a valid + level set and a bound on the distance, but not the exact distance. """ + level_set_exact = False + def __init__(self, left, right, operation): if left.ndim != right.ndim: raise ValueError("Boolean operands must have the same dimension") @@ -51,6 +57,26 @@ def contains_points(self, points): return a & b return a & ~b + @property + def has_level_set(self): + """Whether both operands have a level set.""" + return bool( + getattr(self.left, "has_level_set", False) + and getattr(self.right, "has_level_set", False) + ) + + def level_set(self, points): + """Min/max combination of the operands' level sets; see the class docstring.""" + if not self.has_level_set: + raise NotImplementedError("Both Boolean operands need a level set") + points = _component_major(points, self.ndim) + a, b = self.left.level_set(points), self.right.level_set(points) + if self.operation == "union": + return xp.minimum(a, b) + if self.operation == "intersection": + return xp.maximum(a, b) + return xp.maximum(a, -b) + def contains(self, coords): return bool( xp.to_numpy(self.contains_points(np.asarray(coords).reshape(self.ndim, 1)))[ @@ -164,7 +190,13 @@ def _shapely_ring(ring, *, counter_clockwise): class TransformedShape(Shape): - """Independent affine image of a source, mapping p to matrix @ p + offset.""" + """Independent affine image of a source, mapping p to matrix @ p + offset. + + The level set is the source's at the mapped-back point, times the smallest + singular value of ``matrix``, which keeps it a bound on the distance. Rigid + motions and uniform scalings (similarities) keep an exact level set exact; + nonuniform scalings and shears do not. + """ def __init__(self, source, matrix, offset=None): self.source = copy.deepcopy(source) @@ -183,6 +215,9 @@ def __init__(self, source, matrix, offset=None): except np.linalg.LinAlgError as exc: raise ValueError("Transform must be invertible") from exc vertices = self.matrix @ corners(source) + self.offset[:, None] + singular = np.linalg.svd(self.matrix, compute_uv=False) + self._stretch = float(singular.min()) + self._similarity = bool(np.allclose(singular, self._stretch, rtol=1e-12)) self._inverted = source.inverted self._bc = getattr(source, "_bc", None) self._bc_type = getattr(source, "_bc_type", "dirichlet") @@ -196,6 +231,24 @@ def contains_points(self, points): def contains(self, coords): return self.source.contains(self.inverse @ (np.asarray(coords) - self.offset)) + @property + def has_level_set(self): + """Whether the source has a level set.""" + return bool(getattr(self.source, "has_level_set", False)) + + @property + def level_set_exact(self): + """Exact for an exact source under a rigid motion or uniform scaling.""" + return bool(getattr(self.source, "level_set_exact", False) and self._similarity) + + def level_set(self, points): + """The source's level set, scaled by the smallest stretch of the transform.""" + if not self.has_level_set: + raise NotImplementedError(f"{type(self.source).__name__} has no level set") + points = _component_major(points, self.ndim) + local = xp.asarray(self.inverse) @ (points - xp.asarray(self.offset)[:, None]) + return self._stretch * self.source.level_set(local) + def outlines(self, max_spacing=None): """The source's outline loops, transformed (2D only); see ``Shape2D.outlines``. @@ -374,6 +427,21 @@ def contains_points(self, points): def contains(self, coords): return self.source.contains(self._to_parent(np.atleast_1d(coords))) + #: A 3D distance restricted to a plane only bounds the in-plane distance. + level_set_exact = False + + @property + def has_level_set(self): + """Whether the sliced source has a level set.""" + return bool(getattr(self.source, "has_level_set", False)) + + def level_set(self, points): + """The source's level set on the plane: a bound on the in-plane distance.""" + if not self.has_level_set: + raise NotImplementedError(f"{type(self.source).__name__} has no level set") + points = np.asarray(xp.to_numpy(_component_major(points, self.ndim)), float) + return self.source.level_set(self._to_parent(points)) + def labels(self, points): if not hasattr(self.source, "labels"): return mask(self, points).astype(int) diff --git a/src/simgeometry/queries.py b/src/simgeometry/queries.py index b45ec10..00ac545 100644 --- a/src/simgeometry/queries.py +++ b/src/simgeometry/queries.py @@ -2,6 +2,9 @@ All coordinates and results here are NumPy host arrays. The bisection helper batches containment queries so GPU implementations can still accelerate them. +Shapes with a level set (``shape.has_level_set``) have their crossings refined by +Illinois (modified regula falsi) steps on it instead of bisection, and exact level +sets give ``signed_distance`` directly. """ from __future__ import annotations @@ -12,6 +15,7 @@ import numpy as np import scope_profiler as sp +from simgeometry._level_set import has_level_set, illinois_roots, level_set_gradient from simgeometry._profiling import _PROFILE_TAGS from simgeometry.objects import ( Circle, @@ -39,12 +43,25 @@ def points_array(points, ndim): return p, single +def level_values(shape, points): + """``shape.level_set`` of host points, as a host float array.""" + return np.asarray(xp.to_numpy(shape.level_set(points)), dtype=float) + + def intersection_fractions(obj, outside, inside, *, iterations=48, wrap=None): - """Bisect bracketed outside-to-inside edges; return raw, unclamped fractions. + """Locate bracketed outside-to-inside edge crossings; return raw, unclamped fractions. Endpoints have shape (ndim, n). Each segment must bracket a single crossing; use intersect_segment for multiple crossings or equal-membership endpoints. ``wrap`` maps periodic images into the domain for membership tests. + + Membership (``contains``) decides the brackets. If the object has a level set + and its values at the two ends have opposite signs, the crossing is its root, + found from the linear interpolation of the end values and a few Illinois + steps, to within ``2**-iterations`` of the edge (typically to round-off after + 3 to 6 level-set evaluations). Other edges, and any the secant steps do not + settle, are bisected ``iterations`` times on membership and return the end of + the final interval inside the object. """ outside, _ = points_array(outside, obj.ndim) inside, _ = points_array(inside, obj.ndim) @@ -57,14 +74,46 @@ def wrap(p): if mask(obj, wrap(outside)).any() or not mask(obj, wrap(inside)).all(): raise ValueError("Bisection requires outside-to-inside brackets") - lo, hi = np.zeros(outside.shape[1]), np.ones(outside.shape[1]) + n = outside.shape[1] + lo, hi = np.zeros(n), np.ones(n) step = inside - outside - for _ in range(iterations): - mid = (lo + hi) / 2 - hit = mask(obj, wrap(outside + mid * step)) - hi = np.where(hit, mid, hi) - lo = np.where(hit, lo, mid) - return hi + fractions = np.full(n, np.nan) + pending = np.ones(n, dtype=bool) + if has_level_set(obj) and n: + + def level(points): + return level_values(obj, wrap(points)) + + f_out, f_in = level(outside), level(inside) + secant = np.flatnonzero((f_out > 0) & (f_in < 0)) + if secant.size: + t, a, b, done = illinois_roots( + level, + outside[:, secant], + step[:, secant], + lo[secant], + hi[secant], + f_out[secant], + f_in[secant], + 2.0**-iterations, + ) + fractions[secant[done]] = t[done] + pending[secant[done]] = False + # unsettled edges keep their narrowed bracket for the bisection + lo[secant[~done]], hi[secant[~done]] = a[~done], b[~done] + rest = np.flatnonzero(pending) + if rest.size: + lo, hi = lo[rest], hi[rest] + start, step = outside[:, rest], step[:, rest] + # brackets narrowed by the secant steps need fewer halvings + width = float(np.max(hi - lo)) * 2.0**iterations + for _ in range(min(iterations, max(1, int(np.ceil(np.log2(width)))))): + mid = (lo + hi) / 2 + hit = mask(obj, wrap(start + mid * step)) + hi = np.where(hit, mid, hi) + lo = np.where(hit, lo, mid) + fractions[rest] = hi + return fractions @dataclass(frozen=True) @@ -165,9 +214,28 @@ def cross(u, v): if int(samples) != samples or samples < 2: raise ValueError("samples must be an integer >= 2") ts = np.linspace(0, 1, int(samples) + 1) - states = mask(shape, a[:, None] + d[:, None] * ts) + points = a[:, None] + d[:, None] * ts + states = mask(shape, points) changes = np.flatnonzero(states[:-1] != states[1:]) hits = [] + if has_level_set(shape) and changes.size: + # refine the sampled membership changes on the level set where it changes + # sign too, all at once; the others are bisected below + values = level_values(shape, points) + secant = changes[values[changes] * values[changes + 1] < 0] + if secant.size: + t, _, _, done = illinois_roots( + lambda p: level_values(shape, p), + np.repeat(a[:, None], secant.size, axis=1), + np.repeat(d[:, None], secant.size, axis=1), + ts[secant], + ts[secant + 1], + values[secant], + values[secant + 1], + 2.0**-48, + ) + hits.extend(float(v) for v in t[done]) + changes = np.setdiff1d(changes, secant[done]) for i in changes: lo, hi = ts[i], ts[i + 1] for _ in range(48): @@ -349,6 +417,9 @@ def closest_point(shape, points, *, resolution=64, bounds=None): @sp.profile("simgeometry.signed_distance", tags=_PROFILE_TAGS) def signed_distance(shape, points, *, resolution=64, bounds=None): p, single = points_array(points, shape.ndim) + if has_level_set(shape) and getattr(shape, "level_set_exact", False): + distance = level_values(shape, p) + return float(distance[0]) if single else distance q = closest_point(shape, p, resolution=resolution, bounds=bounds) distance = np.linalg.norm(p - q, axis=0) distance = np.where(mask(shape, p), -distance, distance) @@ -429,6 +500,13 @@ def surface_normal(shape, points, *, scale=None): n[:, i] = outward[edge] if shape.inverted: n = -n + elif has_level_set(shape): + # the gradient of the level set points out of the region where it is negative + extent = float(np.max(np.asarray(shape.upper_bound) - shape.lower_bound)) + scale = extent if scale is None else float(scale) + if not np.isfinite(scale) or scale <= 0: + raise ValueError("Normal sampling scale must be positive and finite") + n = level_set_gradient(shape, p, 1e-7 * scale) else: scale = ( float(np.max(np.asarray(shape.upper_bound) - shape.lower_bound)) diff --git a/src/simgeometry/shapes.py b/src/simgeometry/shapes.py index 02dbd61..898157c 100644 --- a/src/simgeometry/shapes.py +++ b/src/simgeometry/shapes.py @@ -10,6 +10,7 @@ import numpy as np import scope_profiler as sp +from simgeometry._level_set import segment_distance from simgeometry._profiling import _PROFILE_TAGS from simgeometry._types import Array from simgeometry.api import GeometryAPI @@ -104,9 +105,19 @@ class Shape(GeometryAPI, ABC): negated and the shape extends over the whole simulation domain. The bounding box still describes the geometry itself, so consumers must not use it to skip points for inverted shapes. + + Shapes with an analytic description also have a :meth:`level_set` + (``has_level_set``); ``level_set_exact`` says whether it is the exact + Euclidean signed distance. """ _inverted: bool = False + #: Whether :meth:`level_set` is defined. Shapes known only through their + #: membership test (function objects, ``Aperture3D``) have none. + has_level_set: bool = False + #: Whether :meth:`level_set` is the exact Euclidean signed distance; otherwise + #: it is a bound on it. + level_set_exact: bool = False def __init__( self, @@ -159,6 +170,25 @@ def contains_points(self, points: Array) -> Array: points = _component_major(points, self.ndim) return xp.asarray([bool(self.contains(p)) for p in points.T], dtype=bool) + def level_set(self, points: Array) -> Array: + """Level-set values of the ``(ndim, n)`` points: negative inside, positive outside. + + The sign follows :meth:`~simgeometry.api.GeometryAPI.signed_distance`, so it + flips for an inverted shape, and the boundary is the zero level (where + :meth:`contains_points` may go either way). The magnitude never exceeds the + distance to the boundary; with ``level_set_exact`` it equals it. Values are + on the active cunumpy backend, like :meth:`contains_points`. Raises + ``NotImplementedError`` when ``has_level_set`` is False. + """ + if not self.has_level_set: + raise NotImplementedError(f"{type(self).__name__} has no level set") + values = self._level_set(_component_major(points, self.ndim)) + return -values if self.inverted else values + + def _level_set(self, points: Array) -> Array: + """The level set of the geometry itself, before inversion.""" + raise NotImplementedError(f"{type(self).__name__} has no level set") + @sp.profile("simgeometry.node_mask", tags=_PROFILE_TAGS) def node_mask(self, grid: Grid) -> np.ndarray: """Return a host boolean array of the grid's shape, set at the nodes inside. @@ -347,6 +377,23 @@ def _corner_loops(self) -> list[np.ndarray]: """The unrefined ``(2, n)`` boundary loops; polygonal shapes define this.""" raise NotImplementedError(f"{type(self).__name__} has no outline") + def _edge_level_set(self, points: Array) -> Array: + """Distance to the edges of :meth:`_corner_loops`, negative where the geometry is. + + The sign comes from :meth:`contains_points`, so it agrees with membership + everywhere off the boundary. This is the exact signed distance when the loops + are the boundary of the region (a simple polygon, disjoint plates); extra + edges inside the region only make it a bound. + """ + loops = self._corner_loops() + starts = np.concatenate(loops, axis=1) + ends = np.concatenate([np.roll(loop, -1, axis=1) for loop in loops], axis=1) + distance = segment_distance(points, xp.asarray(starts), xp.asarray(ends)) + inside = self.contains_points(points) + if self.inverted: + inside = ~inside + return xp.where(inside, -distance, distance) + def _outline_loops(self, max_spacing: Spacing | None) -> list[np.ndarray]: """The loops of :meth:`_corner_loops`, refined; curved shapes override this.""" loops = self._corner_loops() diff --git a/src/simgeometry/tests/test_axisymmetric.py b/src/simgeometry/tests/test_axisymmetric.py new file mode 100644 index 0000000..84efa65 --- /dev/null +++ b/src/simgeometry/tests/test_axisymmetric.py @@ -0,0 +1,243 @@ +"""Cylinder, Tube, Cone and Torus: membership, volumes, bounds and exact level sets.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from simgeometry import Cone, Cylinder, Grid, Torus, Tube + +RNG = np.random.default_rng(7) +OBLIQUE = np.array([1.0, -2.0, 2.0]) / 3.0 + +SHAPES = { + "cylinder z": Cylinder((0, 0, -0.5), (0, 0, 0.7), 0.4), + "cylinder oblique": Cylinder( + (0.1, 0.2, 0.3), np.add((0.1, 0.2, 0.3), OBLIQUE), 0.3 + ), + "cylinder fillet": Cylinder((0, 0, -0.5), (0, 0.5, 0.5), 0.4, fillet=0.15), + "tube": Tube((0, 0, -0.4), (0, 0, 0.4), 0.3, 0.6), + "tube oblique fillet": Tube((0.2, 0, 0), (0.2, 0.6, 0.8), 0.2, 0.5, fillet=0.1), + "frustum": Cone((0, 0, 0), (0, 0, 1), 0.6, 0.2), + "cone oblique": Cone((0, 0, 0), tuple(1.2 * OBLIQUE), 0.0, 0.5), + "torus": Torus((0, 0, 0), 0.8, 0.25), + "torus oblique": Torus((0.1, 0.0, -0.2), 0.6, 0.3, axis=OBLIQUE), +} + + +def _box_points(shape, n, pad=0.0): + lower = np.asarray(shape.lower_bound) - pad + upper = np.asarray(shape.upper_bound) + pad + return RNG.uniform(lower[:, None], upper[:, None], (3, n)), np.prod(upper - lower) + + +def _profile(shape, spacing): + """Dense ``(rho, s)`` points on the profile of the solid, in its own frame.""" + + def segment(a, b): + n = max(2, int(np.hypot(*np.subtract(b, a)) / spacing) + 1) + t = np.linspace(0, 1, n) + return np.outer(1 - t, a) + np.outer(t, b) + + def arc(centre, radius, start, stop): + n = max(2, int(radius * abs(stop - start) / spacing) + 1) + angle = np.linspace(start, stop, n) + return np.column_stack( + (centre[0] + radius * np.cos(angle), centre[1] + radius * np.sin(angle)) + ) + + if isinstance(shape, Torus): + return arc((shape.major_radius, 0.0), shape.minor_radius, 0, 2 * np.pi) + h = shape.length / 2 + if isinstance(shape, Cone): + r0, r1 = shape.start_radius, shape.end_radius + return np.concatenate( + [ + segment((0, -h), (r0, -h)), + segment((r0, -h), (r1, h)), + segment((r1, h), (0, h)), + ] + ) + f = shape.fillet + inner = shape.inner_radius if isinstance(shape, Tube) else 0.0 + outer = shape.outer_radius if isinstance(shape, Tube) else shape.radius + pieces = [ + segment((outer, -h + f), (outer, h - f)), + segment((outer - f, h), (inner + (f if inner else 0), h)), + segment((inner + (f if inner else 0), -h), (outer - f, -h)), + ] + if f: + pieces += [ + arc((outer - f, h - f), f, 0, np.pi / 2), + arc((outer - f, -h + f), f, -np.pi / 2, 0), + ] + if inner: + pieces.append(segment((inner, h - f), (inner, -h + f))) + if f: + pieces += [ + arc((inner + f, h - f), f, np.pi / 2, np.pi), + arc((inner + f, -h + f), f, np.pi, 1.5 * np.pi), + ] + return np.concatenate(pieces) + + +def _surface(shape, spacing): + """Points on the surface: the profile revolved about the axis.""" + profile = _profile(shape, spacing) + axis = np.asarray(shape.axis) + u = np.cross(axis, np.eye(3)[np.argmin(np.abs(axis))]) + u /= np.linalg.norm(u) + v = np.cross(axis, u) + rho, s = profile[:, 0], profile[:, 1] + count = int(2 * np.pi * max(rho.max(), spacing) / spacing) + 1 + theta = np.linspace(0, 2 * np.pi, count, endpoint=False) + ring = np.cos(theta)[:, None] * u + np.sin(theta)[:, None] * v # (count, 3) + blocks = ( + np.asarray(shape.midpoint) + + s[:, None, None] * axis + + rho[:, None, None] * ring[None, :, :] + ) + return blocks.reshape(-1, 3) + + +@pytest.mark.parametrize("name", SHAPES) +def test_contains_and_level_set_signs_agree(name): + shape = SHAPES[name] + points, _ = _box_points(shape, 20000, pad=0.2) + values = shape.level_set(points) + inside = shape.contains_points(points) + clear = np.abs(values) > 1e-12 + np.testing.assert_array_equal((values < 0)[clear], inside[clear]) + assert inside.any() and not inside.all() + assert shape.has_level_set and shape.level_set_exact + # contains agrees with contains_points + for point in points[:, :20].T: + assert shape.contains(point) == bool(shape.contains_points(point[:, None])[0]) + + +@pytest.mark.parametrize("name", SHAPES) +def test_monte_carlo_volume(name): + shape = SHAPES[name] + n = 400_000 + points, box_volume = _box_points(shape, n) + fraction = float(np.mean(shape.contains_points(points))) + sigma = box_volume * np.sqrt(fraction * (1 - fraction) / n) + assert fraction * box_volume == pytest.approx(shape.volume(), abs=4 * sigma) + + +@pytest.mark.parametrize("name", SHAPES) +def test_bounding_box_is_tight(name): + shape = SHAPES[name] + surface = _surface(shape, spacing=0.01) + np.testing.assert_allclose(surface.min(axis=0), shape.lower_bound, atol=2e-3) + np.testing.assert_allclose(surface.max(axis=0), shape.upper_bound, atol=2e-3) + + +@pytest.mark.parametrize("name", SHAPES) +def test_level_set_is_the_exact_distance(name): + spatial = pytest.importorskip("scipy.spatial") + shape = SHAPES[name] + spacing = 0.006 + surface = _surface(shape, spacing) + np.testing.assert_allclose(shape.level_set(surface.T), 0.0, atol=1e-12) + points, _ = _box_points(shape, 400, pad=0.3) + distance, _ = spatial.cKDTree(surface).query(points.T) + values = np.abs(shape.level_set(points)) + # the sampled surface overestimates the distance by at most half a sample gap + assert np.all(values <= distance + 1e-12) + assert np.all(distance - values <= spacing) + + +def test_surface_areas(): + pytest.importorskip("skimage") + for name, shape in SHAPES.items(): + mesh = shape.to_mesh(resolution=96) + faces = mesh.vertices[mesh.cells] + area = 0.5 * np.linalg.norm( + np.cross(faces[:, 1] - faces[:, 0], faces[:, 2] - faces[:, 0]), axis=1 + ) + assert area.sum() == pytest.approx(shape.surface_area(), rel=0.03), name + + +def test_constructor_checks_and_properties(): + cylinder = Cylinder((0, 0, 0), (0, 0, 2), 0.5) + assert cylinder.length == 2 and cylinder.midpoint == (0, 0, 1) + assert cylinder.axis == (0, 0, 1) and cylinder.start == (0, 0, 0) + assert cylinder.volume() == pytest.approx(np.pi * 0.25 * 2) + with pytest.raises(ValueError): + Cylinder((0, 0, 0), (0, 0, 0), 1) + with pytest.raises(ValueError): + Cylinder((0, 0, 0), (0, 0, 1), -1) + with pytest.raises(ValueError): + Cylinder((0, 0, 0), (0, 0, 1), 1, fillet=0.6) + with pytest.raises(ValueError): + Tube((0, 0, 0), (0, 0, 1), 0.5, 0.4) + with pytest.raises(ValueError): + Tube((0, 0, 0), (0, 0, 1), 0.4, 0.5, fillet=0.06) + with pytest.raises(ValueError): + Cone((0, 0, 0), (0, 0, 1), 0, 0) + with pytest.raises(ValueError): + Torus((0, 0, 0), 1, 1.5) + with pytest.raises(ValueError): + Torus((0, 0, 0), 1, 0.5, axis=(0, 0, 0)) + + +def test_copy_translate_mirror_and_inversion(): + cone = Cone((0, 0, 0), (0, 0, 1), 0.5, 0.1, bc=3.0) + moved = cone.copy() + moved.translate((1, 0, 0)) + assert moved.contains((1, 0, 0.1)) and not moved.contains((0, 0, 0.1)) + assert cone.contains((0, 0, 0.1)) and moved._bc == 3.0 + np.testing.assert_allclose(moved.lower_bound, np.add(cone.lower_bound, (1, 0, 0))) + mirrored = cone.copy() + mirrored.mirror(2, 0.0) + assert mirrored.contains((0, 0, -0.1)) and mirrored.contains((0.45, 0, -0.05)) + assert not mirrored.contains((0.45, 0, -0.95)) + np.testing.assert_allclose(mirrored.end, (0, 0, -1)) + hole = Torus((0, 0, 0), 1, 0.2, inverted=True) + assert not hole.contains((1, 0, 0)) and hole.contains((0, 0, 0)) + assert hole.level_set(np.array([[1.0], [0.0], [0.0]]))[0] == pytest.approx(0.2) + + +def test_booleans_transforms_grids_and_sampling(): + tube = Tube((0, 0, -0.3), (0, 0, 0.3), 0.3, 0.5) + rod = Cylinder((-1, 0, 0), (1, 0, 0), 0.1) + cut = tube - rod + assert cut.has_level_set and not cut.level_set_exact + assert not cut.contains((0.4, 0, 0)) and cut.contains((0, 0.4, 0)) + rotated = tube.rotated(90, axis=(1, 0, 0)) + assert rotated.level_set_exact + reference = Tube((0, 0.3, 0), (0, -0.3, 0), 0.3, 0.5) + points, _ = _box_points(reference, 500, pad=0.2) + np.testing.assert_allclose( + rotated.level_set(points), reference.level_set(points), atol=1e-12 + ) + np.testing.assert_allclose(rotated.lower_bound, reference.lower_bound, atol=1e-12) + grid = Grid( + ndim=3, + lower_bound=(-0.6, -0.6, -0.6), + upper_bound=(0.6, 0.6, 0.6), + num_gridpoints=(13, 13, 13), + ) + nodes = grid.node_points() + np.testing.assert_array_equal( + tube.node_mask(grid).ravel(), tube.contains_points(nodes) + ) + samples = tube.sample_interior(200, rng=1) + assert tube.contains_points(samples).all() + + +def test_plotting_and_meshes(): + pytest.importorskip("matplotlib") + pytest.importorskip("skimage") + import matplotlib + + matplotlib.use("Agg") + import matplotlib.pyplot as plt + + tube = Tube((0, 0, -0.3), (0, 0, 0.3), 0.3, 0.5, fillet=0.05) + tube.show_3d(resolution=24, display=False) + tube.slice(x=0).show(resolution=64, display=False) + surface = tube.sample_surface(100, rng=2, resolution=48) + assert np.max(np.abs(tube.level_set(surface))) < 0.01 + plt.close("all") diff --git a/src/simgeometry/tests/test_conductors.py b/src/simgeometry/tests/test_conductors.py new file mode 100644 index 0000000..a32c03d --- /dev/null +++ b/src/simgeometry/tests/test_conductors.py @@ -0,0 +1,107 @@ +"""Voltages on GeometryCollection: overrides, time dependence and Dirichlet values.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from simgeometry import Circle, GeometryCollection, Grid, Rectangle, Tube + + +def _electrodes(**kwargs): + return GeometryCollection( + { + "wall": Rectangle((0, 0), (4, 0.2)), + "electrode": Circle((0, 1), 0.5), + "dielectric": Circle((1, 1), 0.3), + }, + **kwargs, + ) + + +def test_voltages_name_conductors_in_object_order(): + lens = _electrodes(voltages={"electrode": 1000, "wall": 0.0}) + assert lens.conductors == ("wall", "electrode") + assert lens.voltages == {"wall": 0.0, "electrode": 1000.0} + assert lens.voltages_at() == {"wall": 0.0, "electrode": 1000.0} + assert _electrodes().conductors == () + + +def test_invalid_voltages_raise(): + with pytest.raises(ValueError, match="unknown"): + _electrodes(voltages={"anode": 1.0}) + with pytest.raises(TypeError): + _electrodes(voltages={"wall": "0 V"}) + with pytest.raises(TypeError): + _electrodes(voltages={"wall": True}) + with pytest.raises(ValueError, match="unknown"): + _electrodes().with_voltages(anode=1.0) + + +def test_with_voltages_shares_the_geometry(): + lens = _electrodes(voltages={"electrode": 1000.0, "wall": 0.0}, styles={"wall": {}}) + scan = lens.with_voltages(electrode=500.0) + assert scan is not lens + assert scan.voltages == {"wall": 0.0, "electrode": 500.0} + assert lens.voltages["electrode"] == 1000.0 + assert scan["electrode"] is lens["electrode"] + assert scan.styles == lens.styles + assert scan.lower_bound == lens.lower_bound + assert scan.upper_bound == lens.upper_bound + # a mapping for any name, None to remove a voltage + other = lens.with_voltages({"dielectric": -5}, wall=None) + assert other.conductors == ("electrode", "dielectric") + + +def test_time_dependent_voltages(): + lens = _electrodes(voltages={"wall": 0.0, "electrode": lambda t: 100.0 * np.sin(t)}) + assert lens.voltages_at(0.0)["electrode"] == 0.0 + assert lens.voltages_at(np.pi / 2)["electrode"] == pytest.approx(100.0) + values = lens.dirichlet_values(np.array([[0.0], [1.0]]), t=np.pi / 2) + assert values.voltages[0] == pytest.approx(100.0) + + +def test_dirichlet_values_at_points(): + lens = _electrodes(voltages={"electrode": 1000.0, "wall": 0.0}) + points = np.array([[0.0, 0.0, 1.0, 3.0, 0.4], [0.0, 1.0, 1.0, 3.0, 1.0]]) + values = lens.dirichlet_values(points) + np.testing.assert_array_equal(values.labels, [1, 2, 3, 0, 2]) + np.testing.assert_array_equal( + values.voltages, [0.0, 1000.0, np.nan, np.nan, 1000.0] + ) + np.testing.assert_array_equal(values.conductor, [True, True, False, False, True]) + assert values.names == ("wall", "electrode", "dielectric") + + +def test_dirichlet_values_follow_overlap_precedence(): + # the later, non-conducting object wins where it overlaps the electrode + overlap = GeometryCollection( + {"electrode": Circle((0, 0), 1), "insulator": Circle((1, 0), 0.5)}, + voltages={"electrode": 10.0}, + ) + values = overlap.dirichlet_values(np.array([[0.0, 0.8], [0.0, 0.0]])) + np.testing.assert_array_equal(values.labels, [1, 2]) + np.testing.assert_array_equal(values.voltages, [10.0, np.nan]) + + +def test_dirichlet_values_on_a_grid(): + rings = GeometryCollection( + { + "left": Tube((0, 0, -1.0), (0, 0, -0.6), 0.3, 0.5), + "middle": Tube((0, 0, -0.2), (0, 0, 0.2), 0.3, 0.5), + "right": Tube((0, 0, 0.6), (0, 0, 1.0), 0.3, 0.5), + }, + voltages={"left": 0.0, "middle": 5000.0, "right": 0.0}, + ) + grid = Grid( + ndim=3, + lower_bound=(-0.6, -0.6, -1.2), + upper_bound=(0.6, 0.6, 1.2), + num_gridpoints=(13, 13, 25), + ) + values = rings.dirichlet_values(grid) + assert values.labels.shape == values.voltages.shape == (13, 13, 25) + np.testing.assert_array_equal(values.conductor, rings.node_mask(grid)) + middle = values.labels == 2 + assert middle.any() and np.all(values.voltages[middle] == 5000.0) + assert np.all(values.voltages[values.labels == 1] == 0.0) diff --git a/src/simgeometry/tests/test_inspection_plotting.py b/src/simgeometry/tests/test_inspection_plotting.py index 1279c36..285132e 100644 --- a/src/simgeometry/tests/test_inspection_plotting.py +++ b/src/simgeometry/tests/test_inspection_plotting.py @@ -88,3 +88,81 @@ def test_pyvista_offscreen_clip_widget(tmp_path): assert (tmp_path / "interactive.png").stat().st_size > 0 finally: viewer.close() + + +def _lens(): + from simgeometry import GeometryCollection, Tube + + return GeometryCollection( + { + "outer": Tube((0, 0, -1), (0, 0, -0.4), 0.5, 0.7), + "middle": Tube((0, 0, -0.2), (0, 0, 0.2), 0.5, 0.7), + "probe": Sphere((0, 0, 0.8), 0.2), + }, + voltages={"outer": 0.0, "middle": 1.0e4}, + ) + + +def test_pyvista_draws_the_surfaces_with_plasma_plots(tmp_path): + pytest.importorskip("pyvista") + pytest.importorskip("plasma_plots") + viewer = _lens().show_3d( + backend="pyvista", resolution=16, display=False, save=tmp_path / "lens.png" + ) + try: + assert len(viewer.meshes) >= 3 + assert viewer.legend is not None + assert (tmp_path / "lens.png").stat().st_size > 0 + finally: + viewer.close() + + +def test_pyvista_colors_conductors_by_voltage(): + pytest.importorskip("pyvista") + pytest.importorskip("plasma_plots") + viewer = _lens().show_3d( + backend="pyvista", resolution=16, display=False, color_by="voltage" + ) + try: + voltages = [ + set(mesh.point_data["voltage"].tolist()) + for mesh in viewer.meshes + if "voltage" in mesh.point_data + ] + assert {0.0} in voltages and {1.0e4} in voltages + assert any(np.isnan(next(iter(v))) for v in voltages) # the probe + assert viewer.scalar_bars + finally: + viewer.close() + + +def test_color_by_voltage_needs_pyvista_and_voltages(): + with pytest.raises(ValueError, match="backend='pyvista'"): + _lens().show_3d(color_by="voltage", display=False) + with pytest.raises(ValueError, match="voltages"): + Sphere((0, 0, 0), 1).show_3d( + backend="pyvista", color_by="voltage", display=False + ) + + +def test_level_set_quantity_matches_the_level_set(): + ball = Sphere((0, 0, 0), 1) + field = ball.evaluate(x=[0.0, 0.5, 2.0], y=0.0, z=0.0, quantity="level_set") + np.testing.assert_allclose(field.values, [-1.0, -0.5, 1.0]) + + +def test_level_set_field_is_drawn_by_plasma_plots(): + pytest.importorskip("pyvista") + plasma_plots = pytest.importorskip("plasma_plots") + field = Sphere((0.5, 0, 0), 1).level_set_field(resolution=12) + assert field.dims == ("eta1", "eta2", "eta3") + assert {"X", "Y", "Z"} <= set(field.coords) + assert float(field.X.min()) == pytest.approx(-0.5) + assert float(field.min()) < 0 < float(field.max()) + viewer = plasma_plots.pyvista_plots.pyvista_isosurface( + field, values=[0.0], show_domain=False + ) + try: + assert viewer.meshes + finally: + viewer.close() diff --git a/src/simgeometry/tests/test_level_sets.py b/src/simgeometry/tests/test_level_sets.py new file mode 100644 index 0000000..38912e8 --- /dev/null +++ b/src/simgeometry/tests/test_level_sets.py @@ -0,0 +1,300 @@ +"""Level sets: sign convention, exactness, propagation and their use in queries.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from simgeometry import ( + Aperture2D, + Circle, + Cuboid, + Cylinder, + FunctionObject2D, + GeometryCollection, + Grid, + Line, + Polygon, + Rectangle, + Sphere, + Torus, + Triangle, + Tube, +) +from simgeometry.cut_edges import bracketed_edge_geometry +from simgeometry.queries import intersection_fractions +from simgeometry.shapes import Shape + +RNG = np.random.default_rng(1) + + +class MembershipOnly(Shape): + """A shape that hides the level set of its source, for the fallback paths.""" + + def __init__(self, source): + self.source = source + self.evaluated = 0 + super().__init__(source.ndim, source.lower_bound, source.upper_bound) + + def contains_points(self, points): + self.evaluated += np.shape(points)[1] + return self.source.contains_points(points) + + def contains(self, coords): + return self.source.contains(coords) + + +def _points(shape, n=2000, pad=0.3): + lower = np.asarray(shape.lower_bound, float) - pad + upper = np.asarray(shape.upper_bound, float) + pad + return RNG.uniform(lower[:, None], upper[:, None], (shape.ndim, n)) + + +PRIMITIVES = [ + Line((0.5,), (1.0,)), + Circle((0.2, -0.1), 0.7), + Rectangle((0.0, 0.5), (1.0, 0.4)), + Polygon(((0, 0), (2, 0), (2, 1), (1, 0.4), (0, 1))), + Triangle(((0, 0), (1, 0), (0.2, 0.8))), + Aperture2D.slit(0.0, 0.2, 0.1, 0.5), + Sphere((0.1, 0.2, 0.3), 0.8), + Cuboid((0, 0, 0), (1.0, 0.5, 2.0)), +] + + +@pytest.mark.parametrize("shape", PRIMITIVES, ids=lambda s: type(s).__name__) +@pytest.mark.parametrize("inverted", [False, True]) +def test_level_set_sign_agrees_with_contains(shape, inverted): + shape = shape.copy() + shape._inverted = inverted + points = _points(shape) + values = shape.level_set(points) + inside = shape.contains_points(points) + clear = np.abs(values) > 1e-12 + np.testing.assert_array_equal((values < 0)[clear], inside[clear]) + assert shape.has_level_set and shape.level_set_exact + + +def test_level_set_matches_signed_distance_convention(): + circle = Circle((0, 0), 1) + points = np.array([[0.0, 2.0, 0.0], [0.0, 0.0, 0.5]]) + np.testing.assert_allclose(circle.level_set(points), [-1, 1, -0.5]) + np.testing.assert_allclose(circle.signed_distance(points), [-1, 1, -0.5]) + inverted = Circle((0, 0), 1, inverted=True) + np.testing.assert_allclose(inverted.level_set(points), [1, -1, 0.5]) + np.testing.assert_allclose(inverted.signed_distance(points), [1, -1, 0.5]) + + +def test_box_and_polygon_level_sets_are_the_exact_distance(): + box = Rectangle((0, 0), (2, 1)) + points = np.array([[0, 2, 2, 0.5], [0, 0, 1.5, 0.25]], float) + np.testing.assert_allclose(box.level_set(points), [-0.5, 1, np.hypot(1, 1), -0.25]) + square = Polygon(((-1, -0.5), (1, -0.5), (1, 0.5), (-1, 0.5))) + np.testing.assert_allclose(square.level_set(points), box.level_set(points)) + cube = Cuboid((0, 0, 0), (2, 2, 2)) + np.testing.assert_allclose( + cube.level_set(np.array([[2.0], [2.0], [2.0]])), [3**0.5] + ) + np.testing.assert_allclose( + Line((0,), (2,)).level_set(np.array([[0.5, 3.0]])), [-0.5, 2] + ) + + +def test_shapes_without_a_level_set_say_so(): + ring = FunctionObject2D(lambda p: 1 <= p[0] ** 2 + p[1] ** 2 <= 4, (-2, -2), (2, 2)) + assert not ring.has_level_set + with pytest.raises(NotImplementedError): + ring.level_set(np.zeros((2, 1))) + combined = ring | Circle((0, 0), 1) + assert not combined.has_level_set + with pytest.raises(NotImplementedError): + combined.level_set(np.zeros((2, 1))) + + +def test_boolean_level_sets_are_min_max_bounds(): + a, b = Circle((0, 0), 1), Circle((1, 0), 1) + points = _points(a | b) + for shape, combine in ( + (a | b, np.minimum), + (a & b, np.maximum), + (a - b, lambda u, v: np.maximum(u, -v)), + ): + assert shape.has_level_set and not shape.level_set_exact + values = shape.level_set(points) + np.testing.assert_allclose( + values, combine(a.level_set(points), b.level_set(points)) + ) + clear = np.abs(values) > 1e-12 + np.testing.assert_array_equal( + (values < 0)[clear], shape.contains_points(points)[clear] + ) + # outside a union the minimum is still the exact distance + np.testing.assert_allclose( + (a | b).level_set(np.array([[0.5], [3.0]])), [np.hypot(0.5, 3) - 1] + ) + + +def test_transformed_level_sets(): + circle = Circle((0, 0), 1) + points = _points(circle, pad=2) + moved = circle.rotated(30).translated((1, 2)) + assert moved.level_set_exact + np.testing.assert_allclose( + moved.level_set(points), Circle((1, 2), 1).level_set(points), atol=1e-14 + ) + scaled = circle.scaled(3.0) + assert scaled.level_set_exact + np.testing.assert_allclose( + scaled.level_set(points), Circle((0, 0), 3).level_set(points) + ) + stretched = circle.scaled((2.0, 0.5)) + assert stretched.has_level_set and not stretched.level_set_exact + # a bound on the distance to the ellipse, with the sign of membership + theta = np.linspace(0, 2 * np.pi, 20001) + ellipse = np.stack((2 * np.cos(theta), 0.5 * np.sin(theta))) + distance = np.min( + np.hypot(points[0][:, None] - ellipse[0], points[1][:, None] - ellipse[1]), + axis=1, + ) + values = stretched.level_set(points) + assert np.all(np.abs(values) <= distance + 1e-6) + clear = np.abs(values) > 1e-12 + np.testing.assert_array_equal( + (values < 0)[clear], stretched.contains_points(points)[clear] + ) + + +def test_sections_and_collections_have_bounded_level_sets(): + sphere = Sphere((0, 0, 0), 1) + section = sphere.slice(z=0.6) + assert section.has_level_set and not section.level_set_exact + np.testing.assert_allclose(section.level_set(np.array([[0.0], [0.0]])), [-0.4]) + collection = GeometryCollection({"a": Circle((0, 0), 1), "b": Circle((3, 0), 1)}) + assert collection.has_level_set + np.testing.assert_allclose(collection.level_set(np.array([[1.5], [0.0]])), [0.5]) + mixed = GeometryCollection( + {"a": Circle((0, 0), 1), "f": FunctionObject2D(lambda p: False, (0, 0), (1, 1))} + ) + assert not mixed.has_level_set + + +def test_circle_cut_positions_match_the_analytic_crossings(): + centre, radius = np.array([0.5, 0.5]), 0.3 + circle = Circle(centre, radius) + grid = Grid(ndim=2, lower_bound=(0, 0), upper_bound=(1, 1), num_gridpoints=(64, 64)) + geometry = bracketed_edge_geometry(grid, [circle]) + h = grid.grid_spacing + checked = 0 + for (dim, side), fraction in geometry.fractions.items(): + cut = np.isfinite(fraction) + nodes = geometry.nodes[:, cut] + other = 1 - dim + half_chord = np.sqrt(radius**2 - (nodes[other] - centre[other]) ** 2) + crossing = centre[dim] + (half_chord if side == 0 else -half_chord) + expected = np.abs(crossing - nodes[dim]) / h[dim] + np.testing.assert_allclose(fraction[cut], expected, rtol=0, atol=1e-13) + checked += int(cut.sum()) + assert checked > 100 + + +def test_level_set_fractions_agree_with_bisection_with_fewer_evaluations(): + sphere = Sphere((0.1, 0.2, 0.3), 0.8) + theta = RNG.uniform(0, np.pi, 5000) + phi = RNG.uniform(0, 2 * np.pi, 5000) + direction = np.stack( + (np.sin(theta) * np.cos(phi), np.sin(theta) * np.sin(phi), np.cos(theta)) + ) + centre = np.asarray(sphere.midpoint)[:, None] + outside = centre + 0.81 * direction + inside = centre + 0.79 * direction + RNG.uniform(-0.005, 0.005, direction.shape) + fallback = MembershipOnly(sphere) + bisected = intersection_fractions(fallback, outside, inside) + refined = intersection_fractions(sphere, outside, inside) + for fractions in (bisected, refined): + crossing = outside + fractions * (inside - outside) + np.testing.assert_allclose(sphere.level_set(crossing), 0.0, atol=1e-15) + np.testing.assert_allclose(refined, bisected, atol=1e-12) + assert fallback.evaluated == 50 * 5000 + + class Counted(MembershipOnly): + has_level_set = True + + def level_set(self, points): + self.evaluated += np.shape(points)[1] + return self.source.level_set(points) + + counted = Counted(sphere) + intersection_fractions(counted, outside, inside) + assert counted.evaluated < 10 * 5000 + + +def test_level_set_fractions_fall_back_to_bisection_without_a_sign_change(): + # the half-open rectangle: the node on its upper edge is outside with a zero level + box = Rectangle((0, 0), (2, 2)) + outside = np.array([[1.0, 0.5], [0.5, 1.5]]) + inside = np.array([[0.5, 0.5], [0.5, 0.5]]) + fractions = intersection_fractions(box, outside, inside) + np.testing.assert_allclose(fractions, [0.0, 0.5], atol=1e-14) + + +def test_periodic_wrap_with_a_level_set(): + grid = Grid( + ndim=2, + lower_bound=(0, 0), + upper_bound=(1, 1), + num_gridpoints=(16, 16), + periodic=(True, True), + ) + wall = Rectangle((0.02, 0.5), (0.1, 2.0)) + geometry = bracketed_edge_geometry(grid, [wall]) + fallback = bracketed_edge_geometry(grid, [MembershipOnly(wall)]) + for key, fraction in geometry.fractions.items(): + np.testing.assert_allclose(fraction, fallback.fractions[key], atol=1e-12) + + +def test_signed_distance_returns_exact_level_sets(): + sphere = Sphere((0, 0, 0), 1) + points = _points(sphere) + np.testing.assert_allclose( + sphere.signed_distance(points), np.linalg.norm(points, axis=0) - 1, atol=1e-15 + ) + cylinder = Cylinder((0, 0, -1), (0, 0, 1), 0.5) + assert cylinder.signed_distance([0, 0, 2]) == pytest.approx(1.0) + assert cylinder.signed_distance([1, 0, 0]) == pytest.approx(0.5) + assert cylinder.signed_distance([0, 0, 0]) == pytest.approx(-0.5) + # a non-exact level set keeps the sampled-surface path + shell = Sphere((0, 0, 0), 1) - Sphere((0, 0, 0), 0.4) + assert shell.signed_distance([0, 0, 0], resolution=48) == pytest.approx( + 0.4, abs=0.05 + ) + + +def test_cut_edges_and_crossings_use_the_level_set(): + tube = Tube((0, 0, -0.5), (0, 0, 0.5), 0.3, 0.6) + grid = Grid( + ndim=3, + lower_bound=(-1, -1, -1), + upper_bound=(1, 1, 1), + num_gridpoints=(9, 9, 9), + ) + hits = tube.cut_edges(grid) + assert len(hits.fractions) > 50 + np.testing.assert_allclose(tube.level_set(hits.points), 0.0, atol=1e-14) + start = np.array([[0.0, 0.45], [0.0, 0.0], [0.0, 0.0]]) + end = np.array([[1.0, 0.45], [0.0, 0.0], [0.0, 1.0]]) + result = tube.crossings(start, end) + np.testing.assert_allclose(result.fraction, [0.3, 0.5], atol=1e-12) + np.testing.assert_allclose(result.normal[:, 0], [-1, 0, 0], atol=1e-6) + np.testing.assert_allclose(result.normal[:, 1], [0, 0, 1], atol=1e-6) + + +def test_normals_and_meshes_from_level_sets(): + torus = Torus((0, 0, 0), 1.0, 0.25) + points = np.array([[1.25, 0.0, 0.75], [0.0, 1.0, 0.0], [0.0, 0.25, 0.0]]) + normals = torus.surface_normal(points) + np.testing.assert_allclose(normals[:, 0], [1, 0, 0], atol=1e-6) + np.testing.assert_allclose(normals[:, 1], [0, 0, 1], atol=1e-6) + np.testing.assert_allclose(normals[:, 2], [-1, 0, 0], atol=1e-6) + pytest.importorskip("skimage") + mesh = torus.to_mesh(resolution=40) + assert np.max(np.abs(torus.level_set(mesh.vertices.T))) < 0.01 diff --git a/src/simgeometry/tests/test_level_sets_fake_cupy.py b/src/simgeometry/tests/test_level_sets_fake_cupy.py new file mode 100644 index 0000000..3813f17 --- /dev/null +++ b/src/simgeometry/tests/test_level_sets_fake_cupy.py @@ -0,0 +1,56 @@ +"""Level sets, the new solids and the level-set crossing paths on the fake CuPy.""" + +from __future__ import annotations + +import textwrap + +import pytest + +kernel_testing = pytest.importorskip("cunumpy.kernel_testing") + +CODE = textwrap.dedent( + """ + import cunumpy as xp + import numpy as np + + from simgeometry import Circle, Cone, Cylinder, Sphere, Torus, Tube + + with xp.use_backend("cupy", strict=True): + rng = np.random.default_rng(0) + host = rng.uniform(-1, 1, (3, 500)) + points = xp.asarray(host) + shapes = [ + Sphere((0, 0, 0), 0.7), + Cylinder((0, 0, -0.5), (0.2, 0.1, 0.5), 0.4, fillet=0.1), + Tube((0, 0, -0.5), (0, 0, 0.5), 0.3, 0.6), + Cone((0, 0, -0.5), (0, 0, 0.5), 0.6, 0.0), + Torus((0, 0, 0), 0.6, 0.2, axis=(1, 0, 1)), + Sphere((0, 0, 0), 0.8) - Cylinder((0, 0, -1), (0, 0, 1), 0.3), + Tube((0, 0, -0.5), (0, 0, 0.5), 0.3, 0.6).rotated(30, axis=(1, 0, 0)), + ] + for shape in shapes: + values = shape.level_set(points) + inside = shape.contains_points(points) + assert not isinstance(values, np.ndarray), type(values) + values, inside = xp.to_numpy(values), xp.to_numpy(inside) + clear = np.abs(values) > 1e-12 + assert np.array_equal((values < 0)[clear], inside[clear]) + start = xp.zeros((3, 4)) + end = xp.asarray(np.eye(3, 4) + 0.01) + hits = shape.crossings(start, end) + hit = xp.to_numpy(hits.hit) + fraction = xp.to_numpy(hits.fraction)[hit] + point = xp.to_numpy(hits.point)[:, hit] + level = xp.to_numpy(shape.level_set(xp.asarray(point))) + assert np.all(np.abs(level) < 1e-12), (shape, level) + assert np.all((fraction > 0) & (fraction <= 1)) + circle = Circle((0, 0), 0.5) + assert abs(circle.signed_distance(np.array([1.0, 0.0])) - 0.5) < 1e-15 + print("ok") + """ +) + + +def test_level_sets_on_the_fake_cupy(): + result = kernel_testing.run_in_fake_cupy_subprocess(CODE, timeout=300) + assert "ok" in result.stdout diff --git a/src/simgeometry/visualization.py b/src/simgeometry/visualization.py index 7ab2d31..0c5e97c 100644 --- a/src/simgeometry/visualization.py +++ b/src/simgeometry/visualization.py @@ -1,6 +1,7 @@ """Optional 1D/2D inspection and 3D surface viewers. -Matplotlib is the default backend; PyVista adds interactive clipping widgets. +Matplotlib is the default backend. The PyVista backend draws the surfaces with +plasma-plots (``pyvista_surfaces``) and adds interactive clipping widgets. Plots are sampled views. Increase resolution before interpreting small features. """ @@ -218,20 +219,27 @@ def show_3d( backend="matplotlib", display=True, save=None, + t=0.0, **kwargs, ): """Render surfaces; return axes or an optional PyVista Plotter. clip=(origin, normal) retains the positive half-space, without caps. clip=True enables a PyVista clipping widget. PyVista callers own and should - close the returned plotter. No MPI operations are performed. + close the returned plotter. color_by="voltage" (PyVista) colors the + conductors of a collection by their voltage at time ``t``; objects without + a voltage are grey. No MPI operations are performed. """ if shape.ndim != 3: raise ValueError("show_3d requires three-dimensional geometry") if backend not in ("matplotlib", "pyvista"): raise ValueError("backend must be matplotlib or pyvista") - if color_by not in (None, "name"): - raise ValueError("color_by must be 'name' or None") + if color_by not in (None, "name", "voltage"): + raise ValueError("color_by must be 'name', 'voltage' or None") + if color_by == "voltage" and backend != "pyvista": + raise ValueError("color_by='voltage' needs backend='pyvista'") + if color_by == "voltage" and not getattr(shape, "conductors", ()): + raise ValueError("color_by='voltage' needs a collection with voltages") if grid is True: raise ValueError("Pass a Grid object, or use domain.show_3d(grid=True)") if grid is not None and grid.ndim != 3: @@ -247,6 +255,11 @@ def show_3d( except ImportError as exc: raise ImportError("Interactive 3D needs simgeometry[interactive]") from exc plotter = ax if ax is not None else pv.Plotter(off_screen=not display) + voltages = shape.voltages_at(t) if color_by == "voltage" else {} + # plasma-plots draws the surfaces; the clipping widget, wireframes and extra + # add_mesh options need PyVista's own add_mesh. + surfaces, surface_colors, surface_opacity = {}, {}, {} + use_surfaces = clip is not True and not wireframe and not kwargs else: import matplotlib.pyplot as plt from matplotlib.colors import to_rgba @@ -288,16 +301,26 @@ def show_3d( data = pv.PolyData( vertices, np.column_stack((np.full(len(faces), 3), faces)).ravel() ) - add = plotter.add_mesh_clip_plane if clip is True else plotter.add_mesh - add( - data, - color=to_rgb(chosen), - opacity=alpha, - style="wireframe" if wireframe else "surface", - label=name if labels else None, - **style, - **kwargs, - ) + if color_by == "voltage": + data.point_data["voltage"] = np.full( + data.n_points, voltages.get(name, np.nan) + ) + if use_surfaces and not style: + surfaces[name] = data + surface_colors[name] = to_rgb(chosen) + surface_opacity[name] = alpha + else: + add = plotter.add_mesh_clip_plane if clip is True else plotter.add_mesh + add( + data, + color=None if color_by == "voltage" else to_rgb(chosen), + scalars="voltage" if color_by == "voltage" else None, + opacity=alpha, + style="wireframe" if wireframe else "surface", + label=name if labels else None, + **style, + **kwargs, + ) if normals: stride = max(1, len(mesh.cells) // 64) plotter.add_arrows( @@ -328,6 +351,17 @@ def show_3d( length=float(np.max(np.asarray(upper) - lower)) * 0.05, ) if backend == "pyvista": + if surfaces: + from plasma_plots.pyvista_plots import pyvista_surfaces + + pyvista_surfaces( + surfaces, + colors=surface_colors, + opacity=surface_opacity, + scalars="voltage" if color_by == "voltage" else None, + legend=labels, + plotter=plotter, + ) if show_bounds: plotter.show_bounds(bounds=np.array([lower, upper]).T.ravel()) if grid is not None: @@ -336,7 +370,7 @@ def show_3d( plotter.add_lines(segment, color="gray", width=1) if nodes: plotter.add_points(grid.node_points().T, point_size=4) - if labels and objects: + if labels and len(surfaces) < len(objects) and color_by != "voltage": plotter.add_legend() if display or save: plotter.show(screenshot=str(save) if save else None, auto_close=False) diff --git a/tutorials/conductors_and_level_sets.ipynb b/tutorials/conductors_and_level_sets.ipynb new file mode 100644 index 0000000..7bc8df5 --- /dev/null +++ b/tutorials/conductors_and_level_sets.ipynb @@ -0,0 +1,240 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Conductors and level sets\n", + "\n", + "Build an Einzel lens from ring electrodes, give the electrodes voltages, and read the Dirichlet values a field solver needs. The rings are `Tube` solids; their level sets locate the surface between grid nodes to round-off.\n", + "\n", + "An Einzel lens is three coaxial rings: the outer two grounded, the middle one at a high voltage. A beam passing through the bore is focused without changing its final energy." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "\n", + "from simgeometry import GeometryCollection, Grid, Tube\n", + "\n", + "bore, outer, length, gap = 1.0, 1.6, 0.8, 0.4\n", + "\n", + "\n", + "def ring(centre):\n", + " # a ring electrode along z, with rounded edges\n", + " return Tube(\n", + " (0, 0, centre - length / 2),\n", + " (0, 0, centre + length / 2),\n", + " bore,\n", + " outer,\n", + " fillet=0.1,\n", + " )\n", + "\n", + "\n", + "lens = GeometryCollection(\n", + " {\n", + " \"entrance\": ring(-(length + gap)),\n", + " \"focus\": ring(0.0),\n", + " \"exit\": ring(length + gap),\n", + " },\n", + " voltages={\"entrance\": 0.0, \"focus\": 10e3, \"exit\": 0.0},\n", + ")\n", + "print(lens.conductors, lens.voltages)" + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "The section through the axis shows the three rings in the plane `y = 0`, labelled in insertion order." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "lens.slice(y=0).show(resolution=240, display=False)\n", + "plt.title(\"Einzel lens, section y = 0\")\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "4", + "metadata": {}, + "source": [ + "In 3D, `color_by=\"voltage\"` colours each conductor by its voltage (needs `simgeometry[interactive]`); click **Interactive 3-D view** to look into the bore." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5", + "metadata": {}, + "outputs": [], + "source": [ + "import plasma_plots\n", + "import pyvista as pv\n", + "\n", + "pv.OFF_SCREEN = True\n", + "plotter = lens.show_3d(\n", + " backend=\"pyvista\", display=False, color_by=\"voltage\", resolution=48, opacity=0.8\n", + ")\n", + "plasma_plots.scene(plotter, alt=\"The Einzel lens, the middle ring at 10 kV\")" + ] + }, + { + "cell_type": "markdown", + "id": "6", + "metadata": {}, + "source": [ + "## Dirichlet values on a grid\n", + "\n", + "`dirichlet_values(grid)` returns, for every node, the label of the object it is inside (0 outside every object) and that object's voltage, NaN outside the conductors. Overlaps follow the labels: the last listed object wins. A solver fixes the nodes where `values.conductor` is true." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7", + "metadata": {}, + "outputs": [], + "source": [ + "grid = Grid(\n", + " ndim=3,\n", + " lower_bound=(-2.0, -2.0, -3.0),\n", + " upper_bound=(2.0, 2.0, 3.0),\n", + " num_gridpoints=(41, 41, 61),\n", + ")\n", + "values = lens.dirichlet_values(grid)\n", + "print(values.names, values.labels.shape, int(values.conductor.sum()), \"fixed nodes\")\n", + "\n", + "y0 = 20 # the node plane y = 0\n", + "x, _, z = grid.get_grid_points()\n", + "fig, ax = plt.subplots(figsize=(7, 3.5), layout=\"constrained\")\n", + "image = ax.pcolormesh(\n", + " z, x, values.voltages[:, y0, :], shading=\"nearest\", cmap=\"viridis\"\n", + ")\n", + "fig.colorbar(image, label=\"voltage [V]\")\n", + "ax.set_xlabel(\"z\")\n", + "ax.set_ylabel(\"x\")\n", + "ax.set_aspect(\"equal\")\n", + "ax.set_title(\"Dirichlet voltages at the nodes (white: free nodes)\")\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "8", + "metadata": {}, + "source": [ + "## Other voltages without rebuilding the geometry\n", + "\n", + "`with_voltages` returns a collection with the same objects and other voltages, for a parameter scan. A voltage can also be a function of the time `t`, such as a ramp; `voltages_at(t)` and `dirichlet_values(..., t=t)` evaluate it." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9", + "metadata": {}, + "outputs": [], + "source": [ + "scan = {v: lens.with_voltages(focus=v) for v in (2e3, 5e3, 10e3)}\n", + "print({v: collection.voltages[\"focus\"] for v, collection in scan.items()})\n", + "\n", + "ramped = lens.with_voltages(focus=lambda t: 10e3 * min(t / 1e-6, 1.0))\n", + "for t in (0.0, 0.25e-6, 2e-6):\n", + " print(f\"t = {t:.2e} s:\", ramped.voltages_at(t))\n", + "assert lens[\"focus\"] is ramped[\"focus\"] # the same geometry" + ] + }, + { + "cell_type": "markdown", + "id": "10", + "metadata": {}, + "source": [ + "## Level sets\n", + "\n", + "Analytic shapes have a `level_set(points)`: negative inside, positive outside, zero on the boundary, the same sign as `signed_distance`. Where `level_set_exact` is true it is the Euclidean signed distance, so `signed_distance` returns it directly. Booleans combine level sets by minimum and maximum, which bounds the distance without being exact; transforms keep them exact for rigid motions and uniform scaling." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "11", + "metadata": {}, + "outputs": [], + "source": [ + "focus = lens[\"focus\"]\n", + "points = np.array([[0.0, 1.3, 2.0], [0.0, 0.0, 0.0], [0.0, 0.0, 0.0]])\n", + "print(\"exact:\", focus.level_set_exact)\n", + "print(\"level set:\", focus.level_set(points))\n", + "print(\"signed distance:\", focus.signed_distance(points))" + ] + }, + { + "cell_type": "markdown", + "id": "12", + "metadata": {}, + "source": [ + "Grid-edge crossings (`bracketed_edge_geometry`, used by Picnic's embedded-boundary stencil) interpolate the level set between the two nodes of a cut edge and refine the root with a few Illinois steps, instead of bisecting `contains` 48 times. The crossings lie on the bore surface to round-off." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "13", + "metadata": {}, + "outputs": [], + "source": [ + "from simgeometry.cut_edges import bracketed_edge_geometry\n", + "\n", + "edges = bracketed_edge_geometry(grid, [focus])\n", + "h = np.asarray(grid.grid_spacing)\n", + "errors = []\n", + "for (axis, side), fraction in edges.fractions.items():\n", + " cut = np.isfinite(fraction)\n", + " points = edges.nodes[:, cut].copy()\n", + " points[axis] += (1 if side else -1) * h[axis] * fraction[cut]\n", + " errors.append(np.abs(focus.level_set(points)))\n", + "print(\n", + " f\"{sum(e.size for e in errors)} cut edges, largest distance from the surface:\",\n", + " f\"{max(e.max() for e in errors):.1e}\",\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "14", + "metadata": {}, + "source": [ + "Shapes known only through their membership, such as `FunctionObject3D`, have no level set (`has_level_set` is false); every query then falls back to sampling and bisecting `contains`." + ] + } + ], + "metadata": { + "docs_order": 10, + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/index.ipynb b/tutorials/index.ipynb index 4900e6b..fb902f3 100644 --- a/tutorials/index.ipynb +++ b/tutorials/index.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "markdown", - "id": "2f02dca3", + "id": "0", "metadata": {}, "source": [ "# Tutorials\n", @@ -19,6 +19,7 @@ "7. [Grid intersections and cut edges](/simgeometry/tutorials/grid-intersections-and-cut-edges/) covers surface crossings and cell fractions.\n", "8. [Sampling, export and diagnostics](/simgeometry/tutorials/sampling-export-and-diagnostics/) covers point clouds, portable meshes and resolution checks.\n", "9. [Boundary outlines](/simgeometry/tutorials/boundary-outlines/) turns 2D shapes into vertex loops and panels for boundary-element solvers, and finds where particle steps cross a boundary.\n", + "10. [Conductors and level sets](/simgeometry/tutorials/conductors-and-level-sets/) builds an Einzel lens from ring electrodes with voltages, reads Dirichlet values on a grid, and uses level sets for exact distances and edge crossings.\n", "\n", "See [Building the docs](/simgeometry/development/docs/) to add a tutorial." ] diff --git a/tutorials/inspecting_geometry.ipynb b/tutorials/inspecting_geometry.ipynb index 96ae9cd..c67d295 100644 --- a/tutorials/inspecting_geometry.ipynb +++ b/tutorials/inspecting_geometry.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "markdown", - "id": "inspecting_geometry-0", + "id": "0", "metadata": {}, "source": [ "# Inspecting geometry\n", @@ -15,7 +15,7 @@ { "cell_type": "code", "execution_count": null, - "id": "inspecting_geometry-1", + "id": "1", "metadata": {}, "outputs": [], "source": [ @@ -41,7 +41,7 @@ }, { "cell_type": "markdown", - "id": "inspecting_geometry-2", + "id": "2", "metadata": {}, "source": [ "## Compose views and save figures\n", @@ -52,7 +52,7 @@ { "cell_type": "code", "execution_count": null, - "id": "inspecting_geometry-3", + "id": "3", "metadata": {}, "outputs": [], "source": [ @@ -68,7 +68,7 @@ }, { "cell_type": "markdown", - "id": "inspecting_geometry-4", + "id": "4", "metadata": {}, "source": [ "## Three-dimensional inspection\n", @@ -79,7 +79,7 @@ { "cell_type": "code", "execution_count": null, - "id": "inspecting_geometry-5", + "id": "5", "metadata": {}, "outputs": [], "source": [ @@ -103,7 +103,66 @@ }, { "cell_type": "markdown", - "id": "inspecting_geometry-6", + "id": "6", + "metadata": {}, + "source": [ + "## Interactive 3D views\n", + "\n", + "With `simgeometry[interactive]`, `backend=\"pyvista\"` draws the surfaces with plasma-plots and returns a `pyvista.Plotter` without showing it. `plasma_plots.scene(plotter)` displays it as an interactive scene in a notebook; on this page, click **Interactive 3-D view** below the picture to rotate and zoom it." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7", + "metadata": {}, + "outputs": [], + "source": [ + "import plasma_plots\n", + "import pyvista as pv\n", + "\n", + "from simgeometry import Cylinder, Torus, Tube\n", + "\n", + "pv.OFF_SCREEN = True # render without a window, e.g. on a docs server\n", + "\n", + "parts = GeometryCollection(\n", + " {\n", + " \"vessel\": Torus((0, 0, 0), 1.0, 0.3),\n", + " \"post\": Cylinder((0, 0, -0.7), (0, 0, 0.7), 0.15),\n", + " \"ring\": Tube((0, 0, -0.1), (0, 0, 0.1), 0.45, 0.55),\n", + " }\n", + ")\n", + "plotter = parts.show_3d(backend=\"pyvista\", display=False, resolution=48, opacity=0.8)\n", + "plasma_plots.scene(plotter, alt=\"A torus, a post and a ring, one colour each\")" + ] + }, + { + "cell_type": "markdown", + "id": "8", + "metadata": {}, + "source": [ + "### The level set as a field\n", + "\n", + "`level_set_field()` samples a shape's level set (negative inside) on a grid laid out like a plasma-plots field, so plasma-plots' `isosurface` can draw any of its levels: 0 is the surface, a positive value a shell at that distance outside it (exactly, where `level_set_exact` is true)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9", + "metadata": {}, + "outputs": [], + "source": [ + "field = Torus((0, 0, 0), 1.0, 0.3).level_set_field(resolution=48)\n", + "plotter = field.plasma.plot.isosurface(\n", + " values=[0.0, 0.15], opacity=0.5, show_domain=False\n", + ")\n", + "plasma_plots.scene(plotter, alt=\"The torus surface and a shell 0.15 outside it\")" + ] + }, + { + "cell_type": "markdown", + "id": "10", "metadata": {}, "source": [ "## Interactive clipping\n", diff --git a/uv.lock b/uv.lock index a1dffe0..f088013 100644 --- a/uv.lock +++ b/uv.lock @@ -5,15 +5,219 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.11.*' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version < '3.11'", ] +[[package]] +name = "aiohappyeyeballs" +version = "2.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757, upload-time = "2026-07-01T17:11:55.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038, upload-time = "2026-07-01T17:11:54.055Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.14.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "async-timeout", marker = "python_full_version < '3.11'" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6c/4c/bdccd81e9ee225b69c60e7766c9a5b05364f118f4d383713b89a682d772d/aiohttp-3.14.5.tar.gz", hash = "sha256:5558a7f5a05af9ecf744af91e5baefc436f93c9333e656c27ec253f9a6bbe178", size = 8078052, upload-time = "2026-10-11T01:05:12.408Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/82/f7f0652e3a845e095d8505a2807a0fe5782abb71f99b2f516c93ff0be492/aiohttp-3.14.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ef692a24087a699c0a4a26af45e746e0c1eae2116f6d8a5ff91d8aae2b867b45", size = 834351, upload-time = "2026-10-11T00:59:07.668Z" }, + { url = "https://files.pythonhosted.org/packages/95/c5/10cb0a5195bc3ec05a74ee494f8431a15e559800f2e869f3526ad4a16f04/aiohttp-3.14.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1220353657ad49493551f089ce02f1a348fd57ffd585bfec77f2f3c4fe3a7346", size = 560663, upload-time = "2026-10-11T00:59:09.853Z" }, + { url = "https://files.pythonhosted.org/packages/32/dc/b7fefdd7dd64d080da2d69d609344191cc94b0e2eba70200c371014c9b36/aiohttp-3.14.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:330900acd0dc4cb8b27f9c127fbaad770964845338493e7906ae3822e82dbf8d", size = 553263, upload-time = "2026-10-11T00:59:11.5Z" }, + { url = "https://files.pythonhosted.org/packages/61/5f/3271cad1b34347e8bf27abee20d501ebdc56cec97e8ae59cd3fabb23d917/aiohttp-3.14.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0684952aeae1f5dbfe02d46039338513b94009baecd15d8e4098a357c4c4a2a6", size = 1843813, upload-time = "2026-10-11T00:59:13.143Z" }, + { url = "https://files.pythonhosted.org/packages/67/af/2011b30fe61889af112ad380e6fd44a93cca4b51296f612cac666c177882/aiohttp-3.14.5-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d94e44be379e569758fee8a9a58431cfc3c2598c708b92b1cfe96c66b4c94aef", size = 1811243, upload-time = "2026-10-11T00:59:15.091Z" }, + { url = "https://files.pythonhosted.org/packages/e3/ba/72f3ac523ed48d38cdfb169f1b2a2e93ce06d9fcf4f46d0f5692e9539bcc/aiohttp-3.14.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5af42135fdfebdadbc2bcd9c0842a48ccf0d62794c36a260b21dc4b94d1e0119", size = 1898815, upload-time = "2026-10-11T00:59:17.224Z" }, + { url = "https://files.pythonhosted.org/packages/e6/69/bbf24f3b555ea1befa7db1e2510c728c8b0e80e61ce360beb7d94bc562b7/aiohttp-3.14.5-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4f07fe3ac408d8b3f768be471dc3f56d43843c47d97c66120534467a15ead197", size = 2022390, upload-time = "2026-10-11T00:59:19.244Z" }, + { url = "https://files.pythonhosted.org/packages/5d/38/01f5fc732d5373d6c099db0343279cd4e6607287d336bab6ff6ae9085a0d/aiohttp-3.14.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f375db73a39f5cf83696d500e21a67f418dc9a988955756f254be8f03b7b3651", size = 1856329, upload-time = "2026-10-11T00:59:21.19Z" }, + { url = "https://files.pythonhosted.org/packages/15/85/1f86fbd8fe34ad447c63e679404a1d5be435fe256a9d281b791fc1ed0d35/aiohttp-3.14.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8df7d481654ac96fe1ba9a02a9f67770fdd367823e0d5ef01b922725c4bd2cfa", size = 1712763, upload-time = "2026-10-11T00:59:22.982Z" }, + { url = "https://files.pythonhosted.org/packages/39/1e/ced51cf47e427e7aa6142c3bf6f33022472703bc9061289329532d3c40c0/aiohttp-3.14.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e95c8def4b81c5d68d5cf1f54c07acd7c0d2577af244e5b6da802120825737c6", size = 1812125, upload-time = "2026-10-11T00:59:25.032Z" }, + { url = "https://files.pythonhosted.org/packages/6a/f1/9610b4e263b554ca4191501ba72b952efe06556b647da74887abc5d41049/aiohttp-3.14.5-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f2ebb54b3f932210503072f09974b4fb574d823e497a944adfdcd140a6a00255", size = 1829550, upload-time = "2026-10-11T00:59:27.078Z" }, + { url = "https://files.pythonhosted.org/packages/ac/8a/1d1fb27cf9fa99b13133de33044c4d10d7705be0d4173d6d56abad1b67f9/aiohttp-3.14.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:038c2c7e8caa26b6c8423779b5eaf1893904048a512c19b32fe841ffa5592b50", size = 1868179, upload-time = "2026-10-11T00:59:29.315Z" }, + { url = "https://files.pythonhosted.org/packages/24/9f/3cfafa86ff025623dcca0d30335db7b8a6f3fa348148441208147e827e04/aiohttp-3.14.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:b7806e804889231b0e06469fd4a5c06313d1c0a3377322b6d9237fa5e0fe4167", size = 1697721, upload-time = "2026-10-11T00:59:31.161Z" }, + { url = "https://files.pythonhosted.org/packages/62/1d/86439a23bf32296e715d8c75535f0cd701821d36719324baef65bcd6af9e/aiohttp-3.14.5-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:9bab2045550c4fe0f7baf89574db1b455c195750702ba96fef1f16972b146617", size = 1887000, upload-time = "2026-10-11T00:59:33.329Z" }, + { url = "https://files.pythonhosted.org/packages/1d/0f/6044f2d02b3874966d84cd119f9b8eda46aa2991230078aea4dd17750cc9/aiohttp-3.14.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:96a2e584f0b9ed8f1fa33211397dcf67bb7069866402cb405d191c2f0defb9a3", size = 1842020, upload-time = "2026-10-11T00:59:35.384Z" }, + { url = "https://files.pythonhosted.org/packages/18/64/b49d2a34428ff937631b07dde74d5879a7a2c8559aab39331d40769e8cb6/aiohttp-3.14.5-cp310-cp310-win32.whl", hash = "sha256:602c1e9b718a3275c580149f947e7fac65044c0a20e599553fb12e9700da9eca", size = 489282, upload-time = "2026-10-11T00:59:37.061Z" }, + { url = "https://files.pythonhosted.org/packages/a6/7c/1114e723f1c4a65597820b605a0ddcd7f3b28fdfde3ac8d520d517ca582c/aiohttp-3.14.5-cp310-cp310-win_amd64.whl", hash = "sha256:bea559ad70218d230663e4210875735076a9bfea5994cef34a55a25faeaf2544", size = 512021, upload-time = "2026-10-11T00:59:38.7Z" }, + { url = "https://files.pythonhosted.org/packages/48/97/e08c646f4bad63d77876b24591d013d7f33f02fda7c931eafe3224f38467/aiohttp-3.14.5-cp310-cp310-win_arm64.whl", hash = "sha256:dca3fa8d8a0a26679862eccb0b1a9151b2b9f1cd2c212e7a6335778faaff5833", size = 493016, upload-time = "2026-10-11T00:59:40.525Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f3/8997f18890a92c79f77fcfbb4f78ca17c2d4ab109e9eb6d31b4e9de194a0/aiohttp-3.14.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d51db97c96384fbfcaf8f4c65922183a68b94f891c3c10c862ef5f6df2adbb1f", size = 832358, upload-time = "2026-10-11T00:59:42.195Z" }, + { url = "https://files.pythonhosted.org/packages/8e/42/084651e9efb5cadb99265f786df60f2a7b353cead7bfd2c87003cb4867ad/aiohttp-3.14.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ae53924aa853a7a2ca20ed4142c7c6b56338e4d4cd999e2980075b9efc2e257a", size = 559636, upload-time = "2026-10-11T00:59:43.677Z" }, + { url = "https://files.pythonhosted.org/packages/3d/fe/92838944e601f0fe195fd3f3ada37e29fbee3d19bfd323fe15937ec79d36/aiohttp-3.14.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2c473a355f9239efcb72c92d5abfd8fcdb0cc78c8e9af607e72ca12dbb36593", size = 552588, upload-time = "2026-10-11T00:59:45.592Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b8/dd9b95c5c20ceae1b47738e656bd79e9883e3117a9cc8b143901eb604e9a/aiohttp-3.14.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32e8fa6644e541fcd7e02430588c7fc93b602c1778ea0bc345505db76b61cfb4", size = 1928224, upload-time = "2026-10-11T00:59:47.294Z" }, + { url = "https://files.pythonhosted.org/packages/c6/47/70010cd2ba8746968d53d433ff328c33f06a66c99da5f3de759c64a6b2eb/aiohttp-3.14.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:579f97d5120f2971876d2ddca2968135f6944d00c44c3a6590ad7d86ca9b403f", size = 1877674, upload-time = "2026-10-11T00:59:49.472Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a1/b026f071dbd87f0bdba86b92d47e46f3899e3ad7143b6e0fe7e7887090b4/aiohttp-3.14.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:24409db442e2fb6e766bc7f3943851a8381dec3098140e43bb2e843b79e31b12", size = 1985850, upload-time = "2026-10-11T00:59:51.296Z" }, + { url = "https://files.pythonhosted.org/packages/36/d1/f7b6f6f8c3cb5a71b53baeddc2e70c224c28b12a23a55c42994c504019b7/aiohttp-3.14.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5e8f97c0488ffda3082766ac0f2c8150a9a58c4d05788330e479cfd449b37939", size = 2105471, upload-time = "2026-10-11T00:59:53.191Z" }, + { url = "https://files.pythonhosted.org/packages/42/74/2a2b22953de15c6c8a80debf26db3047e4e8a5a5db6dd7c2c6c01eec0605/aiohttp-3.14.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50a195903119008fe9cc68710535eb37f556ffffd6a7759afe70a2c145587045", size = 1929915, upload-time = "2026-10-11T00:59:55.609Z" }, + { url = "https://files.pythonhosted.org/packages/33/ad/f80e8d33933d0eacd0217efa3b7fb32c9f48ed480ed0db53cf9948bf474f/aiohttp-3.14.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0133c3c3b54a0bf1e71fa5c1ad95c93f07fd54e24ef1fe182f5122e1573d2bf1", size = 1773832, upload-time = "2026-10-11T00:59:57.611Z" }, + { url = "https://files.pythonhosted.org/packages/04/a4/0273d239f3e3bb69438f209c64c82e1f98dc60d5db264795e41f7dd05dcb/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c172db893e516e1358e65a95ee20b7ce7173963eefe318b6ab2a2220688b999e", size = 1901001, upload-time = "2026-10-11T01:00:00.605Z" }, + { url = "https://files.pythonhosted.org/packages/16/ed/7dd439d26c654645bc34ca3c9a822fc11c1c51801fed1cefdfe1cc41c1f6/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f2a7966bda23dd85051f1661ce0ace38d6890e05ec6c357ecae9d2479cba377e", size = 1907218, upload-time = "2026-10-11T01:00:02.699Z" }, + { url = "https://files.pythonhosted.org/packages/32/dd/86249c3b8248562a17fd3e22ee0c164378d65766f01aceef62cf52783710/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:4887d130a7bbfed3a85493bb5a25e5b5b558d40c1d986dd16970d2bb26d63793", size = 1958650, upload-time = "2026-10-11T01:00:04.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/0b/ca4d53d68f683ce195807fd0aeb063bf6d1c6b39343fff234c9524e8d5a9/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:225c579c23b68b343cccea27a7e06e3bd8ec23a09c30b427eb3f1e4ca6239b20", size = 1760057, upload-time = "2026-10-11T01:00:06.978Z" }, + { url = "https://files.pythonhosted.org/packages/6b/42/005437ffd7fa56c2ce3347add40404b654a32754ce91f2e2a14b9e5ab2bc/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ab52d8f1fc1b64821c1fbad64a647ed6203627004059a6d1ed4f0858a1499703", size = 1978081, upload-time = "2026-10-11T01:00:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/3a5b66fe1b7b23d3c6524350817ed55de5feb7fbb4cccba7cbe044d118dd/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cb131d775a1573c1aee66656bd78b023577bbdb6cb8349a07773bd4f73e68a6e", size = 1922954, upload-time = "2026-10-11T01:00:11.035Z" }, + { url = "https://files.pythonhosted.org/packages/69/84/d08a554f281d42fd7c6b2b6dd7738f3e3a7a9f657ddd87e11ca2091cdfe3/aiohttp-3.14.5-cp311-cp311-win32.whl", hash = "sha256:e87046c8ff77a8decdb6a41d8ab25824b47531b2da933aeab0c1e21c7acff329", size = 488101, upload-time = "2026-10-11T01:00:12.933Z" }, + { url = "https://files.pythonhosted.org/packages/0f/15/52b5e65f02b33686ae49f1518548ae4f5a7adaf31d9d3c54fa12a143137b/aiohttp-3.14.5-cp311-cp311-win_amd64.whl", hash = "sha256:6f275c11d1aa6d4c458e05a68be084efe3c55a113d99e3f46a318098e52948fc", size = 512575, upload-time = "2026-10-11T01:00:14.743Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b9/bb75012635f3defb0f7cbb7c4ae391e36bb9cb3fbdca3b9944e2ebebf743/aiohttp-3.14.5-cp311-cp311-win_arm64.whl", hash = "sha256:b032a0023eb41d768ce77d83210ab2a3c389bc0b09313273c7e1eca48c10a755", size = 493179, upload-time = "2026-10-11T01:00:16.961Z" }, + { url = "https://files.pythonhosted.org/packages/d5/94/6ba86efddcb616c811b40e6a0dfdd862738f647e4e3860a961075d5e9ed8/aiohttp-3.14.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:df37b620684e19b5e25724412518ccafc3b1a49cdac706fdbd2f983fad943450", size = 817499, upload-time = "2026-10-11T01:00:18.901Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e1/7bca6d84dabd228aa8eb4b7f9feac2586aaa9be5505d7d65bf287a615c43/aiohttp-3.14.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ef60869969180ec2464f1349aff07138ae35ca2200f0946cb3552e49e8f301a8", size = 549467, upload-time = "2026-10-11T01:00:20.854Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/11b89f45ca486252dd67dd5f3231fec04bf5518da39a95cb3997619f17fb/aiohttp-3.14.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d079c0a0135c36e7beb6f1c88087c8f108dc5891cdd0b5eafa778421bda70ed2", size = 548067, upload-time = "2026-10-11T01:00:22.659Z" }, + { url = "https://files.pythonhosted.org/packages/22/ff/c6615806c14aab34f82b9424ccde8ce6e417315fd57ce1c5b4d4747888e1/aiohttp-3.14.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abfda5cb094a829f7bc25216a32f7db2e85cc65bd59910f8e7b40b3d9b224764", size = 1896245, upload-time = "2026-10-11T01:00:24.638Z" }, + { url = "https://files.pythonhosted.org/packages/39/b2/25a8c971ae6a92c8394d77e42422d7f38989e05cf41a5ceb92d73d67ab7e/aiohttp-3.14.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9cc882cf8619109583c906b4d4a85d6a111a98afa34b7a450d1e08118d016820", size = 1872180, upload-time = "2026-10-11T01:00:26.838Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d5/99f93ea36cc5205e47c1e5a803e087f2ad21b5430b5db2e942cb6e988a37/aiohttp-3.14.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7457580535e019e1247ea35d6a02bf081ad30c26d0cbc210c93f6c3ab67a0835", size = 1937261, upload-time = "2026-10-11T01:00:28.74Z" }, + { url = "https://files.pythonhosted.org/packages/40/a6/9ac9c9e6695040bd73d2584a1b59a9388f6433c76d5294a7bf591e21ffe5/aiohttp-3.14.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c5ed596aedb9c42afd3fe0aae3117725378ac73d2cc5ddc735056fbdb96c5d02", size = 2064994, upload-time = "2026-10-11T01:00:30.623Z" }, + { url = "https://files.pythonhosted.org/packages/da/e4/aa172eb534b7f02f1f8ff1c3213347eaf3cf218db91a727c6863c22f1035/aiohttp-3.14.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20f085697d7e911f1f73c43ed03fafbed1e7121797e2eb5428efa80398060584", size = 1919630, upload-time = "2026-10-11T01:00:32.548Z" }, + { url = "https://files.pythonhosted.org/packages/06/7d/4eedafc5bababa8932636c141e346806966eade12c0b7e5946d43bf8218b/aiohttp-3.14.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:74b0a9c8270f9b0a11410e124ff8d4f18bfc1f1837440ec84da5ae7b50927b5d", size = 1710016, upload-time = "2026-10-11T01:00:34.471Z" }, + { url = "https://files.pythonhosted.org/packages/b2/94/eee018537ba19da0ceb2ac79cab83faed4ac49568e08376e2799043f2538/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:19e2ba471507c34f8252402ab50f5ab512398b9ea8c8f1cb26beb3f75793ba30", size = 1858112, upload-time = "2026-10-11T01:00:36.581Z" }, + { url = "https://files.pythonhosted.org/packages/68/76/354653a306547238f3427283905972d796ba7c292ba9977abec9f2b6f260/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d418ce2af40c6bb685b3f663e9e8de27cb0a22431d8e88a167348d7f01878073", size = 1896371, upload-time = "2026-10-11T01:00:38.478Z" }, + { url = "https://files.pythonhosted.org/packages/f2/ec/63e8c7136b570e356345ad3174e3820fdc973ea10712cb6c649bf875755a/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:70cb4008ac2ed1e0ca9e824deb4b53d3aa0d939109698ebf1e723a84337bd794", size = 1905836, upload-time = "2026-10-11T01:00:40.527Z" }, + { url = "https://files.pythonhosted.org/packages/57/d8/11365bda144b127928cd42533d0eff78a55613c9e28f81941bd6630ea887/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:a23fe35d776bc03cb495938b9594450d047e3bc08c5255315a82323e9cb7d2dd", size = 1696534, upload-time = "2026-10-11T01:00:42.686Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3d/82df0461b18e00b2998f205c03e0d3010222478c43640aceb8e03dcd7e8f/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3e0eb43bed3c6801a6cee315195377789e90b2a72c2277a475b578535312488d", size = 1933970, upload-time = "2026-10-11T01:00:44.71Z" }, + { url = "https://files.pythonhosted.org/packages/03/ad/6ddfe0aacd931c17b53533336d97e9d11a98b96d6ae815a9da0b19f82ccf/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be7dd397d64ca3e1869626fa9318aaebb54b7bf93bc72d7a205448d83e4f748", size = 1899501, upload-time = "2026-10-11T01:00:46.629Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8e/189bdd9ae4793059bb09f6dc880f211a6c6c7859cebcbf33912dbfab7dd7/aiohttp-3.14.5-cp312-cp312-win32.whl", hash = "sha256:eb324e2009fb54db30a071dad7caf6998ee2879c4704007efb244514dad1fec1", size = 480370, upload-time = "2026-10-11T01:00:48.468Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ce/1f08114679d49655b30a6e0a29858375c94b82c1de1a0bd0a20c2fee8b02/aiohttp-3.14.5-cp312-cp312-win_amd64.whl", hash = "sha256:2cc38a4f2b516bef1714e690df87a0e043faf1a7693c82d860091684453d5111", size = 506141, upload-time = "2026-10-11T01:00:50.272Z" }, + { url = "https://files.pythonhosted.org/packages/79/d4/c7b4f60b16a1b7e43249fa9031ae05e7e8c341ba4b7d1866f914dafeaa0e/aiohttp-3.14.5-cp312-cp312-win_arm64.whl", hash = "sha256:a63afd1f757de949028387e65a7127b61ad0f775432dbb0e62816ae619fe69ac", size = 488108, upload-time = "2026-10-11T01:00:52.321Z" }, + { url = "https://files.pythonhosted.org/packages/d3/e1/2841e020ebb7aefae5513586193e011e06313d9a6bdbd296622afbbce204/aiohttp-3.14.5-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:9ad7e6aa38c20da1be697874349c4c273c8a03b7887169665081706398d0439a", size = 538932, upload-time = "2026-10-11T01:00:54.3Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a6/7fb8ea8fe96bcc7b7c7a36d10f021d99d01a8dc8a4b3f0ddacecfad9a80e/aiohttp-3.14.5-cp313-cp313-android_24_x86_64.whl", hash = "sha256:f59c7673465908cbe506117176156c127f29f917677afceada34957179221d91", size = 553329, upload-time = "2026-10-11T01:00:56.442Z" }, + { url = "https://files.pythonhosted.org/packages/de/64/d056e3c27647dc25af1a592cf356245382ea7c808171b9dac7677afedfc8/aiohttp-3.14.5-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1b5416552740edf07234cc9437d0706f2acb67b93c198670b1a68e1b2b587dec", size = 521367, upload-time = "2026-10-11T01:00:58.345Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e4/95226147e11d4db916fd1d495dcf85af8e3816e38333e42718241196e848/aiohttp-3.14.5-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:43351bdb5e4c3cb7d1772368e988534e869a74db7778079a83782c11c69535c7", size = 528322, upload-time = "2026-10-11T01:01:00.211Z" }, + { url = "https://files.pythonhosted.org/packages/17/cd/1d3c9192cafdb51cad62b2d3ded96cff9cc8af51893210aadd448a325389/aiohttp-3.14.5-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:c8c4478bef6d57fcfda15dae461ea3c9f06aa7b257c58df3f2300174ccbb185a", size = 539874, upload-time = "2026-10-11T01:01:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0c/dfa33aecc7d4d1dc75e05248f5eac5a0edf4d09e7b44d93ab62529b0c1db/aiohttp-3.14.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c2c30484dd1417ef98b51021ffa2cc0d7f3c78918adaaaab7e70817335ab3e02", size = 813836, upload-time = "2026-10-11T01:01:04.01Z" }, + { url = "https://files.pythonhosted.org/packages/33/17/4a63738052d20567d55529d6daa1b9480d906fd52930fbcf6d3fbed618f0/aiohttp-3.14.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dab9ac5a67c8d1f070c00fa8fccb7cbd1b8dcc1a8d6b42f37540df9b3d4cc603", size = 548070, upload-time = "2026-10-11T01:01:06.035Z" }, + { url = "https://files.pythonhosted.org/packages/15/e5/b57e58695a757fd4c02497c033fced96a69c631b13866c43d336530c9670/aiohttp-3.14.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e1cc2bfaee8c214f06080a7c7d5772419b8a1108e8e5349236189811823fb02a", size = 545786, upload-time = "2026-10-11T01:01:07.817Z" }, + { url = "https://files.pythonhosted.org/packages/9a/68/8c2c67a3aedf46e00f3c42f04fbc6983de80d4ed5786151e33681ba45883/aiohttp-3.14.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74efb69332b85675b1eabd760a8cfc2e2cf42c60607c66f88014c1bdfb40942d", size = 1886492, upload-time = "2026-10-11T01:01:09.834Z" }, + { url = "https://files.pythonhosted.org/packages/ab/4b/74aab5e8d28c62e8f795b4fe8f38cf5586fd264a9a27bd2141ef6490333d/aiohttp-3.14.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:42b5e616946dbaf505e2bff18c9af2cd4ef9e7ef300ee58a6e951a5b7cf147ae", size = 1870057, upload-time = "2026-10-11T01:01:12.045Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f7/eafc3b1988302b1815d9fd4a21071be5c360d616c0a430d02fd92dc97688/aiohttp-3.14.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f9033b43f511f27547c557dcaba0177649e10a3725336ccd2cce0fdc1dc4850d", size = 1925119, upload-time = "2026-10-11T01:01:14.09Z" }, + { url = "https://files.pythonhosted.org/packages/a1/04/78d8f294f74dd570f3898ff20402349fce524176045df98ba727d6846a68/aiohttp-3.14.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:15a310d3c71398e3d7bfc93a1a73fbe664315cd9e9016b8efc1cff85eeab7155", size = 2053137, upload-time = "2026-10-11T01:01:16.344Z" }, + { url = "https://files.pythonhosted.org/packages/32/51/395d225ef36f5a50d8e548dcd3141bfdbcd31fb6eed859022c573d2c4d66/aiohttp-3.14.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ffa3a523a36d8628f98c06492ae16a31a23d14c0b4ec721757b477319f656d6", size = 1911038, upload-time = "2026-10-11T01:01:18.653Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a4/2aec1aa06d82e8a244843b5dae31d78061e5e76744270a86dd0ee051c889/aiohttp-3.14.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5fb6a6e919bfb703227bc1ce6579281b84b1a2ba57deb9794dfdbec7dcd1e40c", size = 1702205, upload-time = "2026-10-11T01:01:20.904Z" }, + { url = "https://files.pythonhosted.org/packages/16/27/6051bfde7b6f418f70edd60d655fa426abb3355fa0981764739d87ecf160/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:43e1b7994a8b038125f722bff07492ef501110722c2727c408995d9fb864c421", size = 1848588, upload-time = "2026-10-11T01:01:22.918Z" }, + { url = "https://files.pythonhosted.org/packages/9e/44/55efc06fc26c4e6e1c095f231b4c222bf2d86d64a8eebbc24b2bb5958ea8/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:5f3e96071686755d9cd3600c3880183eb94b012178e92746d68101800f0ed8a3", size = 1887540, upload-time = "2026-10-11T01:01:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/48/dc/1502bfdc2a65760d386ac6a00090b0addaa8a3c9c60b3f8127fad3a9afb2/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:cd88b01f3d37b7a2a34f91d98f14720206f1ea3d540843fab2d649dd5fb91fec", size = 1895030, upload-time = "2026-10-11T01:01:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/93/7e/44174bb6288264418c9eec07a5e35180969c0d5a796c7af544db3cb8a33a/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f2ed8b64dc0c651c0f5a9c926777719770021251b8f336d97c4b80b660836ce1", size = 1693244, upload-time = "2026-10-11T01:01:29.39Z" }, + { url = "https://files.pythonhosted.org/packages/47/dd/b507d64e50db23888582fff08eda13998b12f9dea70c072edaac19218380/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a9918e58faf62ba2c7147927d06057aec78f42475aff5048047ec47e7265a600", size = 1926308, upload-time = "2026-10-11T01:01:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/98/4b/5b51b4f63e3f2793151f4aea49c48fe1e00baeb7cec9c7a206de499f8de0/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:42f320d4a5b00b9af0bddcfec5407dc6f2d9816f006b2f79ebbaa31f16895df3", size = 1888406, upload-time = "2026-10-11T01:01:33.715Z" }, + { url = "https://files.pythonhosted.org/packages/ff/13/d5e818a5eaba9f822016727299f41c0e1075799f82a6433ec508a93ab867/aiohttp-3.14.5-cp313-cp313-win32.whl", hash = "sha256:3ae800a20947e2c2e53088047d021e6bf7d51560cc49f6a0737a1f79d2e3a13c", size = 479970, upload-time = "2026-10-11T01:01:35.677Z" }, + { url = "https://files.pythonhosted.org/packages/8d/d0/8eca2c65aa467320990d78fb2005f38ed3588944280c39ff9deb6423fef1/aiohttp-3.14.5-cp313-cp313-win_amd64.whl", hash = "sha256:d05e94cdfe0d15d0206f970722d2554780ce562787b21b218b275447f8751319", size = 505985, upload-time = "2026-10-11T01:01:37.574Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f8/4cdd65305d2fca14b886bea9ed2abb1fe726287872524e56e3d26692b47d/aiohttp-3.14.5-cp313-cp313-win_arm64.whl", hash = "sha256:f001b571ead90ca1770f1e616db255351a1703317f20374c361ef22f12c06d09", size = 487629, upload-time = "2026-10-11T01:01:39.477Z" }, + { url = "https://files.pythonhosted.org/packages/43/be/3184a1d34a8be665569eadb7e9e764b4629e4f3413e241cb2e4d6fecf3b3/aiohttp-3.14.5-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:939042d5cda21d41a6f512e7cc8b8e33a2aebff863352251da495fbd91b673b5", size = 540673, upload-time = "2026-10-11T01:01:41.354Z" }, + { url = "https://files.pythonhosted.org/packages/60/2a/d35f3ba4cf157b072e3b674bf9983047ca5ea5173c995d32d877e1191d36/aiohttp-3.14.5-cp314-cp314-android_24_x86_64.whl", hash = "sha256:6da32b5ff3fd78d244e37300463434c7145162bfd2b6e9e915ab164da37f7343", size = 554087, upload-time = "2026-10-11T01:01:43.719Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d2/61a33880ca4eaca95a9c60ca3f6beed15555af1028652dfaac601627787b/aiohttp-3.14.5-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1b438b73c38111818d0c9d6a5c2bfed8584c8e503a49ef085d70e874ec846738", size = 522613, upload-time = "2026-10-11T01:01:46.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/de579b299d2225dc2c6fd99d579d91f16c02a913fb5af9a3cf2fe9bd88ba/aiohttp-3.14.5-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:755933b107ea7a6a9ac916f635a70595a5b1a32fac10a8ff0b9f2ab88555550c", size = 529682, upload-time = "2026-10-11T01:01:48.477Z" }, + { url = "https://files.pythonhosted.org/packages/f4/4a/ddb923564e15e053b6e060b0036e1694dcadcb13aa476c5a87dcad20e336/aiohttp-3.14.5-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:b3cc509327c7b27f6f4727a8830f4004f6df7766e179f2f4b8e54e65c0bec5d3", size = 540120, upload-time = "2026-10-11T01:01:50.474Z" }, + { url = "https://files.pythonhosted.org/packages/3d/36/a640fbecaa53727a5900b892bdbe17b5f3e8cc88903e22864fe41b654def/aiohttp-3.14.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:7bd8ac754ebd6733a3e2a0dd1674c4d8ab086196803fd8dcd776f07b4e2607d9", size = 818795, upload-time = "2026-10-11T01:01:52.665Z" }, + { url = "https://files.pythonhosted.org/packages/ef/b6/d52ca608859e271b5fa7944074802dc45f60e52c318a4ddc34edbf73586e/aiohttp-3.14.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e724a7b6091f0b1ac064f9d1b15ff9ec52e6033a86cdae649e5f086e32a3c0db", size = 549667, upload-time = "2026-10-11T01:01:54.65Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b5/05b8ac39a76ff4bca89f42a4c2471560c71f894ff6e4bc16158c951874e3/aiohttp-3.14.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c32e26310cc10e547f53cd13d39a369034f69dcb7d749d5cb0e5f67bc196b6ba", size = 548922, upload-time = "2026-10-11T01:01:56.547Z" }, + { url = "https://files.pythonhosted.org/packages/19/b0/5aa186d56ce2334dabe29b70bd99dc8ae926ee44184c0de64a53d415a4f3/aiohttp-3.14.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1eb8167961ec4dfcc8cb9dd50bd0ee72519f7ef496be95203e49e27b01618382", size = 1882422, upload-time = "2026-10-11T01:01:59.258Z" }, + { url = "https://files.pythonhosted.org/packages/db/f7/7d5c91bb9620db300c8ddb05337a9014301acb626223faff3abcb8ea47d7/aiohttp-3.14.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c1d60eafd9c7e8e74abd03a5b00df44e7febfe6d9b89b559c0a6551eef0699d4", size = 1843621, upload-time = "2026-10-11T01:02:01.417Z" }, + { url = "https://files.pythonhosted.org/packages/30/0a/b208953b96d8f24b75f6da704f508e6c5cf3022f52b60c61933df082e89c/aiohttp-3.14.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:137351bf20bbed9a65e839f4a4452ac377389bdb2f2857d2acffef38f5e9f2d1", size = 1927915, upload-time = "2026-10-11T01:02:03.697Z" }, + { url = "https://files.pythonhosted.org/packages/f6/79/90ebcccb55e2d1e11a1fed581d83bb966e38fb35fb4b2577fdc980f8707a/aiohttp-3.14.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fba47bc2c3d7303c3d027c6cf4d07626c37b1314ac81f5820c31032e0ca1f677", size = 2036339, upload-time = "2026-10-11T01:02:06.046Z" }, + { url = "https://files.pythonhosted.org/packages/a6/66/55a8904b3a129fafdf94f9cc0a2e4ca09a3c914650be52355db7ad0bbdb6/aiohttp-3.14.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94684b879ac1d71e4238850c99b62dc1b28d9086b156a2555f082010b85a865c", size = 1888041, upload-time = "2026-10-11T01:02:08.384Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b8/96b25da7329a52e42c812b1e8b076386039ec4fc312afa043d173d8147fc/aiohttp-3.14.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:56572c42e3ecd636de8d2c3dd54cf5fc939cb5c32eb56297f176a0d366fac622", size = 1703849, upload-time = "2026-10-11T01:02:10.903Z" }, + { url = "https://files.pythonhosted.org/packages/1b/43/fbf976e3ae4c038d6f5c84945ab2150298c2d71201674d4e53d158063e75/aiohttp-3.14.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a95529a92a446db351675f4aab518feaf5e99842f63f5dd17160c2b74f382db3", size = 1849703, upload-time = "2026-10-11T01:02:13.15Z" }, + { url = "https://files.pythonhosted.org/packages/8b/7d/218e912f4c1d89bde7ac551409be57ad2f6121638e56942d395a7cb1fa58/aiohttp-3.14.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:3edbece0379b8b4aaa67619b8aa2399bb66fce372cd5911098a434ea77220aa0", size = 1852695, upload-time = "2026-10-11T01:02:15.295Z" }, + { url = "https://files.pythonhosted.org/packages/5a/42/252a1b9287e3b6e393a1c3bd1776f36af30f5f25f071bbf2b0cb7eba9116/aiohttp-3.14.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:56d9828f204331a5ca8850fcfe2bcce95a149f1f223f60cc7216e5524978e480", size = 1893493, upload-time = "2026-10-11T01:02:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/78/97/71cae83d5100556fad1521684f7cd1e3e578432644f850245ed3bd969310/aiohttp-3.14.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:20064a177a070d789ee64a50b01a9161d3468e989baacfc6c714aa685c4b332f", size = 1693468, upload-time = "2026-10-11T01:02:20.666Z" }, + { url = "https://files.pythonhosted.org/packages/1f/69/73d88e97a8b5f0ca7a946d0011c0de99fb188b1687c7948ecd0553dc5bf0/aiohttp-3.14.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:81c2b3dfd56c62bee6108e4852d5970b4cf9086390b6983f52b666e878c1f115", size = 1915398, upload-time = "2026-10-11T01:02:23.011Z" }, + { url = "https://files.pythonhosted.org/packages/05/f0/881644bcb15d4b258daea9b720a0af9dc4330496cc8d6ade9090cdd0cffc/aiohttp-3.14.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:09ec102b4b8c9a920275733bbc11fdbb615efe6f9231a06007c0218d336fb77a", size = 1871470, upload-time = "2026-10-11T01:02:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/7f/de/19d9ebbcce5aedaa3242d8a99ff8816a60bdb7629fb0084bf4a45bfd1f62/aiohttp-3.14.5-cp314-cp314-win32.whl", hash = "sha256:9c428eb2bd8817588d16a0ab898aa4eb5d141f896aa2b394cc79a4cf61d9a8e2", size = 485517, upload-time = "2026-10-11T01:02:27.579Z" }, + { url = "https://files.pythonhosted.org/packages/a9/74/8cdaf0e58c2588371670d5a9a8215bbb971d36940b6e5d051967dd05c07d/aiohttp-3.14.5-cp314-cp314-win_amd64.whl", hash = "sha256:6f967dde489ca6a8c02d093ab245d2cbf50ccb5c36adf0188b17b0ca39d24b67", size = 511755, upload-time = "2026-10-11T01:02:29.685Z" }, + { url = "https://files.pythonhosted.org/packages/1a/6b/e0100e25502430a531c7cf1482a378d0b65bf728ab60c01ee270e56bc469/aiohttp-3.14.5-cp314-cp314-win_arm64.whl", hash = "sha256:1d2d981b53dd09a319e3570ef8cc3bbc3ef86f5a7abef0f6b2bff3867db3a9e7", size = 495151, upload-time = "2026-10-11T01:02:32.163Z" }, + { url = "https://files.pythonhosted.org/packages/9d/c2/ca2ead7b655688c53c03aeeb6e96e6851c9ff08d6be7b13802f53a6ae8fd/aiohttp-3.14.5-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9ce66feae6ac65327379460380549bf1b8df8e17c4e25df2a2bcf168272e3bed", size = 855396, upload-time = "2026-10-11T01:02:34.443Z" }, + { url = "https://files.pythonhosted.org/packages/a7/70/22206fea409255a240c926ce11de48de354ae2bb90ca44f709c05497585d/aiohttp-3.14.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:27c2322e03f66101acb09869ce1cf1efc04994ee95e1735b69827bf8c8b9d781", size = 567734, upload-time = "2026-10-11T01:02:36.644Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e5/79a36c118308b56f8667d67e05d2fb6dc638ab45985704cdb199631bedaa/aiohttp-3.14.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff75a7537413a86e7cafe98e0e1d6e3dc4b15c6349896e7d5c6b881bfdb6d550", size = 567379, upload-time = "2026-10-11T01:02:38.757Z" }, + { url = "https://files.pythonhosted.org/packages/63/a3/2ebec7dece3b1f02c30d2e484647f6f7b13952b1bb40a4cb285b849e8432/aiohttp-3.14.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c061aa954daaf57d2a4b8374f9fca621ef0e1b603584431c220c22458c59b6d", size = 2050686, upload-time = "2026-10-11T01:02:41.169Z" }, + { url = "https://files.pythonhosted.org/packages/3a/d2/7e4d093db2f4450482652e7ef19a9e19919028f5135aa52bc4078c3beb80/aiohttp-3.14.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1612fa5857b37bf32e5c1eaeefb96e3b01e9c70679eec81f0934e8a600080863", size = 1915077, upload-time = "2026-10-11T01:02:43.563Z" }, + { url = "https://files.pythonhosted.org/packages/a6/88/bd40d09958442a0a1df67da81de496361d6e2afc04f9db2950d835da750b/aiohttp-3.14.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:adbeee7d6fd4cf5fe0aece2fb3edc4243615d3180430ba8149d01a90670cac99", size = 2027204, upload-time = "2026-10-11T01:02:46.185Z" }, + { url = "https://files.pythonhosted.org/packages/63/eb/3a601c1f8d3103c1a60ea981f20924855da9f575d2007fc38f8898b792fb/aiohttp-3.14.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b2966998927d7bed9db12c0a4647b0c7b179755878fc9c357fe1ffd3e3b0c1a5", size = 2149520, upload-time = "2026-10-11T01:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/22/d0/4e41bfe1b1ce1cb6f6d2e59fa7a88ef5cf92c402b2d07e9018778ba9edbc/aiohttp-3.14.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e317e0fb6b16212c881d2205a7d87414c29acd69320b3aa6dce9d9c7b86fe4f", size = 2002039, upload-time = "2026-10-11T01:02:51.293Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5e/72067019545c502b881b031153c437752ecef48d7d213bc0218ccebb4bfb/aiohttp-3.14.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50343c1757b4b6f6708eeaf24534b32f19dfb99fb1b762c00420867a62fc81e0", size = 1786164, upload-time = "2026-10-11T01:02:53.533Z" }, + { url = "https://files.pythonhosted.org/packages/d9/fe/7741efd6119bfb7a00827fe6f7b84b4409de58d888ae21adc5a9a6824992/aiohttp-3.14.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:083673c7a94c3ea035caaa5ca04288bdb44887abfe1f5ba23294e6a4b03efd2d", size = 1970689, upload-time = "2026-10-11T01:02:55.888Z" }, + { url = "https://files.pythonhosted.org/packages/43/e7/342a13bf67f34d269bf2f7e870ecd72b99c832cc6f0a271a9210c0ebfb84/aiohttp-3.14.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:2528cb4c6b92008c76ac9ac6298624069bb2db91ff4929905512d1d84485f658", size = 1951967, upload-time = "2026-10-11T01:02:58.467Z" }, + { url = "https://files.pythonhosted.org/packages/e7/d4/fdb3b27340617e5e64df18a70fa89097778652ea5c7c7e2f79def76999c3/aiohttp-3.14.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b1b8ece1e71132d2afba4dbc0c3d62c766e25165990b25db1196c04969eb3d84", size = 1968511, upload-time = "2026-10-11T01:03:00.883Z" }, + { url = "https://files.pythonhosted.org/packages/83/b2/e8f88298de78d1a951f36f9f966d38ec6ed1d4721303ba02064a545e8aa6/aiohttp-3.14.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fce9523df31cea6284f3e2c479876750d7687cf671d7b25d32b19effc0e86441", size = 1778497, upload-time = "2026-10-11T01:03:03.206Z" }, + { url = "https://files.pythonhosted.org/packages/22/68/9ccdb93d664345c546be7f34480b921774d8c0d98f47e70d7e03b115d475/aiohttp-3.14.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:09e0eb18c7e0c8777e2f9149de63799195b9b3ca1b5c81ba6f32f2c6b8628210", size = 1999676, upload-time = "2026-10-11T01:03:05.829Z" }, + { url = "https://files.pythonhosted.org/packages/57/4a/a33cfa6dcb00e94194ae4fe710432ca4ed111e016356b4ba4d2f4c3a124c/aiohttp-3.14.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6774814fd5c338e72ee0da5cbb9432816df450e69c019f72b5d29bdec2a1792d", size = 1963437, upload-time = "2026-10-11T01:03:08.196Z" }, + { url = "https://files.pythonhosted.org/packages/f4/20/eacbecfea3b5c3dcbfc9b023e3a5460f43e5dda16d06a2877ebe7184c3f3/aiohttp-3.14.5-cp314-cp314t-win32.whl", hash = "sha256:33f706574e32c6e694f352a856e05caf18f7f2c871b3e87b41c55ea452b409ab", size = 503973, upload-time = "2026-10-11T01:03:10.481Z" }, + { url = "https://files.pythonhosted.org/packages/ff/78/18eec294f6c8c5dc845dcf6d730a0147d8d0f17e86138a7bdb85e43a30fa/aiohttp-3.14.5-cp314-cp314t-win_amd64.whl", hash = "sha256:5ba14a839fbe87cf7c12a6b5661c05f324a296eb8363141edb3944ba63d4c9d3", size = 531190, upload-time = "2026-10-11T01:03:12.716Z" }, + { url = "https://files.pythonhosted.org/packages/9d/39/e53f8169acc85271ebd12b5b32ad7f1541b35639ccbe0f49034c64785d10/aiohttp-3.14.5-cp314-cp314t-win_arm64.whl", hash = "sha256:1061b364556e8172e8d46b0b183adeeb73e8c42d30ebc745591e1bd89acad52e", size = 510360, upload-time = "2026-10-11T01:03:15.08Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1c/06d89f58b2db3ee92dd377217659d587e06f973e57bf0a97a0d8a4586c0c/aiohttp-3.14.5-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:788ecaa9c10533b786ce5ba70c4f2df78ad41819fd00a6c99d92b66f9a32e1da", size = 540674, upload-time = "2026-10-11T01:03:17.483Z" }, + { url = "https://files.pythonhosted.org/packages/18/39/5e822e038f496f0540ada91e27099d48f9e7f919b6c6deb4cf6db36cc706/aiohttp-3.14.5-cp315-cp315-android_24_x86_64.whl", hash = "sha256:5c76f1802bab718a68ac3cce447160605c734551f95c67ae90fa1132b215cb29", size = 554183, upload-time = "2026-10-11T01:03:19.71Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ff/0cf2619d902b5b160762422a7e5e02091295766a7fe4fcf5b9a655e386c1/aiohttp-3.14.5-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:a6d02b4c38de03d9c7617813433e6a0fb6b522797974177d69d9dad431900833", size = 523160, upload-time = "2026-10-11T01:03:22.193Z" }, + { url = "https://files.pythonhosted.org/packages/86/99/3553abfc53a40849dbaacc3f54c730ec58410809ffa2d05885ae56108f2d/aiohttp-3.14.5-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:20726f9782d5c2744c1c66255842d1d163bb3edcf768b8de25216bf47f7b6ccf", size = 530311, upload-time = "2026-10-11T01:03:24.435Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a4/5d25f73754bc1e8f983ba704e86d641aea290c967f195f2aeebdaed2bd84/aiohttp-3.14.5-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:248d779ad720b49d4fb355720e60c9e5f444f95887bc16974fea48fc56c41789", size = 540274, upload-time = "2026-10-11T01:03:26.698Z" }, + { url = "https://files.pythonhosted.org/packages/29/a4/07eda5db2e3ee017d9590f36c12a94ec6f1f50516e8df78672373dfc7185/aiohttp-3.14.5-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:0a8ea271867e360ac985ae607f4a23ad9a38414b9aca1d49ec98839ae660e49f", size = 819239, upload-time = "2026-10-11T01:03:29.481Z" }, + { url = "https://files.pythonhosted.org/packages/cb/aa/a8723dd987a696dd48d4cf2f0088e589ce77caebff0b96f2a78c20424380/aiohttp-3.14.5-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:823c910f046f23f4c713b8d99a2242dc65f591cb45ee86418fa11762a3c2963c", size = 549832, upload-time = "2026-10-11T01:03:32.02Z" }, + { url = "https://files.pythonhosted.org/packages/29/5c/969a1b72692055fd2a419590c41847ec9144fefb97b75ff1cde5b6372891/aiohttp-3.14.5-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:9b42db919715e91eb76acf3bc492a9a7ccd8bd9adc6745c1412b689735269f14", size = 549234, upload-time = "2026-10-11T01:03:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/38/05/8e3e07fd8a0d33d06955ff4e54a1cb92f4bce347ff55e441dc3e25a7b5e5/aiohttp-3.14.5-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3112585250b199296c26ca6e0131640b6a8d01bab8b232d2eb3763ed469de11", size = 1891314, upload-time = "2026-10-11T01:03:36.899Z" }, + { url = "https://files.pythonhosted.org/packages/b6/b3/05a79ce2e25f024e93de30c94f39dc6aa6e2bc1e9c531a5c4b18dc61b7a4/aiohttp-3.14.5-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c147451b4a58e7050f7f7394e6c467867c84161560001f9ad4fb2d1446743946", size = 1884372, upload-time = "2026-10-11T01:03:39.334Z" }, + { url = "https://files.pythonhosted.org/packages/94/52/0fd8af0717db109eea258191b326b5cb5847fb88928bfa6c862fd78b9ad3/aiohttp-3.14.5-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bf163cc701f3d4ac43ba7d97771bf5fd955220ef5500ef3ee847bc0ecfbf4ec1", size = 1930957, upload-time = "2026-10-11T01:03:42.172Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b3/fa78733da88812bf9fb193913fb0ce1548f8b6912047633fdf88a758ff8c/aiohttp-3.14.5-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f8d40ce41991e9d56fab4f5dc4a51fe59bc3b5c77c27f4b148963064d00232e8", size = 2038886, upload-time = "2026-10-11T01:03:44.646Z" }, + { url = "https://files.pythonhosted.org/packages/cf/f5/2fcc5e30053a938286f17d0edf3f0850b8061b984256fa7c26850b9c8809/aiohttp-3.14.5-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:276a4fc00b1d9ae492b802763a789c5b86328b989c5ea169f2faa447d6a11c7c", size = 1894470, upload-time = "2026-10-11T01:03:47.304Z" }, + { url = "https://files.pythonhosted.org/packages/62/2a/f87feb42abe8e6a7c03814dbcb711540849a1e90aa392055f3183c643610/aiohttp-3.14.5-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50983e3be33d8c0942ab88cec3905b10602f64c469b20153c48c5d4e558dd016", size = 1716273, upload-time = "2026-10-11T01:03:50.121Z" }, + { url = "https://files.pythonhosted.org/packages/81/b2/adf1f960dd977722ed1347d33da512a1624807114f57a3b91f5cc828e081/aiohttp-3.14.5-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:16c8abd5bca220a47efe667d26f8460124c81810787e79ee87b242677563d9dd", size = 1857513, upload-time = "2026-10-11T01:03:52.959Z" }, + { url = "https://files.pythonhosted.org/packages/d7/fd/ef8d910e641de4160026a513ace5888b7f92826bbc3fd90ced05d55a828e/aiohttp-3.14.5-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:0790ec66fa4013e83c53b9025a45d454723da1a2fce28b3208c9b32d08af162f", size = 1887704, upload-time = "2026-10-11T01:03:55.641Z" }, + { url = "https://files.pythonhosted.org/packages/7e/db/6c9142f941cba8d35be8e1fae6ea2bd390e754fc14076b8147aee1a4592f/aiohttp-3.14.5-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:cb11a971a3aea10f9b8373be628f1df932964fc6c6b174516d318a48c3ac4412", size = 1895684, upload-time = "2026-10-11T01:03:58.18Z" }, + { url = "https://files.pythonhosted.org/packages/e6/7c/6a6bd9a72e576d376c668333b00c51c6147aba4fb863ed6c94996d497eca/aiohttp-3.14.5-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:932ce7e694bbc29b2bf6f64f2343c27d148d4997c771d01bdade4639b6749ff4", size = 1706299, upload-time = "2026-10-11T01:04:00.8Z" }, + { url = "https://files.pythonhosted.org/packages/69/ec/d2cc494242f8c4d3d1cd70baf591742818a74386dc3b84af3195c5c4fced/aiohttp-3.14.5-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:a7d470cf7b206e6359fc77b1b860632fde400d5a2ed59cd0181b93a686bc81ee", size = 1916645, upload-time = "2026-10-11T01:04:03.892Z" }, + { url = "https://files.pythonhosted.org/packages/a3/6e/e852c53647e815db09a1b6b5ab634f54bd736412397e11940feef4d2c89a/aiohttp-3.14.5-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:6e1d8637cf73eebc92eba2e11d4cfff98a3b562f2505bd75bba766d908926e8d", size = 1875408, upload-time = "2026-10-11T01:04:06.922Z" }, + { url = "https://files.pythonhosted.org/packages/fe/f6/72ab6ef20c332399be593bac543d2c24a6d241e39d3540ce9f95a63e4bd2/aiohttp-3.14.5-cp315-cp315-win32.whl", hash = "sha256:fbdc5ec49f9ca3cd24955cf3520b10a4d4c901ba2572094c84274e9e7eb30534", size = 485590, upload-time = "2026-10-11T01:04:09.626Z" }, + { url = "https://files.pythonhosted.org/packages/06/eb/e9de75b8c6d2170c42c08ff303abf857ea8a6d9d9b6e99b5aba40f15e962/aiohttp-3.14.5-cp315-cp315-win_amd64.whl", hash = "sha256:a9d3983bd6ab7aa1cfd573544ae98df9b6cb6912a5185a198263e024a636861d", size = 511740, upload-time = "2026-10-11T01:04:12.277Z" }, + { url = "https://files.pythonhosted.org/packages/fc/25/455f3c2785eb0d50748cffd0abd07500815f419a9495b14610b7622d8d2d/aiohttp-3.14.5-cp315-cp315-win_arm64.whl", hash = "sha256:e29347c142cf6e99e0dff5e2995ead1d50fa3b51bf37a7c726a7ccfe5419745a", size = 495171, upload-time = "2026-10-11T01:04:14.667Z" }, + { url = "https://files.pythonhosted.org/packages/e7/6c/497f0a98782eebfcf0f02a7fbdef5428148bd426027140cbad494cf842b5/aiohttp-3.14.5-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:9bf1d5dcc15204d9ec8b8ea4c18fd66e6b80e5de1f4ecbafb3a2f2740f8039d4", size = 855638, upload-time = "2026-10-11T01:04:17.154Z" }, + { url = "https://files.pythonhosted.org/packages/9a/c5/55c0cef2572af9b1ee81608f7c0a1bf74e6c9151a73b04915d933f192335/aiohttp-3.14.5-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:14f04769cfefe4734016a856a83af36133cd17779cef9ae817f812b8ba9d6d51", size = 567455, upload-time = "2026-10-11T01:04:19.702Z" }, + { url = "https://files.pythonhosted.org/packages/4d/47/e1a0e39f4a2b881f6071225afaf94a6547001b5aefc1566734d73c685934/aiohttp-3.14.5-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:6e4251c0ba4624a68a2c11471a1ac54c3306876c21f0ae86de085cc9241c8905", size = 567836, upload-time = "2026-10-11T01:04:22.235Z" }, + { url = "https://files.pythonhosted.org/packages/c3/1d/817d85836f52b687160064a326e62e037dc42f1ad5b6b5188a70e5c134b5/aiohttp-3.14.5-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4f5cf4dc72a71c4cfa9751b4950be22f733626670230d46e7d606592aa22d59", size = 2051409, upload-time = "2026-10-11T01:04:24.821Z" }, + { url = "https://files.pythonhosted.org/packages/f7/25/e8ea6fc212a9aabce982917346ce8ecab0929c74b60232a056dd11c99da0/aiohttp-3.14.5-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:dbf53ae2601b7fd5a93c3944deea3a78d40f495226d582c35ef7a433425ce2b2", size = 1936493, upload-time = "2026-10-11T01:04:27.456Z" }, + { url = "https://files.pythonhosted.org/packages/24/33/de0517f71f1a19feea4aff78a2ec4ec2d98634129ec30ead78fce2f8f81b/aiohttp-3.14.5-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:657291433bf4dd3142f3abac495764cd47d0c7c92087751e6666c6447e65fcef", size = 2021405, upload-time = "2026-10-11T01:04:30.276Z" }, + { url = "https://files.pythonhosted.org/packages/c4/11/ddaf2e7930543e9f0cad3a1c54e1af0c1bd2fae3973d568c4263d3b010e9/aiohttp-3.14.5-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3e51a27980c3788e6e6b3325d694fdd4898087fa8a86b2763af77b39353da41e", size = 2142017, upload-time = "2026-10-11T01:04:33.022Z" }, + { url = "https://files.pythonhosted.org/packages/1d/7b/58784353c06de8adc20f426daad3d85dd86fd55331c713a3f4b638c94573/aiohttp-3.14.5-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:82c7583cd3dfdc7dcc927835b4f6c7faae7ecc1ba3ca5879321621ae2e6f8e84", size = 2002482, upload-time = "2026-10-11T01:04:35.94Z" }, + { url = "https://files.pythonhosted.org/packages/b9/f0/417d9535caa9e165ffe6a53e2347a78107e7c87dcb0e10aca315c3af0344/aiohttp-3.14.5-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6fdcd6af7e2e51d1ba1b4bea16e97b074bcb7b5dd0246a9d8201341bb28085a0", size = 1791774, upload-time = "2026-10-11T01:04:38.733Z" }, + { url = "https://files.pythonhosted.org/packages/98/01/25e49c2e8a01b9f0e19ca0a8448ad50aa2bdf96c8cd41e92bd45af044784/aiohttp-3.14.5-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:c8859a013ae0de1074660992139a1a440df3e6b219b86cf0d3f11c2692bb4fe3", size = 1970358, upload-time = "2026-10-11T01:04:41.66Z" }, + { url = "https://files.pythonhosted.org/packages/2d/fc/c132fd3465b6c7e4ce0193154f602c3e6c46b680e4da7eb3bd0d8a40d1c7/aiohttp-3.14.5-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:8966ecac808dd5f473c9c4cefd10cd3ffda71c18a4d3493b7c7d2ae1803bf2cc", size = 1969192, upload-time = "2026-10-11T01:04:44.66Z" }, + { url = "https://files.pythonhosted.org/packages/95/4e/d58b45e7dba4eb607eca11fc0a4aa77ae0f39c11afa804635a61ce18cff4/aiohttp-3.14.5-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:3093b72c215bda16ce961a6d073f6e71d46e022962a9d5d457c5d4d421c78b57", size = 1963618, upload-time = "2026-10-11T01:04:47.505Z" }, + { url = "https://files.pythonhosted.org/packages/a2/d9/f6ac50946efb3490428ef52b56e62c1c6b7f9c6ff3ec6083535526c83e60/aiohttp-3.14.5-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:149fb56caf7acb67073126f675d0958d9c4b3125fcd3f6d4877df98aa8a97ce9", size = 1784817, upload-time = "2026-10-11T01:04:50.287Z" }, + { url = "https://files.pythonhosted.org/packages/d0/2f/f255eb63da788cd8a452fe350869c03266ccad7d884925f6963c87808f24/aiohttp-3.14.5-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:293d3ae7c6a0ed176a42e59a1b5fde825ead65c835360f734148e96729f928d2", size = 1995956, upload-time = "2026-10-11T01:04:53.213Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9b/241aa3393eaafda0034470add1625f82a4c252901108723b283a9b0b32ca/aiohttp-3.14.5-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:3f2dcc00191fd563e9075181a14ec31d7dd63223ced7582cc70a15a499de0c79", size = 1967794, upload-time = "2026-10-11T01:04:55.958Z" }, + { url = "https://files.pythonhosted.org/packages/5f/7f/a68e689288c9e4bfcf8d0979f2b12b774bf01861985420df6150eba5448e/aiohttp-3.14.5-cp315-cp315t-win32.whl", hash = "sha256:7779cd97e61ebe583ec2f1c5616cdd038aa08a4453b1848c67842176d054948e", size = 503876, upload-time = "2026-10-11T01:04:58.978Z" }, + { url = "https://files.pythonhosted.org/packages/a4/78/49b0299da6d54de19fc6fdc6889d50233ac47d192a461624f4fc010fde83/aiohttp-3.14.5-cp315-cp315t-win_amd64.whl", hash = "sha256:0e6f16f5e49c4b8267988c05ab07760d7064cea57d077c3d068d04b0fbb992cb", size = 530957, upload-time = "2026-10-11T01:05:02.23Z" }, + { url = "https://files.pythonhosted.org/packages/21/d4/b0afc936aeb6d2f93157e3408b069ec5d7934429ae7d023de5e0953b1887/aiohttp-3.14.5-cp315-cp315t-win_arm64.whl", hash = "sha256:1aead151c3abbac6b32942e452020cb66d7efc099d253cc6c20f748e926c858b", size = 510408, upload-time = "2026-10-11T01:05:05.362Z" }, + { url = "https://files.pythonhosted.org/packages/68/30/173960c42b05a6c59f7558e4b12a4b0d9ba376cf6aa9bde7f9e08a30ca8d/aiohttp-3.14.5-py3-none-any.whl", hash = "sha256:efc21a454892828368b11c2c780de0ff8bc991f73f6b99c6b66e56205470929b", size = 279517, upload-time = "2026-10-11T01:05:08.523Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + [[package]] name = "anyio" version = "4.15.1" @@ -152,6 +356,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/e2/c2e3abf398f80732e58b03be77bde9022550d221dd8781bf586bd4d97cc1/async_lru-2.3.0-py3-none-any.whl", hash = "sha256:eea27b01841909316f2cc739807acea1c623df2be8c5cfad7583286397bb8315", size = 8403, upload-time = "2026-03-19T01:04:30.883Z" }, ] +[[package]] +name = "async-timeout" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, +] + [[package]] name = "attrs" version = "26.1.0" @@ -674,9 +887,12 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, @@ -975,9 +1191,12 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.11.*' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", @@ -1185,9 +1404,12 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.11.*' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", @@ -1262,6 +1484,127 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/d5/a8f9149b97cced9e1f4bff92ff0b6206120e2c853ae774d7020d6ecd8fa8/fqdn-1.6.0-py3-none-any.whl", hash = "sha256:6c793c312ccfa29981e1135b3ef5e1277ebe639731a6b0f7350d90e96e63f0b4", size = 12569, upload-time = "2026-09-28T05:43:33.951Z" }, ] +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/4a/557715d5047da48d54e659203b9335be7bfaafda2c3f627b7c47e0b3aaf3/frozenlist-1.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011", size = 86230, upload-time = "2025-10-06T05:35:23.699Z" }, + { url = "https://files.pythonhosted.org/packages/a2/fb/c85f9fed3ea8fe8740e5b46a59cc141c23b842eca617da8876cfce5f760e/frozenlist-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565", size = 49621, upload-time = "2025-10-06T05:35:25.341Z" }, + { url = "https://files.pythonhosted.org/packages/63/70/26ca3f06aace16f2352796b08704338d74b6d1a24ca38f2771afbb7ed915/frozenlist-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a88f062f072d1589b7b46e951698950e7da00442fc1cacbe17e19e025dc327ad", size = 49889, upload-time = "2025-10-06T05:35:26.797Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ed/c7895fd2fde7f3ee70d248175f9b6cdf792fb741ab92dc59cd9ef3bd241b/frozenlist-1.8.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f57fb59d9f385710aa7060e89410aeb5058b99e62f4d16b08b91986b9a2140c2", size = 219464, upload-time = "2025-10-06T05:35:28.254Z" }, + { url = "https://files.pythonhosted.org/packages/6b/83/4d587dccbfca74cb8b810472392ad62bfa100bf8108c7223eb4c4fa2f7b3/frozenlist-1.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:799345ab092bee59f01a915620b5d014698547afd011e691a208637312db9186", size = 221649, upload-time = "2025-10-06T05:35:29.454Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c6/fd3b9cd046ec5fff9dab66831083bc2077006a874a2d3d9247dea93ddf7e/frozenlist-1.8.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c23c3ff005322a6e16f71bf8692fcf4d5a304aaafe1e262c98c6d4adc7be863e", size = 219188, upload-time = "2025-10-06T05:35:30.951Z" }, + { url = "https://files.pythonhosted.org/packages/ce/80/6693f55eb2e085fc8afb28cf611448fb5b90e98e068fa1d1b8d8e66e5c7d/frozenlist-1.8.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a76ea0f0b9dfa06f254ee06053d93a600865b3274358ca48a352ce4f0798450", size = 231748, upload-time = "2025-10-06T05:35:32.101Z" }, + { url = "https://files.pythonhosted.org/packages/97/d6/e9459f7c5183854abd989ba384fe0cc1a0fb795a83c033f0571ec5933ca4/frozenlist-1.8.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c7366fe1418a6133d5aa824ee53d406550110984de7637d65a178010f759c6ef", size = 236351, upload-time = "2025-10-06T05:35:33.834Z" }, + { url = "https://files.pythonhosted.org/packages/97/92/24e97474b65c0262e9ecd076e826bfd1d3074adcc165a256e42e7b8a7249/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:13d23a45c4cebade99340c4165bd90eeb4a56c6d8a9d8aa49568cac19a6d0dc4", size = 218767, upload-time = "2025-10-06T05:35:35.205Z" }, + { url = "https://files.pythonhosted.org/packages/ee/bf/dc394a097508f15abff383c5108cb8ad880d1f64a725ed3b90d5c2fbf0bb/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:e4a3408834f65da56c83528fb52ce7911484f0d1eaf7b761fc66001db1646eff", size = 235887, upload-time = "2025-10-06T05:35:36.354Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/25b201b9c015dbc999a5baf475a257010471a1fa8c200c843fd4abbee725/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:42145cd2748ca39f32801dad54aeea10039da6f86e303659db90db1c4b614c8c", size = 228785, upload-time = "2025-10-06T05:35:37.949Z" }, + { url = "https://files.pythonhosted.org/packages/84/f4/b5bc148df03082f05d2dd30c089e269acdbe251ac9a9cf4e727b2dbb8a3d/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e2de870d16a7a53901e41b64ffdf26f2fbb8917b3e6ebf398098d72c5b20bd7f", size = 230312, upload-time = "2025-10-06T05:35:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/db/4b/87e95b5d15097c302430e647136b7d7ab2398a702390cf4c8601975709e7/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:20e63c9493d33ee48536600d1a5c95eefc870cd71e7ab037763d1fbb89cc51e7", size = 217650, upload-time = "2025-10-06T05:35:40.377Z" }, + { url = "https://files.pythonhosted.org/packages/e5/70/78a0315d1fea97120591a83e0acd644da638c872f142fd72a6cebee825f3/frozenlist-1.8.0-cp310-cp310-win32.whl", hash = "sha256:adbeebaebae3526afc3c96fad434367cafbfd1b25d72369a9e5858453b1bb71a", size = 39659, upload-time = "2025-10-06T05:35:41.863Z" }, + { url = "https://files.pythonhosted.org/packages/66/aa/3f04523fb189a00e147e60c5b2205126118f216b0aa908035c45336e27e4/frozenlist-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:667c3777ca571e5dbeb76f331562ff98b957431df140b54c85fd4d52eea8d8f6", size = 43837, upload-time = "2025-10-06T05:35:43.205Z" }, + { url = "https://files.pythonhosted.org/packages/39/75/1135feecdd7c336938bd55b4dc3b0dfc46d85b9be12ef2628574b28de776/frozenlist-1.8.0-cp310-cp310-win_arm64.whl", hash = "sha256:80f85f0a7cc86e7a54c46d99c9e1318ff01f4687c172ede30fd52d19d1da1c8e", size = 39989, upload-time = "2025-10-06T05:35:44.596Z" }, + { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" }, + { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" }, + { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" }, + { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" }, + { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" }, + { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" }, + { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" }, + { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" }, + { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" }, + { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" }, + { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + [[package]] name = "griffe" version = "2.3.0" @@ -1436,9 +1779,12 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.11.*' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", @@ -1506,9 +1852,12 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.11.*' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", @@ -1565,9 +1914,12 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.11.*' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", @@ -1602,6 +1954,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" }, ] +[[package]] +name = "ipywidgets" +version = "8.1.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "comm" }, + { name = "ipython", version = "8.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "ipython", version = "9.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "jupyterlab-widgets" }, + { name = "traitlets" }, + { name = "widgetsnbextension" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/7c/6db60eddf38547353b06d57941f5eee22a990640ce30479fd71a810507f2/ipywidgets-8.1.9.tar.gz", hash = "sha256:bcccba38a6ec3253f7a39c943cea5b9ad01999ce071396171adbc51c6a6a8613", size = 117252, upload-time = "2026-08-18T08:54:24.123Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/55/298e9b3b864a198234997e87a1471c1b17d7f3546ace6d18fb5cf1ce24b2/ipywidgets-8.1.9-py3-none-any.whl", hash = "sha256:f2b8cbcaae10252b809fbe4d7470db75c09b769a32cbf816d20e5ca6d3c5a79d", size = 140101, upload-time = "2026-08-18T08:54:22.339Z" }, +] + [[package]] name = "isoduration" version = "20.11.0" @@ -1869,6 +2238,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/23/d3/996332fe24f3c2458b9c353b998570dfa2631763e9a8674ba3f94c0877d0/jupyterlab_server-2.28.1-py3-none-any.whl", hash = "sha256:4bd36c7c11d872e15cefa4951e12380a47e6901687165332dd50a007cb367ad9", size = 60197, upload-time = "2026-09-15T19:22:08.201Z" }, ] +[[package]] +name = "jupyterlab-widgets" +version = "3.0.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/8b/e739cf9066ad5037a2d4b0a403f06da374fdccb9748221661c8b492d3dbc/jupyterlab_widgets-3.0.17.tar.gz", hash = "sha256:6e61fe21ca8a66039180a5cc52a433e07279d2fee79c8be963e00d55193f17a8", size = 213919, upload-time = "2026-08-18T08:52:17.511Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/ef/6d27fc118f58cb24886da413545a7efb0853d405fddbfd8b2d9ac09fbed4/jupyterlab_widgets-3.0.17-py3-none-any.whl", hash = "sha256:40ac1e9955acf116c4d995d9bfa082d86ad9ec6d91c4f134827cf5e0a5eb75e0", size = 217292, upload-time = "2026-08-18T08:52:15.47Z" }, +] + [[package]] name = "kiwisolver" version = "1.5.1" @@ -2038,6 +2416,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/23/93/4e45e37f26c820216704b36b8e67cc55c490a7abd287a78a26a2c0e21386/lazy_loader-0.6-py3-none-any.whl", hash = "sha256:77253be3391b06124a0e16105bd663b6c54470af1a9ca8e1cf026f38d58ed056", size = 8791, upload-time = "2026-09-21T21:31:31.632Z" }, ] +[[package]] +name = "loguru" +version = "0.7.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "win32-setctime", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, +] + [[package]] name = "markdown-it-py" version = "4.2.0" @@ -2277,9 +2668,12 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.11.*' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", @@ -2447,6 +2841,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/d6/4340ed6e184f71693d7f531c9ae55d677451351245760d71f8dd714dcbe5/mlx_metal-0.32.4-py3-none-macosx_26_0_arm64.whl", hash = "sha256:514df92d41a286895d2564b4b18f4da45096bd7a4a0c47e1042f345ad5a7f936", size = 78106861, upload-time = "2026-10-14T21:14:56.184Z" }, ] +[[package]] +name = "more-itertools" +version = "11.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/de/1d/f4da6f02cdffe04d6362210b807146a26044c88d839208aec273bb0d9184/more_itertools-11.1.0.tar.gz", hash = "sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d", size = 145772, upload-time = "2026-05-22T14:14:29.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" }, +] + +[[package]] +name = "more-itertools" +version = "11.2.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/52/b8/c63995bebbb9ac9c059350e0fb8c82c2420c85aeca7d34e40aecf3df9281/more_itertools-11.2.1.tar.gz", hash = "sha256:cbf08fd0af284dc69718b9b76a8e6203df624d70209ea511e4219d350c856f63", size = 154133, upload-time = "2026-10-14T18:50:30.407Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/57/3c4b1c6f2e0cc29ba023802458ea3d2a996f87085559e4a5f2192d549532/more_itertools-11.2.1-py3-none-any.whl", hash = "sha256:35a7377edd1dd6608dcb2cdf534ded55ea32d49448875ad042cd3f879fb1ded0", size = 74405, upload-time = "2026-10-14T18:50:29.116Z" }, +] + [[package]] name = "mpi4py" version = "4.1.2" @@ -2516,6 +2945,285 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, ] +[[package]] +name = "msgpack" +version = "1.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/e7/bb605a7bab2d8425a64b3fa762b39dc1bf1c7e3f11ba6fb5413d6db0ff8c/msgpack-1.2.3.tar.gz", hash = "sha256:32edb81a2b5eb7cd7c9d941b2bfbbb082fd2cd09e0e725930316af6b708db186", size = 196517, upload-time = "2026-09-29T02:33:52.276Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/aa/5b6b09f835791045282dc5d08431db599a5f4743a69fe2f6670045a2cd85/msgpack-1.2.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ec0030361cc861ac699b2ef1c695b741fa145c88f8667fa3d7e3f73deeb648a3", size = 90927, upload-time = "2026-09-29T02:31:28.286Z" }, + { url = "https://files.pythonhosted.org/packages/c9/91/7b288e9133bd1ba92ca0ca4e7f2a4cfc53cf467d99d8d2f57b9939908fac/msgpack-1.2.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5c1efdd9181cb1b719ee46865f368a927f1c0c65d577798340b1194545b7515a", size = 89798, upload-time = "2026-09-29T02:31:30.028Z" }, + { url = "https://files.pythonhosted.org/packages/71/9b/5c3dbc450d14645dcec987970692d6ab24008cc33d2155474b1d818486f9/msgpack-1.2.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c309a7abae1d14ba29a8bd0ddbd704a5e469d8e9bd9c3dee0e4ff53d7ae01d56", size = 450687, upload-time = "2026-09-29T02:31:32.407Z" }, + { url = "https://files.pythonhosted.org/packages/2b/21/ea60a8fd0d9e0897fce823e9fd9bf6742567784b35c7eee8f4a18a56eb19/msgpack-1.2.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bf390259cb25a6a1cd197c65810999b811f64cd38683251538bcc5a1e41f7d3", size = 459808, upload-time = "2026-09-29T02:31:34.282Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f7/42140e6afdac8e94bfedae4cfb67ee004b6ad5c4cadd024df42f759bf3b5/msgpack-1.2.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:39b6986c19e1f2dfa549d185dba6ccf1de2e4c0ba10d8cfc0048935b1c5f9109", size = 423845, upload-time = "2026-09-29T02:31:35.713Z" }, + { url = "https://files.pythonhosted.org/packages/19/7b/cd54f27b59dfbdc438a12361fbb6798b66d377a978f946bc9512598290e9/msgpack-1.2.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:fcc6800daac4922960f6eeb7a0dda3dd4105e0bf7bce0e83ebc465a78cb7bdba", size = 445608, upload-time = "2026-09-29T02:31:37.65Z" }, + { url = "https://files.pythonhosted.org/packages/57/38/52bc0dc44cc9f7c2339b632f93d02f8badc78cfb0bb070f2a50a51945e53/msgpack-1.2.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:968583e956d0427878050b371308c5f8647088732ef3e66a117dbe1192ec91e0", size = 421721, upload-time = "2026-09-29T02:31:39.151Z" }, + { url = "https://files.pythonhosted.org/packages/89/e6/451c9a42274fb2be82d8ba8b76a5219c613e20f8de1da521d10cb758a9ef/msgpack-1.2.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1d6bcec3dbbdb89ca385d3a73e63ceae7b841fa0d7ca7c676f1a7bfe7fb2cdb8", size = 460430, upload-time = "2026-09-29T02:31:40.843Z" }, + { url = "https://files.pythonhosted.org/packages/57/bb/663e3100327b58caaa5fb66379e557a2717dac08bb586f22f885756bee47/msgpack-1.2.3-cp310-cp310-win32.whl", hash = "sha256:a6b63917d60d6df451f328bd6afba8565e33c4afe1f62ec4ad758b78731c827b", size = 67987, upload-time = "2026-09-29T02:31:42.157Z" }, + { url = "https://files.pythonhosted.org/packages/28/7a/a00d5d7abc5601099260e0d0af8fadc54fbfac2191315aa56eaee3641d9d/msgpack-1.2.3-cp310-cp310-win_amd64.whl", hash = "sha256:4c0780095871ecc49a58b2ff6b1b43b25214704da67646557ca287a3f49fb2dd", size = 75572, upload-time = "2026-09-29T02:31:43.544Z" }, + { url = "https://files.pythonhosted.org/packages/2a/95/b9c651ccb9d720b2e2c8d537954dff528ab869a03bf89598145716db823c/msgpack-1.2.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ec90a9ae3e1169fa1171147340f0e97d941aa19fcd3b34e8339a55933ed042af", size = 90404, upload-time = "2026-09-29T02:31:44.826Z" }, + { url = "https://files.pythonhosted.org/packages/50/cd/fc9e2e367e80f1493e2ec5f610dda558b344eeede296f88976db133e8f2c/msgpack-1.2.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9d7e9cbb0998bbfd363fd9a09c330520d5e9cb323c05b5a1a05865d23ccf2226", size = 89683, upload-time = "2026-09-29T02:31:46.413Z" }, + { url = "https://files.pythonhosted.org/packages/19/9e/1028485c6886c1c117f777cc9b053e541eff0fedb3292dfb1da95040edb5/msgpack-1.2.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6707d2fa2aa1bb5424ea0b05f44ffc989b15ab41a73ff5855bff4944fec7c8ac", size = 465347, upload-time = "2026-09-29T02:31:47.934Z" }, + { url = "https://files.pythonhosted.org/packages/aa/83/800570e6a22376eb8d599920f70aead4779a63611696f567477c4e85a70f/msgpack-1.2.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:382b219de3d436de3baba0f4b0c6d4336e8f5858d0eb047918b13b69a71c6c55", size = 477820, upload-time = "2026-09-29T02:31:49.479Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ff/817e4a2052f848d3fb67726908d6e4e7c19f68ee7c19553a82ce7b0ed415/msgpack-1.2.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:186e6c602b8a9968b8e864c67d622a69279f7d1e55ae25f40e3bff7e815b2b62", size = 436656, upload-time = "2026-09-29T02:31:51.18Z" }, + { url = "https://files.pythonhosted.org/packages/3d/42/040cc55dde6a7d92057baac8d1fc9cfb9f4fd4162900e2ec16dc33917a7d/msgpack-1.2.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9276ba88891338f2617044429dfd080ae008c9868a25f6f1a7d004a35dc9ac0a", size = 460939, upload-time = "2026-09-29T02:31:53.026Z" }, + { url = "https://files.pythonhosted.org/packages/09/93/4dc007bdef930eed247346773bc0189b710078961d3218d5ee7ba59f322c/msgpack-1.2.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:c942c21a93f36b3a69e828c8945bb72c94dc2ffe488a2086950c812f3edf046c", size = 433608, upload-time = "2026-09-29T02:31:54.981Z" }, + { url = "https://files.pythonhosted.org/packages/c0/97/a1b944046f283ec89445cb2a982c42233b5b07cc630f9be739f4f1d469a3/msgpack-1.2.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:18a6ed513023001b28dcd3ba54966f6bb90a38274ba8d2640464bcab3a1b81d4", size = 477373, upload-time = "2026-09-29T02:31:56.713Z" }, + { url = "https://files.pythonhosted.org/packages/59/79/ab411d0d172743732ab2503f4c32a22dd1a7d1436a6feecbb160e4b6376a/msgpack-1.2.3-cp311-cp311-win32.whl", hash = "sha256:d0238cd05dec9ffbe0de1071df685ba63e30a36ac155285b1a094e727c38cbe9", size = 67514, upload-time = "2026-09-29T02:31:58.267Z" }, + { url = "https://files.pythonhosted.org/packages/63/8d/6f0cb2b84e484e96278455c26870196d025bb0cec312b226a663f1fa9000/msgpack-1.2.3-cp311-cp311-win_amd64.whl", hash = "sha256:30e1522e4173230dca4d9ad896f038f73c0da6c1edd42f4dbad88ac583cf5d46", size = 75850, upload-time = "2026-09-29T02:31:59.449Z" }, + { url = "https://files.pythonhosted.org/packages/aa/25/f99e13a2c1d3f5a1dcaa5aab27f474e8c4358188bbc68ad79fecb0d1aefe/msgpack-1.2.3-cp311-cp311-win_arm64.whl", hash = "sha256:8ca67f77938ea6a3663aa9bd22b3e031f6da84d665be850abab910ee90728dfd", size = 72338, upload-time = "2026-09-29T02:32:00.885Z" }, + { url = "https://files.pythonhosted.org/packages/af/12/4d7c6d6203416d9fbf0f59ebaa805e70fb929b93a41b611bc821ec5964a0/msgpack-1.2.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:89c930aece4e972b208ba589c8410b4167b05e411a5ea2cb25fd96f8bc47ee43", size = 91577, upload-time = "2026-09-29T02:32:02.141Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c7/8576ad39f4ca42ddad26f68eb8621d2d0a60501193d480f504bd9d7f36c4/msgpack-1.2.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:905a189853d6bdb204c7ae5f4ab77fb857448abfff574d3d93c62e2815b24b4f", size = 90027, upload-time = "2026-09-29T02:32:03.508Z" }, + { url = "https://files.pythonhosted.org/packages/0a/3a/aa9c580aea1314529a0f3562461479780b0d254b064f0880956bfbcc74a8/msgpack-1.2.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7b3d0018746b5997dd6b14a1870b07cc4c327d9101145d94a1fc264a51a06", size = 460343, upload-time = "2026-09-29T02:32:04.906Z" }, + { url = "https://files.pythonhosted.org/packages/3a/cf/9c2e4d6c179529d5bf4a64cff76fa581486569e9fbdd35bd98f51cb624bf/msgpack-1.2.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede33b2892ceb976283e009ad12fa1834cfdf1f9c43ee9c97849fc588d00a618", size = 472998, upload-time = "2026-09-29T02:32:06.69Z" }, + { url = "https://files.pythonhosted.org/packages/7b/41/915c81fe6df2d3cbdb0dece4f1a5cd313e1cd2abd9f501d0f50c0582517e/msgpack-1.2.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:666ef5601ab0e6e345e47febc96aa81143cc932201543480cbb9499164f05ffb", size = 423216, upload-time = "2026-09-29T02:32:08.739Z" }, + { url = "https://files.pythonhosted.org/packages/a2/e7/7dda8b1039abfd9bba4c5068172c67135c9e33089f503512db9226f23c24/msgpack-1.2.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:87cf2ef05ff2f2493ba29fcdaef27e960ca64dacfd13460ae29e6f92e0ed05bb", size = 451218, upload-time = "2026-09-29T02:32:10.517Z" }, + { url = "https://files.pythonhosted.org/packages/16/5b/ce995c1ed4a0522b7f2d034bc2034fd63005f240b945961b70fb56fbaf3d/msgpack-1.2.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:b774ff994d844e541439ac5d2d49a14def4104830c3465e9394c153f86200ffb", size = 422453, upload-time = "2026-09-29T02:32:11.956Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/ce191fb87e2650d0166b34c437e499ee4a7f9db9c1eb164f41725eb6160e/msgpack-1.2.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:eaf7e82249837e3aa97297b34a0bb9ff562027381631e057cea6e1367f10b438", size = 469003, upload-time = "2026-09-29T02:32:13.663Z" }, + { url = "https://files.pythonhosted.org/packages/42/35/539123407fe200fb16609c835675496fbeb6017ace9fc93909f0613223ae/msgpack-1.2.3-cp312-cp312-win32.whl", hash = "sha256:7c047250096f9fc19dba26e3d1639b5e7a84114003605c94def667149a70ced1", size = 68303, upload-time = "2026-09-29T02:32:15.02Z" }, + { url = "https://files.pythonhosted.org/packages/6f/4c/331b45f9b86fbda6b9e103244d189068e51f726d8c40021ed66e1f2c415e/msgpack-1.2.3-cp312-cp312-win_amd64.whl", hash = "sha256:3ec409b0d6aa8e9eec6eaf881b893caa215dbe68c5319ca96e8a271d81bb111d", size = 76744, upload-time = "2026-09-29T02:32:16.344Z" }, + { url = "https://files.pythonhosted.org/packages/13/9f/fb572dc42b9fac06c7ea848aaee6e140d84469743bd1402bc07089fc4566/msgpack-1.2.3-cp312-cp312-win_arm64.whl", hash = "sha256:59612b4ed48a04cf024584218e813562f3b30a3bafa5f55abe300b15da314751", size = 71580, upload-time = "2026-09-29T02:32:17.617Z" }, + { url = "https://files.pythonhosted.org/packages/1f/8b/3824d65e912e925d09ce30d9130fa9970d6d2855d7888b13639a6604967f/msgpack-1.2.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:21bfa4d2aa0b04c1806ef778a1199e9e53ea2441bcbf284420a32083896320b8", size = 91728, upload-time = "2026-09-29T02:32:18.949Z" }, + { url = "https://files.pythonhosted.org/packages/05/e6/df7f2c9ebb94760113debbcea2bd3afe5fdab88a4f7bec1b618755517460/msgpack-1.2.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:db84203b13aecc222f465061397fdd5b53b7ae73d2c95ffc1c8dc5be0153a709", size = 89955, upload-time = "2026-09-29T02:32:20.224Z" }, + { url = "https://files.pythonhosted.org/packages/08/6a/e5fc57136e8bacccb2b39627dea2cd546540a06181e22fe6db90e15b3ae4/msgpack-1.2.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e0d7950ca3c1bbae291d0552dd3bb2792fc680629c4c0d44e47e5bab969f3ca", size = 454930, upload-time = "2026-09-29T02:32:21.771Z" }, + { url = "https://files.pythonhosted.org/packages/b0/30/c394d37898db9212d1693456cdf363c7e1a097d0b63e10664007f3df3ec1/msgpack-1.2.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:07c9733089d1b176c3dd2f7fa268452f9d5d784d076473499d754a58e8d1fbbb", size = 466866, upload-time = "2026-09-29T02:32:23.742Z" }, + { url = "https://files.pythonhosted.org/packages/4a/c8/1e4ddf6f6b829b3ee6c530c79dfae89cb609d2b0eedb5e0ae716851c52d1/msgpack-1.2.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f24a43b3560e20f825b807fe1e874bd73d53abaf8bbdcf258a6eb152cddbc1f5", size = 418715, upload-time = "2026-09-29T02:32:25.262Z" }, + { url = "https://files.pythonhosted.org/packages/11/a5/f460ba6d7a12d4301002f3efbb8f841e8bdc9c5fc98d771689677a352885/msgpack-1.2.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6576f348ed6cc4f31db6fd915a8e94245f042f50eae08d48732425e70638ea37", size = 446489, upload-time = "2026-09-29T02:32:26.988Z" }, + { url = "https://files.pythonhosted.org/packages/49/23/adface88db909bed321c85dd673655152d4a514c67e1f0800eb51c777d07/msgpack-1.2.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:cd5a9f9f86a52c24713679aa2631956835f3842512964ff93f736ff76f1f530d", size = 416998, upload-time = "2026-09-29T02:32:28.606Z" }, + { url = "https://files.pythonhosted.org/packages/36/00/5bb3a239ccfc3763c4d0fa49b13b1b7010b00182c499ab3c1fecfe6294bc/msgpack-1.2.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f9ddd28d3e9bbc602a9dced1591882c7fb9ab776eef8837da2c326fde19e2853", size = 463288, upload-time = "2026-09-29T02:32:30.375Z" }, + { url = "https://files.pythonhosted.org/packages/29/8c/456df77f00d701df9d6980ffb80291bce6e4e2e112e25a4dfae216f0715a/msgpack-1.2.3-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:62cc1a4ef0e553bac32c8342e1f04834aca7de276b92744eb7307db77759b890", size = 53347, upload-time = "2026-09-29T02:32:31.867Z" }, + { url = "https://files.pythonhosted.org/packages/9d/22/ce780be666f89b77cdb855daa9ec62e87bb7f69e9f403e4a5d83a2b2208f/msgpack-1.2.3-cp313-cp313-win32.whl", hash = "sha256:d2f9c4f85e47a44d26d5baf3b041eef23436e224d44eed273f01bd8a12048d9f", size = 68258, upload-time = "2026-09-29T02:32:33.163Z" }, + { url = "https://files.pythonhosted.org/packages/51/06/c3def9bc4db283103c5901b302ee2a4305cb1e69729244f94d9bd8f8e8e7/msgpack-1.2.3-cp313-cp313-win_amd64.whl", hash = "sha256:bb89b5dc30469c84bbf8684826eb851d82412ca95690e111b9ac5e8fb343961a", size = 76569, upload-time = "2026-09-29T02:32:34.412Z" }, + { url = "https://files.pythonhosted.org/packages/12/9f/cef344073858b80adb92d6ea342e20b0eae7a8f6fe70281b69cf03707270/msgpack-1.2.3-cp313-cp313-win_arm64.whl", hash = "sha256:471e12a6a42498a31490c206e0069e343b6a7c35db540be73a879eb06f5be047", size = 71530, upload-time = "2026-09-29T02:32:35.892Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8e/f777f74e38731c428857933c8011596f2d2f3160c821152f23b6ffba862f/msgpack-1.2.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3a31905206722103a84c1f72633fe30692cff6732c9d262e09a27dbc468797c8", size = 92042, upload-time = "2026-09-29T02:32:37.464Z" }, + { url = "https://files.pythonhosted.org/packages/a0/71/551608543ee5d590f7e8d522267665d6d9946866ad2a2a70a770f7c70793/msgpack-1.2.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3372475211a9ce1a23acefe512cb3e121d18c95dc74ed56cb1819ef40836ebf4", size = 90578, upload-time = "2026-09-29T02:32:38.883Z" }, + { url = "https://files.pythonhosted.org/packages/ea/11/6d78ce5a9a58bf9ba7b1b6a8f649173b030e6770c8019cf330b91825ee5d/msgpack-1.2.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9324c54995641c3d1f92a9d55093c8cde0ffa2fbc87a467a688ef60428393220", size = 454352, upload-time = "2026-09-29T02:32:40.34Z" }, + { url = "https://files.pythonhosted.org/packages/3d/08/feb9a196269ba7809f44f9117d9e4a601c41c313f6144fd0c337293a5488/msgpack-1.2.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d8ef3a66e4b52d2d7fdd90df2984670124b2ff7546d76bb25dcf68ef47f7df58", size = 462562, upload-time = "2026-09-29T02:32:42.176Z" }, + { url = "https://files.pythonhosted.org/packages/f5/77/3a674f366def24140b103d1ffd4fd27b3d912a13e47da67422afa16bebb3/msgpack-1.2.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:902f3490db0e07a7d40b48536a85c9b28fbf1397e7e1658a45a55f958e303620", size = 418134, upload-time = "2026-09-29T02:32:43.693Z" }, + { url = "https://files.pythonhosted.org/packages/48/82/944e71f280577490d99a3951cbce21aa4cbe04e7ab42cb373fd668af883c/msgpack-1.2.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e51eca14fbb65c4e0a5a9657346962bd3dca78c08e04e3d4dee70ef48687d30", size = 445937, upload-time = "2026-09-29T02:32:45.739Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ec/feddd629c4a3edf1395313680450c525086cceab56dec0d4de9da9ccb618/msgpack-1.2.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f42f146752eedb6765f07dcc04d72dab0a25779ec8d4a88c0085263ce114f22c", size = 416450, upload-time = "2026-09-29T02:32:47.558Z" }, + { url = "https://files.pythonhosted.org/packages/e4/59/263a10f8c4613ba0713f48cbda7695ac8dd6d6fab2fcbc9168f03f23a94d/msgpack-1.2.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0ed5823c4efc20fe87d3530665f40ec18a002be003114814c21235cc8d256207", size = 459546, upload-time = "2026-09-29T02:32:49.145Z" }, + { url = "https://files.pythonhosted.org/packages/1e/21/addcfa1e583cfc8a22fbdc57526621b5decd7ad676ae12e9150b7be1be5d/msgpack-1.2.3-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:2487453ca1b6104442c6442f9a1a8fee1fe8f428a70d99d4cba799108b304150", size = 53462, upload-time = "2026-09-29T02:32:50.708Z" }, + { url = "https://files.pythonhosted.org/packages/8d/2c/3cb5c8524a1335ee27ca952c7ab78d375a16fea8e18ae3767ba0c880416c/msgpack-1.2.3-cp314-cp314-win32.whl", hash = "sha256:6df430419f2338cb71e4a34d6e64f83c88ccd321f91f40ba4513400b36d864ec", size = 70294, upload-time = "2026-09-29T02:32:52.037Z" }, + { url = "https://files.pythonhosted.org/packages/23/f9/9172ff3cdb85d160ad06df5e2708a5fce7682982a5eee8d31869b9f69d2e/msgpack-1.2.3-cp314-cp314-win_amd64.whl", hash = "sha256:84a6616d396ec1bc18a1e83e67c96a393ec35dfe5e17434a5be7b9aa0fe988ab", size = 77778, upload-time = "2026-09-29T02:32:53.429Z" }, + { url = "https://files.pythonhosted.org/packages/04/e8/b4c23178bcf605ae17cec48a75530dd69d49b0a5a6f5f4df5c47d59f746e/msgpack-1.2.3-cp314-cp314-win_arm64.whl", hash = "sha256:7a003b02c6ee2eea6dfe0bb08818631e3597e69f0131f2a8250488a1cc553290", size = 73794, upload-time = "2026-09-29T02:32:54.763Z" }, + { url = "https://files.pythonhosted.org/packages/66/b1/92704be352c4f428b7e0a0e0fb210cb1aa2b1c42c102b8dc22d34b82fac0/msgpack-1.2.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ccea05b5542f6d283fef3f0a8e93a7f0be90af0ddeeef84c25c0216ba76dcae1", size = 93721, upload-time = "2026-09-29T02:32:56.342Z" }, + { url = "https://files.pythonhosted.org/packages/49/78/9c91f1e86cadcbc100b3780fd429c3715648704032a612e77a00646ebe79/msgpack-1.2.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b1631e12fe572e181cd77e831f69335d6cd5278eac22e3db3f33cf264ac2ac18", size = 94256, upload-time = "2026-09-29T02:32:58.056Z" }, + { url = "https://files.pythonhosted.org/packages/91/4d/270f9725921ae88a29d37a774a77ac24f0ef1411fc960a63f5a4665e81b4/msgpack-1.2.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e54394b7dbe2e12ab032d9d21feef7bb61a90a150a2623633ba3781ba69dcb1f", size = 471673, upload-time = "2026-09-29T02:32:59.886Z" }, + { url = "https://files.pythonhosted.org/packages/48/b8/eaa8d930f72dc1d1dd79511dc2ccf965922b059f2f0ed3b30aebac8c4b11/msgpack-1.2.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63bb7448a1e9111319ae2430c09a5596140c160422830d6271bc75730ff2ff9a", size = 466257, upload-time = "2026-09-29T02:33:01.517Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5a/97adc805037bc7e24c4e2f711bbcd3b28be8ec9aea3e778f18208cfbdb46/msgpack-1.2.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:382bc88fe90f29f5ac8a0b65c7046ff255356f2f2f3186c30e370215736fa1dc", size = 418484, upload-time = "2026-09-29T02:33:03.402Z" }, + { url = "https://files.pythonhosted.org/packages/0d/7e/1c53302606fe436ab48ba539ebafafe4a6a9efe12c4f04dc7eb36912d93e/msgpack-1.2.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c77e27790ad72989db783d5303825fba0b71550f00a490efba35cde7dc4b719f", size = 454064, upload-time = "2026-09-29T02:33:04.977Z" }, + { url = "https://files.pythonhosted.org/packages/00/2d/9ee0170f638907b396c15c6cd26b3e54f869159efc6206683acfd8f696e1/msgpack-1.2.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:700bc0fc9e968a292b9137ee70e7a012f7e115bf0107ce45e3a88202788dfc1e", size = 417901, upload-time = "2026-09-29T02:33:06.489Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d2/905c84490a75cd15a27065407cd085d201f7d392e1e0411f49f03fd31ade/msgpack-1.2.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5bd5f91ea75c45cafcc5433ba8fae59b708b736ec178d2441c40c499e9e079db", size = 459896, upload-time = "2026-09-29T02:33:08.361Z" }, + { url = "https://files.pythonhosted.org/packages/37/cd/4ce5809b9ab3b114d7cca64863e436820fa1614b49d55ccb93d49824ac2d/msgpack-1.2.3-cp314-cp314t-win32.whl", hash = "sha256:7995a7c6a62a1d6e7df211b4a16de513bd99fd053525050a319f80f44fb8015e", size = 75983, upload-time = "2026-09-29T02:33:10.023Z" }, + { url = "https://files.pythonhosted.org/packages/8a/31/853bb580744c24be0dbd8b090c3e6987dce466a1fc840fe50c0ac2ef9044/msgpack-1.2.3-cp314-cp314t-win_amd64.whl", hash = "sha256:bfe7d5b62cbe7aa664f0b3e2c49077f10fcdd06183d3014f8271ff3c5edbfbf9", size = 83757, upload-time = "2026-09-29T02:33:11.441Z" }, + { url = "https://files.pythonhosted.org/packages/0d/49/9f1b2ee484414eef9e21ee2b2b23b482bb71433ab9bac1da03cbda15ebf5/msgpack-1.2.3-cp314-cp314t-win_arm64.whl", hash = "sha256:1f585407f740a9eac04a3bb82c61d68a0ea78f90e29e670bfb086b9ce3a518dd", size = 78128, upload-time = "2026-09-29T02:33:13.063Z" }, + { url = "https://files.pythonhosted.org/packages/47/b8/50db4235407c3802f622b4ccdf65c6fe1e48d3c3eab6981fa6a9a5e53f11/msgpack-1.2.3-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:13221a6c81ebb8e43ea63a7251c35d54e4175cea37ebf3a62e911bdf42562a3c", size = 92111, upload-time = "2026-09-29T02:33:14.476Z" }, + { url = "https://files.pythonhosted.org/packages/15/56/50cf2a45c6163edafd737e2fd555103a26ce6748e1e241fb56ed445ea835/msgpack-1.2.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:0955b9000725573d1457c1676944b370dd9643c8d18f25bda5ac72913f850949", size = 90583, upload-time = "2026-09-29T02:33:15.924Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/8cc02f767c3bc94d2649c954d28dea935ce9398eb9c93ce2444bb9474cc1/msgpack-1.2.3-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c91762c48cd686dc9cf2b142c0bc544083952de32f5853d6624c956e54b85e5", size = 454751, upload-time = "2026-09-29T02:33:17.475Z" }, + { url = "https://files.pythonhosted.org/packages/80/c9/ddb896767808e3e022453d8dfae26fd52ed404b0aa6fb7f752d39c040208/msgpack-1.2.3-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f4ae8bd4ad9ba085fde95e95d055a896d19210238a4199a771a3cf36dceed49", size = 463597, upload-time = "2026-09-29T02:33:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a5/e7c261abf75783c07dcac89951cb31dd0c123bf02fbdeda0c67303e698d8/msgpack-1.2.3-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7013534a7163aa4f213c4d9864f1a8a7555daac6fcd48f699a198e29b436bfab", size = 422661, upload-time = "2026-09-29T02:33:21.093Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8e/466d5133f9e1c2e232e15e304f715b62f6f0e28332d18e37d975fe174315/msgpack-1.2.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:6a834097144aabe948b8ca9020a833e8026f7d0abbd0ec54bc7e50f45a8ce012", size = 445188, upload-time = "2026-09-29T02:33:22.877Z" }, + { url = "https://files.pythonhosted.org/packages/d4/b4/33e7ad987ee2f4b3d449a6cbf28f574ed222987ca7f65ad277072646ac5e/msgpack-1.2.3-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:d31864ba3933a589b6a00249f89c0eb422197f49128fc10da550e57e9cb0f377", size = 420451, upload-time = "2026-09-29T02:33:24.485Z" }, + { url = "https://files.pythonhosted.org/packages/34/2c/9d8be0d6c16e7e6131cd7da20257dd3da65473e3e6df0c00572fb10a195c/msgpack-1.2.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e15f70588f4db8cd10df0930145b186de70feb9db51710cd378b1399009655bd", size = 460624, upload-time = "2026-09-29T02:33:26.063Z" }, + { url = "https://files.pythonhosted.org/packages/6a/e7/3a04783582c6f44f398cbfcf5f07a111192126ec4e63edf7f5640143bf64/msgpack-1.2.3-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:b949cc25e4a09252cbcc54e66e507de914d0e94a3a7039bd54c299bf7037c098", size = 53474, upload-time = "2026-09-29T02:33:27.83Z" }, + { url = "https://files.pythonhosted.org/packages/68/fb/db07359851644e258609d84f8e4fe0030ef448c108e20afe73f2a3bf539c/msgpack-1.2.3-cp315-cp315-win32.whl", hash = "sha256:8ec7a1d49ca6c2569d722ab5ec86e90089b0713900aa31905b47b4c4d9e78ce0", size = 70344, upload-time = "2026-09-29T02:33:29.382Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e4/cf5584d2f2a2e4465d5896a855a3e75a34a20ab172360b3d42ad862dd1ce/msgpack-1.2.3-cp315-cp315-win_amd64.whl", hash = "sha256:79dfa38faf92f804aa61beec140d70b18418e1dde1778dbb77a87a4cce85aa8a", size = 77800, upload-time = "2026-09-29T02:33:30.941Z" }, + { url = "https://files.pythonhosted.org/packages/63/f9/518ad4e8a580027b507eafdd26de7aae661a714e43d7c111c212482e4a1b/msgpack-1.2.3-cp315-cp315-win_arm64.whl", hash = "sha256:ed899d73a22f286a72bd9528d63f2ab3030dbad8bf1527fc249319a50d61fb9d", size = 73871, upload-time = "2026-09-29T02:33:32.406Z" }, + { url = "https://files.pythonhosted.org/packages/a4/79/254d4c9ad642b2a3ba84e646787892b34cc815eb36c9976f67a1c4f38515/msgpack-1.2.3-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f56fba61b2516be7917cb00151f0d060b5b21184e3499bb57f0f7d9259bea124", size = 93370, upload-time = "2026-09-29T02:33:33.87Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/5a2ba167646a25e84eaa8894e12935351e4331b80c28a9237ce6fe8d375f/msgpack-1.2.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:69ad12cedb674c73527bed869cddb42b742cac79a207a614202a4abaa24ea173", size = 93959, upload-time = "2026-09-29T02:33:35.503Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a1/2b44612e55f7cf5d5e4b580294959b4429bbbcb1991177888e3e18668137/msgpack-1.2.3-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db9fb67a3a2e75247bae569d34ebb5ff61c0448a4f0d6dbf991dae68af39b007", size = 467921, upload-time = "2026-09-29T02:33:37.023Z" }, + { url = "https://files.pythonhosted.org/packages/0b/6e/3309798ed1c11d7fcfdc7b946642685b0ff1588477925bc0d26bee7dcaae/msgpack-1.2.3-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2574ef81c1c8c38b10e330f3f9406fd09198a776b002030fafcf8e7647e9e06e", size = 467310, upload-time = "2026-09-29T02:33:38.799Z" }, + { url = "https://files.pythonhosted.org/packages/6f/79/9c799f489fa4146de4e00cfe9fee17afe33d8012f88ddffffea94f7c4700/msgpack-1.2.3-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fafc3b8898b432b841d30a61082c599fa7f4d06885f9dc58ad72259e12059fa6", size = 420178, upload-time = "2026-09-29T02:33:40.781Z" }, + { url = "https://files.pythonhosted.org/packages/94/c6/5850dc9cafcd2ea315692e65db0e222d20923dd55f44adf35061003de27e/msgpack-1.2.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:a393e428f6ffb0dcb73308c1fff5593041c16ff42da66e5bac8a83a6107a54b0", size = 450248, upload-time = "2026-09-29T02:33:42.366Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d2/b4c806e3497fe21f0b353568266aec14ff735d092aea672de7b2955db03f/msgpack-1.2.3-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:d1c1e8989a855b7f1f2a64ec4a80b23a631822903952770813857b2e4f460471", size = 418431, upload-time = "2026-09-29T02:33:44.178Z" }, + { url = "https://files.pythonhosted.org/packages/b0/f5/f4ecc3ddac4d551bf2f3cdb283ec546dcc826fe7c500074be61aa273e08a/msgpack-1.2.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:e0bd394e999949c814f7912284243298de1b5a17b6a3dcb6cc8a79b156ffc4fa", size = 457543, upload-time = "2026-09-29T02:33:45.978Z" }, + { url = "https://files.pythonhosted.org/packages/a4/69/1c821d8386fae5cecc5fcaacf3de3947ff0a23f16bb481b5532b5868372a/msgpack-1.2.3-cp315-cp315t-win32.whl", hash = "sha256:3d4c807ed050fe3ddbea5ba7e9f63d7136871ce42861be1f50ff739f0e91047a", size = 75820, upload-time = "2026-09-29T02:33:47.596Z" }, + { url = "https://files.pythonhosted.org/packages/68/9e/41e2f7343a3764a9c1fb10c79f9a6a05db9df93dedd76401d1b511f5a685/msgpack-1.2.3-cp315-cp315t-win_amd64.whl", hash = "sha256:5f304123b90e8b2e49867981b7f6061612c39f50cca51ee88de007c084cf68d3", size = 83345, upload-time = "2026-09-29T02:33:49.325Z" }, + { url = "https://files.pythonhosted.org/packages/80/cd/0c3aa439bc7a7bf24684fef3a0ad776cba170e18ed94445e723bce42fce7/msgpack-1.2.3-cp315-cp315t-win_arm64.whl", hash = "sha256:f41ca154b7737b11893cdce3c78c61d703398a1cd54d4297bdad908392338a8e", size = 77572, upload-time = "2026-09-29T02:33:50.729Z" }, +] + +[[package]] +name = "multidict" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f9/79/84ddb5ba16c4eb2c69c71db76ae3c579fe546e511f7170c7e27eedbab7c1/multidict-7.1.0.tar.gz", hash = "sha256:61a4e5d81b8d4e4ad61964b230129e7a2b914793d96289029078fc9009f074ec", size = 362341, upload-time = "2026-10-09T20:31:38.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/27/77366104f779d883f1a13ef3e8e274cbc2adf75847afecdee0970ace37ed/multidict-7.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:24ad4921135a1410d95b1f1504f4901e1c64cea680014ce2c3c7a825f4f259fc", size = 166031, upload-time = "2026-10-09T13:58:50.692Z" }, + { url = "https://files.pythonhosted.org/packages/02/c0/dd8df25b15560bd6dcb1d844150ed72af8fc2f2b52e29b4362b22c91383d/multidict-7.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8b8429361241da973e594d15344a0989f44fd288ea58d33a6221fb7cc0daf27e", size = 95884, upload-time = "2026-10-09T13:58:52.034Z" }, + { url = "https://files.pythonhosted.org/packages/0b/31/e759ae1d3a44db8af1c6f37e1a0d290122b455ee4ebce4b989705b5a8787/multidict-7.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5c6455f2c11daeee40665c67494cedb426f67dba7375710524071c0c56d739a6", size = 99026, upload-time = "2026-10-09T13:58:53.365Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ee/898d2a18bd1ef8b2adc20c7d8ab8f027f2b9e5da9cb40675133778e26215/multidict-7.1.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6120aab922bb3e15800b6655558cf8e0a5cc79518e954d457f064e5b3d5e9bf6", size = 436692, upload-time = "2026-10-09T13:58:54.57Z" }, + { url = "https://files.pythonhosted.org/packages/7d/c2/2a6994dff5c01685ab08db1a88bad2579fa1db3e74e568cba632118242f8/multidict-7.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:14b1ce8579a43dfc0e592d93fb1d63dea693e4977980ac4166f26d494cc7a358", size = 455754, upload-time = "2026-10-09T13:58:56.159Z" }, + { url = "https://files.pythonhosted.org/packages/48/27/353916a8c627067b54723792f172813025f87f9f70f9771c27214f748b01/multidict-7.1.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:71acdc6eded0f4b86b5e16c96314887cf2572a8eb5d8038b78583d0c0eb3aa1c", size = 427471, upload-time = "2026-10-09T13:58:57.569Z" }, + { url = "https://files.pythonhosted.org/packages/62/f4/08993b1ee8e208dbf6cdcb5b536f4dd261211cde2c864cdcc2b527eb974b/multidict-7.1.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b7cd1cb0b363cd43ebf499beca26d201dd8b89eee49fae60205c82ba13ee03a", size = 477087, upload-time = "2026-10-09T13:58:58.879Z" }, + { url = "https://files.pythonhosted.org/packages/ba/02/31f6890cc54c83de25df712fab9300ef7b13edef463aa7e0f68e5afe40dc/multidict-7.1.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dd8a6b3e8f9edb07fe671b02d8c3241c8b641fecce7eb1e36432db3e55e243da", size = 488933, upload-time = "2026-10-09T13:59:00.632Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9c/598d765e98aa02ff7204b6f11cf790e5840f5e8252d7b1d955ed0043bb94/multidict-7.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a8c826caeb7c08264e0a556df1267531c6ed90cc70506e7e5f4119e2d09f3d7", size = 464425, upload-time = "2026-10-09T13:59:01.955Z" }, + { url = "https://files.pythonhosted.org/packages/eb/07/b6b3508ba72bb82944f985cdd9c4d398c442c07270e5179ea03262b9bf03/multidict-7.1.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c44ced5e5168cdf677f0ae39900863bf2bda7d14a5e13502014005cfe040b8b4", size = 420397, upload-time = "2026-10-09T13:59:03.61Z" }, + { url = "https://files.pythonhosted.org/packages/8c/81/596582dfcf8d3c2d41ec03f24849be9e53c8a44aebd40cf45a31720112f5/multidict-7.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b57d4d7021bfd159db9f8f6f862a85a7a6027934643c512f028d6e5c60c4cbd2", size = 445983, upload-time = "2026-10-09T13:59:04.941Z" }, + { url = "https://files.pythonhosted.org/packages/f6/aa/600a68f29c2b027c2000e4ec6f51824790ff9a13a3828487716e31ac3277/multidict-7.1.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:7fac4250b37d994e3fe42b46ba3c8bfa1614d1d7d8cf1cf23f303099082a9565", size = 421056, upload-time = "2026-10-09T13:59:06.469Z" }, + { url = "https://files.pythonhosted.org/packages/c1/82/006ef3fb5f2b798e9e538125667ba1b90ed8967bc862d7b9103297b2bcb7/multidict-7.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:966ae0588ac9959a040220063733b33f321d04eaf4e60349b42cd855d232202f", size = 436830, upload-time = "2026-10-09T13:59:07.882Z" }, + { url = "https://files.pythonhosted.org/packages/3b/6e/ffa1117db64d4be681f908cb7f38877c70cd06bbbdf29c8685839dcff7d2/multidict-7.1.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:50acd7ee7096949b04482cd7720cb6b85eb9cd9dd5d7ffb6704bfda250261a22", size = 465434, upload-time = "2026-10-09T13:59:09.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/8b/f3ab95a4fb591467c0d2a3c0876f38c17aa0b5c2cd60b4c53bc0c9867c7d/multidict-7.1.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ed6b7f402f3dabd1d72c798b96cf947005ddd796a5bea7b041bccbd517859a42", size = 417108, upload-time = "2026-10-09T13:59:10.595Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2c/3e929884b0f3fd4ae96572d62952e18a76048115832835fb11075295590a/multidict-7.1.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:fadcc96cd6155f35e6d85845fa4fcd37b35885dc8fda77b9f851cdfa538194c1", size = 471643, upload-time = "2026-10-09T13:59:12.082Z" }, + { url = "https://files.pythonhosted.org/packages/32/7b/0c3451ada3a85c1232b04ed0209d5421b07ecad1f69d1b55939b3be0cef4/multidict-7.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c6b67f08014bfc4aedc22cf6a21010c2530cd5fbeb655730406827fe196296be", size = 459115, upload-time = "2026-10-09T13:59:13.613Z" }, + { url = "https://files.pythonhosted.org/packages/ee/d9/aa3b846e69c3730bd999f4f95943301ac26d999d9e285e75b9759c340a45/multidict-7.1.0-cp310-cp310-win32.whl", hash = "sha256:0604ff025497a050a2b2dcc4ae0e5cb6477c525e57b89825152c707e88d74d28", size = 81811, upload-time = "2026-10-09T13:59:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2d/f4907e688463dedc1e644777851c0aaf2b814f37278436f9d3f4d3225a0e/multidict-7.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:36b14886aa3e0b8786ecdaa196374422c7b1c1dcc8764d02b2409f74d47914bc", size = 92126, upload-time = "2026-10-09T13:59:16.222Z" }, + { url = "https://files.pythonhosted.org/packages/16/b8/5f438830119ab1979245c07bf61038813d2f7509f38d1031d6b8e2114bcc/multidict-7.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:a2e575129c048bc286d696ed8e49ca148591768b2d77debcc6569f6fb64d0668", size = 92561, upload-time = "2026-10-09T13:59:17.47Z" }, + { url = "https://files.pythonhosted.org/packages/29/72/c68c86c078a3b3cfa254bce218fbeaf1f6617d4ace811558154ae63d2c20/multidict-7.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:41ff3202cc23c800507777df5a4805b402f262b31008c60fdc652aeb6db2f278", size = 165442, upload-time = "2026-10-09T13:59:19.146Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/988c71ec3a6fe143b2971ac8d57cd5aca7cc6da970bccd1e4c2254997a09/multidict-7.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e50f7775b66c7802f4cb697e986c5acf30ec07301efee95b396c08114e890d67", size = 95611, upload-time = "2026-10-09T13:59:20.591Z" }, + { url = "https://files.pythonhosted.org/packages/30/5e/7d18eb5a75cf4bacb6b3ee689929dcb27d7ed8ca2d245e80e9b8056fcbf5/multidict-7.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:85cb3ced4fa84949cee12bfe78208b6ece7baf3cbd242b26dcaf773efff8d206", size = 98743, upload-time = "2026-10-09T13:59:21.841Z" }, + { url = "https://files.pythonhosted.org/packages/72/38/2aeff3de6ddf235094aad6f833e3df14d1b3175e328748508443cb2b5458/multidict-7.1.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2128f3358335e0c83688ecb40c19d9d6606cd60784dfbf2e24e980ac2ba87b0d", size = 452832, upload-time = "2026-10-09T13:59:23.131Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1f/94d8b7fc7d47bb43be1fc041bccc2784383d352258d4d0dd90ef55d1a2e5/multidict-7.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e2e718fa9d1d900decbc240a533d5d0baf0947ef464c78a8cd4fa32b4e8f590c", size = 475378, upload-time = "2026-10-09T13:59:24.661Z" }, + { url = "https://files.pythonhosted.org/packages/93/86/78f431ef2735fae7a773261ea26cff11e37cbbb4520bf3caed56ece006f1/multidict-7.1.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ecc68f5e47bc6f6f889bbed5bc657b22bb2237ad9ccab8229cb5a0d64f4cb536", size = 442847, upload-time = "2026-10-09T13:59:26.276Z" }, + { url = "https://files.pythonhosted.org/packages/45/d3/1c5af82fe42f677f7c4dcaf487449511d754b84fc29f11a6d190cfb01836/multidict-7.1.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5f21fda91bd6c34455bd5c312e42aa1334da46cdafb4c533ecd01e0f7f19250b", size = 493652, upload-time = "2026-10-09T13:59:28.162Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bc/414b4a83a12811329c046a7c1954d6f08a5eda63f8466939a6cb612da22b/multidict-7.1.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a90453a79423cd7145cc08fc92322dcd7aca4862258f533e03f473226d4b835", size = 506991, upload-time = "2026-10-09T13:59:29.701Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ac/a91709d7153489a3b53661b62af371112b96fb083d10443e22d54665a1e1/multidict-7.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c54ae1b89e582aa25f213cd8b5eac0bda1724e79299f486baeb3f562bbf82ca5", size = 480392, upload-time = "2026-10-09T13:59:31.179Z" }, + { url = "https://files.pythonhosted.org/packages/72/63/a4fc53b6da419876f363fc6860f9fbaa48eb8066f458e907eb2989e9510b/multidict-7.1.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7ff8dd079e7b5f3438332499233a2a5acfca0741fd0eb3d4ddba0c2d9bc04d19", size = 431248, upload-time = "2026-10-09T13:59:32.899Z" }, + { url = "https://files.pythonhosted.org/packages/3d/78/08af4a624b3b9d2f48af730c173394135d14afbf4b3de5ae6c80e0900fd1/multidict-7.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e4ef15d0a29fc2da67fe8ba2301ecabd6f8733696cc2bf0a0cf96a144a20328c", size = 463657, upload-time = "2026-10-09T13:59:34.721Z" }, + { url = "https://files.pythonhosted.org/packages/58/ef/06e5c75b88506972c880d228fe96702cf0fa058ea31f157f5fcd5efcf006/multidict-7.1.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:5fa296f14068538fced53c6eec86520a2ef3d3d27a0fb134640d03e067986d5f", size = 436120, upload-time = "2026-10-09T13:59:36.135Z" }, + { url = "https://files.pythonhosted.org/packages/23/8d/a917004a1e8ceb325c4cf0583e418a40b7ff9519cbfe759bc6982cd3b4c1/multidict-7.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6ab323f0c5490abaf35a78563e1043c7a772eb86d93f359ecc0fd286d1cd3807", size = 453859, upload-time = "2026-10-09T13:59:37.878Z" }, + { url = "https://files.pythonhosted.org/packages/1f/0b/d1cc417355625152b611790aa1f0bc780983ed8c0752f44087fc7dbe66c9/multidict-7.1.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:88ec4d16e9f58071c9896ea01c4da97cce9d01418fe844ff06eebb00e0a1386a", size = 481768, upload-time = "2026-10-09T13:59:39.39Z" }, + { url = "https://files.pythonhosted.org/packages/94/0b/8e79adf65cf5430497d1020504b7d86ebe90e3289a95bb6b252a630d761f/multidict-7.1.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:9161eb81b8062da824426d3700d4b0d287f0cb0b05923713adfe3bd25e7937ac", size = 426973, upload-time = "2026-10-09T13:59:40.932Z" }, + { url = "https://files.pythonhosted.org/packages/ae/4c/e84d6e05943600b16cd2745587e65a407f89b3a4a1f77081d456c5a07011/multidict-7.1.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:c564d0758748f38aec56a6b98c6801a427b3a63f39b7cac538b2b2d18ca32740", size = 489681, upload-time = "2026-10-09T13:59:42.663Z" }, + { url = "https://files.pythonhosted.org/packages/eb/98/01951236a01b3e15c037a4a796f329b5099374e46365d0bc65bb4a7b325d/multidict-7.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c5e4a362a95b85301d262ef6bed06cc8e4a144ac7e2be874cb4c3c46ae89d754", size = 475655, upload-time = "2026-10-09T13:59:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/d3/72/52be318cdb46732da8eb118c2e4212fe570724eaef817901f8b702af2016/multidict-7.1.0-cp311-cp311-win32.whl", hash = "sha256:5d19bb1ec12e385c09215d5d53a243c060c7e8a0aacdba16d933e22902ee380d", size = 81719, upload-time = "2026-10-09T13:59:46.158Z" }, + { url = "https://files.pythonhosted.org/packages/37/fd/1ed7b7d206ef7a6918ac3de515543ca4b49bc50131325ca1814c005daf3f/multidict-7.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:396ba9917fe489ec3a5942ae3e29e91324c8b9956f371f7e124c971c71379e7a", size = 93586, upload-time = "2026-10-09T13:59:47.618Z" }, + { url = "https://files.pythonhosted.org/packages/d8/1e/837877d218bfd2b656ca899ec9dd83e6eb82d8e1ef16e896d5abf3cb60cd/multidict-7.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:b5ed78742502b8d90ff2816688d407a097c8b5cc6af4343fc5ad7a98df53a7cd", size = 92446, upload-time = "2026-10-09T13:59:48.996Z" }, + { url = "https://files.pythonhosted.org/packages/03/e1/215e7df354e2907f3af9d910c8136653cfec94796012a776359bc802a326/multidict-7.1.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ccfb950359a80de0fcd2030ad60ac1b1a861462de3e2ef746697c9256659af21", size = 165426, upload-time = "2026-10-09T13:59:50.564Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ec/461ba588b308ada2cd16907d6ea425ca4d417596b88c12814ad9a0bb7325/multidict-7.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:939d8cd2d8c35e3956f6bc858390b6ccb611e6152b4920d64ab5e98f3fcf39e4", size = 95943, upload-time = "2026-10-09T13:59:52.381Z" }, + { url = "https://files.pythonhosted.org/packages/c9/84/31444ef07ec13a33c42c2772d986129e137e69c4beb89ca64f2138988145/multidict-7.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f8e95c95039eab6a2dad8c83c38ab87fc5431d28849e0c8a7e2a4e70ba38710d", size = 98294, upload-time = "2026-10-09T13:59:54.066Z" }, + { url = "https://files.pythonhosted.org/packages/dc/10/aca13806d73d88b5b01e35e828e23a346cb1abad710a49dff0507702efc9/multidict-7.1.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:05d12b4bac53abe0c65f3163af2b45894e2e1c0cc55493ac784d52a350047d88", size = 453301, upload-time = "2026-10-09T13:59:55.397Z" }, + { url = "https://files.pythonhosted.org/packages/96/8c/382d771bfb3a9282d98332d0e1f27fd1f8b4ae0e7175bd7e008ebf934900/multidict-7.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0e79ed92b1dece6bb57e9b46effd74d7a5d3d00187c85466d880ed184239a698", size = 478570, upload-time = "2026-10-09T13:59:56.899Z" }, + { url = "https://files.pythonhosted.org/packages/52/9c/e81b0c92449da3a1950a575c520d957d7be618777d170717a71e00558d7f/multidict-7.1.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fab380fcff8b3555eb2bd04304fa4330909a771a9a9b0dc07666cfc23148a711", size = 443699, upload-time = "2026-10-09T13:59:58.465Z" }, + { url = "https://files.pythonhosted.org/packages/63/72/f8f5fee6960d1b580a7b046c7c5abccf67aa26fa5647927a91c9c835c2f8/multidict-7.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4b5c41e44da74383c924cc5d75ef0a268f301d69305b3c42bd17af685d55e412", size = 497332, upload-time = "2026-10-09T14:08:01.88Z" }, + { url = "https://files.pythonhosted.org/packages/e2/92/a25d7db3ca451b588e743e067ee80f2b475edf6467a56908454faf6a714c/multidict-7.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dbaa7f7c2f0ca8578895fc61fb8c8e50ebb405dad8982f92f4343285c7a3fda", size = 512457, upload-time = "2026-10-09T14:08:25.465Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f3/374c0ab122bb98b1a62a8742b1e3f59563e4d609942005a4041861a0df67/multidict-7.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed6b7705d49dd07e5e0dd5f5c873fc44047e92d714299b13245b5fecac49d01", size = 483000, upload-time = "2026-10-09T14:08:27.132Z" }, + { url = "https://files.pythonhosted.org/packages/46/1f/01c8522859771dc3cee840d5852233fe84c91aa982a1cd8ad594306d25cc/multidict-7.1.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53daa47dd176db64bb35170e3d5d0ae2388c060121201883696278f055a0e70c", size = 430746, upload-time = "2026-10-09T14:08:28.715Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f3/af90affc8cca6b4ee59b2ec354a20839005e829d14d155451009810ef1ce/multidict-7.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:542429c796430de924d03b68a6173bb6d79d5c4967d4e9a18de3e501cad55593", size = 466859, upload-time = "2026-10-09T20:27:24.139Z" }, + { url = "https://files.pythonhosted.org/packages/f8/97/1b6762f37f6331449e164af9d5500f0abb0f23670e81ba543441e0a6be1d/multidict-7.1.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c44ca6d3cdf4cfcbcd4f928fdcbe87af5fd7319f6ad4169617b7fd6b4527c33c", size = 441952, upload-time = "2026-10-09T20:27:26.559Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d2/4908177fbf22438799c04ba11a2853a99c69d028fccefe61f19e68caba0c/multidict-7.1.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:eb0228c809b2e7eb47921876050af0bc4214b351bad8d8112f70b6ed4288763c", size = 452810, upload-time = "2026-10-09T20:27:28.347Z" }, + { url = "https://files.pythonhosted.org/packages/8d/05/5031f44f680ec54fc182c4d71c9ab7c07216983946aa64ce6fdd56a52692/multidict-7.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:6ad60de1f4c702448fc8f1449f05e810f6b7957c08a5b3950c8a792dfb13b50a", size = 483934, upload-time = "2026-10-09T20:27:30.14Z" }, + { url = "https://files.pythonhosted.org/packages/da/3b/9b21d107dbe96fa7e6966ff9e5e10b9ac0d2d1c2cf1633098e4c700f865e/multidict-7.1.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f79def86aee67b5ba01b2565f1610f262bf88ae53c379f93e5fa29c50fe793be", size = 427042, upload-time = "2026-10-09T20:27:32.433Z" }, + { url = "https://files.pythonhosted.org/packages/53/ce/5b01b1041580072866b30e39c6380bff269e72fb5ca41a7f2fb828ede943/multidict-7.1.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:248dabb89b5aa90b2f7e43e045f048f7e5392ec77b6446d80853ba7117d7bbdf", size = 492543, upload-time = "2026-10-09T20:27:34.459Z" }, + { url = "https://files.pythonhosted.org/packages/1e/6f/6508a23fcc7b1122e4f18409d7900ffeb3cd020cd080fe98aba3eabf9486/multidict-7.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0747a83e7ae617793181a4763ee8b84863cec5c0bbbde70c4394e4c0276c36de", size = 475796, upload-time = "2026-10-09T20:27:36.289Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b6/ebb6433f4aa55ac1fea4bf11e860e99611640d07cc92d21cb3f35608de22/multidict-7.1.0-cp312-cp312-win32.whl", hash = "sha256:1df055e51fe7491120cc84f3362bd43db186be78d0e4c476acad45e435af9ffb", size = 81250, upload-time = "2026-10-09T20:27:38.229Z" }, + { url = "https://files.pythonhosted.org/packages/61/0a/11240e5e7d2e986e288f4a6090f569ad00225b7c2c513d4096b36643f9ab/multidict-7.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:10202ba98cfb3f7eb60da7ca87a2c458a69b7d0d6e4d4388cd6773ebbce89085", size = 93817, upload-time = "2026-10-09T20:27:39.739Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9b/a04ffd76db7cca2a60d347e839315e1ada63017c6c343fa90a23a5a55433/multidict-7.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:0aa1ba3ff7cdda05a1242490612976b2ae1c90fc6200903ef8f53815dcb35c5d", size = 92312, upload-time = "2026-10-09T20:27:41.285Z" }, + { url = "https://files.pythonhosted.org/packages/60/34/22ee5f60d704e8edfe6e0a37ca1e2d1efe276560dc8f6f8caee35ea7a4f5/multidict-7.1.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:51d7f33be9a4a1a2801430846d72841deea0894eae8381a07e7d90e0f71b3c4b", size = 101454, upload-time = "2026-10-09T20:27:43.102Z" }, + { url = "https://files.pythonhosted.org/packages/3b/6d/7652943397171fd624de163d25fc6d2807852931c40c24776cfee3f857cc/multidict-7.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2a964dfeb2aba3663f0536c809aa1ff385f065e89fae57e883fb7edfb4067c2f", size = 95823, upload-time = "2026-10-09T20:27:44.912Z" }, + { url = "https://files.pythonhosted.org/packages/a2/57/717d1aa4e901f58a7e93b945ed741c860d950f456b75be47123fef507ac2/multidict-7.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d02cd23b5af182a49d635ee72be38053767711987a9fd82625b16b93828a0d8c", size = 97841, upload-time = "2026-10-09T20:27:46.616Z" }, + { url = "https://files.pythonhosted.org/packages/a8/74/d2d22d306225f3c53ea5a682abb8a43bc30057d6c78b5c94a74035450bfd/multidict-7.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:0179698c3c913eb64f32397083747fad20ed0f0a2b7469a08cd1a8a95d14d90e", size = 164661, upload-time = "2026-10-09T20:27:48.495Z" }, + { url = "https://files.pythonhosted.org/packages/55/f4/1e63fea41ba86768e18dbc1743e6780f75ad32d7a45e418740c6fb64589e/multidict-7.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ccf98ee859fe29f874ddd8e637f14ba59108a333492b521acb885a9095244a9c", size = 95489, upload-time = "2026-10-09T20:27:50.055Z" }, + { url = "https://files.pythonhosted.org/packages/8c/55/477c351b21b34fe948ffffa8b64cd0c246efd998fed3de922b217e632e59/multidict-7.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5fa1484f74d011addf2e5f5a0378ec41521989839a05d6051d8067d8ce732423", size = 97931, upload-time = "2026-10-09T20:27:51.598Z" }, + { url = "https://files.pythonhosted.org/packages/cc/dd/288508d7deb9489dd7c8e0b172d62dc1da8f3cb24877293ead42e6cc2047/multidict-7.1.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1fed3d721f75c25a9fcdd0e362af53f4b20acbcdc63081112f85419ba0ce3444", size = 453816, upload-time = "2026-10-09T20:27:53.217Z" }, + { url = "https://files.pythonhosted.org/packages/60/ed/172447dd09f06111f69d2cf22a018020b34a93a39ddf72cddfdb48323449/multidict-7.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df03e392cae1e05462918abbae06d6100f1e53f67db971ff0ac6c07d9edf7321", size = 477779, upload-time = "2026-10-09T20:27:54.972Z" }, + { url = "https://files.pythonhosted.org/packages/e4/7d/e5b7755e84611ee846f0dc830cb1363ce10764b29247a01b857722fda653/multidict-7.1.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:379f477b98a1e9a77ddc3ccaa8c709d3fb4a288ff54b96e171e637b55b4adbae", size = 441299, upload-time = "2026-10-09T20:27:56.901Z" }, + { url = "https://files.pythonhosted.org/packages/1b/d6/e9c93da1644491610f09258349283421a279fdb3b383929b4d963698691c/multidict-7.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:40f586bc8a084a3671ddcae9e5fbd3228a596bfb63d9f0380f153f9a65b69f08", size = 496193, upload-time = "2026-10-09T20:27:58.788Z" }, + { url = "https://files.pythonhosted.org/packages/22/49/7fe19efed1b1c73e2f6ae65eba4e6528eeb26e10c970dc0bb0fc009a2aae/multidict-7.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6e7f70d912a589e30290ed926f90ddbc3160998359cbad7c9ede1bcee481748c", size = 511469, upload-time = "2026-10-09T20:28:00.689Z" }, + { url = "https://files.pythonhosted.org/packages/4d/2e/7b708a72001323dc6b2930834d63e355870ea5b1ee8d1450e0fe1a24752b/multidict-7.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5129cc1f5fec6888e2db0be936dab67242e32c738811c8769aeea93aab4257a8", size = 481377, upload-time = "2026-10-09T20:28:03.011Z" }, + { url = "https://files.pythonhosted.org/packages/8b/60/3d23e7d2ebc7e7e6b1b205de0d5c2ce0652a9b2edef65eea3528758b5699/multidict-7.1.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1401caec21fd7f002e79ab6806bbfd1f54bb3de6d5e12bd91c6685dce16ad2be", size = 429992, upload-time = "2026-10-09T20:28:04.846Z" }, + { url = "https://files.pythonhosted.org/packages/f6/e6/8e3bef78ea1929a3f2c395aa292799d4effd6ce1b5cfce42c1986a33e2b5/multidict-7.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c81062e947f4b5a624135a843f6ac4b3c7fe6508300c9fb27347f022ba0c513d", size = 465635, upload-time = "2026-10-09T20:28:06.527Z" }, + { url = "https://files.pythonhosted.org/packages/44/6a/55fe5dd90c0e9ce60f5c4fa8ace27eeb1190d9cbb151929b04412f0c673a/multidict-7.1.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:c53be0dd676484a660acc56e4f1cd0dd74bc1255d12fa285e86a3fa9d5f22bf9", size = 441693, upload-time = "2026-10-09T20:28:08.443Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a9/628913f71537dce7dbd6c4ee1ae5019976264427c55354ecc593b4b921b9/multidict-7.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:41e0c3350d08994ee8640c39884e16514e282f70ba40f5b2299582509a327774", size = 453808, upload-time = "2026-10-09T20:28:10.871Z" }, + { url = "https://files.pythonhosted.org/packages/25/d0/3ae3af653b5776d045f460582006d0b4d7dd41b078b3ab2e874bf4d1e22f/multidict-7.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:abeec7a89d698aa1c9b4c36bd5e3c746faef0867076e6a2ca27fa5077c4ece26", size = 483160, upload-time = "2026-10-09T20:28:12.892Z" }, + { url = "https://files.pythonhosted.org/packages/56/3e/2c13e111b9f4c6216aee0ac57ac39c53db7f97e42bb8490e60a06eaca352/multidict-7.1.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:23f6d325241b0db006ca2841309ed17622137e134930a740a8f1331ec4404791", size = 425896, upload-time = "2026-10-09T20:28:14.65Z" }, + { url = "https://files.pythonhosted.org/packages/45/13/15b4d614cf0d6838a7d89eb14f4820fdecfd1b7f529061d172a674912d41/multidict-7.1.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:034b0dc1b7fb8279599c5d8563f86abb4d2454735b06544ecab23c54572ad2bd", size = 491674, upload-time = "2026-10-09T20:28:16.508Z" }, + { url = "https://files.pythonhosted.org/packages/4f/97/3a6f8c75f12a607ff047e3db9a45dff6a558b1e31b89b2f0f27da0fedb6f/multidict-7.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c8074ad4d67067c87bd0663dfda654f786336078c8fd7d2f6c1aa41de8494cc", size = 473989, upload-time = "2026-10-09T20:28:18.284Z" }, + { url = "https://files.pythonhosted.org/packages/3f/20/5e7726631e50b6afd26324a234851a06104b8ee30f01bc209140788b76c6/multidict-7.1.0-cp313-cp313-win32.whl", hash = "sha256:7b25c335fc53acf29d4d21dbc19fe39d2824201cdda0448623152cc5917bd259", size = 80952, upload-time = "2026-10-09T20:28:20.044Z" }, + { url = "https://files.pythonhosted.org/packages/4f/19/b26bdbef5bf5071ea5a3ba37c963af187fddb8b0a925bd39c0c5dd0ad04a/multidict-7.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:7de54b49e6da811b0321e412d14efdaa1ee0c0b6609296ea5b9022bc5b2bd843", size = 93554, upload-time = "2026-10-09T20:28:21.657Z" }, + { url = "https://files.pythonhosted.org/packages/35/d5/e9a8600d3868e3fdc4a4be1d15e594048508511c0398423df0ed18c11b5a/multidict-7.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:aeba2c750102051aa51e087c2ccbc79f2724a41c94168f8731e36f54c453551a", size = 92039, upload-time = "2026-10-09T20:28:23.464Z" }, + { url = "https://files.pythonhosted.org/packages/2c/a7/c9f5c08348a6f903143b631546e22becf1b704a1304b17f7e1b9850308d2/multidict-7.1.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:128ea4142f81a79d430f3d0eb55206093e5eda03a12abbc7b03c34748ff6116b", size = 100435, upload-time = "2026-10-09T20:28:26.111Z" }, + { url = "https://files.pythonhosted.org/packages/82/60/92fe617008c74cc019483b1c59202c48738c6f637ad5b12b36f01e21db43/multidict-7.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:439a19f7fbbff232ce96682c57e27030b8ac3a4b8121484c94f04bf99d08bfff", size = 95267, upload-time = "2026-10-09T20:28:27.856Z" }, + { url = "https://files.pythonhosted.org/packages/17/78/82182f311d673f17de15bc7f7465c7ea5cbd2987ca3a6bf6546fa721e9d8/multidict-7.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8090c35199d6b7bc6426bb8bdaf341e64f295cc2624a1fda7860c0837f1acc03", size = 97233, upload-time = "2026-10-09T20:28:29.817Z" }, + { url = "https://files.pythonhosted.org/packages/63/25/af4e482d053fd73b4b1d60de3ff5578c6cb1d319d39d3f2a454d65409835/multidict-7.1.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b7cc5333fcbfb27327d12612ed72322f221b61c2b69deb1155078c964f86e1a1", size = 163070, upload-time = "2026-10-09T20:28:31.495Z" }, + { url = "https://files.pythonhosted.org/packages/df/d5/eaed52e199451dac445305fb2631d3f4e7ac9536aeef01f23622a1d93f90/multidict-7.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:b0e0040b0d8dd89bd0af9ab18901981e344ffba68bb30b8eabb4eab6c303279b", size = 94582, upload-time = "2026-10-09T20:28:33.263Z" }, + { url = "https://files.pythonhosted.org/packages/6b/e3/ff58ecad5161baa98dec05716e2745449446f2424a9c727464bf452f7fd1/multidict-7.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:33376418ab2846b931a72b36cfa16810befc4f49485d0b3f4dc054a4d6d00038", size = 97406, upload-time = "2026-10-09T20:28:34.981Z" }, + { url = "https://files.pythonhosted.org/packages/ac/a2/ae4eadf02d4bc035baa7cadf5548ca4fa441517193f76ce1aeb5ed276ae3/multidict-7.1.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1101aea5c3eb1d26e090b931c693488af0db9f3d52e68be8d4cdd807dad9841d", size = 449908, upload-time = "2026-10-09T20:28:36.659Z" }, + { url = "https://files.pythonhosted.org/packages/91/55/bd5101ef760d1af4c3f246330b29b48ee22bfdd5a83150b713dd93b431e2/multidict-7.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f996b19ac89e0dae65821ce65f788619e4286f78c62d005ecd3b75b5d9c0892b", size = 471605, upload-time = "2026-10-09T20:28:38.561Z" }, + { url = "https://files.pythonhosted.org/packages/4d/8f/3dea8a28b0416ab71d47c8ce274bb3cacedde2d9838647ac67cdaa3d48e6/multidict-7.1.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e0d91a4bcb59ac0d7af0d8e0da737332e1b7fe6831e53e47819b1b5349d431b2", size = 433523, upload-time = "2026-10-09T20:28:40.913Z" }, + { url = "https://files.pythonhosted.org/packages/24/97/805d2aa4f746cc43cf4b206f1b1bfe2566add95bbb145abd3d9cf61858bf/multidict-7.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e96d67914ddbf5466e4476a1cd7ff30a332cbab85ed895207acc3e58c979b6a7", size = 488277, upload-time = "2026-10-09T20:28:42.851Z" }, + { url = "https://files.pythonhosted.org/packages/dd/7b/eac247b7e76f6010071c7c2401da5255eeffd1b2ac981925e533413d21ec/multidict-7.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:170ba61761f59ab92afcc86ce5534a3f3d0b07c38b339b950a83213f22dd86ec", size = 501888, upload-time = "2026-10-09T20:28:44.75Z" }, + { url = "https://files.pythonhosted.org/packages/87/58/de62e27b09dd15756265765945f68f1c0d1e23eaba09e2e109fbb4112425/multidict-7.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:33389fe084e5426d9fd85d7d9ca91a29cd0d88a83c7c96e411aca49a3f9967bc", size = 474883, upload-time = "2026-10-09T20:28:46.704Z" }, + { url = "https://files.pythonhosted.org/packages/21/fd/afb4e50ce3b44707b5ee39c6b5fa1573bdb50c4be660bb35b040362b5170/multidict-7.1.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:77024596b9046572c4e90b34c1ff212346756dc48933f90c53cf6e233660788d", size = 424327, upload-time = "2026-10-09T20:28:48.681Z" }, + { url = "https://files.pythonhosted.org/packages/23/70/96c9abf933c4edae53b8a1c8d3f038120a3a60d363f16c6d94eee9b04851/multidict-7.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ff15531a376dc6f35984443fd1429e4b150c36ce27633e7cc52a9e5318546e20", size = 459255, upload-time = "2026-10-09T20:28:50.562Z" }, + { url = "https://files.pythonhosted.org/packages/24/37/dbc1dba26dc1c9e42aa07ee01908131a67fee0e79ebe5c1d0e7fc00aad55/multidict-7.1.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:544f2642a456fa264614e975d921540ee8c3b368b04d5aa1ddbec33241b13e08", size = 432476, upload-time = "2026-10-09T20:28:52.361Z" }, + { url = "https://files.pythonhosted.org/packages/9c/0a/eac70cc1461668bad8253a2da727670a56293eaba112aeb4079af8cd37d9/multidict-7.1.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:08834fb8b20e1a985c70e8380a10940234b4162de62694458727330376e58b33", size = 450030, upload-time = "2026-10-09T20:28:54.252Z" }, + { url = "https://files.pythonhosted.org/packages/23/2d/eb40652ea74f96df859c7c5d38f9997f420b9dc404300dda0c5745327a6f/multidict-7.1.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b4908e17867930b7ac77f89a18dc67308c67c511f037d8580489be86fb585912", size = 476169, upload-time = "2026-10-09T20:28:56.819Z" }, + { url = "https://files.pythonhosted.org/packages/44/d0/7454ab8335bc4ec9f8abcc4c83a314bab060f22db6da8436b396e07ed21a/multidict-7.1.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:9b24e1f93b9b586ec03bc7bea1bf021ec90bf2528c729195028a3ca1c266b3f9", size = 420459, upload-time = "2026-10-09T20:28:58.744Z" }, + { url = "https://files.pythonhosted.org/packages/60/7a/cedb46b287b720a2c4eda2448faf162612d0b718beb32581e35ccd9219b3/multidict-7.1.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5c93473d0d7cd9bbb370973a9679a62f381c7050d7dff4ad6aaa92e8650f5a79", size = 483071, upload-time = "2026-10-09T20:29:01.114Z" }, + { url = "https://files.pythonhosted.org/packages/9a/11/e7ded22b008546dd30ae8c979aa5ac3282cad98876f7d5bbe93c1f2409a0/multidict-7.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bf14cfcc30b097583d698a6e2b8b68c9bcffab277c485d481881958360c2938d", size = 467470, upload-time = "2026-10-09T20:29:03.388Z" }, + { url = "https://files.pythonhosted.org/packages/1d/f3/0136144cb0b1731fd93475cea75d974a55009358d3bf61dd55e2709a3dca/multidict-7.1.0-cp314-cp314-win32.whl", hash = "sha256:86bc779a0896e59e4be30a5be5cd6eeffd0b40b6f0e75e730218736b7bfc6f5c", size = 80205, upload-time = "2026-10-09T20:29:05.736Z" }, + { url = "https://files.pythonhosted.org/packages/74/d9/a62a690d780febc171026d3e3c5700fc8387aa3e5f77cd04ada33d23d3f4/multidict-7.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:6c9fd50f636a8fa9cb6324cd3eac962fec2bc5bb432452a3b583583a1059acfc", size = 92923, upload-time = "2026-10-09T20:29:07.337Z" }, + { url = "https://files.pythonhosted.org/packages/84/08/eb7a1c34dc37c5f2c6aef52e6861e6f1cdfc6c685dcb72581eb218c00953/multidict-7.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:e6906aa4bc62cde2c8aeb8a99a7b4401b241e274ae7b11df67d863d61ab3d5de", size = 92851, upload-time = "2026-10-09T20:29:09.15Z" }, + { url = "https://files.pythonhosted.org/packages/ff/3a/019abd746e8fbed9edd74b81259f20b278c011cef918868506d9b5bd6566/multidict-7.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fabfdd4cf97db033196b51af46b8a681d4785c2a66347f2a5af1b4bbb1182629", size = 177429, upload-time = "2026-10-09T20:29:10.989Z" }, + { url = "https://files.pythonhosted.org/packages/f7/41/c8dda935231305d9b83c4a4cc5b5b9e323615328d4704f00ba2cf6d89916/multidict-7.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a177a0ee5cf19931dcaeb3f662bc562754cfa4f4ace2351d9da24a954ef7db94", size = 102003, upload-time = "2026-10-09T20:29:12.78Z" }, + { url = "https://files.pythonhosted.org/packages/af/23/3c3e79a222eaaa59a32648cec34709d848657be5339f96876075731e7f8e/multidict-7.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0ae91de396d5c4ac97cb24dbada3d5c91a51454781e0a70476b008f4e879e4f0", size = 104861, upload-time = "2026-10-09T20:29:14.591Z" }, + { url = "https://files.pythonhosted.org/packages/cd/5b/04637e7cea5729466fb89048520fde167c5404df1beedcc86f18aeabed7c/multidict-7.1.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:160bdb3520fdadcaa21e1b98aab2e011265070814ecab3804eb61674becbd400", size = 463221, upload-time = "2026-10-09T20:29:16.697Z" }, + { url = "https://files.pythonhosted.org/packages/2d/42/b121767de213a9774399c200cb877b19c2e1e5a0b0a6bc8407c7325fa37e/multidict-7.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c2144785e42527404bbd5cfd11981fee4abe59a22aded0e498eb711a831d3f3", size = 502241, upload-time = "2026-10-09T20:29:18.657Z" }, + { url = "https://files.pythonhosted.org/packages/47/0a/1cccd17ebf39776df7d8a6c99d066fc3ac1823d044cfe86a22ebbf42b841/multidict-7.1.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7d0b4fec6a8d02d7e95de5cfa913261820f1ce04bd4c0381924de0da523179b8", size = 457128, upload-time = "2026-10-09T20:29:20.589Z" }, + { url = "https://files.pythonhosted.org/packages/19/a9/0616d20dee16255f8737d9017288d9304dabf3695ac0071baca25d00c713/multidict-7.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:943a9bce22180ad0f4d32d1b402a0949a4ecfe5a1257b47f54a1b51981d81b86", size = 511842, upload-time = "2026-10-09T20:29:22.569Z" }, + { url = "https://files.pythonhosted.org/packages/91/68/5a406b82ecfeee50c097c05fd519c836a61815d81c4a9a8c523d90fb4b35/multidict-7.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f76ceb623f7ff50df46ac57e1587c479d87a5766319c4f43d0c0a5158896afab", size = 519852, upload-time = "2026-10-09T20:29:24.527Z" }, + { url = "https://files.pythonhosted.org/packages/d5/da/d42234f9d4f0e33885c70149112bf62aa4436045090c15cec0810ada0b2f/multidict-7.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98beff85392ce435b28a0971ec21cade61ce8be8b632c9d855475a28ef92d31a", size = 500322, upload-time = "2026-10-09T20:29:26.569Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c6/323e921a9812a6ea82978d4e1d9713e0b709023be037e6e4c14efe5472eb/multidict-7.1.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:71196ebb8d523148e5975396a444de02367f204b53b14e26794c96b2be0ed742", size = 444070, upload-time = "2026-10-09T20:29:28.52Z" }, + { url = "https://files.pythonhosted.org/packages/93/87/109b7170de2168294f3e562f9d10caaaf946628d614b5a57246279acc1a6/multidict-7.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:35534b366410a36bb3d6f788691e37a76e4d1da48326b0ada3e5032580dd76af", size = 484862, upload-time = "2026-10-09T20:29:30.504Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/7f1f63c9cf13f47036c0c64c607e6eee87c94076e3c6876abb6990ee62ad/multidict-7.1.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b4674b12701c3fcbdf7f88b9e4479701c93bec5da9eb576140d5fcc0092990af", size = 457314, upload-time = "2026-10-09T20:29:32.6Z" }, + { url = "https://files.pythonhosted.org/packages/93/ce/7de8b4ee6a847cc951915c279fc388127ec32a7c14aa7a01c4ad692be575/multidict-7.1.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ead852a5e906a43fcb6784eeac480f6a67919a51d480c1f80d32ddf9d615475", size = 462672, upload-time = "2026-10-09T20:29:34.776Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6c/02dd089475983b1f5b8729319caa0b02fa3a60b527f27c86b2d53c24ca11/multidict-7.1.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:afe36ca503c2ffe30fb6df82b20389fa3c4035b5d65888a61310921cf3ae91c5", size = 498109, upload-time = "2026-10-09T20:29:36.763Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/b9bdc59aa8e3eaf1f9e5d2460bbe0c428a01250d44417f8ccf965a8f6fa6/multidict-7.1.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a5f0bebb10aae010d3c9ee3abaf83ab2069c718457aea09c15532355dd7e061f", size = 442260, upload-time = "2026-10-09T20:29:38.872Z" }, + { url = "https://files.pythonhosted.org/packages/fa/83/21b044885ab81bf5c03ec5c4c16aa449977e428c5c122e38969481cec1df/multidict-7.1.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:b9d9b7d72975521434368fe8aed3f6b522060bf271adabaa5ca6c87c0c08e168", size = 498916, upload-time = "2026-10-09T20:29:41.091Z" }, + { url = "https://files.pythonhosted.org/packages/95/8b/31686730092e349ee2255fa630945ca344fc62e76f0b18d65b0f3dbb0ecf/multidict-7.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:274023bf952f849e0d05eba28a4c1f65f9796430d2b09ec16539386c0f76554c", size = 493525, upload-time = "2026-10-09T20:29:43.192Z" }, + { url = "https://files.pythonhosted.org/packages/c7/d0/c9fddcd7ac42b70a46ac9cf15e21eb527874164e14d418fcb50ce7190a4b/multidict-7.1.0-cp314-cp314t-win32.whl", hash = "sha256:7e0bfa161df365ba3c88899ee3b7c94755200967284bdedef8c1b8b43e2c0f2b", size = 86481, upload-time = "2026-10-09T20:29:45.188Z" }, + { url = "https://files.pythonhosted.org/packages/24/ab/ce727c06680e72b5d381caa8587f561cb6fea1bfab6ba20c877019768a6f/multidict-7.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:34a35be8fb82d37087e8176aba907b9459f03d0e293c80f574c6337a436f4eaa", size = 99676, upload-time = "2026-10-09T20:29:47.441Z" }, + { url = "https://files.pythonhosted.org/packages/fa/e7/d116d7ce514d04d9bf63774ed55e8033e2ce15ab5655a597bb947c53dfb8/multidict-7.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:d7dd46a8fcd7653c09ebe67eae9d4cb6636c7a905d9cbaf587dabcbd4eca6013", size = 98726, upload-time = "2026-10-09T20:29:49.365Z" }, + { url = "https://files.pythonhosted.org/packages/28/42/963e2e38ffd79487b24775841cfe2abb85aa1fa08ee152afc4a9b64e1cdd/multidict-7.1.0-cp315-cp315-android_24_x86_64.whl", hash = "sha256:852c921217f330b3e81a822647ebadeae7e42cf503ec1992d0bfbc90121c09fb", size = 100450, upload-time = "2026-10-09T20:29:51.486Z" }, + { url = "https://files.pythonhosted.org/packages/b3/6e/fbc4721aa0eaea2dff3274ff44e94f60a89841bbb4cdaecd9faa4eb64aa7/multidict-7.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cbec738d2ad551c6f70955d7eec95e339380ee1564e2afe86bfee05fed52ceec", size = 95440, upload-time = "2026-10-09T20:29:53.929Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/045aee749b599560d5348bb1bee5c9d61d8b12f31d16e0594778ef3472de/multidict-7.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a5f721a2437390ab69c10c6df5c142478d399af8dfb02e6d823cf2358e8a4748", size = 97496, upload-time = "2026-10-09T20:29:55.808Z" }, + { url = "https://files.pythonhosted.org/packages/97/79/f2916b81324d629eba363f760fba26cafef5cb437489c24969ada979508d/multidict-7.1.0-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:99cf27791129d37e191ff013bfc29bf6631c29edb21680c00978567b91fc5d6b", size = 163241, upload-time = "2026-10-09T20:29:57.813Z" }, + { url = "https://files.pythonhosted.org/packages/ce/f6/2aab2b2d7bf2d69204bde0cdabfefe4df3d1954f3f156bfc53714917e757/multidict-7.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:2ba6611fc93c4b169d0e0ea376ebf4b8a529933d1f5f2c2ec7d8f8b93ef58ec2", size = 94586, upload-time = "2026-10-09T20:30:00.201Z" }, + { url = "https://files.pythonhosted.org/packages/b6/83/cf690ab80a0db0f50b300a7141f45dbf1bbbb6b32457f74bb55f47d38f5c/multidict-7.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:c45629c0049fbdef932dbe408ac2b271fdc8c7d9962ca31160f4a0fc3455fe4f", size = 97573, upload-time = "2026-10-09T20:30:02.675Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2e/951c1412c9803e8f87c8673305be1e448761c940cd867ccfdd3e6a551dfd/multidict-7.1.0-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cf606cfe3f67984b4064ac605d71e1eba12515fbabf5bd5a34a8952b8800dc66", size = 453476, upload-time = "2026-10-09T20:30:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/a1/55/12f9f95fc65470bf481ad3d1c0e0fc20094720d0af1985119656f2b4be25/multidict-7.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50fdfcb03be719d9573597b095b1175d2e9d0b30d065791dfd9fca727c499442", size = 473078, upload-time = "2026-10-09T20:30:06.986Z" }, + { url = "https://files.pythonhosted.org/packages/87/31/ebe216194a68934de7053d582cde62c839bef4dbc4ae67e9bbccf80a61d2/multidict-7.1.0-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a8bba9d1f6db4ef2a6ebfc937a65d36e80e3aada00b382eaf56fea8f639322d5", size = 435633, upload-time = "2026-10-09T20:30:09.021Z" }, + { url = "https://files.pythonhosted.org/packages/46/e5/4aea764cd6a1d326d91f2bb92b1c8c39c3f5a8da624e7b20b88179dffb72/multidict-7.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:46d4af0afc6eb9867b3ae50605787c80b868e2f52eac3801246034925fe578b8", size = 488656, upload-time = "2026-10-09T20:30:11.119Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f8/b1e5935233fa4b504b1a58b584fcdf03526635e4b5f268f716be9108a1a4/multidict-7.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5cc58ebb731200ddb64d55f1b345630fb5f7a8138cdbd242af9dce964a7cb03d", size = 502311, upload-time = "2026-10-09T20:30:13.396Z" }, + { url = "https://files.pythonhosted.org/packages/9a/8e/cde07147b6fc56b9ae0fe01d9c0bebd730b2be2c3052d3a5a117e13f561e/multidict-7.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b87ad54e8d4adeb0a1f04889504d6ec7f04fb02609220810f51f1b6c66bc1cc", size = 477470, upload-time = "2026-10-09T20:30:15.894Z" }, + { url = "https://files.pythonhosted.org/packages/68/a1/11ac1880eacddcf698aa227ba9387cda60128e38034eda182cf4585109ee/multidict-7.1.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:44f7e5dd83a615636b80182bdf446ece57ed61d5d51854acc5d9840631136d4e", size = 430088, upload-time = "2026-10-09T20:30:18.972Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5a/09926de0ec910704507a21c4f1ad76026aab9a5657ced868c57bbbe9324b/multidict-7.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9bc5e7f843d14a167cdc26fe2d22f6f3aa2feb57919cf3ff034262a57d8d95d0", size = 460632, upload-time = "2026-10-09T20:30:21.45Z" }, + { url = "https://files.pythonhosted.org/packages/ed/36/cc51eb3ffb09f475326bbf868c6af2805fbbe9175122ce6d7a65aa06d6c1/multidict-7.1.0-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:0631eb5f49f67de10bbdc3f64141326dbc62e8d319900966648381ce0845d8ca", size = 434056, upload-time = "2026-10-09T20:30:23.679Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f4/9e0e4dce595573eba1555495ac9ca6b5f7f6e14cf6c3a5cd87ce5bd33d6f/multidict-7.1.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:d1b1b32f3c32f734dde8f36ac1df8e275e768a7b333241cd637cb2538628a4b4", size = 453014, upload-time = "2026-10-09T20:30:26.192Z" }, + { url = "https://files.pythonhosted.org/packages/96/14/993a7ac9f3592f5628a12f6bc2495f5cce5814b883170e06f487dede6969/multidict-7.1.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:159976f9c40f96e3fe0952b708846a43a76bacb114e9cc828816f5080bddd5ec", size = 476343, upload-time = "2026-10-09T20:30:28.532Z" }, + { url = "https://files.pythonhosted.org/packages/b0/da/c2147c1e225a676ee0c97f97bb935fb8a70821bc12df240491bf9f140801/multidict-7.1.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:81a0e08c64dfdad27dab687b96f572b23bafa1999a39d1b6f70b3ddbb73e8bd0", size = 426813, upload-time = "2026-10-09T20:30:31.015Z" }, + { url = "https://files.pythonhosted.org/packages/5f/fa/8858b260a6662035670759e7b3a7da9decc227296491749073e76202de93/multidict-7.1.0-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:4e11e7299079718c78f8147e7206c22fe35bab4466d38992420795288a0b8096", size = 483882, upload-time = "2026-10-09T20:30:33.509Z" }, + { url = "https://files.pythonhosted.org/packages/c2/43/d42dc515d56d506f437e8a19c4f604719a306f8d2b60f70a14be34ac9231/multidict-7.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:34d2ee98e15d5cfe782a431bc913fce3b58cf3fdb34fcb437aeb275cdf9007ab", size = 469953, upload-time = "2026-10-09T20:30:35.996Z" }, + { url = "https://files.pythonhosted.org/packages/5e/bd/1b6fb52f7e082b4d49b069d362169a2e6dbc43800f152f33c8f0e8933117/multidict-7.1.0-cp315-cp315-win32.whl", hash = "sha256:f376224572d1f5da1c871f969ab04765727f180e70d012d93e07bfc08442c64b", size = 80202, upload-time = "2026-10-09T20:30:38.121Z" }, + { url = "https://files.pythonhosted.org/packages/f2/26/5dec513dec950825529037703da89a597dc0b157e7b1b8d0304dd9e672cc/multidict-7.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:67fcf28db77b385820881521db7435e9f1c607cfaf07db6eb78aa9d1146bde86", size = 92921, upload-time = "2026-10-09T20:30:40.507Z" }, + { url = "https://files.pythonhosted.org/packages/dd/eb/fd62025f8cd3dad6f936df8bd7ff2642e2fe3fe0166ef2fc75699ee57cac/multidict-7.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:c7aafa4dd2f702ee2198005d6cba4309c1e25ed1c201d77beddefa47411bead8", size = 92848, upload-time = "2026-10-09T20:30:43.026Z" }, + { url = "https://files.pythonhosted.org/packages/42/f4/2dcd45731d2f57b87211a1dab1c29a72c6a4a527f370851c3e4cef5c4445/multidict-7.1.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:1348ddc076251cd542f4a99ccda4b7c1f8444e8ab489d3541a978ca5901c7c1f", size = 177634, upload-time = "2026-10-09T20:30:45.125Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d6/d57b12a5bb19396a99ae66ec48ff69f764cc2c83e83bd9a003bd5ed5802a/multidict-7.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:9267bf8261a779abb2a6eab5f107f5db85b2d1745f2494081c731aaf28738ce3", size = 101954, upload-time = "2026-10-09T20:30:47.416Z" }, + { url = "https://files.pythonhosted.org/packages/46/8b/374e3f6e4581b8f8425712214cad854ff9370019483aaa49e6207c160adf/multidict-7.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:8a844b8b1685f38a2e8b2f3213b286e2a7abfe67508381780a0d4599ac337c1c", size = 105056, upload-time = "2026-10-09T20:30:49.618Z" }, + { url = "https://files.pythonhosted.org/packages/9e/0a/6c89e2179a84eaa78eec658b9a05f10d3e22654708d7b2e3ebdf0faa0614/multidict-7.1.0-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:18a447d46a3a2f1e61b365cbf5627db7030fdb707dad70c4f2760e5144166ecc", size = 467374, upload-time = "2026-10-09T20:30:51.933Z" }, + { url = "https://files.pythonhosted.org/packages/c5/62/431ed831b5e9b94c75ed0a410786d07176e879603601570eb25594039200/multidict-7.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88811f890db240a1c82bf0bcd52973763707a552c8113ac3fcebca183afb2fa8", size = 504024, upload-time = "2026-10-09T20:30:54.701Z" }, + { url = "https://files.pythonhosted.org/packages/d4/dc/52e4204538a7824e407e531b742ff6f7a2ff614dd40382d29a62755699cb/multidict-7.1.0-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a60b720c329c0007feae692b7bf91cf17b3f9bd3727be96cc6f9a3336651041b", size = 460139, upload-time = "2026-10-09T20:30:57.193Z" }, + { url = "https://files.pythonhosted.org/packages/22/4c/0228d61a8fee69a7ac4d8d0426aa1810b9c36b679a8c51844de701230073/multidict-7.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b117ed1cd1a23df0902461c38093408b95971833dcee629112acda25b603c8d0", size = 513162, upload-time = "2026-10-09T20:30:59.662Z" }, + { url = "https://files.pythonhosted.org/packages/56/50/a0b28bf4f036897bbd44c8761fbb384c014c34f621687799d07345b62820/multidict-7.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bbcae7a54050b7ad7bc7bf425ba63dea7d2cd31a92246ba787a2ce69a9b98dbc", size = 520312, upload-time = "2026-10-09T20:31:02.515Z" }, + { url = "https://files.pythonhosted.org/packages/4f/1d/ba96f77c24174eff9f6521cc7c83958f27598b924a9f7b6aca5d11c73f35/multidict-7.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4cba2b0b9235fe10e12301d6b4cfba0f353fa668d635f6e988b03623c2cd42ba", size = 502953, upload-time = "2026-10-09T20:31:05.187Z" }, + { url = "https://files.pythonhosted.org/packages/59/f2/14c3ffb649cf45a629ec38ca757a493f0caf729e3f3580816dc7779e4caa/multidict-7.1.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:535173fbcc3933d84f9929d49d7a59a0faec259ee07d07c34c7d2a980b4e3683", size = 451497, upload-time = "2026-10-09T20:31:07.597Z" }, + { url = "https://files.pythonhosted.org/packages/55/df/fa4f8f6ee2314bdd3e4bc830c25f1d8a737188198abeaf105c73454f0b95/multidict-7.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:ea027bdeca1d7e498237634ee4e3a852e2723eef39996dec0ff0f77dff8a2336", size = 486212, upload-time = "2026-10-09T20:31:10.178Z" }, + { url = "https://files.pythonhosted.org/packages/32/5e/3227762b04a8ff1a90359ee2c04af9ed095fe3892e101705fa4233b51514/multidict-7.1.0-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:507151e1e3dee95e9e8159e329aed4f75aa5205ecd6505a4f6be546890eafbe1", size = 459864, upload-time = "2026-10-09T20:31:13.044Z" }, + { url = "https://files.pythonhosted.org/packages/5e/04/58524c7e82176438a48704a687fb383cc943dd7075c67b5bea288043b504/multidict-7.1.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:9c10791e9f5ef132effc8fdce2009482c1cfb26618c5fc1b7952a47dd5eb632e", size = 465861, upload-time = "2026-10-09T20:31:15.503Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d6/28e549b6ec0c829647de4c75cf32e4c7ea0d0e87b91dfb399292f630c365/multidict-7.1.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:f16ac8af2804855d3cae5fc3c5ab609c9fd0fc8ecacd92579c05ed3c173396fd", size = 498865, upload-time = "2026-10-09T20:31:18.548Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ac/3006640586b4d33442c53989b78fc290c8eae5fab5b62a31ac7a4c220e69/multidict-7.1.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:b78de22bae456a976f33df34d598dfd16edc9a03df8f4cc8b7c17bdba4c97b4a", size = 449885, upload-time = "2026-10-09T20:31:21.03Z" }, + { url = "https://files.pythonhosted.org/packages/80/7e/1ece407cee9f9aff9ace9e44379ed937bccb790936ae448feb79a82c362e/multidict-7.1.0-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:db77888081431aaa69f3fd3480891746ddce6c2a571f6201869a24e2f06cf423", size = 500237, upload-time = "2026-10-09T20:31:23.611Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4c/c052b700ffcf689454848ee81ba87bedfdc10caff592c0d2a327c43939a5/multidict-7.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:c39dfcaa0bf23443474c0cb58d8d8aea9529c1841d99654cb38e4dada7b1948a", size = 495143, upload-time = "2026-10-09T20:31:26.237Z" }, + { url = "https://files.pythonhosted.org/packages/0a/ef/9ddad94c32fb0bdfc5ad0942d06ae6699bd30d261b0ff59b94810a133238/multidict-7.1.0-cp315-cp315t-win32.whl", hash = "sha256:16b21164797bde6f417066d02775975cc2e15ab8abf80efa55fe85e0b4894020", size = 86526, upload-time = "2026-10-09T20:31:28.734Z" }, + { url = "https://files.pythonhosted.org/packages/b3/ce/2efa1f32a07adfe041d48c6d04c08b6f9db66b8512c765983172399b2855/multidict-7.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:55392202cb374dd1a1f89a8ce1586644870d9e936752059d053e576acc50bc89", size = 99671, upload-time = "2026-10-09T20:31:31.282Z" }, + { url = "https://files.pythonhosted.org/packages/a9/b3/a44c301fe13716c5f2c08afd89a481018988cde4b337b36e28a2b0fcf3a3/multidict-7.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:f979a077d1c0a9a36dd4fab0d3a36b8de7b593bf935e13df85a380395b2c11ad", size = 98893, upload-time = "2026-10-09T20:31:33.62Z" }, + { url = "https://files.pythonhosted.org/packages/d0/86/a3de309c5e28ee85b314d0e3ba0e0dea6fd361c313322a05e67be4656e1e/multidict-7.1.0-py3-none-any.whl", hash = "sha256:d9ef29cfd98e17085b4f91bba8fa1570bec6787d5c52ce653ed33a58785585d0", size = 28087, upload-time = "2026-10-09T20:31:35.945Z" }, +] + [[package]] name = "nbclient" version = "0.11.0" @@ -2614,9 +3322,12 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] sdist = { url = "https://files.pythonhosted.org/packages/dc/76/3af777226b63a5e64a6b36b1ec5855c14e2b94a37096d4760e595fc43511/networkx-3.7.tar.gz", hash = "sha256:fd77a511bd90f39f3d016351345b52cf5319b813bdca01de3f755d3cca62e96a", size = 1866482, upload-time = "2026-09-21T16:45:16.974Z" } wheels = [ @@ -2827,9 +3538,12 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] sdist = { url = "https://files.pythonhosted.org/packages/13/01/11703282db468b85f6f7b8c7f22d058de5970d5c7e60a3a8aaa313c3de36/numpy-2.5.3.tar.gz", hash = "sha256:df2d5874ff183595a4ba404edd04f6bd9b5505c1d7708573f6a6c17489a67563", size = 20791231, upload-time = "2026-09-06T16:27:47.073Z" } wheels = [ @@ -2990,9 +3704,12 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.11.*' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", @@ -3188,6 +3905,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" }, ] +[[package]] +name = "plasma-plots" +version = "0.2.4" +source = { git = "https://github.com/max-models/plasma-plots?branch=devel#46a140ddf4dcce1f1e617c6be94eb71263caf109" } +dependencies = [ + { name = "ipywidgets" }, + { name = "matplotlib", version = "3.10.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "matplotlib", version = "3.11.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "xarray", version = "2025.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "xarray", version = "2026.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] + +[package.optional-dependencies] +scene = [ + { name = "nest-asyncio2" }, + { name = "pyvista" }, + { name = "trame-pyvista" }, +] + [[package]] name = "platformdirs" version = "4.12.3" @@ -3257,6 +3996,151 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/6f/84908cad2d6aa5144abcf7b42709fe4fdb459bc640ec7ac5786e7693dabc/prompt_toolkit-3.0.53-py3-none-any.whl", hash = "sha256:01c0891d7f9237d5e339f7d3e42cdae80b7534abb1c7c0e3352efba6231492f2", size = 392288, upload-time = "2026-07-26T20:56:12.512Z" }, ] +[[package]] +name = "propcache" +version = "0.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/9a/9fbf4e4ec0c2d7f1c32519fff782ef467859b8faa9fbc5331a96f6395d43/propcache-0.5.4.tar.gz", hash = "sha256:ff6b113f50bc066a698db5d944d2c6dc7507168dd3341e255a8892fd0715a558", size = 61545, upload-time = "2026-09-16T00:17:14.386Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/dd/997704118aea215cc5f65c277f5323657b4ba44c1f9a32fb11c8064e4997/propcache-0.5.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b77c313314524ca9c38fbd70f73515d04597ac58c40c939bc0e71eeb4abff680", size = 87225, upload-time = "2026-09-16T00:13:43.864Z" }, + { url = "https://files.pythonhosted.org/packages/3e/6d/ceeca1762ed51230c08d557591404f844ecef5e294550136155572f7faae/propcache-0.5.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8f911c395cef73c510bac566da9507bb6a43e7763d0c79138dc60ee53f11207e", size = 50809, upload-time = "2026-09-16T00:13:45.33Z" }, + { url = "https://files.pythonhosted.org/packages/ff/9a/6be90814d8762952594a9e380161802541bff690f3a2e011dcc28ec193ce/propcache-0.5.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d83b12902eb8bce151259c86c03ba746600b2d994543de46e370cecf96c452f2", size = 52552, upload-time = "2026-09-16T00:13:46.48Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d1/7fd3ffb5ca0e669a8fbf55d9fbb17c50ff5f44becfefcab27a9b9b67908f/propcache-0.5.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9281e922c072158c91974d4589f1dbe0fee6d467f284c28e463f9f5a4d933f4", size = 226804, upload-time = "2026-09-16T00:13:47.739Z" }, + { url = "https://files.pythonhosted.org/packages/74/87/a3e199c45b26587f073db655d19c30d2144b0f883827ba6ab77acf5c7d45/propcache-0.5.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9f3551b8a35c1df3e7ea4d2d86edee15f0dde1bddd434a71744048683544d0ef", size = 234452, upload-time = "2026-09-16T00:13:48.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/7c/08c15c7df256f94a6b4563f74165c3242fb554cdd1909b661d093c31b976/propcache-0.5.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ec6a85f424afa8d23e0d9a094e5dbb6eda01da91c92b9183cd433768247ffc97", size = 240399, upload-time = "2026-09-16T00:13:50.416Z" }, + { url = "https://files.pythonhosted.org/packages/f0/51/5adee15e12a7e314cece4543fbf295b0fe1b504dc78b32dff4c8eb1e29b0/propcache-0.5.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f574e460d1c8a08384a016fdb09ccf3543433263ed6b2f97104f979e64ea57c2", size = 224251, upload-time = "2026-09-16T00:13:51.874Z" }, + { url = "https://files.pythonhosted.org/packages/9d/53/54bd510bb5d473edf66914602885226187218b4e3a5017e9dd80bceea41f/propcache-0.5.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d8e017eeb7482bed34cdb0d61cf2bcfc88d104bbab296a17cd16a6af8aabc70e", size = 202721, upload-time = "2026-09-16T00:13:53.524Z" }, + { url = "https://files.pythonhosted.org/packages/6f/b4/a468700d0526dfb2ca6af5d790125de5894acc7d8ecd99243ecedfa4c4cc/propcache-0.5.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f273dcf7149a50527c4fd1f55cfe9eac0f60753f5af544b4c9352578e20c0874", size = 219148, upload-time = "2026-09-16T00:13:55.068Z" }, + { url = "https://files.pythonhosted.org/packages/6b/d4/bf563e19ac9a5cc47113431337fdf2ee3573859f3f3e0a58e59833e9b75d/propcache-0.5.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:fc2461ecc45f17893f8207e73b46ea8ba93e33630e51cf4af3fbc21d47462b1a", size = 211011, upload-time = "2026-09-16T00:13:56.588Z" }, + { url = "https://files.pythonhosted.org/packages/4b/3d/0189b6537f4a8cd795e685a130a6f47aeda78a5b7b062574705cffc685ca/propcache-0.5.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:279655a16973f1ee2bd2fe79973137681642fd9ae0d89215bba263726eb0dc3a", size = 228148, upload-time = "2026-09-16T00:13:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b7/59c16e245a549df202b4ebe2de91fa58a67dc4373df9840e372a64224dee/propcache-0.5.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:e9f165403b81fea7e89c932d89046a1e3d9a3a60e8d7ef2f249dccdcb0982bf5", size = 201432, upload-time = "2026-09-16T00:13:59.453Z" }, + { url = "https://files.pythonhosted.org/packages/41/0b/7b19eb20bb0b1f9476d08f6e387ae094dc6870fc06d459ea1f35f28b25d3/propcache-0.5.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:1783582065a1f07f9d9ee1e992e13f15d7dc8fb1eb3a7476d43eb3f2e69d26bb", size = 228923, upload-time = "2026-09-16T00:14:00.731Z" }, + { url = "https://files.pythonhosted.org/packages/3a/f9/b1a0bd47218216b935d019912d6fec1676ae7c07f15fa82bfb54b8d58976/propcache-0.5.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d605bb239b796e82a81c6709548b2bd460ab73b4590cb0c83de8a2dd9694d0f", size = 218327, upload-time = "2026-09-16T00:14:02.091Z" }, + { url = "https://files.pythonhosted.org/packages/55/c0/51e5d1ad504e9529831606534c48e272db751072eee9ff07e4abe37e50bd/propcache-0.5.4-cp310-cp310-win32.whl", hash = "sha256:141fdbd73748db0cf7636035030aaac383d2efde8f34e7bc24594cc776d225b8", size = 43096, upload-time = "2026-09-16T00:14:03.427Z" }, + { url = "https://files.pythonhosted.org/packages/80/57/0a0b4b1122f4ed5bcd1c626d910003cdf5282c2a12f8a1cfa17970b3ded2/propcache-0.5.4-cp310-cp310-win_amd64.whl", hash = "sha256:146f48a9e4812611a7581003b1a39de56c34967046310c4171a68ef908c9a745", size = 46589, upload-time = "2026-09-16T00:14:04.581Z" }, + { url = "https://files.pythonhosted.org/packages/66/87/e71b24adc8ece61782ba3d6f3879e6a07fdb85bce21a9c529524184e3ec6/propcache-0.5.4-cp310-cp310-win_arm64.whl", hash = "sha256:6c7599df2b57ebeea8de011b5f2f7b85de95e76037d43d34b95e328430275487", size = 43982, upload-time = "2026-09-16T00:14:05.728Z" }, + { url = "https://files.pythonhosted.org/packages/1e/40/14b21e505b7921617466576423f188a5c9caddfdaa1cf4b2b8a83d8fe216/propcache-0.5.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:897d1ddf6716e8f47200f7aad9a0efa6cc7586df66c6defa572f9eab379c078e", size = 86393, upload-time = "2026-09-16T00:14:06.9Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4b/5a52e1a7b43563f7d408814194bb23cc8bf214eb6b86639b667a33a8d0d0/propcache-0.5.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9cbfff4423eef4cc6cafc021469641a2b835f610b2647a6c5281903e21b8670d", size = 50431, upload-time = "2026-09-16T00:14:08.025Z" }, + { url = "https://files.pythonhosted.org/packages/05/cf/b5248180bf056cc76acc60c9c6e8c0ebbfdbd1c6cffd31fd14996927b7c8/propcache-0.5.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3fc24f209c1b7f7f688b66b98293954f5504279760999b58920ee12dd8471c1d", size = 52116, upload-time = "2026-09-16T00:14:09.114Z" }, + { url = "https://files.pythonhosted.org/packages/86/a8/7c6cd6bfead1a11f2e411e688640e6d26574cb0bde7dcaa7423b0b65ed7a/propcache-0.5.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:62530ca89187827e4a4fe733f971abe81a7542eeea48ff61995f19b64d7199c8", size = 238729, upload-time = "2026-09-16T00:14:10.357Z" }, + { url = "https://files.pythonhosted.org/packages/5c/b4/442715b2e980df51be52d203549279e027728f24c80b00b5e525e31cd5ea/propcache-0.5.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56fc3f7599528db40b1efa0889a620116e2704144495273d66066e8164e45838", size = 246121, upload-time = "2026-09-16T00:14:11.735Z" }, + { url = "https://files.pythonhosted.org/packages/bc/5d/df0684fc2b1732a01a7bec26d7897369022712422d25b09c37ce7dbc88a2/propcache-0.5.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4f2d880ff60f45898f4acfa152aac8d04e3ee627d90ff4003491bf92239d5757", size = 251735, upload-time = "2026-09-16T00:14:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/c7/06/519a5ebb48b6f94beb48396e55c905f12246a25c3a3608a7ec7bceabf50e/propcache-0.5.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e9368e87a3efc285e559131092c5db643eb8e56de4ee42064d5baec22ef2bb5", size = 235381, upload-time = "2026-09-16T00:14:14.398Z" }, + { url = "https://files.pythonhosted.org/packages/3c/07/1e0a9bb310830f2245edbd5cd3c6d24a783c053c4efd8e08e386e513c940/propcache-0.5.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:004e685b315646c410771836e72a44f143bbe624f29653a42687815069a303d5", size = 208973, upload-time = "2026-09-16T00:14:15.715Z" }, + { url = "https://files.pythonhosted.org/packages/62/5c/9324fab27d6088eecc47fe4332bf7aaf8c1ded93c36f558391e8a06d41a7/propcache-0.5.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:594eb4c6ec35e7179b058481f4e9f02521b56de16fa577c4b85c76fb1bf8a9f8", size = 233897, upload-time = "2026-09-16T00:14:17.25Z" }, + { url = "https://files.pythonhosted.org/packages/9c/a3/570d92fc952eae93b676f3a1568f4b89264102abd3c982ab6a9ebec58dcf/propcache-0.5.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:2dba2f02d2d5c09ef8a0e6c1a42aeaa451f4be9898cb00b04fe98717da2eb23b", size = 223512, upload-time = "2026-09-16T00:14:18.87Z" }, + { url = "https://files.pythonhosted.org/packages/65/47/26810d889d89bba31db397e6a88f8984af775f5ed6bad0a29dce84324cff/propcache-0.5.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c3ef2818d63bc86071e9d2989ae75a1bc32b8f7059cfd9f5abbbee70c32e2ed6", size = 239043, upload-time = "2026-09-16T00:14:20.366Z" }, + { url = "https://files.pythonhosted.org/packages/89/2d/f9c47691aa024c8299a3afacd78d22a01ab57eb627b481b6089708e71017/propcache-0.5.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:dd2ac8f5b643454c2cc6b6118b13da16e88f4a6434fc3ba61aca384029f04f36", size = 208218, upload-time = "2026-09-16T00:14:21.801Z" }, + { url = "https://files.pythonhosted.org/packages/74/6b/d510c0c378cabbf9d0ac7b663af6d00f2e9074073d93b20c85f24aa5c071/propcache-0.5.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:4054acf80d40456a0537f2913b349718649d8d6458a14ab7f48d0ce28c30869d", size = 240301, upload-time = "2026-09-16T00:14:23.121Z" }, + { url = "https://files.pythonhosted.org/packages/3d/80/c80f6adaaa1e51f0db2dce8c9b3714d94ec45e358a21f9a1910b10b40a80/propcache-0.5.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:40e94adb1e7d39ff28a8bd8d8b8fbd1df6b9f40976dbe379134f1ce058e532dd", size = 230785, upload-time = "2026-09-16T00:14:24.458Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e2/c32a7df3f39caa7f11b2eb37ea5b6960a6946f2bc7c4b8ff97bbdf6d6b6e/propcache-0.5.4-cp311-cp311-win32.whl", hash = "sha256:9f86f7259efe2c951f43e57d471c9b41daa5bfc7db9f67189059cf1ae6d77fd9", size = 42747, upload-time = "2026-09-16T00:14:25.715Z" }, + { url = "https://files.pythonhosted.org/packages/0a/8a/3db6a3543d8101263b4c52978b6276a04ead2caff2c5ab880d934f47bd89/propcache-0.5.4-cp311-cp311-win_amd64.whl", hash = "sha256:e904d4d01f36bd6e197590be1533c44e06058771e0746dd073a8ebb3ef880858", size = 46268, upload-time = "2026-09-16T00:14:26.996Z" }, + { url = "https://files.pythonhosted.org/packages/41/07/5222e2665bbf6e45847492ecbf3b9f3e4975a0ae300e5fd465df7d48ce55/propcache-0.5.4-cp311-cp311-win_arm64.whl", hash = "sha256:d42a9a856a4a6e2f6c10f1318c07e7daa498d6593abe745c71dae4521a26ca39", size = 43547, upload-time = "2026-09-16T00:14:28.143Z" }, + { url = "https://files.pythonhosted.org/packages/71/cd/348d58f142aebc4873345c6b31087629182ca6e0f2b3caeaa528cf882eba/propcache-0.5.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b28f41fa3b8c6900457f858ec5b03998f3a6d535fbc1bb2edec5961ea05ec429", size = 87285, upload-time = "2026-09-16T00:14:29.362Z" }, + { url = "https://files.pythonhosted.org/packages/df/f4/f3ffaee281b276da854ac1d7a6a506d26cbc62ea2e623756f1d0a4a1ba1a/propcache-0.5.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:dcbf346a318a5e30063f547630b02bb787ce2f45b6368d5da143660b6a3835d8", size = 50984, upload-time = "2026-09-16T00:14:30.473Z" }, + { url = "https://files.pythonhosted.org/packages/25/88/1d7df7201750b37765ef2b23bc1c526c028dadde80afa0f57a118fc01182/propcache-0.5.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:87a3caecf8095e48dc72f84bfa42e23a848cf410cc9cc13031fba4869b706a21", size = 52460, upload-time = "2026-09-16T00:14:31.692Z" }, + { url = "https://files.pythonhosted.org/packages/83/4f/48865bd02a16ee5236bc46166b2946f37b93e07b0eae355dac0be0b216ca/propcache-0.5.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60a64cbccaa11b7760ce705a14ada17ba459e7ca9f23ba587eb013821032d7ef", size = 251768, upload-time = "2026-09-16T00:14:32.908Z" }, + { url = "https://files.pythonhosted.org/packages/b0/19/3742a5eed62317b03b4002ee865dc9fd720308bdd0da1f29a5786c630311/propcache-0.5.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a74bfa37147cc08fb29df10bd9c16f40fa7f860cd3a6d2fff853323a94f6e17f", size = 257723, upload-time = "2026-09-16T00:14:34.267Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d5/ee6350fb0be9122bb6c67082a876d34b90d980d100c106af4b81023e04f4/propcache-0.5.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a4d7a54719b67338a305dca2ce6aafe366817df94ddfd4b5514374356f5ca546", size = 265597, upload-time = "2026-09-16T00:14:35.56Z" }, + { url = "https://files.pythonhosted.org/packages/85/9f/83a07b6ec0e043c050cfdd35fb0cf1b7897b91d554d6eea293740309afe7/propcache-0.5.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2814ecd8e818f487bee4b0f921bc4d1c176cc5fc71ac0f072d0fa67eda4ac14b", size = 250424, upload-time = "2026-09-16T00:14:36.894Z" }, + { url = "https://files.pythonhosted.org/packages/33/2c/a763a8251f50fba042af0fb1f02bfec4b31381e40aff760db2be7b2e1f84/propcache-0.5.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6af4693716bfb03f1752ef1b30faa593db2c01d5272e9b8564a1549452a979ab", size = 216748, upload-time = "2026-09-16T00:14:38.369Z" }, + { url = "https://files.pythonhosted.org/packages/6a/e2/4d11bea8fd6a777149c6c20645f873952eab5de3a2497aa11648ec9ab6ab/propcache-0.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4fbc1a15dc8cd1689508758d626b372b1f09d28d9577667feaf9e6bfcd8efcbc", size = 246533, upload-time = "2026-09-16T00:14:39.82Z" }, + { url = "https://files.pythonhosted.org/packages/9f/36/6683597de4907e70c717e3588c541202c66086a72ff3db58be49de66e72c/propcache-0.5.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cdee8205a44d0be91bbac4c41b95d86641b72dfc7aef1279400e4fda3f26a937", size = 238173, upload-time = "2026-09-16T00:14:41.259Z" }, + { url = "https://files.pythonhosted.org/packages/85/84/cb08d79f1762daafeb2b030c470cd0c725c97b8ad67412457c6f35c53e9d/propcache-0.5.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9a2a8a50a93dee0268a860a07fa3b4bd968f8ce4dbd794957da772f395368526", size = 251128, upload-time = "2026-09-16T00:14:42.652Z" }, + { url = "https://files.pythonhosted.org/packages/c2/0d/41b848036db6621370c1f2e5471a7da8149c730f8552a5257567721f4576/propcache-0.5.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7ffafcbfc7b549ab940047e505c831eabac5e67de53e1bc174adbc5285c55944", size = 214821, upload-time = "2026-09-16T00:14:44.112Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/adfae4bf9c63bccf12e2d9690a175c6579047a6eec3b5a6a5f51428c15e2/propcache-0.5.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:d1f5a500bfcbb2c0ab85e98a0dcd70f5899d34efe365a0187700369a79603031", size = 254793, upload-time = "2026-09-16T00:14:45.429Z" }, + { url = "https://files.pythonhosted.org/packages/51/6f/eeca9647245d5f92e87d53e5f14335bb42fce1a7e6842c8045b364eded8b/propcache-0.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8a235f73d6e020855dc29dff012d920c02ee0feab8d73a24185a7569f4be1161", size = 247134, upload-time = "2026-09-16T00:14:46.976Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a9/424e38838793d37160b4379c702f61c74c598fc6cd17204adbe3c554f7a8/propcache-0.5.4-cp312-cp312-win32.whl", hash = "sha256:b3083bfe87f95c756e610bd8025f26cbd1cd4aaa03a422f2d65efb7a97cd53d8", size = 43073, upload-time = "2026-09-16T00:14:48.338Z" }, + { url = "https://files.pythonhosted.org/packages/58/7b/6e8ef26f6d510a7916064fec68d55fcbfbdf7eb01e377480d66a122152d8/propcache-0.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:98914de2c4d7f0f9f4a8c6ea4bf05841f4175796941e3ef7d47eb718f22311fb", size = 46190, upload-time = "2026-09-16T00:14:49.99Z" }, + { url = "https://files.pythonhosted.org/packages/08/b9/72028c5b56ced97f456de6aefa79435ca64d7f77af78ea8cf3c76fc5195f/propcache-0.5.4-cp312-cp312-win_arm64.whl", hash = "sha256:8876b39961e33d912afe3c1bee18ee564fdad0206f873cc15d522756b7f50737", size = 43075, upload-time = "2026-09-16T00:14:51.155Z" }, + { url = "https://files.pythonhosted.org/packages/78/4c/3b1365d58a667689e067e13d055fcd92bdf8d9a2fca3d9201b47ed5b3631/propcache-0.5.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:36c0d9db44b523ef93d03341b1c42d69ff01d673c053d1b1c6c3a363bcaa39ba", size = 85290, upload-time = "2026-09-16T00:14:52.342Z" }, + { url = "https://files.pythonhosted.org/packages/8f/61/5f9c29c3aa67c30238c4eadf95149b1d983a48f69b86b0cff927a7d6df13/propcache-0.5.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1d52a05dc417279f7e5c7618c5dfbbc29923aaf9bc0a5c1802ddcebf54c61a0", size = 50027, upload-time = "2026-09-16T00:14:53.67Z" }, + { url = "https://files.pythonhosted.org/packages/25/7d/c1ab1ef09e9d4d835be5d58c0a32a1e1de8397abaa4e502a9d4141328cad/propcache-0.5.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:44149f46500a0a41b95b4d99c2e586a77319539730607b9892974a092788b111", size = 51425, upload-time = "2026-09-16T00:14:54.826Z" }, + { url = "https://files.pythonhosted.org/packages/73/36/0093091ebb270fcd1bc1f6e095f93b2e0ed7f1011c28837dc2dbe5f96b99/propcache-0.5.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbab5f5ff6897c81f355d079010cdae85b02e5a0b518b5251523b8ad8ae9ac3c", size = 233595, upload-time = "2026-09-16T00:14:56.09Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/0de9d4c8e05ce0be71b436919a216bd7fc5cc6e2691c0602295efb22b9ed/propcache-0.5.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c3e98c55bde2bcf7db3c70d1aed7ae9aa8aebbf19a250c66645cde44cdb8b867", size = 240318, upload-time = "2026-09-16T00:14:57.674Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/2b35e91455209b85ee98f7859583e0814fab57d3af0f2381aaee34c37304/propcache-0.5.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:db3ae52ccc150dbc84704e9d642743897f3e1c54742ff34cacb661e52e3818a9", size = 246649, upload-time = "2026-09-16T00:14:59.352Z" }, + { url = "https://files.pythonhosted.org/packages/ed/74/08e6c1faf26ee2732023a3828787ba535557122774f4a386b1f715cbd8e0/propcache-0.5.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f85915e00dcb1cd9f2f890ead064ed40a27df06f0db65be427b29482ae357572", size = 234316, upload-time = "2026-09-16T00:15:00.696Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9a/08385733c9321c9bb78039d3ff31045e4fca962d9665023c4eb70f998819/propcache-0.5.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c2ba30a89035b57b73e00475de948521602f543d79ce01db10b04b36c4c76fc8", size = 204666, upload-time = "2026-09-16T00:15:02.019Z" }, + { url = "https://files.pythonhosted.org/packages/1d/f4/e87bc7629af9a14a752b218764a78742d73c2c563ac58315da6841f0cbe4/propcache-0.5.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ae58f361bd5dae942717c65d3413b478c70aea9c462599e7b9adad3731db3894", size = 225900, upload-time = "2026-09-16T00:15:03.394Z" }, + { url = "https://files.pythonhosted.org/packages/d9/6d/11014938d3fe9bea2ea2dcf930f26ed565bfb2f5be3c756362ea48c92636/propcache-0.5.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:96f7c5c15656040ddcbc51e56dc59b58aa25999d743c126abd425b9766ab43e9", size = 219988, upload-time = "2026-09-16T00:15:04.811Z" }, + { url = "https://files.pythonhosted.org/packages/dc/72/fbf17c589f92c0b3bbf6709a425661f8ef2ed0d46b38985a7d7b5a0f6b91/propcache-0.5.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7cc528e760a8af06f2b13e9b9f362cd90c7c718ea61228a96dbd31ba16ed7f47", size = 233611, upload-time = "2026-09-16T00:15:06.498Z" }, + { url = "https://files.pythonhosted.org/packages/55/7e/dbd637572a279692e5518d117274a9331bf5faac59f191d30e82521a3ec7/propcache-0.5.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:425f8cc86ab5018b4b8d4a23bc8e74d964bd3d757c3702e301aa79be76c53f6c", size = 204333, upload-time = "2026-09-16T00:15:07.961Z" }, + { url = "https://files.pythonhosted.org/packages/ba/5a/f99c92068f1e0f5c886899ce0e4a619db376ca98c5279d93f95bd86906af/propcache-0.5.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a5793c7698a53f56f4a1889a4737c7eeb1b7ad0842fa6b1abca22913ff79c8c1", size = 235177, upload-time = "2026-09-16T00:15:09.334Z" }, + { url = "https://files.pythonhosted.org/packages/ee/28/95456fabd2daf6be89049a13fbf03341756014d2959c83d12957d4c49694/propcache-0.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c02c0e570c5c7e077b0181a9f3cdb7d4c3617d1cda6b5c95bd5d34022923d82c", size = 228982, upload-time = "2026-09-16T00:15:10.729Z" }, + { url = "https://files.pythonhosted.org/packages/b1/bb/df90f62c9cf7c93ea235f6f9405143bba802914607317266dd81fc8d737e/propcache-0.5.4-cp313-cp313-win32.whl", hash = "sha256:3e413d7a4a9b4866b7a761d6060d434b64d23cd35122eda3b026a0bbe8196b25", size = 42611, upload-time = "2026-09-16T00:15:12.111Z" }, + { url = "https://files.pythonhosted.org/packages/01/bc/e0a7b84af04ec02d73a48aa71f091e1e4a2107e3074b7ce12195b66901f4/propcache-0.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:0c889f6fa84957bc7e8b4eab71fd16a0455068d5045e3aa40c733071d2b2fd77", size = 45342, upload-time = "2026-09-16T00:15:13.519Z" }, + { url = "https://files.pythonhosted.org/packages/9a/70/50b031cafe72a5c1878b903ee87303f71313345566bf3d6ec202e5ddc9ec/propcache-0.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:69fc35c0779522da366c563e5faf203ffc1f8ff0021d5b1337fa4efa5be73177", size = 42408, upload-time = "2026-09-16T00:15:14.788Z" }, + { url = "https://files.pythonhosted.org/packages/33/c9/07e227b930c8ae513b8ef1aae3793499be097bffcdf7aee4fb8b33db4cd1/propcache-0.5.4-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e6720ba44ad7e72174314d0e1fb0172494cff5c73a3a8a2159c3d2402ff15565", size = 85933, upload-time = "2026-09-16T00:15:16.073Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e1/6710bb44510c4e4a8e0f004bbaf3cecfd048141309c77bae56d4e5a6ebc1/propcache-0.5.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4cfe0a92ae30151869e67a4b5f5e105e4e03ad30b3f38e5211b5bf77d0881993", size = 50179, upload-time = "2026-09-16T00:15:17.377Z" }, + { url = "https://files.pythonhosted.org/packages/e2/22/b533b493d7025456f44518b33e53e000021a20fe7c27b88cf3d341df7186/propcache-0.5.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d759d05634f1b038fb625a66662a8c85e5a8fec912da381b5149ddac107482b", size = 51942, upload-time = "2026-09-16T00:15:18.589Z" }, + { url = "https://files.pythonhosted.org/packages/f1/74/70ac8430e28f21e442c7bcb964eb46c4363f6881ade4aa0e978bfd8d503a/propcache-0.5.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:251c63dd46a0659bb875cb254dc4c1e79ee91a847c737cd62373295afc2235dc", size = 232647, upload-time = "2026-09-16T00:15:19.905Z" }, + { url = "https://files.pythonhosted.org/packages/72/95/f222f13b6fe623310be0eb61a673bf26df439ce27e563ca8e422d0818777/propcache-0.5.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a8d5ff04eb1f85698a78d20c62a14676e7b960dcafde09a388d60ad377d355d", size = 241541, upload-time = "2026-09-16T00:15:21.3Z" }, + { url = "https://files.pythonhosted.org/packages/a2/3e/763e370340db16115c5e63ad46e21ef0770a7f06928b3d3b62d8f8edfca4/propcache-0.5.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b9100a93b372418d8688f3f2a3e5b45c64d70ca4d6176e121aca1e3bfc1e32f", size = 245332, upload-time = "2026-09-16T00:15:22.802Z" }, + { url = "https://files.pythonhosted.org/packages/96/d3/e97cd6f5de2176bd90ed4076c7a9b5e09d0f0b9687d00a576507988bb62c/propcache-0.5.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc07876cfb079b6f6f36d21ce75784ad6c2c6b563eeac0ed26c2fa2669b85df9", size = 232757, upload-time = "2026-09-16T00:15:24.374Z" }, + { url = "https://files.pythonhosted.org/packages/f9/4c/6766e5f60bcda26d244333aa71d0a702c1c9b21b251d543c7af5953d1eee/propcache-0.5.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0951315a6b3142ee2167404d707743f0157c110091342b1aa0accac5cf0e4acf", size = 204389, upload-time = "2026-09-16T00:15:25.667Z" }, + { url = "https://files.pythonhosted.org/packages/b8/5e/ec4bb09a70b26ea99d76a8292c3383b960b296de2b347ac9986678f1761c/propcache-0.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bee7d3aed13d56f54e681df38c3a23031bc9e3863f687d9d598825c9146acd7d", size = 228217, upload-time = "2026-09-16T00:15:27.11Z" }, + { url = "https://files.pythonhosted.org/packages/e1/7d/b53922ba7d9e5bf797324e63aa05906ec240871899f779628df068743e2d/propcache-0.5.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4e985382be6d15da8d0c2710a6fa7b9070fc9ecdeefb7f580e88373984ec8be3", size = 216947, upload-time = "2026-09-16T00:15:28.532Z" }, + { url = "https://files.pythonhosted.org/packages/ff/39/b62eee45e5ea4de094a258cbb3b01c1e856ca51ddfd95b43135c5effd1eb/propcache-0.5.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9e9ab13760aa8b6d0881ae7cb04fd891d8d490cd2554ea8e79bb278399169bcc", size = 233457, upload-time = "2026-09-16T00:15:29.977Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a9/feec61ed296d993db9dd097e0f6723e3f576a647722367547495e4c5b05c/propcache-0.5.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1b2f3bec4261a94019575481c726c29850f72e27907773c75b1de421e20e9f9d", size = 204131, upload-time = "2026-09-16T00:15:31.74Z" }, + { url = "https://files.pythonhosted.org/packages/92/4d/411ef380cddad28dc001f1c6d75ec72c76cd3817030f68ec1ccfba0ec6c1/propcache-0.5.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:720cf832eb2d0b0dfee129cb3335a26f6ce3cc45ee1187e8f0731758caa16792", size = 234820, upload-time = "2026-09-16T00:15:33.087Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/c988229753629ef1cfd5198337a83e624780ea2b3787efe9e747c05aad2d/propcache-0.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9fb0a5be8d9aa213150e8d8148a42aca4984b285bcad1e69587dc4298edd929b", size = 228350, upload-time = "2026-09-16T00:15:34.533Z" }, + { url = "https://files.pythonhosted.org/packages/12/49/5ef1c5cf98591da3c5b952b39e6a298084cc1ce353bc70f85e82397a5036/propcache-0.5.4-cp314-cp314-win32.whl", hash = "sha256:30cc1cebaf9aef49db06357a50398323ae04d70460c0491837d026ab7d6452ea", size = 43578, upload-time = "2026-09-16T00:15:35.957Z" }, + { url = "https://files.pythonhosted.org/packages/1e/9e/a0ac821a2229186af5e2e3c3635a78abb23cfddca57f38513ab5d70420f3/propcache-0.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:0a095db8e15a6020db149ecbed6461939fe74f6acaa3ae8b702a1fe8c38cd983", size = 46304, upload-time = "2026-09-16T00:15:37.655Z" }, + { url = "https://files.pythonhosted.org/packages/a1/19/c8d0d36a9d16cba5dcee67d389c9333b988c8986a653a61c00a451817a46/propcache-0.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:45488d1a5f9ab5bd90aaa1ca20f50fe1922b8ffad71a2009d2adf41355897aac", size = 43440, upload-time = "2026-09-16T00:15:39.091Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e9/42f1da77cacfc184e6ec929557ef653b7961bbf6f1da460b9221273948b3/propcache-0.5.4-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:53eaa697c4d0422ff4cb714d00231b43352064d97b944033b30c1d57cc506ec0", size = 90672, upload-time = "2026-09-16T00:15:40.306Z" }, + { url = "https://files.pythonhosted.org/packages/cf/2f/4b79940908c6ab8c795097c102999d7bc1f7e0b8604dfd1c232f9d99d67a/propcache-0.5.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:886b59c4d28ca97dd23b025fdfc50a0356be934efbbbca89ad26230067f86fe5", size = 52586, upload-time = "2026-09-16T00:15:41.575Z" }, + { url = "https://files.pythonhosted.org/packages/eb/07/02196ae6320c110235bb343f90dbd34be41f8b8964a3ee30db84ec12579e/propcache-0.5.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3fa15757fea1dfcd5b7745cad9f4638929605531bd4018ab2adff7955f1a403d", size = 54335, upload-time = "2026-09-16T00:15:43.027Z" }, + { url = "https://files.pythonhosted.org/packages/6f/44/f48b9a131985659924df5fa5093f68fe72c7ee375329802989ba3126efc6/propcache-0.5.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6f0093ac3e9daada202c2082439d414a625c57184727a46e112a3fb2a81cb788", size = 297567, upload-time = "2026-09-16T00:15:44.373Z" }, + { url = "https://files.pythonhosted.org/packages/04/a1/418d956d2735139f77fc35262179f1f52c23aa666de5a8ab3819c1ae7854/propcache-0.5.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3cd3a7edb6b95b9b33998135ebfa18d709da82290fb8f27c858970b5a12c8b56", size = 297477, upload-time = "2026-09-16T00:15:46.048Z" }, + { url = "https://files.pythonhosted.org/packages/69/fd/ff811fdb6d3d3e67fd9bbfb75881675d34a42d0ef29a45d33e3e233dde07/propcache-0.5.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c174bfd1c48a1b51a3078e95586dde718374bac79719ab3541ec9e74aec40574", size = 302669, upload-time = "2026-09-16T00:15:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/fc/57/527910c455b5ec62f6871bef45d4f79fea16cb8c966ba0d4a07f0339ddc4/propcache-0.5.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a219f0ac59817a9114dd2aa57c13180f993e819ba658c7ddab4b66ed1ee0d370", size = 287908, upload-time = "2026-09-16T00:15:48.99Z" }, + { url = "https://files.pythonhosted.org/packages/1d/86/f69ab82707534a0cb2057bdca04f9200a71214c7551800f9d34d6ac39e4f/propcache-0.5.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:17a7400cec0256f0a71ae71f9da398f9894c956ff6668a1c9d317b3367316320", size = 249804, upload-time = "2026-09-16T00:15:50.486Z" }, + { url = "https://files.pythonhosted.org/packages/27/19/60677af50d93be4256213de7cd487f056944c048b9c0b6f2e45b3a30f666/propcache-0.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:978f28401afbc76cdc3df9e1717b4229a06b626a1dcc75db4e1f2beb3884c3e9", size = 282344, upload-time = "2026-09-16T00:15:52.029Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f7/a0057808a91fb3b6a5f3602b528f0cdcb3d53e0ff8315d73fabdfdf8fec4/propcache-0.5.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4a1f4f5ffa55dce6307631f3cb2948e117e665966ea512e0d502b16c24f567e7", size = 270167, upload-time = "2026-09-16T00:15:53.466Z" }, + { url = "https://files.pythonhosted.org/packages/83/c8/f4a865490df0dc0c8531d4e59ac411cb6dc24bb255d2396a6f1c60a368f4/propcache-0.5.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:213bb68d9ced5cf2bf717b1071bf2b09b4b04c426256f9fe6d054c60318424c4", size = 286551, upload-time = "2026-09-16T00:15:54.995Z" }, + { url = "https://files.pythonhosted.org/packages/b0/67/b4faebde9da4e8173d0e5a30e8cd31335914af7ef350b988f27fec588cfd/propcache-0.5.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:286867fb156488c251a3721766e380ac4495e4fd6b51aaa1403d89ce7f4359d9", size = 249595, upload-time = "2026-09-16T00:15:56.505Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/52e1dd5636e9f5a27f6b5a4b4e2f33c322fd72afe956c397d82523ec4a80/propcache-0.5.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:445ee3bfb46e85838387fb3c536a73cc0b994dc192b004e40e170adc54aa2a7e", size = 286700, upload-time = "2026-09-16T00:15:57.985Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0e/30b2b324b93ff31a0bab539c102aae59e84e444031b2742150a7646aa1bb/propcache-0.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:48cb48c5346a97de792254af77715aa2529c2a1ebc5f586aa0aae44a02f1fe57", size = 280500, upload-time = "2026-09-16T00:15:59.487Z" }, + { url = "https://files.pythonhosted.org/packages/64/36/721bb59f682ff060d0c8df64274fca8cd0521b1a54506c2eedaef795b7f5/propcache-0.5.4-cp314-cp314t-win32.whl", hash = "sha256:03b229037d25b801e7af53fd52b9fc49d9439b036fca1e087e02780631adfa97", size = 46121, upload-time = "2026-09-16T00:16:01.349Z" }, + { url = "https://files.pythonhosted.org/packages/c1/86/0b1b80fa1ac3a0aac44e2922a6964fbe9cd52af5eab8fa933bf9e90b030c/propcache-0.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:8a1fc236528c457cd739c88abe823da851b7ab645d72792f88658114cc340c12", size = 49154, upload-time = "2026-09-16T00:16:02.901Z" }, + { url = "https://files.pythonhosted.org/packages/69/4f/9fe6f05a47cb550c823155052116f710064b6be5c6e8ec4e9faae7e18115/propcache-0.5.4-cp314-cp314t-win_arm64.whl", hash = "sha256:135036c5cfc93864affb0f9af9a27e5d7a71cb7bd745e7b6dbfc2d56cc30e827", size = 46005, upload-time = "2026-09-16T00:16:04.266Z" }, + { url = "https://files.pythonhosted.org/packages/58/25/895a11d1e4c5c2acc6d816e2bece34e02d9dc92f2182ae276cd819e9e804/propcache-0.5.4-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:45bf2e730ab8905d0527fe05a86500f406e64305c34cc81ebe64b4617cab9760", size = 85634, upload-time = "2026-09-16T00:16:05.599Z" }, + { url = "https://files.pythonhosted.org/packages/58/41/c0acd69271de7a1cf439e77d5d60c18575fd09bad56e798b95fa23458ea4/propcache-0.5.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:31eb43ba2edc704ab2ec27815315dd8a19def0fb16215be4cfe8d32fe78ffd51", size = 50084, upload-time = "2026-09-16T00:16:07.384Z" }, + { url = "https://files.pythonhosted.org/packages/a5/1a/ad561f99f90884089e6403b76c220610809429ba868a81a2e7ce115d32e0/propcache-0.5.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:174507f82d3594622acb1dd2dafecf2d899d6d506335494e7107767bf05f3aae", size = 51692, upload-time = "2026-09-16T00:16:08.956Z" }, + { url = "https://files.pythonhosted.org/packages/e9/07/057bdd3a9609ffad59b06239cceee784b047f6c720247bfaa36d2103e138/propcache-0.5.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50e337653721d20ead710da33bf44487fbe8a0db8782714b60306481e9f95b51", size = 232947, upload-time = "2026-09-16T00:16:10.466Z" }, + { url = "https://files.pythonhosted.org/packages/fa/dd/d36ad35986718530498a65e45e3713f9f0e6a580f192ef02d2ef7cae9b52/propcache-0.5.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d21d0d2c82bbfeb1677a9711f38df968f9837576102bb4add1bd449d28d88f1", size = 241250, upload-time = "2026-09-16T00:16:12.056Z" }, + { url = "https://files.pythonhosted.org/packages/fb/81/f1459415cdb6c10d46942779de39bb59a77b38e5a76bb1def9227962eb45/propcache-0.5.4-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccf4f7a79e26bb7efb06ecd50c177833b71df05cbc748701372325e6bcc17f6f", size = 245150, upload-time = "2026-09-16T00:16:13.596Z" }, + { url = "https://files.pythonhosted.org/packages/ce/4e/58b9b1460afc97a4c0b17ee89af701c4011d4d7f46470eba3aaff76a8069/propcache-0.5.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23278f808cd81d5ada7184a76606b925fb3389c60e1077b2cd7da7b1fcf0553c", size = 232166, upload-time = "2026-09-16T00:16:15.126Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c6/5a79e0eda3e7b6987d03d8c622ff6d52a42165a12e8418eb37694b9cc4b4/propcache-0.5.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e738ab81179510ce79b2eac9a6ecf47feffd9e76d1c72e403005dddb6e36c06c", size = 206085, upload-time = "2026-09-16T00:16:16.713Z" }, + { url = "https://files.pythonhosted.org/packages/4e/72/940aed42c73f9da345ca2de0f6e835c726498159abca5f1ef14fb0a2af8a/propcache-0.5.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:a419ee85e654927baabda3929c03c0cc1112bf472ff0dfd6142f4e3a81ca4162", size = 228460, upload-time = "2026-09-16T00:16:18.352Z" }, + { url = "https://files.pythonhosted.org/packages/85/71/3f54e1535c8f323d91ba566044d7c2b39ff6f6a2f1d0bd9071779d07b9b3/propcache-0.5.4-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:b61805357d966680acf68b3b6d49772631ed9df44ebece10ff1460e117a7da8a", size = 218350, upload-time = "2026-09-16T00:16:20.064Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f4/025890cc389ac3ec485ecec607d4a7ca47e15bfa2a465746ab98af602536/propcache-0.5.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:58134228927cee6c047d626c08e60a81be604a20578a12ce752cc5c9a84d4826", size = 233156, upload-time = "2026-09-16T00:16:21.624Z" }, + { url = "https://files.pythonhosted.org/packages/04/29/b39cae08c87c140d3d274f0a2c058cb5588e836175c3309e260b230ab07d/propcache-0.5.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:350b272b2279f4135a64fc0c304a5d08e28a137c9573442c606152446638a831", size = 206206, upload-time = "2026-09-16T00:16:23.204Z" }, + { url = "https://files.pythonhosted.org/packages/18/61/e16462ef18a87247dc9ebbd5c606f46d5ce67e708bd9cc734dd0d9222564/propcache-0.5.4-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:45bebbe252550fec975ba3b62bc6f931643cfd3b5464ef47619cf3fef154e01c", size = 234469, upload-time = "2026-09-16T00:16:24.841Z" }, + { url = "https://files.pythonhosted.org/packages/9f/84/b6a1490922427204fc47df920ed002eec709621de6b79b11592bf45c623a/propcache-0.5.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:ada748108a43d29b7c328ba7db3755327cd94f028bcc1a7ee3f0addcfacd9c38", size = 227311, upload-time = "2026-09-16T00:16:26.549Z" }, + { url = "https://files.pythonhosted.org/packages/ff/5c/5a59527582e9bcb694b2f08b9894134b65a0f5f79dbff174f054f5f74ed0/propcache-0.5.4-cp315-cp315-win32.whl", hash = "sha256:ee19113bce2f3acd46432050688b70f61acd6857d75abb9ec96341b7e9ced123", size = 43512, upload-time = "2026-09-16T00:16:28.313Z" }, + { url = "https://files.pythonhosted.org/packages/26/07/93cf699ed363681e754d7c3fad587fb09ef6b65618ee193332ad16a68d7b/propcache-0.5.4-cp315-cp315-win_amd64.whl", hash = "sha256:ceb3e879afac028f93d272c957814695dc5569e4904262dbee92f6c41bd5e4a3", size = 46264, upload-time = "2026-09-16T00:16:29.751Z" }, + { url = "https://files.pythonhosted.org/packages/65/10/fef04fbdcd44a4a163cb5ff5674599c6d6fdefd64a5a459438f9ad2ba042/propcache-0.5.4-cp315-cp315-win_arm64.whl", hash = "sha256:c83acbce9f2b5e3f5f5eda9e53d2001fed22fcdfef81274a9e02d8fd53b70a30", size = 43395, upload-time = "2026-09-16T00:16:31.5Z" }, + { url = "https://files.pythonhosted.org/packages/70/f6/7e2f4dab0b92ab46111bd48cee9ee1e5f519514c44e3779ede5358d7ada0/propcache-0.5.4-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:a5e8ef588c109725dc713ba69aadcac00a1ef90c2ce9c0a8c7075128f569f47f", size = 89825, upload-time = "2026-09-16T00:16:43.115Z" }, + { url = "https://files.pythonhosted.org/packages/9f/8b/dfeff925cb6ced97ede701d5c6a99998da963c6f2e06abbf879c9dac5b54/propcache-0.5.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:4d86476a935c88963d9b8e1a9a0d38188790e9622169bfbafa173046846709d3", size = 52159, upload-time = "2026-09-16T00:16:44.754Z" }, + { url = "https://files.pythonhosted.org/packages/24/6c/924c810be5b7cf218ef47e707cf06d34adb4e3f3a31e3c24c55c6d945a88/propcache-0.5.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:f5470694918830da62fac9e69133b53d23b736d7070e587b27a4a2be37e08e68", size = 53956, upload-time = "2026-09-16T00:16:46.762Z" }, + { url = "https://files.pythonhosted.org/packages/3f/b6/9ed0a5c939b58b6bed740a05b5d0f919f0b318d03284b4b6d81a0fe8a29a/propcache-0.5.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10ef33a68a61ce317e095fd2e202a592ea92392b90944a78c993f0d9a73ab06c", size = 295235, upload-time = "2026-09-16T00:16:48.577Z" }, + { url = "https://files.pythonhosted.org/packages/5a/eb/5ce886e902a2e781dddf110993d5329458a9b1a8626b876c65e5e25bf413/propcache-0.5.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5cacf3c9efd09df409dc33654dd077e1c245ba8fb747b0f0236ef41b7c49b589", size = 294463, upload-time = "2026-09-16T00:16:50.539Z" }, + { url = "https://files.pythonhosted.org/packages/f2/88/c98f49183ecd3e5b204a556f0ca47baa02c2206a500fe8c7ec1726297b0a/propcache-0.5.4-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:770e8209d018175fc0063936fa9583b6d27e88c5ad31543f3383d66080efdd62", size = 300081, upload-time = "2026-09-16T00:16:52.423Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/c5090f9e6f67cbc30a2b744c7bb0f8006dcba5ec1b0d82f866ae1cc7c5c4/propcache-0.5.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03969626faf0783a592dfa17e28eac06018bd0b44dafae6943d53b92421a7f72", size = 285360, upload-time = "2026-09-16T00:16:54.141Z" }, + { url = "https://files.pythonhosted.org/packages/ac/9c/34a55396910583ed07926669ab309dde2213a2dec05a7e946bb90ad66908/propcache-0.5.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ef3b928d9c984322b5c44e6964d8dbc653da87d2d8ee1647fa6da43072e650a9", size = 248014, upload-time = "2026-09-16T00:16:56.062Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b5/c0a142b656093ca397039dd3fe166cbb87c945712b534546514a24cd2611/propcache-0.5.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7177c43eddf10a0893c4fec52ebb408fdcd7f7d63962caace9180d8f81b14ece", size = 280662, upload-time = "2026-09-16T00:16:58.044Z" }, + { url = "https://files.pythonhosted.org/packages/54/28/fab2809c2e337fe26becea9648e84d5cef46075c91b826acb13e4f9dd04e/propcache-0.5.4-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:420162a77f94eb1cf5ef7893f500016dabd548e73de956785a1dd899cc73006a", size = 266149, upload-time = "2026-09-16T00:16:59.702Z" }, + { url = "https://files.pythonhosted.org/packages/3a/11/7ddf336288b2678a5f054f8da2e2bd1a719f5d4b7de714d9c6bd588a2313/propcache-0.5.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:3eb2e820e8e2101407da93f17c57cbb7d225461955fc60105daaba14cd421ee2", size = 283097, upload-time = "2026-09-16T00:17:01.459Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ae/351b1a5225f5473c411d9a612a229ae147cf0cf65c72ad838b87219ea8e8/propcache-0.5.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:13e52b6e0bde97dee98ab66552dbff2931649c96f1ac432eac299fe689ec373b", size = 248160, upload-time = "2026-09-16T00:17:03.298Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d0/7f79f061e30d135bb615c9782c94a74652033d00b49254edbbf35a9165a8/propcache-0.5.4-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:12682126712ddc19b70ff819debbd279e58adf1f0c8f8f8138c18ade2044b284", size = 283036, upload-time = "2026-09-16T00:17:05.238Z" }, + { url = "https://files.pythonhosted.org/packages/53/3c/016f1cad8bf4c428d748cf399b2bac603026fbfd6966e6a5579b5c5b6956/propcache-0.5.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:3af0c8642b2da4815d86e631232ac8286e17644fad907c19508aa8e7cb4ba8ad", size = 279350, upload-time = "2026-09-16T00:17:06.881Z" }, + { url = "https://files.pythonhosted.org/packages/ea/60/d8f72cb24b412487ed4c397f539117d3b74c3c33dd32020e91fe00a958a8/propcache-0.5.4-cp315-cp315t-win32.whl", hash = "sha256:1df8d8561b21465c5dd56110a01caf897e026d065b4b84e98a488209094272ec", size = 45874, upload-time = "2026-09-16T00:17:08.567Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ef/8bae0a316d406644450522f2f3d44a4e19632f5f3bb60d1d0e6c53842616/propcache-0.5.4-cp315-cp315t-win_amd64.whl", hash = "sha256:02c0a34f16889cf800f10f0247a564d8ce6eeab6ffcd7c87198f769067eb8432", size = 48574, upload-time = "2026-09-16T00:17:10.077Z" }, + { url = "https://files.pythonhosted.org/packages/57/be/bcc053f66a97355683884b448198e79580fae8e8fa4d96b9bb01614e9913/propcache-0.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:dc4242ca653c9b30ab51c5f8193323e7bc0928f897ee9103201e59a43abcb72e", size = 45625, upload-time = "2026-09-16T00:17:11.377Z" }, + { url = "https://files.pythonhosted.org/packages/f5/cd/785c64ed382f3f04201870267b02783f63b4678c2acfddc177a3ebcc2727/propcache-0.5.4-py3-none-any.whl", hash = "sha256:62c60aec739ed00124573cce1178138fd690c7676352d67a37328c1cf51d7468", size = 16338, upload-time = "2026-09-16T00:17:13.106Z" }, +] + [[package]] name = "psutil" version = "7.2.2" @@ -3944,9 +4828,12 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.11.*' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", @@ -4157,9 +5044,12 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.11.*' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", @@ -4373,9 +5263,12 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, @@ -4456,7 +5349,7 @@ wheels = [ [[package]] name = "scope-profiler" version = "0.7.5" -source = { git = "https://github.com/max-models/scope-profiler?branch=devel#2abb4ca4fe74166be554d30c3b78af8041f930ae" } +source = { git = "https://github.com/max-models/scope-profiler?branch=devel#fd1a2409b60c864557d9f92edfa3e1abdeec61bf" } dependencies = [ { name = "h5py" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -4553,9 +5446,12 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.11.*' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", @@ -4660,11 +5556,13 @@ dev = [ { name = "mpi4py" }, { name = "nbclient" }, { name = "nbformat" }, + { name = "plasma-plots", extra = ["scene"] }, { name = "pre-commit" }, { name = "pyproject-fmt" }, { name = "pyright" }, { name = "pytest" }, { name = "pytest-cov" }, + { name = "pyvista" }, { name = "ruff" }, { name = "scikit-image", version = "0.25.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "scikit-image", version = "0.26.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, @@ -4681,6 +5579,8 @@ docs = [ { name = "matplotlib", version = "3.11.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "nbclient" }, { name = "nbformat" }, + { name = "plasma-plots", extra = ["scene"] }, + { name = "pyvista" }, { name = "scikit-image", version = "0.25.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "scikit-image", version = "0.26.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "shapely", version = "2.1.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -4698,6 +5598,7 @@ gpu = [ interactive = [ { name = "matplotlib", version = "3.10.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "matplotlib", version = "3.11.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "plasma-plots" }, { name = "pyvista" }, { name = "scikit-image", version = "0.25.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "scikit-image", version = "0.26.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, @@ -4750,6 +5651,8 @@ requires-dist = [ { name = "nbclient", marker = "extra == 'docs'", specifier = ">=0.11" }, { name = "nbformat", marker = "extra == 'docs'", specifier = ">=5.11.1" }, { name = "numpy" }, + { name = "plasma-plots", marker = "extra == 'interactive'", git = "https://github.com/max-models/plasma-plots?branch=devel" }, + { name = "plasma-plots", extras = ["scene"], marker = "extra == 'docs'", git = "https://github.com/max-models/plasma-plots?branch=devel" }, { name = "pre-commit", marker = "extra == 'dev'" }, { name = "pyccel", marker = "extra == 'pyccel'", specifier = ">2.0" }, { name = "pyproject-fmt", marker = "extra == 'dev'" }, @@ -4762,7 +5665,7 @@ requires-dist = [ { name = "scope-profiler", git = "https://github.com/max-models/scope-profiler?branch=devel" }, { name = "shapely", marker = "extra == 'clip'", specifier = ">=2" }, { name = "simgeometry", extras = ["clip"], marker = "extra == 'test'" }, - { name = "simgeometry", extras = ["clip", "plot"], marker = "extra == 'docs'" }, + { name = "simgeometry", extras = ["clip", "interactive"], marker = "extra == 'docs'" }, { name = "simgeometry", extras = ["docs", "test"], marker = "extra == 'dev'" }, { name = "simgeometry", extras = ["evaluate", "mesh"], marker = "extra == 'plot'" }, { name = "simgeometry", extras = ["plot"], marker = "extra == 'interactive'" }, @@ -4898,9 +5801,12 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, @@ -5002,6 +5908,151 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ad/66/0d785f0bc5e4315a96c989bb476d0fc07ea4f85132550c7b156ca2035d52/traitlets-5.16.1-py3-none-any.whl", hash = "sha256:f775618166caa0396c8e337099240f2bd3e5e917d203b2e6fbe21a58d3cb1f6b", size = 86211, upload-time = "2026-08-03T08:32:34.48Z" }, ] +[[package]] +name = "trame" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, + { name = "trame-client" }, + { name = "trame-common" }, + { name = "trame-server" }, + { name = "wslink" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/1e/d76411adb59829773619a788cce2f4a09645c44bed09f180079369096033/trame-4.0.0.tar.gz", hash = "sha256:148cdd10b4636539e375a898c16df139d7a03f3cf8a186cbd5a23c42d763a6b3", size = 19091, upload-time = "2026-09-09T16:53:40.646Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/a8/137edd4bd17081244dbafd3ebcb35612d791b605eaf9a0c57e47200abd24/trame-4.0.0-py3-none-any.whl", hash = "sha256:e3c8a89f7a209fea7a595bd877301c851cd0db18cc7620f11b08d4d7966e5dcc", size = 27303, upload-time = "2026-09-09T16:53:39.12Z" }, +] + +[[package]] +name = "trame-client" +version = "4.1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "trame-common" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/01/dd/3ccc6562fc59b3a98f76c2705d8a34679d40b8009220f5408c36770d2876/trame_client-4.1.4.tar.gz", hash = "sha256:d615a6a624a8a4d8911869b25d4fa9c5a39c90a4457a091ae86a9bb33ea8c018", size = 553158, upload-time = "2026-10-06T21:42:14.808Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/7f/dc3b12c0cde2523b72cea905e0fc421e50a4f5d0decea73175969c9f60e0/trame_client-4.1.4-py3-none-any.whl", hash = "sha256:6e1d3e20770f5852ac924d8494515700d9906085ecfcb788f0f504ce998be88f", size = 568271, upload-time = "2026-10-06T21:42:13.215Z" }, +] + +[[package]] +name = "trame-common" +version = "1.2.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3d/de/6c3827205df3c83175ecb458712713002176822fae353aac5723f97cbcec/trame_common-1.2.9.tar.gz", hash = "sha256:65c61b50e0c09ce5fe3a1550fc60fd4818ad4b6db07ccf937cd3850e370eeaf0", size = 20458, upload-time = "2026-10-08T14:42:24.452Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/ad/c1cc127e7cf4fd00d2bb7ebd6bec0252d681628626e034a8a8e8ffd13c07/trame_common-1.2.9-py3-none-any.whl", hash = "sha256:14e9747bf247cc446e59ce3f920e31a409149ca60ae2bfc5d02a22cdf1b676c4", size = 24041, upload-time = "2026-10-08T14:42:23.126Z" }, +] + +[[package]] +name = "trame-dataclass" +version = "2.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "loguru" }, + { name = "trame-client" }, + { name = "typing-extensions", marker = "python_full_version == '3.13.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/6b/ba44b13f09663df7e76865e4d829d435a80062fb926d98c059b5ef5f1b19/trame_dataclass-2.2.2.tar.gz", hash = "sha256:71a4887fa0f46147a661079f8d2b4e5cd80d68c752fe8afe8ba5332381a4fb99", size = 27160, upload-time = "2026-10-16T17:45:31.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/90/83eab5f5009ce36b35c2b963901725f7ad0b1368924dda08e915cc3a5587/trame_dataclass-2.2.2-py3-none-any.whl", hash = "sha256:528e45564d2f5c9f52bd299124371986ca92593365476abd42fc55b557d3183d", size = 35779, upload-time = "2026-10-16T17:45:29.51Z" }, +] + +[[package]] +name = "trame-pyvista" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyvista" }, + { name = "trame" }, + { name = "trame-client" }, + { name = "trame-common" }, + { name = "trame-dataclass" }, + { name = "trame-rca" }, + { name = "trame-server" }, + { name = "trame-vtk" }, + { name = "trame-vtklocal" }, + { name = "trame-vuetify" }, + { name = "wslink" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/b3/d162b97c6b2bf6f19241336e0220ae5a85fe6d88abbd50309fd4aede3b61/trame_pyvista-0.2.1.tar.gz", hash = "sha256:c580522eec2eba3f7e80c032f81f0783970f4f3a4ab17ca610332a60ab651de7", size = 116120, upload-time = "2026-10-13T22:23:05.811Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/b7/7cf1312e878e90f3fa82daec3cee48ce076335143066ec353d7bff1025b4/trame_pyvista-0.2.1-py3-none-any.whl", hash = "sha256:33f507131171d377b20adf082219642c7f489b858e5013fb16f2b7aa8caaae17", size = 37962, upload-time = "2026-10-13T22:23:04.199Z" }, +] + +[[package]] +name = "trame-rca" +version = "2.11.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "trame-client" }, + { name = "trame-common" }, + { name = "wslink" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/17/31e22dbda93451c9fe84fe1d1e15079679566115ca2d79fcc0a42d9bcf67/trame_rca-2.11.8.tar.gz", hash = "sha256:4018b5afcad7e85efb401cc819e53c23b00045343e34de7a369b589cb02949cb", size = 69535, upload-time = "2026-10-09T06:25:00.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/ae/1c9aa48198c9bb824766a6bc3b0527db9831d67c1faf1e3f1927b18afa92/trame_rca-2.11.8-py3-none-any.whl", hash = "sha256:59fd897ff8ac42147dcf067f08fd42cdc1b7ecdd357681c5295806209fc2815a", size = 79199, upload-time = "2026-10-09T06:24:59.296Z" }, +] + +[[package]] +name = "trame-server" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools", version = "11.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "more-itertools", version = "11.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "trame-common" }, + { name = "wslink" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/f0/119dd2c85c1788d5b0b6ba6cb9c1e1b15a3d747b94e77cddce3eabb59308/trame_server-4.1.0.tar.gz", hash = "sha256:cad870dd0c63e7df143cf315b00a4c0c6da11965f26abb31c2a86b1bb0de3e2b", size = 40284, upload-time = "2026-10-09T15:31:07.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/30/d6ee48a182e09ca6d66f2e48e91ac7c3efbc5f323d6a50c591a2b4f27840/trame_server-4.1.0-py3-none-any.whl", hash = "sha256:7b23147a77d9616d9c787bd60fe77c3c683a38e193d7f9a4d0aac3e601dd453b", size = 46872, upload-time = "2026-10-09T15:31:06.642Z" }, +] + +[[package]] +name = "trame-vtk" +version = "2.11.17" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "trame-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/53/17db0dbc4483533db6b6de079b24d2c7071faf0aa18a22f23874bb5b5f5b/trame_vtk-2.11.17.tar.gz", hash = "sha256:367ef96505f27d6a01c03cdc4a8fe3f8894998d2b6b623512ef07c1fe716caba", size = 829777, upload-time = "2026-09-22T14:40:15.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/f1/d9b1119bad9c3018b2cc801032447e0f5cf942859845a1eaf03aff9c0905/trame_vtk-2.11.17-py3-none-any.whl", hash = "sha256:8fe2adbe3b6888ad41cb4311421fbd850cece75a801bf8b5f7af5bdc6473bee4", size = 851988, upload-time = "2026-09-22T14:40:13.893Z" }, +] + +[[package]] +name = "trame-vtklocal" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "trame" }, + { name = "trame-common" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/83/aa761d1a5d9ea91336e982e90da8dc572f63e952953f986eddd9a1f74558/trame_vtklocal-1.8.0.tar.gz", hash = "sha256:ec4437d9c9b2af7fb20ffc6e8d0aed45cdb84195afeaf7ad6a30b2da90828dbc", size = 90949, upload-time = "2026-10-21T13:11:16.946Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/51/41b023de8ad30bd1411aef6349bb1471437cd302735a1a6d5c2d2ebf813a/trame_vtklocal-1.8.0-py3-none-any.whl", hash = "sha256:810f0d4eba4f2fd5b9b35ff00b69cc4d9480ef5bd7ae566e0bd5c123eccb4eb1", size = 97337, upload-time = "2026-10-21T13:11:15.509Z" }, +] + +[[package]] +name = "trame-vuetify" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "trame-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/35/5a/fd1403999499a7f8376abb63393b4e00fb2db42d19b180ff6e045ae920bf/trame_vuetify-3.3.0.tar.gz", hash = "sha256:cdef8316d7e7100a6a35aa6d76dd03263956041b534578ba9218f316ffc7d9f9", size = 6734562, upload-time = "2026-09-29T17:38:01.261Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/77/a2479f57944ea1df8dd52cabdf00b1988dfc4c5b609f27573530fbc1cfc1/trame_vuetify-3.3.0-py3-none-any.whl", hash = "sha256:3a68a8457a439c1f2d456bfa1004091116a4c9988503f594fd44e980222773f2", size = 6794266, upload-time = "2026-09-29T17:37:58.449Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0" @@ -5156,6 +6207,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d5/d2/cc4dc1271e464942db7ee278baae2daa99ee77cb2af744025c04da585a3e/websocket_client-1.9.2-py3-none-any.whl", hash = "sha256:e1a673830a9c7bfa47b1cd3d5e4178f4c9651d80a4eab02c9c23a1c3ec6250ce", size = 95786, upload-time = "2026-08-31T14:08:39.899Z" }, ] +[[package]] +name = "widgetsnbextension" +version = "4.0.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/60/bc7a980fc78837d6ef8f5940cca4cadc433364503a4c4d42e2a7a0de3231/widgetsnbextension-4.0.16.tar.gz", hash = "sha256:adeea0ae78f0856ee4945f413299801b82a0a01416303301f39a704282a37b73", size = 1111094, upload-time = "2026-08-18T08:52:55.859Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/95/40e17e20046b7bc820d29d09ae84ec157ec8dd6e6f6cd722626292c31b2e/widgetsnbextension-4.0.16-py3-none-any.whl", hash = "sha256:a31a8774885b96fe825462f5d6496166f0c7cae111195b6465c801d230eb5a4e", size = 2225148, upload-time = "2026-08-18T08:52:53.736Z" }, +] + +[[package]] +name = "win32-setctime" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, +] + +[[package]] +name = "wslink" +version = "2.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "msgpack" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/46/cb26a815db512b67dc6781ce389c26d923e560df938f7f1d4d8e5dec0f54/wslink-2.6.0.tar.gz", hash = "sha256:11e6f4a956a90487e31354fe0436edc850e052d9773ea1d14fc708660cb385da", size = 30260, upload-time = "2026-10-08T17:38:49.111Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/d1/c0bbb1ee6c46906cac7495a5c4eecbf41ae74b7973c287e19a6ed68abf9a/wslink-2.6.0-py3-none-any.whl", hash = "sha256:6edc7ea67c744094702f98857c9316f4805a44af91f6cc4aca69b782392e73d0", size = 37689, upload-time = "2026-10-08T17:38:47.863Z" }, +] + [[package]] name = "xarray" version = "2025.6.1" @@ -5181,9 +6263,12 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.11.*' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", @@ -5198,3 +6283,153 @@ sdist = { url = "https://files.pythonhosted.org/packages/ff/f5/781c70b234f54e040 wheels = [ { url = "https://files.pythonhosted.org/packages/3f/44/3159af4fb3c868970a412e294320ed6bb1a9fbdfa6431050eda8e0b34b6e/xarray-2026.9.0-py3-none-any.whl", hash = "sha256:fe349fa871628b1a0a5217af3fe1283a2862d5485156e6eda354fffb81c3bb7c", size = 1445445, upload-time = "2026-09-29T23:06:23.686Z" }, ] + +[[package]] +name = "yarl" +version = "1.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/75/16/e8be8e2fb175bbf41a0680381a319f1199fae256588241a2ac8677eafb49/yarl-1.25.1.tar.gz", hash = "sha256:03dd38de09bc213e9a8b29761eec33ee1d5318dac0e49d8af36e4d27830e23a7", size = 246245, upload-time = "2026-09-15T19:35:02.264Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/92/55fa9ee84cb8ec9930a910b5753936a926f918d8cc8965bdac571479c095/yarl-1.25.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:142c06c4d6a35ee3ec5da08499805e879cb3ca7c1fbfbecb0140fe72403818d6", size = 144695, upload-time = "2026-09-15T19:29:53.114Z" }, + { url = "https://files.pythonhosted.org/packages/16/b4/9edc8e605b16b2eb5bd0c2ccc73e2b15aab583862460767fb43f91f11839/yarl-1.25.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:24ce942011a61953e7d313438038f4d32ff21387b775f58a957f7a07dd55ef95", size = 104332, upload-time = "2026-09-15T19:29:55.443Z" }, + { url = "https://files.pythonhosted.org/packages/ae/de/4204e6646e278b1cd4a9cf73ebe8b7cfcf16693f1249b324eb273d67602a/yarl-1.25.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e23c82b63cd7652fc24d33ed6cc17099d607aa3b4fc4ddc75e95062f3d82df4", size = 104449, upload-time = "2026-09-15T19:29:57.239Z" }, + { url = "https://files.pythonhosted.org/packages/1d/fc/21d74789198ad68a23a209ab0d73fe5a52d39ebbd9945041e72827acdd77/yarl-1.25.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8ee202350cf57abf0e9502a41601841019c25d3db7ff52d980aaf31446254059", size = 116828, upload-time = "2026-09-15T19:29:59.031Z" }, + { url = "https://files.pythonhosted.org/packages/8f/31/90891ddb61848c067ebd1fb505ab5902a6e7d4707c2f3823f67ff37883d3/yarl-1.25.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5df89f769cc8ff94c3d7e7603386fba309d25ce5240132d26c15baa8d0e96c4c", size = 107150, upload-time = "2026-09-15T19:30:00.868Z" }, + { url = "https://files.pythonhosted.org/packages/a1/15/f3d18743d3688b5f01aa83034b6c00665692ab20e1d0c89a29000d4979ea/yarl-1.25.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:83e9f4a25085bd4b7214701a0794ff1f50fc633ffb8bdfebf07abdd81c2db126", size = 124704, upload-time = "2026-09-15T19:30:03.128Z" }, + { url = "https://files.pythonhosted.org/packages/99/37/718789d8004775d6a2db86b2afc72b1d156e0590b88a1b8634ddd4bdb8ee/yarl-1.25.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e636b64d24fd9c38053c5e389a1174c66361fa49dcfd220f4dd35b4abde7cb89", size = 129246, upload-time = "2026-09-15T19:30:05.014Z" }, + { url = "https://files.pythonhosted.org/packages/28/b9/7818ac6dec7fbcd16be2d19495807c01e2c38834e57d41c63356a75e786b/yarl-1.25.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e5637ca8d0bd7fb72648a6c7934af4baaccb697657f7438c9d264fc2abb8b0b1", size = 118071, upload-time = "2026-09-15T19:30:06.977Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e3/8d098cafb30a64df7283b5a5dd0d32d22a71b18a1b9ec071d0776ffe5c2a/yarl-1.25.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:683e362b8ba453080f7489c66f4ea794e751c35b72e7eab3575ef784c2fbc7fb", size = 116180, upload-time = "2026-09-15T19:30:08.836Z" }, + { url = "https://files.pythonhosted.org/packages/20/9c/fbe9432478d87e00eef80242e15179639df162cb3d5d8d978fc56dcb6c51/yarl-1.25.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:df23df54b5114a17c2d0ef192433e2e5a9f0c5178c32375e90b7cfc965f349d0", size = 116578, upload-time = "2026-09-15T19:30:10.782Z" }, + { url = "https://files.pythonhosted.org/packages/b1/8e/b3dfd03732236b86b0bcf06a72a37de412ff6f82e719a947c3c651d4dece/yarl-1.25.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f53dcd26694f148f738edc052b5a69234833e739f10f4c3287bdfd8ec0f7b326", size = 108886, upload-time = "2026-09-15T19:30:12.951Z" }, + { url = "https://files.pythonhosted.org/packages/83/e1/94dacb650d4963d5f844bc3df7ba70288cb7b68ed5a4d070da71acb19b14/yarl-1.25.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:b8075fe90bc08e40b8b8a1874fab42ee4c7b56af05c5886e9cc841397f916908", size = 124098, upload-time = "2026-09-15T19:30:14.963Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3f/c93f76a218258c7bfc60dd27fd56e9102bdbc625517f4a6ee673295fe4e4/yarl-1.25.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:a8c2b841478068440d8b733005d13a5ef535b9928cbc05f17182d410f32ba449", size = 115768, upload-time = "2026-09-15T19:30:16.736Z" }, + { url = "https://files.pythonhosted.org/packages/a8/79/4d93f13c3b05cda3c962805dec28cbc255c50239b3457808abc5633a00c2/yarl-1.25.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:ca32926d7d77bcc8838425c4c95e040a3ace1cb7dfdae599013458dcda2607ca", size = 122461, upload-time = "2026-09-15T19:30:18.901Z" }, + { url = "https://files.pythonhosted.org/packages/c5/8e/e2ba83b3a9bfc1d3b882ad35fa8abc04a7af4d9356f2e329a23d0c8d889d/yarl-1.25.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:192a866877a49993949ef1975864ad8728bea28ee810f6abe1a0729c2b500426", size = 118295, upload-time = "2026-09-15T19:30:21.07Z" }, + { url = "https://files.pythonhosted.org/packages/42/67/cb5ea1baa0c0ac60bda44ce59f601133154af2a2e67722d3517f8793855d/yarl-1.25.1-cp310-cp310-win_amd64.whl", hash = "sha256:3f4d48a6112712973e676bd792121fee470e432d749177162d9949d5c9460a1b", size = 102929, upload-time = "2026-09-15T19:30:23.122Z" }, + { url = "https://files.pythonhosted.org/packages/2f/85/9a1e98de10fc0e738d517efd2a9984c3e7db6cf35fa72b2b373988a9e9b7/yarl-1.25.1-cp310-cp310-win_arm64.whl", hash = "sha256:48796ea00a303961507dc6c8437c4b325a6fc3f95f7c36c71b91ea9a8150963c", size = 98854, upload-time = "2026-09-15T19:30:25.102Z" }, + { url = "https://files.pythonhosted.org/packages/83/b3/2cea721d495ca57f8f414aa4867ee263f486b274e07463158cf52f02ba7f/yarl-1.25.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9d693bf4bf534e9ba3ae2780cfd577f5135629f7b5ac653490859d0b77864865", size = 143794, upload-time = "2026-09-15T19:30:26.946Z" }, + { url = "https://files.pythonhosted.org/packages/55/e6/cd145cff8e5cf60b8b3c41fbecfa2a45028a8dec3fbc52bec03595ff3d3b/yarl-1.25.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ab2054c5531af2a9ba7b69b8ec91e4f884420e83a8c5e579b013084cb57e5e5d", size = 103993, upload-time = "2026-09-15T19:30:28.661Z" }, + { url = "https://files.pythonhosted.org/packages/ce/7c/fbb40fe2d53747c40aa36a9e2bd2178a202f942bda0b670f3306d4aefbce/yarl-1.25.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:564fdc7085d2245ab84f88882fdb1d6ac0723124bff6ded35bfb1c00f812630d", size = 104010, upload-time = "2026-09-15T19:30:31.069Z" }, + { url = "https://files.pythonhosted.org/packages/f0/0b/5a516f70641092283f57cf3670bdb75e7327bcc0dcb5038697e4dfbfd569/yarl-1.25.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acae6b45d1ace09b6ba3876da43b88366ef368f73b988c7f57e14231753d4420", size = 116444, upload-time = "2026-09-15T19:30:32.894Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/d1a627f827b0a404ae0f5647cab0534959081cde13b0756a783469fb3b5e/yarl-1.25.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1fb2a01ba8cd9c5d2c5dc1ec35e0fc951d04b4f037541d4ac090c993ce58b3d7", size = 107565, upload-time = "2026-09-15T19:30:35.188Z" }, + { url = "https://files.pythonhosted.org/packages/aa/cf/c8e0aaec886840a6c4480fe44eaec7cd4319f79a563b79321473be79c56f/yarl-1.25.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e92b6bcc741b86d67606c40d3cb9c7cc8e6c737f81e31f4a94efc204456c92e3", size = 125006, upload-time = "2026-09-15T19:30:37.1Z" }, + { url = "https://files.pythonhosted.org/packages/50/26/0cce366d54a93cdc8342965dc7663385e4db161625cc1e8b18e786753d24/yarl-1.25.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:72c34ac7ad4314c19362d5ce27626dcc8429bd30bbf8c179f4234078851f9492", size = 128717, upload-time = "2026-09-15T19:30:38.904Z" }, + { url = "https://files.pythonhosted.org/packages/95/0c/a71501bbc1a674ff72c4d6c2b75f4d9a5af819f5244c3a7558080a8802c5/yarl-1.25.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5add7b4ca7afeea91d52e4d4e4db3b1fe9885b71f07054560d8c4296b7441a2", size = 117728, upload-time = "2026-09-15T19:30:40.809Z" }, + { url = "https://files.pythonhosted.org/packages/e7/6f/c3267ca01defeed9ed9c4ff9b17bd54915432c405945233265b707475d1c/yarl-1.25.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:def538065f9e4d4cf1ae164bd59aba00dfa84f03923e0de4c3788f252d6bcd17", size = 116224, upload-time = "2026-09-15T19:30:42.818Z" }, + { url = "https://files.pythonhosted.org/packages/8f/69/fad57ee52d648431718ee0f1f68966a99c1352c3924688ffbdcc9d3fe51a/yarl-1.25.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a191bfdb30a79b98e5d175d75285f9fcb78bf0e46ba5efda042e1c72071a0de", size = 116299, upload-time = "2026-09-15T19:30:44.966Z" }, + { url = "https://files.pythonhosted.org/packages/2a/d4/6a8c1e29f33338687ca278cba0a8fbf6525a322c2c02a9a500ccbe041152/yarl-1.25.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:71f42c5b9a948c113bbdebfa544598321431d064ff959d32e99b1feb61d68345", size = 108625, upload-time = "2026-09-15T19:30:46.874Z" }, + { url = "https://files.pythonhosted.org/packages/e8/d2/3a35ae791c9cb6522c106923ff25c3230d999091e5e65511bca23bbd9914/yarl-1.25.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:72849d892954be4d09e569b8b831ac39ce58417fedc767d4308a0fe542018a40", size = 124515, upload-time = "2026-09-15T19:30:49.082Z" }, + { url = "https://files.pythonhosted.org/packages/be/fd/2b022109a6b4af0f7dc371cf7500af380b0d4f034010243e1b0ce218dc93/yarl-1.25.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:efb01a106f971cb3752856bca2318bbdf7f01bd8823779c461586cbe5ffd5258", size = 115711, upload-time = "2026-09-15T19:30:51.208Z" }, + { url = "https://files.pythonhosted.org/packages/18/59/f7586271136c3ddb0126bbfe661844699369b76b555fe38c4efe86870b2e/yarl-1.25.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:a1daf47cd95a7c3a63456336bc5aaa8c86dd3a47d07ed3d0e76132ae4666a5a1", size = 122751, upload-time = "2026-09-15T19:30:53.535Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0b/07f7a2d881f7e16c385b47fc1753382600847cad305dcc7fa0c25828acf8/yarl-1.25.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9489e6abf47ba37f332075a91444c7cfedb03e6ce99fbb2f116bfe1ce810da3b", size = 117983, upload-time = "2026-09-15T19:30:55.277Z" }, + { url = "https://files.pythonhosted.org/packages/aa/9d/8cdceec66a9b940700cb45931741403f045b162afd79bcb93c41cadd0972/yarl-1.25.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7306dee25b8a0e737363f347362b875094b4dc4e367311470656ae420fdbf8e", size = 102894, upload-time = "2026-09-15T19:30:57.606Z" }, + { url = "https://files.pythonhosted.org/packages/17/f1/7ec357db1d3ad2863542d71e8fe64a126bbec17b134cd7d30951196809a5/yarl-1.25.1-cp311-cp311-win_arm64.whl", hash = "sha256:abb1384477f5901d436b5d2e5465954de46ea6098f59163d243660b5c4461d35", size = 98609, upload-time = "2026-09-15T19:30:59.705Z" }, + { url = "https://files.pythonhosted.org/packages/75/b3/cd32ac66ae622b854c2df0ac52106dda220d361b65a64fde7d5b3684aa3f/yarl-1.25.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:94d7aa6debf92a1dd14cb5280b083a764169a13cfb23a452111160274ed989f4", size = 144798, upload-time = "2026-09-15T19:31:01.821Z" }, + { url = "https://files.pythonhosted.org/packages/61/fb/a2c52a8007c2051ba74662afb112ecf3d00346af4c25e33df9d80fd14fb8/yarl-1.25.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:83d4a37e4b95da4d8bda930d6d35b75b4cdadbacbb4980cae290ea3100b5d51d", size = 104583, upload-time = "2026-09-15T19:31:04.05Z" }, + { url = "https://files.pythonhosted.org/packages/be/dd/ee38aec8e09fdf957e50d4085453fbe202f56c6c3b4cf07b81cdb4f09ee9/yarl-1.25.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e029648f9c951db30e98a7d7ec90835db88ec4b32820efe2a9bdc2287e032eb6", size = 104325, upload-time = "2026-09-15T19:31:06.338Z" }, + { url = "https://files.pythonhosted.org/packages/1e/b3/058dbfb1857b484c9cf9cc135659f50b85ce66e03c99e44dc2f7b6161f55/yarl-1.25.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d781294bb815ecb5ea57ff6bbf8038e0a31a95fdf3e1788f66e0dc100d64b58", size = 115358, upload-time = "2026-09-15T19:31:08.593Z" }, + { url = "https://files.pythonhosted.org/packages/db/39/29693446cf0cf6b15a0e2f75a5d40f93c56819b05b0622196f45e95b5cc0/yarl-1.25.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e12c538e00e7c1b286a07061046b90e8124e6a9793efae2c70db6a4aad07faad", size = 107658, upload-time = "2026-09-15T19:31:10.802Z" }, + { url = "https://files.pythonhosted.org/packages/86/b3/3c4dd7e1af43b931fba95e0a722737f2ea94a6d199c802585282831d7abd/yarl-1.25.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e4de3ac4adbad3d0bc7c6f4360a7dbff5de2f15e3b723be3198074e17fd9c40", size = 122660, upload-time = "2026-09-15T19:31:12.84Z" }, + { url = "https://files.pythonhosted.org/packages/bd/b5/1b60dbc3cfc9c5712b15148c206748f2bc93953ffdbe25ea75b63dfc89c9/yarl-1.25.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:419f392a1da624877975709e3864dfe833af6cc7671b39318086d456e288380c", size = 126506, upload-time = "2026-09-15T19:31:15.088Z" }, + { url = "https://files.pythonhosted.org/packages/bc/7b/ca212cbe170ac8b96e45317ecbcf9c3c3ecf0cdec98d5b088a9c4088929b/yarl-1.25.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6f117789d22dce188e5754e8bc65b7e6ebf8cb73963b9fa761f672a5883769d", size = 117050, upload-time = "2026-09-15T19:31:17.241Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c3/72b4938cdbe619ad71ac156182faef4908846b84dc3ca4dbb4c4e6f84014/yarl-1.25.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80e47012e730da131c9f059c80936783f9659aae22dc31c03c0595590d11ed54", size = 114174, upload-time = "2026-09-15T19:31:19.294Z" }, + { url = "https://files.pythonhosted.org/packages/e8/43/268717870f9ba0cc9701a95181587f6dc8c5f387aab4aeecc83158f38a79/yarl-1.25.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e80f557716fd765439577131e526b8942ffc2c07bdbc5e39fa62f660ba1e963f", size = 114944, upload-time = "2026-09-15T19:31:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/da/84/baa5bf504d51fe062c4bcaf62936da97fffb43285978d0b39984824231fd/yarl-1.25.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f61964f235a43738bfac50da46fc4254943a7eea3051aeb0b6fc7c992c29fadc", size = 108263, upload-time = "2026-09-15T19:31:23.388Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/779a2ed9e0152a601a27039bed9aead3f0b79797a67e2c44bfa444622dd8/yarl-1.25.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e546fe1d4a93ebc2910f0d768baff19faa09843ab3f2036a67ed6e69fae4419d", size = 122184, upload-time = "2026-09-15T19:31:25.343Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1f/118e9e5b8f07694d63fd3222e801d7782270003f1a222aa798df3f8d5933/yarl-1.25.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cce0727fd5ac04d372fa9bbfde9febc2bcf209aadfcf0468e45dec72719895d1", size = 114001, upload-time = "2026-09-15T19:31:27.465Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/a4cf1cf372313734b17996d4007f9f73596e7a178b9485802e5494ecf484/yarl-1.25.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af4ea5b37403ef4e30f3927eaed540db942bde01d8d3ff083527c0704d1c9c68", size = 120565, upload-time = "2026-09-15T19:31:29.47Z" }, + { url = "https://files.pythonhosted.org/packages/05/79/ad94f93ca731bc9e44d321833ab96b82a4f9f5f63cf773f81a4aeea5ecc1/yarl-1.25.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:68782fdb4027b8d1eee25ec35e9a6db05e863b899eb0310b3a33b6c3fef55707", size = 117060, upload-time = "2026-09-15T19:31:31.367Z" }, + { url = "https://files.pythonhosted.org/packages/bb/cc/51a7b4abf4ac593b8e7eb3794b28e5a35ae26eed8bc04787628d215af82f/yarl-1.25.1-cp312-cp312-win_amd64.whl", hash = "sha256:7d575b54cb3863ef9bc290ea4b009999d55dc237326131e4853cf33e888fee03", size = 102593, upload-time = "2026-09-15T19:31:33.329Z" }, + { url = "https://files.pythonhosted.org/packages/9d/21/0941a6b93a58b59a1ec75e5333bf06929b671309c43c0cd201c172d9c39f/yarl-1.25.1-cp312-cp312-win_arm64.whl", hash = "sha256:bc3ac7bf569f6b64dad04dd7808c7872dae8a97df657856eac05e9b7e3614a85", size = 97697, upload-time = "2026-09-15T19:31:35.855Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/2f3129bbcc9a5c8ba12cc2b29d8060a3bab9c8043c456cfd4b5ca3188890/yarl-1.25.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:25868beca8b6765f8f7d0e11fe6dd7c66dd4b0793b9500286d20cc92352126a5", size = 143623, upload-time = "2026-09-15T19:31:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/17/e1/f1bc3390fdca352826676b531d0712736f156919090206700421d46b2c37/yarl-1.25.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:10b2fd95332f0d716d5eee3c9fb2ce8eada19082de7fee83d32e37992fd75c26", size = 104011, upload-time = "2026-09-15T19:31:40.25Z" }, + { url = "https://files.pythonhosted.org/packages/a8/aa/50acc5c3e5da04172ae3c281c75405af4d2ca911e16120ab0563f4dffb66/yarl-1.25.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0f12afda4eea8c8994a76d4df1875c765194f5fbe8a9d197929ea303caee29ec", size = 103677, upload-time = "2026-09-15T19:31:42.46Z" }, + { url = "https://files.pythonhosted.org/packages/30/d2/7d1e0ab9f8390e1fbcede5a6dbf70d23c96ad09b8c5567f3a514d1ddb0e2/yarl-1.25.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:14b79a30a93a3ce2e8832603fd0ab780ada281b0ba5110b519a634f2d7d7d1fc", size = 115392, upload-time = "2026-09-15T19:31:44.371Z" }, + { url = "https://files.pythonhosted.org/packages/71/e1/5ba1e3a2a22139213655e760919038e8ed7e2d4a99826d0bbddb3beb96e5/yarl-1.25.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bd6340d20ae2c7ca719b87b426e808e90743b676d05d4c26c4fb5ca71f41184", size = 107493, upload-time = "2026-09-15T19:31:46.273Z" }, + { url = "https://files.pythonhosted.org/packages/f5/53/780653d5e0f73831f467cf13548912e5eec97f21dc49fc8daf21da027df4/yarl-1.25.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:126a2533570c554719ca40a1288fdee1700b6bc82e7131aa69fa85252d92e651", size = 122537, upload-time = "2026-09-15T19:31:48.654Z" }, + { url = "https://files.pythonhosted.org/packages/03/92/d54fa70236c6036271c9c9c09fd978df5cbe3ef49ef6c46e9b833476d215/yarl-1.25.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a3faadac7d812ddac258feb57b9846b60c1b437c4f4b9ad42595c6f6fe4390df", size = 126170, upload-time = "2026-09-15T19:31:50.872Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b7/a82a49bf88340b837ef6972b508a1604ae377b9e6904b46b10cf5f1cf925/yarl-1.25.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be80550d9bfe83d9b62398a37081a90434e6df2d978ec345c3d2820de6beddab", size = 117012, upload-time = "2026-09-15T19:31:53.189Z" }, + { url = "https://files.pythonhosted.org/packages/ef/78/5d684b411e3f3602464ee9b538db48205038f8605872985f61efb809ced0/yarl-1.25.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e07595c7d6f4db270ceede356a1bd1c07a34f1c26f958d1ed0cd7b48e0d2bba3", size = 114950, upload-time = "2026-09-15T19:31:55.694Z" }, + { url = "https://files.pythonhosted.org/packages/2f/11/51d82b852c64f7fad0fc7a7ff3031517204887e874c722bbca839c0b23ac/yarl-1.25.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eb96ed1ae6c7d072d60840c0434aef07a2df611812810807fbc54263a6053e9a", size = 115428, upload-time = "2026-09-15T19:31:57.966Z" }, + { url = "https://files.pythonhosted.org/packages/e4/49/9d1978049bf646b9ea918313926453c6901b71c92f097467777d47d36a88/yarl-1.25.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3feb99222553a8cbedfa52c2f59dd84c3f50d5b582c728d522caf8d72769a54b", size = 108428, upload-time = "2026-09-15T19:32:00.048Z" }, + { url = "https://files.pythonhosted.org/packages/43/35/7b8f1ebb45d7ec3dda7d1909bf44f458de41ef91e2937f107733582a5166/yarl-1.25.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a2ed0ba415ccdf08f14bf544cb78346d0f76086707ffee24921a2c84dbf1305a", size = 121961, upload-time = "2026-09-15T19:32:02.436Z" }, + { url = "https://files.pythonhosted.org/packages/63/d6/d8b689ab7ca26edeb85f6ff28812aac7a25376eefc1780e303a7bfbaceff/yarl-1.25.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2b49375d22299b0a834c2bca72f39aaecc270d96fb24c30424899676f487b22a", size = 114961, upload-time = "2026-09-15T19:32:04.456Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d5/1a1798ea4dc6b7ee3260010a27907ebc697c95dae99817d817ed446d24aa/yarl-1.25.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ef74070ac553c59eb4f04258722066d6c6135b7baa03b2e9f2da65c096e96d98", size = 120036, upload-time = "2026-09-15T19:32:06.5Z" }, + { url = "https://files.pythonhosted.org/packages/91/8d/b1b35ed7903da6669b1d367cb2c09436acd4ff508029b4f39a0c0c2058fc/yarl-1.25.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0a66db89ea473abeac4b70523cafd94db3772380e565f9d28af7a179b7af71fa", size = 117276, upload-time = "2026-09-15T19:32:09.401Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8f/4db01cef62caff0d7a4593ed694fb8a41a27a11158cab80d290221f13e57/yarl-1.25.1-cp313-cp313-win_amd64.whl", hash = "sha256:1f51020b2eb8a003c84925638ec63c21a750a4bddd3a22ec8eac6a742dadf1b9", size = 101945, upload-time = "2026-09-15T19:32:11.545Z" }, + { url = "https://files.pythonhosted.org/packages/c0/5e/3ce00497c5c0babb74d4130c10c3828ccd215b4819d12020c42429f991ac/yarl-1.25.1-cp313-cp313-win_arm64.whl", hash = "sha256:b10dd0557ba422715b5206b3743192135a6022acca8baec51aa127d0a75db8fe", size = 97270, upload-time = "2026-09-15T19:32:14.127Z" }, + { url = "https://files.pythonhosted.org/packages/80/cf/54023edfab7aa773b860503db0c56e962ccab0922803ee97988c176ea090/yarl-1.25.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:a9ca696eb02e5c02a8afd872ada510eba9b7fe6e68b9572c2e9a9b1941e31e2e", size = 143975, upload-time = "2026-09-15T19:32:16.416Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a8/e6c1be0e6761d0f2d10bbf33a3e1e02b99dc83874d92945d7b461a72481e/yarl-1.25.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a5877f2255aab518ebe528289037699201d5dc5f045f2396cb30aa02db22f57f", size = 104018, upload-time = "2026-09-15T19:32:18.364Z" }, + { url = "https://files.pythonhosted.org/packages/6e/bb/dda344765ffd3430afe1a1c66c866a57fae67786537d4f14607df6505ac1/yarl-1.25.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7a5c3115595995779ee21f2567035793911c3802a43c74f3fbb0314929ec67ac", size = 104156, upload-time = "2026-09-15T19:32:20.459Z" }, + { url = "https://files.pythonhosted.org/packages/e5/5f/ed1538bcd06009fe990d6d283dd7667f639e62a81e35c6d8c6ef6c08fb3c/yarl-1.25.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77e5099b99b37f3cf79c246998ca9f7313a78054cd1809ec46bc1afad47e1c4c", size = 116025, upload-time = "2026-09-15T19:32:22.766Z" }, + { url = "https://files.pythonhosted.org/packages/a2/af/2185daf56b99830d3356ecfada46faaa49945de6626e842b7728088d4980/yarl-1.25.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6efaf45df6a849cef613a03a94c845647456662f85438c886bb67a9c027c8c2c", size = 106985, upload-time = "2026-09-15T19:32:24.749Z" }, + { url = "https://files.pythonhosted.org/packages/c1/65/bc1ae564fb4b04a30b6a8f250e787772581c57e4c3d5cf07ac3359de3103/yarl-1.25.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5f90e44653c4e0f78501ed9bb7d3fce835a8d62b7c6ed0cb16557534087e743", size = 123030, upload-time = "2026-09-15T19:32:27.084Z" }, + { url = "https://files.pythonhosted.org/packages/6a/3e/e2afcde10d74e53b3fa889960991efb3019beda2b1682a01de720a302056/yarl-1.25.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:632da579b2d879f6bad20f2cfa35ded1efe2f4f77f8abb26a6234a5b236acd2f", size = 126765, upload-time = "2026-09-15T19:32:29.332Z" }, + { url = "https://files.pythonhosted.org/packages/a2/be/415b00c0fe5a0615b062a456b26623d7ec91c2bee20faea1a14045aa0469/yarl-1.25.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:30eec96e8a91bd588ce897c9543f6d5d8d34b28fbcba28a4dedf20ebeae9fe57", size = 117199, upload-time = "2026-09-15T19:32:31.49Z" }, + { url = "https://files.pythonhosted.org/packages/97/27/3d8c63ddd3e8bcfd033748ab93876678ce59bacd66e4cb1ed851c9c5b37e/yarl-1.25.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:12b6bc4906e11f5e1a1cdcb12296e7afbd366c783cc8073403cd2fb74334e453", size = 115187, upload-time = "2026-09-15T19:32:34.137Z" }, + { url = "https://files.pythonhosted.org/packages/39/b7/7a81d0be1a502a26a0d4326c6f2ecb736c824f570ea1c6529f2b0b227b50/yarl-1.25.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9d6ed3d17bccce4c05343e1ca8da13bc5c02c812a4e7282ddd05e8769322d3fc", size = 116085, upload-time = "2026-09-15T19:32:36.438Z" }, + { url = "https://files.pythonhosted.org/packages/f0/69/39fff459916aa0fab42215dc47b759586fd80f94aa56dfc4a7c15ba6e0dc/yarl-1.25.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:f38a70074041d3b7e138e452799f5174198bae5bd5ab2000917badf403908c5f", size = 107996, upload-time = "2026-09-15T19:32:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/c0/39/80b9a55a3335590451d9ecf3eb593a8c635351f4c905ef056d7e8a8fd9e7/yarl-1.25.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca89e4e21854ed27ec753297dde84b16c9f8e53b14a4866fb44457d643c19f8", size = 122549, upload-time = "2026-09-15T19:32:41.151Z" }, + { url = "https://files.pythonhosted.org/packages/42/7d/a179c6757818bb59372a4adafd09f7f26a3b4a0f04c3ae404b544c0b0c82/yarl-1.25.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1ab7618921a93767387a4b83776f751588f5b5ae9bb5bc96620e2e2e00bca868", size = 115107, upload-time = "2026-09-15T19:32:43.072Z" }, + { url = "https://files.pythonhosted.org/packages/32/2b/a773ac867e4ab53a98ed98e5cefe3bae31e6f550252ca9d1de266f1a40c5/yarl-1.25.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0ae12ff2b805fa02c4dab838005caef735e39986322698c48588d3beacb65c62", size = 120666, upload-time = "2026-09-15T19:32:45.061Z" }, + { url = "https://files.pythonhosted.org/packages/bc/41/52be6505e85b0f76b4f85b01b5de7e06a0512201abc2c95e14e099549174/yarl-1.25.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:90c30ed53546da833c700115c0064c22120d1b1560f474699fd31f22dd668233", size = 117505, upload-time = "2026-09-15T19:32:47.177Z" }, + { url = "https://files.pythonhosted.org/packages/f5/01/349c0386caedbbe488d519f252df54efac8a1459282d466c474bdd84a620/yarl-1.25.1-cp314-cp314-win_amd64.whl", hash = "sha256:acfa7e22aa6c6e7a5996a41d275bfa01efa7ea56ab890590280e9063e2cf5c1b", size = 103446, upload-time = "2026-09-15T19:32:49.615Z" }, + { url = "https://files.pythonhosted.org/packages/5c/f0/8ec63180f77912f0dc4e5a42760cb8c08d20da1d5ace3578a01b84d1f3d8/yarl-1.25.1-cp314-cp314-win_arm64.whl", hash = "sha256:8e7d98cdbb6d71e726f7d525952867096053d1f290dd4e3c50d7d313a136f414", size = 99159, upload-time = "2026-09-15T19:32:51.686Z" }, + { url = "https://files.pythonhosted.org/packages/47/7d/92d2220d6886b70ab1ed8579533ac2af2dfac716d5d929001daff7986df9/yarl-1.25.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d21f0fa80a02d05299207eeaafef345d812ace96d5306e4ef265e1d419a615fa", size = 150071, upload-time = "2026-09-15T19:32:53.911Z" }, + { url = "https://files.pythonhosted.org/packages/64/fc/b245e448124bcda9340df38e3553fa222b50260fca027a84095e9bd8642d/yarl-1.25.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:17c9877a89fb6e2bca6f9087eb24cd7fb434653946ef5075e470d23d49b52287", size = 106780, upload-time = "2026-09-15T19:32:56.443Z" }, + { url = "https://files.pythonhosted.org/packages/51/e2/9a6ce2e334ebf218a30335ae76fb1696459430d42f733b8cb0d7d65b84d3/yarl-1.25.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:29273edf1530e397bd07cb784db1fbe0d2590b77569f2e24679a9c0a2d763b94", size = 107361, upload-time = "2026-09-15T19:32:58.827Z" }, + { url = "https://files.pythonhosted.org/packages/ed/70/66e8c76b569b450d16e190f15071c916c3df70b0e33927e415ac497cf0c2/yarl-1.25.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7abffdf37af1cec6a2ad69b827aa84320db5894791bc8ed932dc93fb274b7e9", size = 114396, upload-time = "2026-09-15T19:33:02.24Z" }, + { url = "https://files.pythonhosted.org/packages/73/23/0d82838a05c57fdc05bc8b66e8c92dcc0df15e27463a5f163142d521c682/yarl-1.25.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2239a02249d9326655419e0168a28ca9008938eaab31dc29fc875c217927a6c0", size = 104882, upload-time = "2026-09-15T19:33:04.494Z" }, + { url = "https://files.pythonhosted.org/packages/86/d4/ea08615c4edaa6049a13a2f1128944d068d1893abda7d708d4d7ea01599a/yarl-1.25.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:664ec6a520b74a1df2810666eb67695fcb77fa663e6ea0a25aaf2e529cb24dfa", size = 119485, upload-time = "2026-09-15T19:33:06.583Z" }, + { url = "https://files.pythonhosted.org/packages/1a/82/0898bdce9b1ae403b308b9c733d0d24af4a3464270c2c081f457b16c3e0d/yarl-1.25.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4f1c91f5a5980a937ff8e238e98e6897e1ad74a4b1e2c0d68c73b5ffbb3f5c0b", size = 122490, upload-time = "2026-09-15T19:33:08.653Z" }, + { url = "https://files.pythonhosted.org/packages/d1/38/97d79b81c342b78246cfedb74809e68841f3198d21653e10d3232bd9c622/yarl-1.25.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c88edaec8c349ad4c5ad4c486a3defcc4b80ceb2f074436ffa0a87caf5e76a6", size = 115336, upload-time = "2026-09-15T19:33:11.056Z" }, + { url = "https://files.pythonhosted.org/packages/8e/9d/2577896554cd310dc470adb6da0b7dd0b435cb63e2565204a7ac240e504c/yarl-1.25.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:35dcbea443fafb3eece757ad4e514560ddeb6c34cfae1582c620d7b293d7feee", size = 111825, upload-time = "2026-09-15T19:33:13.204Z" }, + { url = "https://files.pythonhosted.org/packages/29/6b/7ac49d8ba84a5c4bd73415a4c949d22c749cb3762579b3d50e48019a78aa/yarl-1.25.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:882569ff613758cac762a457a5d72d6e211b28d4bcfea89d1d71ea942b02eac0", size = 114655, upload-time = "2026-09-15T19:33:15.553Z" }, + { url = "https://files.pythonhosted.org/packages/e5/18/e5942a16723f5b72f9b1297fd5a85a54f6300cd15c0dcb5005b90cd89156/yarl-1.25.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d0f1489233a254bb3643d2f05de7d59019254d81daeca6b9162fe9edef57e0c7", size = 106395, upload-time = "2026-09-15T19:33:17.599Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2d/549fa46240781513ebc47ae7eb418df428a163a2a3d644cc9cbb3ecb7846/yarl-1.25.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f41753a76f4f63927d03a0d8ba8f5ce0f2083bec29a8cfaccc55371b1564b96b", size = 119277, upload-time = "2026-09-15T19:33:19.973Z" }, + { url = "https://files.pythonhosted.org/packages/76/16/4763f78dcdc0b3b9fb3842b04afe72b9320857c6a69300c62a0eab03d119/yarl-1.25.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8fb0eb4955adf0579001581f2f71a126e8781ba61bcd120f127b0401163c6c2d", size = 112504, upload-time = "2026-09-15T19:33:22.464Z" }, + { url = "https://files.pythonhosted.org/packages/ae/b4/974e3edfe0d188393ce1cb9de400111c63fe61f4eb3b772a500d84c970d1/yarl-1.25.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a1e32763e641a1566507d90a8d3b19bfc3cc04a9d4e5ae3e32189874ed4b58a3", size = 116243, upload-time = "2026-09-15T19:33:24.788Z" }, + { url = "https://files.pythonhosted.org/packages/b0/aa/157b940428da80c104ca09666a740e51c94963df65d5b112e06b52e4d7a8/yarl-1.25.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65b5b2066651b7432d389e9799d979c703bcc6ef44266bb8153ef54e91e4aab3", size = 115822, upload-time = "2026-09-15T19:33:26.886Z" }, + { url = "https://files.pythonhosted.org/packages/7e/af/19fbdce41412e1b96825544cc52cd7029d3724655d0988237972f078bd29/yarl-1.25.1-cp314-cp314t-win_amd64.whl", hash = "sha256:734f6e5400352ac4254456003d462866c684703570929cff7a7bde015d0cb371", size = 107386, upload-time = "2026-09-15T19:33:29.009Z" }, + { url = "https://files.pythonhosted.org/packages/2a/99/f6431c8968e89be608d74b28ae2d024521b2953f27dd44e0dece5e04f67a/yarl-1.25.1-cp314-cp314t-win_arm64.whl", hash = "sha256:287e99ff5aa4dc1c7630bfc683ded6f106d756c99dec432a2d7f197a784f51c6", size = 102094, upload-time = "2026-09-15T19:33:31.151Z" }, + { url = "https://files.pythonhosted.org/packages/c7/3b/4f51eab40c2eabea6c3d5b121dff4b8988dc35087732ffede12d2be8b8dd/yarl-1.25.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9b1bdaae98bc016825dd3c9d8ee1832f829b3341f9cc6ebd1a1b0a7fef7367cc", size = 143875, upload-time = "2026-09-15T19:33:33.52Z" }, + { url = "https://files.pythonhosted.org/packages/41/05/bbd58fc063f5f299a883f810760b265ca26c8167c91cb9a494d0fe2387e1/yarl-1.25.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:e7011b8fb8c4054bf0c12e5edc6cd83778b0028e99ce59b18586ed036f92cfdc", size = 104028, upload-time = "2026-09-15T19:33:36.22Z" }, + { url = "https://files.pythonhosted.org/packages/12/ee/2fba0aecb52e7020e189f684148783aa0b9cfa3b3bfb0b400646eef70ad4/yarl-1.25.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:f074e8d4aa0a5798920ddb6de3d08b228c614ff3724c3e8bd7577f4bafea867b", size = 104041, upload-time = "2026-09-15T19:33:38.86Z" }, + { url = "https://files.pythonhosted.org/packages/38/35/884beab53ed88c7247d1671972b5ef116f7351fe0c7e6de8c3558372cb16/yarl-1.25.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d42e7e3ca399555578b4d617e3a6ecf13371b3743a115995fa010c7bf341459", size = 116018, upload-time = "2026-09-15T19:33:44.265Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/1a91b685afb55cc18608443ace95280e96e263a97565811732b6788d3269/yarl-1.25.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:aa4ed3dd308548f9e707d9caaf005d2d7f8c1e7868f858dfeb47fe76e16b391d", size = 107033, upload-time = "2026-09-15T19:33:46.45Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c1/58b379fcb1d68d907b7fcf75200c44321896509b2a6a74abbb4b19d864d2/yarl-1.25.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42a66563d8cc056ee32e6191e05097a7b2b3bc302e0bc3133daf8710eb18bd26", size = 123257, upload-time = "2026-09-15T19:33:48.631Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2d/1fe96cf5c2aeab10095e48f38585cf5a8451fb7253234822398e52aa5336/yarl-1.25.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:98d370568f393215d605304cdb77b3d5539bd192c75b623c7304c42c8d6d8273", size = 126745, upload-time = "2026-09-15T19:33:50.999Z" }, + { url = "https://files.pythonhosted.org/packages/ad/60/8674394ce43f4dadae573a1d6f451716438e9eab7d7fe8d643c673a32d85/yarl-1.25.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23bf5b403c879a54964e0feac7285688e04bb220074878d737d331522da0a5bf", size = 117255, upload-time = "2026-09-15T19:33:53.456Z" }, + { url = "https://files.pythonhosted.org/packages/2f/72/0faa30e02605d56127d42bb987dcc97da3863b7bf70b9bfbf5f739c05e30/yarl-1.25.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d45673badd08456d0340e9364eddafe1c53a9d2896424294de4d7dd71ad3ee57", size = 115166, upload-time = "2026-09-15T19:33:55.669Z" }, + { url = "https://files.pythonhosted.org/packages/8c/90/9a46eac564c437e128285c5c1d7bb385d268394e9209d84f6bf4a14471ef/yarl-1.25.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:0136d640dfa9b0523853e411430a99f8a91eca85774c6420285a33b755bc6de3", size = 116082, upload-time = "2026-09-15T19:33:57.78Z" }, + { url = "https://files.pythonhosted.org/packages/ad/38/4b1a686a3758878f93d2f1ea943f5a165f3555769cd16e761cfd0efdca17/yarl-1.25.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:59ba3a6e1aa8cfe5adf4bd270fd965db21955401b7ca6f1696010c55ed4daec2", size = 108048, upload-time = "2026-09-15T19:34:00.25Z" }, + { url = "https://files.pythonhosted.org/packages/da/14/f348eb967f31a58348612a2b93bd8a2ab664548e2b5e879cac7f592f7201/yarl-1.25.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:87796fedc3ba97ec14fab55acb48584276e6c1e4c1e89c422bda62c838e754a9", size = 122775, upload-time = "2026-09-15T19:34:02.455Z" }, + { url = "https://files.pythonhosted.org/packages/ab/e9/4f7b79700f88cb9e8bb66f8b54f9bce1844c013a2c39fdc47112e9334c95/yarl-1.25.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:bd0912757081f89b107d6c00b2ff8a194401b0b87eadcf4481de2b865a8fd44f", size = 115096, upload-time = "2026-09-15T19:34:05.283Z" }, + { url = "https://files.pythonhosted.org/packages/cf/37/f9cb020331997d3eb887bd28d5410ecfd3d80bf163c23d7cec490d78dade/yarl-1.25.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:b51c159a9794633f5e0db7ecec7b2b6e3734eca1f5d17dc989ff3552a43ff78b", size = 120655, upload-time = "2026-09-15T19:34:07.382Z" }, + { url = "https://files.pythonhosted.org/packages/12/83/52fceb22891a41f168db7ec22fd1d81e06b6a0b8d9f70921bd3e785defd0/yarl-1.25.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:319e070a01db9920fb63761843f96a104c8e2b9427266731810dc1e22595b17c", size = 117488, upload-time = "2026-09-15T19:34:09.988Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5c/ce6c4ff1247fcbe4b33d462c23a097106d909b173fde7042bc52290466e2/yarl-1.25.1-cp315-cp315-win_amd64.whl", hash = "sha256:a2059a2d891bd156bc5184e7ab7a56e78a84dfcfdeac8c501b552533ad1c36ee", size = 103434, upload-time = "2026-09-15T19:34:12.56Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/766a906b0704fb26d52b19dc22bed48a8ba0544203b70dcf44da350e8194/yarl-1.25.1-cp315-cp315-win_arm64.whl", hash = "sha256:a78b50b4f7918a3de71105d5c0b93bbc57bb8339a4d03a9dfd449f9068e76f3d", size = 99155, upload-time = "2026-09-15T19:34:15.132Z" }, + { url = "https://files.pythonhosted.org/packages/bd/d3/a1d09b32cb6ab14f66b44939f5b4255b8b9e747aef3974af1d5d80ccc2fd/yarl-1.25.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:b5402a340723fa7da00b5cff987ddab61276be6d11251ea71ae02bcac54890d8", size = 149284, upload-time = "2026-09-15T19:34:17.452Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3b/fe554d879692650bca70bfbc0df124e82e4d2bb7456f698c7756f1279a96/yarl-1.25.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:eda19ea5ee88742f47a2340816e6f2d40b53bed3ab5b69794769f36af9f35bb4", size = 106399, upload-time = "2026-09-15T19:34:21.474Z" }, + { url = "https://files.pythonhosted.org/packages/e1/4b/e7af56177ac8d40094c82d7728224c0b8472157d50d362e5fb3b014b2bc8/yarl-1.25.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:75baa6cf9b6d1c52f3e111a130e202fd8cf0a5b3a066c3f73d615e885092e4ec", size = 106968, upload-time = "2026-09-15T19:34:23.619Z" }, + { url = "https://files.pythonhosted.org/packages/da/4f/2df41fd738d46f23ef829ae8b4468d94bb6070038fc6dfab6165ed44fea8/yarl-1.25.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbcef5a9119ef653653132cccaf999b30a0af6f33bb0a4ba80bec30056868487", size = 114717, upload-time = "2026-09-15T19:34:25.879Z" }, + { url = "https://files.pythonhosted.org/packages/69/ea/002b66df53bbd1aed1c23358ff99c9bdc744fe3f4d2740e1b2fdd7192885/yarl-1.25.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7efc9f082dfed77c316edffa9deb52888e1bc6789171887cc1f68e06d65465c8", size = 105198, upload-time = "2026-09-15T19:34:28.204Z" }, + { url = "https://files.pythonhosted.org/packages/b1/7c/95c8bc0c8f97d71e59c94525ad60d76f5c57d3f2820f08137ca8b9f0542a/yarl-1.25.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fe01645169a2112aa1d4ebc3e4c5f029c5c8f97adfc32e5d37c993b39a994d75", size = 120271, upload-time = "2026-09-15T19:34:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7a/6fe9da56ec77927baa669fd86c39c567ce6205bab53d581082c6744c8ae7/yarl-1.25.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1c557dfd5e3db046053a0bdc72261ade790ebe8e2c7a41b36b0ca1f14cb95f3", size = 123572, upload-time = "2026-09-15T19:34:32.73Z" }, + { url = "https://files.pythonhosted.org/packages/8b/83/35f222d17fa70a14c7c74fdf112ccf5515e0c2a87082b1f9b99f7693bf57/yarl-1.25.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ce4d6ccafb33d39bd78444612d14938ead674c25702ded2ee9c54a47735d225", size = 115228, upload-time = "2026-09-15T19:34:35.344Z" }, + { url = "https://files.pythonhosted.org/packages/da/6f/fbaaf619423578a7d898d0f226ae47bc1906c293865c416d83c17b828b0e/yarl-1.25.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80a063f8297fc796296f00f100be520f209b23dc98f93ce8eba6ee7122598209", size = 111618, upload-time = "2026-09-15T19:34:37.656Z" }, + { url = "https://files.pythonhosted.org/packages/ba/78/7383278f1b3cf8e0496bd95b3281a7b09b89217b6b428db24c6b99b3deca/yarl-1.25.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7cb414a73e21a7ab58254926073f2930cb22f5b4314ea4260a687e2b3fd4dce3", size = 114839, upload-time = "2026-09-15T19:34:40.099Z" }, + { url = "https://files.pythonhosted.org/packages/62/49/5506e5b6d29aab91bd845cc9016d88c3d3f81b81bc242b8100bdd5737825/yarl-1.25.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:85a18376073f8a39aa07be34f9fc77e2869aa72c55c441efdd2cf79a0407504d", size = 106212, upload-time = "2026-09-15T19:34:42.768Z" }, + { url = "https://files.pythonhosted.org/packages/46/8a/19877c193b7c5929f4b07118c18bbe390f3fadd0f59dd98c0cd12b31fa5c/yarl-1.25.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:77716e245c90f058466a05e6a465bb8600f767a8f4b18b4d40f3aff958e5f73c", size = 119985, upload-time = "2026-09-15T19:34:44.976Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6c/0a46fbbf9ecbcbd0cc20d2193394254b9e19817f22c814aab60f99847400/yarl-1.25.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:1e80dcf1446e1b080b1932b0d103c464a04112f5bc31f0f983ad418172063cde", size = 112081, upload-time = "2026-09-15T19:34:47.45Z" }, + { url = "https://files.pythonhosted.org/packages/ef/30/93f5d471230c74ccd06255d0842739f86551f937f9e63a5e947853c6244a/yarl-1.25.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:bdc8d8b8c22e9e43ac68316b5e6cf083dec537f4ec213cb4aa967b583bc3fa64", size = 116995, upload-time = "2026-09-15T19:34:49.972Z" }, + { url = "https://files.pythonhosted.org/packages/2b/80/c386593035ee3f9c6c6af0847b5578f2830c674794a9d7701b744a3ebd42/yarl-1.25.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:dfbf531053a0935f2e871bcd4753f90313688772ff8c017f5ea402e315a78c1f", size = 115570, upload-time = "2026-09-15T19:34:52.628Z" }, + { url = "https://files.pythonhosted.org/packages/38/02/eef443559563ef8f2e10469387b8b1e97cb5efee95b288a56da60801f7ee/yarl-1.25.1-cp315-cp315t-win_amd64.whl", hash = "sha256:b13b88747769537f3d32e89e3a735da10c0a9e35d7322928c701b5f93d3afffd", size = 106811, upload-time = "2026-09-15T19:34:54.935Z" }, + { url = "https://files.pythonhosted.org/packages/88/91/41e284ca2cf5211e05dae031d126a3668aea88fa759df56e7e35c6ad25ba/yarl-1.25.1-cp315-cp315t-win_arm64.whl", hash = "sha256:783dd1467083f4d3f7722ad6a313f24c173e7571372738fcb7a6e6d1ba48df25", size = 101804, upload-time = "2026-09-15T19:34:57.231Z" }, + { url = "https://files.pythonhosted.org/packages/54/22/318c7980066769c6bcd9221ed2248294f5698811da099013098c670565ed/yarl-1.25.1-py3-none-any.whl", hash = "sha256:681c758b0490f9e96b78e5fa8e8dc6e648e9185bb6eaebe73183c33ea0c445f3", size = 63617, upload-time = "2026-09-15T19:34:59.616Z" }, +] From fa7592572329a36befb1b432da45c03a0ae34279 Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 22 Oct 2026 11:18:02 +0200 Subject: [PATCH 05/36] Add interactive 3D views to six tutorials (#19) --- .github/workflows/docs.yml | 6 + CHANGELOG.md | 3 + tutorials/building_composite_shapes.ipynb | 105 ++++++++++++++++-- tutorials/conductors_and_level_sets.ipynb | 66 ++++++++++- .../grid_intersections_and_cut_edges.ipynb | 71 ++++++++++-- .../sampling_export_and_diagnostics.ipynb | 64 +++++++++-- tutorials/shapes_and_node_masks.ipynb | 99 +++++++++++++++-- .../slices_and_coordinate_sampling.ipynb | 56 ++++++++-- 8 files changed, 419 insertions(+), 51 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index a9cacd6..1e30fb1 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -40,3 +40,9 @@ jobs: tutorials/shapes-and-node-masks/1.png tutorials/inspecting-geometry/4.html tutorials/conductors-and-level-sets/2.html + tutorials/shapes-and-node-masks/2.html + tutorials/building-composite-shapes/5.html + tutorials/slices-and-coordinate-sampling/4.html + tutorials/sampling-export-and-diagnostics/2.html + tutorials/grid-intersections-and-cut-edges/2.html + tutorials/conductors-and-level-sets/4.html diff --git a/CHANGELOG.md b/CHANGELOG.md index f2baca3..649d8c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,9 @@ format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). `field.plasma.plot.isosurface(...)`; `evaluate(quantity="level_set")`. - Interactive 3D scenes in the tutorials "Inspecting geometry" and "Conductors and level sets" (starlight-theme v0.5.0, `plasma_plots.scene`). +- More interactive 3D scenes in the tutorials: the new solids and a 3D node mask, 3D + transforms and Booleans, an oblique slice through a shell, interior and surface samples, + 3D cut edges with normals, the level set of a slotted electrode and its grid-edge crossings. - `DomainDecomposition.owner_ranks(positions, lower_bound, upper_bound, num_gridpoints, layout)` and `owned_mask(...)`: vectorised position-to-rank lookup for NumPy and CuPy arrays (`(ndim, n)` by default, `layout="particle_major"` for `(n, ndim)`). diff --git a/tutorials/building_composite_shapes.ipynb b/tutorials/building_composite_shapes.ipynb index 15938a9..fc53057 100644 --- a/tutorials/building_composite_shapes.ipynb +++ b/tutorials/building_composite_shapes.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "markdown", - "id": "building_composite_s-0", + "id": "0", "metadata": {}, "source": [ "# Building composite shapes\n", @@ -15,7 +15,7 @@ { "cell_type": "code", "execution_count": null, - "id": "building_composite_s-1", + "id": "1", "metadata": {}, "outputs": [], "source": [ @@ -35,7 +35,7 @@ }, { "cell_type": "markdown", - "id": "building_composite_s-2", + "id": "2", "metadata": {}, "source": [ "## Transform snapshots\n", @@ -48,7 +48,7 @@ { "cell_type": "code", "execution_count": null, - "id": "building_composite_s-3", + "id": "3", "metadata": {}, "outputs": [], "source": [ @@ -69,7 +69,51 @@ }, { "cell_type": "markdown", - "id": "building_composite_s-4", + "id": "4", + "metadata": {}, + "source": [ + "The same methods transform 3D shapes; `rotated()` then takes an `axis`, and a negative factor in `scaled()` mirrors. The bracket below has a post at one end and a peg on one side, so no rotation turns it into its mirror image. Click **Interactive 3-D view** and turn the scene to compare the four copies." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5", + "metadata": {}, + "outputs": [], + "source": [ + "import plasma_plots\n", + "import pyvista as pv\n", + "\n", + "from simgeometry import Cylinder\n", + "\n", + "pv.OFF_SCREEN = True # render without a window, e.g. on a docs server\n", + "\n", + "bracket = (\n", + " Cuboid((0, 0, 0), (1.0, 0.3, 0.2))\n", + " | Cylinder((0.35, 0, 0.1), (0.35, 0, 0.8), 0.1) # the post\n", + " | Cylinder((-0.35, 0, 0), (-0.35, 0.45, 0), 0.08) # the peg\n", + ")\n", + "copies = GeometryCollection(\n", + " {\n", + " \"original\": bracket,\n", + " \"rotated\": bracket.rotated(90, axis=(1, 0, 0)).translated((0, 1.6, 0)),\n", + " \"scaled\": bracket.scaled((1, 1, 1.6)).translated((1.6, 0, 0)),\n", + " \"mirrored\": bracket.scaled((-1, 1, 1)).translated((1.6, 1.6, 0)),\n", + " }\n", + ")\n", + "plotter = copies.show_3d(\n", + " backend=\"pyvista\", display=False, resolution=48, show_bounds=False\n", + ")\n", + "plasma_plots.scene(\n", + " plotter,\n", + " alt=\"A bracket with a post and a peg, next to rotated, stretched and mirrored copies\",\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "6", "metadata": {}, "source": [ "## Names and overlap precedence\n", @@ -80,7 +124,7 @@ { "cell_type": "code", "execution_count": null, - "id": "building_composite_s-5", + "id": "7", "metadata": {}, "outputs": [], "source": [ @@ -93,7 +137,7 @@ }, { "cell_type": "markdown", - "id": "building_composite_s-6", + "id": "8", "metadata": {}, "source": [ "## Three-dimensional composition\n", @@ -104,7 +148,7 @@ { "cell_type": "code", "execution_count": null, - "id": "building_composite_s-7", + "id": "9", "metadata": {}, "outputs": [], "source": [ @@ -125,7 +169,50 @@ }, { "cell_type": "markdown", - "id": "building_composite_s-8", + "id": "10", + "metadata": {}, + "source": [ + "All three operations side by side: the union of three rods, the intersection of a cube and a ball (a cube with its corners rounded off), and the difference of a ball and the rods, which drills three bores through it. Turn the scene to look down a bore, or zoom in where the bores meet at the centre." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "11", + "metadata": {}, + "outputs": [], + "source": [ + "from simgeometry import Cylinder\n", + "\n", + "ball = Sphere((0, 0, 0), 1.0)\n", + "rods = (\n", + " Cylinder((-1.2, 0, 0), (1.2, 0, 0), 0.35)\n", + " | Cylinder((0, -1.2, 0), (0, 1.2, 0), 0.35)\n", + " | Cylinder((0, 0, -1.2), (0, 0, 1.2), 0.35)\n", + ")\n", + "booleans = GeometryCollection(\n", + " {\n", + " \"union\": rods,\n", + " \"intersection\": (\n", + " Cuboid((0, 0, 0), (1.6, 1.6, 1.6)) & Sphere((0, 0, 0), 1.1)\n", + " ).translated((-2.0, 2.0, 0)),\n", + " \"difference\": (ball - rods).translated((-4.0, 4.0, 0)),\n", + " },\n", + " styles={\"intersection\": {\"opacity\": 1.0}}, # opaque, to show its flat faces\n", + ")\n", + "assert not booleans[\"difference\"].contains((-4.0, 4.0, 0)) # the bores meet here\n", + "plotter = booleans.show_3d(\n", + " backend=\"pyvista\", display=False, resolution=64, opacity=0.8, show_bounds=False\n", + ")\n", + "plasma_plots.scene(\n", + " plotter,\n", + " alt=\"Three crossed rods, a cube with rounded corners, and a ball with three bores\",\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "12", "metadata": {}, "source": [ "Try an intersection between the block and a rotated box. For inverted objects, supply a finite viewing/sampling region explicitly. Bounds on composed shapes conservatively enclose their operands; they do not certify that the resulting region is nonempty." diff --git a/tutorials/conductors_and_level_sets.ipynb b/tutorials/conductors_and_level_sets.ipynb index 7bc8df5..4e893fd 100644 --- a/tutorials/conductors_and_level_sets.ipynb +++ b/tutorials/conductors_and_level_sets.ipynb @@ -189,7 +189,7 @@ "id": "12", "metadata": {}, "source": [ - "Grid-edge crossings (`bracketed_edge_geometry`, used by Picnic's embedded-boundary stencil) interpolate the level set between the two nodes of a cut edge and refine the root with a few Illinois steps, instead of bisecting `contains` 48 times. The crossings lie on the bore surface to round-off." + "Cut a slot through the focus electrode and the level set becomes a bound. Below are its levels 0 (the surface) and 0.2 (a shell outside it); `bounds` leaves room for the shell. Where the shell dips into the slot it keeps sharp corners, where the shell of the exact distance would be rounded: the difference takes the maximum of two level sets. Click **Interactive 3-D view** and zoom into the slot." ] }, { @@ -198,17 +198,48 @@ "id": "13", "metadata": {}, "outputs": [], + "source": [ + "from simgeometry import Cuboid\n", + "\n", + "slotted = focus - Cuboid((0, 1.3, 0), (0.6, 1.0, 1.0))\n", + "print(\"exact:\", slotted.level_set_exact)\n", + "field = slotted.level_set_field(resolution=64, bounds=((-2, -2, -0.8), (2, 2, 0.8)))\n", + "plotter = field.plasma.plot.isosurface(\n", + " values=[0.0, 0.2], clim=(0.0, 0.3), opacity=0.5, show_domain=False\n", + ")\n", + "plotter.view_vector((0.5, 1, 0.8)) # look at the slot\n", + "plotter.camera.zoom(1.3)\n", + "plasma_plots.scene(\n", + " plotter, alt=\"A ring electrode with a slot, and its level set 0.2 outside it\"\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "14", + "metadata": {}, + "source": [ + "Grid-edge crossings (`bracketed_edge_geometry`, used by Picnic's embedded-boundary stencil) interpolate the level set between the two nodes of a cut edge and refine the root with a few Illinois steps, instead of bisecting `contains` 48 times. The crossings lie on the bore surface to round-off." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "15", + "metadata": {}, + "outputs": [], "source": [ "from simgeometry.cut_edges import bracketed_edge_geometry\n", "\n", "edges = bracketed_edge_geometry(grid, [focus])\n", "h = np.asarray(grid.grid_spacing)\n", - "errors = []\n", + "errors, crossings = [], []\n", "for (axis, side), fraction in edges.fractions.items():\n", " cut = np.isfinite(fraction)\n", " points = edges.nodes[:, cut].copy()\n", " points[axis] += (1 if side else -1) * h[axis] * fraction[cut]\n", " errors.append(np.abs(focus.level_set(points)))\n", + " crossings.append(points)\n", "print(\n", " f\"{sum(e.size for e in errors)} cut edges, largest distance from the surface:\",\n", " f\"{max(e.max() for e in errors):.1e}\",\n", @@ -217,7 +248,36 @@ }, { "cell_type": "markdown", - "id": "14", + "id": "16", + "metadata": {}, + "source": [ + "Drawn over the translucent electrode, the crossings trace the bore, the outer wall and the end faces; each one sits on a grid line. Click **Interactive 3-D view** and zoom in on an edge to see the crossings follow its rounding." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "17", + "metadata": {}, + "outputs": [], + "source": [ + "plotter = focus.show_3d(\n", + " backend=\"pyvista\", display=False, resolution=48, opacity=0.3, show_bounds=False\n", + ")\n", + "plotter.add_points(\n", + " np.concatenate(crossings, axis=1).T,\n", + " color=\"black\",\n", + " point_size=3,\n", + " render_points_as_spheres=True,\n", + ")\n", + "plasma_plots.scene(\n", + " plotter, alt=\"The grid-edge crossings on the surface of the focus electrode\"\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "18", "metadata": {}, "source": [ "Shapes known only through their membership, such as `FunctionObject3D`, have no level set (`has_level_set` is false); every query then falls back to sampling and bisecting `contains`." diff --git a/tutorials/grid_intersections_and_cut_edges.ipynb b/tutorials/grid_intersections_and_cut_edges.ipynb index 3d0ead4..71da34d 100644 --- a/tutorials/grid_intersections_and_cut_edges.ipynb +++ b/tutorials/grid_intersections_and_cut_edges.ipynb @@ -2,12 +2,12 @@ "cells": [ { "cell_type": "markdown", - "id": "grid_intersections_a-0", + "id": "0", "metadata": {}, "source": [ "# Grid intersections and cut edges\n", "\n", - "Inspect the geometric input to a cut-cell solver: crossing positions, edge fractions, distances, object identity, and outward normals. Simgeometry computes geometry; Picnic applies its Shortley\u2013Weller coefficients, boundary conditions and numerical minimum-distance clamp.\n", + "Inspect the geometric input to a cut-cell solver: crossing positions, edge fractions, distances, object identity, and outward normals. Simgeometry computes geometry; Picnic applies its Shortley–Weller coefficients, boundary conditions and numerical minimum-distance clamp.\n", "\n", "This notebook needs `simgeometry[plot]`. It does not solve a PDE. The companion [Picnic cut-cell tutorial](https://max-models.github.io/picnic/guides/numerics/cut-cell-embedded-boundaries/) studies potential and electric-field accuracy." ] @@ -15,7 +15,7 @@ { "cell_type": "code", "execution_count": null, - "id": "grid_intersections_a-1", + "id": "1", "metadata": {}, "outputs": [], "source": [ @@ -45,7 +45,58 @@ }, { "cell_type": "markdown", - "id": "grid_intersections_a-2", + "id": "2", + "metadata": {}, + "source": [ + "`cut_edges()` works on 3D grids too. Here a ball on a 9 × 9 × 9 grid: blue points are the solid nodes, black points the crossings on the grid edges, red arrows the outward normals there. Click **Interactive 3-D view** and rotate the scene: the crossings lie on the grid lines, around the solid nodes." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "import plasma_plots\n", + "import pyvista as pv\n", + "\n", + "from simgeometry import Sphere\n", + "\n", + "pv.OFF_SCREEN = True # render without a window, e.g. on a docs server\n", + "\n", + "grid3 = Grid(\n", + " ndim=3, lower_bound=(-1, -1, -1), upper_bound=(1, 1, 1), num_gridpoints=(9, 9, 9)\n", + ")\n", + "ball = Sphere((0.03, 0.02, -0.01), 0.62)\n", + "cuts3 = ball.cut_edges(grid3)\n", + "assert cuts3.exact\n", + "np.testing.assert_allclose(\n", + " np.linalg.norm(cuts3.points - np.array([[0.03], [0.02], [-0.01]]), axis=0),\n", + " 0.62,\n", + " atol=1e-10,\n", + ")\n", + "print(\"Crossings in 3D:\", len(cuts3.fractions))\n", + "solid = grid3.node_points()[:, ball.node_mask(grid3).ravel()]\n", + "plotter = ball.show_3d(\n", + " backend=\"pyvista\", display=False, resolution=48, opacity=0.25, show_bounds=False\n", + ")\n", + "plotter.add_points(\n", + " solid.T, color=\"tab:blue\", point_size=10, render_points_as_spheres=True\n", + ")\n", + "plotter.add_points(\n", + " cuts3.points.T, color=\"black\", point_size=8, render_points_as_spheres=True\n", + ")\n", + "plotter.add_arrows(cuts3.points.T, cuts3.normals.T, mag=0.12, color=\"tab:red\")\n", + "plasma_plots.scene(\n", + " plotter,\n", + " alt=\"A ball on a 3D grid: solid nodes, grid-edge crossings and outward normals\",\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "4", "metadata": {}, "source": [ "## Solver-oriented brackets\n", @@ -58,7 +109,7 @@ { "cell_type": "code", "execution_count": null, - "id": "grid_intersections_a-3", + "id": "5", "metadata": {}, "outputs": [], "source": [ @@ -75,7 +126,7 @@ }, { "cell_type": "markdown", - "id": "grid_intersections_a-4", + "id": "6", "metadata": {}, "source": [ "## Detect a thin object between fluid nodes\n", @@ -88,7 +139,7 @@ { "cell_type": "code", "execution_count": null, - "id": "grid_intersections_a-5", + "id": "7", "metadata": {}, "outputs": [], "source": [ @@ -112,7 +163,7 @@ }, { "cell_type": "markdown", - "id": "grid_intersections_a-6", + "id": "8", "metadata": {}, "source": [ "## Approximate cell-volume and face-area fractions\n", @@ -123,7 +174,7 @@ { "cell_type": "code", "execution_count": null, - "id": "grid_intersections_a-7", + "id": "9", "metadata": {}, "outputs": [], "source": [ @@ -147,7 +198,7 @@ }, { "cell_type": "markdown", - "id": "grid_intersections_a-8", + "id": "10", "metadata": {}, "source": [ "Try moving the circle by a small fraction of one cell and compare the edge fractions. Simgeometry leaves tiny physical distances unchanged. A solver may need to regularize them to control conditioning, which is a discretization decision rather than a geometry tolerance." diff --git a/tutorials/sampling_export_and_diagnostics.ipynb b/tutorials/sampling_export_and_diagnostics.ipynb index 120fad4..27b6575 100644 --- a/tutorials/sampling_export_and_diagnostics.ipynb +++ b/tutorials/sampling_export_and_diagnostics.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "markdown", - "id": "sampling_export_and_-0", + "id": "0", "metadata": {}, "source": [ "# Sampling, export and diagnostics\n", @@ -13,7 +13,7 @@ { "cell_type": "code", "execution_count": null, - "id": "sampling_export_and_-1", + "id": "1", "metadata": {}, "outputs": [], "source": [ @@ -48,7 +48,7 @@ }, { "cell_type": "markdown", - "id": "sampling_export_and_-2", + "id": "2", "metadata": {}, "source": [ "Interior sampling uses rejection sampling with a finite draw budget. Pass a seed or NumPy Generator for reproducibility, and explicit bounds for inverted/unbounded objects. Exhausting `max_draws` raises an error instead of returning too few points.\n", @@ -58,7 +58,51 @@ }, { "cell_type": "markdown", - "id": "sampling_export_and_-3", + "id": "3", + "metadata": {}, + "source": [ + "A shape with a hole shows the two samplers best. On the left, interior samples of a torus; on the right, surface samples of the same torus, each over its translucent surface. Click **Interactive 3-D view** and turn the scene: the interior points fill the tube and leave the hole empty, while the surface points lie on the faceted mesh." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4", + "metadata": {}, + "outputs": [], + "source": [ + "import plasma_plots\n", + "import pyvista as pv\n", + "\n", + "from simgeometry import Torus\n", + "\n", + "pv.OFF_SCREEN = True # render without a window, e.g. on a docs server\n", + "\n", + "tori = GeometryCollection(\n", + " {\n", + " \"interior samples\": Torus((0, 0, 0), 0.7, 0.3),\n", + " \"surface samples\": Torus((0, 2.4, 0), 0.7, 0.3),\n", + " }\n", + ")\n", + "interior = tori[\"interior samples\"].sample_interior(1500, rng=7)\n", + "boundary = tori[\"surface samples\"].sample_surface(1000, rng=7, resolution=48)\n", + "assert tori[\"interior samples\"].contains_points(interior).all()\n", + "plotter = tori.show_3d(\n", + " backend=\"pyvista\", display=False, resolution=48, opacity=0.25, show_bounds=False\n", + ")\n", + "for points, color in ((interior, \"tab:blue\"), (boundary, \"tab:orange\")):\n", + " plotter.add_points(\n", + " points.T, color=color, point_size=5, render_points_as_spheres=True\n", + " )\n", + "plasma_plots.scene(\n", + " plotter,\n", + " alt=\"Interior samples filling a torus, and surface samples on a second torus\",\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "5", "metadata": {}, "source": [ "## Distance queries\n", @@ -69,7 +113,7 @@ { "cell_type": "code", "execution_count": null, - "id": "sampling_export_and_-4", + "id": "6", "metadata": {}, "outputs": [], "source": [ @@ -84,7 +128,7 @@ }, { "cell_type": "markdown", - "id": "sampling_export_and_-5", + "id": "7", "metadata": {}, "source": [ "## Portable mesh export\n", @@ -95,7 +139,7 @@ { "cell_type": "code", "execution_count": null, - "id": "sampling_export_and_-6", + "id": "8", "metadata": {}, "outputs": [], "source": [ @@ -113,7 +157,7 @@ }, { "cell_type": "markdown", - "id": "sampling_export_and_-7", + "id": "9", "metadata": {}, "source": [ "## Diagnose overlaps and resolution\n", @@ -124,7 +168,7 @@ { "cell_type": "code", "execution_count": null, - "id": "sampling_export_and_-8", + "id": "10", "metadata": {}, "outputs": [], "source": [ @@ -147,7 +191,7 @@ }, { "cell_type": "markdown", - "id": "sampling_export_and_-9", + "id": "11", "metadata": {}, "source": [ "Repeat the diagnostics on a finer grid, then vary the sampling used for a function-defined shape. For finite-volume experiments, compare `cell_fractions()` estimates at several subcell resolutions before using them in a conservation calculation." diff --git a/tutorials/shapes_and_node_masks.ipynb b/tutorials/shapes_and_node_masks.ipynb index fe04a14..ca0ac77 100644 --- a/tutorials/shapes_and_node_masks.ipynb +++ b/tutorials/shapes_and_node_masks.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "markdown", - "id": "136c8221", + "id": "0", "metadata": {}, "source": [ "# Shapes and node masks\n", @@ -12,16 +12,16 @@ }, { "cell_type": "markdown", - "id": "8bfdc9c8", + "id": "1", "metadata": {}, "source": [ - "The `simgeometry.objects` package includes `Line` in 1D; `Circle`, `Rectangle`, `Triangle`, `Polygon`, and apertures in 2D; and `Sphere`, `Cuboid`, and apertures in 3D. The same `contains()`, `contains_points()`, and `node_mask()` pattern works across these shapes." + "The `simgeometry.objects` package includes `Line` in 1D; `Circle`, `Rectangle`, `Triangle`, `Polygon`, and apertures in 2D; and `Sphere`, `Cuboid`, `Cylinder`, `Tube`, `Cone`, `Torus`, and apertures in 3D. The same `contains()`, `contains_points()`, and `node_mask()` pattern works across these shapes." ] }, { "cell_type": "code", "execution_count": null, - "id": "4309a56c", + "id": "2", "metadata": {}, "outputs": [], "source": [ @@ -41,7 +41,7 @@ }, { "cell_type": "markdown", - "id": "6bf7a839", + "id": "3", "metadata": {}, "source": [ "`node_mask()` is a host NumPy boolean array with the grid's shape. Use `get_indices_in_object()` when you need integer node indices instead." @@ -50,7 +50,7 @@ { "cell_type": "code", "execution_count": null, - "id": "4630a61c", + "id": "4", "metadata": {}, "outputs": [], "source": [ @@ -62,7 +62,7 @@ }, { "cell_type": "markdown", - "id": "dc450052", + "id": "5", "metadata": {}, "source": [ "Batch points are component-major: the input shape is `(ndim, N)`, with one point per column. To see where the mask lies on the grid, plot its selected nodes." @@ -71,7 +71,7 @@ { "cell_type": "code", "execution_count": null, - "id": "bc03de70", + "id": "6", "metadata": {}, "outputs": [], "source": [ @@ -87,7 +87,7 @@ }, { "cell_type": "markdown", - "id": "286e0393", + "id": "7", "metadata": {}, "source": [ "## Inverted regions\n", @@ -98,7 +98,7 @@ { "cell_type": "code", "execution_count": null, - "id": "3ff17378", + "id": "8", "metadata": {}, "outputs": [], "source": [ @@ -107,6 +107,85 @@ "print(\"corner selected:\", outside.contains((0.0, 0.0)))\n", "assert np.array_equal(outside.node_mask(grid), ~mask)" ] + }, + { + "cell_type": "markdown", + "id": "9", + "metadata": {}, + "source": [ + "## Solids in three dimensions\n", + "\n", + "Besides `Sphere` and `Cuboid`, 3D geometry has solids of revolution along any axis: `Cylinder(start, end, radius)`, `Tube(start, end, inner_radius, outer_radius)` for rings and drift tubes, `Cone(start, end, start_radius, end_radius)` for cones and frustums, and `Torus(midpoint, major_radius, minor_radius, axis=...)`. `Cylinder` and `Tube` take an optional `fillet` that rounds their circular edges.\n", + "\n", + "With `simgeometry[interactive]`, `show_3d(backend=\"pyvista\")` draws a collection with one colour per object, and `plasma_plots.scene` shows it as an interactive scene. Click **Interactive 3-D view** below the picture, then rotate it to look along the tilted cylinder and into the bore of the tube." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "10", + "metadata": {}, + "outputs": [], + "source": [ + "import plasma_plots\n", + "import pyvista as pv\n", + "\n", + "from simgeometry import Cone, Cylinder, GeometryCollection, Torus, Tube\n", + "\n", + "pv.OFF_SCREEN = True # render without a window, e.g. on a docs server\n", + "\n", + "solids = GeometryCollection(\n", + " {\n", + " \"cylinder\": Cylinder((-0.4, 0.4, -0.5), (0.4, -0.4, 0.5), 0.3),\n", + " \"tube\": Tube((2, 0, -0.5), (2, 0, 0.5), 0.35, 0.65, fillet=0.1),\n", + " \"cone\": Cone((0, 2, -0.5), (0, 2, 0.6), 0.6, 0.0),\n", + " \"torus\": Torus((2, 2, 0), 0.55, 0.18, axis=(1, 0, 1)),\n", + " }\n", + ")\n", + "assert solids[\"tube\"].contains((2.5, 0, 0))\n", + "assert not solids[\"tube\"].contains((2, 0, 0)) # the bore is empty\n", + "plotter = solids.show_3d(\n", + " backend=\"pyvista\", display=False, resolution=56, show_bounds=False\n", + ")\n", + "plasma_plots.scene(\n", + " plotter,\n", + " alt=\"A tilted cylinder, a tube with rounded edges, a cone and a tilted torus, \"\n", + " \"one colour each\",\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "11", + "metadata": {}, + "source": [ + "`node_mask()` works the same way on a 3D grid. The nodes inside a tilted torus are drawn as points over its translucent surface; rotate the view to see that they fill the tube of the torus and leave its hole empty." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "12", + "metadata": {}, + "outputs": [], + "source": [ + "grid3 = Grid(3, (-1.0, -1.0, -1.0), (1.0, 1.0, 1.0), (21, 21, 21))\n", + "torus = Torus((0, 0, 0), 0.6, 0.25, axis=(1, 0, 1))\n", + "mask3 = torus.node_mask(grid3)\n", + "print(\"selected nodes:\", mask3.sum(), \"of\", mask3.size)\n", + "plotter = torus.show_3d(\n", + " backend=\"pyvista\", display=False, resolution=48, opacity=0.3, show_bounds=False\n", + ")\n", + "plotter.add_points(\n", + " grid3.node_points()[:, mask3.ravel()].T,\n", + " color=\"black\",\n", + " point_size=6,\n", + " render_points_as_spheres=True,\n", + ")\n", + "plasma_plots.scene(\n", + " plotter, alt=\"The grid nodes inside a tilted torus, drawn inside its surface\"\n", + ")" + ] } ], "metadata": { diff --git a/tutorials/slices_and_coordinate_sampling.ipynb b/tutorials/slices_and_coordinate_sampling.ipynb index dc7267b..a9aad13 100644 --- a/tutorials/slices_and_coordinate_sampling.ipynb +++ b/tutorials/slices_and_coordinate_sampling.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "markdown", - "id": "slices_and_coordinat-0", + "id": "0", "metadata": {}, "source": [ "# Slices and coordinate sampling\n", @@ -15,7 +15,7 @@ { "cell_type": "code", "execution_count": null, - "id": "slices_and_coordinat-1", + "id": "1", "metadata": {}, "outputs": [], "source": [ @@ -40,7 +40,7 @@ }, { "cell_type": "markdown", - "id": "slices_and_coordinat-2", + "id": "2", "metadata": {}, "source": [ "## Coordinate conventions\n", @@ -53,7 +53,7 @@ { "cell_type": "code", "execution_count": null, - "id": "slices_and_coordinat-3", + "id": "3", "metadata": {}, "outputs": [], "source": [ @@ -67,7 +67,7 @@ }, { "cell_type": "markdown", - "id": "slices_and_coordinat-4", + "id": "4", "metadata": {}, "source": [ "## Query a section with a hole\n", @@ -78,7 +78,7 @@ { "cell_type": "code", "execution_count": null, - "id": "slices_and_coordinat-5", + "id": "5", "metadata": {}, "outputs": [], "source": [ @@ -95,7 +95,7 @@ }, { "cell_type": "markdown", - "id": "slices_and_coordinat-6", + "id": "6", "metadata": {}, "source": [ "## Oblique planes\n", @@ -106,7 +106,7 @@ { "cell_type": "code", "execution_count": null, - "id": "slices_and_coordinat-7", + "id": "7", "metadata": {}, "outputs": [], "source": [ @@ -124,7 +124,45 @@ }, { "cell_type": "markdown", - "id": "slices_and_coordinat-8", + "id": "8", + "metadata": {}, + "source": [ + "In 3D, the oblique plane (orange) cuts the shell in two circles, the outer and inner boundaries of the section above. The section's `to_mesh()` gives that outline in plane coordinates, and `to_world()` maps it back onto the plane. Click **Interactive 3-D view** and rotate the scene to look along the plane, or face-on through it." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9", + "metadata": {}, + "outputs": [], + "source": [ + "import plasma_plots\n", + "import pyvista as pv\n", + "\n", + "pv.OFF_SCREEN = True # render without a window, e.g. on a docs server\n", + "\n", + "outline = oblique.to_mesh(resolution=160) # line segments in plane coordinates\n", + "world = oblique.to_world(outline.vertices.T).T\n", + "plotter = shell.show_3d(\n", + " backend=\"pyvista\", display=False, resolution=56, opacity=0.3, show_bounds=False\n", + ")\n", + "plotter.add_mesh(\n", + " pv.Plane(center=(0, 0, 0.1), direction=(1, 1, 1), i_size=2.6, j_size=2.6),\n", + " color=\"tab:orange\",\n", + " opacity=0.3,\n", + ")\n", + "plotter.add_lines(world[outline.cells].reshape(-1, 3), color=\"black\", width=4)\n", + "plotter.view_vector((1, -0.3, 0.6))\n", + "plasma_plots.scene(\n", + " plotter,\n", + " alt=\"A hollow sphere cut by an oblique plane; the section's two circles drawn on it\",\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "10", "metadata": {}, "source": [ "Try moving the plane beyond the sphere: the section should be empty. Then union two separated spheres and slice both at once to inspect disconnected components. A slice and an affine transform take snapshots, so later in-place edits to the original object do not alter them." From cf51a0b64f8ec5b9df2ab08206732ff243f23f27 Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 22 Oct 2026 12:01:14 +0200 Subject: [PATCH 06/36] Add crossing points and 3D views of cut edges (#20) --- CHANGELOG.md | 8 + .../docs/getting-started/geometry-api.md | 9 +- src/simgeometry/cut_edges.py | 245 +++++++++++++++++- src/simgeometry/tests/test_cut_edges_3d.py | 148 +++++++++++ tutorials/conductors_and_level_sets.ipynb | 27 +- .../grid_intersections_and_cut_edges.ipynb | 21 +- 6 files changed, 413 insertions(+), 45 deletions(-) create mode 100644 src/simgeometry/tests/test_cut_edges_3d.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 649d8c3..e3427ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- `bracketed_edge_geometry(...).points` and `.normals`: the crossings in world coordinates + and the outward normals there, `(ndim, n)` like `cut_edges()`; `crossing_owners` indexes + each crossing's object. `BracketedEdgeGeometry` now keeps `spacing`, `periodic` and + `objects`, and `CutEdges` its `geometry`. +- `CutEdges.show_3d()` and `BracketedEdgeGeometry.show_3d()`: the translucent surface, the + crossings and, optionally, the outward normals and solid nodes in 3D (PyVista or + Matplotlib). The tutorials "Grid intersections and cut edges" and "Conductors and level + sets" use them. - The PyVista backend of `show_3d` draws the surfaces with plasma-plots' `pyvista_surfaces` (plasma-plots is now part of the `interactive` extra); `color_by="voltage"` colours a collection's conductors by their voltage at time `t`. diff --git a/docs/src/content/docs/getting-started/geometry-api.md b/docs/src/content/docs/getting-started/geometry-api.md index c4769f4..a1cdaff 100644 --- a/docs/src/content/docs/getting-started/geometry-api.md +++ b/docs/src/content/docs/getting-started/geometry-api.md @@ -246,7 +246,10 @@ grazing contacts are not crossings. `simgeometry.cut_edges.bracketed_edge_geometry()` is the batched geometry path for Picnic's Shortley–Weller stencil. It visits fluid nodes with solid neighbours and assumes a single crossing in each bracket. Fractions have no numerical lower clamp. -Picnic applies its own minimum edge fraction before assembling coefficients. +Picnic applies its own minimum edge fraction before assembling coefficients. Its +`points` and `normals` are the crossings in world coordinates and the outward normals +of the entered object, `(ndim, n)` arrays in the order of `fractions` (periodic images +wrapped into the domain); `crossing_owners` gives each crossing's object. The full `cut_edges()` inspection can detect multiple crossings and objects lying between fluid nodes. It visits every edge, so it is intended for inspection and @@ -254,6 +257,10 @@ geometry preparation rather than repeated use in a solver timestep. Collection hits include each object's interfaces, including interfaces hidden by overlaps; Boolean shapes instead expose only the resulting solid's boundary. +On a 3D grid, `show_3d()` on either result draws the translucent surface and the +crossings, with `normals=True` the outward normals and with `nodes=True` the solid grid +nodes; `backend="pyvista"` returns a PyVista plotter, otherwise Matplotlib 3D axes. + `diagnose(grid)` reports node overlaps, objects without solid nodes, multiple-hit edges, and very short edge distances. These observations do not prove that all features are resolved. See the [cut-edge tutorial](/simgeometry/tutorials/grid-intersections-and-cut-edges/) diff --git a/src/simgeometry/cut_edges.py b/src/simgeometry/cut_edges.py index 9c72eb0..ad5147e 100644 --- a/src/simgeometry/cut_edges.py +++ b/src/simgeometry/cut_edges.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass +from functools import cached_property import numpy as np import scope_profiler as sp @@ -20,6 +21,9 @@ class BracketedEdgeGeometry: with side 0 lower and 1 upper. Fractions are *not* clamped. This fast path assumes one crossing on every outside-to-inside bracket. Use ``cut_edges`` to inspect multiple hits or objects smaller than a grid cell. + + ``points`` and ``normals`` are the crossings in world coordinates, (ndim, n), + in the order of ``fractions``: key by key, each as ``f[np.isfinite(f)]``. """ solid: np.ndarray @@ -27,6 +31,180 @@ class BracketedEdgeGeometry: fractions: dict cut: np.ndarray nodes: np.ndarray + spacing: tuple + periodic: tuple + objects: tuple + + @cached_property + def _crossings(self): + ndim = self.nodes.shape[0] + lower = self.nodes.reshape(ndim, -1)[:, 0] + periods = np.asarray(self.solid.shape) * np.asarray(self.spacing) + points, owners = [np.empty((ndim, 0))], [np.empty(0, dtype=int)] + for (axis, side), fraction in self.fractions.items(): + cut = np.isfinite(fraction) + offset = 1 if side else -1 + point = self.nodes[:, cut] + point[axis] += offset * self.spacing[axis] * fraction[cut] + points.append(point) + owners.append(np.roll(self.owners, -offset, axis=axis)[cut]) + points = _wrap(np.concatenate(points, axis=1), lower, periods, self.periodic) + return points, np.concatenate(owners) + + @property + def points(self): + """Crossing points in world coordinates, (ndim, n); periodic images wrapped.""" + return self._crossings[0] + + @property + def crossing_owners(self): + """Index into ``objects`` of the object each crossing enters, (n,).""" + return self._crossings[1] + + @cached_property + def normals(self): + """Outward unit normals of the entered object at ``points``, (ndim, n).""" + points, owners = self._crossings + normals = np.full_like(points, np.nan) + for number, obj in enumerate(self.objects): + mine = owners == number + if mine.any(): + normals[:, mine] = obj.surface_normal(points[:, mine]) + return normals + + def show_3d(self, **kwargs): + """Draw the objects, the crossings and optionally normals and solid nodes. + + Takes the keyword arguments of :meth:`CutEdges.show_3d`; several objects + are drawn as one collection. + """ + from simgeometry.collection import GeometryCollection + + if len(self.objects) == 1: + shape = self.objects[0] + else: + shape = GeometryCollection(list(self.objects)) + return _show_crossings_3d( + shape, + self.points, + self.normals if kwargs.get("normals") else None, + self.nodes[:, self.solid], + self.spacing, + **kwargs, + ) + + +def _wrap(points, lower, periods, periodic): + """Map points across periodic faces into the domain.""" + points = points.copy() + for axis, flag in enumerate(periodic): + if flag: + offset = (points[axis] - lower[axis]) % periods[axis] + # a node's image across the face, built as node + step, can land a + # rounding error short of the period; it is the first node + offset[periods[axis] - offset <= 1e-9 * periods[axis]] = 0.0 + points[axis] = lower[axis] + offset + return points + + +def _show_crossings_3d( + shape, + points, + normal_vectors, + solid_nodes, + spacing, + *, + normals=False, + nodes=False, + surface=True, + backend="matplotlib", + ax=None, + resolution=48, + bounds=None, + opacity=0.25, + labels=True, + point_size=None, + normal_length=None, + display=True, + save=None, +): + from simgeometry.visualization import _finish, show_3d + + if points.shape[0] != 3: + raise ValueError("show_3d requires a three-dimensional grid") + if backend not in ("matplotlib", "pyvista"): + raise ValueError("backend must be matplotlib or pyvista") + length = 0.5 * min(spacing) if normal_length is None else float(normal_length) + if normals: + finite = np.all(np.isfinite(normal_vectors), axis=0) + points_n, normal_vectors = points[:, finite], normal_vectors[:, finite] + if surface: + ax = show_3d( + shape, + ax=ax, + backend=backend, + resolution=resolution, + bounds=bounds, + opacity=opacity, + labels=labels, + show_bounds=False, + display=False, + ) + if backend == "pyvista": + if ax is None: + from importlib import import_module + + try: + pv = import_module("pyvista") + except ImportError as exc: + raise ImportError( + "Interactive 3D needs simgeometry[interactive]" + ) from exc + ax = pv.Plotter(off_screen=not display) + if nodes and solid_nodes.shape[1]: + ax.add_points( + solid_nodes.T, + color="tab:blue", + point_size=10 if point_size is None else 1.25 * point_size, + render_points_as_spheres=True, + name="solid nodes", + ) + if points.shape[1]: + ax.add_points( + points.T, + color="black", + point_size=8 if point_size is None else point_size, + render_points_as_spheres=True, + name="crossings", + ) + if normals and points_n.shape[1]: + ax.add_arrows( + points_n.T, + normal_vectors.T, + mag=length, + color="tab:red", + name="normals", + ) + if display or save: + ax.show(screenshot=str(save) if save else None, auto_close=False) + return ax + import matplotlib.pyplot as plt + + ax = ax if ax is not None else plt.figure().add_subplot(projection="3d") + if not hasattr(ax, "set_zlabel"): + raise ValueError("Pass 3D axes to show_3d") + size = 12 if point_size is None else point_size + if nodes and solid_nodes.shape[1]: + ax.scatter(*solid_nodes, s=size, color="tab:blue", alpha=0.5) + ax.scatter(*points, s=size, color="black") + if normals and points_n.shape[1]: + ax.quiver( + *points_n, + *normal_vectors, + length=length, + color="tab:red", + ) + return _finish(ax, display, save) @sp.profile("simgeometry.bracketed_edge_geometry", tags=_PROFILE_TAGS) @@ -73,15 +251,7 @@ def bracketed_edge_geometry( periods = np.asarray(shape) * spacing def wrap(points): - points = points.copy() - for axis, flag in enumerate(periodic): - if flag: - offset = (points[axis] - lower[axis]) % periods[axis] - # a node's image across the face, built as node + step, can land a - # rounding error short of the period; it is the first node - offset[periods[axis] - offset <= 1e-9 * periods[axis]] = 0.0 - points[axis] = lower[axis] + offset - return points + return _wrap(points, lower, periods, periodic) fractions, cut = {}, np.zeros(shape, dtype=bool) for dim in range(grid.ndim): @@ -116,7 +286,9 @@ def wrap(points): ) fractions[dim, side] = fraction cut |= np.isfinite(fraction) - return BracketedEdgeGeometry(solid, owners, fractions, cut, nodes) + return BracketedEdgeGeometry( + solid, owners, fractions, cut, nodes, spacing, periodic, tuple(objects) + ) @dataclass(frozen=True) @@ -137,6 +309,7 @@ class CutEdges: normals: np.ndarray object_names: tuple exact: bool + geometry: object @property def distances(self): @@ -197,6 +370,57 @@ def show( plt.show() return ax + def show_3d( + self, + *, + normals=False, + nodes=False, + surface=True, + backend="matplotlib", + ax=None, + resolution=48, + bounds=None, + opacity=0.25, + labels=True, + point_size=None, + normal_length=None, + display=True, + save=None, + ): + """Draw the translucent surface, the crossings, and optional normals and nodes. + + Crossings are black, outward normals red arrows (``normal_length``, default + half a grid spacing) and the solid grid nodes blue. ``surface``, + ``resolution``, ``bounds``, ``opacity`` and ``labels`` go to ``show_3d``. + Returns the 3D axes or, for ``backend="pyvista"``, the plotter, which the + caller should close; its actors ``"crossings"``, ``"normals"`` and + ``"solid nodes"`` hold the points and arrows. + """ + nodes_inside = np.empty((self.grid.ndim, 0)) + if nodes: + solid = self.geometry.node_mask(self.grid).ravel() + nodes_inside = self.grid.node_points()[:, solid] + return _show_crossings_3d( + self.geometry, + self.points, + self.normals, + nodes_inside, + tuple(float(h) for h in self.grid.grid_spacing), + normals=normals, + nodes=nodes, + surface=surface, + backend=backend, + ax=ax, + resolution=resolution, + bounds=bounds, + opacity=opacity, + labels=labels, + point_size=point_size, + normal_length=normal_length, + display=display, + save=save, + ) + @sp.profile("simgeometry.cut_edges", tags=_PROFILE_TAGS) def cut_edges(geometry, grid, *, samples=64, tolerance=1e-10): @@ -271,6 +495,7 @@ def contains(self, p): np.array([r[4] for r in records]).reshape(n, grid.ndim).T, tuple(r[5] for r in records), exact, + geometry, ) diff --git a/src/simgeometry/tests/test_cut_edges_3d.py b/src/simgeometry/tests/test_cut_edges_3d.py new file mode 100644 index 0000000..5be9ab6 --- /dev/null +++ b/src/simgeometry/tests/test_cut_edges_3d.py @@ -0,0 +1,148 @@ +"""Crossing points of bracketed edges and 3D views of cut edges.""" + +import numpy as np +import pytest + +from simgeometry import Grid, Rectangle, Sphere, Tube +from simgeometry.cut_edges import bracketed_edge_geometry + +CENTRE = np.array([0.03, 0.02, -0.01]) +RADIUS = 0.62 + + +def _grid3(): + return Grid( + ndim=3, + lower_bound=(-1, -1, -1), + upper_bound=(1, 1, 1), + num_gridpoints=(9, 9, 9), + ) + + +def _ball(): + return Sphere(tuple(CENTRE), RADIUS) + + +def test_bracketed_points_lie_on_the_sphere_with_outward_normals(): + edges = bracketed_edge_geometry(_grid3(), [_ball()]) + count = sum(np.isfinite(f).sum() for f in edges.fractions.values()) + assert edges.points.shape == edges.normals.shape == (3, count) and count > 0 + radial = edges.points - CENTRE[:, None] + np.testing.assert_allclose(np.linalg.norm(radial, axis=0), RADIUS, atol=1e-12) + np.testing.assert_allclose(np.linalg.norm(edges.normals, axis=0), 1.0) + np.testing.assert_allclose(np.sum(edges.normals * radial, axis=0), RADIUS) + assert np.all(edges.crossing_owners == 0) + + +def test_bracketed_points_follow_the_fraction_order(): + grid = _grid3() + edges = bracketed_edge_geometry(grid, [_ball()]) + h = np.asarray(grid.grid_spacing) + start = 0 + for (axis, side), fraction in edges.fractions.items(): + cut = np.isfinite(fraction) + expected = edges.nodes[:, cut].copy() + expected[axis] += (1 if side else -1) * h[axis] * fraction[cut] + stop = start + cut.sum() + np.testing.assert_allclose(edges.points[:, start:stop], expected) + start = stop + assert start == edges.points.shape[1] + + +def test_bracketed_normals_belong_to_the_entered_object(): + grid = _grid3() + left, right = Sphere((-0.5, 0, 0), 0.35), Sphere((0.5, 0, 0), 0.3) + edges = bracketed_edge_geometry(grid, [left, right]) + for number, ball in enumerate((left, right)): + mine = edges.crossing_owners == number + assert mine.any() + radial = edges.points[:, mine] - np.asarray(ball.midpoint)[:, None] + np.testing.assert_allclose(np.linalg.norm(radial, axis=0), ball.radius) + assert np.all(np.sum(edges.normals[:, mine] * radial, axis=0) > 0) + + +def test_bracketed_points_on_a_level_set_lie_on_the_surface(): + grid = Grid( + ndim=3, + lower_bound=(-2, -2, -1), + upper_bound=(2, 2, 1), + num_gridpoints=(17, 17, 9), + ) + tube = Tube((0, 0, -0.4), (0, 0, 0.4), 1.0, 1.6, fillet=0.1) + edges = bracketed_edge_geometry(grid, [tube]) + assert edges.points.shape[1] > 0 + assert np.abs(tube.level_set(edges.points)).max() < 1e-10 + outward = tube.level_set(edges.points + 1e-3 * edges.normals) + assert np.all(outward > 0) + + +def test_periodic_crossings_are_wrapped_into_the_domain(): + grid = Grid( + ndim=2, + lower_bound=(0, -1), + upper_bound=(1, 1), + num_gridpoints=(4, 3), + periodic=(True, False), + ) + edges = bracketed_edge_geometry(grid, [Rectangle((0, 0), (0.2, 1))]) + assert edges.points.shape[0] == 2 + assert np.all((edges.points[0] >= 0) & (edges.points[0] < 1)) + seam = edges.fractions[0, 1][-1, 1] # from node 0.75 to its image of node 0 + assert seam == pytest.approx(1.0) + start = np.isfinite(edges.fractions[0, 0]).sum() + np.testing.assert_allclose(edges.points[:, start], [0.0, 0.0]) + + +def test_show_3d_needs_three_dimensions(): + grid = Grid(ndim=2, lower_bound=(-1, -1), upper_bound=(1, 1), num_gridpoints=(5, 5)) + edges = bracketed_edge_geometry(grid, [Rectangle((-0.3, -0.3), (0.6, 0.6))]) + with pytest.raises(ValueError, match="three-dimensional"): + edges.show_3d(display=False) + + +def test_cut_edges_show_3d_pyvista_actors(tmp_path): + pytest.importorskip("pyvista") + pytest.importorskip("plasma_plots") + cuts = _ball().cut_edges(_grid3()) + viewer = cuts.show_3d( + backend="pyvista", + normals=True, + nodes=True, + resolution=16, + display=False, + save=tmp_path / "cuts.png", + ) + try: + assert {"crossings", "normals", "solid nodes"} <= set(viewer.actors) + crossings = viewer.actors["crossings"].mapper.dataset + np.testing.assert_allclose(crossings.points, cuts.points.T) + assert len(viewer.meshes) >= 1 # the surface + assert (tmp_path / "cuts.png").stat().st_size > 0 + finally: + viewer.close() + + +def test_bracketed_show_3d_pyvista_without_surface(): + pytest.importorskip("pyvista") + edges = bracketed_edge_geometry(_grid3(), [_ball()]) + viewer = edges.show_3d(backend="pyvista", surface=False, display=False) + try: + assert set(viewer.actors) == {"crossings"} + finally: + viewer.close() + + +def test_show_3d_matplotlib_scatter_and_quiver(): + matplotlib = pytest.importorskip("matplotlib") + pytest.importorskip("skimage") + matplotlib.use("Agg") + import matplotlib.pyplot as plt + + edges = bracketed_edge_geometry(_grid3(), [_ball(), Sphere((0.7, 0.7, 0.7), 0.2)]) + try: + ax = edges.show_3d(normals=True, nodes=True, resolution=16, display=False) + assert ax.name == "3d" + # two surfaces, the solid nodes, the crossings and the normals + assert len(ax.collections) == 5 + finally: + plt.close("all") diff --git a/tutorials/conductors_and_level_sets.ipynb b/tutorials/conductors_and_level_sets.ipynb index 4e893fd..1c6400d 100644 --- a/tutorials/conductors_and_level_sets.ipynb +++ b/tutorials/conductors_and_level_sets.ipynb @@ -219,7 +219,7 @@ "id": "14", "metadata": {}, "source": [ - "Grid-edge crossings (`bracketed_edge_geometry`, used by Picnic's embedded-boundary stencil) interpolate the level set between the two nodes of a cut edge and refine the root with a few Illinois steps, instead of bisecting `contains` 48 times. The crossings lie on the bore surface to round-off." + "Grid-edge crossings (`bracketed_edge_geometry`, used by Picnic's embedded-boundary stencil) interpolate the level set between the two nodes of a cut edge and refine the root with a few Illinois steps, instead of bisecting `contains` 48 times. `edges.points` holds the crossings in world coordinates (`edges.normals` the outward normals there); they lie on the bore surface to round-off." ] }, { @@ -232,17 +232,10 @@ "from simgeometry.cut_edges import bracketed_edge_geometry\n", "\n", "edges = bracketed_edge_geometry(grid, [focus])\n", - "h = np.asarray(grid.grid_spacing)\n", - "errors, crossings = [], []\n", - "for (axis, side), fraction in edges.fractions.items():\n", - " cut = np.isfinite(fraction)\n", - " points = edges.nodes[:, cut].copy()\n", - " points[axis] += (1 if side else -1) * h[axis] * fraction[cut]\n", - " errors.append(np.abs(focus.level_set(points)))\n", - " crossings.append(points)\n", + "errors = np.abs(focus.level_set(edges.points))\n", "print(\n", - " f\"{sum(e.size for e in errors)} cut edges, largest distance from the surface:\",\n", - " f\"{max(e.max() for e in errors):.1e}\",\n", + " f\"{errors.size} cut edges, largest distance from the surface:\",\n", + " f\"{errors.max():.1e}\",\n", ")" ] }, @@ -251,7 +244,7 @@ "id": "16", "metadata": {}, "source": [ - "Drawn over the translucent electrode, the crossings trace the bore, the outer wall and the end faces; each one sits on a grid line. Click **Interactive 3-D view** and zoom in on an edge to see the crossings follow its rounding." + "`edges.show_3d()` draws the crossings over the translucent electrode: they trace the bore, the outer wall and the end faces; each one sits on a grid line. Click **Interactive 3-D view** and zoom in on an edge to see the crossings follow its rounding." ] }, { @@ -261,15 +254,7 @@ "metadata": {}, "outputs": [], "source": [ - "plotter = focus.show_3d(\n", - " backend=\"pyvista\", display=False, resolution=48, opacity=0.3, show_bounds=False\n", - ")\n", - "plotter.add_points(\n", - " np.concatenate(crossings, axis=1).T,\n", - " color=\"black\",\n", - " point_size=3,\n", - " render_points_as_spheres=True,\n", - ")\n", + "plotter = edges.show_3d(backend=\"pyvista\", display=False, opacity=0.3, point_size=3)\n", "plasma_plots.scene(\n", " plotter, alt=\"The grid-edge crossings on the surface of the focus electrode\"\n", ")" diff --git a/tutorials/grid_intersections_and_cut_edges.ipynb b/tutorials/grid_intersections_and_cut_edges.ipynb index 71da34d..a081e97 100644 --- a/tutorials/grid_intersections_and_cut_edges.ipynb +++ b/tutorials/grid_intersections_and_cut_edges.ipynb @@ -48,7 +48,7 @@ "id": "2", "metadata": {}, "source": [ - "`cut_edges()` works on 3D grids too. Here a ball on a 9 × 9 × 9 grid: blue points are the solid nodes, black points the crossings on the grid edges, red arrows the outward normals there. Click **Interactive 3-D view** and rotate the scene: the crossings lie on the grid lines, around the solid nodes." + "`cut_edges()` works on 3D grids too, and `cuts.show_3d()` draws the crossings over the translucent surface. Here a ball on a 9 × 9 × 9 grid: black points are the crossings on the grid edges, blue points the solid nodes (`nodes=True`), red arrows the outward normals there (`normals=True`). Click **Interactive 3-D view** and rotate the scene: the crossings lie on the grid lines, around the solid nodes." ] }, { @@ -77,17 +77,7 @@ " atol=1e-10,\n", ")\n", "print(\"Crossings in 3D:\", len(cuts3.fractions))\n", - "solid = grid3.node_points()[:, ball.node_mask(grid3).ravel()]\n", - "plotter = ball.show_3d(\n", - " backend=\"pyvista\", display=False, resolution=48, opacity=0.25, show_bounds=False\n", - ")\n", - "plotter.add_points(\n", - " solid.T, color=\"tab:blue\", point_size=10, render_points_as_spheres=True\n", - ")\n", - "plotter.add_points(\n", - " cuts3.points.T, color=\"black\", point_size=8, render_points_as_spheres=True\n", - ")\n", - "plotter.add_arrows(cuts3.points.T, cuts3.normals.T, mag=0.12, color=\"tab:red\")\n", + "plotter = cuts3.show_3d(backend=\"pyvista\", normals=True, nodes=True, display=False)\n", "plasma_plots.scene(\n", " plotter,\n", " alt=\"A ball on a 3D grid: solid nodes, grid-edge crossings and outward normals\",\n", @@ -101,7 +91,7 @@ "source": [ "## Solver-oriented brackets\n", "\n", - "`bracketed_edge_geometry()` batches the outside-to-inside edges adjacent to solid nodes. It returns grid-shaped arrays keyed by `(axis, side)`, where side 0 means lower and side 1 means upper; uncut entries are NaN. Fractions are raw and unclamped.\n", + "`bracketed_edge_geometry()` batches the outside-to-inside edges adjacent to solid nodes. It returns grid-shaped arrays keyed by `(axis, side)`, where side 0 means lower and side 1 means upper; uncut entries are NaN. Fractions are raw and unclamped. `brackets.points` and `brackets.normals` are the crossings in world coordinates and the outward normals there, `(ndim, n)` arrays like those of `cut_edges()`, and `brackets.show_3d()` draws them on a 3D grid.\n", "\n", "This path assumes one crossing per bracket. It matches the geometry used by Picnic's current stencil assembly. Its periodic option tests wrapped coordinates. It does not detect an object that lies entirely between fluid nodes." ] @@ -116,6 +106,11 @@ "brackets = bracketed_edge_geometry(grid, [circle])\n", "fractions = np.concatenate([f[np.isfinite(f)] for f in brackets.fractions.values()])\n", "assert np.all((fractions >= 0) & (fractions <= 1))\n", + "np.testing.assert_allclose(\n", + " np.linalg.norm(brackets.points - np.array([[0.03], [0.02]]), axis=0),\n", + " 0.37,\n", + " atol=1e-10,\n", + ")\n", "print(\"Fluid nodes adjacent to a solid:\", brackets.cut.sum())\n", "print(\"Smallest raw edge fraction:\", fractions.min())\n", "plt.hist(fractions, bins=12, edgecolor=\"black\")\n", From 7d9440343c7d3153c9196ead56aa7cd1fd9e5664 Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 22 Oct 2026 12:18:08 +0200 Subject: [PATCH 07/36] Preview a domain decomposition without a communicator (#23) * Add DomainDecomposition.preview with views of the subdomains * Skip the Compute_dims comparison without an MPI library --- .github/workflows/docs.yml | 1 + CHANGELOG.md | 5 + README.md | 19 ++ README.qmd | 18 ++ docs/src/content/docs/index.mdx | 2 +- src/simgeometry/README.md | 2 +- src/simgeometry/_decomposition_views.py | 181 +++++++++++++++ src/simgeometry/domain_decomposition.py | 218 +++++++++++++++++- .../tests/test_domain_decomposition.py | 213 +++++++++++++++++ tutorials/parallel_grids.ipynb | 93 +++++++- 10 files changed, 738 insertions(+), 14 deletions(-) create mode 100644 src/simgeometry/_decomposition_views.py diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 1e30fb1..b461881 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -46,3 +46,4 @@ jobs: tutorials/sampling-export-and-diagnostics/2.html tutorials/grid-intersections-and-cut-edges/2.html tutorials/conductors-and-level-sets/4.html + tutorials/parallel-grids/1.html diff --git a/CHANGELOG.md b/CHANGELOG.md index e3427ac..119cbfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- `DomainDecomposition.preview(size, decompose=..., periodic=..., rank=...)` (or + `proc_sizes=`): the decomposition a `size`-rank run would use, without a communicator, for + inspection and planning; `subdomain_boxes(...)` returns every rank's subdomain corners, + `show_3d(...)` draws them as boxes coloured by rank (plasma-plots, optional `shape=`) and + `show(...)` as rectangles in 2D. The tutorial "Parallel grids" shows an 8-rank scene. - `bracketed_edge_geometry(...).points` and `.normals`: the crossings in world coordinates and the outward normals there, `(ndim, n)` like `cut_edges()`; `crossing_owners` indexes each crossing's object. `BracketedEdgeGeometry` now keeps `spacing`, `periodic` and diff --git a/README.md b/README.md index 5914211..10c8eb0 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,25 @@ directly. The [conductors tutorial](https://max-models.github.io/simgeometry/tutorials/conductors-and-level-sets/) builds an Einzel lens. +## Preview a domain decomposition + +``` python +from simgeometry import Sphere +from simgeometry.domain_decomposition import DomainDecomposition + +preview = DomainDecomposition.preview(8, decompose=[True, True, True]) +preview.proc_sizes # [2, 2, 2], as on 8 MPI ranks +boxes = preview.subdomain_boxes((0, 0, 0), (2, 1, 1), (33, 17, 17)) +preview.show_3d((0, 0, 0), (2, 1, 1), (33, 17, 17), shape=Sphere((1, 0.5, 0.5), 0.35)) +``` + +`DomainDecomposition.preview(size)` computes the split a `size`-rank run +would use without a communicator, for inspection and planning: index +ranges, subdomains and owner ranks for every rank. `show_3d` draws one +box per rank (with `simgeometry[interactive]`) and `show` one rectangle +per rank of a 2D decomposition. See the [parallel grids +tutorial](https://max-models.github.io/simgeometry/tutorials/parallel-grids/). + ## Profiling simgeometry ships diff --git a/README.qmd b/README.qmd index 4578dc8..d6dfbda 100644 --- a/README.qmd +++ b/README.qmd @@ -77,6 +77,24 @@ refine it with a few secant steps instead of bisecting `contains`, and [conductors tutorial](https://max-models.github.io/simgeometry/tutorials/conductors-and-level-sets/) builds an Einzel lens. +## Preview a domain decomposition + +```python +from simgeometry import Sphere +from simgeometry.domain_decomposition import DomainDecomposition + +preview = DomainDecomposition.preview(8, decompose=[True, True, True]) +preview.proc_sizes # [2, 2, 2], as on 8 MPI ranks +boxes = preview.subdomain_boxes((0, 0, 0), (2, 1, 1), (33, 17, 17)) +preview.show_3d((0, 0, 0), (2, 1, 1), (33, 17, 17), shape=Sphere((1, 0.5, 0.5), 0.35)) +``` + +`DomainDecomposition.preview(size)` computes the split a `size`-rank run would use +without a communicator, for inspection and planning: index ranges, subdomains and +owner ranks for every rank. `show_3d` draws one box per rank (with +`simgeometry[interactive]`) and `show` one rectangle per rank of a 2D decomposition. +See the [parallel grids tutorial](https://max-models.github.io/simgeometry/tutorials/parallel-grids/). + ## Profiling simgeometry ships [scope-profiler](https://github.com/max-models/scope-profiler) diff --git a/docs/src/content/docs/index.mdx b/docs/src/content/docs/index.mdx index d6d4e5c..e6df316 100644 --- a/docs/src/content/docs/index.mdx +++ b/docs/src/content/docs/index.mdx @@ -21,7 +21,7 @@ import { Card, CardGrid, LinkCard } from '@astrojs/starlight/components'; 1D, 2D, and 3D objects with point containment and grid node masks. Structured grids and bounding boxes with periodic axes. - MPI domain decomposition for structured grids. + MPI domain decomposition for structured grids, with a preview of any rank count to inspect and draw. ## Start here diff --git a/src/simgeometry/README.md b/src/simgeometry/README.md index 839f9b7..5314835 100644 --- a/src/simgeometry/README.md +++ b/src/simgeometry/README.md @@ -21,7 +21,7 @@ values; applications can configure their own seeded stream. - `__init__.py` - Package exports for geometry, grids, and simulation domains. - `boundingbox.py` - Boundingbox helpers for geometry, grids, and simulation domains. - `plotting.py` - Optional Matplotlib drawing for shapes and domains. -- `domain_decomposition.py` - The process grid of the MPI ranks (`DomainDecomposition`): neighbours, the physical subdomain and the marker owner of each rank, and `layout()`, the `mpiarray.Layout` of a field on the same grid. +- `domain_decomposition.py` - The process grid of the MPI ranks (`DomainDecomposition`): neighbours, the physical subdomain and the marker owner of each rank, and `layout()`, the `mpiarray.Layout` of a field on the same grid. `DomainDecomposition.preview(size)` builds the split of a `size`-rank run without a communicator, and `show_3d()`/`show()` draw its subdomains. - `grid.py` - Grid helpers for geometry, grids, and simulation domains. - `crossings.py` - `crossings()`: the first boundary crossing of many segments at once, for particle pushers. - `panels.py` - `boundary_panels()`: the straight edges of a 2D outline with lengths and normals, for boundary-element solvers. diff --git a/src/simgeometry/_decomposition_views.py b/src/simgeometry/_decomposition_views.py new file mode 100644 index 0000000..52955b0 --- /dev/null +++ b/src/simgeometry/_decomposition_views.py @@ -0,0 +1,181 @@ +"""Views of a domain decomposition: one box (3D) or rectangle (2D) per rank. + +Used by :meth:`DomainDecomposition.show_3d` and :meth:`DomainDecomposition.show`. +Nothing here communicates; the boxes come from ``subdomain_boxes``. +""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + +_MAX_LEGEND_RANKS = 16 + + +def _rank_colors(size: int) -> list[tuple[float, float, float]]: + """Return one distinct colour per rank: tab10 up to 10 ranks, else tab20/turbo.""" + from matplotlib import colormaps + + if size <= 10: + cmap = colormaps["tab10"] + return [tuple(cmap(i)[:3]) for i in range(size)] + if size <= 20: + cmap = colormaps["tab20"] + return [tuple(cmap(i)[:3]) for i in range(size)] + cmap = colormaps["turbo"] + return [tuple(cmap(i / (size - 1))[:3]) for i in range(size)] + + +def _objects(shape: Any) -> dict[str, Any]: + """Return the named objects of a collection, or the shape itself.""" + return dict(getattr(shape, "named_objects", None) or {"shape": shape}) + + +def show_decomposition_3d( + decomposition: Any, + lower_bound: Any, + upper_bound: Any, + num_gridpoints: Any, + *, + shape: Any = None, + opacity: float = 0.25, + shape_opacity: float = 1.0, + resolution: int = 48, + legend: bool | None = None, + plotter: Any = None, + display: bool = True, + save: Any = None, +) -> Any: + """Draw the subdomains of a 3D decomposition with plasma-plots; return the plotter.""" + if decomposition.ndim != 3: + raise ValueError("show_3d needs a three-dimensional decomposition") + try: + import pyvista as pv + from plasma_plots.pyvista_plots import pyvista_surfaces + except ImportError as exc: + raise ImportError("Interactive 3D needs simgeometry[interactive]") from exc + + boxes = decomposition.subdomain_boxes(lower_bound, upper_bound, num_gridpoints) + if legend is None: + legend = len(boxes) <= _MAX_LEGEND_RANKS + meshes = {} + for rank, (lower, upper) in enumerate(boxes): + box = pv.Box( + bounds=(lower[0], upper[0], lower[1], upper[1], lower[2], upper[2]), + quads=True, + ) + box.point_data["rank"] = np.full(box.n_points, rank) + meshes[f"rank {rank}"] = box + if plotter is None: + plotter = pv.Plotter(off_screen=not display) + pyvista_surfaces( + meshes, + colors=None if not legend else _rank_colors(len(boxes)), + scalars=None if legend else "rank", + cmap="turbo", + opacity=opacity, + show_edges=True, + legend=legend, + plotter=plotter, + ) + if shape is not None: + if shape.ndim != 3: + raise ValueError("shape must be three-dimensional") + bounds = (np.asarray(lower_bound, float), np.asarray(upper_bound, float)) + shapes = { + name: obj.to_mesh( + resolution=resolution, + bounds=bounds if getattr(obj, "inverted", False) else None, + ) + for name, obj in _objects(shape).items() + } + pyvista_surfaces( + shapes, + colors=["0.55"] * len(shapes), + opacity=shape_opacity, + legend=False, + plotter=plotter, + ) + plotter.show_bounds( + bounds=np.array([lower_bound, upper_bound], dtype=float).T.ravel(), + location="outer", + xtitle="x", + ytitle="y", + ztitle="z", + n_xlabels=3, + n_ylabels=3, + n_zlabels=3, + fmt="%.2g", + ) + # x to the right, y into the picture and z up, so rank 0 is in front. + plotter.view_vector((1.0, -1.0, 1.0)) + plotter.reset_camera() + plotter.camera.zoom(0.9) + if legend and plotter.legend is not None: + plotter.legend.SetPosition(0.82, 0.70) # clear of the boxes + if display or save: + plotter.show(screenshot=str(save) if save else None, auto_close=False) + return plotter + + +def show_decomposition_2d( + decomposition: Any, + lower_bound: Any, + upper_bound: Any, + num_gridpoints: Any, + *, + shape: Any = None, + ax: Any = None, + opacity: float = 0.35, + labels: bool = True, + display: bool = True, + save: Any = None, +) -> Any: + """Draw the subdomains of a 2D decomposition with Matplotlib; return the axes.""" + if decomposition.ndim != 2: + raise ValueError("show needs a two-dimensional decomposition") + import matplotlib.pyplot as plt + from matplotlib.patches import Rectangle + + from simgeometry.visualization import _finish + + if ax is None: + ax = plt.figure(layout="constrained").add_subplot() + boxes = decomposition.subdomain_boxes(lower_bound, upper_bound, num_gridpoints) + colors = _rank_colors(len(boxes)) + for rank, ((x0, y0), (x1, y1)) in enumerate(boxes): + ax.add_patch( + Rectangle( + (x0, y0), + x1 - x0, + y1 - y0, + facecolor=(*colors[rank], opacity), + edgecolor=colors[rank], + linewidth=1.5, + ) + ) + if labels: + ax.text( + (x0 + x1) / 2, + (y0 + y1) / 2, + f"rank {rank}", + ha="center", + va="center", + fontsize="small", + ) + if shape is not None: + if shape.ndim != 2: + raise ValueError("shape must be two-dimensional") + for obj in _objects(shape).values(): + closed = getattr(obj, "closed", True) + for loop in obj.outlines(): + if closed: + loop = np.concatenate([loop, loop[:, :1]], axis=1) + ax.plot(*loop, color="black", linewidth=1.2, solid_capstyle="round") + ax.set_xlim(lower_bound[0], upper_bound[0]) + ax.set_ylim(lower_bound[1], upper_bound[1]) + ax.set_aspect("equal") + ax.set_xlabel("x") + ax.set_ylabel("y") + return _finish(ax, display, save) diff --git a/src/simgeometry/domain_decomposition.py b/src/simgeometry/domain_decomposition.py index 6c77368..ddb0605 100644 --- a/src/simgeometry/domain_decomposition.py +++ b/src/simgeometry/domain_decomposition.py @@ -4,6 +4,10 @@ particles (:meth:`DomainDecomposition.owner_ranks`) and, through :meth:`DomainDecomposition.layout`, for the :class:`mpiarray.DistributedArray` of the fields, so that markers and fields agree on who owns what. + +:meth:`DomainDecomposition.preview` builds the decomposition of a parallel run +without a communicator, for inspection and planning; :meth:`DomainDecomposition.show_3d` +and :meth:`DomainDecomposition.show` draw its subdomains. """ from __future__ import annotations @@ -235,14 +239,105 @@ def __init__( periodic: tuple[bool, ...] | None = None, ndim: int | None = None, ) -> None: - """Initialize the process grid of ``comm`` for the decomposed axes.""" - self._comm = comm - if self.comm is None: - self._mpi_size = 1 - self._mpi_rank = 0 + """Initialize the process grid of ``comm`` for the decomposed axes. + + Without a communicator (``comm=None``) there is a single process. To + inspect the split of a parallel run without one, use :meth:`preview`. + """ + if comm is None: + size, rank = 1, 0 else: - self._mpi_size = comm.Get_size() - self._mpi_rank = comm.Get_rank() + size, rank = comm.Get_size(), comm.Get_rank() + self._setup(comm, size, rank, decompose, periodic, ndim) + + @classmethod + def preview( + cls, + size: int | None = None, + *, + proc_sizes: Sequence[int] | None = None, + decompose: Sequence[bool] | None = None, + periodic: Sequence[bool] | None = None, + ndim: int | None = None, + rank: int = 0, + ) -> DomainDecomposition: + """Return the decomposition a ``size``-rank run would use, without a communicator. + + This is for inspection and planning, e.g. to draw the subdomains in + the docs (:meth:`show_3d`, :meth:`show`) or to check the local array + sizes of a run before launching it. Nothing communicates: the result + has no communicator (``comm`` is ``None``) and :meth:`layout` raises. + + With ``size``, the processes per axis are chosen exactly as for a + communicator of that size, so ``proc_sizes``, the index ranges and the + subdomains are those of the real run. Alternatively, ``proc_sizes`` + fixes the processes per axis directly (the axes with more than one are + then the decomposed ones, unless ``decompose`` says otherwise). + + The decomposition is seen from ``rank`` (``mpi_rank``, + ``neighbour_ranks``, ``proc_coord``, ``owned_mask``). The read-only + queries that take a rank, or return one per position, work for every + rank: :meth:`get_index_bounds`, :meth:`subdomain_bounds`, + :meth:`subdomain_boxes`, :meth:`get_proc_coord`, :meth:`owner_ranks`. + + Example: + >>> preview = DomainDecomposition.preview(8, decompose=[True] * 3) + >>> preview.proc_sizes + [2, 2, 2] + >>> preview.get_index_bounds((9, 9, 9), rank=7) + [(5, 9), (5, 9), (5, 9)] + """ + if proc_sizes is not None: + proc_sizes = [int(p) for p in proc_sizes] + if any(p < 1 for p in proc_sizes): + raise ValueError(f"proc_sizes must be positive, got {proc_sizes}") + if ndim is not None and ndim != len(proc_sizes): + raise ValueError(f"proc_sizes {proc_sizes} do not have {ndim} axes") + ndim = len(proc_sizes) + if decompose is None: + decompose = [p > 1 for p in proc_sizes] + if any(p > 1 and not d for p, d in zip(proc_sizes, decompose)): + raise ValueError( + f"proc_sizes {proc_sizes} split an axis that decompose " + f"{list(decompose)} does not" + ) + total = math.prod(proc_sizes) + if size is None: + size = total + elif total not in (1, size): + raise ValueError(f"proc_sizes {proc_sizes} do not multiply to {size}") + if size is None: + raise ValueError("Pass size or proc_sizes") + if size < 1: + raise ValueError(f"size must be positive, got {size}") + if not 0 <= rank < size: + raise ValueError(f"rank {rank} is not in [0, {size})") + decomposition = cls.__new__(cls) + decomposition._setup( + None, + int(size), + int(rank), + None if decompose is None else list(decompose), + None if periodic is None else tuple(periodic), + ndim, + proc_sizes, + ) + return decomposition + + def _setup( + self, + comm: MPI.Comm | None, + size: int, + rank: int, + decompose: list[bool] | None, + periodic: tuple[bool, ...] | None, + ndim: int | None, + proc_sizes: list[int] | None = None, + ) -> None: + """Build the process grid of ``size`` ranks, as seen from ``rank``.""" + self._comm = comm + self._mpi_size = size + self._mpi_rank = rank assert ndim is not None or decompose is not None, ( "DomainDecomposition must take either ndim or decompose!" @@ -263,7 +358,10 @@ def __init__( self._periodic = tuple(periodic) assert len(self.periodic) == self.ndim - self._proc_sizes = self._calculate_proc_sizes() + if proc_sizes is None: + self._proc_sizes = self._calculate_proc_sizes() + else: + self._proc_sizes = list(proc_sizes) # With no decomposed axis the process grid has a single slot and every # rank holds the whole domain; all ranks then share process coordinate 0. @@ -299,6 +397,11 @@ def layout( raise ValueError(f"shape {shape} has fewer than {self.ndim} axes") if periodic is None: periodic = (*self.periodic, *([False] * extra)) + if self.is_preview: + raise ValueError( + "A preview has no communicator, so it has no array layout; use " + "get_index_bounds() or node_cuts() for the index ranges" + ) comm = MPI.COMM_SELF if self.comm is None else self.comm if self.replicated: # No axis is split: every rank holds the whole array. @@ -413,6 +516,100 @@ def subdomain_bounds( upper.append(edges[proc_coord[dim] + 1]) return tuple(lower), tuple(upper) + def subdomain_boxes( + self, + lower_bound: Sequence[float], + upper_bound: Sequence[float], + num_gridpoints: Sequence[int], + ) -> list[tuple[tuple[float, ...], tuple[float, ...]]]: + """Return the physical ``(lower, upper)`` corners of every rank's subdomain. + + Entry ``r`` is :meth:`subdomain_bounds` of rank ``r``, for all + ``mpi_size`` ranks. The boxes tile the domain without gaps or overlaps + (when no axis is decomposed, every rank holds the whole domain). + """ + self._check_grid(lower_bound, upper_bound, num_gridpoints) + grid = (tuple(lower_bound), tuple(upper_bound), tuple(num_gridpoints)) + return [self.subdomain_bounds(rank, *grid) for rank in range(self.mpi_size)] + + def show_3d( + self, + lower_bound: Sequence[float], + upper_bound: Sequence[float], + num_gridpoints: Sequence[int], + *, + shape: Any = None, + opacity: float = 0.25, + shape_opacity: float = 1.0, + resolution: int = 48, + legend: bool | None = None, + plotter: Any = None, + display: bool = True, + save: str | None = None, + ) -> Any: + """Draw every rank's subdomain as a translucent box, coloured by rank. + + Needs the ``interactive`` extra (PyVista and plasma-plots). The boxes + are drawn with ``plasma_plots.pyvista_plots.pyvista_surfaces``, one + mesh per rank labelled ``"rank r"``, with their edges. ``shape`` (a 3D + shape or collection) is drawn inside them. ``legend=None`` shows the + rank legend for up to 16 ranks and colours by a ``rank`` scalar bar + above that. Returns the ``pyvista.Plotter``; it is shown only with + ``display=True``, and ``save`` writes a screenshot. Combine it with + :meth:`preview` to see the split of a parallel run; no MPI operation is + performed. + """ + from simgeometry._decomposition_views import show_decomposition_3d + + return show_decomposition_3d( + self, + lower_bound, + upper_bound, + num_gridpoints, + shape=shape, + opacity=opacity, + shape_opacity=shape_opacity, + resolution=resolution, + legend=legend, + plotter=plotter, + display=display, + save=save, + ) + + def show( + self, + lower_bound: Sequence[float], + upper_bound: Sequence[float], + num_gridpoints: Sequence[int], + *, + shape: Any = None, + ax: Any = None, + opacity: float = 0.35, + labels: bool = True, + display: bool = True, + save: str | None = None, + ) -> Any: + """Draw a 2D decomposition with Matplotlib: one rectangle per rank. + + Each subdomain is filled in its rank's colour and labelled with its + rank (``labels``); ``shape`` (a 2D shape or collection) is outlined on + top. Returns the Matplotlib axes. No MPI operation is performed. + """ + from simgeometry._decomposition_views import show_decomposition_2d + + return show_decomposition_2d( + self, + lower_bound, + upper_bound, + num_gridpoints, + shape=shape, + ax=ax, + opacity=opacity, + labels=labels, + display=display, + save=save, + ) + def _check_grid( self, lower_bound: Sequence[float], @@ -590,6 +787,11 @@ def decompose(self) -> list[bool]: """Return flags indicating decomposed axes.""" return self._decompose + @property + def is_preview(self) -> bool: + """Return whether this is a :meth:`preview` of several ranks, without a communicator.""" + return self.comm is None and self.mpi_size > 1 + @property def replicated(self) -> bool: """Return whether every rank holds the whole domain (no decomposed axis).""" diff --git a/src/simgeometry/tests/test_domain_decomposition.py b/src/simgeometry/tests/test_domain_decomposition.py index a799c0e..633718b 100644 --- a/src/simgeometry/tests/test_domain_decomposition.py +++ b/src/simgeometry/tests/test_domain_decomposition.py @@ -337,3 +337,216 @@ def test_owner_ranks_mpi4(periodic: bool) -> None: ) in_box = np.all((points >= 0) & (points < np.array([1.0, 2.0])[:, None]), axis=0) assert np.all(mine[inside & in_box]) + + +# --- Preview without a communicator ---------------------------------------- + + +class _FakeComm: + """Just enough of a communicator for ``DomainDecomposition``: a size and a rank.""" + + def __init__(self, size: int, rank: int) -> None: + self._size, self._rank = size, rank + + def Get_size(self) -> int: + return self._size + + def Get_rank(self) -> int: + return self._rank + + +PREVIEW_CASES = [ + (1, [True, True, True]), + (4, [True, True]), + (6, [True, True, True]), + (7, [True, True, True]), + (8, [True, True, True]), + (12, [True, True, False]), + (12, [False, True, True]), + (16, [True]), + (4, [False, False]), +] + + +def test_preview_matches_the_mpi_test_expectations() -> None: + """The splits the 4- and 6-rank MPI tests expect, without the ranks.""" + assert DomainDecomposition.preview(6, decompose=[True] * 3).proc_sizes == [2, 1, 3] + assert DomainDecomposition.preview(4, decompose=[True] * 2).proc_sizes == [2, 2] + assert DomainDecomposition.preview(8, ndim=3).proc_sizes == [2, 2, 2] + + +@pytest.mark.parametrize("periodic", [False, True]) +@pytest.mark.parametrize(("size", "decompose"), PREVIEW_CASES) +def test_preview_is_the_split_of_a_real_run( + size: int, decompose: list[bool], periodic: bool +) -> None: + """Seen from every rank, a preview equals the decomposition of a size-rank communicator.""" + shape = tuple(11 + 2 * axis for axis in range(len(decompose))) + flags = [periodic] * len(decompose) + for rank in range(size): + real = DomainDecomposition( + _FakeComm(size, rank), decompose=decompose, periodic=flags + ) + preview = DomainDecomposition.preview( + size, decompose=decompose, periodic=flags, rank=rank + ) + assert preview.comm is None and preview.is_preview == (size > 1) + assert (preview.mpi_size, preview.mpi_rank) == (size, rank) + assert preview.proc_sizes == real.proc_sizes + assert preview.replicated == real.replicated + assert preview.proc_coord == real.proc_coord + assert preview.neighbour_ranks == real.neighbour_ranks + assert preview.node_cuts(shape) == real.node_cuts(shape) + for other in range(size): + assert preview.get_index_bounds(shape, other) == real.get_index_bounds( + shape, other + ) + + +def test_preview_matches_mpi_compute_dims_for_balanced_sizes() -> None: + """Where the balanced split is unique, it is also what MPI.Compute_dims picks.""" + try: + from mpi4py import MPI + except (ImportError, RuntimeError): # RuntimeError: mpi4py without an MPI library + pytest.skip("needs mpi4py with an MPI library") + for size, ndim in [(4, 2), (8, 3), (9, 2), (27, 3), (16, 2)]: + preview = DomainDecomposition.preview(size, ndim=ndim) + assert preview.proc_sizes == list(MPI.Compute_dims(size, ndim)) + + +def test_preview_with_processes_per_axis() -> None: + """``proc_sizes`` fixes the process grid; the split axes are the decomposed ones.""" + preview = DomainDecomposition.preview(proc_sizes=[3, 1, 2], rank=5) + assert preview.mpi_size == 6 and preview.proc_sizes == [3, 1, 2] + assert preview.decompose == [True, False, True] + assert preview.proc_coord == (2, 0, 1) + assert preview.get_index_bounds((9, 4, 6)) == [(6, 9), (0, 4), (3, 6)] + with pytest.raises(ValueError, match="multiply"): + DomainDecomposition.preview(4, proc_sizes=[3, 1]) + with pytest.raises(ValueError, match="decompose"): + DomainDecomposition.preview(proc_sizes=[2, 2], decompose=[True, False]) + with pytest.raises(ValueError, match="rank"): + DomainDecomposition.preview(4, ndim=2, rank=4) + with pytest.raises(ValueError, match="size or proc_sizes"): + DomainDecomposition.preview(ndim=2) + + +def test_preview_has_no_layout() -> None: + """A preview never communicates, so it refuses to build an array layout.""" + with pytest.raises(ValueError, match="preview"): + DomainDecomposition.preview(4, ndim=2).layout((8, 8)) + # One process is an ordinary serial decomposition. + assert DomainDecomposition.preview(1, ndim=2).layout((8, 8)).local_shape == (8, 8) + + +GRIDS = [ + (8, [True, True, True], (0.0, 0.0, 0.0), (2.0, 1.0, 1.0), (33, 17, 17)), + (6, [True, True], (-1.0, 0.0), (1.0, 3.0), (10, 31)), + (12, [True, True, False], (0.0, 0.0, 0.0), (1.0, 1.0, 1.0), (9, 13, 5)), + (5, [True], (0.0,), (1.0,), (21,)), +] + + +@pytest.mark.parametrize("periodic", [False, True]) +@pytest.mark.parametrize(("size", "decompose", "lower", "upper", "nodes"), GRIDS) +def test_subdomain_boxes_tile_the_domain( + size, decompose, lower, upper, nodes, periodic +) -> None: + """The boxes cover the domain once: their volumes add up and no two overlap.""" + preview = DomainDecomposition.preview( + size, decompose=decompose, periodic=[periodic] * len(decompose) + ) + boxes = preview.subdomain_boxes(lower, upper, nodes) + assert len(boxes) == size + assert boxes[3 % size] == preview.subdomain_bounds(3 % size, lower, upper, nodes) + lo = np.array([box[0] for box in boxes]) + hi = np.array([box[1] for box in boxes]) + assert np.all(hi > lo) + assert np.all(lo >= np.array(lower)) and np.all(hi <= np.array(upper)) + assert np.prod(hi - lo, axis=1).sum() == pytest.approx( + np.prod(np.array(upper) - np.array(lower)) + ) + for a in range(size): + for b in range(a + 1, size): + overlap = np.minimum(hi[a], hi[b]) - np.maximum(lo[a], lo[b]) + assert np.any(overlap <= 0), f"ranks {a} and {b} overlap" + + +@pytest.mark.parametrize(("size", "decompose", "lower", "upper", "nodes"), GRIDS) +def test_owner_ranks_agree_with_the_boxes(size, decompose, lower, upper, nodes) -> None: + """A point inside a rank's box is owned by that rank, and by its mask only.""" + preview = DomainDecomposition.preview(size, decompose=decompose) + boxes = preview.subdomain_boxes(lower, upper, nodes) + rng = np.random.default_rng(5) + points = rng.uniform( + np.array(lower)[:, None], np.array(upper)[:, None], (len(lower), 500) + ) + owners = preview.owner_ranks(points, lower, upper, nodes) + for rank, (lo, hi) in enumerate(boxes): + inside = np.all( + (points >= np.array(lo)[:, None]) & (points < np.array(hi)[:, None]), + axis=0, + ) + assert np.all(owners[inside] == rank) + centre = (np.array(lo) + np.array(hi)) / 2 + assert preview.owner_rank_for_position(centre, lower, upper, nodes) == rank + mine = DomainDecomposition.preview(size, decompose=decompose, rank=rank) + assert ( + mine.owned_mask(points, lower, upper, nodes).tolist() + == (owners == rank).tolist() + ) + + +def test_decomposition_show_3d_returns_a_plotter(tmp_path) -> None: + """The 3D view draws one box per rank (and the shape) with plasma-plots.""" + pv = pytest.importorskip("pyvista") + pytest.importorskip("plasma_plots") + pytest.importorskip("skimage") + from simgeometry import Sphere + + preview = DomainDecomposition.preview(8, ndim=3) + plotter = preview.show_3d( + (0.0, 0.0, 0.0), + (2.0, 1.0, 1.0), + (17, 9, 9), + shape=Sphere((1.0, 0.5, 0.5), 0.3), + resolution=12, + display=False, + save=tmp_path / "ranks.png", + ) + try: + assert isinstance(plotter, pv.Plotter) + assert len(plotter.meshes) == 9 + assert plotter.legend is not None + assert (tmp_path / "ranks.png").stat().st_size > 0 + finally: + plotter.close() + # Many ranks are coloured by a rank scalar bar instead of a legend. + plotter = DomainDecomposition.preview(64, ndim=3).show_3d( + (0.0, 0.0, 0.0), (1.0, 1.0, 1.0), (9, 9, 9), display=False + ) + try: + assert len(plotter.meshes) == 64 and "rank" in plotter.scalar_bars + finally: + plotter.close() + with pytest.raises(ValueError, match="three-dimensional"): + DomainDecomposition.preview(4, ndim=2).show_3d((0, 0), (1, 1), (5, 5)) + + +def test_decomposition_show_draws_one_rectangle_per_rank() -> None: + """The 2D Matplotlib view: a rectangle and a label per rank, the shape on top.""" + matplotlib = pytest.importorskip("matplotlib") + matplotlib.use("Agg") + import matplotlib.pyplot as plt + + from simgeometry import Circle + + ax = DomainDecomposition.preview(6, ndim=2).show( + (0.0, 0.0), (2.0, 1.0), (17, 9), shape=Circle((1.0, 0.5), 0.3), display=False + ) + try: + assert len(ax.patches) == 6 + assert [t.get_text() for t in ax.texts] == [f"rank {r}" for r in range(6)] + assert len(ax.lines) == 1 + finally: + plt.close("all") diff --git a/tutorials/parallel_grids.ipynb b/tutorials/parallel_grids.ipynb index 71f4961..4508e82 100644 --- a/tutorials/parallel_grids.ipynb +++ b/tutorials/parallel_grids.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "markdown", - "id": "a7bdc3f4", + "id": "0", "metadata": {}, "source": [ "# Parallel grids\n", @@ -12,7 +12,7 @@ }, { "cell_type": "markdown", - "id": "e0234470", + "id": "1", "metadata": {}, "source": [ "## Decomposition metadata\n", @@ -23,7 +23,7 @@ { "cell_type": "code", "execution_count": null, - "id": "0d53d106", + "id": "2", "metadata": {}, "outputs": [], "source": [ @@ -46,7 +46,7 @@ }, { "cell_type": "markdown", - "id": "38d582a5", + "id": "3", "metadata": {}, "source": [ "For a multi-process run, install `simgeometry[mpi]`, pass `comm=MPI.COMM_WORLD`, and launch the script with `mpiexec -n 4 python script.py`:\n", @@ -69,6 +69,91 @@ "\n", "`proc_index_bounds` contains half-open global index ranges. `neighbor_ranks` gives lower and upper neighbors per axis, using `MPI.PROC_NULL` at nonperiodic walls. `grid.domain_decomposition_class.layout(shape, halo=...)` returns a matching `mpiarray.Layout` for field arrays." ] + }, + { + "cell_type": "markdown", + "id": "4", + "metadata": {}, + "source": [ + "## Preview a decomposition\n", + "\n", + "Without a communicator there is one process, so the grid above is never split. `DomainDecomposition.preview(size, ...)` builds the decomposition a `size`-rank run would use, without MPI: the same processes per axis, index ranges and subdomains, for every rank. It is for inspection and planning; nothing communicates, and it has no array layout. `proc_sizes=` fixes the processes per axis instead of `size`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5", + "metadata": {}, + "outputs": [], + "source": [ + "from simgeometry.domain_decomposition import DomainDecomposition\n", + "\n", + "lower, upper, nodes = (0.0, 0.0, 0.0), (2.0, 1.0, 1.0), (33, 17, 17)\n", + "preview = DomainDecomposition.preview(8, decompose=[True, True, True])\n", + "print(\"process grid:\", preview.proc_sizes)\n", + "for rank in range(preview.mpi_size):\n", + " print(\n", + " f\"rank {rank}: coordinate {preview.get_proc_coord(rank)},\",\n", + " f\"nodes {preview.get_index_bounds(nodes, rank)}\",\n", + " )\n", + "print(\n", + " \"owner of (1.5, 0.2, 0.9):\",\n", + " preview.owner_rank_for_position((1.5, 0.2, 0.9), lower, upper, nodes),\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "6", + "metadata": {}, + "source": [ + "`subdomain_boxes(lower, upper, num_gridpoints)` returns every rank's `(lower, upper)` corners. `show_3d` draws them as translucent boxes, one colour per rank, with an optional `shape` inside (click **Interactive 3-D view** below the picture to rotate it). Rank 0 sits at the lower corner; ranks are numbered row-major, so the last axis changes fastest." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7", + "metadata": {}, + "outputs": [], + "source": [ + "import plasma_plots\n", + "import pyvista as pv\n", + "\n", + "from simgeometry import Sphere\n", + "\n", + "pv.OFF_SCREEN = True # render without a window, e.g. on a docs server\n", + "\n", + "plotter = preview.show_3d(\n", + " lower, upper, nodes, shape=Sphere((1.0, 0.5, 0.5), 0.35), display=False\n", + ")\n", + "plasma_plots.scene(\n", + " plotter, alt=\"Eight subdomains, one colour per rank, around a sphere\"\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "8", + "metadata": {}, + "source": [ + "A 2D decomposition has a Matplotlib view, `show`: one rectangle per rank, with a shape outlined on top. Six ranks on a 2D grid make a 2 × 3 process grid." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9", + "metadata": {}, + "outputs": [], + "source": [ + "from simgeometry import Circle\n", + "\n", + "DomainDecomposition.preview(6, ndim=2).show(\n", + " (0.0, 0.0), (2.0, 1.0), (33, 17), shape=Circle((1.0, 0.5), 0.3)\n", + ");" + ] } ], "metadata": { From e5e0560a53e5228e067cbf3dd6fe19b285c878ed Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 22 Oct 2026 12:28:13 +0200 Subject: [PATCH 08/36] Add Section.outlines and Section.show_3d (#21) --- CHANGELOG.md | 4 + .../docs/getting-started/geometry-api.md | 8 ++ src/simgeometry/mesh.py | 52 +++++--- src/simgeometry/operations.py | 53 ++++++++ src/simgeometry/tests/test_section_3d.py | 120 ++++++++++++++++++ src/simgeometry/visualization.py | 92 ++++++++++++++ .../slices_and_coordinate_sampling.ipynb | 19 ++- 7 files changed, 321 insertions(+), 27 deletions(-) create mode 100644 src/simgeometry/tests/test_section_3d.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 119cbfb..2f9aa2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- `Section.outlines(max_spacing, resolution=, bounds=, world=)`: the sampled boundary loops of a + plane section in plane coordinates (holes clockwise), or on the plane in 3D with + `world=True`; `Section.show_3d()` draws the sliced shape, the cutting plane and the outline in + 3D (Matplotlib or PyVista). The tutorial "Slices and coordinate sampling" uses it. - `DomainDecomposition.preview(size, decompose=..., periodic=..., rank=...)` (or `proc_sizes=`): the decomposition a `size`-rank run would use, without a communicator, for inspection and planning; `subdomain_boxes(...)` returns every rank's subdomain corners, diff --git a/docs/src/content/docs/getting-started/geometry-api.md b/docs/src/content/docs/getting-started/geometry-api.md index a1cdaff..a789a68 100644 --- a/docs/src/content/docs/getting-started/geometry-api.md +++ b/docs/src/content/docs/getting-started/geometry-api.md @@ -17,6 +17,7 @@ shell = Sphere((0, 0, 0), 1) - Sphere((0, 0, 0), 0.4) shell.show_3d(opacity=0.5) shell.show(z=0.2) shell.slice(origin=(0, 0, 0), normal=(1, 1, 1)).show() +shell.slice(origin=(0, 0, 0), normal=(1, 1, 1)).show_3d(backend="pyvista") ``` ## Coordinates and return values @@ -58,6 +59,13 @@ Axis-aligned sections retain remaining world-coordinate names. For example, `sphere.slice(x=0).evaluate(y=ys, z=zs)` samples a yz plane. Oblique sections use local x/y plane coordinates. `section.to_world()` and `section.to_local()` map between the plane and world space; the latter is an orthogonal projection. +`section.outlines()` returns the section's boundary loops as `(2, n)` arrays in +plane coordinates, sampled at `resolution` points per axis (outer boundaries +counter-clockwise, holes clockwise, as for 2D Boolean shapes); +`outlines(world=True)` maps them onto the plane as `(3, n)` arrays. +`section.show_3d()` draws the section in context: the sliced shape translucently, +the cutting plane over the shape's bounds and the outline on it +(`backend="pyvista"` for an interactive view). ### Construction and ownership diff --git a/src/simgeometry/mesh.py b/src/simgeometry/mesh.py index 6ba00ef..711c6c5 100644 --- a/src/simgeometry/mesh.py +++ b/src/simgeometry/mesh.py @@ -85,14 +85,8 @@ def save(self, path): return path -@sp.profile("simgeometry.to_mesh", tags=_PROFILE_TAGS) -def to_mesh(shape, *, resolution=64, bounds=None): - if shape.ndim not in (2, 3): - raise ValueError("Surface meshing requires 2D or 3D geometry") - try: - from skimage.measure import find_contours, marching_cubes - except ImportError as exc: - raise ImportError("Surface meshing requires simgeometry[mesh]") from exc +def _sampled_values(shape, resolution, bounds): + """The sampling axes and spacing, and values positive inside the shape on them.""" from simgeometry.queries import level_values if bounds is None: @@ -109,18 +103,37 @@ def to_mesh(shape, *, resolution=64, bounds=None): values = -level_values(shape, points) else: values = mask(shape, points).astype(float) - 0.5 - values = values.reshape(dimensions) + return axes, spacing, values.reshape(dimensions) + + +def _find_contours(shape, *, resolution=64, bounds=None): + """The sampled boundary of a 2D shape as ``(n, 2)`` polylines, and the spacing. + + Each polyline keeps the region on its left; a closed one repeats its first + vertex at the end. Polylines touching the sampling bounds stay open. + """ + try: + from skimage.measure import find_contours + except ImportError as exc: + raise ImportError("Surface meshing requires simgeometry[mesh]") from exc + axes, spacing, values = _sampled_values(shape, resolution, bounds) if not (values.min() < 0 < values.max()): - return SurfaceMesh( - np.empty((0, shape.ndim)), np.empty((0, shape.ndim), dtype=int), spacing - ) + return [], spacing + start = np.array([a[0] for a in axes]) + contours = find_contours(values, 0, positive_orientation="high") + return [c * np.asarray(spacing) + start for c in contours], spacing + + +@sp.profile("simgeometry.to_mesh", tags=_PROFILE_TAGS) +def to_mesh(shape, *, resolution=64, bounds=None): + if shape.ndim not in (2, 3): + raise ValueError("Surface meshing requires 2D or 3D geometry") if shape.ndim == 2: + contours, spacing = _find_contours(shape, resolution=resolution, bounds=bounds) vertices, cells = [], [] offset = 0 - for contour in find_contours(values, 0, positive_orientation="high"): - vertices.extend( - contour * np.asarray(spacing) + np.array([a[0] for a in axes]) - ) + for contour in contours: + vertices.extend(contour) cells.extend((offset + i, offset + i + 1) for i in range(len(contour) - 1)) offset += len(contour) return SurfaceMesh( @@ -128,6 +141,13 @@ def to_mesh(shape, *, resolution=64, bounds=None): np.asarray(cells, dtype=int).reshape(-1, 2), spacing, ) + try: + from skimage.measure import marching_cubes + except ImportError as exc: + raise ImportError("Surface meshing requires simgeometry[mesh]") from exc + axes, spacing, values = _sampled_values(shape, resolution, bounds) + if not (values.min() < 0 < values.max()): + return SurfaceMesh(np.empty((0, 3)), np.empty((0, 3), dtype=int), spacing) vertices, faces, _, _ = marching_cubes( values, level=0, diff --git a/src/simgeometry/operations.py b/src/simgeometry/operations.py index d00b9cf..dc6f6e7 100644 --- a/src/simgeometry/operations.py +++ b/src/simgeometry/operations.py @@ -455,3 +455,56 @@ def evaluate(self, *args, quantity=None, **kwargs): @property def names(self): return getattr(self.source, "names", ("inside",)) + + def outlines(self, max_spacing=None, *, resolution=128, bounds=None, world=False): + """The sampled boundary loops of a plane section, as ``(2, n)`` host arrays. + + Loops follow ``Shape2D.outlines``: no repeated first vertex, and the region on + their left, so outer boundaries are counter-clockwise and holes clockwise (a + hollow sphere gives one of each). They come from contouring the section on + ``resolution`` samples per axis across ``bounds`` (default: the section's + bounds, padded by 5%), on the level set where the source has one, so vertices + lie on the boundary up to the sampling accuracy. A loop cut by ``bounds`` stays + open. ``max_spacing`` (a length, or a function of ``(2, m)`` plane points) + splits longer edges evenly, as for Boolean outlines. ``world=True`` maps the + loops onto the plane in 3D, as ``(3, n)`` arrays. Requires the ``mesh`` extra; + sections of 2D shapes (lines) have no outlines. + """ + loops = self._sampled_loops(max_spacing, resolution, bounds) + return [self.to_world(loop) if world else loop for loop, _ in loops] + + def _sampled_loops(self, max_spacing, resolution, bounds): + """``(loop, closed)`` pairs of :meth:`outlines`, in plane coordinates.""" + from simgeometry.mesh import _find_contours + + if self.ndim != 2: + raise NotImplementedError("outlines() needs a 2D section of a 3D shape") + if ( + max_spacing is not None + and not callable(max_spacing) + and not max_spacing > 0 + ): + raise ValueError("max_spacing must be positive") + contours, _ = _find_contours(self, resolution=resolution, bounds=bounds) + loops = [] + for contour in contours: + closed = len(contour) > 3 and np.array_equal(contour[0], contour[-1]) + loop = contour[:-1] if closed else contour + loop = loop[np.any(np.diff(loop, axis=0, prepend=np.nan) != 0, axis=1)] + if len(loop) < 2: + continue + loop = np.ascontiguousarray(loop.T) + if max_spacing is not None: + loop = _refine_loop(loop, max_spacing, closed=closed) + loops.append((loop, closed)) + return loops + + def show_3d(self, **kwargs): + """The section in 3D: its source, the cutting plane and the section outline. + + See :func:`simgeometry.visualization.show_section_3d`; returns Matplotlib + axes, or a PyVista plotter for ``backend='pyvista'``. + """ + from simgeometry.visualization import show_section_3d + + return show_section_3d(self, **kwargs) diff --git a/src/simgeometry/tests/test_section_3d.py b/src/simgeometry/tests/test_section_3d.py new file mode 100644 index 0000000..f28fdb1 --- /dev/null +++ b/src/simgeometry/tests/test_section_3d.py @@ -0,0 +1,120 @@ +"""Outlines of plane sections, in plane and world coordinates, and their 3D view.""" + +import numpy as np +import pytest + +pytest.importorskip("skimage") + +from simgeometry import Cuboid, Sphere +from simgeometry.shapes import _signed_area + + +def _shell(): + return Sphere((0, 0, 0), 1) - Sphere((0, 0, 0), 0.55) + + +def test_oblique_section_of_a_sphere_is_a_circle_in_the_plane(): + origin, normal = np.array([0.1, -0.2, 0.3]), np.array([1.0, 2.0, -0.5]) + section = Sphere((0, 0, 0), 1).slice(origin=origin, normal=normal) + unit = normal / np.linalg.norm(normal) + offset = float(origin @ unit) + centre, radius = offset * unit, np.sqrt(1 - offset**2) + + (loop,) = section.outlines(world=True) + assert loop.shape[0] == 3 and loop.shape[1] > 100 + np.testing.assert_allclose(unit @ loop, offset, atol=1e-12) + np.testing.assert_allclose( + np.linalg.norm(loop - centre[:, None], axis=0), radius, atol=2e-3 + ) + (local,) = section.outlines() + np.testing.assert_allclose(section.to_world(local), loop) + assert _signed_area(local) > 0 + + +def test_section_of_a_shell_has_an_outer_loop_and_a_hole(): + outer, hole = sorted(_shell().slice(z=0.2).outlines(), key=_signed_area)[::-1] + assert _signed_area(outer) > 0 > _signed_area(hole) + np.testing.assert_allclose(np.hypot(*outer), np.sqrt(1 - 0.04), atol=2e-3) + np.testing.assert_allclose(np.hypot(*hole), np.sqrt(0.55**2 - 0.04), atol=2e-3) + + +def test_axis_aligned_section_keeps_the_remaining_axes(): + box = Cuboid((1, 0.5, 1.5), (2, 1, 3)) # [0, 2] x [0, 1] x [0, 3] + section = box.slice(y=0.5) + assert section.axis_names == ("x", "z") + (loop,) = section.outlines(max_spacing=0.05) + assert np.all(np.hypot(*np.diff(loop, axis=1)) <= 0.05 + 1e-12) + np.testing.assert_allclose(loop.min(axis=1), (0, 0), atol=0.05) + np.testing.assert_allclose(loop.max(axis=1), (2, 3), atol=0.05) + (world,) = section.outlines(world=True) + np.testing.assert_allclose(world[1], 0.5) + + +def test_sections_missing_the_shape_or_of_2d_shapes_have_no_outlines(): + assert ( + Sphere((0, 0, 0), 1).slice(origin=(0, 0, 2), normal=(0, 0, 1)).outlines() == [] + ) + from simgeometry import Circle + + with pytest.raises(NotImplementedError, match="2D section"): + Circle((0, 0), 1).slice(x=0).outlines() + with pytest.raises(ValueError, match="positive"): + Sphere((0, 0, 0), 1).slice(z=0).outlines(max_spacing=0) + + +def test_show_3d_draws_the_shape_the_plane_and_the_outline(tmp_path): + pytest.importorskip("pyvista") + pytest.importorskip("plasma_plots") + section = _shell().slice(origin=(0, 0, 0.1), normal=(1, 1, 1)) + viewer = section.show_3d( + backend="pyvista", + resolution=16, + outline_resolution=48, + display=False, + save=tmp_path / "section.png", + ) + try: + plane = viewer.actors["section plane"].mapper.dataset + outline = viewer.actors["section outline"].mapper.dataset + assert plane.n_cells == 1 and outline.n_lines == 2 + normal = np.ones(3) / np.sqrt(3) + for points in (plane.points, outline.points): + np.testing.assert_allclose(points @ normal, 0.1 / np.sqrt(3), atol=1e-12) + surfaces = [ + actor.mapper.dataset + for name, actor in viewer.actors.items() + if not name.startswith("section") + and hasattr(getattr(actor, "mapper", None), "dataset") + ] + assert any(getattr(s, "n_faces_strict", 0) > 100 for s in surfaces) + assert (tmp_path / "section.png").stat().st_size > 0 + finally: + viewer.close() + + +def test_show_3d_with_matplotlib(tmp_path): + matplotlib = pytest.importorskip("matplotlib") + matplotlib.use("Agg") + import matplotlib.pyplot as plt + + ax = ( + Sphere((0, 0, 0), 1) + .slice(z=0.3) + .show_3d( + resolution=12, outline_resolution=32, display=False, save=tmp_path / "s.png" + ) + ) + try: + assert hasattr(ax, "set_zlabel") + (line,) = ax.lines + np.testing.assert_allclose(line.get_data_3d()[2], 0.3) + assert (tmp_path / "s.png").stat().st_size > 0 + finally: + plt.close("all") + + +def test_show_3d_needs_a_plane_section(): + from simgeometry import Circle + + with pytest.raises(ValueError, match="plane section"): + Circle((0, 0), 1).slice(x=0).show_3d(display=False) diff --git a/src/simgeometry/visualization.py b/src/simgeometry/visualization.py index 0c5e97c..d0528fc 100644 --- a/src/simgeometry/visualization.py +++ b/src/simgeometry/visualization.py @@ -413,3 +413,95 @@ def _grid_lines_3d(grid): a[axis], b[axis] = grid.lower_bound[axis], grid.upper_bound[axis] segments.append([a, b]) return np.asarray(segments) + + +def show_section_3d( + section, + *, + ax=None, + resolution=48, + outline_resolution=160, + bounds=None, + opacity=0.3, + plane_color="tab:orange", + plane_opacity=0.3, + outline_color="black", + outline_width=None, + backend="matplotlib", + display=True, + save=None, + **kwargs, +): + """Draw a plane section in 3D: its source, the cutting plane and the outline. + + The source is drawn translucently through :func:`show_3d` (``opacity``, + ``resolution`` and the other keyword arguments go there), the plane as a + rectangle covering the source's bounds (or ``bounds``), projected onto it, + and ``section.outlines(world=True)``, sampled at ``outline_resolution``, as + lines on the plane (``outline_width`` defaults to 4 pixels in PyVista, 2 points + in Matplotlib). Returns Matplotlib axes, or a PyVista plotter owned by the + caller for ``backend='pyvista'``. + """ + source = section.source + if section.ndim != 2 or source.ndim != 3: + raise ValueError("show_3d of a section needs a plane section of a 3D shape") + lower, upper = bounds or (source.lower_bound, source.upper_bound) + box = np.array(list(product(*zip(lower, upper))), float).T + local = section.basis.T @ (box - section.origin[:, None]) + (u0, v0), (u1, v1) = local.min(axis=1), local.max(axis=1) + plane = section.to_world(np.array([[u0, u1, u1, u0], [v0, v0, v1, v1]])).T + loops = [ + (section.to_world(loop), closed) + for loop, closed in section._sampled_loops( + None, outline_resolution, None if bounds is None else ((u0, v0), (u1, v1)) + ) + ] + view = show_3d( + source, + ax=ax, + resolution=resolution, + bounds=bounds, + opacity=opacity, + backend=backend, + display=False, + **kwargs, + ) + from matplotlib.colors import to_rgb, to_rgba + + if backend == "pyvista": + from importlib import import_module + + pv = import_module("pyvista") + view.add_mesh( + pv.PolyData(plane, faces=[4, 0, 1, 2, 3]), + color=to_rgb(plane_color), + opacity=plane_opacity, + name="section plane", + ) + if loops: + points = np.concatenate([loop.T for loop, _ in loops]) + lines, offset = [], 0 + for loop, closed in loops: + n = loop.shape[1] + ids = list(range(offset, offset + n)) + ([offset] if closed else []) + lines += [len(ids), *ids] + offset += n + view.add_mesh( + pv.PolyData(points, lines=lines), + color=to_rgb(outline_color), + line_width=4 if outline_width is None else outline_width, + name="section outline", + ) + if display or save: + view.show(screenshot=str(save) if save else None, auto_close=False) + return view + from mpl_toolkits.mplot3d.art3d import Poly3DCollection + + view.add_collection3d( + Poly3DCollection([plane], facecolors=to_rgba(plane_color, plane_opacity)) + ) + for loop, closed in loops: + if closed: + loop = np.concatenate([loop, loop[:, :1]], axis=1) + view.plot(*loop, color=outline_color, linewidth=outline_width or 2) + return _finish(view, display, save) diff --git a/tutorials/slices_and_coordinate_sampling.ipynb b/tutorials/slices_and_coordinate_sampling.ipynb index a9aad13..7c912b7 100644 --- a/tutorials/slices_and_coordinate_sampling.ipynb +++ b/tutorials/slices_and_coordinate_sampling.ipynb @@ -127,7 +127,7 @@ "id": "8", "metadata": {}, "source": [ - "In 3D, the oblique plane (orange) cuts the shell in two circles, the outer and inner boundaries of the section above. The section's `to_mesh()` gives that outline in plane coordinates, and `to_world()` maps it back onto the plane. Click **Interactive 3-D view** and rotate the scene to look along the plane, or face-on through it." + "In 3D, `oblique.show_3d()` draws the sliced shell translucently, the cutting plane (orange) over the shell's bounds, and the section outline on it: here two circles, the outer and inner boundaries of the section above. `outlines()` returns those loops in plane coordinates, holes clockwise, and `outlines(world=True)` maps them onto the plane in 3D. Click **Interactive 3-D view** and rotate the scene to look along the plane, or face-on through it." ] }, { @@ -142,17 +142,14 @@ "\n", "pv.OFF_SCREEN = True # render without a window, e.g. on a docs server\n", "\n", - "outline = oblique.to_mesh(resolution=160) # line segments in plane coordinates\n", - "world = oblique.to_world(outline.vertices.T).T\n", - "plotter = shell.show_3d(\n", - " backend=\"pyvista\", display=False, resolution=56, opacity=0.3, show_bounds=False\n", + "loops = oblique.outlines(world=True) # (3, n) loops on the plane\n", + "unit_normal = np.ones(3) / np.sqrt(3)\n", + "assert len(loops) == 2\n", + "for loop in loops:\n", + " np.testing.assert_allclose(unit_normal @ loop, 0.1 / np.sqrt(3), atol=1e-12)\n", + "plotter = oblique.show_3d(\n", + " backend=\"pyvista\", display=False, resolution=56, show_bounds=False\n", ")\n", - "plotter.add_mesh(\n", - " pv.Plane(center=(0, 0, 0.1), direction=(1, 1, 1), i_size=2.6, j_size=2.6),\n", - " color=\"tab:orange\",\n", - " opacity=0.3,\n", - ")\n", - "plotter.add_lines(world[outline.cells].reshape(-1, 3), color=\"black\", width=4)\n", "plotter.view_vector((1, -0.3, 0.6))\n", "plasma_plots.scene(\n", " plotter,\n", From be397c3b5512e90f0ba10060adf3eb099c3577c6 Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 22 Oct 2026 12:56:30 +0200 Subject: [PATCH 09/36] Add show_3d(points=...) for labelled point sets (#22) * Add show_3d(points=...) for labelled point sets with optional vectors * Lock plasma-plots 0.2.5 --- CHANGELOG.md | 6 + pyproject.toml | 4 +- .../tests/test_inspection_plotting.py | 86 ++++++++++++ src/simgeometry/visualization.py | 129 +++++++++++++++++- .../sampling_export_and_diagnostics.ipynb | 26 ++-- tutorials/shapes_and_node_masks.ipynb | 15 +- uv.lock | 4 +- 7 files changed, 245 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f9aa2d..307f3a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- `show_3d(points=...)` overlays labelled point sets, e.g. samples, grid nodes or + crossings: an array in the `(3, n)` layout (or `(n, 3)`), or a dict of label to array or + to `{"points", "vectors", "color", "size"}`, with optional arrows such as normals. The + PyVista backend draws them with plasma-plots' `pyvista_points` in the same legend as the + objects (needs `plasma-plots>=0.2.5`); Matplotlib uses `scatter` and `quiver`. The + tutorials "Shapes and node masks" and "Sampling, export and diagnostics" use it. - `Section.outlines(max_spacing, resolution=, bounds=, world=)`: the sampled boundary loops of a plane section in plane coordinates (holes clockwise), or on the plane in 3D with `world=True`; `Section.show_3d()` draws the sliced shape, the cutting plane and the outline in diff --git a/pyproject.toml b/pyproject.toml index eabc566..a091c24 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,7 @@ optional-dependencies.docs = [ "griffe", "simgeometry[clip,interactive]", # interactive 3-D scenes in the notebooks (plasma_plots.scene) - "plasma-plots[scene]>=0.2.4", + "plasma-plots[scene]>=0.2.5", "ipykernel", "matplotlib>=3.10.9", "nbclient>=0.11", @@ -50,7 +50,7 @@ optional-dependencies.clip = [ "shapely>=2" ] optional-dependencies.evaluate = [ "xarray" ] optional-dependencies.mesh = [ "scikit-image>=0.22" ] optional-dependencies.plot = [ "matplotlib>=3.10", "simgeometry[evaluate,mesh]" ] -optional-dependencies.interactive = [ "plasma-plots>=0.2.4", "pyvista>=0.44", "simgeometry[plot]" ] +optional-dependencies.interactive = [ "plasma-plots>=0.2.5", "pyvista>=0.44", "simgeometry[plot]" ] optional-dependencies.mpi = [ "mpi4py" ] optional-dependencies.pyccel = [ "pyccel>2.0" ] optional-dependencies.gpu = [ "cupy-cuda12x" ] diff --git a/src/simgeometry/tests/test_inspection_plotting.py b/src/simgeometry/tests/test_inspection_plotting.py index 285132e..deb7c4d 100644 --- a/src/simgeometry/tests/test_inspection_plotting.py +++ b/src/simgeometry/tests/test_inspection_plotting.py @@ -166,3 +166,89 @@ def test_level_set_field_is_drawn_by_plasma_plots(): assert viewer.meshes finally: viewer.close() + + +def _samples(): + """Interior samples of a sphere in simgeometry's (3, n) layout, surface ones with + their outward normals.""" + ball = Sphere((0, 0, 0), 1) + surface = ball.sample_surface(40, rng=1, resolution=16) + return ball, ball.sample_interior(60, rng=1), surface, ball.surface_normal(surface) + + +def test_pyvista_draws_points_with_plasma_plots_in_one_legend(): + pv = pytest.importorskip("pyvista") + pytest.importorskip("plasma_plots") + from matplotlib.colors import to_rgb + + ball, interior, surface, normals = _samples() + viewer = ball.show_3d( + backend="pyvista", + resolution=16, + display=False, + points={ + "interior": interior, + "surface": {"points": surface.T, "vectors": normals, "size": 4}, + }, + ) + try: + clouds = [ + actor + for actor in viewer.renderer.actors.values() + if isinstance(getattr(actor, "mapper", None), pv.DataSetMapper) + and actor.mapper.dataset.n_verts == actor.mapper.dataset.n_points > 0 + ] + assert [actor.mapper.dataset.n_points for actor in clouds] == [60, 40] + np.testing.assert_allclose(clouds[0].mapper.dataset.points, interior.T) + # the next colours after the sphere's, and one legend of all three + colors = [actor.prop.color.float_rgb for actor in clouds] + np.testing.assert_allclose(colors, [to_rgb("C1"), to_rgb("C2")]) + assert viewer.legend.GetNumberOfEntries() == 3 + assert len(viewer.meshes) == 4 # the sphere, two point sets, the arrows + finally: + viewer.close() + single = ball.show_3d( + backend="pyvista", resolution=16, display=False, points=interior + ) + try: + assert single.legend is None # an unlabelled set needs no legend + assert len(single.meshes) == 2 + finally: + single.close() + + +def test_matplotlib_scatters_points_with_arrows_and_legend_entries(): + from mpl_toolkits.mplot3d.art3d import Line3DCollection, Path3DCollection + + ball, interior, surface, normals = _samples() + axis = ball.show_3d( + resolution=16, + display=False, + points={ + "interior": interior, + "surface": {"points": surface, "vectors": normals.T, "color": "red"}, + }, + ) + texts = [text.get_text() for text in axis.get_legend().get_texts()] + assert texts == ["geometry", "interior", "surface"] + scatters = [c for c in axis.collections if isinstance(c, Path3DCollection)] + assert [len(c.get_offsets()) for c in scatters] == [60, 40] + np.testing.assert_allclose( + scatters[1].get_facecolor()[0][:3], matplotlib.colors.to_rgb("red") + ) + lines = [c for c in axis.collections if isinstance(c, Line3DCollection)] + assert len(lines) == 2 # the bounding box and the arrows + + +def test_points_need_three_coordinates_and_known_keys(): + ball = Sphere((0, 0, 0), 1) + with pytest.raises(ValueError, match="shape"): + ball.show_3d(display=False, resolution=8, points=np.zeros((2, 5))) + with pytest.raises(ValueError, match="'vectors'"): + ball.show_3d(display=False, resolution=8, points={"a": {"pts": np.zeros(3)}}) + with pytest.raises(ValueError, match="vectors of 'a'"): + ball.show_3d( + display=False, + resolution=8, + points={"a": {"points": np.zeros((3, 4)), "vectors": np.zeros((3, 2))}}, + ) diff --git a/src/simgeometry/visualization.py b/src/simgeometry/visualization.py index d0528fc..0f622f1 100644 --- a/src/simgeometry/visualization.py +++ b/src/simgeometry/visualization.py @@ -1,7 +1,8 @@ """Optional 1D/2D inspection and 3D surface viewers. -Matplotlib is the default backend. The PyVista backend draws the surfaces with -plasma-plots (``pyvista_surfaces``) and adds interactive clipping widgets. +Matplotlib is the default backend. The PyVista backend draws the surfaces and point +sets with plasma-plots (``pyvista_surfaces``, ``pyvista_points``) and adds interactive +clipping widgets. Plots are sampled views. Increase resolution before interpreting small features. """ @@ -200,6 +201,67 @@ def _box_edges(lower, upper): ) +_POINT_KEYS = ("points", "vectors", "color", "size") + + +def _as_points(points, label): + """``(n, 3)`` host points from simgeometry's ``(3, n)`` layout or ``(n, 3)``.""" + import cunumpy as xp + + array = np.asarray(xp.to_numpy(points), dtype=float) + if array.ndim == 1 and array.size == 3: + array = array[:, None] + if array.ndim == 2 and array.shape[0] == 3: + return array.T + if array.ndim == 2 and array.shape[1] == 3: + return array + raise ValueError( + f"points {label!r} must have shape (3, n) or (n, 3); got {array.shape}" + ) + + +def _as_vectors(vectors, count, label): + """``(count, 3)`` host vectors from ``(3, count)`` or ``(count, 3)``.""" + import cunumpy as xp + + array = np.asarray(xp.to_numpy(vectors), dtype=float) + if array.shape == (3, count): + return array.T + if array.shape == (count, 3): + return array + raise ValueError( + f"vectors of {label!r} must have shape (3, {count}) or ({count}, 3); " + f"got {array.shape}" + ) + + +def _point_sets(points, first_color): + """``show_3d``'s ``points`` as ``{label: {"points", "vectors", "color", "size"}}`` + with ``(n, 3)`` arrays and a colour for each set, and whether they are labelled.""" + named = isinstance(points, dict) + entries = points if named else {"points": points} + sets = {} + for i, (label, value) in enumerate(entries.items()): + entry = dict(value) if isinstance(value, dict) else {"points": value} + unknown = sorted(set(entry) - set(_POINT_KEYS)) + if unknown or "points" not in entry: + raise ValueError( + f"points {label!r}: give an array or a dict with 'points' and " + f"optionally 'vectors', 'color', 'size'; got keys {sorted(entry)}" + ) + coordinates = _as_points(entry["points"], label) + vectors = entry.get("vectors") + sets[str(label)] = { + "points": coordinates, + "vectors": None + if vectors is None + else _as_vectors(vectors, len(coordinates), label), + "color": entry.get("color") or f"C{(first_color + i) % 10}", + "size": entry.get("size"), + } + return sets, named + + def show_3d( shape, *, @@ -220,6 +282,7 @@ def show_3d( display=True, save=None, t=0.0, + points=None, **kwargs, ): """Render surfaces; return axes or an optional PyVista Plotter. @@ -229,6 +292,15 @@ def show_3d( close the returned plotter. color_by="voltage" (PyVista) colors the conductors of a collection by their voltage at time ``t``; objects without a voltage are grey. No MPI operations are performed. + + ``points`` overlays point sets, e.g. samples, grid nodes or crossings: an + array in simgeometry's ``(3, n)`` layout (or ``(n, 3)``; a ``(3, 3)`` array + is read as ``(3, n)``), or a dict of label to array, or of label to a dict + with ``"points"`` and optionally ``"vectors"`` (one per point, e.g. normals, + drawn as arrows; the longest is 5% of the view), ``"color"`` and ``"size"`` + (pixels in PyVista, points in Matplotlib). Each set takes the next colour + after the objects' and, when labelled, a legend entry beside them (PyVista: + plasma-plots' ``pyvista_points``). """ if shape.ndim != 3: raise ValueError("show_3d requires three-dimensional geometry") @@ -247,6 +319,10 @@ def show_3d( objects = getattr(shape, "named_objects", {"geometry": shape}) styles = getattr(shape, "styles", {}) lower, upper = bounds or (shape.lower_bound, shape.upper_bound) + point_sets, named_points = ( + _point_sets(points, len(objects)) if points is not None else ({}, False) + ) + vector_scale = float(np.max(np.asarray(upper) - lower)) * 0.05 if backend == "pyvista": try: from importlib import import_module @@ -372,6 +448,29 @@ def show_3d( plotter.add_points(grid.node_points().T, point_size=4) if labels and len(surfaces) < len(objects) and color_by != "voltage": plotter.add_legend() + if point_sets: + from plasma_plots.pyvista_plots import pyvista_points + + entries = point_sets.values() + pyvista_points( + {label: entry["points"] for label, entry in point_sets.items()} + if named_points + else point_sets["points"]["points"], + colors=[entry["color"] for entry in entries], + size=[ + 6 if entry["size"] is None else entry["size"] for entry in entries + ], + vectors={ + label: entry["vectors"] + for label, entry in point_sets.items() + if entry["vectors"] is not None + } + if named_points + else point_sets["points"]["vectors"], + vector_scale=vector_scale, + legend=labels, + plotter=plotter, + ) if display or save: plotter.show(screenshot=str(save) if save else None, auto_close=False) return plotter @@ -387,6 +486,32 @@ def show_3d( ) if nodes: ax.scatter(*grid.node_points(), s=2, alpha=0.25) + peak = max( + ( + float(np.linalg.norm(entry["vectors"], axis=1).max(initial=0.0)) + for entry in point_sets.values() + if entry["vectors"] is not None + ), + default=0.0, + ) + for label, entry in point_sets.items(): + size = {} if entry["size"] is None else {"s": entry["size"] ** 2} + artist = ax.scatter( + *entry["points"].T, + color=entry["color"], + label=label if named_points else None, + depthshade=False, + **size, + ) + if named_points: + handles.append(artist) + if entry["vectors"] is not None and peak > 0: + ax.quiver( + *entry["points"].T, + *entry["vectors"].T, + length=vector_scale / peak, + color=entry["color"], + ) for setter, a, b in zip((ax.set_xlim, ax.set_ylim, ax.set_zlim), lower, upper): setter(a, b) from matplotlib.ticker import MaxNLocator diff --git a/tutorials/sampling_export_and_diagnostics.ipynb b/tutorials/sampling_export_and_diagnostics.ipynb index 27b6575..b4563aa 100644 --- a/tutorials/sampling_export_and_diagnostics.ipynb +++ b/tutorials/sampling_export_and_diagnostics.ipynb @@ -61,7 +61,7 @@ "id": "3", "metadata": {}, "source": [ - "A shape with a hole shows the two samplers best. On the left, interior samples of a torus; on the right, surface samples of the same torus, each over its translucent surface. Click **Interactive 3-D view** and turn the scene: the interior points fill the tube and leave the hole empty, while the surface points lie on the faceted mesh." + "A shape with a hole shows the two samplers best. On the left, interior samples of a torus; on the right, surface samples of the same torus, each over its translucent surface. `show_3d(points=...)` draws the `(3, n)` samples as labelled point sets, with a legend entry beside the objects. Click **Interactive 3-D view** and turn the scene: the interior points fill the tube and leave the hole empty, while the surface points lie on the faceted mesh." ] }, { @@ -80,20 +80,24 @@ "\n", "tori = GeometryCollection(\n", " {\n", - " \"interior samples\": Torus((0, 0, 0), 0.7, 0.3),\n", - " \"surface samples\": Torus((0, 2.4, 0), 0.7, 0.3),\n", + " \"left torus\": Torus((0, 0, 0), 0.7, 0.3),\n", + " \"right torus\": Torus((0, 2.4, 0), 0.7, 0.3),\n", " }\n", ")\n", - "interior = tori[\"interior samples\"].sample_interior(1500, rng=7)\n", - "boundary = tori[\"surface samples\"].sample_surface(1000, rng=7, resolution=48)\n", - "assert tori[\"interior samples\"].contains_points(interior).all()\n", + "interior = tori[\"left torus\"].sample_interior(1500, rng=7)\n", + "boundary = tori[\"right torus\"].sample_surface(1000, rng=7, resolution=48)\n", + "assert tori[\"left torus\"].contains_points(interior).all()\n", "plotter = tori.show_3d(\n", - " backend=\"pyvista\", display=False, resolution=48, opacity=0.25, show_bounds=False\n", + " backend=\"pyvista\",\n", + " display=False,\n", + " resolution=48,\n", + " opacity=0.25,\n", + " show_bounds=False,\n", + " points={\n", + " \"interior samples\": {\"points\": interior, \"size\": 5},\n", + " \"surface samples\": {\"points\": boundary, \"size\": 5},\n", + " },\n", ")\n", - "for points, color in ((interior, \"tab:blue\"), (boundary, \"tab:orange\")):\n", - " plotter.add_points(\n", - " points.T, color=color, point_size=5, render_points_as_spheres=True\n", - " )\n", "plasma_plots.scene(\n", " plotter,\n", " alt=\"Interior samples filling a torus, and surface samples on a second torus\",\n", diff --git a/tutorials/shapes_and_node_masks.ipynb b/tutorials/shapes_and_node_masks.ipynb index ca0ac77..cb005ca 100644 --- a/tutorials/shapes_and_node_masks.ipynb +++ b/tutorials/shapes_and_node_masks.ipynb @@ -159,7 +159,7 @@ "id": "11", "metadata": {}, "source": [ - "`node_mask()` works the same way on a 3D grid. The nodes inside a tilted torus are drawn as points over its translucent surface; rotate the view to see that they fill the tube of the torus and leave its hole empty." + "`node_mask()` works the same way on a 3D grid. `show_3d(points=...)` draws the nodes inside a tilted torus as a labelled point set over its translucent surface, in simgeometry's `(3, n)` layout as `node_points()` returns them; rotate the view to see that they fill the tube of the torus and leave its hole empty." ] }, { @@ -174,13 +174,12 @@ "mask3 = torus.node_mask(grid3)\n", "print(\"selected nodes:\", mask3.sum(), \"of\", mask3.size)\n", "plotter = torus.show_3d(\n", - " backend=\"pyvista\", display=False, resolution=48, opacity=0.3, show_bounds=False\n", - ")\n", - "plotter.add_points(\n", - " grid3.node_points()[:, mask3.ravel()].T,\n", - " color=\"black\",\n", - " point_size=6,\n", - " render_points_as_spheres=True,\n", + " backend=\"pyvista\",\n", + " display=False,\n", + " resolution=48,\n", + " opacity=0.3,\n", + " show_bounds=False,\n", + " points={\"nodes inside\": grid3.node_points()[:, mask3.ravel()]},\n", ")\n", "plasma_plots.scene(\n", " plotter, alt=\"The grid nodes inside a tilted torus, drawn inside its surface\"\n", diff --git a/uv.lock b/uv.lock index f088013..ce0aae5 100644 --- a/uv.lock +++ b/uv.lock @@ -3907,8 +3907,8 @@ wheels = [ [[package]] name = "plasma-plots" -version = "0.2.4" -source = { git = "https://github.com/max-models/plasma-plots?branch=devel#46a140ddf4dcce1f1e617c6be94eb71263caf109" } +version = "0.2.5" +source = { git = "https://github.com/max-models/plasma-plots?branch=devel#ac6079986a9fa1e681baf3dda5c0e9026989f3a4" } dependencies = [ { name = "ipywidgets" }, { name = "matplotlib", version = "3.10.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, From 9598f1991c35e9b48635010d5a318324c312fe72 Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 22 Oct 2026 21:26:12 +0200 Subject: [PATCH 10/36] Rounded rectangles and cuboids (fillet) (#24) * Add fillet to Rectangle and Cuboid for rounded corners and edges. * Point the docs smoke test at the renumbered tutorial assets. --- .github/workflows/docs.yml | 3 +- CHANGELOG.md | 7 + .../docs/getting-started/geometry-api.md | 5 +- src/simgeometry/README.md | 1 + src/simgeometry/_rounded_box.py | 125 +++++++++ src/simgeometry/crossings.py | 18 +- src/simgeometry/objects/README.md | 4 +- src/simgeometry/objects/cuboid.py | 111 +++++--- src/simgeometry/objects/rectangle.py | 82 +++++- src/simgeometry/queries.py | 39 ++- src/simgeometry/tests/test_crossings.py | 3 + .../tests/test_level_sets_fake_cupy.py | 12 +- src/simgeometry/tests/test_rounded_boxes.py | 255 ++++++++++++++++++ tutorials/shapes_and_node_masks.ipynb | 36 +++ 14 files changed, 653 insertions(+), 48 deletions(-) create mode 100644 src/simgeometry/_rounded_box.py create mode 100644 src/simgeometry/tests/test_rounded_boxes.py diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index b461881..b623eab 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -40,7 +40,8 @@ jobs: tutorials/shapes-and-node-masks/1.png tutorials/inspecting-geometry/4.html tutorials/conductors-and-level-sets/2.html - tutorials/shapes-and-node-masks/2.html + tutorials/shapes-and-node-masks/2.png + tutorials/shapes-and-node-masks/3.html tutorials/building-composite-shapes/5.html tutorials/slices-and-coordinate-sampling/4.html tutorials/sampling-export-and-diagnostics/2.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 307f3a8..8539037 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- `Rectangle(..., fillet=r)`: a rectangle with rounded corners, up to a stadium at half + the shorter side; `Cuboid(..., fillet=r)` rounds the edges and corners of a box, like + the `fillet` of the solids of revolution. Both keep the exact level set (the rounded + box distance), analytic `crossings`, segment intersections, closest points and + normals; the outline is straight edges and arcs, refined by `max_spacing`. The + tutorial "Shapes and node masks" shows them. - `show_3d(points=...)` overlays labelled point sets, e.g. samples, grid nodes or crossings: an array in the `(3, n)` layout (or `(n, 3)`), or a dict of label to array or to `{"points", "vectors", "color", "size"}`, with optional arrows such as normals. The @@ -116,6 +122,7 @@ format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- `Cuboid` keeps its bounds as host floats, so its `crossings` work on CuPy. - `Polygon.contains_points` with the compiled kernels accepts vertices that are not C-ordered (a transposed `(2, n)` array). - `Aperture2D` documents its `axis` as `0` or `1` and its vertices as `(axial, radial)`. diff --git a/docs/src/content/docs/getting-started/geometry-api.md b/docs/src/content/docs/getting-started/geometry-api.md index a789a68..d80a08f 100644 --- a/docs/src/content/docs/getting-started/geometry-api.md +++ b/docs/src/content/docs/getting-started/geometry-api.md @@ -140,7 +140,7 @@ and where `level_set_exact` is true it is the Euclidean signed distance. | Shape | Level set | | --- | --- | -| `Line`, `Circle`, `Sphere`, `Rectangle`, `Cuboid` | exact | +| `Line`, `Circle`, `Sphere`, `Rectangle`, `Cuboid` (with or without fillets) | exact | | `Polygon`, `Triangle` | exact: distance to the nearest edge, signed by `contains` (one pass per edge) | | `Aperture2D` | exact while the plates do not touch | | `Cylinder`, `Tube`, `Cone`, `Torus` (any axis, with or without fillets) | exact | @@ -184,7 +184,8 @@ Einzel lens. ## Exact geometry and numerical approximations -- Balls, axis-aligned boxes and simple polygons have analytic segment intersections +- Balls, axis-aligned boxes (also rounded with `fillet`) and simple polygons have + analytic segment intersections and closest-point distances. Similarity transforms preserve exact closest-point queries. General affine transformations preserve analytic segment intersections. - Composite segment queries merge operand hits and discard hidden interfaces. diff --git a/src/simgeometry/README.md b/src/simgeometry/README.md index 5314835..a45aec5 100644 --- a/src/simgeometry/README.md +++ b/src/simgeometry/README.md @@ -29,6 +29,7 @@ values; applications can configure their own seeded stream. - `simulation_domain.py` - Simulation domain helpers for geometry, grids, and simulation domains. - `utils.py` - Utils helpers for geometry, grids, and simulation domains. - `_level_set.py` - Exact signed-distance formulas of the primitives and the Illinois root finding on level sets. +- `_rounded_box.py` - Line intervals, normals and closest points of boxes with rounded edges (`fillet`). - `collection.py` - `GeometryCollection`: named objects, overlap precedence, styles, and conductor voltages (`with_voltages`, `voltages_at`, `dirichlet_values`). The shared `GeometryAPI` adds sections, transforms, Boolean operations, plotting, diff --git a/src/simgeometry/_rounded_box.py b/src/simgeometry/_rounded_box.py new file mode 100644 index 0000000..9247ad1 --- /dev/null +++ b/src/simgeometry/_rounded_box.py @@ -0,0 +1,125 @@ +"""Line intervals, normals and closest points of boxes with rounded edges. + +A box of half sides ``half`` rounded with radius ``radius`` is the box of half sides +``half - radius`` widened by a ball of that radius. It is convex and the union of +simple pieces: the box widened along one axis at a time, and the discs (2D) or the +edge cylinders and corner balls (3D) around the inner box's corners and edges. The +interval of a line inside it is the hull of the pieces' intervals. + +The functions take component-major ``(ndim, n)`` arrays and an array module +``array_xp`` (NumPy for host arrays, ``xp`` for the active cunumpy backend). +""" + +from __future__ import annotations + +from itertools import combinations, product + +import numpy as np + + +def _slab(a, d, lo, hi, array_xp): + """Interval ``(near, far)`` of ``a + t d`` in ``lo <= x <= hi``, empty as (inf, -inf).""" + moving = d != 0 + safe = array_xp.where(moving, d, 1.0) + t_lo, t_hi = (lo - a) / safe, (hi - a) / safe + near = array_xp.where(moving, array_xp.minimum(t_lo, t_hi), -np.inf) + far = array_xp.where(moving, array_xp.maximum(t_lo, t_hi), np.inf) + outside = ~moving & ((a < lo) | (a > hi)) + return array_xp.where(outside, np.inf, near), array_xp.where(outside, -np.inf, far) + + +def rounded_box_interval(a, d, centre, half, radius, array_xp=np): + """Parameters ``(t0, t1)`` where the lines ``a + t d`` enter and leave the box. + + ``a`` and ``d`` are ``(ndim, n)``; ``centre`` and ``half`` hold one value per + axis. A line that misses gets ``t0 > t1``; a step of zero length inside gets + ``(-inf, inf)``. + """ + ndim, n = a.shape + rel = a - array_xp.asarray(centre, dtype=float)[:, None] + half = np.asarray(half, dtype=float) + inner = half - radius + slabs = [_slab(rel[k], d[k], -inner[k], inner[k], array_xp) for k in range(ndim)] + t0 = array_xp.full(n, np.inf) + t1 = array_xp.full(n, -np.inf) + + def add(near, far): + nonlocal t0, t1 + kept = near <= far + t0 = array_xp.minimum(t0, array_xp.where(kept, near, np.inf)) + t1 = array_xp.maximum(t1, array_xp.where(kept, far, -np.inf)) + + # the inner box widened by the radius along one axis + for k in range(ndim): + near, far = _slab(rel[k], d[k], -half[k], half[k], array_xp) + for j in range(ndim): + if j != k: + near = array_xp.maximum(near, slabs[j][0]) + far = array_xp.minimum(far, slabs[j][1]) + add(near, far) + # discs, edge cylinders and corner balls: round in the axes of ``axes`` + for size in range(2, ndim + 1): + for axes in combinations(range(ndim), size): + for signs in product((-1.0, 1.0), repeat=size): + aa = sum(d[k] * d[k] for k in axes) + bb = sum((rel[k] - s * inner[k]) * d[k] for k, s in zip(axes, signs)) + cc = ( + sum((rel[k] - s * inner[k]) ** 2 for k, s in zip(axes, signs)) + - radius**2 + ) + moving = aa > 0 + safe = array_xp.where(moving, aa, 1.0) + disc = bb * bb - aa * cc + root = array_xp.sqrt(array_xp.where(disc > 0, disc, 0.0)) + cut = moving & (disc >= 0) + still = ~moving & (cc <= 0) + near = array_xp.where( + cut, (-bb - root) / safe, array_xp.where(still, -np.inf, np.inf) + ) + far = array_xp.where( + cut, (-bb + root) / safe, array_xp.where(still, np.inf, -np.inf) + ) + for j in range(ndim): + if j not in axes: + near = array_xp.maximum(near, slabs[j][0]) + far = array_xp.minimum(far, slabs[j][1]) + add(near, far) + return t0, t1 + + +def rounded_box_normal(points, centre, half, radius, array_xp=np): + """Unit outward normals ``(ndim, n)`` of the box at (or near) the points. + + The direction from the nearest point of the inner box; inside the inner box, the + normal of the nearest face. + """ + ndim = points.shape[0] + half = np.asarray(half, dtype=float) + inner = array_xp.asarray(half - radius)[:, None] + q = points - array_xp.asarray(centre, dtype=float)[:, None] + n = q - array_xp.clip(q, -inner, inner) + length = array_xp.sqrt(array_xp.sum(n * n, axis=0)) + face = array_xp.argmax(array_xp.abs(q) - array_xp.asarray(half)[:, None], axis=0) + sign = array_xp.where(q >= 0, 1.0, -1.0) + axes = array_xp.arange(ndim)[:, None] + flat = array_xp.where(axes == face[None, :], sign, 0.0) + unit = n / array_xp.where(length > 0, length, 1.0)[None, :] + return array_xp.where((length > 0)[None, :], unit, flat) + + +def rounded_box_closest(points, centre, half, radius): + """Closest points ``(ndim, n)`` on the boundary of the box, on the host.""" + half = np.asarray(half, dtype=float) + inner = (half - radius)[:, None] + centre = np.asarray(centre, dtype=float)[:, None] + q = points - centre + c = np.clip(q, -inner, inner) + n = q - c + length = np.sqrt(np.sum(n * n, axis=0)) + round_part = c + radius * n / np.where(length > 0, length, 1.0) + # inside the inner box the nearest boundary point is on the nearest face + face = np.argmax(np.abs(q) - half[:, None], axis=0) + flat = q.copy() + columns = np.arange(q.shape[1]) + flat[face, columns] = np.where(q[face, columns] >= 0, 1.0, -1.0) * half[face] + return centre + np.where(length > 0, round_part, flat) diff --git a/src/simgeometry/crossings.py b/src/simgeometry/crossings.py index 0c174f7..9e6f0bc 100644 --- a/src/simgeometry/crossings.py +++ b/src/simgeometry/crossings.py @@ -5,8 +5,8 @@ shape's boundary, entering or leaving. Because the whole step is tested, not only its end point, a fast particle cannot pass through a thin plate unnoticed. -Polygonal 2D shapes (exact outlines), circles, spheres and axis-aligned boxes are -analytic; affine transforms of them stay analytic. Every other shape (Boolean +Polygonal 2D shapes (exact outlines), circles, spheres and axis-aligned boxes, also +with rounded edges (``fillet``), are analytic; affine transforms of them stay analytic. Every other shape (Boolean combinations, collections, function objects, ...) is sampled: membership is tested at ``samples`` points along each step and the first change is bisected, so features thinner than ``1 / samples`` of a step can be missed, and the normals are estimated. @@ -34,6 +34,7 @@ from simgeometry._level_set import has_level_set, illinois_roots, level_set_gradient from simgeometry._profiling import _PROFILE_TAGS +from simgeometry._rounded_box import rounded_box_interval, rounded_box_normal from simgeometry.shapes import _component_major #: Crossings closer than this fraction to the start of a step are ignored. @@ -101,6 +102,8 @@ def _first(shape, a, d, samples, iterations): return _transformed(shape, a, d, samples, iterations) if isinstance(shape, (Circle, Sphere)): return (*_ball(shape, a, d), True) + if isinstance(shape, (Rectangle, Cuboid)) and shape.fillet > 0: + return (*_rounded_box(shape, a, d), True) if isinstance(shape, (Rectangle, Cuboid, Line)): return (*_box(shape, a, d), True) polygonal = (Polygon, Triangle, Aperture2D) @@ -179,6 +182,17 @@ def _box(shape, a, d): return t, _flip(shape, normal) +def _rounded_box(shape, a, d): + lower = np.asarray(xp.to_numpy(shape.lower_bound), float).reshape(-1) + upper = np.asarray(xp.to_numpy(shape.upper_bound), float).reshape(-1) + centre, half = 0.5 * (lower + upper), 0.5 * (upper - lower) + t0, t1 = rounded_box_interval(a, d, centre, half, shape.fillet, array_xp=xp) + t, _ = _first_of_interval(t0, t1) + point = a + xp.where(xp.isfinite(t), t, 0.0)[None, :] * d + normal = rounded_box_normal(point, centre, half, shape.fillet, array_xp=xp) + return t, _flip(shape, normal) + + def _polygon(shape, a, d): closed = getattr(shape, "closed", True) starts, ends = [], [] diff --git a/src/simgeometry/objects/README.md b/src/simgeometry/objects/README.md index 7907266..580771e 100644 --- a/src/simgeometry/objects/README.md +++ b/src/simgeometry/objects/README.md @@ -8,10 +8,10 @@ Concrete geometric objects that can be embedded in a simulation domain. - `aperture2d.py` - Two-dimensional aperture helpers for geometric object definitions. - `aperture3d.py` - Three-dimensional aperture helpers for geometric object definitions. - `circle.py` - Circle helpers for geometric object definitions. -- `cuboid.py` - Cuboid helpers for geometric object definitions. +- `cuboid.py` - Cuboids, optionally with rounded edges. - `line.py` - Line helpers for geometric object definitions. - `polygon.py` - Polygon helpers for geometric object definitions. - `polyline.py` - Open polylines: curves of zero thickness (thin plates, wires). -- `rectangle.py` - Rectangle helpers for geometric object definitions. +- `rectangle.py` - Rectangles, optionally with rounded corners (stadiums). - `sphere.py` - Sphere helpers for geometric object definitions. - `triangle.py` - Triangle helpers for geometric object definitions. diff --git a/src/simgeometry/objects/cuboid.py b/src/simgeometry/objects/cuboid.py index 0e36084..33eb6c9 100644 --- a/src/simgeometry/objects/cuboid.py +++ b/src/simgeometry/objects/cuboid.py @@ -1,10 +1,11 @@ -"""Cuboid helpers for geometric object definitions.""" +"""Cuboids, optionally with rounded edges.""" from __future__ import annotations from operator import add, sub import cunumpy as xp +import numpy as np from simgeometry._level_set import box_distance from simgeometry._types import Array, BoundsLike, ConfigValue @@ -14,7 +15,16 @@ class Cuboid(Shape3D): """Represent a cuboid geometry object. - Its :meth:`level_set` is the exact signed distance to the box. + ``fillet`` rounds the twelve edges and eight corners with that radius, at most + half the shortest side, as ``fillet`` rounds the edges of a + :class:`~simgeometry.Cylinder`. The :meth:`level_set` is the exact signed + distance, with or without the fillet. + + Example + ------- + A 2 x 1 x 0.4 block with edges rounded by 0.05:: + + block = Cuboid((0, 0, 0), (2.0, 1.0, 0.4), fillet=0.05) """ has_level_set = True @@ -26,6 +36,8 @@ def __init__( side_lengths: BoundsLike, bc: ConfigValue = None, inverted: bool = False, + *, + fillet: float = 0.0, ) -> None: """ Initialize a cuboid (rectangular prism) in 3D. @@ -34,9 +46,14 @@ def __init__( midpoint (list or tuple): Coordinates of the midpoint of the cuboid. side_lengths (list or tuple): Lengths of the sides of the cuboid in each dimension. bc (optional): Boundary condition information. + inverted (bool, optional): Cover everything except the cuboid. + fillet (float, optional): Radius of the rounded edges and corners. """ assert len(midpoint) == 3, "Length of midpoint must be 3 for 3D Cuboid." assert len(side_lengths) == 3, "Length of side_lengths must be 3 for 3D Cuboid." + if not 0 <= fillet <= 0.5 * min(float(v) for v in side_lengths): + raise ValueError("fillet must be between 0 and half the shortest side") + self.fillet = float(fillet) self.midpoint = xp.array(midpoint) self.side_lengths = xp.array(side_lengths) @@ -44,13 +61,15 @@ def __init__( self._inverted = bool(inverted) # Calculate bounds based on the midpoint and side lengths - half_lengths = tuple(len / 2 for len in self.side_lengths) - lower_bound = tuple(map(sub, self.midpoint, half_lengths)) - upper_bound = tuple(map(add, self.midpoint, half_lengths)) - - super().__init__( - lower_bound=lower_bound, - upper_bound=upper_bound, + super().__init__(*self._box_bounds()) + + def _box_bounds(self) -> tuple[tuple[float, ...], tuple[float, ...]]: + """The lower and upper corners, as host floats on any backend.""" + half_lengths = tuple(float(v) / 2 for v in self.side_lengths) + midpoint = tuple(float(v) for v in self.midpoint) + return ( + tuple(map(sub, midpoint, half_lengths)), + tuple(map(add, midpoint, half_lengths)), ) def mirror(self, axis: int, position: float) -> None: @@ -67,11 +86,7 @@ def mirror(self, axis: int, position: float) -> None: # Mirror the midpoint coordinate along the specified axis self.midpoint[axis] = 2 * position - self.midpoint[axis] - half_lengths = tuple(len / 2 for len in self.side_lengths) - lower_bound = tuple(map(sub, self.midpoint, half_lengths)) - upper_bound = tuple(map(add, self.midpoint, half_lengths)) - - self.set_bounding_box(lower_bound, upper_bound) + self.set_bounding_box(*self._box_bounds()) def copy(self) -> Cuboid: """ @@ -85,6 +100,7 @@ def copy(self) -> Cuboid: side_lengths=self.side_lengths.copy(), bc=self._bc, inverted=self._inverted, + fillet=self.fillet, ) def translate(self, offset: tuple[float, float, float]) -> None: @@ -99,12 +115,7 @@ def translate(self, offset: tuple[float, float, float]) -> None: # Apply translation self.midpoint = self.midpoint + offset_vec - # Calculate bounds based on the midpoint and side lengths - half_lengths = tuple(len / 2 for len in self.side_lengths) - lower_bound = tuple(map(sub, self.midpoint, half_lengths)) - upper_bound = tuple(map(add, self.midpoint, half_lengths)) - - self.set_bounding_box(lower_bound, upper_bound) + self.set_bounding_box(*self._box_bounds()) def get_vertices(self, slice_axis: int, slice_position: float) -> ConfigValue: """ @@ -115,7 +126,8 @@ def get_vertices(self, slice_axis: int, slice_position: float) -> ConfigValue: slice_index (float): Position along the slice_axis where the slice is taken. Returns: - vertices (list): List of corner vertices defining the slice polygon. + vertices (list): List of corner vertices defining the slice polygon; + with a fillet, the vertices of the rounded rectangle of the slice. """ assert 0 <= slice_axis < 3, "slice_axis must be 0, 1, or 2." inverted = self.inverted @@ -132,6 +144,8 @@ def get_vertices(self, slice_axis: int, slice_position: float) -> ConfigValue: b1 = self.upper_bound if not b0[slice_axis] <= slice_position <= b1[slice_axis]: return xp.array([]), inverted + if self.fillet > 0: + return xp.array(self._slice_outline(slice_axis, slice_position)), inverted vertices = [ [b0[axis1], b0[axis2]], @@ -142,6 +156,21 @@ def get_vertices(self, slice_axis: int, slice_position: float) -> ConfigValue: # TODO: Is this inverted stuff even working? return xp.array(vertices), inverted + def _slice_outline(self, slice_axis: int, slice_position: float) -> np.ndarray: + """The ``(n, 2)`` outline of a rounded cuboid's slice, a rounded rectangle.""" + from simgeometry.objects.rectangle import Rectangle + + r = self.fillet + lower = np.asarray(xp.to_numpy(xp.asarray(self.lower_bound)), float) + upper = np.asarray(xp.to_numpy(xp.asarray(self.upper_bound)), float) + centre, half = 0.5 * (lower + upper), 0.5 * (upper - lower) + depth = abs(slice_position - centre[slice_axis]) - (half[slice_axis] - r) + radius = float(np.sqrt(max(r * r - max(depth, 0.0) ** 2, 0.0))) + others = [(slice_axis + 1) % 3, (slice_axis + 2) % 3] + sides = 2 * (half[others] - r + radius) + section = Rectangle(tuple(centre[others]), tuple(sides), fillet=radius) + return section.outlines()[0].T + def contains(self, coords: Array) -> bool: """ Check if the given coordinates are inside the cuboid. @@ -152,6 +181,9 @@ def contains(self, coords: Array) -> bool: Returns: bool: True if all coordinates are inside the cuboid, False otherwise. """ + if self.fillet > 0: + point = xp.asarray(coords, dtype=float).reshape(3, 1) + return bool(xp.to_numpy(self.contains_points(point))[0]) coords = xp.asarray(coords, dtype=float) lower_bound = xp.asarray(self.lower_bound, dtype=float) upper_bound = xp.asarray(self.upper_bound, dtype=float) @@ -161,26 +193,41 @@ def contains(self, coords: Array) -> bool: def contains_points(self, points: Array) -> Array: """Vectorised :meth:`contains`: a mask of the ``(3, n)`` points inside.""" points = _component_major(points, 3) + if self.fillet > 0: + inside = self._level_set(points) <= 0 + return ~inside if self.inverted else inside lower = xp.asarray(self.lower_bound, dtype=float)[:, None] upper = xp.asarray(self.upper_bound, dtype=float)[:, None] inside = xp.all((points >= lower) & (points <= upper), axis=0) return ~inside if self.inverted else inside def _level_set(self, points: Array) -> Array: - """Exact signed distance to the cuboid.""" + """Exact signed distance to the cuboid, rounded or not.""" lower = xp.asarray(self.lower_bound, dtype=float)[:, None] upper = xp.asarray(self.upper_bound, dtype=float)[:, None] - return box_distance(points - 0.5 * (lower + upper), 0.5 * (upper - lower)) + f = self.fillet + return ( + box_distance(points - 0.5 * (lower + upper), 0.5 * (upper - lower) - f) - f + ) + + def _inner_sides(self) -> np.ndarray: + """The side lengths of the box the fillet widens by a ball.""" + sides = np.asarray(xp.to_numpy(xp.asarray(self.side_lengths)), dtype=float) + return sides - 2 * self.fillet def volume(self) -> float: - """Calculate the volume of the cuboid.""" - return float(xp.prod(self.side_lengths)) + """Calculate the volume of the cuboid, less the rounded edges.""" + a, b, c = self._inner_sides() + r = self.fillet + faces = 2 * r * (a * b + b * c + c * a) + return float( + a * b * c + faces + np.pi * r**2 * (a + b + c) + 4 / 3 * np.pi * r**3 + ) def surface_area(self) -> float: - """Calculate the surface area of the cuboid.""" - areas = [ - self.side_lengths[i] * self.side_lengths[j] - for i in range(3) - for j in range(i + 1, 3) - ] - return float(2 * sum(areas)) + """Calculate the surface area of the cuboid, with its rounded edges.""" + a, b, c = self._inner_sides() + r = self.fillet + return float( + 2 * (a * b + b * c + c * a) + 2 * np.pi * r * (a + b + c) + 4 * np.pi * r**2 + ) diff --git a/src/simgeometry/objects/rectangle.py b/src/simgeometry/objects/rectangle.py index eff144e..8b7e030 100644 --- a/src/simgeometry/objects/rectangle.py +++ b/src/simgeometry/objects/rectangle.py @@ -1,4 +1,4 @@ -"""Rectangle helpers for geometric object definitions.""" +"""Rectangles, optionally with rounded corners.""" from __future__ import annotations @@ -9,13 +9,26 @@ from simgeometry._level_set import box_distance from simgeometry._types import Array, ConfigValue -from simgeometry.shapes import Shape2D, _component_major +from simgeometry.shapes import Shape2D, Spacing, _component_major, _distribute + +#: Vertices per rounded corner of an outline without ``max_spacing``. +CORNER_POINTS = 25 class Rectangle(Shape2D): """Represent a rectangular geometry object. - Its :meth:`level_set` is the exact signed distance to the box. + ``fillet`` rounds the four corners with that radius, at most half the shorter + side; at half the shorter side the rectangle is a stadium (a circle for a + square). Rounded corners keep the surface field of an electrode bounded. The + :meth:`level_set` is the exact signed distance, with or without the fillet. + + Example + ------- + A plate 2 wide and 0.4 thick with rounded corners, and a stadium:: + + plate = Rectangle((0, 0), (2.0, 0.4), fillet=0.05) + stadium = Rectangle((0, 0), (2.0, 0.4), fillet=0.2) """ exact_outline = True @@ -28,6 +41,8 @@ def __init__( side_lengths: tuple, bc: ConfigValue = None, inverted: bool = False, + *, + fillet: float = 0.0, ) -> None: """ Initialize a rectangle in 2D space. @@ -37,12 +52,18 @@ def __init__( side_lengths (list or tuple): Lengths of the sides of the rectangle in each dimension. bc (optional): Boundary condition information. inverted (bool, optional): Cover everything except the rectangle. + fillet (float, optional): Radius of the rounded corners. """ assert len(midpoint) == 2, ( f"Length of midpoint must match ndim (2). {len(midpoint) =}" ) assert len(side_lengths) == 2, "Length of side_lengths must match ndim." + if not 0 <= fillet <= 0.5 * min(float(v) for v in side_lengths): + raise ValueError("fillet must be between 0 and half the shorter side") + self.fillet = float(fillet) + # a rounded rectangle's outline is sampled on its arcs + self.exact_outline = self.fillet == 0 self.midpoint = list(midpoint) self.side_lengths = side_lengths self._bc = bc @@ -90,6 +111,7 @@ def copy(self) -> Rectangle: side_lengths=self.side_lengths, bc=self._bc, inverted=self._inverted, + fillet=self.fillet, ) def translate(self, offset: tuple[float, float]) -> None: @@ -115,6 +137,47 @@ def _corner_loops(self) -> list[np.ndarray]: (xmin, ymin), (xmax, ymax) = self.lower_bound, self.upper_bound return [np.array([[xmin, xmax, xmax, xmin], [ymin, ymin, ymax, ymax]], float)] + def _outline_loops(self, max_spacing: Spacing | None) -> list[np.ndarray]: + """The straight edges and the quarter circles of the corners, counter-clockwise. + + Without ``max_spacing`` each edge is one segment and each corner has + :data:`CORNER_POINTS` vertices on its arc. + """ + if self.fillet == 0: + return super()._outline_loops(max_spacing) + r = self.fillet + (xmin, ymin), (xmax, ymax) = self.lower_bound, self.upper_bound + corners = [(xmax - r, ymin + r), (xmax - r, ymax - r)] + corners += [(xmin + r, ymax - r), (xmin + r, ymin + r)] + pieces = [] + for k, (cx, cy) in enumerate(corners): + # the edge into this corner, from the arc of the previous one + px, py = corners[k - 1] + angle = (k - 1) * 0.5 * np.pi + a = np.array([[px + r * np.cos(angle)], [py + r * np.sin(angle)]]) + b = np.array([[cx + r * np.cos(angle)], [cy + r * np.sin(angle)]]) + + def edge(t, a=a, b=b): + return a + t * (b - a) + + pieces.append((edge, float(np.hypot(*(b - a)[:, 0])), 1)) + + def arc(t, cx=cx, cy=cy, angle=angle): + theta = angle + 0.5 * np.pi * t + return np.stack((cx + r * np.cos(theta), cy + r * np.sin(theta))) + + pieces.append((arc, 0.5 * np.pi * r, CORNER_POINTS)) + points = [] + for curve, length, default in pieces: + if length <= 1e-12 * max(xmax - xmin, ymax - ymin): + continue # the flat sides of a stadium or a circle + if max_spacing is None: + t = np.arange(default) / default + else: + t = _distribute(curve, length, max_spacing) + points.append(curve(t)) + return [np.concatenate(points, axis=1)] + def contains(self, coords: Array) -> bool: """ Check if the given coordinates are inside the rectangle. @@ -125,18 +188,27 @@ def contains(self, coords: Array) -> bool: Returns: bool: True if the coordinates are inside the rectangle, False otherwise. """ + if self.fillet > 0: + point = xp.asarray(coords, dtype=float).reshape(2, 1) + return bool(xp.to_numpy(self.contains_points(point))[0]) return self._apply_inversion(self.bounding_box.contains(coords)) def contains_points(self, points: Array) -> Array: """Vectorised :meth:`contains`: a mask of the ``(2, n)`` points inside.""" points = _component_major(points, 2) + if self.fillet > 0: + inside = self._level_set(points) <= 0 + return ~inside if self.inverted else inside lower = xp.asarray(self.bounding_box.lower_bound, dtype=float)[:, None] upper = xp.asarray(self.bounding_box.upper_bound, dtype=float)[:, None] inside = xp.all((points >= lower) & (points < upper), axis=0) return ~inside if self.inverted else inside def _level_set(self, points: Array) -> Array: - """Exact signed distance to the rectangle.""" + """Exact signed distance to the rectangle, rounded or not.""" lower = xp.asarray(self.bounding_box.lower_bound, dtype=float)[:, None] upper = xp.asarray(self.bounding_box.upper_bound, dtype=float)[:, None] - return box_distance(points - 0.5 * (lower + upper), 0.5 * (upper - lower)) + f = self.fillet + return ( + box_distance(points - 0.5 * (lower + upper), 0.5 * (upper - lower) - f) - f + ) diff --git a/src/simgeometry/queries.py b/src/simgeometry/queries.py index 00ac545..b79927f 100644 --- a/src/simgeometry/queries.py +++ b/src/simgeometry/queries.py @@ -17,6 +17,11 @@ from simgeometry._level_set import has_level_set, illinois_roots, level_set_gradient from simgeometry._profiling import _PROFILE_TAGS +from simgeometry._rounded_box import ( + rounded_box_closest, + rounded_box_interval, + rounded_box_normal, +) from simgeometry.objects import ( Circle, Cuboid, @@ -33,6 +38,21 @@ POLYGONS = (Polygon, Triangle) +def _is_rounded(shape): + """Whether ``shape`` is a box with rounded edges (a ``fillet``).""" + return isinstance(shape, (Rectangle, Cuboid)) and shape.fillet > 0 + + +def _is_box(shape): + return isinstance(shape, BOXES) and not _is_rounded(shape) + + +def _centre_half(shape): + lower = np.asarray(shape.lower_bound, float) + upper = np.asarray(shape.upper_bound, float) + return 0.5 * (lower + upper), 0.5 * (upper - lower) + + def points_array(points, ndim): p = np.asarray(xp.to_numpy(points), float) single = p.ndim == 1 @@ -179,7 +199,14 @@ def _candidates(shape, a, b, samples, tolerance): return [], True half = np.sqrt(max(radial, 0) / aa) return [centre - half, centre + half], True - if isinstance(shape, BOXES): + if _is_rounded(shape): + centre, half = _centre_half(shape) + t0, t1 = rounded_box_interval( + a[:, None], d[:, None], centre, half, shape.fillet + ) + lo, hi = float(t0[0]), float(t1[0]) + return ([lo, hi] if lo <= hi else []), True + if _is_box(shape): lower, upper = np.asarray(shape.lower_bound), np.asarray(shape.upper_bound) lo, hi = -np.inf, np.inf for i in range(shape.ndim): @@ -375,7 +402,9 @@ def closest_point(shape, points, *, resolution=64, bounds=None): unit = delta / np.where(radius > 0, radius, 1) unit[0, radius == 0] = 1 q = np.asarray(shape.midpoint)[:, None] + shape.radius * unit - elif isinstance(shape, BOXES): + elif _is_rounded(shape): + q = rounded_box_closest(p, *_centre_half(shape), shape.fillet) + elif _is_box(shape): lower, upper = np.asarray(shape.lower_bound), np.asarray(shape.upper_bound) q = np.clip(p, lower[:, None], upper[:, None]) inside = np.all((p >= lower[:, None]) & (p <= upper[:, None]), axis=0) @@ -466,7 +495,11 @@ def surface_normal(shape, points, *, scale=None): n = p - np.asarray(shape.midpoint)[:, None] if shape.inverted: n = -n - elif isinstance(shape, BOXES): + elif _is_rounded(shape): + n = rounded_box_normal(p, *_centre_half(shape), shape.fillet) + if shape.inverted: + n = -n + elif _is_box(shape): distances = np.concatenate( ( abs(p - np.asarray(shape.lower_bound)[:, None]), diff --git a/src/simgeometry/tests/test_crossings.py b/src/simgeometry/tests/test_crossings.py index 9293525..50bca3d 100644 --- a/src/simgeometry/tests/test_crossings.py +++ b/src/simgeometry/tests/test_crossings.py @@ -50,6 +50,9 @@ def first_reference(shape, start, end): "sphere": Sphere(midpoint=(0.1, 0.0, -0.2), radius=0.8), "rectangle": Rectangle(midpoint=(0.1, 0.2), side_lengths=(1.4, 0.8)), "cuboid": Cuboid(midpoint=(0.0, 0.1, 0.0), side_lengths=(1.2, 0.6, 1.0)), + "rounded rectangle": Rectangle((0.1, 0.2), (1.4, 0.8), fillet=0.3), + "stadium": Rectangle((0.1, 0.2), (1.4, 0.8), fillet=0.4), + "rounded cuboid": Cuboid((0.0, 0.1, 0.0), (1.2, 0.6, 1.0), fillet=0.2), "triangle": Triangle(vertices=((-1.0, -1.0), (1.2, -0.6), (0.1, 1.3))), "polygon": Polygon( vertices=np.array([(-1, -1), (1, -1), (1, 0), (0, 0), (0, 1), (-1, 1)], float) diff --git a/src/simgeometry/tests/test_level_sets_fake_cupy.py b/src/simgeometry/tests/test_level_sets_fake_cupy.py index 3813f17..ed36efd 100644 --- a/src/simgeometry/tests/test_level_sets_fake_cupy.py +++ b/src/simgeometry/tests/test_level_sets_fake_cupy.py @@ -13,7 +13,7 @@ import cunumpy as xp import numpy as np - from simgeometry import Circle, Cone, Cylinder, Sphere, Torus, Tube + from simgeometry import Circle, Cone, Cuboid, Cylinder, Rectangle, Sphere, Torus, Tube with xp.use_backend("cupy", strict=True): rng = np.random.default_rng(0) @@ -27,6 +27,7 @@ Torus((0, 0, 0), 0.6, 0.2, axis=(1, 0, 1)), Sphere((0, 0, 0), 0.8) - Cylinder((0, 0, -1), (0, 0, 1), 0.3), Tube((0, 0, -0.5), (0, 0, 0.5), 0.3, 0.6).rotated(30, axis=(1, 0, 0)), + Cuboid((0, 0, 0), (1.2, 0.8, 0.6), fillet=0.2), ] for shape in shapes: values = shape.level_set(points) @@ -44,6 +45,15 @@ level = xp.to_numpy(shape.level_set(xp.asarray(point))) assert np.all(np.abs(level) < 1e-12), (shape, level) assert np.all((fraction > 0) & (fraction <= 1)) + rounded = Rectangle((0, 0), (1.2, 0.8), fillet=0.3) + flat = xp.asarray(host[:2]) + values = rounded.level_set(flat) + assert not isinstance(values, np.ndarray), type(values) + inside = xp.to_numpy(rounded.contains_points(flat)) + assert np.array_equal(xp.to_numpy(values) < 0, inside) + hits = rounded.crossings(xp.zeros((2, 3)), xp.asarray(np.eye(2, 3) + 0.01)) + point = hits.point[:, hits.hit] + assert np.all(np.abs(xp.to_numpy(rounded.level_set(point))) < 1e-12) circle = Circle((0, 0), 0.5) assert abs(circle.signed_distance(np.array([1.0, 0.0])) - 0.5) < 1e-15 print("ok") diff --git a/src/simgeometry/tests/test_rounded_boxes.py b/src/simgeometry/tests/test_rounded_boxes.py new file mode 100644 index 0000000..d33a493 --- /dev/null +++ b/src/simgeometry/tests/test_rounded_boxes.py @@ -0,0 +1,255 @@ +"""Rectangles and cuboids with rounded corners and edges (``fillet``).""" + +import cunumpy as xp +import numpy as np +import pytest + +from simgeometry import Circle, Cuboid, Rectangle +from simgeometry._level_set import segment_distance +from simgeometry.shapes import _signed_area + +RNG = np.random.default_rng(7) +PLATE = Rectangle((0.3, -0.2), (2.0, 1.0), fillet=0.3) +BLOCK = Cuboid((0.1, 0.0, -0.2), (1.2, 0.8, 0.6), fillet=0.2) + + +def host(array): + return np.asarray(xp.to_numpy(array)) + + +def _points(shape, n=500, pad=0.4): + lower = np.asarray(shape.lower_bound, float) - pad + upper = np.asarray(shape.upper_bound, float) + pad + return RNG.uniform(lower[:, None], upper[:, None], (shape.ndim, n)) + + +def _capsule_distance(points, a, b, radius): + """Signed distance to the segment ``a``-``b`` widened by ``radius``.""" + a, b = np.asarray(a, float)[:, None], np.asarray(b, float)[:, None] + t = np.clip(np.sum((points - a) * (b - a), axis=0) / np.sum((b - a) ** 2), 0, 1) + return np.linalg.norm(points - (a + t * (b - a)), axis=0) - radius + + +def test_inside_and_outside(): + # the plate spans [-0.7, 1.3] x [-0.7, 0.3]; corners rounded by 0.3 + inside = np.array([[0.3, -0.69, 1.29, 0.3, -0.35], [-0.2, -0.2, -0.2, 0.29, 0.0]]) + outside = np.array([[-0.69, 1.29, 1.29, -0.69, 1.4], [-0.69, -0.69, 0.29, 0.29, 0]]) + assert host(PLATE.contains_points(inside)).all() + assert not host(PLATE.contains_points(outside)).any() + assert PLATE.contains((0.3, -0.2)) and not PLATE.contains((-0.69, 0.29)) + # half sides (0.6, 0.4, 0.3): near a corner, near an edge, past an edge + offsets = np.array([[0.58, 0.5, 0.55], [0.38, 0.3, 0.35], [0.28, 0.0, 0.2]]) + centre = np.asarray(BLOCK.midpoint, float)[:, None] + np.testing.assert_array_equal( + host(BLOCK.contains_points(centre + offsets)), [False, True, False] + ) + assert BLOCK.contains(tuple(centre[:, 0])) + + +@pytest.mark.parametrize("inverted", [False, True]) +def test_inversion_negates_membership_and_level_set(inverted): + shape = Rectangle((0.3, -0.2), (2.0, 1.0), inverted=inverted, fillet=0.3) + points = _points(shape) + values = host(shape.level_set(points)) + np.testing.assert_allclose(values, host(PLATE.level_set(points)) * (-1) ** inverted) + np.testing.assert_array_equal(host(shape.contains_points(points)), values <= 0) + + +def test_level_set_is_the_distance_to_a_fine_outline(): + points = _points(PLATE, 400) + (loop,) = PLATE.outlines(max_spacing=1e-3) + distance = host( + segment_distance( + xp.asarray(points), + xp.asarray(loop), + xp.asarray(np.roll(loop, -1, axis=1)), + ) + ) + inside = host(PLATE.contains_points(points)) + reference = np.where(inside, -distance, distance) + assert PLATE.has_level_set and PLATE.level_set_exact + # the chords of the arcs are within r (1 - cos(h / 2r)) of the arc + np.testing.assert_allclose(host(PLATE.level_set(points)), reference, atol=1e-6) + + +def test_cuboid_level_set_is_the_distance_to_its_closest_points(): + points = _points(BLOCK) + closest = BLOCK.closest_point(points) + assert np.abs(host(BLOCK.level_set(closest))).max() < 1e-12 + distance = np.linalg.norm(points - closest, axis=0) + np.testing.assert_allclose(np.abs(host(BLOCK.level_set(points))), distance) + # in the middle plane: the rounded rectangle, or the distance 0.3 to the faces + rectangle = Rectangle((0.1, 0.0), (1.2, 0.8), fillet=0.2) + flat = points[:2] + middle = np.vstack((flat, np.full(flat.shape[1], -0.2))) + np.testing.assert_allclose( + host(BLOCK.level_set(middle)), + np.maximum(host(rectangle.level_set(flat)), -0.3), + ) + + +@pytest.mark.parametrize("radius", [0.05, 0.3, 0.5]) +def test_area_of_the_outline(radius): + w, h = 2.0, 1.0 + shape = Rectangle((0.0, 0.0), (w, h), fillet=radius) + (loop,) = shape.outlines(max_spacing=1e-3) + assert _signed_area(loop) == pytest.approx(w * h - (4 - np.pi) * radius**2, 1e-6) + + +def test_volume_and_surface_area(): + a, b, c = BLOCK._inner_sides() + r = BLOCK.fillet + assert BLOCK.volume() == pytest.approx( + a * b * c + + 2 * r * (a * b + b * c + c * a) + + np.pi * r**2 * (a + b + c) + + 4 / 3 * np.pi * r**3 + ) + ball = Cuboid((0, 0, 0), (1, 1, 1), fillet=0.5) + assert ball.volume() == pytest.approx(np.pi / 6) + assert ball.surface_area() == pytest.approx(np.pi) + assert Cuboid((0, 0, 0), (1, 2, 3), fillet=0).volume() == pytest.approx(6) + # the volume by the midpoint rule on 60 x 40 x 30 cells + lower, upper = np.asarray(BLOCK.lower_bound), np.asarray(BLOCK.upper_bound) + cells = np.array([60, 40, 30]) + width = (upper - lower) / cells + axes = [lo + (np.arange(n) + 0.5) * h for lo, n, h in zip(lower, cells, width)] + centres = np.stack([g.ravel() for g in np.meshgrid(*axes, indexing="ij")]) + inside = host(BLOCK.contains_points(centres)).sum() + assert inside * np.prod(width) == pytest.approx(BLOCK.volume(), rel=0.01) + + +def test_zero_radius_is_the_rectangle(): + points = _points(PLATE) + sharp = Rectangle((0.3, -0.2), (2.0, 1.0)) + nearly = Rectangle((0.3, -0.2), (2.0, 1.0), fillet=1e-12) + np.testing.assert_allclose( + host(nearly.level_set(points)), host(sharp.level_set(points)), atol=1e-11 + ) + np.testing.assert_array_equal( + host(nearly.contains_points(points)), host(sharp.contains_points(points)) + ) + (loop,) = Rectangle((0.0, 0.0), (2.0, 1.0), fillet=1e-12).outlines() + assert _signed_area(loop) == pytest.approx(2.0) + assert sharp.exact_outline and not PLATE.exact_outline + cube = Cuboid((0, 0, 0), (1.2, 0.8, 0.6)) + rounded = Cuboid((0, 0, 0), (1.2, 0.8, 0.6), fillet=1e-12) + points = _points(cube) + np.testing.assert_allclose( + host(rounded.level_set(points)), host(cube.level_set(points)), atol=1e-11 + ) + + +def test_half_the_shorter_side_is_the_stadium(): + stadium = Rectangle((0.3, -0.2), (2.0, 1.0), fillet=0.5) + points = _points(stadium) + np.testing.assert_allclose( + host(stadium.level_set(points)), + _capsule_distance(points, (-0.2, -0.2), (0.8, -0.2), 0.5), + atol=1e-12, + ) + disc = Rectangle((0.0, 0.0), (1.0, 1.0), fillet=0.5) + circle = Circle((0.0, 0.0), 0.5) + np.testing.assert_allclose( + host(disc.level_set(points)), host(circle.level_set(points)), atol=1e-12 + ) + # no zero-length edges where the flat sides vanish + for shape in (stadium, disc): + (loop,) = shape.outlines() + closed = np.concatenate((loop, loop[:, :1]), axis=1) + assert np.hypot(*np.diff(closed, axis=1)).min() > 1e-3 + + +@pytest.mark.parametrize("fillet", [0.3, 0.5]) +def test_outlines_are_closed_counter_clockwise_and_on_the_boundary(fillet): + shape = Rectangle((0.3, -0.2), (2.0, 1.0), fillet=fillet) + for max_spacing in (None, 0.05, lambda p: 0.02 + 0.05 * np.abs(p[0])): + (loop,) = shape.outlines(max_spacing) + assert loop.shape[0] == 2 and _signed_area(loop) > 0 + assert not np.allclose(loop[:, 0], loop[:, -1]) # not repeated + assert np.abs(host(shape.level_set(loop))).max() < 1e-12 + if max_spacing == 0.05: + closed = np.concatenate((loop, loop[:, :1]), axis=1) + assert np.hypot(*np.diff(closed, axis=1)).max() <= 0.05 + 1e-12 + # the straight edges stay single segments without max_spacing + (loop,) = shape.outlines() + bottom = np.isclose(loop[1], shape.lower_bound[1]) + assert bottom.sum() == 2 + + +def test_crossings_against_brute_force(): + shapes = [PLATE, PLATE.rotated(25).translated((0.1, 0.2)), BLOCK] + for shape in shapes: + start, end = _points(shape, 300), _points(shape, 300) + result = shape.crossings(start, end) + assert result.exact + samples = np.linspace(0.0, 1.0, 4001) + along = start[:, :, None] + (end - start)[:, :, None] * samples + flat = along.reshape(shape.ndim, -1) + inside = host(shape.contains_points(flat)).reshape(start.shape[1], -1) + change = inside[:, 1:] != inside[:, :-1] + hit = host(result.hit) + # a segment that only grazes the boundary between samples may differ + assert np.mean(hit == change.any(axis=1)) > 0.99 + both = hit & change.any(axis=1) + first = samples[np.argmax(change, axis=1)][both] + fraction = host(result.fraction)[both] + assert np.all((fraction >= first - 1e-12) & (fraction <= first + 2.5e-4)) + point, normal = host(result.point)[:, hit], host(result.normal)[:, hit] + assert np.abs(host(shape.level_set(point))).max() < 1e-12 + # the normals point out: a little along them is outside + assert not host(shape.contains_points(point + 1e-6 * normal)).any() + + +def test_segment_queries_and_normals(): + hits = PLATE.intersect_segment((-2.0, -0.2), (2.0, -0.2)) + assert hits.exact + np.testing.assert_allclose(hits.points[0], [-0.7, 1.3]) + corner = np.array([0.3 + 0.7 + 0.3 * np.cos(0.4), -0.2 + 0.2 + 0.3 * np.sin(0.4)]) + np.testing.assert_allclose( + PLATE.surface_normal(corner), [np.cos(0.4), np.sin(0.4)], atol=1e-12 + ) + np.testing.assert_allclose( + PLATE.signed_distance(np.array([1.3 + 0.5, 0.3 + 0.5])), + np.hypot(0.5 + 0.3, 0.5 + 0.3) - 0.3, + ) + + +def test_transforms_copies_and_validation(): + moved = PLATE.copy() + moved.translate((1.0, 0.0)) + moved.mirror(0, 0.0) + assert moved.fillet == PLATE.fillet + points = _points(PLATE) + np.testing.assert_allclose( + host(moved.level_set(points * [[-1], [1]] - [[1.0], [0.0]])), + host(PLATE.level_set(points)), + atol=1e-12, + ) + block = BLOCK.copy() + block.translate((0.0, 0.0, 1.0)) + assert block.fillet == BLOCK.fillet + with pytest.raises(ValueError, match="fillet"): + Rectangle((0, 0), (2.0, 1.0), fillet=0.6) + with pytest.raises(ValueError, match="fillet"): + Cuboid((0, 0, 0), (2.0, 1.0, 0.4), fillet=0.3) + with pytest.raises(ValueError, match="fillet"): + Rectangle((0, 0), (2.0, 1.0), fillet=-0.1) + + +def test_booleans_panels_meshes_and_slices(): + holed = PLATE - Circle((0.3, -0.2), 0.2) + areas = sorted(_signed_area(loop) for loop in holed.outlines(max_spacing=0.05)) + assert areas[0] == pytest.approx(-np.pi * 0.2**2, rel=1e-2) + assert areas[1] == pytest.approx(2.0 - (4 - np.pi) * 0.3**2, rel=1e-3) + panels = PLATE.boundary_panels(max_spacing=0.05) + perimeter = 2 * (2.0 + 1.0) - (8 - 2 * np.pi) * 0.3 + assert panels.length.sum() == pytest.approx(perimeter, rel=1e-3) + mesh = BLOCK.to_mesh(resolution=24) + # linear interpolation between float32 samples 0.03 to 0.06 apart + assert np.abs(host(BLOCK.level_set(mesh.vertices.T))).max() < 1e-2 + # 0.05 above the flat part of the sides + vertices, _ = BLOCK.get_vertices(slice_axis=2, slice_position=-0.05) + radius = np.sqrt(0.2**2 - 0.05**2) + section = Rectangle((0.1, 0.0), (0.8 + 2 * radius, 0.4 + 2 * radius), fillet=radius) + assert np.abs(host(section.level_set(host(vertices).T))).max() < 1e-12 diff --git a/tutorials/shapes_and_node_masks.ipynb b/tutorials/shapes_and_node_masks.ipynb index cb005ca..fbd567e 100644 --- a/tutorials/shapes_and_node_masks.ipynb +++ b/tutorials/shapes_and_node_masks.ipynb @@ -108,6 +108,42 @@ "assert np.array_equal(outside.node_mask(grid), ~mask)" ] }, + { + "cell_type": "markdown", + "id": "rounded-corners", + "metadata": {}, + "source": [ + "## Rounded corners\n", + "\n", + "Electrode fields are singular at sharp corners: the surface field on a rectangle's corner grows without bound as the mesh is refined, while on a rounded one it converges. `Rectangle(midpoint, side_lengths, fillet=r)` rounds the four corners with radius `r`, up to half the shorter side, where it becomes a stadium. `Cuboid(..., fillet=r)` rounds the edges and corners of a box in the same way. The level set stays the exact signed distance, the outline is straight edges plus arcs, and `crossings()` stays analytic." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "rounded-corners-code", + "metadata": {}, + "outputs": [], + "source": [ + "from simgeometry.objects import Rectangle\n", + "\n", + "plate = Rectangle(midpoint=(0.5, 0.5), side_lengths=(0.8, 0.3), fillet=0.08)\n", + "stadium = Rectangle(midpoint=(0.5, 0.15), side_lengths=(0.8, 0.2), fillet=0.1)\n", + "(loop,) = plate.outlines(max_spacing=0.02)\n", + "print(\"outline vertices:\", loop.shape[1])\n", + "print(\"distance from (1, 1):\", plate.signed_distance((1.0, 1.0)))\n", + "\n", + "fig, ax = plt.subplots(figsize=(5, 5))\n", + "for shape in (plate, stadium):\n", + " inside = shape.node_mask(grid).ravel()\n", + " ax.scatter(nodes[0][inside], nodes[1][inside], s=9)\n", + " (outline,) = shape.outlines()\n", + " ax.fill(*outline, fill=False)\n", + "ax.set(xlabel=\"x\", ylabel=\"y\", title=\"A rounded rectangle and a stadium\")\n", + "ax.set_aspect(\"equal\")\n", + "plt.show()" + ] + }, { "cell_type": "markdown", "id": "9", From 53ed9af3ea84033aecca2036c6ff5bfcbd24fd83 Mon Sep 17 00:00:00 2001 From: Max Date: Fri, 23 Oct 2026 13:22:02 +0200 Subject: [PATCH 11/36] SlantedTube: a tube with a slanted end (#27) --- CHANGELOG.md | 9 + .../docs/getting-started/geometry-api.md | 3 +- src/simgeometry/README.md | 2 +- src/simgeometry/__init__.py | 2 + src/simgeometry/crossings.py | 8 +- src/simgeometry/objects/README.md | 1 + src/simgeometry/objects/__init__.py | 2 + src/simgeometry/objects/slanted_tube.py | 339 ++++++++++++++++++ src/simgeometry/tests/test_slanted_tube.py | 290 +++++++++++++++ tutorials/shapes_and_node_masks.ipynb | 51 ++- 10 files changed, 702 insertions(+), 5 deletions(-) create mode 100644 src/simgeometry/objects/slanted_tube.py create mode 100644 src/simgeometry/tests/test_slanted_tube.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8539037..1fcd308 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- `SlantedTube(start, end, inner_radius, outer_radius, cut_normal)`: a tube (a rod with + `inner_radius=0`) along any axis whose end at `end` is cut by the plane through `end` + with the outward normal `cut_normal`, such as an oven nozzle cut at 45°. Membership, + bounds, volume and surface area are exact; the level set is the larger of the distances + to the uncut tube and to the cut plane (exact on the faces, a bound near the cut's rim); + `crossings` are analytic (both walls, the flat end and the cut), so particles in a thin + duct find every wall. Meshes, `show_3d`, node masks and conductors work as for the other + solids. The tutorial "Shapes and node masks" shows one. + - `Rectangle(..., fillet=r)`: a rectangle with rounded corners, up to a stadium at half the shorter side; `Cuboid(..., fillet=r)` rounds the edges and corners of a box, like the `fillet` of the solids of revolution. Both keep the exact level set (the rounded diff --git a/docs/src/content/docs/getting-started/geometry-api.md b/docs/src/content/docs/getting-started/geometry-api.md index d80a08f..751dc07 100644 --- a/docs/src/content/docs/getting-started/geometry-api.md +++ b/docs/src/content/docs/getting-started/geometry-api.md @@ -144,6 +144,7 @@ and where `level_set_exact` is true it is the Euclidean signed distance. | `Polygon`, `Triangle` | exact: distance to the nearest edge, signed by `contains` (one pass per edge) | | `Aperture2D` | exact while the plates do not touch | | `Cylinder`, `Tube`, `Cone`, `Torus` (any axis, with or without fillets) | exact | +| `SlantedTube` | the larger of the uncut tube's and the cut plane's: exact on the faces, a bound near the cut's rim | | `a \| b`, `a & b`, `a - b` | `min(a, b)`, `max(a, b)`, `max(a, -b)`: a bound, not exact | | rotations, translations, reflections, uniform scaling | exact if the source is | | nonuniform scaling, shears | the source's times the smallest stretch: a bound | @@ -246,7 +247,7 @@ end positions, first crosses the shape's boundary, entering or leaving. It retur `fraction` in (0, 1], `point` and a unit `normal` pointing out of the region where `contains` is true. Because the whole step is tested, a fast particle cannot pass through a thin plate between two end points. Polygonal 2D shapes, polylines, circles, spheres, -axis-aligned boxes and their affine transforms are exact. Other shapes are sampled at +axis-aligned boxes, slanted tubes and their affine transforms are exact. Other shapes are sampled at `samples` points per step and bisected, so features thinner than `1 / samples` of a step can be missed, and their normals are estimated (`exact` is `False`). A crossing at the very start of a step is not reported, so a particle stopped on a surface is not counted twice; diff --git a/src/simgeometry/README.md b/src/simgeometry/README.md index a45aec5..1747da2 100644 --- a/src/simgeometry/README.md +++ b/src/simgeometry/README.md @@ -44,5 +44,5 @@ executed into the documentation site. ## Subpackages -- `objects/` - Concrete geometric objects that can be embedded in a simulation domain, including the solids of revolution `Cylinder`, `Tube`, `Cone` and `Torus` (`objects/axisymmetric.py` holds their common frame). +- `objects/` - Concrete geometric objects that can be embedded in a simulation domain, including the solids of revolution `Cylinder`, `Tube`, `Cone` and `Torus` (`objects/axisymmetric.py` holds their common frame), and `SlantedTube`, a tube with a slanted end. - `tests/` - Test modules for `simgeometry`. diff --git a/src/simgeometry/__init__.py b/src/simgeometry/__init__.py index fbe10f9..8a19279 100644 --- a/src/simgeometry/__init__.py +++ b/src/simgeometry/__init__.py @@ -20,6 +20,7 @@ Polygon, Polyline, Rectangle, + SlantedTube, Sphere, Torus, Triangle, @@ -50,6 +51,7 @@ "Polygon", "Polyline", "Rectangle", + "SlantedTube", "Sphere", "Torus", "Triangle", diff --git a/src/simgeometry/crossings.py b/src/simgeometry/crossings.py index 9e6f0bc..fa947b1 100644 --- a/src/simgeometry/crossings.py +++ b/src/simgeometry/crossings.py @@ -5,8 +5,8 @@ shape's boundary, entering or leaving. Because the whole step is tested, not only its end point, a fast particle cannot pass through a thin plate unnoticed. -Polygonal 2D shapes (exact outlines), circles, spheres and axis-aligned boxes, also -with rounded edges (``fillet``), are analytic; affine transforms of them stay analytic. Every other shape (Boolean +Polygonal 2D shapes (exact outlines), circles, spheres, slanted tubes and axis-aligned +boxes, also with rounded edges (``fillet``), are analytic; affine transforms of them stay analytic. Every other shape (Boolean combinations, collections, function objects, ...) is sampled: membership is tested at ``samples`` points along each step and the first change is bisected, so features thinner than ``1 / samples`` of a step can be missed, and the normals are estimated. @@ -93,6 +93,7 @@ def _first(shape, a, d, samples, iterations): Line, Polygon, Rectangle, + SlantedTube, Sphere, Triangle, ) @@ -102,6 +103,9 @@ def _first(shape, a, d, samples, iterations): return _transformed(shape, a, d, samples, iterations) if isinstance(shape, (Circle, Sphere)): return (*_ball(shape, a, d), True) + if isinstance(shape, SlantedTube): + t, normal = shape._first_crossing(a, d, START_TOLERANCE) + return t, _flip(shape, normal), True if isinstance(shape, (Rectangle, Cuboid)) and shape.fillet > 0: return (*_rounded_box(shape, a, d), True) if isinstance(shape, (Rectangle, Cuboid, Line)): diff --git a/src/simgeometry/objects/README.md b/src/simgeometry/objects/README.md index 580771e..7add67d 100644 --- a/src/simgeometry/objects/README.md +++ b/src/simgeometry/objects/README.md @@ -13,5 +13,6 @@ Concrete geometric objects that can be embedded in a simulation domain. - `polygon.py` - Polygon helpers for geometric object definitions. - `polyline.py` - Open polylines: curves of zero thickness (thin plates, wires). - `rectangle.py` - Rectangles, optionally with rounded corners (stadiums). +- `slanted_tube.py` - Tubes and rods whose far end is cut by a slanted plane. - `sphere.py` - Sphere helpers for geometric object definitions. - `triangle.py` - Triangle helpers for geometric object definitions. diff --git a/src/simgeometry/objects/__init__.py b/src/simgeometry/objects/__init__.py index a32c23c..563a8fa 100644 --- a/src/simgeometry/objects/__init__.py +++ b/src/simgeometry/objects/__init__.py @@ -13,6 +13,7 @@ from simgeometry.objects.polygon import Polygon from simgeometry.objects.polyline import Polyline from simgeometry.objects.rectangle import Rectangle +from simgeometry.objects.slanted_tube import SlantedTube # 3D objects from simgeometry.objects.sphere import Sphere @@ -35,6 +36,7 @@ "Polygon", "Polyline", "Rectangle", + "SlantedTube", # 3D objects "Sphere", "Torus", diff --git a/src/simgeometry/objects/slanted_tube.py b/src/simgeometry/objects/slanted_tube.py new file mode 100644 index 0000000..fb3577d --- /dev/null +++ b/src/simgeometry/objects/slanted_tube.py @@ -0,0 +1,339 @@ +"""Tubes (and rods) along any axis whose far end is cut by a slanted plane.""" + +from __future__ import annotations + +import cunumpy as xp +import numpy as np + +from simgeometry._types import Array, BoundsLike, ConfigValue +from simgeometry.objects.axisymmetric import _vector +from simgeometry.shapes import Shape3D, _component_major + + +def _orthonormal(axis: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + """Two unit vectors normal to ``axis`` and to each other.""" + helper = np.eye(3)[int(np.argmin(np.abs(axis)))] + first = np.cross(axis, helper) + first /= np.linalg.norm(first) + return first, np.cross(axis, first) + + +class SlantedTube(Shape3D): + """A tube from ``start`` to ``end`` whose end at ``end`` is cut by a slanted plane. + + The wall lies between ``inner_radius`` and ``outer_radius`` about the axis from + ``start`` to ``end``; ``inner_radius = 0`` gives a solid rod. The end at + ``start`` is the flat face normal to the axis. The end at ``end`` is the plane + through ``end`` with the outward normal ``cut_normal``, tilted from the axis by + :attr:`cut_angle`, like the mouth of an oven nozzle cut at 45°. ``length`` is + measured along the axis to the centre of the cut, and the cut may not reach the + start face (``outer_radius * tan(cut_angle) < length``). + + The :meth:`level_set` is the larger of the signed distance to the tube without + the cut and to the cut plane: exact on the faces and inside away from the cut's + rim, a bound elsewhere (``level_set_exact`` is False). :func:`~simgeometry.crossings` + are analytic. + + Example + ------- + A duct along z, 0.1 long, bore radius 0.01, wall 2 mm, cut at 45° so that its + mouth faces down (-y):: + + c = np.cos(np.pi / 4) + nozzle = SlantedTube((0, 0, 0), (0, 0, 0.1), 0.01, 0.012, (0, -c, c)) + """ + + has_level_set = True + level_set_exact = False + + def __init__( + self, + start: BoundsLike, + end: BoundsLike, + inner_radius: float, + outer_radius: float, + cut_normal: BoundsLike, + bc: ConfigValue = None, + inverted: bool = False, + ) -> None: + """ + Initialize a slanted tube in 3D. + + Parameters: + start (list or tuple): Centre of the flat end face. + end (list or tuple): Centre of the slanted cut, on the axis. + inner_radius (float): Radius of the bore, zero (a rod) or positive. + outer_radius (float): Outer radius, larger than ``inner_radius``. + cut_normal (list or tuple): Outward normal of the cut plane; its component + along the axis (from ``start`` to ``end``) must be positive. + bc (optional): Boundary condition information. + inverted (bool, optional): Cover everything except the tube. + """ + start, end = _vector(start, "start"), _vector(end, "end") + axis = end - start + length = float(np.linalg.norm(axis)) + if length == 0: + raise ValueError("start and end must differ") + if not 0 <= inner_radius < outer_radius: + raise ValueError("A slanted tube needs 0 <= inner_radius < outer_radius") + normal = _vector(cut_normal, "cut_normal") + if np.linalg.norm(normal) == 0: + raise ValueError("cut_normal must have a nonzero length") + self._start = start + self._length = length + self._axis = axis / length + self._normal = normal / np.linalg.norm(normal) + self.inner_radius = float(inner_radius) + self.outer_radius = float(outer_radius) + self._check_cut() + self._bc = bc + self._inverted = bool(inverted) + super().__init__(*self._bounds()) + + def _check_cut(self) -> None: + cosine = float(self._normal @ self._axis) + if cosine <= 0: + raise ValueError("cut_normal must point along the axis, away from start") + tangent = np.sqrt(max(1.0 - cosine**2, 0.0)) / cosine + if self.outer_radius * tangent >= self._length: + raise ValueError( + "The cut reaches the start face: need outer_radius * tan(angle) < length" + ) + + @property + def start(self) -> tuple[float, float, float]: + """The centre of the flat end face.""" + return tuple(float(c) for c in self._start) + + @property + def end(self) -> tuple[float, float, float]: + """The centre of the slanted cut, on the axis.""" + return tuple(float(c) for c in self._start + self._length * self._axis) + + @property + def axis(self) -> tuple[float, float, float]: + """The unit vector from ``start`` to ``end``.""" + return tuple(float(a) for a in self._axis) + + @property + def cut_normal(self) -> tuple[float, float, float]: + """The unit outward normal of the cut plane.""" + return tuple(float(a) for a in self._normal) + + @property + def cut_angle(self) -> float: + """The angle between the cut plane and the cross-section, in degrees.""" + cosine = float(np.clip(self._normal @ self._axis, -1.0, 1.0)) + return float(np.degrees(np.arccos(cosine))) + + @property + def length(self) -> float: + """The distance from ``start`` to ``end`` along the axis.""" + return self._length + + def _bounds(self) -> tuple[tuple[float, ...], tuple[float, ...]]: + """The box around the start disc and the elliptic rim of the cut.""" + radius = self.outer_radius + first, second = _orthonormal(self._axis) + disc = radius * np.sqrt(np.clip(1.0 - self._axis**2, 0.0, None)) + # rim: end + R (cos f u + sin f v) + s(f) a with s chosen to stay on the plane + along = self._normal @ self._axis + u = first - self._axis * (self._normal @ first) / along + v = second - self._axis * (self._normal @ second) / along + rim = radius * np.sqrt(u**2 + v**2) + end = np.asarray(self.end) + lower = np.minimum(self._start - disc, end - rim) + upper = np.maximum(self._start + disc, end + rim) + return tuple(float(c) for c in lower), tuple(float(c) for c in upper) + + def _update_bounds(self) -> None: + self.set_bounding_box(*self._bounds()) + + def copy(self) -> SlantedTube: + """Create a copy of the slanted tube.""" + return SlantedTube( + self.start, + self.end, + self.inner_radius, + self.outer_radius, + self.cut_normal, + bc=self._bc, + inverted=self._inverted, + ) + + def _coordinates(self, points: Array): + """Axial distance from start, distance from the axis and height above the cut.""" + relative = points - xp.asarray(self._start)[:, None] + axis = xp.asarray(self._axis)[:, None] + s = xp.sum(relative * axis, axis=0) + across = relative - s[None, :] * axis + rho = xp.sqrt(xp.sum(across * across, axis=0)) + above = xp.sum(relative * xp.asarray(self._normal)[:, None], axis=0) - ( + self._length * float(self._normal @ self._axis) + ) + return s, rho, above + + def contains_points(self, points: Array) -> Array: + """Vectorised :meth:`contains`: a mask of the ``(3, n)`` points inside.""" + points = _component_major(points, 3) + s, rho, above = self._coordinates(points) + inside = ( + (s >= 0) + & (above <= 0) + & (rho <= self.outer_radius) + & (rho >= self.inner_radius) + ) + return ~inside if self.inverted else inside + + def contains(self, coords: Array) -> bool: + """ + Check if the given coordinates are inside the slanted tube. + + Parameters: + coords (list or tuple): Coordinates (x, y, z). + + Returns: + bool: True if the point is inside the object, False otherwise. + """ + point = xp.asarray(coords, dtype=float).reshape(3, 1) + return bool(xp.to_numpy(self.contains_points(point))[0]) + + def _level_set(self, points: Array) -> Array: + """Larger of the distance to the uncut half-infinite tube and to the cut plane.""" + s, rho, above = self._coordinates(points) + if self.inner_radius > 0: + radial = xp.maximum(self.inner_radius - rho, rho - self.outer_radius) + else: + radial = rho - self.outer_radius + axial = -s + outside = xp.sqrt(xp.maximum(radial, 0.0) ** 2 + xp.maximum(axial, 0.0) ** 2) + tube = outside + xp.minimum(xp.maximum(radial, axial), 0.0) + return xp.maximum(tube, above) + + def translate(self, offset: tuple[float, float, float]) -> None: + """ + Translate the slanted tube by the given offset. + + Parameters: + offset (tuple): (dx, dy, dz) translation vector. + """ + self._start = self._start + _vector(offset, "offset") + self._update_bounds() + + def mirror(self, axis: int, position: float) -> None: + """ + Mirror the slanted tube across the plane where coordinate ``axis`` is ``position``. + + Parameters: + axis (int): Axis index (0 for x-axis, 1 for y-axis, 2 for z-axis). + position (float): Position along the axis to mirror. + """ + if axis < 0 or axis > 2: + raise ValueError("Axis must be 0 (x-axis), 1 (y-axis), or 2 (z-axis).") + self._start = self._start.copy() + self._start[axis] = 2 * position - self._start[axis] + self._axis = self._axis.copy() + self._axis[axis] = -self._axis[axis] + self._normal = self._normal.copy() + self._normal[axis] = -self._normal[axis] + self._update_bounds() + + def volume(self) -> float: + """Calculate the volume: a cut through the axis point leaves that of a straight tube.""" + return float( + np.pi * (self.outer_radius**2 - self.inner_radius**2) * self._length + ) + + def surface_area(self) -> float: + """Calculate the surface area: both walls, the flat end and the elliptic cut.""" + ri, ro = self.inner_radius, self.outer_radius + annulus = np.pi * (ro**2 - ri**2) + cosine = float(self._normal @ self._axis) + walls = 2 * np.pi * (ro + ri) * self._length + return float(walls + annulus + annulus / cosine) + + def _first_crossing(self, a: Array, d: Array, start_tolerance: float): + """Fraction ``(N,)`` (inf without a hit) and outward normal of the first crossing. + + Candidates are the roots on the four surfaces (outer and inner cylinder, + start plane, cut plane) in ``(start_tolerance, 1]``; a root counts where the + point lies within the other three constraints. Tangent roots are not + crossings. The normal is that of the solid, before any inversion. + """ + n = a.shape[1] + origin = xp.asarray(self._start)[:, None] + axis = xp.asarray(self._axis)[:, None] + normal = xp.asarray(self._normal)[:, None] + w = a - origin + w_s = xp.sum(w * axis, axis=0) + d_s = xp.sum(d * axis, axis=0) + w_perp = w - w_s[None, :] * axis + d_perp = d - d_s[None, :] * axis + w_n = xp.sum(w * normal, axis=0) - self._length * float( + self._normal @ self._axis + ) + d_n = xp.sum(d * normal, axis=0) + scale = max(self._length, self.outer_radius) + slack = 1e-12 * scale + + def _within(t: Array, skip: str) -> Array: + s = w_s + t * d_s + rho2 = xp.sum((w_perp + t[None, :] * d_perp) ** 2, axis=0) + above = w_n + t * d_n + ok = xp.ones(t.shape, dtype=bool) + if skip != "start": + ok &= s >= -slack + if skip != "cut": + ok &= above <= slack + if skip != "outer": + ok &= rho2 <= (self.outer_radius + slack) ** 2 + if skip != "inner" and self.inner_radius > 0: + ok &= rho2 >= (self.inner_radius - slack) ** 2 + return ok + + best = xp.full(n, xp.inf) + kind = xp.full(n, -1, dtype=int) + + def _offer(t: Array, valid: Array, label: int, skip: str) -> None: + nonlocal best, kind + valid = valid & (t > start_tolerance) & (t <= 1.0) + t_safe = xp.where(valid, t, 0.0) + valid = valid & _within(t_safe, skip) + better = valid & (t < best) + best = xp.where(better, t, best) + kind = xp.where(better, label, kind) + + for t, valid, label, skip in _plane_roots(w_s, d_s, w_n, d_n): + _offer(t, valid, label, skip) + aa = xp.sum(d_perp * d_perp, axis=0) + bb = xp.sum(w_perp * d_perp, axis=0) + ww = xp.sum(w_perp * w_perp, axis=0) + radii = [(self.outer_radius, 2, "outer")] + if self.inner_radius > 0: + radii.append((self.inner_radius, 3, "inner")) + for radius, label, skip in radii: + disc = bb * bb - aa * (ww - radius**2) + valid = (aa > 0) & (disc > 0) + root = xp.sqrt(xp.where(valid, disc, 0.0)) + safe = xp.where(aa > 0, aa, 1.0) + for sign in (-1.0, 1.0): + _offer((-bb + sign * root) / safe, valid, label, skip) + + point_rel = w_perp + xp.where(xp.isfinite(best), best, 0.0)[None, :] * d_perp + rho = xp.sqrt(xp.sum(point_rel * point_rel, axis=0)) + radial = point_rel / xp.where(rho > 0, rho, 1.0)[None, :] + out = xp.zeros((3, n)) + out = xp.where((kind == 0)[None, :], -axis, out) + out = xp.where((kind == 1)[None, :], normal, out) + out = xp.where((kind == 2)[None, :], radial, out) + out = xp.where((kind == 3)[None, :], -radial, out) + return best, out + + +def _plane_roots(w_s, d_s, w_n, d_n): + """Crossings with the start plane (label 0) and the cut plane (label 1).""" + moving_s = d_s != 0 + t_start = -w_s / xp.where(moving_s, d_s, 1.0) + moving_n = d_n != 0 + t_cut = -w_n / xp.where(moving_n, d_n, 1.0) + return [(t_start, moving_s, 0, "start"), (t_cut, moving_n, 1, "cut")] diff --git a/src/simgeometry/tests/test_slanted_tube.py b/src/simgeometry/tests/test_slanted_tube.py new file mode 100644 index 0000000..ddc2db1 --- /dev/null +++ b/src/simgeometry/tests/test_slanted_tube.py @@ -0,0 +1,290 @@ +"""SlantedTube: membership, volume, bounds, level set, crossings, conductors and views.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from simgeometry import GeometryCollection, Grid, SlantedTube, Tube +from simgeometry.crossings import crossings + +RNG = np.random.default_rng(11) +C45 = np.cos(np.pi / 4) + +SHAPES = { + "nozzle z": SlantedTube((0, 0, 0), (0, 0, 1.0), 0.2, 0.3, (0, -C45, C45)), + "rod oblique": SlantedTube( + (0.1, -0.2, 0.0), (0.7, 0.2, 0.4), 0.0, 0.25, (0.6, 0.0, 0.8) + ), + "tube steep": SlantedTube( + (0, 0, 0), (1.0, 0, 0), 0.1, 0.35, (np.cos(1.1), np.sin(1.1), 0.0) + ), +} + + +def _box_points(shape, n, pad=0.0): + lower = np.asarray(shape.lower_bound) - pad + upper = np.asarray(shape.upper_bound) + pad + return RNG.uniform(lower[:, None], upper[:, None], (3, n)), np.prod(upper - lower) + + +def _frame(shape): + axis = np.asarray(shape.axis) + u = np.cross(axis, np.eye(3)[np.argmin(np.abs(axis))]) + u /= np.linalg.norm(u) + return axis, u, np.cross(axis, u) + + +def _surface(shape, spacing): + """Dense points on the four faces: both walls, the flat end and the cut.""" + axis, u, v = _frame(shape) + start, normal = np.asarray(shape.start), np.asarray(shape.cut_normal) + end = np.asarray(shape.end) + ri, ro = shape.inner_radius, shape.outer_radius + phi = np.linspace(0, 2 * np.pi, int(2 * np.pi * ro / spacing) + 1, endpoint=False) + ring = np.cos(phi)[:, None] * u + np.sin(phi)[:, None] * v # (m, 3) + # axial position of the cut at each azimuth, for a point at radius r + cut_s = lambda r: shape.length - r * (ring @ normal) / (normal @ axis) # noqa: E731 + points = [] + for r in [ro] + ([ri] if ri > 0 else []): + top = cut_s(r) + for k in range(len(phi)): + s = np.linspace(0, top[k], int(top[k] / spacing) + 2) + points.append(start + s[:, None] * axis + r * ring[k]) + radii = np.linspace(ri, ro, max(2, int((ro - ri) / spacing) + 1)) + for r in radii: + points.append(start + r * ring) # flat end + points.append(start + cut_s(r)[:, None] * axis + r * ring) # cut + surface = np.concatenate(points) + # every point on the cut lies on its plane + assert np.allclose((surface - end) @ normal <= 1e-12, True) + return surface + + +@pytest.mark.parametrize("name", SHAPES) +def test_contains_and_level_set_signs_agree(name): + shape = SHAPES[name] + points, _ = _box_points(shape, 20000, pad=0.2) + values = shape.level_set(points) + inside = shape.contains_points(points) + clear = np.abs(values) > 1e-12 + np.testing.assert_array_equal((values < 0)[clear], inside[clear]) + assert inside.any() and not inside.all() + assert shape.has_level_set and not shape.level_set_exact + for point in points[:, :20].T: + assert shape.contains(point) == bool(shape.contains_points(point[:, None])[0]) + + +@pytest.mark.parametrize("name", SHAPES) +def test_monte_carlo_volume(name): + shape = SHAPES[name] + n = 400_000 + points, box_volume = _box_points(shape, n) + fraction = float(np.mean(shape.contains_points(points))) + sigma = box_volume * np.sqrt(fraction * (1 - fraction) / n) + assert fraction * box_volume == pytest.approx(shape.volume(), abs=4 * sigma) + + +@pytest.mark.parametrize("name", SHAPES) +def test_bounding_box_is_tight(name): + shape = SHAPES[name] + surface = _surface(shape, spacing=0.005) + np.testing.assert_allclose(surface.min(axis=0), shape.lower_bound, atol=2e-3) + np.testing.assert_allclose(surface.max(axis=0), shape.upper_bound, atol=2e-3) + + +@pytest.mark.parametrize("name", SHAPES) +def test_level_set_is_zero_on_the_surface_and_bounds_the_distance(name): + spatial = pytest.importorskip("scipy.spatial") + shape = SHAPES[name] + spacing = 0.005 + surface = _surface(shape, spacing) + np.testing.assert_allclose(shape.level_set(surface.T), 0.0, atol=1e-12) + points, _ = _box_points(shape, 2000, pad=0.3) + distance, _ = spatial.cKDTree(surface).query(points.T) + values = np.abs(shape.level_set(points)) + # a bound: never more than the distance (up to the sampling of the surface) + assert np.all(values <= distance + 1e-12) + # and the distance itself for most points (away from the rim of the cut) + close = np.abs(distance - values) <= spacing + assert close.mean() > 0.6 + + +def test_square_cut_is_the_tube(): + """With the cut normal along the axis the shape is a :class:`Tube`.""" + slanted = SlantedTube((0, 0, -0.5), (0, 0, 0.5), 0.3, 0.6, (0, 0, 2.0)) + tube = Tube((0, 0, -0.5), (0, 0, 0.5), 0.3, 0.6) + assert slanted.cut_angle == pytest.approx(0.0) + points, _ = _box_points(tube, 20000, pad=0.3) + np.testing.assert_array_equal( + slanted.contains_points(points), tube.contains_points(points) + ) + inside = tube.contains_points(points) + np.testing.assert_allclose( + slanted.level_set(points)[inside], tube.level_set(points)[inside], atol=1e-12 + ) + np.testing.assert_allclose(slanted.lower_bound, tube.lower_bound, atol=1e-12) + np.testing.assert_allclose(slanted.upper_bound, tube.upper_bound, atol=1e-12) + assert slanted.volume() == pytest.approx(tube.volume()) + assert slanted.surface_area() == pytest.approx(tube.surface_area()) + + +def test_surface_areas(): + pytest.importorskip("skimage") + for name, shape in SHAPES.items(): + mesh = shape.to_mesh(resolution=128) + faces = mesh.vertices[mesh.cells] + area = 0.5 * np.linalg.norm( + np.cross(faces[:, 1] - faces[:, 0], faces[:, 2] - faces[:, 0]), axis=1 + ) + assert area.sum() == pytest.approx(shape.surface_area(), rel=0.03), name + + +def _brute_force_first(shape, a, b, samples=40001): + """First membership change along each segment, from dense samples.""" + t = np.linspace(0, 1, samples) + first = np.full(a.shape[1], np.inf) + for i in range(a.shape[1]): + points = a[:, i, None] + t[None, :] * (b - a)[:, i, None] + inside = shape.contains_points(points) + change = np.flatnonzero(inside[1:] != inside[:-1]) + if change.size: + first[i] = t[change[0] + 1] + return first + + +@pytest.mark.parametrize("name", SHAPES) +@pytest.mark.parametrize("inverted", [False, True]) +def test_analytic_crossings_match_dense_sampling(name, inverted): + shape = SHAPES[name].copy() + shape._inverted = inverted + a, _ = _box_points(shape, 300, pad=0.2) + b, _ = _box_points(shape, 300, pad=0.2) + result = crossings(shape, a, b) + assert result.exact + reference = _brute_force_first(shape, a, b) + np.testing.assert_array_equal(result.hit, np.isfinite(reference)) + assert result.hit.sum() > 100 + hit = result.hit + step = np.linalg.norm(b - a, axis=0) + np.testing.assert_array_less( + np.abs(result.fraction[hit] - reference[hit]) * step[hit], 1e-4 + ) + # on the boundary, the normal out of the contains-region + points = result.point[:, hit] + np.testing.assert_allclose(shape.level_set(points), 0.0, atol=1e-9) + np.testing.assert_allclose(np.linalg.norm(result.normal[:, hit], axis=0), 1.0) + probe = points + 1e-6 * result.normal[:, hit] + assert not shape.contains_points(probe).any() + assert shape.contains_points(points - 1e-6 * result.normal[:, hit]).all() + + +def test_crossings_of_a_bore_and_a_rotated_copy(): + """A step along the bore leaves through the cut's opening; rotated stays analytic.""" + nozzle = SHAPES["nozzle z"] + # along the axis inside the bore: no crossing, the bore is open at both ends + a = np.array([[0.0], [0.0], [-0.5]]) + b = np.array([[0.0], [0.0], [2.0]]) + assert not crossings(nozzle, a, b).hit[0] + # from the bore radially out through the wall, below the cut's lowest point + a = np.array([[0.0, 0.0], [0.0, 0.0], [0.3, 0.6]]) + b = np.array([[0.0, 0.0], [1.0, -1.0], [0.3, 0.6]]) + result = crossings(nozzle, a, b) + np.testing.assert_allclose(result.fraction, [0.2, 0.2]) + np.testing.assert_allclose(result.normal[:, 0], [0, -1, 0], atol=1e-12) + np.testing.assert_allclose(result.normal[:, 1], [0, 1, 0], atol=1e-12) + # into the cut face from outside, along -normal + normal = np.asarray(nozzle.cut_normal) + rim_point = np.asarray(nozzle.end) + 0.25 * np.array([1.0, 0, 0]) + a = (rim_point + 0.1 * normal)[:, None] + b = (rim_point - 0.1 * normal)[:, None] + result = crossings(nozzle, a, b) + assert result.hit[0] and result.fraction[0] == pytest.approx(0.5) + np.testing.assert_allclose(result.normal[:, 0], normal, atol=1e-12) + rotated = nozzle.rotated(30, axis=(1, 0, 0)) + ends_a, _ = _box_points(rotated, 200, pad=0.2) + ends_b, _ = _box_points(rotated, 200, pad=0.2) + result = crossings(rotated, ends_a, ends_b) + assert result.exact + np.testing.assert_array_equal( + result.hit, np.isfinite(_brute_force_first(rotated, ends_a, ends_b, 8001)) + ) + + +def test_constructor_checks_and_properties(): + nozzle = SlantedTube((0, 0, 0), (0, 0, 2), 0.1, 0.2, (0, -1, 1)) + assert nozzle.length == 2 and nozzle.axis == (0, 0, 1) + assert nozzle.cut_angle == pytest.approx(45.0) + np.testing.assert_allclose(nozzle.cut_normal, (0, -C45, C45)) + assert nozzle.end == (0, 0, 2) and nozzle.start == (0, 0, 0) + with pytest.raises(ValueError): + SlantedTube((0, 0, 0), (0, 0, 0), 0.1, 0.2, (0, 0, 1)) + with pytest.raises(ValueError): + SlantedTube((0, 0, 0), (0, 0, 1), 0.3, 0.2, (0, 0, 1)) + with pytest.raises(ValueError): + SlantedTube((0, 0, 0), (0, 0, 1), 0.1, 0.2, (0, 1, 0)) # cut along the axis + with pytest.raises(ValueError): + SlantedTube((0, 0, 0), (0, 0, 1), 0.1, 0.2, (0, 0, -1)) + with pytest.raises(ValueError): + SlantedTube((0, 0, 0), (0, 0, 0.1), 0.1, 0.2, (0, -1, 1)) # cut too long + with pytest.raises(ValueError): + SlantedTube((0, 0, 0), (0, 0, 1), 0.1, 0.2, (0, 0, 0)) + + +def test_copy_translate_mirror_and_inversion(): + nozzle = SlantedTube((0, 0, 0), (0, 0, 1), 0.1, 0.2, (0, -1, 1), bc=2.0) + # below the axis the duct is shorter than above it + assert nozzle.contains((0, 0.15, 1.1)) and not nozzle.contains((0, -0.15, 0.9)) + moved = nozzle.copy() + moved.translate((1, 0, 0)) + assert moved.contains((1, 0.15, 1.1)) and not moved.contains((0, 0.15, 1.1)) + assert nozzle.contains((0, 0.15, 1.1)) and moved._bc == 2.0 + np.testing.assert_allclose(moved.lower_bound, np.add(nozzle.lower_bound, (1, 0, 0))) + flipped = nozzle.copy() + flipped.mirror(1, 0.0) + assert flipped.contains((0, -0.15, 1.1)) and not flipped.contains((0, 0.15, 0.9)) + np.testing.assert_allclose(flipped.cut_normal, (0, C45, C45)) + np.testing.assert_allclose(flipped.upper_bound[1], -nozzle.lower_bound[1]) + hole = SlantedTube((0, 0, 0), (0, 0, 1), 0.1, 0.2, (0, -1, 1), inverted=True) + assert hole.contains((0, 0, 0.5)) and not hole.contains((0, 0.15, 0.5)) + assert hole.level_set(np.array([[0.0], [0.15], [0.5]]))[0] == pytest.approx(0.05) + + +def test_grids_conductors_and_views(): + nozzle = SlantedTube((0, 0, -0.5), (0, 0, 0.5), 0.2, 0.35, (0, -1, 1)) + grid = Grid( + ndim=3, + lower_bound=(-0.6, -0.6, -0.8), + upper_bound=(0.6, 0.6, 1.0), + num_gridpoints=(13, 13, 19), + ) + np.testing.assert_array_equal( + nozzle.node_mask(grid).ravel(), nozzle.contains_points(grid.node_points()) + ) + oven = GeometryCollection( + {"nozzle": nozzle, "wall": Tube((0, 0, -0.8), (0, 0, -0.6), 0.1, 0.6)}, + voltages={"nozzle": 5.0, "wall": 0.0}, + ) + assert oven.conductors == ("nozzle", "wall") + values = oven.dirichlet_values(grid.node_points()) + inside = nozzle.contains_points(grid.node_points()) + assert inside.any() + np.testing.assert_allclose(values.voltages[inside], 5.0) + samples = nozzle.sample_interior(200, rng=1) + assert nozzle.contains_points(samples).all() + + +def test_show_3d_and_mesh(): + pytest.importorskip("matplotlib") + pytest.importorskip("skimage") + import matplotlib + + matplotlib.use("Agg") + import matplotlib.pyplot as plt + + nozzle = SHAPES["nozzle z"] + nozzle.show_3d(resolution=32, display=False) + nozzle.slice(x=0).show(resolution=64, display=False) + surface = nozzle.sample_surface(100, rng=2, resolution=64) + assert np.max(np.abs(nozzle.level_set(surface))) < 0.02 + plt.close("all") diff --git a/tutorials/shapes_and_node_masks.ipynb b/tutorials/shapes_and_node_masks.ipynb index fbd567e..c484311 100644 --- a/tutorials/shapes_and_node_masks.ipynb +++ b/tutorials/shapes_and_node_masks.ipynb @@ -15,7 +15,7 @@ "id": "1", "metadata": {}, "source": [ - "The `simgeometry.objects` package includes `Line` in 1D; `Circle`, `Rectangle`, `Triangle`, `Polygon`, and apertures in 2D; and `Sphere`, `Cuboid`, `Cylinder`, `Tube`, `Cone`, `Torus`, and apertures in 3D. The same `contains()`, `contains_points()`, and `node_mask()` pattern works across these shapes." + "The `simgeometry.objects` package includes `Line` in 1D; `Circle`, `Rectangle`, `Triangle`, `Polygon`, and apertures in 2D; and `Sphere`, `Cuboid`, `Cylinder`, `Tube`, `SlantedTube`, `Cone`, `Torus`, and apertures in 3D. The same `contains()`, `contains_points()`, and `node_mask()` pattern works across these shapes." ] }, { @@ -190,6 +190,55 @@ ")" ] }, + { + "cell_type": "markdown", + "id": "slanted-tube", + "metadata": {}, + "source": [ + "## A tube with a slanted end\n", + "\n", + "`SlantedTube(start, end, inner_radius, outer_radius, cut_normal)` is a tube whose end at `end` is cut by the plane through `end` with the outward normal `cut_normal`, like the mouth of a caesium oven nozzle cut at 45°; `inner_radius=0` gives a solid rod. Its level set is a bound on the distance (exact on the faces), and `crossings` with it are analytic, so particles bouncing inside a thin duct find every wall. Below, steps from inside the bore to random points in all directions: the ones that leave through the open mouth have no crossing, the others stop on the wall." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "slanted-tube-code", + "metadata": {}, + "outputs": [], + "source": [ + "from simgeometry import SlantedTube\n", + "from simgeometry.crossings import crossings\n", + "\n", + "c = np.cos(np.pi / 4)\n", + "nozzle = SlantedTube((0, 0, 0), (0, 0, 1.0), 0.15, 0.2, (0, -c, c))\n", + "print(f\"cut angle {nozzle.cut_angle:.0f} deg, volume {nozzle.volume():.4f}\")\n", + "rng = np.random.default_rng(0)\n", + "count = 400\n", + "start = np.zeros((3, count))\n", + "start[2] = 0.8 # on the axis, inside the bore\n", + "direction = rng.normal(size=(3, count))\n", + "direction /= np.linalg.norm(direction, axis=0)\n", + "hits = crossings(nozzle, start, start + 2.0 * direction)\n", + "print(\"steps stopped by the wall:\", int(hits.hit.sum()), \"of\", count)\n", + "plotter = nozzle.show_3d(\n", + " backend=\"pyvista\",\n", + " display=False,\n", + " resolution=64,\n", + " opacity=0.3,\n", + " show_bounds=False,\n", + " points={\n", + " \"crossings\": {\n", + " \"points\": hits.point[:, hits.hit],\n", + " \"vectors\": hits.normal[:, hits.hit],\n", + " }\n", + " },\n", + ")\n", + "plasma_plots.scene(\n", + " plotter, alt=\"A tube cut at 45 degrees, with the crossings of steps from its bore\"\n", + ")" + ] + }, { "cell_type": "markdown", "id": "11", From b5ba8cdc9764d2ec2b25255ae9a4fc1d0933bd6f Mon Sep 17 00:00:00 2001 From: Max Date: Fri, 23 Oct 2026 14:11:41 +0200 Subject: [PATCH 12/36] Yee-grid cut-cell fractions (#26) * Add Yee-grid edge and face fractions with surface traces for cut cells * Gauss quadrature in faces and staircase treatment of walls on grid lines * Changelog: walls on grid lines * Faces lying in a surface count as inside * Breakpoints at corners of the trace inside a face * Walls in grid planes count as inside also where they cover an edge or face partly --- CHANGELOG.md | 8 + .../docs/getting-started/geometry-api.md | 22 + src/simgeometry/api.py | 9 + src/simgeometry/fractions.py | 440 ++++++++++++++++++ src/simgeometry/tests/test_yee_fractions.py | 262 +++++++++++ 5 files changed, 741 insertions(+) create mode 100644 src/simgeometry/tests/test_yee_fractions.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fcd308..e7c782a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- `shape.yee_fractions(grid)` (`simgeometry.fractions.yee_fractions`): the cut-cell + geometry of a Yee grid in 2D and 3D, for conformal (Dey–Mittra-type) field solvers: the + fraction of every edge's length and every face's area inside the shape, the length of the + surface's trace in every face and that length weighted by the sine between the surface + and the face. Exact for planes; for curved surfaces second order (a circle's area and + perimeter, a sphere's area from the traces). Edges use all crossings, so slits narrower + than a cell are kept; walls on grid lines count like a staircase. Works for every shape + `crossings` supports, sampled where it is not analytic. - `SlantedTube(start, end, inner_radius, outer_radius, cut_normal)`: a tube (a rod with `inner_radius=0`) along any axis whose end at `end` is cut by the plane through `end` with the outward normal `cut_normal`, such as an oven nozzle cut at 45°. Membership, diff --git a/docs/src/content/docs/getting-started/geometry-api.md b/docs/src/content/docs/getting-started/geometry-api.md index 751dc07..67f8ea3 100644 --- a/docs/src/content/docs/getting-started/geometry-api.md +++ b/docs/src/content/docs/getting-started/geometry-api.md @@ -37,6 +37,7 @@ shell.slice(origin=(0, 0, 0), normal=(1, 1, 1)).show_3d(backend="pyvista") | `intersect_segment(start, end)` | Two coordinate vectors | Ordered hits, fractions, normals and crossing directions | | `cut_edges(grid)` | Grid | All detected edge hits with object identities | | `cell_fractions(grid)` | Grid | Approximate solid volume and face fractions | +| `yee_fractions(grid)` | Grid | Edge-length and face-area fractions of a Yee grid and the surface's traces, exact for planes | | `collection.dirichlet_values(grid, t=...)` | Grid or `(ndim, n)` | Host labels and voltages; NaN outside conductors | | `to_mesh()` | Resolution and optional bounds | Vertices, connectivity, centres, normals and sampling spacing | @@ -271,6 +272,27 @@ On a 3D grid, `show_3d()` on either result draws the translucent surface and the crossings, with `normals=True` the outward normals and with `nodes=True` the solid grid nodes; `backend="pyvista"` returns a PyVista plotter, otherwise Matplotlib 3D axes. +`shape.yee_fractions(grid)` gives the cut-cell geometry of a staggered (Yee) grid on the +grid's nodes, 2D or 3D, for conformal finite-difference and finite-integration schemes +(Dey–Mittra edges and faces, embedded boundaries). `edges[a]` is the fraction of each edge +along axis `a` inside the shape (shape: cells along `a`, nodes across), `faces[a]` the +fraction of each face normal to `a` (nodes along `a`, cells across; in 2D one family, the +cells). `traces[a]` is the length of the curve where the surface crosses each face and +`projected_traces[a]` that length weighted by `|n x e_a|`, the sine between the surface +normal and the face's; summed over all faces, each times the spacing along its normal, the +projected traces give twice the surface's area. On a periodic axis the last edge or face +closes the period (no duplicate). Edge fractions use every crossing along the edge (so a +slit narrower than a cell keeps its gap); face fractions and traces integrate lines across +the face, piecewise Gauss–Legendre between the crossings on the face's edges, in both +directions. +Both are exact for planes; for curved surfaces they converge at second order or better (a +circle's area and perimeter to `2e-7` at 80 cells across). A surface lying on grid lines +(walls of an axis-aligned box or slit on node planes) is treated like a staircase: the edges +and faces in it count as inside, whatever `contains` returns on the surface itself, and its trace +counts for the face on its outside. Shapes without analytic crossings are +sampled with `samples` points per segment. Where a shape has a level set, segments farther +from the surface than half their length are not searched. + `diagnose(grid)` reports node overlaps, objects without solid nodes, multiple-hit edges, and very short edge distances. These observations do not prove that all features are resolved. See the [cut-edge tutorial](/simgeometry/tutorials/grid-intersections-and-cut-edges/) diff --git a/src/simgeometry/api.py b/src/simgeometry/api.py index dc78eae..37ef91b 100644 --- a/src/simgeometry/api.py +++ b/src/simgeometry/api.py @@ -259,3 +259,12 @@ def cell_fractions(self, grid, *, samples_per_axis=4): from simgeometry.fractions import cell_fractions return cell_fractions(self, grid, samples_per_axis=samples_per_axis) + + def yee_fractions(self, grid, **kwargs): + """Edge-length and face-area fractions of a Yee grid, and the surface's traces. + + Exact for planes; see :func:`simgeometry.fractions.yee_fractions`. + """ + from simgeometry.fractions import yee_fractions + + return yee_fractions(self, grid, **kwargs) diff --git a/src/simgeometry/fractions.py b/src/simgeometry/fractions.py index b5207b0..0769013 100644 --- a/src/simgeometry/fractions.py +++ b/src/simgeometry/fractions.py @@ -3,6 +3,7 @@ from dataclasses import dataclass from itertools import product +import cunumpy as xp import numpy as np import scope_profiler as sp @@ -69,3 +70,442 @@ def sample(base_axes, fixed=None): ) faces.append(sample(face_axes, fixed=axis)) return CellFractions(volume, tuple(faces), n) + + +#: Relative step past a crossing before the next one is searched for, and the +#: offset of the membership probes beside an edge. +_NUDGE = 1e-9 +#: Three-point Gauss-Legendre rule on [0, 1]. +_GAUSS_POINTS = 0.5 + 0.5 * np.sqrt(0.6) * np.array([-1.0, 0.0, 1.0]) +_GAUSS_WEIGHTS = np.array([5.0, 8.0, 5.0]) / 18.0 +#: The turn of a trace's normal between neighbouring lines taken for a corner. +_KINK_SINE = 0.05 + + +@dataclass(frozen=True) +class YeeFractions: + """Solid fractions of the edges and faces of a staggered (Yee) grid, and the traces. + + The arrays follow the edge and face numbering of a Yee grid on the grid's nodes: + + - ``edges[a]``: the fraction of each edge along axis ``a`` inside the geometry, + shape: cells along ``a``, nodes across (``cells = nodes - 1``, or ``nodes`` on a + periodic axis, whose last edge closes the period). + - ``faces[a]``: the fraction of each face normal to ``a`` inside, shape: nodes + along ``a``, cells across. In 2D there is one family, ``faces[0]``, the cells of + the grid (shape: cells), as faces normal to the plane. + - ``traces[a]``: the length of the surface's trace in each face, ``int dl`` over + the curves where the surface crosses the face [length]. + - ``projected_traces[a]``: ``int |n x e| dl``, ``n`` the surface's unit normal and + ``e`` the face's: the trace weighted by the sine of the angle between the + surface and the face's normal. In 2D it equals ``traces``. Summed over the faces + of all three families, each times the grid spacing along its normal, it is + twice the surface's area. + + Fluid (outside) fractions are one minus these. Edge fractions come from all the + crossings along each edge (``crossings``, repeated); face fractions and traces + integrate the crossings of lines across the face, piecewise Gauss-Legendre + between the face's edge crossings, in both directions. Both are exact for planes (straight + traces) up to the crossings' accuracy; for curved surfaces the face fractions and + traces converge at second order or better. Shapes without an analytic crossing + are sampled (``samples`` points per segment), so features thinner than a + ``samples``-th of a cell can be missed; a feature that crosses no edge of a face and + misses the face's quadrature lines is missed. + + A surface lying on grid lines (an axis-aligned wall on a node plane) is treated as + a staircase: points in a wall that lies in the grid plane of an edge or face count + as inside, whatever ``contains`` returns on the wall (so the edges and faces in it + are inside, also where the wall covers them only partly), and a wall along a face's + edge counts for the face on its outside, with sine one. + """ + + edges: tuple[np.ndarray, ...] + faces: tuple[np.ndarray, ...] + traces: tuple[np.ndarray, ...] + projected_traces: tuple[np.ndarray, ...] + spacing: tuple[float, ...] + + +def _all_hits(geometry, start, end, *, samples, max_hits): + """Every crossing of the segments ``start -> end``, ``(ndim, n)``. + + Returns the fractions along each segment, ``(n, k)`` sorted with NaN padding, + and the unit normals there, ``(ndim, n, k)``. Repeats :func:`crossings` from just + past the last crossing found. + """ + from simgeometry.crossings import crossings + + ndim, n = start.shape + step = end - start + found_t, found_n = [], [] + begin = np.zeros(n) + active = np.arange(n) + for _ in range(max_hits): + if active.size == 0: + break + origin = start[:, active] + begin[active] * step[:, active] + with np.errstate(invalid="ignore"): # sampled shapes: inf * 0 for misses + result = crossings(geometry, origin, end[:, active], samples=samples) + hit = np.asarray(xp.to_numpy(result.hit), dtype=bool) + fraction = np.asarray(xp.to_numpy(result.fraction), dtype=float) + normal = np.asarray(xp.to_numpy(result.normal), dtype=float) + t = np.full(n, np.nan) + nrm = np.full((ndim, n), np.nan) + which = active[hit] + t[which] = begin[which] + fraction[hit] * (1.0 - begin[which]) + nrm[:, which] = normal[:, hit] + found_t.append(t) + found_n.append(nrm) + follow = t[which] + _NUDGE + keep = follow < 1.0 + active = which[keep] + begin[active] = follow[keep] + if not found_t: + return np.full((n, 0), np.nan), np.full((ndim, n, 0), np.nan) + return np.stack(found_t, axis=1), np.stack(found_n, axis=2) + + +def _staircase_mask(geometry, points, offsets): + """Membership where a surface on a grid plane counts as inside. + + ``offsets`` are ``(axis, distance)`` pairs: a point is inside if it or one of its + neighbours at ``+-distance`` along ``axis`` is, so a point in a wall that lies in + the grid plane through it is inside, whatever ``contains`` returns on the wall. + """ + inside = mask(geometry, points) + for axis, distance in offsets: + for sign in (-1.0, 1.0): + probe = points.copy() + probe[axis] += sign * distance + inside |= mask(geometry, probe) + return inside + + +def _inside_length(geometry, start, end, t, offsets=()): + """Fraction of each segment inside, from its sorted crossings ``t`` ``(n, k)``.""" + n = start.shape[1] + breaks = np.concatenate( + [np.zeros((n, 1)), np.where(np.isnan(t), 1.0, t), np.ones((n, 1))], axis=1 + ) + breaks = np.sort(breaks, axis=1) + middle = 0.5 * (breaks[:, 1:] + breaks[:, :-1]) + step = end - start + points = start[:, :, None] + step[:, :, None] * middle[None, :, :] + inside = _staircase_mask( + geometry, points.reshape(start.shape[0], -1), offsets + ).reshape(middle.shape) + return np.sum(np.diff(breaks, axis=1) * inside, axis=1) + + +def _near(geometry, centres, radius): + """Points closer to the surface than ``radius`` (all of them without a level set).""" + from simgeometry._level_set import has_level_set + + if not has_level_set(geometry) or centres.shape[1] == 0: + return np.ones(centres.shape[1], dtype=bool) + from simgeometry.queries import level_values + + # a level set is the signed distance or a bound on it (never larger) + return np.abs(level_values(geometry, centres)) <= radius * (1 + 1e-9) + + +def _segments_inside(geometry, start, end, *, samples, max_hits, offsets=()): + """Inside fractions and crossings of segments; far ones by their midpoint.""" + n = start.shape[1] + length = np.sqrt(np.sum((end - start) ** 2, axis=0)) + middle = 0.5 * (start + end) + near = _near(geometry, middle, 0.5 * length) + fraction = mask(geometry, middle).astype(float) + hits = np.full((n, 0), np.nan) + chosen = np.flatnonzero(near) + if chosen.size: + t, _ = _all_hits( + geometry, + start[:, chosen], + end[:, chosen], + samples=samples, + max_hits=max_hits, + ) + fraction[chosen] = _inside_length( + geometry, start[:, chosen], end[:, chosen], t, offsets + ) + hits = np.full((n, t.shape[1]), np.nan) + hits[chosen] = t + return fraction, hits + + +@sp.profile("simgeometry.yee_fractions", tags=_PROFILE_TAGS) +def yee_fractions(geometry, grid, *, samples=64, max_hits=16): + """Edge-length and face-area fractions of a Yee grid cut by ``geometry``. + + For cut-cell (conformal) finite-difference and finite-integration schemes: the + edges and faces of the grid of nodes ``grid`` (a :class:`~simgeometry.Grid`, 2D or + 3D), with the parts of their lengths and areas inside the geometry and the trace of + the surface in each face. See :class:`YeeFractions` for the layout and accuracy. + ``samples`` is the sampling of shapes without analytic crossings and ``max_hits`` + the most crossings counted per segment. + """ + ndim = grid.ndim + if geometry.ndim != ndim: + raise ValueError("Geometry and grid dimensions must match") + if ndim not in (2, 3): + raise ValueError("Yee fractions need a 2D or 3D grid") + if int(max_hits) != max_hits or max_hits < 1: + raise ValueError("max_hits must be a positive integer") + periodic = tuple(getattr(grid, "periodic", None) or (False,) * ndim) + nodes = tuple(int(n) for n in grid.num_gridpoints) + cells = tuple(n if p else n - 1 for n, p in zip(nodes, periodic)) + if min(cells) < 1: + raise ValueError("Yee fractions need at least one cell per axis") + h = np.asarray(xp.to_numpy(grid.grid_spacing), dtype=float) + lower = np.asarray(xp.to_numpy(grid.lower_bound), dtype=float).reshape(-1) + + def corners(shape): + index = np.indices(shape).reshape(ndim, -1).astype(float) + return lower[:, None] + index * h[:, None] + + edges, hits = [], [] + for a in range(ndim): + shape = tuple(cells[b] if b == a else nodes[b] for b in range(ndim)) + start = corners(shape) + end = start.copy() + end[a] += h[a] + # a wall along the edge (in a grid plane through it) counts as inside + offsets = [(b, _NUDGE * h[b]) for b in range(ndim) if b != a] + fraction, t = _segments_inside( + geometry, start, end, samples=samples, max_hits=max_hits, offsets=offsets + ) + edges.append(fraction.reshape(shape)) + hits.append(t.reshape(*shape, t.shape[1])) + + families = [(1, 2), (2, 0), (0, 1)] if ndim == 3 else [(0, 1)] + faces, traces, projected = [], [], [] + for number, (b, c) in enumerate(families): + normal_axis = number if ndim == 3 else None + shape = tuple(nodes[d] if d == normal_axis else cells[d] for d in range(ndim)) + result = _face_integrals( + geometry, + shape, + corners(shape), + b, + c, + hits, + h, + nodes, + periodic, + samples=samples, + max_hits=max_hits, + ) + faces.append(result[0].reshape(shape)) + traces.append(result[1].reshape(shape)) + projected.append(result[2].reshape(shape)) + return YeeFractions( + tuple(edges), + tuple(faces), + tuple(traces), + tuple(projected), + tuple(float(v) for v in h), + ) + + +def _lines_across(geometry, starts, stops, along, across, h, samples, max_hits): + """Integrate lines along ``along`` across faces, between breakpoints ``stops``. + + ``starts`` are the faces' lower corners ``(ndim, m)``, ``stops`` relative + positions along ``across`` (NaN for none). Returns the inside fraction, the trace + and the projected trace of every face, and the lines' positions ``(m, p)``, + crossings ``(m, p, k)`` and in-plane normals ``(2, m, p, k)`` (along, across). + """ + ndim, m = starts.shape + stops = np.where(np.isnan(stops), 1.0, stops) + stops = np.sort( + np.concatenate([np.zeros((m, 1)), stops, np.ones((m, 1))], axis=1), axis=1 + ) + low, high = stops[:, :-1], stops[:, 1:] + # three Gauss-Legendre points per piece: inside it, never at a breakpoint, where a + # crossing appears or disappears + positions = low[:, :, None] + (high - low)[:, :, None] * _GAUSS_POINTS + weights = ((high - low)[:, :, None] * _GAUSS_WEIGHTS).reshape(m, -1) + count = positions.shape[1] * 3 + start = np.repeat(starts, count, axis=1) + start[across] += positions.reshape(-1) * h[across] + end = start.copy() + end[along] += h[along] + # a surface along the face's edges (on a grid line): the edges along ``across`` at + # the lines' ends, where the face's side is outside and the side across the edge + # inside, sampled at the middle of each piece (away from the crossings); it counts + # for the face outside, with sine one + nudge = _NUDGE * h[along] + # a wall in the face's plane counts as inside + offsets = ( + [(3 - along - across, _NUDGE * h[3 - along - across])] if ndim == 3 else [] + ) + ends_on_edge, ends_differ = [], [] + for point, inward in ((start, 1.0), (end, -1.0)): + middle = point.reshape(ndim, m, -1, 3)[:, :, :, 1].reshape(ndim, -1) + near_side, far_side = middle.copy(), middle.copy() + near_side[along] += inward * nudge + far_side[along] -= inward * nudge + near_in = _staircase_mask(geometry, near_side, offsets) + far_in = _staircase_mask(geometry, far_side, offsets) + ends_on_edge.append(np.repeat(~near_in & far_in, 3)) + ends_differ.append(np.repeat(near_in != far_in, 3)) + on_edges = (1.0 * ends_on_edge[0] + ends_on_edge[1]).reshape(m, -1) + along_edges = h[across] * np.sum(weights * on_edges, axis=1) + t, normal = _all_hits(geometry, start, end, samples=samples, max_hits=max_hits) + # a wall in the face's plane counts as inside; a crossing with that inside on both + # sides (the edge of such a wall) is not one + offsets = ( + [(3 - along - across, _NUDGE * h[3 - along - across])] if ndim == 3 else [] + ) + if offsets and t.shape[1]: + found = np.isfinite(t) + rows, cols = np.nonzero(found) + step = end[:, rows] - start[:, rows] + sides = [ + _staircase_mask( + geometry, start[:, rows] + (t[rows, cols] + shift) * step, offsets + ) + for shift in (-1e-6, 1e-6) + ] + hidden = np.zeros_like(found) + hidden[rows, cols] = sides[0] & sides[1] + t = np.where(hidden, np.nan, t) + normal = np.where(hidden[None], np.nan, normal) + # a crossing at the line's end, on an edge the surface runs along, is the edge's + # (counted above, for the face outside) + at_edge = (t > 1.0 - 1e-9) & ends_differ[1][:, None] + normal = np.where(at_edge[None], 0.0, np.nan_to_num(normal, nan=0.0)) + inside = _inside_length(geometry, start, end, t, offsets).reshape(m, -1) + n_along = np.abs(normal[along]) + n_plane = np.hypot(normal[along], normal[across]) + plain = np.sum( + np.where(n_plane > 0, n_along / np.where(n_plane > 0, n_plane, 1.0), 0.0), + axis=-1, + ).reshape(m, -1) + area = np.sum(weights * inside, axis=1) + trace = along_edges + h[across] * np.sum(weights * plain, axis=1) + projected = along_edges + h[across] * np.sum( + weights * np.sum(n_along, axis=-1).reshape(m, -1), axis=1 + ) + k = t.shape[1] + t = np.where(at_edge, np.nan, t) + in_plane = np.stack([normal[along], normal[across]]).reshape(2, m, -1, k) + return ( + area, + trace, + projected, + positions.reshape(m, -1), + t.reshape(m, -1, k), + in_plane, + ) + + +def _kinks(positions, t, normals, h_along, h_across): + """Corners of the traces between neighbouring lines, relative positions ``(m, j)``. + + Crossings are matched by their order on lines with as many crossings; where the + normals of a matched pair differ, the tangents through the two crossings meet at + the corner if the trace is straight on both sides of it. A meeting point strictly + between the two lines and inside the face is returned (NaN otherwise); on a + coarsely resolved curved trace it is a harmless extra breakpoint. + """ + m, p, k = t.shape + if p < 2 or k == 0: + return np.full((m, 0), np.nan) + counts = np.sum(np.isfinite(t), axis=2) + same = counts[:, :-1] == counts[:, 1:] + x0, x1 = t[:, :-1] * h_along, t[:, 1:] * h_along # along, (m, p - 1, k) + y0 = (positions[:, :-1] * h_across)[:, :, None] + y1 = (positions[:, 1:] * h_across)[:, :, None] + a0, b0 = normals[0][:, :-1], normals[1][:, :-1] + a1, b1 = normals[0][:, 1:], normals[1][:, 1:] + det = a0 * b1 - b0 * a1 + norm = np.hypot(a0, b0) * np.hypot(a1, b1) + # a corner, not the slow turn of a resolved curved trace (about 3 degrees) + turning = np.abs(det) > _KINK_SINE * np.where(norm > 0, norm, np.inf) + ok = turning & same[:, :, None] & np.isfinite(x0) & np.isfinite(x1) + safe = np.where(ok, det, 1.0) + r0 = a0 * x0 + b0 * y0 + r1 = a1 * x1 + b1 * y1 + x = (r0 * b1 - b0 * r1) / safe + y = (a0 * r1 - r0 * a1) / safe + inside = ok & (y > y0) & (y < y1) & (x > 0) & (x < h_along) + corner = np.where(inside, y / h_across, np.nan) + return corner.reshape(m, -1) + + +def _face_integrals( + geometry, shape, starts, b, c, hits, h, nodes, periodic, *, samples, max_hits +): + """Inside fractions, traces and projected traces of the faces spanned by ``b, c``. + + ``starts`` are the faces' lower corners ``(ndim, n)``; ``hits[a]`` the crossings of + the edges along ``a``, shaped like the edge array plus one axis. + """ + ndim, n = starts.shape + index = np.indices(shape).reshape(ndim, -1) + + def edge_hits(axis, across): + """Crossings of the face's edges along ``axis`` at its two ``across`` ends.""" + edge = hits[axis] + edge_shape = edge.shape[:-1] + flat = edge.reshape(int(np.prod(edge_shape)), edge.shape[-1]) + ends = [] + for shift in (0, 1): + multi = index.copy() + multi[across] = multi[across] + shift + if periodic[across]: + multi[across] %= nodes[across] + ends.append(flat[np.ravel_multi_index(tuple(multi), edge_shape)]) + return np.concatenate(ends, axis=1) + + breaks_c = edge_hits(c, b) # lines along b, positioned along c + breaks_b = edge_hits(b, c) + centres = starts.copy() + centres[b] += 0.5 * h[b] + centres[c] += 0.5 * h[c] + area = mask(geometry, centres).astype(float) + trace = np.zeros(n) + projected = np.zeros(n) + cut = np.any(np.isfinite(breaks_c), axis=1) | np.any(np.isfinite(breaks_b), axis=1) + from simgeometry._level_set import has_level_set + + if has_level_set(geometry): + cut |= _near(geometry, centres, 0.5 * float(np.hypot(h[b], h[c]))) + else: + corner = mask(geometry, starts) + cut |= area.astype(bool) != corner + chosen = np.flatnonzero(cut) + if chosen.size == 0: + return area, trace, projected + areas = [] + for along, across, breaks in ((b, c, breaks_c), (c, b, breaks_b)): + stops = np.where(np.isnan(breaks[chosen]), 1.0, breaks[chosen]) + result = _lines_across( + geometry, starts[:, chosen], stops, along, across, h, samples, max_hits + ) + # a corner of the surface inside the face (a kink of the integrands): where + # the tangents of the trace at neighbouring lines meet, a new breakpoint + kinks = _kinks(*result[3:], h[along], h[across]) + again = np.flatnonzero(np.any(np.isfinite(kinks), axis=1)) + if again.size: + refined = _lines_across( + geometry, + starts[:, chosen[again]], + np.concatenate([stops[again], kinks[again]], axis=1), + along, + across, + h, + samples, + max_hits, + ) + for value, update in zip(result[:3], refined[:3]): + value[again] = update + areas.append(result[0]) + trace[chosen] += result[1] + projected[chosen] += result[2] + area[chosen] = 0.5 * (areas[0] + areas[1]) + # the quadrature's round-off next to fully inside or outside faces + area = np.where(area < 1e-12, 0.0, np.where(area > 1 - 1e-12, 1.0, area)) + return area, trace, projected diff --git a/src/simgeometry/tests/test_yee_fractions.py b/src/simgeometry/tests/test_yee_fractions.py new file mode 100644 index 0000000..9ffa250 --- /dev/null +++ b/src/simgeometry/tests/test_yee_fractions.py @@ -0,0 +1,262 @@ +import numpy as np +import pytest + +from simgeometry import ( + Circle, + Cuboid, + FunctionObject2D, + Grid, + Polygon, + Rectangle, + Sphere, +) +from simgeometry.fractions import yee_fractions + + +def _clip(square, normal, offset): + """Sutherland-Hodgman: the part of a convex polygon (k, 2) with normal . p < offset.""" + out = [] + for p, q in zip(square, np.roll(square, -1, axis=0)): + fp, fq = normal @ p - offset, normal @ q - offset + if fp < 0: + out.append(p) + if fp * fq < 0: + out.append(p + fp / (fp - fq) * (q - p)) + return np.array(out).reshape(-1, 2) + + +def _area(polygon): + if len(polygon) < 3: + return 0.0 + x, y = polygon.T + return 0.5 * abs(np.dot(x, np.roll(y, -1)) - np.dot(y, np.roll(x, -1))) + + +def _half_space_reference(normal, offset, lower, h, nodes): + """Exact edge and face fractions and traces of ``normal . x < offset``.""" + ndim = len(nodes) + cells = [n - 1 for n in nodes] + edges = [] + for a in range(ndim): + shape = tuple(cells[b] if b == a else nodes[b] for b in range(ndim)) + start = lower[:, None] + np.indices(shape).reshape(ndim, -1) * h[:, None] + f0 = normal @ start - offset + f1 = f0 + normal[a] * h[a] + with np.errstate(divide="ignore", invalid="ignore"): + t = np.clip(f0 / (f0 - f1), 0, 1) + inside = np.where( + f0 < 0, np.where(f1 < 0, 1.0, t), np.where(f1 < 0, 1 - t, 0.0) + ) + edges.append(inside.reshape(shape)) + families = [(1, 2), (2, 0), (0, 1)] if ndim == 3 else [(0, 1)] + faces, traces, projected = [], [], [] + for number, (b, c) in enumerate(families): + normal_axis = number if ndim == 3 else None + shape = tuple(nodes[d] if d == normal_axis else cells[d] for d in range(ndim)) + corners = lower[:, None] + np.indices(shape).reshape(ndim, -1) * h[:, None] + square = np.array([[0, 0], [h[b], 0], [h[b], h[c]], [0, h[c]]]) + in_plane = np.array([normal[b], normal[c]]) + area, trace = [], [] + for p in corners.T: + local = offset - normal @ p + poly = _clip(square, in_plane, local) + area.append(_area(poly) / (h[b] * h[c])) + # the trace: the clipped polygon's sides on the line + edges_len = np.linalg.norm(np.roll(poly, -1, axis=0) - poly, axis=1) + mid = 0.5 * (np.roll(poly, -1, axis=0) + poly) + interior = np.abs(mid @ in_plane - local) < 1e-12 * max(h) + trace.append(np.sum(edges_len[interior]) if len(poly) >= 3 else 0.0) + faces.append(np.reshape(area, shape)) + traces.append(np.reshape(trace, shape)) + projected.append(traces[-1] * np.linalg.norm(in_plane) / np.linalg.norm(normal)) + return edges, faces, traces, projected + + +def _rotation(angles): + a, b, c = angles + rx = np.array([[1, 0, 0], [0, np.cos(a), -np.sin(a)], [0, np.sin(a), np.cos(a)]]) + ry = np.array([[np.cos(b), 0, np.sin(b)], [0, 1, 0], [-np.sin(b), 0, np.cos(b)]]) + rz = np.array([[np.cos(c), -np.sin(c), 0], [np.sin(c), np.cos(c), 0], [0, 0, 1]]) + return rz @ ry @ rx + + +def test_plane_in_3d_is_exact(): + """A tilted half-space (a big rotated box): fractions and traces to round-off.""" + rotation = _rotation((0.3, -0.5, 0.8)) + # the box x < 0 (local), 40 wide, rotated: the half-space n . x < 0 near the grid + box = Cuboid((-20, 0, 0), (40, 40, 40)).transformed(rotation, (0.03, -0.02, 0.01)) + normal = rotation[:, 0] + offset = normal @ np.array([0.03, -0.02, 0.01]) + nodes = (6, 5, 7) + grid = Grid(3, (-1, -0.9, -1.1), (1.1, 0.8, 1.2), nodes) + result = yee_fractions(box, grid) + h = np.asarray(result.spacing) + edges, faces, traces, projected = _half_space_reference( + normal, offset, np.array([-1, -0.9, -1.1]), h, nodes + ) + for a in range(3): + np.testing.assert_allclose(result.edges[a], edges[a], atol=1e-9) + np.testing.assert_allclose(result.faces[a], faces[a], atol=1e-9) + np.testing.assert_allclose(result.traces[a], traces[a], atol=1e-9) + np.testing.assert_allclose(result.projected_traces[a], projected[a], atol=1e-9) + assert 0 < np.mean(result.faces[0]) < 1 + assert np.count_nonzero((result.faces[2] > 0) & (result.faces[2] < 1)) > 5 + + +def test_line_in_2d_is_exact(): + """A polygon edge across a 2D grid: edges, cells and traces exactly.""" + normal = np.array([0.6, -0.8]) + offset = 0.137 + # a large polygon whose only edge near the grid is normal . x = offset + t = np.array([-normal[1], normal[0]]) + base = offset * normal + vertices = [base + 50 * t, base - 50 * t, base - 50 * t - 50 * normal] + vertices.append(base + 50 * t - 50 * normal) + shape = Polygon([tuple(v) for v in vertices]) + nodes = (8, 7) + grid = Grid(2, (-1, -1), (1, 0.9), nodes) + result = yee_fractions(shape, grid) + h = np.asarray(result.spacing) + edges, faces, traces, projected = _half_space_reference( + normal, offset, np.array([-1.0, -1.0]), h, nodes + ) + np.testing.assert_allclose(result.edges[0], edges[0], atol=1e-9) + np.testing.assert_allclose(result.edges[1], edges[1], atol=1e-9) + np.testing.assert_allclose(result.faces[0], faces[0], atol=1e-9) + np.testing.assert_allclose(result.traces[0], traces[0], atol=1e-9) + np.testing.assert_array_equal(result.traces[0], result.projected_traces[0]) + + +def test_layout_and_periodic_edges(): + grid = Grid(3, (0, 0, 0), (1, 1, 1), (4, 5, 6), periodic=(True, False, False)) + result = yee_fractions(Sphere((0.5, 0.5, 0.5), 0.3), grid) + assert result.edges[0].shape == (4, 5, 6) # periodic: as many cells as nodes + assert result.edges[1].shape == (4, 4, 6) + assert result.edges[2].shape == (4, 5, 5) + assert result.faces[0].shape == (4, 4, 5) + assert result.faces[1].shape == (4, 5, 5) + assert result.faces[2].shape == (4, 4, 6) + flat = yee_fractions(Circle((0.5, 0.5), 0.3), Grid(2, (0, 0), (1, 1), (5, 4))) + assert flat.edges[0].shape == (4, 4) + assert flat.edges[1].shape == (5, 3) + assert flat.faces[0].shape == (4, 3) + assert len(flat.faces) == 1 + + +@pytest.mark.parametrize("radius", [0.7, 1.0]) +def test_circle_area_and_perimeter_converge(radius): + errors = [] + for n in (14, 27, 53, 105): + grid = Grid(2, (-1.3, -1.3), (1.3, 1.3), (n, n)) + result = yee_fractions(Circle((0.011, 0.023), radius), grid) + h = np.asarray(result.spacing) + area = result.faces[0].sum() * h[0] * h[1] + perimeter = result.traces[0].sum() + errors.append( + ( + abs(area / (np.pi * radius**2) - 1), + abs(perimeter / (2 * np.pi * radius) - 1), + ) + ) + errors = np.array(errors) + assert np.all(errors[-1] < 5e-5) + # second order: about a factor four per halving, from the first to the last grid + order = np.log2(errors[0] / errors[-1]) / 3 + assert np.all(order > 1.8), order + + +def test_sphere_area_volume_and_sections_converge(): + """Twice the area from the projected traces; the volume; a section's area.""" + errors = [] + for n in (9, 17, 33): + grid = Grid(3, (-1.3,) * 3, (1.3,) * 3, (n,) * 3) + centre = np.array([0.01, 0.02, -0.03]) + result = yee_fractions(Sphere(tuple(centre), 1.0), grid) + h = np.asarray(result.spacing) + area = 0.5 * sum(result.projected_traces[a].sum() * h[a] for a in range(3)) + volume = result.faces[2].sum() * np.prod(h) + k = n // 2 # the z-faces in one node plane: the disc of the section + z = -1.3 + k * h[2] - centre[2] + section = result.faces[2][:, :, k].sum() * h[0] * h[1] + errors.append( + ( + abs(area / (4 * np.pi) - 1), + abs(volume / (4 * np.pi / 3) - 1), + abs(section / (np.pi * (1 - z * z)) - 1), + ) + ) + errors = np.array(errors) + assert np.all(errors[-1] < [5e-4, 1e-3, 2e-4]), errors[-1] + assert errors[0, 0] / errors[-1, 0] > 30 # area: second order or better + + +def test_slit_narrower_than_a_cell_is_kept(): + """A slit 0.4 cells wide in a plate: the edges across keep the gap exactly.""" + h = 0.25 + plate = Rectangle((0, 0), (20, 20)) - Rectangle((0.35, 0), (0.1, 40)) + grid = Grid(2, (-1, -1), (1, 1), (9, 9)) + result = yee_fractions(plate, grid) + x_edges = result.edges[0] + # the x-edges from x = 0.25 to 0.5 cross the slit: 0.6 of their length is metal + np.testing.assert_allclose(x_edges[5], 0.6, atol=1e-12) + np.testing.assert_allclose(np.delete(x_edges, 5, axis=0), 1.0) + np.testing.assert_allclose(result.faces[0][5], 0.6, atol=1e-12) + # both walls of the slit lie in those cells: twice the cell height each + np.testing.assert_allclose(result.traces[0][5], 2 * h, atol=1e-12) + assert result.traces[0].sum() == pytest.approx(2 * 2.0) + + +def test_sampled_shape_without_level_set(): + disc = FunctionObject2D( + lambda p: (p[0] - 0.05) ** 2 + (p[1] + 0.02) ** 2 < 0.36, + lower_bound=(-1, -1), + upper_bound=(1, 1), + ) + grid = Grid(2, (-1, -1), (1, 1), (41, 41)) + result = yee_fractions(disc, grid) + h = np.asarray(result.spacing) + assert result.faces[0].sum() * h[0] * h[1] == pytest.approx(np.pi * 0.36, rel=2e-3) + assert result.traces[0].sum() == pytest.approx(2 * np.pi * 0.6, rel=2e-3) + + +def test_method_and_validation(): + grid = Grid(2, (0, 0), (1, 1), (5, 5)) + circle = Circle((0.5, 0.5), 0.3) + np.testing.assert_array_equal( + circle.yee_fractions(grid).faces[0], yee_fractions(circle, grid).faces[0] + ) + with pytest.raises(ValueError, match="dimensions"): + yee_fractions(Sphere((0, 0, 0), 1), grid) + with pytest.raises(ValueError, match="max_hits"): + yee_fractions(circle, grid, max_hits=0) + + +def test_surfaces_on_grid_lines_like_a_staircase(): + """Walls on node planes: their edges count as inside, the trace as the outside's. + + The plate minus a slit has its walls on grid lines, where ``contains`` of a + difference is false; a box conductor's surface, where it is true. Both give the + staircase: the edges in a wall are inside, and each wall counts for the cell on + its outside. + """ + h = 0.25 + grid = Grid(2, (-1, -1), (1, 1), (9, 9)) + plate = Rectangle((0, 0), (20, 20)) - Rectangle((0.375, 0), (0.25, 40)) + result = yee_fractions(plate, grid) + np.testing.assert_array_equal(result.edges[1][[5, 6]], 1.0) # the walls + np.testing.assert_array_equal(result.faces[0][5], 0.0) # the slit's cells + np.testing.assert_allclose(result.traces[0][5], 2 * h) + np.testing.assert_allclose(np.delete(result.traces[0], 5, axis=0), 0.0) + box = Cuboid((0, 0.125, 0), (1.0, 0.75, 1.0)) # faces on node planes + grid3 = Grid(3, (-1, -1, -1), (1, 1, 1), (9, 9, 9)) + solid = yee_fractions(box, grid3) + total = sum(solid.traces[a].sum() * h for a in range(3)) + # each wall is counted by the faces normal to its two tangents, in every node + # plane across it including those at its ends (the staircase's corner edges): + # x-walls 4 y-planes * 1.0 + 5 z-planes * 0.75, y-walls 2 * 5 * 1.0, z-walls + # 5 * 0.75 + 4 * 1.0, each times h, both sides + expected = 2 * h * ((4 * 1.0 + 5 * 0.75) + (5 + 5) + (5 * 0.75 + 4 * 1.0)) + assert total == pytest.approx(expected) + np.testing.assert_array_equal(solid.traces[0], solid.projected_traces[0]) + inside = (solid.faces[2] > 0) & (solid.faces[2] < 1) + assert not inside.any() From 2158c5617371440780dfc4e88eb8cfeeca9d7d5d Mon Sep 17 00:00:00 2001 From: Max Date: Fri, 23 Oct 2026 21:02:50 +0200 Subject: [PATCH 13/36] Swept polygon absorption kernel, moved from picnic (#25) * Move the swept polygon absorption kernel and its packing here from picnic * Accept single columns with Fortran strides in PolygonObjects.absorb * Pass the alive mask as int8 to the CUDA absorption kernel --- CHANGELOG.md | 7 + .../docs/getting-started/geometry-api.md | 11 + pyproject.toml | 2 +- .../kernels/absorb_polygons_2d/__init__.py | 20 ++ .../absorb_polygons_2d_cuda.cu | 63 +++++ .../absorb_polygons_2d_numpy.py | 65 +++++ .../absorb_polygons_2d_pyccel.py | 152 ++++++++++ src/simgeometry/kernels/polygon_hit.cuh | 56 ++++ src/simgeometry/kernels/polygon_hits.py | 99 +++++++ src/simgeometry/polygon_objects.py | 229 +++++++++++++++ .../tests/test_polygon_absorption.py | 267 ++++++++++++++++++ 11 files changed, 970 insertions(+), 1 deletion(-) create mode 100644 src/simgeometry/kernels/absorb_polygons_2d/__init__.py create mode 100644 src/simgeometry/kernels/absorb_polygons_2d/absorb_polygons_2d_cuda.cu create mode 100644 src/simgeometry/kernels/absorb_polygons_2d/absorb_polygons_2d_numpy.py create mode 100644 src/simgeometry/kernels/absorb_polygons_2d/absorb_polygons_2d_pyccel.py create mode 100644 src/simgeometry/kernels/polygon_hit.cuh create mode 100644 src/simgeometry/kernels/polygon_hits.py create mode 100644 src/simgeometry/polygon_objects.py create mode 100644 src/simgeometry/tests/test_polygon_absorption.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e7c782a..495aba5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- `simgeometry.polygon_objects`: `polygon_objects(objects)` packs `Polygon` and + `Aperture2D` objects into flat arrays, and `PolygonObjects.absorb(positions, alive, + start=...)` absorbs the markers that end inside one or whose step crosses one of its + edges, exactly, with the object each marker hit first. The kernel + `simgeometry.kernels.absorb_polygons_2d` (Pyccel, NumPy, CUDA) and the shared hit test + (`polygon_hit.cuh`, `polygon_hits.py`) moved here from picnic, so picnic and tree-d-3d use + one implementation. - `shape.yee_fractions(grid)` (`simgeometry.fractions.yee_fractions`): the cut-cell geometry of a Yee grid in 2D and 3D, for conformal (Dey–Mittra-type) field solvers: the fraction of every edge's length and every face's area inside the shape, the length of the diff --git a/docs/src/content/docs/getting-started/geometry-api.md b/docs/src/content/docs/getting-started/geometry-api.md index 67f8ea3..20d8c8c 100644 --- a/docs/src/content/docs/getting-started/geometry-api.md +++ b/docs/src/content/docs/getting-started/geometry-api.md @@ -254,6 +254,17 @@ be missed, and their normals are estimated (`exact` is `False`). A crossing at t start of a step is not reported, so a particle stopped on a surface is not counted twice; grazing contacts are not crossings. +For many markers against fixed 2D obstacles, `simgeometry.polygon_objects.polygon_objects(objects)` +packs the `Polygon` and `Aperture2D` objects (other shapes are left out) into flat arrays, +and `packed.absorb(positions, alive, start=old_positions)` absorbs, in one compiled pass, +the alive markers that end inside an object or whose step crosses one of its edges: exactly, +so a step longer than a plate's thickness is caught. It sets `alive` to false for them and +returns, per marker, the slot of the object met first along the step (`packed.index[slot]` +is its position in `objects`; -1 for none). `sweep_length=` makes longer steps (wrapped by a +periodic side) count by their end only. The kernel, `simgeometry.kernels.absorb_polygons_2d`, +has Pyccel, NumPy and CUDA versions; `polygon_hit.cuh` and `polygon_hits.py` next to it +are the hit test for kernels of other packages that fuse it into their own loops. + `simgeometry.cut_edges.bracketed_edge_geometry()` is the batched geometry path for Picnic's Shortley–Weller stencil. It visits fluid nodes with solid neighbours and assumes a single crossing in each bracket. Fractions have no numerical lower clamp. diff --git a/pyproject.toml b/pyproject.toml index a091c24..334972e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,7 +60,7 @@ urls.Source = "https://github.com/max-models/simgeometry" [tool.setuptools] package-data."*" = [ "py.typed" ] -package-data.simgeometry = [ "kernels/*/*.cu" ] +package-data.simgeometry = [ "kernels/*.cuh", "kernels/*/*.cu" ] packages.find.where = [ "src" ] packages.find.exclude = [ "simgeometry.tests*" ] diff --git a/src/simgeometry/kernels/absorb_polygons_2d/__init__.py b/src/simgeometry/kernels/absorb_polygons_2d/__init__.py new file mode 100644 index 0000000..c506694 --- /dev/null +++ b/src/simgeometry/kernels/absorb_polygons_2d/__init__.py @@ -0,0 +1,20 @@ +"""The absorb_polygons_2d kernel: Pyccel, NumPy and CUDA versions with the same arguments. + +``kernel(...)`` runs the CUDA version when the arrays are CuPy arrays, and +otherwise the Pyccel version, or the NumPy version if Pyccel is unavailable +(see ``cunumpy.kernels.set_host_kernel_implementation`` to choose). The CUDA +version runs one thread per entry of ``rows`` (the third argument). +""" + +import cunumpy as xp + +from simgeometry.kernels._build import KERNEL_OPTIONS + + +def _row_count(args: tuple) -> int: + return int(args[2].shape[0]) + + +kernel = xp.kernels.Kernel.from_folder( + __name__, **{**KERNEL_OPTIONS, "n_threads_from": _row_count} +) diff --git a/src/simgeometry/kernels/absorb_polygons_2d/absorb_polygons_2d_cuda.cu b/src/simgeometry/kernels/absorb_polygons_2d/absorb_polygons_2d_cuda.cu new file mode 100644 index 0000000..81503c0 --- /dev/null +++ b/src/simgeometry/kernels/absorb_polygons_2d/absorb_polygons_2d_cuda.cu @@ -0,0 +1,63 @@ +// The CUDA version (GPU) of the absorb_polygons_2d kernel; +// absorb_polygons_2d_pyccel.py next to this file is the Pyccel version (CPU), +// with the same arguments: one thread per row. The alive markers rows[i] that end inside an object, or whose step from +// start crossed one of its edges, are absorbed; hit[i] is the object met first +// (-1: none). The objects are those of simgeometry_object_hit_time. + +#include "cunumpy/array_view.cuh" +#include "simgeometry/kernels/polygon_hit.cuh" + +extern "C" __global__ +void absorb_polygons_2d( + Array2D positions, + Array2D start, + Array1D rows, + int use_start, + double sweep_length, + Array2D vertices, + Array1D polygon_offsets, + Array1D object_first, + Array1D object_last, + Array1D object_inverted, + Array2D object_lower, + Array2D object_upper, + Array1D object_boxed, + Array1D alive, + Array1D hit +) { + const long long index = blockDim.x * (long long)blockIdx.x + threadIdx.x; + if (index >= rows.shape[0]) return; + const long long marker = rows(index); + hit(index) = -1; + if (!alive(marker)) return; + const double x1 = positions(0, marker); + const double y1 = positions(1, marker); + double x0 = x1; + double y0 = y1; + int sweep = 0; + if (use_start == 1) { + x0 = start(0, marker); + y0 = start(1, marker); + const double length2 = (x1 - x0) * (x1 - x0) + (y1 - y0) * (y1 - y0); + if (sweep_length > 0.0 && length2 <= sweep_length * sweep_length) + sweep = 1; + } + double best = 2.0; + long long best_object = -1; + for (long long obj = 0; obj < object_first.shape[0]; ++obj) { + if (object_boxed(obj) == 1 + && (fmax(x0, x1) < object_lower(obj, 0) || fmin(x0, x1) > object_upper(obj, 0) + || fmax(y0, y1) < object_lower(obj, 1) + || fmin(y0, y1) > object_upper(obj, 1))) + continue; + const double time = simgeometry_object_hit_time( + vertices, polygon_offsets, object_first(obj), object_last(obj), + object_inverted(obj), x0, y0, x1, y1, sweep); + if (time < best) { + best = time; + best_object = obj; + } + } + hit(index) = best_object; + if (best_object >= 0) alive(marker) = 0; +} diff --git a/src/simgeometry/kernels/absorb_polygons_2d/absorb_polygons_2d_numpy.py b/src/simgeometry/kernels/absorb_polygons_2d/absorb_polygons_2d_numpy.py new file mode 100644 index 0000000..2d2063c --- /dev/null +++ b/src/simgeometry/kernels/absorb_polygons_2d/absorb_polygons_2d_numpy.py @@ -0,0 +1,65 @@ +"""The NumPy version of the ``absorb_polygons_2d`` kernel, run when Pyccel is unavailable. + +Same arguments and result as ``absorb_polygons_2d_pyccel.py``, vectorized over +the markers (loops over the objects and their edges). +""" + +from __future__ import annotations + +import numpy as np + +from simgeometry.kernels.polygon_hits import first_hits + + +def absorb_polygons_2d( + positions: np.ndarray, + start: np.ndarray, + rows: np.ndarray, + use_start: int, + sweep_length: float, + vertices: np.ndarray, + polygon_offsets: np.ndarray, + object_first: np.ndarray, + object_last: np.ndarray, + object_inverted: np.ndarray, + object_lower: np.ndarray, + object_upper: np.ndarray, + object_boxed: np.ndarray, + alive: np.ndarray, + hit: np.ndarray, +) -> None: + """Absorb the alive markers ``rows`` that hit an object; ``hit[i]`` is its index.""" + x1, y1 = positions[0, rows], positions[1, rows] + if use_start == 1: + x0, y0 = start[0, rows], start[1, rows] + sweep = (sweep_length > 0.0) & ( + (x1 - x0) * (x1 - x0) + (y1 - y0) * (y1 - y0) <= sweep_length * sweep_length + ) + else: + x0, y0 = x1, y1 + sweep = np.zeros(rows.shape[0], dtype=bool) + live = alive[rows] + tested = np.empty((object_first.shape[0], rows.shape[0]), dtype=bool) + for obj in range(object_first.shape[0]): + tested[obj] = live + if object_boxed[obj] == 1: + tested[obj] &= ~( + (np.maximum(x0, x1) < object_lower[obj, 0]) + | (np.minimum(x0, x1) > object_upper[obj, 0]) + | (np.maximum(y0, y1) < object_lower[obj, 1]) + | (np.minimum(y0, y1) > object_upper[obj, 1]) + ) + hit[...] = first_hits( + vertices, + polygon_offsets, + object_first, + object_last, + object_inverted, + tested, + x0, + y0, + x1, + y1, + sweep, + ) + alive[rows[hit >= 0]] = False diff --git a/src/simgeometry/kernels/absorb_polygons_2d/absorb_polygons_2d_pyccel.py b/src/simgeometry/kernels/absorb_polygons_2d/absorb_polygons_2d_pyccel.py new file mode 100644 index 0000000..a35ea4c --- /dev/null +++ b/src/simgeometry/kernels/absorb_polygons_2d/absorb_polygons_2d_pyccel.py @@ -0,0 +1,152 @@ +"""Absorb the markers that end inside, or stepped through, 2D polygon objects. + +The Pyccel version (CPU) of the ``absorb_polygons_2d`` kernel; +``absorb_polygons_2d_cuda.cu`` next to this file is the CUDA version (GPU), with +the same arguments. + +An object is the union of polygons ``object_first[k] <= p < object_last[k]``, +polygon ``p`` the vertices ``polygon_offsets[p] <= v < polygon_offsets[p + 1]`` +of ``vertices`` (V, 2) with the closing edge implied; ``object_inverted[k] == 1`` +makes it solid outside them. A marker is inside a polygon by the even-odd ray +casting of ``contains_polygon_2d``. With a step from ``start`` it also hits an +object when the step crosses one of its edges, exactly (no sampling), and the +object it meets first along the step takes it. Kernels that fuse the test into +their own loop (picnic's ``push_electrostatic_2d``) copy ``_object_hit_time``; +``polygon_hit.cuh`` and ``polygon_hits.py`` are its CUDA and NumPy forms. +""" + + +def _object_hit_time( + vertices: "float[:,:]", + polygon_offsets: "int[:]", + first: int, + last: int, + inverted: int, + x0: float, + y0: float, + x1: float, + y1: float, + sweep: int, +) -> float: + """Where along the step from (x0, y0) to (x1, y1) the marker enters the object. + + The parameter in (0, 1] of the first edge crossing (with ``sweep == 1``), + else 0 if (x1, y1) lies in the solid, else 2 (no hit). + """ + inside = False + crossing = 2.0 + dx = x1 - x0 + dy = y1 - y0 + for polygon in range(first, last): + begin = polygon_offsets[polygon] + end = polygon_offsets[polygon + 1] + in_polygon = False + xj = vertices[end - 1, 0] + yj = vertices[end - 1, 1] + for vertex in range(begin, end): + xi = vertices[vertex, 0] + yi = vertices[vertex, 1] + if (yi > y1) != (yj > y1) and x1 < (xj - xi) * (y1 - yi) / ( + (yj - yi) + 1e-16 + ) + xi: + in_polygon = not in_polygon + if sweep == 1: + # the step x0 + t d meets the edge xj + u e + ex = xi - xj + ey = yi - yj + denominator = dx * ey - dy * ex + if denominator != 0.0: + wx = xj - x0 + wy = yj - y0 + t = (wx * ey - wy * ex) / denominator + u = (wx * dy - wy * dx) / denominator + if 0.0 < t <= 1.0 and 0.0 <= u <= 1.0 and t < crossing: + crossing = t + xj = xi + yj = yi + if in_polygon: + inside = True + if inverted == 1: + inside = not inside + if crossing <= 1.0: + return crossing + if inside: + return 0.0 + return 2.0 + + +def absorb_polygons_2d( + positions: "float[:,:]", + start: "float[:,:]", + rows: "int[:]", + use_start: int, + sweep_length: float, + vertices: "float[:,:]", + polygon_offsets: "int[:]", + object_first: "int[:]", + object_last: "int[:]", + object_inverted: "int[:]", + object_lower: "float[:,:]", + object_upper: "float[:,:]", + object_boxed: "int[:]", + alive: "bool[:]", + hit: "int[:]", +) -> None: + """Absorb the alive markers ``rows`` that hit an object; ``hit[i]`` is its index. + + ``positions`` and ``start`` are ``(2, N)``; ``hit`` has one entry per row + (-1: no hit). With ``use_start == 1`` a step from ``start`` no longer than + ``sweep_length`` is also tested for crossings (a longer one was wrapped by + a periodic side or reinjected, and only its end counts). An object with + ``object_boxed[k] == 1`` is skipped by steps outside its box + ``object_lower[k]``..``object_upper[k]``. + """ + num_objects = object_first.shape[0] + # fmt: off + #$ omp parallel for private(marker, x0, y0, x1, y1, sweep, best, obj, time) schedule(dynamic, 256) + # fmt: on + for index in range(rows.shape[0]): + marker = rows[index] + hit[index] = -1 + if not alive[marker]: + continue + x1 = positions[0, marker] + y1 = positions[1, marker] + x0 = x1 + y0 = y1 + sweep = 0 + if use_start == 1: + x0 = start[0, marker] + y0 = start[1, marker] + if ( + sweep_length > 0.0 + and (x1 - x0) * (x1 - x0) + (y1 - y0) * (y1 - y0) + <= sweep_length * sweep_length + ): + sweep = 1 + best = 2.0 + for obj in range(num_objects): + if object_boxed[obj] == 1 and ( + max(x0, x1) < object_lower[obj, 0] + or min(x0, x1) > object_upper[obj, 0] + or max(y0, y1) < object_lower[obj, 1] + or min(y0, y1) > object_upper[obj, 1] + ): + continue + time = _object_hit_time( + vertices, + polygon_offsets, + object_first[obj], + object_last[obj], + object_inverted[obj], + x0, + y0, + x1, + y1, + sweep, + ) + if time < best: + best = time + hit[index] = obj + if hit[index] >= 0: + alive[marker] = False diff --git a/src/simgeometry/kernels/polygon_hit.cuh b/src/simgeometry/kernels/polygon_hit.cuh new file mode 100644 index 0000000..557b4de --- /dev/null +++ b/src/simgeometry/kernels/polygon_hit.cuh @@ -0,0 +1,56 @@ +// The swept polygon-object hit test on the GPU: the same arithmetic as +// _object_hit_time of absorb_polygons_2d_pyccel.py. Included by +// absorb_polygons_2d_cuda.cu and by kernels of other packages that fuse the +// test into their own loops (#include "simgeometry/kernels/polygon_hit.cuh", +// with simgeometry's source root among the include directories). An object is the union of polygons first <= p < last of +// the (V, 2) vertices, polygon p the vertices polygon_offsets[p] <= v < +// polygon_offsets[p + 1] with the closing edge implied. +#pragma once +#include "cunumpy/array_view.cuh" + +// Where along the step from (x0, y0) to (x1, y1) the marker enters the object: +// the parameter in (0, 1] of the first edge crossing (sweep == 1), else 0 if +// (x1, y1) lies in the solid, else 2 (no hit). +__device__ inline double simgeometry_object_hit_time( + Array2D vertices, Array1D polygon_offsets, long long first, + long long last, long long inverted, double x0, double y0, double x1, double y1, + int sweep) { + bool inside = false; + double crossing = 2.0; + const double dx = x1 - x0; + const double dy = y1 - y0; + for (long long polygon = first; polygon < last; ++polygon) { + const long long begin = polygon_offsets(polygon); + const long long end = polygon_offsets(polygon + 1); + bool in_polygon = false; + double xj = vertices(end - 1, 0); + double yj = vertices(end - 1, 1); + for (long long vertex = begin; vertex < end; ++vertex) { + const double xi = vertices(vertex, 0); + const double yi = vertices(vertex, 1); + if ((yi > y1) != (yj > y1) + && x1 < (xj - xi) * (y1 - yi) / ((yj - yi) + 1e-16) + xi) + in_polygon = !in_polygon; + if (sweep == 1) { + // the step x0 + t d meets the edge xj + u e + const double ex = xi - xj; + const double ey = yi - yj; + const double denominator = dx * ey - dy * ex; + if (denominator != 0.0) { + const double wx = xj - x0; + const double wy = yj - y0; + const double t = (wx * ey - wy * ex) / denominator; + const double u = (wx * dy - wy * dx) / denominator; + if (t > 0.0 && t <= 1.0 && u >= 0.0 && u <= 1.0 && t < crossing) + crossing = t; + } + } + xj = xi; + yj = yi; + } + if (in_polygon) inside = true; + } + if (inverted == 1) inside = !inside; + if (crossing <= 1.0) return crossing; + return inside ? 0.0 : 2.0; +} diff --git a/src/simgeometry/kernels/polygon_hits.py b/src/simgeometry/kernels/polygon_hits.py new file mode 100644 index 0000000..3d9c941 --- /dev/null +++ b/src/simgeometry/kernels/polygon_hits.py @@ -0,0 +1,99 @@ +"""The swept polygon-object hit test without Pyccel, vectorized over the markers. + +Used by the NumPy version of ``absorb_polygons_2d`` and by NumPy kernels of other +packages that fuse the test (picnic's ``push_electrostatic_2d``): the same +arithmetic as ``_object_hit_time`` of ``absorb_polygons_2d_pyccel.py``. +""" + +from __future__ import annotations + +import numpy as np + + +def object_hit_times( + vertices: np.ndarray, + polygon_offsets: np.ndarray, + first: int, + last: int, + inverted: int, + x0: np.ndarray, + y0: np.ndarray, + x1: np.ndarray, + y1: np.ndarray, + sweep: np.ndarray, +) -> np.ndarray: + """Where along each step from (x0, y0) to (x1, y1) the marker enters the object. + + The parameter in (0, 1] of the first edge crossing (where ``sweep``), else + 0 where (x1, y1) lies in the solid, else 2 (no hit). + """ + inside = np.zeros(x1.shape, dtype=bool) + crossing = np.full(x1.shape, 2.0) + dx = x1 - x0 + dy = y1 - y0 + for polygon in range(first, last): + begin, end = int(polygon_offsets[polygon]), int(polygon_offsets[polygon + 1]) + in_polygon = np.zeros(x1.shape, dtype=bool) + xj, yj = vertices[end - 1] + for xi, yi in vertices[begin:end]: + in_polygon ^= ((yi > y1) != (yj > y1)) & ( + x1 < (xj - xi) * (y1 - yi) / ((yj - yi) + 1e-16) + xi + ) + ex, ey = xi - xj, yi - yj + denominator = dx * ey - dy * ex + wx, wy = xj - x0, yj - y0 + with np.errstate(divide="ignore", invalid="ignore"): + t = (wx * ey - wy * ex) / denominator + u = (wx * dy - wy * dx) / denominator + meets = ( + sweep + & (denominator != 0.0) + & (t > 0.0) + & (t <= 1.0) + & (u >= 0.0) + & (u <= 1.0) + & (t < crossing) + ) + crossing = np.where(meets, t, crossing) + xj, yj = xi, yi + inside |= in_polygon + if inverted == 1: + inside = ~inside + return np.where(crossing <= 1.0, crossing, np.where(inside, 0.0, 2.0)) + + +def first_hits( + vertices: np.ndarray, + polygon_offsets: np.ndarray, + object_first: np.ndarray, + object_last: np.ndarray, + object_inverted: np.ndarray, + tested: np.ndarray, + x0: np.ndarray, + y0: np.ndarray, + x1: np.ndarray, + y1: np.ndarray, + sweep: np.ndarray, +) -> np.ndarray: + """The object each marker meets first among those ``tested[k]`` marks, or -1.""" + best = np.full(x1.shape, 2.0) + hit = np.full(x1.shape, -1, dtype=np.int64) + for obj in range(object_first.shape[0]): + if object_last[obj] <= object_first[obj] or not tested[obj].any(): + continue + time = object_hit_times( + vertices, + polygon_offsets, + int(object_first[obj]), + int(object_last[obj]), + int(object_inverted[obj]), + x0, + y0, + x1, + y1, + sweep, + ) + earlier = tested[obj] & (time < best) + best = np.where(earlier, time, best) + hit = np.where(earlier, obj, hit) + return hit diff --git a/src/simgeometry/polygon_objects.py b/src/simgeometry/polygon_objects.py new file mode 100644 index 0000000..f467686 --- /dev/null +++ b/src/simgeometry/polygon_objects.py @@ -0,0 +1,229 @@ +"""2D objects made of polygons, packed for the swept absorption kernel. + +:func:`polygon_objects` packs the objects the ``absorb_polygons_2d`` kernel can +take (a :class:`~simgeometry.objects.polygon.Polygon`, an +:class:`~simgeometry.objects.aperture2d.Aperture2D`: the union of its two +plates) into flat arrays, so markers are tested against every object in one +pass instead of one containment call per object and sample along the step. +:meth:`PolygonObjects.absorb` runs the kernel: a marker is absorbed when it +ends inside an object or when its straight step from ``start`` crosses one of +the object's edges, exactly (no sampling), so a plate thinner than the step is +not jumped over. The object met first along the step takes the marker. + +Example:: + + from simgeometry.objects import Aperture2D + from simgeometry.polygon_objects import polygon_objects + + packed = polygon_objects([Aperture2D.slit(...)]) + hit = packed.absorb(new_positions, alive, start=old_positions) + # hit[i] is the slot of the object marker i hit (-1: none), alive updated +""" + +from __future__ import annotations + +from collections import OrderedDict +from collections.abc import Sequence +from dataclasses import dataclass +from typing import Any + +import cunumpy as xp +import numpy as np + +from simgeometry.objects.aperture2d import Aperture2D +from simgeometry.objects.polygon import Polygon + +Array = Any + + +@dataclass(frozen=True) +class PolygonObjects: + """Polygon objects ``objects[index[k]]``, slot ``k``, as kernel arrays. + + Slot ``k`` is polygons ``first[k] <= p < last[k]``, polygon ``p`` the rows + ``offsets[p] <= v < offsets[p + 1]`` of ``vertices`` (V, 2). ``boxed[k]`` + says whether a marker outside the box ``lower[k]``..``upper[k]`` cannot touch + the object (false for an inverted one, solid outside its box). The ``_host`` + copies are for building per-call arrays without device transfers. + """ + + index: tuple[int, ...] + vertices: Array + offsets: Array + first: Array + last: Array + inverted: Array + lower: Array + upper: Array + boxed: Array + first_host: np.ndarray + last_host: np.ndarray + inverted_host: np.ndarray + + def slot(self, object_index: int) -> int | None: + """The slot of ``objects[object_index]``, or None if it is not here.""" + try: + return self.index.index(object_index) + except ValueError: + return None + + def kernel_arguments(self) -> tuple[Array, ...]: + """The object arguments of ``absorb_polygons_2d``, after the sweep length.""" + return ( + self.vertices, + self.offsets, + self.first, + self.last, + self.inverted, + self.lower, + self.upper, + self.boxed, + ) + + def absorb( + self, + positions: Array, + alive: Array, + *, + start: Array | None = None, + rows: Array | None = None, + sweep_length: float = np.inf, + ) -> Array: + """Absorb the alive markers that end in, or step through, an object. + + Args: + positions: The marker positions after the step, ``(2, N)``. + alive: ``(N,)`` booleans; absorbed markers are set to False. + start: The positions before the step, ``(2, N)``; without it only + the end points count. + rows: The markers to test (default: all ``N``). + sweep_length: Steps longer than this count by their end only (e.g. + steps wrapped by a periodic side). + + Returns: + ``hit``, one entry per row: the slot of the object it hit (the + object ``objects[index[hit]]``), or -1. + """ + from simgeometry.kernels.absorb_polygons_2d import kernel + + positions = _c_ordered(positions) + if rows is None: + rows = xp.arange(positions.shape[1], dtype=xp.int64) + rows = xp.asarray(rows, dtype=xp.int64) + use_start = start is not None + if use_start: + start = _c_ordered(start) + hit = xp.empty(rows.shape[0], dtype=xp.int64) + if rows.shape[0] == 0: + return hit + kernel( + positions, + start if use_start else positions, + rows, + int(use_start), + float(sweep_length) if use_start else 0.0, + *self.kernel_arguments(), + # the CUDA version takes the mask as char (int8), the same bytes + alive.view(xp.int8) if xp.is_gpu(alive) and alive.dtype == bool else alive, + hit, + ) + return hit + + +def _c_ordered(points: Array) -> Array: + """``points`` as a float ``(2, N)`` array with C strides, copied only if needed. + + A ``(2, 1)`` column picked by fancy indexing keeps Fortran strides that NumPy + still flags as C-contiguous; Pyccel checks the strides. + """ + points = xp.ascontiguousarray(xp.asarray(points, dtype=float)) + itemsize = points.dtype.itemsize + if tuple(points.strides) != (points.shape[1] * itemsize, itemsize): + points = points.copy(order="C") + return points + + +def object_polygons(obj: object) -> list[np.ndarray] | None: + """The (n, 2) vertex arrays whose union is ``obj``, or None if it is not polygons.""" + if isinstance(obj, Aperture2D): + plates = (obj._lower_polygon, obj._upper_polygon) + return [_host_vertices(p.vertices) for p in plates] + if isinstance(obj, Polygon): + return [_host_vertices(obj.vertices)] + return None + + +def _host_vertices(vertices: Array) -> np.ndarray: + if xp.is_gpu(vertices): + vertices = xp.to_numpy(vertices) + return np.asarray(vertices, dtype=float) + + +#: The packed objects of recent calls; the key holds the objects themselves +#: (alive while cached, so their ids are not reused). +_CACHE: OrderedDict[tuple, tuple[tuple, PolygonObjects | None]] = OrderedDict() +_CACHE_SIZE = 16 + + +def _pack(objects: Sequence[object], skip: frozenset[int]) -> PolygonObjects | None: + index, vertices, offsets, first, last = [], [], [0], [], [] + inverted, lower, upper, boxed = [], [], [], [] + for object_index, obj in enumerate(objects): + polygons = None if object_index in skip else object_polygons(obj) + if not polygons: + continue + index.append(object_index) + first.append(len(offsets) - 1) + for polygon in polygons: + vertices.append(polygon) + offsets.append(offsets[-1] + polygon.shape[0]) + last.append(len(offsets) - 1) + is_inverted = bool(getattr(obj, "inverted", False)) + inverted.append(int(is_inverted)) + lower.append(np.asarray(obj.lower_bound, dtype=float).reshape(2)) + upper.append(np.asarray(obj.upper_bound, dtype=float).reshape(2)) + boxed.append(int(not is_inverted)) + if not index: + return None + + def integers(values: list[int]) -> Array: + return xp.asarray(np.asarray(values, dtype=np.int64)) + + return PolygonObjects( + index=tuple(index), + vertices=xp.asarray(np.concatenate(vertices)), + offsets=integers(offsets), + first=integers(first), + last=integers(last), + inverted=integers(inverted), + lower=xp.asarray(np.array(lower)), + upper=xp.asarray(np.array(upper)), + boxed=integers(boxed), + first_host=np.asarray(first, dtype=np.int64), + last_host=np.asarray(last, dtype=np.int64), + inverted_host=np.asarray(inverted, dtype=np.int64), + ) + + +def polygon_objects( + objects: Sequence[object] | None, + skip: frozenset[int] = frozenset(), +) -> PolygonObjects | None: + """Pack the polygon objects among ``objects`` (not those indexed in ``skip``). + + Objects that are not polygons (e.g. a ``Circle``) are left out; None if none + is left. The objects are expected to stay fixed, so the packing is cached + by their identity (and the array backend). + """ + if not objects: + return None + key = (tuple(id(obj) for obj in objects), skip, xp.get_backend()) + cached = _CACHE.get(key) + if cached is not None: + _CACHE.move_to_end(key) + return cached[1] + packed = _pack(objects, skip) + _CACHE[key] = (tuple(objects), packed) + if len(_CACHE) > _CACHE_SIZE: + _CACHE.popitem(last=False) + return packed diff --git a/src/simgeometry/tests/test_polygon_absorption.py b/src/simgeometry/tests/test_polygon_absorption.py new file mode 100644 index 0000000..046c42f --- /dev/null +++ b/src/simgeometry/tests/test_polygon_absorption.py @@ -0,0 +1,267 @@ +"""The swept polygon absorption: absorb_polygons_2d and the packed polygon objects.""" + +from __future__ import annotations + +import importlib.util + +import cunumpy as xp +import numpy as np +import pytest + +from simgeometry.kernels.absorb_polygons_2d import kernel as absorb_polygons_2d +from simgeometry.kernels.contains_polygon_2d import kernel as contains_polygon_2d +from simgeometry.objects import Aperture2D, Circle, Polygon +from simgeometry.polygon_objects import object_polygons, polygon_objects + +# two plates of an aperture (one object), a triangle, and an inverted square +PLATE_LOW = [(4.0, 0.0), (4.0, 3.0), (5.0, 3.0), (5.0, 0.0)] +PLATE_HIGH = [(4.0, 7.0), (4.0, 10.0), (5.0, 10.0), (5.0, 7.0)] +TRIANGLE = [(7.0, 4.0), (8.0, 6.0), (9.0, 4.0)] +SQUARE = [(1.0, 1.0), (1.0, 9.0), (9.5, 9.0), (9.5, 1.0)] + +IMPLEMENTATIONS = [ + "numpy", + "python", # the uncompiled source of the Pyccel version + pytest.param( + "pyccel", + marks=pytest.mark.skipif( + importlib.util.find_spec("pyccel") is None, reason="needs pyccel" + ), + ), +] + + +def _objects(polygon_sets, inverted): + vertices, offsets, first, last = [], [0], [], [] + for polygons in polygon_sets: + first.append(len(offsets) - 1) + for polygon in polygons: + vertices.extend(polygon) + offsets.append(offsets[-1] + len(polygon)) + last.append(len(offsets) - 1) + lower = np.array([np.min(np.concatenate(p), axis=0) for p in polygon_sets]) + upper = np.array([np.max(np.concatenate(p), axis=0) for p in polygon_sets]) + return ( + np.asarray(vertices, dtype=float), + np.asarray(offsets, dtype=np.int64), + np.asarray(first, dtype=np.int64), + np.asarray(last, dtype=np.int64), + np.asarray(inverted, dtype=np.int64), + lower, + upper, + np.asarray([1 - flag for flag in inverted], dtype=np.int64), + ) + + +OBJECTS = _objects([[PLATE_LOW, PLATE_HIGH], [TRIANGLE], [SQUARE]], [0, 0, 1]) + + +def _absorb(implementation, end, start, rows, use_start, sweep_length, objects, alive): + alive = alive.copy() + hit = np.full(rows.shape[0], 99, dtype=np.int64) + with xp.kernels.use_host_kernel_implementation(implementation): + absorb_polygons_2d( + end, start, rows, use_start, sweep_length, *objects, alive, hit + ) + return alive, hit + + +def _random_steps(n, seed, step=1.5): + rng = np.random.default_rng(seed) + start = rng.uniform(0.0, 10.0, (2, n)) + end = start + rng.normal(scale=step, size=(2, n)) + return start, end + + +@pytest.mark.parametrize("implementation", IMPLEMENTATIONS[1:]) +@pytest.mark.parametrize("use_start", [0, 1]) +def test_pyccel_version_matches_numpy(implementation, use_start): + n = 1500 + start, end = _random_steps(n, seed=1) + alive = np.random.default_rng(2).random(n) > 0.1 + rows = np.flatnonzero(np.random.default_rng(3).random(n) > 0.3) + results = [ + _absorb(name, end, start, rows, use_start, 3.0, OBJECTS, alive) + for name in (implementation, "numpy") + ] + np.testing.assert_array_equal(results[0][0], results[1][0]) + np.testing.assert_array_equal(results[0][1], results[1][1]) + hit = results[0][1] + assert set(np.unique(hit)) == {-1, 0, 1, 2} + # dead rows are never hit, and every hit row dies + np.testing.assert_array_equal(hit[~alive[rows]], -1) + assert not results[0][0][rows[hit >= 0]].any() + + +@pytest.mark.parametrize("implementation", IMPLEMENTATIONS) +def test_steps_through_thin_plates_and_corners_are_caught_exactly(implementation): + objects = _objects([[PLATE_LOW, PLATE_HIGH], [TRIANGLE]], [0, 0]) + steps = { + # (start, end): the object it hits (-1: none) + ((3.0, 1.0), (6.0, 1.0)): 0, # through a plate, ends beyond it + ((3.9, 3.05), (4.1, 2.95)): 0, # clips the plate's corner + ((3.0, 5.0), (6.0, 5.0)): -1, # through the aperture + ((3.0, 2.0), (6.5, 5.0)): 0, # the plate before the triangle + ((6.5, 5.0), (9.5, 5.0)): 1, # through the triangle + ((6.0, 6.5), (10.0, 6.5)): -1, # above the triangle + ((4.5, 5.0), (4.5, 1.5)): 0, # ends inside, from the gap + } + start = np.array([s for s, _ in steps]).T.copy() + end = np.array([e for _, e in steps]).T.copy() + rows = np.arange(len(steps), dtype=np.int64) + alive = np.ones(len(steps), dtype=bool) + absorbed, hit = _absorb(implementation, end, start, rows, 1, 10.0, objects, alive) + np.testing.assert_array_equal(hit, list(steps.values())) + np.testing.assert_array_equal(absorbed, hit < 0) + # without the step only the end counts + _, hit = _absorb(implementation, end, start, rows, 0, 10.0, objects, alive) + np.testing.assert_array_equal(hit, [-1, 0, -1, -1, -1, -1, 0]) + # a step longer than the sweep length (wrapped) counts by its end only + _, hit = _absorb(implementation, end, start, rows, 1, 0.5, objects, alive) + np.testing.assert_array_equal(hit, [-1, 0, -1, -1, -1, -1, 0]) + + +def test_ends_match_contains_polygon(): + """Without a step the test is the even-odd rule of contains_polygon_2d.""" + rng = np.random.default_rng(4) + points = rng.uniform(-1.0, 11.0, (2, 3000)) + rows = np.arange(3000, dtype=np.int64) + alive, hit = _absorb( + "numpy", points, points, rows, 0, 0.0, OBJECTS, np.ones(3000, dtype=bool) + ) + inside = np.zeros((3, 3000), dtype=bool) + for k, (polygons, inverted) in enumerate( + [([PLATE_LOW, PLATE_HIGH], 0), ([TRIANGLE], 0), ([SQUARE], 1)] + ): + for polygon in polygons: + mask = np.empty(3000, dtype=bool) + contains_polygon_2d(points, mask, np.asarray(polygon, dtype=float), 0) + inside[k] |= mask + if inverted: + inside[k] = ~inside[k] + first = np.where(inside.any(axis=0), np.argmax(inside, axis=0), -1) + np.testing.assert_array_equal(hit, first) + np.testing.assert_array_equal(alive, first < 0) + + +@pytest.mark.parametrize("use_start", [0, 1]) +def test_cuda_version_matches_numpy_in_emulation(use_start): + """The CUDA source, compiled as C++ and run serially, against the NumPy version.""" + from cunumpy.kernel_testing import emulate_cuda_kernel, emulation_compiler + + if emulation_compiler() is None: + pytest.skip("no C++ compiler") + n = 400 + start, end = _random_steps(n, seed=13) + rows = np.flatnonzero(np.random.default_rng(14).random(n) > 0.2).astype(np.int64) + alive = np.random.default_rng(15).random(n) > 0.1 + expected_alive, expected_hit = _absorb( + "numpy", end, start, rows, use_start, 3.0, OBJECTS, alive + ) + emulated_alive = alive.astype(np.int8) + hit = np.full(rows.shape[0], 99, dtype=np.int64) + emulate_cuda_kernel( + absorb_polygons_2d.cuda_kernel, + end, + start, + rows, + use_start, + 3.0, + *OBJECTS, + emulated_alive, + hit, + n_threads=rows.shape[0], + ) + np.testing.assert_array_equal(emulated_alive.astype(bool), expected_alive) + np.testing.assert_array_equal(hit, expected_hit) + assert set(np.unique(expected_hit)) == {-1, 0, 1, 2} + + +def test_polygon_objects_pack_polygons_and_apertures_only(): + aperture = Aperture2D.slit(4.5, 2.0, 1.0, 3.0, axis=0, position=5.0) + triangle = Polygon(TRIANGLE) + circle = Circle(midpoint=(2.0, 2.0), radius=0.5) + square = Polygon(SQUARE, inverted=True) + packed = polygon_objects([aperture, circle, triangle, square]) + assert packed.index == (0, 2, 3) + assert packed.slot(1) is None and packed.slot(2) == 1 + np.testing.assert_array_equal(packed.first_host, [0, 2, 3]) + np.testing.assert_array_equal(packed.last_host, [2, 3, 4]) + np.testing.assert_array_equal(packed.inverted_host, [0, 0, 1]) + np.testing.assert_array_equal(xp.to_numpy(packed.boxed), [1, 1, 0]) + assert len(object_polygons(aperture)) == 2 and object_polygons(circle) is None + # cached by identity, and None without polygons + assert polygon_objects([aperture, circle, triangle, square]) is packed + assert polygon_objects([circle]) is None and polygon_objects([]) is None + assert polygon_objects([aperture, triangle], skip=frozenset({0})).index == (1,) + + +def test_absorb_takes_a_step_longer_than_the_plate_thickness(): + """A slit: one marker passes the aperture, one jumps over a plate and is taken.""" + aperture = Aperture2D.slit(4.5, 2.0, 0.1, 5.0, axis=0, position=5.0) + circle = Circle(midpoint=(8.0, 5.0), radius=0.5) # not a polygon: ignored + triangle = Polygon(TRIANGLE) + packed = polygon_objects([circle, aperture, triangle]) + start = np.array([[3.0, 3.0, 3.0, 6.5], [5.0, 1.0, 9.0, 5.2]]) + end = np.array([[6.0, 6.0, 6.0, 9.5], [5.0, 1.0, 9.0, 5.2]]) + alive = np.ones(4, dtype=bool) + hit = packed.absorb(end, alive, start=start) + np.testing.assert_array_equal(hit, [-1, 0, 0, 1]) + np.testing.assert_array_equal(alive, [True, False, False, False]) + assert [packed.index[k] for k in hit[hit >= 0]] == [1, 1, 2] + # by the end points only, nothing ends in a plate + alive = np.ones(4, dtype=bool) + np.testing.assert_array_equal(packed.absorb(end, alive), [-1, -1, -1, -1]) + # a subset of rows, and a sweep length shorter than the steps + alive = np.ones(4, dtype=bool) + hit = packed.absorb(end, alive, start=start, rows=np.array([1, 3])) + np.testing.assert_array_equal(hit, [0, 1]) + alive = np.ones(4, dtype=bool) + hit = packed.absorb(end, alive, start=start, sweep_length=1.0) + np.testing.assert_array_equal(hit, [-1, -1, -1, -1]) + + +def test_absorb_takes_a_single_column_with_fortran_strides(): + packed = polygon_objects([Polygon(TRIANGLE)]) + points = np.array([[8.0, 0.0], [5.0, 0.0]])[:, [0]] # (2, 1), F strides + start = np.array([[6.0, 0.0], [5.0, 0.0]])[:, [0]] + hit = packed.absorb(points, np.ones(1, dtype=bool), start=start) + np.testing.assert_array_equal(hit, [0]) + + +FAKE_CUPY_CODE = """ +import cunumpy as xp +import numpy as np +from cunumpy.kernel_testing import emulated_launches + +from simgeometry.objects import Aperture2D, Polygon +from simgeometry.polygon_objects import polygon_objects + +objects = [Aperture2D.slit(4.5, 2.0, 0.1, 5.0, position=5.0), Polygon( + [(7.0, 4.0), (8.0, 6.0), (9.0, 4.0)])] +rng = np.random.default_rng(0) +start = rng.uniform(0.0, 10.0, (2, 300)) +end = start + rng.normal(scale=1.5, size=(2, 300)) +results = [] +for backend in ("numpy", "cupy"): + with xp.use_backend(backend, strict=True), emulated_launches(): + packed = polygon_objects(objects) + alive = xp.ones(300, dtype=bool) + hit = packed.absorb(xp.asarray(end), alive, start=xp.asarray(start)) + assert (backend == "cupy") == xp.is_gpu(hit) + results.append((xp.to_numpy(hit), xp.to_numpy(alive))) +assert set(np.unique(results[0][0])) == {-1, 0, 1} +for a, b in zip(*results): + assert np.array_equal(a, b) +print("ok") +""" + + +def test_absorb_on_the_cupy_backend_matches_numpy(): + """The CUDA path of absorb (fake CuPy, emulated launches) against the host path.""" + from cunumpy.kernel_testing import emulation_compiler, run_in_fake_cupy_subprocess + + if emulation_compiler() is None: + pytest.skip("no C++ compiler") + result = run_in_fake_cupy_subprocess(FAKE_CUPY_CODE, timeout=300) + assert "ok" in result.stdout From 46aa49d5acd6d0cdd5c49d83bdfd1026d4930de4 Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 26 Oct 2026 22:39:25 +0100 Subject: [PATCH 14/36] Type periodic as one flag or one per axis (#30) --- src/simgeometry/boundingbox.py | 2 +- src/simgeometry/grid.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/simgeometry/boundingbox.py b/src/simgeometry/boundingbox.py index b137594..9ecfa3b 100644 --- a/src/simgeometry/boundingbox.py +++ b/src/simgeometry/boundingbox.py @@ -24,7 +24,7 @@ def __init__( upper_bound: tuple, comm: MPI.Comm | None = None, domain_decomposition: ConfigValue = None, - periodic: bool | None = None, + periodic: bool | tuple[bool, ...] | None = None, ) -> None: """ Initialize the simulation domain. diff --git a/src/simgeometry/grid.py b/src/simgeometry/grid.py index aa71f1d..3a6fde7 100644 --- a/src/simgeometry/grid.py +++ b/src/simgeometry/grid.py @@ -24,7 +24,7 @@ def __init__( num_ghost_points: int = 0, comm: MPI.Comm | None = None, domain_decomposition: ConfigValue = None, - periodic: bool | None = None, + periodic: bool | tuple[bool, ...] | None = None, ) -> None: """ Initialize a grid. From 798fa245db63cbcc8f3733e83a449f12a8f8e55c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 22:39:35 +0100 Subject: [PATCH 15/36] Bump ruff from 0.17.1 to 0.17.2 in the python group (#29) Bumps the python group with 1 update: [ruff](https://github.com/astral-sh/ruff). Updates `ruff` from 0.17.1 to 0.17.2 - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.17.1...0.17.2) --- updated-dependencies: - dependency-name: ruff dependency-version: 0.17.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: python ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- uv.lock | 364 ++++++++++++++++++++++++++++---------------------------- 1 file changed, 182 insertions(+), 182 deletions(-) diff --git a/uv.lock b/uv.lock index ce0aae5..04213a9 100644 --- a/uv.lock +++ b/uv.lock @@ -1,15 +1,15 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", "python_full_version == '3.13.*' and sys_platform == 'emscripten'", - "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.11.*' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'emscripten'", @@ -730,7 +730,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130, upload-time = "2025-04-15T17:47:53.79Z" } wheels = [ @@ -802,7 +802,7 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } wheels = [ @@ -888,14 +888,14 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", "python_full_version == '3.13.*' and sys_platform == 'emscripten'", - "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/83/5a/a55177dd22553a277388e8a1b3220e92de91bacb28356cdc73caa240121d/contourpy-1.4.0.tar.gz", hash = "sha256:20156f5a1ac4f8ce02656e39a61e82164a3d359796dc8026f75b062783d500e1", size = 13323726, upload-time = "2026-09-11T19:05:05.808Z" } wheels = [ @@ -1171,12 +1171,12 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "attrs", marker = "python_full_version < '3.11'" }, - { name = "docstring-parser", marker = "python_full_version < '3.11'" }, - { name = "rich", marker = "python_full_version < '3.11'" }, - { name = "rich-rst", marker = "python_full_version < '3.11'" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "attrs" }, + { name = "docstring-parser" }, + { name = "rich" }, + { name = "rich-rst" }, + { name = "tomli" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c1/08/a444f3cca86cb27bc256831fc8c42b578390f4087bf6631cccb5334bd9ed/cyclopts-4.25.3.tar.gz", hash = "sha256:7d3a067bc7699a1a65fe2ea0b092a925855bb711a05adc1e65c5b9ba307acea4", size = 202814, upload-time = "2026-09-17T16:25:16.892Z" } wheels = [ @@ -1192,20 +1192,20 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", "python_full_version == '3.13.*' and sys_platform == 'emscripten'", - "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.11.*' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "attrs", marker = "python_full_version >= '3.11'" }, - { name = "docstring-parser", marker = "python_full_version >= '3.11'" }, - { name = "rich", marker = "python_full_version >= '3.11'" }, - { name = "rich-rst", marker = "python_full_version >= '3.11'" }, + { name = "attrs" }, + { name = "docstring-parser" }, + { name = "rich" }, + { name = "rich-rst" }, ] sdist = { url = "https://files.pythonhosted.org/packages/28/c1/3debeeb6e0eb74a51d6f8cf1f273ed7c479e3321631cbf89fb9700e65e28/cyclopts-5.2.0.tar.gz", hash = "sha256:b63c1b1beaadf3ead19214385a0f90b990f152c4107c174e1724c45dc71e9541", size = 246542, upload-time = "2026-10-06T15:02:39.212Z" } wheels = [ @@ -1286,7 +1286,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -1405,10 +1405,10 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", "python_full_version == '3.13.*' and sys_platform == 'emscripten'", - "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.11.*' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'emscripten'", @@ -1763,8 +1763,8 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "pillow", marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "pillow" }, ] sdist = { url = "https://files.pythonhosted.org/packages/81/c8/2b56274457e4fadcfa86bf7c0fb93f19e124ab8f38f4bfa4372423052046/imageio-2.38.0.tar.gz", hash = "sha256:fe1d406862f6bc2930e8ec662d7bc40d4370cb76e3390dcd33819560d2708531", size = 403333, upload-time = "2026-09-28T07:44:08.101Z" } wheels = [ @@ -1780,19 +1780,19 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", "python_full_version == '3.13.*' and sys_platform == 'emscripten'", - "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.11.*' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "pillow", marker = "python_full_version >= '3.11'" }, + { name = "pillow" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f3/cd/69e4ac55b6dafdd2b5f32075236841a3945dea7323d0232d80f28c37cea8/imageio-2.38.1.tar.gz", hash = "sha256:6769f1f01c4dd46448307863a787c9a22fa4dbe11c0c88f525c6e410fdfd7983", size = 403667, upload-time = "2026-10-08T14:35:32.961Z" } wheels = [ @@ -1825,19 +1825,19 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "appnope", marker = "python_full_version < '3.11' and sys_platform == 'darwin'" }, - { name = "comm", marker = "python_full_version < '3.11'" }, - { name = "debugpy", marker = "python_full_version < '3.11'" }, - { name = "ipython", version = "8.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "jupyter-client", marker = "python_full_version < '3.11'" }, - { name = "jupyter-core", marker = "python_full_version < '3.11'" }, - { name = "matplotlib-inline", marker = "python_full_version < '3.11'" }, - { name = "nest-asyncio2", marker = "python_full_version < '3.11'" }, - { name = "packaging", marker = "python_full_version < '3.11'" }, - { name = "psutil", marker = "python_full_version < '3.11'" }, - { name = "pyzmq", marker = "python_full_version < '3.11'" }, - { name = "tornado", marker = "python_full_version < '3.11'" }, - { name = "traitlets", marker = "python_full_version < '3.11'" }, + { name = "appnope", marker = "sys_platform == 'darwin'" }, + { name = "comm" }, + { name = "debugpy" }, + { name = "ipython", version = "8.39.0", source = { registry = "https://pypi.org/simple" } }, + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "matplotlib-inline" }, + { name = "nest-asyncio2" }, + { name = "packaging" }, + { name = "psutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3d/c4/e4a38f579de4225a561305666f7541cdabb30075def2aa1ac17bd73c1fb5/ipykernel-7.3.0.tar.gz", hash = "sha256:9acaaaf97d16355166e4085afe9d225bfbdf2b7ef520f9df3be8f2b248275e09", size = 184899, upload-time = "2026-06-10T08:41:25.481Z" } wheels = [ @@ -1853,28 +1853,28 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", "python_full_version == '3.13.*' and sys_platform == 'emscripten'", - "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.11.*' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "appnope", marker = "python_full_version >= '3.11' and sys_platform == 'darwin'" }, - { name = "comm", marker = "python_full_version >= '3.11'" }, - { name = "debugpy", marker = "python_full_version >= '3.11'" }, - { name = "ipython", version = "9.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "jupyter-client", marker = "python_full_version >= '3.11'" }, - { name = "jupyter-core", marker = "python_full_version >= '3.11'" }, - { name = "matplotlib-inline", marker = "python_full_version >= '3.11'" }, - { name = "nest-asyncio2", marker = "python_full_version >= '3.11'" }, - { name = "packaging", marker = "python_full_version >= '3.11'" }, - { name = "pyzmq", marker = "python_full_version >= '3.11'" }, - { name = "tornado", marker = "python_full_version >= '3.11'" }, - { name = "traitlets", marker = "python_full_version >= '3.11'" }, + { name = "appnope", marker = "sys_platform == 'darwin'" }, + { name = "comm" }, + { name = "debugpy" }, + { name = "ipython", version = "9.17.1", source = { registry = "https://pypi.org/simple" } }, + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "matplotlib-inline" }, + { name = "nest-asyncio2" }, + { name = "packaging" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/05/e5499c8b762387d83926bf0389e8fff888c9bf6e7af30566a8527bc12f4a/ipykernel-7.4.0.tar.gz", hash = "sha256:4330114d22b9b33575b2c7c68753fc8faabbbcfbc60b0cdff9f14dd4ce63742f", size = 192731, upload-time = "2026-09-29T16:33:11.468Z" } wheels = [ @@ -1889,17 +1889,17 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, - { name = "decorator", marker = "python_full_version < '3.11'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, - { name = "jedi", marker = "python_full_version < '3.11'" }, - { name = "matplotlib-inline", marker = "python_full_version < '3.11'" }, - { name = "pexpect", marker = "python_full_version < '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit", marker = "python_full_version < '3.11'" }, - { name = "pygments", marker = "python_full_version < '3.11'" }, - { name = "stack-data", marker = "python_full_version < '3.11'" }, - { name = "traitlets", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "decorator" }, + { name = "exceptiongroup" }, + { name = "jedi" }, + { name = "matplotlib-inline" }, + { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit" }, + { name = "pygments" }, + { name = "stack-data" }, + { name = "traitlets" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/40/18/f8598d287006885e7136451fdea0755af4ebcbfe342836f24deefaed1164/ipython-8.39.0.tar.gz", hash = "sha256:4110ae96012c379b8b6db898a07e186c40a2a1ef5d57a7fa83166047d9da7624", size = 5513971, upload-time = "2026-03-27T10:02:13.94Z" } wheels = [ @@ -1915,27 +1915,27 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", "python_full_version == '3.13.*' and sys_platform == 'emscripten'", - "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.11.*' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "colorama", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" }, - { name = "ipython-pygments-lexers", marker = "python_full_version >= '3.11'" }, - { name = "jedi", marker = "python_full_version >= '3.11'" }, - { name = "matplotlib-inline", marker = "python_full_version >= '3.11'" }, - { name = "pexpect", marker = "python_full_version >= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit", marker = "python_full_version >= '3.11'" }, - { name = "psutil", marker = "python_full_version >= '3.11' and sys_platform != 'cygwin' and sys_platform != 'emscripten'" }, - { name = "pygments", marker = "python_full_version >= '3.11'" }, - { name = "stack-data", marker = "python_full_version >= '3.11'" }, - { name = "traitlets", marker = "python_full_version >= '3.11'" }, - { name = "typing-extensions", marker = "python_full_version == '3.11.*'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "ipython-pygments-lexers" }, + { name = "jedi" }, + { name = "matplotlib-inline" }, + { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit" }, + { name = "psutil", marker = "sys_platform != 'cygwin' and sys_platform != 'emscripten'" }, + { name = "pygments" }, + { name = "stack-data" }, + { name = "traitlets" }, + { name = "typing-extensions", marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b9/32/99451b1283ec5d92ad77073f12e1c667dc10775384d8f15c2914207149dd/ipython-9.17.1.tar.gz", hash = "sha256:8919be8c27f20a6f4423145028063f6637b42a03ce57665bb12015ee1f073529", size = 4539289, upload-time = "2026-09-01T08:29:32.6Z" } wheels = [ @@ -1947,7 +1947,7 @@ name = "ipython-pygments-lexers" version = "1.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pygments", marker = "python_full_version >= '3.11'" }, + { name = "pygments" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } wheels = [ @@ -2592,15 +2592,15 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "contourpy", version = "1.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "cycler", marker = "python_full_version < '3.11'" }, - { name = "fonttools", version = "4.65.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "kiwisolver", marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "packaging", marker = "python_full_version < '3.11'" }, - { name = "pillow", marker = "python_full_version < '3.11'" }, - { name = "pyparsing", marker = "python_full_version < '3.11'" }, - { name = "python-dateutil", marker = "python_full_version < '3.11'" }, + { name = "contourpy", version = "1.3.2", source = { registry = "https://pypi.org/simple" } }, + { name = "cycler" }, + { name = "fonttools", version = "4.65.0", source = { registry = "https://pypi.org/simple" } }, + { name = "kiwisolver" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, ] sdist = { url = "https://files.pythonhosted.org/packages/63/1b/4be5be87d43d327a0cf4de1a56e86f7f84c89312452406cf122efe2839e6/matplotlib-3.10.9.tar.gz", hash = "sha256:fd66508e8c6877d98e586654b608a0456db8d7e8a546eb1e2600efd957302358", size = 34811233, upload-time = "2026-04-24T00:14:13.539Z" } wheels = [ @@ -2669,27 +2669,27 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", "python_full_version == '3.13.*' and sys_platform == 'emscripten'", - "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.11.*' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "contourpy", version = "1.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "contourpy", version = "1.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "contourpy", version = "1.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "cycler", marker = "python_full_version >= '3.11'" }, - { name = "fonttools", version = "4.66.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "kiwisolver", marker = "python_full_version >= '3.11'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "cycler" }, + { name = "fonttools", version = "4.66.1", source = { registry = "https://pypi.org/simple" } }, + { name = "kiwisolver" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "packaging", marker = "python_full_version >= '3.11'" }, - { name = "pillow", marker = "python_full_version >= '3.11'" }, - { name = "pyparsing", marker = "python_full_version >= '3.11'" }, - { name = "python-dateutil", marker = "python_full_version >= '3.11'" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e7/c8/9aa712a0afb882649424dd8de8ad9aa6235e796e84c6052e8f6dc1598d0d/matplotlib-3.11.2.tar.gz", hash = "sha256:cec596316640f2b394b8f0daa0ea61a8eae82d017b620b9f202befb972a59ea4", size = 32660610, upload-time = "2026-09-11T19:05:31.214Z" } wheels = [ @@ -2799,7 +2799,7 @@ name = "mlx" version = "0.32.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "mlx-metal", marker = "sys_platform == 'darwin'" }, + { name = "mlx-metal" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/52/73/f2b18f4bbcfeacd6880e68f2854609d6d2d305e5676586451a6c26664061/mlx-0.32.4-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:60ba41564e8b5018c9d8a5f8ce7a5aaf70c96d8dd98c1a54b854cbada362d471", size = 671799, upload-time = "2026-10-14T21:15:37.06Z" }, @@ -2862,10 +2862,10 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", "python_full_version == '3.13.*' and sys_platform == 'emscripten'", - "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.11.*' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'emscripten'", @@ -3323,10 +3323,10 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", "python_full_version == '3.13.*' and sys_platform == 'emscripten'", - "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] sdist = { url = "https://files.pythonhosted.org/packages/dc/76/3af777226b63a5e64a6b36b1ec5855c14e2b94a37096d4760e595fc43511/networkx-3.7.tar.gz", hash = "sha256:fd77a511bd90f39f3d016351345b52cf5319b813bdca01de3f755d3cca62e96a", size = 1866482, upload-time = "2026-09-21T16:45:16.974Z" } @@ -3539,10 +3539,10 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", "python_full_version == '3.13.*' and sys_platform == 'emscripten'", - "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] sdist = { url = "https://files.pythonhosted.org/packages/13/01/11703282db468b85f6f7b8c7f22d058de5970d5c7e60a3a8aaa313c3de36/numpy-2.5.3.tar.gz", hash = "sha256:df2d5874ff183595a4ba404edd04f6bd9b5505c1d7708573f6a6c17489a67563", size = 20791231, upload-time = "2026-09-06T16:27:47.073Z" } @@ -3640,10 +3640,10 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "python-dateutil", marker = "python_full_version < '3.11'" }, - { name = "pytz", marker = "python_full_version < '3.11'" }, - { name = "tzdata", marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "tzdata" }, ] sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } wheels = [ @@ -3705,20 +3705,20 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", "python_full_version == '3.13.*' and sys_platform == 'emscripten'", - "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.11.*' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "python-dateutil", marker = "python_full_version >= '3.11'" }, - { name = "tzdata", marker = "(python_full_version >= '3.11' and sys_platform == 'emscripten') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e2/17/d7b106e05bfa642e8694451e7d3d759c6a241c5386a5d962e4f66c047e06/pandas-3.0.6.tar.gz", hash = "sha256:66b07ef7315a31bfe1089cd3d71a7de781c9dca986762d0b4fe7c0ef17465d10", size = 4667686, upload-time = "2026-09-17T23:23:18.345Z" } wheels = [ @@ -3804,7 +3804,7 @@ name = "pexpect" version = "4.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ptyprocess", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, + { name = "ptyprocess" }, ] 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 = [ @@ -4246,7 +4246,7 @@ name = "pyobjc-framework-cocoa" version = "12.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, + { name = "pyobjc-core" }, ] sdist = { url = "https://files.pythonhosted.org/packages/75/76/49c6da2c6a831020b4854ba20079d5a1030474bffc776b7b73c2eeff8c15/pyobjc_framework_cocoa-12.2.2.tar.gz", hash = "sha256:c96c0ef69a71afbbb0e6a7d594b455c5fe47d62e0db376ee7a2b4b828c16ace9", size = 3132831, upload-time = "2026-08-11T19:44:02.288Z" } wheels = [ @@ -4829,10 +4829,10 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", "python_full_version == '3.13.*' and sys_platform == 'emscripten'", - "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.11.*' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'emscripten'", @@ -4971,27 +4971,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.17.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e7/e6d5749983b020e6cb1055abec0a6f361a8e94f0184cdcf56d4760995f02/ruff-0.17.1.tar.gz", hash = "sha256:5bb796c5112e9fb9527f2ef2faa0a130d6312d19de92bcfaddd70dbb2d41020f", size = 5323349, upload-time = "2026-10-15T15:23:18.64Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/78/90c46ca6250709f7c37e397b60d06061adee8d0f7d13959ed6a8e49b14fd/ruff-0.17.1-py3-none-linux_armv6l.whl", hash = "sha256:1796c41eafec66f4635125dd1e074a7aa27b8d20c187d484360acf11d247fc4a", size = 10147528, upload-time = "2026-10-15T15:22:33.667Z" }, - { url = "https://files.pythonhosted.org/packages/aa/d2/a2e28bc35b083ae51fa07dfba5280a667c8b01ecaee7802fc8849dcd5c46/ruff-0.17.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8eadb09a788010be29c895418bdfa513e4e1c65e64b94e5b19e4cd381f6cc7fb", size = 10225974, upload-time = "2026-10-15T15:22:36.328Z" }, - { url = "https://files.pythonhosted.org/packages/8e/71/7a9029a81c8206ee01a98f6cfeeb56e6c095af86857581a37045c46d7e94/ruff-0.17.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:29b454f18ca3eeaf37fbf140841dcf27d36109e29dee9aae8acecb68c26c6f74", size = 9270361, upload-time = "2026-10-15T15:22:38.651Z" }, - { url = "https://files.pythonhosted.org/packages/c0/8c/e075e9c917cb0575297880c96768304e881240171c870cbd8d9b7b54bec2/ruff-0.17.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:872e03dd231744d6c5ec5c8af6be52b71323fc6ee096e4c8b34d9f844ee3b978", size = 9938300, upload-time = "2026-10-15T15:22:41.284Z" }, - { url = "https://files.pythonhosted.org/packages/87/27/0b4bb926a49de2383f161272b4d863571a4c7ecdbf595a6bc58813a13367/ruff-0.17.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4c0d6cb5f8fbac77eec9af49f4fc0eaf856b05545e372b0694d24b4e76b1c640", size = 9948464, upload-time = "2026-10-15T15:22:43.681Z" }, - { url = "https://files.pythonhosted.org/packages/14/27/518d57e0e02aecb8ea4acac6b57b51a53173d25fa734952bfdd7d531e88e/ruff-0.17.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78beced4a3c94dfedbee4b999b95b6a27b35820fdc0afbacc6d37a8da26c1cd1", size = 10638639, upload-time = "2026-10-15T15:22:46.081Z" }, - { url = "https://files.pythonhosted.org/packages/01/82/d89a4e498ade59e6cee52d67f8da569019372212b87468c1fdeae884601c/ruff-0.17.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7feefc63921202894f0e158b0e32f45a5950d70a7fb693ccb9264816c25b1c05", size = 11337238, upload-time = "2026-10-15T15:22:48.894Z" }, - { url = "https://files.pythonhosted.org/packages/a2/7c/3b0fdef9352656317c977086fe1d802902406e4f2a0d8578da90c0e6d1c5/ruff-0.17.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:684154861a819bf1f433669d2a0ce9c3e54677c65b8ebb866cc2a7ec86345785", size = 10896327, upload-time = "2026-10-15T15:22:51.45Z" }, - { url = "https://files.pythonhosted.org/packages/62/86/5566290219708ce2928f3a29c4f843a1185b341ec6111c4631a33c5ceced/ruff-0.17.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6ef98e51de8ef38082f90653ceeb84944bd7673783eea13e1647c5b9b064189", size = 10350811, upload-time = "2026-10-15T15:22:54.401Z" }, - { url = "https://files.pythonhosted.org/packages/ad/d2/ca8462dabb3c413990d49703ba306c30c862195a75448d080fa8fcce9775/ruff-0.17.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:8f9aa5b5bd5fa1937803ea609c8f153b8de39bf3e0e149897ae34b0c5b52f467", size = 10569994, upload-time = "2026-10-15T15:22:57.142Z" }, - { url = "https://files.pythonhosted.org/packages/e3/ad/dd1864c1f1db17c8cfb297ce70b352388e6977620f66f7182c52855407df/ruff-0.17.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:80e53b0e5faa3c5667f035daaa032893ae42b7cfe708c886d39ed131e54688ab", size = 10059672, upload-time = "2026-10-15T15:23:00.084Z" }, - { url = "https://files.pythonhosted.org/packages/65/45/3f56cf1d2168c2165cdd5ac6f9668aee66a4cfdd4caf2b2cc5ccb2b1d568/ruff-0.17.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:056c5d5fa0ac790051ae165d7035c7bf956d02de2db557f2e9b31d877a505fca", size = 9947803, upload-time = "2026-10-15T15:23:03.132Z" }, - { url = "https://files.pythonhosted.org/packages/aa/4b/5457cb2be4c1d1ea5e919558131a0f197a4a454a0530d9e30820e8206e4c/ruff-0.17.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:bd8f5a55b10f371642eacd0e8665a42a8b97da15c9a7187c79e697b77b5e0ce4", size = 10351807, upload-time = "2026-10-15T15:23:05.628Z" }, - { url = "https://files.pythonhosted.org/packages/49/fb/db15b4f8fa24a1e36b99e1401f42ef6df16ee299a48670d5d262b771e7d7/ruff-0.17.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:ce05e6f3973611d9550a600e04bb531061e368baba31610eb57c1509a8f21a0a", size = 10765716, upload-time = "2026-10-15T15:23:08.092Z" }, - { url = "https://files.pythonhosted.org/packages/0a/e8/1e186578076aff7a80735f625005fde678412ab609cd323ed182eb7d6b87/ruff-0.17.1-py3-none-win32.whl", hash = "sha256:1de0030a8aa78c65c5fecb84b2e5c390ac684c0452875a02afc7c083d49a496a", size = 9895093, upload-time = "2026-10-15T15:23:10.582Z" }, - { url = "https://files.pythonhosted.org/packages/6a/2e/da3d9589bae8e8b58f3a48bd5bc0b980232fb14a83bf02c3be7660f87c1b/ruff-0.17.1-py3-none-win_amd64.whl", hash = "sha256:5df76f16580fab58d0f6c98a3ac254a4abceb7b2489b8b678335869cff77b3d7", size = 10097955, upload-time = "2026-10-15T15:23:13.309Z" }, - { url = "https://files.pythonhosted.org/packages/df/c9/0f2a4dea06a5e12c1191ee3d96b1e1233797d0d5e18f97a2b909a95a9836/ruff-0.17.1-py3-none-win_arm64.whl", hash = "sha256:42400b9a6ff8515ff27796f07f92e0195be011c86e81e381606f58ea5502fef4", size = 9853542, upload-time = "2026-10-15T15:23:16.05Z" }, +version = "0.17.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/58/690cf56da80c8f2c8bf12042b6017beac2c34fe961f65420bcb3fb7fac38/ruff-0.17.2.tar.gz", hash = "sha256:84d632402d14c23b488f941db23712d6736a013225162062d34558c3f3c780eb", size = 5333400, upload-time = "2026-10-22T18:38:00.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/b8/8b6d69c57e8fe1cc6af1aa7493b72a1a5b5a8afe34694e302025441729d4/ruff-0.17.2-py3-none-linux_armv6l.whl", hash = "sha256:96a65e755d8dba094707fb23ab602111d9a31ec2835b3a6992a1914a8fcb211e", size = 10183857, upload-time = "2026-10-22T18:37:14.956Z" }, + { url = "https://files.pythonhosted.org/packages/e8/61/12cc2ff7b2fa90a268ce1e2162c23aaf76e601d22623f6a31b0eeaa764b0/ruff-0.17.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:39c5b55c960515cdb32be16e69efb5734c589c06a0eb38a22694524a336b889d", size = 10210904, upload-time = "2026-10-22T18:37:17.985Z" }, + { url = "https://files.pythonhosted.org/packages/e8/7c/b8850a8e4ad29f64a827dd6c6902db4a9aa538fb2b9eef4e26084001a3bb/ruff-0.17.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:92a80d2f44584dabb0189b3a6c393e37e525e04942838482010575cb81945be7", size = 9408439, upload-time = "2026-10-22T18:37:20.964Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b6/6a64fae86ca5e5e29d5adc311e5481de726cedf9aee607435a16c31c442a/ruff-0.17.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b7d0906677d89bbf3cf73cde64b5bbe2ab35df3ae43e49ee2627301e91265bac", size = 10047078, upload-time = "2026-10-22T18:37:23.717Z" }, + { url = "https://files.pythonhosted.org/packages/10/19/23d376547715175aa1bd46df48df6aacc7c6439d9daf19cac15975180aa5/ruff-0.17.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7cfd783590edf2b77317882631900f6a13ea2e8eacefe6ebf2370d2715299160", size = 9990478, upload-time = "2026-10-22T18:37:26.584Z" }, + { url = "https://files.pythonhosted.org/packages/93/6c/34cda32cbe13adac5c6b291a307a5570d8ebd37c0c86339f0fe628e55632/ruff-0.17.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122258f6ecde0e0dd1bb60b24263c3edde4338caf723fca711cc729f9bfd414a", size = 10673624, upload-time = "2026-10-22T18:37:29.767Z" }, + { url = "https://files.pythonhosted.org/packages/bf/10/5d762a4dbc851d8bb47863cf3a28a61bf31c804d393b70d891fac899f5d1/ruff-0.17.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7f8fba429d6ac4128ab29cfb8fa6240d2fa671d04adf7f84d3b22fc7b6b6bea4", size = 11351797, upload-time = "2026-10-22T18:37:32.535Z" }, + { url = "https://files.pythonhosted.org/packages/3f/04/a0e92a81faf5c81c1c98d4374c8401734ccf2b8cca7d0170cb0fec08c6e2/ruff-0.17.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:583bd67bb9e7061dd09bc46a0a884bae2bbd214dbcd48e509813612619683869", size = 10949414, upload-time = "2026-10-22T18:37:35.501Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d8/59894cf630d2d86882457a2a6a0a1533410c5c805bfbac6f6eb4b5aef69d/ruff-0.17.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c60f93fec7142dba5b8f487f94bf2f6231dae24dc11e4c48648347c8b0720004", size = 10528000, upload-time = "2026-10-22T18:37:37.88Z" }, + { url = "https://files.pythonhosted.org/packages/ab/95/d6e30c51e851d37e10762ad91fa1e76ddaad998589b5667962a3cfbc9edd/ruff-0.17.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:c555a877f8f41447e318101cfefea9bf12dfa9f82eedf90c376e57e069f917d0", size = 10571779, upload-time = "2026-10-22T18:37:40.444Z" }, + { url = "https://files.pythonhosted.org/packages/f4/5a/a7e1d74fb464250137d3d4eca645d323536533b672004d04ee0e46e0caf3/ruff-0.17.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ba405cbd5ec7bda1f555d5e52be1fd68d02608d4d95d24591bdac92cfacb579f", size = 10051707, upload-time = "2026-10-22T18:37:42.812Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f2/02fca29ca7999380f0350a7212fe755c11ab9c22d61327356cce8decdbc6/ruff-0.17.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8c21b4ab24485358f17291a4f8dc5399b834e963b8efedd920fe4fdb02081bb4", size = 9998227, upload-time = "2026-10-22T18:37:45.224Z" }, + { url = "https://files.pythonhosted.org/packages/d1/00/55aa604a093dfdc1526e570fae0bb7d37a2221c7b4728d5f080b2add16ba/ruff-0.17.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5875a16b0de70b43cb81eabea5ae5e8f5d1b2fd1b5e6ed07f80cfe5019d0bd5a", size = 10377692, upload-time = "2026-10-22T18:37:47.704Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5a/a7a640f47bb906ec8fa233ea84319d503e6dee02b59e3901bd312ebd1f9b/ruff-0.17.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:2d086be452eed686aa51271ecf2cbe5037956517cc6ee39a17b4489ac12f8bbe", size = 10790587, upload-time = "2026-10-22T18:37:50.191Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d4/c3b74e1b4808438ea7f14bb914478025e2d51261f60774a54a0fa222ec46/ruff-0.17.2-py3-none-win32.whl", hash = "sha256:6bce7d1f5165701ae18f5df85c65afe78cddfa6c5fb4b9bdb0014d60388f3edd", size = 9870537, upload-time = "2026-10-22T18:37:52.486Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9a/c7da96c2c96d20704cf9027f7a5121da272a8c05f1d1531ba949e3b3df04/ruff-0.17.2-py3-none-win_amd64.whl", hash = "sha256:1b59d447f197bef9ef48a22ba1a6016137753c6099e9bac9c36eb566e422424c", size = 10282826, upload-time = "2026-10-22T18:37:55.2Z" }, + { url = "https://files.pythonhosted.org/packages/81/60/a3604c79cb02777b6cb84da894104021986f06c4bc41375102cc95049a32/ruff-0.17.2-py3-none-win_arm64.whl", hash = "sha256:195ad918027f817c119310ca89a42d347887dfc28934aaa869325e9c5dc9d962", size = 9827036, upload-time = "2026-10-22T18:37:58.031Z" }, ] [[package]] @@ -5002,14 +5002,14 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "imageio", version = "2.38.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "lazy-loader", marker = "python_full_version < '3.11'" }, - { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "packaging", marker = "python_full_version < '3.11'" }, - { name = "pillow", marker = "python_full_version < '3.11'" }, - { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "tifffile", version = "2025.5.10", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "imageio", version = "2.38.0", source = { registry = "https://pypi.org/simple" } }, + { name = "lazy-loader" }, + { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "packaging" }, + { name = "pillow" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" } }, + { name = "tifffile", version = "2025.5.10", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/c7/a8/3c0f256012b93dd2cb6fda9245e9f4bff7dc0486880b248005f15ea2255e/scikit_image-0.25.2.tar.gz", hash = "sha256:e5a37e6cd4d0c018a7a55b9d601357e3382826d3888c10d0213fc63bff977dde", size = 22693594, upload-time = "2025-02-18T18:05:24.538Z" } wheels = [ @@ -5045,27 +5045,27 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", "python_full_version == '3.13.*' and sys_platform == 'emscripten'", - "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.11.*' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "imageio", version = "2.38.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "lazy-loader", marker = "python_full_version >= '3.11'" }, - { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "imageio", version = "2.38.1", source = { registry = "https://pypi.org/simple" } }, + { name = "lazy-loader" }, + { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "networkx", version = "3.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "packaging", marker = "python_full_version >= '3.11'" }, - { name = "pillow", marker = "python_full_version >= '3.11'" }, - { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "scipy", version = "1.18.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "tifffile", version = "2026.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "tifffile", version = "2026.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "tifffile", version = "2026.9.20", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a1/b4/2528bb43c67d48053a7a649a9666432dc307d66ba02e3a6d5c40f46655df/scikit_image-0.26.0.tar.gz", hash = "sha256:f5f970ab04efad85c24714321fcc91613fcb64ef2a892a13167df2f3e59199fa", size = 22729739, upload-time = "2025-12-20T17:12:21.824Z" } @@ -5128,7 +5128,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" } wheels = [ @@ -5189,7 +5189,7 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } wheels = [ @@ -5264,14 +5264,14 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", "python_full_version == '3.13.*' and sys_platform == 'emscripten'", - "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/7e/74/66de6258867beb2ef08f35f9f2ac017a52cacd5081714d239ff1a442d458/scipy-1.18.1.tar.gz", hash = "sha256:52c4b7422442aba924d03ad4019852b08a92e64ea187b933135687bfe2747307", size = 30781235, upload-time = "2026-08-21T23:28:50.599Z" } wheels = [ @@ -5376,7 +5376,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/4d/bc/0989043118a27cccb4e906a46b7565ce36ca7b57f5a18b78f4f1b0f72d9d/shapely-2.1.2.tar.gz", hash = "sha256:2ed4ecb28320a433db18a5bf029986aa8afcfd740745e78847e330d5d94922a9", size = 315489, upload-time = "2025-09-24T13:51:41.432Z" } wheels = [ @@ -5447,17 +5447,17 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", "python_full_version == '3.13.*' and sys_platform == 'emscripten'", - "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.11.*' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f3/ab/924b6e202f796d270a3041a230151f7908db5ea48c74effe6f8023e9bd05/shapely-2.2.0.tar.gz", hash = "sha256:e8865e553d874a1ec4a032057ea81fca9def37b188cd8fb550af3b3480b3f88c", size = 380326, upload-time = "2026-10-07T09:18:01.001Z" } @@ -5769,7 +5769,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/44/d0/18fed0fc0916578a4463f775b0fbd9c5fed2392152d039df2fb533bfdd5d/tifffile-2025.5.10.tar.gz", hash = "sha256:018335d34283aa3fd8c263bae5c3c2b661ebc45548fde31504016fcae7bf1103", size = 365290, upload-time = "2025-05-10T19:22:34.386Z" } wheels = [ @@ -5786,7 +5786,7 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/c5/cb/2f6d79c7576e22c116352a801f4c3c8ace5957e9aced862012430b62e14f/tifffile-2026.3.3.tar.gz", hash = "sha256:d9a1266bed6f2ee1dd0abde2018a38b4f8b2935cb843df381d70ac4eac5458b7", size = 388745, upload-time = "2026-03-03T19:14:38.134Z" } wheels = [ @@ -5802,14 +5802,14 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", "python_full_version == '3.13.*' and sys_platform == 'emscripten'", - "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/92/66/634db78ebad513038d753830dd8815eea26278b5463ba0f43198b0c24c4e/tifffile-2026.9.20.tar.gz", hash = "sha256:30e145a7042ce7143ae50a50fe8b7221b0070aae22adab8f9e79a264be6b5cdc", size = 449417, upload-time = "2026-09-21T03:59:40.055Z" } wheels = [ @@ -6246,9 +6246,9 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "packaging", marker = "python_full_version < '3.11'" }, - { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "packaging" }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/19/ec/e50d833518f10b0c24feb184b209bb6856f25b919ba8c1f89678b930b1cd/xarray-2025.6.1.tar.gz", hash = "sha256:a84f3f07544634a130d7dc615ae44175419f4c77957a7255161ed99c69c7c8b0", size = 3003185, upload-time = "2025-06-12T03:04:09.099Z" } wheels = [ @@ -6264,20 +6264,20 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", "python_full_version == '3.13.*' and sys_platform == 'emscripten'", - "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.11.*' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "packaging", marker = "python_full_version >= '3.11'" }, - { name = "pandas", version = "3.0.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "packaging" }, + { name = "pandas", version = "3.0.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/ff/f5/781c70b234f54e0401f4f7c4427a7486da38d50dbb138c7d7c55144b3d86/xarray-2026.9.0.tar.gz", hash = "sha256:6abc69694c22fa1f0fb2f357ff4e41d88beb4477ed71091f944b7dbf67ed54fe", size = 3176922, upload-time = "2026-09-29T23:06:25.807Z" } wheels = [ From 36fa30f142eda33c63653ebf24121818e9c68ecc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:49:17 +0100 Subject: [PATCH 16/36] Bump the docs group in /docs with 2 updates (#28) * Bump the docs group in /docs with 2 updates Bumps the docs group in /docs with 2 updates: [@astrojs/starlight](https://github.com/withastro/starlight/tree/HEAD/packages/starlight) and [astro](https://github.com/withastro/astro/tree/HEAD/packages/astro). Updates `@astrojs/starlight` from 0.42.6 to 0.42.7 - [Release notes](https://github.com/withastro/starlight/releases) - [Changelog](https://github.com/withastro/starlight/blob/main/packages/starlight/CHANGELOG.md) - [Commits](https://github.com/withastro/starlight/commits/@astrojs/starlight@0.42.7/packages/starlight) Updates `astro` from 7.4.0 to 7.4.2 - [Release notes](https://github.com/withastro/astro/releases) - [Changelog](https://github.com/withastro/astro/blob/main/packages/astro/CHANGELOG.md) - [Commits](https://github.com/withastro/astro/commits/astro@7.4.2/packages/astro) --- updated-dependencies: - dependency-name: "@astrojs/starlight" dependency-version: 0.42.7 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: docs - dependency-name: astro dependency-version: 7.4.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: docs ... Signed-off-by: dependabot[bot] * Build the docs on Node 24, which the new astro needs --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Max --- .github/workflows/docs.yml | 1 + docs/package-lock.json | 46 +++++++++++++++++++------------------- docs/package.json | 4 ++-- 3 files changed, 26 insertions(+), 25 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index b623eab..1b5922d 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -24,6 +24,7 @@ jobs: docs: uses: max-models/starlight-theme/.github/workflows/docs.yml@v0.5.0 with: + node-version: "24" # the PyVista scenes in the tutorials render under a virtual display xvfb: true smoke-paths: | diff --git a/docs/package-lock.json b/docs/package-lock.json index 622cf75..db1054b 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -8,9 +8,9 @@ "name": "simgeometry-docs", "version": "0.0.1", "dependencies": { - "@astrojs/starlight": "^0.42.6", + "@astrojs/starlight": "^0.42.7", "@max-models/starlight-theme": "github:max-models/starlight-theme#v0.5.0", - "astro": "^7.4.0", + "astro": "^7.4.2", "sharp": "^0.35.3" }, "engines": { @@ -333,9 +333,9 @@ } }, "node_modules/@astrojs/starlight": { - "version": "0.42.6", - "resolved": "https://registry.npmjs.org/@astrojs/starlight/-/starlight-0.42.6.tgz", - "integrity": "sha512-Cfs7GdgzgNRCauDxQVsFUVL1thrGHK/rQdbv/SURv/xhGBXx+j5ypOHW9ENPraQxbIzUC8KAnJV7L9mvEVRuyQ==", + "version": "0.42.7", + "resolved": "https://registry.npmjs.org/@astrojs/starlight/-/starlight-0.42.7.tgz", + "integrity": "sha512-mlbit5iRjLx5eIcLf84Q2CwkrrsEgZrQwKKBK+vRf+ZGiSrwy1UGdT1hUfAIaHrMfcvkDpL+gw3aua7HobiKFg==", "license": "MIT", "dependencies": { "@astrojs/markdown-satteri": "^0.4.0", @@ -378,9 +378,9 @@ } }, "node_modules/@astrojs/telemetry": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.4.0.tgz", - "integrity": "sha512-Qt2T6RGtYacyV8OZU3IcyFbsp69s6qIcnWvQvn7/p9MKebPCpEswP5Ji4RhifG3DfeBX7e07Pjj5smkd+Tfp7Q==", + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.4.1.tgz", + "integrity": "sha512-SMkvMsGOpM0FMAoT5pzhgI3LZC0JSPWf7Evwop+fZ3N6UkusPM7lI+sOJjMNI5OJrVoiei1/AGo1cY7sikTc5A==", "license": "MIT", "dependencies": { "ci-info": "^4.4.0", @@ -389,7 +389,7 @@ "package-manager-detector": "^1.9.0" }, "engines": { - "node": ">=22.22.0" + "node": ">=22.23.4" } }, "node_modules/@babel/helper-string-parser": { @@ -1998,15 +1998,15 @@ } }, "node_modules/astro": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/astro/-/astro-7.4.0.tgz", - "integrity": "sha512-6ThZU2bp7asGSceVcnh3r23dE88HpYN5vxtnCWYYrjr8lpQjx4eDW6/W8WgNlcc+T/b8zHmQ/EcS4DpQG4ZoOg==", + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/astro/-/astro-7.4.2.tgz", + "integrity": "sha512-FAH4cdDlFaVnQGjazLTEIC5vJqGlo5XANc1wIbzQHIqAjJTll/RWTUs+SPNW1JdezG4YGxMZllaE5A41T2kzhA==", "license": "MIT", "dependencies": { "@astrojs/compiler-rs": "^0.5.1", "@astrojs/internal-helpers": "0.13.0", - "@astrojs/markdown-satteri": "0.4.4", - "@astrojs/telemetry": "3.4.0", + "@astrojs/markdown-satteri": "0.4.5", + "@astrojs/telemetry": "3.4.1", "@capsizecss/unpack": "^4.0.1", "@clack/prompts": "^1.8.1", "@oslojs/encoding": "^1.1.0", @@ -2060,7 +2060,7 @@ "astro": "bin/astro.mjs" }, "engines": { - "node": ">=22.22.0", + "node": ">=22.23.4", "npm": ">=9.6.5", "pnpm": ">=7.1.0" }, @@ -2110,27 +2110,27 @@ } }, "node_modules/astro/node_modules/@astrojs/markdown-satteri": { - "version": "0.4.4", - "resolved": "https://registry.npmjs.org/@astrojs/markdown-satteri/-/markdown-satteri-0.4.4.tgz", - "integrity": "sha512-Byhe9CAiGFJrhMACKpgN/FPf4lcwapqwFQAB7E1sPELTEJINfBIpgnEYOBTXzr//fvSSNhfp/X3AqN4mUV9p+A==", + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@astrojs/markdown-satteri/-/markdown-satteri-0.4.5.tgz", + "integrity": "sha512-0IeGdoFbv0f+442KJhYS44Wg8F3MuHmRsxIEYuFyqkjRubc6dPBFZ7azor5t4AeXLQoT5A5h9HyViKpd70Dl9w==", "license": "MIT", "dependencies": { "@astrojs/internal-helpers": "0.13.0", - "@astrojs/prism": "4.1.0", + "@astrojs/prism": "4.1.1", "github-slugger": "^2.0.0", "satteri": "^0.10.5" } }, "node_modules/astro/node_modules/@astrojs/prism": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@astrojs/prism/-/prism-4.1.0.tgz", - "integrity": "sha512-zvRaL3MKTAXWfVQrHAlYtocRZWXBM6ruF3Bl7Lc7QgFHLRTfwMKo33KczlkvkXDY+qcytKhAWOT9dwUUYLUxAw==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@astrojs/prism/-/prism-4.1.1.tgz", + "integrity": "sha512-sXlAXJX6t4QfuLg+CuCDzpg7NoxWcyb4paFprgtFppLeisCBmh935h+tJ5CWXfsXEoW2IDy/ihbRMtiu28Chjw==", "license": "MIT", "dependencies": { "prismjs": "^1.30.0" }, "engines": { - "node": ">=22.22.0" + "node": ">=22.23.4" } }, "node_modules/astro/node_modules/es-module-lexer": { diff --git a/docs/package.json b/docs/package.json index 7b8cd16..3968f0e 100644 --- a/docs/package.json +++ b/docs/package.json @@ -16,9 +16,9 @@ "astro": "astro" }, "dependencies": { - "@astrojs/starlight": "^0.42.6", + "@astrojs/starlight": "^0.42.7", "@max-models/starlight-theme": "github:max-models/starlight-theme#v0.5.0", - "astro": "^7.4.0", + "astro": "^7.4.2", "sharp": "^0.35.3" } } From 79342a806b22ce7fc1e67c540c8908b97da14b88 Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 27 Oct 2026 14:48:31 +0100 Subject: [PATCH 17/36] Use ruff for formatting and import sorting, with the shared pre-commit config (#31) --- .github/workflows/static_analysis.yml | 3 + .pre-commit-config.yaml | 11 +- Makefile | 3 +- src/simgeometry/api.py | 18 +- src/simgeometry/collection.py | 44 ++-- src/simgeometry/crossings.py | 254 ++++++++++---------- src/simgeometry/cut_edges.py | 162 ++++++------- src/simgeometry/domain_decomposition.py | 136 +++++------ src/simgeometry/fractions.py | 148 ++++++------ src/simgeometry/objects/aperture2d.py | 20 +- src/simgeometry/objects/cuboid.py | 66 ++--- src/simgeometry/objects/cylinder.py | 8 +- src/simgeometry/objects/rectangle.py | 18 +- src/simgeometry/objects/slanted_tube.py | 68 +++--- src/simgeometry/objects/torus.py | 14 +- src/simgeometry/objects/tube.py | 18 +- src/simgeometry/operations.py | 106 ++++----- src/simgeometry/panels.py | 6 +- src/simgeometry/polygon_objects.py | 34 +-- src/simgeometry/queries.py | 226 +++++++++--------- src/simgeometry/shapes.py | 30 +-- src/simgeometry/visualization.py | 304 ++++++++++++------------ tutorials/boundary_outlines.ipynb | 34 +-- tutorials/grids_and_coordinates.ipynb | 18 +- tutorials/shapes_and_node_masks.ipynb | 16 +- 25 files changed, 887 insertions(+), 878 deletions(-) diff --git a/.github/workflows/static_analysis.yml b/.github/workflows/static_analysis.yml index 60fd5c0..878ab43 100644 --- a/.github/workflows/static_analysis.yml +++ b/.github/workflows/static_analysis.yml @@ -31,6 +31,9 @@ jobs: - name: Check formatting run: uvx ruff format --check . + - name: Check statement order + run: uvx ssort --check --diff . + pyright: runs-on: ubuntu-latest steps: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 211ae9c..2effd49 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,6 +4,7 @@ repos: rev: v6.0.0 hooks: - id: check-yaml + - id: check-toml - id: end-of-file-fixer - id: trailing-whitespace - id: check-added-large-files # Prevent giant files from being committed. @@ -11,12 +12,16 @@ repos: exclude: ^(uv\.lock|docs/package-lock\.json)$ # generated lock files - id: check-merge-conflict # Check for files that contain merge conflict strings. - repo: https://github.com/kynan/nbstripout - rev: 0.9.0 + rev: 0.9.1 hooks: - id: nbstripout # strip jupyter notebook output + - repo: https://github.com/bwhmather/ssort + rev: 0.17.0 + hooks: + - id: ssort # order statements so names are defined before they are used - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.9.1 + rev: v0.17.3 hooks: - - id: ruff + - id: ruff-check # lint, including import sorting (the I rules) args: [--fix] - id: ruff-format diff --git a/Makefile b/Makefile index e68a9f9..232cd0d 100644 --- a/Makefile +++ b/Makefile @@ -17,9 +17,10 @@ install: ## uv sync with the dev extra, and the pre-commit hooks test: ## pytest with coverage uv run pytest --cov . -lint: ## ruff and pyright +lint: ## ruff, ssort and pyright uv run ruff check . uv run ruff format --check . + uvx ssort --check --diff . uv run pyright src/ readme: ## render README.md from README.qmd with quarto diff --git a/src/simgeometry/api.py b/src/simgeometry/api.py index 37ef91b..11af2b2 100644 --- a/src/simgeometry/api.py +++ b/src/simgeometry/api.py @@ -137,15 +137,6 @@ def difference(self, other): return BooleanShape(self, other, "difference") - def __or__(self, other): - return self.union(other) - - def __and__(self, other): - return self.intersection(other) - - def __sub__(self, other): - return self.difference(other) - def intersect_segment(self, start, end, *, samples=256, tolerance=1e-10): """Return ordered surface hits, including multiple crossings and tangencies. @@ -268,3 +259,12 @@ def yee_fractions(self, grid, **kwargs): from simgeometry.fractions import yee_fractions return yee_fractions(self, grid, **kwargs) + + def __sub__(self, other): + return self.difference(other) + + def __and__(self, other): + return self.intersection(other) + + def __or__(self, other): + return self.union(other) diff --git a/src/simgeometry/collection.py b/src/simgeometry/collection.py index d3cad11..fb3c1ca 100644 --- a/src/simgeometry/collection.py +++ b/src/simgeometry/collection.py @@ -57,6 +57,25 @@ class GeometryCollection(Shape): level_set_exact = False + def _checked_voltages(self, voltages): + """``voltages`` as a dict in object order, checking names and values.""" + unknown = set(voltages) - set(self.names) + if unknown: + raise ValueError(f"Voltages name unknown objects: {sorted(unknown)}") + for name, value in voltages.items(): + if isinstance(value, bool) or not ( + callable(value) or isinstance(value, Real) + ): + raise TypeError( + f"The voltage of {name!r} must be a number or a function of t, " + f"got {type(value).__name__}" + ) + return { + name: value if callable(value) else float(value) + for name in self.names + if (value := voltages.get(name)) is not None + } + def __init__(self, objects, *, bounds=None, styles=None, voltages=None): if not isinstance(objects, Mapping): objects = {f"object_{i + 1}": obj for i, obj in enumerate(objects)} @@ -82,28 +101,6 @@ def __init__(self, objects, *, bounds=None, styles=None, voltages=None): self._inverted = any(obj.inverted for obj in self.objects) super().__init__(ndim, *bounds) - def __getitem__(self, name): - return self.named_objects[name] - - def _checked_voltages(self, voltages): - """``voltages`` as a dict in object order, checking names and values.""" - unknown = set(voltages) - set(self.names) - if unknown: - raise ValueError(f"Voltages name unknown objects: {sorted(unknown)}") - for name, value in voltages.items(): - if isinstance(value, bool) or not ( - callable(value) or isinstance(value, Real) - ): - raise TypeError( - f"The voltage of {name!r} must be a number or a function of t, " - f"got {type(value).__name__}" - ) - return { - name: value if callable(value) else float(value) - for name in self.names - if (value := voltages.get(name)) is not None - } - @property def conductors(self): """Names of the objects with a voltage, in insertion order.""" @@ -191,3 +188,6 @@ def level_set(self, points): def evaluate(self, *args, quantity="labels", **kwargs): return super().evaluate(*args, quantity=quantity, **kwargs) + + def __getitem__(self, name): + return self.named_objects[name] diff --git a/src/simgeometry/crossings.py b/src/simgeometry/crossings.py index fa947b1..8c48616 100644 --- a/src/simgeometry/crossings.py +++ b/src/simgeometry/crossings.py @@ -62,62 +62,6 @@ def __len__(self) -> int: return int(self.hit.shape[0]) -@sp.profile("simgeometry.crossings", tags=_PROFILE_TAGS) -def crossings(shape, start, end, *, samples: int = 64, iterations: int = 40): - """Return the first boundary crossing of each segment ``start[:, i] -> end[:, i]``. - - See the module docstring for the conventions and which shapes are analytic. - """ - a = _component_major(start, shape.ndim) - b = _component_major(end, shape.ndim) - if a.shape != b.shape: - raise ValueError(f"start {a.shape} and end {b.shape} must have the same shape") - if int(samples) != samples or samples < 2: - raise ValueError("samples must be an integer >= 2") - if int(iterations) != iterations or iterations < 1: - raise ValueError("iterations must be a positive integer") - t, normal, exact = _first(shape, a, b - a, int(samples), int(iterations)) - hit = xp.isfinite(t) - fraction = xp.where(hit, t, xp.nan) - point = xp.where(hit[None, :], a + xp.where(hit, t, 0.0)[None, :] * (b - a), xp.nan) - normal = xp.where(hit[None, :], normal, xp.nan) - return Crossings(hit, fraction, point, normal, exact) - - -def _first(shape, a, d, samples, iterations): - """Fraction ``(N,)`` (inf without a hit) and normal ``(ndim, N)`` of the first crossing.""" - from simgeometry.objects import ( - Aperture2D, - Circle, - Cuboid, - Line, - Polygon, - Rectangle, - SlantedTube, - Sphere, - Triangle, - ) - from simgeometry.operations import TransformedShape - - if isinstance(shape, TransformedShape): - return _transformed(shape, a, d, samples, iterations) - if isinstance(shape, (Circle, Sphere)): - return (*_ball(shape, a, d), True) - if isinstance(shape, SlantedTube): - t, normal = shape._first_crossing(a, d, START_TOLERANCE) - return t, _flip(shape, normal), True - if isinstance(shape, (Rectangle, Cuboid)) and shape.fillet > 0: - return (*_rounded_box(shape, a, d), True) - if isinstance(shape, (Rectangle, Cuboid, Line)): - return (*_box(shape, a, d), True) - polygonal = (Polygon, Triangle, Aperture2D) - if shape.ndim == 2 and ( - getattr(shape, "exact_outline", False) or isinstance(shape, polygonal) - ): - return (*_polygon(shape, a, d), True) - return (*_sampled(shape, a, d, samples, iterations), False) - - def _flip(shape, normal): return -normal if shape.inverted else normal @@ -197,6 +141,30 @@ def _rounded_box(shape, a, d): return t, _flip(shape, normal) +def _polygon_chunk(a, d, p, e, edge_length): + d0, d1 = d[0][:, None], d[1][:, None] + w0 = p[0][None, :] - a[0][:, None] + w1 = p[1][None, :] - a[1][:, None] + den = d0 * e[1][None, :] - d1 * e[0][None, :] + step = xp.sqrt(d0 * d0 + d1 * d1) + proper = xp.abs(den) > 1e-14 * step * edge_length[None, :] + safe = xp.where(proper, den, 1.0) + t = (w0 * e[1][None, :] - w1 * e[0][None, :]) / safe + u = (w0 * d1 - w1 * d0) / safe + tol = 1e-12 + valid = ( + proper & (u >= -tol) & (u <= 1 + tol) & (t > START_TOLERANCE) & (t <= 1 + tol) + ) + t = xp.where(valid, xp.minimum(t, 1.0), xp.inf) + edge = xp.argmin(t, axis=1) if t.shape[1] else xp.zeros(t.shape[0], dtype=int) + first = ( + xp.take_along_axis(t, edge[:, None], axis=1)[:, 0] + if t.shape[1] + else xp.full(t.shape[0], xp.inf) + ) + return first, edge + + def _polygon(shape, a, d): closed = getattr(shape, "closed", True) starts, ends = [], [] @@ -230,77 +198,6 @@ def _polygon(shape, a, d): return t, xp.where(facing > 0, -normal, normal) -def _polygon_chunk(a, d, p, e, edge_length): - d0, d1 = d[0][:, None], d[1][:, None] - w0 = p[0][None, :] - a[0][:, None] - w1 = p[1][None, :] - a[1][:, None] - den = d0 * e[1][None, :] - d1 * e[0][None, :] - step = xp.sqrt(d0 * d0 + d1 * d1) - proper = xp.abs(den) > 1e-14 * step * edge_length[None, :] - safe = xp.where(proper, den, 1.0) - t = (w0 * e[1][None, :] - w1 * e[0][None, :]) / safe - u = (w0 * d1 - w1 * d0) / safe - tol = 1e-12 - valid = ( - proper & (u >= -tol) & (u <= 1 + tol) & (t > START_TOLERANCE) & (t <= 1 + tol) - ) - t = xp.where(valid, xp.minimum(t, 1.0), xp.inf) - edge = xp.argmin(t, axis=1) if t.shape[1] else xp.zeros(t.shape[0], dtype=int) - first = ( - xp.take_along_axis(t, edge[:, None], axis=1)[:, 0] - if t.shape[1] - else xp.full(t.shape[0], xp.inf) - ) - return first, edge - - -def _transformed(shape, a, d, samples, iterations): - inverse = xp.asarray(shape.inverse) - offset = xp.asarray(shape.offset)[:, None] - local_a = inverse @ (a - offset) - local_d = inverse @ d - t, normal, exact = _first(shape.source, local_a, local_d, samples, iterations) - normal = inverse.T @ xp.where(xp.isfinite(normal), normal, 0.0) - length = xp.sqrt(xp.sum(normal * normal, axis=0)) - normal = normal / xp.where(length > 0, length, xp.nan) - return t, normal, exact - - -def _sampled(shape, a, d, samples, iterations): - """First membership change along each segment, sampled then bisected.""" - n = a.shape[1] - ndim = a.shape[0] - if n == 0: - return xp.full(0, xp.inf), xp.zeros((ndim, 0)) - ts = xp.linspace(0.0, 1.0, samples + 1) - state = _inside(shape, a, d, ts) # (n, samples + 1) - change = state[:, :-1] != state[:, 1:] - has = xp.any(change, axis=1) - index = xp.argmax(change, axis=1) - lo = ts[index] - hi = ts[index + 1] - start_state = xp.take_along_axis(state, index[:, None], axis=1)[:, 0] - t = xp.full(n, xp.nan) - rest = xp.flatnonzero(has) # segments without a membership change need no search - level = has_level_set(shape) - if level: - t, rest = _level_set_refined(shape, a, d, has, lo, hi, iterations) - if rest.shape[0]: - lo, hi, start_state = lo[rest], hi[rest], start_state[rest] - a_rest, d_rest = a[:, rest], d[:, rest] - for _ in range(iterations): - mid = 0.5 * (lo + hi) - same = _contains(shape, a_rest + mid[None, :] * d_rest) == start_state - lo = xp.where(same, mid, lo) - hi = xp.where(same, hi, mid) - t[rest] = 0.5 * (lo + hi) - t = xp.where(has & (t > START_TOLERANCE), t, xp.inf) - point = a + xp.where(has, t, 0.0)[None, :] * d - if level: - return t, _level_set_normal(shape, point) - return t, _estimated_normal(shape, point) - - def _level_set_refined(shape, a, d, has, lo, hi, iterations): """Crossings ``(n,)`` in the sampled brackets where the level set changes sign. @@ -394,3 +291,106 @@ def _estimated_normal(shape, points): on_boundary = xp.any(inside, axis=1) & ~xp.all(inside, axis=1) & (length > 0) normal = normal / xp.where(length > 0, length, 1.0)[:, None] return xp.where(on_boundary[None, :], normal.T, xp.nan) + + +def _sampled(shape, a, d, samples, iterations): + """First membership change along each segment, sampled then bisected.""" + n = a.shape[1] + ndim = a.shape[0] + if n == 0: + return xp.full(0, xp.inf), xp.zeros((ndim, 0)) + ts = xp.linspace(0.0, 1.0, samples + 1) + state = _inside(shape, a, d, ts) # (n, samples + 1) + change = state[:, :-1] != state[:, 1:] + has = xp.any(change, axis=1) + index = xp.argmax(change, axis=1) + lo = ts[index] + hi = ts[index + 1] + start_state = xp.take_along_axis(state, index[:, None], axis=1)[:, 0] + t = xp.full(n, xp.nan) + rest = xp.flatnonzero(has) # segments without a membership change need no search + level = has_level_set(shape) + if level: + t, rest = _level_set_refined(shape, a, d, has, lo, hi, iterations) + if rest.shape[0]: + lo, hi, start_state = lo[rest], hi[rest], start_state[rest] + a_rest, d_rest = a[:, rest], d[:, rest] + for _ in range(iterations): + mid = 0.5 * (lo + hi) + same = _contains(shape, a_rest + mid[None, :] * d_rest) == start_state + lo = xp.where(same, mid, lo) + hi = xp.where(same, hi, mid) + t[rest] = 0.5 * (lo + hi) + t = xp.where(has & (t > START_TOLERANCE), t, xp.inf) + point = a + xp.where(has, t, 0.0)[None, :] * d + if level: + return t, _level_set_normal(shape, point) + return t, _estimated_normal(shape, point) + + +def _first(shape, a, d, samples, iterations): + """Fraction ``(N,)`` (inf without a hit) and normal ``(ndim, N)`` of the first crossing.""" + from simgeometry.objects import ( + Aperture2D, + Circle, + Cuboid, + Line, + Polygon, + Rectangle, + SlantedTube, + Sphere, + Triangle, + ) + from simgeometry.operations import TransformedShape + + if isinstance(shape, TransformedShape): + return _transformed(shape, a, d, samples, iterations) + if isinstance(shape, (Circle, Sphere)): + return (*_ball(shape, a, d), True) + if isinstance(shape, SlantedTube): + t, normal = shape._first_crossing(a, d, START_TOLERANCE) + return t, _flip(shape, normal), True + if isinstance(shape, (Rectangle, Cuboid)) and shape.fillet > 0: + return (*_rounded_box(shape, a, d), True) + if isinstance(shape, (Rectangle, Cuboid, Line)): + return (*_box(shape, a, d), True) + polygonal = (Polygon, Triangle, Aperture2D) + if shape.ndim == 2 and ( + getattr(shape, "exact_outline", False) or isinstance(shape, polygonal) + ): + return (*_polygon(shape, a, d), True) + return (*_sampled(shape, a, d, samples, iterations), False) + + +@sp.profile("simgeometry.crossings", tags=_PROFILE_TAGS) +def crossings(shape, start, end, *, samples: int = 64, iterations: int = 40): + """Return the first boundary crossing of each segment ``start[:, i] -> end[:, i]``. + + See the module docstring for the conventions and which shapes are analytic. + """ + a = _component_major(start, shape.ndim) + b = _component_major(end, shape.ndim) + if a.shape != b.shape: + raise ValueError(f"start {a.shape} and end {b.shape} must have the same shape") + if int(samples) != samples or samples < 2: + raise ValueError("samples must be an integer >= 2") + if int(iterations) != iterations or iterations < 1: + raise ValueError("iterations must be a positive integer") + t, normal, exact = _first(shape, a, b - a, int(samples), int(iterations)) + hit = xp.isfinite(t) + fraction = xp.where(hit, t, xp.nan) + point = xp.where(hit[None, :], a + xp.where(hit, t, 0.0)[None, :] * (b - a), xp.nan) + normal = xp.where(hit[None, :], normal, xp.nan) + return Crossings(hit, fraction, point, normal, exact) + + +def _transformed(shape, a, d, samples, iterations): + inverse = xp.asarray(shape.inverse) + offset = xp.asarray(shape.offset)[:, None] + local_a = inverse @ (a - offset) + local_d = inverse @ d + t, normal, exact = _first(shape.source, local_a, local_d, samples, iterations) + normal = inverse.T @ xp.where(xp.isfinite(normal), normal, 0.0) + length = xp.sqrt(xp.sum(normal * normal, axis=0)) + normal = normal / xp.where(length > 0, length, xp.nan) + return t, normal, exact diff --git a/src/simgeometry/cut_edges.py b/src/simgeometry/cut_edges.py index ad5147e..981bc7a 100644 --- a/src/simgeometry/cut_edges.py +++ b/src/simgeometry/cut_edges.py @@ -13,87 +13,6 @@ from simgeometry.queries import intersection_fractions -@dataclass(frozen=True) -class BracketedEdgeGeometry: - """Single crossings from fluid nodes to solid neighbours. - - ``fractions[(axis, side)]`` is a grid-shaped array (NaN for uncut edges), - with side 0 lower and 1 upper. Fractions are *not* clamped. This fast path - assumes one crossing on every outside-to-inside bracket. Use ``cut_edges`` - to inspect multiple hits or objects smaller than a grid cell. - - ``points`` and ``normals`` are the crossings in world coordinates, (ndim, n), - in the order of ``fractions``: key by key, each as ``f[np.isfinite(f)]``. - """ - - solid: np.ndarray - owners: np.ndarray - fractions: dict - cut: np.ndarray - nodes: np.ndarray - spacing: tuple - periodic: tuple - objects: tuple - - @cached_property - def _crossings(self): - ndim = self.nodes.shape[0] - lower = self.nodes.reshape(ndim, -1)[:, 0] - periods = np.asarray(self.solid.shape) * np.asarray(self.spacing) - points, owners = [np.empty((ndim, 0))], [np.empty(0, dtype=int)] - for (axis, side), fraction in self.fractions.items(): - cut = np.isfinite(fraction) - offset = 1 if side else -1 - point = self.nodes[:, cut] - point[axis] += offset * self.spacing[axis] * fraction[cut] - points.append(point) - owners.append(np.roll(self.owners, -offset, axis=axis)[cut]) - points = _wrap(np.concatenate(points, axis=1), lower, periods, self.periodic) - return points, np.concatenate(owners) - - @property - def points(self): - """Crossing points in world coordinates, (ndim, n); periodic images wrapped.""" - return self._crossings[0] - - @property - def crossing_owners(self): - """Index into ``objects`` of the object each crossing enters, (n,).""" - return self._crossings[1] - - @cached_property - def normals(self): - """Outward unit normals of the entered object at ``points``, (ndim, n).""" - points, owners = self._crossings - normals = np.full_like(points, np.nan) - for number, obj in enumerate(self.objects): - mine = owners == number - if mine.any(): - normals[:, mine] = obj.surface_normal(points[:, mine]) - return normals - - def show_3d(self, **kwargs): - """Draw the objects, the crossings and optionally normals and solid nodes. - - Takes the keyword arguments of :meth:`CutEdges.show_3d`; several objects - are drawn as one collection. - """ - from simgeometry.collection import GeometryCollection - - if len(self.objects) == 1: - shape = self.objects[0] - else: - shape = GeometryCollection(list(self.objects)) - return _show_crossings_3d( - shape, - self.points, - self.normals if kwargs.get("normals") else None, - self.nodes[:, self.solid], - self.spacing, - **kwargs, - ) - - def _wrap(points, lower, periods, periodic): """Map points across periodic faces into the domain.""" points = points.copy() @@ -207,6 +126,87 @@ def _show_crossings_3d( return _finish(ax, display, save) +@dataclass(frozen=True) +class BracketedEdgeGeometry: + """Single crossings from fluid nodes to solid neighbours. + + ``fractions[(axis, side)]`` is a grid-shaped array (NaN for uncut edges), + with side 0 lower and 1 upper. Fractions are *not* clamped. This fast path + assumes one crossing on every outside-to-inside bracket. Use ``cut_edges`` + to inspect multiple hits or objects smaller than a grid cell. + + ``points`` and ``normals`` are the crossings in world coordinates, (ndim, n), + in the order of ``fractions``: key by key, each as ``f[np.isfinite(f)]``. + """ + + solid: np.ndarray + owners: np.ndarray + fractions: dict + cut: np.ndarray + nodes: np.ndarray + spacing: tuple + periodic: tuple + objects: tuple + + @cached_property + def _crossings(self): + ndim = self.nodes.shape[0] + lower = self.nodes.reshape(ndim, -1)[:, 0] + periods = np.asarray(self.solid.shape) * np.asarray(self.spacing) + points, owners = [np.empty((ndim, 0))], [np.empty(0, dtype=int)] + for (axis, side), fraction in self.fractions.items(): + cut = np.isfinite(fraction) + offset = 1 if side else -1 + point = self.nodes[:, cut] + point[axis] += offset * self.spacing[axis] * fraction[cut] + points.append(point) + owners.append(np.roll(self.owners, -offset, axis=axis)[cut]) + points = _wrap(np.concatenate(points, axis=1), lower, periods, self.periodic) + return points, np.concatenate(owners) + + @property + def points(self): + """Crossing points in world coordinates, (ndim, n); periodic images wrapped.""" + return self._crossings[0] + + @property + def crossing_owners(self): + """Index into ``objects`` of the object each crossing enters, (n,).""" + return self._crossings[1] + + @cached_property + def normals(self): + """Outward unit normals of the entered object at ``points``, (ndim, n).""" + points, owners = self._crossings + normals = np.full_like(points, np.nan) + for number, obj in enumerate(self.objects): + mine = owners == number + if mine.any(): + normals[:, mine] = obj.surface_normal(points[:, mine]) + return normals + + def show_3d(self, **kwargs): + """Draw the objects, the crossings and optionally normals and solid nodes. + + Takes the keyword arguments of :meth:`CutEdges.show_3d`; several objects + are drawn as one collection. + """ + from simgeometry.collection import GeometryCollection + + if len(self.objects) == 1: + shape = self.objects[0] + else: + shape = GeometryCollection(list(self.objects)) + return _show_crossings_3d( + shape, + self.points, + self.normals if kwargs.get("normals") else None, + self.nodes[:, self.solid], + self.spacing, + **kwargs, + ) + + @sp.profile("simgeometry.bracketed_edge_geometry", tags=_PROFILE_TAGS) def bracketed_edge_geometry( grid, objects, *, spacing=None, object_masks=None, periodic=None diff --git a/src/simgeometry/domain_decomposition.py b/src/simgeometry/domain_decomposition.py index ddb0605..1882c57 100644 --- a/src/simgeometry/domain_decomposition.py +++ b/src/simgeometry/domain_decomposition.py @@ -232,6 +232,62 @@ def _calculate_proc_sizes(self) -> list[int]: active_decomposition[j] = False break + def _setup( + self, + comm: MPI.Comm | None, + size: int, + rank: int, + decompose: list[bool] | None, + periodic: tuple[bool, ...] | None, + ndim: int | None, + proc_sizes: list[int] | None = None, + ) -> None: + """Build the process grid of ``size`` ranks, as seen from ``rank``.""" + self._comm = comm + self._mpi_size = size + self._mpi_rank = rank + + assert ndim is not None or decompose is not None, ( + "DomainDecomposition must take either ndim or decompose!" + ) + + if ndim is None: + ndim = len(decompose) + self._ndim = ndim + + if decompose is None: + self._decompose = [True] * self.ndim + else: + self._decompose = list(decompose) + assert len(self.decompose) == self.ndim + + if periodic is None: + periodic = [False] * self.ndim + self._periodic = tuple(periodic) + assert len(self.periodic) == self.ndim + + if proc_sizes is None: + self._proc_sizes = self._calculate_proc_sizes() + else: + self._proc_sizes = list(proc_sizes) + + # With no decomposed axis the process grid has a single slot and every + # rank holds the whole domain; all ranks then share process coordinate 0. + self._replicated = self.mpi_size > 1 and math.prod(self.proc_sizes) == 1 + + # Ranks are placed row-major on the process grid, as mpiarray does. + if self.replicated: + self._neighbour_ranks = [ + ( + (self.mpi_rank, self.mpi_rank) + if is_periodic + else (MPI.PROC_NULL, MPI.PROC_NULL) + ) + for is_periodic in self.periodic + ] + else: + self._neighbour_ranks = self._calculate_neighbor_ranks() + def __init__( self, comm: MPI.Comm | None, @@ -324,62 +380,6 @@ def preview( ) return decomposition - def _setup( - self, - comm: MPI.Comm | None, - size: int, - rank: int, - decompose: list[bool] | None, - periodic: tuple[bool, ...] | None, - ndim: int | None, - proc_sizes: list[int] | None = None, - ) -> None: - """Build the process grid of ``size`` ranks, as seen from ``rank``.""" - self._comm = comm - self._mpi_size = size - self._mpi_rank = rank - - assert ndim is not None or decompose is not None, ( - "DomainDecomposition must take either ndim or decompose!" - ) - - if ndim is None: - ndim = len(decompose) - self._ndim = ndim - - if decompose is None: - self._decompose = [True] * self.ndim - else: - self._decompose = list(decompose) - assert len(self.decompose) == self.ndim - - if periodic is None: - periodic = [False] * self.ndim - self._periodic = tuple(periodic) - assert len(self.periodic) == self.ndim - - if proc_sizes is None: - self._proc_sizes = self._calculate_proc_sizes() - else: - self._proc_sizes = list(proc_sizes) - - # With no decomposed axis the process grid has a single slot and every - # rank holds the whole domain; all ranks then share process coordinate 0. - self._replicated = self.mpi_size > 1 and math.prod(self.proc_sizes) == 1 - - # Ranks are placed row-major on the process grid, as mpiarray does. - if self.replicated: - self._neighbour_ranks = [ - ( - (self.mpi_rank, self.mpi_rank) - if is_periodic - else (MPI.PROC_NULL, MPI.PROC_NULL) - ) - for is_periodic in self.periodic - ] - else: - self._neighbour_ranks = self._calculate_neighbor_ranks() - def layout( self, shape: tuple[int, ...], @@ -516,6 +516,18 @@ def subdomain_bounds( upper.append(edges[proc_coord[dim] + 1]) return tuple(lower), tuple(upper) + def _check_grid( + self, + lower_bound: Sequence[float], + upper_bound: Sequence[float], + num_gridpoints: Sequence[int], + ) -> None: + """Raise if the grid description does not have ``ndim`` entries.""" + if len(lower_bound) != self.ndim or len(upper_bound) != self.ndim: + raise ValueError("Bounds must match decomposition dimensions") + if len(num_gridpoints) != self.ndim: + raise ValueError("num_gridpoints must match decomposition dimensions") + def subdomain_boxes( self, lower_bound: Sequence[float], @@ -610,18 +622,6 @@ def show( save=save, ) - def _check_grid( - self, - lower_bound: Sequence[float], - upper_bound: Sequence[float], - num_gridpoints: Sequence[int], - ) -> None: - """Raise if the grid description does not have ``ndim`` entries.""" - if len(lower_bound) != self.ndim or len(upper_bound) != self.ndim: - raise ValueError("Bounds must match decomposition dimensions") - if len(num_gridpoints) != self.ndim: - raise ValueError("num_gridpoints must match decomposition dimensions") - def _components(self, positions: Any, layout: Layout) -> Any: """Return ``positions`` as a float ``(ndim, n)`` array on the active backend.""" if layout not in ("component_major", "particle_major"): diff --git a/src/simgeometry/fractions.py b/src/simgeometry/fractions.py index 0769013..5b0cae0 100644 --- a/src/simgeometry/fractions.py +++ b/src/simgeometry/fractions.py @@ -234,80 +234,6 @@ def _segments_inside(geometry, start, end, *, samples, max_hits, offsets=()): return fraction, hits -@sp.profile("simgeometry.yee_fractions", tags=_PROFILE_TAGS) -def yee_fractions(geometry, grid, *, samples=64, max_hits=16): - """Edge-length and face-area fractions of a Yee grid cut by ``geometry``. - - For cut-cell (conformal) finite-difference and finite-integration schemes: the - edges and faces of the grid of nodes ``grid`` (a :class:`~simgeometry.Grid`, 2D or - 3D), with the parts of their lengths and areas inside the geometry and the trace of - the surface in each face. See :class:`YeeFractions` for the layout and accuracy. - ``samples`` is the sampling of shapes without analytic crossings and ``max_hits`` - the most crossings counted per segment. - """ - ndim = grid.ndim - if geometry.ndim != ndim: - raise ValueError("Geometry and grid dimensions must match") - if ndim not in (2, 3): - raise ValueError("Yee fractions need a 2D or 3D grid") - if int(max_hits) != max_hits or max_hits < 1: - raise ValueError("max_hits must be a positive integer") - periodic = tuple(getattr(grid, "periodic", None) or (False,) * ndim) - nodes = tuple(int(n) for n in grid.num_gridpoints) - cells = tuple(n if p else n - 1 for n, p in zip(nodes, periodic)) - if min(cells) < 1: - raise ValueError("Yee fractions need at least one cell per axis") - h = np.asarray(xp.to_numpy(grid.grid_spacing), dtype=float) - lower = np.asarray(xp.to_numpy(grid.lower_bound), dtype=float).reshape(-1) - - def corners(shape): - index = np.indices(shape).reshape(ndim, -1).astype(float) - return lower[:, None] + index * h[:, None] - - edges, hits = [], [] - for a in range(ndim): - shape = tuple(cells[b] if b == a else nodes[b] for b in range(ndim)) - start = corners(shape) - end = start.copy() - end[a] += h[a] - # a wall along the edge (in a grid plane through it) counts as inside - offsets = [(b, _NUDGE * h[b]) for b in range(ndim) if b != a] - fraction, t = _segments_inside( - geometry, start, end, samples=samples, max_hits=max_hits, offsets=offsets - ) - edges.append(fraction.reshape(shape)) - hits.append(t.reshape(*shape, t.shape[1])) - - families = [(1, 2), (2, 0), (0, 1)] if ndim == 3 else [(0, 1)] - faces, traces, projected = [], [], [] - for number, (b, c) in enumerate(families): - normal_axis = number if ndim == 3 else None - shape = tuple(nodes[d] if d == normal_axis else cells[d] for d in range(ndim)) - result = _face_integrals( - geometry, - shape, - corners(shape), - b, - c, - hits, - h, - nodes, - periodic, - samples=samples, - max_hits=max_hits, - ) - faces.append(result[0].reshape(shape)) - traces.append(result[1].reshape(shape)) - projected.append(result[2].reshape(shape)) - return YeeFractions( - tuple(edges), - tuple(faces), - tuple(traces), - tuple(projected), - tuple(float(v) for v in h), - ) - - def _lines_across(geometry, starts, stops, along, across, h, samples, max_hits): """Integrate lines along ``along`` across faces, between breakpoints ``stops``. @@ -509,3 +435,77 @@ def edge_hits(axis, across): # the quadrature's round-off next to fully inside or outside faces area = np.where(area < 1e-12, 0.0, np.where(area > 1 - 1e-12, 1.0, area)) return area, trace, projected + + +@sp.profile("simgeometry.yee_fractions", tags=_PROFILE_TAGS) +def yee_fractions(geometry, grid, *, samples=64, max_hits=16): + """Edge-length and face-area fractions of a Yee grid cut by ``geometry``. + + For cut-cell (conformal) finite-difference and finite-integration schemes: the + edges and faces of the grid of nodes ``grid`` (a :class:`~simgeometry.Grid`, 2D or + 3D), with the parts of their lengths and areas inside the geometry and the trace of + the surface in each face. See :class:`YeeFractions` for the layout and accuracy. + ``samples`` is the sampling of shapes without analytic crossings and ``max_hits`` + the most crossings counted per segment. + """ + ndim = grid.ndim + if geometry.ndim != ndim: + raise ValueError("Geometry and grid dimensions must match") + if ndim not in (2, 3): + raise ValueError("Yee fractions need a 2D or 3D grid") + if int(max_hits) != max_hits or max_hits < 1: + raise ValueError("max_hits must be a positive integer") + periodic = tuple(getattr(grid, "periodic", None) or (False,) * ndim) + nodes = tuple(int(n) for n in grid.num_gridpoints) + cells = tuple(n if p else n - 1 for n, p in zip(nodes, periodic)) + if min(cells) < 1: + raise ValueError("Yee fractions need at least one cell per axis") + h = np.asarray(xp.to_numpy(grid.grid_spacing), dtype=float) + lower = np.asarray(xp.to_numpy(grid.lower_bound), dtype=float).reshape(-1) + + def corners(shape): + index = np.indices(shape).reshape(ndim, -1).astype(float) + return lower[:, None] + index * h[:, None] + + edges, hits = [], [] + for a in range(ndim): + shape = tuple(cells[b] if b == a else nodes[b] for b in range(ndim)) + start = corners(shape) + end = start.copy() + end[a] += h[a] + # a wall along the edge (in a grid plane through it) counts as inside + offsets = [(b, _NUDGE * h[b]) for b in range(ndim) if b != a] + fraction, t = _segments_inside( + geometry, start, end, samples=samples, max_hits=max_hits, offsets=offsets + ) + edges.append(fraction.reshape(shape)) + hits.append(t.reshape(*shape, t.shape[1])) + + families = [(1, 2), (2, 0), (0, 1)] if ndim == 3 else [(0, 1)] + faces, traces, projected = [], [], [] + for number, (b, c) in enumerate(families): + normal_axis = number if ndim == 3 else None + shape = tuple(nodes[d] if d == normal_axis else cells[d] for d in range(ndim)) + result = _face_integrals( + geometry, + shape, + corners(shape), + b, + c, + hits, + h, + nodes, + periodic, + samples=samples, + max_hits=max_hits, + ) + faces.append(result[0].reshape(shape)) + traces.append(result[1].reshape(shape)) + projected.append(result[2].reshape(shape)) + return YeeFractions( + tuple(edges), + tuple(faces), + tuple(traces), + tuple(projected), + tuple(float(v) for v in h), + ) diff --git a/src/simgeometry/objects/aperture2d.py b/src/simgeometry/objects/aperture2d.py index 27c0282..fcb4427 100644 --- a/src/simgeometry/objects/aperture2d.py +++ b/src/simgeometry/objects/aperture2d.py @@ -27,6 +27,16 @@ class Aperture2D(Shape2D): exact_outline = True has_level_set = True + def _plate_vertices(self) -> tuple[ndarray, ndarray]: + """The ``(n, 2)`` vertices of the plate below and the plate above the centre line.""" + profile = np.asarray(self.vertices, dtype=float) + axial, radial = profile[:, 0], profile[:, 1] + plates = [] + for across in (self.position - radial, self.position + radial): + points = (axial, across) if self.axis == 0 else (across, axial) + plates.append(np.stack(points, axis=1)) + return plates[0], plates[1] + def __init__( self, axis: int, @@ -118,16 +128,6 @@ def axis(self, value: int) -> None: assert value in [0, 1], "Axis must be one of 0 or 1." self._axis = value - def _plate_vertices(self) -> tuple[ndarray, ndarray]: - """The ``(n, 2)`` vertices of the plate below and the plate above the centre line.""" - profile = np.asarray(self.vertices, dtype=float) - axial, radial = profile[:, 0], profile[:, 1] - plates = [] - for across in (self.position - radial, self.position + radial): - points = (axial, across) if self.axis == 0 else (across, axial) - plates.append(np.stack(points, axis=1)) - return plates[0], plates[1] - def _corner_loops(self) -> list[np.ndarray]: """One loop per plate: the lower plate, then the upper one.""" return [ diff --git a/src/simgeometry/objects/cuboid.py b/src/simgeometry/objects/cuboid.py index 33eb6c9..bd2247d 100644 --- a/src/simgeometry/objects/cuboid.py +++ b/src/simgeometry/objects/cuboid.py @@ -30,6 +30,15 @@ class Cuboid(Shape3D): has_level_set = True level_set_exact = True + def _box_bounds(self) -> tuple[tuple[float, ...], tuple[float, ...]]: + """The lower and upper corners, as host floats on any backend.""" + half_lengths = tuple(float(v) / 2 for v in self.side_lengths) + midpoint = tuple(float(v) for v in self.midpoint) + return ( + tuple(map(sub, midpoint, half_lengths)), + tuple(map(add, midpoint, half_lengths)), + ) + def __init__( self, midpoint: BoundsLike, @@ -63,15 +72,6 @@ def __init__( # Calculate bounds based on the midpoint and side lengths super().__init__(*self._box_bounds()) - def _box_bounds(self) -> tuple[tuple[float, ...], tuple[float, ...]]: - """The lower and upper corners, as host floats on any backend.""" - half_lengths = tuple(float(v) / 2 for v in self.side_lengths) - midpoint = tuple(float(v) for v in self.midpoint) - return ( - tuple(map(sub, midpoint, half_lengths)), - tuple(map(add, midpoint, half_lengths)), - ) - def mirror(self, axis: int, position: float) -> None: """ Mirror the cuboid across a specified axis at a given position. @@ -117,6 +117,21 @@ def translate(self, offset: tuple[float, float, float]) -> None: self.set_bounding_box(*self._box_bounds()) + def _slice_outline(self, slice_axis: int, slice_position: float) -> np.ndarray: + """The ``(n, 2)`` outline of a rounded cuboid's slice, a rounded rectangle.""" + from simgeometry.objects.rectangle import Rectangle + + r = self.fillet + lower = np.asarray(xp.to_numpy(xp.asarray(self.lower_bound)), float) + upper = np.asarray(xp.to_numpy(xp.asarray(self.upper_bound)), float) + centre, half = 0.5 * (lower + upper), 0.5 * (upper - lower) + depth = abs(slice_position - centre[slice_axis]) - (half[slice_axis] - r) + radius = float(np.sqrt(max(r * r - max(depth, 0.0) ** 2, 0.0))) + others = [(slice_axis + 1) % 3, (slice_axis + 2) % 3] + sides = 2 * (half[others] - r + radius) + section = Rectangle(tuple(centre[others]), tuple(sides), fillet=radius) + return section.outlines()[0].T + def get_vertices(self, slice_axis: int, slice_position: float) -> ConfigValue: """ Get the corner vertices of a slice of the cuboid along a given axis. @@ -156,21 +171,6 @@ def get_vertices(self, slice_axis: int, slice_position: float) -> ConfigValue: # TODO: Is this inverted stuff even working? return xp.array(vertices), inverted - def _slice_outline(self, slice_axis: int, slice_position: float) -> np.ndarray: - """The ``(n, 2)`` outline of a rounded cuboid's slice, a rounded rectangle.""" - from simgeometry.objects.rectangle import Rectangle - - r = self.fillet - lower = np.asarray(xp.to_numpy(xp.asarray(self.lower_bound)), float) - upper = np.asarray(xp.to_numpy(xp.asarray(self.upper_bound)), float) - centre, half = 0.5 * (lower + upper), 0.5 * (upper - lower) - depth = abs(slice_position - centre[slice_axis]) - (half[slice_axis] - r) - radius = float(np.sqrt(max(r * r - max(depth, 0.0) ** 2, 0.0))) - others = [(slice_axis + 1) % 3, (slice_axis + 2) % 3] - sides = 2 * (half[others] - r + radius) - section = Rectangle(tuple(centre[others]), tuple(sides), fillet=radius) - return section.outlines()[0].T - def contains(self, coords: Array) -> bool: """ Check if the given coordinates are inside the cuboid. @@ -190,6 +190,15 @@ def contains(self, coords: Array) -> bool: inside = bool(xp.all((coords >= lower_bound) & (coords <= upper_bound))) return (not inside) if self.inverted else inside + def _level_set(self, points: Array) -> Array: + """Exact signed distance to the cuboid, rounded or not.""" + lower = xp.asarray(self.lower_bound, dtype=float)[:, None] + upper = xp.asarray(self.upper_bound, dtype=float)[:, None] + f = self.fillet + return ( + box_distance(points - 0.5 * (lower + upper), 0.5 * (upper - lower) - f) - f + ) + def contains_points(self, points: Array) -> Array: """Vectorised :meth:`contains`: a mask of the ``(3, n)`` points inside.""" points = _component_major(points, 3) @@ -201,15 +210,6 @@ def contains_points(self, points: Array) -> Array: inside = xp.all((points >= lower) & (points <= upper), axis=0) return ~inside if self.inverted else inside - def _level_set(self, points: Array) -> Array: - """Exact signed distance to the cuboid, rounded or not.""" - lower = xp.asarray(self.lower_bound, dtype=float)[:, None] - upper = xp.asarray(self.upper_bound, dtype=float)[:, None] - f = self.fillet - return ( - box_distance(points - 0.5 * (lower + upper), 0.5 * (upper - lower) - f) - f - ) - def _inner_sides(self) -> np.ndarray: """The side lengths of the box the fillet widens by a ball.""" sides = np.asarray(xp.to_numpy(xp.asarray(self.side_lengths)), dtype=float) diff --git a/src/simgeometry/objects/cylinder.py b/src/simgeometry/objects/cylinder.py index d69cd7d..3f1bff6 100644 --- a/src/simgeometry/objects/cylinder.py +++ b/src/simgeometry/objects/cylinder.py @@ -70,15 +70,15 @@ def copy(self) -> Cylinder: fillet=self.fillet, ) + def _profile_distance(self, s: Array, rho: Array) -> Array: + f = self.fillet + return rectangle_distance(rho, s, self.radius - f, self._half_length - f) - f + def _inside(self, s: Array, rho2: Array) -> Array: if self.fillet > 0: return self._profile_distance(s, xp.sqrt(rho2)) <= 0 return (xp.abs(s) <= self._half_length) & (rho2 <= self.radius**2) - def _profile_distance(self, s: Array, rho: Array) -> Array: - f = self.fillet - return rectangle_distance(rho, s, self.radius - f, self._half_length - f) - f - def volume(self) -> float: """Calculate the volume of the cylinder, less the rounded edges.""" r, h, f = self.radius, self._half_length, self.fillet diff --git a/src/simgeometry/objects/rectangle.py b/src/simgeometry/objects/rectangle.py index 8b7e030..2ab021b 100644 --- a/src/simgeometry/objects/rectangle.py +++ b/src/simgeometry/objects/rectangle.py @@ -193,6 +193,15 @@ def contains(self, coords: Array) -> bool: return bool(xp.to_numpy(self.contains_points(point))[0]) return self._apply_inversion(self.bounding_box.contains(coords)) + def _level_set(self, points: Array) -> Array: + """Exact signed distance to the rectangle, rounded or not.""" + lower = xp.asarray(self.bounding_box.lower_bound, dtype=float)[:, None] + upper = xp.asarray(self.bounding_box.upper_bound, dtype=float)[:, None] + f = self.fillet + return ( + box_distance(points - 0.5 * (lower + upper), 0.5 * (upper - lower) - f) - f + ) + def contains_points(self, points: Array) -> Array: """Vectorised :meth:`contains`: a mask of the ``(2, n)`` points inside.""" points = _component_major(points, 2) @@ -203,12 +212,3 @@ def contains_points(self, points: Array) -> Array: upper = xp.asarray(self.bounding_box.upper_bound, dtype=float)[:, None] inside = xp.all((points >= lower) & (points < upper), axis=0) return ~inside if self.inverted else inside - - def _level_set(self, points: Array) -> Array: - """Exact signed distance to the rectangle, rounded or not.""" - lower = xp.asarray(self.bounding_box.lower_bound, dtype=float)[:, None] - upper = xp.asarray(self.bounding_box.upper_bound, dtype=float)[:, None] - f = self.fillet - return ( - box_distance(points - 0.5 * (lower + upper), 0.5 * (upper - lower) - f) - f - ) diff --git a/src/simgeometry/objects/slanted_tube.py b/src/simgeometry/objects/slanted_tube.py index fb3577d..d49a920 100644 --- a/src/simgeometry/objects/slanted_tube.py +++ b/src/simgeometry/objects/slanted_tube.py @@ -18,6 +18,15 @@ def _orthonormal(axis: np.ndarray) -> tuple[np.ndarray, np.ndarray]: return first, np.cross(axis, first) +def _plane_roots(w_s, d_s, w_n, d_n): + """Crossings with the start plane (label 0) and the cut plane (label 1).""" + moving_s = d_s != 0 + t_start = -w_s / xp.where(moving_s, d_s, 1.0) + moving_n = d_n != 0 + t_cut = -w_n / xp.where(moving_n, d_n, 1.0) + return [(t_start, moving_s, 0, "start"), (t_cut, moving_n, 1, "cut")] + + class SlantedTube(Shape3D): """A tube from ``start`` to ``end`` whose end at ``end`` is cut by a slanted plane. @@ -46,6 +55,31 @@ class SlantedTube(Shape3D): has_level_set = True level_set_exact = False + def _check_cut(self) -> None: + cosine = float(self._normal @ self._axis) + if cosine <= 0: + raise ValueError("cut_normal must point along the axis, away from start") + tangent = np.sqrt(max(1.0 - cosine**2, 0.0)) / cosine + if self.outer_radius * tangent >= self._length: + raise ValueError( + "The cut reaches the start face: need outer_radius * tan(angle) < length" + ) + + def _bounds(self) -> tuple[tuple[float, ...], tuple[float, ...]]: + """The box around the start disc and the elliptic rim of the cut.""" + radius = self.outer_radius + first, second = _orthonormal(self._axis) + disc = radius * np.sqrt(np.clip(1.0 - self._axis**2, 0.0, None)) + # rim: end + R (cos f u + sin f v) + s(f) a with s chosen to stay on the plane + along = self._normal @ self._axis + u = first - self._axis * (self._normal @ first) / along + v = second - self._axis * (self._normal @ second) / along + rim = radius * np.sqrt(u**2 + v**2) + end = np.asarray(self.end) + lower = np.minimum(self._start - disc, end - rim) + upper = np.maximum(self._start + disc, end + rim) + return tuple(float(c) for c in lower), tuple(float(c) for c in upper) + def __init__( self, start: BoundsLike, @@ -90,16 +124,6 @@ def __init__( self._inverted = bool(inverted) super().__init__(*self._bounds()) - def _check_cut(self) -> None: - cosine = float(self._normal @ self._axis) - if cosine <= 0: - raise ValueError("cut_normal must point along the axis, away from start") - tangent = np.sqrt(max(1.0 - cosine**2, 0.0)) / cosine - if self.outer_radius * tangent >= self._length: - raise ValueError( - "The cut reaches the start face: need outer_radius * tan(angle) < length" - ) - @property def start(self) -> tuple[float, float, float]: """The centre of the flat end face.""" @@ -131,21 +155,6 @@ def length(self) -> float: """The distance from ``start`` to ``end`` along the axis.""" return self._length - def _bounds(self) -> tuple[tuple[float, ...], tuple[float, ...]]: - """The box around the start disc and the elliptic rim of the cut.""" - radius = self.outer_radius - first, second = _orthonormal(self._axis) - disc = radius * np.sqrt(np.clip(1.0 - self._axis**2, 0.0, None)) - # rim: end + R (cos f u + sin f v) + s(f) a with s chosen to stay on the plane - along = self._normal @ self._axis - u = first - self._axis * (self._normal @ first) / along - v = second - self._axis * (self._normal @ second) / along - rim = radius * np.sqrt(u**2 + v**2) - end = np.asarray(self.end) - lower = np.minimum(self._start - disc, end - rim) - upper = np.maximum(self._start + disc, end + rim) - return tuple(float(c) for c in lower), tuple(float(c) for c in upper) - def _update_bounds(self) -> None: self.set_bounding_box(*self._bounds()) @@ -328,12 +337,3 @@ def _offer(t: Array, valid: Array, label: int, skip: str) -> None: out = xp.where((kind == 2)[None, :], radial, out) out = xp.where((kind == 3)[None, :], -radial, out) return best, out - - -def _plane_roots(w_s, d_s, w_n, d_n): - """Crossings with the start plane (label 0) and the cut plane (label 1).""" - moving_s = d_s != 0 - t_start = -w_s / xp.where(moving_s, d_s, 1.0) - moving_n = d_n != 0 - t_cut = -w_n / xp.where(moving_n, d_n, 1.0) - return [(t_start, moving_s, 0, "start"), (t_cut, moving_n, 1, "cut")] diff --git a/src/simgeometry/objects/torus.py b/src/simgeometry/objects/torus.py index a5980fe..add8591 100644 --- a/src/simgeometry/objects/torus.py +++ b/src/simgeometry/objects/torus.py @@ -22,6 +22,13 @@ class Torus(AxisymmetricShape): ring = Torus((0, 0, 0), 3.0, 1.0) """ + def _bounds(self) -> tuple[tuple[float, ...], tuple[float, ...]]: + """The centre circle's box, widened by the tube radius.""" + extent = self._disc_extent(self.major_radius) + self.minor_radius + lower = self._midpoint - extent + upper = self._midpoint + extent + return tuple(float(v) for v in lower), tuple(float(v) for v in upper) + def __init__( self, midpoint: BoundsLike, @@ -51,13 +58,6 @@ def __init__( self._inverted = bool(inverted) super().__init__(*self._bounds()) - def _bounds(self) -> tuple[tuple[float, ...], tuple[float, ...]]: - """The centre circle's box, widened by the tube radius.""" - extent = self._disc_extent(self.major_radius) + self.minor_radius - lower = self._midpoint - extent - upper = self._midpoint + extent - return tuple(float(v) for v in lower), tuple(float(v) for v in upper) - def copy(self) -> Torus: """Create a copy of the torus.""" return Torus( diff --git a/src/simgeometry/objects/tube.py b/src/simgeometry/objects/tube.py index 856aad2..02b7448 100644 --- a/src/simgeometry/objects/tube.py +++ b/src/simgeometry/objects/tube.py @@ -78,15 +78,6 @@ def copy(self) -> Tube: fillet=self.fillet, ) - def _inside(self, s: Array, rho2: Array) -> Array: - if self.fillet > 0: - return self._profile_distance(s, xp.sqrt(rho2)) <= 0 - return ( - (xp.abs(s) <= self._half_length) - & (rho2 >= self.inner_radius**2) - & (rho2 <= self.outer_radius**2) - ) - def _profile_distance(self, s: Array, rho: Array) -> Array: f = self.fillet middle = 0.5 * (self.inner_radius + self.outer_radius) @@ -98,6 +89,15 @@ def _profile_distance(self, s: Array, rho: Array) -> Array: - f ) + def _inside(self, s: Array, rho2: Array) -> Array: + if self.fillet > 0: + return self._profile_distance(s, xp.sqrt(rho2)) <= 0 + return ( + (xp.abs(s) <= self._half_length) + & (rho2 >= self.inner_radius**2) + & (rho2 <= self.outer_radius**2) + ) + def volume(self) -> float: """Calculate the volume of the tube, less the rounded edges.""" ri, ro, h, f = ( diff --git a/src/simgeometry/operations.py b/src/simgeometry/operations.py index dc6f6e7..3d52078 100644 --- a/src/simgeometry/operations.py +++ b/src/simgeometry/operations.py @@ -21,6 +21,39 @@ def mask(shape, points): return np.asarray(xp.to_numpy(shape.contains_points(points)), dtype=bool) +def _shapely(): + try: + import shapely + except ImportError as exc: # pragma: no cover - depends on the environment + raise ImportError( + "Outlines of Boolean shapes need shapely: pip install 'simgeometry[clip]'" + ) from exc + return shapely + + +def _polygons(geometry): + """The nonempty polygons of a shapely geometry, in a deterministic order.""" + shapely = _shapely() + polygons = [] + for part in shapely.get_parts(geometry): + if isinstance(part, shapely.Polygon): + if part.area > 0: + polygons.append(part) + elif isinstance(part, (shapely.MultiPolygon, shapely.GeometryCollection)): + polygons.extend(_polygons(part)) + return sorted(polygons, key=lambda p: (p.bounds[0], p.bounds[1], -p.area)) + + +def _shapely_ring(ring, *, counter_clockwise): + """A shapely ring as a ``(2, n)`` loop, without repeated vertices.""" + coords = np.asarray(ring.coords, dtype=float)[:-1] + keep = np.any(coords != np.roll(coords, 1, axis=0), axis=1) + loop = np.ascontiguousarray(coords[keep].T) + if (_signed_area(loop) > 0) != counter_clockwise: + loop = np.ascontiguousarray(loop[:, ::-1]) + return loop + + class BooleanShape(Shape): """A snapshot of a union, intersection, or difference of two shapes. @@ -116,16 +149,6 @@ def boundary_panels(self, max_spacing=None): return boundary_panels(self, max_spacing) -def _shapely(): - try: - import shapely - except ImportError as exc: # pragma: no cover - depends on the environment - raise ImportError( - "Outlines of Boolean shapes need shapely: pip install 'simgeometry[clip]'" - ) from exc - return shapely - - def _shapely_geometry(shape, max_spacing): """A shapely geometry of a 2D shape, built from its outline loops.""" shapely = _shapely() @@ -166,29 +189,6 @@ def _shapely_geometry(shape, max_spacing): return shapely.make_valid(geometry) -def _polygons(geometry): - """The nonempty polygons of a shapely geometry, in a deterministic order.""" - shapely = _shapely() - polygons = [] - for part in shapely.get_parts(geometry): - if isinstance(part, shapely.Polygon): - if part.area > 0: - polygons.append(part) - elif isinstance(part, (shapely.MultiPolygon, shapely.GeometryCollection)): - polygons.extend(_polygons(part)) - return sorted(polygons, key=lambda p: (p.bounds[0], p.bounds[1], -p.area)) - - -def _shapely_ring(ring, *, counter_clockwise): - """A shapely ring as a ``(2, n)`` loop, without repeated vertices.""" - coords = np.asarray(ring.coords, dtype=float)[:-1] - keep = np.any(coords != np.roll(coords, 1, axis=0), axis=1) - loop = np.ascontiguousarray(coords[keep].T) - if (_signed_area(loop) > 0) != counter_clockwise: - loop = np.ascontiguousarray(loop[:, ::-1]) - return loop - - class TransformedShape(Shape): """Independent affine image of a source, mapping p to matrix @ p + offset. @@ -351,6 +351,9 @@ class Section(Shape): an orthogonal projection. For oblique planes, x/y denote plane coordinates. """ + #: A 3D distance restricted to a plane only bounds the in-plane distance. + level_set_exact = False + def __init__(self, source, *, x=None, y=None, z=None, origin=None, normal=None): if source.ndim < 2: raise ValueError("Cannot slice one-dimensional geometry") @@ -427,9 +430,6 @@ def contains_points(self, points): def contains(self, coords): return self.source.contains(self._to_parent(np.atleast_1d(coords))) - #: A 3D distance restricted to a plane only bounds the in-plane distance. - level_set_exact = False - @property def has_level_set(self): """Whether the sliced source has a level set.""" @@ -456,23 +456,6 @@ def evaluate(self, *args, quantity=None, **kwargs): def names(self): return getattr(self.source, "names", ("inside",)) - def outlines(self, max_spacing=None, *, resolution=128, bounds=None, world=False): - """The sampled boundary loops of a plane section, as ``(2, n)`` host arrays. - - Loops follow ``Shape2D.outlines``: no repeated first vertex, and the region on - their left, so outer boundaries are counter-clockwise and holes clockwise (a - hollow sphere gives one of each). They come from contouring the section on - ``resolution`` samples per axis across ``bounds`` (default: the section's - bounds, padded by 5%), on the level set where the source has one, so vertices - lie on the boundary up to the sampling accuracy. A loop cut by ``bounds`` stays - open. ``max_spacing`` (a length, or a function of ``(2, m)`` plane points) - splits longer edges evenly, as for Boolean outlines. ``world=True`` maps the - loops onto the plane in 3D, as ``(3, n)`` arrays. Requires the ``mesh`` extra; - sections of 2D shapes (lines) have no outlines. - """ - loops = self._sampled_loops(max_spacing, resolution, bounds) - return [self.to_world(loop) if world else loop for loop, _ in loops] - def _sampled_loops(self, max_spacing, resolution, bounds): """``(loop, closed)`` pairs of :meth:`outlines`, in plane coordinates.""" from simgeometry.mesh import _find_contours @@ -499,6 +482,23 @@ def _sampled_loops(self, max_spacing, resolution, bounds): loops.append((loop, closed)) return loops + def outlines(self, max_spacing=None, *, resolution=128, bounds=None, world=False): + """The sampled boundary loops of a plane section, as ``(2, n)`` host arrays. + + Loops follow ``Shape2D.outlines``: no repeated first vertex, and the region on + their left, so outer boundaries are counter-clockwise and holes clockwise (a + hollow sphere gives one of each). They come from contouring the section on + ``resolution`` samples per axis across ``bounds`` (default: the section's + bounds, padded by 5%), on the level set where the source has one, so vertices + lie on the boundary up to the sampling accuracy. A loop cut by ``bounds`` stays + open. ``max_spacing`` (a length, or a function of ``(2, m)`` plane points) + splits longer edges evenly, as for Boolean outlines. ``world=True`` maps the + loops onto the plane in 3D, as ``(3, n)`` arrays. Requires the ``mesh`` extra; + sections of 2D shapes (lines) have no outlines. + """ + loops = self._sampled_loops(max_spacing, resolution, bounds) + return [self.to_world(loop) if world else loop for loop, _ in loops] + def show_3d(self, **kwargs): """The section in 3D: its source, the cutting plane and the section outline. diff --git a/src/simgeometry/panels.py b/src/simgeometry/panels.py index d2d6c77..8ca5c6e 100644 --- a/src/simgeometry/panels.py +++ b/src/simgeometry/panels.py @@ -27,9 +27,6 @@ class Panels: piece: np.ndarray closed: bool = True - def __len__(self) -> int: - return int(self.start.shape[1]) - @property def midpoint(self) -> np.ndarray: """Panel midpoints, ``(2, n)``.""" @@ -51,6 +48,9 @@ def normal(self) -> np.ndarray: tx, ty = self.tangent return np.stack((ty, -tx)) + def __len__(self) -> int: + return int(self.start.shape[1]) + def boundary_panels(shape, max_spacing=None) -> Panels: """The edges of ``shape.outlines(max_spacing)`` as :class:`Panels`. diff --git a/src/simgeometry/polygon_objects.py b/src/simgeometry/polygon_objects.py index f467686..1cf8cc4 100644 --- a/src/simgeometry/polygon_objects.py +++ b/src/simgeometry/polygon_objects.py @@ -36,6 +36,19 @@ Array = Any +def _c_ordered(points: Array) -> Array: + """``points`` as a float ``(2, N)`` array with C strides, copied only if needed. + + A ``(2, 1)`` column picked by fancy indexing keeps Fortran strides that NumPy + still flags as C-contiguous; Pyccel checks the strides. + """ + points = xp.ascontiguousarray(xp.asarray(points, dtype=float)) + itemsize = points.dtype.itemsize + if tuple(points.strides) != (points.shape[1] * itemsize, itemsize): + points = points.copy(order="C") + return points + + @dataclass(frozen=True) class PolygonObjects: """Polygon objects ``objects[index[k]]``, slot ``k``, as kernel arrays. @@ -130,17 +143,10 @@ def absorb( return hit -def _c_ordered(points: Array) -> Array: - """``points`` as a float ``(2, N)`` array with C strides, copied only if needed. - - A ``(2, 1)`` column picked by fancy indexing keeps Fortran strides that NumPy - still flags as C-contiguous; Pyccel checks the strides. - """ - points = xp.ascontiguousarray(xp.asarray(points, dtype=float)) - itemsize = points.dtype.itemsize - if tuple(points.strides) != (points.shape[1] * itemsize, itemsize): - points = points.copy(order="C") - return points +def _host_vertices(vertices: Array) -> np.ndarray: + if xp.is_gpu(vertices): + vertices = xp.to_numpy(vertices) + return np.asarray(vertices, dtype=float) def object_polygons(obj: object) -> list[np.ndarray] | None: @@ -153,12 +159,6 @@ def object_polygons(obj: object) -> list[np.ndarray] | None: return None -def _host_vertices(vertices: Array) -> np.ndarray: - if xp.is_gpu(vertices): - vertices = xp.to_numpy(vertices) - return np.asarray(vertices, dtype=float) - - #: The packed objects of recent calls; the key holds the objects themselves #: (alive while cached, so their ids are not reused). _CACHE: OrderedDict[tuple, tuple[tuple, PolygonObjects | None]] = OrderedDict() diff --git a/src/simgeometry/queries.py b/src/simgeometry/queries.py index b79927f..60dc604 100644 --- a/src/simgeometry/queries.py +++ b/src/simgeometry/queries.py @@ -275,6 +275,119 @@ def cross(u, v): return hits, False +def _closest_segments(p, segments): + a, b = segments[:, 0], segments[:, 1] + d = b - a + denominator = np.sum(d * d, axis=1) + t = np.sum((p - a) * d, axis=1) / np.where(denominator > 0, denominator, 1) + return a + np.clip(t, 0, 1)[:, None] * d + + +def estimate_surface_normal(obj, point, scale, *, require_boundary=True): + """Solid-outward unit normal estimated by directional membership samples. + + Set require_boundary=False to retain the original Picnic estimator at + periodic image points, including its cancellation behavior away from a surface. + """ + point = np.asarray(point, float) + ndim = point.size + if ndim == 1: + directions = np.asarray([[-1.0], [1.0]]) + elif ndim == 2: + theta = np.linspace(0, 2 * np.pi, 64, endpoint=False) + directions = np.column_stack((np.cos(theta), np.sin(theta))) + else: + indices = np.arange(128, dtype=float) + 0.5 + z = 1 - 2 * indices / 128 + theta = np.pi * (1 + np.sqrt(5)) * indices + radial = np.sqrt(1 - z * z) + directions = np.column_stack( + (radial * np.cos(theta), radial * np.sin(theta), z) + ) + epsilon = max(scale * 1e-5, np.finfo(float).eps * 128) + hits = mask(obj, point[:, None] + epsilon * directions.T) + normal = np.sum(np.where(hits[:, None], -directions, directions), axis=0) + magnitude = np.linalg.norm(normal) + if magnitude == 0 or (require_boundary and (hits.all() or not hits.any())): + return np.full(ndim, np.nan) + return normal / magnitude + + +def surface_normal(shape, points, *, scale=None): + p, single = points_array(points, shape.ndim) + if isinstance(shape, TransformedShape): + n = shape.inverse.T @ surface_normal( + shape.source, shape.inverse @ (p - shape.offset[:, None]), scale=scale + ) + elif isinstance(shape, BALLS): + n = p - np.asarray(shape.midpoint)[:, None] + if shape.inverted: + n = -n + elif _is_rounded(shape): + n = rounded_box_normal(p, *_centre_half(shape), shape.fillet) + if shape.inverted: + n = -n + elif _is_box(shape): + distances = np.concatenate( + ( + abs(p - np.asarray(shape.lower_bound)[:, None]), + abs(p - np.asarray(shape.upper_bound)[:, None]), + ), + axis=0, + ) + face = np.argmin(distances, axis=0) + n = np.zeros_like(p) + n[face % shape.ndim, np.arange(p.shape[1])] = np.where(face < shape.ndim, -1, 1) + if shape.inverted: + n = -n + elif isinstance(shape, POLYGONS): + vertices = _polygon_vertices(shape) + segments = np.stack((vertices, np.roll(vertices, -1, axis=0)), axis=1) + edges = segments[:, 1] - segments[:, 0] + signed_area = np.sum( + vertices[:, 0] * np.roll(vertices[:, 1], -1) + - vertices[:, 1] * np.roll(vertices[:, 0], -1) + ) + if signed_area == 0: + n = np.full_like(p, np.nan) + else: + outward = np.column_stack((edges[:, 1], -edges[:, 0])) * np.sign( + signed_area + ) + n = np.empty_like(p) + for i, point in enumerate(p.T): + closest = _closest_segments(point, segments) + edge = np.argmin(np.sum((closest - point) ** 2, axis=1)) + n[:, i] = outward[edge] + if shape.inverted: + n = -n + elif has_level_set(shape): + # the gradient of the level set points out of the region where it is negative + extent = float(np.max(np.asarray(shape.upper_bound) - shape.lower_bound)) + scale = extent if scale is None else float(scale) + if not np.isfinite(scale) or scale <= 0: + raise ValueError("Normal sampling scale must be positive and finite") + n = level_set_gradient(shape, p, 1e-7 * scale) + else: + scale = ( + float(np.max(np.asarray(shape.upper_bound) - shape.lower_bound)) + if scale is None + else float(scale) + ) + if not np.isfinite(scale) or scale <= 0: + raise ValueError("Normal sampling scale must be positive and finite") + n = ( + np.column_stack( + [estimate_surface_normal(shape, point, scale) for point in p.T] + ) + if p.shape[1] + else np.empty_like(p) + ) + length = np.linalg.norm(n, axis=0) + n = n / np.where(length > 0, length, np.nan) + return n[:, 0] if single else n + + def intersect_segment(shape, start, end, *, samples=256, tolerance=1e-10): a, b = np.asarray(start, float), np.asarray(end, float) if ( @@ -332,14 +445,6 @@ def intersect_segment(shape, start, end, *, samples=256, tolerance=1e-10): ) -def _closest_segments(p, segments): - a, b = segments[:, 0], segments[:, 1] - d = b - a - denominator = np.sum(d * d, axis=1) - t = np.sum((p - a) * d, axis=1) / np.where(denominator > 0, denominator, 1) - return a + np.clip(t, 0, 1)[:, None] * d - - def closest_on_mesh(points, vertices, cells): if not len(cells): raise ValueError("No surface resolved; enlarge bounds or increase resolution") @@ -453,108 +558,3 @@ def signed_distance(shape, points, *, resolution=64, bounds=None): distance = np.linalg.norm(p - q, axis=0) distance = np.where(mask(shape, p), -distance, distance) return float(distance[0]) if single else distance - - -def estimate_surface_normal(obj, point, scale, *, require_boundary=True): - """Solid-outward unit normal estimated by directional membership samples. - - Set require_boundary=False to retain the original Picnic estimator at - periodic image points, including its cancellation behavior away from a surface. - """ - point = np.asarray(point, float) - ndim = point.size - if ndim == 1: - directions = np.asarray([[-1.0], [1.0]]) - elif ndim == 2: - theta = np.linspace(0, 2 * np.pi, 64, endpoint=False) - directions = np.column_stack((np.cos(theta), np.sin(theta))) - else: - indices = np.arange(128, dtype=float) + 0.5 - z = 1 - 2 * indices / 128 - theta = np.pi * (1 + np.sqrt(5)) * indices - radial = np.sqrt(1 - z * z) - directions = np.column_stack( - (radial * np.cos(theta), radial * np.sin(theta), z) - ) - epsilon = max(scale * 1e-5, np.finfo(float).eps * 128) - hits = mask(obj, point[:, None] + epsilon * directions.T) - normal = np.sum(np.where(hits[:, None], -directions, directions), axis=0) - magnitude = np.linalg.norm(normal) - if magnitude == 0 or (require_boundary and (hits.all() or not hits.any())): - return np.full(ndim, np.nan) - return normal / magnitude - - -def surface_normal(shape, points, *, scale=None): - p, single = points_array(points, shape.ndim) - if isinstance(shape, TransformedShape): - n = shape.inverse.T @ surface_normal( - shape.source, shape.inverse @ (p - shape.offset[:, None]), scale=scale - ) - elif isinstance(shape, BALLS): - n = p - np.asarray(shape.midpoint)[:, None] - if shape.inverted: - n = -n - elif _is_rounded(shape): - n = rounded_box_normal(p, *_centre_half(shape), shape.fillet) - if shape.inverted: - n = -n - elif _is_box(shape): - distances = np.concatenate( - ( - abs(p - np.asarray(shape.lower_bound)[:, None]), - abs(p - np.asarray(shape.upper_bound)[:, None]), - ), - axis=0, - ) - face = np.argmin(distances, axis=0) - n = np.zeros_like(p) - n[face % shape.ndim, np.arange(p.shape[1])] = np.where(face < shape.ndim, -1, 1) - if shape.inverted: - n = -n - elif isinstance(shape, POLYGONS): - vertices = _polygon_vertices(shape) - segments = np.stack((vertices, np.roll(vertices, -1, axis=0)), axis=1) - edges = segments[:, 1] - segments[:, 0] - signed_area = np.sum( - vertices[:, 0] * np.roll(vertices[:, 1], -1) - - vertices[:, 1] * np.roll(vertices[:, 0], -1) - ) - if signed_area == 0: - n = np.full_like(p, np.nan) - else: - outward = np.column_stack((edges[:, 1], -edges[:, 0])) * np.sign( - signed_area - ) - n = np.empty_like(p) - for i, point in enumerate(p.T): - closest = _closest_segments(point, segments) - edge = np.argmin(np.sum((closest - point) ** 2, axis=1)) - n[:, i] = outward[edge] - if shape.inverted: - n = -n - elif has_level_set(shape): - # the gradient of the level set points out of the region where it is negative - extent = float(np.max(np.asarray(shape.upper_bound) - shape.lower_bound)) - scale = extent if scale is None else float(scale) - if not np.isfinite(scale) or scale <= 0: - raise ValueError("Normal sampling scale must be positive and finite") - n = level_set_gradient(shape, p, 1e-7 * scale) - else: - scale = ( - float(np.max(np.asarray(shape.upper_bound) - shape.lower_bound)) - if scale is None - else float(scale) - ) - if not np.isfinite(scale) or scale <= 0: - raise ValueError("Normal sampling scale must be positive and finite") - n = ( - np.column_stack( - [estimate_surface_normal(shape, point, scale) for point in p.T] - ) - if p.shape[1] - else np.empty_like(p) - ) - length = np.linalg.norm(n, axis=0) - n = n / np.where(length > 0, length, np.nan) - return n[:, 0] if single else n diff --git a/src/simgeometry/shapes.py b/src/simgeometry/shapes.py index 898157c..958cc98 100644 --- a/src/simgeometry/shapes.py +++ b/src/simgeometry/shapes.py @@ -170,6 +170,10 @@ def contains_points(self, points: Array) -> Array: points = _component_major(points, self.ndim) return xp.asarray([bool(self.contains(p)) for p in points.T], dtype=bool) + def _level_set(self, points: Array) -> Array: + """The level set of the geometry itself, before inversion.""" + raise NotImplementedError(f"{type(self).__name__} has no level set") + def level_set(self, points: Array) -> Array: """Level-set values of the ``(ndim, n)`` points: negative inside, positive outside. @@ -185,10 +189,6 @@ def level_set(self, points: Array) -> Array: values = self._level_set(_component_major(points, self.ndim)) return -values if self.inverted else values - def _level_set(self, points: Array) -> Array: - """The level set of the geometry itself, before inversion.""" - raise NotImplementedError(f"{type(self).__name__} has no level set") - @sp.profile("simgeometry.node_mask", tags=_PROFILE_TAGS) def node_mask(self, grid: Grid) -> np.ndarray: """Return a host boolean array of the grid's shape, set at the nodes inside. @@ -338,6 +338,17 @@ def contains(self, coords: tuple[float, float]) -> bool: bool: True if the coordinates are inside the shape, False otherwise. """ + def _corner_loops(self) -> list[np.ndarray]: + """The unrefined ``(2, n)`` boundary loops; polygonal shapes define this.""" + raise NotImplementedError(f"{type(self).__name__} has no outline") + + def _outline_loops(self, max_spacing: Spacing | None) -> list[np.ndarray]: + """The loops of :meth:`_corner_loops`, refined; curved shapes override this.""" + loops = self._corner_loops() + if max_spacing is None: + return loops + return [_refine_loop(loop, max_spacing, closed=self.closed) for loop in loops] + def outlines(self, max_spacing: Spacing | None = None) -> list[np.ndarray]: """Return the boundary loops of the shape as ``(2, n)`` host arrays. @@ -373,10 +384,6 @@ def boundary_panels(self, max_spacing: Spacing | None = None) -> Panels: return boundary_panels(self, max_spacing) - def _corner_loops(self) -> list[np.ndarray]: - """The unrefined ``(2, n)`` boundary loops; polygonal shapes define this.""" - raise NotImplementedError(f"{type(self).__name__} has no outline") - def _edge_level_set(self, points: Array) -> Array: """Distance to the edges of :meth:`_corner_loops`, negative where the geometry is. @@ -394,13 +401,6 @@ def _edge_level_set(self, points: Array) -> Array: inside = ~inside return xp.where(inside, -distance, distance) - def _outline_loops(self, max_spacing: Spacing | None) -> list[np.ndarray]: - """The loops of :meth:`_corner_loops`, refined; curved shapes override this.""" - loops = self._corner_loops() - if max_spacing is None: - return loops - return [_refine_loop(loop, max_spacing, closed=self.closed) for loop in loops] - def plot(self, ax: Any = None, **kwargs: Any) -> Any: """Draw the shape as filled polygons into ``ax`` and return the axes. diff --git a/src/simgeometry/visualization.py b/src/simgeometry/visualization.py index 0f622f1..0918d68 100644 --- a/src/simgeometry/visualization.py +++ b/src/simgeometry/visualization.py @@ -23,147 +23,6 @@ def _finish(ax, display, save): return ax -def show( - shape, - *, - x=None, - y=None, - z=None, - ax=None, - resolution=None, - bounds=None, - grid=None, - nodes=False, - labels=True, - normals=False, - show_bounds=False, - color=None, - color_by="name", - opacity=0.65, - display=True, - save=None, - **kwargs, -): - """Plot occupancy or a slice; dimensions and coordinates are labelled.""" - import matplotlib.pyplot as plt - from matplotlib.colors import ListedColormap - from matplotlib.patches import Patch - - selectors = {k: v for k, v in zip("xyz", (x, y, z)) if v is not None} - if resolution is None: - resolution = 48 if shape.ndim == 3 and not selectors else 128 - if shape.ndim == 3 and not selectors: - return show_3d( - shape, - ax=ax, - resolution=resolution, - bounds=bounds, - grid=grid, - nodes=nodes, - labels=labels, - normals=normals, - show_bounds=show_bounds, - color=color, - color_by=color_by, - opacity=opacity, - display=display, - save=save, - **kwargs, - ) - if color_by not in (None, "name"): - raise ValueError("color_by must be 'name' or None") - if grid is True: - raise ValueError("Pass a Grid object, or use domain.show(grid=True)") - if grid is not None and grid.ndim != shape.ndim: - raise ValueError("Grid and geometry dimensions must match") - grid_axes = list(grid.get_grid_points()) if grid is not None else None - if selectors: - if len(selectors) != 1: - raise ValueError("Select one coordinate for a geometry slice") - source_names = getattr(shape, "axis_names", tuple("xyz"[: shape.ndim])) - name = next(iter(selectors)) - if name not in source_names: - raise ValueError(f"Available slice axes are {source_names}") - axis = source_names.index(name) - shape = shape.slice(**selectors) - if grid_axes is not None: - grid_axes.pop(axis) - if bounds is not None: - bounds = tuple(np.delete(v, axis) for v in bounds) - names = getattr(shape, "axis_names", tuple("xyz"[: shape.ndim])) - coordinates = dict(zip(names, grid_axes)) if grid_axes is not None else {} - quantity = "labels" if hasattr(shape, "labels") else "occupancy" - values = shape.evaluate( - **coordinates, resolution=resolution, bounds=bounds, quantity=quantity - ) - ax = ax or plt.subplots()[1] - if shape.ndim == 1: - ax.step( - values.coords[names[0]], values.values, where="mid", color=color, **kwargs - ) - ax.set_ylabel(quantity) - else: - xs, ys = (values.coords[n].values for n in names) - object_names = getattr(shape, "names", ("geometry",)) - styles = getattr( - shape, "styles", getattr(getattr(shape, "source", None), "styles", {}) - ) - colors = [ - styles.get(name, {}).get( - "color", - styles.get(name, {}).get( - "facecolor", color or (f"C{i % 10}" if color_by == "name" else "C0") - ), - ) - for i, name in enumerate(object_names) - ] - cmap = ListedColormap(colors or [color or "C0"]) - data = np.ma.masked_where(values.values == 0, values.values.astype(float) - 1) - style = { - "cmap": cmap, - "vmin": -0.5, - "vmax": max(len(colors) - 0.5, 0.5), - "alpha": opacity, - **kwargs, - } - ax.pcolormesh(xs, ys, data.T, shading="nearest", **style) - if labels and len(object_names): - ax.legend( - handles=[Patch(color=c, label=n) for n, c in zip(object_names, colors)] - ) - if grid_axes is not None: - for value in xs: - ax.axvline(value, color="gray", linewidth=0.4, alpha=0.5) - for value in ys: - ax.axhline(value, color="gray", linewidth=0.4, alpha=0.5) - if nodes: - xx, yy = np.meshgrid(xs, ys, indexing="ij") - ax.scatter( - xx.ravel(), - yy.ravel(), - c=(values.values != 0).ravel(), - cmap="coolwarm", - s=5, - ) - if normals: - mesh = shape.to_mesh(resolution=resolution, bounds=bounds) - stride = max(1, len(mesh.cells) // 48) - ax.quiver(*mesh.centres[::stride].T, *mesh.normals[::stride].T, angles="xy") - if show_bounds: - lower, upper = bounds or (shape.lower_bound, shape.upper_bound) - ax.plot( - [lower[0], upper[0], upper[0], lower[0], lower[0]], - [lower[1], lower[1], upper[1], upper[1], lower[1]], - color="black", - ) - ax.set_aspect("equal") - ax.set_ylabel(names[1]) - ax.set_xlabel(names[0]) - if selectors: - ax.set_title(", ".join(f"{k} = {v:g}" for k, v in selectors.items())) - return _finish(ax, display, save) - - def _clip_triangles(vertices, faces, origin, normal): """Clip each triangle against a half-space, without adding artificial caps.""" origin, normal = np.asarray(origin, float), np.asarray(normal, float) @@ -262,6 +121,19 @@ def _point_sets(points, first_color): return sets, named +def _grid_lines_3d(grid): + axes = grid.get_grid_points() + segments = [] + for axis in range(3): + other = [i for i in range(3) if i != axis] + for values in product(*(axes[i] for i in other)): + a, b = np.zeros(3), np.zeros(3) + a[other], b[other] = values, values + a[axis], b[axis] = grid.lower_bound[axis], grid.upper_bound[axis] + segments.append([a, b]) + return np.asarray(segments) + + def show_3d( shape, *, @@ -527,17 +399,145 @@ def show_3d( return _finish(ax, display, save) -def _grid_lines_3d(grid): - axes = grid.get_grid_points() - segments = [] - for axis in range(3): - other = [i for i in range(3) if i != axis] - for values in product(*(axes[i] for i in other)): - a, b = np.zeros(3), np.zeros(3) - a[other], b[other] = values, values - a[axis], b[axis] = grid.lower_bound[axis], grid.upper_bound[axis] - segments.append([a, b]) - return np.asarray(segments) +def show( + shape, + *, + x=None, + y=None, + z=None, + ax=None, + resolution=None, + bounds=None, + grid=None, + nodes=False, + labels=True, + normals=False, + show_bounds=False, + color=None, + color_by="name", + opacity=0.65, + display=True, + save=None, + **kwargs, +): + """Plot occupancy or a slice; dimensions and coordinates are labelled.""" + import matplotlib.pyplot as plt + from matplotlib.colors import ListedColormap + from matplotlib.patches import Patch + + selectors = {k: v for k, v in zip("xyz", (x, y, z)) if v is not None} + if resolution is None: + resolution = 48 if shape.ndim == 3 and not selectors else 128 + if shape.ndim == 3 and not selectors: + return show_3d( + shape, + ax=ax, + resolution=resolution, + bounds=bounds, + grid=grid, + nodes=nodes, + labels=labels, + normals=normals, + show_bounds=show_bounds, + color=color, + color_by=color_by, + opacity=opacity, + display=display, + save=save, + **kwargs, + ) + if color_by not in (None, "name"): + raise ValueError("color_by must be 'name' or None") + if grid is True: + raise ValueError("Pass a Grid object, or use domain.show(grid=True)") + if grid is not None and grid.ndim != shape.ndim: + raise ValueError("Grid and geometry dimensions must match") + grid_axes = list(grid.get_grid_points()) if grid is not None else None + if selectors: + if len(selectors) != 1: + raise ValueError("Select one coordinate for a geometry slice") + source_names = getattr(shape, "axis_names", tuple("xyz"[: shape.ndim])) + name = next(iter(selectors)) + if name not in source_names: + raise ValueError(f"Available slice axes are {source_names}") + axis = source_names.index(name) + shape = shape.slice(**selectors) + if grid_axes is not None: + grid_axes.pop(axis) + if bounds is not None: + bounds = tuple(np.delete(v, axis) for v in bounds) + names = getattr(shape, "axis_names", tuple("xyz"[: shape.ndim])) + coordinates = dict(zip(names, grid_axes)) if grid_axes is not None else {} + quantity = "labels" if hasattr(shape, "labels") else "occupancy" + values = shape.evaluate( + **coordinates, resolution=resolution, bounds=bounds, quantity=quantity + ) + ax = ax or plt.subplots()[1] + if shape.ndim == 1: + ax.step( + values.coords[names[0]], values.values, where="mid", color=color, **kwargs + ) + ax.set_ylabel(quantity) + else: + xs, ys = (values.coords[n].values for n in names) + object_names = getattr(shape, "names", ("geometry",)) + styles = getattr( + shape, "styles", getattr(getattr(shape, "source", None), "styles", {}) + ) + colors = [ + styles.get(name, {}).get( + "color", + styles.get(name, {}).get( + "facecolor", color or (f"C{i % 10}" if color_by == "name" else "C0") + ), + ) + for i, name in enumerate(object_names) + ] + cmap = ListedColormap(colors or [color or "C0"]) + data = np.ma.masked_where(values.values == 0, values.values.astype(float) - 1) + style = { + "cmap": cmap, + "vmin": -0.5, + "vmax": max(len(colors) - 0.5, 0.5), + "alpha": opacity, + **kwargs, + } + ax.pcolormesh(xs, ys, data.T, shading="nearest", **style) + if labels and len(object_names): + ax.legend( + handles=[Patch(color=c, label=n) for n, c in zip(object_names, colors)] + ) + if grid_axes is not None: + for value in xs: + ax.axvline(value, color="gray", linewidth=0.4, alpha=0.5) + for value in ys: + ax.axhline(value, color="gray", linewidth=0.4, alpha=0.5) + if nodes: + xx, yy = np.meshgrid(xs, ys, indexing="ij") + ax.scatter( + xx.ravel(), + yy.ravel(), + c=(values.values != 0).ravel(), + cmap="coolwarm", + s=5, + ) + if normals: + mesh = shape.to_mesh(resolution=resolution, bounds=bounds) + stride = max(1, len(mesh.cells) // 48) + ax.quiver(*mesh.centres[::stride].T, *mesh.normals[::stride].T, angles="xy") + if show_bounds: + lower, upper = bounds or (shape.lower_bound, shape.upper_bound) + ax.plot( + [lower[0], upper[0], upper[0], lower[0], lower[0]], + [lower[1], lower[1], upper[1], upper[1], lower[1]], + color="black", + ) + ax.set_aspect("equal") + ax.set_ylabel(names[1]) + ax.set_xlabel(names[0]) + if selectors: + ax.set_title(", ".join(f"{k} = {v:g}" for k, v in selectors.items())) + return _finish(ax, display, save) def show_section_3d( diff --git a/tutorials/boundary_outlines.ipynb b/tutorials/boundary_outlines.ipynb index 19db622..98d954a 100644 --- a/tutorials/boundary_outlines.ipynb +++ b/tutorials/boundary_outlines.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "markdown", - "id": "outlines-0", + "id": "0", "metadata": {}, "source": [ "# Boundary outlines\n", @@ -15,7 +15,7 @@ { "cell_type": "code", "execution_count": null, - "id": "outlines-1", + "id": "1", "metadata": {}, "outputs": [], "source": [ @@ -32,7 +32,7 @@ }, { "cell_type": "markdown", - "id": "outlines-2", + "id": "2", "metadata": {}, "source": [ "Without `max_spacing`, a polygon's outline is its corners and a circle gets 100 vertices. A shape made of several pieces has one loop per piece: an `Aperture2D` has a lower and an upper plate. `Aperture2D.slit()` builds the common rectangular plates; `axis=1` turns the beam along y. Inverting a shape does not change its outline." @@ -41,7 +41,7 @@ { "cell_type": "code", "execution_count": null, - "id": "outlines-3", + "id": "3", "metadata": {}, "outputs": [], "source": [ @@ -72,7 +72,7 @@ }, { "cell_type": "markdown", - "id": "outlines-4", + "id": "4", "metadata": {}, "source": [ "## Choosing the spacing\n", @@ -83,7 +83,7 @@ { "cell_type": "code", "execution_count": null, - "id": "outlines-5", + "id": "5", "metadata": {}, "outputs": [], "source": [ @@ -126,7 +126,7 @@ }, { "cell_type": "markdown", - "id": "outlines-6", + "id": "6", "metadata": {}, "source": [ "Both errors fall as the square of the spacing, the accuracy of a polygon inscribed in a smooth curve.\n", @@ -139,7 +139,7 @@ { "cell_type": "code", "execution_count": null, - "id": "outlines-7", + "id": "7", "metadata": {}, "outputs": [], "source": [ @@ -185,7 +185,7 @@ }, { "cell_type": "markdown", - "id": "outlines-8", + "id": "8", "metadata": {}, "source": [ "The graded outline matches the uniform one at the tip with about a third of the vertices. Edges come out at or a little below the requested spacing, because each edge of the shape is split into a whole number of pieces.\n", @@ -198,7 +198,7 @@ { "cell_type": "code", "execution_count": null, - "id": "outlines-9", + "id": "9", "metadata": {}, "outputs": [], "source": [ @@ -220,7 +220,7 @@ }, { "cell_type": "markdown", - "id": "outlines-10", + "id": "10", "metadata": {}, "source": [ "## Holes and open curves\n", @@ -233,7 +233,7 @@ { "cell_type": "code", "execution_count": null, - "id": "outlines-11", + "id": "11", "metadata": {}, "outputs": [], "source": [ @@ -252,7 +252,7 @@ }, { "cell_type": "markdown", - "id": "outlines-12", + "id": "12", "metadata": {}, "source": [ "## Panels for boundary-element solvers\n", @@ -263,7 +263,7 @@ { "cell_type": "code", "execution_count": null, - "id": "outlines-13", + "id": "13", "metadata": {}, "outputs": [], "source": [ @@ -293,7 +293,7 @@ }, { "cell_type": "markdown", - "id": "outlines-14", + "id": "14", "metadata": {}, "source": [ "## Crossing the boundary\n", @@ -304,7 +304,7 @@ { "cell_type": "code", "execution_count": null, - "id": "outlines-15", + "id": "15", "metadata": {}, "outputs": [], "source": [ @@ -335,7 +335,7 @@ }, { "cell_type": "markdown", - "id": "outlines-16", + "id": "16", "metadata": {}, "source": [ "Try a graded spacing that refines the corners of the L-shaped polygon, where the field of a charged conductor is singular." diff --git a/tutorials/grids_and_coordinates.ipynb b/tutorials/grids_and_coordinates.ipynb index 3cb58c2..7dce3c1 100644 --- a/tutorials/grids_and_coordinates.ipynb +++ b/tutorials/grids_and_coordinates.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "markdown", - "id": "8c3f74c7", + "id": "0", "metadata": {}, "source": [ "# Grids and coordinates\n", @@ -12,7 +12,7 @@ }, { "cell_type": "markdown", - "id": "8be45f42", + "id": "1", "metadata": {}, "source": [ "## Bounded axes\n", @@ -23,7 +23,7 @@ { "cell_type": "code", "execution_count": null, - "id": "bb828428", + "id": "2", "metadata": {}, "outputs": [], "source": [ @@ -45,7 +45,7 @@ }, { "cell_type": "markdown", - "id": "5c288a62", + "id": "3", "metadata": {}, "source": [ "`node_points()` returns a host NumPy array of shape `(ndim, N)`. Each column is one node, ordered like `grid.indices`. In 1D, `node_coordinates()` takes a plain integer index." @@ -54,7 +54,7 @@ { "cell_type": "code", "execution_count": null, - "id": "33ca742b", + "id": "4", "metadata": {}, "outputs": [], "source": [ @@ -66,7 +66,7 @@ }, { "cell_type": "markdown", - "id": "cc48c76e", + "id": "5", "metadata": {}, "source": [ "## Periodic axes\n", @@ -77,7 +77,7 @@ { "cell_type": "code", "execution_count": null, - "id": "421b546f", + "id": "6", "metadata": {}, "outputs": [], "source": [ @@ -95,7 +95,7 @@ }, { "cell_type": "markdown", - "id": "fbc38b1c", + "id": "7", "metadata": {}, "source": [ "`BoundingBox.contains()` uses half-open bounds (`lower <= x < upper`). A nonperiodic upper endpoint is therefore a grid node even though `grid.contains()` reports that point as outside the box." @@ -104,7 +104,7 @@ { "cell_type": "code", "execution_count": null, - "id": "78ffa773", + "id": "8", "metadata": {}, "outputs": [], "source": [ diff --git a/tutorials/shapes_and_node_masks.ipynb b/tutorials/shapes_and_node_masks.ipynb index c484311..5de280a 100644 --- a/tutorials/shapes_and_node_masks.ipynb +++ b/tutorials/shapes_and_node_masks.ipynb @@ -110,7 +110,7 @@ }, { "cell_type": "markdown", - "id": "rounded-corners", + "id": "9", "metadata": {}, "source": [ "## Rounded corners\n", @@ -121,7 +121,7 @@ { "cell_type": "code", "execution_count": null, - "id": "rounded-corners-code", + "id": "10", "metadata": {}, "outputs": [], "source": [ @@ -146,7 +146,7 @@ }, { "cell_type": "markdown", - "id": "9", + "id": "11", "metadata": {}, "source": [ "## Solids in three dimensions\n", @@ -159,7 +159,7 @@ { "cell_type": "code", "execution_count": null, - "id": "10", + "id": "12", "metadata": {}, "outputs": [], "source": [ @@ -192,7 +192,7 @@ }, { "cell_type": "markdown", - "id": "slanted-tube", + "id": "13", "metadata": {}, "source": [ "## A tube with a slanted end\n", @@ -203,7 +203,7 @@ { "cell_type": "code", "execution_count": null, - "id": "slanted-tube-code", + "id": "14", "metadata": {}, "outputs": [], "source": [ @@ -241,7 +241,7 @@ }, { "cell_type": "markdown", - "id": "11", + "id": "15", "metadata": {}, "source": [ "`node_mask()` works the same way on a 3D grid. `show_3d(points=...)` draws the nodes inside a tilted torus as a labelled point set over its translucent surface, in simgeometry's `(3, n)` layout as `node_points()` returns them; rotate the view to see that they fill the tube of the torus and leave its hole empty." @@ -250,7 +250,7 @@ { "cell_type": "code", "execution_count": null, - "id": "12", + "id": "16", "metadata": {}, "outputs": [], "source": [ From bf97439036e732dda769ed6e66189b2e2469399d Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 27 Oct 2026 20:46:44 +0100 Subject: [PATCH 18/36] Build the kernels with cunumpy's KernelPackage (#32) * Build the kernels with cunumpy's KernelPackage * Lock cunumpy at devel with KernelPackage --- CHANGELOG.md | 3 ++ src/simgeometry/kernels/__init__.py | 24 ++++++++- src/simgeometry/kernels/_build.py | 53 ------------------- src/simgeometry/kernels/_metal.py | 41 -------------- .../kernels/absorb_polygons_2d/__init__.py | 8 +-- src/simgeometry/kernels/compile.py | 24 --------- .../kernels/contains_ball/__init__.py | 6 +-- .../contains_ball/contains_ball_metal.py | 6 +-- .../kernels/contains_box/__init__.py | 6 +-- .../contains_box/contains_box_metal.py | 6 +-- .../kernels/contains_polygon_2d/__init__.py | 6 +-- .../contains_polygon_2d_metal.py | 6 +-- uv.lock | 3 +- 13 files changed, 45 insertions(+), 147 deletions(-) delete mode 100644 src/simgeometry/kernels/_build.py delete mode 100644 src/simgeometry/kernels/_metal.py delete mode 100644 src/simgeometry/kernels/compile.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 495aba5..37aa43c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -117,6 +117,9 @@ format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed +- The kernels are built with cunumpy's `KernelPackage` + (`simgeometry.kernels.KERNEL_PACKAGE`): `kernels/_build.py`, `kernels/_metal.py` and + `kernels/compile.py` are gone, and the build cache is still `SIMGEOMETRY_KERNEL_CACHE`. - `__version__` is read from the installed package metadata (`importlib.metadata`), not hardcoded, so it can no longer disagree with `pyproject.toml` (the 0.1.1 wheel reported 0.1.0). - One out-of-range rule for owners: per axis, positions below the lower bound belong to the diff --git a/src/simgeometry/kernels/__init__.py b/src/simgeometry/kernels/__init__.py index 4843dea..6699051 100644 --- a/src/simgeometry/kernels/__init__.py +++ b/src/simgeometry/kernels/__init__.py @@ -1 +1,23 @@ -"""Compiled containment kernels for geometric objects.""" +"""simgeometry's compiled kernels, one folder per kernel. + +Every kernel folder builds its kernel with :data:`KERNEL_PACKAGE`:: + + kernel = KERNEL_PACKAGE.from_folder(__name__) + +with the defaults of :class:`cunumpy.kernels.KernelPackage`: the Pyccel version is +compiled on its first host call and kept under ``SIMGEOMETRY_KERNEL_CACHE`` (default +``~/.cache/simgeometry/kernels``; ``SIMGEOMETRY_OPENMP=0`` builds serial), the CUDA version runs +for CuPy arguments and the host version for NumPy ones, one CUDA thread per entry of +the last axis of the first array. Importing a kernel needs neither Pyccel nor CuPy. + +The Metal versions (``/_metal.py``) wrap a kernel built by +``KERNEL_PACKAGE.metal_kernel`` in a function with the arguments of the Pyccel +version. The Apple GPU has no float64, so they compute in float32: expect a relative +error of about 1e-5 against the CPU versions. Import one only where +:func:`cunumpy.kernels.metal_available` is true. +""" + +import cunumpy as xp + +#: The build cache and launch options of simgeometry's kernels. +KERNEL_PACKAGE = xp.kernels.KernelPackage("simgeometry") diff --git a/src/simgeometry/kernels/_build.py b/src/simgeometry/kernels/_build.py deleted file mode 100644 index f703878..0000000 --- a/src/simgeometry/kernels/_build.py +++ /dev/null @@ -1,53 +0,0 @@ -"""The options every kernel folder passes to :meth:`cunumpy.kernels.Kernel.from_folder`. - -The ``__init__.py`` of a kernel folder ``simgeometry/kernels//`` declares its -kernel with these:: - - kernel = xp.kernels.Kernel.from_folder(__name__, **KERNEL_OPTIONS) - -which finds ``_pyccel.py``, ``_numpy.py`` and ``_cuda.cu`` in -the folder. -Nothing is compiled here: the Pyccel version is compiled on its first host call -(cached on disk by :func:`~simgeometry.kernels.compile.compile_cached`), the CUDA -version on its first launch. Importing this module needs neither Pyccel nor CuPy. -""" - -from __future__ import annotations - -from typing import Any - -#: CUDA threads per block of every kernel. -BLOCK_SIZE = 256 - -#: Default dynamic shared memory per block of a CUDA device, in bytes. -SHARED_MEMORY_PER_BLOCK = 48 * 1024 - - -def shared_memory_fits(num_values: int, itemsize: int = 8) -> bool: - """Whether ``num_values`` values fit the default shared memory of a block.""" - return num_values * itemsize <= SHARED_MEMORY_PER_BLOCK - - -def _compile(module: Any) -> Any: - from simgeometry.kernels.compile import compile_cached - - return compile_cached(module) - - -def _marker_count(args: tuple[Any, ...]) -> int: - """Use the last axis of the first array as the CUDA thread count.""" - return int(args[0].shape[-1]) - - -#: ``_pyccel.py`` compiled with Pyccel on first use; CUDA for CuPy -#: arguments, the host version for NumPy ones (also while CuPy is active); -#: without ``n_threads`` one CUDA thread per entry of the last axis of the -#: first array (the marker count of a component-major ``(ncomp, N)`` or -#: ``(N,)`` marker array). CUDA sources include cunumpy's array-view header. -KERNEL_OPTIONS: dict[str, Any] = { - "host_suffix": "_pyccel", - "compile_host": _compile, - "dispatch": "arrays", - "block_size": BLOCK_SIZE, - "n_threads_from": _marker_count, -} diff --git a/src/simgeometry/kernels/_metal.py b/src/simgeometry/kernels/_metal.py deleted file mode 100644 index 10360db..0000000 --- a/src/simgeometry/kernels/_metal.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Helpers of the Metal versions (``/_metal.py``) of the kernels. - -A Metal version is the body of a Metal Shading Language kernel run by -:class:`cunumpy.kernels.MetalKernel` on the GPU of an Apple-silicon Mac, wrapped in -a function with the arguments of the Pyccel version: NumPy arrays in, results -written into the output arrays. The GPU has no float64, so the kernels compute in -float32 (``float64="cast"``): expect a relative error of about 1e-5 against the -CPU versions. Import a version only where :func:`metal_available` is true. -""" - -from __future__ import annotations - -import cunumpy as xp - -#: Threads per threadgroup of every Metal kernel. -THREADGROUP = 256 - - -def metal_available() -> bool: - """Whether the Metal versions can run here (MLX installed, Metal GPU present).""" - return bool(xp.kernels.metal_available()) - - -def make_kernel( - name: str, - source: str, - inputs: list[str], - outputs: list[str], - header: str = "", -) -> xp.kernels.MetalKernel: - """A :class:`~cunumpy.kernels.MetalKernel` computing in float32, outputs zeroed.""" - return xp.kernels.MetalKernel( - source, - inputs=inputs, - outputs=outputs, - name=name, - header=header, - threadgroup=THREADGROUP, - float64="cast", - init_value=0, - ) diff --git a/src/simgeometry/kernels/absorb_polygons_2d/__init__.py b/src/simgeometry/kernels/absorb_polygons_2d/__init__.py index c506694..c14fb3c 100644 --- a/src/simgeometry/kernels/absorb_polygons_2d/__init__.py +++ b/src/simgeometry/kernels/absorb_polygons_2d/__init__.py @@ -6,15 +6,11 @@ version runs one thread per entry of ``rows`` (the third argument). """ -import cunumpy as xp - -from simgeometry.kernels._build import KERNEL_OPTIONS +from simgeometry.kernels import KERNEL_PACKAGE def _row_count(args: tuple) -> int: return int(args[2].shape[0]) -kernel = xp.kernels.Kernel.from_folder( - __name__, **{**KERNEL_OPTIONS, "n_threads_from": _row_count} -) +kernel = KERNEL_PACKAGE.from_folder(__name__, n_threads_from=_row_count) diff --git a/src/simgeometry/kernels/compile.py b/src/simgeometry/kernels/compile.py deleted file mode 100644 index 8f4f16c..0000000 --- a/src/simgeometry/kernels/compile.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Compile simgeometry's Pyccel kernel modules once and reuse the builds across processes. - -The cache is :class:`cunumpy.kernels.PyccelCache`: a kernel module is built with -``pyccel compile`` on its first use, keyed on its source, the Pyccel and Python -versions and the platform, and stored under ``SIMGEOMETRY_KERNEL_CACHE`` (default -``~/.cache/simgeometry/kernels``, or ``/simgeometry-kernels`` if that is not writable). -Later processes import the stored extension directly. - -Kernels are built with OpenMP, so the loops marked ``#$ omp parallel for`` run on -all cores (limit them with ``OMP_NUM_THREADS``; under MPI set it to the cores per -rank). ``SIMGEOMETRY_OPENMP=0`` builds them serial, except the kernels that call the OpenMP -runtime; the setting is part of the cache key. -""" - -from __future__ import annotations - -import cunumpy as xp - -#: The on-disk build cache of simgeometry's kernels. -KERNEL_CACHE = xp.kernels.PyccelCache("simgeometry") - -#: Return the compiled form of a kernel module, building it on first use -#: (:meth:`cunumpy.kernels.PyccelCache.compile`). -compile_cached = KERNEL_CACHE.compile diff --git a/src/simgeometry/kernels/contains_ball/__init__.py b/src/simgeometry/kernels/contains_ball/__init__.py index 3c05d88..077bd3b 100644 --- a/src/simgeometry/kernels/contains_ball/__init__.py +++ b/src/simgeometry/kernels/contains_ball/__init__.py @@ -5,8 +5,6 @@ (see ``cunumpy.kernels.set_host_kernel_implementation`` to choose). """ -import cunumpy as xp +from simgeometry.kernels import KERNEL_PACKAGE -from simgeometry.kernels._build import KERNEL_OPTIONS - -kernel = xp.kernels.Kernel.from_folder(__name__, **KERNEL_OPTIONS) +kernel = KERNEL_PACKAGE.from_folder(__name__) diff --git a/src/simgeometry/kernels/contains_ball/contains_ball_metal.py b/src/simgeometry/kernels/contains_ball/contains_ball_metal.py index 5ae163d..8e66989 100644 --- a/src/simgeometry/kernels/contains_ball/contains_ball_metal.py +++ b/src/simgeometry/kernels/contains_ball/contains_ball_metal.py @@ -1,7 +1,7 @@ """The Metal version (Apple-silicon GPU, float32) of the ``contains_ball`` kernel. ``contains_ball_pyccel.py`` next to this file is the CPU version with the same -arguments. One thread per marker (``positions`` is ``(ndim, N)``); see :mod:`simgeometry.kernels._metal`. The test is +arguments. One thread per marker (``positions`` is ``(ndim, N)``); see :mod:`simgeometry.kernels`. The test is done in float32, so a marker within about 1e-7 (relative) of the boundary may be classified differently from the CPU version. """ @@ -11,7 +11,7 @@ import numpy as np from simgeometry._types import Array -from simgeometry.kernels._metal import make_kernel +from simgeometry.kernels import KERNEL_PACKAGE _SOURCE = """ uint marker = thread_position_in_grid.x; @@ -28,7 +28,7 @@ inside_flag[marker] = inside ? 1 : 0; """ -_KERNEL = make_kernel( +_KERNEL = KERNEL_PACKAGE.metal_kernel( "simgeometry_contains_ball", _SOURCE, inputs=["positions", "midpoint", "params"], diff --git a/src/simgeometry/kernels/contains_box/__init__.py b/src/simgeometry/kernels/contains_box/__init__.py index 1bddf2c..22a2e93 100644 --- a/src/simgeometry/kernels/contains_box/__init__.py +++ b/src/simgeometry/kernels/contains_box/__init__.py @@ -5,8 +5,6 @@ (see ``cunumpy.kernels.set_host_kernel_implementation`` to choose). """ -import cunumpy as xp +from simgeometry.kernels import KERNEL_PACKAGE -from simgeometry.kernels._build import KERNEL_OPTIONS - -kernel = xp.kernels.Kernel.from_folder(__name__, **KERNEL_OPTIONS) +kernel = KERNEL_PACKAGE.from_folder(__name__) diff --git a/src/simgeometry/kernels/contains_box/contains_box_metal.py b/src/simgeometry/kernels/contains_box/contains_box_metal.py index f250f86..7ec3906 100644 --- a/src/simgeometry/kernels/contains_box/contains_box_metal.py +++ b/src/simgeometry/kernels/contains_box/contains_box_metal.py @@ -1,7 +1,7 @@ """The Metal version (Apple-silicon GPU, float32) of the ``contains_box`` kernel. ``contains_box_pyccel.py`` next to this file is the CPU version with the same -arguments. One thread per marker (``positions`` is ``(ndim, N)``); see :mod:`simgeometry.kernels._metal`. The test is +arguments. One thread per marker (``positions`` is ``(ndim, N)``); see :mod:`simgeometry.kernels`. The test is done in float32, so a marker within about 1e-7 (relative) of the boundary may be classified differently from the CPU version. """ @@ -11,7 +11,7 @@ import numpy as np from simgeometry._types import Array -from simgeometry.kernels._metal import make_kernel +from simgeometry.kernels import KERNEL_PACKAGE _SOURCE = """ uint marker = thread_position_in_grid.x; @@ -27,7 +27,7 @@ inside_flag[marker] = inside ? 1 : 0; """ -_KERNEL = make_kernel( +_KERNEL = KERNEL_PACKAGE.metal_kernel( "simgeometry_contains_box", _SOURCE, inputs=["positions", "lower", "upper", "flags"], diff --git a/src/simgeometry/kernels/contains_polygon_2d/__init__.py b/src/simgeometry/kernels/contains_polygon_2d/__init__.py index aaeab5c..e96fa48 100644 --- a/src/simgeometry/kernels/contains_polygon_2d/__init__.py +++ b/src/simgeometry/kernels/contains_polygon_2d/__init__.py @@ -5,8 +5,6 @@ (see ``cunumpy.kernels.set_host_kernel_implementation`` to choose). """ -import cunumpy as xp +from simgeometry.kernels import KERNEL_PACKAGE -from simgeometry.kernels._build import KERNEL_OPTIONS - -kernel = xp.kernels.Kernel.from_folder(__name__, **KERNEL_OPTIONS) +kernel = KERNEL_PACKAGE.from_folder(__name__) diff --git a/src/simgeometry/kernels/contains_polygon_2d/contains_polygon_2d_metal.py b/src/simgeometry/kernels/contains_polygon_2d/contains_polygon_2d_metal.py index 993d289..d6e9d15 100644 --- a/src/simgeometry/kernels/contains_polygon_2d/contains_polygon_2d_metal.py +++ b/src/simgeometry/kernels/contains_polygon_2d/contains_polygon_2d_metal.py @@ -1,7 +1,7 @@ """The Metal version (Apple-silicon GPU, float32) of the ``contains_polygon_2d`` kernel. ``contains_polygon_2d_pyccel.py`` next to this file is the CPU version with the same -arguments. One thread per marker (``positions`` is ``(2, N)``); see :mod:`simgeometry.kernels._metal`. The test is +arguments. One thread per marker (``positions`` is ``(2, N)``); see :mod:`simgeometry.kernels`. The test is done in float32, so a marker within about 1e-7 (relative) of the boundary may be classified differently from the CPU version. """ @@ -11,7 +11,7 @@ import numpy as np from simgeometry._types import Array -from simgeometry.kernels._metal import make_kernel +from simgeometry.kernels import KERNEL_PACKAGE _SOURCE = """ uint marker = thread_position_in_grid.x; @@ -36,7 +36,7 @@ inside_flag[marker] = inside ? 1 : 0; """ -_KERNEL = make_kernel( +_KERNEL = KERNEL_PACKAGE.metal_kernel( "simgeometry_contains_polygon_2d", _SOURCE, inputs=["positions", "vertices", "flags"], diff --git a/uv.lock b/uv.lock index 04213a9..ca15887 100644 --- a/uv.lock +++ b/uv.lock @@ -1114,13 +1114,14 @@ wheels = [ [[package]] name = "cunumpy" version = "0.7.1" -source = { git = "https://github.com/max-models/cunumpy.git?branch=devel#9fb918b130b08a2c159005997186efdb5edacb4f" } +source = { git = "https://github.com/max-models/cunumpy.git?branch=devel#92b38a5a0e00d7ccf03aa39849d6c7eaee946859" } dependencies = [ { name = "array-api-compat" }, { name = "maybempi" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "scope-profiler" }, ] [[package]] From f39424e24fc266c463cde7d3561b8daf58669084 Mon Sep 17 00:00:00 2001 From: Max Date: Wed, 28 Oct 2026 09:12:24 +0100 Subject: [PATCH 19/36] Bump starlight-theme to 0.5.2 and use the newest Node release in CI. (#33) --- .github/workflows/docs.yml | 2 +- .github/workflows/test_tutorials.yml | 2 ++ docs/package-lock.json | 2 +- docs/package.json | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 1b5922d..e2fc584 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -22,7 +22,7 @@ concurrency: jobs: docs: - uses: max-models/starlight-theme/.github/workflows/docs.yml@v0.5.0 + uses: max-models/starlight-theme/.github/workflows/docs.yml@v0.5.2 with: node-version: "24" # the PyVista scenes in the tutorials render under a virtual display diff --git a/.github/workflows/test_tutorials.yml b/.github/workflows/test_tutorials.yml index 28da34a..ae173b8 100644 --- a/.github/workflows/test_tutorials.yml +++ b/.github/workflows/test_tutorials.yml @@ -37,6 +37,8 @@ jobs: uses: actions/setup-node@v7 with: node-version: 22 + # The newest release, not the runner's cached one: Astro raises its minimum often. + check-latest: true cache: npm cache-dependency-path: docs/package-lock.json diff --git a/docs/package-lock.json b/docs/package-lock.json index db1054b..e4558ce 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.1", "dependencies": { "@astrojs/starlight": "^0.42.7", - "@max-models/starlight-theme": "github:max-models/starlight-theme#v0.5.0", + "@max-models/starlight-theme": "github:max-models/starlight-theme#v0.5.2", "astro": "^7.4.2", "sharp": "^0.35.3" }, diff --git a/docs/package.json b/docs/package.json index 3968f0e..930321b 100644 --- a/docs/package.json +++ b/docs/package.json @@ -17,7 +17,7 @@ }, "dependencies": { "@astrojs/starlight": "^0.42.7", - "@max-models/starlight-theme": "github:max-models/starlight-theme#v0.5.0", + "@max-models/starlight-theme": "github:max-models/starlight-theme#v0.5.2", "astro": "^7.4.2", "sharp": "^0.35.3" } From bcbec89f91edaf5a9d4d0ecb87efbb1cedec42bc Mon Sep 17 00:00:00 2001 From: Max Date: Wed, 28 Oct 2026 22:44:43 +0100 Subject: [PATCH 20/36] Declare the scope_profiler.opt_in_tags entry point. (#34) --- pyproject.toml | 3 +++ src/simgeometry/tests/test_profiling.py | 14 ++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 334972e..f58c9e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,6 +58,9 @@ optional-dependencies.metal = [ "mlx; sys_platform == 'darwin' and platform_mach optional-dependencies.test = [ "mpi4py", "pytest", "pytest-cov", "simgeometry[clip]" ] urls.Source = "https://github.com/max-models/simgeometry" +[project.entry-points."scope_profiler.opt_in_tags"] +simgeometry = "simgeometry" + [tool.setuptools] package-data."*" = [ "py.typed" ] package-data.simgeometry = [ "kernels/*.cuh", "kernels/*/*.cu" ] diff --git a/src/simgeometry/tests/test_profiling.py b/src/simgeometry/tests/test_profiling.py index 1b999ca..00ab876 100644 --- a/src/simgeometry/tests/test_profiling.py +++ b/src/simgeometry/tests/test_profiling.py @@ -30,3 +30,17 @@ def test_regions_are_off_by_default() -> None: def test_enabling_the_tag_records_the_regions() -> None: assert _node_mask_regions(enable_tags=["simgeometry"]) == ["simgeometry.node_mask"] + + +def test_the_opt_in_tag_has_an_entry_point(): + """``scope-profiler tags`` finds the tag through the package's entry point.""" + from importlib.metadata import entry_points + + from simgeometry._profiling import _PROFILE_TAGS + + found = { + entry_point.name: entry_point.value + for entry_point in entry_points(group="scope_profiler.opt_in_tags") + } + assert found.get("simgeometry") == "simgeometry" + assert "simgeometry" in _PROFILE_TAGS From adc5a1d770bb45aea24f1e30619d2a01f07c480f Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 29 Oct 2026 14:59:33 +0100 Subject: [PATCH 21/36] Bump starlight-theme to 0.6.0 and drop the separate tutorial job (#35) * Bump starlight-theme to 0.6.0. * Drop the separate tutorial job; the docs workflow runs the tutorials. --- .github/workflows/docs.yml | 2 +- .github/workflows/test_tutorials.yml | 56 ---------------------------- docs/package-lock.json | 10 +++-- docs/package.json | 2 +- 4 files changed, 8 insertions(+), 62 deletions(-) delete mode 100644 .github/workflows/test_tutorials.yml diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index e2fc584..13cc567 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -22,7 +22,7 @@ concurrency: jobs: docs: - uses: max-models/starlight-theme/.github/workflows/docs.yml@v0.5.2 + uses: max-models/starlight-theme/.github/workflows/docs.yml@v0.6.0 with: node-version: "24" # the PyVista scenes in the tutorials render under a virtual display diff --git a/.github/workflows/test_tutorials.yml b/.github/workflows/test_tutorials.yml deleted file mode 100644 index ae173b8..0000000 --- a/.github/workflows/test_tutorials.yml +++ /dev/null @@ -1,56 +0,0 @@ -name: Test Tutorials - -on: - push: - branches: - - main - - devel - pull_request: - branches: - - main - - devel - -env: - # A PR to main or a push to main (the release) uses PyPI versions only; devel uses the - # devel branches from [tool.uv.sources]. - UV_NO_SOURCES: ${{ (github.base_ref || github.ref_name) == 'main' }} - -jobs: - tutorials: - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - python-version: ["3.10", "3.13"] - - steps: - - name: Checkout code - uses: actions/checkout@v7 - - - name: Set up uv and Python ${{ matrix.python-version }} - uses: astral-sh/setup-uv@v7 - with: - python-version: ${{ matrix.python-version }} - enable-cache: true - - - name: Set up Node - uses: actions/setup-node@v7 - with: - node-version: 22 - # The newest release, not the runner's cached one: Astro raises its minimum often. - check-latest: true - cache: npm - cache-dependency-path: docs/package-lock.json - - - name: Install a virtual display for the PyVista scenes - run: sudo apt-get update && sudo apt-get install -y xvfb libgl1 libglx-mesa0 - - - name: Install project - run: | - uv sync --extra docs - uv run python -m ipykernel install --user --name python3 - cd docs && npm ci - - - name: Execute tutorials - working-directory: docs - run: DOCS_PYTHON="$(pwd)/../.venv/bin/python" xvfb-run -a npx starlight-notebooks execute --force diff --git a/docs/package-lock.json b/docs/package-lock.json index e4558ce..1cda0ce 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.1", "dependencies": { "@astrojs/starlight": "^0.42.7", - "@max-models/starlight-theme": "github:max-models/starlight-theme#v0.5.2", + "@max-models/starlight-theme": "github:max-models/starlight-theme#v0.6.0", "astro": "^7.4.2", "sharp": "^0.35.3" }, @@ -1223,8 +1223,8 @@ "license": "MIT" }, "node_modules/@max-models/starlight-theme": { - "version": "0.5.0", - "resolved": "git+ssh://git@github.com/max-models/starlight-theme.git#03d08c323572c44f47e769f1a07a7833ac8ed9fc", + "version": "0.6.0", + "resolved": "git+ssh://git@github.com/max-models/starlight-theme.git#62427a589ba327fb04664639ed8250e344dabb61", "license": "MIT", "workspaces": [ "example/docs" @@ -1236,8 +1236,10 @@ "plotly.js-dist-min": "^4.1.2", "rehype-katex": "^7.0.1", "remark-math": "^6.0.0", + "sharp": "^0.35.3", "starlight-links-validator": "^0.27.0", - "starlight-pydocs": "0.3.0" + "starlight-pydocs": "0.3.0", + "yaml": "^2.9.1" }, "bin": { "starlight-check-mdx": "bin/check-mdx.mjs", diff --git a/docs/package.json b/docs/package.json index 930321b..332ddeb 100644 --- a/docs/package.json +++ b/docs/package.json @@ -17,7 +17,7 @@ }, "dependencies": { "@astrojs/starlight": "^0.42.7", - "@max-models/starlight-theme": "github:max-models/starlight-theme#v0.5.2", + "@max-models/starlight-theme": "github:max-models/starlight-theme#v0.6.0", "astro": "^7.4.2", "sharp": "^0.35.3" } From b798363aeaffc8bda4eac115aa3140cd1cba5a79 Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 29 Oct 2026 18:20:19 +0100 Subject: [PATCH 22/36] Shorten the titles of the docs pages. (#36) --- tutorials/grid_intersections_and_cut_edges.ipynb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tutorials/grid_intersections_and_cut_edges.ipynb b/tutorials/grid_intersections_and_cut_edges.ipynb index a081e97..fd23cbc 100644 --- a/tutorials/grid_intersections_and_cut_edges.ipynb +++ b/tutorials/grid_intersections_and_cut_edges.ipynb @@ -5,7 +5,7 @@ "id": "0", "metadata": {}, "source": [ - "# Grid intersections and cut edges\n", + "# Cut edges\n", "\n", "Inspect the geometric input to a cut-cell solver: crossing positions, edge fractions, distances, object identity, and outward normals. Simgeometry computes geometry; Picnic applies its Shortley–Weller coefficients, boundary conditions and numerical minimum-distance clamp.\n", "\n", From 65238ce01045ab183a1f886b8eff693d26ab45de Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 29 Oct 2026 22:10:18 +0100 Subject: [PATCH 23/36] Add an animated figure to the docs home page. (#37) --- Makefile | 5 +- docs/hero.py | 78 +++++ docs/src/assets/hero.svg | 709 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 791 insertions(+), 1 deletion(-) create mode 100644 docs/hero.py create mode 100644 docs/src/assets/hero.svg diff --git a/Makefile b/Makefile index 232cd0d..680a88d 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ NOTEBOOKS = cd docs && $(DOCS_ENV) npx starlight-notebooks DOCS_ENV = DOCS_PYTHON=$(shell $(PYTHON) -c "import sys; print(sys.executable)") FORCE ?= -.PHONY: help install test lint readme readme-check docs-install docs-notebooks docs-dev docs-build docs-preview docs-clean +.PHONY: help install test lint readme readme-check docs-install docs-notebooks docs-hero docs-dev docs-build docs-preview docs-clean help: ## list the targets @grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " %-16s %s\n", $$1, $$2}' @@ -38,6 +38,9 @@ docs-notebooks: ## execute the changed tutorials and convert all of them (FORCE $(NOTEBOOKS) execute $(if $(FORCE),--force,) $(NOTEBOOKS) convert +docs-hero: ## redraw the home page figure (docs/src/assets/hero.svg) + $(PYTHON) docs/hero.py + docs-dev: ## live preview at http://localhost:4321/simgeometry/ $(NOTEBOOKS) convert cd docs && $(DOCS_ENV) DOCS_VALIDATE_LINKS=false npm run dev diff --git a/docs/hero.py b/docs/hero.py new file mode 100644 index 0000000..13ca87a --- /dev/null +++ b/docs/hero.py @@ -0,0 +1,78 @@ +"""The animated figure at the top of the docs home page: ``docs/src/assets/hero.svg``. + +simgeometry describes a small ion-optics column on a grid: a rounded source plate, three slit +apertures and a round target. The figure shows their outlines and the grid nodes inside them +(``node_mask``), with the domain decomposition behind them as blocks +(``DomainDecomposition.preview``); the blocks reshuffle as the rank count steps through 2, 4, 6, +8, 12 and 16. Hovering a block gives its rank and how many electrode nodes it holds. Takes well +under a second. + +Rerun with ``make docs-hero`` or ``python docs/hero.py`` (needs ``plasma_plots.hero``). +""" + +from pathlib import Path + +import numpy as np +from plasma_plots.hero import HeroFigure + +from simgeometry import Aperture2D, Circle, Rectangle +from simgeometry.domain_decomposition import DomainDecomposition +from simgeometry.grid import Grid + +OUTPUT = Path(__file__).parent / "src" / "assets" / "hero.svg" +LOWER, UPPER, NODES = (0.0, 0.0), (8.0, 3.0), (161, 61) +RANKS = (2, 4, 6, 8, 12, 16) +STEP = 3.0 # seconds per rank count + +grid = Grid(2, LOWER, UPPER, NODES) +shapes = [ + Rectangle(midpoint=(3.3, 1.5), side_lengths=(0.3, 1.6), fillet=0.12), + Aperture2D.slit(4.3, half_width=0.35, thickness=0.15, length=1.0, position=1.5), + Aperture2D.slit(5.3, half_width=0.25, thickness=0.15, length=1.0, position=1.5), + Aperture2D.slit(6.3, half_width=0.15, thickness=0.15, length=1.0, position=1.5), + Circle(midpoint=(7.3, 1.5), radius=0.3), +] +mask = np.zeros(NODES, dtype=bool) +for shape in shapes: + mask |= shape.node_mask(grid) +nodes = grid.node_points() +inside = mask.ravel() + +fig = HeroFigure(xlim=(LOWER[0], UPPER[0]), ylim=(LOWER[1], UPPER[1])) + +# the decomposition for each rank count, shown in turn +phases = len(RANKS) +for phase, size in enumerate(RANKS): + preview = DomainDecomposition.preview(size, decompose=[True, True]) + shown = [0.0] * phases + shown[phase] = 1.0 + for rank, (lo, hi) in enumerate(preview.subdomain_boxes(LOWER, UPPER, NODES)): + owned = ( + (nodes[0] >= lo[0]) + & (nodes[0] < hi[0]) + & (nodes[1] >= lo[1]) + & (nodes[1] < hi[1]) + ) + coord = preview.get_proc_coord(rank) + block = fig.rect( + *lo, + *hi, + tone="low", + opacity=0.15 + 0.4 * (sum(coord) % 2), + outline="muted", + title=f"rank {rank} of {size}: {int((owned & inside).sum())} electrode nodes", + ) + block.attrs["stroke-width"] = "1" + # hold each rank count for a step, crossfading over a sixth of it + values = [v for v in shown for _ in range(6)] + block.animate("opacity", values + values[:1], STEP * phases) + +for shape in shapes: + for loop in shape.outlines(max_spacing=0.05): + poly = fig.polygon(*loop, tone="bg", outline="accent") + poly.attrs["stroke-width"] = "2" +fig_nodes = np.flatnonzero(inside) +for x, y in zip(nodes[0][fig_nodes], nodes[1][fig_nodes], strict=True): + fig.circle(x, y, 1.6, tone="high", opacity=0.8) + +print(f"Saved {fig.save(OUTPUT)}") diff --git a/docs/src/assets/hero.svg b/docs/src/assets/hero.svg new file mode 100644 index 0000000..eed125c --- /dev/null +++ b/docs/src/assets/hero.svg @@ -0,0 +1,709 @@ + +rank 0 of 2: 334 electrode nodes +rank 1 of 2: 317 electrode nodes +rank 0 of 4: 95 electrode nodes +rank 1 of 4: 88 electrode nodes +rank 2 of 4: 239 electrode nodes +rank 3 of 4: 229 electrode nodes +rank 0 of 6: 35 electrode nodes +rank 1 of 6: 120 electrode nodes +rank 2 of 6: 28 electrode nodes +rank 3 of 6: 141 electrode nodes +rank 4 of 6: 201 electrode nodes +rank 5 of 6: 126 electrode nodes +rank 0 of 8: 6 electrode nodes +rank 1 of 8: 89 electrode nodes +rank 2 of 8: 88 electrode nodes +rank 3 of 8: 0 electrode nodes +rank 4 of 8: 96 electrode nodes +rank 5 of 8: 143 electrode nodes +rank 6 of 8: 148 electrode nodes +rank 7 of 8: 81 electrode nodes +rank 0 of 12: 0 electrode nodes +rank 1 of 12: 0 electrode nodes +rank 2 of 12: 0 electrode nodes +rank 3 of 12: 0 electrode nodes +rank 4 of 12: 78 electrode nodes +rank 5 of 12: 137 electrode nodes +rank 6 of 12: 148 electrode nodes +rank 7 of 12: 60 electrode nodes +rank 8 of 12: 24 electrode nodes +rank 9 of 12: 95 electrode nodes +rank 10 of 12: 88 electrode nodes +rank 11 of 12: 21 electrode nodes +rank 0 of 16: 0 electrode nodes +rank 1 of 16: 0 electrode nodes +rank 2 of 16: 0 electrode nodes +rank 3 of 16: 0 electrode nodes +rank 4 of 16: 6 electrode nodes +rank 5 of 16: 89 electrode nodes +rank 6 of 16: 88 electrode nodes +rank 7 of 16: 0 electrode nodes +rank 8 of 16: 72 electrode nodes +rank 9 of 16: 48 electrode nodes +rank 10 of 16: 60 electrode nodes +rank 11 of 16: 60 electrode nodes +rank 12 of 16: 24 electrode nodes +rank 13 of 16: 95 electrode nodes +rank 14 of 16: 88 electrode nodes +rank 15 of 16: 21 electrode nodes + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 6ed898fcf25f660a05c97228dca18dda8a5a9c4a Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 29 Oct 2026 23:43:10 +0100 Subject: [PATCH 24/36] Remove the license. (#39) --- LICENSE.txt | 21 --------------------- pyproject.toml | 2 -- 2 files changed, 23 deletions(-) delete mode 100644 LICENSE.txt diff --git a/LICENSE.txt b/LICENSE.txt deleted file mode 100644 index 8c9bbb9..0000000 --- a/LICENSE.txt +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 Max Lindqvist - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/pyproject.toml b/pyproject.toml index f58c9e3..df192bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,8 +8,6 @@ version = "0.1.1" description = "Shapes, grids, and simulation domains for numerical simulations" readme = "README.md" keywords = [ "geometry", "grids", "simulation", "mpi" ] -license = "MIT" -license-files = [ "LICENSE.txt" ] authors = [ { name = "Max" } ] requires-python = ">=3.10" classifiers = [ From 2c16dbb35ee6ff060323c7f0a4b4f5d79125980e Mon Sep 17 00:00:00 2001 From: Max Date: Fri, 30 Oct 2026 08:50:50 +0100 Subject: [PATCH 25/36] Merge Tutorials and Full examples into one Examples section. (#38) --- .github/workflows/docs.yml | 38 +++++++++---------- Makefile | 2 +- README.md | 6 +-- README.qmd | 6 +-- docs/.gitignore | 4 +- docs/astro.config.mjs | 6 +-- docs/package.json | 3 ++ docs/src/content/docs/development/docs.md | 11 +++--- .../content/docs/development/publishing.md | 2 +- .../docs/getting-started/geometry-api.md | 8 ++-- .../docs/getting-started/quickstart.md | 6 +-- docs/src/content/docs/index.mdx | 2 +- src/simgeometry/README.md | 2 +- tutorials/index.ipynb | 24 ++++++------ 14 files changed, 62 insertions(+), 58 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 13cc567..10afb89 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -25,27 +25,27 @@ jobs: uses: max-models/starlight-theme/.github/workflows/docs.yml@v0.6.0 with: node-version: "24" - # the PyVista scenes in the tutorials render under a virtual display + # the PyVista scenes in the examples render under a virtual display xvfb: true smoke-paths: | getting-started/installation/index.html - tutorials/index.html - tutorials/grids-and-coordinates/index.html - tutorials/shapes-and-node-masks/index.html - tutorials/parallel-grids/index.html + examples/index.html + examples/grids-and-coordinates/index.html + examples/shapes-and-node-masks/index.html + examples/parallel-grids/index.html api/simgeometry/index.html api/simgeometry/grid/index.html - tutorials/grids-and-coordinates/grids_and_coordinates.ipynb - tutorials/shapes-and-node-masks/shapes_and_node_masks.ipynb - tutorials/parallel-grids/parallel_grids.ipynb - tutorials/shapes-and-node-masks/1.png - tutorials/inspecting-geometry/4.html - tutorials/conductors-and-level-sets/2.html - tutorials/shapes-and-node-masks/2.png - tutorials/shapes-and-node-masks/3.html - tutorials/building-composite-shapes/5.html - tutorials/slices-and-coordinate-sampling/4.html - tutorials/sampling-export-and-diagnostics/2.html - tutorials/grid-intersections-and-cut-edges/2.html - tutorials/conductors-and-level-sets/4.html - tutorials/parallel-grids/1.html + examples/grids-and-coordinates/grids_and_coordinates.ipynb + examples/shapes-and-node-masks/shapes_and_node_masks.ipynb + examples/parallel-grids/parallel_grids.ipynb + examples/shapes-and-node-masks/1.png + examples/inspecting-geometry/4.html + examples/conductors-and-level-sets/2.html + examples/shapes-and-node-masks/2.png + examples/shapes-and-node-masks/3.html + examples/building-composite-shapes/5.html + examples/slices-and-coordinate-sampling/4.html + examples/sampling-export-and-diagnostics/2.html + examples/grid-intersections-and-cut-edges/2.html + examples/conductors-and-level-sets/4.html + examples/parallel-grids/1.html diff --git a/Makefile b/Makefile index 680a88d..5338cd6 100644 --- a/Makefile +++ b/Makefile @@ -34,7 +34,7 @@ docs-install: ## Python docs extra and the npm packages $(PYTHON) -m pip install -e ".[docs]" cd docs && npm ci -docs-notebooks: ## execute the changed tutorials and convert all of them (FORCE=1 reruns all) +docs-notebooks: ## execute the changed notebooks and convert all of them (FORCE=1 reruns all) $(NOTEBOOKS) execute $(if $(FORCE),--force,) $(NOTEBOOKS) convert diff --git a/README.md b/README.md index 10c8eb0..b841225 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ inside = shell.evaluate(x=[0, 0.8], y=0, z=0) Shapes support named collections, Boolean operations, affine transforms, surface queries, point sampling, mesh export, cut-edge geometry and approximate cell/face fractions. See the [executed -tutorials](https://max-models.github.io/simgeometry/tutorials/) and [API +examples](https://max-models.github.io/simgeometry/examples/) and [API guide](https://max-models.github.io/simgeometry/getting-started/geometry-api/) for examples and the accuracy limits of sampled operations. @@ -77,7 +77,7 @@ where it is the Euclidean signed distance; Booleans and transforms propagate it. Edge crossings refine it with a few secant steps instead of bisecting `contains`, and `signed_distance` returns an exact one directly. The [conductors -tutorial](https://max-models.github.io/simgeometry/tutorials/conductors-and-level-sets/) +example](https://max-models.github.io/simgeometry/examples/conductors-and-level-sets/) builds an Einzel lens. ## Preview a domain decomposition @@ -97,7 +97,7 @@ would use without a communicator, for inspection and planning: index ranges, subdomains and owner ranks for every rank. `show_3d` draws one box per rank (with `simgeometry[interactive]`) and `show` one rectangle per rank of a 2D decomposition. See the [parallel grids -tutorial](https://max-models.github.io/simgeometry/tutorials/parallel-grids/). +example](https://max-models.github.io/simgeometry/examples/parallel-grids/). ## Profiling diff --git a/README.qmd b/README.qmd index d6dfbda..3d56909 100644 --- a/README.qmd +++ b/README.qmd @@ -50,7 +50,7 @@ inside = shell.evaluate(x=[0, 0.8], y=0, z=0) Shapes support named collections, Boolean operations, affine transforms, surface queries, point sampling, mesh export, cut-edge geometry and approximate cell/face -fractions. See the [executed tutorials](https://max-models.github.io/simgeometry/tutorials/) +fractions. See the [executed examples](https://max-models.github.io/simgeometry/examples/) and [API guide](https://max-models.github.io/simgeometry/getting-started/geometry-api/) for examples and the accuracy limits of sampled operations. @@ -74,7 +74,7 @@ distance = lens["focus"].level_set(points) # exact signed distance Euclidean signed distance; Booleans and transforms propagate it. Edge crossings refine it with a few secant steps instead of bisecting `contains`, and `signed_distance` returns an exact one directly. The -[conductors tutorial](https://max-models.github.io/simgeometry/tutorials/conductors-and-level-sets/) +[conductors example](https://max-models.github.io/simgeometry/examples/conductors-and-level-sets/) builds an Einzel lens. ## Preview a domain decomposition @@ -93,7 +93,7 @@ preview.show_3d((0, 0, 0), (2, 1, 1), (33, 17, 17), shape=Sphere((1, 0.5, 0.5), without a communicator, for inspection and planning: index ranges, subdomains and owner ranks for every rank. `show_3d` draws one box per rank (with `simgeometry[interactive]`) and `show` one rectangle per rank of a 2D decomposition. -See the [parallel grids tutorial](https://max-models.github.io/simgeometry/tutorials/parallel-grids/). +See the [parallel grids example](https://max-models.github.io/simgeometry/examples/parallel-grids/). ## Profiling diff --git a/docs/.gitignore b/docs/.gitignore index 2a97a9b..d340f5a 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -4,5 +4,5 @@ dist/ node_modules/ # generated by starlight-notebooks (regenerated in CI) .cache/ -public/tutorials/ -src/content/docs/tutorials/*.mdx +public/examples/ +src/content/docs/examples/*.mdx diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 4ce6111..0a159ff 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -10,7 +10,7 @@ export default defineDocs({ pythonPackage: 'simgeometry', nav: [ { label: 'Getting started', link: '/getting-started/installation/' }, - { label: 'Tutorials', link: '/tutorials/' }, + { label: 'Examples', link: '/examples/' }, { label: 'Development', link: '/development/docs/' }, { label: 'API', link: '/api/simgeometry/' }, ], @@ -24,8 +24,8 @@ export default defineDocs({ ], }, { - label: 'Tutorials', - items: [{ autogenerate: { directory: 'tutorials' } }], + label: 'Examples', + items: [{ autogenerate: { directory: 'examples' } }], }, { label: 'Development', diff --git a/docs/package.json b/docs/package.json index 332ddeb..e62f0c6 100644 --- a/docs/package.json +++ b/docs/package.json @@ -20,5 +20,8 @@ "@max-models/starlight-theme": "github:max-models/starlight-theme#v0.6.0", "astro": "^7.4.2", "sharp": "^0.35.3" + }, + "starlight-notebooks": { + "section": "examples" } } diff --git a/docs/src/content/docs/development/docs.md b/docs/src/content/docs/development/docs.md index 2d4434b..e06aef5 100644 --- a/docs/src/content/docs/development/docs.md +++ b/docs/src/content/docs/development/docs.md @@ -1,14 +1,15 @@ --- title: Building the docs -description: How this documentation site is built from Markdown pages, Jupyter tutorials and docstrings. +description: How this documentation site is built from Markdown pages, Jupyter notebooks and docstrings. --- The site in `docs/` is built with [Astro](https://astro.build/) and [Starlight](https://starlight.astro.build/). It has three kinds of pages: - **Hand-written pages** in `docs/src/content/docs/`, as Markdown or MDX. -- **Tutorials**, converted from the Jupyter notebooks in `tutorials/` by `starlight-notebooks`. - Each notebook is executed and its outputs (text, images, tables) are written into an MDX page. +- **Examples**, converted from the Jupyter notebooks in `tutorials/` by `starlight-notebooks` + (the `section` setting in `docs/package.json` publishes them under `examples/`). Each + notebook is executed and its outputs (text, images, tables) are written into an MDX page. - **API reference**, generated from the docstrings by [starlight-pydocs](https://ewels.github.io/starlight-pydocs/), one page per module. @@ -37,9 +38,9 @@ make docs-clean # remove generated pages and build output `make docs-notebooks` only reruns a notebook whose cells changed since the last run; pass `FORCE=1` to rerun all of them. -## Writing tutorials +## Writing example notebooks -Put a Jupyter notebook in `tutorials/`. Tutorials are authored only as `.ipynb` files. Its first `# Heading` becomes the page title and the first +Put a Jupyter notebook in `tutorials/`. Examples are authored only as `.ipynb` files. Its first `# Heading` becomes the page title and the first paragraph its description. Cell tags control the output: Set `docs_order` in the notebook metadata to control its sidebar position. diff --git a/docs/src/content/docs/development/publishing.md b/docs/src/content/docs/development/publishing.md index bb242d0..08bd43b 100644 --- a/docs/src/content/docs/development/publishing.md +++ b/docs/src/content/docs/development/publishing.md @@ -6,7 +6,7 @@ description: Publish the package to PyPI and the documentation to GitHub Pages. ## Documentation The `Docs` workflow builds the site on every push and pull request to `main` and `devel`. It -executes the tutorials, generates the API reference and uploads the site. Pushes to `devel` +executes the notebooks, generates the API reference and uploads the site. Pushes to `devel` also deploy it to GitHub Pages at https://max-models.github.io/simgeometry/. Enable GitHub Pages once in the repository settings, with "GitHub Actions" as the source. diff --git a/docs/src/content/docs/getting-started/geometry-api.md b/docs/src/content/docs/getting-started/geometry-api.md index 20d8c8c..fff3632 100644 --- a/docs/src/content/docs/getting-started/geometry-api.md +++ b/docs/src/content/docs/getting-started/geometry-api.md @@ -181,7 +181,7 @@ and `conductors` lists the names with one. `labels()`: 0 outside every object, the last listed object winning in overlaps. The voltage is NaN outside the conductors, including inside an object without a voltage; `values.conductor` marks the nodes a field solver fixes. See the -[conductors tutorial](/simgeometry/tutorials/conductors-and-level-sets/) for an +[conductors example](/simgeometry/examples/conductors-and-level-sets/) for an Einzel lens. ## Exact geometry and numerical approximations @@ -223,7 +223,7 @@ every vertex on the circle. Loops keep the shape on their left: outer boundaries counter-clockwise and holes clockwise. `max_spacing` can also be a function of `(2, m)` points returning `(m,)` spacings, which grades the vertices (fine near an aperture tip, coarse far from it); see the -[boundary outlines tutorial](/simgeometry/tutorials/boundary-outlines/). Transformed 2D +[boundary outlines example](/simgeometry/examples/boundary-outlines/). Transformed 2D shapes transform their source's loops. Boolean 2D shapes (`a | b`, `a & b`, `a - b`, nested) have outlines through shapely (`pip install 'simgeometry[clip]'`): each operand enters as the polygon of its own outline, so curved operands are approximated by inscribed @@ -306,5 +306,5 @@ from the surface than half their length are not searched. `diagnose(grid)` reports node overlaps, objects without solid nodes, multiple-hit edges, and very short edge distances. These observations do not prove that all -features are resolved. See the [cut-edge tutorial](/simgeometry/tutorials/grid-intersections-and-cut-edges/) -and the [sampling/export tutorial](/simgeometry/tutorials/sampling-export-and-diagnostics/). +features are resolved. See the [cut-edge example](/simgeometry/examples/grid-intersections-and-cut-edges/) +and the [sampling/export example](/simgeometry/examples/sampling-export-and-diagnostics/). diff --git a/docs/src/content/docs/getting-started/quickstart.md b/docs/src/content/docs/getting-started/quickstart.md index 97cebc1..ecf555e 100644 --- a/docs/src/content/docs/getting-started/quickstart.md +++ b/docs/src/content/docs/getting-started/quickstart.md @@ -16,10 +16,10 @@ mask = circle.node_mask(grid) `mask` marks the grid nodes inside the circle. -Continue with [grids and coordinates](/simgeometry/tutorials/grids-and-coordinates/) to +Continue with [grids and coordinates](/simgeometry/examples/grids-and-coordinates/) to understand node spacing and periodic axes, or [shapes and node -masks](/simgeometry/tutorials/shapes-and-node-masks/) for point queries and index lists. -For MPI layouts, see [parallel grids](/simgeometry/tutorials/parallel-grids/). +masks](/simgeometry/examples/shapes-and-node-masks/) for point queries and index lists. +For MPI layouts, see [parallel grids](/simgeometry/examples/parallel-grids/). With `simgeometry[plot]`, inspect objects and sections directly: diff --git a/docs/src/content/docs/index.mdx b/docs/src/content/docs/index.mdx index e6df316..679abdd 100644 --- a/docs/src/content/docs/index.mdx +++ b/docs/src/content/docs/index.mdx @@ -29,6 +29,6 @@ import { Card, CardGrid, LinkCard } from '@astrojs/starlight/components'; - + diff --git a/src/simgeometry/README.md b/src/simgeometry/README.md index 1747da2..964da4f 100644 --- a/src/simgeometry/README.md +++ b/src/simgeometry/README.md @@ -39,7 +39,7 @@ names, overlap precedence and conductor voltages. Analytic shapes have a collections propagate and the crossing and distance queries use when present. `cut_edges` owns solver-independent intersections; `fractions` estimates cell/face occupancy. Picnic owns stencil coefficients and -its minimum-distance regularization. Tutorials live in `tutorials/` and are +its minimum-distance regularization. Example notebooks live in `tutorials/` and are executed into the documentation site. ## Subpackages diff --git a/tutorials/index.ipynb b/tutorials/index.ipynb index fb902f3..0ed604f 100644 --- a/tutorials/index.ipynb +++ b/tutorials/index.ipynb @@ -5,23 +5,23 @@ "id": "0", "metadata": {}, "source": [ - "# Tutorials\n", + "# Examples\n", "\n", "Run these notebooks to explore simgeometry with real grids and shapes. Each page includes its source notebook for download.\n", "\n", - "1. [Grids and coordinates](/simgeometry/tutorials/grids-and-coordinates/) explains node locations, masks, and periodic axes.\n", - "2. [Shapes and node masks](/simgeometry/tutorials/shapes-and-node-masks/) selects grid nodes, tests point batches, and plots a circle.\n", - "3. [Parallel grids](/simgeometry/tutorials/parallel-grids/) covers MPI decomposition metadata, local index ranges, and neighbouring ranks.\n", + "1. [Grids and coordinates](/simgeometry/examples/grids-and-coordinates/) explains node locations, masks, and periodic axes.\n", + "2. [Shapes and node masks](/simgeometry/examples/shapes-and-node-masks/) selects grid nodes, tests point batches, and plots a circle.\n", + "3. [Parallel grids](/simgeometry/examples/parallel-grids/) covers MPI decomposition metadata, local index ranges, and neighbouring ranks.\n", "\n", - "4. [Inspecting geometry](/simgeometry/tutorials/inspecting-geometry/) covers plotting, 3D surfaces and interactive clipping.\n", - "5. [Slices and coordinate sampling](/simgeometry/tutorials/slices-and-coordinate-sampling/) covers labelled evaluation and arbitrary planes.\n", - "6. [Building composite shapes](/simgeometry/tutorials/building-composite-shapes/) combines, transforms and names objects.\n", - "7. [Grid intersections and cut edges](/simgeometry/tutorials/grid-intersections-and-cut-edges/) covers surface crossings and cell fractions.\n", - "8. [Sampling, export and diagnostics](/simgeometry/tutorials/sampling-export-and-diagnostics/) covers point clouds, portable meshes and resolution checks.\n", - "9. [Boundary outlines](/simgeometry/tutorials/boundary-outlines/) turns 2D shapes into vertex loops and panels for boundary-element solvers, and finds where particle steps cross a boundary.\n", - "10. [Conductors and level sets](/simgeometry/tutorials/conductors-and-level-sets/) builds an Einzel lens from ring electrodes with voltages, reads Dirichlet values on a grid, and uses level sets for exact distances and edge crossings.\n", + "4. [Inspecting geometry](/simgeometry/examples/inspecting-geometry/) covers plotting, 3D surfaces and interactive clipping.\n", + "5. [Slices and coordinate sampling](/simgeometry/examples/slices-and-coordinate-sampling/) covers labelled evaluation and arbitrary planes.\n", + "6. [Building composite shapes](/simgeometry/examples/building-composite-shapes/) combines, transforms and names objects.\n", + "7. [Grid intersections and cut edges](/simgeometry/examples/grid-intersections-and-cut-edges/) covers surface crossings and cell fractions.\n", + "8. [Sampling, export and diagnostics](/simgeometry/examples/sampling-export-and-diagnostics/) covers point clouds, portable meshes and resolution checks.\n", + "9. [Boundary outlines](/simgeometry/examples/boundary-outlines/) turns 2D shapes into vertex loops and panels for boundary-element solvers, and finds where particle steps cross a boundary.\n", + "10. [Conductors and level sets](/simgeometry/examples/conductors-and-level-sets/) builds an Einzel lens from ring electrodes with voltages, reads Dirichlet values on a grid, and uses level sets for exact distances and edge crossings.\n", "\n", - "See [Building the docs](/simgeometry/development/docs/) to add a tutorial." + "See [Building the docs](/simgeometry/development/docs/) to add an example." ] } ], From c70ec66d1230055fee912c3b513ed00386274055 Mon Sep 17 00:00:00 2001 From: Max Date: Fri, 30 Oct 2026 08:54:33 +0100 Subject: [PATCH 26/36] Build the docs with the theme's major tag v0. (#40) --- .github/workflows/docs.yml | 2 +- docs/package-lock.json | 6 +++--- docs/package.json | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 10afb89..a90914b 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -22,7 +22,7 @@ concurrency: jobs: docs: - uses: max-models/starlight-theme/.github/workflows/docs.yml@v0.6.0 + uses: max-models/starlight-theme/.github/workflows/docs.yml@v0 with: node-version: "24" # the PyVista scenes in the examples render under a virtual display diff --git a/docs/package-lock.json b/docs/package-lock.json index 1cda0ce..97e015a 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.1", "dependencies": { "@astrojs/starlight": "^0.42.7", - "@max-models/starlight-theme": "github:max-models/starlight-theme#v0.6.0", + "@max-models/starlight-theme": "github:max-models/starlight-theme#v0", "astro": "^7.4.2", "sharp": "^0.35.3" }, @@ -1223,8 +1223,8 @@ "license": "MIT" }, "node_modules/@max-models/starlight-theme": { - "version": "0.6.0", - "resolved": "git+ssh://git@github.com/max-models/starlight-theme.git#62427a589ba327fb04664639ed8250e344dabb61", + "version": "0.7.0", + "resolved": "git+ssh://git@github.com/max-models/starlight-theme.git#35ba7be44b183dd82e92f638a46d095a3fbaa23d", "license": "MIT", "workspaces": [ "example/docs" diff --git a/docs/package.json b/docs/package.json index e62f0c6..54994e8 100644 --- a/docs/package.json +++ b/docs/package.json @@ -17,7 +17,7 @@ }, "dependencies": { "@astrojs/starlight": "^0.42.7", - "@max-models/starlight-theme": "github:max-models/starlight-theme#v0.6.0", + "@max-models/starlight-theme": "github:max-models/starlight-theme#v0", "astro": "^7.4.2", "sharp": "^0.35.3" }, From 4f5fbfef0ece053501100e802cf28886d9d5416a Mon Sep 17 00:00:00 2001 From: Max Date: Fri, 30 Oct 2026 17:01:49 +0100 Subject: [PATCH 27/36] Bump plasma-plots in the lock. (#41) --- uv.lock | 252 ++++++++++++++++++++++++++++---------------------------- 1 file changed, 126 insertions(+), 126 deletions(-) diff --git a/uv.lock b/uv.lock index ca15887..4a31ea3 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 5 +revision = 3 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", @@ -730,7 +730,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130, upload-time = "2025-04-15T17:47:53.79Z" } wheels = [ @@ -802,7 +802,7 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } wheels = [ @@ -895,7 +895,7 @@ resolution-markers = [ "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/83/5a/a55177dd22553a277388e8a1b3220e92de91bacb28356cdc73caa240121d/contourpy-1.4.0.tar.gz", hash = "sha256:20156f5a1ac4f8ce02656e39a61e82164a3d359796dc8026f75b062783d500e1", size = 13323726, upload-time = "2026-09-11T19:05:05.808Z" } wheels = [ @@ -1172,12 +1172,12 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "attrs" }, - { name = "docstring-parser" }, - { name = "rich" }, - { name = "rich-rst" }, - { name = "tomli" }, - { name = "typing-extensions" }, + { name = "attrs", marker = "python_full_version < '3.11'" }, + { name = "docstring-parser", marker = "python_full_version < '3.11'" }, + { name = "rich", marker = "python_full_version < '3.11'" }, + { name = "rich-rst", marker = "python_full_version < '3.11'" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c1/08/a444f3cca86cb27bc256831fc8c42b578390f4087bf6631cccb5334bd9ed/cyclopts-4.25.3.tar.gz", hash = "sha256:7d3a067bc7699a1a65fe2ea0b092a925855bb711a05adc1e65c5b9ba307acea4", size = 202814, upload-time = "2026-09-17T16:25:16.892Z" } wheels = [ @@ -1203,10 +1203,10 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "attrs" }, - { name = "docstring-parser" }, - { name = "rich" }, - { name = "rich-rst" }, + { name = "attrs", marker = "python_full_version >= '3.11'" }, + { name = "docstring-parser", marker = "python_full_version >= '3.11'" }, + { name = "rich", marker = "python_full_version >= '3.11'" }, + { name = "rich-rst", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/28/c1/3debeeb6e0eb74a51d6f8cf1f273ed7c479e3321631cbf89fb9700e65e28/cyclopts-5.2.0.tar.gz", hash = "sha256:b63c1b1beaadf3ead19214385a0f90b990f152c4107c174e1724c45dc71e9541", size = 246542, upload-time = "2026-10-06T15:02:39.212Z" } wheels = [ @@ -1287,7 +1287,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -1764,8 +1764,8 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, - { name = "pillow" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "pillow", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/81/c8/2b56274457e4fadcfa86bf7c0fb93f19e124ab8f38f4bfa4372423052046/imageio-2.38.0.tar.gz", hash = "sha256:fe1d406862f6bc2930e8ec662d7bc40d4370cb76e3390dcd33819560d2708531", size = 403333, upload-time = "2026-09-28T07:44:08.101Z" } wheels = [ @@ -1791,9 +1791,9 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "pillow" }, + { name = "pillow", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f3/cd/69e4ac55b6dafdd2b5f32075236841a3945dea7323d0232d80f28c37cea8/imageio-2.38.1.tar.gz", hash = "sha256:6769f1f01c4dd46448307863a787c9a22fa4dbe11c0c88f525c6e410fdfd7983", size = 403667, upload-time = "2026-10-08T14:35:32.961Z" } wheels = [ @@ -1826,19 +1826,19 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "appnope", marker = "sys_platform == 'darwin'" }, - { name = "comm" }, - { name = "debugpy" }, - { name = "ipython", version = "8.39.0", source = { registry = "https://pypi.org/simple" } }, - { name = "jupyter-client" }, - { name = "jupyter-core" }, - { name = "matplotlib-inline" }, - { name = "nest-asyncio2" }, - { name = "packaging" }, - { name = "psutil" }, - { name = "pyzmq" }, - { name = "tornado" }, - { name = "traitlets" }, + { name = "appnope", marker = "python_full_version < '3.11' and sys_platform == 'darwin'" }, + { name = "comm", marker = "python_full_version < '3.11'" }, + { name = "debugpy", marker = "python_full_version < '3.11'" }, + { name = "ipython", version = "8.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "jupyter-client", marker = "python_full_version < '3.11'" }, + { name = "jupyter-core", marker = "python_full_version < '3.11'" }, + { name = "matplotlib-inline", marker = "python_full_version < '3.11'" }, + { name = "nest-asyncio2", marker = "python_full_version < '3.11'" }, + { name = "packaging", marker = "python_full_version < '3.11'" }, + { name = "psutil", marker = "python_full_version < '3.11'" }, + { name = "pyzmq", marker = "python_full_version < '3.11'" }, + { name = "tornado", marker = "python_full_version < '3.11'" }, + { name = "traitlets", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3d/c4/e4a38f579de4225a561305666f7541cdabb30075def2aa1ac17bd73c1fb5/ipykernel-7.3.0.tar.gz", hash = "sha256:9acaaaf97d16355166e4085afe9d225bfbdf2b7ef520f9df3be8f2b248275e09", size = 184899, upload-time = "2026-06-10T08:41:25.481Z" } wheels = [ @@ -1864,18 +1864,18 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "appnope", marker = "sys_platform == 'darwin'" }, - { name = "comm" }, - { name = "debugpy" }, - { name = "ipython", version = "9.17.1", source = { registry = "https://pypi.org/simple" } }, - { name = "jupyter-client" }, - { name = "jupyter-core" }, - { name = "matplotlib-inline" }, - { name = "nest-asyncio2" }, - { name = "packaging" }, - { name = "pyzmq" }, - { name = "tornado" }, - { name = "traitlets" }, + { name = "appnope", marker = "python_full_version >= '3.11' and sys_platform == 'darwin'" }, + { name = "comm", marker = "python_full_version >= '3.11'" }, + { name = "debugpy", marker = "python_full_version >= '3.11'" }, + { name = "ipython", version = "9.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "jupyter-client", marker = "python_full_version >= '3.11'" }, + { name = "jupyter-core", marker = "python_full_version >= '3.11'" }, + { name = "matplotlib-inline", marker = "python_full_version >= '3.11'" }, + { name = "nest-asyncio2", marker = "python_full_version >= '3.11'" }, + { name = "packaging", marker = "python_full_version >= '3.11'" }, + { name = "pyzmq", marker = "python_full_version >= '3.11'" }, + { name = "tornado", marker = "python_full_version >= '3.11'" }, + { name = "traitlets", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/05/e5499c8b762387d83926bf0389e8fff888c9bf6e7af30566a8527bc12f4a/ipykernel-7.4.0.tar.gz", hash = "sha256:4330114d22b9b33575b2c7c68753fc8faabbbcfbc60b0cdff9f14dd4ce63742f", size = 192731, upload-time = "2026-09-29T16:33:11.468Z" } wheels = [ @@ -1890,17 +1890,17 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "decorator" }, - { name = "exceptiongroup" }, - { name = "jedi" }, - { name = "matplotlib-inline" }, - { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit" }, - { name = "pygments" }, - { name = "stack-data" }, - { name = "traitlets" }, - { name = "typing-extensions" }, + { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, + { name = "decorator", marker = "python_full_version < '3.11'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "jedi", marker = "python_full_version < '3.11'" }, + { name = "matplotlib-inline", marker = "python_full_version < '3.11'" }, + { name = "pexpect", marker = "python_full_version < '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "python_full_version < '3.11'" }, + { name = "pygments", marker = "python_full_version < '3.11'" }, + { name = "stack-data", marker = "python_full_version < '3.11'" }, + { name = "traitlets", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/40/18/f8598d287006885e7136451fdea0755af4ebcbfe342836f24deefaed1164/ipython-8.39.0.tar.gz", hash = "sha256:4110ae96012c379b8b6db898a07e186c40a2a1ef5d57a7fa83166047d9da7624", size = 5513971, upload-time = "2026-03-27T10:02:13.94Z" } wheels = [ @@ -1926,17 +1926,17 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "ipython-pygments-lexers" }, - { name = "jedi" }, - { name = "matplotlib-inline" }, - { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit" }, - { name = "psutil", marker = "sys_platform != 'cygwin' and sys_platform != 'emscripten'" }, - { name = "pygments" }, - { name = "stack-data" }, - { name = "traitlets" }, - { name = "typing-extensions", marker = "python_full_version < '3.12'" }, + { name = "colorama", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" }, + { name = "ipython-pygments-lexers", marker = "python_full_version >= '3.11'" }, + { name = "jedi", marker = "python_full_version >= '3.11'" }, + { name = "matplotlib-inline", marker = "python_full_version >= '3.11'" }, + { name = "pexpect", marker = "python_full_version >= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "python_full_version >= '3.11'" }, + { name = "psutil", marker = "python_full_version >= '3.11' and sys_platform != 'cygwin' and sys_platform != 'emscripten'" }, + { name = "pygments", marker = "python_full_version >= '3.11'" }, + { name = "stack-data", marker = "python_full_version >= '3.11'" }, + { name = "traitlets", marker = "python_full_version >= '3.11'" }, + { name = "typing-extensions", marker = "python_full_version == '3.11.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b9/32/99451b1283ec5d92ad77073f12e1c667dc10775384d8f15c2914207149dd/ipython-9.17.1.tar.gz", hash = "sha256:8919be8c27f20a6f4423145028063f6637b42a03ce57665bb12015ee1f073529", size = 4539289, upload-time = "2026-09-01T08:29:32.6Z" } wheels = [ @@ -1948,7 +1948,7 @@ name = "ipython-pygments-lexers" version = "1.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pygments" }, + { name = "pygments", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } wheels = [ @@ -2593,15 +2593,15 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "contourpy", version = "1.3.2", source = { registry = "https://pypi.org/simple" } }, - { name = "cycler" }, - { name = "fonttools", version = "4.65.0", source = { registry = "https://pypi.org/simple" } }, - { name = "kiwisolver" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, - { name = "packaging" }, - { name = "pillow" }, - { name = "pyparsing" }, - { name = "python-dateutil" }, + { name = "contourpy", version = "1.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "cycler", marker = "python_full_version < '3.11'" }, + { name = "fonttools", version = "4.65.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "kiwisolver", marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "packaging", marker = "python_full_version < '3.11'" }, + { name = "pillow", marker = "python_full_version < '3.11'" }, + { name = "pyparsing", marker = "python_full_version < '3.11'" }, + { name = "python-dateutil", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/63/1b/4be5be87d43d327a0cf4de1a56e86f7f84c89312452406cf122efe2839e6/matplotlib-3.10.9.tar.gz", hash = "sha256:fd66508e8c6877d98e586654b608a0456db8d7e8a546eb1e2600efd957302358", size = 34811233, upload-time = "2026-04-24T00:14:13.539Z" } wheels = [ @@ -2680,17 +2680,17 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "contourpy", version = "1.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "contourpy", version = "1.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "contourpy", version = "1.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "cycler" }, - { name = "fonttools", version = "4.66.1", source = { registry = "https://pypi.org/simple" } }, - { name = "kiwisolver" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "cycler", marker = "python_full_version >= '3.11'" }, + { name = "fonttools", version = "4.66.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "kiwisolver", marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "packaging" }, - { name = "pillow" }, - { name = "pyparsing" }, - { name = "python-dateutil" }, + { name = "packaging", marker = "python_full_version >= '3.11'" }, + { name = "pillow", marker = "python_full_version >= '3.11'" }, + { name = "pyparsing", marker = "python_full_version >= '3.11'" }, + { name = "python-dateutil", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e7/c8/9aa712a0afb882649424dd8de8ad9aa6235e796e84c6052e8f6dc1598d0d/matplotlib-3.11.2.tar.gz", hash = "sha256:cec596316640f2b394b8f0daa0ea61a8eae82d017b620b9f202befb972a59ea4", size = 32660610, upload-time = "2026-09-11T19:05:31.214Z" } wheels = [ @@ -2800,7 +2800,7 @@ name = "mlx" version = "0.32.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "mlx-metal" }, + { name = "mlx-metal", marker = "sys_platform == 'darwin'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/52/73/f2b18f4bbcfeacd6880e68f2854609d6d2d305e5676586451a6c26664061/mlx-0.32.4-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:60ba41564e8b5018c9d8a5f8ce7a5aaf70c96d8dd98c1a54b854cbada362d471", size = 671799, upload-time = "2026-10-14T21:15:37.06Z" }, @@ -3641,10 +3641,10 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, - { name = "python-dateutil" }, - { name = "pytz" }, - { name = "tzdata" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "python-dateutil", marker = "python_full_version < '3.11'" }, + { name = "pytz", marker = "python_full_version < '3.11'" }, + { name = "tzdata", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } wheels = [ @@ -3716,10 +3716,10 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "python-dateutil" }, - { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, + { name = "python-dateutil", marker = "python_full_version >= '3.11'" }, + { name = "tzdata", marker = "(python_full_version >= '3.11' and sys_platform == 'emscripten') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e2/17/d7b106e05bfa642e8694451e7d3d759c6a241c5386a5d962e4f66c047e06/pandas-3.0.6.tar.gz", hash = "sha256:66b07ef7315a31bfe1089cd3d71a7de781c9dca986762d0b4fe7c0ef17465d10", size = 4667686, upload-time = "2026-09-17T23:23:18.345Z" } wheels = [ @@ -3805,7 +3805,7 @@ name = "pexpect" version = "4.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ptyprocess" }, + { name = "ptyprocess", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') 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 = [ @@ -3909,7 +3909,7 @@ wheels = [ [[package]] name = "plasma-plots" version = "0.2.5" -source = { git = "https://github.com/max-models/plasma-plots?branch=devel#ac6079986a9fa1e681baf3dda5c0e9026989f3a4" } +source = { git = "https://github.com/max-models/plasma-plots?branch=devel#6522a7f1cc50f3b66bcfdb5ffe1e302d660c1ec9" } dependencies = [ { name = "ipywidgets" }, { name = "matplotlib", version = "3.10.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -4247,7 +4247,7 @@ name = "pyobjc-framework-cocoa" version = "12.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, + { name = "pyobjc-core", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/75/76/49c6da2c6a831020b4854ba20079d5a1030474bffc776b7b73c2eeff8c15/pyobjc_framework_cocoa-12.2.2.tar.gz", hash = "sha256:c96c0ef69a71afbbb0e6a7d594b455c5fe47d62e0db376ee7a2b4b828c16ace9", size = 3132831, upload-time = "2026-08-11T19:44:02.288Z" } wheels = [ @@ -5003,14 +5003,14 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "imageio", version = "2.38.0", source = { registry = "https://pypi.org/simple" } }, - { name = "lazy-loader" }, - { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" } }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, - { name = "packaging" }, - { name = "pillow" }, - { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" } }, - { name = "tifffile", version = "2025.5.10", source = { registry = "https://pypi.org/simple" } }, + { name = "imageio", version = "2.38.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "lazy-loader", marker = "python_full_version < '3.11'" }, + { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "packaging", marker = "python_full_version < '3.11'" }, + { name = "pillow", marker = "python_full_version < '3.11'" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "tifffile", version = "2025.5.10", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c7/a8/3c0f256012b93dd2cb6fda9245e9f4bff7dc0486880b248005f15ea2255e/scikit_image-0.25.2.tar.gz", hash = "sha256:e5a37e6cd4d0c018a7a55b9d601357e3382826d3888c10d0213fc63bff977dde", size = 22693594, upload-time = "2025-02-18T18:05:24.538Z" } wheels = [ @@ -5056,17 +5056,17 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "imageio", version = "2.38.1", source = { registry = "https://pypi.org/simple" } }, - { name = "lazy-loader" }, - { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "imageio", version = "2.38.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "lazy-loader", marker = "python_full_version >= '3.11'" }, + { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "networkx", version = "3.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "packaging" }, - { name = "pillow" }, - { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "packaging", marker = "python_full_version >= '3.11'" }, + { name = "pillow", marker = "python_full_version >= '3.11'" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "scipy", version = "1.18.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "tifffile", version = "2026.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "tifffile", version = "2026.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "tifffile", version = "2026.9.20", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a1/b4/2528bb43c67d48053a7a649a9666432dc307d66ba02e3a6d5c40f46655df/scikit_image-0.26.0.tar.gz", hash = "sha256:f5f970ab04efad85c24714321fcc91613fcb64ef2a892a13167df2f3e59199fa", size = 22729739, upload-time = "2025-12-20T17:12:21.824Z" } @@ -5129,7 +5129,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" } wheels = [ @@ -5190,7 +5190,7 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } wheels = [ @@ -5272,7 +5272,7 @@ resolution-markers = [ "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7e/74/66de6258867beb2ef08f35f9f2ac017a52cacd5081714d239ff1a442d458/scipy-1.18.1.tar.gz", hash = "sha256:52c4b7422442aba924d03ad4019852b08a92e64ea187b933135687bfe2747307", size = 30781235, upload-time = "2026-08-21T23:28:50.599Z" } wheels = [ @@ -5377,7 +5377,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/4d/bc/0989043118a27cccb4e906a46b7565ce36ca7b57f5a18b78f4f1b0f72d9d/shapely-2.1.2.tar.gz", hash = "sha256:2ed4ecb28320a433db18a5bf029986aa8afcfd740745e78847e330d5d94922a9", size = 315489, upload-time = "2025-09-24T13:51:41.432Z" } wheels = [ @@ -5458,7 +5458,7 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f3/ab/924b6e202f796d270a3041a230151f7908db5ea48c74effe6f8023e9bd05/shapely-2.2.0.tar.gz", hash = "sha256:e8865e553d874a1ec4a032057ea81fca9def37b188cd8fb550af3b3480b3f88c", size = 380326, upload-time = "2026-10-07T09:18:01.001Z" } @@ -5770,7 +5770,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/44/d0/18fed0fc0916578a4463f775b0fbd9c5fed2392152d039df2fb533bfdd5d/tifffile-2025.5.10.tar.gz", hash = "sha256:018335d34283aa3fd8c263bae5c3c2b661ebc45548fde31504016fcae7bf1103", size = 365290, upload-time = "2025-05-10T19:22:34.386Z" } wheels = [ @@ -5787,7 +5787,7 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c5/cb/2f6d79c7576e22c116352a801f4c3c8ace5957e9aced862012430b62e14f/tifffile-2026.3.3.tar.gz", hash = "sha256:d9a1266bed6f2ee1dd0abde2018a38b4f8b2935cb843df381d70ac4eac5458b7", size = 388745, upload-time = "2026-03-03T19:14:38.134Z" } wheels = [ @@ -5810,7 +5810,7 @@ resolution-markers = [ "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/92/66/634db78ebad513038d753830dd8815eea26278b5463ba0f43198b0c24c4e/tifffile-2026.9.20.tar.gz", hash = "sha256:30e145a7042ce7143ae50a50fe8b7221b0070aae22adab8f9e79a264be6b5cdc", size = 449417, upload-time = "2026-09-21T03:59:40.055Z" } wheels = [ @@ -6247,9 +6247,9 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, - { name = "packaging" }, - { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "packaging", marker = "python_full_version < '3.11'" }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/19/ec/e50d833518f10b0c24feb184b209bb6856f25b919ba8c1f89678b930b1cd/xarray-2025.6.1.tar.gz", hash = "sha256:a84f3f07544634a130d7dc615ae44175419f4c77957a7255161ed99c69c7c8b0", size = 3003185, upload-time = "2025-06-12T03:04:09.099Z" } wheels = [ @@ -6275,10 +6275,10 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "packaging" }, - { name = "pandas", version = "3.0.6", source = { registry = "https://pypi.org/simple" } }, + { name = "packaging", marker = "python_full_version >= '3.11'" }, + { name = "pandas", version = "3.0.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ff/f5/781c70b234f54e0401f4f7c4427a7486da38d50dbb138c7d7c55144b3d86/xarray-2026.9.0.tar.gz", hash = "sha256:6abc69694c22fa1f0fb2f357ff4e41d88beb4477ed71091f944b7dbf67ed54fe", size = 3176922, upload-time = "2026-09-29T23:06:25.807Z" } wheels = [ From fe1cd1f9ba27c1afc73861d0301be9892147bcb1 Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 31 Oct 2026 22:47:06 +0100 Subject: [PATCH 28/36] Find the first crossing of a GeometryCollection per object, with the surface hit and the weight per surface. (#42) --- .../docs/getting-started/geometry-api.md | 16 +++ src/simgeometry/crossings.py | 55 +++++++++- .../tests/test_collection_crossings.py | 101 ++++++++++++++++++ 3 files changed, 170 insertions(+), 2 deletions(-) create mode 100644 src/simgeometry/tests/test_collection_crossings.py diff --git a/docs/src/content/docs/getting-started/geometry-api.md b/docs/src/content/docs/getting-started/geometry-api.md index fff3632..2bccc0c 100644 --- a/docs/src/content/docs/getting-started/geometry-api.md +++ b/docs/src/content/docs/getting-started/geometry-api.md @@ -254,6 +254,22 @@ be missed, and their normals are estimated (`exact` is `False`). A crossing at t start of a step is not reported, so a particle stopped on a surface is not counted twice; grazing contacts are not crossings. +On a `GeometryCollection` each object is tested on its own (exactly where it is) and the +earliest crossing wins: `hits.surface` is the index into `hits.names` of the object hit +first (-1 for none), and `hits.per_surface(power)` sums the marker weights each object +receives, for the power on the dump plates, calorimeter panels and duct of a beamline: + +```python +walls = GeometryCollection( + { + "dump": Cuboid((1, 0, 0.3), (1, 0.6, 0.02)), + "calorimeter": Cuboid((5, 0, 0), (0.02, 0.8, 0.8)).rotated(30.0), + } +) +hits = walls.crossings(old_positions, positions) +power = hits.per_surface(weights) # {"dump": ..., "calorimeter": ...} +``` + For many markers against fixed 2D obstacles, `simgeometry.polygon_objects.polygon_objects(objects)` packs the `Polygon` and `Aperture2D` objects (other shapes are left out) into flat arrays, and `packed.absorb(positions, alive, start=old_positions)` absorbs, in one compiled pass, diff --git a/src/simgeometry/crossings.py b/src/simgeometry/crossings.py index 8c48616..98c00ec 100644 --- a/src/simgeometry/crossings.py +++ b/src/simgeometry/crossings.py @@ -21,6 +21,11 @@ are not crossings. Normals have unit length and point out of the region where ``contains`` is true, so they flip for inverted shapes. An open curve (a shape with ``closed = False``) has no inside: its normal faces the incoming step. + +A :class:`~simgeometry.collection.GeometryCollection` is a set of absorbing surfaces +(dump plates, calorimeter panels, a duct): each object is tested on its own path +(analytic where it is), the earliest crossing wins and ``surface`` tells which object +was hit, so :meth:`Crossings.per_surface` sums the power each surface receives. """ from __future__ import annotations @@ -50,6 +55,10 @@ class Crossings: ``hit`` is ``(N,)``; ``fraction`` is ``(N,)`` in ``(0, 1]``; ``point`` and ``normal`` are ``(ndim, N)``. Where ``hit`` is false they are NaN. ``exact`` is true for analytic results and false for sampled ones. + + For a collection, ``surface`` ``(N,)`` is the index into ``names`` of the object + hit first (-1 for none); for a single shape it is 0 where ``hit`` and ``names`` is + empty. """ hit: Any @@ -57,6 +66,21 @@ class Crossings: point: Any normal: Any exact: bool + surface: Any = None + names: tuple[str, ...] = () + + def per_surface(self, weights=None) -> dict[str, float]: + """Sum ``weights`` ``(N,)`` (default 1, a count) of the steps hitting each surface. + + Returns ``{name: total}`` for every object of the collection, in its order. + """ + if not self.names: + raise ValueError("per_surface needs the crossings of a GeometryCollection") + surface = np.asarray(xp.to_numpy(self.surface)) + w = np.ones(surface.shape) if weights is None else weights + w = np.broadcast_to(np.asarray(xp.to_numpy(w), float), surface.shape) + totals = np.bincount(surface[surface >= 0], w[surface >= 0], len(self.names)) + return dict(zip(self.names, totals.tolist(), strict=True)) def __len__(self) -> int: return int(self.hit.shape[0]) @@ -362,6 +386,22 @@ def _first(shape, a, d, samples, iterations): return (*_sampled(shape, a, d, samples, iterations), False) +def _collection(shape, a, d, samples, iterations): + """First crossing over the objects of a collection, with the index of the object.""" + t = xp.full(a.shape[1], xp.inf) + normal = xp.full(a.shape, xp.nan) + surface = xp.full(a.shape[1], -1, dtype=xp.int64) + exact = True + for index, obj in enumerate(shape.objects): + t_obj, n_obj, e_obj = _first(obj, a, d, samples, iterations) + exact = exact and e_obj + closer = t_obj < t + t = xp.where(closer, t_obj, t) + normal = xp.where(closer[None, :], n_obj, normal) + surface = xp.where(closer, index, surface) + return t, normal, exact, surface + + @sp.profile("simgeometry.crossings", tags=_PROFILE_TAGS) def crossings(shape, start, end, *, samples: int = 64, iterations: int = 40): """Return the first boundary crossing of each segment ``start[:, i] -> end[:, i]``. @@ -376,12 +416,23 @@ def crossings(shape, start, end, *, samples: int = 64, iterations: int = 40): raise ValueError("samples must be an integer >= 2") if int(iterations) != iterations or iterations < 1: raise ValueError("iterations must be a positive integer") - t, normal, exact = _first(shape, a, b - a, int(samples), int(iterations)) + from simgeometry.collection import GeometryCollection + + names = () + if isinstance(shape, GeometryCollection): + names = shape.names + t, normal, exact, surface = _collection( + shape, a, b - a, int(samples), int(iterations) + ) + else: + t, normal, exact = _first(shape, a, b - a, int(samples), int(iterations)) + surface = None hit = xp.isfinite(t) + surface = xp.where(hit, 0 if surface is None else surface, -1) fraction = xp.where(hit, t, xp.nan) point = xp.where(hit[None, :], a + xp.where(hit, t, 0.0)[None, :] * (b - a), xp.nan) normal = xp.where(hit[None, :], normal, xp.nan) - return Crossings(hit, fraction, point, normal, exact) + return Crossings(hit, fraction, point, normal, exact, surface, names) def _transformed(shape, a, d, samples, iterations): diff --git a/src/simgeometry/tests/test_collection_crossings.py b/src/simgeometry/tests/test_collection_crossings.py new file mode 100644 index 0000000..ddd4201 --- /dev/null +++ b/src/simgeometry/tests/test_collection_crossings.py @@ -0,0 +1,101 @@ +"""Tests for the first crossing of steps against a collection of 3D absorbing surfaces.""" + +import cunumpy as xp +import numpy as np +import pytest + +from simgeometry import Cuboid, GeometryCollection, Sphere +from simgeometry.objects import SlantedTube + + +def host(array): + return np.asarray(xp.to_numpy(array)) + + +def beamline(): + """Two dump plates, a duct and a calorimeter panel tilted by 30 degrees, along x.""" + return GeometryCollection( + { + "dump_upper": Cuboid((1.0, 0.0, 0.3), (1.0, 0.6, 0.02)), + "dump_lower": Cuboid((1.0, 0.0, -0.3), (1.0, 0.6, 0.02)), + "duct": SlantedTube((2.0, 0, 0), (4.0, 0, 0), 0.2, 0.25, (1.0, 0, 0)), + "calorimeter": Cuboid((5.0, 0.0, 0.0), (0.02, 0.8, 0.8)).rotated( + 30.0, axis=(0, 0, 1), origin=(5.0, 0.0, 0.0) + ), + } + ) + + +def test_pencil_beam_lands_on_the_panel_it_is_aimed_at(): + geometry = beamline() + n = 5 + # One step per pencil, each far longer than the plates are thick. + start = np.array([[0.5] * n, [0.0] * n, [0.0, 0.29, -0.29, 0.22, 0.0]]) + end = start.copy() + end[0] = 6.0 + end[2, 1], end[2, 2] = 0.4, -0.4 # aimed at the upper and lower dump plates + hits = geometry.crossings(start, end) + assert hits.exact + assert host(hits.surface).tolist() == [3, 0, 1, 2, 3] + names = [hits.names[i] for i in host(hits.surface)] + assert names == ["calorimeter", "dump_upper", "dump_lower", "duct", "calorimeter"] + # The calorimeter's face is at x = 5 - 0.01 / cos(30 deg) on the axis. + x = host(hits.point)[0, 0] + assert x == pytest.approx(5.0 - 0.01 / np.cos(np.pi / 6)) + normal = host(hits.normal)[:, 0] + assert normal == pytest.approx([-np.cos(np.pi / 6), -np.sin(np.pi / 6), 0.0]) + + +def test_power_balance_over_surfaces_and_exit_plane(): + geometry = beamline() + rng = np.random.default_rng(1) + n = 20_000 + start = np.zeros((3, n)) + direction = rng.normal(0.0, 0.1, (3, n)) + direction[0] = 1.0 + end = start + 6.0 * direction + power = rng.uniform(0.5, 1.5, n) + hits = geometry.crossings(start, end) + totals = geometry.crossings(start, end).per_surface(power) + assert list(totals) == list(geometry.names) + escaped = power[~host(hits.hit)].sum() + assert sum(totals.values()) + escaped == pytest.approx(power.sum()) + assert all(value > 0 for value in totals.values()) + assert host(hits.fraction)[host(hits.hit)].max() <= 1.0 + + +def test_earliest_object_wins_whatever_the_order(): + near = Cuboid((1.0, 0, 0), (0.1, 1, 1)) + far = Cuboid((2.0, 0, 0), (0.1, 1, 1)) + start, end = np.zeros((3, 1)), np.array([[3.0], [0], [0]]) + for objects, index in ( + ({"near": near, "far": far}, 0), + ({"far": far, "near": near}, 1), + ): + hits = GeometryCollection(objects).crossings(start, end) + assert host(hits.surface).tolist() == [index] + assert hits.per_surface() == {**dict.fromkeys(objects, 0.0), "near": 1.0} + + +def test_misses_and_sampled_members(): + geometry = GeometryCollection( + { + "ball": Sphere((1.0, 0, 0), 0.2), + "union": Sphere((3, 0, 0), 0.2) | Sphere((3, 1, 0), 0.2), + } + ) + start = np.zeros((3, 3)) + end = np.array([[2.0, 4.0, 4.0], [0, 0, 5.0], [0, 0, 0]]) + hits = geometry.crossings(start, end) + assert not hits.exact # the union is sampled + assert host(hits.surface).tolist() == [0, 0, -1] + assert np.isnan(host(hits.normal)[:, 2]).all() + + +def test_single_shape_surface_and_per_surface_error(): + hits = Sphere((1.0, 0, 0), 0.2).crossings( + np.zeros((3, 2)), np.array([[2.0, 0], [0, 2.0], [0, 0]]) + ) + assert host(hits.surface).tolist() == [0, -1] + with pytest.raises(ValueError, match="GeometryCollection"): + hits.per_surface() From 52fde7fdf9e9c9666de370fcd5e2ba2a2379bb9f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:04:02 +0100 Subject: [PATCH 29/36] Bump the docs group in /docs with 2 updates (#43) Bumps the docs group in /docs with 2 updates: [@astrojs/starlight](https://github.com/withastro/starlight/tree/HEAD/packages/starlight) and [astro](https://github.com/withastro/astro/tree/HEAD/packages/astro). Updates `@astrojs/starlight` from 0.42.7 to 0.42.8 - [Release notes](https://github.com/withastro/starlight/releases) - [Changelog](https://github.com/withastro/starlight/blob/main/packages/starlight/CHANGELOG.md) - [Commits](https://github.com/withastro/starlight/commits/@astrojs/starlight@0.42.8/packages/starlight) Updates `astro` from 7.4.2 to 7.4.4 - [Release notes](https://github.com/withastro/astro/releases) - [Changelog](https://github.com/withastro/astro/blob/main/packages/astro/CHANGELOG.md) - [Commits](https://github.com/withastro/astro/commits/astro@7.4.4/packages/astro) --- updated-dependencies: - dependency-name: "@astrojs/starlight" dependency-version: 0.42.8 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: docs - dependency-name: astro dependency-version: 7.4.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: docs ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docs/package-lock.json | 64 +++++++++++++++++++++--------------------- docs/package.json | 4 +-- 2 files changed, 34 insertions(+), 34 deletions(-) diff --git a/docs/package-lock.json b/docs/package-lock.json index 97e015a..8fbfb72 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -8,9 +8,9 @@ "name": "simgeometry-docs", "version": "0.0.1", "dependencies": { - "@astrojs/starlight": "^0.42.7", + "@astrojs/starlight": "^0.42.8", "@max-models/starlight-theme": "github:max-models/starlight-theme#v0", - "astro": "^7.4.2", + "astro": "^7.4.4", "sharp": "^0.35.3" }, "engines": { @@ -333,9 +333,9 @@ } }, "node_modules/@astrojs/starlight": { - "version": "0.42.7", - "resolved": "https://registry.npmjs.org/@astrojs/starlight/-/starlight-0.42.7.tgz", - "integrity": "sha512-mlbit5iRjLx5eIcLf84Q2CwkrrsEgZrQwKKBK+vRf+ZGiSrwy1UGdT1hUfAIaHrMfcvkDpL+gw3aua7HobiKFg==", + "version": "0.42.8", + "resolved": "https://registry.npmjs.org/@astrojs/starlight/-/starlight-0.42.8.tgz", + "integrity": "sha512-BGP8bmTbdky4OFCV9BNWZedLuzONyUtz754q+hie+li/2TQ5XJ6nvfO8fhRhIMcCpUEsA5Ynf4qTTFf/ft/3Pg==", "license": "MIT", "dependencies": { "@astrojs/markdown-satteri": "^0.4.0", @@ -378,9 +378,9 @@ } }, "node_modules/@astrojs/telemetry": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.4.1.tgz", - "integrity": "sha512-SMkvMsGOpM0FMAoT5pzhgI3LZC0JSPWf7Evwop+fZ3N6UkusPM7lI+sOJjMNI5OJrVoiei1/AGo1cY7sikTc5A==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.4.2.tgz", + "integrity": "sha512-2JOMRUxJrBSfiVDfeKLv1wBmvSIx1Y6u2YEO8awZ9qSq3u/rNAiUyFdOYi8dHv91kHrmYEQX7pRDw0J4xyj3UA==", "license": "MIT", "dependencies": { "ci-info": "^4.4.0", @@ -2000,15 +2000,15 @@ } }, "node_modules/astro": { - "version": "7.4.2", - "resolved": "https://registry.npmjs.org/astro/-/astro-7.4.2.tgz", - "integrity": "sha512-FAH4cdDlFaVnQGjazLTEIC5vJqGlo5XANc1wIbzQHIqAjJTll/RWTUs+SPNW1JdezG4YGxMZllaE5A41T2kzhA==", + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/astro/-/astro-7.4.4.tgz", + "integrity": "sha512-e+BzhGCaIY/H1tV/BQPTbOPZVZ8wuu6+bbpc+3oSMH2UgoB1l9peSGASk6nswtYra9PO9OoMREhldKtQkZ5TVw==", "license": "MIT", "dependencies": { "@astrojs/compiler-rs": "^0.5.1", "@astrojs/internal-helpers": "0.13.0", - "@astrojs/markdown-satteri": "0.4.5", - "@astrojs/telemetry": "3.4.1", + "@astrojs/markdown-satteri": "0.4.6", + "@astrojs/telemetry": "3.4.2", "@capsizecss/unpack": "^4.0.1", "@clack/prompts": "^1.8.1", "@oslojs/encoding": "^1.1.0", @@ -2040,7 +2040,7 @@ "package-manager-detector": "^1.9.0", "piccolore": "^0.1.3", "picomatch": "^4.0.7", - "rolldown": "^1.2.13", + "rolldown": "^1.2.14", "shiki": "^4.5.0", "smol-toml": "^1.9.1", "svgo": "^4.1.0", @@ -2052,7 +2052,7 @@ "unifont": "^1.0.3", "unstorage": "^1.17.5", "verkit": "^0.5.0", - "vite": "^8.3.4", + "vite": "^8.3.5", "vitefu": "^1.1.3", "xxhash-wasm": "^1.1.0", "yargs-parser": "^22.0.0", @@ -2112,21 +2112,21 @@ } }, "node_modules/astro/node_modules/@astrojs/markdown-satteri": { - "version": "0.4.5", - "resolved": "https://registry.npmjs.org/@astrojs/markdown-satteri/-/markdown-satteri-0.4.5.tgz", - "integrity": "sha512-0IeGdoFbv0f+442KJhYS44Wg8F3MuHmRsxIEYuFyqkjRubc6dPBFZ7azor5t4AeXLQoT5A5h9HyViKpd70Dl9w==", + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/@astrojs/markdown-satteri/-/markdown-satteri-0.4.6.tgz", + "integrity": "sha512-6biKp2lEAYycF3jhv91br+Otyf4EgDFkF1oRL9ks0u/m/YRGmI7XkGQ2MgaM35DslT03f7NtozKtlqfBNkp6bw==", "license": "MIT", "dependencies": { "@astrojs/internal-helpers": "0.13.0", - "@astrojs/prism": "4.1.1", + "@astrojs/prism": "4.1.2", "github-slugger": "^2.0.0", "satteri": "^0.10.5" } }, "node_modules/astro/node_modules/@astrojs/prism": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@astrojs/prism/-/prism-4.1.1.tgz", - "integrity": "sha512-sXlAXJX6t4QfuLg+CuCDzpg7NoxWcyb4paFprgtFppLeisCBmh935h+tJ5CWXfsXEoW2IDy/ihbRMtiu28Chjw==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@astrojs/prism/-/prism-4.1.2.tgz", + "integrity": "sha512-jMrUEpFDarDO1KWPvVc9eSa8DSfwBlwEOHwX+8zvZQpiqXRGDxFnOXHsYqV6DP7w+0qIgTr98gS1GdQlm8ypWg==", "license": "MIT", "dependencies": { "prismjs": "^1.30.0" @@ -5309,9 +5309,9 @@ "license": "MIT" }, "node_modules/postcss": { - "version": "8.5.29", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", - "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", + "version": "8.5.32", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz", + "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==", "funding": [ { "type": "opencollective", @@ -5328,7 +5328,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.19", + "nanoid": "^3.3.20", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" }, @@ -6623,15 +6623,15 @@ } }, "node_modules/vite": { - "version": "8.3.4", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", - "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz", + "integrity": "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==", "license": "MIT", "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", - "postcss": "^8.5.29", - "rolldown": "~1.2.12", + "postcss": "^8.5.30", + "rolldown": "~1.2.14", "tinyglobby": "^0.2.17" }, "bin": { @@ -6648,7 +6648,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.7.1", + "@vitejs/devtools": "^0.7.1 || ^0.8.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", diff --git a/docs/package.json b/docs/package.json index 54994e8..e7e1b03 100644 --- a/docs/package.json +++ b/docs/package.json @@ -16,9 +16,9 @@ "astro": "astro" }, "dependencies": { - "@astrojs/starlight": "^0.42.7", + "@astrojs/starlight": "^0.42.8", "@max-models/starlight-theme": "github:max-models/starlight-theme#v0", - "astro": "^7.4.2", + "astro": "^7.4.4", "sharp": "^0.35.3" }, "starlight-notebooks": { From 9c70309c283b77f832999209708999175e5242df Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:04:11 +0100 Subject: [PATCH 30/36] Bump the python group with 4 updates (#44) Bumps the python group with 4 updates: [jupyterlab](https://github.com/jupyterlab/jupyterlab), [ruff](https://github.com/astral-sh/ruff), [pyvista](https://github.com/pyvista/pyvista) and [pytest](https://github.com/pytest-dev/pytest). Updates `jupyterlab` from 4.6.4 to 4.6.5 - [Release notes](https://github.com/jupyterlab/jupyterlab/releases) - [Changelog](https://github.com/jupyterlab/jupyterlab/blob/main/RELEASE.md) - [Commits](https://github.com/jupyterlab/jupyterlab/compare/@jupyterlab/lsp@4.6.4...@jupyterlab/lsp@4.6.5) Updates `ruff` from 0.17.2 to 0.17.3 - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.17.2...0.17.3) Updates `pyvista` from 0.49.1 to 0.49.2 - [Release notes](https://github.com/pyvista/pyvista/releases) - [Commits](https://github.com/pyvista/pyvista/compare/v0.49.1...v0.49.2) Updates `pytest` from 9.1.1 to 9.2.0 - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/9.1.1...9.2.0) --- updated-dependencies: - dependency-name: jupyterlab dependency-version: 4.6.5 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: python - dependency-name: ruff dependency-version: 0.17.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: python - dependency-name: pyvista dependency-version: 0.49.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: python - dependency-name: pytest dependency-version: 9.2.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: python ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- uv.lock | 310 ++++++++++++++++++++++++++++---------------------------- 1 file changed, 155 insertions(+), 155 deletions(-) diff --git a/uv.lock b/uv.lock index 4a31ea3..8b631cb 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", @@ -730,7 +730,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130, upload-time = "2025-04-15T17:47:53.79Z" } wheels = [ @@ -802,7 +802,7 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } wheels = [ @@ -895,7 +895,7 @@ resolution-markers = [ "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/83/5a/a55177dd22553a277388e8a1b3220e92de91bacb28356cdc73caa240121d/contourpy-1.4.0.tar.gz", hash = "sha256:20156f5a1ac4f8ce02656e39a61e82164a3d359796dc8026f75b062783d500e1", size = 13323726, upload-time = "2026-09-11T19:05:05.808Z" } wheels = [ @@ -1172,12 +1172,12 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "attrs", marker = "python_full_version < '3.11'" }, - { name = "docstring-parser", marker = "python_full_version < '3.11'" }, - { name = "rich", marker = "python_full_version < '3.11'" }, - { name = "rich-rst", marker = "python_full_version < '3.11'" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "attrs" }, + { name = "docstring-parser" }, + { name = "rich" }, + { name = "rich-rst" }, + { name = "tomli" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c1/08/a444f3cca86cb27bc256831fc8c42b578390f4087bf6631cccb5334bd9ed/cyclopts-4.25.3.tar.gz", hash = "sha256:7d3a067bc7699a1a65fe2ea0b092a925855bb711a05adc1e65c5b9ba307acea4", size = 202814, upload-time = "2026-09-17T16:25:16.892Z" } wheels = [ @@ -1203,10 +1203,10 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "attrs", marker = "python_full_version >= '3.11'" }, - { name = "docstring-parser", marker = "python_full_version >= '3.11'" }, - { name = "rich", marker = "python_full_version >= '3.11'" }, - { name = "rich-rst", marker = "python_full_version >= '3.11'" }, + { name = "attrs" }, + { name = "docstring-parser" }, + { name = "rich" }, + { name = "rich-rst" }, ] sdist = { url = "https://files.pythonhosted.org/packages/28/c1/3debeeb6e0eb74a51d6f8cf1f273ed7c479e3321631cbf89fb9700e65e28/cyclopts-5.2.0.tar.gz", hash = "sha256:b63c1b1beaadf3ead19214385a0f90b990f152c4107c174e1724c45dc71e9541", size = 246542, upload-time = "2026-10-06T15:02:39.212Z" } wheels = [ @@ -1287,7 +1287,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -1764,8 +1764,8 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "pillow", marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "pillow" }, ] sdist = { url = "https://files.pythonhosted.org/packages/81/c8/2b56274457e4fadcfa86bf7c0fb93f19e124ab8f38f4bfa4372423052046/imageio-2.38.0.tar.gz", hash = "sha256:fe1d406862f6bc2930e8ec662d7bc40d4370cb76e3390dcd33819560d2708531", size = 403333, upload-time = "2026-09-28T07:44:08.101Z" } wheels = [ @@ -1791,9 +1791,9 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "pillow", marker = "python_full_version >= '3.11'" }, + { name = "pillow" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f3/cd/69e4ac55b6dafdd2b5f32075236841a3945dea7323d0232d80f28c37cea8/imageio-2.38.1.tar.gz", hash = "sha256:6769f1f01c4dd46448307863a787c9a22fa4dbe11c0c88f525c6e410fdfd7983", size = 403667, upload-time = "2026-10-08T14:35:32.961Z" } wheels = [ @@ -1826,19 +1826,19 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "appnope", marker = "python_full_version < '3.11' and sys_platform == 'darwin'" }, - { name = "comm", marker = "python_full_version < '3.11'" }, - { name = "debugpy", marker = "python_full_version < '3.11'" }, - { name = "ipython", version = "8.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "jupyter-client", marker = "python_full_version < '3.11'" }, - { name = "jupyter-core", marker = "python_full_version < '3.11'" }, - { name = "matplotlib-inline", marker = "python_full_version < '3.11'" }, - { name = "nest-asyncio2", marker = "python_full_version < '3.11'" }, - { name = "packaging", marker = "python_full_version < '3.11'" }, - { name = "psutil", marker = "python_full_version < '3.11'" }, - { name = "pyzmq", marker = "python_full_version < '3.11'" }, - { name = "tornado", marker = "python_full_version < '3.11'" }, - { name = "traitlets", marker = "python_full_version < '3.11'" }, + { name = "appnope", marker = "sys_platform == 'darwin'" }, + { name = "comm" }, + { name = "debugpy" }, + { name = "ipython", version = "8.39.0", source = { registry = "https://pypi.org/simple" } }, + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "matplotlib-inline" }, + { name = "nest-asyncio2" }, + { name = "packaging" }, + { name = "psutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3d/c4/e4a38f579de4225a561305666f7541cdabb30075def2aa1ac17bd73c1fb5/ipykernel-7.3.0.tar.gz", hash = "sha256:9acaaaf97d16355166e4085afe9d225bfbdf2b7ef520f9df3be8f2b248275e09", size = 184899, upload-time = "2026-06-10T08:41:25.481Z" } wheels = [ @@ -1864,18 +1864,18 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "appnope", marker = "python_full_version >= '3.11' and sys_platform == 'darwin'" }, - { name = "comm", marker = "python_full_version >= '3.11'" }, - { name = "debugpy", marker = "python_full_version >= '3.11'" }, - { name = "ipython", version = "9.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "jupyter-client", marker = "python_full_version >= '3.11'" }, - { name = "jupyter-core", marker = "python_full_version >= '3.11'" }, - { name = "matplotlib-inline", marker = "python_full_version >= '3.11'" }, - { name = "nest-asyncio2", marker = "python_full_version >= '3.11'" }, - { name = "packaging", marker = "python_full_version >= '3.11'" }, - { name = "pyzmq", marker = "python_full_version >= '3.11'" }, - { name = "tornado", marker = "python_full_version >= '3.11'" }, - { name = "traitlets", marker = "python_full_version >= '3.11'" }, + { name = "appnope", marker = "sys_platform == 'darwin'" }, + { name = "comm" }, + { name = "debugpy" }, + { name = "ipython", version = "9.17.1", source = { registry = "https://pypi.org/simple" } }, + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "matplotlib-inline" }, + { name = "nest-asyncio2" }, + { name = "packaging" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/05/e5499c8b762387d83926bf0389e8fff888c9bf6e7af30566a8527bc12f4a/ipykernel-7.4.0.tar.gz", hash = "sha256:4330114d22b9b33575b2c7c68753fc8faabbbcfbc60b0cdff9f14dd4ce63742f", size = 192731, upload-time = "2026-09-29T16:33:11.468Z" } wheels = [ @@ -1890,17 +1890,17 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, - { name = "decorator", marker = "python_full_version < '3.11'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, - { name = "jedi", marker = "python_full_version < '3.11'" }, - { name = "matplotlib-inline", marker = "python_full_version < '3.11'" }, - { name = "pexpect", marker = "python_full_version < '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit", marker = "python_full_version < '3.11'" }, - { name = "pygments", marker = "python_full_version < '3.11'" }, - { name = "stack-data", marker = "python_full_version < '3.11'" }, - { name = "traitlets", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "decorator" }, + { name = "exceptiongroup" }, + { name = "jedi" }, + { name = "matplotlib-inline" }, + { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit" }, + { name = "pygments" }, + { name = "stack-data" }, + { name = "traitlets" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/40/18/f8598d287006885e7136451fdea0755af4ebcbfe342836f24deefaed1164/ipython-8.39.0.tar.gz", hash = "sha256:4110ae96012c379b8b6db898a07e186c40a2a1ef5d57a7fa83166047d9da7624", size = 5513971, upload-time = "2026-03-27T10:02:13.94Z" } wheels = [ @@ -1926,17 +1926,17 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "colorama", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" }, - { name = "ipython-pygments-lexers", marker = "python_full_version >= '3.11'" }, - { name = "jedi", marker = "python_full_version >= '3.11'" }, - { name = "matplotlib-inline", marker = "python_full_version >= '3.11'" }, - { name = "pexpect", marker = "python_full_version >= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit", marker = "python_full_version >= '3.11'" }, - { name = "psutil", marker = "python_full_version >= '3.11' and sys_platform != 'cygwin' and sys_platform != 'emscripten'" }, - { name = "pygments", marker = "python_full_version >= '3.11'" }, - { name = "stack-data", marker = "python_full_version >= '3.11'" }, - { name = "traitlets", marker = "python_full_version >= '3.11'" }, - { name = "typing-extensions", marker = "python_full_version == '3.11.*'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "ipython-pygments-lexers" }, + { name = "jedi" }, + { name = "matplotlib-inline" }, + { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit" }, + { name = "psutil", marker = "sys_platform != 'cygwin' and sys_platform != 'emscripten'" }, + { name = "pygments" }, + { name = "stack-data" }, + { name = "traitlets" }, + { name = "typing-extensions", marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b9/32/99451b1283ec5d92ad77073f12e1c667dc10775384d8f15c2914207149dd/ipython-9.17.1.tar.gz", hash = "sha256:8919be8c27f20a6f4423145028063f6637b42a03ce57665bb12015ee1f073529", size = 4539289, upload-time = "2026-09-01T08:29:32.6Z" } wheels = [ @@ -1948,7 +1948,7 @@ name = "ipython-pygments-lexers" version = "1.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pygments", marker = "python_full_version >= '3.11'" }, + { name = "pygments" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } wheels = [ @@ -2187,7 +2187,7 @@ wheels = [ [[package]] name = "jupyterlab" -version = "4.6.4" +version = "4.6.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "async-lru" }, @@ -2207,9 +2207,9 @@ dependencies = [ { name = "traitlets" }, { name = "typing-extensions", marker = "python_full_version < '3.12'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/33/8d/995cc142f6083346b35e7d3eadc5a6717ee89ce64ea53577010ac493bc3c/jupyterlab-4.6.4.tar.gz", hash = "sha256:404f49b081819378524886c9db66dba57a5565981eff885830df1baba3a17df5", size = 28335647, upload-time = "2026-09-21T15:25:18.779Z" } +sdist = { url = "https://files.pythonhosted.org/packages/22/60/95238fb78af6a6838b63a05437951856cd82946b672028788b8aaf3ec5f7/jupyterlab-4.6.5.tar.gz", hash = "sha256:e115a0ef663927d60afd20bd9a8e822a5c61d7d22bcab489d8e37f4885d0d2ce", size = 28393095, upload-time = "2026-10-29T12:57:55.261Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/e4/072bc0d3c6d45d414062a06af7ee81c02a2c12d769c358ec9919f9995ffd/jupyterlab-4.6.4-py3-none-any.whl", hash = "sha256:15b13f991d3985129c797eb84d9949eeb8b6615e14b444868e642411f2c418b2", size = 17171596, upload-time = "2026-09-21T15:25:14.123Z" }, + { url = "https://files.pythonhosted.org/packages/41/8f/58a539ef036aca50891a06b283f044da84a6544177fb6e49217c5c5206c9/jupyterlab-4.6.5-py3-none-any.whl", hash = "sha256:cb96fc3d59d20b6f17d0cc1afe6e0508a61d881ab39be89236c55b01fa0e2b99", size = 17331695, upload-time = "2026-10-29T12:57:51.281Z" }, ] [[package]] @@ -2593,15 +2593,15 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "contourpy", version = "1.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "cycler", marker = "python_full_version < '3.11'" }, - { name = "fonttools", version = "4.65.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "kiwisolver", marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "packaging", marker = "python_full_version < '3.11'" }, - { name = "pillow", marker = "python_full_version < '3.11'" }, - { name = "pyparsing", marker = "python_full_version < '3.11'" }, - { name = "python-dateutil", marker = "python_full_version < '3.11'" }, + { name = "contourpy", version = "1.3.2", source = { registry = "https://pypi.org/simple" } }, + { name = "cycler" }, + { name = "fonttools", version = "4.65.0", source = { registry = "https://pypi.org/simple" } }, + { name = "kiwisolver" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, ] sdist = { url = "https://files.pythonhosted.org/packages/63/1b/4be5be87d43d327a0cf4de1a56e86f7f84c89312452406cf122efe2839e6/matplotlib-3.10.9.tar.gz", hash = "sha256:fd66508e8c6877d98e586654b608a0456db8d7e8a546eb1e2600efd957302358", size = 34811233, upload-time = "2026-04-24T00:14:13.539Z" } wheels = [ @@ -2680,17 +2680,17 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "contourpy", version = "1.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "contourpy", version = "1.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "contourpy", version = "1.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "cycler", marker = "python_full_version >= '3.11'" }, - { name = "fonttools", version = "4.66.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "kiwisolver", marker = "python_full_version >= '3.11'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "cycler" }, + { name = "fonttools", version = "4.66.1", source = { registry = "https://pypi.org/simple" } }, + { name = "kiwisolver" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "packaging", marker = "python_full_version >= '3.11'" }, - { name = "pillow", marker = "python_full_version >= '3.11'" }, - { name = "pyparsing", marker = "python_full_version >= '3.11'" }, - { name = "python-dateutil", marker = "python_full_version >= '3.11'" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e7/c8/9aa712a0afb882649424dd8de8ad9aa6235e796e84c6052e8f6dc1598d0d/matplotlib-3.11.2.tar.gz", hash = "sha256:cec596316640f2b394b8f0daa0ea61a8eae82d017b620b9f202befb972a59ea4", size = 32660610, upload-time = "2026-09-11T19:05:31.214Z" } wheels = [ @@ -2800,7 +2800,7 @@ name = "mlx" version = "0.32.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "mlx-metal", marker = "sys_platform == 'darwin'" }, + { name = "mlx-metal" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/52/73/f2b18f4bbcfeacd6880e68f2854609d6d2d305e5676586451a6c26664061/mlx-0.32.4-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:60ba41564e8b5018c9d8a5f8ce7a5aaf70c96d8dd98c1a54b854cbada362d471", size = 671799, upload-time = "2026-10-14T21:15:37.06Z" }, @@ -3641,10 +3641,10 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "python-dateutil", marker = "python_full_version < '3.11'" }, - { name = "pytz", marker = "python_full_version < '3.11'" }, - { name = "tzdata", marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "tzdata" }, ] sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } wheels = [ @@ -3716,10 +3716,10 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "python-dateutil", marker = "python_full_version >= '3.11'" }, - { name = "tzdata", marker = "(python_full_version >= '3.11' and sys_platform == 'emscripten') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e2/17/d7b106e05bfa642e8694451e7d3d759c6a241c5386a5d962e4f66c047e06/pandas-3.0.6.tar.gz", hash = "sha256:66b07ef7315a31bfe1089cd3d71a7de781c9dca986762d0b4fe7c0ef17465d10", size = 4667686, upload-time = "2026-09-17T23:23:18.345Z" } wheels = [ @@ -3805,7 +3805,7 @@ name = "pexpect" version = "4.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ptyprocess", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, + { name = "ptyprocess" }, ] 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 = [ @@ -4247,7 +4247,7 @@ name = "pyobjc-framework-cocoa" version = "12.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, + { name = "pyobjc-core" }, ] sdist = { url = "https://files.pythonhosted.org/packages/75/76/49c6da2c6a831020b4854ba20079d5a1030474bffc776b7b73c2eeff8c15/pyobjc_framework_cocoa-12.2.2.tar.gz", hash = "sha256:c96c0ef69a71afbbb0e6a7d594b455c5fe47d62e0db376ee7a2b4b828c16ace9", size = 3132831, upload-time = "2026-08-11T19:44:02.288Z" } wheels = [ @@ -4322,7 +4322,7 @@ wheels = [ [[package]] name = "pytest" -version = "9.1.1" +version = "9.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -4333,9 +4333,9 @@ dependencies = [ { name = "pygments" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +sdist = { url = "https://files.pythonhosted.org/packages/79/42/bb4641652e58dedeac2ec51abc1f8cae4fee4ec63899a0999016229a0f2a/pytest-9.2.0.tar.gz", hash = "sha256:97c4f3b1f3c7414aa36465e09b8220f1d03627993bd51b1282e193753c80557b", size = 1797555, upload-time = "2026-10-27T21:30:20.728Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, + { url = "https://files.pythonhosted.org/packages/51/39/1f5d8c8877a0fa327db959c19341d4be61660de1ab6b7391f9eb805bcd67/pytest-9.2.0-py3-none-any.whl", hash = "sha256:07acdcf88ec174f907dc01af70bf52d49b014bbab03faeeade31c96b18c2e588", size = 411776, upload-time = "2026-10-27T21:30:19.131Z" }, ] [[package]] @@ -4396,7 +4396,7 @@ wheels = [ [[package]] name = "pyvista" -version = "0.49.1" +version = "0.49.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cyclopts", version = "4.25.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -4414,9 +4414,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "vtk" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7b/1f/77d43ddf04d8fb7703bc125a6546d6300657f56464c76c18550aa0c991f4/pyvista-0.49.1.tar.gz", hash = "sha256:e3a37f2330ce9d2d680a153ac08fea2bd900aa59d13db46da8f64c92896ddd8e", size = 2705888, upload-time = "2026-10-11T16:53:05.078Z" } +sdist = { url = "https://files.pythonhosted.org/packages/65/9b/6ae34b3fcde3ab8e4e59d4727ef6733ed938e8ad5b3ec05655f1e0cd296d/pyvista-0.49.2.tar.gz", hash = "sha256:24cf548b15a375ddb0952bda5ea0ae4948006c7ab724cd676c420f8e04b3718d", size = 2707024, upload-time = "2026-10-28T22:24:34.54Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/48/39c762c55cb110aa1bd72cebbcc927c1a481f5bd17db4cab86933ce03578/pyvista-0.49.1-py3-none-any.whl", hash = "sha256:e4bd78c829051207e885281c45d03280dcde25445e1885c0824518ea7849b1c8", size = 2734000, upload-time = "2026-10-11T16:53:02.571Z" }, + { url = "https://files.pythonhosted.org/packages/5e/72/0d1da868fc241bb2ab930ff9b802bbc17a1df7e9f36565769a978d052dbf/pyvista-0.49.2-py3-none-any.whl", hash = "sha256:63f78495b30e146c9d1debeca7fa44c7a3935cc9f9ded8638b809ef955fc167a", size = 2734789, upload-time = "2026-10-28T22:24:32.607Z" }, ] [[package]] @@ -4972,27 +4972,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.17.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ae/58/690cf56da80c8f2c8bf12042b6017beac2c34fe961f65420bcb3fb7fac38/ruff-0.17.2.tar.gz", hash = "sha256:84d632402d14c23b488f941db23712d6736a013225162062d34558c3f3c780eb", size = 5333400, upload-time = "2026-10-22T18:38:00.181Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/98/b8/8b6d69c57e8fe1cc6af1aa7493b72a1a5b5a8afe34694e302025441729d4/ruff-0.17.2-py3-none-linux_armv6l.whl", hash = "sha256:96a65e755d8dba094707fb23ab602111d9a31ec2835b3a6992a1914a8fcb211e", size = 10183857, upload-time = "2026-10-22T18:37:14.956Z" }, - { url = "https://files.pythonhosted.org/packages/e8/61/12cc2ff7b2fa90a268ce1e2162c23aaf76e601d22623f6a31b0eeaa764b0/ruff-0.17.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:39c5b55c960515cdb32be16e69efb5734c589c06a0eb38a22694524a336b889d", size = 10210904, upload-time = "2026-10-22T18:37:17.985Z" }, - { url = "https://files.pythonhosted.org/packages/e8/7c/b8850a8e4ad29f64a827dd6c6902db4a9aa538fb2b9eef4e26084001a3bb/ruff-0.17.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:92a80d2f44584dabb0189b3a6c393e37e525e04942838482010575cb81945be7", size = 9408439, upload-time = "2026-10-22T18:37:20.964Z" }, - { url = "https://files.pythonhosted.org/packages/b7/b6/6a64fae86ca5e5e29d5adc311e5481de726cedf9aee607435a16c31c442a/ruff-0.17.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b7d0906677d89bbf3cf73cde64b5bbe2ab35df3ae43e49ee2627301e91265bac", size = 10047078, upload-time = "2026-10-22T18:37:23.717Z" }, - { url = "https://files.pythonhosted.org/packages/10/19/23d376547715175aa1bd46df48df6aacc7c6439d9daf19cac15975180aa5/ruff-0.17.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7cfd783590edf2b77317882631900f6a13ea2e8eacefe6ebf2370d2715299160", size = 9990478, upload-time = "2026-10-22T18:37:26.584Z" }, - { url = "https://files.pythonhosted.org/packages/93/6c/34cda32cbe13adac5c6b291a307a5570d8ebd37c0c86339f0fe628e55632/ruff-0.17.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122258f6ecde0e0dd1bb60b24263c3edde4338caf723fca711cc729f9bfd414a", size = 10673624, upload-time = "2026-10-22T18:37:29.767Z" }, - { url = "https://files.pythonhosted.org/packages/bf/10/5d762a4dbc851d8bb47863cf3a28a61bf31c804d393b70d891fac899f5d1/ruff-0.17.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7f8fba429d6ac4128ab29cfb8fa6240d2fa671d04adf7f84d3b22fc7b6b6bea4", size = 11351797, upload-time = "2026-10-22T18:37:32.535Z" }, - { url = "https://files.pythonhosted.org/packages/3f/04/a0e92a81faf5c81c1c98d4374c8401734ccf2b8cca7d0170cb0fec08c6e2/ruff-0.17.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:583bd67bb9e7061dd09bc46a0a884bae2bbd214dbcd48e509813612619683869", size = 10949414, upload-time = "2026-10-22T18:37:35.501Z" }, - { url = "https://files.pythonhosted.org/packages/c3/d8/59894cf630d2d86882457a2a6a0a1533410c5c805bfbac6f6eb4b5aef69d/ruff-0.17.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c60f93fec7142dba5b8f487f94bf2f6231dae24dc11e4c48648347c8b0720004", size = 10528000, upload-time = "2026-10-22T18:37:37.88Z" }, - { url = "https://files.pythonhosted.org/packages/ab/95/d6e30c51e851d37e10762ad91fa1e76ddaad998589b5667962a3cfbc9edd/ruff-0.17.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:c555a877f8f41447e318101cfefea9bf12dfa9f82eedf90c376e57e069f917d0", size = 10571779, upload-time = "2026-10-22T18:37:40.444Z" }, - { url = "https://files.pythonhosted.org/packages/f4/5a/a7e1d74fb464250137d3d4eca645d323536533b672004d04ee0e46e0caf3/ruff-0.17.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ba405cbd5ec7bda1f555d5e52be1fd68d02608d4d95d24591bdac92cfacb579f", size = 10051707, upload-time = "2026-10-22T18:37:42.812Z" }, - { url = "https://files.pythonhosted.org/packages/6e/f2/02fca29ca7999380f0350a7212fe755c11ab9c22d61327356cce8decdbc6/ruff-0.17.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8c21b4ab24485358f17291a4f8dc5399b834e963b8efedd920fe4fdb02081bb4", size = 9998227, upload-time = "2026-10-22T18:37:45.224Z" }, - { url = "https://files.pythonhosted.org/packages/d1/00/55aa604a093dfdc1526e570fae0bb7d37a2221c7b4728d5f080b2add16ba/ruff-0.17.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5875a16b0de70b43cb81eabea5ae5e8f5d1b2fd1b5e6ed07f80cfe5019d0bd5a", size = 10377692, upload-time = "2026-10-22T18:37:47.704Z" }, - { url = "https://files.pythonhosted.org/packages/e4/5a/a7a640f47bb906ec8fa233ea84319d503e6dee02b59e3901bd312ebd1f9b/ruff-0.17.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:2d086be452eed686aa51271ecf2cbe5037956517cc6ee39a17b4489ac12f8bbe", size = 10790587, upload-time = "2026-10-22T18:37:50.191Z" }, - { url = "https://files.pythonhosted.org/packages/2c/d4/c3b74e1b4808438ea7f14bb914478025e2d51261f60774a54a0fa222ec46/ruff-0.17.2-py3-none-win32.whl", hash = "sha256:6bce7d1f5165701ae18f5df85c65afe78cddfa6c5fb4b9bdb0014d60388f3edd", size = 9870537, upload-time = "2026-10-22T18:37:52.486Z" }, - { url = "https://files.pythonhosted.org/packages/1c/9a/c7da96c2c96d20704cf9027f7a5121da272a8c05f1d1531ba949e3b3df04/ruff-0.17.2-py3-none-win_amd64.whl", hash = "sha256:1b59d447f197bef9ef48a22ba1a6016137753c6099e9bac9c36eb566e422424c", size = 10282826, upload-time = "2026-10-22T18:37:55.2Z" }, - { url = "https://files.pythonhosted.org/packages/81/60/a3604c79cb02777b6cb84da894104021986f06c4bc41375102cc95049a32/ruff-0.17.2-py3-none-win_arm64.whl", hash = "sha256:195ad918027f817c119310ca89a42d347887dfc28934aaa869325e9c5dc9d962", size = 9827036, upload-time = "2026-10-22T18:37:58.031Z" }, +version = "0.17.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/59/73/81f140c4edd9cc323de5478e6bbd3bbcfae9a82692df1a258de8cec6a6d2/ruff-0.17.3.tar.gz", hash = "sha256:a57049c8addb3662073859bb55a7ad35aa54f8a7c9ed268911d1333c5aadaaa9", size = 5339444, upload-time = "2026-10-27T00:34:05.914Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/8c/083365310c4e79c842f25bf5a842facbd9b0c0ec336f96dffa5074fe723d/ruff-0.17.3-py3-none-linux_armv6l.whl", hash = "sha256:ab86b84cd9d05e56d329742c0cabdcbe2d1d56c98f4a04aab9b5b66196febfb4", size = 10216522, upload-time = "2026-10-27T00:33:29.822Z" }, + { url = "https://files.pythonhosted.org/packages/72/26/4b2edf31cfebe9605d8f6ed8f1d6f376d3a53d4c9a123e62661c64c440f0/ruff-0.17.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e407b4a6852853a9a2cdce96963e94ad9775578bb44e7561a0f2829f3cc04b11", size = 10217786, upload-time = "2026-10-27T00:33:33.949Z" }, + { url = "https://files.pythonhosted.org/packages/a2/7e/537aba4b7f54397a374d2f6a6fc93e9c8015e18553f31a4a7b7dbced48b5/ruff-0.17.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b75cc113b3aca388933d8296b667b1ac401a80317ea3074c9b0a66541199da29", size = 9440362, upload-time = "2026-10-27T00:33:35.784Z" }, + { url = "https://files.pythonhosted.org/packages/3d/66/283e39734f66c8a5ebaa93c22eb7b7515779a0b1d5a5c0ad20e05c0edb41/ruff-0.17.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab5fa7ed5ce18554ea69d850ad162cbaa0c85fc47c08d83a569f806681c9a6ad", size = 10094281, upload-time = "2026-10-27T00:33:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/21/4c/1ffc768497bd025a32c72152771e40c0b99bddc5cc6e4f50c7949a395346/ruff-0.17.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c673c170e27834d1262e3a9c1e97bdc9b9c3b180c63d89173ce69747232474c1", size = 10022543, upload-time = "2026-10-27T00:33:40.181Z" }, + { url = "https://files.pythonhosted.org/packages/73/c3/f90db547301080fcb82c92abf914c8bd57b89911d9b65a6c4cc6b52d0635/ruff-0.17.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a754c0341bc9ca151b7b335988b3263921c08859f84ca219b70cee825571b6f", size = 10683921, upload-time = "2026-10-27T00:33:42.182Z" }, + { url = "https://files.pythonhosted.org/packages/e5/98/92d4a8a26c6c96cf1a5b34dd59036d8a71e52d650aab065c58867e964e63/ruff-0.17.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4590b9d86c0b1543ebb09094254e191cd43fe8edaf330baa035aab03da251532", size = 11373408, upload-time = "2026-10-27T00:33:44.19Z" }, + { url = "https://files.pythonhosted.org/packages/43/02/1f24d2e3064c9753926deeb901afa57777b37ee46799b09ddd7aee6e5145/ruff-0.17.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bbde18beccd98c29a227c63c99022e3443b99258e08326df6425dfe0443705ef", size = 10977469, upload-time = "2026-10-27T00:33:46.179Z" }, + { url = "https://files.pythonhosted.org/packages/f0/20/fecedea2209f574ab92033b344e01a7f0c2dc76c6d6d19a14c9f8141173f/ruff-0.17.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fa4b0f931cdbcb8a52044f370a3eb429fb42eb88807b0e800fcaa98dd42b24e", size = 10577491, upload-time = "2026-10-27T00:33:48.233Z" }, + { url = "https://files.pythonhosted.org/packages/33/36/b93fdf0bc6f84f0cd487aadfb0354deb40013669ea35ac3d26855de1e959/ruff-0.17.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:39405f341deec934b4289634324ad695f72df2970f9121582872eaf5b722f0f5", size = 10608748, upload-time = "2026-10-27T00:33:50.284Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b7/7d3f5f14145daead1efb0b9a8289ed1daa100f419d60774375951723ce00/ruff-0.17.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68074094f55ca3d29ab19491f640282f7a84061c4c7977aa5094b968ca457e9a", size = 10075057, upload-time = "2026-10-27T00:33:52.074Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ed/93ff7a5909ee56e2bd4b982deae9e0efb2724417874a90c9e41ad66c6dd7/ruff-0.17.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:27bb571ac78fef178fe5118d2827df95c2aae74a48fff2b15f6acd03af5ff5bb", size = 10022431, upload-time = "2026-10-27T00:33:54.061Z" }, + { url = "https://files.pythonhosted.org/packages/9b/07/a83b9eeec8c1c3c0e4eda15f06c3a91001bfd2687ea2f07b750719bf9712/ruff-0.17.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:029c37cbd3de48ca37aa12117bead2d065f15da17d66bf84e0d4274013705db2", size = 10394903, upload-time = "2026-10-27T00:33:56.158Z" }, + { url = "https://files.pythonhosted.org/packages/af/b2/05c39857c2526fcbebe46ad93a8dc97424656dfef14d3ef933abe1ffd331/ruff-0.17.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:692fdb46d0fd9c71e380947dc5fc72f2c3f881073bc1f739e4579376231affbe", size = 10799780, upload-time = "2026-10-27T00:33:58.204Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d9/47171a16739eb7a353936b31c20cf54340531e68c9af7d72fd7a784e235d/ruff-0.17.3-py3-none-win32.whl", hash = "sha256:46539cb031b01fd29ae0a02142483c46640b233ee12bb1c1b690bffa1555d5ca", size = 9908481, upload-time = "2026-10-27T00:34:00.087Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a0/f08c1631bfd81faa6a90eaf70c37c786c11bed2046302b56bb506c25e1a0/ruff-0.17.3-py3-none-win_amd64.whl", hash = "sha256:56b8ffb3f3b3cbccb8b76b71b38261b7ed982ef5d20f5b447b1b61a7f4949b24", size = 10341333, upload-time = "2026-10-27T00:34:02.099Z" }, + { url = "https://files.pythonhosted.org/packages/99/d5/4ad4ed31413c301430918e70d3d38ae6f097af0766cb9ab8c9d502b2a4d6/ruff-0.17.3-py3-none-win_arm64.whl", hash = "sha256:81fce74bcd6be0e9da7c67c7553fd0f1f12964a2052cd728ef917b937a5ca1d9", size = 9854332, upload-time = "2026-10-27T00:34:03.847Z" }, ] [[package]] @@ -5003,14 +5003,14 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "imageio", version = "2.38.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "lazy-loader", marker = "python_full_version < '3.11'" }, - { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "packaging", marker = "python_full_version < '3.11'" }, - { name = "pillow", marker = "python_full_version < '3.11'" }, - { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "tifffile", version = "2025.5.10", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "imageio", version = "2.38.0", source = { registry = "https://pypi.org/simple" } }, + { name = "lazy-loader" }, + { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "packaging" }, + { name = "pillow" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" } }, + { name = "tifffile", version = "2025.5.10", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/c7/a8/3c0f256012b93dd2cb6fda9245e9f4bff7dc0486880b248005f15ea2255e/scikit_image-0.25.2.tar.gz", hash = "sha256:e5a37e6cd4d0c018a7a55b9d601357e3382826d3888c10d0213fc63bff977dde", size = 22693594, upload-time = "2025-02-18T18:05:24.538Z" } wheels = [ @@ -5056,17 +5056,17 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "imageio", version = "2.38.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "lazy-loader", marker = "python_full_version >= '3.11'" }, - { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "imageio", version = "2.38.1", source = { registry = "https://pypi.org/simple" } }, + { name = "lazy-loader" }, + { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "networkx", version = "3.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "packaging", marker = "python_full_version >= '3.11'" }, - { name = "pillow", marker = "python_full_version >= '3.11'" }, - { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "scipy", version = "1.18.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "tifffile", version = "2026.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "tifffile", version = "2026.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "tifffile", version = "2026.9.20", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a1/b4/2528bb43c67d48053a7a649a9666432dc307d66ba02e3a6d5c40f46655df/scikit_image-0.26.0.tar.gz", hash = "sha256:f5f970ab04efad85c24714321fcc91613fcb64ef2a892a13167df2f3e59199fa", size = 22729739, upload-time = "2025-12-20T17:12:21.824Z" } @@ -5129,7 +5129,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" } wheels = [ @@ -5190,7 +5190,7 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } wheels = [ @@ -5272,7 +5272,7 @@ resolution-markers = [ "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/7e/74/66de6258867beb2ef08f35f9f2ac017a52cacd5081714d239ff1a442d458/scipy-1.18.1.tar.gz", hash = "sha256:52c4b7422442aba924d03ad4019852b08a92e64ea187b933135687bfe2747307", size = 30781235, upload-time = "2026-08-21T23:28:50.599Z" } wheels = [ @@ -5377,7 +5377,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/4d/bc/0989043118a27cccb4e906a46b7565ce36ca7b57f5a18b78f4f1b0f72d9d/shapely-2.1.2.tar.gz", hash = "sha256:2ed4ecb28320a433db18a5bf029986aa8afcfd740745e78847e330d5d94922a9", size = 315489, upload-time = "2025-09-24T13:51:41.432Z" } wheels = [ @@ -5458,7 +5458,7 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f3/ab/924b6e202f796d270a3041a230151f7908db5ea48c74effe6f8023e9bd05/shapely-2.2.0.tar.gz", hash = "sha256:e8865e553d874a1ec4a032057ea81fca9def37b188cd8fb550af3b3480b3f88c", size = 380326, upload-time = "2026-10-07T09:18:01.001Z" } @@ -5770,7 +5770,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/44/d0/18fed0fc0916578a4463f775b0fbd9c5fed2392152d039df2fb533bfdd5d/tifffile-2025.5.10.tar.gz", hash = "sha256:018335d34283aa3fd8c263bae5c3c2b661ebc45548fde31504016fcae7bf1103", size = 365290, upload-time = "2025-05-10T19:22:34.386Z" } wheels = [ @@ -5787,7 +5787,7 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/c5/cb/2f6d79c7576e22c116352a801f4c3c8ace5957e9aced862012430b62e14f/tifffile-2026.3.3.tar.gz", hash = "sha256:d9a1266bed6f2ee1dd0abde2018a38b4f8b2935cb843df381d70ac4eac5458b7", size = 388745, upload-time = "2026-03-03T19:14:38.134Z" } wheels = [ @@ -5810,7 +5810,7 @@ resolution-markers = [ "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/92/66/634db78ebad513038d753830dd8815eea26278b5463ba0f43198b0c24c4e/tifffile-2026.9.20.tar.gz", hash = "sha256:30e145a7042ce7143ae50a50fe8b7221b0070aae22adab8f9e79a264be6b5cdc", size = 449417, upload-time = "2026-09-21T03:59:40.055Z" } wheels = [ @@ -6247,9 +6247,9 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "packaging", marker = "python_full_version < '3.11'" }, - { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "packaging" }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/19/ec/e50d833518f10b0c24feb184b209bb6856f25b919ba8c1f89678b930b1cd/xarray-2025.6.1.tar.gz", hash = "sha256:a84f3f07544634a130d7dc615ae44175419f4c77957a7255161ed99c69c7c8b0", size = 3003185, upload-time = "2025-06-12T03:04:09.099Z" } wheels = [ @@ -6275,10 +6275,10 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "packaging", marker = "python_full_version >= '3.11'" }, - { name = "pandas", version = "3.0.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "packaging" }, + { name = "pandas", version = "3.0.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/ff/f5/781c70b234f54e0401f4f7c4427a7486da38d50dbb138c7d7c55144b3d86/xarray-2026.9.0.tar.gz", hash = "sha256:6abc69694c22fa1f0fb2f357ff4e41d88beb4477ed71091f944b7dbf67ed54fe", size = 3176922, upload-time = "2026-09-29T23:06:25.807Z" } wheels = [ From d1a48dadde218d074417500f12e896212c19b19e Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 3 Nov 2026 14:11:06 +0100 Subject: [PATCH 31/36] Add method cards and a Numerics section to the docs. (#45) --- .github/workflows/docs.yml | 3 + CHANGELOG.md | 15 + docs/astro.config.mjs | 37 +++ docs/package-lock.json | 72 +---- docs/src/content/docs/numerics/index.mdx | 119 ++++++++ pyproject.toml | 5 +- src/simgeometry/_decomposition_views.py | 2 +- src/simgeometry/_level_set.py | 6 +- src/simgeometry/api.py | 4 +- src/simgeometry/collection.py | 26 +- src/simgeometry/crossings.py | 95 +++++- src/simgeometry/cut_edges.py | 26 +- src/simgeometry/cylindrical.py | 2 +- src/simgeometry/domain_decomposition.py | 79 +++-- src/simgeometry/fractions.py | 39 ++- src/simgeometry/grid.py | 23 +- src/simgeometry/kernels/__init__.py | 6 +- .../contains_ball/contains_ball_metal.py | 2 +- .../contains_box/contains_box_metal.py | 2 +- .../contains_polygon_2d_metal.py | 2 +- src/simgeometry/kernels/polygon_hits.py | 40 +++ src/simgeometry/objects/aperture2d.py | 19 +- src/simgeometry/objects/aperture3d.py | 16 +- src/simgeometry/objects/axisymmetric.py | 39 ++- src/simgeometry/objects/circle.py | 42 ++- src/simgeometry/objects/cone.py | 59 +++- src/simgeometry/objects/cuboid.py | 52 +++- src/simgeometry/objects/cylinder.py | 48 +++- src/simgeometry/objects/function_object.py | 4 +- src/simgeometry/objects/line.py | 32 ++- src/simgeometry/objects/polygon.py | 46 ++- src/simgeometry/objects/polyline.py | 4 +- src/simgeometry/objects/rectangle.py | 57 +++- src/simgeometry/objects/slanted_tube.py | 50 +++- src/simgeometry/objects/sphere.py | 32 ++- src/simgeometry/objects/torus.py | 47 ++- src/simgeometry/objects/triangle.py | 51 +++- src/simgeometry/objects/tube.py | 52 +++- src/simgeometry/operations.py | 96 ++++++- src/simgeometry/panels.py | 15 +- src/simgeometry/polygon_objects.py | 23 +- src/simgeometry/queries.py | 43 +++ src/simgeometry/shapes.py | 79 +++-- src/simgeometry/simulation_domain.py | 14 + src/simgeometry/tests/test_method_cards.py | 5 + src/simgeometry/visualization.py | 2 +- uv.lock | 272 ++++++++++-------- 47 files changed, 1472 insertions(+), 332 deletions(-) create mode 100644 docs/src/content/docs/numerics/index.mdx create mode 100644 src/simgeometry/tests/test_method_cards.py diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index a90914b..d7356f9 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -35,6 +35,9 @@ jobs: examples/parallel-grids/index.html api/simgeometry/index.html api/simgeometry/grid/index.html + numerics/index.html + numerics/crossings/index.html + numerics/objects-rectangle/index.html examples/grids-and-coordinates/grids_and_coordinates.ipynb examples/shapes-and-node-masks/shapes_and_node_masks.ipynb examples/parallel-grids/parallel_grids.ipynb diff --git a/CHANGELOG.md b/CHANGELOG.md index 37aa43c..056d19f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- A Numerics section in the docs (header and sidebar): an overview of the geometry + (`docs/src/content/docs/numerics/index.mdx`, with the main cards) and one page per module, + from the shapes to the grids (starlight-theme's `methodcard: { pages: true }`). +- Method cards (methodcard) on the geometry: the inside tests and level sets of every shape + (line, circle, sphere, boxes with fillets, polygons by the even-odd rule, triangles, + apertures, the solids of revolution and the slanted tube), the Boolean, affine and section + level sets, collections, outlines and boundary panels, the first crossings (analytic and + sampled, Illinois), the swept polygon hit test and absorption, the edge crossings, cut edges + and cut-cell fractions, the grid, the domain decomposition and the particle boundaries, + with their assumptions and references. `test_method_cards.py` checks them: SymPy rules for + 19 closed forms, the observed order of `outlines`, `crossings` and + `intersection_fractions`, and the cards of the docs against those of the code. Adds the + `methodcard` dependency. - `simgeometry.polygon_objects`: `polygon_objects(objects)` packs `Polygon` and `Aperture2D` objects into flat arrays, and `PolygonObjects.absorb(positions, alive, start=...)` absorbs the markers that end inside one or whose step crosses one of its @@ -149,6 +162,8 @@ format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- The docstrings render on the API pages: code spans instead of the reST roles (`:func:`, + `:class:`, `:meth:`, ...). - `Cuboid` keeps its bounds as host floats, so its `crossings` work on CuPy. - `Polygon.contains_points` with the compiled kernels accepts vertices that are not C-ordered (a transposed `(2, n)` array). diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 0a159ff..9f93dac 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -8,9 +8,46 @@ export default defineDocs({ title: 'simgeometry', description: 'Shapes, structured grids, and domains for numerical simulations.', pythonPackage: 'simgeometry', + // the method cards: a Numerics section with the overview (numerics/index.mdx) and one page per + // module, from the shapes to the grids, and a Numerics section on each method's API page + methodcard: { + pages: true, + index: false, + labels: { + 'simgeometry.shapes': 'Shape conventions', + 'simgeometry.objects.line': 'Line', + 'simgeometry.objects.circle': 'Circle', + 'simgeometry.objects.sphere': 'Sphere', + 'simgeometry.objects.rectangle': 'Rectangle', + 'simgeometry.objects.cuboid': 'Cuboid', + 'simgeometry.objects.polygon': 'Polygon', + 'simgeometry.objects.triangle': 'Triangle', + 'simgeometry.objects.aperture2d': 'Aperture2D', + 'simgeometry.objects.aperture3d': 'Aperture3D', + 'simgeometry.objects.axisymmetric': 'Solids of revolution', + 'simgeometry.objects.cylinder': 'Cylinder', + 'simgeometry.objects.tube': 'Tube', + 'simgeometry.objects.cone': 'Cone', + 'simgeometry.objects.torus': 'Torus', + 'simgeometry.objects.slanted_tube': 'SlantedTube', + 'simgeometry.operations': 'Booleans and transforms', + 'simgeometry.collection': 'Collections', + 'simgeometry.panels': 'Boundary panels', + 'simgeometry.crossings': 'First crossings', + 'simgeometry.kernels.polygon_hits': 'Polygon hit test', + 'simgeometry.polygon_objects': 'Swept absorption', + 'simgeometry.queries': 'Edge crossings', + 'simgeometry.cut_edges': 'Cut edges', + 'simgeometry.fractions': 'Cut-cell fractions', + 'simgeometry.grid': 'Grid', + 'simgeometry.domain_decomposition': 'Domain decomposition', + 'simgeometry.simulation_domain': 'Particle boundaries', + }, + }, nav: [ { label: 'Getting started', link: '/getting-started/installation/' }, { label: 'Examples', link: '/examples/' }, + { label: 'Numerics', link: '/numerics/' }, { label: 'Development', link: '/development/docs/' }, { label: 'API', link: '/api/simgeometry/' }, ], diff --git a/docs/package-lock.json b/docs/package-lock.json index 8fbfb72..78707a8 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -1223,8 +1223,8 @@ "license": "MIT" }, "node_modules/@max-models/starlight-theme": { - "version": "0.7.0", - "resolved": "git+ssh://git@github.com/max-models/starlight-theme.git#35ba7be44b183dd82e92f638a46d095a3fbaa23d", + "version": "0.7.4", + "resolved": "git+ssh://git@github.com/max-models/starlight-theme.git#24889b06c9553ce4af91ea2563ce9130ce190ef4", "license": "MIT", "workspaces": [ "example/docs" @@ -1232,7 +1232,7 @@ "dependencies": { "@astrojs/markdown-remark": "^7.3.2", "@mdx-js/mdx": "^3.1.1", - "katex": "^0.19.0", + "katex": "^0.16.47", "plotly.js-dist-min": "^4.1.2", "rehype-katex": "^7.0.1", "remark-math": "^6.0.0", @@ -2301,12 +2301,12 @@ } }, "node_modules/commander": { - "version": "15.0.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", - "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", "license": "MIT", "engines": { - "node": ">=22.12.0" + "node": ">= 12" } }, "node_modules/common-ancestor-path": { @@ -3541,16 +3541,16 @@ "license": "MIT" }, "node_modules/katex": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/katex/-/katex-0.19.0.tgz", - "integrity": "sha512-v6Tznz3zJ7u3niRCoDTsumM2+HA2XXcCu+WAacCeHD2z3p9A9Ks987o5FzfTGBN0e8A0vjEgIDvLbICrXpdw/Q==", + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", "funding": [ "https://opencollective.com/katex", "https://github.com/sponsors/katex" ], "license": "MIT", "dependencies": { - "commander": "^15.0.0" + "commander": "^8.3.0" }, "bin": { "katex": "cli.js" @@ -4456,31 +4456,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/micromark-extension-math/node_modules/commander": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", - "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/micromark-extension-math/node_modules/katex": { - "version": "0.16.47", - "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", - "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", - "funding": [ - "https://opencollective.com/katex", - "https://github.com/sponsors/katex" - ], - "license": "MIT", - "dependencies": { - "commander": "^8.3.0" - }, - "bin": { - "katex": "cli.js" - } - }, "node_modules/micromark-extension-mdx-expression": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/micromark-extension-mdx-expression/-/micromark-extension-mdx-expression-3.0.1.tgz", @@ -5540,31 +5515,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/rehype-katex/node_modules/commander": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", - "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/rehype-katex/node_modules/katex": { - "version": "0.16.47", - "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", - "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", - "funding": [ - "https://opencollective.com/katex", - "https://github.com/sponsors/katex" - ], - "license": "MIT", - "dependencies": { - "commander": "^8.3.0" - }, - "bin": { - "katex": "cli.js" - } - }, "node_modules/rehype-raw": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", diff --git a/docs/src/content/docs/numerics/index.mdx b/docs/src/content/docs/numerics/index.mdx new file mode 100644 index 0000000..f1429d0 --- /dev/null +++ b/docs/src/content/docs/numerics/index.mdx @@ -0,0 +1,119 @@ +--- +title: Overview +description: The geometry simgeometry computes, from the shapes and their level sets to the crossings, the cut cells and the decomposition of the grid, and what it leaves out. +sidebar: + label: Overview +--- + +import MethodCard from '@max-models/starlight-theme/components/MethodCard.astro'; + +simgeometry is the geometry of the simulation codes, nothing physical: shapes with an inside +test, a signed distance and a normal; the first crossing of a particle's step with them; the +cut-cell data of a grid; and the uniform grids and the MPI process grid that splits them. +Every array query takes component-major points $p$ of shape `(ndim, n)` and runs on NumPy or, +through cunumpy, on CuPy; the hot loops are compiled Pyccel kernels. + +Each equation on these pages is declared on the code that implements it, as a method card, and +checked by the tests: 19 closed forms (the level sets of the shapes, the Boolean and affine +level sets, the analytic crossings) against SymPy rules, three discretisations against their +order of convergence. The pages of the section list the cards module by module; this one shows +how they fit together. + +## Shapes + +A shape is a set $S$ with a membership test (`contains_points`) and, where it has an analytic +description, a level set $\phi$: negative inside, zero on the boundary, the exact Euclidean +signed distance where `level_set_exact` is true and a bound on it otherwise. An inverted shape is +the complement, with the sign of $\phi$ flipped +([shape conventions](/simgeometry/numerics/shapes/)). The level sets are the standard exact +distances of boxes, segments, capped cones and tori: + +- [Line](/simgeometry/numerics/objects-line/), [Circle](/simgeometry/numerics/objects-circle/), + [Sphere](/simgeometry/numerics/objects-sphere/); +- boxes with rounded edges: [Rectangle](/simgeometry/numerics/objects-rectangle/), + [Cuboid](/simgeometry/numerics/objects-cuboid/); +- polygons by the even-odd rule: [Polygon](/simgeometry/numerics/objects-polygon/), + [Triangle](/simgeometry/numerics/objects-triangle/), + [Aperture2D](/simgeometry/numerics/objects-aperture2d/), and the profile of + [Aperture3D](/simgeometry/numerics/objects-aperture3d/) (no level set); +- [solids of revolution](/simgeometry/numerics/objects-axisymmetric/), whose distance is that + of their profile in the half-plane $(s, \rho)$: + [Cylinder](/simgeometry/numerics/objects-cylinder/), + [Tube](/simgeometry/numerics/objects-tube/), [Cone](/simgeometry/numerics/objects-cone/), + [Torus](/simgeometry/numerics/objects-torus/), and the + [SlantedTube](/simgeometry/numerics/objects-slanted-tube/), a bound near its cut; +- [Booleans, affine transforms and sections](/simgeometry/numerics/operations/), whose level + sets are bounds, and [collections](/simgeometry/numerics/collection/) of labelled objects. + + + +2D shapes also give their boundary as counter-clockwise loops (`outlines`, second order in the +spacing for curved boundaries) and as [boundary panels](/simgeometry/numerics/panels/) for +boundary-element solvers. + +## Crossings + +A particle pusher tests the whole step $a \to b$ of every marker, not only its end, so a fast +particle cannot pass through a thin plate. [First crossings](/simgeometry/numerics/crossings/) +are analytic for balls, boxes, polygons, slanted tubes and their affine images; other shapes +are sampled along the step and refined by Illinois steps on the level set, or bisected on +membership without one. + + + +The compiled [swept absorption](/simgeometry/numerics/polygon-objects/) of 2D markers runs the +same [segment-edge test](/simgeometry/numerics/kernels-polygon-hits/) against packed polygon +objects in one pass and returns the object each marker hit first. + +## Cut cells + +[Edge crossings](/simgeometry/numerics/queries/) locate where the edges of a grid enter a shape +(Illinois on the level set, bisection otherwise); [cut edges](/simgeometry/numerics/cut-edges/) +collect them per grid edge for embedded-boundary finite differences. +[Cut-cell fractions](/simgeometry/numerics/fractions/) give the inside part of every cell by +midpoint quadrature, and of every edge and face of a Yee grid, with the trace of the surface, +for conformal field solvers. + +## Grids and decomposition + +A [grid](/simgeometry/numerics/grid/) has uniform nodes per axis, the first and last on the +bounds (one spacing short of the upper bound on a periodic axis). The +[domain decomposition](/simgeometry/numerics/domain-decomposition/) splits it over a process +grid with row-major ranks: one near-even split of the nodes, shared by the field arrays +(mpiarray) and the markers, whose owner follows from the subdomain edges. The +[particle boundaries](/simgeometry/numerics/simulation-domain/) of the box kill, reflect, wrap +or reinject markers per axis. + +## Conventions + +Lengths are in the units of the caller. Shapes and grids are axis-aligned unless an axis or a +matrix is given. Normals have unit length and point out of the region where `contains` is +true, so they flip for inverted shapes. Fractions along a step lie in $(0, 1]$: a crossing at +the very start ($t \le 10^{-12}$) is not reported, one exactly at the end is. Boundary +conventions of the inside tests differ: `Cuboid`, `Line` and the balls are closed; `Rectangle`, +`Polygon`, `Triangle` and `Aperture3D` use the half-open bounding box $l \le p < u$. + +## Not covered + +- No winding-number test: self-intersecting polygons follow the even-odd rule. +- The absorption kernel reports the object only, not the hit point, edge or normal, and tests + the end points of long (wrapped) steps only. +- Sampled crossings miss features thinner than a step over `samples`; Boolean, section and + collection level sets are bounds, so `signed_distance` falls back to a sampled mesh for them. +- `Aperture3D` and function objects have no level set and no analytic crossing; a section of a + 3D shape only bounds the in-plane distance. +- Halo exchange, ghost cells and field layouts are mpiarray's; simgeometry hands over the + process grid and the halo width. +- The process-grid search does not balance the axes and never fails. +- The cut-cell fractions have no single order: the Yee fractions are exact for planes and second + order or better for curved surfaces, the midpoint fractions are multiples of $n^{-d}$. + +## Verification + +Besides the checks of the cards, the tests compare every vectorised inside test with the scalar +one, the compiled kernels (Pyccel, NumPy, CUDA in emulation) with each other and with the +shapes, the level sets with exact distances and the Boolean bounds, the analytic crossings with +dense sampling, the Illinois roots with bisection (fewer than 10 level-set evaluations per edge +against 50 membership tests), the Yee fractions with planes (exact) and with a circle and a +sphere (area, perimeter and volume), and the decomposition with brute-force owners and with +runs on 4 and 6 MPI ranks. diff --git a/pyproject.toml b/pyproject.toml index df192bc..40c899f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ classifiers = [ dependencies = [ "cunumpy>=0.6.2", "maybempi>=0.1", + "methodcard", "mpiarray>=0.1.0", "numpy", "scope-profiler>=0.7.5", @@ -53,7 +54,7 @@ optional-dependencies.mpi = [ "mpi4py" ] optional-dependencies.pyccel = [ "pyccel>2.0" ] optional-dependencies.gpu = [ "cupy-cuda12x" ] optional-dependencies.metal = [ "mlx; sys_platform == 'darwin' and platform_machine == 'arm64'" ] -optional-dependencies.test = [ "mpi4py", "pytest", "pytest-cov", "simgeometry[clip]" ] +optional-dependencies.test = [ "methodcard[symbolic]", "mpi4py", "pytest", "pytest-cov", "simgeometry[clip]" ] urls.Source = "https://github.com/max-models/simgeometry" [project.entry-points."scope_profiler.opt_in_tags"] @@ -87,6 +88,7 @@ reportMissingImports = "error" ini_options.pythonpath = [ "src" ] ini_options.testpaths = [ "src" ] ini_options.addopts = "-ra" +ini_options.markers = [ "convergence: an order-of-accuracy test of a method card" ] [tool.coverage] run.omit = [ "*/tests/*" ] @@ -98,6 +100,7 @@ report.show_missing = true [tool.uv.sources] cunumpy = { git = "https://github.com/max-models/cunumpy", branch = "devel" } maybempi = { git = "https://github.com/max-models/maybempi", branch = "devel" } +methodcard = { git = "https://github.com/max-models/methodcard", branch = "devel" } mpiarray = { git = "https://github.com/max-models/mpiarray", branch = "devel" } plasma-plots = { git = "https://github.com/max-models/plasma-plots", branch = "devel" } scope-profiler = { git = "https://github.com/max-models/scope-profiler", branch = "devel" } diff --git a/src/simgeometry/_decomposition_views.py b/src/simgeometry/_decomposition_views.py index 52955b0..41e7340 100644 --- a/src/simgeometry/_decomposition_views.py +++ b/src/simgeometry/_decomposition_views.py @@ -1,6 +1,6 @@ """Views of a domain decomposition: one box (3D) or rectangle (2D) per rank. -Used by :meth:`DomainDecomposition.show_3d` and :meth:`DomainDecomposition.show`. +Used by `DomainDecomposition.show_3d` and `DomainDecomposition.show`. Nothing here communicates; the boxes come from ``subdomain_boxes``. """ diff --git a/src/simgeometry/_level_set.py b/src/simgeometry/_level_set.py index f8b472f..2c0f45c 100644 --- a/src/simgeometry/_level_set.py +++ b/src/simgeometry/_level_set.py @@ -21,7 +21,7 @@ def has_level_set(shape) -> bool: - """Whether ``shape`` provides :meth:`~simgeometry.shapes.Shape.level_set`.""" + """Whether ``shape`` provides `level_set`.""" return bool(getattr(shape, "has_level_set", False)) @@ -76,7 +76,7 @@ def segment_distance(points, starts, ends): """Unsigned distance of the ``(2, n)`` points to the nearest of the segments. ``starts`` and ``ends`` are ``(2, m)``. Points are processed in chunks so that - at most :data:`MAX_PAIRS` point-segment pairs are held at once. + at most `MAX_PAIRS` point-segment pairs are held at once. """ n, m = points.shape[1], starts.shape[1] edge = ends - starts @@ -178,7 +178,7 @@ def illinois_roots( def level_set_gradient(shape, points, spacing: float, array_xp=np): """Central-difference gradient ``(ndim, n)`` of ``shape.level_set`` at the points. - ``array_xp`` is the array module of ``points``, as in :func:`illinois_roots`. + ``array_xp`` is the array module of ``points``, as in `illinois_roots`. """ def level(p): diff --git a/src/simgeometry/api.py b/src/simgeometry/api.py index 11af2b2..c79dd3a 100644 --- a/src/simgeometry/api.py +++ b/src/simgeometry/api.py @@ -153,7 +153,7 @@ def crossings(self, start, end, *, samples=64, iterations=40): """First boundary crossing of every segment ``start[:, i] -> end[:, i]``. ``start`` and ``end`` are ``(ndim, N)``, such as particle positions before and - after a step. Returns a :class:`~simgeometry.crossings.Crossings` with ``hit``, + after a step. Returns a `Crossings` with ``hit``, ``fraction``, ``point`` and ``normal`` (out of the region where ``contains`` is true) on the active array backend. Polygons, circles, spheres, boxes and their affine transforms are analytic; other shapes sample ``samples`` points per @@ -254,7 +254,7 @@ def cell_fractions(self, grid, *, samples_per_axis=4): def yee_fractions(self, grid, **kwargs): """Edge-length and face-area fractions of a Yee grid, and the surface's traces. - Exact for planes; see :func:`simgeometry.fractions.yee_fractions`. + Exact for planes; see `simgeometry.fractions.yee_fractions`. """ from simgeometry.fractions import yee_fractions diff --git a/src/simgeometry/collection.py b/src/simgeometry/collection.py index fb3c1ca..79ab634 100644 --- a/src/simgeometry/collection.py +++ b/src/simgeometry/collection.py @@ -9,6 +9,7 @@ import cunumpy as xp import numpy as np import scope_profiler as sp +from methodcard import method from simgeometry._profiling import _PROFILE_TAGS from simgeometry.operations import mask @@ -20,7 +21,7 @@ @dataclass(frozen=True) class DirichletValues: - """The object and the voltage at each point of :meth:`GeometryCollection.dirichlet_values`. + """The object and the voltage at each point of `GeometryCollection.dirichlet_values`. ``labels`` are the collection's labels (0 outside every object, ``i`` inside ``names[i - 1]``, the last listed object winning in overlaps). ``voltages`` is the @@ -38,6 +39,17 @@ def conductor(self) -> np.ndarray: return np.isfinite(self.voltages) +@method( + equations={ + "labels": r"\ell(p) = \max\{k : p \in S_k\}, \qquad \ell(p) = 0 \text{ outside every object}", + "level_set": r"\phi(p) = \min_k \phi_k(p)", + "dirichlet": r"V(p, t) = V_{\ell(p)}(t), \qquad \text{NaN where } \ell(p) = 0 \text{ or the object has no voltage}", + }, + assumptions=( + r"objects $S_1, \dots, S_N$ in insertion order: the last listed object wins overlaps, also one without a voltage", + "the level set, when every object has one, is that of the union: a bound on the distance", + ), +) class GeometryCollection(Shape): """Named objects; last listed object wins labels in overlaps. @@ -48,9 +60,9 @@ class GeometryCollection(Shape): ``voltages`` makes named objects conductors: it maps names to a voltage, either a number or a function of the time ``t`` (a ramped or RF electrode). Objects without - a voltage are not conductors. :meth:`with_voltages` returns the same geometry with - other voltages, :meth:`voltages_at` evaluates them at a time and - :meth:`dirichlet_values` gives the voltage at points or grid nodes. + a voltage are not conductors. `with_voltages` returns the same geometry with + other voltages, `voltages_at` evaluates them at a time and + `dirichlet_values` gives the voltage at points or grid nodes. The level set, when every object has one, is the minimum of theirs (the union). """ @@ -134,9 +146,9 @@ def voltages_at(self, t=0.0): def dirichlet_values(self, where, *, t=0.0): """The label and the voltage of the object at each point, for Dirichlet nodes. - ``where`` is a :class:`~simgeometry.grid.Grid` (results have its shape) or - ``(ndim, n)`` points. Returns :class:`DirichletValues`; a point outside every - conductor has a NaN voltage. Overlaps follow :meth:`labels`: the last listed + ``where`` is a `Grid` (results have its shape) or + ``(ndim, n)`` points. Returns `DirichletValues`; a point outside every + conductor has a NaN voltage. Overlaps follow `labels`: the last listed object wins, even if it is not a conductor. """ if hasattr(where, "node_points") and hasattr(where, "num_gridpoints"): diff --git a/src/simgeometry/crossings.py b/src/simgeometry/crossings.py index 98c00ec..58ccb3a 100644 --- a/src/simgeometry/crossings.py +++ b/src/simgeometry/crossings.py @@ -1,6 +1,6 @@ """First boundary crossings of many segments at once, for particle pushers. -:func:`crossings` takes the start and end of every particle's step as component-major +`crossings` takes the start and end of every particle's step as component-major ``(ndim, N)`` arrays and finds, for each step, the first point where it crosses the shape's boundary, entering or leaving. Because the whole step is tested, not only its end point, a fast particle cannot pass through a thin plate unnoticed. @@ -15,17 +15,17 @@ gradient. Conventions: fractions lie in ``(0, 1]``. A crossing at the very start of a step -(fraction below :data:`START_TOLERANCE`) is not reported, so a particle that stops +(fraction below `START_TOLERANCE`) is not reported, so a particle that stops exactly on a boundary is not counted again on its next step; a crossing exactly at the end is. Grazing contacts (a step that touches a circle, or slides along an edge) are not crossings. Normals have unit length and point out of the region where ``contains`` is true, so they flip for inverted shapes. An open curve (a shape with ``closed = False``) has no inside: its normal faces the incoming step. -A :class:`~simgeometry.collection.GeometryCollection` is a set of absorbing surfaces +A `GeometryCollection` is a set of absorbing surfaces (dump plates, calorimeter panels, a duct): each object is tested on its own path (analytic where it is), the earliest crossing wins and ``surface`` tells which object -was hit, so :meth:`Crossings.per_surface` sums the power each surface receives. +was hit, so `Crossings.per_surface` sums the power each surface receives. """ from __future__ import annotations @@ -36,6 +36,7 @@ import cunumpy as xp import numpy as np import scope_profiler as sp +from methodcard import ConvergenceProblem, Rule, method from simgeometry._level_set import has_level_set, illinois_roots, level_set_gradient from simgeometry._profiling import _PROFILE_TAGS @@ -283,7 +284,7 @@ def _inside(shape, a, d, ts): def _estimated_normal(shape, points): """Unit normal out of the contains-region from membership in many directions. - The vectorised form of :func:`simgeometry.queries.estimate_surface_normal`: each + The vectorised form of `simgeometry.queries.estimate_surface_normal`: each point is probed a small distance away in a fan (2D) or sphere (3D) of directions. """ ndim, n = points.shape @@ -402,6 +403,90 @@ def _collection(shape, a, d, samples, iterations): return t, normal, exact, surface +def _fraction(shape, start, end) -> float: + """The fraction of the first crossing of one step (for the rules of the tests).""" + result = crossings(shape, np.reshape(start, (-1, 1)), np.reshape(end, (-1, 1))) + return float(np.asarray(xp.to_numpy(result.fraction))[0]) + + +def _crossing_rules() -> dict: + """The SymPy rules of the analytic crossings: a circle and a box (for the tests).""" + import sympy + + from simgeometry.objects import Circle, Rectangle + + x0, y0, x1, y1, r = sympy.symbols("x0 y0 x1 y1 r", real=True) + dx, dy = x1 - x0, y1 - y0 + alpha, beta = dx**2 + dy**2, x0 * dx + y0 * dy + gamma = x0**2 + y0**2 - r**2 + ball = (-beta - sympy.sqrt(beta**2 - alpha * gamma)) / alpha + slab = sympy.Max( + sympy.Min((-1 - x0) / dx, (1 - x0) / dx), + sympy.Min((-0.8 - y0) / dy, (0.8 - y0) / dy), + ) + ranges = {"x0": (-3.0, -2.0), "y0": (-2.0, 2.0), "x1": (-0.5, 0.5)} + ranges |= {"y1": (-0.5, 0.5), "r": (0.6, 1.0)} + + def circle(x0, y0, x1, y1, r): + return _fraction(Circle((0.0, 0.0), r), (x0, y0), (x1, y1)) + + def box(x0, y0, x1, y1): + return _fraction(Rectangle((0.0, 0.0), (2.0, 1.6)), (x0, y0), (x1, y1)) + + return { + "ball": Rule(ball, implementation=circle, ranges=ranges), + "slab": Rule(slab, implementation=box, ranges=ranges), + } + + +def _sampled_problem() -> ConvergenceProblem: + """Bisected crossings of the bore of an `Aperture3D` (no level set), against the exact ones.""" + from simgeometry.objects import Aperture3D + + radius = 0.5 + bore = Aperture3D("z", (0.0, 0.0), ((0.0, radius),), (-1, -1, -1), (1, 1, 1)) + rng = np.random.default_rng(1) + y = rng.uniform(-0.4, 0.4, 2000) + z = rng.uniform(-0.5, 0.5, 2000) + x0 = rng.uniform(-0.3, 0.0, 2000) + start = np.stack((x0, y, z)) + end = np.stack((np.full(2000, 0.9), y, z)) + exact = (np.sqrt(radius**2 - y**2) - x0) / (0.9 - x0) + + def error(width: float) -> float: + iterations = round(-np.log2(width)) + found = crossings(bore, start, end, samples=16, iterations=iterations) + return float(np.mean(np.abs(np.asarray(xp.to_numpy(found.fraction)) - exact))) + + return ConvergenceProblem(error, steps=[2.0**-6, 2.0**-8, 2.0**-10, 2.0**-12]) + + +@method( + equations={ + "ball": r"t = \frac{-\beta \mp \sqrt{\beta^2 - \alpha\gamma}}{\alpha}, \qquad \alpha = d\cdot d, \quad \beta = (a - c)\cdot d, \quad \gamma = |a - c|^2 - r^2", + "slab": r"t_k^\pm = \frac{l_k - a_k}{d_k}, \frac{u_k - a_k}{d_k}, \qquad t_0 = \max_k \min(t_k^-, t_k^+), \quad t_1 = \min_k \max(t_k^-, t_k^+)", + "first": r"t = \begin{cases} t_0 & t_0 \in (10^{-12}, 1] \text{ (entering)} \\ t_1 & t_1 \in (10^{-12}, 1] \text{ (leaving)} \end{cases}, \qquad \text{none if } t_0 \ge t_1", + "polygon": r"t = \frac{w \times e}{d \times e}, \quad u = \frac{w \times d}{d \times e}, \qquad w = v_j - a, \quad e = v_i - v_j, \quad a \times b = a_x b_y - a_y b_x", + "transformed": r"a' = M^{-1}(a - o), \quad d' = M^{-1} d, \qquad n = \frac{M^{-T} n'}{|M^{-T} n'|}", + "bisection": r"[t_\mathrm{lo}, t_\mathrm{hi}] \ni \tfrac{k}{s} \text{ first membership change}, \quad \text{halved } m \text{ times}, \quad t = \tfrac12 (t_\mathrm{lo} + t_\mathrm{hi})", + "illinois": r"t = \frac{t_a f_b - t_b f_a}{f_b - f_a}, \qquad f = \phi(a + t d), \quad f_a \leftarrow f_a / 2 \text{ if } a \text{ is kept twice in a row}", + "normal": r"n = \frac{\nabla\phi}{|\nabla\phi|}, \qquad \partial_k \phi \approx \frac{\phi(p + \delta e_k) - \phi(p - \delta e_k)}{2\delta}", + }, + order=1, + assumptions=( + r"each step $a \to b$, $d = b - a$; the first crossing $t \in (0, 1]$, entering or leaving; crossings at $t \le 10^{-12}$ are ignored, at $t = 1$ counted; grazing contacts and zero steps are not crossings", + r"analytic (`exact`): balls, boxes and rounded boxes (the hull of the intervals of the inner box widened along one axis and the discs, edge cylinders and corner balls of radius $f$), polygonal 2D shapes ($|d \times e| > 10^{-14}|d||e|$, $u \in [-10^{-12}, 1 + 10^{-12}]$), slanted tubes and affine images of them", + r"sampled otherwise: membership at $s + 1$ points $t = k/s$ (`samples` $s = 64$), then Illinois steps on the level set to $2^{-m}$ if it changes sign, else $m$ bisections on membership (`iterations` $m = 40$)", + r"normals point out of the region where `contains` is true: radial for balls, $\mp\operatorname{sign}(d_k)\,e_k$ for boxes, $(e_y, -e_x)/|e|$ for counter-clockwise edges; the level-set gradient ($\delta = 10^{-7}$ times the extent) or 64 (2D) or 128 (3D) membership probes for sampled shapes; flipped for inverted shapes", + "a `GeometryCollection`: each object on its own path, the earliest crossing wins, the first listed on a tie", + r"the order is that of the bisection on membership in the final bracket width $2^{-m}$; the analytic paths are exact and Illinois converges superlinearly (about 1.44)", + ), + references=( + "[Dowell and Jarratt, BIT 11, 168 (1971)](https://doi.org/10.1007/BF01934364)", + ), + symbolic=_crossing_rules, + problem=_sampled_problem, +) @sp.profile("simgeometry.crossings", tags=_PROFILE_TAGS) def crossings(shape, start, end, *, samples: int = 64, iterations: int = 40): """Return the first boundary crossing of each segment ``start[:, i] -> end[:, i]``. diff --git a/src/simgeometry/cut_edges.py b/src/simgeometry/cut_edges.py index 981bc7a..a3a6e84 100644 --- a/src/simgeometry/cut_edges.py +++ b/src/simgeometry/cut_edges.py @@ -7,6 +7,7 @@ import numpy as np import scope_profiler as sp +from methodcard import method from simgeometry._profiling import _PROFILE_TAGS from simgeometry.operations import mask @@ -188,7 +189,7 @@ def normals(self): def show_3d(self, **kwargs): """Draw the objects, the crossings and optionally normals and solid nodes. - Takes the keyword arguments of :meth:`CutEdges.show_3d`; several objects + Takes the keyword arguments of `CutEdges.show_3d`; several objects are drawn as one collection. """ from simgeometry.collection import GeometryCollection @@ -208,6 +209,19 @@ def show_3d(self, **kwargs): @sp.profile("simgeometry.bracketed_edge_geometry", tags=_PROFILE_TAGS) +@method( + equations={ + "solid": r"\text{solid} = \bigcup_k M_k, \qquad \text{owner}(i) = \max\{k : i \in M_k\}", + "cut": r"f_{(a, \pm)}(i) = \text{fraction from fluid node } i \text{ towards its solid neighbour } i \pm e_a, \quad \text{NaN where uncut}", + }, + assumptions=( + "node masks $M_k$ per object, the last listed object owning an overlap", + "every edge from a fluid node to a solid neighbour is one outside-to-inside bracket, located by `intersection_fractions` against the neighbour's owner", + "periodic axes roll the masks and wrap the probe points", + "no boundary values, stencil weights or minimum-distance clamps", + ), + components=("simgeometry.queries.intersection_fractions",), +) def bracketed_edge_geometry( grid, objects, *, spacing=None, object_masks=None, periodic=None ): @@ -423,6 +437,16 @@ def show_3d( @sp.profile("simgeometry.cut_edges", tags=_PROFILE_TAGS) +@method( + equations={ + "hits": r"t \in [0, 1]: \text{ all hits of } x_i + t\,h\,e_a, \text{ merged closer than } 10^{-10}", + "entering": r"\text{entering} = [p(t + \delta) \in S] - [p(t - \delta) \in S], \qquad \delta = \min(10^{-7}, \Delta t / 4)", + }, + assumptions=( + "the diagnostic path: every edge of the grid, all hits of `intersect_segment`, sorted by axis, lower node and fraction", + "analytic for primitives on nonperiodic edges, sampled (`samples` per edge) otherwise; features narrower than a sampling interval can be missed", + ), +) def cut_edges(geometry, grid, *, samples=64, tolerance=1e-10): """Find all surface crossings, including objects between two fluid nodes. diff --git a/src/simgeometry/cylindrical.py b/src/simgeometry/cylindrical.py index eba8d3d..bf90766 100644 --- a/src/simgeometry/cylindrical.py +++ b/src/simgeometry/cylindrical.py @@ -2,7 +2,7 @@ A marker in cylindrical coordinates stands for a ring of charge around the axis. Its weight is the number of physical particles in the ring. Markers are placed uniformly in ``(x, r)`` -and carry weights proportional to ``r`` (:func:`radial_weight_factors`), so every cell holds +and carry weights proportional to ``r`` (`radial_weight_factors`), so every cell holds about the same number of markers, also next to the axis where beams are extracted. """ diff --git a/src/simgeometry/domain_decomposition.py b/src/simgeometry/domain_decomposition.py index 1882c57..434e537 100644 --- a/src/simgeometry/domain_decomposition.py +++ b/src/simgeometry/domain_decomposition.py @@ -1,13 +1,13 @@ """Process grid of the MPI ranks and the physical subdomain each one owns. The process grid decides which rank owns which part of the domain. It is used for -particles (:meth:`DomainDecomposition.owner_ranks`) and, through -:meth:`DomainDecomposition.layout`, for the :class:`mpiarray.DistributedArray` of the +particles (`DomainDecomposition.owner_ranks`) and, through +`DomainDecomposition.layout`, for the `mpiarray.DistributedArray` of the fields, so that markers and fields agree on who owns what. -:meth:`DomainDecomposition.preview` builds the decomposition of a parallel run -without a communicator, for inspection and planning; :meth:`DomainDecomposition.show_3d` -and :meth:`DomainDecomposition.show` draw its subdomains. +`DomainDecomposition.preview` builds the decomposition of a parallel run +without a communicator, for inspection and planning; `DomainDecomposition.show_3d` +and `DomainDecomposition.show` draw its subdomains. """ from __future__ import annotations @@ -20,6 +20,7 @@ import mpiarray as mpa import numpy as np from maybempi import MPI +from methodcard import method Layout = Literal["component_major", "particle_major"] """Axis order of a positions array: ``(ndim, n)`` or ``(n, ndim)``.""" @@ -32,7 +33,7 @@ class PositionDecomposition(Protocol): The bounds of the domain are already bound in, so a container only passes positions. ``positions`` have shape ``(ndim, n)`` (component-major). This is the structural interface of the object returned by - :meth:`DomainDecomposition.position_decomposition`; other decompositions + `DomainDecomposition.position_decomposition`; other decompositions can implement it without importing simgeometry. """ @@ -50,7 +51,7 @@ def owned_mask(self, positions: Any) -> Any: class _BoundPositionDecomposition: - """A :class:`DomainDecomposition` with the grid bounds bound in.""" + """A `DomainDecomposition` with the grid bounds bound in.""" def __init__( self, @@ -91,6 +92,23 @@ def owned_mask(self, positions: Any, layout: Layout = "component_major") -> Any: return self._decomposition.owned_mask(positions, *self._grid, layout=layout) +@method( + equations={ + "rank": r"\text{rank} = \sum_i c_i \prod_{j > i} P_j", + "neighbours": r"c_i \pm 1, \text{ wrapped modulo } P_i \text{ on a periodic axis, else } \texttt{MPI.PROC\_NULL}", + "node_cuts": r"\mathrm{cut}_c = c \left\lfloor \frac{N_i}{P_i} \right\rfloor + \min(c, N_i \bmod P_i), \qquad \text{block } c \text{ owns nodes } [\mathrm{cut}_c, \mathrm{cut}_{c+1})", + "edges": r"e_c = l + \mathrm{cut}_c\, h, \qquad e_0 = l, \quad e_{P_i} = u, \qquad \text{block } c \text{ owns } [e_c, e_{c+1})", + "owner": r"c_i(x) = \#\{k = 1, \dots, P_i - 1 : e_k \le x_i\}", + }, + assumptions=( + r"the process grid $P$ from `calculate_proc_sizes`; row-major ranks, as mpiarray places them", + r"$h$ over $N - 1$ cells on a bounded axis, $N$ on a periodic one; the first $N_i \bmod P_i$ blocks are one node longer (mpiarray's `chunk_bounds`)", + r"a position below $l$ goes to block 0, one at or above $e_{P-1}$ (also $u$ and NaN) to the last block", + "replicated (more than one rank, one block): every rank holds everything at process coordinate 0; its neighbours are itself on periodic axes", + "fields (`layout`) and markers (`owner_ranks`) share the one split", + ), + components=("simgeometry.domain_decomposition.calculate_proc_sizes",), +) class DomainDecomposition: """Represent the MPI process grid of a decomposed domain.""" @@ -298,7 +316,7 @@ def __init__( """Initialize the process grid of ``comm`` for the decomposed axes. Without a communicator (``comm=None``) there is a single process. To - inspect the split of a parallel run without one, use :meth:`preview`. + inspect the split of a parallel run without one, use `preview`. """ if comm is None: size, rank = 1, 0 @@ -320,9 +338,9 @@ def preview( """Return the decomposition a ``size``-rank run would use, without a communicator. This is for inspection and planning, e.g. to draw the subdomains in - the docs (:meth:`show_3d`, :meth:`show`) or to check the local array + the docs (`show_3d`, `show`) or to check the local array sizes of a run before launching it. Nothing communicates: the result - has no communicator (``comm`` is ``None``) and :meth:`layout` raises. + has no communicator (``comm`` is ``None``) and `layout` raises. With ``size``, the processes per axis are chosen exactly as for a communicator of that size, so ``proc_sizes``, the index ranges and the @@ -333,8 +351,8 @@ def preview( The decomposition is seen from ``rank`` (``mpi_rank``, ``neighbour_ranks``, ``proc_coord``, ``owned_mask``). The read-only queries that take a rank, or return one per position, work for every - rank: :meth:`get_index_bounds`, :meth:`subdomain_bounds`, - :meth:`subdomain_boxes`, :meth:`get_proc_coord`, :meth:`owner_ranks`. + rank: `get_index_bounds`, `subdomain_bounds`, + `subdomain_boxes`, `get_proc_coord`, `owner_ranks`. Example: >>> preview = DomainDecomposition.preview(8, decompose=[True] * 3) @@ -386,7 +404,7 @@ def layout( halo: int | Sequence[int] = 0, periodic: Sequence[bool] | None = None, ) -> mpa.Layout: - """Return the :class:`mpiarray.Layout` of an array of ``shape`` on this process grid. + """Return the `mpiarray.Layout` of an array of ``shape`` on this process grid. Axes after ``ndim`` (for example the component axis of a field) are not split. ``periodic`` defaults to this decomposition's flags, padded with @@ -421,8 +439,8 @@ def node_cuts(self, shape: Sequence[int]) -> tuple[tuple[int, ...] | None, ...]: ``(0, c1, ..., n)`` along a split axis (process coordinate ``c`` owns nodes ``cuts[c] <= i < cuts[c + 1]``), ``None`` along the others. This - is the one split of the grid: the field arrays (:meth:`layout`), the - solver's matrix rows and the markers (:meth:`subdomain_edges`) all + is the one split of the grid: the field arrays (`layout`), the + solver's matrix rows and the markers (`subdomain_edges`) all follow it. """ if len(shape) != self.ndim: @@ -477,12 +495,12 @@ def subdomain_edges( ) -> list[float]: """Return the ``proc_sizes[dim] + 1`` edges of the subdomains along one axis. - The edges sit on the nodes where :meth:`node_cuts` splits the + The edges sit on the nodes where `node_cuts` splits the ``num_nodes`` nodes of the axis (``n - 1`` cells over ``[lower, upper]``, ``n`` on a periodic axis). Process coordinate ``c`` owns ``[edges[c], edges[c + 1])``, the cells whose lower node it owns; the last one also owns ``upper`` and everything beyond, the first - everything below ``lower`` (see :meth:`owner_ranks`). + everything below ``lower`` (see `owner_ranks`). """ num_procs = self.proc_sizes[dim] if num_procs == 1 or self.replicated: @@ -536,7 +554,7 @@ def subdomain_boxes( ) -> list[tuple[tuple[float, ...], tuple[float, ...]]]: """Return the physical ``(lower, upper)`` corners of every rank's subdomain. - Entry ``r`` is :meth:`subdomain_bounds` of rank ``r``, for all + Entry ``r`` is `subdomain_bounds` of rank ``r``, for all ``mpi_size`` ranks. The boxes tile the domain without gaps or overlaps (when no axis is decomposed, every rank holds the whole domain). """ @@ -568,7 +586,7 @@ def show_3d( rank legend for up to 16 ranks and colours by a ``rank`` scalar bar above that. Returns the ``pyvista.Plotter``; it is shown only with ``display=True``, and ``save`` writes a screenshot. Combine it with - :meth:`preview` to see the split of a parallel run; no MPI operation is + `preview` to see the split of a parallel run; no MPI operation is performed. """ from simgeometry._decomposition_views import show_decomposition_3d @@ -656,10 +674,10 @@ def owner_ranks( ``layout="particle_major"``. The layout is never guessed, so ``n == ndim`` is not ambiguous. The result is an ``int64`` array of shape ``(n,)`` in the same memory as ``positions``; ranks are row-major over - :attr:`proc_sizes`. + `proc_sizes`. Along each axis the subdomains are the blocks of - :meth:`subdomain_edges`: block ``c`` owns ``[edges[c], edges[c + 1])``. + `subdomain_edges`: block ``c`` owns ``[edges[c], edges[c + 1])``. **Out-of-range rule**: a position below the lower bound belongs to the first block and one at or above the upper edge (including exactly the upper bound) to the last block, that is, the block index is clipped into @@ -698,7 +716,7 @@ def owned_mask( num_gridpoints: Sequence[int], layout: Layout = "component_major", ) -> Any: - """Return which positions this rank owns, by the rule of :meth:`owner_ranks`. + """Return which positions this rank owns, by the rule of `owner_ranks`. Equal to ``owner_ranks(...) == mpi_rank`` (every rank owns everything when no axis is decomposed), but only two comparisons per decomposed @@ -739,7 +757,7 @@ def owner_rank_for_position( upper_bound: tuple[float, ...], num_gridpoints: tuple[int, ...], ) -> int: - """Return the rank owning one physical position (see :meth:`owner_ranks`).""" + """Return the rank owning one physical position (see `owner_ranks`).""" self._check_grid(lower_bound, upper_bound, num_gridpoints) point = xp.asarray(position, dtype=float).reshape(-1, 1) owners = self.owner_ranks(point, lower_bound, upper_bound, num_gridpoints) @@ -753,7 +771,7 @@ def position_decomposition( ) -> PositionDecomposition: """Return this decomposition with the grid bounds bound in. - The result implements :class:`PositionDecomposition` (``comm``, + The result implements `PositionDecomposition` (``comm``, ``rank``, ``size``, ``owners(positions)``, ``owned_mask(positions)``), which is what a particle container needs; it does not change when the bounds argument sequences are mutated afterwards. @@ -789,7 +807,7 @@ def decompose(self) -> list[bool]: @property def is_preview(self) -> bool: - """Return whether this is a :meth:`preview` of several ranks, without a communicator.""" + """Return whether this is a `preview` of several ranks, without a communicator.""" return self.comm is None and self.mpi_size > 1 @property @@ -818,6 +836,17 @@ def periodic(self) -> tuple[bool, ...]: return self._periodic +@method( + equations={ + "search": r"P_i = \operatorname{round}\!\big(S_\mathrm{rem}^{1/k}\big), \text{ lowered until } P_i \mid S_\mathrm{rem}; \qquad S_\mathrm{rem} \leftarrow S_\mathrm{rem} / P_i, \quad k \leftarrow k - 1", + }, + assumptions=( + r"$S$ ranks, $k$ the number of active (decomposed) axes, taken in order with $S_\mathrm{rem} = S$ at the start", + r"the last active axis gets $S_\mathrm{rem}$, so $\prod P_i = S$ and the search never restarts or fails", + "no axis decomposed: all ranks on the first decomposed axis (all ones when none is)", + r"not balanced across axes: 6 ranks in 3D give $(2, 1, 3)$, 16 give $(2, 2, 4)$", + ), +) def calculate_proc_sizes(size: int, domain_decomposition: list[bool]) -> list[int]: """ Calculate the optimal number of processes in each dimension based on the diff --git a/src/simgeometry/fractions.py b/src/simgeometry/fractions.py index 5b0cae0..2f81b59 100644 --- a/src/simgeometry/fractions.py +++ b/src/simgeometry/fractions.py @@ -6,6 +6,7 @@ import cunumpy as xp import numpy as np import scope_profiler as sp +from methodcard import method from simgeometry._profiling import _PROFILE_TAGS from simgeometry.operations import mask @@ -26,6 +27,17 @@ class CellFractions: samples_per_axis: int +@method( + equations={ + "samples": r"p = x_\mathrm{cell} + \frac{k + \tfrac12}{n}\,h, \qquad k = 0, \dots, n - 1 \text{ per axis}", + "fraction": r"f = \frac{\#\{\text{samples inside}\}}{n^{d}} \;\text{(cells)}, \qquad \frac{\#\{\text{inside}\}}{n^{d-1}} \;\text{(faces, normal coordinate on the face)}", + }, + assumptions=( + r"midpoint quadrature of the membership indicator, `samples_per_axis` $n$ per axis: a fraction is a multiple of $n^{-d}$, not a reconstructed interface", + "features smaller than a sample spacing can be missed", + r"periodic axes wrap the sample points and have $N$ cells; face arrays carry both periodic boundary copies", + ), +) @sp.profile("simgeometry.cell_fractions", tags=_PROFILE_TAGS) def cell_fractions(geometry, grid, *, samples_per_axis=4): """Estimate cell and face occupancy using a regular subcell sample lattice.""" @@ -130,7 +142,7 @@ def _all_hits(geometry, start, end, *, samples, max_hits): """Every crossing of the segments ``start -> end``, ``(ndim, n)``. Returns the fractions along each segment, ``(n, k)`` sorted with NaN padding, - and the unit normals there, ``(ndim, n, k)``. Repeats :func:`crossings` from just + and the unit normals there, ``(ndim, n, k)``. Repeats `crossings` from just past the last crossing found. """ from simgeometry.crossings import crossings @@ -438,13 +450,34 @@ def edge_hits(axis, across): @sp.profile("simgeometry.yee_fractions", tags=_PROFILE_TAGS) +@method( + equations={ + "edge": r"f_\mathrm{edge} = \sum_k (t_{k+1} - t_k)\,\big[\tfrac{t_k + t_{k+1}}{2} \text{ inside}\big] \;\text{(near edges, all crossings } t_k\text{)}", + "gauss": r"x_q = \tfrac12 \pm \tfrac12\sqrt{0.6},\ \tfrac12, \qquad w_q = \tfrac{5}{18}, \tfrac{8}{18}, \tfrac{5}{18} \quad \text{per piece between breakpoints}", + "face": r"f_\mathrm{face} = \tfrac12 \sum_{\text{directions}} \sum_q w_q\,\ell_\mathrm{inside}(x_q)", + "trace": r"T = h_\perp \sum_q w_q \sum_\text{crossings} \frac{|n_\parallel|}{|n_\mathrm{plane}|}, \qquad T_\mathrm{proj} = h_\perp \sum_q w_q \sum_\text{crossings} |n_\parallel| \quad \text{(summed over both directions)}", + "trace_sum": r"\hat n_\parallel^2\,d\ell + \hat n_\perp^2\,d\ell = d\ell, \qquad \sum_{\text{families}} h_a \sum T_{\mathrm{proj}, a} = 2\,\text{area}", + }, + assumptions=( + "edges whose midpoint is farther than half their length from the surface take the midpoint's membership; near ones get all crossings (repeated `crossings`, at most `max_hits`)", + "a face is cut if one of its edges is crossed or its centre is within half a diagonal of the surface; lines across a cut face at the Gauss points of each piece between the face's edge crossings, in both in-plane directions", + r"corners: where the in-plane normals of neighbouring lines turn by a sine above 0.05, the tangents' intersection becomes an extra breakpoint", + r"fractions within $10^{-12}$ of 0 or 1 are snapped; a wall lying in a grid plane counts as inside (a staircase)", + "exact for planes up to the crossings' accuracy; second order or better for curved surfaces (docstring and tests), without a single clean order", + "2D or 3D grids with at least one cell per axis", + ), + references=( + "[Yee, IEEE Trans. Antennas Propag. 14, 302 (1966)](https://doi.org/10.1109/TAP.1966.1138693)", + ), + components=("simgeometry.crossings.crossings",), +) def yee_fractions(geometry, grid, *, samples=64, max_hits=16): """Edge-length and face-area fractions of a Yee grid cut by ``geometry``. For cut-cell (conformal) finite-difference and finite-integration schemes: the - edges and faces of the grid of nodes ``grid`` (a :class:`~simgeometry.Grid`, 2D or + edges and faces of the grid of nodes ``grid`` (a `Grid`, 2D or 3D), with the parts of their lengths and areas inside the geometry and the trace of - the surface in each face. See :class:`YeeFractions` for the layout and accuracy. + the surface in each face. See `YeeFractions` for the layout and accuracy. ``samples`` is the sampling of shapes without analytic crossings and ``max_hits`` the most crossings counted per segment. """ diff --git a/src/simgeometry/grid.py b/src/simgeometry/grid.py index 3a6fde7..ed6a5cd 100644 --- a/src/simgeometry/grid.py +++ b/src/simgeometry/grid.py @@ -5,6 +5,7 @@ import cunumpy as xp import numpy as np from maybempi import MPI +from methodcard import method from numpy import ndarray from simgeometry._normalize import ensure_dim_tuple @@ -12,6 +13,18 @@ from simgeometry.boundingbox import BoundingBox +@method( + equations={ + "nodes": r"x_i = l + i\,h, \qquad i = 0, \dots, N - 1", + "spacing": r"h = \frac{u - l}{N - 1} \;\text{(bounded axis)}, \qquad h = \frac{u - l}{N} \;\text{(periodic axis)}", + "cell": r"i = \left\lfloor \frac{x - l}{h} \right\rfloor \quad \text{for } l \le x < u", + }, + assumptions=( + "uniform nodes per axis, in C order; on a bounded axis the first and last node sit on the bounds, on a periodic axis the last node is one spacing before $u$", + "outside the half-open box `get_cell_indices` returns an empty tuple", + r"ghost points only widen the local node count to $N_\mathrm{local} + 2g$; the halo exchange is mpiarray's", + ), +) class Grid(BoundingBox): """Represent grid coordinates for a bounded simulation domain.""" @@ -124,8 +137,8 @@ def indices(self) -> list[tuple[int, int]]: def node_points(self) -> np.ndarray: """Return the coordinates of every node as an ``(ndim, N)`` host array. - The nodes are in the order of :attr:`indices` (C order of the grid); - column ``n`` equals :meth:`node_coordinates` of the ``n``-th index. + The nodes are in the order of `indices` (C order of the grid); + column ``n`` equals `node_coordinates` of the ``n``-th index. """ lower = np.asarray(self.lower_bound, dtype=float) spacing = np.asarray(xp.to_numpy(self.grid_spacing), dtype=float) @@ -135,10 +148,10 @@ def node_points(self) -> np.ndarray: return lower[:, None] + index.astype(float) * spacing[:, None] def indices_from_mask(self, mask: Array) -> list[int] | list[tuple[int, ...]]: - """Return the :attr:`indices` of the nodes where the grid-shaped ``mask`` is set. + """Return the `indices` of the nodes where the grid-shaped ``mask`` is set. - ``mask`` is a boolean array of shape :attr:`num_gridpoints` (or flat, in - C order); the result keeps the order of :attr:`indices`. + ``mask`` is a boolean array of shape `num_gridpoints` (or flat, in + C order); the result keeps the order of `indices`. """ mask = np.asarray(xp.to_numpy(mask), dtype=bool).reshape( tuple(int(n) for n in self.num_gridpoints) diff --git a/src/simgeometry/kernels/__init__.py b/src/simgeometry/kernels/__init__.py index 6699051..c9b40d8 100644 --- a/src/simgeometry/kernels/__init__.py +++ b/src/simgeometry/kernels/__init__.py @@ -1,10 +1,10 @@ """simgeometry's compiled kernels, one folder per kernel. -Every kernel folder builds its kernel with :data:`KERNEL_PACKAGE`:: +Every kernel folder builds its kernel with `KERNEL_PACKAGE`:: kernel = KERNEL_PACKAGE.from_folder(__name__) -with the defaults of :class:`cunumpy.kernels.KernelPackage`: the Pyccel version is +with the defaults of `cunumpy.kernels.KernelPackage`: the Pyccel version is compiled on its first host call and kept under ``SIMGEOMETRY_KERNEL_CACHE`` (default ``~/.cache/simgeometry/kernels``; ``SIMGEOMETRY_OPENMP=0`` builds serial), the CUDA version runs for CuPy arguments and the host version for NumPy ones, one CUDA thread per entry of @@ -14,7 +14,7 @@ ``KERNEL_PACKAGE.metal_kernel`` in a function with the arguments of the Pyccel version. The Apple GPU has no float64, so they compute in float32: expect a relative error of about 1e-5 against the CPU versions. Import one only where -:func:`cunumpy.kernels.metal_available` is true. +`cunumpy.kernels.metal_available` is true. """ import cunumpy as xp diff --git a/src/simgeometry/kernels/contains_ball/contains_ball_metal.py b/src/simgeometry/kernels/contains_ball/contains_ball_metal.py index 8e66989..8824092 100644 --- a/src/simgeometry/kernels/contains_ball/contains_ball_metal.py +++ b/src/simgeometry/kernels/contains_ball/contains_ball_metal.py @@ -1,7 +1,7 @@ """The Metal version (Apple-silicon GPU, float32) of the ``contains_ball`` kernel. ``contains_ball_pyccel.py`` next to this file is the CPU version with the same -arguments. One thread per marker (``positions`` is ``(ndim, N)``); see :mod:`simgeometry.kernels`. The test is +arguments. One thread per marker (``positions`` is ``(ndim, N)``); see `simgeometry.kernels`. The test is done in float32, so a marker within about 1e-7 (relative) of the boundary may be classified differently from the CPU version. """ diff --git a/src/simgeometry/kernels/contains_box/contains_box_metal.py b/src/simgeometry/kernels/contains_box/contains_box_metal.py index 7ec3906..e51709d 100644 --- a/src/simgeometry/kernels/contains_box/contains_box_metal.py +++ b/src/simgeometry/kernels/contains_box/contains_box_metal.py @@ -1,7 +1,7 @@ """The Metal version (Apple-silicon GPU, float32) of the ``contains_box`` kernel. ``contains_box_pyccel.py`` next to this file is the CPU version with the same -arguments. One thread per marker (``positions`` is ``(ndim, N)``); see :mod:`simgeometry.kernels`. The test is +arguments. One thread per marker (``positions`` is ``(ndim, N)``); see `simgeometry.kernels`. The test is done in float32, so a marker within about 1e-7 (relative) of the boundary may be classified differently from the CPU version. """ diff --git a/src/simgeometry/kernels/contains_polygon_2d/contains_polygon_2d_metal.py b/src/simgeometry/kernels/contains_polygon_2d/contains_polygon_2d_metal.py index d6e9d15..34fe77e 100644 --- a/src/simgeometry/kernels/contains_polygon_2d/contains_polygon_2d_metal.py +++ b/src/simgeometry/kernels/contains_polygon_2d/contains_polygon_2d_metal.py @@ -1,7 +1,7 @@ """The Metal version (Apple-silicon GPU, float32) of the ``contains_polygon_2d`` kernel. ``contains_polygon_2d_pyccel.py`` next to this file is the CPU version with the same -arguments. One thread per marker (``positions`` is ``(2, N)``); see :mod:`simgeometry.kernels`. The test is +arguments. One thread per marker (``positions`` is ``(2, N)``); see `simgeometry.kernels`. The test is done in float32, so a marker within about 1e-7 (relative) of the boundary may be classified differently from the CPU version. """ diff --git a/src/simgeometry/kernels/polygon_hits.py b/src/simgeometry/kernels/polygon_hits.py index 3d9c941..79f760c 100644 --- a/src/simgeometry/kernels/polygon_hits.py +++ b/src/simgeometry/kernels/polygon_hits.py @@ -8,8 +8,48 @@ from __future__ import annotations import numpy as np +from methodcard import Rule, method +def _hit_time_rules() -> dict: + """The SymPy rule of the crossing of a step with one edge (for the tests).""" + import sympy + + x0, y0, x1, y1, a, b = sympy.symbols("x0 y0 x1 y1 a b", real=True) + + def hit_time(x0, y0, x1, y1, a, b): + edge = np.array([[a, -2.0], [b, 2.0]]) # a polygon of two vertices: one edge + times = object_hit_times( + edge, + np.array([0, 2]), + 0, + 1, + 0, + *(np.array([v]) for v in (x0, y0, x1, y1)), + np.array([True]), + ) + return float(times[0]) + + dx, dy, ex, ey, wx, wy = x1 - x0, y1 - y0, b - a, 4, a - x0, -2 - y0 + rule = (wx * ey - wy * ex) / (dx * ey - dy * ex) + ranges = {"x0": (-2.0, -1.0), "y0": (-0.5, 0.5), "x1": (1.0, 2.0)} + ranges |= {"y1": (-0.5, 0.5), "a": (-0.5, 0.5), "b": (-0.5, 0.5)} + return {"crossing": Rule(rule, implementation=hit_time, ranges=ranges)} + + +@method( + equations={ + "crossing": r"t = \frac{w \times e}{d \times e}, \quad u = \frac{w \times d}{d \times e}, \qquad d = x_1 - x_0, \quad e = v_i - v_j, \quad w = v_j - x_0", + "inside": r"\text{toggle at edge } v_j v_i \iff (y_i > y_1) \ne (y_j > y_1) \;\text{and}\; x_1 < \frac{(x_j - x_i)(y_1 - y_i)}{(y_j - y_i) + 10^{-16}} + x_i", + "hit_time": r"\tau = \begin{cases} \min\{t : 0 < t \le 1,\ 0 \le u \le 1\} & \text{a crossing (with sweep)} \\ 0 & x_1 \text{ in the solid} \\ 2 & \text{no hit} \end{cases}", + }, + assumptions=( + r"the object is the union of its polygons, negated if inverted; the end $x_1$ is tested by the even-odd rule over the vertices, without the bounding box of `Polygon.contains_points`", + r"edges parallel to the step ($d \times e = 0$) are never crossings; a crossing at $t = 0$ is not a hit, at $t = 1$ it is", + "the same arithmetic as `_object_hit_time` of the Pyccel kernel `absorb_polygons_2d` and its CUDA form `polygon_hit.cuh`", + ), + symbolic=_hit_time_rules, +) def object_hit_times( vertices: np.ndarray, polygon_offsets: np.ndarray, diff --git a/src/simgeometry/objects/aperture2d.py b/src/simgeometry/objects/aperture2d.py index fcb4427..58c7df7 100644 --- a/src/simgeometry/objects/aperture2d.py +++ b/src/simgeometry/objects/aperture2d.py @@ -3,6 +3,7 @@ from __future__ import annotations import numpy as np +from methodcard import method from numpy import ndarray from simgeometry._types import ConfigValue @@ -10,6 +11,18 @@ from simgeometry.shapes import Shape2D +@method( + equations={ + "plates": r"P_\pm = \{(s_k,\ c \pm r_k)\}_k, \qquad S = P_- \cup P_+", + "level_set": r"\phi(p) = \pm \min_{\text{edges of } P_\pm} \left| p - a - \operatorname{clip}\!\left(\tfrac{(p - a)\cdot e}{|e|^2}, 0, 1\right) e \right|", + }, + assumptions=( + r"the profile $(s_k, r_k)$: $s$ along the beam axis, $r$ across it from the centre line at $c$ = `position`", + "each plate is a `Polygon`: even-odd rule inside its half-open bounding box", + r"$\phi$ is negative inside a plate and exact while the plates are apart (every $r_k > 0$), a bound otherwise", + ), + components=(Polygon,), +) class Aperture2D(Shape2D): """Two plates mirrored about a beam axis: a slit, or the 2D cut of a round aperture. @@ -17,10 +30,10 @@ class Aperture2D(Shape2D): where the other coordinate equals ``position``. ``vertices`` are the plate profile as ``(axial, radial)`` pairs: the coordinate along the beam and the distance from the centre line. One plate is the polygon at ``position + radial``, the other its - mirror image at ``position - radial``. :meth:`slit` builds the common rectangular + mirror image at ``position - radial``. `slit` builds the common rectangular plates. - Its :meth:`level_set` is the signed distance to the plates' edges, exact while the + Its `level_set` is the signed distance to the plates' edges, exact while the plates do not touch (every radial coordinate positive). """ @@ -153,7 +166,7 @@ def contains(self, coords: ndarray) -> bool: return self._apply_inversion(inside) def contains_points(self, points: ndarray) -> ndarray: - """Vectorised :meth:`contains`: a mask of the ``(2, n)`` points inside.""" + """Vectorised `contains`: a mask of the ``(2, n)`` points inside.""" inside = self._lower_polygon.contains_points( points ) | self._upper_polygon.contains_points(points) diff --git a/src/simgeometry/objects/aperture3d.py b/src/simgeometry/objects/aperture3d.py index 5e64b24..04a44ba 100644 --- a/src/simgeometry/objects/aperture3d.py +++ b/src/simgeometry/objects/aperture3d.py @@ -3,11 +3,25 @@ from __future__ import annotations import cunumpy as xp +from methodcard import method from simgeometry._types import Array, ConfigValue from simgeometry.shapes import Shape3D, _component_major +@method( + equations={ + "radius": r"r(z) = r_{k-1} + \frac{z - z_{k-1}}{z_k - z_{k-1}}\,(r_k - r_{k-1}), \qquad z_{k-1} < z \le z_k", + "opening": r"\text{open} \iff \rho^2 < r_{\min}^2 \;\text{or}\; \rho^2 \le r(z)^2, \qquad \rho^2 = |p_\perp - c|^2", + "inside": r"p \in S \iff l \le p < u \;\text{and}\; \lnot\,\text{open} \quad (\text{inverted, the default})", + }, + assumptions=( + r"the profile of (axial position $z_k$, radius $r_k$) vertices about the axis through `center_coords`; $k$ from `searchsorted` (side left)", + "beyond the first or last vertex the end radius holds", + "the inversion applies before the half-open box test: the solid is the material around the opening within the box only", + "no level set and no analytic crossing: crossings are sampled", + ), +) class Aperture3D(Shape3D): """Represent a three-dimensional aperture embedded in a simulation domain.""" @@ -309,7 +323,7 @@ def contains(self, coords: Array) -> bool: return (not inside) if self.inverted else inside def contains_points(self, points: Array) -> Array: - """Vectorized :meth:`contains`: a mask of the ``(3, n)`` points inside.""" + """Vectorized `contains`: a mask of the ``(3, n)`` points inside.""" points = _component_major(points, 3) if points.shape[1] == 0: return xp.zeros(0, dtype=bool) diff --git a/src/simgeometry/objects/axisymmetric.py b/src/simgeometry/objects/axisymmetric.py index 4c6f389..8fda2dc 100644 --- a/src/simgeometry/objects/axisymmetric.py +++ b/src/simgeometry/objects/axisymmetric.py @@ -6,6 +6,7 @@ import cunumpy as xp import numpy as np +from methodcard import method from simgeometry._types import Array, BoundsLike from simgeometry.shapes import Shape3D, _component_major @@ -19,6 +20,42 @@ def _vector(value: BoundsLike, name: str) -> np.ndarray: return vector +def _frame(p, centre, axis): + """The SymPy axial coordinate and distance from the axis (for the rules of the tests).""" + import sympy + + relative = [pk - ck for pk, ck in zip(p, centre)] + s = sum(rk * ak for rk, ak in zip(relative, axis)) + rho = sympy.sqrt(sum((rk - s * ak) ** 2 for rk, ak in zip(relative, axis))) + return s, rho + + +def _rectangle_distance(x, y, half_x, half_y): + """The SymPy signed distance to the rectangle ``|x| <= half_x, |y| <= half_y``.""" + import sympy + + dx, dy = sympy.Abs(x) - half_x, sympy.Abs(y) - half_y + return sympy.sqrt(sympy.Max(dx, 0) ** 2 + sympy.Max(dy, 0) ** 2) + sympy.Min( + sympy.Max(dx, dy), 0 + ) + + +#: An oblique unit axis and a centre for the rules of the solids of revolution. +_AXIS = (1.0 / 3.0, 2.0 / 3.0, 2.0 / 3.0) +_CENTRE = (0.1, -0.2, 0.3) + + +@method( + equations={ + "frame": r"s = (p - c)\cdot a, \qquad \rho = |p - c - s\,a|", + "level_set": r"\phi(p) = \phi_\mathrm{profile}(s, \rho)", + }, + assumptions=( + r"unit axis $a$ through the centre $c$; the profile lives in the half-plane $(s, \rho \ge 0)$", + "the exact signed distance of the profile there is the exact 3D distance of the solid", + "a frame along a coordinate axis takes a cheaper path with the same values", + ), +) class AxisymmetricShape(Shape3D): """A solid of revolution about an axis through ``midpoint`` along ``axis``. @@ -94,7 +131,7 @@ def _update_bounds(self) -> None: self.set_bounding_box(*self._bounds()) def contains_points(self, points: Array) -> Array: - """Vectorised :meth:`contains`: a mask of the ``(3, n)`` points inside.""" + """Vectorised `contains`: a mask of the ``(3, n)`` points inside.""" points = _component_major(points, 3) s, rho2 = self._axial_radial(points, squared=True) inside = self._inside(s, rho2) diff --git a/src/simgeometry/objects/circle.py b/src/simgeometry/objects/circle.py index 1bf5a92..6868309 100644 --- a/src/simgeometry/objects/circle.py +++ b/src/simgeometry/objects/circle.py @@ -4,15 +4,47 @@ import cunumpy as xp import numpy as np +from methodcard import Rule, method from simgeometry._types import Array, BoundsLike, ConfigValue -from simgeometry.shapes import Shape2D, Spacing, _component_major, _distribute - - +from simgeometry.shapes import ( + Shape2D, + Spacing, + _component_major, + _distribute, + _level_set_at, +) + + +def _circle_rules() -> dict: + """The SymPy rule of the `Circle` level set (for the tests).""" + import sympy + + x, y, cx, cy, r = sympy.symbols("x y cx cy r", real=True) + + def level_set(x, y, cx, cy, r): + return _level_set_at(Circle((cx, cy), r), x, y) + + rule = sympy.sqrt((x - cx) ** 2 + (y - cy) ** 2) - r + ranges = {"x": (-3.0, 3.0), "y": (-3.0, 3.0), "r": (0.2, 2.0)} + return {"level_set": Rule(rule, implementation=level_set, ranges=ranges)} + + +@method( + equations={ + "inside": r"p \in S \iff |p - c|^2 \le r^2", + "level_set": r"\phi(p) = |p - c| - r", + }, + assumptions=( + "closed: points on the circle are inside", + r"$\phi$ is the exact signed distance, negative inside", + ), + symbolic=_circle_rules, +) class Circle(Shape2D): """Represent a circular geometry object. - Its :meth:`level_set` is the exact signed distance ``|p - midpoint| - radius``. + Its `level_set` is the exact signed distance ``|p - midpoint| - radius``. """ has_level_set = True @@ -135,7 +167,7 @@ def contains(self, coords: Array) -> bool: return self._apply_inversion(bool(distance_squared <= self.radius**2)) def contains_points(self, points: Array) -> Array: - """Vectorised :meth:`contains`: a mask of the ``(2, n)`` points inside.""" + """Vectorised `contains`: a mask of the ``(2, n)`` points inside.""" points = _component_major(points, 2) midpoint = xp.asarray(self.midpoint, dtype=float)[:, None] # summed over the axes in order, as xp.sum does for one point diff --git a/src/simgeometry/objects/cone.py b/src/simgeometry/objects/cone.py index 27e3005..92cfe59 100644 --- a/src/simgeometry/objects/cone.py +++ b/src/simgeometry/objects/cone.py @@ -4,17 +4,68 @@ import cunumpy as xp import numpy as np +from methodcard import Rule, method from simgeometry._level_set import capped_cone_distance from simgeometry._types import Array, BoundsLike, ConfigValue -from simgeometry.objects.axisymmetric import CappedAxisymmetricShape - - +from simgeometry.objects.axisymmetric import ( + _AXIS, + _CENTRE, + AxisymmetricShape, + CappedAxisymmetricShape, + _frame, +) +from simgeometry.shapes import _level_set_at + + +def _cone_rules() -> dict: + """The SymPy rule of the `Cone` level set on an oblique axis (for the tests).""" + import sympy + + x, y, z, r1, r2, half = sympy.symbols("x y z r1 r2 half", real=True) + + def level_set(x, y, z, r1, r2, half): + start = np.subtract(_CENTRE, np.multiply(half, _AXIS)) + end = np.add(_CENTRE, np.multiply(half, _AXIS)) + return _level_set_at(Cone(start, end, r1, r2), x, y, z) + + s, rho = _frame((x, y, z), _CENTRE, _AXIS) + cap = sympy.Piecewise((r1, s < 0), (r2, True)) + cap_x, cap_y = rho - sympy.Min(rho, cap), sympy.Abs(s) - half + side_x, side_y = r1 - r2, -2 * half + t = ((rho - r2) * side_x + (s - half) * side_y) / (side_x**2 + side_y**2) + t = sympy.Min(sympy.Max(t, 0), 1) + slant_x, slant_y = rho - (r2 + t * side_x), s - (half + t * side_y) + distance = sympy.sqrt(sympy.Min(cap_x**2 + cap_y**2, slant_x**2 + slant_y**2)) + inside = sympy.And(slant_x < 0, cap_y < 0) + rule = sympy.Piecewise((-distance, inside), (distance, True)) + ranges = {"x": (-2.0, 2.0), "y": (-2.0, 2.0), "z": (-2.0, 2.0)} + ranges |= {"r1": (0.0, 1.0), "r2": (0.2, 1.2), "half": (0.5, 1.5)} + return {"level_set": Rule(rule, implementation=level_set, ranges=ranges)} + + +@method( + equations={ + "inside": r"p \in S \iff |s| \le L,\; \rho \le r_1 + (r_2 - r_1)\,\frac{s + L}{2L}", + "level_set": r"\phi = \pm \min\Big(\big|(\rho - \min(\rho, r_\mathrm{cap}),\ |s| - L)\big|,\ \big|(\rho, s) - q\big|\Big), \qquad q = (r_2, L) + \operatorname{clip}(\tau, 0, 1)\,(r_1 - r_2, -2L)", + "slant": r"\tau = \frac{(\rho - r_2)(r_1 - r_2) - 2L\,(s - L)}{(r_1 - r_2)^2 + 4L^2}", + }, + assumptions=( + r"radius $r_1$ at $s = -L$ (`start`), $r_2$ at $s = L$ (`end`), one of them may be zero; frame $(s, \rho)$ of `AxisymmetricShape`", + r"$r_\mathrm{cap}$ is the radius of the nearer cap; the minus sign where the point is on the inner side of the slant and $|s| < L$", + r"$\phi$ is the exact signed distance", + ), + references=( + "[Quílez, distance functions (capped cone)](https://iquilezles.org/articles/distfunctions/)", + ), + components=(AxisymmetricShape,), + symbolic=_cone_rules, +) class Cone(CappedAxisymmetricShape): """A frustum with radius ``start_radius`` at ``start`` and ``end_radius`` at ``end``. One radius may be zero, which gives a cone with its tip there. The - :meth:`level_set` is the exact signed distance. + `level_set` is the exact signed distance. Example ------- diff --git a/src/simgeometry/objects/cuboid.py b/src/simgeometry/objects/cuboid.py index bd2247d..57acbc3 100644 --- a/src/simgeometry/objects/cuboid.py +++ b/src/simgeometry/objects/cuboid.py @@ -6,18 +6,60 @@ import cunumpy as xp import numpy as np +from methodcard import Rule, method from simgeometry._level_set import box_distance from simgeometry._types import Array, BoundsLike, ConfigValue -from simgeometry.shapes import Shape3D, _component_major - - +from simgeometry.shapes import Shape3D, _component_major, _level_set_at + + +def _cuboid_rules() -> dict: + """The SymPy rule of the `Cuboid` level set, with a fillet (for the tests).""" + import sympy + + x, y, z, hx, hy, hz, f = sympy.symbols("x y z hx hy hz f", real=True) + + def level_set(x, y, z, hx, hy, hz, f): + cuboid = Cuboid((0.2, -0.1, 0.3), (2 * hx, 2 * hy, 2 * hz), fillet=f) + return _level_set_at(cuboid, x, y, z) + + d = ( + sympy.Abs(x - 0.2) - (hx - f), + sympy.Abs(y + 0.1) - (hy - f), + sympy.Abs(z - 0.3) - (hz - f), + ) + rule = ( + sympy.sqrt(sum(sympy.Max(dk, 0) ** 2 for dk in d)) + + sympy.Min(sympy.Max(*d), 0) + - f + ) + ranges = {"x": (-2.0, 2.0), "y": (-2.0, 2.0), "z": (-2.0, 2.0)} + ranges |= {"hx": (0.5, 1.5), "hy": (0.5, 1.5), "hz": (0.5, 1.5), "f": (0.0, 0.25)} + return {"level_set": Rule(rule, implementation=level_set, ranges=ranges)} + + +@method( + equations={ + "inside": r"p \in S \iff l \le p \le u \quad (f = 0), \qquad p \in S \iff \phi(p) \le 0 \quad (f > 0)", + "level_set": r"\phi(p) = \big|\max(d, 0)\big| + \min\big(\max_k d_k, 0\big) - f, \qquad d = |p - c| - (h - f)", + }, + assumptions=( + r"axis-aligned, centre $c$, half sides $h$; $|\cdot|$ and $\max(\cdot, 0)$ of $d$ componentwise", + r"the fillet $f \le \min_k h_k$ rounds the edges and corners: the box shrunk by $f$, widened by a ball of radius $f$", + "closed: points on the faces are inside", + r"$\phi$ is the exact signed distance, negative inside", + ), + references=( + "[Quílez, distance functions](https://iquilezles.org/articles/distfunctions/)", + ), + symbolic=_cuboid_rules, +) class Cuboid(Shape3D): """Represent a cuboid geometry object. ``fillet`` rounds the twelve edges and eight corners with that radius, at most half the shortest side, as ``fillet`` rounds the edges of a - :class:`~simgeometry.Cylinder`. The :meth:`level_set` is the exact signed + `Cylinder`. The `level_set` is the exact signed distance, with or without the fillet. Example @@ -200,7 +242,7 @@ def _level_set(self, points: Array) -> Array: ) def contains_points(self, points: Array) -> Array: - """Vectorised :meth:`contains`: a mask of the ``(3, n)`` points inside.""" + """Vectorised `contains`: a mask of the ``(3, n)`` points inside.""" points = _component_major(points, 3) if self.fillet > 0: inside = self._level_set(points) <= 0 diff --git a/src/simgeometry/objects/cylinder.py b/src/simgeometry/objects/cylinder.py index 3f1bff6..4e63791 100644 --- a/src/simgeometry/objects/cylinder.py +++ b/src/simgeometry/objects/cylinder.py @@ -4,17 +4,57 @@ import cunumpy as xp import numpy as np +from methodcard import Rule, method from simgeometry._level_set import rectangle_distance from simgeometry._types import Array, BoundsLike, ConfigValue -from simgeometry.objects.axisymmetric import CappedAxisymmetricShape - - +from simgeometry.objects.axisymmetric import ( + _AXIS, + _CENTRE, + AxisymmetricShape, + CappedAxisymmetricShape, + _frame, + _rectangle_distance, +) +from simgeometry.shapes import _level_set_at + + +def _cylinder_rules() -> dict: + """The SymPy rule of the `Cylinder` level set on an oblique axis (for the tests).""" + import sympy + + x, y, z, radius, half, f = sympy.symbols("x y z radius half f", real=True) + + def level_set(x, y, z, radius, half, f): + start = np.subtract(_CENTRE, np.multiply(half, _AXIS)) + end = np.add(_CENTRE, np.multiply(half, _AXIS)) + return _level_set_at(Cylinder(start, end, radius, fillet=f), x, y, z) + + s, rho = _frame((x, y, z), _CENTRE, _AXIS) + rule = _rectangle_distance(rho, s, radius - f, half - f) - f + ranges = {"x": (-2.0, 2.0), "y": (-2.0, 2.0), "z": (-2.0, 2.0)} + ranges |= {"radius": (0.5, 1.5), "half": (0.5, 1.5), "f": (0.0, 0.25)} + return {"level_set": Rule(rule, implementation=level_set, ranges=ranges)} + + +@method( + equations={ + "inside": r"p \in S \iff |s| \le L,\; \rho^2 \le R^2 \quad (f = 0), \qquad p \in S \iff \phi(p) \le 0 \quad (f > 0)", + "level_set": r"\phi = \phi_\mathrm{rect}(\rho, s;\ R - f, L - f) - f, \qquad \phi_\mathrm{rect}(x, y; h_x, h_y) = \big|\max(d, 0)\big| + \min(\max(d_x, d_y), 0), \quad d = (|x| - h_x, |y| - h_y)", + }, + assumptions=( + r"half length $L$, radius $R$, in the frame $(s, \rho)$ of `AxisymmetricShape`", + r"the fillet $f \le \min(R, L)$ rounds the two circular edges", + r"$\phi$ is the exact signed distance, negative inside", + ), + components=(AxisymmetricShape,), + symbolic=_cylinder_rules, +) class Cylinder(CappedAxisymmetricShape): """A solid circular cylinder with its axis from ``start`` to ``end``. ``fillet`` rounds the two circular edges with that radius (at most the radius - and half the length). The :meth:`level_set` is the exact signed distance, with + and half the length). The `level_set` is the exact signed distance, with or without the fillet. Example diff --git a/src/simgeometry/objects/function_object.py b/src/simgeometry/objects/function_object.py index a8d8ac6..ae60a5a 100644 --- a/src/simgeometry/objects/function_object.py +++ b/src/simgeometry/objects/function_object.py @@ -98,7 +98,7 @@ def plot(self, ax: Any = None, **kwargs: Any) -> Any: ``resolution`` (samples per axis) and ``bounds`` set the sampling of the mask, other keyword arguments style the image. With an inspection option (``x``, ``y``, ``z``, ``grid``, ``nodes``, ``normals``) this is - :meth:`Shape2D.plot`. + `Shape2D.plot`. """ if any(key in kwargs for key in ("x", "y", "z", "grid", "nodes", "normals")): return super().plot(ax=ax, **kwargs) @@ -116,7 +116,7 @@ class FunctionObject3D(Shape3D): A function object has no level set, so crossings and distances are found by sampling its membership. Prefer a primitive where one fits (a torus is - :class:`~simgeometry.Torus`, a ring electrode :class:`~simgeometry.Tube`). + `Torus`, a ring electrode `Tube`). Example ------- diff --git a/src/simgeometry/objects/line.py b/src/simgeometry/objects/line.py index 2cda1dc..b259f99 100644 --- a/src/simgeometry/objects/line.py +++ b/src/simgeometry/objects/line.py @@ -3,15 +3,41 @@ from __future__ import annotations import cunumpy as xp +from methodcard import Rule, method from simgeometry._types import Array, BoundsLike, ConfigValue -from simgeometry.shapes import Shape1D, _component_major +from simgeometry.shapes import Shape1D, _component_major, _level_set_at +def _line_rules() -> dict: + """The SymPy rule of the `Line` level set (for the tests).""" + import sympy + + x, c, w = sympy.symbols("x c w", real=True) + + def level_set(x, c, w): + return _level_set_at(Line((c,), (2 * w,)), x) + + rule = sympy.Abs(x - c) - w + ranges = {"x": (-3.0, 3.0), "c": (-1.0, 1.0), "w": (0.1, 2.0)} + return {"level_set": Rule(rule, implementation=level_set, ranges=ranges)} + + +@method( + equations={ + "inside": r"x \in S \iff l \le x \le u", + "level_set": r"\phi(x) = \left|x - \tfrac{l + u}{2}\right| - \tfrac{u - l}{2}", + }, + assumptions=( + "closed: both end points are inside", + r"$\phi$ is the exact signed distance, negative inside", + ), + symbolic=_line_rules, +) class Line(Shape1D): """Represent a line geometry object. - Its :meth:`level_set` is the exact signed distance to the interval. + Its `level_set` is the exact signed distance to the interval. """ has_level_set = True @@ -127,7 +153,7 @@ def contains(self, coords: Array) -> bool: ) def contains_points(self, points: Array) -> Array: - """Vectorised :meth:`contains`: a mask of the ``(1, n)`` points inside.""" + """Vectorised `contains`: a mask of the ``(1, n)`` points inside.""" points = _component_major(points, 1) inside = (self.lower_bound[0] <= points[0]) & (points[0] <= self.upper_bound[0]) return ~inside if self.inverted else inside diff --git a/src/simgeometry/objects/polygon.py b/src/simgeometry/objects/polygon.py index 61ec3fa..b8f62fe 100644 --- a/src/simgeometry/objects/polygon.py +++ b/src/simgeometry/objects/polygon.py @@ -4,18 +4,56 @@ import cunumpy as xp import numpy as np +from methodcard import Rule, method from numpy import ndarray from simgeometry.kernels.contains_polygon_2d import kernel as contains_polygon_2d -from simgeometry.shapes import Shape2D, _component_major +from simgeometry.shapes import Shape2D, _component_major, _level_set_at from simgeometry.utils import vertices_to_bounds +def _polygon_rules() -> dict: + """The SymPy rule of the `Polygon` level set: a rectangle's distance (for the tests).""" + import sympy + + x, y = sympy.symbols("x y", real=True) + square = Polygon([(-0.4, -0.6), (1.0, -0.6), (1.0, 0.2), (-0.4, 0.2)]) + + def level_set(x, y): + return _level_set_at(square, x, y) + + dx = sympy.Abs(x - 0.3) - 0.7 + dy = sympy.Abs(y + 0.2) - 0.4 + rule = sympy.sqrt(sympy.Max(dx, 0) ** 2 + sympy.Max(dy, 0) ** 2) + sympy.Min( + sympy.Max(dx, dy), 0 + ) + ranges = {"x": (-2.0, 2.0), "y": (-2.0, 2.0)} + return {"level_set": Rule(rule, implementation=level_set, ranges=ranges)} + + +@method( + equations={ + "inside": r"\text{toggle at edge } v_j v_i \iff (y_i > y) \ne (y_j > y) \;\text{and}\; x < \frac{(x_j - x_i)(y - y_i)}{(y_j - y_i) + 10^{-16}} + x_i", + "level_set": r"\phi(p) = \pm \min_\text{edges} \left| p - a - \operatorname{clip}\!\left(\tfrac{(p - a)\cdot e}{|e|^2}, 0, 1\right) e \right|, \qquad e = b - a", + }, + assumptions=( + "the even-odd (crossing-number) rule along a ray towards $+x$, not a winding number; $v_j$ is the vertex before $v_i$, the closing edge implied", + r"half-open in $y$, so a vertex on the ray is counted once; $10^{-16}$ guards horizontal edges", + r"inside also needs the half-open bounding box $l \le p < u$; the compiled kernel `contains_polygon_2d` runs the test", + r"$\phi$: the distance to the nearest edge, negative where `contains_points` is true; exact for a simple polygon", + ), + references=( + "W. R. Franklin, PNPOLY - Point Inclusion in Polygon Test", + "M. Shimrat, Algorithm 112: Position of point relative to polygon, Commun. ACM 5, 434 (1962)", + "[Quílez, 2D distance functions](https://iquilezles.org/articles/distfunctions2d/)", + ), + symbolic=_polygon_rules, +) class Polygon(Shape2D): """Represent a polygon geometry object. - Its :meth:`level_set` is the exact signed distance: the distance to the nearest - edge, negative where :meth:`contains_points` is true. It costs one pass per edge. + Its `level_set` is the exact signed distance: the distance to the nearest + edge, negative where `contains_points` is true. It costs one pass per edge. """ exact_outline = True @@ -169,7 +207,7 @@ def contains(self, coords: ndarray) -> bool: return self._apply_inversion(self._contains_polygon(coords)) def contains_points(self, points: ndarray) -> ndarray: - """Vectorised :meth:`contains`: a mask of the ``(2, n)`` points inside.""" + """Vectorised `contains`: a mask of the ``(2, n)`` points inside.""" points = xp.asarray(_component_major(points, 2), dtype=float) if points.shape[1] == 0: # the compiled kernels reject empty arrays return xp.zeros(0, dtype=bool) diff --git a/src/simgeometry/objects/polyline.py b/src/simgeometry/objects/polyline.py index b85cc6b..b66e522 100644 --- a/src/simgeometry/objects/polyline.py +++ b/src/simgeometry/objects/polyline.py @@ -14,10 +14,10 @@ class Polyline(Shape2D): """An open curve through ``vertices``: a thin plate, wire or grid of zero thickness. - It encloses no area, so :meth:`contains` is always False and it cannot be + It encloses no area, so `contains` is always False and it cannot be inverted or combined in Boolean shapes. Its geometry is its outline, an open polyline from the first vertex to the last (``closed`` is False), which - :meth:`~simgeometry.shapes.Shape2D.boundary_panels` and ``crossings()`` use + `boundary_panels` and ``crossings()`` use directly. """ diff --git a/src/simgeometry/objects/rectangle.py b/src/simgeometry/objects/rectangle.py index 2ab021b..5b518fe 100644 --- a/src/simgeometry/objects/rectangle.py +++ b/src/simgeometry/objects/rectangle.py @@ -6,22 +6,71 @@ import cunumpy as xp import numpy as np +from methodcard import Rule, method from simgeometry._level_set import box_distance from simgeometry._types import Array, ConfigValue -from simgeometry.shapes import Shape2D, Spacing, _component_major, _distribute +from simgeometry.shapes import ( + Shape2D, + Spacing, + _component_major, + _distribute, + _level_set_at, +) #: Vertices per rounded corner of an outline without ``max_spacing``. CORNER_POINTS = 25 +def _rectangle_rules() -> dict: + """The SymPy rule of the `Rectangle` level set, with a fillet (for the tests).""" + import sympy + + x, y, hx, hy, f = sympy.symbols("x y hx hy f", real=True) + + def level_set(x, y, hx, hy, f): + return _level_set_at(Rectangle((0.2, -0.1), (2 * hx, 2 * hy), fillet=f), x, y) + + dx = sympy.Abs(x - 0.2) - (hx - f) + dy = sympy.Abs(y + 0.1) - (hy - f) + rule = ( + sympy.sqrt(sympy.Max(dx, 0) ** 2 + sympy.Max(dy, 0) ** 2) + + sympy.Min(sympy.Max(dx, dy), 0) + - f + ) + ranges = { + "x": (-2.0, 2.0), + "y": (-2.0, 2.0), + "hx": (0.5, 1.5), + "hy": (0.5, 1.5), + "f": (0.0, 0.25), + } + return {"level_set": Rule(rule, implementation=level_set, ranges=ranges)} + + +@method( + equations={ + "inside": r"p \in S \iff l \le p < u \quad (f = 0), \qquad p \in S \iff \phi(p) \le 0 \quad (f > 0)", + "level_set": r"\phi(p) = \big|\max(d, 0)\big| + \min\big(\max_k d_k, 0\big) - f, \qquad d = |p - c| - (h - f)", + }, + assumptions=( + r"axis-aligned, centre $c$, half sides $h$; $|\cdot|$ and $\max(\cdot, 0)$ of $d$ componentwise", + r"the fillet $f \le \min_k h_k$ rounds the corners: the box shrunk by $f$, widened by a disc of radius $f$", + "without a fillet the inside test is the half-open box of `BoundingBox.contains`", + r"$\phi$ is the exact signed distance, negative inside", + ), + references=( + "[Quílez, distance functions](https://iquilezles.org/articles/distfunctions/)", + ), + symbolic=_rectangle_rules, +) class Rectangle(Shape2D): """Represent a rectangular geometry object. ``fillet`` rounds the four corners with that radius, at most half the shorter side; at half the shorter side the rectangle is a stadium (a circle for a square). Rounded corners keep the surface field of an electrode bounded. The - :meth:`level_set` is the exact signed distance, with or without the fillet. + `level_set` is the exact signed distance, with or without the fillet. Example ------- @@ -141,7 +190,7 @@ def _outline_loops(self, max_spacing: Spacing | None) -> list[np.ndarray]: """The straight edges and the quarter circles of the corners, counter-clockwise. Without ``max_spacing`` each edge is one segment and each corner has - :data:`CORNER_POINTS` vertices on its arc. + `CORNER_POINTS` vertices on its arc. """ if self.fillet == 0: return super()._outline_loops(max_spacing) @@ -203,7 +252,7 @@ def _level_set(self, points: Array) -> Array: ) def contains_points(self, points: Array) -> Array: - """Vectorised :meth:`contains`: a mask of the ``(2, n)`` points inside.""" + """Vectorised `contains`: a mask of the ``(2, n)`` points inside.""" points = _component_major(points, 2) if self.fillet > 0: inside = self._level_set(points) <= 0 diff --git a/src/simgeometry/objects/slanted_tube.py b/src/simgeometry/objects/slanted_tube.py index d49a920..3a47d4f 100644 --- a/src/simgeometry/objects/slanted_tube.py +++ b/src/simgeometry/objects/slanted_tube.py @@ -4,10 +4,11 @@ import cunumpy as xp import numpy as np +from methodcard import Rule, method from simgeometry._types import Array, BoundsLike, ConfigValue from simgeometry.objects.axisymmetric import _vector -from simgeometry.shapes import Shape3D, _component_major +from simgeometry.shapes import Shape3D, _component_major, _level_set_at def _orthonormal(axis: np.ndarray) -> tuple[np.ndarray, np.ndarray]: @@ -27,6 +28,45 @@ def _plane_roots(w_s, d_s, w_n, d_n): return [(t_start, moving_s, 0, "start"), (t_cut, moving_n, 1, "cut")] +def _slanted_tube_rules() -> dict: + """The SymPy rule of the `SlantedTube` level set, cut about the x axis (for the tests).""" + import sympy + + x, y, z, inner, outer, length, angle = sympy.symbols( + "x y z inner outer length angle", real=True + ) + + def level_set(x, y, z, inner, outer, length, angle): + normal = (0.0, -np.sin(angle), np.cos(angle)) + tube = SlantedTube((0, 0, 0), (0, 0, length), inner, outer, normal) + return _level_set_at(tube, x, y, z) + + rho = sympy.sqrt(x**2 + y**2) + radial = sympy.Max(inner - rho, rho - outer) + uncut = sympy.sqrt(sympy.Max(radial, 0) ** 2 + sympy.Max(-z, 0) ** 2) + sympy.Min( + sympy.Max(radial, -z), 0 + ) + above = -y * sympy.sin(angle) + (z - length) * sympy.cos(angle) + rule = sympy.Max(uncut, above) + ranges = {"x": (-1.0, 1.0), "y": (-1.0, 1.0), "z": (-0.5, 2.5)} + ranges |= {"inner": (0.05, 0.25), "outer": (0.3, 0.6), "length": (1.0, 2.0)} + ranges |= {"angle": (0.0, 0.8)} + return {"level_set": Rule(rule, implementation=level_set, ranges=ranges)} + + +@method( + equations={ + "frame": r"w = p - p_\mathrm{start}, \qquad s = w\cdot a, \qquad \rho = |w - s\,a|, \qquad \eta = w\cdot n - L\,(n\cdot a)", + "inside": r"p \in S \iff s \ge 0,\; \eta \le 0,\; r_i \le \rho \le r_o", + "level_set": r"\phi = \max(\phi_\mathrm{tube}, \eta), \qquad \phi_\mathrm{tube} = \big|(\max(\varrho, 0), \max(-s, 0))\big| + \min(\max(\varrho, -s), 0), \qquad \varrho = \max(r_i - \rho, \rho - r_o)", + }, + assumptions=( + r"unit axis $a$ from `start`, length $L$ to the centre of the cut; cut plane through `end` with unit outward normal $n$, $n\cdot a > 0$", + r"the cut misses the start face: $r_o \tan\theta < L$; a rod ($r_i = 0$) has $\varrho = \rho - r_o$", + r"$\phi$, the larger of the distances to the half-infinite tube and to the cut plane, is exact away from the rim of the cut and a bound near it", + ), + symbolic=_slanted_tube_rules, +) class SlantedTube(Shape3D): """A tube from ``start`` to ``end`` whose end at ``end`` is cut by a slanted plane. @@ -34,13 +74,13 @@ class SlantedTube(Shape3D): ``start`` to ``end``; ``inner_radius = 0`` gives a solid rod. The end at ``start`` is the flat face normal to the axis. The end at ``end`` is the plane through ``end`` with the outward normal ``cut_normal``, tilted from the axis by - :attr:`cut_angle`, like the mouth of an oven nozzle cut at 45°. ``length`` is + `cut_angle`, like the mouth of an oven nozzle cut at 45°. ``length`` is measured along the axis to the centre of the cut, and the cut may not reach the start face (``outer_radius * tan(cut_angle) < length``). - The :meth:`level_set` is the larger of the signed distance to the tube without + The `level_set` is the larger of the signed distance to the tube without the cut and to the cut plane: exact on the faces and inside away from the cut's - rim, a bound elsewhere (``level_set_exact`` is False). :func:`~simgeometry.crossings` + rim, a bound elsewhere (``level_set_exact`` is False). `crossings` are analytic. Example @@ -183,7 +223,7 @@ def _coordinates(self, points: Array): return s, rho, above def contains_points(self, points: Array) -> Array: - """Vectorised :meth:`contains`: a mask of the ``(3, n)`` points inside.""" + """Vectorised `contains`: a mask of the ``(3, n)`` points inside.""" points = _component_major(points, 3) s, rho, above = self._coordinates(points) inside = ( diff --git a/src/simgeometry/objects/sphere.py b/src/simgeometry/objects/sphere.py index 18b500a..7ff5254 100644 --- a/src/simgeometry/objects/sphere.py +++ b/src/simgeometry/objects/sphere.py @@ -3,15 +3,41 @@ from __future__ import annotations import cunumpy as xp +from methodcard import Rule, method from simgeometry._types import Array, ConfigValue -from simgeometry.shapes import Shape3D, _component_major +from simgeometry.shapes import Shape3D, _component_major, _level_set_at +def _sphere_rules() -> dict: + """The SymPy rule of the `Sphere` level set (for the tests).""" + import sympy + + x, y, z, r = sympy.symbols("x y z r", real=True) + + def level_set(x, y, z, r): + return _level_set_at(Sphere((0.3, -0.2, 0.1), r), x, y, z) + + rule = sympy.sqrt((x - 0.3) ** 2 + (y + 0.2) ** 2 + (z - 0.1) ** 2) - r + ranges = {"x": (-3.0, 3.0), "y": (-3.0, 3.0), "z": (-3.0, 3.0), "r": (0.2, 2.0)} + return {"level_set": Rule(rule, implementation=level_set, ranges=ranges)} + + +@method( + equations={ + "inside": r"p \in S \iff |p - c|^2 \le r^2", + "level_set": r"\phi(p) = |p - c| - r", + }, + assumptions=( + "closed: points on the sphere are inside", + r"$\phi$ is the exact signed distance, negative inside", + ), + symbolic=_sphere_rules, +) class Sphere(Shape3D): """Represent a spherical geometry object. - Its :meth:`level_set` is the exact signed distance ``|p - midpoint| - radius``. + Its `level_set` is the exact signed distance ``|p - midpoint| - radius``. """ has_level_set = True @@ -171,7 +197,7 @@ def contains(self, coords: Array) -> bool: return (not inside) if self.inverted else inside def contains_points(self, points: Array) -> Array: - """Vectorised :meth:`contains`: a mask of the ``(3, n)`` points inside.""" + """Vectorised `contains`: a mask of the ``(3, n)`` points inside.""" points = _component_major(points, 3) midpoint = xp.asarray(self.midpoint, dtype=float)[:, None] distance_squared = xp.sum((points - midpoint) ** 2, axis=0) diff --git a/src/simgeometry/objects/torus.py b/src/simgeometry/objects/torus.py index add8591..4a6ae47 100644 --- a/src/simgeometry/objects/torus.py +++ b/src/simgeometry/objects/torus.py @@ -4,16 +4,55 @@ import cunumpy as xp import numpy as np +from methodcard import Rule, method from simgeometry._types import Array, BoundsLike, ConfigValue -from simgeometry.objects.axisymmetric import AxisymmetricShape, _vector - - +from simgeometry.objects.axisymmetric import ( + _AXIS, + _CENTRE, + AxisymmetricShape, + _frame, + _vector, +) +from simgeometry.shapes import _level_set_at + + +def _torus_rules() -> dict: + """The SymPy rule of the `Torus` level set on an oblique axis (for the tests).""" + import sympy + + x, y, z, major, minor = sympy.symbols("x y z major minor", real=True) + + def level_set(x, y, z, major, minor): + return _level_set_at(Torus(_CENTRE, major, minor, axis=_AXIS), x, y, z) + + s, rho = _frame((x, y, z), _CENTRE, _AXIS) + rule = sympy.sqrt((rho - major) ** 2 + s**2) - minor + ranges = {"x": (-2.0, 2.0), "y": (-2.0, 2.0), "z": (-2.0, 2.0)} + ranges |= {"major": (1.0, 1.5), "minor": (0.2, 0.9)} + return {"level_set": Rule(rule, implementation=level_set, ranges=ranges)} + + +@method( + equations={ + "inside": r"p \in S \iff (\rho - R)^2 + s^2 \le r^2", + "level_set": r"\phi = \sqrt{(\rho - R)^2 + s^2} - r", + }, + assumptions=( + r"major radius $R$, minor radius $0 < r \le R$, centre circle in the plane $s = 0$ of the frame of `AxisymmetricShape`", + r"$\phi$ is the exact signed distance, negative inside", + ), + references=( + "[Quílez, distance functions (torus)](https://iquilezles.org/articles/distfunctions/)", + ), + components=(AxisymmetricShape,), + symbolic=_torus_rules, +) class Torus(AxisymmetricShape): """A solid torus: the points within ``minor_radius`` of a circle of ``major_radius``. The circle is centred at ``midpoint`` in the plane normal to ``axis``. The - :meth:`level_set` is the exact signed distance. + `level_set` is the exact signed distance. Example ------- diff --git a/src/simgeometry/objects/triangle.py b/src/simgeometry/objects/triangle.py index 2ac2825..61369e8 100644 --- a/src/simgeometry/objects/triangle.py +++ b/src/simgeometry/objects/triangle.py @@ -4,16 +4,61 @@ import cunumpy as xp import numpy as np +from methodcard import Rule, method from simgeometry._types import Array, ConfigValue, Vertices2DLike -from simgeometry.shapes import Shape2D, _component_major +from simgeometry.shapes import Shape2D, _component_major, _level_set_at from simgeometry.utils import vertices_to_bounds +def _triangle_rules() -> dict: + """The SymPy rule of the `Triangle` level set (for the tests). + + Membership by the sides of the edges (a counter-clockwise triangle), the distance + to the nearest edge. + """ + import sympy + + x, y = sympy.symbols("x y", real=True) + corners = [(0.0, 0.0), (1.2, 0.1), (0.2, 0.9)] + triangle = Triangle(corners) + + def level_set(x, y): + return _level_set_at(triangle, x, y) + + distances, sides = [], [] + for (ax, ay), (bx, by) in zip(corners, corners[1:] + corners[:1]): + ex, ey = bx - ax, by - ay + t = sympy.Min( + sympy.Max(((x - ax) * ex + (y - ay) * ey) / (ex**2 + ey**2), 0), 1 + ) + distances.append(sympy.sqrt((x - ax - t * ex) ** 2 + (y - ay - t * ey) ** 2)) + sides.append(ex * (y - ay) - ey * (x - ax) > 0) + distance = sympy.Min(*distances) + rule = sympy.Piecewise((-distance, sympy.And(*sides)), (distance, True)) + ranges = {"x": (-0.5, 1.5), "y": (-0.5, 1.2)} + return {"level_set": Rule(rule, implementation=level_set, ranges=ranges)} + + +@method( + equations={ + "inside": r"p = v_0 + u\,(v_2 - v_0) + v\,(v_1 - v_0), \qquad p \in S \iff u \ge 0,\; v \ge 0,\; u + v \le 1", + "level_set": r"\phi(p) = \pm \min_\text{edges} \left| p - a - \operatorname{clip}\!\left(\tfrac{(p - a)\cdot e}{|e|^2}, 0, 1\right) e \right|, \qquad e = b - a", + }, + assumptions=( + r"barycentric coordinates $u, v$ with closed edges, inside the half-open bounding box", + "a degenerate triangle contains no point", + r"$\phi$: the distance to the nearest edge, negative inside; exact", + ), + references=( + "[Quílez, 2D distance functions](https://iquilezles.org/articles/distfunctions2d/)", + ), + symbolic=_triangle_rules, +) class Triangle(Shape2D): """Represent a triangular geometry object. - Its :meth:`level_set` is the exact signed distance to its edges. + Its `level_set` is the exact signed distance to its edges. """ exact_outline = True @@ -118,7 +163,7 @@ def contains(self, coords: Array) -> bool: return bool(xp.to_numpy(self.contains_points(point))[0]) def contains_points(self, points: Array) -> Array: - """Vectorised :meth:`contains`: a mask of the ``(2, n)`` points inside.""" + """Vectorised `contains`: a mask of the ``(2, n)`` points inside.""" points = _component_major(points, 2) vertices = [xp.asarray(vertex, dtype=float) for vertex in self._vertices] lower = xp.asarray(self.bounding_box.lower_bound, dtype=float)[:, None] diff --git a/src/simgeometry/objects/tube.py b/src/simgeometry/objects/tube.py index 02b7448..3431b60 100644 --- a/src/simgeometry/objects/tube.py +++ b/src/simgeometry/objects/tube.py @@ -4,18 +4,62 @@ import cunumpy as xp import numpy as np +from methodcard import Rule, method from simgeometry._level_set import rectangle_distance from simgeometry._types import Array, BoundsLike, ConfigValue -from simgeometry.objects.axisymmetric import CappedAxisymmetricShape - - +from simgeometry.objects.axisymmetric import ( + _AXIS, + _CENTRE, + AxisymmetricShape, + CappedAxisymmetricShape, + _frame, + _rectangle_distance, +) +from simgeometry.shapes import _level_set_at + + +def _tube_rules() -> dict: + """The SymPy rule of the `Tube` level set on an oblique axis (for the tests).""" + import sympy + + x, y, z, inner, outer, half, f = sympy.symbols( + "x y z inner outer half f", real=True + ) + + def level_set(x, y, z, inner, outer, half, f): + start = np.subtract(_CENTRE, np.multiply(half, _AXIS)) + end = np.add(_CENTRE, np.multiply(half, _AXIS)) + return _level_set_at(Tube(start, end, inner, outer, fillet=f), x, y, z) + + s, rho = _frame((x, y, z), _CENTRE, _AXIS) + middle, thickness = (inner + outer) / 2, (outer - inner) / 2 + rule = _rectangle_distance(rho - middle, s, thickness - f, half - f) - f + ranges = {"x": (-2.0, 2.0), "y": (-2.0, 2.0), "z": (-2.0, 2.0)} + ranges |= {"inner": (0.3, 0.6), "outer": (0.8, 1.5), "half": (0.5, 1.5)} + ranges |= {"f": (0.0, 0.1)} + return {"level_set": Rule(rule, implementation=level_set, ranges=ranges)} + + +@method( + equations={ + "inside": r"p \in S \iff |s| \le L,\; r_i^2 \le \rho^2 \le r_o^2 \quad (f = 0), \qquad p \in S \iff \phi(p) \le 0 \quad (f > 0)", + "level_set": r"\phi = \phi_\mathrm{rect}\!\left(\rho - \tfrac{r_i + r_o}{2}, s;\ \tfrac{r_o - r_i}{2} - f, L - f\right) - f", + }, + assumptions=( + r"half length $L$, $0 < r_i < r_o$, in the frame $(s, \rho)$ of `AxisymmetricShape`; $\phi_\mathrm{rect}$ the 2D box distance as for `Cylinder`", + r"the fillet $f \le \min(\tfrac{r_o - r_i}{2}, L)$ rounds the four circular edges", + r"$\phi$ is the exact signed distance, negative inside", + ), + components=(AxisymmetricShape,), + symbolic=_tube_rules, +) class Tube(CappedAxisymmetricShape): """A hollow cylinder between ``inner_radius`` and ``outer_radius``, from ``start`` to ``end``. A short tube is a ring electrode; a long one a drift tube or a chamber wall. ``fillet`` rounds its four circular edges (at most half the wall thickness and - half the length). The :meth:`level_set` is the exact signed distance, with or + half the length). The `level_set` is the exact signed distance, with or without the fillet. Example diff --git a/src/simgeometry/operations.py b/src/simgeometry/operations.py index 3d52078..37ec1df 100644 --- a/src/simgeometry/operations.py +++ b/src/simgeometry/operations.py @@ -8,9 +8,16 @@ import cunumpy as xp import numpy as np import scope_profiler as sp +from methodcard import Rule, method from simgeometry._profiling import _PROFILE_TAGS -from simgeometry.shapes import Shape, _component_major, _refine_loop, _signed_area +from simgeometry.shapes import ( + Shape, + _component_major, + _level_set_at, + _refine_loop, + _signed_area, +) def corners(shape): @@ -54,6 +61,42 @@ def _shapely_ring(ring, *, counter_clockwise): return loop +def _boolean_rules() -> dict: + """The SymPy rules of the `BooleanShape` level sets, of two circles (for the tests).""" + import sympy + + from simgeometry.objects import Circle + + x, y = sympy.symbols("x y", real=True) + a, b = Circle((-0.3, 0.0), 0.8), Circle((0.4, 0.2), 0.6) + phi_a = sympy.sqrt((x + 0.3) ** 2 + y**2) - 0.8 + phi_b = sympy.sqrt((x - 0.4) ** 2 + (y - 0.2) ** 2) - 0.6 + ranges = {"x": (-1.5, 1.5), "y": (-1.5, 1.5)} + + def rule(shape, expr): + return Rule( + expr, implementation=lambda x, y: _level_set_at(shape, x, y), ranges=ranges + ) + + return { + "union": rule(a | b, sympy.Min(phi_a, phi_b)), + "intersection": rule(a & b, sympy.Max(phi_a, phi_b)), + "difference": rule(a - b, sympy.Max(phi_a, -phi_b)), + } + + +@method( + equations={ + "union": r"a \lor b: \quad \phi = \min(\phi_a, \phi_b)", + "intersection": r"a \land b: \quad \phi = \max(\phi_a, \phi_b)", + "difference": r"a \land \lnot b: \quad \phi = \max(\phi_a, -\phi_b)", + }, + assumptions=( + "membership combines the operands' masks; a level set needs one on both operands", + "the combined level set is a bound on the distance, not the exact distance (`level_set_exact` is False)", + ), + symbolic=_boolean_rules, +) class BooleanShape(Shape): """A snapshot of a union, intersection, or difference of two shapes. @@ -143,7 +186,7 @@ def outlines(self, max_spacing=None): return [_refine_loop(loop, max_spacing) for loop in loops] def boundary_panels(self, max_spacing=None): - """The edges of :meth:`outlines` as panels; see ``Shape2D.boundary_panels``.""" + """The edges of `outlines` as panels; see ``Shape2D.boundary_panels``.""" from simgeometry.panels import boundary_panels return boundary_panels(self, max_spacing) @@ -189,6 +232,39 @@ def _shapely_geometry(shape, max_spacing): return shapely.make_valid(geometry) +def _transformed_rules() -> dict: + """The SymPy rule of the `TransformedShape` level set, a stretched circle (for the tests).""" + import sympy + + from simgeometry.objects import Circle + + x, y, sx, sy = sympy.symbols("x y sx sy", positive=True) + + def level_set(x, y, sx, sy): + shape = TransformedShape( + Circle((0.0, 0.0), 1.0), np.diag([sx, sy]), (0.3, -0.2) + ) + return _level_set_at(shape, x, y) + + local = sympy.sqrt(((x - 0.3) / sx) ** 2 + ((y + 0.2) / sy) ** 2) - 1 + rule = sympy.Min(sx, sy) * local + ranges = {"x": (-2.0, 2.0), "y": (-2.0, 2.0), "sx": (0.5, 2.0), "sy": (0.5, 2.0)} + return {"level_set": Rule(rule, implementation=level_set, ranges=ranges)} + + +@method( + equations={ + "map": r"p \mapsto M p + o, \qquad p \in T(S) \iff M^{-1}(p - o) \in S", + "level_set": r"\phi_T(p) = \sigma_\mathrm{min}(M)\,\phi\big(M^{-1}(p - o)\big)", + "bound": r"|M^{-1}x - M^{-1}y| \le \frac{|x - y|}{\sigma_\mathrm{min}(M)}", + "normal": r"n_T = \frac{M^{-T} n}{|M^{-T} n|}", + }, + assumptions=( + r"$M$ invertible and finite; $\sigma_\mathrm{min}$ its smallest singular value", + "the level set stays a bound on the distance; it is exact only for an exact source under a similarity (rigid motion or uniform scaling)", + ), + symbolic=_transformed_rules, +) class TransformedShape(Shape): """Independent affine image of a source, mapping p to matrix @ p + offset. @@ -287,7 +363,7 @@ def exact_outline(self): return getattr(self.source, "exact_outline", False) def boundary_panels(self, max_spacing=None): - """The edges of :meth:`outlines` as panels; see ``Shape2D.boundary_panels``.""" + """The edges of `outlines` as panels; see ``Shape2D.boundary_panels``.""" from simgeometry.panels import boundary_panels return boundary_panels(self, max_spacing) @@ -344,6 +420,16 @@ def rotation(shape, angle, *, axis=(0, 0, 1), origin=None): return shape.transformed(matrix, origin - matrix @ origin) +@method( + equations={ + "plane": r"p = o + B q, \qquad q = B^\mathsf{T}(p - o)", + "level_set": r"\phi_\mathrm{section}(q) = \phi(o + B q)", + }, + assumptions=( + r"$B$ the orthonormal in-plane basis (columns), $o$ the plane origin; membership is the source's at $o + Bq$", + "the source's level set on the plane only bounds the in-plane distance (`level_set_exact` is False)", + ), +) class Section(Shape): """A membership-preserving section, including holes and disconnected pieces. @@ -457,7 +543,7 @@ def names(self): return getattr(self.source, "names", ("inside",)) def _sampled_loops(self, max_spacing, resolution, bounds): - """``(loop, closed)`` pairs of :meth:`outlines`, in plane coordinates.""" + """``(loop, closed)`` pairs of `outlines`, in plane coordinates.""" from simgeometry.mesh import _find_contours if self.ndim != 2: @@ -502,7 +588,7 @@ def outlines(self, max_spacing=None, *, resolution=128, bounds=None, world=False def show_3d(self, **kwargs): """The section in 3D: its source, the cutting plane and the section outline. - See :func:`simgeometry.visualization.show_section_3d`; returns Matplotlib + See `simgeometry.visualization.show_section_3d`; returns Matplotlib axes, or a PyVista plotter for ``backend='pyvista'``. """ from simgeometry.visualization import show_section_3d diff --git a/src/simgeometry/panels.py b/src/simgeometry/panels.py index 8ca5c6e..665638b 100644 --- a/src/simgeometry/panels.py +++ b/src/simgeometry/panels.py @@ -5,6 +5,7 @@ from dataclasses import dataclass import numpy as np +from methodcard import method @dataclass(frozen=True) @@ -15,7 +16,7 @@ class Panels: outline loop (see ``outlines()``) each panel belongs to, so the panels of one loop are contiguous and in order. ``closed`` is False for an open curve. - :attr:`normal` is the unit normal to the right of each panel's direction. Loops + `normal` is the unit normal to the right of each panel's direction. Loops keep the shape on their left, so for a closed shape it points out of the geometry, at outer boundaries and holes alike (inversion is ignored, as in ``outlines()``). For an open curve it is the side to the right of the curve's @@ -52,8 +53,18 @@ def __len__(self) -> int: return int(self.start.shape[1]) +@method( + equations={ + "panels": r"P_i = [v_i, v_{i+1}], \qquad t_i = \frac{v_{i+1} - v_i}{|v_{i+1} - v_i|}, \qquad n_i = (t_{i,y}, -t_{i,x})", + }, + assumptions=( + "the vertices $v_i$ of `outlines`, counter-clockwise, so $n_i$, to the right of $t_i$, points out of the geometry at outer boundaries and holes alike", + "a closed loop also has the panel back to its first vertex; an open curve has one panel fewer than vertices", + "zero-length edges are dropped; inversion is ignored, as in `outlines`", + ), +) def boundary_panels(shape, max_spacing=None) -> Panels: - """The edges of ``shape.outlines(max_spacing)`` as :class:`Panels`. + """The edges of ``shape.outlines(max_spacing)`` as `Panels`. Each edge of a closed loop is a panel, including the one back to its first vertex; an open curve has one panel fewer than vertices. Zero-length edges are diff --git a/src/simgeometry/polygon_objects.py b/src/simgeometry/polygon_objects.py index 1cf8cc4..194b769 100644 --- a/src/simgeometry/polygon_objects.py +++ b/src/simgeometry/polygon_objects.py @@ -1,11 +1,11 @@ """2D objects made of polygons, packed for the swept absorption kernel. -:func:`polygon_objects` packs the objects the ``absorb_polygons_2d`` kernel can -take (a :class:`~simgeometry.objects.polygon.Polygon`, an -:class:`~simgeometry.objects.aperture2d.Aperture2D`: the union of its two +`polygon_objects` packs the objects the ``absorb_polygons_2d`` kernel can +take (a `Polygon`, an +`Aperture2D`: the union of its two plates) into flat arrays, so markers are tested against every object in one pass instead of one containment call per object and sample along the step. -:meth:`PolygonObjects.absorb` runs the kernel: a marker is absorbed when it +`PolygonObjects.absorb` runs the kernel: a marker is absorbed when it ends inside an object or when its straight step from ``start`` crosses one of the object's edges, exactly (no sampling), so a plate thinner than the step is not jumped over. The object met first along the step takes the marker. @@ -29,6 +29,7 @@ import cunumpy as xp import numpy as np +from methodcard import method from simgeometry.objects.aperture2d import Aperture2D from simgeometry.objects.polygon import Polygon @@ -93,6 +94,20 @@ def kernel_arguments(self) -> tuple[Array, ...]: self.boxed, ) + @method( + equations={ + "sweep": r"\text{sweep} \iff |x_1 - x_0|^2 \le \ell_\mathrm{sweep}^2, \qquad \ell_\mathrm{sweep} > 0", + "first_object": r"k^* = \operatorname*{arg\,min}_k \tau_k, \quad \tau_k < 2; \qquad \text{alive} \leftarrow \text{false if } k^* \text{ exists}", + }, + assumptions=( + r"objects in slot order; an object $k$ with `boxed[k]` is skipped when the axis-aligned box of the step misses its bounds (an inverted object is solid outside its box and never skipped)", + r"$\tau_k$ from `object_hit_times`: a crossing in $(0, 1]$, 0 for an end inside, 2 for no hit; strictly smaller wins, so the lower slot wins a tie, and an end inside beats every crossing", + "without a start, or for a step longer than `sweep_length` (wrapped or reinjected), only the end counts", + "packed: `Polygon` and the two plates of `Aperture2D`; other objects are left out", + "returns the object only, not the hit point, edge or normal (those come from `crossings`); cost O(edges) per marker and unboxed object", + ), + components=("simgeometry.kernels.polygon_hits.object_hit_times",), + ) def absorb( self, positions: Array, diff --git a/src/simgeometry/queries.py b/src/simgeometry/queries.py index 60dc604..e577b3a 100644 --- a/src/simgeometry/queries.py +++ b/src/simgeometry/queries.py @@ -14,6 +14,7 @@ import cunumpy as xp import numpy as np import scope_profiler as sp +from methodcard import ConvergenceProblem, method from simgeometry._level_set import has_level_set, illinois_roots, level_set_gradient from simgeometry._profiling import _PROFILE_TAGS @@ -68,6 +69,48 @@ def level_values(shape, points): return np.asarray(xp.to_numpy(shape.level_set(points)), dtype=float) +def _bisection_problem() -> ConvergenceProblem: + """Edges into the solid around the bore of an `Aperture3D` (no level set).""" + from simgeometry.objects import Aperture3D + + radius = 0.5 + bore = Aperture3D("z", (0.0, 0.0), ((0.0, radius),), (-1, -1, -1), (1, 1, 1)) + rng = np.random.default_rng(2) + y = rng.uniform(-0.4, 0.4, 2000) + z = rng.uniform(-0.5, 0.5, 2000) + x0 = rng.uniform(-0.3, 0.0, 2000) + outside = np.stack((x0, y, z)) + inside = np.stack((np.full(2000, 0.9), y, z)) + exact = (np.sqrt(radius**2 - y**2) - x0) / (0.9 - x0) + + def error(width: float) -> float: + iterations = round(-np.log2(width)) + found = intersection_fractions(bore, outside, inside, iterations=iterations) + return float(np.mean(np.abs(found - exact))) + + return ConvergenceProblem(error, steps=[2.0**-6, 2.0**-8, 2.0**-10, 2.0**-12]) + + +@method( + equations={ + "secant": r"t = \frac{t_a f_b - t_b f_a}{f_b - f_a}, \qquad f(t) = \phi(a + t\,d)", + "illinois": r"(t_a, f_a) \leftarrow (t, f) \text{ if } \operatorname{sign} f = \operatorname{sign} f_a, \text{ else } (t_b, f_b) \leftarrow (t, f); \quad f_b \leftarrow f_b/2 \;(f_a \leftarrow f_a/2) \text{ if } b \;(a) \text{ is kept twice in a row}", + "converged": r"|f| \le \nu = \varepsilon\,\big(\max_k|a_k| + \max_k|d_k|\big) \;\text{or}\; |t' - t| \le \tau \;\text{or}\; |t_b - t_a| \le \tau, \qquad \tau = 2^{-m}", + "bisection": r"\min\!\Big(m, \max\big(1, \big\lceil \log_2(w\,2^{m}) \big\rceil\big)\Big) \text{ halvings on membership}, \qquad t = t_\mathrm{hi}", + }, + order=1, + assumptions=( + r"each edge from an outside point $a$ to an inside point $a + d$ brackets one crossing (`ValueError` otherwise); the fraction is measured from the outside end and not clamped", + r"with a level set and $\phi(a) > 0 > \phi(a + d)$: at most 12 Illinois steps (`SECANT_STEPS`), superlinear (order about 1.44 for a simple root)", + r"the other edges, and those Illinois left unsettled with their narrowed bracket of width $w$, are bisected on membership and return the inside end $t_\mathrm{hi}$", + "`wrap` maps periodic images into the domain before every membership or level-set call", + r"the order is that of the bisection in the final bracket width $2^{-m}$ (`iterations` $m = 48$)", + ), + references=( + "[Dowell and Jarratt, BIT 11, 168 (1971)](https://doi.org/10.1007/BF01934364)", + ), + problem=_bisection_problem, +) def intersection_fractions(obj, outside, inside, *, iterations=48, wrap=None): """Locate bracketed outside-to-inside edge crossings; return raw, unclamped fractions. diff --git a/src/simgeometry/shapes.py b/src/simgeometry/shapes.py index 958cc98..3fa6542 100644 --- a/src/simgeometry/shapes.py +++ b/src/simgeometry/shapes.py @@ -9,6 +9,7 @@ import cunumpy as xp import numpy as np import scope_profiler as sp +from methodcard import ConvergenceProblem, method from simgeometry._level_set import segment_distance from simgeometry._profiling import _PROFILE_TAGS @@ -29,12 +30,31 @@ def _component_major(points: Array, ndim: int) -> Array: return points +def _level_set_at(shape, *coords: float) -> float: + """The level set of ``shape`` at one point, as a float (for the method-card rules).""" + point = xp.asarray(np.asarray(coords, dtype=float).reshape(-1, 1)) + return float(np.asarray(xp.to_numpy(shape.level_set(point)))[0]) + + def _signed_area(loop: np.ndarray) -> float: """Shoelace area of a ``(2, n)`` loop: positive when counter-clockwise.""" x, y = loop return 0.5 * float(np.sum(x * np.roll(y, -1) - np.roll(x, -1) * y)) +def _outline_problem() -> ConvergenceProblem: + """The area enclosed by the outline of a unit circle, against pi.""" + from simgeometry.objects import Circle + + circle = Circle((0.1, 0.2), 1.0) + + def error(spacing: float) -> float: + (loop,) = circle.outlines(spacing) + return abs(_signed_area(loop) - np.pi) + + return ConvergenceProblem(error, steps=[0.2, 0.1, 0.05, 0.025]) + + #: A spacing: a length, or a function of ``(2, m)`` points returning ``(m,)`` lengths. Spacing = float | Callable[[np.ndarray], np.ndarray] @@ -106,16 +126,16 @@ class Shape(GeometryAPI, ABC): bounding box still describes the geometry itself, so consumers must not use it to skip points for inverted shapes. - Shapes with an analytic description also have a :meth:`level_set` + Shapes with an analytic description also have a `level_set` (``has_level_set``); ``level_set_exact`` says whether it is the exact Euclidean signed distance. """ _inverted: bool = False - #: Whether :meth:`level_set` is defined. Shapes known only through their + #: Whether `level_set` is defined. Shapes known only through their #: membership test (function objects, ``Aperture3D``) have none. has_level_set: bool = False - #: Whether :meth:`level_set` is the exact Euclidean signed distance; otherwise + #: Whether `level_set` is the exact Euclidean signed distance; otherwise #: it is a bound on it. level_set_exact: bool = False @@ -164,7 +184,7 @@ def contains(self, coords: tuple[float, ...]) -> bool: def contains_points(self, points: Array) -> Array: """Return a mask of the ``(ndim, n)`` points inside the shape. - The default calls :meth:`contains` point by point; shapes override it with a + The default calls `contains` point by point; shapes override it with a vectorised test. """ points = _component_major(points, self.ndim) @@ -174,14 +194,25 @@ def _level_set(self, points: Array) -> Array: """The level set of the geometry itself, before inversion.""" raise NotImplementedError(f"{type(self).__name__} has no level set") + @method( + equations={ + "inversion": r"\phi_{S^c}(p) = -\phi_S(p)", + "bound": r"\phi < 0 \text{ inside}, \quad \phi = 0 \text{ on } \partial S, \quad |\phi(p)| \le \operatorname{dist}(p, \partial S)", + }, + assumptions=( + "an inverted shape is the complement of its geometry: `contains_points` is negated and the level set flips sign", + "equality in the bound where `level_set_exact` is true: the Euclidean signed distance", + "shapes known only by their membership test (`has_level_set` False) raise `NotImplementedError`", + ), + ) def level_set(self, points: Array) -> Array: """Level-set values of the ``(ndim, n)`` points: negative inside, positive outside. - The sign follows :meth:`~simgeometry.api.GeometryAPI.signed_distance`, so it + The sign follows `signed_distance`, so it flips for an inverted shape, and the boundary is the zero level (where - :meth:`contains_points` may go either way). The magnitude never exceeds the + `contains_points` may go either way). The magnitude never exceeds the distance to the boundary; with ``level_set_exact`` it equals it. Values are - on the active cunumpy backend, like :meth:`contains_points`. Raises + on the active cunumpy backend, like `contains_points`. Raises ``NotImplementedError`` when ``has_level_set`` is False. """ if not self.has_level_set: @@ -193,7 +224,7 @@ def level_set(self, points: Array) -> Array: def node_mask(self, grid: Grid) -> np.ndarray: """Return a host boolean array of the grid's shape, set at the nodes inside. - One vectorised :meth:`contains_points` over :meth:`Grid.node_points`, so + One vectorised `contains_points` over `Grid.node_points`, so the cost is that of a few array passes over the grid. """ inside = self.contains_points(grid.node_points()) @@ -208,7 +239,7 @@ def get_indices_in_object(self, grid: Grid) -> list[int] | list[tuple[int, ...]] grid (object): Grid object containing indices and cell sizes. Returns: - list: The :attr:`Grid.indices` of the nodes inside, in their order. + list: The `Grid.indices` of the nodes inside, in their order. """ if hasattr(grid, "indices_from_mask"): return grid.indices_from_mask(self.node_mask(grid)) @@ -305,9 +336,9 @@ def contains(self, coords: float) -> bool: class Shape2D(Shape): """Represent a two-dimensional shape. - ``exact_outline`` is True for shapes whose :meth:`outlines` are their exact + ``exact_outline`` is True for shapes whose `outlines` are their exact boundary (polygons), False for curved ones. ``closed`` is False for open curves - (:class:`~simgeometry.Polyline`), which enclose no area. + (`Polyline`), which enclose no area. """ exact_outline: bool = False @@ -343,17 +374,31 @@ def _corner_loops(self) -> list[np.ndarray]: raise NotImplementedError(f"{type(self).__name__} has no outline") def _outline_loops(self, max_spacing: Spacing | None) -> list[np.ndarray]: - """The loops of :meth:`_corner_loops`, refined; curved shapes override this.""" + """The loops of `_corner_loops`, refined; curved shapes override this.""" loops = self._corner_loops() if max_spacing is None: return loops return [_refine_loop(loop, max_spacing, closed=self.closed) for loop in loops] + @method( + equations={ + "orientation": r"A = \tfrac12 \sum_i (x_i y_{i+1} - x_{i+1} y_i) > 0 \quad \text{(counter-clockwise; otherwise reversed)}", + "pieces": r"n = \left\lceil \frac{\ell}{s_{\max}}\,(1 - 10^{-12}) \right\rceil", + "graded": r"\int_0^{\ell} \frac{dl}{s(l)} \text{ in equal steps, trapezoidal on 256 intervals}", + }, + order=2, + assumptions=( + r"loops are $(2, n)$ without a repeated vertex; each edge of length $\ell$ is split into $n$ equal pieces", + "curved shapes place every vertex on the curve; a circle gets 100 vertices without `max_spacing`", + r"the order is that of the area enclosed by the outline of a circle in $s_{\max}$", + ), + problem=_outline_problem, + ) def outlines(self, max_spacing: Spacing | None = None) -> list[np.ndarray]: """Return the boundary loops of the shape as ``(2, n)`` host arrays. Each loop is counter-clockwise, without repeating its first vertex. A shape - made of several pieces (such as :class:`~simgeometry.Aperture2D`'s two + made of several pieces (such as `Aperture2D`'s two plates) has one loop per piece. An open curve (``closed`` is False) gives its polyline instead, from its first vertex to its last. With ``max_spacing``, edges longer than it are split evenly so that no two @@ -375,9 +420,9 @@ def outlines(self, max_spacing: Spacing | None = None) -> list[np.ndarray]: return [loop[:, ::-1] if _signed_area(loop) < 0.0 else loop for loop in loops] def boundary_panels(self, max_spacing: Spacing | None = None) -> Panels: - """The edges of :meth:`outlines` as panels, for boundary-element solvers. + """The edges of `outlines` as panels, for boundary-element solvers. - See :class:`~simgeometry.panels.Panels` for the arrays and the normal + See `Panels` for the arrays and the normal convention. """ from simgeometry.panels import boundary_panels @@ -385,9 +430,9 @@ def boundary_panels(self, max_spacing: Spacing | None = None) -> Panels: return boundary_panels(self, max_spacing) def _edge_level_set(self, points: Array) -> Array: - """Distance to the edges of :meth:`_corner_loops`, negative where the geometry is. + """Distance to the edges of `_corner_loops`, negative where the geometry is. - The sign comes from :meth:`contains_points`, so it agrees with membership + The sign comes from `contains_points`, so it agrees with membership everywhere off the boundary. This is the exact signed distance when the loops are the boundary of the region (a simple polygon, disjoint plates); extra edges inside the region only make it a bound. diff --git a/src/simgeometry/simulation_domain.py b/src/simgeometry/simulation_domain.py index c3fe7bb..33d6603 100644 --- a/src/simgeometry/simulation_domain.py +++ b/src/simgeometry/simulation_domain.py @@ -7,6 +7,7 @@ import cunumpy as xp import scope_profiler as sp from maybempi import MPI +from methodcard import method from simgeometry._particle_boundaries import apply_particle_boundaries from simgeometry._profiling import _PROFILE_TAGS @@ -237,6 +238,19 @@ def _wrap_periodic(values: Array, lower: float, upper: float) -> Array: wrapped = lower + (values - lower) % (upper - lower) return xp.where(wrapped >= upper, lower, wrapped) + @method( + equations={ + "sides": r"\text{below} \iff x < l, \qquad \text{above} \iff x > u \quad (x \ge u \text{ on a periodic upper side})", + "reflect": r"x \leftarrow 2b - x, \qquad v \leftarrow -v", + "periodic": r"x \leftarrow l + (x - l) \bmod (u - l), \qquad x = u \mapsto l", + "kill": r"\text{alive} \leftarrow \text{alive} \land l \le x < u \quad \text{after every axis}", + }, + assumptions=( + r"per axis in order, on alive markers not flagged for reinjection; $b$ the side crossed", + "kill (code 0), reflect (1), periodic (2), reinject (3): a reinjected marker gets a new position from the injection zone", + "a marker on a non-periodic upper side is killed, not reflected; a reflection that still lands outside kills the marker", + ), + ) @sp.profile("simgeometry.apply_bc_arrays", tags=_PROFILE_TAGS) def apply_bc_arrays( self, diff --git a/src/simgeometry/tests/test_method_cards.py b/src/simgeometry/tests/test_method_cards.py new file mode 100644 index 0000000..8d7d174 --- /dev/null +++ b/src/simgeometry/tests/test_method_cards.py @@ -0,0 +1,5 @@ +"""The method cards of simgeometry: complete, seen by the docs, and checked.""" + +from methodcard.testing import method_suite + +TestMethodCards = method_suite("simgeometry") diff --git a/src/simgeometry/visualization.py b/src/simgeometry/visualization.py index 0918d68..7209fc9 100644 --- a/src/simgeometry/visualization.py +++ b/src/simgeometry/visualization.py @@ -559,7 +559,7 @@ def show_section_3d( ): """Draw a plane section in 3D: its source, the cutting plane and the outline. - The source is drawn translucently through :func:`show_3d` (``opacity``, + The source is drawn translucently through `show_3d` (``opacity``, ``resolution`` and the other keyword arguments go there), the plane as a rectangle covering the source's bounds (or ``bounds``), projected onto it, and ``section.outlines(world=True)``, sampled at ``outline_resolution``, as diff --git a/uv.lock b/uv.lock index 8b631cb..e6b3a0e 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 5 +revision = 3 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", @@ -730,7 +730,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130, upload-time = "2025-04-15T17:47:53.79Z" } wheels = [ @@ -802,7 +802,7 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } wheels = [ @@ -895,7 +895,7 @@ resolution-markers = [ "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/83/5a/a55177dd22553a277388e8a1b3220e92de91bacb28356cdc73caa240121d/contourpy-1.4.0.tar.gz", hash = "sha256:20156f5a1ac4f8ce02656e39a61e82164a3d359796dc8026f75b062783d500e1", size = 13323726, upload-time = "2026-09-11T19:05:05.808Z" } wheels = [ @@ -1172,12 +1172,12 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "attrs" }, - { name = "docstring-parser" }, - { name = "rich" }, - { name = "rich-rst" }, - { name = "tomli" }, - { name = "typing-extensions" }, + { name = "attrs", marker = "python_full_version < '3.11'" }, + { name = "docstring-parser", marker = "python_full_version < '3.11'" }, + { name = "rich", marker = "python_full_version < '3.11'" }, + { name = "rich-rst", marker = "python_full_version < '3.11'" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c1/08/a444f3cca86cb27bc256831fc8c42b578390f4087bf6631cccb5334bd9ed/cyclopts-4.25.3.tar.gz", hash = "sha256:7d3a067bc7699a1a65fe2ea0b092a925855bb711a05adc1e65c5b9ba307acea4", size = 202814, upload-time = "2026-09-17T16:25:16.892Z" } wheels = [ @@ -1203,10 +1203,10 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "attrs" }, - { name = "docstring-parser" }, - { name = "rich" }, - { name = "rich-rst" }, + { name = "attrs", marker = "python_full_version >= '3.11'" }, + { name = "docstring-parser", marker = "python_full_version >= '3.11'" }, + { name = "rich", marker = "python_full_version >= '3.11'" }, + { name = "rich-rst", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/28/c1/3debeeb6e0eb74a51d6f8cf1f273ed7c479e3321631cbf89fb9700e65e28/cyclopts-5.2.0.tar.gz", hash = "sha256:b63c1b1beaadf3ead19214385a0f90b990f152c4107c174e1724c45dc71e9541", size = 246542, upload-time = "2026-10-06T15:02:39.212Z" } wheels = [ @@ -1287,7 +1287,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -1764,8 +1764,8 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, - { name = "pillow" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "pillow", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/81/c8/2b56274457e4fadcfa86bf7c0fb93f19e124ab8f38f4bfa4372423052046/imageio-2.38.0.tar.gz", hash = "sha256:fe1d406862f6bc2930e8ec662d7bc40d4370cb76e3390dcd33819560d2708531", size = 403333, upload-time = "2026-09-28T07:44:08.101Z" } wheels = [ @@ -1791,9 +1791,9 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "pillow" }, + { name = "pillow", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f3/cd/69e4ac55b6dafdd2b5f32075236841a3945dea7323d0232d80f28c37cea8/imageio-2.38.1.tar.gz", hash = "sha256:6769f1f01c4dd46448307863a787c9a22fa4dbe11c0c88f525c6e410fdfd7983", size = 403667, upload-time = "2026-10-08T14:35:32.961Z" } wheels = [ @@ -1826,19 +1826,19 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "appnope", marker = "sys_platform == 'darwin'" }, - { name = "comm" }, - { name = "debugpy" }, - { name = "ipython", version = "8.39.0", source = { registry = "https://pypi.org/simple" } }, - { name = "jupyter-client" }, - { name = "jupyter-core" }, - { name = "matplotlib-inline" }, - { name = "nest-asyncio2" }, - { name = "packaging" }, - { name = "psutil" }, - { name = "pyzmq" }, - { name = "tornado" }, - { name = "traitlets" }, + { name = "appnope", marker = "python_full_version < '3.11' and sys_platform == 'darwin'" }, + { name = "comm", marker = "python_full_version < '3.11'" }, + { name = "debugpy", marker = "python_full_version < '3.11'" }, + { name = "ipython", version = "8.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "jupyter-client", marker = "python_full_version < '3.11'" }, + { name = "jupyter-core", marker = "python_full_version < '3.11'" }, + { name = "matplotlib-inline", marker = "python_full_version < '3.11'" }, + { name = "nest-asyncio2", marker = "python_full_version < '3.11'" }, + { name = "packaging", marker = "python_full_version < '3.11'" }, + { name = "psutil", marker = "python_full_version < '3.11'" }, + { name = "pyzmq", marker = "python_full_version < '3.11'" }, + { name = "tornado", marker = "python_full_version < '3.11'" }, + { name = "traitlets", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3d/c4/e4a38f579de4225a561305666f7541cdabb30075def2aa1ac17bd73c1fb5/ipykernel-7.3.0.tar.gz", hash = "sha256:9acaaaf97d16355166e4085afe9d225bfbdf2b7ef520f9df3be8f2b248275e09", size = 184899, upload-time = "2026-06-10T08:41:25.481Z" } wheels = [ @@ -1864,18 +1864,18 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "appnope", marker = "sys_platform == 'darwin'" }, - { name = "comm" }, - { name = "debugpy" }, - { name = "ipython", version = "9.17.1", source = { registry = "https://pypi.org/simple" } }, - { name = "jupyter-client" }, - { name = "jupyter-core" }, - { name = "matplotlib-inline" }, - { name = "nest-asyncio2" }, - { name = "packaging" }, - { name = "pyzmq" }, - { name = "tornado" }, - { name = "traitlets" }, + { name = "appnope", marker = "python_full_version >= '3.11' and sys_platform == 'darwin'" }, + { name = "comm", marker = "python_full_version >= '3.11'" }, + { name = "debugpy", marker = "python_full_version >= '3.11'" }, + { name = "ipython", version = "9.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "jupyter-client", marker = "python_full_version >= '3.11'" }, + { name = "jupyter-core", marker = "python_full_version >= '3.11'" }, + { name = "matplotlib-inline", marker = "python_full_version >= '3.11'" }, + { name = "nest-asyncio2", marker = "python_full_version >= '3.11'" }, + { name = "packaging", marker = "python_full_version >= '3.11'" }, + { name = "pyzmq", marker = "python_full_version >= '3.11'" }, + { name = "tornado", marker = "python_full_version >= '3.11'" }, + { name = "traitlets", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/05/e5499c8b762387d83926bf0389e8fff888c9bf6e7af30566a8527bc12f4a/ipykernel-7.4.0.tar.gz", hash = "sha256:4330114d22b9b33575b2c7c68753fc8faabbbcfbc60b0cdff9f14dd4ce63742f", size = 192731, upload-time = "2026-09-29T16:33:11.468Z" } wheels = [ @@ -1890,17 +1890,17 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "decorator" }, - { name = "exceptiongroup" }, - { name = "jedi" }, - { name = "matplotlib-inline" }, - { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit" }, - { name = "pygments" }, - { name = "stack-data" }, - { name = "traitlets" }, - { name = "typing-extensions" }, + { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, + { name = "decorator", marker = "python_full_version < '3.11'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "jedi", marker = "python_full_version < '3.11'" }, + { name = "matplotlib-inline", marker = "python_full_version < '3.11'" }, + { name = "pexpect", marker = "python_full_version < '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "python_full_version < '3.11'" }, + { name = "pygments", marker = "python_full_version < '3.11'" }, + { name = "stack-data", marker = "python_full_version < '3.11'" }, + { name = "traitlets", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/40/18/f8598d287006885e7136451fdea0755af4ebcbfe342836f24deefaed1164/ipython-8.39.0.tar.gz", hash = "sha256:4110ae96012c379b8b6db898a07e186c40a2a1ef5d57a7fa83166047d9da7624", size = 5513971, upload-time = "2026-03-27T10:02:13.94Z" } wheels = [ @@ -1926,17 +1926,17 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "ipython-pygments-lexers" }, - { name = "jedi" }, - { name = "matplotlib-inline" }, - { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit" }, - { name = "psutil", marker = "sys_platform != 'cygwin' and sys_platform != 'emscripten'" }, - { name = "pygments" }, - { name = "stack-data" }, - { name = "traitlets" }, - { name = "typing-extensions", marker = "python_full_version < '3.12'" }, + { name = "colorama", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" }, + { name = "ipython-pygments-lexers", marker = "python_full_version >= '3.11'" }, + { name = "jedi", marker = "python_full_version >= '3.11'" }, + { name = "matplotlib-inline", marker = "python_full_version >= '3.11'" }, + { name = "pexpect", marker = "python_full_version >= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "python_full_version >= '3.11'" }, + { name = "psutil", marker = "python_full_version >= '3.11' and sys_platform != 'cygwin' and sys_platform != 'emscripten'" }, + { name = "pygments", marker = "python_full_version >= '3.11'" }, + { name = "stack-data", marker = "python_full_version >= '3.11'" }, + { name = "traitlets", marker = "python_full_version >= '3.11'" }, + { name = "typing-extensions", marker = "python_full_version == '3.11.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b9/32/99451b1283ec5d92ad77073f12e1c667dc10775384d8f15c2914207149dd/ipython-9.17.1.tar.gz", hash = "sha256:8919be8c27f20a6f4423145028063f6637b42a03ce57665bb12015ee1f073529", size = 4539289, upload-time = "2026-09-01T08:29:32.6Z" } wheels = [ @@ -1948,7 +1948,7 @@ name = "ipython-pygments-lexers" version = "1.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pygments" }, + { name = "pygments", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } wheels = [ @@ -2593,15 +2593,15 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "contourpy", version = "1.3.2", source = { registry = "https://pypi.org/simple" } }, - { name = "cycler" }, - { name = "fonttools", version = "4.65.0", source = { registry = "https://pypi.org/simple" } }, - { name = "kiwisolver" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, - { name = "packaging" }, - { name = "pillow" }, - { name = "pyparsing" }, - { name = "python-dateutil" }, + { name = "contourpy", version = "1.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "cycler", marker = "python_full_version < '3.11'" }, + { name = "fonttools", version = "4.65.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "kiwisolver", marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "packaging", marker = "python_full_version < '3.11'" }, + { name = "pillow", marker = "python_full_version < '3.11'" }, + { name = "pyparsing", marker = "python_full_version < '3.11'" }, + { name = "python-dateutil", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/63/1b/4be5be87d43d327a0cf4de1a56e86f7f84c89312452406cf122efe2839e6/matplotlib-3.10.9.tar.gz", hash = "sha256:fd66508e8c6877d98e586654b608a0456db8d7e8a546eb1e2600efd957302358", size = 34811233, upload-time = "2026-04-24T00:14:13.539Z" } wheels = [ @@ -2680,17 +2680,17 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "contourpy", version = "1.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "contourpy", version = "1.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "contourpy", version = "1.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "cycler" }, - { name = "fonttools", version = "4.66.1", source = { registry = "https://pypi.org/simple" } }, - { name = "kiwisolver" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "cycler", marker = "python_full_version >= '3.11'" }, + { name = "fonttools", version = "4.66.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "kiwisolver", marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "packaging" }, - { name = "pillow" }, - { name = "pyparsing" }, - { name = "python-dateutil" }, + { name = "packaging", marker = "python_full_version >= '3.11'" }, + { name = "pillow", marker = "python_full_version >= '3.11'" }, + { name = "pyparsing", marker = "python_full_version >= '3.11'" }, + { name = "python-dateutil", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e7/c8/9aa712a0afb882649424dd8de8ad9aa6235e796e84c6052e8f6dc1598d0d/matplotlib-3.11.2.tar.gz", hash = "sha256:cec596316640f2b394b8f0daa0ea61a8eae82d017b620b9f202befb972a59ea4", size = 32660610, upload-time = "2026-09-11T19:05:31.214Z" } wheels = [ @@ -2783,6 +2783,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/57/99/36cd25f9598eef70db97577440a70a360af505f6f01c2338a18d1556079c/meson-1.12.1-py3-none-any.whl", hash = "sha256:930bc7542cbd9f57009e182fd014eba48cf1a0180a7b9006d2d32d8f168d8b02", size = 1108668, upload-time = "2026-09-22T22:58:26.047Z" }, ] +[[package]] +name = "methodcard" +version = "0.1.0" +source = { git = "https://github.com/max-models/methodcard?branch=devel#cc1c7bd98faefc860c3dee8b8843045c69990430" } +dependencies = [ + { name = "griffe" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "scope-profiler" }, +] + +[package.optional-dependencies] +symbolic = [ + { name = "sympy" }, +] + [[package]] name = "mistune" version = "3.3.4" @@ -2800,7 +2817,7 @@ name = "mlx" version = "0.32.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "mlx-metal" }, + { name = "mlx-metal", marker = "sys_platform == 'darwin'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/52/73/f2b18f4bbcfeacd6880e68f2854609d6d2d305e5676586451a6c26664061/mlx-0.32.4-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:60ba41564e8b5018c9d8a5f8ce7a5aaf70c96d8dd98c1a54b854cbada362d471", size = 671799, upload-time = "2026-10-14T21:15:37.06Z" }, @@ -3641,10 +3658,10 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, - { name = "python-dateutil" }, - { name = "pytz" }, - { name = "tzdata" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "python-dateutil", marker = "python_full_version < '3.11'" }, + { name = "pytz", marker = "python_full_version < '3.11'" }, + { name = "tzdata", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } wheels = [ @@ -3716,10 +3733,10 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "python-dateutil" }, - { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, + { name = "python-dateutil", marker = "python_full_version >= '3.11'" }, + { name = "tzdata", marker = "(python_full_version >= '3.11' and sys_platform == 'emscripten') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e2/17/d7b106e05bfa642e8694451e7d3d759c6a241c5386a5d962e4f66c047e06/pandas-3.0.6.tar.gz", hash = "sha256:66b07ef7315a31bfe1089cd3d71a7de781c9dca986762d0b4fe7c0ef17465d10", size = 4667686, upload-time = "2026-09-17T23:23:18.345Z" } wheels = [ @@ -3805,7 +3822,7 @@ name = "pexpect" version = "4.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ptyprocess" }, + { name = "ptyprocess", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') 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 = [ @@ -4247,7 +4264,7 @@ name = "pyobjc-framework-cocoa" version = "12.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, + { name = "pyobjc-core", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/75/76/49c6da2c6a831020b4854ba20079d5a1030474bffc776b7b73c2eeff8c15/pyobjc_framework_cocoa-12.2.2.tar.gz", hash = "sha256:c96c0ef69a71afbbb0e6a7d594b455c5fe47d62e0db376ee7a2b4b828c16ace9", size = 3132831, upload-time = "2026-08-11T19:44:02.288Z" } wheels = [ @@ -5003,14 +5020,14 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "imageio", version = "2.38.0", source = { registry = "https://pypi.org/simple" } }, - { name = "lazy-loader" }, - { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" } }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, - { name = "packaging" }, - { name = "pillow" }, - { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" } }, - { name = "tifffile", version = "2025.5.10", source = { registry = "https://pypi.org/simple" } }, + { name = "imageio", version = "2.38.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "lazy-loader", marker = "python_full_version < '3.11'" }, + { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "packaging", marker = "python_full_version < '3.11'" }, + { name = "pillow", marker = "python_full_version < '3.11'" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "tifffile", version = "2025.5.10", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c7/a8/3c0f256012b93dd2cb6fda9245e9f4bff7dc0486880b248005f15ea2255e/scikit_image-0.25.2.tar.gz", hash = "sha256:e5a37e6cd4d0c018a7a55b9d601357e3382826d3888c10d0213fc63bff977dde", size = 22693594, upload-time = "2025-02-18T18:05:24.538Z" } wheels = [ @@ -5056,17 +5073,17 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "imageio", version = "2.38.1", source = { registry = "https://pypi.org/simple" } }, - { name = "lazy-loader" }, - { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "imageio", version = "2.38.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "lazy-loader", marker = "python_full_version >= '3.11'" }, + { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "networkx", version = "3.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "packaging" }, - { name = "pillow" }, - { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "packaging", marker = "python_full_version >= '3.11'" }, + { name = "pillow", marker = "python_full_version >= '3.11'" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "scipy", version = "1.18.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "tifffile", version = "2026.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "tifffile", version = "2026.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "tifffile", version = "2026.9.20", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a1/b4/2528bb43c67d48053a7a649a9666432dc307d66ba02e3a6d5c40f46655df/scikit_image-0.26.0.tar.gz", hash = "sha256:f5f970ab04efad85c24714321fcc91613fcb64ef2a892a13167df2f3e59199fa", size = 22729739, upload-time = "2025-12-20T17:12:21.824Z" } @@ -5129,7 +5146,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" } wheels = [ @@ -5190,7 +5207,7 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } wheels = [ @@ -5272,7 +5289,7 @@ resolution-markers = [ "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7e/74/66de6258867beb2ef08f35f9f2ac017a52cacd5081714d239ff1a442d458/scipy-1.18.1.tar.gz", hash = "sha256:52c4b7422442aba924d03ad4019852b08a92e64ea187b933135687bfe2747307", size = 30781235, upload-time = "2026-08-21T23:28:50.599Z" } wheels = [ @@ -5377,7 +5394,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/4d/bc/0989043118a27cccb4e906a46b7565ce36ca7b57f5a18b78f4f1b0f72d9d/shapely-2.1.2.tar.gz", hash = "sha256:2ed4ecb28320a433db18a5bf029986aa8afcfd740745e78847e330d5d94922a9", size = 315489, upload-time = "2025-09-24T13:51:41.432Z" } wheels = [ @@ -5458,7 +5475,7 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f3/ab/924b6e202f796d270a3041a230151f7908db5ea48c74effe6f8023e9bd05/shapely-2.2.0.tar.gz", hash = "sha256:e8865e553d874a1ec4a032057ea81fca9def37b188cd8fb550af3b3480b3f88c", size = 380326, upload-time = "2026-10-07T09:18:01.001Z" } @@ -5535,6 +5552,7 @@ source = { editable = "." } dependencies = [ { name = "cunumpy" }, { name = "maybempi" }, + { name = "methodcard" }, { name = "mpiarray" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, @@ -5554,6 +5572,7 @@ dev = [ { name = "jupyterlab" }, { name = "matplotlib", version = "3.10.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "matplotlib", version = "3.11.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "methodcard", extra = ["symbolic"] }, { name = "mpi4py" }, { name = "nbclient" }, { name = "nbformat" }, @@ -5628,6 +5647,7 @@ pyccel = [ { name = "pyccel" }, ] test = [ + { name = "methodcard", extra = ["symbolic"] }, { name = "mpi4py" }, { name = "pytest" }, { name = "pytest-cov" }, @@ -5645,6 +5665,8 @@ requires-dist = [ { name = "matplotlib", marker = "extra == 'docs'", specifier = ">=3.10.9" }, { name = "matplotlib", marker = "extra == 'plot'", specifier = ">=3.10" }, { name = "maybempi", git = "https://github.com/max-models/maybempi?branch=devel" }, + { name = "methodcard", git = "https://github.com/max-models/methodcard?branch=devel" }, + { name = "methodcard", extras = ["symbolic"], marker = "extra == 'test'", git = "https://github.com/max-models/methodcard?branch=devel" }, { name = "mlx", marker = "platform_machine == 'arm64' and sys_platform == 'darwin' and extra == 'metal'" }, { name = "mpi4py", marker = "extra == 'mpi'" }, { name = "mpi4py", marker = "extra == 'test'" }, @@ -5770,7 +5792,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/44/d0/18fed0fc0916578a4463f775b0fbd9c5fed2392152d039df2fb533bfdd5d/tifffile-2025.5.10.tar.gz", hash = "sha256:018335d34283aa3fd8c263bae5c3c2b661ebc45548fde31504016fcae7bf1103", size = 365290, upload-time = "2025-05-10T19:22:34.386Z" } wheels = [ @@ -5787,7 +5809,7 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c5/cb/2f6d79c7576e22c116352a801f4c3c8ace5957e9aced862012430b62e14f/tifffile-2026.3.3.tar.gz", hash = "sha256:d9a1266bed6f2ee1dd0abde2018a38b4f8b2935cb843df381d70ac4eac5458b7", size = 388745, upload-time = "2026-03-03T19:14:38.134Z" } wheels = [ @@ -5810,7 +5832,7 @@ resolution-markers = [ "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/92/66/634db78ebad513038d753830dd8815eea26278b5463ba0f43198b0c24c4e/tifffile-2026.9.20.tar.gz", hash = "sha256:30e145a7042ce7143ae50a50fe8b7221b0070aae22adab8f9e79a264be6b5cdc", size = 449417, upload-time = "2026-09-21T03:59:40.055Z" } wheels = [ @@ -6247,9 +6269,9 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, - { name = "packaging" }, - { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "packaging", marker = "python_full_version < '3.11'" }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/19/ec/e50d833518f10b0c24feb184b209bb6856f25b919ba8c1f89678b930b1cd/xarray-2025.6.1.tar.gz", hash = "sha256:a84f3f07544634a130d7dc615ae44175419f4c77957a7255161ed99c69c7c8b0", size = 3003185, upload-time = "2025-06-12T03:04:09.099Z" } wheels = [ @@ -6275,10 +6297,10 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "packaging" }, - { name = "pandas", version = "3.0.6", source = { registry = "https://pypi.org/simple" } }, + { name = "packaging", marker = "python_full_version >= '3.11'" }, + { name = "pandas", version = "3.0.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ff/f5/781c70b234f54e0401f4f7c4427a7486da38d50dbb138c7d7c55144b3d86/xarray-2026.9.0.tar.gz", hash = "sha256:6abc69694c22fa1f0fb2f357ff4e41d88beb4477ed71091f944b7dbf67ed54fe", size = 3176922, upload-time = "2026-09-29T23:06:25.807Z" } wheels = [ From 66fbb952823c9aa3cf74a05360a2b1fdcab5f12a Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 3 Nov 2026 21:12:13 +0100 Subject: [PATCH 32/36] Add Helix, a helical wire, and PolarRepeat, copies of a shape about an axis. (#46) --- CHANGELOG.md | 9 + .../docs/getting-started/geometry-api.md | 2 + docs/src/content/docs/numerics/index.mdx | 2 + src/simgeometry/__init__.py | 4 + src/simgeometry/objects/__init__.py | 2 + src/simgeometry/objects/helix.py | 335 ++++++++++++++++++ src/simgeometry/operations.py | 100 ++++++ src/simgeometry/tests/test_helix.py | 154 ++++++++ src/simgeometry/tests/test_polar_repeat.py | 67 ++++ tutorials/shapes_and_node_masks.ipynb | 40 ++- 10 files changed, 714 insertions(+), 1 deletion(-) create mode 100644 src/simgeometry/objects/helix.py create mode 100644 src/simgeometry/tests/test_helix.py create mode 100644 src/simgeometry/tests/test_polar_repeat.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 056d19f..67896b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- `Helix(start, radius, pitch, turns, wire_radius, axis=..., left_handed=...)`: a helical + wire of round cross-section with hemispherical ends, e.g. the winding of an RF coil. Its + level set is the distance from the centreline (Newton steps along it from the + linearised minima of the nearest turns), exact within a wire radius of the wire and a + bound farther away; `centreline(t)`, `wire_length`, volume and surface area. +- `PolarRepeat(source, count, axis=..., origin=...)`: ``count`` copies of a 3D shape about + an axis, evaluated by folding the point's angle into the first sector, so its cost does + not grow with ``count`` (the slits of a Faraday shield: ``tube - PolarRepeat(slit, 24)``; + the cut cells of 24 slits as fast as of one). - A Numerics section in the docs (header and sidebar): an overview of the geometry (`docs/src/content/docs/numerics/index.mdx`, with the main cards) and one page per module, from the shapes to the grids (starlight-theme's `methodcard: { pages: true }`). diff --git a/docs/src/content/docs/getting-started/geometry-api.md b/docs/src/content/docs/getting-started/geometry-api.md index 2bccc0c..afb6795 100644 --- a/docs/src/content/docs/getting-started/geometry-api.md +++ b/docs/src/content/docs/getting-started/geometry-api.md @@ -146,6 +146,8 @@ and where `level_set_exact` is true it is the Euclidean signed distance. | `Aperture2D` | exact while the plates do not touch | | `Cylinder`, `Tube`, `Cone`, `Torus` (any axis, with or without fillets) | exact | | `SlantedTube` | the larger of the uncut tube's and the cut plane's: exact on the faces, a bound near the cut's rim | +| `Helix` | the distance from the centreline minus the wire's radius: exact within a wire radius of the wire, a bound farther away | +| `PolarRepeat` (copies about an axis) | the source's, the least over the point's sector and its two neighbours: a bound | | `a \| b`, `a & b`, `a - b` | `min(a, b)`, `max(a, b)`, `max(a, -b)`: a bound, not exact | | rotations, translations, reflections, uniform scaling | exact if the source is | | nonuniform scaling, shears | the source's times the smallest stretch: a bound | diff --git a/docs/src/content/docs/numerics/index.mdx b/docs/src/content/docs/numerics/index.mdx index f1429d0..df326b6 100644 --- a/docs/src/content/docs/numerics/index.mdx +++ b/docs/src/content/docs/numerics/index.mdx @@ -42,6 +42,8 @@ distances of boxes, segments, capped cones and tori: [Tube](/simgeometry/numerics/objects-tube/), [Cone](/simgeometry/numerics/objects-cone/), [Torus](/simgeometry/numerics/objects-torus/), and the [SlantedTube](/simgeometry/numerics/objects-slanted-tube/), a bound near its cut; +- the [Helix](/simgeometry/numerics/objects-helix/), a helical wire (an RF coil), its + distance found by Newton steps along the centreline; - [Booleans, affine transforms and sections](/simgeometry/numerics/operations/), whose level sets are bounds, and [collections](/simgeometry/numerics/collection/) of labelled objects. diff --git a/src/simgeometry/__init__.py b/src/simgeometry/__init__.py index 8a19279..6f547a6 100644 --- a/src/simgeometry/__init__.py +++ b/src/simgeometry/__init__.py @@ -16,6 +16,7 @@ Cylinder, FunctionObject2D, FunctionObject3D, + Helix, Line, Polygon, Polyline, @@ -26,6 +27,7 @@ Triangle, Tube, ) +from simgeometry.operations import PolarRepeat from simgeometry.simulation_domain import SimulationDomain # simgeometry's regions stay off unless the application enables the "simgeometry" tag. @@ -38,6 +40,7 @@ __all__ = [ "GeometryCollection", "Grid", + "PolarRepeat", "SimulationDomain", "Aperture2D", "Aperture3D", @@ -47,6 +50,7 @@ "Cylinder", "FunctionObject2D", "FunctionObject3D", + "Helix", "Line", "Polygon", "Polyline", diff --git a/src/simgeometry/objects/__init__.py b/src/simgeometry/objects/__init__.py index 563a8fa..29e96dc 100644 --- a/src/simgeometry/objects/__init__.py +++ b/src/simgeometry/objects/__init__.py @@ -9,6 +9,7 @@ from simgeometry.objects.cuboid import Cuboid from simgeometry.objects.cylinder import Cylinder from simgeometry.objects.function_object import FunctionObject2D, FunctionObject3D +from simgeometry.objects.helix import Helix from simgeometry.objects.line import Line from simgeometry.objects.polygon import Polygon from simgeometry.objects.polyline import Polyline @@ -31,6 +32,7 @@ "Cylinder", "FunctionObject2D", "FunctionObject3D", + "Helix", # 1D objects "Line", "Polygon", diff --git a/src/simgeometry/objects/helix.py b/src/simgeometry/objects/helix.py new file mode 100644 index 0000000..9558aaf --- /dev/null +++ b/src/simgeometry/objects/helix.py @@ -0,0 +1,335 @@ +"""Helical wires: the points within a radius of a helix, e.g. the winding of an RF coil.""" + +from __future__ import annotations + +import cunumpy as xp +import numpy as np +from methodcard import method + +from simgeometry._types import Array, BoundsLike, ConfigValue +from simgeometry.objects.axisymmetric import _vector +from simgeometry.shapes import Shape3D, _component_major + + +def _reference(axis: np.ndarray) -> np.ndarray: + """The unit vector normal to ``axis`` closest to x (to y for an axis along x).""" + helper = np.eye(3)[0] if abs(axis[0]) < 0.9 else np.eye(3)[1] + first = helper - (helper @ axis) * axis + return first / np.linalg.norm(first) + + +@method( + equations={ + "centreline": r"\mathbf c(t) = \mathbf p_0 + R\,(\cos t\,\mathbf u + \sin t\,\mathbf v) + \frac{p\,t}{2\pi}\,\mathbf a, \qquad 0 \le t \le 2\pi N", + "level_set": r"\phi(\mathbf x) = \min_{0 \le t \le 2\pi N} |\mathbf x - \mathbf c(t)| - r_w", + "descent": r"t \leftarrow t - \frac{f'(t)}{f''(t) \text{ or } 2\rho R + 2b^2}, \qquad f(t) = \rho^2 + R^2 - 2\rho R\cos(t - \varphi) + (s - b t)^2, \quad b = \frac{p}{2\pi}", + "bound": r"|\mathbf x - \mathbf c(t)| \ge \sqrt{(\rho - R)^2 + \max(-s, s - pN, 0)^2}", + }, + assumptions=( + r"$\mathbf a$ the unit axis, $\mathbf u$ the direction of the start point from the axis, $\mathbf v = \pm\mathbf a\times\mathbf u$ (right- or left-handed); $(s, \rho, \varphi)$ the point's cylindrical coordinates in that frame", + r"the wire's ends are hemispheres (the points within $r_w$ of the end points of the centreline)", + r"within $2 r_w$ of the cylinder the centreline lies on: the minimum over $t$ from four starts, the two ends and the linearised minima of the two turns around $s / b$, by six Newton steps (descent steps on the majorant $f'' \le 2\rho R + 2b^2$ where $f''$ is not clearly positive), clipped to $[0, 2\pi N]$; exact there (tested against dense sampling)", + r"farther away $\phi$ is the bound (the distance from the cylinder, minus $r_w$): a level set that never exceeds the distance", + ), +) +class Helix(Shape3D): + """A helical wire of round cross-section: the points within ``wire_radius`` of a helix. + + The centreline winds ``turns`` times around ``axis`` at ``radius``, advancing + ``pitch`` along the axis per turn, from its start point at + ``start + radius * u`` (``u`` the unit vector normal to the axis in the + direction of ``start_direction``, by default the one closest to x) to its end + ``pitch * turns`` further along the axis. Right-handed by default (counter- + clockwise seen from the end the axis points to); ``left_handed`` reverses it. The + wire's ends are hemispherical caps. The `level_set` is the signed distance to + the centreline minus the wire's radius, found by Newton steps on the squared + distance along the centreline, within a wire radius of the wire; farther away it is + a lower bound (``level_set_exact`` is False). + + Example + ------- + The six windings of an RF coil, radius 0.14 m, 2 cm pitch, a copper tube of 6 mm + outer diameter, along z from z = 0.02:: + + coil = Helix((0, 0, 0.02), 0.14, 0.02, 6, 0.003) + """ + + has_level_set = True + level_set_exact = False + + def _bounds(self) -> tuple[tuple[float, ...], tuple[float, ...]]: + """The box of the cylinder of radius ``radius + wire_radius`` around the wire.""" + extent = (self.radius + self.wire_radius) * np.sqrt( + np.clip(1.0 - self._axis**2, 0.0, None) + ) + first = self._start - self.wire_radius * self._axis + last = self._start + (self.length + self.wire_radius) * self._axis + lower = np.minimum(first, last) - extent + upper = np.maximum(first, last) + extent + return tuple(float(c) for c in lower), tuple(float(c) for c in upper) + + def __init__( + self, + start: BoundsLike, + radius: float, + pitch: float, + turns: float, + wire_radius: float, + axis: BoundsLike = (0.0, 0.0, 1.0), + start_direction: BoundsLike | None = None, + left_handed: bool = False, + bc: ConfigValue = None, + inverted: bool = False, + ) -> None: + """ + Initialize a helical wire in 3D. + + Parameters: + start (list or tuple): The point on the axis level with the wire's start. + radius (float): The radius of the centreline. + pitch (float): The advance along the axis per turn, positive. + turns (float): The number of turns, positive (need not be whole). + wire_radius (float): The wire's radius, below ``radius``. + axis (list or tuple, optional): The direction the helix advances along. + start_direction (list or tuple, optional): The direction from the axis to the + start point (its part normal to the axis); default the one closest to x. + left_handed (bool, optional): Wind clockwise seen from the end ``axis`` + points to. + bc (optional): Boundary condition information. + inverted (bool, optional): Cover everything except the wire. + """ + axis = _vector(axis, "axis") + if np.linalg.norm(axis) == 0: + raise ValueError("The axis must have a nonzero length") + axis = axis / np.linalg.norm(axis) + if not 0 < wire_radius < radius: + raise ValueError("A helix needs 0 < wire_radius < radius") + if pitch <= 0 or turns <= 0: + raise ValueError("A helix needs pitch > 0 and turns > 0") + if start_direction is None: + u = _reference(axis) + else: + direction = _vector(start_direction, "start_direction") + u = direction - (direction @ axis) * axis + if np.linalg.norm(u) < 1e-12: + raise ValueError("start_direction must not be along the axis") + u = u / np.linalg.norm(u) + v = np.cross(axis, u) + self._start = _vector(start, "start") + self._axis = axis + self._u = u + self._v = -v if left_handed else v + self.radius = float(radius) + self.pitch = float(pitch) + self.turns = float(turns) + self.wire_radius = float(wire_radius) + self._bc = bc + self._inverted = bool(inverted) + super().__init__(*self._bounds()) + + @property + def start(self) -> tuple[float, float, float]: + """The point on the axis level with the wire's start.""" + return tuple(float(c) for c in self._start) + + @property + def axis(self) -> tuple[float, float, float]: + """The unit vector the helix advances along.""" + return tuple(float(c) for c in self._axis) + + @property + def start_direction(self) -> tuple[float, float, float]: + """The unit vector from the axis to the start point.""" + return tuple(float(c) for c in self._u) + + @property + def left_handed(self) -> bool: + """Whether the helix winds clockwise seen from the end the axis points to.""" + return bool(np.cross(self._axis, self._u) @ self._v < 0) + + @property + def length(self) -> float: + """The axial length of the centreline, ``pitch * turns``.""" + return self.pitch * self.turns + + @property + def wire_length(self) -> float: + """The length of the centreline, ``turns * sqrt((2 pi radius)^2 + pitch^2)``.""" + return float(self.turns * np.hypot(2 * np.pi * self.radius, self.pitch)) + + def centreline(self, t: Array) -> Array: + """The points ``(3, n)`` of the centreline at the angles ``t`` (``0`` to ``2 pi N``).""" + t = xp.asarray(t, dtype=float) + b = self.pitch / (2 * np.pi) + return ( + xp.asarray(self._start)[:, None] + + self.radius * xp.cos(t)[None, :] * xp.asarray(self._u)[:, None] + + self.radius * xp.sin(t)[None, :] * xp.asarray(self._v)[:, None] + + b * t[None, :] * xp.asarray(self._axis)[:, None] + ) + + def copy(self) -> Helix: + """Create a copy of the helix.""" + return Helix( + self.start, + self.radius, + self.pitch, + self.turns, + self.wire_radius, + axis=self.axis, + start_direction=self.start_direction, + left_handed=self.left_handed, + bc=self._bc, + inverted=self._inverted, + ) + + def _frame_coordinates(self, points: Array): + """Axial position ``s``, distance ``rho`` from the axis and angle ``phi``.""" + relative = points - xp.asarray(self._start)[:, None] + s = xp.sum(relative * xp.asarray(self._axis)[:, None], axis=0) + x = xp.sum(relative * xp.asarray(self._u)[:, None], axis=0) + y = xp.sum(relative * xp.asarray(self._v)[:, None], axis=0) + return s, xp.sqrt(x * x + y * y), xp.arctan2(y, x) + + def _bound(self, s: Array, rho: Array) -> Array: + """A lower bound of the distance from the centreline: from its cylinder.""" + beyond = xp.maximum(xp.maximum(-s, s - self.length), 0.0) + return xp.sqrt((rho - self.radius) ** 2 + beyond * beyond) + + def _nearest(self, s: Array, rho: Array, phi: Array) -> Array: + """The distance from the centreline, by descent along it from four starts.""" + radius, b = self.radius, self.pitch / (2 * np.pi) + top, turn = 2 * np.pi * self.turns, 2 * np.pi + coupling = rho * radius + majorant = 2 * coupling + 2 * b * b + k = xp.floor((s / b - phi) / turn) + starts = [] + for j in (0, 1): + # the minimum of the cosine's basin around phi + 2 pi (k + j), linearised: + # rho R sin(tau) = b (s - b t) with sin(tau) = tau + base = phi + turn * (k + j) + # moved by whole turns into the range (points beyond the ends) + base = xp.where( + base > top, base - turn * xp.ceil((base - top) / turn), base + ) + base = xp.where(base < 0, base + turn * xp.ceil(-base / turn), base) + starts.append(base + b * (s - b * base) / (coupling + b * b)) + + def descend(t, s, phi, coupling, majorant): + t = xp.clip(t, 0.0, top) + for _ in range(5): + angle = t - phi + slope = 2 * coupling * xp.sin(angle) - 2 * b * (s - b * t) + curvature = 2 * coupling * xp.cos(angle) + 2 * b * b + # Newton where the curvature is clearly positive, else a descent + # step on the majorant f'' <= 2 rho R + 2 b^2 + divisor = xp.where(curvature > 0.05 * majorant, curvature, majorant) + t = xp.clip(t - slope / divisor, 0.0, top) + return t + + def squared(t, s, rho, phi): + return ( + rho * rho + + radius**2 + - 2 * rho * radius * xp.cos(t - phi) + + (s - b * t) ** 2 + ) + + best = None + for t in starts: + t = descend(t, s, phi, coupling, majorant) + value = squared(t, s, rho, phi) + best = value if best is None else xp.minimum(best, value) + # the two ends, for the points within a pitch of them (axially) + margin = self.pitch + self.wire_radius + for end in (0.0, top): + near = xp.flatnonzero(xp.abs(s - b * end) < margin) + if near.shape[0]: + sub = (s[near], phi[near], coupling[near], majorant[near]) + t = descend(xp.full(near.shape, end), *sub) + best[near] = xp.minimum( + best[near], squared(t, s[near], rho[near], phi[near]) + ) + return xp.sqrt(xp.maximum(best, 0.0)) + + def _distance(self, points: Array, reach: float) -> Array: + """The distance from the centreline: exact within ``reach`` of its cylinder. + + Farther away the lower bound of `_bound` (the distance from the cylinder the + centreline lies on). + """ + s, rho, phi = self._frame_coordinates(points) + distance = self._bound(s, rho) + near = xp.flatnonzero(distance < reach) + if near.shape[0]: + distance[near] = self._nearest(s[near], rho[near], phi[near]) + return distance + + def _level_set(self, points: Array) -> Array: + """The distance from the centreline minus the wire's radius (a bound beyond it).""" + return self._distance(points, 2 * self.wire_radius) - self.wire_radius + + def contains_points(self, points: Array) -> Array: + """Vectorised `contains`: a mask of the ``(3, n)`` points inside.""" + points = _component_major(points, 3) + inside = self._distance(points, self.wire_radius) <= self.wire_radius + return ~inside if self.inverted else inside + + def contains(self, coords: Array) -> bool: + """ + Check if the given coordinates are inside the wire. + + Parameters: + coords (list or tuple): Coordinates (x, y, z). + + Returns: + bool: True if the point is inside the object, False otherwise. + """ + point = xp.asarray(coords, dtype=float).reshape(3, 1) + return bool(xp.to_numpy(self.contains_points(point))[0]) + + def translate(self, offset: tuple[float, float, float]) -> None: + """ + Translate the helix by the given offset. + + Parameters: + offset (tuple): (dx, dy, dz) translation vector. + """ + self._start = self._start + _vector(offset, "offset") + self.set_bounding_box(*self._bounds()) + + def mirror(self, axis: int, position: float) -> None: + """ + Mirror the helix across the plane where coordinate ``axis`` is ``position``. + + A mirror image of a right-handed helix is left-handed. + + Parameters: + axis (int): Axis index (0 for x-axis, 1 for y-axis, 2 for z-axis). + position (float): Position along the axis to mirror. + """ + if axis < 0 or axis > 2: + raise ValueError("Axis must be 0 (x-axis), 1 (y-axis), or 2 (z-axis).") + flip = np.ones(3) + flip[axis] = -1.0 + self._start = self._start * flip + self._start[axis] += 2 * position + self._axis = self._axis * flip + self._u = self._u * flip + self._v = self._v * flip + self.set_bounding_box(*self._bounds()) + + def volume(self) -> float: + """The volume: the swept disc along the centreline and the two hemispheres. + + Exact (Pappus for a curve) as long as the wire does not overlap itself + (``pitch >= 2 * wire_radius`` and ``wire_radius`` below the radius of + curvature). + """ + disc = np.pi * self.wire_radius**2 + return float(disc * self.wire_length + 4 / 3 * np.pi * self.wire_radius**3) + + def surface_area(self) -> float: + """The surface area: the swept circle and the two hemispheres (no overlap).""" + r = self.wire_radius + return float(2 * np.pi * r * self.wire_length + 4 * np.pi * r**2) diff --git a/src/simgeometry/operations.py b/src/simgeometry/operations.py index 37ec1df..6cb27db 100644 --- a/src/simgeometry/operations.py +++ b/src/simgeometry/operations.py @@ -397,6 +397,106 @@ def evaluate(self, *args, quantity=None, **kwargs): return super().evaluate(*args, quantity=quantity, **kwargs) +@method( + equations={ + "fold": r"\varphi' = \varphi - k\,\Delta, \quad k = \operatorname{round}(\varphi / \Delta), \quad \Delta = \frac{2\pi}{N}", + "level_set": r"\phi_N(p) = \min_{j \in \{k - 1, k, k + 1\}} \phi\big(R_{-j\Delta}\, p\big)", + }, + assumptions=( + r"$N$ copies of the source rotated about the axis by multiples of $\Delta$; $\varphi$ the point's angle about the axis from the reference direction (the unit vector normal to the axis closest to x)", + r"the source lies within its sector $|\varphi| < \Delta / 2$ (copies do not overlap), so membership is the source's at the point rotated into the first sector", + r"the level set is the smallest over the point's sector and its two neighbours: a bound on the distance where the nearest copy is among them (copies away from the axis)", + ), +) +class PolarRepeat(Shape): + """``count`` copies of a 3D shape rotated about an axis, evaluated as one. + + Copy ``j`` is the source rotated by ``j * 360 / count`` degrees about ``axis`` + through ``origin``. A point is mapped into the first copy's sector (its angle + folded to ``|phi| < 180 / count`` degrees) and tested against the source alone, + so the cost does not grow with ``count`` as a union of rotated copies would: the + 24 slits of a Faraday shield are ``tube - PolarRepeat(slit, 24)``. The source must + lie within its sector, centred on the reference direction (the unit vector + normal to the axis closest to x, as `Helix` uses). + """ + + level_set_exact = False + + def __init__(self, source, count, *, axis=(0.0, 0.0, 1.0), origin=None): + if source.ndim != 3: + raise ValueError("PolarRepeat needs a 3D shape") + if int(count) != count or count < 1: + raise ValueError("count must be a positive integer") + axis = np.asarray(axis, float) + if axis.shape != (3,) or not np.all(np.isfinite(axis)) or not np.any(axis): + raise ValueError("The axis must be a finite nonzero 3-vector") + self.source = copy.deepcopy(source) + self.count = int(count) + self.axis = axis / np.linalg.norm(axis) + self.origin = np.zeros(3) if origin is None else np.asarray(origin, float) + helper = np.eye(3)[0] if abs(self.axis[0]) < 0.9 else np.eye(3)[1] + u = helper - (helper @ self.axis) * self.axis + self._u = u / np.linalg.norm(u) + self._v = np.cross(self.axis, self._u) + self._inverted = source.inverted + self._bc = getattr(source, "_bc", None) + relative = corners(source) - self.origin[:, None] + along = self.axis @ relative + radius = float( + np.max(np.linalg.norm(relative - np.outer(self.axis, along), axis=0)) + ) + extent = radius * np.sqrt(np.clip(1.0 - self.axis**2, 0.0, None)) + ends = self.origin[:, None] + np.outer(self.axis, [along.min(), along.max()]) + super().__init__(3, ends.min(axis=1) - extent, ends.max(axis=1) + extent) + + def _folded(self, points, shift=0): + """The points rotated by ``-(k + shift)`` sector angles, ``k`` their sector.""" + relative = points - xp.asarray(self.origin)[:, None] + axis, u, v = (xp.asarray(w)[:, None] for w in (self.axis, self._u, self._v)) + s = xp.sum(relative * axis, axis=0) + x = xp.sum(relative * u, axis=0) + y = xp.sum(relative * v, axis=0) + sector = 2 * np.pi / self.count + phi = xp.arctan2(y, x) + phi = phi - sector * (xp.round(phi / sector) + shift) + rho = xp.sqrt(x * x + y * y) + return ( + xp.asarray(self.origin)[:, None] + + s[None, :] * axis + + (rho * xp.cos(phi))[None, :] * u + + (rho * xp.sin(phi))[None, :] * v + ) + + def contains_points(self, points): + """Membership of the ``(3, n)`` points: the source's in the first sector.""" + points = _component_major(points, 3) + return self.source.contains_points(self._folded(points)) + + def contains(self, coords): + """Whether the point is inside one of the copies.""" + point = xp.asarray(coords, dtype=float).reshape(3, 1) + return bool(xp.to_numpy(self.contains_points(point))[0]) + + @property + def has_level_set(self): + """Whether the source has a level set.""" + return bool(getattr(self.source, "has_level_set", False)) + + def level_set(self, points): + """The smallest of the source's level set in the point's and the next sectors.""" + if not self.has_level_set: + raise NotImplementedError(f"{type(self.source).__name__} has no level set") + points = _component_major(points, 3) + values = [ + self.source.level_set(self._folded(points, shift)) for shift in (-1, 0, 1) + ] + return xp.minimum(xp.minimum(values[0], values[1]), values[2]) + + def volume(self): + """``count`` times the source's volume (the copies do not overlap).""" + return self.count * self.source.volume() + + def rotation(shape, angle, *, axis=(0, 0, 1), origin=None): angle = np.deg2rad(float(angle)) c, s = np.cos(angle), np.sin(angle) diff --git a/src/simgeometry/tests/test_helix.py b/src/simgeometry/tests/test_helix.py new file mode 100644 index 0000000..4e11771 --- /dev/null +++ b/src/simgeometry/tests/test_helix.py @@ -0,0 +1,154 @@ +"""Helix: the level set against dense sampling, volume, bounds, frames, crossings, Yee fractions.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from simgeometry import Grid, Helix +from simgeometry.crossings import crossings + +RNG = np.random.default_rng(5) + +SHAPES = { + "coil": Helix((0, 0, 0.02), 0.14, 0.02, 6, 0.003), + "oblique left": Helix( + (0.1, -0.2, 0.3), 0.05, 0.03, 2.5, 0.008, axis=(1, 1, 0.5), left_handed=True + ), + "steep": Helix((0, 0, 0), 0.02, 0.2, 1.3, 0.005, start_direction=(0, 1, 0)), +} + + +def _box_points(shape, n, pad=0.0): + lower = np.asarray(shape.lower_bound) - pad + upper = np.asarray(shape.upper_bound) + pad + return RNG.uniform(lower[:, None], upper[:, None], (3, n)), np.prod(upper - lower) + + +def _sampled_distance(shape, points, samples=100_000): + t = np.linspace(0, 2 * np.pi * shape.turns, samples) + curve = np.asarray(shape.centreline(t)) + return np.array( + [np.min(np.linalg.norm(curve - p[:, None], axis=0)) for p in points.T] + ) + + +@pytest.mark.parametrize("name", SHAPES) +def test_level_set_is_the_distance_to_the_centreline(name): + shape = SHAPES[name] + box, _ = _box_points(shape, 300, pad=0.03) + along = np.asarray(shape.centreline(RNG.uniform(0, 2 * np.pi * shape.turns, 300))) + points = np.concatenate( + [box, along + RNG.normal(0, shape.wire_radius, along.shape)], axis=1 + ) + expected = _sampled_distance(shape, points) - shape.wire_radius + values = np.asarray(shape.level_set(points)) + # exact within a wire radius of the wire (the dense sampling's own error is + # below 1e-6 here), a lower bound farther away + near = expected < shape.wire_radius + assert near.sum() > 100 + np.testing.assert_allclose(values[near], expected[near], atol=2e-6) + assert np.all(values <= expected + 2e-6) + inside = np.asarray(shape.contains_points(points)) + np.testing.assert_array_equal(inside, values <= 0) + assert shape.has_level_set and not shape.level_set_exact + for point in points[:, :5].T: + assert shape.contains(point) == bool(inside[np.argmax(points[0] == point[0])]) + + +@pytest.mark.parametrize("name", SHAPES) +def test_volume_and_bounds(name): + shape = SHAPES[name] + points, box = _box_points(shape, 300_000) + inside = np.asarray(shape.contains_points(points)) + assert np.mean(inside) * box == pytest.approx(shape.volume(), rel=0.03) + outside, _ = _box_points(shape, 20_000, pad=0.02) + beyond = np.any( + (outside < np.asarray(shape.lower_bound)[:, None]) + | (outside > np.asarray(shape.upper_bound)[:, None]), + axis=0, + ) + assert not np.asarray(shape.contains_points(outside[:, beyond])).any() + + +def test_frame_handedness_and_ends(): + coil = SHAPES["coil"] + first, last = np.asarray(coil.centreline([0.0, 2 * np.pi * coil.turns])).T + np.testing.assert_allclose(first, (0.14, 0, 0.02), atol=1e-15) + np.testing.assert_allclose(last, (0.14, 0, 0.14), atol=1e-12) + # right-handed: a quarter turn goes from +x to +y + np.testing.assert_allclose( + np.asarray(coil.centreline([np.pi / 2]))[:, 0], (0, 0.14, 0.025), atol=1e-12 + ) + assert not coil.left_handed and SHAPES["oblique left"].left_handed + assert coil.length == pytest.approx(0.12) + assert coil.wire_length == pytest.approx(6 * np.hypot(2 * np.pi * 0.14, 0.02)) + assert coil.surface_area() == pytest.approx( + 2 * np.pi * 0.003 * coil.wire_length + 4 * np.pi * 0.003**2 + ) + # hemispherical caps: just beyond the start, along the wire's tangent + assert coil.contains((0.14, -0.0029, 0.02)) + assert not coil.contains((0.14, -0.0031, 0.02)) + + +def test_copy_translate_and_mirror(): + shape = SHAPES["oblique left"] + points, _ = _box_points(shape, 2000, pad=0.02) + copied = shape.copy() + np.testing.assert_allclose(copied.level_set(points), shape.level_set(points)) + assert copied.left_handed and copied.start_direction == shape.start_direction + moved = shape.copy() + moved.translate((0.1, 0.0, -0.2)) + np.testing.assert_allclose( + moved.level_set(points + np.array([[0.1], [0.0], [-0.2]])), + shape.level_set(points), + atol=1e-12, + ) + mirrored = shape.copy() + mirrored.mirror(1, 0.05) + image = points.copy() + image[1] = 0.1 - image[1] + np.testing.assert_allclose( + mirrored.level_set(image), shape.level_set(points), atol=1e-12 + ) + assert not mirrored.left_handed + with pytest.raises(ValueError, match="Axis"): + mirrored.mirror(3, 0.0) + + +def test_crossings_of_the_wire(): + coil = SHAPES["coil"] + # radial segments through the first turn's wire at z = 0.02 + 0.02 * angle / 2 pi + angles = np.linspace(0.1, 2 * np.pi - 0.1, 50) + z = 0.02 + 0.02 * angles / (2 * np.pi) + start = np.stack([0.1 * np.cos(angles), 0.1 * np.sin(angles), z]) + end = np.stack([0.2 * np.cos(angles), 0.2 * np.sin(angles), z]) + result = crossings(coil, start, end) + assert np.all(np.asarray(result.hit)) + radius = 0.1 + 0.1 * np.asarray(result.fraction) + # the wire's inner side, where the plane z = const cuts it (to 1e-3 of the pitch) + np.testing.assert_allclose(radius, 0.14 - 0.003, atol=2e-5) + + +def test_yee_fractions_of_the_wire(): + coil = Helix((0, 0, -0.01), 0.03, 0.02, 1, 0.005) + grid = Grid(3, (-0.04, -0.04, -0.02), (0.04, 0.04, 0.02), (33, 33, 17)) + fractions = coil.yee_fractions(grid, samples=16) + h = 0.08 / 32 + # each family of edges, its inside lengths times h^2, is the wire's volume + for family in fractions.edges: + assert np.sum(family) * h**3 == pytest.approx(coil.volume(), rel=0.05) + # the projected traces times h over all three families: twice the surface's area + area = sum(np.sum(t) * h for t in fractions.projected_traces) + assert area == pytest.approx(2 * coil.surface_area(), rel=0.05) + + +def test_invalid_input(): + with pytest.raises(ValueError, match="wire_radius"): + Helix((0, 0, 0), 0.1, 0.02, 3, 0.2) + with pytest.raises(ValueError, match="pitch"): + Helix((0, 0, 0), 0.1, 0.0, 3, 0.01) + with pytest.raises(ValueError, match="nonzero"): + Helix((0, 0, 0), 0.1, 0.02, 3, 0.01, axis=(0, 0, 0)) + with pytest.raises(ValueError, match="start_direction"): + Helix((0, 0, 0), 0.1, 0.02, 3, 0.01, start_direction=(0, 0, 2)) diff --git a/src/simgeometry/tests/test_polar_repeat.py b/src/simgeometry/tests/test_polar_repeat.py new file mode 100644 index 0000000..7c0b5bc --- /dev/null +++ b/src/simgeometry/tests/test_polar_repeat.py @@ -0,0 +1,67 @@ +"""PolarRepeat: the copies of a shape about an axis, against their explicit union.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from simgeometry import Circle, Cuboid, Grid, PolarRepeat, Sphere, Tube + +RNG = np.random.default_rng(3) + + +def _union(shape, count, axis=(0, 0, 1), origin=None): + union = shape + for k in range(1, count): + union = union | shape.rotated(360.0 * k / count, axis=axis, origin=origin) + return union + + +@pytest.mark.parametrize( + ("source", "count", "axis", "origin"), + [ + (Cuboid((0.12, 0, 0.08), (0.02, 0.002, 0.15)), 24, (0, 0, 1), None), + (Sphere((0.2, 0.0, 0.1), 0.05), 5, (0, 0, 1), (0.0, 0.0, 0.1)), + (Sphere((0.0, 0.3, 0.0), 0.1), 3, (1, 0, 0), (0.1, 0.0, 0.0)), + ], +) +def test_membership_and_level_set_are_those_of_the_union(source, count, axis, origin): + repeat = PolarRepeat(source, count, axis=axis, origin=origin) + union = _union(source, count, axis, origin) + lower = np.asarray(repeat.lower_bound) - 0.02 + upper = np.asarray(repeat.upper_bound) + 0.02 + points = RNG.uniform(lower[:, None], upper[:, None], (3, 50_000)) + np.testing.assert_array_equal( + np.asarray(repeat.contains_points(points)), + np.asarray(union.contains_points(points)), + ) + np.testing.assert_allclose( + repeat.level_set(points), union.level_set(points), atol=1e-12 + ) + assert repeat.has_level_set and not repeat.level_set_exact + inside = np.asarray(union.contains_points(points)) + assert np.all((points[:, inside] >= lower[:, None] + 0.02 - 1e-12).all(axis=0)) + assert np.all((points[:, inside] <= upper[:, None] - 0.02 + 1e-12).all(axis=0)) + assert repeat.contains(points[:, np.argmax(inside)]) + assert repeat.volume() == pytest.approx(count * source.volume()) + + +def test_slotted_tube_fractions_match_the_union(): + tube = Tube((0, 0, 0), (0, 0, 0.1), 0.03, 0.036) + slit = Cuboid((0.033, 0, 0.05), (0.02, 0.003, 0.08)) + grid = Grid(3, (-0.04, -0.04, 0.0), (0.04, 0.04, 0.1), (17, 17, 11)) + fast = (tube - PolarRepeat(slit, 8)).yee_fractions(grid, samples=16) + slow = (tube - _union(slit, 8)).yee_fractions(grid, samples=16) + for a in range(3): + np.testing.assert_allclose(fast.edges[a], slow.edges[a], atol=1e-9) + np.testing.assert_allclose(fast.faces[a], slow.faces[a], atol=1e-9) + np.testing.assert_allclose(fast.traces[a], slow.traces[a], atol=1e-9) + + +def test_invalid_input(): + with pytest.raises(ValueError, match="3D"): + PolarRepeat(Circle((1, 0), 0.1), 3) + with pytest.raises(ValueError, match="count"): + PolarRepeat(Sphere((1, 0, 0), 0.1), 0) + with pytest.raises(ValueError, match="axis"): + PolarRepeat(Sphere((1, 0, 0), 0.1), 3, axis=(0, 0, 0)) diff --git a/tutorials/shapes_and_node_masks.ipynb b/tutorials/shapes_and_node_masks.ipynb index 5de280a..5b39d56 100644 --- a/tutorials/shapes_and_node_masks.ipynb +++ b/tutorials/shapes_and_node_masks.ipynb @@ -244,7 +244,13 @@ "id": "15", "metadata": {}, "source": [ - "`node_mask()` works the same way on a 3D grid. `show_3d(points=...)` draws the nodes inside a tilted torus as a labelled point set over its translucent surface, in simgeometry's `(3, n)` layout as `node_points()` returns them; rotate the view to see that they fill the tube of the torus and leave its hole empty." + "## A coil and a slotted shield\n", + "\n", + "`Helix(start, radius, pitch, turns, wire_radius)` is a helical wire, such as the copper\n", + "tube of an RF coil: the points within `wire_radius` of a helix around `axis` (z by\n", + "default) that starts at `start + radius * x`. `PolarRepeat(shape, count)` makes `count`\n", + "copies of a shape about an axis and tests a point against one of them, so the 24 slits of\n", + "a Faraday shield cost no more than one." ] }, { @@ -253,6 +259,38 @@ "id": "16", "metadata": {}, "outputs": [], + "source": [ + "from simgeometry import Cuboid, Helix, PolarRepeat\n", + "\n", + "coil = Helix((0, 0, 0.025), 0.1385, 0.02, 6, 0.004) # six turns, an 8 mm tube\n", + "tube = Tube((0, 0, 0), (0, 0, 0.17), 0.1185, 0.1265)\n", + "shield = tube - PolarRepeat(Cuboid((0.1225, 0, 0.085), (0.024, 0.002, 0.15)), 24)\n", + "print(\"wire length:\", round(coil.wire_length, 3), \"m\")\n", + "points = (\n", + " np.random.default_rng(0).uniform(coil.lower_bound, coil.upper_bound, (200_000, 3)).T\n", + ")\n", + "box = np.prod(np.subtract(coil.upper_bound, coil.lower_bound))\n", + "print(\"volume:\", coil.volume(), \"sampled:\", coil.contains_points(points).mean() * box)\n", + "print(\n", + " \"in a slit, in the wall:\",\n", + " shield.contains_points(np.array([[0.1225, 0.1225], [0, 0.003], [0.085, 0.085]])),\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "17", + "metadata": {}, + "source": [ + "`node_mask()` works the same way on a 3D grid. `show_3d(points=...)` draws the nodes inside a tilted torus as a labelled point set over its translucent surface, in simgeometry's `(3, n)` layout as `node_points()` returns them; rotate the view to see that they fill the tube of the torus and leave its hole empty." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "18", + "metadata": {}, + "outputs": [], "source": [ "grid3 = Grid(3, (-1.0, -1.0, -1.0), (1.0, 1.0, 1.0), (21, 21, 21))\n", "torus = Torus((0, 0, 0), 0.6, 0.25, axis=(1, 0, 1))\n", From 3f2609e59e59773cbc6ce90948dc01f850497254 Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 3 Nov 2026 21:57:47 +0100 Subject: [PATCH 33/36] Add an example page for each object. (#47) --- CHANGELOG.md | 3 + tutorials/index.ipynb | 2 + tutorials/objects/aperture2d.ipynb | 98 +++++++++++++++++ tutorials/objects/aperture3d.ipynb | 100 ++++++++++++++++++ tutorials/objects/circle.ipynb | 115 ++++++++++++++++++++ tutorials/objects/cone.ipynb | 71 +++++++++++++ tutorials/objects/cuboid.ipynb | 73 +++++++++++++ tutorials/objects/cylinder.ipynb | 69 ++++++++++++ tutorials/objects/function_object_2d.ipynb | 95 +++++++++++++++++ tutorials/objects/function_object_3d.ipynb | 104 ++++++++++++++++++ tutorials/objects/helix.ipynb | 73 +++++++++++++ tutorials/objects/line.ipynb | 116 +++++++++++++++++++++ tutorials/objects/polygon.ipynb | 93 +++++++++++++++++ tutorials/objects/polyline.ipynb | 98 +++++++++++++++++ tutorials/objects/rectangle.ipynb | 91 ++++++++++++++++ tutorials/objects/slanted_tube.ipynb | 95 +++++++++++++++++ tutorials/objects/sphere.ipynb | 109 +++++++++++++++++++ tutorials/objects/torus.ipynb | 94 +++++++++++++++++ tutorials/objects/triangle.ipynb | 91 ++++++++++++++++ tutorials/objects/tube.ipynb | 90 ++++++++++++++++ 20 files changed, 1680 insertions(+) create mode 100644 tutorials/objects/aperture2d.ipynb create mode 100644 tutorials/objects/aperture3d.ipynb create mode 100644 tutorials/objects/circle.ipynb create mode 100644 tutorials/objects/cone.ipynb create mode 100644 tutorials/objects/cuboid.ipynb create mode 100644 tutorials/objects/cylinder.ipynb create mode 100644 tutorials/objects/function_object_2d.ipynb create mode 100644 tutorials/objects/function_object_3d.ipynb create mode 100644 tutorials/objects/helix.ipynb create mode 100644 tutorials/objects/line.ipynb create mode 100644 tutorials/objects/polygon.ipynb create mode 100644 tutorials/objects/polyline.ipynb create mode 100644 tutorials/objects/rectangle.ipynb create mode 100644 tutorials/objects/slanted_tube.ipynb create mode 100644 tutorials/objects/sphere.ipynb create mode 100644 tutorials/objects/torus.ipynb create mode 100644 tutorials/objects/triangle.ipynb create mode 100644 tutorials/objects/tube.ipynb diff --git a/CHANGELOG.md b/CHANGELOG.md index 67896b6..c7c76ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- One example page per object of `simgeometry.objects`, from `Line` to `Helix` + (`tutorials/objects/`, the Objects group of the Examples): how to build it, membership, + level set and a figure. - `Helix(start, radius, pitch, turns, wire_radius, axis=..., left_handed=...)`: a helical wire of round cross-section with hemispherical ends, e.g. the winding of an RF coil. Its level set is the distance from the centreline (Newton steps along it from the diff --git a/tutorials/index.ipynb b/tutorials/index.ipynb index 0ed604f..bd23794 100644 --- a/tutorials/index.ipynb +++ b/tutorials/index.ipynb @@ -21,6 +21,8 @@ "9. [Boundary outlines](/simgeometry/examples/boundary-outlines/) turns 2D shapes into vertex loops and panels for boundary-element solvers, and finds where particle steps cross a boundary.\n", "10. [Conductors and level sets](/simgeometry/examples/conductors-and-level-sets/) builds an Einzel lens from ring electrodes with voltages, reads Dirichlet values on a grid, and uses level sets for exact distances and edge crossings.\n", "\n", + "The Objects pages below show each shape of `simgeometry.objects` on its own, from `Line` in 1D to `Helix` in 3D: how to build it, what it contains, its level set and how it looks.\n", + "\n", "See [Building the docs](/simgeometry/development/docs/) to add an example." ] } diff --git a/tutorials/objects/aperture2d.ipynb b/tutorials/objects/aperture2d.ipynb new file mode 100644 index 0000000..e406bf5 --- /dev/null +++ b/tutorials/objects/aperture2d.ipynb @@ -0,0 +1,98 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Aperture2D\n", + "\n", + "Two plates mirrored about a beam axis: a slit, or the 2D cut through a round aperture.\n", + "\n", + "`Aperture2D(axis, position, vertices)` takes the profile of one plate as `(axial, radial)`\n", + "vertices, the coordinate along the beam and the distance from the centre line at\n", + "`position`; the second plate is its mirror image. `Aperture2D.slit` builds the common\n", + "rectangular plates." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "\n", + "from simgeometry import Aperture2D\n", + "from simgeometry.crossings import crossings\n", + "\n", + "slit = Aperture2D.slit(axial_position=0.3, half_width=0.1, thickness=0.05, length=0.4)\n", + "# a knife edge with a narrower bore: thin at the bore, thicker outside\n", + "knife = Aperture2D(\n", + " axis=0,\n", + " position=0.0,\n", + " vertices=[(0.8, 0.05), (0.84, 0.05), (0.95, 0.5), (0.7, 0.5)],\n", + ")\n", + "print(\n", + " \"on the axis:\", slit.contains((0.3, 0.0)), \" in a plate:\", slit.contains((0.3, 0.2))\n", + ")\n", + "assert not slit.contains((0.3, 0.0)) and slit.contains((0.3, -0.2))" + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "## A beam through both apertures\n", + "\n", + "Steps along the beam axis (x) at different heights: the slit stops those with $|y| > 0.1$,\n", + "the knife edge those with $|y| > 0.05$ that got through the slit." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "y = np.linspace(-0.425, 0.425, 18)\n", + "start = np.stack((np.zeros_like(y), y))\n", + "end = np.stack((np.ones_like(y), y))\n", + "first = crossings(slit, start, end)\n", + "end_after_slit = np.where(first.hit, first.point, end)\n", + "second = crossings(knife, start, end_after_slit)\n", + "stopped = np.where(second.hit, second.point, end_after_slit)\n", + "assert first.hit.sum() == np.sum(np.abs(y) > 0.1)\n", + "assert second.hit.sum() == np.sum((np.abs(y) > 0.05) & (np.abs(y) < 0.1))\n", + "\n", + "fig, ax = plt.subplots(figsize=(6, 3.6), layout=\"constrained\")\n", + "for shape, color in ((slit, \"tab:blue\"), (knife, \"tab:green\")):\n", + " for loop in shape.outlines():\n", + " ax.fill(*loop, color=color, alpha=0.5)\n", + "for i in range(len(y)):\n", + " ax.plot([start[0, i], stopped[0, i]], [y[i], y[i]], color=\"tab:orange\", lw=1)\n", + "ax.axhline(0.0, color=\"0.5\", lw=0.8, ls=\"--\")\n", + "ax.set(xlabel=\"x (beam axis)\", ylabel=\"y\", title=\"A slit and a knife-edge aperture\")\n", + "ax.set_aspect(\"equal\")\n", + "plt.show()" + ] + } + ], + "metadata": { + "docs_order": 116, + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/objects/aperture3d.ipynb b/tutorials/objects/aperture3d.ipynb new file mode 100644 index 0000000..26cc7bc --- /dev/null +++ b/tutorials/objects/aperture3d.ipynb @@ -0,0 +1,100 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Aperture3D\n", + "\n", + "A plate with a round opening along a coordinate axis: the extraction or acceleration grid of an\n", + "ion source, a skimmer or a beam-limiting aperture.\n", + "\n", + "`Aperture3D(axis, center_coords, vertices, lower_bound, upper_bound)` takes the opening's\n", + "radius along the axis as `(axial, radius)` vertices, interpolated linearly between them, and\n", + "the box the plate fills. It is inverted by default: the solid is the material around the\n", + "opening, and nothing outside the box. It has no level set, so crossings are sampled." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "\n", + "from simgeometry import Aperture3D\n", + "\n", + "# a 0.2 thick plate whose bore narrows from radius 0.4 to 0.2\n", + "grid_plate = Aperture3D(\n", + " axis=\"z\",\n", + " center_coords=(0.0, 0.0),\n", + " vertices=[(0.0, 0.4), (0.2, 0.2)],\n", + " lower_bound=(-1.0, -1.0, 0.0),\n", + " upper_bound=(1.0, 1.0, 0.2),\n", + ")\n", + "print(\"on the axis:\", grid_plate.contains((0.0, 0.0, 0.1)))\n", + "print(\"radius 0.3 at the front:\", grid_plate.contains((0.3, 0.0, 0.01)))\n", + "print(\"radius 0.3 at the back:\", grid_plate.contains((0.3, 0.0, 0.19)))\n", + "assert not grid_plate.contains((0.3, 0.0, 0.01)) and grid_plate.contains(\n", + " (0.3, 0.0, 0.19)\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "## Section and surface\n", + "\n", + "The section `y = 0` shows the tapered bore through the plate." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "ax = grid_plate.show(y=0.0, resolution=200, display=False)\n", + "ax.set_title(\"The section y = 0 of an Aperture3D\")\n", + "plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4", + "metadata": {}, + "outputs": [], + "source": [ + "import plasma_plots\n", + "import pyvista as pv\n", + "\n", + "pv.OFF_SCREEN = True # render without a window, e.g. on a docs server\n", + "\n", + "plotter = grid_plate.show_3d(\n", + " backend=\"pyvista\", display=False, resolution=96, show_bounds=False\n", + ")\n", + "plasma_plots.scene(plotter, alt=\"A square plate with a tapered round hole\")" + ] + } + ], + "metadata": { + "docs_order": 129, + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/objects/circle.ipynb b/tutorials/objects/circle.ipynb new file mode 100644 index 0000000..84b0160 --- /dev/null +++ b/tutorials/objects/circle.ipynb @@ -0,0 +1,115 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Circle\n", + "\n", + "A disc of a given radius: the cross-section of a wire, a rod or a round electrode.\n", + "\n", + "`Circle(midpoint, radius)` has an exact level set, the signed distance\n", + "`|p - midpoint| - radius`, so distances, closest points and normals are analytic." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "\n", + "from simgeometry import Circle, Grid\n", + "\n", + "rod = Circle(midpoint=(0.0, 0.0), radius=0.5)\n", + "points = np.array([[0.0, 0.4, 1.0], [0.0, 0.0, 1.0]]) # (2, n): one point per column\n", + "print(\"inside:\", rod.contains_points(points))\n", + "print(\"signed distance:\", rod.signed_distance(points))\n", + "print(\"closest point to (1, 1):\", rod.closest_point((1.0, 1.0)))\n", + "print(\"normal at (0.5, 0):\", rod.surface_normal((0.5, 0.0)))\n", + "assert np.isclose(rod.signed_distance((1.0, 1.0)), np.sqrt(2) - 0.5)" + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "## Distance field and node mask\n", + "\n", + "The level set sampled around the rod: its contours are circles, one per distance. The grid\n", + "nodes inside the rod are those `node_mask` selects." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "x = np.linspace(-1.0, 1.0, 201)\n", + "X, Y = np.meshgrid(x, x, indexing=\"ij\")\n", + "distance = rod.signed_distance(np.stack((X.ravel(), Y.ravel()))).reshape(X.shape)\n", + "\n", + "grid = Grid(2, (-1.0, -1.0), (1.0, 1.0), (21, 21))\n", + "mask = rod.node_mask(grid).ravel()\n", + "nodes = grid.node_points()\n", + "\n", + "fig, ax = plt.subplots(figsize=(5.5, 4.5), layout=\"constrained\")\n", + "image = ax.contourf(X, Y, distance, levels=np.linspace(-0.5, 1.0, 16), cmap=\"RdBu_r\")\n", + "ax.contour(X, Y, distance, levels=[0.0], colors=\"k\")\n", + "ax.scatter(*nodes[:, mask], s=8, color=\"k\", label=\"nodes inside\")\n", + "fig.colorbar(image, ax=ax, label=\"signed distance\")\n", + "ax.set(xlabel=\"x\", ylabel=\"y\", title=\"The level set of a Circle\")\n", + "ax.set_aspect(\"equal\")\n", + "ax.legend(loc=\"upper right\")\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "4", + "metadata": {}, + "source": [ + "## Outlines and inversion\n", + "\n", + "`outlines(max_spacing)` gives the boundary as a counter-clockwise vertex loop, for\n", + "boundary-element solvers and for plotting. An inverted circle is everything outside it: the\n", + "wall of a round chamber." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5", + "metadata": {}, + "outputs": [], + "source": [ + "(loop,) = rod.outlines(max_spacing=0.05)\n", + "print(\"outline vertices:\", loop.shape[1])\n", + "\n", + "chamber = Circle(midpoint=(0.0, 0.0), radius=0.5, inverted=True)\n", + "assert np.array_equal(chamber.node_mask(grid).ravel(), ~mask)\n", + "assert np.isclose(chamber.signed_distance((1.0, 1.0)), -rod.signed_distance((1.0, 1.0)))" + ] + } + ], + "metadata": { + "docs_order": 111, + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/objects/cone.ipynb b/tutorials/objects/cone.ipynb new file mode 100644 index 0000000..bb3530d --- /dev/null +++ b/tutorials/objects/cone.ipynb @@ -0,0 +1,71 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Cone\n", + "\n", + "A cone or a frustum along any axis: nozzles, tips and tapered electrodes.\n", + "\n", + "`Cone(start, end, start_radius, end_radius)` has radius `start_radius` at `start` and\n", + "`end_radius` at `end`; one of them may be zero, which puts the tip there. The level set is the\n", + "exact signed distance." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "\n", + "from simgeometry import Cone, GeometryCollection\n", + "\n", + "tip = Cone(start=(0.0, 0.0, 0.0), end=(0.0, 0.0, 1.0), start_radius=0.5, end_radius=0.0)\n", + "frustum = Cone(\n", + " start=(1.5, 0.0, 0.0), end=(1.5, 0.0, 1.0), start_radius=0.5, end_radius=0.25\n", + ")\n", + "print(\"volumes:\", round(tip.volume(), 4), round(frustum.volume(), 4))\n", + "print(\"distance from the tip:\", tip.signed_distance((0.0, 0.0, 1.5)))\n", + "assert np.isclose(tip.volume(), np.pi * 0.5**2 / 3)\n", + "assert np.isclose(tip.signed_distance((0.0, 0.0, 1.5)), 0.5)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2", + "metadata": {}, + "outputs": [], + "source": [ + "import plasma_plots\n", + "import pyvista as pv\n", + "\n", + "pv.OFF_SCREEN = True # render without a window, e.g. on a docs server\n", + "\n", + "cones = GeometryCollection({\"cone\": tip, \"frustum\": frustum})\n", + "plotter = cones.show_3d(\n", + " backend=\"pyvista\", display=False, resolution=64, show_bounds=False\n", + ")\n", + "plasma_plots.scene(plotter, alt=\"A cone and a frustum side by side\")" + ] + } + ], + "metadata": { + "docs_order": 125, + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/objects/cuboid.ipynb b/tutorials/objects/cuboid.ipynb new file mode 100644 index 0000000..21260a4 --- /dev/null +++ b/tutorials/objects/cuboid.ipynb @@ -0,0 +1,73 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Cuboid\n", + "\n", + "An axis-aligned box in 3D, optionally with rounded edges and corners: blocks, plates and\n", + "chamber walls.\n", + "\n", + "`Cuboid(midpoint, side_lengths, fillet=r)` rounds the twelve edges and eight corners with\n", + "radius `r`, up to half the shortest side. The level set is the exact signed distance with or\n", + "without the fillet." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "\n", + "from simgeometry import Cuboid, GeometryCollection\n", + "\n", + "block = Cuboid(midpoint=(0.0, 0.0, 0.0), side_lengths=(2.0, 1.0, 0.4))\n", + "rounded = Cuboid(midpoint=(0.0, 1.5, 0.0), side_lengths=(2.0, 1.0, 0.4), fillet=0.15)\n", + "print(\"volume:\", block.volume(), \" rounded:\", round(rounded.volume(), 4))\n", + "print(\n", + " \"corner inside:\",\n", + " block.contains((0.99, 0.49, 0.19)),\n", + " rounded.contains((0.99, 1.99, 0.19)),\n", + ")\n", + "assert np.isclose(block.signed_distance((1.5, 0.0, 0.0)), 0.5)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2", + "metadata": {}, + "outputs": [], + "source": [ + "import plasma_plots\n", + "import pyvista as pv\n", + "\n", + "pv.OFF_SCREEN = True # render without a window, e.g. on a docs server\n", + "\n", + "blocks = GeometryCollection({\"sharp\": block, \"fillet 0.15\": rounded})\n", + "plotter = blocks.show_3d(\n", + " backend=\"pyvista\", display=False, resolution=64, show_bounds=False\n", + ")\n", + "plasma_plots.scene(plotter, alt=\"A box with sharp edges and one with rounded edges\")" + ] + } + ], + "metadata": { + "docs_order": 122, + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/objects/cylinder.ipynb b/tutorials/objects/cylinder.ipynb new file mode 100644 index 0000000..af98f0f --- /dev/null +++ b/tutorials/objects/cylinder.ipynb @@ -0,0 +1,69 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Cylinder\n", + "\n", + "A solid circular cylinder along any axis: rods, posts and feedthroughs.\n", + "\n", + "`Cylinder(start, end, radius)` runs from the centre of one end face to the other; `fillet`\n", + "rounds the two circular edges. The level set is the exact signed distance, that of the\n", + "profile in the half-plane of axial position and radius." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "\n", + "from simgeometry import Cylinder\n", + "\n", + "rod = Cylinder(start=(0.0, 0.0, 0.0), end=(2.0, 0.0, 1.0), radius=0.3, fillet=0.1)\n", + "print(\"length:\", rod.length, \" volume:\", round(rod.volume(), 4))\n", + "print(\"on the axis:\", rod.contains((1.0, 0.0, 0.5)))\n", + "side = (1.0, 1.0, 0.5) # 1 from the middle of the axis, normal to it\n", + "print(\"distance from\", np.round(side, 3), \":\", rod.signed_distance(side))\n", + "assert np.isclose(rod.length, np.sqrt(5))\n", + "assert np.isclose(rod.signed_distance(side), 0.7)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2", + "metadata": {}, + "outputs": [], + "source": [ + "import plasma_plots\n", + "import pyvista as pv\n", + "\n", + "pv.OFF_SCREEN = True # render without a window, e.g. on a docs server\n", + "\n", + "plotter = rod.show_3d(\n", + " backend=\"pyvista\", display=False, resolution=64, show_bounds=False\n", + ")\n", + "plasma_plots.scene(plotter, alt=\"A tilted cylinder with rounded edges\")" + ] + } + ], + "metadata": { + "docs_order": 123, + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/objects/function_object_2d.ipynb b/tutorials/objects/function_object_2d.ipynb new file mode 100644 index 0000000..268349f --- /dev/null +++ b/tutorials/objects/function_object_2d.ipynb @@ -0,0 +1,95 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# FunctionObject2D\n", + "\n", + "A 2D shape defined by a Python function: anything that has a membership test but no primitive.\n", + "\n", + "`FunctionObject2D(func, lower_bound, upper_bound)` calls `func((x, y))` for each point and\n", + "needs a bounding box, which cannot be derived from a function. It has no level set, so\n", + "distances and crossings are found by sampling its membership. Prefer a primitive or a Boolean\n", + "combination of primitives where one fits." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "\n", + "from simgeometry import FunctionObject2D, Grid\n", + "from simgeometry.crossings import crossings\n", + "\n", + "\n", + "def flower(coords):\n", + " x, y = coords\n", + " return np.hypot(x, y) < 0.8 + 0.25 * np.cos(5 * np.arctan2(y, x))\n", + "\n", + "\n", + "shape = FunctionObject2D(flower, lower_bound=(-1.05, -1.05), upper_bound=(1.05, 1.05))\n", + "print(\"centre inside:\", shape.contains((0.0, 0.0)), \" level set:\", shape.has_level_set)\n", + "assert shape.contains((1.0, 0.0)) and not shape.contains((0.0, 1.0))" + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "## Node mask and sampled crossings\n", + "\n", + "The grid nodes inside, and the first crossing of steps from the centre outward. The crossings\n", + "are sampled along each step and refined by bisection (`exact` is false)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "grid = Grid(2, (-1.1, -1.1), (1.1, 1.1), (45, 45))\n", + "mask = shape.node_mask(grid).ravel()\n", + "nodes = grid.node_points()\n", + "\n", + "angles = np.linspace(0, 2 * np.pi, 24, endpoint=False)\n", + "start = np.zeros((2, angles.size))\n", + "end = 1.2 * np.stack((np.cos(angles), np.sin(angles)))\n", + "hits = crossings(shape, start, end)\n", + "print(\"all steps leave the shape:\", bool(hits.hit.all()), \" exact:\", hits.exact)\n", + "\n", + "fig, ax = plt.subplots(figsize=(5, 5), layout=\"constrained\")\n", + "ax.scatter(*nodes[:, mask], s=6, color=\"tab:purple\", label=\"nodes inside\")\n", + "for i in range(angles.size):\n", + " ax.plot(*np.stack((start[:, i], hits.point[:, i]), axis=1), color=\"0.6\", lw=0.8)\n", + "ax.plot(*hits.point, \"o\", color=\"tab:red\", ms=4, label=\"crossings\")\n", + "ax.set(xlabel=\"x\", ylabel=\"y\", title=\"A FunctionObject2D\")\n", + "ax.set_aspect(\"equal\")\n", + "ax.legend(loc=\"upper right\")\n", + "plt.show()" + ] + } + ], + "metadata": { + "docs_order": 117, + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/objects/function_object_3d.ipynb b/tutorials/objects/function_object_3d.ipynb new file mode 100644 index 0000000..1ff38c9 --- /dev/null +++ b/tutorials/objects/function_object_3d.ipynb @@ -0,0 +1,104 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# FunctionObject3D\n", + "\n", + "A 3D shape defined by a Python function: anything that has a membership test but no primitive.\n", + "\n", + "`FunctionObject3D(func, lower_bound, upper_bound)` calls `func((x, y, z))` for each point and\n", + "needs a bounding box. It has no level set and no volume: crossings and distances are found by\n", + "sampling its membership. Prefer a primitive where one fits (a torus is `Torus`, a ring\n", + "electrode `Tube`)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "from simgeometry import FunctionObject3D, Grid\n", + "\n", + "\n", + "def rounded_cube(coords):\n", + " x, y, z = coords\n", + " return x**4 + y**4 + z**4 <= 1.0\n", + "\n", + "\n", + "shape = FunctionObject3D(\n", + " rounded_cube, lower_bound=(-1.0, -1.0, -1.0), upper_bound=(1.0, 1.0, 1.0)\n", + ")\n", + "print(\"corner of the cube inside:\", shape.contains((0.9, 0.9, 0.9)))\n", + "print(\"level set:\", shape.has_level_set)\n", + "assert shape.contains((0.8, 0.0, 0.0)) and not shape.contains((0.9, 0.9, 0.9))" + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "## Volume from a node mask\n", + "\n", + "Without a `volume()`, count the nodes inside: the superellipsoid $x^4 + y^4 + z^4 \\le 1$ has\n", + "the volume $8\\,\\Gamma(5/4)^3 / \\Gamma(7/4) \\approx 6.48$. The surface below is also drawn from\n", + "sampled membership, which leaves faint terraces." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "from math import gamma\n", + "\n", + "n = 41\n", + "grid = Grid(3, (-1.0, -1.0, -1.0), (1.0, 1.0, 1.0), (n, n, n))\n", + "estimate = shape.node_mask(grid).sum() * (2.0 / (n - 1)) ** 3\n", + "exact = 8 * gamma(5 / 4) ** 3 / gamma(7 / 4)\n", + "print(f\"node count: {estimate:.3f}, exact: {exact:.3f}\")\n", + "assert abs(estimate / exact - 1) < 0.05" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4", + "metadata": {}, + "outputs": [], + "source": [ + "import plasma_plots\n", + "import pyvista as pv\n", + "\n", + "pv.OFF_SCREEN = True # render without a window, e.g. on a docs server\n", + "\n", + "plotter = shape.show_3d(\n", + " backend=\"pyvista\", display=False, resolution=96, show_bounds=False\n", + ")\n", + "plasma_plots.scene(\n", + " plotter, alt=\"A cube with rounded edges, the superellipsoid of order four\"\n", + ")" + ] + } + ], + "metadata": { + "docs_order": 130, + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/objects/helix.ipynb b/tutorials/objects/helix.ipynb new file mode 100644 index 0000000..14f8427 --- /dev/null +++ b/tutorials/objects/helix.ipynb @@ -0,0 +1,73 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Helix\n", + "\n", + "A helical wire of round cross-section: an RF coil, a spring or a helical electrode.\n", + "\n", + "`Helix(start, radius, pitch, turns, wire_radius)` winds `turns` times around `axis` at\n", + "`radius`, advancing `pitch` per turn, right-handed unless `left_handed=True`. Its ends are\n", + "hemispherical caps. The level set is the exact signed distance within a wire radius of the\n", + "wire and a lower bound farther away (`level_set_exact` is false), so `signed_distance`\n", + "measures the distance to a sampled surface mesh instead." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "\n", + "from simgeometry import Helix\n", + "\n", + "coil = Helix(start=(0.0, 0.0, 0.0), radius=0.5, pitch=0.25, turns=4, wire_radius=0.05)\n", + "print(\"axial length:\", coil.length, \" wire length:\", round(coil.wire_length, 4))\n", + "print(\"start of the wire inside:\", coil.contains((0.5, 0.0, 0.0)))\n", + "print(\"axis inside:\", coil.contains((0.0, 0.0, 0.5)))\n", + "assert np.isclose(coil.wire_length, 4 * np.hypot(2 * np.pi * 0.5, 0.25))\n", + "# half a turn in, the centreline is at (-0.5, 0, 0.125); 0.02 outward from it:\n", + "level = coil.level_set(np.array([[-0.52], [0.0], [0.125]]))\n", + "print(f\"level set 0.02 from the centreline: {float(level[0]):.4f}\")\n", + "assert np.isclose(float(level[0]), 0.02 - 0.05)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2", + "metadata": {}, + "outputs": [], + "source": [ + "import plasma_plots\n", + "import pyvista as pv\n", + "\n", + "pv.OFF_SCREEN = True # render without a window, e.g. on a docs server\n", + "\n", + "plotter = coil.show_3d(\n", + " backend=\"pyvista\", display=False, resolution=96, show_bounds=False\n", + ")\n", + "plasma_plots.scene(plotter, alt=\"A helical wire of four turns\")" + ] + } + ], + "metadata": { + "docs_order": 128, + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/objects/line.ipynb b/tutorials/objects/line.ipynb new file mode 100644 index 0000000..7b79e3e --- /dev/null +++ b/tutorials/objects/line.ipynb @@ -0,0 +1,116 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Line\n", + "\n", + "An interval of the real line: a wall, an electrode or an absorber in a 1D domain.\n", + "\n", + "`Line(midpoint, side_lengths)` covers `midpoint ± side_lengths / 2`. Both are one-element\n", + "sequences, as for `Rectangle` and `Cuboid` in two and three dimensions. Points are `(1, n)`\n", + "arrays, component-major like every query in simgeometry." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "\n", + "from simgeometry import Grid, Line\n", + "\n", + "wall = Line(midpoint=(0.6,), side_lengths=(0.25,))\n", + "print(\"from\", float(wall.lower_bound[0]), \"to\", float(wall.upper_bound[0]))\n", + "print(\"x = 0.6 inside:\", wall.contains((0.6,)))\n", + "print(\"points inside:\", wall.contains_points(np.array([[0.1, 0.5, 0.7, 0.9]])))\n", + "assert wall.contains((0.6,)) and not wall.contains((0.2,))" + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "## Node mask and level set\n", + "\n", + "`node_mask(grid)` marks the grid nodes inside the line. Its level set is the exact signed\n", + "distance to the interval: negative inside, zero at both ends, growing with slope one outside." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "grid = Grid(1, (0.0,), (1.0,), (21,))\n", + "mask = wall.node_mask(grid)\n", + "nodes = grid.node_points()[0]\n", + "print(\"nodes inside:\", nodes[mask])\n", + "\n", + "x = np.linspace(0.0, 1.0, 401)\n", + "distance = wall.signed_distance(x[None, :])\n", + "assert np.isclose(wall.signed_distance((0.2,)), 0.6 - 0.125 - 0.2)\n", + "\n", + "fig, ax = plt.subplots(figsize=(6, 3), layout=\"constrained\")\n", + "ax.axvspan(wall.lower_bound[0], wall.upper_bound[0], color=\"tab:orange\", alpha=0.25)\n", + "ax.plot(x, distance, label=\"signed distance\")\n", + "ax.axhline(0.0, color=\"0.5\", lw=0.8)\n", + "ax.plot(\n", + " nodes[~mask], np.zeros((~mask).sum()), \"o\", mfc=\"none\", color=\"0.5\", label=\"nodes\"\n", + ")\n", + "ax.plot(\n", + " nodes[mask], np.zeros(mask.sum()), \"o\", color=\"tab:orange\", label=\"nodes inside\"\n", + ")\n", + "ax.set(xlabel=\"x\", ylabel=\"signed distance\", title=\"A Line and its level set\")\n", + "ax.legend(loc=\"upper center\")\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "4", + "metadata": {}, + "source": [ + "## Inverted lines\n", + "\n", + "`inverted=True` covers everything except the interval, for example the walls on both sides of\n", + "a gap. The node mask is the complement and the level set changes sign." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5", + "metadata": {}, + "outputs": [], + "source": [ + "gap = Line(midpoint=(0.6,), side_lengths=(0.25,), inverted=True)\n", + "assert np.array_equal(gap.node_mask(grid), ~mask)\n", + "assert np.isclose(gap.signed_distance((0.2,)), -wall.signed_distance((0.2,)))\n", + "print(\"walls at x = 0.2:\", gap.contains((0.2,)))" + ] + } + ], + "metadata": { + "docs_order": 101, + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/objects/polygon.ipynb b/tutorials/objects/polygon.ipynb new file mode 100644 index 0000000..6727d22 --- /dev/null +++ b/tutorials/objects/polygon.ipynb @@ -0,0 +1,93 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Polygon\n", + "\n", + "A closed polygon through any number of vertices: electrode profiles, wall cross-sections and\n", + "other shapes made of straight edges.\n", + "\n", + "`Polygon(vertices)` decides membership by the even-odd rule, so a self-intersecting polygon\n", + "has holes where its edges overlap. Its level set is the exact signed distance to the nearest\n", + "edge, at a cost of one pass per edge." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "\n", + "from simgeometry import Polygon\n", + "\n", + "# an L-shaped electrode and a five-pointed star drawn in one stroke\n", + "electrode = Polygon(\n", + " [(0.0, 0.0), (1.0, 0.0), (1.0, 0.3), (0.3, 0.3), (0.3, 1.0), (0.0, 1.0)]\n", + ")\n", + "angles = np.pi / 2 + 4 * np.pi / 5 * np.arange(5)\n", + "star = Polygon(\n", + " np.stack((2.0 + 0.5 * np.cos(angles), 0.5 + 0.5 * np.sin(angles)), axis=1)\n", + ")\n", + "\n", + "print(\"in the notch of the L:\", electrode.contains((0.6, 0.6)))\n", + "print(\"distance from (0.6, 0.6):\", electrode.signed_distance((0.6, 0.6)))\n", + "print(\"star centre inside:\", star.contains((2.0, 0.5))) # covered twice: a hole\n", + "print(\"star point inside:\", star.contains((2.0, 0.9)))\n", + "assert not star.contains((2.0, 0.5)) and star.contains((2.0, 0.9))" + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "## Membership and level set\n", + "\n", + "The level set sampled around both polygons, with the zero contour drawn in black: the pentagon\n", + "in the middle of the star is outside." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "x = np.linspace(-0.2, 2.7, 291)\n", + "y = np.linspace(-0.2, 1.2, 141)\n", + "X, Y = np.meshgrid(x, y, indexing=\"ij\")\n", + "points = np.stack((X.ravel(), Y.ravel()))\n", + "distance = np.minimum(electrode.signed_distance(points), star.signed_distance(points))\n", + "\n", + "fig, ax = plt.subplots(figsize=(7, 3.6), layout=\"constrained\")\n", + "image = ax.contourf(X, Y, distance.reshape(X.shape), levels=15, cmap=\"RdBu_r\")\n", + "ax.contour(X, Y, distance.reshape(X.shape), levels=[0.0], colors=\"k\")\n", + "fig.colorbar(image, ax=ax, label=\"signed distance\")\n", + "ax.set(xlabel=\"x\", ylabel=\"y\", title=\"Two Polygons by the even-odd rule\")\n", + "ax.set_aspect(\"equal\")\n", + "plt.show()" + ] + } + ], + "metadata": { + "docs_order": 114, + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/objects/polyline.ipynb b/tutorials/objects/polyline.ipynb new file mode 100644 index 0000000..2f24fc6 --- /dev/null +++ b/tutorials/objects/polyline.ipynb @@ -0,0 +1,98 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Polyline\n", + "\n", + "An open curve of zero thickness through a list of vertices: a thin plate, a wire or a grid\n", + "electrode.\n", + "\n", + "`Polyline(vertices)` encloses no area, so `contains` is always false and it takes no part in\n", + "Boolean shapes. Its geometry is its outline, one open curve from the first vertex to the last,\n", + "which `boundary_panels` and `crossings` use directly." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "\n", + "from simgeometry import Polyline\n", + "from simgeometry.crossings import crossings\n", + "\n", + "deflector = Polyline([(0.0, 0.3), (0.5, 0.7), (1.0, 0.3)])\n", + "(curve,) = deflector.outlines()\n", + "panels = deflector.boundary_panels(max_spacing=0.1)\n", + "print(\"closed:\", deflector.closed, \" vertices:\", curve.shape[1])\n", + "print(\"panels:\", len(panels), \" total length:\", panels.length.sum())\n", + "assert not deflector.contains((0.5, 0.5))\n", + "assert np.isclose(panels.length.sum(), 2 * np.hypot(0.5, 0.4))" + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "## Particle steps\n", + "\n", + "A particle step that passes through the curve stops on it: `crossings` returns, for each step\n", + "from `start` to `end`, whether it hits, where, and the normal there. Here vertical steps rise\n", + "from below the deflector." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "x = np.linspace(-0.2, 1.2, 15)\n", + "start = np.stack((x, np.zeros_like(x)))\n", + "end = np.stack((x, np.ones_like(x)))\n", + "hits = crossings(deflector, start, end)\n", + "print(\"steps stopped:\", int(hits.hit.sum()), \"of\", len(x), \" analytic:\", hits.exact)\n", + "\n", + "fig, ax = plt.subplots(figsize=(5.5, 4), layout=\"constrained\")\n", + "for i in range(len(x)):\n", + " stop = hits.point[:, i] if hits.hit[i] else end[:, i]\n", + " ax.plot(*np.stack((start[:, i], stop), axis=1), color=\"0.7\", lw=1)\n", + "ax.plot(*curve, color=\"k\", lw=2, label=\"Polyline\")\n", + "ax.plot(*hits.point[:, hits.hit], \"o\", color=\"tab:red\", label=\"crossings\")\n", + "ax.quiver(\n", + " *hits.point[:, hits.hit],\n", + " *hits.normal[:, hits.hit],\n", + " color=\"tab:red\",\n", + " scale=14,\n", + " width=0.005,\n", + ")\n", + "ax.set(xlabel=\"x\", ylabel=\"y\", title=\"Steps stopped by a Polyline\")\n", + "ax.set_aspect(\"equal\")\n", + "ax.legend(loc=\"lower center\")\n", + "plt.show()" + ] + } + ], + "metadata": { + "docs_order": 115, + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/objects/rectangle.ipynb b/tutorials/objects/rectangle.ipynb new file mode 100644 index 0000000..ee6c143 --- /dev/null +++ b/tutorials/objects/rectangle.ipynb @@ -0,0 +1,91 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Rectangle\n", + "\n", + "An axis-aligned box in 2D, optionally with rounded corners: plates, electrodes and walls.\n", + "\n", + "`Rectangle(midpoint, side_lengths, fillet=r)` rounds the four corners with radius `r`, up to\n", + "half the shorter side, where it becomes a stadium. Rounded corners keep the surface field of an\n", + "electrode bounded. With or without the fillet, the level set is the exact signed distance." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "\n", + "from simgeometry import Rectangle\n", + "\n", + "plates = {\n", + " \"sharp\": Rectangle(midpoint=(0.0, 0.0), side_lengths=(2.0, 0.6)),\n", + " \"fillet 0.15\": Rectangle(midpoint=(0.0, 0.0), side_lengths=(2.0, 0.6), fillet=0.15),\n", + " \"stadium\": Rectangle(midpoint=(0.0, 0.0), side_lengths=(2.0, 0.6), fillet=0.3),\n", + "}\n", + "corner = (0.97, 0.27)\n", + "for name, plate in plates.items():\n", + " print(f\"{name:12s} near the corner inside: {plate.contains(corner)!s:5s}\", end=\" \")\n", + " print(f\"distance from (1.2, 0.5): {plate.signed_distance((1.2, 0.5)):.4f}\")\n", + "assert np.isclose(plates[\"sharp\"].signed_distance((1.2, 0.5)), np.hypot(0.2, 0.2))" + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "## Outlines and offset contours\n", + "\n", + "Each rectangle with its outline and the contours of its level set at 0, 0.15 and 0.3 outside:\n", + "the offset contours of an exact distance are rounded even around sharp corners." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "x = np.linspace(-1.6, 1.6, 321)\n", + "y = np.linspace(-0.9, 0.9, 181)\n", + "X, Y = np.meshgrid(x, y, indexing=\"ij\")\n", + "points = np.stack((X.ravel(), Y.ravel()))\n", + "\n", + "fig, axes = plt.subplots(3, 1, figsize=(6, 6.5), layout=\"constrained\", sharex=True)\n", + "for ax, (name, plate) in zip(axes, plates.items(), strict=True):\n", + " distance = plate.signed_distance(points).reshape(X.shape)\n", + " ax.contour(X, Y, distance, levels=[0.15, 0.3], colors=\"0.6\", linewidths=0.8)\n", + " (loop,) = plate.outlines(max_spacing=0.02)\n", + " ax.fill(*loop, color=\"tab:blue\", alpha=0.4)\n", + " ax.plot(*np.c_[loop, loop[:, :1]], color=\"tab:blue\")\n", + " ax.set(ylabel=\"y\", title=name)\n", + " ax.set_aspect(\"equal\")\n", + "axes[-1].set_xlabel(\"x\")\n", + "plt.show()" + ] + } + ], + "metadata": { + "docs_order": 112, + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/objects/slanted_tube.ipynb b/tutorials/objects/slanted_tube.ipynb new file mode 100644 index 0000000..3b21362 --- /dev/null +++ b/tutorials/objects/slanted_tube.ipynb @@ -0,0 +1,95 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# SlantedTube\n", + "\n", + "A tube or rod whose far end is cut by a slanted plane, like the mouth of an oven nozzle cut\n", + "at 45°.\n", + "\n", + "`SlantedTube(start, end, inner_radius, outer_radius, cut_normal)` has a flat face at `start`\n", + "and a cut through `end` with the outward normal `cut_normal`; `inner_radius=0` gives a solid\n", + "rod. Its level set is a bound on the distance, exact on the faces, and its crossings are\n", + "analytic." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "\n", + "from simgeometry import SlantedTube\n", + "\n", + "c = np.cos(np.pi / 4)\n", + "nozzle = SlantedTube(\n", + " (0.0, 0.0, 0.0), (0.0, 0.0, 1.0), 0.15, 0.2, cut_normal=(0.0, -c, c)\n", + ")\n", + "print(f\"cut angle: {nozzle.cut_angle:.0f} deg, volume: {nozzle.volume():.4f}\")\n", + "print(\"wall at y = +0.175, z = 1.1 (long side):\", nozzle.contains((0.0, 0.175, 1.1)))\n", + "print(\"wall at y = -0.175, z = 1.1 (short side):\", nozzle.contains((0.0, -0.175, 1.1)))\n", + "assert nozzle.contains((0.0, 0.175, 1.1)) and not nozzle.contains((0.0, -0.175, 1.1))" + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "## The section through the cut\n", + "\n", + "The section `x = 0` shows the wall on both sides of the bore and the 45° mouth." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "ax = nozzle.show(x=0.0, resolution=200, display=False)\n", + "ax.set_title(\"The section x = 0 of a SlantedTube\")\n", + "plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4", + "metadata": {}, + "outputs": [], + "source": [ + "import plasma_plots\n", + "import pyvista as pv\n", + "\n", + "pv.OFF_SCREEN = True # render without a window, e.g. on a docs server\n", + "\n", + "plotter = nozzle.show_3d(\n", + " backend=\"pyvista\", display=False, resolution=72, opacity=0.6, show_bounds=False\n", + ")\n", + "plasma_plots.scene(plotter, alt=\"A tube whose end is cut at 45 degrees\")" + ] + } + ], + "metadata": { + "docs_order": 127, + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/objects/sphere.ipynb b/tutorials/objects/sphere.ipynb new file mode 100644 index 0000000..daa30be --- /dev/null +++ b/tutorials/objects/sphere.ipynb @@ -0,0 +1,109 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Sphere\n", + "\n", + "A ball of a given radius: a probe tip, a dust grain or a spherical electrode.\n", + "\n", + "`Sphere(midpoint, radius)` has an exact level set, `|p - midpoint| - radius`, and analytic\n", + "crossings." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "\n", + "from simgeometry import Grid, Sphere\n", + "\n", + "ball = Sphere(midpoint=(0.0, 0.0, 0.0), radius=1.0)\n", + "print(\"volume:\", ball.volume())\n", + "print(\"distance from (2, 0, 0):\", ball.signed_distance((2.0, 0.0, 0.0)))\n", + "assert np.isclose(ball.volume(), 4 / 3 * np.pi)" + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "## Node mask\n", + "\n", + "The nodes inside the ball, times the cell volume, approach its volume as the grid is refined." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "for n in (11, 21, 41, 81):\n", + " grid = Grid(3, (-1.0, -1.0, -1.0), (1.0, 1.0, 1.0), (n, n, n))\n", + " cell = np.prod([2.0 / (n - 1)] * 3)\n", + " estimate = ball.node_mask(grid).sum() * cell\n", + " print(\n", + " f\"{n:3d}^3 nodes: {estimate:.4f} (error {estimate / ball.volume() - 1:+.2%})\"\n", + " )" + ] + }, + { + "cell_type": "markdown", + "id": "4", + "metadata": {}, + "source": [ + "## In 3D\n", + "\n", + "`show_3d(backend=\"pyvista\")` draws the surface with plasma-plots; `points=` adds labelled point\n", + "sets, here the nodes of a coarse grid inside the ball." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5", + "metadata": {}, + "outputs": [], + "source": [ + "import plasma_plots\n", + "import pyvista as pv\n", + "\n", + "pv.OFF_SCREEN = True # render without a window, e.g. on a docs server\n", + "\n", + "grid = Grid(3, (-1.0, -1.0, -1.0), (1.0, 1.0, 1.0), (13, 11, 9))\n", + "inside = grid.node_points()[:, ball.node_mask(grid).ravel()]\n", + "plotter = ball.show_3d(\n", + " backend=\"pyvista\",\n", + " display=False,\n", + " resolution=64,\n", + " opacity=0.3,\n", + " show_bounds=False,\n", + " points={\"nodes inside\": inside},\n", + ")\n", + "plasma_plots.scene(plotter, alt=\"A translucent sphere with the grid nodes inside it\")" + ] + } + ], + "metadata": { + "docs_order": 121, + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/objects/torus.ipynb b/tutorials/objects/torus.ipynb new file mode 100644 index 0000000..a6afcdc --- /dev/null +++ b/tutorials/objects/torus.ipynb @@ -0,0 +1,94 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Torus\n", + "\n", + "A solid torus: the points within `minor_radius` of a circle of `major_radius`, the shape of a\n", + "tokamak or stellarator vessel, or of a ring-shaped wire.\n", + "\n", + "`Torus(midpoint, major_radius, minor_radius, axis=...)` puts the circle in the plane normal to\n", + "`axis`. The level set is the exact signed distance." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "\n", + "from simgeometry import Torus\n", + "\n", + "vessel = Torus(midpoint=(0.0, 0.0, 0.0), major_radius=1.0, minor_radius=0.3)\n", + "print(\"volume:\", round(vessel.volume(), 4))\n", + "print(\"hole empty:\", not vessel.contains((0.0, 0.0, 0.0)))\n", + "print(\"distance from the centre:\", vessel.signed_distance((0.0, 0.0, 0.0)))\n", + "assert np.isclose(vessel.volume(), 2 * np.pi**2 * 1.0 * 0.3**2)" + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "## Sections\n", + "\n", + "`show(y=0)` draws the section through the axis: the two discs of the tube." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "\n", + "ax = vessel.show(y=0.0, resolution=200, display=False)\n", + "ax.set_title(\"The section y = 0 of a Torus\")\n", + "plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4", + "metadata": {}, + "outputs": [], + "source": [ + "import plasma_plots\n", + "import pyvista as pv\n", + "\n", + "pv.OFF_SCREEN = True # render without a window, e.g. on a docs server\n", + "\n", + "tilted = Torus(\n", + " midpoint=(0.0, 0.0, 0.0), major_radius=1.0, minor_radius=0.3, axis=(1.0, 0.0, 1.0)\n", + ")\n", + "plotter = tilted.show_3d(\n", + " backend=\"pyvista\", display=False, resolution=64, show_bounds=False\n", + ")\n", + "plasma_plots.scene(plotter, alt=\"A torus with its axis tilted by 45 degrees\")" + ] + } + ], + "metadata": { + "docs_order": 126, + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/objects/triangle.ipynb b/tutorials/objects/triangle.ipynb new file mode 100644 index 0000000..31618fe --- /dev/null +++ b/tutorials/objects/triangle.ipynb @@ -0,0 +1,91 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Triangle\n", + "\n", + "A triangle from three vertices, in either orientation.\n", + "\n", + "`Triangle(vertices)` has an exact level set, the signed distance to its edges, and an exact\n", + "outline: its three corners, counter-clockwise." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "\n", + "from simgeometry import Grid, Triangle\n", + "\n", + "wedge = Triangle([(0.0, 0.0), (1.0, 0.0), (0.3, 0.8)])\n", + "print(\"centroid inside:\", wedge.contains((0.43, 0.27)))\n", + "print(\"distance from (0.5, -0.2):\", wedge.signed_distance((0.5, -0.2)))\n", + "assert np.isclose(wedge.signed_distance((0.5, -0.2)), 0.2)" + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "## Nodes, outline and normals\n", + "\n", + "The grid nodes inside, the outline loop and the outward normals at points on the boundary from\n", + "`surface_normal`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "grid = Grid(2, (-0.1, -0.1), (1.1, 0.9), (25, 21))\n", + "mask = wedge.node_mask(grid).ravel()\n", + "nodes = grid.node_points()\n", + "(loop,) = wedge.outlines()\n", + "panels = wedge.boundary_panels(max_spacing=0.15)\n", + "normals = wedge.surface_normal(panels.midpoint)\n", + "\n", + "fig, ax = plt.subplots(figsize=(5.5, 4.5), layout=\"constrained\")\n", + "ax.scatter(*nodes[:, ~mask], s=6, color=\"0.75\")\n", + "ax.scatter(*nodes[:, mask], s=8, color=\"tab:blue\", label=\"nodes inside\")\n", + "ax.plot(*np.c_[loop, loop[:, :1]], color=\"k\")\n", + "ax.quiver(\n", + " *panels.midpoint,\n", + " *normals,\n", + " color=\"tab:red\",\n", + " scale=12,\n", + " width=0.005,\n", + " label=\"outward normals\",\n", + ")\n", + "ax.set(xlabel=\"x\", ylabel=\"y\", title=\"A Triangle on a grid\")\n", + "ax.set_aspect(\"equal\")\n", + "ax.legend(loc=\"upper right\")\n", + "plt.show()" + ] + } + ], + "metadata": { + "docs_order": 113, + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/objects/tube.ipynb b/tutorials/objects/tube.ipynb new file mode 100644 index 0000000..7bcc588 --- /dev/null +++ b/tutorials/objects/tube.ipynb @@ -0,0 +1,90 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Tube\n", + "\n", + "A hollow cylinder between two radii: a ring electrode when short, a drift tube or a chamber\n", + "wall when long.\n", + "\n", + "`Tube(start, end, inner_radius, outer_radius)` has an empty bore and an exact level set;\n", + "`fillet` rounds its four circular edges." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "\n", + "from simgeometry import Cuboid, PolarRepeat, Tube\n", + "\n", + "ring = Tube(\n", + " start=(0.0, 0.0, -0.2),\n", + " end=(0.0, 0.0, 0.2),\n", + " inner_radius=1.0,\n", + " outer_radius=1.5,\n", + " fillet=0.05,\n", + ")\n", + "print(\"bore empty:\", not ring.contains((0.0, 0.0, 0.0)))\n", + "print(\"in the wall:\", ring.contains((1.25, 0.0, 0.0)))\n", + "print(\"distance from the centre:\", ring.signed_distance((0.0, 0.0, 0.0)))\n", + "assert np.isclose(ring.signed_distance((0.0, 0.0, 0.0)), 1.0)" + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "## A slotted shield\n", + "\n", + "`PolarRepeat(shape, count)` repeats a shape about an axis at the cost of one copy. Cutting\n", + "twelve slots out of a long tube gives a Faraday shield." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "import plasma_plots\n", + "import pyvista as pv\n", + "\n", + "pv.OFF_SCREEN = True # render without a window, e.g. on a docs server\n", + "\n", + "wall = Tube(\n", + " start=(0.0, 0.0, -1.0), end=(0.0, 0.0, 1.0), inner_radius=0.9, outer_radius=1.0\n", + ")\n", + "slot = Cuboid(midpoint=(0.95, 0.0, 0.0), side_lengths=(0.3, 0.12, 1.5))\n", + "shield = wall - PolarRepeat(slot, 12)\n", + "assert not shield.contains((0.95, 0.0, 0.0)) and shield.contains((0.95, 0.0, 0.9))\n", + "plotter = shield.show_3d(\n", + " backend=\"pyvista\", display=False, resolution=96, show_bounds=False\n", + ")\n", + "plasma_plots.scene(plotter, alt=\"A tube with twelve axial slots cut into its wall\")" + ] + } + ], + "metadata": { + "docs_order": 124, + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From 5b9aa82ca35a69b6f4669d9fd0876bfe61076fc6 Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 7 Nov 2026 16:58:10 +0100 Subject: [PATCH 34/36] Release 0.2.0: Helix, PolarRepeat, method cards, section outlines, level sets; cunumpy 0.7.1. (#48) --- CHANGELOG.md | 3 +++ pyproject.toml | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c7c76ae..1c1ccf6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +## [0.2.0] - 2026-11-07 + ### Added - One example page per object of `simgeometry.objects`, from `Line` to `Helix` @@ -142,6 +144,7 @@ format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed +- Requires cunumpy 0.7.1 or newer, for its `KernelPackage`. - The kernels are built with cunumpy's `KernelPackage` (`simgeometry.kernels.KERNEL_PACKAGE`): `kernels/_build.py`, `kernels/_metal.py` and `kernels/compile.py` are gone, and the build cache is still `SIMGEOMETRY_KERNEL_CACHE`. diff --git a/pyproject.toml b/pyproject.toml index 40c899f..c250441 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ requires = [ "setuptools", "wheel" ] [project] name = "simgeometry" -version = "0.1.1" +version = "0.2.0" description = "Shapes, grids, and simulation domains for numerical simulations" readme = "README.md" keywords = [ "geometry", "grids", "simulation", "mpi" ] @@ -20,7 +20,7 @@ classifiers = [ "Programming Language :: Python :: 3.14", ] dependencies = [ - "cunumpy>=0.6.2", + "cunumpy>=0.7.1", "maybempi>=0.1", "methodcard", "mpiarray>=0.1.0", From 1d0f0a79c8d2874bd4ea5642a8612ffda09b3c59 Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 7 Nov 2026 17:00:37 +0100 Subject: [PATCH 35/36] Require cunumpy 0.7.2, the release with KernelPackage. (#50) --- CHANGELOG.md | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c1ccf6..e65ede9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -144,7 +144,7 @@ format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed -- Requires cunumpy 0.7.1 or newer, for its `KernelPackage`. +- Requires cunumpy 0.7.2 or newer, for its `KernelPackage`. - The kernels are built with cunumpy's `KernelPackage` (`simgeometry.kernels.KERNEL_PACKAGE`): `kernels/_build.py`, `kernels/_metal.py` and `kernels/compile.py` are gone, and the build cache is still `SIMGEOMETRY_KERNEL_CACHE`. diff --git a/pyproject.toml b/pyproject.toml index c250441..7cc9a9a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ classifiers = [ "Programming Language :: Python :: 3.14", ] dependencies = [ - "cunumpy>=0.7.1", + "cunumpy>=0.7.2", "maybempi>=0.1", "methodcard", "mpiarray>=0.1.0", From c5fd403da74e8e888da4677c0c4489ca2b50eefa Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 7 Nov 2026 17:14:28 +0100 Subject: [PATCH 36/36] Release 0.2.1: the cunumpy lower bound is 0.7.2. (#51) --- CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e65ede9..b680dab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +## [0.2.1] - 2026-11-07 + +### Fixed + +- The cunumpy lower bound is 0.7.2, the first release with `KernelPackage`; 0.2.0 declared + 0.7.1 and did not import with it. + ## [0.2.0] - 2026-11-07 ### Added diff --git a/pyproject.toml b/pyproject.toml index 7cc9a9a..a85bae5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ requires = [ "setuptools", "wheel" ] [project] name = "simgeometry" -version = "0.2.0" +version = "0.2.1" description = "Shapes, grids, and simulation domains for numerical simulations" readme = "README.md" keywords = [ "geometry", "grids", "simulation", "mpi" ]