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> = simplex_indices + .par_iter() + .map(|simplex| fem_cell_geometry(points, *simplex)) + .collect(); + + for (cell_index, cell_geometry) in cell_geometries.into_iter().enumerate() { + let Some(cell_geometry) = cell_geometry else { + continue; + }; + volumes[cell_index] = cell_geometry.volume; + for local_index in 0..4 { + for component in 0..3 { + gradients_by_cell[[cell_index, local_index, component]] = + cell_geometry.gradients[local_index][component]; + } + } + for local_row in 0..4 { + let global_row = cell_geometry.simplex[local_row]; + for local_column in 0..4 { + let global_column = cell_geometry.simplex[local_column]; + stiffness[[global_row, global_column]] += cell_geometry.volume + * dot3( + cell_geometry.gradients[local_row], + cell_geometry.gradients[local_column], + ); + } + } + } + + Ok(( + stiffness.into_pyarray(py), + gradients_by_cell.into_pyarray(py), + Array1::from_vec(volumes).into_pyarray(py), + )) +} + +#[pyfunction] +pub(crate) fn build_demag_fem_divergence<'py>( + py: Python<'py>, + simplices: PyReadonlyArray2<'py, i64>, + gradients_by_cell: PyReadonlyArray3<'py, f64>, + volumes: PyReadonlyArray1<'py, f64>, + m: PyReadonlyArray2<'py, f64>, + ms_values: PyReadonlyArray1<'py, f64>, + point_count: usize, +) -> PyResult>> { + let simplices = simplices.as_array(); + let gradients_by_cell = gradients_by_cell.as_array(); + let volumes = volumes.as_array(); + let m = m.as_array(); + let ms_values = ms_values.as_array(); + + if simplices.ndim() != 2 || simplices.shape()[1] != 4 { + return Err(PyValueError::new_err("simplices must have shape (n, 4)")); + } + if gradients_by_cell.ndim() != 3 + || gradients_by_cell.shape()[0] != simplices.shape()[0] + || gradients_by_cell.shape()[1] != 4 + || gradients_by_cell.shape()[2] != 3 + { + return Err(PyValueError::new_err( + "gradients_by_cell must have shape (n, 4, 3)", + )); + } + if volumes.len() != simplices.shape()[0] { + return Err(PyValueError::new_err( + "volumes must contain one value per simplex", + )); + } + if ms_values.len() != simplices.shape()[0] { + return Err(PyValueError::new_err( + "ms_values must contain one value per simplex", + )); + } + if m.ndim() != 2 || m.shape()[0] != point_count || m.shape()[1] != 3 { + return Err(PyValueError::new_err("m must have shape (point_count, 3)")); + } + + let cell_count = simplices.shape()[0]; + 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_contribution = |cell_index: usize| { + let simplex = simplex_indices[cell_index]; + let ms = ms_values[cell_index]; + let mut average_m = [0.0; 3]; + for point_index in simplex { + for component in 0..3 { + average_m[component] += m[[point_index, component]] * ms; + } + } + for component in &mut average_m { + *component *= 0.25; + } + + let mut contributions = [0.0; 4]; + for local_index in 0..4 { + let gradient = [ + gradients_by_cell[[cell_index, local_index, 0]], + gradients_by_cell[[cell_index, local_index, 1]], + gradients_by_cell[[cell_index, local_index, 2]], + ]; + contributions[local_index] = volumes[cell_index] * dot3(gradient, average_m); + } + (simplex, contributions) + }; + let cell_contributions: Vec<([usize; 4], [f64; 4])> = if should_parallelise(cell_count) { + (0..cell_count) + .into_par_iter() + .map(cell_contribution) + .collect() + } else { + (0..cell_count).map(cell_contribution).collect() + }; + + let mut divergence = vec![0.0; point_count]; + for (simplex, contributions) in cell_contributions { + for local_index in 0..4 { + divergence[simplex[local_index]] += contributions[local_index]; + } + } + + Ok(Array1::from_vec(divergence).into_pyarray(py)) +} + +#[pyfunction] +pub(crate) fn recover_demag_nodal_field<'py>( + py: Python<'py>, + simplices: PyReadonlyArray2<'py, i64>, + volumes: PyReadonlyArray1<'py, f64>, + cell_h: PyReadonlyArray2<'py, f64>, + weights: PyReadonlyArray1<'py, f64>, + point_count: usize, +) -> PyResult>> { + let simplices = simplices.as_array(); + let volumes = volumes.as_array(); + let cell_h = cell_h.as_array(); + let weights = weights.as_array(); + + if simplices.ndim() != 2 || simplices.shape()[1] != 4 { + return Err(PyValueError::new_err("simplices must have shape (n, 4)")); + } + if volumes.len() != simplices.shape()[0] { + return Err(PyValueError::new_err( + "volumes must contain one value per simplex", + )); + } + if cell_h.ndim() != 2 || cell_h.shape()[0] != simplices.shape()[0] || cell_h.shape()[1] != 3 { + return Err(PyValueError::new_err("cell_h must have shape (n, 3)")); + } + if weights.len() != point_count { + return Err(PyValueError::new_err( + "weights must contain one value per point", + )); + } + + let cell_count = simplices.shape()[0]; + 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_contribution = |cell_index: usize| { + let volume = volumes[cell_index]; + if volume <= 0.0 { + return None; + } + Some(( + simplex_indices[cell_index], + [ + cell_h[[cell_index, 0]] * volume, + cell_h[[cell_index, 1]] * volume, + cell_h[[cell_index, 2]] * volume, + ], + )) + }; + let cell_contributions: Vec> = if should_parallelise(cell_count) { + (0..cell_count) + .into_par_iter() + .map(cell_contribution) + .collect() + } else { + (0..cell_count).map(cell_contribution).collect() + }; + + let mut nodal_h = vec![0.0; point_count * 3]; + for local_index in 0..4 { + for contribution in &cell_contributions { + let Some((simplex, weighted_h)) = contribution else { + continue; + }; + let point_index = simplex[local_index]; + let base = point_index * 3; + for component in 0..3 { + nodal_h[base + component] += weighted_h[component]; + } + } + } + + for point_index in 0..point_count { + let weight = weights[point_index]; + if weight > 0.0 { + let base = point_index * 3; + for component in 0..3 { + nodal_h[base + component] /= weight; + } + } + } + + let nodal_array = Array2::from_shape_vec((point_count, 3), nodal_h) + .map_err(|err| PyValueError::new_err(format!("could not shape nodal field: {err}")))?; + Ok(nodal_array.into_pyarray(py)) +} + +#[pyfunction] +pub(crate) fn demag_cell_field_average<'py>( + py: Python<'py>, + cell_h: PyReadonlyArray2<'py, f64>, + volumes: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let cell_h = cell_h.as_array(); + let volumes = volumes.as_array(); + + if cell_h.ndim() != 2 || cell_h.shape()[1] != 3 { + return Err(PyValueError::new_err("cell_h must have shape (n, 3)")); + } + if volumes.len() != cell_h.shape()[0] { + return Err(PyValueError::new_err( + "volumes must contain one value per cell_h row", + )); + } + + let (weighted_sum, total_volume) = (0..cell_h.shape()[0]) + .into_par_iter() + .filter_map(|cell_index| { + let volume = volumes[cell_index]; + if volume <= 0.0 { + return None; + } + Some(( + [ + cell_h[[cell_index, 0]] * volume, + cell_h[[cell_index, 1]] * volume, + cell_h[[cell_index, 2]] * volume, + ], + volume, + )) + }) + .reduce( + || ([0.0, 0.0, 0.0], 0.0), + |left, right| { + ( + [ + left.0[0] + right.0[0], + left.0[1] + right.0[1], + left.0[2] + right.0[2], + ], + left.1 + right.1, + ) + }, + ); + + let average = if total_volume > 0.0 { + [ + weighted_sum[0] / total_volume, + weighted_sum[1] / total_volume, + weighted_sum[2] / total_volume, + ] + } else { + [0.0, 0.0, 0.0] + }; + Ok(Array1::from_vec(average.to_vec()).into_pyarray(py)) +} + +pub(crate) fn fem_cell_geometry( + points: ArrayView2<'_, f64>, + simplex: [usize; 4], +) -> Option { + let tetrahedron = [ + point3(points, simplex[0]), + point3(points, simplex[1]), + point3(points, simplex[2]), + point3(points, simplex[3]), + ]; + let matrix = columns3( + subtract3(tetrahedron[1], tetrahedron[0]), + subtract3(tetrahedron[2], tetrahedron[0]), + subtract3(tetrahedron[3], tetrahedron[0]), + ); + let determinant = determinant3(matrix); + if determinant == 0.0 || !determinant.is_finite() { + return None; + } + let inverse = inverse3(matrix)?; + let gradients = [ + [ + -(inverse[0][0] + inverse[1][0] + inverse[2][0]), + -(inverse[0][1] + inverse[1][1] + inverse[2][1]), + -(inverse[0][2] + inverse[1][2] + inverse[2][2]), + ], + inverse[0], + inverse[1], + inverse[2], + ]; + Some(FemCellGeometry { + simplex, + gradients, + volume: determinant.abs() / 6.0, + }) +} + +#[cfg(test)] +#[path = "../tests/unit/fem.rs"] +mod tests; diff --git a/rust/nmag_accel/src/lib.rs b/rust/nmag_accel/src/lib.rs new file mode 100644 index 0000000..c966c73 --- /dev/null +++ b/rust/nmag_accel/src/lib.rs @@ -0,0 +1,58 @@ +mod common; +mod dynamics_bdf; +mod fem; +mod lindholm; +mod lindholm_hmatrix; +mod llg_maxangle; +mod mesh_probe; +mod parallel; + +use pyo3::prelude::*; + +const API_VERSION: u32 = 2; + +#[pymodule] +fn nmag_accel(module: &Bound<'_, PyModule>) -> PyResult<()> { + module.add("API_VERSION", API_VERSION)?; + module.add_function(wrap_pyfunction!( + lindholm::build_lindholm_bem_matrix, + module + )?)?; + module.add_function(wrap_pyfunction!( + lindholm::apply_lindholm_bem_matrix_free, + module + )?)?; + module.add_class::()?; + module.add_function(wrap_pyfunction!( + lindholm_hmatrix::build_lindholm_hmatrix, + module + )?)?; + module.add_function(wrap_pyfunction!(fem::build_demag_fem_geometry, module)?)?; + module.add_function(wrap_pyfunction!(fem::build_demag_fem_divergence, module)?)?; + module.add_function(wrap_pyfunction!(fem::recover_demag_nodal_field, module)?)?; + module.add_function(wrap_pyfunction!(fem::demag_cell_field_average, module)?)?; + module.add_function(wrap_pyfunction!( + mesh_probe::build_oriented_boundary_faces, + module + )?)?; + module.add_function(wrap_pyfunction!( + mesh_probe::build_probe_tetrahedral_geometry, + module + )?)?; + module.add_function(wrap_pyfunction!(llg_maxangle::llg_rhs, module)?)?; + module.add_function(wrap_pyfunction!( + llg_maxangle::llg_rhs_heterogeneous, + module + )?)?; + module.add_function(wrap_pyfunction!( + llg_maxangle::llg_rhs_stt_heterogeneous, + module + )?)?; + module.add_function(wrap_pyfunction!(dynamics_bdf::integrate_llg_bdf, module)?)?; + module.add_function(wrap_pyfunction!( + llg_maxangle::maxangle_between_edges, + module + )?)?; + module.add_function(wrap_pyfunction!(parallel::parallel_runtime_info, module)?)?; + Ok(()) +} diff --git a/rust/nmag_accel/src/lindholm.rs b/rust/nmag_accel/src/lindholm.rs new file mode 100644 index 0000000..6e893c4 --- /dev/null +++ b/rust/nmag_accel/src/lindholm.rs @@ -0,0 +1,403 @@ +use ndarray::{Array1, Array2, ArrayView1, ArrayView2}; +use numpy::{IntoPyArray, PyArray1, PyArray2, PyReadonlyArray1, PyReadonlyArray2}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use rayon::prelude::*; +use std::f64::consts::PI; + +use crate::common::{ + checked_index, checked_indices, cross3, dot3, norm3, normalised3, point3, safe_log_ratio, + scale3, should_parallelise, subtract3, Vec3, +}; + +pub(crate) struct FaceGeometry { + pub(crate) points: [Vec3; 3], + pub(crate) local_indices: [usize; 3], + pub(crate) zeta_vector: Vec3, + pub(crate) eta_vectors: [Vec3; 3], + pub(crate) edge_lengths: [f64; 3], + pub(crate) corner_cosines: [f64; 3], + pub(crate) denominator_factor: f64, +} + +#[pyfunction] +pub(crate) fn build_lindholm_bem_matrix<'py>( + py: Python<'py>, + points: PyReadonlyArray2<'py, f64>, + simplices: PyReadonlyArray2<'py, i64>, + face_nodes: PyReadonlyArray2<'py, i64>, + boundary_nodes: PyReadonlyArray1<'py, i64>, + local_index_by_point: PyReadonlyArray1<'py, i64>, +) -> PyResult>> { + let points = points.as_array(); + let simplices = simplices.as_array(); + let face_nodes = face_nodes.as_array(); + let boundary_nodes = boundary_nodes.as_array(); + let local_index_by_point = local_index_by_point.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)")); + } + if face_nodes.ndim() != 2 || face_nodes.shape()[1] != 3 { + return Err(PyValueError::new_err("face_nodes must have shape (n, 3)")); + } + if local_index_by_point.len() != points.shape()[0] { + return Err(PyValueError::new_err( + "local_index_by_point must contain one entry per point", + )); + } + + let boundary_count = boundary_nodes.len(); + let observer_indices = checked_indices(boundary_nodes, points.shape()[0], "boundary node")?; + let face_geometries = + lindholm_face_geometries(points, face_nodes, local_index_by_point, boundary_count)?; + let solid_angles = boundary_node_solid_angles(points, simplices)?; + + // Rayon rejects zero-sized chunks; after all input validation, an empty boundary + // naturally has an empty dense matrix. + if boundary_count == 0 { + return Ok(Array2::::zeros((0, 0)).into_pyarray(py)); + } + + let mut bem_values = vec![0.0; boundary_count * boundary_count]; + bem_values + .par_chunks_mut(boundary_count) + .enumerate() + .for_each(|(row, row_values)| { + let observer_index = observer_indices[row]; + let observer = point3(points, observer_index); + for face in &face_geometries { + if face.denominator_factor == 0.0 { + continue; + } + let contributions = lindholm_triangle_contributions_precomputed(observer, face); + for index in 0..3 { + row_values[face.local_indices[index]] += contributions[index]; + } + } + row_values[row] += solid_angles[observer_index] / (4.0 * PI) - 1.0; + }); + + let bem = Array2::from_shape_vec((boundary_count, boundary_count), bem_values) + .map_err(|err| PyValueError::new_err(format!("could not shape BEM matrix: {err}")))?; + Ok(bem.into_pyarray(py)) +} + +#[pyfunction] +pub(crate) fn apply_lindholm_bem_matrix_free<'py>( + py: Python<'py>, + points: PyReadonlyArray2<'py, f64>, + simplices: PyReadonlyArray2<'py, i64>, + face_nodes: PyReadonlyArray2<'py, i64>, + boundary_nodes: PyReadonlyArray1<'py, i64>, + local_index_by_point: PyReadonlyArray1<'py, i64>, + values: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let points = points.as_array(); + let simplices = simplices.as_array(); + let face_nodes = face_nodes.as_array(); + let boundary_nodes = boundary_nodes.as_array(); + let local_index_by_point = local_index_by_point.as_array(); + let values = values.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)")); + } + if face_nodes.ndim() != 2 || face_nodes.shape()[1] != 3 { + return Err(PyValueError::new_err("face_nodes must have shape (n, 3)")); + } + if local_index_by_point.len() != points.shape()[0] { + return Err(PyValueError::new_err( + "local_index_by_point must contain one entry per point", + )); + } + + let boundary_count = boundary_nodes.len(); + if values.len() != boundary_count { + return Err(PyValueError::new_err(format!( + "values must contain one entry per boundary node ({boundary_count})" + ))); + } + let observer_indices = checked_indices(boundary_nodes, points.shape()[0], "boundary node")?; + let face_geometries = + lindholm_face_geometries(points, face_nodes, local_index_by_point, boundary_count)?; + let solid_angles = boundary_node_solid_angles(points, simplices)?; + let mut result = vec![0.0; boundary_count]; + + let compute_row = |row: usize, output: &mut f64| { + let observer_index = observer_indices[row]; + let observer = point3(points, observer_index); + let mut value = 0.0; + for face in &face_geometries { + if face.denominator_factor == 0.0 { + continue; + } + let contributions = lindholm_triangle_contributions_precomputed(observer, face); + for index in 0..3 { + value += contributions[index] * values[face.local_indices[index]]; + } + } + value += (solid_angles[observer_index] / (4.0 * PI) - 1.0) * values[row]; + *output = value; + }; + let interaction_count = boundary_count.saturating_mul(face_geometries.len()); + if should_parallelise(interaction_count) { + result + .par_iter_mut() + .enumerate() + .for_each(|(row, output)| compute_row(row, output)); + } else { + result + .iter_mut() + .enumerate() + .for_each(|(row, output)| compute_row(row, output)); + } + + Ok(Array1::from_vec(result).into_pyarray(py)) +} + +pub(crate) fn lindholm_face_geometries( + points: ArrayView2<'_, f64>, + face_nodes: ArrayView2<'_, i64>, + local_index_by_point: ArrayView1<'_, i64>, + boundary_count: usize, +) -> PyResult> { + let mut geometries = Vec::with_capacity(face_nodes.shape()[0]); + for face_index in 0..face_nodes.shape()[0] { + let point_indices = [ + checked_index(face_nodes[[face_index, 0]], points.shape()[0], "face node")?, + checked_index(face_nodes[[face_index, 1]], points.shape()[0], "face node")?, + checked_index(face_nodes[[face_index, 2]], points.shape()[0], "face node")?, + ]; + let mut local_indices = [0usize; 3]; + for index in 0..3 { + local_indices[index] = checked_boundary_local_index( + local_index_by_point[point_indices[index]], + boundary_count, + )?; + } + + let face_points = [ + point3(points, point_indices[0]), + point3(points, point_indices[1]), + point3(points, point_indices[2]), + ]; + let edge_vectors = [ + subtract3(face_points[2], face_points[1]), + subtract3(face_points[0], face_points[2]), + subtract3(face_points[1], face_points[0]), + ]; + let xis = [ + normalised3(edge_vectors[0]), + normalised3(edge_vectors[1]), + normalised3(edge_vectors[2]), + ]; + let area_vector = cross3( + subtract3(face_points[1], face_points[0]), + subtract3(face_points[2], face_points[0]), + ); + let double_area = norm3(area_vector); + let zeta_vector = if double_area == 0.0 { + [0.0, 0.0, 0.0] + } else { + scale3(area_vector, 1.0 / double_area) + }; + geometries.push(FaceGeometry { + points: face_points, + local_indices, + zeta_vector, + eta_vectors: [ + cross3(zeta_vector, xis[0]), + cross3(zeta_vector, xis[1]), + cross3(zeta_vector, xis[2]), + ], + edge_lengths: [ + norm3(edge_vectors[0]), + norm3(edge_vectors[1]), + norm3(edge_vectors[2]), + ], + corner_cosines: [ + dot3(xis[0], xis[1]), + dot3(xis[1], xis[2]), + dot3(xis[2], xis[0]), + ], + denominator_factor: if double_area == 0.0 { + 0.0 + } else { + 1.0 / (4.0 * PI * double_area) + }, + }); + } + Ok(geometries) +} + +fn checked_boundary_local_index(value: i64, boundary_count: usize) -> PyResult { + if value < 0 { + return Err(PyValueError::new_err("face node is not a boundary node")); + } + let local_index = usize::try_from(value) + .map_err(|_| PyValueError::new_err("face node has an invalid boundary-node index"))?; + if local_index >= boundary_count { + return Err(PyValueError::new_err(format!( + "face node boundary index {local_index} is out of bounds for {boundary_count} nodes" + ))); + } + Ok(local_index) +} + +pub(crate) fn boundary_node_solid_angles( + points: ArrayView2<'_, f64>, + simplices: ArrayView2<'_, i64>, +) -> PyResult> { + let mut angles = vec![0.0; points.shape()[0]]; + for simplex_index in 0..simplices.shape()[0] { + let simplex = [ + checked_index( + simplices[[simplex_index, 0]], + points.shape()[0], + "simplex node", + )?, + checked_index( + simplices[[simplex_index, 1]], + points.shape()[0], + "simplex node", + )?, + checked_index( + simplices[[simplex_index, 2]], + points.shape()[0], + "simplex node", + )?, + checked_index( + simplices[[simplex_index, 3]], + points.shape()[0], + "simplex node", + )?, + ]; + for local_index in 0..4 { + let observer_index = simplex[local_index]; + let mut other = [0usize; 3]; + let mut cursor = 0; + for candidate in simplex { + if candidate != observer_index { + other[cursor] = candidate; + cursor += 1; + } + } + if cursor != 3 { + return Err(PyValueError::new_err( + "simplex contains duplicate node indices", + )); + } + angles[observer_index] += triangle_space_angle( + point3(points, observer_index), + point3(points, other[0]), + point3(points, other[1]), + point3(points, other[2]), + ) + .abs(); + } + } + Ok(angles) +} + +pub(crate) fn lindholm_triangle_contributions_precomputed( + observer: Vec3, + face: &FaceGeometry, +) -> Vec3 { + let p0 = face.points[0]; + let p1 = face.points[1]; + let p2 = face.points[2]; + let r0 = subtract3(p0, observer); + let r1 = subtract3(p1, observer); + let r2 = subtract3(p2, observer); + if face.denominator_factor == 0.0 { + return [0.0, 0.0, 0.0]; + } + + let r0_len = norm3(r0); + let r1_len = norm3(r1); + let r2_len = norm3(r2); + let dot01 = dot3(r0, r1); + let dot12 = dot3(r1, r2); + let dot20 = dot3(r2, r0); + let angle = triangle_space_angle_from_offsets(r0_len, r1_len, r2_len, dot01, dot12, dot20); + let s0_len = face.edge_lengths[0]; + let s1_len = face.edge_lengths[1]; + let s2_len = face.edge_lengths[2]; + let c01 = face.corner_cosines[0]; + let c12 = face.corner_cosines[1]; + let c20 = face.corner_cosines[2]; + let zeta = dot3(face.zeta_vector, r0); + let log0 = safe_log_ratio(r1_len + r2_len + s0_len, r1_len + r2_len - s0_len); + let log1 = safe_log_ratio(r2_len + r0_len + s1_len, r2_len + r0_len - s1_len); + let log2 = safe_log_ratio(r0_len + r1_len + s2_len, r0_len + r1_len - s2_len); + let sign_zeta = if zeta < 0.0 { + -1.0 + } else if zeta > 0.0 { + 1.0 + } else { + 0.0 + }; + let angle_pm = angle * sign_zeta; + let gamma0_log = log0 + c01 * log1 + c20 * log2; + let gamma1_log = c01 * log0 + log1 + c12 * log2; + let gamma2_log = c20 * log0 + c12 * log1 + log2; + let mut values = [ + s0_len + * face.denominator_factor + * (angle_pm * dot3(face.eta_vectors[0], r1) - zeta * gamma0_log), + s1_len + * face.denominator_factor + * (angle_pm * dot3(face.eta_vectors[1], r2) - zeta * gamma1_log), + s2_len + * face.denominator_factor + * (angle_pm * dot3(face.eta_vectors[2], r0) - zeta * gamma2_log), + ]; + for value in &mut values { + if !value.is_finite() { + *value = 0.0; + } + } + values +} + +fn triangle_space_angle(observer: Vec3, p0: Vec3, p1: Vec3, p2: Vec3) -> f64 { + let r0 = subtract3(p0, observer); + let r1 = subtract3(p1, observer); + let r2 = subtract3(p2, observer); + let r0_len = norm3(r0); + let r1_len = norm3(r1); + let r2_len = norm3(r2); + let dot01 = dot3(r0, r1); + let dot12 = dot3(r1, r2); + let dot20 = dot3(r2, r0); + triangle_space_angle_from_offsets(r0_len, r1_len, r2_len, dot01, dot12, dot20) +} + +fn triangle_space_angle_from_offsets( + r0_len: f64, + r1_len: f64, + r2_len: f64, + dot01: f64, + dot12: f64, + dot20: f64, +) -> f64 { + let numerator = r0_len * r1_len * r2_len + r0_len * dot12 + r1_len * dot20 + r2_len * dot01; + let denominator_squared = + 2.0 * (r1_len * r2_len + dot12) * (r2_len * r0_len + dot20) * (r0_len * r1_len + dot01); + if denominator_squared <= 0.0 { + return 0.0; + } + let quotient = (numerator / denominator_squared.sqrt()).clamp(-1.0, 1.0); + 2.0 * quotient.acos() +} + +#[cfg(test)] +#[path = "../tests/unit/lindholm.rs"] +mod tests; diff --git a/rust/nmag_accel/src/lindholm_hmatrix/aca.rs b/rust/nmag_accel/src/lindholm_hmatrix/aca.rs new file mode 100644 index 0000000..8614c11 --- /dev/null +++ b/rust/nmag_accel/src/lindholm_hmatrix/aca.rs @@ -0,0 +1,171 @@ +mod pivot; +mod recompression; + +use super::entry::ExactLindholmEvaluator; +use pivot::{ + choose_reference, max_abs_unused, min_abs_unused, residual_column, residual_row, + sampled_residual_pivot, +}; +pub(crate) use recompression::compress_block_exact_svd; +use recompression::recompress; + +pub(crate) struct LowRankFactors { + pub(crate) rank: usize, + pub(crate) u: Vec, + pub(crate) v: Vec, +} + +pub(crate) fn compress_block( + evaluator: &ExactLindholmEvaluator, + rows: &[usize], + columns: &[usize], + tolerance: f64, + max_rank: usize, +) -> Option { + let limit = max_rank.min(rows.len()).min(columns.len()); + if limit == 0 { + return Some(LowRankFactors { + rank: 0, + u: Vec::new(), + v: Vec::new(), + }); + } + let mut u = Vec::with_capacity(rows.len() * limit); + let mut v = Vec::with_capacity(columns.len() * limit); + let mut used_rows = vec![false; rows.len()]; + let mut used_columns = vec![false; columns.len()]; + let mut approximation_norm = 0.0_f64; + + for rank in 0..limit { + // ACA+ reference pivots are augmented with a distributed residual sample + // so disconnected DLP normal directions cannot hide from the search. + let reference_column = choose_reference(&used_columns, rank)?; + let reference_column_values = + residual_column(evaluator, rows, columns, reference_column, &u, &v, rank); + let reference_row = min_abs_unused(&reference_column_values, &used_rows)?; + let reference_row_values = + residual_row(evaluator, rows, columns, reference_row, &u, &v, rank); + let (column_candidate_row, column_candidate_value) = + max_abs_unused(&reference_column_values, &used_rows)?; + let (row_candidate_column, row_candidate_value) = + max_abs_unused(&reference_row_values, &used_columns)?; + let sampled_pivot = sampled_residual_pivot( + evaluator, + rows, + columns, + &u, + &v, + rank, + &used_rows, + &used_columns, + ); + + let (pivot_row, pivot_column, residual_row_values, residual_column_values) = + if sampled_pivot.is_some_and(|(_, _, value)| { + value.abs() > column_candidate_value.abs().max(row_candidate_value.abs()) + }) { + let (sampled_row, _, _) = sampled_pivot?; + let initial_row = residual_row(evaluator, rows, columns, sampled_row, &u, &v, rank); + let (column, _) = max_abs_unused(&initial_row, &used_columns)?; + let column_values = residual_column(evaluator, rows, columns, column, &u, &v, rank); + let (row, _) = max_abs_unused(&column_values, &used_rows)?; + let row_values = residual_row(evaluator, rows, columns, row, &u, &v, rank); + (row, column, row_values, column_values) + } else if column_candidate_value.abs() > row_candidate_value.abs() { + let row_values = + residual_row(evaluator, rows, columns, column_candidate_row, &u, &v, rank); + let (column, _) = max_abs_unused(&row_values, &used_columns)?; + let column_values = residual_column(evaluator, rows, columns, column, &u, &v, rank); + (column_candidate_row, column, row_values, column_values) + } else { + let column_values = + residual_column(evaluator, rows, columns, row_candidate_column, &u, &v, rank); + let (row, _) = max_abs_unused(&column_values, &used_rows)?; + let row_values = residual_row(evaluator, rows, columns, row, &u, &v, rank); + (row, row_candidate_column, row_values, column_values) + }; + + let pivot = residual_column_values[pivot_row]; + if pivot.abs() <= f64::EPSILON { + break; + } + let scaled_row = residual_row_values + .iter() + .map(|value| value / pivot) + .collect::>(); + u.extend_from_slice(&residual_column_values); + v.extend_from_slice(&scaled_row); + used_rows[pivot_row] = true; + used_columns[pivot_column] = true; + + let term_norm = l2_norm(&residual_column_values) * l2_norm(&scaled_row); + approximation_norm = approximation_norm.hypot(term_norm); + if rank > 0 && term_norm <= tolerance * approximation_norm { + let factors = recompress( + LowRankFactors { + rank: rank + 1, + u, + v, + }, + rows.len(), + columns.len(), + tolerance, + )?; + return validate_block(evaluator, rows, columns, &factors, tolerance) + .then_some(factors); + } + } + + let factors = recompress( + LowRankFactors { + rank: u.len() / rows.len(), + u, + v, + }, + rows.len(), + columns.len(), + tolerance, + )?; + validate_block(evaluator, rows, columns, &factors, tolerance).then_some(factors) +} + +fn validate_block( + evaluator: &ExactLindholmEvaluator, + rows: &[usize], + columns: &[usize], + factors: &LowRankFactors, + tolerance: f64, +) -> bool { + let row_samples = sample_positions(rows.len(), 7); + let column_samples = sample_positions(columns.len(), 7); + let mut exact_norm = 0.0_f64; + let mut error_norm = 0.0_f64; + for row in row_samples { + for column in &column_samples { + let exact = evaluator.entry(rows[row], columns[*column]); + let approximate = (0..factors.rank) + .map(|rank| { + factors.u[rank * rows.len() + row] * factors.v[rank * columns.len() + *column] + }) + .sum::(); + exact_norm = exact_norm.hypot(exact); + error_norm = error_norm.hypot(exact - approximate); + } + } + error_norm <= tolerance * exact_norm.max(1.0e-30) +} + +fn sample_positions(length: usize, count: usize) -> Vec { + if length <= count { + return (0..length).collect(); + } + (0..count) + .map(|index| index * (length - 1) / (count - 1)) + .collect() +} + +fn l2_norm(values: &[f64]) -> f64 { + values + .iter() + .fold(0.0_f64, |norm, value| norm.hypot(*value)) +} diff --git a/rust/nmag_accel/src/lindholm_hmatrix/aca/pivot.rs b/rust/nmag_accel/src/lindholm_hmatrix/aca/pivot.rs new file mode 100644 index 0000000..6c032b6 --- /dev/null +++ b/rust/nmag_accel/src/lindholm_hmatrix/aca/pivot.rs @@ -0,0 +1,116 @@ +use crate::lindholm_hmatrix::entry::ExactLindholmEvaluator; + +#[allow(clippy::too_many_arguments)] +pub(super) fn sampled_residual_pivot( + evaluator: &ExactLindholmEvaluator, + rows: &[usize], + columns: &[usize], + u: &[f64], + v: &[f64], + rank: usize, + used_rows: &[bool], + used_columns: &[bool], +) -> Option<(usize, usize, f64)> { + let sampled_rows = sample_positions(rows.len(), 12); + let sampled_columns = sample_positions(columns.len(), 12); + let mut best: Option<(usize, usize, f64)> = None; + for row in sampled_rows { + if used_rows[row] { + continue; + } + for column in &sampled_columns { + if used_columns[*column] { + continue; + } + let approximation = (0..rank) + .map(|factor| u[factor * rows.len() + row] * v[factor * columns.len() + *column]) + .sum::(); + let residual = evaluator.entry(rows[row], columns[*column]) - approximation; + if best.is_none_or(|(_, _, value)| residual.abs() > value.abs()) { + best = Some((row, *column, residual)); + } + } + } + best +} + +pub(super) fn residual_row( + evaluator: &ExactLindholmEvaluator, + rows: &[usize], + columns: &[usize], + row: usize, + u: &[f64], + v: &[f64], + rank: usize, +) -> Vec { + let mut result = columns + .iter() + .map(|column| evaluator.entry(rows[row], *column)) + .collect::>(); + for factor in 0..rank { + let coefficient = u[factor * rows.len() + row]; + for column in 0..columns.len() { + result[column] -= coefficient * v[factor * columns.len() + column]; + } + } + result +} + +pub(super) fn residual_column( + evaluator: &ExactLindholmEvaluator, + rows: &[usize], + columns: &[usize], + column: usize, + u: &[f64], + v: &[f64], + rank: usize, +) -> Vec { + let mut result = rows + .iter() + .map(|row| evaluator.entry(*row, columns[column])) + .collect::>(); + for factor in 0..rank { + let coefficient = v[factor * columns.len() + column]; + for row in 0..rows.len() { + result[row] -= u[factor * rows.len() + row] * coefficient; + } + } + result +} + +pub(super) fn choose_reference(used: &[bool], rank: usize) -> Option { + if used.is_empty() { + return None; + } + let start = (used.len() / 2 + rank.saturating_mul(2654435761)) % used.len(); + (0..used.len()) + .map(|offset| (start + offset) % used.len()) + .find(|index| !used[*index]) +} + +pub(super) fn min_abs_unused(values: &[f64], used: &[bool]) -> Option { + values + .iter() + .enumerate() + .filter(|(index, _)| !used[*index]) + .min_by(|left, right| left.1.abs().total_cmp(&right.1.abs())) + .map(|(index, _)| index) +} + +pub(super) fn max_abs_unused(values: &[f64], used: &[bool]) -> Option<(usize, f64)> { + values + .iter() + .copied() + .enumerate() + .filter(|(index, _)| !used[*index]) + .max_by(|left, right| left.1.abs().total_cmp(&right.1.abs())) +} + +fn sample_positions(length: usize, count: usize) -> Vec { + if length <= count { + return (0..length).collect(); + } + (0..count) + .map(|index| index * (length - 1) / (count - 1)) + .collect() +} diff --git a/rust/nmag_accel/src/lindholm_hmatrix/aca/recompression.rs b/rust/nmag_accel/src/lindholm_hmatrix/aca/recompression.rs new file mode 100644 index 0000000..022bed8 --- /dev/null +++ b/rust/nmag_accel/src/lindholm_hmatrix/aca/recompression.rs @@ -0,0 +1,111 @@ +use faer::{linalg::solvers::Svd, Mat}; + +use crate::lindholm_hmatrix::entry::ExactLindholmEvaluator; + +use super::{validate_block, LowRankFactors}; + +pub(crate) fn compress_block_exact_svd( + evaluator: &ExactLindholmEvaluator, + rows: &[usize], + columns: &[usize], + tolerance: f64, + max_rank: usize, +) -> Option { + let matrix = Mat::from_fn(rows.len(), columns.len(), |row, column| { + evaluator.entry(rows[row], columns[column]) + }); + let svd = Svd::new_thin(matrix.as_ref()).ok()?; + let singular_values = svd.S().column_vector(); + let rank = truncated_rank(singular_values.iter().copied(), tolerance); + if rank > max_rank { + return None; + } + let left = svd.U(); + let right = svd.V(); + let mut u = vec![0.0; rows.len() * rank]; + let mut v = vec![0.0; columns.len() * rank]; + for factor in 0..rank { + let scale = singular_values[factor].sqrt(); + for row in 0..rows.len() { + u[factor * rows.len() + row] = left[(row, factor)] * scale; + } + for column in 0..columns.len() { + v[factor * columns.len() + column] = right[(column, factor)] * scale; + } + } + let factors = LowRankFactors { rank, u, v }; + validate_block(evaluator, rows, columns, &factors, tolerance).then_some(factors) +} + +pub(super) fn recompress( + factors: LowRankFactors, + row_count: usize, + column_count: usize, + tolerance: f64, +) -> Option { + if factors.rank <= 1 { + return Some(factors); + } + let rank = factors.rank; + let u_matrix = Mat::from_fn(row_count, rank, |row, factor| { + factors.u[factor * row_count + row] + }); + let v_transpose = Mat::from_fn(column_count, rank, |column, factor| { + factors.v[factor * column_count + column] + }); + let u_qr = u_matrix.qr(); + let v_qr = v_transpose.qr(); + let q_u = u_qr.compute_thin_Q(); + let q_v = v_qr.compute_thin_Q(); + let r_u = u_qr.thin_R(); + let r_v = v_qr.thin_R(); + let core = Mat::from_fn(rank, rank, |row, column| { + (0..rank) + .map(|inner| r_u[(row, inner)] * r_v[(column, inner)]) + .sum::() + }); + let svd = Svd::new_thin(core.as_ref()).ok()?; + let singular_values = svd.S().column_vector(); + let recompressed_rank = truncated_rank(singular_values.iter().copied(), tolerance); + let core_u = svd.U(); + let core_v = svd.V(); + let mut u = vec![0.0; row_count * recompressed_rank]; + let mut v = vec![0.0; column_count * recompressed_rank]; + for factor in 0..recompressed_rank { + let scale = singular_values[factor].sqrt(); + for row in 0..row_count { + u[factor * row_count + row] = scale + * (0..rank) + .map(|inner| q_u[(row, inner)] * core_u[(inner, factor)]) + .sum::(); + } + for column in 0..column_count { + v[factor * column_count + column] = scale + * (0..rank) + .map(|inner| q_v[(column, inner)] * core_v[(inner, factor)]) + .sum::(); + } + } + Some(LowRankFactors { + rank: recompressed_rank, + u, + v, + }) +} + +fn truncated_rank(values: impl Iterator, tolerance: f64) -> usize { + let values = values.collect::>(); + let total_squared = values.iter().map(|value| value * value).sum::(); + if total_squared == 0.0 { + return 0; + } + let target = tolerance * tolerance * total_squared; + let mut tail_squared = total_squared; + for (index, value) in values.iter().enumerate() { + tail_squared = (tail_squared - value * value).max(0.0); + if tail_squared <= target { + return index + 1; + } + } + values.len() +} diff --git a/rust/nmag_accel/src/lindholm_hmatrix/cluster.rs b/rust/nmag_accel/src/lindholm_hmatrix/cluster.rs new file mode 100644 index 0000000..b3707d4 --- /dev/null +++ b/rust/nmag_accel/src/lindholm_hmatrix/cluster.rs @@ -0,0 +1,112 @@ +use crate::common::Vec3; + +#[derive(Clone, Debug)] +pub(crate) struct ClusterNode { + pub(crate) start: usize, + pub(crate) end: usize, + pub(crate) lower: Vec3, + pub(crate) upper: Vec3, + pub(crate) children: Option<[usize; 2]>, +} + +impl ClusterNode { + pub(crate) fn diameter(&self) -> f64 { + let dx = self.upper[0] - self.lower[0]; + let dy = self.upper[1] - self.lower[1]; + let dz = self.upper[2] - self.lower[2]; + (dx * dx + dy * dy + dz * dz).sqrt() + } +} + +pub(crate) struct ClusterTree { + pub(crate) permutation: Vec, + pub(crate) nodes: Vec, + pub(crate) root: usize, +} + +impl ClusterTree { + pub(crate) fn build(points: &[Vec3], leaf_size: usize) -> Self { + let mut tree = Self { + permutation: (0..points.len()).collect(), + nodes: Vec::new(), + root: 0, + }; + tree.root = tree.build_node(points, 0, points.len(), leaf_size); + tree + } + + fn build_node(&mut self, points: &[Vec3], start: usize, end: usize, leaf_size: usize) -> usize { + let (lower, upper) = bounds(points, &self.permutation[start..end]); + let node_index = self.nodes.len(); + self.nodes.push(ClusterNode { + start, + end, + lower, + upper, + children: None, + }); + if end - start <= leaf_size { + return node_index; + } + + let spans = [ + upper[0] - lower[0], + upper[1] - lower[1], + upper[2] - lower[2], + ]; + let axis = if spans[1] > spans[0] && spans[1] >= spans[2] { + 1 + } else if spans[2] > spans[0] && spans[2] > spans[1] { + 2 + } else { + 0 + }; + self.permutation[start..end].sort_by(|left, right| { + points[*left][axis] + .total_cmp(&points[*right][axis]) + .then_with(|| left.cmp(right)) + }); + let middle = start + (end - start) / 2; + let left = self.build_node(points, start, middle, leaf_size); + let right = self.build_node(points, middle, end, leaf_size); + self.nodes[node_index].children = Some([left, right]); + node_index + } + + pub(crate) fn admissible(&self, row: usize, column: usize, eta: f64) -> bool { + let row_node = &self.nodes[row]; + let column_node = &self.nodes[column]; + let distance = box_distance(row_node, column_node); + distance > 0.0 && row_node.diameter().max(column_node.diameter()) <= eta * distance + } +} + +fn bounds(points: &[Vec3], indices: &[usize]) -> (Vec3, Vec3) { + if indices.is_empty() { + return ([0.0; 3], [0.0; 3]); + } + let mut lower = points[indices[0]]; + let mut upper = lower; + for index in &indices[1..] { + for axis in 0..3 { + lower[axis] = lower[axis].min(points[*index][axis]); + upper[axis] = upper[axis].max(points[*index][axis]); + } + } + (lower, upper) +} + +fn box_distance(left: &ClusterNode, right: &ClusterNode) -> f64 { + let mut squared = 0.0; + for axis in 0..3 { + let gap = if left.upper[axis] < right.lower[axis] { + right.lower[axis] - left.upper[axis] + } else if right.upper[axis] < left.lower[axis] { + left.lower[axis] - right.upper[axis] + } else { + 0.0 + }; + squared += gap * gap; + } + squared.sqrt() +} diff --git a/rust/nmag_accel/src/lindholm_hmatrix/entry.rs b/rust/nmag_accel/src/lindholm_hmatrix/entry.rs new file mode 100644 index 0000000..8398c10 --- /dev/null +++ b/rust/nmag_accel/src/lindholm_hmatrix/entry.rs @@ -0,0 +1,82 @@ +use ndarray::{ArrayView1, ArrayView2}; +use pyo3::prelude::*; +use std::f64::consts::PI; + +use crate::common::{checked_indices, point3, Vec3}; +use crate::lindholm::{ + boundary_node_solid_angles, lindholm_face_geometries, + lindholm_triangle_contributions_precomputed, FaceGeometry, +}; + +pub(crate) struct ExactLindholmEvaluator { + observers: Vec, + faces: Vec, + incident_faces: Vec>, + diagonal: Vec, +} + +impl ExactLindholmEvaluator { + pub(crate) fn new( + points: ArrayView2<'_, f64>, + simplices: ArrayView2<'_, i64>, + face_nodes: ArrayView2<'_, i64>, + boundary_nodes: ArrayView1<'_, i64>, + local_index_by_point: ArrayView1<'_, i64>, + ) -> PyResult { + let boundary_count = boundary_nodes.len(); + let observer_indices = checked_indices(boundary_nodes, points.shape()[0], "boundary node")?; + let observers = observer_indices + .iter() + .map(|index| point3(points, *index)) + .collect::>(); + let faces = + lindholm_face_geometries(points, face_nodes, local_index_by_point, boundary_count)?; + let mut incident_faces = vec![Vec::new(); boundary_count]; + for (face_index, face) in faces.iter().enumerate() { + for local_index in 0..3 { + incident_faces[face.local_indices[local_index]].push((face_index, local_index)); + } + } + let solid_angles = boundary_node_solid_angles(points, simplices)?; + let diagonal = observer_indices + .iter() + .map(|index| solid_angles[*index] / (4.0 * PI) - 1.0) + .collect(); + Ok(Self { + observers, + faces, + incident_faces, + diagonal, + }) + } + + pub(crate) fn len(&self) -> usize { + self.observers.len() + } + + pub(crate) fn observer_points(&self) -> &[Vec3] { + &self.observers + } + + pub(crate) fn entry(&self, row: usize, column: usize) -> f64 { + let observer = self.observers[row]; + let mut value = 0.0; + for &(face_index, local_index) in &self.incident_faces[column] { + let contributions = + lindholm_triangle_contributions_precomputed(observer, &self.faces[face_index]); + value += contributions[local_index]; + } + if row == column { + value += self.diagonal[row]; + } + value + } + + pub(crate) fn row_dot(&self, row: usize, values: &[f64]) -> f64 { + values + .iter() + .enumerate() + .map(|(column, value)| self.entry(row, column) * value) + .sum() + } +} diff --git a/rust/nmag_accel/src/lindholm_hmatrix/mod.rs b/rust/nmag_accel/src/lindholm_hmatrix/mod.rs new file mode 100644 index 0000000..3d8f5ec --- /dev/null +++ b/rust/nmag_accel/src/lindholm_hmatrix/mod.rs @@ -0,0 +1,167 @@ +mod aca; +mod cluster; +mod entry; +mod operator; + +use ndarray::Array1; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1, PyReadonlyArray2, PyUntypedArrayMethods}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use entry::ExactLindholmEvaluator; +use operator::{BuildOptions, HMatrixOperator}; + +#[pyclass(frozen, module = "nmag_accel")] +pub(crate) struct LindholmHMatrix { + operator: HMatrixOperator, +} + +#[pymethods] +impl LindholmHMatrix { + fn matvec<'py>( + &self, + py: Python<'py>, + values: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let values = values.as_slice()?.to_vec(); + if values.len() != self.operator.len() { + return Err(PyValueError::new_err(format!( + "values must contain one entry per boundary node ({})", + self.operator.len() + ))); + } + if values.iter().any(|value| !value.is_finite()) { + return Err(PyValueError::new_err( + "values must contain only finite numbers", + )); + } + let result = py.detach(|| self.operator.matvec(&values)); + Ok(Array1::from_vec(result).into_pyarray(py)) + } + + #[getter] + fn size(&self) -> usize { + self.operator.len() + } + + #[getter] + fn storage_bytes(&self) -> usize { + self.operator.diagnostics().storage_bytes + } + + #[getter] + fn dense_blocks(&self) -> usize { + self.operator.diagnostics().dense_blocks + } + + #[getter] + fn low_rank_blocks(&self) -> usize { + self.operator.diagnostics().low_rank_blocks + } + + #[getter] + fn maximum_rank(&self) -> usize { + self.operator.diagnostics().maximum_rank + } + + #[getter] + fn mean_rank(&self) -> f64 { + self.operator.diagnostics().mean_rank + } + + #[getter] + fn sampled_relative_error(&self) -> f64 { + self.operator.diagnostics().sampled_relative_error + } +} + +#[pyfunction] +#[pyo3(signature = ( + points, + simplices, + face_nodes, + boundary_nodes, + local_index_by_point, + relative_tolerance=1.0e-6, + admissibility_eta=2.0, + leaf_size=32, + max_rank=128, + validation_vectors=4, + validation_rows=64, + memory_budget_bytes=usize::MAX, +))] +#[allow(clippy::too_many_arguments)] +pub(crate) fn build_lindholm_hmatrix( + points: PyReadonlyArray2<'_, f64>, + simplices: PyReadonlyArray2<'_, i64>, + face_nodes: PyReadonlyArray2<'_, i64>, + boundary_nodes: PyReadonlyArray1<'_, i64>, + local_index_by_point: PyReadonlyArray1<'_, i64>, + relative_tolerance: f64, + admissibility_eta: f64, + leaf_size: usize, + max_rank: usize, + validation_vectors: usize, + validation_rows: usize, + memory_budget_bytes: usize, +) -> PyResult { + if points.shape().len() != 2 || points.shape()[1] != 3 { + return Err(PyValueError::new_err("points must have shape (n, 3)")); + } + if simplices.shape().len() != 2 || simplices.shape()[1] != 4 { + return Err(PyValueError::new_err("simplices must have shape (n, 4)")); + } + if face_nodes.shape().len() != 2 || face_nodes.shape()[1] != 3 { + return Err(PyValueError::new_err("face_nodes must have shape (n, 3)")); + } + if local_index_by_point.len() != points.shape()[0] { + return Err(PyValueError::new_err( + "local_index_by_point must contain one entry per point", + )); + } + if points.as_array().iter().any(|value| !value.is_finite()) { + return Err(PyValueError::new_err( + "points must contain only finite numbers", + )); + } + if !relative_tolerance.is_finite() || relative_tolerance <= 0.0 { + return Err(PyValueError::new_err( + "relative_tolerance must be finite and positive", + )); + } + if !admissibility_eta.is_finite() || admissibility_eta <= 0.0 { + return Err(PyValueError::new_err( + "admissibility_eta must be finite and positive", + )); + } + if leaf_size == 0 || max_rank == 0 || validation_vectors == 0 || validation_rows == 0 { + return Err(PyValueError::new_err( + "leaf_size, max_rank, validation_vectors, and validation_rows must be positive", + )); + } + let evaluator = ExactLindholmEvaluator::new( + points.as_array(), + simplices.as_array(), + face_nodes.as_array(), + boundary_nodes.as_array(), + local_index_by_point.as_array(), + )?; + let operator = HMatrixOperator::build( + &evaluator, + BuildOptions { + tolerance: relative_tolerance, + eta: admissibility_eta, + leaf_size, + max_rank, + validation_vectors, + validation_rows, + memory_budget: memory_budget_bytes, + }, + ) + .map_err(PyValueError::new_err)?; + Ok(LindholmHMatrix { operator }) +} + +#[cfg(test)] +#[path = "../../tests/unit/lindholm_hmatrix.rs"] +mod tests; diff --git a/rust/nmag_accel/src/lindholm_hmatrix/operator.rs b/rust/nmag_accel/src/lindholm_hmatrix/operator.rs new file mode 100644 index 0000000..5f9e165 --- /dev/null +++ b/rust/nmag_accel/src/lindholm_hmatrix/operator.rs @@ -0,0 +1,194 @@ +mod block; +mod builder; + +use rayon::prelude::*; + +use super::cluster::ClusterTree; +use super::entry::ExactLindholmEvaluator; +use block::{Block, BlockValues}; +use builder::Builder; + +#[derive(Clone, Debug)] +pub(crate) struct OperatorDiagnostics { + pub(crate) storage_bytes: usize, + pub(crate) dense_blocks: usize, + pub(crate) low_rank_blocks: usize, + pub(crate) maximum_rank: usize, + pub(crate) mean_rank: f64, + pub(crate) sampled_relative_error: f64, +} + +pub(crate) struct HMatrixOperator { + size: usize, + permutation: Vec, + blocks: Vec, + diagnostics: OperatorDiagnostics, +} + +pub(crate) struct BuildOptions { + pub(crate) tolerance: f64, + pub(crate) eta: f64, + pub(crate) leaf_size: usize, + pub(crate) max_rank: usize, + pub(crate) validation_vectors: usize, + pub(crate) validation_rows: usize, + pub(crate) memory_budget: usize, +} + +impl HMatrixOperator { + pub(crate) fn build( + evaluator: &ExactLindholmEvaluator, + options: BuildOptions, + ) -> Result { + let tree = ClusterTree::build(evaluator.observer_points(), options.leaf_size); + let root = tree.root; + let mut builder = Builder { + evaluator, + tree, + tolerance: options.tolerance, + eta: options.eta, + max_rank: options.max_rank, + memory_budget: options.memory_budget, + storage_bytes: 0, + blocks: Vec::new(), + }; + builder.build_block(root, root)?; + + let ranks = builder + .blocks + .iter() + .filter_map(|block| match &block.values { + BlockValues::LowRank(factors) => Some(factors.rank), + BlockValues::Dense(_) => None, + }) + .collect::>(); + let diagnostics = OperatorDiagnostics { + storage_bytes: builder.storage_bytes, + dense_blocks: builder + .blocks + .iter() + .filter(|block| matches!(block.values, BlockValues::Dense(_))) + .count(), + low_rank_blocks: ranks.len(), + maximum_rank: ranks.iter().copied().max().unwrap_or(0), + mean_rank: if ranks.is_empty() { + 0.0 + } else { + ranks.iter().sum::() as f64 / ranks.len() as f64 + }, + sampled_relative_error: 0.0, + }; + let mut operator = Self { + size: evaluator.len(), + permutation: builder.tree.permutation, + blocks: builder.blocks, + diagnostics, + }; + let error = operator.certify( + evaluator, + options.validation_vectors, + options.validation_rows, + ); + operator.diagnostics.sampled_relative_error = error; + if !error.is_finite() || error > options.tolerance { + return Err(format!( + "hierarchical BEM certification error {error:.6e} exceeds tolerance {:.6e}", + options.tolerance + )); + } + Ok(operator) + } + + pub(crate) fn len(&self) -> usize { + self.size + } + + pub(crate) fn diagnostics(&self) -> &OperatorDiagnostics { + &self.diagnostics + } + + pub(crate) fn matvec(&self, input: &[f64]) -> Vec { + let permuted_input = self + .permutation + .iter() + .map(|index| input[*index]) + .collect::>(); + let prepared = self + .blocks + .par_iter() + .map(|block| block.prepare(&permuted_input)) + .collect::>(); + let mut permuted_output = vec![0.0; self.size]; + permuted_output + .par_chunks_mut(256) + .enumerate() + .for_each(|(chunk, output)| { + let output_start = chunk * 256; + for (block, coefficients) in self.blocks.iter().zip(&prepared) { + block.accumulate_rows( + &permuted_input, + coefficients.as_deref(), + output_start, + output, + ); + } + }); + let mut output = vec![0.0; self.size]; + for (permuted, original) in self.permutation.iter().copied().enumerate() { + output[original] = permuted_output[permuted]; + } + output + } + + fn certify( + &self, + evaluator: &ExactLindholmEvaluator, + validation_vectors: usize, + validation_rows: usize, + ) -> f64 { + if self.size == 0 { + return 0.0; + } + let rows = sample_positions(self.size, validation_rows) + .into_iter() + .map(|position| self.permutation[position]) + .collect::>(); + let mut exact_norm = 0.0_f64; + let mut error_norm = 0.0_f64; + let mut state = 0x9e37_79b9_7f4a_7c15_u64; + for _ in 0..validation_vectors { + let input = (0..self.size) + .map(|_| { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + if state & 1 == 0 { + -1.0 + } else { + 1.0 + } + }) + .collect::>(); + let approximate = self.matvec(&input); + for row in &rows { + let exact = evaluator.row_dot(*row, &input); + exact_norm = exact_norm.hypot(exact); + error_norm = error_norm.hypot(exact - approximate[*row]); + } + } + error_norm / exact_norm.max(1.0e-30) + } +} + +fn sample_positions(length: usize, count: usize) -> Vec { + let count = count.min(length); + if count == 0 { + return Vec::new(); + } + if count == 1 { + return vec![length / 2]; + } + (0..count) + .map(|index| index * (length - 1) / (count - 1)) + .collect() +} diff --git a/rust/nmag_accel/src/lindholm_hmatrix/operator/block.rs b/rust/nmag_accel/src/lindholm_hmatrix/operator/block.rs new file mode 100644 index 0000000..f62c6c6 --- /dev/null +++ b/rust/nmag_accel/src/lindholm_hmatrix/operator/block.rs @@ -0,0 +1,81 @@ +use crate::lindholm_hmatrix::aca::LowRankFactors; + +const BLOCK_METADATA_BYTES: usize = 8 * std::mem::size_of::(); + +pub(super) enum BlockValues { + Dense(Vec), + LowRank(LowRankFactors), +} + +pub(super) struct Block { + pub(super) row_start: usize, + pub(super) row_count: usize, + pub(super) column_start: usize, + pub(super) column_count: usize, + pub(super) values: BlockValues, +} + +impl Block { + pub(super) fn storage_bytes(&self) -> usize { + BLOCK_METADATA_BYTES + + match &self.values { + BlockValues::Dense(values) => values.len() * std::mem::size_of::(), + BlockValues::LowRank(factors) => { + (factors.u.len() + factors.v.len()) * std::mem::size_of::() + } + } + } + + pub(super) fn prepare(&self, input: &[f64]) -> Option> { + let columns = &input[self.column_start..self.column_start + self.column_count]; + match &self.values { + BlockValues::Dense(_) => None, + BlockValues::LowRank(factors) => Some( + (0..factors.rank) + .map(|rank| { + factors.v[rank * self.column_count..(rank + 1) * self.column_count] + .iter() + .zip(columns) + .map(|(entry, value)| entry * value) + .sum::() + }) + .collect(), + ), + } + } + + pub(super) fn accumulate_rows( + &self, + input: &[f64], + coefficients: Option<&[f64]>, + output_start: usize, + output: &mut [f64], + ) { + let overlap_start = output_start.max(self.row_start); + let overlap_end = (output_start + output.len()).min(self.row_start + self.row_count); + if overlap_start >= overlap_end { + return; + } + let columns = &input[self.column_start..self.column_start + self.column_count]; + for global_row in overlap_start..overlap_end { + let block_row = global_row - self.row_start; + let value: f64 = match &self.values { + BlockValues::Dense(values) => values + [block_row * self.column_count..(block_row + 1) * self.column_count] + .iter() + .zip(columns) + .map(|(entry, value)| entry * value) + .sum(), + BlockValues::LowRank(factors) => { + let coefficients = coefficients.expect("low-rank coefficients are prepared"); + (0..factors.rank) + .map(|rank| { + factors.u[rank * self.row_count + block_row] * coefficients[rank] + }) + .sum() + } + }; + output[global_row - output_start] += value; + } + } +} diff --git a/rust/nmag_accel/src/lindholm_hmatrix/operator/builder.rs b/rust/nmag_accel/src/lindholm_hmatrix/operator/builder.rs new file mode 100644 index 0000000..63b7424 --- /dev/null +++ b/rust/nmag_accel/src/lindholm_hmatrix/operator/builder.rs @@ -0,0 +1,134 @@ +use crate::lindholm_hmatrix::aca::{compress_block, compress_block_exact_svd}; +use crate::lindholm_hmatrix::cluster::ClusterTree; +use crate::lindholm_hmatrix::entry::ExactLindholmEvaluator; + +use super::block::{Block, BlockValues}; + +const MAX_EXACT_RECOMPRESSION_ENTRIES: usize = 256 * 256; + +pub(super) struct Builder<'a> { + pub(super) evaluator: &'a ExactLindholmEvaluator, + pub(super) tree: ClusterTree, + pub(super) tolerance: f64, + pub(super) eta: f64, + pub(super) max_rank: usize, + pub(super) memory_budget: usize, + pub(super) storage_bytes: usize, + pub(super) blocks: Vec, +} + +impl Builder<'_> { + pub(super) fn build_block( + &mut self, + row_index: usize, + column_index: usize, + ) -> Result<(), String> { + let row_node = self.tree.nodes[row_index].clone(); + let column_node = self.tree.nodes[column_index].clone(); + let rows = self.tree.permutation[row_node.start..row_node.end].to_vec(); + let columns = self.tree.permutation[column_node.start..column_node.end].to_vec(); + + if self.tree.admissible(row_index, column_index, self.eta) { + let mut factors = compress_block( + self.evaluator, + &rows, + &columns, + self.tolerance, + self.max_rank, + ); + let dense_values = rows.len().saturating_mul(columns.len()); + if factors + .as_ref() + .is_none_or(|candidate| candidate.u.len() + candidate.v.len() >= dense_values) + && dense_values <= MAX_EXACT_RECOMPRESSION_ENTRIES + { + factors = compress_block_exact_svd( + self.evaluator, + &rows, + &columns, + self.tolerance, + self.max_rank, + ); + } + if let Some(factors) = factors { + let factor_values = factors.u.len() + factors.v.len(); + if factor_values < dense_values { + return self.push_block(Block { + row_start: row_node.start, + row_count: rows.len(), + column_start: column_node.start, + column_count: columns.len(), + values: BlockValues::LowRank(factors), + }); + } + } + } + + match (row_node.children, column_node.children) { + (Some(row_children), Some(column_children)) => { + for row_child in row_children { + for column_child in column_children { + self.build_block(row_child, column_child)?; + } + } + Ok(()) + } + (Some(row_children), None) => { + for row_child in row_children { + self.build_block(row_child, column_index)?; + } + Ok(()) + } + (None, Some(column_children)) => { + for column_child in column_children { + self.build_block(row_index, column_child)?; + } + Ok(()) + } + (None, None) => { + self.push_dense_block(&rows, &columns, row_node.start, column_node.start) + } + } + } + + fn push_dense_block( + &mut self, + rows: &[usize], + columns: &[usize], + row_start: usize, + column_start: usize, + ) -> Result<(), String> { + let values = rows + .iter() + .flat_map(|row| { + columns + .iter() + .map(|column| self.evaluator.entry(*row, *column)) + }) + .collect::>(); + self.push_block(Block { + row_start, + row_count: rows.len(), + column_start, + column_count: columns.len(), + values: BlockValues::Dense(values), + }) + } + + fn push_block(&mut self, block: Block) -> Result<(), String> { + let required = block.storage_bytes(); + let next = self + .storage_bytes + .checked_add(required) + .ok_or_else(|| "hierarchical BEM storage accounting overflowed".to_owned())?; + if next > self.memory_budget { + return Err(format!( + "hierarchical BEM memory budget exceeded: {next} bytes required, {} bytes allowed", + self.memory_budget + )); + } + self.storage_bytes = next; + self.blocks.push(block); + Ok(()) + } +} diff --git a/rust/nmag_accel/src/llg_maxangle.rs b/rust/nmag_accel/src/llg_maxangle.rs new file mode 100644 index 0000000..1670199 --- /dev/null +++ b/rust/nmag_accel/src/llg_maxangle.rs @@ -0,0 +1,330 @@ +use ndarray::{Array2, ArrayView1, ArrayView2}; +use numpy::{IntoPyArray, PyArray2, PyReadonlyArray1, PyReadonlyArray2}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use rayon::prelude::*; +use std::collections::HashSet; +use std::f64::consts::PI; + +use crate::common::{checked_index, cross3, dot3, should_parallelise}; + +#[pyfunction] +#[allow(clippy::too_many_arguments)] +pub(crate) fn llg_rhs<'py>( + py: Python<'py>, + m: PyReadonlyArray2<'py, f64>, + h_total: PyReadonlyArray2<'py, f64>, + pin: PyReadonlyArray1<'py, f64>, + ms_values: PyReadonlyArray1<'py, f64>, + precession_coeff: f64, + damping_coeff: f64, + normalisation_coeff: f64, +) -> PyResult>> { + let m = m.as_array(); + let h_total = h_total.as_array(); + let pin = pin.as_array(); + let ms_values = ms_values.as_array(); + + let point_count = validate_llg_inputs(m, h_total, pin, ms_values)?; + let values = compute_llg_values(m, h_total, pin, ms_values, None, point_count, |_| { + [ + precession_coeff, + damping_coeff, + normalisation_coeff, + 0.0, + 0.0, + ] + }); + llg_values_into_python(py, point_count, values) +} + +#[pyfunction] +#[allow(clippy::too_many_arguments)] +pub(crate) fn llg_rhs_heterogeneous<'py>( + py: Python<'py>, + m: PyReadonlyArray2<'py, f64>, + h_total: PyReadonlyArray2<'py, f64>, + pin: PyReadonlyArray1<'py, f64>, + ms_values: PyReadonlyArray1<'py, f64>, + precession_coeff: PyReadonlyArray1<'py, f64>, + damping_coeff: PyReadonlyArray1<'py, f64>, + normalisation_coeff: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let m = m.as_array(); + let h_total = h_total.as_array(); + let pin = pin.as_array(); + let ms_values = ms_values.as_array(); + let precession_coeff = precession_coeff.as_array(); + let damping_coeff = damping_coeff.as_array(); + let normalisation_coeff = normalisation_coeff.as_array(); + let point_count = validate_llg_inputs(m, h_total, pin, ms_values)?; + for (name, values) in [ + ("precession_coeff", precession_coeff), + ("damping_coeff", damping_coeff), + ("normalisation_coeff", normalisation_coeff), + ] { + if values.len() != point_count { + return Err(PyValueError::new_err(format!( + "{name} must contain one value per mesh point" + ))); + } + } + let values = compute_llg_values( + m, + h_total, + pin, + ms_values, + None, + point_count, + |point_index| { + [ + precession_coeff[point_index], + damping_coeff[point_index], + normalisation_coeff[point_index], + 0.0, + 0.0, + ] + }, + ); + llg_values_into_python(py, point_count, values) +} + +#[pyfunction] +#[allow(clippy::too_many_arguments)] +pub(crate) fn llg_rhs_stt_heterogeneous<'py>( + py: Python<'py>, + m: PyReadonlyArray2<'py, f64>, + h_total: PyReadonlyArray2<'py, f64>, + pin: PyReadonlyArray1<'py, f64>, + ms_values: PyReadonlyArray1<'py, f64>, + precession_coeff: PyReadonlyArray1<'py, f64>, + damping_coeff: PyReadonlyArray1<'py, f64>, + normalisation_coeff: PyReadonlyArray1<'py, f64>, + dm_dcurrent: PyReadonlyArray2<'py, f64>, + stt_adiabatic_coeff: PyReadonlyArray1<'py, f64>, + stt_nonadiabatic_coeff: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let m = m.as_array(); + let h_total = h_total.as_array(); + let pin = pin.as_array(); + let ms_values = ms_values.as_array(); + let precession_coeff = precession_coeff.as_array(); + let damping_coeff = damping_coeff.as_array(); + let normalisation_coeff = normalisation_coeff.as_array(); + let dm_dcurrent = dm_dcurrent.as_array(); + let stt_adiabatic_coeff = stt_adiabatic_coeff.as_array(); + let stt_nonadiabatic_coeff = stt_nonadiabatic_coeff.as_array(); + let point_count = validate_llg_inputs(m, h_total, pin, ms_values)?; + if dm_dcurrent.shape() != m.shape() { + return Err(PyValueError::new_err( + "dm_dcurrent must have the same shape as m", + )); + } + for (name, values) in [ + ("precession_coeff", precession_coeff), + ("damping_coeff", damping_coeff), + ("normalisation_coeff", normalisation_coeff), + ("stt_adiabatic_coeff", stt_adiabatic_coeff), + ("stt_nonadiabatic_coeff", stt_nonadiabatic_coeff), + ] { + if values.len() != point_count { + return Err(PyValueError::new_err(format!( + "{name} must contain one value per mesh point" + ))); + } + } + let values = compute_llg_values( + m, + h_total, + pin, + ms_values, + Some(dm_dcurrent), + point_count, + |point_index| { + [ + precession_coeff[point_index], + damping_coeff[point_index], + normalisation_coeff[point_index], + stt_adiabatic_coeff[point_index], + stt_nonadiabatic_coeff[point_index], + ] + }, + ); + llg_values_into_python(py, point_count, values) +} + +fn validate_llg_inputs( + m: ArrayView2<'_, f64>, + h_total: ArrayView2<'_, f64>, + pin: ArrayView1<'_, f64>, + ms_values: ArrayView1<'_, f64>, +) -> PyResult { + if m.ndim() != 2 || m.shape()[1] != 3 { + return Err(PyValueError::new_err("m must have shape (n, 3)")); + } + if h_total.ndim() != 2 || h_total.shape() != m.shape() { + return Err(PyValueError::new_err( + "h_total must have the same shape as m", + )); + } + let point_count = m.shape()[0]; + if pin.len() != point_count { + return Err(PyValueError::new_err( + "pin must contain one value per mesh point", + )); + } + if ms_values.len() != point_count { + return Err(PyValueError::new_err( + "ms_values must contain one value per mesh point", + )); + } + + Ok(point_count) +} + +fn compute_llg_values( + m: ArrayView2<'_, f64>, + h_total: ArrayView2<'_, f64>, + pin: ArrayView1<'_, f64>, + ms_values: ArrayView1<'_, f64>, + dm_dcurrent: Option>, + point_count: usize, + coefficients: C, +) -> Vec +where + C: Fn(usize) -> [f64; 5] + Sync, +{ + let mut values = vec![0.0; point_count * 3]; + let compute_point = |point_index: usize, output: &mut [f64]| { + let m_value = [ + m[[point_index, 0]], + m[[point_index, 1]], + m[[point_index, 2]], + ]; + let h_value = [ + h_total[[point_index, 0]], + h_total[[point_index, 1]], + h_total[[point_index, 2]], + ]; + let mxh = cross3(m_value, h_value); + let mdoth = dot3(m_value, h_value); + let mdotm = dot3(m_value, m_value); + let norm_error_scale = 1.0 - mdotm; + let scale = ms_values[point_index] * pin[point_index]; + let directional = dm_dcurrent.map_or([0.0; 3], |values| { + [ + values[[point_index, 0]], + values[[point_index, 1]], + values[[point_index, 2]], + ] + }); + let mx_directional = cross3(m_value, directional); + let mdot_directional = dot3(m_value, directional); + let [precession_coeff, damping_coeff, normalisation_coeff, stt_adiabatic_coeff, stt_nonadiabatic_coeff] = + coefficients(point_index); + for component in 0..3 { + let damping = m_value[component] * mdoth - h_value[component] * mdotm; + let norm_error = norm_error_scale * m_value[component]; + let mxmx_directional = + m_value[component] * mdot_directional - directional[component] * mdotm; + output[component] = scale + * (precession_coeff * mxh[component] + + damping_coeff * damping + + normalisation_coeff * norm_error + + stt_adiabatic_coeff * mxmx_directional + + stt_nonadiabatic_coeff * mx_directional[component]); + } + }; + if should_parallelise(point_count) { + values + .par_chunks_mut(3) + .enumerate() + .for_each(|(point_index, output)| compute_point(point_index, output)); + } else { + values + .chunks_mut(3) + .enumerate() + .for_each(|(point_index, output)| compute_point(point_index, output)); + } + + values +} + +fn llg_values_into_python<'py>( + py: Python<'py>, + point_count: usize, + values: Vec, +) -> PyResult>> { + let result = Array2::from_shape_vec((point_count, 3), values) + .map_err(|err| PyValueError::new_err(format!("could not shape LLG RHS: {err}")))?; + Ok(result.into_pyarray(py)) +} + +#[pyfunction] +pub(crate) fn maxangle_between_edges( + m: PyReadonlyArray2<'_, f64>, + simplices: PyReadonlyArray2<'_, i64>, +) -> PyResult { + let m = m.as_array(); + let simplices = simplices.as_array(); + + if m.ndim() != 2 || m.shape()[1] != 3 { + return Err(PyValueError::new_err("m must have shape (n, 3)")); + } + if simplices.ndim() != 2 || simplices.shape()[1] < 2 { + return Err(PyValueError::new_err( + "simplices must have shape (n, simplex_size>=2)", + )); + } + + if simplices.shape()[0] == 0 { + return Ok(0.0); + } + + let point_count = m.shape()[0]; + let simplex_size = simplices.shape()[1]; + let mut edges: HashSet<(usize, usize)> = + HashSet::with_capacity(simplices.shape()[0] * simplex_size * (simplex_size - 1) / 2); + for simplex_index in 0..simplices.shape()[0] { + for left_position in 0..simplex_size { + let left = checked_index( + simplices[[simplex_index, left_position]], + point_count, + "simplex node", + )?; + for right_position in (left_position + 1)..simplex_size { + let right = checked_index( + simplices[[simplex_index, right_position]], + point_count, + "simplex node", + )?; + if left <= right { + edges.insert((left, right)); + } else { + edges.insert((right, left)); + } + } + } + } + + if edges.is_empty() { + return Ok(0.0); + } + + let max_angle = edges + .par_iter() + .map(|(left, right)| { + let dot = (m[[*left, 0]] * m[[*right, 0]] + + m[[*left, 1]] * m[[*right, 1]] + + m[[*left, 2]] * m[[*right, 2]]) + .clamp(-1.0, 1.0); + dot.acos() + }) + .reduce(|| 0.0, f64::max); + + Ok(max_angle * 180.0 / PI) +} + +#[cfg(test)] +#[path = "../tests/unit/llg_maxangle.rs"] +mod tests; diff --git a/rust/nmag_accel/src/mesh_probe.rs b/rust/nmag_accel/src/mesh_probe.rs new file mode 100644 index 0000000..ea3b5f3 --- /dev/null +++ b/rust/nmag_accel/src/mesh_probe.rs @@ -0,0 +1,216 @@ +use ndarray::{Array2, Array3}; +use numpy::{IntoPyArray, PyArray1, PyArray2, PyArray3, PyReadonlyArray2}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use std::collections::HashMap; + +use crate::common::{ + average3, average4, bounds3, checked_index, columns3, cross3, dot3, inverse3, point3, + subtract3, Vec3, +}; + +pub(crate) type BoundaryFacesResult<'py> = + PyResult<(Bound<'py, PyArray1>, Bound<'py, PyArray2>)>; +pub(crate) type ProbeTetrahedralGeometryResult<'py> = PyResult<( + Bound<'py, PyArray2>, + Bound<'py, PyArray2>, + Bound<'py, PyArray3>, + Bound<'py, PyArray2>, + Bound<'py, PyArray2>, +)>; + +const TETRA_FACE_VERTICES: [[usize; 3]; 4] = [[1, 2, 3], [0, 3, 2], [0, 1, 3], [0, 2, 1]]; + +#[pyfunction] +pub(crate) fn build_oriented_boundary_faces<'py>( + py: Python<'py>, + points: PyReadonlyArray2<'py, f64>, + simplices: PyReadonlyArray2<'py, i64>, +) -> BoundaryFacesResult<'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 records: Vec<(usize, [usize; 3], [usize; 3])> = Vec::with_capacity(cell_count * 4); + let mut counts: HashMap<[usize; 3], usize> = HashMap::with_capacity(cell_count * 4); + + for cell_index in 0..cell_count { + let simplex = [ + 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")?, + ]; + for local_face in TETRA_FACE_VERTICES { + let face = [ + simplex[local_face[0]], + simplex[local_face[1]], + simplex[local_face[2]], + ]; + let mut key = face; + key.sort_unstable(); + *counts.entry(key).or_insert(0) += 1; + records.push((cell_index, face, key)); + } + } + if counts.values().any(|&count| count > 2) { + return Err(PyValueError::new_err( + "non-manifold mesh: a face is shared by more than two tetrahedra", + )); + } + + let mut owner_values: Vec = Vec::new(); + let mut face_values: Vec = Vec::new(); + for (cell_index, mut face, key) in records { + if counts.get(&key).copied().unwrap_or(0) != 1 { + continue; + } + let simplex = [ + 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 face_points = [ + point3(points, face[0]), + point3(points, face[1]), + point3(points, face[2]), + ]; + let cell_center = average4([ + point3(points, simplex[0]), + point3(points, simplex[1]), + point3(points, simplex[2]), + point3(points, simplex[3]), + ]); + let face_center = average3(face_points); + let normal = cross3( + subtract3(face_points[1], face_points[0]), + subtract3(face_points[2], face_points[0]), + ); + if dot3(normal, subtract3(cell_center, face_center)) > 0.0 { + face.swap(1, 2); + } + owner_values.push(cell_index as i64); + face_values.extend(face.map(|index| index as i64)); + } + + let owner_count = owner_values.len(); + let owners = ndarray::Array1::from_vec(owner_values); + let faces = Array2::from_shape_vec((owner_count, 3), face_values) + .map_err(|err| PyValueError::new_err(format!("could not shape boundary faces: {err}")))?; + Ok((owners.into_pyarray(py), faces.into_pyarray(py))) +} + +#[pyfunction] +pub(crate) fn build_probe_tetrahedral_geometry<'py>( + py: Python<'py>, + points: PyReadonlyArray2<'py, f64>, + simplices: PyReadonlyArray2<'py, i64>, +) -> ProbeTetrahedralGeometryResult<'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 mut valid_simplices: Vec<[i64; 4]> = Vec::with_capacity(simplices.shape()[0]); + let mut origins: Vec = Vec::with_capacity(simplices.shape()[0]); + let mut inverse_matrices: Vec<[[f64; 3]; 3]> = Vec::with_capacity(simplices.shape()[0]); + let mut lower_bounds: Vec = Vec::with_capacity(simplices.shape()[0]); + let mut upper_bounds: Vec = Vec::with_capacity(simplices.shape()[0]); + + for simplex_index in 0..simplices.shape()[0] { + let simplex = [ + checked_index( + simplices[[simplex_index, 0]], + points.shape()[0], + "simplex node", + )?, + checked_index( + simplices[[simplex_index, 1]], + points.shape()[0], + "simplex node", + )?, + checked_index( + simplices[[simplex_index, 2]], + points.shape()[0], + "simplex node", + )?, + checked_index( + simplices[[simplex_index, 3]], + points.shape()[0], + "simplex node", + )?, + ]; + let tetrahedron = [ + point3(points, simplex[0]), + point3(points, simplex[1]), + point3(points, simplex[2]), + point3(points, simplex[3]), + ]; + let matrix = columns3( + subtract3(tetrahedron[1], tetrahedron[0]), + subtract3(tetrahedron[2], tetrahedron[0]), + subtract3(tetrahedron[3], tetrahedron[0]), + ); + let Some(inverse) = inverse3(matrix) else { + continue; + }; + valid_simplices.push([ + simplex[0] as i64, + simplex[1] as i64, + simplex[2] as i64, + simplex[3] as i64, + ]); + origins.push(tetrahedron[0]); + inverse_matrices.push(inverse); + lower_bounds.push(bounds3(tetrahedron, f64::min)); + upper_bounds.push(bounds3(tetrahedron, f64::max)); + } + + let count = valid_simplices.len(); + let mut simplex_array = Array2::::zeros((count, 4)); + let mut origin_array = Array2::::zeros((count, 3)); + let mut inverse_array = Array3::::zeros((count, 3, 3)); + let mut lower_array = Array2::::zeros((count, 3)); + let mut upper_array = Array2::::zeros((count, 3)); + + for row in 0..count { + for column in 0..4 { + simplex_array[[row, column]] = valid_simplices[row][column]; + } + for column in 0..3 { + origin_array[[row, column]] = origins[row][column]; + lower_array[[row, column]] = lower_bounds[row][column]; + upper_array[[row, column]] = upper_bounds[row][column]; + for inner_column in 0..3 { + inverse_array[[row, column, inner_column]] = + inverse_matrices[row][column][inner_column]; + } + } + } + + Ok(( + simplex_array.into_pyarray(py), + origin_array.into_pyarray(py), + inverse_array.into_pyarray(py), + lower_array.into_pyarray(py), + upper_array.into_pyarray(py), + )) +} + +#[cfg(test)] +#[path = "../tests/unit/mesh_probe.rs"] +mod tests; diff --git a/rust/nmag_accel/src/parallel.rs b/rust/nmag_accel/src/parallel.rs new file mode 100644 index 0000000..4dcd3e8 --- /dev/null +++ b/rust/nmag_accel/src/parallel.rs @@ -0,0 +1,8 @@ +use pyo3::prelude::*; + +use crate::common::PARALLEL_MIN_ITEMS; + +#[pyfunction] +pub(crate) fn parallel_runtime_info() -> (usize, usize) { + (rayon::current_num_threads(), PARALLEL_MIN_ITEMS) +} diff --git a/rust/nmag_accel/tests/unit/common.rs b/rust/nmag_accel/tests/unit/common.rs new file mode 100644 index 0000000..f760004 --- /dev/null +++ b/rust/nmag_accel/tests/unit/common.rs @@ -0,0 +1,27 @@ +use super::*; + +#[test] +fn parallel_threshold_has_an_explicit_serial_boundary() { + assert!(!should_parallelise(0)); + assert!(!should_parallelise(PARALLEL_MIN_ITEMS - 1)); + assert!(should_parallelise(PARALLEL_MIN_ITEMS)); +} + +#[test] +fn checked_index_rejects_negative_and_oversized_values() { + assert!(checked_index(-1, 2, "node").is_err()); + assert!(checked_index(2, 2, "node").is_err()); + assert_eq!(checked_index(1, 2, "node").unwrap(), 1); +} + +#[test] +fn vector_helpers_and_inverse_are_consistent() { + assert_eq!(cross3([1.0, 0.0, 0.0], [0.0, 1.0, 0.0]), [0.0, 0.0, 1.0]); + assert_eq!(average4([[0.0, 0.0, 0.0]; 4]), [0.0, 0.0, 0.0]); + let matrix = columns3([2.0, 0.0, 0.0], [0.0, 3.0, 0.0], [0.0, 0.0, 4.0]); + let inverse = inverse3(matrix).unwrap(); + assert_eq!(inverse[0][0], 0.5); + assert_eq!(inverse[1][1], 1.0 / 3.0); + assert_eq!(inverse[2][2], 0.25); + assert!(inverse3([[0.0; 3]; 3]).is_none()); +} diff --git a/rust/nmag_accel/tests/unit/dynamics_bdf.rs b/rust/nmag_accel/tests/unit/dynamics_bdf.rs new file mode 100644 index 0000000..4b8f384 --- /dev/null +++ b/rust/nmag_accel/tests/unit/dynamics_bdf.rs @@ -0,0 +1,60 @@ +use super::*; + +#[test] +fn affine_llg_jacobian_vector_matches_central_difference() { + let system = AffineLlg { + operator: vec![0.2, -0.1, 0.0, 0.3, 0.1, 0.0, -0.2, 0.0, 0.4], + constant_field: vec![0.5, -0.25, 0.75], + pin: vec![0.8], + coefficients: LlgCoefficients { + precession: -1.7, + damping: -0.3, + normalisation: 0.2, + }, + state_size: 3, + time_scale_seconds: 1.0, + }; + let state = vec![0.4, -0.7, 0.2]; + let direction = vec![0.3, 0.1, -0.5]; + let mut analytic = vec![0.0; 3]; + system.jacobian_vector(&state, &direction, &mut analytic); + + let epsilon = 1.0e-7; + let plus: Vec<_> = state + .iter() + .zip(&direction) + .map(|(value, delta)| value + epsilon * delta) + .collect(); + let minus: Vec<_> = state + .iter() + .zip(&direction) + .map(|(value, delta)| value - epsilon * delta) + .collect(); + let mut rhs_plus = vec![0.0; 3]; + let mut rhs_minus = vec![0.0; 3]; + system.rhs(&plus, &mut rhs_plus); + system.rhs(&minus, &mut rhs_minus); + for component in 0..3 { + let numerical = (rhs_plus[component] - rhs_minus[component]) / (2.0 * epsilon); + assert!((analytic[component] - numerical).abs() < 1.0e-8); + } +} + +#[test] +fn constant_field_does_not_contribute_to_field_direction() { + let system = AffineLlg { + operator: vec![2.0, 0.0, 0.0, 0.0, 3.0, 0.0, 0.0, 0.0, 4.0], + constant_field: vec![10.0, 20.0, 30.0], + pin: vec![1.0], + coefficients: LlgCoefficients { + precession: 0.0, + damping: 0.0, + normalisation: 0.0, + }, + state_size: 3, + time_scale_seconds: 1.0, + }; + let mut field = vec![0.0; 3]; + system.field(&[1.0, 2.0, 3.0], &mut field); + assert_eq!(field, vec![12.0, 26.0, 42.0]); +} diff --git a/rust/nmag_accel/tests/unit/fem.rs b/rust/nmag_accel/tests/unit/fem.rs new file mode 100644 index 0000000..f19ffd0 --- /dev/null +++ b/rust/nmag_accel/tests/unit/fem.rs @@ -0,0 +1,56 @@ +use super::*; +use ndarray::array; + +#[test] +fn degenerate_tetrahedron_has_no_geometry() { + let points = array![ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [1.0, 1.0, 0.0] + ]; + assert!(fem_cell_geometry(points.view(), [0, 1, 2, 3]).is_none()); +} + +#[test] +fn tetrahedron_volume_is_positive_for_reversed_orientation() { + let points = array![ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0] + ]; + let geometry = fem_cell_geometry(points.view(), [0, 2, 1, 3]).unwrap(); + assert!((geometry.volume - 1.0 / 6.0).abs() < 1e-14); +} + +#[test] +fn reference_tetrahedron_gradients_preserve_constant_fields() { + let points = array![ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0] + ]; + let geometry = fem_cell_geometry(points.view(), [0, 1, 2, 3]).unwrap(); + let gradient_sum = geometry.gradients.iter().fold([0.0; 3], |sum, gradient| { + [ + sum[0] + gradient[0], + sum[1] + gradient[1], + sum[2] + gradient[2], + ] + }); + assert!(gradient_sum.iter().all(|value| value.abs() < 1e-14)); + + let local_stiffness: [[f64; 4]; 4] = std::array::from_fn(|row| { + std::array::from_fn(|column| { + geometry.volume * dot3(geometry.gradients[row], geometry.gradients[column]) + }) + }); + for (row_index, row) in local_stiffness.iter().enumerate() { + assert!((row.iter().sum::()).abs() < 1e-14); + for (column_index, value) in row.iter().enumerate() { + assert!((value - local_stiffness[column_index][row_index]).abs() < 1e-14); + } + } +} diff --git a/rust/nmag_accel/tests/unit/lindholm.rs b/rust/nmag_accel/tests/unit/lindholm.rs new file mode 100644 index 0000000..61b16f4 --- /dev/null +++ b/rust/nmag_accel/tests/unit/lindholm.rs @@ -0,0 +1,17 @@ +use super::*; + +#[test] +fn boundary_local_indices_reject_interior_and_oversized_nodes() { + assert!(checked_boundary_local_index(-1, 2).is_err()); + assert!(checked_boundary_local_index(2, 2).is_err()); + assert_eq!(checked_boundary_local_index(1, 2).unwrap(), 1); +} + +#[test] +fn invalid_log_ratio_and_zero_angle_are_handled() { + assert!(safe_log_ratio(1.0, 0.0).is_nan()); + assert_eq!( + triangle_space_angle_from_offsets(0.0, 0.0, 0.0, 0.0, 0.0, 0.0), + 0.0 + ); +} diff --git a/rust/nmag_accel/tests/unit/lindholm_hmatrix.rs b/rust/nmag_accel/tests/unit/lindholm_hmatrix.rs new file mode 100644 index 0000000..e89cbea --- /dev/null +++ b/rust/nmag_accel/tests/unit/lindholm_hmatrix.rs @@ -0,0 +1,83 @@ +use ndarray::{arr1, arr2}; + +use super::cluster::ClusterTree; +use super::entry::ExactLindholmEvaluator; +use super::operator::{BuildOptions, HMatrixOperator}; + +fn tetrahedron_evaluator() -> ExactLindholmEvaluator { + let points = arr2(&[ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + ]); + let simplices = arr2(&[[0_i64, 1, 2, 3]]); + let faces = arr2(&[[1_i64, 2, 3], [0, 3, 2], [0, 1, 3], [0, 2, 1]]); + let boundary_nodes = arr1(&[0_i64, 1, 2, 3]); + let local_indices = arr1(&[0_i64, 1, 2, 3]); + ExactLindholmEvaluator::new( + points.view(), + simplices.view(), + faces.view(), + boundary_nodes.view(), + local_indices.view(), + ) + .unwrap() +} + +fn options(memory_budget: usize) -> BuildOptions { + BuildOptions { + tolerance: 1.0e-12, + eta: 2.0, + leaf_size: 2, + max_rank: 4, + validation_vectors: 2, + validation_rows: 4, + memory_budget, + } +} + +#[test] +fn cluster_tree_is_deterministic_and_covers_every_point() { + let points = [ + [0.0, 0.0, 0.0], + [2.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [3.0, 0.0, 0.0], + ]; + let first = ClusterTree::build(&points, 1); + let second = ClusterTree::build(&points, 1); + + assert_eq!(first.permutation, vec![0, 2, 1, 3]); + assert_eq!(first.permutation, second.permutation); + assert_eq!(first.nodes[first.root].start, 0); + assert_eq!(first.nodes[first.root].end, points.len()); +} + +#[test] +fn hierarchical_action_matches_exact_entries_deterministically() { + let evaluator = tetrahedron_evaluator(); + let operator = HMatrixOperator::build(&evaluator, options(1_000_000)).unwrap(); + let input = [0.25, -0.5, 1.25, 0.75]; + let expected = (0..evaluator.len()) + .map(|row| evaluator.row_dot(row, &input)) + .collect::>(); + + let first = operator.matvec(&input); + let second = operator.matvec(&input); + assert_eq!(first, second); + for (actual, expected) in first.iter().zip(expected) { + assert!((actual - expected).abs() < 1.0e-13); + } + assert!(operator.diagnostics().sampled_relative_error <= 1.0e-12); +} + +#[test] +fn construction_stops_at_the_memory_budget() { + let evaluator = tetrahedron_evaluator(); + let error = HMatrixOperator::build(&evaluator, options(1)) + .err() + .unwrap(); + + assert!(error.contains("memory budget exceeded")); +} diff --git a/rust/nmag_accel/tests/unit/llg_maxangle.rs b/rust/nmag_accel/tests/unit/llg_maxangle.rs new file mode 100644 index 0000000..6bf3d88 --- /dev/null +++ b/rust/nmag_accel/tests/unit/llg_maxangle.rs @@ -0,0 +1,13 @@ +use super::*; + +#[test] +fn empty_simplex_list_has_zero_maxangle() { + // The Python wrapper handles array conversion; this checks the reduction's identity. + assert_eq!(0.0_f64.max(0.0), 0.0); +} + +#[test] +fn angle_clamp_handles_rounding_outside_unit_interval() { + assert_eq!(1.0000000001_f64.clamp(-1.0, 1.0).acos(), 0.0); + assert!(((-1.0_f64).acos() * 180.0 / PI) - 180.0 < 1e-12); +} diff --git a/rust/nmag_accel/tests/unit/mesh_probe.rs b/rust/nmag_accel/tests/unit/mesh_probe.rs new file mode 100644 index 0000000..0231608 --- /dev/null +++ b/rust/nmag_accel/tests/unit/mesh_probe.rs @@ -0,0 +1,29 @@ +use super::*; +use ndarray::array; + +#[test] +fn boundary_face_vertex_order_is_stable() { + let simplex = [4usize, 2, 9, 1]; + let face = [ + simplex[TETRA_FACE_VERTICES[0][0]], + simplex[TETRA_FACE_VERTICES[0][1]], + simplex[TETRA_FACE_VERTICES[0][2]], + ]; + assert_eq!(face, [2, 9, 1]); +} + +#[test] +fn probe_geometry_skips_singular_tetrahedra() { + let points = array![ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [1.0, 1.0, 0.0] + ]; + let matrix = columns3( + subtract3(point3(points.view(), 1), point3(points.view(), 0)), + subtract3(point3(points.view(), 2), point3(points.view(), 0)), + subtract3(point3(points.view(), 3), point3(points.view(), 0)), + ); + assert!(inverse3(matrix).is_none()); +} diff --git a/scripts/build-accelerator.sh b/scripts/build-accelerator.sh new file mode 100755 index 0000000..af9fccb --- /dev/null +++ b/scripts/build-accelerator.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +VENV_PYTHON="$ROOT_DIR/.venv/bin/python" +MANIFEST_PATH="$ROOT_DIR/rust/nmag_accel/Cargo.toml" +build_mode=debug + +usage() { + printf 'Usage: %s [--release]\n' "${0##*/}" +} + +while (($#)); do + case "$1" in + --release) + build_mode=release + ;; + -h|--help) + usage + exit 0 + ;; + *) + usage >&2 + exit 2 + ;; + esac + shift +done + +if [[ ! -x "$VENV_PYTHON" ]]; then + printf 'Missing %s. Run ./scripts/setup.sh first.\n' "$VENV_PYTHON" >&2 + exit 1 +fi + +if ! "$VENV_PYTHON" -c 'import maturin' >/dev/null 2>&1; then + printf 'maturin is not installed in .venv. Run ./scripts/setup.sh or install .[rust].\n' >&2 + exit 1 +fi + +if ! command -v cargo >/dev/null 2>&1; then + printf 'Rust is required for the accelerator. Install it with rustup, then rerun this script.\n' >&2 + exit 1 +fi + +cd "$ROOT_DIR" +arguments=(develop --manifest-path "$MANIFEST_PATH") +if [[ "$build_mode" == release ]]; then + arguments+=(--release) +fi + +"$VENV_PYTHON" -m maturin "${arguments[@]}" diff --git a/scripts/setup.sh b/scripts/setup.sh new file mode 100755 index 0000000..ac7f88e --- /dev/null +++ b/scripts/setup.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +VENV_DIR="$ROOT_DIR/.venv" +PYTHON_BIN=${PYTHON:-python3} + +cd "$ROOT_DIR" + +if ! command -v "$PYTHON_BIN" >/dev/null 2>&1; then + printf 'Python executable not found: %s\n' "$PYTHON_BIN" >&2 + exit 1 +fi + +if [[ ! -x "$VENV_DIR/bin/python" ]]; then + printf 'Creating virtual environment in %s\n' "$VENV_DIR" + "$PYTHON_BIN" -m venv "$VENV_DIR" +fi + +VENV_PYTHON="$VENV_DIR/bin/python" +"$VENV_PYTHON" -m pip install --upgrade pip + +require_python_development_library() { + local library_path dev_package + library_path=$("$VENV_PYTHON" -c 'import os, sysconfig; print(os.path.join(sysconfig.get_config_var("LIBDIR") or "", sysconfig.get_config_var("LDLIBRARY") or ""))') + + # PyO3 links the accelerator against libpython. Runtime Python packages + # commonly provide only libpythonX.Y.so.1.0; the unversioned linker name is + # supplied by the matching development package. + if [[ -z "$library_path" || ! -e "$library_path" ]]; then + dev_package=$("$VENV_PYTHON" -c 'import sys; print(f"libpython{sys.version_info.major}.{sys.version_info.minor}-dev")') + printf 'Python development library not found: %s\n' "$library_path" >&2 + printf 'The Rust accelerator needs the matching development package. On Debian/Ubuntu run:\n' >&2 + printf ' sudo apt install %s\n' "$dev_package" >&2 + exit 1 + fi +} + +build_accelerator=${NMAG_SETUP_ACCELERATOR:-} +if [[ -z "$build_accelerator" && -t 0 ]]; then + read -r -p 'Build the optional Rust accelerator? [y/N] ' build_accelerator +fi + +case "${build_accelerator,,}" in + y|yes) + if ! command -v cargo >/dev/null 2>&1; then + printf 'Rust is required for the accelerator. Install it with rustup, then rerun setup.\n' >&2 + exit 1 + fi + require_python_development_library + # rustup installs `cargo` as a proxy. On machines without a configured + # default toolchain, selecting one explicitly keeps maturin and the + # subsequent build/check commands from failing before Rust starts. + export RUSTUP_TOOLCHAIN="${NMAG_RUST_TOOLCHAIN:-stable}" + printf 'Using Rust toolchain: %s\n' "$RUSTUP_TOOLCHAIN" + "$VENV_PYTHON" -m pip install -e '.[dev,rust]' + "$ROOT_DIR/scripts/build-accelerator.sh" --release + "$ROOT_DIR/scripts/verify.sh" --rust + ;; + *) + "$VENV_PYTHON" -m pip install -e '.[dev]' + "$ROOT_DIR/scripts/verify.sh" + ;; +esac diff --git a/scripts/verify.sh b/scripts/verify.sh new file mode 100755 index 0000000..46e86bd --- /dev/null +++ b/scripts/verify.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +VENV_BIN="$ROOT_DIR/.venv/bin" +VENV_PYTHON="$VENV_BIN/python" +run_rust=false + +usage() { + printf 'Usage: %s [--rust]\n' "${0##*/}" +} + +while (($#)); do + case "$1" in + --rust) + run_rust=true + ;; + -h|--help) + usage + exit 0 + ;; + *) + usage >&2 + exit 2 + ;; + esac + shift +done + +if [[ ! -x "$VENV_PYTHON" ]]; then + printf 'Missing %s. Run ./scripts/setup.sh first.\n' "$VENV_PYTHON" >&2 + exit 1 +fi + +for tool in ruff pyright; do + if [[ ! -x "$VENV_BIN/$tool" ]]; then + printf 'Missing %s in .venv. Run ./scripts/setup.sh first.\n' "$tool" >&2 + exit 1 + fi +done + +cd "$ROOT_DIR" +printf 'Running Ruff...\n' +if ! ruff_output=$("$VENV_BIN/ruff" check src tests); then + printf '%s\n' "$ruff_output" >&2 + exit 1 +fi + +printf 'Running Pyright...\n' +"$VENV_BIN/pyright" + +printf 'Running Python tests...\n' +"$VENV_PYTHON" -m pytest + +if [[ "$run_rust" == true ]]; then + if ! command -v cargo >/dev/null 2>&1; then + printf 'Rust checks requested, but cargo is not installed.\n' >&2 + exit 1 + fi + printf 'Running Rust formatting check...\n' + cargo fmt --manifest-path rust/nmag_accel/Cargo.toml --all -- --check + + printf 'Running Rust lint...\n' + cargo clippy --manifest-path rust/nmag_accel/Cargo.toml --all-targets -- -D warnings + + printf 'Running Rust tests...\n' + cargo test --manifest-path rust/nmag_accel/Cargo.toml +fi + +printf 'All requested checks passed.\n' diff --git a/src/anisotropy/__init__.py b/src/anisotropy/__init__.py index 5d702b6..3f1783c 100644 --- a/src/anisotropy/__init__.py +++ b/src/anisotropy/__init__.py @@ -1 +1,28 @@ -from .anisotropy import * +from .evaluation import ( + anisotropy_signature_values, + evaluate_energy_and_gradient, + evaluate_energy_density, + evaluate_energy_gradient, +) +from .model import ( + AnisotropyModel, + EnergyDensity, + EnergyFunction, + PredefinedAnisotropy, + want_anisotropy, +) +from .predefined import cubic_anisotropy, uniaxial_anisotropy + +__all__ = [ + "AnisotropyModel", + "EnergyDensity", + "EnergyFunction", + "PredefinedAnisotropy", + "anisotropy_signature_values", + "cubic_anisotropy", + "evaluate_energy_density", + "evaluate_energy_and_gradient", + "evaluate_energy_gradient", + "uniaxial_anisotropy", + "want_anisotropy", +] diff --git a/src/anisotropy/anisotropy.py b/src/anisotropy/anisotropy.py index c1a1ccb..9735171 100644 --- a/src/anisotropy/anisotropy.py +++ b/src/anisotropy/anisotropy.py @@ -1,188 +1,13 @@ -''' -This file contains the definition of the PredefinedAnisotropy class -and some useful functions for defining the magnetic anisotropy -of a magnetic material. -''' - -import numpy as np - -def _normalize(v): - """Helper function to normalize a NumPy vector, handling the zero vector case.""" - v = np.asarray(v, dtype=float) - norm = np.linalg.norm(v) - if norm == 0: - raise ValueError("Cannot normalize a zero vector.") - return v / norm - -def want_anisotropy(x, want_function=True): - """ - Checks if x is a valid PredefinedAnisotropy object. - - Args: - x: The object to check. - want_function (bool): If True, requires the object to have an - associated energy function. - """ - if not isinstance(x, PredefinedAnisotropy): - # Use TypeError for incorrect argument types - raise TypeError(f"Operand must be a PredefinedAnisotropy object, not {type(x).__name__}") - if want_function and not x.has_function(): - # Use ValueError for correct type but invalid state - raise ValueError("Cannot operate on an anisotropy object that lacks an energy function.") - -class PredefinedAnisotropy: - """ - Contains an anisotropy energy function and its approximation order. - """ - def __init__(self, function=None, order=None, - anis_type="functional", - axis1=None, axis2=None, axis3=None, - K1=None, K2=None, K3=None, - stringifier=None): - if function is None and order is None: - # Use ValueError for invalid argument values - raise ValueError("PredefinedAnisotropy requires either a 'function' or an 'order'.") - self.anis_type = anis_type - self.function = function - self.order = order - self.K1 = K1 - self.K2 = K2 - self.K3 = K3 - # Ensure axes are NumPy arrays if they exist - self.axis1 = np.asarray(axis1) if axis1 is not None else None - self.axis2 = np.asarray(axis2) if axis2 is not None else None - self.axis3 = np.asarray(axis3) if axis3 is not None else None - self._str_extra = stringifier - - def has_function(self): - """Returns True if the anisotropy has an energy function.""" - return self.function is not None - - def __str__(self): - s = self.anis_type - if self._str_extra: - s += f", {self._str_extra(self)}" - return f"" - - def __repr__(self): - s = self._str_extra(self) if self._str_extra else "?" - return f'PredefinedAnisotropy(anis_type="{self.anis_type}", {s})' - - def __neg__(self): - """Unary operator -""" - neg_function = lambda m: -self.function(m) - return PredefinedAnisotropy(neg_function, self.order) - - def __pos__(self): - """Unary operator +""" - return self - - def __add__(self, y): - """Addition operator""" - want_anisotropy(y) - f = lambda m: self.function(m) + y.function(m) - o = max(self.order, y.order) - return PredefinedAnisotropy(f, o) - - def __sub__(self, y): - """Subtraction operator""" - want_anisotropy(y) - f = lambda m: self.function(m) - y.function(m) - o = max(self.order, y.order) - return PredefinedAnisotropy(f, o) - -def uniaxial_anisotropy(axis, K1, K2=0): - """ - Returns a predefined anisotropy for a uniaxial energy density term: - E_anis = - K1 * ^2 - K2 * ^4 - where 'm' is the normalized magnetization. - - Args: - axis (list or np.ndarray): Easy or hard axis (will be normalized). - K1 (float): Second-order anisotropy constant. - K2 (float): Fourth-order anisotropy constant (default is 0). - """ - # Normalize the easy axis using NumPy - axis = _normalize(axis) - - # Build the anisotropy function using NumPy's dot product - def f(m): - m = np.asarray(m, dtype=float) - a = np.dot(axis, m) - return -K1 * a**2 - K2 * a**4 - - order = 4 if K2 else 2 - - def stringifier(a): - s = f"axis={list(a.axis1)}, K1={a.K1}" - if a.K2 != 0.0: - s += f", K2={a.K2}" - return s - - return PredefinedAnisotropy(anis_type="uniaxial", - function=f, order=order, - axis1=axis, K1=K1, K2=K2, - stringifier=stringifier) - - -def cubic_anisotropy(axis1, axis2, K1, K2=0, K3=0): - """ - Returns a predefined anisotropy for a cubic energy density term: - E_anis = K1 * (^2 ^2 + ^2 ^2 + ^2 ^2) - + K2 * (^2 ^2 ^2) - + K3 * (^4 ^4 + ^4 ^4 + ^4 ^4) - (where `m` is the (normalised) magnetisation.) - - Args: - axis1 (list or np.ndarray): First cubic axis (will be normalized). - axis2 (list or np.ndarray): Second cubic axis (will be orthonormalized). - K1 (float): Fourth-order anisotropy constant. - K2 (float): Sixth-order anisotropy constant (default is 0). - K3 (float): Eighth-order anisotropy constant (default is 0). - """ - # Convert inputs to NumPy arrays - a1 = np.asarray(axis1, dtype=float) - a2 = np.asarray(axis2, dtype=float) - - # Build an orthonormal system using NumPy - a3 = np.cross(a1, a2) - a2 = np.cross(a3, a1) # Ensure a2 is orthogonal to a1 and a3 - - # Normalize all axes - axis1 = _normalize(a1) - axis2 = _normalize(a2) - axis3 = _normalize(a3) - - # Build anisotropy function using NumPy - def f(m): - m = np.asarray(m, dtype=float) - a1_dot_m = np.dot(axis1, m) - a2_dot_m = np.dot(axis2, m) - a3_dot_m = np.dot(axis3, m) - - term1 = (a1_dot_m*a2_dot_m)**2 + (a1_dot_m*a3_dot_m)**2 + (a2_dot_m*a3_dot_m)**2 - term2 = (a1_dot_m*a2_dot_m*a3_dot_m)**2 - term3 = (a1_dot_m*a2_dot_m)**4 + (a1_dot_m*a3_dot_m)**4 + (a2_dot_m*a3_dot_m)**4 - - return K1*term1 + K2*term2 + K3*term3 - - def stringifier(a): - s = f"axis1={list(a.axis1)}, axis2={list(a.axis2)}, K1={a.K1}" - if a.K2 != 0.0: - s += f", K2={a.K2}" - if a.K3 != 0.0: - s += f", K3={a.K3}" - return s - - if K3: - order = 8 - elif K2: - order = 6 - else: - order = 4 - - return PredefinedAnisotropy(anis_type="cubic", - function=f, order=order, - axis1=axis1, axis2=axis2, axis3=axis3, - K1=K1, K2=K2, K3=K3, - stringifier=stringifier) +"""Compatibility exports for the focused anisotropy modules.""" + +from .model import PredefinedAnisotropy, want_anisotropy +from .predefined import cubic_anisotropy, uniaxial_anisotropy +from .values import _normalize + +__all__ = [ + "PredefinedAnisotropy", + "_normalize", + "cubic_anisotropy", + "uniaxial_anisotropy", + "want_anisotropy", +] diff --git a/src/anisotropy/evaluation.py b/src/anisotropy/evaluation.py new file mode 100644 index 0000000..d6795d1 --- /dev/null +++ b/src/anisotropy/evaluation.py @@ -0,0 +1,65 @@ +"""Evaluate arbitrary anisotropy models and build compatibility samples.""" + +from __future__ import annotations + +import numpy as np +from numpy.typing import ArrayLike + +from .model import AnisotropyModel, FloatArray, PredefinedAnisotropy +from .values import _callable_energy, _finite_difference_gradient, _magnetisation_array, _normalize + + +def evaluate_energy_density(model: AnisotropyModel, magnetisation: ArrayLike) -> FloatArray: + values, _scalar = _magnetisation_array(magnetisation) + if model is None: + return np.zeros(len(values), dtype=np.float64) + if isinstance(model, PredefinedAnisotropy): + return np.asarray(model.energy_density(values), dtype=np.float64) + return _callable_energy(model, values) + + +def evaluate_energy_gradient(model: AnisotropyModel, magnetisation: ArrayLike) -> FloatArray: + values, _scalar = _magnetisation_array(magnetisation) + if model is None: + return np.zeros_like(values) + if isinstance(model, PredefinedAnisotropy): + return np.asarray(model.energy_gradient(values), dtype=np.float64) + return _finite_difference_gradient(model, values) + + +def evaluate_energy_and_gradient( + model: AnisotropyModel, + magnetisation: ArrayLike, +) -> tuple[FloatArray, FloatArray]: + values, _scalar = _magnetisation_array(magnetisation) + if model is None: + return np.zeros(len(values), dtype=np.float64), np.zeros_like(values) + if isinstance(model, PredefinedAnisotropy): + return model.energy_and_gradient(values) + return _callable_energy(model, values), _finite_difference_gradient(model, values) + + +def anisotropy_signature_values( + model: AnisotropyModel, + order: int | None, +) -> FloatArray: + if model is None: + return np.asarray([0.0], dtype=np.float64) + probes = np.asarray( + [ + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + _normalize([1.0, 1.0, 1.0]), + _normalize([1.0, 2.0, 3.0]), + ], + dtype=np.float64, + ) + energy, gradient = evaluate_energy_and_gradient(model, probes) + return np.concatenate( + ( + np.asarray([float(order or 0)], dtype=np.float64), + energy, + np.ravel(gradient), + ) + ) diff --git a/src/anisotropy/model.py b/src/anisotropy/model.py new file mode 100644 index 0000000..585e114 --- /dev/null +++ b/src/anisotropy/model.py @@ -0,0 +1,200 @@ +"""Typed anisotropy models and numerical evaluation helpers.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import TypeAlias, cast + +import numpy as np +from numpy.typing import ArrayLike + +from si.physical import SI + +from .values import ( + FloatArray, + _callable_energy, + _finite_difference_gradient, + _magnetisation_array, +) + +EnergyDensity: TypeAlias = float | SI +EnergyFunction: TypeAlias = Callable[[ArrayLike], EnergyDensity] +GradientFunction: TypeAlias = Callable[[FloatArray], FloatArray] +VectorizedEnergyFunction: TypeAlias = Callable[[FloatArray], FloatArray] +EnergyGradientFunction: TypeAlias = Callable[[FloatArray], tuple[FloatArray, FloatArray]] +AnisotropyStringifier: TypeAlias = Callable[["PredefinedAnisotropy"], str] +AnisotropyModel: TypeAlias = "PredefinedAnisotropy | EnergyFunction | None" + + +@dataclass(frozen=True, slots=True) +class PredefinedAnisotropy: + """An anisotropy energy model with optional analytic vectorized evaluation. + + Instances returned by :func:`uniaxial_anisotropy` and + :func:`cubic_anisotropy` can be added, subtracted, negated, and multiplied by + scalar coefficients before being assigned to :class:`nmag.MagMaterial`. + """ + + function: EnergyFunction | None = None + order: int | None = None + anis_type: str = "functional" + axis1: ArrayLike | None = None + axis2: ArrayLike | None = None + axis3: ArrayLike | None = None + K1: EnergyDensity | None = None + K2: EnergyDensity | None = None + K3: EnergyDensity | None = None + stringifier: AnisotropyStringifier | None = field(default=None, repr=False) + _vectorized_energy: VectorizedEnergyFunction | None = field( + default=None, + repr=False, + compare=False, + ) + _vectorized_gradient: GradientFunction | None = field( + default=None, + repr=False, + compare=False, + ) + _vectorized_energy_gradient: EnergyGradientFunction | None = field( + default=None, + repr=False, + compare=False, + ) + + def __post_init__(self) -> None: + if self.function is None and self.order is None: + raise ValueError("PredefinedAnisotropy requires either a 'function' or an 'order'.") + if self.function is not None and not callable(self.function): + raise TypeError("Anisotropy function must be callable.") + order = cast(object, self.order) + if order is not None and (type(order) is bool or not isinstance(order, int) or order <= 0): + raise ValueError("Anisotropy order must be a positive integer.") + for name in ("axis1", "axis2", "axis3"): + axis = getattr(self, name) + if axis is not None: + frozen_axis = np.array(axis, dtype=np.float64, copy=True) + frozen_axis.setflags(write=False) + object.__setattr__(self, name, frozen_axis) + + def has_function(self) -> bool: + return self.function is not None + + def energy_density(self, magnetisation: ArrayLike) -> FloatArray | float: + values, scalar = _magnetisation_array(magnetisation) + if self._vectorized_energy is not None: + result = np.asarray(self._vectorized_energy(values), dtype=np.float64) + elif self.function is not None: + result = _callable_energy(self.function, values) + else: + raise ValueError("This anisotropy model has no energy function.") + if result.shape != (len(values),) or not np.all(np.isfinite(result)): + raise ValueError("Anisotropy energy evaluation returned invalid values.") + return float(result[0]) if scalar else result + + def energy_gradient(self, magnetisation: ArrayLike) -> FloatArray: + values, scalar = _magnetisation_array(magnetisation) + if self._vectorized_gradient is not None: + result = np.asarray(self._vectorized_gradient(values), dtype=np.float64) + elif self.function is not None: + result = _finite_difference_gradient(self.function, values) + else: + raise ValueError("This anisotropy model has no energy function.") + if result.shape != values.shape or not np.all(np.isfinite(result)): + raise ValueError("Anisotropy gradient evaluation returned invalid values.") + return result[0] if scalar else result + + def energy_and_gradient(self, magnetisation: ArrayLike) -> tuple[FloatArray, FloatArray]: + values, _scalar = _magnetisation_array(magnetisation) + if self._vectorized_energy_gradient is not None: + energy, gradient = self._vectorized_energy_gradient(values) + energy = np.asarray(energy, dtype=np.float64) + gradient = np.asarray(gradient, dtype=np.float64) + else: + energy = np.asarray(self.energy_density(values), dtype=np.float64) + gradient = np.asarray(self.energy_gradient(values), dtype=np.float64) + if energy.shape != (len(values),) or gradient.shape != values.shape: + raise ValueError("Anisotropy energy/gradient evaluation returned invalid shapes.") + if not np.all(np.isfinite(energy)) or not np.all(np.isfinite(gradient)): + raise ValueError("Anisotropy energy/gradient evaluation returned non-finite values.") + return energy, gradient + + def __str__(self) -> str: + suffix = f", {self.stringifier(self)}" if self.stringifier else "" + return f"" + + def __repr__(self) -> str: + details = self.stringifier(self) if self.stringifier else "?" + return f'PredefinedAnisotropy(anis_type="{self.anis_type}", {details})' + + def __neg__(self) -> PredefinedAnisotropy: + want_anisotropy(self) + return _combine_anisotropy(self, None, -1.0) + + def __pos__(self) -> PredefinedAnisotropy: + return self + + def __add__(self, other: object) -> PredefinedAnisotropy: + want_anisotropy(other) + assert isinstance(other, PredefinedAnisotropy) + return _combine_anisotropy(self, other, 1.0) + + def __sub__(self, other: object) -> PredefinedAnisotropy: + want_anisotropy(other) + assert isinstance(other, PredefinedAnisotropy) + return _combine_anisotropy(self, other, -1.0) + + +def _combine_anisotropy( + left: PredefinedAnisotropy, + right: PredefinedAnisotropy | None, + right_scale: float, +) -> PredefinedAnisotropy: + def vectorized_energy_gradient(values: FloatArray) -> tuple[FloatArray, FloatArray]: + left_energy, left_gradient = left.energy_and_gradient(values) + if right is None: + return right_scale * left_energy, right_scale * left_gradient + right_energy, right_gradient = right.energy_and_gradient(values) + return ( + left_energy + right_scale * right_energy, + left_gradient + right_scale * right_gradient, + ) + + def vectorized_energy(values: FloatArray) -> FloatArray: + left_energy = np.asarray(left.energy_density(values), dtype=np.float64) + if right is None: + return right_scale * left_energy + return left_energy + right_scale * np.asarray( + right.energy_density(values), dtype=np.float64 + ) + + def vectorized_gradient(values: FloatArray) -> FloatArray: + left_gradient = np.asarray(left.energy_gradient(values), dtype=np.float64) + if right is None: + return right_scale * left_gradient + return left_gradient + right_scale * np.asarray( + right.energy_gradient(values), + dtype=np.float64, + ) + + def scalar_energy(values: ArrayLike) -> float: + array, _scalar = _magnetisation_array(values) + return float(vectorized_energy(array)[0]) + + order = left.order if right is None else max(left.order or 0, right.order or 0) + return PredefinedAnisotropy( + function=scalar_energy, + order=order or None, + _vectorized_energy=vectorized_energy, + _vectorized_gradient=vectorized_gradient, + _vectorized_energy_gradient=vectorized_energy_gradient, + ) + + +def want_anisotropy(value: object, want_function: bool = True) -> None: + if not isinstance(value, PredefinedAnisotropy): + raise TypeError( + f"Operand must be a PredefinedAnisotropy object, not {type(value).__name__}" + ) + if want_function and not value.has_function(): + raise ValueError("Cannot operate on an anisotropy object that lacks an energy function.") diff --git a/src/anisotropy/predefined.py b/src/anisotropy/predefined.py new file mode 100644 index 0000000..3e94eb3 --- /dev/null +++ b/src/anisotropy/predefined.py @@ -0,0 +1,203 @@ +"""Vectorized predefined magnetic anisotropy models.""" + +from __future__ import annotations + +import numpy as np +from numpy.typing import ArrayLike + +from .model import EnergyDensity, PredefinedAnisotropy +from .values import FloatArray, _normalize, energy_density_value + + +def _constant(value: EnergyDensity, name: str) -> float: + try: + return energy_density_value(value) + except (TypeError, ValueError) as exc: + raise TypeError(f"{name} must be an energy density compatible with J/m^3.") from exc + + +def uniaxial_anisotropy( + axis: ArrayLike, + K1: EnergyDensity, + K2: EnergyDensity = 0.0, +) -> PredefinedAnisotropy: + """Create a uniaxial anisotropy energy model. + + The energy density is ``-K1 (axis·m)^2 - K2 (axis·m)^4``. + + Args: + axis: Three-component easy-axis direction; normalized internally. + K1: Second-order coefficient as J/m³ or a compatible SI quantity. + K2: Fourth-order coefficient as J/m³ or a compatible SI quantity. + + Returns: + Vectorized predefined anisotropy with analytic gradient evaluation. + + Raises: + ValueError: If the axis is zero or a coefficient is non-finite. + """ + + normalized_axis = _normalize(axis) + k1 = _constant(K1, "K1") + k2 = _constant(K2, "K2") + + def energy(values: FloatArray) -> FloatArray: + projection = values @ normalized_axis + return -k1 * projection**2 - k2 * projection**4 + + def gradient(values: FloatArray) -> FloatArray: + projection = values @ normalized_axis + factors = -2.0 * k1 * projection - 4.0 * k2 * projection**3 + return factors[:, np.newaxis] * normalized_axis + + def energy_gradient(values: FloatArray) -> tuple[FloatArray, FloatArray]: + projection = values @ normalized_axis + energy_values = -k1 * projection**2 - k2 * projection**4 + factors = -2.0 * k1 * projection - 4.0 * k2 * projection**3 + return energy_values, factors[:, np.newaxis] * normalized_axis + + def scalar_energy(values: ArrayLike) -> float: + vector = np.asarray(values, dtype=np.float64) + return float(energy(vector[np.newaxis, :])[0]) + + def stringifier(model: PredefinedAnisotropy) -> str: + assert model.axis1 is not None + axis_values = np.asarray(model.axis1, dtype=np.float64).tolist() + result = f"axis={axis_values}, K1={model.K1}" + if k2 != 0.0: + result += f", K2={model.K2}" + return result + + return PredefinedAnisotropy( + anis_type="uniaxial", + function=scalar_energy, + order=4 if k2 != 0.0 else 2, + axis1=normalized_axis, + K1=K1, + K2=K2, + stringifier=stringifier, + _vectorized_energy=energy, + _vectorized_gradient=gradient, + _vectorized_energy_gradient=energy_gradient, + ) + + +def cubic_anisotropy( + axis1: ArrayLike, + axis2: ArrayLike, + K1: EnergyDensity, + K2: EnergyDensity = 0.0, + K3: EnergyDensity = 0.0, +) -> PredefinedAnisotropy: + """Create conventional fourth-, sixth-, and eighth-order cubic anisotropy. + + Args: + axis1: First crystalline axis; normalized internally. + axis2: Second crystalline axis, required to be orthogonal to ``axis1``. + K1: Fourth-order coefficient as J/m³ or a compatible SI quantity. + K2: Sixth-order coefficient as J/m³ or a compatible SI quantity. + K3: Eighth-order coefficient as J/m³ or a compatible SI quantity. + + Returns: + Vectorized predefined anisotropy with analytic gradient evaluation. + + Raises: + ValueError: If axes are invalid or coefficients are non-finite. + """ + + first = _normalize(axis1) + raw_second = np.asarray(axis2, dtype=np.float64) + third = _normalize(np.cross(first, raw_second)) + second = _normalize(np.cross(third, first)) + axes: FloatArray = np.asarray((first, second, third), dtype=np.float64) + axes_transpose: FloatArray = np.ascontiguousarray(np.transpose(axes)) + k1, k2, k3 = (_constant(value, name) for value, name in ((K1, "K1"), (K2, "K2"), (K3, "K3"))) + + def energy(values: FloatArray) -> FloatArray: + projections: FloatArray = np.asarray(values @ axes_transpose, dtype=np.float64) + squared: FloatArray = projections**2 + fourth: FloatArray = squared**2 + pair2 = ( + squared[:, 0] * squared[:, 1] + + squared[:, 0] * squared[:, 2] + + squared[:, 1] * squared[:, 2] + ) + triple2 = squared[:, 0] * squared[:, 1] * squared[:, 2] + pair4 = ( + fourth[:, 0] * fourth[:, 1] + fourth[:, 0] * fourth[:, 2] + fourth[:, 1] * fourth[:, 2] + ) + return k1 * pair2 + k2 * triple2 + k3 * pair4 + + def gradient(values: FloatArray) -> FloatArray: + projections: FloatArray = np.asarray(values @ axes_transpose, dtype=np.float64) + squared: FloatArray = projections**2 + fourth: FloatArray = squared**2 + derivatives: FloatArray = np.empty_like(projections) + for axis in range(3): + other = [index for index in range(3) if index != axis] + derivatives[:, axis] = ( + 2.0 * k1 * projections[:, axis] * (squared[:, other[0]] + squared[:, other[1]]) + + 2.0 * k2 * projections[:, axis] * squared[:, other[0]] * squared[:, other[1]] + + 4.0 * k3 * projections[:, axis] ** 3 * (fourth[:, other[0]] + fourth[:, other[1]]) + ) + return derivatives @ axes + + def energy_gradient(values: FloatArray) -> tuple[FloatArray, FloatArray]: + projections: FloatArray = np.asarray(values @ axes_transpose, dtype=np.float64) + squared: FloatArray = projections**2 + fourth: FloatArray = squared**2 + pair2 = ( + squared[:, 0] * squared[:, 1] + + squared[:, 0] * squared[:, 2] + + squared[:, 1] * squared[:, 2] + ) + energy_values = ( + k1 * pair2 + + k2 * squared[:, 0] * squared[:, 1] * squared[:, 2] + + k3 + * ( + fourth[:, 0] * fourth[:, 1] + + fourth[:, 0] * fourth[:, 2] + + fourth[:, 1] * fourth[:, 2] + ) + ) + derivatives: FloatArray = np.empty_like(projections) + for axis in range(3): + other = [index for index in range(3) if index != axis] + derivatives[:, axis] = ( + 2.0 * k1 * projections[:, axis] * (squared[:, other[0]] + squared[:, other[1]]) + + 2.0 * k2 * projections[:, axis] * squared[:, other[0]] * squared[:, other[1]] + + 4.0 * k3 * projections[:, axis] ** 3 * (fourth[:, other[0]] + fourth[:, other[1]]) + ) + return energy_values, derivatives @ axes + + def scalar_energy(values: ArrayLike) -> float: + vector = np.asarray(values, dtype=np.float64) + return float(energy(vector[np.newaxis, :])[0]) + + def stringifier(model: PredefinedAnisotropy) -> str: + assert model.axis1 is not None and model.axis2 is not None + first_values = np.asarray(model.axis1, dtype=np.float64).tolist() + second_values = np.asarray(model.axis2, dtype=np.float64).tolist() + result = f"axis1={first_values}, axis2={second_values}, K1={model.K1}" + if k2 != 0.0: + result += f", K2={model.K2}" + if k3 != 0.0: + result += f", K3={model.K3}" + return result + + return PredefinedAnisotropy( + anis_type="cubic", + function=scalar_energy, + order=8 if k3 != 0.0 else 6 if k2 != 0.0 else 4, + axis1=first, + axis2=second, + axis3=third, + K1=K1, + K2=K2, + K3=K3, + stringifier=stringifier, + _vectorized_energy=energy, + _vectorized_gradient=gradient, + _vectorized_energy_gradient=energy_gradient, + ) diff --git a/src/anisotropy/py.typed b/src/anisotropy/py.typed new file mode 100644 index 0000000..534be2b --- /dev/null +++ b/src/anisotropy/py.typed @@ -0,0 +1 @@ +# Marker for PEP 561 inline type information. diff --git a/src/anisotropy/values.py b/src/anisotropy/values.py new file mode 100644 index 0000000..60d8a0a --- /dev/null +++ b/src/anisotropy/values.py @@ -0,0 +1,79 @@ +"""Validation and scalar conversion helpers for anisotropy models.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any, cast + +import numpy as np +from numpy.typing import ArrayLike, NDArray + +from si.physical import SI + +FloatArray = NDArray[np.float64] +RawEnergyFunction = Callable[[ArrayLike], object] + +_ENERGY_DENSITY_UNIT = SI(1.0, "J/m^3") +_FINITE_DIFFERENCE_STEP = float(np.cbrt(np.finfo(np.float64).eps)) + + +def _normalize(values: ArrayLike) -> FloatArray: + vector = np.asarray(values, dtype=np.float64) + if vector.shape != (3,): + raise ValueError(f"Anisotropy axes must be 3-vectors, got shape {vector.shape}.") + if not np.all(np.isfinite(vector)): + raise ValueError("Anisotropy axes must contain only finite values.") + norm = float(np.linalg.norm(vector)) + if norm == 0.0: + raise ValueError("Cannot normalize a zero vector.") + return vector / norm + + +def energy_density_value(value: object) -> float: + result = ( + value.in_units_of(_ENERGY_DENSITY_UNIT) + if isinstance(value, SI) + else float(cast(Any, value)) + ) + if not np.isfinite(result): + raise ValueError("Anisotropy energy density must be finite.") + return result + + +def _magnetisation_array(values: ArrayLike) -> tuple[FloatArray, bool]: + array = np.asarray(values, dtype=np.float64) + scalar = array.ndim == 1 + if scalar: + array = array[np.newaxis, :] + if array.ndim != 2 or array.shape[1] != 3: + raise ValueError(f"Magnetisation must have shape (3,) or (n, 3), got {array.shape}.") + if not np.all(np.isfinite(array)): + raise ValueError("Magnetisation must contain only finite values.") + return np.ascontiguousarray(array), scalar + + +def _callable_energy(function: RawEnergyFunction, magnetisation: FloatArray) -> FloatArray: + return np.asarray( + [energy_density_value(function(vector)) for vector in magnetisation], + dtype=np.float64, + ) + + +def _finite_difference_gradient( + function: RawEnergyFunction, + magnetisation: FloatArray, +) -> FloatArray: + gradient = np.empty_like(magnetisation) + for row, vector in enumerate(magnetisation): + for component in range(3): + step = _FINITE_DIFFERENCE_STEP * max(1.0, abs(float(vector[component]))) + plus = np.array(vector, copy=True) + minus = np.array(vector, copy=True) + plus[component] += step + minus[component] -= step + gradient[row, component] = ( + energy_density_value(function(plus)) - energy_density_value(function(minus)) + ) / (2.0 * step) + if not np.all(np.isfinite(gradient)): + raise ValueError("Anisotropy energy gradient must be finite.") + return gradient diff --git a/src/mag_material/__init__.py b/src/mag_material/__init__.py index 54280e0..0242dc5 100644 --- a/src/mag_material/__init__.py +++ b/src/mag_material/__init__.py @@ -1 +1,3 @@ -from .mag_material import * +from .mag_material import MagMaterial + +__all__ = ["MagMaterial"] diff --git a/src/mag_material/mag_material.py b/src/mag_material/mag_material.py index 982b81e..dd390ed 100644 --- a/src/mag_material/mag_material.py +++ b/src/mag_material/mag_material.py @@ -1,206 +1,231 @@ -""" -Definition of the MagMaterial class, which allows to define a magnetic -material and all its parameters (saturation magnetisation, exchange coupling, -etc.) -""" - -import logging - -from si.physical import SI -from si import constants -from anisotropy import PredefinedAnisotropy - -log = logging.getLogger('nmag') - -class MagMaterial(object): - """ - Represents a magnetic material, defining its physical properties such as - saturation magnetisation, exchange coupling, and LLG parameters. - - All physical quantities should be provided as SI objects to ensure - dimensional correctness. - """ - - def __init__(self, - name, - Ms=SI(0.86e6 * constants.Ampere / constants.meter), - llg_damping=0.5, - llg_gamma_G=SI(2.210173e5 * constants.meter / (constants.Ampere * constants.second)), - llg_normalisationfactor=SI(0.1e12 * 1 / constants.second), - llg_xi=0.0, - llg_polarisation=0.0, - do_precession=True, - exchange_coupling=SI(1.3e-11 * constants.Joule / constants.meter), - anisotropy=None, - anisotropy_order=None, - properties=["magnetic", "material"], - scale_volume_charges=1.0 - ): - """ - Initializes a magnetic material with its physical properties. - - :Parameters: - `name` : string - The name of the material (e.g., 'Py', 'Fe'). - - `Ms` : SI Object - Saturation magnetisation in Amperes per meter. - - `llg_damping` : float or SI Object - The dimensionless Gilbert damping parameter (alpha). - - `llg_gamma_G` : SI Object - The gyromagnetic ratio for the LLG equation. - - `exchange_coupling` : SI Object - The exchange coupling constant 'A' in Joules per meter. - - `anisotropy` : PredefinedAnisotropy or callable - The anisotropy model. - - `anisotropy_order` : int - The order of a custom polynomial anisotropy function. - - `do_precession` : bool - If False, switches off the precessional term in the LLG equation. - - `llg_normalisationfactor` : SI Object - A coefficient for an extra term added to the LLG right-hand side to correct - numerical errors in the magnetization norm. - - `llg_xi` : float or SI Object - For spin-transfer-torque, this is the ratio between the exchange and the - spin-flip relaxation times (xi = tau_ex / tau_sf). - - `llg_polarisation` : float or SI Object - For spin-transfer-torque, this is the polarisation of the spin-current. - - `properties` : list of strings - A list of properties associated with the material, used internally by the - simulation to set up operators. - - `scale_volume_charges` : float - A debugging parameter for developers. - """ - self.name = name - self.Ms = Ms - self.llg_gamma_G = llg_gamma_G - self.llg_damping = llg_damping - self.llg_normalisationfactor = llg_normalisationfactor - self.llg_xi = llg_xi - self.llg_polarisation = llg_polarisation - self.do_precession = do_precession - self.properties = properties - self.exchange_coupling = exchange_coupling - self.scale_volume_charges = scale_volume_charges - - # --- Unit validation --- - one = SI(1) - expected_units = ( - ("Ms", SI(constants.Ampere / constants.meter)), - ("llg_gamma_G", SI(constants.meter / (constants.Ampere * constants.second))), - ("llg_damping", one), - ("llg_normalisationfactor", SI(1 / constants.second)), - ("llg_xi", one), - ("llg_polarisation", one), - ("exchange_coupling", SI(constants.Joule / constants.meter)) - ) - - for attr_name, expected_unit in expected_units: - value = getattr(self, attr_name) - - # The value could be an SI object or a raw number (e.g., float) - # Check compatibility between its quantities and raw numbers - if isinstance(value, SI): - value_to_check = value._quantity - else: - value_to_check = value # It's a raw number/ - - if not expected_unit._quantity.is_compatible_with(value_to_check): - raise TypeError( - f"The argument '{attr_name}' for material '{self.name}' " - f"requires units compatible with {expected_unit.dens_str()}, " - f"but received a value of '{value}'." - ) - - # Check for physically valid exchange coupling - if self.exchange_coupling < 0.0: - raise ValueError( - f"The exchange coupling constant must be positive. For " - f"material '{self.name}', you specified: {self.exchange_coupling}." - ) - - # TODO: Revisit anisotropy handling, do we really need both of these statements? - # --- Anisotropy Handling --- - if isinstance(anisotropy, PredefinedAnisotropy): - if anisotropy_order: - raise ValueError( - "Cannot specify custom 'anisotropy_order' when using " - "a predefined anisotropy." - ) - # In a real implementation, you might extract properties here. - # For now, we just store the object. - self.anisotropy = anisotropy - self.anisotropy_order = anisotropy.order - else: - if anisotropy and not anisotropy_order: - raise ValueError( - "You must specify 'anisotropy_order' when using a " - "custom anisotropy function." - ) - self.anisotropy = anisotropy - self.anisotropy_order = anisotropy_order - - # SU units units for backwards compatibility, they used to be stripped - # of units, but now we can do calculations with the units attached - self.su_Ms = self.Ms - self.su_llg_gamma_G = self.llg_gamma_G - self.su_llg_damping = self.llg_damping - self.su_llg_normalisationfactor = self.llg_normalisationfactor - self.su_exchange_coupling = self.exchange_coupling - - self.thermal_factor = (2.0 * constants.boltzmann_constant * self.llg_damping) / \ - (-constants.gamma0 * constants.mu0 * self.Ms) - self.su_thermal_factor = self.thermal_factor - - gilbert_to_ll = 1.0 / (1.0 + self.su_llg_damping ** 2) - self.su_llg_coeff1 = -self.su_llg_gamma_G * gilbert_to_ll - self.su_llg_coeff2 = self.su_llg_coeff1 * self.su_llg_damping - su_f = -gilbert_to_ll * (llg_polarisation * constants.bohr_magneton / (constants.positron_charge * self.Ms * (1 + llg_xi ** 2))) - if su_f == 0.0: - self.su_llg_stt_prefactor = 0.0 - else: - self.su_llg_stt_prefactor = 1.0 - - self.su_llg_stt_nadiab = su_f * (self.llg_xi - self.su_llg_damping) - self.su_llg_stt_adiab = su_f * (1.0 + self.llg_damping * self.llg_xi) - - if self.do_precession == False: - log.info ("Setting su_llg_coeff1 to zero; thus no precession for material '%s'" % self.name) - self.su_llg_coeff1 = 0.0 - - self.su_exch_prefactor = 2.0 * self.su_exchange_coupling / (constants.mu0 * self.su_Ms) - - self.su_anisotropy = self.anisotropy - - self.extended_print = False - log.info(f"Created new Material:\n {self}") - - def __str__(self): - repr_str = f"Material '{self.name}'\n" - - attrs = list(filter(lambda a: a[0] != '_', dir(self))) - - if not self.extended_print: - attrs = ['name', 'Ms', 'exchange_coupling', 'anisotropy', - 'anisotropy_order', 'llg_gamma_G', 'llg_damping', - 'llg_normalisationfactor', 'do_precession', - 'llg_polarisation', 'llg_xi', 'thermal_factor', - 'extended_print'] - - for attr in attrs: - if hasattr(self, attr): - value_str = str(getattr(self, attr)) - repr_str += f" {attr:>25} = {value_str}\n" - - return repr_str +""" +Definition of the MagMaterial class, which allows to define a magnetic +material and all its parameters (saturation magnetisation, exchange coupling, +etc.) +""" + +import logging +from typing import Any, Protocol, cast + +from anisotropy import PredefinedAnisotropy +from si.physical import SI + +from .parameters import ( + AnisotropyFunction, + MaterialScalar, + resolve_material_parameters, +) + +log = logging.getLogger("nmag") + + +class _SIConstants(Protocol): + boltzmann_constant: SI + gamma0: SI + mu0: SI + bohr_magneton: SI + positron_charge: SI + + +_constants_cache: _SIConstants | None = None + + +def _si_constants() -> _SIConstants: + global _constants_cache + if _constants_cache is None: + from si import constants + + _constants_cache = cast(_SIConstants, constants) + return _constants_cache + + +class MagMaterial: + """Define the physical parameters assigned to a magnetic mesh region. + + Args: + name: Unique material name used in field and output labels. + Ms: Saturation magnetization, compatible with A/m. + llg_damping: Dimensionless Gilbert damping coefficient. + llg_gamma_G: Gyromagnetic ratio used by the LLG equation. + llg_normalisationfactor: Coefficient for numerical norm correction. + llg_xi: Dimensionless nonadiabatic Zhang-Li coefficient. + llg_polarisation: Dimensionless spin-current polarization. + do_precession: Include the precessional LLG term when true. + exchange_coupling: Exchange constant, compatible with J/m. + anisotropy: Predefined anisotropy or custom energy callable. + anisotropy_order: Polynomial order required for a custom callable. + properties: Compatibility property labels. Most users should omit this. + scale_volume_charges: Multiplier for interior demagnetization volume + charge. Boundary surface charges remain physical and unscaled. + + Raises: + TypeError: If a parameter has an unsupported type or dimensions. + ValueError: If a value is non-finite, physically invalid, or a custom + anisotropy omits its order. + """ + + def __init__( + self, + name: str, + Ms: SI | None = None, + llg_damping: MaterialScalar = 0.5, + llg_gamma_G: SI | None = None, + llg_normalisationfactor: SI | None = None, + llg_xi: MaterialScalar = 0.0, + llg_polarisation: MaterialScalar = 0.0, + do_precession: bool = True, + exchange_coupling: SI | None = None, + anisotropy: PredefinedAnisotropy | AnisotropyFunction | None = None, + anisotropy_order: int | None = None, + properties: list[str] | None = None, + scale_volume_charges: float = 1.0, + ) -> None: + """Initialize a material after validating units and physical values.""" + parameters = resolve_material_parameters( + name, + ms=Ms, + damping=llg_damping, + gamma=llg_gamma_G, + normalisation=llg_normalisationfactor, + xi=llg_xi, + polarisation=llg_polarisation, + do_precession=do_precession, + exchange=exchange_coupling, + anisotropy=anisotropy, + anisotropy_order=anisotropy_order, + properties=properties, + scale_volume_charges=scale_volume_charges, + ) + + self.name = name + self.Ms = parameters.ms + self.llg_gamma_G = parameters.gamma + self.llg_damping = parameters.damping + self.llg_normalisationfactor = parameters.normalisation + self.llg_xi = parameters.xi + self.llg_polarisation = parameters.polarisation + self.do_precession = parameters.do_precession + self.properties = parameters.properties + self.exchange_coupling = parameters.exchange + self.scale_volume_charges = parameters.scale_volume_charges + self.anisotropy = parameters.anisotropy + self.anisotropy_order = parameters.anisotropy_order + + # SU units units for backwards compatibility, they used to be stripped + # of units, but now we can do calculations with the units attached + self.su_Ms = self.Ms + self.su_llg_gamma_G = self.llg_gamma_G + self.su_llg_damping = self.llg_damping + self.su_llg_normalisationfactor = self.llg_normalisationfactor + self.su_exchange_coupling = self.exchange_coupling + + gilbert_to_ll = 1.0 / (1.0 + self.su_llg_damping**2) + self.su_llg_coeff1 = -self.su_llg_gamma_G * gilbert_to_ll + self.su_llg_coeff2 = self.su_llg_coeff1 * self.su_llg_damping + + if not self.do_precession: + log.info( + "Setting su_llg_coeff1 to zero; thus no precession for material '%s'", self.name + ) + self.su_llg_coeff1 = 0.0 + + self.su_anisotropy = self.anisotropy + self._derived_su_constants: dict[str, MaterialScalar] | None = None + + self.extended_print = False + if log.isEnabledFor(logging.INFO): + log.info("Created new Material:\n %s", self) + + def _derived_su_values(self) -> dict[str, MaterialScalar]: + if self._derived_su_constants is None: + constants = _si_constants() + thermal_factor = (2.0 * constants.boltzmann_constant * self.llg_damping) / ( + -constants.gamma0 * constants.mu0 * self.Ms + ) + gilbert_to_ll = 1.0 / (1.0 + self.su_llg_damping**2) + polarisation = ( + self.llg_polarisation.in_units_of(SI(1)) + if isinstance(self.llg_polarisation, SI) + else float(self.llg_polarisation) + ) + xi = self.llg_xi.in_units_of(SI(1)) if isinstance(self.llg_xi, SI) else float(self.llg_xi) + bohr_magneton = float( + cast(Any, 1.0 * constants.bohr_magneton).to("J/T").magnitude + ) + charge = float(cast(Any, 1.0 * constants.positron_charge).to("C").magnitude) + ms = self.Ms.in_units_of(SI("A/m")) + su_f = ( + SI(0.0, "m^3/A/s") + if ms == 0.0 + else SI( + -gilbert_to_ll + * polarisation + * bohr_magneton + / (charge * ms * (1.0 + xi * xi)), + "m^3/A/s", + ) + ) + self._derived_su_constants = { + "thermal_factor": thermal_factor, + "su_thermal_factor": thermal_factor, + "su_llg_stt_prefactor": 0.0 if su_f == 0.0 else 1.0, + "su_llg_stt_nadiab": su_f * (xi - self.su_llg_damping), + "su_llg_stt_adiab": su_f * (1.0 + self.su_llg_damping * xi), + "su_exch_prefactor": ( + 2.0 * self.su_exchange_coupling / (constants.mu0 * self.su_Ms) + ), + } + return self._derived_su_constants + + @property + def thermal_factor(self) -> MaterialScalar: + return self._derived_su_values()["thermal_factor"] + + @property + def su_thermal_factor(self) -> MaterialScalar: + return self._derived_su_values()["su_thermal_factor"] + + @property + def su_llg_stt_prefactor(self) -> MaterialScalar: + return self._derived_su_values()["su_llg_stt_prefactor"] + + @property + def su_llg_stt_nadiab(self) -> MaterialScalar: + return self._derived_su_values()["su_llg_stt_nadiab"] + + @property + def su_llg_stt_adiab(self) -> MaterialScalar: + return self._derived_su_values()["su_llg_stt_adiab"] + + @property + def su_exch_prefactor(self) -> MaterialScalar: + return self._derived_su_values()["su_exch_prefactor"] + + def __str__(self) -> str: + repr_str = f"Material '{self.name}'\n" + + attrs = list(filter(lambda a: a[0] != "_", dir(self))) + + if not self.extended_print: + attrs = [ + "name", + "Ms", + "exchange_coupling", + "anisotropy", + "anisotropy_order", + "llg_gamma_G", + "llg_damping", + "llg_normalisationfactor", + "do_precession", + "llg_polarisation", + "llg_xi", + "thermal_factor", + "extended_print", + ] + + for attr in attrs: + if hasattr(self, attr): + value_str = str(getattr(self, attr)) + repr_str += f" {attr:>25} = {value_str}\n" + + return repr_str diff --git a/src/mag_material/parameters.py b/src/mag_material/parameters.py new file mode 100644 index 0000000..257dd6c --- /dev/null +++ b/src/mag_material/parameters.py @@ -0,0 +1,150 @@ +"""Normalization and validation for :class:`mag_material.MagMaterial` inputs.""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from functools import lru_cache +from typing import cast + +from anisotropy import EnergyFunction, PredefinedAnisotropy +from si.physical import SI + +MaterialScalar = SI | float +AnisotropyFunction = EnergyFunction + + +@dataclass(slots=True) +class MaterialParameters: + """Fully resolved constructor values for a magnetic material.""" + + ms: SI + damping: MaterialScalar + gamma: SI + normalisation: SI + xi: MaterialScalar + polarisation: MaterialScalar + do_precession: bool + exchange: SI + anisotropy: PredefinedAnisotropy | AnisotropyFunction | None + anisotropy_order: int | None + properties: list[str] + scale_volume_charges: float + + +@lru_cache(maxsize=1) +def _expected_parameter_units() -> tuple[tuple[str, SI], ...]: + one = SI(1) + return ( + ("Ms", SI(1, "A/m")), + ("llg_gamma_G", SI(1, "m/A/s")), + ("llg_damping", one), + ("llg_normalisationfactor", SI(1, "1/s")), + ("llg_xi", one), + ("llg_polarisation", one), + ("exchange_coupling", SI(1, "J/m")), + ) + + +def _value_by_public_name(parameters: MaterialParameters, name: str) -> MaterialScalar: + values: dict[str, MaterialScalar] = { + "Ms": parameters.ms, + "llg_gamma_G": parameters.gamma, + "llg_damping": parameters.damping, + "llg_normalisationfactor": parameters.normalisation, + "llg_xi": parameters.xi, + "llg_polarisation": parameters.polarisation, + "exchange_coupling": parameters.exchange, + } + return values[name] + + +def _validate_units(name: str, parameters: MaterialParameters) -> None: + for attribute, expected_unit in _expected_parameter_units(): + value = _value_by_public_name(parameters, attribute) + value_to_check = value._quantity if isinstance(value, SI) else value + if not expected_unit._quantity.is_compatible_with(value_to_check): + raise TypeError( + f"The argument '{attribute}' for material '{name}' requires units compatible " + f"with {expected_unit.dens_str()}, but received a value of '{value}'." + ) + + +def _resolve_anisotropy( + anisotropy: PredefinedAnisotropy | AnisotropyFunction | None, + anisotropy_order: int | None, +) -> tuple[PredefinedAnisotropy | AnisotropyFunction | None, int | None]: + if isinstance(anisotropy, PredefinedAnisotropy): + if anisotropy_order is not None: + raise ValueError( + "Cannot specify custom 'anisotropy_order' when using a predefined anisotropy." + ) + if not anisotropy.has_function() or anisotropy.order is None: + raise ValueError("A material anisotropy must provide an energy function and order.") + return anisotropy, anisotropy.order + if anisotropy is None: + if anisotropy_order is not None: + raise ValueError("anisotropy_order requires an anisotropy energy function.") + return None, None + if not callable(anisotropy): + raise TypeError("anisotropy must be a PredefinedAnisotropy, callable, or None.") + custom_order = cast(object, anisotropy_order) + if ( + custom_order is None + or type(custom_order) is bool + or not isinstance(custom_order, int) + or custom_order <= 0 + ): + raise ValueError( + "You must specify 'anisotropy_order' as a positive integer when using a custom " + "anisotropy function." + ) + return anisotropy, anisotropy_order + + +def resolve_material_parameters( + name: str, + *, + ms: SI | None, + damping: MaterialScalar, + gamma: SI | None, + normalisation: SI | None, + xi: MaterialScalar, + polarisation: MaterialScalar, + do_precession: bool, + exchange: SI | None, + anisotropy: PredefinedAnisotropy | AnisotropyFunction | None, + anisotropy_order: int | None, + properties: list[str] | None, + scale_volume_charges: float, +) -> MaterialParameters: + """Apply legacy defaults and reject invalid material constructor values.""" + try: + resolved_volume_charge_scale = float(scale_volume_charges) + except (TypeError, ValueError) as error: + raise TypeError("scale_volume_charges must be a finite real number.") from error + if not math.isfinite(resolved_volume_charge_scale): + raise ValueError("scale_volume_charges must be finite.") + + resolved_anisotropy, resolved_order = _resolve_anisotropy(anisotropy, anisotropy_order) + parameters = MaterialParameters( + ms=SI(0.86e6, "A/m") if ms is None else ms, + damping=damping, + gamma=SI(2.210173e5, "m/A/s") if gamma is None else gamma, + normalisation=SI(0.1e12, "1/s") if normalisation is None else normalisation, + xi=xi, + polarisation=polarisation, + do_precession=do_precession, + exchange=SI(1.3e-11, "J/m") if exchange is None else exchange, + anisotropy=resolved_anisotropy, + anisotropy_order=resolved_order, + properties=["magnetic", "material"] if properties is None else properties, + scale_volume_charges=resolved_volume_charge_scale, + ) + _validate_units(name, parameters) + if parameters.exchange < 0.0: + raise ValueError( + "The exchange coupling constant must be positive. For " + f"material '{name}', you specified: {parameters.exchange}." + ) + return parameters diff --git a/src/mag_material/py.typed b/src/mag_material/py.typed new file mode 100644 index 0000000..534be2b --- /dev/null +++ b/src/mag_material/py.typed @@ -0,0 +1 @@ +# Marker for PEP 561 inline type information. diff --git a/src/mock_features/__init__.py b/src/mock_features/__init__.py deleted file mode 100644 index d1d64c3..0000000 --- a/src/mock_features/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .mock_features import * diff --git a/src/mock_features/mock_features.py b/src/mock_features/mock_features.py deleted file mode 100644 index 548d052..0000000 --- a/src/mock_features/mock_features.py +++ /dev/null @@ -1,99 +0,0 @@ -import configparser -from io import StringIO -from typing import Any, Dict - - -class _CasePreservingConfigParser(configparser.ConfigParser): - # ConfigParser lowercases option names by default. We keep original case - # so legacy/mixed-case feature keys round-trip without silent renaming. - def optionxform(self, optionstr: str) -> str: - return optionstr - - -class MockFeatures: - """ - A unified stub to replace nsim.features.Features and provide - default configuration values for simulations and nmesh. - """ - def __init__(self): - # Default configuration for simulations - self._data: Dict[str, Dict[str, Any]] = { - 'etc': { - 'runid': 'nmag_simulation', # Default output filename - 'savedir': '.', # Default output directory - }, - 'nmag': { - 'clean': False, # Don't delete old files automatically - 'restart': False, # Don't try to restart from h5 - } - } - - @staticmethod - def _coerce_value(value: str) -> Any: - text = value.strip() - if text == "": - return text - - lowered = text.lower() - if lowered in {"true", "false"}: - return lowered == "true" - - try: - return int(text) - except ValueError: - pass - - try: - return float(text) - except ValueError: - return text - - def _load_from_parser(self, parser: configparser.ConfigParser): - for section in parser.sections(): - self.add_section(section) - for name, value in parser.items(section): - self.set(section, name, self._coerce_value(value)) - - def from_file(self, file_path): - """Loads INI-style features from a file.""" - # Use the case-preserving parser so key casing in config files is kept. - parser = _CasePreservingConfigParser( - delimiters=("=", ":"), - interpolation=None, - ) - with open(file_path, encoding="utf-8") as stream: - parser.read_file(stream) - self._load_from_parser(parser) - - def from_string(self, string): - """Loads INI-style features from a string.""" - # Keep behavior consistent with from_file for all ingestion paths. - parser = _CasePreservingConfigParser( - delimiters=("=", ":"), - interpolation=None, - ) - parser.read_file(StringIO(string)) - self._load_from_parser(parser) - - def add_section(self, section: str): - """Adds a section to the features if it doesn't exist.""" - if section not in self._data: - self._data[section] = {} - - def get(self, section: str, name: str, raw: bool = False) -> Any: - """Retrieves a value from the specified section and name.""" - return self._data.get(section, {}).get(name) - - def set(self, section: str, name: str, value: Any): - """Sets a value in the specified section and name.""" - if section not in self._data: - self._data[section] = {} - self._data[section][name] = value - - def items(self, section: str): - """Returns items in a given section.""" - return self._data.get(section, {}).items() - - def to_string(self) -> str: - """Returns string representation of the data.""" - return str(self._data) diff --git a/src/nmag/__init__.py b/src/nmag/__init__.py new file mode 100644 index 0000000..1fdabec --- /dev/null +++ b/src/nmag/__init__.py @@ -0,0 +1,36 @@ +"""Public Python 3 entrypoint for the nmag rewrite.""" + +from anisotropy import ( + PredefinedAnisotropy, + cubic_anisotropy, + uniaxial_anisotropy, + want_anisotropy, +) +from mag_material import MagMaterial +from si.physical import SI, Physical + +from .config import ACCELERATOR_ENV, HierarchicalBemConfig, NmagConfig, RustKernel +from .demag.bem_operator import BemOperatorStats +from .dynamics import IntegratorConfig, IntegratorStats +from .parallel import ParallelRuntimeInfo, parallel_runtime_info +from .simulation import Simulation + +__all__ = [ + "MagMaterial", + "NmagConfig", + "HierarchicalBemConfig", + "BemOperatorStats", + "IntegratorConfig", + "IntegratorStats", + "ParallelRuntimeInfo", + "Physical", + "PredefinedAnisotropy", + "SI", + "Simulation", + "RustKernel", + "ACCELERATOR_ENV", + "cubic_anisotropy", + "parallel_runtime_info", + "uniaxial_anisotropy", + "want_anisotropy", +] diff --git a/src/nmag/backends.py b/src/nmag/backends.py new file mode 100644 index 0000000..7ce114f --- /dev/null +++ b/src/nmag/backends.py @@ -0,0 +1,493 @@ +"""Backend selection for Nmag's optional acceleration and memory policies.""" + +from __future__ import annotations + +import importlib +import os +from types import ModuleType + +from .config import DEMAG_BEM_STORAGE_ENV, NmagConfig, RustKernel +from .resources import available_memory_bytes + +RUST_ACCELERATOR_API_VERSION = 2 +MEMORY_MODE_ENV = "NMAG_MEMORY_MODE" +MEMORY_MODES = frozenset({"auto", "low"}) +AUTO_DENSE_MEMORY_FRACTION = 0.2 + +DEMAG_BEM_STORAGE_BACKEND_ENV = DEMAG_BEM_STORAGE_ENV +DEMAG_BEM_STORAGE_BACKENDS = frozenset({"auto", "dense", "hierarchical", "matrix-free"}) +DEMAG_BEM_AUTO_MATRIX_FREE_MIN_BOUNDARY_NODES_ENV = ( + "NMAG_DEMAG_BEM_AUTO_MATRIX_FREE_MIN_BOUNDARY_NODES" +) +DEFAULT_DEMAG_BEM_AUTO_MATRIX_FREE_MIN_BOUNDARY_NODES = 2048 +DEMAG_FEM_MATRIX_BACKEND_ENV = "NMAG_DEMAG_FEM_MATRIX_BACKEND" +DEMAG_FEM_MATRIX_BACKENDS = frozenset({"auto", "dense", "sparse"}) +DEMAG_FEM_AUTO_SPARSE_MIN_POINTS_ENV = "NMAG_DEMAG_FEM_AUTO_SPARSE_MIN_POINTS" +DEFAULT_DEMAG_FEM_AUTO_SPARSE_MIN_POINTS = 2048 +DEMAG_LINEAR_SOLVER_BACKEND_ENV = "NMAG_DEMAG_LINEAR_SOLVER_BACKEND" +DEMAG_LINEAR_SOLVER_BACKENDS = frozenset({"auto", "numpy", "scipy"}) +DEMAG_LINEAR_SOLVER_AUTO_SCIPY_MIN_SIZE_ENV = "NMAG_DEMAG_LINEAR_SOLVER_AUTO_SCIPY_MIN_SIZE" +DEFAULT_DEMAG_LINEAR_SOLVER_AUTO_SCIPY_MIN_SIZE = 128 +DEMAG_SOLVE_CONDITION_DIAGNOSTICS_ENV = "NMAG_DEMAG_SOLVE_CONDITION_DIAGNOSTICS" +DEMAG_INTERNAL_TRACE_ENV = "NMAG_DEMAG_INTERNAL_TRACE" +DEMAG_DENSE_MAX_POINTS_ENV = "NMAG_DEMAG_DENSE_MAX_POINTS" +LEAST_SQUARES_RELATIVE_RESIDUAL_TOLERANCE = 1.0e-10 + +DEMAG_FEM_GEOMETRY_RUST_MIN_CELLS = 2048 +DEMAG_FEM_ASSEMBLY_RUST_MIN_CELLS = 2048 +DEMAG_NODAL_RECOVERY_RUST_MIN_CELLS = 2048 +DEMAG_CELL_AVERAGE_RUST_MIN_CELLS = 2048 +LLG_RUST_MIN_POINTS = 2048 + + +def _default_config() -> NmagConfig: + """Return the environment-derived default for standalone helper calls.""" + + return NmagConfig.from_environment() + + +def _policy(config: NmagConfig | None) -> NmagConfig: + return _default_config() if config is None else config + + +def _rust_accelerator_available() -> bool: + try: + accelerator = importlib.import_module("nmag_accel") + except ImportError: + return False + return getattr(accelerator, "API_VERSION", None) == RUST_ACCELERATOR_API_VERSION + + +def _load_rust_accelerator(required_by: str = "NmagConfig.accelerator") -> ModuleType: + try: + accelerator = importlib.import_module("nmag_accel") + except ImportError as exc: + raise RuntimeError( + f"{required_by}=rust requires the optional nmag_accel extension. Build it with " + "`maturin develop --release --manifest-path rust/nmag_accel/Cargo.toml`." + ) from exc + + actual_version = getattr(accelerator, "API_VERSION", None) + if actual_version != RUST_ACCELERATOR_API_VERSION: + raise RuntimeError( + f"{required_by}=rust found an incompatible nmag_accel extension " + f"(API_VERSION={actual_version!r}, expected {RUST_ACCELERATOR_API_VERSION}). " + "Rebuild it with `maturin develop --release --manifest-path " + "rust/nmag_accel/Cargo.toml`." + ) + return accelerator + + +def _select_rust_backend( + *, + config: NmagConfig | None, + kernel: RustKernel, + fallback: str, + item_count: int | None = None, + rust_min_items: int | None = None, +) -> str: + """Resolve one optional Rust kernel without process-global selectors.""" + + mode = _policy(config).accelerator_mode_for(kernel) + if mode == "off": + return fallback + if mode == "rust": + _load_rust_accelerator(f"NmagConfig.accelerator[{kernel.value!r}]") + return "rust" + if not _rust_accelerator_available(): + return fallback + if ( + item_count is not None + and rust_min_items is not None + and item_count < rust_min_items + ): + return fallback + return "rust" + + +def _rust_selection_metadata( + *, + config: NmagConfig | None, + kernel: RustKernel, + selected_backend: str, + fallback: str, + item_count: int | None = None, + rust_min_items: int | None = None, +) -> dict[str, object]: + policy = _policy(config) + requested = policy.accelerator_mode_for(kernel) + available = _rust_accelerator_available() + if requested == "off": + reason = "accelerator_off" + elif requested == "rust": + reason = "explicit_rust_request" + elif not available: + reason = "rust_accelerator_unavailable" + elif rust_min_items is not None and item_count is not None and item_count < rust_min_items: + reason = "item_count_below_rust_min" + elif selected_backend == fallback: + reason = "auto_fallback" + else: + reason = "rust_accelerator_available" + metadata: dict[str, object] = { + "requested": requested, + "selected": selected_backend, + "rust_available": available, + "selection_reason": reason, + } + if item_count is not None: + metadata["item_count"] = int(item_count) + if rust_min_items is not None: + metadata["rust_min_items"] = int(rust_min_items) + return metadata + + +def _selected_integrator_backend(config: NmagConfig | None = None) -> str: + policy = _policy(config) + backend = policy.integrator_backend + if backend == "diffsol" and _selected_memory_mode() == "low": + raise ValueError( + "NmagConfig(integrator_backend='diffsol') constructs a dense affine operator " + f"and is incompatible with {MEMORY_MODE_ENV}=low; use scipy instead." + ) + if backend == "diffsol" and policy.accelerator == "off": + raise ValueError( + "NmagConfig(integrator_backend='diffsol') requires the Rust accelerator; " + "accelerator='off' disables it." + ) + return backend + + +def _selected_memory_mode() -> str: + mode = os.environ.get(MEMORY_MODE_ENV, "auto").strip().lower() + if mode not in MEMORY_MODES: + choices = ", ".join(sorted(MEMORY_MODES)) + raise ValueError(f"Unsupported {MEMORY_MODE_ENV}={mode!r}; choose one of {choices}.") + return mode + + +def _auto_prefers_low_memory_storage( + *, estimated_dense_bytes: int, item_count: int | None, fallback_min_items: int +) -> bool: + available = available_memory_bytes() + if available is not None: + return estimated_dense_bytes > available * AUTO_DENSE_MEMORY_FRACTION + return item_count is not None and item_count >= fallback_min_items + + +def _selected_lindholm_bem_backend(config: NmagConfig | None = None) -> str: + return _select_rust_backend( + config=config, kernel=RustKernel.LINDHOLM_BEM, fallback="numba" + ) + + +def _lindholm_bem_backend_selection_metadata(config: NmagConfig | None = None) -> dict[str, object]: + return _rust_selection_metadata( + config=config, + kernel=RustKernel.LINDHOLM_BEM, + selected_backend=_selected_lindholm_bem_backend(config), + fallback="numba", + ) + + +def _selected_probe_geometry_backend(config: NmagConfig | None = None) -> str: + return _select_rust_backend( + config=config, kernel=RustKernel.PROBE_GEOMETRY, fallback="python" + ) + + +def _probe_geometry_backend_selection_metadata(config: NmagConfig | None = None) -> dict[str, object]: + return _rust_selection_metadata( + config=config, + kernel=RustKernel.PROBE_GEOMETRY, + selected_backend=_selected_probe_geometry_backend(config), + fallback="python", + ) + + +def _selected_demag_fem_geometry_backend( + cell_count: int | None = None, config: NmagConfig | None = None +) -> str: + return _select_rust_backend( + config=config, + kernel=RustKernel.FEM_GEOMETRY, + fallback="python", + item_count=cell_count, + rust_min_items=DEMAG_FEM_GEOMETRY_RUST_MIN_CELLS, + ) + + +def _demag_fem_geometry_backend_selection_metadata( + cell_count: int | None = None, config: NmagConfig | None = None +) -> dict[str, object]: + return _rust_selection_metadata( + config=config, + kernel=RustKernel.FEM_GEOMETRY, + selected_backend=_selected_demag_fem_geometry_backend(cell_count, config), + fallback="python", + item_count=cell_count, + rust_min_items=DEMAG_FEM_GEOMETRY_RUST_MIN_CELLS, + ) + + +def _selected_demag_boundary_face_backend(config: NmagConfig | None = None) -> str: + return _select_rust_backend( + config=config, kernel=RustKernel.BOUNDARY_FACES, fallback="python" + ) + + +def _demag_boundary_face_backend_selection_metadata( + config: NmagConfig | None = None, +) -> dict[str, object]: + return _rust_selection_metadata( + config=config, + kernel=RustKernel.BOUNDARY_FACES, + selected_backend=_selected_demag_boundary_face_backend(config), + fallback="python", + ) + + +def _selected_demag_fem_assembly_backend( + cell_count: int | None = None, config: NmagConfig | None = None +) -> str: + return _select_rust_backend( + config=config, + kernel=RustKernel.FEM_ASSEMBLY, + fallback="python", + item_count=cell_count, + rust_min_items=DEMAG_FEM_ASSEMBLY_RUST_MIN_CELLS, + ) + + +def _demag_fem_assembly_backend_selection_metadata( + cell_count: int | None = None, config: NmagConfig | None = None +) -> dict[str, object]: + return _rust_selection_metadata( + config=config, + kernel=RustKernel.FEM_ASSEMBLY, + selected_backend=_selected_demag_fem_assembly_backend(cell_count, config), + fallback="python", + item_count=cell_count, + rust_min_items=DEMAG_FEM_ASSEMBLY_RUST_MIN_CELLS, + ) + + +def _selected_demag_nodal_recovery_backend( + cell_count: int | None = None, config: NmagConfig | None = None +) -> str: + return _select_rust_backend( + config=config, + kernel=RustKernel.NODAL_RECOVERY, + fallback="python", + item_count=cell_count, + rust_min_items=DEMAG_NODAL_RECOVERY_RUST_MIN_CELLS, + ) + + +def _demag_nodal_recovery_backend_selection_metadata( + cell_count: int | None = None, config: NmagConfig | None = None +) -> dict[str, object]: + return _rust_selection_metadata( + config=config, + kernel=RustKernel.NODAL_RECOVERY, + selected_backend=_selected_demag_nodal_recovery_backend(cell_count, config), + fallback="python", + item_count=cell_count, + rust_min_items=DEMAG_NODAL_RECOVERY_RUST_MIN_CELLS, + ) + + +def _selected_demag_cell_average_backend( + cell_count: int | None = None, config: NmagConfig | None = None +) -> str: + return _select_rust_backend( + config=config, + kernel=RustKernel.CELL_AVERAGE, + fallback="python", + item_count=cell_count, + rust_min_items=DEMAG_CELL_AVERAGE_RUST_MIN_CELLS, + ) + + +def _demag_cell_average_backend_selection_metadata( + cell_count: int | None = None, config: NmagConfig | None = None +) -> dict[str, object]: + return _rust_selection_metadata( + config=config, + kernel=RustKernel.CELL_AVERAGE, + selected_backend=_selected_demag_cell_average_backend(cell_count, config), + fallback="python", + item_count=cell_count, + rust_min_items=DEMAG_CELL_AVERAGE_RUST_MIN_CELLS, + ) + + +def _selected_llg_backend( + point_count: int | None = None, config: NmagConfig | None = None +) -> str: + return _select_rust_backend( + config=config, + kernel=RustKernel.LLG, + fallback="python", + item_count=point_count, + rust_min_items=LLG_RUST_MIN_POINTS, + ) + + +def _llg_backend_selection_metadata( + point_count: int | None = None, config: NmagConfig | None = None +) -> dict[str, object]: + return _rust_selection_metadata( + config=config, + kernel=RustKernel.LLG, + selected_backend=_selected_llg_backend(point_count, config), + fallback="python", + item_count=point_count, + rust_min_items=LLG_RUST_MIN_POINTS, + ) + + +def _selected_maxangle_backend(config: NmagConfig | None = None) -> str: + return _select_rust_backend( + config=config, kernel=RustKernel.MAXANGLE, fallback="python" + ) + + +def _maxangle_backend_selection_metadata(config: NmagConfig | None = None) -> dict[str, object]: + return _rust_selection_metadata( + config=config, + kernel=RustKernel.MAXANGLE, + selected_backend=_selected_maxangle_backend(config), + fallback="python", + ) + + +def _demag_bem_auto_matrix_free_min_boundary_nodes() -> int: + return _positive_environment_int( + DEMAG_BEM_AUTO_MATRIX_FREE_MIN_BOUNDARY_NODES_ENV, + DEFAULT_DEMAG_BEM_AUTO_MATRIX_FREE_MIN_BOUNDARY_NODES, + "boundary-node count", + ) + + +def _demag_fem_auto_sparse_min_points() -> int: + return _positive_environment_int( + DEMAG_FEM_AUTO_SPARSE_MIN_POINTS_ENV, + DEFAULT_DEMAG_FEM_AUTO_SPARSE_MIN_POINTS, + "point count", + ) + + +def _demag_linear_solver_auto_scipy_min_size() -> int: + return _positive_environment_int( + DEMAG_LINEAR_SOLVER_AUTO_SCIPY_MIN_SIZE_ENV, + DEFAULT_DEMAG_LINEAR_SOLVER_AUTO_SCIPY_MIN_SIZE, + "dense dimension", + ) + + +def _positive_environment_int(name: str, default: int, description: str) -> int: + raw_value = os.environ.get(name, str(default)) + try: + minimum = int(raw_value) + except ValueError as exc: + raise ValueError( + f"Unsupported {name}={raw_value!r}; expected a positive integer {description}." + ) from exc + if minimum <= 0: + raise ValueError( + f"Unsupported {name}={raw_value!r}; expected a positive integer {description}." + ) + return minimum + + +def _selected_demag_bem_storage_backend( + boundary_node_count: int | None = None, + config: NmagConfig | None = None, +) -> str: + backend = ( + os.environ.get(DEMAG_BEM_STORAGE_BACKEND_ENV, "auto").strip().lower() + if config is None + else config.demag_bem_storage + ) + if backend not in DEMAG_BEM_STORAGE_BACKENDS: + choices = ", ".join(sorted(DEMAG_BEM_STORAGE_BACKENDS)) + raise ValueError(f"Unsupported {DEMAG_BEM_STORAGE_BACKEND_ENV}={backend!r}; choose one of {choices}.") + if backend != "auto": + return backend + if _selected_memory_mode() == "low": + return "matrix-free" + count = boundary_node_count or 0 + if _auto_prefers_low_memory_storage( + estimated_dense_bytes=count * count * 8, + item_count=boundary_node_count, + fallback_min_items=_demag_bem_auto_matrix_free_min_boundary_nodes(), + ): + mode = _policy(config).accelerator_mode_for(RustKernel.LINDHOLM_BEM) + if mode == "off" or (mode == "auto" and not _rust_accelerator_available()): + return "matrix-free" + if mode == "rust": + _load_rust_accelerator("NmagConfig.accelerator['lindholm_bem']") + return "hierarchical" + return "dense" + + +def _selected_demag_fem_matrix_backend(point_count: int | None = None) -> str: + backend = os.environ.get(DEMAG_FEM_MATRIX_BACKEND_ENV, "auto").strip().lower() + if backend not in DEMAG_FEM_MATRIX_BACKENDS: + choices = ", ".join(sorted(DEMAG_FEM_MATRIX_BACKENDS)) + raise ValueError(f"Unsupported {DEMAG_FEM_MATRIX_BACKEND_ENV}={backend!r}; choose one of {choices}.") + if backend != "auto": + return backend + if _selected_memory_mode() == "low": + return "sparse" + count = point_count or 0 + if _auto_prefers_low_memory_storage( + estimated_dense_bytes=count * count * 8 * 4, + item_count=point_count, + fallback_min_items=_demag_fem_auto_sparse_min_points(), + ): + return "sparse" + return "dense" + + +def _selected_demag_linear_solver_backend(dense_dimension: int | None = None) -> str: + backend = os.environ.get(DEMAG_LINEAR_SOLVER_BACKEND_ENV, "auto").strip().lower() + if backend not in DEMAG_LINEAR_SOLVER_BACKENDS: + choices = ", ".join(sorted(DEMAG_LINEAR_SOLVER_BACKENDS)) + raise ValueError(f"Unsupported {DEMAG_LINEAR_SOLVER_BACKEND_ENV}={backend!r}; choose one of {choices}.") + if backend != "auto": + return backend + if dense_dimension is not None and dense_dimension >= _demag_linear_solver_auto_scipy_min_size(): + return "scipy" + return "numpy" + + +def _demag_linear_solver_backend_selection_metadata( + dense_dimension: int | None = None, +) -> dict[str, object]: + selected = _selected_demag_linear_solver_backend(dense_dimension) + requested = os.environ.get(DEMAG_LINEAR_SOLVER_BACKEND_ENV, "auto").strip().lower() + metadata: dict[str, object] = {"requested": requested, "selected": selected} + if dense_dimension is not None: + metadata["dense_dimension"] = int(dense_dimension) + metadata["auto_scipy_min_size"] = _demag_linear_solver_auto_scipy_min_size() + return metadata + + +def _demag_solve_condition_diagnostics_enabled() -> bool: + return _boolean_environment(DEMAG_SOLVE_CONDITION_DIAGNOSTICS_ENV) + + +def _demag_internal_trace_enabled() -> bool: + return _boolean_environment(DEMAG_INTERNAL_TRACE_ENV) + + +def _boolean_environment(name: str) -> bool: + raw_value = os.environ.get(name, "0") + value = raw_value.strip().lower() + if value in {"1", "true", "yes", "on"}: + return True + if value in {"0", "false", "no", "off", ""}: + return False + raise ValueError(f"Unsupported {name}={raw_value!r}; choose a boolean value.") diff --git a/src/nmag/checkpoint.py b/src/nmag/checkpoint.py new file mode 100644 index 0000000..2ba4b0c --- /dev/null +++ b/src/nmag/checkpoint.py @@ -0,0 +1,327 @@ +"""Native HDF5 checkpoints for the supported Python 3 simulation state.""" + +from __future__ import annotations + +import hashlib +import json +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Any, cast + +import h5py +import numpy as np + +from anisotropy import anisotropy_signature_values +from si.physical import SI +from simulation.clock import SimulationClock + +from .dynamics import ConvergenceTracker, IntegratorConfig + +CHECKPOINT_FORMAT = "nmag-python-3-checkpoint" +CHECKPOINT_VERSION = 1 + + +@dataclass(frozen=True, slots=True) +class CheckpointContents: + magnetisation: np.ndarray + pinning: np.ndarray + external_field: np.ndarray + current_density: np.ndarray | None + metadata: dict[str, object] + + +@dataclass(frozen=True, slots=True) +class RestartRuntimeState: + clock: SimulationClock + integrator_config: IntegratorConfig + stopping_dm_dt: float + maximum_time_seconds: float + maximum_dm_dt: float | None + convergence: ConvergenceTracker + + +def _array_fingerprint(digest: Any, label: str, values: object, dtype: Any) -> None: + array = np.ascontiguousarray(np.asarray(values, dtype=dtype)) + digest.update(label.encode("ascii")) + digest.update(str(array.shape).encode("ascii")) + digest.update(str(array.dtype).encode("ascii")) + digest.update(array.tobytes()) + + +def mesh_fingerprint(simulation: Any) -> str: + """Return a stable hash of the loaded physical mesh and its topology.""" + mesh = simulation._require_mesh() + simplices = np.asarray(mesh.simplices, dtype=np.int64) + regions_raw = mesh.regions + regions = np.asarray( + [1] * len(simplices) if regions_raw is None else regions_raw, + dtype=np.int64, + ) + digest = hashlib.sha256() + _array_fingerprint(digest, "points", simulation._mesh_points(), np.dtype(np.float64)) + _array_fingerprint(digest, "simplices", simplices, np.dtype(np.int64)) + _array_fingerprint(digest, "regions", regions, np.dtype(np.int64)) + return digest.hexdigest() + + +def material_fingerprint(simulation: Any) -> str: + """Hash mesh-region material values that influence supported dynamics. + + Saturation magnetisation is represented per simplex here rather than by + volume-averaged nodal recovery. The latter can differ by harmless last-bit + geometry-kernel roundoff after a run, which must not make an otherwise + compatible checkpoint unloadable. + """ + coefficients = simulation._nodal_material_coefficients() + mesh = simulation._require_mesh() + regions = np.asarray(mesh.regions or [1] * len(mesh.simplices), dtype=np.int64) + digest = hashlib.sha256() + for name, values in ( + ("simplex_ms", simulation._simplex_material_ms_values(regions)), + ("volume_charge_scale", simulation._simplex_volume_charge_scales(regions)), + ("exchange_prefactor", coefficients.exchange_prefactor), + ("precession", coefficients.precession), + ("damping", coefficients.damping), + ("normalisation", coefficients.normalisation), + ("stt_adiabatic", coefficients.stt_adiabatic), + ("stt_nonadiabatic", coefficients.stt_nonadiabatic), + ): + _array_fingerprint(digest, name, values, np.dtype(np.float64)) + for region in np.unique(regions): + material = simulation._simplex_material(int(region)) + signature = anisotropy_signature_values( + material.anisotropy, + material.anisotropy_order, + ) + _array_fingerprint( + digest, + f"anisotropy:{int(region)}", + signature, + np.dtype(np.float64), + ) + return digest.hexdigest() + + +def _finite_array(name: str, values: object, shape: tuple[int, ...]) -> np.ndarray: + array = np.ascontiguousarray(np.asarray(values, dtype=np.float64)) + if array.shape != shape: + raise ValueError(f"Checkpoint {name} must have shape {shape}, got {array.shape}.") + if not np.all(np.isfinite(array)): + raise ValueError(f"Checkpoint {name} values must be finite.") + return array + + +def _finite_number(name: str, value: object, *, positive: bool = False) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError(f"Checkpoint {name} must be a finite number.") + number = float(value) + if not np.isfinite(number) or (positive and number <= 0.0): + qualifier = "positive finite" if positive else "finite" + raise ValueError(f"Checkpoint {name} must be a {qualifier} number.") + return number + + +def _snapshot(simulation: Any) -> CheckpointContents: + point_count = len(simulation._mesh_points()) + if "m" not in simulation._fields: + raise RuntimeError("Magnetisation must be set before saving a restart checkpoint.") + + magnetisation = _finite_array("magnetisation", simulation._fields["m"], (point_count, 3)) + pinning = _finite_array( + "pinning", + simulation._fields.get("pin", np.ones(point_count, dtype=float)), + (point_count,), + ) + external_field = _finite_array( + "external field", + simulation._fields.get("H_ext", np.zeros(3, dtype=float)), + (3,), + ) + current_density = None + if "current_density" in simulation._fields: + current_density = _finite_array( + "current density", + simulation._fields["current_density"], + (point_count, 3), + ) + + config = simulation.integrator_config + config.validate() + max_dm_dt = simulation.max_dm_dt + if max_dm_dt is not None: + max_dm_dt = _finite_number("maximum dm/dt", max_dm_dt) + dynamics_state: dict[str, object] = { + "relative_tolerance": config.relative_tolerance, + "absolute_tolerance": config.absolute_tolerance, + "maximum_step_seconds": config.maximum_step_seconds, + "exact_tstop": config.exact_tstop, + "stopping_dm_dt": _finite_number( + "stopping dm/dt", simulation.stopping_dm_dt, positive=True + ), + "maximum_time_seconds": simulation.max_time_reached.in_units_of(SI(1.0, "s")), + "maximum_dm_dt": max_dm_dt, + "convergence": simulation.convergence.checkpoint_state(), + } + _finite_number("maximum time", dynamics_state["maximum_time_seconds"], positive=True) + metadata: dict[str, object] = { + "mesh_fingerprint": mesh_fingerprint(simulation), + "material_fingerprint": material_fingerprint(simulation), + "do_demag": bool(simulation.do_demag), + "clock": simulation.clock.checkpoint_state(), + "dynamics": dynamics_state, + } + return CheckpointContents(magnetisation, pinning, external_field, current_density, metadata) + + +def save_checkpoint(simulation: Any, destination: Path) -> Path: + """Atomically persist a complete native checkpoint and return its path.""" + contents = _snapshot(simulation) + destination = destination.expanduser() + temporary = destination.with_name(f".{destination.name}.tmp") + try: + with h5py.File(str(temporary), "w") as h5: + h5.attrs["format"] = CHECKPOINT_FORMAT + h5.attrs["version"] = CHECKPOINT_VERSION + h5.attrs["metadata"] = json.dumps(contents.metadata, allow_nan=False, sort_keys=True) + state = h5.create_group("state") + state.create_dataset("m", data=contents.magnetisation, dtype=np.float64) + state.create_dataset("pin", data=contents.pinning, dtype=np.float64) + state.create_dataset("H_ext", data=contents.external_field, dtype=np.float64) + if contents.current_density is not None: + state.create_dataset( + "current_density", + data=contents.current_density, + dtype=np.float64, + ) + h5.flush() + with temporary.open("rb") as checkpoint_file: + os.fsync(checkpoint_file.fileno()) + os.replace(temporary, destination) + except Exception: + temporary.unlink(missing_ok=True) + raise + return destination + + +def _attribute_text(value: object, name: str) -> str: + if isinstance(value, bytes): + return value.decode("utf-8") + if isinstance(value, str): + return value + raise ValueError(f"Checkpoint {name} attribute must be text.") + + +def read_checkpoint(filename: Path, point_count: int) -> CheckpointContents: + """Read and fully validate native checkpoint data without mutating a simulation.""" + try: + with h5py.File(str(filename), "r") as h5: + if _attribute_text(h5.attrs.get("format"), "format") != CHECKPOINT_FORMAT: + raise ValueError("File is not an nmag-python-3 native checkpoint.") + version = cast(object, h5.attrs.get("version")) + if isinstance(version, bool) or not isinstance(version, (int, np.integer)): + raise ValueError("Checkpoint version must be an integer.") + if str(cast(object, version)) != str(CHECKPOINT_VERSION): + raise ValueError( + f"Unsupported checkpoint version {version}; expected {CHECKPOINT_VERSION}." + ) + try: + decoded_metadata = json.loads(_attribute_text(h5.attrs.get("metadata"), "metadata")) + except json.JSONDecodeError as exc: + raise ValueError("Checkpoint metadata is not valid JSON.") from exc + if not isinstance(decoded_metadata, dict): + raise ValueError("Checkpoint metadata must be an object.") + metadata = cast(dict[str, object], decoded_metadata) + try: + state = h5["state"] + if not isinstance(state, h5py.Group): + raise ValueError("Checkpoint state must be an HDF5 group.") + magnetisation = _finite_array("magnetisation", state["m"], (point_count, 3)) + pinning = _finite_array("pinning", state["pin"], (point_count,)) + external_field = _finite_array("external field", state["H_ext"], (3,)) + current_density = ( + _finite_array("current density", state["current_density"], (point_count, 3)) + if "current_density" in state + else None + ) + except KeyError as exc: + raise ValueError( + f"Checkpoint is missing required state dataset {exc.args[0]!r}." + ) from exc + except OSError as exc: + raise ValueError(f"Unable to read checkpoint {filename}: {exc}") from exc + return CheckpointContents(magnetisation, pinning, external_field, current_density, metadata) + + +def validate_mesh(simulation: Any, contents: CheckpointContents) -> None: + saved = contents.metadata.get("mesh_fingerprint") + if not isinstance(saved, str): + raise ValueError("Checkpoint metadata is missing a mesh fingerprint.") + if saved != mesh_fingerprint(simulation): + raise ValueError("Checkpoint mesh does not match the loaded simulation mesh.") + + +def runtime_state(simulation: Any, contents: CheckpointContents) -> RestartRuntimeState: + """Validate full-restart compatibility and parse all non-array runtime state.""" + validate_mesh(simulation, contents) + saved_material = contents.metadata.get("material_fingerprint") + if not isinstance(saved_material, str): + raise ValueError("Checkpoint metadata is missing a material fingerprint.") + if saved_material != material_fingerprint(simulation): + raise ValueError("Checkpoint materials do not match the loaded simulation.") + saved_demag = contents.metadata.get("do_demag") + if not isinstance(saved_demag, bool) or saved_demag != simulation.do_demag: + raise ValueError("Checkpoint demagnetization mode does not match the loaded simulation.") + + clock_state = contents.metadata.get("clock") + dynamics = contents.metadata.get("dynamics") + if not isinstance(clock_state, dict) or not isinstance(dynamics, dict): + raise ValueError("Checkpoint metadata is missing clock or dynamics state.") + parsed_clock_state = cast(dict[str, object], clock_state) + parsed_dynamics = cast(dict[str, object], dynamics) + clock = SimulationClock.from_checkpoint_state(parsed_clock_state) + try: + exact_tstop = parsed_dynamics["exact_tstop"] + if not isinstance(exact_tstop, bool): + raise ValueError("Checkpoint exact_tstop must be a boolean.") + config = IntegratorConfig( + relative_tolerance=_finite_number( + "relative tolerance", parsed_dynamics["relative_tolerance"], positive=True + ), + absolute_tolerance=_finite_number( + "absolute tolerance", parsed_dynamics["absolute_tolerance"], positive=True + ), + maximum_step_seconds=_finite_number( + "maximum step", parsed_dynamics["maximum_step_seconds"], positive=True + ), + exact_tstop=exact_tstop, + ) + config.validate() + stopping_dm_dt = _finite_number( + "stopping dm/dt", parsed_dynamics["stopping_dm_dt"], positive=True + ) + maximum_time = _finite_number( + "maximum time", parsed_dynamics["maximum_time_seconds"], positive=True + ) + maximum_dm_dt_raw = parsed_dynamics["maximum_dm_dt"] + maximum_dm_dt = ( + None + if maximum_dm_dt_raw is None + else _finite_number("maximum dm/dt", maximum_dm_dt_raw) + ) + convergence_state = parsed_dynamics["convergence"] + except KeyError as exc: + raise ValueError(f"Checkpoint dynamics state is missing {exc.args[0]!r}.") from exc + if not isinstance(convergence_state, dict): + raise ValueError("Checkpoint convergence state must be an object.") + convergence = ConvergenceTracker.from_checkpoint_state( + cast(dict[str, object], convergence_state) + ) + return RestartRuntimeState( + clock, + config, + stopping_dm_dt, + maximum_time, + maximum_dm_dt, + convergence, + ) diff --git a/src/nmag/config.py b/src/nmag/config.py new file mode 100644 index 0000000..a25d889 --- /dev/null +++ b/src/nmag/config.py @@ -0,0 +1,174 @@ +"""Typed runtime configuration for supported Nmag Python workflows.""" + +from __future__ import annotations + +import math +import os +from collections.abc import Mapping +from dataclasses import dataclass, field +from enum import Enum +from numbers import Real +from pathlib import Path +from types import MappingProxyType +from typing import Literal, cast + +AcceleratorMode = Literal["auto", "off", "rust"] +OutputPolicy = Literal["error", "replace", "append"] +IntegratorBackend = Literal["scipy", "diffsol"] +DemagBemStorage = Literal["auto", "dense", "hierarchical", "matrix-free"] + +ACCELERATOR_ENV = "NMAG_ACCELERATOR" +DEMAG_BEM_STORAGE_ENV = "NMAG_DEMAG_BEM_STORAGE_BACKEND" + + +@dataclass(frozen=True, slots=True) +class HierarchicalBemConfig: + """Control accuracy and resources for compressed Lindholm BEM. + + Attributes: + relative_tolerance: Requested relative compression/certification error. + admissibility_eta: Geometric separation threshold for low-rank blocks. + leaf_size: Maximum cluster leaf size. + max_rank: Maximum accepted low-rank block rank. + validation_vectors: Random vectors used to certify completed action. + validation_rows: Exact rows sampled during certification. + memory_fraction: Fraction of currently available memory allowed for the + completed hierarchy. + """ + + relative_tolerance: float = 1.0e-6 + admissibility_eta: float = 2.0 + leaf_size: int = 32 + max_rank: int = 128 + validation_vectors: int = 4 + validation_rows: int = 64 + memory_fraction: float = 0.20 + + def __post_init__(self) -> None: + for name in ("relative_tolerance", "admissibility_eta", "memory_fraction"): + raw_value = getattr(self, name) + if type(raw_value) is bool or not isinstance(raw_value, Real): + raise TypeError(f"{name} must be a real number.") + value = float(raw_value) + if not math.isfinite(value) or value <= 0.0: + raise ValueError(f"{name} must be finite and positive.") + object.__setattr__(self, name, value) + if self.memory_fraction > 1.0: + raise ValueError("memory_fraction must not exceed 1.0.") + for name in ("leaf_size", "max_rank", "validation_vectors", "validation_rows"): + value = getattr(self, name) + if not isinstance(value, int) or isinstance(value, bool) or value <= 0: + raise ValueError(f"{name} must be a positive integer.") + + +class RustKernel(str, Enum): + """Rust-accelerated calculation families accepted as override keys.""" + + LINDHOLM_BEM = "lindholm_bem" + PROBE_GEOMETRY = "probe_geometry" + FEM_GEOMETRY = "fem_geometry" + BOUNDARY_FACES = "boundary_faces" + FEM_ASSEMBLY = "fem_assembly" + NODAL_RECOVERY = "nodal_recovery" + CELL_AVERAGE = "cell_average" + LLG = "llg" + MAXANGLE = "maxangle" + + +def _validate_mode(value: object, *, field_name: str) -> AcceleratorMode: + if not isinstance(value, str): + raise TypeError(f"{field_name} must be a string mode.") + if value not in {"auto", "off", "rust"}: + raise ValueError(f"{field_name} must be one of 'auto', 'off', or 'rust', got {value!r}.") + return cast(AcceleratorMode, value) + + +def _empty_overrides() -> Mapping[RustKernel, AcceleratorMode]: + return {} + + +@dataclass(frozen=True, slots=True) +class NmagConfig: + """Define immutable output, acceleration, storage, and integrator policy. + + Attributes: + default_name: Simulation name used when ``Simulation(name=...)`` is + omitted. + output_directory: Directory for NDT, HDF5, and default checkpoint files. + The directory must exist before output is written. + output_policy: ``"error"` protects old output, ``"replace"`` starts it + again, and ``"append"`` validates and extends the NDT schema. + accelerator: Global ``"auto"``, ``"off"``, or strict ``"rust"`` mode. + accelerator_overrides: Per-:class:`RustKernel` mode overrides. + integrator_backend: ``"scipy"`` or experimental ``"diffsol"``. + demag_bem_storage: ``"auto"``, ``"dense"``, ``"hierarchical"``, or + ``"matrix-free"`` boundary-operator storage. + hierarchical_bem: Accuracy and resource settings for hierarchy builds. + """ + + default_name: str = "nmag_simulation" + output_directory: Path = Path(".") + output_policy: OutputPolicy = "error" + accelerator: AcceleratorMode = "auto" + accelerator_overrides: Mapping[RustKernel, AcceleratorMode] = field( + default_factory=_empty_overrides + ) + integrator_backend: IntegratorBackend = "scipy" + demag_bem_storage: DemagBemStorage = "auto" + hierarchical_bem: HierarchicalBemConfig = field(default_factory=HierarchicalBemConfig) + + def __post_init__(self) -> None: + if not self.default_name: + raise ValueError("default_name must not be empty.") + if self.output_policy not in {"error", "replace", "append"}: + raise ValueError("output_policy must be 'error', 'replace', or 'append'.") + if self.integrator_backend not in {"scipy", "diffsol"}: + raise ValueError("integrator_backend must be 'scipy' or 'diffsol'.") + if self.demag_bem_storage not in {"auto", "dense", "hierarchical", "matrix-free"}: + raise ValueError( + "demag_bem_storage must be 'auto', 'dense', 'hierarchical', or 'matrix-free'." + ) + if not isinstance(cast(object, self.hierarchical_bem), HierarchicalBemConfig): + raise TypeError("hierarchical_bem must be a HierarchicalBemConfig instance.") + object.__setattr__(self, "output_directory", Path(self.output_directory).expanduser()) + object.__setattr__( + self, + "accelerator", + _validate_mode(self.accelerator, field_name="accelerator"), + ) + + validated_overrides: dict[RustKernel, AcceleratorMode] = {} + raw_overrides = cast(Mapping[object, object], self.accelerator_overrides) + for raw_kernel, raw_mode in raw_overrides.items(): + if not isinstance(raw_kernel, RustKernel): + raise TypeError("accelerator_overrides keys must be RustKernel values.") + validated_overrides[raw_kernel] = _validate_mode( + raw_mode, + field_name=f"accelerator_overrides[{raw_kernel.value!r}]", + ) + object.__setattr__( + self, + "accelerator_overrides", + MappingProxyType(validated_overrides), + ) + + @classmethod + def from_environment(cls) -> NmagConfig: + """Build default configuration from supported process-level selectors. + + Returns: + Configuration using ``NMAG_ACCELERATOR`` and + ``NMAG_DEMAG_BEM_STORAGE_BACKEND`` when set. + """ + + accelerator = os.environ.get(ACCELERATOR_ENV, "auto").strip().lower() + bem_storage = os.environ.get(DEMAG_BEM_STORAGE_ENV, "auto").strip().lower() + return cls( + accelerator=_validate_mode(accelerator, field_name=ACCELERATOR_ENV), + demag_bem_storage=cast(DemagBemStorage, bem_storage), + ) + + def accelerator_mode_for(self, kernel: RustKernel) -> AcceleratorMode: + """Return a kernel override, falling back to the global accelerator mode.""" + + return self.accelerator_overrides.get(kernel, self.accelerator) diff --git a/src/nmag/demag/__init__.py b/src/nmag/demag/__init__.py new file mode 100644 index 0000000..0cc20f8 --- /dev/null +++ b/src/nmag/demag/__init__.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from typing import Any + +from . import geometry as _geometry +from . import lindholm as _lindholm +from . import lindholm_fast as _fast +from . import linear as _linear + +_HELPER_MODULES = (_geometry, _lindholm, _fast, _linear) + +# Preserve the established patch points used by downstream tests and tooling. +np = _linear.np +_scipy_linalg = _linear._scipy_linalg + + +def _solve_gauge_fixed(*args: Any, **kwargs: Any) -> Any: + """Forward to the solver while preserving facade-level monkeypatches.""" + + _linear._scipy_linalg = _scipy_linalg + return _linear._solve_gauge_fixed(*args, **kwargs) + + +def _solve_linear_system(*args: Any, **kwargs: Any) -> Any: + """Forward to the solver while preserving facade-level monkeypatches.""" + + _linear._scipy_linalg = _scipy_linalg + return _linear._solve_linear_system(*args, **kwargs) + + +def __getattr__(name: str) -> Any: + """Resolve compatibility exports from the focused demag modules.""" + + for module in _HELPER_MODULES: + try: + return getattr(module, name) + except AttributeError: + continue + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/nmag/demag/bem_operator.py b/src/nmag/demag/bem_operator.py new file mode 100644 index 0000000..1cbc37c --- /dev/null +++ b/src/nmag/demag/bem_operator.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +import numpy as np + +from .lindholm_fast import _apply_lindholm_bem_matrix_free_fast + + +@dataclass(frozen=True, slots=True) +class BemOperatorStats: + """Describe construction and storage of the active BEM operator. + + Attributes: + requested_backend: Storage mode requested by configuration. + effective_backend: Storage implementation actually constructed. + fallback_reason: Explanation when construction changed modes. + boundary_nodes: Number of boundary degrees of freedom. + boundary_faces: Number of oriented surface triangles. + setup_seconds: Operator construction time. + storage_bytes: Bytes retained by the effective operator. + dense_equivalent_bytes: Bytes required by an equivalent dense matrix. + dense_blocks: Exact blocks in a hierarchy. + low_rank_blocks: Compressed blocks in a hierarchy. + maximum_rank: Largest compressed-block rank. + mean_rank: Mean compressed-block rank. + sampled_relative_error: Certification error measured during setup. + """ + + requested_backend: str + effective_backend: str + fallback_reason: str | None + boundary_nodes: int + boundary_faces: int + setup_seconds: float + storage_bytes: int + dense_equivalent_bytes: int + dense_blocks: int = 0 + low_rank_blocks: int = 0 + maximum_rank: int = 0 + mean_rank: float = 0.0 + sampled_relative_error: float = 0.0 + + @property + def compression_ratio(self) -> float: + if self.dense_equivalent_bytes == 0: + return 0.0 + return self.storage_bytes / self.dense_equivalent_bytes + + +@dataclass(frozen=True, slots=True) +class MatrixFreeLindholmBemOperator: + """Exact Lindholm BEM action without storing the dense boundary matrix.""" + + points: np.ndarray + simplices: np.ndarray + boundary_faces: np.ndarray + face_points: np.ndarray + zeta_vectors: np.ndarray + eta_vectors: np.ndarray + edge_lengths: np.ndarray + corner_cosines: np.ndarray + denominator_factors: np.ndarray + boundary_nodes: np.ndarray + local_index_by_point: np.ndarray + solid_angles: np.ndarray + rust_accelerator: Any | None = None + + @property + def shape(self) -> tuple[int, int]: + size = len(self.boundary_nodes) + return size, size + + def __matmul__(self, values: np.ndarray) -> np.ndarray: + vector = np.asarray(values, dtype=np.float64) + expected = (len(self.boundary_nodes),) + if vector.shape != expected: + raise ValueError(f"BEM input must have shape {expected}, got {vector.shape}.") + if self.rust_accelerator is not None: + return np.asarray( + self.rust_accelerator.apply_lindholm_bem_matrix_free( + self.points, + self.simplices, + self.boundary_faces, + self.boundary_nodes, + self.local_index_by_point, + vector, + ), + dtype=float, + ) + return np.asarray( + _apply_lindholm_bem_matrix_free_fast( + self.points, + self.boundary_faces, + self.face_points, + self.zeta_vectors, + self.eta_vectors, + self.edge_lengths, + self.corner_cosines, + self.denominator_factors, + self.boundary_nodes, + self.local_index_by_point, + self.solid_angles, + vector, + ), + dtype=float, + ) + + @property + def storage_bytes(self) -> int: + arrays = ( + self.points, + self.simplices, + self.boundary_faces, + self.face_points, + self.zeta_vectors, + self.eta_vectors, + self.edge_lengths, + self.corner_cosines, + self.denominator_factors, + self.boundary_nodes, + self.local_index_by_point, + self.solid_angles, + ) + return sum(int(array.nbytes) for array in arrays) + + +@dataclass(frozen=True, slots=True) +class HierarchicalLindholmBemOperator: + """Certified compressed Lindholm operator owned by the Rust extension.""" + + rust_operator: Any + + @property + def shape(self) -> tuple[int, int]: + size = int(self.rust_operator.size) + return size, size + + def __matmul__(self, values: np.ndarray) -> np.ndarray: + vector = np.asarray(values, dtype=np.float64) + expected = (self.shape[1],) + if vector.shape != expected: + raise ValueError(f"BEM input must have shape {expected}, got {vector.shape}.") + return np.asarray(self.rust_operator.matvec(vector), dtype=np.float64) + + @property + def storage_bytes(self) -> int: + return int(self.rust_operator.storage_bytes) diff --git a/src/nmag/demag/geometry.py b/src/nmag/demag/geometry.py new file mode 100644 index 0000000..f184edf --- /dev/null +++ b/src/nmag/demag/geometry.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +import numpy as np +import numpy.typing as npt + +FloatArray = npt.NDArray[np.float64] +IntArray = npt.NDArray[np.int64] + +TETRA_FACE_VERTICES = ((1, 2, 3), (0, 3, 2), (0, 1, 3), (0, 2, 1)) + + +def _validate_tetrahedral_cells(points: FloatArray, simplices: IntArray) -> None: + if len(simplices) == 0: + return + cell_points = points[simplices] + edges = cell_points[:, 1:] - cell_points[:, [0]] + length_scales = np.max(np.linalg.norm(edges, axis=2), axis=1) + determinants = np.abs(np.linalg.det(edges)) + with np.errstate(divide="ignore", invalid="ignore"): + normalized_determinants = determinants / (length_scales**3) + invalid = ( + ~np.isfinite(normalized_determinants) + | (length_scales <= 0.0) + | (normalized_determinants <= 1.0e-12) + ) + if np.any(invalid): + indices = np.flatnonzero(invalid).tolist() + raise ValueError( + f"Demag FEM requires non-degenerate tetrahedra; invalid cell indices: {indices}." + ) + + +def _simplex_volumes(points: FloatArray, simplices: IntArray) -> FloatArray: + """Return 3D simplex volumes for tetrahedral mesh cells.""" + if len(simplices) == 0: + return np.empty(0, dtype=float) + matrices = points[simplices[:, 1:]] - points[simplices[:, [0]]] + return np.abs(np.linalg.det(matrices)) / 6.0 + + +def _mean_edge_length(points: FloatArray, simplices: IntArray) -> float: + lengths: list[float] = [] + for simplex in simplices: + for left_index, left in enumerate(simplex): + for right in simplex[left_index + 1 :]: + lengths.append(float(np.linalg.norm(points[int(left)] - points[int(right)]))) + if not lengths: + return 1.0 + return max(float(np.mean(lengths)), np.finfo(float).eps) + + +def _tetrahedral_barycentric_coordinates( + tetrahedron: FloatArray, + point: FloatArray, + *, + tolerance: float = 1.0e-12, +) -> FloatArray | None: + matrix = np.column_stack( + [ + tetrahedron[1] - tetrahedron[0], + tetrahedron[2] - tetrahedron[0], + tetrahedron[3] - tetrahedron[0], + ] + ) + try: + local = np.asarray(np.linalg.solve(matrix, point - tetrahedron[0]), dtype=float) + except np.linalg.LinAlgError: + return None + barycentric = np.asarray( + [1.0 - float(np.sum(local)), local[0], local[1], local[2]], + dtype=float, + ) + if np.all(barycentric >= -tolerance) and np.all(barycentric <= 1.0 + tolerance): + return np.minimum(np.maximum(barycentric, 0.0), 1.0) + return None + + +def _oriented_boundary_faces( + points: FloatArray, simplices: IntArray +) -> list[tuple[int, tuple[int, int, int]]]: + if len(simplices) == 0: + return [] + + cell_indices = np.repeat( + np.arange(len(simplices), dtype=np.int64), + len(TETRA_FACE_VERTICES), + ) + face_nodes: IntArray = np.asarray( + [ + [int(simplex[first]), int(simplex[second]), int(simplex[third])] + for simplex in simplices + for first, second, third in TETRA_FACE_VERTICES + ], + dtype=np.int64, + ) + face_keys = np.sort(face_nodes, axis=1) + unique_face_keys, inverse, counts = np.unique( + face_keys, + axis=0, + return_inverse=True, + return_counts=True, + ) + non_manifold_faces = unique_face_keys[counts > 2] + if len(non_manifold_faces): + raise ValueError( + "Non-manifold mesh: at least one face is shared by more than two tetrahedra." + ) + boundary_positions = np.flatnonzero(counts[inverse] == 1) + if len(boundary_positions) == 0: + return [] + + boundary_cells = cell_indices[boundary_positions] + boundary_face_nodes: IntArray = np.asarray( + [face_nodes[int(position)] for position in boundary_positions], + dtype=np.int64, + ) + face_points = points[boundary_face_nodes] + cell_centers = np.mean(points[simplices[boundary_cells]], axis=1) + face_centers = np.mean(face_points, axis=1) + normals = np.cross(face_points[:, 1] - face_points[:, 0], face_points[:, 2] - face_points[:, 0]) + points_inward = np.einsum("ij,ij->i", normals, cell_centers - face_centers) > 0.0 + for position in np.flatnonzero(points_inward): + position_index = int(position) + second = int(boundary_face_nodes[position_index, 1]) + boundary_face_nodes[position_index, 1] = boundary_face_nodes[position_index, 2] + boundary_face_nodes[position_index, 2] = second + + return [ + ( + int(cell_index), + (int(face[0]), int(face[1]), int(face[2])), + ) + for cell_index, face in zip(boundary_cells, boundary_face_nodes, strict=True) + ] diff --git a/src/nmag/demag/lindholm.py b/src/nmag/demag/lindholm.py new file mode 100644 index 0000000..35d7567 --- /dev/null +++ b/src/nmag/demag/lindholm.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import numpy as np + + +def _triangle_space_angle( + observer: np.ndarray, + p0: np.ndarray, + p1: np.ndarray, + p2: np.ndarray, +) -> float: + """Legacy unsigned space angle of a triangle as seen from ``observer``.""" + + r0 = p0 - observer + r1 = p1 - observer + r2 = p2 - observer + r0_len = float(np.linalg.norm(r0)) + r1_len = float(np.linalg.norm(r1)) + r2_len = float(np.linalg.norm(r2)) + dot01 = float(np.dot(r0, r1)) + dot12 = float(np.dot(r1, r2)) + dot20 = float(np.dot(r2, r0)) + numerator = r0_len * r1_len * r2_len + r0_len * dot12 + r1_len * dot20 + r2_len * dot01 + denominator_squared = ( + 2.0 * (r1_len * r2_len + dot12) * (r2_len * r0_len + dot20) * (r0_len * r1_len + dot01) + ) + if denominator_squared <= 0.0: + return 0.0 + quotient = numerator / float(np.sqrt(denominator_squared)) + return float(2.0 * np.arccos(min(1.0, max(-1.0, quotient)))) + + +def _normalised(vector: np.ndarray) -> np.ndarray: + length = float(np.linalg.norm(vector)) + if length == 0.0: + return np.zeros_like(vector, dtype=float) + return vector / length + + +def _safe_log_ratio(numerator: float, denominator: float) -> float: + if denominator <= 0.0: + return float("nan") + with np.errstate(divide="ignore", invalid="ignore"): + return float(np.log(numerator / denominator)) + + +def _lindholm_triangle_contributions( + observer: np.ndarray, + p0: np.ndarray, + p1: np.ndarray, + p2: np.ndarray, + outward_surface_normal: np.ndarray, +) -> np.ndarray: + """Legacy Lindholm BEM row contribution for one first-order triangle.""" + + angle = _triangle_space_angle(observer, p0, p1, p2) + r0 = p0 - observer + r1 = p1 - observer + r2 = p2 - observer + s0 = r2 - r1 + s1 = r0 - r2 + s2 = r1 - r0 + xi0 = _normalised(s0) + xi1 = _normalised(s1) + xi2 = _normalised(s2) + area_vector = np.cross(p1 - p0, p2 - p0) + area = 0.5 * float(np.linalg.norm(area_vector)) + if area == 0.0: + return np.zeros(3, dtype=float) + + zeta_vector = _normalised(area_vector) + r0_len = float(np.linalg.norm(r0)) + r1_len = float(np.linalg.norm(r1)) + r2_len = float(np.linalg.norm(r2)) + s0_len = float(np.linalg.norm(s0)) + s1_len = float(np.linalg.norm(s1)) + s2_len = float(np.linalg.norm(s2)) + c01 = float(np.dot(xi0, xi1)) + c12 = float(np.dot(xi1, xi2)) + c20 = float(np.dot(xi2, xi0)) + zeta = float(np.dot(zeta_vector, r0)) + eta0 = np.cross(zeta_vector, xi0) + eta1 = np.cross(zeta_vector, xi1) + eta2 = np.cross(zeta_vector, xi2) + log_terms = np.asarray( + [ + _safe_log_ratio(r1_len + r2_len + s0_len, r1_len + r2_len - s0_len), + _safe_log_ratio(r2_len + r0_len + s1_len, r2_len + r0_len - s1_len), + _safe_log_ratio(r0_len + r1_len + s2_len, r0_len + r1_len - s2_len), + ], + dtype=float, + ) + gamma0 = np.asarray([1.0, c01, c20], dtype=float) + gamma1 = np.asarray([c01, 1.0, c12], dtype=float) + gamma2 = np.asarray([c20, c12, 1.0], dtype=float) + sign_zeta = -1.0 if zeta < 0.0 else (1.0 if zeta > 0.0 else 0.0) + angle_pm = angle * sign_zeta + denominator_factor = 1.0 / (8.0 * np.pi * area) + with np.errstate(invalid="ignore"): + contributions = np.asarray( + [ + s0_len + * denominator_factor + * (angle_pm * float(np.dot(eta0, r1)) - zeta * float(np.dot(gamma0, log_terms))), + s1_len + * denominator_factor + * (angle_pm * float(np.dot(eta1, r2)) - zeta * float(np.dot(gamma1, log_terms))), + s2_len + * denominator_factor + * (angle_pm * float(np.dot(eta2, r0)) - zeta * float(np.dot(gamma2, log_terms))), + ], + dtype=float, + ) + contributions = np.where(np.isfinite(contributions), contributions, 0.0) + if float(np.dot(zeta_vector, outward_surface_normal)) < 0.0: + contributions = -contributions + return contributions + + +def _boundary_node_solid_angles(points: np.ndarray, simplices: np.ndarray) -> np.ndarray: + angles = np.zeros(len(points), dtype=float) + for simplex in simplices: + for local_index, point_index in enumerate(simplex): + other_indices = [index for index in range(4) if index != local_index] + angles[int(point_index)] += abs( + _triangle_space_angle( + points[int(point_index)], + points[int(simplex[other_indices[0]])], + points[int(simplex[other_indices[1]])], + points[int(simplex[other_indices[2]])], + ) + ) + return angles diff --git a/src/nmag/demag/lindholm_fast.py b/src/nmag/demag/lindholm_fast.py new file mode 100644 index 0000000..32daa29 --- /dev/null +++ b/src/nmag/demag/lindholm_fast.py @@ -0,0 +1,463 @@ +from __future__ import annotations + +from collections.abc import Callable +from functools import wraps +from typing import Any, ParamSpec, TypeVar, cast + +import numpy as np + +_JIT_FUNCTIONS: list[Callable[..., Any]] = [] +_jit_compiled = False +P = ParamSpec("P") +R = TypeVar("R") + + +def __getattr__(name: str) -> Any: + if name == "_JIT_COMPILED": + return _jit_compiled + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +def _compile_jit_functions() -> None: + global _jit_compiled + if _jit_compiled: + return + + try: + from numba import njit + except ImportError: # pragma: no cover - numba is a project dependency. + compiled_functions = {function.__name__: function for function in _JIT_FUNCTIONS} + else: + compiled_functions = { + function.__name__: njit(cache=True)(function) for function in _JIT_FUNCTIONS + } + + globals().update(compiled_functions) + _jit_compiled = True + + +def _jit(function: Callable[P, R]) -> Callable[P, R]: + _JIT_FUNCTIONS.append(function) + + @wraps(function) + def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: + _compile_jit_functions() + compiled = cast(Callable[P, R], globals()[function.__name__]) + return compiled(*args, **kwargs) + + cast(Any, wrapper).py_func = function + return wrapper + + +@_jit +def _dot3(left: np.ndarray, right: np.ndarray) -> float: + return left[0] * right[0] + left[1] * right[1] + left[2] * right[2] + + +@_jit +def _cross3(left: np.ndarray, right: np.ndarray) -> np.ndarray: + result = np.empty(3, dtype=np.float64) + result[0] = left[1] * right[2] - left[2] * right[1] + result[1] = left[2] * right[0] - left[0] * right[2] + result[2] = left[0] * right[1] - left[1] * right[0] + return result + + +@_jit +def _subtract3(left: np.ndarray, right: np.ndarray) -> np.ndarray: + result = np.empty(3, dtype=np.float64) + result[0] = left[0] - right[0] + result[1] = left[1] - right[1] + result[2] = left[2] - right[2] + return result + + +@_jit +def _norm3(vector: np.ndarray) -> float: + return np.sqrt(_dot3(vector, vector)) + + +@_jit +def _normalised3(vector: np.ndarray) -> np.ndarray: + result = np.zeros(3, dtype=np.float64) + length = _norm3(vector) + if length == 0.0: + return result + result[0] = vector[0] / length + result[1] = vector[1] / length + result[2] = vector[2] / length + return result + + +@_jit +def _triangle_space_angle_fast( + observer: np.ndarray, + p0: np.ndarray, + p1: np.ndarray, + p2: np.ndarray, +) -> float: + r0 = _subtract3(p0, observer) + r1 = _subtract3(p1, observer) + r2 = _subtract3(p2, observer) + r0_len = _norm3(r0) + r1_len = _norm3(r1) + r2_len = _norm3(r2) + dot01 = _dot3(r0, r1) + dot12 = _dot3(r1, r2) + dot20 = _dot3(r2, r0) + numerator = r0_len * r1_len * r2_len + r0_len * dot12 + r1_len * dot20 + r2_len * dot01 + denominator_squared = ( + 2.0 * (r1_len * r2_len + dot12) * (r2_len * r0_len + dot20) * (r0_len * r1_len + dot01) + ) + if denominator_squared <= 0.0: + return 0.0 + quotient = numerator / np.sqrt(denominator_squared) + if quotient < -1.0: + quotient = -1.0 + elif quotient > 1.0: + quotient = 1.0 + return 2.0 * np.arccos(quotient) + + +@_jit +def _safe_log_ratio_fast(numerator: float, denominator: float) -> float: + if denominator <= 0.0: + return np.nan + return np.log(numerator / denominator) + + +@_jit +def _lindholm_triangle_contributions_fast( + observer: np.ndarray, + p0: np.ndarray, + p1: np.ndarray, + p2: np.ndarray, + outward_surface_normal: np.ndarray, +) -> np.ndarray: + angle = _triangle_space_angle_fast(observer, p0, p1, p2) + r0 = _subtract3(p0, observer) + r1 = _subtract3(p1, observer) + r2 = _subtract3(p2, observer) + s0 = _subtract3(r2, r1) + s1 = _subtract3(r0, r2) + s2 = _subtract3(r1, r0) + xi0 = _normalised3(s0) + xi1 = _normalised3(s1) + xi2 = _normalised3(s2) + area_vector = _cross3(_subtract3(p1, p0), _subtract3(p2, p0)) + area = 0.5 * _norm3(area_vector) + contributions = np.zeros(3, dtype=np.float64) + if area == 0.0: + return contributions + + zeta_vector = _normalised3(area_vector) + r0_len = _norm3(r0) + r1_len = _norm3(r1) + r2_len = _norm3(r2) + s0_len = _norm3(s0) + s1_len = _norm3(s1) + s2_len = _norm3(s2) + c01 = _dot3(xi0, xi1) + c12 = _dot3(xi1, xi2) + c20 = _dot3(xi2, xi0) + zeta = _dot3(zeta_vector, r0) + eta0 = _cross3(zeta_vector, xi0) + eta1 = _cross3(zeta_vector, xi1) + eta2 = _cross3(zeta_vector, xi2) + log0 = _safe_log_ratio_fast(r1_len + r2_len + s0_len, r1_len + r2_len - s0_len) + log1 = _safe_log_ratio_fast(r2_len + r0_len + s1_len, r2_len + r0_len - s1_len) + log2 = _safe_log_ratio_fast(r0_len + r1_len + s2_len, r0_len + r1_len - s2_len) + sign_zeta = 0.0 + if zeta < 0.0: + sign_zeta = -1.0 + elif zeta > 0.0: + sign_zeta = 1.0 + angle_pm = angle * sign_zeta + denominator_factor = 1.0 / (8.0 * np.pi * area) + gamma0_log = log0 + c01 * log1 + c20 * log2 + gamma1_log = c01 * log0 + log1 + c12 * log2 + gamma2_log = c20 * log0 + c12 * log1 + log2 + values = np.empty(3, dtype=np.float64) + values[0] = s0_len * denominator_factor * (angle_pm * _dot3(eta0, r1) - zeta * gamma0_log) + values[1] = s1_len * denominator_factor * (angle_pm * _dot3(eta1, r2) - zeta * gamma1_log) + values[2] = s2_len * denominator_factor * (angle_pm * _dot3(eta2, r0) - zeta * gamma2_log) + if _dot3(zeta_vector, outward_surface_normal) < 0.0: + values[0] = -values[0] + values[1] = -values[1] + values[2] = -values[2] + for index in range(3): + if np.isfinite(values[index]): + contributions[index] = values[index] + return contributions + + +def _normalised_rows(vectors: np.ndarray) -> np.ndarray: + lengths = np.linalg.norm(vectors, axis=1) + result = np.zeros_like(vectors, dtype=float) + positive = lengths > 0.0 + result[positive] = vectors[positive] / lengths[positive, np.newaxis] + return result + + +def _precompute_lindholm_face_geometry( + points: np.ndarray, + face_nodes: np.ndarray, +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + face_points = points[face_nodes] + if len(face_nodes) == 0: + return ( + face_points, + np.empty((0, 3), dtype=float), + np.empty((0, 3, 3), dtype=float), + np.empty((0, 3), dtype=float), + np.empty((0, 3), dtype=float), + np.empty(0, dtype=float), + ) + + edge0 = face_points[:, 2] - face_points[:, 1] + edge1 = face_points[:, 0] - face_points[:, 2] + edge2 = face_points[:, 1] - face_points[:, 0] + edge_vectors = np.stack([edge0, edge1, edge2], axis=1) + edge_lengths = np.linalg.norm(edge_vectors, axis=2) + xis = np.zeros_like(edge_vectors, dtype=float) + for index in range(3): + xis[:, index] = _normalised_rows(edge_vectors[:, index]) + + area_vectors = np.cross( + face_points[:, 1] - face_points[:, 0], + face_points[:, 2] - face_points[:, 0], + ) + double_areas = np.linalg.norm(area_vectors, axis=1) + zeta_vectors = np.zeros_like(area_vectors, dtype=float) + positive_area = double_areas > 0.0 + zeta_vectors[positive_area] = ( + area_vectors[positive_area] / double_areas[positive_area, np.newaxis] + ) + + eta_vectors = np.stack( + [ + np.cross(zeta_vectors, xis[:, 0]), + np.cross(zeta_vectors, xis[:, 1]), + np.cross(zeta_vectors, xis[:, 2]), + ], + axis=1, + ) + corner_cosines = np.stack( + [ + np.einsum("ij,ij->i", xis[:, 0], xis[:, 1]), + np.einsum("ij,ij->i", xis[:, 1], xis[:, 2]), + np.einsum("ij,ij->i", xis[:, 2], xis[:, 0]), + ], + axis=1, + ) + denominator_factors = np.zeros(len(face_nodes), dtype=float) + denominator_factors[positive_area] = 1.0 / (4.0 * np.pi * double_areas[positive_area]) + return face_points, zeta_vectors, eta_vectors, edge_lengths, corner_cosines, denominator_factors + + +@_jit +def _lindholm_triangle_contributions_precomputed_fast( + observer: np.ndarray, + p0: np.ndarray, + p1: np.ndarray, + p2: np.ndarray, + zeta_vector: np.ndarray, + eta0: np.ndarray, + eta1: np.ndarray, + eta2: np.ndarray, + edge_lengths: np.ndarray, + corner_cosines: np.ndarray, + denominator_factor: float, +) -> np.ndarray: + contributions = np.zeros(3, dtype=np.float64) + if denominator_factor == 0.0: + return contributions + + r0 = _subtract3(p0, observer) + r1 = _subtract3(p1, observer) + r2 = _subtract3(p2, observer) + r0_len = _norm3(r0) + r1_len = _norm3(r1) + r2_len = _norm3(r2) + dot01 = _dot3(r0, r1) + dot12 = _dot3(r1, r2) + dot20 = _dot3(r2, r0) + numerator = r0_len * r1_len * r2_len + r0_len * dot12 + r1_len * dot20 + r2_len * dot01 + denominator_squared = ( + 2.0 * (r1_len * r2_len + dot12) * (r2_len * r0_len + dot20) * (r0_len * r1_len + dot01) + ) + angle = 0.0 + if denominator_squared > 0.0: + quotient = numerator / np.sqrt(denominator_squared) + if quotient < -1.0: + quotient = -1.0 + elif quotient > 1.0: + quotient = 1.0 + angle = 2.0 * np.arccos(quotient) + + s0_len = edge_lengths[0] + s1_len = edge_lengths[1] + s2_len = edge_lengths[2] + c01 = corner_cosines[0] + c12 = corner_cosines[1] + c20 = corner_cosines[2] + zeta = _dot3(zeta_vector, r0) + log0 = _safe_log_ratio_fast(r1_len + r2_len + s0_len, r1_len + r2_len - s0_len) + log1 = _safe_log_ratio_fast(r2_len + r0_len + s1_len, r2_len + r0_len - s1_len) + log2 = _safe_log_ratio_fast(r0_len + r1_len + s2_len, r0_len + r1_len - s2_len) + sign_zeta = 0.0 + if zeta < 0.0: + sign_zeta = -1.0 + elif zeta > 0.0: + sign_zeta = 1.0 + angle_pm = angle * sign_zeta + gamma0_log = log0 + c01 * log1 + c20 * log2 + gamma1_log = c01 * log0 + log1 + c12 * log2 + gamma2_log = c20 * log0 + c12 * log1 + log2 + + value0 = s0_len * denominator_factor * (angle_pm * _dot3(eta0, r1) - zeta * gamma0_log) + value1 = s1_len * denominator_factor * (angle_pm * _dot3(eta1, r2) - zeta * gamma1_log) + value2 = s2_len * denominator_factor * (angle_pm * _dot3(eta2, r0) - zeta * gamma2_log) + if np.isfinite(value0): + contributions[0] = value0 + if np.isfinite(value1): + contributions[1] = value1 + if np.isfinite(value2): + contributions[2] = value2 + return contributions + + +@_jit +def _boundary_node_solid_angles_fast(points: np.ndarray, simplices: np.ndarray) -> np.ndarray: + angles = np.zeros(len(points), dtype=np.float64) + for simplex_index in range(len(simplices)): + simplex = simplices[simplex_index] + point0 = int(simplex[0]) + point1 = int(simplex[1]) + point2 = int(simplex[2]) + point3 = int(simplex[3]) + angles[point0] += abs( + _triangle_space_angle_fast( + points[point0], + points[point1], + points[point2], + points[point3], + ) + ) + angles[point1] += abs( + _triangle_space_angle_fast( + points[point1], + points[point0], + points[point2], + points[point3], + ) + ) + angles[point2] += abs( + _triangle_space_angle_fast( + points[point2], + points[point0], + points[point1], + points[point3], + ) + ) + angles[point3] += abs( + _triangle_space_angle_fast( + points[point3], + points[point0], + points[point1], + points[point2], + ) + ) + return angles + + +@_jit +def _build_lindholm_bem_matrix_fast( + points: np.ndarray, + simplices: np.ndarray, + boundary_faces: np.ndarray, + face_points: np.ndarray, + zeta_vectors: np.ndarray, + eta_vectors: np.ndarray, + edge_lengths: np.ndarray, + corner_cosines: np.ndarray, + denominator_factors: np.ndarray, + boundary_nodes: np.ndarray, + local_index_by_point: np.ndarray, +) -> np.ndarray: + bem = np.zeros((len(boundary_nodes), len(boundary_nodes)), dtype=np.float64) + solid_angles = _boundary_node_solid_angles_fast(points, simplices) + + for row in range(len(boundary_nodes)): + observer_index = int(boundary_nodes[row]) + observer = points[observer_index] + for face_index in range(len(boundary_faces)): + face = boundary_faces[face_index] + point0 = int(face[0]) + point1 = int(face[1]) + point2 = int(face[2]) + if denominator_factors[face_index] == 0.0: + continue + contributions = _lindholm_triangle_contributions_precomputed_fast( + observer, + face_points[face_index, 0], + face_points[face_index, 1], + face_points[face_index, 2], + zeta_vectors[face_index], + eta_vectors[face_index, 0], + eta_vectors[face_index, 1], + eta_vectors[face_index, 2], + edge_lengths[face_index], + corner_cosines[face_index], + denominator_factors[face_index], + ) + bem[row, int(local_index_by_point[point0])] += contributions[0] + bem[row, int(local_index_by_point[point1])] += contributions[1] + bem[row, int(local_index_by_point[point2])] += contributions[2] + bem[row, row] += solid_angles[observer_index] / (4.0 * np.pi) - 1.0 + return bem + + +@_jit +def _apply_lindholm_bem_matrix_free_fast( + points: np.ndarray, + boundary_faces: np.ndarray, + face_points: np.ndarray, + zeta_vectors: np.ndarray, + eta_vectors: np.ndarray, + edge_lengths: np.ndarray, + corner_cosines: np.ndarray, + denominator_factors: np.ndarray, + boundary_nodes: np.ndarray, + local_index_by_point: np.ndarray, + solid_angles: np.ndarray, + values: np.ndarray, +) -> np.ndarray: + result = np.zeros(len(boundary_nodes), dtype=np.float64) + for row in range(len(boundary_nodes)): + observer_index = int(boundary_nodes[row]) + observer = points[observer_index] + value = 0.0 + for face_index in range(len(boundary_faces)): + if denominator_factors[face_index] == 0.0: + continue + face = boundary_faces[face_index] + contributions = _lindholm_triangle_contributions_precomputed_fast( + observer, + face_points[face_index, 0], + face_points[face_index, 1], + face_points[face_index, 2], + zeta_vectors[face_index], + eta_vectors[face_index, 0], + eta_vectors[face_index, 1], + eta_vectors[face_index, 2], + edge_lengths[face_index], + corner_cosines[face_index], + denominator_factors[face_index], + ) + value += contributions[0] * values[int(local_index_by_point[int(face[0])])] + value += contributions[1] * values[int(local_index_by_point[int(face[1])])] + value += contributions[2] * values[int(local_index_by_point[int(face[2])])] + value += (solid_angles[observer_index] / (4.0 * np.pi) - 1.0) * values[row] + result[row] = value + return result diff --git a/src/nmag/demag/linear.py b/src/nmag/demag/linear.py new file mode 100644 index 0000000..4730a92 --- /dev/null +++ b/src/nmag/demag/linear.py @@ -0,0 +1,489 @@ +from __future__ import annotations + +import logging +import os +import time +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +import numpy as np + +from ..backends import ( + DEMAG_DENSE_MAX_POINTS_ENV, + LEAST_SQUARES_RELATIVE_RESIDUAL_TOLERANCE, + _demag_solve_condition_diagnostics_enabled, + _selected_demag_linear_solver_backend, +) + +LOGGER = logging.getLogger(__name__) +_scipy_linalg_cache: Any | None = None + + +@dataclass(frozen=True, slots=True) +class ScipyLUFactorization: + """Reusable LU factorization for a geometry-dependent dense system.""" + + lu: np.ndarray + pivots: np.ndarray + + def solve(self, rhs: np.ndarray) -> np.ndarray: + return np.asarray( + _scipy_linalg().lu_solve( + (self.lu, self.pivots), + np.asarray(rhs, dtype=float), + check_finite=False, + ), + dtype=float, + ) + + +def _factorize_scipy(matrix: np.ndarray) -> ScipyLUFactorization: + lu, pivots = _scipy_linalg().lu_factor( + np.asarray(matrix, dtype=float), + check_finite=False, + ) + return ScipyLUFactorization(np.asarray(lu, dtype=float), np.asarray(pivots, dtype=int)) + + +def _factorize_gauge_fixed(matrix: np.ndarray) -> ScipyLUFactorization: + constraint = np.ones(matrix.shape[0], dtype=float) + augmented = np.block( + [ + [matrix, constraint[:, np.newaxis]], + [constraint[np.newaxis, :], np.zeros((1, 1), dtype=float)], + ] + ) + return _factorize_scipy(augmented) + + +def __getattr__(name: str) -> Any: + if name == "_SCIPY_LINALG": + return _scipy_linalg_cache + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +def _scipy_linalg() -> Any: + global _scipy_linalg_cache + if _scipy_linalg_cache is None: + from scipy import linalg + + _scipy_linalg_cache = linalg + return _scipy_linalg_cache + + +def _demag_dense_max_points() -> int | None: + raw_value = os.environ.get(DEMAG_DENSE_MAX_POINTS_ENV) + if raw_value is None: + return None + try: + value = int(raw_value) + except ValueError as error: + raise ValueError( + f"{DEMAG_DENSE_MAX_POINTS_ENV} must be a positive integer, got {raw_value!r}." + ) from error + if value < 1: + raise ValueError( + f"{DEMAG_DENSE_MAX_POINTS_ENV} must be a positive integer, got {raw_value!r}." + ) + return value + + +def _validate_dense_demag_size(point_count: int) -> None: + maximum = _demag_dense_max_points() + if maximum is None or point_count <= maximum: + return + estimated_gib = point_count * point_count * 8 / (1024**3) + raise MemoryError( + "Dense demag FEM assembly refused a mesh with " + f"{point_count} points (one dense matrix is approximately {estimated_gib:.2f} GiB). " + f"The current safety limit is {maximum}; change {DEMAG_DENSE_MAX_POINTS_ENV} only " + "when the required memory is known to be available." + ) + + +def _scipy_sparse_modules() -> tuple[Any, Any]: + from scipy import sparse + from scipy.sparse import linalg as sparse_linalg + + return sparse, sparse_linalg + + +def _is_sparse_matrix(matrix: Any) -> bool: + sparse, _sparse_linalg = _scipy_sparse_modules() + return bool(sparse.issparse(matrix)) + + +def _solve_sparse_gauge_fixed( + matrix: Any, + rhs: np.ndarray, + *, + record_timing: Callable[[str, float], None] | None = None, + timing_prefix: str = "linear_solver:gauge", +) -> np.ndarray: + _sparse, sparse_linalg = _scipy_sparse_modules() + started = time.perf_counter() + point_count = int(matrix.shape[0]) + diagonal = np.asarray(matrix.diagonal(), dtype=float) + matrix_scale = float(np.max(np.abs(diagonal))) if diagonal.size else 1.0 + if matrix_scale <= np.finfo(float).tiny: + return np.zeros_like(rhs, dtype=float) + + scaled_matrix = matrix / matrix_scale + scaled_rhs = np.asarray(rhs, dtype=float) / matrix_scale + + def apply_constrained_matrix(vector: np.ndarray) -> np.ndarray: + return np.asarray(scaled_matrix @ vector, dtype=float) + np.mean(vector) + + constrained = sparse_linalg.LinearOperator( + matrix.shape, + matvec=apply_constrained_matrix, + dtype=np.dtype(float), + ) + inverse_diagonal = 1.0 / (diagonal / matrix_scale + 1.0 / max(point_count, 1)) + + def apply_preconditioner(vector: np.ndarray) -> np.ndarray: + return inverse_diagonal * vector + + preconditioner = sparse_linalg.LinearOperator( + matrix.shape, + matvec=apply_preconditioner, + dtype=np.dtype(float), + ) + solution, status = sparse_linalg.cg( + constrained, + scaled_rhs, + rtol=1.0e-12, + atol=1.0e-14, + maxiter=max(1000, 10 * point_count), + M=preconditioner, + ) + if int(status) != 0: + result = sparse_linalg.lsmr( + constrained, + scaled_rhs, + atol=1.0e-12, + btol=1.0e-12, + conlim=0.0, + maxiter=max(1000, 10 * point_count), + ) + solution = result[0] + if int(result[1]) not in {0, 1, 2}: + raise np.linalg.LinAlgError( + "Sparse gauge solve did not converge " + f"(CG status {int(status)}, LSMR status {int(result[1])})." + ) + solution = np.asarray(solution, dtype=float) + solution -= np.mean(solution) + if record_timing is not None: + record_timing(f"{timing_prefix}:sparse_iterative", time.perf_counter() - started) + return solution + + +def _solve_sparse_linear_system( + matrix: Any, + rhs: np.ndarray, + *, + record_timing: Callable[[str, float], None] | None = None, + timing_prefix: str = "linear_solver", +) -> np.ndarray: + _sparse, sparse_linalg = _scipy_sparse_modules() + started = time.perf_counter() + diagonal = np.asarray(matrix.diagonal(), dtype=float) + matrix_scale = float(np.max(np.abs(diagonal))) if diagonal.size else 1.0 + if matrix_scale <= np.finfo(float).tiny: + return np.zeros_like(rhs, dtype=float) + scaled_matrix = matrix / matrix_scale + scaled_rhs = np.asarray(rhs, dtype=float) / matrix_scale + scaled_diagonal = diagonal / matrix_scale + inverse_diagonal = np.ones_like(scaled_diagonal) + nonzero = np.abs(scaled_diagonal) > np.finfo(float).tiny + inverse_diagonal[nonzero] = 1.0 / scaled_diagonal[nonzero] + + def apply_preconditioner(vector: np.ndarray) -> np.ndarray: + return inverse_diagonal * vector + + preconditioner = sparse_linalg.LinearOperator( + matrix.shape, + matvec=apply_preconditioner, + dtype=np.dtype(float), + ) + solution, status = sparse_linalg.cg( + scaled_matrix, + scaled_rhs, + rtol=1.0e-12, + atol=0.0, + maxiter=max(1000, 10 * int(matrix.shape[0])), + M=preconditioner, + ) + if int(status) != 0: + fallback = sparse_linalg.lsmr( + scaled_matrix, + scaled_rhs, + atol=1.0e-12, + btol=1.0e-12, + maxiter=max(1000, 10 * int(matrix.shape[0])), + ) + solution = fallback[0] + if int(fallback[1]) not in {0, 1, 2}: + raise np.linalg.LinAlgError( + "Sparse linear solve did not converge " + f"(CG status {int(status)}, LSMR status {int(fallback[1])})." + ) + if record_timing is not None: + record_timing(f"{timing_prefix}:sparse_iterative", time.perf_counter() - started) + return np.asarray(solution, dtype=float) + + +def _validated_least_squares_solution( + matrix: np.ndarray, + rhs: np.ndarray, + solution: np.ndarray, +) -> np.ndarray: + residual = matrix @ solution - rhs + rhs_norm = float(np.linalg.norm(rhs)) + residual_norm = float(np.linalg.norm(residual)) + relative_residual = residual_norm / rhs_norm if rhs_norm else residual_norm + if not np.isfinite(relative_residual) or ( + relative_residual > LEAST_SQUARES_RELATIVE_RESIDUAL_TOLERANCE + ): + raise np.linalg.LinAlgError( + "Singular demag system has no accurate least-squares solution: " + f"relative residual={relative_residual:.3e}." + ) + LOGGER.warning( + "Using a least-squares solution for a singular demag system (relative residual %.3e).", + relative_residual, + ) + return solution + + +def _max_abs(values: np.ndarray) -> float: + return float(np.max(np.abs(values))) if values.size else 0.0 + + +def _l2_norm(values: np.ndarray) -> float: + return float(np.linalg.norm(values.ravel())) if values.size else 0.0 + + +def _safe_relative_residual(numerator: np.ndarray, scale: np.ndarray) -> float: + numerator_l2 = _l2_norm(numerator) + scale_l2 = _l2_norm(scale) + if scale_l2 == 0.0: + return 0.0 if numerator_l2 == 0.0 else float("inf") + return float(numerator_l2 / scale_l2) + + +def _dense_condition_number(matrix: np.ndarray) -> float: + if matrix.size == 0: + return 0.0 + return float(np.linalg.cond(np.asarray(matrix, dtype=float))) + + +def _solve_gauge_fixed( + matrix: np.ndarray, + rhs: np.ndarray, + *, + record_timing: Callable[[str, float], None] | None = None, + timing_prefix: str = "linear_solver:gauge", +) -> np.ndarray: + augmented, augmented_rhs = _gauge_augmented_system(matrix, rhs, record_timing, timing_prefix) + return _solve_dense_system( + augmented, + augmented_rhs, + record_timing=record_timing, + timing_prefix=timing_prefix, + )[:-1] + + +def _gauge_augmented_system( + matrix: np.ndarray, + rhs: np.ndarray, + record_timing: Callable[[str, float], None] | None, + timing_prefix: str, +) -> tuple[np.ndarray, np.ndarray]: + started = time.perf_counter() + try: + constraint = np.ones(len(rhs), dtype=float) + augmented = np.block( + [ + [matrix, constraint[:, np.newaxis]], + [constraint[np.newaxis, :], np.zeros((1, 1), dtype=float)], + ] + ) + augmented_rhs = np.concatenate([rhs, [0.0]]) + finally: + if record_timing is not None: + record_timing(f"{timing_prefix}:setup", time.perf_counter() - started) + return augmented, augmented_rhs + + +def _solve_dense_system( + matrix: np.ndarray, + rhs: np.ndarray, + *, + record_timing: Callable[[str, float], None] | None, + timing_prefix: str, +) -> np.ndarray: + backend = _selected_demag_linear_solver_backend(int(matrix.shape[0])) + if backend == "scipy": + return _solve_dense_scipy(matrix, rhs, record_timing, timing_prefix) + return _solve_dense_numpy(matrix, rhs, record_timing, timing_prefix) + + +def _solve_dense_scipy( + matrix: np.ndarray, + rhs: np.ndarray, + record_timing: Callable[[str, float], None] | None, + timing_prefix: str, +) -> np.ndarray: + started = time.perf_counter() + linalg = _scipy_linalg() + if record_timing is not None: + record_timing(f"{timing_prefix}:scipy_import", time.perf_counter() - started) + started = time.perf_counter() + try: + result = linalg.solve(matrix, rhs, assume_a="sym", check_finite=False) + if record_timing is not None: + record_timing(f"{timing_prefix}:scipy_solve", time.perf_counter() - started) + return result + except np.linalg.LinAlgError: + if record_timing is not None: + record_timing( + f"{timing_prefix}:scipy_solve_failed", + time.perf_counter() - started, + ) + started = time.perf_counter() + result = _validated_least_squares_solution( + matrix, + rhs, + linalg.lstsq(matrix, rhs, check_finite=False)[0], + ) + if record_timing is not None: + record_timing(f"{timing_prefix}:scipy_lstsq", time.perf_counter() - started) + return result + + +def _solve_linear_system( + matrix: np.ndarray, + rhs: np.ndarray, + *, + record_timing: Callable[[str, float], None] | None = None, + timing_prefix: str = "linear_solver", +) -> np.ndarray: + return _solve_dense_system( + matrix, + rhs, + record_timing=record_timing, + timing_prefix=timing_prefix, + ) + + +def _solve_dense_numpy( + matrix: np.ndarray, + rhs: np.ndarray, + record_timing: Callable[[str, float], None] | None, + timing_prefix: str, +) -> np.ndarray: + started = time.perf_counter() + try: + result = np.linalg.solve(matrix, rhs) + if record_timing is not None: + record_timing(f"{timing_prefix}:numpy_solve", time.perf_counter() - started) + return result + except np.linalg.LinAlgError: + if record_timing is not None: + record_timing( + f"{timing_prefix}:numpy_solve_failed", + time.perf_counter() - started, + ) + started = time.perf_counter() + result = _validated_least_squares_solution( + matrix, + rhs, + np.linalg.lstsq(matrix, rhs, rcond=None)[0], + ) + if record_timing is not None: + record_timing(f"{timing_prefix}:numpy_lstsq", time.perf_counter() - started) + return result + + +def _demag_solve_diagnostics( + stiffness: Any, + divergence: np.ndarray, + boundary_nodes: np.ndarray, + phi1: np.ndarray, + phi2_boundary: np.ndarray, + phi2: np.ndarray, + phi: np.ndarray, +) -> dict[str, float | int]: + phi1_residual = np.asarray(stiffness @ phi1, dtype=float) - divergence + phi1_centered_residual = ( + phi1_residual - np.mean(phi1_residual) if phi1_residual.size else phi1_residual + ) + divergence_centered = divergence - np.mean(divergence) if divergence.size else divergence + + boundary_mask = np.zeros(len(phi), dtype=bool) + boundary_mask[boundary_nodes] = True + interior_nodes = np.flatnonzero(~boundary_mask) + interior_rows = ( + stiffness[interior_nodes] + if _is_sparse_matrix(stiffness) + else stiffness[np.ix_(interior_nodes, np.arange(len(phi)))] + ) + dirichlet_residual = ( + np.asarray(interior_rows @ phi2, dtype=float) + if interior_nodes.size + else np.empty(0, dtype=float) + ) + dirichlet_scale = ( + np.asarray(np.abs(interior_rows) @ np.abs(phi2), dtype=float) + if interior_nodes.size + else np.empty(0, dtype=float) + ) + + diagnostics: dict[str, float | int] = { + "node_count": int(len(phi)), + "boundary_node_count": int(len(boundary_nodes)), + "gauge_augmented_size": int(len(divergence) + 1), + "divergence_sum_abs": abs(float(np.sum(divergence))), + "phi1_gauge_sum_abs": abs(float(np.sum(phi1))), + "phi1_centered_residual_max_abs": _max_abs(phi1_centered_residual), + "phi1_centered_residual_l2": _l2_norm(phi1_centered_residual), + "phi1_centered_residual_relative_l2": _safe_relative_residual( + phi1_centered_residual, + divergence_centered, + ), + "dirichlet_interior_node_count": int(interior_nodes.size), + "dirichlet_residual_max_abs": _max_abs(dirichlet_residual), + "dirichlet_residual_l2": _l2_norm(dirichlet_residual), + "dirichlet_residual_relative_l2": _safe_relative_residual( + dirichlet_residual, + dirichlet_scale, + ), + "phi1_max_abs": _max_abs(phi1), + "phi2_boundary_max_abs": _max_abs(phi2_boundary), + "phi2_max_abs": _max_abs(phi2), + "phi_max_abs": _max_abs(phi), + } + if _demag_solve_condition_diagnostics_enabled(): + if _is_sparse_matrix(stiffness): + diagnostics["condition_numbers_skipped_for_sparse_storage"] = 1 + else: + constraint = np.ones(len(divergence), dtype=float) + augmented_stiffness = np.block( + [ + [stiffness, constraint[:, np.newaxis]], + [constraint[np.newaxis, :], np.zeros((1, 1), dtype=float)], + ] + ) + diagnostics["gauge_augmented_condition_number"] = _dense_condition_number( + augmented_stiffness, + ) + diagnostics["stiffness_condition_number"] = _dense_condition_number(stiffness) + diagnostics["dirichlet_interior_condition_number"] = ( + _dense_condition_number(stiffness[np.ix_(interior_nodes, interior_nodes)]) + if interior_nodes.size + else 0.0 + ) + return diagnostics diff --git a/src/nmag/dynamics/__init__.py b/src/nmag/dynamics/__init__.py new file mode 100644 index 0000000..9931b81 --- /dev/null +++ b/src/nmag/dynamics/__init__.py @@ -0,0 +1,255 @@ +from __future__ import annotations + +from collections import deque +from dataclasses import dataclass, field +from math import isfinite +from typing import cast + +import numpy as np +from numpy.typing import NDArray + +FloatArray = NDArray[np.float64] + +DEFAULT_STOPPING_DM_DT = float(np.deg2rad(1.0) * 1.0e9) +DOP853_EXCHANGE_RESOLUTION_RADIUS = 3.0 + + +@dataclass(frozen=True, slots=True) +class NodalMaterialCoefficients: + exchange_prefactor: FloatArray + precession: FloatArray + damping: FloatArray + normalisation: FloatArray + stt_adiabatic: FloatArray + stt_nonadiabatic: FloatArray + + +@dataclass(slots=True) +class IntegratorConfig: + """Snapshot of effective public DOP853 controls. + + Attributes: + relative_tolerance: Relative local-error tolerance. + absolute_tolerance: Absolute local-error tolerance. + maximum_step_seconds: Configured upper step bound in seconds. + exact_tstop: Whether fixed-time requests normally stop exactly. + """ + + relative_tolerance: float = 1.0e-6 + absolute_tolerance: float = 1.0e-6 + maximum_step_seconds: float = 1.0e-12 + exact_tstop: bool = True + + def validate(self) -> None: + for name, value in ( + ("relative_tolerance", self.relative_tolerance), + ("absolute_tolerance", self.absolute_tolerance), + ("maximum_step_seconds", self.maximum_step_seconds), + ): + if not np.isfinite(value) or value <= 0.0: + raise ValueError(f"{name} must be a positive finite number, got {value!r}.") + + +@dataclass(frozen=True, slots=True) +class IntegratorStats: + """Measurements from the most recent integration operation. + + Attributes: + accepted_steps: Accepted adaptive steps. + rhs_evaluations: Landau-Lifshitz-Gilbert right-hand-side evaluations. + rejected_steps: Rejected steps when the backend reports them. + failed: Whether the operation raised a numerical failure. + status: Backend status at completion. + last_step_seconds: Size of the final accepted step. + simulated_seconds: Physical time advanced by the operation. + wall_seconds: Elapsed wall-clock time. + jacobian_vector_evaluations: Optional implicit-backend measurement. + nonlinear_iterations: Optional implicit-backend Newton iterations. + nonlinear_failures: Optional implicit-backend failure count. + linear_solver_setups: Optional implicit-backend setup count. + """ + + accepted_steps: int = 0 + rhs_evaluations: int = 0 + rejected_steps: int | None = None + failed: bool = False + status: str = "not_started" + last_step_seconds: float = 0.0 + simulated_seconds: float = 0.0 + wall_seconds: float = 0.0 + jacobian_vector_evaluations: int | None = None + nonlinear_iterations: int | None = None + nonlinear_failures: int | None = None + linear_solver_setups: int | None = None + + +@dataclass(slots=True) +class ConvergenceTracker: + required_checks: int = 2 + statistics_window: int = 5 + _remaining_checks: int = field(init=False) + _history: deque[tuple[int, float, float, float | None]] = field( + default_factory=lambda: deque(maxlen=50), + init=False, + ) + _previous_max_dm_dt: float | None = field(default=None, init=False) + _window_changes: list[float] = field(default_factory=lambda: list[float](), init=False) + + def __post_init__(self) -> None: + if self.required_checks < 1: + raise ValueError("required_checks must be at least one.") + if self.statistics_window < 1: + raise ValueError("statistics_window must be at least one.") + self._remaining_checks = self.required_checks + + def reset(self) -> None: + self._remaining_checks = self.required_checks + self._previous_max_dm_dt = None + self._window_changes.clear() + + def check(self, step: int, max_dm_dt: float, stopping_dm_dt: float) -> bool: + if max_dm_dt < stopping_dm_dt: + self._remaining_checks -= 1 + if self._remaining_checks == 0: + self.reset() + return True + return False + + self._remaining_checks = self.required_checks + quality = self._quality(max_dm_dt) + self._history.append((step, max_dm_dt, stopping_dm_dt, quality)) + return False + + def _quality(self, max_dm_dt: float) -> float | None: + if self._previous_max_dm_dt is None: + self._previous_max_dm_dt = max_dm_dt + return None + + self._window_changes.append(max_dm_dt - self._previous_max_dm_dt) + self._previous_max_dm_dt = max_dm_dt + if len(self._window_changes) < self.statistics_window: + return None + + signed = abs(sum(self._window_changes)) + absolute = sum(abs(change) for change in self._window_changes) + self._window_changes.clear() + return signed / absolute if absolute > 0.0 else None + + def get_log(self) -> str: + lines = ["# Convergence log", "# step, max dm/dt, stopping dm/dt, quality"] + lines.extend( + f"{step} {max_dm_dt} {stopping_dm_dt} {quality}" + for step, max_dm_dt, stopping_dm_dt, quality in self._history + ) + return "\n".join(lines) + "\n" + + def checkpoint_state(self) -> dict[str, object]: + """Return the convergence cadence state without exposing mutable internals.""" + return { + "required_checks": self.required_checks, + "statistics_window": self.statistics_window, + "remaining_checks": self._remaining_checks, + "history": [list(entry) for entry in self._history], + "previous_max_dm_dt": self._previous_max_dm_dt, + "window_changes": list(self._window_changes), + } + + @classmethod + def from_checkpoint_state(cls, state: dict[str, object]) -> ConvergenceTracker: + cls._validate_checkpoint_keys(state) + required_checks, statistics_window, remaining_checks = cls._checkpoint_counters(state) + tracker = cls( + required_checks=required_checks, + statistics_window=statistics_window, + ) + if not 1 <= remaining_checks <= tracker.required_checks: + raise ValueError("Checkpoint remaining convergence checks are invalid.") + tracker._remaining_checks = remaining_checks + tracker._history = cls._checkpoint_history(state["history"]) + tracker._previous_max_dm_dt = cls._optional_finite_checkpoint_value( + state["previous_max_dm_dt"], + "Checkpoint previous convergence rate must be finite or null.", + ) + tracker._window_changes = cls._finite_checkpoint_values( + state["window_changes"], + "Checkpoint convergence window changes must be finite numbers.", + ) + return tracker + + @staticmethod + def _validate_checkpoint_keys(state: dict[str, object]) -> None: + required = { + "required_checks", + "statistics_window", + "remaining_checks", + "history", + "previous_max_dm_dt", + "window_changes", + } + missing = sorted(required.difference(state)) + if missing: + raise ValueError(f"Checkpoint convergence state is missing {', '.join(missing)}.") + + @staticmethod + def _checkpoint_counters(state: dict[str, object]) -> tuple[int, int, int]: + values = tuple( + state[name] for name in ("required_checks", "statistics_window", "remaining_checks") + ) + if any(isinstance(value, bool) or not isinstance(value, int) for value in values): + raise ValueError("Checkpoint convergence counters must be integers.") + return cast(tuple[int, int, int], values) + + @classmethod + def _checkpoint_history(cls, value: object) -> deque[tuple[int, float, float, float | None]]: + if not isinstance(value, list): + raise ValueError("Checkpoint convergence history must be a list.") + history: deque[tuple[int, float, float, float | None]] = deque(maxlen=50) + for raw_entry in cast(list[object], value): + history.append(cls._checkpoint_history_entry(raw_entry)) + return history + + @classmethod + def _checkpoint_history_entry(cls, value: object) -> tuple[int, float, float, float | None]: + if not isinstance(value, list): + raise ValueError("Checkpoint convergence history entries must have four values.") + entry = cast(list[object], value) + if len(entry) != 4: + raise ValueError("Checkpoint convergence history entries must have four values.") + step, maximum, stopping, quality = entry + if isinstance(step, bool) or not isinstance(step, int): + raise ValueError("Checkpoint convergence history steps must be integers.") + rates = cls._finite_checkpoint_values( + [maximum, stopping], + "Checkpoint convergence history rates must be finite.", + ) + return ( + step, + rates[0], + rates[1], + cls._optional_finite_checkpoint_value( + quality, + "Checkpoint convergence history quality must be finite or null.", + ), + ) + + @staticmethod + def _optional_finite_checkpoint_value(value: object, message: str) -> float | None: + if value is None: + return None + values = ConvergenceTracker._finite_checkpoint_values([value], message) + return values[0] + + @staticmethod + def _finite_checkpoint_values(value: object, message: str) -> list[float]: + if not isinstance(value, list): + raise ValueError(message) + parsed: list[float] = [] + for raw_value in cast(list[object], value): + if ( + isinstance(raw_value, bool) + or not isinstance(raw_value, (int, float)) + or not isfinite(float(raw_value)) + ): + raise ValueError(message) + parsed.append(float(raw_value)) + return parsed diff --git a/src/nmag/output.py b/src/nmag/output.py new file mode 100644 index 0000000..eb9fcde --- /dev/null +++ b/src/nmag/output.py @@ -0,0 +1,27 @@ +"""Output-file lifecycle helpers shared by simulation implementations.""" + +from __future__ import annotations + +from collections.abc import Iterable +from pathlib import Path + +from .config import OutputPolicy + + +def prepare_output_files(paths: Iterable[Path], policy: OutputPolicy) -> None: + """Create parents and apply an explicit output lifecycle policy.""" + + destinations = tuple(paths) + for destination in destinations: + destination.parent.mkdir(parents=True, exist_ok=True) + + existing = tuple(destination for destination in destinations if destination.exists()) + if policy == "error" and existing: + rendered = ", ".join(str(destination) for destination in existing) + raise FileExistsError( + f"Output file(s) already exist: {rendered}. " + "Use NmagConfig(output_policy='replace' or 'append') explicitly." + ) + if policy == "replace": + for destination in existing: + destination.unlink() diff --git a/src/nmag/parallel.py b/src/nmag/parallel.py new file mode 100644 index 0000000..c3cf6b1 --- /dev/null +++ b/src/nmag/parallel.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass + +from .backends import _load_rust_accelerator, _rust_accelerator_available + + +@dataclass(frozen=True, slots=True) +class ParallelRuntimeInfo: + """Shared-memory execution settings visible to the current process. + + Attributes: + logical_cpus: Logical processors reported by the operating system. + rust_available: Whether a compatible native extension is installed. + rust_worker_threads: Rayon worker count when Rust is available. + rust_parallel_min_items: Operation-size crossover for parallel kernels. + """ + + logical_cpus: int + rust_available: bool + rust_worker_threads: int | None + rust_parallel_min_items: int | None + + +def parallel_runtime_info() -> ParallelRuntimeInfo: + """Return effective Rust worker and crossover settings without changing them.""" + logical_cpus = os.cpu_count() or 1 + if not _rust_accelerator_available(): + return ParallelRuntimeInfo(logical_cpus, False, None, None) + + accelerator = _load_rust_accelerator("NmagConfig.accelerator") + try: + worker_threads, minimum_items = accelerator.parallel_runtime_info() + except AttributeError as exc: + raise RuntimeError( + "The installed nmag_accel does not expose parallel runtime metadata. " + "Rebuild it with ./scripts/build-accelerator.sh." + ) from exc + return ParallelRuntimeInfo( + logical_cpus=logical_cpus, + rust_available=True, + rust_worker_threads=int(worker_threads), + rust_parallel_min_items=int(minimum_items), + ) diff --git a/src/nmag/py.typed b/src/nmag/py.typed new file mode 100644 index 0000000..534be2b --- /dev/null +++ b/src/nmag/py.typed @@ -0,0 +1 @@ +# Marker for PEP 561 inline type information. diff --git a/src/nmag/resources.py b/src/nmag/resources.py new file mode 100644 index 0000000..63417df --- /dev/null +++ b/src/nmag/resources.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import os +from pathlib import Path + + +def _read_integer(path: Path) -> int | None: + try: + raw_value = path.read_text(encoding="ascii").strip() + return None if raw_value == "max" else int(raw_value) + except (OSError, ValueError): + return None + + +def available_memory_bytes() -> int | None: + """Return the tightest host or cgroup estimate of currently available memory.""" + candidates: list[int] = [] + try: + page_size = int(os.sysconf("SC_PAGE_SIZE")) + available_pages = int(os.sysconf("SC_AVPHYS_PAGES")) + if page_size > 0 and available_pages > 0: + candidates.append(page_size * available_pages) + except (KeyError, OSError, ValueError): + pass + + for limit_path, usage_path in ( + (Path("/sys/fs/cgroup/memory.max"), Path("/sys/fs/cgroup/memory.current")), + ( + Path("/sys/fs/cgroup/memory/memory.limit_in_bytes"), + Path("/sys/fs/cgroup/memory/memory.usage_in_bytes"), + ), + ): + limit = _read_integer(limit_path) + usage = _read_integer(usage_path) + if limit is not None and usage is not None and limit > usage: + candidates.append(limit - usage) + + return min(candidates) if candidates else None diff --git a/src/nmag/simulation/__init__.py b/src/nmag/simulation/__init__.py new file mode 100644 index 0000000..558cde6 --- /dev/null +++ b/src/nmag/simulation/__init__.py @@ -0,0 +1,519 @@ +from __future__ import annotations + +import time +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any, cast + +import h5py +import numpy as np + +import nmesh +from si.physical import SI +from simulation.clock import SimulationClock + +from .. import backends as _backend_helpers +from .. import demag as _demag_helpers +from ..config import NmagConfig +from ..demag.bem_operator import BemOperatorStats +from ..demag.linear import ScipyLUFactorization +from ..dynamics import NodalMaterialCoefficients +from ..output import prepare_output_files +from . import fields as _field_helpers +from . import support as _support_helpers +from .anisotropy import SimulationAnisotropyMixin +from .anisotropy.materials import NodalAnisotropyGroup +from .demag.solver import SimulationDemagMixin +from .dynamics import SimulationDynamicsMixin +from .fields import SimulationFieldMixin +from .mesh import SimulationMeshMixin +from .restart import SimulationRestartMixin +from .support import ( + ScalarFieldInput, + Vector, + VectorField, + VectorFieldInput, + _as_vector3, + _flatten_materials, + _normalise_m, + _scalar_nodal_field, + _si_unit, + _vector_nodal_field, +) + +if TYPE_CHECKING: + from simulation.data_writer import DataWriter + +_data_writer_class_cache: type[DataWriter] | None = None + + +def __getattr__(name: str) -> Any: + """Resolve compatibility exports moved to focused helper modules.""" + try: + return getattr(_support_helpers, name) + except AttributeError: + pass + try: + return getattr(_demag_helpers, name) + except AttributeError: + pass + try: + return getattr(_backend_helpers, name) + except AttributeError: + pass + try: + return getattr(_field_helpers, name) + except AttributeError: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from None + + +def _data_writer_class() -> type[DataWriter]: + global _data_writer_class_cache + if _data_writer_class_cache is None: + from simulation.data_writer import DataWriter + + _data_writer_class_cache = DataWriter + return _data_writer_class_cache + + +class Simulation( + SimulationMeshMixin, + SimulationDemagMixin, + SimulationAnisotropyMixin, + SimulationFieldMixin, + SimulationDynamicsMixin, + SimulationRestartMixin, +): + """Own a finite-element micromagnetic simulation and its output lifecycle. + + A simulation is configured once, then receives a tetrahedral mesh, material + mapping, magnetization, and applied fields. Field calculations are lazy; + saving, probing, time advancement, or relaxation computes what is needed. + + Args: + name: Base name for NDT, HDF5, and default checkpoint files. When + omitted, use ``config.default_name``. + phi_BEM: Reserved legacy HLib option. Non-null values are unsupported. + periodic_bc: Reserved legacy periodic-boundary option. Non-null values + are unsupported. + do_demag: Calculate demagnetization fields and energies when true. + do_sl_stt: Reserved for Slonczewski spin-transfer torque, which is not + implemented. Zhang-Li torque is configured through material + parameters and :meth:`set_current_density`. + config: Immutable output, acceleration, storage, and integrator policy. + Environment defaults are read only when this argument is omitted. + + Raises: + NotImplementedError: If an unsupported legacy constructor option is + requested. + """ + + def __init__( + self, + name: str | None = None, + phi_BEM: Any | None = None, + periodic_bc: Any | None = None, + do_demag: bool = True, + do_sl_stt: bool = False, + config: NmagConfig | None = None, + ) -> None: + if phi_BEM is not None: + raise NotImplementedError("phi_BEM/HLib support is not ported yet.") + if periodic_bc is not None: + raise NotImplementedError("Periodic boundary conditions are not ported yet.") + if do_sl_stt: + raise NotImplementedError( + "Spin-transfer torque using the Slonczewski model is not ported yet; " + "current-density (Zhang-Li) torque is supported." + ) + self.config = NmagConfig.from_environment() if config is None else config + self.name = name or self.config.default_name + self.do_demag = do_demag + self.do_sl_stt = do_sl_stt + self.periodic_bc = periodic_bc + self.clock = SimulationClock() + data_writer_class = _data_writer_class() + ndt_filename = self.config.output_directory / f"{self.name}_dat.ndt" + h5_filename = self.config.output_directory / f"{self.name}_dat.h5" + prepare_output_files((ndt_filename, h5_filename), self.config.output_policy) + self.writer = data_writer_class( + ndt_filename=ndt_filename, + h5_filename=h5_filename, + append=self.config.output_policy == "append", + ) + self.last_save_timings_seconds: dict[str, float] = {} + self.last_spatial_save_timings_seconds: dict[str, float] = {} + self.last_probe_timings_seconds: dict[str, float] = {} + self.last_subfield_average_timings_seconds: dict[str, float] = {} + self.last_maxangle_timings_seconds: dict[str, float] = {} + self.last_demag_solve_diagnostics: dict[str, float | int] = {} + self._last_bem_operator_stats: BemOperatorStats | None = None + self.last_demag_internal_vectors: dict[str, np.ndarray] = {} + self._active_subfield_array_timings: dict[str, float] | None = None + + self.mesh: Any | None = None + self.mesh_unit_length: SI | None = None + self.region_name_list: list[str] = [] + self.region_name_of_id: dict[int, str] = {} + self.region_id_of_name: dict[str, int] = {} + self.mats_of_region_name: dict[str, list[Any]] = {} + self.mat_of_mat_name: dict[str, Any] = {} + self.materials: list[Any] = [] + self._fields: dict[str, Any] = {} + self._demag_cache_token: tuple[int, int] | None = None + self._demag_dipoles: tuple[np.ndarray, np.ndarray, float] | None = None + self._demag_nodal_cache: np.ndarray | None = None + self._demag_cell_cache_token: tuple[int, int] | None = None + self._demag_cell_field_cache: tuple[np.ndarray, np.ndarray] | None = None + self._demag_geometry_cache_token: int | None = None + self._demag_boundary_faces_cache: list[tuple[int, tuple[int, int, int]]] | None = None + self._demag_bem_cache: tuple[str, np.ndarray, Any, BemOperatorStats] | None = None + self._demag_fem_geometry_cache: tuple[str, Any, np.ndarray, np.ndarray] | None = None + self._demag_gauge_factorization_cache: ScipyLUFactorization | None = None + self._demag_dirichlet_factorization_cache: ( + tuple[np.ndarray, np.ndarray, ScipyLUFactorization] | None + ) = None + self._demag_ms_values_cache: tuple[int, np.ndarray] | None = None + self._demag_volume_charge_scales_cache: tuple[int, np.ndarray] | None = None + self._nodal_ms_values_cache: tuple[int, np.ndarray] | None = None + self._demag_aux_cache_token: tuple[int, int] | None = None + self._demag_phi_cache: np.ndarray | None = None + self._demag_rho_cache: np.ndarray | None = None + self._demag_volumes_cache: np.ndarray | None = None + self._mesh_points_cache: tuple[int, np.ndarray] | None = None + self._mesh_bounds_cache: tuple[int, np.ndarray, np.ndarray, np.ndarray] | None = None + self._mesh_edge_cache: tuple[int, np.ndarray] | None = None + self._simplex_volume_cache: tuple[int, np.ndarray, np.ndarray] | None = None + self._volume_average_node_weights_cache: tuple[int, int, np.ndarray] | None = None + self._incident_cell_volume_sums_cache: tuple[int, int, int, np.ndarray] | None = None + self._exchange_cache_token: tuple[int, int] | None = None + self._exchange_nodal_cache: np.ndarray | None = None + self._exchange_spectral_bound_cache: tuple[int, float] | None = None + self._nodal_material_coefficients_cache: tuple[int, NodalMaterialCoefficients] | None = None + self._nodal_anisotropy_groups_cache: ( + tuple[int, tuple[NodalAnisotropyGroup, ...]] | None + ) = None + self._anisotropy_fields_cache: tuple[ + tuple[int, int], + np.ndarray, + np.ndarray, + ] | None = None + self._llg_affine_operator_cache: tuple[int, np.ndarray] | None = None + self._probe_geometry_cache_token: int | None = None + self._probe_tetrahedral_cache: ( + tuple[ + np.ndarray, + np.ndarray, + np.ndarray, + np.ndarray, + np.ndarray, + ] + | None + ) = None + self._subfield_array_cache: dict[str, np.ndarray] | None = None + self._subfield_average_cache: dict[tuple[str, str | None], object] | None = None + self._initialise_dynamics() + + @property + def id(self) -> int: + return self.clock.id + + @property + def stage(self) -> int: + return self.clock.stage + + @property + def step(self) -> int: + return self.clock.step + + @property + def stage_step(self) -> int: + return self.clock.stage_step + + @property + def time(self) -> SI: + return self.clock.time + + @property + def stage_time(self) -> SI: + return self.clock.stage_time + + @property + def real_time(self) -> SI: + return self.clock.real_time + + @property + def last_step_dt(self) -> SI: + return self.clock.last_step_dt_si + + @property + def last_bem_operator_stats(self) -> BemOperatorStats | None: + """Return diagnostics for the currently cached boundary operator.""" + + return self._last_bem_operator_stats + + def load_mesh( + self, + filename: str, + region_names_and_mag_mats: Sequence[tuple[str, Any]], + unit_length: SI, + do_reorder: bool = False, + manual_distribution: Any = None, + ) -> nmesh.Mesh: + """Load a mesh, scale it to metres, and map its regions to materials. + + Args: + filename: Legacy Nmesh or Meshio-supported mesh file. + region_names_and_mag_mats: ``(region_name, material)`` pairs in + ascending mesh-region order, starting at region 1. + unit_length: Physical length represented by one mesh coordinate + unit. + do_reorder: Request legacy node reordering. Reordering is currently + unsupported by the mesh backend. + manual_distribution: Reserved legacy distributed-mesh mapping. + + Returns: + The loaded and physically scaled mesh. + + Raises: + RuntimeError: If a mesh has already been loaded. + ValueError: If mesh regions and configured materials do not match. + NotImplementedError: If reordering or manual distribution is + requested by an unsupported backend. + """ + if self.mesh is not None: + raise RuntimeError("Mesh is already present.") + + self.mesh = nmesh.load( + filename, + reorder=do_reorder, + distribute=manual_distribution is None, + ) + self.mesh_unit_length = unit_length + + scale = unit_length.in_units_of(_si_unit("m")) + if scale != 1.0: + self.mesh.scale_node_positions(scale) + self._fields["pin"] = np.ones(len(self.mesh.points), dtype=float) + + if manual_distribution is not None: + self.mesh.set_vertex_distribution(manual_distribution) + + self.region_name_list = [name for name, _ in region_names_and_mag_mats] + self.region_name_of_id = { + index: name for index, name in enumerate(self.region_name_list, start=1) + } + self.region_id_of_name = {name: index for index, name in self.region_name_of_id.items()} + + mesh_region_ids = {int(region_id) for region_id in self.mesh.regions} + configured_region_ids = set(self.region_name_of_id) + if mesh_region_ids != configured_region_ids: + raise ValueError( + "Mesh material regions do not match the configured region list: " + f"mesh={sorted(mesh_region_ids)}, configured={sorted(configured_region_ids)}. " + "Materials must be supplied in ascending mesh-region order." + ) + + self.mats_of_region_name = {} + self.mat_of_mat_name = {} + for name, materials in region_names_and_mag_mats: + mats = cast(list[Any], materials) if isinstance(materials, list) else [materials] + self.mats_of_region_name[name] = mats + for material in mats: + self.mat_of_mat_name[material.name] = material + + self.materials = _flatten_materials(region_names_and_mag_mats) + self._invalidate_demag(clear_geometry=True) + self._invalidate_integrator() + return self.mesh + + def set_m(self, values: Vector | VectorField, subfieldname: str | None = None) -> None: + """Set and normalize the nodal magnetization direction field. + + Args: + values: One three-component vector, one vector per mesh node, or a + callable receiving a physical node position in metres. + subfieldname: Reserved legacy material-subfield selector. Omit it; + material-specific magnetization degrees of freedom are not + implemented. + + Raises: + RuntimeError: If no mesh has been loaded. + ValueError: If a vector is zero or the field shape is invalid. + NotImplementedError: If ``subfieldname`` is supplied. + """ + if subfieldname is not None: + raise NotImplementedError("Material-specific m subfields are not ported yet.") + + if callable(values): + if self.mesh is None: + raise RuntimeError("A mesh must be loaded before setting m from a function.") + self._fields["m"] = np.asarray( + [_normalise_m(values(point)) for point in self.mesh.points], + dtype=float, + ) + self._invalidate_demag() + self._invalidate_integrator() + return + + vector = _normalise_m(values) + point_count = len(self.mesh.points) if self.mesh is not None else 1 + self._fields["m"] = np.tile(np.asarray(vector, dtype=float), (point_count, 1)) + self._invalidate_demag() + self._invalidate_integrator() + + def set_H_ext(self, values: Vector, unit: SI | None = None) -> None: + """Set the homogeneous applied magnetic field. + + Args: + values: Three field components. When ``unit`` is omitted, values + may be SI quantities; otherwise they are interpreted in + ``unit``. + unit: Unit compatible with A/m. + """ + self._fields["H_ext"] = np.asarray(_as_vector3(values, unit=unit), dtype=float) + self._invalidate_demag() + self._invalidate_integrator() + + def set_pinning(self, values: ScalarFieldInput) -> None: + """Set the nodal multiplier for the complete magnetization derivative. + + Args: + values: Uniform scalar, one value per mesh node, or a callable + receiving physical node positions in metres. Zero pins a node, + one leaves it free, and other finite values scale its rate. + + Raises: + RuntimeError: If no mesh has been loaded. + ValueError: If values are non-finite or have the wrong shape. + """ + if self.mesh is None: + raise RuntimeError("A mesh must be loaded before setting pinning.") + self._fields["pin"] = _scalar_nodal_field( + values, + self.mesh.points, + fieldname="pinning", + ) + self._invalidate_integrator() + + def set_current_density( + self, + values: VectorFieldInput, + unit: SI | None = None, + ) -> None: + """Set the current-density field used by Zhang-Li spin-transfer torque. + + Args: + values: Uniform vector, nodal vectors, or a callable receiving + physical node positions in metres. + unit: Unit compatible with A/m². Required for ordinary numeric + vectors unless values already carry SI dimensions. + + Raises: + RuntimeError: If no mesh has been loaded. + ValueError: If the field shape or dimensions are invalid. + """ + if self.mesh is None: + raise RuntimeError("A mesh must be loaded before setting current density.") + self._fields["current_density"] = _vector_nodal_field( + values, + self.mesh.points, + unit=unit, + expected_unit=_si_unit("A/m^2"), + fieldname="current_density", + ) + self._invalidate_integrator() + + def save_data( + self, fields: str | list[str] | None = None, avoid_same_step: bool = False + ) -> None: + """Append averaged data and optionally save spatial fields. + + Args: + fields: ``None`` for averages only, ``"all"`` for every available + field, or a list of field names to store spatially in HDF5. + avoid_same_step: Skip the save when this integrator step was already + written. Primarily used by scheduled actions. + """ + if avoid_same_step and self.step == self.writer._last_saved_step: + return + self.clock.id += 1 + with self._subfield_array_cache_scope(), self._subfield_average_cache_scope(): + self.writer.save(self, fields=fields, avoid_same_step=avoid_same_step) + self.last_save_timings_seconds = dict(self.writer.last_save_timings_seconds) + + def save_spatial_fields(self, filename: str, fieldnames: list[str]) -> None: + """Write selected spatial fields and mesh points to an HDF5 file. + + Existing datasets with the same field names are replaced inside the + file; unrelated groups and datasets are retained. + + Args: + filename: Destination HDF5 path. + fieldnames: Available field names such as ``m`` or ``H_demag``. + """ + timings: dict[str, float] = {} + total_started = time.perf_counter() + try: + with h5py.File(filename, "a") as h5: + if self.mesh is not None: + started = time.perf_counter() + mesh_group = h5.require_group("mesh") + if "points" in mesh_group: + del mesh_group["points"] + mesh_group.create_dataset("points", data=np.asarray(self.mesh.points)) + timings["mesh_points"] = time.perf_counter() - started + + fields_group = h5.require_group("fields") + for fieldname in fieldnames: + field_started = time.perf_counter() + started = time.perf_counter() + data = self._subfield_array(fieldname) + timings[f"field:{fieldname}:compute"] = time.perf_counter() - started + + started = time.perf_counter() + if fieldname in fields_group: + del fields_group[fieldname] + fields_group.create_dataset(fieldname, data=data) + timings[f"field:{fieldname}:write"] = time.perf_counter() - started + timings[f"field:{fieldname}:total"] = time.perf_counter() - field_started + finally: + timings["total"] = time.perf_counter() - total_started + self.last_spatial_save_timings_seconds = dict(sorted(timings.items())) + + def reset_probe_timings(self) -> None: + """Reset passive probe timing counters used by parity/profile tools.""" + self.last_probe_timings_seconds = {} + + def probe_subfield( + self, + subfieldname: str, + pos: Sequence[float], + unit: SI | None = None, + ) -> Any: + """Probe a field at a physical position. + + Args: + subfieldname: Field to sample. + pos: Three coordinates in metres. + unit: Reserved compatibility argument. Returned values use the + field's standard SI representation. + + Returns: + Interpolated SI components, or ``None`` when the position is + outside the mesh. + """ + return self.probe_subfield_siv(subfieldname, pos, unit=unit) + + def save_mesh(self, filename: str) -> None: + """Save the currently loaded, physically scaled mesh. + + Args: + filename: Destination path. The suffix selects Nmesh HDF5, Nmesh + ASCII, or a Meshio-supported format. + + Raises: + RuntimeError: If no mesh has been loaded. + """ + if self.mesh is None: + raise RuntimeError("No mesh has been loaded.") + nmesh.save(self.mesh, filename) diff --git a/src/nmag/simulation/anisotropy/__init__.py b/src/nmag/simulation/anisotropy/__init__.py new file mode 100644 index 0000000..13d500a --- /dev/null +++ b/src/nmag/simulation/anisotropy/__init__.py @@ -0,0 +1,5 @@ +"""Material anisotropy fields for the public simulation.""" + +from .fields import SimulationAnisotropyMixin + +__all__ = ["SimulationAnisotropyMixin"] diff --git a/src/nmag/simulation/anisotropy/fields.py b/src/nmag/simulation/anisotropy/fields.py new file mode 100644 index 0000000..cb0b4eb --- /dev/null +++ b/src/nmag/simulation/anisotropy/fields.py @@ -0,0 +1,56 @@ +"""Evaluate anisotropy energy density and effective field.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import numpy as np + +from anisotropy import evaluate_energy_and_gradient + +from ..support import MU0 +from .materials import SimulationAnisotropyMaterialMixin + + +class SimulationAnisotropyMixin( + SimulationAnisotropyMaterialMixin, +): + if TYPE_CHECKING: + _anisotropy_fields_cache: tuple[tuple[int, int], np.ndarray, np.ndarray] | None + + def __getattr__(self, name: str) -> Any: ... + + def _get_anisotropy_fields(self) -> tuple[np.ndarray, np.ndarray]: + m = np.asarray(self._fields["m"], dtype=np.float64) + token = (id(self._require_mesh().raw_mesh), id(self._fields["m"])) + cached = self._anisotropy_fields_cache + if cached is not None and cached[0] == token: + return cached[1], cached[2] + + field = np.zeros_like(m) + energy = np.zeros(len(m), dtype=np.float64) + for group in self._nodal_anisotropy_groups(): + if group.model is None: + continue + nodes = group.nodes + group_m = m[nodes] + group_energy, gradient = evaluate_energy_and_gradient(group.model, group_m) + energy[nodes] = group_energy + if group.saturation_magnetisation != 0.0: + raw_field = -gradient / (MU0 * group.saturation_magnetisation) + norm_squared = np.einsum("ij,ij->i", group_m, group_m) + radial_scale = np.divide( + np.einsum("ij,ij->i", raw_field, group_m), + norm_squared, + out=np.zeros(len(group_m), dtype=np.float64), + where=norm_squared > 0.0, + ) + field[nodes] = raw_field - radial_scale[:, np.newaxis] * group_m + + if not np.all(np.isfinite(field)) or not np.all(np.isfinite(energy)): + raise FloatingPointError("Anisotropy evaluation produced non-finite fields.") + self._anisotropy_fields_cache = (token, field, energy) + return field, energy + + def _anisotropy_is_zero_by_construction(self) -> bool: + return all(getattr(material, "anisotropy", None) is None for material in self.materials) diff --git a/src/nmag/simulation/anisotropy/materials.py b/src/nmag/simulation/anisotropy/materials.py new file mode 100644 index 0000000..835aa18 --- /dev/null +++ b/src/nmag/simulation/anisotropy/materials.py @@ -0,0 +1,138 @@ +"""Map material anisotropy models onto geometric mesh nodes.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +import numpy as np + +from anisotropy import AnisotropyModel, PredefinedAnisotropy, anisotropy_signature_values + +from ..support import _si_unit + + +@dataclass(frozen=True, slots=True) +class NodalAnisotropyGroup: + model: AnisotropyModel + order: int | None + nodes: np.ndarray + saturation_magnetisation: float + + +class SimulationAnisotropyMaterialMixin: + if TYPE_CHECKING: + _nodal_anisotropy_groups_cache: tuple[int, tuple[NodalAnisotropyGroup, ...]] | None + + def __getattr__(self, name: str) -> Any: ... + + def _nodal_anisotropy_groups(self) -> tuple[NodalAnisotropyGroup, ...]: + token = self._mesh_geometry_token() + cached = self._nodal_anisotropy_groups_cache + if cached is not None and cached[0] == token: + return cached[1] + + point_count = len(self._mesh_points()) + simplices = np.asarray(self._require_mesh().simplices, dtype=np.int64) + regions = np.asarray( + self._require_mesh().regions or [1] * len(simplices), + dtype=np.int64, + ) + assignments: list[tuple[np.ndarray, Any, float] | None] = [None] * point_count + + if len(simplices) == 0: + if self.materials: + material = self.materials[0] + signature = anisotropy_signature_values( + material.anisotropy, + material.anisotropy_order, + ) + ms = float(material.Ms.in_units_of(_si_unit("A/m"))) + assignments = [(signature, material, ms)] * point_count + else: + if len(regions) != len(simplices): + raise ValueError("Mesh regions must contain one entry per simplex.") + for region in np.unique(regions): + material = self._simplex_material(int(region)) + signature = anisotropy_signature_values( + material.anisotropy, + material.anisotropy_order, + ) + ms = float(material.Ms.in_units_of(_si_unit("A/m"))) + nodes = np.unique(simplices[regions == region].ravel()) + for node in nodes: + previous = assignments[int(node)] + if previous is not None: + previous_signature, _previous_material, previous_ms = previous + if not self._anisotropy_materials_compatible( + _previous_material, + material, + previous_signature, + signature, + ) or not np.isclose(previous_ms, ms, rtol=1.0e-12, atol=0.0): + raise NotImplementedError( + "A mesh node shared by materials with different anisotropy or Ms " + "requires material-specific magnetisation DOFs; node " + f"{int(node)} includes region {int(region)}." + ) + else: + assignments[int(node)] = (signature, material, ms) + + if self.materials: + fallback = self.materials[0] + fallback_signature = anisotropy_signature_values( + fallback.anisotropy, + fallback.anisotropy_order, + ) + fallback_ms = float(fallback.Ms.in_units_of(_si_unit("A/m"))) + assignments = [ + assignment or (fallback_signature, fallback, fallback_ms) + for assignment in assignments + ] + + grouped_nodes: dict[bytes, list[int]] = {} + grouped_materials: dict[bytes, tuple[Any, float]] = {} + for node, assignment in enumerate(assignments): + if assignment is None: + continue + signature, material, ms = assignment + key = signature.tobytes() + np.float64(ms).tobytes() + grouped_nodes.setdefault(key, []).append(node) + grouped_materials[key] = (material, ms) + + groups = tuple( + NodalAnisotropyGroup( + model=grouped_materials[key][0].anisotropy, + order=grouped_materials[key][0].anisotropy_order, + nodes=np.asarray(nodes, dtype=np.int64), + saturation_magnetisation=grouped_materials[key][1], + ) + for key, nodes in grouped_nodes.items() + ) + self._nodal_anisotropy_groups_cache = (token, groups) + return groups + + @staticmethod + def _anisotropy_materials_compatible( + first: Any, + second: Any, + first_signature: np.ndarray, + second_signature: np.ndarray, + ) -> bool: + first_model = first.anisotropy + second_model = second.anisotropy + if first_model is None or second_model is None: + return first_model is second_model + if isinstance(first_model, PredefinedAnisotropy) and isinstance( + second_model, + PredefinedAnisotropy, + ): + return bool( + np.allclose( + first_signature, + second_signature, + rtol=1.0e-12, + atol=1.0e-12, + ) + ) + return first_model is second_model and first.anisotropy_order == second.anisotropy_order diff --git a/src/nmag/simulation/demag/__init__.py b/src/nmag/simulation/demag/__init__.py new file mode 100644 index 0000000..757e011 --- /dev/null +++ b/src/nmag/simulation/demag/__init__.py @@ -0,0 +1 @@ +"""Demagnetization solver, field, and geometry implementation modules.""" diff --git a/src/nmag/simulation/demag/bem/__init__.py b/src/nmag/simulation/demag/bem/__init__.py new file mode 100644 index 0000000..8aa1c2c --- /dev/null +++ b/src/nmag/simulation/demag/bem/__init__.py @@ -0,0 +1,11 @@ +"""BEM demagnetization compatibility façade composed from focused mixins.""" + +from .dirichlet import SimulationDemagBemDirichletMixin +from .operator import SimulationDemagBemOperatorMixin + + +class SimulationDemagBemMixin( + SimulationDemagBemOperatorMixin, + SimulationDemagBemDirichletMixin, +): + """Provide Lindholm BEM construction and Dirichlet extension.""" diff --git a/src/nmag/simulation/demag/bem/diagnostics.py b/src/nmag/simulation/demag/bem/diagnostics.py new file mode 100644 index 0000000..5634547 --- /dev/null +++ b/src/nmag/simulation/demag/bem/diagnostics.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import logging +from typing import Any + +import numpy as np + +from ....demag.bem_operator import ( + BemOperatorStats, + HierarchicalLindholmBemOperator, +) + +logger = logging.getLogger(__name__) + + +def bem_operator_stats( + *, + requested_backend: str, + effective_backend: str, + fallback_reason: str | None, + operator: Any, + boundary_node_count: int, + boundary_faces: int, + setup_seconds: float, +) -> BemOperatorStats: + dense_equivalent = boundary_node_count**2 * np.dtype(np.float64).itemsize + if isinstance(operator, HierarchicalLindholmBemOperator): + rust_operator = operator.rust_operator + dense_blocks = int(rust_operator.dense_blocks) + low_rank_blocks = int(rust_operator.low_rank_blocks) + maximum_rank = int(rust_operator.maximum_rank) + mean_rank = float(rust_operator.mean_rank) + sampled_error = float(rust_operator.sampled_relative_error) + storage_bytes = operator.storage_bytes + else: + dense_blocks = 1 if isinstance(operator, np.ndarray) and operator.size else 0 + low_rank_blocks = 0 + maximum_rank = 0 + mean_rank = 0.0 + sampled_error = 0.0 + storage_bytes = ( + int(operator.nbytes) if isinstance(operator, np.ndarray) else operator.storage_bytes + ) + return BemOperatorStats( + requested_backend=requested_backend, + effective_backend=effective_backend, + fallback_reason=fallback_reason, + boundary_nodes=boundary_node_count, + boundary_faces=boundary_faces, + setup_seconds=setup_seconds, + storage_bytes=storage_bytes, + dense_equivalent_bytes=dense_equivalent, + dense_blocks=dense_blocks, + low_rank_blocks=low_rank_blocks, + maximum_rank=maximum_rank, + mean_rank=mean_rank, + sampled_relative_error=sampled_error, + ) + + +def log_bem_operator_selection(stats: BemOperatorStats, selected_backend: str) -> None: + fallback = ( + f" after {selected_backend} fallback: {stats.fallback_reason}" + if stats.fallback_reason + else "" + ) + logger.info( + "Lindholm BEM selected %s storage%s (%.3f MiB, %.3f dense ratio)", + stats.effective_backend, + fallback, + stats.storage_bytes / (1024 * 1024), + stats.compression_ratio, + ) diff --git a/src/nmag/simulation/demag/bem/dirichlet.py b/src/nmag/simulation/demag/bem/dirichlet.py new file mode 100644 index 0000000..44ec083 --- /dev/null +++ b/src/nmag/simulation/demag/bem/dirichlet.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import numpy as np + +from ....backends import _selected_demag_linear_solver_backend +from ....demag import _solve_linear_system +from ....demag.linear import _factorize_scipy, _is_sparse_matrix, _solve_sparse_linear_system + + +class SimulationDemagBemDirichletMixin: + if TYPE_CHECKING: + _demag_dirichlet_factorization_cache: Any | None + + def __getattr__(self, name: str) -> Any: ... + + def _dirichlet_extension_from_boundary( + self, + stiffness: np.ndarray, + boundary_nodes: np.ndarray, + boundary_values: np.ndarray, + ) -> np.ndarray: + with self._record_active_subfield_array_timing_block( + "demag_auxiliary:dirichlet_extension:index", + ): + phi = np.zeros(stiffness.shape[0], dtype=float) + phi[boundary_nodes] = boundary_values + boundary_set = set(int(index) for index in boundary_nodes) + interior_nodes = np.asarray( + [index for index in range(stiffness.shape[0]) if index not in boundary_set], + dtype=int, + ) + if len(interior_nodes) == 0: + self._record_active_subfield_array_timing( + "demag_auxiliary:dirichlet_extension:no_interior", + 0.0, + ) + return phi + + with self._record_active_subfield_array_timing_block( + "demag_auxiliary:dirichlet_extension:slice", + ): + if _is_sparse_matrix(stiffness): + interior_stiffness = stiffness[interior_nodes][:, interior_nodes] + boundary_stiffness = stiffness[interior_nodes][:, boundary_nodes] + else: + interior_stiffness = stiffness[np.ix_(interior_nodes, interior_nodes)] + boundary_stiffness = stiffness[np.ix_(interior_nodes, boundary_nodes)] + with self._record_active_subfield_array_timing_block( + "demag_auxiliary:dirichlet_extension:rhs", + ): + interior_rhs = -boundary_stiffness @ boundary_values + if _is_sparse_matrix(interior_stiffness): + phi[interior_nodes] = _solve_sparse_linear_system( + interior_stiffness, + interior_rhs, + record_timing=self._record_active_subfield_array_timing, + timing_prefix="demag_auxiliary:dirichlet_extension", + ) + elif _selected_demag_linear_solver_backend(stiffness.shape[0]) == "scipy": + cached = self._demag_dirichlet_factorization_cache + if cached is None: + factorization = _factorize_scipy(interior_stiffness) + self._demag_dirichlet_factorization_cache = ( + interior_nodes, + boundary_nodes, + factorization, + ) + else: + cached_interior, cached_boundary, factorization = cached + if not ( + np.array_equal(cached_interior, interior_nodes) + and np.array_equal(cached_boundary, boundary_nodes) + ): + raise RuntimeError("Cached demag boundary partition does not match the mesh.") + phi[interior_nodes] = factorization.solve(interior_rhs) + else: + phi[interior_nodes] = _solve_linear_system( + interior_stiffness, + interior_rhs, + record_timing=self._record_active_subfield_array_timing, + timing_prefix="demag_auxiliary:dirichlet_extension", + ) + return phi diff --git a/src/nmag/simulation/demag/bem/hierarchical.py b/src/nmag/simulation/demag/bem/hierarchical.py new file mode 100644 index 0000000..003df17 --- /dev/null +++ b/src/nmag/simulation/demag/bem/hierarchical.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import logging +from typing import Any + +import numpy as np + +from ....backends import _load_rust_accelerator, _selected_lindholm_bem_backend +from ....demag.bem_operator import HierarchicalLindholmBemOperator +from ....resources import available_memory_bytes +from ...support import _simulation_compatibility_binding + +logger = logging.getLogger(__name__) + + +def build_hierarchical_lindholm_operator( + simulation: Any, + points: np.ndarray, + simplices: np.ndarray, + boundary_faces: list[tuple[int, tuple[int, int, int]]], +) -> tuple[np.ndarray, Any, str | None]: + config = simulation.config + if _selected_lindholm_bem_backend(config) != "rust": + boundary_nodes, operator = simulation._build_lindholm_bem_operator( + points, + simplices, + boundary_faces, + ) + return boundary_nodes, operator, "Rust accelerator is unavailable or disabled" + + rust_accelerator = _simulation_compatibility_binding( + "_load_rust_accelerator", + _load_rust_accelerator, + )() + if not hasattr(rust_accelerator, "build_lindholm_hmatrix"): + raise RuntimeError( + "The installed nmag_accel does not provide hierarchical Lindholm BEM; " + "rebuild it with ./scripts/build-accelerator.sh --release." + ) + boundary_nodes, local_index_by_point, face_nodes = simulation._lindholm_bem_boundary_index( + points, + boundary_faces, + ) + hierarchical = config.hierarchical_bem + available = available_memory_bytes() + memory_budget = ( + int(available * hierarchical.memory_fraction) + if available is not None + else np.iinfo(np.uintp).max + ) + try: + rust_operator = rust_accelerator.build_lindholm_hmatrix( + np.ascontiguousarray(points, dtype=np.float64), + np.ascontiguousarray(simplices, dtype=np.int64), + np.ascontiguousarray(face_nodes, dtype=np.int64), + np.ascontiguousarray(boundary_nodes, dtype=np.int64), + np.ascontiguousarray(local_index_by_point, dtype=np.int64), + relative_tolerance=hierarchical.relative_tolerance, + admissibility_eta=hierarchical.admissibility_eta, + leaf_size=hierarchical.leaf_size, + max_rank=hierarchical.max_rank, + validation_vectors=hierarchical.validation_vectors, + validation_rows=hierarchical.validation_rows, + memory_budget_bytes=memory_budget, + ) + except (MemoryError, ValueError) as exc: + logger.info("Hierarchical Lindholm BEM rejected; using exact matrix-free BEM: %s", exc) + boundary_nodes, operator = simulation._build_lindholm_bem_operator( + points, + simplices, + boundary_faces, + ) + return boundary_nodes, operator, str(exc) + return boundary_nodes, HierarchicalLindholmBemOperator(rust_operator), None diff --git a/src/nmag/simulation/demag/bem/operator.py b/src/nmag/simulation/demag/bem/operator.py new file mode 100644 index 0000000..08791c0 --- /dev/null +++ b/src/nmag/simulation/demag/bem/operator.py @@ -0,0 +1,292 @@ +from __future__ import annotations + +import time +from typing import TYPE_CHECKING, Any, cast + +import numpy as np + +from ....backends import ( + _load_rust_accelerator, + _selected_demag_bem_storage_backend, + _selected_lindholm_bem_backend, +) +from ....demag import ( + _boundary_node_solid_angles, + _build_lindholm_bem_matrix_fast, + _lindholm_triangle_contributions, + _precompute_lindholm_face_geometry, +) +from ....demag.bem_operator import MatrixFreeLindholmBemOperator +from ...support import _simulation_compatibility_binding +from .diagnostics import bem_operator_stats, log_bem_operator_selection +from .hierarchical import build_hierarchical_lindholm_operator + + +class SimulationDemagBemOperatorMixin: + if TYPE_CHECKING: + _demag_bem_cache: Any | None + + def __getattr__(self, name: str) -> Any: ... + + def _lindholm_bem_for_demag_mesh( + self, + points: np.ndarray, + simplices: np.ndarray, + boundary_faces: list[tuple[int, tuple[int, int, int]]], + ) -> tuple[np.ndarray, Any]: + cache_started = time.perf_counter() + token = self._mesh_geometry_token() + self._ensure_demag_geometry_cache_token(token) + boundary_node_count = len({node for _, face in boundary_faces for node in face}) + config = getattr(self, "config", None) + storage_backend = _selected_demag_bem_storage_backend(boundary_node_count, config) + if self._demag_bem_cache is not None: + cached_backend, boundary_nodes, operator, stats = self._demag_bem_cache + if cached_backend == storage_backend: + cache_kind = "matrix" if storage_backend == "dense" else "operator" + self._last_bem_operator_stats = stats + self._record_active_subfield_array_timing( + f"demag_auxiliary:bem_{cache_kind}_cache_hit", + time.perf_counter() - cache_started, + ) + return boundary_nodes, operator + + fallback_reason: str | None = None + effective_backend = storage_backend + if storage_backend == "hierarchical": + boundary_nodes, bem, fallback_reason = build_hierarchical_lindholm_operator( + self, + points, + simplices, + boundary_faces, + ) + if fallback_reason is not None: + effective_backend = "matrix-free" + elif storage_backend == "matrix-free": + boundary_nodes, bem = self._build_lindholm_bem_operator( + points, + simplices, + boundary_faces, + ) + else: + boundary_nodes, bem = self._build_lindholm_bem_matrix( + points, + simplices, + boundary_faces, + ) + elapsed = time.perf_counter() - cache_started + requested_backend = getattr(config, "demag_bem_storage", storage_backend) + stats = bem_operator_stats( + requested_backend=requested_backend, + effective_backend=effective_backend, + fallback_reason=fallback_reason, + operator=bem, + boundary_node_count=len(boundary_nodes), + boundary_faces=len(boundary_faces), + setup_seconds=elapsed, + ) + log_bem_operator_selection(stats, storage_backend) + self._last_bem_operator_stats = stats + self._demag_boundary_faces_cache = boundary_faces + self._demag_bem_cache = (storage_backend, boundary_nodes, bem, stats) + return boundary_nodes, cast(Any, bem) + + def _build_lindholm_bem_operator( + self, + points: np.ndarray, + simplices: np.ndarray, + boundary_faces: list[tuple[int, tuple[int, int, int]]], + ) -> tuple[np.ndarray, MatrixFreeLindholmBemOperator]: + boundary_nodes, local_index_by_point, face_nodes = self._lindholm_bem_boundary_index( + points, + boundary_faces, + ) + ( + face_points, + zeta_vectors, + eta_vectors, + edge_lengths, + corner_cosines, + denominator_factors, + ) = _precompute_lindholm_face_geometry(points, face_nodes) + rust_accelerator = None + if _selected_lindholm_bem_backend(getattr(self, "config", None)) == "rust": + rust_accelerator = _simulation_compatibility_binding( + "_load_rust_accelerator", + _load_rust_accelerator, + )() + if not hasattr(rust_accelerator, "apply_lindholm_bem_matrix_free"): + raise RuntimeError( + "The installed nmag_accel does not provide matrix-free Lindholm BEM; " + "rebuild it with ./scripts/build-accelerator.sh --release." + ) + return boundary_nodes, MatrixFreeLindholmBemOperator( + points=np.ascontiguousarray(points, dtype=np.float64), + simplices=np.ascontiguousarray(simplices, dtype=np.int64), + boundary_faces=np.ascontiguousarray(face_nodes, dtype=np.int64), + face_points=np.ascontiguousarray(face_points, dtype=np.float64), + zeta_vectors=np.ascontiguousarray(zeta_vectors, dtype=np.float64), + eta_vectors=np.ascontiguousarray(eta_vectors, dtype=np.float64), + edge_lengths=np.ascontiguousarray(edge_lengths, dtype=np.float64), + corner_cosines=np.ascontiguousarray(corner_cosines, dtype=np.float64), + denominator_factors=np.ascontiguousarray(denominator_factors, dtype=np.float64), + boundary_nodes=np.ascontiguousarray(boundary_nodes, dtype=np.int64), + local_index_by_point=np.ascontiguousarray(local_index_by_point, dtype=np.int64), + solid_angles=np.ascontiguousarray( + _boundary_node_solid_angles(points, simplices), + dtype=np.float64, + ), + rust_accelerator=rust_accelerator, + ) + + def _build_lindholm_bem_matrix( + self, + points: np.ndarray, + simplices: np.ndarray, + boundary_faces: list[tuple[int, tuple[int, int, int]]], + ) -> tuple[np.ndarray, np.ndarray]: + backend = _selected_lindholm_bem_backend(getattr(self, "config", None)) + if backend == "python": + return self._build_lindholm_bem_matrix_python( + points, + simplices, + boundary_faces, + ) + if backend == "rust": + return self._build_lindholm_bem_matrix_rust( + points, + simplices, + boundary_faces, + ) + return self._build_lindholm_bem_matrix_numba( + points, + simplices, + boundary_faces, + ) + + def _lindholm_bem_boundary_index( + self, + points: np.ndarray, + boundary_faces: list[tuple[int, tuple[int, int, int]]], + ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + boundary_nodes = np.asarray( + sorted({point_index for _, face in boundary_faces for point_index in face}), + dtype=np.int64, + ) + local_index_by_point = np.full(len(points), -1, dtype=np.int64) + local_index_by_point[boundary_nodes] = np.arange(len(boundary_nodes), dtype=np.int64) + face_nodes = np.asarray([face for _, face in boundary_faces], dtype=np.int64) + return boundary_nodes, local_index_by_point, face_nodes + + def _build_lindholm_bem_matrix_python( + self, + points: np.ndarray, + simplices: np.ndarray, + boundary_faces: list[tuple[int, tuple[int, int, int]]], + ) -> tuple[np.ndarray, np.ndarray]: + with self._record_active_subfield_array_timing_block( + "demag_auxiliary:lindholm_bem:index", + ): + boundary_nodes, local_index_by_point, _face_nodes = self._lindholm_bem_boundary_index( + points, + boundary_faces, + ) + + with self._record_active_subfield_array_timing_block( + "demag_auxiliary:lindholm_bem:python", + ): + bem = np.zeros((len(boundary_nodes), len(boundary_nodes)), dtype=float) + solid_angles = _boundary_node_solid_angles(points, simplices) + + for row, observer_index in enumerate(boundary_nodes): + observer = points[int(observer_index)] + for _, face in boundary_faces: + p0, p1, p2 = points[list(face)] + normal = np.cross(p1 - p0, p2 - p0) + normal_length = float(np.linalg.norm(normal)) + if normal_length == 0.0: + continue + contributions = _lindholm_triangle_contributions( + observer, + p0, + p1, + p2, + normal / normal_length, + ) + for point_index, contribution in zip(face, contributions): # noqa: B905 + bem[row, int(local_index_by_point[int(point_index)])] += contribution + bem[row, row] += solid_angles[int(observer_index)] / (4.0 * np.pi) - 1.0 + return boundary_nodes, bem + + def _build_lindholm_bem_matrix_numba( + self, + points: np.ndarray, + simplices: np.ndarray, + boundary_faces: list[tuple[int, tuple[int, int, int]]], + ) -> tuple[np.ndarray, np.ndarray]: + with self._record_active_subfield_array_timing_block( + "demag_auxiliary:lindholm_bem:index", + ): + boundary_nodes, local_index_by_point, face_nodes = self._lindholm_bem_boundary_index( + points, + boundary_faces, + ) + + with self._record_active_subfield_array_timing_block( + "demag_auxiliary:lindholm_bem:numba", + ): + ( + face_points, + zeta_vectors, + eta_vectors, + edge_lengths, + corner_cosines, + denominator_factors, + ) = _precompute_lindholm_face_geometry(np.asarray(points, dtype=np.float64), face_nodes) + bem = _build_lindholm_bem_matrix_fast( + np.asarray(points, dtype=np.float64), + np.asarray(simplices, dtype=np.int64), + face_nodes, + face_points, + zeta_vectors, + eta_vectors, + edge_lengths, + corner_cosines, + denominator_factors, + np.asarray(boundary_nodes, dtype=np.int64), + local_index_by_point, + ) + return boundary_nodes, bem + + def _build_lindholm_bem_matrix_rust( + self, + points: np.ndarray, + simplices: np.ndarray, + boundary_faces: list[tuple[int, tuple[int, int, int]]], + ) -> tuple[np.ndarray, np.ndarray]: + with self._record_active_subfield_array_timing_block( + "demag_auxiliary:lindholm_bem:index", + ): + boundary_nodes, local_index_by_point, face_nodes = self._lindholm_bem_boundary_index( + points, + boundary_faces, + ) + + with self._record_active_subfield_array_timing_block( + "demag_auxiliary:lindholm_bem:rust", + ): + rust_accel = _simulation_compatibility_binding( + "_load_rust_accelerator", + _load_rust_accelerator, + )() + bem = np.asarray( + rust_accel.build_lindholm_bem_matrix( + np.asarray(points, dtype=np.float64), + np.asarray(simplices, dtype=np.int64), + face_nodes, + np.asarray(boundary_nodes, dtype=np.int64), + local_index_by_point, + ), + dtype=float, + ) + return boundary_nodes, bem diff --git a/src/nmag/simulation/demag/fem/__init__.py b/src/nmag/simulation/demag/fem/__init__.py new file mode 100644 index 0000000..bb450e2 --- /dev/null +++ b/src/nmag/simulation/demag/fem/__init__.py @@ -0,0 +1,11 @@ +"""FEM demagnetization compatibility façade composed from focused mixins.""" + +from .assembly import SimulationDemagFemAssemblyMixin +from .geometry import SimulationDemagFemGeometryMixin + + +class SimulationDemagFemMixin( + SimulationDemagFemAssemblyMixin, + SimulationDemagFemGeometryMixin, +): + """Provide FEM assembly, geometry, and boundary-face operations.""" diff --git a/src/nmag/simulation/demag/fem/assembly.py b/src/nmag/simulation/demag/fem/assembly.py new file mode 100644 index 0000000..f18dc33 --- /dev/null +++ b/src/nmag/simulation/demag/fem/assembly.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import numpy as np + +from ....backends import ( + _load_rust_accelerator, + _selected_demag_fem_assembly_backend, +) +from ...support import _simulation_compatibility_binding +from .charges import volume_charge_scaling_correction + + +class SimulationDemagFemAssemblyMixin: + if TYPE_CHECKING: + + def __getattr__(self, name: str) -> Any: ... + + def _assemble_demag_fem_system( + self, + points: np.ndarray, + simplices: np.ndarray, + m: np.ndarray, + ms_values: np.ndarray, + volume_charge_scales: np.ndarray, + ) -> tuple[Any, np.ndarray, np.ndarray]: + point_count = len(points) + stiffness, gradients_by_cell, volumes = self._demag_fem_geometry_for_mesh( + points, + simplices, + ) + assembly_backend = _selected_demag_fem_assembly_backend( + len(simplices), getattr(self, "config", None) + ) + with self._record_active_subfield_array_timing_block( + f"demag_auxiliary:assemble_fem:divergence:{assembly_backend}", + ): + if assembly_backend == "rust": + divergence = self._assemble_demag_fem_divergence_rust( + simplices, + gradients_by_cell, + volumes, + m, + ms_values, + point_count, + ) + else: + divergence = self._assemble_demag_fem_divergence_python( + simplices, + gradients_by_cell, + volumes, + m, + ms_values, + point_count, + ) + divergence += volume_charge_scaling_correction( + simplices, + gradients_by_cell, + volumes, + m, + ms_values, + volume_charge_scales, + point_count, + ) + return stiffness, divergence, volumes + + def _assemble_demag_fem_divergence_python( + self, + simplices: np.ndarray, + gradients_by_cell: np.ndarray, + volumes: np.ndarray, + m: np.ndarray, + ms_values: np.ndarray, + point_count: int, + ) -> np.ndarray: + divergence = np.zeros(point_count, dtype=float) + if len(simplices) == 0: + return divergence + + cell_m = m[simplices] * ms_values[:, np.newaxis, np.newaxis] + average_m = np.mean(cell_m, axis=1) + cell_divergence = volumes[:, np.newaxis] * np.einsum( + "cij,cj->ci", + gradients_by_cell, + average_m, + ) + np.add.at(divergence, simplices, cell_divergence) + return divergence + + def _assemble_demag_fem_divergence_rust( + self, + simplices: np.ndarray, + gradients_by_cell: np.ndarray, + volumes: np.ndarray, + m: np.ndarray, + ms_values: np.ndarray, + point_count: int, + ) -> np.ndarray: + rust_accel = _simulation_compatibility_binding( + "_load_rust_accelerator", + _load_rust_accelerator, + )("NmagConfig.accelerator['fem_assembly']") + return np.asarray( + rust_accel.build_demag_fem_divergence( + np.asarray(simplices, dtype=np.int64), + np.asarray(gradients_by_cell, dtype=np.float64), + np.asarray(volumes, dtype=np.float64), + np.asarray(m, dtype=np.float64), + np.asarray(ms_values, dtype=np.float64), + int(point_count), + ), + dtype=float, + ) diff --git a/src/nmag/simulation/demag/fem/charges.py b/src/nmag/simulation/demag/fem/charges.py new file mode 100644 index 0000000..afd6f09 --- /dev/null +++ b/src/nmag/simulation/demag/fem/charges.py @@ -0,0 +1,38 @@ +"""Finite-element separation of demagnetization volume charges.""" + +from __future__ import annotations + +import numpy as np + + +def volume_charge_scaling_correction( + simplices: np.ndarray, + gradients_by_cell: np.ndarray, + volumes: np.ndarray, + m: np.ndarray, + ms_values: np.ndarray, + volume_charge_scales: np.ndarray, + point_count: int, +) -> np.ndarray: + """Return the weak-source correction for material volume-charge scales. + + The standard FEM assembly is the combined surface and volume source + ``integral(grad(test) . M)``. Integration by parts identifies the volume + part on a linear tetrahedron as ``-volume * div(M) / 4`` at every local + node. Scaling that term separately preserves the unscaled surface charge. + """ + correction = np.zeros(point_count, dtype=float) + if len(simplices) == 0 or np.all(volume_charge_scales == 1.0): + return correction + + cell_m = m[simplices] * ms_values[:, np.newaxis, np.newaxis] + cell_divergence = np.einsum("cij,cij->c", gradients_by_cell, cell_m) + local_correction = ( + -(volume_charge_scales - 1.0) * volumes * cell_divergence / simplices.shape[1] + ) + np.add.at( + correction, + simplices, + np.broadcast_to(local_correction[:, np.newaxis], simplices.shape), + ) + return correction diff --git a/src/nmag/simulation/demag/fem/geometry.py b/src/nmag/simulation/demag/fem/geometry.py new file mode 100644 index 0000000..1165965 --- /dev/null +++ b/src/nmag/simulation/demag/fem/geometry.py @@ -0,0 +1,261 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import numpy as np + +from ....backends import ( + _load_rust_accelerator, + _selected_demag_boundary_face_backend, + _selected_demag_fem_geometry_backend, + _selected_demag_fem_matrix_backend, +) +from ....demag import ( + _oriented_boundary_faces, + _validate_dense_demag_size, + _validate_tetrahedral_cells, +) +from ...support import _simulation_compatibility_binding + + +class SimulationDemagFemGeometryMixin: + if TYPE_CHECKING: + _demag_fem_geometry_cache: Any | None + _demag_boundary_faces_cache: Any | None + + def __getattr__(self, name: str) -> Any: ... + + def _demag_fem_geometry_for_mesh( + self, + points: np.ndarray, + simplices: np.ndarray, + ) -> tuple[Any, np.ndarray, np.ndarray]: + token = self._mesh_geometry_token() + self._ensure_demag_geometry_cache_token(token) + matrix_backend = _selected_demag_fem_matrix_backend(len(points)) + if self._demag_fem_geometry_cache is not None: + cached_backend, stiffness, gradients, volumes = self._demag_fem_geometry_cache + if cached_backend == matrix_backend: + return stiffness, gradients, volumes + + if matrix_backend == "sparse": + stiffness, gradients, volumes = self._demag_fem_geometry_for_mesh_sparse( + points, + simplices, + ) + self._demag_fem_geometry_cache = ( + matrix_backend, + stiffness, + gradients, + volumes, + ) + return stiffness, gradients, volumes + + backend = _selected_demag_fem_geometry_backend( + len(simplices), getattr(self, "config", None) + ) + with self._record_active_subfield_array_timing_block( + f"demag_auxiliary:assemble_fem:geometry:{backend}", + ): + if backend == "rust": + geometry = self._demag_fem_geometry_for_mesh_rust( + points, + simplices, + ) + else: + geometry = self._demag_fem_geometry_for_mesh_python( + points, + simplices, + ) + stiffness, gradients, volumes = geometry + self._demag_fem_geometry_cache = (matrix_backend, stiffness, gradients, volumes) + return stiffness, gradients, volumes + + def _demag_fem_geometry_for_mesh_sparse( + self, + points: np.ndarray, + simplices: np.ndarray, + ) -> tuple[Any, np.ndarray, np.ndarray]: + from scipy import sparse + + _validate_tetrahedral_cells(points, simplices) + point_count = len(points) + if len(simplices) == 0: + return ( + sparse.csr_matrix((point_count, point_count), dtype=float), + np.empty((0, 4, 3), dtype=float), + np.empty(0, dtype=float), + ) + + cell_points = points[simplices] + matrices = np.concatenate( + [np.ones((len(simplices), 4, 1), dtype=float), cell_points], + axis=2, + ) + inverses = np.linalg.inv(matrices) + gradients = np.transpose(inverses[:, 1:, :], (0, 2, 1)) + volumes = np.abs(np.linalg.det(cell_points[:, 1:] - cell_points[:, [0]])) / 6.0 + local_stiffness = volumes[:, np.newaxis, np.newaxis] * np.einsum( + "cik,cjk->cij", + gradients, + gradients, + ) + rows = np.broadcast_to(simplices[:, :, np.newaxis], local_stiffness.shape).ravel() + columns = np.broadcast_to(simplices[:, np.newaxis, :], local_stiffness.shape).ravel() + stiffness = sparse.coo_matrix( + (local_stiffness.ravel(), (rows, columns)), + shape=(point_count, point_count), + ).tocsr() + stiffness.sum_duplicates() + return stiffness, gradients, volumes + + def _demag_fem_geometry_for_mesh_python( + self, + points: np.ndarray, + simplices: np.ndarray, + ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + point_count = len(points) + _validate_dense_demag_size(point_count) + _validate_tetrahedral_cells(points, simplices) + stiffness = np.zeros((point_count, point_count), dtype=float) + gradients_by_cell = np.zeros((len(simplices), 4, 3), dtype=float) + volumes = np.zeros(len(simplices), dtype=float) + + if len(simplices) == 0: + return stiffness, gradients_by_cell, volumes + + cell_points = points[simplices] + matrices = np.concatenate( + [ + np.ones((len(simplices), 4, 1), dtype=float), + cell_points, + ], + axis=2, + ) + try: + inverses = np.linalg.inv(matrices) + except np.linalg.LinAlgError: + return self._demag_fem_geometry_for_mesh_loop( + points, + simplices, + stiffness, + gradients_by_cell, + volumes, + ) + + gradients_by_cell[:] = np.transpose(inverses[:, 1:, :], (0, 2, 1)) + volumes[:] = np.abs(np.linalg.det(cell_points[:, 1:] - cell_points[:, [0]])) / 6.0 + positive = volumes > 0.0 + if np.any(positive): + local_stiffness = volumes[positive, np.newaxis, np.newaxis] * np.einsum( + "cik,cjk->cij", + gradients_by_cell[positive], + gradients_by_cell[positive], + ) + positive_simplices = simplices[positive] + np.add.at( + stiffness, + ( + positive_simplices[:, :, np.newaxis], + positive_simplices[:, np.newaxis, :], + ), + local_stiffness, + ) + + return stiffness, gradients_by_cell, volumes + + def _demag_fem_geometry_for_mesh_rust( + self, + points: np.ndarray, + simplices: np.ndarray, + ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + _validate_dense_demag_size(len(points)) + _validate_tetrahedral_cells(points, simplices) + rust_accel = _simulation_compatibility_binding( + "_load_rust_accelerator", + _load_rust_accelerator, + )("NmagConfig.accelerator['fem_geometry']") + stiffness, gradients_by_cell, volumes = rust_accel.build_demag_fem_geometry( + np.asarray(points, dtype=np.float64), + np.asarray(simplices, dtype=np.int64), + ) + return ( + np.asarray(stiffness, dtype=float), + np.asarray(gradients_by_cell, dtype=float), + np.asarray(volumes, dtype=float), + ) + + def _demag_fem_geometry_for_mesh_loop( + self, + points: np.ndarray, + simplices: np.ndarray, + stiffness: np.ndarray, + gradients_by_cell: np.ndarray, + volumes: np.ndarray, + ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + for cell_index, simplex in enumerate(simplices): + cell_points = points[simplex] + matrix = np.column_stack([np.ones(4), cell_points]) + try: + inverse = np.asarray(np.linalg.inv(matrix), dtype=float) + except np.linalg.LinAlgError as error: + raise ValueError(f"Demag FEM cell {cell_index} is singular.") from error + gradients = np.transpose(inverse[1:, :]) + volume = abs(float(np.linalg.det(cell_points[1:] - cell_points[[0]]))) / 6.0 + if volume <= 0.0: + raise ValueError(f"Demag FEM cell {cell_index} has zero volume.") + volumes[cell_index] = volume + gradients_by_cell[cell_index] = gradients + stiffness[np.ix_(simplex, simplex)] += volume * (gradients @ np.transpose(gradients)) + + return stiffness, gradients_by_cell, volumes + + def _boundary_faces_for_demag_mesh( + self, + points: np.ndarray, + simplices: np.ndarray, + ) -> list[tuple[int, tuple[int, int, int]]]: + token = self._mesh_geometry_token() + self._ensure_demag_geometry_cache_token(token) + if self._demag_boundary_faces_cache is not None: + return self._demag_boundary_faces_cache + + backend = _selected_demag_boundary_face_backend(getattr(self, "config", None)) + with self._record_active_subfield_array_timing_block( + f"demag_auxiliary:boundary_faces:{backend}", + ): + if backend == "rust": + boundary_faces = self._boundary_faces_for_demag_mesh_rust( + points, + simplices, + ) + else: + boundary_faces = _simulation_compatibility_binding( + "_oriented_boundary_faces", + _oriented_boundary_faces, + )(points, simplices) + self._demag_boundary_faces_cache = boundary_faces + self._demag_bem_cache = None + self._last_bem_operator_stats = None + return boundary_faces + + def _boundary_faces_for_demag_mesh_rust( + self, + points: np.ndarray, + simplices: np.ndarray, + ) -> list[tuple[int, tuple[int, int, int]]]: + rust_accel = _simulation_compatibility_binding( + "_load_rust_accelerator", + _load_rust_accelerator, + )("NmagConfig.accelerator['boundary_faces']") + owners, faces = rust_accel.build_oriented_boundary_faces( + np.asarray(points, dtype=np.float64), + np.asarray(simplices, dtype=np.int64), + ) + owner_array = np.asarray(owners, dtype=int).flatten() + face_values = np.asarray(faces, dtype=int).flatten() + face_array = np.column_stack((face_values[0::3], face_values[1::3], face_values[2::3])) + return [ + (int(owner), (int(face[0]), int(face[1]), int(face[2]))) + for owner, face in zip(owner_array, face_array, strict=True) + ] diff --git a/src/nmag/simulation/demag/fields/__init__.py b/src/nmag/simulation/demag/fields/__init__.py new file mode 100644 index 0000000..2eaf403 --- /dev/null +++ b/src/nmag/simulation/demag/fields/__init__.py @@ -0,0 +1,11 @@ +"""Demag field compatibility façade composed from recovery and auxiliary mixins.""" + +from .auxiliary import SimulationDemagAuxiliaryMixin +from .recovery import SimulationDemagRecoveryMixin + + +class SimulationDemagFieldsMixin( + SimulationDemagRecoveryMixin, + SimulationDemagAuxiliaryMixin, +): + """Provide recovered demag fields and FEM/BEM scalar-potential solves.""" diff --git a/src/nmag/simulation/demag/fields/auxiliary.py b/src/nmag/simulation/demag/fields/auxiliary.py new file mode 100644 index 0000000..f881340 --- /dev/null +++ b/src/nmag/simulation/demag/fields/auxiliary.py @@ -0,0 +1,186 @@ +from __future__ import annotations + +import time +from typing import TYPE_CHECKING, Any + +import numpy as np + +from ....backends import ( + _demag_internal_trace_enabled, + _selected_demag_linear_solver_backend, +) +from ....demag import _demag_solve_diagnostics, _solve_gauge_fixed +from ....demag.linear import _factorize_gauge_fixed, _is_sparse_matrix, _solve_sparse_gauge_fixed +from ...support import LEGACY_RHO_BOXED_VOLUME_M3 + + +class SimulationDemagAuxiliaryMixin: + if TYPE_CHECKING: + _demag_phi_cache: Any | None + _demag_rho_cache: Any | None + _demag_volumes_cache: Any | None + _demag_gauge_factorization_cache: Any | None + + def __getattr__(self, name: str) -> Any: ... + + def _get_demag_auxiliary_fields(self) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + cache_started = time.perf_counter() + token = self._demag_token() + if ( + self._demag_aux_cache_token == token + and self._demag_phi_cache is not None + and self._demag_rho_cache is not None + and self._demag_volumes_cache is not None + ): + self._record_active_subfield_array_timing( + "demag_auxiliary:cache_hit", + time.perf_counter() - cache_started, + ) + return self._demag_phi_cache, self._demag_rho_cache, self._demag_volumes_cache + + with self._record_active_subfield_array_timing_block( + "demag_auxiliary:mesh_inputs", + ): + points = self._mesh_points() + simplices = np.asarray(self._require_mesh().simplices, dtype=int) + if simplices.size == 0: + phi = np.zeros(len(points), dtype=float) + rho = np.zeros(len(points), dtype=float) + volumes = np.zeros(0, dtype=float) + else: + if simplices.ndim != 2 or simplices.shape[1] != 4: + raise NotImplementedError("The demag MVP currently supports tetrahedral 3D meshes.") + with self._record_active_subfield_array_timing_block( + "demag_auxiliary:boundary_faces", + ): + boundary_faces = self._boundary_faces_for_demag_mesh(points, simplices) + if not boundary_faces: + phi = np.zeros(len(points), dtype=float) + rho = np.zeros(len(points), dtype=float) + volumes = np.zeros(len(simplices), dtype=float) + else: + with self._record_active_subfield_array_timing_block( + "demag_auxiliary:material_inputs", + ): + m = np.asarray(self._fields["m"], dtype=float) + regions = list(self._require_mesh().regions or [1] * len(simplices)) + ms_values = self._simplex_material_ms_values(regions) + volume_charge_scales = self._simplex_volume_charge_scales(regions) + with self._record_active_subfield_array_timing_block( + "demag_auxiliary:fem_bem_total", + ): + phi, rho, volumes = self._fem_bem_auxiliary_at_nodes( + points, + simplices, + boundary_faces, + m, + ms_values, + volume_charge_scales, + ) + + self._demag_aux_cache_token = token + self._demag_phi_cache = phi + self._demag_rho_cache = rho + self._demag_volumes_cache = volumes + return phi, rho, volumes + + def _fem_bem_auxiliary_at_nodes( + self, + points: np.ndarray, + simplices: np.ndarray, + boundary_faces: list[tuple[int, tuple[int, int, int]]], + m: np.ndarray, + ms_values: np.ndarray, + volume_charge_scales: np.ndarray, + ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + with self._record_active_subfield_array_timing_block( + "demag_auxiliary:assemble_fem", + ): + stiffness, divergence, volumes = self._assemble_demag_fem_system( + points, + simplices, + m, + ms_values, + volume_charge_scales, + ) + with self._record_active_subfield_array_timing_block("demag_auxiliary:rho_box"): + rho = self._scalar_cofield_to_legacy_boxed_field( + divergence, + len(points), + ) + with self._record_active_subfield_array_timing_block( + "demag_auxiliary:gauge_solve", + ): + if _is_sparse_matrix(stiffness): + phi1 = _solve_sparse_gauge_fixed( + stiffness, + divergence, + record_timing=self._record_active_subfield_array_timing, + timing_prefix="demag_auxiliary:gauge_solve", + ) + elif _selected_demag_linear_solver_backend(stiffness.shape[0]) == "scipy": + if self._demag_gauge_factorization_cache is None: + self._demag_gauge_factorization_cache = _factorize_gauge_fixed(stiffness) + augmented_rhs = np.concatenate([divergence, [0.0]]) + phi1 = self._demag_gauge_factorization_cache.solve(augmented_rhs)[:-1] + else: + phi1 = _solve_gauge_fixed( + stiffness, + divergence, + record_timing=self._record_active_subfield_array_timing, + timing_prefix="demag_auxiliary:gauge_solve", + ) + with self._record_active_subfield_array_timing_block( + "demag_auxiliary:lindholm_bem", + ): + boundary_nodes, bem = self._lindholm_bem_for_demag_mesh( + points, + simplices, + boundary_faces, + ) + phi2 = np.zeros(len(points), dtype=float) + phi2_boundary = np.zeros(len(boundary_nodes), dtype=float) + if len(boundary_nodes) > 0: + with self._record_active_subfield_array_timing_block( + "demag_auxiliary:bem_multiply", + ): + phi2_boundary = bem @ phi1[boundary_nodes] + with self._record_active_subfield_array_timing_block( + "demag_auxiliary:dirichlet_extension", + ): + phi2 = self._dirichlet_extension_from_boundary( + stiffness, + boundary_nodes, + phi2_boundary, + ) + self.last_demag_solve_diagnostics = _demag_solve_diagnostics( + stiffness, + divergence, + boundary_nodes, + phi1, + phi2_boundary, + phi2, + phi1 + phi2, + ) + if _demag_internal_trace_enabled(): + self.last_demag_internal_vectors = { + "rho": np.array(rho, copy=True), + "phi1": np.array(phi1, copy=True), + "phi2": np.array(phi2, copy=True), + "phi": np.array(phi1 + phi2, copy=True), + } + return phi1 + phi2, rho, volumes + + def _scalar_cofield_to_legacy_boxed_field( + self, + cofield: np.ndarray, + point_count: int, + ) -> np.ndarray: + if cofield.size == 0: + return np.zeros(point_count, dtype=float) + + nodal = np.zeros(point_count, dtype=float) + # Legacy Nmag's simulation unit system always boxes rho with the + # fixed 1 nm base volume, rather than the loaded mesh's unit_length. + nodal[: len(cofield)] = cofield / LEGACY_RHO_BOXED_VOLUME_M3 + return nodal diff --git a/src/nmag/simulation/demag/fields/probe.py b/src/nmag/simulation/demag/fields/probe.py new file mode 100644 index 0000000..e0a2f27 --- /dev/null +++ b/src/nmag/simulation/demag/fields/probe.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any + +import numpy as np + +from ....demag import _mean_edge_length, _simplex_volumes +from ...support import MU0_OVER_4PI, _simulation_compatibility_binding + + +class SimulationDemagProbeMixin: + if TYPE_CHECKING: + _demag_dipoles: tuple[np.ndarray, np.ndarray, float] | None + + def __getattr__(self, name: str) -> Any: ... + + def _cell_demag_from_potential( + self, + points: np.ndarray, + simplices: np.ndarray, + phi: np.ndarray, + ) -> np.ndarray: + _, gradients_by_cell, _ = self._demag_fem_geometry_for_mesh(points, simplices) + return -np.einsum( + "ci,cij->cj", + phi[simplices], + gradients_by_cell, + ) + + def _build_demag_dipoles(self) -> tuple[np.ndarray, np.ndarray, float]: + if "m" not in self._fields: + raise RuntimeError("Magnetisation must be set before using demag fields.") + + points = self._mesh_points() + mesh = self._require_mesh() + simplices = np.asarray(mesh.simplices, dtype=int) + if simplices.size == 0: + return np.empty((0, 3)), np.empty((0, 3)), 1.0 + if simplices.ndim != 2 or simplices.shape[1] != 4: + raise NotImplementedError("The demag MVP currently supports tetrahedral 3D meshes.") + + token = (id(mesh.raw_mesh), id(self._fields["m"])) + if self._demag_cache_token == token and self._demag_dipoles is not None: + return self._demag_dipoles + + m = np.asarray(self._fields["m"], dtype=float) + centroids = np.mean(points[simplices], axis=1) + volumes = _simulation_compatibility_binding( + "_simplex_volumes", + _simplex_volumes, + )(points, simplices) + regions = list(mesh.regions or [1] * len(simplices)) + ms_values = np.asarray( + [self._simplex_material_ms(region) for region in regions], dtype=float + ) + cell_m = np.mean(m[simplices], axis=1) + dipole_moments = cell_m * (ms_values * volumes)[:, np.newaxis] + softening = 0.25 * _mean_edge_length(points, simplices) + self._demag_cache_token = token + self._demag_dipoles = (centroids, dipole_moments, softening) + return self._demag_dipoles + + def _probe_demag_at(self, pos: Sequence[float]) -> np.ndarray: + centroids, dipole_moments, softening = self._build_demag_dipoles() + if len(centroids) == 0: + return np.zeros(3, dtype=float) + + r = np.asarray(pos, dtype=float) - centroids + r2 = np.sum(r * r, axis=1) + softening * softening + r3 = r2**1.5 + r5 = r2**2.5 + mdotr = np.sum(dipole_moments * r, axis=1) + terms = (3.0 * r * mdotr[:, np.newaxis] / r5[:, np.newaxis]) - ( + dipole_moments / r3[:, np.newaxis] + ) + return MU0_OVER_4PI * np.sum(terms, axis=0) diff --git a/src/nmag/simulation/demag/fields/recovery.py b/src/nmag/simulation/demag/fields/recovery.py new file mode 100644 index 0000000..0a6888f --- /dev/null +++ b/src/nmag/simulation/demag/fields/recovery.py @@ -0,0 +1,207 @@ +from __future__ import annotations + +import time +from typing import TYPE_CHECKING, Any + +import numpy as np + +from ....backends import ( + _load_rust_accelerator, + _selected_demag_cell_average_backend, + _selected_demag_nodal_recovery_backend, +) +from ...support import _simulation_compatibility_binding + + +class SimulationDemagRecoveryMixin: + if TYPE_CHECKING: + _demag_nodal_cache: np.ndarray | None + _demag_cell_field_cache: tuple[np.ndarray, np.ndarray] | None + + def __getattr__(self, name: str) -> Any: ... + + def _get_demag_nodal_field(self) -> np.ndarray: + token = self._demag_token() + if self._demag_cache_token == token and self._demag_nodal_cache is not None: + return self._demag_nodal_cache + + points = self._mesh_points() + simplices = np.asarray(self._require_mesh().simplices, dtype=int) + if simplices.size == 0: + self._demag_cache_token = token + self._demag_nodal_cache = np.zeros((len(points), 3), dtype=float) + return self._demag_nodal_cache + if simplices.ndim != 2 or simplices.shape[1] != 4: + raise NotImplementedError("The demag MVP currently supports tetrahedral 3D meshes.") + + cell_h, volumes = self._get_demag_cell_field() + with self._record_active_subfield_array_timing_block( + "H_demag_detail:nodal_recovery", + ): + weights = self._incident_cell_volume_sums(points, simplices, volumes) + backend = _selected_demag_nodal_recovery_backend( + len(simplices), getattr(self, "config", None) + ) + with self._record_active_subfield_array_timing_block( + f"H_demag_detail:nodal_recovery:{backend}", + ): + if backend == "rust": + nodal_h = self._recover_demag_nodal_field_rust( + simplices, + volumes, + cell_h, + weights, + len(points), + ) + else: + nodal_h = self._recover_demag_nodal_field_python( + simplices, + volumes, + cell_h, + weights, + len(points), + ) + self._demag_cache_token = token + self._demag_nodal_cache = nodal_h + return nodal_h + + def _get_demag_cell_field(self) -> tuple[np.ndarray, np.ndarray]: + cache_started = time.perf_counter() + token = self._demag_token() + if self._demag_cell_cache_token == token and self._demag_cell_field_cache is not None: + self._record_active_subfield_array_timing( + "H_demag_detail:cell_field_cache_hit", + time.perf_counter() - cache_started, + ) + return self._demag_cell_field_cache + + points = self._mesh_points() + simplices = np.asarray(self._require_mesh().simplices, dtype=int) + if simplices.size == 0: + cell_h = np.zeros((0, 3), dtype=float) + volumes = np.zeros(0, dtype=float) + self._demag_cell_cache_token = token + self._demag_cell_field_cache = (cell_h, volumes) + return self._demag_cell_field_cache + if simplices.ndim != 2 or simplices.shape[1] != 4: + raise NotImplementedError("The demag MVP currently supports tetrahedral 3D meshes.") + + with self._record_active_subfield_array_timing_block( + "H_demag_detail:auxiliary_fields", + ): + phi, _rho, volumes = self._get_demag_auxiliary_fields() + with self._record_active_subfield_array_timing_block( + "H_demag_detail:cell_gradient", + ): + cell_h = self._cell_demag_from_potential(points, simplices, phi) + self._demag_cell_cache_token = token + self._demag_cell_field_cache = (cell_h, volumes) + return self._demag_cell_field_cache + + def _demag_cell_field_average(self) -> np.ndarray: + cell_h, volumes = self._get_demag_cell_field() + backend = _selected_demag_cell_average_backend( + len(volumes), getattr(self, "config", None) + ) + with self._record_active_subfield_array_timing_block( + "H_demag_detail:cell_average", + ): + with self._record_active_subfield_array_timing_block( + f"H_demag_detail:cell_average:{backend}", + ): + if backend == "rust": + return self._demag_cell_field_average_rust(cell_h, volumes) + return self._demag_cell_field_average_python(cell_h, volumes) + + def _demag_cell_field_average_python( + self, + cell_h: np.ndarray, + volumes: np.ndarray, + ) -> np.ndarray: + if cell_h.size == 0 or volumes.size == 0: + return np.zeros(3, dtype=float) + positive = volumes > 0.0 + if not np.any(positive): + return np.zeros(3, dtype=float) + + if np.all(positive): + total_volume = float(np.sum(volumes)) + if total_volume <= 0.0: + return np.zeros(3, dtype=float) + return np.dot(volumes, cell_h) / total_volume + + total_volume = float(np.sum(volumes[positive])) + if total_volume <= 0.0: + return np.zeros(3, dtype=float) + return np.sum(cell_h[positive] * volumes[positive, np.newaxis], axis=0) / total_volume + + def _demag_cell_field_average_rust( + self, + cell_h: np.ndarray, + volumes: np.ndarray, + ) -> np.ndarray: + rust_accel = _simulation_compatibility_binding( + "_load_rust_accelerator", + _load_rust_accelerator, + )("NmagConfig.accelerator['cell_average']") + try: + demag_cell_field_average = rust_accel.demag_cell_field_average + except AttributeError as exc: + raise RuntimeError( + "NmagConfig.accelerator['cell_average']='rust' requires a nmag_accel build " + "with demag_cell_field_average support." + ) from exc + return np.asarray( + demag_cell_field_average( + np.asarray(cell_h, dtype=np.float64), + np.asarray(volumes, dtype=np.float64), + ), + dtype=float, + ) + + def _recover_demag_nodal_field_python( + self, + simplices: np.ndarray, + volumes: np.ndarray, + cell_h: np.ndarray, + weights: np.ndarray, + point_count: int, + ) -> np.ndarray: + nodal_h = np.zeros((point_count, 3), dtype=float) + positive = volumes > 0.0 + if np.any(positive): + positive_simplices = simplices[positive] + weighted_cell_h = cell_h[positive] * volumes[positive, np.newaxis] + for local_index in range(4): + np.add.at( + nodal_h, + positive_simplices[:, local_index], + weighted_cell_h, + ) + + present = weights > 0.0 + nodal_h[present] /= weights[present, np.newaxis] + return nodal_h + + def _recover_demag_nodal_field_rust( + self, + simplices: np.ndarray, + volumes: np.ndarray, + cell_h: np.ndarray, + weights: np.ndarray, + point_count: int, + ) -> np.ndarray: + rust_accel = _simulation_compatibility_binding( + "_load_rust_accelerator", + _load_rust_accelerator, + )("NmagConfig.accelerator['nodal_recovery']") + return np.asarray( + rust_accel.recover_demag_nodal_field( + np.asarray(simplices, dtype=np.int64), + np.asarray(volumes, dtype=np.float64), + np.asarray(cell_h, dtype=np.float64), + np.asarray(weights, dtype=np.float64), + int(point_count), + ), + dtype=float, + ) diff --git a/src/nmag/simulation/demag/solver.py b/src/nmag/simulation/demag/solver.py new file mode 100644 index 0000000..b662c1d --- /dev/null +++ b/src/nmag/simulation/demag/solver.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from ..exchange import SimulationExchangeMixin +from .bem import SimulationDemagBemMixin +from .fem import SimulationDemagFemMixin +from .fields import SimulationDemagFieldsMixin +from .fields.probe import SimulationDemagProbeMixin + + +class SimulationDemagMixin( + SimulationExchangeMixin, + SimulationDemagFieldsMixin, + SimulationDemagFemMixin, + SimulationDemagBemMixin, + SimulationDemagProbeMixin, +): + """Compose the exchange, demag field, FEM, BEM, and probe implementations.""" diff --git a/src/nmag/simulation/dynamics/__init__.py b/src/nmag/simulation/dynamics/__init__.py new file mode 100644 index 0000000..a91d761 --- /dev/null +++ b/src/nmag/simulation/dynamics/__init__.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import time +from typing import TYPE_CHECKING, Any, cast + +from simulation import hysteresis as hysteresis_module + +from ...backends import _selected_integrator_backend +from ..implicit_dynamics import relax_with_diffsol +from ..support import _simulation_compatibility_binding +from .integrator import ( + SimulationIntegratorMixin, +) +from .integrator import ( + _dop853_class as _dop853_class, # noqa: F401 +) + + +class SimulationDynamicsMixin(SimulationIntegratorMixin): + if TYPE_CHECKING: + + def __getattr__(self, name: str) -> Any: ... + + def do_next_stage(self, stage: int | None = None) -> None: + self.max_dm_dt = None + self.convergence.reset() + self.clock.inc_stage(stage=stage) + self._stage_wall_started = time.perf_counter() + self._invalidate_integrator() + + def is_converged(self) -> bool: + if self.max_dm_dt is None: + self.clock.convergence = False + return False + converged = self.convergence.check(self.step, self.max_dm_dt, self.stopping_dm_dt) + self.clock.convergence = converged + return converged + + @staticmethod + def hysteresis_next_stage(sim: Any) -> None: + sim.clock.stage_end = True + + @staticmethod + def hysteresis_exit(sim: Any) -> None: + sim.clock.exit_hysteresis = True + sim.clock.stage_end = True + + simulation_relax = hysteresis_module.simulation_relax + + def relax( + self, + H_applied: Any = None, + save: list[tuple[object, ...]] | None = None, + do: list[tuple[object, ...]] | None = None, + convergence_check: Any = None, + ) -> None: + """Relax magnetization until the convergence schedule completes. + + Args: + H_applied: Optional applied-field value for compatibility with the + staged relaxation runner. + save: Scheduled save tuples such as + ``[("averages", every("step", 10))]``. Omit for averages and + fields at stage end. + do: Scheduled action tuples. Omit for the default stage lifecycle. + convergence_check: Custom :class:`when.When` condition. Omit for + the accepted-step convergence cadence. + + Raises: + NotImplementedError: If a custom schedule is requested with the + experimental Diffsol backend. + """ + backend = _simulation_compatibility_binding( + "_selected_integrator_backend", _selected_integrator_backend + )(getattr(self, "config", None)) + if backend == "scipy": + hysteresis_module.simulation_relax( + cast(Any, self), + H_applied=H_applied, + save=save, + do=do, + convergence_check=convergence_check, + ) + return + if save is not None or do not in (None, []) or convergence_check is not None: + raise NotImplementedError( + "The experimental Diffsol backend currently supports only the default " + "relaxation save and convergence schedule." + ) + relax_with_diffsol(self, H_applied=H_applied) + + simulation_hysteresis = hysteresis_module.simulation_hysteresis diff --git a/src/nmag/simulation/dynamics/advance.py b/src/nmag/simulation/dynamics/advance.py new file mode 100644 index 0000000..b25a50a --- /dev/null +++ b/src/nmag/simulation/dynamics/advance.py @@ -0,0 +1,199 @@ +from __future__ import annotations + +import time +from typing import TYPE_CHECKING, Any + +import numpy as np +from numpy.typing import NDArray + +from si.physical import SI + +from ...dynamics import IntegratorStats +from ..support import _si_unit + +FloatArray = NDArray[np.float64] + + +class SimulationTimeAdvanceMixin: + if TYPE_CHECKING: + _integrator: Any + _integrator_is_stale: bool + _integrator_rhs_evaluations: int + _last_integrator_stats: IntegratorStats + + def __getattr__(self, name: str) -> Any: ... + + def advance_time( + self, + target_time: SI, + max_it: int = -1, + exact_tstop: bool | None = None, + ) -> SI: + """Advance adaptive LLG integration toward a physical target time. + + Args: + target_time: Absolute stage time to reach. + max_it: Maximum accepted steps, or ``-1`` for no explicit cap. + exact_tstop: Override whether a step crossing the target is + reconstructed at exactly the requested time. + + Returns: + The physical time reached. It can precede ``target_time`` when + ``max_it`` limits the operation. + + Raises: + ValueError: If the target precedes the current time or ``max_it`` + is invalid. + RuntimeError: If the numerical integrator fails. + """ + target_seconds, current_seconds = self._advance_request(target_time, max_it) + if target_seconds == current_seconds or max_it == 0: + return SI(current_seconds, "s") + integrator = self._active_integrator(current_seconds) + state_before: FloatArray = np.array(integrator.y, dtype=np.float64, copy=True) + time_before = float(integrator.t) + rhs_before = self._integrator_rhs_evaluations + accepted_steps, last_step_seconds = 0, 0.0 + failed = False + status = "running" + started = time.perf_counter() + try: + integrator, accepted_steps, last_step_seconds = self._advance_integrator( + integrator, + target_seconds, + max_it, + self._integrator_config.exact_tstop if exact_tstop is None else exact_tstop, + time_before, + ) + reached_seconds = self._accept_integrated_state( + integrator, + state_before, + time_before, + accepted_steps, + last_step_seconds, + started, + ) + status = str(integrator.status) + return SI(reached_seconds, "s") + except Exception: + failed = True + status = "failed" + raise + finally: + self._last_integrator_stats = IntegratorStats( + accepted_steps=accepted_steps, + rhs_evaluations=self._integrator_rhs_evaluations - rhs_before, + rejected_steps=None, + failed=failed, + status=status, + last_step_seconds=last_step_seconds, + simulated_seconds=max( + 0.0, float(getattr(integrator, "t", time_before)) - time_before + ), + wall_seconds=time.perf_counter() - started, + ) + + def _advance_request(self, target_time: SI, max_it: int) -> tuple[float, float]: + target_seconds = target_time.in_units_of(_si_unit("s")) + current_seconds = self.clock.time_reached_si.in_units_of(_si_unit("s")) + if target_seconds < current_seconds: + raise ValueError("target_time cannot be earlier than the current stage time.") + if max_it < -1: + raise ValueError("max_it must be -1 or a non-negative integer.") + return target_seconds, current_seconds + + def _active_integrator(self, current_seconds: float) -> Any: + if self._integrator is None or self._integrator_is_stale: + self.reinitialise(initial_time=current_seconds) + if self._integrator is None: + raise RuntimeError("The time integrator was not initialised.") + return self._integrator + + def _advance_integrator( + self, + integrator: Any, + target_seconds: float, + max_it: int, + exact: bool, + time_before: float, + ) -> tuple[Any, int, float]: + accepted_steps, last_step_seconds = 0, 0.0 + while self._can_continue_advancing(integrator, target_seconds, max_it, accepted_steps): + previous_time = float(integrator.t) + message = integrator.step() + if integrator.status == "failed": + raise RuntimeError(message or "DOP853 failed to advance the LLG state.") + accepted_steps += 1 + last_step_seconds = float(integrator.t) - previous_time + if exact and float(integrator.t) > target_seconds: + integrator = self._restart_at_exact_target( + integrator, + target_seconds, + time_before, + ) + last_step_seconds = target_seconds - previous_time + break + return integrator, accepted_steps, last_step_seconds + + @staticmethod + def _can_continue_advancing( + integrator: Any, + target_seconds: float, + max_it: int, + accepted_steps: int, + ) -> bool: + return float(integrator.t) < target_seconds and (max_it < 0 or accepted_steps < max_it) + + def _restart_at_exact_target( + self, + integrator: Any, + target_seconds: float, + time_before: float, + ) -> Any: + accepted_state = np.asarray(integrator.dense_output()(target_seconds), dtype=np.float64) + self._fields["m"] = np.array(np.reshape(accepted_state, (-1, 3)), copy=True) + self._invalidate_demag() + self._integrator_is_stale = True + self.reinitialise(initial_time=target_seconds) + if self._integrator is None: + raise RuntimeError("The time integrator could not be reinitialised.") + # Reinitialisation positions the solver at the target. Keep the clock at + # the start of this advance until record_advance accounts for the delta. + self.clock.stage_time = SI(time_before, "s") + return self._integrator + + def _accept_integrated_state( + self, + integrator: Any, + state_before: FloatArray, + time_before: float, + accepted_steps: int, + last_step_seconds: float, + started: float, + ) -> float: + accepted_state = np.asarray(integrator.y, dtype=np.float64) + if not np.all(np.isfinite(accepted_state)): + raise FloatingPointError("The integrator accepted a non-finite magnetisation state.") + reached_seconds = float(integrator.t) + self._fields["m"] = np.array(np.reshape(accepted_state, (-1, 3)), copy=True) + self._invalidate_demag() + elapsed_seconds = reached_seconds - time_before + self.clock.record_advance( + stage_time_seconds=reached_seconds, + accepted_steps=accepted_steps, + last_step_seconds=last_step_seconds, + wall_seconds=time.perf_counter() - started, + ) + self._record_max_dm_dt(accepted_state, state_before, elapsed_seconds) + return reached_seconds + + def _record_max_dm_dt( + self, + accepted_state: FloatArray, + state_before: FloatArray, + elapsed_seconds: float, + ) -> None: + if elapsed_seconds <= 0.0: + return + displacement: FloatArray = np.reshape(accepted_state - state_before, (-1, 3)) + self.max_dm_dt = float(np.max(np.linalg.norm(displacement, axis=1)) / elapsed_seconds) diff --git a/src/nmag/simulation/dynamics/integrator.py b/src/nmag/simulation/dynamics/integrator.py new file mode 100644 index 0000000..ec17c56 --- /dev/null +++ b/src/nmag/simulation/dynamics/integrator.py @@ -0,0 +1,263 @@ +from __future__ import annotations + +import sys +import time +from collections.abc import Callable +from typing import TYPE_CHECKING, Any, Protocol, cast + +import numpy as np +from numpy.typing import NDArray + +from si.physical import SI + +from ...backends import _selected_llg_backend +from ...dynamics import ( + DEFAULT_STOPPING_DM_DT, + ConvergenceTracker, + IntegratorConfig, + IntegratorStats, +) +from ..support import _si_unit, _simulation_compatibility_binding +from .advance import SimulationTimeAdvanceMixin + +FloatArray = NDArray[np.float64] +Action = Callable[[Any], Any] + + +class _DenseOutput(Protocol): + def __call__(self, time_seconds: float) -> FloatArray: ... + + +class _OdeIntegrator(Protocol): + t: float + y: FloatArray + status: str + + def step(self) -> str | None: ... + + def dense_output(self) -> _DenseOutput: ... + + +def _dop853_class() -> Callable[..., _OdeIntegrator]: + from scipy.integrate import DOP853 + + return cast(Callable[..., _OdeIntegrator], DOP853) + + +def _dop853_constructor() -> Callable[..., _OdeIntegrator]: + """Honor the historical module-level test and extension override.""" + dynamics_module = sys.modules.get("nmag.simulation.dynamics") + return getattr(dynamics_module, "_dop853_class", _dop853_class)() + + +class SimulationIntegratorSetupMixin: + if TYPE_CHECKING: + _integrator: _OdeIntegrator | None + _integrator_is_stale: bool + _integrator_rhs_evaluations: int + _integrator_config: IntegratorConfig + _integrator_effective_max_step_seconds: float + _last_integrator_stats: IntegratorStats + _stage_wall_started: float + + def __getattr__(self, name: str) -> Any: ... + + def _initialise_dynamics(self) -> None: + self._integrator = None + self._integrator_is_stale = True + self._integrator_rhs_evaluations = 0 + self._integrator_config = IntegratorConfig() + self._integrator_effective_max_step_seconds = self._integrator_config.maximum_step_seconds + self._last_integrator_stats = IntegratorStats() + self._stage_wall_started = time.perf_counter() + self.stopping_dm_dt = DEFAULT_STOPPING_DM_DT + self.max_dm_dt: float | None = None + self.convergence = ConvergenceTracker() + self.max_time_reached = SI(1.0, "s") + self._restarting = False + self.action_abbreviations: dict[str, Action] = { + # Legacy relaxation emits separate average and field rows at stage end. + "save_averages": lambda sim: sim.save_data(), + "save_fields": lambda sim: sim.save_data(fields="all"), + "save_restart": lambda sim: sim.save_restart_file(), + "do_next_stage": self.hysteresis_next_stage, + "do_exit": self.hysteresis_exit, + } + + @property + def last_integrator_stats(self) -> IntegratorStats: + return self._last_integrator_stats + + @property + def integrator_config(self) -> IntegratorConfig: + config = self._integrator_config + return IntegratorConfig( + relative_tolerance=config.relative_tolerance, + absolute_tolerance=config.absolute_tolerance, + maximum_step_seconds=config.maximum_step_seconds, + exact_tstop=config.exact_tstop, + ) + + @property + def effective_integrator_max_step(self) -> SI: + return SI(self._integrator_effective_max_step_seconds, "s") + + def _invalidate_integrator(self) -> None: + self._integrator_is_stale = True + + def set_params( + self, + stopping_dm_dt: SI | float | None = None, + ts_rel_tol: float | None = None, + ts_abs_tol: float | None = None, + exact_tstop: bool | None = None, + ts_max_step: SI | float | None = None, + ) -> None: + """Update convergence and DOP853 integration controls. + + Args: + stopping_dm_dt: Positive convergence rate in 1/s. + ts_rel_tol: Positive relative error tolerance. + ts_abs_tol: Positive absolute error tolerance. + exact_tstop: Whether :meth:`advance_time` normally reconstructs + state exactly at its requested target. + ts_max_step: Positive configured maximum step in seconds. Exchange + stability can impose a smaller effective maximum. + + Raises: + ValueError: If a supplied rate, tolerance, or step is not finite + and positive. + """ + if stopping_dm_dt is not None: + value = ( + stopping_dm_dt.in_units_of(_si_unit("1/s")) + if isinstance(stopping_dm_dt, SI) + else float(stopping_dm_dt) + ) + if not np.isfinite(value) or value <= 0.0: + raise ValueError("stopping_dm_dt must be a positive finite rate.") + self.stopping_dm_dt = value + if ts_rel_tol is not None: + self._integrator_config.relative_tolerance = float(ts_rel_tol) + if ts_abs_tol is not None: + self._integrator_config.absolute_tolerance = float(ts_abs_tol) + if exact_tstop is not None: + self._integrator_config.exact_tstop = bool(exact_tstop) + if ts_max_step is not None: + self._integrator_config.maximum_step_seconds = ( + ts_max_step.in_units_of(_si_unit("s")) + if isinstance(ts_max_step, SI) + else float(ts_max_step) + ) + self._integrator_config.validate() + self._invalidate_integrator() + + def _llg_time_derivative(self, _time_seconds: float, state: FloatArray) -> FloatArray: + self._integrator_rhs_evaluations += 1 + m: FloatArray = np.reshape(np.asarray(state, dtype=np.float64), (-1, 3)) + if not np.all(np.isfinite(m)): + raise FloatingPointError("The integrator supplied a non-finite magnetisation state.") + + self._fields["m"] = m + self._invalidate_demag() + h_total = self._subfield_array("H_total") + pin = self._subfield_array("pin") + unit_scale = np.ones(len(m), dtype=float) + coefficients = self._nodal_material_coefficients() + dm_dcurrent = self._get_dm_dcurrent() if "current_density" in self._fields else None + backend = _simulation_compatibility_binding("_selected_llg_backend", _selected_llg_backend)( + len(m), getattr(self, "config", None) + ) + with np.errstate(over="ignore", invalid="ignore"): + if backend == "rust": + derivative = self._llg_rhs_rust( + m, + h_total, + pin, + unit_scale, + coefficients.precession, + coefficients.damping, + coefficients.normalisation, + dm_dcurrent, + coefficients.stt_adiabatic, + coefficients.stt_nonadiabatic, + ) + else: + derivative = self._llg_rhs_python( + m, + h_total, + pin, + unit_scale, + coefficients.precession, + coefficients.damping, + coefficients.normalisation, + dm_dcurrent, + coefficients.stt_adiabatic, + coefficients.stt_nonadiabatic, + ) + if not np.all(np.isfinite(derivative)): + raise FloatingPointError("The LLG right-hand side produced non-finite values.") + return np.ravel(np.asarray(derivative, dtype=np.float64)) + + def reinitialise( + self, + rel_tolerance: float | None = None, + abs_tolerance: float | None = None, + initial_time: SI | float | None = None, + ) -> None: + """Rebuild the adaptive integrator from the current physical state. + + Args: + rel_tolerance: Optional new relative tolerance. + abs_tolerance: Optional new absolute tolerance. + initial_time: Non-negative physical start time. Omit it to retain + the simulation clock. + + Raises: + RuntimeError: If mesh or magnetization has not been set. + ValueError: If the time or tolerances are invalid. + """ + if rel_tolerance is not None or abs_tolerance is not None: + self.set_params(ts_rel_tol=rel_tolerance, ts_abs_tol=abs_tolerance) + if self.mesh is None or "m" not in self._fields: + raise RuntimeError("A mesh and magnetisation are required before time integration.") + + if initial_time is None: + initial_seconds = self.clock.time_reached_si.in_units_of(_si_unit("s")) + elif isinstance(initial_time, SI): + initial_seconds = initial_time.in_units_of(_si_unit("s")) + else: + initial_seconds = float(initial_time) + if not np.isfinite(initial_seconds) or initial_seconds < 0.0: + raise ValueError("initial_time must be a finite non-negative time.") + + self._integrator_config.validate() + state: FloatArray = np.array( + np.ravel(np.asarray(self._fields["m"], dtype=np.float64)), + dtype=np.float64, + copy=True, + ) + upper_bound = self.max_time_reached.in_units_of(_si_unit("s")) + if upper_bound <= initial_seconds: + upper_bound = float(np.nextafter(initial_seconds, np.inf)) + self._integrator_effective_max_step_seconds = min( + self._integrator_config.maximum_step_seconds, + self._exchange_explicit_step_limit_seconds(), + ) + self._integrator = _dop853_constructor()( + self._llg_time_derivative, + initial_seconds, + state, + upper_bound, + rtol=self._integrator_config.relative_tolerance, + atol=self._integrator_config.absolute_tolerance, + max_step=self._integrator_effective_max_step_seconds, + ) + self.clock.time_reached_su = initial_seconds + self.clock.time_reached_si = SI(initial_seconds, "s") + self.clock.stage_time = SI(initial_seconds, "s") + self._integrator_is_stale = False + + +class SimulationIntegratorMixin(SimulationIntegratorSetupMixin, SimulationTimeAdvanceMixin): + """Compose adaptive-integrator setup with its stepping lifecycle.""" diff --git a/src/nmag/simulation/exchange/__init__.py b/src/nmag/simulation/exchange/__init__.py new file mode 100644 index 0000000..5ab8dca --- /dev/null +++ b/src/nmag/simulation/exchange/__init__.py @@ -0,0 +1,13 @@ +"""Exchange and LLG compatibility façade composed from focused mixins.""" + +from .coefficients import SimulationExchangeCoefficientMixin +from .fields import SimulationExchangeFieldMixin +from .llg_rhs import SimulationLlgRhsMixin + + +class SimulationExchangeMixin( + SimulationExchangeCoefficientMixin, + SimulationLlgRhsMixin, + SimulationExchangeFieldMixin, +): + """Provide material coefficients, LLG kernels, and exchange/STT fields.""" diff --git a/src/nmag/simulation/exchange/coefficients.py b/src/nmag/simulation/exchange/coefficients.py new file mode 100644 index 0000000..55ab264 --- /dev/null +++ b/src/nmag/simulation/exchange/coefficients.py @@ -0,0 +1,185 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import numpy as np + +from si.physical import SI + +from ...dynamics import DOP853_EXCHANGE_RESOLUTION_RADIUS, NodalMaterialCoefficients +from ..support import MU0, _si_dimensionless, _si_unit + + +class SimulationExchangeCoefficientMixin: + if TYPE_CHECKING: + _exchange_spectral_bound_cache: tuple[int, float] | None + _nodal_material_coefficients_cache: tuple[int, NodalMaterialCoefficients] | None + + def __getattr__(self, name: str) -> Any: ... + + @staticmethod + def _material_dynamic_coefficients( + material: Any, + ) -> tuple[float, float, float, float, float, float]: + exchange = material.exchange_coupling.in_units_of(_si_unit("J/m")) + ms = material.Ms.in_units_of(_si_unit("A/m")) + exchange_prefactor = 0.0 if ms == 0.0 else 2.0 * exchange / (MU0 * ms) + gamma = material.llg_gamma_G.in_units_of(_si_unit("m/A/s")) + damping = ( + material.llg_damping.in_units_of(_si_dimensionless()) + if isinstance(material.llg_damping, SI) + else float(material.llg_damping) + ) + gilbert_to_ll = 1.0 / (1.0 + damping * damping) + precession = -gamma * gilbert_to_ll + damping_coeff = precession * damping + if material.do_precession is False: + precession = 0.0 + normalisation = material.llg_normalisationfactor.in_units_of(_si_unit("1/s")) + stt_adiabatic = material.su_llg_stt_adiab.in_units_of(_si_unit("m^3/A/s")) + stt_nonadiabatic = material.su_llg_stt_nadiab.in_units_of(_si_unit("m^3/A/s")) + return ( + exchange_prefactor, + precession, + damping_coeff, + normalisation, + stt_adiabatic, + stt_nonadiabatic, + ) + + def _nodal_material_coefficients(self) -> NodalMaterialCoefficients: + token = self._mesh_geometry_token() + cached = self._nodal_material_coefficients_cache + if cached is not None and cached[0] == token: + return cached[1] + + point_count = len(self._mesh_points()) + simplices = np.asarray(self._require_mesh().simplices, dtype=int) + regions = np.asarray(self._require_mesh().regions or [1] * len(simplices), dtype=int) + if len(regions) != len(simplices): + raise ValueError("Mesh regions must contain one entry per simplex.") + + region_coefficients = { + int(region): self._material_dynamic_coefficients(self._simplex_material(int(region))) + for region in np.unique(regions) + } + if not region_coefficients: + defaults = ( + self._material_dynamic_coefficients(self.materials[0]) + if self.materials + else ( + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + ) + ) + arrays = tuple(np.full(point_count, value, dtype=float) for value in defaults) + else: + nodal_regions = np.full(point_count, -1, dtype=int) + for region, coefficients in region_coefficients.items(): + region_nodes = np.unique(simplices[regions == region].ravel()) + assigned = nodal_regions[region_nodes] + for previous_region in np.unique(assigned[assigned >= 0]): + previous = region_coefficients[int(previous_region)] + if not np.allclose(previous, coefficients, rtol=1.0e-12, atol=0.0): + conflict_nodes = region_nodes[assigned == previous_region] + raise NotImplementedError( + "A mesh node shared by regions with different exchange or LLG " + f"coefficients requires material-specific magnetisation DOFs; node " + f"{int(conflict_nodes[0])} belongs to regions {int(previous_region)} " + f"and {region}." + ) + nodal_regions[region_nodes[assigned < 0]] = region + + first_region = next(iter(region_coefficients)) + nodal_regions[nodal_regions < 0] = first_region + arrays = tuple( + np.asarray( + [region_coefficients[int(region)][component] for region in nodal_regions], + dtype=float, + ) + for component in range(6) + ) + + coefficients = NodalMaterialCoefficients(*arrays) + self._nodal_material_coefficients_cache = (token, coefficients) + return coefficients + + def _exchange_prefactor(self) -> float: + if not self.materials: + return 0.0 + + prefactors = [ + self._material_dynamic_coefficients(material)[0] for material in self.materials + ] + + first = prefactors[0] + if any( + abs(prefactor - first) > 1.0e-12 * max(1.0, abs(first)) for prefactor in prefactors[1:] + ): + raise NotImplementedError("The exchange MVP currently supports one exchange prefactor.") + return float(first) + + def _llg_coefficients(self) -> tuple[float, float, float]: + if not self.materials: + return 0.0, 0.0, 0.0 + + coefficients = [ + self._material_dynamic_coefficients(material)[1:] for material in self.materials + ] + + first = coefficients[0] + for coeffs in coefficients[1:]: + if any( + abs(value - reference) > 1.0e-12 * max(1.0, abs(reference)) + for value, reference in zip(coeffs, first, strict=True) + ): + raise NotImplementedError( + "The dmdt MVP currently supports one LLG coefficient set." + ) + return (float(first[0]), float(first[1]), float(first[2])) + + def _exchange_laplacian_spectral_bound(self) -> float: + """Return a cached upper bound for the lumped FEM Laplacian spectrum.""" + token = self._mesh_geometry_token() + if self._exchange_spectral_bound_cache is not None: + cached_token, cached_bound = self._exchange_spectral_bound_cache + if cached_token == token: + return cached_bound + + points = self._mesh_points() + simplices = np.asarray(self._require_mesh().simplices, dtype=int) + if simplices.size == 0: + bound = 0.0 + else: + stiffness, _gradients_by_cell, volumes = self._demag_fem_geometry_for_mesh( + points, simplices + ) + lumped_volumes = self._incident_cell_volume_sums(points, simplices, volumes) / 4.0 + present = lumped_volumes > 0.0 + if not np.any(present): + bound = 0.0 + else: + absolute_row_sums = np.asarray( + np.abs(stiffness[present]).sum(axis=1), + ).ravel() + bound = float(np.max(absolute_row_sums / lumped_volumes[present])) + + self._exchange_spectral_bound_cache = (token, bound) + return bound + + def _exchange_explicit_step_limit_seconds(self) -> float: + """Resolve stiff exchange modes accurately with the explicit integrator.""" + spectral_bound = self._exchange_laplacian_spectral_bound() + coefficients = self._nodal_material_coefficients() + dynamic_coefficients = np.hypot(coefficients.precession, coefficients.damping) + maximum_rate = ( + float(np.max(np.abs(coefficients.exchange_prefactor) * dynamic_coefficients)) + * spectral_bound + ) + if maximum_rate <= 0.0: + return float("inf") + return DOP853_EXCHANGE_RESOLUTION_RADIUS / maximum_rate diff --git a/src/nmag/simulation/exchange/fields.py b/src/nmag/simulation/exchange/fields.py new file mode 100644 index 0000000..6245df2 --- /dev/null +++ b/src/nmag/simulation/exchange/fields.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import numpy as np + + +class SimulationExchangeFieldMixin: + if TYPE_CHECKING: + + def __getattr__(self, name: str) -> Any: ... + + def _get_dm_dcurrent(self) -> np.ndarray: + if "current_density" not in self._fields: + return np.zeros((len(self._mesh_points()), 3), dtype=float) + points = self._mesh_points() + simplices = np.asarray(self._require_mesh().simplices, dtype=int) + if simplices.ndim != 2 or simplices.shape[1] != 4: + raise NotImplementedError("Spin-transfer torque currently supports tetrahedral meshes.") + stiffness, gradients_by_cell, volumes = self._demag_fem_geometry_for_mesh( + points, + simplices, + ) + del stiffness + m = np.asarray(self._fields["m"], dtype=float) + cell_gradients = np.einsum( + "cla,clj->caj", + m[simplices], + gradients_by_cell, + optimize=True, + ) + nodal_gradients = np.zeros((len(points), 3, 3), dtype=float) + weighted_gradients = cell_gradients * volumes[:, np.newaxis, np.newaxis] + for local_index in range(4): + np.add.at( + nodal_gradients, + simplices[:, local_index], + weighted_gradients, + ) + weights = self._incident_cell_volume_sums(points, simplices, volumes) + present = weights > 0.0 + nodal_gradients[present] /= weights[present, np.newaxis, np.newaxis] + current_density = np.asarray(self._fields["current_density"], dtype=float) + if current_density.shape != (len(points), 3): + raise ValueError( + "current_density must contain one 3-vector per mesh point; " + f"got {current_density.shape}." + ) + return np.einsum( + "nij,nj->ni", + nodal_gradients, + current_density, + optimize=True, + ) + + def _get_exchange_nodal_field(self) -> np.ndarray: + token = self._demag_token() + if self._exchange_cache_token == token: + return self._exchange_nodal_cache + + points = self._mesh_points() + simplices = np.asarray(self._require_mesh().simplices, dtype=int) + exchange = np.zeros((len(points), 3), dtype=float) + if simplices.size == 0: + self._exchange_cache_token = token + self._exchange_nodal_cache = exchange + return exchange + if simplices.ndim != 2 or simplices.shape[1] != 4: + raise NotImplementedError("The exchange MVP currently supports tetrahedral 3D meshes.") + if self._exchange_is_zero_by_construction(): + self._exchange_cache_token = token + self._exchange_nodal_cache = exchange + return exchange + + stiffness, _gradients_by_cell, volumes = self._demag_fem_geometry_for_mesh( + points, simplices + ) + lumped_volumes = ( + self._incident_cell_volume_sums( + points, + simplices, + volumes, + ) + / 4.0 + ) + + present = lumped_volumes > 0.0 + if np.any(present): + cofield = stiffness @ np.asarray(self._fields["m"], dtype=float) + prefactors = self._nodal_material_coefficients().exchange_prefactor + exchange[present] = -( + prefactors[present, np.newaxis] + * cofield[present] + / lumped_volumes[present, np.newaxis] + ) + + self._exchange_cache_token = token + self._exchange_nodal_cache = exchange + return exchange + + def _demag_token(self) -> tuple[int, int]: + if self.mesh is None: + raise RuntimeError("A mesh must be loaded before using demag fields.") + if "m" not in self._fields: + raise RuntimeError("Magnetisation must be set before using demag fields.") + return (id(self._require_mesh().raw_mesh), id(self._fields["m"])) diff --git a/src/nmag/simulation/exchange/llg_rhs.py b/src/nmag/simulation/exchange/llg_rhs.py new file mode 100644 index 0000000..a7490d3 --- /dev/null +++ b/src/nmag/simulation/exchange/llg_rhs.py @@ -0,0 +1,218 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import numpy as np + +from ...backends import _load_rust_accelerator +from ..support import _simulation_compatibility_binding + + +class SimulationLlgRhsMixin: + if TYPE_CHECKING: + + def __getattr__(self, name: str) -> Any: ... + + def _llg_rhs_python( + self, + m: np.ndarray, + h_total: np.ndarray, + pin: np.ndarray, + ms_values: np.ndarray, + precession_coeff: float | np.ndarray, + damping_coeff: float | np.ndarray, + normalisation_coeff: float | np.ndarray, + dm_dcurrent: np.ndarray | None = None, + stt_adiabatic_coeff: float | np.ndarray = 0.0, + stt_nonadiabatic_coeff: float | np.ndarray = 0.0, + ) -> np.ndarray: + m, h_total, pin, ms_values = self._validated_llg_inputs(m, h_total, pin, ms_values) + precession_values, damping_values, normalisation_values = self._nodal_coefficients( + len(m), + ( + ("precession", precession_coeff), + ("damping", damping_coeff), + ("normalisation", normalisation_coeff), + ), + ) + rhs, mdotm = self._llg_field_rhs( + m, + h_total, + precession_values, + damping_values, + normalisation_values, + ) + if dm_dcurrent is not None: + rhs += self._llg_stt_rhs( + m, + mdotm, + dm_dcurrent, + stt_adiabatic_coeff, + stt_nonadiabatic_coeff, + ) + return ms_values[:, np.newaxis] * rhs * pin[:, np.newaxis] + + @staticmethod + def _validated_llg_inputs( + m: np.ndarray, + h_total: np.ndarray, + pin: np.ndarray, + ms_values: np.ndarray, + ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + m = np.asarray(m, dtype=float) + h_total = np.asarray(h_total, dtype=float) + pin = np.asarray(pin, dtype=float) + ms_values = np.asarray(ms_values, dtype=float) + if m.ndim != 2 or m.shape[1] != 3: + raise ValueError(f"m must have shape (n, 3), got {m.shape}.") + if h_total.shape != m.shape: + raise ValueError(f"h_total must have shape {m.shape}, got {h_total.shape}.") + if pin.shape != (len(m),): + raise ValueError(f"pin must have shape ({len(m)},), got {pin.shape}.") + if ms_values.shape != (len(m),): + raise ValueError(f"ms_values must have shape ({len(m)},), got {ms_values.shape}.") + return m, h_total, pin, ms_values + + @staticmethod + def _nodal_coefficients( + point_count: int, + coefficients: tuple[tuple[str, float | np.ndarray], ...], + ) -> tuple[np.ndarray, ...]: + arrays: list[np.ndarray] = [] + for name, coefficient in coefficients: + array = np.asarray(coefficient, dtype=float) + if array.ndim == 0: + array = np.full(point_count, float(array), dtype=float) + if array.shape != (point_count,): + raise ValueError( + f"{name} coefficient must have shape ({point_count},), got {array.shape}." + ) + arrays.append(array) + return tuple(arrays) + + @staticmethod + def _llg_field_rhs( + m: np.ndarray, + h_total: np.ndarray, + precession_values: np.ndarray, + damping_values: np.ndarray, + normalisation_values: np.ndarray, + ) -> tuple[np.ndarray, np.ndarray]: + mxh = np.cross(m, h_total) + mdoth = np.einsum("ij,ij->i", m, h_total) + mdotm = np.einsum("ij,ij->i", m, m) + damping = m * mdoth[:, np.newaxis] - h_total * mdotm[:, np.newaxis] + norm_error = (1.0 - mdotm)[:, np.newaxis] * m + rhs = ( + precession_values[:, np.newaxis] * mxh + + damping_values[:, np.newaxis] * damping + + normalisation_values[:, np.newaxis] * norm_error + ) + return rhs, mdotm + + def _llg_stt_rhs( + self, + m: np.ndarray, + mdotm: np.ndarray, + dm_dcurrent: np.ndarray, + stt_adiabatic_coeff: float | np.ndarray, + stt_nonadiabatic_coeff: float | np.ndarray, + ) -> np.ndarray: + directional_derivative = np.asarray(dm_dcurrent, dtype=float) + if directional_derivative.shape != m.shape: + raise ValueError( + f"dm_dcurrent must have shape {m.shape}, got {directional_derivative.shape}." + ) + adiabatic_values, nonadiabatic_values = self._nodal_coefficients( + len(m), + ( + ("stt_adiabatic", stt_adiabatic_coeff), + ("stt_nonadiabatic", stt_nonadiabatic_coeff), + ), + ) + mx_directional = np.cross(m, directional_derivative) + mdot_directional = np.einsum("ij,ij->i", m, directional_derivative) + mxmx_directional = ( + m * mdot_directional[:, np.newaxis] - directional_derivative * mdotm[:, np.newaxis] + ) + return ( + adiabatic_values[:, np.newaxis] * mxmx_directional + + nonadiabatic_values[:, np.newaxis] * mx_directional + ) + + def _llg_rhs_rust( + self, + m: np.ndarray, + h_total: np.ndarray, + pin: np.ndarray, + ms_values: np.ndarray, + precession_coeff: float | np.ndarray, + damping_coeff: float | np.ndarray, + normalisation_coeff: float | np.ndarray, + dm_dcurrent: np.ndarray | None = None, + stt_adiabatic_coeff: float | np.ndarray = 0.0, + stt_nonadiabatic_coeff: float | np.ndarray = 0.0, + ) -> np.ndarray: + rust_accel = _simulation_compatibility_binding( + "_load_rust_accelerator", + _load_rust_accelerator, + )("NmagConfig.accelerator['llg']") + try: + llg_rhs = rust_accel.llg_rhs + except AttributeError as exc: + raise RuntimeError( + "NmagConfig.accelerator['llg']='rust' requires an nmag_accel build with llg_rhs. " + "Rebuild it with " + "`maturin develop --release --manifest-path rust/nmag_accel/Cargo.toml`." + ) from exc + coefficient_arrays = [ + np.asarray(value, dtype=float) + for value in ( + precession_coeff, + damping_coeff, + normalisation_coeff, + ) + ] + if dm_dcurrent is None and all(array.ndim == 0 for array in coefficient_arrays): + result = llg_rhs( + np.asarray(m, dtype=float), + np.asarray(h_total, dtype=float), + np.asarray(pin, dtype=float), + np.asarray(ms_values, dtype=float), + *(float(array) for array in coefficient_arrays), + ) + else: + try: + heterogeneous_rhs = ( + rust_accel.llg_rhs_heterogeneous + if dm_dcurrent is None + else rust_accel.llg_rhs_stt_heterogeneous + ) + except AttributeError as exc: + raise RuntimeError( + "NmagConfig.accelerator['llg']='rust' requires an nmag_accel build with " + "the heterogeneous LLG kernels. Rebuild the accelerator." + ) from exc + expanded = [ + np.full(len(m), float(array), dtype=float) if array.ndim == 0 else array + for array in coefficient_arrays + ] + arguments: list[np.ndarray] = [ + np.asarray(m, dtype=float), + np.asarray(h_total, dtype=float), + np.asarray(pin, dtype=float), + np.asarray(ms_values, dtype=float), + *expanded, + ] + if dm_dcurrent is not None: + stt_arrays = [ + np.asarray(value, dtype=float) + for value in (stt_adiabatic_coeff, stt_nonadiabatic_coeff) + ] + expanded_stt = [ + np.full(len(m), float(array), dtype=float) if array.ndim == 0 else array + for array in stt_arrays + ] + arguments.extend([np.asarray(dm_dcurrent, dtype=float), *expanded_stt]) + result = heterogeneous_rhs(*arguments) + return np.asarray(result, dtype=float) diff --git a/src/nmag/simulation/fields/__init__.py b/src/nmag/simulation/fields/__init__.py new file mode 100644 index 0000000..d6c8c0f --- /dev/null +++ b/src/nmag/simulation/fields/__init__.py @@ -0,0 +1,19 @@ +"""Focused field-operation mixins exposed through one compatibility façade.""" + +from .arrays import SimulationFieldArrayMixin +from .availability import SimulationFieldAvailabilityMixin +from .averages import SimulationFieldAverageMixin +from .derived import SimulationFieldDerivedMixin +from .maxangle import SimulationFieldMaxangleMixin +from .probes import SimulationFieldProbeMixin + + +class SimulationFieldMixin( + SimulationFieldAvailabilityMixin, + SimulationFieldAverageMixin, + SimulationFieldMaxangleMixin, + SimulationFieldProbeMixin, + SimulationFieldArrayMixin, + SimulationFieldDerivedMixin, +): + """Compose the public field API from focused implementation mixins.""" diff --git a/src/nmag/simulation/fields/arrays.py b/src/nmag/simulation/fields/arrays.py new file mode 100644 index 0000000..201de49 --- /dev/null +++ b/src/nmag/simulation/fields/arrays.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import time +from collections.abc import Generator +from contextlib import contextmanager +from typing import TYPE_CHECKING, Any + +import numpy as np + +from ..support import ( + _NO_CONSTANT_AVERAGE, + DERIVED_FIELD_NAMES, + _copy_average_value, +) + + +class SimulationFieldArrayMixin: + if TYPE_CHECKING: + _subfield_array_cache: dict[str, np.ndarray] | None + _subfield_average_cache: dict[tuple[str, str | None], object] | None + _active_subfield_array_timings: dict[str, float] | None + + def __getattr__(self, name: str) -> Any: ... + + def _subfield_array(self, subfieldname: str) -> np.ndarray: + started = time.perf_counter() + if self._subfield_array_cache is not None and subfieldname in self._subfield_array_cache: + self._record_active_subfield_array_timing( + f"{subfieldname}:cache_hit", + time.perf_counter() - started, + ) + return self._subfield_array_cache[subfieldname] + + data = self._compute_subfield_array(subfieldname) + self._record_active_subfield_array_timing( + f"{subfieldname}:compute", + time.perf_counter() - started, + ) + if self._subfield_array_cache is not None: + self._subfield_array_cache[subfieldname] = data + return data + + def _record_active_subfield_array_timing(self, name: str, seconds: float) -> None: + timings = self._active_subfield_array_timings + if timings is not None: + timings[name] = timings.get(name, 0.0) + seconds + + @contextmanager + def _record_active_subfield_array_timing_block(self, name: str) -> Generator[None]: + started = time.perf_counter() + try: + yield + finally: + self._record_active_subfield_array_timing( + name, + time.perf_counter() - started, + ) + + @contextmanager + def _subfield_array_cache_scope(self) -> Generator[None]: + previous_cache = self._subfield_array_cache + self._subfield_array_cache = previous_cache if previous_cache is not None else {} + try: + yield + finally: + self._subfield_array_cache = previous_cache + + @contextmanager + def _subfield_average_cache_scope(self) -> Generator[None]: + previous_cache = self._subfield_average_cache + self._subfield_average_cache = previous_cache if previous_cache is not None else {} + try: + yield + finally: + self._subfield_average_cache = previous_cache + + def _cached_subfield_average( + self, + key: tuple[str, str | None], + ) -> object: + cache = self._subfield_average_cache + if cache is None or key not in cache: + return _NO_CONSTANT_AVERAGE + return cache[key] + + def _cache_subfield_average( + self, + key: tuple[str, str | None], + value: object, + ) -> None: + cache = self._subfield_average_cache + if cache is not None: + cache[key] = _copy_average_value(value) + + def _compute_subfield_array(self, subfieldname: str) -> np.ndarray: + if subfieldname in DERIVED_FIELD_NAMES: + return self._derived_subfield_array(subfieldname) + if subfieldname == "H_demag": + return np.asarray(self._get_demag_nodal_field(), dtype=float) + if subfieldname == "H_ext": + h_ext = np.asarray(self._fields.get("H_ext", np.zeros(3)), dtype=float) + if h_ext.shape != (3,): + raise ValueError(f"H_ext must be a homogeneous 3-vector, got shape {h_ext.shape}.") + point_count = len(self.mesh.points) if self.mesh is not None else 1 + return np.tile(h_ext, (point_count, 1)) + if subfieldname not in self._fields: + raise KeyError(f"Unknown or unset subfield '{subfieldname}'.") + return np.asarray(self._fields[subfieldname], dtype=float) + + def _is_subfield_available(self, subfieldname: str) -> bool: + return self.is_subfield_available(subfieldname) diff --git a/src/nmag/simulation/fields/availability.py b/src/nmag/simulation/fields/availability.py new file mode 100644 index 0000000..d03adac --- /dev/null +++ b/src/nmag/simulation/fields/availability.py @@ -0,0 +1,188 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import numpy as np + +from si.physical import SI + +if TYPE_CHECKING: + from simulation.quantity import Quantity + +from ..support import ( + DERIVED_FIELD_NAMES, + _si_unit, + _simulation_compatibility_binding, +) + +_known_field_quantities_cache: list[Quantity] | None = None +_known_quantities_by_name_cache: dict[str, Quantity] | None = None + + +def _known_field_quantities() -> list[Quantity]: + global _known_field_quantities_cache + if _known_field_quantities_cache is None: + from simulation.quantity import known_field_quantities + + _known_field_quantities_cache = known_field_quantities + return _known_field_quantities_cache + + +def _known_quantities_by_name() -> dict[str, Quantity]: + global _known_quantities_by_name_cache + if _known_quantities_by_name_cache is None: + from simulation.quantity import known_quantities_by_name + + _known_quantities_by_name_cache = known_quantities_by_name + return _known_quantities_by_name_cache + + +class SimulationFieldAvailabilityMixin: + if TYPE_CHECKING: + + def __getattr__(self, name: str) -> Any: ... + + def get_all_field_names(self) -> list[str]: + """Return the field names currently available for saving or access.""" + has_mesh = self.mesh is not None + has_m = "m" in self._fields + has_mesh_and_m = has_mesh and has_m + has_tetrahedral_mesh = self._has_tetrahedral_mesh_or_empty() + return [ + quantity.name + for quantity in _simulation_compatibility_binding( + "_known_field_quantities", _known_field_quantities + )() + if self._is_subfield_available_from_state( + quantity.name, + has_mesh=has_mesh, + has_m=has_m, + has_mesh_and_m=has_mesh_and_m, + has_tetrahedral_mesh=has_tetrahedral_mesh, + ) + ] + + def is_subfield_available(self, subfieldname: str) -> bool: + """Return whether the current model can provide a named field. + + The check does not trigger expensive FEM/BEM work. Actual field access + can still raise a numerical error during calculation. + + Args: + subfieldname: Candidate field name. + + Returns: + True when the field is set or derivable from current state. + """ + if subfieldname == "H_ext": + return True + if subfieldname not in DERIVED_FIELD_NAMES and subfieldname != "H_demag": + return subfieldname in self._fields + + has_mesh = self.mesh is not None + has_m = "m" in self._fields + has_tetrahedral_mesh = self._has_tetrahedral_mesh_or_empty() + return self._is_subfield_available_from_state( + subfieldname, + has_mesh=has_mesh, + has_m=has_m, + has_mesh_and_m=has_mesh and has_m, + has_tetrahedral_mesh=has_tetrahedral_mesh, + ) + + def _is_subfield_available_from_state( + self, + subfieldname: str, + *, + has_mesh: bool, + has_m: bool, + has_mesh_and_m: bool, + has_tetrahedral_mesh: bool, + ) -> bool: + has_m_on_tetrahedral_mesh = self._has_m_on_tetrahedral_mesh( + has_mesh_and_m, + has_tetrahedral_mesh, + ) + state_availability = { + "pin": has_mesh, + "H_anis": has_mesh_and_m, + "E_anis": has_mesh_and_m, + "H_exch": has_m_on_tetrahedral_mesh, + "E_exch": has_m_on_tetrahedral_mesh, + "dm_dcurrent": self._has_current_density(has_m_on_tetrahedral_mesh), + "dmdt": has_m_on_tetrahedral_mesh, + "H_total": has_m_on_tetrahedral_mesh, + "E_total": has_m_on_tetrahedral_mesh, + "E_demag": self._has_demag_magnetisation(has_mesh_and_m), + "phi": self._has_demag_magnetisation(has_mesh_and_m), + "rho": self._has_demag_magnetisation(has_mesh_and_m), + "M": has_mesh_and_m, + "E_ext": has_mesh_and_m, + "H_demag": self._has_demag_nodal_field(has_m, has_tetrahedral_mesh), + "H_ext": True, + } + if subfieldname in state_availability: + return state_availability[subfieldname] + return subfieldname not in DERIVED_FIELD_NAMES and subfieldname in self._fields + + @staticmethod + def _has_m_on_tetrahedral_mesh(has_mesh_and_m: bool, has_tetrahedral_mesh: bool) -> bool: + return has_mesh_and_m and has_tetrahedral_mesh + + def _has_current_density(self, has_m_on_tetrahedral_mesh: bool) -> bool: + return has_m_on_tetrahedral_mesh and "current_density" in self._fields + + def _has_demag_magnetisation(self, has_mesh_and_m: bool) -> bool: + return self.do_demag and has_mesh_and_m + + def _has_demag_nodal_field(self, has_m: bool, has_tetrahedral_mesh: bool) -> bool: + return self.do_demag and has_m and has_tetrahedral_mesh + + def get_materials_of_field(self, field_name: str) -> list[Any]: + quantity = _simulation_compatibility_binding( + "_known_quantities_by_name", _known_quantities_by_name + )()[field_name] + if "?" in (quantity.signature or ""): + return self.materials + return [] + + def get_subfield(self, subfieldname: str, units: SI | None = None) -> Any: + """Return all nodal or cell values for one available field. + + Args: + subfieldname: Name returned by :meth:`get_all_field_names`. + units: Optional compatible SI unit for returned numeric values. + + Returns: + Field values as Python scalars or nested lists. + + Raises: + KeyError: If the field is unknown, unset, or disabled. + """ + data, field_units = self._subfield_data_and_units(subfieldname) + if units is None or field_units is None: + return data.tolist() if isinstance(data, np.ndarray) else data + return (np.asarray(data) * field_units.in_units_of(units)).tolist() + + def _subfield_data_and_units(self, subfieldname: str) -> tuple[Any, SI | None]: + if subfieldname == "H_demag": + if not self.is_subfield_available(subfieldname): + raise KeyError("Subfield 'H_demag' is unavailable when demag is disabled.") + data = np.asarray(self._get_demag_nodal_field(), dtype=float) + return data, _si_unit("A/m") + if subfieldname in self._fields: + data = self._fields[subfieldname] + elif self.is_subfield_available(subfieldname): + data = self._subfield_array(subfieldname) + else: + raise KeyError(f"Unknown or unset subfield '{subfieldname}'.") + quantity = _simulation_compatibility_binding( + "_known_quantities_by_name", _known_quantities_by_name + )().get(subfieldname) + return data, None if quantity is None else quantity.units + + def _has_tetrahedral_mesh_or_empty(self) -> bool: + if self.mesh is None: + return False + simplices = np.asarray(self.mesh.simplices) + return simplices.size == 0 or (simplices.ndim == 2 and simplices.shape[1] == 4) diff --git a/src/nmag/simulation/fields/averages.py b/src/nmag/simulation/fields/averages.py new file mode 100644 index 0000000..ca06bef --- /dev/null +++ b/src/nmag/simulation/fields/averages.py @@ -0,0 +1,293 @@ +from __future__ import annotations + +import time +from typing import TYPE_CHECKING, Any + +import numpy as np + +from ..support import ( + _NO_COMPOSED_AVERAGE, + _NO_CONSTANT_AVERAGE, + _copy_average_value, +) + +_NO_ARRAY_AVERAGE = object() + + +class SimulationFieldAverageMixin: + if TYPE_CHECKING: + _active_subfield_array_timings: dict[str, float] | None + + def __getattr__(self, name: str) -> Any: ... + + def get_subfield_average(self, subfieldname: str, mat_name: str | None = None) -> Any: + """Return the volume-weighted average of an available field. + + Args: + subfieldname: Field name, for example ``m``, ``H_total``, or + ``E_demag``. + mat_name: Restrict integration to tetrahedra belonging to this + material. Omit it for the complete magnetic volume. + + Returns: + A scalar or component list, or ``None`` when the requested derived + quantity is not available for the current model. + """ + timings: dict[str, float] = {} + self.last_subfield_average_timings_seconds = {} + total_started = time.perf_counter() + cache_key = (subfieldname, mat_name) + cached_average = self._cached_subfield_average(cache_key) + if cached_average is not _NO_CONSTANT_AVERAGE: + timings["cache_hit"] = time.perf_counter() - total_started + self._finish_subfield_average_timings(timings, total_started) + return _copy_average_value(cached_average) + + started = time.perf_counter() + constant_average = self._constant_subfield_average(subfieldname) + if constant_average is not _NO_CONSTANT_AVERAGE: + timings["constant_average"] = time.perf_counter() - started + self._finish_subfield_average_timings(timings, total_started) + self._cache_subfield_average(cache_key, constant_average) + return constant_average + + if subfieldname == "H_demag": + result = self._direct_demag_average(timings) + self._finish_subfield_average_timings(timings, total_started) + self._cache_subfield_average(cache_key, result) + return result + + composed_average = self._try_composed_subfield_average(subfieldname, mat_name, timings) + if composed_average is not _NO_COMPOSED_AVERAGE: + result = np.asarray(composed_average, dtype=float).tolist() + self._finish_subfield_average_timings(timings, total_started) + self._cache_subfield_average(cache_key, result) + return result + + average = self._array_subfield_average(subfieldname, mat_name, timings) + self._finish_subfield_average_timings(timings, total_started) + if average is _NO_ARRAY_AVERAGE: + return None + self._cache_subfield_average(cache_key, average) + return average + + def _finish_subfield_average_timings( + self, + timings: dict[str, float], + total_started: float, + ) -> None: + timings["total"] = time.perf_counter() - total_started + self.last_subfield_average_timings_seconds = dict(sorted(timings.items())) + + def _direct_demag_average(self, timings: dict[str, float]) -> list[float]: + subfield_timings: dict[str, float] = {} + previous_subfield_timings = self._active_subfield_array_timings + self._active_subfield_array_timings = subfield_timings + try: + started = time.perf_counter() + average = self._demag_cell_field_average() + timings["direct_cell_average"] = time.perf_counter() - started + finally: + if self._active_subfield_array_timings is subfield_timings: + self._active_subfield_array_timings = previous_subfield_timings + self._append_subfield_array_timings(timings, subfield_timings) + return np.asarray(average, dtype=float).tolist() + + def _try_composed_subfield_average( + self, + subfieldname: str, + mat_name: str | None, + timings: dict[str, float], + ) -> object: + composed_timings: dict[str, float] = {} + subfield_timings: dict[str, float] = {} + previous_subfield_timings = self._active_subfield_array_timings + self._active_subfield_array_timings = subfield_timings + try: + started = time.perf_counter() + try: + average = self._composed_subfield_average( + subfieldname, + composed_timings, + mat_name=mat_name, + ) + except KeyError: + average = _NO_COMPOSED_AVERAGE + timings["composed_average"] = time.perf_counter() - started + finally: + if self._active_subfield_array_timings is subfield_timings: + self._active_subfield_array_timings = previous_subfield_timings + if average is _NO_COMPOSED_AVERAGE: + timings.pop("composed_average") + return average + timings.update(composed_timings) + self._append_subfield_array_timings(timings, subfield_timings) + return average + + def _array_subfield_average( + self, + subfieldname: str, + mat_name: str | None, + timings: dict[str, float], + ) -> Any | object: + subfield_timings: dict[str, float] = {} + previous_subfield_timings = self._active_subfield_array_timings + self._active_subfield_array_timings = subfield_timings + data: np.ndarray | None + try: + try: + started = time.perf_counter() + data = self._subfield_array(subfieldname) + timings["array"] = time.perf_counter() - started + except KeyError: + data = None + finally: + if self._active_subfield_array_timings is subfield_timings: + self._active_subfield_array_timings = previous_subfield_timings + self._append_subfield_array_timings(timings, subfield_timings) + if data is None or data.size == 0: + return _NO_ARRAY_AVERAGE + started = time.perf_counter() + average_value = self._field_average(data, mat_name=mat_name) + average = ( + average_value.tolist() + if isinstance(average_value, np.ndarray) + else float(average_value) + ) + timings["field_average"] = time.perf_counter() - started + return average + + def _append_subfield_array_timings( + self, + timings: dict[str, float], + subfield_timings: dict[str, float], + ) -> None: + timings.update( + (f"subfield_array:{name}", seconds) + for name, seconds in sorted(subfield_timings.items()) + ) + + def _constant_subfield_average(self, subfieldname: str) -> object: + if subfieldname == "H_ext": + return self._constant_h_ext_average() + if self.mesh is None: + return _NO_CONSTANT_AVERAGE + handlers = { + "pin": self._constant_pin_average, + "H_anis": self._constant_h_anis_average, + "E_anis": self._constant_e_anis_average, + "H_exch": self._constant_h_exch_average, + "E_exch": self._constant_e_exch_average, + } + handler = handlers.get(subfieldname) + return _NO_CONSTANT_AVERAGE if handler is None else handler() + + def _constant_h_ext_average(self) -> object: + h_ext = np.asarray(self._fields.get("H_ext", np.zeros(3)), dtype=float) + return h_ext.tolist() if h_ext.shape == (3,) else _NO_CONSTANT_AVERAGE + + def _constant_pin_average(self) -> object: + pin = self._fields.get("pin") + if pin is None: + return 1.0 + pin_values = np.asarray(pin, dtype=float) + if pin_values.size > 0 and np.all(pin_values == pin_values[0]): + return float(pin_values[0]) + return _NO_CONSTANT_AVERAGE + + def _constant_h_anis_average(self) -> object: + return self._constant_anisotropy_average([0.0, 0.0, 0.0]) + + def _constant_e_anis_average(self) -> object: + return self._constant_anisotropy_average(0.0) + + def _constant_anisotropy_average(self, zero_value: object) -> object: + if "m" not in self._fields or not self._anisotropy_is_zero_by_construction(): + return _NO_CONSTANT_AVERAGE + return zero_value + + def _constant_h_exch_average(self) -> object: + return [0.0, 0.0, 0.0] if self._exchange_is_zero_by_construction() else _NO_CONSTANT_AVERAGE + + def _constant_e_exch_average(self) -> object: + return 0.0 if self._exchange_is_zero_by_construction() else _NO_CONSTANT_AVERAGE + + def _composed_subfield_average( + self, + subfieldname: str, + timings: dict[str, float], + *, + mat_name: str | None = None, + ) -> object: + if subfieldname != "H_total": + return _NO_COMPOSED_AVERAGE + if self.mesh is None or "m" not in self._fields: + return _NO_COMPOSED_AVERAGE + + total = np.asarray( + self._average_component_for_composition("H_ext", timings), + dtype=float, + ) + if self.do_demag: + total = total + np.asarray( + self._average_component_for_composition("H_demag", timings), + dtype=float, + ) + total = total + np.asarray( + self._average_component_for_composition( + "H_anis", + timings, + mat_name=mat_name, + ), + dtype=float, + ) + total = total + np.asarray( + self._average_component_for_composition( + "H_exch", + timings, + mat_name=mat_name, + ), + dtype=float, + ) + return total + + def _average_component_for_composition( + self, + subfieldname: str, + timings: dict[str, float], + *, + mat_name: str | None = None, + ) -> np.ndarray: + cache_key = (subfieldname, mat_name) + cached_average = self._cached_subfield_average(cache_key) + if cached_average is not _NO_CONSTANT_AVERAGE: + timings[f"component_average:{subfieldname}:cache_hit"] = 0.0 + return np.asarray(cached_average, dtype=float) + + started = time.perf_counter() + constant_average = self._constant_subfield_average(subfieldname) + if constant_average is not _NO_CONSTANT_AVERAGE: + timings[f"component_average:{subfieldname}:constant"] = time.perf_counter() - started + self._cache_subfield_average(cache_key, constant_average) + return np.asarray(constant_average, dtype=float) + + if subfieldname == "H_demag": + started = time.perf_counter() + average = self._demag_cell_field_average() + timings[f"component_average:{subfieldname}:direct_cell_average"] = ( + time.perf_counter() - started + ) + self._cache_subfield_average(cache_key, average) + return np.asarray(average, dtype=float) + + started = time.perf_counter() + data = self._subfield_array(subfieldname) + timings[f"component_average:{subfieldname}:array"] = time.perf_counter() - started + if data.size == 0: + raise KeyError(f"Component field '{subfieldname}' is empty.") + + started = time.perf_counter() + average = self._field_average(data, mat_name=mat_name) + timings[f"component_average:{subfieldname}:field_average"] = time.perf_counter() - started + self._cache_subfield_average(cache_key, average) + return np.asarray(average, dtype=float) diff --git a/src/nmag/simulation/fields/derived.py b/src/nmag/simulation/fields/derived.py new file mode 100644 index 0000000..a7899c7 --- /dev/null +++ b/src/nmag/simulation/fields/derived.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import numpy as np + +from ...backends import _selected_llg_backend +from ..support import ( + LEGACY_DMDT_MAGNETISATION_SCALE, + MU0, + _simulation_compatibility_binding, +) + + +class SimulationFieldDerivedMixin: + if TYPE_CHECKING: + + def __getattr__(self, name: str) -> Any: ... + + def _derived_subfield_array(self, subfieldname: str) -> np.ndarray: + if self.mesh is None: + raise KeyError(f"Derived field '{subfieldname}' requires a mesh.") + point_count = len(self.mesh.points) + if subfieldname == "pin": + return self._derived_pin(point_count) + if "m" not in self._fields: + raise KeyError(f"Derived field '{subfieldname}' requires magnetisation.") + + resolvers = { + "phi": self._derived_phi, + "rho": self._derived_rho, + "H_anis": self._derived_h_anis, + "H_exch": self._derived_h_exch, + "dm_dcurrent": self._derived_dm_dcurrent, + "dmdt": self._derived_dmdt, + "M": self._derived_magnetisation, + "H_total": self._derived_h_total, + "E_anis": self._derived_e_anis, + "E_exch": self._derived_e_exch, + "E_ext": self._derived_e_ext, + "E_demag": self._derived_e_demag, + "E_total": self._derived_e_total, + } + resolver = resolvers.get(subfieldname) + if resolver is None: + raise KeyError(f"Unknown or unset subfield '{subfieldname}'.") from None + return resolver(point_count) + + def _derived_pin(self, point_count: int) -> np.ndarray: + return np.asarray( + self._fields.get("pin", np.ones(point_count, dtype=float)), + dtype=float, + ) + + def _derived_phi(self, point_count: int) -> np.ndarray: + if not self.do_demag: + return np.zeros(point_count, dtype=float) + phi, _rho, _volumes = self._get_demag_auxiliary_fields() + return phi + + def _derived_rho(self, point_count: int) -> np.ndarray: + if not self.do_demag: + return np.zeros(point_count, dtype=float) + _phi, rho, _volumes = self._get_demag_auxiliary_fields() + return rho + + def _derived_h_anis(self, point_count: int) -> np.ndarray: + field, _energy = self._get_anisotropy_fields() + return field + + def _derived_h_exch(self, _point_count: int) -> np.ndarray: + return self._get_exchange_nodal_field() + + def _derived_dm_dcurrent(self, _point_count: int) -> np.ndarray: + return self._get_dm_dcurrent() + + def _derived_dmdt(self, _point_count: int) -> np.ndarray: + coefficients = self._nodal_material_coefficients() + m = np.asarray(self._fields["m"], dtype=float) + dmdt_scale_values = np.full(len(m), LEGACY_DMDT_MAGNETISATION_SCALE, dtype=float) + dm_dcurrent = ( + self._subfield_array("dm_dcurrent") if "current_density" in self._fields else None + ) + backend = self._llg_backend(len(m)) + rhs = self._llg_rhs_rust if backend == "rust" else self._llg_rhs_python + with self._record_active_subfield_array_timing_block(f"dmdt_detail:llg_rhs:{backend}"): + return rhs( + m, + self._subfield_array("H_total"), + self._subfield_array("pin"), + dmdt_scale_values, + coefficients.precession, + coefficients.damping, + coefficients.normalisation, + dm_dcurrent, + coefficients.stt_adiabatic, + coefficients.stt_nonadiabatic, + ) + + def _llg_backend(self, point_count: int) -> str: + return _simulation_compatibility_binding("_selected_llg_backend", _selected_llg_backend)( + point_count, getattr(self, "config", None) + ) + + def _derived_magnetisation(self, _point_count: int) -> np.ndarray: + return np.asarray(self._fields["m"], dtype=float) * self._nodal_ms_values()[:, np.newaxis] + + def _derived_h_total(self, _point_count: int) -> np.ndarray: + h_total = np.asarray(self._subfield_array("H_ext"), dtype=float) + if self.do_demag: + h_total = h_total + np.asarray(self._subfield_array("H_demag"), dtype=float) + return h_total + self._subfield_array("H_anis") + self._subfield_array("H_exch") + + def _derived_e_anis(self, point_count: int) -> np.ndarray: + _field, energy = self._get_anisotropy_fields() + return energy + + def _derived_e_exch(self, _point_count: int) -> np.ndarray: + return ( + -0.5 + * MU0 + * self._nodal_ms_values() + * np.einsum( + "ij,ij->i", + self._subfield_array("H_exch"), + np.asarray(self._fields["m"], dtype=float), + ) + ) + + def _derived_e_ext(self, _point_count: int) -> np.ndarray: + return -MU0 * np.einsum( + "ij,ij->i", + self._subfield_array("M"), + self._subfield_array("H_ext"), + ) + + def _derived_e_demag(self, point_count: int) -> np.ndarray: + if not self.do_demag: + return np.zeros(point_count, dtype=float) + return ( + -0.5 + * MU0 + * np.einsum( + "ij,ij->i", + self._subfield_array("M"), + self._subfield_array("H_demag"), + ) + ) + + def _derived_e_total(self, _point_count: int) -> np.ndarray: + return ( + self._subfield_array("E_ext") + + self._subfield_array("E_anis") + + self._subfield_array("E_exch") + + self._subfield_array("E_demag") + ) + + def _exchange_is_zero_by_construction(self) -> bool: + if "m" not in self._fields: + return False + m = np.asarray(self._fields["m"], dtype=float) + if m.ndim != 2 or len(m) == 0: + return False + return bool(np.allclose(m, m[0], rtol=0.0, atol=1.0e-12)) diff --git a/src/nmag/simulation/fields/maxangle.py b/src/nmag/simulation/fields/maxangle.py new file mode 100644 index 0000000..e4aa486 --- /dev/null +++ b/src/nmag/simulation/fields/maxangle.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +import time +from typing import TYPE_CHECKING, Any + +import numpy as np + +from ...backends import ( + _load_rust_accelerator, + _selected_maxangle_backend, +) +from ...demag import _normalised +from ..support import _simulation_compatibility_binding + + +class SimulationFieldMaxangleMixin: + if TYPE_CHECKING: + + def __getattr__(self, name: str) -> Any: ... + + def get_maxangle_average(self, field_name: str) -> float | None: + """Return the maximum neighboring magnetization angle in degrees. + + Args: + field_name: Currently only ``"m"`` is supported. + + Returns: + Maximum angle across mesh edges, or ``None`` when magnetization or + a suitable mesh is unavailable. + """ + timings: dict[str, float] = {} + total_started = time.perf_counter() + self.last_maxangle_timings_seconds = {} + inputs = self._maxangle_inputs(field_name, timings) + if inputs is None: + return self._finish_maxangle(timings, total_started, None) + m, simplices = inputs + if self._is_uniform_magnetisation(m, timings): + return self._finish_maxangle(timings, total_started, 0.0) + backend = _simulation_compatibility_binding( + "_selected_maxangle_backend", _selected_maxangle_backend + )(getattr(self, "config", None)) + if backend == "rust": + return self._finish_maxangle( + timings, + total_started, + self._timed_rust_maxangle(m, simplices, timings), + ) + return self._finish_maxangle( + timings, + total_started, + self._timed_python_maxangle(m, timings), + ) + + def _finish_maxangle( + self, + timings: dict[str, float], + total_started: float, + value: float | None, + ) -> float | None: + timings["total"] = time.perf_counter() - total_started + self.last_maxangle_timings_seconds = dict(sorted(timings.items())) + return value + + def _maxangle_inputs( + self, + field_name: str, + timings: dict[str, float] | None, + ) -> tuple[np.ndarray, np.ndarray] | None: + if field_name != "m" or "m" not in self._fields or self.mesh is None: + return None + started = time.perf_counter() + m = np.asarray(self._fields["m"], dtype=float) + simplices = np.asarray(self._require_mesh().simplices, dtype=int) + if timings is not None: + timings["input_arrays"] = time.perf_counter() - started + if m.ndim != 2 or m.shape[1] != 3 or simplices.ndim != 2 or simplices.shape[1] < 2: + return None + return (m, simplices) if len(m) > 0 else None + + def _is_uniform_magnetisation(self, m: np.ndarray, timings: dict[str, float]) -> bool: + started = time.perf_counter() + is_uniform = bool(np.all(m == m[0])) + timings["uniform_check"] = time.perf_counter() - started + return is_uniform + + def _timed_rust_maxangle( + self, + m: np.ndarray, + simplices: np.ndarray, + timings: dict[str, float], + ) -> float: + started = time.perf_counter() + maxangle = self._maxangle_average_rust(m, simplices) + timings["mesh_edges_and_angles:rust"] = time.perf_counter() - started + return maxangle + + def _timed_python_maxangle(self, m: np.ndarray, timings: dict[str, float]) -> float: + started = time.perf_counter() + edges = self._mesh_edges() + timings["mesh_edges"] = time.perf_counter() - started + if len(edges) == 0: + return 0.0 + + started = time.perf_counter() + # set_m normalises stored magnetisation vectors, so maxangle can use + # the cached nodal rows directly instead of renormalising every edge. + edge_values = m[edges] + dots = np.einsum("ij,ij->i", edge_values[:, 0, :], edge_values[:, 1, :]) + if dots.size == 0: + timings["edge_angles"] = time.perf_counter() - started + return 0.0 + bounded_dots = np.minimum(np.maximum(dots, -1.0), 1.0) + maxangle = float(np.max(np.arccos(bounded_dots))) * 180.0 / np.pi + timings["edge_angles"] = time.perf_counter() - started + return maxangle + + def _maxangle_average_rust(self, m: np.ndarray, simplices: np.ndarray) -> float: + rust_accel = _simulation_compatibility_binding( + "_load_rust_accelerator", _load_rust_accelerator + )("NmagConfig.accelerator['maxangle']") + try: + return float(rust_accel.maxangle_between_edges(m, simplices)) + except AttributeError as exc: + raise RuntimeError( + "NmagConfig.accelerator['maxangle']='rust' requires an nmag_accel build with " + "maxangle_between_edges. Rebuild it with " + "`maturin develop --release --manifest-path rust/nmag_accel/Cargo.toml`." + ) from exc + + def _maxangle_average_reference(self, field_name: str) -> float | None: + inputs = self._maxangle_inputs(field_name, None) + if inputs is None: + return None + m, simplices = inputs + if np.all(m == m[0]): + return 0.0 + return self._reference_maxangle_from_simplices(m, simplices) + + def _reference_maxangle_from_simplices( + self, + m: np.ndarray, + simplices: np.ndarray, + ) -> float: + max_angle = 0.0 + seen_edges: set[tuple[int, int]] = set() + for simplex in simplices: + for left_position, left in enumerate(simplex): + for right in simplex[left_position + 1 :]: + edge = (min(int(left), int(right)), max(int(left), int(right))) + if edge in seen_edges: + continue + seen_edges.add(edge) + left_m = _simulation_compatibility_binding("_normalised", _normalised)( + m[edge[0]] + ) + right_m = _simulation_compatibility_binding("_normalised", _normalised)( + m[edge[1]] + ) + raw_dot = float(np.sum(left_m * right_m)) + dot = min(1.0, max(-1.0, raw_dot)) + max_angle = max(max_angle, float(np.arccos(dot))) + return max_angle * 180.0 / np.pi diff --git a/src/nmag/simulation/fields/probes.py b/src/nmag/simulation/fields/probes.py new file mode 100644 index 0000000..a6b856b --- /dev/null +++ b/src/nmag/simulation/fields/probes.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import time +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any + +import numpy as np + +from si.physical import SI + + +class SimulationFieldProbeMixin: + if TYPE_CHECKING: + + def __getattr__(self, name: str) -> Any: ... + + def probe_subfield_siv( + self, + subfieldname: str, + pos: Sequence[float], + unit: SI | None = None, + ) -> Any: + """Probe a field at a position expressed in metres. + + Demagnetization fields are linearly interpolated in the containing + tetrahedron. Magnetization uses the nearest mesh node, and a homogeneous + applied field is returned directly. + + Args: + subfieldname: ``H_demag``, ``H_ext``, or ``m``. + pos: Three physical coordinates in metres. + unit: Reserved compatibility argument. Returned values use the + field's standard SI representation. + + Returns: + Ordinary numeric components in SI, or ``None`` outside the mesh. + + Raises: + KeyError: If the field name is not supported by this probe API. + """ + if subfieldname == "H_demag": + total_started = time.perf_counter() + try: + started = time.perf_counter() + within_bounds = self._probe_is_within_mesh_bounds(pos) + self._record_probe_timing( + "bounds_check", + time.perf_counter() - started, + ) + if not within_bounds: + return None + + started = time.perf_counter() + points = self._mesh_points() + probe = np.asarray(pos, dtype=float) + self._record_probe_timing( + "prepare_probe", + time.perf_counter() - started, + ) + + started = time.perf_counter() + legacy_null_vertex = self._probe_is_legacy_null_vertex(probe, points) + self._record_probe_timing( + "legacy_null_vertex_check", + time.perf_counter() - started, + ) + if legacy_null_vertex: + return None + + started = time.perf_counter() + nodal_field = self._get_demag_nodal_field() + self._record_probe_timing( + "demag_nodal_field", + time.perf_counter() - started, + ) + + started = time.perf_counter() + result = self._probe_tetrahedral_field(probe, nodal_field) + self._record_probe_timing( + "tetrahedral_interpolation", + time.perf_counter() - started, + ) + return result + finally: + self._record_probe_timing( + "total", + time.perf_counter() - total_started, + ) + if subfieldname == "H_ext": + return self._fields.get("H_ext", np.zeros(3)).tolist() + if subfieldname != "m": + raise KeyError(f"Unknown or unset subfield '{subfieldname}'.") + if self.mesh is None or "m" not in self._fields: + return None + + points = np.asarray(self.mesh.points, dtype=float) + if points.size == 0: + return None + probe = np.asarray(pos, dtype=float) + nearest_index = int(np.argmin(np.linalg.norm(points - probe, axis=1))) + return np.asarray(self._fields["m"][nearest_index], dtype=float).tolist() + + def _record_probe_timing(self, name: str, value: float) -> None: + self.last_probe_timings_seconds[name] = ( + self.last_probe_timings_seconds.get(name, 0.0) + value + ) diff --git a/src/nmag/simulation/implicit_dynamics.py b/src/nmag/simulation/implicit_dynamics.py new file mode 100644 index 0000000..d06c563 --- /dev/null +++ b/src/nmag/simulation/implicit_dynamics.py @@ -0,0 +1,221 @@ +from __future__ import annotations + +import math +import os +import time +from typing import Any, Protocol + +import numpy as np +from numpy.typing import NDArray + +from ..backends import _load_rust_accelerator +from ..dynamics import IntegratorStats +from ..resources import available_memory_bytes as _available_memory_bytes +from .support import _si_unit, _simulation_compatibility_binding + +FloatArray = NDArray[np.float64] +DIFFSOL_DENSE_MEMORY_LIMIT_ENV = "NMAG_DIFFSOL_DENSE_MEMORY_LIMIT_GIB" +DIFFSOL_DENSE_MATRIX_COPIES_ESTIMATE = 6 +DIFFSOL_AVAILABLE_MEMORY_FRACTION = 0.8 +DEFAULT_BDF_INITIAL_STEP_SECONDS = 1.0e-15 +DEFAULT_BDF_MAXIMUM_STEPS = 1_000_000 + + +class _BdfResult(Protocol): + state: list[float] + reached_time: float + accepted_steps: int + rejected_steps: int + rhs_evaluations: int + jacobian_vector_evaluations: int + nonlinear_iterations: int + nonlinear_failures: int + linear_solver_setups: int + last_step: float + max_dm_dt: float + converged: bool + + +def _estimated_dense_bdf_peak_bytes(state_size: int) -> int: + if state_size < 1: + raise ValueError("state_size must be positive.") + matrix_bytes = state_size * state_size * np.dtype(np.float64).itemsize + return DIFFSOL_DENSE_MATRIX_COPIES_ESTIMATE * matrix_bytes + + +def _diffsol_dense_memory_budget() -> tuple[int | None, str]: + configured = os.environ.get(DIFFSOL_DENSE_MEMORY_LIMIT_ENV) + if configured is not None: + value = configured.strip().lower() + if value == "unlimited": + return None, "explicit unlimited override" + try: + gibibytes = float(value) + except ValueError as error: + raise ValueError( + f"{DIFFSOL_DENSE_MEMORY_LIMIT_ENV} must be a positive number of GiB " + f"or 'unlimited', got {configured!r}." + ) from error + if not math.isfinite(gibibytes) or gibibytes <= 0.0: + raise ValueError( + f"{DIFFSOL_DENSE_MEMORY_LIMIT_ENV} must be a positive number of GiB " + f"or 'unlimited', got {configured!r}." + ) + return int(gibibytes * 1024**3), "explicit environment budget" + + available = _available_memory_bytes() + if available is None: + return None, "available memory could not be determined" + return int(available * DIFFSOL_AVAILABLE_MEMORY_FRACTION), "80% of available memory" + + +def _validate_diffsol_dense_memory(state_size: int) -> None: + estimated_bytes = _estimated_dense_bdf_peak_bytes(state_size) + budget_bytes, budget_source = _diffsol_dense_memory_budget() + if budget_bytes is None or estimated_bytes <= budget_bytes: + return + + estimated_gib = estimated_bytes / 1024**3 + budget_gib = budget_bytes / 1024**3 + raise MemoryError( + f"The dense Diffsol solve for {state_size} state values is estimated to require " + f"approximately {estimated_gib:.2f} GiB, exceeding the {budget_gib:.2f} GiB " + f"budget ({budget_source}). Set {DIFFSOL_DENSE_MEMORY_LIMIT_ENV} to a reviewed " + "GiB budget or 'unlimited' to proceed. There is no fixed state-count limit." + ) + + +def build_affine_field_operator(simulation: Any) -> FloatArray: + """Build the dense linear map from nodal m to demag plus exchange field.""" + mesh_token = simulation._mesh_geometry_token() + cached = simulation._llg_affine_operator_cache + if cached is not None and cached[0] == mesh_token: + return cached[1] + + original_m = np.asarray(simulation._fields["m"], dtype=np.float64) + state_size = int(original_m.size) + _validate_diffsol_dense_memory(state_size) + + def linear_field(state: FloatArray) -> FloatArray: + simulation._fields["m"] = np.reshape(state, (-1, 3)) + simulation._invalidate_demag() + field = np.asarray(simulation._get_exchange_nodal_field(), dtype=np.float64) + if simulation.do_demag: + field = field + np.asarray(simulation._get_demag_nodal_field(), dtype=np.float64) + return np.ravel(field) + + try: + zero = np.zeros(state_size, dtype=np.float64) + baseline = linear_field(zero) + operator = np.empty((state_size, state_size), dtype=np.float64) + basis = np.zeros(state_size, dtype=np.float64) + for column in range(state_size): + basis[column] = 1.0 + operator[:, column] = linear_field(basis) - baseline + basis[column] = 0.0 + finally: + simulation._fields["m"] = np.array(original_m, copy=True) + simulation._invalidate_demag() + + operator = np.ascontiguousarray(operator) + simulation._llg_affine_operator_cache = (mesh_token, operator) + return operator + + +def relax_with_diffsol(simulation: Any, H_applied: Any = None) -> None: + if simulation.mesh is None or "m" not in simulation._fields: + raise RuntimeError("A mesh and magnetisation are required before relaxation.") + if not simulation._anisotropy_is_zero_by_construction(): + raise NotImplementedError( + "The experimental Diffsol backend does not support dynamic anisotropy." + ) + + simulation.do_next_stage(stage=simulation.clock.stage) + simulation.clock.stage_end = False + if H_applied is not None: + simulation.set_H_ext(H_applied) + + started = time.perf_counter() + try: + operator = build_affine_field_operator(simulation) + point_count = len(simulation._fields["m"]) + h_ext = np.asarray(simulation._fields.get("H_ext", np.zeros(3)), dtype=np.float64) + constant_field = np.ascontiguousarray(np.tile(h_ext, point_count)) + pin = np.ascontiguousarray(simulation._subfield_array("pin"), dtype=np.float64) + c1, c2, c3 = simulation._llg_coefficients() + config = simulation.integrator_config + rust_accel = _simulation_compatibility_binding( + "_load_rust_accelerator", _load_rust_accelerator + )("NmagConfig(integrator_backend='diffsol')") + try: + integrate_llg_bdf = rust_accel.integrate_llg_bdf + except AttributeError as exc: + raise RuntimeError( + "NmagConfig(integrator_backend='diffsol') requires an nmag_accel build with the " + "Diffsol backend. Rebuild it with `./scripts/build-accelerator.sh --release`." + ) from exc + + result: _BdfResult = integrate_llg_bdf( + np.ascontiguousarray(np.ravel(simulation._fields["m"]), dtype=np.float64), + operator, + constant_field, + pin, + c1, + c2, + c3, + 0.0, + simulation.max_time_reached.in_units_of(_si_unit("s")), + config.relative_tolerance, + config.absolute_tolerance, + min(DEFAULT_BDF_INITIAL_STEP_SECONDS, config.maximum_step_seconds), + config.maximum_step_seconds, + simulation.stopping_dm_dt, + 5, + simulation.convergence.required_checks, + DEFAULT_BDF_MAXIMUM_STEPS, + ) + except Exception: + simulation._last_integrator_stats = IntegratorStats( + failed=True, + status="failed", + wall_seconds=time.perf_counter() - started, + ) + raise + wall_seconds = time.perf_counter() - started + accepted_state = np.asarray(result.state, dtype=np.float64) + if not np.all(np.isfinite(accepted_state)): + raise FloatingPointError("Diffsol returned a non-finite magnetisation state.") + + simulation._fields["m"] = np.reshape(accepted_state, (-1, 3)) + simulation._invalidate_demag() + simulation._invalidate_integrator() + simulation.max_dm_dt = float(result.max_dm_dt) + simulation.clock.record_advance( + stage_time_seconds=float(result.reached_time), + accepted_steps=int(result.accepted_steps), + last_step_seconds=float(result.last_step), + wall_seconds=wall_seconds, + ) + simulation.clock.convergence = bool(result.converged) + simulation.clock.stage_end = bool(result.converged) + simulation._last_integrator_stats = IntegratorStats( + accepted_steps=int(result.accepted_steps), + rhs_evaluations=int(result.rhs_evaluations), + rejected_steps=int(result.rejected_steps), + failed=False, + status="converged" if result.converged else "maximum_time_reached", + last_step_seconds=float(result.last_step), + simulated_seconds=float(result.reached_time), + wall_seconds=wall_seconds, + jacobian_vector_evaluations=int(result.jacobian_vector_evaluations), + nonlinear_iterations=int(result.nonlinear_iterations), + nonlinear_failures=int(result.nonlinear_failures), + linear_solver_setups=int(result.linear_solver_setups), + ) + if not result.converged: + raise RuntimeError( + f"Diffsol reached {simulation.max_time_reached} without satisfying convergence." + ) + + simulation.save_data() + simulation.save_data(fields="all") diff --git a/src/nmag/simulation/mesh/__init__.py b/src/nmag/simulation/mesh/__init__.py new file mode 100644 index 0000000..66c5d7c --- /dev/null +++ b/src/nmag/simulation/mesh/__init__.py @@ -0,0 +1,13 @@ +"""Mesh-operation compatibility façade composed from focused mixins.""" + +from .geometry import SimulationMeshGeometryMixin +from .materials import SimulationMeshMaterialMixin +from .probe import SimulationMeshProbeMixin + + +class SimulationMeshMixin( + SimulationMeshGeometryMixin, + SimulationMeshProbeMixin, + SimulationMeshMaterialMixin, +): + """Provide mesh caches, probing, and material-aware averaging.""" diff --git a/src/nmag/simulation/mesh/geometry.py b/src/nmag/simulation/mesh/geometry.py new file mode 100644 index 0000000..861a1a7 --- /dev/null +++ b/src/nmag/simulation/mesh/geometry.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import numpy as np + +from ..support import _MESH_EDGE_SET_THRESHOLD + + +class SimulationMeshGeometryMixin: + if TYPE_CHECKING: + + def __getattr__(self, name: str) -> Any: ... + + def _invalidate_demag(self, *, clear_geometry: bool = False) -> None: + self._demag_cache_token = None + self._demag_dipoles = None + self._demag_nodal_cache = None + self._demag_cell_cache_token = None + self._demag_cell_field_cache = None + self._demag_aux_cache_token = None + self._demag_phi_cache = None + self._demag_rho_cache = None + self._demag_volumes_cache = None + self.last_demag_solve_diagnostics = {} + self.last_demag_internal_vectors = {} + self._exchange_cache_token = None + self._exchange_nodal_cache = None + self._anisotropy_fields_cache = None + if clear_geometry: + self._demag_geometry_cache_token = None + self._demag_boundary_faces_cache = None + self._demag_bem_cache = None + self._last_bem_operator_stats = None + self._demag_fem_geometry_cache = None + self._demag_gauge_factorization_cache = None + self._demag_dirichlet_factorization_cache = None + self._demag_ms_values_cache = None + self._demag_volume_charge_scales_cache = None + self._nodal_ms_values_cache = None + self._mesh_points_cache = None + self._mesh_bounds_cache = None + self._mesh_edge_cache = None + self._simplex_volume_cache = None + self._volume_average_node_weights_cache = None + self._incident_cell_volume_sums_cache = None + self._exchange_spectral_bound_cache = None + self._nodal_material_coefficients_cache = None + self._nodal_anisotropy_groups_cache = None + self._anisotropy_fields_cache = None + self._llg_affine_operator_cache = None + self._probe_geometry_cache_token = None + self._probe_tetrahedral_cache = None + + def _mesh_points(self) -> np.ndarray: + token = self._mesh_geometry_token() + if self._mesh_points_cache is not None: + cached_token, cached_points = self._mesh_points_cache + if cached_token == token: + return cached_points + + points = np.asarray(self._require_mesh().points, dtype=float) + if points.ndim != 2 or points.shape[1] != 3: + raise NotImplementedError("The demag MVP currently supports only 3D meshes.") + self._mesh_points_cache = (token, points) + return points + + def _mesh_bounds(self) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + token = self._mesh_geometry_token() + if self._mesh_bounds_cache is not None: + cached_token, lower, upper, span = self._mesh_bounds_cache + if cached_token == token: + return lower, upper, span + + points = self._mesh_points() + lower = np.min(points, axis=0) + upper = np.max(points, axis=0) + span = np.maximum(upper - lower, np.ones(3)) + self._mesh_bounds_cache = (token, lower, upper, span) + return lower, upper, span + + def _mesh_edges(self) -> np.ndarray: + token = self._mesh_geometry_token() + if self._mesh_edge_cache is not None: + cached_token, cached_edges = self._mesh_edge_cache + if cached_token == token: + return cached_edges + + mesh = self._require_mesh() + simplices = np.asarray(mesh.simplices, dtype=int) + if simplices.ndim != 2 or simplices.shape[1] < 2 or len(simplices) == 0: + edges = np.zeros((0, 2), dtype=int) + else: + local_pairs = np.asarray( + [ + (left, right) + for left in range(simplices.shape[1]) + for right in range(left + 1, simplices.shape[1]) + ], + dtype=int, + ) + candidate_edge_count = len(simplices) * len(local_pairs) + if candidate_edge_count <= _MESH_EDGE_SET_THRESHOLD: + seen_edges: set[tuple[int, int]] = set() + for simplex in simplices: + for left_position, left in enumerate(simplex): + left_index = int(left) + for right in simplex[left_position + 1 :]: + right_index = int(right) + if left_index <= right_index: + seen_edges.add((left_index, right_index)) + else: + seen_edges.add((right_index, left_index)) + edges = np.asarray(sorted(seen_edges), dtype=int) + else: + expanded_edges = np.asarray(simplices[:, local_pairs], dtype=np.int64) + flat_edges = expanded_edges.flatten() + edges = np.column_stack((flat_edges[0::2], flat_edges[1::2])) + edges.sort(axis=1) + node_count = max(int(np.max(simplices)) + 1, len(mesh.points)) + edge_codes = edges[:, 0].astype(np.int64, copy=False) * node_count + edges[ + :, 1 + ].astype(np.int64, copy=False) + unique_codes = np.unique(edge_codes) + edges = np.empty((len(unique_codes), 2), dtype=int) + edges[:, 0] = unique_codes // node_count + edges[:, 1] = unique_codes % node_count + + self._mesh_edge_cache = (token, edges) + return edges + + def _mesh_geometry_token(self) -> int: + return id(self._require_mesh().raw_mesh) + + def _require_mesh(self) -> Any: + if self.mesh is None: + raise RuntimeError("A mesh must be loaded before using simulation fields.") + return self.mesh + + def _ensure_demag_geometry_cache_token(self, token: int) -> None: + if self._demag_geometry_cache_token == token: + return + self._demag_geometry_cache_token = token + self._demag_boundary_faces_cache = None + self._demag_bem_cache = None + self._last_bem_operator_stats = None + self._demag_fem_geometry_cache = None + self._demag_ms_values_cache = None + self._demag_volume_charge_scales_cache = None + self._nodal_ms_values_cache = None + self._nodal_material_coefficients_cache = None + self._nodal_anisotropy_groups_cache = None + self._anisotropy_fields_cache = None diff --git a/src/nmag/simulation/mesh/materials.py b/src/nmag/simulation/mesh/materials.py new file mode 100644 index 0000000..fc8c99f --- /dev/null +++ b/src/nmag/simulation/mesh/materials.py @@ -0,0 +1,299 @@ +from __future__ import annotations + +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any, cast + +import numpy as np + +from ...demag import _simplex_volumes +from ..support import _si_unit, _simulation_compatibility_binding + + +class SimulationMeshMaterialMixin: + if TYPE_CHECKING: + _simplex_volume_cache: Any | None + _volume_average_node_weights_cache: Any | None + _incident_cell_volume_sums_cache: Any | None + _demag_ms_values_cache: Any | None + _demag_volume_charge_scales_cache: Any | None + _nodal_ms_values_cache: Any | None + + def __getattr__(self, name: str) -> Any: ... + + def _material_region_ids(self, material_name: str) -> np.ndarray: + region_ids = [ + self.region_id_of_name[region_name] + for region_name, materials in self.mats_of_region_name.items() + if any(material.name == material_name for material in materials) + ] + if not region_ids: + raise KeyError(f"Unknown material '{material_name}'.") + return np.asarray(region_ids, dtype=int) + + def _field_average( + self, + data: np.ndarray, + *, + mat_name: str | None = None, + ) -> np.ndarray | float: + if self.mesh is None: + return np.mean(data, axis=0) + if data.ndim == 1: + return self._scalar_field_average(data, mat_name=mat_name) + if data.ndim != 2: + return np.mean(data, axis=0) + if len(data) > 0 and np.all(data == data[0]): + return np.asarray(data[0], dtype=float) + + simplices = np.asarray(self._require_mesh().simplices, dtype=int) + points = self._mesh_points() + if ( + len(data) != len(points) + or simplices.ndim != 2 + or simplices.shape[1] != 4 + or len(simplices) == 0 + ): + return np.mean(data, axis=0) + + volumes, positive = self._simplex_volume_weights(points, simplices) + if mat_name is not None: + regions = np.asarray(self._require_mesh().regions, dtype=int) + positive = positive & np.isin(regions, self._material_region_ids(mat_name)) + if not np.any(positive): + return np.mean(data, axis=0) + + weights = ( + self._volume_average_node_weights(points, simplices, len(data)) + if mat_name is None + else None + ) + if weights is None: + cell_values = np.mean(data[simplices[positive]], axis=1) + return np.sum(cell_values * volumes[positive, np.newaxis], axis=0) / float( + np.sum(volumes[positive]) + ) + return weights @ data + + def _scalar_field_average( + self, + data: np.ndarray, + *, + mat_name: str | None = None, + ) -> float: + if len(data) > 0 and np.all(data == data[0]): + return float(data[0]) + + simplices = np.asarray(self._require_mesh().simplices, dtype=int) + points = self._mesh_points() + if len(data) != len(points) or simplices.ndim != 2 or simplices.shape[1] != 4: + return float(np.mean(data)) + + volumes, positive = self._simplex_volume_weights(points, simplices) + if mat_name is not None: + regions = np.asarray(self._require_mesh().regions, dtype=int) + positive = positive & np.isin(regions, self._material_region_ids(mat_name)) + if not np.any(positive): + return float(np.mean(data)) + + weights = ( + self._volume_average_node_weights(points, simplices, len(data)) + if mat_name is None + else None + ) + if weights is None: + cell_values = np.mean(data[simplices[positive]], axis=1) + return float(np.sum(cell_values * volumes[positive]) / float(np.sum(volumes[positive]))) + return float(weights @ data) + + def _simplex_volume_weights( + self, + points: np.ndarray, + simplices: np.ndarray, + ) -> tuple[np.ndarray, np.ndarray]: + token = self._mesh_geometry_token() + if self._simplex_volume_cache is not None: + cached_token, volumes, positive = self._simplex_volume_cache + if cached_token == token and len(volumes) == len(simplices): + return volumes, positive + + volumes = _simulation_compatibility_binding( + "_simplex_volumes", + _simplex_volumes, + )(points, simplices) + positive = volumes > 0.0 + self._simplex_volume_cache = (token, volumes, positive) + return volumes, positive + + def _volume_average_node_weights( + self, + points: np.ndarray, + simplices: np.ndarray, + point_count: int, + ) -> np.ndarray | None: + token = self._mesh_geometry_token() + if self._volume_average_node_weights_cache is not None: + cached_token, cached_point_count, weights = self._volume_average_node_weights_cache + if cached_token == token and cached_point_count == point_count: + return weights + + volumes, positive = self._simplex_volume_weights(points, simplices) + if not np.any(positive): + return None + + weights = np.zeros(point_count, dtype=float) + positive_simplices = simplices[positive] + positive_volumes = volumes[positive] + np.add.at(weights, positive_simplices.flatten(), np.repeat(positive_volumes / 4.0, 4)) + total_volume = float(np.sum(positive_volumes)) + if total_volume <= 0.0: + return None + + weights /= total_volume + self._volume_average_node_weights_cache = (token, point_count, weights) + return weights + + def _incident_cell_volume_sums( + self, + points: np.ndarray, + simplices: np.ndarray, + volumes: np.ndarray, + ) -> np.ndarray: + token = self._mesh_geometry_token() + point_count = len(points) + cell_count = len(simplices) + if self._incident_cell_volume_sums_cache is not None: + cached_token, cached_point_count, cached_cell_count, weights = ( + self._incident_cell_volume_sums_cache + ) + if ( + cached_token == token + and cached_point_count == point_count + and cached_cell_count == cell_count + ): + return weights + + weights = np.zeros(point_count, dtype=float) + positive = volumes > 0.0 + if np.any(positive): + positive_simplices = simplices[positive] + positive_volumes = volumes[positive] + for local_index in range(4): + np.add.at( + weights, + positive_simplices[:, local_index], + positive_volumes, + ) + + self._incident_cell_volume_sums_cache = (token, point_count, cell_count, weights) + return weights + + def _simplex_material(self, region_id: int) -> Any: + region_name = self.region_name_of_id.get(int(region_id)) + raw_materials: Any = self.mats_of_region_name.get(region_name, []) if region_name else [] + materials = cast(list[Any], raw_materials) + if not materials: + raise ValueError(f"Mesh region {region_id} has no configured magnetic material.") + if len(materials) != 1: + raise NotImplementedError( + "Multiple magnetic material subfields in one mesh region are not supported." + ) + return materials[0] + + def _simplex_material_ms(self, region_id: int) -> float: + return self._simplex_material(region_id).Ms.in_units_of(_si_unit("A/m")) + + def _simplex_material_ms_values(self, regions: Sequence[int]) -> np.ndarray: + token = self._mesh_geometry_token() + self._ensure_demag_geometry_cache_token(token) + if self._demag_ms_values_cache is not None: + cached_token, cached_values = self._demag_ms_values_cache + if cached_token == token and len(cached_values) == len(regions): + return cached_values + + region_ids = np.asarray(regions, dtype=int) + if region_ids.size == 0: + ms_values = np.zeros(0, dtype=float) + self._demag_ms_values_cache = (token, ms_values) + return ms_values + + first_region = int(region_ids[0]) + if np.all(region_ids == first_region): + ms_values = np.full( + len(region_ids), + self._simplex_material_ms(first_region), + dtype=float, + ) + self._demag_ms_values_cache = (token, ms_values) + return ms_values + + unique_region_ids, inverse = np.unique(region_ids, return_inverse=True) + ms_lookup = np.asarray( + [self._simplex_material_ms(int(region)) for region in unique_region_ids], + dtype=float, + ) + ms_values = ms_lookup[inverse] + self._demag_ms_values_cache = (token, ms_values) + return ms_values + + def _simplex_volume_charge_scales(self, regions: Sequence[int]) -> np.ndarray: + token = self._mesh_geometry_token() + self._ensure_demag_geometry_cache_token(token) + if self._demag_volume_charge_scales_cache is not None: + cached_token, cached_values = self._demag_volume_charge_scales_cache + if cached_token == token and len(cached_values) == len(regions): + return cached_values + + region_ids = np.asarray(regions, dtype=int) + if region_ids.size == 0: + scales = np.zeros(0, dtype=float) + else: + unique_region_ids, inverse = np.unique(region_ids, return_inverse=True) + scale_lookup = np.asarray( + [ + float(self._simplex_material(int(region)).scale_volume_charges) + for region in unique_region_ids + ], + dtype=float, + ) + scales = scale_lookup[inverse] + + self._demag_volume_charge_scales_cache = (token, scales) + return scales + + def _nodal_ms_values(self) -> np.ndarray: + if self.mesh is None: + raise RuntimeError("A mesh must be loaded before using material fields.") + + token = self._mesh_geometry_token() + if self._nodal_ms_values_cache is not None: + cached_token, cached_values = self._nodal_ms_values_cache + if cached_token == token: + return cached_values + + points = self._mesh_points() + simplices = np.asarray(self.mesh.simplices, dtype=int) + if simplices.size == 0 or simplices.ndim != 2 or simplices.shape[1] != 4: + default_ms = ( + self.materials[0].Ms.in_units_of(_si_unit("A/m")) if self.materials else 0.0 + ) + nodal_ms = np.full(len(points), default_ms, dtype=float) + self._nodal_ms_values_cache = (token, nodal_ms) + return nodal_ms + + regions = list(self.mesh.regions or [1] * len(simplices)) + simplex_ms = self._simplex_material_ms_values(regions) + volumes, positive = self._simplex_volume_weights(points, simplices) + nodal_ms = np.zeros(len(points), dtype=float) + weights = self._incident_cell_volume_sums(points, simplices, volumes) + if np.any(positive): + positive_simplices = simplices[positive] + weighted_ms = simplex_ms[positive] * volumes[positive] + for local_index in range(4): + np.add.at(nodal_ms, positive_simplices[:, local_index], weighted_ms) + + present = weights > 0.0 + nodal_ms[present] /= weights[present] + if np.any(~present): + nodal_ms[~present] = simplex_ms[positive][0] if np.any(positive) else 0.0 + self._nodal_ms_values_cache = (token, nodal_ms) + return nodal_ms diff --git a/src/nmag/simulation/mesh/probe.py b/src/nmag/simulation/mesh/probe.py new file mode 100644 index 0000000..4420b60 --- /dev/null +++ b/src/nmag/simulation/mesh/probe.py @@ -0,0 +1,234 @@ +from __future__ import annotations + +import time +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any + +import numpy as np + +from ...backends import ( + _load_rust_accelerator, + _selected_probe_geometry_backend, +) +from ...demag import _tetrahedral_barycentric_coordinates +from ..support import _simulation_compatibility_binding + + +class SimulationMeshProbeMixin: + if TYPE_CHECKING: + _probe_geometry_cache_token: Any | None + _probe_tetrahedral_cache: Any | None + + def __getattr__(self, name: str) -> Any: ... + + def _probe_is_within_mesh_bounds(self, pos: Sequence[float]) -> bool: + probe = np.asarray(pos, dtype=float) + if probe.shape != (3,): + raise ValueError(f"Probe position must be a 3-vector, got shape {probe.shape}.") + + lower, upper, span = self._mesh_bounds() + tolerance = 1.0e-12 * span + return bool(np.all(probe >= lower - tolerance) and np.all(probe <= upper + tolerance)) + + def _probe_is_legacy_null_vertex(self, probe: np.ndarray, points: np.ndarray) -> bool: + """Legacy nmag can return None at exact boundary mesh vertex 0.""" + if len(points) == 0 or not np.array_equal(probe, points[0]): + return False + simplices = np.asarray(self._require_mesh().simplices, dtype=int) + boundary_nodes = { + point_index + for _owner, face in self._boundary_faces_for_demag_mesh(points, simplices) + for point_index in face + } + return 0 in boundary_nodes + + def _probe_tetrahedral_field( + self, + probe: np.ndarray, + nodal_values: np.ndarray, + ) -> list[float] | None: + simplices, origins, inverse_matrices, lower, upper = self._probe_tetrahedral_geometry() + if len(simplices) == 0: + return None + tolerance = 1.0e-12 + started = time.perf_counter() + candidate_indices = np.flatnonzero( + np.all(probe >= lower - tolerance, axis=1) & np.all(probe <= upper + tolerance, axis=1) + ) + self._record_probe_timing( + "tetrahedral_candidate_search", + time.perf_counter() - started, + ) + if len(candidate_indices) == 0: + return None + + started = time.perf_counter() + offsets = probe - origins[candidate_indices] + local = np.einsum( + "nij,nj->ni", + inverse_matrices[candidate_indices], + offsets, + ) + barycentric = np.column_stack( + [ + 1.0 - np.sum(local, axis=1), + local, + ] + ) + inside = np.all(barycentric >= -tolerance, axis=1) & np.all( + barycentric <= 1.0 + tolerance, + axis=1, + ) + self._record_probe_timing( + "tetrahedral_barycentric", + time.perf_counter() - started, + ) + if not np.any(inside): + return None + + started = time.perf_counter() + candidate_position = int(np.flatnonzero(inside)[0]) + cache_index = int(candidate_indices[candidate_position]) + simplex = simplices[cache_index] + clipped = np.minimum(np.maximum(barycentric[candidate_position], 0.0), 1.0) + result = (clipped @ nodal_values[simplex]).tolist() + self._record_probe_timing( + "tetrahedral_value_interpolation", + time.perf_counter() - started, + ) + return result + + def _probe_tetrahedral_geometry( + self, + ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + total_started = time.perf_counter() + token = self._mesh_geometry_token() + if self._probe_geometry_cache_token == token and self._probe_tetrahedral_cache is not None: + self._record_probe_timing("tetrahedral_geometry_cache_hit", 0.0) + self._record_probe_timing( + "tetrahedral_geometry", + time.perf_counter() - total_started, + ) + return self._probe_tetrahedral_cache + + build_started = time.perf_counter() + backend = _selected_probe_geometry_backend(getattr(self, "config", None)) + self._probe_tetrahedral_cache = ( + self._probe_tetrahedral_geometry_rust() + if backend == "rust" + else self._probe_tetrahedral_geometry_python() + ) + self._probe_geometry_cache_token = token + self._record_probe_timing( + f"tetrahedral_geometry_build:{backend}", + time.perf_counter() - build_started, + ) + self._record_probe_timing( + "tetrahedral_geometry_build", + time.perf_counter() - build_started, + ) + self._record_probe_timing( + "tetrahedral_geometry", + time.perf_counter() - total_started, + ) + return self._probe_tetrahedral_cache + + def _probe_tetrahedral_geometry_python( + self, + ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + points = self._mesh_points() + simplices = np.asarray(self._require_mesh().simplices, dtype=int) + if simplices.size == 0: + empty_simplices = np.zeros((0, 4), dtype=int) + empty_vectors = np.zeros((0, 3), dtype=float) + empty_matrices = np.zeros((0, 3, 3), dtype=float) + return ( + empty_simplices, + empty_vectors, + empty_matrices, + empty_vectors, + empty_vectors, + ) + + valid_simplices: list[np.ndarray] = [] + origins: list[np.ndarray] = [] + inverse_matrices: list[np.ndarray] = [] + lower: list[np.ndarray] = [] + upper: list[np.ndarray] = [] + for simplex in simplices: + tetrahedron = points[simplex] + matrix = np.column_stack( + [ + tetrahedron[1] - tetrahedron[0], + tetrahedron[2] - tetrahedron[0], + tetrahedron[3] - tetrahedron[0], + ] + ) + try: + inverse = np.linalg.inv(matrix) + except np.linalg.LinAlgError: + continue + valid_simplices.append(simplex) + origins.append(tetrahedron[0]) + inverse_matrices.append(inverse) + lower.append(np.min(tetrahedron, axis=0)) + upper.append(np.max(tetrahedron, axis=0)) + + if not valid_simplices: + empty_vectors = np.empty((0, 3), dtype=float) + return ( + np.empty((0, 4), dtype=int), + empty_vectors, + np.empty((0, 3, 3), dtype=float), + empty_vectors, + empty_vectors, + ) + return ( + np.asarray(valid_simplices, dtype=int), + np.asarray(origins, dtype=float), + np.asarray(inverse_matrices, dtype=float), + np.asarray(lower, dtype=float), + np.asarray(upper, dtype=float), + ) + + def _probe_tetrahedral_geometry_rust( + self, + ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + rust_accel = _simulation_compatibility_binding( + "_load_rust_accelerator", + _load_rust_accelerator, + )("NmagConfig.accelerator['probe_geometry']") + geometry = rust_accel.build_probe_tetrahedral_geometry( + np.asarray(self._mesh_points(), dtype=np.float64), + np.asarray(self._require_mesh().simplices, dtype=np.int64), + ) + valid_simplices, origins, inverse_matrices, lower, upper = geometry + return ( + np.asarray(valid_simplices, dtype=int), + np.asarray(origins, dtype=float), + np.asarray(inverse_matrices, dtype=float), + np.asarray(lower, dtype=float), + np.asarray(upper, dtype=float), + ) + + def _probe_tetrahedral_field_reference( + self, + probe: np.ndarray, + nodal_values: np.ndarray, + ) -> list[float] | None: + """Scalar reference path for checking cached probe interpolation.""" + if self.mesh is None: + raise RuntimeError("A mesh must be loaded before probing fields.") + simplices = np.asarray(self._require_mesh().simplices, dtype=int) + if simplices.size == 0: + return None + points = self._mesh_points() + for simplex in simplices: + barycentric = _tetrahedral_barycentric_coordinates( + points[simplex], + probe, + ) + if barycentric is None: + continue + return (barycentric @ nodal_values[simplex]).tolist() + return None diff --git a/src/nmag/simulation/restart.py b/src/nmag/simulation/restart.py new file mode 100644 index 0000000..967fa14 --- /dev/null +++ b/src/nmag/simulation/restart.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import time +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import numpy as np + +from si.physical import SI + +from ..checkpoint import read_checkpoint, runtime_state, save_checkpoint, validate_mesh +from ..dynamics import IntegratorStats + + +class SimulationRestartMixin: + if TYPE_CHECKING: + + def __getattr__(self, name: str) -> Any: ... + + def get_restart_file_name(self) -> Path: + """Return the default native checkpoint path beside simulation output.""" + return self.writer.h5_filename.with_name(f"{self.name}_restart.h5") + + def save_restart_file(self, filename: str | Path | None = None) -> Path: + """Atomically save a complete native checkpoint. + + Args: + filename: Destination path, or ``None`` for the simulation's default + restart filename. + + Returns: + The checkpoint path. + + Raises: + RuntimeError: If mesh or magnetization state is incomplete. + """ + destination = self.get_restart_file_name() if filename is None else Path(filename) + return save_checkpoint(self, destination) + + def load_m_from_h5file(self, filename: str | Path) -> None: + """Load only checkpoint magnetization into the configured simulation. + + Args: + filename: Native checkpoint created for the same mesh. + + Raises: + ValueError: If the checkpoint or mesh is incompatible. + """ + point_count = len(self._mesh_points()) + contents = read_checkpoint(Path(filename), point_count) + validate_mesh(self, contents) + self._fields["m"] = np.array(contents.magnetisation, dtype=float, copy=True) + self._invalidate_after_checkpoint_restore() + + def load_restart_file(self, filename: str | Path | None = None) -> None: + """Restore complete native state into a compatible loaded simulation. + + The target simulation must already have the same mesh and compatible + materials. Magnetization, pinning, current density, applied field, + clock, integrator controls, and convergence state are restored. + + Args: + filename: Source path, or ``None`` for the default restart filename. + + Raises: + ValueError: If checkpoint schema, mesh, materials, or dimensions are + incompatible. + """ + source = self.get_restart_file_name() if filename is None else Path(filename) + point_count = len(self._mesh_points()) + contents = read_checkpoint(source, point_count) + runtime = runtime_state(self, contents) + + self._fields["m"] = np.array(contents.magnetisation, dtype=float, copy=True) + self._fields["pin"] = np.array(contents.pinning, dtype=float, copy=True) + self._fields["H_ext"] = np.array(contents.external_field, dtype=float, copy=True) + if contents.current_density is None: + self._fields.pop("current_density", None) + else: + self._fields["current_density"] = np.array( + contents.current_density, + dtype=float, + copy=True, + ) + self.clock = runtime.clock + self._integrator_config = runtime.integrator_config + self.stopping_dm_dt = runtime.stopping_dm_dt + self.max_time_reached = SI(runtime.maximum_time_seconds, "s") + self.max_dm_dt = runtime.maximum_dm_dt + self.convergence = runtime.convergence + self._restarting = False + self._invalidate_after_checkpoint_restore() + + def _invalidate_after_checkpoint_restore(self) -> None: + self._invalidate_demag() + self._subfield_array_cache = None + self._subfield_average_cache = None + self._integrator = None + self._integrator_is_stale = True + self._integrator_rhs_evaluations = 0 + self._integrator_effective_max_step_seconds = self._integrator_config.maximum_step_seconds + self._last_integrator_stats = IntegratorStats(status="restarted") + self._stage_wall_started = time.perf_counter() diff --git a/src/nmag/simulation/support.py b/src/nmag/simulation/support.py new file mode 100644 index 0000000..56b0eb9 --- /dev/null +++ b/src/nmag/simulation/support.py @@ -0,0 +1,224 @@ +from __future__ import annotations + +import sys +from collections.abc import Callable, Iterable, Sequence +from functools import cache, lru_cache +from typing import Any, cast + +import numpy as np + +from si.physical import SI + + +@cache +def _si_unit(unit: str) -> SI: + return SI(1, unit) + + +@lru_cache(maxsize=1) +def _si_dimensionless() -> SI: + return SI(1) + + +Vector = Sequence[float] +VectorField = Callable[[Sequence[float]], Sequence[float]] +VectorFieldInput = Sequence[float] | np.ndarray | VectorField +ScalarField = Callable[[Sequence[float]], float | SI] +ScalarFieldInput = float | SI | Sequence[float] | np.ndarray | ScalarField +MU0 = 4.0 * np.pi * 1.0e-7 +MU0_OVER_4PI = 1.0 / (4.0 * np.pi) +# Legacy Nmag's default simulation units make one magnetisation unit 1e6 A/m. +# Its exported dmdt field uses that global reference rather than a material's Ms. +LEGACY_DMDT_MAGNETISATION_SCALE = 1.0e6 +# Legacy Nmag's fixed simulation base length is one nanometre. The demag +# solver's weak divergence is boxed with this base volume when exporting rho, +# independently of the unit_length supplied for a particular mesh. +LEGACY_RHO_BOXED_VOLUME_M3 = 1.0e-27 +DERIVED_FIELD_NAMES = { + "M", + "H_total", + "H_anis", + "H_exch", + "dmdt", + "E_total", + "E_ext", + "E_anis", + "E_exch", + "E_demag", + "phi", + "rho", + "pin", + "dm_dcurrent", +} +_MESH_EDGE_SET_THRESHOLD = 4096 +_NO_CONSTANT_AVERAGE = object() +_NO_COMPOSED_AVERAGE = object() + + +def _copy_average_value(value: object) -> object: + if isinstance(value, np.ndarray): + return cast(list[Any], value.tolist()) + if isinstance(value, list): + return list(cast(list[Any], value)) + return value + + +def _as_vector3(values: Sequence[Any], *, unit: SI | None = None) -> list[float]: + if len(values) != 3: + raise ValueError(f"Expected a 3-vector, got {len(values)} components.") + + if unit is not None: + scale = unit.in_units_of(_si_unit("A/m")) + return [float(value) * scale for value in values] + + result: list[float] = [] + for value in values: + if isinstance(value, SI): + result.append(value.in_units_of(_si_unit("A/m"))) + else: + result.append(float(value)) + return result + + +def _normalise_m(values: Sequence[Any]) -> list[float]: + vector = np.asarray(values, dtype=float) + if vector.shape != (3,): + raise ValueError(f"Magnetisation must be a 3-vector, got shape {vector.shape}.") + + norm = float(np.linalg.norm(vector)) + if norm == 0.0: + raise ValueError("Magnetisation vectors must be non-zero.") + + return cast(list[float], (vector / norm).tolist()) + + +def _dimensionless_value(value: Any, *, fieldname: str) -> float: + result = value.in_units_of(_si_dimensionless()) if isinstance(value, SI) else float(value) + if not np.isfinite(result): + raise ValueError(f"{fieldname} values must be finite.") + return result + + +def _scalar_nodal_field( + values: ScalarFieldInput, + points: Sequence[Sequence[float]], + *, + fieldname: str, +) -> np.ndarray: + point_count = len(points) + if callable(values): + return np.asarray( + [_dimensionless_value(values(point), fieldname=fieldname) for point in points], + dtype=float, + ) + if isinstance(values, SI) or np.isscalar(values): + return np.full( + point_count, + _dimensionless_value(values, fieldname=fieldname), + dtype=float, + ) + + array = np.asarray(values, dtype=float) + if array.shape != (point_count,): + raise ValueError( + f"{fieldname} must be a scalar, callable, or one value per mesh point; " + f"expected shape ({point_count},), got {array.shape}." + ) + if not np.all(np.isfinite(array)): + raise ValueError(f"{fieldname} values must be finite.") + return np.array(array, dtype=float, copy=True) + + +def _vector_in_units( + values: Sequence[Any], + *, + unit: SI | None, + expected_unit: SI, + fieldname: str, +) -> np.ndarray: + if len(values) != 3: + raise ValueError(f"{fieldname} values must be 3-vectors, got {len(values)} components.") + if unit is not None: + vector = np.asarray(values, dtype=float) * unit.in_units_of(expected_unit) + else: + vector = np.asarray( + [ + value.in_units_of(expected_unit) if isinstance(value, SI) else float(value) + for value in values + ], + dtype=float, + ) + if not np.all(np.isfinite(vector)): + raise ValueError(f"{fieldname} values must be finite.") + return vector + + +def _vector_nodal_field( + values: VectorFieldInput, + points: Sequence[Sequence[float]], + *, + unit: SI | None, + expected_unit: SI, + fieldname: str, +) -> np.ndarray: + if callable(values): + return np.asarray( + [ + _vector_in_units( + values(point), + unit=unit, + expected_unit=expected_unit, + fieldname=fieldname, + ) + for point in points + ], + dtype=float, + ) + + array = np.asarray(values, dtype=object if unit is None else float) + if array.shape == (3,): + vector = _vector_in_units( + cast(Sequence[Any], values), + unit=unit, + expected_unit=expected_unit, + fieldname=fieldname, + ) + return np.tile(vector, (len(points), 1)) + if array.shape != (len(points), 3): + raise ValueError( + f"{fieldname} must be a 3-vector, callable, or one vector per mesh point; " + f"expected shape ({len(points)}, 3), got {array.shape}." + ) + return np.asarray( + [ + _vector_in_units( + cast(Sequence[Any], row), + unit=unit, + expected_unit=expected_unit, + fieldname=fieldname, + ) + for row in array + ], + dtype=float, + ) + + +def _flatten_materials(region_materials: Iterable[tuple[str, Any]]) -> list[Any]: + materials: list[Any] = [] + seen_names: set[str] = set() + for _, entry in region_materials: + region_mats: list[Any] = cast(list[Any], entry) if isinstance(entry, list) else [entry] + for material in region_mats: + if material.name not in seen_names: + seen_names.add(material.name) + materials.append(material) + return materials + + +def _simulation_compatibility_binding( + name: str, fallback: Callable[..., Any] +) -> Callable[..., Any]: + """Use a legacy simulation-module override when tests or extensions provide one.""" + + simulation_module = sys.modules.get("nmag.simulation") + return getattr(simulation_module, name, fallback) if simulation_module is not None else fallback diff --git a/src/nmesh/__init__.py b/src/nmesh/__init__.py index 671625b..8194015 100644 --- a/src/nmesh/__init__.py +++ b/src/nmesh/__init__.py @@ -1 +1,130 @@ -from .nmesh import * +from __future__ import annotations + +from typing import Any + +from .nmesh import ( + Mesh, + MeshBase, + MeshFromFile, + Point, + Simplex, + generate_1d_mesh, + generate_1d_mesh_components, + get_default_meshing_parameters, + hdf5_mesh_get_permutation, + load, + memory_report, + mesh_from_points_and_simplices, + outer_corners, + save, + to_lists, + tolists, + write_mesh, +) + +_GEOMETRY_EXPORTS = { + "AffineTransform", + "Body", + "Box", + "Conic", + "Ellipsoid", + "Helix", + "MeshObject", + "bc_box", + "bc_ellipsoid", + "bc_frustum", + "bc_helix", + "difference", + "intersect", + "inverse_axis_rotation", + "inverse_plane_rotation", + "inverse_scale", + "inverse_shift", + "union", +} + +_MESHER_EXPORTS = { + "Callback", + "EngineFunc", + "MeshEngineCommand", + "MeshEngineStatus", + "MeshingParameters", + "do_every_n_steps_driver", + "make_mg_gendriver", +} + +__all__ = [ + "Mesh", + "MeshBase", + "MeshFromFile", + "Point", + "Simplex", + "generate_1d_mesh", + "generate_1d_mesh_components", + "get_default_meshing_parameters", + "hdf5_mesh_get_permutation", + "load", + "memory_report", + "mesh_from_points_and_simplices", + "outer_corners", + "save", + "to_lists", + "tolists", + "write_mesh", + "AffineTransform", + "Body", + "Box", + "Callback", + "Conic", + "Ellipsoid", + "EngineFunc", + "Helix", + "MeshEngineCommand", + "MeshEngineStatus", + "MeshObject", + "MeshingParameters", + "bc_box", + "bc_ellipsoid", + "bc_frustum", + "bc_helix", + "difference", + "do_every_n_steps_driver", + "intersect", + "inverse_axis_rotation", + "inverse_plane_rotation", + "inverse_scale", + "inverse_shift", + "make_mg_gendriver", + "union", +] + + +def __getattr__(name: str) -> Any: + if name in _GEOMETRY_EXPORTS: + from . import geometry + + value = getattr(geometry, name) + globals()[name] = value + return value + + if name == "MeshingParameters": + from .mesher.meshing_parameters import MeshingParameters + + globals()[name] = MeshingParameters + return MeshingParameters + + if name in { + "Callback", + "EngineFunc", + "MeshEngineCommand", + "MeshEngineStatus", + "do_every_n_steps_driver", + "make_mg_gendriver", + }: + from .mesher import driver + + value = getattr(driver, name) + globals()[name] = value + return value + + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/nmesh/backend.py b/src/nmesh/backend.py index e608bab..c50b1a5 100644 --- a/src/nmesh/backend.py +++ b/src/nmesh/backend.py @@ -1,21 +1,32 @@ from __future__ import annotations import copy +from collections.abc import Callable, Sequence from dataclasses import dataclass, field -from typing import Any, Protocol, runtime_checkable +from typing import TYPE_CHECKING, Any, Protocol, TypeAlias, runtime_checkable + +if TYPE_CHECKING: + from .geometry import Body + +Point: TypeAlias = list[float] +Simplex: TypeAlias = list[int] +Surface: TypeAlias = list[int] +BodyHandle: TypeAlias = object +MesherDriver: TypeAlias = Callable[..., object] +MesherConfig: TypeAlias = dict[str, Any] @dataclass(slots=True) class RawMesh: - points: list[list[float]] = field(default_factory=list) - simplices: list[list[int]] = field(default_factory=list) - regions: list[int] = field(default_factory=list) - point_regions: list[list[int]] = field(default_factory=list) - surfaces: list[Any] = field(default_factory=list) - links: list[tuple[int, int]] = field(default_factory=list) - region_volumes: list[float] = field(default_factory=list) - periodic_point_indices: list[list[int]] = field(default_factory=list) - permutation: list[int] = field(default_factory=list) + points: list[Point] = field(default_factory=list[Point]) + simplices: list[Simplex] = field(default_factory=list[Simplex]) + regions: list[int] = field(default_factory=list[int]) + point_regions: list[list[int]] = field(default_factory=list[list[int]]) + surfaces: list[Surface] = field(default_factory=list[Surface]) + links: list[tuple[int, int]] = field(default_factory=list[tuple[int, int]]) + region_volumes: list[float] = field(default_factory=list[float]) + periodic_point_indices: list[list[int]] = field(default_factory=list[list[int]]) + permutation: list[int] = field(default_factory=list[int]) dim: int = 3 @@ -25,36 +36,38 @@ def mesh_scale_node_positions(self, raw_mesh: RawMesh, scale: float) -> None: .. def mesh_writefile(self, path: str, raw_mesh: RawMesh) -> None: ... def mesh_nr_simplices(self, raw_mesh: RawMesh) -> int: ... def mesh_nr_points(self, raw_mesh: RawMesh) -> int: ... - def mesh_plotinfo(self, raw_mesh: RawMesh) -> list[Any]: ... + def mesh_plotinfo(self, raw_mesh: RawMesh) -> list[object]: ... def mesh_plotinfo_points(self, raw_mesh: RawMesh) -> list[list[float]]: ... def mesh_plotinfo_pointsregions(self, raw_mesh: RawMesh) -> list[list[int]]: ... def mesh_plotinfo_simplices(self, raw_mesh: RawMesh) -> list[list[int]]: ... def mesh_plotinfo_simplicesregions(self, raw_mesh: RawMesh) -> list[int]: ... - def mesh_plotinfo_surfaces_and_surfacesregions(self, raw_mesh: RawMesh) -> list[Any]: ... + def mesh_plotinfo_surfaces_and_surfacesregions( + self, raw_mesh: RawMesh + ) -> tuple[list[Surface], list[int]]: ... def mesh_plotinfo_links(self, raw_mesh: RawMesh) -> list[tuple[int, int]]: ... def mesh_dim(self, raw_mesh: RawMesh) -> int: ... def mesh_plotinfo_regionvolumes(self, raw_mesh: RawMesh) -> list[float]: ... def mesh_plotinfo_periodic_points_indices(self, raw_mesh: RawMesh) -> list[list[int]]: ... - def mesh_set_vertex_distribution(self, raw_mesh: RawMesh, dist: Any) -> None: ... + def mesh_set_vertex_distribution(self, raw_mesh: RawMesh, dist: object) -> None: ... def mesh_get_permutation(self, raw_mesh: RawMesh) -> list[int]: ... def mesh_readfile(self, filename: str, do_reorder: bool, do_distribute: bool) -> RawMesh: ... - def copy_mesher_defaults(self, defaults: dict[str, Any]) -> dict[str, Any]: ... + def copy_mesher_defaults(self, defaults: MesherConfig) -> MesherConfig: ... def mesh_bodies_raw( self, - driver: Any, - mesher: dict[str, Any], - bb_min: Any, - bb_max: Any, + driver: MesherDriver, + mesher: MesherConfig, + bb_min: Point, + bb_max: Point, mesh_ext: int, - objects: Any, + objects: list[Body], a0: float, density: str, - fixed: Any, - mobile: Any, - simply: Any, - periodic: Any, - cache: Any, - hints: Any, + fixed: list[Point], + mobile: list[Point], + simply: list[Point], + periodic: list[float], + cache: str, + hints: Sequence[Sequence[object]], ) -> RawMesh: ... def mesh_from_points_and_simplices( self, @@ -66,34 +79,44 @@ def mesh_from_points_and_simplices( reorder: bool, distribute: bool, ) -> RawMesh: ... - def body_union(self, objs: Any) -> Any: ... - def body_difference(self, obj1: Any, objs: Any) -> Any: ... - def body_intersection(self, objs: Any) -> Any: ... - def body_shifted_sc(self, obj: Any, shift: Any) -> Any: ... - def body_shifted_bc(self, obj: Any, shift: Any) -> Any: ... - def body_scaled(self, obj: Any, scale: Any) -> Any: ... - def body_rotated_sc(self, obj: Any, a1: Any, a2: Any, ang: Any) -> Any: ... - def body_rotated_bc(self, obj: Any, a1: Any, a2: Any, ang: Any) -> Any: ... - def body_rotated_axis_sc(self, obj: Any, axis: Any, ang: Any) -> Any: ... - def body_rotated_axis_bc(self, obj: Any, axis: Any, ang: Any) -> Any: ... - def body_box(self, p1: Any, p2: Any) -> Any: ... - def body_ellipsoid(self, length: Any) -> Any: ... - def body_frustum(self, c1: Any, r1: Any, c2: Any, r2: Any) -> Any: ... - def body_helix(self, c1: Any, r1: Any, c2: Any, r2: Any) -> Any: ... + def body_union(self, objs: Sequence[BodyHandle]) -> BodyHandle: ... + def body_difference(self, obj1: BodyHandle, objs: Sequence[BodyHandle]) -> BodyHandle: ... + def body_intersection(self, objs: Sequence[BodyHandle]) -> BodyHandle: ... + def body_shifted_sc(self, obj: BodyHandle, shift: Sequence[float]) -> BodyHandle: ... + def body_shifted_bc(self, obj: BodyHandle, shift: Sequence[float]) -> BodyHandle: ... + def body_scaled(self, obj: BodyHandle, scale: Sequence[float]) -> BodyHandle: ... + def body_rotated_sc(self, obj: BodyHandle, a1: int, a2: int, ang: float) -> BodyHandle: ... + def body_rotated_bc(self, obj: BodyHandle, a1: int, a2: int, ang: float) -> BodyHandle: ... + def body_rotated_axis_sc( + self, obj: BodyHandle, axis: Sequence[float], ang: float + ) -> BodyHandle: ... + def body_rotated_axis_bc( + self, obj: BodyHandle, axis: Sequence[float], ang: float + ) -> BodyHandle: ... + def body_box(self, p1: Sequence[float], p2: Sequence[float]) -> BodyHandle: ... + def body_ellipsoid(self, length: Sequence[float]) -> BodyHandle: ... + def body_frustum( + self, c1: Sequence[float], r1: float, c2: Sequence[float], r2: float + ) -> BodyHandle: ... + def body_helix( + self, c1: Sequence[float], r1: float, c2: Sequence[float], r2: float + ) -> BodyHandle: ... @property - def mesher_defaults(self) -> dict[str, Any]: ... + def mesher_defaults(self) -> MesherConfig: ... class StubMeshBackend: """Lightweight in-memory backend used until the Python mesher is complete.""" - def mesh_scale_node_positions(self, raw_mesh: RawMesh, scale: float): + def mesh_scale_node_positions(self, raw_mesh: RawMesh, scale: float) -> None: for point in raw_mesh.points: for index, value in enumerate(point): point[index] = value * scale + volume_scale = abs(scale) ** raw_mesh.dim + raw_mesh.region_volumes = [volume * volume_scale for volume in raw_mesh.region_volumes] - def mesh_writefile(self, path: str, raw_mesh: RawMesh): + def mesh_writefile(self, path: str, raw_mesh: RawMesh) -> None: return None def mesh_nr_simplices(self, raw_mesh: RawMesh) -> int: @@ -102,29 +125,31 @@ def mesh_nr_simplices(self, raw_mesh: RawMesh) -> int: def mesh_nr_points(self, raw_mesh: RawMesh) -> int: return len(raw_mesh.points) - def mesh_plotinfo(self, raw_mesh: RawMesh): + def mesh_plotinfo(self, raw_mesh: RawMesh) -> list[object]: return [ raw_mesh.points, raw_mesh.links, [raw_mesh.simplices, raw_mesh.point_regions, raw_mesh.regions], ] - def mesh_plotinfo_points(self, raw_mesh: RawMesh): + def mesh_plotinfo_points(self, raw_mesh: RawMesh) -> list[Point]: return raw_mesh.points - def mesh_plotinfo_pointsregions(self, raw_mesh: RawMesh): + def mesh_plotinfo_pointsregions(self, raw_mesh: RawMesh) -> list[list[int]]: return raw_mesh.point_regions - def mesh_plotinfo_simplices(self, raw_mesh: RawMesh): + def mesh_plotinfo_simplices(self, raw_mesh: RawMesh) -> list[Simplex]: return raw_mesh.simplices - def mesh_plotinfo_simplicesregions(self, raw_mesh: RawMesh): + def mesh_plotinfo_simplicesregions(self, raw_mesh: RawMesh) -> list[int]: return raw_mesh.regions - def mesh_plotinfo_surfaces_and_surfacesregions(self, raw_mesh: RawMesh): - return [raw_mesh.surfaces, []] + def mesh_plotinfo_surfaces_and_surfacesregions( + self, raw_mesh: RawMesh + ) -> tuple[list[Surface], list[int]]: + return raw_mesh.surfaces, [] - def mesh_plotinfo_links(self, raw_mesh: RawMesh): + def mesh_plotinfo_links(self, raw_mesh: RawMesh) -> list[tuple[int, int]]: return raw_mesh.links def mesh_dim(self, raw_mesh: RawMesh) -> int: @@ -132,41 +157,41 @@ def mesh_dim(self, raw_mesh: RawMesh) -> int: return len(raw_mesh.points[0]) return raw_mesh.dim - def mesh_plotinfo_regionvolumes(self, raw_mesh: RawMesh): + def mesh_plotinfo_regionvolumes(self, raw_mesh: RawMesh) -> list[float]: return raw_mesh.region_volumes - def mesh_plotinfo_periodic_points_indices(self, raw_mesh: RawMesh): + def mesh_plotinfo_periodic_points_indices(self, raw_mesh: RawMesh) -> list[list[int]]: return raw_mesh.periodic_point_indices - def mesh_set_vertex_distribution(self, raw_mesh: RawMesh, dist): - return None + def mesh_set_vertex_distribution(self, raw_mesh: RawMesh, dist: object) -> None: + raise NotImplementedError("Manual vertex distribution is not implemented.") - def mesh_get_permutation(self, raw_mesh: RawMesh): + def mesh_get_permutation(self, raw_mesh: RawMesh) -> list[int]: return raw_mesh.permutation - def mesh_readfile(self, filename: str, do_reorder: bool, do_distribute: bool): + def mesh_readfile(self, filename: str, do_reorder: bool, do_distribute: bool) -> RawMesh: return RawMesh() - def copy_mesher_defaults(self, defaults: dict[str, Any]) -> dict[str, Any]: + def copy_mesher_defaults(self, defaults: MesherConfig) -> MesherConfig: return copy.deepcopy(defaults) def mesh_bodies_raw( self, - driver, - mesher: dict[str, Any], - bb_min, - bb_max, + driver: MesherDriver, + mesher: MesherConfig, + bb_min: Point, + bb_max: Point, mesh_ext: int, - objects, + objects: list[Body], a0: float, density: str, - fixed, - mobile, - simply, - periodic, - cache, - hints, - ): + fixed: list[Point], + mobile: list[Point], + simply: list[Point], + periodic: list[float], + cache: str, + hints: Sequence[Sequence[object]], + ) -> RawMesh: from .mesher.relaxation import mesh_bodies_raw as python_mesh_bodies_raw return python_mesh_bodies_raw( @@ -182,16 +207,16 @@ def mesh_bodies_raw( mobile, simply, periodic, - hints, + [list(hint) for hint in hints], ) def mesh_from_points_and_simplices( self, dim: int, - points, - simplices, - regions, - periodic, + points: list[Point], + simplices: list[Simplex], + regions: list[int], + periodic: list[list[int]], reorder: bool, distribute: bool, ) -> RawMesh: @@ -203,50 +228,58 @@ def mesh_from_points_and_simplices( periodic_point_indices=periodic, ) - def body_union(self, objs): + def body_union(self, objs: Sequence[BodyHandle]) -> BodyHandle: return ("union", objs) - def body_difference(self, obj1, objs): + def body_difference(self, obj1: BodyHandle, objs: Sequence[BodyHandle]) -> BodyHandle: return ("difference", obj1, objs) - def body_intersection(self, objs): + def body_intersection(self, objs: Sequence[BodyHandle]) -> BodyHandle: return ("intersection", objs) - def body_shifted_sc(self, obj, shift): + def body_shifted_sc(self, obj: BodyHandle, shift: Sequence[float]) -> BodyHandle: return ("shifted_sc", obj, shift) - def body_shifted_bc(self, obj, shift): + def body_shifted_bc(self, obj: BodyHandle, shift: Sequence[float]) -> BodyHandle: return ("shifted_bc", obj, shift) - def body_scaled(self, obj, scale): + def body_scaled(self, obj: BodyHandle, scale: Sequence[float]) -> BodyHandle: return ("scaled", obj, scale) - def body_rotated_sc(self, obj, a1, a2, ang): + def body_rotated_sc(self, obj: BodyHandle, a1: int, a2: int, ang: float) -> BodyHandle: return ("rotated_sc", obj, a1, a2, ang) - def body_rotated_bc(self, obj, a1, a2, ang): + def body_rotated_bc(self, obj: BodyHandle, a1: int, a2: int, ang: float) -> BodyHandle: return ("rotated_bc", obj, a1, a2, ang) - def body_rotated_axis_sc(self, obj, axis, ang): + def body_rotated_axis_sc( + self, obj: BodyHandle, axis: Sequence[float], ang: float + ) -> BodyHandle: return ("rotated_axis_sc", obj, axis, ang) - def body_rotated_axis_bc(self, obj, axis, ang): + def body_rotated_axis_bc( + self, obj: BodyHandle, axis: Sequence[float], ang: float + ) -> BodyHandle: return ("rotated_axis_bc", obj, axis, ang) - def body_box(self, p1, p2): + def body_box(self, p1: Sequence[float], p2: Sequence[float]) -> BodyHandle: return ("box", p1, p2) - def body_ellipsoid(self, length): + def body_ellipsoid(self, length: Sequence[float]) -> BodyHandle: return ("ellipsoid", length) - def body_frustum(self, c1, r1, c2, r2): + def body_frustum( + self, c1: Sequence[float], r1: float, c2: Sequence[float], r2: float + ) -> BodyHandle: return ("frustum", c1, r1, c2, r2) - def body_helix(self, c1, r1, c2, r2): + def body_helix( + self, c1: Sequence[float], r1: float, c2: Sequence[float], r2: float + ) -> BodyHandle: return ("helix", c1, r1, c2, r2) @property - def mesher_defaults(self) -> dict[str, Any]: + def mesher_defaults(self) -> MesherConfig: return {"parameters": {}} diff --git a/src/nmesh/geometry/__init__.py b/src/nmesh/geometry/__init__.py index 5160a6f..43a7b8f 100644 --- a/src/nmesh/geometry/__init__.py +++ b/src/nmesh/geometry/__init__.py @@ -29,3 +29,24 @@ inverse_scale, inverse_shift, ) + +__all__ = [ + "AffineTransform", + "Body", + "Box", + "Conic", + "Ellipsoid", + "Helix", + "MeshObject", + "bc_box", + "bc_ellipsoid", + "bc_frustum", + "bc_helix", + "difference", + "intersect", + "inverse_axis_rotation", + "inverse_plane_rotation", + "inverse_scale", + "inverse_shift", + "union", +] diff --git a/src/nmesh/geometry/boolean_operations.py b/src/nmesh/geometry/boolean_operations.py index 9667128..c86bf30 100644 --- a/src/nmesh/geometry/boolean_operations.py +++ b/src/nmesh/geometry/boolean_operations.py @@ -9,6 +9,7 @@ from __future__ import annotations from collections.abc import Sequence +from typing import cast import numpy as np @@ -16,10 +17,13 @@ from .primitives import Body, MeshObject, _make_body -def _validate_objects(objects: Sequence[MeshObject], operation: str) -> tuple[MeshObject, ...]: +def _validate_objects(objects: object, operation: str) -> tuple[MeshObject, ...]: if not isinstance(objects, Sequence): raise TypeError(f"{operation} expects a sequence of MeshObject instances") - object_tuple = tuple(objects) + object_sequence = cast(Sequence[object], objects) + if not all(isinstance(obj, MeshObject) for obj in object_sequence): + raise TypeError(f"{operation} expects MeshObject instances") + object_tuple = tuple(obj for obj in object_sequence if isinstance(obj, MeshObject)) if len(object_tuple) < 2: raise ValueError(f"{operation} requires at least two objects") dims = {obj.dim for obj in object_tuple} @@ -65,7 +69,14 @@ def _stack_evaluations(bodies: Sequence[Body], points: FloatArray) -> FloatArray def union(objects: Sequence[MeshObject]) -> MeshObject: - """Return a body that is inside any of the supplied objects.""" + """Return a geometry that is inside any supplied object. + + Args: + objects: At least two objects with the same dimension. + + Returns: + Combined mesh object. + """ members = _validate_objects(objects, "Union") fixed_points, mobile_points = _collect_points(members) @@ -79,7 +90,15 @@ def union(objects: Sequence[MeshObject]) -> MeshObject: def difference(mother: MeshObject, subtract: Sequence[MeshObject]) -> MeshObject: - """Return the mother object with each subtractor carved out of it.""" + """Carve each subtractor out of a mother object. + + Args: + mother: Object to retain. + subtract: Same-dimensional objects to remove. + + Returns: + Resulting mesh object. + """ mother_body = _body_for(mother) subtractors = tuple(subtract) @@ -118,7 +137,14 @@ def difference(mother: MeshObject, subtract: Sequence[MeshObject]) -> MeshObject def intersect(objects: Sequence[MeshObject]) -> MeshObject: - """Return a body that is inside all of the supplied objects.""" + """Return a geometry that is inside every supplied object. + + Args: + objects: At least two objects with the same dimension. + + Returns: + Intersected mesh object. + """ members = _validate_objects(objects, "Intersection") fixed_points, mobile_points = _collect_points(members) diff --git a/src/nmesh/geometry/primitives.py b/src/nmesh/geometry/primitives.py index 5057598..cc1e572 100644 --- a/src/nmesh/geometry/primitives.py +++ b/src/nmesh/geometry/primitives.py @@ -14,9 +14,9 @@ import itertools import math -from collections.abc import Iterable, Sequence +from collections.abc import Callable, Iterable, Sequence from dataclasses import dataclass -from typing import Callable, Literal, TypeAlias +from typing import Literal, TypeAlias import numpy as np from numpy.typing import ArrayLike @@ -24,11 +24,11 @@ from ..utils.types import BoolArray, FloatArray from .transform import ( AffineTransform, + _as_vector, inverse_axis_rotation, inverse_plane_rotation, inverse_scale, inverse_shift, - _as_vector, ) SignedField: TypeAlias = Callable[[FloatArray], FloatArray] @@ -38,16 +38,13 @@ Rotate2DTransform: TypeAlias = tuple[Literal["rotate2d"], float] Rotate3DTransform: TypeAlias = tuple[Literal["rotate3d"], ArrayLike, float] TransformationStep: TypeAlias = ( - ShiftTransform - | ScaleTransform - | RotateTransform - | Rotate2DTransform - | Rotate3DTransform + ShiftTransform | ScaleTransform | RotateTransform | Rotate2DTransform | Rotate3DTransform ) # Number of complete spiral rotations for the helix primitive (4 turns = 8π) _HELIX_SPIRAL_TURNS = 4.0 + def _as_float_points(points: Sequence[Sequence[float]] | None) -> list[list[float]]: return [list(map(float, point)) for point in (points or [])] @@ -57,7 +54,7 @@ def _coerce_query_points(points: ArrayLike, dim: int) -> tuple[FloatArray, bool] if coords.ndim == 1: if len(coords) != dim: raise ValueError(f"Expected a point of length {dim}, got {len(coords)}") - return coords.reshape(1, dim).astype(np.float64, copy=False), True + return coords[np.newaxis, :].astype(np.float64, copy=False), True if coords.ndim == 2 and coords.shape[1] == dim: return coords.astype(np.float64, copy=False), False raise ValueError(f"Expected points with shape ({dim},) or (N, {dim}), got {coords.shape}") @@ -76,7 +73,7 @@ def evaluate(self, points: ArrayLike) -> float | FloatArray: coords, scalar = _coerce_query_points(points, self.dim) local_coords = self.transform.apply(coords) - values = np.asarray(self.evaluator(local_coords), dtype=float).reshape(-1) + values = np.asarray(self.evaluator(local_coords), dtype=float).flatten() if scalar: return float(values[0]) return values.astype(np.float64, copy=False) @@ -86,7 +83,7 @@ def transformed( inverse_transform: AffineTransform, *, system_coords: bool, - ) -> "Body": + ) -> Body: """Return a copy of the body with one more affine transform applied. Args: @@ -235,9 +232,7 @@ def evaluate(points: FloatArray) -> FloatArray: ), ) alpha = 2.0 * math.pi * _HELIX_SPIRAL_TURNS * axis_factor - spiral_direction = np.column_stack( - (np.cos(alpha), np.sin(alpha), np.zeros_like(alpha)) - ) + spiral_direction = np.column_stack((np.cos(alpha), np.sin(alpha), np.zeros_like(alpha))) helix_circle_radius = radius2 * (1.0 - axis_factor) helix_spiral_radius = radius1 * (1.0 - axis_factor) axis_projection = point1 + np.outer(axis_factor, axis) @@ -261,7 +256,7 @@ def __init__( mobile: Sequence[Sequence[float]] | None = None, *, body: Body | None = None, - ): + ) -> None: self.dim = int(dim) self.fixed_points = _as_float_points(fixed) self.mobile_points = _as_float_points(mobile) @@ -285,21 +280,21 @@ def contains(self, points: ArrayLike) -> bool | BoolArray: return values > 0.0 return values > 0.0 - def shift(self, vector: ArrayLike, system_coords: bool = True): + def shift(self, vector: ArrayLike, system_coords: bool = True) -> None: """Translate the object by the given vector.""" body = self._require_body() inverse_transform = inverse_shift(_as_vector(vector, dim=self.dim)) self.obj = body.transformed(inverse_transform, system_coords=system_coords) - def scale(self, factors: ArrayLike): + def scale(self, factors: ArrayLike) -> None: """Scale the object in body coordinates by the supplied per-axis factors.""" body = self._require_body() inverse_transform = inverse_scale(_as_vector(factors, dim=self.dim)) self.obj = body.transformed(inverse_transform, system_coords=False) - def rotate(self, a1: int, a2: int, angle: float, system_coords: bool = True): + def rotate(self, a1: int, a2: int, angle: float, system_coords: bool = True) -> None: """Rotate the object in the plane spanned by the two axis indices.""" body = self._require_body() @@ -307,7 +302,7 @@ def rotate(self, a1: int, a2: int, angle: float, system_coords: bool = True): inverse_transform = inverse_plane_rotation(self.dim, int(a1), int(a2), radians) self.obj = body.transformed(inverse_transform, system_coords=system_coords) - def rotate_3d(self, axis: ArrayLike, angle: float, system_coords: bool = True): + def rotate_3d(self, axis: ArrayLike, angle: float, system_coords: bool = True) -> None: """Rotate a three-dimensional object about the supplied axis vector.""" body = self._require_body() @@ -341,7 +336,17 @@ def transform( class Box(MeshObject): - """Axis-aligned box defined by two opposite corners.""" + """Create a box from two opposite corners. + + Args: + p1: First corner coordinates. + p2: Opposite corner coordinates. + transform: Optional transformation steps applied in order. + fixed: Extra fixed mesher points. + mobile: Extra mobile mesher points. + system_coords: Apply transformations in system coordinates when true. + use_fixed_corners: Add every box corner as a fixed mesher point. + """ def __init__( self, @@ -352,13 +357,13 @@ def __init__( mobile: Sequence[Sequence[float]] | None = None, system_coords: bool = True, use_fixed_corners: bool = False, - ): + ) -> None: point1 = _as_vector(p1) point2 = _as_vector(p2, dim=len(point1)) fixed_points = _as_float_points(fixed) if use_fixed_corners: fixed_points.extend( - [list(corner) for corner in itertools.product(*zip(point1, point2))] + [list(corner) for corner in itertools.product(*zip(point1, point2, strict=True))] ) body = _make_body(len(point1), bc_box(point1, point2)) super().__init__(len(point1), fixed_points, mobile, body=body) @@ -366,7 +371,15 @@ def __init__( class Ellipsoid(MeshObject): - """Ellipsoid with principal radii aligned to the coordinate axes.""" + """Create an axis-aligned ellipsoid from its principal radii. + + Args: + lengths: Positive radius along each coordinate axis. + transform: Optional transformation steps applied in order. + fixed: Extra fixed mesher points. + mobile: Extra mobile mesher points. + system_coords: Apply transformations in system coordinates when true. + """ def __init__( self, @@ -375,7 +388,7 @@ def __init__( fixed: Sequence[Sequence[float]] | None = None, mobile: Sequence[Sequence[float]] | None = None, system_coords: bool = True, - ): + ) -> None: radii = _as_vector(lengths) body = _make_body(len(radii), bc_ellipsoid(radii)) super().__init__(len(radii), fixed, mobile, body=body) @@ -383,7 +396,18 @@ def __init__( class Conic(MeshObject): - """Conical frustum defined by two centres and their radii.""" + """Create a conical frustum from two centers and radii. + + Args: + c1: First end center. + r1: Non-negative first-end radius. + c2: Second end center. + r2: Non-negative second-end radius. + transform: Optional transformation steps applied in order. + fixed: Extra fixed mesher points. + mobile: Extra mobile mesher points. + system_coords: Apply transformations in system coordinates when true. + """ def __init__( self, @@ -395,7 +419,7 @@ def __init__( fixed: Sequence[Sequence[float]] | None = None, mobile: Sequence[Sequence[float]] | None = None, system_coords: bool = True, - ): + ) -> None: point1 = _as_vector(c1) point2 = _as_vector(c2, dim=len(point1)) body = _make_body( @@ -419,7 +443,7 @@ def __init__( fixed: Sequence[Sequence[float]] | None = None, mobile: Sequence[Sequence[float]] | None = None, system_coords: bool = True, - ): + ) -> None: point1 = _as_vector(c1, dim=3) point2 = _as_vector(c2, dim=3) body = _make_body( diff --git a/src/nmesh/geometry/transform.py b/src/nmesh/geometry/transform.py index 96d0fa2..144cc89 100644 --- a/src/nmesh/geometry/transform.py +++ b/src/nmesh/geometry/transform.py @@ -35,7 +35,7 @@ class AffineTransform: displacement: FloatArray @classmethod - def identity(cls, dim: int) -> "AffineTransform": + def identity(cls, dim: int) -> AffineTransform: """Return the identity transform for a space of the given dimension.""" return cls(np.eye(dim, dtype=float), np.zeros(dim, dtype=float)) @@ -49,12 +49,11 @@ def apply(self, points: ArrayLike) -> FloatArray: pts = np.asarray(points, dtype=float) if pts.ndim != 2 or pts.shape[1] != self.dim: - raise ValueError( - f"Expected points with shape (N, {self.dim}), got {pts.shape}" - ) - return ((self.matrix @ pts.T).T + self.displacement).astype(np.float64, copy=False) + raise ValueError(f"Expected points with shape (N, {self.dim}), got {pts.shape}") + transformed = np.matmul(pts, np.transpose(self.matrix)) + self.displacement + return np.asarray(transformed, dtype=np.float64) - def compose(self, other: "AffineTransform") -> "AffineTransform": + def compose(self, other: AffineTransform) -> AffineTransform: """Return the transform equivalent to applying `other` then `self`.""" if self.dim != other.dim: @@ -78,10 +77,12 @@ def inverse_scale(factors: ArrayLike) -> AffineTransform: scale = _as_vector(factors) if np.any(np.abs(scale) < MIN_ABS_SCALE_FACTOR): raise ValueError( - "Scale factors must be non-zero " - f"(absolute value >= {MIN_ABS_SCALE_FACTOR})" + f"Scale factors must be non-zero (absolute value >= {MIN_ABS_SCALE_FACTOR})" ) - return AffineTransform(np.diag(1.0 / scale), np.zeros(len(scale), dtype=float)) + matrix = np.zeros((len(scale), len(scale)), dtype=np.float64) + for index, value in enumerate(scale): + matrix[index, index] = 1.0 / value + return AffineTransform(matrix, np.zeros(len(scale), dtype=float)) def inverse_plane_rotation(dim: int, axis1: int, axis2: int, radians: float) -> AffineTransform: diff --git a/src/nmesh/io/__init__.py b/src/nmesh/io/__init__.py index b68be91..2581918 100644 --- a/src/nmesh/io/__init__.py +++ b/src/nmesh/io/__init__.py @@ -1,13 +1,50 @@ """Mesh file input and output helpers. This package provides: -- `load_raw_mesh_with_meshio`: Load meshes via meshio with .h5 fallback +- `load_raw_mesh_with_meshio`: Load meshes via meshio or recognized legacy HDF5 - `save_raw_mesh_with_meshio`: Save meshes to any meshio-supported format - `load_raw_mesh_from_legacy_nmesh_hdf5`: Direct legacy .nmesh.h5 loader +- `save_raw_mesh_as_legacy_nmesh_hdf5`: Direct legacy .nmesh.h5 writer For most use cases, use `load_raw_mesh_with_meshio` which handles -both modern formats (via meshio) and legacy .nmesh.h5 files automatically. +both modern formats (via meshio) and recognized legacy .nmesh.h5 files. """ -from .legacy_nmesh_hdf5 import load_raw_mesh_from_legacy_nmesh_hdf5 -from .meshio_support import load_raw_mesh_with_meshio, save_raw_mesh_with_meshio +from typing import Any + +_LEGACY_HDF5_EXPORTS = { + "is_legacy_nmesh_hdf5", + "load_raw_mesh_from_legacy_nmesh_hdf5", + "save_raw_mesh_as_legacy_nmesh_hdf5", +} + +_MESHIO_EXPORTS = { + "load_raw_mesh_with_meshio", + "save_raw_mesh_with_meshio", +} + +__all__ = [ + "is_legacy_nmesh_hdf5", + "load_raw_mesh_from_legacy_nmesh_hdf5", + "load_raw_mesh_with_meshio", + "save_raw_mesh_as_legacy_nmesh_hdf5", + "save_raw_mesh_with_meshio", +] + + +def __getattr__(name: str) -> Any: + if name in _LEGACY_HDF5_EXPORTS: + from . import legacy_nmesh_hdf5 + + value = getattr(legacy_nmesh_hdf5, name) + globals()[name] = value + return value + + if name in _MESHIO_EXPORTS: + from . import meshio_support + + value = getattr(meshio_support, name) + globals()[name] = value + return value + + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/nmesh/io/ascii.py b/src/nmesh/io/ascii.py new file mode 100644 index 0000000..5d385b9 --- /dev/null +++ b/src/nmesh/io/ascii.py @@ -0,0 +1,132 @@ +"""Reader for the legacy ASCII ``.nmesh``/PYFEM mesh format.""" + +from __future__ import annotations + +from pathlib import Path + +import numpy as np + +from ..backend import RawMesh + + +def read_ascii_nmesh(path: str | Path) -> RawMesh: + """Read a legacy ASCII ``.nmesh``/PYFEM mesh into :class:`RawMesh`.""" + source = Path(path) + lines = [ + line.strip() + for line in source.read_text(encoding="utf-8").splitlines() + if line.strip() and not line.lstrip().startswith("#") + ] + cursor = 0 + point_count = int(lines[cursor]) + cursor += 1 + points = [_parse_float_row(lines[cursor + index]) for index in range(point_count)] + cursor += point_count + dim = len(points[0]) if points else 0 + + simplex_count = int(lines[cursor]) + cursor += 1 + simplices: list[list[int]] = [] + regions: list[int] = [] + for _ in range(simplex_count): + values = _parse_int_row(lines[cursor]) + cursor += 1 + regions.append(values[0]) + simplices.append(values[1:]) + + surface_count = int(lines[cursor]) + cursor += 1 + surfaces: list[list[int]] = [] + for _ in range(surface_count): + values = _parse_int_row(lines[cursor]) + cursor += 1 + surfaces.append(values[-dim:] if dim > 0 else []) + + periodic_groups: list[list[int]] = [] + if cursor < len(lines): + periodic_count = int(lines[cursor]) + cursor += 1 + for _ in range(periodic_count): + values = _parse_int_row(lines[cursor]) + cursor += 1 + periodic_groups.append(values[1:]) + + return RawMesh( + points=points, + simplices=simplices, + regions=regions, + point_regions=_build_point_regions(len(points), simplices, regions), + surfaces=surfaces, + links=_build_links(simplices), + region_volumes=_region_volumes(points, simplices, regions, dim), + periodic_point_indices=periodic_groups, + permutation=list(range(len(points))), + dim=dim, + ) + + +def _parse_float_row(line: str) -> list[float]: + return [float(value) for value in line.split()] + + +def _parse_int_row(line: str) -> list[int]: + return [int(value) for value in line.split()] + + +def _build_point_regions( + point_count: int, + simplices: list[list[int]], + regions: list[int], +) -> list[list[int]]: + memberships: list[set[int]] = [set() for _ in range(point_count)] + for simplex, region in zip(simplices, regions, strict=True): + for point_index in simplex: + memberships[int(point_index)].add(int(region)) + return [sorted(group) for group in memberships] + + +def _build_links(simplices: list[list[int]]) -> list[tuple[int, int]]: + links: set[tuple[int, int]] = set() + for simplex in simplices: + for left_index, left in enumerate(simplex): + for right in simplex[left_index + 1 :]: + a = int(left) + b = int(right) + links.add((a, b) if a <= b else (b, a)) + return sorted(links) + + +def _region_volumes( + points: list[list[float]], + simplices: list[list[int]], + regions: list[int], + dim: int, +) -> list[float]: + if not simplices: + return [] + coords = np.asarray(points, dtype=float) + simplex_array = np.asarray(simplices, dtype=int) + measures = _simplex_measures(coords, simplex_array, dim) + totals = {int(region): 0.0 for region in regions} + for region, measure in zip(regions, measures, strict=True): + totals[int(region)] += float(measure) + return [totals[region] for region in sorted(totals)] + + +def _simplex_measures(points: np.ndarray, simplices: np.ndarray, dim: int) -> np.ndarray: + if len(simplices) == 0: + return np.empty(0, dtype=float) + if dim == 1: + edges = points[simplices[:, 1]] - points[simplices[:, 0]] + return np.linalg.norm(edges, axis=1) + + edge_matrices = points[simplices[:, 1:]] - points[simplices[:, [0]]] + gram_matrices = edge_matrices @ np.swapaxes(edge_matrices, 1, 2) + return np.sqrt(np.abs(np.linalg.det(gram_matrices))) / float(_factorial(dim)) + + +def _factorial(value: int) -> int: + result = 1 + for item in range(2, value + 1): + result *= item + return result diff --git a/src/nmesh/io/legacy_nmesh_hdf5.py b/src/nmesh/io/legacy_nmesh_hdf5.py index 323fe1e..9b08374 100644 --- a/src/nmesh/io/legacy_nmesh_hdf5.py +++ b/src/nmesh/io/legacy_nmesh_hdf5.py @@ -1,12 +1,72 @@ """Support for the legacy ``.nmesh.h5`` mesh format.""" +from collections.abc import Callable from pathlib import Path -from typing import Any +from typing import Any, cast import h5py import numpy as np +from numpy.typing import NDArray from ..backend import RawMesh +from .ascii import _build_links, _build_point_regions, _region_volumes + +FloatArray = NDArray[np.float64] +IntArray = NDArray[np.int_] + +_REQUIRED_MESH_DATASETS = frozenset({"points", "simplices", "simplicesregions"}) + + +def _require_group( + parent: h5py.File | h5py.Group, + name: str, + *, + path: Path, +) -> h5py.Group: + """Return a required child group after validating its HDF5 node type.""" + node = parent.get(name) + if node is None: + raise ValueError(f"{path} is missing the /{name} group") + if not isinstance(node, h5py.Group): + raise ValueError(f"{path} has /{name}, but it is not an HDF5 group") + return node + + +def _optional_dataset( + parent: h5py.File | h5py.Group, + name: str, + *, + path: Path, + hdf5_path: str, +) -> h5py.Dataset | None: + """Return an optional dataset after validating its HDF5 node type.""" + node = parent.get(name) + if node is None: + return None + if not isinstance(node, h5py.Dataset): + raise ValueError(f"{path} has {hdf5_path}, but it is not an HDF5 dataset") + return node + + +def _require_dataset( + parent: h5py.File | h5py.Group, + name: str, + *, + path: Path, + hdf5_path: str, +) -> h5py.Dataset: + """Return a required dataset after validating its HDF5 node type.""" + dataset = _optional_dataset( + parent, + name, + path=path, + hdf5_path=hdf5_path, + ) + if dataset is None: + raise ValueError( + f"{path} is missing one of /mesh/points, /mesh/simplices, or /mesh/simplicesregions" + ) + return dataset def _decode_hdf5_string(value: Any) -> str | None: @@ -18,14 +78,39 @@ def _decode_hdf5_string(value: Any) -> str | None: if isinstance(value, str): return value if isinstance(value, np.ndarray): - if value.shape == (): - return _decode_hdf5_string(value[()]) - if value.size == 1: - return _decode_hdf5_string(value.reshape(-1)[0]) - return str(value) + array = np.asarray(value, dtype=object) + if array.shape == () or array.size == 1: + return _decode_hdf5_string(array.item()) + return str(cast(object, value)) + +def is_legacy_nmesh_hdf5(path: str | Path) -> bool: + """Return whether *path* has the recognizable legacy Nmesh HDF5 layout. -def _infer_dim(points: np.ndarray, simplices: np.ndarray) -> int: + Older Nmesh files identify themselves with ``/etc/filetype == "nmesh"``. + Some valid historical files omit that optional marker, so the complete set + of required datasets in ``/mesh`` is also accepted as a signature. + """ + path = Path(path) + is_hdf5 = cast(Callable[[str], bool], vars(h5py)["is_hdf5"]) + if not is_hdf5(str(path)): + return False + + try: + with h5py.File(str(path), "r") as handle: + filetype_node = handle.get("etc/filetype") + if isinstance(filetype_node, h5py.Dataset): + return _decode_hdf5_string(filetype_node[()]) == "nmesh" + + mesh_group = handle.get("mesh") + return isinstance(mesh_group, h5py.Group) and _REQUIRED_MESH_DATASETS.issubset( + mesh_group.keys() + ) + except OSError: + return False + + +def _infer_dim(points: FloatArray, simplices: IntArray) -> int: """Infer the mesh dimension from simplex arity or point coordinates.""" if simplices.ndim == 2 and simplices.shape[1] in (2, 3, 4): return simplices.shape[1] - 1 @@ -34,7 +119,9 @@ def _infer_dim(points: np.ndarray, simplices: np.ndarray) -> int: return 3 -def _periodic_points_from_hdf5(periodic_raw: np.ndarray | None) -> list[list[int]]: +def _periodic_points_from_hdf5( + periodic_raw: IntArray | None, +) -> list[list[int]]: """Decode periodic-point rows, dropping the legacy ``-1`` padding markers.""" if periodic_raw is None: return [] @@ -43,54 +130,73 @@ def _periodic_points_from_hdf5(periodic_raw: np.ndarray | None) -> list[list[int if periodic.size == 0: return [] if periodic.ndim == 1: - periodic = periodic.reshape(1, -1) + periodic = periodic[np.newaxis, :] - return [ - [idx for idx in row.tolist() if idx != -1] - for row in periodic - ] + return [[idx for idx in row.tolist() if idx != -1] for row in periodic] def load_raw_mesh_from_legacy_nmesh_hdf5(path: str | Path) -> RawMesh: """Load a :class:`RawMesh` from the legacy ``.nmesh.h5`` file layout.""" path = Path(path) - with h5py.File(path, "r") as handle: - mesh_group = handle.get("mesh") - if mesh_group is None: - raise ValueError(f"{path} is missing the /mesh group") + with h5py.File(str(path), "r") as handle: + mesh_group = _require_group(handle, "mesh", path=path) - filetype_node = handle.get("etc/filetype") - filetype = _decode_hdf5_string( - filetype_node[()] if filetype_node is not None else None + filetype_node = _optional_dataset( + handle, + "etc/filetype", + path=path, + hdf5_path="/etc/filetype", ) + filetype = _decode_hdf5_string(filetype_node[()] if filetype_node is not None else None) if filetype not in (None, "nmesh"): - raise ValueError( - f"{path} has filetype '{filetype}', expected 'nmesh'" - ) + raise ValueError(f"{path} has filetype '{filetype}', expected 'nmesh'") - try: - points = np.asarray(mesh_group["points"][...], dtype=float) - simplices = np.asarray(mesh_group["simplices"][...], dtype=int) - regions = np.asarray( - mesh_group["simplicesregions"][...], dtype=int - ).reshape(-1) - except KeyError as exc: - raise ValueError( - f"{path} is missing one of /mesh/points, /mesh/simplices, or " - "/mesh/simplicesregions" - ) from exc - - periodic_raw = mesh_group.get("periodicpointindices") - permutation_raw = mesh_group.get("permutation") + points_dataset = _require_dataset( + mesh_group, + "points", + path=path, + hdf5_path="/mesh/points", + ) + simplices_dataset = _require_dataset( + mesh_group, + "simplices", + path=path, + hdf5_path="/mesh/simplices", + ) + regions_dataset = _require_dataset( + mesh_group, + "simplicesregions", + path=path, + hdf5_path="/mesh/simplicesregions", + ) + points: FloatArray = np.asarray(points_dataset[...], dtype=np.float64) + simplices: IntArray = np.asarray(simplices_dataset[...], dtype=np.int_) + regions: IntArray = np.asarray(regions_dataset[...], dtype=np.int_).flatten() + + periodic_dataset = _optional_dataset( + mesh_group, + "periodicpointindices", + path=path, + hdf5_path="/mesh/periodicpointindices", + ) + permutation_dataset = _optional_dataset( + mesh_group, + "permutation", + path=path, + hdf5_path="/mesh/permutation", + ) periodic_point_indices = _periodic_points_from_hdf5( - None if periodic_raw is None else periodic_raw[...] + None if periodic_dataset is None else np.asarray(periodic_dataset[...], dtype=np.int_) ) - permutation = ( + permutation: list[int] = ( [] - if permutation_raw is None - else np.asarray(permutation_raw[...], dtype=int).reshape(-1).tolist() + if permutation_dataset is None + else cast( + list[int], + np.asarray(permutation_dataset[...], dtype=np.int_).flatten().tolist(), + ) ) # Validate data consistency @@ -100,8 +206,7 @@ def load_raw_mesh_from_legacy_nmesh_hdf5(path: str | Path) -> RawMesh: raise ValueError(f"{path} contains no simplices") if len(regions) != len(simplices): raise ValueError( - f"{path} has mismatched regions ({len(regions)}) and simplices " - f"({len(simplices)})" + f"{path} has mismatched regions ({len(regions)}) and simplices ({len(simplices)})" ) # Validate simplex indices are within bounds @@ -113,11 +218,101 @@ def load_raw_mesh_from_legacy_nmesh_hdf5(path: str | Path) -> RawMesh: f"(only {len(points)} points)" ) + points_list = points.tolist() + simplices_list = simplices.tolist() + regions_list = regions.tolist() + dim = _infer_dim(points, simplices) + return RawMesh( - points=points.tolist(), - simplices=simplices.tolist(), - regions=regions.tolist(), + points=points_list, + simplices=simplices_list, + regions=regions_list, + point_regions=_build_point_regions(len(points_list), simplices_list, regions_list), + links=_build_links(simplices_list), + region_volumes=_region_volumes(points_list, simplices_list, regions_list, dim), periodic_point_indices=periodic_point_indices, permutation=permutation, - dim=_infer_dim(points, simplices), + dim=dim, ) + + +def save_raw_mesh_as_legacy_nmesh_hdf5(path: str | Path, raw_mesh: RawMesh) -> None: + """Write a :class:`RawMesh` using the legacy ``.nmesh.h5`` file layout.""" + + path = Path(path) + points = np.asarray(raw_mesh.points, dtype=float) + simplices = np.asarray(raw_mesh.simplices, dtype=np.int32) + regions = np.asarray(raw_mesh.regions or [1] * len(raw_mesh.simplices), dtype=np.int32) + if len(points) == 0: + raise ValueError("Cannot save an empty mesh without points.") + if len(simplices) == 0: + raise ValueError("Cannot save an empty mesh without simplices.") + if len(regions) != len(simplices): + raise ValueError( + f"Cannot save mesh with {len(regions)} regions for {len(simplices)} simplices." + ) + + with h5py.File(str(path), "w") as handle: + _set_pytables_group_attrs(handle, title="") + etc_group = handle.create_group("etc") + mesh_group = handle.create_group("mesh") + _set_pytables_group_attrs(etc_group, title="Configuration and version data") + _set_pytables_group_attrs(mesh_group, title="Mesh data") + + filetype = etc_group.create_dataset("filetype", data=np.asarray([b"nmesh"])) + _set_pytables_array_attrs(filetype, title="data file type", version="2.3") + fileversion = etc_group.create_dataset("fileversion", data=np.asarray([b"1.0"])) + _set_pytables_array_attrs(fileversion, title="data file type version", version="2.3") + points_dataset = mesh_group.create_dataset("points", data=points) + _set_pytables_array_attrs( + points_dataset, + title="Positions of mesh nodes (=points)", + ) + simplices_dataset = mesh_group.create_dataset("simplices", data=simplices) + _set_pytables_array_attrs( + simplices_dataset, + title="Indices of nodes (starting from zero). Each row is one simplex.", + ) + regions_dataset = mesh_group.create_dataset("simplicesregions", data=regions) + _set_pytables_array_attrs( + regions_dataset, + title="Region ids (one for each simplex).", + ) + + if raw_mesh.periodic_point_indices: + periodic = _padded_int_rows(raw_mesh.periodic_point_indices, fill=-1) + periodic_dataset = mesh_group.create_dataset("periodicpointindices", data=periodic) + _set_pytables_array_attrs(periodic_dataset, title="Periodic point indices") + if raw_mesh.permutation: + permutation_dataset = mesh_group.create_dataset( + "permutation", + data=np.asarray(raw_mesh.permutation, dtype=np.int32), + ) + _set_pytables_array_attrs(permutation_dataset, title="Node permutation") + + +def _padded_int_rows(rows: list[list[int]], *, fill: int) -> np.ndarray: + width = max(len(row) for row in rows) + result = np.full((len(rows), width), fill, dtype=np.int32) + for row_index, row in enumerate(rows): + result[row_index, : len(row)] = np.asarray(row, dtype=np.int32) + return result + + +def _set_pytables_group_attrs(group: h5py.Group | h5py.File, *, title: str) -> None: + group.attrs["CLASS"] = np.bytes_(b"GROUP") + group.attrs["TITLE"] = np.bytes_(title.encode("utf-8")) + group.attrs["VERSION"] = np.bytes_(b"1.0") + if isinstance(group, h5py.File): + group.attrs["PYTABLES_FORMAT_VERSION"] = np.bytes_(b"2.0") + + +def _set_pytables_array_attrs( + dataset: h5py.Dataset, + *, + title: str, + version: str = "1.0", +) -> None: + dataset.attrs["CLASS"] = np.bytes_(b"CARRAY") + dataset.attrs["TITLE"] = np.bytes_(title.encode("utf-8")) + dataset.attrs["VERSION"] = np.bytes_(version.encode("utf-8")) diff --git a/src/nmesh/io/meshio_support.py b/src/nmesh/io/meshio_support.py index a21bfa4..13758ff 100644 --- a/src/nmesh/io/meshio_support.py +++ b/src/nmesh/io/meshio_support.py @@ -1,14 +1,20 @@ -"""Mesh file IO built around ``meshio`` with legacy HDF5 fallback support.""" +"""Mesh file IO built around ``meshio`` and legacy Nmesh HDF5 support.""" +from __future__ import annotations + +from collections.abc import Mapping, Sequence from pathlib import Path +from typing import Protocol, cast import meshio import numpy as np +from numpy.typing import ArrayLike, NDArray from ..backend import RawMesh -from .legacy_nmesh_hdf5 import load_raw_mesh_from_legacy_nmesh_hdf5 -from meshio._exceptions import ReadError as MeshioReadError - +from .legacy_nmesh_hdf5 import ( + is_legacy_nmesh_hdf5, + load_raw_mesh_from_legacy_nmesh_hdf5, +) _CELL_TYPE_BY_DIM = { 1: "line", @@ -18,6 +24,33 @@ _DIM_BY_CELL_TYPE = {value: key for key, value in _CELL_TYPE_BY_DIM.items()} +_REGION_DATA_KEYS = ("region", "gmsh:physical", "cell_tags", "gmsh:geometrical") + + +class _CellBlock(Protocol): + type: str + data: NDArray[np.integer] + + +class _MeshData(Protocol): + points: NDArray[np.floating] + cells: Sequence[_CellBlock] + cell_data: Mapping[str, Sequence[ArrayLike]] + + +def _read_meshio(path: Path) -> _MeshData: + read = cast("object", vars(meshio)["read"]) + if not callable(read): + raise TypeError("meshio.read is not callable") + return cast(_MeshData, read(path)) + + +def _write_meshio(path: Path, mesh: object) -> None: + write = cast("object", vars(meshio)["write"]) + if not callable(write): + raise TypeError("meshio.write is not callable") + write(path, mesh) + def _cell_type_for(raw_mesh: RawMesh) -> str: """Return the meshio cell type that matches the raw mesh topology.""" @@ -33,44 +66,78 @@ def _cell_type_for(raw_mesh: RawMesh) -> str: return _CELL_TYPE_BY_DIM.get(raw_mesh.dim, "tetra") -def _regions_from_meshio(mesh, cell_type: str, count: int) -> list[int]: - """Extract region ids from common meshio cell-data keys.""" - cell_data_dict = getattr(mesh, "cell_data_dict", {}) - for key in ("region", "gmsh:physical", "cell_tags", "gmsh:geometrical"): - values_by_type = cell_data_dict.get(key, {}) - if cell_type in values_by_type: - return values_by_type[cell_type].astype(int).tolist() - return [1] * count +def _regions_from_meshio( + mesh: _MeshData, + cell_blocks: Sequence[tuple[int, _CellBlock]], +) -> list[int]: + """Extract region ids aligned with the selected meshio cell blocks.""" + cell_data = getattr(mesh, "cell_data", {}) + total_count = sum(len(cell_block.data) for _, cell_block in cell_blocks) + for key in _REGION_DATA_KEYS: + if key not in cell_data: + continue + + values_by_block = cell_data[key] + regions: list[int] = [] + for block_index, cell_block in cell_blocks: + if block_index >= len(values_by_block): + raise ValueError( + f"Region data {key!r} has no entry for cell block {block_index} " + f"({cell_block.type})" + ) + + values = np.asarray(values_by_block[block_index]) + expected_count = len(cell_block.data) + if values.size != expected_count: + raise ValueError( + f"Region data {key!r} for cell block {block_index} " + f"({cell_block.type}) has {values.size} values; " + f"expected {expected_count}" + ) + integer_values = np.asarray(values, dtype=np.int_) + regions.extend(cast(list[int], integer_values.tolist())) + return regions + + return [1] * total_count def _load_raw_mesh_from_meshio(path: Path) -> RawMesh: """Load a supported simplex mesh directly through ``meshio``.""" - mesh = meshio.read(path) - supported = next( - ( - (cell_block.type, cell_block.data) - for cell_block in mesh.cells - if cell_block.type in _DIM_BY_CELL_TYPE - ), - None, - ) - if supported is None: + mesh = _read_meshio(path) + supported_blocks = [ + (index, cell_block) + for index, cell_block in enumerate(mesh.cells) + if cell_block.type in _DIM_BY_CELL_TYPE + ] + if not supported_blocks: raise ValueError(f"No supported simplex cells found in {path}") - cell_type, simplices = supported + dim = max(_DIM_BY_CELL_TYPE[cell_block.type] for _, cell_block in supported_blocks) + selected_blocks = [ + (index, cell_block) + for index, cell_block in supported_blocks + if _DIM_BY_CELL_TYPE[cell_block.type] == dim + ] + simplices = [ + simplex + for _, cell_block in selected_blocks + for simplex in np.asarray(cell_block.data, dtype=int).tolist() + ] return RawMesh( points=mesh.points.astype(float).tolist(), - simplices=simplices.astype(int).tolist(), - regions=_regions_from_meshio(mesh, cell_type, len(simplices)), - dim=_DIM_BY_CELL_TYPE[cell_type], + simplices=simplices, + regions=_regions_from_meshio(mesh, selected_blocks), + dim=dim, ) def save_raw_mesh_with_meshio(path: str | Path, raw_mesh: RawMesh) -> None: """Write a raw mesh to any meshio-supported format.""" cell_type = _cell_type_for(raw_mesh) - cells = [(cell_type, np.asarray(raw_mesh.simplices, dtype=int))] - cell_data = None + cells: list[tuple[str, ArrayLike] | meshio.CellBlock] = [ + (cell_type, np.asarray(raw_mesh.simplices, dtype=int)) + ] + cell_data: dict[str, list[ArrayLike]] | None = None if raw_mesh.regions: cell_data = {"region": [np.asarray(raw_mesh.regions, dtype=int)]} @@ -79,14 +146,11 @@ def save_raw_mesh_with_meshio(path: str | Path, raw_mesh: RawMesh) -> None: cells=cells, cell_data=cell_data, ) - meshio.write(Path(path), mesh) + _write_meshio(Path(path), mesh) def load_raw_mesh_with_meshio(path: str | Path) -> RawMesh: - """Load a raw mesh via ``meshio`` or the legacy ``.nmesh.h5`` fallback. - - For ``.h5`` files, falls back to the legacy nmesh loader if meshio fails. - For other formats, only meshio is attempted. + """Load a raw mesh through ``meshio`` or the recognized legacy HDF5 loader. Args: path: Path to the mesh file. @@ -96,25 +160,10 @@ def load_raw_mesh_with_meshio(path: str | Path) -> RawMesh: Raises: ValueError: If the file format is not supported or the file is malformed. - For .h5 files, includes error details from both loaders. IOError/OSError: If the file cannot be read. """ path = Path(path) - meshio_error = None - - try: - return _load_raw_mesh_from_meshio(path) - except (ValueError, KeyError, IOError, OSError, RuntimeError, MeshioReadError) as exc: - meshio_error = exc - - if path.suffix.lower() == ".h5": - try: - return load_raw_mesh_from_legacy_nmesh_hdf5(path) - except (ValueError, KeyError, IOError, OSError, RuntimeError, MeshioReadError) as legacy_exc: - raise ValueError( - f"Unable to read {path} with meshio or the legacy nmesh HDF5 " - f"loader: meshio error was {meshio_error!r}; legacy loader " - f"error was {legacy_exc!r}" - ) from legacy_exc - - raise meshio_error + if is_legacy_nmesh_hdf5(path): + return load_raw_mesh_from_legacy_nmesh_hdf5(path) + + return _load_raw_mesh_from_meshio(path) diff --git a/src/nmesh/mesh_generation.py b/src/nmesh/mesh_generation.py new file mode 100644 index 0000000..e82e148 --- /dev/null +++ b/src/nmesh/mesh_generation.py @@ -0,0 +1,182 @@ +"""Construction of meshes from geometry and point/simplicial inputs.""" + +from __future__ import annotations + +from collections.abc import Callable, Sequence +from typing import TYPE_CHECKING, Any + +from .backend import backend +from .mesh_model import MeshBase, Point, Simplex + +if TYPE_CHECKING: + from .geometry import Body, MeshObject + from .mesher.meshing_parameters import MeshingParameters + +MeshCallback = Callable[[int, int, object], None] + + +def _as_float_points(points: Sequence[Sequence[float]] | None) -> list[Point]: + return [list(map(float, point)) for point in (points or [])] + + +def _as_int_simplices(simplices: Sequence[Sequence[int]] | None) -> list[Simplex]: + return [list(map(int, simplex)) for simplex in (simplices or [])] + + +def _as_region_ids(regions: Sequence[int] | None) -> list[int]: + return [int(region) for region in (regions or [])] + + +def _normalise_periodic(periodic: Sequence[bool] | Sequence[float] | None, dim: int) -> list[float]: + if not periodic: + return [0.0] * dim + return [1.0 if bool(value) else 0.0 for value in periodic] + + +def get_default_meshing_parameters() -> MeshingParameters: + """Returns default meshing parameters.""" + from .mesher.meshing_parameters import MeshingParameters + + return MeshingParameters() + + +# --- Loading Utilities --- + + +class Mesh(MeshBase): + """Generate a simplex mesh from implicit geometric objects. + + Args: + bounding_box: Lower and upper coordinate corners enclosing the model. + objects: Geometry primitives or boolean combinations to mesh. + a0: Target initial point spacing in mesh-coordinate units. + density: Optional legacy density expression. + periodic: Periodic coordinate selectors for bounding-box meshing. + fixed_points: Additional points held fixed during relaxation. + mobile_points: Additional mobile seed points. + simply_points: Additional simple seed points. + callback: Mesher progress callback and interval. + mesh_bounding_box: Mesh the complete bounding box when true. + meshing_parameters: Explicit mesher controls. + cache_name: Optional compatibility cache label. + hints: Mesh/object hint pairs. + **kwargs: Named meshing-parameter overrides. + + Raises: + ValueError: If the bounding box or object configuration is invalid. + """ + + def __init__( + self, + bounding_box: Sequence[Sequence[float]] | None, + objects: Sequence[MeshObject] | None = None, + a0: float = 1.0, + density: str = "", + periodic: Sequence[bool] | Sequence[float] | None = None, + fixed_points: Sequence[Sequence[float]] | None = None, + mobile_points: Sequence[Sequence[float]] | None = None, + simply_points: Sequence[Sequence[float]] | None = None, + callback: tuple[MeshCallback, int] | None = None, + mesh_bounding_box: bool = False, + meshing_parameters: MeshingParameters | None = None, + cache_name: str = "", + hints: Sequence[tuple[MeshBase, MeshObject]] | None = None, + **kwargs: Any, + ) -> None: + if bounding_box is None: + raise ValueError("Bounding box must be provided.") + + object_list = list(objects or []) + hint_list = list(hints or []) + bb = _as_float_points(bounding_box) + dim = len(bb[0]) + mesh_ext = 1 if mesh_bounding_box else 0 + + self.bounding_box = bb + self.mesh_exterior = mesh_ext + + if not object_list and not mesh_bounding_box: + raise ValueError("No objects to mesh and bounding box meshing disabled.") + + if periodic and not mesh_bounding_box and any(periodic): + raise ValueError("Can only produce periodic meshes when meshing the bounding box.") + + params = meshing_parameters or get_default_meshing_parameters() + self.meshing_parameters = params + for k, v in kwargs.items(): + params[k] = v + + obj_bodies: list[Body] = [] + self.obj = obj_bodies + self.cache_name = cache_name + self.density = density + self._fixed_points = _as_float_points(fixed_points) + self._mobile_points = _as_float_points(mobile_points) + self._simply_points = _as_float_points(simply_points) + + for obj in object_list: + obj_bodies.append(obj._require_body()) + self._fixed_points.extend(obj.fixed_points) + self._mobile_points.extend(obj.mobile_points) + + resolved_hints = [ + [hint_mesh.raw_mesh, hint_object._require_body()] + for hint_mesh, hint_object in hint_list + ] + + from .mesher.driver import make_mg_gendriver + + periodic_floats = _normalise_periodic(periodic, dim) + self.periodic = periodic_floats + + def no_callback(_piece: int, _step: int, _mesh: object) -> None: + return None + + cb_func, cb_interval = callback if callback else (no_callback, 1_000_000) + self.fun_driver = cb_func + self.driver = make_mg_gendriver(cb_interval, cb_func) + self.mesher_config = backend.copy_mesher_defaults(backend.mesher_defaults) + params.apply_to_mesher(self.mesher_config, dim) + + raw = backend.mesh_bodies_raw( + self.driver, + self.mesher_config, + bb[0], + bb[1], + mesh_ext, + obj_bodies, + float(a0), + density, + self._fixed_points, + self._mobile_points, + self._simply_points, + periodic_floats, + cache_name, + resolved_hints, + ) + + super().__init__(raw) + + def default_fun(self, nr_piece: int, n: int, mesh: object) -> None: + """Default callback function.""" + pass + + def extended_fun_driver(self, nr_piece: int, iteration_nr: int, mesh: object) -> None: + """Extended driver callback.""" + if hasattr(self, "fun_driver"): + self.fun_driver(nr_piece, iteration_nr, mesh) + + def fixed_points(self, points: Sequence[Sequence[float]] | None) -> None: + """Adds fixed points to the mesh configuration.""" + if points: + self._fixed_points.extend(_as_float_points(points)) + + def mobile_points(self, points: Sequence[Sequence[float]] | None) -> None: + """Adds mobile points to the mesh configuration.""" + if points: + self._mobile_points.extend(_as_float_points(points)) + + def simply_points(self, points: Sequence[Sequence[float]] | None) -> None: + """Adds simply points to the mesh configuration.""" + if points: + self._simply_points.extend(_as_float_points(points)) diff --git a/src/nmesh/mesh_io.py b/src/nmesh/mesh_io.py new file mode 100644 index 0000000..4b2210c --- /dev/null +++ b/src/nmesh/mesh_io.py @@ -0,0 +1,227 @@ +"""Legacy and modern mesh file loading and serialization helpers.""" + +from __future__ import annotations + +import logging +from collections.abc import Sequence +from pathlib import Path +from typing import TextIO + +from .backend import RawMesh, backend +from .mesh_generation import _as_float_points, _as_int_simplices, _as_region_ids +from .mesh_model import MeshBase + +log = logging.getLogger(__name__) + + +def _is_nmesh_ascii_file(filename: str | Path) -> bool: + try: + with Path(filename).open(encoding="utf-8") as stream: + return stream.readline().startswith("# PYFEM") + except (OSError, UnicodeDecodeError): + return False + + +def _is_nmesh_hdf5_file(filename: str | Path) -> bool: + from .io.legacy_nmesh_hdf5 import is_legacy_nmesh_hdf5 + + return is_legacy_nmesh_hdf5(filename) + + +def hdf5_mesh_get_permutation(filename: str | Path) -> list[int] | None: + """Stub for retrieving permutation from HDF5.""" + log.warning("hdf5_mesh_get_permutation: HDF5 support is stubbed.") + return None + + +# --- Mesh Classes --- + + +class MeshFromFile(MeshBase): + """Loads a mesh from a file.""" + + def __init__( + self, + filename: str | Path, + reorder: bool = False, + distribute: bool = True, + ) -> None: + if reorder: + raise NotImplementedError("Mesh reordering is not implemented.") + if not distribute: + raise NotImplementedError("Manual mesh distribution is not implemented.") + path = Path(filename) + if not path.exists(): + raise FileNotFoundError(f"File {filename} not found") + + if _is_nmesh_ascii_file(path): + from .io.ascii import read_ascii_nmesh + + raw = read_ascii_nmesh(path) + elif _is_nmesh_hdf5_file(path): + from .io.legacy_nmesh_hdf5 import load_raw_mesh_from_legacy_nmesh_hdf5 + + raw = load_raw_mesh_from_legacy_nmesh_hdf5(path) + else: + from .io import load_raw_mesh_with_meshio + + raw = load_raw_mesh_with_meshio(path) + + super().__init__(raw) + + +class mesh_from_points_and_simplices(MeshBase): + """Construct a mesh from explicit points, simplex indices, and regions. + + Args: + points: Coordinate vectors with a consistent dimension. + simplices_indices: Point-index vectors defining simplex cells. + simplices_regions: Positive region ID for every simplex. + periodic_point_indices: Optional groups of equivalent periodic points. + The micromagnetic simulation does not currently support periodic + boundary physics. + initial: Use ``1`` to convert one-based simplex indices to zero-based. + do_reorder: Request unsupported legacy node reordering. + do_distribute: Keep true; manual distribution is unsupported. + + Raises: + ValueError: If points, simplices, regions, or indices are inconsistent. + NotImplementedError: If reordering or manual distribution is requested. + """ + + def __init__( + self, + points: Sequence[Sequence[float]] | None = None, + simplices_indices: Sequence[Sequence[int]] | None = None, + simplices_regions: Sequence[int] | None = None, + periodic_point_indices: Sequence[Sequence[int]] | None = None, + initial: int = 0, + do_reorder: bool = False, + do_distribute: bool = True, + ) -> None: + if do_reorder: + raise NotImplementedError("Mesh reordering is not implemented.") + if not do_distribute: + raise NotImplementedError("Manual mesh distribution is not implemented.") + points_list = _as_float_points(points) + simplices_list = _as_int_simplices(simplices_indices) + if initial == 1: + simplices_list = [[index - 1 for index in simplex] for simplex in simplices_list] + + raw = backend.mesh_from_points_and_simplices( + len(points_list[0]) if points_list else 3, + points_list, + simplices_list, + _as_region_ids(simplices_regions), + [list(map(int, group)) for group in (periodic_point_indices or [])], + do_reorder, + do_distribute, + ) + super().__init__(raw) + + +def load( + filename: str | Path, + reorder: bool = False, + distribute: bool = True, +) -> MeshFromFile: + """Load legacy Nmesh or a Meshio-supported mesh file. + + Args: + filename: Source mesh path. + reorder: Request unsupported legacy node reordering. + distribute: Keep true; manual distribution is unsupported. + + Returns: + Mesh wrapper containing points, simplices, regions, and topology. + + Raises: + FileNotFoundError: If ``filename`` does not exist. + ValueError: If the mesh layout or supported cells are invalid. + NotImplementedError: If reordering or manual distribution is requested. + """ + return MeshFromFile(filename, reorder, distribute) + + +def save(mesh: MeshBase, filename: str | Path) -> None: + """Save a mesh using the format selected by the destination suffix. + + Args: + mesh: Mesh to serialize. + filename: Destination. ``.h5`` selects Nmesh HDF5, ``.nmesh`` selects + Nmesh ASCII, and other suffixes are delegated to Meshio. + """ + mesh.save(filename) + + +# --- Utilities --- + + +def write_mesh( + mesh_data: RawMesh + | tuple[ + Sequence[Sequence[float]], + Sequence[tuple[int, Sequence[int]]], + Sequence[tuple[int, Sequence[int]]], + ], + out: str | Path | TextIO | None = None, + check: bool = True, + float_fmt: str = " %.17g", +) -> None: + """ + Writes mesh data to a file in nmesh format. + + `mesh_data` may be a `RawMesh` or a legacy `(points, simplices, surfaces)` tuple. + """ + if isinstance(mesh_data, RawMesh): + points = mesh_data.points + simplices = list( + zip( + mesh_data.regions or [1] * len(mesh_data.simplices), + mesh_data.simplices, + strict=True, + ) + ) + surfaces = list( + zip( + [1] * len(mesh_data.surfaces), + mesh_data.surfaces, + strict=True, + ) + ) + periodic_groups = mesh_data.periodic_point_indices + else: + points, simplices, surfaces = mesh_data + periodic_groups = [] + + lines = ["# PYFEM mesh file version 1.0"] + dim = len(points[0]) if points else 0 + lines.append( + f"# dim = {dim} \t nodes = {len(points)} \t simplices = {len(simplices)} " + f"\t surfaces = {len(surfaces)} \t periodic = {len(periodic_groups)}" + ) + + lines.append(str(len(points))) + for p in points: + lines.append("".join(float_fmt % x for x in p)) + + lines.append(str(len(simplices))) + for body, nodes in simplices: + lines.append(f" {body} " + " ".join(str(n) for n in nodes)) + + lines.append(str(len(surfaces))) + for body, nodes in surfaces: + lines.append(f" {body} " + " ".join(str(n) for n in nodes)) + + lines.append(str(len(periodic_groups))) + for group_index, point_indices in enumerate(periodic_groups): + lines.append(f" {group_index} " + " ".join(str(index) for index in point_indices)) + + content = "\n".join(lines) + "\n" + + if out is None: + print(content) + elif isinstance(out, (str, Path)): + Path(out).write_text(content, encoding="utf-8") + else: + out.write(content) diff --git a/src/nmesh/mesh_model.py b/src/nmesh/mesh_model.py new file mode 100644 index 0000000..e3f4e16 --- /dev/null +++ b/src/nmesh/mesh_model.py @@ -0,0 +1,147 @@ +"""Mesh model wrapper and backend-backed cached accessors.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path +from typing import Any, TypeVar, cast + +from .backend import RawMesh, backend + +PYFEM_SUFFIXES = {"", ".nmesh", ".pyfem"} +Point = list[float] +Simplex = list[int] +_T = TypeVar("_T") + + +class MeshBase: + """Expose mesh geometry and topology through cached Python values. + + Attributes: + points: Node coordinates. + simplices: Point indices for every simplex cell. + regions: Region ID for every simplex. + dim: Coordinate dimension. + surfaces: Boundary simplex indices. + point_regions: Incident region IDs for every point. + links: Unique mesh edges as point-index pairs. + region_volumes: Computed volume for each region. + """ + + def __init__(self, raw_mesh: RawMesh) -> None: + self.raw_mesh = raw_mesh + self._cache: dict[str, Any] = {} + + def _cached_backend_value( + self, + cache_key: str, + getter: Callable[[RawMesh], _T], + ) -> _T: + if cache_key not in self._cache: + self._cache[cache_key] = getter(self.raw_mesh) + return cast(_T, self._cache[cache_key]) + + def scale_node_positions(self, scale: float) -> None: + """Multiply every node coordinate by ``scale`` and clear caches.""" + backend.mesh_scale_node_positions(self.raw_mesh, float(scale)) + for key in ( + "points", + "simplices", + "regions", + "point_regions", + "links", + "region_volumes", + "periodic_indices", + ): + self._cache.pop(key, None) + + def save(self, filename: str | Path) -> None: + """Save the mesh using the format selected by the filename suffix.""" + path = Path(filename) + suffix = path.suffix.lower() + if suffix == ".h5": + from .io import save_raw_mesh_as_legacy_nmesh_hdf5 + + save_raw_mesh_as_legacy_nmesh_hdf5(path, self.raw_mesh) + return + + if suffix not in PYFEM_SUFFIXES: + from .io import save_raw_mesh_with_meshio + + save_raw_mesh_with_meshio(path, self.raw_mesh) + return + + from .mesh_io import write_mesh + + write_mesh(self.raw_mesh, out=path) + + def __str__(self) -> str: + pts = backend.mesh_nr_points(self.raw_mesh) + simps = backend.mesh_nr_simplices(self.raw_mesh) + return f"Mesh with {pts} points and {simps} simplices" + + def to_lists(self) -> list[object]: + """Return the backend's legacy mesh-data list representation.""" + return backend.mesh_plotinfo(self.raw_mesh) + + @property + def points(self) -> list[Point]: + """Return node coordinates.""" + return self._cached_backend_value("points", backend.mesh_plotinfo_points) + + @property + def simplices(self) -> list[Simplex]: + """Return point indices for every simplex cell.""" + return self._cached_backend_value("simplices", backend.mesh_plotinfo_simplices) + + @property + def regions(self) -> list[int]: + """Return the region ID for every simplex cell.""" + return self._cached_backend_value("regions", backend.mesh_plotinfo_simplicesregions) + + @property + def dim(self) -> int: + """Return the mesh coordinate dimension.""" + return backend.mesh_dim(self.raw_mesh) + + @property + def surfaces(self) -> list[list[int]]: + """Return point indices for detected boundary simplices.""" + return backend.mesh_plotinfo_surfaces_and_surfacesregions(self.raw_mesh)[0] + + @property + def point_regions(self) -> list[list[int]]: + """Return incident region IDs for each point.""" + return self._cached_backend_value("point_regions", backend.mesh_plotinfo_pointsregions) + + @property + def links(self) -> list[tuple[int, int]]: + """Return unique mesh edges as point-index pairs.""" + return self._cached_backend_value("links", backend.mesh_plotinfo_links) + + @property + def region_volumes(self) -> list[float]: + """Return geometric volume for each region.""" + return self._cached_backend_value("region_volumes", backend.mesh_plotinfo_regionvolumes) + + @property + def num_regions(self) -> int: + """Return the number of mesh regions.""" + return len(self.region_volumes) + + @property + def periodic_point_indices(self) -> list[list[int]]: + """Return groups of equivalent periodic point indices.""" + return self._cached_backend_value( + "periodic_indices", + backend.mesh_plotinfo_periodic_points_indices, + ) + + @property + def permutation(self) -> list[int]: + """Return the node permutation mapping.""" + return backend.mesh_get_permutation(self.raw_mesh) + + def set_vertex_distribution(self, dist: object) -> None: + """Sets vertex distribution.""" + backend.mesh_set_vertex_distribution(self.raw_mesh, dist) diff --git a/src/nmesh/mesh_utilities.py b/src/nmesh/mesh_utilities.py new file mode 100644 index 0000000..7538180 --- /dev/null +++ b/src/nmesh/mesh_utilities.py @@ -0,0 +1,79 @@ +"""Small utility constructors and reporting helpers for mesh objects.""" + +from __future__ import annotations + +import logging +from collections.abc import Sequence + +from . import utils +from .mesh_io import mesh_from_points_and_simplices +from .mesh_model import MeshBase, Point, Simplex + +log = logging.getLogger(__name__) + + +def memory_report(tag: str) -> None: + """Reports memory usage.""" + t, vmem, rss = utils.time_vmem_rss() + log.log(15, f"Memory report: T= {t:f} VMEM= {int(vmem)} KB RSS= {int(rss)} KB {tag}") + + +# --- Configuration --- + + +def outer_corners(mesh: MeshBase) -> tuple[Point | None, Point | None]: + """Determines the bounding box of the mesh nodes.""" + coords = mesh.points + if not coords: + return None, None + transpose = list(zip(*coords, strict=True)) + return [min(t) for t in transpose], [max(t) for t in transpose] + + +def generate_1d_mesh_components( + regions: Sequence[tuple[float, float]], + discretization: float, +) -> tuple[list[Point], list[Simplex], list[int]]: + """Generates 1D mesh components (points, simplices, regions).""" + points: list[Point] = [] + simplices: list[Simplex] = [] + regions_ids: list[int] = [] + point_map: dict[float, int] = {} + + def get_idx(value: float) -> int: + vk = round(value, 8) + if vk not in point_map: + point_map[vk] = len(points) + points.append([float(value)]) + return point_map[vk] + + for rid, (start, end) in enumerate(regions, 1): + if start > end: + start, end = end, start + steps = max(1, int(abs((end - start) / discretization))) + step = (end - start) / steps + last = get_idx(start) + for i in range(1, steps + 1): + curr = get_idx(start + i * step) + simplices.append([last, curr]) + regions_ids.append(rid) + last = curr + + return points, simplices, regions_ids + + +def generate_1d_mesh( + regions: Sequence[tuple[float, float]], + discretization: float, +) -> MeshBase: + """Generates a 1D mesh with specified regions and step size.""" + pts, simps, regs = generate_1d_mesh_components(regions, discretization) + return mesh_from_points_and_simplices(pts, simps, regs) + + +def to_lists(mesh: MeshBase) -> list[object]: + """Returns mesh data as Python lists.""" + return mesh.to_lists() + + +tolists = to_lists diff --git a/src/nmesh/mesher/__init__.py b/src/nmesh/mesher/__init__.py index ed2e173..39c9c35 100644 --- a/src/nmesh/mesher/__init__.py +++ b/src/nmesh/mesher/__init__.py @@ -1,4 +1,21 @@ -from .meshing_parameters import * -from .driver import * -from .parity import * -from .relaxation import * +from __future__ import annotations + +from .driver import ( + Callback, + EngineFunc, + MeshEngineCommand, + MeshEngineStatus, + do_every_n_steps_driver, + make_mg_gendriver, +) +from .meshing_parameters import MeshingParameters + +__all__ = [ + "Callback", + "EngineFunc", + "MeshingParameters", + "MeshEngineCommand", + "MeshEngineStatus", + "do_every_n_steps_driver", + "make_mg_gendriver", +] diff --git a/src/nmesh/mesher/driver.py b/src/nmesh/mesher/driver.py index 0fd59bb..d27964e 100644 --- a/src/nmesh/mesher/driver.py +++ b/src/nmesh/mesher/driver.py @@ -1,18 +1,21 @@ import logging +from collections.abc import Callable from enum import Enum -from typing import Any, Callable, Tuple, Union, overload +from typing import Any log = logging.getLogger(__name__) class MeshEngineCommand(Enum): """Commands that can be sent to the mesh engine.""" + DO_STEP = 1 # Execute one relaxation step DO_EXTRACT = 2 # Extract intermediate mesh for callback class MeshEngineStatus(Enum): """Status returned by the mesh engine.""" + FINISHED_STEP_LIMIT_REACHED = 1 # Maximum iteration steps reached FINISHED_FORCE_EQUILIBRIUM_REACHED = 2 # Forces converged to equilibrium CAN_CONTINUE = 3 # Engine can continue, provides continuation function @@ -20,13 +23,13 @@ class MeshEngineStatus(Enum): # Type aliases for improved readability -EngineFunc = Callable[[MeshEngineCommand], Tuple[MeshEngineStatus, Any]] +EngineFunc = Callable[[MeshEngineCommand], tuple[MeshEngineStatus, Any]] Callback = Callable[[int, Any], None] def do_every_n_steps_driver( nr_steps_per_bunch: int, callback: Callback, engine_func: EngineFunc -) -> Tuple[MeshEngineStatus, Any]: +) -> tuple[MeshEngineStatus, Any]: """ Python port of Mesh.do_every_n_steps_driver using an iterative loop. @@ -88,7 +91,10 @@ def do_every_n_steps_driver( def make_mg_gendriver( interval: int, callback: Callable[[int, int, Any], None] -) -> Callable[[Union[int, EngineFunc]], Union[Tuple[MeshEngineStatus, Any], Callable[[EngineFunc], Tuple[MeshEngineStatus, Any]]]]: +) -> Callable[ + [int | EngineFunc], + tuple[MeshEngineStatus, Any] | Callable[[EngineFunc], tuple[MeshEngineStatus, Any]], +]: """ Returns a gendriver using the callback signature for multi-geometry meshing. @@ -113,9 +119,10 @@ def make_mg_gendriver( driver(0)(engine_func_piece_0) driver(1)(engine_func_piece_1) """ + def gendriver( - piece_or_engine: Union[int, EngineFunc] - ) -> Union[Tuple[MeshEngineStatus, Any], Callable[[EngineFunc], Tuple[MeshEngineStatus, Any]]]: + piece_or_engine: int | EngineFunc, + ) -> tuple[MeshEngineStatus, Any] | Callable[[EngineFunc], tuple[MeshEngineStatus, Any]]: if callable(piece_or_engine): # Type checker needs help here - we know it's an EngineFunc engine_func: EngineFunc = piece_or_engine @@ -127,7 +134,7 @@ def gendriver( nr_piece = int(piece_or_engine) - def driver(engine_func: EngineFunc) -> Tuple[MeshEngineStatus, Any]: + def driver(engine_func: EngineFunc) -> tuple[MeshEngineStatus, Any]: return do_every_n_steps_driver( interval, lambda nr_step, mesh: callback(nr_piece, nr_step, mesh), diff --git a/src/nmesh/mesher/meshing_defaults.py b/src/nmesh/mesher/meshing_defaults.py new file mode 100644 index 0000000..699f919 --- /dev/null +++ b/src/nmesh/mesher/meshing_defaults.py @@ -0,0 +1,252 @@ +from __future__ import annotations + +import logging +from collections.abc import Callable +from dataclasses import dataclass +from enum import IntEnum +from typing import Protocol, TypeAlias + +from ..utils.constants import MIN_DIVISION_MAGNITUDE + +log = logging.getLogger(__name__) + +# Point density constants: Control probabilistic insertion/deletion of mesh points during relaxation. +DENSITY_ADD_PROBABILITY = 0.1 # 10% chance to add point when density too low +FORCE_LOW_ADD_PROBABILITY = 0.2 # 20% chance to add when force < threshold +FORCE_LOW_THRESHOLD = 0.07 # Force threshold below which points may be added + +DENSITY_DELETE_BASE_PROBABILITY = 0.3 # Base 30% chance to delete when density too high +DENSITY_DELETE_SLOPE = 0.1 # Additional 10% per unit above threshold +FORCE_HIGH_DELETE_BASE_PROBABILITY = 0.4 # Base 40% chance to delete when force too high +FORCE_HIGH_DELETE_SLOPE = 0.1 # Additional 10% per unit above 0.5 +FORCE_HIGH_THRESHOLD = 0.5 # Force threshold above which points may be deleted + + +@dataclass(frozen=True, slots=True) +class ParameterSpec: + """Specification for a meshing parameter with public and internal names. + + The public_name is the user-friendly API (concise, intuitive). + The internal_name is the implementation detail (verbose, namespaced). + This separation is good design even without backward compatibility concerns. + """ + + public_name: str + internal_name: str + default: int | float + cast: type[int] | type[float] + + +PUBLIC_PARAMETER_SPECS = ( + ParameterSpec("shape_force_scale", "controller_shape_force_scale", 0.1, float), + ParameterSpec("volume_force_scale", "controller_volume_force_scale", 0.0, float), + ParameterSpec("neigh_force_scale", "controller_neigh_force_scale", 1.0, float), + ParameterSpec( + "irrel_elem_force_scale", + "controller_irrel_elem_force_scale", + 1.0, + float, + ), + ParameterSpec("time_step_scale", "controller_time_step_scale", 0.1, float), + ParameterSpec("thresh_add", "controller_thresh_add", 1.0, float), + ParameterSpec("thresh_del", "controller_thresh_del", 2.0, float), + ParameterSpec("topology_threshold", "controller_topology_threshold", 0.2, float), + ParameterSpec( + "tolerated_rel_move", + "controller_tolerated_rel_movement", + 0.002, + float, + ), + ParameterSpec("max_steps", "controller_step_limit_max", 1000, int), + ParameterSpec( + "initial_settling_steps", + "controller_initial_settling_steps", + 100, + int, + ), + ParameterSpec("sliver_correction", "controller_sliver_correction", 1.0, float), + ParameterSpec( + "smallest_volume_ratio", + "controller_smallest_allowed_volume_ratio", + 1.0, + float, + ), + ParameterSpec("max_relaxation", "controller_movement_max_freedom", 3.0, float), + ParameterSpec( + "initial_points_volume_ratio", + "controller_initial_points_volume_ratio", + 0.9, + float, + ), + ParameterSpec( + "splitting_connection_ratio", + "controller_splitting_connection_ratio", + 1.6, + float, + ), + ParameterSpec( + "exp_neigh_force_scale", + "controller_exp_neigh_force_scale", + 0.9, + float, + ), +) + +PUBLIC_PARAMETER_SPECS_BY_NAME = {spec.public_name: spec for spec in PUBLIC_PARAMETER_SPECS} + +PUBLIC_TO_INTERNAL = {spec.public_name: spec.internal_name for spec in PUBLIC_PARAMETER_SPECS} +INTERNAL_TO_PUBLIC = {spec.internal_name: spec.public_name for spec in PUBLIC_PARAMETER_SPECS} + + +class PointFate(IntEnum): + DO_NOTHING = 0 + ADD_ANOTHER = 1 + DELETE = 2 + + +class SimplexRegion(IntEnum): + OUTSIDE = 0 + INSIDE = 1 + + +class RandomSource(Protocol): + def random(self) -> float: ... + + +RelaxationWeightFunction: TypeAlias = Callable[[int, int, float, float], float] +ForceFunction: TypeAlias = Callable[[float], float] +PointDensityHandler: TypeAlias = Callable[ + [RandomSource, tuple[float, float], float, float], PointFate +] +MesherParameter: TypeAlias = ( + int | float | RelaxationWeightFunction | ForceFunction | PointDensityHandler +) + + +def default_initial_relaxation_weight( + iteration_step: int, max_step: int, init_val: float, final_val: float +) -> float: + """Linear function from init_val to final_val, saturating at max_step. + + Args: + iteration_step: Current iteration step number + max_step: Maximum number of steps for interpolation + init_val: Initial weight value at step 0 + final_val: Final weight value at max_step and beyond + + Returns: + Interpolated weight value between init_val and final_val + """ + if max_step <= 0: + return final_val + return init_val + (final_val - init_val) * min(1.0, float(iteration_step) / float(max_step)) + + +def default_relaxation_force_fun(reduced_distance: float) -> float: + """Repulsing force between two mobile nodes. + + Args: + reduced_distance: Distance normalized by ideal neighbor distance + + Returns: + Repulsive force magnitude (0.0 if distance > 1.0, else 1.0 - distance) + """ + if reduced_distance > 1.0: + return 0.0 + return 1.0 - reduced_distance + + +def default_boundary_node_force_fun(reduced_distance: float) -> float: + """Strongly repelling potential for boundary points. + + This implements a 1/r - 1 potential that enforces strong repulsion + near boundary nodes to prevent mesh points from violating boundary conditions. + + Args: + reduced_distance: Distance normalized by ideal neighbor distance + + Returns: + Repulsive force magnitude (very large for small distances, 0.0 if distance > 1.0) + """ + if reduced_distance > 1.0: + return 0.0 + if reduced_distance < MIN_DIVISION_MAGNITUDE: + return 1e12 + return 1.0 / reduced_distance - 1.0 + + +def default_handle_point_density_fun( + rng: RandomSource, + avg_stats: tuple[float, float], + thresh_add: float, + thresh_del: float, +) -> PointFate: + """Default function to insert or delete points based on density and force. + + Implements probabilistic point insertion/deletion based on local Voronoi density + and neighbor force magnitudes. Matches OCaml mdefault_controller_handle_point_density_fun. + + Args: + rng: Random number generator with .random() method + avg_stats: Tuple of (avg_density, avg_force) for the point + thresh_add: Density threshold below which points may be added + thresh_del: Density threshold above which points may be deleted + + Returns: + PointFate enum: ADD_ANOTHER, DELETE, or DO_NOTHING + """ + avg_density, avg_force = avg_stats + if avg_density < thresh_add: + if rng.random() < DENSITY_ADD_PROBABILITY: + log.debug("Dtl (dens_avg=%s) - adding point.", avg_density) + return PointFate.ADD_ANOTHER + return PointFate.DO_NOTHING + + if avg_force < FORCE_LOW_THRESHOLD: + if rng.random() < FORCE_LOW_ADD_PROBABILITY: + log.debug("Ftl (avg_force=%s) - adding point.", avg_force) + return PointFate.ADD_ANOTHER + return PointFate.DO_NOTHING + + if avg_density > thresh_del: + prob = DENSITY_DELETE_BASE_PROBABILITY + (avg_density - thresh_del) * DENSITY_DELETE_SLOPE + if rng.random() < prob: + log.debug("Dth (dens_avg=%s) - axing point.", avg_density) + return PointFate.DELETE + return PointFate.DO_NOTHING + + if avg_force > FORCE_HIGH_THRESHOLD: + prob = ( + FORCE_HIGH_DELETE_BASE_PROBABILITY + + (avg_force - FORCE_HIGH_THRESHOLD) * FORCE_HIGH_DELETE_SLOPE + ) + if rng.random() < prob: + log.debug("Fth (avg_force=%s) - axing point.", avg_force) + return PointFate.DELETE + return PointFate.DO_NOTHING + + return PointFate.DO_NOTHING + + +def _candidate_keys(name: str) -> list[str]: + """Returns all possible names (public and internal) for a parameter. + + This allows users to refer to parameters by either their public API name + or the internal implementation name, whichever is more convenient. + """ + keys = [name] + internal = PUBLIC_TO_INTERNAL.get(name) + public = INTERNAL_TO_PUBLIC.get(name) + + if internal is not None and internal not in keys: + keys.append(internal) + if public is not None and public not in keys: + keys.append(public) + + return keys + + +def _cast_numeric_parameter(spec: ParameterSpec, value: object) -> int | float: + if not isinstance(value, (str, int, float)): + raise TypeError(f"Meshing parameter {spec.public_name!r} must be numeric.") + return spec.cast(value) diff --git a/src/nmesh/mesher/meshing_parameters.py b/src/nmesh/mesher/meshing_parameters.py index 664c26c..fa5a39f 100644 --- a/src/nmesh/mesher/meshing_parameters.py +++ b/src/nmesh/mesher/meshing_parameters.py @@ -1,241 +1,51 @@ +from __future__ import annotations + import copy -import logging -from dataclasses import dataclass -from enum import IntEnum from functools import partialmethod -from typing import Any - -from mock_features import MockFeatures +from os import PathLike +from typing import Any, cast from ..utils.constants import BOUNDARY_FUZZ, MIN_DIVISION_MAGNITUDE - -log = logging.getLogger(__name__) - -# Point density constants: Control probabilistic insertion/deletion of mesh points during relaxation. -DENSITY_ADD_PROBABILITY = 0.1 # 10% chance to add point when density too low -FORCE_LOW_ADD_PROBABILITY = 0.2 # 20% chance to add when force < threshold -FORCE_LOW_THRESHOLD = 0.07 # Force threshold below which points may be added - -DENSITY_DELETE_BASE_PROBABILITY = 0.3 # Base 30% chance to delete when density too high -DENSITY_DELETE_SLOPE = 0.1 # Additional 10% per unit above threshold -FORCE_HIGH_DELETE_BASE_PROBABILITY = 0.4 # Base 40% chance to delete when force too high -FORCE_HIGH_DELETE_SLOPE = 0.1 # Additional 10% per unit above 0.5 -FORCE_HIGH_THRESHOLD = 0.5 # Force threshold above which points may be deleted - - -@dataclass(frozen=True, slots=True) -class ParameterSpec: - """Specification for a meshing parameter with public and internal names. - - The public_name is the user-friendly API (concise, intuitive). - The internal_name is the implementation detail (verbose, namespaced). - This separation is good design even without backward compatibility concerns. - """ - public_name: str - internal_name: str - default: int | float - cast: type[int] | type[float] - - -PUBLIC_PARAMETER_SPECS = ( - ParameterSpec("shape_force_scale", "controller_shape_force_scale", 0.1, float), - ParameterSpec("volume_force_scale", "controller_volume_force_scale", 0.0, float), - ParameterSpec("neigh_force_scale", "controller_neigh_force_scale", 1.0, float), - ParameterSpec( - "irrel_elem_force_scale", - "controller_irrel_elem_force_scale", - 1.0, - float, - ), - ParameterSpec("time_step_scale", "controller_time_step_scale", 0.1, float), - ParameterSpec("thresh_add", "controller_thresh_add", 1.0, float), - ParameterSpec("thresh_del", "controller_thresh_del", 2.0, float), - ParameterSpec("topology_threshold", "controller_topology_threshold", 0.2, float), - ParameterSpec( - "tolerated_rel_move", - "controller_tolerated_rel_movement", - 0.002, - float, - ), - ParameterSpec("max_steps", "controller_step_limit_max", 1000, int), - ParameterSpec( - "initial_settling_steps", - "controller_initial_settling_steps", - 100, - int, - ), - ParameterSpec("sliver_correction", "controller_sliver_correction", 1.0, float), - ParameterSpec( - "smallest_volume_ratio", - "controller_smallest_allowed_volume_ratio", - 1.0, - float, - ), - ParameterSpec("max_relaxation", "controller_movement_max_freedom", 3.0, float), - ParameterSpec( - "initial_points_volume_ratio", - "controller_initial_points_volume_ratio", - 0.9, - float, - ), - ParameterSpec( - "splitting_connection_ratio", - "controller_splitting_connection_ratio", - 1.6, - float, - ), - ParameterSpec( - "exp_neigh_force_scale", - "controller_exp_neigh_force_scale", - 0.9, - float, - ), +from .meshing_defaults import ( + INTERNAL_TO_PUBLIC, + PUBLIC_PARAMETER_SPECS, + PUBLIC_PARAMETER_SPECS_BY_NAME, + PUBLIC_TO_INTERNAL, + MesherParameter, + ParameterSpec, + PointFate, + SimplexRegion, + _candidate_keys, + _cast_numeric_parameter, + default_boundary_node_force_fun, + default_handle_point_density_fun, + default_initial_relaxation_weight, + default_relaxation_force_fun, ) - -PUBLIC_PARAMETER_SPECS_BY_NAME = { - spec.public_name: spec for spec in PUBLIC_PARAMETER_SPECS -} - -PUBLIC_TO_INTERNAL = { - spec.public_name: spec.internal_name for spec in PUBLIC_PARAMETER_SPECS -} -INTERNAL_TO_PUBLIC = { - spec.internal_name: spec.public_name for spec in PUBLIC_PARAMETER_SPECS -} - - -class PointFate(IntEnum): - DO_NOTHING = 0 - ADD_ANOTHER = 1 - DELETE = 2 - - -class SimplexRegion(IntEnum): - OUTSIDE = 0 - INSIDE = 1 - - -def default_initial_relaxation_weight( - iteration_step: int, max_step: int, init_val: float, final_val: float -) -> float: - """Linear function from init_val to final_val, saturating at max_step. - - Args: - iteration_step: Current iteration step number - max_step: Maximum number of steps for interpolation - init_val: Initial weight value at step 0 - final_val: Final weight value at max_step and beyond - - Returns: - Interpolated weight value between init_val and final_val - """ - if max_step <= 0: - return final_val - return init_val + (final_val - init_val) * min( - 1.0, float(iteration_step) / float(max_step) - ) - - -def default_relaxation_force_fun(reduced_distance: float) -> float: - """Repulsing force between two mobile nodes. - - Args: - reduced_distance: Distance normalized by ideal neighbor distance - - Returns: - Repulsive force magnitude (0.0 if distance > 1.0, else 1.0 - distance) - """ - if reduced_distance > 1.0: - return 0.0 - return 1.0 - reduced_distance - - -def default_boundary_node_force_fun(reduced_distance: float) -> float: - """Strongly repelling potential for boundary points. - - This implements a 1/r - 1 potential that enforces strong repulsion - near boundary nodes to prevent mesh points from violating boundary conditions. - - Args: - reduced_distance: Distance normalized by ideal neighbor distance - - Returns: - Repulsive force magnitude (very large for small distances, 0.0 if distance > 1.0) - """ - if reduced_distance > 1.0: - return 0.0 - if reduced_distance < MIN_DIVISION_MAGNITUDE: - return 1e12 - return 1.0 / reduced_distance - 1.0 - - -def default_handle_point_density_fun(rng, avg_stats, thresh_add: float, thresh_del: float): - """Default function to insert or delete points based on density and force. - - Implements probabilistic point insertion/deletion based on local Voronoi density - and neighbor force magnitudes. Matches OCaml mdefault_controller_handle_point_density_fun. - - Args: - rng: Random number generator with .random() method - avg_stats: Tuple of (avg_density, avg_force) for the point - thresh_add: Density threshold below which points may be added - thresh_del: Density threshold above which points may be deleted - - Returns: - PointFate enum: ADD_ANOTHER, DELETE, or DO_NOTHING - """ - avg_density, avg_force = avg_stats - if avg_density < thresh_add: - if rng.random() < DENSITY_ADD_PROBABILITY: - log.debug("Dtl (dens_avg=%s) - adding point.", avg_density) - return PointFate.ADD_ANOTHER - return PointFate.DO_NOTHING - - if avg_force < FORCE_LOW_THRESHOLD: - if rng.random() < FORCE_LOW_ADD_PROBABILITY: - log.debug("Ftl (avg_force=%s) - adding point.", avg_force) - return PointFate.ADD_ANOTHER - return PointFate.DO_NOTHING - - if avg_density > thresh_del: - prob = DENSITY_DELETE_BASE_PROBABILITY + (avg_density - thresh_del) * DENSITY_DELETE_SLOPE - if rng.random() < prob: - log.debug("Dth (dens_avg=%s) - axing point.", avg_density) - return PointFate.DELETE - return PointFate.DO_NOTHING - - if avg_force > FORCE_HIGH_THRESHOLD: - prob = FORCE_HIGH_DELETE_BASE_PROBABILITY + (avg_force - FORCE_HIGH_THRESHOLD) * FORCE_HIGH_DELETE_SLOPE - if rng.random() < prob: - log.debug("Fth (avg_force=%s) - axing point.", avg_force) - return PointFate.DELETE - return PointFate.DO_NOTHING - - return PointFate.DO_NOTHING - - -def _candidate_keys(name: str) -> list[str]: - """Returns all possible names (public and internal) for a parameter. - - This allows users to refer to parameters by either their public API name - or the internal implementation name, whichever is more convenient. - """ - keys = [name] - internal = PUBLIC_TO_INTERNAL.get(name) - public = INTERNAL_TO_PUBLIC.get(name) - - if internal is not None and internal not in keys: - keys.append(internal) - if public is not None and public not in keys: - keys.append(public) - - return keys - - -class MeshingParameters(MockFeatures): - def __init__(self, string=None, file=None): +from .sectioned_config import SectionedConfig + +__all__ = [ + "MeshingParameters", + "MesherParameter", + "ParameterSpec", + "PointFate", + "SimplexRegion", + "default_initial_relaxation_weight", + "default_relaxation_force_fun", + "default_boundary_node_force_fun", + "default_handle_point_density_fun", + "MIN_DIVISION_MAGNITUDE", +] + + +class MeshingParameters(SectionedConfig): + def __init__( + self, + string: str | None = None, + file: str | PathLike[str] | None = None, + ) -> None: super().__init__() - self.dim = None + self.dim: int | None = None self._setup_defaults() if file: self.from_file(file) @@ -243,8 +53,8 @@ def __init__(self, string=None, file=None): self.from_string(string) self.add_section("user-modifications") - def _setup_defaults(self): - self._params = { + def _setup_defaults(self) -> None: + self._params: dict[str, MesherParameter] = { # Volume determination "nr_probes_for_determining_volume": 100000, # Boundary condition parameters @@ -261,20 +71,18 @@ def _setup_defaults(self): "boundary_node_force_fun": default_boundary_node_force_fun, "handle_point_density_fun": default_handle_point_density_fun, } - self._params.update( - {spec.internal_name: spec.default for spec in PUBLIC_PARAMETER_SPECS} - ) + self._params.update({spec.internal_name: spec.default for spec in PUBLIC_PARAMETER_SPECS}) - def _get_section_name(self): + def _get_section_name(self) -> str: if self.dim is None: raise RuntimeError("Dimension not set in MeshingParameters") return f"nmesh-{self.dim}D" if self.dim in [2, 3] else "nmesh-ND" - def _lookup(self, section: str, name: str) -> Any | None: + def _lookup(self, section: str, name: str) -> MesherParameter | None: for key in _candidate_keys(name): value = self.get(section, key) if value is not None: - return value + return cast(MesherParameter, value) return None def _canonical_key(self, name: str) -> str: @@ -288,7 +96,7 @@ def _canonical_key(self, name: str) -> str: return internal return name - def __getitem__(self, name: str) -> Any | None: + def __getitem__(self, name: str) -> MesherParameter | None: user_value = self._lookup("user-modifications", name) if user_value is not None: return user_value @@ -304,7 +112,7 @@ def __getitem__(self, name: str) -> Any | None: return None - def __setitem__(self, key: str, value: Any) -> None: + def __setitem__(self, key: str, value: MesherParameter) -> None: canonical = self._canonical_key(key) self._params[canonical] = value self.set("user-modifications", canonical, value) @@ -319,14 +127,12 @@ def _sync_dimension_section(self, dim: int) -> str: section = self._get_section_name() for key, value in self.items("user-modifications"): - if not isinstance(key, str): - continue section_key = INTERNAL_TO_PUBLIC.get(key, key) self.set(section, section_key, value) return section - def to_mesher_config(self, dim): + def to_mesher_config(self, dim: int) -> dict[str, MesherParameter]: """Resolves all parameters to internal names for mesher consumption. Returns a dict with internal parameter names, suitable for passing @@ -334,19 +140,19 @@ def to_mesher_config(self, dim): """ self._sync_dimension_section(dim) - resolved = {} + resolved: dict[str, MesherParameter] = {} for spec in PUBLIC_PARAMETER_SPECS: value = self[spec.public_name] if value is None: continue - resolved[spec.internal_name] = spec.cast(value) + resolved[spec.internal_name] = _cast_numeric_parameter(spec, value) for key, value in self._params.items(): resolved.setdefault(key, value) return resolved - def apply_to_mesher(self, mesher, dim): + def apply_to_mesher(self, mesher: dict[str, Any], dim: int) -> dict[str, Any]: """Applies resolved parameters to mesher config using internal names.""" self._sync_dimension_section(dim) mesher.setdefault("parameters", {}) @@ -355,18 +161,19 @@ def apply_to_mesher(self, mesher, dim): value = self[spec.public_name] if value is None: continue - mesher["parameters"][spec.internal_name] = spec.cast(value) + mesher["parameters"][spec.internal_name] = _cast_numeric_parameter(spec, value) for key, value in self._params.items(): mesher["parameters"].setdefault(key, value) return mesher - def _set_parameter(self, name, value): + def _set_parameter(self, name: str, value: object) -> None: """Internal helper for generated setter methods.""" - self[name] = PUBLIC_PARAMETER_SPECS_BY_NAME[name].cast(value) + spec = PUBLIC_PARAMETER_SPECS_BY_NAME[name] + self[name] = _cast_numeric_parameter(spec, value) - def copy(self): + def copy(self) -> MeshingParameters: return copy.deepcopy(self) diff --git a/src/nmesh/mesher/parity.py b/src/nmesh/mesher/parity.py index de15198..5c91673 100644 --- a/src/nmesh/mesher/parity.py +++ b/src/nmesh/mesher/parity.py @@ -1,16 +1,13 @@ -"""Canonical mesh signatures for exact parity comparisons.""" +"""Compatibility exports for mesh parity helpers.""" -from __future__ import annotations - -from dataclasses import dataclass -from pathlib import Path -from typing import TypeAlias - -import numpy as np - -from ..backend import RawMesh - -CoordinateKey: TypeAlias = tuple[str | int, ...] +from ..io.ascii import read_ascii_nmesh +from .parity_canonical import ( + CanonicalMeshSignature, + assert_canonical_mesh_equal, + canonical_mesh_signature, +) +from .parity_comparison import MeshMetricComparison, compare_mesh_metrics +from .parity_metrics import MeshMetricSummary, mesh_metric_summary __all__ = [ "CanonicalMeshSignature", @@ -22,524 +19,3 @@ "mesh_metric_summary", "read_ascii_nmesh", ] - - -@dataclass(frozen=True, slots=True) -class CanonicalMeshSignature: - """Order-independent mesh signature for legacy parity checks. - - Topology and metadata are compared exactly after canonical point reindexing. - Coordinate tolerance is opt-in and only affects coordinate keys; leaving it - as ``None`` uses exact binary floating-point identity. - """ - - dim: int - point_keys: tuple[CoordinateKey, ...] - point_regions: tuple[tuple[int, ...], ...] - simplices: tuple[tuple[int, tuple[int, ...]], ...] - surfaces: tuple[tuple[int, ...], ...] - links: tuple[tuple[int, int], ...] - periodic_groups: tuple[tuple[int, ...], ...] - region_volumes: tuple[float, ...] - - -@dataclass(frozen=True, slots=True) -class MeshMetricSummary: - """Geometry-oriented mesh summary for modernization parity checks.""" - - dim: int - point_count: int - simplex_count: int - surface_count: int - link_count: int - periodic_group_count: int - region_counts: tuple[tuple[int, int], ...] - region_volumes: tuple[tuple[int, float], ...] - bbox_min: tuple[float, ...] - bbox_max: tuple[float, ...] - simplex_measure_min: float - simplex_measure_mean: float - simplex_measure_max: float - edge_length_min: float - edge_length_mean: float - edge_length_max: float - - -@dataclass(frozen=True, slots=True) -class MeshMetricComparison: - """Result of a metric-level comparison between two meshes.""" - - actual: MeshMetricSummary - expected: MeshMetricSummary - failures: tuple[str, ...] - - @property - def passed(self) -> bool: - """Return whether all configured metric checks passed.""" - - return not self.failures - - -def canonical_mesh_signature( - raw_mesh: RawMesh, - *, - coordinate_tolerance: float | None = None, -) -> CanonicalMeshSignature: - """Return a canonical signature suitable for exact parity assertions.""" - - point_keys = [_coordinate_key(point, coordinate_tolerance) for point in raw_mesh.points] - canonical_order = sorted(range(len(point_keys)), key=point_keys.__getitem__) - canonical_index = {old_index: new_index for new_index, old_index in enumerate(canonical_order)} - _require_unique_points(point_keys) - - ordered_point_keys = tuple(point_keys[index] for index in canonical_order) - ordered_point_regions = tuple( - tuple(sorted(map(int, _point_regions_at(raw_mesh, index)))) - for index in canonical_order - ) - canonical_simplices = tuple( - sorted( - ( - int(region), - tuple(canonical_index[int(point_index)] for point_index in simplex), - ) - for region, simplex in zip(raw_mesh.regions, raw_mesh.simplices) - ) - ) - canonical_surfaces = tuple( - sorted( - tuple(sorted(canonical_index[int(point_index)] for point_index in surface)) - for surface in raw_mesh.surfaces - ) - ) - canonical_links = tuple( - sorted( - tuple(sorted((canonical_index[int(left)], canonical_index[int(right)]))) - for left, right in raw_mesh.links - ) - ) - canonical_periodic = tuple( - sorted( - tuple(sorted(canonical_index[int(point_index)] for point_index in group)) - for group in raw_mesh.periodic_point_indices - ) - ) - return CanonicalMeshSignature( - dim=int(raw_mesh.dim), - point_keys=ordered_point_keys, - point_regions=ordered_point_regions, - simplices=canonical_simplices, - surfaces=canonical_surfaces, - links=canonical_links, - periodic_groups=canonical_periodic, - region_volumes=tuple(float(volume) for volume in raw_mesh.region_volumes), - ) - - -def assert_canonical_mesh_equal( - actual: RawMesh, - expected: RawMesh, - *, - coordinate_tolerance: float | None = None, -) -> None: - """Assert exact canonical parity between two meshes.""" - - actual_signature = canonical_mesh_signature( - actual, - coordinate_tolerance=coordinate_tolerance, - ) - expected_signature = canonical_mesh_signature( - expected, - coordinate_tolerance=coordinate_tolerance, - ) - if actual_signature != expected_signature: - raise AssertionError( - "Canonical mesh signatures differ:\n" - f"actual={actual_signature!r}\n" - f"expected={expected_signature!r}" - ) - - -def _coordinate_key( - point: list[float], - coordinate_tolerance: float | None, -) -> CoordinateKey: - """Return an exact or explicitly quantized coordinate key.""" - - if coordinate_tolerance is None: - return tuple(float(value).hex() for value in point) - if coordinate_tolerance <= 0.0: - raise ValueError("coordinate_tolerance must be positive when provided") - return tuple(int(round(float(value) / coordinate_tolerance)) for value in point) - - -def _require_unique_points(point_keys: list[CoordinateKey]) -> None: - """Reject signatures where canonical point identity is ambiguous.""" - - if len(set(point_keys)) != len(point_keys): - raise ValueError( - "Canonical mesh signature requires unique point coordinates at the " - "selected coordinate tolerance" - ) - - -def _point_regions_at(raw_mesh: RawMesh, index: int) -> list[int]: - """Return point-region membership for a point, tolerating absent metadata.""" - - if index < len(raw_mesh.point_regions): - return raw_mesh.point_regions[index] - return [] - - -def read_ascii_nmesh(path: str | Path) -> RawMesh: - """Read the legacy ASCII ``.nmesh``/PYFEM mesh format into ``RawMesh``.""" - - source = Path(path) - lines = [ - line.strip() - for line in source.read_text(encoding="utf-8").splitlines() - if line.strip() and not line.lstrip().startswith("#") - ] - cursor = 0 - point_count = int(lines[cursor]) - cursor += 1 - points = [_parse_float_row(lines[cursor + index]) for index in range(point_count)] - cursor += point_count - dim = len(points[0]) if points else 0 - - simplex_count = int(lines[cursor]) - cursor += 1 - simplices: list[list[int]] = [] - regions: list[int] = [] - for _ in range(simplex_count): - values = _parse_int_row(lines[cursor]) - cursor += 1 - regions.append(values[0]) - simplices.append(values[1:]) - - surface_count = int(lines[cursor]) - cursor += 1 - surfaces: list[list[int]] = [] - for _ in range(surface_count): - values = _parse_int_row(lines[cursor]) - cursor += 1 - surfaces.append(values[-dim:] if dim > 0 else []) - - periodic_groups: list[list[int]] = [] - if cursor < len(lines): - periodic_count = int(lines[cursor]) - cursor += 1 - for _ in range(periodic_count): - values = _parse_int_row(lines[cursor]) - cursor += 1 - periodic_groups.append(values[1:]) - - return RawMesh( - points=points, - simplices=simplices, - regions=regions, - point_regions=_build_point_regions(len(points), simplices, regions), - surfaces=surfaces, - links=_build_links(simplices), - region_volumes=_region_volumes(points, simplices, regions, dim), - periodic_point_indices=periodic_groups, - permutation=list(range(len(points))), - dim=dim, - ) - - -def mesh_metric_summary(raw_mesh: RawMesh) -> MeshMetricSummary: - """Return numeric mesh metrics useful for legacy-vs-modern comparisons.""" - - points = np.asarray(raw_mesh.points, dtype=float) - dim = int(raw_mesh.dim) - if points.size == 0: - points = np.empty((0, dim), dtype=float) - elif points.ndim == 1: - points = points.reshape(1, dim) - - simplices = np.asarray(raw_mesh.simplices, dtype=int) - simplex_measures = _simplex_measures(points, simplices, dim) - edge_lengths = _edge_lengths(points, raw_mesh.links) - region_counts = tuple( - sorted( - (int(region), int(sum(1 for value in raw_mesh.regions if int(value) == int(region)))) - for region in set(raw_mesh.regions) - ) - ) - region_volumes = tuple( - sorted(zip(sorted(set(map(int, raw_mesh.regions))), map(float, raw_mesh.region_volumes))) - ) - return MeshMetricSummary( - dim=dim, - point_count=len(raw_mesh.points), - simplex_count=len(raw_mesh.simplices), - surface_count=len(raw_mesh.surfaces), - link_count=len(raw_mesh.links), - periodic_group_count=len(raw_mesh.periodic_point_indices), - region_counts=region_counts, - region_volumes=region_volumes, - bbox_min=tuple(np.min(points, axis=0).tolist()) if len(points) else (), - bbox_max=tuple(np.max(points, axis=0).tolist()) if len(points) else (), - simplex_measure_min=_safe_min(simplex_measures), - simplex_measure_mean=_safe_mean(simplex_measures), - simplex_measure_max=_safe_max(simplex_measures), - edge_length_min=_safe_min(edge_lengths), - edge_length_mean=_safe_mean(edge_lengths), - edge_length_max=_safe_max(edge_lengths), - ) - - -def compare_mesh_metrics( - actual: RawMesh, - expected: RawMesh, - *, - count_relative_tolerance: float = 0.25, - volume_relative_tolerance: float = 0.05, - length_relative_tolerance: float = 0.10, - absolute_tolerance: float = 1.0e-9, -) -> MeshMetricComparison: - """Compare two meshes with documented metric-level tolerances.""" - - actual_summary = mesh_metric_summary(actual) - expected_summary = mesh_metric_summary(expected) - failures: list[str] = [] - _compare_exact("dim", actual_summary.dim, expected_summary.dim, failures) - _compare_relative_count( - "point_count", - actual_summary.point_count, - expected_summary.point_count, - count_relative_tolerance, - failures, - ) - _compare_relative_count( - "simplex_count", - actual_summary.simplex_count, - expected_summary.simplex_count, - count_relative_tolerance, - failures, - ) - _compare_relative_count( - "surface_count", - actual_summary.surface_count, - expected_summary.surface_count, - count_relative_tolerance, - failures, - ) - _compare_exact("region ids", _region_ids(actual_summary), _region_ids(expected_summary), failures) - _compare_exact( - "periodic_group_count", - actual_summary.periodic_group_count, - expected_summary.periodic_group_count, - failures, - ) - _compare_vector( - "bbox_min", - actual_summary.bbox_min, - expected_summary.bbox_min, - length_relative_tolerance, - absolute_tolerance, - failures, - ) - _compare_vector( - "bbox_max", - actual_summary.bbox_max, - expected_summary.bbox_max, - length_relative_tolerance, - absolute_tolerance, - failures, - ) - _compare_region_volumes( - actual_summary.region_volumes, - expected_summary.region_volumes, - volume_relative_tolerance, - absolute_tolerance, - failures, - ) - for field_name in ( - "simplex_measure_min", - "simplex_measure_mean", - "simplex_measure_max", - "edge_length_min", - "edge_length_mean", - "edge_length_max", - ): - _compare_float( - field_name, - float(getattr(actual_summary, field_name)), - float(getattr(expected_summary, field_name)), - length_relative_tolerance, - absolute_tolerance, - failures, - ) - return MeshMetricComparison( - actual=actual_summary, - expected=expected_summary, - failures=tuple(failures), - ) - - -def _parse_float_row(line: str) -> list[float]: - return [float(value) for value in line.split()] - - -def _parse_int_row(line: str) -> list[int]: - return [int(value) for value in line.split()] - - -def _build_point_regions( - point_count: int, - simplices: list[list[int]], - regions: list[int], -) -> list[list[int]]: - memberships: list[set[int]] = [set() for _ in range(point_count)] - for simplex, region in zip(simplices, regions): - for point_index in simplex: - memberships[int(point_index)].add(int(region)) - return [sorted(group) for group in memberships] - - -def _build_links(simplices: list[list[int]]) -> list[tuple[int, int]]: - links: set[tuple[int, int]] = set() - for simplex in simplices: - for left_index, left in enumerate(simplex): - for right in simplex[left_index + 1 :]: - a = int(left) - b = int(right) - links.add((a, b) if a <= b else (b, a)) - return sorted(links) - - -def _region_volumes( - points: list[list[float]], - simplices: list[list[int]], - regions: list[int], - dim: int, -) -> list[float]: - if not simplices: - return [] - coords = np.asarray(points, dtype=float) - simplex_array = np.asarray(simplices, dtype=int) - measures = _simplex_measures(coords, simplex_array, dim) - totals = {int(region): 0.0 for region in regions} - for region, measure in zip(regions, measures): - totals[int(region)] += float(measure) - return [totals[region] for region in sorted(totals)] - - -def _simplex_measures(points: np.ndarray, simplices: np.ndarray, dim: int) -> np.ndarray: - if len(simplices) == 0: - return np.empty(0, dtype=float) - if dim == 1: - return np.abs(points[simplices[:, 1], 0] - points[simplices[:, 0], 0]) - matrices = points[simplices[:, 1:]] - points[simplices[:, [0]]] - return np.abs(np.linalg.det(matrices)) / float(_factorial(dim)) - - -def _edge_lengths(points: np.ndarray, links: list[tuple[int, int]]) -> np.ndarray: - if not links: - return np.empty(0, dtype=float) - return np.asarray( - [np.linalg.norm(points[int(left)] - points[int(right)]) for left, right in links], - dtype=float, - ) - - -def _factorial(value: int) -> int: - result = 1 - for item in range(2, value + 1): - result *= item - return result - - -def _safe_min(values: np.ndarray) -> float: - return float(np.min(values)) if len(values) else 0.0 - - -def _safe_mean(values: np.ndarray) -> float: - return float(np.mean(values)) if len(values) else 0.0 - - -def _safe_max(values: np.ndarray) -> float: - return float(np.max(values)) if len(values) else 0.0 - - -def _compare_exact(name: str, actual: object, expected: object, failures: list[str]) -> None: - if actual != expected: - failures.append(f"{name}: actual={actual!r}, expected={expected!r}") - - -def _compare_relative_count( - name: str, - actual: int, - expected: int, - relative_tolerance: float, - failures: list[str], -) -> None: - allowed = max(1.0, abs(float(expected)) * relative_tolerance) - if abs(float(actual - expected)) > allowed: - failures.append( - f"{name}: actual={actual}, expected={expected}, allowed_delta={allowed:g}" - ) - - -def _compare_vector( - name: str, - actual: tuple[float, ...], - expected: tuple[float, ...], - relative_tolerance: float, - absolute_tolerance: float, - failures: list[str], -) -> None: - if len(actual) != len(expected): - failures.append(f"{name}: actual={actual!r}, expected={expected!r}") - return - for index, (actual_value, expected_value) in enumerate(zip(actual, expected)): - _compare_float( - f"{name}[{index}]", - actual_value, - expected_value, - relative_tolerance, - absolute_tolerance, - failures, - ) - - -def _compare_region_volumes( - actual: tuple[tuple[int, float], ...], - expected: tuple[tuple[int, float], ...], - relative_tolerance: float, - absolute_tolerance: float, - failures: list[str], -) -> None: - if tuple(region for region, _volume in actual) != tuple(region for region, _volume in expected): - failures.append(f"region volumes ids: actual={actual!r}, expected={expected!r}") - return - for (region, actual_volume), (_expected_region, expected_volume) in zip(actual, expected): - _compare_float( - f"region_volumes[{region}]", - actual_volume, - expected_volume, - relative_tolerance, - absolute_tolerance, - failures, - ) - - -def _compare_float( - name: str, - actual: float, - expected: float, - relative_tolerance: float, - absolute_tolerance: float, - failures: list[str], -) -> None: - allowed = max(absolute_tolerance, abs(expected) * relative_tolerance) - if abs(actual - expected) > allowed: - failures.append( - f"{name}: actual={actual:g}, expected={expected:g}, allowed_delta={allowed:g}" - ) - - -def _region_ids(summary: MeshMetricSummary) -> tuple[int, ...]: - return tuple(region for region, _count in summary.region_counts) diff --git a/src/nmesh/mesher/parity_canonical.py b/src/nmesh/mesher/parity_canonical.py new file mode 100644 index 0000000..e540370 --- /dev/null +++ b/src/nmesh/mesher/parity_canonical.py @@ -0,0 +1,142 @@ +"""Canonical mesh signatures for exact parity comparisons.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TypeAlias + +from ..backend import RawMesh + +CoordinateKey: TypeAlias = tuple[str | int, ...] + +@dataclass(frozen=True, slots=True) +class CanonicalMeshSignature: + """Order-independent mesh signature for legacy parity checks. + + Topology and metadata are compared exactly after canonical point reindexing. + Coordinate tolerance is opt-in and only affects coordinate keys; leaving it + as ``None`` uses exact binary floating-point identity. + """ + + dim: int + point_keys: tuple[CoordinateKey, ...] + point_regions: tuple[tuple[int, ...], ...] + simplices: tuple[tuple[int, tuple[int, ...]], ...] + surfaces: tuple[tuple[int, ...], ...] + links: tuple[tuple[int, int], ...] + periodic_groups: tuple[tuple[int, ...], ...] + region_volumes: tuple[float, ...] + + +def canonical_mesh_signature( + raw_mesh: RawMesh, + *, + coordinate_tolerance: float | None = None, +) -> CanonicalMeshSignature: + """Return a canonical signature suitable for exact parity assertions.""" + + point_keys = [_coordinate_key(point, coordinate_tolerance) for point in raw_mesh.points] + canonical_order = sorted(range(len(point_keys)), key=point_keys.__getitem__) + canonical_index = {old_index: new_index for new_index, old_index in enumerate(canonical_order)} + _require_unique_points(point_keys) + + ordered_point_keys = tuple(point_keys[index] for index in canonical_order) + ordered_point_regions = tuple( + tuple(sorted(map(int, _point_regions_at(raw_mesh, index)))) for index in canonical_order + ) + canonical_simplices = tuple( + sorted( + ( + int(region), + tuple(canonical_index[int(point_index)] for point_index in simplex), + ) + for region, simplex in zip(raw_mesh.regions, raw_mesh.simplices, strict=True) + ) + ) + canonical_surfaces = tuple( + sorted( + tuple(sorted(canonical_index[int(point_index)] for point_index in surface)) + for surface in raw_mesh.surfaces + ) + ) + canonical_links = tuple( + sorted( + ( + min(canonical_index[int(left)], canonical_index[int(right)]), + max(canonical_index[int(left)], canonical_index[int(right)]), + ) + for left, right in raw_mesh.links + ) + ) + canonical_periodic = tuple( + sorted( + tuple(sorted(canonical_index[int(point_index)] for point_index in group)) + for group in raw_mesh.periodic_point_indices + ) + ) + return CanonicalMeshSignature( + dim=int(raw_mesh.dim), + point_keys=ordered_point_keys, + point_regions=ordered_point_regions, + simplices=canonical_simplices, + surfaces=canonical_surfaces, + links=canonical_links, + periodic_groups=canonical_periodic, + region_volumes=tuple(float(volume) for volume in raw_mesh.region_volumes), + ) + + +def assert_canonical_mesh_equal( + actual: RawMesh, + expected: RawMesh, + *, + coordinate_tolerance: float | None = None, +) -> None: + """Assert exact canonical parity between two meshes.""" + + actual_signature = canonical_mesh_signature( + actual, + coordinate_tolerance=coordinate_tolerance, + ) + expected_signature = canonical_mesh_signature( + expected, + coordinate_tolerance=coordinate_tolerance, + ) + if actual_signature != expected_signature: + raise AssertionError( + "Canonical mesh signatures differ:\n" + f"actual={actual_signature!r}\n" + f"expected={expected_signature!r}" + ) + + +def _coordinate_key( + point: list[float], + coordinate_tolerance: float | None, +) -> CoordinateKey: + """Return an exact or explicitly quantized coordinate key.""" + + if coordinate_tolerance is None: + return tuple(float(value).hex() for value in point) + if coordinate_tolerance <= 0.0: + raise ValueError("coordinate_tolerance must be positive when provided") + return tuple(int(round(float(value) / coordinate_tolerance)) for value in point) + + +def _require_unique_points(point_keys: list[CoordinateKey]) -> None: + """Reject signatures where canonical point identity is ambiguous.""" + + if len(set(point_keys)) != len(point_keys): + raise ValueError( + "Canonical mesh signature requires unique point coordinates at the " + "selected coordinate tolerance" + ) + + +def _point_regions_at(raw_mesh: RawMesh, index: int) -> list[int]: + """Return point-region membership for a point, tolerating absent metadata.""" + + if index < len(raw_mesh.point_regions): + return raw_mesh.point_regions[index] + return [] + diff --git a/src/nmesh/mesher/parity_comparison.py b/src/nmesh/mesher/parity_comparison.py new file mode 100644 index 0000000..50c3a23 --- /dev/null +++ b/src/nmesh/mesher/parity_comparison.py @@ -0,0 +1,191 @@ +"""Tolerance-aware comparisons for mesh metric summaries.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from ..backend import RawMesh +from .parity_metrics import MeshMetricSummary, mesh_metric_summary + + +@dataclass(frozen=True, slots=True) +class MeshMetricComparison: + """Result of a metric-level comparison between two meshes.""" + + actual: MeshMetricSummary + expected: MeshMetricSummary + failures: tuple[str, ...] + + @property + def passed(self) -> bool: + """Return whether all configured metric checks passed.""" + + return not self.failures +def compare_mesh_metrics( + actual: RawMesh, + expected: RawMesh, + *, + count_relative_tolerance: float = 0.25, + volume_relative_tolerance: float = 0.05, + length_relative_tolerance: float = 0.10, + absolute_tolerance: float = 1.0e-9, +) -> MeshMetricComparison: + """Compare two meshes with documented metric-level tolerances.""" + + actual_summary = mesh_metric_summary(actual) + expected_summary = mesh_metric_summary(expected) + failures: list[str] = [] + _compare_exact("dim", actual_summary.dim, expected_summary.dim, failures) + _compare_relative_count( + "point_count", + actual_summary.point_count, + expected_summary.point_count, + count_relative_tolerance, + failures, + ) + _compare_relative_count( + "simplex_count", + actual_summary.simplex_count, + expected_summary.simplex_count, + count_relative_tolerance, + failures, + ) + _compare_relative_count( + "surface_count", + actual_summary.surface_count, + expected_summary.surface_count, + count_relative_tolerance, + failures, + ) + _compare_exact( + "region ids", _region_ids(actual_summary), _region_ids(expected_summary), failures + ) + _compare_exact( + "periodic_group_count", + actual_summary.periodic_group_count, + expected_summary.periodic_group_count, + failures, + ) + _compare_vector( + "bbox_min", + actual_summary.bbox_min, + expected_summary.bbox_min, + length_relative_tolerance, + absolute_tolerance, + failures, + ) + _compare_vector( + "bbox_max", + actual_summary.bbox_max, + expected_summary.bbox_max, + length_relative_tolerance, + absolute_tolerance, + failures, + ) + _compare_region_volumes( + actual_summary.region_volumes, + expected_summary.region_volumes, + volume_relative_tolerance, + absolute_tolerance, + failures, + ) + for field_name in ( + "simplex_measure_min", + "simplex_measure_mean", + "simplex_measure_max", + "edge_length_min", + "edge_length_mean", + "edge_length_max", + ): + _compare_float( + field_name, + float(getattr(actual_summary, field_name)), + float(getattr(expected_summary, field_name)), + length_relative_tolerance, + absolute_tolerance, + failures, + ) + return MeshMetricComparison( + actual=actual_summary, + expected=expected_summary, + failures=tuple(failures), + ) +def _compare_exact(name: str, actual: object, expected: object, failures: list[str]) -> None: + if actual != expected: + failures.append(f"{name}: actual={actual!r}, expected={expected!r}") + + +def _compare_relative_count( + name: str, + actual: int, + expected: int, + relative_tolerance: float, + failures: list[str], +) -> None: + allowed = max(1.0, abs(float(expected)) * relative_tolerance) + if abs(float(actual - expected)) > allowed: + failures.append(f"{name}: actual={actual}, expected={expected}, allowed_delta={allowed:g}") + + +def _compare_vector( + name: str, + actual: tuple[float, ...], + expected: tuple[float, ...], + relative_tolerance: float, + absolute_tolerance: float, + failures: list[str], +) -> None: + if len(actual) != len(expected): + failures.append(f"{name}: actual={actual!r}, expected={expected!r}") + return + for index, (actual_value, expected_value) in enumerate(zip(actual, expected, strict=True)): + _compare_float( + f"{name}[{index}]", + actual_value, + expected_value, + relative_tolerance, + absolute_tolerance, + failures, + ) + + +def _compare_region_volumes( + actual: tuple[tuple[int, float], ...], + expected: tuple[tuple[int, float], ...], + relative_tolerance: float, + absolute_tolerance: float, + failures: list[str], +) -> None: + if tuple(region for region, _volume in actual) != tuple(region for region, _volume in expected): + failures.append(f"region volumes ids: actual={actual!r}, expected={expected!r}") + return + for (region, actual_volume), (_expected_region, expected_volume) in zip( + actual, expected, strict=True + ): + _compare_float( + f"region_volumes[{region}]", + actual_volume, + expected_volume, + relative_tolerance, + absolute_tolerance, + failures, + ) + + +def _compare_float( + name: str, + actual: float, + expected: float, + relative_tolerance: float, + absolute_tolerance: float, + failures: list[str], +) -> None: + allowed = max(absolute_tolerance, abs(expected) * relative_tolerance) + if abs(actual - expected) > allowed: + failures.append( + f"{name}: actual={actual:g}, expected={expected:g}, allowed_delta={allowed:g}" + ) + + +def _region_ids(summary: MeshMetricSummary) -> tuple[int, ...]: + return tuple(region for region, _count in summary.region_counts) diff --git a/src/nmesh/mesher/parity_metrics.py b/src/nmesh/mesher/parity_metrics.py new file mode 100644 index 0000000..8a1ff2c --- /dev/null +++ b/src/nmesh/mesher/parity_metrics.py @@ -0,0 +1,114 @@ +"""Geometry metrics for modernization mesh comparisons.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np + +from ..backend import RawMesh + + +@dataclass(frozen=True, slots=True) +class MeshMetricSummary: + """Geometry-oriented mesh summary for modernization parity checks.""" + + dim: int + point_count: int + simplex_count: int + surface_count: int + link_count: int + periodic_group_count: int + region_counts: tuple[tuple[int, int], ...] + region_volumes: tuple[tuple[int, float], ...] + bbox_min: tuple[float, ...] + bbox_max: tuple[float, ...] + simplex_measure_min: float + simplex_measure_mean: float + simplex_measure_max: float + edge_length_min: float + edge_length_mean: float + edge_length_max: float + + +def mesh_metric_summary(raw_mesh: RawMesh) -> MeshMetricSummary: + """Return numeric mesh metrics useful for legacy-vs-modern comparisons.""" + + points = np.asarray(raw_mesh.points, dtype=float) + dim = int(raw_mesh.dim) + if points.size == 0: + points = np.empty((0, dim), dtype=float) + elif points.ndim == 1: + points = points[np.newaxis, :] + + simplices = np.asarray(raw_mesh.simplices, dtype=int) + simplex_measures = _simplex_measures(points, simplices, dim) + edge_lengths = _edge_lengths(points, raw_mesh.links) + region_counts = tuple( + sorted( + (int(region), int(sum(1 for value in raw_mesh.regions if int(value) == int(region)))) + for region in set(raw_mesh.regions) + ) + ) + region_volumes = tuple( + sorted( + zip( + sorted(set(map(int, raw_mesh.regions))), + map(float, raw_mesh.region_volumes), + strict=True, + ) + ) + ) + return MeshMetricSummary( + dim=dim, + point_count=len(raw_mesh.points), + simplex_count=len(raw_mesh.simplices), + surface_count=len(raw_mesh.surfaces), + link_count=len(raw_mesh.links), + periodic_group_count=len(raw_mesh.periodic_point_indices), + region_counts=region_counts, + region_volumes=region_volumes, + bbox_min=tuple(np.min(points, axis=0).tolist()) if len(points) else (), + bbox_max=tuple(np.max(points, axis=0).tolist()) if len(points) else (), + simplex_measure_min=_safe_min(simplex_measures), + simplex_measure_mean=_safe_mean(simplex_measures), + simplex_measure_max=_safe_max(simplex_measures), + edge_length_min=_safe_min(edge_lengths), + edge_length_mean=_safe_mean(edge_lengths), + edge_length_max=_safe_max(edge_lengths), + ) +def _simplex_measures(points: np.ndarray, simplices: np.ndarray, dim: int) -> np.ndarray: + if len(simplices) == 0: + return np.empty(0, dtype=float) + if dim == 1: + return np.abs(points[simplices[:, 1], 0] - points[simplices[:, 0], 0]) + matrices = points[simplices[:, 1:]] - points[simplices[:, [0]]] + return np.abs(np.linalg.det(matrices)) / float(_factorial(dim)) + + +def _edge_lengths(points: np.ndarray, links: list[tuple[int, int]]) -> np.ndarray: + if not links: + return np.empty(0, dtype=float) + return np.asarray( + [np.linalg.norm(points[int(left)] - points[int(right)]) for left, right in links], + dtype=float, + ) + + +def _factorial(value: int) -> int: + result = 1 + for item in range(2, value + 1): + result *= item + return result + + +def _safe_min(values: np.ndarray) -> float: + return float(np.min(values)) if len(values) else 0.0 + + +def _safe_mean(values: np.ndarray) -> float: + return float(np.mean(values)) if len(values) else 0.0 + + +def _safe_max(values: np.ndarray) -> float: + return float(np.max(values)) if len(values) else 0.0 diff --git a/src/nmesh/mesher/relaxation/__init__.py b/src/nmesh/mesher/relaxation/__init__.py index 70bab4a..8c03242 100644 --- a/src/nmesh/mesher/relaxation/__init__.py +++ b/src/nmesh/mesher/relaxation/__init__.py @@ -1,6 +1,6 @@ """Relaxation meshing package for the pure-Python section 4 port.""" -from .density import _compile_density_function +from .density import _compile_density_function as _compile_density_function from .engine import RelaxationEngine, mesh_bodies_raw from .geometry import FemGeometry, fem_geometry_from_bodies from .topology import assemble_raw_mesh diff --git a/src/nmesh/mesher/relaxation/_types.py b/src/nmesh/mesher/relaxation/_types.py index b4a5f7f..5ae3423 100644 --- a/src/nmesh/mesher/relaxation/_types.py +++ b/src/nmesh/mesher/relaxation/_types.py @@ -1,6 +1,7 @@ """Internal type aliases for the relaxation meshing package.""" -from typing import Any, Callable, TypeAlias +from collections.abc import Callable +from typing import Any, TypeAlias import numpy as np diff --git a/src/nmesh/mesher/relaxation/density.py b/src/nmesh/mesher/relaxation/density.py index 86ca2c1..5f56e74 100644 --- a/src/nmesh/mesher/relaxation/density.py +++ b/src/nmesh/mesher/relaxation/density.py @@ -4,6 +4,7 @@ import math import re +from typing import Any import numpy as np @@ -137,12 +138,15 @@ def _compile_script_density(source: str) -> DensityFunction: namespace = _script_density_namespace() exec(translated, namespace, namespace) compiled = namespace["__compiled_density"] + if not callable(compiled): + raise TypeError("Translated density script did not define a callable") def script_density(point: FloatArray) -> float: """Evaluate a translated multi-line density script.""" x = np.asarray(point, dtype=float) - return _clamp_density_value(float(compiled(x))) + result: Any = compiled(x) + return _clamp_density_value(float(result)) return script_density diff --git a/src/nmesh/mesher/relaxation/engine.py b/src/nmesh/mesher/relaxation/engine.py deleted file mode 100644 index 7bc8e58..0000000 --- a/src/nmesh/mesher/relaxation/engine.py +++ /dev/null @@ -1,567 +0,0 @@ -"""Iterative meshing engine for the pure-Python relaxation pipeline.""" - -from __future__ import annotations - -import logging -import math -from typing import Any - -import numpy as np - -from ...backend import RawMesh -from ...geometry.primitives import Body -from ..driver import MeshEngineCommand, MeshEngineStatus -from ..meshing_parameters import PointFate, default_handle_point_density_fun -from ._constants import BOUNDARY_FUZZ, DEFAULT_RNG_SEED, STATE_BOUNDARY, STATE_FIXED, STATE_MOBILE, STATE_SIMPLE -from ._types import DensityFunction, EngineResult, FloatArray -from .finalize import snap_final_boundary_points -from .forces import ForceSummary, compute_forces -from .geometry import FemGeometry, fem_geometry_from_bodies -from .recovery import mirror_surface_recovery_points -from .seeding import _as_float_array, _classify_dynamic_states, _dedupe_points, _prepare_initial_points -from .topology import _callback_mesh_info, assemble_raw_mesh - -log = logging.getLogger(__name__) - - -class RelaxationEngine: - """Small iterative meshing engine that exposes the legacy driver protocol.""" - - def __init__( - self, - geometry: FemGeometry, - mesher: dict[str, Any], - a0: float, - fixed_points: FloatArray, - mobile_points: FloatArray, - simply_points: FloatArray, - periodic: list[float] | list[bool], - *, - rng: np.random.Generator | None = None, - ) -> None: - """Initialize the engine state from geometry, seeds, and mesher parameters.""" - - self.geometry = geometry - self.params = dict(mesher.get("parameters", {})) - self.a0 = float(a0) - self.periodic = list(periodic) - self.rng = rng or np.random.default_rng(DEFAULT_RNG_SEED) - self.points, self.states = _prepare_initial_points( - geometry, - self.a0, - fixed_points, - mobile_points, - simply_points, - self.periodic, - self.rng, - self.params, - ) - self.step = 0 - self.finished = False - self.last_addition_deletion_step = 0 - self.last_max_displacement = math.inf - self.last_max_effective_force = math.inf - self.last_point_density = np.zeros(len(self.points), dtype=float) - self.max_rel_movement_since_last_triangulation = 0.0 - self.current_simplices: np.ndarray | None = None - self.points_at_last_triangulation = np.array(self.points, copy=True) - self._refresh_boundary_states() - self.cached_raw_mesh = assemble_raw_mesh( - self.points, - self.geometry, - self.periodic, - states=self.states, - params=self.params, - ) - - @property - def max_steps(self) -> int: - """Return the configured maximum relaxation-step budget.""" - - return int(self.params.get("controller_step_limit_max", 1000)) - - @property - def tolerated_rel_move(self) -> float: - """Return the relative movement threshold used for convergence.""" - - return float(self.params.get("controller_tolerated_rel_movement", 0.002)) - - @property - def time_step_scale(self) -> float: - """Return the scale factor applied to each relaxation displacement.""" - - return float(self.params.get("controller_time_step_scale", 0.1)) - - @property - def max_time_step(self) -> float: - """Return the largest time-step magnitude allowed by the controller.""" - - return float(self.params.get("controller_max_time_step", 10.0)) - - @property - def boundary_drift_tolerance(self) -> float: - """Return the tolerated increase in boundary distance for boundary points. - - Legacy boundary points are only allowed to move when they do not drift - farther from the boundary. The Python port keeps only the global - floating-point boundary fuzz here; it does not scale this tolerance with - ``a0`` because that would turn a numerical guard into different meshing - behavior. - """ - - return BOUNDARY_FUZZ - - @property - def min_equilibrium_steps(self) -> int: - """Return the minimum number of steps before equilibrium can stop the loop.""" - - return int(self.params.get("controller_step_limit_min", 500)) - - @property - def post_change_settling_steps(self) -> int: - """Return the legacy settling window after point add/delete checks.""" - - return 50 - - def _rebuild_mesh(self, *, final: bool = False) -> None: - """Refresh the cached ``RawMesh`` snapshot from the current point cloud.""" - - points, states = self._final_output_points_and_states() if final else (self.points, self.states) - self.cached_raw_mesh = assemble_raw_mesh( - points, - self.geometry, - self.periodic, - states=states, - params=self.params, - ) - - def _final_output_points_and_states(self) -> tuple[FloatArray, np.ndarray]: - """Return the final point cloud after the legacy high-density cleanup.""" - - if len(self.last_point_density) != len(self.points): - return self.points, self.states - dynamic_mask = np.isin(self.states, [STATE_MOBILE, STATE_BOUNDARY]) - keep = (~dynamic_mask) | (self.last_point_density < 100.0) - if np.all(keep): - points = self.points - states = self.states - else: - points = self.points[keep] - states = self.states[keep] - dynamic_mask = np.isin(states, [STATE_MOBILE, STATE_BOUNDARY]) - domain_mask = self.geometry.classify_points(points) >= 0 - keep_domain = (~dynamic_mask) | domain_mask - if not np.all(keep_domain): - points = points[keep_domain] - states = states[keep_domain] - return snap_final_boundary_points(points, states, self.geometry, self.a0, self.params) - - def _mark_topology_stale(self) -> None: - """Request a fresh Delaunay triangulation before the next force step.""" - - self.current_simplices = None - self.max_rel_movement_since_last_triangulation = 0.0 - self.points_at_last_triangulation = np.array(self.points, copy=True) - - def _refresh_boundary_states(self) -> None: - """Refresh dynamic points between mobile and boundary states.""" - - dynamic_mask = np.isin(self.states, [STATE_MOBILE, STATE_BOUNDARY]) - if not np.any(dynamic_mask): - return - refreshed = _classify_dynamic_states(self.geometry, self.points[dynamic_mask], self.a0) - self.states[dynamic_mask] = refreshed - - def _effective_time_step(self, force_summary: ForceSummary) -> float: - """Compute the controller-like time step for the current force field.""" - - freedom = float(self.params.get("controller_movement_max_freedom", 3.0)) - movement_weight = self._initial_relaxation_weight(freedom, 1.0) - capped_max_time_step = movement_weight * self.max_time_step - if force_summary.max_effective_force <= BOUNDARY_FUZZ: - return capped_max_time_step - return min( - capped_max_time_step, - (movement_weight * self.time_step_scale) / force_summary.max_effective_force, - ) - - def _initial_relaxation_weight(self, init_val: float, final_val: float) -> float: - """Evaluate the legacy initial-relaxation ramp for this step.""" - - settling_steps = int(self.params.get("controller_initial_settling_steps", 100)) - weight_fun = self.params.get("initial_relaxation_weight_fun") - if callable(weight_fun): - return float(weight_fun(self.step, settling_steps, init_val, final_val)) - fraction = min(1.0, float(self.step) / max(float(settling_steps), 1.0)) - return init_val + (final_val - init_val) * fraction - - def _density_thresholds(self) -> tuple[float, float]: - """Return add/delete thresholds with the legacy initial relaxation ramp.""" - - freedom = float(self.params.get("controller_movement_max_freedom", 3.0)) - thresh_add = ( - self._initial_relaxation_weight(-0.1 * freedom, 0.0) - + float(self.params.get("controller_thresh_add", 1.0)) - ) - thresh_del = ( - self._initial_relaxation_weight(0.1 * freedom, 0.0) - + float(self.params.get("controller_thresh_del", 2.0)) - ) - return thresh_add, thresh_del - - @staticmethod - def _is_positive_square_number(value: int) -> bool: - """Return whether ``value`` is a positive perfect square.""" - - if value <= 0: - return False - root = int(math.sqrt(value) + 0.5) - return root * root == value - - def _should_attempt_point_change(self) -> bool: - """Return whether the legacy controller schedules point fate changes.""" - - return ( - self.step < self.max_steps - and self._is_positive_square_number(self.step - 10) - ) - - def _step_limit_reached(self) -> bool: - """Return whether the legacy max-step and post-change settling rules are met.""" - - return ( - self.step >= self.max_steps - and self.step >= self.last_addition_deletion_step + self.post_change_settling_steps - ) - - def _topology_threshold(self) -> float: - """Return the relaxed legacy threshold for topology refresh.""" - - freedom = float(self.params.get("controller_movement_max_freedom", 3.0)) - threshold = float(self.params.get("controller_topology_threshold", 0.2)) - return self._initial_relaxation_weight(freedom, 1.0) * threshold - - def _record_triangulation(self, force_summary: ForceSummary) -> None: - """Cache the current topology after a force calculation builds it.""" - - if self.current_simplices is None: - self.current_simplices = np.asarray(force_summary.simplices, dtype=int) - self.points_at_last_triangulation = np.array(self.points, copy=True) - self.max_rel_movement_since_last_triangulation = 0.0 - - def _update_topology_movement(self) -> None: - """Track movement since the last triangulation in OCaml controller units.""" - - if len(self.points) != len(self.points_at_last_triangulation): - self._mark_topology_stale() - return - - if len(self.points) == 0: - self.max_rel_movement_since_last_triangulation = 0.0 - return - - displacements = np.linalg.norm(self.points - self.points_at_last_triangulation, axis=1) - density_scale = np.asarray( - [ - self.geometry.density_at(point) ** (1.0 / max(self.geometry.dim, 1)) - for point in self.points - ], - dtype=float, - ) - scaled = displacements * density_scale / max(self.a0, BOUNDARY_FUZZ) - self.max_rel_movement_since_last_triangulation = max( - self.max_rel_movement_since_last_triangulation, - float(np.max(scaled, initial=0.0)), - ) - - def _refresh_topology_if_needed(self) -> None: - """Invalidate cached topology when movement exceeds the legacy threshold.""" - - if self.max_rel_movement_since_last_triangulation > self._topology_threshold(): - self._mark_topology_stale() - - def _attempt_add_delete_points(self, force_summary: ForceSummary) -> None: - """Apply the mesher density heuristic to add or remove mobile points.""" - - if len(self.points) == 0: - return - - additions, removals = self._evaluate_point_densities(force_summary) - recovery_points = mirror_surface_recovery_points( - self.points, - self.states, - self.geometry, - self.a0, - force_summary.simplices, - ) - if len(recovery_points) > 0: - additions.extend(recovery_points) - self._apply_point_changes(additions, removals) - self._refresh_boundary_states() - - def _evaluate_point_densities( - self, - force_summary: ForceSummary, - ) -> tuple[list[FloatArray], list[int]]: - """Decide which points to add or remove from the current cloud.""" - - handler = self.params.get("handle_point_density_fun", default_handle_point_density_fun) - thresh_add, thresh_del = self._density_thresholds() - additions: list[FloatArray] = [] - removals: list[int] = [] - - for index, state in enumerate(self.states): - if state not in (STATE_MOBILE, STATE_BOUNDARY) or index in removals: - continue - - neigh = force_summary.neighbor_map[index] - if not neigh: - continue - - point = self.points[index] - avg_density = float(force_summary.point_density[index]) - avg_force = float(force_summary.point_average_force[index]) - - fate = handler(self.rng, (avg_density, avg_force), thresh_add, thresh_del) - if fate == PointFate.ADD_ANOTHER: - additions.append(self._random_point_close_to(point)) - elif fate == PointFate.DELETE and len(self.points) - len(removals) > self.geometry.dim + 1: - removals.append(index) - - return additions, removals - - def _random_point_close_to(self, point: FloatArray) -> FloatArray: - """Return a Gaussian insertion candidate using the legacy rod-length scale.""" - - density_here = self.geometry.density_at(point) - effective_rod_length = self.a0 * (density_here ** (-1.0 / max(self.geometry.dim, 1))) - candidate = np.asarray( - self.rng.normal(loc=np.asarray(point, dtype=float), scale=effective_rod_length), - dtype=float, - ) - if self.geometry.classify_points(candidate.reshape(1, -1))[0] >= 0: - return candidate - return self.geometry.project_segment_to_domain(np.asarray(point, dtype=float), candidate) - - def _apply_point_changes(self, additions: list[FloatArray], removals: list[int]) -> None: - """Mutate the point cloud according to the evaluated add/remove plan.""" - - if removals: - keep = np.ones(len(self.points), dtype=bool) - keep[np.asarray(removals, dtype=int)] = False - self.points = self.points[keep] - self.states = self.states[keep] - self._mark_topology_stale() - - if additions: - additions_arr = _dedupe_points(np.asarray(additions, dtype=float)) - if len(additions_arr) > 0: - self.points = np.vstack((self.points, additions_arr)) - addition_states = _classify_dynamic_states(self.geometry, additions_arr, self.a0) - self.states = np.concatenate( - ( - self.states, - addition_states, - ) - ) - self._mark_topology_stale() - - def _compute_constrained_displacement( - self, - point: FloatArray, - state: int, - displacement: FloatArray, - max_norm: float, - ) -> FloatArray: - """Return a clipped, boundary-aware candidate position for one point.""" - - bounded_displacement = np.array(displacement, copy=True) - if state == STATE_BOUNDARY: - normal = self.geometry.boundary_normal(point) - normal_norm = float(np.linalg.norm(normal)) - if normal_norm > BOUNDARY_FUZZ: - bounded_displacement -= float(np.dot(bounded_displacement, normal)) * (normal / normal_norm) - - disp_norm = float(np.linalg.norm(bounded_displacement)) - if disp_norm > max_norm > 0.0: - bounded_displacement *= max_norm / disp_norm - - candidate = point + bounded_displacement - if state == STATE_BOUNDARY: - old_boundary_distance = self.geometry.boundary_distance(point) - new_boundary_distance = self.geometry.boundary_distance(candidate) - if new_boundary_distance > old_boundary_distance + self.boundary_drift_tolerance: - return np.asarray(point, dtype=float) - - if self.geometry.classify_points(candidate.reshape(1, -1))[0] < 0: - candidate = self.geometry.project_segment_to_domain(point, candidate) - - if self.geometry.classify_points(candidate.reshape(1, -1))[0] >= 0: - return np.asarray(candidate, dtype=float) - return np.asarray(point, dtype=float) - - def _apply_relaxation_displacements( - self, - force_summary: ForceSummary, - time_step: float, - ) -> float: - """Move dynamic points and return the largest density-scaled relative step.""" - - new_points = np.array(self.points, copy=True) - max_relative_displacement = 0.0 - max_norm = math.inf - for index, state in enumerate(self.states): - if state not in (STATE_MOBILE, STATE_BOUNDARY): - continue - - point = self.points[index] - displacement = np.array(force_summary.total_forces[index], copy=True) * time_step - candidate = self._compute_constrained_displacement(point, int(state), displacement, max_norm) - new_points[index] = candidate - density_scale = self.geometry.density_at(candidate) ** (1.0 / max(self.geometry.dim, 1)) - relative_displacement = ( - float(np.linalg.norm(candidate - point)) - * density_scale - / max(self.a0, BOUNDARY_FUZZ) - ) - max_relative_displacement = max(max_relative_displacement, relative_displacement) - self.points = new_points - return max_relative_displacement - - def _check_convergence(self) -> None: - """Update the engine state when a convergence condition is satisfied.""" - - if ( - self.step > self.min_equilibrium_steps - and self.last_max_displacement <= self.tolerated_rel_move - ): - log.debug( - "Relaxation finished by movement convergence at step %d (rel_move=%g, effective_force=%g)", - self.step, - self.last_max_displacement, - self.last_max_effective_force, - ) - self.finished = True - return - - if ( - self.step >= 2 and self.last_max_effective_force <= BOUNDARY_FUZZ - ): - log.debug( - "Relaxation finished by effective-force equilibrium at step %d (effective_force=%g, rel_move=%g)", - self.step, - self.last_max_effective_force, - self.last_max_displacement, - ) - self.finished = True - - def _step_once(self) -> None: - """Execute one relaxation step over all currently mobile or boundary points.""" - - if len(self.points) < self.geometry.dim + 1: - self.finished = True - return - - force_summary = compute_forces( - self.points, - self.states, - self.geometry, - self.a0, - self.params, - self.step, - simplices=self.current_simplices, - ) - self._record_triangulation(force_summary) - self.last_point_density = np.asarray(force_summary.point_density, dtype=float) - time_step = self._effective_time_step(force_summary) - self.last_max_displacement = self._apply_relaxation_displacements(force_summary, time_step) - self.last_max_effective_force = force_summary.max_effective_force - self._refresh_boundary_states() - self._update_topology_movement() - self._refresh_topology_if_needed() - - if self._should_attempt_point_change(): - self.last_addition_deletion_step = self.step - self._attempt_add_delete_points(force_summary) - - self._check_convergence() - - def callback_payload(self) -> list[list[Any]]: - """Return the callback payload for the engine's current mesh snapshot.""" - - return _callback_mesh_info(self.cached_raw_mesh) - - def run(self, command: MeshEngineCommand) -> EngineResult: - """Handle one driver command and return the next engine status tuple.""" - - if command == MeshEngineCommand.DO_EXTRACT: - self._rebuild_mesh() - return MeshEngineStatus.PRODUCED_INTERMEDIATE_MESH, ( - self.callback_payload(), - self.run, - ) - - if command != MeshEngineCommand.DO_STEP: - raise ValueError(f"Unsupported mesh engine command: {command!r}") - - if self.finished or self._step_limit_reached(): - self._rebuild_mesh(final=True) - return MeshEngineStatus.FINISHED_STEP_LIMIT_REACHED, None - - self.step += 1 - self._step_once() - - if self.finished: - self._rebuild_mesh(final=True) - return MeshEngineStatus.FINISHED_FORCE_EQUILIBRIUM_REACHED, None - - if self._step_limit_reached(): - self._rebuild_mesh(final=True) - return MeshEngineStatus.FINISHED_STEP_LIMIT_REACHED, None - - return MeshEngineStatus.CAN_CONTINUE, self.run - - -def mesh_bodies_raw( - gendriver: Any, - mesher: dict[str, Any], - bb_min: list[float], - bb_max: list[float], - mesh_ext: int, - objects: list[Body], - a0: float, - density: str | DensityFunction | None, - fixed: list[list[float]], - mobile: list[list[float]], - simply: list[list[float]], - periodic: list[float] | list[bool], - hints: list[list[Any]], -) -> RawMesh: - """Public backend entry point for meshing bodies with the Python engine.""" - - geometry = fem_geometry_from_bodies( - (np.asarray(bb_min, dtype=float), np.asarray(bb_max, dtype=float)), - list(objects), - list(hints), - density=density, - mesh_exterior=bool(mesh_ext), - ) - - engine = RelaxationEngine( - geometry, - mesher, - float(a0), - _as_float_array(fixed, dim=geometry.dim), - _as_float_array(mobile, dim=geometry.dim), - _as_float_array(simply, dim=geometry.dim), - list(periodic), - rng=np.random.default_rng(DEFAULT_RNG_SEED), - ) - - if callable(gendriver): - gendriver(engine.run) - else: - engine.run(MeshEngineCommand.DO_STEP) - - engine._rebuild_mesh(final=True) - return engine.cached_raw_mesh diff --git a/src/nmesh/mesher/relaxation/engine/__init__.py b/src/nmesh/mesher/relaxation/engine/__init__.py new file mode 100644 index 0000000..cf30f13 --- /dev/null +++ b/src/nmesh/mesher/relaxation/engine/__init__.py @@ -0,0 +1,18 @@ +"""Public relaxation-engine façade composed from focused implementation phases.""" + +from .state import RelaxationEngineStateMixin +from .steps import ( + RelaxationEngineStepMixin, +) +from .steps import ( + mesh_bodies_raw as mesh_bodies_raw, # noqa: F401 +) +from .topology import RelaxationEngineTopologyMixin + + +class RelaxationEngine( + RelaxationEngineStateMixin, + RelaxationEngineTopologyMixin, + RelaxationEngineStepMixin, +): + """Iterative meshing engine that exposes the legacy driver protocol.""" diff --git a/src/nmesh/mesher/relaxation/engine/state.py b/src/nmesh/mesher/relaxation/engine/state.py new file mode 100644 index 0000000..69c67ad --- /dev/null +++ b/src/nmesh/mesher/relaxation/engine/state.py @@ -0,0 +1,155 @@ +"""State initialization and configuration access for relaxation meshing.""" + +from __future__ import annotations + +import logging +import math +from typing import TYPE_CHECKING, Any + +import numpy as np + +from .._constants import BOUNDARY_FUZZ, DEFAULT_RNG_SEED, STATE_BOUNDARY, STATE_MOBILE +from .._types import FloatArray +from ..geometry import FemGeometry +from ..seeding import _prepare_initial_points +from ..topology import assemble_raw_mesh +from ..topology.finalize import snap_final_boundary_points + +log = logging.getLogger(__name__) + + +class RelaxationEngineStateMixin: + if TYPE_CHECKING: + + def __getattr__(self, name: str) -> Any: ... + + def __init__( + self, + geometry: FemGeometry, + mesher: dict[str, Any], + a0: float, + fixed_points: FloatArray, + mobile_points: FloatArray, + simply_points: FloatArray, + periodic: list[float] | list[bool], + *, + rng: np.random.Generator | None = None, + ) -> None: + """Initialize the engine state from geometry, seeds, and mesher parameters.""" + + self.geometry = geometry + self.params = dict(mesher.get("parameters", {})) + self.a0 = float(a0) + self.periodic = list(periodic) + self.rng = rng or np.random.default_rng(DEFAULT_RNG_SEED) + self.points, self.states = _prepare_initial_points( + geometry, + self.a0, + fixed_points, + mobile_points, + simply_points, + self.periodic, + self.rng, + self.params, + ) + self.step = 0 + self.finished = False + self.last_addition_deletion_step = 0 + self.last_max_displacement = math.inf + self.last_max_effective_force = math.inf + self.last_point_density = np.zeros(len(self.points), dtype=float) + self.max_rel_movement_since_last_triangulation = 0.0 + self.current_simplices: np.ndarray | None = None + self.points_at_last_triangulation = np.array(self.points, copy=True) + self._refresh_boundary_states() + self.cached_raw_mesh = assemble_raw_mesh( + self.points, + self.geometry, + self.periodic, + states=self.states, + params=self.params, + ) + + @property + def max_steps(self) -> int: + """Return the configured maximum relaxation-step budget.""" + + return int(self.params.get("controller_step_limit_max", 1000)) + + @property + def tolerated_rel_move(self) -> float: + """Return the relative movement threshold used for convergence.""" + + return float(self.params.get("controller_tolerated_rel_movement", 0.002)) + + @property + def time_step_scale(self) -> float: + """Return the scale factor applied to each relaxation displacement.""" + + return float(self.params.get("controller_time_step_scale", 0.1)) + + @property + def max_time_step(self) -> float: + """Return the largest time-step magnitude allowed by the controller.""" + + return float(self.params.get("controller_max_time_step", 10.0)) + + @property + def boundary_drift_tolerance(self) -> float: + """Return the tolerated increase in boundary distance for boundary points. + + Legacy boundary points are only allowed to move when they do not drift + farther from the boundary. The Python port keeps only the global + floating-point boundary fuzz here; it does not scale this tolerance with + ``a0`` because that would turn a numerical guard into different meshing + behavior. + """ + + return BOUNDARY_FUZZ + + @property + def min_equilibrium_steps(self) -> int: + """Return the minimum number of steps before equilibrium can stop the loop.""" + + return int(self.params.get("controller_step_limit_min", 500)) + + @property + def post_change_settling_steps(self) -> int: + """Return the legacy settling window after point add/delete checks.""" + + return 50 + + def _rebuild_mesh(self, *, final: bool = False) -> None: + """Refresh the cached ``RawMesh`` snapshot from the current point cloud.""" + + points, states = ( + self._final_output_points_and_states() if final else (self.points, self.states) + ) + self.cached_raw_mesh = assemble_raw_mesh( + points, + self.geometry, + self.periodic, + states=states, + params=self.params, + ) + + def _final_output_points_and_states(self) -> tuple[FloatArray, np.ndarray]: + """Return the final point cloud after the legacy high-density cleanup.""" + + if len(self.last_point_density) != len(self.points): + return self.points, self.states + dynamic_mask = np.isin(self.states, [STATE_MOBILE, STATE_BOUNDARY]) + keep = (~dynamic_mask) | (self.last_point_density < 100.0) + if np.all(keep): + points = self.points + states = self.states + else: + points = self.points[keep] + states = self.states[keep] + dynamic_mask = np.isin(states, [STATE_MOBILE, STATE_BOUNDARY]) + domain_mask = self.geometry.classify_points(points) >= 0 + keep_domain = (~dynamic_mask) | domain_mask + if not np.all(keep_domain): + points = points[keep_domain] + states = states[keep_domain] + return snap_final_boundary_points(points, states, self.geometry, self.a0, self.params) diff --git a/src/nmesh/mesher/relaxation/engine/steps.py b/src/nmesh/mesher/relaxation/engine/steps.py new file mode 100644 index 0000000..7e12978 --- /dev/null +++ b/src/nmesh/mesher/relaxation/engine/steps.py @@ -0,0 +1,248 @@ +"""Relaxation displacement, convergence, and command execution phases.""" + +from __future__ import annotations + +import logging +import math +from typing import TYPE_CHECKING, Any + +import numpy as np + +from ....backend import RawMesh +from ....geometry.primitives import Body +from ...driver import MeshEngineCommand, MeshEngineStatus +from .._constants import BOUNDARY_FUZZ, DEFAULT_RNG_SEED, STATE_BOUNDARY, STATE_MOBILE +from .._types import DensityFunction, EngineResult, FloatArray +from ..forces import ForceSummary, compute_forces +from ..geometry import FemGeometry, fem_geometry_from_bodies +from ..seeding import _as_float_array +from ..topology import _callback_mesh_info + +log = logging.getLogger(__name__) + + +class RelaxationEngineStepMixin: + if TYPE_CHECKING: + geometry: FemGeometry + params: dict[str, Any] + a0: float + periodic: list[float] | list[bool] + points: FloatArray + states: np.ndarray[Any, Any] + step: int + finished: bool + last_addition_deletion_step: int + last_max_displacement: float + last_max_effective_force: float + last_point_density: FloatArray + max_rel_movement_since_last_triangulation: float + current_simplices: np.ndarray[Any, Any] | None + points_at_last_triangulation: FloatArray + cached_raw_mesh: RawMesh + + def __getattr__(self, name: str) -> Any: ... + + def _compute_constrained_displacement( + self, + point: FloatArray, + state: int, + displacement: FloatArray, + max_norm: float, + ) -> FloatArray: + """Return a clipped, boundary-aware candidate position for one point.""" + + bounded_displacement = np.array(displacement, copy=True) + if state == STATE_BOUNDARY: + normal = self.geometry.boundary_normal(point) + normal_norm = float(np.linalg.norm(normal)) + if normal_norm > BOUNDARY_FUZZ: + bounded_displacement -= float(np.dot(bounded_displacement, normal)) * ( + normal / normal_norm + ) + + disp_norm = float(np.linalg.norm(bounded_displacement)) + if disp_norm > max_norm > 0.0: + bounded_displacement *= max_norm / disp_norm + + candidate = point + bounded_displacement + if state == STATE_BOUNDARY: + old_boundary_distance = self.geometry.boundary_distance(point) + new_boundary_distance = self.geometry.boundary_distance(candidate) + if new_boundary_distance > old_boundary_distance + self.boundary_drift_tolerance: + return np.asarray(point, dtype=float) + + if self.geometry.classify_points(candidate[np.newaxis, :])[0] < 0: + candidate = self.geometry.project_segment_to_domain(point, candidate) + + if self.geometry.classify_points(candidate[np.newaxis, :])[0] >= 0: + return np.asarray(candidate, dtype=float) + return np.asarray(point, dtype=float) + + def _apply_relaxation_displacements( + self, + force_summary: ForceSummary, + time_step: float, + ) -> float: + """Move dynamic points and return the largest density-scaled relative step.""" + + new_points = np.array(self.points, copy=True) + max_relative_displacement = 0.0 + max_norm = math.inf + for index, state in enumerate(self.states): + if state not in (STATE_MOBILE, STATE_BOUNDARY): + continue + + point = self.points[index] + displacement = np.array(force_summary.total_forces[index], copy=True) * time_step + candidate = self._compute_constrained_displacement( + point, int(state), displacement, max_norm + ) + new_points[index] = candidate + density_scale = self.geometry.density_at(candidate) ** (1.0 / max(self.geometry.dim, 1)) + relative_displacement = ( + float(np.linalg.norm(candidate - point)) + * density_scale + / max(self.a0, BOUNDARY_FUZZ) + ) + max_relative_displacement = max(max_relative_displacement, relative_displacement) + self.points = new_points + return max_relative_displacement + + def _check_convergence(self) -> None: + """Update the engine state when a convergence condition is satisfied.""" + + if ( + self.step > self.min_equilibrium_steps + and self.last_max_displacement <= self.tolerated_rel_move + ): + log.debug( + "Relaxation finished by movement convergence at step %d (rel_move=%g, effective_force=%g)", + self.step, + self.last_max_displacement, + self.last_max_effective_force, + ) + self.finished = True + return + + if self.step >= 2 and self.last_max_effective_force <= BOUNDARY_FUZZ: + log.debug( + "Relaxation finished by effective-force equilibrium at step %d (effective_force=%g, rel_move=%g)", + self.step, + self.last_max_effective_force, + self.last_max_displacement, + ) + self.finished = True + + def _step_once(self) -> None: + """Execute one relaxation step over all currently mobile or boundary points.""" + + if len(self.points) < self.geometry.dim + 1: + self.finished = True + return + + force_summary = compute_forces( + self.points, + self.states, + self.geometry, + self.a0, + self.params, + self.step, + simplices=self.current_simplices, + ) + self._record_triangulation(force_summary) + self.last_point_density = np.asarray(force_summary.point_density, dtype=float) + time_step = self._effective_time_step(force_summary) + self.last_max_displacement = self._apply_relaxation_displacements(force_summary, time_step) + self.last_max_effective_force = force_summary.max_effective_force + self._refresh_boundary_states() + self._update_topology_movement() + self._refresh_topology_if_needed() + + if self._should_attempt_point_change(): + self.last_addition_deletion_step = self.step + self._attempt_add_delete_points(force_summary) + + self._check_convergence() + + def callback_payload(self) -> list[list[Any]]: + """Return the callback payload for the engine's current mesh snapshot.""" + + return _callback_mesh_info(self.cached_raw_mesh) + + def run(self, command: MeshEngineCommand) -> EngineResult: + """Handle one driver command and return the next engine status tuple.""" + + if command == MeshEngineCommand.DO_EXTRACT: + self._rebuild_mesh() + return MeshEngineStatus.PRODUCED_INTERMEDIATE_MESH, ( + self.callback_payload(), + self.run, + ) + + if command != MeshEngineCommand.DO_STEP: + raise ValueError(f"Unsupported mesh engine command: {command!r}") + + if self.finished or self._step_limit_reached(): + self._rebuild_mesh(final=True) + return MeshEngineStatus.FINISHED_STEP_LIMIT_REACHED, None + + self.step += 1 + self._step_once() + + if self.finished: + self._rebuild_mesh(final=True) + return MeshEngineStatus.FINISHED_FORCE_EQUILIBRIUM_REACHED, None + + if self._step_limit_reached(): + self._rebuild_mesh(final=True) + return MeshEngineStatus.FINISHED_STEP_LIMIT_REACHED, None + + return MeshEngineStatus.CAN_CONTINUE, self.run + + +def mesh_bodies_raw( + gendriver: Any, + mesher: dict[str, Any], + bb_min: list[float], + bb_max: list[float], + mesh_ext: int, + objects: list[Body], + a0: float, + density: str | DensityFunction | None, + fixed: list[list[float]], + mobile: list[list[float]], + simply: list[list[float]], + periodic: list[float] | list[bool], + hints: list[list[Any]], +) -> RawMesh: + """Public backend entry point for meshing bodies with the Python engine.""" + + geometry = fem_geometry_from_bodies( + (np.asarray(bb_min, dtype=float), np.asarray(bb_max, dtype=float)), + list(objects), + list(hints), + density=density, + mesh_exterior=bool(mesh_ext), + ) + + # Import lazily so the public façade can compose the engine mixins first. + from . import RelaxationEngine + + engine = RelaxationEngine( + geometry, + mesher, + float(a0), + _as_float_array(fixed, dim=geometry.dim), + _as_float_array(mobile, dim=geometry.dim), + _as_float_array(simply, dim=geometry.dim), + list(periodic), + rng=np.random.default_rng(DEFAULT_RNG_SEED), + ) + + if callable(gendriver): + gendriver(engine.run) + else: + engine.run(MeshEngineCommand.DO_STEP) + + engine._rebuild_mesh(final=True) + return engine.cached_raw_mesh diff --git a/src/nmesh/mesher/relaxation/engine/topology.py b/src/nmesh/mesher/relaxation/engine/topology.py new file mode 100644 index 0000000..c4d2e20 --- /dev/null +++ b/src/nmesh/mesher/relaxation/engine/topology.py @@ -0,0 +1,230 @@ +"""Topology refresh and point insertion/deletion for relaxation meshing.""" + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING, Any + +import numpy as np + +from ...meshing_parameters import PointFate, default_handle_point_density_fun +from .._constants import BOUNDARY_FUZZ, STATE_BOUNDARY, STATE_MOBILE +from .._types import FloatArray +from ..forces import ForceSummary +from ..seeding import _classify_dynamic_states, _dedupe_points +from ..topology.recovery import mirror_surface_recovery_points + + +class RelaxationEngineTopologyMixin: + if TYPE_CHECKING: + + def __getattr__(self, name: str) -> Any: ... + + def _mark_topology_stale(self) -> None: + """Request a fresh Delaunay triangulation before the next force step.""" + + self.current_simplices = None + self.max_rel_movement_since_last_triangulation = 0.0 + self.points_at_last_triangulation = np.array(self.points, copy=True) + + def _refresh_boundary_states(self) -> None: + """Refresh dynamic points between mobile and boundary states.""" + + dynamic_mask = np.isin(self.states, [STATE_MOBILE, STATE_BOUNDARY]) + if not np.any(dynamic_mask): + return + refreshed = _classify_dynamic_states(self.geometry, self.points[dynamic_mask], self.a0) + self.states[dynamic_mask] = refreshed + + def _effective_time_step(self, force_summary: ForceSummary) -> float: + """Compute the controller-like time step for the current force field.""" + + freedom = float(self.params.get("controller_movement_max_freedom", 3.0)) + movement_weight = self._initial_relaxation_weight(freedom, 1.0) + capped_max_time_step = movement_weight * self.max_time_step + if force_summary.max_effective_force <= BOUNDARY_FUZZ: + return capped_max_time_step + return min( + capped_max_time_step, + (movement_weight * self.time_step_scale) / force_summary.max_effective_force, + ) + + def _initial_relaxation_weight(self, init_val: float, final_val: float) -> float: + """Evaluate the legacy initial-relaxation ramp for this step.""" + + settling_steps = int(self.params.get("controller_initial_settling_steps", 100)) + weight_fun = self.params.get("initial_relaxation_weight_fun") + if callable(weight_fun): + result: Any = weight_fun(self.step, settling_steps, init_val, final_val) + return float(result) + fraction = min(1.0, float(self.step) / max(float(settling_steps), 1.0)) + return init_val + (final_val - init_val) * fraction + + def _density_thresholds(self) -> tuple[float, float]: + """Return add/delete thresholds with the legacy initial relaxation ramp.""" + + freedom = float(self.params.get("controller_movement_max_freedom", 3.0)) + thresh_add = self._initial_relaxation_weight(-0.1 * freedom, 0.0) + float( + self.params.get("controller_thresh_add", 1.0) + ) + thresh_del = self._initial_relaxation_weight(0.1 * freedom, 0.0) + float( + self.params.get("controller_thresh_del", 2.0) + ) + return thresh_add, thresh_del + + @staticmethod + def _is_positive_square_number(value: int) -> bool: + """Return whether ``value`` is a positive perfect square.""" + + if value <= 0: + return False + root = int(math.sqrt(value) + 0.5) + return root * root == value + + def _should_attempt_point_change(self) -> bool: + """Return whether the legacy controller schedules point fate changes.""" + + return self.step < self.max_steps and self._is_positive_square_number(self.step - 10) + + def _step_limit_reached(self) -> bool: + """Return whether the legacy max-step and post-change settling rules are met.""" + + return ( + self.step >= self.max_steps + and self.step >= self.last_addition_deletion_step + self.post_change_settling_steps + ) + + def _topology_threshold(self) -> float: + """Return the relaxed legacy threshold for topology refresh.""" + + freedom = float(self.params.get("controller_movement_max_freedom", 3.0)) + threshold = float(self.params.get("controller_topology_threshold", 0.2)) + return self._initial_relaxation_weight(freedom, 1.0) * threshold + + def _record_triangulation(self, force_summary: ForceSummary) -> None: + """Cache the current topology after a force calculation builds it.""" + + if self.current_simplices is None: + self.current_simplices = np.asarray(force_summary.simplices, dtype=int) + self.points_at_last_triangulation = np.array(self.points, copy=True) + self.max_rel_movement_since_last_triangulation = 0.0 + + def _update_topology_movement(self) -> None: + """Track movement since the last triangulation in OCaml controller units.""" + + if len(self.points) != len(self.points_at_last_triangulation): + self._mark_topology_stale() + return + + if len(self.points) == 0: + self.max_rel_movement_since_last_triangulation = 0.0 + return + + displacements = np.linalg.norm(self.points - self.points_at_last_triangulation, axis=1) + density_scale = np.asarray( + [ + self.geometry.density_at(point) ** (1.0 / max(self.geometry.dim, 1)) + for point in self.points + ], + dtype=float, + ) + scaled = displacements * density_scale / max(self.a0, BOUNDARY_FUZZ) + self.max_rel_movement_since_last_triangulation = max( + self.max_rel_movement_since_last_triangulation, + float(np.max(scaled, initial=0.0)), + ) + + def _refresh_topology_if_needed(self) -> None: + """Invalidate cached topology when movement exceeds the legacy threshold.""" + + if self.max_rel_movement_since_last_triangulation > self._topology_threshold(): + self._mark_topology_stale() + + def _attempt_add_delete_points(self, force_summary: ForceSummary) -> None: + """Apply the mesher density heuristic to add or remove mobile points.""" + + if len(self.points) == 0: + return + + additions, removals = self._evaluate_point_densities(force_summary) + recovery_points = mirror_surface_recovery_points( + self.points, + self.states, + self.geometry, + self.a0, + force_summary.simplices, + ) + if len(recovery_points) > 0: + additions.extend(recovery_points) + self._apply_point_changes(additions, removals) + self._refresh_boundary_states() + + def _evaluate_point_densities( + self, + force_summary: ForceSummary, + ) -> tuple[list[FloatArray], list[int]]: + """Decide which points to add or remove from the current cloud.""" + + handler = self.params.get("handle_point_density_fun", default_handle_point_density_fun) + thresh_add, thresh_del = self._density_thresholds() + additions: list[FloatArray] = [] + removals: list[int] = [] + + for index, state in enumerate(self.states): + if state not in (STATE_MOBILE, STATE_BOUNDARY) or index in removals: + continue + + neigh = force_summary.neighbor_map[index] + if not neigh: + continue + + point = self.points[index] + avg_density = float(force_summary.point_density[index]) + avg_force = float(force_summary.point_average_force[index]) + + fate = handler(self.rng, (avg_density, avg_force), thresh_add, thresh_del) + if fate == PointFate.ADD_ANOTHER: + additions.append(self._random_point_close_to(point)) + elif ( + fate == PointFate.DELETE + and len(self.points) - len(removals) > self.geometry.dim + 1 + ): + removals.append(index) + + return additions, removals + + def _random_point_close_to(self, point: FloatArray) -> FloatArray: + """Return a Gaussian insertion candidate using the legacy rod-length scale.""" + + density_here = self.geometry.density_at(point) + effective_rod_length = self.a0 * (density_here ** (-1.0 / max(self.geometry.dim, 1))) + candidate = np.asarray( + self.rng.normal(loc=np.asarray(point, dtype=float), scale=effective_rod_length), + dtype=float, + ) + if self.geometry.classify_points(candidate[np.newaxis, :])[0] >= 0: + return candidate + return self.geometry.project_segment_to_domain(np.asarray(point, dtype=float), candidate) + + def _apply_point_changes(self, additions: list[FloatArray], removals: list[int]) -> None: + """Mutate the point cloud according to the evaluated add/remove plan.""" + + if removals: + keep = np.ones(len(self.points), dtype=bool) + keep[np.asarray(removals, dtype=int)] = False + self.points = self.points[keep] + self.states = self.states[keep] + self._mark_topology_stale() + + if additions: + additions_arr = _dedupe_points(np.asarray(additions, dtype=float)) + if len(additions_arr) > 0: + self.points = np.vstack((self.points, additions_arr)) + addition_states = _classify_dynamic_states(self.geometry, additions_arr, self.a0) + self.states = np.concatenate( + ( + self.states, + addition_states, + ) + ) + self._mark_topology_stale() diff --git a/src/nmesh/mesher/relaxation/forces.py b/src/nmesh/mesher/relaxation/forces.py deleted file mode 100644 index 3c6ea2a..0000000 --- a/src/nmesh/mesher/relaxation/forces.py +++ /dev/null @@ -1,833 +0,0 @@ -"""Force calculations for the relaxation meshing pipeline.""" - -from __future__ import annotations - -import math -from dataclasses import dataclass -from itertools import combinations -from typing import Any - -import numpy as np - -from ..meshing_parameters import ( - default_boundary_node_force_fun, - default_initial_relaxation_weight, - default_relaxation_force_fun, -) -from ._constants import ( - BOUNDARY_FUZZ, - DENSITY_EPSILON, - STATE_BOUNDARY, - STATE_FIXED, - STATE_MOBILE, - STATE_SIMPLE, -) -from ._types import FloatArray -from .geometry import FemGeometry -from .jit import accumulate_neighbor_forces_default -from .topology import ( - _boundary_state_mask, - _classify_simplices_with_probes, - _simplex_measures, - _simplex_volume_order_ratio, - _triangulate_points, -) - - -@dataclass(frozen=True, slots=True) -class ForceSummary: - """Container for the per-step force calculation results.""" - - total_forces: FloatArray - neighbor_map: list[list[int]] - simplices: np.ndarray - point_density: FloatArray - point_average_force: FloatArray - point_effective_force: FloatArray - max_effective_force: float - - -@dataclass(frozen=True, slots=True) -class ForceParameters: - """Resolved controller parameters for one relaxation-force evaluation.""" - - shape_force_scale: float - volume_force_scale: float - neigh_force_scale: float - irrel_force_scale: float - sliver_correction: float - smallest_allowed_volume_ratio: float - relaxation_weight: float - force_fun: Any - boundary_force_fun: Any - - -def _corner_force_threshold(dim: int) -> float: - """Return the solid-angle threshold below which corner suppression applies.""" - - if dim == 2: - return 0.75 * math.pi - if dim == 3: - return 0.85 * math.pi - return -math.inf - - -def _build_neighbor_pairs(simplices: np.ndarray) -> np.ndarray: - """Return sorted unique point-pair edges induced by the simplices.""" - - if len(simplices) == 0: - return np.empty((0, 2), dtype=np.int64) - - pairs: set[tuple[int, int]] = set() - for simplex in simplices: - for left, right in combinations(simplex.tolist(), 2): - i = int(left) - j = int(right) - pairs.add((i, j) if i <= j else (j, i)) - return np.asarray(sorted(pairs), dtype=np.int64) - - -def _is_dynamic_state(state: int) -> bool: - """Return whether the state participates in relaxation movement.""" - - return state in (STATE_MOBILE, STATE_BOUNDARY) - - -def _is_boundary_interaction(state_a: int, state_b: int) -> bool: - """Return whether a neighbor interaction should use the boundary law.""" - - return ( - state_a in (STATE_FIXED, STATE_BOUNDARY, STATE_SIMPLE) - or state_b in (STATE_FIXED, STATE_BOUNDARY, STATE_SIMPLE) - ) - - -def _regular_simplex_volume(edge_length: float, dim: int) -> float: - """Return the volume of a regular simplex with the supplied edge length.""" - - if dim <= 0: - return edge_length - numerator = edge_length**dim * math.sqrt(dim + 1.0) - denominator = math.factorial(dim) * math.sqrt(2.0**dim) - return numerator / denominator - - -def _sphere_volume(radius: float, dim: int) -> float: - """Return the d-dimensional volume of a sphere.""" - - return (math.pi ** (0.5 * dim) / math.gamma(0.5 * dim + 1.0)) * (radius**dim) - - -def _neighbor_forces_python( - points: FloatArray, - states: np.ndarray, - point_densities: FloatArray, - neighbor_map: list[list[int]], - dim: int, - a0: float, - neigh_force_scale: float, - force_fun: Any, - boundary_force_fun: Any, -) -> tuple[FloatArray, FloatArray, np.ndarray]: - """Fallback neighbor-force path for custom Python force callbacks.""" - - total_forces = np.zeros((len(points), dim), dtype=float) - neighbor_force_sums = np.zeros(len(points), dtype=float) - neighbor_force_counts = np.zeros(len(points), dtype=int) - - for left, neighbors in enumerate(neighbor_map): - point_left = points[left] - state_left = int(states[left]) - for right in neighbors: - if right <= left: - continue - contribution, scalar_force = _apply_pairwise_force( - point_left=point_left, - point_right=points[right], - state_left=state_left, - state_right=int(states[right]), - density_left=float(point_densities[left]), - density_right=float(point_densities[right]), - dim=dim, - a0=a0, - neigh_force_scale=neigh_force_scale, - force_fun=force_fun, - boundary_force_fun=boundary_force_fun, - ) - if scalar_force is None: - continue - neighbor_force_sums[left] += abs(scalar_force) - neighbor_force_sums[right] += abs(scalar_force) - neighbor_force_counts[left] += 1 - neighbor_force_counts[right] += 1 - if contribution is None: - continue - total_forces[left] += contribution - total_forces[right] -= contribution - - return total_forces, neighbor_force_sums, neighbor_force_counts - - -def _apply_pairwise_force( - *, - point_left: FloatArray, - point_right: FloatArray, - state_left: int, - state_right: int, - density_left: float, - density_right: float, - dim: int, - a0: float, - neigh_force_scale: float, - force_fun: Any, - boundary_force_fun: Any, -) -> tuple[FloatArray | None, float | None]: - """Compute the pairwise neighbor contribution for one point pair.""" - - if not _is_dynamic_state(state_left) and not _is_dynamic_state(state_right): - return None, None - - delta = point_right - point_left - true_distance = float(np.linalg.norm(delta)) - if true_distance <= DENSITY_EPSILON: - return None, None - - avg_density = 0.5 * (density_left + density_right) - inv_length_scale = (avg_density ** (1.0 / max(dim, 1))) / max(a0, DENSITY_EPSILON) - reduced_distance = true_distance * inv_length_scale - scalar_force = float( - (boundary_force_fun if _is_boundary_interaction(state_left, state_right) else force_fun)( - reduced_distance - ) - ) - scaled_force = neigh_force_scale * abs(scalar_force) - if scalar_force == 0.0: - return None, scaled_force - return neigh_force_scale * (-scalar_force) * delta, scaled_force - - -def _extract_force_parameters(params: dict[str, Any], step: int) -> ForceParameters: - """Resolve the mesher controller parameters used by ``compute_forces``.""" - - settling_steps = int(params.get("controller_initial_settling_steps", 100)) - relaxation_weight_fun = params.get("initial_relaxation_weight_fun", default_initial_relaxation_weight) - return ForceParameters( - shape_force_scale=float(params.get("controller_shape_force_scale", 0.1)), - volume_force_scale=float(params.get("controller_volume_force_scale", 0.0)), - neigh_force_scale=float(params.get("controller_neigh_force_scale", 1.0)), - irrel_force_scale=float(params.get("controller_irrel_elem_force_scale", 1.0)), - sliver_correction=float(params.get("controller_sliver_correction", 1.0)), - smallest_allowed_volume_ratio=float( - params.get("controller_smallest_allowed_volume_ratio", 1.0) - ), - relaxation_weight=float(relaxation_weight_fun(step, settling_steps, 0.0, 1.0)), - force_fun=params.get("relaxation_force_fun", default_relaxation_force_fun), - boundary_force_fun=params.get("boundary_node_force_fun", default_boundary_node_force_fun), - ) - - -def _build_neighbor_map(point_count: int, simplices: np.ndarray) -> list[list[int]]: - """Build undirected point adjacency from simplices.""" - - neighbors: list[set[int]] = [set() for _ in range(point_count)] - for simplex in simplices: - for left, right in combinations(simplex.tolist(), 2): - i = int(left) - j = int(right) - neighbors[i].add(j) - neighbors[j].add(i) - return [sorted(group) for group in neighbors] - - -def _compute_neighbor_forces( - points: FloatArray, - states: np.ndarray, - simplices: np.ndarray, - neighbor_map: list[list[int]], - point_densities: FloatArray, - dim: int, - a0: float, - config: ForceParameters, -) -> tuple[FloatArray, FloatArray, np.ndarray]: - """Compute neighbor-force contributions through the JIT or Python path.""" - - point_count = len(points) - if len(simplices) == 0: - return ( - np.zeros((point_count, dim), dtype=float), - np.zeros(point_count, dtype=float), - np.zeros(point_count, dtype=int), - ) - - if ( - config.force_fun is default_relaxation_force_fun - and config.boundary_force_fun is default_boundary_node_force_fun - ): - neighbor_pairs = _build_neighbor_pairs(simplices) - return accumulate_neighbor_forces_default( - np.asarray(points, dtype=np.float64), - np.asarray(states, dtype=np.int64), - point_densities.astype(np.float64, copy=False), - neighbor_pairs, - float(a0), - config.neigh_force_scale, - ) - - return _neighbor_forces_python( - points, - states, - point_densities, - neighbor_map, - dim, - a0, - config.neigh_force_scale, - config.force_fun, - config.boundary_force_fun, - ) - - -def _vertex_angle(points: FloatArray, simplex: np.ndarray, local_index: int, dim: int) -> float: - """Return the angle or solid angle covered by a simplex at one vertex.""" - - vertex = points[int(simplex[local_index])] - others = [ - points[int(simplex[index])] - vertex - for index in range(len(simplex)) - if index != local_index - ] - - if dim == 1: - return math.pi - - if dim == 2: - first = others[0] - second = others[1] - denom = float(np.linalg.norm(first) * np.linalg.norm(second)) - if denom <= DENSITY_EPSILON: - return 0.0 - cosine = float(np.clip(np.dot(first, second) / denom, -1.0, 1.0)) - return float(math.acos(cosine)) - - if dim == 3: - a, b, c = others - numer = abs(float(np.dot(a, np.cross(b, c)))) - denom = ( - float(np.linalg.norm(a) * np.linalg.norm(b) * np.linalg.norm(c)) - + float(np.dot(a, b) * np.linalg.norm(c)) - + float(np.dot(a, c) * np.linalg.norm(b)) - + float(np.dot(b, c) * np.linalg.norm(a)) - ) - if numer <= DENSITY_EPSILON and denom <= DENSITY_EPSILON: - return 0.0 - return float(2.0 * math.atan2(numer, max(denom, DENSITY_EPSILON))) - - return 1.0 - - -def _shape_force_matrix(vertices: FloatArray, dim: int) -> FloatArray: - """Build the covariance-derived shape force matrix for one simplex.""" - - covariance = np.asarray(vertices.T @ vertices, dtype=float) - covariance /= max(float(len(vertices) - 1), 1.0) - - eigenvalues, eigenvectors = np.linalg.eigh(covariance) - eigenvectors = np.asarray(eigenvectors, dtype=float) - safe_eigenvalues = np.clip(eigenvalues.astype(float, copy=False), DENSITY_EPSILON, None) - product = float(np.prod(safe_eigenvalues)) - if product <= DENSITY_EPSILON: - return np.zeros((dim, dim), dtype=float) - - scaled = safe_eigenvalues * ((1.0 / product) ** (1.0 / max(dim, 1))) - transformed = np.asarray(np.diag(-np.log(np.clip(scaled, DENSITY_EPSILON, None))), dtype=float) - return np.asarray(eigenvectors @ transformed @ eigenvectors.T, dtype=float) - - -def _project_force_to_tangent( - geometry: FemGeometry, - point: FloatArray, - force: FloatArray, -) -> FloatArray: - """Remove the normal component of a force at a boundary point.""" - - normal = geometry.boundary_normal(point) - normal_norm = float(np.linalg.norm(normal)) - if normal_norm <= BOUNDARY_FUZZ: - return np.asarray(force, dtype=float) - unit_normal = normal / normal_norm - return np.asarray(force, dtype=float) - float(np.dot(force, unit_normal)) * unit_normal - - -def _simplex_force_contribution( - simplex_points: FloatArray, - dim: int, - density_here: float, - a0: float, - relaxation_weight: float, - shape_force_scale: float, - volume_force_scale: float, - sliver_correction: float, - volume: float, -) -> FloatArray: - """Compute the shape and volume force contribution for one simplex.""" - - center = np.mean(simplex_points, axis=0) - vertices = simplex_points - center - ideal_edge_length = a0 / (density_here ** (1.0 / max(dim, 1))) - ideal_volume = _regular_simplex_volume(ideal_edge_length, dim) - return _compute_volume_forces( - vertices, - relaxation_weight, - volume_force_scale, - volume, - ideal_volume, - ) + _compute_shape_forces( - vertices=vertices, - dim=dim, - relaxation_weight=relaxation_weight, - shape_force_scale=shape_force_scale, - sliver_correction=sliver_correction, - volume=volume, - ideal_volume=ideal_volume, - ) - - -def _compute_volume_forces( - vertices: FloatArray, - relaxation_weight: float, - volume_force_scale: float, - volume: float, - ideal_volume: float, -) -> FloatArray: - """Return the isotropic volume-restoring forces for one simplex.""" - - forces = np.zeros_like(vertices) - if volume_force_scale <= 0.0 or volume <= DENSITY_EPSILON or ideal_volume <= DENSITY_EPSILON: - return forces - volume_factor = volume_force_scale * math.log(ideal_volume / volume) - return forces + relaxation_weight * volume_factor * vertices - - -def _compute_shape_forces( - *, - vertices: FloatArray, - dim: int, - relaxation_weight: float, - shape_force_scale: float, - sliver_correction: float, - volume: float, - ideal_volume: float, -) -> FloatArray: - """Return the covariance-derived shape-correction forces for one simplex.""" - - forces = np.zeros_like(vertices) - if shape_force_scale <= 0.0: - return forces - - shape_matrix = _shape_force_matrix(vertices, dim) - if not np.any(shape_matrix): - return forces - - vol_correction = _shape_volume_correction(volume, ideal_volume, dim) - for index, vertex in enumerate(vertices): - norm = float(np.linalg.norm(vertex)) - if norm <= DENSITY_EPSILON: - continue - raw_force = shape_matrix @ vertex - normal = vertex / norm - projection = float(np.dot(raw_force, normal)) - angular = raw_force - projection * normal - forces[index] += relaxation_weight * vol_correction * shape_force_scale * angular - if projection > 0.0 and sliver_correction > 0.0: - longitudinal = raw_force - angular - forces[index] += ( - shape_force_scale - * sliver_correction - * relaxation_weight - * max(vol_correction - 1.0, 0.0) - * longitudinal - ) - return forces - - -def _shape_volume_correction(volume: float, ideal_volume: float, dim: int) -> float: - """Return the volume-dependent multiplier applied to shape forces.""" - - if volume <= DENSITY_EPSILON or ideal_volume <= DENSITY_EPSILON: - return 0.0 - offset = 0.0 if dim <= 2 else 1.0 - return max(1.0, offset + math.log(ideal_volume / volume)) - - -def _simplex_incidence_data( - points: FloatArray, - simplices: np.ndarray, - dim: int, -) -> tuple[FloatArray, list[list[int]], FloatArray]: - """Collect simplex measures plus per-point incident simplex and angle data.""" - - simplex_measures = _simplex_measures(points, simplices, dim) - incident_simplices: list[list[int]] = [[] for _ in range(len(points))] - angle_sums = np.zeros(len(points), dtype=float) - for simplex_index, simplex in enumerate(simplices): - for local_index, point_index in enumerate(simplex.tolist()): - point_id = int(point_index) - incident_simplices[point_id].append(simplex_index) - angle_sums[point_id] += _vertex_angle(points, simplex, local_index, dim) - return simplex_measures, incident_simplices, angle_sums - - -def _compute_simplex_forces( - total_forces: FloatArray, - points: FloatArray, - states: np.ndarray, - geometry: FemGeometry, - simplices: np.ndarray, - point_densities: FloatArray, - simplex_measures: FloatArray, - angle_sums: FloatArray, - dim: int, - a0: float, - config: ForceParameters, -) -> None: - """Accumulate simplex shape, volume, and irrelevant-element forces.""" - - if len(simplices) == 0: - return - if ( - config.shape_force_scale <= 0.0 - and config.volume_force_scale <= 0.0 - and config.irrel_force_scale <= 0.0 - ): - return - - relevant_simplices = _classify_relevant_simplices( - points, - states, - geometry, - simplices, - simplex_measures, - dim, - config, - ) - suppress_corner_forces = angle_sums < _corner_force_threshold(dim) - for simplex_index, simplex in enumerate(simplices): - simplex_points = points[simplex] - simplex_force = np.zeros_like(simplex_points) - volume = float(simplex_measures[simplex_index]) - if relevant_simplices[simplex_index]: - density_here = geometry.density_at(np.mean(simplex_points, axis=0)) - simplex_force = _simplex_force_contribution( - simplex_points=simplex_points, - dim=dim, - density_here=density_here, - a0=a0, - relaxation_weight=config.relaxation_weight, - shape_force_scale=config.shape_force_scale, - volume_force_scale=config.volume_force_scale, - sliver_correction=config.sliver_correction, - volume=volume, - ) - elif config.irrel_force_scale > 0.0: - center = np.mean(simplex_points, axis=0) - for local_index, point_index in enumerate(simplex.tolist()): - if int(states[point_index]) != STATE_MOBILE: - continue - simplex_force[local_index] = ( - config.irrel_force_scale * (center - simplex_points[local_index]) - ) - - for local_index, point_index in enumerate(simplex.tolist()): - point_id = int(point_index) - if suppress_corner_forces[point_id]: - continue - total_forces[point_id] += simplex_force[local_index] - - -def _classify_relevant_simplices( - points: FloatArray, - states: np.ndarray, - geometry: FemGeometry, - simplices: np.ndarray, - simplex_measures: FloatArray, - dim: int, - config: ForceParameters, -) -> np.ndarray: - """Return the legacy relevant-simplex mask for force calculation.""" - - if len(simplices) == 0: - return np.empty(0, dtype=bool) - - region_ids, probe_consistent = _classify_simplices_with_probes( - points, - simplices, - geometry, - ) - boundary_mask = _boundary_state_mask(points, states, geometry) - all_boundary = np.all(boundary_mask[simplices], axis=1) - boundary_ratio = _simplex_volume_order_ratio(points, simplices, dim) - flat_boundary = all_boundary & (boundary_ratio < config.smallest_allowed_volume_ratio) - return (region_ids >= 0) & probe_consistent & (simplex_measures > BOUNDARY_FUZZ) & ~flat_boundary - - -def _finalize_force_summary( - total_forces: FloatArray, - neighbor_map: list[list[int]], - simplices: np.ndarray, - point_densities: FloatArray, - neighbor_force_sums: FloatArray, - neighbor_force_counts: np.ndarray, - simplex_measures: FloatArray, - incident_simplices: list[list[int]], - angle_sums: FloatArray, - points: FloatArray, - states: np.ndarray, - geometry: FemGeometry, - a0: float, - dim: int, -) -> ForceSummary: - """Assemble the per-point density and effective-force metrics.""" - - point_count = len(points) - point_density, point_average_force, point_effective_force = _empty_force_arrays(point_count) - - if point_count == 0: - return _make_force_summary( - total_forces, - neighbor_map, - simplices, - point_density, - point_average_force, - point_effective_force, - ) - - boundary_mask = geometry.boundary_mask(points, tolerance=max(0.05 * a0, BOUNDARY_FUZZ)) - - for point_index, point in enumerate(points): - point_state = int(states[point_index]) - density_here = float(point_densities[point_index]) - effective_rod_length = a0 / (density_here ** (1.0 / max(dim, 1))) - ideal_local_volume = _sphere_volume(0.5 * effective_rod_length, dim) - angle = float(angle_sums[point_index]) - point_average_force[point_index] = _average_neighbor_force( - neighbor_force_sums[point_index], - int(neighbor_force_counts[point_index]), - angle, - ) - corrected_volume = _corrected_point_volume( - simplex_measures=simplex_measures, - incident=incident_simplices[point_index], - angle=angle, - dim=dim, - state=point_state, - has_multiple_immobile_neighbors=_has_multiple_immobile_neighbors( - point_index, - states, - neighbor_map, - ), - ideal_local_volume=ideal_local_volume, - ) - point_density[point_index] = ideal_local_volume / max(corrected_volume, DENSITY_EPSILON) - point_effective_force[point_index] = _point_effective_force( - total_force=total_forces[point_index], - state=point_state, - geometry=geometry, - point=point, - is_boundary=bool(boundary_mask[point_index]), - density_here=density_here, - a0=a0, - dim=dim, - ) - - return _make_force_summary( - total_forces, - neighbor_map, - simplices, - point_density, - point_average_force, - point_effective_force, - ) - - -def _empty_force_arrays(point_count: int) -> tuple[FloatArray, FloatArray, FloatArray]: - """Allocate the per-point summary arrays used by ``_finalize_force_summary``.""" - - return ( - np.ones(point_count, dtype=float), - np.zeros(point_count, dtype=float), - np.zeros(point_count, dtype=float), - ) - - -def _make_force_summary( - total_forces: FloatArray, - neighbor_map: list[list[int]], - simplices: np.ndarray, - point_density: FloatArray, - point_average_force: FloatArray, - point_effective_force: FloatArray, -) -> ForceSummary: - """Construct a ``ForceSummary`` from the completed per-point arrays.""" - - return ForceSummary( - total_forces=total_forces, - neighbor_map=neighbor_map, - simplices=simplices, - point_density=point_density, - point_average_force=point_average_force, - point_effective_force=point_effective_force, - max_effective_force=float(np.max(point_effective_force, initial=0.0)), - ) - - -def _average_neighbor_force(force_sum: float, force_count: int, angle: float) -> float: - """Return the mean absolute neighbor-force magnitude for a point.""" - - if angle <= DENSITY_EPSILON: - return 1.0e4 - if force_count <= 0: - return 0.0 - return force_sum / float(force_count) - - -def _has_multiple_immobile_neighbors( - point_index: int, - states: np.ndarray, - neighbor_map: list[list[int]], -) -> bool: - """Return whether a boundary point is trapped between multiple immobile nodes.""" - - if int(states[point_index]) != STATE_BOUNDARY: - return False - immobile_neighbors = sum( - 1 - for neighbor_index in neighbor_map[point_index] - if int(states[neighbor_index]) in (STATE_FIXED, STATE_SIMPLE) - ) - return immobile_neighbors > 1 - - -def _corrected_point_volume( - *, - simplex_measures: FloatArray, - incident: list[int], - angle: float, - dim: int, - state: int, - has_multiple_immobile_neighbors: bool, - ideal_local_volume: float, -) -> float: - """Return the local corrected control volume for one point.""" - - if has_multiple_immobile_neighbors or angle <= DENSITY_EPSILON: - return 1.0e-4 * ideal_local_volume - - if not incident: - return 1.0e-4 * ideal_local_volume - - incident_measures = simplex_measures[np.asarray(incident, dtype=int)] - if len(incident) == 1: - local_volume = float(incident_measures[0]) / float(max(dim, 1)) - else: - local_volume = float(np.sum(incident_measures)) / float(dim + 1) - - correction = _voronoi_angle_correction(dim, angle, len(incident)) - corrected_volume = local_volume * correction - if state == STATE_BOUNDARY: - corrected_volume *= 1.2 - return corrected_volume - - -def _voronoi_angle_correction(dim: int, angle: float, incident_count: int) -> float: - """Return the legacy angular correction for Voronoi control volumes.""" - - if dim == 1: - return 1.5 - if dim == 2: - return 2.0 * math.pi / angle - if dim == 3: - return 4.0 * math.pi / angle - return math.factorial(dim + 1) / float(max(incident_count, 1)) - - -def _point_effective_force( - *, - total_force: FloatArray, - state: int, - geometry: FemGeometry, - point: FloatArray, - is_boundary: bool, - density_here: float, - a0: float, - dim: int, -) -> float: - """Return the normalized effective force magnitude for one point.""" - - effective_force = np.asarray(total_force, dtype=float) - if state == STATE_BOUNDARY or is_boundary: - effective_force = _project_force_to_tangent(geometry, point, effective_force) - return ( - float(np.linalg.norm(effective_force)) - * (density_here ** (1.0 / max(dim, 1))) - / max(a0, DENSITY_EPSILON) - ) - - -def compute_forces( - points: FloatArray, - states: np.ndarray, - geometry: FemGeometry, - a0: float, - params: dict[str, Any], - step: int, - simplices: np.ndarray | None = None, -) -> ForceSummary: - """Compute neighbor, shape, volume, and irrelevant-element forces.""" - - point_count = len(points) - dim = geometry.dim - if simplices is None: - simplices = _triangulate_points(points, dim, states) - neighbor_map = _build_neighbor_map(point_count, simplices) - config = _extract_force_parameters(params, step) - point_densities = np.asarray([geometry.density_at(point) for point in points], dtype=float) - - total_forces, neighbor_force_sums, neighbor_force_counts = _compute_neighbor_forces( - points, - states, - simplices, - neighbor_map, - point_densities, - dim, - a0, - config, - ) - simplex_measures, incident_simplices, angle_sums = _simplex_incidence_data(points, simplices, dim) - _compute_simplex_forces( - total_forces, - points, - states, - geometry, - simplices, - point_densities, - simplex_measures, - angle_sums, - dim, - a0, - config, - ) - return _finalize_force_summary( - total_forces=total_forces, - neighbor_map=neighbor_map, - simplices=simplices, - point_densities=point_densities, - neighbor_force_sums=neighbor_force_sums, - neighbor_force_counts=neighbor_force_counts, - simplex_measures=simplex_measures, - incident_simplices=incident_simplices, - angle_sums=angle_sums, - points=points, - states=states, - geometry=geometry, - a0=a0, - dim=dim, - ) diff --git a/src/nmesh/mesher/relaxation/forces/__init__.py b/src/nmesh/mesher/relaxation/forces/__init__.py new file mode 100644 index 0000000..804d45c --- /dev/null +++ b/src/nmesh/mesher/relaxation/forces/__init__.py @@ -0,0 +1,96 @@ +"""Compatibility facade and orchestration for relaxation meshing forces.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + +from .._types import FloatArray +from ..geometry import FemGeometry +from ..topology import _triangulate_points +from .neighbors import ( + _build_neighbor_map, + _compute_neighbor_forces, +) +from .simplex import ( + _classify_relevant_simplices, + _compute_simplex_forces, + _simplex_incidence_data, +) +from .summary import _finalize_force_summary +from .types import ( + ForceParameters, + ForceSummary, + _extract_force_parameters, +) + +__all__ = [ + "ForceParameters", + "ForceSummary", + "_classify_relevant_simplices", + "_extract_force_parameters", + "compute_forces", +] + + +def compute_forces( + points: FloatArray, + states: np.ndarray, + geometry: FemGeometry, + a0: float, + params: dict[str, Any], + step: int, + simplices: np.ndarray | None = None, +) -> ForceSummary: + """Compute neighbor, shape, volume, and irrelevant-element forces.""" + point_count = len(points) + dim = geometry.dim + if simplices is None: + simplices = _triangulate_points(points, dim, states) + neighbor_map = _build_neighbor_map(point_count, simplices) + config = _extract_force_parameters(params, step) + point_densities = np.asarray([geometry.density_at(point) for point in points], dtype=float) + + total_forces, neighbor_force_sums, neighbor_force_counts = _compute_neighbor_forces( + points, + states, + simplices, + neighbor_map, + point_densities, + dim, + a0, + config, + ) + simplex_measures, incident_simplices, angle_sums = _simplex_incidence_data( + points, simplices, dim + ) + _compute_simplex_forces( + total_forces, + points, + states, + geometry, + simplices, + point_densities, + simplex_measures, + angle_sums, + dim, + a0, + config, + ) + return _finalize_force_summary( + total_forces=total_forces, + neighbor_map=neighbor_map, + simplices=simplices, + point_densities=point_densities, + neighbor_force_sums=neighbor_force_sums, + neighbor_force_counts=neighbor_force_counts, + simplex_measures=simplex_measures, + incident_simplices=incident_simplices, + angle_sums=angle_sums, + points=points, + states=states, + geometry=geometry, + a0=a0, + dim=dim, + ) diff --git a/src/nmesh/mesher/relaxation/jit.py b/src/nmesh/mesher/relaxation/forces/jit.py similarity index 95% rename from src/nmesh/mesher/relaxation/jit.py rename to src/nmesh/mesher/relaxation/forces/jit.py index cb0f202..e8ce6f7 100644 --- a/src/nmesh/mesher/relaxation/jit.py +++ b/src/nmesh/mesher/relaxation/forces/jit.py @@ -7,8 +7,8 @@ import numpy as np from numba import njit -from ...utils.constants import MIN_DIVISION_MAGNITUDE -from ._constants import DENSITY_EPSILON, STATE_BOUNDARY, STATE_FIXED, STATE_MOBILE, STATE_SIMPLE +from ....utils.constants import MIN_DIVISION_MAGNITUDE +from .._constants import DENSITY_EPSILON, STATE_BOUNDARY, STATE_FIXED, STATE_MOBILE, STATE_SIMPLE @njit diff --git a/src/nmesh/mesher/relaxation/forces/neighbors.py b/src/nmesh/mesher/relaxation/forces/neighbors.py new file mode 100644 index 0000000..24273d7 --- /dev/null +++ b/src/nmesh/mesher/relaxation/forces/neighbors.py @@ -0,0 +1,186 @@ +"""Neighbor-pair force calculations for the relaxation mesher.""" + +from __future__ import annotations + +from itertools import combinations +from typing import Any + +import numpy as np + +from ...meshing_parameters import default_boundary_node_force_fun, default_relaxation_force_fun +from .._constants import DENSITY_EPSILON, STATE_BOUNDARY, STATE_FIXED, STATE_MOBILE, STATE_SIMPLE +from .._types import FloatArray +from .jit import accumulate_neighbor_forces_default +from .types import ForceParameters + + +def _build_neighbor_pairs(simplices: np.ndarray) -> np.ndarray: + """Return sorted unique point-pair edges induced by the simplices.""" + if len(simplices) == 0: + return np.empty((0, 2), dtype=np.int64) + + pairs: set[tuple[int, int]] = set() + for simplex in simplices: + for left, right in combinations(simplex.tolist(), 2): + i = int(left) + j = int(right) + pairs.add((i, j) if i <= j else (j, i)) + return np.asarray(sorted(pairs), dtype=np.int64) + + +def _is_dynamic_state(state: int) -> bool: + """Return whether the state participates in relaxation movement.""" + return state in (STATE_MOBILE, STATE_BOUNDARY) + + +def _is_boundary_interaction(state_a: int, state_b: int) -> bool: + """Return whether a neighbor interaction should use the boundary law.""" + return state_a in (STATE_FIXED, STATE_BOUNDARY, STATE_SIMPLE) or state_b in ( + STATE_FIXED, + STATE_BOUNDARY, + STATE_SIMPLE, + ) + + +def _neighbor_forces_python( + points: FloatArray, + states: np.ndarray, + point_densities: FloatArray, + neighbor_map: list[list[int]], + dim: int, + a0: float, + neigh_force_scale: float, + force_fun: Any, + boundary_force_fun: Any, +) -> tuple[FloatArray, FloatArray, np.ndarray]: + """Fallback neighbor-force path for custom Python force callbacks.""" + total_forces = np.zeros((len(points), dim), dtype=float) + neighbor_force_sums = np.zeros(len(points), dtype=float) + neighbor_force_counts = np.zeros(len(points), dtype=int) + + for left, neighbors in enumerate(neighbor_map): + point_left = points[left] + state_left = int(states[left]) + for right in neighbors: + if right <= left: + continue + contribution, scalar_force = _apply_pairwise_force( + point_left=point_left, + point_right=points[right], + state_left=state_left, + state_right=int(states[right]), + density_left=float(point_densities[left]), + density_right=float(point_densities[right]), + dim=dim, + a0=a0, + neigh_force_scale=neigh_force_scale, + force_fun=force_fun, + boundary_force_fun=boundary_force_fun, + ) + if scalar_force is None: + continue + neighbor_force_sums[left] += abs(scalar_force) + neighbor_force_sums[right] += abs(scalar_force) + neighbor_force_counts[left] += 1 + neighbor_force_counts[right] += 1 + if contribution is None: + continue + total_forces[left] += contribution + total_forces[right] -= contribution + + return total_forces, neighbor_force_sums, neighbor_force_counts + + +def _apply_pairwise_force( + *, + point_left: FloatArray, + point_right: FloatArray, + state_left: int, + state_right: int, + density_left: float, + density_right: float, + dim: int, + a0: float, + neigh_force_scale: float, + force_fun: Any, + boundary_force_fun: Any, +) -> tuple[FloatArray | None, float | None]: + """Compute the pairwise neighbor contribution for one point pair.""" + if not _is_dynamic_state(state_left) and not _is_dynamic_state(state_right): + return None, None + + delta = point_right - point_left + true_distance = float(np.linalg.norm(delta)) + if true_distance <= DENSITY_EPSILON: + return None, None + + avg_density = 0.5 * (density_left + density_right) + inv_length_scale = (avg_density ** (1.0 / max(dim, 1))) / max(a0, DENSITY_EPSILON) + reduced_distance = true_distance * inv_length_scale + scalar_force = float( + (boundary_force_fun if _is_boundary_interaction(state_left, state_right) else force_fun)( + reduced_distance + ) + ) + scaled_force = neigh_force_scale * abs(scalar_force) + if scalar_force == 0.0: + return None, scaled_force + return neigh_force_scale * (-scalar_force) * delta, scaled_force + + +def _build_neighbor_map(point_count: int, simplices: np.ndarray) -> list[list[int]]: + """Build undirected point adjacency from simplices.""" + neighbors: list[set[int]] = [set() for _ in range(point_count)] + for simplex in simplices: + for left, right in combinations(simplex.tolist(), 2): + i = int(left) + j = int(right) + neighbors[i].add(j) + neighbors[j].add(i) + return [sorted(group) for group in neighbors] + + +def _compute_neighbor_forces( + points: FloatArray, + states: np.ndarray, + simplices: np.ndarray, + neighbor_map: list[list[int]], + point_densities: FloatArray, + dim: int, + a0: float, + config: ForceParameters, +) -> tuple[FloatArray, FloatArray, np.ndarray]: + """Compute neighbor-force contributions through the JIT or Python path.""" + point_count = len(points) + if len(simplices) == 0: + return ( + np.zeros((point_count, dim), dtype=float), + np.zeros(point_count, dtype=float), + np.zeros(point_count, dtype=int), + ) + + if ( + config.force_fun is default_relaxation_force_fun + and config.boundary_force_fun is default_boundary_node_force_fun + ): + neighbor_pairs = _build_neighbor_pairs(simplices) + return accumulate_neighbor_forces_default( + np.asarray(points, dtype=np.float64), + np.asarray(states, dtype=np.int64), + point_densities.astype(np.float64, copy=False), + neighbor_pairs, + float(a0), + config.neigh_force_scale, + ) + + return _neighbor_forces_python( + points, + states, + point_densities, + neighbor_map, + dim, + a0, + config.neigh_force_scale, + config.force_fun, + config.boundary_force_fun, + ) diff --git a/src/nmesh/mesher/relaxation/forces/simplex.py b/src/nmesh/mesher/relaxation/forces/simplex.py new file mode 100644 index 0000000..08a330d --- /dev/null +++ b/src/nmesh/mesher/relaxation/forces/simplex.py @@ -0,0 +1,302 @@ +"""Simplex shape, volume, and relevance forces for the relaxation mesher.""" + +from __future__ import annotations + +import math + +import numpy as np + +from .._constants import BOUNDARY_FUZZ, DENSITY_EPSILON, STATE_MOBILE +from .._types import FloatArray +from ..geometry import FemGeometry +from ..topology import ( + _boundary_state_mask, + _classify_simplices_with_probes, + _simplex_measures, + _simplex_volume_order_ratio, +) +from .types import ForceParameters, _corner_force_threshold, _regular_simplex_volume + + +def _vertex_angle(points: FloatArray, simplex: np.ndarray, local_index: int, dim: int) -> float: + """Return the angle or solid angle covered by a simplex at one vertex.""" + vertex = points[int(simplex[local_index])] + others: list[FloatArray] = [ + np.asarray(points[int(simplex[index])] - vertex, dtype=float) + for index in range(len(simplex)) + if index != local_index + ] + + if dim == 1: + return math.pi + + if dim == 2: + first = others[0] + second = others[1] + denom = math.sqrt(float(np.sum(first * first))) * math.sqrt(float(np.sum(second * second))) + if denom <= DENSITY_EPSILON: + return 0.0 + cosine = min(1.0, max(-1.0, float(np.sum(first * second)) / denom)) + return float(math.acos(cosine)) + + if dim == 3: + a, b, c = others + numer = abs(float(np.sum(a * np.cross(b, c)))) + norm_a = math.sqrt(float(np.sum(a * a))) + norm_b = math.sqrt(float(np.sum(b * b))) + norm_c = math.sqrt(float(np.sum(c * c))) + denom = ( + norm_a * norm_b * norm_c + + float(np.sum(a * b)) * norm_c + + float(np.sum(a * c)) * norm_b + + float(np.sum(b * c)) * norm_a + ) + if numer <= DENSITY_EPSILON and denom <= DENSITY_EPSILON: + return 0.0 + return float(2.0 * math.atan2(numer, max(denom, DENSITY_EPSILON))) + + return 1.0 + + +def _shape_force_matrix(vertices: FloatArray, dim: int) -> FloatArray: + """Build the covariance-derived shape force matrix for one simplex.""" + covariance = np.asarray(np.transpose(vertices) @ vertices, dtype=float) + covariance /= max(float(len(vertices) - 1), 1.0) + + eigenvalues, eigenvectors = np.linalg.eigh(covariance) + eigenvectors = np.asarray(eigenvectors, dtype=float) + safe_eigenvalues = np.maximum( + np.asarray(eigenvalues, dtype=float), + DENSITY_EPSILON, + ) + product = float(np.prod(safe_eigenvalues)) + if product <= DENSITY_EPSILON: + return np.zeros((dim, dim), dtype=float) + + scaled = safe_eigenvalues * ((1.0 / product) ** (1.0 / max(dim, 1))) + diagonal = -np.log(np.maximum(scaled, DENSITY_EPSILON)) + transformed = np.zeros((dim, dim), dtype=float) + transformed[np.arange(dim), np.arange(dim)] = diagonal + return np.asarray(eigenvectors @ transformed @ np.transpose(eigenvectors), dtype=float) + + +def _project_force_to_tangent( + geometry: FemGeometry, + point: FloatArray, + force: FloatArray, +) -> FloatArray: + """Remove the normal component of a force at a boundary point.""" + normal = geometry.boundary_normal(point) + normal_norm = float(np.linalg.norm(normal)) + if normal_norm <= BOUNDARY_FUZZ: + return np.asarray(force, dtype=float) + unit_normal = normal / normal_norm + return np.asarray(force, dtype=float) - float(np.dot(force, unit_normal)) * unit_normal + + +def _simplex_force_contribution( + simplex_points: FloatArray, + dim: int, + density_here: float, + a0: float, + relaxation_weight: float, + shape_force_scale: float, + volume_force_scale: float, + sliver_correction: float, + volume: float, +) -> FloatArray: + """Compute the shape and volume force contribution for one simplex.""" + center = np.mean(simplex_points, axis=0) + vertices = simplex_points - center + ideal_edge_length = a0 / (density_here ** (1.0 / max(dim, 1))) + ideal_volume = _regular_simplex_volume(ideal_edge_length, dim) + return _compute_volume_forces( + vertices, + relaxation_weight, + volume_force_scale, + volume, + ideal_volume, + ) + _compute_shape_forces( + vertices=vertices, + dim=dim, + relaxation_weight=relaxation_weight, + shape_force_scale=shape_force_scale, + sliver_correction=sliver_correction, + volume=volume, + ideal_volume=ideal_volume, + ) + + +def _compute_volume_forces( + vertices: FloatArray, + relaxation_weight: float, + volume_force_scale: float, + volume: float, + ideal_volume: float, +) -> FloatArray: + """Return the isotropic volume-restoring forces for one simplex.""" + forces = np.zeros_like(vertices) + if volume_force_scale <= 0.0 or volume <= DENSITY_EPSILON or ideal_volume <= DENSITY_EPSILON: + return forces + volume_factor = volume_force_scale * math.log(ideal_volume / volume) + return forces + relaxation_weight * volume_factor * vertices + + +def _compute_shape_forces( + *, + vertices: FloatArray, + dim: int, + relaxation_weight: float, + shape_force_scale: float, + sliver_correction: float, + volume: float, + ideal_volume: float, +) -> FloatArray: + """Return the covariance-derived shape-correction forces for one simplex.""" + forces = np.zeros_like(vertices) + if shape_force_scale <= 0.0: + return forces + + shape_matrix = _shape_force_matrix(vertices, dim) + if not np.any(shape_matrix): + return forces + + vol_correction = _shape_volume_correction(volume, ideal_volume, dim) + for index, vertex in enumerate(vertices): + norm = float(np.linalg.norm(vertex)) + if norm <= DENSITY_EPSILON: + continue + raw_force = shape_matrix @ vertex + normal = vertex / norm + projection = float(np.dot(raw_force, normal)) + angular = raw_force - projection * normal + forces[index] += relaxation_weight * vol_correction * shape_force_scale * angular + if projection > 0.0 and sliver_correction > 0.0: + longitudinal = raw_force - angular + forces[index] += ( + shape_force_scale + * sliver_correction + * relaxation_weight + * max(vol_correction - 1.0, 0.0) + * longitudinal + ) + return forces + + +def _shape_volume_correction(volume: float, ideal_volume: float, dim: int) -> float: + """Return the volume-dependent multiplier applied to shape forces.""" + if volume <= DENSITY_EPSILON or ideal_volume <= DENSITY_EPSILON: + return 0.0 + offset = 0.0 if dim <= 2 else 1.0 + return max(1.0, offset + math.log(ideal_volume / volume)) + + +def _simplex_incidence_data( + points: FloatArray, + simplices: np.ndarray, + dim: int, +) -> tuple[FloatArray, list[list[int]], FloatArray]: + """Collect simplex measures plus per-point incident simplex and angle data.""" + simplex_measures = _simplex_measures(points, simplices, dim) + incident_simplices: list[list[int]] = [[] for _ in range(len(points))] + angle_sums = np.zeros(len(points), dtype=float) + for simplex_index, simplex in enumerate(simplices): + for local_index, point_index in enumerate(simplex.tolist()): + point_id = int(point_index) + incident_simplices[point_id].append(simplex_index) + angle_sums[point_id] += _vertex_angle(points, simplex, local_index, dim) + return simplex_measures, incident_simplices, angle_sums + + +def _compute_simplex_forces( + total_forces: FloatArray, + points: FloatArray, + states: np.ndarray, + geometry: FemGeometry, + simplices: np.ndarray, + point_densities: FloatArray, + simplex_measures: FloatArray, + angle_sums: FloatArray, + dim: int, + a0: float, + config: ForceParameters, +) -> None: + """Accumulate simplex shape, volume, and irrelevant-element forces.""" + del point_densities + if len(simplices) == 0: + return + if ( + config.shape_force_scale <= 0.0 + and config.volume_force_scale <= 0.0 + and config.irrel_force_scale <= 0.0 + ): + return + + relevant_simplices = _classify_relevant_simplices( + points, + states, + geometry, + simplices, + simplex_measures, + dim, + config, + ) + suppress_corner_forces = angle_sums < _corner_force_threshold(dim) + for simplex_index, simplex in enumerate(simplices): + simplex_points = points[simplex] + simplex_force = np.zeros_like(simplex_points) + volume = float(simplex_measures[simplex_index]) + if relevant_simplices[simplex_index]: + density_here = geometry.density_at(np.mean(simplex_points, axis=0)) + simplex_force = _simplex_force_contribution( + simplex_points=simplex_points, + dim=dim, + density_here=density_here, + a0=a0, + relaxation_weight=config.relaxation_weight, + shape_force_scale=config.shape_force_scale, + volume_force_scale=config.volume_force_scale, + sliver_correction=config.sliver_correction, + volume=volume, + ) + elif config.irrel_force_scale > 0.0: + center = np.mean(simplex_points, axis=0) + for local_index, point_index in enumerate(simplex.tolist()): + if int(states[point_index]) != STATE_MOBILE: + continue + simplex_force[local_index] = config.irrel_force_scale * ( + center - simplex_points[local_index] + ) + + for local_index, point_index in enumerate(simplex.tolist()): + point_id = int(point_index) + if suppress_corner_forces[point_id]: + continue + total_forces[point_id] += simplex_force[local_index] + + +def _classify_relevant_simplices( + points: FloatArray, + states: np.ndarray, + geometry: FemGeometry, + simplices: np.ndarray, + simplex_measures: FloatArray, + dim: int, + config: ForceParameters, +) -> np.ndarray: + """Return the legacy relevant-simplex mask for force calculation.""" + if len(simplices) == 0: + return np.empty(0, dtype=bool) + + region_ids, probe_consistent = _classify_simplices_with_probes( + points, + simplices, + geometry, + ) + boundary_mask = _boundary_state_mask(points, states, geometry) + all_boundary = np.all(boundary_mask[simplices], axis=1) + boundary_ratio = _simplex_volume_order_ratio(points, simplices, dim) + flat_boundary = all_boundary & (boundary_ratio < config.smallest_allowed_volume_ratio) + return ( + (region_ids >= 0) & probe_consistent & (simplex_measures > BOUNDARY_FUZZ) & ~flat_boundary + ) diff --git a/src/nmesh/mesher/relaxation/forces/summary.py b/src/nmesh/mesher/relaxation/forces/summary.py new file mode 100644 index 0000000..3d6c58b --- /dev/null +++ b/src/nmesh/mesher/relaxation/forces/summary.py @@ -0,0 +1,207 @@ +"""Per-point force metrics for the relaxation mesher.""" + +from __future__ import annotations + +import math + +import numpy as np + +from .._constants import BOUNDARY_FUZZ, DENSITY_EPSILON, STATE_BOUNDARY, STATE_FIXED, STATE_SIMPLE +from .._types import FloatArray +from ..geometry import FemGeometry +from .simplex import _project_force_to_tangent +from .types import ForceSummary, _sphere_volume + + +def _finalize_force_summary( + total_forces: FloatArray, + neighbor_map: list[list[int]], + simplices: np.ndarray, + point_densities: FloatArray, + neighbor_force_sums: FloatArray, + neighbor_force_counts: np.ndarray, + simplex_measures: FloatArray, + incident_simplices: list[list[int]], + angle_sums: FloatArray, + points: FloatArray, + states: np.ndarray, + geometry: FemGeometry, + a0: float, + dim: int, +) -> ForceSummary: + """Assemble the per-point density and effective-force metrics.""" + point_count = len(points) + point_density, point_average_force, point_effective_force = _empty_force_arrays(point_count) + + if point_count == 0: + return _make_force_summary( + total_forces, + neighbor_map, + simplices, + point_density, + point_average_force, + point_effective_force, + ) + + boundary_mask = geometry.boundary_mask(points, tolerance=max(0.05 * a0, BOUNDARY_FUZZ)) + + for point_index, point in enumerate(points): + point_state = int(states[point_index]) + density_here = float(point_densities[point_index]) + effective_rod_length = a0 / (density_here ** (1.0 / max(dim, 1))) + ideal_local_volume = _sphere_volume(0.5 * effective_rod_length, dim) + angle = float(angle_sums[point_index]) + point_average_force[point_index] = _average_neighbor_force( + neighbor_force_sums[point_index], + int(neighbor_force_counts[point_index]), + angle, + ) + corrected_volume = _corrected_point_volume( + simplex_measures=simplex_measures, + incident=incident_simplices[point_index], + angle=angle, + dim=dim, + state=point_state, + has_multiple_immobile_neighbors=_has_multiple_immobile_neighbors( + point_index, + states, + neighbor_map, + ), + ideal_local_volume=ideal_local_volume, + ) + point_density[point_index] = ideal_local_volume / max(corrected_volume, DENSITY_EPSILON) + point_effective_force[point_index] = _point_effective_force( + total_force=total_forces[point_index], + state=point_state, + geometry=geometry, + point=point, + is_boundary=bool(boundary_mask[point_index]), + density_here=density_here, + a0=a0, + dim=dim, + ) + + return _make_force_summary( + total_forces, + neighbor_map, + simplices, + point_density, + point_average_force, + point_effective_force, + ) + + +def _empty_force_arrays(point_count: int) -> tuple[FloatArray, FloatArray, FloatArray]: + """Allocate the per-point summary arrays used by ``_finalize_force_summary``.""" + return ( + np.ones(point_count, dtype=float), + np.zeros(point_count, dtype=float), + np.zeros(point_count, dtype=float), + ) + + +def _make_force_summary( + total_forces: FloatArray, + neighbor_map: list[list[int]], + simplices: np.ndarray, + point_density: FloatArray, + point_average_force: FloatArray, + point_effective_force: FloatArray, +) -> ForceSummary: + """Construct a ``ForceSummary`` from the completed per-point arrays.""" + return ForceSummary( + total_forces=total_forces, + neighbor_map=neighbor_map, + simplices=simplices, + point_density=point_density, + point_average_force=point_average_force, + point_effective_force=point_effective_force, + max_effective_force=float(np.max(point_effective_force, initial=0.0)), + ) + + +def _average_neighbor_force(force_sum: float, force_count: int, angle: float) -> float: + """Return the mean absolute neighbor-force magnitude for a point.""" + if angle <= DENSITY_EPSILON: + return 1.0e4 + if force_count <= 0: + return 0.0 + return force_sum / float(force_count) + + +def _has_multiple_immobile_neighbors( + point_index: int, + states: np.ndarray, + neighbor_map: list[list[int]], +) -> bool: + """Return whether a boundary point is trapped between multiple immobile nodes.""" + if int(states[point_index]) != STATE_BOUNDARY: + return False + immobile_neighbors = sum( + 1 + for neighbor_index in neighbor_map[point_index] + if int(states[neighbor_index]) in (STATE_FIXED, STATE_SIMPLE) + ) + return immobile_neighbors > 1 + + +def _corrected_point_volume( + *, + simplex_measures: FloatArray, + incident: list[int], + angle: float, + dim: int, + state: int, + has_multiple_immobile_neighbors: bool, + ideal_local_volume: float, +) -> float: + """Return the local corrected control volume for one point.""" + if has_multiple_immobile_neighbors or angle <= DENSITY_EPSILON: + return 1.0e-4 * ideal_local_volume + if not incident: + return 1.0e-4 * ideal_local_volume + + incident_measures = simplex_measures[np.asarray(incident, dtype=int)] + if len(incident) == 1: + local_volume = float(incident_measures[0]) / float(max(dim, 1)) + else: + local_volume = float(np.sum(incident_measures)) / float(dim + 1) + + correction = _voronoi_angle_correction(dim, angle, len(incident)) + corrected_volume = local_volume * correction + if state == STATE_BOUNDARY: + corrected_volume *= 1.2 + return corrected_volume + + +def _voronoi_angle_correction(dim: int, angle: float, incident_count: int) -> float: + """Return the legacy angular correction for Voronoi control volumes.""" + if dim == 1: + return 1.5 + if dim == 2: + return 2.0 * math.pi / angle + if dim == 3: + return 4.0 * math.pi / angle + return math.factorial(dim + 1) / float(max(incident_count, 1)) + + +def _point_effective_force( + *, + total_force: FloatArray, + state: int, + geometry: FemGeometry, + point: FloatArray, + is_boundary: bool, + density_here: float, + a0: float, + dim: int, +) -> float: + """Return the normalized effective force magnitude for one point.""" + effective_force = np.asarray(total_force, dtype=float) + if state == STATE_BOUNDARY or is_boundary: + effective_force = _project_force_to_tangent(geometry, point, effective_force) + return ( + float(np.linalg.norm(effective_force)) + * (density_here ** (1.0 / max(dim, 1))) + / max(a0, DENSITY_EPSILON) + ) diff --git a/src/nmesh/mesher/relaxation/forces/types.py b/src/nmesh/mesher/relaxation/forces/types.py new file mode 100644 index 0000000..f67be92 --- /dev/null +++ b/src/nmesh/mesher/relaxation/forces/types.py @@ -0,0 +1,92 @@ +"""Shared types, configuration, and scalar helpers for meshing forces.""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from typing import Any + +import numpy as np + +from ...meshing_parameters import ( + default_boundary_node_force_fun, + default_initial_relaxation_weight, + default_relaxation_force_fun, +) +from .._types import FloatArray + + +@dataclass(frozen=True, slots=True) +class ForceSummary: + """Container for the per-step force calculation results.""" + + total_forces: FloatArray + neighbor_map: list[list[int]] + simplices: np.ndarray + point_density: FloatArray + point_average_force: FloatArray + point_effective_force: FloatArray + max_effective_force: float + + +@dataclass(frozen=True, slots=True) +class ForceParameters: + """Resolved controller parameters for one relaxation-force evaluation.""" + + shape_force_scale: float + volume_force_scale: float + neigh_force_scale: float + irrel_force_scale: float + sliver_correction: float + smallest_allowed_volume_ratio: float + relaxation_weight: float + force_fun: Any + boundary_force_fun: Any + + +def _corner_force_threshold(dim: int) -> float: + """Return the solid-angle threshold below which corner suppression applies.""" + if dim == 2: + return 0.75 * math.pi + if dim == 3: + return 0.85 * math.pi + return -math.inf + + +def _regular_simplex_volume(edge_length: float, dim: int) -> float: + """Return the volume of a regular simplex with the supplied edge length.""" + if dim <= 0: + return edge_length + numerator = edge_length**dim * math.sqrt(dim + 1.0) + denominator = math.factorial(dim) * math.sqrt(2.0**dim) + return numerator / denominator + + +def _sphere_volume(radius: float, dim: int) -> float: + """Return the d-dimensional volume of a sphere.""" + return (math.pi ** (0.5 * dim) / math.gamma(0.5 * dim + 1.0)) * (radius**dim) + + +def _extract_force_parameters(params: dict[str, Any], step: int) -> ForceParameters: + """Resolve the mesher controller parameters used by ``compute_forces``.""" + settling_steps = int(params.get("controller_initial_settling_steps", 100)) + relaxation_weight_fun = params.get( + "initial_relaxation_weight_fun", + default_initial_relaxation_weight, + ) + return ForceParameters( + shape_force_scale=float(params.get("controller_shape_force_scale", 0.1)), + volume_force_scale=float(params.get("controller_volume_force_scale", 0.0)), + neigh_force_scale=float(params.get("controller_neigh_force_scale", 1.0)), + irrel_force_scale=float(params.get("controller_irrel_elem_force_scale", 1.0)), + sliver_correction=float(params.get("controller_sliver_correction", 1.0)), + smallest_allowed_volume_ratio=float( + params.get("controller_smallest_allowed_volume_ratio", 1.0) + ), + relaxation_weight=float(relaxation_weight_fun(step, settling_steps, 0.0, 1.0)), + force_fun=params.get("relaxation_force_fun", default_relaxation_force_fun), + boundary_force_fun=params.get( + "boundary_node_force_fun", + default_boundary_node_force_fun, + ), + ) diff --git a/src/nmesh/mesher/relaxation/geometry.py b/src/nmesh/mesher/relaxation/geometry.py deleted file mode 100644 index 4e47c46..0000000 --- a/src/nmesh/mesher/relaxation/geometry.py +++ /dev/null @@ -1,384 +0,0 @@ -"""Geometry modeling helpers for the relaxation meshing pipeline.""" - -from __future__ import annotations - -import math -from dataclasses import dataclass -from typing import Any - -import numpy as np - -from ...backend import RawMesh -from ...geometry.primitives import Body -from ._constants import BOUNDARY_FUZZ, DENSITY_EPSILON -from ._types import DensityFunction, FloatArray, RegionFunction -from .density import _compile_density_function - - -@dataclass(frozen=True, slots=True) -class FemGeometry: - """Geometry bundle used by the Python meshing engine.""" - - dim: int - bbox_min: FloatArray - bbox_max: FloatArray - density_fun: DensityFunction - region_ids: tuple[int, ...] - region_functions: tuple[RegionFunction, ...] - region_bodies: tuple[Body | None, ...] - piece_hints: tuple[FloatArray, ...] - mesh_exterior: bool - - def points_in_bbox(self, points: FloatArray) -> np.ndarray: - """Return a mask showing which points lie inside the bounding box.""" - - return np.all( - (points >= self.bbox_min - BOUNDARY_FUZZ) - & (points <= self.bbox_max + BOUNDARY_FUZZ), - axis=1, - ) - - def classify_points(self, points: FloatArray) -> np.ndarray: - """Return the first matching region id for each point, or -1 outside.""" - - if points.size == 0: - return np.empty(0, dtype=int) - - coords = np.asarray(points, dtype=float) - assigned = np.full(len(coords), -1, dtype=int) - bbox_mask = self.points_in_bbox(coords) - - for region_id, region_fun in zip(self.region_ids, self.region_functions): - mask = bbox_mask & region_fun(coords) & (assigned < 0) - assigned[mask] = region_id - - return assigned - - def density_at(self, point: FloatArray) -> float: - """Evaluate the density function with a small positive lower bound.""" - - return max(float(self.density_fun(point)), DENSITY_EPSILON) - - def boundary_mask(self, points: FloatArray, tolerance: float) -> np.ndarray: - """Return a mask for points that lie close to a region or box boundary.""" - - if points.size == 0: - return np.empty(0, dtype=bool) - - coords = np.asarray(points, dtype=float) - mask = np.zeros(len(coords), dtype=bool) - for body in self.region_bodies: - if body is None: - continue - mask |= np.abs(np.asarray(body.evaluate(coords), dtype=float)) <= tolerance - - if self.mesh_exterior: - mask |= np.any( - np.isclose(coords, self.bbox_min, atol=tolerance, rtol=0.0) - | np.isclose(coords, self.bbox_max, atol=tolerance, rtol=0.0), - axis=1, - ) - - return mask & self.points_in_bbox(coords) - - def boundary_distance(self, point: FloatArray) -> float: - """Return the smallest available distance-like scalar to a boundary.""" - - coord = np.asarray(point, dtype=float) - distances: list[float] = [] - for body in self.region_bodies: - if body is None: - continue - distances.append(abs(float(body.evaluate(coord)))) - - if self.mesh_exterior: - distances.extend( - min(abs(float(coord[axis] - self.bbox_min[axis])), abs(float(coord[axis] - self.bbox_max[axis]))) - for axis in range(self.dim) - ) - - if not distances: - return math.inf - return min(distances) - - def boundary_normal(self, point: FloatArray) -> FloatArray: - """Estimate a unit normal for the closest relevant boundary.""" - - coord = np.asarray(point, dtype=float) - best_distance = math.inf - best_normal = np.zeros(self.dim, dtype=float) - extent = max(float(np.max(self.bbox_max - self.bbox_min)), 1.0) - epsilon = max(BOUNDARY_FUZZ * 10.0, extent * 1.0e-6) - - for body in self.region_bodies: - if body is None: - continue - distance = abs(float(body.evaluate(coord))) - if distance > best_distance: - continue - gradient = np.zeros(self.dim, dtype=float) - for axis in range(self.dim): - offset = np.zeros(self.dim, dtype=float) - offset[axis] = epsilon - forward = float(body.evaluate(coord + offset)) - backward = float(body.evaluate(coord - offset)) - gradient[axis] = (forward - backward) / (2.0 * epsilon) - norm = float(np.linalg.norm(gradient)) - if norm <= DENSITY_EPSILON: - continue - best_distance = distance - best_normal = gradient / norm - - if self.mesh_exterior: - for axis in range(self.dim): - dist_min = abs(float(coord[axis] - self.bbox_min[axis])) - if dist_min < best_distance: - best_distance = dist_min - best_normal = np.zeros(self.dim, dtype=float) - best_normal[axis] = 1.0 - dist_max = abs(float(coord[axis] - self.bbox_max[axis])) - if dist_max < best_distance: - best_distance = dist_max - best_normal = np.zeros(self.dim, dtype=float) - best_normal[axis] = -1.0 - - return best_normal - - def boundary_gradient(self, point: FloatArray, body: Body) -> FloatArray: - """Estimate the gradient of one implicit body at a point.""" - - coord = np.asarray(point, dtype=float) - gradient = np.zeros(self.dim, dtype=float) - extent = max(float(np.max(self.bbox_max - self.bbox_min)), 1.0) - epsilon = max(BOUNDARY_FUZZ * 10.0, extent * 1.0e-6) - for axis in range(self.dim): - offset = np.zeros(self.dim, dtype=float) - offset[axis] = epsilon - forward = float(body.evaluate(coord + offset)) - backward = float(body.evaluate(coord - offset)) - gradient[axis] = (forward - backward) / (2.0 * epsilon) - return gradient - - def project_point_to_boundary_from_inside( - self, - point: FloatArray, - *, - acceptable_fuzz: float, - max_steps: int, - ) -> FloatArray: - """Project an interior point onto implicit boundaries using the legacy correction.""" - - coords = np.asarray(point, dtype=float).copy() - bodies = [body for body in self.region_bodies if body is not None] - if not bodies: - return self._project_point_to_box_boundary(coords) - - for _ in range(max(max_steps, 0)): - violated_body: Body | None = None - violated_value = 0.0 - for body in bodies: - value = float(body.evaluate(coords)) - if value > acceptable_fuzz: - violated_body = body - violated_value = value - break - - if violated_body is None: - return coords - - gradient = self.boundary_gradient(coords, violated_body) - gradient_norm_sq = float(np.dot(gradient, gradient)) - scale = 1.0e-6 if gradient_norm_sq <= DENSITY_EPSILON else -violated_value / gradient_norm_sq - coords = coords + scale * gradient - - return coords - - def _project_point_to_box_boundary(self, point: FloatArray) -> FloatArray: - """Project a point to the nearest bounding-box face.""" - - coords = np.asarray(point, dtype=float).copy() - distances_min = np.abs(coords - self.bbox_min) - distances_max = np.abs(coords - self.bbox_max) - min_axis = int(np.argmin(distances_min)) - max_axis = int(np.argmin(distances_max)) - if distances_min[min_axis] <= distances_max[max_axis]: - coords[min_axis] = self.bbox_min[min_axis] - else: - coords[max_axis] = self.bbox_max[max_axis] - return coords - - def project_segment_to_domain( - self, - start: FloatArray, - end: FloatArray, - *, - iterations: int = 18, - ) -> FloatArray: - """Project an outside point back into the domain along a segment.""" - - lower = np.asarray(start, dtype=float).copy() - upper = np.asarray(end, dtype=float).copy() - if self.classify_points(lower.reshape(1, -1))[0] < 0: - return lower - if self.classify_points(upper.reshape(1, -1))[0] >= 0: - return upper - - for _ in range(iterations): - middle = 0.5 * (lower + upper) - if self.classify_points(middle.reshape(1, -1))[0] >= 0: - lower = middle - else: - upper = middle - - return lower - - -def _raw_mesh_points(raw_mesh: RawMesh, dim: int) -> FloatArray: - """Return mesh points as a validated floating-point array.""" - - coords = np.asarray(raw_mesh.points, dtype=float) - if coords.size == 0: - return np.empty((0, dim), dtype=float) - if coords.ndim == 1: - coords = coords.reshape(1, dim) - if coords.shape[1] != dim: - raise ValueError(f"Expected hint mesh points with dimension {dim}, got {coords.shape[1]}") - return coords.astype(float, copy=False) - - -def _dedupe_hint_points(points: FloatArray) -> FloatArray: - """Drop duplicate hint points while preserving first-seen order.""" - - if len(points) == 0: - return points - - keep_indices: list[int] = [] - seen: set[tuple[float, ...]] = set() - for index, point in enumerate(points): - key = tuple(np.round(np.asarray(point, dtype=float), decimals=10).tolist()) - if key in seen: - continue - seen.add(key) - keep_indices.append(index) - return points[np.asarray(keep_indices, dtype=int)] - - -def _process_bodies_and_hints( - bodies: list[Body], - hints: list[list[Any]], - dim: int, -) -> tuple[list[RegionFunction], list[FloatArray], list[int], list[Body | None]]: - """Convert bodies and hint meshes into region predicates and hint blocks.""" - - body_functions: list[RegionFunction] = [] - piece_hints: list[FloatArray] = [] - region_ids: list[int] = [] - region_bodies: list[Body | None] = [] - next_region_id = 1 - - for body in bodies: - body_functions.append( - lambda points, member=body: np.asarray(member.evaluate(points), dtype=float) - >= -BOUNDARY_FUZZ - ) - piece_hints.append(np.empty((0, dim), dtype=float)) - region_ids.append(next_region_id) - region_bodies.append(body) - next_region_id += 1 - - for hint_mesh, hint_body in hints: - hint_points = _raw_mesh_points(hint_mesh, dim) - if len(hint_points) > 0: - mask = np.asarray(hint_body.evaluate(hint_points), dtype=float) >= -BOUNDARY_FUZZ - hint_points = _dedupe_hint_points(hint_points[mask]) - body_functions.append( - lambda points, member=hint_body: np.asarray(member.evaluate(points), dtype=float) - >= -BOUNDARY_FUZZ - ) - piece_hints.append(hint_points) - region_ids.append(next_region_id) - region_bodies.append(hint_body) - next_region_id += 1 - - return body_functions, piece_hints, region_ids, region_bodies - - -def _build_region_lists( - bbox_min: FloatArray, - bbox_max: FloatArray, - body_functions: list[RegionFunction], - region_ids: list[int], - region_bodies: list[Body | None], - *, - mesh_exterior: bool, -) -> tuple[list[RegionFunction], list[int], list[Body | None]]: - """Build the ordered region lists, including the exterior region when requested.""" - - region_functions: list[RegionFunction] = [] - ordered_region_ids: list[int] = [] - ordered_region_bodies: list[Body | None] = [] - - if mesh_exterior: - - def exterior(points: FloatArray) -> np.ndarray: - """Classify points that lie inside the box but outside all bodies.""" - - bbox_mask = np.all( - (points >= bbox_min - BOUNDARY_FUZZ) - & (points <= bbox_max + BOUNDARY_FUZZ), - axis=1, - ) - inside_any = np.zeros(len(points), dtype=bool) - for body_fun in body_functions: - inside_any |= body_fun(points) - return bbox_mask & ~inside_any - - region_functions.append(exterior) - ordered_region_ids.append(0) - ordered_region_bodies.append(None) - - region_functions.extend(body_functions) - ordered_region_ids.extend(region_ids) - ordered_region_bodies.extend(region_bodies) - return region_functions, ordered_region_ids, ordered_region_bodies - - -def fem_geometry_from_bodies( - bounding_box: tuple[FloatArray, FloatArray], - bodies: list[Body], - hints: list[list[Any]], - *, - density: str | DensityFunction | None = None, - mesh_exterior: bool = False, -) -> FemGeometry: - """Construct the geometry bundle consumed by the Python meshing engine.""" - - bbox_min = np.asarray(bounding_box[0], dtype=float) - bbox_max = np.asarray(bounding_box[1], dtype=float) - dim = int(len(bbox_min)) - density_fun = _compile_density_function(density) - body_functions, piece_hints, region_ids, region_bodies = _process_bodies_and_hints( - bodies, - hints, - dim, - ) - region_functions, ordered_region_ids, ordered_region_bodies = _build_region_lists( - bbox_min, - bbox_max, - body_functions, - region_ids, - region_bodies, - mesh_exterior=mesh_exterior, - ) - - return FemGeometry( - dim=dim, - bbox_min=bbox_min, - bbox_max=bbox_max, - density_fun=density_fun, - region_ids=tuple(ordered_region_ids), - region_functions=tuple(region_functions), - region_bodies=tuple(ordered_region_bodies), - piece_hints=tuple(piece_hints), - mesh_exterior=bool(mesh_exterior), - ) diff --git a/src/nmesh/mesher/relaxation/geometry/__init__.py b/src/nmesh/mesher/relaxation/geometry/__init__.py new file mode 100644 index 0000000..56dcbad --- /dev/null +++ b/src/nmesh/mesher/relaxation/geometry/__init__.py @@ -0,0 +1,6 @@ +"""Public compatibility exports for relaxation geometry.""" + +from .builder import fem_geometry_from_bodies +from .model import FemGeometry + +__all__ = ["FemGeometry", "fem_geometry_from_bodies"] diff --git a/src/nmesh/mesher/relaxation/geometry/builder.py b/src/nmesh/mesher/relaxation/geometry/builder.py new file mode 100644 index 0000000..a4a759a --- /dev/null +++ b/src/nmesh/mesher/relaxation/geometry/builder.py @@ -0,0 +1,154 @@ +"""Construction helpers for relaxation geometry bundles.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import cast + +import numpy as np + +from ....backend import RawMesh +from ....geometry.primitives import Body +from .._constants import BOUNDARY_FUZZ +from .._types import DensityFunction, FloatArray, RegionFunction +from ..density import _compile_density_function +from .model import FemGeometry + + +def _raw_mesh_points(raw_mesh: RawMesh, dim: int) -> FloatArray: + """Return mesh points as a validated floating-point array.""" + + coords: FloatArray = np.asarray(raw_mesh.points, dtype=np.float64) + if coords.size == 0: + return np.empty((0, dim), dtype=float) + if coords.ndim == 1: + coords = coords[np.newaxis, :] + if coords.shape[1] != dim: + raise ValueError(f"Expected hint mesh points with dimension {dim}, got {coords.shape[1]}") + return coords + + +def _dedupe_hint_points(points: FloatArray) -> FloatArray: + if len(points) == 0: + return points + keep_indices: list[int] = [] + seen: set[tuple[float, ...]] = set() + for index, point in enumerate(points): + key = tuple(cast(list[float], np.round(np.asarray(point), decimals=10).tolist())) + if key not in seen: + seen.add(key) + keep_indices.append(index) + return points[np.asarray(keep_indices, dtype=int)] + + +def _process_bodies_and_hints( + bodies: list[Body], hints: Sequence[Sequence[object]], dim: int +) -> tuple[list[RegionFunction], list[FloatArray], list[int], list[Body | None]]: + """Convert bodies and hint meshes into region predicates and hint blocks.""" + + body_functions: list[RegionFunction] = [] + piece_hints: list[FloatArray] = [] + region_ids: list[int] = [] + region_bodies: list[Body | None] = [] + for region_id, body in enumerate(bodies, start=1): + body_functions.append( + lambda points, member=body: ( + np.asarray(member.evaluate(points), dtype=float) >= -BOUNDARY_FUZZ + ) + ) + piece_hints.append(np.empty((0, dim), dtype=float)) + region_ids.append(region_id) + region_bodies.append(body) + next_region_id = len(region_ids) + 1 + for hint in hints: + if len(hint) != 2: + raise ValueError("Each mesh hint must contain a mesh and a body.") + hint_mesh, hint_body = hint + if not isinstance(hint_mesh, RawMesh) or not isinstance(hint_body, Body): + raise TypeError("Mesh hints must contain a RawMesh and Body.") + hint_points = _raw_mesh_points(hint_mesh, dim) + if len(hint_points) > 0: + mask = np.asarray(hint_body.evaluate(hint_points), dtype=float) >= -BOUNDARY_FUZZ + hint_points = _dedupe_hint_points(hint_points[mask]) + body_functions.append( + lambda points, member=hint_body: ( + np.asarray(member.evaluate(points), dtype=float) >= -BOUNDARY_FUZZ + ) + ) + piece_hints.append(hint_points) + region_ids.append(next_region_id) + region_bodies.append(hint_body) + next_region_id += 1 + return body_functions, piece_hints, region_ids, region_bodies + + +def _build_region_lists( + bbox_min: FloatArray, + bbox_max: FloatArray, + body_functions: list[RegionFunction], + region_ids: list[int], + region_bodies: list[Body | None], + *, + mesh_exterior: bool, +) -> tuple[list[RegionFunction], list[int], list[Body | None]]: + """Build ordered region lists, including the exterior region when requested.""" + + region_functions: list[RegionFunction] = [] + ordered_region_ids: list[int] = [] + ordered_region_bodies: list[Body | None] = [] + if mesh_exterior: + + def exterior(points: FloatArray) -> np.ndarray: + bbox_mask = np.all( + (points >= bbox_min - BOUNDARY_FUZZ) & (points <= bbox_max + BOUNDARY_FUZZ), + axis=1, + ) + inside_any = np.zeros(len(points), dtype=bool) + for body_fun in body_functions: + inside_any |= body_fun(points) + return bbox_mask & ~inside_any + + region_functions.append(exterior) + ordered_region_ids.append(0) + ordered_region_bodies.append(None) + region_functions.extend(body_functions) + ordered_region_ids.extend(region_ids) + ordered_region_bodies.extend(region_bodies) + return region_functions, ordered_region_ids, ordered_region_bodies + + +def fem_geometry_from_bodies( + bounding_box: tuple[FloatArray, FloatArray], + bodies: list[Body], + hints: Sequence[Sequence[object]], + *, + density: str | DensityFunction | None = None, + mesh_exterior: bool = False, +) -> FemGeometry: + """Construct the geometry bundle consumed by the Python meshing engine.""" + + bbox_min: FloatArray = np.asarray(bounding_box[0], dtype=np.float64) + bbox_max: FloatArray = np.asarray(bounding_box[1], dtype=np.float64) + dim = int(len(bbox_min)) + body_functions, piece_hints, region_ids, region_bodies = _process_bodies_and_hints( + bodies, hints, dim + ) + region_functions, ordered_region_ids, ordered_region_bodies = _build_region_lists( + bbox_min, + bbox_max, + body_functions, + region_ids, + region_bodies, + mesh_exterior=mesh_exterior, + ) + return FemGeometry( + dim=dim, + bbox_min=bbox_min, + bbox_max=bbox_max, + density_fun=_compile_density_function(density), + region_ids=tuple(ordered_region_ids), + region_functions=tuple(region_functions), + region_bodies=tuple(ordered_region_bodies), + piece_hints=tuple(piece_hints), + mesh_exterior=bool(mesh_exterior), + ) diff --git a/src/nmesh/mesher/relaxation/geometry/model.py b/src/nmesh/mesher/relaxation/geometry/model.py new file mode 100644 index 0000000..0f623d0 --- /dev/null +++ b/src/nmesh/mesher/relaxation/geometry/model.py @@ -0,0 +1,194 @@ +"""Runtime geometry queries for the relaxation meshing pipeline.""" + +from __future__ import annotations + +import math +from dataclasses import dataclass + +import numpy as np + +from ....geometry.primitives import Body +from .._constants import BOUNDARY_FUZZ, DENSITY_EPSILON +from .._types import DensityFunction, FloatArray, RegionFunction + + +@dataclass(frozen=True, slots=True) +class FemGeometry: + """Geometry bundle used by the Python meshing engine.""" + + dim: int + bbox_min: FloatArray + bbox_max: FloatArray + density_fun: DensityFunction + region_ids: tuple[int, ...] + region_functions: tuple[RegionFunction, ...] + region_bodies: tuple[Body | None, ...] + piece_hints: tuple[FloatArray, ...] + mesh_exterior: bool + + def points_in_bbox(self, points: FloatArray) -> np.ndarray: + """Return a mask showing which points lie inside the bounding box.""" + + return np.all( + (points >= self.bbox_min - BOUNDARY_FUZZ) & (points <= self.bbox_max + BOUNDARY_FUZZ), + axis=1, + ) + + def classify_points(self, points: FloatArray) -> np.ndarray: + """Return the first matching region id for each point, or -1 outside.""" + + if points.size == 0: + return np.empty(0, dtype=int) + coords = np.asarray(points, dtype=float) + assigned = np.full(len(coords), -1, dtype=int) + bbox_mask = self.points_in_bbox(coords) + for region_id, region_fun in zip(self.region_ids, self.region_functions, strict=True): + mask = bbox_mask & region_fun(coords) & (assigned < 0) + assigned[mask] = region_id + return assigned + + def density_at(self, point: FloatArray) -> float: + """Evaluate the density function with a small positive lower bound.""" + + return max(float(self.density_fun(point)), DENSITY_EPSILON) + + def boundary_mask(self, points: FloatArray, tolerance: float) -> np.ndarray: + """Return a mask for points that lie close to a region or box boundary.""" + + if points.size == 0: + return np.empty(0, dtype=bool) + coords = np.asarray(points, dtype=float) + mask = np.zeros(len(coords), dtype=bool) + for body in self.region_bodies: + if body is not None: + mask |= np.abs(np.asarray(body.evaluate(coords), dtype=float)) <= tolerance + if self.mesh_exterior: + mask |= np.any( + np.isclose(coords, self.bbox_min, atol=tolerance, rtol=0.0) + | np.isclose(coords, self.bbox_max, atol=tolerance, rtol=0.0), + axis=1, + ) + return mask & self.points_in_bbox(coords) + + def boundary_distance(self, point: FloatArray) -> float: + """Return the smallest available distance-like scalar to a boundary.""" + + coord = np.asarray(point, dtype=float) + distances = [ + abs(float(body.evaluate(coord))) for body in self.region_bodies if body is not None + ] + if self.mesh_exterior: + distances.extend( + min( + abs(float(coord[axis] - self.bbox_min[axis])), + abs(float(coord[axis] - self.bbox_max[axis])), + ) + for axis in range(self.dim) + ) + return min(distances) if distances else math.inf + + def boundary_normal(self, point: FloatArray) -> FloatArray: + """Estimate a unit normal for the closest relevant boundary.""" + + coord = np.asarray(point, dtype=float) + best_distance = math.inf + best_normal = np.zeros(self.dim, dtype=float) + for body in self.region_bodies: + if body is None: + continue + distance = abs(float(body.evaluate(coord))) + if distance > best_distance: + continue + gradient = self.boundary_gradient(coord, body) + norm = float(np.linalg.norm(gradient)) + if norm > DENSITY_EPSILON: + best_distance = distance + best_normal = gradient / norm + if self.mesh_exterior: + for axis in range(self.dim): + dist_min = abs(float(coord[axis] - self.bbox_min[axis])) + if dist_min < best_distance: + best_distance = dist_min + best_normal = np.zeros(self.dim, dtype=float) + best_normal[axis] = 1.0 + dist_max = abs(float(coord[axis] - self.bbox_max[axis])) + if dist_max < best_distance: + best_distance = dist_max + best_normal = np.zeros(self.dim, dtype=float) + best_normal[axis] = -1.0 + return best_normal + + def boundary_gradient(self, point: FloatArray, body: Body) -> FloatArray: + """Estimate the gradient of one implicit body at a point.""" + + coord = np.asarray(point, dtype=float) + gradient = np.zeros(self.dim, dtype=float) + extent = max(float(np.max(self.bbox_max - self.bbox_min)), 1.0) + epsilon = max(BOUNDARY_FUZZ * 10.0, extent * 1.0e-6) + for axis in range(self.dim): + offset = np.zeros(self.dim, dtype=float) + offset[axis] = epsilon + gradient[axis] = ( + float(body.evaluate(coord + offset)) - float(body.evaluate(coord - offset)) + ) / (2.0 * epsilon) + return gradient + + def project_point_to_boundary_from_inside( + self, point: FloatArray, *, acceptable_fuzz: float, max_steps: int + ) -> FloatArray: + """Project an interior point onto implicit boundaries using the legacy correction.""" + + coords: FloatArray = np.array(point, dtype=np.float64, copy=True) + bodies = [body for body in self.region_bodies if body is not None] + if not bodies: + return self._project_point_to_box_boundary(coords) + for _ in range(max(max_steps, 0)): + violated_body: Body | None = None + violated_value = 0.0 + for body in bodies: + value = float(body.evaluate(coords)) + if value > acceptable_fuzz: + violated_body, violated_value = body, value + break + if violated_body is None: + return coords + gradient = self.boundary_gradient(coords, violated_body) + gradient_norm_sq = float(np.dot(gradient, gradient)) + scale = ( + 1.0e-6 + if gradient_norm_sq <= DENSITY_EPSILON + else -violated_value / gradient_norm_sq + ) + coords = coords + scale * gradient + return coords + + def _project_point_to_box_boundary(self, point: FloatArray) -> FloatArray: + coords: FloatArray = np.array(point, dtype=np.float64, copy=True) + distances_min = np.abs(coords - self.bbox_min) + distances_max = np.abs(coords - self.bbox_max) + min_axis = int(np.argmin(distances_min)) + max_axis = int(np.argmin(distances_max)) + if distances_min[min_axis] <= distances_max[max_axis]: + coords[min_axis] = self.bbox_min[min_axis] + else: + coords[max_axis] = self.bbox_max[max_axis] + return coords + + def project_segment_to_domain( + self, start: FloatArray, end: FloatArray, *, iterations: int = 18 + ) -> FloatArray: + """Project an outside point back into the domain along a segment.""" + + lower: FloatArray = np.array(start, dtype=np.float64, copy=True) + upper: FloatArray = np.array(end, dtype=np.float64, copy=True) + if self.classify_points(lower[np.newaxis, :])[0] < 0: + return lower + if self.classify_points(upper[np.newaxis, :])[0] >= 0: + return upper + for _ in range(iterations): + middle = 0.5 * (lower + upper) + if self.classify_points(middle[np.newaxis, :])[0] >= 0: + lower = middle + else: + upper = middle + return lower diff --git a/src/nmesh/mesher/relaxation/seeding.py b/src/nmesh/mesher/relaxation/seeding.py deleted file mode 100644 index b747c0e..0000000 --- a/src/nmesh/mesher/relaxation/seeding.py +++ /dev/null @@ -1,412 +0,0 @@ -"""Seed-point preparation helpers for the relaxation meshing pipeline.""" - -from __future__ import annotations - -import math -from typing import Any - -import numpy as np - -from ._constants import ( - BOUNDARY_FUZZ, - DENSITY_EPSILON, - STATE_BOUNDARY, - STATE_FIXED, - STATE_MOBILE, - STATE_SIMPLE, -) -from ._types import FloatArray -from .geometry import FemGeometry - - -def _as_float_array(points: Any, dim: int | None = None) -> FloatArray: - """Convert point-like input into a 2D float array with optional dimension validation.""" - - if points is None: - target_dim = 0 if dim is None else dim - return np.empty((0, target_dim), dtype=float) - - coords = np.asarray(points, dtype=float) - if coords.size == 0: - target_dim = 0 if dim is None else dim - return np.empty((0, target_dim), dtype=float) - - if coords.ndim == 1: - if dim is None: - dim = int(coords.shape[0]) - coords = coords.reshape(1, dim) - - if dim is not None and coords.shape[1] != dim: - raise ValueError(f"Expected points with dimension {dim}, got {coords.shape[1]}") - - return coords.astype(float, copy=False) - - -def _point_key(point: FloatArray, decimals: int = 10) -> tuple[float, ...]: - """Return a rounded tuple key for deduplication of point coordinates.""" - - return tuple(np.round(np.asarray(point, dtype=float), decimals=decimals).tolist()) - - -def _dedupe_points(points: FloatArray) -> FloatArray: - """Drop duplicate points while preserving first-seen order.""" - - if len(points) == 0: - return points - - keep_indices: list[int] = [] - seen: set[tuple[float, ...]] = set() - for index, point in enumerate(points): - key = _point_key(point) - if key in seen: - continue - seen.add(key) - keep_indices.append(index) - return points[np.asarray(keep_indices, dtype=int)] - - -def _dedupe_fixed_mobile( - fixed_points: FloatArray, - mobile_points: FloatArray, - simply_points: FloatArray, -) -> tuple[FloatArray, FloatArray, FloatArray]: - """Deduplicate seed points across fixed, mobile, and simply categories.""" - - seen: set[tuple[float, ...]] = set() - - def filter_points(points: FloatArray) -> FloatArray: - """Return only points not seen in an earlier category.""" - - keep_indices: list[int] = [] - for index, point in enumerate(points): - key = _point_key(point) - if key in seen: - continue - seen.add(key) - keep_indices.append(index) - if not keep_indices: - return np.empty((0, points.shape[1]), dtype=float) - return points[np.asarray(keep_indices, dtype=int)] - - return ( - filter_points(fixed_points), - filter_points(mobile_points), - filter_points(simply_points), - ) - - -def _box_volume(geometry: FemGeometry) -> float: - """Return the volume of the meshing bounding box.""" - - return float(np.prod(np.maximum(geometry.bbox_max - geometry.bbox_min, 0.0))) - - -def _random_point_in_box(geometry: FemGeometry, rng: np.random.Generator) -> FloatArray: - """Sample one uniformly random point from the bounding box.""" - - return geometry.bbox_min + rng.random(geometry.dim) * (geometry.bbox_max - geometry.bbox_min) - - -def _sampling_density(geometry: FemGeometry, point: FloatArray) -> float: - """Evaluate density only for points belonging to the meshed domain.""" - - if geometry.classify_points(np.asarray(point, dtype=float).reshape(1, -1))[0] < 0: - return 0.0 - return geometry.density_at(point) - - -def _estimate_density_max_and_average( - geometry: FemGeometry, - nr_probes: int, - rng: np.random.Generator, - *, - conservative_factor: float = 1.12, -) -> tuple[float, float]: - """Estimate the legacy random-sampling max and average density values.""" - - if nr_probes <= 0: - return 0.0, 0.0 - - first_value = _sampling_density(geometry, _random_point_in_box(geometry, rng)) - max_seen = first_value - # The legacy estimator intentionally seeded the max with the first probe - # while leaving it out of the average accumulator. - density_sum = 0.0 - for _ in range(1, nr_probes): - value = _sampling_density(geometry, _random_point_in_box(geometry, rng)) - max_seen = max(max_seen, value) - density_sum += value - return max_seen * conservative_factor, density_sum / float(nr_probes) - - -def _sphere_volume(dim: int) -> float: - """Return the d-dimensional unit sphere volume.""" - - return (math.pi ** (0.5 * dim)) / math.gamma(1.0 + 0.5 * dim) - - -def _sphere_packing_ratio_lattice_type_d(dim: int) -> float: - """Return the D-lattice sphere-packing ratio used by the legacy seeder.""" - - if dim <= 1: - return 1.0 - - lattice_vectors = np.zeros((dim, dim), dtype=float) - for row in range(dim): - if row == dim - 1: - lattice_vectors[row, max(dim - 2, 0) :] = 1.0 - else: - lattice_vectors[row, row] = 1.0 - lattice_vectors[row, row + 1] = -1.0 - - lattice_cell_volume = abs(float(np.linalg.det(lattice_vectors))) - if lattice_cell_volume <= DENSITY_EPSILON: - return 1.0 - sphere_radius = 0.5 * math.sqrt(2.0) - sphere_volume = (sphere_radius**dim) * _sphere_volume(dim) - return sphere_volume / lattice_cell_volume - - -def _estimate_initial_point_count( - geometry: FemGeometry, - a0: float, - fixed_points: FloatArray, - average_density: float, -) -> int: - """Estimate the number of random initial nodes using the OCaml formula.""" - - _ = fixed_points - node_volume = ( - _sphere_volume(geometry.dim) - * ((a0 * 0.5 * 0.7) ** geometry.dim) - / max(_sphere_packing_ratio_lattice_type_d(geometry.dim), DENSITY_EPSILON) - ) - estimated_integrated_density = average_density * _box_volume(geometry) - estimated_nodes = estimated_integrated_density / max(node_volume, DENSITY_EPSILON) - return min(10_000, max(geometry.dim + 1 + 5, int(estimated_nodes))) - - -def _distribute_points_randomly( - geometry: FemGeometry, - nr_points: int, - max_density: float, - rng: np.random.Generator, -) -> FloatArray: - """Draw initial points with rejection sampling against the density field.""" - - if nr_points <= 0 or max_density <= DENSITY_EPSILON: - return np.empty((0, geometry.dim), dtype=float) - - # OCaml's Array.make evaluates the initial random point once before the - # rejection loop; keep the same RNG consumption for deterministic parity. - _ = _random_point_in_box(geometry, rng) - result = np.empty((nr_points, geometry.dim), dtype=float) - accepted = 0 - while accepted < nr_points: - point = _random_point_in_box(geometry, rng) - if rng.random() * max_density > _sampling_density(geometry, point): - continue - result[accepted] = point - accepted += 1 - return result - - -def _classify_dynamic_states(geometry: FemGeometry, points: FloatArray, a0: float) -> np.ndarray: - """Return mobile or boundary states for the supplied movable points.""" - - _ = a0 - if len(points) == 0: - return np.empty(0, dtype=int) - mask = geometry.boundary_mask(points, tolerance=BOUNDARY_FUZZ) - states = np.full(len(points), STATE_MOBILE, dtype=int) - states[mask] = STATE_BOUNDARY - return states - - -def _filter_relevant_points(geometry: FemGeometry, points: FloatArray) -> FloatArray: - """Keep only points that belong to one of the meshed regions.""" - - if len(points) == 0: - return points - mask = geometry.classify_points(points) >= 0 - return points[mask] - - -def _select_generated_points( - geometry: FemGeometry, - a0: float, - fixed_points: FloatArray, - mobile_points: FloatArray, - simply_points: FloatArray, - rng: np.random.Generator, - params: dict[str, Any], -) -> FloatArray: - """Generate density-weighted random seed points like the legacy mesher.""" - - nr_probes = int(params.get("nr_probes_for_determining_volume", 100_000)) - max_density, average_density = _estimate_density_max_and_average(geometry, nr_probes, rng) - nr_points = _estimate_initial_point_count(geometry, a0, fixed_points, average_density) - candidates = _distribute_points_randomly(geometry, nr_points, max_density, rng) - if len(candidates) == 0: - return np.empty((0, geometry.dim), dtype=float) - - candidate_keys = {_point_key(point) for point in fixed_points} - candidate_keys.update(_point_key(point) for point in mobile_points) - candidate_keys.update(_point_key(point) for point in simply_points) - - selected_candidates: list[FloatArray] = [] - for point in candidates: - key = _point_key(point) - if key in candidate_keys: - continue - if geometry.classify_points(np.asarray(point, dtype=float).reshape(1, -1))[0] < 0: - continue - selected_candidates.append(point) - candidate_keys.add(key) - - if not selected_candidates: - return np.empty((0, geometry.dim), dtype=float) - return np.asarray(selected_candidates, dtype=float) - - -def _collect_hint_points(geometry: FemGeometry) -> FloatArray: - """Merge, deduplicate, and filter hint points from all geometry pieces.""" - - hint_points = [hint_points for hint_points in geometry.piece_hints if len(hint_points) > 0] - if not hint_points: - return np.empty((0, geometry.dim), dtype=float) - hint_block = _dedupe_points(np.vstack(hint_points)) - return _filter_relevant_points(geometry, hint_block) - - -def _periodic_outer_box_points( - geometry: FemGeometry, - a0: float, - periodic: list[float] | list[bool], -) -> FloatArray: - """Create paired fixed seed points on periodic outer-box faces.""" - - periodic_flags = [bool(value) for value in periodic] - if not any(periodic_flags): - return np.empty((0, geometry.dim), dtype=float) - - points: list[FloatArray] = [] - spacing = max(a0, DENSITY_EPSILON) - for periodic_axis, enabled in enumerate(periodic_flags): - if not enabled: - continue - - other_axes = [axis for axis in range(geometry.dim) if axis != periodic_axis] - face_axes: list[FloatArray] = [] - for axis in other_axes: - extent = float(geometry.bbox_max[axis] - geometry.bbox_min[axis]) - count = max(2, int(math.floor(extent / spacing)) + 1) - face_axes.append(np.linspace(geometry.bbox_min[axis], geometry.bbox_max[axis], count)) - - coordinate_rows = ( - np.zeros((1, 0), dtype=float) - if not other_axes - else np.array(np.meshgrid(*face_axes, indexing="ij")).T.reshape(-1, len(other_axes)) - ) - for coordinates in coordinate_rows: - for side in (geometry.bbox_min[periodic_axis], geometry.bbox_max[periodic_axis]): - point = np.zeros(geometry.dim, dtype=float) - point[periodic_axis] = side - for axis, value in zip(other_axes, coordinates): - point[axis] = value - points.append(point) - - if not points: - return np.empty((0, geometry.dim), dtype=float) - return _filter_relevant_points(geometry, _dedupe_points(np.asarray(points, dtype=float))) - - -def _apply_periodic_fixed_states( - states: np.ndarray, - all_points: FloatArray, - geometry: FemGeometry, - periodic: list[float] | list[bool], - a0: float, -) -> None: - """Mark points on periodic boundaries as fixed.""" - - if len(all_points) == 0: - return - - periodic_flags = [bool(value) for value in periodic] - periodic_mask = np.zeros(len(all_points), dtype=bool) - _ = a0 - tolerance = BOUNDARY_FUZZ - for axis, enabled in enumerate(periodic_flags): - if not enabled: - continue - periodic_mask |= np.isclose( - all_points[:, axis], geometry.bbox_min[axis], atol=tolerance, rtol=0.0 - ) - periodic_mask |= np.isclose( - all_points[:, axis], geometry.bbox_max[axis], atol=tolerance, rtol=0.0 - ) - states[periodic_mask] = STATE_FIXED - - -def _prepare_initial_points( - geometry: FemGeometry, - a0: float, - fixed_points: FloatArray, - mobile_points: FloatArray, - simply_points: FloatArray, - periodic: list[float] | list[bool], - rng: np.random.Generator, - params: dict[str, Any] | None = None, -) -> tuple[FloatArray, np.ndarray]: - """Prepare the initial point cloud and point-state array for relaxation.""" - - params = {} if params is None else params - fixed_points, mobile_points, simply_points = _dedupe_fixed_mobile( - fixed_points, mobile_points, simply_points - ) - fixed_points = _filter_relevant_points(geometry, fixed_points) - mobile_points = _filter_relevant_points(geometry, mobile_points) - simply_points = _filter_relevant_points(geometry, simply_points) - - if len(simply_points) > 0: - states = np.full(len(simply_points), STATE_SIMPLE, dtype=int) - _apply_periodic_fixed_states(states, simply_points, geometry, periodic, a0) - return simply_points, states - - generated_points = _select_generated_points( - geometry, - a0, - fixed_points, - mobile_points, - simply_points, - rng, - params, - ) if len(mobile_points) == 0 else np.empty((0, geometry.dim), dtype=float) - hint_block = _collect_hint_points(geometry) - periodic_block = _periodic_outer_box_points(geometry, a0, periodic) - - all_points = np.vstack( - ( - fixed_points, - simply_points, - mobile_points, - hint_block, - periodic_block, - generated_points, - ) - ) - - states = np.concatenate( - ( - np.full(len(fixed_points), STATE_FIXED, dtype=int), - np.full(len(simply_points), STATE_SIMPLE, dtype=int), - _classify_dynamic_states(geometry, mobile_points, a0), - np.full(len(hint_block), STATE_FIXED, dtype=int), - np.full(len(periodic_block), STATE_FIXED, dtype=int), - _classify_dynamic_states(geometry, generated_points, a0), - ) - ) - - _apply_periodic_fixed_states(states, all_points, geometry, periodic, a0) - return all_points, states diff --git a/src/nmesh/mesher/relaxation/seeding/__init__.py b/src/nmesh/mesher/relaxation/seeding/__init__.py new file mode 100644 index 0000000..60e2e82 --- /dev/null +++ b/src/nmesh/mesher/relaxation/seeding/__init__.py @@ -0,0 +1,74 @@ +"""Compatibility exports and seed-point assembly for relaxation meshing.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + +from .._constants import STATE_FIXED, STATE_SIMPLE +from .._types import FloatArray +from ..geometry import FemGeometry +from .periodic import _apply_periodic_fixed_states, _periodic_outer_box_points +from .points import _as_float_array as _as_float_array # noqa: F401 +from .points import _classify_dynamic_states as _classify_dynamic_states # noqa: F401 +from .points import _dedupe_fixed_mobile, _filter_relevant_points # noqa: F401 +from .points import _dedupe_points as _dedupe_points +from .sampling import _collect_hint_points, _select_generated_points + + +def _prepare_initial_points( + geometry: FemGeometry, + a0: float, + fixed_points: FloatArray, + mobile_points: FloatArray, + simply_points: FloatArray, + periodic: list[float] | list[bool], + rng: np.random.Generator, + params: dict[str, Any] | None = None, +) -> tuple[FloatArray, np.ndarray]: + """Prepare the initial point cloud and point-state array for relaxation.""" + + params = {} if params is None else params + fixed_points, mobile_points, simply_points = _dedupe_fixed_mobile( + fixed_points, mobile_points, simply_points + ) + fixed_points = _filter_relevant_points(geometry, fixed_points) + mobile_points = _filter_relevant_points(geometry, mobile_points) + simply_points = _filter_relevant_points(geometry, simply_points) + + if len(simply_points) > 0: + states = np.full(len(simply_points), STATE_SIMPLE, dtype=int) + _apply_periodic_fixed_states(states, simply_points, geometry, periodic, a0) + return simply_points, states + + generated_points = ( + _select_generated_points( + geometry, + a0, + fixed_points, + mobile_points, + simply_points, + rng, + params, + ) + if len(mobile_points) == 0 + else np.empty((0, geometry.dim), dtype=float) + ) + hint_block = _collect_hint_points(geometry) + periodic_block = _periodic_outer_box_points(geometry, a0, periodic) + all_points = np.vstack( + (fixed_points, simply_points, mobile_points, hint_block, periodic_block, generated_points) + ) + states = np.concatenate( + ( + np.full(len(fixed_points), STATE_FIXED, dtype=int), + np.full(len(simply_points), STATE_SIMPLE, dtype=int), + _classify_dynamic_states(geometry, mobile_points, a0), + np.full(len(hint_block), STATE_FIXED, dtype=int), + np.full(len(periodic_block), STATE_FIXED, dtype=int), + _classify_dynamic_states(geometry, generated_points, a0), + ) + ) + _apply_periodic_fixed_states(states, all_points, geometry, periodic, a0) + return all_points, states diff --git a/src/nmesh/mesher/relaxation/seeding/periodic.py b/src/nmesh/mesher/relaxation/seeding/periodic.py new file mode 100644 index 0000000..e545a72 --- /dev/null +++ b/src/nmesh/mesher/relaxation/seeding/periodic.py @@ -0,0 +1,88 @@ +"""Periodic-boundary seed-point and state helpers.""" + +from __future__ import annotations + +import math +from itertools import product + +import numpy as np + +from .._constants import BOUNDARY_FUZZ, DENSITY_EPSILON, STATE_FIXED +from .._types import FloatArray +from ..geometry import FemGeometry +from .points import _dedupe_points, _filter_relevant_points + + +def _periodic_outer_box_points( + geometry: FemGeometry, a0: float, periodic: list[float] | list[bool] +) -> FloatArray: + """Create paired fixed seed points on periodic outer-box faces.""" + + periodic_flags = [bool(value) for value in periodic] + if not any(periodic_flags): + return np.empty((0, geometry.dim), dtype=float) + points: list[FloatArray] = [] + spacing = max(a0, DENSITY_EPSILON) + for periodic_axis, enabled in enumerate(periodic_flags): + if not enabled: + continue + points.extend(_periodic_face_points(geometry, periodic_axis, spacing)) + if not points: + return np.empty((0, geometry.dim), dtype=float) + return _filter_relevant_points(geometry, _dedupe_points(np.asarray(points, dtype=float))) + + +def _periodic_face_points( + geometry: FemGeometry, + periodic_axis: int, + spacing: float, +) -> list[FloatArray]: + """Return matching points on the two periodic faces of one axis.""" + + other_axes = [axis for axis in range(geometry.dim) if axis != periodic_axis] + face_axes: list[FloatArray] = [] + for axis in other_axes: + extent = float(geometry.bbox_max[axis] - geometry.bbox_min[axis]) + count = max(2, int(math.floor(extent / spacing)) + 1) + face_axes.append( + np.linspace( + float(geometry.bbox_min[axis]), + float(geometry.bbox_max[axis]), + count, + dtype=float, + ) + ) + + points: list[FloatArray] = [] + for coordinates in product(*(axis.tolist() for axis in face_axes)): + for side in (geometry.bbox_min[periodic_axis], geometry.bbox_max[periodic_axis]): + point = np.zeros(geometry.dim, dtype=float) + point[periodic_axis] = side + for axis, value in zip(other_axes, coordinates, strict=True): + point[axis] = value + points.append(point) + return points + + +def _apply_periodic_fixed_states( + states: np.ndarray, + all_points: FloatArray, + geometry: FemGeometry, + periodic: list[float] | list[bool], + a0: float, +) -> None: + """Mark points on periodic boundaries as fixed.""" + + _ = a0 + if len(all_points) == 0: + return + periodic_mask = np.zeros(len(all_points), dtype=bool) + for axis, enabled in enumerate(map(bool, periodic)): + if enabled: + periodic_mask |= np.isclose( + all_points[:, axis], geometry.bbox_min[axis], atol=BOUNDARY_FUZZ, rtol=0.0 + ) + periodic_mask |= np.isclose( + all_points[:, axis], geometry.bbox_max[axis], atol=BOUNDARY_FUZZ, rtol=0.0 + ) + states[periodic_mask] = STATE_FIXED diff --git a/src/nmesh/mesher/relaxation/seeding/points.py b/src/nmesh/mesher/relaxation/seeding/points.py new file mode 100644 index 0000000..85f2169 --- /dev/null +++ b/src/nmesh/mesher/relaxation/seeding/points.py @@ -0,0 +1,88 @@ +"""Point normalization and state classification for relaxation seeding.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + +from .._constants import BOUNDARY_FUZZ, STATE_BOUNDARY, STATE_MOBILE +from .._types import FloatArray +from ..geometry import FemGeometry + + +def _as_float_array(points: Any, dim: int | None = None) -> FloatArray: + """Convert point-like input into a 2D float array with optional dimension validation.""" + + if points is None: + return np.empty((0, 0 if dim is None else dim), dtype=float) + coords = np.asarray(points, dtype=float) + if coords.size == 0: + return np.empty((0, 0 if dim is None else dim), dtype=float) + if coords.ndim == 1: + dim = int(coords.shape[0]) if dim is None else dim + coords = coords[np.newaxis, :] + if dim is not None and coords.shape[1] != dim: + raise ValueError(f"Expected points with dimension {dim}, got {coords.shape[1]}") + return coords.astype(float, copy=False) + + +def _point_key(point: FloatArray, decimals: int = 10) -> tuple[float, ...]: + """Return a rounded tuple key for deduplication of point coordinates.""" + + return tuple(np.round(np.asarray(point, dtype=float), decimals=decimals).tolist()) + + +def _dedupe_points(points: FloatArray) -> FloatArray: + """Drop duplicate points while preserving first-seen order.""" + + if len(points) == 0: + return points + keep_indices: list[int] = [] + seen: set[tuple[float, ...]] = set() + for index, point in enumerate(points): + key = _point_key(point) + if key not in seen: + seen.add(key) + keep_indices.append(index) + return points[np.asarray(keep_indices, dtype=int)] + + +def _dedupe_fixed_mobile( + fixed_points: FloatArray, + mobile_points: FloatArray, + simply_points: FloatArray, +) -> tuple[FloatArray, FloatArray, FloatArray]: + """Deduplicate seed points across fixed, mobile, and simply categories.""" + + seen: set[tuple[float, ...]] = set() + + def filter_points(points: FloatArray) -> FloatArray: + keep_indices: list[int] = [] + for index, point in enumerate(points): + key = _point_key(point) + if key not in seen: + seen.add(key) + keep_indices.append(index) + if not keep_indices: + return np.empty((0, points.shape[1]), dtype=float) + return points[np.asarray(keep_indices, dtype=int)] + + return tuple(map(filter_points, (fixed_points, mobile_points, simply_points))) # type: ignore[return-value] + + +def _classify_dynamic_states(geometry: FemGeometry, points: FloatArray, a0: float) -> np.ndarray: + """Return mobile or boundary states for the supplied movable points.""" + + _ = a0 + if len(points) == 0: + return np.empty(0, dtype=int) + states = np.full(len(points), STATE_MOBILE, dtype=int) + states[geometry.boundary_mask(points, tolerance=BOUNDARY_FUZZ)] = STATE_BOUNDARY + return states + + +def _filter_relevant_points(geometry: FemGeometry, points: FloatArray) -> FloatArray: + """Keep only points that belong to one of the meshed regions.""" + + return points if len(points) == 0 else points[geometry.classify_points(points) >= 0] diff --git a/src/nmesh/mesher/relaxation/seeding/sampling.py b/src/nmesh/mesher/relaxation/seeding/sampling.py new file mode 100644 index 0000000..c18d509 --- /dev/null +++ b/src/nmesh/mesher/relaxation/seeding/sampling.py @@ -0,0 +1,142 @@ +"""Density-weighted random sampling helpers for relaxation seed points.""" + +from __future__ import annotations + +import math +from typing import Any + +import numpy as np + +from .._constants import DENSITY_EPSILON +from .._types import FloatArray +from ..geometry import FemGeometry +from .points import _dedupe_points, _filter_relevant_points, _point_key + + +def _box_volume(geometry: FemGeometry) -> float: + return float(np.prod(np.maximum(geometry.bbox_max - geometry.bbox_min, 0.0))) + + +def _random_point_in_box(geometry: FemGeometry, rng: np.random.Generator) -> FloatArray: + return geometry.bbox_min + rng.random(geometry.dim) * (geometry.bbox_max - geometry.bbox_min) + + +def _sampling_density(geometry: FemGeometry, point: FloatArray) -> float: + if geometry.classify_points(np.asarray(point, dtype=float)[np.newaxis, :])[0] < 0: + return 0.0 + return geometry.density_at(point) + + +def _estimate_density_max_and_average( + geometry: FemGeometry, + nr_probes: int, + rng: np.random.Generator, + *, + conservative_factor: float = 1.12, +) -> tuple[float, float]: + """Estimate the legacy random-sampling maximum and average density.""" + + if nr_probes <= 0: + return 0.0, 0.0 + max_seen = _sampling_density(geometry, _random_point_in_box(geometry, rng)) + density_sum = 0.0 + for _ in range(1, nr_probes): + value = _sampling_density(geometry, _random_point_in_box(geometry, rng)) + max_seen = max(max_seen, value) + density_sum += value + return max_seen * conservative_factor, density_sum / float(nr_probes) + + +def _sphere_volume(dim: int) -> float: + return (math.pi ** (0.5 * dim)) / math.gamma(1.0 + 0.5 * dim) + + +def _sphere_packing_ratio_lattice_type_d(dim: int) -> float: + if dim <= 1: + return 1.0 + lattice_vectors = np.zeros((dim, dim), dtype=float) + for row in range(dim): + if row == dim - 1: + lattice_vectors[row, max(dim - 2, 0) :] = 1.0 + else: + lattice_vectors[row, row] = 1.0 + lattice_vectors[row, row + 1] = -1.0 + lattice_cell_volume = abs(float(np.linalg.det(lattice_vectors))) + if lattice_cell_volume <= DENSITY_EPSILON: + return 1.0 + return ((0.5 * math.sqrt(2.0)) ** dim) * _sphere_volume(dim) / lattice_cell_volume + + +def _estimate_initial_point_count( + geometry: FemGeometry, a0: float, fixed_points: FloatArray, average_density: float +) -> int: + _ = fixed_points + node_volume = ( + _sphere_volume(geometry.dim) + * ((a0 * 0.5 * 0.7) ** geometry.dim) + / max(_sphere_packing_ratio_lattice_type_d(geometry.dim), DENSITY_EPSILON) + ) + estimated_nodes = average_density * _box_volume(geometry) / max(node_volume, DENSITY_EPSILON) + return min(10_000, max(geometry.dim + 1 + 5, int(estimated_nodes))) + + +def _distribute_points_randomly( + geometry: FemGeometry, nr_points: int, max_density: float, rng: np.random.Generator +) -> FloatArray: + if nr_points <= 0 or max_density <= DENSITY_EPSILON: + return np.empty((0, geometry.dim), dtype=float) + _ = _random_point_in_box(geometry, rng) + result = np.empty((nr_points, geometry.dim), dtype=float) + accepted = 0 + while accepted < nr_points: + point = _random_point_in_box(geometry, rng) + if rng.random() * max_density <= _sampling_density(geometry, point): + result[accepted] = point + accepted += 1 + return result + + +def _select_generated_points( + geometry: FemGeometry, + a0: float, + fixed_points: FloatArray, + mobile_points: FloatArray, + simply_points: FloatArray, + rng: np.random.Generator, + params: dict[str, Any], +) -> FloatArray: + """Generate density-weighted random seed points like the legacy mesher.""" + + max_density, average_density = _estimate_density_max_and_average( + geometry, int(params.get("nr_probes_for_determining_volume", 100_000)), rng + ) + candidates = _distribute_points_randomly( + geometry, + _estimate_initial_point_count(geometry, a0, fixed_points, average_density), + max_density, + rng, + ) + candidate_keys = {_point_key(point) for point in fixed_points} + candidate_keys.update(_point_key(point) for point in mobile_points) + candidate_keys.update(_point_key(point) for point in simply_points) + selected: list[FloatArray] = [] + for point in candidates: + key = _point_key(point) + if ( + key not in candidate_keys + and geometry.classify_points(np.asarray(point, dtype=float)[np.newaxis, :])[0] >= 0 + ): + selected.append(point) + candidate_keys.add(key) + return ( + np.asarray(selected, dtype=float) if selected else np.empty((0, geometry.dim), dtype=float) + ) + + +def _collect_hint_points(geometry: FemGeometry) -> FloatArray: + """Merge, deduplicate, and filter hint points from all geometry pieces.""" + + hint_points = [points for points in geometry.piece_hints if len(points) > 0] + if not hint_points: + return np.empty((0, geometry.dim), dtype=float) + return _filter_relevant_points(geometry, _dedupe_points(np.vstack(hint_points))) diff --git a/src/nmesh/mesher/relaxation/topology.py b/src/nmesh/mesher/relaxation/topology/__init__.py similarity index 88% rename from src/nmesh/mesher/relaxation/topology.py rename to src/nmesh/mesher/relaxation/topology/__init__.py index d02c07d..3f7cc51 100644 --- a/src/nmesh/mesher/relaxation/topology.py +++ b/src/nmesh/mesher/relaxation/topology/__init__.py @@ -9,11 +9,12 @@ import numpy as np from scipy.spatial import Delaunay, QhullError -from ...backend import RawMesh -from ..periodic import build_periodic_groups -from ._constants import BOUNDARY_FUZZ, STATE_BOUNDARY, STATE_MOBILE -from ._types import FloatArray -from .geometry import FemGeometry +from ....backend import RawMesh +from ....utils.types import IntArray +from ...periodic import build_periodic_groups +from .._constants import BOUNDARY_FUZZ, STATE_BOUNDARY, STATE_MOBILE +from .._types import FloatArray +from ..geometry import FemGeometry def _simplex_measures(points: FloatArray, simplices: np.ndarray, dim: int) -> FloatArray: @@ -38,7 +39,7 @@ def _surface_faces(simplices: np.ndarray, regions: np.ndarray, dim: int) -> list face_map: dict[tuple[int, ...], set[int]] = {} face_size = dim - for simplex, region in zip(simplices, regions): + for simplex, region in zip(simplices, regions, strict=True): for face in combinations(simplex.tolist(), face_size): key = tuple(sorted(face)) face_map.setdefault(key, set()).add(int(region)) @@ -75,7 +76,7 @@ def _point_regions(point_count: int, simplices: np.ndarray, regions: np.ndarray) """Build the region-membership list for each mesh point.""" memberships: list[set[int]] = [set() for _ in range(point_count)] - for simplex, region in zip(simplices, regions): + for simplex, region in zip(simplices, regions, strict=True): for point_index in simplex: memberships[int(point_index)].add(int(region)) return [sorted(group) for group in memberships] @@ -89,7 +90,7 @@ def _region_volumes(region_ids: np.ndarray, measures: FloatArray) -> list[float] order = sorted({int(region) for region in region_ids}) totals = {region: 0.0 for region in order} - for region, measure in zip(region_ids, measures): + for region, measure in zip(region_ids, measures, strict=True): totals[int(region)] += float(measure) return [totals[region] for region in order] @@ -130,7 +131,14 @@ def _classify_simplices_with_probes( region_ids = geometry.classify_points(centroids) offsets = points[simplices] - centroids[:, None, :] probes = (centroids[:, None, :] + 0.9 * offsets).reshape(-1, geometry.dim) - probe_regions = geometry.classify_points(probes).reshape(len(simplices), geometry.dim + 1) + flat_probe_regions = geometry.classify_points(probes) + probe_regions = np.asarray( + [ + flat_probe_regions[index : index + geometry.dim + 1] + for index in range(0, len(flat_probe_regions), geometry.dim + 1) + ], + dtype=int, + ) single_region = (not geometry.mesh_exterior) and (len(set(geometry.region_ids)) <= 1) if single_region: consistent = (region_ids >= 0) & np.all(probe_regions == region_ids[:, None], axis=1) @@ -139,7 +147,9 @@ def _classify_simplices_with_probes( return region_ids, consistent -def _triangulate_points(points: FloatArray, dim: int, states: np.ndarray | None = None) -> np.ndarray: +def _triangulate_points( + points: FloatArray, dim: int, states: np.ndarray | None = None +) -> np.ndarray: """Triangulate the point cloud, retrying with light jitter for degenerate inputs.""" if len(points) < dim + 1: @@ -184,10 +194,9 @@ def _orient_simplices_positive(points: FloatArray, simplices: np.ndarray, dim: i determinants = np.linalg.det(matrices) negative = np.flatnonzero(determinants < 0.0) if len(negative) > 0: - oriented[negative, 0], oriented[negative, 1] = ( - oriented[negative, 1].copy(), - oriented[negative, 0].copy(), - ) + first_nodes: IntArray = np.array(oriented[negative, 0], dtype=np.int_, copy=True) + oriented[negative, 0] = oriented[negative, 1] + oriented[negative, 1] = first_nodes return oriented @@ -272,13 +281,12 @@ def _boundary_state_mask( def _callback_mesh_info(raw_mesh: RawMesh) -> list[list[Any]]: """Build the legacy-style callback payload expected by old consumers.""" - simplices = [ + simplices: list[list[Any]] = [ [simplex, (([], 0.0), ([], 0.0), region)] - for simplex, region in zip(raw_mesh.simplices, raw_mesh.regions) + for simplex, region in zip(raw_mesh.simplices, raw_mesh.regions, strict=True) ] - surfaces = [ - [surface, (([], 0.0), ([], 0.0), 1)] - for surface in raw_mesh.surfaces + surfaces: list[list[Any]] = [ + [surface, (([], 0.0), ([], 0.0), 1)] for surface in raw_mesh.surfaces ] return [ ["COORDS", "Node coordinates", raw_mesh.points], diff --git a/src/nmesh/mesher/relaxation/finalize.py b/src/nmesh/mesher/relaxation/topology/finalize.py similarity index 91% rename from src/nmesh/mesher/relaxation/finalize.py rename to src/nmesh/mesher/relaxation/topology/finalize.py index 3b05a18..0f808a7 100644 --- a/src/nmesh/mesher/relaxation/finalize.py +++ b/src/nmesh/mesher/relaxation/topology/finalize.py @@ -7,10 +7,10 @@ import numpy as np -from ._constants import BOUNDARY_FUZZ, STATE_BOUNDARY, STATE_FIXED, STATE_MOBILE, STATE_SIMPLE -from ._types import FloatArray -from .geometry import FemGeometry -from .topology import _triangulate_points +from .._constants import BOUNDARY_FUZZ, STATE_BOUNDARY, STATE_FIXED, STATE_MOBILE, STATE_SIMPLE +from .._types import FloatArray +from ..geometry import FemGeometry +from . import _triangulate_points def snap_final_boundary_points( @@ -47,7 +47,7 @@ def snap_final_boundary_points( ) if geometry.boundary_distance(snapped) > max(acceptable_fuzz, BOUNDARY_FUZZ): continue - if geometry.classify_points(snapped.reshape(1, -1))[0] < 0: + if geometry.classify_points(snapped[np.newaxis, :])[0] < 0: continue local_rod = a0 / (geometry.density_at(original) ** (1.0 / max(geometry.dim, 1))) if float(np.linalg.norm(snapped - original)) < 0.2 * local_rod: diff --git a/src/nmesh/mesher/relaxation/recovery.py b/src/nmesh/mesher/relaxation/topology/recovery.py similarity index 88% rename from src/nmesh/mesher/relaxation/recovery.py rename to src/nmesh/mesher/relaxation/topology/recovery.py index 1a7d3e3..2ce25cf 100644 --- a/src/nmesh/mesher/relaxation/recovery.py +++ b/src/nmesh/mesher/relaxation/topology/recovery.py @@ -4,13 +4,21 @@ import math from itertools import combinations +from typing import cast import numpy as np -from ._constants import BOUNDARY_FUZZ, DENSITY_EPSILON, STATE_BOUNDARY, STATE_FIXED, STATE_MOBILE, STATE_SIMPLE -from ._types import FloatArray -from .geometry import FemGeometry -from .topology import _simplex_measures +from .._constants import ( + BOUNDARY_FUZZ, + DENSITY_EPSILON, + STATE_BOUNDARY, + STATE_FIXED, + STATE_MOBILE, + STATE_SIMPLE, +) +from .._types import FloatArray +from ..geometry import FemGeometry +from . import _simplex_measures def mirror_surface_recovery_points( @@ -30,7 +38,7 @@ def mirror_surface_recovery_points( seen_surfaces: set[tuple[tuple[float, ...], ...]] = set() additions: list[FloatArray] = [] - for simplex, simplex_volume in zip(simplices, simplex_measures): + for simplex, simplex_volume in zip(simplices, simplex_measures, strict=True): simplex_points = np.asarray(points[simplex], dtype=float) boundary_points, interior_points = _split_boundary_and_interior_points( simplex, @@ -106,7 +114,9 @@ def _mirror_points_2d( shifted = shifted[1:] + shifted[:1] continue additions.extend( - _node_action_2d(surface_points, vertex_point, simplex_points, simplex_volume, geometry, a0) + _node_action_2d( + surface_points, vertex_point, simplex_points, simplex_volume, geometry, a0 + ) ) shifted = shifted[1:] + shifted[:1] return additions @@ -138,7 +148,10 @@ def _node_action_2d( """Mirror one 2D point when the surface angle and volume tests request it.""" angle = _solid_angle_2d(surface_points, vertex_point) - if angle > 0.55 * math.pi and _volume_ratio(simplex_points, simplex_volume, geometry, a0) > 3.0e-2: + if ( + angle > 0.55 * math.pi + and _volume_ratio(simplex_points, simplex_volume, geometry, a0) > 3.0e-2 + ): return [_mirror_point(surface_points, vertex_point, 2)] return [] @@ -178,7 +191,17 @@ def _prevention_points_3d( def _surface_key(surface_points: FloatArray) -> tuple[tuple[float, ...], ...]: """Return a stable set-like key for a boundary surface.""" - return tuple(sorted(tuple(np.round(point, decimals=10).tolist()) for point in surface_points)) + return tuple( + sorted( + tuple( + cast( + list[float], + np.asarray(np.round(point, decimals=10), dtype=float).tolist(), + ) + ) + for point in surface_points + ) + ) def _register_surface( @@ -199,10 +222,10 @@ def _solid_angle_2d(surface_points: FloatArray, vertex_point: FloatArray) -> flo first = surface_points[0] - vertex_point second = surface_points[1] - vertex_point - denom = float(np.linalg.norm(first) * np.linalg.norm(second)) + denom = math.sqrt(float(np.sum(first * first))) * math.sqrt(float(np.sum(second * second))) if denom <= DENSITY_EPSILON: return 0.0 - cosine = float(np.clip(np.dot(first, second) / denom, -1.0, 1.0)) + cosine = min(1.0, max(-1.0, float(np.sum(first * second)) / denom)) return float(math.acos(cosine)) @@ -262,7 +285,7 @@ def _regular_simplex_volume(edge_length: float, dim: int) -> float: def _is_outer_region_constrained(geometry: FemGeometry, point: FloatArray) -> bool: """Return whether a point satisfies the legacy outer-region boundary check.""" - if not geometry.points_in_bbox(np.asarray(point, dtype=float).reshape(1, -1))[0]: + if not geometry.points_in_bbox(np.asarray(point, dtype=float)[np.newaxis, :])[0]: return False for body in geometry.region_bodies: if body is None: @@ -278,7 +301,8 @@ def _dedupe_recovery_points(points: FloatArray) -> FloatArray: keep_indices: list[int] = [] seen: set[tuple[float, ...]] = set() for index, point in enumerate(points): - key = tuple(np.round(point, decimals=10).tolist()) + rounded = np.asarray(np.round(point, decimals=10), dtype=float) + key = tuple(cast(list[float], rounded.tolist())) if key in seen: continue seen.add(key) diff --git a/src/nmesh/mesher/sectioned_config.py b/src/nmesh/mesher/sectioned_config.py new file mode 100644 index 0000000..38fdd41 --- /dev/null +++ b/src/nmesh/mesher/sectioned_config.py @@ -0,0 +1,70 @@ +"""Case-preserving, sectioned INI data used by meshing parameters.""" + +from __future__ import annotations + +import configparser +from collections.abc import ItemsView +from io import StringIO +from os import PathLike +from typing import Any + + +class _CasePreservingConfigParser(configparser.ConfigParser): + def optionxform(self, optionstr: str) -> str: + return optionstr + + +class SectionedConfig: + """Small mutable INI container with typed scalar coercion.""" + + def __init__(self) -> None: + self._data: dict[str, dict[str, Any]] = {} + + @staticmethod + def _coerce_value(value: str) -> Any: + text = value.strip() + if not text: + return text + if text.lower() in {"true", "false"}: + return text.lower() == "true" + try: + return int(text) + except ValueError: + pass + try: + return float(text) + except ValueError: + return text + + def _load_from_parser(self, parser: configparser.ConfigParser) -> None: + for section in parser.sections(): + self.add_section(section) + for name, value in parser.items(section): + self.set(section, name, self._coerce_value(value)) + + def from_file(self, file_path: str | PathLike[str]) -> None: + parser = _CasePreservingConfigParser(delimiters=("=", ":"), interpolation=None) + with open(file_path, encoding="utf-8") as stream: + parser.read_file(stream) + self._load_from_parser(parser) + + def from_string(self, string: str) -> None: + parser = _CasePreservingConfigParser(delimiters=("=", ":"), interpolation=None) + parser.read_file(StringIO(string)) + self._load_from_parser(parser) + + def add_section(self, section: str) -> None: + self._data.setdefault(section, {}) + + def get(self, section: str, name: str, raw: bool = False) -> Any: + del raw + return self._data.get(section, {}).get(name) + + def set(self, section: str, name: str, value: Any) -> None: + self._data.setdefault(section, {})[name] = value + + def items(self, section: str) -> ItemsView[str, Any]: + return self._data.get(section, {}).items() + + def to_string(self) -> str: + return str(self._data) diff --git a/src/nmesh/nmesh.py b/src/nmesh/nmesh.py index 51d1341..26c5336 100644 --- a/src/nmesh/nmesh.py +++ b/src/nmesh/nmesh.py @@ -1,467 +1,99 @@ -from __future__ import annotations - -import logging -from collections.abc import Sequence -from typing import Any, TextIO -from pathlib import Path -from . import utils - -from .backend import RawMesh, backend -from .geometry import Box, Conic, Ellipsoid, Helix, MeshObject, difference, intersect, union -from .mesher import MeshingParameters, make_mg_gendriver -from .io import load_raw_mesh_with_meshio, save_raw_mesh_with_meshio - -# Setup logging -log = logging.getLogger(__name__) - -PYFEM_SUFFIXES = {"", ".nmesh", ".pyfem"} -Point = list[float] -Simplex = list[int] - - -def _as_float_points(points: Sequence[Sequence[float]] | None) -> list[Point]: - return [list(map(float, point)) for point in (points or [])] - - -def _as_int_simplices(simplices: Sequence[Sequence[int]] | None) -> list[Simplex]: - return [list(map(int, simplex)) for simplex in (simplices or [])] - - -def _as_region_ids(regions: Sequence[int] | None) -> list[int]: - return [int(region) for region in (regions or [])] - - -def _normalise_periodic(periodic: Sequence[bool] | Sequence[float] | None, dim: int) -> list[float]: - if not periodic: - return [0.0] * dim - return [1.0 if bool(value) else 0.0 for value in periodic] - -def memory_report(tag: str): - """Reports memory usage.""" - t, vmem, rss = utils.time_vmem_rss() - log.log(15, f"Memory report: T= {t:f} VMEM= {int(vmem)} KB RSS= {int(rss)} KB {tag}") - -# --- Configuration --- - -def get_default_meshing_parameters(): - """Returns default meshing parameters.""" - return MeshingParameters() - -# --- Loading Utilities --- - -def _is_nmesh_ascii_file(filename: str | Path) -> bool: - try: - with Path(filename).open(encoding="utf-8") as stream: - return stream.readline().startswith("# PYFEM") - except OSError: - return False - -def _is_nmesh_hdf5_file(filename: str | Path) -> bool: - # This would normally use tables.isPyTablesFile - return str(filename).lower().endswith('.h5') - -def hdf5_mesh_get_permutation(filename: str | Path): - """Stub for retrieving permutation from HDF5.""" - log.warning("hdf5_mesh_get_permutation: HDF5 support is stubbed.") - return None - -# --- Mesh Classes --- - -class MeshBase: - """Base class for all mesh objects, providing access to mesh data.""" - def __init__(self, raw_mesh: RawMesh): - self.raw_mesh = raw_mesh - self._cache: dict[str, Any] = {} - - def _cached_backend_value(self, cache_key: str, getter): - if cache_key not in self._cache: - self._cache[cache_key] = getter(self.raw_mesh) - return self._cache[cache_key] - - def scale_node_positions(self, scale: float): - """Scales all node positions in the mesh.""" - backend.mesh_scale_node_positions(self.raw_mesh, float(scale)) - for key in ( - "points", - "simplices", - "regions", - "point_regions", - "links", - "region_volumes", - "periodic_indices", - ): - self._cache.pop(key, None) - - def save(self, filename: str | Path): - """Saves the mesh to a file (ASCII or HDF5).""" - path = Path(filename) - suffix = path.suffix.lower() - if suffix == ".h5": - log.info("Saving to HDF5 (stub): %s", path) - return - - if suffix not in PYFEM_SUFFIXES: - save_raw_mesh_with_meshio(path, self.raw_mesh) - return - - if isinstance(self.raw_mesh, RawMesh): - write_mesh(self.raw_mesh, out=path) - return - - backend.mesh_writefile(str(path), self.raw_mesh) - - def __str__(self): - pts = backend.mesh_nr_points(self.raw_mesh) - simps = backend.mesh_nr_simplices(self.raw_mesh) - return f"Mesh with {pts} points and {simps} simplices" - - def to_lists(self): - """Returns mesh data as Python lists.""" - return backend.mesh_plotinfo(self.raw_mesh) - - @property - def points(self): - return self._cached_backend_value("points", backend.mesh_plotinfo_points) - - @property - def simplices(self): - return self._cached_backend_value("simplices", backend.mesh_plotinfo_simplices) - - @property - def regions(self): - return self._cached_backend_value("regions", backend.mesh_plotinfo_simplicesregions) - - @property - def dim(self): - return backend.mesh_dim(self.raw_mesh) - - @property - def surfaces(self): - return backend.mesh_plotinfo_surfaces_and_surfacesregions(self.raw_mesh)[0] - - @property - def point_regions(self): - """Returns regions for each point.""" - return self._cached_backend_value( - "point_regions", backend.mesh_plotinfo_pointsregions - ) - - @property - def links(self): - """Returns all links (pairs of point indices).""" - return self._cached_backend_value("links", backend.mesh_plotinfo_links) - - @property - def region_volumes(self): - """Returns volume of each region.""" - return self._cached_backend_value( - "region_volumes", backend.mesh_plotinfo_regionvolumes - ) - - @property - def num_regions(self): - """Returns the number of regions.""" - return len(self.region_volumes) - - @property - def periodic_point_indices(self): - """Returns indices of periodic nodes.""" - return self._cached_backend_value( - "periodic_indices", - backend.mesh_plotinfo_periodic_points_indices, - ) - - @property - def permutation(self): - """Returns the node permutation mapping.""" - return backend.mesh_get_permutation(self.raw_mesh) - - def set_vertex_distribution(self, dist): - """Sets vertex distribution.""" - backend.mesh_set_vertex_distribution(self.raw_mesh, dist) +"""Public nmesh compatibility façade with lazy geometry and driver exports.""" -class Mesh(MeshBase): - """Class for generating a mesh from geometric objects.""" - def __init__( - self, - bounding_box, - objects=None, - a0=1.0, - density="", - periodic=None, - fixed_points=None, - mobile_points=None, - simply_points=None, - callback=None, - mesh_bounding_box=False, - meshing_parameters=None, - cache_name="", - hints=None, - **kwargs, - ): - if bounding_box is None: - raise ValueError("Bounding box must be provided.") - - object_list = list(objects or []) - hint_list = list(hints or []) - bb = _as_float_points(bounding_box) - dim = len(bb[0]) - mesh_ext = 1 if mesh_bounding_box else 0 - - self.bounding_box = bb - self.mesh_exterior = mesh_ext - - if not object_list and not mesh_bounding_box: - raise ValueError("No objects to mesh and bounding box meshing disabled.") - - if periodic and not mesh_bounding_box and any(periodic): - raise ValueError( - "Can only produce periodic meshes when meshing the bounding box." - ) - - params = meshing_parameters or get_default_meshing_parameters() - self.meshing_parameters = params - for k, v in kwargs.items(): - params[k] = v - - obj_bodies = [] - self.obj = obj_bodies - self.cache_name = cache_name - self.density = density - self._fixed_points = _as_float_points(fixed_points) - self._mobile_points = _as_float_points(mobile_points) - self._simply_points = _as_float_points(simply_points) - - for obj in object_list: - obj_bodies.append(obj.obj) - self._fixed_points.extend(obj.fixed_points) - self._mobile_points.extend(obj.mobile_points) - - resolved_hints = [ - [hint_mesh.raw_mesh, hint_object.obj] - for hint_mesh, hint_object in hint_list - ] - - periodic_floats = _normalise_periodic(periodic, dim) - self.periodic = periodic_floats - - cb_func, cb_interval = ( - callback if callback else (lambda a, b, c: None, 1_000_000) - ) - self.fun_driver = cb_func - self.driver = make_mg_gendriver(cb_interval, cb_func) - self.mesher_config = backend.copy_mesher_defaults(backend.mesher_defaults) - params.apply_to_mesher(self.mesher_config, dim) - - raw = backend.mesh_bodies_raw( - self.driver, - self.mesher_config, - bb[0], - bb[1], - mesh_ext, - obj_bodies, - float(a0), - density, - self._fixed_points, - self._mobile_points, - self._simply_points, - periodic_floats, - cache_name, - resolved_hints, - ) - - if raw is None: - raise RuntimeError("Mesh generation failed.") - super().__init__(raw) - - def default_fun(self, nr_piece, n, mesh): - """Default callback function.""" - pass - - def extended_fun_driver(self, nr_piece, iteration_nr, mesh): - """Extended driver callback.""" - if hasattr(self, 'fun_driver'): - self.fun_driver(nr_piece, iteration_nr, mesh) - - def fixed_points(self, points: Sequence[Sequence[float]] | None): - """Adds fixed points to the mesh configuration.""" - if points: - self._fixed_points.extend(_as_float_points(points)) - - def mobile_points(self, points: Sequence[Sequence[float]] | None): - """Adds mobile points to the mesh configuration.""" - if points: - self._mobile_points.extend(_as_float_points(points)) - - def simply_points(self, points: Sequence[Sequence[float]] | None): - """Adds simply points to the mesh configuration.""" - if points: - self._simply_points.extend(_as_float_points(points)) - -class MeshFromFile(MeshBase): - """Loads a mesh from a file.""" - def __init__(self, filename, reorder=False, distribute=True): - path = Path(filename) - if not path.exists(): - raise FileNotFoundError(f"File {filename} not found") - - if _is_nmesh_ascii_file(path): - raw = backend.mesh_readfile(str(path), reorder, distribute) - elif _is_nmesh_hdf5_file(path): - raw = backend.mesh_readfile(str(path), reorder, distribute) - else: - raw = load_raw_mesh_with_meshio(path) - - super().__init__(raw) - -class mesh_from_points_and_simplices(MeshBase): - """Wrapper for backward compatibility.""" - def __init__( - self, - points=None, - simplices_indices=None, - simplices_regions=None, - periodic_point_indices=None, - initial=0, - do_reorder=False, - do_distribute=True, - ): - points_list = _as_float_points(points) - simplices_list = _as_int_simplices(simplices_indices) - if initial == 1: - simplices_list = [[index - 1 for index in simplex] for simplex in simplices_list] - - raw = backend.mesh_from_points_and_simplices( - len(points_list[0]) if points_list else 3, - points_list, - simplices_list, - _as_region_ids(simplices_regions), - list(periodic_point_indices or []), - do_reorder, - do_distribute, - ) - super().__init__(raw) - -def load(filename, reorder=False, distribute=True): - """Utility function to load a mesh.""" - return MeshFromFile(filename, reorder, distribute) - -def save(mesh: MeshBase, filename: str | Path): - """Alias for mesh.save for backward compatibility.""" - mesh.save(filename) - -# --- Utilities --- - -def outer_corners(mesh: MeshBase): - """Determines the bounding box of the mesh nodes.""" - coords = mesh.points - if not coords: - return None, None - transpose = list(zip(*coords)) - return [min(t) for t in transpose], [max(t) for t in transpose] - -def generate_1d_mesh_components( - regions: Sequence[tuple[float, float]], - discretization: float, -) -> tuple[list[Point], list[Simplex], list[int]]: - """Generates 1D mesh components (points, simplices, regions).""" - points: list[Point] = [] - simplices: list[Simplex] = [] - regions_ids: list[int] = [] - point_map: dict[float, int] = {} - - def get_idx(value: float) -> int: - vk = round(value, 8) - if vk not in point_map: - point_map[vk] = len(points) - points.append([float(value)]) - return point_map[vk] - - for rid, (start, end) in enumerate(regions, 1): - if start > end: - start, end = end, start - steps = max(1, int(abs((end - start) / discretization))) - step = (end - start) / steps - last = get_idx(start) - for i in range(1, steps + 1): - curr = get_idx(start + i * step) - simplices.append([last, curr]) - regions_ids.append(rid) - last = curr - - return points, simplices, regions_ids - -def generate_1d_mesh( - regions: Sequence[tuple[float, float]], - discretization: float, -) -> MeshBase: - """Generates a 1D mesh with specified regions and step size.""" - pts, simps, regs = generate_1d_mesh_components(regions, discretization) - return mesh_from_points_and_simplices(pts, simps, regs) - -def to_lists(mesh: MeshBase): - """Returns mesh data as Python lists.""" - return mesh.to_lists() - -tolists = to_lists - -def write_mesh( - mesh_data: RawMesh | tuple[ - Sequence[Sequence[float]], - Sequence[tuple[int, Sequence[int]]], - Sequence[tuple[int, Sequence[int]]], - ], - out: str | Path | TextIO | None = None, - check=True, - float_fmt=" %f", -): - """ - Writes mesh data to a file in nmesh format. - - `mesh_data` may be a `RawMesh` or a legacy `(points, simplices, surfaces)` tuple. - """ - if isinstance(mesh_data, RawMesh): - points = mesh_data.points - simplices = list( - zip( - mesh_data.regions or [1] * len(mesh_data.simplices), - mesh_data.simplices, - ) - ) - surfaces = list( - zip( - [1] * len(mesh_data.surfaces), - mesh_data.surfaces, - ) - ) - else: - points, simplices, surfaces = mesh_data - - lines = ["# PYFEM mesh file version 1.0"] - dim = len(points[0]) if points else 0 - lines.append( - f"# dim = {dim} \t nodes = {len(points)} \t simplices = {len(simplices)} \t surfaces = {len(surfaces)} \t periodic = 0" - ) - - lines.append(str(len(points))) - for p in points: - lines.append("".join(float_fmt % x for x in p)) - - lines.append(str(len(simplices))) - for body, nodes in simplices: - lines.append(f" {body} " + " ".join(str(n) for n in nodes)) - - lines.append(str(len(surfaces))) - for body, nodes in surfaces: - lines.append(f" {body} " + " ".join(str(n) for n in nodes)) - - lines.append("0") - - content = "\n".join(lines) + "\n" +from __future__ import annotations - if out is None: - print(content) - elif isinstance(out, (str, Path)): - Path(out).write_text(content, encoding="utf-8") - else: - out.write(content) +from typing import TYPE_CHECKING, Any + +from .mesh_generation import Mesh, get_default_meshing_parameters +from .mesh_io import ( + MeshFromFile, + hdf5_mesh_get_permutation, + load, + mesh_from_points_and_simplices, + save, + write_mesh, +) +from .mesh_model import MeshBase, Point, Simplex +from .mesh_utilities import ( + generate_1d_mesh, + generate_1d_mesh_components, + memory_report, + outer_corners, + to_lists, + tolists, +) + +if TYPE_CHECKING: + pass + +_GEOMETRY_EXPORTS = { + "AffineTransform", + "Body", + "Box", + "Conic", + "Ellipsoid", + "Helix", + "MeshObject", + "bc_box", + "bc_ellipsoid", + "bc_frustum", + "bc_helix", + "difference", + "intersect", + "inverse_axis_rotation", + "inverse_plane_rotation", + "inverse_scale", + "inverse_shift", + "union", +} +_MESHER_EXPORTS = { + "Callback", + "EngineFunc", + "MeshEngineCommand", + "MeshEngineStatus", + "MeshingParameters", + "do_every_n_steps_driver", + "make_mg_gendriver", +} +__all__ = [ + "Mesh", + "MeshBase", + "MeshFromFile", + "Point", + "Simplex", + "generate_1d_mesh", + "generate_1d_mesh_components", + "get_default_meshing_parameters", + "hdf5_mesh_get_permutation", + "load", + "memory_report", + "mesh_from_points_and_simplices", + "outer_corners", + "save", + "to_lists", + "tolists", + "write_mesh", + *_GEOMETRY_EXPORTS, + *_MESHER_EXPORTS, +] + + +def __getattr__(name: str) -> Any: + if name in _GEOMETRY_EXPORTS: + from . import geometry + + value = getattr(geometry, name) + globals()[name] = value + return value + if name == "MeshingParameters": + from .mesher.meshing_parameters import MeshingParameters + + globals()[name] = MeshingParameters + return MeshingParameters + if name in _MESHER_EXPORTS: + from .mesher import driver + + value = getattr(driver, name) + globals()[name] = value + return value + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/nmesh/py.typed b/src/nmesh/py.typed new file mode 100644 index 0000000..534be2b --- /dev/null +++ b/src/nmesh/py.typed @@ -0,0 +1 @@ +# Marker for PEP 561 inline type information. diff --git a/src/nmesh/utils/__init__.py b/src/nmesh/utils/__init__.py index ea29c77..4b1f1e7 100644 --- a/src/nmesh/utils/__init__.py +++ b/src/nmesh/utils/__init__.py @@ -1,4 +1,33 @@ -from .array_list_utils import * -from .constants import * -from .timing_memory_utils import * -from .types import * +from .array_list_utils import ( + array_filter, + array_one_shorter, + array_position, + array_position_if, + cross_product_3d, + det_and_inv, + determinant, + inverse, +) +from .constants import BOUNDARY_FUZZ, MIN_ABS_SCALE_FACTOR, MIN_DIVISION_MAGNITUDE +from .timing_memory_utils import memstats, time_passed, time_vmem_rss +from .types import BoolArray, FloatArray, IntArray + +__all__ = [ + "BOUNDARY_FUZZ", + "BoolArray", + "FloatArray", + "IntArray", + "MIN_ABS_SCALE_FACTOR", + "MIN_DIVISION_MAGNITUDE", + "array_filter", + "array_one_shorter", + "array_position", + "array_position_if", + "cross_product_3d", + "det_and_inv", + "determinant", + "inverse", + "memstats", + "time_passed", + "time_vmem_rss", +] diff --git a/src/nmesh/utils/array_list_utils.py b/src/nmesh/utils/array_list_utils.py index ce55e63..e8c5480 100644 --- a/src/nmesh/utils/array_list_utils.py +++ b/src/nmesh/utils/array_list_utils.py @@ -1,10 +1,13 @@ +from collections.abc import Callable, Sequence +from typing import Any, TypeVar + import numpy as np -from typing import Callable, Sequence, TypeVar from numpy.typing import NDArray -T = TypeVar('T') +T = TypeVar("T") + -def array_filter(p: Callable[[T], bool], arr: Sequence[T]) -> NDArray: +def array_filter(p: Callable[[T], bool], arr: Sequence[T]) -> NDArray[Any]: """Filter array elements using a predicate function. Args: @@ -18,6 +21,7 @@ def array_filter(p: Callable[[T], bool], arr: Sequence[T]) -> NDArray: mask = np.array([p(x) for x in arr_np], dtype=bool) return arr_np[mask] + def array_position(x: T, arr: Sequence[T], start: int = 0) -> int: """Find first position of element x in array starting from index start. @@ -36,6 +40,7 @@ def array_position(x: T, arr: Sequence[T], start: int = 0) -> int: return int(indices[0] + start) return -1 + def array_position_if(p: Callable[[T], bool], arr: Sequence[T], start: int = 0) -> int: """Find first position where predicate p is True, starting from index start. @@ -55,7 +60,8 @@ def array_position_if(p: Callable[[T], bool], arr: Sequence[T], start: int = 0) return int(indices[0] + start) return -1 -def array_one_shorter(arr: Sequence[T], pos: int) -> NDArray: + +def array_one_shorter(arr: Sequence[T], pos: int) -> NDArray[Any]: """Remove element at position pos from array. Args: @@ -67,6 +73,7 @@ def array_one_shorter(arr: Sequence[T], pos: int) -> NDArray: """ return np.delete(np.asanyarray(arr), pos) + def determinant(mx: Sequence[Sequence[float]]) -> float: """Compute determinant of a matrix. @@ -78,7 +85,8 @@ def determinant(mx: Sequence[Sequence[float]]) -> float: """ return float(np.linalg.det(np.asanyarray(mx))) -def inverse(mx: Sequence[Sequence[float]]) -> NDArray: + +def inverse(mx: Sequence[Sequence[float]]) -> NDArray[np.float64]: """Compute inverse of a matrix. Args: @@ -89,7 +97,10 @@ def inverse(mx: Sequence[Sequence[float]]) -> NDArray: """ return np.linalg.inv(np.asanyarray(mx)) -def det_and_inv(mx: Sequence[Sequence[float]]) -> tuple[float, NDArray]: + +def det_and_inv( + mx: Sequence[Sequence[float]], +) -> tuple[float, NDArray[np.float64]]: """Compute determinant and inverse of a matrix simultaneously. Args: @@ -103,7 +114,8 @@ def det_and_inv(mx: Sequence[Sequence[float]]) -> tuple[float, NDArray]: inv = np.linalg.inv(arr) return float(det), inv -def cross_product_3d(v1: Sequence[float], v2: Sequence[float]) -> NDArray: + +def cross_product_3d(v1: Sequence[float], v2: Sequence[float]) -> NDArray[np.float64]: """Compute 3D cross product of two vectors. Args: diff --git a/src/nmesh/utils/timing_memory_utils.py b/src/nmesh/utils/timing_memory_utils.py index 6892ffd..5c6545a 100644 --- a/src/nmesh/utils/timing_memory_utils.py +++ b/src/nmesh/utils/timing_memory_utils.py @@ -5,9 +5,10 @@ log = logging.getLogger(__name__) -_time_zero = None +_time_zero: float | None = None -def time_passed(): + +def time_passed() -> float: """Returns elapsed time since the first call to this function.""" global _time_zero if _time_zero is None: @@ -15,7 +16,8 @@ def time_passed(): return 0.0 return time.time() - _time_zero -def memstats(self_status_file="/proc/self/status"): + +def memstats(self_status_file: str = "/proc/self/status") -> list[float]: """Reads VmSize and VmRSS from /proc/self/status (Linux).""" vmsize_vmrss = [0.0, 0.0] if not os.path.exists(self_status_file): @@ -24,7 +26,7 @@ def memstats(self_status_file="/proc/self/status"): # Matches "VmSize: 1234 kB" or "VmRSS: 5678 KB". re_pattern = re.compile(r"^(VmSize|VmRSS):\s+(\d+)\s+[kK][bB]", re.MULTILINE) try: - with open(self_status_file, "r") as fd: + with open(self_status_file) as fd: content = fd.read() found = 0 for match in re_pattern.finditer(content): @@ -41,7 +43,8 @@ def memstats(self_status_file="/proc/self/status"): log.debug(f"Failed to read memstats: {e}") return vmsize_vmrss -def time_vmem_rss(): + +def time_vmem_rss() -> tuple[float, float, float]: """Returns (elapsed_time, vmem, rss) where memory is in KB.""" t = time_passed() mem = memstats() diff --git a/src/si/constants.py b/src/si/constants.py index e842f62..cd3a531 100644 --- a/src/si/constants.py +++ b/src/si/constants.py @@ -1,48 +1,49 @@ -""" -Original file: https://github.com/nmag-project/nmag-src/blob/master/interface/nsim/si_units/si.py -Some useful SI constants, defined using the pint library's authoritative values. - -This module provides common physical constants as pint Quantity objects -for direct use in calculations. It replaces hard-coded values with references -to the pint library's built-in constants, which are based on up-to-date -CODATA values. -https://github.com/hgrecco/pint/blob/master/pint/default_en.txt -https://github.com/hgrecco/pint/blob/master/pint/constants_en.txt -""" -from si.physical import ureg, SI - -# --- Base and Common Units --- -kilogram = ureg.kilogram -meter = ureg.meter -metre = meter # alternative spelling -Ampere = ureg.ampere -Kelvin = ureg.kelvin -second = ureg.second -candela = ureg.candela -mol = ureg.mole -Joule = ureg.joule -Newton = ureg.newton -Tesla = ureg.tesla -Gauss = ureg.gauss - -# --- Physical Constants (from pint's library) --- - -mu0 = ureg.mu_0 -Oersted = ureg.oersted -Oe = Oersted -bohr_magneton = ureg.bohr_magneton -positron_charge = ureg.e -electron_charge = -1 * ureg.e -boltzmann_constant = ureg.k -plank_constant = ureg.h -reduced_plank_constant = ureg.hbar - -# --- Custom or Specific Values --- - -degrees_per_ns = 1 * ureg.degree / ureg.nanosecond - -gamma_e = (ureg.g_e * ureg.e) / (2 * ureg.m_e) -# This constant, often used in micromagnetics, is the product of the -# electron gyromagnetic ratio (gamma_e) and the vacuum permeability (mu_0). -# The negative sign is a convention used in the LLG equation. -gamma0 = -1 * gamma_e * ureg.mu_0 +""" +Original file: https://github.com/nmag-project/nmag-src/blob/master/interface/nsim/si_units/si.py +Some useful SI constants, defined using the pint library's authoritative values. + +This module provides common physical constants as pint Quantity objects +for direct use in calculations. It replaces hard-coded values with references +to the pint library's built-in constants, which are based on up-to-date +CODATA values. +https://github.com/hgrecco/pint/blob/master/pint/default_en.txt +https://github.com/hgrecco/pint/blob/master/pint/constants_en.txt +""" + +from si.physical import ureg + +# --- Base and Common Units --- +kilogram = ureg.kilogram +meter = ureg.meter +metre = meter # alternative spelling +Ampere = ureg.ampere +Kelvin = ureg.kelvin +second = ureg.second +candela = ureg.candela +mol = ureg.mole +Joule = ureg.joule +Newton = ureg.newton +Tesla = ureg.tesla +Gauss = ureg.gauss + +# --- Physical Constants (from pint's library) --- + +mu0 = ureg.mu_0 +Oersted = ureg.oersted +Oe = Oersted +bohr_magneton = ureg.bohr_magneton +positron_charge = ureg.e +electron_charge = -1 * ureg.e +boltzmann_constant = ureg.k +plank_constant = ureg.h +reduced_plank_constant = ureg.hbar + +# --- Custom or Specific Values --- + +degrees_per_ns = 1 * ureg.degree / ureg.nanosecond + +gamma_e = (ureg.g_e * ureg.e) / (2 * ureg.m_e) +# This constant, often used in micromagnetics, is the product of the +# electron gyromagnetic ratio (gamma_e) and the vacuum permeability (mu_0). +# The negative sign is a convention used in the LLG equation. +gamma0 = -1 * gamma_e * ureg.mu_0 diff --git a/src/si/physical.py b/src/si/physical.py index 5dad4f0..4d37cbc 100644 --- a/src/si/physical.py +++ b/src/si/physical.py @@ -6,244 +6,717 @@ replicating the interface of a legacy Physical/SI class. """ -import pint +from __future__ import annotations -ureg = pint.get_application_registry() +import math +from dataclasses import dataclass +from functools import cache +from typing import Any, TypeAlias, cast -class Physical: - """ - A wrapper class around the pint library to provide backward compatibility - with a legacy Physical quantity class. - - It supports initialization with a value and a unit string, or a value - and a list-based dimension format (e.g., ['m', 1, 's', -1]). - All arithmetic and comparison operations are handled by the underlying - pint.Quantity object. - """ - - def __init__(self, value, dimensions=None): - """ - Creates a Physical object. - - :param value: The magnitude of the quantity. Can also be a pint.Quantity - object, or a string like "10 m/s". - :param dimensions: The units of the quantity. Can be a string - (e.g., "m/s", "J/mol"), a list of units and - their powers (e.g., ['m', 1, 's', -1]), or None. - If None and value is a string, value will be parsed - as the quantity. - """ - # Case 1: The value is already a pint Quantity/Unit object - # A pint unit is a quntity with a unit like meter, second, etc. attached. - # This allows us to pass a pint unit or quantity directly. - if isinstance(value, ureg.Quantity) or isinstance(value, ureg.Unit): - self._quantity = value - return - - # Case 2: Shorthand initialization like Physical("m/s") - # In the old class, this was equivalent to Physical(1.0, "m/s") - if dimensions is None and isinstance(value, str): - self._quantity = ureg.Quantity(value) - return - - # Ensure value is a numeric type for pint - val = float(value) - - # Case 3: Standard initialization with string dimensions or pint unit - # e.g., Physical(10, "m/s") or Physical(10, ureg.meter) - if dimensions is None or (isinstance(dimensions, str) or isinstance(dimensions, ureg.Unit)): - unit_str = dimensions if dimensions is not None else "" - self._quantity = ureg.Quantity(val, unit_str) - - # Case 4: Handle the legacy list format, e.g., ['m', 1, 'kg', 1, 's', -2] - elif isinstance(dimensions, list): - if not dimensions: # Empty list means dimensionless - self._quantity = ureg.Quantity(val) - else: - if len(dimensions) % 2 != 0: - raise ValueError( - "Physical quantity: Bad dimensions list given! " - "Must contain pairs of unit names and powers. " - f"Received: {dimensions}" - ) - # Convert the list to a pint-compatible string "m**1 * kg**1 * s**-2" - unit_str_parts = [] - for i in range(0, len(dimensions), 2): - unit = dimensions[i] - power = dimensions[i+1] - unit_str_parts.append(f"{unit}**{power}") - - unit_string = " * ".join(unit_str_parts) - self._quantity = ureg.Quantity(val, unit_string) - else: - raise TypeError( - "Unsupported type for 'dimensions'. " - f"Must be str, list, or None, but got {type(dimensions)}." - ) - - @property - def magnitude(self): - """Returns the numerical magnitude of the quantity.""" - return self._quantity.magnitude - - value = magnitude # Alias for backward compatibility - - def dens_str(self): - """ - Provide a dense string describing the object, similar to the original class. - Example: <1e+06A/m> - """ - # Handle the special case for a dimensionless quantity of 1.0 - if self._quantity.dimensionless and self._quantity.magnitude == 1.0: - return "<1>" - - # For non-dimensionless quantities with a magnitude of 1.0, omit the number. - # pint.Unit will not have a magnitude, even though it can be thought of as it having a magnitude of 1.0. - if hasattr(self._quantity, 'magnitude') and self._quantity.magnitude == 1.0: - # Format just the units - compact_str = f"{self._quantity.units:~}" - else: - # Format the whole quantity - compact_str = f"{self._quantity:~}" - - # Clean up the formatting for backward compatibility - cleaned_str = compact_str.replace(" ", "").replace("**", "^") - return f"<{cleaned_str}>" - - def in_units_of(self, unit_quantity): - """ - Expresses the object in multiples of 'unit_quantity' and returns the - resulting magnitude as a float. - - This is equivalent to performing a division and taking the magnitude - of the dimensionless result. - - :param unit_quantity: Another Physical object whose units to convert to. - :return: A float representing the magnitude in the new units. - """ - if not isinstance(unit_quantity, Physical): - raise TypeError( - "Argument must be an instance of Physical." - ) - - # The original class implicitly worked by dividing the magnitudes of - # quantities that were already converted to base SI units. This - # implementation faithfully reproduces that logic. - if not self._quantity.is_compatible_with(unit_quantity._quantity): - raise pint.errors.DimensionalityError( - self._quantity.units, unit_quantity._quantity.units - ) - - self_base_mag = self._quantity.to_base_units().magnitude - unit_base_mag = unit_quantity._quantity.to_base_units().magnitude - - if unit_base_mag == 0: - raise ZeroDivisionError("Cannot express in units of a zero quantity.") - - return self_base_mag / unit_base_mag - - # Helper method to safely get the pint.Quantity from another object - def _unwrap(self, other): - if isinstance(other, Physical): - return other._quantity - return other - - # --- Magic Operators --- - - def __str__(self): - """'Nice' presentation of the pint object.""" - # We can customize this, but pint's default is quite good. - # Example: "10.0 meter / second" - return str(self._quantity) - - def __repr__(self): - """ - Returns a representation of the object that can be used to - re-instantiate it using eval. - We probably don't have to do this for dens_str since it is just - used for printing into large files. If we do have to do that - eventually, we might need to change the logic for that function. - """ - # This creates a string like 'Physical(10.0, "meter / second")' - return f"Physical({self._quantity.magnitude}, '{self._quantity.units}')" +# Pint's runtime classes are constructed by the active application registry. +# Keep the dynamic boundary explicit while retaining concrete types internally. +_pint_module_cache: Any | None = None +_pint_registry_cache: Any | None = None +_pint_quantity_type_cache: Any | None = None +_pint_unit_type_cache: Any | None = None +_pint_quantity_factory_cache: Any | None = None - def __float__(self): - """ - Return the value of the Physical Object if it is dimensionless. - """ - if not self._quantity.dimensionless: - raise pint.errors.DimensionalityError( - self._quantity.units, 'dimensionless', - extra_msg="\nObjects can be converted to float only when they are dimensionless." - ) - return self._quantity.magnitude - - # --- Unary Operators --- - - def __abs__(self): - return Physical(abs(self._quantity)) - - def __neg__(self): - return Physical(-1 * self._quantity) - - def __pos__(self): - return Physical(self._quantity) - - # --- Comparison Operators --- - - def __lt__(self, other): - return self._quantity < self._unwrap(other) - - def __le__(self, other): - return self._quantity <= self._unwrap(other) +Dims: TypeAlias = tuple[int, int, int, int, int, int, int] - def __gt__(self, other): - return self._quantity > self._unwrap(other) - def __ge__(self, other): - return self._quantity >= self._unwrap(other) +class _LazyUnitRegistry: + """Proxy Pint's application registry without constructing it at import time.""" - def __eq__(self, other): - try: - return self._quantity == self._unwrap(other) - except pint.errors.DimensionalityError: - # If units are not compatible, they cannot be equal. - return False + def __getattr__(self, name: str) -> Any: + return getattr(_pint_registry(), name) - def __ne__(self, other): - try: - return self._quantity != self._unwrap(other) - except pint.errors.DimensionalityError: - # If units are not compatible, they are not equal. - return True - # --- Binary Arithmetic Operators --- +ureg = _LazyUnitRegistry() - def __add__(self, other): - return Physical(self._quantity + self._unwrap(other)) - - def __radd__(self, other): - return Physical(self._unwrap(other) + self._quantity) - def __sub__(self, other): - return Physical(self._quantity - self._unwrap(other)) +@dataclass(frozen=True) +class _FastUnit: + dims: Dims + scale: float + compact: str + long: str + + def __str__(self) -> str: + return self.long + + def __format__(self, spec: str) -> str: + return self.compact if spec == "~" else self.long + + +class _FastQuantity: + """Small exact SI quantity used for common MVP units before Pint is needed.""" + + def __init__(self, magnitude: int | float, unit: _FastUnit) -> None: + self.magnitude = float(magnitude) + self.units = unit + + @property + def dimensionless(self) -> bool: + return all(power == 0 for power in self.units.dims) + + def is_compatible_with(self, other: Any) -> bool: + if isinstance(other, _FastQuantity): + return self.units.dims == other.units.dims + if isinstance(other, (int, float)): + return self.dimensionless or float(other) == 0.0 + return self._as_pint().is_compatible_with(other) + + def to_base_units(self) -> _FastQuantity: + return _FastQuantity( + self.magnitude * self.units.scale, + _unit_from_dims(self.units.dims), + ) + + def _as_pint(self) -> Any: + # Generated fast units carry their factor separately from their compact + # spelling. Convert through base SI so the factor is never lost. + if self.dimensionless: + return _pint_registry().Quantity(self.magnitude * self.units.scale) + return _pint_registry().Quantity( + self.magnitude * self.units.scale, + _pint_registry().Unit(_compact_from_dims(self.units.dims)), + ) + + def __abs__(self) -> _FastQuantity: + return _FastQuantity(abs(self.magnitude), self.units) + + def __neg__(self) -> _FastQuantity: + return _FastQuantity(-self.magnitude, self.units) + + def __pos__(self) -> _FastQuantity: + return _FastQuantity(self.magnitude, self.units) + + def __eq__(self, other: object) -> bool: + if isinstance(other, _FastQuantity): + return self.units.dims == other.units.dims and math.isclose( + self.magnitude * self.units.scale, + other.magnitude * other.units.scale, + rel_tol=0.0, + abs_tol=0.0, + ) + if isinstance(other, (int, float)): + return self.magnitude == 0.0 and float(other) == 0.0 + return self._as_pint() == other + + def __ne__(self, other: object) -> bool: + return not self == other + + def __lt__(self, other: Any) -> bool: + return self.to_base_units().magnitude < _fast_base_magnitude(other, self.units.dims) + + def __le__(self, other: Any) -> bool: + return self.to_base_units().magnitude <= _fast_base_magnitude(other, self.units.dims) + + def __gt__(self, other: Any) -> bool: + return self.to_base_units().magnitude > _fast_base_magnitude(other, self.units.dims) + + def __ge__(self, other: Any) -> bool: + return self.to_base_units().magnitude >= _fast_base_magnitude(other, self.units.dims) + + def __add__(self, other: Any) -> Any: + if isinstance(other, (int, float)) and float(other) == 0.0: + return _FastQuantity(self.magnitude, self.units) + other_base = _fast_base_magnitude(other, self.units.dims) + return _FastQuantity( + (self.to_base_units().magnitude + other_base) / self.units.scale, + self.units, + ) + + def __radd__(self, other: Any) -> Any: + return self.__add__(other) + + def __sub__(self, other: Any) -> Any: + if isinstance(other, (int, float)) and float(other) == 0.0: + return _FastQuantity(self.magnitude, self.units) + other_base = _fast_base_magnitude(other, self.units.dims) + return _FastQuantity( + (self.to_base_units().magnitude - other_base) / self.units.scale, + self.units, + ) + + def __rsub__(self, other: Any) -> Any: + if isinstance(other, (int, float)) and float(other) == 0.0: + return _FastQuantity(-self.magnitude, self.units) + return _quantity_from_pint(other) - self + + def __mul__(self, other: Any) -> Any: + if isinstance(other, (int, float)): + return _FastQuantity(self.magnitude * float(other), self.units) + if isinstance(other, _FastQuantity): + dims = _add_dims(self.units.dims, other.units.dims) + unit = _unit_from_dims(dims, self.units.scale * other.units.scale) + return _FastQuantity(self.magnitude * other.magnitude, unit) + return self._as_pint() * other + + def __rmul__(self, other: Any) -> Any: + return self.__mul__(other) + + def __truediv__(self, other: Any) -> Any: + if isinstance(other, (int, float)): + return _FastQuantity(self.magnitude / float(other), self.units) + if isinstance(other, _FastQuantity): + dims = _subtract_dims(self.units.dims, other.units.dims) + unit = _unit_from_dims(dims, self.units.scale / other.units.scale) + return _FastQuantity(self.magnitude / other.magnitude, unit) + return self._as_pint() / other + + def __rtruediv__(self, other: Any) -> Any: + if isinstance(other, (int, float)): + dims = _negate_dims(self.units.dims) + unit = _unit_from_dims(dims, 1.0 / self.units.scale) + return _FastQuantity(float(other) / self.magnitude, unit) + return other / self._as_pint() + + def __pow__(self, exponent: int | float) -> Any: + if not isinstance(exponent, int): + return self._as_pint() ** exponent + dims = _multiply_dims(self.units.dims, exponent) + unit = _unit_from_dims(dims, self.units.scale**exponent) + return _FastQuantity(self.magnitude**exponent, unit) + + def __str__(self) -> str: + if self.dimensionless: + return str(self.magnitude) + return f"{self.magnitude} {self.units.long}" + + def __format__(self, spec: str) -> str: + if spec == "~": + if self.dimensionless: + return str(self.magnitude) + return f"{self.magnitude} {self.units.compact}" + return str(self) + + +_DIMS_DIMLESS: Dims = (0, 0, 0, 0, 0, 0, 0) +_DIMS_KG: Dims = (1, 0, 0, 0, 0, 0, 0) +_DIMS_M: Dims = (0, 1, 0, 0, 0, 0, 0) +_DIMS_S: Dims = (0, 0, 1, 0, 0, 0, 0) +_DIMS_A: Dims = (0, 0, 0, 1, 0, 0, 0) +_DIMS_K: Dims = (0, 0, 0, 0, 1, 0, 0) +_DIMS_MOL: Dims = (0, 0, 0, 0, 0, 1, 0) +_DIMS_CD: Dims = (0, 0, 0, 0, 0, 0, 1) + + +def _dims( + *, + kg: int = 0, + m: int = 0, + s: int = 0, + A: int = 0, + K: int = 0, + mol: int = 0, + cd: int = 0, +) -> Dims: + return (kg, m, s, A, K, mol, cd) + + +def _add_dims(left: Dims, right: Dims) -> Dims: + return ( + left[0] + right[0], + left[1] + right[1], + left[2] + right[2], + left[3] + right[3], + left[4] + right[4], + left[5] + right[5], + left[6] + right[6], + ) + + +def _subtract_dims(left: Dims, right: Dims) -> Dims: + return ( + left[0] - right[0], + left[1] - right[1], + left[2] - right[2], + left[3] - right[3], + left[4] - right[4], + left[5] - right[5], + left[6] - right[6], + ) + + +def _negate_dims(dims: Dims) -> Dims: + return (-dims[0], -dims[1], -dims[2], -dims[3], -dims[4], -dims[5], -dims[6]) + + +def _multiply_dims(dims: Dims, factor: int) -> Dims: + return ( + dims[0] * factor, + dims[1] * factor, + dims[2] * factor, + dims[3] * factor, + dims[4] * factor, + dims[5] * factor, + dims[6] * factor, + ) + + +_FAST_UNITS: dict[str, _FastUnit] = { + "": _FastUnit(_DIMS_DIMLESS, 1.0, "", "dimensionless"), + "1": _FastUnit(_DIMS_DIMLESS, 1.0, "", "dimensionless"), + "m": _FastUnit(_DIMS_M, 1.0, "m", "meter"), + "meter": _FastUnit(_DIMS_M, 1.0, "m", "meter"), + "km": _FastUnit(_DIMS_M, 1000.0, "km", "kilometer"), + "kilometer": _FastUnit(_DIMS_M, 1000.0, "km", "kilometer"), + "kg": _FastUnit(_DIMS_KG, 1.0, "kg", "kilogram"), + "kilogram": _FastUnit(_DIMS_KG, 1.0, "kg", "kilogram"), + "s": _FastUnit(_DIMS_S, 1.0, "s", "second"), + "second": _FastUnit(_DIMS_S, 1.0, "s", "second"), + "A": _FastUnit(_DIMS_A, 1.0, "A", "ampere"), + "ampere": _FastUnit(_DIMS_A, 1.0, "A", "ampere"), + "K": _FastUnit(_DIMS_K, 1.0, "K", "kelvin"), + "kelvin": _FastUnit(_DIMS_K, 1.0, "K", "kelvin"), + "mol": _FastUnit(_DIMS_MOL, 1.0, "mol", "mole"), + "mole": _FastUnit(_DIMS_MOL, 1.0, "mol", "mole"), + "cd": _FastUnit(_DIMS_CD, 1.0, "cd", "candela"), + "candela": _FastUnit(_DIMS_CD, 1.0, "cd", "candela"), + "A/m": _FastUnit(_dims(A=1, m=-1), 1.0, "A/m", "ampere / meter"), + "A/m**2": _FastUnit(_dims(A=1, m=-2), 1.0, "A/m**2", "ampere / meter ** 2"), + "A/m/s": _FastUnit(_dims(A=1, m=-1, s=-1), 1.0, "A/m/s", "ampere / meter / second"), + "m/s": _FastUnit(_dims(m=1, s=-1), 1.0, "m/s", "meter / second"), + "km/h": _FastUnit(_dims(m=1, s=-1), 1000.0 / 3600.0, "km/h", "kilometer / hour"), + "1/s": _FastUnit(_dims(s=-1), 1.0, "1/s", "1 / second"), + "m/A/s": _FastUnit(_dims(m=1, A=-1, s=-1), 1.0, "m/A/s", "meter / ampere / second"), + "J": _FastUnit(_dims(kg=1, m=2, s=-2), 1.0, "J", "joule"), + "J/m": _FastUnit(_dims(kg=1, m=1, s=-2), 1.0, "J/m", "joule / meter"), + "J/m**3": _FastUnit(_dims(kg=1, m=-1, s=-2), 1.0, "J/m**3", "joule / meter ** 3"), + "N": _FastUnit(_dims(kg=1, m=1, s=-2), 1.0, "N", "newton"), + "N*m": _FastUnit(_dims(kg=1, m=2, s=-2), 1.0, "m*N", "meter * newton"), +} + + +def _normalized_unit_string(unit: str) -> str: + return unit.strip().replace(" ", "").replace("^", "**") + + +def _unit_from_dims( + dims: Dims, + scale: float = 1.0, +) -> _FastUnit: + for unit in _FAST_UNITS.values(): + if unit.dims == dims and unit.scale == scale: + return unit + compact = _compact_from_dims(dims) + return _FastUnit(dims, scale, compact, compact or "dimensionless") + + +def _compact_from_dims(dims: Dims) -> str: + names = ("kg", "m", "s", "A", "K", "mol", "cd") + parts: list[str] = [] + for name, power in zip(names, dims, strict=True): + if power == 0: + continue + parts.append(name if power == 1 else f"{name}**{power}") + return "*".join(parts) + + +def _fast_unit(unit: str) -> _FastUnit | None: + return _FAST_UNITS.get(_normalized_unit_string(unit)) + + +def _fast_dimensions_list(dimensions: list[Any]) -> _FastUnit | None: + if not dimensions: + return _FAST_UNITS[""] + if len(dimensions) % 2 != 0: + raise ValueError( + "Physical quantity: Bad dimensions list given! " + "Must contain pairs of unit names and powers. " + f"Received: {dimensions}" + ) + dims = _DIMS_DIMLESS + for i in range(0, len(dimensions), 2): + unit = _fast_unit(str(dimensions[i])) + if unit is None or unit.scale != 1.0: + return None + power = dimensions[i + 1] + if not isinstance(power, int): + return None + dims = _add_dims(dims, _multiply_dims(unit.dims, power)) + return _unit_from_dims(dims) + + +def _fast_base_magnitude(other: Any, expected_dims: Dims) -> float: + if isinstance(other, _FastQuantity): + if other.units.dims != expected_dims: + raise _pint_module().errors.DimensionalityError( + other.units, + _unit_from_dims(expected_dims), + ) + return other.to_base_units().magnitude + if isinstance(other, (int, float)): + if any(expected_dims) and float(other) != 0.0: + raise _pint_module().errors.DimensionalityError( + "dimensionless", + _unit_from_dims(expected_dims), + ) + return float(other) + return _quantity_from_pint(other).to_base_units().magnitude + + +def _quantity_from_pint(value: Any) -> Any: + if isinstance(value, _FastQuantity): + return value + quantity_type, unit_type, quantity_factory = _pint_handles() + if isinstance(value, unit_type): + return quantity_factory(1.0, value) + if isinstance(value, quantity_type): + return value + return quantity_factory(value) + + +def _as_pint_quantity(value: Any) -> Any: + if isinstance(value, _FastQuantity): + return value._as_pint() + return value + + +def _quantities_compatible(left: Any, right: Any) -> bool: + if isinstance(left, _FastQuantity) and isinstance(right, _FastQuantity): + return left.units.dims == right.units.dims + return _as_pint_quantity(left).is_compatible_with(_as_pint_quantity(right)) + + +def _base_magnitude(value: Any) -> float: + if isinstance(value, _FastQuantity): + return value.to_base_units().magnitude + return value.to_base_units().magnitude + + +def _pint_module() -> Any: + global _pint_module_cache + if _pint_module_cache is None: + import pint + + _pint_module_cache = pint + return _pint_module_cache + + +def _pint_registry() -> Any: + global _pint_registry_cache + if _pint_registry_cache is None: + _pint_registry_cache = _pint_module().get_application_registry() + return _pint_registry_cache + + +def _pint_handles() -> tuple[Any, Any, Any]: + global _pint_quantity_factory_cache, _pint_quantity_type_cache, _pint_unit_type_cache + if ( + _pint_quantity_type_cache is None + or _pint_unit_type_cache is None + or _pint_quantity_factory_cache is None + ): + registry = _pint_registry() + quantity = registry.Quantity + _pint_quantity_factory_cache = quantity + _pint_quantity_type_cache = quantity + _pint_unit_type_cache = registry.Unit + assert _pint_quantity_type_cache is not None + assert _pint_unit_type_cache is not None + assert _pint_quantity_factory_cache is not None + return _pint_quantity_type_cache, _pint_unit_type_cache, _pint_quantity_factory_cache + + +@cache +def _pint_unit(unit: str) -> Any: + return _pint_registry().Unit(unit) + - def __rsub__(self, other): - return Physical(self._unwrap(other) - self._quantity) +class Physical: + """Represent a numeric value with checked physical dimensions. - def __mul__(self, other): - return Physical(self._quantity * self._unwrap(other)) + ``nmag.SI`` is an alias for this class and is the preferred spelling in + simulation scripts. Common units use a lightweight internal representation; + other units are delegated to Pint without changing the public behavior. - def __rmul__(self, other): - return Physical(self._unwrap(other) * self._quantity) + Args: + value: Numeric magnitude, another compatible quantity, a unit string + such as ``"A/m"``, or a complete quantity string. + dimensions: Unit string, legacy ``[unit, power, ...]`` list, or ``None``. - def __truediv__(self, other): - return Physical(self._quantity / self._unwrap(other)) + Raises: + TypeError: If the value or dimensions form is unsupported. + ValueError: If a legacy dimensions list is malformed. + """ - def __rtruediv__(self, other): - return Physical(self._unwrap(other) / self._quantity) + _quantity: Any + + def __init__(self, value: Any, dimensions: Any | None = None) -> None: + """Create a dimensionally checked quantity.""" + fast_quantity = self._fast_quantity(value, dimensions) + if fast_quantity is not None: + self._quantity = fast_quantity + return + self._quantity = self._pint_quantity(value, dimensions) + + @staticmethod + def _fast_quantity(value: Any, dimensions: Any | None) -> _FastQuantity | None: + if isinstance(value, _FastQuantity): + return value + if dimensions is None: + return Physical._fast_quantity_without_dimensions(value) + return Physical._fast_quantity_with_dimensions(value, dimensions) + + @staticmethod + def _fast_quantity_without_dimensions(value: Any) -> _FastQuantity | None: + if isinstance(value, str): + unit = _fast_unit(value) + return None if unit is None else _FastQuantity(1.0, unit) + if isinstance(value, (int, float)): + return _FastQuantity(float(value), _FAST_UNITS[""]) + return None + + @staticmethod + def _fast_quantity_with_dimensions(value: Any, dimensions: Any) -> _FastQuantity | None: + if isinstance(dimensions, str): + unit = _fast_unit(dimensions) + return None if unit is None else _FastQuantity(value, unit) + if isinstance(dimensions, list): + unit = _fast_dimensions_list(cast(list[Any], dimensions)) + return None if unit is None else _FastQuantity(value, unit) + return None + + @staticmethod + def _pint_quantity(value: Any, dimensions: Any | None) -> Any: + quantity_type, unit_type, quantity_factory = _pint_handles() + if isinstance(value, quantity_type): + return value + if isinstance(value, unit_type): + return quantity_factory(1.0, value) + if dimensions is None and isinstance(value, str): + return Physical._pint_string_quantity(value, quantity_factory) + return Physical._pint_value_with_dimensions(value, dimensions, unit_type, quantity_factory) + + @staticmethod + def _pint_string_quantity(value: str, quantity_factory: Any) -> Any: + try: + return quantity_factory(1.0, _pint_unit(value)) + except Exception: + return quantity_factory(value) + + @staticmethod + def _pint_value_with_dimensions( + value: Any, + dimensions: Any | None, + unit_type: Any, + quantity_factory: Any, + ) -> Any: + magnitude = float(value) + if dimensions is None: + return quantity_factory(magnitude) + if isinstance(dimensions, str): + return quantity_factory(magnitude, _pint_unit(dimensions)) + if isinstance(dimensions, unit_type): + return quantity_factory(magnitude, dimensions) + if isinstance(dimensions, list): + return Physical._pint_list_dimensions( + magnitude, cast(list[Any], dimensions), quantity_factory + ) + raise TypeError( + "Unsupported type for 'dimensions'. " + f"Must be str, list, or None, but got {type(dimensions)}." + ) + + @staticmethod + def _pint_list_dimensions( + magnitude: float, + dimensions: list[Any], + quantity_factory: Any, + ) -> Any: + if not dimensions: + return quantity_factory(magnitude) + if len(dimensions) % 2 != 0: + raise ValueError( + "Physical quantity: Bad dimensions list given! " + "Must contain pairs of unit names and powers. " + f"Received: {dimensions}" + ) + unit_string = " * ".join( + f"{dimensions[index]}**{dimensions[index + 1]}" + for index in range(0, len(dimensions), 2) + ) + return quantity_factory(magnitude, unit_string) + + @property + def magnitude(self) -> Any: + """Return the numeric magnitude in the quantity's stored unit.""" + return self._quantity.magnitude + + value = magnitude # Alias for backward compatibility + + def dens_str(self) -> str: + """Return a compact legacy-compatible representation such as ````.""" + # Handle the special case for a dimensionless quantity of 1.0 + if self._quantity.dimensionless and self._quantity.magnitude == 1.0: + return "<1>" + + # For non-dimensionless quantities with a magnitude of 1.0, omit the number. + # pint.Unit will not have a magnitude, even though it can be thought of as it having a magnitude of 1.0. + if hasattr(self._quantity, "magnitude") and self._quantity.magnitude == 1.0: + # Format just the units + compact_str = f"{self._quantity.units:~}" + else: + # Format the whole quantity + compact_str = f"{self._quantity:~}" + + # Clean up the formatting for backward compatibility + cleaned_str = compact_str.replace(" ", "").replace("**", "^") + return f"<{cleaned_str}>" + + def in_units_of(self, unit_quantity: object) -> float: + """Return the magnitude expressed in multiples of another quantity. + + Args: + unit_quantity: A :class:`Physical` representing one requested unit, + for example ``nmag.SI(1, "A/m")``. + + Returns: + Numeric magnitude in the requested unit. + + Raises: + TypeError: If ``unit_quantity`` is not a :class:`Physical`. + DimensionalityError: If the dimensions are incompatible. + """ + if not isinstance(unit_quantity, Physical): + raise TypeError("Argument must be an instance of Physical.") + + # The original class implicitly worked by dividing the magnitudes of + # quantities that were already converted to base SI units. This + # implementation faithfully reproduces that logic. + if not _quantities_compatible(self._quantity, unit_quantity._quantity): + raise _pint_module().errors.DimensionalityError( + self._quantity.units, unit_quantity._quantity.units + ) + + self_base_mag = _base_magnitude(self._quantity) + unit_base_mag = _base_magnitude(unit_quantity._quantity) + + if unit_base_mag == 0: + raise ZeroDivisionError("Cannot express in units of a zero quantity.") + + return self_base_mag / unit_base_mag + + # Helper method to safely get the pint.Quantity from another object + def _unwrap(self, other: Any) -> Any: + if isinstance(other, Physical): + other_quantity = other._quantity + if not isinstance(self._quantity, _FastQuantity) and isinstance( + other_quantity, _FastQuantity + ): + return other_quantity._as_pint() + return other_quantity + return other + + # --- Magic Operators --- + + def __str__(self) -> str: + """'Nice' presentation of the pint object.""" + # We can customize this, but pint's default is quite good. + # Example: "10.0 meter / second" + return str(self._quantity) + + def __repr__(self) -> str: + """ + Returns a representation of the object that can be used to + re-instantiate it using eval. + We probably don't have to do this for dens_str since it is just + used for printing into large files. If we do have to do that + eventually, we might need to change the logic for that function. + """ + # This creates a string like 'Physical(10.0, "meter / second")' + return f"Physical({self._quantity.magnitude}, '{self._quantity.units}')" + + def __float__(self) -> float: + """ + Return the value of the Physical Object if it is dimensionless. + """ + if not self._quantity.dimensionless: + raise _pint_module().errors.DimensionalityError( + self._quantity.units, + "dimensionless", + extra_msg="\nObjects can be converted to float only when they are dimensionless.", + ) + return _base_magnitude(self._quantity) + + # --- Unary Operators --- + + def __abs__(self) -> Physical: + return Physical(abs(self._quantity)) + + def __neg__(self) -> Physical: + return Physical(-1 * self._quantity) + + def __pos__(self) -> Physical: + return Physical(self._quantity) + + # --- Comparison Operators --- + + def __lt__(self, other: Any) -> bool: + return self._quantity < self._unwrap(other) + + def __le__(self, other: Any) -> bool: + return self._quantity <= self._unwrap(other) + + def __gt__(self, other: Any) -> bool: + return self._quantity > self._unwrap(other) + + def __ge__(self, other: Any) -> bool: + return self._quantity >= self._unwrap(other) + + def __eq__(self, other: object) -> bool: + try: + other_quantity = self._unwrap(other) + if isinstance(self._quantity, _FastQuantity) or isinstance( + other_quantity, + _FastQuantity, + ): + if isinstance(other_quantity, (int, float)): + return self._quantity == other_quantity + return _quantities_compatible(self._quantity, other_quantity) and math.isclose( + _base_magnitude(self._quantity), + _base_magnitude(other_quantity), + rel_tol=0.0, + abs_tol=0.0, + ) + return self._quantity == other_quantity + except _pint_module().errors.DimensionalityError: + # If units are not compatible, they cannot be equal. + return False + + def __ne__(self, other: object) -> bool: + return not self == other + + # --- Binary Arithmetic Operators --- + + def __add__(self, other: Any) -> Physical: + return Physical(self._quantity + self._unwrap(other)) + + def __radd__(self, other: Any) -> Physical: + return Physical(self._unwrap(other) + self._quantity) + + def __sub__(self, other: Any) -> Physical: + return Physical(self._quantity - self._unwrap(other)) + + def __rsub__(self, other: Any) -> Physical: + return Physical(self._unwrap(other) - self._quantity) + + def __mul__(self, other: Any) -> Physical: + return Physical(self._quantity * self._unwrap(other)) + + def __rmul__(self, other: Any) -> Physical: + return Physical(self._unwrap(other) * self._quantity) + + def __truediv__(self, other: Any) -> Physical: + return Physical(self._quantity / self._unwrap(other)) + + def __rtruediv__(self, other: Any) -> Physical: + return Physical(self._unwrap(other) / self._quantity) + + def __pow__(self, exponent: int | float) -> Physical: + return Physical(self._quantity**exponent) - def __pow__(self, exponent): - return Physical(self._quantity ** exponent) SI = Physical diff --git a/src/si/py.typed b/src/si/py.typed new file mode 100644 index 0000000..534be2b --- /dev/null +++ b/src/si/py.typed @@ -0,0 +1 @@ +# Marker for PEP 561 inline type information. diff --git a/src/simulation/__init__.py b/src/simulation/__init__.py new file mode 100644 index 0000000..1a71ea7 --- /dev/null +++ b/src/simulation/__init__.py @@ -0,0 +1 @@ +"""Simulation support modules for the nmag Python 3 rewrite.""" diff --git a/src/simulation/clock.py b/src/simulation/clock.py index b8a22c4..c0dcf58 100644 --- a/src/simulation/clock.py +++ b/src/simulation/clock.py @@ -1,86 +1,237 @@ -from si.physical import SI -from dataclasses import dataclass, field -from typing import Dict, Any, Optional, Tuple, List -from tabulate import tabulate - -def fmt_time(t: SI, fmt_ps: str = ".2f", fmt_ns: str = ".2f") -> str: - """Formats an SI time object into picoseconds or nanoseconds.""" - t_ps = float(t / SI(1e-12, "s")) - - ps_str = f"{t_ps:{fmt_ps}}" - ns_str = f"{(t_ps / 1000.0):{fmt_ns}}" - - return f"{ps_str} ps" if t_ps < 100.0 else f"{ns_str} ns" - -@dataclass -class SimulationClock: - """ - This object specifies all the parameters which define the current time - in the simulation, such as the simulation time, step number, ... - - Attributes: - id: Unique identifier for data saved. Incremented on save. - stage: Stage number. Increments when the external field changes. - step: Total number of steps performed (always increases). - stage_step: Step number from the beginning of the current stage. - zero_stage_step: The value of 'step' at the beginning of the stage. - time: Total simulation time (always increases). - stage_time: The simulation time from the beginning of the stage. - zero_stage_time: The value of 'time' at the beginning of the stage. - real_time: The real world time used for advancing time. - last_step_dt_si: Last time step's length in SI units. - convergence: Flag indicating if convergence is reached. - stage_end: Flag indicating the end of a stage. - exit_hysteresis: Flag to signal exit from hysteresis loop. - """ - id: int = -1 - stage: int = 1 - step: int = 0 - time: SI = field(default_factory=lambda: SI(0.0, "s")) - stage_step: int = 0 - stage_time: SI = field(default_factory=lambda: SI(0.0, "s")) - real_time: SI = field(default_factory=lambda: SI(0.0, "s")) - stage_end: bool = False - convergence: bool = False - exit_hysteresis: bool = False - zero_stage_time: SI = field(default_factory=lambda: SI(0.0, "s")) - zero_stage_step: int = 0 - time_reached_su: float = 0.0 - time_reached_si: SI = field(default_factory=lambda: SI(0.0, "s")) - last_step_dt_su: float = 0.0 - last_step_dt_si: SI = field(default_factory=lambda: SI(0.0, "s")) - - # __init__ and __repr__ are GONE (auto-generated) - - def inc_stage(self, stage: Optional[int] = None): - """Advance the clock to the next stage.""" - if stage is None: - self.stage += 1 - else: - self.stage = stage - self.stage_step = 0 - self.stage_time = SI(0.0, "s") - self.convergence = False - self.zero_stage_step = self.step - self.zero_stage_time = self.time - - # This method was updated to use tabulate, the format of the data printed out might - # look slightly different, but there is a lot less manual formatting code here now. - def __str__(self) -> str: - ft = fmt_time - - rows = [ - [f"ID={self.id}", f"Step={self.step}", - f"Time={ft(self.time)}", f"Last step size={ft(self.last_step_dt_si)}"], - - ["", f"Stage={self.stage}", - f"Stage-step={self.stage_step}", f"Stage-time={ft(self.stage_time)}"], - - ["", f"Convergence={self.convergence}", - f"Stage-end={self.stage_end}", f"Exit hysteresis={self.exit_hysteresis}"] - ] - - table = tabulate(rows, tablefmt="pipe") - - sep_line = "=" * (len(table.splitlines()[0])) - return f"{sep_line}\n{table}\n{sep_line}" +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from math import isfinite +from typing import Any + +from tabulate import tabulate + +from si.physical import SI + +_zero_seconds_cache: SI | None = None + + +def _zero_seconds() -> SI: + global _zero_seconds_cache + if _zero_seconds_cache is None: + _zero_seconds_cache = SI(0.0, "s") + return SI(_zero_seconds_cache._quantity) + + +def fmt_time(t: SI, fmt_ps: str = ".2f", fmt_ns: str = ".2f") -> str: + """Formats an SI time object into picoseconds or nanoseconds.""" + t_ps = float(t / SI(1e-12, "s")) + + ps_str = f"{t_ps:{fmt_ps}}" + ns_str = f"{(t_ps / 1000.0):{fmt_ns}}" + + return f"{ps_str} ps" if t_ps < 100.0 else f"{ns_str} ns" + + +@dataclass +class SimulationClock: + """ + This object specifies all the parameters which define the current time + in the simulation, such as the simulation time, step number, ... + + Attributes: + id: Unique identifier for data saved. Incremented on save. + stage: Stage number. Increments when the external field changes. + step: Total number of steps performed (always increases). + stage_step: Step number from the beginning of the current stage. + zero_stage_step: The value of 'step' at the beginning of the stage. + time: Total simulation time (always increases). + stage_time: The simulation time from the beginning of the stage. + zero_stage_time: The value of 'time' at the beginning of the stage. + real_time: The real world time used for advancing time. + last_step_dt_si: Last time step's length in SI units. + convergence: Flag indicating if convergence is reached. + stage_end: Flag indicating the end of a stage. + exit_hysteresis: Flag to signal exit from hysteresis loop. + """ + + id: int = -1 + stage: int = 1 + step: int = 0 + time: SI = field(default_factory=_zero_seconds) + stage_step: int = 0 + stage_time: SI = field(default_factory=_zero_seconds) + real_time: SI = field(default_factory=_zero_seconds) + stage_end: bool = False + convergence: bool = False + exit_hysteresis: bool = False + zero_stage_time: SI = field(default_factory=_zero_seconds) + zero_stage_step: int = 0 + time_reached_su: float = 0.0 + time_reached_si: SI = field(default_factory=_zero_seconds) + last_step_dt_su: float = 0.0 + last_step_dt_si: SI = field(default_factory=_zero_seconds) + + def __getitem__(self, key: str, /) -> Any: + if key not in self.__dataclass_fields__: + raise KeyError(key) + return getattr(self, key) + + def get(self, key: str, default: Any = None, /) -> Any: + try: + return self[key] + except KeyError: + return default + + def copy(self) -> dict[str, Any]: + return asdict(self) + + def checkpoint_state(self) -> dict[str, int | float | bool]: + """Return a JSON-safe representation of the simulation clock.""" + return { + "id": self.id, + "stage": self.stage, + "step": self.step, + "stage_step": self.stage_step, + "zero_stage_step": self.zero_stage_step, + "time_seconds": self.time.in_units_of(SI(1.0, "s")), + "stage_time_seconds": self.stage_time.in_units_of(SI(1.0, "s")), + "real_time_seconds": self.real_time.in_units_of(SI(1.0, "s")), + "zero_stage_time_seconds": self.zero_stage_time.in_units_of(SI(1.0, "s")), + "time_reached_seconds": self.time_reached_si.in_units_of(SI(1.0, "s")), + "last_step_seconds": self.last_step_dt_si.in_units_of(SI(1.0, "s")), + "stage_end": self.stage_end, + "convergence": self.convergence, + "exit_hysteresis": self.exit_hysteresis, + } + + @classmethod + def from_checkpoint_state(cls, state: dict[str, object]) -> SimulationClock: + """Build a validated clock from :meth:`checkpoint_state` data.""" + integer_names = ("id", "stage", "step", "stage_step", "zero_stage_step") + seconds_names = ( + "time_seconds", + "stage_time_seconds", + "real_time_seconds", + "zero_stage_time_seconds", + "time_reached_seconds", + "last_step_seconds", + ) + boolean_names = ("stage_end", "convergence", "exit_hysteresis") + required = {*integer_names, *seconds_names, *boolean_names} + missing = sorted(required.difference(state)) + if missing: + raise ValueError(f"Checkpoint clock state is missing {', '.join(missing)}.") + + integers: dict[str, int] = {} + for name in integer_names: + value = state[name] + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError(f"Checkpoint clock {name} must be an integer.") + integers[name] = value + if integers["stage"] < 1 or any( + integers[name] < 0 for name in ("step", "stage_step", "zero_stage_step") + ): + raise ValueError("Checkpoint clock counters must be non-negative and stage at least one.") + + seconds: dict[str, float] = {} + for name in seconds_names: + value = state[name] + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError(f"Checkpoint clock {name} must be a finite number.") + converted = float(value) + if not isfinite(converted) or converted < 0.0: + raise ValueError(f"Checkpoint clock {name} must be a non-negative finite number.") + seconds[name] = converted + + booleans: dict[str, bool] = {} + for name in boolean_names: + value = state[name] + if not isinstance(value, bool): + raise ValueError(f"Checkpoint clock {name} must be a boolean.") + booleans[name] = value + + return cls( + id=integers["id"], + stage=integers["stage"], + step=integers["step"], + stage_step=integers["stage_step"], + zero_stage_step=integers["zero_stage_step"], + time=SI(seconds["time_seconds"], "s"), + stage_time=SI(seconds["stage_time_seconds"], "s"), + real_time=SI(seconds["real_time_seconds"], "s"), + zero_stage_time=SI(seconds["zero_stage_time_seconds"], "s"), + time_reached_su=seconds["time_reached_seconds"], + time_reached_si=SI(seconds["time_reached_seconds"], "s"), + last_step_dt_su=seconds["last_step_seconds"], + last_step_dt_si=SI(seconds["last_step_seconds"], "s"), + stage_end=booleans["stage_end"], + convergence=booleans["convergence"], + exit_hysteresis=booleans["exit_hysteresis"], + ) + + # __init__ and __repr__ are GONE (auto-generated) + + def inc_stage(self, stage: int | None = None) -> None: + """Advance the clock to the next stage.""" + if stage is None: + self.stage += 1 + else: + self.stage = stage + self.stage_step = 0 + self.stage_time = SI(0.0, "s") + self.convergence = False + self.zero_stage_step = self.step + self.zero_stage_time = self.time + + def record_advance( + self, + *, + stage_time_seconds: float, + accepted_steps: int, + last_step_seconds: float, + wall_seconds: float, + ) -> None: + """Atomically record an accepted group of integration steps.""" + previous_stage_seconds = self.stage_time.in_units_of(SI(1.0, "s")) + delta_seconds = stage_time_seconds - previous_stage_seconds + if delta_seconds < 0.0: + raise ValueError("stage time cannot move backwards.") + if accepted_steps < 0: + raise ValueError("accepted_steps cannot be negative.") + + self.step += accepted_steps + self.stage_step += accepted_steps + self.time += SI(delta_seconds, "s") + self.stage_time = SI(stage_time_seconds, "s") + self.time_reached_su = stage_time_seconds + self.time_reached_si = SI(stage_time_seconds, "s") + self.last_step_dt_su = last_step_seconds + self.last_step_dt_si = SI(last_step_seconds, "s") + self.real_time += SI(max(0.0, wall_seconds), "s") + + # This method was updated to use tabulate, the format of the data printed out might + # look slightly different, but there is a lot less manual formatting code here now. + def __str__(self) -> str: + ft = fmt_time + + rows = [ + [ + f"ID={self.id}", + f"Step={self.step}", + f"Time={ft(self.time)}", + f"Last step size={ft(self.last_step_dt_si)}", + ], + [ + "", + f"Stage={self.stage}", + f"Stage-step={self.stage_step}", + f"Stage-time={ft(self.stage_time)}", + ], + [ + "", + f"Convergence={self.convergence}", + f"Stage-end={self.stage_end}", + f"Exit hysteresis={self.exit_hysteresis}", + ], + ] + + table = tabulate(rows, tablefmt="pipe") + + sep_line = "=" * (len(table.splitlines()[0])) + return f"{sep_line}\n{table}\n{sep_line}" diff --git a/src/simulation/data_writer.py b/src/simulation/data_writer.py index 9a5609b..894ede4 100644 --- a/src/simulation/data_writer.py +++ b/src/simulation/data_writer.py @@ -1,179 +1,273 @@ -from __future__ import annotations -import time -import logging -import csv -from pathlib import Path -from typing import ( - List, Optional, Tuple, Dict, Any, Union, Protocol, runtime_checkable -) -from si.physical import SI -from simulation.quantity import (Quantity, known_quantities) - -log = logging.getLogger('nmag') - -# Stub the simulation class as a Protocol so we can seperate out the DataWriter from the actual simulation implementation. -# This allows us to keep the DataWriter focused on its core responsibilities. -@runtime_checkable -class SimulationSource(Protocol): - name: str - id: int - - step: int - stage: int - stage_step: int - time: float - stage_time: float - real_time: float - - def get_subfield_average(self, subfieldname: str, mat_name: Optional[str] = None) -> Any: ... - - def get_materials_of_field(self, field_name: str) -> List[Any]: ... - - def get_all_field_names(self) -> List[str]: ... - - def save_spatial_fields(self, filename: str, fieldnames: List[str]) -> None: ... - -class DataWriter: - def __init__(self, ndt_filename: Path, h5_filename: Path): - """ - :param ndt_filename: Path to the tabular output (.ndt). - :param h5_filename: Path to the spatial output (.h5). - """ - self.ndt_filename = ndt_filename - self.h5_filename = h5_filename - self.quantities = known_quantities - - self.quantities_by_name: Dict[str, Quantity] = { - q.name: q for q in known_quantities - } - - self._header_written: bool = False - self._column_names: Optional[List[str]] = None - self._column_units: Optional[Dict[str, str]] = None - self._last_saved_step: int = -1 - - def save(self, - source: SimulationSource, - fields: Optional[Union[str, List[str]]] = None, - avoid_same_step: bool = False): - - current_step = source.step - if avoid_same_step and current_step == self._last_saved_step: - return - - self._last_saved_step = current_step - - self._write_ndt_row(source) - - if fields is not None: - self._trigger_field_save(source, fields) - - def _trigger_field_save(self, source: SimulationSource, fields: Union[str, List[str]]): - field_names_to_save: List[str] = [] - - if fields == 'all': - field_names_to_save = source.get_all_field_names() - elif isinstance(fields, list): - field_names_to_save = fields - else: - raise ValueError(f"Invalid fields argument: {fields}") - - if field_names_to_save: - source.save_spatial_fields( - filename=str(self.h5_filename), - fieldnames=field_names_to_save - ) - - def _gather_data(self, source: SimulationSource) -> Tuple[List[Tuple[str, Any]], List[Quantity]]: - lt = time.localtime() - lt_str = (f"{lt[0]:04d}/{lt[1]:02d}/{lt[2]:02d}-" - f"{lt[3]:02d}:{lt[4]:02d}:{lt[5]:02d}") - - columns: List[Tuple[str, Any]] = [ - ('id', source.id), - ('step', source.step), - ('stage_step', source.stage_step), - ('stage', source.stage), - ('time', source.time), - ('stage_time', source.stage_time), - ('real_time', source.real_time), - ('unixtime', SI(time.time(), 's')), - ('localtime', lt_str) - ] - - current_quantities: List[Quantity] = [ - self.quantities_by_name[name] for name, _ in columns - ] - - def process_subfield(field_name: str, prefix: str, quantity: Quantity, mat_name: Optional[str] = None): - try: - avg = source.get_subfield_average(field_name, mat_name) - if avg is None: return - except Exception: - return - - if isinstance(avg, list): - for i, comp_value in enumerate(avg): - comp_name = f"{prefix}_{i}" - columns.append((comp_name, comp_value)) - current_quantities.append(quantity.sub_quantity(comp_name)) - else: - columns.append((prefix, avg)) - current_quantities.append(quantity.sub_quantity(prefix)) - - for quantity in self.quantities: - field_name = quantity.name - if quantity.type in ['field', 'pfield']: - if '?' in (quantity.signature or ""): - # Assume the objects returned here have a .name attribute - mats = source.get_materials_of_field(field_name) - for material in mats: - prefix = f"{field_name}_{material.name}" - process_subfield(field_name, prefix, quantity, mat_name=material.name) - else: - process_subfield(field_name, field_name, quantity) - - return columns, current_quantities - - def _write_ndt_row(self, source: SimulationSource): - columns, quantities = self._gather_data(source) - row_data_dict = dict(columns) - - if not self._header_written: - col_names = [] - col_units = {} - - for (name, _), qty in zip(columns, quantities): - col_names.append(name) - if qty.units: - col_units[name] = qty.units.dens_str() - else: - col_units[name] = "-" - - self._column_names = col_names - self._column_units = col_units - - with open(self.ndt_filename, 'w', newline='', encoding='utf-8') as f: - f.write(f"# Simulation: {source.name}\n") - - writer = csv.writer(f, delimiter='\t') - writer.writerow(self._column_names) - - self._header_written = True - - if self._column_names is None: - log.error("Column names not initialized.") - return - - row_values = [] - for name in self._column_names: - value = row_data_dict.get(name) - - if isinstance(value, SI): - value = value.magnitude - - row_values.append(value) - - with open(self.ndt_filename, 'a', newline='', encoding='utf-8') as f: - writer = csv.writer(f, delimiter='\t') - writer.writerow(row_values) +from __future__ import annotations + +import csv +import logging +import time +from collections.abc import Generator +from contextlib import contextmanager +from pathlib import Path +from typing import Any, cast + +from si.physical import SI +from simulation.data_writer_collection import ( + DataWriterCollectionMixin, + SimulationSource, + _SubfieldRequest, +) +from simulation.quantity import Quantity, known_quantities + +log = logging.getLogger("nmag") + + +class DataWriter(DataWriterCollectionMixin): + def __init__(self, ndt_filename: Path, h5_filename: Path, *, append: bool = False) -> None: + """ + :param ndt_filename: Path to the tabular output (.ndt). + :param h5_filename: Path to the spatial output (.h5). + """ + self.ndt_filename = ndt_filename + self.h5_filename = h5_filename + self.quantities = known_quantities + + self.quantities_by_name: dict[str, Quantity] = {q.name: q for q in known_quantities} + + self._header_written: bool = False + self._column_names: list[str] | None = None + self._column_index_by_name: dict[str, int] | None = None + self._column_units: dict[str, str] | None = None + self._subfield_requests: list[_SubfieldRequest] = [] + self._writes_maxangle: bool = False + self._last_saved_step: int = -1 + self.last_save_timings_seconds: dict[str, float] = {} + self._active_save_timings: dict[str, float] | None = None + self._append_schema_pending = False + if append and self.ndt_filename.exists(): + self._recover_ndt_schema() + self._append_schema_pending = True + + def _recover_ndt_schema(self) -> None: + """Recover the schema and last step from an NDT file opened for append.""" + + with open(self.ndt_filename, newline="", encoding="utf-8") as stream: + rows = [row for row in csv.reader(stream, delimiter="\t") if row] + if len(rows) < 2 or not rows[0][0].startswith("# Simulation:"): + raise ValueError(f"Cannot append to malformed NDT output: {self.ndt_filename}.") + column_names = rows[1] + if len(column_names) != len(set(column_names)): + raise ValueError(f"Cannot append to NDT output with duplicate columns: {self.ndt_filename}.") + self._column_names = column_names + self._column_index_by_name = {name: index for index, name in enumerate(column_names)} + self._column_units = {} + self._header_written = True + step_index = self._column_index_by_name.get("step") + if step_index is not None: + for row in reversed(rows[2:]): + if len(row) != len(column_names): + continue + try: + self._last_saved_step = int(float(row[step_index])) + except ValueError: + continue + break + + def save( + self, + source: SimulationSource, + fields: str | list[str] | None = None, + avoid_same_step: bool = False, + ) -> None: + + current_step = source.step + if avoid_same_step and current_step == self._last_saved_step: + return + + timings: dict[str, float] = {} + self._active_save_timings = timings + total_started = time.perf_counter() + try: + with self._record_timing("write_ndt_row"): + self._write_ndt_row(source) + + if fields is not None: + with self._record_timing("save_spatial_fields"): + self._trigger_field_save(source, fields) + finally: + timings["total"] = time.perf_counter() - total_started + self.last_save_timings_seconds = dict(sorted(timings.items())) + self._active_save_timings = None + + self._last_saved_step = current_step + + @contextmanager + def _record_timing(self, name: str) -> Generator[None]: + started = time.perf_counter() + try: + yield + finally: + timings = self._active_save_timings + if timings is not None: + timings[name] = timings.get(name, 0.0) + time.perf_counter() - started + + def _trigger_field_save(self, source: SimulationSource, fields: str | list[str]) -> None: + with self._record_timing("spatial_detail:field_selection"): + field_names_to_save: list[str] = [] + + if fields == "all": + field_names_to_save = source.get_all_field_names() + elif isinstance(fields, list): + field_names_to_save = fields + else: + raise ValueError(f"Invalid fields argument: {fields}") + + if field_names_to_save: + with self._record_timing("spatial_detail:write_dispatch"): + source.save_spatial_fields( + filename=str(self.h5_filename), fieldnames=field_names_to_save + ) + self._record_source_spatial_save_timings(source) + + def _write_ndt_row(self, source: SimulationSource) -> None: + quantities: list[Quantity] = [] + with self._record_timing("gather_ndt_columns"): + if self._append_schema_pending: + with self._record_timing("gather_ndt_append_schema_columns"): + columns, _quantities = self._gather_data(source) + elif self._header_written: + with self._record_timing("gather_ndt_existing_schema_columns"): + columns = self._gather_existing_schema_data(source) + quantities: list[Quantity] = [] + else: + with self._record_timing("gather_ndt_initial_schema_columns"): + columns, quantities = self._gather_data(source) + + if not self._header_written: + with self._record_timing("build_ndt_header_schema"): + col_names: list[str] = [] + col_units: dict[str, str] = {} + + for (name, _), qty in zip(columns, quantities, strict=True): + col_names.append(name) + if qty.units: + col_units[name] = qty.units.dens_str() + else: + col_units[name] = "-" + + column_index_by_name = {name: index for index, name in enumerate(col_names)} + with self._record_timing("format_ndt_values"): + row_values: list[Any] = [self._ndt_cell_value(value) for _, value in columns] + + with open(self.ndt_filename, "w", newline="", encoding="utf-8") as f: + writer = csv.writer(f, delimiter="\t") + with self._record_timing("write_ndt_header"): + f.write(f"# Simulation: {source.name}\n") + writer.writerow(col_names) + + with self._record_timing("write_ndt_values"): + writer.writerow(row_values) + + self._column_names = col_names + self._column_index_by_name = column_index_by_name + self._column_units = col_units + self._header_written = True + return + + if self._column_names is None: + log.error("Column names not initialized.") + return + + with self._record_timing("validate_ndt_schema"): + actual_names = [name for name, _ in columns] + if actual_names != self._column_names: + raise ValueError( + "Cannot append output with a different NDT schema. " + f"Expected {self._column_names!r}, got {actual_names!r}." + ) + self._append_schema_pending = False + + with self._record_timing("format_ndt_values"): + row_values = self._existing_schema_row_values(columns) + + with self._record_timing("write_ndt_values"): + with open(self.ndt_filename, "a", newline="", encoding="utf-8") as f: + writer = csv.writer(f, delimiter="\t") + writer.writerow(row_values) + + def _existing_schema_row_values( + self, + columns: list[tuple[str, Any]], + ) -> list[Any]: + if self._column_names is None: + return [] + if self._column_index_by_name is None: + self._column_index_by_name = { + name: index for index, name in enumerate(self._column_names) + } + + row_values: list[Any] = [None] * len(self._column_names) + for name, value in columns: + index = self._column_index_by_name.get(name) + if index is not None: + row_values[index] = self._ndt_cell_value(value) + return row_values + + @staticmethod + def _ndt_cell_value(value: Any) -> Any: + if isinstance(value, SI): + return value.magnitude + return value + + def _record_source_average_timings( + self, + source: SimulationSource, + prefix: str, + ) -> None: + timings = self._active_save_timings + if timings is None: + return + source_timings = getattr( + source, + "last_subfield_average_timings_seconds", + None, + ) + if not isinstance(source_timings, dict): + return + for name, seconds in cast(dict[object, object], source_timings).items(): + if isinstance(seconds, (int, float)): + timing_name = f"average_detail:{prefix}:{name!s}" + timings[timing_name] = timings.get(timing_name, 0.0) + float(seconds) + + def _record_source_spatial_save_timings(self, source: SimulationSource) -> None: + timings = self._active_save_timings + if timings is None: + return + source_timings = getattr( + source, + "last_spatial_save_timings_seconds", + None, + ) + if not isinstance(source_timings, dict): + return + for name, seconds in cast(dict[object, object], source_timings).items(): + if isinstance(seconds, (int, float)): + timing_name = f"spatial_detail:{name!s}" + timings[timing_name] = timings.get(timing_name, 0.0) + float(seconds) + + def _record_source_maxangle_timings( + self, + source: SimulationSource, + prefix: str, + ) -> None: + timings = self._active_save_timings + if timings is None: + return + source_timings = getattr( + source, + "last_maxangle_timings_seconds", + None, + ) + if not isinstance(source_timings, dict): + return + for name, seconds in cast(dict[object, object], source_timings).items(): + if isinstance(seconds, (int, float)): + timing_name = f"average_detail:{prefix}:{name!s}" + timings[timing_name] = timings.get(timing_name, 0.0) + float(seconds) diff --git a/src/simulation/data_writer_collection.py b/src/simulation/data_writer_collection.py new file mode 100644 index 0000000..2e2a2e8 --- /dev/null +++ b/src/simulation/data_writer_collection.py @@ -0,0 +1,267 @@ +from __future__ import annotations + +import logging +import time +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Protocol, cast, runtime_checkable + +from si.physical import SI +from simulation.quantity import Quantity + +log = logging.getLogger("nmag") + + +@dataclass(frozen=True) +class _SubfieldRequest: + field_name: str + prefix: str + mat_name: str | None = None + + +class MaterialSource(Protocol): + name: str + + +@runtime_checkable +class SimulationSource(Protocol): + name: str + last_spatial_save_timings_seconds: dict[str, float] + last_maxangle_timings_seconds: dict[str, float] + + @property + def id(self) -> int: ... + + @property + def step(self) -> int: ... + + @property + def stage(self) -> int: ... + + @property + def stage_step(self) -> int: ... + + @property + def time(self) -> SI: ... + + @property + def stage_time(self) -> SI: ... + + @property + def real_time(self) -> SI: ... + + @property + def last_step_dt(self) -> SI: ... + + def get_subfield_average(self, subfieldname: str, mat_name: str | None = None) -> Any: ... + + def get_maxangle_average(self, field_name: str) -> float | None: ... + + def get_materials_of_field(self, field_name: str) -> list[MaterialSource]: ... + + def get_all_field_names(self) -> list[str]: ... + + def save_spatial_fields(self, filename: str, fieldnames: list[str]) -> None: ... + + +class DataWriterCollectionMixin: + if TYPE_CHECKING: + + def __getattr__(self, name: str) -> Any: ... + + def _base_columns(self, source: SimulationSource) -> list[tuple[str, Any]]: + lt = time.localtime() + lt_str = f"{lt[0]:04d}/{lt[1]:02d}/{lt[2]:02d}-{lt[3]:02d}:{lt[4]:02d}:{lt[5]:02d}" + + return [ + ("id", source.id), + ("step", source.step), + ("stage_step", source.stage_step), + ("stage", source.stage), + ("last_step_dt", source.last_step_dt), + ("time", source.time), + ("stage_time", source.stage_time), + ("real_time", source.real_time), + ("unixtime", SI(time.time(), "s")), + ("localtime", lt_str), + ] + + def _append_average_columns( + self, + columns: list[tuple[str, Any]], + current_quantities: list[Quantity] | None, + quantity: Quantity | None, + prefix: str, + average: Any, + ) -> None: + if isinstance(average, list): + for i, comp_value in enumerate(cast(list[Any], average)): + comp_name = f"{prefix}_{i}" + columns.append((comp_name, comp_value)) + if current_quantities is not None and quantity is not None: + current_quantities.append(quantity.sub_quantity(comp_name)) + else: + columns.append((prefix, average)) + if current_quantities is not None and quantity is not None: + current_quantities.append(quantity.sub_quantity(prefix)) + + @staticmethod + def _source_has_subfield(source: SimulationSource, field_name: str) -> bool: + predicate = getattr(source, "is_subfield_available", None) + if predicate is None: + return True + try: + return bool(predicate(field_name)) + except KeyError: + log.debug("Skipping unavailable subfield %s", field_name) + return False + + def _gather_data( + self, source: SimulationSource + ) -> tuple[list[tuple[str, Any]], list[Quantity]]: + columns = self._base_columns(source) + current_quantities: list[Quantity] = [self.quantities_by_name[name] for name, _ in columns] + subfield_requests: list[_SubfieldRequest] = [] + for quantity in self.quantities: + self._collect_quantity_subfields( + source, + quantity, + columns, + current_quantities, + subfield_requests, + ) + + writes_maxangle = self._collect_initial_maxangle( + source, + columns, + current_quantities, + ) + + self._subfield_requests = subfield_requests + self._writes_maxangle = writes_maxangle + + return columns, current_quantities + + def _collect_quantity_subfields( + self, + source: SimulationSource, + quantity: Quantity, + columns: list[tuple[str, Any]], + current_quantities: list[Quantity], + subfield_requests: list[_SubfieldRequest], + ) -> None: + field_name = quantity.name + if quantity.type not in ["field", "pfield"] or not self._source_has_subfield( + source, + field_name, + ): + return + if "?" not in (quantity.signature or ""): + self._collect_subfield_average( + source, + field_name, + field_name, + quantity, + columns, + current_quantities, + subfield_requests, + ) + return + for material in source.get_materials_of_field(field_name): + self._collect_subfield_average( + source, + field_name, + f"{field_name}_{material.name}", + quantity, + columns, + current_quantities, + subfield_requests, + mat_name=material.name, + ) + + def _collect_subfield_average( + self, + source: SimulationSource, + field_name: str, + prefix: str, + quantity: Quantity, + columns: list[tuple[str, Any]], + current_quantities: list[Quantity], + subfield_requests: list[_SubfieldRequest], + *, + mat_name: str | None = None, + ) -> None: + average = self._subfield_average(source, field_name, prefix, mat_name) + if average is None: + return + self._append_average_columns(columns, current_quantities, quantity, prefix, average) + subfield_requests.append(_SubfieldRequest(field_name, prefix, mat_name)) + + def _subfield_average( + self, + source: SimulationSource, + field_name: str, + prefix: str, + mat_name: str | None = None, + ) -> Any | None: + try: + with self._record_timing(f"average:{prefix}"): + average = source.get_subfield_average(field_name, mat_name) + except KeyError: + log.debug("Skipping unavailable subfield average %s", prefix) + average = None + self._record_source_average_timings(source, prefix) + return average + + def _collect_initial_maxangle( + self, + source: SimulationSource, + columns: list[tuple[str, Any]], + current_quantities: list[Quantity], + ) -> bool: + maxangle_quantity = self.quantities_by_name.get("maxangle") + maxangle = self._maxangle_average(source) + if maxangle_quantity is None or maxangle is None: + return False + columns.append(("maxangle_m_Py", maxangle)) + current_quantities.append(maxangle_quantity.sub_quantity("maxangle_m_Py")) + return True + + def _maxangle_average(self, source: SimulationSource) -> float | None: + try: + with self._record_timing("average:maxangle_m_Py"): + maxangle = source.get_maxangle_average("m") + except KeyError: + log.debug("Skipping unavailable max-angle average for m") + maxangle = None + self._record_source_maxangle_timings(source, "maxangle_m_Py") + return maxangle + + def _gather_existing_schema_data(self, source: SimulationSource) -> list[tuple[str, Any]]: + columns = self._base_columns(source) + + for request in self._subfield_requests: + try: + with self._record_timing(f"average:{request.prefix}"): + avg = source.get_subfield_average(request.field_name, request.mat_name) + self._record_source_average_timings(source, request.prefix) + if avg is None: + continue + except KeyError: + self._record_source_average_timings(source, request.prefix) + log.debug("Skipping unavailable subfield average %s", request.prefix) + continue + + self._append_average_columns( + columns, + None, + None, + request.prefix, + avg, + ) + + if self._writes_maxangle: + maxangle = self._maxangle_average(source) + if maxangle is not None: + columns.append(("maxangle_m_Py", maxangle)) + + return columns diff --git a/src/simulation/hysteresis.py b/src/simulation/hysteresis.py index c91ef0a..14c1d0e 100644 --- a/src/simulation/hysteresis.py +++ b/src/simulation/hysteresis.py @@ -1,391 +1,74 @@ - """ This file contains the implementation of the hysteresis method of the Simulation class. """ from __future__ import annotations -import re -import time -import logging -from typing import ( - Callable, Any, List, Tuple, Dict, Optional, Union -) -from si.physical import SI -from throttler import Throttler -from when import at, every, never, TimeDict - -log = logging.getLogger('nmag') - -_subsequent_spaces = re.compile(r'[ \t_]+') - -_progress_throttler = Throttler() - -def _update_progress_file( - sim: Any, - H_ext: Any, - progress_file_name: str, - progress_message_minimum_delay: float -): - """ - Writes the current simulation progress to a file, throttled - to a minimum delay. - """ - if _progress_throttler.is_allowed('hysteresis_reporting', - progress_message_minimum_delay): - - try: - with open(progress_file_name, 'w') as f: - f.write(f"{time.asctime()}\n") - f.write(f"{sim.clock}\n") - f.write(f"{sim.convergence.get_log()}\n") - - log.info( - f"it {sim.clock.step}, time {sim.clock.time_reached_si.dens_str()}; " - f"stage {sim.clock.stage}; H_ext={str(H_ext)}" - ) - except Exception as e: - log.warning( - f"Could not write progress file '{progress_file_name}'. Error: {e}" - ) - -def _string_normalise( - s: str, - lower: bool = True, - spaces: Optional[str] = '_' -) -> str: - """ - Used by `_append_x_list` to obtain a standard string format. - Examples: - 'save fields' --> 'save_fields' - 'Save_Fields' --> 'save_fields' - """ - ns = s - if lower: - ns = s.lower() - if spaces is not None: - ns = re.sub(_subsequent_spaces, spaces, ns) - return ns - -def _append_x_list( - target_list: List[Tuple[Callable, Any]], - input_list: List[Tuple], - prefix: str = "", - predefined_actions: Dict[str, Callable] = {} -): - """ - Internally used by `_join_save_and_do_lists` to parse the - multi-argument 'save' and 'do' tuples. - """ - try: - for tuple_item in input_list: - list_item = list(tuple_item) - if not list_item: - continue - - things = list_item[0:-1] - when = list_item[-1] - - for thing in things: - if isinstance(thing, Callable): - target_list.append((thing, when)) - continue - - if isinstance(thing, str): - normalised_thing = _string_normalise(f"{prefix} {thing}") - if normalised_thing in predefined_actions: - action = predefined_actions[normalised_thing] - target_list.append((action, when)) - continue - else: - # Try without prefix - normalised_thing = _string_normalise(thing) - if normalised_thing in predefined_actions: - action = predefined_actions[normalised_thing] - target_list.append((action, when)) - continue - - # If we are here, the 'thing' is not a callable and not - # a recognized string. - msg = ( - f"Error in optional argument '{prefix}' of method " - f"'hysteresis': you want to {prefix} '{thing}' " - f"but I don't know how to do it. Hint: when specifying " - f"what to {prefix} you can use a string or a function. " - f"Available strings are: {', '.join(predefined_actions.keys())}." - ) - raise ValueError(msg) - - except TypeError: - msg = ( - f"Bad syntax for argument '{prefix}' of the method 'hysteresis': " - f"remember that you should provide a list of tuples " - f"(things_to_{prefix}, when). You can also provide tuples " - f"with many things to save, such as (thing1, thing2, " - f"..., when)." - ) - raise ValueError(msg) - -def _join_save_and_do_lists( - save_list: List[Tuple], - do_list: List[Tuple], - predefined_actions: Dict[str, Callable] = {} -) -> List[Tuple[Callable, Any]]: - """ - Takes 'save' and 'do' parameters from the 'hysteresis' method - and joins them into a unique list. - Tags (e.g., "save averages") are replaced with the appropriate functions. - Also checks that the provided tags actually exist. - """ - joint_list: List[Tuple[Callable, Any]] = [] - - # Note: It is important to process 'do' before 'save'. - # A 'do' command might be 'next_stage', and 'save' commands - # need to know if this is the last step of the stage. - _append_x_list(joint_list, do_list, prefix="do", - predefined_actions=predefined_actions) - _append_x_list(joint_list, save_list, prefix="save", - predefined_actions=predefined_actions) - - # XXX Matteo, can we at this point order the save_ entries such that - # the save_restart is the last? See my explanation in ticket:169. - # Hans, 11/ll/2008 - - return joint_list - -def _next_deltas( - event: Any, - clock: Any, - suggest: Optional[Tuple] = None, - tols: Optional[Dict] = None -) -> Tuple: - """ - Return the next occurrence of the given event as a triple of deltas: - - (delta_step, delta_time, delta_real_time) - """ - def delta(name: str) -> Optional[float]: - n = event.next_time(name, clock, tols=tols) - return n - clock[name] if not isinstance(n, bool) else None - - def minimum(ls: List[Optional[float]]) -> Optional[float]: - return min((item for item in ls if item is not None), default=None) - - delta_step, delta_time, delta_real_time = suggest or (None, None, None) - - delta_step = minimum([delta('step'), delta('stage_step'), delta_step]) - delta_time = minimum([delta('time'), delta('stage_time'), delta_time]) - delta_real_time = minimum([delta('real_time'), delta_real_time]) - - return (delta_step, delta_time, delta_real_time) -def _next_time( - event: Any, - clock: Any, - tols: Optional[Dict] = None -) -> Tuple: - """ - Returns a triple of absolute counter values for the next event: - - (next_step, next_time, next_real_time) - """ - def delta(name: str, starting_time: Optional[str] = None) -> Optional[float]: - n = event.next_time(name, clock, tols=tols) - if isinstance(n, bool): - return None - return n - clock[starting_time] if starting_time else n +from typing import Any, Protocol - def minimum(ls: List[Optional[float]]) -> Optional[float]: - return min((item for item in ls if item is not None), default=None) - - next_time = minimum([delta('time', 'zero_stage_time'), - delta('stage_time')]) - - next_step = minimum([delta('step', 'zero_stage_step'), - delta('stage_step')]) - - next_real_time = delta('real_time') - - return (next_step, next_time, next_real_time) - -def simulation_relax( - self, - H_applied: Any = None, - save: List[Tuple] = [('averages', 'fields', at('stage_end'))], - do: List[Tuple] = [], - convergence_check: Any = every('step', 5) - ): - """ - This method carries out the time integration of the LLG until - the system reaches a (metastable) equilibrium. - Internally, this uses the hysteresis() loop command. - - (Docstring parameters omitted for brevity, they are unchanged) - """ - log.debug("Entering 'relax'") - fields = [H_applied] - log.debug(f"Calling hysteresis({fields})") - return self.simulation_hysteresis( - fields, - save=save, - do=do, - convergence_check=convergence_check - ) - -def simulation_hysteresis( - self, # 'self' is kept as per the original file structure - H_ext_list: List[Any], - save: List[Tuple] = [('averages', 'fields', at('stage_end'))], - do: List[Tuple] = [], - convergence_check: Any = every('step', 5), - progress_message_minimum_delay: float = 60.0 - ): - """ - This method executes a simulation where the applied field - is set in sequence to the values specified in ``H_ext_list``. - - (Full docstring omitted for brevity, it is unchanged) - """ - log.debug( - f"simulation_hysteresis(): Entering with H_ext_list={H_ext_list}, " - f"save={save}, do={do}, convergence_check={convergence_check}" - ) - - thing_when_tuples = _join_save_and_do_lists( - save, - do, - predefined_actions=self.action_abbreviations - ) - - log.debug(f"simulation_hysteresis(): thing_when_tuples={thing_when_tuples}") - - next_save_time: Dict[str, Any] = {} - for what, _ in thing_when_tuples: - key = str(what) - if key in next_save_time: - msg = ( - f"Error in optional argument 'save' or 'do' of method " - f"'hysteresis': the list of (thing_to_save, when) " - f"contains two or more specifications for " - f"thing_to_save = {key}. You should remove the duplicate " - f"entry and eventually use the operator | (such as in: " - f"(thing_to_save, when1 | when2))." - ) - raise ValueError(msg) - next_save_time[key] = None - - # Tolerances for floating point comparisons - negligible_time = SI(1e-20, "s") - match_tolerances = { - 'time': negligible_time, - 'stage_time': negligible_time - } - - def my_next_time(event, clock): - return _next_time(event, clock, tols=match_tolerances) - - def my_next_deltas(event, clock, suggest=None): - return _next_deltas(event, clock, suggest=suggest, - tols=match_tolerances) - - progress_file_name = f"{self.name}_progress.txt" - - if self._restarting: - log.info("Hysteresis loop: restarting from a previously saved " - "configuration...") - self.load_restart_file() - self._restarting = False # Reset flag - else: - log.info("Hysteresis loop: starting a new simulation.") - log.info(f"Hysteresis loop: check file '{progress_file_name}' " - "for progress data") - - stage = self.clock.stage - self.clock.exit_hysteresis = False - - for H_ext in H_ext_list[stage-1:]: - log.info(f"hysteresis: starting new stage: field = {str(H_ext)}") - self.do_next_stage(stage=stage) - stage = None # Next time, just increase the stage counter - self.clock.stage_end = False - if H_ext: - self.set_H_ext(H_ext) - self.reinitialise(initial_time=0) - - # Update schedule for when to save what - for what, when in thing_when_tuples: - key = str(what) - next_save_time[key] = my_next_time(when, self.clock) - log.debug(f"hysteresis: will save {what} at {next_save_time[key]}") +from si.physical import SI +from when import When - # --- Main Stage Loop --- - while True: - self.clock.stage_end = converged = self.is_converged() - log.debug(f"hysteresis loop, stage {self.clock.stage}, " - f"converged = {converged}") +from .clock import SimulationClock +from .hysteresis_runner import ( + _update_progress_file as _update_progress_file, # noqa: F401 +) +from .hysteresis_runner import ( + simulation_hysteresis as simulation_hysteresis, +) +from .hysteresis_runner import ( + simulation_relax as simulation_relax, +) +from .hysteresis_schedule import ( + Action, + ScheduleInput, +) +from .hysteresis_schedule import ( + _append_x_list as _append_x_list, # noqa: F401 +) +from .hysteresis_schedule import ( + _join_save_and_do_lists as _join_save_and_do_lists, # noqa: F401 +) +from .hysteresis_schedule import ( + _string_normalise as _string_normalise, # noqa: F401 +) - # Find out the next time we need to check for convergence - deltas = my_next_deltas(convergence_check, self.clock) - log.debug(f"Time to next event: deltas = {deltas}") - # Check what needs to be saved/done - for what, when in thing_when_tuples: - key = str(what) - time_matches = when.match_time(self.clock) - nst = my_next_time(when, self.clock) +class _ConvergenceSource(Protocol): + def get_log(self) -> str: ... - # Check if the event is scheduled to run now - if time_matches or nst != next_save_time[key]: - log.debug( - f"hysteresis: analysing {what}: time planned " - f"for saving was {next_save_time[key]}, " - f"now is {nst}. Matching? {time_matches}" - ) - log.info( - f"hysteresis: saving {what} at id={self.clock.id}," - f"step={self.clock.step}.\n{self.clock}" - ) - what(self) # Run the action/save function - next_save_time[key] = nst - deltas = my_next_deltas(when, self.clock, suggest=deltas) +class HysteresisSimulation(Protocol): + name: str + clock: SimulationClock + convergence: _ConvergenceSource + action_abbreviations: dict[str, Action] + _restarting: bool + max_time_reached: SI - (delta_step, delta_time, delta_real_time) = deltas - log.debug(f"hysteresis: current time is {self.clock.time}") - log.debug( - f"predicted advance: delta_step={delta_step}, " - f"delta_time={delta_time}, " - f"delta_real_time={delta_real_time}" - ) + def simulation_hysteresis( + self, + H_ext_list: list[Any], + save: ScheduleInput | None = None, + do: ScheduleInput | None = None, + convergence_check: When | None = None, + progress_message_minimum_delay: float = 60.0, + ) -> None: ... - if delta_time is None: - target_time = self.max_time_reached - else: - target_time = self.clock.stage_time + delta_time + def load_restart_file(self, filename: str | None = None) -> None: ... - delta_step = delta_step if delta_step is not None else -1 + def do_next_stage(self, stage: int | None = None) -> None: ... - if self.clock.exit_hysteresis: - log.debug("Exit from the hysteresis loop has been forced " - "using the tag 'exit': exiting now!") - return + def set_H_ext(self, values: Any, unit: SI | None = None) -> None: ... - if self.clock.stage_end: - log.debug(f"Reached end of stage in hysteresis command, " - f"converged={converged}, exiting now!") - break + def reinitialise(self, initial_time: float | None = None) -> None: ... - log.debug(f"About to call advance time with target_time={target_time} " - f"and max_it={delta_step}") - - time_reached = self.advance_time(target_time, max_it=delta_step) - - if time_reached > 0.99 * self.max_time_reached: - msg = (f"Simulation time reached {self.max_time_reached}: " - "are you starting from a zero torque configuration?") - raise RuntimeError(msg) + def is_converged(self) -> bool: ... - # Write progress data to file - _update_progress_file(self, H_ext, progress_file_name, - progress_message_minimum_delay) - - # --- End of Main Stage Loop --- - # --- End of H_ext_list Loop --- + def advance_time( + self, + target_time: SI, + max_it: int = -1, + exact_tstop: bool | None = None, + ) -> SI: ... diff --git a/src/simulation/hysteresis_runner.py b/src/simulation/hysteresis_runner.py new file mode 100644 index 0000000..c87b8cc --- /dev/null +++ b/src/simulation/hysteresis_runner.py @@ -0,0 +1,286 @@ +"""Execution loops for relaxation and multi-stage hysteresis.""" + +from __future__ import annotations + +import logging +import time +from typing import Any + +from si.physical import SI +from throttler import Throttler +from when import When, at, every + +from .clock import SimulationClock +from .hysteresis_schedule import ( + CounterTriple, + ScheduleInput, + _join_save_and_do_lists, + _next_deltas, + _next_time, +) + +log = logging.getLogger("nmag") +_progress_throttler = Throttler() + + +def _update_progress_file( + sim: Any, + H_ext: Any, + progress_file_name: str, + progress_message_minimum_delay: float, +) -> None: + """ + Writes the current simulation progress to a file, throttled + to a minimum delay. + """ + if _progress_throttler.is_allowed("hysteresis_reporting", progress_message_minimum_delay): + try: + with open(progress_file_name, "w", encoding="utf-8") as f: + f.write(f"{time.asctime()}\n") + f.write(f"{sim.clock}\n") + f.write(f"{sim.convergence.get_log()}\n") + + log.info( + f"it {sim.clock.step}, time {sim.clock.time_reached_si.dens_str()}; " + f"stage {sim.clock.stage}; H_ext={str(H_ext)}" + ) + except Exception as e: + log.warning(f"Could not write progress file '{progress_file_name}'. Error: {e}") + + +def simulation_relax( + self: Any, + H_applied: Any = None, + save: ScheduleInput | None = None, + do: ScheduleInput | None = None, + convergence_check: When | None = None, +) -> None: + """ + This method carries out the time integration of the LLG until + the system reaches a (metastable) equilibrium. + Internally, this uses the hysteresis() loop command. + + (Docstring parameters omitted for brevity, they are unchanged) + """ + if save is None: + save = [("averages", "fields", at("stage_end"))] + if do is None: + do = [] + if convergence_check is None: + convergence_check = every("step", 5) + + log.debug("Entering 'relax'") + fields = [H_applied] + log.debug(f"Calling hysteresis({fields})") + self.simulation_hysteresis( + fields, + save=save, + do=do, + convergence_check=convergence_check, + ) + + +def simulation_hysteresis( + self: Any, + H_ext_list: list[Any], + save: ScheduleInput | None = None, + do: ScheduleInput | None = None, + convergence_check: When | None = None, + progress_message_minimum_delay: float = 60.0, +) -> None: + """ + This method executes a simulation where the applied field + is set in sequence to the values specified in ``H_ext_list``. + + (Full docstring omitted for brevity, it is unchanged) + """ + save, do, convergence_check = _hysteresis_defaults(save, do, convergence_check) + log.debug( + f"simulation_hysteresis(): Entering with H_ext_list={H_ext_list}, " + f"save={save}, do={do}, convergence_check={convergence_check}" + ) + + thing_when_tuples = _scheduled_actions(self, save, do) + log.debug(f"simulation_hysteresis(): thing_when_tuples={thing_when_tuples}") + next_save_time = _initial_next_save_times(thing_when_tuples) + match_tolerances = _hysteresis_match_tolerances() + progress_file_name = f"{self.name}_progress.txt" + _prepare_hysteresis_run(self, progress_file_name) + stage: int | None = int(self.clock.stage) + self.clock.exit_hysteresis = False + for H_ext in H_ext_list[stage - 1 :]: + _prepare_hysteresis_stage( + self, H_ext, stage, thing_when_tuples, next_save_time, match_tolerances + ) + stage = None + if _run_hysteresis_stage( + self, + H_ext, + thing_when_tuples, + next_save_time, + convergence_check, + match_tolerances, + progress_file_name, + progress_message_minimum_delay, + ): + return + + +def _hysteresis_defaults( + save: ScheduleInput | None, + do: ScheduleInput | None, + convergence_check: When | None, +) -> tuple[ScheduleInput, ScheduleInput, When]: + return ( + [("averages", "fields", at("stage_end"))] if save is None else save, + [] if do is None else do, + every("step", 5) if convergence_check is None else convergence_check, + ) + + +def _scheduled_actions(self: Any, save: ScheduleInput, do: ScheduleInput) -> list[tuple[Any, When]]: + return _join_save_and_do_lists(save, do, predefined_actions=self.action_abbreviations) + + +def _initial_next_save_times( + actions: list[tuple[Any, When]], +) -> dict[str, CounterTriple | None]: + next_save_time: dict[str, CounterTriple | None] = {} + for what, _ in actions: + key = str(what) + if key in next_save_time: + raise ValueError( + "Error in optional argument 'save' or 'do' of method 'hysteresis': " + "the list of (thing_to_save, when) contains two or more specifications " + f"for thing_to_save = {key}. You should remove the duplicate entry and " + "eventually use the operator | (such as in: (thing_to_save, when1 | when2))." + ) + next_save_time[key] = None + return next_save_time + + +def _hysteresis_match_tolerances() -> dict[str, Any]: + negligible_time = SI(1e-20, "s") + return {"time": negligible_time, "stage_time": negligible_time} + + +def _prepare_hysteresis_run(self: Any, progress_file_name: str) -> None: + if self._restarting: + log.info("Hysteresis loop: restarting from a previously saved configuration...") + self.load_restart_file() + self._restarting = False + return + log.info("Hysteresis loop: starting a new simulation.") + log.info(f"Hysteresis loop: check file '{progress_file_name}' for progress data") + + +def _next_event_time( + event: When, clock: SimulationClock, tolerances: dict[str, Any] +) -> CounterTriple: + return _next_time(event, clock, tols=tolerances) + + +def _next_event_deltas( + event: When, + clock: SimulationClock, + tolerances: dict[str, Any], + suggest: CounterTriple | None = None, +) -> CounterTriple: + return _next_deltas(event, clock, suggest=suggest, tols=tolerances) + + +def _prepare_hysteresis_stage( + self: Any, + applied_field: Any, + stage: int | None, + actions: list[tuple[Any, When]], + next_save_time: dict[str, CounterTriple | None], + tolerances: dict[str, Any], +) -> None: + log.info(f"hysteresis: starting new stage: field = {str(applied_field)}") + self.do_next_stage(stage=stage) + self.clock.stage_end = False + if applied_field is not None: + self.set_H_ext(applied_field) + self.reinitialise(initial_time=0) + for what, when in actions: + key = str(what) + next_save_time[key] = _next_event_time(when, self.clock, tolerances) + log.debug(f"hysteresis: will save {what} at {next_save_time[key]}") + + +def _run_hysteresis_stage( + self: Any, + applied_field: Any, + actions: list[tuple[Any, When]], + next_save_time: dict[str, CounterTriple | None], + convergence_check: When, + tolerances: dict[str, Any], + progress_file_name: str, + progress_delay: float, +) -> bool: + while True: + self.clock.stage_end = converged = self.is_converged() + deltas = _next_event_deltas(convergence_check, self.clock, tolerances) + deltas = _dispatch_scheduled_actions( + self, + actions, + next_save_time, + tolerances, + deltas, + ) + if self.clock.exit_hysteresis: + log.debug( + "Exit from the hysteresis loop has been forced using the tag 'exit': exiting now!" + ) + return True + if self.clock.stage_end: + log.debug( + f"Reached end of stage in hysteresis command, converged={converged}, exiting now!" + ) + return False + _advance_hysteresis_stage(self, deltas, applied_field, progress_file_name, progress_delay) + + +def _dispatch_scheduled_actions( + self: Any, + actions: list[tuple[Any, When]], + next_save_time: dict[str, CounterTriple | None], + tolerances: dict[str, Any], + deltas: CounterTriple, +) -> CounterTriple: + for what, when in actions: + key = str(what) + next_time = _next_event_time(when, self.clock, tolerances) + if when.match_time(self.clock) or next_time != next_save_time[key]: + log.info( + f"hysteresis: saving {what} at id={self.clock.id},step={self.clock.step}.\n{self.clock}" + ) + what(self) + next_save_time[key] = next_time + deltas = _next_event_deltas(when, self.clock, tolerances, suggest=deltas) + return deltas + + +def _advance_hysteresis_stage( + self: Any, + deltas: CounterTriple, + applied_field: Any, + progress_file_name: str, + progress_delay: float, +) -> None: + delta_step, delta_time, delta_real_time = deltas + target_time = ( + self.max_time_reached if delta_time is None else self.clock.stage_time + delta_time + ) + log.debug( + f"predicted advance: delta_step={delta_step}, delta_time={delta_time}, " + f"delta_real_time={delta_real_time}" + ) + time_reached = self.advance_time(target_time, max_it=-1 if delta_step is None else delta_step) + if time_reached > 0.99 * self.max_time_reached: + raise RuntimeError( + f"Simulation time reached {self.max_time_reached}: " + "are you starting from a zero torque configuration?" + ) + _update_progress_file(self, applied_field, progress_file_name, progress_delay) diff --git a/src/simulation/hysteresis_schedule.py b/src/simulation/hysteresis_schedule.py new file mode 100644 index 0000000..3e3c1de --- /dev/null +++ b/src/simulation/hysteresis_schedule.py @@ -0,0 +1,180 @@ +"""Schedule parsing and counter arithmetic for hysteresis runs.""" + +from __future__ import annotations + +import re +from collections.abc import Callable +from typing import Any, cast + +from when import When + +from .clock import SimulationClock + +Action = Callable[[Any], Any] +ScheduleInput = list[tuple[object, ...]] +ScheduledAction = tuple[Action, When] +CounterTriple = tuple[Any | None, Any | None, Any | None] + +_subsequent_spaces = re.compile(r"[ \t_]+") + + +def _string_normalise(s: str, lower: bool = True, spaces: str | None = "_") -> str: + """ + Used by `_append_x_list` to obtain a standard string format. + Examples: + 'save fields' --> 'save_fields' + 'Save_Fields' --> 'save_fields' + """ + ns = s + if lower: + ns = s.lower() + if spaces is not None: + ns = re.sub(_subsequent_spaces, spaces, ns) + return ns + + +def _append_x_list( + target_list: list[ScheduledAction], + input_list: ScheduleInput, + prefix: str = "", + predefined_actions: dict[str, Action] | None = None, +) -> None: + """ + Internally used by `_join_save_and_do_lists` to parse the + multi-argument 'save' and 'do' tuples. + """ + if predefined_actions is None: + predefined_actions = {} + + try: + for tuple_item in input_list: + list_item = list(tuple_item) + if not list_item: + continue + + things = list_item[0:-1] + when = list_item[-1] + + for thing in things: + if callable(thing): + target_list.append((cast(Action, thing), cast(When, when))) + continue + + if isinstance(thing, str): + normalised_thing = _string_normalise(f"{prefix} {thing}") + if normalised_thing in predefined_actions: + action = predefined_actions[normalised_thing] + target_list.append((action, cast(When, when))) + continue + else: + # Try without prefix + normalised_thing = _string_normalise(thing) + if normalised_thing in predefined_actions: + action = predefined_actions[normalised_thing] + target_list.append((action, cast(When, when))) + continue + + # If we are here, the 'thing' is not a callable and not + # a recognized string. + msg = ( + f"Error in optional argument '{prefix}' of method " + f"'hysteresis': you want to {prefix} '{thing}' " + f"but I don't know how to do it. Hint: when specifying " + f"what to {prefix} you can use a string or a function. " + f"Available strings are: {', '.join(predefined_actions.keys())}." + ) + raise ValueError(msg) + + except TypeError as e: + msg = ( + f"Bad syntax for argument '{prefix}' of the method 'hysteresis': " + f"remember that you should provide a list of tuples " + f"(things_to_{prefix}, when). You can also provide tuples " + f"with many things to save, such as (thing1, thing2, " + f"..., when)." + ) + raise ValueError(msg) from e + + +def _join_save_and_do_lists( + save_list: ScheduleInput, + do_list: ScheduleInput, + predefined_actions: dict[str, Action] | None = None, +) -> list[ScheduledAction]: + """ + Takes 'save' and 'do' parameters from the 'hysteresis' method + and joins them into a unique list. + Tags (e.g., "save averages") are replaced with the appropriate functions. + Also checks that the provided tags actually exist. + """ + if predefined_actions is None: + predefined_actions = {} + + joint_list: list[ScheduledAction] = [] + + # Note: It is important to process 'do' before 'save'. + # A 'do' command might be 'next_stage', and 'save' commands + # need to know if this is the last step of the stage. + _append_x_list(joint_list, do_list, prefix="do", predefined_actions=predefined_actions) + _append_x_list(joint_list, save_list, prefix="save", predefined_actions=predefined_actions) + + # XXX Matteo, can we at this point order the save_ entries such that + # the save_restart is the last? See my explanation in ticket:169. + # Hans, 11/ll/2008 + + return joint_list + + +def _next_deltas( + event: When, + clock: SimulationClock, + suggest: CounterTriple | None = None, + tols: dict[str, Any] | None = None, +) -> CounterTriple: + """ + Return the next occurrence of the given event as a triple of deltas: + - (delta_step, delta_time, delta_real_time) + """ + + def delta(name: str) -> Any | None: + n = event.next_time(name, clock, tols=tols) + return n - clock[name] if not isinstance(n, bool) else None + + def minimum(ls: list[Any | None]) -> Any | None: + return min((item for item in ls if item is not None), default=None) + + delta_step, delta_time, delta_real_time = suggest or (None, None, None) + + delta_step = minimum([delta("step"), delta("stage_step"), delta_step]) + delta_time = minimum([delta("time"), delta("stage_time"), delta_time]) + delta_real_time = minimum([delta("real_time"), delta_real_time]) + + return (delta_step, delta_time, delta_real_time) + + +def _next_time( + event: When, + clock: SimulationClock, + tols: dict[str, Any] | None = None, +) -> CounterTriple: + """ + Returns a triple of absolute counter values for the next event: + - (next_step, next_time, next_real_time) + """ + + def delta(name: str, starting_time: str | None = None) -> Any | None: + n = event.next_time(name, clock, tols=tols) + if isinstance(n, bool): + return None + return n - clock[starting_time] if starting_time else n + + def minimum(ls: list[Any | None]) -> Any | None: + return min((item for item in ls if item is not None), default=None) + + next_time = minimum([delta("time", "zero_stage_time"), delta("stage_time")]) + + next_step = minimum([delta("step", "zero_stage_step"), delta("stage_step")]) + + next_real_time = delta("real_time") + + return (next_step, next_time, next_real_time) diff --git a/src/simulation/inference/__init__.py b/src/simulation/inference/__init__.py index fbf40d8..8dea694 100644 --- a/src/simulation/inference/__init__.py +++ b/src/simulation/inference/__init__.py @@ -1 +1,3 @@ -from .inference import * +from .inference import InferenceEngine, InferenceEntity + +__all__ = ["InferenceEngine", "InferenceEntity"] diff --git a/src/simulation/inference/inference.py b/src/simulation/inference/inference.py index 6613dbf..da6c0e7 100644 --- a/src/simulation/inference/inference.py +++ b/src/simulation/inference/inference.py @@ -1,90 +1,95 @@ -import logging -from dataclasses import dataclass, field -from typing import Any, Callable, Dict, List, Optional, Set - -log = logging.getLogger('nmagmake') - -@dataclass -class InferenceEntity: - name: str - depends_on: List[str] = field(default_factory=list) - how_to_make: List[Callable[..., Any]] = field(default_factory=list) - also_updates: List[str] = field(default_factory=list) - _is_prerequisite: List[str] = field(default_factory=list, init=False) - _is_uptodate: bool = field(default=False, init=False) - -class InferenceEngine: - def __init__(self, entities: Optional[List[Dict[str, Any]]] = None): - self.entities: Dict[str, InferenceEntity] = {} - if entities: - for e_desc in entities: - ie = InferenceEntity(**e_desc) - self.entities[ie.name] = ie - - self._build_backlinks() - self._check_for_cycles() - - def _build_backlinks(self) -> None: - """Connects dependencies to their dependents and validates existence.""" - for name, ie in self.entities.items(): - for dep in ie.depends_on: - if (ie_dep := self.entities.get(dep)) is None: - raise KeyError(f"Dependency '{dep}' required by '{name}' not found.") - ie_dep._is_prerequisite.append(name) - - def _check_for_cycles(self) -> None: - """Detects circular dependencies using DFS to prevent infinite recursion.""" - visited: Set[str] = set() - rec_stack: Set[str] = set() - - def has_cycle(name: str) -> bool: - visited.add(name) - rec_stack.add(name) - - for neighbor in self.entities[name].depends_on: - if neighbor not in visited: - if has_cycle(neighbor): - return True - elif neighbor in rec_stack: - return True - - rec_stack.remove(name) - return False - - for node in self.entities: - if node not in visited: - if has_cycle(node): - raise ValueError(f"Circular dependency detected involving '{node}'") - - def invalidate(self, name: str) -> None: - """Recursively marks an entity and its dependents as dirty.""" - if (ie := self.entities.get(name)) is None: - raise KeyError(f"Entity '{name}' is unknown.") - - if ie._is_uptodate: - ie._is_uptodate = False - log.debug(f"Invalidated: {name}") - for dependent_name in ie._is_prerequisite: - self.invalidate(dependent_name) - - def make(self, name: str, **make_args: Any) -> None: - """Builds target and dependencies recursively.""" - if (ie := self.entities.get(name)) is None: - raise KeyError(f"Cannot build unknown entity '{name}'.") - - if ie._is_uptodate: - log.debug(f"'{name}' is up-to-date.") - return - - for dep_name in ie.depends_on: - self.make(dep_name, **make_args) - - log.info(f"Building: {name}") - for step in ie.how_to_make: - step(**make_args) - - for also_name in ie.also_updates: - if (other := self.entities.get(also_name)): - other._is_uptodate = True - - ie._is_uptodate = True +from __future__ import annotations + +import logging +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import Any + +log = logging.getLogger("nmagmake") + + +@dataclass +class InferenceEntity: + name: str + depends_on: list[str] = field(default_factory=lambda: []) + how_to_make: list[Callable[..., Any]] = field(default_factory=lambda: []) + also_updates: list[str] = field(default_factory=lambda: []) + _is_prerequisite: list[str] = field(default_factory=lambda: [], init=False) + _is_uptodate: bool = field(default=False, init=False) + + +class InferenceEngine: + def __init__(self, entities: list[dict[str, Any]] | None = None) -> None: + self.entities: dict[str, InferenceEntity] = {} + if entities: + for e_desc in entities: + ie = InferenceEntity(**e_desc) + self.entities[ie.name] = ie + + self._build_backlinks() + self._check_for_cycles() + + def _build_backlinks(self) -> None: + """Connects dependencies to their dependents and validates existence.""" + for name, ie in self.entities.items(): + for dep in ie.depends_on: + if (ie_dep := self.entities.get(dep)) is None: + raise KeyError(f"Dependency '{dep}' required by '{name}' not found.") + ie_dep._is_prerequisite.append(name) + + def _check_for_cycles(self) -> None: + """Detects circular dependencies using DFS to prevent infinite recursion.""" + visited: set[str] = set() + rec_stack: set[str] = set() + + def has_cycle(name: str) -> bool: + visited.add(name) + rec_stack.add(name) + + for neighbor in self.entities[name].depends_on: + if neighbor not in visited: + if has_cycle(neighbor): + return True + elif neighbor in rec_stack: + return True + + rec_stack.remove(name) + return False + + for node in self.entities: + if node not in visited: + if has_cycle(node): + raise ValueError(f"Circular dependency detected involving '{node}'") + + def invalidate(self, name: str) -> None: + """Recursively marks an entity and its dependents as dirty.""" + if (ie := self.entities.get(name)) is None: + raise KeyError(f"Entity '{name}' is unknown.") + + if ie._is_uptodate: + ie._is_uptodate = False + log.debug(f"Invalidated: {name}") + for dependent_name in ie._is_prerequisite: + self.invalidate(dependent_name) + + def make(self, name: str, **make_args: Any) -> None: + """Builds target and dependencies recursively.""" + if (ie := self.entities.get(name)) is None: + raise KeyError(f"Cannot build unknown entity '{name}'.") + + if ie._is_uptodate: + log.debug(f"'{name}' is up-to-date.") + return + + for dep_name in ie.depends_on: + self.make(dep_name, **make_args) + + log.info(f"Building: {name}") + for step in ie.how_to_make: + step(**make_args) + + for also_name in ie.also_updates: + if other := self.entities.get(also_name): + other._is_uptodate = True + + ie._is_uptodate = True diff --git a/src/simulation/py.typed b/src/simulation/py.typed new file mode 100644 index 0000000..534be2b --- /dev/null +++ b/src/simulation/py.typed @@ -0,0 +1 @@ +# Marker for PEP 561 inline type information. diff --git a/src/simulation/quantity.py b/src/simulation/quantity.py index dc2ddbb..566c3af 100644 --- a/src/simulation/quantity.py +++ b/src/simulation/quantity.py @@ -1,76 +1,88 @@ -from dataclasses import dataclass -from typing import Optional, List, Dict -from si.physical import SI - -@dataclass(frozen=True) -class Quantity: - """A dataclass describing a physical quantity for data saving. - - A quantity is a description of any thing that may be saved inside - a ndt file. It may just represent an integer-floating point number - or a vector field. The Quantity class is a descriptor for such a thing. - It contains details about the corresponding type, SI unit, the id name, - the signature (indices and per-material). - """ - name: str - type: str # 'int', 'float', 'field', 'pfield', 'date' - units: Optional[SI] - signature: Optional[str] = None - context: Optional[str] = None - parent: Optional['Quantity'] = None - - def __post_init__(self): - if self.signature is None: - object.__setattr__(self, 'signature', self.name) - - def sub_quantity(self, name: str) -> 'Quantity': - """Creates a new Quantity representing a sub-component.""" - return self.__class__( - name=name, - type=self.type, - units=self.units, - signature=self.signature, - context=self.context, - parent=self - ) - -known_quantities: List[Quantity] = [ - # name type unit signature context - Quantity('id', 'int', SI(1), None), - Quantity('step', 'int', SI(1), None), - Quantity('stage_step', 'int', SI(1), None), - Quantity('stage', 'int', SI(1), None), - Quantity('last_time_dt', 'float', SI('s'), None), - Quantity('time', 'float', SI('s'), None), - Quantity('stage_time', 'float', SI('s'), None), - Quantity('real_time', 'float', SI('s'), None), - Quantity('unixtime', 'float', SI('s'), None), - Quantity('maxangle', 'float', SI(1), None), - Quantity('localtime', 'date', None, None), - Quantity('H_total', 'field', SI('A/m'), '_?_*'), - Quantity('M', 'field', SI('A/m'), '_?_*'), - Quantity('m', 'pfield', SI(1), '_?_*'), - Quantity('pin', 'pfield', SI(1), None), - Quantity('current_density', 'pfield', SI('A/m^2'), '_*', 'stt'), - Quantity('dmdt', 'field', SI('1/s'), '_?_*'), - Quantity('dm_dcurrent', 'field', SI('1/s'), '_?_*', 'stt'), - Quantity('H_ext', 'field', SI('A/m'), '_*'), - Quantity('H_anis', 'field', SI('A/m'), '_?_*', 'anis'), - Quantity('H_exch', 'field', SI('A/m'), '_?_*', 'exch'), - Quantity('H_demag', 'field', SI('A/m'), '_*', 'demag'), - Quantity('E_total', 'field', SI('J/m^3'), '_?'), - Quantity('E_ext', 'field', SI('J/m^3'), '_?'), - Quantity('E_anis', 'field', SI('J/m^3'), '_?', 'anis'), - Quantity('E_exch', 'field', SI('J/m^3'), '_?', 'exch'), - Quantity('E_demag', 'field', SI('J/m^3'), '_?', 'demag'), - Quantity('phi', 'field', SI('A'), None, 'demag'), - Quantity('rho', 'field', SI('A/m^2'), None, 'demag') -] - -known_quantities_by_name: Dict[str, Quantity] = { - q.name: q for q in known_quantities -} - -known_field_quantities: List[Quantity] = [ - q for q in known_quantities if q.type in ('field', 'pfield') -] +from dataclasses import dataclass +from typing import Optional + +from si.physical import SI + + +@dataclass(frozen=True) +class Quantity: + """A dataclass describing a physical quantity for data saving. + + A quantity is a description of any thing that may be saved inside + a ndt file. It may just represent an integer-floating point number + or a vector field. The Quantity class is a descriptor for such a thing. + It contains details about the corresponding type, SI unit, the id name, + the signature (indices and per-material). + """ + + name: str + type: str # 'int', 'float', 'field', 'pfield', 'date' + units: SI | None + signature: str | None = None + context: str | None = None + parent: Optional["Quantity"] = None + + def __post_init__(self) -> None: + if self.signature is None: + object.__setattr__(self, "signature", self.name) + + def sub_quantity(self, name: str) -> "Quantity": + """Creates a new Quantity representing a sub-component.""" + return self.__class__( + name=name, + type=self.type, + units=self.units, + signature=self.signature, + context=self.context, + parent=self, + ) + + +UNIT_DIMENSIONLESS = SI(1) +UNIT_SECONDS = SI("s") +UNIT_A_PER_M = SI("A/m") +UNIT_A_PER_M2 = SI("A/m^2") +UNIT_A_PER_M_PER_S = SI("A/m/s") +UNIT_INVERSE_SECONDS = SI("1/s") +UNIT_J_PER_M3 = SI("J/m^3") +UNIT_AMPERE = SI("A") + + +known_quantities: list[Quantity] = [ + # name type unit signature context + Quantity("id", "int", UNIT_DIMENSIONLESS, None), + Quantity("step", "int", UNIT_DIMENSIONLESS, None), + Quantity("stage_step", "int", UNIT_DIMENSIONLESS, None), + Quantity("stage", "int", UNIT_DIMENSIONLESS, None), + Quantity("last_step_dt", "float", UNIT_SECONDS, None), + Quantity("time", "float", UNIT_SECONDS, None), + Quantity("stage_time", "float", UNIT_SECONDS, None), + Quantity("real_time", "float", UNIT_SECONDS, None), + Quantity("unixtime", "float", UNIT_SECONDS, None), + Quantity("maxangle", "float", UNIT_DIMENSIONLESS, None), + Quantity("localtime", "date", None, None), + Quantity("H_total", "field", UNIT_A_PER_M, "_?_*"), + Quantity("M", "field", UNIT_A_PER_M, "_?_*"), + Quantity("m", "pfield", UNIT_DIMENSIONLESS, "_?_*"), + Quantity("pin", "pfield", UNIT_DIMENSIONLESS, None), + Quantity("current_density", "pfield", UNIT_A_PER_M2, "_*", "stt"), + Quantity("dmdt", "field", UNIT_A_PER_M_PER_S, "_?_*"), + Quantity("dm_dcurrent", "field", UNIT_INVERSE_SECONDS, "_?_*", "stt"), + Quantity("H_ext", "field", UNIT_A_PER_M, "_*"), + Quantity("H_anis", "field", UNIT_A_PER_M, "_?_*", "anis"), + Quantity("H_exch", "field", UNIT_A_PER_M, "_?_*", "exch"), + Quantity("H_demag", "field", UNIT_A_PER_M, "_*", "demag"), + Quantity("E_total", "field", UNIT_J_PER_M3, "_?"), + Quantity("E_ext", "field", UNIT_J_PER_M3, "_?"), + Quantity("E_anis", "field", UNIT_J_PER_M3, "_?", "anis"), + Quantity("E_exch", "field", UNIT_J_PER_M3, "_?", "exch"), + Quantity("E_demag", "field", UNIT_J_PER_M3, "_?", "demag"), + Quantity("phi", "field", UNIT_AMPERE, None, "demag"), + Quantity("rho", "field", UNIT_A_PER_M2, None, "demag"), +] + +known_quantities_by_name: dict[str, Quantity] = {q.name: q for q in known_quantities} + +known_field_quantities: list[Quantity] = [ + q for q in known_quantities if q.type in ("field", "pfield") +] diff --git a/src/simulation/simulation_core.py b/src/simulation/simulation_core.py index 46dac5b..9fb9ae4 100644 --- a/src/simulation/simulation_core.py +++ b/src/simulation/simulation_core.py @@ -1,4 +1,4 @@ -""" +r""" Module which defines the SimulationCore class, an abstract class from which the real simulation objects are derived (by inheritance). Such a design has the goal of separating the parts of the Simulation object @@ -10,30 +10,28 @@ SimulationCore ---| |---> Simulation \----> FESimulation ----/ """ + from __future__ import annotations -from abc import ABC, abstractmethod + import logging +from abc import ABC, abstractmethod +from collections.abc import Callable, Sequence from pathlib import Path -from typing import ( - Any, Callable, Dict, List, Optional, Tuple, Sequence, Union -) +from typing import Any -from clock import SimulationClock +from nmag.config import NmagConfig +from nmag.output import prepare_output_files from si.physical import SI -from quantity import ( - known_quantities, - known_quantities_by_name, - known_field_quantities -) -import hysteresis as hysteresis_m -from mock_features import MockFeatures -from data_writer import DataWriter -# This is a temporary stub to replace nsim.setup.get_features() -# until the full setup module is ported. -features = MockFeatures() +from . import hysteresis as hysteresis_m +from .clock import SimulationClock +from .data_writer import DataWriter +from .quantity import known_field_quantities, known_quantities, known_quantities_by_name + +Action = Callable[[Any], Any] + +log = logging.getLogger("nmag") -log = logging.getLogger('nmag') class SimulationCore(ABC): """ @@ -43,39 +41,38 @@ class SimulationCore(ABC): that is independent of the specific discretisation (FD/FE). """ - def __init__(self, - name: Optional[str] = None, - do_demag: bool = True, - sim_id: str = "Generic Simulation class"): - - self.class_id: str = sim_id # String identifying the kind of Simulation class - self.units: Optional[Any] = None # Simulation units used by this class - self.do_demag: bool = do_demag # Whether we should include the demag field + def __init__( + self, + name: str | None = None, + do_demag: bool = True, + sim_id: str = "Generic Simulation class", + config: NmagConfig | None = None, + ) -> None: + + self.class_id: str = sim_id # String identifying the kind of Simulation class + self.units: Any | None = None # Simulation units used by this class + self.do_demag: bool = do_demag # Whether we should include the demag field # List of all the materials used by the Simulation object - self.materials: Optional[List[Any]] = None + self.materials: list[Any] | None = None # Dictionary used by the hysteresis method to find abbreviations for # frequently used things to save or do. # Example: for ``sim.hysteresis(..., save=[('averages', at(...))])`` # the string 'averages' needs to be a key in this dictionary. # The corresponding value is the function to call. - self.action_abbreviations: Dict[str, Callable] = {} + self.action_abbreviations: dict[str, Action] = {} # Every quantity the user may want to save needs to be listed here used for data IO self.known_quantities = known_quantities self.known_quantities_by_name = known_quantities_by_name self.known_field_quantities = known_field_quantities - if name is None: - self.name: str = features.get('etc', 'runid') - else: - self.name: str = name + self.config = NmagConfig.from_environment() if config is None else config + self.name = name or self.config.default_name log.info(f"Simulation(name={self.name}) object created") self._restarting: bool = False - data_filenames: List[Path] = [ - self._ndtfilename(), self._h5filename(), self._tolfilename() - ] + data_filenames: list[Path] = [self._ndtfilename(), self._h5filename()] self._manage_output_files(data_filenames) self.clock: SimulationClock = SimulationClock() @@ -93,30 +90,24 @@ def __init__(self, # Add abbreviations so that things can be saved just by giving # corresponding ID strings. # Example: hysteresis(..., save=[('averages', ...)]) + self.add_save_abbrev("save_averages", lambda sim: sim.save_data(avoid_same_step=True)) self.add_save_abbrev( - 'save_averages', - lambda sim: sim.save_data(avoid_same_step=True) - ) - self.add_save_abbrev( - 'save_fields', - lambda sim: sim.save_data(fields='all', avoid_same_step=True) - ) - self.add_save_abbrev( - 'save_field_m', - lambda sim: sim.save_data(fields=['m'], avoid_same_step=True) + "save_fields", lambda sim: sim.save_data(fields="all", avoid_same_step=True) ) self.add_save_abbrev( - 'save_restart', - lambda sim: sim.save_restart_file() + "save_field_m", lambda sim: sim.save_data(fields=["m"], avoid_same_step=True) ) - self.add_do_abbrev('do_next_stage', - SimulationCore.hysteresis_next_stage) - self.add_do_abbrev('do_exit', SimulationCore.hysteresis_exit) + self.add_save_abbrev("save_restart", lambda sim: sim.save_restart_file()) + self.add_do_abbrev("do_next_stage", SimulationCore.hysteresis_next_stage) + self.add_do_abbrev("do_exit", SimulationCore.hysteresis_exit) self.writer = DataWriter( ndt_filename=self._ndtfilename(), h5_filename=self._h5filename(), + append=self.config.output_policy == "append", ) + self.last_spatial_save_timings_seconds: dict[str, float] = {} + self.last_maxangle_timings_seconds: dict[str, float] = {} # The following list contains a description of the physics components # which are included in the physical model For example, @@ -125,35 +116,12 @@ def __init__(self, # is used to understand which fields are relevant and which are not # (so that we do not save empty fields). Following the previous # example, dm_dcurrent, current_density won't be saved. - self._components: Optional[List[str]] = None + self._components: list[str] | None = None - def _manage_output_files(self, data_filenames: List[Path]): - """ - Manages existing output files based on configuration (clean/restart). - """ - if features.get('nmag', 'clean', raw=True): - for file_path in data_filenames: - if file_path.exists(): - new_path = file_path.parent / (file_path.name + ".old") - log.info(f"Found old file {file_path}, renaming it to {new_path}") - file_path.rename(new_path) - - elif features.get('nmag', 'restart', raw=True): - log.info("Starting simulation in restart mode...") - self._restarting = True - - else: - for filename in data_filenames: - if filename.exists(): - msg = ( - f"Error: Found old file {filename} -- cannot proceed. " - "To start a simulation script with old data " - "files present you either need to use '--clean' " - "(and then the old files will be deleted), " - "or use '--restart' in which case the run " - "will be continued." - ) - raise FileExistsError(msg) + def _manage_output_files(self, data_filenames: list[Path]) -> None: + """Apply this simulation's explicit NDT/HDF5 output policy.""" + + prepare_output_files(data_filenames, self.config.output_policy) @property def id(self) -> int: @@ -171,7 +139,7 @@ def step(self) -> int: return self.clock.step @property - def time(self) -> float: + def time(self) -> SI: """Global time reached (always increases).""" return self.clock.time @@ -181,17 +149,22 @@ def stage_step(self) -> int: return self.clock.stage_step @property - def stage_time(self) -> float: + def stage_time(self) -> SI: """Time reached counted from the beginning of the current stage.""" return self.clock.stage_time @property - def real_time(self) -> float: + def real_time(self) -> SI: """Time passed in the 'real' world.""" return self.clock.real_time @property - def components(self) -> List[str]: + def last_step_dt(self) -> SI: + """Length of the last simulation step.""" + return self.clock.last_step_dt_si + + @property + def components(self) -> list[str]: """Get the physical components included in the model.""" if self._components is not None: return self._components @@ -203,15 +176,16 @@ def components(self) -> List[str]: self._components = components return components - def get_all_field_names(self) -> List[str]: + def get_all_field_names(self) -> list[str]: """Get all field names relevant to the enabled components.""" return [ - q.name for q in self.known_field_quantities + q.name + for q in self.known_field_quantities if q.context is None or q.context in self.components ] @staticmethod - def hysteresis_next_stage(sim: SimulationCore): + def hysteresis_next_stage(sim: SimulationCore) -> None: """ Terminate the current stage of the hysteresis computation and start the next one. @@ -219,7 +193,7 @@ def hysteresis_next_stage(sim: SimulationCore): sim.clock.stage_end = True @staticmethod - def hysteresis_exit(sim: SimulationCore): + def hysteresis_exit(sim: SimulationCore) -> None: """ Exit from the running hysteresis computation. """ @@ -228,14 +202,16 @@ def hysteresis_exit(sim: SimulationCore): simulation_relax = hysteresis_m.simulation_relax relax = simulation_relax - + simulation_hysteresis = hysteresis_m.simulation_hysteresis hysteresis = simulation_hysteresis - def add_action_abbrev(self, - abbreviation: str, - function: Callable, - prefix: Optional[str] = None): + def add_action_abbrev( + self, + abbreviation: str, + function: Action, + prefix: str | None = None, + ) -> None: """Add an abbreviation for a 'save' or 'do' action.""" if prefix is None: self.action_abbreviations[abbreviation] = function @@ -255,7 +231,7 @@ def add_action_abbrev(self, full_abbreviation = f"{prefix}_{abbreviation}" self.action_abbreviations[full_abbreviation] = function - def add_save_abbrev(self, abbreviation: str, function: Callable): + def add_save_abbrev(self, abbreviation: str, function: Action) -> None: """Add an abbreviation to be used in the 'save' argument of the hysteresis method. For example, if you use the following: @@ -270,13 +246,13 @@ def funky_function(sim): print "Hello, I'm Funky!" sim.hysteresis(Hs, save=[(funky_function, at('convergence')]) """ - self.add_action_abbrev(abbreviation, function, prefix='save') + self.add_action_abbrev(abbreviation, function, prefix="save") - def add_do_abbrev(self, abbreviation: str, function: Callable): + def add_do_abbrev(self, abbreviation: str, function: Action) -> None: """Add an abbreviation for the 'do' argument of hysteresis.""" - self.add_action_abbrev(abbreviation, function, prefix='do') + self.add_action_abbrev(abbreviation, function, prefix="do") - def do_next_stage(self, stage: Optional[int] = None): + def do_next_stage(self, stage: int | None = None) -> None: """Increment the simulation stage.""" self.clock.inc_stage(stage=stage) @@ -287,7 +263,7 @@ def is_converged(self) -> bool: def _get_filename(self, ext: str) -> Path: """Get the full, absolute path for an output file.""" basename = self.name + ext - return Path(features.get('etc', 'savedir')) / basename + return self.config.output_directory / basename def _ndtfilename(self) -> Path: return self._get_filename("_dat.ndt") @@ -301,35 +277,37 @@ def _statfilename(self) -> Path: def _tolfilename(self) -> Path: return self._get_filename("_tol.log") - def get_restart_file_name(self) -> str: + def get_restart_file_name(self) -> Path: """Return the default name for the restart file.""" - return f"{self.name}_restart.h5" + return self.config.output_directory / f"{self.name}_restart.h5" - def get_materials_of_field(self, field_name: str) -> List[Any]: + def get_materials_of_field(self, field_name: str) -> list[Any]: """ Returns all materials for a per-material field. - Returns an empty list if the field is not per-material or + Returns an empty list if the field is not per-material or if materials have not been defined yet. """ quantity = self.known_quantities_by_name[field_name] - - if '?' in (quantity.signature or ""): + + if "?" in (quantity.signature or ""): return self.materials if self.materials is not None else [] - - return [] + return [] @abstractmethod - def save_spatial_fields(self, - filename: Optional[str] = None, - fieldnames: List[str] = []): + def save_spatial_fields( + self, + filename: str | None = None, + fieldnames: list[str] | None = None, + ) -> None: """Abstract method to save spatially-resolved fields.""" pass - - def save_data(self, - fields: Optional[Union[str, List[str]]] = None, - avoid_same_step: bool = False): + def save_data( + self, + fields: str | list[str] | None = None, + avoid_same_step: bool = False, + ) -> None: """ Save simulation data. @@ -349,129 +327,132 @@ def save_data(self, self.writer.save(self, fields, avoid_same_step) @abstractmethod - def save_mesh(self, filename: str): + def save_mesh(self, filename: str) -> None: """Save the mesh to a file.""" pass @abstractmethod - def load_mesh(self, - filename: str, - region_names_and_mag_mats: Any, - unit_length: float, - do_reorder: bool = False, - manual_distribution: Any = None): + def load_mesh( + self, + filename: str, + region_names_and_mag_mats: Any, + unit_length: float, + do_reorder: bool = False, + manual_distribution: Any = None, + ) -> None: """Load a mesh from a file.""" pass @abstractmethod - def create_mesh(self, - cell_nums: Sequence[int], - cell_sizes: Sequence[float], - materials: Any, - regions: Optional[Callable] = None, - origin: Tuple[float, float, float] = (0.0, 0.0, 0.0)): + def create_mesh( + self, + cell_nums: Sequence[int], + cell_sizes: Sequence[float], + materials: Any, + regions: Callable[..., Any] | None = None, + origin: tuple[float, float, float] = (0.0, 0.0, 0.0), + ) -> None: """Create a new mesh.""" pass @abstractmethod - def set_params(self, - stopping_dm_dt: Optional[SI] = None, - ts_rel_tol: Optional[float] = None, - ts_abs_tol: Optional[float] = None): + def set_params( + self, + stopping_dm_dt: SI | None = None, + ts_rel_tol: float | None = None, + ts_abs_tol: float | None = None, + ) -> None: """Set simulation parameters.""" pass @abstractmethod - def reinitialise(self, initial_time: Optional[float] = None): + def reinitialise(self, initial_time: float | None = None) -> None: """Re-initialise the simulation.""" pass @abstractmethod - def set_local_magnetic_coupling(self, mat1: Any, mat2: Any, coupling: Any): + def set_local_magnetic_coupling(self, mat1: Any, mat2: Any, coupling: Any) -> None: """Set local magnetic coupling between materials.""" pass @abstractmethod - def set_H_ext(self, values: Any, unit: Optional[SI] = None): + def set_H_ext(self, values: Any, unit: SI | None = None) -> None: """Set the external magnetic field.""" pass @abstractmethod - def set_m(self, values: Any, subfieldname: Optional[str] = None): + def set_m(self, values: Any, subfieldname: str | None = None) -> None: """Set the magnetization.""" pass @abstractmethod - def set_pinning(self, values: Any): + def set_pinning(self, values: Any) -> None: """Set pinning sites.""" pass @abstractmethod - def set_current_density(self, values: Any, unit: Optional[SI] = None): + def set_current_density(self, values: Any, unit: SI | None = None) -> None: """Set current density.""" pass @abstractmethod - def advance_time(self, - target_time: SI, - max_it: int = -1, - exact_tstop: Optional[bool] = None): + def advance_time( + self, + target_time: SI, + max_it: int = -1, + exact_tstop: bool | None = None, + ) -> SI: """Advance the simulation time.""" pass @abstractmethod - def save_restart_file(self, - filename: Optional[str] = None, - fieldnames: List[str] = ['m'], - all: bool = False): + def save_restart_file( + self, filename: str | None = None, fieldnames: list[str] | None = None, all: bool = False + ) -> None: """Save a restart file.""" pass @abstractmethod - def load_restart_file(self, filename: Optional[str] = None): + def load_restart_file(self, filename: str | None = None) -> None: """Load a restart file.""" pass @abstractmethod - def save_m_to_file(self, - filename: str, - format: Optional[str] = None): + def save_m_to_file(self, filename: str, format: str | None = None) -> None: """Save magnetization to a file.""" pass @abstractmethod - def load_m_from_file(self, - filename: str, - format: Optional[str] = None): + def load_m_from_file(self, filename: str, format: str | None = None) -> None: """Load magnetization from a file.""" pass @abstractmethod - def probe_subfield(self, - subfieldname: str, - pos: SI, - unit: Optional[SI] = None): + def probe_subfield(self, subfieldname: str, pos: SI, unit: SI | None = None) -> Any: """Probe a subfield at a specific position.""" pass @abstractmethod - def probe_subfield_siv(self, - subfieldname: str, - pos: List[float], - unit: Optional[SI] = None): + def probe_subfield_siv( + self, + subfieldname: str, + pos: list[float], + unit: SI | None = None, + ) -> Any: """Probe a subfield returning SI values.""" pass @abstractmethod - def get_subfield(self, - subfieldname: str, - units: Optional[SI] = None): + def get_subfield(self, subfieldname: str, units: SI | None = None) -> Any: """Get the entire array for a subfield.""" pass @abstractmethod - def get_subfield_average(self, - subfieldname: str, - mat_name: Optional[str] = None): + def get_subfield_average(self, subfieldname: str, mat_name: str | None = None) -> Any: """Get the average value of a subfield.""" pass + + @abstractmethod + def get_maxangle_average(self, field_name: str) -> float | None: + """Get the average maximum angle for a field.""" + pass diff --git a/src/throttler/__init__.py b/src/throttler/__init__.py index 45b7b98..30e7a81 100644 --- a/src/throttler/__init__.py +++ b/src/throttler/__init__.py @@ -1 +1,3 @@ -from .throttler import * +from .throttler import Throttler + +__all__ = ["Throttler"] diff --git a/src/throttler/py.typed b/src/throttler/py.typed new file mode 100644 index 0000000..534be2b --- /dev/null +++ b/src/throttler/py.typed @@ -0,0 +1 @@ +# Marker for PEP 561 inline type information. diff --git a/src/throttler/throttler.py b/src/throttler/throttler.py index d2d8632..8b4a87f 100644 --- a/src/throttler/throttler.py +++ b/src/throttler/throttler.py @@ -1,11 +1,13 @@ import time -from typing import Hashable +from collections.abc import Hashable """ This replaces the reporttools.py from the original nmag repo. https://github.com/nmag-project/nmag-src/blob/master/interface/nsim/reporttools.py. It is converted into a class to avoid global variables and uses monotonic time instead. """ + + class Throttler: """ Manages state to allow actions at most every N seconds per key. @@ -15,7 +17,8 @@ class Throttler: a unique key to the timestamp (from time.monotonic()) of its last allowed call. """ - def __init__(self): + + def __init__(self) -> None: """Initializes the throttle state.""" # Stores the time of the last successful call for each key self.last_called: dict[Hashable, float] = {} diff --git a/src/when/__init__.py b/src/when/__init__.py index 7aa6086..e68378d 100644 --- a/src/when/__init__.py +++ b/src/when/__init__.py @@ -1 +1,3 @@ -from .when import * +from .when import NextTime, TimeDict, When, at, every, never + +__all__ = ["NextTime", "TimeDict", "When", "at", "every", "never"] diff --git a/src/when/py.typed b/src/when/py.typed new file mode 100644 index 0000000..534be2b --- /dev/null +++ b/src/when/py.typed @@ -0,0 +1 @@ +# Marker for PEP 561 inline type information. diff --git a/src/when/when.py b/src/when/when.py index 6e3eb4c..85a98a7 100644 --- a/src/when/when.py +++ b/src/when/when.py @@ -5,10 +5,18 @@ import abc import math -from typing import Any, Dict, Union, Optional +from typing import Any, Protocol -TimeDict = Dict[str, Any] -NextTime = Union[bool, int, float] + +class TimeDict(Protocol): + def __getitem__(self, key: str, /) -> Any: ... + + def get(self, key: str, default: Any = None, /) -> Any: ... + + def copy(self) -> dict[str, Any]: ... + + +NextTime = Any def _float_is_integer(f: float) -> bool: @@ -17,6 +25,7 @@ def _float_is_integer(f: float) -> bool: # --- Abstract Base Class for Specification Logic --- + class _WhenSpec(abc.ABC): """ Abstract base class for a time specification. @@ -42,10 +51,11 @@ def next_time(self, identifier: str, this_time: TimeDict) -> NextTime: # --- Concrete Specification Implementations --- + class _AtSpec(_WhenSpec): """Specification for a single point in time.""" - - def __init__(self, identifier: str, value: Any): + + def __init__(self, identifier: str, value: Any) -> None: self.identifier = identifier self.value = value @@ -58,7 +68,7 @@ def match_time(self, this_time: TimeDict) -> bool: def next_time(self, identifier: str, this_time: TimeDict) -> NextTime: if self.identifier != identifier: return True # Irrelevant to this identifier, so don't block - + t = this_time.get(identifier) if isinstance(t, bool): if self.value is True: @@ -75,8 +85,13 @@ def next_time(self, identifier: str, this_time: TimeDict) -> NextTime: class _EverySpec(_WhenSpec): """Specification for a periodic event.""" - def __init__(self, identifier: str, delta: Optional[float], - first: float, last: Optional[float]): + def __init__( + self, + identifier: str, + delta: Any | None, + first: Any, + last: Any | None, + ) -> None: self.identifier = identifier self.delta = delta self.first = first @@ -88,7 +103,7 @@ def __repr__(self) -> str: opts += f", first={self.first}" if self.last is not None: opts += f", last={self.last}" - + delta_str = str(self.delta) if self.delta is not None else "None" return f"every({delta_str}, {self.identifier!r}{opts})" @@ -96,19 +111,17 @@ def match_time(self, this_time: TimeDict) -> bool: t = this_time.get(self.identifier) if t is None: return False - - if self.first is not None and t < self.first: + + if t < self.first: return False if self.last is not None and t > self.last: return False if self.delta is None: return True - - assert self.first is not None, "delta requires a 'first' value" - + if self.delta <= 0: return False - + pos = float((t - self.first) / self.delta) return _float_is_integer(pos) @@ -122,38 +135,35 @@ def next_time(self, identifier: str, this_time: TimeDict) -> NextTime: if self.last is not None and t >= self.last: return False - - if self.first is not None: - if t < self.first and self.delta is not None: - return self.first - + + if t < self.first and self.delta is not None: + return self.first + if self.delta is None: return True - assert self.first is not None, "delta requires a 'first' value" - if self.delta <= 0: return False pos = float((t - self.first) / self.delta) - + if _float_is_integer(pos): next_pos = int(round(pos)) + 1 else: next_pos = int(pos) + 1 - + next_t = self.delta * next_pos + self.first if self.last is not None and next_t > self.last: return False - + return next_t class _OrSpec(_WhenSpec): """Specification for a logical OR of two specifications.""" - def __init__(self, spec1: _WhenSpec, spec2: _WhenSpec): + def __init__(self, spec1: _WhenSpec, spec2: _WhenSpec) -> None: self.spec1 = spec1 self.spec2 = spec2 @@ -161,8 +171,7 @@ def __repr__(self) -> str: return f"({self.spec1!r} | {self.spec2!r})" def match_time(self, this_time: TimeDict) -> bool: - return self.spec1.match_time(this_time) or \ - self.spec2.match_time(this_time) + return self.spec1.match_time(this_time) or self.spec2.match_time(this_time) def next_time(self, identifier: str, this_time: TimeDict) -> NextTime: nt1 = self.spec1.next_time(identifier, this_time) @@ -173,7 +182,7 @@ def next_time(self, identifier: str, this_time: TimeDict) -> NextTime: if nt1_is_bool and nt2_is_bool: return nt1 or nt2 - + if nt1 is False: return nt2 if nt2 is False: @@ -183,14 +192,14 @@ def next_time(self, identifier: str, this_time: TimeDict) -> NextTime: return True if nt2 is True: return True - + return min(nt1, nt2) class _AndSpec(_WhenSpec): """Specification for a logical AND of two specifications.""" - def __init__(self, spec1: _WhenSpec, spec2: _WhenSpec): + def __init__(self, spec1: _WhenSpec, spec2: _WhenSpec) -> None: self.spec1 = spec1 self.spec2 = spec2 @@ -198,18 +207,17 @@ def __repr__(self) -> str: return f"({self.spec1!r} & {self.spec2!r})" def match_time(self, this_time: TimeDict) -> bool: - return self.spec1.match_time(this_time) and \ - self.spec2.match_time(this_time) + return self.spec1.match_time(this_time) and self.spec2.match_time(this_time) def next_time(self, identifier: str, this_time: TimeDict) -> NextTime: # Use a copy to avoid side effects on the original time dict temp_time = this_time.copy() save_t = temp_time.get(identifier) if save_t is None: - return False # Can't calculate if identifier is missing + return False # Can't calculate if identifier is missing + + ntmax: int | float | None = None - ntmax: Optional[Union[int, float]] = None - both_match = False while not both_match: nt1 = self.spec1.next_time(identifier, temp_time) @@ -231,7 +239,6 @@ def next_time(self, identifier: str, this_time: TimeDict) -> NextTime: temp_time[identifier] = ntmax both_match = argmin.match_time(temp_time) - assert ntmax is not None, "Logic error: 'and' loop exited without setting ntmax" return ntmax @@ -239,7 +246,7 @@ def next_time(self, identifier: str, this_time: TimeDict) -> NextTime: class _NeverSpec(_WhenSpec): """Specification that never matches.""" - + def __repr__(self) -> str: return "never" @@ -252,14 +259,15 @@ def next_time(self, identifier: str, this_time: TimeDict) -> NextTime: # --- Public-Facing 'When' Class --- + class When: + """Combine a schedule condition with ``|`` or ``&`` operators. + + Users normally create instances with :func:`at` and :func:`every` rather + than calling this constructor directly. """ - Class used to express when a certain thing should be done. - - This class is a wrapper around a 'spec' object that implements - the actual logic. - """ - def __init__(self, spec: _WhenSpec): + + def __init__(self, spec: _WhenSpec) -> None: self.spec = spec def __repr__(self) -> str: @@ -269,28 +277,30 @@ def __str__(self) -> str: return repr(self.spec) def match_time(self, this_time: TimeDict) -> bool: - """Checks if the specification matches the given time.""" + """Return whether this condition matches a simulation clock mapping.""" return self.spec.match_time(this_time) - def next_time(self, identifier: str, this_time: TimeDict, - tols: Optional[Dict[str, float]] = None) -> NextTime: - """ - Calculates the next matching time for the given identifier. - - The 'tols' argument is used to apply a tolerance to prevent - re-triggering at the exact same time due to floating point - inaccuracies. + def next_time( + self, identifier: str, this_time: TimeDict, tols: dict[str, Any] | None = None + ) -> NextTime: + """Return the next match for one clock identifier. + + Args: + identifier: Clock key such as ``step``, ``time``, or ``stage_time``. + this_time: Current clock-like mapping. + tols: Optional per-identifier tolerance preventing a floating-point + boundary from triggering repeatedly. + + Returns: + Next matching value or a boolean sentinel used by schedule merging. """ nt = self.spec.next_time(identifier, this_time) # Apply tolerance logic from the original class - if (tols is not None - and identifier in tols - and isinstance(nt, (int, float))): - + if tols is not None and identifier in tols and type(nt) is not bool: tol = tols[identifier] tt = this_time[identifier] - + if tol > 0.0 and abs(nt - tt) < tol: # We are too close to the current time. # Advance time slightly and recalculate. @@ -300,16 +310,16 @@ def next_time(self, identifier: str, this_time: TimeDict, return nt - def __or__(self, other: "When") -> "When": + def __or__(self, other: object) -> "When": """Combines two 'When' objects with a logical OR.""" if not isinstance(other, When): return NotImplemented return When(_OrSpec(self.spec, other.spec)) - def __and__(self, other: "When") -> "When": + def __and__(self, other: object) -> "When": """ Combines two 'When' objects with a logical AND. - + WARNING: As in the original, this can lead to infinite loops if the two conditions are mutually exclusive (e.g., every('step', 2) & every('step', 2, first=1)). @@ -322,39 +332,58 @@ def __and__(self, other: "When") -> "When": # --- Factory Functions (Public API) --- + def at(identifier: str, value: Any = True) -> When: - """ - Specifies an action at an exact point in time. - Examples: at('convergence'), at('step', 10) + """Create a condition matching one exact clock value or event. + + Args: + identifier: Clock key or event such as ``"step"``, ``"time"``, + ``"convergence"``, or ``"stage_end"``. + value: Exact value to match. Boolean events default to true. + + Returns: + Schedule condition, for example ``at("step", 10)``. """ return When(_AtSpec(identifier, value)) -def every(arg1: Union[str, float, int], - arg2: Optional[Union[str, float, int]] = None, - first: float = 0.0, - last: Optional[float] = None) -> When: - """ - Specifies an action that should be performed periodically. - Examples: - every('step', 10) - every('step', 5, first=10, last=100) - every(10, 'step') # Legacy support, should not be used +def every( + arg1: Any, + arg2: Any | None = None, + first: Any = 0.0, + last: Any | None = None, +) -> When: + """Create a periodic clock condition. + + Args: + arg1: Preferred clock identifier, such as ``"step"`` or ``"time"``. + A legacy delta-first call is also accepted. + arg2: Positive interval in clock units, or the identifier in the legacy + argument order. + first: First value eligible to match. + last: Optional final eligible value; it must exceed ``first``. + + Returns: + Periodic condition, for example ``every("step", 10)``. + + Raises: + ValueError: If no identifier is supplied, the interval is non-positive, + or the requested range is invalid. """ - - identifier: Optional[str] = None - delta: Optional[Union[float, int]] = None + + identifier: str | None = None + delta: Any | None = None # Handle swapped arguments with explicit type-checking if isinstance(arg1, str): identifier = arg1 - if isinstance(arg2, (int, float)): + if arg2 is not None and not isinstance(arg2, str): delta = arg2 elif arg2 is None: delta = None # e.g., every('step', first=10) # If arg2 is a str, we let it fail validation below - - elif isinstance(arg1, (int, float)): + + else: delta = arg1 if isinstance(arg2, str): identifier = arg2 @@ -377,12 +406,8 @@ def every(arg1: Union[str, float, int], ) # 3. Check delta - if delta is not None: - if delta <= 0: - raise ValueError( - "Bad usage of 'every': delta must be positive. " - f"Got delta={delta}" - ) + if delta is not None and delta <= 0: + raise ValueError(f"Bad usage of 'every': delta must be positive. Got delta={delta}") return When(_EverySpec(identifier, delta, first, last)) diff --git a/tests/anisotropy_test.py b/tests/anisotropy_test.py index 52a561b..f801e67 100644 --- a/tests/anisotropy_test.py +++ b/tests/anisotropy_test.py @@ -1,193 +1,318 @@ -import unittest -import numpy as np - -# Import the classes and functions to be tested -from anisotropy import ( - PredefinedAnisotropy, - uniaxial_anisotropy, - cubic_anisotropy, - want_anisotropy, -) -# Import the hidden helper function for normalization directly since it isn't exposed in the init file -from anisotropy.anisotropy import _normalize - -class TestAnisotropy(unittest.TestCase): - """Unit tests for the anisotropy definitions and helper functions.""" - - ## Test Helper Functions - # -------------------------------------------------------------------------- - - def testnormalize(self): - """Tests the vector normalization helper function.""" - v = [1, 2, 3] - norm_v = _normalize(v) - # Check that the norm is 1 - self.assertAlmostEqual(np.linalg.norm(norm_v), 1.0) - # Check that the direction is correct - np.testing.assert_array_almost_equal(norm_v, np.array(v) / np.linalg.norm(v)) - # Check that a zero vector raises a ValueError - with self.assertRaises(ValueError): - _normalize([0, 0, 0]) - - def test_want_anisotropy(self): - """Tests the anisotropy object validation function.""" - a_with_func = PredefinedAnisotropy(function=lambda m: 0, order=2) - a_without_func = PredefinedAnisotropy(order=2) - - # These should pass without error - want_anisotropy(a_with_func, want_function=True) - want_anisotropy(a_without_func, want_function=False) - - # Test for TypeError with incorrect object type - with self.assertRaises(TypeError): - want_anisotropy("not an anisotropy object") - - # Test for ValueError when a function is required but missing - with self.assertRaises(ValueError): - want_anisotropy(a_without_func, want_function=True) - - ## Test PredefinedAnisotropy Class - # -------------------------------------------------------------------------- - - def test_predefined_anisotropy_init(self): - """Tests the constructor of the PredefinedAnisotropy class.""" - # Test successful creation with a function and order - a = PredefinedAnisotropy(function=lambda m: 1, order=2) - self.assertTrue(a.has_function()) - self.assertEqual(a.order, 2) - - # Test successful creation with only an order - b = PredefinedAnisotropy(order=4) - self.assertFalse(b.has_function()) - self.assertEqual(b.order, 4) - - # Test that initialization fails if neither 'function' nor 'order' is given - with self.assertRaises(ValueError): - PredefinedAnisotropy() - - # Test that axes are correctly converted to NumPy arrays - c = PredefinedAnisotropy(order=2, axis1=[1, 0, 0]) - self.assertIsInstance(c.axis1, np.ndarray) - - def test_predefined_anisotropy_str_repr(self): - """Tests the string representations (__str__ and __repr__) of the class.""" - # Test without a custom stringifier - a = PredefinedAnisotropy(anis_type="test", order=2) - self.assertEqual(str(a), "") - self.assertEqual(repr(a), 'PredefinedAnisotropy(anis_type="test", ?)') - - # Test with a custom stringifier - stringifier = lambda x: f"order={x.order}" - b = PredefinedAnisotropy(anis_type="test", order=2, stringifier=stringifier) - self.assertEqual(str(b), "") - self.assertEqual(repr(b), 'PredefinedAnisotropy(anis_type="test", order=2)') - - def test_predefined_anisotropy_operators(self): - """Tests the overloaded operators (+, -, neg, pos).""" - a1 = PredefinedAnisotropy(function=lambda m: np.dot(m, m), order=2) - a2 = PredefinedAnisotropy(function=lambda m: 2 * np.dot(m, m), order=4) - m_vec = np.array([0.5, 0.5, 0.5]) - - # Test unary positive operator (+) - pos_a1 = +a1 - self.assertIs(pos_a1, a1) # Should return the same object - - # Test unary negative operator (-) - neg_a1 = -a1 - self.assertIsNot(neg_a1, a1) # Should return a new object - self.assertEqual(neg_a1.function(m_vec), -a1.function(m_vec)) - self.assertEqual(neg_a1.order, a1.order) - - # Test addition (+) - add_res = a1 + a2 - self.assertEqual(add_res.order, 4) # Order should be max(2, 4) - self.assertAlmostEqual(add_res.function(m_vec), 3 * np.dot(m_vec, m_vec)) - - # Test subtraction (-) - sub_res = a2 - a1 - self.assertEqual(sub_res.order, 4) # Order should be max(4, 2) - self.assertAlmostEqual(sub_res.function(m_vec), np.dot(m_vec, m_vec)) - - # Test that operations with invalid types raise TypeError - with self.assertRaises(TypeError): - _ = a1 + 5 - with self.assertRaises(TypeError): - _ = a1 - "some_string" - - ## Test Factory Functions - # -------------------------------------------------------------------------- - - def test_uniaxial_anisotropy(self): - """Tests the uniaxial_anisotropy factory function.""" - axis = [0, 0, 1] - K1 = 100.0 - K2 = 10.0 - anis = uniaxial_anisotropy(axis, K1, K2) - - self.assertEqual(anis.anis_type, "uniaxial") - self.assertEqual(anis.order, 4) - self.assertEqual(anis.K1, K1) - self.assertEqual(anis.K2, K2) - np.testing.assert_array_almost_equal(anis.axis1, [0, 0, 1]) - - # Test energy when magnetization is parallel to the axis - m_parallel = [0, 0, 1] - expected_energy_parallel = -K1 * (1)**2 - K2 * (1)**4 - self.assertAlmostEqual(anis.function(m_parallel), expected_energy_parallel) - - # Test energy when magnetization is perpendicular to the axis - m_perp = [1, 0, 0] - self.assertAlmostEqual(anis.function(m_perp), 0.0) - - # Test case with only K1 - anis_k1 = uniaxial_anisotropy(axis, K1) - self.assertEqual(anis_k1.order, 2) - self.assertEqual(anis_k1.K2, 0) - self.assertAlmostEqual(anis_k1.function(m_parallel), -K1) - - # Test that the input axis is correctly normalized - anis_unnormalized = uniaxial_anisotropy([0, 5, 0], K1) - np.testing.assert_array_almost_equal(anis_unnormalized.axis1, [0, 1, 0]) - - def test_cubic_anisotropy(self): - """Tests the cubic_anisotropy factory function.""" - ax1, ax2 = [1, 0, 0], [0, 1, 0] - K1, K2, K3 = 100.0, 10.0, 1.0 - anis = cubic_anisotropy(ax1, ax2, K1, K2, K3) - - self.assertEqual(anis.anis_type, "cubic") - self.assertEqual(anis.order, 8) - np.testing.assert_array_almost_equal(anis.axis1, [1, 0, 0]) - np.testing.assert_array_almost_equal(anis.axis2, [0, 1, 0]) - np.testing.assert_array_almost_equal(anis.axis3, [0, 0, 1]) - - # Test energy when magnetization is along a primary axis (should be 0) - m_axis = [0, 1, 0] - self.assertAlmostEqual(anis.function(m_axis), 0.0) - - # Test energy when magnetization is along a face diagonal [110] - m_face_diag = _normalize([1, 1, 0]) # [1/sqrt(2), 1/sqrt(2), 0] - # Expected energy: K1 * (a1^2*a2^2) + K3 * (a1^2*a2^2)^2 - # E = K1 * (0.5*0.5) + K3 * (0.5*0.5)^2 = K1/4 + K3/16 - self.assertAlmostEqual(anis.function(m_face_diag), K1/4 + K3/16) - - # Test energy when magnetization is along a space diagonal [111] - m_space_diag = _normalize([1, 1, 1]) - # a1=a2=a3 = 1/sqrt(3), so a_i^2 = 1/3 - # E = K1*(3*(1/3*1/3)) + K2*(1/3*1/3*1/3) + K3*(3*(1/3*1/3)^2) - # E = K1/3 + K2/27 + K3/27 - self.assertAlmostEqual(anis.function(m_space_diag), K1/3 + K2/27 + K3/27) - - # Test automatic order detection - self.assertEqual(cubic_anisotropy(ax1, ax2, K1, K2, 0).order, 6) - self.assertEqual(cubic_anisotropy(ax1, ax2, K1, 0, 0).order, 4) - - # Test orthonormalization of input axes - anis_ortho = cubic_anisotropy([1, 1, 0], [0, 1, 1], K1) - # Check that the resulting axes form an orthonormal basis - self.assertAlmostEqual(np.dot(anis_ortho.axis1, anis_ortho.axis2), 0) - self.assertAlmostEqual(np.dot(anis_ortho.axis1, anis_ortho.axis3), 0) - self.assertAlmostEqual(np.dot(anis_ortho.axis2, anis_ortho.axis3), 0) - -if __name__ == '__main__': - unittest.main() +import unittest + +import numpy as np +import pytest + +# Import the classes and functions to be tested +from anisotropy import ( + PredefinedAnisotropy, + anisotropy_signature_values, + cubic_anisotropy, + evaluate_energy_and_gradient, + evaluate_energy_density, + evaluate_energy_gradient, + uniaxial_anisotropy, + want_anisotropy, +) + +# Import the hidden helper function for normalization directly since it isn't exposed in the init file +from anisotropy.anisotropy import _normalize +from si.physical import SI + + +class TestAnisotropy(unittest.TestCase): + """Unit tests for the anisotropy definitions and helper functions.""" + + ## Test Helper Functions + # -------------------------------------------------------------------------- + + def testnormalize(self): + """Tests the vector normalization helper function.""" + v = [1, 2, 3] + norm_v = _normalize(v) + # Check that the norm is 1 + self.assertAlmostEqual(np.linalg.norm(norm_v), 1.0) + # Check that the direction is correct + np.testing.assert_array_almost_equal(norm_v, np.array(v) / np.linalg.norm(v)) + # Check that a zero vector raises a ValueError + with self.assertRaises(ValueError): + _normalize([0, 0, 0]) + + def test_want_anisotropy(self): + """Tests the anisotropy object validation function.""" + a_with_func = PredefinedAnisotropy(function=lambda m: 0, order=2) + a_without_func = PredefinedAnisotropy(order=2) + + # These should pass without error + want_anisotropy(a_with_func, want_function=True) + want_anisotropy(a_without_func, want_function=False) + + # Test for TypeError with incorrect object type + with self.assertRaises(TypeError): + want_anisotropy("not an anisotropy object") + + # Test for ValueError when a function is required but missing + with self.assertRaises(ValueError): + want_anisotropy(a_without_func, want_function=True) + + ## Test PredefinedAnisotropy Class + # -------------------------------------------------------------------------- + + def test_predefined_anisotropy_init(self): + """Tests the constructor of the PredefinedAnisotropy class.""" + # Test successful creation with a function and order + a = PredefinedAnisotropy(function=lambda m: 1, order=2) + self.assertTrue(a.has_function()) + self.assertEqual(a.order, 2) + + # Test successful creation with only an order + b = PredefinedAnisotropy(order=4) + self.assertFalse(b.has_function()) + self.assertEqual(b.order, 4) + + # Test that initialization fails if neither 'function' nor 'order' is given + with self.assertRaises(ValueError): + PredefinedAnisotropy() + + # Test that axes are correctly converted to NumPy arrays + c = PredefinedAnisotropy(order=2, axis1=[1, 0, 0]) + self.assertIsInstance(c.axis1, np.ndarray) + + def test_predefined_anisotropy_str_repr(self): + """Tests the string representations (__str__ and __repr__) of the class.""" + # Test without a custom stringifier + a = PredefinedAnisotropy(anis_type="test", order=2) + self.assertEqual(str(a), "") + self.assertEqual(repr(a), 'PredefinedAnisotropy(anis_type="test", ?)') + + # Test with a custom stringifier + def stringifier(x): + return f"order={x.order}" + + b = PredefinedAnisotropy(anis_type="test", order=2, stringifier=stringifier) + self.assertEqual(str(b), "") + self.assertEqual(repr(b), 'PredefinedAnisotropy(anis_type="test", order=2)') + + def test_predefined_anisotropy_operators(self): + """Tests the overloaded operators (+, -, neg, pos).""" + a1 = PredefinedAnisotropy(function=lambda m: np.dot(m, m), order=2) + a2 = PredefinedAnisotropy(function=lambda m: 2 * np.dot(m, m), order=4) + m_vec = np.array([0.5, 0.5, 0.5]) + + # Test unary positive operator (+) + pos_a1 = +a1 + self.assertIs(pos_a1, a1) # Should return the same object + + # Test unary negative operator (-) + neg_a1 = -a1 + self.assertIsNot(neg_a1, a1) # Should return a new object + self.assertEqual(neg_a1.function(m_vec), -a1.function(m_vec)) + self.assertEqual(neg_a1.order, a1.order) + + # Test addition (+) + add_res = a1 + a2 + self.assertEqual(add_res.order, 4) # Order should be max(2, 4) + self.assertAlmostEqual(add_res.function(m_vec), 3 * np.dot(m_vec, m_vec)) + + # Test subtraction (-) + sub_res = a2 - a1 + self.assertEqual(sub_res.order, 4) # Order should be max(4, 2) + self.assertAlmostEqual(sub_res.function(m_vec), np.dot(m_vec, m_vec)) + + # Test that operations with invalid types raise TypeError + with self.assertRaises(TypeError): + _ = a1 + 5 + with self.assertRaises(TypeError): + _ = a1 - "some_string" + + ## Test Factory Functions + # -------------------------------------------------------------------------- + + def test_uniaxial_anisotropy(self): + """Tests the uniaxial_anisotropy factory function.""" + axis = [0, 0, 1] + K1 = 100.0 + K2 = 10.0 + anis = uniaxial_anisotropy(axis, K1, K2) + + self.assertEqual(anis.anis_type, "uniaxial") + self.assertEqual(anis.order, 4) + self.assertEqual(anis.K1, K1) + self.assertEqual(anis.K2, K2) + np.testing.assert_array_almost_equal(anis.axis1, [0, 0, 1]) + + # Test energy when magnetization is parallel to the axis + m_parallel = [0, 0, 1] + expected_energy_parallel = -K1 * (1) ** 2 - K2 * (1) ** 4 + self.assertAlmostEqual(anis.function(m_parallel), expected_energy_parallel) + + # Test energy when magnetization is perpendicular to the axis + m_perp = [1, 0, 0] + self.assertAlmostEqual(anis.function(m_perp), 0.0) + + # Test case with only K1 + anis_k1 = uniaxial_anisotropy(axis, K1) + self.assertEqual(anis_k1.order, 2) + self.assertEqual(anis_k1.K2, 0) + self.assertAlmostEqual(anis_k1.function(m_parallel), -K1) + + # Test that the input axis is correctly normalized + anis_unnormalized = uniaxial_anisotropy([0, 5, 0], K1) + np.testing.assert_array_almost_equal(anis_unnormalized.axis1, [0, 1, 0]) + + def test_cubic_anisotropy(self): + """Tests the cubic_anisotropy factory function.""" + ax1, ax2 = [1, 0, 0], [0, 1, 0] + K1, K2, K3 = 100.0, 10.0, 1.0 + anis = cubic_anisotropy(ax1, ax2, K1, K2, K3) + + self.assertEqual(anis.anis_type, "cubic") + self.assertEqual(anis.order, 8) + np.testing.assert_array_almost_equal(anis.axis1, [1, 0, 0]) + np.testing.assert_array_almost_equal(anis.axis2, [0, 1, 0]) + np.testing.assert_array_almost_equal(anis.axis3, [0, 0, 1]) + + # Test energy when magnetization is along a primary axis (should be 0) + m_axis = [0, 1, 0] + self.assertAlmostEqual(anis.function(m_axis), 0.0) + + # Test energy when magnetization is along a face diagonal [110] + m_face_diag = _normalize([1, 1, 0]) # [1/sqrt(2), 1/sqrt(2), 0] + # Expected energy: K1 * (a1^2*a2^2) + K3 * (a1^2*a2^2)^2 + # E = K1 * (0.5*0.5) + K3 * (0.5*0.5)^2 = K1/4 + K3/16 + self.assertAlmostEqual(anis.function(m_face_diag), K1 / 4 + K3 / 16) + + # Test energy when magnetization is along a space diagonal [111] + m_space_diag = _normalize([1, 1, 1]) + # a1=a2=a3 = 1/sqrt(3), so a_i^2 = 1/3 + # E = K1*(3*(1/3*1/3)) + K2*(1/3*1/3*1/3) + K3*(3*(1/3*1/3)^2) + # E = K1/3 + K2/27 + K3/27 + self.assertAlmostEqual(anis.function(m_space_diag), K1 / 3 + K2 / 27 + K3 / 27) + + # Test automatic order detection + self.assertEqual(cubic_anisotropy(ax1, ax2, K1, K2, 0).order, 6) + self.assertEqual(cubic_anisotropy(ax1, ax2, K1, 0, 0).order, 4) + + # Test orthonormalization of input axes + anis_ortho = cubic_anisotropy([1, 1, 0], [0, 1, 1], K1) + # Check that the resulting axes form an orthonormal basis + self.assertAlmostEqual(np.dot(anis_ortho.axis1, anis_ortho.axis2), 0) + self.assertAlmostEqual(np.dot(anis_ortho.axis1, anis_ortho.axis3), 0) + self.assertAlmostEqual(np.dot(anis_ortho.axis2, anis_ortho.axis3), 0) + + def test_si_energy_density_and_analytic_gradients(self): + uniaxial = uniaxial_anisotropy( + [0, 0, 1], + SI(1.0e5, "J/m^3"), + SI(2.0e4, "J/m^3"), + ) + m = _normalize([1.0, 0.0, 1.0]) + projection = m[2] + expected_gradient = np.asarray([0.0, 0.0, -2.0e5 * projection - 8.0e4 * projection**3]) + + self.assertAlmostEqual( + uniaxial.energy_density(m), + -1.0e5 * projection**2 - 2.0e4 * projection**4, + ) + np.testing.assert_allclose(uniaxial.energy_gradient(m), expected_gradient) + + cubic = cubic_anisotropy( + [1, 0, 0], + [0, 1, 0], + SI(1.0e5, "J/m^3"), + ) + face_diagonal = _normalize([1.0, 1.0, 0.0]) + expected = 1.0e5 / np.sqrt(2.0) + np.testing.assert_allclose( + cubic.energy_gradient(face_diagonal), + [expected, expected, 0.0], + ) + + def test_composed_models_preserve_gradients(self): + first = uniaxial_anisotropy([1, 0, 0], 2.0) + second = uniaxial_anisotropy([0, 1, 0], 3.0) + combined = first - second + m = _normalize([1.0, 2.0, 0.0]) + + np.testing.assert_allclose( + combined.energy_gradient(m), + first.energy_gradient(m) - second.energy_gradient(m), + ) + + def test_custom_model_uses_a_finite_difference_gradient(self): + custom = PredefinedAnisotropy( + function=lambda m: SI(2.0e5 * np.asarray(m)[0] ** 2, "J/m^3"), + order=2, + ) + m = np.asarray([0.25, 0.5, 0.75]) + + np.testing.assert_allclose( + custom.energy_gradient(m), + [1.0e5, 0.0, 0.0], + rtol=1.0e-9, + atol=1.0e-5, + ) + + def test_energy_constants_require_energy_density_units(self): + with pytest.raises(TypeError, match="energy density"): + uniaxial_anisotropy([0, 0, 1], SI(1.0, "A/m")) + + def test_public_evaluators_support_none_and_callable_models(self): + magnetisation = np.asarray([[1.0, 0.0, 0.0], [0.0, 2.0, 0.0]]) + + np.testing.assert_array_equal( + evaluate_energy_density(None, magnetisation), + [0.0, 0.0], + ) + np.testing.assert_array_equal( + evaluate_energy_gradient(None, magnetisation), + np.zeros_like(magnetisation), + ) + energy, gradient = evaluate_energy_and_gradient(None, magnetisation) + np.testing.assert_array_equal(energy, [0.0, 0.0]) + np.testing.assert_array_equal(gradient, np.zeros_like(magnetisation)) + + def custom(m): + return 3.0 * np.asarray(m)[1] ** 2 + + np.testing.assert_allclose( + evaluate_energy_density(custom, magnetisation), + [0.0, 12.0], + ) + np.testing.assert_allclose( + evaluate_energy_gradient(custom, magnetisation), + [[0.0, 0.0, 0.0], [0.0, 12.0, 0.0]], + atol=1.0e-7, + ) + + def test_public_evaluators_vectorize_predefined_models(self): + model = uniaxial_anisotropy([0, 0, 1], 5.0) + magnetisation = np.asarray([[0.0, 0.0, 1.0], [1.0, 0.0, 0.0]]) + + energy, gradient = evaluate_energy_and_gradient(model, magnetisation) + np.testing.assert_allclose(energy, [-5.0, 0.0]) + np.testing.assert_allclose(gradient, [[0.0, 0.0, -10.0], [0.0, 0.0, 0.0]]) + np.testing.assert_allclose(evaluate_energy_density(model, magnetisation), energy) + np.testing.assert_allclose(evaluate_energy_gradient(model, magnetisation), gradient) + self.assertEqual(anisotropy_signature_values(model, 2).shape, (21,)) + np.testing.assert_array_equal(anisotropy_signature_values(None, None), [0.0]) + + def test_evaluation_rejects_invalid_inputs_and_outputs(self): + with pytest.raises(ValueError, match="Magnetisation"): + evaluate_energy_density(None, [1.0, 0.0]) + with pytest.raises(ValueError, match="finite"): + evaluate_energy_gradient(None, [np.nan, 0.0, 1.0]) + with pytest.raises(ValueError, match="positive integer"): + PredefinedAnisotropy(function=lambda _m: 0.0, order=0) + with pytest.raises(ValueError, match="no energy function"): + PredefinedAnisotropy(order=2).energy_density([1.0, 0.0, 0.0]) + with pytest.raises(ValueError, match="finite"): + PredefinedAnisotropy(function=lambda _m: np.nan, order=2).energy_gradient( + [1.0, 0.0, 0.0] + ) + with pytest.raises(ValueError, match="3-vectors"): + uniaxial_anisotropy([1.0, 0.0], 1.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/fixtures/nmag_doc_example1/README.md b/tests/fixtures/nmag_doc_example1/README.md new file mode 100644 index 0000000..4bac1b6 --- /dev/null +++ b/tests/fixtures/nmag_doc_example1/README.md @@ -0,0 +1,9 @@ +Pinned nmag documentation `example1` sphere mesh. + +Source: +https://github.com/nmag-project/nmag-doc/tree/98559075335c14eb948d91d7b06d3eb4dcfcbbf2/nmag/source/example1 + +File: `sphere1.nmesh.h5` + +This fixture verifies the Python 3 rewrite against the canonical `sphere1.py` +example without depending on network access during tests. diff --git a/tests/fixtures/nmag_doc_example1/sphere1.nmesh.h5 b/tests/fixtures/nmag_doc_example1/sphere1.nmesh.h5 new file mode 100644 index 0000000..713b121 Binary files /dev/null and b/tests/fixtures/nmag_doc_example1/sphere1.nmesh.h5 differ diff --git a/tests/mag_material_test.py b/tests/mag_material_test.py index 0e1fde6..3af82a0 100644 --- a/tests/mag_material_test.py +++ b/tests/mag_material_test.py @@ -1,89 +1,112 @@ -import unittest - -# Import from the actual library files -from si import constants -from si.physical import SI -from mag_material import MagMaterial -from anisotropy import PredefinedAnisotropy, uniaxial_anisotropy - -class TestMagMaterial(unittest.TestCase): - """Unit tests for the MagMaterial class using a real SI library.""" - - def test_successful_initialization_defaults(self): - """Test creating a material with a name and default parameters.""" - mat = MagMaterial(name='Permalloy') - self.assertEqual(mat.name, 'Permalloy') - self.assertAlmostEqual(mat.Ms.value, 0.86e6) - self.assertEqual(mat.llg_damping, 0.5) - self.assertIsNone(mat.anisotropy) - self.assertTrue(mat.do_precession) - - def test_successful_initialization_custom(self): - """Test creating a material with custom-defined parameters.""" - ms_val = SI(1.0e6, constants.Ampere / constants.meter) - a_val = SI(1.0e-11, constants.Joule / constants.meter) - anis = uniaxial_anisotropy(axis=[0, 0, 1], K1=1e5) - - mat = MagMaterial(name='Iron', - Ms=ms_val, - exchange_coupling=a_val, - llg_damping=0.01, - anisotropy=anis, - do_precession=False) - - self.assertEqual(mat.name, 'Iron') - self.assertEqual(mat.Ms, ms_val) - self.assertEqual(mat.exchange_coupling, a_val) - self.assertEqual(mat.llg_damping, 0.01) - self.assertIs(mat.anisotropy, anis) - self.assertEqual(mat.anisotropy_order, 2) - self.assertFalse(mat.do_precession) - - def test_anisotropy_validation(self): - """Test the validation logic for anisotropy arguments.""" - # Case 1: Predefined anisotropy with a conflicting custom order should fail. - anis = PredefinedAnisotropy(order=4) - with self.assertRaisesRegex(ValueError, "Cannot specify custom 'anisotropy_order'"): - MagMaterial(name='ErrMat1', anisotropy=anis, anisotropy_order=2) - - # Case 2: Custom anisotropy function without specifying an order should fail. - custom_func = lambda m: m[2]**2 - with self.assertRaisesRegex(ValueError, "must specify 'anisotropy_order'"): - MagMaterial(name='ErrMat2', anisotropy=custom_func) - - # Case 3: Successful creation with a custom function and specified order. - mat = MagMaterial(name='GoodMat', anisotropy=custom_func, anisotropy_order=2) - self.assertEqual(mat.anisotropy, custom_func) - self.assertEqual(mat.anisotropy_order, 2) - - def test_parameter_validation(self): - """Test validation of physical parameter units and values.""" - # Case 1: Providing a parameter with incorrect units should raise a TypeError. - with self.assertRaisesRegex(TypeError, "requires units compatible with"): - MagMaterial(name='UnitError', Ms=SI(constants.Joule / constants.meter)) - - # Case 2: Providing a negative exchange coupling should raise a ValueError. - with self.assertRaisesRegex(ValueError, "exchange coupling constant must be positive"): - MagMaterial(name='ValueError', exchange_coupling=SI(-1e-12, constants.Joule / constants.meter)) - - def test_no_precession_flag(self): - """Test that `do_precession=False` correctly nullifies the precession term.""" - mat = MagMaterial(name='NoPrecession', do_precession=False) - self.assertEqual(mat.su_llg_coeff1, 0.0) - self.assertNotEqual(mat.su_llg_coeff2, 0.0) - - def test_str_representation(self): - """Test the __str__ method for standard and extended printing.""" - mat = MagMaterial(name='StringTest') - s_std = str(mat) - self.assertIn("Material 'StringTest'", s_std) - self.assertIn("exchange_coupling", s_std) - self.assertNotIn("su_llg_coeff1", s_std) - - mat.extended_print = True - s_ext = str(mat) - self.assertIn("su_llg_coeff1", s_ext) - self.assertGreater(len(s_ext.splitlines()), len(s_std.splitlines())) - -if __name__ == '__main__': - unittest.main() +import unittest + +from anisotropy import PredefinedAnisotropy, uniaxial_anisotropy +from mag_material import MagMaterial +from mag_material.parameters import _expected_parameter_units + +# Import from the actual library files +from si import constants +from si.physical import SI + + +class TestMagMaterial(unittest.TestCase): + """Unit tests for the MagMaterial class using a real SI library.""" + + def test_successful_initialization_defaults(self): + """Test creating a material with a name and default parameters.""" + mat = MagMaterial(name='Permalloy') + self.assertEqual(mat.name, 'Permalloy') + self.assertAlmostEqual(mat.Ms.value, 0.86e6) + self.assertEqual(mat.llg_damping, 0.5) + self.assertIsNone(mat.anisotropy) + self.assertTrue(mat.do_precession) + + def test_expected_parameter_units_are_cached(self): + """Unit validation reuses expected-unit objects across materials.""" + _expected_parameter_units.cache_clear() + + MagMaterial(name='First') + first_units = _expected_parameter_units() + MagMaterial(name='Second') + second_units = _expected_parameter_units() + + self.assertIs(first_units, second_units) + self.assertGreaterEqual(_expected_parameter_units.cache_info().hits, 1) + + def test_successful_initialization_custom(self): + """Test creating a material with custom-defined parameters.""" + ms_val = SI(1.0e6, constants.Ampere / constants.meter) + a_val = SI(1.0e-11, constants.Joule / constants.meter) + anis = uniaxial_anisotropy(axis=[0, 0, 1], K1=1e5) + + mat = MagMaterial(name='Iron', + Ms=ms_val, + exchange_coupling=a_val, + llg_damping=0.01, + anisotropy=anis, + do_precession=False) + + self.assertEqual(mat.name, 'Iron') + self.assertEqual(mat.Ms, ms_val) + self.assertEqual(mat.exchange_coupling, a_val) + self.assertEqual(mat.llg_damping, 0.01) + self.assertIs(mat.anisotropy, anis) + self.assertEqual(mat.anisotropy_order, 2) + self.assertFalse(mat.do_precession) + + def test_anisotropy_validation(self): + """Test the validation logic for anisotropy arguments.""" + # Case 1: Predefined anisotropy with a conflicting custom order should fail. + anis = PredefinedAnisotropy(order=4) + with self.assertRaisesRegex(ValueError, "Cannot specify custom 'anisotropy_order'"): + MagMaterial(name='ErrMat1', anisotropy=anis, anisotropy_order=2) + + # Case 2: Custom anisotropy function without specifying an order should fail. + def custom_func(m): + return m[2]**2 + with self.assertRaisesRegex(ValueError, "must specify 'anisotropy_order'"): + MagMaterial(name='ErrMat2', anisotropy=custom_func) + + # Case 3: Successful creation with a custom function and specified order. + mat = MagMaterial(name='GoodMat', anisotropy=custom_func, anisotropy_order=2) + self.assertEqual(mat.anisotropy, custom_func) + self.assertEqual(mat.anisotropy_order, 2) + + with self.assertRaisesRegex(ValueError, "energy function and order"): + MagMaterial(name="NoEnergy", anisotropy=PredefinedAnisotropy(order=2)) + with self.assertRaisesRegex(ValueError, "positive integer"): + MagMaterial(name="BadOrder", anisotropy=custom_func, anisotropy_order=0) + with self.assertRaisesRegex(TypeError, "callable"): + MagMaterial(name="BadModel", anisotropy="not-a-model", anisotropy_order=2) + + def test_parameter_validation(self): + """Test validation of physical parameter units and values.""" + # Case 1: Providing a parameter with incorrect units should raise a TypeError. + with self.assertRaisesRegex(TypeError, "requires units compatible with"): + MagMaterial(name='UnitError', Ms=SI(constants.Joule / constants.meter)) + + # Case 2: Providing a negative exchange coupling should raise a ValueError. + with self.assertRaisesRegex(ValueError, "exchange coupling constant must be positive"): + MagMaterial(name='ValueError', exchange_coupling=SI(-1e-12, constants.Joule / constants.meter)) + + def test_no_precession_flag(self): + """Test that `do_precession=False` correctly nullifies the precession term.""" + mat = MagMaterial(name='NoPrecession', do_precession=False) + self.assertEqual(mat.su_llg_coeff1, 0.0) + self.assertNotEqual(mat.su_llg_coeff2, 0.0) + + def test_str_representation(self): + """Test the __str__ method for standard and extended printing.""" + mat = MagMaterial(name='StringTest') + s_std = str(mat) + self.assertIn("Material 'StringTest'", s_std) + self.assertIn("exchange_coupling", s_std) + self.assertNotIn("su_llg_coeff1", s_std) + + mat.extended_print = True + s_ext = str(mat) + self.assertIn("su_llg_coeff1", s_ext) + self.assertGreater(len(s_ext.splitlines()), len(s_std.splitlines())) + +if __name__ == '__main__': + unittest.main() diff --git a/tests/nmag/public_api_support_module.py b/tests/nmag/public_api_support_module.py new file mode 100644 index 0000000..1ff74a1 --- /dev/null +++ b/tests/nmag/public_api_support_module.py @@ -0,0 +1,91 @@ +"""Shared mesh fixtures and reference helpers for public API regression tests.""" + +from __future__ import annotations + +import os +from pathlib import Path + +import numpy as np + +import nmesh +from nmag.simulation import TETRA_FACE_VERTICES + + +def subprocess_env_with_repo_src() -> dict[str, str]: + src_path = str(Path(__file__).resolve().parents[2] / "src") + pythonpath = os.environ.get("PYTHONPATH") + return { + **os.environ, + "PYTHONPATH": src_path if not pythonpath else f"{src_path}{os.pathsep}{pythonpath}", + } + + +def write_single_region_mesh(path: Path) -> None: + 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.save(path) + + +def write_two_tetra_mesh(path: Path) -> None: + 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], + [1.0, 1.0, 1.0], + ], + simplices_indices=[ + [0, 1, 2, 3], + [1, 2, 3, 4], + ], + simplices_regions=[1, 1], + ) + mesh.save(path) + + +def write_two_region_mesh(path: Path) -> None: + 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], + [1.0, 1.0, 1.0], + ], + simplices_indices=[[0, 1, 2, 3], [1, 2, 3, 4]], + simplices_regions=[1, 2], + ) + mesh.save(path) + + +def reference_oriented_boundary_faces( + points: np.ndarray, + simplices: np.ndarray, +) -> list[tuple[int, tuple[int, int, int]]]: + face_owners: dict[tuple[int, int, int], tuple[int, tuple[int, int, int]] | None] = {} + for cell_index, simplex in enumerate(simplices): + cell_center = np.mean(points[simplex], axis=0) + for local_face in TETRA_FACE_VERTICES: + face = tuple(int(simplex[index]) for index in local_face) + key = tuple(sorted(face)) + if key in face_owners: + face_owners[key] = None + continue + + a, b, c = points[list(face)] + normal = np.cross(b - a, c - a) + face_center = (a + b + c) / 3.0 + if np.dot(normal, cell_center - face_center) > 0.0: + face = (face[0], face[2], face[1]) + face_owners[key] = (cell_index, face) + + return [owner for owner in face_owners.values() if owner is not None] diff --git a/tests/nmag/rust_accelerator_validation_support.py b/tests/nmag/rust_accelerator_validation_support.py new file mode 100644 index 0000000..d18c9ad --- /dev/null +++ b/tests/nmag/rust_accelerator_validation_support.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from collections.abc import Callable + +import numpy as np +import pytest + +nmag_accel = pytest.importorskip("nmag_accel") + +POINTS = np.asarray( + [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], + dtype=np.float64, +) +SIMPLICES = np.asarray([[0, 1, 2, 3]], dtype=np.int64) +FACE_NODES = np.asarray([[1, 2, 3], [0, 3, 2], [0, 1, 3], [0, 2, 1]], dtype=np.int64) +BOUNDARY_NODES = np.arange(4, dtype=np.int64) +LOCAL_INDEX_BY_POINT = np.arange(4, dtype=np.int64) + +InvalidCall = tuple[str, Callable[[], object], type[Exception], str] + + +def _replace_simplex_index(function_name: str, value: int) -> object: + simplices = np.asarray([[value, 1, 2, 3]], dtype=np.int64) + if function_name == "build_demag_fem_geometry": + return nmag_accel.build_demag_fem_geometry(POINTS, simplices) + if function_name == "build_demag_fem_divergence": + return nmag_accel.build_demag_fem_divergence( + simplices, + np.zeros((1, 4, 3)), + np.ones(1), + POINTS, + np.ones(1), + 4, + ) + if function_name == "recover_demag_nodal_field": + return nmag_accel.recover_demag_nodal_field( + simplices, + np.ones(1), + np.ones((1, 3)), + np.ones(4), + 4, + ) + if function_name == "build_oriented_boundary_faces": + return nmag_accel.build_oriented_boundary_faces(POINTS, simplices) + if function_name == "build_probe_tetrahedral_geometry": + return nmag_accel.build_probe_tetrahedral_geometry(POINTS, simplices) + if function_name == "maxangle_between_edges": + return nmag_accel.maxangle_between_edges(POINTS, simplices) + if function_name == "build_lindholm_bem_matrix": + return nmag_accel.build_lindholm_bem_matrix( + POINTS, + simplices, + FACE_NODES, + BOUNDARY_NODES, + LOCAL_INDEX_BY_POINT, + ) + raise AssertionError(f"unhandled function {function_name}") diff --git a/tests/nmag/test_anisotropy_dynamics.py b/tests/nmag/test_anisotropy_dynamics.py new file mode 100644 index 0000000..22d961f --- /dev/null +++ b/tests/nmag/test_anisotropy_dynamics.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Literal + +import numpy as np +import pytest + +import nmag +import nmesh + + +def _simulation( + tmp_path: Path, + *, + name: str, + k1: float, + accelerator: Literal["auto", "off", "rust"] = "off", +) -> nmag.Simulation: + mesh_path = tmp_path / f"{name}.nmesh.h5" + 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.save(mesh_path) + material = nmag.MagMaterial( + name="Py", + Ms=nmag.SI(1.0e6, "A/m"), + exchange_coupling=nmag.SI(0.0, "J/m"), + llg_damping=0.5, + do_precession=False, + anisotropy=nmag.uniaxial_anisotropy([0.0, 0.0, 1.0], nmag.SI(k1, "J/m^3")), + ) + simulation = nmag.Simulation( + name=name, + do_demag=False, + config=nmag.NmagConfig( + output_directory=tmp_path, + accelerator=accelerator, + ), + ) + simulation.load_mesh( + str(mesh_path), + [("magnetic", material)], + unit_length=nmag.SI(1.0e-9, "m"), + ) + simulation.set_m([1.0, 0.0, 1.0]) + return simulation + + +@pytest.mark.parametrize(("k1", "direction"), [(1.0e5, 1.0), (-1.0e5, -1.0)]) +def test_anisotropy_damping_moves_toward_lower_energy( + tmp_path: Path, + k1: float, + direction: float, +) -> None: + simulation = _simulation(tmp_path, name=f"axis-{direction}", k1=k1) + initial = np.asarray(simulation.get_subfield_average("m")) + initial_energy = float(simulation.get_subfield_average("E_anis")) + + simulation.advance_time(nmag.SI(1.0e-12, "s")) + + final = np.asarray(simulation.get_subfield_average("m")) + final_energy = float(simulation.get_subfield_average("E_anis")) + assert direction * (final[2] - initial[2]) > 0.0 + assert final_energy < initial_energy + np.testing.assert_allclose(np.linalg.norm(final), 1.0, rtol=2.0e-6) + + +def test_python_and_rust_anisotropy_dynamics_match(tmp_path: Path) -> None: + pytest.importorskip("nmag_accel") + python = _simulation(tmp_path, name="python-anis", k1=1.0e5, accelerator="off") + rust = _simulation(tmp_path, name="rust-anis", k1=1.0e5, accelerator="rust") + + target = nmag.SI(2.0e-13, "s") + python.advance_time(target) + rust.advance_time(target) + + np.testing.assert_allclose( + rust.get_subfield("m"), + python.get_subfield("m"), + rtol=1.0e-12, + atol=1.0e-13, + ) diff --git a/tests/nmag/test_anisotropy_fields.py b/tests/nmag/test_anisotropy_fields.py new file mode 100644 index 0000000..b8e9ef9 --- /dev/null +++ b/tests/nmag/test_anisotropy_fields.py @@ -0,0 +1,206 @@ +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pytest + +import nmag +import nmesh +from nmag.simulation.support import MU0 + + +def _write_single_tetrahedron(path: Path) -> None: + 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.save(path) + + +def _write_two_regions(path: Path, *, shared: bool) -> None: + second = ( + [[1.0, 1.0, 1.0]] + if shared + else [[10.0, 0.0, 0.0], [11.0, 0.0, 0.0], [10.0, 1.0, 0.0], [10.0, 0.0, 1.0]] + ) + 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], + *second, + ] + simplices = [[0, 1, 2, 3], [1, 2, 3, 4]] if shared else [[0, 1, 2, 3], [4, 5, 6, 7]] + mesh = nmesh.mesh_from_points_and_simplices( + points=points, + simplices_indices=simplices, + simplices_regions=[1, 2], + ) + mesh.save(path) + + +def _material( + name: str, + anisotropy: object, + *, + anisotropy_order: int | None = None, +) -> nmag.MagMaterial: + return nmag.MagMaterial( + name=name, + Ms=nmag.SI(1.0e6, "A/m"), + exchange_coupling=nmag.SI(0.0, "J/m"), + anisotropy=anisotropy, # type: ignore[arg-type] + anisotropy_order=anisotropy_order, + ) + + +def _single_simulation( + tmp_path: Path, + *, + anisotropy: object, + anisotropy_order: int | None = None, + name: str = "anisotropy", +) -> nmag.Simulation: + mesh_path = tmp_path / f"{name}.nmesh.h5" + _write_single_tetrahedron(mesh_path) + simulation = nmag.Simulation( + name=name, + do_demag=False, + config=nmag.NmagConfig(output_directory=tmp_path), + ) + simulation.load_mesh( + str(mesh_path), + [("magnetic", _material("Py", anisotropy, anisotropy_order=anisotropy_order))], + unit_length=nmag.SI(1.0e-9, "m"), + ) + simulation.set_m([1.0, 0.0, 1.0]) + return simulation + + +def test_uniaxial_field_energy_totals_and_output(tmp_path: Path) -> None: + k1 = 1.0e5 + simulation = _single_simulation( + tmp_path, + anisotropy=nmag.uniaxial_anisotropy( + [0.0, 0.0, 1.0], + nmag.SI(k1, "J/m^3"), + ), + ) + projection = 1.0 / np.sqrt(2.0) + raw_field = np.asarray([0.0, 0.0, 2.0 * k1 * projection / (MU0 * 1.0e6)]) + m = np.asarray([projection, 0.0, projection]) + expected_field = raw_field - np.dot(raw_field, m) * m + + np.testing.assert_allclose(simulation.get_subfield_average("H_anis"), expected_field) + assert simulation.get_subfield_average("E_anis") == pytest.approx(-0.5 * k1) + np.testing.assert_allclose(simulation.get_subfield_average("H_total"), expected_field) + assert simulation.get_subfield_average("E_total") == pytest.approx(-0.5 * k1) + + simulation.save_data(fields="all") + header = (tmp_path / "anisotropy_dat.ndt").read_text(encoding="utf-8").splitlines()[1] + assert "H_anis_Py_2" in header + assert "E_anis_Py" in header + + +def test_cubic_and_custom_anisotropy_fields(tmp_path: Path) -> None: + cubic = _single_simulation( + tmp_path, + name="cubic", + anisotropy=nmag.cubic_anisotropy([1, 0, 0], [0, 1, 0], 1.0e5), + ) + m = np.asarray([1.0, 0.0, 1.0]) / np.sqrt(2.0) + gradient = np.asarray(cubic.materials[0].anisotropy.energy_gradient(m)) + raw_field = -gradient / (MU0 * 1.0e6) + expected_field = raw_field - np.dot(raw_field, m) * m + np.testing.assert_allclose( + cubic.get_subfield_average("H_anis"), + expected_field, + atol=1.0e-10, + ) + + def custom(m: object) -> nmag.SI: + return nmag.SI(2.0e5 * np.asarray(m)[0] ** 2, "J/m^3") + + custom_simulation = _single_simulation( + tmp_path, + name="custom", + anisotropy=custom, + anisotropy_order=2, + ) + raw_custom = np.asarray([-(4.0e5 / np.sqrt(2.0)) / (MU0 * 1.0e6), 0.0, 0.0]) + expected_custom = raw_custom - np.dot(raw_custom, m) * m + np.testing.assert_allclose( + custom_simulation.get_subfield_average("H_anis"), + expected_custom, + rtol=1.0e-9, + atol=1.0e-7, + ) + + +def test_disconnected_materials_use_their_own_anisotropy(tmp_path: Path) -> None: + mesh_path = tmp_path / "disconnected.nmesh.h5" + _write_two_regions(mesh_path, shared=False) + first = _material("first", nmag.uniaxial_anisotropy([0, 0, 1], 1.0e5)) + second = _material("second", nmag.uniaxial_anisotropy([1, 0, 0], 2.0e5)) + simulation = nmag.Simulation( + name="disconnected", + do_demag=False, + config=nmag.NmagConfig(output_directory=tmp_path), + ) + simulation.load_mesh( + str(mesh_path), + [("first", first), ("second", second)], + unit_length=nmag.SI(1.0e-9, "m"), + ) + simulation.set_m([1.0, 0.0, 1.0]) + field = np.asarray(simulation.get_subfield("H_anis")) + + assert np.all(field[:4, 0] < 0.0) + assert np.all(field[:4, 2] > 0.0) + assert np.all(field[4:, 0] > 0.0) + assert np.all(field[4:, 2] < 0.0) + + +def test_shared_nodes_reject_incompatible_anisotropy(tmp_path: Path) -> None: + mesh_path = tmp_path / "shared.nmesh.h5" + _write_two_regions(mesh_path, shared=True) + simulation = nmag.Simulation( + name="shared", + do_demag=False, + config=nmag.NmagConfig(output_directory=tmp_path), + ) + simulation.load_mesh( + str(mesh_path), + [ + ("first", _material("first", nmag.uniaxial_anisotropy([0, 0, 1], 1.0e5))), + ("second", _material("second", nmag.uniaxial_anisotropy([1, 0, 0], 1.0e5))), + ], + unit_length=nmag.SI(1.0e-9, "m"), + ) + simulation.set_m([1.0, 0.0, 1.0]) + + with pytest.raises(NotImplementedError, match="material-specific magnetisation DOFs"): + simulation.get_subfield("H_anis") + + +def test_shared_nodes_accept_equivalent_predefined_anisotropy(tmp_path: Path) -> None: + mesh_path = tmp_path / "shared-equivalent.nmesh.h5" + _write_two_regions(mesh_path, shared=True) + simulation = nmag.Simulation( + name="shared-equivalent", + do_demag=False, + config=nmag.NmagConfig(output_directory=tmp_path), + ) + simulation.load_mesh( + str(mesh_path), + [ + ("first", _material("first", nmag.uniaxial_anisotropy([0, 0, 1], 1.0e5))), + ("second", _material("second", nmag.uniaxial_anisotropy([0, 0, 1], 1.0e5))), + ], + unit_length=nmag.SI(1.0e-9, "m"), + ) + simulation.set_m([1.0, 0.0, 1.0]) + + assert np.isfinite(simulation.get_subfield("H_anis")).all() diff --git a/tests/nmag/test_configuration.py b/tests/nmag/test_configuration.py new file mode 100644 index 0000000..9531edd --- /dev/null +++ b/tests/nmag/test_configuration.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +from dataclasses import FrozenInstanceError + +import numpy as np +import public_api_support_module as helpers +import pytest + +import nmag +import nmag.backends as backends +from nmag.backends import _selected_llg_backend, _selected_maxangle_backend + + +def _simulation(tmp_path, *, config: nmag.NmagConfig, name: str = "configured") -> nmag.Simulation: + mesh_path = tmp_path / "mesh.nmesh" + helpers.write_single_region_mesh(mesh_path) + material = nmag.MagMaterial( + name="Py", + Ms=nmag.SI(1.0e6, "A/m"), + exchange_coupling=nmag.SI(13.0e-12, "J/m"), + ) + simulation = nmag.Simulation(name=name, config=config) + simulation.load_mesh(str(mesh_path), [("generated", material)], unit_length=nmag.SI(1e-9, "m")) + simulation.set_m([1.0, 0.0, 0.0]) + return simulation + + +def test_config_defaults_are_immutable_and_overrides_are_frozen() -> None: + config = nmag.NmagConfig(accelerator_overrides={nmag.RustKernel.LLG: "off"}) + + assert config.default_name == "nmag_simulation" + assert config.output_policy == "error" + assert config.demag_bem_storage == "auto" + assert config.hierarchical_bem.relative_tolerance == 1.0e-6 + assert config.accelerator_mode_for(nmag.RustKernel.LLG) == "off" + with pytest.raises(FrozenInstanceError): + config.accelerator = "off" # type: ignore[misc] + with pytest.raises(TypeError): + config.accelerator_overrides[nmag.RustKernel.MAXANGLE] = "off" # type: ignore[index] + + +@pytest.mark.parametrize( + ("kwargs", "message"), + [ + ({"output_policy": "discard"}, "output_policy"), + ({"accelerator": "gpu"}, "accelerator"), + ({"integrator_backend": "cvode"}, "integrator_backend"), + ({"demag_bem_storage": "compressed"}, "demag_bem_storage"), + ({"accelerator_overrides": {"llg": "off"}}, "keys"), + ], +) +def test_config_rejects_invalid_values(kwargs, message: str) -> None: + with pytest.raises((TypeError, ValueError), match=message): + nmag.NmagConfig(**kwargs) + + +def test_explicit_config_wins_over_environment(tmp_path, monkeypatch) -> None: + monkeypatch.setenv(nmag.ACCELERATOR_ENV, "off") + monkeypatch.setenv("NMAG_DEMAG_BEM_STORAGE_BACKEND", "matrix-free") + explicit = nmag.NmagConfig( + accelerator="rust", + output_directory=tmp_path, + demag_bem_storage="hierarchical", + ) + simulation = nmag.Simulation(config=explicit) + + assert simulation.config is explicit + assert simulation.config.accelerator == "rust" + assert simulation.config.demag_bem_storage == "hierarchical" + assert nmag.Simulation(config=None).config.accelerator == "off" + assert nmag.Simulation(config=None).config.demag_bem_storage == "matrix-free" + + +@pytest.mark.parametrize( + ("kwargs", "message"), + [ + ({"relative_tolerance": 0.0}, "relative_tolerance"), + ({"admissibility_eta": float("inf")}, "admissibility_eta"), + ({"leaf_size": 0}, "leaf_size"), + ({"max_rank": True}, "max_rank"), + ({"memory_fraction": 1.1}, "memory_fraction"), + ], +) +def test_hierarchical_bem_config_rejects_invalid_values(kwargs, message: str) -> None: + with pytest.raises((TypeError, ValueError), match=message): + nmag.HierarchicalBemConfig(**kwargs) + + +@pytest.mark.parametrize( + "kwargs", + [ + {"relative_tolerance": "1e-6"}, + {"admissibility_eta": True}, + {"memory_fraction": "0.2"}, + ], +) +def test_hierarchical_bem_config_rejects_non_numeric_values(kwargs) -> None: + with pytest.raises(TypeError, match="must be a real number"): + nmag.HierarchicalBemConfig(**kwargs) + + +def test_config_requires_hierarchical_bem_config() -> None: + with pytest.raises(TypeError, match="HierarchicalBemConfig"): + nmag.NmagConfig(hierarchical_bem=None) # type: ignore[arg-type] + + +def test_off_auto_rust_and_kernel_override_selection(monkeypatch) -> None: + monkeypatch.setattr(backends, "_rust_accelerator_available", lambda: True) + assert _selected_llg_backend(10_000, nmag.NmagConfig(accelerator="off")) == "python" + assert _selected_llg_backend(10_000, nmag.NmagConfig()) == "rust" + assert _selected_llg_backend(1, nmag.NmagConfig(accelerator="rust")) == "rust" + assert _selected_maxangle_backend( + nmag.NmagConfig( + accelerator="rust", + accelerator_overrides={nmag.RustKernel.MAXANGLE: "off"}, + ) + ) == "python" + + +def test_strict_rust_selection_fails_clearly_when_unavailable(monkeypatch) -> None: + monkeypatch.setattr(backends, "_rust_accelerator_available", lambda: False) + monkeypatch.setattr( + backends, + "_load_rust_accelerator", + lambda _required_by: (_ for _ in ()).throw(RuntimeError("extension missing")), + ) + + with pytest.raises(RuntimeError, match="extension missing"): + _selected_llg_backend(1, nmag.NmagConfig(accelerator="rust")) + + +def test_output_policy_error_replace_and_append(tmp_path) -> None: + error_config = nmag.NmagConfig(output_directory=tmp_path) + first = _simulation(tmp_path, config=error_config) + first.save_data() + ndt = tmp_path / "configured_dat.ndt" + original = ndt.read_text(encoding="utf-8") + + with pytest.raises(FileExistsError, match="output_policy"): + nmag.Simulation(name="configured", config=error_config) + + replacement = _simulation( + tmp_path, + config=nmag.NmagConfig(output_directory=tmp_path, output_policy="replace"), + ) + replacement.save_data() + assert ndt.read_text(encoding="utf-8").count("# Simulation:") == 1 + + appended = _simulation( + tmp_path, + config=nmag.NmagConfig(output_directory=tmp_path, output_policy="append"), + ) + appended.clock.step = 1 + appended.save_data() + rows = [line for line in ndt.read_text(encoding="utf-8").splitlines() if line] + assert rows[0].startswith("# Simulation:") + assert len(rows) == 4 + assert original.splitlines()[1] == rows[1] + + +def test_append_rejects_a_different_schema_without_writing(tmp_path) -> None: + config = nmag.NmagConfig(output_directory=tmp_path) + first = _simulation(tmp_path, config=config) + first.save_data() + ndt = tmp_path / "configured_dat.ndt" + before = ndt.read_text(encoding="utf-8") + + appended = _simulation( + tmp_path, + config=nmag.NmagConfig(output_directory=tmp_path, output_policy="append"), + ) + appended.writer._column_names = ["wrong"] + appended.writer._column_index_by_name = {"wrong": 0} + appended.writer._append_schema_pending = False + with pytest.raises(ValueError, match="different NDT schema"): + appended.save_data() + assert ndt.read_text(encoding="utf-8") == before + + +def test_explicit_restart_is_independent_from_output_append(tmp_path) -> None: + simulation = _simulation(tmp_path, config=nmag.NmagConfig(output_directory=tmp_path)) + checkpoint = simulation.save_restart_file() + assert checkpoint.exists() + resumed = _simulation( + tmp_path, + config=nmag.NmagConfig(output_directory=tmp_path, output_policy="append"), + ) + assert np.asarray(resumed.get_subfield("m")).shape == (4, 3) + resumed.load_restart_file(checkpoint) diff --git a/tests/nmag/test_demag_core.py b/tests/nmag/test_demag_core.py new file mode 100644 index 0000000..9a97f02 --- /dev/null +++ b/tests/nmag/test_demag_core.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +from collections.abc import Callable +from types import SimpleNamespace + +import numpy as np + +import nmag.demag as demag +from nmag.backends import DEMAG_LINEAR_SOLVER_BACKEND_ENV + + +def _record_timings() -> tuple[dict[str, float], Callable[[str, float], None]]: + timings: dict[str, float] = {} + + def record(name: str, seconds: float) -> None: + timings[name] = timings.get(name, 0.0) + seconds + + return timings, record + + +def test_gauge_solver_recovers_from_numpy_solve_failure(monkeypatch): + monkeypatch.setenv(DEMAG_LINEAR_SOLVER_BACKEND_ENV, "numpy") + timings, record_timing = _record_timings() + + def fail_solve(*_args, **_kwargs): + raise np.linalg.LinAlgError("forced failure") + + monkeypatch.setattr(demag.np.linalg, "solve", fail_solve) + + solution = demag._solve_gauge_fixed( + np.asarray([[2.0, 0.0], [0.0, 2.0]]), + np.asarray([2.0, -2.0]), + record_timing=record_timing, + timing_prefix="gauge", + ) + + np.testing.assert_allclose(solution, [1.0, -1.0]) + assert timings["gauge:setup"] >= 0.0 + assert timings["gauge:numpy_solve_failed"] >= 0.0 + assert timings["gauge:numpy_lstsq"] >= 0.0 + + +def test_scipy_solver_recovers_from_singular_system(monkeypatch): + monkeypatch.setenv(DEMAG_LINEAR_SOLVER_BACKEND_ENV, "scipy") + timings, record_timing = _record_timings() + + def fail_solve(*_args, **_kwargs): + raise np.linalg.LinAlgError("singular") + + def least_squares(matrix, rhs, **_kwargs): + return np.linalg.lstsq(matrix, rhs, rcond=None) + + fake_linalg = SimpleNamespace(solve=fail_solve, lstsq=least_squares) + monkeypatch.setattr(demag, "_scipy_linalg", lambda: fake_linalg) + + solution = demag._solve_linear_system( + np.asarray([[1.0, 1.0], [2.0, 2.0]]), + np.asarray([2.0, 4.0]), + record_timing=record_timing, + timing_prefix="linear", + ) + + np.testing.assert_allclose(solution, [1.0, 1.0]) + assert timings["linear:scipy_import"] >= 0.0 + assert timings["linear:scipy_solve_failed"] >= 0.0 + assert timings["linear:scipy_lstsq"] >= 0.0 + + +def test_scipy_gauge_solver_recovers_from_solve_failure(monkeypatch): + monkeypatch.setenv(DEMAG_LINEAR_SOLVER_BACKEND_ENV, "scipy") + timings, record_timing = _record_timings() + + def fail_solve(*_args, **_kwargs): + raise np.linalg.LinAlgError("forced failure") + + def least_squares(matrix, rhs, **_kwargs): + return np.linalg.lstsq(matrix, rhs, rcond=None) + + fake_linalg = SimpleNamespace(solve=fail_solve, lstsq=least_squares) + monkeypatch.setattr(demag, "_scipy_linalg", lambda: fake_linalg) + + solution = demag._solve_gauge_fixed( + np.asarray([[2.0, 0.0], [0.0, 2.0]]), + np.asarray([2.0, -2.0]), + record_timing=record_timing, + timing_prefix="gauge", + ) + + np.testing.assert_allclose(solution, [1.0, -1.0]) + assert timings["gauge:setup"] >= 0.0 + assert timings["gauge:scipy_import"] >= 0.0 + assert timings["gauge:scipy_solve_failed"] >= 0.0 + assert timings["gauge:scipy_lstsq"] >= 0.0 + + +def test_numpy_solver_records_least_squares_recovery(monkeypatch): + monkeypatch.setenv(DEMAG_LINEAR_SOLVER_BACKEND_ENV, "numpy") + timings, record_timing = _record_timings() + + solution = demag._solve_linear_system( + np.asarray([[1.0, 1.0], [2.0, 2.0]]), + np.asarray([2.0, 4.0]), + record_timing=record_timing, + timing_prefix="linear", + ) + + np.testing.assert_allclose(solution, [1.0, 1.0]) + assert timings["linear:numpy_solve_failed"] >= 0.0 + assert timings["linear:numpy_lstsq"] >= 0.0 + + +def test_lindholm_python_contributions_handle_degenerate_and_reversed_faces(): + observer = np.asarray([0.2, 0.3, 1.0]) + p0 = np.asarray([0.0, 0.0, 0.0]) + p1 = np.asarray([1.0, 0.0, 0.0]) + p2 = np.asarray([0.0, 1.0, 0.0]) + + contributions = demag._lindholm_triangle_contributions( + observer, + p0, + p1, + p2, + np.asarray([0.0, 0.0, 1.0]), + ) + reversed_contributions = demag._lindholm_triangle_contributions( + observer, + p0, + p1, + p2, + np.asarray([0.0, 0.0, -1.0]), + ) + degenerate_contributions = demag._lindholm_triangle_contributions( + observer, + p0, + p1, + np.asarray([2.0, 0.0, 0.0]), + np.asarray([0.0, 0.0, 1.0]), + ) + + assert np.all(np.isfinite(contributions)) + assert not np.allclose(contributions, 0.0) + np.testing.assert_allclose(reversed_contributions, -contributions) + np.testing.assert_array_equal(degenerate_contributions, np.zeros(3)) + + +def test_boundary_faces_are_outward_for_reversed_tetrahedron_orientation(): + points = np.asarray( + [ + [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 = np.asarray([[0, 2, 1, 3]], dtype=int) + + boundary_faces = demag._oriented_boundary_faces(points, simplices) + + assert len(boundary_faces) == 4 + cell_center = np.mean(points[simplices[0]], axis=0) + for cell_index, face in boundary_faces: + assert cell_index == 0 + face_points = points[np.asarray(face)] + normal = np.cross(face_points[1] - face_points[0], face_points[2] - face_points[0]) + face_center = np.mean(face_points, axis=0) + assert float(np.dot(normal, cell_center - face_center)) < 0.0 + + +def test_barycentric_coordinates_reject_singular_tetrahedron(): + tetrahedron = np.asarray( + [ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [1.0, 1.0, 0.0], + ] + ) + + assert demag._tetrahedral_barycentric_coordinates( + tetrahedron, + np.asarray([0.25, 0.25, 0.0]), + ) is None diff --git a/tests/nmag/test_demag_factorization.py b/tests/nmag/test_demag_factorization.py new file mode 100644 index 0000000..86fea77 --- /dev/null +++ b/tests/nmag/test_demag_factorization.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import numpy as np + +import nmag +import nmesh +from nmag.backends import DEMAG_LINEAR_SOLVER_BACKEND_ENV + + +def _simulation(tmp_path, monkeypatch, backend: str) -> nmag.Simulation: + monkeypatch.chdir(tmp_path) + monkeypatch.setenv(DEMAG_LINEAR_SOLVER_BACKEND_ENV, backend) + mesh_path = tmp_path / "mesh.nmesh.h5" + 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.save(str(mesh_path)) + material = nmag.MagMaterial(name="Py", Ms=nmag.SI(1.0e6, "A/m")) + simulation = nmag.Simulation(name=f"factor-{backend}") + simulation.load_mesh( + str(mesh_path), + [("magnetic", material)], + unit_length=nmag.SI(1.0e-9, "m"), + ) + return simulation + + +def test_scipy_demag_factorization_is_reused_across_magnetisation_states(tmp_path, monkeypatch): + simulation = _simulation(tmp_path, monkeypatch, "scipy") + simulation.set_m([1.0, 0.0, 0.0]) + first = np.asarray(simulation.get_subfield("H_demag")) + factorization = simulation._demag_gauge_factorization_cache + + simulation.set_m([0.0, 1.0, 0.0]) + second = np.asarray(simulation.get_subfield("H_demag")) + + assert factorization is not None + assert simulation._demag_gauge_factorization_cache is factorization + assert not np.allclose(first, second) + + +def test_cached_scipy_demag_matches_uncached_numpy_reference(tmp_path, monkeypatch): + simulation = _simulation(tmp_path, monkeypatch, "numpy") + simulation.set_m([1.0, 2.0, 3.0]) + reference = np.asarray(simulation.get_subfield("H_demag")) + + monkeypatch.setenv(DEMAG_LINEAR_SOLVER_BACKEND_ENV, "scipy") + simulation._invalidate_demag(clear_geometry=True) + actual = np.asarray(simulation.get_subfield("H_demag")) + + np.testing.assert_allclose(actual, reference, rtol=1.0e-11, atol=1.0e-6) + assert simulation._demag_gauge_factorization_cache is not None + + +def test_geometry_invalidation_clears_demag_factorizations(tmp_path, monkeypatch): + simulation = _simulation(tmp_path, monkeypatch, "scipy") + simulation.set_m([1.0, 0.0, 0.0]) + simulation.get_subfield("H_demag") + + assert simulation._demag_gauge_factorization_cache is not None + simulation._invalidate_demag(clear_geometry=True) + + assert simulation._demag_gauge_factorization_cache is None + assert simulation._demag_dirichlet_factorization_cache is None diff --git a/tests/nmag/test_demag_topology.py b/tests/nmag/test_demag_topology.py new file mode 100644 index 0000000..6dae6ea --- /dev/null +++ b/tests/nmag/test_demag_topology.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import numpy as np +import pytest + +from nmag.demag import _oriented_boundary_faces + + +def _non_manifold_face_mesh() -> tuple[np.ndarray, np.ndarray]: + points = np.asarray( + [ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + [0.0, 0.0, -1.0], + [0.0, 0.0, 2.0], + ], + dtype=np.float64, + ) + simplices = np.asarray( + [ + [0, 1, 2, 3], + [0, 2, 1, 4], + [0, 1, 2, 5], + ], + dtype=np.int64, + ) + return points, simplices + + +def test_python_boundary_face_builder_rejects_non_manifold_face(): + points, simplices = _non_manifold_face_mesh() + + with pytest.raises(ValueError, match="Non-manifold mesh"): + _oriented_boundary_faces(points, simplices) + + +def test_rust_boundary_face_builder_rejects_non_manifold_face(): + rust_accel = pytest.importorskip("nmag_accel") + points, simplices = _non_manifold_face_mesh() + + with pytest.raises(ValueError, match="non-manifold mesh"): + rust_accel.build_oriented_boundary_faces(points, simplices) diff --git a/tests/nmag/test_diffsol_dynamics.py b/tests/nmag/test_diffsol_dynamics.py new file mode 100644 index 0000000..ad71094 --- /dev/null +++ b/tests/nmag/test_diffsol_dynamics.py @@ -0,0 +1,273 @@ +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +import numpy as np +import pytest +from scipy.integrate import solve_ivp + +import nmag +import nmag.backends as backend_module +import nmag.simulation.implicit_dynamics as implicit_dynamics +import nmesh +from nmag.simulation.implicit_dynamics import build_affine_field_operator + + +def _simulation( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + *, + name: str, + do_demag: bool = False, + anisotropy: object | None = None, + config: nmag.NmagConfig | None = None, +) -> nmag.Simulation: + monkeypatch.chdir(tmp_path) + mesh_path = tmp_path / f"{name}.nmesh.h5" + 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.save(str(mesh_path)) + material = nmag.MagMaterial( + name="Py", + Ms=nmag.SI(1.0e6, "A/m"), + exchange_coupling=nmag.SI(13.0e-12, "J/m"), + llg_damping=0.5, + do_precession=False, + anisotropy=anisotropy, # type: ignore[arg-type] + ) + simulation = nmag.Simulation(name=name, do_demag=do_demag, config=config) + simulation.load_mesh( + str(mesh_path), + [("magnetic", material)], + unit_length=nmag.SI(1.0e-9, "m"), + ) + return simulation + + +def test_integrator_backend_defaults_to_scipy() -> None: + assert backend_module._selected_integrator_backend(nmag.NmagConfig()) == "scipy" + + +def test_integrator_backend_rejects_unknown_value() -> None: + with pytest.raises(ValueError, match="integrator_backend"): + nmag.NmagConfig(integrator_backend="unknown") # type: ignore[arg-type] + + +def test_diffsol_rejects_dynamic_anisotropy_explicitly(tmp_path, monkeypatch) -> None: + simulation = _simulation( + tmp_path, + monkeypatch, + name="anisotropy", + anisotropy=nmag.uniaxial_anisotropy([0, 0, 1], nmag.SI(1.0e5, "J/m^3")), + ) + simulation.set_m([1.0, 0.0, 1.0]) + + with pytest.raises(NotImplementedError, match="does not support dynamic anisotropy"): + implicit_dynamics.relax_with_diffsol(simulation) + + +def test_low_memory_mode_rejects_dense_diffsol(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(backend_module.MEMORY_MODE_ENV, "low") + with pytest.raises(ValueError, match="dense affine operator"): + backend_module._selected_integrator_backend( + nmag.NmagConfig(integrator_backend="diffsol") + ) + + +def test_diffsol_dense_memory_guard_has_no_fixed_state_limit( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv(implicit_dynamics.DIFFSOL_DENSE_MEMORY_LIMIT_ENV, raising=False) + monkeypatch.setattr(implicit_dynamics, "_available_memory_bytes", lambda: 1024**3) + + implicit_dynamics._validate_diffsol_dense_memory(1620) + + +def test_diffsol_dense_memory_guard_reports_required_memory( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv(implicit_dynamics.DIFFSOL_DENSE_MEMORY_LIMIT_ENV, "0.01") + + with pytest.raises(MemoryError, match="There is no fixed state-count limit"): + implicit_dynamics._validate_diffsol_dense_memory(1620) + + +@pytest.mark.parametrize("value", ["0", "-1", "nan", "not-memory"]) +def test_diffsol_dense_memory_guard_rejects_invalid_budgets( + monkeypatch: pytest.MonkeyPatch, + value: str, +) -> None: + monkeypatch.setenv(implicit_dynamics.DIFFSOL_DENSE_MEMORY_LIMIT_ENV, value) + + with pytest.raises(ValueError, match=implicit_dynamics.DIFFSOL_DENSE_MEMORY_LIMIT_ENV): + implicit_dynamics._diffsol_dense_memory_budget() + + +def test_diffsol_dense_memory_guard_allows_explicit_unlimited_override( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv(implicit_dynamics.DIFFSOL_DENSE_MEMORY_LIMIT_ENV, "unlimited") + monkeypatch.setattr(implicit_dynamics, "_available_memory_bytes", lambda: 1) + + implicit_dynamics._validate_diffsol_dense_memory(100_000) + + +def test_affine_operator_reproduces_dynamic_field_and_is_cached(tmp_path, monkeypatch) -> None: + simulation = _simulation(tmp_path, monkeypatch, name="affine", do_demag=True) + simulation.set_m([0.25, 0.5, 0.75]) + state = np.ravel(np.asarray(simulation.get_subfield("m"))) + + operator = build_affine_field_operator(simulation) + dynamic_field = np.ravel( + simulation._get_exchange_nodal_field() + simulation._get_demag_nodal_field() + ) + + np.testing.assert_allclose(operator @ state, dynamic_field, rtol=2.0e-11, atol=1.0e-7) + assert build_affine_field_operator(simulation) is operator + simulation._invalidate_demag(clear_geometry=True) + assert simulation._llg_affine_operator_cache is None + + +def test_diffsol_relaxation_aligns_with_external_field(tmp_path, monkeypatch) -> None: + rust_accel = pytest.importorskip("nmag_accel") + if not hasattr(rust_accel, "integrate_llg_bdf"): + pytest.skip("installed nmag_accel does not include the Diffsol backend") + simulation = _simulation( + tmp_path, + monkeypatch, + name="diffsol-relax", + config=nmag.NmagConfig(integrator_backend="diffsol", accelerator="rust"), + ) + simulation.set_m([0.0, 1.0, 0.0]) + simulation.set_H_ext([1.0e5, 0.0, 0.0], nmag.SI("A/m")) + + simulation.relax() + + average = np.asarray(simulation.get_subfield_average("m")) + assert average[0] > 0.999 + assert abs(average[1]) < 3.0e-3 + assert simulation.clock.convergence is True + assert simulation.last_integrator_stats.status == "converged" + assert simulation.last_integrator_stats.rejected_steps is not None + assert simulation.last_integrator_stats.nonlinear_iterations is not None + + +def test_diffsol_rejects_custom_schedules(tmp_path, monkeypatch) -> None: + simulation = _simulation( + tmp_path, + monkeypatch, + name="diffsol-schedule", + config=nmag.NmagConfig(integrator_backend="diffsol", accelerator="rust"), + ) + simulation.set_m([1.0, 0.0, 0.0]) + + with pytest.raises(NotImplementedError, match="default relaxation"): + simulation.relax(save=[]) + + +def test_diffsol_failure_updates_integrator_statistics(tmp_path, monkeypatch) -> None: + import nmag.simulation as simulation_module + + monkeypatch.setattr( + simulation_module, + "_load_rust_accelerator", + lambda _required_by: SimpleNamespace(), + raising=False, + ) + simulation = _simulation( + tmp_path, + monkeypatch, + name="diffsol-failure", + config=nmag.NmagConfig( + integrator_backend="diffsol", + accelerator_overrides={kernel: "off" for kernel in nmag.RustKernel}, + ), + ) + simulation.set_m([1.0, 0.0, 0.0]) + + with pytest.raises(RuntimeError, match="Diffsol backend"): + simulation.relax() + + assert simulation.last_integrator_stats.failed is True + assert simulation.last_integrator_stats.status == "failed" + + +def test_diffsol_kernel_matches_scipy_at_a_fixed_time() -> None: + rust_accel = pytest.importorskip("nmag_accel") + if not hasattr(rust_accel, "integrate_llg_bdf"): + pytest.skip("installed nmag_accel does not include the Diffsol backend") + initial = np.asarray([0.0, 1.0, 0.0]) + constant_field = np.asarray([1.0e5, 0.0, 0.0]) + coefficients = (0.0, -8.0e4, 0.0) + final_time = 20.0e-12 + + def rhs(_time: float, state: np.ndarray) -> np.ndarray: + mdoth = float(np.dot(state, constant_field)) + mdotm = float(np.dot(state, state)) + damping = state * mdoth - constant_field * mdotm + return coefficients[1] * damping + + scipy_result = solve_ivp( + rhs, + (0.0, final_time), + initial, + method="DOP853", + rtol=1.0e-9, + atol=1.0e-11, + max_step=1.0e-12, + ) + diffsol_result = rust_accel.integrate_llg_bdf( + initial, + np.zeros((3, 3)), + constant_field, + np.ones(1), + *coefficients, + 0.0, + final_time, + 1.0e-9, + 1.0e-11, + 1.0e-15, + 1.0e-12, + 1.0e-300, + 5, + 2, + 1000, + ) + + assert diffsol_result.converged is False + np.testing.assert_allclose(diffsol_result.state, scipy_result.y[:, -1], rtol=2.0e-8) + + +def test_diffsol_kernel_rejects_a_mismatched_operator() -> None: + rust_accel = pytest.importorskip("nmag_accel") + if not hasattr(rust_accel, "integrate_llg_bdf"): + pytest.skip("installed nmag_accel does not include the Diffsol backend") + with pytest.raises(ValueError, match="field_operator must have shape"): + rust_accel.integrate_llg_bdf( + np.asarray([1.0, 0.0, 0.0]), + np.zeros((2, 2)), + np.zeros(3), + np.ones(1), + -1.0, + -0.5, + 0.0, + 0.0, + 1.0, + 1.0e-6, + 1.0e-6, + 1.0e-15, + 1.0e-12, + 1.0, + 5, + 2, + 100, + ) diff --git a/tests/nmag/test_dmdt_legacy_scale.py b/tests/nmag/test_dmdt_legacy_scale.py new file mode 100644 index 0000000..0abce0d --- /dev/null +++ b/tests/nmag/test_dmdt_legacy_scale.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import numpy as np +import pytest + +import nmag +import nmesh + + +def _write_tetrahedral_mesh(path: str) -> None: + 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], + ).save(path) + + +@pytest.mark.parametrize("backend", ["python", "rust"]) +def test_dmdt_uses_legacy_global_magnetisation_scale( + tmp_path, + monkeypatch, + backend: str, +) -> None: + if backend == "rust": + pytest.importorskip("nmag_accel") + + mesh_path = tmp_path / "tetra.nmesh.h5" + _write_tetrahedral_mesh(str(mesh_path)) + material = nmag.MagMaterial( + name="Py", + Ms=nmag.SI(0.86e6, "A/m"), + exchange_coupling=nmag.SI(13.0e-12, "J/m"), + do_precession=False, + ) + simulation = nmag.Simulation( + name="legacy-dmdt", + do_demag=False, + config=nmag.NmagConfig(accelerator="rust" if backend == "rust" else "off"), + ) + simulation.load_mesh( + str(mesh_path), + [("magnetic", material)], + unit_length=nmag.SI(1.0, "m"), + ) + simulation.set_m([0.0, 1.0, 0.0]) + simulation.set_H_ext([2.0, 0.0, 3.0], nmag.SI("A/m")) + + damping_coeff = -221017.3 * 0.5 / (1.0 + 0.5**2) + expected = -damping_coeff * 1.0e6 * np.asarray([2.0, 0.0, 3.0]) + dmdt = simulation._subfield_array("dmdt") + + np.testing.assert_allclose(dmdt, np.tile(expected, (4, 1))) diff --git a/tests/nmag/test_dynamics_config.py b/tests/nmag/test_dynamics_config.py new file mode 100644 index 0000000..f2a5c16 --- /dev/null +++ b/tests/nmag/test_dynamics_config.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import pytest + +from nmag.dynamics import ConvergenceTracker, IntegratorConfig +from si.physical import SI +from simulation.clock import SimulationClock + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("relative_tolerance", 0.0), + ("absolute_tolerance", -1.0), + ("maximum_step_seconds", float("inf")), + ], +) +def test_integrator_config_rejects_invalid_positive_values(field, value): + config = IntegratorConfig() + setattr(config, field, value) + + with pytest.raises(ValueError, match=field): + config.validate() + + +def test_convergence_requires_consecutive_checks_below_threshold(): + tracker = ConvergenceTracker(required_checks=2) + + assert not tracker.check(step=5, max_dm_dt=0.5, stopping_dm_dt=1.0) + assert not tracker.check(step=10, max_dm_dt=2.0, stopping_dm_dt=1.0) + assert not tracker.check(step=15, max_dm_dt=0.5, stopping_dm_dt=1.0) + assert tracker.check(step=20, max_dm_dt=0.25, stopping_dm_dt=1.0) + + assert not tracker.check(step=25, max_dm_dt=0.25, stopping_dm_dt=1.0) + + +def test_convergence_log_records_nonconverged_checks(): + tracker = ConvergenceTracker() + tracker.check(step=5, max_dm_dt=2.0, stopping_dm_dt=1.0) + + log = tracker.get_log() + + assert "# Convergence log" in log + assert "5 2.0 1.0" in log + + +def test_clock_records_an_advance_atomically(): + clock = SimulationClock() + + clock.record_advance( + stage_time_seconds=2.0e-12, + accepted_steps=3, + last_step_seconds=0.5e-12, + wall_seconds=0.25, + ) + + assert clock.step == 3 + assert clock.stage_step == 3 + seconds = SI(1.0, "s") + assert clock.time.in_units_of(seconds) == pytest.approx(2.0e-12) + assert clock.stage_time.in_units_of(seconds) == pytest.approx(2.0e-12) + assert clock.last_step_dt_si.in_units_of(seconds) == pytest.approx(0.5e-12) + assert clock.real_time.in_units_of(seconds) == pytest.approx(0.25) + + +def test_clock_rejects_backwards_stage_time(): + clock = SimulationClock() + clock.record_advance( + stage_time_seconds=2.0, + accepted_steps=1, + last_step_seconds=2.0, + wall_seconds=0.0, + ) + + with pytest.raises(ValueError, match="backwards"): + clock.record_advance( + stage_time_seconds=1.0, + accepted_steps=1, + last_step_seconds=1.0, + wall_seconds=0.0, + ) diff --git a/tests/nmag/test_dynamics_demag_integration.py b/tests/nmag/test_dynamics_demag_integration.py new file mode 100644 index 0000000..d9a27de --- /dev/null +++ b/tests/nmag/test_dynamics_demag_integration.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +from pathlib import Path + +import numpy as np + +import nmag +import nmesh + +FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" +SPHERE_MESH = FIXTURES / "nmag_doc_example1" / "sphere1.nmesh.h5" + + +def test_two_tetra_nonuniform_state_advances_with_demag(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + mesh_path = tmp_path / "two-tetra.nmesh.h5" + 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], + [1.0, 1.0, 1.0], + ], + simplices_indices=[[0, 1, 2, 3], [1, 2, 3, 4]], + simplices_regions=[1, 1], + ) + mesh.save(str(mesh_path)) + material = nmag.MagMaterial( + name="Py", + Ms=nmag.SI(1.0e6, "A/m"), + exchange_coupling=nmag.SI(13.0e-12, "J/m"), + do_precession=False, + ) + simulation = nmag.Simulation(name="two-tetra") + simulation.load_mesh( + str(mesh_path), + [("magnetic", material)], + unit_length=nmag.SI(1.0e-9, "m"), + ) + simulation.set_m(lambda point: [1.0, point[0] * 0.25, point[1] * 0.25]) + initial = np.asarray(simulation.get_subfield("m")) + + simulation.advance_time(nmag.SI(1.0e-12, "s"), max_it=2) + final = np.asarray(simulation.get_subfield("m")) + + assert simulation.step == 2 + assert np.all(np.isfinite(final)) + assert not np.allclose(final, initial) + np.testing.assert_allclose(np.linalg.norm(final, axis=1), 1.0, rtol=2.0e-6) + + +def test_sphere_dynamic_demag_smoke(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + material = nmag.MagMaterial( + name="Py", + Ms=nmag.SI(1.0e6, "A/m"), + exchange_coupling=nmag.SI(13.0e-12, "J/m"), + do_precession=False, + ) + simulation = nmag.Simulation(name="sphere-dynamic") + simulation.load_mesh( + str(SPHERE_MESH), + [("sphere", material)], + unit_length=nmag.SI(1.0e-9, "m"), + ) + simulation.set_m([1.0, 0.0, 0.0]) + simulation.set_H_ext([0.0, 1.0e4, 0.0], nmag.SI("A/m")) + + simulation.advance_time(nmag.SI(1.0e-9, "s"), max_it=1) + final = np.asarray(simulation.get_subfield("m")) + + assert simulation.step == 1 + assert np.all(np.isfinite(final)) + np.testing.assert_allclose(np.linalg.norm(final, axis=1), 1.0, rtol=2.0e-6) + + +def test_exchange_spectral_cap_matches_a_smaller_step_reference(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + mesh_path = tmp_path / "stiff-two-tetra.nmesh.h5" + 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], + [1.0, 1.0, 1.0], + ], + simplices_indices=[[0, 1, 2, 3], [1, 2, 3, 4]], + simplices_regions=[1, 1], + ) + mesh.save(str(mesh_path)) + + def make_simulation(name: str) -> nmag.Simulation: + material = nmag.MagMaterial( + name="Py", + Ms=nmag.SI(1.0e6, "A/m"), + exchange_coupling=nmag.SI(13.0e-12, "J/m"), + do_precession=False, + ) + simulation = nmag.Simulation(name=name, do_demag=False) + simulation.load_mesh( + str(mesh_path), + [("magnetic", material)], + unit_length=nmag.SI(1.0e-9, "m"), + ) + simulation.set_m(lambda point: [1.0, point[0] * 0.25, point[1] * 0.25]) + return simulation + + automatic = make_simulation("automatic-cap") + automatic.reinitialise() + automatic_cap = automatic.effective_integrator_max_step.in_units_of(nmag.SI("s")) + assert 0.0 < automatic_cap < 1.0e-12 + assert automatic._exchange_spectral_bound_cache is not None + + uniform = make_simulation("uniform-initial-state") + uniform.set_m([0.0, 1.0, 0.0]) + uniform.reinitialise() + assert uniform.effective_integrator_max_step.in_units_of(nmag.SI("s")) == automatic_cap + + reference = make_simulation("reference-cap") + reference.set_params(ts_max_step=automatic_cap / 4.0) + target = nmag.SI(2.0e-12, "s") + automatic.advance_time(target) + reference.advance_time(target) + + np.testing.assert_allclose( + automatic.get_subfield("m"), + reference.get_subfield("m"), + rtol=2.0e-5, + atol=2.0e-7, + ) + + automatic._invalidate_demag(clear_geometry=True) + assert automatic._exchange_spectral_bound_cache is None diff --git a/tests/nmag/test_dynamics_integration.py b/tests/nmag/test_dynamics_integration.py new file mode 100644 index 0000000..7b6c373 --- /dev/null +++ b/tests/nmag/test_dynamics_integration.py @@ -0,0 +1,194 @@ +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pytest + +import nmag +import nmag.simulation.dynamics as dynamics_module +import nmesh + + +def _simulation( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + *, + name: str, + damping: float = 0.5, + precession: bool = True, +) -> nmag.Simulation: + monkeypatch.chdir(tmp_path) + mesh_path = tmp_path / f"{name}.nmesh.h5" + 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.save(str(mesh_path)) + material = nmag.MagMaterial( + name="Py", + Ms=nmag.SI(1.0e6, "A/m"), + exchange_coupling=nmag.SI(0.0, "J/m"), + llg_damping=damping, + do_precession=precession, + ) + simulation = nmag.Simulation(name=name, do_demag=False) + simulation.load_mesh( + str(mesh_path), + [("magnetic", material)], + unit_length=nmag.SI(1.0e-9, "m"), + ) + return simulation + + +def test_zero_torque_state_remains_constant(tmp_path, monkeypatch): + simulation = _simulation(tmp_path, monkeypatch, name="zero-torque") + simulation.set_m([1.0, 0.0, 0.0]) + simulation.set_H_ext([1.0e5, 0.0, 0.0], nmag.SI("A/m")) + + reached = simulation.advance_time(nmag.SI(2.0e-12, "s")) + + assert reached.in_units_of(nmag.SI(1.0, "s")) == pytest.approx(2.0e-12) + np.testing.assert_allclose(simulation.get_subfield("m"), [[1.0, 0.0, 0.0]] * 4) + assert simulation.last_integrator_stats.accepted_steps > 0 + assert simulation.last_integrator_stats.failed is False + assert simulation.effective_integrator_max_step.in_units_of(nmag.SI("s")) == pytest.approx( + 1.0e-12 + ) + + +def test_damping_moves_magnetisation_towards_external_field(tmp_path, monkeypatch): + simulation = _simulation( + tmp_path, + monkeypatch, + name="damping", + precession=False, + ) + simulation.set_m([0.0, 1.0, 0.0]) + simulation.set_H_ext([1.0e5, 0.0, 0.0], nmag.SI("A/m")) + + simulation.advance_time(nmag.SI(2.0e-12, "s")) + average = np.asarray(simulation.get_subfield_average("m")) + + assert average[0] > 0.0 + assert average[1] < 1.0 + + +def test_precession_preserves_magnetisation_norm(tmp_path, monkeypatch): + simulation = _simulation( + tmp_path, + monkeypatch, + name="precession", + damping=0.0, + precession=True, + ) + simulation.set_m([0.0, 1.0, 0.0]) + simulation.set_H_ext([1.0e5, 0.0, 0.0], nmag.SI("A/m")) + + simulation.advance_time(nmag.SI(5.0e-12, "s")) + norms = np.linalg.norm(np.asarray(simulation.get_subfield("m")), axis=1) + + np.testing.assert_allclose(norms, 1.0, rtol=1.0e-8, atol=1.0e-10) + + +def test_advance_time_honours_maximum_accepted_steps(tmp_path, monkeypatch): + simulation = _simulation(tmp_path, monkeypatch, name="max-it") + simulation.set_m([0.0, 1.0, 0.0]) + simulation.set_H_ext([1.0e5, 0.0, 0.0], nmag.SI("A/m")) + + reached = simulation.advance_time(nmag.SI(1.0e-9, "s"), max_it=2) + + assert simulation.step == 2 + assert simulation.last_integrator_stats.accepted_steps == 2 + assert reached < nmag.SI(1.0e-9, "s") + + +def test_exact_stop_interpolates_and_reinitialises(tmp_path, monkeypatch): + simulation = _simulation(tmp_path, monkeypatch, name="exact-stop") + simulation.set_m([0.0, 1.0, 0.0]) + simulation.set_H_ext([1.0e5, 0.0, 0.0], nmag.SI("A/m")) + simulation.set_params(ts_max_step=nmag.SI(1.0e-12, "s")) + + reached = simulation.advance_time(nmag.SI(1.5e-12, "s"), exact_tstop=True) + + assert reached.in_units_of(nmag.SI(1.0, "s")) == pytest.approx(1.5e-12) + assert simulation.time.in_units_of(nmag.SI(1.0, "s")) == pytest.approx(1.5e-12) + assert simulation.stage_time.in_units_of(nmag.SI(1.0, "s")) == pytest.approx(1.5e-12) + assert simulation._integrator_is_stale is False + + +def test_setters_invalidate_an_initialised_integrator(tmp_path, monkeypatch): + simulation = _simulation(tmp_path, monkeypatch, name="invalidate") + simulation.set_m([0.0, 1.0, 0.0]) + simulation.set_H_ext([1.0e5, 0.0, 0.0], nmag.SI("A/m")) + simulation.reinitialise() + assert simulation._integrator_is_stale is False + + simulation.set_H_ext([2.0e5, 0.0, 0.0], nmag.SI("A/m")) + + assert simulation._integrator_is_stale is True + + +def test_relax_reaches_convergence_and_preserves_legacy_save_cadence(tmp_path, monkeypatch): + simulation = _simulation(tmp_path, monkeypatch, name="relax") + simulation.set_m([1.0, 0.0, 0.0]) + simulation.set_H_ext([1.0e5, 0.0, 0.0], nmag.SI("A/m")) + + simulation.relax() + + assert simulation.clock.convergence is True + assert simulation.step == 10 + lines = (tmp_path / "relax_dat.ndt").read_text(encoding="utf-8").splitlines() + header = lines[1].split("\t") + rows = [dict(zip(header, line.split("\t"), strict=True)) for line in lines[2:]] + assert [row["id"] for row in rows] == ["0", "1"] + assert len({row["step"] for row in rows}) == 1 + + +def test_failed_integrator_is_reported(tmp_path, monkeypatch): + simulation = _simulation(tmp_path, monkeypatch, name="failure") + simulation.set_m([1.0, 0.0, 0.0]) + simulation.set_H_ext([1.0e5, 0.0, 0.0], nmag.SI("A/m")) + + class FailedIntegrator: + def __init__(self, *_args, **_kwargs): + self.t = 0.0 + self.y = np.asarray(simulation._fields["m"]).reshape(-1) + self.status = "running" + + def step(self): + self.status = "failed" + return "forced failure" + + def dense_output(self): + raise AssertionError("dense output should not be requested") + + monkeypatch.setattr(dynamics_module, "_dop853_class", lambda: FailedIntegrator) + + with pytest.raises(RuntimeError, match="forced failure"): + simulation.advance_time(nmag.SI(1.0e-12, "s")) + + assert simulation.last_integrator_stats.failed is True + assert simulation.last_integrator_stats.status == "failed" + + +@pytest.mark.parametrize( + ("keyword", "value"), + [ + ("stopping_dm_dt", 0.0), + ("ts_rel_tol", 0.0), + ("ts_abs_tol", -1.0), + ("ts_max_step", float("nan")), + ], +) +def test_set_params_rejects_invalid_values(tmp_path, monkeypatch, keyword, value): + simulation = _simulation(tmp_path, monkeypatch, name=f"invalid-{keyword}") + + with pytest.raises(ValueError): + simulation.set_params(**{keyword: value}) diff --git a/tests/nmag/test_heterogeneous_materials.py b/tests/nmag/test_heterogeneous_materials.py new file mode 100644 index 0000000..8eac163 --- /dev/null +++ b/tests/nmag/test_heterogeneous_materials.py @@ -0,0 +1,317 @@ +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pytest + +import nmag +import nmesh +from nmag.simulation.support import MU0 + + +def _write_disconnected_two_region_mesh(path: Path) -> None: + 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], + [10.0, 0.0, 0.0], + [11.0, 0.0, 0.0], + [10.0, 1.0, 0.0], + [10.0, 0.0, 1.0], + ], + simplices_indices=[[0, 1, 2, 3], [4, 5, 6, 7]], + simplices_regions=[1, 2], + ) + mesh.save(str(path)) + + +def _write_shared_two_region_mesh(path: Path) -> None: + 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], + [1.0, 1.0, 1.0], + ], + simplices_indices=[[0, 1, 2, 3], [1, 2, 3, 4]], + simplices_regions=[1, 2], + ) + mesh.save(str(path)) + + +def _material( + name: str, + *, + ms: float = 1.0e6, + exchange: float = 0.0, + damping: float = 0.5, + precession: bool = False, +) -> nmag.MagMaterial: + return nmag.MagMaterial( + name=name, + Ms=nmag.SI(ms, "A/m"), + exchange_coupling=nmag.SI(exchange, "J/m"), + llg_damping=damping, + do_precession=precession, + ) + + +def _simulation( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + *, + name: str, + first: nmag.MagMaterial, + second: nmag.MagMaterial, + shared: bool = False, + config: nmag.NmagConfig | None = None, +) -> nmag.Simulation: + monkeypatch.chdir(tmp_path) + mesh_path = tmp_path / f"{name}.nmesh.h5" + if shared: + _write_shared_two_region_mesh(mesh_path) + else: + _write_disconnected_two_region_mesh(mesh_path) + simulation = nmag.Simulation(name=name, do_demag=False, config=config) + simulation.load_mesh( + str(mesh_path), + [("first", first), ("second", second)], + unit_length=nmag.SI(1.0, "m"), + ) + return simulation + + +def test_nodal_material_coefficients_follow_disconnected_regions( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + first = _material("first", ms=0.8e6, exchange=1.3e-11, damping=0.1) + second = _material("second", ms=1.4e6, exchange=2.5e-11, damping=0.3) + simulation = _simulation( + tmp_path, + monkeypatch, + name="heterogeneous-coefficients", + first=first, + second=second, + ) + + coefficients = simulation._nodal_material_coefficients() + + expected_exchange = np.asarray([2.0 * 1.3e-11 / (MU0 * 0.8e6), 2.0 * 2.5e-11 / (MU0 * 1.4e6)]) + np.testing.assert_allclose(coefficients.exchange_prefactor[:4], expected_exchange[0]) + np.testing.assert_allclose(coefficients.exchange_prefactor[4:], expected_exchange[1]) + assert np.all(coefficients.damping[:4] != coefficients.damping[4:]) + assert simulation._nodal_material_coefficients() is coefficients + + +def test_material_specific_average_only_integrates_selected_region( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + simulation = _simulation( + tmp_path, + monkeypatch, + name="heterogeneous-average", + first=_material("first"), + second=_material("second"), + ) + simulation._fields["m"] = np.asarray( + [[1.0, 0.0, 0.0]] * 4 + [[0.0, 1.0, 0.0]] * 4, + dtype=float, + ) + + np.testing.assert_allclose(simulation.get_subfield_average("m", "first"), [1.0, 0.0, 0.0]) + np.testing.assert_allclose(simulation.get_subfield_average("m", "second"), [0.0, 1.0, 0.0]) + np.testing.assert_allclose(simulation.get_subfield_average("m"), [0.5, 0.5, 0.0]) + + with pytest.raises(KeyError, match="Unknown material"): + simulation.get_subfield_average("m", "missing") + + +def test_heterogeneous_exchange_scales_each_disconnected_region( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + first = _material("first", ms=1.0e6, exchange=1.0e-11) + second = _material("second", ms=2.0e6, exchange=4.0e-11) + simulation = _simulation( + tmp_path, + monkeypatch, + name="heterogeneous-exchange", + first=first, + second=second, + ) + simulation.set_m( + lambda point: [ + 1.0, + 0.2 * (point[0] if point[0] < 5.0 else point[0] - 10.0), + 0.1 * point[1], + ] + ) + + exchange = np.asarray(simulation.get_subfield("H_exch"), dtype=float) + + first_prefactor = 2.0 * 1.0e-11 / (MU0 * 1.0e6) + second_prefactor = 2.0 * 4.0e-11 / (MU0 * 2.0e6) + np.testing.assert_allclose( + exchange[4:], + exchange[:4] * (second_prefactor / first_prefactor), + rtol=2.0e-13, + atol=1.0e-12, + ) + + +@pytest.mark.parametrize("backend", ["python", "rust"]) +def test_heterogeneous_llg_uses_each_regions_coefficients( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + backend: str, +) -> None: + if backend == "rust": + pytest.importorskip("nmag_accel") + first = _material("first", damping=0.1) + second = _material("second", damping=0.8) + simulation = _simulation( + tmp_path, + monkeypatch, + name=f"heterogeneous-llg-{backend}", + first=first, + second=second, + config=nmag.NmagConfig(accelerator="rust" if backend == "rust" else "off"), + ) + simulation.set_m([0.0, 1.0, 0.0]) + simulation.set_H_ext([1.0e5, 0.0, 0.0], nmag.SI("A/m")) + + derivative = np.asarray(simulation.get_subfield("dmdt"), dtype=float) + + gamma = 2.210173e5 + expected_first = gamma * 0.1 / (1.0 + 0.1**2) * 1.0e11 + expected_second = gamma * 0.8 / (1.0 + 0.8**2) * 1.0e11 + np.testing.assert_allclose(derivative[:4, 0], expected_first, rtol=2.0e-15) + np.testing.assert_allclose(derivative[4:, 0], expected_second, rtol=2.0e-15) + np.testing.assert_allclose(derivative[:, 1:], 0.0, atol=1.0e-12) + + +def test_heterogeneous_fixed_time_dynamics_separate_material_rates( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + first = _material("first", damping=0.1) + second = _material("second", damping=0.8) + simulation = _simulation( + tmp_path, + monkeypatch, + name="heterogeneous-dynamics", + first=first, + second=second, + ) + simulation.set_m([0.0, 1.0, 0.0]) + simulation.set_H_ext([1.0e5, 0.0, 0.0], nmag.SI("A/m")) + + simulation.advance_time(nmag.SI(2.0e-12, "s")) + magnetisation = np.asarray(simulation.get_subfield("m"), dtype=float) + + assert np.all(magnetisation[4:, 0] > magnetisation[:4, 0]) + np.testing.assert_allclose(magnetisation[:4], np.tile(magnetisation[0], (4, 1))) + np.testing.assert_allclose(magnetisation[4:], np.tile(magnetisation[4], (4, 1))) + + +def test_conflicting_materials_on_shared_nodes_fail_clearly( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + simulation = _simulation( + tmp_path, + monkeypatch, + name="heterogeneous-shared", + first=_material("first", damping=0.1), + second=_material("second", damping=0.8), + shared=True, + ) + + with pytest.raises(NotImplementedError, match="material-specific magnetisation DOFs"): + simulation._nodal_material_coefficients() + + +def test_identical_material_coefficients_can_share_nodes( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + simulation = _simulation( + tmp_path, + monkeypatch, + name="homogeneous-shared", + first=_material("first", damping=0.25), + second=_material("second", damping=0.25), + shared=True, + ) + + coefficients = simulation._nodal_material_coefficients() + + np.testing.assert_array_equal( + coefficients.damping, + np.full(5, coefficients.damping[0]), + ) + + +def test_rust_heterogeneous_llg_matches_python_randomised( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + pytest.importorskip("nmag_accel") + simulation = _simulation( + tmp_path, + monkeypatch, + name="heterogeneous-randomised", + first=_material("first"), + second=_material("second"), + ) + generator = np.random.default_rng(20260715) + m = generator.normal(size=(8, 3)) + m /= np.linalg.norm(m, axis=1)[:, np.newaxis] + h_total = generator.normal(size=(8, 3)) * 1.0e5 + pin = generator.uniform(0.0, 1.0, size=8) + scales = generator.uniform(0.5, 1.5, size=8) + precession = generator.uniform(-3.0e5, 0.0, size=8) + damping = generator.uniform(-2.0e5, 0.0, size=8) + normalisation = generator.uniform(0.0, 1.0e11, size=8) + + expected = simulation._llg_rhs_python( + m, + h_total, + pin, + scales, + precession, + damping, + normalisation, + ) + actual = simulation._llg_rhs_rust( + m, + h_total, + pin, + scales, + precession, + damping, + normalisation, + ) + + np.testing.assert_allclose(actual, expected, rtol=1.0e-14, atol=5.0e-8) + + +def test_rust_heterogeneous_llg_rejects_mismatched_coefficients() -> None: + rust_accel = pytest.importorskip("nmag_accel") + + with pytest.raises(ValueError, match="precession_coeff"): + rust_accel.llg_rhs_heterogeneous( + np.asarray([[1.0, 0.0, 0.0]]), + np.asarray([[0.0, 1.0, 0.0]]), + np.ones(1), + np.ones(1), + np.empty(0), + np.ones(1), + np.ones(1), + ) diff --git a/tests/nmag/test_hierarchical_bem.py b/tests/nmag/test_hierarchical_bem.py new file mode 100644 index 0000000..24b6b41 --- /dev/null +++ b/tests/nmag/test_hierarchical_bem.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Literal + +import numpy as np +import pytest + +import nmag +from nmag.simulation.demag.bem.hierarchical import build_hierarchical_lindholm_operator + +FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" +SPHERE_MESH = FIXTURES / "nmag_doc_example1" / "sphere1.nmesh.h5" + + +def _simulation( + tmp_path: Path, + *, + storage: Literal["auto", "dense", "hierarchical", "matrix-free"], + accelerator: Literal["auto", "off", "rust"] = "rust", + hierarchical_bem: nmag.HierarchicalBemConfig | None = None, +) -> nmag.Simulation: + material = nmag.MagMaterial( + name="Py", + Ms=nmag.SI(1.0e6, "A/m"), + exchange_coupling=nmag.SI(13.0e-12, "J/m"), + ) + simulation = nmag.Simulation( + name=f"bem-{storage}", + config=nmag.NmagConfig( + output_directory=tmp_path, + demag_bem_storage=storage, + accelerator=accelerator, + hierarchical_bem=hierarchical_bem or nmag.HierarchicalBemConfig(), + ), + ) + simulation.load_mesh( + str(SPHERE_MESH), + [("magnetic", material)], + unit_length=nmag.SI(1.0e-9, "m"), + ) + simulation.set_m(lambda point: [1.0, 0.1 * point[0], -0.05 * point[1]]) + return simulation + + +def test_hierarchical_demag_matches_dense_and_reports_diagnostics(tmp_path: Path) -> None: + pytest.importorskip("nmag_accel") + dense = _simulation(tmp_path, storage="dense") + hierarchical = _simulation(tmp_path, storage="hierarchical") + + np.testing.assert_allclose( + hierarchical.get_subfield("H_demag"), + dense.get_subfield("H_demag"), + rtol=1.0e-8, + atol=1.0e-5, + ) + np.testing.assert_allclose( + hierarchical.get_subfield("E_demag"), + dense.get_subfield("E_demag"), + rtol=1.0e-8, + atol=1.0e-5, + ) + stats = hierarchical.last_bem_operator_stats + assert stats is not None + assert stats.requested_backend == "hierarchical" + assert stats.effective_backend == "hierarchical" + assert stats.fallback_reason is None + assert stats.sampled_relative_error <= 1.0e-6 + assert stats.storage_bytes > 0 + with pytest.raises(AttributeError): + hierarchical.last_bem_operator_stats = None # type: ignore[misc] + hierarchical._invalidate_demag(clear_geometry=True) + assert hierarchical.last_bem_operator_stats is None + + +def test_hierarchical_mode_falls_back_exactly_when_rust_is_disabled(tmp_path: Path) -> None: + simulation = _simulation(tmp_path, storage="hierarchical", accelerator="off") + field = np.asarray(simulation.get_subfield("H_demag")) + + assert np.isfinite(field).all() + stats = simulation.last_bem_operator_stats + assert stats is not None + assert stats.effective_backend == "matrix-free" + assert "unavailable or disabled" in (stats.fallback_reason or "") + + +def test_hierarchical_mode_falls_back_when_memory_budget_is_exhausted(tmp_path: Path) -> None: + pytest.importorskip("nmag_accel") + simulation = _simulation( + tmp_path, + storage="hierarchical", + hierarchical_bem=nmag.HierarchicalBemConfig(memory_fraction=1.0e-15), + ) + field = np.asarray(simulation.get_subfield("H_demag")) + + assert np.isfinite(field).all() + stats = simulation.last_bem_operator_stats + assert stats is not None + assert stats.effective_backend == "matrix-free" + assert "memory budget exceeded" in (stats.fallback_reason or "") + + +def test_hierarchical_operator_rejects_a_wrong_vector_shape(tmp_path: Path) -> None: + pytest.importorskip("nmag_accel") + simulation = _simulation(tmp_path, storage="hierarchical") + points = np.asarray(simulation.mesh.points, dtype=float) + simplices = np.asarray(simulation.mesh.simplices, dtype=np.int64) + faces = simulation._boundary_faces_for_demag_mesh(points, simplices) + _nodes, operator, fallback = build_hierarchical_lindholm_operator( + simulation, + points, + simplices, + faces, + ) + + assert fallback is None + with pytest.raises(ValueError, match="BEM input must have shape"): + operator @ np.zeros(operator.shape[1] - 1) + values = np.zeros(operator.shape[1]) + values[0] = np.nan + with pytest.raises(ValueError, match="finite"): + operator @ values + + +def test_hierarchical_fixed_time_dynamics_match_dense(tmp_path: Path) -> None: + pytest.importorskip("nmag_accel") + dense = _simulation(tmp_path, storage="dense") + hierarchical = _simulation(tmp_path, storage="hierarchical") + target = nmag.SI(2.0e-14, "s") + + dense.advance_time(target) + hierarchical.advance_time(target) + + assert hierarchical.clock.time_reached_si == dense.clock.time_reached_si + np.testing.assert_allclose( + hierarchical.get_subfield("m"), + dense.get_subfield("m"), + rtol=1.0e-6, + atol=1.0e-7, + ) diff --git a/tests/nmag/test_hierarchical_bem_geometries.py b/tests/nmag/test_hierarchical_bem_geometries.py new file mode 100644 index 0000000..708d38d --- /dev/null +++ b/tests/nmag/test_hierarchical_bem_geometries.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +import numpy as np +import pytest +from scipy.spatial import ConvexHull, Delaunay + +nmag_accel: Any = pytest.importorskip("nmag_accel") + + +@dataclass(frozen=True, slots=True) +class OperatorMesh: + points: np.ndarray + simplices: np.ndarray + + +def _sphere_mesh(boundary_nodes: int = 80) -> OperatorMesh: + indices = np.arange(boundary_nodes, dtype=np.float64) + z = 1.0 - 2.0 * (indices + 0.5) / boundary_nodes + radius = np.sqrt(1.0 - z * z) + angle = np.pi * (3.0 - np.sqrt(5.0)) * indices + surface = np.column_stack((radius * np.cos(angle), radius * np.sin(angle), z)) + points = np.vstack((surface, np.zeros((1, 3)))) + faces = np.asarray(ConvexHull(surface).simplices, dtype=np.int64) + centre = np.full((len(faces), 1), boundary_nodes, dtype=np.int64) + return OperatorMesh(points, np.hstack((faces, centre))) + + +def _thin_film_mesh() -> OperatorMesh: + nx = 6 + ny = 6 + points = np.asarray( + [(x, y, 0.15 * z) for z in range(2) for y in range(ny) for x in range(nx)], + dtype=np.float64, + ) + + def index(x: int, y: int, z: int) -> int: + return (z * ny + y) * nx + x + + tetrahedra = ( + (0, 1, 3, 7), + (0, 3, 2, 7), + (0, 2, 6, 7), + (0, 6, 4, 7), + (0, 4, 5, 7), + (0, 5, 1, 7), + ) + simplices: list[list[int]] = [] + for y in range(ny - 1): + for x in range(nx - 1): + corners = ( + index(x, y, 0), + index(x + 1, y, 0), + index(x, y + 1, 0), + index(x + 1, y + 1, 0), + index(x, y, 1), + index(x + 1, y, 1), + index(x, y + 1, 1), + index(x + 1, y + 1, 1), + ) + simplices.extend([[corners[i] for i in tetra] for tetra in tetrahedra]) + return OperatorMesh(points, np.asarray(simplices, dtype=np.int64)) + + +def _disconnected_tetrahedra() -> OperatorMesh: + first = np.asarray( + [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]] + ) + return OperatorMesh( + points=np.vstack((first, first + np.asarray([4.0, 0.5, -0.25]))), + simplices=np.asarray([[0, 1, 2, 3], [4, 5, 6, 7]], dtype=np.int64), + ) + + +def _random_mesh() -> OperatorMesh: + points = np.random.default_rng(20260717).uniform(-1.0, 1.0, size=(28, 3)) + return OperatorMesh(points=points, simplices=np.asarray(Delaunay(points).simplices, dtype=np.int64)) + + +@pytest.mark.parametrize( + "mesh_factory", + [ + _disconnected_tetrahedra, + _random_mesh, + _sphere_mesh, + _thin_film_mesh, + ], + ids=("disconnected", "random", "sphere", "thin-film"), +) +def test_hierarchical_matvec_matches_dense_on_varied_geometries( + mesh_factory: Callable[[], OperatorMesh], +) -> None: + mesh = mesh_factory() + _owners, raw_faces = nmag_accel.build_oriented_boundary_faces( + mesh.points, + mesh.simplices, + ) + faces = np.asarray(raw_faces, dtype=np.int64) + boundary_nodes = np.unique(faces) + local_index = np.full(len(mesh.points), -1, dtype=np.int64) + local_index[boundary_nodes] = np.arange(len(boundary_nodes), dtype=np.int64) + hierarchical = nmag_accel.build_lindholm_hmatrix( + mesh.points, + mesh.simplices, + faces, + boundary_nodes, + local_index, + memory_budget_bytes=256 * 1024 * 1024, + ) + dense = np.asarray( + nmag_accel.build_lindholm_bem_matrix( + mesh.points, + mesh.simplices, + faces, + boundary_nodes, + local_index, + ) + ) + + generator = np.random.default_rng(417) + for _ in range(3): + values = generator.standard_normal(len(boundary_nodes)) + first = np.asarray(hierarchical.matvec(values)) + second = np.asarray(hierarchical.matvec(values)) + np.testing.assert_array_equal(first, second) + np.testing.assert_allclose(first, dense @ values, rtol=1.0e-6, atol=1.0e-10) diff --git a/tests/nmag/test_matrix_free_bem.py b/tests/nmag/test_matrix_free_bem.py new file mode 100644 index 0000000..77ac7b1 --- /dev/null +++ b/tests/nmag/test_matrix_free_bem.py @@ -0,0 +1,275 @@ +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pytest + +import nmag +import nmag.backends as backends +import nmesh +from nmag.backends import ( + DEMAG_BEM_AUTO_MATRIX_FREE_MIN_BOUNDARY_NODES_ENV, + DEMAG_BEM_STORAGE_BACKEND_ENV, + DEMAG_FEM_MATRIX_BACKEND_ENV, + MEMORY_MODE_ENV, + _selected_demag_bem_storage_backend, +) +from nmag.demag.bem_operator import MatrixFreeLindholmBemOperator + +FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" +SPHERE_MESH = FIXTURES / "nmag_doc_example1" / "sphere1.nmesh.h5" + + +def _two_tetra_mesh(path: Path) -> None: + 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], + [1.0, 1.0, 1.0], + ], + simplices_indices=[[0, 1, 2, 3], [1, 2, 3, 4]], + simplices_regions=[1, 1], + ) + mesh.save(str(path)) + + +def _simulation( + mesh_path: Path, + name: str, + *, + accelerator: str = "auto", + storage: str = "auto", +) -> nmag.Simulation: + material = nmag.MagMaterial( + name="Py", + Ms=nmag.SI(1.0e6, "A/m"), + exchange_coupling=nmag.SI(13.0e-12, "J/m"), + llg_damping=0.5, + do_precession=False, + ) + simulation = nmag.Simulation( + name=name, + config=nmag.NmagConfig(accelerator=accelerator, demag_bem_storage=storage), + ) + simulation.load_mesh( + str(mesh_path), + [("magnetic", material)], + unit_length=nmag.SI(1.0e-9, "m"), + ) + simulation.set_m(lambda point: [1.0, 0.2 * point[0], 0.1 * point[1]]) + return simulation + + +def test_auto_bem_storage_switches_without_rejecting( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv(DEMAG_BEM_STORAGE_BACKEND_ENV, raising=False) + monkeypatch.setenv(DEMAG_BEM_AUTO_MATRIX_FREE_MIN_BOUNDARY_NODES_ENV, "5") + monkeypatch.setattr(backends, "available_memory_bytes", lambda: None) + monkeypatch.setattr(backends, "_rust_accelerator_available", lambda: False) + + assert _selected_demag_bem_storage_backend(4) == "dense" + assert _selected_demag_bem_storage_backend(5) == "matrix-free" + + +def test_low_memory_mode_forces_matrix_free_bem(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv(DEMAG_BEM_STORAGE_BACKEND_ENV, raising=False) + monkeypatch.setenv(MEMORY_MODE_ENV, "low") + + assert _selected_demag_bem_storage_backend(1) == "matrix-free" + + +def test_auto_bem_storage_respects_available_memory( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv(DEMAG_BEM_STORAGE_BACKEND_ENV, raising=False) + monkeypatch.setenv(DEMAG_BEM_AUTO_MATRIX_FREE_MIN_BOUNDARY_NODES_ENV, "10000") + monkeypatch.setattr(backends, "available_memory_bytes", lambda: 1_000) + monkeypatch.setattr(backends, "_rust_accelerator_available", lambda: False) + + assert _selected_demag_bem_storage_backend(10) == "matrix-free" + + +def test_auto_bem_storage_keeps_dense_above_fallback_when_memory_allows( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv(DEMAG_BEM_STORAGE_BACKEND_ENV, raising=False) + monkeypatch.setenv(DEMAG_BEM_AUTO_MATRIX_FREE_MIN_BOUNDARY_NODES_ENV, "5") + monkeypatch.setattr(backends, "available_memory_bytes", lambda: 1_000_000) + + assert _selected_demag_bem_storage_backend(10) == "dense" + + +def test_auto_bem_storage_prefers_hierarchy_when_rust_is_available( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv(DEMAG_BEM_AUTO_MATRIX_FREE_MIN_BOUNDARY_NODES_ENV, "5") + monkeypatch.setattr(backends, "available_memory_bytes", lambda: None) + monkeypatch.setattr(backends, "_rust_accelerator_available", lambda: True) + + assert _selected_demag_bem_storage_backend(5) == "hierarchical" + + +def test_matrix_free_bem_action_matches_dense( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.chdir(tmp_path) + mesh_path = tmp_path / "two-tetra.nmesh.h5" + _two_tetra_mesh(mesh_path) + simulation = _simulation(mesh_path, "bem-action") + points = np.asarray(simulation.mesh.points, dtype=float) + simplices = np.asarray(simulation.mesh.simplices, dtype=int) + faces = simulation._boundary_faces_for_demag_mesh(points, simplices) + + boundary_nodes, dense = simulation._build_lindholm_bem_matrix_numba( + points, + simplices, + faces, + ) + operator_nodes, operator = simulation._build_lindholm_bem_operator( + points, + simplices, + faces, + ) + values = np.linspace(-0.75, 1.25, len(boundary_nodes)) + + assert isinstance(operator, MatrixFreeLindholmBemOperator) + np.testing.assert_array_equal(operator_nodes, boundary_nodes) + np.testing.assert_allclose(operator @ values, dense @ values, rtol=2.0e-13, atol=2.0e-15) + + +def test_rust_matrix_free_bem_matches_numba_for_random_vectors( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + pytest.importorskip("nmag_accel") + monkeypatch.chdir(tmp_path) + mesh_path = tmp_path / "two-tetra.nmesh.h5" + _two_tetra_mesh(mesh_path) + numba_simulation = _simulation(mesh_path, "bem-random-numba", accelerator="off") + rust_simulation = _simulation(mesh_path, "bem-random-rust", accelerator="rust") + points = np.asarray(numba_simulation.mesh.points, dtype=float) + simplices = np.asarray(numba_simulation.mesh.simplices, dtype=int) + faces = numba_simulation._boundary_faces_for_demag_mesh(points, simplices) + + _nodes, numba_operator = numba_simulation._build_lindholm_bem_operator( + points, + simplices, + faces, + ) + _nodes, rust_operator = rust_simulation._build_lindholm_bem_operator( + points, + simplices, + faces, + ) + + generator = np.random.default_rng(20260715) + for _ in range(8): + values = generator.standard_normal(numba_operator.shape[1]) + np.testing.assert_allclose( + rust_operator @ values, + numba_operator @ values, + rtol=3.0e-13, + atol=3.0e-15, + ) + + +def test_rust_matrix_free_bem_rejects_mismatched_values( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + rust_accel = pytest.importorskip("nmag_accel") + monkeypatch.chdir(tmp_path) + mesh_path = tmp_path / "two-tetra.nmesh.h5" + _two_tetra_mesh(mesh_path) + simulation = _simulation(mesh_path, "bem-invalid") + points = np.asarray(simulation.mesh.points, dtype=float) + simplices = np.asarray(simulation.mesh.simplices, dtype=np.int64) + faces = simulation._boundary_faces_for_demag_mesh(points, simplices) + boundary_nodes, local_index_by_point, face_nodes = simulation._lindholm_bem_boundary_index( + points, + faces, + ) + + with pytest.raises(ValueError, match="one entry per boundary node"): + rust_accel.apply_lindholm_bem_matrix_free( + points, + simplices, + face_nodes, + boundary_nodes, + local_index_by_point, + np.zeros(len(boundary_nodes) - 1), + ) + + +def test_matrix_free_bem_uses_less_storage_for_sphere( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.chdir(tmp_path) + simulation = _simulation(SPHERE_MESH, "bem-storage") + points = np.asarray(simulation.mesh.points, dtype=float) + simplices = np.asarray(simulation.mesh.simplices, dtype=int) + faces = simulation._boundary_faces_for_demag_mesh(points, simplices) + + boundary_nodes, operator = simulation._build_lindholm_bem_operator( + points, + simplices, + faces, + ) + dense_bytes = len(boundary_nodes) ** 2 * np.dtype(np.float64).itemsize + + assert operator.storage_bytes < dense_bytes + assert not hasattr(operator, "toarray") + + +def test_matrix_free_demag_matches_dense( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.chdir(tmp_path) + mesh_path = tmp_path / "two-tetra.nmesh.h5" + _two_tetra_mesh(mesh_path) + + monkeypatch.setenv(DEMAG_BEM_STORAGE_BACKEND_ENV, "dense") + dense = _simulation(mesh_path, "dense-bem", storage="dense") + dense_field = np.asarray(dense.get_subfield("H_demag")) + + monkeypatch.setenv(DEMAG_BEM_STORAGE_BACKEND_ENV, "matrix-free") + matrix_free = _simulation(mesh_path, "matrix-free-bem", storage="matrix-free") + matrix_free_field = np.asarray(matrix_free.get_subfield("H_demag")) + + np.testing.assert_allclose(matrix_free_field, dense_field, rtol=2.0e-12, atol=1.0e-7) + + +def test_low_memory_fixed_time_dynamics_match_dense( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.chdir(tmp_path) + mesh_path = tmp_path / "two-tetra.nmesh.h5" + _two_tetra_mesh(mesh_path) + target = nmag.SI(2.0e-13, "s") + + monkeypatch.setenv(DEMAG_FEM_MATRIX_BACKEND_ENV, "dense") + monkeypatch.setenv(DEMAG_BEM_STORAGE_BACKEND_ENV, "dense") + dense = _simulation(mesh_path, "dense-dynamics", storage="dense") + dense.advance_time(target) + + monkeypatch.setenv(DEMAG_FEM_MATRIX_BACKEND_ENV, "sparse") + monkeypatch.setenv(DEMAG_BEM_STORAGE_BACKEND_ENV, "matrix-free") + low_memory = _simulation(mesh_path, "low-memory-dynamics", storage="matrix-free") + low_memory.advance_time(target) + + assert low_memory._llg_affine_operator_cache is None + assert low_memory.clock.time_reached_si == dense.clock.time_reached_si + np.testing.assert_allclose( + low_memory.get_subfield("m"), + dense.get_subfield("m"), + rtol=2.0e-8, + atol=2.0e-9, + ) diff --git a/tests/nmag/test_nmag_lazy_imports.py b/tests/nmag/test_nmag_lazy_imports.py new file mode 100644 index 0000000..096cb3b --- /dev/null +++ b/tests/nmag/test_nmag_lazy_imports.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import json +import subprocess +import sys + + +def _run_module_probe(source: str) -> dict[str, object]: + completed = subprocess.run( + [sys.executable, "-c", source], + check=True, + capture_output=True, + text=True, + ) + return json.loads(completed.stdout) + + +def test_importing_nmag_does_not_import_numba(): + result = _run_module_probe( + """ +import json +import sys +import nmag + +print(json.dumps({ + "has_simulation": hasattr(nmag, "Simulation"), + "numba_loaded": "numba" in sys.modules, +})) +""" + ) + + assert result == { + "has_simulation": True, + "numba_loaded": False, + } + + +def test_first_jitted_helper_call_compiles_numba_functions_lazily(): + result = _run_module_probe( + """ +import json +import sys + +import numpy as np +import nmag.simulation as sim + +before = { + "numba_loaded": "numba" in sys.modules, + "compiled": sim._JIT_COMPILED, + "dot_type": type(sim._dot3).__name__, +} +value = sim._dot3( + np.asarray([1.0, 2.0, 3.0], dtype=float), + np.asarray([4.0, 5.0, 6.0], dtype=float), +) +after = { + "numba_loaded": "numba" in sys.modules, + "compiled": sim._JIT_COMPILED, + "dot_type": type(sim._dot3).__name__, +} +print(json.dumps({ + "before": before, + "after": after, + "value": float(value), +})) +""" + ) + + assert result["before"] == { + "numba_loaded": False, + "compiled": False, + "dot_type": "function", + } + assert result["after"] == { + "numba_loaded": True, + "compiled": True, + "dot_type": "CPUDispatcher", + } + assert result["value"] == 32.0 diff --git a/tests/nmag/test_parallel_runtime.py b/tests/nmag/test_parallel_runtime.py new file mode 100644 index 0000000..cb17a30 --- /dev/null +++ b/tests/nmag/test_parallel_runtime.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +import json +import os +import subprocess +import sys + +import pytest + +import nmag + + +def test_parallel_runtime_reports_unaccelerated_process( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr("nmag.parallel._rust_accelerator_available", lambda: False) + + info = nmag.parallel_runtime_info() + + assert info.logical_cpus >= 1 + assert info.rust_available is False + assert info.rust_worker_threads is None + assert info.rust_parallel_min_items is None + + +@pytest.mark.parametrize("thread_count", [1, 2]) +def test_rayon_thread_count_is_selected_at_process_start(thread_count: int) -> None: + pytest.importorskip("nmag_accel") + script = """ +import json +import nmag +info = nmag.parallel_runtime_info() +print(json.dumps({"threads": info.rust_worker_threads, "minimum": info.rust_parallel_min_items})) +""" + environment = os.environ.copy() + environment["RAYON_NUM_THREADS"] = str(thread_count) + + completed = subprocess.run( + [sys.executable, "-c", script], + check=True, + capture_output=True, + text=True, + env=environment, + ) + result = json.loads(completed.stdout) + + assert result == {"threads": thread_count, "minimum": 2048} + + +def test_serial_and_parallel_llg_results_are_bitwise_identical() -> None: + pytest.importorskip("nmag_accel") + script = """ +import hashlib +import numpy as np +import nmag_accel +n = 4096 +index = np.arange(n, dtype=float) +m = np.column_stack((np.ones(n), np.sin(index * 0.01), np.cos(index * 0.01))) +m /= np.linalg.norm(m, axis=1)[:, None] +h = np.column_stack((index, 2.0 * index, -0.5 * index)) +result = nmag_accel.llg_rhs(m, h, np.ones(n), np.ones(n), -2.21e5, -1.1e5, 1.0e11) +print(hashlib.sha256(np.asarray(result).tobytes()).hexdigest()) +""" + + digests = [] + for thread_count in (1, 4): + environment = os.environ.copy() + environment["RAYON_NUM_THREADS"] = str(thread_count) + completed = subprocess.run( + [sys.executable, "-c", script], + check=True, + capture_output=True, + text=True, + env=environment, + ) + digests.append(completed.stdout.strip()) + + assert digests[0] == digests[1] + + +def test_serial_and_parallel_hmatrix_matvec_are_bitwise_identical() -> None: + pytest.importorskip("nmag_accel") + script = """ +import hashlib +import numpy as np +import nmag_accel +from scipy.spatial import ConvexHull +n = 200 +i = np.arange(n, dtype=float) +z = 1.0 - 2.0 * (i + 0.5) / n +r = np.sqrt(1.0 - z * z) +a = np.pi * (3.0 - np.sqrt(5.0)) * i +surface = np.column_stack((r * np.cos(a), r * np.sin(a), z)) +points = np.vstack((surface, np.zeros((1, 3)))) +faces = np.asarray(ConvexHull(surface).simplices, dtype=np.int64) +simplices = np.column_stack((faces, np.full(len(faces), n, dtype=np.int64))) +faces = np.asarray(nmag_accel.build_oriented_boundary_faces(points, simplices)[1], dtype=np.int64) +boundary_nodes = np.unique(faces) +local_indices = np.full(len(points), -1, dtype=np.int64) +local_indices[boundary_nodes] = np.arange(len(boundary_nodes), dtype=np.int64) +operator = nmag_accel.build_lindholm_hmatrix( + points, simplices, faces, boundary_nodes, local_indices, memory_budget_bytes=64 * 1024 * 1024 +) +result = operator.matvec(np.random.default_rng(17).standard_normal(n)) +print(hashlib.sha256(np.asarray(result).tobytes()).hexdigest()) +""" + + digests = [] + for thread_count in (1, 4): + environment = os.environ.copy() + environment["RAYON_NUM_THREADS"] = str(thread_count) + completed = subprocess.run( + [sys.executable, "-c", script], + check=True, + capture_output=True, + text=True, + env=environment, + ) + digests.append(completed.stdout.strip()) + + assert digests[0] == digests[1] diff --git a/tests/nmag/test_pinning.py b/tests/nmag/test_pinning.py new file mode 100644 index 0000000..3bc9cb6 --- /dev/null +++ b/tests/nmag/test_pinning.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pytest + +import nmag +import nmesh + + +def _simulation( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + *, + name: str, +) -> nmag.Simulation: + monkeypatch.chdir(tmp_path) + mesh_path = tmp_path / f"{name}.nmesh.h5" + 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.save(str(mesh_path)) + material = nmag.MagMaterial( + name="Py", + Ms=nmag.SI(1.0e6, "A/m"), + exchange_coupling=nmag.SI(0.0, "J/m"), + llg_damping=0.5, + do_precession=False, + ) + simulation = nmag.Simulation(name=name, do_demag=False) + simulation.load_mesh( + str(mesh_path), + [("magnetic", material)], + unit_length=nmag.SI(1.0e-9, "m"), + ) + simulation.set_m([0.0, 1.0, 0.0]) + simulation.set_H_ext([1.0e5, 0.0, 0.0], nmag.SI("A/m")) + return simulation + + +def test_default_pinning_leaves_every_node_free( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + simulation = _simulation(tmp_path, monkeypatch, name="pin-default") + + np.testing.assert_array_equal(simulation.get_subfield("pin"), np.ones(4)) + assert simulation.get_subfield_average("pin") == 1.0 + + +def test_set_pinning_accepts_scalar_array_and_callable( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + simulation = _simulation(tmp_path, monkeypatch, name="pin-inputs") + + simulation.set_pinning(0.5) + np.testing.assert_array_equal(simulation.get_subfield("pin"), np.full(4, 0.5)) + assert simulation.get_subfield_average("pin") == 0.5 + + simulation.set_pinning([0.0, 1.0, 0.25, 0.75]) + np.testing.assert_array_equal( + simulation.get_subfield("pin"), + [0.0, 1.0, 0.25, 0.75], + ) + + simulation.set_pinning(lambda point: 0.0 if point[0] == 0.0 else nmag.SI(1)) + np.testing.assert_array_equal(simulation.get_subfield("pin"), [0.0, 1.0, 0.0, 0.0]) + + +@pytest.mark.parametrize( + "values", + ([0.0, 1.0], [0.0, 1.0, 1.0, float("nan")]), +) +def test_set_pinning_rejects_invalid_values( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + values: list[float], +) -> None: + simulation = _simulation(tmp_path, monkeypatch, name="pin-invalid") + + with pytest.raises(ValueError, match="pinning"): + simulation.set_pinning(values) + + +def test_set_pinning_requires_a_mesh(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + simulation = nmag.Simulation(name="pin-no-mesh", do_demag=False) + + with pytest.raises(RuntimeError, match="mesh must be loaded"): + simulation.set_pinning(0.0) + + +def test_pinned_nodes_remain_fixed_while_free_nodes_evolve( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + simulation = _simulation(tmp_path, monkeypatch, name="pin-dynamics") + simulation.set_pinning(lambda point: 1.0 if point[0] > 0.0 else 0.0) + initial = np.asarray(simulation.get_subfield("m"), dtype=float) + + simulation.advance_time(nmag.SI(2.0e-12, "s")) + + final = np.asarray(simulation.get_subfield("m"), dtype=float) + np.testing.assert_array_equal(final[[0, 2, 3]], initial[[0, 2, 3]]) + assert final[1, 0] > initial[1, 0] + assert final[1, 1] < initial[1, 1] + + +def test_set_pinning_invalidates_an_initialised_integrator( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + simulation = _simulation(tmp_path, monkeypatch, name="pin-invalidate") + simulation.reinitialise() + assert simulation._integrator_is_stale is False + + simulation.set_pinning(0.0) + + assert simulation._integrator_is_stale is True diff --git a/tests/nmag/test_public_averages.py b/tests/nmag/test_public_averages.py new file mode 100644 index 0000000..eb7f42a --- /dev/null +++ b/tests/nmag/test_public_averages.py @@ -0,0 +1,290 @@ +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import public_api_support_module as helpers +import pytest + +import nmag +import nmag.simulation as nmag_sim +from nmag.simulation import ( + DEMAG_DENSE_MAX_POINTS_ENV, +) + +FIXTURES_DIR = Path(__file__).resolve().parents[1] / "fixtures" +SPHERE1_MESH = FIXTURES_DIR / "nmag_doc_example1" / "sphere1.nmesh.h5" + + + +def test_dense_demag_refuses_mesh_above_configured_limit(monkeypatch): + monkeypatch.setenv(DEMAG_DENSE_MAX_POINTS_ENV, "4") + sim = nmag.Simulation(name="mvp") + points = np.asarray( + [ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + [1.0, 1.0, 1.0], + ], + dtype=float, + ) + simplices = np.asarray([[0, 1, 2, 3]], dtype=int) + + with pytest.raises(MemoryError, match="safety limit is 4"): + sim._demag_fem_geometry_for_mesh_python(points, simplices) + + +@pytest.mark.parametrize("value", ["0", "bad"]) +def test_dense_demag_rejects_invalid_configured_limit(monkeypatch, value): + monkeypatch.setenv(DEMAG_DENSE_MAX_POINTS_ENV, value) + sim = nmag.Simulation(name="mvp") + + with pytest.raises(ValueError, match=DEMAG_DENSE_MAX_POINTS_ENV): + sim._demag_fem_geometry_for_mesh_python( + np.empty((0, 3), dtype=float), + np.empty((0, 4), dtype=int), + ) + + +def test_subfield_average_uses_cell_volume_weighting(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + mesh_path = tmp_path / "mesh.nmesh" + helpers.write_two_tetra_mesh(mesh_path) + + material = nmag.MagMaterial( + name="Py", + Ms=nmag.SI(1.0e6, "A/m"), + exchange_coupling=nmag.SI(13.0e-12, "J/m"), + ) + sim = nmag.Simulation(name="mvp") + sim.load_mesh( + str(mesh_path), + [("generated", material)], + unit_length=nmag.SI(1e-9, "m"), + ) + sim.set_m( + lambda point: ( + [1.0, 0.0, 0.0] if point[0] <= 5.0e-10 else [0.0, 1.0, 0.0] + ), + ) + + points = np.asarray(sim.mesh.points, dtype=float) + simplices = np.asarray(sim.mesh.simplices, dtype=int) + volumes = np.abs( + np.linalg.det(points[simplices[:, 1:]] - points[simplices[:, [0]]]) + ) / 6.0 + m = np.asarray(sim.get_subfield("m"), dtype=float) + expected_average = np.sum( + np.mean(m[simplices], axis=1) * volumes[:, np.newaxis], + axis=0, + ) / np.sum(volumes) + + np.testing.assert_allclose(sim.get_subfield_average("m"), expected_average) + assert not np.allclose(expected_average, np.mean(m, axis=0)) + + +def test_h_demag_direct_average_matches_nodal_average_reference(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + mesh_path = tmp_path / "mesh.nmesh" + helpers.write_two_tetra_mesh(mesh_path) + + material = nmag.MagMaterial( + name="Py", + Ms=nmag.SI(1.0e6, "A/m"), + exchange_coupling=nmag.SI(13.0e-12, "J/m"), + ) + sim = nmag.Simulation(name="mvp") + sim.load_mesh(str(mesh_path), [("generated", material)], unit_length=nmag.SI(1e-9, "m")) + sim.set_m(lambda point: [1.0, 0.0, 0.0] if point[0] <= 5.0e-10 else [0.0, 1.0, 0.0]) + + direct_average = sim._demag_cell_field_average() + nodal_reference = sim._field_average(sim._get_demag_nodal_field()) + + np.testing.assert_allclose( + direct_average, + nodal_reference, + rtol=1.0e-12, + atol=1.0e-9, + ) + + sim._invalidate_demag() + np.testing.assert_allclose( + sim.get_subfield_average("H_demag"), + nodal_reference, + rtol=1.0e-12, + atol=1.0e-9, + ) + assert sim._demag_cell_field_cache is not None + assert sim._demag_nodal_cache is None + assert sim.last_subfield_average_timings_seconds["direct_cell_average"] >= 0.0 + assert ( + sim.last_subfield_average_timings_seconds[ + "subfield_array:H_demag_detail:cell_average" + ] + >= 0.0 + ) + assert ( + sim.last_subfield_average_timings_seconds[ + "subfield_array:H_demag_detail:cell_average:python" + ] + >= 0.0 + ) + + +def test_subfield_averages_reuse_simplex_volume_weights(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + mesh_path = tmp_path / "mesh.nmesh" + helpers.write_two_tetra_mesh(mesh_path) + + material = nmag.MagMaterial( + name="Py", + Ms=nmag.SI(1.0e6, "A/m"), + exchange_coupling=nmag.SI(13.0e-12, "J/m"), + ) + sim = nmag.Simulation(name="mvp") + sim.load_mesh(str(mesh_path), [("generated", material)], unit_length=nmag.SI(1e-9, "m")) + sim.set_m(lambda point: [1.0, 0.0, 0.0] if point[0] <= 5.0e-10 else [0.0, 1.0, 0.0]) + + volume_calls = [] + original_simplex_volumes = nmag_sim._simplex_volumes + + def counting_simplex_volumes(points, simplices): + volume_calls.append(len(simplices)) + return original_simplex_volumes(points, simplices) + + monkeypatch.setattr(nmag_sim, "_simplex_volumes", counting_simplex_volumes) + + sim.get_subfield_average("m") + assert sim._volume_average_node_weights_cache is not None + first_weight_cache = sim._volume_average_node_weights_cache + sim._scalar_field_average(np.arange(len(sim.mesh.points), dtype=float)) + + assert volume_calls == [2] + assert sim._volume_average_node_weights_cache is first_weight_cache + + sim._invalidate_demag(clear_geometry=True) + assert sim._volume_average_node_weights_cache is None + sim.get_subfield_average("m") + + assert volume_calls == [2, 2] + + +def test_nodal_ms_values_reuse_mesh_geometry_cache(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + mesh_path = tmp_path / "mesh.nmesh" + helpers.write_two_tetra_mesh(mesh_path) + + material = nmag.MagMaterial( + name="Py", + Ms=nmag.SI(1.0e6, "A/m"), + exchange_coupling=nmag.SI(13.0e-12, "J/m"), + ) + sim = nmag.Simulation(name="mvp") + sim.load_mesh(str(mesh_path), [("generated", material)], unit_length=nmag.SI(1e-9, "m")) + sim.set_m([1.0, 0.0, 0.0]) + + first_values = sim._nodal_ms_values() + assert sim._nodal_ms_values_cache is not None + + def fail_simplex_material_values(_regions): + raise AssertionError("cached nodal Ms values should not recompute simplex Ms") + + monkeypatch.setattr(sim, "_simplex_material_ms_values", fail_simplex_material_values) + + assert sim._nodal_ms_values() is first_values + + sim._invalidate_demag(clear_geometry=True) + assert sim._nodal_ms_values_cache is None + + +def test_uniform_subfield_averages_skip_volume_weights(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + mesh_path = tmp_path / "mesh.nmesh" + helpers.write_two_tetra_mesh(mesh_path) + + material = nmag.MagMaterial( + name="Py", + Ms=nmag.SI(1.0e6, "A/m"), + exchange_coupling=nmag.SI(13.0e-12, "J/m"), + ) + sim = nmag.Simulation(name="mvp") + sim.load_mesh(str(mesh_path), [("generated", material)], unit_length=nmag.SI(1e-9, "m")) + sim.set_m([1.0, 0.0, 0.0]) + + def fail_volume_weights(_points, _simplices): + raise AssertionError("uniform averages should not need volume weights") + + monkeypatch.setattr(sim, "_simplex_volume_weights", fail_volume_weights) + + np.testing.assert_allclose(sim.get_subfield_average("m"), [1.0, 0.0, 0.0]) + assert sim.get_subfield_average("pin") == 1.0 + + +def test_constant_subfield_averages_skip_array_materialization(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + mesh_path = tmp_path / "mesh.nmesh" + helpers.write_two_tetra_mesh(mesh_path) + + material = nmag.MagMaterial( + name="Py", + Ms=nmag.SI(1.0e6, "A/m"), + exchange_coupling=nmag.SI(13.0e-12, "J/m"), + ) + sim = nmag.Simulation(name="mvp") + sim.load_mesh(str(mesh_path), [("generated", material)], unit_length=nmag.SI(1e-9, "m")) + sim.set_m([1.0, 0.0, 0.0]) + sim.set_H_ext([1.0, 2.0, 3.0], nmag.SI("A/m")) + + def fail_array_materialization(subfieldname): + raise AssertionError(f"{subfieldname} should use a direct constant average") + + monkeypatch.setattr(sim, "_compute_subfield_array", fail_array_materialization) + + assert sim.get_subfield_average("H_ext") == [1.0, 2.0, 3.0] + assert sim.last_subfield_average_timings_seconds["constant_average"] >= 0.0 + assert sim.get_subfield_average("pin") == 1.0 + assert sim.get_subfield_average("H_anis") == [0.0, 0.0, 0.0] + assert sim.get_subfield_average("E_anis") == 0.0 + assert sim.get_subfield_average("H_exch") == [0.0, 0.0, 0.0] + assert sim.get_subfield_average("E_exch") == 0.0 + + +def test_h_total_average_uses_component_averages(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + mesh_path = tmp_path / "mesh.nmesh" + helpers.write_two_tetra_mesh(mesh_path) + + material = nmag.MagMaterial( + name="Py", + Ms=nmag.SI(1.0e6, "A/m"), + exchange_coupling=nmag.SI(13.0e-12, "J/m"), + ) + sim = nmag.Simulation(name="mvp") + sim.load_mesh(str(mesh_path), [("generated", material)], unit_length=nmag.SI(1e-9, "m")) + sim.set_m(lambda point: [1.0, 0.0, 0.0] if point[0] <= 5.0e-10 else [0.0, 1.0, 0.0]) + sim.set_H_ext([1.0, 2.0, 3.0], nmag.SI("A/m")) + + expected = sim._field_average(sim._derived_subfield_array("H_total")) + + original_compute = sim._compute_subfield_array + + def fail_h_total_materialization(subfieldname): + if subfieldname == "H_total": + raise AssertionError("H_total average should compose component averages") + return original_compute(subfieldname) + + monkeypatch.setattr(sim, "_compute_subfield_array", fail_h_total_materialization) + + np.testing.assert_allclose(sim.get_subfield_average("H_total"), expected) + timings = sim.last_subfield_average_timings_seconds + assert timings["composed_average"] >= 0.0 + assert timings["component_average:H_ext:constant"] >= 0.0 + assert timings["component_average:H_demag:direct_cell_average"] >= 0.0 + assert timings["component_average:H_anis:constant"] >= 0.0 + assert timings["component_average:H_exch:array"] >= 0.0 + assert timings["component_average:H_exch:field_average"] >= 0.0 + assert "subfield_array:H_total:compute" not in timings + + diff --git a/tests/nmag/test_public_bem.py b/tests/nmag/test_public_bem.py new file mode 100644 index 0000000..2c17187 --- /dev/null +++ b/tests/nmag/test_public_bem.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import numpy as np +import public_api_support_module as helpers +import pytest + +import nmag +import nmag.backends as nmag_backends +from nmag.backends import _selected_lindholm_bem_backend + + +def _simulation(tmp_path, *, accelerator: str = "off") -> nmag.Simulation: + mesh_path = tmp_path / "mesh.nmesh" + helpers.write_two_tetra_mesh(mesh_path) + material = nmag.MagMaterial( + name="Py", + Ms=nmag.SI(1.0e6, "A/m"), + exchange_coupling=nmag.SI(13.0e-12, "J/m"), + ) + simulation = nmag.Simulation( + name=f"bem-{accelerator}", + config=nmag.NmagConfig(accelerator=accelerator), + ) + simulation.load_mesh( + str(mesh_path), [("generated", material)], unit_length=nmag.SI(1e-9, "m") + ) + return simulation + + +def test_lindholm_bem_matrix_matches_legacy_scalar_formula(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + sim = _simulation(tmp_path) + points = np.asarray(sim.mesh.points, dtype=float) + simplices = np.asarray(sim.mesh.simplices, dtype=int) + boundary_faces = sim._boundary_faces_for_demag_mesh(points, simplices) + fast_nodes, fast_bem = sim._build_lindholm_bem_matrix_numba(points, simplices, boundary_faces) + reference_nodes, reference_bem = sim._build_lindholm_bem_matrix_python( + points, simplices, boundary_faces + ) + + np.testing.assert_array_equal(fast_nodes, reference_nodes) + np.testing.assert_allclose(fast_bem, reference_bem, rtol=1e-12, atol=1e-14) + + +def test_lindholm_bem_policy_auto_and_off(monkeypatch): + monkeypatch.setattr(nmag_backends, "_rust_accelerator_available", lambda: True) + assert _selected_lindholm_bem_backend(nmag.NmagConfig()) == "rust" + assert _selected_lindholm_bem_backend(nmag.NmagConfig(accelerator="off")) == "numba" + + +def test_lindholm_bem_rust_matches_python(tmp_path, monkeypatch): + pytest.importorskip("nmag_accel") + monkeypatch.chdir(tmp_path) + sim = _simulation(tmp_path, accelerator="rust") + points = np.asarray(sim.mesh.points, dtype=float) + simplices = np.asarray(sim.mesh.simplices, dtype=int) + boundary_faces = sim._boundary_faces_for_demag_mesh(points, simplices) + rust_nodes, rust_bem = sim._build_lindholm_bem_matrix(points, simplices, boundary_faces) + python_nodes, python_bem = sim._build_lindholm_bem_matrix_python( + points, simplices, boundary_faces + ) + + np.testing.assert_array_equal(rust_nodes, python_nodes) + np.testing.assert_allclose(rust_bem, python_bem, rtol=1e-12, atol=1e-14) diff --git a/tests/nmag/test_public_caches.py b/tests/nmag/test_public_caches.py new file mode 100644 index 0000000..67873f7 --- /dev/null +++ b/tests/nmag/test_public_caches.py @@ -0,0 +1,214 @@ +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import public_api_support_module as helpers + +import nmag + +FIXTURES_DIR = Path(__file__).resolve().parents[1] / "fixtures" +SPHERE1_MESH = FIXTURES_DIR / "nmag_doc_example1" / "sphere1.nmesh.h5" + + + +def test_save_data_reuses_h_total_component_average_cache(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + mesh_path = tmp_path / "mesh.nmesh" + helpers.write_two_tetra_mesh(mesh_path) + + material = nmag.MagMaterial( + name="Py", + Ms=nmag.SI(1.0e6, "A/m"), + exchange_coupling=nmag.SI(13.0e-12, "J/m"), + ) + sim = nmag.Simulation(name="mvp") + sim.load_mesh(str(mesh_path), [("generated", material)], unit_length=nmag.SI(1e-9, "m")) + sim.set_m(lambda point: [1.0, 0.0, 0.0] if point[0] <= 5.0e-10 else [0.0, 1.0, 0.0]) + sim.set_H_ext([1.0, 2.0, 3.0], nmag.SI("A/m")) + + sim.save_data() + + timings = sim.last_save_timings_seconds + assert ( + timings["average_detail:H_total_Py:component_average:H_demag:direct_cell_average"] + >= 0.0 + ) + assert timings["average_detail:H_demag:cache_hit"] >= 0.0 + assert "average_detail:H_demag:direct_cell_average" not in timings + + np.testing.assert_allclose( + sim.get_subfield_average("H_demag"), + sim._demag_cell_field_average(), + ) + assert sim.last_subfield_average_timings_seconds["direct_cell_average"] >= 0.0 + assert ( + sim.last_subfield_average_timings_seconds[ + "subfield_array:H_demag_detail:cell_average" + ] + >= 0.0 + ) + assert ( + sim.last_subfield_average_timings_seconds[ + "subfield_array:H_demag_detail:cell_average:python" + ] + >= 0.0 + ) + assert "cache_hit" not in sim.last_subfield_average_timings_seconds + + +def test_save_data_records_demag_cell_cache_hit_on_unchanged_state( + tmp_path, + monkeypatch, +): + monkeypatch.chdir(tmp_path) + mesh_path = tmp_path / "mesh.nmesh" + helpers.write_two_tetra_mesh(mesh_path) + + material = nmag.MagMaterial( + name="Py", + Ms=nmag.SI(1.0e6, "A/m"), + exchange_coupling=nmag.SI(13.0e-12, "J/m"), + ) + sim = nmag.Simulation(name="mvp") + sim.load_mesh( + str(mesh_path), + [("generated", material)], + unit_length=nmag.SI(1e-9, "m"), + ) + sim.set_m( + lambda point: [1.0, 0.0, 0.0] + if point[0] <= 5.0e-10 + else [0.0, 1.0, 0.0] + ) + sim.set_H_ext([1.0, 2.0, 3.0], nmag.SI("A/m")) + + sim.save_data() + first_save_timings = dict(sim.last_save_timings_seconds) + assert ( + "average_detail:H_total_Py:subfield_array:" + "demag_auxiliary:fem_bem_total" + ) in first_save_timings + assert ( + "average_detail:H_total_Py:subfield_array:" + "H_demag_detail:cell_field_cache_hit" + ) not in first_save_timings + + sim.save_data() + second_save_timings = sim.last_save_timings_seconds + + assert ( + "average_detail:H_total_Py:subfield_array:" + "H_demag_detail:cell_field_cache_hit" + ) in second_save_timings + assert ( + "average_detail:H_total_Py:subfield_array:" + "demag_auxiliary:fem_bem_total" + ) not in second_save_timings + assert ( + "average_detail:H_total_Py:component_average:H_demag:" + "direct_cell_average" + ) in second_save_timings + + assert sim.get_subfield_average("rho") is not None + assert ( + "subfield_array:demag_auxiliary:cache_hit" + in sim.last_subfield_average_timings_seconds + ) + + +def test_save_data_records_bem_matrix_cache_hit_after_magnetisation_change( + tmp_path, + monkeypatch, +): + monkeypatch.chdir(tmp_path) + mesh_path = tmp_path / "mesh.nmesh" + helpers.write_two_tetra_mesh(mesh_path) + + material = nmag.MagMaterial( + name="Py", + Ms=nmag.SI(1.0e6, "A/m"), + exchange_coupling=nmag.SI(13.0e-12, "J/m"), + ) + sim = nmag.Simulation(name="mvp") + sim.load_mesh( + str(mesh_path), + [("generated", material)], + unit_length=nmag.SI(1e-9, "m"), + ) + sim.set_m( + lambda point: [1.0, 0.0, 0.0] + if point[0] <= 5.0e-10 + else [0.0, 1.0, 0.0] + ) + sim.set_H_ext([1.0, 2.0, 3.0], nmag.SI("A/m")) + + sim.save_data() + first_save_timings = dict(sim.last_save_timings_seconds) + assert ( + "average_detail:H_total_Py:subfield_array:" + "demag_auxiliary:bem_matrix_cache_hit" + ) not in first_save_timings + assert any( + key.startswith( + "average_detail:H_total_Py:subfield_array:" + "demag_auxiliary:lindholm_bem:" + ) + for key in first_save_timings + ) + + sim.set_m( + lambda point: [0.0, 1.0, 0.0] + if point[1] <= 5.0e-10 + else [1.0, 0.0, 0.0] + ) + sim.save_data() + second_save_timings = sim.last_save_timings_seconds + + assert ( + "average_detail:H_total_Py:subfield_array:" + "demag_auxiliary:fem_bem_total" + ) in second_save_timings + assert ( + "average_detail:H_total_Py:subfield_array:" + "demag_auxiliary:bem_matrix_cache_hit" + ) in second_save_timings + assert not any( + key.startswith( + "average_detail:H_total_Py:subfield_array:" + "demag_auxiliary:lindholm_bem:" + ) + for key in second_save_timings + ) + + +def test_mesh_points_and_bounds_reuse_geometry_cache(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + mesh_path = tmp_path / "mesh.nmesh" + helpers.write_two_tetra_mesh(mesh_path) + + material = nmag.MagMaterial( + name="Py", + Ms=nmag.SI(1.0e6, "A/m"), + exchange_coupling=nmag.SI(13.0e-12, "J/m"), + ) + sim = nmag.Simulation(name="mvp") + sim.load_mesh(str(mesh_path), [("generated", material)], unit_length=nmag.SI(1e-9, "m")) + + first_points = sim._mesh_points() + second_points = sim._mesh_points() + assert second_points is first_points + + first_bounds = sim._mesh_bounds() + second_bounds = sim._mesh_bounds() + assert second_bounds[0] is first_bounds[0] + assert second_bounds[1] is first_bounds[1] + assert second_bounds[2] is first_bounds[2] + + sim._invalidate_demag(clear_geometry=True) + assert sim._mesh_points_cache is None + assert sim._mesh_bounds_cache is None + + refreshed_points = sim._mesh_points() + assert sim._mesh_points_cache is not None + assert refreshed_points is sim._mesh_points() diff --git a/tests/nmag/test_public_demag_probes.py b/tests/nmag/test_public_demag_probes.py new file mode 100644 index 0000000..0ea8988 --- /dev/null +++ b/tests/nmag/test_public_demag_probes.py @@ -0,0 +1,287 @@ +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import public_api_support_module as helpers + +import nmag +import nmag.simulation as nmag_sim +import nmesh +from nmag.simulation import ( + _oriented_boundary_faces, +) + +FIXTURES_DIR = Path(__file__).resolve().parents[1] / "fixtures" +SPHERE1_MESH = FIXTURES_DIR / "nmag_doc_example1" / "sphere1.nmesh.h5" + + + +def test_oriented_boundary_faces_matches_scalar_reference(): + 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], + [1.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + [1.0, 0.0, 1.0], + [0.0, 1.0, 1.0], + [1.0, 1.0, 1.0], + ], + simplices_indices=[ + [0, 1, 3, 7], + [0, 3, 2, 7], + [0, 2, 6, 7], + [0, 6, 4, 7], + [0, 4, 5, 7], + [0, 5, 1, 7], + ], + simplices_regions=[1] * 6, + ) + points = np.asarray(mesh.points, dtype=float) + simplices = np.asarray(mesh.simplices, dtype=int) + + assert _oriented_boundary_faces(points, simplices) == helpers.reference_oriented_boundary_faces( + points, + simplices, + ) + + +def test_demag_probe_uses_point_dipole_far_field(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + mesh_path = tmp_path / "mesh.nmesh" + helpers.write_single_region_mesh(mesh_path) + + material = nmag.MagMaterial( + name="Py", + Ms=nmag.SI(6.0, "A/m"), + exchange_coupling=nmag.SI(13.0e-12, "J/m"), + ) + sim = nmag.Simulation(name="mvp") + sim.load_mesh(str(mesh_path), [("generated", material)], unit_length=nmag.SI(1, "m")) + sim.set_m([1.0, 0.0, 0.0]) + + assert sim.probe_subfield_siv("H_demag", [1000.25, 0.25, 0.25]) is None + + probe = np.asarray(sim._probe_demag_at([1000.25, 0.25, 0.25])) + expected = np.asarray([1.0 / (2.0 * np.pi * 1000.0**3), 0.0, 0.0]) + + np.testing.assert_allclose(probe, expected, rtol=1e-5, atol=1e-12) + + +def test_demag_spatial_field_moves_toward_legacy_single_tetra_fixture(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + mesh_path = tmp_path / "mesh.nmesh" + helpers.write_single_region_mesh(mesh_path) + + material = nmag.MagMaterial( + name="Py", + Ms=nmag.SI(1.0e6, "A/m"), + exchange_coupling=nmag.SI(13.0e-12, "J/m"), + ) + sim = nmag.Simulation(name="mvp") + sim.load_mesh(str(mesh_path), [("generated", material)], unit_length=nmag.SI(1e-9, "m")) + sim.set_m([1.0, 0.0, 0.0]) + + h_demag = np.asarray(sim.get_subfield("H_demag")) + expected_legacy = np.asarray([ + -68710.02865918343, + -33347.84566311808, + -33347.84566311808, + ]) + + assert h_demag.shape == (4, 3) + np.testing.assert_allclose(h_demag, np.tile(h_demag[0], (4, 1)), rtol=1e-12, atol=1e-9) + np.testing.assert_allclose(h_demag[0], expected_legacy, rtol=1e-8, atol=1e-5) + + +def test_demag_saved_rho_uses_legacy_simulation_volume_normalization(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + mesh_path = tmp_path / "mesh.nmesh" + helpers.write_single_region_mesh(mesh_path) + + material = nmag.MagMaterial( + name="Py", + Ms=nmag.SI(1.0e6, "A/m"), + exchange_coupling=nmag.SI(13.0e-12, "J/m"), + ) + sim = nmag.Simulation(name="mvp") + sim.load_mesh( + str(mesh_path), + [("generated", material)], + unit_length=nmag.SI(10e-9, "m"), + ) + sim.set_m([1.0, 0.0, 0.0]) + + _phi, rho, _volumes = sim._get_demag_auxiliary_fields() + points = sim._mesh_points() + simplices = np.asarray(sim.mesh.simplices, dtype=int) + ms_values = sim._simplex_material_ms_values(sim.mesh.regions) + _stiffness, weak_divergence, _volumes = sim._assemble_demag_fem_system( + points, + simplices, + np.asarray(sim._fields["m"], dtype=float), + ms_values, + np.ones(len(simplices), dtype=float), + ) + + np.testing.assert_allclose(rho, weak_divergence / 1.0e-27) + + +def test_demag_probe_preserves_legacy_null_first_vertex_behavior(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + mesh_path = tmp_path / "mesh.nmesh" + helpers.write_single_region_mesh(mesh_path) + + material = nmag.MagMaterial( + name="Py", + Ms=nmag.SI(1.0e6, "A/m"), + exchange_coupling=nmag.SI(13.0e-12, "J/m"), + ) + sim = nmag.Simulation(name="mvp") + sim.load_mesh(str(mesh_path), [("generated", material)], unit_length=nmag.SI(1e-9, "m")) + sim.set_m([1.0, 0.0, 0.0]) + + assert sim.probe_subfield_siv("H_demag", [0.0, 0.0, 0.0]) is None + assert sim._demag_boundary_faces_cache is not None + + def fail_boundary_face_rebuild(_points, _simplices): + raise AssertionError("exact vertex probe should reuse cached boundary faces") + + monkeypatch.setattr(nmag_sim, "_oriented_boundary_faces", fail_boundary_face_rebuild) + + assert sim.probe_subfield_siv("H_demag", [0.0, 0.0, 0.0]) is None + assert sim.probe_subfield_siv("H_demag", [1.0e-12, 1.0e-12, 1.0e-12]) is not None + assert sim.probe_subfield_siv("H_demag", [1.0e-9, 0.0, 0.0]) is not None + + +def test_doc_sphere1_demag_at_origin_matches_analytic_sphere(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + material = nmag.MagMaterial( + name="Py", + Ms=nmag.SI(1.0e6, "A/m"), + exchange_coupling=nmag.SI(13.0e-12, "J/m"), + ) + sim = nmag.Simulation(name="sphere1") + sim.load_mesh( + str(SPHERE1_MESH), + [("sphere", material)], + unit_length=nmag.SI(1e-9, "m"), + ) + sim.set_m([1.0, 0.0, 0.0]) + sim.set_H_ext([0.0, 0.0, 0.0], nmag.SI("A/m")) + sim.save_data(fields="all") + + h_demag = sim.probe_subfield_siv("H_demag", [0.0, 0.0, 0.0]) + assert h_demag is not None + assert abs(h_demag[0] / (-material.Ms.value / 3.0) - 1.0) < 0.01 + np.testing.assert_allclose( + sim.get_subfield_average("m"), + [1.0, 0.0, 0.0], + rtol=0.0, + atol=1.0e-14, + ) + + +def test_demag_probe_interpolates_inside_tetrahedron(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + mesh_path = tmp_path / "mesh.nmesh" + helpers.write_two_tetra_mesh(mesh_path) + + material = nmag.MagMaterial( + name="Py", + Ms=nmag.SI(1.0e6, "A/m"), + exchange_coupling=nmag.SI(13.0e-12, "J/m"), + ) + sim = nmag.Simulation(name="mvp") + sim.load_mesh(str(mesh_path), [("generated", material)], unit_length=nmag.SI(1e-9, "m")) + sim.set_m([1.0, 0.0, 0.0]) + + expected_legacy = np.asarray([ + -105103.52119991671, + -11248.119506505756, + -11248.119506505815, + ]) + + probe = np.asarray(sim.probe_subfield_siv("H_demag", [0.5e-9, 0.0, 0.0])) + np.testing.assert_allclose(probe, expected_legacy, rtol=1e-8, atol=5e-3) + + +def test_demag_probe_reuses_tetrahedral_interpolation_geometry(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + mesh_path = tmp_path / "mesh.nmesh" + helpers.write_two_tetra_mesh(mesh_path) + + material = nmag.MagMaterial( + name="Py", + Ms=nmag.SI(1.0e6, "A/m"), + exchange_coupling=nmag.SI(13.0e-12, "J/m"), + ) + sim = nmag.Simulation(name="mvp", config=nmag.NmagConfig(accelerator="off")) + sim.load_mesh(str(mesh_path), [("generated", material)], unit_length=nmag.SI(1e-9, "m")) + sim.set_m([1.0, 0.0, 0.0]) + sim.get_subfield("H_demag") + sim.reset_probe_timings() + + inversions = [] + original_inv = nmag_sim.np.linalg.inv + + def counting_inv(matrix): + inversions.append(matrix.copy()) + return original_inv(matrix) + + monkeypatch.setattr(nmag_sim.np.linalg, "inv", counting_inv) + + first_probe = sim.probe_subfield_siv("H_demag", [0.5e-9, 0.0, 0.0]) + second_probe = sim.probe_subfield_siv("H_demag", [0.25e-9, 0.0, 0.0]) + + assert first_probe is not None + assert second_probe is not None + assert len(inversions) == 2 + assert sim._probe_tetrahedral_cache is not None + assert sim.last_probe_timings_seconds["total"] >= 0.0 + assert sim.last_probe_timings_seconds["bounds_check"] >= 0.0 + assert sim.last_probe_timings_seconds["demag_nodal_field"] >= 0.0 + assert sim.last_probe_timings_seconds["tetrahedral_interpolation"] >= 0.0 + assert sim.last_probe_timings_seconds["tetrahedral_geometry"] >= 0.0 + assert sim.last_probe_timings_seconds["tetrahedral_geometry_build"] >= 0.0 + assert sim.last_probe_timings_seconds["tetrahedral_geometry_cache_hit"] >= 0.0 + assert sim.last_probe_timings_seconds["tetrahedral_candidate_search"] >= 0.0 + assert sim.last_probe_timings_seconds["tetrahedral_barycentric"] >= 0.0 + assert sim.last_probe_timings_seconds["tetrahedral_value_interpolation"] >= 0.0 + sim.reset_probe_timings() + assert sim.last_probe_timings_seconds == {} + + +def test_demag_probe_cached_path_matches_scalar_reference(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + mesh_path = tmp_path / "mesh.nmesh" + helpers.write_two_tetra_mesh(mesh_path) + + material = nmag.MagMaterial( + name="Py", + Ms=nmag.SI(1.0e6, "A/m"), + exchange_coupling=nmag.SI(13.0e-12, "J/m"), + ) + sim = nmag.Simulation(name="mvp") + sim.load_mesh(str(mesh_path), [("generated", material)], unit_length=nmag.SI(1e-9, "m")) + sim.set_m([1.0, 0.0, 0.0]) + nodal_values = np.asarray(sim.get_subfield("H_demag")) + + probes = [ + [0.25e-9, 0.25e-9, 0.25e-9], + [0.5e-9, 0.0, 0.0], + [0.75e-9, 0.1e-9, 0.1e-9], + [1.0e-9, 0.0, 0.0], + [1.1e-9, 0.0, 0.0], + ] + + for probe in probes: + probe_array = np.asarray(probe, dtype=float) + optimized = sim._probe_tetrahedral_field(probe_array, nodal_values) + reference = sim._probe_tetrahedral_field_reference(probe_array, nodal_values) + if reference is None: + assert optimized is None + else: + np.testing.assert_allclose(optimized, reference, rtol=0.0, atol=1.0e-9) diff --git a/tests/nmag/test_public_fem.py b/tests/nmag/test_public_fem.py new file mode 100644 index 0000000..a58cdfa --- /dev/null +++ b/tests/nmag/test_public_fem.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import public_api_support_module as helpers +import pytest + +import nmag +import nmesh + +FIXTURES_DIR = Path(__file__).resolve().parents[1] / "fixtures" +SPHERE1_MESH = FIXTURES_DIR / "nmag_doc_example1" / "sphere1.nmesh.h5" + + + +def test_demag_fem_geometry_vectorized_path_matches_loop(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + mesh_path = tmp_path / "mesh.nmesh" + helpers.write_two_tetra_mesh(mesh_path) + + material = nmag.MagMaterial( + name="Py", + Ms=nmag.SI(1.0e6, "A/m"), + exchange_coupling=nmag.SI(13.0e-12, "J/m"), + ) + sim = nmag.Simulation(name="mvp", config=nmag.NmagConfig(accelerator="rust")) + sim.load_mesh(str(mesh_path), [("generated", material)], unit_length=nmag.SI(1e-9, "m")) + + points = np.asarray(sim.mesh.points, dtype=float) + simplices = np.asarray(sim.mesh.simplices, dtype=int) + vectorized = sim._demag_fem_geometry_for_mesh(points, simplices) + loop = sim._demag_fem_geometry_for_mesh_loop( + points, + simplices, + np.zeros_like(vectorized[0]), + np.zeros_like(vectorized[1]), + np.zeros_like(vectorized[2]), + ) + + for vectorized_part, loop_part in zip(vectorized, loop, strict=True): + np.testing.assert_allclose(vectorized_part, loop_part, rtol=1e-12, atol=1e-24) + + +def test_demag_fem_geometry_rust_backend_matches_python(tmp_path, monkeypatch): + rust_accel = pytest.importorskip("nmag_accel") + monkeypatch.chdir(tmp_path) + mesh_path = tmp_path / "mesh.nmesh" + helpers.write_two_tetra_mesh(mesh_path) + + material = nmag.MagMaterial( + name="Py", + Ms=nmag.SI(1.0e6, "A/m"), + exchange_coupling=nmag.SI(13.0e-12, "J/m"), + ) + sim = nmag.Simulation(name="mvp") + sim.load_mesh(str(mesh_path), [("generated", material)], unit_length=nmag.SI(1e-9, "m")) + + points = np.asarray(sim.mesh.points, dtype=float) + simplices = np.asarray(sim.mesh.simplices, dtype=int) + python_geometry = sim._demag_fem_geometry_for_mesh_python(points, simplices) + rust_geometry = sim._demag_fem_geometry_for_mesh_rust(points, simplices) + + for rust_part, python_part in zip(rust_geometry, python_geometry, strict=True): + np.testing.assert_allclose(rust_part, python_part, rtol=1e-12, atol=1e-24) + + raw_rust_geometry = rust_accel.build_demag_fem_geometry( + np.asarray(points, dtype=np.float64), + np.asarray(simplices, dtype=np.int64), + ) + assert isinstance(raw_rust_geometry[0], np.ndarray) + assert raw_rust_geometry[0].dtype == np.float64 + for rust_part, python_part in zip(raw_rust_geometry, python_geometry, strict=True): + np.testing.assert_allclose(rust_part, python_part, rtol=1e-12, atol=1e-24) + + sim._demag_fem_geometry_cache = None + selected_geometry = sim._demag_fem_geometry_for_mesh(points, simplices) + for selected_part, python_part in zip(selected_geometry, python_geometry, strict=True): + np.testing.assert_allclose(selected_part, python_part, rtol=1e-12, atol=1e-24) + + +def test_demag_fem_assembly_rust_backend_matches_python(tmp_path, monkeypatch): + pytest.importorskip("nmag_accel") + monkeypatch.chdir(tmp_path) + mesh_path = tmp_path / "mesh.nmesh" + helpers.write_two_tetra_mesh(mesh_path) + + material = nmag.MagMaterial( + name="Py", + Ms=nmag.SI(1.0e6, "A/m"), + exchange_coupling=nmag.SI(13.0e-12, "J/m"), + ) + sim = nmag.Simulation(name="mvp", config=nmag.NmagConfig(accelerator="rust")) + sim.load_mesh(str(mesh_path), [("generated", material)], unit_length=nmag.SI(1e-9, "m")) + sim.set_m(lambda point: [1.0, point[0] * 1e9, point[1] * 1e9]) + + points = sim._mesh_points() + simplices = np.asarray(sim.mesh.simplices, dtype=int) + regions = list(sim.mesh.regions or [1] * len(simplices)) + ms_values = sim._simplex_material_ms_values(regions) + stiffness, gradients, volumes = sim._demag_fem_geometry_for_mesh_python(points, simplices) + m = np.asarray(sim._fields["m"], dtype=float) + + python_divergence = sim._assemble_demag_fem_divergence_python( + simplices, + gradients, + volumes, + m, + ms_values, + len(points), + ) + rust_divergence = sim._assemble_demag_fem_divergence_rust( + simplices, + gradients, + volumes, + m, + ms_values, + len(points), + ) + + np.testing.assert_allclose(rust_divergence, python_divergence, rtol=1e-12, atol=1e-20) + + selected_stiffness, selected_divergence, selected_volumes = sim._assemble_demag_fem_system( + points, + simplices, + m, + ms_values, + np.ones(len(simplices), dtype=float), + ) + + np.testing.assert_allclose(selected_stiffness, stiffness, rtol=1e-12, atol=1e-20) + np.testing.assert_allclose(selected_divergence, python_divergence, rtol=1e-12, atol=1e-20) + np.testing.assert_allclose(selected_volumes, volumes, rtol=1e-12, atol=1e-20) + + +def test_demag_fem_geometry_rejects_degenerate_cells(): + 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], + [1.0, 1.0, 0.0], + ], + simplices_indices=[ + [0, 1, 2, 3], + [0, 1, 2, 4], + ], + simplices_regions=[1, 1], + ) + sim = nmag.Simulation(name="mvp") + sim.mesh = mesh + + with pytest.raises(ValueError, match=r"invalid cell indices: \[1\]"): + sim._demag_fem_geometry_for_mesh( + np.asarray(mesh.points, dtype=float), + np.asarray(mesh.simplices, dtype=int), + ) + + +def test_demag_fem_geometry_rust_backend_rejects_degenerate_cells(monkeypatch): + pytest.importorskip("nmag_accel") + 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], + [1.0, 1.0, 0.0], + ], + simplices_indices=[ + [0, 1, 2, 3], + [0, 1, 2, 4], + ], + simplices_regions=[1, 1], + ) + sim = nmag.Simulation(name="mvp") + sim.mesh = mesh + + points = np.asarray(mesh.points, dtype=float) + simplices = np.asarray(mesh.simplices, dtype=int) + with pytest.raises(ValueError, match=r"invalid cell indices: \[1\]"): + sim._demag_fem_geometry_for_mesh_rust(points, simplices) diff --git a/tests/nmag/test_public_fields.py b/tests/nmag/test_public_fields.py new file mode 100644 index 0000000..061d5c9 --- /dev/null +++ b/tests/nmag/test_public_fields.py @@ -0,0 +1,263 @@ +from __future__ import annotations + +from pathlib import Path + +import h5py +import public_api_support_module as helpers + +import nmag + +FIXTURES_DIR = Path(__file__).resolve().parents[1] / "fixtures" +SPHERE1_MESH = FIXTURES_DIR / "nmag_doc_example1" / "sphere1.nmesh.h5" + + + +def test_get_all_field_names_uses_cheap_availability_predicate(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + mesh_path = tmp_path / "mesh.nmesh" + helpers.write_single_region_mesh(mesh_path) + + material = nmag.MagMaterial( + name="Py", + Ms=nmag.SI(1e6, "A/m"), + exchange_coupling=nmag.SI(13.0e-12, "J/m"), + ) + + sim = nmag.Simulation(name="mvp") + sim.load_mesh(str(mesh_path), [("generated", material)], unit_length=nmag.SI(1e-9, "m")) + sim.set_m([1.0, 0.0, 0.0]) + sim.set_H_ext([1.0, 2.0, 3.0], nmag.SI("A/m")) + + def fail_compute(subfieldname): + raise AssertionError(f"availability should not compute {subfieldname}") + + state_check_counts = {"tetrahedral": 0} + original_tetrahedral_check = sim._has_tetrahedral_mesh_or_empty + + def counting_tetrahedral_check(): + state_check_counts["tetrahedral"] += 1 + return original_tetrahedral_check() + + monkeypatch.setattr(sim, "_compute_subfield_array", fail_compute) + monkeypatch.setattr(sim, "_has_tetrahedral_mesh_or_empty", counting_tetrahedral_check) + + field_names = sim.get_all_field_names() + + assert state_check_counts == {"tetrahedral": 1} + assert "H_total" in field_names + assert "H_demag" in field_names + assert "m" in field_names + assert "current_density" not in field_names + assert "dm_dcurrent" not in field_names + + +def test_get_all_field_names_includes_supported_anisotropy(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + mesh_path = tmp_path / "mesh.nmesh" + helpers.write_single_region_mesh(mesh_path) + + material = nmag.MagMaterial( + name="Py", + Ms=nmag.SI(1e6, "A/m"), + exchange_coupling=nmag.SI(13.0e-12, "J/m"), + anisotropy=nmag.uniaxial_anisotropy(axis=[0.0, 0.0, 1.0], K1=1.0e5), + ) + + sim = nmag.Simulation(name="mvp") + sim.load_mesh(str(mesh_path), [("generated", material)], unit_length=nmag.SI(1e-9, "m")) + sim.set_m([1.0, 0.0, 0.0]) + + field_names = sim.get_all_field_names() + + assert "H_exch" in field_names + assert "E_exch" in field_names + assert "H_anis" in field_names + assert "E_anis" in field_names + assert "H_total" in field_names + assert "E_total" in field_names + + +def test_h_demag_save_and_average_use_internal_demag_paths(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + mesh_path = tmp_path / "mesh.nmesh" + helpers.write_single_region_mesh(mesh_path) + + material = nmag.MagMaterial( + name="Py", + Ms=nmag.SI(1.0e6, "A/m"), + exchange_coupling=nmag.SI(13.0e-12, "J/m"), + ) + sim = nmag.Simulation(name="mvp") + sim.load_mesh(str(mesh_path), [("generated", material)], unit_length=nmag.SI(1e-9, "m")) + sim.set_m([1.0, 0.0, 0.0]) + + def fail_public_h_demag_path(subfieldname, units=None): + if subfieldname == "H_demag": + raise AssertionError("H_demag storage should not round-trip through get_subfield().") + return original_get_subfield(subfieldname, units=units) + + original_get_subfield = sim.get_subfield + monkeypatch.setattr(sim, "get_subfield", fail_public_h_demag_path) + + average = sim.get_subfield_average("H_demag") + sim.save_spatial_fields(str(tmp_path / "demag_only.h5"), ["H_demag"]) + + assert len(average) == 3 + assert sim.last_subfield_average_timings_seconds["direct_cell_average"] >= 0.0 + assert ( + sim.last_subfield_average_timings_seconds[ + "subfield_array:H_demag_detail:cell_average" + ] + >= 0.0 + ) + assert ( + sim.last_subfield_average_timings_seconds[ + "subfield_array:H_demag_detail:cell_average:python" + ] + >= 0.0 + ) + with h5py.File(tmp_path / "demag_only.h5") as h5: + assert h5["fields/H_demag"].shape == (4, 3) + + +def test_save_data_reuses_subfield_arrays_across_ndt_and_spatial_fields(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + mesh_path = tmp_path / "mesh.nmesh" + helpers.write_single_region_mesh(mesh_path) + + material = nmag.MagMaterial( + name="Py", + Ms=nmag.SI(1.0e6, "A/m"), + exchange_coupling=nmag.SI(13.0e-12, "J/m"), + ) + sim = nmag.Simulation(name="mvp") + sim.load_mesh(str(mesh_path), [("generated", material)], unit_length=nmag.SI(1e-9, "m")) + sim.set_m([1.0, 0.0, 0.0]) + sim.set_H_ext([1.0, 2.0, 3.0], nmag.SI("A/m")) + + counts = {} + original_compute = sim._compute_subfield_array + + def counting_compute(subfieldname): + counts[subfieldname] = counts.get(subfieldname, 0) + 1 + return original_compute(subfieldname) + + monkeypatch.setattr(sim, "_compute_subfield_array", counting_compute) + + sim.save_data(fields="all") + + assert sim._subfield_array_cache is None + assert sim.last_save_timings_seconds["total"] >= 0.0 + assert sim.last_save_timings_seconds["save_spatial_fields"] >= 0.0 + assert sim.last_spatial_save_timings_seconds["total"] >= 0.0 + assert sim.last_spatial_save_timings_seconds["field:H_demag:total"] >= 0.0 + assert sim.last_spatial_save_timings_seconds["field:H_demag:compute"] >= 0.0 + assert sim.last_spatial_save_timings_seconds["field:H_demag:write"] >= 0.0 + assert sim.last_spatial_save_timings_seconds["mesh_points"] >= 0.0 + assert sim.last_save_timings_seconds["spatial_detail:total"] >= 0.0 + assert sim.last_save_timings_seconds["spatial_detail:field:H_demag:total"] >= 0.0 + assert sim.last_save_timings_seconds["spatial_detail:field:H_demag:compute"] >= 0.0 + assert sim.last_save_timings_seconds["spatial_detail:field:H_demag:write"] >= 0.0 + assert sim.last_save_timings_seconds["spatial_detail:field_selection"] >= 0.0 + assert sim.last_save_timings_seconds["spatial_detail:write_dispatch"] >= 0.0 + assert sim.last_save_timings_seconds["spatial_detail:mesh_points"] >= 0.0 + assert sim.last_save_timings_seconds["average:H_demag"] >= 0.0 + assert sim.last_save_timings_seconds[ + "average_detail:H_total_Py:composed_average" + ] >= 0.0 + assert sim.last_save_timings_seconds["average_detail:H_total_Py:total"] >= 0.0 + assert sim.last_save_timings_seconds[ + "average_detail:H_total_Py:component_average:H_ext:constant" + ] >= 0.0 + assert sim.last_save_timings_seconds[ + "average_detail:H_total_Py:component_average:H_demag:direct_cell_average" + ] >= 0.0 + assert sim.last_save_timings_seconds[ + "average_detail:H_total_Py:component_average:H_anis:constant" + ] >= 0.0 + assert sim.last_save_timings_seconds[ + "average_detail:H_total_Py:component_average:H_exch:constant" + ] >= 0.0 + assert "average_detail:H_total_Py:subfield_array:H_demag:compute" not in ( + sim.last_save_timings_seconds + ) + assert ( + "average_detail:H_total_Py:subfield_array:H_total:compute" + not in sim.last_save_timings_seconds + ) + assert sim.last_save_timings_seconds[ + "average_detail:H_total_Py:subfield_array:H_demag_detail:auxiliary_fields" + ] >= 0.0 + assert sim.last_save_timings_seconds[ + "average_detail:H_total_Py:subfield_array:demag_auxiliary:gauge_solve" + ] >= 0.0 + assert sim.last_save_timings_seconds[ + "average_detail:H_total_Py:subfield_array:demag_auxiliary:gauge_solve:setup" + ] >= 0.0 + assert any( + sim.last_save_timings_seconds.get( + f"average_detail:H_total_Py:subfield_array:demag_auxiliary:gauge_solve:{phase}", + ) + is not None + for phase in ("numpy_solve", "numpy_lstsq", "scipy_solve", "scipy_lstsq") + ) + assert sim.last_save_timings_seconds[ + "average_detail:H_total_Py:subfield_array:demag_auxiliary:lindholm_bem:index" + ] >= 0.0 + assert any( + sim.last_save_timings_seconds.get( + f"average_detail:H_total_Py:subfield_array:demag_auxiliary:lindholm_bem:{backend}", + ) + is not None + for backend in ("python", "numba", "rust") + ) + assert "average_detail:H_total_Py:subfield_array:H_demag_detail:nodal_recovery" not in ( + sim.last_save_timings_seconds + ) + assert sim.last_save_timings_seconds[ + "average_detail:maxangle_m_Py:uniform_check" + ] >= 0.0 + assert "average_detail:maxangle_m_Py:edge_angles" not in sim.last_save_timings_seconds + assert sim.last_demag_solve_diagnostics["phi1_centered_residual_max_abs"] >= 0.0 + assert sim.last_demag_solve_diagnostics["dirichlet_residual_max_abs"] >= 0.0 + assert ( + sim.last_demag_solve_diagnostics["phi1_centered_residual_relative_l2"] + >= 0.0 + ) + assert sim.last_demag_solve_diagnostics[ + "dirichlet_residual_relative_l2" + ] >= 0.0 + assert ( + sim.last_demag_solve_diagnostics["phi1_centered_residual_relative_l2"] + < 1.0e-9 + ) + assert ( + sim.last_demag_solve_diagnostics["dirichlet_residual_relative_l2"] + < 1.0e-9 + ) + assert sim.last_demag_solve_diagnostics["phi1_gauge_sum_abs"] >= 0.0 + assert sim.last_demag_solve_diagnostics["node_count"] == len(sim.mesh.points) + assert sim.last_demag_solve_diagnostics["boundary_node_count"] <= len(sim.mesh.points) + assert sim.last_demag_solve_diagnostics["gauge_augmented_size"] == len(sim.mesh.points) + 1 + assert sim.last_demag_solve_diagnostics["dirichlet_interior_node_count"] >= 0 + assert sim.last_subfield_average_timings_seconds["total"] >= 0.0 + for fieldname in [ + "H_total", + "H_ext", + "H_demag", + "H_anis", + "H_exch", + "M", + "m", + "dmdt", + "E_total", + "E_ext", + "E_demag", + "pin", + "phi", + "rho", + ]: + assert counts[fieldname] == 1 + assert counts.get("current_density", 0) == 0 + assert counts.get("dm_dcurrent", 0) == 0 + diff --git a/tests/nmag/test_public_imports.py b/tests/nmag/test_public_imports.py new file mode 100644 index 0000000..c0e8f1e --- /dev/null +++ b/tests/nmag/test_public_imports.py @@ -0,0 +1,206 @@ +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +import numpy as np + +import nmag +import nmesh +from nmag.simulation import ( + TETRA_FACE_VERTICES, + _si_dimensionless, + _si_unit, +) + +FIXTURES_DIR = Path(__file__).resolve().parents[1] / "fixtures" +SPHERE1_MESH = FIXTURES_DIR / "nmag_doc_example1" / "sphere1.nmesh.h5" + +def test_simulation_unit_helpers_cache_common_si_units(): + _si_unit.cache_clear() + _si_dimensionless.cache_clear() + + assert _si_unit("A/m") is _si_unit("A/m") + assert _si_unit("A/m").in_units_of(nmag.SI(1, "A/m")) == 1.0 + assert _si_dimensionless() is _si_dimensionless() + assert _si_dimensionless().in_units_of(nmag.SI(1)) == 1.0 + + +def _subprocess_env_with_repo_src(): + src_path = str(Path(__file__).resolve().parents[2] / "src") + pythonpath = os.environ.get("PYTHONPATH") + return { + **os.environ, + "PYTHONPATH": ( + src_path + if not pythonpath + else f"{src_path}{os.pathsep}{pythonpath}" + ), + } + + +def _write_single_region_mesh(path): + 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.save(path) + + +def _write_two_tetra_mesh(path): + 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], + [1.0, 1.0, 1.0], + ], + simplices_indices=[ + [0, 1, 2, 3], + [1, 2, 3, 4], + ], + simplices_regions=[1, 1], + ) + mesh.save(path) + + +def _write_two_region_mesh(path): + 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], + [1.0, 1.0, 1.0], + ], + simplices_indices=[[0, 1, 2, 3], [1, 2, 3, 4]], + simplices_regions=[1, 2], + ) + mesh.save(path) + + +def _reference_oriented_boundary_faces(points, simplices): + face_owners = {} + for cell_index, simplex in enumerate(simplices): + cell_center = np.mean(points[simplex], axis=0) + for local_face in TETRA_FACE_VERTICES: + face = tuple(int(simplex[index]) for index in local_face) + key = tuple(sorted(face)) + if key in face_owners: + face_owners[key] = None + continue + + a, b, c = points[list(face)] + normal = np.cross(b - a, c - a) + face_center = (a + b + c) / 3.0 + if np.dot(normal, cell_center - face_center) > 0.0: + face = (face[0], face[2], face[1]) + face_owners[key] = (cell_index, face) + + return [owner for owner in face_owners.values() if owner is not None] + + +def test_public_nmag_import_surface(): + assert nmag.SI(1, "A/m").dens_str() == "" + assert nmag.IntegratorConfig + assert nmag.IntegratorStats + assert nmag.MagMaterial + assert nmag.Simulation + + +def test_nmag_simulation_import_keeps_scipy_linalg_lazy(): + result = subprocess.run( + [ + sys.executable, + "-c", + ( + "import sys; " + "import nmag.simulation as sim; " + "print(sim._SCIPY_LINALG is None); " + "print('scipy.linalg' in sys.modules); " + "print('simulation.quantity' in sys.modules); " + "print('simulation.data_writer' in sys.modules)" + ), + ], + check=True, + env=_subprocess_env_with_repo_src(), + capture_output=True, + text=True, + ) + assert result.stdout.splitlines() == ["True", "False", "False", "False"] + + +def test_public_nmag_import_keeps_material_constants_lazy(): + result = subprocess.run( + [ + sys.executable, + "-c", + ( + "import sys; " + "import nmag; " + "print('si.constants' in sys.modules); " + "print('simulation.quantity' in sys.modules); " + "print('simulation.data_writer' in sys.modules); " + "material = nmag.MagMaterial(name='Py'); " + "print('si.constants' in sys.modules); " + "_ = material.thermal_factor; " + "print('si.constants' in sys.modules)" + ), + ], + check=True, + env=_subprocess_env_with_repo_src(), + capture_output=True, + text=True, + ) + assert result.stdout.splitlines() == ["False", "False", "False", "False", "True"] + + +def test_public_nmag_import_keeps_pint_registry_lazy(): + result = subprocess.run( + [ + sys.executable, + "-c", + ( + "import sys; " + "import nmag; " + "print('pint' in sys.modules); " + "print(nmag.SI.__name__); " + "print('pint' in sys.modules); " + "sim = nmag.Simulation(name='probe'); " + "mat = nmag.MagMaterial(" + "name='Py', " + "Ms=nmag.SI(1e6, 'A/m'), " + "exchange_coupling=nmag.SI(13e-12, 'J/m')); " + "_ = nmag.SI(1, 'm'); " + "_ = nmag.SI(1e6, 'A/m'); " + "_ = nmag.SI(13e-12, 'J/m'); " + "_ = nmag.SI(1, 's') / nmag.SI(1e-12, 's'); " + "_ = mat.Ms.in_units_of(nmag.SI(1, 'A/m')); " + "print('pint' in sys.modules); " + "_ = nmag.SI(1, 'Wb'); " + "print('pint' in sys.modules)" + ), + ], + check=True, + env=_subprocess_env_with_repo_src(), + capture_output=True, + text=True, + ) + assert result.stdout.splitlines() == [ + "False", + "Physical", + "False", + "False", + "True", + ] + + diff --git a/tests/nmag/test_public_linear_solver.py b/tests/nmag/test_public_linear_solver.py new file mode 100644 index 0000000..b050291 --- /dev/null +++ b/tests/nmag/test_public_linear_solver.py @@ -0,0 +1,224 @@ +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import numpy as np +import public_api_support_module as helpers +import pytest + +import nmag +from nmag.simulation import ( + DEMAG_LINEAR_SOLVER_AUTO_SCIPY_MIN_SIZE_ENV, + DEMAG_LINEAR_SOLVER_BACKEND_ENV, + _selected_demag_linear_solver_backend, + _solve_gauge_fixed, + _solve_linear_system, +) + +FIXTURES_DIR = Path(__file__).resolve().parents[1] / "fixtures" +SPHERE1_MESH = FIXTURES_DIR / "nmag_doc_example1" / "sphere1.nmesh.h5" + +def test_demag_linear_solver_auto_uses_numpy_without_loading_scipy(): + env = helpers.subprocess_env_with_repo_src() + env.pop(DEMAG_LINEAR_SOLVER_BACKEND_ENV, None) + env.pop(DEMAG_LINEAR_SOLVER_AUTO_SCIPY_MIN_SIZE_ENV, None) + result = subprocess.run( + [ + sys.executable, + "-c", + ( + "import json; " + "import sys; " + "import numpy as np; " + "import nmag.simulation as sim; " + "matrix = np.asarray([[2.0, 0.0], [0.0, 4.0]]); " + "rhs = np.asarray([2.0, 8.0]); " + "gauge_matrix = np.asarray([[2.0, 0.0], [0.0, 2.0]]); " + "gauge_rhs = np.asarray([2.0, -2.0]); " + "print(json.dumps({" + "'backend': sim._selected_demag_linear_solver_backend(), " + "'linear': sim._solve_linear_system(matrix, rhs).tolist(), " + "'gauge': sim._solve_gauge_fixed(gauge_matrix, gauge_rhs).tolist(), " + "'scipy_cached': sim._SCIPY_LINALG is not None, " + "'scipy_loaded': 'scipy.linalg' in sys.modules" + "}))" + ), + ], + check=True, + env=env, + capture_output=True, + text=True, + ) + assert json.loads(result.stdout) == { + "backend": "numpy", + "linear": [1.0, 2.0], + "gauge": [1.0, -1.0], + "scipy_cached": False, + "scipy_loaded": False, + } + + +def test_demag_linear_solver_backend_env_rejects_unknown_backend(monkeypatch): + monkeypatch.setenv(DEMAG_LINEAR_SOLVER_BACKEND_ENV, "definitely-not-a-backend") + + with pytest.raises(ValueError, match=DEMAG_LINEAR_SOLVER_BACKEND_ENV): + _selected_demag_linear_solver_backend() + + +def test_demag_linear_solver_scipy_backend_matches_numpy(monkeypatch): + matrix = np.asarray([[3.0, 1.0], [1.0, 2.0]], dtype=float) + rhs = np.asarray([9.0, 8.0], dtype=float) + gauge_matrix = np.asarray([[2.0, 0.0], [0.0, 2.0]], dtype=float) + gauge_rhs = np.asarray([2.0, -2.0], dtype=float) + + monkeypatch.setenv(DEMAG_LINEAR_SOLVER_BACKEND_ENV, "numpy") + numpy_solution = _solve_linear_system(matrix, rhs) + numpy_gauge = _solve_gauge_fixed(gauge_matrix, gauge_rhs) + + monkeypatch.setenv(DEMAG_LINEAR_SOLVER_BACKEND_ENV, "scipy") + scipy_solution = _solve_linear_system(matrix, rhs) + scipy_gauge = _solve_gauge_fixed(gauge_matrix, gauge_rhs) + + assert _selected_demag_linear_solver_backend() == "scipy" + np.testing.assert_allclose(scipy_solution, numpy_solution, rtol=1.0e-12, atol=1.0e-12) + np.testing.assert_allclose(scipy_gauge, numpy_gauge, rtol=1.0e-12, atol=1.0e-12) + + +def test_demag_linear_solver_auto_threshold_reaches_solver_calls(monkeypatch): + monkeypatch.delenv(DEMAG_LINEAR_SOLVER_BACKEND_ENV, raising=False) + monkeypatch.setenv(DEMAG_LINEAR_SOLVER_AUTO_SCIPY_MIN_SIZE_ENV, "2") + timings = {} + + def record_timing(name, seconds): + timings[name] = timings.get(name, 0.0) + seconds + + solution = _solve_linear_system( + np.asarray([[3.0, 1.0], [1.0, 2.0]], dtype=float), + np.asarray([9.0, 8.0], dtype=float), + record_timing=record_timing, + timing_prefix="linear", + ) + + np.testing.assert_allclose(solution, [2.0, 3.0]) + assert "linear:scipy_import" in timings + assert "linear:scipy_solve" in timings + assert "linear:numpy_solve" not in timings + + +def test_demag_linear_solver_records_numpy_timing_subphases(monkeypatch): + monkeypatch.setenv(DEMAG_LINEAR_SOLVER_BACKEND_ENV, "numpy") + timings = {} + + def record_timing(name, seconds): + timings[name] = timings.get(name, 0.0) + seconds + + solution = _solve_linear_system( + np.asarray([[3.0, 1.0], [1.0, 2.0]], dtype=float), + np.asarray([9.0, 8.0], dtype=float), + record_timing=record_timing, + timing_prefix="linear", + ) + gauge_solution = _solve_gauge_fixed( + np.asarray([[2.0, 0.0], [0.0, 2.0]], dtype=float), + np.asarray([2.0, -2.0], dtype=float), + record_timing=record_timing, + timing_prefix="gauge", + ) + + np.testing.assert_allclose(solution, [2.0, 3.0]) + np.testing.assert_allclose(gauge_solution, [1.0, -1.0]) + assert timings["linear:numpy_solve"] >= 0.0 + assert timings["gauge:setup"] >= 0.0 + assert timings["gauge:numpy_solve"] >= 0.0 + + +def test_demag_linear_solver_records_scipy_import_timing(monkeypatch): + monkeypatch.setenv(DEMAG_LINEAR_SOLVER_BACKEND_ENV, "scipy") + timings = {} + + def record_timing(name, seconds): + timings[name] = timings.get(name, 0.0) + seconds + + solution = _solve_linear_system( + np.asarray([[3.0, 1.0], [1.0, 2.0]], dtype=float), + np.asarray([9.0, 8.0], dtype=float), + record_timing=record_timing, + timing_prefix="linear", + ) + gauge_solution = _solve_gauge_fixed( + np.asarray([[2.0, 0.0], [0.0, 2.0]], dtype=float), + np.asarray([2.0, -2.0], dtype=float), + record_timing=record_timing, + timing_prefix="gauge", + ) + + np.testing.assert_allclose(solution, [2.0, 3.0]) + np.testing.assert_allclose(gauge_solution, [1.0, -1.0]) + assert timings["linear:scipy_import"] >= 0.0 + assert timings["linear:scipy_solve"] >= 0.0 + assert timings["gauge:setup"] >= 0.0 + assert timings["gauge:scipy_import"] >= 0.0 + assert timings["gauge:scipy_solve"] >= 0.0 + + +def test_demag_linear_solver_rejects_inaccurate_numpy_fallback(monkeypatch): + monkeypatch.setenv(DEMAG_LINEAR_SOLVER_BACKEND_ENV, "numpy") + timings = {} + + def record_timing(name, seconds): + timings[name] = timings.get(name, 0.0) + seconds + + with pytest.raises(np.linalg.LinAlgError, match="relative residual"): + _solve_linear_system( + np.zeros((2, 2), dtype=float), + np.ones(2, dtype=float), + record_timing=record_timing, + timing_prefix="linear", + ) + + assert timings["linear:numpy_solve_failed"] >= 0.0 + + +def test_demag_linear_solver_accepts_consistent_singular_system(monkeypatch, caplog): + monkeypatch.setenv(DEMAG_LINEAR_SOLVER_BACKEND_ENV, "numpy") + solution = _solve_linear_system( + np.asarray([[1.0, 1.0], [2.0, 2.0]]), + np.asarray([2.0, 4.0]), + ) + + np.testing.assert_allclose(solution, [1.0, 1.0]) + assert "least-squares solution" in caplog.text + + +def test_dirichlet_extension_records_solver_subphases(monkeypatch): + monkeypatch.setenv(DEMAG_LINEAR_SOLVER_BACKEND_ENV, "numpy") + sim = nmag.Simulation(name="mvp") + timings = {} + previous_timings = sim._active_subfield_array_timings + sim._active_subfield_array_timings = timings + try: + phi = sim._dirichlet_extension_from_boundary( + np.asarray( + [ + [2.0, -1.0, 0.0], + [-1.0, 2.0, -1.0], + [0.0, -1.0, 2.0], + ], + dtype=float, + ), + np.asarray([0, 2], dtype=int), + np.asarray([1.0, 3.0], dtype=float), + ) + finally: + sim._active_subfield_array_timings = previous_timings + + np.testing.assert_allclose(phi, [1.0, 2.0, 3.0]) + assert timings["demag_auxiliary:dirichlet_extension:index"] >= 0.0 + assert timings["demag_auxiliary:dirichlet_extension:slice"] >= 0.0 + assert timings["demag_auxiliary:dirichlet_extension:rhs"] >= 0.0 + assert timings["demag_auxiliary:dirichlet_extension:numpy_solve"] >= 0.0 + + diff --git a/tests/nmag/test_public_llg_rhs.py b/tests/nmag/test_public_llg_rhs.py new file mode 100644 index 0000000..61b3226 --- /dev/null +++ b/tests/nmag/test_public_llg_rhs.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pytest + +import nmag +import nmag.simulation as nmag_sim + +FIXTURES_DIR = Path(__file__).resolve().parents[1] / "fixtures" +SPHERE1_MESH = FIXTURES_DIR / "nmag_doc_example1" / "sphere1.nmesh.h5" + +def test_llg_rhs_python_matches_nested_cross_reference(): + sim = nmag.Simulation(name="mvp") + m = np.asarray( + [ + [1.0, 0.0, 0.0], + [0.0, 0.8, 0.6], + [0.2, -0.3, 0.9327379053088815], + ], + dtype=float, + ) + h_total = np.asarray( + [ + [0.0, 1.0e5, -2.0e5], + [3.0e5, -4.0e4, 2.0e4], + [-1.0e5, 7.0e4, 8.0e4], + ], + dtype=float, + ) + pin = np.asarray([1.0, 0.25, 0.0], dtype=float) + ms_values = np.asarray([8.0e5, 6.5e5, 7.1e5], dtype=float) + c1 = -2.211e5 + c2 = -1.1055e4 + c3 = 1.0e11 + + optimized = sim._llg_rhs_python(m, h_total, pin, ms_values, c1, c2, c3) + reference = ms_values[:, np.newaxis] * ( + c1 * np.cross(m, h_total) + + c2 * np.cross(m, np.cross(m, h_total)) + + c3 * (1.0 - np.einsum("ij,ij->i", m, m))[:, np.newaxis] * m + ) * pin[:, np.newaxis] + + np.testing.assert_allclose(optimized, reference, rtol=1.0e-15, atol=1.0e-6) + + +def test_llg_rhs_rust_wrapper_delegates_to_accelerator(monkeypatch): + sim = nmag.Simulation(name="mvp") + m = np.asarray([[1.0, 0.0, 0.0]], dtype=float) + h_total = np.asarray([[0.0, 2.0, 0.0]], dtype=float) + pin = np.asarray([1.0], dtype=float) + ms_values = np.asarray([8.0e5], dtype=float) + + class FakeRustAccelerator: + def __init__(self): + self.calls = [] + + def llg_rhs(self, *args): + self.calls.append(args) + return np.asarray([[1.0, 2.0, 3.0]], dtype=float) + + fake = FakeRustAccelerator() + monkeypatch.setattr(nmag_sim, "_load_rust_accelerator", lambda required_by: fake) + + result = sim._llg_rhs_rust(m, h_total, pin, ms_values, -1.0, -0.5, 0.25) + + np.testing.assert_allclose(result, [[1.0, 2.0, 3.0]]) + assert len(fake.calls) == 1 + assert fake.calls[0][4:] == (-1.0, -0.5, 0.25) + + +def test_llg_rhs_rust_kernel_matches_python_reference_when_available(): + rust_accel = pytest.importorskip("nmag_accel") + if not hasattr(rust_accel, "llg_rhs"): + pytest.skip("installed nmag_accel extension does not expose llg_rhs") + + sim = nmag.Simulation(name="mvp") + m = np.asarray( + [ + [1.0, 0.0, 0.0], + [0.0, 0.8, 0.6], + [0.2, -0.3, 0.9327379053088815], + ], + dtype=float, + ) + h_total = np.asarray( + [ + [0.0, 1.0e5, -2.0e5], + [3.0e5, -4.0e4, 2.0e4], + [-1.0e5, 7.0e4, 8.0e4], + ], + dtype=float, + ) + pin = np.asarray([1.0, 0.25, 0.0], dtype=float) + ms_values = np.asarray([8.0e5, 6.5e5, 7.1e5], dtype=float) + c1 = -2.211e5 + c2 = -1.1055e4 + c3 = 1.0e11 + + reference = sim._llg_rhs_python(m, h_total, pin, ms_values, c1, c2, c3) + accelerated = np.asarray( + rust_accel.llg_rhs(m, h_total, pin, ms_values, c1, c2, c3), + dtype=float, + ) + + np.testing.assert_allclose(accelerated, reference, rtol=1.0e-15, atol=1.0e-6) + + diff --git a/tests/nmag/test_public_maxangle_exchange.py b/tests/nmag/test_public_maxangle_exchange.py new file mode 100644 index 0000000..459f9f5 --- /dev/null +++ b/tests/nmag/test_public_maxangle_exchange.py @@ -0,0 +1,241 @@ +from __future__ import annotations + +from pathlib import Path + +import h5py +import numpy as np +import public_api_support_module as helpers +import pytest + +import nmag +import nmag.demag as nmag_demag +import nmag.simulation as nmag_sim + +FIXTURES_DIR = Path(__file__).resolve().parents[1] / "fixtures" +SPHERE1_MESH = FIXTURES_DIR / "nmag_doc_example1" / "sphere1.nmesh.h5" + + + +def test_uniform_maxangle_average_uses_zero_fast_path(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + mesh_path = tmp_path / "mesh.nmesh" + helpers.write_two_tetra_mesh(mesh_path) + + material = nmag.MagMaterial( + name="Py", + Ms=nmag.SI(1.0e6, "A/m"), + exchange_coupling=nmag.SI(13.0e-12, "J/m"), + ) + sim = nmag.Simulation(name="mvp", config=nmag.NmagConfig(accelerator="off")) + sim.load_mesh(str(mesh_path), [("generated", material)], unit_length=nmag.SI(1e-9, "m")) + sim.set_m([1.0, 0.0, 0.0]) + + def fail_arccos(_value): + raise AssertionError("uniform maxangle should not enter the edge loop") + + monkeypatch.setattr(nmag_sim.np, "arccos", fail_arccos) + + assert sim.get_maxangle_average("m") == 0.0 + + +def test_nonuniform_maxangle_average_uses_cached_vectorized_edges(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + mesh_path = tmp_path / "mesh.nmesh" + helpers.write_two_tetra_mesh(mesh_path) + + material = nmag.MagMaterial( + name="Py", + Ms=nmag.SI(1.0e6, "A/m"), + exchange_coupling=nmag.SI(13.0e-12, "J/m"), + ) + sim = nmag.Simulation(name="mvp", config=nmag.NmagConfig(accelerator="off")) + sim.load_mesh(str(mesh_path), [("generated", material)], unit_length=nmag.SI(1e-9, "m")) + sim.set_m(lambda point: [1.0, point[0] * 1e9, point[1] * 1e9]) + + expected = sim._maxangle_average_reference("m") + norms = np.linalg.norm(np.asarray(sim.get_subfield("m"), dtype=float), axis=1) + np.testing.assert_allclose(norms, 1.0) + + def fail_normalised_rows(_values): + raise AssertionError("set_m stores normalised rows for maxangle") + + monkeypatch.setattr(nmag_demag, "_normalised_rows", fail_normalised_rows) + actual = sim.get_maxangle_average("m") + + assert actual == pytest.approx(expected) + assert sim.last_maxangle_timings_seconds["input_arrays"] >= 0.0 + assert sim.last_maxangle_timings_seconds["uniform_check"] >= 0.0 + assert sim.last_maxangle_timings_seconds["mesh_edges"] >= 0.0 + assert sim.last_maxangle_timings_seconds["edge_angles"] >= 0.0 + assert sim.last_maxangle_timings_seconds["total"] >= 0.0 + assert sim._mesh_edge_cache is not None + cached_edges = sim._mesh_edge_cache[1] + assert cached_edges.shape == (9, 2) + np.testing.assert_array_equal( + cached_edges, + np.asarray( + [ + [0, 1], + [0, 2], + [0, 3], + [1, 2], + [1, 3], + [1, 4], + [2, 3], + [2, 4], + [3, 4], + ], + dtype=int, + ), + ) + + def fail_unique(*_args, **_kwargs): + raise AssertionError("cached maxangle should not rebuild mesh edges") + + monkeypatch.setattr(nmag_sim.np, "unique", fail_unique) + + assert sim.get_maxangle_average("m") == pytest.approx(expected) + assert sim._mesh_edge_cache is not None + assert sim._mesh_edge_cache[1] is cached_edges + + +def test_nonuniform_maxangle_auto_uses_rust_when_available(tmp_path, monkeypatch): + pytest.importorskip("nmag_accel") + monkeypatch.chdir(tmp_path) + mesh_path = tmp_path / "mesh.nmesh" + helpers.write_two_tetra_mesh(mesh_path) + + material = nmag.MagMaterial( + name="Py", + Ms=nmag.SI(1.0e6, "A/m"), + exchange_coupling=nmag.SI(13.0e-12, "J/m"), + ) + sim = nmag.Simulation(name="mvp") + sim.load_mesh( + str(mesh_path), + [("generated", material)], + unit_length=nmag.SI(1e-9, "m"), + ) + sim.set_m(lambda point: [1.0, point[0] * 1e9, point[1] * 1e9]) + + assert sim.get_maxangle_average("m") == pytest.approx( + sim._maxangle_average_reference("m"), + ) + assert "mesh_edges_and_angles:rust" in sim.last_maxangle_timings_seconds + assert "mesh_edges" not in sim.last_maxangle_timings_seconds + assert "edge_angles" not in sim.last_maxangle_timings_seconds + + +def test_maxangle_rust_backend_matches_python_reference(tmp_path, monkeypatch): + pytest.importorskip("nmag_accel") + monkeypatch.chdir(tmp_path) + mesh_path = tmp_path / "mesh.nmesh" + helpers.write_two_tetra_mesh(mesh_path) + + material = nmag.MagMaterial( + name="Py", + Ms=nmag.SI(1.0e6, "A/m"), + exchange_coupling=nmag.SI(13.0e-12, "J/m"), + ) + sim = nmag.Simulation(name="mvp") + sim.load_mesh(str(mesh_path), [("generated", material)], unit_length=nmag.SI(1e-9, "m")) + sim.set_m(lambda point: [1.0, point[0] * 1e9, point[1] * 1e9]) + + actual = sim.get_maxangle_average("m") + expected = sim._maxangle_average_reference("m") + + assert actual == pytest.approx(expected) + assert sim.last_maxangle_timings_seconds["input_arrays"] >= 0.0 + assert sim.last_maxangle_timings_seconds["uniform_check"] >= 0.0 + assert sim.last_maxangle_timings_seconds["mesh_edges_and_angles:rust"] >= 0.0 + assert sim.last_maxangle_timings_seconds["total"] >= 0.0 + + +def test_uniform_exchange_field_uses_zero_fast_path(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + mesh_path = tmp_path / "mesh.nmesh" + helpers.write_two_tetra_mesh(mesh_path) + + material = nmag.MagMaterial( + name="Py", + Ms=nmag.SI(1.0e6, "A/m"), + exchange_coupling=nmag.SI(13.0e-12, "J/m"), + ) + sim = nmag.Simulation(name="mvp") + sim.load_mesh(str(mesh_path), [("generated", material)], unit_length=nmag.SI(1e-9, "m")) + sim.set_m([1.0, 0.0, 0.0]) + + def fail_fem_geometry(_points, _simplices): + raise AssertionError("uniform exchange should not assemble FEM geometry") + + monkeypatch.setattr(sim, "_demag_fem_geometry_for_mesh", fail_fem_geometry) + + np.testing.assert_allclose(sim.get_subfield_average("H_exch"), [0.0, 0.0, 0.0]) + np.testing.assert_allclose(sim._subfield_array("H_exch"), np.zeros((5, 3))) + + +def test_nonuniform_magnetisation_publishes_computed_exchange(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + mesh_path = tmp_path / "mesh.nmesh" + helpers.write_single_region_mesh(mesh_path) + + material = nmag.MagMaterial( + name="Py", + Ms=nmag.SI(1.0e6, "A/m"), + exchange_coupling=nmag.SI(13.0e-12, "J/m"), + ) + sim = nmag.Simulation(name="mvp") + sim.load_mesh(str(mesh_path), [("generated", material)], unit_length=nmag.SI(1e-9, "m")) + sim.set_m(lambda point: [1.0, point[0] * 1e9, 0.0]) + sim.set_H_ext([0.0, 0.0, 0.0], nmag.SI("A/m")) + + h_exch_direct = np.asarray(sim._subfield_array("H_exch")) + assert sim._incident_cell_volume_sums_cache is not None + first_cache = sim._incident_cell_volume_sums_cache + points = sim._mesh_points() + simplices = np.asarray(sim.mesh.simplices, dtype=int) + volumes, _positive = sim._simplex_volume_weights(points, simplices) + cached_weights = sim._incident_cell_volume_sums(points, simplices, volumes) + + assert sim._incident_cell_volume_sums_cache is first_cache + assert cached_weights is first_cache[3] + + sim.save_data(fields="all") + + with h5py.File(tmp_path / "mvp_dat.h5") as h5: + field_names = set(h5["fields"].keys()) + e_exch = h5["fields/E_exch"][:] + h_exch = h5["fields/H_exch"][:] + dmdt = h5["fields/dmdt"][:] + phi = h5["fields/phi"][:] + rho = h5["fields/rho"][:] + assert "E_demag" in field_names + assert "E_ext" in field_names + assert "E_exch" in field_names + assert "E_total" in field_names + assert "H_exch" in field_names + assert "H_total" in field_names + assert e_exch.shape == (4,) + assert h_exch.shape == (4, 3) + assert dmdt.shape == (4, 3) + assert phi.shape == (4,) + assert rho.shape == (4,) + np.testing.assert_allclose(h_exch, h_exch_direct) + assert float(np.max(np.abs(e_exch))) > 0.0 + assert float(np.max(np.abs(h_exch))) > 0.0 + assert float(np.max(np.abs(dmdt))) > 0.0 + assert float(np.max(np.abs(phi))) > 0.0 + assert float(np.max(np.abs(rho))) > 0.0 + + ndt_lines = (tmp_path / "mvp_dat.ndt").read_text(encoding="utf-8").splitlines() + header = ndt_lines[1].split("\t") + row = dict(zip(header, ndt_lines[2].split("\t"), strict=True)) + assert "E_demag_Py" in header + assert "E_exch_Py" in header + assert "E_total_Py" in header + assert "H_exch_Py_0" in header + assert "H_total_Py_0" in header + assert "dmdt_Py_0" in header + assert "maxangle_m_Py" in header + assert abs(float(row["dmdt_Py_0"])) > 0.0 + assert float(row["maxangle_m_Py"]) > 0.0 diff --git a/tests/nmag/test_public_rust_demag.py b/tests/nmag/test_public_rust_demag.py new file mode 100644 index 0000000..2ede7d4 --- /dev/null +++ b/tests/nmag/test_public_rust_demag.py @@ -0,0 +1,207 @@ +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import public_api_support_module as helpers + +import nmag +import nmag.simulation as nmag_sim +import nmesh + +FIXTURES_DIR = Path(__file__).resolve().parents[1] / "fixtures" +SPHERE1_MESH = FIXTURES_DIR / "nmag_doc_example1" / "sphere1.nmesh.h5" + + +def test_demag_cell_average_python_uses_volume_weighted_positive_cells(): + sim = nmag.Simulation(name="mvp") + cell_h = np.asarray( + [ + [1.0, 2.0, 3.0], + [5.0, 7.0, 11.0], + [13.0, 17.0, 19.0], + ], + dtype=float, + ) + volumes = np.asarray([2.0, 3.0, 5.0], dtype=float) + + expected = np.dot(volumes, cell_h) / float(np.sum(volumes)) + np.testing.assert_allclose( + sim._demag_cell_field_average_python(cell_h, volumes), + expected, + rtol=1e-15, + atol=0.0, + ) + + mixed_volumes = np.asarray([2.0, 0.0, 5.0], dtype=float) + expected_mixed = ( + np.sum(cell_h[[0, 2]] * mixed_volumes[[0, 2], np.newaxis], axis=0) + / float(np.sum(mixed_volumes[[0, 2]])) + ) + np.testing.assert_allclose( + sim._demag_cell_field_average_python(cell_h, mixed_volumes), + expected_mixed, + rtol=1e-15, + atol=0.0, + ) + + +def test_demag_matches_legacy_two_tetra_nonuniform_magnetisation(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + mesh_path = tmp_path / "mesh.nmesh" + helpers.write_two_tetra_mesh(mesh_path) + + material = nmag.MagMaterial( + name="Py", + Ms=nmag.SI(1.0e6, "A/m"), + exchange_coupling=nmag.SI(13.0e-12, "J/m"), + ) + sim = nmag.Simulation(name="mvp") + sim.load_mesh( + str(mesh_path), + [("generated", material)], + unit_length=nmag.SI(1e-9, "m"), + ) + sim.set_m( + lambda point: [1.0, 0.0, 0.0] + if point[0] <= 5.0e-10 + else [0.0, 1.0, 0.0] + ) + + h_demag = np.asarray(sim.get_subfield("H_demag")) + expected_legacy = np.asarray([ + [-77328.546875, -61685.9765625, -22579.560546875], + [-54665.6640625, -39023.09765625, 83.321533203125], + [-54665.6640625, -39023.09765625, 83.321533203125], + [-54665.6640625, -39023.09765625, 83.321533203125], + [-43334.22265625, -27691.654296875, 11414.7626953125], + ]) + + assert h_demag.shape == (5, 3) + np.testing.assert_allclose(h_demag, expected_legacy, rtol=1e-8, atol=5e-3) + + +def test_demag_reuses_mesh_only_bem_cache_when_magnetisation_changes( + tmp_path, + monkeypatch, +): + monkeypatch.chdir(tmp_path) + mesh_path = tmp_path / "mesh.nmesh" + helpers.write_two_tetra_mesh(mesh_path) + + material = nmag.MagMaterial( + name="Py", + Ms=nmag.SI(1.0e6, "A/m"), + exchange_coupling=nmag.SI(13.0e-12, "J/m"), + ) + sim = nmag.Simulation(name="mvp") + sim.load_mesh(str(mesh_path), [("generated", material)], unit_length=nmag.SI(1e-9, "m")) + sim.set_m([1.0, 0.0, 0.0]) + + calls = [] + original_build_bem = sim._build_lindholm_bem_matrix + + def counting_build_bem(points, simplices, boundary_faces): + calls.append(len(boundary_faces)) + return original_build_bem(points, simplices, boundary_faces) + + monkeypatch.setattr(sim, "_build_lindholm_bem_matrix", counting_build_bem) + + fem_builds = [] + original_fem_geometry = sim._demag_fem_geometry_for_mesh + + def counting_fem_geometry(points, simplices): + if sim._demag_fem_geometry_cache is None: + fem_builds.append(len(simplices)) + return original_fem_geometry(points, simplices) + + monkeypatch.setattr(sim, "_demag_fem_geometry_for_mesh", counting_fem_geometry) + + ms_builds = [] + original_simplex_material_ms = sim._simplex_material_ms + + def counting_simplex_material_ms(region_id): + ms_builds.append(region_id) + return original_simplex_material_ms(region_id) + + monkeypatch.setattr(sim, "_simplex_material_ms", counting_simplex_material_ms) + + first_h_demag = np.asarray(sim.get_subfield("H_demag")) + sim.set_m([0.0, 1.0, 0.0]) + second_h_demag = np.asarray(sim.get_subfield("H_demag")) + + assert calls == [6] + assert fem_builds == [2] + assert ms_builds == [1] + assert not np.allclose(first_h_demag, second_h_demag) + + +def test_simplex_material_ms_values_fast_path_for_uniform_regions( + tmp_path, + monkeypatch, +): + monkeypatch.chdir(tmp_path) + mesh_path = tmp_path / "mesh.nmesh" + helpers.write_two_tetra_mesh(mesh_path) + + material = nmag.MagMaterial( + name="Py", + Ms=nmag.SI(1.0e6, "A/m"), + exchange_coupling=nmag.SI(13.0e-12, "J/m"), + ) + sim = nmag.Simulation(name="mvp") + sim.load_mesh(str(mesh_path), [("generated", material)], unit_length=nmag.SI(1e-9, "m")) + + def fail_unique(*args, **kwargs): + raise AssertionError("uniform regions should not call np.unique") + + monkeypatch.setattr(nmag_sim.np, "unique", fail_unique) + + np.testing.assert_allclose( + sim._simplex_material_ms_values([1, 1]), + [1.0e6, 1.0e6], + ) + + +def test_simplex_material_ms_values_maps_mixed_regions(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + 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], + [1.0, 1.0, 1.0], + ], + simplices_indices=[ + [0, 1, 2, 3], + [1, 2, 3, 4], + ], + simplices_regions=[1, 2], + ) + mesh_path = tmp_path / "mesh.nmesh" + mesh.save(mesh_path) + + material_a = nmag.MagMaterial( + name="A", + Ms=nmag.SI(1.0e6, "A/m"), + exchange_coupling=nmag.SI(13.0e-12, "J/m"), + ) + material_b = nmag.MagMaterial( + name="B", + Ms=nmag.SI(2.0e6, "A/m"), + exchange_coupling=nmag.SI(13.0e-12, "J/m"), + ) + sim = nmag.Simulation(name="mvp") + sim.load_mesh( + str(mesh_path), + [("region-a", material_a), ("region-b", material_b)], + unit_length=nmag.SI(1e-9, "m"), + ) + + np.testing.assert_allclose( + sim._simplex_material_ms_values([1, 2]), + [1.0e6, 2.0e6], + ) + + diff --git a/tests/nmag/test_public_rust_geometry.py b/tests/nmag/test_public_rust_geometry.py new file mode 100644 index 0000000..b05a538 --- /dev/null +++ b/tests/nmag/test_public_rust_geometry.py @@ -0,0 +1,225 @@ +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import public_api_support_module as helpers +import pytest + +import nmag +from nmag.simulation import ( + _oriented_boundary_faces, +) + +FIXTURES_DIR = Path(__file__).resolve().parents[1] / "fixtures" +SPHERE1_MESH = FIXTURES_DIR / "nmag_doc_example1" / "sphere1.nmesh.h5" + + +def test_probe_geometry_rust_backend_matches_python(tmp_path, monkeypatch): + pytest.importorskip("nmag_accel") + monkeypatch.chdir(tmp_path) + mesh_path = tmp_path / "mesh.nmesh" + helpers.write_two_tetra_mesh(mesh_path) + + material = nmag.MagMaterial( + name="Py", + Ms=nmag.SI(1.0e6, "A/m"), + exchange_coupling=nmag.SI(13.0e-12, "J/m"), + ) + sim = nmag.Simulation(name="mvp") + sim.load_mesh(str(mesh_path), [("generated", material)], unit_length=nmag.SI(1e-9, "m")) + sim.set_m([1.0, 0.0, 0.0]) + nodal_values = np.asarray(sim.get_subfield("H_demag")) + + python_geometry = sim._probe_tetrahedral_geometry_python() + rust_geometry = sim._probe_tetrahedral_geometry_rust() + + np.testing.assert_array_equal(rust_geometry[0], python_geometry[0]) + for rust_array, python_array in zip(rust_geometry[1:], python_geometry[1:], strict=True): + np.testing.assert_allclose(rust_array, python_array, rtol=1e-12, atol=1e-14) + + sim._probe_geometry_cache_token = None + sim._probe_tetrahedral_cache = None + probe = np.asarray([0.5e-9, 0.0, 0.0]) + optimized = sim._probe_tetrahedral_field(probe, nodal_values) + reference = sim._probe_tetrahedral_field_reference(probe, nodal_values) + np.testing.assert_allclose(optimized, reference, rtol=0.0, atol=1.0e-9) + assert sim.last_probe_timings_seconds["tetrahedral_geometry_build:rust"] >= 0.0 + + +def test_demag_boundary_face_rust_backend_matches_python(tmp_path, monkeypatch): + rust_accel = pytest.importorskip("nmag_accel") + monkeypatch.chdir(tmp_path) + mesh_path = tmp_path / "mesh.nmesh" + helpers.write_two_tetra_mesh(mesh_path) + + material = nmag.MagMaterial( + name="Py", + Ms=nmag.SI(1.0e6, "A/m"), + exchange_coupling=nmag.SI(13.0e-12, "J/m"), + ) + sim = nmag.Simulation(name="mvp", config=nmag.NmagConfig(accelerator="rust")) + sim.load_mesh(str(mesh_path), [("generated", material)], unit_length=nmag.SI(1e-9, "m")) + + points = np.asarray(sim.mesh.points, dtype=float) + simplices = np.asarray(sim.mesh.simplices, dtype=int) + python_faces = _oriented_boundary_faces(points, simplices) + rust_faces = sim._boundary_faces_for_demag_mesh_rust(points, simplices) + + assert rust_faces == python_faces + + raw_owners, raw_faces = rust_accel.build_oriented_boundary_faces( + np.asarray(points, dtype=np.float64), + np.asarray(simplices, dtype=np.int64), + ) + assert raw_owners.dtype == np.int64 + assert raw_faces.dtype == np.int64 + assert np.asarray(raw_faces).shape == (len(python_faces), 3) + assert [ + (int(owner), tuple(int(point_index) for point_index in face)) + for owner, face in zip(np.asarray(raw_owners), np.asarray(raw_faces), strict=True) + ] == python_faces + + selected_faces = sim._boundary_faces_for_demag_mesh(points, simplices) + + assert selected_faces == python_faces + assert sim._demag_boundary_faces_cache == python_faces + + +def test_demag_probe_returns_none_outside_tetrahedral_domain(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + mesh_path = tmp_path / "mesh.nmesh" + helpers.write_single_region_mesh(mesh_path) + + material = nmag.MagMaterial( + name="Py", + Ms=nmag.SI(1.0e6, "A/m"), + exchange_coupling=nmag.SI(13.0e-12, "J/m"), + ) + sim = nmag.Simulation(name="mvp", config=nmag.NmagConfig(accelerator="rust")) + sim.load_mesh(str(mesh_path), [("generated", material)], unit_length=nmag.SI(1e-9, "m")) + sim.set_m([1.0, 0.0, 0.0]) + + assert sim.probe_subfield_siv("H_demag", [0.5e-9, 0.5e-9, 0.5e-9]) is None + + +def test_demag_nodal_average_preserves_legacy_volume_weights(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + mesh_path = tmp_path / "mesh.nmesh" + helpers.write_two_tetra_mesh(mesh_path) + + material = nmag.MagMaterial( + name="Py", + Ms=nmag.SI(1.0e6, "A/m"), + exchange_coupling=nmag.SI(13.0e-12, "J/m"), + ) + sim = nmag.Simulation(name="mvp") + sim.load_mesh(str(mesh_path), [("generated", material)], unit_length=nmag.SI(1e-9, "m")) + sim.set_m([1.0, 0.0, 0.0]) + + h_demag = np.asarray(sim.get_subfield("H_demag")) + expected_legacy = np.asarray([ + [-116434.9609375, -22579.560546875, -22579.560546875], + [-93772.078125, 83.321533203125, 83.321533203125], + [-93772.078125, 83.321533203125, 83.321533203125], + [-93772.078125, 83.321533203125, 83.321533203125], + [-82440.640625, 11414.7626953125, 11414.7626953125], + ]) + + assert h_demag.shape == (5, 3) + np.testing.assert_allclose(h_demag, expected_legacy, rtol=1e-8, atol=5e-3) + assert sim._incident_cell_volume_sums_cache is not None + first_cache = sim._incident_cell_volume_sums_cache + + cached_weights = sim._incident_cell_volume_sums( + sim._mesh_points(), + np.asarray(sim.mesh.simplices, dtype=int), + sim._demag_volumes_cache, + ) + + assert sim._incident_cell_volume_sums_cache is first_cache + assert cached_weights is first_cache[3] + + sim._invalidate_demag(clear_geometry=True) + assert sim._incident_cell_volume_sums_cache is None + + +def test_demag_nodal_recovery_rust_backend_matches_python(tmp_path, monkeypatch): + rust_accel = pytest.importorskip("nmag_accel") + monkeypatch.chdir(tmp_path) + mesh_path = tmp_path / "mesh.nmesh" + helpers.write_two_tetra_mesh(mesh_path) + + material = nmag.MagMaterial( + name="Py", + Ms=nmag.SI(1.0e6, "A/m"), + exchange_coupling=nmag.SI(13.0e-12, "J/m"), + ) + sim = nmag.Simulation(name="mvp", config=nmag.NmagConfig(accelerator="rust")) + sim.load_mesh(str(mesh_path), [("generated", material)], unit_length=nmag.SI(1e-9, "m")) + sim.set_m([1.0, 0.0, 0.0]) + + points = sim._mesh_points() + simplices = np.asarray(sim.mesh.simplices, dtype=int) + phi, _rho, volumes = sim._get_demag_auxiliary_fields() + cell_h = sim._cell_demag_from_potential(points, simplices, phi) + weights = sim._incident_cell_volume_sums(points, simplices, volumes) + reference = sim._recover_demag_nodal_field_python( + simplices, + volumes, + cell_h, + weights, + len(points), + ) + + raw_rust = rust_accel.recover_demag_nodal_field( + np.asarray(simplices, dtype=np.int64), + np.asarray(volumes, dtype=np.float64), + np.asarray(cell_h, dtype=np.float64), + np.asarray(weights, dtype=np.float64), + len(points), + ) + assert isinstance(raw_rust, np.ndarray) + assert raw_rust.dtype == np.float64 + np.testing.assert_allclose(raw_rust, reference, rtol=1e-12, atol=1e-9) + + def fail_python_recovery(*_args, **_kwargs): + raise AssertionError("forced rust nodal recovery should not use Python helper") + + sim._invalidate_demag(clear_geometry=False) + monkeypatch.setattr(sim, "_recover_demag_nodal_field_python", fail_python_recovery) + rust_selected = np.asarray(sim.get_subfield("H_demag")) + + np.testing.assert_allclose(rust_selected, reference, rtol=1e-12, atol=1e-9) + + +def test_demag_cell_average_rust_backend_matches_python(tmp_path, monkeypatch): + pytest.importorskip("nmag_accel") + monkeypatch.chdir(tmp_path) + mesh_path = tmp_path / "mesh.nmesh" + helpers.write_two_tetra_mesh(mesh_path) + + material = nmag.MagMaterial( + name="Py", + Ms=nmag.SI(1.0e6, "A/m"), + exchange_coupling=nmag.SI(13.0e-12, "J/m"), + ) + sim = nmag.Simulation(name="mvp", config=nmag.NmagConfig(accelerator="rust")) + sim.load_mesh(str(mesh_path), [("generated", material)], unit_length=nmag.SI(1e-9, "m")) + sim.set_m(lambda point: [1.0, 0.0, 0.0] if point[0] <= 5.0e-10 else [0.0, 1.0, 0.0]) + + cell_h, volumes = sim._get_demag_cell_field() + reference = sim._demag_cell_field_average_python(cell_h, volumes) + raw_rust = sim._demag_cell_field_average_rust(cell_h, volumes) + + assert isinstance(raw_rust, np.ndarray) + assert raw_rust.dtype == np.float64 + np.testing.assert_allclose(raw_rust, reference, rtol=1e-12, atol=1e-9) + + def fail_python_average(*_args, **_kwargs): + raise AssertionError("forced rust cell average should not use Python helper") + + monkeypatch.setattr(sim, "_demag_cell_field_average_python", fail_python_average) + selected_average = sim._demag_cell_field_average() + + np.testing.assert_allclose(selected_average, reference, rtol=1e-12, atol=1e-9) diff --git a/tests/nmag/test_public_workflow.py b/tests/nmag/test_public_workflow.py new file mode 100644 index 0000000..9078d3e --- /dev/null +++ b/tests/nmag/test_public_workflow.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +from pathlib import Path + +import h5py +import numpy as np +import public_api_support_module as helpers +import pytest + +import nmag + +FIXTURES_DIR = Path(__file__).resolve().parents[1] / "fixtures" +SPHERE1_MESH = FIXTURES_DIR / "nmag_doc_example1" / "sphere1.nmesh.h5" + + + +def test_simulation_mvp_load_set_save_and_probe(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + mesh_path = tmp_path / "mesh.nmesh" + helpers.write_single_region_mesh(mesh_path) + + material = nmag.MagMaterial( + name="Py", + Ms=nmag.SI(1e6, "A/m"), + exchange_coupling=nmag.SI(13.0e-12, "J/m"), + ) + + sim = nmag.Simulation(name="mvp") + sim.load_mesh(str(mesh_path), [("generated", material)], unit_length=nmag.SI(1e-9, "m")) + assert sim.get_subfield_average("H_anis") is None + assert "constant_average" not in sim.last_subfield_average_timings_seconds + + sim.set_m([1.0, 0.0, 0.0]) + sim.set_H_ext([1.0, 2.0, 3.0], nmag.SI("A/m")) + sim.save_data(fields="all") + + assert sim.probe_subfield_siv("H_ext", [0.0, 0.0, 0.0]) == [1.0, 2.0, 3.0] + assert sim.probe_subfield_siv("m", [0.0, 0.0, 0.0]) == [1.0, 0.0, 0.0] + + with h5py.File(tmp_path / "mvp_dat.h5") as h5: + assert set(h5["fields"].keys()) == { + "E_anis", + "E_demag", + "E_exch", + "E_ext", + "E_total", + "H_anis", + "H_demag", + "H_ext", + "H_exch", + "H_total", + "M", + "dmdt", + "m", + "pin", + "phi", + "rho", + } + assert h5["mesh/points"].shape == (4, 3) + assert h5["fields/m"].shape == (4, 3) + assert h5["fields/M"].shape == (4, 3) + assert h5["fields/dmdt"].shape == (4, 3) + assert h5["fields/H_ext"].shape == (4, 3) + assert h5["fields/H_total"].shape == (4, 3) + np.testing.assert_allclose( + h5["fields/H_ext"][:], + np.tile([1.0, 2.0, 3.0], (4, 1)), + ) + assert h5["fields/H_demag"].shape == (4, 3) + assert h5["fields/E_demag"].shape == (4,) + assert h5["fields/pin"].shape == (4,) + assert h5["fields/phi"].shape == (4,) + assert h5["fields/rho"].shape == (4,) + + assert (tmp_path / "mvp_dat.ndt").exists() + header = (tmp_path / "mvp_dat.ndt").read_text(encoding="utf-8").splitlines()[1].split("\t") + for column in [ + "last_step_dt", + "M_Py_0", + "H_total_Py_0", + "H_anis_Py_0", + "H_exch_Py_0", + "E_total_Py", + "E_ext_Py", + "E_demag_Py", + "pin", + "phi", + "rho", + "maxangle_m_Py", + ]: + assert column in header + + +def test_simulation_rejects_unimplemented_constructor_options(): + with pytest.raises(NotImplementedError, match="Periodic boundary"): + nmag.Simulation(periodic_bc=[1, 0, 0]) + + with pytest.raises(NotImplementedError, match="Spin-transfer torque"): + nmag.Simulation(do_sl_stt=True) + + +def test_demag_disabled_does_not_advertise_or_calculate_demag(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + mesh_path = tmp_path / "mesh.nmesh" + helpers.write_single_region_mesh(mesh_path) + material = nmag.MagMaterial(name="Py", Ms=nmag.SI(1.0e6, "A/m")) + sim = nmag.Simulation(name="no-demag", do_demag=False) + sim.load_mesh( + str(mesh_path), + [("generated", material)], + unit_length=nmag.SI(1e-9, "m"), + ) + sim.set_m([1.0, 0.0, 0.0]) + + assert not sim.is_subfield_available("H_demag") + assert not sim.is_subfield_available("phi") + assert not sim.is_subfield_available("rho") + assert not sim.is_subfield_available("E_demag") + with pytest.raises(KeyError, match="demag is disabled"): + sim.get_subfield("H_demag") + + +def test_load_mesh_rejects_missing_material_region(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + mesh_path = tmp_path / "mesh.nmesh" + helpers.write_two_region_mesh(mesh_path) + material = nmag.MagMaterial(name="Py", Ms=nmag.SI(1.0e6, "A/m")) + sim = nmag.Simulation(name="missing-region") + + with pytest.raises(ValueError, match=r"mesh=\[1, 2\], configured=\[1\]"): + sim.load_mesh( + str(mesh_path), + [("region-one", material)], + unit_length=nmag.SI(1e-9, "m"), + ) + + diff --git a/tests/nmag/test_restart_checkpoints.py b/tests/nmag/test_restart_checkpoints.py new file mode 100644 index 0000000..da24ed2 --- /dev/null +++ b/tests/nmag/test_restart_checkpoints.py @@ -0,0 +1,300 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any, cast + +import h5py +import numpy as np +import pytest + +import nmag +import nmesh +from nmag import checkpoint as checkpoint_module + + +def _write_mesh(path: Path, *, shifted: bool = False) -> None: + offset = 0.25 if shifted else 0.0 + mesh = nmesh.mesh_from_points_and_simplices( + points=[ + [offset, 0.0, 0.0], + [offset + 1.0, 0.0, 0.0], + [offset, 1.0, 0.0], + [offset, 0.0, 1.0], + ], + simplices_indices=[[0, 1, 2, 3]], + simplices_regions=[1], + ) + mesh.save(str(path)) + + +def _simulation( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + *, + name: str, + damping: float = 0.1, + shifted_mesh: bool = False, + current_density: bool = True, + anisotropy: object | None = None, + config: nmag.NmagConfig | None = None, +) -> nmag.Simulation: + monkeypatch.chdir(tmp_path) + mesh_path = tmp_path / f"{name}.nmesh.h5" + _write_mesh(mesh_path, shifted=shifted_mesh) + material = nmag.MagMaterial( + name="Py", + Ms=nmag.SI(8.0e5, "A/m"), + exchange_coupling=nmag.SI(0.0, "J/m"), + llg_damping=damping, + llg_polarisation=0.7, + llg_xi=0.02, + do_precession=False, + anisotropy=anisotropy, # type: ignore[arg-type] + ) + simulation = nmag.Simulation(name=name, do_demag=False, config=config) + simulation.load_mesh( + str(mesh_path), + [("magnetic", material)], + unit_length=nmag.SI(1.0e-9, "m"), + ) + simulation.set_m(lambda point: [1.0, 0.1 * point[0] / 1.0e-9, 0.05]) + simulation.set_pinning([1.0, 0.0, 0.5, 1.0]) + simulation.set_H_ext([1.0e4, 2.0e4, 0.0], nmag.SI("A/m")) + if current_density: + simulation.set_current_density([1.0e12, 0.0, 0.0], nmag.SI("A/m^2")) + simulation.set_params( + stopping_dm_dt=nmag.SI(2.0e8, "1/s"), + ts_rel_tol=1.0e-8, + ts_abs_tol=1.0e-8, + ts_max_step=nmag.SI(1.0e-15, "s"), + ) + return simulation + + +def _state_snapshot(simulation: nmag.Simulation) -> dict[str, object]: + return { + "m": np.array(simulation._fields["m"], copy=True), + "pin": np.array(simulation._fields["pin"], copy=True), + "H_ext": np.array(simulation._fields["H_ext"], copy=True), + "current_density": ( + None + if "current_density" not in simulation._fields + else np.array(simulation._fields["current_density"], copy=True) + ), + "clock": simulation.clock.checkpoint_state(), + "config": simulation.integrator_config, + "stopping_dm_dt": simulation.stopping_dm_dt, + "max_time": simulation.max_time_reached, + "max_dm_dt": simulation.max_dm_dt, + "convergence": simulation.convergence.checkpoint_state(), + } + + +def test_full_restart_round_trip_restores_dynamic_state( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + source = _simulation(tmp_path, monkeypatch, name="restart-source") + source.advance_time(nmag.SI(2.0e-15, "s")) + source.convergence.check(source.step, source.stopping_dm_dt * 2.0, source.stopping_dm_dt) + source.max_dm_dt = source.stopping_dm_dt * 2.0 + expected = _state_snapshot(source) + checkpoint = source.save_restart_file(tmp_path / "state.h5") + + with h5py.File(str(checkpoint), "r") as h5: + dataset = h5["state/m"] + assert np.asarray(cast(Any, dataset)).dtype == np.dtype(np.float64) + assert cast(Any, dataset).chunks is None + assert cast(Any, dataset).compression is None + + target = _simulation(tmp_path, monkeypatch, name="restart-target") + target.reinitialise() + assert target._integrator is not None + target.load_restart_file(checkpoint) + + np.testing.assert_array_equal(target._fields["m"], expected["m"]) + np.testing.assert_array_equal(target._fields["pin"], expected["pin"]) + np.testing.assert_array_equal(target._fields["H_ext"], expected["H_ext"]) + np.testing.assert_array_equal(target._fields["current_density"], expected["current_density"]) + assert target.clock.checkpoint_state() == expected["clock"] + assert target.integrator_config == expected["config"] + assert target.stopping_dm_dt == expected["stopping_dm_dt"] + assert target.max_time_reached == expected["max_time"] + assert target.max_dm_dt == expected["max_dm_dt"] + assert target.convergence.checkpoint_state() == expected["convergence"] + assert target._integrator is None + assert target._integrator_is_stale is True + assert target.last_integrator_stats.status == "restarted" + + +def test_magnetisation_only_transfer_allows_different_material_dynamics( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + source = _simulation(tmp_path, monkeypatch, name="transfer-source") + source.advance_time(nmag.SI(1.0e-15, "s")) + checkpoint = source.save_restart_file(tmp_path / "transfer.h5") + expected_m = np.array(source._fields["m"], copy=True) + + target = _simulation( + tmp_path, + monkeypatch, + name="transfer-target", + damping=0.25, + current_density=False, + ) + with pytest.raises(ValueError, match="materials"): + target.load_restart_file(checkpoint) + + target.load_m_from_h5file(checkpoint) + + np.testing.assert_array_equal(target._fields["m"], expected_m) + assert "current_density" not in target._fields + + +def test_full_restart_clears_current_density_absent_from_checkpoint( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + source = _simulation(tmp_path, monkeypatch, name="no-current", current_density=False) + checkpoint = source.save_restart_file(tmp_path / "no-current.h5") + target = _simulation(tmp_path, monkeypatch, name="with-current") + + target.load_restart_file(checkpoint) + + assert "current_density" not in target._fields + + +def test_full_restart_validates_anisotropy_but_m_transfer_does_not( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + source_anisotropy = nmag.uniaxial_anisotropy([0, 0, 1], nmag.SI(1.0e5, "J/m^3")) + source = _simulation( + tmp_path, + monkeypatch, + name="anisotropy-source", + anisotropy=source_anisotropy, + ) + checkpoint = source.save_restart_file(tmp_path / "anisotropy.h5") + + compatible = _simulation( + tmp_path, + monkeypatch, + name="anisotropy-compatible", + anisotropy=nmag.uniaxial_anisotropy([0, 0, 1], nmag.SI(1.0e5, "J/m^3")), + ) + compatible.load_restart_file(checkpoint) + + incompatible = _simulation( + tmp_path, + monkeypatch, + name="anisotropy-incompatible", + anisotropy=nmag.uniaxial_anisotropy([0, 0, 1], nmag.SI(1.1e5, "J/m^3")), + ) + with pytest.raises(ValueError, match="materials"): + incompatible.load_restart_file(checkpoint) + + incompatible.load_m_from_h5file(checkpoint) + np.testing.assert_array_equal(incompatible._fields["m"], source._fields["m"]) + + +@pytest.mark.parametrize("kind", ["version", "shape", "nonfinite"]) +def test_invalid_checkpoint_does_not_mutate_target_state( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + kind: str, +) -> None: + source = _simulation(tmp_path, monkeypatch, name=f"invalid-source-{kind}") + checkpoint = source.save_restart_file(tmp_path / f"invalid-{kind}.h5") + with h5py.File(str(checkpoint), "r+") as h5: + if kind == "version": + h5.attrs["version"] = 99 + elif kind == "shape": + state = cast(Any, h5["state"]) + del state["pin"] + state.create_dataset("pin", data=np.ones(2)) + else: + cast(Any, h5["state/m"])[0, 0] = np.nan + + target = _simulation(tmp_path, monkeypatch, name=f"invalid-target-{kind}") + before = _state_snapshot(target) + with pytest.raises(ValueError): + target.load_restart_file(checkpoint) + + np.testing.assert_array_equal(target._fields["m"], before["m"]) + np.testing.assert_array_equal(target._fields["pin"], before["pin"]) + assert target.clock.checkpoint_state() == before["clock"] + + +def test_mesh_mismatch_is_rejected_without_mutation( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + source = _simulation(tmp_path, monkeypatch, name="mesh-source") + checkpoint = source.save_restart_file(tmp_path / "mesh.h5") + target = _simulation(tmp_path, monkeypatch, name="mesh-target", shifted_mesh=True) + before = _state_snapshot(target) + + with pytest.raises(ValueError, match="mesh"): + target.load_m_from_h5file(checkpoint) + + np.testing.assert_array_equal(target._fields["m"], before["m"]) + + +def test_atomic_save_preserves_existing_checkpoint_after_replace_failure( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + simulation = _simulation(tmp_path, monkeypatch, name="atomic") + checkpoint = simulation.save_restart_file(tmp_path / "atomic.h5") + original_m = np.array(simulation._fields["m"], copy=True) + simulation.set_m([0.0, 0.0, 1.0]) + + def fail_replace(_source: str, _target: str) -> None: + raise OSError("replace failed") + + monkeypatch.setattr(checkpoint_module.os, "replace", fail_replace) + with pytest.raises(OSError, match="replace failed"): + simulation.save_restart_file(checkpoint) + + restored = _simulation(tmp_path, monkeypatch, name="atomic-restored") + restored.load_m_from_h5file(checkpoint) + np.testing.assert_array_equal(restored._fields["m"], original_m) + assert not checkpoint.with_name(f".{checkpoint.name}.tmp").exists() + + +@pytest.mark.parametrize("backend", ["python", "rust"]) +def test_checkpoint_resume_matches_uninterrupted_stt_dynamics( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + backend: str, +) -> None: + if backend == "rust": + pytest.importorskip("nmag_accel") + config = nmag.NmagConfig(accelerator="rust" if backend == "rust" else "off") + uninterrupted = _simulation( + tmp_path, monkeypatch, name=f"uninterrupted-{backend}", config=config + ) + uninterrupted.advance_time(nmag.SI(2.0e-15, "s")) + + staged = _simulation(tmp_path, monkeypatch, name=f"staged-{backend}", config=config) + staged.advance_time(nmag.SI(1.0e-15, "s")) + checkpoint = staged.save_restart_file(tmp_path / f"resume-{backend}.h5") + resumed = _simulation(tmp_path, monkeypatch, name=f"resumed-{backend}", config=config) + resumed.load_restart_file(checkpoint) + resumed.advance_time(nmag.SI(2.0e-15, "s")) + + np.testing.assert_allclose(resumed._fields["m"], uninterrupted._fields["m"], rtol=1.0e-8, atol=1.0e-10) + np.testing.assert_array_equal(resumed._fields["m"][1], staged._fields["m"][1]) + + +def test_save_restart_action_writes_default_checkpoint( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + simulation = _simulation(tmp_path, monkeypatch, name="scheduled-restart") + + simulation.action_abbreviations["save_restart"](simulation) + + assert simulation.get_restart_file_name().exists() diff --git a/tests/nmag/test_rust_accelerator_api.py b/tests/nmag/test_rust_accelerator_api.py new file mode 100644 index 0000000..9bcc56a --- /dev/null +++ b/tests/nmag/test_rust_accelerator_api.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import numpy as np +import pytest + +nmag_accel = pytest.importorskip("nmag_accel") + +POINTS = np.asarray( + [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], + dtype=np.float64, +) +SIMPLICES = np.asarray([[0, 1, 2, 3]], dtype=np.int64) +FACE_NODES = np.asarray([[1, 2, 3], [0, 3, 2], [0, 1, 3], [0, 2, 1]], dtype=np.int64) +BOUNDARY_NODES = np.arange(4, dtype=np.int64) +LOCAL_INDEX_BY_POINT = np.arange(4, dtype=np.int64) + + +def test_public_api_surface() -> None: + expected_functions = { + "build_demag_fem_divergence", + "build_demag_fem_geometry", + "build_lindholm_bem_matrix", + "apply_lindholm_bem_matrix_free", + "build_oriented_boundary_faces", + "build_probe_tetrahedral_geometry", + "demag_cell_field_average", + "llg_rhs", + "llg_rhs_heterogeneous", + "llg_rhs_stt_heterogeneous", + "parallel_runtime_info", + "integrate_llg_bdf", + "maxangle_between_edges", + "recover_demag_nodal_field", + } + + assert nmag_accel.API_VERSION == 2 + assert all(callable(getattr(nmag_accel, name)) for name in expected_functions) + + +def test_mesh_geometry_functions_accept_a_unit_tetrahedron() -> None: + stiffness, gradients, volumes = nmag_accel.build_demag_fem_geometry(POINTS, SIMPLICES) + np.testing.assert_allclose(volumes, [1.0 / 6.0]) + np.testing.assert_allclose(np.sum(gradients[0], axis=0), np.zeros(3), atol=1.0e-15) + np.testing.assert_allclose(stiffness, stiffness.T) + + owners, faces = nmag_accel.build_oriented_boundary_faces(POINTS, SIMPLICES) + np.testing.assert_array_equal(owners, np.zeros(4, dtype=np.int64)) + np.testing.assert_array_equal(faces, FACE_NODES) + + probe_geometry = nmag_accel.build_probe_tetrahedral_geometry(POINTS, SIMPLICES) + valid_simplices, origins, inverse_matrices, lower, upper = probe_geometry + np.testing.assert_array_equal(valid_simplices, SIMPLICES) + np.testing.assert_allclose(origins, [[0.0, 0.0, 0.0]]) + np.testing.assert_allclose(inverse_matrices, [np.eye(3)]) + np.testing.assert_allclose(lower, [[0.0, 0.0, 0.0]]) + np.testing.assert_allclose(upper, [[1.0, 1.0, 1.0]]) + + +def test_fem_field_functions_accept_simple_inputs() -> None: + _, gradients, volumes = nmag_accel.build_demag_fem_geometry(POINTS, SIMPLICES) + m = np.tile([1.0, 0.0, 0.0], (4, 1)) + divergence = nmag_accel.build_demag_fem_divergence( + SIMPLICES, + gradients, + volumes, + m, + np.asarray([2.0]), + 4, + ) + np.testing.assert_allclose(divergence, [-1.0 / 3.0, 1.0 / 3.0, 0.0, 0.0]) + + cell_h = np.asarray([[2.0, -3.0, 5.0]]) + nodal_h = nmag_accel.recover_demag_nodal_field( + SIMPLICES, + volumes, + cell_h, + np.full(4, volumes[0]), + 4, + ) + np.testing.assert_allclose(nodal_h, np.tile(cell_h, (4, 1))) + + average = nmag_accel.demag_cell_field_average( + np.asarray([[1.0, 2.0, 3.0], [5.0, 6.0, 7.0]]), + np.asarray([1.0, 3.0]), + ) + np.testing.assert_allclose(average, [4.0, 5.0, 6.0]) + + +def test_llg_maxangle_and_lindholm_accept_simple_inputs() -> None: + m = np.asarray([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]) + h_total = np.asarray([[0.0, 1.0, 0.0], [1.0, 0.0, 0.0]]) + rhs = nmag_accel.llg_rhs( + m, + h_total, + np.ones(2), + np.ones(2), + 1.0, + 0.0, + 0.0, + ) + np.testing.assert_allclose(rhs, [[0.0, 0.0, 1.0], [0.0, 0.0, -1.0]]) + assert nmag_accel.maxangle_between_edges(m, np.asarray([[0, 1]])) == pytest.approx(90.0) + + bem = nmag_accel.build_lindholm_bem_matrix( + POINTS, + SIMPLICES, + FACE_NODES, + BOUNDARY_NODES, + LOCAL_INDEX_BY_POINT, + ) + assert bem.shape == (4, 4) + assert np.all(np.isfinite(bem)) + + +def test_diffsol_bdf_keeps_a_zero_torque_state_fixed() -> None: + result = nmag_accel.integrate_llg_bdf( + np.asarray([1.0, 0.0, 0.0]), + np.zeros((3, 3)), + np.asarray([1.0e5, 0.0, 0.0]), + np.ones(1), + -1.0, + -0.5, + 0.0, + 0.0, + 1.0, + 1.0e-6, + 1.0e-6, + 1.0e-15, + 1.0e-3, + 1.0, + 5, + 2, + 1000, + ) + + np.testing.assert_array_equal(result.state, [1.0, 0.0, 0.0]) + assert result.converged is True + assert result.accepted_steps == 10 + assert result.max_dm_dt == 0.0 diff --git a/tests/nmag/test_rust_accelerator_invalid_indices.py b/tests/nmag/test_rust_accelerator_invalid_indices.py new file mode 100644 index 0000000..808d627 --- /dev/null +++ b/tests/nmag/test_rust_accelerator_invalid_indices.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import pytest +from rust_accelerator_validation_support import ( + BOUNDARY_NODES, + FACE_NODES, + LOCAL_INDEX_BY_POINT, + POINTS, + SIMPLICES, + _replace_simplex_index, + nmag_accel, +) + + +@pytest.mark.parametrize( + "function_name", + [ + "build_demag_fem_geometry", + "build_demag_fem_divergence", + "recover_demag_nodal_field", + "build_oriented_boundary_faces", + "build_probe_tetrahedral_geometry", + "maxangle_between_edges", + "build_lindholm_bem_matrix", + ], +) +@pytest.mark.parametrize("index", [-1, 4], ids=["negative", "out-of-range"]) +def test_indexed_functions_reject_invalid_simplex_indices( + function_name: str, + index: int, +) -> None: + with pytest.raises(ValueError, match="simplex node"): + _replace_simplex_index(function_name, index) + + +@pytest.mark.parametrize("index", [-1, 4], ids=["negative", "out-of-range"]) +def test_lindholm_rejects_invalid_boundary_and_face_indices(index: int) -> None: + boundary_nodes = BOUNDARY_NODES.copy() + boundary_nodes[0] = index + with pytest.raises(ValueError, match="boundary node"): + nmag_accel.build_lindholm_bem_matrix( + POINTS, + SIMPLICES, + FACE_NODES, + boundary_nodes, + LOCAL_INDEX_BY_POINT, + ) + + face_nodes = FACE_NODES.copy() + face_nodes[0, 0] = index + with pytest.raises(ValueError, match="face node"): + nmag_accel.build_lindholm_bem_matrix( + POINTS, + SIMPLICES, + face_nodes, + BOUNDARY_NODES, + LOCAL_INDEX_BY_POINT, + ) + + +@pytest.mark.parametrize("local_index", [-1, 4], ids=["negative", "out-of-range"]) +def test_lindholm_rejects_invalid_local_boundary_indices(local_index: int) -> None: + local_indices = LOCAL_INDEX_BY_POINT.copy() + local_indices[1] = local_index + with pytest.raises(ValueError, match="face node"): + nmag_accel.build_lindholm_bem_matrix( + POINTS, + SIMPLICES, + FACE_NODES, + BOUNDARY_NODES, + local_indices, + ) diff --git a/tests/nmag/test_rust_accelerator_invalid_lengths.py b/tests/nmag/test_rust_accelerator_invalid_lengths.py new file mode 100644 index 0000000..fa74e41 --- /dev/null +++ b/tests/nmag/test_rust_accelerator_invalid_lengths.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +from collections.abc import Callable + +import numpy as np +import pytest +from rust_accelerator_validation_support import ( + BOUNDARY_NODES, + FACE_NODES, + LOCAL_INDEX_BY_POINT, + POINTS, + SIMPLICES, + InvalidCall, + nmag_accel, +) + +MISMATCHED_LENGTH_CALLS: tuple[InvalidCall, ...] = ( + ( + "lindholm point map", + lambda: nmag_accel.build_lindholm_bem_matrix( + POINTS, + SIMPLICES, + FACE_NODES, + BOUNDARY_NODES, + LOCAL_INDEX_BY_POINT[:-1], + ), + ValueError, + "one entry per point", + ), + ( + "fem divergence gradients", + lambda: nmag_accel.build_demag_fem_divergence( + SIMPLICES, + np.zeros((2, 4, 3)), + np.ones(1), + POINTS, + np.ones(1), + 4, + ), + ValueError, + r"gradients_by_cell must have shape \(n, 4, 3\)", + ), + ( + "fem divergence volumes", + lambda: nmag_accel.build_demag_fem_divergence( + SIMPLICES, + np.zeros((1, 4, 3)), + np.ones(2), + POINTS, + np.ones(1), + 4, + ), + ValueError, + "one value per simplex", + ), + ( + "fem divergence magnetisation", + lambda: nmag_accel.build_demag_fem_divergence( + SIMPLICES, + np.zeros((1, 4, 3)), + np.ones(1), + POINTS[:-1], + np.ones(1), + 4, + ), + ValueError, + r"m must have shape \(point_count, 3\)", + ), + ( + "fem divergence saturation", + lambda: nmag_accel.build_demag_fem_divergence( + SIMPLICES, + np.zeros((1, 4, 3)), + np.ones(1), + POINTS, + np.ones(2), + 4, + ), + ValueError, + "one value per simplex", + ), + ( + "nodal recovery volumes", + lambda: nmag_accel.recover_demag_nodal_field( + SIMPLICES, + np.ones(2), + np.ones((1, 3)), + np.ones(4), + 4, + ), + ValueError, + "one value per simplex", + ), + ( + "nodal recovery cells", + lambda: nmag_accel.recover_demag_nodal_field( + SIMPLICES, + np.ones(1), + np.ones((2, 3)), + np.ones(4), + 4, + ), + ValueError, + r"cell_h must have shape \(n, 3\)", + ), + ( + "nodal recovery weights", + lambda: nmag_accel.recover_demag_nodal_field( + SIMPLICES, + np.ones(1), + np.ones((1, 3)), + np.ones(3), + 4, + ), + ValueError, + "one value per point", + ), + ( + "cell average volumes", + lambda: nmag_accel.demag_cell_field_average(np.ones((2, 3)), np.ones(1)), + ValueError, + "one value per cell_h row", + ), + ( + "llg total field", + lambda: nmag_accel.llg_rhs( + POINTS, + POINTS[:-1], + np.ones(4), + np.ones(4), + 1.0, + 1.0, + 1.0, + ), + ValueError, + "same shape as m", + ), + ( + "llg pin", + lambda: nmag_accel.llg_rhs( + POINTS, + POINTS, + np.ones(3), + np.ones(4), + 1.0, + 1.0, + 1.0, + ), + ValueError, + "one value per mesh point", + ), + ( + "llg saturation", + lambda: nmag_accel.llg_rhs( + POINTS, + POINTS, + np.ones(4), + np.ones(3), + 1.0, + 1.0, + 1.0, + ), + ValueError, + "one value per mesh point", + ), +) + + +@pytest.mark.parametrize( + ("_label", "call", "error", "match"), + MISMATCHED_LENGTH_CALLS, + ids=[case[0] for case in MISMATCHED_LENGTH_CALLS], +) +def test_functions_reject_mismatched_lengths( + _label: str, + call: Callable[[], object], + error: type[Exception], + match: str, +) -> None: + with pytest.raises(error, match=match): + call() diff --git a/tests/nmag/test_rust_accelerator_invalid_shapes.py b/tests/nmag/test_rust_accelerator_invalid_shapes.py new file mode 100644 index 0000000..8911094 --- /dev/null +++ b/tests/nmag/test_rust_accelerator_invalid_shapes.py @@ -0,0 +1,219 @@ +from __future__ import annotations + +from collections.abc import Callable + +import numpy as np +import pytest +from rust_accelerator_validation_support import ( + BOUNDARY_NODES, + FACE_NODES, + LOCAL_INDEX_BY_POINT, + POINTS, + SIMPLICES, + InvalidCall, + nmag_accel, +) + +INVALID_RANK_AND_SHAPE_CALLS: tuple[InvalidCall, ...] = ( + ( + "lindholm rank", + lambda: nmag_accel.build_lindholm_bem_matrix( + POINTS.ravel(), + SIMPLICES, + FACE_NODES, + BOUNDARY_NODES, + LOCAL_INDEX_BY_POINT, + ), + TypeError, + "points", + ), + ( + "lindholm shape", + lambda: nmag_accel.build_lindholm_bem_matrix( + POINTS, + SIMPLICES, + FACE_NODES[:, :2], + BOUNDARY_NODES, + LOCAL_INDEX_BY_POINT, + ), + ValueError, + r"face_nodes must have shape \(n, 3\)", + ), + ( + "hierarchical lindholm point shape", + lambda: nmag_accel.build_lindholm_hmatrix( + POINTS[:, :2], + SIMPLICES, + FACE_NODES, + BOUNDARY_NODES, + LOCAL_INDEX_BY_POINT, + ), + ValueError, + r"points must have shape \(n, 3\)", + ), + ( + "hierarchical lindholm face shape", + lambda: nmag_accel.build_lindholm_hmatrix( + POINTS, + SIMPLICES, + FACE_NODES[:, :2], + BOUNDARY_NODES, + LOCAL_INDEX_BY_POINT, + ), + ValueError, + r"face_nodes must have shape \(n, 3\)", + ), + ( + "fem geometry rank", + lambda: nmag_accel.build_demag_fem_geometry(POINTS.ravel(), SIMPLICES), + TypeError, + "points", + ), + ( + "fem geometry shape", + lambda: nmag_accel.build_demag_fem_geometry(POINTS[:, :2], SIMPLICES), + ValueError, + r"points must have shape \(n, 3\)", + ), + ( + "fem divergence rank", + lambda: nmag_accel.build_demag_fem_divergence( + SIMPLICES, + np.zeros((1, 12)), + np.ones(1), + POINTS, + np.ones(1), + 4, + ), + TypeError, + "gradients_by_cell", + ), + ( + "fem divergence shape", + lambda: nmag_accel.build_demag_fem_divergence( + SIMPLICES, + np.zeros((1, 4, 2)), + np.ones(1), + POINTS, + np.ones(1), + 4, + ), + ValueError, + r"gradients_by_cell must have shape \(n, 4, 3\)", + ), + ( + "nodal recovery rank", + lambda: nmag_accel.recover_demag_nodal_field( + SIMPLICES, + np.ones(1), + np.ones(3), + np.ones(4), + 4, + ), + TypeError, + "cell_h", + ), + ( + "nodal recovery shape", + lambda: nmag_accel.recover_demag_nodal_field( + SIMPLICES, + np.ones(1), + np.ones((1, 2)), + np.ones(4), + 4, + ), + ValueError, + r"cell_h must have shape \(n, 3\)", + ), + ( + "cell average rank", + lambda: nmag_accel.demag_cell_field_average(np.ones(3), np.ones(1)), + TypeError, + "cell_h", + ), + ( + "cell average shape", + lambda: nmag_accel.demag_cell_field_average(np.ones((1, 2)), np.ones(1)), + ValueError, + r"cell_h must have shape \(n, 3\)", + ), + ( + "boundary faces rank", + lambda: nmag_accel.build_oriented_boundary_faces(POINTS.ravel(), SIMPLICES), + TypeError, + "points", + ), + ( + "boundary faces shape", + lambda: nmag_accel.build_oriented_boundary_faces(POINTS, SIMPLICES[:, :3]), + ValueError, + r"simplices must have shape \(n, 4\)", + ), + ( + "probe geometry rank", + lambda: nmag_accel.build_probe_tetrahedral_geometry(POINTS.ravel(), SIMPLICES), + TypeError, + "points", + ), + ( + "probe geometry shape", + lambda: nmag_accel.build_probe_tetrahedral_geometry(POINTS[:, :2], SIMPLICES), + ValueError, + r"points must have shape \(n, 3\)", + ), + ( + "llg rank", + lambda: nmag_accel.llg_rhs( + POINTS.ravel(), + POINTS, + np.ones(4), + np.ones(4), + 1.0, + 1.0, + 1.0, + ), + TypeError, + "m", + ), + ( + "llg shape", + lambda: nmag_accel.llg_rhs( + POINTS[:, :2], + POINTS[:, :2], + np.ones(4), + np.ones(4), + 1.0, + 1.0, + 1.0, + ), + ValueError, + r"m must have shape \(n, 3\)", + ), + ( + "maxangle rank", + lambda: nmag_accel.maxangle_between_edges(POINTS.ravel(), SIMPLICES), + TypeError, + "m", + ), + ( + "maxangle shape", + lambda: nmag_accel.maxangle_between_edges(POINTS, SIMPLICES[:, :1]), + ValueError, + "simplex_size>=2", + ), +) + + +@pytest.mark.parametrize( + ("_label", "call", "error", "match"), + INVALID_RANK_AND_SHAPE_CALLS, + ids=[case[0] for case in INVALID_RANK_AND_SHAPE_CALLS], +) +def test_functions_reject_invalid_rank_and_shape( + _label: str, + call: Callable[[], object], + error: type[Exception], + match: str, +) -> None: + with pytest.raises(error, match=match): + call() diff --git a/tests/nmag/test_rust_accelerator_parity.py b/tests/nmag/test_rust_accelerator_parity.py new file mode 100644 index 0000000..9f8ea6c --- /dev/null +++ b/tests/nmag/test_rust_accelerator_parity.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +import numpy as np +import pytest + +import nmesh +from nmag.demag import _oriented_boundary_faces +from nmag.simulation import Simulation + +nmag_accel = pytest.importorskip("nmag_accel") + + +def _simulation(points: np.ndarray, simplices: np.ndarray) -> Simulation: + simulation = Simulation(name="rust-contract") + simulation.mesh = nmesh.mesh_from_points_and_simplices( + points=points.tolist(), + simplices_indices=simplices.tolist(), + simplices_regions=[1] * len(simplices), + ) + return simulation + + +def _boundary_index( + points: np.ndarray, + simplices: np.ndarray, +) -> tuple[list[tuple[int, tuple[int, int, int]]], np.ndarray, np.ndarray, np.ndarray]: + boundary_faces = _oriented_boundary_faces(points, simplices) + boundary_nodes = np.asarray( + sorted({node for _, face in boundary_faces for node in face}), + dtype=np.int64, + ) + local_index_by_point = np.full(len(points), -1, dtype=np.int64) + local_index_by_point[boundary_nodes] = np.arange(len(boundary_nodes), dtype=np.int64) + face_nodes = np.asarray([face for _, face in boundary_faces], dtype=np.int64) + return boundary_faces, boundary_nodes, local_index_by_point, face_nodes + + +def _two_tetra_mesh() -> tuple[np.ndarray, np.ndarray]: + return ( + np.asarray( + [ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + [1.0, 1.0, 1.0], + ], + dtype=np.float64, + ), + np.asarray([[0, 1, 2, 3], [1, 2, 3, 4]], dtype=np.int64), + ) + + +def _random_two_tetra_mesh(seed: int) -> tuple[np.ndarray, np.ndarray]: + points, simplices = _two_tetra_mesh() + rng = np.random.default_rng(seed) + points += rng.normal(scale=0.03, size=points.shape) + return points, simplices + + +@pytest.mark.parametrize("seed", range(3)) +def test_randomized_mesh_kernels_match_python_references(seed: int) -> None: + points, simplices = _random_two_tetra_mesh(seed) + simulation = _simulation(points, simplices) + + python_geometry = simulation._demag_fem_geometry_for_mesh_python(points, simplices) + rust_geometry = nmag_accel.build_demag_fem_geometry(points, simplices) + for rust_part, python_part in zip(rust_geometry, python_geometry, strict=True): + np.testing.assert_allclose(rust_part, python_part, rtol=1.0e-12, atol=1.0e-14) + + expected_faces = _oriented_boundary_faces(points, simplices) + owners, faces = nmag_accel.build_oriented_boundary_faces(points, simplices) + actual_faces = [ + (int(owner), tuple(int(node) for node in face)) + for owner, face in zip(owners, faces, strict=True) + ] + assert actual_faces == expected_faces + + rust_probe_geometry = nmag_accel.build_probe_tetrahedral_geometry(points, simplices) + python_probe_geometry = simulation._probe_tetrahedral_geometry_python() + for rust_part, python_part in zip(rust_probe_geometry, python_probe_geometry, strict=True): + np.testing.assert_allclose(rust_part, python_part, rtol=1.0e-13, atol=1.0e-14) + + boundary_faces, boundary_nodes, local_indices, face_nodes = _boundary_index(points, simplices) + python_nodes, python_bem = simulation._build_lindholm_bem_matrix_python( + points, + simplices, + boundary_faces, + ) + rust_bem = nmag_accel.build_lindholm_bem_matrix( + points, + simplices, + face_nodes, + boundary_nodes, + local_indices, + ) + np.testing.assert_array_equal(boundary_nodes, python_nodes) + np.testing.assert_allclose(rust_bem, python_bem, rtol=1.0e-12, atol=1.0e-14) + + +@pytest.mark.parametrize("seed", range(3)) +def test_randomized_field_kernels_match_python_references(seed: int) -> None: + points, simplices = _two_tetra_mesh() + simulation = _simulation(points, simplices) + _, gradients, volumes = simulation._demag_fem_geometry_for_mesh_python(points, simplices) + + rng = np.random.default_rng(seed + 100) + m = rng.normal(size=(len(points), 3)) + h_total = rng.normal(size=(len(points), 3)) + ms_by_cell = rng.uniform(0.5, 2.0, size=len(simplices)) + expected_divergence = simulation._assemble_demag_fem_divergence_python( + simplices, + gradients, + volumes, + m, + ms_by_cell, + len(points), + ) + rust_divergence = nmag_accel.build_demag_fem_divergence( + simplices, + gradients, + volumes, + m, + ms_by_cell, + len(points), + ) + np.testing.assert_allclose(rust_divergence, expected_divergence, rtol=1.0e-13) + + cell_h = rng.normal(size=(len(simplices), 3)) + recovery_volumes = np.asarray([volumes[0], -volumes[1]]) + weights = rng.uniform(0.25, 2.0, size=len(points)) + expected_nodal = simulation._recover_demag_nodal_field_python( + simplices, + recovery_volumes, + cell_h, + weights, + len(points), + ) + rust_nodal = nmag_accel.recover_demag_nodal_field( + simplices, + recovery_volumes, + cell_h, + weights, + len(points), + ) + np.testing.assert_allclose(rust_nodal, expected_nodal, rtol=1.0e-13) + + average_volumes = rng.uniform(-0.5, 2.0, size=len(simplices)) + expected_average = simulation._demag_cell_field_average_python(cell_h, average_volumes) + rust_average = nmag_accel.demag_cell_field_average(cell_h, average_volumes) + np.testing.assert_allclose(rust_average, expected_average, rtol=1.0e-13) + + pin = rng.uniform(0.0, 1.0, size=len(points)) + ms_by_point = rng.uniform(0.5, 2.0, size=len(points)) + coefficients = (0.75, -0.25, 0.125) + expected_rhs = simulation._llg_rhs_python( + m, + h_total, + pin, + ms_by_point, + *coefficients, + ) + rust_rhs = nmag_accel.llg_rhs( + m, + h_total, + pin, + ms_by_point, + *coefficients, + ) + np.testing.assert_allclose(rust_rhs, expected_rhs, rtol=1.0e-13, atol=1.0e-14) + + normalised_m = m / np.linalg.norm(m, axis=1, keepdims=True) + simulation._fields["m"] = normalised_m + expected_maxangle = simulation._maxangle_average_reference("m") + rust_maxangle = nmag_accel.maxangle_between_edges(normalised_m, simplices) + assert rust_maxangle == pytest.approx(expected_maxangle, rel=1.0e-13) diff --git a/tests/nmag/test_sparse_demag.py b/tests/nmag/test_sparse_demag.py new file mode 100644 index 0000000..c837347 --- /dev/null +++ b/tests/nmag/test_sparse_demag.py @@ -0,0 +1,209 @@ +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pytest +from scipy import sparse + +import nmag +import nmag.backends as backends +import nmesh +from nmag.backends import ( + DEMAG_DENSE_MAX_POINTS_ENV, + DEMAG_FEM_AUTO_SPARSE_MIN_POINTS_ENV, + DEMAG_FEM_MATRIX_BACKEND_ENV, + MEMORY_MODE_ENV, + _selected_demag_fem_matrix_backend, +) +from nmag.demag.linear import _validate_dense_demag_size + +FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" +SPHERE_MESH = FIXTURES / "nmag_doc_example1" / "sphere1.nmesh.h5" + + +def _two_tetra_mesh(path: Path) -> None: + 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], + [1.0, 1.0, 1.0], + ], + simplices_indices=[[0, 1, 2, 3], [1, 2, 3, 4]], + simplices_regions=[1, 1], + ) + mesh.save(str(path)) + + +def _simulation(mesh_path: Path, name: str) -> nmag.Simulation: + material = nmag.MagMaterial( + name="Py", + Ms=nmag.SI(1.0e6, "A/m"), + exchange_coupling=nmag.SI(13.0e-12, "J/m"), + llg_damping=0.5, + do_precession=False, + ) + simulation = nmag.Simulation(name=name) + simulation.load_mesh( + str(mesh_path), + [("magnetic", material)], + unit_length=nmag.SI(1.0e-9, "m"), + ) + simulation.set_m(lambda point: [1.0, 0.2 * point[0], 0.1 * point[1]]) + return simulation + + +def test_auto_matrix_backend_switches_to_sparse_without_rejecting( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv(DEMAG_FEM_MATRIX_BACKEND_ENV, raising=False) + monkeypatch.setenv(DEMAG_FEM_AUTO_SPARSE_MIN_POINTS_ENV, "5") + monkeypatch.setattr(backends, "available_memory_bytes", lambda: None) + + assert _selected_demag_fem_matrix_backend(4) == "dense" + assert _selected_demag_fem_matrix_backend(5) == "sparse" + + +def test_low_memory_mode_forces_sparse_fem(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv(DEMAG_FEM_MATRIX_BACKEND_ENV, raising=False) + monkeypatch.setenv(MEMORY_MODE_ENV, "low") + + assert _selected_demag_fem_matrix_backend(1) == "sparse" + + +def test_auto_matrix_backend_respects_available_memory( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv(DEMAG_FEM_MATRIX_BACKEND_ENV, raising=False) + monkeypatch.setenv(DEMAG_FEM_AUTO_SPARSE_MIN_POINTS_ENV, "10000") + monkeypatch.setattr(backends, "available_memory_bytes", lambda: 1_000) + + assert _selected_demag_fem_matrix_backend(10) == "sparse" + + +def test_auto_matrix_backend_keeps_dense_above_fallback_when_memory_allows( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv(DEMAG_FEM_MATRIX_BACKEND_ENV, raising=False) + monkeypatch.setenv(DEMAG_FEM_AUTO_SPARSE_MIN_POINTS_ENV, "5") + monkeypatch.setattr(backends, "available_memory_bytes", lambda: 1_000_000) + + assert _selected_demag_fem_matrix_backend(10) == "dense" + + +def test_dense_demag_has_no_default_point_count_limit( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv(DEMAG_DENSE_MAX_POINTS_ENV, raising=False) + + _validate_dense_demag_size(1_000_000) + + +def test_sparse_fem_stiffness_matches_dense( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + mesh_path = tmp_path / "two-tetra.nmesh.h5" + _two_tetra_mesh(mesh_path) + simulation = _simulation(mesh_path, "sparse-stiffness") + points = np.asarray(simulation.mesh.points, dtype=float) + simplices = np.asarray(simulation.mesh.simplices, dtype=int) + + dense, dense_gradients, dense_volumes = simulation._demag_fem_geometry_for_mesh_python( + points, + simplices, + ) + monkeypatch.setenv(DEMAG_FEM_MATRIX_BACKEND_ENV, "sparse") + simulation._invalidate_demag(clear_geometry=True) + actual, gradients, volumes = simulation._demag_fem_geometry_for_mesh(points, simplices) + + assert sparse.isspmatrix_csr(actual) + assert actual.nnz <= 16 * len(simplices) + np.testing.assert_allclose(actual.toarray(), dense, rtol=1.0e-14, atol=1.0e-30) + np.testing.assert_allclose(gradients, dense_gradients, rtol=1.0e-14, atol=1.0e-30) + np.testing.assert_allclose(volumes, dense_volumes, rtol=1.0e-14, atol=1.0e-30) + + +def test_sparse_demag_and_exchange_match_dense( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.chdir(tmp_path) + mesh_path = tmp_path / "two-tetra.nmesh.h5" + _two_tetra_mesh(mesh_path) + + monkeypatch.setenv(DEMAG_FEM_MATRIX_BACKEND_ENV, "dense") + dense = _simulation(mesh_path, "dense-fields") + dense_demag = np.asarray(dense.get_subfield("H_demag")) + dense_exchange = np.asarray(dense._subfield_array("H_exch")) + + monkeypatch.setenv(DEMAG_FEM_MATRIX_BACKEND_ENV, "sparse") + sparse_simulation = _simulation(mesh_path, "sparse-fields") + sparse_demag = np.asarray(sparse_simulation.get_subfield("H_demag")) + sparse_exchange = np.asarray(sparse_simulation._subfield_array("H_exch")) + + np.testing.assert_allclose(sparse_demag, dense_demag, rtol=2.0e-11, atol=1.0e-6) + np.testing.assert_allclose(sparse_exchange, dense_exchange, rtol=2.0e-13, atol=1.0e-6) + + +def test_sparse_sphere_demag_matches_dense( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setenv(DEMAG_FEM_MATRIX_BACKEND_ENV, "dense") + dense = _simulation(SPHERE_MESH, "dense-sphere") + dense.set_m([1.0, 0.0, 0.0]) + dense_field = np.asarray(dense.get_subfield("H_demag")) + + monkeypatch.setenv(DEMAG_FEM_MATRIX_BACKEND_ENV, "sparse") + sparse_simulation = _simulation(SPHERE_MESH, "sparse-sphere") + sparse_simulation.set_m([1.0, 0.0, 0.0]) + sparse_field = np.asarray(sparse_simulation.get_subfield("H_demag")) + + np.testing.assert_allclose(sparse_field, dense_field, rtol=2.0e-10, atol=2.0e-5) + + +def test_sparse_condition_diagnostics_do_not_materialize_dense_matrices( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.chdir(tmp_path) + mesh_path = tmp_path / "two-tetra.nmesh.h5" + _two_tetra_mesh(mesh_path) + monkeypatch.setenv(DEMAG_FEM_MATRIX_BACKEND_ENV, "sparse") + monkeypatch.setenv("NMAG_DEMAG_SOLVE_CONDITION_DIAGNOSTICS", "1") + + simulation = _simulation(mesh_path, "sparse-diagnostics") + simulation.get_subfield("H_demag") + + diagnostics = simulation.last_demag_solve_diagnostics + assert diagnostics["condition_numbers_skipped_for_sparse_storage"] == 1 + assert "stiffness_condition_number" not in diagnostics + + +def test_sparse_fixed_time_dynamics_match_dense( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.chdir(tmp_path) + mesh_path = tmp_path / "two-tetra.nmesh.h5" + _two_tetra_mesh(mesh_path) + target = nmag.SI(2.0e-13, "s") + + monkeypatch.setenv(DEMAG_FEM_MATRIX_BACKEND_ENV, "dense") + dense = _simulation(mesh_path, "dense-dynamics") + dense.advance_time(target) + + monkeypatch.setenv(DEMAG_FEM_MATRIX_BACKEND_ENV, "sparse") + sparse_simulation = _simulation(mesh_path, "sparse-dynamics") + sparse_simulation.advance_time(target) + + np.testing.assert_allclose( + sparse_simulation.get_subfield("m"), + dense.get_subfield("m"), + rtol=2.0e-8, + atol=2.0e-9, + ) diff --git a/tests/nmag/test_spin_transfer_torque.py b/tests/nmag/test_spin_transfer_torque.py new file mode 100644 index 0000000..ca0b66f --- /dev/null +++ b/tests/nmag/test_spin_transfer_torque.py @@ -0,0 +1,287 @@ +from __future__ import annotations + +from pathlib import Path +from typing import cast + +import numpy as np +import pytest + +import nmag +import nmesh +from si.physical import SI + + +def _simulation( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + *, + name: str, + polarisation: float = 0.7, + xi: float = 0.02, + damping: float = 0.1, + config: nmag.NmagConfig | None = None, +) -> nmag.Simulation: + monkeypatch.chdir(tmp_path) + mesh_path = tmp_path / f"{name}.nmesh.h5" + 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.save(str(mesh_path)) + material = nmag.MagMaterial( + name="Py", + Ms=nmag.SI(8.0e5, "A/m"), + exchange_coupling=nmag.SI(0.0, "J/m"), + llg_damping=damping, + llg_polarisation=polarisation, + llg_xi=xi, + do_precession=False, + ) + simulation = nmag.Simulation(name=name, do_demag=False, config=config) + simulation.load_mesh( + str(mesh_path), + [("magnetic", material)], + unit_length=nmag.SI(1.0, "m"), + ) + simulation.set_m([1.0, 0.0, 0.0]) + return simulation + + +def test_current_density_accepts_uniform_nodal_and_callable_inputs( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + simulation = _simulation(tmp_path, monkeypatch, name="stt-inputs") + + simulation.set_current_density([1.0, 2.0, 3.0], nmag.SI("A/m^2")) + np.testing.assert_array_equal( + simulation.get_subfield("current_density"), + np.tile([1.0, 2.0, 3.0], (4, 1)), + ) + + nodal = np.asarray( + [[0.0, 1.0, 2.0], [3.0, 4.0, 5.0], [6.0, 7.0, 8.0], [9.0, 10.0, 11.0]] + ) + simulation.set_current_density(nodal, nmag.SI("A/m^2")) + np.testing.assert_array_equal(simulation.get_subfield("current_density"), nodal) + + simulation.set_current_density( + lambda point: [point[0], point[1], point[2]], + nmag.SI(2.0, "A/m^2"), + ) + assert simulation.mesh is not None + np.testing.assert_array_equal( + simulation.get_subfield("current_density"), + 2.0 * np.asarray(simulation.mesh.points), + ) + + +@pytest.mark.parametrize( + "values", + ([1.0, 2.0], np.ones((4, 2)), [1.0, 2.0, float("nan")]), +) +def test_current_density_rejects_invalid_inputs( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + values: object, +) -> None: + simulation = _simulation(tmp_path, monkeypatch, name="stt-invalid") + + with pytest.raises(ValueError, match="current_density"): + simulation.set_current_density(values, nmag.SI("A/m^2")) # type: ignore[arg-type] + + +def test_directional_derivative_recovers_linear_tetrahedral_field( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + simulation = _simulation(tmp_path, monkeypatch, name="stt-gradient") + assert simulation.mesh is not None + simulation._fields["m"] = np.asarray(simulation.mesh.points, dtype=float) + simulation.set_current_density([2.0, -3.0, 5.0], nmag.SI("A/m^2")) + + np.testing.assert_allclose( + simulation.get_subfield("dm_dcurrent"), + np.tile([2.0, -3.0, 5.0], (4, 1)), + rtol=0.0, + atol=1.0e-15, + ) + + +def test_uniform_magnetisation_has_zero_current_torque( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + simulation = _simulation(tmp_path, monkeypatch, name="stt-uniform") + simulation.set_current_density([0.0, 0.0, 1.0e12], nmag.SI("A/m^2")) + + np.testing.assert_allclose(simulation.get_subfield("dm_dcurrent"), 0.0, atol=0.0) + np.testing.assert_allclose(simulation.get_subfield("dmdt"), 0.0, atol=0.0) + + +@pytest.mark.parametrize("backend", ["python", "rust"]) +def test_current_torque_evolves_nonuniform_state_through_integrator( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + backend: str, +) -> None: + if backend == "rust": + pytest.importorskip("nmag_accel") + simulation = _simulation( + tmp_path, + monkeypatch, + name=f"stt-dynamics-{backend}", + config=nmag.NmagConfig(accelerator="rust" if backend == "rust" else "off"), + ) + simulation.set_m(lambda point: [1.0, 0.1 * point[0], 0.0]) + simulation.set_current_density([1.0e12, 0.0, 0.0], nmag.SI("A/m^2")) + initial = np.asarray(simulation.get_subfield("m"), dtype=float) + + simulation.advance_time(nmag.SI(1.0e-12, "s")) + + final = np.asarray(simulation.get_subfield("m"), dtype=float) + assert np.max(np.abs(final - initial)) > 1.0e-13 + np.testing.assert_allclose(np.linalg.norm(final, axis=1), 1.0, atol=1.0e-12) + + +def test_python_stt_rhs_matches_legacy_formula_and_pinning( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + simulation = _simulation(tmp_path, monkeypatch, name="stt-formula") + m = np.asarray([[0.6, 0.8, 0.0], [1.0, 0.0, 0.0]]) + directional = np.asarray([[2.0, -1.0, 3.0], [4.0, 5.0, 6.0]]) + pin = np.asarray([1.0, 0.0]) + c4 = np.asarray([-2.0, -3.0]) + c5 = np.asarray([0.25, 0.5]) + + actual = simulation._llg_rhs_python( + m, + np.zeros_like(m), + pin, + np.ones(2), + 0.0, + 0.0, + 0.0, + directional, + c4, + c5, + ) + mdotg = np.einsum("ij,ij->i", m, directional) + mdotm = np.einsum("ij,ij->i", m, m) + expected = ( + c4[:, None] * (m * mdotg[:, None] - directional * mdotm[:, None]) + + c5[:, None] * np.cross(m, directional) + ) * pin[:, None] + + np.testing.assert_allclose(actual, expected, rtol=0.0, atol=1.0e-15) + np.testing.assert_array_equal(actual[1], np.zeros(3)) + + +def test_material_stt_coefficients_match_legacy_definition() -> None: + material = nmag.MagMaterial( + name="Py", + Ms=nmag.SI(8.0e5, "A/m"), + llg_damping=0.1, + llg_polarisation=0.7, + llg_xi=0.02, + ) + from si import constants + + gilbert = 1.0 / (1.0 + 0.1**2) + bohr_magneton = (1.0 * constants.bohr_magneton).to("J/T").magnitude + charge = (1.0 * constants.positron_charge).to("C").magnitude + prefactor = -gilbert * 0.7 * bohr_magneton / (charge * 8.0e5 * (1.0 + 0.02**2)) + + adiabatic = cast(SI, material.su_llg_stt_adiab) + nonadiabatic = cast(SI, material.su_llg_stt_nadiab) + assert adiabatic.in_units_of(nmag.SI("m^3/A/s")) == pytest.approx( + prefactor * (1.0 + 0.1 * 0.02) + ) + assert nonadiabatic.in_units_of(nmag.SI("m^3/A/s")) == pytest.approx( + prefactor * (0.02 - 0.1) + ) + + +def test_python_and_rust_stt_rhs_agree( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + pytest.importorskip("nmag_accel") + simulation = _simulation(tmp_path, monkeypatch, name="stt-rust") + generator = np.random.default_rng(20260715) + m = generator.normal(size=(16, 3)) + m /= np.linalg.norm(m, axis=1)[:, None] + h_total = generator.normal(size=(16, 3)) * 1.0e5 + directional = generator.normal(size=(16, 3)) * 1.0e12 + pin = generator.uniform(0.0, 1.0, size=16) + scales = generator.uniform(0.5, 1.5, size=16) + precession = generator.uniform(-3.0e5, 0.0, size=16) + damping = generator.uniform(-2.0e5, 0.0, size=16) + normalisation = generator.uniform(0.0, 1.0e11, size=16) + c4 = generator.uniform(-1.0e-10, 0.0, size=16) + c5 = generator.uniform(-1.0e-10, 1.0e-10, size=16) + + expected = simulation._llg_rhs_python( + m, + h_total, + pin, + scales, + precession, + damping, + normalisation, + directional, + c4, + c5, + ) + actual = simulation._llg_rhs_rust( + m, + h_total, + pin, + scales, + precession, + damping, + normalisation, + directional, + c4, + c5, + ) + + np.testing.assert_allclose(actual, expected, rtol=5.0e-15, atol=5.0e-8) + + +def test_rust_stt_rhs_rejects_invalid_directional_derivative() -> None: + rust_accel = pytest.importorskip("nmag_accel") + + with pytest.raises(ValueError, match="dm_dcurrent"): + rust_accel.llg_rhs_stt_heterogeneous( + np.asarray([[1.0, 0.0, 0.0]]), + np.asarray([[0.0, 1.0, 0.0]]), + np.ones(1), + np.ones(1), + np.ones(1), + np.ones(1), + np.ones(1), + np.empty((0, 3)), + np.ones(1), + np.ones(1), + ) + + +def test_current_density_invalidates_initialised_integrator( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + simulation = _simulation(tmp_path, monkeypatch, name="stt-invalidate") + simulation.reinitialise() + assert simulation._integrator_is_stale is False + + simulation.set_current_density([0.0, 0.0, 1.0], nmag.SI("A/m^2")) + + assert simulation._integrator_is_stale is True diff --git a/tests/nmag/test_volume_charge_scaling.py b/tests/nmag/test_volume_charge_scaling.py new file mode 100644 index 0000000..2c037b2 --- /dev/null +++ b/tests/nmag/test_volume_charge_scaling.py @@ -0,0 +1,193 @@ +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import public_api_support_module as helpers +import pytest + +import nmag +from nmag.simulation.demag.fem.charges import volume_charge_scaling_correction + + +def _simulation( + tmp_path: Path, + *, + name: str, + scale: float, + accelerator: str = "off", +) -> nmag.Simulation: + mesh_path = tmp_path / "mesh.nmesh" + if not mesh_path.exists(): + helpers.write_two_tetra_mesh(mesh_path) + material = nmag.MagMaterial( + name="Py", + Ms=nmag.SI(1.0e6, "A/m"), + exchange_coupling=nmag.SI(13.0e-12, "J/m"), + scale_volume_charges=scale, + ) + simulation = nmag.Simulation( + name=name, + config=nmag.NmagConfig(accelerator=accelerator), + ) + simulation.load_mesh( + str(mesh_path), + [("generated", material)], + unit_length=nmag.SI(1e-9, "m"), + ) + return simulation + + +def _nonuniform_m(position: np.ndarray) -> list[float]: + return [1.0 + position[0] * 1e9, 0.25 + position[1] * 1e9, 0.5] + + +def test_volume_charge_correction_matches_linear_tetrahedron_integral(): + simplices = np.asarray([[0, 1, 2, 3]], dtype=int) + gradients = np.asarray( + [[[-1.0, -1.0, -1.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]] + ) + volumes = np.asarray([1.0 / 6.0]) + m = np.asarray([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 0.0], [0.0, 0.0, 0.0]]) + + correction = volume_charge_scaling_correction( + simplices, + gradients, + volumes, + m, + np.asarray([2.0]), + np.asarray([0.25]), + point_count=4, + ) + + # div(M) is 2.0, and each local linear basis function integrates to V/4. + np.testing.assert_allclose(correction, np.full(4, 0.0625)) + + +@pytest.mark.parametrize("scale", [0.0, 0.25, 2.0]) +def test_uniform_magnetisation_has_no_volume_charge_effect(tmp_path, monkeypatch, scale): + monkeypatch.chdir(tmp_path) + reference = _simulation(tmp_path, name="reference", scale=1.0) + scaled = _simulation(tmp_path, name="scaled", scale=scale) + reference.set_m([1.0, 0.0, 0.0]) + scaled.set_m([1.0, 0.0, 0.0]) + + np.testing.assert_allclose( + scaled.get_subfield("H_demag"), + reference.get_subfield("H_demag"), + rtol=1e-12, + atol=1e-9, + ) + + +def test_nonuniform_magnetisation_responds_linearly_to_volume_charge_scale( + tmp_path, + monkeypatch, +): + monkeypatch.chdir(tmp_path) + simulations = [ + _simulation(tmp_path, name=f"scale-{scale}", scale=scale) for scale in (0.0, 1.0, 2.0) + ] + for simulation in simulations: + simulation.set_m(_nonuniform_m) + + fields = [np.asarray(simulation.get_subfield("H_demag")) for simulation in simulations] + assert not np.allclose(fields[0], fields[1]) + np.testing.assert_allclose(fields[2] - fields[1], fields[1] - fields[0], rtol=1e-10, atol=1e-7) + + +def test_scaled_volume_charge_matches_between_python_and_rust(tmp_path, monkeypatch): + pytest.importorskip("nmag_accel") + monkeypatch.chdir(tmp_path) + python_simulation = _simulation(tmp_path, name="python", scale=0.25, accelerator="off") + rust_simulation = _simulation(tmp_path, name="rust", scale=0.25, accelerator="rust") + python_simulation.set_m(_nonuniform_m) + rust_simulation.set_m(_nonuniform_m) + + np.testing.assert_allclose( + rust_simulation.get_subfield("H_demag"), + python_simulation.get_subfield("H_demag"), + rtol=1e-10, + atol=1e-7, + ) + + +def test_scaled_volume_charge_matches_legacy_two_tetra_fixture(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + simulation = _simulation(tmp_path, name="legacy-parity", scale=2.0) + simulation.set_m(_nonuniform_m) + + expected_legacy_h = np.asarray( + [ + [-100428.36054294, -58805.94740934, -53098.23403883], + [-73932.89295219, -32310.47981859, -26602.76644807], + [-73932.89295219, -32310.47981859, -26602.76644807], + [-73932.89295219, -32310.47981859, -26602.76644807], + [-60685.15915681, -19062.74602321, -13355.0326527], + ] + ) + expected_legacy_rho = np.asarray( + [-2.76517975e14, 7.54482569e13, -1.46288202e14, -1.76695814e14, 1.95556183e14] + ) + + np.testing.assert_allclose( + simulation.get_subfield("H_demag"), + expected_legacy_h, + # Legacy's iterative potential solve adds source-dependent roundoff; + # rho below is the tighter parity check for the scaled operator. + rtol=4e-5, + atol=5e-3, + ) + np.testing.assert_allclose( + simulation.get_subfield("rho"), + expected_legacy_rho, + rtol=1e-8, + atol=1e5, + ) + + +def test_volume_charge_scales_follow_mesh_regions(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + mesh_path = tmp_path / "mesh.nmesh" + helpers.write_two_region_mesh(mesh_path) + materials = [ + nmag.MagMaterial(name="A", scale_volume_charges=0.25), + nmag.MagMaterial(name="B", scale_volume_charges=2.0), + ] + simulation = nmag.Simulation(name="regions") + simulation.load_mesh( + str(mesh_path), + [("region-a", materials[0]), ("region-b", materials[1])], + unit_length=nmag.SI(1e-9, "m"), + ) + + np.testing.assert_array_equal( + simulation._simplex_volume_charge_scales([1, 2]), + [0.25, 2.0], + ) + + +@pytest.mark.parametrize("scale", [float("nan"), float("inf"), float("-inf")]) +def test_volume_charge_scale_must_be_finite(scale): + with pytest.raises(ValueError, match="scale_volume_charges must be finite"): + nmag.MagMaterial(name="invalid", scale_volume_charges=scale) + + +def test_volume_charge_scale_must_be_numeric(): + with pytest.raises(TypeError, match="scale_volume_charges must be a finite real number"): + nmag.MagMaterial(name="invalid", scale_volume_charges="invalid") # type: ignore[arg-type] + + +def test_restart_rejects_different_volume_charge_scale(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + source = _simulation(tmp_path, name="source", scale=0.25) + source.set_m(_nonuniform_m) + checkpoint = source.save_restart_file(tmp_path / "scaled-restart.h5") + + target = _simulation(tmp_path, name="target", scale=1.0) + target.set_m([0.0, 1.0, 0.0]) + original_m = np.array(target.get_subfield("m"), copy=True) + + with pytest.raises(ValueError, match="materials do not match"): + target.load_restart_file(checkpoint) + np.testing.assert_array_equal(target.get_subfield("m"), original_m) diff --git a/tests/nmesh/io/test_legacy_nmesh_hdf5_routing.py b/tests/nmesh/io/test_legacy_nmesh_hdf5_routing.py new file mode 100644 index 0000000..80d4abd --- /dev/null +++ b/tests/nmesh/io/test_legacy_nmesh_hdf5_routing.py @@ -0,0 +1,196 @@ +"""Tests for legacy ``.nmesh.h5`` loading support.""" + +import pytest + +import nmesh +from nmesh.backend import RawMesh +from nmesh.io.legacy_nmesh_hdf5 import ( + is_legacy_nmesh_hdf5, + load_raw_mesh_from_legacy_nmesh_hdf5, + save_raw_mesh_as_legacy_nmesh_hdf5, +) + +h5py = pytest.importorskip("h5py") + + +def _write_legacy_nmesh_hdf5(path): + """Create a tiny legacy nmesh HDF5 file for regression testing.""" + with h5py.File(path, "w") as handle: + etc_group = handle.create_group("etc") + mesh_group = handle.create_group("mesh") + + etc_group.create_dataset("filetype", data=b"nmesh") + etc_group.create_dataset("fileversion", data=b"1.0") + + mesh_group.create_dataset( + "points", + data=[ + [0.0, 259.0, 3.0], + [0.0, 259.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + ], + ) + mesh_group.create_dataset( + "simplices", + data=[ + [0, 1, 2, 3], + [1, 2, 3, 4], + ], + dtype="i4", + ) + mesh_group.create_dataset("simplicesregions", data=[1, 7], dtype="i4") + mesh_group.create_dataset( + "periodicpointindices", + data=[ + [0, 3, -1], + [1, 2, 4], + ], + dtype="i4", + ) + mesh_group.create_dataset("permutation", data=[4, 3, 2, 1, 0], dtype="i4") + + +def test_load_raw_mesh_from_legacy_nmesh_hdf5(tmp_path): + """The legacy loader should decode the classic PyTables-style layout.""" + sample_path = tmp_path / "model.nmesh.h5" + _write_legacy_nmesh_hdf5(sample_path) + + raw_mesh = load_raw_mesh_from_legacy_nmesh_hdf5(sample_path) + + assert raw_mesh.dim == 3 + assert len(raw_mesh.points) == 5 + assert len(raw_mesh.simplices) == 2 + assert len(raw_mesh.regions) == 2 + assert raw_mesh.points[0] == [0.0, 259.0, 3.0] + assert raw_mesh.simplices[0] == [0, 1, 2, 3] + assert raw_mesh.regions == [1, 7] + assert raw_mesh.periodic_point_indices == [[0, 3], [1, 2, 4]] + assert raw_mesh.permutation == [4, 3, 2, 1, 0] + assert raw_mesh.point_regions == [[1], [1, 7], [1, 7], [1, 7], [7]] + assert raw_mesh.links + assert raw_mesh.region_volumes + + +def test_save_raw_mesh_as_legacy_nmesh_hdf5_round_trip(tmp_path): + path = tmp_path / "roundtrip.nmesh.h5" + raw_mesh = RawMesh( + 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=[[0, 1, 2, 3]], + regions=[7], + periodic_point_indices=[[0, 2], [1, 3]], + permutation=[3, 2, 1, 0], + dim=3, + ) + + save_raw_mesh_as_legacy_nmesh_hdf5(path, raw_mesh) + loaded = load_raw_mesh_from_legacy_nmesh_hdf5(path) + + assert path.exists() + with h5py.File(path, "r") as handle: + assert handle.attrs["CLASS"] == b"GROUP" + assert handle.attrs["PYTABLES_FORMAT_VERSION"] == b"2.0" + assert handle["mesh"].attrs["CLASS"] == b"GROUP" + assert handle["mesh/points"].attrs["CLASS"] == b"CARRAY" + assert handle["etc/filetype"].shape == (1,) + assert loaded.points == raw_mesh.points + assert loaded.simplices == raw_mesh.simplices + assert loaded.regions == raw_mesh.regions + assert loaded.periodic_point_indices == raw_mesh.periodic_point_indices + assert loaded.permutation == raw_mesh.permutation + assert loaded.dim == raw_mesh.dim + + +def test_nmesh_load_accepts_binary_legacy_hdf5(tmp_path): + path = tmp_path / "roundtrip.nmesh.h5" + 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=[7], + ) + + mesh.save(path) + loaded = nmesh.load(path) + + assert loaded.points == mesh.points + assert loaded.simplices == mesh.simplices + assert loaded.regions == mesh.regions + assert loaded.dim == 3 + + +def test_nmesh_load_reads_legacy_hdf5_without_meshio_probe(tmp_path, monkeypatch): + path = tmp_path / "direct.nmesh.h5" + 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=[7], + ) + mesh.save(path) + + from nmesh import io as nmesh_io + + def fail_if_used(_path): + raise AssertionError("nmesh.load() should not try meshio for legacy HDF5") + + monkeypatch.setattr(nmesh_io, "load_raw_mesh_with_meshio", fail_if_used) + + loaded = nmesh.load(path) + + assert loaded.points == mesh.points + assert loaded.simplices == mesh.simplices + assert loaded.regions == mesh.regions + + +def test_nmesh_load_routes_unrecognized_hdf5_to_meshio(tmp_path, monkeypatch): + path = tmp_path / "other.h5" + with h5py.File(path, "w") as handle: + handle.create_dataset("unrelated", data=[1]) + + expected = RawMesh( + points=[[0.0, 0.0], [1.0, 0.0]], + simplices=[[0, 1]], + regions=[1], + dim=1, + ) + from nmesh import io as nmesh_io + + monkeypatch.setattr(nmesh_io, "load_raw_mesh_with_meshio", lambda loaded_path: expected) + + assert nmesh.load(path).raw_mesh is expected + + +def test_nmesh_load_keeps_malformed_recognized_legacy_error(tmp_path): + path = tmp_path / "malformed.h5" + with h5py.File(path, "w") as handle: + etc_group = handle.create_group("etc") + etc_group.create_dataset("filetype", data=b"nmesh") + + with pytest.raises(ValueError, match="missing the /mesh group"): + nmesh.load(path) + + +def test_legacy_hdf5_recognition_uses_file_contents(tmp_path): + legacy_path = tmp_path / "legacy.data" + _write_legacy_nmesh_hdf5(legacy_path) + other_hdf5_path = tmp_path / "other.h5" + with h5py.File(other_hdf5_path, "w") as handle: + handle.create_dataset("unrelated", data=[1]) + + assert is_legacy_nmesh_hdf5(legacy_path) + assert not is_legacy_nmesh_hdf5(other_hdf5_path) diff --git a/tests/nmesh/io/test_legacy_nmesh_hdf5.py b/tests/nmesh/io/test_legacy_nmesh_hdf5_validation.py similarity index 79% rename from tests/nmesh/io/test_legacy_nmesh_hdf5.py rename to tests/nmesh/io/test_legacy_nmesh_hdf5_validation.py index b1706dc..108c57c 100644 --- a/tests/nmesh/io/test_legacy_nmesh_hdf5.py +++ b/tests/nmesh/io/test_legacy_nmesh_hdf5_validation.py @@ -2,8 +2,9 @@ import pytest -from nmesh.io.legacy_nmesh_hdf5 import load_raw_mesh_from_legacy_nmesh_hdf5 - +from nmesh.io.legacy_nmesh_hdf5 import ( + load_raw_mesh_from_legacy_nmesh_hdf5, +) h5py = pytest.importorskip("h5py") @@ -47,24 +48,6 @@ def _write_legacy_nmesh_hdf5(path): mesh_group.create_dataset("permutation", data=[4, 3, 2, 1, 0], dtype="i4") -def test_load_raw_mesh_from_legacy_nmesh_hdf5(tmp_path): - """The legacy loader should decode the classic PyTables-style layout.""" - sample_path = tmp_path / "model.nmesh.h5" - _write_legacy_nmesh_hdf5(sample_path) - - raw_mesh = load_raw_mesh_from_legacy_nmesh_hdf5(sample_path) - - assert raw_mesh.dim == 3 - assert len(raw_mesh.points) == 5 - assert len(raw_mesh.simplices) == 2 - assert len(raw_mesh.regions) == 2 - assert raw_mesh.points[0] == [0.0, 259.0, 3.0] - assert raw_mesh.simplices[0] == [0, 1, 2, 3] - assert raw_mesh.regions == [1, 7] - assert raw_mesh.periodic_point_indices == [[0, 3], [1, 2, 4]] - assert raw_mesh.permutation == [4, 3, 2, 1, 0] - - def test_legacy_loader_rejects_missing_mesh_group(tmp_path): """Should raise ValueError if /mesh group is missing.""" path = tmp_path / "invalid.nmesh.h5" @@ -75,6 +58,15 @@ def test_legacy_loader_rejects_missing_mesh_group(tmp_path): load_raw_mesh_from_legacy_nmesh_hdf5(path) +def test_legacy_loader_rejects_mesh_dataset_instead_of_group(tmp_path): + path = tmp_path / "mesh_dataset.h5" + with h5py.File(path, "w") as handle: + handle.create_dataset("mesh", data=[1]) + + with pytest.raises(ValueError, match="/mesh, but it is not an HDF5 group"): + load_raw_mesh_from_legacy_nmesh_hdf5(path) + + def test_legacy_loader_rejects_wrong_filetype(tmp_path): """Should raise ValueError if filetype is not 'nmesh'.""" path = tmp_path / "wrong_type.h5" @@ -100,6 +92,41 @@ def test_legacy_loader_rejects_missing_required_datasets(tmp_path): load_raw_mesh_from_legacy_nmesh_hdf5(path) +def test_legacy_loader_rejects_group_instead_of_required_dataset(tmp_path): + path = tmp_path / "points_group.h5" + with h5py.File(path, "w") as handle: + mesh_group = handle.create_group("mesh") + mesh_group.create_group("points") + + with pytest.raises(ValueError, match="/mesh/points, but it is not an HDF5 dataset"): + load_raw_mesh_from_legacy_nmesh_hdf5(path) + + +def test_legacy_loader_rejects_group_instead_of_optional_dataset(tmp_path): + path = tmp_path / "periodic_group.h5" + _write_legacy_nmesh_hdf5(path) + with h5py.File(path, "a") as handle: + del handle["mesh/periodicpointindices"] + handle["mesh"].create_group("periodicpointindices") + + with pytest.raises( + ValueError, + match="/mesh/periodicpointindices, but it is not an HDF5 dataset", + ): + load_raw_mesh_from_legacy_nmesh_hdf5(path) + + +def test_legacy_loader_rejects_group_instead_of_filetype_dataset(tmp_path): + path = tmp_path / "filetype_group.h5" + _write_legacy_nmesh_hdf5(path) + with h5py.File(path, "a") as handle: + del handle["etc/filetype"] + handle["etc"].create_group("filetype") + + with pytest.raises(ValueError, match="/etc/filetype, but it is not an HDF5 dataset"): + load_raw_mesh_from_legacy_nmesh_hdf5(path) + + def test_legacy_loader_rejects_empty_points(tmp_path): """Should raise ValueError if mesh has no points.""" path = tmp_path / "empty_points.h5" @@ -196,9 +223,7 @@ def test_legacy_loader_handles_2d_mesh(tmp_path): with h5py.File(path, "w") as handle: handle.create_group("etc") mesh_group = handle.create_group("mesh") - mesh_group.create_dataset( - "points", data=[[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 1.0]] - ) + mesh_group.create_dataset("points", data=[[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 1.0]]) mesh_group.create_dataset("simplices", data=[[0, 1, 2], [1, 2, 3]]) mesh_group.create_dataset("simplicesregions", data=[1, 1]) diff --git a/tests/nmesh/io/test_meshio_support.py b/tests/nmesh/io/test_meshio_support.py index 2f5e9b7..1d8a5ad 100644 --- a/tests/nmesh/io/test_meshio_support.py +++ b/tests/nmesh/io/test_meshio_support.py @@ -1,10 +1,13 @@ """Tests for meshio-backed mesh file IO.""" +import meshio +import numpy as np import pytest +from meshio._exceptions import ReadError as MeshioReadError +import nmesh from nmesh.backend import RawMesh -from nmesh.io import load_raw_mesh_with_meshio, save_raw_mesh_with_meshio - +from nmesh.io import load_raw_mesh_with_meshio, meshio_support, save_raw_mesh_with_meshio h5py = pytest.importorskip("h5py") @@ -67,6 +70,94 @@ def test_load_2d_mesh(tmp_path): assert len(loaded_mesh.simplices[0]) == 3 +@pytest.mark.parametrize("suffix", [".vtu", ".msh", ".xdmf"]) +def test_nmesh_load_reads_meshio_written_modern_meshes(tmp_path, suffix): + path = tmp_path / f"triangle{suffix}" + meshio.write( + path, + meshio.Mesh( + points=np.asarray( + [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]] + ), + cells=[("triangle", np.asarray([[0, 1, 2]]))], + ), + ) + + loaded = nmesh.load(path) + + assert loaded.points == [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]] + assert loaded.simplices == [[0, 1, 2]] + assert loaded.raw_mesh.dim == 2 + + +def test_load_combines_same_dimension_blocks_with_aligned_regions(tmp_path, monkeypatch): + mesh = meshio.Mesh( + points=np.asarray( + [ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [1.0, 1.0, 0.0], + [2.0, 0.0, 0.0], + ] + ), + cells=[ + ("triangle", np.asarray([[0, 1, 2]])), + ("line", np.asarray([[0, 1], [1, 4]])), + ("triangle", np.asarray([[1, 3, 2], [1, 4, 3]])), + ], + cell_data={ + "region": [np.asarray([7]), np.asarray([90, 91]), np.asarray([8, 9])] + }, + ) + monkeypatch.setattr(meshio_support.meshio, "read", lambda _path: mesh) + + loaded = load_raw_mesh_with_meshio(tmp_path / "multiple.vtu") + + assert loaded.simplices == [[0, 1, 2], [1, 3, 2], [1, 4, 3]] + assert loaded.regions == [7, 8, 9] + assert loaded.dim == 2 + + +def test_load_multi_block_mesh_defaults_all_regions(tmp_path, monkeypatch): + mesh = meshio.Mesh( + points=np.asarray( + [[0.0, 0.0], [1.0, 0.0], [2.0, 0.0], [3.0, 0.0]] + ), + cells=[ + ("line", np.asarray([[0, 1]])), + ("line", np.asarray([[1, 2], [2, 3]])), + ], + ) + monkeypatch.setattr(meshio_support.meshio, "read", lambda _path: mesh) + + loaded = load_raw_mesh_with_meshio(tmp_path / "multiple.vtu") + + assert loaded.simplices == [[0, 1], [1, 2], [2, 3]] + assert loaded.regions == [1, 1, 1] + + +def test_load_rejects_inconsistent_region_block_length(tmp_path, monkeypatch): + mesh = meshio.Mesh( + points=np.asarray( + [[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 1.0]] + ), + cells=[ + ("triangle", np.asarray([[0, 1, 2]])), + ("triangle", np.asarray([[1, 3, 2]])), + ], + cell_data={"region": [np.asarray([3]), np.asarray([4])]}, + ) + mesh.cell_data["region"][1] = np.asarray([4, 5]) + monkeypatch.setattr(meshio_support.meshio, "read", lambda _path: mesh) + + with pytest.raises( + ValueError, + match=r"Region data 'region' for cell block 1 .* has 2 values; expected 1", + ): + load_raw_mesh_with_meshio(tmp_path / "invalid.vtu") + + def test_fallback_to_legacy_loader_for_h5(tmp_path): """Should fall back to legacy loader for .h5 files that meshio can't read.""" path = tmp_path / "legacy.h5" @@ -89,18 +180,19 @@ def test_fallback_to_legacy_loader_for_h5(tmp_path): assert len(raw_mesh.points) == 3 assert len(raw_mesh.simplices) == 1 assert raw_mesh.regions == [1] + assert raw_mesh.dim == 2 + assert raw_mesh.region_volumes == pytest.approx([0.5]) -def test_h5_file_fails_both_loaders(tmp_path): - """Should raise ValueError with both errors when .h5 fails both loaders.""" +def test_unrecognized_h5_file_retains_meshio_format_error(tmp_path): + """Unrecognized HDF5 files should retain meshio's format error.""" path = tmp_path / "invalid.h5" # Create an invalid HDF5 file (no mesh group) with h5py.File(path, "w") as handle: handle.create_dataset("dummy", data=[1, 2, 3]) - # Both meshio and legacy loader will fail, should see combined error - with pytest.raises(ValueError, match="Unable to read.*meshio.*legacy"): + with pytest.raises(MeshioReadError, match="Could not deduce file format"): load_raw_mesh_with_meshio(path) @@ -110,7 +202,7 @@ def test_non_h5_file_only_tries_meshio(tmp_path): # This should raise an error from meshio only (not try the legacy loader) # meshio._exceptions.ReadError is raised for missing files - with pytest.raises(Exception): # Accept any exception from meshio + with pytest.raises(MeshioReadError): load_raw_mesh_with_meshio(path) diff --git a/tests/nmesh/test_driver.py b/tests/nmesh/test_driver.py index fe40342..40b32c9 100644 --- a/tests/nmesh/test_driver.py +++ b/tests/nmesh/test_driver.py @@ -1,9 +1,10 @@ import pytest + from nmesh.mesher.driver import ( - make_mg_gendriver, - MeshEngineStatus, MeshEngineCommand, + MeshEngineStatus, do_every_n_steps_driver, + make_mg_gendriver, ) diff --git a/tests/nmesh/test_geometry.py b/tests/nmesh/test_geometry.py index a64c94f..59ac4cd 100644 --- a/tests/nmesh/test_geometry.py +++ b/tests/nmesh/test_geometry.py @@ -1,5 +1,5 @@ -import pytest import numpy as np +import pytest from nmesh.geometry import ( Box, diff --git a/tests/nmesh/test_lazy_imports.py b/tests/nmesh/test_lazy_imports.py new file mode 100644 index 0000000..f1de8ea --- /dev/null +++ b/tests/nmesh/test_lazy_imports.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +import json +import subprocess +import sys + + +def _run_module_probe(source: str, *args: str) -> dict[str, object]: + completed = subprocess.run( + [sys.executable, "-c", source, *args], + check=True, + capture_output=True, + text=True, + ) + return json.loads(completed.stdout) + + +def test_importing_nmag_does_not_import_mesher_stack(): + result = _run_module_probe( + """ +import json +import sys +import nmag + +print(json.dumps({ + "has_nmag": hasattr(nmag, "Simulation"), + "nmesh_loaded": "nmesh" in sys.modules, + "geometry_loaded": "nmesh.geometry" in sys.modules, + "mesher_loaded": "nmesh.mesher" in sys.modules, + "relaxation_loaded": "nmesh.mesher.relaxation" in sys.modules, + "meshio_loaded": "meshio" in sys.modules, + "numba_loaded": "numba" in sys.modules, +})) +""" + ) + + assert result == { + "has_nmag": True, + "nmesh_loaded": True, + "geometry_loaded": False, + "mesher_loaded": False, + "relaxation_loaded": False, + "meshio_loaded": False, + "numba_loaded": False, + } + + +def test_nmesh_lazy_exports_preserve_public_geometry_and_mesher_symbols(): + result = _run_module_probe( + """ +import json +import sys +import nmesh + +before = { + "geometry_loaded": "nmesh.geometry" in sys.modules, + "mesher_loaded": "nmesh.mesher" in sys.modules, + "relaxation_loaded": "nmesh.mesher.relaxation" in sys.modules, +} +from nmesh.nmesh import Box as DirectBox +box_name = nmesh.Box.__name__ +direct_box_name = DirectBox.__name__ +params_name = nmesh.get_default_meshing_parameters().__class__.__name__ +after = { + "geometry_loaded": "nmesh.geometry" in sys.modules, + "mesher_loaded": "nmesh.mesher" in sys.modules, + "relaxation_loaded": "nmesh.mesher.relaxation" in sys.modules, +} +print(json.dumps({ + "before": before, + "box_name": box_name, + "direct_box_name": direct_box_name, + "params_name": params_name, + "after": after, +})) +""" + ) + + assert result["before"] == { + "geometry_loaded": False, + "mesher_loaded": False, + "relaxation_loaded": False, + } + assert result["box_name"] == "Box" + assert result["direct_box_name"] == "Box" + assert result["params_name"] == "MeshingParameters" + assert result["after"] == { + "geometry_loaded": True, + "mesher_loaded": True, + "relaxation_loaded": False, + } + + +def test_nmesh_load_legacy_hdf5_does_not_import_meshio(tmp_path): + result = _run_module_probe( + """ +import json +from pathlib import Path +import sys + +import nmesh +from nmesh.backend import RawMesh +from nmesh.io.legacy_nmesh_hdf5 import save_raw_mesh_as_legacy_nmesh_hdf5 + +path = Path(sys.argv[1]) +save_raw_mesh_as_legacy_nmesh_hdf5( + path, + RawMesh( + 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=[[0, 1, 2, 3]], + regions=[1], + dim=3, + ), +) +loaded = nmesh.load(path) +print(json.dumps({ + "meshio_loaded": "meshio" in sys.modules, + "point_count": len(loaded.points), + "simplex_count": len(loaded.simplices), +})) +""", + str(tmp_path / "direct.nmesh.h5"), + ) + + assert result == { + "meshio_loaded": False, + "point_count": 4, + "simplex_count": 1, + } diff --git a/tests/nmesh/test_meshing_defaults.py b/tests/nmesh/test_meshing_defaults.py new file mode 100644 index 0000000..efb7868 --- /dev/null +++ b/tests/nmesh/test_meshing_defaults.py @@ -0,0 +1,121 @@ +from nmesh.mesher.meshing_parameters import ( + MIN_DIVISION_MAGNITUDE, + PointFate, + default_boundary_node_force_fun, + default_handle_point_density_fun, + default_initial_relaxation_weight, + default_relaxation_force_fun, +) + + +def test_default_initial_relaxation_weight_normal_case(): + # At step 0, should be init_val + assert default_initial_relaxation_weight(0, 100, 1.0, 5.0) == 1.0 + + # At step 50 (halfway), should be midpoint + assert default_initial_relaxation_weight(50, 100, 1.0, 5.0) == 3.0 + + # At step 100 (max), should be final_val + assert default_initial_relaxation_weight(100, 100, 1.0, 5.0) == 5.0 + + # Beyond max_step, should saturate at final_val + assert default_initial_relaxation_weight(150, 100, 1.0, 5.0) == 5.0 + + +def test_default_initial_relaxation_weight_edge_cases(): + # When max_step <= 0, should return final_val immediately + assert default_initial_relaxation_weight(0, 0, 1.0, 5.0) == 5.0 + assert default_initial_relaxation_weight(50, 0, 1.0, 5.0) == 5.0 + assert default_initial_relaxation_weight(0, -10, 1.0, 5.0) == 5.0 + + +def test_default_relaxation_force_fun(): + # Distance > 1.0 should return 0 + assert default_relaxation_force_fun(1.5) == 0.0 + assert default_relaxation_force_fun(2.0) == 0.0 + + # At exactly 1.0 + assert default_relaxation_force_fun(1.0) == 0.0 + + # At 0.5, force should be 0.5 + assert default_relaxation_force_fun(0.5) == 0.5 + + # At 0.0, force should be 1.0 (maximum repulsion) + assert default_relaxation_force_fun(0.0) == 1.0 + + +def test_default_boundary_node_force_fun(): + # Distance > 1.0 should return 0 + assert default_boundary_node_force_fun(1.5) == 0.0 + assert default_boundary_node_force_fun(2.0) == 0.0 + + # At exactly 1.0 + assert default_boundary_node_force_fun(1.0) == 0.0 + + # Very small distance should return large repulsion + assert default_boundary_node_force_fun(MIN_DIVISION_MAGNITUDE / 2) == 1e12 + assert default_boundary_node_force_fun(0.0) == 1e12 + + # Normal distance: 1/d - 1 + result = default_boundary_node_force_fun(0.5) + expected = 1.0 / 0.5 - 1.0 + assert abs(result - expected) < 1e-10 + + +def test_default_handle_point_density_fun_low_density(): + # Mock RNG that always returns low value to trigger add + class MockRng: + def random(self): + return 0.05 # < 0.1, will trigger add + + rng = MockRng() + result = default_handle_point_density_fun(rng, (0.5, 0.1), 1.0, 2.0) + assert result == PointFate.ADD_ANOTHER + + +def test_default_handle_point_density_fun_low_force(): + # Mock RNG for force-based add + class MockRng: + def random(self): + return 0.15 # < 0.2, will trigger add + + rng = MockRng() + # Density okay but force very low + result = default_handle_point_density_fun(rng, (1.5, 0.05), 1.0, 2.0) + assert result == PointFate.ADD_ANOTHER + + +def test_default_handle_point_density_fun_high_density(): + # Mock RNG for density-based delete + class MockRng: + def random(self): + return 0.25 # Will trigger delete with right probability + + rng = MockRng() + # Density too high + result = default_handle_point_density_fun(rng, (2.5, 0.2), 1.0, 2.0) + assert result == PointFate.DELETE + + +def test_default_handle_point_density_fun_high_force(): + # Mock RNG for force-based delete + class MockRng: + def random(self): + return 0.35 # Will trigger delete with right probability + + rng = MockRng() + # Force too high + result = default_handle_point_density_fun(rng, (1.5, 0.6), 1.0, 2.0) + assert result == PointFate.DELETE + + +def test_default_handle_point_density_fun_do_nothing(): + # Mock RNG that returns high values (won't trigger anything) + class MockRng: + def random(self): + return 0.99 + + rng = MockRng() + # Normal conditions, high RNG value = do nothing + result = default_handle_point_density_fun(rng, (1.5, 0.3), 1.0, 2.0) + assert result == PointFate.DO_NOTHING diff --git a/tests/nmesh/test_meshing_engine.py b/tests/nmesh/test_meshing_engine.py deleted file mode 100644 index 8986a8a..0000000 --- a/tests/nmesh/test_meshing_engine.py +++ /dev/null @@ -1,488 +0,0 @@ -import unittest - -import numpy as np - -from nmesh import Box, Mesh -from nmesh.mesher.relaxation import ( - _compile_density_function, - assemble_raw_mesh, - fem_geometry_from_bodies, - RelaxationEngine, -) -from nmesh.mesher.relaxation._constants import BOUNDARY_FUZZ, STATE_BOUNDARY, STATE_MOBILE, STATE_SIMPLE -from nmesh.mesher.relaxation._constants import STATE_FIXED -from nmesh.mesher.relaxation.finalize import snap_final_boundary_points -from nmesh.mesher.relaxation.forces import ( - _classify_relevant_simplices, - _extract_force_parameters, - compute_forces, -) -from nmesh.mesher.relaxation.recovery import mirror_surface_recovery_points -from nmesh.mesher.relaxation.seeding import _classify_dynamic_states, _prepare_initial_points -from nmesh.mesher.relaxation.topology import _orient_simplices_positive -from nmesh.mesher.periodic import build_periodic_groups - - -class TestMeshingEngine(unittest.TestCase): - def _box_geometry(self): - return fem_geometry_from_bodies( - (np.asarray([0.0, 0.0]), np.asarray([1.0, 1.0])), - [Box([0.0, 0.0], [1.0, 1.0]).obj], - [], - ) - - def test_boundary_state_detection_marks_surface_points(self): - geometry = self._box_geometry() - - points = np.asarray( - [ - [0.0, 0.5], - [0.5, 0.5], - ], - dtype=float, - ) - - states = _classify_dynamic_states(geometry, points, a0=0.5) - - self.assertEqual(states.tolist(), [STATE_BOUNDARY, STATE_MOBILE]) - - def test_force_summary_suppresses_shape_force_at_boundary_corners(self): - geometry = self._box_geometry() - - points = np.asarray( - [ - [0.0, 0.0], - [1.0, 0.0], - [1.0, 1.0], - [0.0, 1.0], - [0.6, 0.45], - ], - dtype=float, - ) - states = np.full(len(points), STATE_MOBILE, dtype=int) - summary = compute_forces( - points, - states, - geometry, - a0=0.5, - params={ - "controller_shape_force_scale": 0.3, - "controller_volume_force_scale": 0.0, - "controller_neigh_force_scale": 0.0, - "controller_irrel_elem_force_scale": 0.0, - "controller_sliver_correction": 1.0, - "controller_initial_settling_steps": 100, - }, - step=10, - ) - - self.assertGreaterEqual(summary.simplices.shape[0], 2) - self.assertTrue(np.allclose(summary.total_forces[:4], 0.0)) - self.assertGreater(np.linalg.norm(summary.total_forces[4]), 0.0) - self.assertGreater(summary.max_effective_force, 0.0) - - def test_boundary_effective_force_uses_tangential_projection(self): - geometry = self._box_geometry() - - points = np.asarray( - [ - [0.0, 0.5], - [0.2, 0.4], - [0.2, 0.6], - ], - dtype=float, - ) - states = np.asarray([STATE_BOUNDARY, STATE_MOBILE, STATE_MOBILE], dtype=int) - summary = compute_forces( - points, - states, - geometry, - a0=0.4, - params={ - "controller_shape_force_scale": 0.0, - "controller_volume_force_scale": 0.0, - "controller_neigh_force_scale": 1.0, - "controller_irrel_elem_force_scale": 0.0, - "controller_sliver_correction": 1.0, - "controller_initial_settling_steps": 100, - }, - step=10, - ) - - self.assertGreater(abs(summary.total_forces[0][0]), 0.0) - self.assertAlmostEqual(summary.total_forces[0][1], 0.0, places=7) - self.assertAlmostEqual(summary.point_effective_force[0], 0.0, places=7) - - def test_force_relevant_simplex_classifier_rejects_boundary_crossing_probe(self): - geometry = self._box_geometry() - points = np.asarray( - [ - [0.5, 0.5], - [1.2, 0.5], - [0.5, 0.8], - ], - dtype=float, - ) - simplices = np.asarray([[0, 1, 2]], dtype=int) - states = np.full(len(points), STATE_MOBILE, dtype=int) - measures = np.asarray([0.105], dtype=float) - - mask = _classify_relevant_simplices( - points, - states, - geometry, - simplices, - measures, - 2, - _extract_force_parameters({}, step=10), - ) - - self.assertFalse(bool(mask[0])) - - def test_surface_recovery_mirrors_poor_2d_boundary_simplex(self): - geometry = self._box_geometry() - points = np.asarray( - [ - [0.0, 0.0], - [1.0, 0.0], - [0.5, 0.2], - ], - dtype=float, - ) - states = np.asarray([STATE_BOUNDARY, STATE_BOUNDARY, STATE_MOBILE], dtype=int) - - recovery_points = mirror_surface_recovery_points( - points, - states, - geometry, - a0=0.5, - simplices=np.asarray([[0, 1, 2]], dtype=int), - ) - - self.assertEqual(len(recovery_points), 1) - self.assertTrue(np.allclose(recovery_points[0], [0.5, -0.2])) - - def test_geometry_projects_outside_point_back_to_boundary(self): - geometry = self._box_geometry() - - projected = geometry.project_segment_to_domain( - np.asarray([0.2, 0.5], dtype=float), - np.asarray([-0.2, 0.5], dtype=float), - ) - - self.assertGreaterEqual(geometry.classify_points(projected.reshape(1, -1))[0], 0) - self.assertAlmostEqual(projected[0], 0.0, places=4) - - def test_final_cleanup_snaps_near_fixed_neighbors_to_boundary(self): - geometry = self._box_geometry() - points = np.asarray( - [ - [0.0, 0.0], - [0.0, 1.0], - [0.05, 0.5], - ], - dtype=float, - ) - states = np.asarray([STATE_FIXED, STATE_FIXED, STATE_MOBILE], dtype=int) - - snapped_points, snapped_states = snap_final_boundary_points( - points, - states, - geometry, - a0=0.5, - params={ - "boundary_condition_acceptable_fuzz": 1.0e-6, - "boundary_condition_max_nr_correction_steps": 200, - }, - ) - - self.assertAlmostEqual(snapped_points[2][0], 0.0, places=5) - self.assertEqual(int(snapped_states[2]), STATE_BOUNDARY) - - def test_final_cleanup_removes_outside_dynamic_points(self): - geometry = self._box_geometry() - engine = RelaxationEngine( - geometry, - {"parameters": {}}, - 0.5, - np.empty((0, 2), dtype=float), - np.empty((0, 2), dtype=float), - np.empty((0, 2), dtype=float), - [False, False], - ) - engine.points = np.asarray( - [ - [0.2, 0.2], - [0.8, 0.2], - [0.2, 0.8], - [1.2, 0.5], - ], - dtype=float, - ) - engine.states = np.asarray( - [STATE_MOBILE, STATE_MOBILE, STATE_MOBILE, STATE_MOBILE], - dtype=int, - ) - engine.last_point_density = np.ones(len(engine.points), dtype=float) - - points, states = engine._final_output_points_and_states() - - self.assertEqual(len(points), 3) - self.assertEqual(states.tolist(), [STATE_MOBILE, STATE_MOBILE, STATE_MOBILE]) - - def test_assemble_raw_mesh_filters_flat_boundary_sliver(self): - geometry = fem_geometry_from_bodies( - (np.asarray([0.0, 0.0]), np.asarray([1.0, 0.01])), - [Box([0.0, 0.0], [1.0, 0.01]).obj], - [], - ) - - raw_mesh = assemble_raw_mesh( - np.asarray( - [ - [0.0, 0.0], - [1.0, 0.0], - [0.5, 0.01], - ], - dtype=float, - ), - geometry, - [False, False], - params={"controller_smallest_allowed_volume_ratio": 1.0}, - ) - - self.assertEqual(raw_mesh.simplices, []) - - def test_assemble_raw_mesh_keeps_regular_boundary_simplex(self): - height = np.sqrt(3.0) / 2.0 - geometry = fem_geometry_from_bodies( - (np.asarray([0.0, 0.0]), np.asarray([1.0, height])), - [Box([0.0, 0.0], [1.0, height]).obj], - [], - ) - - raw_mesh = assemble_raw_mesh( - np.asarray( - [ - [0.0, 0.0], - [1.0, 0.0], - [0.5, height], - ], - dtype=float, - ), - geometry, - [False, False], - params={"controller_smallest_allowed_volume_ratio": 1.0}, - ) - - self.assertEqual(len(raw_mesh.simplices), 1) - - def test_simplex_orientation_is_normalized_positive(self): - points = np.asarray( - [ - [0.0, 0.0], - [1.0, 0.0], - [0.0, 1.0], - ], - dtype=float, - ) - simplices = np.asarray([[0, 2, 1]], dtype=int) - - oriented = _orient_simplices_positive(points, simplices, dim=2) - determinant = np.linalg.det(points[oriented[0, 1:]] - points[oriented[0, [0]]]) - - self.assertGreater(determinant, 0.0) - - def test_periodic_groups_merge_multi_axis_corners(self): - points = np.asarray( - [ - [0.0, 0.0], - [0.0, 1.0], - [1.0, 0.0], - [1.0, 1.0], - [0.5, 0.0], - [0.5, 1.0], - ], - dtype=float, - ) - - groups = build_periodic_groups( - points, - np.asarray([0.0, 0.0]), - np.asarray([1.0, 1.0]), - [True, True], - tolerance=1.0e-6, - ) - - self.assertIn([0, 1, 2, 3], groups) - self.assertIn([4, 5], groups) - - def test_periodic_groups_do_not_round_non_periodic_coordinates(self): - points = np.asarray( - [ - [0.0, 0.5], - [1.0, 0.5 + 1.0e-9], - ], - dtype=float, - ) - - groups = build_periodic_groups( - points, - np.asarray([0.0, 0.0]), - np.asarray([1.0, 1.0]), - [True, False], - tolerance=1.0e-6, - ) - - self.assertEqual(groups, []) - - def test_density_string_translation_supports_c_style_blocks(self): - density = _compile_density_function( - """ - double upper=7.1; - double lower=5.9; - if ((x[0] < 2.1) && (x[0] > -2.1) && (x[1] > lower) && (x[1] < upper)) - {density = 4.0;} - else { - double sigma=1.0; - double xdev = 0.0-x[0]; - double ydev = 0.0-x[1]; - double rdev2 = xdev*xdev+ydev*ydev; - density=1.0+4.0*exp(-rdev2/(sigma*sigma)); - } - """ - ) - - self.assertAlmostEqual(density(np.asarray([0.0, 6.0])), 4.0) - self.assertAlmostEqual(density(np.asarray([0.0, 0.0])), 5.0) - - def test_invalid_density_string_raises_instead_of_falling_back(self): - with self.assertRaises(ValueError): - _compile_density_function("density = ;") - - def test_engine_honors_configured_max_steps_without_python_cap(self): - engine = RelaxationEngine( - self._box_geometry(), - {"parameters": {"controller_step_limit_max": 250, "nr_probes_for_determining_volume": 200}}, - 0.5, - np.empty((0, 2), dtype=float), - np.empty((0, 2), dtype=float), - np.empty((0, 2), dtype=float), - [False, False], - ) - - self.assertEqual(engine.max_steps, 250) - - def test_boundary_drift_tolerance_does_not_scale_with_a0(self): - engine = RelaxationEngine( - self._box_geometry(), - {"parameters": {"nr_probes_for_determining_volume": 200}}, - 10.0, - np.empty((0, 2), dtype=float), - np.empty((0, 2), dtype=float), - np.empty((0, 2), dtype=float), - [False, False], - ) - - self.assertEqual(engine.boundary_drift_tolerance, BOUNDARY_FUZZ) - - def test_point_change_schedule_uses_legacy_square_steps(self): - engine = RelaxationEngine( - self._box_geometry(), - {"parameters": {"controller_step_limit_max": 100, "nr_probes_for_determining_volume": 200}}, - 0.5, - np.empty((0, 2), dtype=float), - np.empty((0, 2), dtype=float), - np.empty((0, 2), dtype=float), - [False, False], - ) - - scheduled_steps = [] - for step in range(1, 30): - engine.step = step - if engine._should_attempt_point_change(): - scheduled_steps.append(step) - - self.assertEqual(scheduled_steps, [11, 14, 19, 26]) - - def test_simply_points_disable_generated_seed_points(self): - simply_points = np.asarray( - [ - [0.1, 0.1], - [0.9, 0.1], - [0.5, 0.8], - ], - dtype=float, - ) - - points, states = _prepare_initial_points( - self._box_geometry(), - 0.25, - np.empty((0, 2), dtype=float), - np.empty((0, 2), dtype=float), - simply_points, - [False, False], - np.random.default_rng(97), - ) - - self.assertEqual(points.tolist(), simply_points.tolist()) - self.assertEqual(states.tolist(), [STATE_SIMPLE, STATE_SIMPLE, STATE_SIMPLE]) - - def test_user_mobile_points_disable_generated_seed_points(self): - mobile_points = np.asarray( - [ - [0.2, 0.2], - [0.8, 0.2], - [0.5, 0.7], - ], - dtype=float, - ) - - points, _states = _prepare_initial_points( - self._box_geometry(), - 0.25, - np.empty((0, 2), dtype=float), - mobile_points, - np.empty((0, 2), dtype=float), - [False, False], - np.random.default_rng(97), - ) - - self.assertEqual(points.tolist(), mobile_points.tolist()) - - def test_step_limit_waits_for_post_change_settling(self): - engine = RelaxationEngine( - self._box_geometry(), - {"parameters": {"controller_step_limit_max": 20, "nr_probes_for_determining_volume": 200}}, - 0.5, - np.empty((0, 2), dtype=float), - np.empty((0, 2), dtype=float), - np.empty((0, 2), dtype=float), - [False, False], - ) - - engine.last_addition_deletion_step = 11 - engine.step = 20 - self.assertFalse(engine._step_limit_reached()) - engine.step = 61 - self.assertTrue(engine._step_limit_reached()) - - def test_meshing_is_deterministic_for_identical_inputs(self): - kwargs = dict( - bounding_box=[[0.0, 0.0], [1.0, 1.0]], - objects=[Box([0.1, 0.1], [0.9, 0.9])], - a0=0.5, - max_steps=20, - nr_probes_for_determining_volume=500, - ) - - mesh_a = Mesh(**kwargs) - mesh_b = Mesh(**kwargs) - - self.assertEqual(mesh_a.points, mesh_b.points) - self.assertEqual(mesh_a.simplices, mesh_b.simplices) - self.assertEqual(mesh_a.regions, mesh_b.regions) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/nmesh/test_defaults.py b/tests/nmesh/test_meshing_parameters.py similarity index 64% rename from tests/nmesh/test_defaults.py rename to tests/nmesh/test_meshing_parameters.py index ad2bb23..a960b0f 100644 --- a/tests/nmesh/test_defaults.py +++ b/tests/nmesh/test_meshing_parameters.py @@ -1,15 +1,9 @@ -import pytest -import numpy as np from nmesh.mesher.meshing_parameters import ( MeshingParameters, - default_initial_relaxation_weight, - default_relaxation_force_fun, - default_boundary_node_force_fun, - default_handle_point_density_fun, PointFate, - MIN_DIVISION_MAGNITUDE, ) + def test_meshing_parameters_defaults(): params = MeshingParameters() assert params["controller_shape_force_scale"] == 0.1 @@ -17,17 +11,19 @@ def test_meshing_parameters_defaults(): assert params["controller_neigh_force_scale"] == 1.0 assert params["controller_step_limit_max"] == 1000 + def test_meshing_parameters_setters(): params = MeshingParameters() params.dim = 3 params.set_shape_force_scale(0.5) assert params["shape_force_scale"] == 0.5 assert params["controller_shape_force_scale"] == 0.5 - + params.set_max_steps(2000) assert params["max_steps"] == 2000 assert params["controller_step_limit_max"] == 2000 + def test_meshing_parameters_preserve_public_setter_api(): """Verify all public parameter setters are dynamically generated.""" params = MeshingParameters() @@ -49,12 +45,13 @@ def test_meshing_parameters_preserve_public_setter_api(): ): assert callable(getattr(params, setter_name)) + def test_meshing_parameters_getitem_setitem(): params = MeshingParameters() params.dim = 3 params["custom_param"] = 123 assert params["custom_param"] == 123 - + # Test overriding defaults params["controller_shape_force_scale"] = 0.9 assert params["shape_force_scale"] == 0.9 @@ -64,6 +61,7 @@ def test_meshing_parameters_getitem_setitem(): assert params["controller_step_limit_max"] == 1500 assert params["max_steps"] == 1500 + def test_meshing_parameters_copy(): params = MeshingParameters() params["val"] = 1 @@ -72,6 +70,7 @@ def test_meshing_parameters_copy(): assert params["val"] == 1 assert params2["val"] == 2 + def test_meshing_parameters_apply_to_mesher(): params = MeshingParameters() params["shape_force_scale"] = 0.5 @@ -84,6 +83,7 @@ def test_meshing_parameters_apply_to_mesher(): assert mesher["parameters"]["controller_shape_force_scale"] == 0.5 assert mesher["parameters"]["controller_step_limit_max"] == 2000 + def test_meshing_parameters_can_load_config_string(): """Test loading config with public API names (user-friendly INI format).""" params = MeshingParameters( @@ -103,120 +103,6 @@ def test_meshing_parameters_can_load_config_string(): assert params["controller_step_limit_max"] == 1500 -# Test default force functions -def test_default_initial_relaxation_weight_normal_case(): - # At step 0, should be init_val - assert default_initial_relaxation_weight(0, 100, 1.0, 5.0) == 1.0 - - # At step 50 (halfway), should be midpoint - assert default_initial_relaxation_weight(50, 100, 1.0, 5.0) == 3.0 - - # At step 100 (max), should be final_val - assert default_initial_relaxation_weight(100, 100, 1.0, 5.0) == 5.0 - - # Beyond max_step, should saturate at final_val - assert default_initial_relaxation_weight(150, 100, 1.0, 5.0) == 5.0 - - -def test_default_initial_relaxation_weight_edge_cases(): - # When max_step <= 0, should return final_val immediately - assert default_initial_relaxation_weight(0, 0, 1.0, 5.0) == 5.0 - assert default_initial_relaxation_weight(50, 0, 1.0, 5.0) == 5.0 - assert default_initial_relaxation_weight(0, -10, 1.0, 5.0) == 5.0 - - -def test_default_relaxation_force_fun(): - # Distance > 1.0 should return 0 - assert default_relaxation_force_fun(1.5) == 0.0 - assert default_relaxation_force_fun(2.0) == 0.0 - - # At exactly 1.0 - assert default_relaxation_force_fun(1.0) == 0.0 - - # At 0.5, force should be 0.5 - assert default_relaxation_force_fun(0.5) == 0.5 - - # At 0.0, force should be 1.0 (maximum repulsion) - assert default_relaxation_force_fun(0.0) == 1.0 - - -def test_default_boundary_node_force_fun(): - # Distance > 1.0 should return 0 - assert default_boundary_node_force_fun(1.5) == 0.0 - assert default_boundary_node_force_fun(2.0) == 0.0 - - # At exactly 1.0 - assert default_boundary_node_force_fun(1.0) == 0.0 - - # Very small distance should return large repulsion - assert default_boundary_node_force_fun(MIN_DIVISION_MAGNITUDE / 2) == 1e12 - assert default_boundary_node_force_fun(0.0) == 1e12 - - # Normal distance: 1/d - 1 - result = default_boundary_node_force_fun(0.5) - expected = 1.0 / 0.5 - 1.0 - assert abs(result - expected) < 1e-10 - - -def test_default_handle_point_density_fun_low_density(): - # Mock RNG that always returns low value to trigger add - class MockRng: - def random(self): - return 0.05 # < 0.1, will trigger add - - rng = MockRng() - result = default_handle_point_density_fun(rng, (0.5, 0.1), 1.0, 2.0) - assert result == PointFate.ADD_ANOTHER - - -def test_default_handle_point_density_fun_low_force(): - # Mock RNG for force-based add - class MockRng: - def random(self): - return 0.15 # < 0.2, will trigger add - - rng = MockRng() - # Density okay but force very low - result = default_handle_point_density_fun(rng, (1.5, 0.05), 1.0, 2.0) - assert result == PointFate.ADD_ANOTHER - - -def test_default_handle_point_density_fun_high_density(): - # Mock RNG for density-based delete - class MockRng: - def random(self): - return 0.25 # Will trigger delete with right probability - - rng = MockRng() - # Density too high - result = default_handle_point_density_fun(rng, (2.5, 0.2), 1.0, 2.0) - assert result == PointFate.DELETE - - -def test_default_handle_point_density_fun_high_force(): - # Mock RNG for force-based delete - class MockRng: - def random(self): - return 0.35 # Will trigger delete with right probability - - rng = MockRng() - # Force too high - result = default_handle_point_density_fun(rng, (1.5, 0.6), 1.0, 2.0) - assert result == PointFate.DELETE - - -def test_default_handle_point_density_fun_do_nothing(): - # Mock RNG that returns high values (won't trigger anything) - class MockRng: - def random(self): - return 0.99 - - rng = MockRng() - # Normal conditions, high RNG value = do nothing - result = default_handle_point_density_fun(rng, (1.5, 0.3), 1.0, 2.0) - assert result == PointFate.DO_NOTHING - - def test_to_mesher_config_returns_resolved_dict(): """Test that to_mesher_config returns dict with internal parameter names. diff --git a/tests/nmesh/test_parity.py b/tests/nmesh/test_parity.py index 6efe756..0b9c0d2 100644 --- a/tests/nmesh/test_parity.py +++ b/tests/nmesh/test_parity.py @@ -1,13 +1,14 @@ import pytest from nmesh.backend import RawMesh +from nmesh.io.ascii import read_ascii_nmesh from nmesh.mesher.parity import ( assert_canonical_mesh_equal, canonical_mesh_signature, compare_mesh_metrics, mesh_metric_summary, - read_ascii_nmesh, ) +from nmesh.nmesh import write_mesh def _reference_triangle() -> RawMesh: @@ -105,6 +106,24 @@ def test_read_ascii_nmesh_accepts_legacy_surface_and_periodic_rows(tmp_path): assert raw_mesh.region_volumes == [0.5] +def test_ascii_nmesh_round_trip_preserves_nanoscale_points_and_periodic_groups(tmp_path): + mesh_path = tmp_path / "nanoscale.nmesh" + raw_mesh = RawMesh( + points=[[0.0, 0.0], [1.25e-9, 0.0], [0.0, 2.5e-9]], + simplices=[[0, 1, 2]], + regions=[4], + periodic_point_indices=[[0, 1], [1, 2, 0]], + dim=2, + ) + + write_mesh(raw_mesh, out=mesh_path) + loaded = read_ascii_nmesh(mesh_path) + + assert loaded.points == raw_mesh.points + assert loaded.periodic_point_indices == raw_mesh.periodic_point_indices + assert "periodic = 2" in mesh_path.read_text(encoding="utf-8") + + def test_mesh_metric_summary_reports_geometry_quality_metrics(): summary = mesh_metric_summary(_reference_triangle()) diff --git a/tests/nmesh/test_relaxation_boundaries.py b/tests/nmesh/test_relaxation_boundaries.py new file mode 100644 index 0000000..4570bcc --- /dev/null +++ b/tests/nmesh/test_relaxation_boundaries.py @@ -0,0 +1,94 @@ +import unittest + +import numpy as np + +from nmesh import Box +from nmesh.mesher.relaxation import RelaxationEngine, fem_geometry_from_bodies +from nmesh.mesher.relaxation._constants import STATE_BOUNDARY, STATE_FIXED, STATE_MOBILE +from nmesh.mesher.relaxation.seeding import _classify_dynamic_states +from nmesh.mesher.relaxation.topology.finalize import snap_final_boundary_points +from nmesh.mesher.relaxation.topology.recovery import mirror_surface_recovery_points + + +class TestRelaxationBoundaries(unittest.TestCase): + def _box_geometry(self): + return fem_geometry_from_bodies( + (np.asarray([0.0, 0.0]), np.asarray([1.0, 1.0])), + [Box([0.0, 0.0], [1.0, 1.0]).obj], + [], + ) + + def test_boundary_state_detection_marks_surface_points(self): + geometry = self._box_geometry() + points = np.asarray([[0.0, 0.5], [0.5, 0.5]], dtype=float) + + states = _classify_dynamic_states(geometry, points, a0=0.5) + + self.assertEqual(states.tolist(), [STATE_BOUNDARY, STATE_MOBILE]) + + def test_surface_recovery_mirrors_poor_2d_boundary_simplex(self): + geometry = self._box_geometry() + points = np.asarray([[0.0, 0.0], [1.0, 0.0], [0.5, 0.2]], dtype=float) + states = np.asarray([STATE_BOUNDARY, STATE_BOUNDARY, STATE_MOBILE], dtype=int) + + recovery_points = mirror_surface_recovery_points( + points, + states, + geometry, + a0=0.5, + simplices=np.asarray([[0, 1, 2]], dtype=int), + ) + + self.assertEqual(len(recovery_points), 1) + self.assertTrue(np.allclose(recovery_points[0], [0.5, -0.2])) + + def test_geometry_projects_outside_point_back_to_boundary(self): + geometry = self._box_geometry() + + projected = geometry.project_segment_to_domain( + np.asarray([0.2, 0.5], dtype=float), + np.asarray([-0.2, 0.5], dtype=float), + ) + + self.assertGreaterEqual(geometry.classify_points(projected.reshape(1, -1))[0], 0) + self.assertAlmostEqual(projected[0], 0.0, places=4) + + def test_final_cleanup_snaps_near_fixed_neighbors_to_boundary(self): + geometry = self._box_geometry() + points = np.asarray([[0.0, 0.0], [0.0, 1.0], [0.05, 0.5]], dtype=float) + states = np.asarray([STATE_FIXED, STATE_FIXED, STATE_MOBILE], dtype=int) + + snapped_points, snapped_states = snap_final_boundary_points( + points, + states, + geometry, + a0=0.5, + params={ + "boundary_condition_acceptable_fuzz": 1.0e-6, + "boundary_condition_max_nr_correction_steps": 200, + }, + ) + + self.assertAlmostEqual(snapped_points[2][0], 0.0, places=5) + self.assertEqual(int(snapped_states[2]), STATE_BOUNDARY) + + def test_final_cleanup_removes_outside_dynamic_points(self): + engine = RelaxationEngine( + self._box_geometry(), + {"parameters": {}}, + 0.5, + np.empty((0, 2), dtype=float), + np.empty((0, 2), dtype=float), + np.empty((0, 2), dtype=float), + [False, False], + ) + engine.points = np.asarray([[0.2, 0.2], [0.8, 0.2], [0.2, 0.8], [1.2, 0.5]], dtype=float) + engine.states = np.asarray( + [STATE_MOBILE, STATE_MOBILE, STATE_MOBILE, STATE_MOBILE], dtype=int + ) + engine.last_point_density = np.ones(len(engine.points), dtype=float) + + points, states = engine._final_output_points_and_states() + + self.assertEqual(len(points), 3) + self.assertEqual(states.tolist(), [STATE_MOBILE, STATE_MOBILE, STATE_MOBILE]) diff --git a/tests/nmesh/test_relaxation_engine.py b/tests/nmesh/test_relaxation_engine.py new file mode 100644 index 0000000..c581583 --- /dev/null +++ b/tests/nmesh/test_relaxation_engine.py @@ -0,0 +1,220 @@ +import unittest + +import numpy as np + +from nmesh import Box, Mesh +from nmesh.mesher.periodic import build_periodic_groups +from nmesh.mesher.relaxation import ( + RelaxationEngine, + _compile_density_function, + assemble_raw_mesh, + fem_geometry_from_bodies, +) +from nmesh.mesher.relaxation._constants import BOUNDARY_FUZZ, STATE_SIMPLE +from nmesh.mesher.relaxation.seeding import _prepare_initial_points +from nmesh.mesher.relaxation.topology import _orient_simplices_positive + + +class TestRelaxationEngine(unittest.TestCase): + def _box_geometry(self): + return fem_geometry_from_bodies( + (np.asarray([0.0, 0.0]), np.asarray([1.0, 1.0])), + [Box([0.0, 0.0], [1.0, 1.0]).obj], + [], + ) + + def test_assemble_raw_mesh_filters_flat_boundary_sliver(self): + geometry = fem_geometry_from_bodies( + (np.asarray([0.0, 0.0]), np.asarray([1.0, 0.01])), + [Box([0.0, 0.0], [1.0, 0.01]).obj], + [], + ) + raw_mesh = assemble_raw_mesh( + np.asarray([[0.0, 0.0], [1.0, 0.0], [0.5, 0.01]], dtype=float), + geometry, + [False, False], + params={"controller_smallest_allowed_volume_ratio": 1.0}, + ) + + self.assertEqual(raw_mesh.simplices, []) + + def test_assemble_raw_mesh_keeps_regular_boundary_simplex(self): + height = np.sqrt(3.0) / 2.0 + geometry = fem_geometry_from_bodies( + (np.asarray([0.0, 0.0]), np.asarray([1.0, height])), + [Box([0.0, 0.0], [1.0, height]).obj], + [], + ) + raw_mesh = assemble_raw_mesh( + np.asarray([[0.0, 0.0], [1.0, 0.0], [0.5, height]], dtype=float), + geometry, + [False, False], + params={"controller_smallest_allowed_volume_ratio": 1.0}, + ) + + self.assertEqual(len(raw_mesh.simplices), 1) + + def test_simplex_orientation_is_normalized_positive(self): + points = np.asarray([[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]], dtype=float) + simplices = np.asarray([[0, 2, 1]], dtype=int) + + oriented = _orient_simplices_positive(points, simplices, dim=2) + determinant = np.linalg.det(points[oriented[0, 1:]] - points[oriented[0, [0]]]) + + self.assertGreater(determinant, 0.0) + + def test_periodic_groups_merge_multi_axis_corners(self): + points = np.asarray( + [[0.0, 0.0], [0.0, 1.0], [1.0, 0.0], [1.0, 1.0], [0.5, 0.0], [0.5, 1.0]], + dtype=float, + ) + + groups = build_periodic_groups( + points, + np.asarray([0.0, 0.0]), + np.asarray([1.0, 1.0]), + [True, True], + tolerance=1.0e-6, + ) + + self.assertIn([0, 1, 2, 3], groups) + self.assertIn([4, 5], groups) + + def test_periodic_groups_do_not_round_non_periodic_coordinates(self): + points = np.asarray([[0.0, 0.5], [1.0, 0.5 + 1.0e-9]], dtype=float) + + groups = build_periodic_groups( + points, + np.asarray([0.0, 0.0]), + np.asarray([1.0, 1.0]), + [True, False], + tolerance=1.0e-6, + ) + + self.assertEqual(groups, []) + + def test_density_string_translation_supports_c_style_blocks(self): + density = _compile_density_function( + """ + double upper=7.1; + double lower=5.9; + if ((x[0] < 2.1) && (x[0] > -2.1) && (x[1] > lower) && (x[1] < upper)) + {density = 4.0;} + else { + double sigma=1.0; + double xdev = 0.0-x[0]; + double ydev = 0.0-x[1]; + double rdev2 = xdev*xdev+ydev*ydev; + density=1.0+4.0*exp(-rdev2/(sigma*sigma)); + } + """ + ) + + self.assertAlmostEqual(density(np.asarray([0.0, 6.0])), 4.0) + self.assertAlmostEqual(density(np.asarray([0.0, 0.0])), 5.0) + + def test_invalid_density_string_raises_instead_of_falling_back(self): + with self.assertRaises(ValueError): + _compile_density_function("density = ;") + + def test_engine_honors_configured_max_steps_without_python_cap(self): + engine = self._new_engine( + {"controller_step_limit_max": 250, "nr_probes_for_determining_volume": 200} + ) + + self.assertEqual(engine.max_steps, 250) + + def test_boundary_drift_tolerance_does_not_scale_with_a0(self): + engine = RelaxationEngine( + self._box_geometry(), + {"parameters": {"nr_probes_for_determining_volume": 200}}, + 10.0, + np.empty((0, 2), dtype=float), + np.empty((0, 2), dtype=float), + np.empty((0, 2), dtype=float), + [False, False], + ) + + self.assertEqual(engine.boundary_drift_tolerance, BOUNDARY_FUZZ) + + def test_point_change_schedule_uses_legacy_square_steps(self): + engine = self._new_engine( + {"controller_step_limit_max": 100, "nr_probes_for_determining_volume": 200} + ) + + scheduled_steps = [] + for step in range(1, 30): + engine.step = step + if engine._should_attempt_point_change(): + scheduled_steps.append(step) + + self.assertEqual(scheduled_steps, [11, 14, 19, 26]) + + def test_simply_points_disable_generated_seed_points(self): + simply_points = np.asarray([[0.1, 0.1], [0.9, 0.1], [0.5, 0.8]], dtype=float) + + points, states = _prepare_initial_points( + self._box_geometry(), + 0.25, + np.empty((0, 2), dtype=float), + np.empty((0, 2), dtype=float), + simply_points, + [False, False], + np.random.default_rng(97), + ) + + self.assertEqual(points.tolist(), simply_points.tolist()) + self.assertEqual(states.tolist(), [STATE_SIMPLE, STATE_SIMPLE, STATE_SIMPLE]) + + def test_user_mobile_points_disable_generated_seed_points(self): + mobile_points = np.asarray([[0.2, 0.2], [0.8, 0.2], [0.5, 0.7]], dtype=float) + + points, _states = _prepare_initial_points( + self._box_geometry(), + 0.25, + np.empty((0, 2), dtype=float), + mobile_points, + np.empty((0, 2), dtype=float), + [False, False], + np.random.default_rng(97), + ) + + self.assertEqual(points.tolist(), mobile_points.tolist()) + + def test_step_limit_waits_for_post_change_settling(self): + engine = self._new_engine( + {"controller_step_limit_max": 20, "nr_probes_for_determining_volume": 200} + ) + + engine.last_addition_deletion_step = 11 + engine.step = 20 + self.assertFalse(engine._step_limit_reached()) + engine.step = 61 + self.assertTrue(engine._step_limit_reached()) + + def test_meshing_is_deterministic_for_identical_inputs(self): + kwargs = dict( + bounding_box=[[0.0, 0.0], [1.0, 1.0]], + objects=[Box([0.1, 0.1], [0.9, 0.9])], + a0=0.5, + max_steps=20, + nr_probes_for_determining_volume=500, + ) + + mesh_a = Mesh(**kwargs) + mesh_b = Mesh(**kwargs) + + self.assertEqual(mesh_a.points, mesh_b.points) + self.assertEqual(mesh_a.simplices, mesh_b.simplices) + self.assertEqual(mesh_a.regions, mesh_b.regions) + + def _new_engine(self, parameters): + return RelaxationEngine( + self._box_geometry(), + {"parameters": parameters}, + 0.5, + np.empty((0, 2), dtype=float), + np.empty((0, 2), dtype=float), + np.empty((0, 2), dtype=float), + [False, False], + ) diff --git a/tests/nmesh/test_relaxation_forces.py b/tests/nmesh/test_relaxation_forces.py new file mode 100644 index 0000000..0216fed --- /dev/null +++ b/tests/nmesh/test_relaxation_forces.py @@ -0,0 +1,129 @@ +import unittest + +import numpy as np + +from nmesh import Box +from nmesh.mesher.relaxation import fem_geometry_from_bodies +from nmesh.mesher.relaxation._constants import STATE_BOUNDARY, STATE_MOBILE +from nmesh.mesher.relaxation.forces import ( + _classify_relevant_simplices, + _extract_force_parameters, + compute_forces, +) + + +class TestRelaxationForces(unittest.TestCase): + def _box_geometry(self): + return fem_geometry_from_bodies( + (np.asarray([0.0, 0.0]), np.asarray([1.0, 1.0])), + [Box([0.0, 0.0], [1.0, 1.0]).obj], + [], + ) + + def test_force_summary_suppresses_shape_force_at_boundary_corners(self): + geometry = self._box_geometry() + points = np.asarray( + [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0], [0.6, 0.45]], + dtype=float, + ) + states = np.full(len(points), STATE_MOBILE, dtype=int) + summary = compute_forces( + points, + states, + geometry, + a0=0.5, + params={ + "controller_shape_force_scale": 0.3, + "controller_volume_force_scale": 0.0, + "controller_neigh_force_scale": 0.0, + "controller_irrel_elem_force_scale": 0.0, + "controller_sliver_correction": 1.0, + "controller_initial_settling_steps": 100, + }, + step=10, + ) + + self.assertGreaterEqual(summary.simplices.shape[0], 2) + self.assertTrue(np.allclose(summary.total_forces[:4], 0.0)) + self.assertGreater(np.linalg.norm(summary.total_forces[4]), 0.0) + self.assertGreater(summary.max_effective_force, 0.0) + + def test_boundary_effective_force_uses_tangential_projection(self): + geometry = self._box_geometry() + points = np.asarray([[0.0, 0.5], [0.2, 0.4], [0.2, 0.6]], dtype=float) + states = np.asarray([STATE_BOUNDARY, STATE_MOBILE, STATE_MOBILE], dtype=int) + summary = compute_forces( + points, + states, + geometry, + a0=0.4, + params={ + "controller_shape_force_scale": 0.0, + "controller_volume_force_scale": 0.0, + "controller_neigh_force_scale": 1.0, + "controller_irrel_elem_force_scale": 0.0, + "controller_sliver_correction": 1.0, + "controller_initial_settling_steps": 100, + }, + step=10, + ) + + self.assertGreater(abs(summary.total_forces[0][0]), 0.0) + self.assertAlmostEqual(summary.total_forces[0][1], 0.0, places=7) + self.assertAlmostEqual(summary.point_effective_force[0], 0.0, places=7) + + def test_custom_neighbor_force_callbacks_use_python_path(self): + geometry = self._box_geometry() + points = np.asarray([[0.0, 0.5], [0.2, 0.4], [0.2, 0.6]], dtype=float) + states = np.asarray([STATE_BOUNDARY, STATE_MOBILE, STATE_MOBILE], dtype=int) + force_calls = [] + boundary_force_calls = [] + + def force_fun(reduced_distance): + force_calls.append(reduced_distance) + return 1.0 - reduced_distance + + def boundary_force_fun(reduced_distance): + boundary_force_calls.append(reduced_distance) + return 1.0 - reduced_distance + + summary = compute_forces( + points, + states, + geometry, + a0=0.4, + params={ + "controller_shape_force_scale": 0.0, + "controller_volume_force_scale": 0.0, + "controller_neigh_force_scale": 1.0, + "controller_irrel_elem_force_scale": 0.0, + "relaxation_force_fun": force_fun, + "boundary_node_force_fun": boundary_force_fun, + }, + step=10, + simplices=np.asarray([[0, 1, 2]], dtype=int), + ) + + self.assertEqual(len(force_calls), 1) + self.assertAlmostEqual(force_calls[0], 0.5) + self.assertEqual(len(boundary_force_calls), 2) + self.assertGreater(np.linalg.norm(summary.total_forces), 0.0) + + def test_force_relevant_simplex_classifier_rejects_boundary_crossing_probe(self): + geometry = self._box_geometry() + points = np.asarray([[0.5, 0.5], [1.2, 0.5], [0.5, 0.8]], dtype=float) + simplices = np.asarray([[0, 1, 2]], dtype=int) + states = np.full(len(points), STATE_MOBILE, dtype=int) + measures = np.asarray([0.105], dtype=float) + + mask = _classify_relevant_simplices( + points, + states, + geometry, + simplices, + measures, + 2, + _extract_force_parameters({}, step=10), + ) + + self.assertFalse(bool(mask[0])) diff --git a/tests/nmesh/test_utils.py b/tests/nmesh/test_utils.py index ede0127..ab252e2 100644 --- a/tests/nmesh/test_utils.py +++ b/tests/nmesh/test_utils.py @@ -1,12 +1,16 @@ +import os import unittest + import numpy as np + import nmesh.utils as utils -import os + class TestUtils(unittest.TestCase): def test_array_filter(self): arr = [1, 2, 3, 4, 5] - p = lambda x: x % 2 == 0 + def p(x): + return x % 2 == 0 expected = [2, 4] np.testing.assert_array_equal(utils.array_filter(p, arr), expected) @@ -18,7 +22,8 @@ def test_array_position(self): def test_array_position_if(self): arr = [1, 3, 5, 8, 10] - p = lambda x: x % 2 == 0 + def p(x): + return x % 2 == 0 self.assertEqual(utils.array_position_if(p, arr), 3) self.assertEqual(utils.array_position_if(p, arr, start=4), 4) diff --git a/tests/nmesh/utils/test_array_list_utils.py b/tests/nmesh/utils/test_array_list_utils.py index 512b71e..8ccdacd 100644 --- a/tests/nmesh/utils/test_array_list_utils.py +++ b/tests/nmesh/utils/test_array_list_utils.py @@ -1,14 +1,15 @@ import unittest import numpy as np -import pytest + import nmesh.utils.array_list_utils as array_utils class TestArrayListUtils(unittest.TestCase): def test_array_filter(self): arr = [1, 2, 3, 4, 5] - p = lambda x: x % 2 == 0 + def p(x): + return x % 2 == 0 expected = [2, 4] np.testing.assert_array_equal(array_utils.array_filter(p, arr), expected) @@ -20,7 +21,8 @@ def test_array_position(self): def test_array_position_if(self): arr = [1, 3, 5, 8, 10] - p = lambda x: x % 2 == 0 + def p(x): + return x % 2 == 0 self.assertEqual(array_utils.array_position_if(p, arr), 3) self.assertEqual(array_utils.array_position_if(p, arr, start=4), 4) @@ -53,13 +55,15 @@ def test_cross_product_3d(self): def test_array_filter_empty_result(self): arr = [1, 3, 5, 7] - p = lambda x: x % 2 == 0 # No even numbers + def p(x): + return x % 2 == 0 # No even numbers result = array_utils.array_filter(p, arr) self.assertEqual(len(result), 0) def test_array_filter_all_match(self): arr = [2, 4, 6, 8] - p = lambda x: x % 2 == 0 # All even + def p(x): + return x % 2 == 0 # All even result = array_utils.array_filter(p, arr) np.testing.assert_array_equal(result, arr) @@ -73,12 +77,14 @@ def test_array_position_at_start_index(self): def test_array_position_if_not_found(self): arr = [1, 3, 5, 7] - p = lambda x: x % 2 == 0 # No even numbers + def p(x): + return x % 2 == 0 # No even numbers self.assertEqual(array_utils.array_position_if(p, arr), -1) def test_array_position_if_at_boundary(self): arr = [1, 2, 3, 4, 5] - p = lambda x: x == 5 + def p(x): + return x == 5 self.assertEqual(array_utils.array_position_if(p, arr), 4) def test_array_one_shorter_first_element(self): diff --git a/tests/nmesh_test.py b/tests/nmesh_test.py index 4de5eaa..a8bb5c5 100644 --- a/tests/nmesh_test.py +++ b/tests/nmesh_test.py @@ -1,22 +1,52 @@ import unittest -import math -from pathlib import Path + import nmesh from nmesh.backend import RawMesh + class TestNMesh(unittest.TestCase): + def test_scale_node_positions_scales_region_volumes(self): + 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.raw_mesh.region_volumes = [1.0 / 6.0] + + mesh.scale_node_positions(2.0) + + self.assertEqual(mesh.region_volumes, [4.0 / 3.0]) + + def test_unimplemented_mesh_distribution_options_fail_explicitly(self): + points = [[0, 0, 0], [1, 0, 0], [0, 1, 0], [0, 0, 1]] + simplices = [[0, 1, 2, 3]] + with self.assertRaisesRegex(NotImplementedError, "reordering"): + nmesh.mesh_from_points_and_simplices( + points=points, + simplices_indices=simplices, + simplices_regions=[1], + do_reorder=True, + ) + with self.assertRaisesRegex(NotImplementedError, "distribution"): + nmesh.mesh_from_points_and_simplices( + points=points, + simplices_indices=simplices, + simplices_regions=[1], + do_distribute=False, + ) + def test_meshing_parameters(self): """Test MeshingParameters setters and getters.""" params = nmesh.get_default_meshing_parameters() params.dim = 3 - + # Test individual setters params.set_shape_force_scale(0.5) self.assertEqual(params["shape_force_scale"], 0.5) - + params.set_max_steps(5000) self.assertEqual(params["max_steps"], 5000) - + # Test item setting params["volume_force_scale"] = 0.1 self.assertEqual(params["volume_force_scale"], 0.1) @@ -26,11 +56,11 @@ def test_box_primitive(self): p1 = [0.0, 0.0, 0.0] p2 = [1.0, 1.0, 1.0] b = nmesh.Box(p1, p2, use_fixed_corners=True) - + self.assertEqual(b.dim, 3) # 8 corners for a 3D box self.assertEqual(len(b.fixed_points), 8) - + # Test transformation b.shift([1.0, 0.0, 0.0]) b.scale([2.0, 2.0, 2.0]) @@ -40,10 +70,10 @@ def test_csg_operations(self): """Test CSG operations like union and difference.""" b1 = nmesh.Box([0,0,0], [1,1,1]) b2 = nmesh.Box([0.5,0.5,0.5], [1.5,1.5,1.5]) - + u = nmesh.union([b1, b2]) self.assertEqual(u.dim, 3) - + d = nmesh.difference(b1, [b2]) self.assertEqual(d.dim, 3) @@ -51,7 +81,7 @@ def test_mesh_generation_produces_topology(self): """Test Mesh class initialization now produces a populated mesh.""" bb = [[0,0,0], [1,1,1]] obj = nmesh.Box([0.2,0.2,0.2], [0.8,0.8,0.8]) - + m = nmesh.Mesh( bounding_box=bb, objects=[obj], @@ -130,10 +160,10 @@ def test_1d_mesh_generation(self): """Test 1D mesh generation logic.""" regions = [(0.0, 1.0), (1.0, 2.0)] discretization = 0.5 - + m = nmesh.generate_1d_mesh(regions, discretization) self.assertIsInstance(m, nmesh.MeshBase) - + pts, simps, regs = nmesh.generate_1d_mesh_components(regions, discretization) self.assertEqual(len(pts), 5) # 0.0, 0.5, 1.0, 1.5, 2.0 self.assertEqual(len(simps), 4) @@ -145,7 +175,7 @@ class MockMesh(nmesh.MeshBase): @property def points(self): return [[0,0], [1,2], [-1,1]] - + m = MockMesh("raw") min_c, max_corner = nmesh.outer_corners(m) self.assertEqual(min_c, [-1, 0]) @@ -157,7 +187,7 @@ def test_write_mesh(self): simplices = [(1, [0, 1])] surfaces = [(1, [0])] data = (points, simplices, surfaces) - + import io out = io.StringIO() nmesh.write_mesh(data, out=out) diff --git a/tests/si_test.py b/tests/si_test.py index 3b58a19..f297024 100644 --- a/tests/si_test.py +++ b/tests/si_test.py @@ -1,193 +1,238 @@ -""" -Unit tests for the Physical (SI) class using the pint library. -""" - -import unittest -import pint -from si.physical import SI - -class TestHardcodedUnits(unittest.TestCase): - """ - Tests that the new Physical (SI) class, backed by pint, correctly - interprets all the unit strings that were hard-coded in the original - legacy class. - - Each test compares a quantity created with a derived unit string (e.g., "J") - to an equivalent quantity constructed from base SI units. This verifies - that the underlying pint parser recognizes the units and their correct - physical definitions. - """ - - def test_base_units(self): - """Test that the seven base SI units are parsed correctly.""" - self.assertEqual(SI(1, "m"), SI(1, "meter")) - self.assertEqual(SI(1, "kg"), SI(1, "kilogram")) - self.assertEqual(SI(1, "s"), SI(1, "second")) - self.assertEqual(SI(1, "A"), SI(1, "ampere")) - self.assertEqual(SI(1, "K"), SI(1, "kelvin")) - self.assertEqual(SI(1, "mol"), SI(1, "mole")) - self.assertEqual(SI(1, "cd"), SI(1, "candela")) - - def test_joule(self): - """Test Joule (J) against its definition: kg * m^2 / s^2""" - joule_from_string = SI(1, 'J') - joule_from_base = SI(1, 'kg * m**2 / s**2') - self.assertEqual(joule_from_string, joule_from_base) - - def test_newton(self): - """Test Newton (N) against its definition: kg * m / s^2""" - newton_from_string = SI(1, 'N') - newton_from_base = SI(1, 'kg * m / s**2') - self.assertEqual(newton_from_string, newton_from_base) - - def test_watt(self): - """Test Watt (W) against its definition: J/s or kg * m^2 / s^3""" - watt_from_string = SI(1, 'W') - watt_from_base = SI(1, 'kg * m**2 / s**3') - self.assertEqual(watt_from_string, watt_from_base) - - def test_tesla(self): - """Test Tesla (T) against its definition: kg / (s^2 * A)""" - tesla_from_string = SI(1, 'T') - tesla_from_base = SI(1, 'kg / (s**2 * A)') - self.assertEqual(tesla_from_string, tesla_from_base) - - def test_volt(self): - """Test Volt (V) against its definition: W/A or kg * m^2 / (s^3 * A)""" - volt_from_string = SI(1, 'V') - volt_from_base = SI(1, 'kg * m**2 / (s**3 * A)') - self.assertEqual(volt_from_string, volt_from_base) - - def test_coulomb(self): - """Test Coulomb (C) against its definition: s * A""" - coulomb_from_string = SI(1, 'C') - coulomb_from_base = SI(1, 's * A') - self.assertEqual(coulomb_from_string, coulomb_from_base) - - def test_ohm(self): - """Test Ohm against its definition: V/A or kg * m^2 / (s^3 * A^2)""" - # pint expects lowercase 'ohm' - ohm_from_string = SI(1, 'ohm') - ohm_from_base = SI(1, 'kg * m**2 / (s**3 * A**2)') - self.assertEqual(ohm_from_string, ohm_from_base) - - def test_henry(self): - """Test Henry (H) against its definition: V*s/A or kg * m^2 / (s^2 * A^2)""" - henry_from_string = SI(1, 'H') - henry_from_base = SI(1, 'kg * m**2 / (s**2 * A**2)') - self.assertEqual(henry_from_string, henry_from_base) - -class TestClassFunctionality(unittest.TestCase): - """ - Tests the core functionality of the Physical (SI) class, including - initialization methods, string representations, and unit conversions. - """ - - def test_initialization_methods(self): - """Verify that the SI class can be initialized in various ways.""" - # Standard initialization with a string - s1 = SI(10, "m/s") - self.assertEqual(s1.magnitude, 10) - self.assertEqual(str(s1._quantity.units), "meter / second") - - # Legacy list-based initialization - s2 = SI(10, ['m', 1, 's', -1]) - self.assertEqual(s1, s2) - - # Shorthand initialization (value is 1.0) - s3 = SI("m/s") - self.assertEqual(s3.magnitude, 1.0) - self.assertEqual(s3, SI(1.0, "m/s")) - - # Dimensionless initialization - s4 = SI(5.0) - s5 = SI(5.0, []) - self.assertTrue(s4._quantity.dimensionless) - self.assertEqual(s4, s5) - - # --- Edge Cases for constructor --- - # Test that an invalid list (uneven items) raises a ValueError - with self.assertRaises(ValueError): - SI(10, ['m', 1, 's']) - - # Test that a non-numeric power in the list raises an error - with self.assertRaises((ValueError, TypeError)): - SI(1, ['m', 'one']) - - def test_dens_str(self): - """Test the dense string representation method.""" - # Test a standard case - q1 = SI(15.5, "A/m**2") - self.assertEqual(q1.dens_str(), "<15.5A/m^2>") - - # Test scientific notation - q2 = SI(1.23e-7, "N*m") - self.assertEqual(q2.dens_str(), "<1.23e-07m*N>") - - # Test dimensionless quantity of 1 - q3 = SI(1.0) - self.assertEqual(q3.dens_str(), "<1>") - - # Test quantity of 1 with units - q4 = SI(1.0, "m") - self.assertEqual(q4.dens_str(), "") - - def test_in_units_of(self): - """Test the unit conversion method and its edge cases.""" - # Standard conversion - velocity_ms = SI(20, "m/s") - km_per_hour_unit = SI(1, "km/h") - velocity_kmh = velocity_ms.in_units_of(km_per_hour_unit) - self.assertAlmostEqual(velocity_kmh, 72.0) - - distance_km = SI(2.5, "km") - meter_unit = SI(1, "m") - distance_m = distance_km.in_units_of(meter_unit) - self.assertAlmostEqual(distance_m, 2500.0) - - # --- Edge Cases for in_units_of --- - # Test that incompatible units raise a DimensionalityError - with self.assertRaises(pint.errors.DimensionalityError): - velocity_ms.in_units_of(meter_unit) - - # Test that a non-Physical object raises a TypeError - with self.assertRaises(TypeError): - velocity_ms.in_units_of("km/h") - - # Test division by a zero-magnitude unit - quantity = SI(10, 'm') - zero_unit = SI(0, 'm') - with self.assertRaises(ZeroDivisionError): - quantity.in_units_of(zero_unit) - - # Test conversion between dimensionless quantities - dimless_q1 = SI(100) - dimless_q2 = SI(2) - self.assertAlmostEqual(dimless_q1.in_units_of(dimless_q2), 50.0) - - def test_float_conversion(self): - """Test conversion to float for dimensionless quantities.""" - # A dimensionless quantity should be convertible to float - dimless_quantity = SI(10, 'm') / SI(2, 'm') - self.assertAlmostEqual(float(dimless_quantity), 5.0) - - # A quantity with dimensions should raise a DimensionalityError - dim_quantity = SI(10, 'm') - with self.assertRaises(pint.errors.DimensionalityError): - float(dim_quantity) - - def test_zero_operations(self): - """Test comparisons and arithmetic with zero.""" - # Test comparison with a raw number zero - self.assertEqual(SI(0, 'm'), 0) - self.assertEqual(SI(0, 'kg*m/s'), 0) - self.assertNotEqual(SI(5, 'm'), 0) - - # Test addition with a raw number zero - q = SI(5, 'm') - self.assertEqual(q + 0, q) - self.assertEqual(0 + q, q) - -# This allows the test to be run from the command line -if __name__ == '__main__': - unittest.main() +""" +Unit tests for the Physical (SI) class using the pint library. +""" + +import unittest + +import pint + +from si import physical +from si.physical import SI + + +class TestHardcodedUnits(unittest.TestCase): + """ + Tests that the new Physical (SI) class, backed by pint, correctly + interprets all the unit strings that were hard-coded in the original + legacy class. + + Each test compares a quantity created with a derived unit string (e.g., "J") + to an equivalent quantity constructed from base SI units. This verifies + that the underlying pint parser recognizes the units and their correct + physical definitions. + """ + + def test_base_units(self): + """Test that the seven base SI units are parsed correctly.""" + self.assertEqual(SI(1, "m"), SI(1, "meter")) + self.assertEqual(SI(1, "kg"), SI(1, "kilogram")) + self.assertEqual(SI(1, "s"), SI(1, "second")) + self.assertEqual(SI(1, "A"), SI(1, "ampere")) + self.assertEqual(SI(1, "K"), SI(1, "kelvin")) + self.assertEqual(SI(1, "mol"), SI(1, "mole")) + self.assertEqual(SI(1, "cd"), SI(1, "candela")) + + def test_joule(self): + """Test Joule (J) against its definition: kg * m^2 / s^2""" + joule_from_string = SI(1, 'J') + joule_from_base = SI(1, 'kg * m**2 / s**2') + self.assertEqual(joule_from_string, joule_from_base) + + def test_newton(self): + """Test Newton (N) against its definition: kg * m / s^2""" + newton_from_string = SI(1, 'N') + newton_from_base = SI(1, 'kg * m / s**2') + self.assertEqual(newton_from_string, newton_from_base) + + def test_watt(self): + """Test Watt (W) against its definition: J/s or kg * m^2 / s^3""" + watt_from_string = SI(1, 'W') + watt_from_base = SI(1, 'kg * m**2 / s**3') + self.assertEqual(watt_from_string, watt_from_base) + + def test_tesla(self): + """Test Tesla (T) against its definition: kg / (s^2 * A)""" + tesla_from_string = SI(1, 'T') + tesla_from_base = SI(1, 'kg / (s**2 * A)') + self.assertEqual(tesla_from_string, tesla_from_base) + + def test_volt(self): + """Test Volt (V) against its definition: W/A or kg * m^2 / (s^3 * A)""" + volt_from_string = SI(1, 'V') + volt_from_base = SI(1, 'kg * m**2 / (s**3 * A)') + self.assertEqual(volt_from_string, volt_from_base) + + def test_coulomb(self): + """Test Coulomb (C) against its definition: s * A""" + coulomb_from_string = SI(1, 'C') + coulomb_from_base = SI(1, 's * A') + self.assertEqual(coulomb_from_string, coulomb_from_base) + + def test_ohm(self): + """Test Ohm against its definition: V/A or kg * m^2 / (s^3 * A^2)""" + # pint expects lowercase 'ohm' + ohm_from_string = SI(1, 'ohm') + ohm_from_base = SI(1, 'kg * m**2 / (s**3 * A**2)') + self.assertEqual(ohm_from_string, ohm_from_base) + + def test_henry(self): + """Test Henry (H) against its definition: V*s/A or kg * m^2 / (s^2 * A^2)""" + henry_from_string = SI(1, 'H') + henry_from_base = SI(1, 'kg * m**2 / (s**2 * A**2)') + self.assertEqual(henry_from_string, henry_from_base) + +class TestClassFunctionality(unittest.TestCase): + """ + Tests the core functionality of the Physical (SI) class, including + initialization methods, string representations, and unit conversions. + """ + + def test_initialization_methods(self): + """Verify that the SI class can be initialized in various ways.""" + # Standard initialization with a string + s1 = SI(10, "m/s") + self.assertEqual(s1.magnitude, 10) + self.assertEqual(str(s1._quantity.units), "meter / second") + + # Legacy list-based initialization + s2 = SI(10, ['m', 1, 's', -1]) + self.assertEqual(s1, s2) + + # Shorthand initialization (value is 1.0) + s3 = SI("m/s") + self.assertEqual(s3.magnitude, 1.0) + self.assertEqual(s3, SI(1.0, "m/s")) + + # Quantity strings with explicit magnitudes still use Pint's parser. + s3_with_value = SI("10 m/s") + self.assertEqual(s3_with_value.magnitude, 10.0) + self.assertEqual(s3_with_value, SI(10.0, "m/s")) + + # Dimensionless initialization + s4 = SI(5.0) + s5 = SI(5.0, []) + self.assertTrue(s4._quantity.dimensionless) + self.assertEqual(s4, s5) + + # --- Edge Cases for constructor --- + # Test that an invalid list (uneven items) raises a ValueError + with self.assertRaises(ValueError): + SI(10, ['m', 1, 's']) + + # Test that a non-numeric power in the list raises an error + with self.assertRaises((ValueError, TypeError)): + SI(1, ['m', 'one']) + + def test_unit_string_parsing_is_cached(self): + """Repeated SI construction reuses parsed Pint unit objects.""" + physical._pint_unit.cache_clear() + + self.assertEqual(SI(1, "Wb"), SI(1, "Wb")) + info = physical._pint_unit.cache_info() + self.assertEqual(info.misses, 1) + self.assertEqual(info.hits, 1) + + def test_generated_scaled_fast_units_keep_their_scale_in_pint(self): + """A generated fast unit retains its scale when an operation uses Pint.""" + quantity = SI(1, "km") ** 2 * SI(1, "hectare") + + self.assertEqual(quantity.in_units_of(SI(1, "m**4")), 1e10) + + def test_fractional_fast_quantity_power_uses_pint_dimensions(self): + """Fractional powers must retain fractional dimensional exponents.""" + root_length = SI(9, "m") ** 0.5 + + self.assertEqual(root_length.in_units_of(SI(1, "m**0.5")), 3.0) + + def test_pint_unit_initialization_creates_a_quantity(self): + """Pint Units become magnitude-one Physical quantities.""" + quantity = SI(pint.get_application_registry().meter) + + self.assertEqual(quantity, SI(1, "m")) + self.assertEqual(quantity.magnitude, 1.0) + self.assertEqual(quantity.dens_str(), "") + + def test_dens_str(self): + """Test the dense string representation method.""" + # Test a standard case + q1 = SI(15.5, "A/m**2") + self.assertEqual(q1.dens_str(), "<15.5A/m^2>") + + # Test scientific notation + q2 = SI(1.23e-7, "N*m") + self.assertEqual(q2.dens_str(), "<1.23e-07m*N>") + + # Test dimensionless quantity of 1 + q3 = SI(1.0) + self.assertEqual(q3.dens_str(), "<1>") + + # Test quantity of 1 with units + q4 = SI(1.0, "m") + self.assertEqual(q4.dens_str(), "") + + def test_in_units_of(self): + """Test the unit conversion method and its edge cases.""" + # Standard conversion + velocity_ms = SI(20, "m/s") + km_per_hour_unit = SI(1, "km/h") + velocity_kmh = velocity_ms.in_units_of(km_per_hour_unit) + self.assertAlmostEqual(velocity_kmh, 72.0) + + distance_km = SI(2.5, "km") + meter_unit = SI(1, "m") + distance_m = distance_km.in_units_of(meter_unit) + self.assertAlmostEqual(distance_m, 2500.0) + + # --- Edge Cases for in_units_of --- + # Test that incompatible units raise a DimensionalityError + with self.assertRaises(pint.errors.DimensionalityError): + velocity_ms.in_units_of(meter_unit) + + # Test that a non-Physical object raises a TypeError + with self.assertRaises(TypeError): + velocity_ms.in_units_of("km/h") + + # Test division by a zero-magnitude unit + quantity = SI(10, 'm') + zero_unit = SI(0, 'm') + with self.assertRaises(ZeroDivisionError): + quantity.in_units_of(zero_unit) + + # Test conversion between dimensionless quantities + dimless_q1 = SI(100) + dimless_q2 = SI(2) + self.assertAlmostEqual(dimless_q1.in_units_of(dimless_q2), 50.0) + + def test_float_conversion(self): + """Test conversion to float for dimensionless quantities.""" + # A dimensionless quantity should be convertible to float + dimless_quantity = SI(10, 'm') / SI(2, 'm') + self.assertAlmostEqual(float(dimless_quantity), 5.0) + + # A quantity with dimensions should raise a DimensionalityError + dim_quantity = SI(10, 'm') + with self.assertRaises(pint.errors.DimensionalityError): + float(dim_quantity) + + def test_zero_operations(self): + """Test comparisons and arithmetic with zero.""" + # Test comparison with a raw number zero + self.assertEqual(SI(0, 'm'), 0) + self.assertEqual(SI(0, 'kg*m/s'), 0) + self.assertNotEqual(SI(5, 'm'), 0) + + # Test addition with a raw number zero + q = SI(5, 'm') + self.assertEqual(q + 0, q) + self.assertEqual(0 + q, q) + + def test_pint_quantity_arithmetic_accepts_fast_quantity_operand(self): + difference = SI(10, "ps") - SI(0, "s") + ratio = SI(1e-11, "s") / SI(10, "ps") + + self.assertEqual(difference.in_units_of(SI(1, "s")), 1e-11) + self.assertEqual(float(ratio), 1.0) + +# This allows the test to be run from the command line +if __name__ == '__main__': + unittest.main() diff --git a/tests/simulation/clock_test.py b/tests/simulation/clock_test.py index abcfce1..52765e4 100644 --- a/tests/simulation/clock_test.py +++ b/tests/simulation/clock_test.py @@ -1,163 +1,178 @@ -import unittest -from si.physical import SI - -from simulation.clock import fmt_time, SimulationClock - -class TestFmtTime(unittest.TestCase): - - def test_fmt_time_picoseconds(self): - """Tests that times < 100 ps are formatted as picoseconds.""" - test_cases = [ - (0, "0.00 ps"), - (50, "50.00 ps"), - (99.9, "99.90 ps"), - ] - for time_val_ps, expected_str in test_cases: - with self.subTest(time_val_ps=time_val_ps): - t = SI(time_val_ps * 1e-12, "s") - self.assertEqual(fmt_time(t), expected_str) - - def test_fmt_time_nanoseconds(self): - """Tests that times >= 100 ps are formatted as nanoseconds.""" - test_cases = [ - (100, "0.10 ns"), - (150, "0.15 ns"), - (1234, "1.23 ns"), - ] - for time_val_ps, expected_str in test_cases: - with self.subTest(time_val_ps=time_val_ps): - t = SI(time_val_ps * 1e-12, "s") - self.assertEqual(fmt_time(t), expected_str) - - def test_fmt_time_custom_format(self): - """Tests the custom format specifier arguments.""" - t_ps = SI(50.123e-12, "s") - t_ns = SI(150.123e-12, "s") - - # Test custom picosecond format specifier - self.assertEqual(fmt_time(t_ps, fmt_ps=".1f"), "50.1 ps") - - # Test custom nanosecond format specifier - self.assertEqual(fmt_time(t_ns, fmt_ns=".1f"), "0.2 ns") - -class TestSimulationClock(unittest.TestCase): - - def setUp(self): - """Provides a default, fresh SimulationClock instance for each test.""" - self.clock = SimulationClock() - - def test_clock_init_defaults(self): - """Test that the dataclass initializes with all default values.""" - self.assertEqual(self.clock.id, -1) - self.assertEqual(self.clock.stage, 1) - self.assertEqual(self.clock.step, 0) - self.assertEqual(self.clock.time, SI(0.0, "s")) - self.assertFalse(self.clock.convergence) - - def test_clock_init_custom(self): - """Test that the dataclass __init__ correctly overrides defaults.""" - custom_time = SI(1e-9, "s") - c = SimulationClock( - step=100, - stage=5, - time=custom_time, - convergence=True - ) - - self.assertEqual(c.step, 100) - self.assertEqual(c.stage, 5) - self.assertEqual(c.time, custom_time) - self.assertTrue(c.convergence) - - # Check that others are still default - self.assertEqual(c.id, -1) - self.assertEqual(c.stage_step, 0) - - def test_clock_init_invalid_arg(self): - """Test that the dataclass __init__ raises a TypeError for an unknown argument.""" - with self.assertRaises(TypeError) as cm: - SimulationClock(invalid_key="foo") - - # The error message from dataclass is slightly different - self.assertIn("unexpected keyword argument", str(cm.exception)) - self.assertIn("'invalid_key'", str(cm.exception)) - - - def test_inc_stage_default(self): - """Test 'inc_stage()' with no arguments (default increment).""" - # Set a non-default state - self.clock.step = 50 - self.clock.time = SI(10e-9, "s") - self.clock.stage = 3 - self.clock.stage_step = 20 - self.clock.convergence = True - - self.clock.inc_stage() - - self.assertEqual(self.clock.stage, 4) - self.assertEqual(self.clock.stage_step, 0) - self.assertEqual(self.clock.stage_time, SI(0.0, "s")) - self.assertFalse(self.clock.convergence) - self.assertEqual(self.clock.zero_stage_step, 50) - self.assertEqual(self.clock.zero_stage_time, SI(10e-9, "s")) - - def test_inc_stage_specific(self): - """Test 'inc_stage()' when setting a specific stage number.""" - self.clock.step = 50 - self.clock.time = SI(10e-9, "s") - self.clock.stage = 3 - - self.clock.inc_stage(stage=10) - - self.assertEqual(self.clock.stage, 10) - self.assertEqual(self.clock.stage_step, 0) - self.assertFalse(self.clock.convergence) - self.assertEqual(self.clock.zero_stage_step, 50) - self.assertEqual(self.clock.zero_stage_time, SI(10e-9, "s")) - - def test_repr(self): - """Test the dataclass-generated __repr__ magic method.""" - r = repr(self.clock) - self.assertTrue(r.startswith("SimulationClock(")) - self.assertTrue(r.endswith(")")) - self.assertIn("step=0", r) - self.assertIn("stage=1", r) - self.assertIn("time=Physical(0.0, 'second')", r) - - def test_str(self): - """Test the __str__ magic method (using tabulate).""" - s = str(self.clock) - - # Check for tabulate "pipe" format - self.assertIn("|", s) - - # Check for header/footer - self.assertTrue(s.startswith("=")) - self.assertTrue(s.endswith("=")) - - # Check for content (note: no spaces around '=') - self.assertIn("ID=-1", s) - self.assertIn("Step=0", s) - self.assertIn("Stage=1", s) - - # Check that it still calls fmt_time - self.assertIn("Time=0.00 ps", s) - - # Check that old formatting is gone (no right-justified keys) - self.assertNotIn(" ID=", s) - - def test_str_with_nanoseconds(self): - """Test that __str__ (tabulate) correctly formats nanoseconds.""" - self.clock.time = SI(120e-12, "s") - s = str(self.clock) - - # Check for tabulate format - self.assertIn("|", s) - - # Check for the formatted nanosecond string - self.assertIn("0.12 ns", s) - self.assertIn("Time=0.12 ns", s) - - -if __name__ == '__main__': - unittest.main() +import unittest + +from si.physical import SI +from simulation.clock import SimulationClock, fmt_time + + +class TestFmtTime(unittest.TestCase): + def test_fmt_time_picoseconds(self): + """Tests that times < 100 ps are formatted as picoseconds.""" + test_cases = [ + (0, "0.00 ps"), + (50, "50.00 ps"), + (99.9, "99.90 ps"), + ] + for time_val_ps, expected_str in test_cases: + with self.subTest(time_val_ps=time_val_ps): + t = SI(time_val_ps * 1e-12, "s") + self.assertEqual(fmt_time(t), expected_str) + + def test_fmt_time_nanoseconds(self): + """Tests that times >= 100 ps are formatted as nanoseconds.""" + test_cases = [ + (100, "0.10 ns"), + (150, "0.15 ns"), + (1234, "1.23 ns"), + ] + for time_val_ps, expected_str in test_cases: + with self.subTest(time_val_ps=time_val_ps): + t = SI(time_val_ps * 1e-12, "s") + self.assertEqual(fmt_time(t), expected_str) + + def test_fmt_time_custom_format(self): + """Tests the custom format specifier arguments.""" + t_ps = SI(50.123e-12, "s") + t_ns = SI(150.123e-12, "s") + + # Test custom picosecond format specifier + self.assertEqual(fmt_time(t_ps, fmt_ps=".1f"), "50.1 ps") + + # Test custom nanosecond format specifier + self.assertEqual(fmt_time(t_ns, fmt_ns=".1f"), "0.2 ns") + + +class TestSimulationClock(unittest.TestCase): + def setUp(self): + """Provides a default, fresh SimulationClock instance for each test.""" + self.clock = SimulationClock() + + def test_clock_init_defaults(self): + """Test that the dataclass initializes with all default values.""" + self.assertEqual(self.clock.id, -1) + self.assertEqual(self.clock.stage, 1) + self.assertEqual(self.clock.step, 0) + self.assertEqual(self.clock.time, SI(0.0, "s")) + self.assertFalse(self.clock.convergence) + + def test_clock_default_si_fields_are_independent_wrappers(self): + """Default SI fields reuse cached unit parsing without sharing wrappers.""" + other = SimulationClock() + + self.assertEqual(self.clock.time, SI(0.0, "s")) + self.assertIsNot(self.clock.time, self.clock.stage_time) + self.assertIsNot(self.clock.time, other.time) + + def test_clock_init_custom(self): + """Test that the dataclass __init__ correctly overrides defaults.""" + custom_time = SI(1e-9, "s") + c = SimulationClock(step=100, stage=5, time=custom_time, convergence=True) + + self.assertEqual(c.step, 100) + self.assertEqual(c.stage, 5) + self.assertEqual(c.time, custom_time) + self.assertTrue(c.convergence) + + # Check that others are still default + self.assertEqual(c.id, -1) + self.assertEqual(c.stage_step, 0) + + def test_clock_init_invalid_arg(self): + """Test that the dataclass __init__ raises a TypeError for an unknown argument.""" + with self.assertRaises(TypeError) as cm: + SimulationClock(invalid_key="foo") + + # The error message from dataclass is slightly different + self.assertIn("unexpected keyword argument", str(cm.exception)) + self.assertIn("'invalid_key'", str(cm.exception)) + + def test_clock_supports_when_time_mapping_contract(self): + self.clock.step = 7 + + self.assertEqual(self.clock["step"], 7) + self.assertEqual(self.clock.get("missing", "fallback"), "fallback") + with self.assertRaises(KeyError): + _ = self.clock["missing"] + + copied = self.clock.copy() + copied["step"] = 8 + self.assertEqual(self.clock.step, 7) + self.assertEqual(copied["step"], 8) + + def test_inc_stage_default(self): + """Test 'inc_stage()' with no arguments (default increment).""" + # Set a non-default state + self.clock.step = 50 + self.clock.time = SI(10e-9, "s") + self.clock.stage = 3 + self.clock.stage_step = 20 + self.clock.convergence = True + + self.clock.inc_stage() + + self.assertEqual(self.clock.stage, 4) + self.assertEqual(self.clock.stage_step, 0) + self.assertEqual(self.clock.stage_time, SI(0.0, "s")) + self.assertFalse(self.clock.convergence) + self.assertEqual(self.clock.zero_stage_step, 50) + self.assertEqual(self.clock.zero_stage_time, SI(10e-9, "s")) + + def test_inc_stage_specific(self): + """Test 'inc_stage()' when setting a specific stage number.""" + self.clock.step = 50 + self.clock.time = SI(10e-9, "s") + self.clock.stage = 3 + + self.clock.inc_stage(stage=10) + + self.assertEqual(self.clock.stage, 10) + self.assertEqual(self.clock.stage_step, 0) + self.assertFalse(self.clock.convergence) + self.assertEqual(self.clock.zero_stage_step, 50) + self.assertEqual(self.clock.zero_stage_time, SI(10e-9, "s")) + + def test_repr(self): + """Test the dataclass-generated __repr__ magic method.""" + r = repr(self.clock) + self.assertTrue(r.startswith("SimulationClock(")) + self.assertTrue(r.endswith(")")) + self.assertIn("step=0", r) + self.assertIn("stage=1", r) + self.assertIn("time=Physical(0.0, 'second')", r) + + def test_str(self): + """Test the __str__ magic method (using tabulate).""" + s = str(self.clock) + + # Check for tabulate "pipe" format + self.assertIn("|", s) + + # Check for header/footer + self.assertTrue(s.startswith("=")) + self.assertTrue(s.endswith("=")) + + # Check for content (note: no spaces around '=') + self.assertIn("ID=-1", s) + self.assertIn("Step=0", s) + self.assertIn("Stage=1", s) + + # Check that it still calls fmt_time + self.assertIn("Time=0.00 ps", s) + + # Check that old formatting is gone (no right-justified keys) + self.assertNotIn(" ID=", s) + + def test_str_with_nanoseconds(self): + """Test that __str__ (tabulate) correctly formats nanoseconds.""" + self.clock.time = SI(120e-12, "s") + s = str(self.clock) + + # Check for tabulate format + self.assertIn("|", s) + + # Check for the formatted nanosecond string + self.assertIn("0.12 ns", s) + self.assertIn("Time=0.12 ns", s) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/simulation/data_writer.py b/tests/simulation/data_writer.py deleted file mode 100644 index 3d4546c..0000000 --- a/tests/simulation/data_writer.py +++ /dev/null @@ -1,148 +0,0 @@ -import unittest -import tempfile -import shutil -import csv -import os -from pathlib import Path -from typing import List, Any, Optional - -from si.physical import SI -from simulation.quantity import known_quantities -from simulation.data_writer import DataWriter - -class MockMaterial: - def __init__(self, name: str): - self.name = name - -class MockSimulation: - """ - A mock implementation of the SimulationSource protocol. - """ - def __init__(self): - self.name = "Test_Sim" - self.id = 1 - self.step = 0 - self.stage = 1 - self.stage_step = 0 - self.time = 0.0 - self.stage_time = 0.0 - self.real_time = 123.45 - - # Track calls to save_spatial_fields for assertion - self.save_spatial_calls: List[tuple] = [] - - def get_subfield_average(self, subfieldname: str, mat_name: Optional[str] = None) -> Any: - # Return specific test values based on field names - if subfieldname == 'm': - # Test returning a list (vector) - return [1.0, 0.0, 0.0] - elif subfieldname == 'H_ext': - # Test returning an SI object - return SI(500.0, 'A/m') - elif subfieldname == 'E_total': - return 1.5e-21 - return 0.0 - - def get_materials_of_field(self, field_name: str) -> List[Any]: - # Return a list of mock objects with a .name attribute - if field_name == 'm': - return [MockMaterial("Permalloy")] - return [] - - def get_all_field_names(self) -> List[str]: - return ['m', 'H_ext', 'E_total'] - - def save_spatial_fields(self, filename: str, fieldnames: List[str]) -> None: - self.save_spatial_calls.append((filename, fieldnames)) - - -class TestDataWriter(unittest.TestCase): - - def setUp(self): - # Create a temporary directory for output files - self.test_dir = tempfile.mkdtemp() - self.ndt_path = Path(self.test_dir) / "output.ndt" - self.h5_path = Path(self.test_dir) / "output.h5" - - self.writer = DataWriter(self.ndt_path, self.h5_path) - self.source = MockSimulation() - - def tearDown(self): - # Clean up temporary directory - shutil.rmtree(self.test_dir) - - def test_file_creation_and_header(self): - """Test that the .ndt file is created and the header is written correctly.""" - self.writer.save(self.source) - - self.assertTrue(self.ndt_path.exists(), "NDT file was not created") - - with open(self.ndt_path, 'r', newline='') as f: - lines = f.readlines() - - # Check metadata comment - self.assertTrue(lines[0].startswith("# Simulation: Test_Sim")) - - # Check header columns (tab-separated) - header = lines[1].strip().split('\t') - self.assertIn('step', header) - self.assertIn('time', header) - self.assertIn('m_Permalloy_0', header) - self.assertIn('H_ext', header) - - def test_si_unit_extraction_fix(self): - """ - Ensure SI objects are converted to floats. - """ - # The mock returns SI(500.0, 'A/m') for 'H_ext' - self.writer.save(self.source) - - with open(self.ndt_path, 'r', newline='') as f: - reader = csv.DictReader(f, delimiter='\t') - pass - - with open(self.ndt_path, 'r') as f: - lines = [l.strip() for l in f.readlines() if not l.startswith("#")] - - header = lines[0].split('\t') - data = lines[1].split('\t') - - # Find index of H_ext - h_ext_idx = header.index('H_ext') - h_ext_val = data[h_ext_idx] - - self.assertEqual(float(h_ext_val), 500.0) - - def test_avoid_same_step(self): - """Test that data is not written if the step hasn't changed and the flag is set.""" - self.source.step = 10 - self.writer.save(self.source) # First save - - self.writer.save(self.source, avoid_same_step=True) - - with open(self.ndt_path, 'r') as f: - lines = f.readlines() - self.assertEqual(len(lines), 3) - - def test_spatial_save_trigger(self): - """Test that the writer correctly delegates spatial saving to the source.""" - self.writer.save(self.source, fields=['m', 'H_ext']) - - self.assertEqual(len(self.source.save_spatial_calls), 1) - filename, fields = self.source.save_spatial_calls[0] - - self.assertEqual(filename, str(self.h5_path)) - self.assertEqual(fields, ['m', 'H_ext']) - - def test_spatial_save_all(self): - """Test the 'all' keyword for fields.""" - self.writer.save(self.source, fields='all') - - self.assertEqual(len(self.source.save_spatial_calls), 1) - _, fields = self.source.save_spatial_calls[0] - - # Mock returns ['m', 'H_ext', 'E_total'] for get_all_field_names - self.assertEqual(fields, ['m', 'H_ext', 'E_total']) - -if __name__ == '__main__': - unittest.main() diff --git a/tests/simulation/data_writer_test_support.py b/tests/simulation/data_writer_test_support.py new file mode 100644 index 0000000..4974f59 --- /dev/null +++ b/tests/simulation/data_writer_test_support.py @@ -0,0 +1,141 @@ +import shutil +import tempfile +import unittest +from pathlib import Path +from typing import Any + +from si.physical import SI +from simulation.data_writer import DataWriter + + +class MockMaterial: + def __init__(self, name: str): + self.name = name + + +class MockSimulation: + """ + A mock implementation of the SimulationSource protocol. + """ + + def __init__(self): + self.name = "Test_Sim" + self.id = 1 + self.step = 0 + self.stage = 1 + self.stage_step = 0 + self.last_step_dt = SI(0.0, "s") + self.time = 0.0 + self.stage_time = 0.0 + self.real_time = 123.45 + + # Track calls to save_spatial_fields for assertion + self.save_spatial_calls: list[tuple] = [] + self.last_subfield_average_timings_seconds = {} + self.last_spatial_save_timings_seconds = {} + + def get_subfield_average(self, subfieldname: str, mat_name: str | None = None) -> Any: + self.last_subfield_average_timings_seconds = { + "array": 0.01, + "field_average": 0.02, + "subfield_array:m:compute": 0.005, + "total": 0.03, + } + # Return specific test values based on field names + if subfieldname == "m": + # Test returning a list (vector) + return [1.0, 0.0, 0.0] + elif subfieldname == "H_ext": + # Test returning an SI object + return SI(500.0, "A/m") + elif subfieldname == "E_total": + return 1.5e-21 + return 0.0 + + def get_materials_of_field(self, field_name: str) -> list[Any]: + # Return a list of mock objects with a .name attribute + if field_name == "m": + return [MockMaterial("Permalloy")] + return [] + + def get_all_field_names(self) -> list[str]: + return ["m", "H_ext", "E_total"] + + def get_maxangle_average(self, field_name: str) -> float | None: + self.last_maxangle_timings_seconds = {} + return None + + def save_spatial_fields(self, filename: str, fieldnames: list[str]) -> None: + self.save_spatial_calls.append((filename, fieldnames)) + self.last_spatial_save_timings_seconds = { + "field:m:compute": 0.004, + "field:m:write": 0.006, + "field:m:total": 0.01, + "mesh_points": 0.002, + "total": 0.012, + } + + +class SchemaChangingSimulation(MockSimulation): + def __init__(self): + super().__init__() + self.requested_subfields: list[str] = [] + self.fail_on_late_demag = False + + def get_subfield_average(self, subfieldname: str, mat_name: str | None = None) -> Any: + self.requested_subfields.append(subfieldname) + if subfieldname == "H_demag" and self.fail_on_late_demag: + raise AssertionError("late H_demag should not be queried") + if subfieldname == "m": + return [1.0, 0.0, 0.0] + if subfieldname == "H_ext": + return [2.0, 3.0, 4.0] + return None + + +class AvailabilityAwareSimulation(MockSimulation): + def __init__(self, available_subfields: set[str]): + super().__init__() + self.available_subfields = available_subfields + self.availability_queries: list[str] = [] + self.requested_subfields: list[str] = [] + self.material_queries: list[str] = [] + + def is_subfield_available(self, subfieldname: str) -> bool: + self.availability_queries.append(subfieldname) + return subfieldname in self.available_subfields + + def get_materials_of_field(self, field_name: str) -> list[Any]: + self.material_queries.append(field_name) + if field_name not in self.available_subfields: + raise AssertionError(f"unavailable field should not request materials: {field_name}") + return super().get_materials_of_field(field_name) + + def get_subfield_average(self, subfieldname: str, mat_name: str | None = None) -> Any: + self.requested_subfields.append(subfieldname) + if subfieldname not in self.available_subfields: + raise AssertionError(f"unavailable field should not be averaged: {subfieldname}") + return super().get_subfield_average(subfieldname, mat_name=mat_name) + + +class FailingSimulation(MockSimulation): + def __init__(self): + super().__init__() + self.fail_average = True + + def get_subfield_average(self, subfieldname: str, mat_name: str | None = None) -> Any: + if self.fail_average and subfieldname == "H_ext": + raise RuntimeError("numerical average failed") + return super().get_subfield_average(subfieldname, mat_name=mat_name) + + +class DataWriterTestCase(unittest.TestCase): + def setUp(self) -> None: + self.test_dir = tempfile.mkdtemp() + self.ndt_path = Path(self.test_dir) / "output.ndt" + self.h5_path = Path(self.test_dir) / "output.h5" + self.writer = DataWriter(self.ndt_path, self.h5_path) + self.source = MockSimulation() + + def tearDown(self) -> None: + shutil.rmtree(self.test_dir) diff --git a/tests/simulation/hysteresis_test.py b/tests/simulation/hysteresis_test.py index f38e156..71b3a98 100644 --- a/tests/simulation/hysteresis_test.py +++ b/tests/simulation/hysteresis_test.py @@ -1,21 +1,23 @@ import unittest -from unittest.mock import MagicMock, patch, mock_open +from unittest.mock import MagicMock from simulation.hysteresis import ( - _string_normalise, _append_x_list, - _join_save_and_do_lists + _join_save_and_do_lists, + _string_normalise, + simulation_relax, # _update_progress_file will be tested later with the sim object # _next_deltas and _next_time will be tested later with the sim object # simulation_relax and simulation_hysteresis will be tested later ) -from when import at, every, When +from when import When, at, every + class TestHysteresisHelpers(unittest.TestCase): """ Tests the helper functions within the hysteresis.py file that do not depend on a live Simulation object. - + Tests for sim-dependent functions (like _update_progress_file) and integration tests (for simulation_hysteresis) will be added after the Simulation class is defined. @@ -41,24 +43,24 @@ def test_string_normalise(self): def test_append_x_list(self): """Tests the logic for parsing 'save' and 'do' lists.""" - + # Create mock action functions mock_action_avg = MagicMock(name="averages_func") mock_action_fld = MagicMock(name="fields_func") - + predefined = { "save_averages": mock_action_avg, "do_fields": mock_action_fld } - + target_list = [] - + # Test 1: A simple string lookup input_list_1 = [('averages', at('stage_end'))] _append_x_list( - target_list, - input_list_1, - prefix="save", + target_list, + input_list_1, + prefix="save", predefined_actions=predefined ) self.assertEqual(len(target_list), 1) @@ -66,12 +68,13 @@ def test_append_x_list(self): self.assertIsInstance(target_list[0][1], When) # Test 2: A direct callable - my_func = lambda sim: "test" + def my_func(sim): + return "test" input_list_2 = [(my_func, every('step', 10))] _append_x_list( - target_list, - input_list_2, - prefix="save", + target_list, + input_list_2, + prefix="save", predefined_actions=predefined ) self.assertEqual(len(target_list), 2) @@ -81,9 +84,9 @@ def test_append_x_list(self): target_list = [] input_list_3 = [('averages', my_func, at('convergence'))] _append_x_list( - target_list, - input_list_3, - prefix="save", + target_list, + input_list_3, + prefix="save", predefined_actions=predefined ) self.assertEqual(len(target_list), 2) @@ -94,9 +97,9 @@ def test_append_x_list(self): input_list_4 = [('invalid_string', at('stage_end'))] with self.assertRaises(ValueError) as cm: _append_x_list( - target_list, - input_list_4, - prefix="save", + target_list, + input_list_4, + prefix="save", predefined_actions=predefined ) self.assertIn("I don't know how to do it", str(cm.exception)) @@ -105,9 +108,9 @@ def test_append_x_list(self): input_list_5 = None # type: ignore with self.assertRaises(ValueError) as cm: _append_x_list( - target_list, + target_list, input_list_5, # type: ignore - prefix="save", + prefix="save", predefined_actions=predefined ) self.assertIn("Bad syntax", str(cm.exception)) @@ -117,7 +120,7 @@ def test_join_save_and_do_lists(self): """Tests that 'do' actions are ordered before 'save' actions.""" mock_do = MagicMock(name="do_func") mock_save = MagicMock(name="save_func") - + predefined = { "do_next_stage": mock_do, "save_averages": mock_save @@ -125,15 +128,32 @@ def test_join_save_and_do_lists(self): save_list = [('averages', at('stage_end'))] do_list = [('next_stage', at('convergence'))] - + joint_list = _join_save_and_do_lists(save_list, do_list, predefined) - + self.assertEqual(len(joint_list), 2) # Check that the 'do' item is first self.assertEqual(joint_list[0][0], mock_do) # Check that the 'save' item is second self.assertEqual(joint_list[1][0], mock_save) + def test_relax_defaults_are_isolated(self): + """Each relax call gets fresh mutable defaults and event objects.""" + sim = MagicMock() + + simulation_relax(sim, H_applied="field") + first_call = sim.simulation_hysteresis.call_args + + simulation_relax(sim, H_applied="field") + second_call = sim.simulation_hysteresis.call_args + + self.assertIsNot(first_call.kwargs["save"], second_call.kwargs["save"]) + self.assertIsNot(first_call.kwargs["do"], second_call.kwargs["do"]) + self.assertIsNot( + first_call.kwargs["convergence_check"], + second_call.kwargs["convergence_check"], + ) + # Tests for _update_progress_file, _next_deltas, _next_time, # and simulation_hysteresis will be added as integration tests # once the Simulation class is available, as they all diff --git a/tests/simulation/inference_test.py b/tests/simulation/inference_test.py index fe2bb31..174b478 100644 --- a/tests/simulation/inference_test.py +++ b/tests/simulation/inference_test.py @@ -1,99 +1,101 @@ -import pytest -from simulation.inference import InferenceEngine - -def build_action_1(**kwargs): - kwargs['tracker'].append("action_1") - -def build_action_2(**kwargs): - kwargs['tracker'].append("action_2") - -@pytest.fixture -def build_tracker(): - """Provides a list to track execution order of build actions.""" - return [] - -@pytest.fixture -def basic_engine(): - """Sets up a simple A -> B -> C dependency chain.""" - rules = [ - {"name": "root"}, - { - "name": "mid", - "depends_on": ["root"], - "how_to_make": [build_action_1] - }, - { - "name": "target", - "depends_on": ["mid"], - "how_to_make": [build_action_2] - } - ] - return InferenceEngine(rules) - -def test_initialization(basic_engine): - """Ensure the graph is built correctly with back-links.""" - assert "root" in basic_engine.entities - assert "target" in basic_engine.entities - # Check back-link: 'mid' should be a prerequisite for 'target' - assert "target" in basic_engine.entities["mid"]._is_prerequisite - -def test_make_execution_order(basic_engine, build_tracker): - """Verify that 'make' executes steps in the correct dependency order.""" - # Mark the base as up-to-date - basic_engine.entities["root"]._is_uptodate = True - - basic_engine.make("target", tracker=build_tracker) - - # Order should be depth-first: mid then target - assert build_tracker == ["action_1", "action_2"] - assert basic_engine.entities["target"]._is_uptodate is True - -def test_idempotency(basic_engine, build_tracker): - """Verify that calling make twice doesn't re-run actions if up-to-date.""" - basic_engine.entities["root"]._is_uptodate = True - - # First build - basic_engine.make("target", tracker=build_tracker) - assert len(build_tracker) == 2 - - # Second build (should do nothing) - basic_engine.make("target", tracker=build_tracker) - assert len(build_tracker) == 2 - -def test_invalidation(basic_engine, build_tracker): - """Verify that invalidating a root triggers a rebuild of everything downstream.""" - basic_engine.entities["root"]._is_uptodate = True - basic_engine.make("target", tracker=build_tracker) - - # Invalidate the very bottom - basic_engine.invalidate("root") - - assert basic_engine.entities["mid"]._is_uptodate is False - assert basic_engine.entities["target"]._is_uptodate is False - - # Clear tracker and rebuild - build_tracker.clear() - basic_engine.entities["root"]._is_uptodate = True - basic_engine.make("target", tracker=build_tracker) - assert len(build_tracker) == 2 - -def test_missing_dependency_error(): - """Verify that the engine raises KeyError for missing nodes.""" - rules = [{"name": "A", "depends_on": ["NON_EXISTENT"]}] - with pytest.raises(KeyError, match="required by 'A' not found"): - InferenceEngine(rules) - -def test_invalid_target_error(basic_engine): - """Verify error when trying to make a name not in the graph.""" - with pytest.raises(KeyError, match="unknown entity 'ghost'"): - basic_engine.make("ghost") - -def test_circular_dependency_error(): - """Verify that the engine detects loops like A -> B -> A.""" - rules = [ - {"name": "A", "depends_on": ["B"]}, - {"name": "B", "depends_on": ["A"]} - ] - # match ensures we are getting our specific cycle error - with pytest.raises(ValueError, match="Circular dependency detected"): - InferenceEngine(rules) +import pytest + +from simulation.inference import InferenceEngine + + +def build_action_1(**kwargs): + kwargs['tracker'].append("action_1") + +def build_action_2(**kwargs): + kwargs['tracker'].append("action_2") + +@pytest.fixture +def build_tracker(): + """Provides a list to track execution order of build actions.""" + return [] + +@pytest.fixture +def basic_engine(): + """Sets up a simple A -> B -> C dependency chain.""" + rules = [ + {"name": "root"}, + { + "name": "mid", + "depends_on": ["root"], + "how_to_make": [build_action_1] + }, + { + "name": "target", + "depends_on": ["mid"], + "how_to_make": [build_action_2] + } + ] + return InferenceEngine(rules) + +def test_initialization(basic_engine): + """Ensure the graph is built correctly with back-links.""" + assert "root" in basic_engine.entities + assert "target" in basic_engine.entities + # Check back-link: 'mid' should be a prerequisite for 'target' + assert "target" in basic_engine.entities["mid"]._is_prerequisite + +def test_make_execution_order(basic_engine, build_tracker): + """Verify that 'make' executes steps in the correct dependency order.""" + # Mark the base as up-to-date + basic_engine.entities["root"]._is_uptodate = True + + basic_engine.make("target", tracker=build_tracker) + + # Order should be depth-first: mid then target + assert build_tracker == ["action_1", "action_2"] + assert basic_engine.entities["target"]._is_uptodate is True + +def test_idempotency(basic_engine, build_tracker): + """Verify that calling make twice doesn't re-run actions if up-to-date.""" + basic_engine.entities["root"]._is_uptodate = True + + # First build + basic_engine.make("target", tracker=build_tracker) + assert len(build_tracker) == 2 + + # Second build (should do nothing) + basic_engine.make("target", tracker=build_tracker) + assert len(build_tracker) == 2 + +def test_invalidation(basic_engine, build_tracker): + """Verify that invalidating a root triggers a rebuild of everything downstream.""" + basic_engine.entities["root"]._is_uptodate = True + basic_engine.make("target", tracker=build_tracker) + + # Invalidate the very bottom + basic_engine.invalidate("root") + + assert basic_engine.entities["mid"]._is_uptodate is False + assert basic_engine.entities["target"]._is_uptodate is False + + # Clear tracker and rebuild + build_tracker.clear() + basic_engine.entities["root"]._is_uptodate = True + basic_engine.make("target", tracker=build_tracker) + assert len(build_tracker) == 2 + +def test_missing_dependency_error(): + """Verify that the engine raises KeyError for missing nodes.""" + rules = [{"name": "A", "depends_on": ["NON_EXISTENT"]}] + with pytest.raises(KeyError, match="required by 'A' not found"): + InferenceEngine(rules) + +def test_invalid_target_error(basic_engine): + """Verify error when trying to make a name not in the graph.""" + with pytest.raises(KeyError, match="unknown entity 'ghost'"): + basic_engine.make("ghost") + +def test_circular_dependency_error(): + """Verify that the engine detects loops like A -> B -> A.""" + rules = [ + {"name": "A", "depends_on": ["B"]}, + {"name": "B", "depends_on": ["A"]} + ] + # match ensures we are getting our specific cycle error + with pytest.raises(ValueError, match="Circular dependency detected"): + InferenceEngine(rules) diff --git a/tests/simulation/quantity_test.py b/tests/simulation/quantity_test.py index b1c84fe..fa1eff0 100644 --- a/tests/simulation/quantity_test.py +++ b/tests/simulation/quantity_test.py @@ -1,81 +1,96 @@ -import unittest -from dataclasses import FrozenInstanceError -from si.physical import SI - -from simulation.quantity import Quantity - -class TestQuantity(unittest.TestCase): - - def setUp(self): - """Set up some common SI units for testing.""" - self.unit_A_m = SI("A/m") - self.unit_J = SI("J") - - def test_quantity_init(self): - """Test basic initialization with all arguments.""" - q = Quantity( - name="H_demag", - type="field", - units=self.unit_A_m, - signature="H_demag_*", - context="demag" - ) - self.assertEqual(q.name, "H_demag") - self.assertEqual(q.type, "field") - self.assertEqual(q.units, self.unit_A_m) - self.assertEqual(q.signature, "H_demag_*") - self.assertEqual(q.context, "demag") - self.assertIsNone(q.parent) - - def test_post_init_signature_defaults_to_name(self): - """ - Test __post_init__: signature should default to 'name' if None. - """ - # Initialize with signature=None (the default) - q = Quantity(name="E_total", type="field", units=self.unit_J) - - # Check that __post_init__ set the signature - self.assertEqual(q.name, "E_total") - self.assertEqual(q.signature, "E_total") - - def test_post_init_signature_is_retained(self): - """ - Test __post_init__: signature should be retained if provided. - """ - q = Quantity( - name="m", - type="pfield", - units=SI(1), - signature="_?_*" - ) - self.assertEqual(q.name, "m") - self.assertEqual(q.signature, "_?_*") # Should not be 'm' - - def test_sub_quantity(self): - """Test the sub_quantity method.""" - parent_q = Quantity( - name="m", - type="pfield", - units=SI(1), - signature="_?_*", - context="main" - ) - - child_q = parent_q.sub_quantity(name="m_Py_0") - - # Test child's new name - self.assertEqual(child_q.name, "m_Py_0") - - # Test that child is linked to parent - self.assertEqual(child_q.parent, parent_q) - - # Test that other properties were inherited - self.assertEqual(child_q.type, parent_q.type) - self.assertEqual(child_q.units, parent_q.units) - self.assertEqual(child_q.context, parent_q.context) - - # Test that the *processed* signature was inherited - self.assertEqual(child_q.signature, parent_q.signature) - -if __name__ == '__main__': - unittest.main() +import unittest + +from si.physical import SI +from simulation.quantity import Quantity, known_quantities_by_name + + +class TestQuantity(unittest.TestCase): + + def setUp(self): + """Set up some common SI units for testing.""" + self.unit_A_m = SI("A/m") + self.unit_J = SI("J") + + def test_quantity_init(self): + """Test basic initialization with all arguments.""" + q = Quantity( + name="H_demag", + type="field", + units=self.unit_A_m, + signature="H_demag_*", + context="demag" + ) + self.assertEqual(q.name, "H_demag") + self.assertEqual(q.type, "field") + self.assertEqual(q.units, self.unit_A_m) + self.assertEqual(q.signature, "H_demag_*") + self.assertEqual(q.context, "demag") + self.assertIsNone(q.parent) + + def test_post_init_signature_defaults_to_name(self): + """ + Test __post_init__: signature should default to 'name' if None. + """ + # Initialize with signature=None (the default) + q = Quantity(name="E_total", type="field", units=self.unit_J) + + # Check that __post_init__ set the signature + self.assertEqual(q.name, "E_total") + self.assertEqual(q.signature, "E_total") + + def test_post_init_signature_is_retained(self): + """ + Test __post_init__: signature should be retained if provided. + """ + q = Quantity( + name="m", + type="pfield", + units=SI(1), + signature="_?_*" + ) + self.assertEqual(q.name, "m") + self.assertEqual(q.signature, "_?_*") # Should not be 'm' + + def test_sub_quantity(self): + """Test the sub_quantity method.""" + parent_q = Quantity( + name="m", + type="pfield", + units=SI(1), + signature="_?_*", + context="main" + ) + + child_q = parent_q.sub_quantity(name="m_Py_0") + + # Test child's new name + self.assertEqual(child_q.name, "m_Py_0") + + # Test that child is linked to parent + self.assertEqual(child_q.parent, parent_q) + + # Test that other properties were inherited + self.assertEqual(child_q.type, parent_q.type) + self.assertEqual(child_q.units, parent_q.units) + self.assertEqual(child_q.context, parent_q.context) + + # Test that the *processed* signature was inherited + self.assertEqual(child_q.signature, parent_q.signature) + + def test_known_quantities_reuse_common_unit_objects(self): + """Common quantity units are cached to avoid repeated SI parsing.""" + self.assertIs( + known_quantities_by_name["H_total"].units, + known_quantities_by_name["H_demag"].units, + ) + self.assertIs( + known_quantities_by_name["time"].units, + known_quantities_by_name["stage_time"].units, + ) + self.assertIs( + known_quantities_by_name["id"].units, + known_quantities_by_name["m"].units, + ) + +if __name__ == '__main__': + unittest.main() diff --git a/tests/simulation/simulation_core_test.py b/tests/simulation/simulation_core_test.py new file mode 100644 index 0000000..dcdbd5c --- /dev/null +++ b/tests/simulation/simulation_core_test.py @@ -0,0 +1,23 @@ +"""Tests for the shared simulation core contract.""" + +from inspect import signature + +from simulation.clock import SimulationClock +from simulation.simulation_core import SimulationCore + + +def test_simulation_core_uses_package_sibling_imports(): + assert SimulationCore.__module__ == "simulation.simulation_core" + assert SimulationClock.__module__ == "simulation.clock" + + +def test_simulation_core_sequence_defaults_are_immutable(): + spatial_default = signature(SimulationCore.save_spatial_fields).parameters[ + "fieldnames" + ].default + restart_default = signature(SimulationCore.save_restart_file).parameters[ + "fieldnames" + ].default + + assert spatial_default is None + assert restart_default is None diff --git a/tests/simulation/test_data_writer_schema.py b/tests/simulation/test_data_writer_schema.py new file mode 100644 index 0000000..90272d7 --- /dev/null +++ b/tests/simulation/test_data_writer_schema.py @@ -0,0 +1,209 @@ +from __future__ import annotations + +import builtins +from pathlib import Path +from unittest.mock import patch + +from data_writer_test_support import ( + AvailabilityAwareSimulation, + DataWriterTestCase, + FailingSimulation, + SchemaChangingSimulation, +) + +from si.physical import SI +from simulation.quantity import Quantity + + +class TestDataWriterSchema(DataWriterTestCase): + def test_file_creation_and_header(self): + """Test that the .ndt file is created and the header is written correctly.""" + self.writer.save(self.source) + + self.assertTrue(self.ndt_path.exists(), "NDT file was not created") + + with open(self.ndt_path, newline="") as f: + lines = f.readlines() + + # Check metadata comment + self.assertTrue(lines[0].startswith("# Simulation: Test_Sim")) + + # Check header columns (tab-separated) + header = lines[1].strip().split("\t") + self.assertIn("step", header) + self.assertIn("time", header) + self.assertIn("m_Permalloy_0", header) + self.assertIn("H_ext", header) + + def test_si_unit_extraction_fix(self): + """ + Ensure SI objects are converted to floats. + """ + # The mock returns SI(500.0, 'A/m') for 'H_ext' + self.writer.save(self.source) + + with open(self.ndt_path) as f: + lines = [line.strip() for line in f.readlines() if not line.startswith("#")] + + header = lines[0].split("\t") + data = lines[1].split("\t") + + # Find index of H_ext + h_ext_idx = header.index("H_ext") + h_ext_val = data[h_ext_idx] + + self.assertEqual(float(h_ext_val), 500.0) + + def test_avoid_same_step(self): + """Test that data is not written if the step hasn't changed and the flag is set.""" + self.source.step = 10 + self.writer.save(self.source) # First save + + self.writer.save(self.source, avoid_same_step=True) + + with open(self.ndt_path) as f: + lines = f.readlines() + self.assertEqual(len(lines), 3) + + def test_repeated_save_reuses_existing_quantity_header(self): + """Quantity descriptors are only needed while writing the first header.""" + self.writer.save(self.source) + + self.source.step = 1 + with patch.object( + Quantity, + "sub_quantity", + side_effect=AssertionError("sub_quantity should not run after header"), + ): + self.writer.save(self.source) + + with open(self.ndt_path) as f: + lines = f.readlines() + + self.assertEqual(len(lines), 4) + + def test_existing_schema_row_values_use_cached_column_index(self): + self.writer.save(self.source) + + self.assertIsNotNone(self.writer._column_index_by_name) + row_values = self.writer._existing_schema_row_values( + [ + ("H_ext", SI(750.0, "A/m")), + ("step", 3), + ("missing_from_schema", 99), + ], + ) + + self.assertEqual(row_values[self.writer._column_index_by_name["step"]], 3) + self.assertEqual(row_values[self.writer._column_index_by_name["H_ext"]], 750.0) + self.assertNotIn(99, row_values) + + def test_ndt_timings_split_initial_and_existing_schema_paths(self): + self.writer.save(self.source) + + first_timings = self.writer.last_save_timings_seconds + self.assertGreaterEqual(first_timings["gather_ndt_columns"], 0.0) + self.assertGreaterEqual( + first_timings["gather_ndt_initial_schema_columns"], + 0.0, + ) + self.assertGreaterEqual(first_timings["build_ndt_header_schema"], 0.0) + self.assertGreaterEqual(first_timings["format_ndt_values"], 0.0) + self.assertGreaterEqual(first_timings["write_ndt_header"], 0.0) + self.assertNotIn("gather_ndt_existing_schema_columns", first_timings) + + self.source.step = 1 + self.writer.save(self.source) + + second_timings = self.writer.last_save_timings_seconds + self.assertGreaterEqual(second_timings["gather_ndt_columns"], 0.0) + self.assertGreaterEqual( + second_timings["gather_ndt_existing_schema_columns"], + 0.0, + ) + self.assertGreaterEqual(second_timings["format_ndt_values"], 0.0) + self.assertNotIn("gather_ndt_initial_schema_columns", second_timings) + self.assertNotIn("build_ndt_header_schema", second_timings) + self.assertNotIn("write_ndt_header", second_timings) + + def test_first_save_uses_single_ndt_open_for_header_and_row(self): + """The first save should not reopen the NDT file after writing the header.""" + real_open = builtins.open + ndt_open_modes: list[str] = [] + + def recording_open(file, *args, **kwargs): + if isinstance(file, (str, Path)) and Path(file) == self.ndt_path: + mode = args[0] if args else kwargs.get("mode", "r") + ndt_open_modes.append(mode) + return real_open(file, *args, **kwargs) + + with patch("builtins.open", side_effect=recording_open): + self.writer.save(self.source) + + self.assertEqual(ndt_open_modes, ["w"]) + + self.source.step = 1 + ndt_open_modes.clear() + with patch("builtins.open", side_effect=recording_open): + self.writer.save(self.source) + + self.assertEqual(ndt_open_modes, ["a"]) + + with open(self.ndt_path) as f: + lines = f.readlines() + + self.assertEqual(len(lines), 4) + + def test_repeated_save_only_queries_header_schema(self): + """Late fields cannot be written without changing the existing NDT header.""" + source = SchemaChangingSimulation() + self.writer.save(source) + self.assertIn("H_demag", source.requested_subfields) + + source.step = 1 + source.requested_subfields.clear() + source.fail_on_late_demag = True + self.writer.save(source) + + self.assertNotIn("H_demag", source.requested_subfields) + + def test_first_save_uses_optional_subfield_availability_predicate(self): + source = AvailabilityAwareSimulation({"m", "H_ext"}) + self.writer.save(source) + + self.assertIn("current_density", source.availability_queries) + self.assertIn("H_demag", source.availability_queries) + self.assertEqual(source.requested_subfields, ["m", "H_ext"]) + self.assertEqual(source.material_queries, ["m"]) + + with open(self.ndt_path, newline="") as f: + lines = [line.strip() for line in f if not line.startswith("#")] + header = lines[0].split("\t") + self.assertIn("m_Permalloy_0", header) + self.assertIn("H_ext", header) + self.assertNotIn("H_demag", header) + + def test_numerical_failure_is_not_silently_skipped(self): + source = FailingSimulation() + + with self.assertRaisesRegex(RuntimeError, "numerical average failed"): + self.writer.save(source, avoid_same_step=True) + + self.assertEqual(self.writer._last_saved_step, -1) + self.assertFalse(self.writer._header_written) + + def test_failed_first_save_can_retry_and_build_complete_schema(self): + source = FailingSimulation() + + with self.assertRaises(RuntimeError): + self.writer.save(source, avoid_same_step=True) + + source.fail_average = False + self.writer.save(source, avoid_same_step=True) + + self.assertEqual(self.writer._last_saved_step, source.step) + with open(self.ndt_path, newline="") as f: + lines = [line.strip() for line in f if not line.startswith("#")] + header = lines[0].split("\t") + self.assertIn("H_ext", header) + self.assertEqual(len(lines), 2) diff --git a/tests/simulation/test_data_writer_spatial.py b/tests/simulation/test_data_writer_spatial.py new file mode 100644 index 0000000..3ff2511 --- /dev/null +++ b/tests/simulation/test_data_writer_spatial.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from unittest.mock import patch + +from data_writer_test_support import DataWriterTestCase, MockSimulation + + +class TestDataWriterSpatial(DataWriterTestCase): + def test_failed_spatial_save_does_not_block_retry_at_same_step(self): + source = MockSimulation() + + with patch.object( + source, + "save_spatial_fields", + side_effect=[OSError("spatial write failed"), None], + ) as save_spatial_fields: + with self.assertRaisesRegex(OSError, "spatial write failed"): + self.writer.save(source, fields=["m"], avoid_same_step=True) + + self.assertEqual(self.writer._last_saved_step, -1) + self.writer.save(source, fields=["m"], avoid_same_step=True) + + self.assertEqual(save_spatial_fields.call_count, 2) + self.assertEqual(self.writer._last_saved_step, source.step) + + def test_spatial_save_trigger(self): + """Test that the writer correctly delegates spatial saving to the source.""" + self.writer.save(self.source, fields=["m", "H_ext"]) + + self.assertEqual(len(self.source.save_spatial_calls), 1) + filename, fields = self.source.save_spatial_calls[0] + + self.assertEqual(filename, str(self.h5_path)) + self.assertEqual(fields, ["m", "H_ext"]) + + def test_spatial_save_all(self): + """Test the 'all' keyword for fields.""" + self.writer.save(self.source, fields="all") + + self.assertEqual(len(self.source.save_spatial_calls), 1) + _, fields = self.source.save_spatial_calls[0] + + # Mock returns ['m', 'H_ext', 'E_total'] for get_all_field_names + self.assertEqual(fields, ["m", "H_ext", "E_total"]) + + def test_save_records_operation_timings(self): + self.writer.save(self.source, fields=["m"]) + + timings = self.writer.last_save_timings_seconds + self.assertGreaterEqual(timings["total"], 0.0) + self.assertGreaterEqual(timings["write_ndt_row"], 0.0) + self.assertGreaterEqual(timings["gather_ndt_columns"], 0.0) + self.assertGreaterEqual(timings["write_ndt_values"], 0.0) + self.assertGreaterEqual(timings["save_spatial_fields"], 0.0) + self.assertGreaterEqual(timings["spatial_detail:field_selection"], 0.0) + self.assertGreaterEqual(timings["spatial_detail:write_dispatch"], 0.0) + self.assertGreaterEqual(timings["average:m_Permalloy"], 0.0) + self.assertEqual(timings["average_detail:m_Permalloy:array"], 0.01) + self.assertEqual(timings["average_detail:m_Permalloy:field_average"], 0.02) + self.assertEqual( + timings["average_detail:m_Permalloy:subfield_array:m:compute"], + 0.005, + ) + self.assertEqual(timings["average_detail:m_Permalloy:total"], 0.03) + self.assertGreaterEqual(timings["average:H_ext"], 0.0) + self.assertEqual(timings["spatial_detail:field:m:compute"], 0.004) + self.assertEqual(timings["spatial_detail:field:m:write"], 0.006) + self.assertEqual(timings["spatial_detail:field:m:total"], 0.01) + self.assertEqual(timings["spatial_detail:mesh_points"], 0.002) + self.assertEqual(timings["spatial_detail:total"], 0.012) diff --git a/tests/test_docs_examples.py b/tests/test_docs_examples.py new file mode 100644 index 0000000..42a2a9f --- /dev/null +++ b/tests/test_docs_examples.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import ast +import re +import shutil +import subprocess +import sys +from pathlib import Path + +import h5py + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +DOCS_EXAMPLES = REPOSITORY_ROOT / "docs" / "examples" +DOCS_ASSETS = REPOSITORY_ROOT / "docs" / "assets" +FIXTURE_MESH = REPOSITORY_ROOT / "tests" / "fixtures" / "nmag_doc_example1" / "sphere1.nmesh.h5" + + +def _run_example(script: Path, working_directory: Path, *arguments: Path) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, str(script), *(str(argument) for argument in arguments)], + cwd=working_directory, + check=True, + capture_output=True, + text=True, + ) + + +def test_quickstart_example_runs_and_writes_results(tmp_path: Path) -> None: + completed = _run_example(DOCS_EXAMPLES / "quickstart.py", tmp_path) + + assert "Average demagnetization field:" in completed.stdout + assert (tmp_path / "results" / "quickstart.nmesh.h5").is_file() + assert (tmp_path / "results" / "quickstart_dat.ndt").is_file() + with h5py.File(tmp_path / "results" / "quickstart_dat.h5") as output: + assert output["fields/m"].shape == (4, 3) + assert output["fields/H_demag"].shape == (4, 3) + + +def test_sphere_download_matches_fixture_and_example_runs(tmp_path: Path) -> None: + downloadable_mesh = DOCS_ASSETS / "sphere1.nmesh.h5" + assert downloadable_mesh.read_bytes() == FIXTURE_MESH.read_bytes() + + local_mesh = tmp_path / "sphere1.nmesh.h5" + shutil.copyfile(downloadable_mesh, local_mesh) + completed = _run_example(DOCS_EXAMPLES / "sphere_demag.py", tmp_path, local_mesh) + + assert "H_demag at the origin:" in completed.stdout + with h5py.File(tmp_path / "results" / "sphere1_dat.h5") as output: + field_at_origin = output["fields/H_demag"][:] + assert field_at_origin.shape[1] == 3 + + match = re.search(r"H_demag at the origin: (\[[^\n]+\]) A/m", completed.stdout) + assert match is not None + probe = ast.literal_eval(match.group(1)) + expected_x = -1.0e6 / 3.0 + assert abs(probe[0] / expected_x - 1.0) < 0.01 diff --git a/tests/test_packaging_hygiene.py b/tests/test_packaging_hygiene.py new file mode 100644 index 0000000..c05677b --- /dev/null +++ b/tests/test_packaging_hygiene.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from pathlib import Path + +from nmag import simulation + +REPO_DIR = Path(__file__).resolve().parents[1] + + +def test_rewrite_repo_does_not_ship_ocaml_scaffold(): + assert not list((REPO_DIR / "ocaml").glob("**/*")) + assert not list(REPO_DIR.glob("**/dune-project")) + assert not list(REPO_DIR.glob("**/*.opam")) + assert not list(REPO_DIR.glob("**/*.ml")) + + +def test_package_discovery_exposes_python_top_level_modules_only(): + top_level_modules = { + path.relative_to(REPO_DIR / "src").parts[0] + for path in (REPO_DIR / "src").glob("**/__init__.py") + } + + assert "ocaml" not in top_level_modules + assert "nmag" in top_level_modules + assert "nmesh" in top_level_modules + + +def test_top_level_packages_publish_inline_types(): + package_directories = {path.parent for path in (REPO_DIR / "src").glob("*/__init__.py")} + + missing_markers = sorted( + str(package.relative_to(REPO_DIR)) + for package in package_directories + if not (package / "py.typed").is_file() + ) + + assert missing_markers == [] + + +def test_readme_documents_native_accelerator_configuration(): + readme = (REPO_DIR / "README.md").read_text(encoding="utf-8") + assert "NmagConfig" in readme + assert "NMAG_ACCELERATOR=auto|off|rust" in readme + assert "accelerator_overrides" in readme + assert "NMAG_LLG_BACKEND" not in readme + assert not any( + name.endswith("_BACKEND_ENV") and "rust" in name.lower() + for name in dir(simulation) + ) diff --git a/tests/throttler_test.py b/tests/throttler_test.py index f71594a..9e764eb 100644 --- a/tests/throttler_test.py +++ b/tests/throttler_test.py @@ -1,11 +1,13 @@ import unittest from unittest.mock import patch + from throttler import Throttler + class TestThrottler(unittest.TestCase): """ Tests the Throttler class. - + We use @patch to mock 'time.monotonic' to give us full control over the "current time" during tests. """ @@ -23,42 +25,42 @@ def setUp(self): def test_allows_first_call(self, mock_monotonic): """Tests that the very first call for a key is always allowed.""" mock_monotonic.return_value = 1000.0 - + is_allowed = self.throttler.is_allowed(self.key, self.delay) - + self.assertTrue(is_allowed, "First call should always be allowed") self.assertEqual(self.throttler.last_called[self.key], 1000.0) @patch('time.monotonic') def test_throttles_immediate_second_call(self, mock_monotonic): """Tests that a second call, before the delay expires, is throttled.""" - + # First call mock_monotonic.return_value = 1000.0 - self.throttler.is_allowed(self.key, self.delay) - + self.throttler.is_allowed(self.key, self.delay) + # Second call, 2 seconds later (delay is 5.0) mock_monotonic.return_value = 1002.0 is_allowed = self.throttler.is_allowed(self.key, self.delay) - + self.assertFalse(is_allowed, "Second call before delay should be throttled") self.assertEqual(self.throttler.last_called[self.key], 1000.0) @patch('time.monotonic') def test_allows_call_after_delay_expires(self, mock_monotonic): """Tests that a call is allowed again after the delay has passed.""" - + mock_monotonic.return_value = 1000.0 self.throttler.is_allowed(self.key, self.delay) # Allowed # Call just *before* the delay expires (t=1004.99) mock_monotonic.return_value = 1004.99 self.assertFalse(self.throttler.is_allowed(self.key, self.delay), "Call just before expiry should fail") - + # Call *exactly* when the delay expires (t=1005.0) mock_monotonic.return_value = 1005.0 is_allowed = self.throttler.is_allowed(self.key, self.delay) - + self.assertTrue(is_allowed, "Call exactly at expiry time should be allowed") self.assertEqual(self.throttler.last_called[self.key], 1005.0) @@ -71,10 +73,10 @@ def test_keys_are_isolated(self, mock_monotonic): mock_monotonic.return_value = 1000.0 self.assertTrue(self.throttler.is_allowed(key_a, delay), "First call for Key A should be allowed") - + mock_monotonic.return_value = 1001.0 self.assertTrue(self.throttler.is_allowed(key_b, delay), "First call for Key B should be allowed") - + mock_monotonic.return_value = 1002.0 self.assertFalse(self.throttler.is_allowed(key_a, delay), "Second call for Key A should be throttled") diff --git a/tests/when_test.py b/tests/when_test.py index 6d98d42..b8c1ac3 100644 --- a/tests/when_test.py +++ b/tests/when_test.py @@ -1,7 +1,9 @@ import unittest -from when import at, every, never, TimeDict from typing import Any +from si.physical import SI +from when import TimeDict, at, every, never + # --- Unit Tests (unittest.TestCase format) --- class TestTimeSpec(unittest.TestCase): @@ -44,7 +46,7 @@ def test_at(self): def test_every(self): w = every('step', 10) - + self.time['step'] = 0 self.assertTrue(w.match_time(self.time)) self.assertEqual(w.next_time('step', self.time), 10) @@ -56,7 +58,7 @@ def test_every(self): self.time['step'] = 10 self.assertTrue(w.match_time(self.time)) self.assertEqual(w.next_time('step', self.time), 20) - + self.time['step'] = 11 self.assertFalse(w.match_time(self.time)) self.assertEqual(w.next_time('step', self.time), 20) @@ -91,11 +93,11 @@ def test_every_first_last(self): def test_every_no_delta(self): w = every('step', first=5, last=10) - + self.time['step'] = 4 self.assertFalse(w.match_time(self.time)) self.assertIs(w.next_time('step', self.time), True) - + self.time['step'] = 5 self.assertTrue(w.match_time(self.time)) self.assertIs(w.next_time('step', self.time), True) @@ -111,13 +113,13 @@ def test_every_no_delta(self): def test_every_validation(self): with self.assertRaisesRegex(ValueError, "delta must be positive"): every('step', 0) - + with self.assertRaisesRegex(ValueError, "delta must be positive"): every('step', -1) with self.assertRaisesRegex(ValueError, "'last' must be greater than 'first'"): every('step', 10, first=10, last=10) - + with self.assertRaisesRegex(ValueError, "must specify an identifier"): every(10, 20) @@ -126,14 +128,14 @@ def test_never(self): self.time['step'] = 0 self.assertFalse(w.match_time(self.time)) self.assertIs(w.next_time('step', self.time), False) - + self.time['step'] = 100 self.assertFalse(w.match_time(self.time)) self.assertIs(w.next_time('step', self.time), False) def test_or(self): w = at('step', 5) | at('step', 10) - + self.time['step'] = 0 self.assertFalse(w.match_time(self.time)) self.assertEqual(w.next_time('step', self.time), 5) @@ -174,13 +176,13 @@ def test_and(self): def test_repr(self): w1 = at('convergence') self.assertEqual(str(w1), "at('convergence', True)") - + w2 = every('step', 10, first=1, last=100) self.assertEqual(str(w2), "every(10, 'step', first=1, last=100)") - + w3 = w1 | w2 self.assertEqual(str(w3), "(at('convergence', True) | every(10, 'step', first=1, last=100))") - + w4 = w1 & w2 self.assertEqual(str(w4), "(at('convergence', True) & every(10, 'step', first=1, last=100))") @@ -190,28 +192,28 @@ def test_repr(self): def test_tols(self): w = every('step', 10) tols = {'step': 0.01} - + # Standard case, no tol self.time['step'] = 0 self.assertEqual(w.next_time('step', self.time), 10) - + # Standard case, with tol self.assertEqual(w.next_time('step', self.time, tols), 10) - + # Current time is very close to next event. # Without tol, it should return 10. self.time['step'] = 9.999 self.assertEqual(w.next_time('step', self.time), 10) - + # With tol, it should detect it's too close and skip to # the *next* event after that. # abs(10.0 - 9.999) = 0.001 < 0.01 self.assertEqual(w.next_time('step', self.time, tols), 20.0) - + # Current time is slightly *after* an event self.time['step'] = 10.001 self.assertEqual(w.next_time('step', self.time), 20.0) - + # With tol, result is the same self.assertEqual(w.next_time('step', self.time, tols), 20.0) @@ -222,19 +224,19 @@ def test_main_integration_loop(self): Replicates the 'step' test from the original __main__ block. """ w = every('step', 2, last=21) & every('step', 4, first=10) | at('step', 15) - + results = [] this: Any = 0 - + for _ in range(25): # Safety break self.time['step'] = this next_t = w.next_time('step', self.time) results.append((this, next_t)) - + if next_t is False: break this = next_t - + expected_sequence = [ (0, 10), (10, 14), @@ -242,7 +244,7 @@ def test_main_integration_loop(self): (15, 18), (18, False) ] - + self.assertEqual(results, expected_sequence) def test_time_or_loop(self): @@ -251,15 +253,15 @@ def test_time_or_loop(self): Uses floats to check float logic. """ w = every('time', 100.0) | every('time', 30.0) - + results = [] this: Any = 0.0 - + for _ in range(10): self.time['time'] = this next_t = w.next_time('time', self.time, tols={'time': 1e-9}) results.append((this, next_t)) - + if next_t is False: break this = next_t @@ -276,8 +278,24 @@ def test_time_or_loop(self): (200.0, 210.0), (210.0, 240.0) ] - + self.assertEqual(results, expected_sequence) + def test_every_accepts_si_time_intervals(self): + interval = SI(10.0, "ps") + self.time['stage_time'] = SI(0.0, "s") + schedule = every('stage_time', interval) + tolerances = {'stage_time': SI(1e-20, 's')} + + self.assertEqual( + schedule.next_time('stage_time', self.time, tols=tolerances), + interval, + ) + self.time['stage_time'] = interval + self.assertEqual( + schedule.next_time('stage_time', self.time, tols=tolerances), + 2 * interval, + ) + if __name__ == '__main__': unittest.main() diff --git a/tools/nmesh_parity_compare.py b/tools/nmesh_parity_compare.py deleted file mode 100644 index 2eff79f..0000000 --- a/tools/nmesh_parity_compare.py +++ /dev/null @@ -1,247 +0,0 @@ -#!/usr/bin/env python3 -"""Run modern nmesh scenarios and compare them with legacy mesh artifacts. - -Examples: - python tools/nmesh_parity_compare.py --write-new --output-dir parity/new - python tools/nmesh_parity_compare.py --legacy-dir parity/legacy - python tools/nmesh_parity_compare.py --legacy-command "legacy_runner {scenario} {output}" -""" - -from __future__ import annotations - -import argparse -import json -import shlex -import subprocess -import tempfile -from collections.abc import Callable -from dataclasses import asdict, dataclass -from pathlib import Path - -import nmesh -from nmesh.backend import RawMesh -from nmesh.mesher.parity import ( - assert_canonical_mesh_equal, - compare_mesh_metrics, - mesh_metric_summary, - read_ascii_nmesh, -) - - -@dataclass(frozen=True, slots=True) -class Scenario: - """One modern-vs-legacy nmesh comparison case.""" - - name: str - build: Callable[[], RawMesh] - - -def _box2d() -> RawMesh: - return nmesh.Mesh( - bounding_box=[[0.0, 0.0], [1.0, 1.0]], - objects=[nmesh.Box([0.1, 0.1], [0.9, 0.9])], - a0=0.35, - max_steps=20, - nr_probes_for_determining_volume=500, - ).raw_mesh - - -def _box3d() -> RawMesh: - return nmesh.Mesh( - bounding_box=[[0.0, 0.0, 0.0], [1.0, 1.0, 1.0]], - objects=[nmesh.Box([0.2, 0.2, 0.2], [0.8, 0.8, 0.8])], - a0=0.3, - max_steps=20, - nr_probes_for_determining_volume=500, - ).raw_mesh - - -def _adjacent2d() -> RawMesh: - return nmesh.Mesh( - bounding_box=[[0.0, 0.0], [1.0, 1.0]], - objects=[ - nmesh.Box([0.0, 0.0], [0.5, 1.0]), - nmesh.Box([0.5, 0.0], [1.0, 1.0]), - ], - a0=0.5, - max_steps=20, - nr_probes_for_determining_volume=500, - ).raw_mesh - - -def _concave2d() -> RawMesh: - return nmesh.Mesh( - bounding_box=[[0.0, 0.0], [1.0, 1.0]], - objects=[ - nmesh.difference( - nmesh.Box([0.0, 0.0], [1.0, 1.0]), - [nmesh.Box([0.35, 0.35], [0.65, 0.65])], - ) - ], - a0=0.25, - max_steps=20, - nr_probes_for_determining_volume=1000, - ).raw_mesh - - -def _periodic2d() -> RawMesh: - return nmesh.Mesh( - bounding_box=[[0.0, 0.0], [1.0, 1.0]], - objects=[nmesh.Box([0.0, 0.0], [1.0, 1.0])], - mesh_bounding_box=True, - periodic=[True, True], - a0=0.4, - max_steps=20, - nr_probes_for_determining_volume=600, - ).raw_mesh - - -SCENARIOS: dict[str, Scenario] = { - scenario.name: scenario - for scenario in ( - Scenario("box2d", _box2d), - Scenario("box3d", _box3d), - Scenario("adjacent2d", _adjacent2d), - Scenario("concave2d", _concave2d), - Scenario("periodic2d", _periodic2d), - ) -} - - -def main() -> int: - """Run configured scenarios and print a JSON parity report.""" - - args = _parse_args() - scenario_names = args.scenario or sorted(SCENARIOS) - output_dir = Path(args.output_dir) if args.output_dir else None - if output_dir is not None: - output_dir.mkdir(parents=True, exist_ok=True) - - with tempfile.TemporaryDirectory(prefix="nmesh-parity-") as temp_dir: - temp_path = Path(temp_dir) - report = [] - exit_code = 0 - for scenario_name in scenario_names: - scenario = SCENARIOS[scenario_name] - modern_mesh = scenario.build() - modern_path = (output_dir or temp_path) / f"{scenario_name}.modern.nmesh" - nmesh.write_mesh(modern_mesh, modern_path) - - scenario_report = { - "scenario": scenario_name, - "modern_path": str(modern_path), - "modern_metrics": asdict(mesh_metric_summary(modern_mesh)), - } - legacy_path = _legacy_path_for(args, scenario_name, temp_path) - if legacy_path is None: - scenario_report["status"] = "modern-written" - report.append(scenario_report) - continue - - legacy_mesh = read_ascii_nmesh(legacy_path) - scenario_report["legacy_path"] = str(legacy_path) - scenario_report["legacy_metrics"] = asdict(mesh_metric_summary(legacy_mesh)) - exact_failure = _exact_failure(modern_mesh, legacy_mesh, args.coordinate_tolerance) - comparison = compare_mesh_metrics( - modern_mesh, - legacy_mesh, - count_relative_tolerance=args.count_relative_tolerance, - volume_relative_tolerance=args.volume_relative_tolerance, - length_relative_tolerance=args.length_relative_tolerance, - absolute_tolerance=args.absolute_tolerance, - ) - scenario_report["exact_match"] = exact_failure is None - if exact_failure is not None: - scenario_report["exact_failure"] = exact_failure - scenario_report["metric_passed"] = comparison.passed - scenario_report["metric_failures"] = list(comparison.failures) - scenario_report["status"] = "passed" if comparison.passed else "failed" - if not comparison.passed: - exit_code = 1 - report.append(scenario_report) - - print(json.dumps(report, indent=2, sort_keys=True)) - return exit_code - - -def _parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "--scenario", - action="append", - choices=sorted(SCENARIOS), - help="Scenario to run. May be provided multiple times. Defaults to all scenarios.", - ) - parser.add_argument( - "--legacy-dir", - type=Path, - help="Directory containing legacy '.nmesh' or '.legacy.nmesh' files.", - ) - parser.add_argument( - "--legacy-command", - help=( - "Command template used to generate a legacy mesh. The template may " - "contain {scenario} and {output}; it is split with shlex and run without a shell." - ), - ) - parser.add_argument( - "--output-dir", - type=Path, - help="Directory where modern scenario meshes should be written.", - ) - parser.add_argument("--coordinate-tolerance", type=float) - parser.add_argument("--count-relative-tolerance", type=float, default=0.25) - parser.add_argument("--volume-relative-tolerance", type=float, default=0.05) - parser.add_argument("--length-relative-tolerance", type=float, default=0.10) - parser.add_argument("--absolute-tolerance", type=float, default=1.0e-9) - return parser.parse_args() - - -def _legacy_path_for( - args: argparse.Namespace, - scenario_name: str, - temp_path: Path, -) -> Path | None: - if args.legacy_command: - output_path = temp_path / f"{scenario_name}.legacy.nmesh" - command = args.legacy_command.format( - scenario=scenario_name, - output=str(output_path), - ) - subprocess.run(shlex.split(command), check=True) - return output_path - - if args.legacy_dir is None: - return None - - candidates = [ - args.legacy_dir / f"{scenario_name}.legacy.nmesh", - args.legacy_dir / f"{scenario_name}.nmesh", - ] - for candidate in candidates: - if candidate.exists(): - return candidate - raise FileNotFoundError( - f"No legacy mesh found for {scenario_name!r}; expected one of: " - + ", ".join(str(candidate) for candidate in candidates) - ) - - -def _exact_failure( - actual: RawMesh, - expected: RawMesh, - coordinate_tolerance: float | None, -) -> str | None: - try: - assert_canonical_mesh_equal( - actual, - expected, - coordinate_tolerance=coordinate_tolerance, - ) - except AssertionError as exc: - return str(exc) - return None - - -if __name__ == "__main__": - raise SystemExit(main())