diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..9cbf559
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,2 @@
+* text=auto eol=lf
+*.h5 binary
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
new file mode 100644
index 0000000..3458c74
--- /dev/null
+++ b/.github/workflows/docs.yml
@@ -0,0 +1,62 @@
+name: Documentation
+
+on:
+ pull_request:
+ push:
+ branches:
+ - main
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: pages
+ cancel-in-progress: true
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check out the repository
+ uses: actions/checkout@v6
+
+ - name: Set up Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: "3.12"
+ cache: pip
+
+ - name: Install the package and documentation tools
+ run: python -m pip install -e '.[docs,test]'
+
+ - name: Test documentation examples
+ run: python -m pytest tests/test_docs_examples.py --no-cov
+
+ - name: Build documentation
+ run: mkdocs build --strict
+
+ - name: Configure GitHub Pages
+ if: github.event_name == 'workflow_dispatch' || github.event_name == 'push'
+ uses: actions/configure-pages@v5
+
+ - name: Upload GitHub Pages artifact
+ if: github.event_name == 'workflow_dispatch' || github.event_name == 'push'
+ uses: actions/upload-pages-artifact@v4
+ with:
+ path: site
+
+ deploy:
+ if: github.event_name == 'workflow_dispatch' || github.event_name == 'push'
+ needs: build
+ runs-on: ubuntu-latest
+ permissions:
+ pages: write
+ id-token: write
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ steps:
+ - name: Deploy to GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/.gitignore b/.gitignore
index 1ad2444..6b61d7d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,15 +1,19 @@
*.swp
.DS_Store
venv
+.venv/
__pycache__
*.egg-info/
htmlcov/
.coverage
.pytest_cache/
-
-# Dune/OCaml Project Ignore File
-_build/
-.dune/
-.merlin
-.ocaml-history
-.cmi
+target/
+build/
+dist/
+site/
+.eggs/
+.mypy_cache/
+.pytype/
+.tox/
+.nox/
+.ruff_cache/
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 425503a..f1a0923 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -1,6 +1,7 @@
{
- "ocaml.sandbox": {
- "kind": "opam",
- "switch": "4.14.2"
- }
+ "python.analysis.diagnosticMode": "workspace",
+ "python.analysis.extraPaths": [
+ "${workspaceFolder}/src"
+ ],
+ "python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python"
}
diff --git a/PARITY_COMPLETION.md b/PARITY_COMPLETION.md
deleted file mode 100644
index ea928d8..0000000
--- a/PARITY_COMPLETION.md
+++ /dev/null
@@ -1,53 +0,0 @@
-# NMesh Section 4 Parity Completion
-
-**Status:** exact standalone behavioral parity required
-**Branch:** `nmesh-section4`
-**Reviewed:** 2026-05-04
-**Reference sources:** `nmag-src/src/mesh.ml`, `nmag-src/src/snippets.ml`, `nmag-src/interface/nmeshlib/lib1.py`
-
-## Position
-
-The Python 3 `nmesh` port must implement the full behavior of the original OCaml/Python2 mesher without requiring OCaml at runtime. Missing behavior is implementation work, not an accepted end state.
-
-The target is exact behavioral parity, not approximate parity. Topology decisions, region assignment, point-state transitions, add/delete scheduling, boundary recovery, periodic grouping, and file-visible mesh metadata must match the legacy implementation for the same inputs. Numeric tolerances are only allowed for floating-point coordinate comparisons, and each tolerance must be tied to a specific floating-point or triangulator tie-break reason.
-
-The port may use Python, NumPy, SciPy, Numba, Rust, C++, or C as needed. The public Python API should remain stable, and compiled helpers should be isolated behind that API.
-
-## Completed In This Section 4 Pass
-
-- Removed the Python-side 60-step cap; configured `controller_step_limit_max` is honored.
-- Implemented legacy controller cadence: square-number add/delete checks, relaxed add/delete thresholds, topology-threshold retriangulation, minimum-step convergence, force-equilibrium stopping, and 50-step post-change settling.
-- Aligned force metrics with legacy behavior: density-scaled movement, centroid density for simplex forces, tangent-projected boundary effective force, corner suppression, and OCaml-style Voronoi density correction.
-- Classified force-time simplices with the same centroid, near-vertex probe, `Boundary` state, and raw volume-order ratio rules used by the legacy relevant/irrelevant topology split.
-- Restricted irrelevant-element forces to mobile nodes, matching the active legacy path.
-- Replaced deterministic grid seeding with the legacy random density estimator, D-lattice sphere-packing node estimate, rejection sampler, and RNG consumption order.
-- Replaced midpoint point insertion with Gaussian insertion around the source point using local effective rod length.
-- Ported active boundary recovery behavior from `mirror_simplices`: 2D mirrored points and 3D boundary-edge midpoint prevention points.
-- Seeded paired fixed points on periodic outer-box faces, fixed only exact periodic boundary nodes, and canonicalized multi-axis periodic equivalence groups.
-- Added final high-density moving-point cleanup, outside dynamic point cleanup, and final boundary snapping before final assembly.
-- Normalized final simplex orientation to the legacy positive-volume convention.
-- Fixed flat-boundary filtering so it follows the legacy `Boundary` state and raw volume-order ratio rules instead of deleting all geometrically boundary-adjacent multi-region simplices.
-- Changed unsupported density snippets to fail loudly rather than silently falling back to density `1.0`.
-- Added parity coverage for coarse adjacent pieces, concave difference domains, multi-axis periodic groups, final cleanup, and force-time relevant/irrelevant classification.
-- Added canonical mesh signature helpers so parity fixtures compare topology and metadata exactly, with coordinate tolerance explicitly opt-in.
-- Added a parity comparison runner (`tools/nmesh_parity_compare.py`) that generates modern scenario meshes and compares them with legacy `.nmesh` artifacts or a configured legacy runner command.
-- Tightened runtime matching to avoid mesh-size-scaled boundary drift tolerance and rounded non-periodic periodic-group coordinates.
-
-## Validation Gate
-
-These are verification tasks, not accepted limitations:
-
-- Build reference fixtures from legacy examples and compare canonicalized topology, regions, surfaces, links, periodic groups, controller decisions, and mesh metadata exactly using `nmesh.mesher.parity`.
-- Compare coordinates with the narrowest practical floating-point tolerance, documenting each tolerance and why exact binary equality is not the right assertion.
-- Run the modernization scenario matrix through `tools/nmesh_parity_compare.py` and record metric-level acceptance thresholds for physically equivalent meshes when exact topology is not expected.
-- Complete reference validation for periodic outer-box workflows on multiple periodic directions and complex periodic entities.
-- Expand multi-piece/interface parity fixtures to include more interface-prevention cases.
-- Profile large 3D meshes with legacy defaults and move any proven hot path to a compiled Rust, C++, or C helper behind the Python API.
-
-## Sign-Off Criteria
-
-- Legacy reference examples produce exact canonical mesh topology and metadata parity.
-- Periodic, hint-driven, concave, and multi-piece examples match legacy behavior exactly, apart from explicitly documented floating-point coordinate tolerance.
-- Add/delete, retriangulation, boundary recovery, and final cleanup decisions are covered by parity tests.
-- Unsupported density syntax never changes mesh density silently.
-- The full project test suite and parity fixtures pass in the project venv.
diff --git a/README.md b/README.md
index 624e7f4..7e97712 100644
--- a/README.md
+++ b/README.md
@@ -1,20 +1,119 @@
-# NMAG Python 3
-
-This is an in-progress port of [nmag](https://github.com/nmag-project/nmag-src) that updates old dependencies and packages nmag as a standalone python 3 module.
-
-The Python 3 port is intended to be standalone and must not require OCaml at runtime. See [PARITY_COMPLETION.md](PARITY_COMPLETION.md) for the Section 4 parity record and validation gate.
-
-### Installation
-* Create a virtual environment: `python -m venv venv`
-* Activate the virtual environment:
- * On Windows: `venv\Scripts\activate`
- * On macOS/Linux: `source venv/bin/activate`
-* To initialize the project and install dependencies: `pip install -e .`
-
-### Testing
-* To install the optional test dependencies run `pip install -e ".[test]"`
-* Once the project has been initialized simply run `pytest` to run your tests
-* To generate modern nmesh parity scenario outputs run `python tools/nmesh_parity_compare.py --output-dir parity/new`
-* To compare against legacy `.nmesh` artifacts run `python tools/nmesh_parity_compare.py --legacy-dir parity/legacy`
-* To view coverage open `htmlcov/index.html` in your browser
-* [Specifying what tests to run](https://docs.pytest.org/en/latest/how-to/usage.html#specifying-which-tests-to-run)
+# Nmag for Python 3
+
+`nmag-python-3` is a standalone Python implementation of the Nmag finite-element
+micromagnetic simulation interface. It supports tetrahedral meshes, magnetic
+materials, static effective fields, adaptive LLG dynamics and relaxation,
+NDT/HDF5 output, field probing, and native restart checkpoints.
+
+The human documentation is available at
+****. Start with the
+[installation guide](https://trimagnetix.github.io/nmag-python-3/getting-started/installation/)
+and [quickstart](https://trimagnetix.github.io/nmag-python-3/getting-started/quickstart/).
+
+## Requirements
+
+- Linux
+- Python 3.10 or newer
+- A C compiler for Python dependencies without a platform wheel
+- Rust and the matching Python development library only for optional native
+ acceleration
+
+## Installation
+
+From a clone of this repository:
+
+```bash
+./scripts/setup.sh
+```
+
+The setup script creates or reuses `.venv`, offers to build the optional Rust
+accelerator, and runs the standard checks. Run a simulation with:
+
+```bash
+.venv/bin/python {{simulation_file_name.py}}
+```
+
+## Minimal simulation
+
+```python
+import nmag
+import nmesh
+
+mesh = nmesh.mesh_from_points_and_simplices(
+ points=[[0, 0, 0], [1, 0, 0], [0, 1, 0], [0, 0, 1]],
+ simplices_indices=[[0, 1, 2, 3]],
+ simplices_regions=[1],
+)
+mesh.save("sample.nmesh.h5")
+
+material = nmag.MagMaterial(
+ name="Py",
+ Ms=nmag.SI(1e6, "A/m"),
+ exchange_coupling=nmag.SI(13e-12, "J/m"),
+)
+
+simulation = nmag.Simulation(name="sample")
+simulation.load_mesh(
+ "sample.nmesh.h5",
+ [("magnetic", material)],
+ unit_length=nmag.SI(1e-9, "m"),
+)
+simulation.set_m([1, 0, 0])
+simulation.set_H_ext([0, 0, 0], nmag.SI("A/m"))
+simulation.save_data(fields="all")
+```
+
+For a tested, rerunnable version and a canonical sphere example, see the
+[getting-started guide](https://trimagnetix.github.io/nmag-python-3/getting-started/quickstart/).
+
+## Status
+
+The supported solver covers 3D tetrahedral demagnetization, exchange, uniform
+applied fields, uniaxial and cubic anisotropy, custom polynomial anisotropy,
+pinning, Zhang-Li current torque, adaptive dynamics and relaxation, checkpoints,
+and resource-aware demagnetization storage.
+
+Thermal dynamics, Slonczewski torque, periodic micromagnetic boundaries,
+material-specific magnetization at incompatible shared nodes, local
+inter-material coupling, and full legacy hysteresis compatibility are not yet
+supported. Validate new geometries and material models before relying on
+production results. The complete and current list is maintained in the
+[supported-scope guide](https://trimagnetix.github.io/nmag-python-3/guides/supported-scope/).
+
+## Configuration and acceleration
+
+Each simulation accepts an immutable `NmagConfig`. `accelerator="auto"` uses
+installed Rust kernels where helpful, `"off"` keeps Python/Numba paths, and
+`"rust"` requires the extension. Advanced callers can use
+`accelerator_overrides={nmag.RustKernel.LLG: "rust"}`.
+
+For a simulation created without an explicit configuration,
+`NMAG_ACCELERATOR=auto|off|rust` selects the process default. Prefer
+`NmagConfig` in reusable programs. See the
+[configuration guide](https://trimagnetix.github.io/nmag-python-3/guides/configuration/)
+for storage, memory, and integrator choices.
+
+## Development checks
+
+```bash
+./scripts/verify.sh
+./scripts/verify.sh --rust
+```
+
+Build the documentation locally with:
+
+```bash
+.venv/bin/python -m pip install -e '.[docs]'
+.venv/bin/mkdocs build --strict
+```
+
+## Project lineage
+
+This project modernizes the original
+[nmag-project/nmag-src](https://github.com/nmag-project/nmag-src). The
+[historical Nmag 0.2 manual](https://nmag.readthedocs.io/en/latest/) remains a
+useful background reference, but its Python 2 runtime and some of its features
+do not describe this rewrite.
+
+Nmag for Python 3 is distributed under the GNU General Public License version 2
+or, at your option, any later version. See [LICENSE](LICENSE).
diff --git a/docs/assets/sphere1.nmesh.h5 b/docs/assets/sphere1.nmesh.h5
new file mode 100644
index 0000000..713b121
Binary files /dev/null and b/docs/assets/sphere1.nmesh.h5 differ
diff --git a/docs/examples/quickstart.py b/docs/examples/quickstart.py
new file mode 100644
index 0000000..a3737a5
--- /dev/null
+++ b/docs/examples/quickstart.py
@@ -0,0 +1,46 @@
+"""Small, self-contained Nmag simulation used by the quickstart guide."""
+
+from pathlib import Path
+
+import nmag
+import nmesh
+
+
+output_directory = Path("results")
+output_directory.mkdir(exist_ok=True)
+
+mesh = nmesh.mesh_from_points_and_simplices(
+ points=[
+ [0.0, 0.0, 0.0],
+ [1.0, 0.0, 0.0],
+ [0.0, 1.0, 0.0],
+ [0.0, 0.0, 1.0],
+ ],
+ simplices_indices=[[0, 1, 2, 3]],
+ simplices_regions=[1],
+)
+mesh_path = output_directory / "quickstart.nmesh.h5"
+mesh.save(mesh_path)
+
+permalloy = nmag.MagMaterial(
+ name="Py",
+ Ms=nmag.SI(1e6, "A/m"),
+ exchange_coupling=nmag.SI(13e-12, "J/m"),
+)
+
+config = nmag.NmagConfig(
+ output_directory=output_directory,
+ output_policy="replace",
+)
+simulation = nmag.Simulation(name="quickstart", config=config)
+simulation.load_mesh(
+ str(mesh_path),
+ [("magnetic", permalloy)],
+ unit_length=nmag.SI(1e-9, "m"),
+)
+simulation.set_m([1.0, 0.0, 0.0])
+simulation.set_H_ext([0.0, 0.0, 0.0], nmag.SI("A/m"))
+simulation.save_data(fields="all")
+
+print("Average demagnetization field:", simulation.get_subfield_average("H_demag"))
+print("Results written to:", output_directory.resolve())
diff --git a/docs/examples/sphere_demag.py b/docs/examples/sphere_demag.py
new file mode 100644
index 0000000..ad0691c
--- /dev/null
+++ b/docs/examples/sphere_demag.py
@@ -0,0 +1,41 @@
+"""Compute and sample the demagnetization field of a uniformly magnetized sphere."""
+
+import sys
+from pathlib import Path
+
+import nmag
+
+
+mesh_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("sphere1.nmesh.h5")
+if not mesh_path.is_file():
+ raise SystemExit(f"Mesh not found: {mesh_path}")
+
+output_directory = Path("results")
+output_directory.mkdir(exist_ok=True)
+
+permalloy = nmag.MagMaterial(
+ name="Py",
+ Ms=nmag.SI(1e6, "A/m"),
+ exchange_coupling=nmag.SI(13e-12, "J/m"),
+)
+
+config = nmag.NmagConfig(
+ output_directory=output_directory,
+ output_policy="replace",
+)
+simulation = nmag.Simulation(name="sphere1", config=config)
+simulation.load_mesh(
+ str(mesh_path),
+ [("sphere", permalloy)],
+ unit_length=nmag.SI(1e-9, "m"),
+)
+simulation.set_m([1.0, 0.0, 0.0])
+simulation.set_H_ext([0.0, 0.0, 0.0], nmag.SI("A/m"))
+simulation.save_data(fields="all")
+
+field_at_origin = simulation.probe_subfield_siv("H_demag", [0.0, 0.0, 0.0])
+expected_x = -permalloy.Ms.value / 3.0
+
+print("H_demag at the origin:", field_at_origin, "A/m")
+print("Analytic x component for a sphere:", expected_x, "A/m")
+print("Average magnetization:", simulation.get_subfield_average("m"))
diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md
new file mode 100644
index 0000000..a27a2b9
--- /dev/null
+++ b/docs/getting-started/installation.md
@@ -0,0 +1,87 @@
+# Installation
+
+## Requirements
+
+Nmag currently targets Linux and Python 3.10 or newer. A normal installation
+needs:
+
+- Python, `venv`, and `pip`;
+- a C compiler for dependencies without a wheel for your platform; and
+- Git to work from the source repository.
+
+On Ubuntu or Debian:
+
+```bash
+sudo apt update
+sudo apt install python3 python3-venv python3-pip build-essential
+```
+
+## Recommended setup
+
+Clone the repository and use its setup script:
+
+```bash
+git clone https://github.com/TriMagnetix/nmag-python-3.git
+cd nmag-python-3
+./scripts/setup.sh
+```
+
+The script creates or reuses `.venv`, installs Nmag in editable mode, offers to
+build the optional Rust accelerator, and runs the standard checks. It invokes
+the virtual environment explicitly, so activation is not required.
+
+Run a simulation with:
+
+```bash
+.venv/bin/python simulation.py
+```
+
+You may activate the environment if that is more convenient:
+
+```bash
+source .venv/bin/activate
+python simulation.py
+```
+
+## Manual setup
+
+For CI or an existing environment:
+
+```bash
+python3 -m venv .venv
+.venv/bin/python -m pip install --upgrade pip
+.venv/bin/python -m pip install -e .
+```
+
+Normal simulations do not require Rust. To build the optional accelerator,
+install Rust with [rustup](https://rustup.rs/), install the `rust` extra, and run
+the build script:
+
+```bash
+.venv/bin/python -m pip install -e '.[rust]'
+./scripts/build-accelerator.sh --release
+```
+
+Python 3.12 accelerator builds on Ubuntu or Debian also need
+`libpython3.12-dev`. Substitute the development package matching your Python
+version.
+
+## Verify the installation
+
+```bash
+.venv/bin/python -c "import nmag; print(nmag.SI(1, 'A/m'))"
+```
+
+Then run the [quickstart](quickstart.md). If the optional accelerator was built,
+`nmag.parallel_runtime_info()` reports its effective thread configuration.
+
+## Building this documentation
+
+Documentation tools are isolated in a separate extra:
+
+```bash
+.venv/bin/python -m pip install -e '.[docs]'
+.venv/bin/mkdocs serve
+```
+
+Use `mkdocs build --strict` before submitting documentation changes.
diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md
new file mode 100644
index 0000000..85b4c71
--- /dev/null
+++ b/docs/getting-started/quickstart.md
@@ -0,0 +1,34 @@
+# Quickstart
+
+This example constructs one tetrahedron, assigns a magnetic material, computes
+the static fields, and writes both averaged and spatial output. It is deliberately
+small: its purpose is to verify the complete software path, not to represent a
+well-resolved physical body.
+
+[Download `quickstart.py`](../examples/quickstart.py){ .md-button }
+
+```python title="quickstart.py"
+--8<-- "docs/examples/quickstart.py"
+```
+
+Run it from an installed checkout:
+
+```bash
+.venv/bin/python quickstart.py
+```
+
+The script creates a `results/` directory containing:
+
+- `quickstart.nmesh.h5`, the generated mesh;
+- `quickstart_dat.ndt`, a tab-separated row of averaged quantities; and
+- `quickstart_dat.h5`, spatial field arrays and mesh metadata.
+
+The mesh coordinates are dimensionless until `load_mesh` applies
+`unit_length`. Here one coordinate unit is one nanometre. Material parameters
+and fields use explicit [SI quantities](../guides/materials.md#si-quantities).
+
+`output_policy="replace"` makes this learning example safe to rerun. For normal
+work, the default `"error"` policy protects existing result files; use
+checkpoints when a run must continue from earlier state.
+
+Next, use a resolved geometry in the [sphere tutorial](sphere.md).
diff --git a/docs/getting-started/sphere.md b/docs/getting-started/sphere.md
new file mode 100644
index 0000000..c38a542
--- /dev/null
+++ b/docs/getting-started/sphere.md
@@ -0,0 +1,46 @@
+# Uniformly magnetized sphere
+
+A uniformly magnetized sphere has an analytic demagnetization field at its
+centre: the component parallel to the magnetization is approximately
+`-Ms / 3`. This classic Nmag example therefore gives a compact physical check of
+mesh loading, material assignment, the FEM/BEM demagnetization calculation,
+spatial output, and field probing.
+
+Download both files into the same directory:
+
+[Download the script](../examples/sphere_demag.py){ .md-button .md-button--primary }
+[Download the mesh](../assets/sphere1.nmesh.h5){ .md-button }
+
+```python title="sphere_demag.py"
+--8<-- "docs/examples/sphere_demag.py"
+```
+
+Run the example:
+
+```bash
+.venv/bin/python sphere_demag.py
+```
+
+You can also pass an explicit mesh path:
+
+```bash
+.venv/bin/python sphere_demag.py /path/to/sphere1.nmesh.h5
+```
+
+The script prints the interpolated `H_demag` at the origin and the analytic
+x component. The supplied mesh agrees at the percent level. It also writes
+`sphere1_dat.ndt` and `sphere1_dat.h5` under `results/`.
+
+## What the example establishes
+
+- The mesh contains region `1`, named `"sphere"` when it is mapped to the
+ material in `load_mesh`.
+- Mesh coordinates are in nanometres because `unit_length` is `SI(1e-9, "m")`.
+- `set_m([1, 0, 0])` assigns a normalized, uniform magnetization direction.
+- `save_data(fields="all")` calculates and stores every available field.
+- `probe_subfield_siv` accepts a position in metres and returns ordinary SI
+ values, which is convenient for plotting and numerical analysis.
+
+The example is tested against the same pinned mesh used by the production
+solver tests. For a new scientific model, repeat this pattern with an analytic,
+legacy, or convergence reference appropriate to that model.
diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md
new file mode 100644
index 0000000..53b9ff7
--- /dev/null
+++ b/docs/guides/configuration.md
@@ -0,0 +1,83 @@
+# Configuration and performance
+
+`NmagConfig` keeps process choices explicit and local to one simulation:
+
+```python
+from pathlib import Path
+
+import nmag
+
+config = nmag.NmagConfig(
+ default_name="relaxation",
+ output_directory=Path("results"),
+ output_policy="error",
+ accelerator="auto",
+ demag_bem_storage="auto",
+)
+```
+
+An explicit configuration takes precedence over environment selectors.
+
+## Optional Rust acceleration
+
+`accelerator` accepts:
+
+- `"auto"`: use installed Rust kernels where Nmag expects a benefit;
+- `"off"`: keep Python/Numba implementations; or
+- `"rust"`: require the extension and fail if it is unavailable.
+
+Advanced callers can override individual calculation families with
+`accelerator_overrides={nmag.RustKernel.LLG: "rust"}`. Cap Rayon worker threads
+before starting Python when sharing a machine:
+
+```bash
+RAYON_NUM_THREADS=4 .venv/bin/python simulation.py
+```
+
+Inspect the effective setup with `nmag.parallel_runtime_info()`.
+
+## Demagnetization storage
+
+The boundary-element operator is often the dominant memory cost.
+`demag_bem_storage` accepts `"auto"`, `"dense"`, `"hierarchical"`, or
+`"matrix-free"`.
+
+Auto mode keeps dense storage while it fits the resource budget, then selects a
+certified hierarchical operator when Rust is available, or exact matrix-free
+action otherwise. Hierarchical construction trades setup time for fast repeated
+application; matrix-free action trades computation for linear storage.
+
+Select the complete low-memory path for large meshes:
+
+```bash
+NMAG_MEMORY_MODE=low .venv/bin/python simulation.py
+```
+
+This selects sparse FEM systems, iterative scalar-potential solves, and exact
+matrix-free Lindholm BEM. SciPy DOP853 remains the integrator.
+
+## Environment selectors
+
+For simulations created without an explicit config:
+
+```text
+NMAG_ACCELERATOR=auto|off|rust
+NMAG_MEMORY_MODE=auto|low
+NMAG_DEMAG_FEM_MATRIX_BACKEND=auto|dense|sparse
+NMAG_DEMAG_BEM_STORAGE_BACKEND=auto|dense|hierarchical|matrix-free
+NMAG_DEMAG_LINEAR_SOLVER_BACKEND=auto|numpy|scipy
+```
+
+Prefer `NmagConfig` in reusable programs. Environment settings are convenient
+for benchmark sweeps and operational overrides.
+
+## Diagnostics
+
+- `last_integrator_stats` reports accepted steps, right-hand-side evaluations,
+ simulated time, wall time, status, and failure state.
+- `last_bem_operator_stats` reports the selected boundary operator, setup and
+ storage measurements, compression information, and fallback reason.
+- `last_*_timings_seconds` mappings expose operation timings for profiling.
+
+Treat diagnostics as measurements of one run and host, not portable performance
+guarantees.
diff --git a/docs/guides/dynamics.md b/docs/guides/dynamics.md
new file mode 100644
index 0000000..ae34fcc
--- /dev/null
+++ b/docs/guides/dynamics.md
@@ -0,0 +1,88 @@
+# Dynamics and relaxation
+
+Nmag integrates the Landau-Lifshitz-Gilbert dynamics of the nodal magnetization.
+The default backend is SciPy DOP853 with relative and absolute tolerances of
+`1e-6` and a configured maximum step of 1 ps.
+
+For meshes with exchange coupling, Nmag may derive a smaller effective maximum
+step to resolve the fastest lumped-FEM exchange mode. Inspect both the configured
+and effective values:
+
+```python
+print(simulation.integrator_config)
+print(simulation.effective_integrator_max_step)
+```
+
+## Advance to a physical time
+
+```python
+reached = simulation.advance_time(nmag.SI(10e-12, "s"))
+print(reached, simulation.last_integrator_stats)
+```
+
+The target cannot precede the current stage time. `max_it` can cap accepted
+steps for interactive work, and `exact_tstop` controls whether the final state
+is reconstructed at the requested time.
+
+## Relax a state
+
+```python
+simulation.relax()
+print(simulation.time)
+print(simulation.step)
+```
+
+The default convergence threshold is one degree per nanosecond. It is checked
+every five accepted integration steps and must be satisfied twice consecutively.
+Because that cadence counts accepted steps, a different integrator can confirm
+the same physical threshold at a different simulated time. Compare spatial stop
+states as well as averages when validating a workflow.
+
+Tune a simulation before starting integration:
+
+```python
+simulation.set_params(
+ stopping_dm_dt=nmag.SI(0.5e9 * 3.141592653589793 / 180.0, "1/s"),
+ ts_rel_tol=1e-7,
+ ts_abs_tol=1e-7,
+ ts_max_step=nmag.SI(0.5e-12, "s"),
+)
+```
+
+Tighter tolerances do not compensate for an under-resolved mesh. Perform mesh,
+step, and tolerance convergence checks separately.
+
+## Scheduled saves
+
+The default relaxation saves averages and fields at the end of the stage. A
+custom schedule uses `when` specifications:
+
+```python
+from when import at, every
+
+simulation.relax(
+ save=[
+ ("averages", every("step", 10)),
+ ("fields", at("stage_end")),
+ ("restart", at("stage_end")),
+ ]
+)
+```
+
+The public abbreviations normalize to `save_averages`, `save_fields`, and
+`save_restart`. Use simulated-time schedules when comparisons must be independent
+of adaptive step counts.
+
+## Experimental Diffsol backend
+
+After building the Rust accelerator:
+
+```python
+config = nmag.NmagConfig(integrator_backend="diffsol", accelerator="rust")
+simulation = nmag.Simulation(config=config)
+```
+
+Diffsol is an opt-in dense implicit relaxation backend. It supports the default
+relaxation schedule for dense isotropic systems, but not `advance_time`, custom
+save/convergence schedules, anisotropy, or low-memory mode. SciPy remains the
+general production backend.
diff --git a/docs/guides/materials.md b/docs/guides/materials.md
new file mode 100644
index 0000000..ddcdf87
--- /dev/null
+++ b/docs/guides/materials.md
@@ -0,0 +1,87 @@
+# Units, materials, and anisotropy
+
+## SI quantities
+
+`nmag.SI` attaches physical dimensions to a value:
+
+```python
+import nmag
+
+length = nmag.SI(5e-9, "m")
+field = nmag.SI(8e3, "A/m")
+exchange = nmag.SI(13e-12, "J/m")
+```
+
+Use `quantity.in_units_of(nmag.SI(1, "unit"))` when an external library needs
+an ordinary number. Nmag checks compatible dimensions at API boundaries; do not
+strip units from material constants merely to silence an error.
+
+## Magnetic materials
+
+```python
+permalloy = nmag.MagMaterial(
+ name="Py",
+ Ms=nmag.SI(1e6, "A/m"),
+ exchange_coupling=nmag.SI(13e-12, "J/m"),
+ llg_damping=0.02,
+)
+```
+
+The most commonly used parameters are:
+
+| Parameter | Meaning | Typical units |
+| --- | --- | --- |
+| `Ms` | Saturation magnetization | A/m |
+| `exchange_coupling` | Exchange constant | J/m |
+| `llg_damping` | Gilbert damping | dimensionless |
+| `llg_gamma_G` | Gyromagnetic ratio | m/(A s) |
+| `llg_polarisation` | Current polarization for Zhang-Li torque | dimensionless |
+| `llg_xi` | Nonadiabatic Zhang-Li coefficient | dimensionless |
+
+`scale_volume_charges` is an expert control that scales only the interior
+volume-charge source of the demagnetization calculation. The physical default
+is `1.0`; surface charges are never scaled by this setting.
+
+## Uniaxial anisotropy
+
+```python
+easy_axis = nmag.uniaxial_anisotropy(
+ axis=[0.0, 0.0, 1.0],
+ K1=nmag.SI(1e5, "J/m^3"),
+ K2=nmag.SI(2e4, "J/m^3"),
+)
+
+material = nmag.MagMaterial(
+ name="uniaxial",
+ Ms=nmag.SI(8e5, "A/m"),
+ exchange_coupling=nmag.SI(10e-12, "J/m"),
+ anisotropy=easy_axis,
+)
+```
+
+The axis is normalized by the constructor. `K1` and `K2` may be SI energy
+densities or plain values interpreted as J/m³.
+
+## Cubic and custom anisotropy
+
+Use `nmag.cubic_anisotropy(axis1, axis2, K1, K2, K3)` for crystalline cubic
+terms. The two supplied axes must define an orthogonal orientation.
+
+A custom polynomial energy can be supplied as a callable with an explicit
+order:
+
+```python
+def energy(m):
+ return nmag.SI(1e5 * m[2] ** 2, "J/m^3")
+
+material = nmag.MagMaterial(
+ name="custom",
+ anisotropy=energy,
+ anisotropy_order=2,
+)
+```
+
+Predefined models use vectorized analytic derivatives. Custom callables use a
+validated finite-difference derivative, so they are intended for smaller or
+exploratory models. The experimental Diffsol backend does not currently support
+anisotropy.
diff --git a/docs/guides/meshes.md b/docs/guides/meshes.md
new file mode 100644
index 0000000..b68d830
--- /dev/null
+++ b/docs/guides/meshes.md
@@ -0,0 +1,77 @@
+# Meshes
+
+Nmag represents the magnetic body with first-order tetrahedral finite elements.
+The mesh coordinates define geometry, tetrahedra define the volume cells, and
+positive integer region IDs determine material assignment.
+
+## Load an existing mesh
+
+```python
+import nmesh
+
+mesh = nmesh.load("geometry.msh")
+print(len(mesh.points), len(mesh.simplices), sorted(set(mesh.regions)))
+```
+
+`nmesh.load` recognizes legacy Nmesh HDF5 by its internal layout. Other formats
+are read through Meshio, so formats such as Gmsh and VTU can be used without
+renaming them to resemble Nmesh files.
+
+Only line, triangle, and tetrahedron cell blocks are imported. When a file mixes
+supported dimensions, the highest-dimensional cells are selected. Region IDs
+are read, in order of preference, from `region`, `gmsh:physical`, `cell_tags`,
+or `gmsh:geometrical`; if none exists, region `1` is assigned.
+
+!!! warning
+
+ Meshio can represent many cell types, but the micromagnetic simulation path
+ supports only 3D tetrahedra. Loading a format successfully does not make its
+ non-tetrahedral cells valid for simulation.
+
+## Construct a simplex mesh
+
+Small meshes and programmatically generated meshes can be built directly:
+
+```python
+import nmesh
+
+mesh = nmesh.mesh_from_points_and_simplices(
+ points=[
+ [0, 0, 0],
+ [1, 0, 0],
+ [0, 1, 0],
+ [0, 0, 1],
+ ],
+ simplices_indices=[[0, 1, 2, 3]],
+ simplices_regions=[1],
+)
+mesh.save("tetrahedron.nmesh.h5")
+```
+
+Coordinates remain in the units chosen by the mesh author. They are converted
+to metres only when `Simulation.load_mesh` applies `unit_length`.
+
+## Generate geometry with Nmesh
+
+The compatibility geometry API provides bodies such as `Box`, `Ellipsoid`, and
+`Conic`, boolean operations, and the `nmesh.Mesh` generator. This is useful for
+simple procedural geometries, but imported meshes from dedicated tools are often
+easier to inspect and reproduce for complex models.
+
+Whatever tool creates the mesh, check:
+
+- tetrahedron orientation and nonzero volume;
+- region tags and their intended material ordering;
+- boundary resolution and element quality; and
+- convergence as the characteristic element length is reduced.
+
+The discretization length should be comfortably smaller than the physical
+length scales that control the magnetization texture. Monitor
+`maxangle_m_` in NDT output: large angles between neighboring nodal
+magnetizations are a warning that the mesh does not resolve the state.
+
+## Save and inspect
+
+`Mesh.save(path)` and `nmesh.save(mesh, path)` write an Nmesh HDF5 file.
+Mesh objects also expose points, simplex indices, region IDs, surfaces, and
+region volumes for validation before simulation.
diff --git a/docs/guides/migration.md b/docs/guides/migration.md
new file mode 100644
index 0000000..a8c5b3b
--- /dev/null
+++ b/docs/guides/migration.md
@@ -0,0 +1,71 @@
+# Migrating from legacy Nmag
+
+The Python 3 interface deliberately preserves the recognizable Nmag workflow,
+but it is a standalone implementation rather than a drop-in replacement for the
+old `nsim` runtime.
+
+## Common translations
+
+| Legacy usage | Python 3 usage |
+| --- | --- |
+| Run with `nsim script.py` | Run with `.venv/bin/python script.py` |
+| `from nmag import SI` | `import nmag`, then `nmag.SI(...)` |
+| `nsim.when.at/every` | `from when import at, every` |
+| `--clean` | `NmagConfig(output_policy="replace")` |
+| Process-wide solver flags | Per-simulation `NmagConfig` and `set_params` |
+| `nmagpp`/`ncol` analysis | H5py, NumPy, pandas, Meshio, or other current tools |
+| Legacy restart files | Native `save_restart_file`/`load_restart_file` |
+
+The core construction pattern remains familiar:
+
+```python
+import nmag
+
+material = nmag.MagMaterial(
+ name="Py",
+ Ms=nmag.SI(1e6, "A/m"),
+ exchange_coupling=nmag.SI(13e-12, "J/m"),
+)
+
+simulation = nmag.Simulation(name="ported")
+simulation.load_mesh(
+ "legacy.nmesh.h5",
+ [("magnetic", material)],
+ unit_length=nmag.SI(1e-9, "m"),
+)
+simulation.set_m([1, 0, 0])
+simulation.set_H_ext([0, 0, 0], nmag.SI("A/m"))
+simulation.relax()
+```
+
+## Do not translate blindly
+
+Remove old command-line options and imports before debugging physics. In
+particular, do not carry over:
+
+- Python 2 syntax;
+- imports from `ocaml`, `nsim`, or old `nmag` implementation modules;
+- CVODE/PVODE, PETSc, HLib, or MPI configuration arguments;
+- assumptions that legacy output helpers or restart formats exist; or
+- a physics option listed as unsupported in the
+ [current scope](supported-scope.md).
+
+The rewrite uses SciPy DOP853 by default, so accepted step counts and relaxation
+times are not expected to match legacy CVODE exactly. Start comparison at fixed
+physical times, then compare the spatial magnetization and effective fields.
+
+## Porting checklist
+
+1. Make the script valid Python 3 and replace legacy imports.
+2. Run it with Python, not `nsim`.
+3. Add an explicit `NmagConfig`, especially for output lifecycle.
+4. Confirm mesh regions and `unit_length` before assigning materials.
+5. Replace unsupported physics with a reviewed model decision; do not silently
+ omit it.
+6. Establish one small fixed-time or static reference before running a long
+ relaxation.
+7. Inspect NDT/HDF5 data directly with maintained Python tools.
+
+The historical [Nmag 0.2 manual](https://nmag.readthedocs.io/en/latest/) is a
+valuable description of the original interface and micromagnetic background.
+Use this manual for the Python 3 contract whenever the two differ.
diff --git a/docs/guides/output.md b/docs/guides/output.md
new file mode 100644
index 0000000..3919bc5
--- /dev/null
+++ b/docs/guides/output.md
@@ -0,0 +1,89 @@
+# Fields, output, and restarts
+
+## Discover and read fields
+
+After loading a mesh and setting magnetization:
+
+```python
+print(simulation.get_all_field_names())
+print(simulation.is_subfield_available("H_demag"))
+
+nodal_field = simulation.get_subfield("H_demag")
+average_field = simulation.get_subfield_average("H_demag")
+```
+
+Common fields include magnetization direction `m`, magnetization `M`, applied
+field `H_ext`, exchange field `H_exch`, demagnetization field `H_demag`,
+anisotropy field `H_anis`, total field `H_total`, energy densities, pinning, and
+the scalar-potential quantities `phi` and `rho`. Availability depends on the
+loaded materials and whether demagnetization is enabled.
+
+Material-specific averages can be requested by material name:
+
+```python
+py_average = simulation.get_subfield_average("m", "Py")
+```
+
+## Probe a position
+
+`probe_subfield_siv` takes positions in metres and returns ordinary SI values:
+
+```python
+field = simulation.probe_subfield_siv("H_demag", [0.0, 0.0, 0.0])
+```
+
+It returns `None` outside the mesh. `probe_subfield` provides the same behavior
+and can accept an explicit unit argument.
+
+## NDT and HDF5 output
+
+```python
+simulation.save_data() # averaged quantities only
+simulation.save_data(fields=["m", "H_total"])
+simulation.save_data(fields="all") # every available spatial field
+```
+
+For a simulation named `sample`, Nmag writes:
+
+- `sample_dat.ndt`: tab-separated, SI-labelled averaged quantities, one row per
+ save event;
+- `sample_dat.h5`: mesh metadata and requested nodal or cell fields.
+
+Use HDF5 readers such as H5py for analysis. The current rewrite does not ship
+the legacy `ncol` or `nmagpp` executables.
+
+To write selected spatial fields to another compact HDF5 file:
+
+```python
+simulation.save_spatial_fields("snapshot.h5", ["m", "H_demag"])
+```
+
+## Output lifecycle
+
+`NmagConfig.output_policy` controls files created by a new simulation:
+
+- `"error"` refuses to overwrite existing NDT/HDF5 output;
+- `"replace"` starts those output files again; and
+- `"append"` validates the old NDT schema before adding rows.
+
+Append is an output choice, not a physical restart.
+
+## Restart checkpoints
+
+```python
+checkpoint = simulation.save_restart_file("relaxed_state.h5")
+
+# Create a compatible simulation and load the same mesh/materials first.
+continued.load_restart_file(checkpoint)
+```
+
+Native checkpoints preserve magnetization, pinning, current density, external
+field, clock, and supported dynamics state. Mesh and material fingerprints must
+match. DOP853 is reconstructed from the physical state, so later adaptive step
+sizes need not reproduce an earlier run exactly.
+
+`load_m_from_h5file(path)` transfers only magnetization and requires the same
+mesh, making it useful when other simulation settings should remain new.
+
+These are native Python 3 checkpoint files. Legacy Nmag restart files are not
+supported.
diff --git a/docs/guides/simulation-workflow.md b/docs/guides/simulation-workflow.md
new file mode 100644
index 0000000..e979651
--- /dev/null
+++ b/docs/guides/simulation-workflow.md
@@ -0,0 +1,110 @@
+# Simulation workflow
+
+## 1. Choose output behavior
+
+`NmagConfig` holds immutable choices for one simulation. Give each study a
+dedicated output directory and choose deliberately how existing files are
+handled:
+
+```python
+from pathlib import Path
+
+import nmag
+
+results = Path("results")
+results.mkdir(exist_ok=True)
+config = nmag.NmagConfig(
+ output_directory=results,
+ output_policy="error",
+ accelerator="auto",
+)
+simulation = nmag.Simulation(name="sample", config=config)
+```
+
+The default `"error"` policy protects existing NDT and HDF5 files. `"replace"`
+starts those files again, while `"append"` checks the existing NDT schema and
+adds rows. Append does not restore physical state; use a restart file for that.
+
+## 2. Prepare a mesh and materials
+
+The simulation solver expects a 3D tetrahedral mesh whose cells carry integer
+region IDs. A material definition supplies saturation magnetization and the
+physics enabled for that region:
+
+```python
+permalloy = nmag.MagMaterial(
+ name="Py",
+ Ms=nmag.SI(1e6, "A/m"),
+ exchange_coupling=nmag.SI(13e-12, "J/m"),
+ llg_damping=0.02,
+)
+
+simulation.load_mesh(
+ "sample.msh",
+ [("magnetic", permalloy)],
+ unit_length=nmag.SI(1e-9, "m"),
+)
+```
+
+The material list is ordered by mesh region: the first entry maps to region 1,
+the second to region 2, and so on. Nmag checks that configured and actual
+regions match. Region names then appear in field and output labels.
+
+`unit_length` converts mesh coordinate units into metres. If a Gmsh file already
+stores metres, use `nmag.SI(1, "m")`.
+
+## 3. Set the state and applied fields
+
+Magnetization is a unit direction vector. Assign it uniformly, per node, or
+with a callable evaluated at physical positions in metres:
+
+```python
+simulation.set_m([1.0, 0.0, 0.0])
+
+# A position-dependent alternative:
+# simulation.set_m(lambda position: [1, 0, 0] if position[0] < 0 else [0, 1, 0])
+```
+
+The applied field can be supplied as a vector plus a unit:
+
+```python
+simulation.set_H_ext([0.0, 0.0, 8e3], nmag.SI("A/m"))
+```
+
+Pinning and Zhang-Li current density are optional:
+
+```python
+simulation.set_pinning(lambda position: 0.0 if position[2] < 2e-9 else 1.0)
+simulation.set_current_density([0.0, 0.0, 1e12], nmag.SI("A/m^2"))
+```
+
+A pin value of zero fixes the complete local magnetization derivative; one
+leaves it free.
+
+## 4. Calculate, advance, or relax
+
+Field access triggers the required calculation lazily:
+
+```python
+average_demag = simulation.get_subfield_average("H_demag")
+```
+
+For dynamics, either advance to a requested physical time or relax until the
+configured convergence test passes:
+
+```python
+simulation.advance_time(nmag.SI(1e-12, "s"))
+simulation.relax()
+```
+
+## 5. Save and inspect results
+
+```python
+simulation.save_data(fields="all")
+print(simulation.time)
+print(simulation.last_integrator_stats)
+```
+
+See [fields, output, and restarts](output.md) for selective output and safe
+continuation, and [dynamics and relaxation](dynamics.md) before tuning an
+integrator.
diff --git a/docs/guides/supported-scope.md b/docs/guides/supported-scope.md
new file mode 100644
index 0000000..1a2bd842
--- /dev/null
+++ b/docs/guides/supported-scope.md
@@ -0,0 +1,67 @@
+# Supported scope and limitations
+
+## Supported simulation model
+
+Nmag for Python 3 supports 3D, first-order tetrahedral micromagnetic workflows
+with:
+
+- legacy Nmesh HDF5 and modern Meshio-supported mesh input;
+- single- and multi-region material assignment where material degrees of
+ freedom are compatible at shared nodes;
+- demagnetization, exchange, uniform applied field, uniaxial and cubic
+ anisotropy, and custom polynomial anisotropy energy;
+- pinning and Zhang-Li current-density spin-transfer torque;
+- adaptive time advancement and relaxation with SciPy DOP853;
+- NDT/HDF5 data, spatial field probing, and native restart checkpoints; and
+- optional Rust kernels and resource-aware dense, hierarchical, or matrix-free
+ demagnetization storage.
+
+## Important limitations
+
+The following are not currently supported:
+
+- thermal or stochastic dynamics;
+- Slonczewski spin-transfer torque;
+- periodic micromagnetic boundary conditions;
+- custom `phi_BEM`/legacy HLib configuration;
+- material-specific magnetization degrees of freedom where shared geometric
+ nodes require conflicting material coefficients;
+- local inter-material exchange coupling;
+- spatially or time-varying applied fields;
+- complete multi-stage legacy hysteresis compatibility;
+- legacy restart-file import; and
+- a distributed-memory MPI solver.
+
+The experimental Diffsol backend has a narrower scope: dense isotropic
+relaxation with the default save and convergence schedule. It does not support
+anisotropy, low-memory mode, custom schedules, or public `advance_time`.
+
+## Numerical responsibility
+
+Passing a solver test does not establish that a scientific model is resolved.
+For each new workflow:
+
+1. verify material values and units;
+2. check geometry, region tags, tetrahedron quality, and boundary resolution;
+3. refine the mesh and compare relevant observables;
+4. tighten tolerances and maximum time step independently;
+5. inspect maximum neighboring spin angle and spatial fields; and
+6. compare against an analytic result, trusted package, experiment, or a
+ previously validated Nmag case.
+
+Adaptive solvers need not take the same internal steps or stop at the same time
+to represent equivalent physical states. Compare field and magnetization arrays,
+energies, and problem-specific observables rather than requiring identical step
+histories.
+
+## Resource limits
+
+There is no universal mesh-size limit. Memory and time depend strongly on the
+number of volume and boundary nodes, geometry, selected demagnetization storage,
+and requested dynamics. Dense boundary storage grows quadratically. Auto mode
+uses available-memory estimates, while low-memory mode avoids dense BEM storage
+at the cost of repeated computation.
+
+Review `last_bem_operator_stats`, host memory, and elapsed setup/action time
+before scaling a study. Never disable a resource guard without estimating the
+resulting allocation.
diff --git a/docs/index.md b/docs/index.md
new file mode 100644
index 0000000..af683de
--- /dev/null
+++ b/docs/index.md
@@ -0,0 +1,52 @@
+# Nmag for Python 3
+
+Nmag is a Python interface for finite-element micromagnetic simulations. This
+rewrite runs on modern Python, reads legacy Nmesh files and common mesh formats,
+and provides static fields, adaptive magnetization dynamics, relaxation,
+checkpointing, and structured NDT/HDF5 output.
+
+[Install Nmag](getting-started/installation.md){ .md-button .md-button--primary }
+[Run the quickstart](getting-started/quickstart.md){ .md-button }
+
+## Where to begin
+
+- **New to the package?** Follow the [quickstart](getting-started/quickstart.md),
+ then work through the [sphere tutorial](getting-started/sphere.md).
+- **Building a simulation?** Read the [simulation workflow](guides/simulation-workflow.md)
+ and the guides for [meshes](guides/meshes.md),
+ [materials](guides/materials.md), and [output](guides/output.md).
+- **Moving an old script?** Start with the
+ [legacy migration guide](guides/migration.md).
+- **Looking up a call?** Use the curated
+ [API reference](reference/simulation.md).
+
+!!! warning "Current project status"
+
+ The supported workflows are usable, but the rewrite does not implement
+ every feature of Nmag 0.2. Validate a new geometry or material model before
+ relying on production results, and check the
+ [supported scope](guides/supported-scope.md) before porting a legacy study.
+
+## A Python simulation, end to end
+
+An Nmag program follows a small set of explicit steps:
+
+1. load or construct a tetrahedral mesh;
+2. define magnetic materials with SI-valued parameters;
+3. create a simulation and map mesh regions to materials;
+4. set magnetization and applied fields;
+5. calculate fields, advance time, or relax the state; and
+6. save averages and spatial fields for analysis.
+
+The documentation concentrates on that user workflow. Internal solver design,
+development investigations, and validation machinery are intentionally not part
+of this manual.
+
+## Project lineage
+
+Nmag was originally developed at the University of Southampton. The historical
+[Nmag 0.2 manual](https://nmag.readthedocs.io/en/latest/) remains useful for
+micromagnetic background, but its Python 2 syntax, command-line tools, solver
+options, and some supported physics do not describe this rewrite. The original
+source is available from
+[nmag-project/nmag-src](https://github.com/nmag-project/nmag-src).
diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md
new file mode 100644
index 0000000..8212fb3
--- /dev/null
+++ b/docs/reference/configuration.md
@@ -0,0 +1,37 @@
+# Configuration and diagnostics API
+
+## Simulation configuration
+
+::: nmag.config.NmagConfig
+ options:
+ members:
+ - from_environment
+ - accelerator_mode_for
+
+::: nmag.config.HierarchicalBemConfig
+ options:
+ members: false
+
+::: nmag.config.RustKernel
+ options:
+ members: true
+
+## Runtime diagnostics
+
+::: nmag.dynamics.IntegratorConfig
+ options:
+ members: false
+
+::: nmag.dynamics.IntegratorStats
+ options:
+ members: false
+
+::: nmag.demag.bem_operator.BemOperatorStats
+ options:
+ members: false
+
+::: nmag.parallel.ParallelRuntimeInfo
+ options:
+ members: false
+
+::: nmag.parallel.parallel_runtime_info
diff --git a/docs/reference/materials.md b/docs/reference/materials.md
new file mode 100644
index 0000000..10e4ddd
--- /dev/null
+++ b/docs/reference/materials.md
@@ -0,0 +1,29 @@
+# Materials and units API
+
+## SI quantities
+
+`nmag.SI` and `nmag.Physical` are aliases for the same dimensional quantity
+class. Prefer `nmag.SI` in simulation scripts.
+
+::: si.physical.Physical
+ options:
+ members:
+ - magnitude
+ - dens_str
+ - in_units_of
+
+## Magnetic materials
+
+::: mag_material.mag_material.MagMaterial
+ options:
+ members: false
+
+## Anisotropy
+
+::: anisotropy.predefined.uniaxial_anisotropy
+
+::: anisotropy.predefined.cubic_anisotropy
+
+::: anisotropy.model.PredefinedAnisotropy
+ options:
+ members: false
diff --git a/docs/reference/meshes.md b/docs/reference/meshes.md
new file mode 100644
index 0000000..8e58185
--- /dev/null
+++ b/docs/reference/meshes.md
@@ -0,0 +1,58 @@
+# Mesh API
+
+The reference uses implementation paths where necessary for static
+documentation, but the documented primitives and operations are exported from
+the top-level `nmesh` package. For example, use `nmesh.Box`, not an internal
+module import.
+
+## Loading and construction
+
+::: nmesh.mesh_io.load
+
+::: nmesh.mesh_io.mesh_from_points_and_simplices
+
+::: nmesh.mesh_io.save
+
+## Mesh objects
+
+::: nmesh.mesh_model.MeshBase
+ options:
+ members:
+ - points
+ - simplices
+ - regions
+ - dim
+ - surfaces
+ - point_regions
+ - links
+ - region_volumes
+ - num_regions
+ - periodic_point_indices
+ - permutation
+ - scale_node_positions
+ - save
+ - to_lists
+
+::: nmesh.mesh_generation.Mesh
+ options:
+ members: false
+
+## Geometry primitives
+
+::: nmesh.geometry.primitives.Box
+ options:
+ members: false
+
+::: nmesh.geometry.primitives.Ellipsoid
+ options:
+ members: false
+
+::: nmesh.geometry.primitives.Conic
+ options:
+ members: false
+
+::: nmesh.geometry.boolean_operations.union
+
+::: nmesh.geometry.boolean_operations.difference
+
+::: nmesh.geometry.boolean_operations.intersect
diff --git a/docs/reference/scheduling.md b/docs/reference/scheduling.md
new file mode 100644
index 0000000..b92fdef
--- /dev/null
+++ b/docs/reference/scheduling.md
@@ -0,0 +1,19 @@
+# Scheduling API
+
+Schedules describe save and action times during relaxation. Prefer the modern
+identifier-first form, such as `every("step", 10)`.
+
+::: when.when.at
+
+::: when.when.every
+
+## Never
+
+`when.never` is a reusable condition that never matches. It is useful when a
+schedule is assembled programmatically and an action needs to be disabled.
+
+::: when.when.When
+ options:
+ members:
+ - match_time
+ - next_time
diff --git a/docs/reference/simulation.md b/docs/reference/simulation.md
new file mode 100644
index 0000000..4da2cbe
--- /dev/null
+++ b/docs/reference/simulation.md
@@ -0,0 +1,43 @@
+# Simulation API
+
+This page contains the supported, user-facing simulation surface. Compatibility
+helpers and internal solver methods are intentionally omitted.
+
+::: nmag.simulation.Simulation
+ options:
+ members:
+ - id
+ - stage
+ - step
+ - stage_step
+ - time
+ - stage_time
+ - real_time
+ - last_step_dt
+ - last_bem_operator_stats
+ - last_integrator_stats
+ - integrator_config
+ - effective_integrator_max_step
+ - load_mesh
+ - set_m
+ - set_H_ext
+ - set_pinning
+ - set_current_density
+ - get_all_field_names
+ - is_subfield_available
+ - get_subfield
+ - get_subfield_average
+ - get_maxangle_average
+ - probe_subfield
+ - probe_subfield_siv
+ - save_data
+ - save_spatial_fields
+ - save_mesh
+ - advance_time
+ - relax
+ - set_params
+ - reinitialise
+ - get_restart_file_name
+ - save_restart_file
+ - load_restart_file
+ - load_m_from_h5file
diff --git a/mkdocs.yml b/mkdocs.yml
new file mode 100644
index 0000000..434594b
--- /dev/null
+++ b/mkdocs.yml
@@ -0,0 +1,102 @@
+site_name: Nmag for Python 3
+site_description: Human-readable documentation for the Nmag micromagnetic simulation interface
+site_url: https://trimagnetix.github.io/nmag-python-3/
+repo_url: https://github.com/TriMagnetix/nmag-python-3
+repo_name: TriMagnetix/nmag-python-3
+edit_uri: edit/main/docs/
+copyright: Copyright © TriMagnetix and the Nmag contributors
+strict: true
+
+theme:
+ name: material
+ icon:
+ logo: material/vector-triangle
+ repo: fontawesome/brands/github
+ palette:
+ - media: "(prefers-color-scheme: light)"
+ scheme: default
+ primary: indigo
+ accent: teal
+ toggle:
+ icon: material/weather-night
+ name: Switch to dark mode
+ - media: "(prefers-color-scheme: dark)"
+ scheme: slate
+ primary: indigo
+ accent: teal
+ toggle:
+ icon: material/weather-sunny
+ name: Switch to light mode
+ features:
+ - navigation.instant
+ - navigation.sections
+ - navigation.top
+ - toc.follow
+ - search.suggest
+ - search.highlight
+ - content.code.copy
+ - content.tabs.link
+
+nav:
+ - Home: index.md
+ - Getting started:
+ - Installation: getting-started/installation.md
+ - Quickstart: getting-started/quickstart.md
+ - Sphere tutorial: getting-started/sphere.md
+ - User guide:
+ - Simulation workflow: guides/simulation-workflow.md
+ - Meshes: guides/meshes.md
+ - Units, materials, and anisotropy: guides/materials.md
+ - Dynamics and relaxation: guides/dynamics.md
+ - Fields, output, and restarts: guides/output.md
+ - Configuration and performance: guides/configuration.md
+ - Supported scope and limitations: guides/supported-scope.md
+ - Migrating from legacy Nmag: guides/migration.md
+ - API reference:
+ - Simulation: reference/simulation.md
+ - Materials and units: reference/materials.md
+ - Configuration and diagnostics: reference/configuration.md
+ - Meshes: reference/meshes.md
+ - Scheduling: reference/scheduling.md
+
+plugins:
+ - search
+ - mkdocstrings:
+ default_handler: python
+ handlers:
+ python:
+ paths: [src]
+ options:
+ docstring_style: google
+ heading_level: 2
+ inherited_members: true
+ members_order: source
+ merge_init_into_class: true
+ separate_signature: true
+ show_bases: false
+ show_root_full_path: false
+ show_root_heading: true
+ show_signature_annotations: true
+ show_source: false
+ signature_crossrefs: true
+
+markdown_extensions:
+ - admonition
+ - attr_list
+ - md_in_html
+ - tables
+ - toc:
+ permalink: true
+ - pymdownx.details
+ - pymdownx.highlight:
+ anchor_linenums: true
+ - pymdownx.inlinehilite
+ - pymdownx.snippets:
+ base_path: .
+ check_paths: true
+ - pymdownx.superfences
+ - pymdownx.tabbed:
+ alternate_style: true
+
+watch:
+ - src
diff --git a/ocaml/example.py b/ocaml/example.py
deleted file mode 100644
index 544ccfd..0000000
--- a/ocaml/example.py
+++ /dev/null
@@ -1,7 +0,0 @@
-import ocaml
-
-ocaml.require('nmag')
-
-from ocaml import Nmag
-
-print(Nmag.Example.add(3,5))
diff --git a/ocaml/nmag/dune-project b/ocaml/nmag/dune-project
deleted file mode 100644
index b934951..0000000
--- a/ocaml/nmag/dune-project
+++ /dev/null
@@ -1,29 +0,0 @@
-(lang dune 3.14)
-
-(name nmag)
-
-(version 0.0.1)
-
-(generate_opam_files true)
-
-(source
- (github TriMagnetix/nmag-python-3))
-
-(authors "TriMagnetix")
-
-(maintainers "TriMagnetix ")
-
-(license LICENSE)
-
-(documentation https://github.com/nmag-project/nmag-src)
-
-(package
- (name nmag)
- (synopsis "Flexible finite element micromagnetic simulation package (Python 3 port)")
- (description "Nmag is a flexible finite element micromagnetic simulation package with a user interface based on the Python programming language. This is a rewrite to make it available for Python 3.")
- (depends
- (ocaml (and (>= 4.14.0) (< 5.0.0))) ; ocaml-in-python doesn't work with OCaml 5 yet
- dune
- ocaml-in-python)
- (tags
- (micromagnetism simulation python finite-element)))
diff --git a/ocaml/nmag/lib/dune b/ocaml/nmag/lib/dune
deleted file mode 100644
index 383b01d..0000000
--- a/ocaml/nmag/lib/dune
+++ /dev/null
@@ -1,6 +0,0 @@
-(library
- (name nmag)
- (public_name nmag)
- (libraries
- ocaml-in-python.api
- ))
diff --git a/ocaml/nmag/lib/example.ml b/ocaml/nmag/lib/example.ml
deleted file mode 100644
index e7aed31..0000000
--- a/ocaml/nmag/lib/example.ml
+++ /dev/null
@@ -1 +0,0 @@
-let add x y = x + y
diff --git a/ocaml/nmag/nmag.opam b/ocaml/nmag/nmag.opam
deleted file mode 100644
index aa39b75..0000000
--- a/ocaml/nmag/nmag.opam
+++ /dev/null
@@ -1,35 +0,0 @@
-# This file is generated by dune, edit dune-project instead
-opam-version: "2.0"
-version: "0.0.1"
-synopsis:
- "Flexible finite element micromagnetic simulation package (Python 3 port)"
-description:
- "Nmag is a flexible finite element micromagnetic simulation package with a user interface based on the Python programming language. This is a rewrite to make it available for Python 3."
-maintainer: ["TriMagnetix "]
-authors: ["TriMagnetix"]
-license: "LICENSE"
-tags: ["micromagnetism" "simulation" "python" "finite-element"]
-homepage: "https://github.com/TriMagnetix/nmag-python-3"
-doc: "https://github.com/nmag-project/nmag-src"
-bug-reports: "https://github.com/TriMagnetix/nmag-python-3/issues"
-depends: [
- "ocaml" {>= "4.14.0" & < "5.0.0"}
- "dune" {>= "3.14"}
- "ocaml-in-python"
- "odoc" {with-doc}
-]
-build: [
- ["dune" "subst"] {dev}
- [
- "dune"
- "build"
- "-p"
- name
- "-j"
- jobs
- "@install"
- "@runtest" {with-test}
- "@doc" {with-doc}
- ]
-]
-dev-repo: "git+https://github.com/TriMagnetix/nmag-python-3.git"
diff --git a/pyproject.toml b/pyproject.toml
index 428f70b..383e5a7 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,34 +1,83 @@
-[build-system]
-requires = ["setuptools>=61.0"]
-build-backend = "setuptools.build_meta"
-
-[project]
-name = "nmag-python-3"
-version = "0.0.1"
-authors = [{ name = "TriMagnetix", email = "info@trimagnetix.com" }]
-description = "\"Nmag is a flexible finite element micromagnetic simulation package with an user interface based on the Python_ programming language.\" This is a rewrite to make it available for Python 3."
-readme = "README.md"
+[build-system]
+requires = ["setuptools>=61.0"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "nmag-python-3"
+version = "0.0.1"
+authors = [{ name = "TriMagnetix", email = "info@trimagnetix.com" }]
+description = "Standalone Python 3 implementation of the Nmag micromagnetic simulation interface"
+readme = "README.md"
requires-python = ">=3.10"
-classifiers = [
- "Programming Language :: Python :: 3",
- "Operating System :: OS Independent" ]
+classifiers = [
+ "Programming Language :: Python :: 3",
+ "Operating System :: OS Independent" ]
dependencies = ["pint", "numpy", "scipy", "numba", "tabulate", "meshio", "h5py"]
-
-[project.optional-dependencies]
-test = ["pytest", "pytest-cov", "pytest-watch"]
-
-[tool]
-setuptools.package-dir = { "" = "src" }
-setuptools.packages.find.where = ["src"]
-
-pytest.ini_options.addopts = [
- "-ra",
- "-v",
- "--cov=src",
- "--cov-report=term-missing",
- "--cov-report=html" ]
-pytest.ini_options.testpaths = ["tests"]
-
-pytest_watch.runner = "pytest --clear"
-
-coverage.run.omit = ["*/tests/*"]
+
+[project.optional-dependencies]
+test = ["pytest", "pytest-cov", "pytest-watch"]
+rust = ["maturin>=1.14,<2"]
+docs = [
+ "mkdocs==1.6.1",
+ "mkdocs-material==9.7.7",
+ "mkdocstrings==1.0.6",
+ "mkdocstrings-python==2.0.5",
+]
+dev = [
+ "maturin>=1.14,<2",
+ "h5py-stubs>=0.1.2,<0.2",
+ "pyright>=1.1.400,<2",
+ "pytest",
+ "pytest-cov",
+ "pytest-watch",
+ "ruff>=0.12,<1",
+]
+
+[tool]
+setuptools.package-dir = { "" = "src" }
+setuptools.packages.find.where = ["src"]
+setuptools.package-data."*" = ["py.typed"]
+
+pytest.ini_options.addopts = [
+ "-ra",
+ "-v",
+ "--cov=src",
+ "--cov-report=term-missing",
+ "--cov-report=html" ]
+pytest.ini_options.testpaths = ["tests"]
+
+pytest_watch.runner = "pytest --clear"
+
+coverage.run.omit = ["*/tests/*"]
+coverage.run.branch = true
+coverage.report.fail_under = 80
+
+[tool.ruff]
+target-version = "py310"
+line-length = 100
+src = ["src"]
+
+[tool.ruff.lint]
+select = ["E4", "E7", "E9", "F", "I", "B", "UP", "ANN"]
+ignore = ["ANN401"]
+
+[tool.ruff.lint.per-file-ignores]
+"tests/**/*.py" = ["ANN"]
+"tests/*.py" = ["ANN"]
+
+[tool.pyright]
+include = ["src"]
+exclude = ["**/__pycache__", "build", "dist"]
+extraPaths = ["src"]
+venvPath = "."
+venv = ".venv"
+pythonVersion = "3.10"
+typeCheckingMode = "strict"
+useLibraryCodeForTypes = true
+reportMissingImports = "error"
+reportMissingModuleSource = "error"
+reportMissingTypeStubs = "none"
+reportPrivateUsage = "none"
+reportUnusedFunction = "none"
+# PEP 562 lazy exports are validated by import and packaging tests.
+reportUnsupportedDunderAll = "none"
diff --git a/rust/nmag_accel/Cargo.lock b/rust/nmag_accel/Cargo.lock
new file mode 100644
index 0000000..ba46695
--- /dev/null
+++ b/rust/nmag_accel/Cargo.lock
@@ -0,0 +1,1702 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "approx"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "atomic-wait"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a55b94919229f2c42292fd71ffa4b75e83193bffdd77b1e858cd55fd2d0b0ea8"
+dependencies = [
+ "libc",
+ "windows-sys 0.42.0",
+]
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "bitflags"
+version = "2.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
+
+[[package]]
+name = "bytemuck"
+version = "1.25.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6aedf8ae72766347502cf3cb4f41cf5e9cc37d28bee90f1fdaaae15f9cf9424"
+dependencies = [
+ "bytemuck_derive",
+]
+
+[[package]]
+name = "bytemuck_derive"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f65693059b6b9c588b9f62fed1cedbf0a8b805631457ea162d68f0de186f3de5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "byteorder"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+
+[[package]]
+name = "cc"
+version = "1.2.67"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "crossbeam"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8"
+dependencies = [
+ "crossbeam-channel",
+ "crossbeam-deque",
+ "crossbeam-epoch",
+ "crossbeam-queue",
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-channel"
+version = "0.5.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-deque"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb"
+dependencies = [
+ "crossbeam-epoch",
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-epoch"
+version = "0.9.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-queue"
+version = "0.3.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
+
+[[package]]
+name = "crunchy"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
+
+[[package]]
+name = "defer"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "930c7171c8df9fb1782bdf9b918ed9ed2d33d1d22300abb754f9085bc48bf8e8"
+
+[[package]]
+name = "diffsol"
+version = "0.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "068d5f82ef700d82859ac0b1f91380c47b66661c290ae8fdd0cd1089d08b43ad"
+dependencies = [
+ "faer",
+ "faer-traits",
+ "log",
+ "nalgebra",
+ "nalgebra-sparse",
+ "num-traits",
+ "petgraph",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "dyn-stack"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1c4713e43e2886ba72b8271aa66c93d722116acf7a75555cce11dcde84388fe8"
+dependencies = [
+ "bytemuck",
+ "dyn-stack-macros",
+]
+
+[[package]]
+name = "dyn-stack-macros"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1d926b4d407d372f141f93bb444696142c29d32962ccbd3531117cf3aa0bfa9"
+
+[[package]]
+name = "either"
+version = "1.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
+
+[[package]]
+name = "enum-as-inner"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "equator"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c35da53b5a021d2484a7cc49b2ac7f2d840f8236a286f84202369bd338d761ea"
+dependencies = [
+ "equator-macro 0.2.1",
+]
+
+[[package]]
+name = "equator"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc"
+dependencies = [
+ "equator-macro 0.4.2",
+]
+
+[[package]]
+name = "equator"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02da895aab06bbebefb6b2595f6d637b18c9ff629b4cd840965bb3164e4194b0"
+dependencies = [
+ "equator-macro 0.6.0",
+]
+
+[[package]]
+name = "equator-macro"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3bf679796c0322556351f287a51b49e48f7c4986e727b5dd78c972d30e2e16cc"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "equator-macro"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "equator-macro"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b14b339eb76d07f052cdbad76ca7c1310e56173a138095d3bf42a23c06ef5d8"
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "faer"
+version = "0.24.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ab6df3dd147fe8d702a288b95bcd8fcc499ab572fc80da6828f60cd4d524d67"
+dependencies = [
+ "bytemuck",
+ "dyn-stack",
+ "equator 0.6.0",
+ "faer-traits",
+ "gemm",
+ "generativity",
+ "libm",
+ "nano-gemm",
+ "npyz",
+ "num-complex",
+ "num-traits",
+ "private-gemm-x86",
+ "pulp",
+ "rand 0.9.5",
+ "rand_distr",
+ "rayon",
+ "reborrow",
+ "spindle",
+]
+
+[[package]]
+name = "faer-traits"
+version = "0.24.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b87d23ed7ab1f26c0cba0e5b9e061a796fbb7dc170fa8bee6970055a1308bb0f"
+dependencies = [
+ "bytemuck",
+ "dyn-stack",
+ "generativity",
+ "libm",
+ "num-complex",
+ "num-traits",
+ "pulp",
+ "qd",
+ "reborrow",
+]
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+
+[[package]]
+name = "fixedbitset"
+version = "0.5.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
+
+[[package]]
+name = "foldhash"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+
+[[package]]
+name = "gemm"
+version = "0.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa0673db364b12263d103b68337a68fbecc541d6f6b61ba72fe438654709eacb"
+dependencies = [
+ "dyn-stack",
+ "gemm-c32",
+ "gemm-c64",
+ "gemm-common",
+ "gemm-f16",
+ "gemm-f32",
+ "gemm-f64",
+ "num-complex",
+ "num-traits",
+ "paste",
+ "raw-cpuid",
+ "seq-macro",
+]
+
+[[package]]
+name = "gemm-c32"
+version = "0.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "086936dbdcb99e37aad81d320f98f670e53c1e55a98bee70573e83f95beb128c"
+dependencies = [
+ "dyn-stack",
+ "gemm-common",
+ "num-complex",
+ "num-traits",
+ "paste",
+ "raw-cpuid",
+ "seq-macro",
+]
+
+[[package]]
+name = "gemm-c64"
+version = "0.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20c8aeeeec425959bda4d9827664029ba1501a90a0d1e6228e48bef741db3a3f"
+dependencies = [
+ "dyn-stack",
+ "gemm-common",
+ "num-complex",
+ "num-traits",
+ "paste",
+ "raw-cpuid",
+ "seq-macro",
+]
+
+[[package]]
+name = "gemm-common"
+version = "0.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88027625910cc9b1085aaaa1c4bc46bb3a36aad323452b33c25b5e4e7c8e2a3e"
+dependencies = [
+ "bytemuck",
+ "dyn-stack",
+ "half",
+ "libm",
+ "num-complex",
+ "num-traits",
+ "once_cell",
+ "paste",
+ "pulp",
+ "raw-cpuid",
+ "rayon",
+ "seq-macro",
+ "sysctl",
+]
+
+[[package]]
+name = "gemm-f16"
+version = "0.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3df7a55202e6cd6739d82ae3399c8e0c7e1402859b30e4cb780e61525d9486e"
+dependencies = [
+ "dyn-stack",
+ "gemm-common",
+ "gemm-f32",
+ "half",
+ "num-complex",
+ "num-traits",
+ "paste",
+ "raw-cpuid",
+ "rayon",
+ "seq-macro",
+]
+
+[[package]]
+name = "gemm-f32"
+version = "0.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02e0b8c9da1fbec6e3e3ab2ce6bc259ef18eb5f6f0d3e4edf54b75f9fd41a81c"
+dependencies = [
+ "dyn-stack",
+ "gemm-common",
+ "num-complex",
+ "num-traits",
+ "paste",
+ "raw-cpuid",
+ "seq-macro",
+]
+
+[[package]]
+name = "gemm-f64"
+version = "0.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "056131e8f2a521bfab322f804ccd652520c79700d81209e9d9275bbdecaadc6a"
+dependencies = [
+ "dyn-stack",
+ "gemm-common",
+ "num-complex",
+ "num-traits",
+ "paste",
+ "raw-cpuid",
+ "seq-macro",
+]
+
+[[package]]
+name = "generativity"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2c81fb5260e37854d09d5c87183309fd8c555b75289427884b25660bc87a85e"
+
+[[package]]
+name = "generator"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "libc",
+ "log",
+ "rustversion",
+ "windows-link",
+ "windows-result",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+ "wasip2",
+]
+
+[[package]]
+name = "glam"
+version = "0.30.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19fc433e8437a212d1b6f1e68c7824af3aed907da60afa994e7f542d18d12aa9"
+
+[[package]]
+name = "glam"
+version = "0.31.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "556f6b2ea90b8d15a74e0e7bb41671c9bdf38cd9f78c284d750b9ce58a2b5be7"
+
+[[package]]
+name = "glam"
+version = "0.32.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f70749695b063ecbf6b62949ccccde2e733ec3ecbbd71d467dca4e5c6c97cca0"
+
+[[package]]
+name = "glam"
+version = "0.33.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f22fb22f065b308be0d8724e3706c7fa3fc2a6c7d6899df4cad7860e7a75436"
+
+[[package]]
+name = "half"
+version = "2.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
+dependencies = [
+ "bytemuck",
+ "cfg-if",
+ "crunchy",
+ "num-traits",
+ "zerocopy",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.15.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
+dependencies = [
+ "foldhash",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "hermit-abi"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
+
+[[package]]
+name = "indexmap"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.17.1",
+]
+
+[[package]]
+name = "indoc"
+version = "2.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
+dependencies = [
+ "rustversion",
+]
+
+[[package]]
+name = "interpol"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eb58032ba748f4010d15912a1855a8a0b1ba9eaad3395b0c171c09b3b356ae50"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "lazy_static"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+
+[[package]]
+name = "libc"
+version = "0.2.186"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
+
+[[package]]
+name = "libm"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
+
+[[package]]
+name = "log"
+version = "0.4.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
+
+[[package]]
+name = "loom"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca"
+dependencies = [
+ "cfg-if",
+ "generator",
+ "scoped-tls",
+ "tracing",
+ "tracing-subscriber",
+]
+
+[[package]]
+name = "matchers"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
+dependencies = [
+ "regex-automata",
+]
+
+[[package]]
+name = "matrixmultiply"
+version = "0.3.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08"
+dependencies = [
+ "autocfg",
+ "rawpointer",
+]
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "memoffset"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "nalgebra"
+version = "0.35.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "adc43a60c217b0c6ff46e47f26911015ad8d2e5a8be1af668c67e370d99a4346"
+dependencies = [
+ "approx",
+ "glam 0.30.10",
+ "glam 0.31.1",
+ "glam 0.32.1",
+ "glam 0.33.2",
+ "matrixmultiply",
+ "nalgebra-macros",
+ "num-complex",
+ "num-rational",
+ "num-traits",
+ "simba",
+ "typenum",
+]
+
+[[package]]
+name = "nalgebra-macros"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "973e7178a678cfd059ccec50887658d482ce16b0aa9da3888ddeab5cd5eb4889"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "nalgebra-sparse"
+version = "0.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8935b578b6a012f3667131452add895472f37c242d2844497daf7c49f6b8059"
+dependencies = [
+ "nalgebra",
+ "num-traits",
+ "pest",
+ "pest_derive",
+]
+
+[[package]]
+name = "nano-gemm"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e04345dc84b498ff89fe0d38543d1f170da9e43a2c2bcee73a0f9069f72d081"
+dependencies = [
+ "equator 0.2.2",
+ "nano-gemm-c32",
+ "nano-gemm-c64",
+ "nano-gemm-codegen",
+ "nano-gemm-core",
+ "nano-gemm-f32",
+ "nano-gemm-f64",
+ "num-complex",
+]
+
+[[package]]
+name = "nano-gemm-c32"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0775b1e2520e64deee8fc78b7732e3091fb7585017c0b0f9f4b451757bbbc562"
+dependencies = [
+ "nano-gemm-codegen",
+ "nano-gemm-core",
+ "num-complex",
+]
+
+[[package]]
+name = "nano-gemm-c64"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9af49a20d58816e6b5ee65f64142e50edb5eba152678d4bb7377fcbf63f8437a"
+dependencies = [
+ "nano-gemm-codegen",
+ "nano-gemm-core",
+ "num-complex",
+]
+
+[[package]]
+name = "nano-gemm-codegen"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6cc8d495c791627779477a2cf5df60049f5b165342610eb0d76bee5ff5c5d74c"
+
+[[package]]
+name = "nano-gemm-core"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d998dfa644de87a0f8660e5ea511d7cb5c33b5a2d9847b7af57a2565105089f0"
+
+[[package]]
+name = "nano-gemm-f32"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "879d962e79bc8952e4ad21ca4845a21132540ed3f5e01184b2ff7f720e666523"
+dependencies = [
+ "nano-gemm-codegen",
+ "nano-gemm-core",
+]
+
+[[package]]
+name = "nano-gemm-f64"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9a513473dce7dc00c7e7c318481ca4494034e76997218d8dad51bd9f007a815"
+dependencies = [
+ "nano-gemm-codegen",
+ "nano-gemm-core",
+]
+
+[[package]]
+name = "ndarray"
+version = "0.17.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d"
+dependencies = [
+ "matrixmultiply",
+ "num-complex",
+ "num-integer",
+ "num-traits",
+ "portable-atomic",
+ "portable-atomic-util",
+ "rawpointer",
+]
+
+[[package]]
+name = "nmag-accel"
+version = "0.1.0"
+dependencies = [
+ "diffsol",
+ "faer",
+ "ndarray",
+ "numpy",
+ "pyo3",
+ "rayon",
+]
+
+[[package]]
+name = "npyz"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f0e759e014e630f90af745101b614f761306ddc541681e546649068e25ec1b9"
+dependencies = [
+ "byteorder",
+ "num-bigint",
+ "py_literal",
+]
+
+[[package]]
+name = "nu-ansi-term"
+version = "0.50.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "num-bigint"
+version = "0.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
+dependencies = [
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-complex"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
+dependencies = [
+ "bytemuck",
+ "num-traits",
+ "rand 0.8.7",
+]
+
+[[package]]
+name = "num-integer"
+version = "0.1.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "num-rational"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
+dependencies = [
+ "num-bigint",
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+ "libm",
+]
+
+[[package]]
+name = "num_cpus"
+version = "1.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
+dependencies = [
+ "hermit-abi",
+ "libc",
+]
+
+[[package]]
+name = "numpy"
+version = "0.27.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7aac2e6a6e4468ffa092ad43c39b81c79196c2bb773b8db4085f695efe3bba17"
+dependencies = [
+ "libc",
+ "ndarray",
+ "num-complex",
+ "num-integer",
+ "num-traits",
+ "pyo3",
+ "pyo3-build-config",
+ "rustc-hash",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "paste"
+version = "1.0.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
+
+[[package]]
+name = "pest"
+version = "2.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47627dd7305c6a2d6c8c6bcd24c5a4c17dbbf425f4f9c5313e724b38fc9782e9"
+dependencies = [
+ "memchr",
+ "ucd-trie",
+]
+
+[[package]]
+name = "pest_derive"
+version = "2.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b4254325ecad416ab689e27ba51da03ba01a9632bc6e108f5fe7c3c4ad29d58"
+dependencies = [
+ "pest",
+ "pest_generator",
+]
+
+[[package]]
+name = "pest_generator"
+version = "2.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c4c0e91ead7a8f7acecbca6f003fc2e8282b1dbe2dd9c9d2f16aba42995e0a7"
+dependencies = [
+ "pest",
+ "pest_meta",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "pest_meta"
+version = "2.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9744bc48116fee06334924bb5f2bad41eed5e89bd26e29b0b799f9a3f82c210"
+dependencies = [
+ "pest",
+]
+
+[[package]]
+name = "petgraph"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
+dependencies = [
+ "fixedbitset",
+ "hashbrown 0.15.5",
+ "indexmap",
+ "serde",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "portable-atomic"
+version = "1.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
+
+[[package]]
+name = "portable-atomic-util"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
+dependencies = [
+ "portable-atomic",
+]
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
+[[package]]
+name = "private-gemm-x86"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0af8c3e5087969c323f667ccb4b789fa0954f5aa650550e38e81cf9108be21b5"
+dependencies = [
+ "crossbeam",
+ "defer",
+ "interpol",
+ "num_cpus",
+ "raw-cpuid",
+ "rayon",
+ "spindle",
+ "sysctl",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "pulp"
+version = "0.22.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "046aa45b989642ec2e4717c8e72d677b13edd831a4d3b6cf37d9a3e54912496a"
+dependencies = [
+ "bytemuck",
+ "cfg-if",
+ "libm",
+ "num-complex",
+ "paste",
+ "pulp-wasm-simd-flag",
+ "raw-cpuid",
+ "reborrow",
+ "version_check",
+]
+
+[[package]]
+name = "pulp-wasm-simd-flag"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d8f70e07b9c3962945a74e59ca1c511bba65b6419468acc217c457d93f3c740"
+
+[[package]]
+name = "py_literal"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "102df7a3d46db9d3891f178dcc826dc270a6746277a9ae6436f8d29fd490a8e1"
+dependencies = [
+ "num-bigint",
+ "num-complex",
+ "num-traits",
+ "pest",
+ "pest_derive",
+]
+
+[[package]]
+name = "pyo3"
+version = "0.27.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab53c047fcd1a1d2a8820fe84f05d6be69e9526be40cb03b73f86b6b03e6d87d"
+dependencies = [
+ "indoc",
+ "libc",
+ "memoffset",
+ "once_cell",
+ "portable-atomic",
+ "pyo3-build-config",
+ "pyo3-ffi",
+ "pyo3-macros",
+ "unindent",
+]
+
+[[package]]
+name = "pyo3-build-config"
+version = "0.27.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b455933107de8642b4487ed26d912c2d899dec6114884214a0b3bb3be9261ea6"
+dependencies = [
+ "target-lexicon",
+]
+
+[[package]]
+name = "pyo3-ffi"
+version = "0.27.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1c85c9cbfaddf651b1221594209aed57e9e5cff63c4d11d1feead529b872a089"
+dependencies = [
+ "libc",
+ "pyo3-build-config",
+]
+
+[[package]]
+name = "pyo3-macros"
+version = "0.27.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0a5b10c9bf9888125d917fb4d2ca2d25c8df94c7ab5a52e13313a07e050a3b02"
+dependencies = [
+ "proc-macro2",
+ "pyo3-macros-backend",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "pyo3-macros-backend"
+version = "0.27.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "03b51720d314836e53327f5871d4c0cfb4fb37cc2c4a11cc71907a86342c40f9"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "pyo3-build-config",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "qd"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "15f1304a5aecdcfe9ee72fbba90aa37b3aa067a69d14cb7f3d9deada0be7c07c"
+dependencies = [
+ "bytemuck",
+ "libm",
+ "num-traits",
+ "pulp",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
+name = "rand"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
+dependencies = [
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "rand"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
+dependencies = [
+ "rand_chacha",
+ "rand_core 0.9.5",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
+dependencies = [
+ "ppv-lite86",
+ "rand_core 0.9.5",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+
+[[package]]
+name = "rand_core"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
+dependencies = [
+ "getrandom",
+]
+
+[[package]]
+name = "rand_distr"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463"
+dependencies = [
+ "num-traits",
+ "rand 0.9.5",
+]
+
+[[package]]
+name = "raw-cpuid"
+version = "11.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "rawpointer"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
+
+[[package]]
+name = "rayon"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
+dependencies = [
+ "either",
+ "rayon-core",
+]
+
+[[package]]
+name = "rayon-core"
+version = "1.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
+dependencies = [
+ "crossbeam-deque",
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "reborrow"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430"
+
+[[package]]
+name = "regex-automata"
+version = "0.4.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
+
+[[package]]
+name = "rustc-hash"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
+
+[[package]]
+name = "rustversion"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
+
+[[package]]
+name = "safe_arch"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f7caad094bd561859bcd467734a720c3c1f5d1f338995351fefe2190c45efed"
+dependencies = [
+ "bytemuck",
+]
+
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "scoped-tls"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
+
+[[package]]
+name = "seq-macro"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc"
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.150"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "sharded-slab"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
+dependencies = [
+ "lazy_static",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "simba"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f45c644a9f3a386f9288625d9f0c1e999e1acf07a37df35d0516c7f199d9cb2"
+dependencies = [
+ "approx",
+ "num-complex",
+ "num-traits",
+ "wide",
+]
+
+[[package]]
+name = "smallvec"
+version = "1.15.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
+
+[[package]]
+name = "spindle"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "673aaca3d8aa5387a6eba861fbf984af5348d9df5d940c25c6366b19556fdf64"
+dependencies = [
+ "atomic-wait",
+ "crossbeam",
+ "equator 0.4.2",
+ "loom",
+ "rayon",
+]
+
+[[package]]
+name = "syn"
+version = "1.0.109"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.118"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "sysctl"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "01198a2debb237c62b6826ec7081082d951f46dbb64b0e8c7649a452230d1dfc"
+dependencies = [
+ "bitflags",
+ "byteorder",
+ "enum-as-inner",
+ "libc",
+ "thiserror 1.0.69",
+ "walkdir",
+]
+
+[[package]]
+name = "target-lexicon"
+version = "0.13.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
+
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl 1.0.69",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
+dependencies = [
+ "thiserror-impl 2.0.18",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "thread_local"
+version = "1.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "pin-project-lite",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+ "valuable",
+]
+
+[[package]]
+name = "tracing-log"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
+dependencies = [
+ "log",
+ "once_cell",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-subscriber"
+version = "0.3.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
+dependencies = [
+ "matchers",
+ "nu-ansi-term",
+ "once_cell",
+ "regex-automata",
+ "sharded-slab",
+ "smallvec",
+ "thread_local",
+ "tracing",
+ "tracing-core",
+ "tracing-log",
+]
+
+[[package]]
+name = "typenum"
+version = "1.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+
+[[package]]
+name = "ucd-trie"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "unindent"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3"
+
+[[package]]
+name = "valuable"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "walkdir"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
+[[package]]
+name = "wasip2"
+version = "1.0.4+wasi-0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
+dependencies = [
+ "wit-bindgen",
+]
+
+[[package]]
+name = "wide"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dfdfe6a32973f2d1b268b8895845a8a96cac2f0191e72c27cc929036060dbf89"
+dependencies = [
+ "bytemuck",
+ "safe_arch",
+]
+
+[[package]]
+name = "winapi-util"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-result"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.42.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a3e1820f08b8513f676f7ab6c1f99ff312fb97b553d30ff4dd86f9f15728aa7"
+dependencies = [
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
+
+[[package]]
+name = "wit-bindgen"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+
+[[package]]
+name = "zerocopy"
+version = "0.8.54"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.54"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
diff --git a/rust/nmag_accel/Cargo.toml b/rust/nmag_accel/Cargo.toml
new file mode 100644
index 0000000..1385c4b
--- /dev/null
+++ b/rust/nmag_accel/Cargo.toml
@@ -0,0 +1,16 @@
+[package]
+name = "nmag-accel"
+version = "0.1.0"
+edition = "2021"
+
+[lib]
+name = "nmag_accel"
+crate-type = ["cdylib"]
+
+[dependencies]
+diffsol = "0.16"
+faer = { version = "0.24", default-features = false, features = ["std"] }
+ndarray = "0.17"
+numpy = "0.27"
+pyo3 = "0.27"
+rayon = "1.11"
diff --git a/rust/nmag_accel/pyproject.toml b/rust/nmag_accel/pyproject.toml
new file mode 100644
index 0000000..53b062b
--- /dev/null
+++ b/rust/nmag_accel/pyproject.toml
@@ -0,0 +1,13 @@
+[build-system]
+requires = ["maturin>=1.14,<2"]
+build-backend = "maturin"
+
+[project]
+name = "nmag-accel"
+version = "0.1.0"
+requires-python = ">=3.10"
+description = "Optional Rust accelerator kernels for nmag-python-3"
+
+[tool.maturin]
+module-name = "nmag_accel"
+features = ["pyo3/extension-module"]
diff --git a/rust/nmag_accel/src/common.rs b/rust/nmag_accel/src/common.rs
new file mode 100644
index 0000000..799907a
--- /dev/null
+++ b/rust/nmag_accel/src/common.rs
@@ -0,0 +1,184 @@
+use ndarray::{ArrayView1, ArrayView2};
+use pyo3::exceptions::PyValueError;
+use pyo3::prelude::PyResult;
+
+pub(crate) type Vec3 = [f64; 3];
+
+pub(crate) const PARALLEL_MIN_ITEMS: usize = 2_048;
+
+pub(crate) fn should_parallelise(item_count: usize) -> bool {
+ item_count >= PARALLEL_MIN_ITEMS
+}
+
+pub(crate) fn checked_index(value: i64, length: usize, label: &str) -> PyResult {
+ if value < 0 || value as usize >= length {
+ return Err(PyValueError::new_err(format!(
+ "{label} index {value} is out of bounds for {length} points"
+ )));
+ }
+ Ok(value as usize)
+}
+
+pub(crate) fn checked_indices(
+ values: ArrayView1<'_, i64>,
+ length: usize,
+ label: &str,
+) -> PyResult> {
+ values
+ .iter()
+ .map(|value| checked_index(*value, length, label))
+ .collect()
+}
+
+pub(crate) fn point3(points: ArrayView2<'_, f64>, index: usize) -> Vec3 {
+ [points[[index, 0]], points[[index, 1]], points[[index, 2]]]
+}
+
+pub(crate) fn subtract3(left: Vec3, right: Vec3) -> Vec3 {
+ [left[0] - right[0], left[1] - right[1], left[2] - right[2]]
+}
+
+pub(crate) fn scale3(vector: Vec3, scale: f64) -> Vec3 {
+ [vector[0] * scale, vector[1] * scale, vector[2] * scale]
+}
+
+pub(crate) fn average3(vectors: [Vec3; 3]) -> Vec3 {
+ scale3(
+ [
+ vectors[0][0] + vectors[1][0] + vectors[2][0],
+ vectors[0][1] + vectors[1][1] + vectors[2][1],
+ vectors[0][2] + vectors[1][2] + vectors[2][2],
+ ],
+ 1.0 / 3.0,
+ )
+}
+
+pub(crate) fn average4(vectors: [Vec3; 4]) -> Vec3 {
+ scale3(
+ [
+ vectors[0][0] + vectors[1][0] + vectors[2][0] + vectors[3][0],
+ vectors[0][1] + vectors[1][1] + vectors[2][1] + vectors[3][1],
+ vectors[0][2] + vectors[1][2] + vectors[2][2] + vectors[3][2],
+ ],
+ 0.25,
+ )
+}
+
+pub(crate) fn dot3(left: Vec3, right: Vec3) -> f64 {
+ left[0] * right[0] + left[1] * right[1] + left[2] * right[2]
+}
+
+pub(crate) fn cross3(left: Vec3, right: Vec3) -> Vec3 {
+ [
+ left[1] * right[2] - left[2] * right[1],
+ left[2] * right[0] - left[0] * right[2],
+ left[0] * right[1] - left[1] * right[0],
+ ]
+}
+
+pub(crate) fn columns3(column0: Vec3, column1: Vec3, column2: Vec3) -> [[f64; 3]; 3] {
+ [
+ [column0[0], column1[0], column2[0]],
+ [column0[1], column1[1], column2[1]],
+ [column0[2], column1[2], column2[2]],
+ ]
+}
+
+pub(crate) fn inverse3(matrix: [[f64; 3]; 3]) -> Option<[[f64; 3]; 3]> {
+ let a = matrix[0][0];
+ let b = matrix[0][1];
+ let c = matrix[0][2];
+ let d = matrix[1][0];
+ let e = matrix[1][1];
+ let f = matrix[1][2];
+ let g = matrix[2][0];
+ let h = matrix[2][1];
+ let i = matrix[2][2];
+
+ let cofactor00 = e * i - f * h;
+ let cofactor01 = -(d * i - f * g);
+ let cofactor02 = d * h - e * g;
+ let cofactor10 = -(b * i - c * h);
+ let cofactor11 = a * i - c * g;
+ let cofactor12 = -(a * h - b * g);
+ let cofactor20 = b * f - c * e;
+ let cofactor21 = -(a * f - c * d);
+ let cofactor22 = a * e - b * d;
+ let determinant = a * cofactor00 + b * cofactor01 + c * cofactor02;
+ if determinant == 0.0 || !determinant.is_finite() {
+ return None;
+ }
+ let inv_det = 1.0 / determinant;
+ Some([
+ [
+ cofactor00 * inv_det,
+ cofactor10 * inv_det,
+ cofactor20 * inv_det,
+ ],
+ [
+ cofactor01 * inv_det,
+ cofactor11 * inv_det,
+ cofactor21 * inv_det,
+ ],
+ [
+ cofactor02 * inv_det,
+ cofactor12 * inv_det,
+ cofactor22 * inv_det,
+ ],
+ ])
+}
+
+pub(crate) fn determinant3(matrix: [[f64; 3]; 3]) -> f64 {
+ let a = matrix[0][0];
+ let b = matrix[0][1];
+ let c = matrix[0][2];
+ let d = matrix[1][0];
+ let e = matrix[1][1];
+ let f = matrix[1][2];
+ let g = matrix[2][0];
+ let h = matrix[2][1];
+ let i = matrix[2][2];
+ a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g)
+}
+
+pub(crate) fn bounds3(tetrahedron: [Vec3; 4], op: fn(f64, f64) -> f64) -> Vec3 {
+ [
+ op(
+ op(tetrahedron[0][0], tetrahedron[1][0]),
+ op(tetrahedron[2][0], tetrahedron[3][0]),
+ ),
+ op(
+ op(tetrahedron[0][1], tetrahedron[1][1]),
+ op(tetrahedron[2][1], tetrahedron[3][1]),
+ ),
+ op(
+ op(tetrahedron[0][2], tetrahedron[1][2]),
+ op(tetrahedron[2][2], tetrahedron[3][2]),
+ ),
+ ]
+}
+
+pub(crate) fn norm3(vector: Vec3) -> f64 {
+ dot3(vector, vector).sqrt()
+}
+
+pub(crate) fn normalised3(vector: Vec3) -> Vec3 {
+ let length = norm3(vector);
+ if length == 0.0 {
+ [0.0, 0.0, 0.0]
+ } else {
+ scale3(vector, 1.0 / length)
+ }
+}
+
+pub(crate) fn safe_log_ratio(numerator: f64, denominator: f64) -> f64 {
+ if denominator <= 0.0 {
+ f64::NAN
+ } else {
+ (numerator / denominator).ln()
+ }
+}
+
+#[cfg(test)]
+#[path = "../tests/unit/common.rs"]
+mod tests;
diff --git a/rust/nmag_accel/src/dynamics_bdf.rs b/rust/nmag_accel/src/dynamics_bdf.rs
new file mode 100644
index 0000000..1b17358
--- /dev/null
+++ b/rust/nmag_accel/src/dynamics_bdf.rs
@@ -0,0 +1,364 @@
+use std::sync::{
+ atomic::{AtomicUsize, Ordering},
+ Arc,
+};
+
+use diffsol::{NalgebraLU, NalgebraMat, OdeBuilder, OdeSolverMethod, Vector, VectorHost};
+use numpy::{PyReadonlyArray1, PyReadonlyArray2, PyUntypedArrayMethods};
+use pyo3::exceptions::{PyRuntimeError, PyValueError};
+use pyo3::prelude::*;
+
+#[pyclass(get_all)]
+pub(crate) struct BdfIntegrationResult {
+ state: Vec,
+ reached_time: f64,
+ accepted_steps: usize,
+ rejected_steps: usize,
+ rhs_evaluations: usize,
+ jacobian_vector_evaluations: usize,
+ nonlinear_iterations: usize,
+ nonlinear_failures: usize,
+ linear_solver_setups: usize,
+ last_step: f64,
+ max_dm_dt: f64,
+ converged: bool,
+}
+
+#[derive(Clone, Copy)]
+struct LlgCoefficients {
+ precession: f64,
+ damping: f64,
+ normalisation: f64,
+}
+
+struct AffineLlg {
+ operator: Vec,
+ constant_field: Vec,
+ pin: Vec,
+ coefficients: LlgCoefficients,
+ state_size: usize,
+ time_scale_seconds: f64,
+}
+
+impl AffineLlg {
+ fn field(&self, state: &[f64], output: &mut [f64]) {
+ for (row, value) in output.iter_mut().enumerate() {
+ let offset = row * self.state_size;
+ *value = self.constant_field[row]
+ + self.operator[offset..offset + self.state_size]
+ .iter()
+ .zip(state)
+ .map(|(coefficient, state_value)| coefficient * state_value)
+ .sum::();
+ }
+ }
+
+ fn rhs(&self, state: &[f64], output: &mut [f64]) {
+ let mut field = vec![0.0; self.state_size];
+ self.field(state, &mut field);
+ for point in 0..self.pin.len() {
+ let offset = point * 3;
+ let m = &state[offset..offset + 3];
+ let h = &field[offset..offset + 3];
+ let mxh = [
+ m[1] * h[2] - m[2] * h[1],
+ m[2] * h[0] - m[0] * h[2],
+ m[0] * h[1] - m[1] * h[0],
+ ];
+ let mdoth = m
+ .iter()
+ .zip(h)
+ .map(|(left, right)| left * right)
+ .sum::();
+ let mdotm = m.iter().map(|value| value * value).sum::();
+ for component in 0..3 {
+ let damping = m[component] * mdoth - h[component] * mdotm;
+ let normalisation = (1.0 - mdotm) * m[component];
+ output[offset + component] = self.pin[point]
+ * self.time_scale_seconds
+ * (self.coefficients.precession * mxh[component]
+ + self.coefficients.damping * damping
+ + self.coefficients.normalisation * normalisation);
+ }
+ }
+ }
+
+ fn jacobian_vector(&self, state: &[f64], vector: &[f64], output: &mut [f64]) {
+ let mut field = vec![0.0; self.state_size];
+ let mut field_direction = vec![0.0; self.state_size];
+ self.field(state, &mut field);
+ self.field(vector, &mut field_direction);
+ for (value, constant) in field_direction.iter_mut().zip(&self.constant_field) {
+ *value -= constant;
+ }
+
+ for point in 0..self.pin.len() {
+ let offset = point * 3;
+ let m = &state[offset..offset + 3];
+ let v = &vector[offset..offset + 3];
+ let h = &field[offset..offset + 3];
+ let dh = &field_direction[offset..offset + 3];
+ let cross_direction = [
+ v[1] * h[2] - v[2] * h[1] + m[1] * dh[2] - m[2] * dh[1],
+ v[2] * h[0] - v[0] * h[2] + m[2] * dh[0] - m[0] * dh[2],
+ v[0] * h[1] - v[1] * h[0] + m[0] * dh[1] - m[1] * dh[0],
+ ];
+ let mdoth = dot(m, h);
+ let v_doth = dot(v, h);
+ let m_dot_dh = dot(m, dh);
+ let mdotm = dot(m, m);
+ let mdotv = dot(m, v);
+ for component in 0..3 {
+ let damping_direction = v[component] * mdoth + m[component] * (v_doth + m_dot_dh)
+ - dh[component] * mdotm
+ - h[component] * (2.0 * mdotv);
+ let normalisation_direction =
+ -2.0 * mdotv * m[component] + (1.0 - mdotm) * v[component];
+ output[offset + component] = self.pin[point]
+ * self.time_scale_seconds
+ * (self.coefficients.precession * cross_direction[component]
+ + self.coefficients.damping * damping_direction
+ + self.coefficients.normalisation * normalisation_direction);
+ }
+ }
+ }
+}
+
+fn dot(left: &[f64], right: &[f64]) -> f64 {
+ left.iter().zip(right).map(|(a, b)| a * b).sum()
+}
+
+fn validate_finite(values: &[f64], name: &str) -> PyResult<()> {
+ if values.iter().any(|value| !value.is_finite()) {
+ return Err(PyValueError::new_err(format!(
+ "{name} must contain only finite values"
+ )));
+ }
+ Ok(())
+}
+
+#[pyfunction]
+#[allow(clippy::too_many_arguments)]
+pub(crate) fn integrate_llg_bdf(
+ initial_state: PyReadonlyArray1<'_, f64>,
+ field_operator: PyReadonlyArray2<'_, f64>,
+ constant_field: PyReadonlyArray1<'_, f64>,
+ pin: PyReadonlyArray1<'_, f64>,
+ precession_coeff: f64,
+ damping_coeff: f64,
+ normalisation_coeff: f64,
+ initial_time: f64,
+ maximum_time: f64,
+ relative_tolerance: f64,
+ absolute_tolerance: f64,
+ initial_step: f64,
+ maximum_step: f64,
+ convergence_threshold: f64,
+ convergence_check_steps: usize,
+ required_successes: usize,
+ maximum_steps: usize,
+) -> PyResult {
+ let initial_state = initial_state.as_slice()?.to_vec();
+ let operator_shape = field_operator.shape();
+ let operator = field_operator.as_slice()?.to_vec();
+ let constant_field = constant_field.as_slice()?.to_vec();
+ let pin = pin.as_slice()?.to_vec();
+ let state_size = initial_state.len();
+
+ if state_size == 0 || !state_size.is_multiple_of(3) {
+ return Err(PyValueError::new_err(
+ "initial_state length must be a positive multiple of three",
+ ));
+ }
+ if operator_shape != [state_size, state_size] {
+ return Err(PyValueError::new_err(format!(
+ "field_operator must have shape ({state_size}, {state_size})"
+ )));
+ }
+ if constant_field.len() != state_size {
+ return Err(PyValueError::new_err(format!(
+ "constant_field must contain {state_size} values"
+ )));
+ }
+ if pin.len() * 3 != state_size {
+ return Err(PyValueError::new_err(
+ "pin must contain one value per mesh point",
+ ));
+ }
+ for (value, name) in [
+ (relative_tolerance, "relative_tolerance"),
+ (absolute_tolerance, "absolute_tolerance"),
+ (initial_step, "initial_step"),
+ (maximum_step, "maximum_step"),
+ (convergence_threshold, "convergence_threshold"),
+ ] {
+ if !value.is_finite() || value <= 0.0 {
+ return Err(PyValueError::new_err(format!(
+ "{name} must be positive and finite"
+ )));
+ }
+ }
+ if !initial_time.is_finite() || !maximum_time.is_finite() || maximum_time <= initial_time {
+ return Err(PyValueError::new_err(
+ "maximum_time must be finite and greater than initial_time",
+ ));
+ }
+ if convergence_check_steps == 0 || required_successes == 0 || maximum_steps == 0 {
+ return Err(PyValueError::new_err(
+ "step and convergence counts must be positive",
+ ));
+ }
+ validate_finite(&initial_state, "initial_state")?;
+ validate_finite(&operator, "field_operator")?;
+ validate_finite(&constant_field, "constant_field")?;
+ validate_finite(&pin, "pin")?;
+ validate_finite(
+ &[precession_coeff, damping_coeff, normalisation_coeff],
+ "LLG coefficients",
+ )?;
+
+ let system = Arc::new(AffineLlg {
+ operator,
+ constant_field,
+ pin,
+ coefficients: LlgCoefficients {
+ precession: precession_coeff,
+ damping: damping_coeff,
+ normalisation: normalisation_coeff,
+ },
+ state_size,
+ time_scale_seconds: 1.0e-12,
+ });
+ let rhs_system = Arc::clone(&system);
+ let jacobian_system = Arc::clone(&system);
+ let rhs_evaluations = Arc::new(AtomicUsize::new(0));
+ let jacobian_evaluations = Arc::new(AtomicUsize::new(0));
+ let rhs_counter = Arc::clone(&rhs_evaluations);
+ let jacobian_counter = Arc::clone(&jacobian_evaluations);
+ let initial_state_for_builder = initial_state.clone();
+
+ let time_scale_seconds = system.time_scale_seconds;
+ let scaled_maximum_time = (maximum_time - initial_time) / time_scale_seconds;
+ let problem = OdeBuilder::>::new()
+ .t0(0.0)
+ .h0(initial_step / time_scale_seconds)
+ .rtol(relative_tolerance)
+ .atol([absolute_tolerance])
+ .rhs_implicit(
+ move |state, _parameters, _time, output| {
+ rhs_counter.fetch_add(1, Ordering::Relaxed);
+ rhs_system.rhs(state.as_slice(), output.as_mut_slice());
+ },
+ move |state, _parameters, _time, vector, output| {
+ jacobian_counter.fetch_add(1, Ordering::Relaxed);
+ jacobian_system.jacobian_vector(
+ state.as_slice(),
+ vector.as_slice(),
+ output.as_mut_slice(),
+ );
+ },
+ )
+ .init(
+ move |_parameters, _time, output| {
+ output
+ .as_mut_slice()
+ .copy_from_slice(&initial_state_for_builder);
+ },
+ state_size,
+ )
+ .build()
+ .map_err(|error| {
+ PyRuntimeError::new_err(format!("could not build BDF problem: {error}"))
+ })?;
+ let mut solver = problem
+ .bdf::>()
+ .map_err(|error| PyRuntimeError::new_err(format!("could not initialise BDF: {error}")))?;
+ let mut check_state = initial_state;
+ let mut check_time = 0.0;
+ let mut consecutive_successes = 0;
+ let mut max_dm_dt = f64::INFINITY;
+ let mut last_step = 0.0;
+ let mut converged = false;
+ let scaled_maximum_step = maximum_step / time_scale_seconds;
+
+ while solver.get_statistics().number_of_steps < maximum_steps {
+ let previous_time = solver.state().t;
+ if previous_time >= scaled_maximum_time {
+ break;
+ }
+ solver
+ .set_stop_time((previous_time + scaled_maximum_step).min(scaled_maximum_time))
+ .map_err(|error| {
+ PyRuntimeError::new_err(format!("could not set BDF step ceiling: {error}"))
+ })?;
+ solver
+ .step()
+ .map_err(|error| PyRuntimeError::new_err(format!("BDF integration failed: {error}")))?;
+ let state = solver.state();
+ last_step = state.t - previous_time;
+ if state.y.as_slice().iter().any(|value| !value.is_finite()) {
+ return Err(PyRuntimeError::new_err("BDF accepted a non-finite state"));
+ }
+
+ let accepted_steps = solver.get_statistics().number_of_steps;
+ if accepted_steps.is_multiple_of(convergence_check_steps) {
+ let elapsed = state.t - check_time;
+ max_dm_dt = state
+ .y
+ .as_slice()
+ .chunks_exact(3)
+ .zip(check_state.chunks_exact(3))
+ .map(|(current, previous)| {
+ current
+ .iter()
+ .zip(previous)
+ .map(|(left, right)| (left - right).powi(2))
+ .sum::()
+ .sqrt()
+ / (elapsed * time_scale_seconds)
+ })
+ .fold(0.0, f64::max);
+ if max_dm_dt < convergence_threshold {
+ consecutive_successes += 1;
+ if consecutive_successes >= required_successes {
+ converged = true;
+ break;
+ }
+ } else {
+ consecutive_successes = 0;
+ }
+ check_state = state.y.clone_as_vec();
+ check_time = state.t;
+ }
+
+ if state.t >= scaled_maximum_time {
+ break;
+ }
+ }
+
+ let state = solver.state();
+ let statistics = solver.get_statistics();
+ if statistics.number_of_steps >= maximum_steps && !converged && state.t < scaled_maximum_time {
+ return Err(PyRuntimeError::new_err(format!(
+ "BDF reached the maximum of {maximum_steps} accepted steps"
+ )));
+ }
+ Ok(BdfIntegrationResult {
+ state: state.y.clone_as_vec(),
+ reached_time: initial_time + state.t * time_scale_seconds,
+ accepted_steps: statistics.number_of_steps,
+ rejected_steps: statistics.number_of_error_test_failures,
+ rhs_evaluations: rhs_evaluations.load(Ordering::Relaxed),
+ jacobian_vector_evaluations: jacobian_evaluations.load(Ordering::Relaxed),
+ nonlinear_iterations: statistics.number_of_nonlinear_solver_iterations,
+ nonlinear_failures: statistics.number_of_nonlinear_solver_fails,
+ linear_solver_setups: statistics.number_of_linear_solver_setups,
+ last_step: last_step * time_scale_seconds,
+ max_dm_dt,
+ converged,
+ })
+}
+
+#[cfg(test)]
+#[path = "../tests/unit/dynamics_bdf.rs"]
+mod tests;
diff --git a/rust/nmag_accel/src/fem.rs b/rust/nmag_accel/src/fem.rs
new file mode 100644
index 0000000..87cb3fc
--- /dev/null
+++ b/rust/nmag_accel/src/fem.rs
@@ -0,0 +1,383 @@
+use ndarray::{Array1, Array2, Array3, ArrayView2};
+use numpy::{
+ IntoPyArray, PyArray1, PyArray2, PyArray3, PyReadonlyArray1, PyReadonlyArray2, PyReadonlyArray3,
+};
+use pyo3::exceptions::PyValueError;
+use pyo3::prelude::*;
+use rayon::prelude::*;
+
+use crate::common::{
+ checked_index, columns3, determinant3, dot3, inverse3, point3, should_parallelise, subtract3,
+ Vec3,
+};
+
+pub(crate) type FemGeometryResult<'py> = PyResult<(
+ Bound<'py, PyArray2>,
+ Bound<'py, PyArray3>,
+ Bound<'py, PyArray1>,
+)>;
+
+pub(crate) struct FemCellGeometry {
+ simplex: [usize; 4],
+ gradients: [Vec3; 4],
+ volume: f64,
+}
+
+#[pyfunction]
+pub(crate) fn build_demag_fem_geometry<'py>(
+ py: Python<'py>,
+ points: PyReadonlyArray2<'py, f64>,
+ simplices: PyReadonlyArray2<'py, i64>,
+) -> FemGeometryResult<'py> {
+ let points = points.as_array();
+ let simplices = simplices.as_array();
+
+ if points.ndim() != 2 || points.shape()[1] != 3 {
+ return Err(PyValueError::new_err("points must have shape (n, 3)"));
+ }
+ if simplices.ndim() != 2 || simplices.shape()[1] != 4 {
+ return Err(PyValueError::new_err("simplices must have shape (n, 4)"));
+ }
+
+ let point_count = points.shape()[0];
+ let cell_count = simplices.shape()[0];
+ let mut stiffness = Array2::::zeros((point_count, point_count));
+ let mut gradients_by_cell = Array3::::zeros((cell_count, 4, 3));
+ let mut volumes = vec![0.0; cell_count];
+
+ let mut simplex_indices: Vec<[usize; 4]> = Vec::with_capacity(cell_count);
+ for cell_index in 0..cell_count {
+ simplex_indices.push([
+ checked_index(simplices[[cell_index, 0]], point_count, "simplex node")?,
+ checked_index(simplices[[cell_index, 1]], point_count, "simplex node")?,
+ checked_index(simplices[[cell_index, 2]], point_count, "simplex node")?,
+ checked_index(simplices[[cell_index, 3]], point_count, "simplex node")?,
+ ]);
+ }
+
+ let cell_geometries: Vec