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 = [