Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/getting-started/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
21 changes: 21 additions & 0 deletions docs/guides/meshes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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")
```
29 changes: 28 additions & 1 deletion docs/guides/output.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
16 changes: 16 additions & 0 deletions src/nmag/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Public Python 3 entrypoint for the nmag rewrite."""

from typing import Any

from anisotropy import (
PredefinedAnisotropy,
cubic_anisotropy,
Expand All @@ -15,6 +17,8 @@
from .parallel import ParallelRuntimeInfo, parallel_runtime_info
from .simulation import Simulation

_OUTPUT_EXPORTS = {"export_vtk", "resolve_snapshot"}

__all__ = [
"MagMaterial",
"NmagConfig",
Expand All @@ -33,4 +37,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}")
1 change: 1 addition & 0 deletions src/nmag/cli/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Command-line entry points for Nmag utilities."""
43 changes: 43 additions & 0 deletions src/nmag/cli/nmagpp.py
Original file line number Diff line number Diff line change
@@ -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())
124 changes: 124 additions & 0 deletions src/nmag/vtk_export.py
Original file line number Diff line number Diff line change
@@ -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}")
8 changes: 8 additions & 0 deletions src/nmesh/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"do_every_n_steps_driver",
"make_mg_gendriver",
}
_NETGEN_EXPORTS = {"read_netgen_neutral"}

__all__ = [
"Mesh",
Expand Down Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions src/nmesh/cli/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Command-line entry points for mesh utilities."""
39 changes: 39 additions & 0 deletions src/nmesh/cli/nmeshimport.py
Original file line number Diff line number Diff line change
@@ -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())
9 changes: 9 additions & 0 deletions src/nmesh/io/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]


Expand All @@ -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}")
Loading
Loading