From 7dc80225fe8a43d87c33cd1b6cc1d4491f132ec9 Mon Sep 17 00:00:00 2001 From: Emanuel Pituch <32016786+epituch@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:57:36 -0700 Subject: [PATCH 1/2] feat: add published CLI and VTK workflow helpers --- docs/getting-started/installation.md | 7 ++ docs/guides/meshes.md | 21 +++++ docs/guides/output.md | 29 ++++++- pyproject.toml | 4 + src/nmag/__init__.py | 29 +++++++ src/nmag/cli/__init__.py | 1 + src/nmag/cli/nmagpp.py | 43 ++++++++++ src/nmag/vtk_export.py | 124 +++++++++++++++++++++++++++ src/nmesh/__init__.py | 8 ++ src/nmesh/cli/__init__.py | 1 + src/nmesh/cli/nmeshimport.py | 39 +++++++++ src/nmesh/io/__init__.py | 9 ++ src/nmesh/io/netgen.py | 122 ++++++++++++++++++++++++++ tests/nmag/test_public_imports.py | 4 +- tests/nmag/test_vtk_export.py | 63 ++++++++++++++ tests/nmesh/io/test_netgen.py | 51 +++++++++++ 16 files changed, 553 insertions(+), 2 deletions(-) create mode 100644 src/nmag/cli/__init__.py create mode 100644 src/nmag/cli/nmagpp.py create mode 100644 src/nmag/vtk_export.py create mode 100644 src/nmesh/cli/__init__.py create mode 100644 src/nmesh/cli/nmeshimport.py create mode 100644 src/nmesh/io/netgen.py create mode 100644 tests/nmag/test_vtk_export.py create mode 100644 tests/nmesh/io/test_netgen.py diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 81f72e0..1de2e3a 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -95,6 +95,13 @@ version. .venv/bin/python -c "import nmag; print(nmag.SI(1, 'A/m'))" ``` +The published package exposes reusable mesh and VTK helpers directly: + +```python +from nmag import export_vtk +from nmesh import read_netgen_neutral +``` + Then run the [quickstart](quickstart.md). If the optional accelerator was built, `nmag.parallel_runtime_info()` reports its effective thread configuration. diff --git a/docs/guides/meshes.md b/docs/guides/meshes.md index b68d830..7b57780 100644 --- a/docs/guides/meshes.md +++ b/docs/guides/meshes.md @@ -75,3 +75,24 @@ magnetizations are a warning that the mesh does not resolve the state. `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. + +## Convert Netgen Neutral meshes + +The supported replacement for the historical `nmeshimport --netgen` command +is shipped with the package: + +```bash +nmeshimport --netgen input.mesh output.nmesh.h5 +``` + +Netgen tetrahedron indices are converted from one-based to zero-based indexing; +positive region IDs are retained. Other mesh formats can be converted through +Meshio or loaded directly with `nmesh.load`. + +The parser is also importable from the installed package: + +```python +from nmesh import read_netgen_neutral + +mesh = read_netgen_neutral("input.mesh") +``` diff --git a/docs/guides/output.md b/docs/guides/output.md index 3919bc5..196dd1e 100644 --- a/docs/guides/output.md +++ b/docs/guides/output.md @@ -50,7 +50,34 @@ For a simulation named `sample`, Nmag writes: - `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. +the legacy `ncol` executable. It does ship a modern single-snapshot `nmagpp` +exporter: + +```bash +nmagpp --vtk results/simulation.vtu \ + results/simulation_dat.h5 \ + --mesh assets/model.nmesh.h5 \ + --field m +``` + +Use `--field NAME` more than once or `--all-fields` to select additional point +arrays. Modern spatial HDF5 output contains point coordinates and fields; an +external mesh is required when it does not contain tetrahedral cell topology. +The command exports one current snapshot and does not recreate the legacy +time-series `nmagpp` format. + +The same exporter is available as a normal Python import: + +```python +from nmag import export_vtk + +export_vtk( + "results/simulation_dat.h5", + "results/simulation.vtu", + mesh_path="assets/model.nmesh.h5", + fields=("m", "H_demag"), +) +``` To write selected spatial fields to another compact HDF5 file: diff --git a/pyproject.toml b/pyproject.toml index 7f9807b..f8ca0b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,10 @@ classifiers = [ "Operating System :: OS Independent" ] dependencies = ["pint", "numpy", "scipy", "numba", "tabulate", "meshio", "h5py"] +[project.scripts] +nmeshimport = "nmesh.cli.nmeshimport:main" +nmagpp = "nmag.cli.nmagpp:main" + [project.optional-dependencies] test = ["pytest", "pytest-cov", "pytest-watch"] rust = ["maturin>=1.14,<2"] diff --git a/src/nmag/__init__.py b/src/nmag/__init__.py index 1fdabec..1d74428 100644 --- a/src/nmag/__init__.py +++ b/src/nmag/__init__.py @@ -1,5 +1,20 @@ """Public Python 3 entrypoint for the nmag rewrite.""" +# ruff: noqa: E402, I001 # path cleanup must precede the compatibility imports + +import sys +from pathlib import Path +from typing import Any + + +_CURRENT_DIRECTORY = Path.cwd().resolve() +if (_CURRENT_DIRECTORY / "simulation.py").is_file(): + sys.path[:] = [ + entry + for entry in sys.path + if Path(entry or ".").resolve() != _CURRENT_DIRECTORY + ] + from anisotropy import ( PredefinedAnisotropy, cubic_anisotropy, @@ -15,6 +30,8 @@ from .parallel import ParallelRuntimeInfo, parallel_runtime_info from .simulation import Simulation +_OUTPUT_EXPORTS = {"export_vtk", "resolve_snapshot"} + __all__ = [ "MagMaterial", "NmagConfig", @@ -33,4 +50,16 @@ "parallel_runtime_info", "uniaxial_anisotropy", "want_anisotropy", + "export_vtk", + "resolve_snapshot", ] + + +def __getattr__(name: str) -> Any: + if name in _OUTPUT_EXPORTS: + from . import vtk_export + + value = getattr(vtk_export, name) + globals()[name] = value + return value + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/nmag/cli/__init__.py b/src/nmag/cli/__init__.py new file mode 100644 index 0000000..ad08314 --- /dev/null +++ b/src/nmag/cli/__init__.py @@ -0,0 +1 @@ +"""Command-line entry points for Nmag utilities.""" diff --git a/src/nmag/cli/nmagpp.py b/src/nmag/cli/nmagpp.py new file mode 100644 index 0000000..f9557b3 --- /dev/null +++ b/src/nmag/cli/nmagpp.py @@ -0,0 +1,43 @@ +"""Export modern Nmag snapshots to VTK-compatible files.""" + +from __future__ import annotations + +import argparse +from collections.abc import Sequence +from pathlib import Path + +import h5py + +from ..vtk_export import export_vtk, resolve_snapshot + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="nmagpp", + description="Export a modern Nmag HDF5 snapshot to VTK.", + ) + parser.add_argument("--vtk", dest="output", required=True, type=Path) + parser.add_argument("input", type=Path, help="snapshot path or simulation base name") + parser.add_argument("--mesh", type=Path, help="mesh file supplying cell topology") + parser.add_argument("--field", action="append", dest="fields", metavar="NAME") + parser.add_argument("--all-fields", action="store_true") + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + args = _parser().parse_args(argv) + snapshot = resolve_snapshot(args.input) + if args.all_fields: + with h5py.File(str(snapshot), "r") as handle: + group = handle.get("fields") + if not isinstance(group, h5py.Group): + raise ValueError("Snapshot is missing the /fields group.") + fields = tuple(str(name) for name in group.keys()) + else: + fields = tuple(args.fields or ("m",)) + export_vtk(snapshot, args.output, mesh_path=args.mesh, fields=fields) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/nmag/vtk_export.py b/src/nmag/vtk_export.py new file mode 100644 index 0000000..f0738bd --- /dev/null +++ b/src/nmag/vtk_export.py @@ -0,0 +1,124 @@ +"""Export modern Nmag spatial snapshots to VTK-compatible mesh files.""" + +from __future__ import annotations + +from collections.abc import Iterable +from pathlib import Path + +import h5py +import meshio +import numpy as np + +import nmesh + + +def _dataset_names(handle: h5py.File) -> list[str]: + fields = handle.get("fields") + if not isinstance(fields, h5py.Group): + raise ValueError("Snapshot is missing the /fields group.") + return sorted(str(name) for name in fields.keys()) + + +def _read_points(handle: h5py.File) -> np.ndarray: + try: + points = np.asarray(handle["mesh/points"], dtype=float) + except KeyError as exc: + raise ValueError("Snapshot is missing /mesh/points.") from exc + if points.ndim != 2 or points.shape[1] != 3: + raise ValueError(f"Snapshot points must have shape (N, 3), got {points.shape}.") + return points + + +def _read_topology( + handle: h5py.File, + mesh_path: str | Path | None, +) -> tuple[np.ndarray, np.ndarray | None, np.ndarray]: + if "mesh/simplices" in handle: + simplices = np.asarray(handle["mesh/simplices"], dtype=np.int64) + regions = ( + np.asarray(handle["mesh/simplicesregions"], dtype=np.int64) + if "mesh/simplicesregions" in handle + else None + ) + elif mesh_path is not None: + source_mesh = nmesh.load(mesh_path) + simplices = np.asarray(source_mesh.simplices, dtype=np.int64) + regions = np.asarray(source_mesh.regions, dtype=np.int64) + else: + raise ValueError( + "Snapshot contains point coordinates but no cell topology; provide --mesh." + ) + + if simplices.ndim != 2 or simplices.shape[1] != 4: + raise ValueError(f"VTK export requires tetrahedral cells, got {simplices.shape}.") + if regions is not None and len(regions) != len(simplices): + raise ValueError("Mesh region data does not match the number of cells.") + return simplices, regions, _read_points(handle) + + +def export_vtk( + snapshot_path: str | Path, + output_path: str | Path, + *, + mesh_path: str | Path | None = None, + fields: Iterable[str] = ("m",), +) -> Path: + """Export selected point fields from a modern Nmag HDF5 snapshot.""" + + snapshot = Path(snapshot_path) + output = Path(output_path) + selected_fields = tuple(dict.fromkeys(fields)) + if not selected_fields: + raise ValueError("At least one point field must be selected.") + + with h5py.File(str(snapshot), "r") as handle: + points = _read_points(handle) + simplices, regions, _ = _read_topology(handle, mesh_path) + if np.any(simplices < 0) or np.any(simplices >= len(points)): + raise ValueError("Mesh cell indices are outside the snapshot point range.") + + point_data: dict[str, np.ndarray] = {} + field_group = handle.get("fields") + if not isinstance(field_group, h5py.Group): + raise ValueError("Snapshot is missing the /fields group.") + for field_name in selected_fields: + if field_name not in field_group: + available = ", ".join(_dataset_names(handle)) + raise KeyError(f"Field {field_name!r} is not present; available fields: {available}") + values = np.asarray(field_group[field_name]) + if values.ndim not in (1, 2) or values.shape[0] != len(points): + raise ValueError( + f"Field {field_name!r} must have one value per point, got {values.shape}." + ) + if values.ndim == 2 and values.shape[1] not in (1, 3): + raise ValueError( + f"Field {field_name!r} must be scalar or three-component vector, " + f"got {values.shape}." + ) + point_data[field_name] = np.asarray(values, dtype=float) + + cell_data: dict[str, list[np.ndarray]] = {} + if regions is not None: + cell_data["region"] = [regions] + mesh = meshio.Mesh( + points=points, + cells=[("tetra", simplices)], + point_data=point_data, + cell_data=cell_data or None, + ) + output.parent.mkdir(parents=True, exist_ok=True) + meshio.write(output, mesh) + return output + + +def resolve_snapshot(path: str | Path) -> Path: + """Resolve a snapshot path or a simulation base name.""" + + candidate = Path(path) + if candidate.is_file(): + return candidate + if candidate.suffix == "": + suffixed = candidate.with_name(f"{candidate.name}_dat.h5") + if suffixed.is_file(): + return suffixed + raise FileNotFoundError(f"Nmag snapshot does not exist: {candidate}") diff --git a/src/nmesh/__init__.py b/src/nmesh/__init__.py index 8194015..4374d99 100644 --- a/src/nmesh/__init__.py +++ b/src/nmesh/__init__.py @@ -52,6 +52,7 @@ "do_every_n_steps_driver", "make_mg_gendriver", } +_NETGEN_EXPORTS = {"read_netgen_neutral"} __all__ = [ "Mesh", @@ -96,10 +97,17 @@ "inverse_shift", "make_mg_gendriver", "union", + "read_netgen_neutral", ] def __getattr__(name: str) -> Any: + if name in _NETGEN_EXPORTS: + from .io.netgen import read_netgen_neutral + + globals()[name] = read_netgen_neutral + return read_netgen_neutral + if name in _GEOMETRY_EXPORTS: from . import geometry diff --git a/src/nmesh/cli/__init__.py b/src/nmesh/cli/__init__.py new file mode 100644 index 0000000..6bf0068 --- /dev/null +++ b/src/nmesh/cli/__init__.py @@ -0,0 +1 @@ +"""Command-line entry points for mesh utilities.""" diff --git a/src/nmesh/cli/nmeshimport.py b/src/nmesh/cli/nmeshimport.py new file mode 100644 index 0000000..5d5767e --- /dev/null +++ b/src/nmesh/cli/nmeshimport.py @@ -0,0 +1,39 @@ +"""Convert Netgen Neutral meshes to legacy Nmesh HDF5.""" + +from __future__ import annotations + +import argparse +from collections.abc import Sequence +from pathlib import Path + +from ..io import save_raw_mesh_as_legacy_nmesh_hdf5 +from ..io.netgen import read_netgen_neutral + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="nmeshimport", + description="Convert a Netgen Neutral tetrahedral mesh to Nmesh HDF5.", + ) + parser.add_argument( + "--netgen", + action="store_true", + help="read Netgen Neutral input (the supported conversion mode)", + ) + parser.add_argument("input", type=Path) + parser.add_argument("output", type=Path) + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + args = _parser().parse_args(argv) + if not args.netgen: + raise SystemExit("nmeshimport currently requires --netgen.") + mesh = read_netgen_neutral(args.input) + args.output.parent.mkdir(parents=True, exist_ok=True) + save_raw_mesh_as_legacy_nmesh_hdf5(args.output, mesh) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/nmesh/io/__init__.py b/src/nmesh/io/__init__.py index 2581918..a722782 100644 --- a/src/nmesh/io/__init__.py +++ b/src/nmesh/io/__init__.py @@ -23,12 +23,15 @@ "save_raw_mesh_with_meshio", } +_NETGEN_EXPORTS = {"read_netgen_neutral"} + __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", + "read_netgen_neutral", ] @@ -47,4 +50,10 @@ def __getattr__(name: str) -> Any: globals()[name] = value return value + if name in _NETGEN_EXPORTS: + from .netgen import read_netgen_neutral + + globals()[name] = read_netgen_neutral + return read_netgen_neutral + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/nmesh/io/netgen.py b/src/nmesh/io/netgen.py new file mode 100644 index 0000000..1a7a6c3 --- /dev/null +++ b/src/nmesh/io/netgen.py @@ -0,0 +1,122 @@ +"""Readers for Netgen Neutral tetrahedral meshes.""" + +from __future__ import annotations + +import gzip +from pathlib import Path +from typing import TextIO + +from ..backend import RawMesh + + +def _open_text(path: Path) -> TextIO: + if path.suffix == ".gz": + return gzip.open(path, "rt", encoding="utf-8") + return path.open("r", encoding="utf-8") + + +class _LineReader: + def __init__(self, stream: TextIO, path: Path) -> None: + self.stream = stream + self.path = path + self.line_number = 0 + + def next(self, description: str) -> tuple[int, str]: + for line in self.stream: + self.line_number += 1 + stripped = line.strip() + if stripped: + return self.line_number, stripped + raise ValueError(f"{self.path} ended while reading {description}.") + + +def _parse_count(line: str, path: Path, line_number: int, description: str) -> int: + try: + count = int(line) + except ValueError as exc: + raise ValueError( + f"{path}:{line_number} has an invalid {description} count: {line!r}." + ) from exc + if count < 0: + raise ValueError(f"{path}:{line_number} has a negative {description} count.") + return count + + +def read_netgen_neutral(path: str | Path) -> RawMesh: + """Read a Netgen Neutral tetrahedral mesh into a :class:`RawMesh`. + + Netgen Neutral element indices are one-based. They are converted to the + zero-based indexing used by ``nmesh`` while region IDs are preserved. + Gzip-compressed files are accepted when the filename ends in ``.gz``. + """ + + mesh_path = Path(path) + if not mesh_path.is_file(): + raise FileNotFoundError(f"Netgen mesh does not exist: {mesh_path}") + + try: + with _open_text(mesh_path) as stream: + reader = _LineReader(stream, mesh_path) + point_count_line, point_count_text = reader.next("point") + point_count = _parse_count( + point_count_text, mesh_path, point_count_line, "point" + ) + if point_count == 0: + raise ValueError(f"{mesh_path} contains no points.") + + points: list[list[float]] = [] + for index in range(point_count): + line_number, line = reader.next(f"point {index + 1}") + values = line.split() + if len(values) != 3: + raise ValueError( + f"{mesh_path}:{line_number} point {index + 1} must have three coordinates." + ) + try: + points.append([float(value) for value in values]) + except ValueError as exc: + raise ValueError( + f"{mesh_path}:{line_number} contains a non-numeric point coordinate." + ) from exc + + simplex_count_line, simplex_count_text = reader.next("tetrahedron") + simplex_count = _parse_count( + simplex_count_text, + mesh_path, + simplex_count_line, + "tetrahedron", + ) + if simplex_count == 0: + raise ValueError(f"{mesh_path} contains no tetrahedra.") + + simplices: list[list[int]] = [] + regions: list[int] = [] + for index in range(simplex_count): + line_number, line = reader.next(f"tetrahedron {index + 1}") + values = line.split() + if len(values) != 5: + raise ValueError( + f"{mesh_path}:{line_number} tetrahedron {index + 1} must contain " + "one region ID and four point indices." + ) + try: + region = int(values[0]) + indices = [int(value) - 1 for value in values[1:]] + except ValueError as exc: + raise ValueError( + f"{mesh_path}:{line_number} contains a non-integer tetrahedron value." + ) from exc + if region <= 0: + raise ValueError( + f"{mesh_path}:{line_number} has invalid region ID {region}; expected > 0." + ) + if any(point < 0 or point >= point_count for point in indices): + raise ValueError( + f"{mesh_path}:{line_number} contains a point index outside 1..{point_count}." + ) + simplices.append(indices) + regions.append(region) + except (OSError, UnicodeError) as exc: + raise ValueError(f"Unable to read Netgen mesh {mesh_path}: {exc}") from exc + + return RawMesh(points=points, simplices=simplices, regions=regions, dim=3) diff --git a/tests/nmag/test_public_imports.py b/tests/nmag/test_public_imports.py index c0e8f1e..a28659f 100644 --- a/tests/nmag/test_public_imports.py +++ b/tests/nmag/test_public_imports.py @@ -115,6 +115,9 @@ def test_public_nmag_import_surface(): assert nmag.IntegratorStats assert nmag.MagMaterial assert nmag.Simulation + assert nmag.export_vtk + assert nmag.resolve_snapshot + assert nmesh.read_netgen_neutral def test_nmag_simulation_import_keeps_scipy_linalg_lazy(): @@ -203,4 +206,3 @@ def test_public_nmag_import_keeps_pint_registry_lazy(): "True", ] - diff --git a/tests/nmag/test_vtk_export.py b/tests/nmag/test_vtk_export.py new file mode 100644 index 0000000..8211120 --- /dev/null +++ b/tests/nmag/test_vtk_export.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import h5py +import meshio +import numpy as np + +import nmesh +from nmag.vtk_export import export_vtk, resolve_snapshot + + +def _write_snapshot(path, mesh_path): + points = np.asarray(nmesh.load(mesh_path).points, dtype=float) + with h5py.File(path, "w") as handle: + handle.create_dataset("mesh/points", data=points) + handle.create_dataset("fields/m", data=np.tile([1.0, 0.0, 0.0], (len(points), 1))) + handle.create_dataset("fields/pin", data=np.ones(len(points))) + + +def _write_mesh(path): + 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=[4], + ) + mesh.save(path) + + +def test_export_vtk_writes_selected_point_fields_and_regions(tmp_path): + mesh_path = tmp_path / "mesh.nmesh.h5" + snapshot_path = tmp_path / "simulation_dat.h5" + output_path = tmp_path / "simulation.vtu" + _write_mesh(mesh_path) + _write_snapshot(snapshot_path, mesh_path) + + export_vtk(snapshot_path, output_path, mesh_path=mesh_path, fields=("m",)) + + exported = meshio.read(output_path) + assert exported.points.shape == (4, 3) + assert exported.cells[0].type == "tetra" + assert np.array_equal(exported.cell_data["region"][0], [4]) + assert exported.point_data["m"].shape == (4, 3) + assert "pin" not in exported.point_data + + +def test_export_vtk_requires_external_topology_for_modern_snapshot(tmp_path): + snapshot_path = tmp_path / "simulation_dat.h5" + with h5py.File(snapshot_path, "w") as handle: + handle.create_dataset("mesh/points", data=np.zeros((4, 3))) + handle.create_dataset("fields/m", data=np.zeros((4, 3))) + + try: + export_vtk(snapshot_path, tmp_path / "out.vtk") + except ValueError as exc: + assert "provide --mesh" in str(exc) + else: + raise AssertionError("export_vtk accepted a snapshot without cell topology") + + +def test_resolve_snapshot_accepts_simulation_base_name(tmp_path): + snapshot = tmp_path / "simulation_dat.h5" + snapshot.touch() + + assert resolve_snapshot(tmp_path / "simulation") == snapshot diff --git a/tests/nmesh/io/test_netgen.py b/tests/nmesh/io/test_netgen.py new file mode 100644 index 0000000..62b8b10 --- /dev/null +++ b/tests/nmesh/io/test_netgen.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import gzip + +import pytest + +from nmesh.io.netgen import read_netgen_neutral + +NETGEN = """4 +0 0 0 +1 0 0 +0 1 0 +0 0 1 +1 +7 1 2 3 4 +""" + + +def test_read_netgen_neutral_converts_indices_and_preserves_regions(tmp_path): + path = tmp_path / "mesh.mesh" + path.write_text(NETGEN, encoding="utf-8") + + mesh = read_netgen_neutral(path) + + assert mesh.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]] + assert mesh.simplices == [[0, 1, 2, 3]] + assert mesh.regions == [7] + + +def test_read_netgen_neutral_accepts_gzip(tmp_path): + path = tmp_path / "mesh.mesh.gz" + with gzip.open(path, "wt", encoding="utf-8") as stream: + stream.write(NETGEN) + + assert read_netgen_neutral(path).simplices == [[0, 1, 2, 3]] + + +@pytest.mark.parametrize( + ("content", "message"), + [ + ("1\n0 0 0\n1\n1 1 1 1 2\n", "outside"), + ("4\n0 0 0\n1 0 0\n0 1 0\n0 0 1\n1\n0 1 2 3 4\n", "region"), + ("4\n0 0 0\n1 0 0\n0 1 0\n0 0 1\n1\n1 1 2 3\n", "four point indices"), + ], +) +def test_read_netgen_neutral_rejects_invalid_elements(tmp_path, content, message): + path = tmp_path / "invalid.mesh" + path.write_text(content, encoding="utf-8") + + with pytest.raises(ValueError, match=message): + read_netgen_neutral(path) From ea860eccca20bf5d4ae48e55fa8140f72d0d7663 Mon Sep 17 00:00:00 2001 From: Emanuel Pituch <32016786+epituch@users.noreply.github.com> Date: Tue, 15 Sep 2026 17:17:27 -0700 Subject: [PATCH 2/2] fix: avoid simulation module import shadowing --- src/nmag/__init__.py | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/src/nmag/__init__.py b/src/nmag/__init__.py index 1d74428..577c2a4 100644 --- a/src/nmag/__init__.py +++ b/src/nmag/__init__.py @@ -1,20 +1,7 @@ """Public Python 3 entrypoint for the nmag rewrite.""" -# ruff: noqa: E402, I001 # path cleanup must precede the compatibility imports - -import sys -from pathlib import Path from typing import Any - -_CURRENT_DIRECTORY = Path.cwd().resolve() -if (_CURRENT_DIRECTORY / "simulation.py").is_file(): - sys.path[:] = [ - entry - for entry in sys.path - if Path(entry or ".").resolve() != _CURRENT_DIRECTORY - ] - from anisotropy import ( PredefinedAnisotropy, cubic_anisotropy,