From 66851f8f4877f7a70fd5232b5c21b897c89020df Mon Sep 17 00:00:00 2001 From: Emanuel Pituch <32016786+epituch@users.noreply.github.com> Date: Fri, 13 Feb 2026 18:28:25 -0800 Subject: [PATCH 01/14] Initial nmesh attempt --- src/nmesh/__init__.py | 1 + src/nmesh/nmesh.py | 599 ++++++++++++++++++++++++++++++++++++++++++ tests/nmesh_test.py | 102 +++++++ 3 files changed, 702 insertions(+) create mode 100644 src/nmesh/__init__.py create mode 100644 src/nmesh/nmesh.py create mode 100644 tests/nmesh_test.py diff --git a/src/nmesh/__init__.py b/src/nmesh/__init__.py new file mode 100644 index 0000000..671625b --- /dev/null +++ b/src/nmesh/__init__.py @@ -0,0 +1 @@ +from .nmesh import * diff --git a/src/nmesh/nmesh.py b/src/nmesh/nmesh.py new file mode 100644 index 0000000..02c9b83 --- /dev/null +++ b/src/nmesh/nmesh.py @@ -0,0 +1,599 @@ +import math +import logging +from typing import List, Tuple, Optional, Any, Union +from pathlib import Path +import itertools + +# Setup logging +log = logging.getLogger(__name__) + +# --- Stubs for External Dependencies --- + +class OCamlStub: + """Stub for the OCaml backend interface.""" + def time_vmem_rss(self): + return 0.0, 0, 0 + + # Mesher defaults setters + def mesher_defaults_set_shape_force_scale(self, mesher, scale): pass + def mesher_defaults_set_volume_force_scale(self, mesher, scale): pass + def mesher_defaults_set_neigh_force_scale(self, mesher, scale): pass + def mesher_defaults_set_irrel_elem_force_scale(self, mesher, scale): pass + def mesher_defaults_set_time_step_scale(self, mesher, scale): pass + def mesher_defaults_set_thresh_add(self, mesher, thresh): pass + def mesher_defaults_set_thresh_del(self, mesher, thresh): pass + def mesher_defaults_set_topology_threshold(self, mesher, thresh): pass + def mesher_defaults_set_tolerated_rel_movement(self, mesher, scale): pass + def mesher_defaults_set_max_relaxation_steps(self, mesher, steps): pass + def mesher_defaults_set_initial_settling_steps(self, mesher, steps): pass + def mesher_defaults_set_sliver_correction(self, mesher, scale): pass + def mesher_defaults_set_smallest_allowed_volume_ratio(self, mesher, scale): pass + def mesher_defaults_set_movement_max_freedom(self, mesher, scale): pass + + # Mesh operations + def mesh_scale_node_positions(self, raw_mesh, scale): pass + def mesh_writefile(self, path, raw_mesh): pass + def mesh_nr_simplices(self, raw_mesh): return 0 + def mesh_nr_points(self, raw_mesh): return 0 + def mesh_plotinfo(self, raw_mesh): return [[], [], [[], [], []]] + def mesh_plotinfo_points(self, raw_mesh): return [] + def mesh_plotinfo_pointsregions(self, raw_mesh): return [] + def mesh_plotinfo_simplices(self, raw_mesh): return [] + def mesh_plotinfo_simplicesregions(self, raw_mesh): return [] + def mesh_plotinfo_surfaces_and_surfacesregions(self, raw_mesh): return [[], []] + def mesh_plotinfo_links(self, raw_mesh): return [] + def mesh_dim(self, raw_mesh): return 3 + def mesh_plotinfo_regionvolumes(self, raw_mesh): return [] + def mesh_plotinfo_periodic_points_indices(self, raw_mesh): return [] + def mesh_set_vertex_distribution(self, raw_mesh, dist): pass + def mesh_get_permutation(self, raw_mesh): return [] + def mesh_readfile(self, filename, do_reorder, do_distribute): return "STUB_MESH" + + # Driver and Mesh creation + def make_mg_gendriver(self, interval, callback): return "STUB_DRIVER" + def copy_mesher_defaults(self, defaults): return "STUB_MESHER" + def mesh_bodies_raw(self, driver, mesher, bb_min, bb_max, mesh_ext, objects, a0, density, fixed, mobile, simply, periodic, cache, hints): return "STUB_MESH" + def mesh_from_points_and_simplices(self, dim, points, simplices, regions, periodic, reorder, distribute): return "STUB_MESH" + + # Body operations + def body_union(self, objs): return "STUB_OBJ_UNION" + def body_difference(self, obj1, objs): return "STUB_OBJ_DIFF" + def body_intersection(self, objs): return "STUB_OBJ_INTERSECT" + def body_shifted_sc(self, obj, shift): return obj + def body_shifted_bc(self, obj, shift): return obj + def body_scaled(self, obj, scale): return obj + def body_rotated_sc(self, obj, a1, a2, ang): return obj + def body_rotated_bc(self, obj, a1, a2, ang): return obj + def body_rotated_axis_sc(self, obj, axis, ang): return obj + def body_rotated_axis_bc(self, obj, axis, ang): return obj + + # Primitives + def body_box(self, p1, p2): return "STUB_BOX" + def body_ellipsoid(self, length): return "STUB_ELLIPSOID" + def body_frustum(self, c1, r1, c2, r2): return "STUB_FRUSTUM" + def body_helix(self, c1, r1, c2, r2): return "STUB_HELIX" + + @property + def mesher_defaults(self): return "STUB_DEFAULTS" + +ocaml = OCamlStub() + +def memory_report(tag: str): + """Reports memory usage via OCaml backend.""" + t, vmem, rss = ocaml.time_vmem_rss() + log.log(15, f"Memory report: T= {t:f} VMEM= {int(vmem)} KB RSS= {int(rss)} KB {tag}") + +# --- Configuration --- + +class FeaturesStub: + """Stub for nsim.features.Features.""" + def __init__(self, local=True): + self._data = {} + + def from_file(self, file_path): pass + def from_string(self, string): pass + def add_section(self, section): + if section not in self._data: + self._data[section] = {} + + def get(self, section, name, raw=False): + return self._data.get(section, {}).get(name) + + def set(self, section, name, value): + if section not in self._data: + self._data[section] = {} + self._data[section][name] = value + + def items(self, section): + return self._data.get(section, {}).items() + + def to_string(self): + return str(self._data) + +class MeshingParameters(FeaturesStub): + """Parameters for the meshing algorithm, supporting multiple dimensions.""" + def __init__(self, string=None, file=None): + super().__init__(local=True) + self.dim = None + if file: self.from_file(file) + if string: self.from_string(string) + self.add_section('user-modifications') + + def _get_section_name(self): + 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 __getitem__(self, name): + val = self.get('user-modifications', name) + if val is not None: + return val + section = self._get_section_name() + return self.get(section, name) + + def __setitem__(self, key, value): + self.set('user-modifications', key, value) + + def set_shape_force_scale(self, v): self["shape_force_scale"] = float(v) + def set_volume_force_scale(self, v): self["volume_force_scale"] = float(v) + def set_neigh_force_scale(self, v): self["neigh_force_scale"] = float(v) + def set_irrel_elem_force_scale(self, v): self["irrel_elem_force_scale"] = float(v) + def set_time_step_scale(self, v): self["time_step_scale"] = float(v) + def set_thresh_add(self, v): self["thresh_add"] = float(v) + def set_thresh_del(self, v): self["thresh_del"] = float(v) + def set_topology_threshold(self, v): self["topology_threshold"] = float(v) + def set_tolerated_rel_move(self, v): self["tolerated_rel_move"] = float(v) + def set_max_steps(self, v): self["max_steps"] = int(v) + def set_initial_settling_steps(self, v): self["initial_settling_steps"] = int(v) + def set_sliver_correction(self, v): self["sliver_correction"] = float(v) + def set_smallest_volume_ratio(self, v): self["smallest_volume_ratio"] = float(v) + def set_max_relaxation(self, v): self["max_relaxation"] = float(v) + + def pass_parameters_to_ocaml(self, mesher, dim): + self.dim = dim + for key, value in self.items('user-modifications'): + section = self._get_section_name() + self.set(section, key, str(value)) + + params = [ + ("shape_force_scale", ocaml.mesher_defaults_set_shape_force_scale), + ("volume_force_scale", ocaml.mesher_defaults_set_volume_force_scale), + ("neigh_force_scale", ocaml.mesher_defaults_set_neigh_force_scale), + ("irrel_elem_force_scale", ocaml.mesher_defaults_set_irrel_elem_force_scale), + ("time_step_scale", ocaml.mesher_defaults_set_time_step_scale), + ("thresh_add", ocaml.mesher_defaults_set_thresh_add), + ("thresh_del", ocaml.mesher_defaults_set_thresh_del), + ("topology_threshold", ocaml.mesher_defaults_set_topology_threshold), + ("tolerated_rel_move", ocaml.mesher_defaults_set_tolerated_rel_movement), + ("max_steps", ocaml.mesher_defaults_set_max_relaxation_steps), + ("initial_settling_steps", ocaml.mesher_defaults_set_initial_settling_steps), + ("sliver_correction", ocaml.mesher_defaults_set_sliver_correction), + ("smallest_volume_ratio", ocaml.mesher_defaults_set_smallest_allowed_volume_ratio), + ("max_relaxation", ocaml.mesher_defaults_set_movement_max_freedom), + ] + + for key, setter in params: + val = self[key] + if val is not None: + setter(mesher, float(val) if "steps" not in key else int(val)) + +def get_default_meshing_parameters(): + """Returns default meshing parameters.""" + return MeshingParameters() + +# --- Loading Utilities --- + +def _is_nmesh_ascii_file(filename): + try: + with open(filename, 'r') as f: + return f.readline().startswith("# PYFEM") + except: return False + +def _is_nmesh_hdf5_file(filename): + # This would normally use tables.isPyTablesFile + return str(filename).lower().endswith('.h5') + +def hdf5_mesh_get_permutation(filename): + """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): + self.raw_mesh = raw_mesh + self._cache = {} + + def scale_node_positions(self, scale: float): + """Scales all node positions in the mesh.""" + ocaml.mesh_scale_node_positions(self.raw_mesh, float(scale)) + self._cache.pop('points', None) + self._cache.pop('region_volumes', None) + + def save(self, filename: Union[str, Path]): + """Saves the mesh to a file (ASCII or HDF5).""" + path = str(filename) + if path.lower().endswith('.h5'): + log.info(f"Saving to HDF5 (stub): {path}") + else: + ocaml.mesh_writefile(path, self.raw_mesh) + + def __str__(self): + pts = ocaml.mesh_nr_points(self.raw_mesh) + simps = ocaml.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 ocaml.mesh_plotinfo(self.raw_mesh) + + @property + def points(self): + if 'points' not in self._cache: + self._cache['points'] = ocaml.mesh_plotinfo_points(self.raw_mesh) + return self._cache['points'] + + @property + def simplices(self): + if 'simplices' not in self._cache: + self._cache['simplices'] = ocaml.mesh_plotinfo_simplices(self.raw_mesh) + return self._cache['simplices'] + + @property + def regions(self): + if 'regions' not in self._cache: + self._cache['regions'] = ocaml.mesh_plotinfo_simplicesregions(self.raw_mesh) + return self._cache['regions'] + + @property + def dim(self): + return ocaml.mesh_dim(self.raw_mesh) + + @property + def surfaces(self): + return ocaml.mesh_plotinfo_surfaces_and_surfacesregions(self.raw_mesh)[0] + + @property + def point_regions(self): + """Returns regions for each point.""" + if 'point_regions' not in self._cache: + self._cache['point_regions'] = ocaml.mesh_plotinfo_pointsregions(self.raw_mesh) + return self._cache['point_regions'] + + @property + def links(self): + """Returns all links (pairs of point indices).""" + if 'links' not in self._cache: + self._cache['links'] = ocaml.mesh_plotinfo_links(self.raw_mesh) + return self._cache['links'] + + @property + def region_volumes(self): + """Returns volume of each region.""" + if 'region_volumes' not in self._cache: + self._cache['region_volumes'] = ocaml.mesh_plotinfo_regionvolumes(self.raw_mesh) + return self._cache['region_volumes'] + + @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.""" + if 'periodic_indices' not in self._cache: + self._cache['periodic_indices'] = ocaml.mesh_plotinfo_periodic_points_indices(self.raw_mesh) + return self._cache['periodic_indices'] + + @property + def permutation(self): + """Returns the node permutation mapping.""" + return ocaml.mesh_get_permutation(self.raw_mesh) + + def set_vertex_distribution(self, dist): + """Sets vertex distribution.""" + ocaml.mesh_set_vertex_distribution(self.raw_mesh, dist) + +class Mesh(MeshBase): + """Class for generating a mesh from geometric objects.""" + def __init__(self, bounding_box, objects=[], a0=1.0, density="", + periodic=[], fixed_points=[], mobile_points=[], simply_points=[], + callback=None, mesh_bounding_box=False, meshing_parameters=None, + cache_name="", hints=[], **kwargs): + + if bounding_box is None: + raise ValueError("Bounding box must be provided.") + + bb = [[float(x) for x in p] for p in bounding_box] + dim = len(bb[0]) + mesh_ext = 1 if mesh_bounding_box else 0 + + if not objects and not mesh_bounding_box: + raise ValueError("No objects to mesh and bounding box meshing disabled.") + + params = meshing_parameters or get_default_meshing_parameters() + for k, v in kwargs.items(): + params[k] = v + + obj_bodies = [] + # Store points lists to allow appending later (mimicking original API) + self._fixed_points = [list(map(float, p)) for p in fixed_points] + self._mobile_points = [list(map(float, p)) for p in mobile_points] + self._simply_points = [list(map(float, p)) for p in simply_points] + + for obj in objects: + obj_bodies.append(obj.obj) + self._fixed_points.extend(obj.fixed_points) + self._mobile_points.extend(obj.mobile_points) + + periodic_floats = [1.0 if p else 0.0 for p in periodic] if periodic else [0.0] * dim + + cb_func, cb_interval = callback if callback else (lambda a,b,c: None, 1000000) + self.fun_driver = cb_func + driver = ocaml.make_mg_gendriver(cb_interval, cb_func) + mesher = ocaml.copy_mesher_defaults(ocaml.mesher_defaults) + params.pass_parameters_to_ocaml(mesher, dim) + + # Note: In the original code, mesh generation happens in __init__. + # Adding points via methods afterwards wouldn't affect the already generated mesh + # unless we regenerate or if those methods were meant for pre-generation setup. + # However, checking lib1.py, __init__ calls mesh_bodies_raw immediately. + # The methods fixed_points/mobile_points in lib1.py just append to self.fixed_points + # which seems useless after __init__ unless the user manually triggers something else. + # But we will preserve them for API compatibility. + + raw = ocaml.mesh_bodies_raw( + driver, mesher, bb[0], bb[1], mesh_ext, obj_bodies, float(a0), + density, self._fixed_points, self._mobile_points, self._simply_points, periodic_floats, + cache_name, 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: List[List[float]]): + """Adds fixed points to the mesh configuration.""" + if points: + self._fixed_points.extend(points) + + def mobile_points(self, points: List[List[float]]): + """Adds mobile points to the mesh configuration.""" + if points: + self._mobile_points.extend(points) + + def simply_points(self, points: List[List[float]]): + """Adds simply points to the mesh configuration.""" + if points: + self._simply_points.extend(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") + + # Determine format + if _is_nmesh_ascii_file(filename): + raw = ocaml.mesh_readfile(str(path), reorder, distribute) + elif _is_nmesh_hdf5_file(filename): + # load_hdf5 logic would go here + raw = ocaml.mesh_readfile(str(path), reorder, distribute) + else: + raise ValueError(f"Unknown mesh file format: {filename}") + + super().__init__(raw) + +class mesh_from_points_and_simplices(MeshBase): + """Wrapper for backward compatibility.""" + def __init__(self, points=[], simplices_indices=[], simplices_regions=[], + periodic_point_indices=[], initial=0, do_reorder=False, + do_distribute=True): + + # Adjust for 1-based indexing if initial=1 + if initial == 1: + simplices_indices = [[idx - 1 for idx in s] for s in simplices_indices] + + raw = ocaml.mesh_from_points_and_simplices( + len(points[0]) if points else 3, + [[float(x) for x in p] for p in points], + [[int(x) for x in s] for s in simplices_indices], + [int(r) for r in simplices_regions], + periodic_point_indices, 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: Union[str, Path]): + """Alias for mesh.save for backward compatibility.""" + mesh.save(filename) + +# --- Exception Aliases --- +NmeshUserError = ValueError +NmeshIOError = IOError +NmeshStandardError = RuntimeError + +# --- Geometry --- + +class MeshObject: + """Base class for geometric primitives and CSG operations.""" + def __init__(self, dim, fixed=[], mobile=[]): + self.dim = dim + self.fixed_points = fixed + self.mobile_points = mobile + self.obj: Any = None + + def shift(self, vector, system_coords=True): + self.obj = (ocaml.body_shifted_sc if system_coords else ocaml.body_shifted_bc)(self.obj, vector) + + def scale(self, factors): + self.obj = ocaml.body_scaled(self.obj, factors) + + def rotate(self, a1, a2, angle, system_coords=True): + rad = math.radians(angle) + self.obj = (ocaml.body_rotated_sc if system_coords else ocaml.body_rotated_bc)(self.obj, a1, a2, rad) + + def rotate_3d(self, axis, angle, system_coords=True): + rad = math.radians(angle) + self.obj = (ocaml.body_rotated_axis_sc if system_coords else ocaml.body_rotated_axis_bc)(self.obj, axis, rad) + + def transform(self, transformations, system_coords=True): + """Applies a list of transformation tuples.""" + for t in transformations: + name, *args = t + if name == "shift": self.shift(args[0], system_coords) + elif name == "scale": self.scale(args[0]) + elif name == "rotate": self.rotate(args[0][0], args[0][1], args[1], system_coords) + elif name == "rotate2d": self.rotate(0, 1, args[0], system_coords) + elif name == "rotate3d": self.rotate_3d(args[0], args[1], system_coords) + +class Box(MeshObject): + def __init__(self, p1, p2, transform=[], fixed=[], mobile=[], system_coords=True, use_fixed_corners=False): + dim = len(p1) + if use_fixed_corners: + fixed.extend([list(c) for c in itertools.product(*zip(p1, p2))]) + super().__init__(dim, fixed, mobile) + self.obj = ocaml.body_box([float(x) for x in p1], [float(x) for x in p2]) + self.transform(transform, system_coords) + +class Ellipsoid(MeshObject): + def __init__(self, lengths, transform=[], fixed=[], mobile=[], system_coords=True): + super().__init__(len(lengths), fixed, mobile) + self.obj = ocaml.body_ellipsoid([float(x) for x in lengths]) + self.transform(transform, system_coords) + +class Conic(MeshObject): + def __init__(self, c1, r1, c2, r2, transform=[], fixed=[], mobile=[], system_coords=True): + super().__init__(len(c1), fixed, mobile) + self.obj = ocaml.body_frustum(c1, r1, c2, r2) + self.transform(transform, system_coords) + +class Helix(MeshObject): + def __init__(self, c1, r1, c2, r2, transform=[], fixed=[], mobile=[], system_coords=True): + super().__init__(len(c1), fixed, mobile) + self.obj = ocaml.body_helix(c1, r1, c2, r2) + self.transform(transform, system_coords) + +# --- CSG --- + +def union(objects: List[MeshObject]) -> MeshObject: + if len(objects) < 2: raise ValueError("Union requires at least two objects") + res = MeshObject(objects[0].dim) + for o in objects: + res.fixed_points.extend(o.fixed_points) + res.mobile_points.extend(o.mobile_points) + res.obj = ocaml.body_union([o.obj for o in objects]) + return res + +def difference(mother: MeshObject, subtract: List[MeshObject]) -> MeshObject: + res = MeshObject(mother.dim, mother.fixed_points[:], mother.mobile_points[:]) + for o in subtract: + res.fixed_points.extend(o.fixed_points) + res.mobile_points.extend(o.mobile_points) + res.obj = ocaml.body_difference(mother.obj, [o.obj for o in subtract]) + return res + +def intersect(objects: List[MeshObject]) -> MeshObject: + if len(objects) < 2: raise ValueError("Intersection requires at least two objects") + res = MeshObject(objects[0].dim) + for o in objects: + res.fixed_points.extend(o.fixed_points) + res.mobile_points.extend(o.mobile_points) + res.obj = ocaml.body_intersection([o.obj for o in objects]) + return res + +# --- 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: List[Tuple[float, float]], discretization: float) -> Tuple: + """Generates 1D mesh components (points, simplices, regions).""" + points, simplices, regions_ids = [], [], [] + point_map = {} + + def get_idx(v): + vk = round(v, 8) + if vk not in point_map: + point_map[vk] = len(points) + points.append([float(v)]) + 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 + + # Note: original unidmesher also returned surfaces, but simplified here + # Standard format for mesh_from_points_and_simplices: + # simplices are list of point indices, regions are separate list + return points, simplices, regions_ids + +def generate_1d_mesh(regions: List[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, out=None, check=True, float_fmt=" %f"): + """ + Writes mesh data (points, simplices, surfaces) to a file in nmesh format. + mesh_data: (points, simplices, surfaces) + """ + 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" + + if out is None: + print(content) + elif isinstance(out, (str, Path)): + Path(out).write_text(content) + else: + out.write(content) diff --git a/tests/nmesh_test.py b/tests/nmesh_test.py new file mode 100644 index 0000000..ec44546 --- /dev/null +++ b/tests/nmesh_test.py @@ -0,0 +1,102 @@ +import unittest +import math +from pathlib import Path +import nmesh + +class TestNMesh(unittest.TestCase): + 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) + + def test_box_primitive(self): + """Test Box primitive creation and transformations.""" + 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]) + b.rotate(0, 1, 90) + + 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) + + def test_mesh_generation_stub(self): + """Test Mesh class initialization with stubs.""" + 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], a0=0.1) + self.assertEqual(str(m), "Mesh with 0 points and 0 simplices") # From stubs + + # Test properties (should return empty lists from stubs) + self.assertEqual(m.points, []) + self.assertEqual(m.simplices, []) + self.assertEqual(m.regions, []) + + 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) + self.assertEqual(len(regs), 4) + + def test_outer_corners(self): + """Test outer_corners utility.""" + 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]) + self.assertEqual(max_corner, [1, 2]) + + def test_write_mesh(self): + """Test write_mesh utility.""" + points = [[0.0, 0.0], [1.0, 1.0]] + simplices = [(1, [0, 1])] + surfaces = [(1, [0])] + data = (points, simplices, surfaces) + + import io + out = io.StringIO() + nmesh.write_mesh(data, out=out) + content = out.getvalue() + self.assertIn("# PYFEM mesh file version 1.0", content) + self.assertIn("nodes = 2", content) + +if __name__ == '__main__': + unittest.main() From 52c4e8704171e4d06152d711e39faf4b5d5cf949 Mon Sep 17 00:00:00 2001 From: Emanuel Pituch <32016786+epituch@users.noreply.github.com> Date: Tue, 10 Mar 2026 19:44:18 -0700 Subject: [PATCH 02/14] Added more ocaml modules --- CONVERSION_PLAN.md | 46 +++ src/nmesh/__init__.py | 39 +- src/nmesh/backend.py | 603 ++++++++++++++++++++++++++++++ src/nmesh/base.py | 224 +++++++++++ src/nmesh/features.py | 76 ++++ src/nmesh/geometry.py | 101 +++++ src/nmesh/nmesh.py | 599 ----------------------------- src/nmesh/utils.py | 123 ++++++ src/simulation/features.py | 71 ++++ src/simulation/mock_features.py | 19 - src/simulation/simulation_core.py | 4 +- tests/nmesh_test.py | 80 ++-- 12 files changed, 1341 insertions(+), 644 deletions(-) create mode 100644 CONVERSION_PLAN.md create mode 100644 src/nmesh/backend.py create mode 100644 src/nmesh/base.py create mode 100644 src/nmesh/features.py create mode 100644 src/nmesh/geometry.py delete mode 100644 src/nmesh/nmesh.py create mode 100644 src/nmesh/utils.py create mode 100644 src/simulation/features.py delete mode 100644 src/simulation/mock_features.py diff --git a/CONVERSION_PLAN.md b/CONVERSION_PLAN.md new file mode 100644 index 0000000..071a715 --- /dev/null +++ b/CONVERSION_PLAN.md @@ -0,0 +1,46 @@ +# Nmag Python 3 Conversion Plan: nmesh & Features + +This document outlines the progress and remaining tasks for converting the `nmesh` OCaml backend stubs to native Python 3 and refactoring the configuration system. + +## Completed Tasks + +### 1. Features Refactoring +- **New `Features` Class**: Implemented a robust replacement for the OCaml `nsim.setup.get_features()` in `src/simulation/features.py`. +- **Cleanup**: Removed `MockFeatures` and updated `simulation_core.py` to use the new system. +- **Integration**: Integrated `Features` into `MeshingParameters`. + +### 2. nmesh Modularization +- **Modular Structure**: Refactored the monolithic `nmesh.py` into a package: + - `src/nmesh/backend.py`: Core backend logic, including the relaxation mesher. + - `src/nmesh/base.py`: Primary mesh classes (`Mesh`, `MeshBase`, `MeshFromFile`). + - `src/nmesh/geometry.py`: Geometric primitives and CSG operations. + - `src/nmesh/features.py`: Meshing parameter management. + - `src/nmesh/utils.py`: Utility functions (I/O, 1D meshing, etc.). + - `src/nmesh/__init__.py`: API compatibility layer. + +### 3. Functional Implementation +- **Mesher**: Functional `mesh_bodies_raw` with point sampling, iterative relaxation, and `scipy.spatial.Delaunay` triangulation. +- **Boundary Enforcement**: Gradient-based point correction for complex geometries. +- **Geometry**: Native Python 3 implementations of all primitives and transformations. +- **I/O**: Native PYFEM mesh reader/writer. + +### 4. Verification +- **Testing**: Updated existing tests and added new ones in `tests/nmesh_test.py`. +- **Validation**: Verified that both `nmesh` and `simulation` test suites pass (34/34 tests). + +## Remaining Tasks + +### 1. Advanced Meshing Features +- **Adaptive Refinement**: Complete the point addition/deletion logic based on local density vs. target rod length (currently simplified). +- **Periodicity Completion**: Fully implement `mesh_periodic_outer_box` to generate periodic slice meshes. + +### 2. HDF5 Support +- **Implementation**: Replace ASCII-only I/O with robust HDF5 support using `h5py` or `tables`. + +### 3. Performance Optimization +- **Vectorization**: Further optimize BC evaluation and force calculations using more aggressive NumPy vectorization. + +## Technical Notes + +- **Dependencies**: Added `numpy` and `scipy` as core requirements for the meshing system. +- **API Continuity**: Maintained the original `nmesh` API to ensure that existing scripts and higher-level modules remain functional. diff --git a/src/nmesh/__init__.py b/src/nmesh/__init__.py index 671625b..cd71707 100644 --- a/src/nmesh/__init__.py +++ b/src/nmesh/__init__.py @@ -1 +1,38 @@ -from .nmesh import * +from nmesh.backend import nmesh_backend as backend +from nmesh.base import ( + MeshBase, Mesh, MeshFromFile, mesh_from_points_and_simplices +) +from nmesh.geometry import ( + MeshObject, Box, Ellipsoid, Conic, Helix, union, difference, intersect +) +from nmesh.features import MeshingParameters, get_default_meshing_parameters +from nmesh.utils import ( + outer_corners, generate_1d_mesh_components, generate_1d_mesh, write_mesh, memory_report +) + +def load(filename, reorder=False, do_distribute=True): + """Load nmesh file with name filename.""" + import os + if not os.path.exists(filename): + raise ValueError(f"file '{filename}' does not exist") + + # Simple extension based check + if filename.lower().endswith('.h5'): + # For now, we don't have HDF5 support implemented + raise NotImplementedError("HDF5 mesh loading is not yet implemented in Python 3 version.") + + return MeshFromFile(filename, reorder=reorder, distribute=do_distribute) + +def save(mesh, filename): + """Alias for mesh.save for backward compatibility.""" + mesh.save(filename) + +# --- Exception Aliases --- +NmeshUserError = ValueError +NmeshIOError = IOError +NmeshStandardError = RuntimeError +NMeshTypeError = TypeError + +# --- Compatibility Aliases --- +tolists = lambda mesh: mesh.to_lists() +mesh_1d = generate_1d_mesh_components diff --git a/src/nmesh/backend.py b/src/nmesh/backend.py new file mode 100644 index 0000000..9aebec2 --- /dev/null +++ b/src/nmesh/backend.py @@ -0,0 +1,603 @@ +import math +import logging +import time +import itertools +import numpy as np +import re +import copy +from pathlib import Path + +log = logging.getLogger(__name__) + +class MesherDefaults: + def __init__(self): + # Default values from nmag-src/src/mesh.ml + self.mdefault_controller_initial_points_volume_ratio = 0.9 + self.mdefault_controller_splitting_connection_ratio = 1.6 + self.mdefault_controller_exp_neigh_force_scale = 0.9 + self.mdefault_nr_probes_for_determining_volume = 100000 + self.mdefault_boundary_condition_acceptable_fuzz = 1.0e-6 + self.mdefault_boundary_condition_max_nr_correction_steps = 200 + self.mdefault_boundary_condition_debuglevel = 0 + self.mdefault_relaxation_debuglevel = 0 + self.mdefault_controller_movement_max_freedom = 3.0 + self.mdefault_controller_topology_threshold = 0.2 + self.mdefault_controller_step_limit_min = 500 + self.mdefault_controller_step_limit_max = 1000 + self.mdefault_controller_max_time_step = 10.0 + self.mdefault_controller_time_step_scale = 0.1 + self.mdefault_controller_tolerated_rel_movement = 0.002 + self.mdefault_controller_shape_force_scale = 0.1 + self.mdefault_controller_volume_force_scale = 0.0 + self.mdefault_controller_neigh_force_scale = 1.0 + self.mdefault_controller_irrel_elem_force_scale = 1.0 + self.mdefault_controller_initial_settling_steps = 100 + self.mdefault_controller_thresh_add = 1.0 + self.mdefault_controller_thresh_del = 2.0 + self.mdefault_controller_sliver_correction = 1.0 + self.mdefault_controller_smallest_allowed_volume_ratio = 1.0 + + def mesher_defaults_set_shape_force_scale(self, v): self.mdefault_controller_shape_force_scale = v + def mesher_defaults_set_volume_force_scale(self, v): self.mdefault_controller_volume_force_scale = v + def mesher_defaults_set_neigh_force_scale(self, v): self.mdefault_controller_neigh_force_scale = v + def mesher_defaults_set_irrel_elem_force_scale(self, v): self.mdefault_controller_irrel_elem_force_scale = v + def mesher_defaults_set_time_step_scale(self, v): self.mdefault_controller_time_step_scale = v + def mesher_defaults_set_thresh_add(self, v): self.mdefault_controller_thresh_add = v + def mesher_defaults_set_thresh_del(self, v): self.mdefault_controller_thresh_del = v + def mesher_defaults_set_topology_threshold(self, v): self.mdefault_controller_topology_threshold = v + def mesher_defaults_set_tolerated_rel_movement(self, v): self.mdefault_controller_tolerated_rel_movement = v + def mesher_defaults_set_max_relaxation_steps(self, v): self.mdefault_controller_step_limit_max = v + def mesher_defaults_set_initial_settling_steps(self, v): self.mdefault_controller_initial_settling_steps = v + def mesher_defaults_set_sliver_correction(self, v): self.mdefault_controller_sliver_correction = v + def mesher_defaults_set_smallest_allowed_volume_ratio(self, v): self.mdefault_controller_smallest_allowed_volume_ratio = v + def mesher_defaults_set_movement_max_freedom(self, v): self.mdefault_controller_movement_max_freedom = v + +class RawMesh: + """Internal representation of a mesh.""" + def __init__(self, dim, points, simplices, regions, periodic_points=None, permutation=None): + self.dim = dim + self.points = points # List[List[float]] + self.simplices = simplices # List[List[int]] + self.regions = regions # List[int] + self.periodic_points = periodic_points or [] + self.permutation = permutation + self._links = None + self._surfaces = None + self._surfaces_regions = None + self._region_volumes = None + self._point_regions = None + + def get_links(self): + if self._links is None: + links_set = set() + for sx in self.simplices: + for p1, p2 in itertools.combinations(sx, 2): + links_set.add(tuple(sorted((p1, p2)))) + self._links = [list(link) for link in links_set] + return self._links + + def get_surfaces(self): + if self._surfaces is None: + faces = {} + for i, sx in enumerate(self.simplices): + region = self.regions[i] + for j in range(len(sx)): + face = tuple(sorted(sx[:j] + sx[j+1:])) + if face not in faces: + faces[face] = [] + faces[face].append(region) + + self._surfaces = [] + self._surfaces_regions = [] + for face, regs in faces.items(): + if len(regs) == 1: + self._surfaces.append(list(face)) + self._surfaces_regions.append(regs[0]) + elif len(regs) == 2 and regs[0] != regs[1]: + self._surfaces.append(list(face)) + self._surfaces_regions.append(regs[0]) + self._surfaces.append(list(face)) + self._surfaces_regions.append(regs[1]) + return self._surfaces, self._surfaces_regions + + def get_region_volumes(self): + if self._region_volumes is None: + if not self.simplices: + self._region_volumes = [] + return self._region_volumes + + max_reg = max(self.regions) if self.regions else 0 + volumes = [0.0] * (max_reg + 1) + + fact_dim = math.factorial(self.dim) + for i, sx in enumerate(self.simplices): + reg = self.regions[i] + pts = [self.points[idx] for idx in sx] + if len(pts) > 1: + mat = np.array([np.array(pts[j]) - np.array(pts[0]) for j in range(1, len(pts))]) + vol = abs(np.linalg.det(mat)) / fact_dim + if reg >= 0: + volumes[reg] += vol + self._region_volumes = volumes + return self._region_volumes + + def get_point_regions(self): + if self._point_regions is None: + pt_regs = [set() for _ in range(len(self.points))] + for i, sx in enumerate(self.simplices): + reg = self.regions[i] + for pt_idx in sx: + pt_regs[pt_idx].add(reg) + self._point_regions = [list(regs) for regs in pt_regs] + return self._point_regions + +class AffineTrafo: + def __init__(self, dim, matrix=None, displacement=None): + self.dim = dim + self.matrix = matrix if matrix is not None else np.eye(dim) + self.displacement = displacement if displacement is not None else np.zeros(dim) + + def combine(self, other: 'AffineTrafo'): + new_matrix = np.dot(self.matrix, other.matrix) + new_displacement = self.displacement + np.dot(self.matrix, other.displacement) + return AffineTrafo(self.dim, new_matrix, new_displacement) + + def apply_to_pos(self, pos): + return np.dot(self.matrix, np.array(pos)) + self.displacement + +class Body: + """Representation of a geometric body with a boundary condition.""" + def __init__(self, trafo: AffineTrafo, bc_func): + self.trafo = trafo + self.bc_func = bc_func + + def __call__(self, pos): + return self.bc_func(self.trafo.apply_to_pos(pos)) + +class NMeshBackend: + """Implementation of the NMesh backend in Python.""" + def __init__(self): + self._mesher_defaults = MesherDefaults() + + def time_vmem_rss(self): + try: + import psutil + process = psutil.Process() + mem = process.memory_info() + return time.time(), mem.vms / 1024.0, mem.rss / 1024.0 + except ImportError: + return time.time(), 0.0, 0.0 + + # Mesh operations + def mesh_scale_node_positions(self, raw_mesh, scale): + for p in raw_mesh.points: + for i in range(len(p)): + p[i] *= float(scale) + raw_mesh._region_volumes = None + + def mesh_writefile(self, path, raw_mesh): + from .utils import write_mesh + write_mesh((raw_mesh.points, list(zip(raw_mesh.regions, raw_mesh.simplices)), []), out=path) + + def mesh_nr_simplices(self, raw_mesh): + return len(raw_mesh.simplices) + + def mesh_nr_points(self, raw_mesh): + return len(raw_mesh.points) + + def mesh_plotinfo(self, raw_mesh): + surfaces, _ = raw_mesh.get_surfaces() + simps_info = [] + for i, sx in enumerate(raw_mesh.simplices): + simps_info.append([sx, [[[], 0.0], [[], 0.0], raw_mesh.regions[i]]]) + return [raw_mesh.points, raw_mesh.get_links(), simps_info, raw_mesh.get_point_regions()] + + def mesh_plotinfo_points(self, raw_mesh): + return raw_mesh.points + + def mesh_plotinfo_pointsregions(self, raw_mesh): + return raw_mesh.get_point_regions() + + def mesh_plotinfo_simplices(self, raw_mesh): + return raw_mesh.simplices + + def mesh_plotinfo_simplicesregions(self, raw_mesh): + return raw_mesh.regions + + def mesh_plotinfo_surfaces_and_surfacesregions(self, raw_mesh): + return raw_mesh.get_surfaces() + + def mesh_plotinfo_links(self, raw_mesh): + return raw_mesh.get_links() + + def mesh_dim(self, raw_mesh): + return raw_mesh.dim + + def mesh_plotinfo_regionvolumes(self, raw_mesh): + return raw_mesh.get_region_volumes() + + def mesh_plotinfo_periodic_points_indices(self, raw_mesh): + return raw_mesh.periodic_points + + def mesh_set_vertex_distribution(self, raw_mesh, dist): + pass + + def mesh_get_permutation(self, raw_mesh): + return raw_mesh.permutation or list(range(len(raw_mesh.points))) + + def mesh_readfile(self, filename, do_reorder, do_distribute): + path = Path(filename) + if not path.exists(): raise FileNotFoundError(f"File {filename} not found") + with open(path, 'r') as f: + lines = f.readlines() + if not lines or not lines[0].startswith("# PYFEM"): + raise ValueError(f"Invalid mesh file: {filename}") + m = re.search(r"dim\s*=\s*(\d+)\s*nodes\s*=\s*(\d+)\s*simplices\s*=\s*(\d+)", lines[1]) + if not m: raise ValueError(f"Invalid header in mesh file: {filename}") + dim = int(m.group(1)) + ptr = 2 + while ptr < len(lines) and (lines[ptr].strip().startswith("#") or not lines[ptr].strip()): + ptr += 1 + n_pts = int(lines[ptr].strip()) + ptr += 1 + points = [] + for _ in range(n_pts): + points.append([float(x) for x in lines[ptr].split()]) + ptr += 1 + n_simps = int(lines[ptr].strip()) + ptr += 1 + simplices = [] + regions = [] + for _ in range(n_simps): + parts = [float(x) for x in lines[ptr].split()] + regions.append(int(parts[0])) + simplices.append([int(x) for x in parts[1:]]) + ptr += 1 + return RawMesh(dim, points, simplices, regions) + + # Driver and Mesh creation + def make_mg_gendriver(self, interval, callback): + return (interval, callback) + + def symm_grad(self, f, x, epsilon=1e-7): + dim = len(x) + grad = np.zeros(dim) + for i in range(dim): + x_plus = np.array(x, dtype=float) + x_minus = np.array(x, dtype=float) + x_plus[i] += epsilon + x_minus[i] -= epsilon + grad[i] = (f(x_plus) - f(x_minus)) / (2.0 * epsilon) + return grad + + def _enforce_boundary_conditions(self, mesher_defaults, bcs, coords): + acceptable_fuzz = mesher_defaults.mdefault_boundary_condition_acceptable_fuzz + max_steps = mesher_defaults.mdefault_boundary_condition_max_nr_correction_steps + for _ in range(max_steps): + violated_idx = -1 + for i, bc in enumerate(bcs): + if bc(coords) < -acceptable_fuzz: + violated_idx = i + break + if violated_idx == -1: + return True + bc = bcs[violated_idx] + val = bc(coords) + grad = self.symm_grad(bc, coords) + grad_sq = np.sum(grad**2) + if grad_sq < 1e-12: + break + scale = -val / grad_sq + coords += scale * grad + return False + + def _enforce_boundary_conditions_reversed(self, mesher_defaults, bcs, coords): + acceptable_fuzz = mesher_defaults.mdefault_boundary_condition_acceptable_fuzz + max_steps = mesher_defaults.mdefault_boundary_condition_max_nr_correction_steps + for _ in range(max_steps): + violated_idx = -1 + for i, bc in enumerate(bcs): + if bc(coords) > acceptable_fuzz: + violated_idx = i + break + if violated_idx == -1: + return True + bc = bcs[violated_idx] + val = bc(coords) + grad = self.symm_grad(bc, coords) + grad_sq = np.sum(grad**2) + if grad_sq < 1e-12: + break + scale = -val / grad_sq + coords += scale * grad + return False + + def mesher_defaults_set_shape_force_scale(self, m, v): m.mesher_defaults_set_shape_force_scale(v) + def mesher_defaults_set_volume_force_scale(self, m, v): m.mesher_defaults_set_volume_force_scale(v) + def mesher_defaults_set_neigh_force_scale(self, m, v): m.mesher_defaults_set_neigh_force_scale(v) + def mesher_defaults_set_irrel_elem_force_scale(self, m, v): m.mesher_defaults_set_irrel_elem_force_scale(v) + def mesher_defaults_set_time_step_scale(self, m, v): m.mesher_defaults_set_time_step_scale(v) + def mesher_defaults_set_thresh_add(self, m, v): m.mesher_defaults_set_thresh_add(v) + def mesher_defaults_set_thresh_del(self, m, v): m.mesher_defaults_set_thresh_del(v) + def mesher_defaults_set_topology_threshold(self, m, v): m.mesher_defaults_set_topology_threshold(v) + def mesher_defaults_set_tolerated_rel_movement(self, m, v): m.mesher_defaults_set_tolerated_rel_movement(v) + def mesher_defaults_set_max_relaxation_steps(self, m, v): m.mesher_defaults_set_max_relaxation_steps(v) + def mesher_defaults_set_initial_settling_steps(self, m, v): m.mesher_defaults_set_initial_settling_steps(v) + def mesher_defaults_set_sliver_correction(self, m, v): m.mesher_defaults_set_sliver_correction(v) + def mesher_defaults_set_smallest_allowed_volume_ratio(self, m, v): m.mesher_defaults_set_smallest_allowed_volume_ratio(v) + def mesher_defaults_set_movement_max_freedom(self, m, v): m.mesher_defaults_set_movement_max_freedom(v) + + def _all_combinations(self, n): + comb = [] + for i in range(1 << n): + c = [(i & (1 << j)) != 0 for j in range(n)] + comb.append(c) + comb.sort(key=lambda x: sum(x)) + return [np.array(c) for c in comb] + + def _periodic_directions(self, filter_mask): + dim = len(filter_mask) + components = [] + for i in range(dim): + if filter_mask[i]: + c = [False] * dim + c[i] = True + components.append(np.array(c)) + def get_sub_masks(comp): + inv = ~comp + sub = [inv] + for i in range(dim): + if inv[i]: + c = [False] * dim + c[i] = True + sub.append(np.array(c)) + return sub + all_masks = [] + for c in components: + all_masks.extend(get_sub_masks(c)) + unique = {} + for m in all_masks: + unique[tuple(m)] = m + res = list(unique.values()) + res.sort(key=lambda x: sum(x)) + return res + + def _mask_coords(self, mask, nw, se, pt): + res = [] + for i in range(len(pt)): + if mask[i]: + res.append(pt[i]) + else: + if abs(pt[i] - nw[i]) < 1e-10 or abs(pt[i] - se[i]) < 1e-10: + pass + else: + return None + return np.array(res) + + def _unmask_coords(self, mask, point, nw, se): + dim = len(mask) + unmasked_count = dim - sum(mask) + combs = self._all_combinations(unmasked_count) + res = [] + for comb in combs: + new_pt = np.zeros(dim) + p_idx = 0 + c_idx = 0 + for i in range(dim): + if mask[i]: + new_pt[i] = point[p_idx] + p_idx += 1 + else: + new_pt[i] = nw[i] if comb[c_idx] else se[i] + c_idx += 1 + res.append(new_pt) + return res + + def mesh_periodic_outer_box(self, fixed_points, fem_geometry, mdefaults, length_scale, filter_mask): + return np.array([]), [] + + def _relaxation_force(self, reduced_dist): + if reduced_dist > 1.0: return 0.0 + return 1.0 - reduced_dist + + def _boundary_node_force(self, reduced_dist): + if reduced_dist > 1.0: return 0.0 + if reduced_dist < 1e-10: return 100.0 + return (1.0 / reduced_dist) - 1.0 + + def _sample_points(self, dim, nw, se, density_fun, target_count, rng=None): + if rng is None: rng = np.random.default_rng() + points = [] + max_attempts = target_count * 100 + attempts = 0 + while len(points) < target_count and attempts < max_attempts: + p = rng.uniform(nw, se) + d = density_fun(p) + if rng.random() < d: + points.append(p.tolist()) + attempts += 1 + return points + + def mesh_bodies_raw(self, driver, mesher, bb_min, bb_max, mesh_ext, objects, a0, density, fixed, mobile, simply, periodic, cache, hints): + import scipy.spatial + dim = len(bb_min) + nw, se = np.array(bb_min), np.array(bb_max) + if not fixed and not mobile and not simply: + node_vol = (a0**dim) * 0.7 + def global_density(p): + if not objects: return 1.0 + for obj in objects: + if obj(p) >= -1e-6: + return 1.0 + return 0.0 + box_vol = np.prod(se - nw) + target_count = int(box_vol / node_vol) + target_count = max(min(target_count, 10000), dim + 1 + 5) + mobile = self._sample_points(dim, nw, se, global_density, target_count) + all_points = list(fixed) + list(mobile) + list(simply) + if not all_points and not objects: + return RawMesh(dim, [], [], []) + points_np = np.array(all_points) + if points_np.shape[0] <= dim: + return RawMesh(dim, all_points, [], []) + max_relaxation_steps = min(mesher.mdefault_controller_step_limit_max, 50) + for step in range(max_relaxation_steps): + try: + tri = scipy.spatial.Delaunay(points_np) + except: + break + forces = np.zeros_like(points_np) + indptr, indices = tri.vertex_neighbor_vertices + for i in range(len(points_np)): + if i < len(fixed): continue + pt = points_np[i] + target_a = a0 + for neighbor_idx in indices[indptr[i]:indptr[i+1]]: + neighbor_pt = points_np[neighbor_idx] + vec = pt - neighbor_pt + dist = np.linalg.norm(vec) + if dist < 1e-12: continue + reduced_dist = dist / target_a + f_mag = self._relaxation_force(reduced_dist) + forces[i] += (f_mag / dist) * vec + dt = mesher.mdefault_controller_time_step_scale * a0 + points_np += dt * forces + if objects: + bcs = [obj.bc_func for obj in objects] + for i in range(len(fixed), len(points_np)): + self._enforce_boundary_conditions(mesher, bcs, points_np[i]) + try: + tri = scipy.spatial.Delaunay(points_np) + except: + return RawMesh(dim, points_np.tolist(), [], []) + simplices = tri.simplices.tolist() + final_simplices = [] + final_regions = [] + if not objects: + final_simplices = simplices + final_regions = [1] * len(simplices) + else: + for sx in simplices: + sx_pts = points_np[sx] + cog = np.mean(sx_pts, axis=0) + best_region = 0 + for i, obj in enumerate(objects, 1): + if obj(cog) >= -1e-6: + best_region = i + break + if best_region > 0: + final_simplices.append(sx) + final_regions.append(best_region) + return RawMesh(dim, points_np.tolist(), final_simplices, final_regions) + + # Body operations + def body_union(self, objs): + def bc(pos): + return max(o(pos) for o in objs) + return Body(AffineTrafo(objs[0].trafo.dim), bc) + + def body_difference(self, mother, subs): + def bc(pos): + res = mother(pos) + for s in subs: + res = min(res, -s(pos)) + return res + return Body(AffineTrafo(mother.trafo.dim), bc) + + def body_intersection(self, objs): + def bc(pos): + return min(o(pos) for o in objs) + return Body(AffineTrafo(objs[0].trafo.dim), bc) + + def _body_transform(self, body, matrix, displacement): + trafo = AffineTrafo(body.trafo.dim, matrix, displacement) + new_trafo = trafo.combine(body.trafo) + return Body(new_trafo, body.bc_func) + + def body_shifted_sc(self, body, shift): + dim = body.trafo.dim + return self._body_transform(body, np.eye(dim), -np.array(shift)) + + def body_shifted_bc(self, body, shift): + return self.body_shifted_sc(body, shift) + + def body_scaled(self, body, scale): + dim = body.trafo.dim + s = np.array(scale) + if s.ndim == 0: s = np.full(dim, s) + return self._body_transform(body, np.diag(1.0/s), np.zeros(dim)) + + def body_rotated_sc(self, body, a1, a2, rad): + dim = body.trafo.dim + mat = np.eye(dim) + c, s = math.cos(rad), math.sin(rad) + mat[a1, a1] = c + mat[a1, a2] = s + mat[a2, a1] = -s + mat[a2, a2] = c + return self._body_transform(body, mat, np.zeros(dim)) + + def body_rotated_bc(self, body, a1, a2, rad): + return self.body_rotated_sc(body, a1, a2, rad) + + def body_rotated_axis_sc(self, body, axis, rad): + dim = body.trafo.dim + if dim != 3: return body + axis = np.array(axis) / np.linalg.norm(axis) + c, s = math.cos(rad), math.sin(rad) + t = 1 - c + x, y, z = axis + mat = np.array([ + [t*x*x + c, t*x*y - s*z, t*x*z + s*y], + [t*x*y + s*z, t*y*y + c, t*y*z - s*x], + [t*x*z - s*y, t*y*z + s*x, t*z*z + c] + ]) + return self._body_transform(body, mat.T, np.zeros(dim)) + + def body_rotated_axis_bc(self, body, axis, rad): + return self.body_rotated_axis_sc(body, axis, rad) + + # Primitives + def body_box(self, p1, p2): + nw, se = np.array(p1), np.array(p2) + mid = (nw + se) * 0.5 + inv_half_len = 2.0 / np.abs(nw - se) + def bc(pos): + rel_dist = np.abs((pos - mid) * inv_half_len) + return 1.0 - np.max(rel_dist) + return Body(AffineTrafo(len(p1)), bc) + + def body_ellipsoid(self, radii): + r = np.array(radii) + inv_r = 1.0 / r + def bc(pos): + return 1.0 - np.sum((pos * inv_r)**2) + return Body(AffineTrafo(len(radii)), bc) + + def body_frustum(self, c1, r1, c2, r2): + p1, p2 = np.array(c1), np.array(c2) + axis = p2 - p1 + axis_len_sq = np.sum(axis**2) + def bc(pos): + vec = pos - p1 + projection = np.dot(vec, axis) / axis_len_sq + if projection < 0 or projection > 1: return -1.0 + r_at_p = r1 + projection * (r2 - r1) + dist_sq = np.sum((vec - projection * axis)**2) + return r_at_p**2 - dist_sq + return Body(AffineTrafo(len(c1)), bc) + + def body_helix(self, c1, r1, c2, r2): + return self.body_frustum(c1, r1, c2, r2) + + def mesh_from_points_and_simplices(self, dim, points, simplices, regions, periodic, reorder, distribute): + return RawMesh(dim, points, simplices, regions, periodic) + + def copy_mesher_defaults(self, defaults): + return copy.deepcopy(defaults) + + @property + def mesher_defaults(self): + return self._mesher_defaults + +nmesh_backend = NMeshBackend() diff --git a/src/nmesh/base.py b/src/nmesh/base.py new file mode 100644 index 0000000..0ac04a4 --- /dev/null +++ b/src/nmesh/base.py @@ -0,0 +1,224 @@ +import logging +import os +from typing import List, Union, Optional +from pathlib import Path +from nmesh.backend import nmesh_backend as backend, RawMesh + +log = logging.getLogger(__name__) + +class MeshBase: + """Base class for all mesh objects, providing access to mesh data.""" + def __init__(self, raw_mesh): + self.raw_mesh = raw_mesh + self._cache = {} + + def scale_node_positions(self, scale: float): + """Scales all node positions in the mesh.""" + backend.mesh_scale_node_positions(self.raw_mesh, float(scale)) + self._cache = {} # Clear cache + + def save_hdf5(self, filename): + log.warning("save_hdf5: HDF5 support not yet implemented.") + pass + + def save(self, file_name, directory=None, format=None): + """Saves the mesh to a file.""" + from simulation.features import features + # In original, output_file_location comes from nsim.snippets + # Here we simplify it or use Path + path = str(file_name) + if directory: + path = os.path.join(directory, path) + + if format == 'hdf5' or path.lower().endswith('.h5'): + self.save_hdf5(path) + else: + backend.mesh_writefile(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 tolists(self): + """Alias for to_lists for backward compatibility.""" + return self.to_lists() + + def to_lists(self): + """Returns mesh data as Python lists.""" + return backend.mesh_plotinfo(self.raw_mesh) + + @property + def points(self): + if 'points' not in self._cache: + self._cache['points'] = backend.mesh_plotinfo_points(self.raw_mesh) + return self._cache['points'] + + @property + def pointsregions(self): + if 'pointsregions' not in self._cache: + self._cache['pointsregions'] = backend.mesh_plotinfo_pointsregions(self.raw_mesh) + return self._cache['pointsregions'] + + point_regions = pointsregions + + @property + def simplices(self): + if 'simplices' not in self._cache: + self._cache['simplices'] = backend.mesh_plotinfo_simplices(self.raw_mesh) + return self._cache['simplices'] + + @property + def simplicesregions(self): + if 'simplicesregions' not in self._cache: + self._cache['simplicesregions'] = backend.mesh_plotinfo_simplicesregions(self.raw_mesh) + return self._cache['simplicesregions'] + + regions = simplicesregions + + @property + def dim(self): + return backend.mesh_dim(self.raw_mesh) + + @property + def surfaces_and_surfacesregions(self): + if 'surfaces_all' not in self._cache: + self._cache['surfaces_all'] = backend.mesh_plotinfo_surfaces_and_surfacesregions(self.raw_mesh) + return self._cache['surfaces_all'] + + @property + def surfaces(self): + return self.surfaces_and_surfacesregions[0] + + @property + def surfacesregions(self): + return self.surfaces_and_surfacesregions[1] + + @property + def links(self): + """Returns all links (pairs of point indices).""" + if 'links' not in self._cache: + self._cache['links'] = backend.mesh_plotinfo_links(self.raw_mesh) + return self._cache['links'] + + @property + def regionvolumes(self): + """Returns volume of each region.""" + if 'regionvolumes' not in self._cache: + self._cache['regionvolumes'] = backend.mesh_plotinfo_regionvolumes(self.raw_mesh) + return self._cache['regionvolumes'] + + region_volumes = regionvolumes + + @property + def numregions(self): + """Returns the number of regions.""" + return len(self.region_volumes) + + num_regions = numregions + + @property + def periodicpointindices(self): + """Returns indices of periodic nodes.""" + if 'periodicpointindices' not in self._cache: + self._cache['periodicpointindices'] = backend.mesh_plotinfo_periodic_points_indices(self.raw_mesh) + return self._cache['periodicpointindices'] + + periodic_point_indices = periodicpointindices + + @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) + +class Mesh(MeshBase): + """Class for generating a mesh from geometric objects.""" + def __init__(self, bounding_box=None, objects=[], a0=1.0, density="", + periodic=[], fixed_points=[], mobile_points=[], simply_points=[], + callback=None, mesh_bounding_box=False, meshing_parameters=None, + cache_name="", hints=[], **kwargs): + + if bounding_box is None: + raise ValueError("Bounding box must be provided.") + + bb = [[float(x) for x in p] for p in bounding_box] + dim = len(bb[0]) + mesh_ext = 1 if mesh_bounding_box else 0 + + from nmesh.features import get_default_meshing_parameters + params = meshing_parameters or get_default_meshing_parameters() + for k, v in kwargs.items(): + params[k] = v + + obj_bodies = [] + self._fixed_points = [list(map(float, p)) for p in fixed_points] + self._mobile_points = [list(map(float, p)) for p in mobile_points] + self._simply_points = [list(map(float, p)) for p in simply_points] + + for obj in objects: + obj_bodies.append(obj.obj) + self._fixed_points.extend(obj.fixed_points) + self._mobile_points.extend(obj.mobile_points) + + periodic_floats = [1.0 if p else 0.0 for p in periodic] if periodic else [0.0] * dim + + cb_func, cb_interval = callback if callback else (lambda a,b,c: None, 1000000) + self.fun_driver = cb_func + driver = backend.make_mg_gendriver(cb_interval, cb_func) + mesher = backend.copy_mesher_defaults(backend.mesher_defaults) + params.pass_parameters_to_ocaml(mesher, dim) + + raw = backend.mesh_bodies_raw( + driver, mesher, bb[0], bb[1], mesh_ext, obj_bodies, float(a0), + density, self._fixed_points, self._mobile_points, self._simply_points, periodic_floats, + cache_name, hints + ) + + if raw is None: raise RuntimeError("Mesh generation failed.") + super().__init__(raw) + + def fixed_points(self, points: List[List[float]]): + """Adds fixed points to the mesh configuration.""" + if points: + self._fixed_points.extend(points) + + def mobile_points(self, points: List[List[float]]): + """Adds mobile points to the mesh configuration.""" + if points: + self._mobile_points.extend(points) + + def simply_points(self, points: List[List[float]]): + """Adds simply points to the mesh configuration.""" + if points: + self._simply_points.extend(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") + + raw = backend.mesh_readfile(str(path), reorder, distribute) + super().__init__(raw) + +class mesh_from_points_and_simplices(MeshBase): + """Wrapper for backward compatibility.""" + def __init__(self, points=[], simplices_indices=[], simplices_regions=[], + periodic_point_indices=[], initial=0, do_reorder=False, + do_distribute=True): + + if initial == 1: + simplices_indices = [[idx - 1 for idx in s] for s in simplices_indices] + + raw = backend.mesh_from_points_and_simplices( + len(points[0]) if points else 3, + [[float(x) for x in p] for p in points], + [[int(x) for x in s] for s in simplices_indices], + [int(r) for r in simplices_regions], + periodic_point_indices, do_reorder, do_distribute + ) + super().__init__(raw) diff --git a/src/nmesh/features.py b/src/nmesh/features.py new file mode 100644 index 0000000..a82810e --- /dev/null +++ b/src/nmesh/features.py @@ -0,0 +1,76 @@ +import logging +from nmesh.backend import nmesh_backend as backend +from simulation.features import Features + +log = logging.getLogger(__name__) + +class MeshingParameters(Features): + """Parameters for the meshing algorithm, supporting multiple dimensions.""" + def __init__(self, string=None, file=None): + super().__init__() + self.dim = None + if file: self.from_file(file) + if string: self.from_string(string) + self.add_section('user-modifications') + + def _get_section_name(self): + 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 __getitem__(self, name): + val = self.get('user-modifications', name) + if val is not None: + return val + section = self._get_section_name() + return self.get(section, name) + + def __setitem__(self, key, value): + self.set('user-modifications', key, value) + + def set_shape_force_scale(self, v): self["shape_force_scale"] = float(v) + def set_volume_force_scale(self, v): self["volume_force_scale"] = float(v) + def set_neigh_force_scale(self, v): self["neigh_force_scale"] = float(v) + def set_irrel_elem_force_scale(self, v): self["irrel_elem_force_scale"] = float(v) + def set_time_step_scale(self, v): self["time_step_scale"] = float(v) + def set_thresh_add(self, v): self["thresh_add"] = float(v) + def set_thresh_del(self, v): self["thresh_del"] = float(v) + def set_topology_threshold(self, v): self["topology_threshold"] = float(v) + def set_tolerated_rel_move(self, v): self["tolerated_rel_move"] = float(v) + def set_max_steps(self, v): self["max_steps"] = int(v) + def set_initial_settling_steps(self, v): self["initial_settling_steps"] = int(v) + def set_sliver_correction(self, v): self["sliver_correction"] = float(v) + def set_smallest_volume_ratio(self, v): self["smallest_volume_ratio"] = float(v) + def set_max_relaxation(self, v): self["max_relaxation"] = float(v) + + def pass_parameters_to_ocaml(self, mesher, dim): + self.dim = dim + for key, value in self.items('user-modifications'): + section = self._get_section_name() + self.set(section, key, str(value)) + + params = [ + ("shape_force_scale", backend.mesher_defaults_set_shape_force_scale), + ("volume_force_scale", backend.mesher_defaults_set_volume_force_scale), + ("neigh_force_scale", backend.mesher_defaults_set_neigh_force_scale), + ("irrel_elem_force_scale", backend.mesher_defaults_set_irrel_elem_force_scale), + ("time_step_scale", backend.mesher_defaults_set_time_step_scale), + ("thresh_add", backend.mesher_defaults_set_thresh_add), + ("thresh_del", backend.mesher_defaults_set_thresh_del), + ("topology_threshold", backend.mesher_defaults_set_topology_threshold), + ("tolerated_rel_move", backend.mesher_defaults_set_tolerated_rel_movement), + ("max_steps", backend.mesher_defaults_set_max_relaxation_steps), + ("initial_settling_steps", backend.mesher_defaults_set_initial_settling_steps), + ("sliver_correction", backend.mesher_defaults_set_sliver_correction), + ("smallest_volume_ratio", backend.mesher_defaults_set_smallest_allowed_volume_ratio), + ("max_relaxation", backend.mesher_defaults_set_movement_max_freedom), + ] + + for key, setter in params: + val = self[key] + if val is not None: + setter(mesher, float(val) if "steps" not in key else int(val)) + +def get_default_meshing_parameters(): + """Returns default meshing parameters.""" + return MeshingParameters() diff --git a/src/nmesh/geometry.py b/src/nmesh/geometry.py new file mode 100644 index 0000000..ceb70c6 --- /dev/null +++ b/src/nmesh/geometry.py @@ -0,0 +1,101 @@ +import math +from typing import List, Any +from nmesh.backend import nmesh_backend as backend + +class MeshObject: + """Base class for geometric primitives and CSG operations.""" + def __init__(self, dim, fixed=None, mobile=None): + self.dim = dim + self.fixed_points = fixed if fixed is not None else [] + self.mobile_points = mobile if mobile is not None else [] + self.obj: Any = None + + def shift(self, vector, system_coords=True): + self.obj = (backend.body_shifted_sc if system_coords else backend.body_shifted_bc)(self.obj, vector) + return self + + def scale(self, factors): + self.obj = backend.body_scaled(self.obj, factors) + return self + + def rotate(self, a1, a2, angle, system_coords=True): + rad = math.radians(angle) + self.obj = (backend.body_rotated_sc if system_coords else backend.body_rotated_bc)(self.obj, a1, a2, rad) + return self + + def rotate_3d(self, axis, angle, system_coords=True): + rad = math.radians(angle) + self.obj = (backend.body_rotated_axis_sc if system_coords else backend.body_rotated_axis_bc)(self.obj, axis, rad) + return self + + def transform(self, transformations, system_coords=True): + """Applies a list of transformation tuples.""" + for t in transformations: + name, *args = t + if name == "shift": self.shift(args[0], system_coords) + elif name == "scale": self.scale(args[0]) + elif name == "rotate": self.rotate(args[0][0], args[0][1], args[1], system_coords) + elif name == "rotate2d": self.rotate(0, 1, args[0], system_coords) + elif name == "rotate3d": self.rotate_3d(args[0], args[1], system_coords) + return self + +class Box(MeshObject): + def __init__(self, p1, p2, transform=None, fixed=None, mobile=None, system_coords=True, use_fixed_corners=False): + import itertools + dim = len(p1) + fixed_pts = fixed if fixed is not None else [] + if use_fixed_corners: + fixed_pts.extend([list(c) for c in itertools.product(*zip(p1, p2))]) + super().__init__(dim, fixed_pts, mobile) + self.obj = backend.body_box([float(x) for x in p1], [float(x) for x in p2]) + if transform: + self.transform(transform, system_coords) + +class Ellipsoid(MeshObject): + def __init__(self, lengths, transform=None, fixed=None, mobile=None, system_coords=True): + super().__init__(len(lengths), fixed, mobile) + self.obj = backend.body_ellipsoid([float(x) for x in lengths]) + if transform: + self.transform(transform, system_coords) + +class Conic(MeshObject): + def __init__(self, c1, r1, c2, r2, transform=None, fixed=None, mobile=None, system_coords=True): + super().__init__(len(c1), fixed, mobile) + self.obj = backend.body_frustum(c1, r1, c2, r2) + if transform: + self.transform(transform, system_coords) + +class Helix(MeshObject): + def __init__(self, c1, r1, c2, r2, transform=None, fixed=None, mobile=None, system_coords=True): + super().__init__(len(c1), fixed, mobile) + self.obj = backend.body_helix(c1, r1, c2, r2) + if transform: + self.transform(transform, system_coords) + +# --- CSG --- + +def union(objects: List[MeshObject]) -> MeshObject: + if len(objects) < 2: raise ValueError("Union requires at least two objects") + res = MeshObject(objects[0].dim) + for o in objects: + res.fixed_points.extend(o.fixed_points) + res.mobile_points.extend(o.mobile_points) + res.obj = backend.body_union([o.obj for o in objects]) + return res + +def difference(mother: MeshObject, subtract: List[MeshObject]) -> MeshObject: + res = MeshObject(mother.dim, mother.fixed_points[:], mother.mobile_points[:]) + for o in subtract: + res.fixed_points.extend(o.fixed_points) + res.mobile_points.extend(o.mobile_points) + res.obj = backend.body_difference(mother.obj, [o.obj for o in subtract]) + return res + +def intersect(objects: List[MeshObject]) -> MeshObject: + if len(objects) < 2: raise ValueError("Intersection requires at least two objects") + res = MeshObject(objects[0].dim) + for o in objects: + res.fixed_points.extend(o.fixed_points) + res.mobile_points.extend(o.mobile_points) + res.obj = backend.body_intersection([o.obj for o in objects]) + return res diff --git a/src/nmesh/nmesh.py b/src/nmesh/nmesh.py deleted file mode 100644 index 02c9b83..0000000 --- a/src/nmesh/nmesh.py +++ /dev/null @@ -1,599 +0,0 @@ -import math -import logging -from typing import List, Tuple, Optional, Any, Union -from pathlib import Path -import itertools - -# Setup logging -log = logging.getLogger(__name__) - -# --- Stubs for External Dependencies --- - -class OCamlStub: - """Stub for the OCaml backend interface.""" - def time_vmem_rss(self): - return 0.0, 0, 0 - - # Mesher defaults setters - def mesher_defaults_set_shape_force_scale(self, mesher, scale): pass - def mesher_defaults_set_volume_force_scale(self, mesher, scale): pass - def mesher_defaults_set_neigh_force_scale(self, mesher, scale): pass - def mesher_defaults_set_irrel_elem_force_scale(self, mesher, scale): pass - def mesher_defaults_set_time_step_scale(self, mesher, scale): pass - def mesher_defaults_set_thresh_add(self, mesher, thresh): pass - def mesher_defaults_set_thresh_del(self, mesher, thresh): pass - def mesher_defaults_set_topology_threshold(self, mesher, thresh): pass - def mesher_defaults_set_tolerated_rel_movement(self, mesher, scale): pass - def mesher_defaults_set_max_relaxation_steps(self, mesher, steps): pass - def mesher_defaults_set_initial_settling_steps(self, mesher, steps): pass - def mesher_defaults_set_sliver_correction(self, mesher, scale): pass - def mesher_defaults_set_smallest_allowed_volume_ratio(self, mesher, scale): pass - def mesher_defaults_set_movement_max_freedom(self, mesher, scale): pass - - # Mesh operations - def mesh_scale_node_positions(self, raw_mesh, scale): pass - def mesh_writefile(self, path, raw_mesh): pass - def mesh_nr_simplices(self, raw_mesh): return 0 - def mesh_nr_points(self, raw_mesh): return 0 - def mesh_plotinfo(self, raw_mesh): return [[], [], [[], [], []]] - def mesh_plotinfo_points(self, raw_mesh): return [] - def mesh_plotinfo_pointsregions(self, raw_mesh): return [] - def mesh_plotinfo_simplices(self, raw_mesh): return [] - def mesh_plotinfo_simplicesregions(self, raw_mesh): return [] - def mesh_plotinfo_surfaces_and_surfacesregions(self, raw_mesh): return [[], []] - def mesh_plotinfo_links(self, raw_mesh): return [] - def mesh_dim(self, raw_mesh): return 3 - def mesh_plotinfo_regionvolumes(self, raw_mesh): return [] - def mesh_plotinfo_periodic_points_indices(self, raw_mesh): return [] - def mesh_set_vertex_distribution(self, raw_mesh, dist): pass - def mesh_get_permutation(self, raw_mesh): return [] - def mesh_readfile(self, filename, do_reorder, do_distribute): return "STUB_MESH" - - # Driver and Mesh creation - def make_mg_gendriver(self, interval, callback): return "STUB_DRIVER" - def copy_mesher_defaults(self, defaults): return "STUB_MESHER" - def mesh_bodies_raw(self, driver, mesher, bb_min, bb_max, mesh_ext, objects, a0, density, fixed, mobile, simply, periodic, cache, hints): return "STUB_MESH" - def mesh_from_points_and_simplices(self, dim, points, simplices, regions, periodic, reorder, distribute): return "STUB_MESH" - - # Body operations - def body_union(self, objs): return "STUB_OBJ_UNION" - def body_difference(self, obj1, objs): return "STUB_OBJ_DIFF" - def body_intersection(self, objs): return "STUB_OBJ_INTERSECT" - def body_shifted_sc(self, obj, shift): return obj - def body_shifted_bc(self, obj, shift): return obj - def body_scaled(self, obj, scale): return obj - def body_rotated_sc(self, obj, a1, a2, ang): return obj - def body_rotated_bc(self, obj, a1, a2, ang): return obj - def body_rotated_axis_sc(self, obj, axis, ang): return obj - def body_rotated_axis_bc(self, obj, axis, ang): return obj - - # Primitives - def body_box(self, p1, p2): return "STUB_BOX" - def body_ellipsoid(self, length): return "STUB_ELLIPSOID" - def body_frustum(self, c1, r1, c2, r2): return "STUB_FRUSTUM" - def body_helix(self, c1, r1, c2, r2): return "STUB_HELIX" - - @property - def mesher_defaults(self): return "STUB_DEFAULTS" - -ocaml = OCamlStub() - -def memory_report(tag: str): - """Reports memory usage via OCaml backend.""" - t, vmem, rss = ocaml.time_vmem_rss() - log.log(15, f"Memory report: T= {t:f} VMEM= {int(vmem)} KB RSS= {int(rss)} KB {tag}") - -# --- Configuration --- - -class FeaturesStub: - """Stub for nsim.features.Features.""" - def __init__(self, local=True): - self._data = {} - - def from_file(self, file_path): pass - def from_string(self, string): pass - def add_section(self, section): - if section not in self._data: - self._data[section] = {} - - def get(self, section, name, raw=False): - return self._data.get(section, {}).get(name) - - def set(self, section, name, value): - if section not in self._data: - self._data[section] = {} - self._data[section][name] = value - - def items(self, section): - return self._data.get(section, {}).items() - - def to_string(self): - return str(self._data) - -class MeshingParameters(FeaturesStub): - """Parameters for the meshing algorithm, supporting multiple dimensions.""" - def __init__(self, string=None, file=None): - super().__init__(local=True) - self.dim = None - if file: self.from_file(file) - if string: self.from_string(string) - self.add_section('user-modifications') - - def _get_section_name(self): - 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 __getitem__(self, name): - val = self.get('user-modifications', name) - if val is not None: - return val - section = self._get_section_name() - return self.get(section, name) - - def __setitem__(self, key, value): - self.set('user-modifications', key, value) - - def set_shape_force_scale(self, v): self["shape_force_scale"] = float(v) - def set_volume_force_scale(self, v): self["volume_force_scale"] = float(v) - def set_neigh_force_scale(self, v): self["neigh_force_scale"] = float(v) - def set_irrel_elem_force_scale(self, v): self["irrel_elem_force_scale"] = float(v) - def set_time_step_scale(self, v): self["time_step_scale"] = float(v) - def set_thresh_add(self, v): self["thresh_add"] = float(v) - def set_thresh_del(self, v): self["thresh_del"] = float(v) - def set_topology_threshold(self, v): self["topology_threshold"] = float(v) - def set_tolerated_rel_move(self, v): self["tolerated_rel_move"] = float(v) - def set_max_steps(self, v): self["max_steps"] = int(v) - def set_initial_settling_steps(self, v): self["initial_settling_steps"] = int(v) - def set_sliver_correction(self, v): self["sliver_correction"] = float(v) - def set_smallest_volume_ratio(self, v): self["smallest_volume_ratio"] = float(v) - def set_max_relaxation(self, v): self["max_relaxation"] = float(v) - - def pass_parameters_to_ocaml(self, mesher, dim): - self.dim = dim - for key, value in self.items('user-modifications'): - section = self._get_section_name() - self.set(section, key, str(value)) - - params = [ - ("shape_force_scale", ocaml.mesher_defaults_set_shape_force_scale), - ("volume_force_scale", ocaml.mesher_defaults_set_volume_force_scale), - ("neigh_force_scale", ocaml.mesher_defaults_set_neigh_force_scale), - ("irrel_elem_force_scale", ocaml.mesher_defaults_set_irrel_elem_force_scale), - ("time_step_scale", ocaml.mesher_defaults_set_time_step_scale), - ("thresh_add", ocaml.mesher_defaults_set_thresh_add), - ("thresh_del", ocaml.mesher_defaults_set_thresh_del), - ("topology_threshold", ocaml.mesher_defaults_set_topology_threshold), - ("tolerated_rel_move", ocaml.mesher_defaults_set_tolerated_rel_movement), - ("max_steps", ocaml.mesher_defaults_set_max_relaxation_steps), - ("initial_settling_steps", ocaml.mesher_defaults_set_initial_settling_steps), - ("sliver_correction", ocaml.mesher_defaults_set_sliver_correction), - ("smallest_volume_ratio", ocaml.mesher_defaults_set_smallest_allowed_volume_ratio), - ("max_relaxation", ocaml.mesher_defaults_set_movement_max_freedom), - ] - - for key, setter in params: - val = self[key] - if val is not None: - setter(mesher, float(val) if "steps" not in key else int(val)) - -def get_default_meshing_parameters(): - """Returns default meshing parameters.""" - return MeshingParameters() - -# --- Loading Utilities --- - -def _is_nmesh_ascii_file(filename): - try: - with open(filename, 'r') as f: - return f.readline().startswith("# PYFEM") - except: return False - -def _is_nmesh_hdf5_file(filename): - # This would normally use tables.isPyTablesFile - return str(filename).lower().endswith('.h5') - -def hdf5_mesh_get_permutation(filename): - """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): - self.raw_mesh = raw_mesh - self._cache = {} - - def scale_node_positions(self, scale: float): - """Scales all node positions in the mesh.""" - ocaml.mesh_scale_node_positions(self.raw_mesh, float(scale)) - self._cache.pop('points', None) - self._cache.pop('region_volumes', None) - - def save(self, filename: Union[str, Path]): - """Saves the mesh to a file (ASCII or HDF5).""" - path = str(filename) - if path.lower().endswith('.h5'): - log.info(f"Saving to HDF5 (stub): {path}") - else: - ocaml.mesh_writefile(path, self.raw_mesh) - - def __str__(self): - pts = ocaml.mesh_nr_points(self.raw_mesh) - simps = ocaml.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 ocaml.mesh_plotinfo(self.raw_mesh) - - @property - def points(self): - if 'points' not in self._cache: - self._cache['points'] = ocaml.mesh_plotinfo_points(self.raw_mesh) - return self._cache['points'] - - @property - def simplices(self): - if 'simplices' not in self._cache: - self._cache['simplices'] = ocaml.mesh_plotinfo_simplices(self.raw_mesh) - return self._cache['simplices'] - - @property - def regions(self): - if 'regions' not in self._cache: - self._cache['regions'] = ocaml.mesh_plotinfo_simplicesregions(self.raw_mesh) - return self._cache['regions'] - - @property - def dim(self): - return ocaml.mesh_dim(self.raw_mesh) - - @property - def surfaces(self): - return ocaml.mesh_plotinfo_surfaces_and_surfacesregions(self.raw_mesh)[0] - - @property - def point_regions(self): - """Returns regions for each point.""" - if 'point_regions' not in self._cache: - self._cache['point_regions'] = ocaml.mesh_plotinfo_pointsregions(self.raw_mesh) - return self._cache['point_regions'] - - @property - def links(self): - """Returns all links (pairs of point indices).""" - if 'links' not in self._cache: - self._cache['links'] = ocaml.mesh_plotinfo_links(self.raw_mesh) - return self._cache['links'] - - @property - def region_volumes(self): - """Returns volume of each region.""" - if 'region_volumes' not in self._cache: - self._cache['region_volumes'] = ocaml.mesh_plotinfo_regionvolumes(self.raw_mesh) - return self._cache['region_volumes'] - - @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.""" - if 'periodic_indices' not in self._cache: - self._cache['periodic_indices'] = ocaml.mesh_plotinfo_periodic_points_indices(self.raw_mesh) - return self._cache['periodic_indices'] - - @property - def permutation(self): - """Returns the node permutation mapping.""" - return ocaml.mesh_get_permutation(self.raw_mesh) - - def set_vertex_distribution(self, dist): - """Sets vertex distribution.""" - ocaml.mesh_set_vertex_distribution(self.raw_mesh, dist) - -class Mesh(MeshBase): - """Class for generating a mesh from geometric objects.""" - def __init__(self, bounding_box, objects=[], a0=1.0, density="", - periodic=[], fixed_points=[], mobile_points=[], simply_points=[], - callback=None, mesh_bounding_box=False, meshing_parameters=None, - cache_name="", hints=[], **kwargs): - - if bounding_box is None: - raise ValueError("Bounding box must be provided.") - - bb = [[float(x) for x in p] for p in bounding_box] - dim = len(bb[0]) - mesh_ext = 1 if mesh_bounding_box else 0 - - if not objects and not mesh_bounding_box: - raise ValueError("No objects to mesh and bounding box meshing disabled.") - - params = meshing_parameters or get_default_meshing_parameters() - for k, v in kwargs.items(): - params[k] = v - - obj_bodies = [] - # Store points lists to allow appending later (mimicking original API) - self._fixed_points = [list(map(float, p)) for p in fixed_points] - self._mobile_points = [list(map(float, p)) for p in mobile_points] - self._simply_points = [list(map(float, p)) for p in simply_points] - - for obj in objects: - obj_bodies.append(obj.obj) - self._fixed_points.extend(obj.fixed_points) - self._mobile_points.extend(obj.mobile_points) - - periodic_floats = [1.0 if p else 0.0 for p in periodic] if periodic else [0.0] * dim - - cb_func, cb_interval = callback if callback else (lambda a,b,c: None, 1000000) - self.fun_driver = cb_func - driver = ocaml.make_mg_gendriver(cb_interval, cb_func) - mesher = ocaml.copy_mesher_defaults(ocaml.mesher_defaults) - params.pass_parameters_to_ocaml(mesher, dim) - - # Note: In the original code, mesh generation happens in __init__. - # Adding points via methods afterwards wouldn't affect the already generated mesh - # unless we regenerate or if those methods were meant for pre-generation setup. - # However, checking lib1.py, __init__ calls mesh_bodies_raw immediately. - # The methods fixed_points/mobile_points in lib1.py just append to self.fixed_points - # which seems useless after __init__ unless the user manually triggers something else. - # But we will preserve them for API compatibility. - - raw = ocaml.mesh_bodies_raw( - driver, mesher, bb[0], bb[1], mesh_ext, obj_bodies, float(a0), - density, self._fixed_points, self._mobile_points, self._simply_points, periodic_floats, - cache_name, 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: List[List[float]]): - """Adds fixed points to the mesh configuration.""" - if points: - self._fixed_points.extend(points) - - def mobile_points(self, points: List[List[float]]): - """Adds mobile points to the mesh configuration.""" - if points: - self._mobile_points.extend(points) - - def simply_points(self, points: List[List[float]]): - """Adds simply points to the mesh configuration.""" - if points: - self._simply_points.extend(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") - - # Determine format - if _is_nmesh_ascii_file(filename): - raw = ocaml.mesh_readfile(str(path), reorder, distribute) - elif _is_nmesh_hdf5_file(filename): - # load_hdf5 logic would go here - raw = ocaml.mesh_readfile(str(path), reorder, distribute) - else: - raise ValueError(f"Unknown mesh file format: {filename}") - - super().__init__(raw) - -class mesh_from_points_and_simplices(MeshBase): - """Wrapper for backward compatibility.""" - def __init__(self, points=[], simplices_indices=[], simplices_regions=[], - periodic_point_indices=[], initial=0, do_reorder=False, - do_distribute=True): - - # Adjust for 1-based indexing if initial=1 - if initial == 1: - simplices_indices = [[idx - 1 for idx in s] for s in simplices_indices] - - raw = ocaml.mesh_from_points_and_simplices( - len(points[0]) if points else 3, - [[float(x) for x in p] for p in points], - [[int(x) for x in s] for s in simplices_indices], - [int(r) for r in simplices_regions], - periodic_point_indices, 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: Union[str, Path]): - """Alias for mesh.save for backward compatibility.""" - mesh.save(filename) - -# --- Exception Aliases --- -NmeshUserError = ValueError -NmeshIOError = IOError -NmeshStandardError = RuntimeError - -# --- Geometry --- - -class MeshObject: - """Base class for geometric primitives and CSG operations.""" - def __init__(self, dim, fixed=[], mobile=[]): - self.dim = dim - self.fixed_points = fixed - self.mobile_points = mobile - self.obj: Any = None - - def shift(self, vector, system_coords=True): - self.obj = (ocaml.body_shifted_sc if system_coords else ocaml.body_shifted_bc)(self.obj, vector) - - def scale(self, factors): - self.obj = ocaml.body_scaled(self.obj, factors) - - def rotate(self, a1, a2, angle, system_coords=True): - rad = math.radians(angle) - self.obj = (ocaml.body_rotated_sc if system_coords else ocaml.body_rotated_bc)(self.obj, a1, a2, rad) - - def rotate_3d(self, axis, angle, system_coords=True): - rad = math.radians(angle) - self.obj = (ocaml.body_rotated_axis_sc if system_coords else ocaml.body_rotated_axis_bc)(self.obj, axis, rad) - - def transform(self, transformations, system_coords=True): - """Applies a list of transformation tuples.""" - for t in transformations: - name, *args = t - if name == "shift": self.shift(args[0], system_coords) - elif name == "scale": self.scale(args[0]) - elif name == "rotate": self.rotate(args[0][0], args[0][1], args[1], system_coords) - elif name == "rotate2d": self.rotate(0, 1, args[0], system_coords) - elif name == "rotate3d": self.rotate_3d(args[0], args[1], system_coords) - -class Box(MeshObject): - def __init__(self, p1, p2, transform=[], fixed=[], mobile=[], system_coords=True, use_fixed_corners=False): - dim = len(p1) - if use_fixed_corners: - fixed.extend([list(c) for c in itertools.product(*zip(p1, p2))]) - super().__init__(dim, fixed, mobile) - self.obj = ocaml.body_box([float(x) for x in p1], [float(x) for x in p2]) - self.transform(transform, system_coords) - -class Ellipsoid(MeshObject): - def __init__(self, lengths, transform=[], fixed=[], mobile=[], system_coords=True): - super().__init__(len(lengths), fixed, mobile) - self.obj = ocaml.body_ellipsoid([float(x) for x in lengths]) - self.transform(transform, system_coords) - -class Conic(MeshObject): - def __init__(self, c1, r1, c2, r2, transform=[], fixed=[], mobile=[], system_coords=True): - super().__init__(len(c1), fixed, mobile) - self.obj = ocaml.body_frustum(c1, r1, c2, r2) - self.transform(transform, system_coords) - -class Helix(MeshObject): - def __init__(self, c1, r1, c2, r2, transform=[], fixed=[], mobile=[], system_coords=True): - super().__init__(len(c1), fixed, mobile) - self.obj = ocaml.body_helix(c1, r1, c2, r2) - self.transform(transform, system_coords) - -# --- CSG --- - -def union(objects: List[MeshObject]) -> MeshObject: - if len(objects) < 2: raise ValueError("Union requires at least two objects") - res = MeshObject(objects[0].dim) - for o in objects: - res.fixed_points.extend(o.fixed_points) - res.mobile_points.extend(o.mobile_points) - res.obj = ocaml.body_union([o.obj for o in objects]) - return res - -def difference(mother: MeshObject, subtract: List[MeshObject]) -> MeshObject: - res = MeshObject(mother.dim, mother.fixed_points[:], mother.mobile_points[:]) - for o in subtract: - res.fixed_points.extend(o.fixed_points) - res.mobile_points.extend(o.mobile_points) - res.obj = ocaml.body_difference(mother.obj, [o.obj for o in subtract]) - return res - -def intersect(objects: List[MeshObject]) -> MeshObject: - if len(objects) < 2: raise ValueError("Intersection requires at least two objects") - res = MeshObject(objects[0].dim) - for o in objects: - res.fixed_points.extend(o.fixed_points) - res.mobile_points.extend(o.mobile_points) - res.obj = ocaml.body_intersection([o.obj for o in objects]) - return res - -# --- 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: List[Tuple[float, float]], discretization: float) -> Tuple: - """Generates 1D mesh components (points, simplices, regions).""" - points, simplices, regions_ids = [], [], [] - point_map = {} - - def get_idx(v): - vk = round(v, 8) - if vk not in point_map: - point_map[vk] = len(points) - points.append([float(v)]) - 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 - - # Note: original unidmesher also returned surfaces, but simplified here - # Standard format for mesh_from_points_and_simplices: - # simplices are list of point indices, regions are separate list - return points, simplices, regions_ids - -def generate_1d_mesh(regions: List[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, out=None, check=True, float_fmt=" %f"): - """ - Writes mesh data (points, simplices, surfaces) to a file in nmesh format. - mesh_data: (points, simplices, surfaces) - """ - 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" - - if out is None: - print(content) - elif isinstance(out, (str, Path)): - Path(out).write_text(content) - else: - out.write(content) diff --git a/src/nmesh/utils.py b/src/nmesh/utils.py new file mode 100644 index 0000000..8ab7636 --- /dev/null +++ b/src/nmesh/utils.py @@ -0,0 +1,123 @@ +import logging +from typing import List, Tuple +from pathlib import Path +from nmesh.backend import nmesh_backend as backend, RawMesh + +log = logging.getLogger(__name__) + +def _is_nmesh_ascii_file(filename): + try: + with open(filename, 'r') as f: + return f.readline().startswith("# PYFEM") + except: return False + +def outer_corners(mesh): + """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: List[Tuple[float, float]], discretization: float, + tolerance=lambda x: x) -> Tuple: + """Generates 1D mesh components (points, simplices, surfaces).""" + points = [] + simplices = [] + surfaces = [] + pnt_hash = {} + srf_hash = {} + + def add_point(y): + i = len(points) + x = tolerance(y) + if x in pnt_hash: + return pnt_hash[x] + pnt_hash[x] = i + points.append([float(x)]) + return i + + def add_surface(y, idx, body): + if y in srf_hash: + i, _, _ = srf_hash[y] + srf_hash[y] = (i + 1, body, idx) + else: + srf_hash[y] = (0, body, idx) + + nbody = 0 + for (left_x, right_x) in regions: + nbody += 1 + if left_x > right_x: left_x, right_x = right_x, left_x + width = right_x - left_x + num_pts_per_reg = max(1, abs(int(width / discretization))) + step = width / num_pts_per_reg + + last_idx = add_point(left_x) + add_surface(left_x, last_idx, nbody) + for i in range(1, num_pts_per_reg + 1): + idx = add_point(left_x + i * step) + simplices.append((nbody, [last_idx, idx])) + last_idx = idx + + add_surface(right_x, last_idx, nbody) + + for s in srf_hash: + count, body, idx = srf_hash[s] + if count == 0: + surfaces.append((body, [idx])) + + return (points, simplices, surfaces) + +def generate_1d_mesh(regions: List[Tuple[float, float]], discretization: float): + """Generates a 1D mesh.""" + from nmesh.base import mesh_from_points_and_simplices + points, simplices, surfaces = generate_1d_mesh_components(regions, discretization) + + simplices_indices = [indices for _, indices in simplices] + simplices_regions = [region for region, _ in simplices] + + return mesh_from_points_and_simplices( + points=points, + simplices_indices=simplices_indices, + simplices_regions=simplices_regions, + do_distribute=False + ) + +def write_mesh(mesh_data, out=None, check=True, float_fmt=" %f"): + """ + Writes mesh data (points, simplices, surfaces) to a file in nmesh format. + mesh_data: (points, simplices, surfaces) + """ + 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" + + if out is None: + import sys + sys.stdout.write(content) + elif isinstance(out, (str, Path)): + Path(out).write_text(content) + else: + out.write(content) + +def memory_report(tag: str): + """Reports memory usage.""" + t, vmem, rss = backend.time_vmem_rss() + log.log(15, f"Memory report: T= {t:f} VMEM= {int(vmem)} KB RSS= {int(rss)} KB {tag}") diff --git a/src/simulation/features.py b/src/simulation/features.py new file mode 100644 index 0000000..0b9d442 --- /dev/null +++ b/src/simulation/features.py @@ -0,0 +1,71 @@ +from typing import Any, Dict, Tuple + +class Features: + """ + A replacement for nsim.setup.get_features(). + Provides configuration management for the simulation and meshing systems. + """ + def __init__(self): + self._config: Dict[Tuple[str, str], Any] = { + ('etc', 'runid'): 'nmag_simulation', + ('etc', 'savedir'): '.', + ('nmag', 'clean'): False, + ('nmag', 'restart'): False, + # Meshing defaults + ('nmesh-2D', 'shape_force_scale'): 0.1, + ('nmesh-2D', 'volume_force_scale'): 0.0, + ('nmesh-2D', 'neigh_force_scale'): 1.0, + ('nmesh-2D', 'irrel_elem_force_scale'): 1.0, + ('nmesh-2D', 'time_step_scale'): 0.1, + ('nmesh-2D', 'thresh_add'): 1.0, + ('nmesh-2D', 'thresh_del'): 2.0, + ('nmesh-2D', 'topology_threshold'): 0.2, + ('nmesh-2D', 'tolerated_rel_move'): 0.002, + ('nmesh-2D', 'max_steps'): 1000, + ('nmesh-2D', 'initial_settling_steps'): 100, + ('nmesh-2D', 'sliver_correction'): 1.0, + ('nmesh-2D', 'smallest_volume_ratio'): 1.0, + ('nmesh-2D', 'max_relaxation'): 3.0, + ('nmesh-3D', 'shape_force_scale'): 0.1, + ('nmesh-3D', 'volume_force_scale'): 0.0, + ('nmesh-3D', 'neigh_force_scale'): 1.0, + ('nmesh-3D', 'irrel_elem_force_scale'): 1.0, + ('nmesh-3D', 'time_step_scale'): 0.1, + ('nmesh-3D', 'thresh_add'): 1.0, + ('nmesh-3D', 'thresh_del'): 2.0, + ('nmesh-3D', 'topology_threshold'): 0.2, + ('nmesh-3D', 'tolerated_rel_move'): 0.002, + ('nmesh-3D', 'max_steps'): 1000, + ('nmesh-3D', 'initial_settling_steps'): 100, + ('nmesh-3D', 'sliver_correction'): 1.0, + ('nmesh-3D', 'smallest_volume_ratio'): 1.0, + ('nmesh-3D', 'max_relaxation'): 3.0, + } + self._user_mods: Dict[str, Any] = {} + + def get(self, section: str, key: str, raw: bool = False) -> Any: + if section == 'user-modifications': + return self._user_mods.get(key) + return self._config.get((section, key)) + + def set(self, section: str, key: str, value: Any): + if section == 'user-modifications': + self._user_mods[key] = value + else: + self._config[(section, key)] = value + + def items(self, section: str): + if section == 'user-modifications': + return self._user_mods.items() + return [(k[1], v) for k, v in self._config.items() if k[0] == section] + + def add_section(self, section: str): + pass + + def from_file(self, file_path: str): + # Placeholder for loading from a config file + pass + + def from_string(self, string: str): + # Placeholder for loading from a string + pass diff --git a/src/simulation/mock_features.py b/src/simulation/mock_features.py deleted file mode 100644 index af997d4..0000000 --- a/src/simulation/mock_features.py +++ /dev/null @@ -1,19 +0,0 @@ -from typing import Any - -class MockFeatures: - """ - A simple stub to replace nsim.setup.get_features(). - Currently this is used in the simulation_core class. - Provides default configuration values for the simulation. - There are no tests for this class as it is only a stub and will removed in future. - """ - def __init__(self): - self._config = { - ('etc', 'runid'): 'nmag_simulation', # Default output filename - ('etc', 'savedir'): '.', # Default output directory - ('nmag', 'clean'): False, # Don't delete old files automatically - ('nmag', 'restart'): False, # Don't try to restart from h5 - } - - def get(self, section: str, key: str, raw: bool = False) -> Any: - return self._config.get((section, key)) diff --git a/src/simulation/simulation_core.py b/src/simulation/simulation_core.py index 46dac5b..109e34f 100644 --- a/src/simulation/simulation_core.py +++ b/src/simulation/simulation_core.py @@ -26,12 +26,12 @@ known_field_quantities ) import hysteresis as hysteresis_m -from mock_features import MockFeatures +from features import Features 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() +features = Features() log = logging.getLogger('nmag') diff --git a/tests/nmesh_test.py b/tests/nmesh_test.py index ec44546..432dace 100644 --- a/tests/nmesh_test.py +++ b/tests/nmesh_test.py @@ -1,7 +1,8 @@ import unittest -import math -from pathlib import Path +import os +import numpy as np import nmesh +from nmesh.backend import nmesh_backend as backend class TestNMesh(unittest.TestCase): def test_meshing_parameters(self): @@ -46,18 +47,24 @@ def test_csg_operations(self): d = nmesh.difference(b1, [b2]) self.assertEqual(d.dim, 3) - def test_mesh_generation_stub(self): - """Test Mesh class initialization with stubs.""" + def test_mesh_generation(self): + """Test functional Mesh generation.""" 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], a0=0.1) - self.assertEqual(str(m), "Mesh with 0 points and 0 simplices") # From stubs + # We use a large a0 to keep it fast + m = nmesh.Mesh(bounding_box=bb, objects=[obj], a0=0.5) - # Test properties (should return empty lists from stubs) - self.assertEqual(m.points, []) - self.assertEqual(m.simplices, []) - self.assertEqual(m.regions, []) + # It should have some points and simplices now + self.assertGreater(len(m.points), 0) + self.assertGreater(len(m.simplices), 0) + self.assertGreater(len(m.regions), 0) + + # Check if points are within bounding box + for p in m.points: + for i in range(3): + self.assertGreaterEqual(p[i], 0.0 - 1e-9) + self.assertLessEqual(p[i], 1.0 + 1e-9) def test_1d_mesh_generation(self): """Test 1D mesh generation logic.""" @@ -74,29 +81,56 @@ def test_1d_mesh_generation(self): def test_outer_corners(self): """Test outer_corners utility.""" - class MockMesh(nmesh.MeshBase): + from nmesh.base import MeshBase + class MockMesh(MeshBase): @property def points(self): return [[0,0], [1,2], [-1,1]] - m = MockMesh("raw") + m = MockMesh(None) min_c, max_corner = nmesh.outer_corners(m) self.assertEqual(min_c, [-1, 0]) self.assertEqual(max_corner, [1, 2]) - def test_write_mesh(self): - """Test write_mesh utility.""" - points = [[0.0, 0.0], [1.0, 1.0]] - simplices = [(1, [0, 1])] - surfaces = [(1, [0])] + def test_write_read_mesh(self): + """Test writing and reading mesh back.""" + points = [[0.0, 0.0], [1.0, 1.0], [0.0, 1.0], [1.0, 0.0]] + simplices = [(1, [0, 1, 2]), (1, [1, 2, 3])] + surfaces = [] data = (points, simplices, surfaces) - import io - out = io.StringIO() - nmesh.write_mesh(data, out=out) - content = out.getvalue() - self.assertIn("# PYFEM mesh file version 1.0", content) - self.assertIn("nodes = 2", content) + test_file = "test_temp.nmesh" + nmesh.write_mesh(data, out=test_file) + + try: + m = nmesh.load(test_file) + self.assertEqual(len(m.points), 4) + self.assertEqual(len(m.simplices), 2) + self.assertEqual(m.dim, 2) + finally: + if os.path.exists(test_file): + os.remove(test_file) + + def test_periodicity_helpers(self): + """Test internal periodicity helpers.""" + + # Test _all_combinations + combs = backend._all_combinations(2) + self.assertEqual(len(combs), 4) + + # Test _periodic_directions + masks = backend._periodic_directions([True, False, True]) + # Should return masks for all sub-entities (edges, faces) + self.assertGreater(len(masks), 0) + + def test_gradient(self): + """Test numeric gradient calculation.""" + def f(x): return x[0]**2 + x[1]**2 + + grad = backend.symm_grad(f, [1.0, 2.0]) + # Gradient of x^2 + y^2 is [2x, 2y] -> [2, 4] + self.assertAlmostEqual(grad[0], 2.0, places=5) + self.assertAlmostEqual(grad[1], 4.0, places=5) if __name__ == '__main__': unittest.main() From 77eaac27d93090756eef7a2b6ed48dfcf2f2d0e3 Mon Sep 17 00:00:00 2001 From: Emanuel Pituch <32016786+epituch@users.noreply.github.com> Date: Tue, 10 Mar 2026 20:30:54 -0700 Subject: [PATCH 03/14] Revert "Added more ocaml modules" This reverts commit 52c4e8704171e4d06152d711e39faf4b5d5cf949. --- CONVERSION_PLAN.md | 46 --- src/nmesh/__init__.py | 39 +- src/nmesh/backend.py | 603 ------------------------------ src/nmesh/base.py | 224 ----------- src/nmesh/features.py | 76 ---- src/nmesh/geometry.py | 101 ----- src/nmesh/nmesh.py | 599 +++++++++++++++++++++++++++++ src/nmesh/utils.py | 123 ------ src/simulation/features.py | 71 ---- src/simulation/mock_features.py | 19 + src/simulation/simulation_core.py | 4 +- tests/nmesh_test.py | 80 ++-- 12 files changed, 644 insertions(+), 1341 deletions(-) delete mode 100644 CONVERSION_PLAN.md delete mode 100644 src/nmesh/backend.py delete mode 100644 src/nmesh/base.py delete mode 100644 src/nmesh/features.py delete mode 100644 src/nmesh/geometry.py create mode 100644 src/nmesh/nmesh.py delete mode 100644 src/nmesh/utils.py delete mode 100644 src/simulation/features.py create mode 100644 src/simulation/mock_features.py diff --git a/CONVERSION_PLAN.md b/CONVERSION_PLAN.md deleted file mode 100644 index 071a715..0000000 --- a/CONVERSION_PLAN.md +++ /dev/null @@ -1,46 +0,0 @@ -# Nmag Python 3 Conversion Plan: nmesh & Features - -This document outlines the progress and remaining tasks for converting the `nmesh` OCaml backend stubs to native Python 3 and refactoring the configuration system. - -## Completed Tasks - -### 1. Features Refactoring -- **New `Features` Class**: Implemented a robust replacement for the OCaml `nsim.setup.get_features()` in `src/simulation/features.py`. -- **Cleanup**: Removed `MockFeatures` and updated `simulation_core.py` to use the new system. -- **Integration**: Integrated `Features` into `MeshingParameters`. - -### 2. nmesh Modularization -- **Modular Structure**: Refactored the monolithic `nmesh.py` into a package: - - `src/nmesh/backend.py`: Core backend logic, including the relaxation mesher. - - `src/nmesh/base.py`: Primary mesh classes (`Mesh`, `MeshBase`, `MeshFromFile`). - - `src/nmesh/geometry.py`: Geometric primitives and CSG operations. - - `src/nmesh/features.py`: Meshing parameter management. - - `src/nmesh/utils.py`: Utility functions (I/O, 1D meshing, etc.). - - `src/nmesh/__init__.py`: API compatibility layer. - -### 3. Functional Implementation -- **Mesher**: Functional `mesh_bodies_raw` with point sampling, iterative relaxation, and `scipy.spatial.Delaunay` triangulation. -- **Boundary Enforcement**: Gradient-based point correction for complex geometries. -- **Geometry**: Native Python 3 implementations of all primitives and transformations. -- **I/O**: Native PYFEM mesh reader/writer. - -### 4. Verification -- **Testing**: Updated existing tests and added new ones in `tests/nmesh_test.py`. -- **Validation**: Verified that both `nmesh` and `simulation` test suites pass (34/34 tests). - -## Remaining Tasks - -### 1. Advanced Meshing Features -- **Adaptive Refinement**: Complete the point addition/deletion logic based on local density vs. target rod length (currently simplified). -- **Periodicity Completion**: Fully implement `mesh_periodic_outer_box` to generate periodic slice meshes. - -### 2. HDF5 Support -- **Implementation**: Replace ASCII-only I/O with robust HDF5 support using `h5py` or `tables`. - -### 3. Performance Optimization -- **Vectorization**: Further optimize BC evaluation and force calculations using more aggressive NumPy vectorization. - -## Technical Notes - -- **Dependencies**: Added `numpy` and `scipy` as core requirements for the meshing system. -- **API Continuity**: Maintained the original `nmesh` API to ensure that existing scripts and higher-level modules remain functional. diff --git a/src/nmesh/__init__.py b/src/nmesh/__init__.py index cd71707..671625b 100644 --- a/src/nmesh/__init__.py +++ b/src/nmesh/__init__.py @@ -1,38 +1 @@ -from nmesh.backend import nmesh_backend as backend -from nmesh.base import ( - MeshBase, Mesh, MeshFromFile, mesh_from_points_and_simplices -) -from nmesh.geometry import ( - MeshObject, Box, Ellipsoid, Conic, Helix, union, difference, intersect -) -from nmesh.features import MeshingParameters, get_default_meshing_parameters -from nmesh.utils import ( - outer_corners, generate_1d_mesh_components, generate_1d_mesh, write_mesh, memory_report -) - -def load(filename, reorder=False, do_distribute=True): - """Load nmesh file with name filename.""" - import os - if not os.path.exists(filename): - raise ValueError(f"file '{filename}' does not exist") - - # Simple extension based check - if filename.lower().endswith('.h5'): - # For now, we don't have HDF5 support implemented - raise NotImplementedError("HDF5 mesh loading is not yet implemented in Python 3 version.") - - return MeshFromFile(filename, reorder=reorder, distribute=do_distribute) - -def save(mesh, filename): - """Alias for mesh.save for backward compatibility.""" - mesh.save(filename) - -# --- Exception Aliases --- -NmeshUserError = ValueError -NmeshIOError = IOError -NmeshStandardError = RuntimeError -NMeshTypeError = TypeError - -# --- Compatibility Aliases --- -tolists = lambda mesh: mesh.to_lists() -mesh_1d = generate_1d_mesh_components +from .nmesh import * diff --git a/src/nmesh/backend.py b/src/nmesh/backend.py deleted file mode 100644 index 9aebec2..0000000 --- a/src/nmesh/backend.py +++ /dev/null @@ -1,603 +0,0 @@ -import math -import logging -import time -import itertools -import numpy as np -import re -import copy -from pathlib import Path - -log = logging.getLogger(__name__) - -class MesherDefaults: - def __init__(self): - # Default values from nmag-src/src/mesh.ml - self.mdefault_controller_initial_points_volume_ratio = 0.9 - self.mdefault_controller_splitting_connection_ratio = 1.6 - self.mdefault_controller_exp_neigh_force_scale = 0.9 - self.mdefault_nr_probes_for_determining_volume = 100000 - self.mdefault_boundary_condition_acceptable_fuzz = 1.0e-6 - self.mdefault_boundary_condition_max_nr_correction_steps = 200 - self.mdefault_boundary_condition_debuglevel = 0 - self.mdefault_relaxation_debuglevel = 0 - self.mdefault_controller_movement_max_freedom = 3.0 - self.mdefault_controller_topology_threshold = 0.2 - self.mdefault_controller_step_limit_min = 500 - self.mdefault_controller_step_limit_max = 1000 - self.mdefault_controller_max_time_step = 10.0 - self.mdefault_controller_time_step_scale = 0.1 - self.mdefault_controller_tolerated_rel_movement = 0.002 - self.mdefault_controller_shape_force_scale = 0.1 - self.mdefault_controller_volume_force_scale = 0.0 - self.mdefault_controller_neigh_force_scale = 1.0 - self.mdefault_controller_irrel_elem_force_scale = 1.0 - self.mdefault_controller_initial_settling_steps = 100 - self.mdefault_controller_thresh_add = 1.0 - self.mdefault_controller_thresh_del = 2.0 - self.mdefault_controller_sliver_correction = 1.0 - self.mdefault_controller_smallest_allowed_volume_ratio = 1.0 - - def mesher_defaults_set_shape_force_scale(self, v): self.mdefault_controller_shape_force_scale = v - def mesher_defaults_set_volume_force_scale(self, v): self.mdefault_controller_volume_force_scale = v - def mesher_defaults_set_neigh_force_scale(self, v): self.mdefault_controller_neigh_force_scale = v - def mesher_defaults_set_irrel_elem_force_scale(self, v): self.mdefault_controller_irrel_elem_force_scale = v - def mesher_defaults_set_time_step_scale(self, v): self.mdefault_controller_time_step_scale = v - def mesher_defaults_set_thresh_add(self, v): self.mdefault_controller_thresh_add = v - def mesher_defaults_set_thresh_del(self, v): self.mdefault_controller_thresh_del = v - def mesher_defaults_set_topology_threshold(self, v): self.mdefault_controller_topology_threshold = v - def mesher_defaults_set_tolerated_rel_movement(self, v): self.mdefault_controller_tolerated_rel_movement = v - def mesher_defaults_set_max_relaxation_steps(self, v): self.mdefault_controller_step_limit_max = v - def mesher_defaults_set_initial_settling_steps(self, v): self.mdefault_controller_initial_settling_steps = v - def mesher_defaults_set_sliver_correction(self, v): self.mdefault_controller_sliver_correction = v - def mesher_defaults_set_smallest_allowed_volume_ratio(self, v): self.mdefault_controller_smallest_allowed_volume_ratio = v - def mesher_defaults_set_movement_max_freedom(self, v): self.mdefault_controller_movement_max_freedom = v - -class RawMesh: - """Internal representation of a mesh.""" - def __init__(self, dim, points, simplices, regions, periodic_points=None, permutation=None): - self.dim = dim - self.points = points # List[List[float]] - self.simplices = simplices # List[List[int]] - self.regions = regions # List[int] - self.periodic_points = periodic_points or [] - self.permutation = permutation - self._links = None - self._surfaces = None - self._surfaces_regions = None - self._region_volumes = None - self._point_regions = None - - def get_links(self): - if self._links is None: - links_set = set() - for sx in self.simplices: - for p1, p2 in itertools.combinations(sx, 2): - links_set.add(tuple(sorted((p1, p2)))) - self._links = [list(link) for link in links_set] - return self._links - - def get_surfaces(self): - if self._surfaces is None: - faces = {} - for i, sx in enumerate(self.simplices): - region = self.regions[i] - for j in range(len(sx)): - face = tuple(sorted(sx[:j] + sx[j+1:])) - if face not in faces: - faces[face] = [] - faces[face].append(region) - - self._surfaces = [] - self._surfaces_regions = [] - for face, regs in faces.items(): - if len(regs) == 1: - self._surfaces.append(list(face)) - self._surfaces_regions.append(regs[0]) - elif len(regs) == 2 and regs[0] != regs[1]: - self._surfaces.append(list(face)) - self._surfaces_regions.append(regs[0]) - self._surfaces.append(list(face)) - self._surfaces_regions.append(regs[1]) - return self._surfaces, self._surfaces_regions - - def get_region_volumes(self): - if self._region_volumes is None: - if not self.simplices: - self._region_volumes = [] - return self._region_volumes - - max_reg = max(self.regions) if self.regions else 0 - volumes = [0.0] * (max_reg + 1) - - fact_dim = math.factorial(self.dim) - for i, sx in enumerate(self.simplices): - reg = self.regions[i] - pts = [self.points[idx] for idx in sx] - if len(pts) > 1: - mat = np.array([np.array(pts[j]) - np.array(pts[0]) for j in range(1, len(pts))]) - vol = abs(np.linalg.det(mat)) / fact_dim - if reg >= 0: - volumes[reg] += vol - self._region_volumes = volumes - return self._region_volumes - - def get_point_regions(self): - if self._point_regions is None: - pt_regs = [set() for _ in range(len(self.points))] - for i, sx in enumerate(self.simplices): - reg = self.regions[i] - for pt_idx in sx: - pt_regs[pt_idx].add(reg) - self._point_regions = [list(regs) for regs in pt_regs] - return self._point_regions - -class AffineTrafo: - def __init__(self, dim, matrix=None, displacement=None): - self.dim = dim - self.matrix = matrix if matrix is not None else np.eye(dim) - self.displacement = displacement if displacement is not None else np.zeros(dim) - - def combine(self, other: 'AffineTrafo'): - new_matrix = np.dot(self.matrix, other.matrix) - new_displacement = self.displacement + np.dot(self.matrix, other.displacement) - return AffineTrafo(self.dim, new_matrix, new_displacement) - - def apply_to_pos(self, pos): - return np.dot(self.matrix, np.array(pos)) + self.displacement - -class Body: - """Representation of a geometric body with a boundary condition.""" - def __init__(self, trafo: AffineTrafo, bc_func): - self.trafo = trafo - self.bc_func = bc_func - - def __call__(self, pos): - return self.bc_func(self.trafo.apply_to_pos(pos)) - -class NMeshBackend: - """Implementation of the NMesh backend in Python.""" - def __init__(self): - self._mesher_defaults = MesherDefaults() - - def time_vmem_rss(self): - try: - import psutil - process = psutil.Process() - mem = process.memory_info() - return time.time(), mem.vms / 1024.0, mem.rss / 1024.0 - except ImportError: - return time.time(), 0.0, 0.0 - - # Mesh operations - def mesh_scale_node_positions(self, raw_mesh, scale): - for p in raw_mesh.points: - for i in range(len(p)): - p[i] *= float(scale) - raw_mesh._region_volumes = None - - def mesh_writefile(self, path, raw_mesh): - from .utils import write_mesh - write_mesh((raw_mesh.points, list(zip(raw_mesh.regions, raw_mesh.simplices)), []), out=path) - - def mesh_nr_simplices(self, raw_mesh): - return len(raw_mesh.simplices) - - def mesh_nr_points(self, raw_mesh): - return len(raw_mesh.points) - - def mesh_plotinfo(self, raw_mesh): - surfaces, _ = raw_mesh.get_surfaces() - simps_info = [] - for i, sx in enumerate(raw_mesh.simplices): - simps_info.append([sx, [[[], 0.0], [[], 0.0], raw_mesh.regions[i]]]) - return [raw_mesh.points, raw_mesh.get_links(), simps_info, raw_mesh.get_point_regions()] - - def mesh_plotinfo_points(self, raw_mesh): - return raw_mesh.points - - def mesh_plotinfo_pointsregions(self, raw_mesh): - return raw_mesh.get_point_regions() - - def mesh_plotinfo_simplices(self, raw_mesh): - return raw_mesh.simplices - - def mesh_plotinfo_simplicesregions(self, raw_mesh): - return raw_mesh.regions - - def mesh_plotinfo_surfaces_and_surfacesregions(self, raw_mesh): - return raw_mesh.get_surfaces() - - def mesh_plotinfo_links(self, raw_mesh): - return raw_mesh.get_links() - - def mesh_dim(self, raw_mesh): - return raw_mesh.dim - - def mesh_plotinfo_regionvolumes(self, raw_mesh): - return raw_mesh.get_region_volumes() - - def mesh_plotinfo_periodic_points_indices(self, raw_mesh): - return raw_mesh.periodic_points - - def mesh_set_vertex_distribution(self, raw_mesh, dist): - pass - - def mesh_get_permutation(self, raw_mesh): - return raw_mesh.permutation or list(range(len(raw_mesh.points))) - - def mesh_readfile(self, filename, do_reorder, do_distribute): - path = Path(filename) - if not path.exists(): raise FileNotFoundError(f"File {filename} not found") - with open(path, 'r') as f: - lines = f.readlines() - if not lines or not lines[0].startswith("# PYFEM"): - raise ValueError(f"Invalid mesh file: {filename}") - m = re.search(r"dim\s*=\s*(\d+)\s*nodes\s*=\s*(\d+)\s*simplices\s*=\s*(\d+)", lines[1]) - if not m: raise ValueError(f"Invalid header in mesh file: {filename}") - dim = int(m.group(1)) - ptr = 2 - while ptr < len(lines) and (lines[ptr].strip().startswith("#") or not lines[ptr].strip()): - ptr += 1 - n_pts = int(lines[ptr].strip()) - ptr += 1 - points = [] - for _ in range(n_pts): - points.append([float(x) for x in lines[ptr].split()]) - ptr += 1 - n_simps = int(lines[ptr].strip()) - ptr += 1 - simplices = [] - regions = [] - for _ in range(n_simps): - parts = [float(x) for x in lines[ptr].split()] - regions.append(int(parts[0])) - simplices.append([int(x) for x in parts[1:]]) - ptr += 1 - return RawMesh(dim, points, simplices, regions) - - # Driver and Mesh creation - def make_mg_gendriver(self, interval, callback): - return (interval, callback) - - def symm_grad(self, f, x, epsilon=1e-7): - dim = len(x) - grad = np.zeros(dim) - for i in range(dim): - x_plus = np.array(x, dtype=float) - x_minus = np.array(x, dtype=float) - x_plus[i] += epsilon - x_minus[i] -= epsilon - grad[i] = (f(x_plus) - f(x_minus)) / (2.0 * epsilon) - return grad - - def _enforce_boundary_conditions(self, mesher_defaults, bcs, coords): - acceptable_fuzz = mesher_defaults.mdefault_boundary_condition_acceptable_fuzz - max_steps = mesher_defaults.mdefault_boundary_condition_max_nr_correction_steps - for _ in range(max_steps): - violated_idx = -1 - for i, bc in enumerate(bcs): - if bc(coords) < -acceptable_fuzz: - violated_idx = i - break - if violated_idx == -1: - return True - bc = bcs[violated_idx] - val = bc(coords) - grad = self.symm_grad(bc, coords) - grad_sq = np.sum(grad**2) - if grad_sq < 1e-12: - break - scale = -val / grad_sq - coords += scale * grad - return False - - def _enforce_boundary_conditions_reversed(self, mesher_defaults, bcs, coords): - acceptable_fuzz = mesher_defaults.mdefault_boundary_condition_acceptable_fuzz - max_steps = mesher_defaults.mdefault_boundary_condition_max_nr_correction_steps - for _ in range(max_steps): - violated_idx = -1 - for i, bc in enumerate(bcs): - if bc(coords) > acceptable_fuzz: - violated_idx = i - break - if violated_idx == -1: - return True - bc = bcs[violated_idx] - val = bc(coords) - grad = self.symm_grad(bc, coords) - grad_sq = np.sum(grad**2) - if grad_sq < 1e-12: - break - scale = -val / grad_sq - coords += scale * grad - return False - - def mesher_defaults_set_shape_force_scale(self, m, v): m.mesher_defaults_set_shape_force_scale(v) - def mesher_defaults_set_volume_force_scale(self, m, v): m.mesher_defaults_set_volume_force_scale(v) - def mesher_defaults_set_neigh_force_scale(self, m, v): m.mesher_defaults_set_neigh_force_scale(v) - def mesher_defaults_set_irrel_elem_force_scale(self, m, v): m.mesher_defaults_set_irrel_elem_force_scale(v) - def mesher_defaults_set_time_step_scale(self, m, v): m.mesher_defaults_set_time_step_scale(v) - def mesher_defaults_set_thresh_add(self, m, v): m.mesher_defaults_set_thresh_add(v) - def mesher_defaults_set_thresh_del(self, m, v): m.mesher_defaults_set_thresh_del(v) - def mesher_defaults_set_topology_threshold(self, m, v): m.mesher_defaults_set_topology_threshold(v) - def mesher_defaults_set_tolerated_rel_movement(self, m, v): m.mesher_defaults_set_tolerated_rel_movement(v) - def mesher_defaults_set_max_relaxation_steps(self, m, v): m.mesher_defaults_set_max_relaxation_steps(v) - def mesher_defaults_set_initial_settling_steps(self, m, v): m.mesher_defaults_set_initial_settling_steps(v) - def mesher_defaults_set_sliver_correction(self, m, v): m.mesher_defaults_set_sliver_correction(v) - def mesher_defaults_set_smallest_allowed_volume_ratio(self, m, v): m.mesher_defaults_set_smallest_allowed_volume_ratio(v) - def mesher_defaults_set_movement_max_freedom(self, m, v): m.mesher_defaults_set_movement_max_freedom(v) - - def _all_combinations(self, n): - comb = [] - for i in range(1 << n): - c = [(i & (1 << j)) != 0 for j in range(n)] - comb.append(c) - comb.sort(key=lambda x: sum(x)) - return [np.array(c) for c in comb] - - def _periodic_directions(self, filter_mask): - dim = len(filter_mask) - components = [] - for i in range(dim): - if filter_mask[i]: - c = [False] * dim - c[i] = True - components.append(np.array(c)) - def get_sub_masks(comp): - inv = ~comp - sub = [inv] - for i in range(dim): - if inv[i]: - c = [False] * dim - c[i] = True - sub.append(np.array(c)) - return sub - all_masks = [] - for c in components: - all_masks.extend(get_sub_masks(c)) - unique = {} - for m in all_masks: - unique[tuple(m)] = m - res = list(unique.values()) - res.sort(key=lambda x: sum(x)) - return res - - def _mask_coords(self, mask, nw, se, pt): - res = [] - for i in range(len(pt)): - if mask[i]: - res.append(pt[i]) - else: - if abs(pt[i] - nw[i]) < 1e-10 or abs(pt[i] - se[i]) < 1e-10: - pass - else: - return None - return np.array(res) - - def _unmask_coords(self, mask, point, nw, se): - dim = len(mask) - unmasked_count = dim - sum(mask) - combs = self._all_combinations(unmasked_count) - res = [] - for comb in combs: - new_pt = np.zeros(dim) - p_idx = 0 - c_idx = 0 - for i in range(dim): - if mask[i]: - new_pt[i] = point[p_idx] - p_idx += 1 - else: - new_pt[i] = nw[i] if comb[c_idx] else se[i] - c_idx += 1 - res.append(new_pt) - return res - - def mesh_periodic_outer_box(self, fixed_points, fem_geometry, mdefaults, length_scale, filter_mask): - return np.array([]), [] - - def _relaxation_force(self, reduced_dist): - if reduced_dist > 1.0: return 0.0 - return 1.0 - reduced_dist - - def _boundary_node_force(self, reduced_dist): - if reduced_dist > 1.0: return 0.0 - if reduced_dist < 1e-10: return 100.0 - return (1.0 / reduced_dist) - 1.0 - - def _sample_points(self, dim, nw, se, density_fun, target_count, rng=None): - if rng is None: rng = np.random.default_rng() - points = [] - max_attempts = target_count * 100 - attempts = 0 - while len(points) < target_count and attempts < max_attempts: - p = rng.uniform(nw, se) - d = density_fun(p) - if rng.random() < d: - points.append(p.tolist()) - attempts += 1 - return points - - def mesh_bodies_raw(self, driver, mesher, bb_min, bb_max, mesh_ext, objects, a0, density, fixed, mobile, simply, periodic, cache, hints): - import scipy.spatial - dim = len(bb_min) - nw, se = np.array(bb_min), np.array(bb_max) - if not fixed and not mobile and not simply: - node_vol = (a0**dim) * 0.7 - def global_density(p): - if not objects: return 1.0 - for obj in objects: - if obj(p) >= -1e-6: - return 1.0 - return 0.0 - box_vol = np.prod(se - nw) - target_count = int(box_vol / node_vol) - target_count = max(min(target_count, 10000), dim + 1 + 5) - mobile = self._sample_points(dim, nw, se, global_density, target_count) - all_points = list(fixed) + list(mobile) + list(simply) - if not all_points and not objects: - return RawMesh(dim, [], [], []) - points_np = np.array(all_points) - if points_np.shape[0] <= dim: - return RawMesh(dim, all_points, [], []) - max_relaxation_steps = min(mesher.mdefault_controller_step_limit_max, 50) - for step in range(max_relaxation_steps): - try: - tri = scipy.spatial.Delaunay(points_np) - except: - break - forces = np.zeros_like(points_np) - indptr, indices = tri.vertex_neighbor_vertices - for i in range(len(points_np)): - if i < len(fixed): continue - pt = points_np[i] - target_a = a0 - for neighbor_idx in indices[indptr[i]:indptr[i+1]]: - neighbor_pt = points_np[neighbor_idx] - vec = pt - neighbor_pt - dist = np.linalg.norm(vec) - if dist < 1e-12: continue - reduced_dist = dist / target_a - f_mag = self._relaxation_force(reduced_dist) - forces[i] += (f_mag / dist) * vec - dt = mesher.mdefault_controller_time_step_scale * a0 - points_np += dt * forces - if objects: - bcs = [obj.bc_func for obj in objects] - for i in range(len(fixed), len(points_np)): - self._enforce_boundary_conditions(mesher, bcs, points_np[i]) - try: - tri = scipy.spatial.Delaunay(points_np) - except: - return RawMesh(dim, points_np.tolist(), [], []) - simplices = tri.simplices.tolist() - final_simplices = [] - final_regions = [] - if not objects: - final_simplices = simplices - final_regions = [1] * len(simplices) - else: - for sx in simplices: - sx_pts = points_np[sx] - cog = np.mean(sx_pts, axis=0) - best_region = 0 - for i, obj in enumerate(objects, 1): - if obj(cog) >= -1e-6: - best_region = i - break - if best_region > 0: - final_simplices.append(sx) - final_regions.append(best_region) - return RawMesh(dim, points_np.tolist(), final_simplices, final_regions) - - # Body operations - def body_union(self, objs): - def bc(pos): - return max(o(pos) for o in objs) - return Body(AffineTrafo(objs[0].trafo.dim), bc) - - def body_difference(self, mother, subs): - def bc(pos): - res = mother(pos) - for s in subs: - res = min(res, -s(pos)) - return res - return Body(AffineTrafo(mother.trafo.dim), bc) - - def body_intersection(self, objs): - def bc(pos): - return min(o(pos) for o in objs) - return Body(AffineTrafo(objs[0].trafo.dim), bc) - - def _body_transform(self, body, matrix, displacement): - trafo = AffineTrafo(body.trafo.dim, matrix, displacement) - new_trafo = trafo.combine(body.trafo) - return Body(new_trafo, body.bc_func) - - def body_shifted_sc(self, body, shift): - dim = body.trafo.dim - return self._body_transform(body, np.eye(dim), -np.array(shift)) - - def body_shifted_bc(self, body, shift): - return self.body_shifted_sc(body, shift) - - def body_scaled(self, body, scale): - dim = body.trafo.dim - s = np.array(scale) - if s.ndim == 0: s = np.full(dim, s) - return self._body_transform(body, np.diag(1.0/s), np.zeros(dim)) - - def body_rotated_sc(self, body, a1, a2, rad): - dim = body.trafo.dim - mat = np.eye(dim) - c, s = math.cos(rad), math.sin(rad) - mat[a1, a1] = c - mat[a1, a2] = s - mat[a2, a1] = -s - mat[a2, a2] = c - return self._body_transform(body, mat, np.zeros(dim)) - - def body_rotated_bc(self, body, a1, a2, rad): - return self.body_rotated_sc(body, a1, a2, rad) - - def body_rotated_axis_sc(self, body, axis, rad): - dim = body.trafo.dim - if dim != 3: return body - axis = np.array(axis) / np.linalg.norm(axis) - c, s = math.cos(rad), math.sin(rad) - t = 1 - c - x, y, z = axis - mat = np.array([ - [t*x*x + c, t*x*y - s*z, t*x*z + s*y], - [t*x*y + s*z, t*y*y + c, t*y*z - s*x], - [t*x*z - s*y, t*y*z + s*x, t*z*z + c] - ]) - return self._body_transform(body, mat.T, np.zeros(dim)) - - def body_rotated_axis_bc(self, body, axis, rad): - return self.body_rotated_axis_sc(body, axis, rad) - - # Primitives - def body_box(self, p1, p2): - nw, se = np.array(p1), np.array(p2) - mid = (nw + se) * 0.5 - inv_half_len = 2.0 / np.abs(nw - se) - def bc(pos): - rel_dist = np.abs((pos - mid) * inv_half_len) - return 1.0 - np.max(rel_dist) - return Body(AffineTrafo(len(p1)), bc) - - def body_ellipsoid(self, radii): - r = np.array(radii) - inv_r = 1.0 / r - def bc(pos): - return 1.0 - np.sum((pos * inv_r)**2) - return Body(AffineTrafo(len(radii)), bc) - - def body_frustum(self, c1, r1, c2, r2): - p1, p2 = np.array(c1), np.array(c2) - axis = p2 - p1 - axis_len_sq = np.sum(axis**2) - def bc(pos): - vec = pos - p1 - projection = np.dot(vec, axis) / axis_len_sq - if projection < 0 or projection > 1: return -1.0 - r_at_p = r1 + projection * (r2 - r1) - dist_sq = np.sum((vec - projection * axis)**2) - return r_at_p**2 - dist_sq - return Body(AffineTrafo(len(c1)), bc) - - def body_helix(self, c1, r1, c2, r2): - return self.body_frustum(c1, r1, c2, r2) - - def mesh_from_points_and_simplices(self, dim, points, simplices, regions, periodic, reorder, distribute): - return RawMesh(dim, points, simplices, regions, periodic) - - def copy_mesher_defaults(self, defaults): - return copy.deepcopy(defaults) - - @property - def mesher_defaults(self): - return self._mesher_defaults - -nmesh_backend = NMeshBackend() diff --git a/src/nmesh/base.py b/src/nmesh/base.py deleted file mode 100644 index 0ac04a4..0000000 --- a/src/nmesh/base.py +++ /dev/null @@ -1,224 +0,0 @@ -import logging -import os -from typing import List, Union, Optional -from pathlib import Path -from nmesh.backend import nmesh_backend as backend, RawMesh - -log = logging.getLogger(__name__) - -class MeshBase: - """Base class for all mesh objects, providing access to mesh data.""" - def __init__(self, raw_mesh): - self.raw_mesh = raw_mesh - self._cache = {} - - def scale_node_positions(self, scale: float): - """Scales all node positions in the mesh.""" - backend.mesh_scale_node_positions(self.raw_mesh, float(scale)) - self._cache = {} # Clear cache - - def save_hdf5(self, filename): - log.warning("save_hdf5: HDF5 support not yet implemented.") - pass - - def save(self, file_name, directory=None, format=None): - """Saves the mesh to a file.""" - from simulation.features import features - # In original, output_file_location comes from nsim.snippets - # Here we simplify it or use Path - path = str(file_name) - if directory: - path = os.path.join(directory, path) - - if format == 'hdf5' or path.lower().endswith('.h5'): - self.save_hdf5(path) - else: - backend.mesh_writefile(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 tolists(self): - """Alias for to_lists for backward compatibility.""" - return self.to_lists() - - def to_lists(self): - """Returns mesh data as Python lists.""" - return backend.mesh_plotinfo(self.raw_mesh) - - @property - def points(self): - if 'points' not in self._cache: - self._cache['points'] = backend.mesh_plotinfo_points(self.raw_mesh) - return self._cache['points'] - - @property - def pointsregions(self): - if 'pointsregions' not in self._cache: - self._cache['pointsregions'] = backend.mesh_plotinfo_pointsregions(self.raw_mesh) - return self._cache['pointsregions'] - - point_regions = pointsregions - - @property - def simplices(self): - if 'simplices' not in self._cache: - self._cache['simplices'] = backend.mesh_plotinfo_simplices(self.raw_mesh) - return self._cache['simplices'] - - @property - def simplicesregions(self): - if 'simplicesregions' not in self._cache: - self._cache['simplicesregions'] = backend.mesh_plotinfo_simplicesregions(self.raw_mesh) - return self._cache['simplicesregions'] - - regions = simplicesregions - - @property - def dim(self): - return backend.mesh_dim(self.raw_mesh) - - @property - def surfaces_and_surfacesregions(self): - if 'surfaces_all' not in self._cache: - self._cache['surfaces_all'] = backend.mesh_plotinfo_surfaces_and_surfacesregions(self.raw_mesh) - return self._cache['surfaces_all'] - - @property - def surfaces(self): - return self.surfaces_and_surfacesregions[0] - - @property - def surfacesregions(self): - return self.surfaces_and_surfacesregions[1] - - @property - def links(self): - """Returns all links (pairs of point indices).""" - if 'links' not in self._cache: - self._cache['links'] = backend.mesh_plotinfo_links(self.raw_mesh) - return self._cache['links'] - - @property - def regionvolumes(self): - """Returns volume of each region.""" - if 'regionvolumes' not in self._cache: - self._cache['regionvolumes'] = backend.mesh_plotinfo_regionvolumes(self.raw_mesh) - return self._cache['regionvolumes'] - - region_volumes = regionvolumes - - @property - def numregions(self): - """Returns the number of regions.""" - return len(self.region_volumes) - - num_regions = numregions - - @property - def periodicpointindices(self): - """Returns indices of periodic nodes.""" - if 'periodicpointindices' not in self._cache: - self._cache['periodicpointindices'] = backend.mesh_plotinfo_periodic_points_indices(self.raw_mesh) - return self._cache['periodicpointindices'] - - periodic_point_indices = periodicpointindices - - @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) - -class Mesh(MeshBase): - """Class for generating a mesh from geometric objects.""" - def __init__(self, bounding_box=None, objects=[], a0=1.0, density="", - periodic=[], fixed_points=[], mobile_points=[], simply_points=[], - callback=None, mesh_bounding_box=False, meshing_parameters=None, - cache_name="", hints=[], **kwargs): - - if bounding_box is None: - raise ValueError("Bounding box must be provided.") - - bb = [[float(x) for x in p] for p in bounding_box] - dim = len(bb[0]) - mesh_ext = 1 if mesh_bounding_box else 0 - - from nmesh.features import get_default_meshing_parameters - params = meshing_parameters or get_default_meshing_parameters() - for k, v in kwargs.items(): - params[k] = v - - obj_bodies = [] - self._fixed_points = [list(map(float, p)) for p in fixed_points] - self._mobile_points = [list(map(float, p)) for p in mobile_points] - self._simply_points = [list(map(float, p)) for p in simply_points] - - for obj in objects: - obj_bodies.append(obj.obj) - self._fixed_points.extend(obj.fixed_points) - self._mobile_points.extend(obj.mobile_points) - - periodic_floats = [1.0 if p else 0.0 for p in periodic] if periodic else [0.0] * dim - - cb_func, cb_interval = callback if callback else (lambda a,b,c: None, 1000000) - self.fun_driver = cb_func - driver = backend.make_mg_gendriver(cb_interval, cb_func) - mesher = backend.copy_mesher_defaults(backend.mesher_defaults) - params.pass_parameters_to_ocaml(mesher, dim) - - raw = backend.mesh_bodies_raw( - driver, mesher, bb[0], bb[1], mesh_ext, obj_bodies, float(a0), - density, self._fixed_points, self._mobile_points, self._simply_points, periodic_floats, - cache_name, hints - ) - - if raw is None: raise RuntimeError("Mesh generation failed.") - super().__init__(raw) - - def fixed_points(self, points: List[List[float]]): - """Adds fixed points to the mesh configuration.""" - if points: - self._fixed_points.extend(points) - - def mobile_points(self, points: List[List[float]]): - """Adds mobile points to the mesh configuration.""" - if points: - self._mobile_points.extend(points) - - def simply_points(self, points: List[List[float]]): - """Adds simply points to the mesh configuration.""" - if points: - self._simply_points.extend(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") - - raw = backend.mesh_readfile(str(path), reorder, distribute) - super().__init__(raw) - -class mesh_from_points_and_simplices(MeshBase): - """Wrapper for backward compatibility.""" - def __init__(self, points=[], simplices_indices=[], simplices_regions=[], - periodic_point_indices=[], initial=0, do_reorder=False, - do_distribute=True): - - if initial == 1: - simplices_indices = [[idx - 1 for idx in s] for s in simplices_indices] - - raw = backend.mesh_from_points_and_simplices( - len(points[0]) if points else 3, - [[float(x) for x in p] for p in points], - [[int(x) for x in s] for s in simplices_indices], - [int(r) for r in simplices_regions], - periodic_point_indices, do_reorder, do_distribute - ) - super().__init__(raw) diff --git a/src/nmesh/features.py b/src/nmesh/features.py deleted file mode 100644 index a82810e..0000000 --- a/src/nmesh/features.py +++ /dev/null @@ -1,76 +0,0 @@ -import logging -from nmesh.backend import nmesh_backend as backend -from simulation.features import Features - -log = logging.getLogger(__name__) - -class MeshingParameters(Features): - """Parameters for the meshing algorithm, supporting multiple dimensions.""" - def __init__(self, string=None, file=None): - super().__init__() - self.dim = None - if file: self.from_file(file) - if string: self.from_string(string) - self.add_section('user-modifications') - - def _get_section_name(self): - 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 __getitem__(self, name): - val = self.get('user-modifications', name) - if val is not None: - return val - section = self._get_section_name() - return self.get(section, name) - - def __setitem__(self, key, value): - self.set('user-modifications', key, value) - - def set_shape_force_scale(self, v): self["shape_force_scale"] = float(v) - def set_volume_force_scale(self, v): self["volume_force_scale"] = float(v) - def set_neigh_force_scale(self, v): self["neigh_force_scale"] = float(v) - def set_irrel_elem_force_scale(self, v): self["irrel_elem_force_scale"] = float(v) - def set_time_step_scale(self, v): self["time_step_scale"] = float(v) - def set_thresh_add(self, v): self["thresh_add"] = float(v) - def set_thresh_del(self, v): self["thresh_del"] = float(v) - def set_topology_threshold(self, v): self["topology_threshold"] = float(v) - def set_tolerated_rel_move(self, v): self["tolerated_rel_move"] = float(v) - def set_max_steps(self, v): self["max_steps"] = int(v) - def set_initial_settling_steps(self, v): self["initial_settling_steps"] = int(v) - def set_sliver_correction(self, v): self["sliver_correction"] = float(v) - def set_smallest_volume_ratio(self, v): self["smallest_volume_ratio"] = float(v) - def set_max_relaxation(self, v): self["max_relaxation"] = float(v) - - def pass_parameters_to_ocaml(self, mesher, dim): - self.dim = dim - for key, value in self.items('user-modifications'): - section = self._get_section_name() - self.set(section, key, str(value)) - - params = [ - ("shape_force_scale", backend.mesher_defaults_set_shape_force_scale), - ("volume_force_scale", backend.mesher_defaults_set_volume_force_scale), - ("neigh_force_scale", backend.mesher_defaults_set_neigh_force_scale), - ("irrel_elem_force_scale", backend.mesher_defaults_set_irrel_elem_force_scale), - ("time_step_scale", backend.mesher_defaults_set_time_step_scale), - ("thresh_add", backend.mesher_defaults_set_thresh_add), - ("thresh_del", backend.mesher_defaults_set_thresh_del), - ("topology_threshold", backend.mesher_defaults_set_topology_threshold), - ("tolerated_rel_move", backend.mesher_defaults_set_tolerated_rel_movement), - ("max_steps", backend.mesher_defaults_set_max_relaxation_steps), - ("initial_settling_steps", backend.mesher_defaults_set_initial_settling_steps), - ("sliver_correction", backend.mesher_defaults_set_sliver_correction), - ("smallest_volume_ratio", backend.mesher_defaults_set_smallest_allowed_volume_ratio), - ("max_relaxation", backend.mesher_defaults_set_movement_max_freedom), - ] - - for key, setter in params: - val = self[key] - if val is not None: - setter(mesher, float(val) if "steps" not in key else int(val)) - -def get_default_meshing_parameters(): - """Returns default meshing parameters.""" - return MeshingParameters() diff --git a/src/nmesh/geometry.py b/src/nmesh/geometry.py deleted file mode 100644 index ceb70c6..0000000 --- a/src/nmesh/geometry.py +++ /dev/null @@ -1,101 +0,0 @@ -import math -from typing import List, Any -from nmesh.backend import nmesh_backend as backend - -class MeshObject: - """Base class for geometric primitives and CSG operations.""" - def __init__(self, dim, fixed=None, mobile=None): - self.dim = dim - self.fixed_points = fixed if fixed is not None else [] - self.mobile_points = mobile if mobile is not None else [] - self.obj: Any = None - - def shift(self, vector, system_coords=True): - self.obj = (backend.body_shifted_sc if system_coords else backend.body_shifted_bc)(self.obj, vector) - return self - - def scale(self, factors): - self.obj = backend.body_scaled(self.obj, factors) - return self - - def rotate(self, a1, a2, angle, system_coords=True): - rad = math.radians(angle) - self.obj = (backend.body_rotated_sc if system_coords else backend.body_rotated_bc)(self.obj, a1, a2, rad) - return self - - def rotate_3d(self, axis, angle, system_coords=True): - rad = math.radians(angle) - self.obj = (backend.body_rotated_axis_sc if system_coords else backend.body_rotated_axis_bc)(self.obj, axis, rad) - return self - - def transform(self, transformations, system_coords=True): - """Applies a list of transformation tuples.""" - for t in transformations: - name, *args = t - if name == "shift": self.shift(args[0], system_coords) - elif name == "scale": self.scale(args[0]) - elif name == "rotate": self.rotate(args[0][0], args[0][1], args[1], system_coords) - elif name == "rotate2d": self.rotate(0, 1, args[0], system_coords) - elif name == "rotate3d": self.rotate_3d(args[0], args[1], system_coords) - return self - -class Box(MeshObject): - def __init__(self, p1, p2, transform=None, fixed=None, mobile=None, system_coords=True, use_fixed_corners=False): - import itertools - dim = len(p1) - fixed_pts = fixed if fixed is not None else [] - if use_fixed_corners: - fixed_pts.extend([list(c) for c in itertools.product(*zip(p1, p2))]) - super().__init__(dim, fixed_pts, mobile) - self.obj = backend.body_box([float(x) for x in p1], [float(x) for x in p2]) - if transform: - self.transform(transform, system_coords) - -class Ellipsoid(MeshObject): - def __init__(self, lengths, transform=None, fixed=None, mobile=None, system_coords=True): - super().__init__(len(lengths), fixed, mobile) - self.obj = backend.body_ellipsoid([float(x) for x in lengths]) - if transform: - self.transform(transform, system_coords) - -class Conic(MeshObject): - def __init__(self, c1, r1, c2, r2, transform=None, fixed=None, mobile=None, system_coords=True): - super().__init__(len(c1), fixed, mobile) - self.obj = backend.body_frustum(c1, r1, c2, r2) - if transform: - self.transform(transform, system_coords) - -class Helix(MeshObject): - def __init__(self, c1, r1, c2, r2, transform=None, fixed=None, mobile=None, system_coords=True): - super().__init__(len(c1), fixed, mobile) - self.obj = backend.body_helix(c1, r1, c2, r2) - if transform: - self.transform(transform, system_coords) - -# --- CSG --- - -def union(objects: List[MeshObject]) -> MeshObject: - if len(objects) < 2: raise ValueError("Union requires at least two objects") - res = MeshObject(objects[0].dim) - for o in objects: - res.fixed_points.extend(o.fixed_points) - res.mobile_points.extend(o.mobile_points) - res.obj = backend.body_union([o.obj for o in objects]) - return res - -def difference(mother: MeshObject, subtract: List[MeshObject]) -> MeshObject: - res = MeshObject(mother.dim, mother.fixed_points[:], mother.mobile_points[:]) - for o in subtract: - res.fixed_points.extend(o.fixed_points) - res.mobile_points.extend(o.mobile_points) - res.obj = backend.body_difference(mother.obj, [o.obj for o in subtract]) - return res - -def intersect(objects: List[MeshObject]) -> MeshObject: - if len(objects) < 2: raise ValueError("Intersection requires at least two objects") - res = MeshObject(objects[0].dim) - for o in objects: - res.fixed_points.extend(o.fixed_points) - res.mobile_points.extend(o.mobile_points) - res.obj = backend.body_intersection([o.obj for o in objects]) - return res diff --git a/src/nmesh/nmesh.py b/src/nmesh/nmesh.py new file mode 100644 index 0000000..02c9b83 --- /dev/null +++ b/src/nmesh/nmesh.py @@ -0,0 +1,599 @@ +import math +import logging +from typing import List, Tuple, Optional, Any, Union +from pathlib import Path +import itertools + +# Setup logging +log = logging.getLogger(__name__) + +# --- Stubs for External Dependencies --- + +class OCamlStub: + """Stub for the OCaml backend interface.""" + def time_vmem_rss(self): + return 0.0, 0, 0 + + # Mesher defaults setters + def mesher_defaults_set_shape_force_scale(self, mesher, scale): pass + def mesher_defaults_set_volume_force_scale(self, mesher, scale): pass + def mesher_defaults_set_neigh_force_scale(self, mesher, scale): pass + def mesher_defaults_set_irrel_elem_force_scale(self, mesher, scale): pass + def mesher_defaults_set_time_step_scale(self, mesher, scale): pass + def mesher_defaults_set_thresh_add(self, mesher, thresh): pass + def mesher_defaults_set_thresh_del(self, mesher, thresh): pass + def mesher_defaults_set_topology_threshold(self, mesher, thresh): pass + def mesher_defaults_set_tolerated_rel_movement(self, mesher, scale): pass + def mesher_defaults_set_max_relaxation_steps(self, mesher, steps): pass + def mesher_defaults_set_initial_settling_steps(self, mesher, steps): pass + def mesher_defaults_set_sliver_correction(self, mesher, scale): pass + def mesher_defaults_set_smallest_allowed_volume_ratio(self, mesher, scale): pass + def mesher_defaults_set_movement_max_freedom(self, mesher, scale): pass + + # Mesh operations + def mesh_scale_node_positions(self, raw_mesh, scale): pass + def mesh_writefile(self, path, raw_mesh): pass + def mesh_nr_simplices(self, raw_mesh): return 0 + def mesh_nr_points(self, raw_mesh): return 0 + def mesh_plotinfo(self, raw_mesh): return [[], [], [[], [], []]] + def mesh_plotinfo_points(self, raw_mesh): return [] + def mesh_plotinfo_pointsregions(self, raw_mesh): return [] + def mesh_plotinfo_simplices(self, raw_mesh): return [] + def mesh_plotinfo_simplicesregions(self, raw_mesh): return [] + def mesh_plotinfo_surfaces_and_surfacesregions(self, raw_mesh): return [[], []] + def mesh_plotinfo_links(self, raw_mesh): return [] + def mesh_dim(self, raw_mesh): return 3 + def mesh_plotinfo_regionvolumes(self, raw_mesh): return [] + def mesh_plotinfo_periodic_points_indices(self, raw_mesh): return [] + def mesh_set_vertex_distribution(self, raw_mesh, dist): pass + def mesh_get_permutation(self, raw_mesh): return [] + def mesh_readfile(self, filename, do_reorder, do_distribute): return "STUB_MESH" + + # Driver and Mesh creation + def make_mg_gendriver(self, interval, callback): return "STUB_DRIVER" + def copy_mesher_defaults(self, defaults): return "STUB_MESHER" + def mesh_bodies_raw(self, driver, mesher, bb_min, bb_max, mesh_ext, objects, a0, density, fixed, mobile, simply, periodic, cache, hints): return "STUB_MESH" + def mesh_from_points_and_simplices(self, dim, points, simplices, regions, periodic, reorder, distribute): return "STUB_MESH" + + # Body operations + def body_union(self, objs): return "STUB_OBJ_UNION" + def body_difference(self, obj1, objs): return "STUB_OBJ_DIFF" + def body_intersection(self, objs): return "STUB_OBJ_INTERSECT" + def body_shifted_sc(self, obj, shift): return obj + def body_shifted_bc(self, obj, shift): return obj + def body_scaled(self, obj, scale): return obj + def body_rotated_sc(self, obj, a1, a2, ang): return obj + def body_rotated_bc(self, obj, a1, a2, ang): return obj + def body_rotated_axis_sc(self, obj, axis, ang): return obj + def body_rotated_axis_bc(self, obj, axis, ang): return obj + + # Primitives + def body_box(self, p1, p2): return "STUB_BOX" + def body_ellipsoid(self, length): return "STUB_ELLIPSOID" + def body_frustum(self, c1, r1, c2, r2): return "STUB_FRUSTUM" + def body_helix(self, c1, r1, c2, r2): return "STUB_HELIX" + + @property + def mesher_defaults(self): return "STUB_DEFAULTS" + +ocaml = OCamlStub() + +def memory_report(tag: str): + """Reports memory usage via OCaml backend.""" + t, vmem, rss = ocaml.time_vmem_rss() + log.log(15, f"Memory report: T= {t:f} VMEM= {int(vmem)} KB RSS= {int(rss)} KB {tag}") + +# --- Configuration --- + +class FeaturesStub: + """Stub for nsim.features.Features.""" + def __init__(self, local=True): + self._data = {} + + def from_file(self, file_path): pass + def from_string(self, string): pass + def add_section(self, section): + if section not in self._data: + self._data[section] = {} + + def get(self, section, name, raw=False): + return self._data.get(section, {}).get(name) + + def set(self, section, name, value): + if section not in self._data: + self._data[section] = {} + self._data[section][name] = value + + def items(self, section): + return self._data.get(section, {}).items() + + def to_string(self): + return str(self._data) + +class MeshingParameters(FeaturesStub): + """Parameters for the meshing algorithm, supporting multiple dimensions.""" + def __init__(self, string=None, file=None): + super().__init__(local=True) + self.dim = None + if file: self.from_file(file) + if string: self.from_string(string) + self.add_section('user-modifications') + + def _get_section_name(self): + 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 __getitem__(self, name): + val = self.get('user-modifications', name) + if val is not None: + return val + section = self._get_section_name() + return self.get(section, name) + + def __setitem__(self, key, value): + self.set('user-modifications', key, value) + + def set_shape_force_scale(self, v): self["shape_force_scale"] = float(v) + def set_volume_force_scale(self, v): self["volume_force_scale"] = float(v) + def set_neigh_force_scale(self, v): self["neigh_force_scale"] = float(v) + def set_irrel_elem_force_scale(self, v): self["irrel_elem_force_scale"] = float(v) + def set_time_step_scale(self, v): self["time_step_scale"] = float(v) + def set_thresh_add(self, v): self["thresh_add"] = float(v) + def set_thresh_del(self, v): self["thresh_del"] = float(v) + def set_topology_threshold(self, v): self["topology_threshold"] = float(v) + def set_tolerated_rel_move(self, v): self["tolerated_rel_move"] = float(v) + def set_max_steps(self, v): self["max_steps"] = int(v) + def set_initial_settling_steps(self, v): self["initial_settling_steps"] = int(v) + def set_sliver_correction(self, v): self["sliver_correction"] = float(v) + def set_smallest_volume_ratio(self, v): self["smallest_volume_ratio"] = float(v) + def set_max_relaxation(self, v): self["max_relaxation"] = float(v) + + def pass_parameters_to_ocaml(self, mesher, dim): + self.dim = dim + for key, value in self.items('user-modifications'): + section = self._get_section_name() + self.set(section, key, str(value)) + + params = [ + ("shape_force_scale", ocaml.mesher_defaults_set_shape_force_scale), + ("volume_force_scale", ocaml.mesher_defaults_set_volume_force_scale), + ("neigh_force_scale", ocaml.mesher_defaults_set_neigh_force_scale), + ("irrel_elem_force_scale", ocaml.mesher_defaults_set_irrel_elem_force_scale), + ("time_step_scale", ocaml.mesher_defaults_set_time_step_scale), + ("thresh_add", ocaml.mesher_defaults_set_thresh_add), + ("thresh_del", ocaml.mesher_defaults_set_thresh_del), + ("topology_threshold", ocaml.mesher_defaults_set_topology_threshold), + ("tolerated_rel_move", ocaml.mesher_defaults_set_tolerated_rel_movement), + ("max_steps", ocaml.mesher_defaults_set_max_relaxation_steps), + ("initial_settling_steps", ocaml.mesher_defaults_set_initial_settling_steps), + ("sliver_correction", ocaml.mesher_defaults_set_sliver_correction), + ("smallest_volume_ratio", ocaml.mesher_defaults_set_smallest_allowed_volume_ratio), + ("max_relaxation", ocaml.mesher_defaults_set_movement_max_freedom), + ] + + for key, setter in params: + val = self[key] + if val is not None: + setter(mesher, float(val) if "steps" not in key else int(val)) + +def get_default_meshing_parameters(): + """Returns default meshing parameters.""" + return MeshingParameters() + +# --- Loading Utilities --- + +def _is_nmesh_ascii_file(filename): + try: + with open(filename, 'r') as f: + return f.readline().startswith("# PYFEM") + except: return False + +def _is_nmesh_hdf5_file(filename): + # This would normally use tables.isPyTablesFile + return str(filename).lower().endswith('.h5') + +def hdf5_mesh_get_permutation(filename): + """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): + self.raw_mesh = raw_mesh + self._cache = {} + + def scale_node_positions(self, scale: float): + """Scales all node positions in the mesh.""" + ocaml.mesh_scale_node_positions(self.raw_mesh, float(scale)) + self._cache.pop('points', None) + self._cache.pop('region_volumes', None) + + def save(self, filename: Union[str, Path]): + """Saves the mesh to a file (ASCII or HDF5).""" + path = str(filename) + if path.lower().endswith('.h5'): + log.info(f"Saving to HDF5 (stub): {path}") + else: + ocaml.mesh_writefile(path, self.raw_mesh) + + def __str__(self): + pts = ocaml.mesh_nr_points(self.raw_mesh) + simps = ocaml.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 ocaml.mesh_plotinfo(self.raw_mesh) + + @property + def points(self): + if 'points' not in self._cache: + self._cache['points'] = ocaml.mesh_plotinfo_points(self.raw_mesh) + return self._cache['points'] + + @property + def simplices(self): + if 'simplices' not in self._cache: + self._cache['simplices'] = ocaml.mesh_plotinfo_simplices(self.raw_mesh) + return self._cache['simplices'] + + @property + def regions(self): + if 'regions' not in self._cache: + self._cache['regions'] = ocaml.mesh_plotinfo_simplicesregions(self.raw_mesh) + return self._cache['regions'] + + @property + def dim(self): + return ocaml.mesh_dim(self.raw_mesh) + + @property + def surfaces(self): + return ocaml.mesh_plotinfo_surfaces_and_surfacesregions(self.raw_mesh)[0] + + @property + def point_regions(self): + """Returns regions for each point.""" + if 'point_regions' not in self._cache: + self._cache['point_regions'] = ocaml.mesh_plotinfo_pointsregions(self.raw_mesh) + return self._cache['point_regions'] + + @property + def links(self): + """Returns all links (pairs of point indices).""" + if 'links' not in self._cache: + self._cache['links'] = ocaml.mesh_plotinfo_links(self.raw_mesh) + return self._cache['links'] + + @property + def region_volumes(self): + """Returns volume of each region.""" + if 'region_volumes' not in self._cache: + self._cache['region_volumes'] = ocaml.mesh_plotinfo_regionvolumes(self.raw_mesh) + return self._cache['region_volumes'] + + @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.""" + if 'periodic_indices' not in self._cache: + self._cache['periodic_indices'] = ocaml.mesh_plotinfo_periodic_points_indices(self.raw_mesh) + return self._cache['periodic_indices'] + + @property + def permutation(self): + """Returns the node permutation mapping.""" + return ocaml.mesh_get_permutation(self.raw_mesh) + + def set_vertex_distribution(self, dist): + """Sets vertex distribution.""" + ocaml.mesh_set_vertex_distribution(self.raw_mesh, dist) + +class Mesh(MeshBase): + """Class for generating a mesh from geometric objects.""" + def __init__(self, bounding_box, objects=[], a0=1.0, density="", + periodic=[], fixed_points=[], mobile_points=[], simply_points=[], + callback=None, mesh_bounding_box=False, meshing_parameters=None, + cache_name="", hints=[], **kwargs): + + if bounding_box is None: + raise ValueError("Bounding box must be provided.") + + bb = [[float(x) for x in p] for p in bounding_box] + dim = len(bb[0]) + mesh_ext = 1 if mesh_bounding_box else 0 + + if not objects and not mesh_bounding_box: + raise ValueError("No objects to mesh and bounding box meshing disabled.") + + params = meshing_parameters or get_default_meshing_parameters() + for k, v in kwargs.items(): + params[k] = v + + obj_bodies = [] + # Store points lists to allow appending later (mimicking original API) + self._fixed_points = [list(map(float, p)) for p in fixed_points] + self._mobile_points = [list(map(float, p)) for p in mobile_points] + self._simply_points = [list(map(float, p)) for p in simply_points] + + for obj in objects: + obj_bodies.append(obj.obj) + self._fixed_points.extend(obj.fixed_points) + self._mobile_points.extend(obj.mobile_points) + + periodic_floats = [1.0 if p else 0.0 for p in periodic] if periodic else [0.0] * dim + + cb_func, cb_interval = callback if callback else (lambda a,b,c: None, 1000000) + self.fun_driver = cb_func + driver = ocaml.make_mg_gendriver(cb_interval, cb_func) + mesher = ocaml.copy_mesher_defaults(ocaml.mesher_defaults) + params.pass_parameters_to_ocaml(mesher, dim) + + # Note: In the original code, mesh generation happens in __init__. + # Adding points via methods afterwards wouldn't affect the already generated mesh + # unless we regenerate or if those methods were meant for pre-generation setup. + # However, checking lib1.py, __init__ calls mesh_bodies_raw immediately. + # The methods fixed_points/mobile_points in lib1.py just append to self.fixed_points + # which seems useless after __init__ unless the user manually triggers something else. + # But we will preserve them for API compatibility. + + raw = ocaml.mesh_bodies_raw( + driver, mesher, bb[0], bb[1], mesh_ext, obj_bodies, float(a0), + density, self._fixed_points, self._mobile_points, self._simply_points, periodic_floats, + cache_name, 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: List[List[float]]): + """Adds fixed points to the mesh configuration.""" + if points: + self._fixed_points.extend(points) + + def mobile_points(self, points: List[List[float]]): + """Adds mobile points to the mesh configuration.""" + if points: + self._mobile_points.extend(points) + + def simply_points(self, points: List[List[float]]): + """Adds simply points to the mesh configuration.""" + if points: + self._simply_points.extend(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") + + # Determine format + if _is_nmesh_ascii_file(filename): + raw = ocaml.mesh_readfile(str(path), reorder, distribute) + elif _is_nmesh_hdf5_file(filename): + # load_hdf5 logic would go here + raw = ocaml.mesh_readfile(str(path), reorder, distribute) + else: + raise ValueError(f"Unknown mesh file format: {filename}") + + super().__init__(raw) + +class mesh_from_points_and_simplices(MeshBase): + """Wrapper for backward compatibility.""" + def __init__(self, points=[], simplices_indices=[], simplices_regions=[], + periodic_point_indices=[], initial=0, do_reorder=False, + do_distribute=True): + + # Adjust for 1-based indexing if initial=1 + if initial == 1: + simplices_indices = [[idx - 1 for idx in s] for s in simplices_indices] + + raw = ocaml.mesh_from_points_and_simplices( + len(points[0]) if points else 3, + [[float(x) for x in p] for p in points], + [[int(x) for x in s] for s in simplices_indices], + [int(r) for r in simplices_regions], + periodic_point_indices, 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: Union[str, Path]): + """Alias for mesh.save for backward compatibility.""" + mesh.save(filename) + +# --- Exception Aliases --- +NmeshUserError = ValueError +NmeshIOError = IOError +NmeshStandardError = RuntimeError + +# --- Geometry --- + +class MeshObject: + """Base class for geometric primitives and CSG operations.""" + def __init__(self, dim, fixed=[], mobile=[]): + self.dim = dim + self.fixed_points = fixed + self.mobile_points = mobile + self.obj: Any = None + + def shift(self, vector, system_coords=True): + self.obj = (ocaml.body_shifted_sc if system_coords else ocaml.body_shifted_bc)(self.obj, vector) + + def scale(self, factors): + self.obj = ocaml.body_scaled(self.obj, factors) + + def rotate(self, a1, a2, angle, system_coords=True): + rad = math.radians(angle) + self.obj = (ocaml.body_rotated_sc if system_coords else ocaml.body_rotated_bc)(self.obj, a1, a2, rad) + + def rotate_3d(self, axis, angle, system_coords=True): + rad = math.radians(angle) + self.obj = (ocaml.body_rotated_axis_sc if system_coords else ocaml.body_rotated_axis_bc)(self.obj, axis, rad) + + def transform(self, transformations, system_coords=True): + """Applies a list of transformation tuples.""" + for t in transformations: + name, *args = t + if name == "shift": self.shift(args[0], system_coords) + elif name == "scale": self.scale(args[0]) + elif name == "rotate": self.rotate(args[0][0], args[0][1], args[1], system_coords) + elif name == "rotate2d": self.rotate(0, 1, args[0], system_coords) + elif name == "rotate3d": self.rotate_3d(args[0], args[1], system_coords) + +class Box(MeshObject): + def __init__(self, p1, p2, transform=[], fixed=[], mobile=[], system_coords=True, use_fixed_corners=False): + dim = len(p1) + if use_fixed_corners: + fixed.extend([list(c) for c in itertools.product(*zip(p1, p2))]) + super().__init__(dim, fixed, mobile) + self.obj = ocaml.body_box([float(x) for x in p1], [float(x) for x in p2]) + self.transform(transform, system_coords) + +class Ellipsoid(MeshObject): + def __init__(self, lengths, transform=[], fixed=[], mobile=[], system_coords=True): + super().__init__(len(lengths), fixed, mobile) + self.obj = ocaml.body_ellipsoid([float(x) for x in lengths]) + self.transform(transform, system_coords) + +class Conic(MeshObject): + def __init__(self, c1, r1, c2, r2, transform=[], fixed=[], mobile=[], system_coords=True): + super().__init__(len(c1), fixed, mobile) + self.obj = ocaml.body_frustum(c1, r1, c2, r2) + self.transform(transform, system_coords) + +class Helix(MeshObject): + def __init__(self, c1, r1, c2, r2, transform=[], fixed=[], mobile=[], system_coords=True): + super().__init__(len(c1), fixed, mobile) + self.obj = ocaml.body_helix(c1, r1, c2, r2) + self.transform(transform, system_coords) + +# --- CSG --- + +def union(objects: List[MeshObject]) -> MeshObject: + if len(objects) < 2: raise ValueError("Union requires at least two objects") + res = MeshObject(objects[0].dim) + for o in objects: + res.fixed_points.extend(o.fixed_points) + res.mobile_points.extend(o.mobile_points) + res.obj = ocaml.body_union([o.obj for o in objects]) + return res + +def difference(mother: MeshObject, subtract: List[MeshObject]) -> MeshObject: + res = MeshObject(mother.dim, mother.fixed_points[:], mother.mobile_points[:]) + for o in subtract: + res.fixed_points.extend(o.fixed_points) + res.mobile_points.extend(o.mobile_points) + res.obj = ocaml.body_difference(mother.obj, [o.obj for o in subtract]) + return res + +def intersect(objects: List[MeshObject]) -> MeshObject: + if len(objects) < 2: raise ValueError("Intersection requires at least two objects") + res = MeshObject(objects[0].dim) + for o in objects: + res.fixed_points.extend(o.fixed_points) + res.mobile_points.extend(o.mobile_points) + res.obj = ocaml.body_intersection([o.obj for o in objects]) + return res + +# --- 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: List[Tuple[float, float]], discretization: float) -> Tuple: + """Generates 1D mesh components (points, simplices, regions).""" + points, simplices, regions_ids = [], [], [] + point_map = {} + + def get_idx(v): + vk = round(v, 8) + if vk not in point_map: + point_map[vk] = len(points) + points.append([float(v)]) + 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 + + # Note: original unidmesher also returned surfaces, but simplified here + # Standard format for mesh_from_points_and_simplices: + # simplices are list of point indices, regions are separate list + return points, simplices, regions_ids + +def generate_1d_mesh(regions: List[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, out=None, check=True, float_fmt=" %f"): + """ + Writes mesh data (points, simplices, surfaces) to a file in nmesh format. + mesh_data: (points, simplices, surfaces) + """ + 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" + + if out is None: + print(content) + elif isinstance(out, (str, Path)): + Path(out).write_text(content) + else: + out.write(content) diff --git a/src/nmesh/utils.py b/src/nmesh/utils.py deleted file mode 100644 index 8ab7636..0000000 --- a/src/nmesh/utils.py +++ /dev/null @@ -1,123 +0,0 @@ -import logging -from typing import List, Tuple -from pathlib import Path -from nmesh.backend import nmesh_backend as backend, RawMesh - -log = logging.getLogger(__name__) - -def _is_nmesh_ascii_file(filename): - try: - with open(filename, 'r') as f: - return f.readline().startswith("# PYFEM") - except: return False - -def outer_corners(mesh): - """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: List[Tuple[float, float]], discretization: float, - tolerance=lambda x: x) -> Tuple: - """Generates 1D mesh components (points, simplices, surfaces).""" - points = [] - simplices = [] - surfaces = [] - pnt_hash = {} - srf_hash = {} - - def add_point(y): - i = len(points) - x = tolerance(y) - if x in pnt_hash: - return pnt_hash[x] - pnt_hash[x] = i - points.append([float(x)]) - return i - - def add_surface(y, idx, body): - if y in srf_hash: - i, _, _ = srf_hash[y] - srf_hash[y] = (i + 1, body, idx) - else: - srf_hash[y] = (0, body, idx) - - nbody = 0 - for (left_x, right_x) in regions: - nbody += 1 - if left_x > right_x: left_x, right_x = right_x, left_x - width = right_x - left_x - num_pts_per_reg = max(1, abs(int(width / discretization))) - step = width / num_pts_per_reg - - last_idx = add_point(left_x) - add_surface(left_x, last_idx, nbody) - for i in range(1, num_pts_per_reg + 1): - idx = add_point(left_x + i * step) - simplices.append((nbody, [last_idx, idx])) - last_idx = idx - - add_surface(right_x, last_idx, nbody) - - for s in srf_hash: - count, body, idx = srf_hash[s] - if count == 0: - surfaces.append((body, [idx])) - - return (points, simplices, surfaces) - -def generate_1d_mesh(regions: List[Tuple[float, float]], discretization: float): - """Generates a 1D mesh.""" - from nmesh.base import mesh_from_points_and_simplices - points, simplices, surfaces = generate_1d_mesh_components(regions, discretization) - - simplices_indices = [indices for _, indices in simplices] - simplices_regions = [region for region, _ in simplices] - - return mesh_from_points_and_simplices( - points=points, - simplices_indices=simplices_indices, - simplices_regions=simplices_regions, - do_distribute=False - ) - -def write_mesh(mesh_data, out=None, check=True, float_fmt=" %f"): - """ - Writes mesh data (points, simplices, surfaces) to a file in nmesh format. - mesh_data: (points, simplices, surfaces) - """ - 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" - - if out is None: - import sys - sys.stdout.write(content) - elif isinstance(out, (str, Path)): - Path(out).write_text(content) - else: - out.write(content) - -def memory_report(tag: str): - """Reports memory usage.""" - t, vmem, rss = backend.time_vmem_rss() - log.log(15, f"Memory report: T= {t:f} VMEM= {int(vmem)} KB RSS= {int(rss)} KB {tag}") diff --git a/src/simulation/features.py b/src/simulation/features.py deleted file mode 100644 index 0b9d442..0000000 --- a/src/simulation/features.py +++ /dev/null @@ -1,71 +0,0 @@ -from typing import Any, Dict, Tuple - -class Features: - """ - A replacement for nsim.setup.get_features(). - Provides configuration management for the simulation and meshing systems. - """ - def __init__(self): - self._config: Dict[Tuple[str, str], Any] = { - ('etc', 'runid'): 'nmag_simulation', - ('etc', 'savedir'): '.', - ('nmag', 'clean'): False, - ('nmag', 'restart'): False, - # Meshing defaults - ('nmesh-2D', 'shape_force_scale'): 0.1, - ('nmesh-2D', 'volume_force_scale'): 0.0, - ('nmesh-2D', 'neigh_force_scale'): 1.0, - ('nmesh-2D', 'irrel_elem_force_scale'): 1.0, - ('nmesh-2D', 'time_step_scale'): 0.1, - ('nmesh-2D', 'thresh_add'): 1.0, - ('nmesh-2D', 'thresh_del'): 2.0, - ('nmesh-2D', 'topology_threshold'): 0.2, - ('nmesh-2D', 'tolerated_rel_move'): 0.002, - ('nmesh-2D', 'max_steps'): 1000, - ('nmesh-2D', 'initial_settling_steps'): 100, - ('nmesh-2D', 'sliver_correction'): 1.0, - ('nmesh-2D', 'smallest_volume_ratio'): 1.0, - ('nmesh-2D', 'max_relaxation'): 3.0, - ('nmesh-3D', 'shape_force_scale'): 0.1, - ('nmesh-3D', 'volume_force_scale'): 0.0, - ('nmesh-3D', 'neigh_force_scale'): 1.0, - ('nmesh-3D', 'irrel_elem_force_scale'): 1.0, - ('nmesh-3D', 'time_step_scale'): 0.1, - ('nmesh-3D', 'thresh_add'): 1.0, - ('nmesh-3D', 'thresh_del'): 2.0, - ('nmesh-3D', 'topology_threshold'): 0.2, - ('nmesh-3D', 'tolerated_rel_move'): 0.002, - ('nmesh-3D', 'max_steps'): 1000, - ('nmesh-3D', 'initial_settling_steps'): 100, - ('nmesh-3D', 'sliver_correction'): 1.0, - ('nmesh-3D', 'smallest_volume_ratio'): 1.0, - ('nmesh-3D', 'max_relaxation'): 3.0, - } - self._user_mods: Dict[str, Any] = {} - - def get(self, section: str, key: str, raw: bool = False) -> Any: - if section == 'user-modifications': - return self._user_mods.get(key) - return self._config.get((section, key)) - - def set(self, section: str, key: str, value: Any): - if section == 'user-modifications': - self._user_mods[key] = value - else: - self._config[(section, key)] = value - - def items(self, section: str): - if section == 'user-modifications': - return self._user_mods.items() - return [(k[1], v) for k, v in self._config.items() if k[0] == section] - - def add_section(self, section: str): - pass - - def from_file(self, file_path: str): - # Placeholder for loading from a config file - pass - - def from_string(self, string: str): - # Placeholder for loading from a string - pass diff --git a/src/simulation/mock_features.py b/src/simulation/mock_features.py new file mode 100644 index 0000000..af997d4 --- /dev/null +++ b/src/simulation/mock_features.py @@ -0,0 +1,19 @@ +from typing import Any + +class MockFeatures: + """ + A simple stub to replace nsim.setup.get_features(). + Currently this is used in the simulation_core class. + Provides default configuration values for the simulation. + There are no tests for this class as it is only a stub and will removed in future. + """ + def __init__(self): + self._config = { + ('etc', 'runid'): 'nmag_simulation', # Default output filename + ('etc', 'savedir'): '.', # Default output directory + ('nmag', 'clean'): False, # Don't delete old files automatically + ('nmag', 'restart'): False, # Don't try to restart from h5 + } + + def get(self, section: str, key: str, raw: bool = False) -> Any: + return self._config.get((section, key)) diff --git a/src/simulation/simulation_core.py b/src/simulation/simulation_core.py index 109e34f..46dac5b 100644 --- a/src/simulation/simulation_core.py +++ b/src/simulation/simulation_core.py @@ -26,12 +26,12 @@ known_field_quantities ) import hysteresis as hysteresis_m -from features import Features +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 = Features() +features = MockFeatures() log = logging.getLogger('nmag') diff --git a/tests/nmesh_test.py b/tests/nmesh_test.py index 432dace..ec44546 100644 --- a/tests/nmesh_test.py +++ b/tests/nmesh_test.py @@ -1,8 +1,7 @@ import unittest -import os -import numpy as np +import math +from pathlib import Path import nmesh -from nmesh.backend import nmesh_backend as backend class TestNMesh(unittest.TestCase): def test_meshing_parameters(self): @@ -47,24 +46,18 @@ def test_csg_operations(self): d = nmesh.difference(b1, [b2]) self.assertEqual(d.dim, 3) - def test_mesh_generation(self): - """Test functional Mesh generation.""" + def test_mesh_generation_stub(self): + """Test Mesh class initialization with stubs.""" bb = [[0,0,0], [1,1,1]] obj = nmesh.Box([0.2,0.2,0.2], [0.8,0.8,0.8]) - # We use a large a0 to keep it fast - m = nmesh.Mesh(bounding_box=bb, objects=[obj], a0=0.5) + m = nmesh.Mesh(bounding_box=bb, objects=[obj], a0=0.1) + self.assertEqual(str(m), "Mesh with 0 points and 0 simplices") # From stubs - # It should have some points and simplices now - self.assertGreater(len(m.points), 0) - self.assertGreater(len(m.simplices), 0) - self.assertGreater(len(m.regions), 0) - - # Check if points are within bounding box - for p in m.points: - for i in range(3): - self.assertGreaterEqual(p[i], 0.0 - 1e-9) - self.assertLessEqual(p[i], 1.0 + 1e-9) + # Test properties (should return empty lists from stubs) + self.assertEqual(m.points, []) + self.assertEqual(m.simplices, []) + self.assertEqual(m.regions, []) def test_1d_mesh_generation(self): """Test 1D mesh generation logic.""" @@ -81,56 +74,29 @@ def test_1d_mesh_generation(self): def test_outer_corners(self): """Test outer_corners utility.""" - from nmesh.base import MeshBase - class MockMesh(MeshBase): + class MockMesh(nmesh.MeshBase): @property def points(self): return [[0,0], [1,2], [-1,1]] - m = MockMesh(None) + m = MockMesh("raw") min_c, max_corner = nmesh.outer_corners(m) self.assertEqual(min_c, [-1, 0]) self.assertEqual(max_corner, [1, 2]) - def test_write_read_mesh(self): - """Test writing and reading mesh back.""" - points = [[0.0, 0.0], [1.0, 1.0], [0.0, 1.0], [1.0, 0.0]] - simplices = [(1, [0, 1, 2]), (1, [1, 2, 3])] - surfaces = [] + def test_write_mesh(self): + """Test write_mesh utility.""" + points = [[0.0, 0.0], [1.0, 1.0]] + simplices = [(1, [0, 1])] + surfaces = [(1, [0])] data = (points, simplices, surfaces) - test_file = "test_temp.nmesh" - nmesh.write_mesh(data, out=test_file) - - try: - m = nmesh.load(test_file) - self.assertEqual(len(m.points), 4) - self.assertEqual(len(m.simplices), 2) - self.assertEqual(m.dim, 2) - finally: - if os.path.exists(test_file): - os.remove(test_file) - - def test_periodicity_helpers(self): - """Test internal periodicity helpers.""" - - # Test _all_combinations - combs = backend._all_combinations(2) - self.assertEqual(len(combs), 4) - - # Test _periodic_directions - masks = backend._periodic_directions([True, False, True]) - # Should return masks for all sub-entities (edges, faces) - self.assertGreater(len(masks), 0) - - def test_gradient(self): - """Test numeric gradient calculation.""" - def f(x): return x[0]**2 + x[1]**2 - - grad = backend.symm_grad(f, [1.0, 2.0]) - # Gradient of x^2 + y^2 is [2x, 2y] -> [2, 4] - self.assertAlmostEqual(grad[0], 2.0, places=5) - self.assertAlmostEqual(grad[1], 4.0, places=5) + import io + out = io.StringIO() + nmesh.write_mesh(data, out=out) + content = out.getvalue() + self.assertIn("# PYFEM mesh file version 1.0", content) + self.assertIn("nodes = 2", content) if __name__ == '__main__': unittest.main() From 483ba4399e3ae23b142aa63b7db98022dbcb653a Mon Sep 17 00:00:00 2001 From: Emanuel Pituch <32016786+epituch@users.noreply.github.com> Date: Tue, 10 Mar 2026 21:17:22 -0700 Subject: [PATCH 04/14] Move features to own folder --- src/mock_features/__init__.py | 1 + src/mock_features/mock_features.py | 50 ++++++++++++++++++++++++++++++ src/nmesh/nmesh.py | 30 ++---------------- src/simulation/mock_features.py | 19 ------------ 4 files changed, 54 insertions(+), 46 deletions(-) create mode 100644 src/mock_features/__init__.py create mode 100644 src/mock_features/mock_features.py delete mode 100644 src/simulation/mock_features.py diff --git a/src/mock_features/__init__.py b/src/mock_features/__init__.py new file mode 100644 index 0000000..fd5d7d5 --- /dev/null +++ b/src/mock_features/__init__.py @@ -0,0 +1 @@ +from .mock_features import * \ No newline at end of file diff --git a/src/mock_features/mock_features.py b/src/mock_features/mock_features.py new file mode 100644 index 0000000..87545d0 --- /dev/null +++ b/src/mock_features/mock_features.py @@ -0,0 +1,50 @@ +from typing import Any, Dict + +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 + } + } + + def from_file(self, file_path): + """Stub for loading features from a file.""" + pass + + def from_string(self, string): + """Stub for loading features from a string.""" + pass + + 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/nmesh/nmesh.py b/src/nmesh/nmesh.py index 02c9b83..ce75fc8 100644 --- a/src/nmesh/nmesh.py +++ b/src/nmesh/nmesh.py @@ -3,6 +3,7 @@ from typing import List, Tuple, Optional, Any, Union from pathlib import Path import itertools +from mock_features import MockFeatures # Setup logging log = logging.getLogger(__name__) @@ -85,35 +86,10 @@ def memory_report(tag: str): # --- Configuration --- -class FeaturesStub: - """Stub for nsim.features.Features.""" - def __init__(self, local=True): - self._data = {} - - def from_file(self, file_path): pass - def from_string(self, string): pass - def add_section(self, section): - if section not in self._data: - self._data[section] = {} - - def get(self, section, name, raw=False): - return self._data.get(section, {}).get(name) - - def set(self, section, name, value): - if section not in self._data: - self._data[section] = {} - self._data[section][name] = value - - def items(self, section): - return self._data.get(section, {}).items() - - def to_string(self): - return str(self._data) - -class MeshingParameters(FeaturesStub): +class MeshingParameters(MockFeatures): """Parameters for the meshing algorithm, supporting multiple dimensions.""" def __init__(self, string=None, file=None): - super().__init__(local=True) + super().__init__() self.dim = None if file: self.from_file(file) if string: self.from_string(string) diff --git a/src/simulation/mock_features.py b/src/simulation/mock_features.py deleted file mode 100644 index af997d4..0000000 --- a/src/simulation/mock_features.py +++ /dev/null @@ -1,19 +0,0 @@ -from typing import Any - -class MockFeatures: - """ - A simple stub to replace nsim.setup.get_features(). - Currently this is used in the simulation_core class. - Provides default configuration values for the simulation. - There are no tests for this class as it is only a stub and will removed in future. - """ - def __init__(self): - self._config = { - ('etc', 'runid'): 'nmag_simulation', # Default output filename - ('etc', 'savedir'): '.', # Default output directory - ('nmag', 'clean'): False, # Don't delete old files automatically - ('nmag', 'restart'): False, # Don't try to restart from h5 - } - - def get(self, section: str, key: str, raw: bool = False) -> Any: - return self._config.get((section, key)) From eca7387e0c5038ce1f8ad9e643e558d5461b83f3 Mon Sep 17 00:00:00 2001 From: Emanuel Pituch <32016786+epituch@users.noreply.github.com> Date: Tue, 10 Mar 2026 21:36:11 -0700 Subject: [PATCH 05/14] Add migration plan --- docs/nmesh-ocaml-to-python3-migration-plan.md | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 docs/nmesh-ocaml-to-python3-migration-plan.md diff --git a/docs/nmesh-ocaml-to-python3-migration-plan.md b/docs/nmesh-ocaml-to-python3-migration-plan.md new file mode 100644 index 0000000..aa1ff47 --- /dev/null +++ b/docs/nmesh-ocaml-to-python3-migration-plan.md @@ -0,0 +1,65 @@ +### NMesh OCaml to Python3 Migration Plan (Incremental, Section-by-Section) + +**Summary** +- Migrate [nmesh.py](/mnt/g/Code/nmag/nmag-python-3/src/nmesh/nmesh.py) from OCaml-backed calls to pure Python by porting logic from [mesh.ml](/mnt/g/Code/nmag/nmag-src/src/mesh.ml) and [snippets.ml](/mnt/g/Code/nmag/nmag-src/src/snippets.ml). +- Keep modern API only. +- Deliver full parity (not a reduced subset), but in controlled sections with explicit exit criteria. +- Use `scipy`, `h5py`, and `pymetis` as required dependencies. + +**Public Interfaces / Type Changes** +- Keep current Python entrypoints in `nmesh.py` (`MeshBase`, `Mesh`, `MeshFromFile`, `MeshObject`, primitives, CSG, `load/save`, `mesh_from_points_and_simplices`). +- Replace OCaml-pill-style `raw_mesh` internals with a Python `RawMesh` data model (points, simplices, regions, neighbors, periodic groups, permutation, distribution). +- Remove `OCamlStub` after parity is verified; do not add extra legacy-name compatibility surface. + +**Implementation Sections** +1. Section 1: Backend seam + core data model +Scope: Introduce internal pure-Python backend interfaces in `nmesh.py`. +Implement: `RawMesh`, `Body`, `MesherDefaults`, `Driver` abstractions; route all current `ocaml.*` call sites through backend methods with matching signatures. +Exit criteria: `nmesh.py` control flow no longer depends on an imported OCaml module. + +2. Section 2: Port snippets foundations used by meshing +Scope: Port utility primitives needed by geometry and mesher internals. +Implement: array/index/filter helpers, `mx_mult`, `mx_x_vec`, determinant/inverse wrappers, list intersections, and `time_vmem_rss` equivalent behavior. +Exit criteria: utility tests pass and numeric behavior matches OCaml reference tolerance. + +3. Section 3: Port body geometry + CSG +Scope: Replace body primitives/transforms currently delegated to OCaml. +Implement: `bc_box`, `bc_ellipsoid`, `bc_frustum`, `bc_helix`, affine transform composition, union/difference/intersection, shifted/scaled/rotated variants. +Exit criteria: geometry/CSG tests validate inside/outside logic, transforms, and dimension consistency. + +4. Section 4: Port mesher defaults + driver semantics +Scope: Move mesher parameter and callback behavior fully into Python. +Implement: Python defaults mirroring `opt_mesher_defaults`; all setter mappings used by `MeshingParameters`; callback cadence/payload logic from `make_mg_gendriver`. +Exit criteria: setter and callback tests reproduce expected legacy behavior. + +5. Section 5: Port core meshing pipeline +Scope: Implement pure-Python equivalent of `mesh_bodies_raw` + `mesh_it` workflow. +Implement: fem-geometry from bodies/hints, fixed/mobile/simply point handling, periodic bookkeeping, iterative relax/retriangulate loop, stop conditions, connectivity growth. +Exit criteria: deterministic mesh generation succeeds for 1D/2D/3D reference scenarios with fixed seeds. + +6. Section 6: Port mesh query/extraction APIs +Scope: Replace all `mesh_plotinfo*`, counts/dim queries, regions, links, surfaces, volumes, permutation/distribution accessors. +Implement: `mesh_nr_points`, `mesh_nr_simplices`, `mesh_dim`, `mesh_plotinfo*`, `mesh_get_permutation`, `mesh_set_vertex_distribution`, and cache invalidation in `MeshBase`. +Exit criteria: accessor tests pass, including cache behavior and periodic data exposure. + +7. Section 7: Port I/O + constructors +Scope: Complete constructor and file parity without OCaml. +Implement: ASCII `# PYFEM` read/write parity, `mesh_from_points_and_simplices`, `MeshFromFile`, `load/save`; HDF5 via `h5py` using `/mesh/{points,simplices,simplicesregions,permutation,periodicpointindices}` compatibility schema. +Exit criteria: ASCII/HDF5 roundtrip tests pass and preserve permutation/periodic info. + +8. Section 8: Reorder/distribute parity + cleanup +Scope: Final parity for reorder/distribute behavior and remove migration scaffolding. +Implement: reordering strategy via `scipy.sparse.csgraph`; partitioning via `pymetis`; finalize `do_reorder/do_distribute` semantics and delete stub-only branches/comments. +Exit criteria: distribution/permutation invariants pass; `OCamlStub` removed. + +**Test Plan** +1. Build OCaml parity fixtures for canonical geometries (single body, CSG, periodic, mesh-from-points). +2. Add section-gated tests for utilities, geometry/CSG, defaults/driver, meshing, query APIs, ASCII I/O, HDF5 I/O, reorder/distribute. +3. Use deterministic seeds and tolerance-based numeric assertions for coordinates/volumes. +4. Run full CI with required native deps and fail on parity regressions. + +**Assumptions / Defaults Locked** +- Pure Python only target. +- Modern API only. +- Full parity required (not staged down to serial-only). +- New dependencies are allowed (`scipy`, `h5py`, `pymetis`). From cfb4eab38d3b569435e7575303c8d46828541fbb Mon Sep 17 00:00:00 2001 From: Emanuel Pituch <32016786+epituch@users.noreply.github.com> Date: Tue, 10 Mar 2026 21:38:16 -0700 Subject: [PATCH 06/14] Split up plan a bit more --- docs/nmesh-ocaml-to-python3-migration-plan.md | 187 ++++++++++++++---- 1 file changed, 144 insertions(+), 43 deletions(-) diff --git a/docs/nmesh-ocaml-to-python3-migration-plan.md b/docs/nmesh-ocaml-to-python3-migration-plan.md index aa1ff47..90cbe52 100644 --- a/docs/nmesh-ocaml-to-python3-migration-plan.md +++ b/docs/nmesh-ocaml-to-python3-migration-plan.md @@ -11,52 +11,153 @@ - Replace OCaml-pill-style `raw_mesh` internals with a Python `RawMesh` data model (points, simplices, regions, neighbors, periodic groups, permutation, distribution). - Remove `OCamlStub` after parity is verified; do not add extra legacy-name compatibility surface. -**Implementation Sections** -1. Section 1: Backend seam + core data model -Scope: Introduce internal pure-Python backend interfaces in `nmesh.py`. -Implement: `RawMesh`, `Body`, `MesherDefaults`, `Driver` abstractions; route all current `ocaml.*` call sites through backend methods with matching signatures. -Exit criteria: `nmesh.py` control flow no longer depends on an imported OCaml module. - -2. Section 2: Port snippets foundations used by meshing -Scope: Port utility primitives needed by geometry and mesher internals. -Implement: array/index/filter helpers, `mx_mult`, `mx_x_vec`, determinant/inverse wrappers, list intersections, and `time_vmem_rss` equivalent behavior. -Exit criteria: utility tests pass and numeric behavior matches OCaml reference tolerance. - -3. Section 3: Port body geometry + CSG -Scope: Replace body primitives/transforms currently delegated to OCaml. -Implement: `bc_box`, `bc_ellipsoid`, `bc_frustum`, `bc_helix`, affine transform composition, union/difference/intersection, shifted/scaled/rotated variants. -Exit criteria: geometry/CSG tests validate inside/outside logic, transforms, and dimension consistency. - -4. Section 4: Port mesher defaults + driver semantics -Scope: Move mesher parameter and callback behavior fully into Python. -Implement: Python defaults mirroring `opt_mesher_defaults`; all setter mappings used by `MeshingParameters`; callback cadence/payload logic from `make_mg_gendriver`. -Exit criteria: setter and callback tests reproduce expected legacy behavior. - -5. Section 5: Port core meshing pipeline -Scope: Implement pure-Python equivalent of `mesh_bodies_raw` + `mesh_it` workflow. -Implement: fem-geometry from bodies/hints, fixed/mobile/simply point handling, periodic bookkeeping, iterative relax/retriangulate loop, stop conditions, connectivity growth. -Exit criteria: deterministic mesh generation succeeds for 1D/2D/3D reference scenarios with fixed seeds. - -6. Section 6: Port mesh query/extraction APIs -Scope: Replace all `mesh_plotinfo*`, counts/dim queries, regions, links, surfaces, volumes, permutation/distribution accessors. -Implement: `mesh_nr_points`, `mesh_nr_simplices`, `mesh_dim`, `mesh_plotinfo*`, `mesh_get_permutation`, `mesh_set_vertex_distribution`, and cache invalidation in `MeshBase`. -Exit criteria: accessor tests pass, including cache behavior and periodic data exposure. - -7. Section 7: Port I/O + constructors -Scope: Complete constructor and file parity without OCaml. -Implement: ASCII `# PYFEM` read/write parity, `mesh_from_points_and_simplices`, `MeshFromFile`, `load/save`; HDF5 via `h5py` using `/mesh/{points,simplices,simplicesregions,permutation,periodicpointindices}` compatibility schema. -Exit criteria: ASCII/HDF5 roundtrip tests pass and preserve permutation/periodic info. - -8. Section 8: Reorder/distribute parity + cleanup -Scope: Final parity for reorder/distribute behavior and remove migration scaffolding. -Implement: reordering strategy via `scipy.sparse.csgraph`; partitioning via `pymetis`; finalize `do_reorder/do_distribute` semantics and delete stub-only branches/comments. -Exit criteria: distribution/permutation invariants pass; `OCamlStub` removed. +## Section 1: Backend seam + core data model + +### 1.1 Goal +Introduce internal pure-Python backend interfaces in `nmesh.py` so all `ocaml.*` calls are routed through a Python backend object. + +### 1.2 Work packages +1. Define backend protocol for mesh ops, body ops, defaults, and driver creation. +2. Implement `RawMesh`, `Body`, `MesherDefaults`, and `Driver` Python types. +3. Replace direct `ocaml.*` usage in classes/functions with backend calls. +4. Keep method signatures stable at the `nmesh.py` public boundary. + +### 1.3 Acceptance +1. `nmesh.py` imports and runs without an OCaml module. +2. All previous `ocaml.*` call sites are backend-routed. +3. Public constructor signatures remain unchanged. + +## Section 2: Port snippets foundations used by meshing + +### 2.1 Goal +Port utility primitives from `snippets.ml` required by geometry and mesher internals. + +### 2.2 Work packages +1. Port array/list helpers (`filter`, `position`, `one_shorter`, intersections, sorting checks). +2. Port numeric helpers (`mx_mult`, `mx_x_vec`, determinant/inverse wrappers). +3. Port timing/memory reporting equivalent for `time_vmem_rss`. +4. Add unit tests for utility parity and numerical tolerance behavior. + +### 2.3 Acceptance +1. Utility tests pass with deterministic inputs. +2. Numerical helpers match OCaml reference outputs within tolerance. +3. No utility dependency on OCaml runtime remains. + +## Section 3: Port body geometry + CSG + +### 3.1 Goal +Replace OCaml body primitive and transformation logic with Python equivalents. + +### 3.2 Work packages +1. Port primitive boundary-condition builders: `bc_box`, `bc_ellipsoid`, `bc_frustum`, `bc_helix`. +2. Port affine transform operations and composition order semantics. +3. Port CSG operations: `union`, `difference`, `intersection`. +4. Keep shifted/scaled/rotated variants behaviorally compatible with current `nmesh.py` API. + +### 3.3 Acceptance +1. Geometry tests validate in/out classification for representative points. +2. Transform order tests validate equivalence to OCaml semantics. +3. CSG tests confirm region and object composition behavior. + +## Section 4: Port mesher defaults + driver semantics + +### 4.1 Goal +Move mesher parameter behavior and callback driver semantics fully to Python. + +### 4.2 Work packages +1. Mirror `opt_mesher_defaults` values and field structure in Python. +2. Implement full setter mapping used by `MeshingParameters.pass_parameters_to_ocaml`. +3. Port callback cadence and payload flow used by `make_mg_gendriver`. +4. Preserve current modern API while internalizing behavior. + +### 4.3 Acceptance +1. Setter-based tests reproduce expected defaults/overrides. +2. Callback interval tests verify invocation cadence and payload shape. +3. No dependency on OCaml mesher-default objects remains. + +## Section 5: Port core meshing pipeline + +### 5.1 Goal +Implement pure-Python equivalent of `mesh_bodies_raw` and `mesh_it` flow. + +### 5.2 Work packages +1. Port `fem_geometry_from_bodies` behavior for bodies, hints, and density handling. +2. Port fixed/mobile/simply point filtering and initial point preparation. +3. Port periodic point bookkeeping and periodic index mapping behavior. +4. Port iterative relax/retriangulate loop and stop conditions. +5. Port connectivity/bookkeeping growth path needed by downstream plotinfo accessors. + +### 5.3 Acceptance +1. Deterministic 1D/2D/3D meshing succeeds with fixed RNG seeds. +2. Periodic and hint-driven scenarios complete with valid topology. +3. Mesh-generation failure modes raise consistent Python exceptions. + +## Section 6: Port mesh query/extraction APIs + +### 6.1 Goal +Replace `mesh_plotinfo*` and related mesh query accessors with pure Python implementations. + +### 6.2 Work packages +1. Implement `mesh_nr_points`, `mesh_nr_simplices`, `mesh_dim`. +2. Implement `mesh_plotinfo*` family (points, simplices, regions, links, surfaces, periodic indices, full plotinfo bundle). +3. Implement `mesh_get_permutation` and `mesh_set_vertex_distribution`. +4. Preserve and validate `MeshBase` cache invalidation behavior. + +### 6.3 Acceptance +1. Accessor tests pass for populated meshes and edge cases. +2. Cache tests validate stale-data invalidation when nodes are rescaled/updated. +3. Returned structures match expected shape/type contracts. + +## Section 7: Port I/O + constructors + +### 7.1 Goal +Complete pure-Python constructor and file I/O parity for ASCII and HDF5 paths. + +### 7.2 Work packages +1. Port ASCII `# PYFEM` reader/writer rules, including validation and orientation-sensitive output behavior. +2. Implement `mesh_from_points_and_simplices` constructor parity and index handling. +3. Implement `MeshFromFile`, `load`, `save` without OCaml backend. +4. Implement HDF5 compatibility via `h5py` for `/mesh/{points,simplices,simplicesregions,permutation,periodicpointindices}`. + +### 7.3 Acceptance +1. ASCII roundtrip tests preserve topology, region ids, and periodic groups. +2. HDF5 roundtrip tests preserve permutation and periodic data. +3. Constructor tests validate initial index modes and error handling. + +## Section 8: Reorder/distribute parity + cleanup + +### 8.1 Goal +Finish reorder/distribution behavior and remove migration scaffolding. + +### 8.2 Work packages +1. Implement reorder strategy using `scipy.sparse.csgraph`-based flow. +2. Implement partitioning/distribution flow using `pymetis`. +3. Validate `do_reorder` and `do_distribute` semantics across load/generation paths. +4. Remove `OCamlStub` and any obsolete shim code/comments. + +### 8.3 Acceptance +1. Distribution/permutation invariants are validated in tests. +2. Reorder/distribute behavior is consistent between constructors and file-load paths. +3. `nmesh.py` contains no OCaml stub backend path. **Test Plan** + +### T1 Fixtures 1. Build OCaml parity fixtures for canonical geometries (single body, CSG, periodic, mesh-from-points). -2. Add section-gated tests for utilities, geometry/CSG, defaults/driver, meshing, query APIs, ASCII I/O, HDF5 I/O, reorder/distribute. -3. Use deterministic seeds and tolerance-based numeric assertions for coordinates/volumes. -4. Run full CI with required native deps and fail on parity regressions. +2. Store deterministic fixture metadata (seed, dimensions, expected invariants). + +### T2 Section-gated tests +1. Utilities/numerics tests for snippet ports. +2. Geometry/CSG tests for primitives, transforms, and CSG. +3. Defaults/driver tests for setter and callback behavior. +4. Core meshing tests for deterministic output and convergence behavior. +5. Query/extraction tests for `mesh_plotinfo*` and cache semantics. +6. ASCII/HDF5 I/O roundtrip tests. +7. Reorder/distribute tests with permutation integrity checks. + +### T3 CI policy +1. Run full CI with required native dependencies installed. +2. Fail build on parity regression or deterministic fixture drift. **Assumptions / Defaults Locked** - Pure Python only target. From 975b0bbc53cc8110f25fb0f23e40880bd23cb924 Mon Sep 17 00:00:00 2001 From: Emanuel Pituch <32016786+epituch@users.noreply.github.com> Date: Tue, 17 Mar 2026 18:42:31 -0700 Subject: [PATCH 07/14] Added v2 migration plan --- ...mesh-ocaml-to-python3-migration-plan-v2.md | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 docs/nmesh-ocaml-to-python3-migration-plan-v2.md diff --git a/docs/nmesh-ocaml-to-python3-migration-plan-v2.md b/docs/nmesh-ocaml-to-python3-migration-plan-v2.md new file mode 100644 index 0000000..5757505 --- /dev/null +++ b/docs/nmesh-ocaml-to-python3-migration-plan-v2.md @@ -0,0 +1,138 @@ +# Refined NMesh OCaml to Python 3 Migration Plan (V3 - Comprehensive Engineering & Management) + +This plan details the migration of the `nmesh` library from a hybrid Python 2 / OCaml implementation to a pure Python 3 implementation, combining technical depth with clear work packages and acceptance criteria. + +## 1. Objectives +- **Pure Python 3:** Eliminate the OCaml dependency and the `ocaml` Python module. +- **NumPy-First Architecture:** Mandate NumPy as the foundation for all internal data storage and linear algebra. +- **Performance Parity:** Use vectorization and optimized libraries (`scipy`, `numba`) to ensure performance parity with OCaml. +- **API Parity:** Maintain backward compatibility with the existing `nmesh` Python API. +- **Modular Design:** Split the monolithic `nmesh.py` into maintainable sub-modules. +- **Incremental Verification:** Mandate unit tests for every module to ensure parity with the legacy implementation. + +## 2. Proposed Module Structure (`nmesh/` directory) +The `nmesh` package will be reorganized as follows: + +```text +nmesh/ +├── __init__.py # Exposed public API (Mesh, MeshFromFile, load, save, etc.) +├── core.py # RawMesh data model and core state management +├── geometry/ +│ ├── __init__.py # Geometry primitives (Box, Ellipsoid, etc.) +│ ├── csg.py # CSG operations (union, difference, intersect) +│ └── transform.py # Affine transformations and matrix logic +├── mesher/ +│ ├── __init__.py # High-level mesh_it_work coordination +│ ├── defaults.py # Mesher parameter management (MeshingParameters) +│ ├── driver.py # Callback driver semantics (make_mg_gendriver) +│ ├── forces.py # Physics/Force calculations (Shape, Volume, etc.) +│ ├── relaxation.py # Iterative relaxation loop and JIT-optimized logic +│ └── periodic.py # Periodic boundary condition bookkeeping +├── io/ +│ ├── __init__.py # Unified load/save interface +│ ├── ascii.py # # PYFEM 1.0 reader/writer +│ └── h5.py # h5py-based HDF5 operations +└── utils.py # Math/NumPy snippets and general helpers +``` + +## 3. Core Data Model (`RawMesh`) +| Field | Shape | Type | Description | +|-------|-------|------|-------------| +| `points` | `(N, dim)` | `float64` | Cartesian coordinates of all nodes. | +| `simplices` | `(M, dim+1)` | `int32` | Indices into `points` forming the mesh elements. | +| `regions` | `(M,)` | `int32` | Region ID for each simplex. | +| `point_regions` | `(N, K)` | `int32` | Sparse mapping or ragged list of regions each point belongs to. | +| `periodic_indices`| `(P, 2)` | `int32` | Pairs of indices representing periodic node equivalences. | +| `permutation` | `(N,)` | `int32` | Map from original input indices to current reordered indices. | + +--- + +## 4. Migration Sections + +### Section 1: Foundation & Utilities (Porting `snippets.ml`) +**Goal:** Port utility primitives and math required by geometry and mesher internals. +- **Work Packages:** + 1. Port array/list helpers (`filter`, `position`, `one_shorter`) using NumPy vectorization. + 2. Port numeric helpers (determinant, inverse, cross product) using `numpy.linalg`. + 3. Port timing/memory reporting equivalent for `time_vmem_rss`. +- **Acceptance:** + 1. Utility tests pass with deterministic inputs in `tests/nmesh/test_utils.py`. + 2. Numerical helpers match OCaml reference outputs within `1e-9` tolerance. + +### Section 2: Mesher Defaults & Driver (Porting `mesh.ml` / `lib1.py`) +**Goal:** Move mesher parameter behavior and callback driver semantics fully to Python. +- **Work Packages:** + 1. Mirror `opt_mesher_defaults` values and field structure in `nmesh.mesher.defaults`. + 2. Implement full `MeshingParameters` setter mapping in pure Python. + 3. Port callback cadence and payload flow used by `make_mg_gendriver`. +- **Acceptance:** + 1. Setter-based tests reproduce expected overrides in `tests/nmesh/test_defaults.py`. + 2. Callback interval tests verify invocation cadence and payload shape. + +### Section 3: Geometry & CSG (Porting `mesh.ml` / `lib1.py`) +**Goal:** Replace OCaml body primitive and transformation logic with Python equivalents. +- **Work Packages:** + 1. Port primitive boundary-condition builders: `bc_box`, `bc_ellipsoid`, `bc_frustum`, `bc_helix`. + 2. Transition to NumPy-compatible Signed Distance Functions (SDFs). + 3. Implement affine transform logic (`shift`, `scale`, `rotate`) using matrix multiplication. + 4. Implement CSG operations: `union`, `difference`, `intersection`. +- **Acceptance:** + 1. Geometry tests validate in/out classification in `tests/nmesh/test_geometry.py`. + 2. Transform order tests validate equivalence to OCaml semantics. + +### Section 4: Core Meshing Engine (Porting `mesh.ml`) +**Goal:** Implement pure-Python equivalent of `mesh_bodies_raw` and `mesh_it_work`. +- **Work Packages:** + 1. Port `fem_geometry_from_bodies` for bodies, hints, and density handling. + 2. Implement point preparation: fixed/mobile/simply filtering and initial distribution. + 3. Port periodic point bookkeeping and index mapping. + 4. Port **Iterative Relaxation Loop**: + - Shape, Volume, Neighbor, and Irrelevant Element force calculations. + - JIT-optimized smoothing loop using `numba`. + - Insertion/Deletion of points (Voronoi/Delaunay criteria). +- **Acceptance:** + 1. Deterministic meshing succeeds with fixed RNG seeds in `tests/nmesh/test_integration.py`. + 2. Periodic and hint-driven scenarios complete with valid topology. + +### Section 5: Query & Extraction APIs (Porting `mesh.ml`) +**Goal:** Replace `mesh_plotinfo*` and related mesh query accessors. +- **Work Packages:** + 1. Implement `points`, `simplices`, `regions`, `links`, and `surfaces` accessors. + 2. Use `scipy.spatial.Delaunay` for adjacency and surface extraction. + 3. Implement cache invalidation logic for `MeshBase`. +- **Acceptance:** + 1. Accessor tests pass for populated meshes and edge cases. + 2. Cache tests validate stale-data invalidation when nodes are scaled. + +### Section 6: I/O & Constructors (Porting `lib1.py`) +**Goal:** Complete pure-Python constructor and file I/O parity. +- **Work Packages:** + 1. Port ASCII `# PYFEM` reader/writer rules. + 2. Implement `MeshFromFile`, `load`, `save` without OCaml backend. + 3. Implement HDF5 compatibility via `h5py` for all core datasets. +- **Acceptance:** + 1. ASCII and HDF5 round-trip tests preserve topology and metadata in `tests/nmesh/test_io.py`. + +### Section 7: Optimization & Distribution (Porting `mesh.ml`) +**Goal:** Finish reorder/distribution behavior using Metis. +- **Work Packages:** + 1. Implement reorder strategy using `scipy.sparse.csgraph.reverse_cuthill_mckee`. + 2. Implement partitioning/distribution flow using `pymetis`. +- **Acceptance:** + 1. Distribution invariants and connectivity bandwidth reductions are validated. + +--- + +## 5. Performance Strategy +1. **Vectorization:** Avoid Python loops for force calculations; use NumPy broadcasting. +2. **JIT Compilation:** Apply `@numba.njit` to the relaxation loop and hot math functions. +3. **Sparse Structures:** Use `scipy.sparse` for large connectivity matrices. + +## 6. Technical Mapping Table +| OCaml Symbol | Python / NumPy / SciPy Equivalent | +|--------------|-----------------------------------| +| `Qhull.delaunay` | `scipy.spatial.Delaunay` | +| `determinant` | `numpy.linalg.det` | +| `Mt19937` | `numpy.random.Generator(PCG64)` | +| `Metis` / `Parmetis` | `pymetis.part_mesh` | +| `PyTables` | `h5py` | From fd5a74b45de5f7a846f5013b4cb82ba17eded229 Mon Sep 17 00:00:00 2001 From: Emanuel Pituch <32016786+epituch@users.noreply.github.com> Date: Tue, 17 Mar 2026 19:13:08 -0700 Subject: [PATCH 08/14] Part 1 of migration plan --- src/nmesh/__init__.py | 1 + src/nmesh/nmesh.py | 7 ++- src/nmesh/utils.py | 105 ++++++++++++++++++++++++++++++++++++++ tests/nmesh/test_utils.py | 55 ++++++++++++++++++++ 4 files changed, 164 insertions(+), 4 deletions(-) create mode 100644 src/nmesh/utils.py create mode 100644 tests/nmesh/test_utils.py diff --git a/src/nmesh/__init__.py b/src/nmesh/__init__.py index 671625b..836fe03 100644 --- a/src/nmesh/__init__.py +++ b/src/nmesh/__init__.py @@ -1 +1,2 @@ from .nmesh import * +from . import utils diff --git a/src/nmesh/nmesh.py b/src/nmesh/nmesh.py index ce75fc8..d3ed2ea 100644 --- a/src/nmesh/nmesh.py +++ b/src/nmesh/nmesh.py @@ -4,6 +4,7 @@ from pathlib import Path import itertools from mock_features import MockFeatures +from . import utils # Setup logging log = logging.getLogger(__name__) @@ -12,8 +13,6 @@ class OCamlStub: """Stub for the OCaml backend interface.""" - def time_vmem_rss(self): - return 0.0, 0, 0 # Mesher defaults setters def mesher_defaults_set_shape_force_scale(self, mesher, scale): pass @@ -80,8 +79,8 @@ def mesher_defaults(self): return "STUB_DEFAULTS" ocaml = OCamlStub() def memory_report(tag: str): - """Reports memory usage via OCaml backend.""" - t, vmem, rss = ocaml.time_vmem_rss() + """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 --- diff --git a/src/nmesh/utils.py b/src/nmesh/utils.py new file mode 100644 index 0000000..0b230bc --- /dev/null +++ b/src/nmesh/utils.py @@ -0,0 +1,105 @@ +import numpy as np +import time +import os +import re +import logging + +log = logging.getLogger(__name__) + +# --- Timing and Memory Utilities --- + +_time_zero = None + +def time_passed(): + """Returns elapsed time since the first call to this function.""" + global _time_zero + if _time_zero is None: + _time_zero = time.time() + return 0.0 + return time.time() - _time_zero + +def memstats(self_status_file="/proc/self/status"): + """Reads VmSize and VmRSS from /proc/self/status (Linux).""" + vmsize_vmrss = [0.0, 0.0] + if not os.path.exists(self_status_file): + return vmsize_vmrss + + # 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: + content = fd.read() + found = 0 + for match in re_pattern.finditer(content): + key = match.group(1) + value = float(match.group(2)) + if key == "VmSize": + vmsize_vmrss[0] = value + else: + vmsize_vmrss[1] = value + found += 1 + if found >= 2: + break + except Exception as e: + log.debug(f"Failed to read memstats: {e}") + pass + return vmsize_vmrss + +def time_vmem_rss(): + """Returns (elapsed_time, vmem, rss) where memory is in KB.""" + t = time_passed() + mem = memstats() + return t, mem[0], mem[1] + +# --- Array and List Helpers (NumPy Vectorized) --- + +def array_filter(p, arr): + """Port of Snippets.array_filter using NumPy boolean indexing.""" + arr = np.asanyarray(arr) + # Apply predicate to create a boolean mask + mask = np.vectorize(p)(arr) + return arr[mask] + +def array_position(x, arr, start=0): + """Port of Snippets.array_position.""" + arr = np.asanyarray(arr) + sub_arr = arr[start:] + indices = np.where(sub_arr == x)[0] + if len(indices) > 0: + return int(indices[0] + start) + return -1 + +def array_position_if(p, arr, start=0): + """Port of Snippets.array_position_if.""" + arr = np.asanyarray(arr) + sub_arr = arr[start:] + mask = np.vectorize(p)(sub_arr) + indices = np.where(mask)[0] + if len(indices) > 0: + return int(indices[0] + start) + return -1 + +def array_one_shorter(arr, pos): + """Port of Snippets.array_one_shorter.""" + return np.delete(np.asanyarray(arr), pos) + +# --- Numerical Helpers --- + +def determinant(mx): + """Port of Snippets.determinant using numpy.linalg.det.""" + return float(np.linalg.det(np.asanyarray(mx))) + +def inverse(mx): + """Port of Snippets.compute_inv_on_scratchpads using numpy.linalg.inv.""" + return np.linalg.inv(np.asanyarray(mx)) + +def det_and_inv(mx): + """Port of Snippets.det_and_inv.""" + arr = np.asanyarray(mx) + det = np.linalg.det(arr) + inv = np.linalg.inv(arr) + return float(det), inv + +def cross_product_3d(v1, v2): + """Port of Snippets.cross_product_3d.""" + return np.cross(np.asanyarray(v1), np.asanyarray(v2)) diff --git a/tests/nmesh/test_utils.py b/tests/nmesh/test_utils.py new file mode 100644 index 0000000..ede0127 --- /dev/null +++ b/tests/nmesh/test_utils.py @@ -0,0 +1,55 @@ +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 + expected = [2, 4] + np.testing.assert_array_equal(utils.array_filter(p, arr), expected) + + def test_array_position(self): + arr = [10, 20, 30, 40, 30] + self.assertEqual(utils.array_position(30, arr), 2) + self.assertEqual(utils.array_position(30, arr, start=3), 4) + self.assertEqual(utils.array_position(50, arr), -1) + + def test_array_position_if(self): + arr = [1, 3, 5, 8, 10] + p = lambda x: x % 2 == 0 + self.assertEqual(utils.array_position_if(p, arr), 3) + self.assertEqual(utils.array_position_if(p, arr, start=4), 4) + + def test_array_one_shorter(self): + arr = [1, 2, 3, 4] + np.testing.assert_array_equal(utils.array_one_shorter(arr, 1), [1, 3, 4]) + + def test_determinant(self): + mx = [[1, 2], [3, 4]] + # det = 1*4 - 2*3 = -2 + self.assertAlmostEqual(utils.determinant(mx), -2.0, places=9) + + def test_inverse(self): + mx = [[1, 2], [3, 4]] + inv = utils.inverse(mx) + expected = [[-2.0, 1.0], [1.5, -0.5]] + np.testing.assert_array_almost_equal(inv, expected, decimal=9) + + def test_cross_product_3d(self): + v1 = [1, 0, 0] + v2 = [0, 1, 0] + expected = [0, 0, 1] + np.testing.assert_array_equal(utils.cross_product_3d(v1, v2), expected) + + def test_time_vmem_rss(self): + t, vmem, rss = utils.time_vmem_rss() + self.assertGreaterEqual(t, 0.0) + # On non-linux this might be 0.0, but if /proc/self/status exists it should be > 0 + if os.path.exists("/proc/self/status"): + self.assertGreater(vmem, 0.0) + self.assertGreater(rss, 0.0) + +if __name__ == '__main__': + unittest.main() From b65ab3dfc853e31c36aba450cb8000a2e37665ac Mon Sep 17 00:00:00 2001 From: Emanuel Pituch <32016786+epituch@users.noreply.github.com> Date: Thu, 19 Mar 2026 22:08:01 -0700 Subject: [PATCH 09/14] WIP: section 2 implementation --- pyproject.toml | 4 +- src/mock_features/mock_features.py | 49 ++- src/nmesh/backend.py | 237 +++++++++++ src/nmesh/mesher/__init__.py | 2 + src/nmesh/mesher/defaults.py | 302 ++++++++++++++ src/nmesh/mesher/driver.py | 181 ++++++++ src/nmesh/meshio_support.py | 86 ++++ src/nmesh/nmesh.py | 650 +++++++++++++++-------------- tests/nmesh/test_defaults.py | 90 ++++ tests/nmesh/test_driver.py | 93 +++++ 10 files changed, 1384 insertions(+), 310 deletions(-) create mode 100644 src/nmesh/backend.py create mode 100644 src/nmesh/mesher/__init__.py create mode 100644 src/nmesh/mesher/defaults.py create mode 100644 src/nmesh/mesher/driver.py create mode 100644 src/nmesh/meshio_support.py create mode 100644 tests/nmesh/test_defaults.py create mode 100644 tests/nmesh/test_driver.py diff --git a/pyproject.toml b/pyproject.toml index f025197..284ec5e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,11 +8,11 @@ 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" -requires-python = ">=3.8" +requires-python = ">=3.10" classifiers = [ "Programming Language :: Python :: 3", "Operating System :: OS Independent" ] -dependencies = ["pint", "numpy", "tabulate"] +dependencies = ["pint", "numpy", "tabulate", "meshio"] [project.optional-dependencies] test = ["pytest", "pytest-cov", "pytest-watch"] diff --git a/src/mock_features/mock_features.py b/src/mock_features/mock_features.py index 87545d0..8023a9a 100644 --- a/src/mock_features/mock_features.py +++ b/src/mock_features/mock_features.py @@ -1,3 +1,5 @@ +import configparser +from io import StringIO from typing import Any, Dict class MockFeatures: @@ -18,13 +20,52 @@ def __init__(self): } } + @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): - """Stub for loading features from a file.""" - pass + """Loads INI-style features from a file.""" + parser = configparser.ConfigParser( + delimiters=("=", ":"), + interpolation=None, + ) + parser.optionxform = str + with open(file_path, encoding="utf-8") as stream: + parser.read_file(stream) + self._load_from_parser(parser) def from_string(self, string): - """Stub for loading features from a string.""" - pass + """Loads INI-style features from a string.""" + parser = configparser.ConfigParser( + delimiters=("=", ":"), + interpolation=None, + ) + parser.optionxform = str + 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.""" diff --git a/src/nmesh/backend.py b/src/nmesh/backend.py new file mode 100644 index 0000000..5a65f7f --- /dev/null +++ b/src/nmesh/backend.py @@ -0,0 +1,237 @@ +from __future__ import annotations + +import copy +from dataclasses import dataclass, field +from typing import Any, Protocol, runtime_checkable + + +@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) + dim: int = 3 + + +@runtime_checkable +class MeshBackendProtocol(Protocol): + def mesh_scale_node_positions(self, raw_mesh: RawMesh, scale: float): ... + def mesh_writefile(self, path: str, raw_mesh: RawMesh): ... + 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): ... + def mesh_plotinfo_points(self, raw_mesh: RawMesh): ... + def mesh_plotinfo_pointsregions(self, raw_mesh: RawMesh): ... + def mesh_plotinfo_simplices(self, raw_mesh: RawMesh): ... + def mesh_plotinfo_simplicesregions(self, raw_mesh: RawMesh): ... + def mesh_plotinfo_surfaces_and_surfacesregions(self, raw_mesh: RawMesh): ... + def mesh_plotinfo_links(self, raw_mesh: RawMesh): ... + def mesh_dim(self, raw_mesh: RawMesh) -> int: ... + def mesh_plotinfo_regionvolumes(self, raw_mesh: RawMesh): ... + def mesh_plotinfo_periodic_points_indices(self, raw_mesh: RawMesh): ... + def mesh_set_vertex_distribution(self, raw_mesh: RawMesh, dist): ... + def mesh_get_permutation(self, raw_mesh: RawMesh): ... + def mesh_readfile(self, filename: str, do_reorder: bool, do_distribute: bool): ... + def copy_mesher_defaults(self, defaults: dict[str, Any]) -> dict[str, Any]: ... + def mesh_bodies_raw( + self, + driver, + mesher: dict[str, Any], + bb_min, + bb_max, + mesh_ext: int, + objects, + a0: float, + density: str, + fixed, + mobile, + simply, + periodic, + cache, + hints, + ): ... + def mesh_from_points_and_simplices( + self, + dim: int, + points, + simplices, + regions, + periodic, + reorder: bool, + distribute: bool, + ) -> RawMesh: ... + def body_union(self, objs): ... + def body_difference(self, obj1, objs): ... + def body_intersection(self, objs): ... + def body_shifted_sc(self, obj, shift): ... + def body_shifted_bc(self, obj, shift): ... + def body_scaled(self, obj, scale): ... + def body_rotated_sc(self, obj, a1, a2, ang): ... + def body_rotated_bc(self, obj, a1, a2, ang): ... + def body_rotated_axis_sc(self, obj, axis, ang): ... + def body_rotated_axis_bc(self, obj, axis, ang): ... + def body_box(self, p1, p2): ... + def body_ellipsoid(self, length): ... + def body_frustum(self, c1, r1, c2, r2): ... + def body_helix(self, c1, r1, c2, r2): ... + + @property + def mesher_defaults(self) -> dict[str, Any]: ... + + +class StubMeshBackend: + """Lightweight in-memory backend used until the Python mesher is complete.""" + + def mesh_scale_node_positions(self, raw_mesh: RawMesh, scale: float): + for point in raw_mesh.points: + for index, value in enumerate(point): + point[index] = value * scale + + def mesh_writefile(self, path: str, raw_mesh: RawMesh): + return None + + def mesh_nr_simplices(self, raw_mesh: RawMesh) -> int: + return len(raw_mesh.simplices) + + def mesh_nr_points(self, raw_mesh: RawMesh) -> int: + return len(raw_mesh.points) + + def mesh_plotinfo(self, raw_mesh: RawMesh): + 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): + return raw_mesh.points + + def mesh_plotinfo_pointsregions(self, raw_mesh: RawMesh): + return raw_mesh.point_regions + + def mesh_plotinfo_simplices(self, raw_mesh: RawMesh): + return raw_mesh.simplices + + def mesh_plotinfo_simplicesregions(self, raw_mesh: RawMesh): + return raw_mesh.regions + + def mesh_plotinfo_surfaces_and_surfacesregions(self, raw_mesh: RawMesh): + return [raw_mesh.surfaces, []] + + def mesh_plotinfo_links(self, raw_mesh: RawMesh): + return raw_mesh.links + + def mesh_dim(self, raw_mesh: RawMesh) -> int: + if raw_mesh.points: + return len(raw_mesh.points[0]) + return raw_mesh.dim + + def mesh_plotinfo_regionvolumes(self, raw_mesh: RawMesh): + return raw_mesh.region_volumes + + def mesh_plotinfo_periodic_points_indices(self, raw_mesh: RawMesh): + return raw_mesh.periodic_point_indices + + def mesh_set_vertex_distribution(self, raw_mesh: RawMesh, dist): + return None + + def mesh_get_permutation(self, raw_mesh: RawMesh): + return raw_mesh.permutation + + def mesh_readfile(self, filename: str, do_reorder: bool, do_distribute: bool): + return RawMesh() + + def copy_mesher_defaults(self, defaults: dict[str, Any]) -> dict[str, Any]: + return copy.deepcopy(defaults) + + def mesh_bodies_raw( + self, + driver, + mesher: dict[str, Any], + bb_min, + bb_max, + mesh_ext: int, + objects, + a0: float, + density: str, + fixed, + mobile, + simply, + periodic, + cache, + hints, + ): + return RawMesh(dim=len(bb_min)) + + def mesh_from_points_and_simplices( + self, + dim: int, + points, + simplices, + regions, + periodic, + reorder: bool, + distribute: bool, + ) -> RawMesh: + return RawMesh( + points=points, + simplices=simplices, + regions=regions, + dim=dim, + periodic_point_indices=periodic, + ) + + def body_union(self, objs): + return ("union", objs) + + def body_difference(self, obj1, objs): + return ("difference", obj1, objs) + + def body_intersection(self, objs): + return ("intersection", objs) + + def body_shifted_sc(self, obj, shift): + return ("shifted_sc", obj, shift) + + def body_shifted_bc(self, obj, shift): + return ("shifted_bc", obj, shift) + + def body_scaled(self, obj, scale): + return ("scaled", obj, scale) + + def body_rotated_sc(self, obj, a1, a2, ang): + return ("rotated_sc", obj, a1, a2, ang) + + def body_rotated_bc(self, obj, a1, a2, ang): + return ("rotated_bc", obj, a1, a2, ang) + + def body_rotated_axis_sc(self, obj, axis, ang): + return ("rotated_axis_sc", obj, axis, ang) + + def body_rotated_axis_bc(self, obj, axis, ang): + return ("rotated_axis_bc", obj, axis, ang) + + def body_box(self, p1, p2): + return ("box", p1, p2) + + def body_ellipsoid(self, length): + return ("ellipsoid", length) + + def body_frustum(self, c1, r1, c2, r2): + return ("frustum", c1, r1, c2, r2) + + def body_helix(self, c1, r1, c2, r2): + return ("helix", c1, r1, c2, r2) + + @property + def mesher_defaults(self) -> dict[str, Any]: + return {"parameters": {}} + + +backend: MeshBackendProtocol = StubMeshBackend() diff --git a/src/nmesh/mesher/__init__.py b/src/nmesh/mesher/__init__.py new file mode 100644 index 0000000..796bf05 --- /dev/null +++ b/src/nmesh/mesher/__init__.py @@ -0,0 +1,2 @@ +from .defaults import MeshingParameters, PointFate, SimplexRegion +from .driver import make_mg_gendriver, do_every_n_steps_driver, MeshEngineStatus, MeshEngineCommand diff --git a/src/nmesh/mesher/defaults.py b/src/nmesh/mesher/defaults.py new file mode 100644 index 0000000..cc73467 --- /dev/null +++ b/src/nmesh/mesher/defaults.py @@ -0,0 +1,302 @@ +import copy +import logging +from dataclasses import dataclass +from enum import IntEnum +from functools import partialmethod + +from mock_features import MockFeatures + +log = logging.getLogger(__name__) + + +@dataclass(frozen=True, slots=True) +class ParameterSpec: + legacy_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_LEGACY = { + spec.legacy_name: spec for spec in PUBLIC_PARAMETER_SPECS +} + +LEGACY_TO_INTERNAL = { + spec.legacy_name: spec.internal_name for spec in PUBLIC_PARAMETER_SPECS +} +INTERNAL_TO_LEGACY = { + spec.internal_name: spec.legacy_name for spec in PUBLIC_PARAMETER_SPECS +} +LEGACY_SETTER_NAMES = ( + "shape_force_scale", + "volume_force_scale", + "neigh_force_scale", + "irrel_elem_force_scale", + "time_step_scale", + "thresh_add", + "thresh_del", + "topology_threshold", + "tolerated_rel_move", + "max_steps", + "initial_settling_steps", + "sliver_correction", + "smallest_volume_ratio", + "max_relaxation", +) + + +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, max_step, init_val, final_val): + """Linear function from init_val to final_val, saturating at max_step.""" + 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): + """Repulsing force between two mobile nodes.""" + if reduced_distance > 1.0: + return 0.0 + return 1.0 - reduced_distance + + +def default_boundary_node_force_fun(reduced_distance): + """Strongly repelling potential for boundary points.""" + if reduced_distance > 1.0: + return 0.0 + try: + return 1.0 / reduced_distance - 1.0 + except ZeroDivisionError: + return 1e12 + + +def default_handle_point_density_fun(rng, avg_stats, thresh_add, thresh_del): + """Default function to insert or delete points based on density and force.""" + avg_density, avg_force = avg_stats + if avg_density < thresh_add: + if rng.random() < 0.1: + log.debug("Dtl (dens_avg=%s) - adding point.", avg_density) + return PointFate.ADD_ANOTHER + return PointFate.DO_NOTHING + + if avg_force < 0.07: + if rng.random() < 0.2: + log.debug("Ftl (avg_force=%s) - adding point.", avg_force) + return PointFate.ADD_ANOTHER + return PointFate.DO_NOTHING + + if avg_density > thresh_del: + prob = 0.3 + (avg_density - thresh_del) * 0.1 + if rng.random() < prob: + log.debug("Dth (dens_avg=%s) - axing point.", avg_density) + return PointFate.DELETE + return PointFate.DO_NOTHING + + if avg_force > 0.5: + prob = 0.4 + (avg_force - 0.5) * 0.1 + 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): + keys = [name] + internal = LEGACY_TO_INTERNAL.get(name) + legacy = INTERNAL_TO_LEGACY.get(name) + + if internal is not None and internal not in keys: + keys.append(internal) + if legacy is not None and legacy not in keys: + keys.append(legacy) + + return keys + + +class MeshingParameters(MockFeatures): + """Pure-Python mesher parameter container.""" + + def __init__(self, string=None, file=None): + super().__init__() + self.dim = None + self._setup_defaults() + if file: + self.from_file(file) + if string: + self.from_string(string) + self.add_section("user-modifications") + + def _setup_defaults(self): + # Internal mesher field names and defaults used by the Python port. + self._params = { + "nr_probes_for_determining_volume": 100000, + "boundary_condition_acceptable_fuzz": 1e-6, + "boundary_condition_max_nr_correction_steps": 200, + "boundary_condition_debuglevel": 0, + "relaxation_debuglevel": 0, + "controller_step_limit_min": 500, + "controller_max_time_step": 10.0, + "initial_relaxation_weight_fun": default_initial_relaxation_weight, + "relaxation_force_fun": default_relaxation_force_fun, + "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} + ) + + def _get_section_name(self): + 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, name): + for key in _candidate_keys(name): + value = self.get(section, key) + if value is not None: + return value + return None + + def _canonical_key(self, name): + internal = LEGACY_TO_INTERNAL.get(name, name) + if internal in self._params or name in LEGACY_TO_INTERNAL: + return internal + return name + + def __getitem__(self, name): + user_value = self._lookup("user-modifications", name) + if user_value is not None: + return user_value + + if self.dim is not None: + section_value = self._lookup(self._get_section_name(), name) + if section_value is not None: + return section_value + + canonical = self._canonical_key(name) + if canonical in self._params: + return self._params[canonical] + + return None + + def __setitem__(self, key, value): + canonical = self._canonical_key(key) + self._params[canonical] = value + self.set("user-modifications", canonical, value) + + def _sync_dimension_section(self, dim): + self.dim = dim + section = self._get_section_name() + + for key, value in self.items("user-modifications"): + section_key = INTERNAL_TO_LEGACY.get(key, key) + self.set(section, section_key, value) + + return section + + def to_mesher_config(self, dim): + self._sync_dimension_section(dim) + + resolved = {} + for spec in PUBLIC_PARAMETER_SPECS: + value = self[spec.legacy_name] + if value is None: + continue + resolved[spec.internal_name] = spec.cast(value) + + return resolved + + def apply_to_mesher(self, mesher, dim): + self._sync_dimension_section(dim) + mesher.setdefault("parameters", {}) + + for spec in PUBLIC_PARAMETER_SPECS: + value = self[spec.legacy_name] + if value is None: + continue + mesher["parameters"][spec.internal_name] = spec.cast(value) + + return mesher + + def _set_parameter(self, name, value): + self[name] = PUBLIC_PARAMETER_SPECS_BY_LEGACY[name].cast(value) + + def copy(self): + return copy.deepcopy(self) + + +for _name in LEGACY_SETTER_NAMES: + setattr( + MeshingParameters, + f"set_{_name}", + partialmethod(MeshingParameters._set_parameter, _name), + ) diff --git a/src/nmesh/mesher/driver.py b/src/nmesh/mesher/driver.py new file mode 100644 index 0000000..2ababfd --- /dev/null +++ b/src/nmesh/mesher/driver.py @@ -0,0 +1,181 @@ +import inspect +import logging +from enum import Enum + +log = logging.getLogger(__name__) + +LEGACY_CALLBACK_DOCS = { + "COORDS": "Coordinates of points", + "LINKS": "Links in the mesh (pairs of point indices)", + "SIMPLICES": ( + "Simplex info (points-indices,((circumcirc center,cc radius)," + "(ic center,ic radius),region))" + ), + "POINT-BODIES": ( + "Which bodies does the corresponding point belong to (body index list)" + ), + "SURFACES": ( + "Surface elements info (points-indices,((circumcirc center,cc radius)," + "(ic center,ic radius),region))" + ), + "REGION-VOLUMES": "Volume for every region", +} + + +class MeshEngineCommand(Enum): + DO_STEP = 1 + DO_EXTRACT = 2 + + +class MeshEngineStatus(Enum): + FINISHED_STEP_LIMIT_REACHED = 1 + FINISHED_FORCE_EQUILIBRIUM_REACHED = 2 + CAN_CONTINUE = 3 + PRODUCED_INTERMEDIATE_MESH = 4 + + +def _looks_like_legacy_payload(mesh): + return ( + isinstance(mesh, list) + and all( + isinstance(entry, tuple) + and len(entry) == 3 + and isinstance(entry[0], str) + for entry in mesh + ) + ) + + +def _mesh_payload(mesh): + if _looks_like_legacy_payload(mesh): + return mesh + + return [ + ("COORDS", LEGACY_CALLBACK_DOCS["COORDS"], getattr(mesh, "points", [])), + ("LINKS", LEGACY_CALLBACK_DOCS["LINKS"], getattr(mesh, "links", [])), + ( + "SIMPLICES", + LEGACY_CALLBACK_DOCS["SIMPLICES"], + getattr(mesh, "simplices", []), + ), + ( + "POINT-BODIES", + LEGACY_CALLBACK_DOCS["POINT-BODIES"], + getattr(mesh, "point_regions", []), + ), + ( + "SURFACES", + LEGACY_CALLBACK_DOCS["SURFACES"], + getattr(mesh, "surfaces", []), + ), + ( + "REGION-VOLUMES", + LEGACY_CALLBACK_DOCS["REGION-VOLUMES"], + getattr(mesh, "region_volumes", []), + ), + ] + + +def _callback_accepts_piece_number(callback): + try: + signature = inspect.signature(callback) + except (TypeError, ValueError): + return True + + try: + signature.bind_partial(0, 0, []) + return True + except TypeError: + return False + + +def _invoke_callback(callback, accepts_piece_number, nr_piece, nr_step, mesh): + payload = _mesh_payload(mesh) + if accepts_piece_number: + callback(nr_piece, nr_step, payload) + else: + callback(nr_step, payload) + + +def do_every_n_steps_driver(nr_steps_per_bunch, callback, engine_func): + """ + Python port of Mesh.do_every_n_steps_driver using an iterative loop. + """ + if nr_steps_per_bunch <= 0: + raise ValueError("nr_steps_per_bunch must be positive") + + nr_step = 0 + status_out = engine_func(MeshEngineCommand.DO_STEP) + + while True: + log.info("do_every_n_steps_driver [%d]", nr_step) + status, data = status_out + + if status in ( + MeshEngineStatus.FINISHED_STEP_LIMIT_REACHED, + MeshEngineStatus.FINISHED_FORCE_EQUILIBRIUM_REACHED, + ): + return status_out + + if status == MeshEngineStatus.CAN_CONTINUE: + cont = data + if (nr_step % nr_steps_per_bunch != 0) or nr_step == 0: + nr_step += 1 + status_out = cont(MeshEngineCommand.DO_STEP) + continue + + log.debug("Scheduling Mesh Extraction!") + status_out = cont(MeshEngineCommand.DO_EXTRACT) + continue + + if status == MeshEngineStatus.PRODUCED_INTERMEDIATE_MESH: + mesh, cont = data + log.debug("Extracted Mesh!") + if nr_step != 0: + callback(nr_step, mesh) + nr_step += 1 + status_out = cont(MeshEngineCommand.DO_STEP) + continue + + raise ValueError(f"Unknown mesh engine status: {status}") + + +def make_mg_gendriver(interval, callback): + """ + Returns a gendriver compatible with both the legacy piece-aware API and the + simplified direct-driver test usage. + """ + accepts_piece_number = _callback_accepts_piece_number(callback) + + def gendriver(piece_or_engine): + if callable(piece_or_engine): + return do_every_n_steps_driver( + interval, + lambda nr_step, mesh: _invoke_callback( + callback, + accepts_piece_number, + 0, + nr_step, + mesh, + ), + piece_or_engine, + ) + + nr_piece = int(piece_or_engine) + + def driver(engine_func): + return do_every_n_steps_driver( + interval, + lambda nr_step, mesh: _invoke_callback( + callback, + accepts_piece_number, + nr_piece, + nr_step, + mesh, + ), + engine_func, + ) + + return driver + + return gendriver diff --git a/src/nmesh/meshio_support.py b/src/nmesh/meshio_support.py new file mode 100644 index 0000000..1997441 --- /dev/null +++ b/src/nmesh/meshio_support.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from pathlib import Path + +import numpy as np + +from .backend import RawMesh + +try: + import meshio as _meshio +except ImportError: + _meshio = None + + +_CELL_TYPE_BY_DIM = { + 1: "line", + 2: "triangle", + 3: "tetra", +} + +_DIM_BY_CELL_TYPE = {value: key for key, value in _CELL_TYPE_BY_DIM.items()} + + +def meshio_available() -> bool: + return _meshio is not None + + +def _cell_type_for(raw_mesh: RawMesh) -> str: + if raw_mesh.simplices: + simplex_size = len(raw_mesh.simplices[0]) + if simplex_size == 2: + return "line" + if simplex_size == 3: + return "triangle" + if simplex_size == 4: + return "tetra" + + return _CELL_TYPE_BY_DIM.get(raw_mesh.dim, "tetra") + + +def _regions_from_meshio(mesh, cell_type: str, count: int) -> list[int]: + 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 save_raw_mesh_with_meshio(path: str | Path, raw_mesh: RawMesh) -> None: + if _meshio is None: + raise RuntimeError("meshio is not installed") + + cell_type = _cell_type_for(raw_mesh) + cells = [(cell_type, np.asarray(raw_mesh.simplices, dtype=int))] + cell_data = None + if raw_mesh.regions: + cell_data = {"region": [np.asarray(raw_mesh.regions, dtype=int)]} + + mesh = _meshio.Mesh( + points=np.asarray(raw_mesh.points, dtype=float), + cells=cells, + cell_data=cell_data, + ) + _meshio.write(Path(path), mesh) + + +def load_raw_mesh_with_meshio(path: str | Path) -> RawMesh: + if _meshio is None: + raise RuntimeError("meshio is not installed") + + mesh = _meshio.read(Path(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: + raise ValueError(f"No supported simplex cells found in {path}") + + cell_type, simplices = supported + 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], + ) diff --git a/src/nmesh/nmesh.py b/src/nmesh/nmesh.py index d3ed2ea..97f8572 100644 --- a/src/nmesh/nmesh.py +++ b/src/nmesh/nmesh.py @@ -1,82 +1,57 @@ +from __future__ import annotations + import math import logging -from typing import List, Tuple, Optional, Any, Union +from collections.abc import Iterable, Sequence +from typing import Any, TextIO from pathlib import Path import itertools -from mock_features import MockFeatures from . import utils +from .backend import RawMesh, backend +from .mesher import MeshingParameters, make_mg_gendriver +from .meshio_support import load_raw_mesh_with_meshio, meshio_available, save_raw_mesh_with_meshio + # Setup logging log = logging.getLogger(__name__) -# --- Stubs for External Dependencies --- - -class OCamlStub: - """Stub for the OCaml backend interface.""" - - # Mesher defaults setters - def mesher_defaults_set_shape_force_scale(self, mesher, scale): pass - def mesher_defaults_set_volume_force_scale(self, mesher, scale): pass - def mesher_defaults_set_neigh_force_scale(self, mesher, scale): pass - def mesher_defaults_set_irrel_elem_force_scale(self, mesher, scale): pass - def mesher_defaults_set_time_step_scale(self, mesher, scale): pass - def mesher_defaults_set_thresh_add(self, mesher, thresh): pass - def mesher_defaults_set_thresh_del(self, mesher, thresh): pass - def mesher_defaults_set_topology_threshold(self, mesher, thresh): pass - def mesher_defaults_set_tolerated_rel_movement(self, mesher, scale): pass - def mesher_defaults_set_max_relaxation_steps(self, mesher, steps): pass - def mesher_defaults_set_initial_settling_steps(self, mesher, steps): pass - def mesher_defaults_set_sliver_correction(self, mesher, scale): pass - def mesher_defaults_set_smallest_allowed_volume_ratio(self, mesher, scale): pass - def mesher_defaults_set_movement_max_freedom(self, mesher, scale): pass - - # Mesh operations - def mesh_scale_node_positions(self, raw_mesh, scale): pass - def mesh_writefile(self, path, raw_mesh): pass - def mesh_nr_simplices(self, raw_mesh): return 0 - def mesh_nr_points(self, raw_mesh): return 0 - def mesh_plotinfo(self, raw_mesh): return [[], [], [[], [], []]] - def mesh_plotinfo_points(self, raw_mesh): return [] - def mesh_plotinfo_pointsregions(self, raw_mesh): return [] - def mesh_plotinfo_simplices(self, raw_mesh): return [] - def mesh_plotinfo_simplicesregions(self, raw_mesh): return [] - def mesh_plotinfo_surfaces_and_surfacesregions(self, raw_mesh): return [[], []] - def mesh_plotinfo_links(self, raw_mesh): return [] - def mesh_dim(self, raw_mesh): return 3 - def mesh_plotinfo_regionvolumes(self, raw_mesh): return [] - def mesh_plotinfo_periodic_points_indices(self, raw_mesh): return [] - def mesh_set_vertex_distribution(self, raw_mesh, dist): pass - def mesh_get_permutation(self, raw_mesh): return [] - def mesh_readfile(self, filename, do_reorder, do_distribute): return "STUB_MESH" - - # Driver and Mesh creation - def make_mg_gendriver(self, interval, callback): return "STUB_DRIVER" - def copy_mesher_defaults(self, defaults): return "STUB_MESHER" - def mesh_bodies_raw(self, driver, mesher, bb_min, bb_max, mesh_ext, objects, a0, density, fixed, mobile, simply, periodic, cache, hints): return "STUB_MESH" - def mesh_from_points_and_simplices(self, dim, points, simplices, regions, periodic, reorder, distribute): return "STUB_MESH" - - # Body operations - def body_union(self, objs): return "STUB_OBJ_UNION" - def body_difference(self, obj1, objs): return "STUB_OBJ_DIFF" - def body_intersection(self, objs): return "STUB_OBJ_INTERSECT" - def body_shifted_sc(self, obj, shift): return obj - def body_shifted_bc(self, obj, shift): return obj - def body_scaled(self, obj, scale): return obj - def body_rotated_sc(self, obj, a1, a2, ang): return obj - def body_rotated_bc(self, obj, a1, a2, ang): return obj - def body_rotated_axis_sc(self, obj, axis, ang): return obj - def body_rotated_axis_bc(self, obj, axis, ang): return obj - - # Primitives - def body_box(self, p1, p2): return "STUB_BOX" - def body_ellipsoid(self, length): return "STUB_ELLIPSOID" - def body_frustum(self, c1, r1, c2, r2): return "STUB_FRUSTUM" - def body_helix(self, c1, r1, c2, r2): return "STUB_HELIX" - - @property - def mesher_defaults(self): return "STUB_DEFAULTS" +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] -ocaml = OCamlStub() + +def _raw_mesh_as_legacy_write_data(raw_mesh: RawMesh): + simplices = list( + zip( + raw_mesh.regions or [1] * len(raw_mesh.simplices), + raw_mesh.simplices, + ) + ) + surfaces = list( + zip( + [1] * len(raw_mesh.surfaces), + raw_mesh.surfaces, + ) + ) + return raw_mesh.points, simplices, surfaces def memory_report(tag: str): """Reports memory usage.""" @@ -85,90 +60,24 @@ def memory_report(tag: str): # --- Configuration --- -class MeshingParameters(MockFeatures): - """Parameters for the meshing algorithm, supporting multiple dimensions.""" - def __init__(self, string=None, file=None): - super().__init__() - self.dim = None - if file: self.from_file(file) - if string: self.from_string(string) - self.add_section('user-modifications') - - def _get_section_name(self): - 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 __getitem__(self, name): - val = self.get('user-modifications', name) - if val is not None: - return val - section = self._get_section_name() - return self.get(section, name) - - def __setitem__(self, key, value): - self.set('user-modifications', key, value) - - def set_shape_force_scale(self, v): self["shape_force_scale"] = float(v) - def set_volume_force_scale(self, v): self["volume_force_scale"] = float(v) - def set_neigh_force_scale(self, v): self["neigh_force_scale"] = float(v) - def set_irrel_elem_force_scale(self, v): self["irrel_elem_force_scale"] = float(v) - def set_time_step_scale(self, v): self["time_step_scale"] = float(v) - def set_thresh_add(self, v): self["thresh_add"] = float(v) - def set_thresh_del(self, v): self["thresh_del"] = float(v) - def set_topology_threshold(self, v): self["topology_threshold"] = float(v) - def set_tolerated_rel_move(self, v): self["tolerated_rel_move"] = float(v) - def set_max_steps(self, v): self["max_steps"] = int(v) - def set_initial_settling_steps(self, v): self["initial_settling_steps"] = int(v) - def set_sliver_correction(self, v): self["sliver_correction"] = float(v) - def set_smallest_volume_ratio(self, v): self["smallest_volume_ratio"] = float(v) - def set_max_relaxation(self, v): self["max_relaxation"] = float(v) - - def pass_parameters_to_ocaml(self, mesher, dim): - self.dim = dim - for key, value in self.items('user-modifications'): - section = self._get_section_name() - self.set(section, key, str(value)) - - params = [ - ("shape_force_scale", ocaml.mesher_defaults_set_shape_force_scale), - ("volume_force_scale", ocaml.mesher_defaults_set_volume_force_scale), - ("neigh_force_scale", ocaml.mesher_defaults_set_neigh_force_scale), - ("irrel_elem_force_scale", ocaml.mesher_defaults_set_irrel_elem_force_scale), - ("time_step_scale", ocaml.mesher_defaults_set_time_step_scale), - ("thresh_add", ocaml.mesher_defaults_set_thresh_add), - ("thresh_del", ocaml.mesher_defaults_set_thresh_del), - ("topology_threshold", ocaml.mesher_defaults_set_topology_threshold), - ("tolerated_rel_move", ocaml.mesher_defaults_set_tolerated_rel_movement), - ("max_steps", ocaml.mesher_defaults_set_max_relaxation_steps), - ("initial_settling_steps", ocaml.mesher_defaults_set_initial_settling_steps), - ("sliver_correction", ocaml.mesher_defaults_set_sliver_correction), - ("smallest_volume_ratio", ocaml.mesher_defaults_set_smallest_allowed_volume_ratio), - ("max_relaxation", ocaml.mesher_defaults_set_movement_max_freedom), - ] - - for key, setter in params: - val = self[key] - if val is not None: - setter(mesher, float(val) if "steps" not in key else int(val)) - def get_default_meshing_parameters(): """Returns default meshing parameters.""" return MeshingParameters() # --- Loading Utilities --- -def _is_nmesh_ascii_file(filename): +def _is_nmesh_ascii_file(filename: str | Path) -> bool: try: - with open(filename, 'r') as f: - return f.readline().startswith("# PYFEM") - except: return False + with Path(filename).open(encoding="utf-8") as stream: + return stream.readline().startswith("# PYFEM") + except OSError: + return False -def _is_nmesh_hdf5_file(filename): +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): +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 @@ -177,79 +86,96 @@ def hdf5_mesh_get_permutation(filename): class MeshBase: """Base class for all mesh objects, providing access to mesh data.""" - def __init__(self, raw_mesh): + def __init__(self, raw_mesh: RawMesh): self.raw_mesh = raw_mesh - self._cache = {} + 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.""" - ocaml.mesh_scale_node_positions(self.raw_mesh, float(scale)) - self._cache.pop('points', None) - self._cache.pop('region_volumes', None) - - def save(self, filename: Union[str, Path]): + 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 = str(filename) - if path.lower().endswith('.h5'): - log.info(f"Saving to HDF5 (stub): {path}") - else: - ocaml.mesh_writefile(path, self.raw_mesh) + 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: + if meshio_available(): + save_raw_mesh_with_meshio(path, self.raw_mesh) + return + raise RuntimeError("meshio is required to save non-PYFEM mesh formats") + + if isinstance(self.raw_mesh, RawMesh): + write_mesh(_raw_mesh_as_legacy_write_data(self.raw_mesh), out=path) + return + + backend.mesh_writefile(str(path), self.raw_mesh) def __str__(self): - pts = ocaml.mesh_nr_points(self.raw_mesh) - simps = ocaml.mesh_nr_simplices(self.raw_mesh) + 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 ocaml.mesh_plotinfo(self.raw_mesh) + return backend.mesh_plotinfo(self.raw_mesh) @property def points(self): - if 'points' not in self._cache: - self._cache['points'] = ocaml.mesh_plotinfo_points(self.raw_mesh) - return self._cache['points'] + return self._cached_backend_value("points", backend.mesh_plotinfo_points) @property def simplices(self): - if 'simplices' not in self._cache: - self._cache['simplices'] = ocaml.mesh_plotinfo_simplices(self.raw_mesh) - return self._cache['simplices'] + return self._cached_backend_value("simplices", backend.mesh_plotinfo_simplices) @property def regions(self): - if 'regions' not in self._cache: - self._cache['regions'] = ocaml.mesh_plotinfo_simplicesregions(self.raw_mesh) - return self._cache['regions'] + return self._cached_backend_value("regions", backend.mesh_plotinfo_simplicesregions) @property def dim(self): - return ocaml.mesh_dim(self.raw_mesh) + return backend.mesh_dim(self.raw_mesh) @property def surfaces(self): - return ocaml.mesh_plotinfo_surfaces_and_surfacesregions(self.raw_mesh)[0] + return backend.mesh_plotinfo_surfaces_and_surfacesregions(self.raw_mesh)[0] @property def point_regions(self): """Returns regions for each point.""" - if 'point_regions' not in self._cache: - self._cache['point_regions'] = ocaml.mesh_plotinfo_pointsregions(self.raw_mesh) - return self._cache['point_regions'] + return self._cached_backend_value( + "point_regions", backend.mesh_plotinfo_pointsregions + ) @property def links(self): """Returns all links (pairs of point indices).""" - if 'links' not in self._cache: - self._cache['links'] = ocaml.mesh_plotinfo_links(self.raw_mesh) - return self._cache['links'] + return self._cached_backend_value("links", backend.mesh_plotinfo_links) @property def region_volumes(self): """Returns volume of each region.""" - if 'region_volumes' not in self._cache: - self._cache['region_volumes'] = ocaml.mesh_plotinfo_regionvolumes(self.raw_mesh) - return self._cache['region_volumes'] + return self._cached_backend_value( + "region_volumes", backend.mesh_plotinfo_regionvolumes + ) @property def num_regions(self): @@ -259,74 +185,112 @@ def num_regions(self): @property def periodic_point_indices(self): """Returns indices of periodic nodes.""" - if 'periodic_indices' not in self._cache: - self._cache['periodic_indices'] = ocaml.mesh_plotinfo_periodic_points_indices(self.raw_mesh) - return self._cache['periodic_indices'] + return self._cached_backend_value( + "periodic_indices", + backend.mesh_plotinfo_periodic_points_indices, + ) @property def permutation(self): """Returns the node permutation mapping.""" - return ocaml.mesh_get_permutation(self.raw_mesh) + return backend.mesh_get_permutation(self.raw_mesh) def set_vertex_distribution(self, dist): """Sets vertex distribution.""" - ocaml.mesh_set_vertex_distribution(self.raw_mesh, dist) + backend.mesh_set_vertex_distribution(self.raw_mesh, dist) class Mesh(MeshBase): """Class for generating a mesh from geometric objects.""" - def __init__(self, bounding_box, objects=[], a0=1.0, density="", - periodic=[], fixed_points=[], mobile_points=[], simply_points=[], - callback=None, mesh_bounding_box=False, meshing_parameters=None, - cache_name="", hints=[], **kwargs): - + 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.") - - bb = [[float(x) for x in p] for p in bounding_box] + + 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 - - if not objects and not mesh_bounding_box: + + 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 = [] - # Store points lists to allow appending later (mimicking original API) - self._fixed_points = [list(map(float, p)) for p in fixed_points] - self._mobile_points = [list(map(float, p)) for p in mobile_points] - self._simply_points = [list(map(float, p)) for p in simply_points] - - for obj in objects: + 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) - periodic_floats = [1.0 if p else 0.0 for p in periodic] if periodic else [0.0] * dim - - cb_func, cb_interval = callback if callback else (lambda a,b,c: None, 1000000) + 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 - driver = ocaml.make_mg_gendriver(cb_interval, cb_func) - mesher = ocaml.copy_mesher_defaults(ocaml.mesher_defaults) - params.pass_parameters_to_ocaml(mesher, dim) - - # Note: In the original code, mesh generation happens in __init__. - # Adding points via methods afterwards wouldn't affect the already generated mesh - # unless we regenerate or if those methods were meant for pre-generation setup. - # However, checking lib1.py, __init__ calls mesh_bodies_raw immediately. - # The methods fixed_points/mobile_points in lib1.py just append to self.fixed_points - # which seems useless after __init__ unless the user manually triggers something else. - # But we will preserve them for API compatibility. - - raw = ocaml.mesh_bodies_raw( - driver, mesher, bb[0], bb[1], mesh_ext, obj_bodies, float(a0), - density, self._fixed_points, self._mobile_points, self._simply_points, periodic_floats, - cache_name, hints + 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.") + + if raw is None: + raise RuntimeError("Mesh generation failed.") super().__init__(raw) def default_fun(self, nr_piece, n, mesh): @@ -338,54 +302,66 @@ def extended_fun_driver(self, nr_piece, iteration_nr, mesh): if hasattr(self, 'fun_driver'): self.fun_driver(nr_piece, iteration_nr, mesh) - def fixed_points(self, points: List[List[float]]): + def fixed_points(self, points: Sequence[Sequence[float]] | None): """Adds fixed points to the mesh configuration.""" if points: - self._fixed_points.extend(points) + self._fixed_points.extend(_as_float_points(points)) - def mobile_points(self, points: List[List[float]]): + def mobile_points(self, points: Sequence[Sequence[float]] | None): """Adds mobile points to the mesh configuration.""" if points: - self._mobile_points.extend(points) + self._mobile_points.extend(_as_float_points(points)) - def simply_points(self, points: List[List[float]]): + def simply_points(self, points: Sequence[Sequence[float]] | None): """Adds simply points to the mesh configuration.""" if points: - self._simply_points.extend(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") - - # Determine format - if _is_nmesh_ascii_file(filename): - raw = ocaml.mesh_readfile(str(path), reorder, distribute) - elif _is_nmesh_hdf5_file(filename): - # load_hdf5 logic would go here - raw = ocaml.mesh_readfile(str(path), reorder, distribute) + 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) + elif meshio_available(): + raw = load_raw_mesh_with_meshio(path) else: - raise ValueError(f"Unknown mesh file format: {filename}") - + raise ValueError( + f"Unknown mesh file format or meshio is unavailable: {filename}" + ) + super().__init__(raw) class mesh_from_points_and_simplices(MeshBase): """Wrapper for backward compatibility.""" - def __init__(self, points=[], simplices_indices=[], simplices_regions=[], - periodic_point_indices=[], initial=0, do_reorder=False, - do_distribute=True): - - # Adjust for 1-based indexing if initial=1 + 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_indices = [[idx - 1 for idx in s] for s in simplices_indices] - - raw = ocaml.mesh_from_points_and_simplices( - len(points[0]) if points else 3, - [[float(x) for x in p] for p in points], - [[int(x) for x in s] for s in simplices_indices], - [int(r) for r in simplices_regions], - periodic_point_indices, do_reorder, do_distribute + 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) @@ -393,7 +369,7 @@ def load(filename, reorder=False, distribute=True): """Utility function to load a mesh.""" return MeshFromFile(filename, reorder, distribute) -def save(mesh: MeshBase, filename: Union[str, Path]): +def save(mesh: MeshBase, filename: str | Path): """Alias for mesh.save for backward compatibility.""" mesh.save(filename) @@ -406,89 +382,141 @@ def save(mesh: MeshBase, filename: Union[str, Path]): class MeshObject: """Base class for geometric primitives and CSG operations.""" - def __init__(self, dim, fixed=[], mobile=[]): + def __init__( + self, + dim: int, + fixed: Sequence[Sequence[float]] | None = None, + mobile: Sequence[Sequence[float]] | None = None, + ): self.dim = dim - self.fixed_points = fixed - self.mobile_points = mobile + self.fixed_points = _as_float_points(fixed) + self.mobile_points = _as_float_points(mobile) self.obj: Any = None def shift(self, vector, system_coords=True): - self.obj = (ocaml.body_shifted_sc if system_coords else ocaml.body_shifted_bc)(self.obj, vector) + self.obj = (backend.body_shifted_sc if system_coords else backend.body_shifted_bc)(self.obj, vector) def scale(self, factors): - self.obj = ocaml.body_scaled(self.obj, factors) + self.obj = backend.body_scaled(self.obj, factors) def rotate(self, a1, a2, angle, system_coords=True): rad = math.radians(angle) - self.obj = (ocaml.body_rotated_sc if system_coords else ocaml.body_rotated_bc)(self.obj, a1, a2, rad) + self.obj = (backend.body_rotated_sc if system_coords else backend.body_rotated_bc)(self.obj, a1, a2, rad) def rotate_3d(self, axis, angle, system_coords=True): rad = math.radians(angle) - self.obj = (ocaml.body_rotated_axis_sc if system_coords else ocaml.body_rotated_axis_bc)(self.obj, axis, rad) + self.obj = (backend.body_rotated_axis_sc if system_coords else backend.body_rotated_axis_bc)(self.obj, axis, rad) - def transform(self, transformations, system_coords=True): + def transform(self, transformations: Iterable[tuple] | None, system_coords=True): """Applies a list of transformation tuples.""" - for t in transformations: + for t in transformations or []: name, *args = t - if name == "shift": self.shift(args[0], system_coords) - elif name == "scale": self.scale(args[0]) - elif name == "rotate": self.rotate(args[0][0], args[0][1], args[1], system_coords) - elif name == "rotate2d": self.rotate(0, 1, args[0], system_coords) - elif name == "rotate3d": self.rotate_3d(args[0], args[1], system_coords) + match name: + case "shift": + self.shift(args[0], system_coords) + case "scale": + self.scale(args[0]) + case "rotate": + self.rotate(args[0][0], args[0][1], args[1], system_coords) + case "rotate2d": + self.rotate(0, 1, args[0], system_coords) + case "rotate3d": + self.rotate_3d(args[0], args[1], system_coords) + case _: + raise ValueError(f"Unknown transformation {name!r}") class Box(MeshObject): - def __init__(self, p1, p2, transform=[], fixed=[], mobile=[], system_coords=True, use_fixed_corners=False): + def __init__( + self, + p1, + p2, + transform=None, + fixed=None, + mobile=None, + system_coords=True, + use_fixed_corners=False, + ): dim = len(p1) + fixed_points = _as_float_points(fixed) if use_fixed_corners: - fixed.extend([list(c) for c in itertools.product(*zip(p1, p2))]) - super().__init__(dim, fixed, mobile) - self.obj = ocaml.body_box([float(x) for x in p1], [float(x) for x in p2]) + fixed_points.extend([list(c) for c in itertools.product(*zip(p1, p2))]) + super().__init__(dim, fixed_points, mobile) + self.obj = backend.body_box([float(x) for x in p1], [float(x) for x in p2]) self.transform(transform, system_coords) class Ellipsoid(MeshObject): - def __init__(self, lengths, transform=[], fixed=[], mobile=[], system_coords=True): + def __init__( + self, + lengths, + transform=None, + fixed=None, + mobile=None, + system_coords=True, + ): super().__init__(len(lengths), fixed, mobile) - self.obj = ocaml.body_ellipsoid([float(x) for x in lengths]) + self.obj = backend.body_ellipsoid([float(x) for x in lengths]) self.transform(transform, system_coords) class Conic(MeshObject): - def __init__(self, c1, r1, c2, r2, transform=[], fixed=[], mobile=[], system_coords=True): + def __init__( + self, + c1, + r1, + c2, + r2, + transform=None, + fixed=None, + mobile=None, + system_coords=True, + ): super().__init__(len(c1), fixed, mobile) - self.obj = ocaml.body_frustum(c1, r1, c2, r2) + self.obj = backend.body_frustum(c1, r1, c2, r2) self.transform(transform, system_coords) class Helix(MeshObject): - def __init__(self, c1, r1, c2, r2, transform=[], fixed=[], mobile=[], system_coords=True): + def __init__( + self, + c1, + r1, + c2, + r2, + transform=None, + fixed=None, + mobile=None, + system_coords=True, + ): super().__init__(len(c1), fixed, mobile) - self.obj = ocaml.body_helix(c1, r1, c2, r2) + self.obj = backend.body_helix(c1, r1, c2, r2) self.transform(transform, system_coords) # --- CSG --- -def union(objects: List[MeshObject]) -> MeshObject: - if len(objects) < 2: raise ValueError("Union requires at least two objects") +def union(objects: Sequence[MeshObject]) -> MeshObject: + if len(objects) < 2: + raise ValueError("Union requires at least two objects") res = MeshObject(objects[0].dim) for o in objects: res.fixed_points.extend(o.fixed_points) res.mobile_points.extend(o.mobile_points) - res.obj = ocaml.body_union([o.obj for o in objects]) + res.obj = backend.body_union([o.obj for o in objects]) return res -def difference(mother: MeshObject, subtract: List[MeshObject]) -> MeshObject: +def difference(mother: MeshObject, subtract: Sequence[MeshObject]) -> MeshObject: res = MeshObject(mother.dim, mother.fixed_points[:], mother.mobile_points[:]) for o in subtract: res.fixed_points.extend(o.fixed_points) res.mobile_points.extend(o.mobile_points) - res.obj = ocaml.body_difference(mother.obj, [o.obj for o in subtract]) + res.obj = backend.body_difference(mother.obj, [o.obj for o in subtract]) return res -def intersect(objects: List[MeshObject]) -> MeshObject: - if len(objects) < 2: raise ValueError("Intersection requires at least two objects") +def intersect(objects: Sequence[MeshObject]) -> MeshObject: + if len(objects) < 2: + raise ValueError("Intersection requires at least two objects") res = MeshObject(objects[0].dim) for o in objects: res.fixed_points.extend(o.fixed_points) res.mobile_points.extend(o.mobile_points) - res.obj = ocaml.body_intersection([o.obj for o in objects]) + res.obj = backend.body_intersection([o.obj for o in objects]) return res # --- Utilities --- @@ -496,24 +524,31 @@ def intersect(objects: List[MeshObject]) -> MeshObject: def outer_corners(mesh: MeshBase): """Determines the bounding box of the mesh nodes.""" coords = mesh.points - if not coords: return None, None + 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: List[Tuple[float, float]], discretization: float) -> Tuple: +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, simplices, regions_ids = [], [], [] - point_map = {} - - def get_idx(v): - vk = round(v, 8) + 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(v)]) + points.append([float(value)]) return point_map[vk] for rid, (start, end) in enumerate(regions, 1): - if start > end: start, end = end, start + if start > end: + start, end = end, start steps = max(1, int(abs((end - start) / discretization))) step = (end - start) / steps last = get_idx(start) @@ -522,13 +557,13 @@ def get_idx(v): simplices.append([last, curr]) regions_ids.append(rid) last = curr - - # Note: original unidmesher also returned surfaces, but simplified here - # Standard format for mesh_from_points_and_simplices: - # simplices are list of point indices, regions are separate list + return points, simplices, regions_ids -def generate_1d_mesh(regions: List[Tuple[float, float]], discretization: float) -> MeshBase: +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) @@ -539,36 +574,43 @@ def to_lists(mesh: MeshBase): tolists = to_lists -def write_mesh(mesh_data, out=None, check=True, float_fmt=" %f"): +def write_mesh( + mesh_data, + out: str | Path | TextIO | None = None, + check=True, + float_fmt=" %f", +): """ Writes mesh data (points, simplices, surfaces) to a file in nmesh format. mesh_data: (points, simplices, surfaces) """ 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( + 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" - + if out is None: print(content) elif isinstance(out, (str, Path)): - Path(out).write_text(content) + Path(out).write_text(content, encoding="utf-8") else: out.write(content) diff --git a/tests/nmesh/test_defaults.py b/tests/nmesh/test_defaults.py new file mode 100644 index 0000000..36ed82a --- /dev/null +++ b/tests/nmesh/test_defaults.py @@ -0,0 +1,90 @@ +import pytest +from nmesh.mesher import MeshingParameters + +def test_meshing_parameters_defaults(): + params = MeshingParameters() + assert params["controller_shape_force_scale"] == 0.1 + assert params["controller_volume_force_scale"] == 0.0 + 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_legacy_setter_api(): + params = MeshingParameters() + for setter_name in ( + "set_shape_force_scale", + "set_volume_force_scale", + "set_neigh_force_scale", + "set_irrel_elem_force_scale", + "set_time_step_scale", + "set_thresh_add", + "set_thresh_del", + "set_topology_threshold", + "set_tolerated_rel_move", + "set_max_steps", + "set_initial_settling_steps", + "set_sliver_correction", + "set_smallest_volume_ratio", + "set_max_relaxation", + ): + 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 + assert params["controller_shape_force_scale"] == 0.9 + + params["max_steps"] = 1500 + assert params["controller_step_limit_max"] == 1500 + assert params["max_steps"] == 1500 + +def test_meshing_parameters_copy(): + params = MeshingParameters() + params["val"] = 1 + params2 = params.copy() + params2["val"] = 2 + assert params["val"] == 1 + assert params2["val"] == 2 + +def test_meshing_parameters_apply_to_mesher(): + params = MeshingParameters() + params["shape_force_scale"] = 0.5 + params["max_steps"] = 2000 + + mesher = {"parameters": {"existing": 1}} + params.apply_to_mesher(mesher, 3) + + assert mesher["parameters"]["existing"] == 1 + assert mesher["parameters"]["controller_shape_force_scale"] == 0.5 + assert mesher["parameters"]["controller_step_limit_max"] == 2000 + +def test_meshing_parameters_can_load_legacy_config_string(): + params = MeshingParameters( + string=""" +[nmesh-3D] +shape_force_scale : 0.25 +max_steps : 1500 +""" + ) + params.dim = 3 + + assert params["shape_force_scale"] == 0.25 + assert params["controller_shape_force_scale"] == 0.25 + assert params["max_steps"] == 1500 + assert params["controller_step_limit_max"] == 1500 diff --git a/tests/nmesh/test_driver.py b/tests/nmesh/test_driver.py new file mode 100644 index 0000000..297d03d --- /dev/null +++ b/tests/nmesh/test_driver.py @@ -0,0 +1,93 @@ +from nmesh.mesher import make_mg_gendriver, MeshEngineStatus, MeshEngineCommand + + +def make_engine(limit, mesh_factory): + class Engine: + def __init__(self): + self.step = 0 + + def run(self, cmd): + if cmd == MeshEngineCommand.DO_STEP: + self.step += 1 + if self.step > limit: + return MeshEngineStatus.FINISHED_STEP_LIMIT_REACHED, None + return MeshEngineStatus.CAN_CONTINUE, self.run + + if cmd == MeshEngineCommand.DO_EXTRACT: + return MeshEngineStatus.PRODUCED_INTERMEDIATE_MESH, ( + mesh_factory(), + self.run, + ) + + raise AssertionError(f"Unexpected command: {cmd}") + + return Engine().run + + +def test_driver_cadence(): + steps = [] + payloads = [] + + def callback(step, mesh): + steps.append(step) + payloads.append(mesh) + + def mesh_factory(): + return [ + ("COORDS", "Coordinates", []), + ("LINKS", "Links", []), + ("SIMPLICES", "Simplex info", []), + ] + + driver = make_mg_gendriver(3, callback) + driver(make_engine(10, mesh_factory)) + + assert steps == [3, 6, 9] + for payload in payloads: + assert isinstance(payload, list) + assert payload[0][0] == "COORDS" + + +def test_driver_supports_legacy_callback_signature_and_piece_numbers(): + calls = [] + + class MockMesh: + points = [[0.0, 0.0, 0.0]] + links = [(0, 1)] + simplices = [([0, 1, 2, 3], (([], 0.0), ([], 0.0), 1))] + point_regions = [[1]] + surfaces = [([0, 1, 2], (([], 0.0), ([], 0.0), 1))] + region_volumes = [1.0] + + def callback(piece, step, mesh): + calls.append((piece, step, mesh)) + + driver = make_mg_gendriver(2, callback) + driver(7)(make_engine(5, MockMesh)) + + assert [call[:2] for call in calls] == [(7, 2), (7, 4)] + payload = calls[0][2] + assert [tag for tag, _, _ in payload] == [ + "COORDS", + "LINKS", + "SIMPLICES", + "POINT-BODIES", + "SURFACES", + "REGION-VOLUMES", + ] + assert payload[0][2] == [[0.0, 0.0, 0.0]] + + +def test_driver_handles_large_step_counts_without_recursion(): + steps = [] + + def callback(step, mesh): + steps.append(step) + + driver = make_mg_gendriver(100, callback) + status, _ = driver(make_engine(1100, lambda: [])) + + assert status == MeshEngineStatus.FINISHED_STEP_LIMIT_REACHED + assert steps[0] == 100 + assert steps[-1] == 1000 + assert len(steps) == 10 From 8e01b7ae67ce0c9ba39576e357e6942e5aae3450 Mon Sep 17 00:00:00 2001 From: Emanuel Pituch <32016786+epituch@users.noreply.github.com> Date: Fri, 20 Mar 2026 17:32:04 -0700 Subject: [PATCH 10/14] Revert "WIP: section 2 implementation" This reverts commit b65ab3dfc853e31c36aba450cb8000a2e37665ac. --- pyproject.toml | 4 +- src/mock_features/mock_features.py | 49 +-- src/nmesh/backend.py | 237 ----------- src/nmesh/mesher/__init__.py | 2 - src/nmesh/mesher/defaults.py | 302 -------------- src/nmesh/mesher/driver.py | 181 -------- src/nmesh/meshio_support.py | 86 ---- src/nmesh/nmesh.py | 650 ++++++++++++++--------------- tests/nmesh/test_defaults.py | 90 ---- tests/nmesh/test_driver.py | 93 ----- 10 files changed, 310 insertions(+), 1384 deletions(-) delete mode 100644 src/nmesh/backend.py delete mode 100644 src/nmesh/mesher/__init__.py delete mode 100644 src/nmesh/mesher/defaults.py delete mode 100644 src/nmesh/mesher/driver.py delete mode 100644 src/nmesh/meshio_support.py delete mode 100644 tests/nmesh/test_defaults.py delete mode 100644 tests/nmesh/test_driver.py diff --git a/pyproject.toml b/pyproject.toml index 284ec5e..f025197 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,11 +8,11 @@ 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" -requires-python = ">=3.10" +requires-python = ">=3.8" classifiers = [ "Programming Language :: Python :: 3", "Operating System :: OS Independent" ] -dependencies = ["pint", "numpy", "tabulate", "meshio"] +dependencies = ["pint", "numpy", "tabulate"] [project.optional-dependencies] test = ["pytest", "pytest-cov", "pytest-watch"] diff --git a/src/mock_features/mock_features.py b/src/mock_features/mock_features.py index 8023a9a..87545d0 100644 --- a/src/mock_features/mock_features.py +++ b/src/mock_features/mock_features.py @@ -1,5 +1,3 @@ -import configparser -from io import StringIO from typing import Any, Dict class MockFeatures: @@ -20,52 +18,13 @@ def __init__(self): } } - @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.""" - parser = configparser.ConfigParser( - delimiters=("=", ":"), - interpolation=None, - ) - parser.optionxform = str - with open(file_path, encoding="utf-8") as stream: - parser.read_file(stream) - self._load_from_parser(parser) + """Stub for loading features from a file.""" + pass def from_string(self, string): - """Loads INI-style features from a string.""" - parser = configparser.ConfigParser( - delimiters=("=", ":"), - interpolation=None, - ) - parser.optionxform = str - parser.read_file(StringIO(string)) - self._load_from_parser(parser) + """Stub for loading features from a string.""" + pass def add_section(self, section: str): """Adds a section to the features if it doesn't exist.""" diff --git a/src/nmesh/backend.py b/src/nmesh/backend.py deleted file mode 100644 index 5a65f7f..0000000 --- a/src/nmesh/backend.py +++ /dev/null @@ -1,237 +0,0 @@ -from __future__ import annotations - -import copy -from dataclasses import dataclass, field -from typing import Any, Protocol, runtime_checkable - - -@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) - dim: int = 3 - - -@runtime_checkable -class MeshBackendProtocol(Protocol): - def mesh_scale_node_positions(self, raw_mesh: RawMesh, scale: float): ... - def mesh_writefile(self, path: str, raw_mesh: RawMesh): ... - 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): ... - def mesh_plotinfo_points(self, raw_mesh: RawMesh): ... - def mesh_plotinfo_pointsregions(self, raw_mesh: RawMesh): ... - def mesh_plotinfo_simplices(self, raw_mesh: RawMesh): ... - def mesh_plotinfo_simplicesregions(self, raw_mesh: RawMesh): ... - def mesh_plotinfo_surfaces_and_surfacesregions(self, raw_mesh: RawMesh): ... - def mesh_plotinfo_links(self, raw_mesh: RawMesh): ... - def mesh_dim(self, raw_mesh: RawMesh) -> int: ... - def mesh_plotinfo_regionvolumes(self, raw_mesh: RawMesh): ... - def mesh_plotinfo_periodic_points_indices(self, raw_mesh: RawMesh): ... - def mesh_set_vertex_distribution(self, raw_mesh: RawMesh, dist): ... - def mesh_get_permutation(self, raw_mesh: RawMesh): ... - def mesh_readfile(self, filename: str, do_reorder: bool, do_distribute: bool): ... - def copy_mesher_defaults(self, defaults: dict[str, Any]) -> dict[str, Any]: ... - def mesh_bodies_raw( - self, - driver, - mesher: dict[str, Any], - bb_min, - bb_max, - mesh_ext: int, - objects, - a0: float, - density: str, - fixed, - mobile, - simply, - periodic, - cache, - hints, - ): ... - def mesh_from_points_and_simplices( - self, - dim: int, - points, - simplices, - regions, - periodic, - reorder: bool, - distribute: bool, - ) -> RawMesh: ... - def body_union(self, objs): ... - def body_difference(self, obj1, objs): ... - def body_intersection(self, objs): ... - def body_shifted_sc(self, obj, shift): ... - def body_shifted_bc(self, obj, shift): ... - def body_scaled(self, obj, scale): ... - def body_rotated_sc(self, obj, a1, a2, ang): ... - def body_rotated_bc(self, obj, a1, a2, ang): ... - def body_rotated_axis_sc(self, obj, axis, ang): ... - def body_rotated_axis_bc(self, obj, axis, ang): ... - def body_box(self, p1, p2): ... - def body_ellipsoid(self, length): ... - def body_frustum(self, c1, r1, c2, r2): ... - def body_helix(self, c1, r1, c2, r2): ... - - @property - def mesher_defaults(self) -> dict[str, Any]: ... - - -class StubMeshBackend: - """Lightweight in-memory backend used until the Python mesher is complete.""" - - def mesh_scale_node_positions(self, raw_mesh: RawMesh, scale: float): - for point in raw_mesh.points: - for index, value in enumerate(point): - point[index] = value * scale - - def mesh_writefile(self, path: str, raw_mesh: RawMesh): - return None - - def mesh_nr_simplices(self, raw_mesh: RawMesh) -> int: - return len(raw_mesh.simplices) - - def mesh_nr_points(self, raw_mesh: RawMesh) -> int: - return len(raw_mesh.points) - - def mesh_plotinfo(self, raw_mesh: RawMesh): - 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): - return raw_mesh.points - - def mesh_plotinfo_pointsregions(self, raw_mesh: RawMesh): - return raw_mesh.point_regions - - def mesh_plotinfo_simplices(self, raw_mesh: RawMesh): - return raw_mesh.simplices - - def mesh_plotinfo_simplicesregions(self, raw_mesh: RawMesh): - return raw_mesh.regions - - def mesh_plotinfo_surfaces_and_surfacesregions(self, raw_mesh: RawMesh): - return [raw_mesh.surfaces, []] - - def mesh_plotinfo_links(self, raw_mesh: RawMesh): - return raw_mesh.links - - def mesh_dim(self, raw_mesh: RawMesh) -> int: - if raw_mesh.points: - return len(raw_mesh.points[0]) - return raw_mesh.dim - - def mesh_plotinfo_regionvolumes(self, raw_mesh: RawMesh): - return raw_mesh.region_volumes - - def mesh_plotinfo_periodic_points_indices(self, raw_mesh: RawMesh): - return raw_mesh.periodic_point_indices - - def mesh_set_vertex_distribution(self, raw_mesh: RawMesh, dist): - return None - - def mesh_get_permutation(self, raw_mesh: RawMesh): - return raw_mesh.permutation - - def mesh_readfile(self, filename: str, do_reorder: bool, do_distribute: bool): - return RawMesh() - - def copy_mesher_defaults(self, defaults: dict[str, Any]) -> dict[str, Any]: - return copy.deepcopy(defaults) - - def mesh_bodies_raw( - self, - driver, - mesher: dict[str, Any], - bb_min, - bb_max, - mesh_ext: int, - objects, - a0: float, - density: str, - fixed, - mobile, - simply, - periodic, - cache, - hints, - ): - return RawMesh(dim=len(bb_min)) - - def mesh_from_points_and_simplices( - self, - dim: int, - points, - simplices, - regions, - periodic, - reorder: bool, - distribute: bool, - ) -> RawMesh: - return RawMesh( - points=points, - simplices=simplices, - regions=regions, - dim=dim, - periodic_point_indices=periodic, - ) - - def body_union(self, objs): - return ("union", objs) - - def body_difference(self, obj1, objs): - return ("difference", obj1, objs) - - def body_intersection(self, objs): - return ("intersection", objs) - - def body_shifted_sc(self, obj, shift): - return ("shifted_sc", obj, shift) - - def body_shifted_bc(self, obj, shift): - return ("shifted_bc", obj, shift) - - def body_scaled(self, obj, scale): - return ("scaled", obj, scale) - - def body_rotated_sc(self, obj, a1, a2, ang): - return ("rotated_sc", obj, a1, a2, ang) - - def body_rotated_bc(self, obj, a1, a2, ang): - return ("rotated_bc", obj, a1, a2, ang) - - def body_rotated_axis_sc(self, obj, axis, ang): - return ("rotated_axis_sc", obj, axis, ang) - - def body_rotated_axis_bc(self, obj, axis, ang): - return ("rotated_axis_bc", obj, axis, ang) - - def body_box(self, p1, p2): - return ("box", p1, p2) - - def body_ellipsoid(self, length): - return ("ellipsoid", length) - - def body_frustum(self, c1, r1, c2, r2): - return ("frustum", c1, r1, c2, r2) - - def body_helix(self, c1, r1, c2, r2): - return ("helix", c1, r1, c2, r2) - - @property - def mesher_defaults(self) -> dict[str, Any]: - return {"parameters": {}} - - -backend: MeshBackendProtocol = StubMeshBackend() diff --git a/src/nmesh/mesher/__init__.py b/src/nmesh/mesher/__init__.py deleted file mode 100644 index 796bf05..0000000 --- a/src/nmesh/mesher/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from .defaults import MeshingParameters, PointFate, SimplexRegion -from .driver import make_mg_gendriver, do_every_n_steps_driver, MeshEngineStatus, MeshEngineCommand diff --git a/src/nmesh/mesher/defaults.py b/src/nmesh/mesher/defaults.py deleted file mode 100644 index cc73467..0000000 --- a/src/nmesh/mesher/defaults.py +++ /dev/null @@ -1,302 +0,0 @@ -import copy -import logging -from dataclasses import dataclass -from enum import IntEnum -from functools import partialmethod - -from mock_features import MockFeatures - -log = logging.getLogger(__name__) - - -@dataclass(frozen=True, slots=True) -class ParameterSpec: - legacy_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_LEGACY = { - spec.legacy_name: spec for spec in PUBLIC_PARAMETER_SPECS -} - -LEGACY_TO_INTERNAL = { - spec.legacy_name: spec.internal_name for spec in PUBLIC_PARAMETER_SPECS -} -INTERNAL_TO_LEGACY = { - spec.internal_name: spec.legacy_name for spec in PUBLIC_PARAMETER_SPECS -} -LEGACY_SETTER_NAMES = ( - "shape_force_scale", - "volume_force_scale", - "neigh_force_scale", - "irrel_elem_force_scale", - "time_step_scale", - "thresh_add", - "thresh_del", - "topology_threshold", - "tolerated_rel_move", - "max_steps", - "initial_settling_steps", - "sliver_correction", - "smallest_volume_ratio", - "max_relaxation", -) - - -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, max_step, init_val, final_val): - """Linear function from init_val to final_val, saturating at max_step.""" - 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): - """Repulsing force between two mobile nodes.""" - if reduced_distance > 1.0: - return 0.0 - return 1.0 - reduced_distance - - -def default_boundary_node_force_fun(reduced_distance): - """Strongly repelling potential for boundary points.""" - if reduced_distance > 1.0: - return 0.0 - try: - return 1.0 / reduced_distance - 1.0 - except ZeroDivisionError: - return 1e12 - - -def default_handle_point_density_fun(rng, avg_stats, thresh_add, thresh_del): - """Default function to insert or delete points based on density and force.""" - avg_density, avg_force = avg_stats - if avg_density < thresh_add: - if rng.random() < 0.1: - log.debug("Dtl (dens_avg=%s) - adding point.", avg_density) - return PointFate.ADD_ANOTHER - return PointFate.DO_NOTHING - - if avg_force < 0.07: - if rng.random() < 0.2: - log.debug("Ftl (avg_force=%s) - adding point.", avg_force) - return PointFate.ADD_ANOTHER - return PointFate.DO_NOTHING - - if avg_density > thresh_del: - prob = 0.3 + (avg_density - thresh_del) * 0.1 - if rng.random() < prob: - log.debug("Dth (dens_avg=%s) - axing point.", avg_density) - return PointFate.DELETE - return PointFate.DO_NOTHING - - if avg_force > 0.5: - prob = 0.4 + (avg_force - 0.5) * 0.1 - 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): - keys = [name] - internal = LEGACY_TO_INTERNAL.get(name) - legacy = INTERNAL_TO_LEGACY.get(name) - - if internal is not None and internal not in keys: - keys.append(internal) - if legacy is not None and legacy not in keys: - keys.append(legacy) - - return keys - - -class MeshingParameters(MockFeatures): - """Pure-Python mesher parameter container.""" - - def __init__(self, string=None, file=None): - super().__init__() - self.dim = None - self._setup_defaults() - if file: - self.from_file(file) - if string: - self.from_string(string) - self.add_section("user-modifications") - - def _setup_defaults(self): - # Internal mesher field names and defaults used by the Python port. - self._params = { - "nr_probes_for_determining_volume": 100000, - "boundary_condition_acceptable_fuzz": 1e-6, - "boundary_condition_max_nr_correction_steps": 200, - "boundary_condition_debuglevel": 0, - "relaxation_debuglevel": 0, - "controller_step_limit_min": 500, - "controller_max_time_step": 10.0, - "initial_relaxation_weight_fun": default_initial_relaxation_weight, - "relaxation_force_fun": default_relaxation_force_fun, - "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} - ) - - def _get_section_name(self): - 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, name): - for key in _candidate_keys(name): - value = self.get(section, key) - if value is not None: - return value - return None - - def _canonical_key(self, name): - internal = LEGACY_TO_INTERNAL.get(name, name) - if internal in self._params or name in LEGACY_TO_INTERNAL: - return internal - return name - - def __getitem__(self, name): - user_value = self._lookup("user-modifications", name) - if user_value is not None: - return user_value - - if self.dim is not None: - section_value = self._lookup(self._get_section_name(), name) - if section_value is not None: - return section_value - - canonical = self._canonical_key(name) - if canonical in self._params: - return self._params[canonical] - - return None - - def __setitem__(self, key, value): - canonical = self._canonical_key(key) - self._params[canonical] = value - self.set("user-modifications", canonical, value) - - def _sync_dimension_section(self, dim): - self.dim = dim - section = self._get_section_name() - - for key, value in self.items("user-modifications"): - section_key = INTERNAL_TO_LEGACY.get(key, key) - self.set(section, section_key, value) - - return section - - def to_mesher_config(self, dim): - self._sync_dimension_section(dim) - - resolved = {} - for spec in PUBLIC_PARAMETER_SPECS: - value = self[spec.legacy_name] - if value is None: - continue - resolved[spec.internal_name] = spec.cast(value) - - return resolved - - def apply_to_mesher(self, mesher, dim): - self._sync_dimension_section(dim) - mesher.setdefault("parameters", {}) - - for spec in PUBLIC_PARAMETER_SPECS: - value = self[spec.legacy_name] - if value is None: - continue - mesher["parameters"][spec.internal_name] = spec.cast(value) - - return mesher - - def _set_parameter(self, name, value): - self[name] = PUBLIC_PARAMETER_SPECS_BY_LEGACY[name].cast(value) - - def copy(self): - return copy.deepcopy(self) - - -for _name in LEGACY_SETTER_NAMES: - setattr( - MeshingParameters, - f"set_{_name}", - partialmethod(MeshingParameters._set_parameter, _name), - ) diff --git a/src/nmesh/mesher/driver.py b/src/nmesh/mesher/driver.py deleted file mode 100644 index 2ababfd..0000000 --- a/src/nmesh/mesher/driver.py +++ /dev/null @@ -1,181 +0,0 @@ -import inspect -import logging -from enum import Enum - -log = logging.getLogger(__name__) - -LEGACY_CALLBACK_DOCS = { - "COORDS": "Coordinates of points", - "LINKS": "Links in the mesh (pairs of point indices)", - "SIMPLICES": ( - "Simplex info (points-indices,((circumcirc center,cc radius)," - "(ic center,ic radius),region))" - ), - "POINT-BODIES": ( - "Which bodies does the corresponding point belong to (body index list)" - ), - "SURFACES": ( - "Surface elements info (points-indices,((circumcirc center,cc radius)," - "(ic center,ic radius),region))" - ), - "REGION-VOLUMES": "Volume for every region", -} - - -class MeshEngineCommand(Enum): - DO_STEP = 1 - DO_EXTRACT = 2 - - -class MeshEngineStatus(Enum): - FINISHED_STEP_LIMIT_REACHED = 1 - FINISHED_FORCE_EQUILIBRIUM_REACHED = 2 - CAN_CONTINUE = 3 - PRODUCED_INTERMEDIATE_MESH = 4 - - -def _looks_like_legacy_payload(mesh): - return ( - isinstance(mesh, list) - and all( - isinstance(entry, tuple) - and len(entry) == 3 - and isinstance(entry[0], str) - for entry in mesh - ) - ) - - -def _mesh_payload(mesh): - if _looks_like_legacy_payload(mesh): - return mesh - - return [ - ("COORDS", LEGACY_CALLBACK_DOCS["COORDS"], getattr(mesh, "points", [])), - ("LINKS", LEGACY_CALLBACK_DOCS["LINKS"], getattr(mesh, "links", [])), - ( - "SIMPLICES", - LEGACY_CALLBACK_DOCS["SIMPLICES"], - getattr(mesh, "simplices", []), - ), - ( - "POINT-BODIES", - LEGACY_CALLBACK_DOCS["POINT-BODIES"], - getattr(mesh, "point_regions", []), - ), - ( - "SURFACES", - LEGACY_CALLBACK_DOCS["SURFACES"], - getattr(mesh, "surfaces", []), - ), - ( - "REGION-VOLUMES", - LEGACY_CALLBACK_DOCS["REGION-VOLUMES"], - getattr(mesh, "region_volumes", []), - ), - ] - - -def _callback_accepts_piece_number(callback): - try: - signature = inspect.signature(callback) - except (TypeError, ValueError): - return True - - try: - signature.bind_partial(0, 0, []) - return True - except TypeError: - return False - - -def _invoke_callback(callback, accepts_piece_number, nr_piece, nr_step, mesh): - payload = _mesh_payload(mesh) - if accepts_piece_number: - callback(nr_piece, nr_step, payload) - else: - callback(nr_step, payload) - - -def do_every_n_steps_driver(nr_steps_per_bunch, callback, engine_func): - """ - Python port of Mesh.do_every_n_steps_driver using an iterative loop. - """ - if nr_steps_per_bunch <= 0: - raise ValueError("nr_steps_per_bunch must be positive") - - nr_step = 0 - status_out = engine_func(MeshEngineCommand.DO_STEP) - - while True: - log.info("do_every_n_steps_driver [%d]", nr_step) - status, data = status_out - - if status in ( - MeshEngineStatus.FINISHED_STEP_LIMIT_REACHED, - MeshEngineStatus.FINISHED_FORCE_EQUILIBRIUM_REACHED, - ): - return status_out - - if status == MeshEngineStatus.CAN_CONTINUE: - cont = data - if (nr_step % nr_steps_per_bunch != 0) or nr_step == 0: - nr_step += 1 - status_out = cont(MeshEngineCommand.DO_STEP) - continue - - log.debug("Scheduling Mesh Extraction!") - status_out = cont(MeshEngineCommand.DO_EXTRACT) - continue - - if status == MeshEngineStatus.PRODUCED_INTERMEDIATE_MESH: - mesh, cont = data - log.debug("Extracted Mesh!") - if nr_step != 0: - callback(nr_step, mesh) - nr_step += 1 - status_out = cont(MeshEngineCommand.DO_STEP) - continue - - raise ValueError(f"Unknown mesh engine status: {status}") - - -def make_mg_gendriver(interval, callback): - """ - Returns a gendriver compatible with both the legacy piece-aware API and the - simplified direct-driver test usage. - """ - accepts_piece_number = _callback_accepts_piece_number(callback) - - def gendriver(piece_or_engine): - if callable(piece_or_engine): - return do_every_n_steps_driver( - interval, - lambda nr_step, mesh: _invoke_callback( - callback, - accepts_piece_number, - 0, - nr_step, - mesh, - ), - piece_or_engine, - ) - - nr_piece = int(piece_or_engine) - - def driver(engine_func): - return do_every_n_steps_driver( - interval, - lambda nr_step, mesh: _invoke_callback( - callback, - accepts_piece_number, - nr_piece, - nr_step, - mesh, - ), - engine_func, - ) - - return driver - - return gendriver diff --git a/src/nmesh/meshio_support.py b/src/nmesh/meshio_support.py deleted file mode 100644 index 1997441..0000000 --- a/src/nmesh/meshio_support.py +++ /dev/null @@ -1,86 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import numpy as np - -from .backend import RawMesh - -try: - import meshio as _meshio -except ImportError: - _meshio = None - - -_CELL_TYPE_BY_DIM = { - 1: "line", - 2: "triangle", - 3: "tetra", -} - -_DIM_BY_CELL_TYPE = {value: key for key, value in _CELL_TYPE_BY_DIM.items()} - - -def meshio_available() -> bool: - return _meshio is not None - - -def _cell_type_for(raw_mesh: RawMesh) -> str: - if raw_mesh.simplices: - simplex_size = len(raw_mesh.simplices[0]) - if simplex_size == 2: - return "line" - if simplex_size == 3: - return "triangle" - if simplex_size == 4: - return "tetra" - - return _CELL_TYPE_BY_DIM.get(raw_mesh.dim, "tetra") - - -def _regions_from_meshio(mesh, cell_type: str, count: int) -> list[int]: - 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 save_raw_mesh_with_meshio(path: str | Path, raw_mesh: RawMesh) -> None: - if _meshio is None: - raise RuntimeError("meshio is not installed") - - cell_type = _cell_type_for(raw_mesh) - cells = [(cell_type, np.asarray(raw_mesh.simplices, dtype=int))] - cell_data = None - if raw_mesh.regions: - cell_data = {"region": [np.asarray(raw_mesh.regions, dtype=int)]} - - mesh = _meshio.Mesh( - points=np.asarray(raw_mesh.points, dtype=float), - cells=cells, - cell_data=cell_data, - ) - _meshio.write(Path(path), mesh) - - -def load_raw_mesh_with_meshio(path: str | Path) -> RawMesh: - if _meshio is None: - raise RuntimeError("meshio is not installed") - - mesh = _meshio.read(Path(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: - raise ValueError(f"No supported simplex cells found in {path}") - - cell_type, simplices = supported - 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], - ) diff --git a/src/nmesh/nmesh.py b/src/nmesh/nmesh.py index 97f8572..d3ed2ea 100644 --- a/src/nmesh/nmesh.py +++ b/src/nmesh/nmesh.py @@ -1,57 +1,82 @@ -from __future__ import annotations - import math import logging -from collections.abc import Iterable, Sequence -from typing import Any, TextIO +from typing import List, Tuple, Optional, Any, Union from pathlib import Path import itertools +from mock_features import MockFeatures from . import utils -from .backend import RawMesh, backend -from .mesher import MeshingParameters, make_mg_gendriver -from .meshio_support import load_raw_mesh_with_meshio, meshio_available, 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] - +# --- Stubs for External Dependencies --- + +class OCamlStub: + """Stub for the OCaml backend interface.""" + + # Mesher defaults setters + def mesher_defaults_set_shape_force_scale(self, mesher, scale): pass + def mesher_defaults_set_volume_force_scale(self, mesher, scale): pass + def mesher_defaults_set_neigh_force_scale(self, mesher, scale): pass + def mesher_defaults_set_irrel_elem_force_scale(self, mesher, scale): pass + def mesher_defaults_set_time_step_scale(self, mesher, scale): pass + def mesher_defaults_set_thresh_add(self, mesher, thresh): pass + def mesher_defaults_set_thresh_del(self, mesher, thresh): pass + def mesher_defaults_set_topology_threshold(self, mesher, thresh): pass + def mesher_defaults_set_tolerated_rel_movement(self, mesher, scale): pass + def mesher_defaults_set_max_relaxation_steps(self, mesher, steps): pass + def mesher_defaults_set_initial_settling_steps(self, mesher, steps): pass + def mesher_defaults_set_sliver_correction(self, mesher, scale): pass + def mesher_defaults_set_smallest_allowed_volume_ratio(self, mesher, scale): pass + def mesher_defaults_set_movement_max_freedom(self, mesher, scale): pass + + # Mesh operations + def mesh_scale_node_positions(self, raw_mesh, scale): pass + def mesh_writefile(self, path, raw_mesh): pass + def mesh_nr_simplices(self, raw_mesh): return 0 + def mesh_nr_points(self, raw_mesh): return 0 + def mesh_plotinfo(self, raw_mesh): return [[], [], [[], [], []]] + def mesh_plotinfo_points(self, raw_mesh): return [] + def mesh_plotinfo_pointsregions(self, raw_mesh): return [] + def mesh_plotinfo_simplices(self, raw_mesh): return [] + def mesh_plotinfo_simplicesregions(self, raw_mesh): return [] + def mesh_plotinfo_surfaces_and_surfacesregions(self, raw_mesh): return [[], []] + def mesh_plotinfo_links(self, raw_mesh): return [] + def mesh_dim(self, raw_mesh): return 3 + def mesh_plotinfo_regionvolumes(self, raw_mesh): return [] + def mesh_plotinfo_periodic_points_indices(self, raw_mesh): return [] + def mesh_set_vertex_distribution(self, raw_mesh, dist): pass + def mesh_get_permutation(self, raw_mesh): return [] + def mesh_readfile(self, filename, do_reorder, do_distribute): return "STUB_MESH" + + # Driver and Mesh creation + def make_mg_gendriver(self, interval, callback): return "STUB_DRIVER" + def copy_mesher_defaults(self, defaults): return "STUB_MESHER" + def mesh_bodies_raw(self, driver, mesher, bb_min, bb_max, mesh_ext, objects, a0, density, fixed, mobile, simply, periodic, cache, hints): return "STUB_MESH" + def mesh_from_points_and_simplices(self, dim, points, simplices, regions, periodic, reorder, distribute): return "STUB_MESH" + + # Body operations + def body_union(self, objs): return "STUB_OBJ_UNION" + def body_difference(self, obj1, objs): return "STUB_OBJ_DIFF" + def body_intersection(self, objs): return "STUB_OBJ_INTERSECT" + def body_shifted_sc(self, obj, shift): return obj + def body_shifted_bc(self, obj, shift): return obj + def body_scaled(self, obj, scale): return obj + def body_rotated_sc(self, obj, a1, a2, ang): return obj + def body_rotated_bc(self, obj, a1, a2, ang): return obj + def body_rotated_axis_sc(self, obj, axis, ang): return obj + def body_rotated_axis_bc(self, obj, axis, ang): return obj + + # Primitives + def body_box(self, p1, p2): return "STUB_BOX" + def body_ellipsoid(self, length): return "STUB_ELLIPSOID" + def body_frustum(self, c1, r1, c2, r2): return "STUB_FRUSTUM" + def body_helix(self, c1, r1, c2, r2): return "STUB_HELIX" + + @property + def mesher_defaults(self): return "STUB_DEFAULTS" -def _raw_mesh_as_legacy_write_data(raw_mesh: RawMesh): - simplices = list( - zip( - raw_mesh.regions or [1] * len(raw_mesh.simplices), - raw_mesh.simplices, - ) - ) - surfaces = list( - zip( - [1] * len(raw_mesh.surfaces), - raw_mesh.surfaces, - ) - ) - return raw_mesh.points, simplices, surfaces +ocaml = OCamlStub() def memory_report(tag: str): """Reports memory usage.""" @@ -60,24 +85,90 @@ def memory_report(tag: str): # --- Configuration --- +class MeshingParameters(MockFeatures): + """Parameters for the meshing algorithm, supporting multiple dimensions.""" + def __init__(self, string=None, file=None): + super().__init__() + self.dim = None + if file: self.from_file(file) + if string: self.from_string(string) + self.add_section('user-modifications') + + def _get_section_name(self): + 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 __getitem__(self, name): + val = self.get('user-modifications', name) + if val is not None: + return val + section = self._get_section_name() + return self.get(section, name) + + def __setitem__(self, key, value): + self.set('user-modifications', key, value) + + def set_shape_force_scale(self, v): self["shape_force_scale"] = float(v) + def set_volume_force_scale(self, v): self["volume_force_scale"] = float(v) + def set_neigh_force_scale(self, v): self["neigh_force_scale"] = float(v) + def set_irrel_elem_force_scale(self, v): self["irrel_elem_force_scale"] = float(v) + def set_time_step_scale(self, v): self["time_step_scale"] = float(v) + def set_thresh_add(self, v): self["thresh_add"] = float(v) + def set_thresh_del(self, v): self["thresh_del"] = float(v) + def set_topology_threshold(self, v): self["topology_threshold"] = float(v) + def set_tolerated_rel_move(self, v): self["tolerated_rel_move"] = float(v) + def set_max_steps(self, v): self["max_steps"] = int(v) + def set_initial_settling_steps(self, v): self["initial_settling_steps"] = int(v) + def set_sliver_correction(self, v): self["sliver_correction"] = float(v) + def set_smallest_volume_ratio(self, v): self["smallest_volume_ratio"] = float(v) + def set_max_relaxation(self, v): self["max_relaxation"] = float(v) + + def pass_parameters_to_ocaml(self, mesher, dim): + self.dim = dim + for key, value in self.items('user-modifications'): + section = self._get_section_name() + self.set(section, key, str(value)) + + params = [ + ("shape_force_scale", ocaml.mesher_defaults_set_shape_force_scale), + ("volume_force_scale", ocaml.mesher_defaults_set_volume_force_scale), + ("neigh_force_scale", ocaml.mesher_defaults_set_neigh_force_scale), + ("irrel_elem_force_scale", ocaml.mesher_defaults_set_irrel_elem_force_scale), + ("time_step_scale", ocaml.mesher_defaults_set_time_step_scale), + ("thresh_add", ocaml.mesher_defaults_set_thresh_add), + ("thresh_del", ocaml.mesher_defaults_set_thresh_del), + ("topology_threshold", ocaml.mesher_defaults_set_topology_threshold), + ("tolerated_rel_move", ocaml.mesher_defaults_set_tolerated_rel_movement), + ("max_steps", ocaml.mesher_defaults_set_max_relaxation_steps), + ("initial_settling_steps", ocaml.mesher_defaults_set_initial_settling_steps), + ("sliver_correction", ocaml.mesher_defaults_set_sliver_correction), + ("smallest_volume_ratio", ocaml.mesher_defaults_set_smallest_allowed_volume_ratio), + ("max_relaxation", ocaml.mesher_defaults_set_movement_max_freedom), + ] + + for key, setter in params: + val = self[key] + if val is not None: + setter(mesher, float(val) if "steps" not in key else int(val)) + def get_default_meshing_parameters(): """Returns default meshing parameters.""" return MeshingParameters() # --- Loading Utilities --- -def _is_nmesh_ascii_file(filename: str | Path) -> bool: +def _is_nmesh_ascii_file(filename): try: - with Path(filename).open(encoding="utf-8") as stream: - return stream.readline().startswith("# PYFEM") - except OSError: - return False + with open(filename, 'r') as f: + return f.readline().startswith("# PYFEM") + except: return False -def _is_nmesh_hdf5_file(filename: str | Path) -> bool: +def _is_nmesh_hdf5_file(filename): # This would normally use tables.isPyTablesFile return str(filename).lower().endswith('.h5') -def hdf5_mesh_get_permutation(filename: str | Path): +def hdf5_mesh_get_permutation(filename): """Stub for retrieving permutation from HDF5.""" log.warning("hdf5_mesh_get_permutation: HDF5 support is stubbed.") return None @@ -86,96 +177,79 @@ def hdf5_mesh_get_permutation(filename: str | Path): class MeshBase: """Base class for all mesh objects, providing access to mesh data.""" - def __init__(self, raw_mesh: RawMesh): + def __init__(self, raw_mesh): 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] + self._cache = {} 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: - if meshio_available(): - save_raw_mesh_with_meshio(path, self.raw_mesh) - return - raise RuntimeError("meshio is required to save non-PYFEM mesh formats") + ocaml.mesh_scale_node_positions(self.raw_mesh, float(scale)) + self._cache.pop('points', None) + self._cache.pop('region_volumes', None) - if isinstance(self.raw_mesh, RawMesh): - write_mesh(_raw_mesh_as_legacy_write_data(self.raw_mesh), out=path) - return - - backend.mesh_writefile(str(path), self.raw_mesh) + def save(self, filename: Union[str, Path]): + """Saves the mesh to a file (ASCII or HDF5).""" + path = str(filename) + if path.lower().endswith('.h5'): + log.info(f"Saving to HDF5 (stub): {path}") + else: + ocaml.mesh_writefile(path, self.raw_mesh) def __str__(self): - pts = backend.mesh_nr_points(self.raw_mesh) - simps = backend.mesh_nr_simplices(self.raw_mesh) + pts = ocaml.mesh_nr_points(self.raw_mesh) + simps = ocaml.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) + return ocaml.mesh_plotinfo(self.raw_mesh) @property def points(self): - return self._cached_backend_value("points", backend.mesh_plotinfo_points) + if 'points' not in self._cache: + self._cache['points'] = ocaml.mesh_plotinfo_points(self.raw_mesh) + return self._cache['points'] @property def simplices(self): - return self._cached_backend_value("simplices", backend.mesh_plotinfo_simplices) + if 'simplices' not in self._cache: + self._cache['simplices'] = ocaml.mesh_plotinfo_simplices(self.raw_mesh) + return self._cache['simplices'] @property def regions(self): - return self._cached_backend_value("regions", backend.mesh_plotinfo_simplicesregions) + if 'regions' not in self._cache: + self._cache['regions'] = ocaml.mesh_plotinfo_simplicesregions(self.raw_mesh) + return self._cache['regions'] @property def dim(self): - return backend.mesh_dim(self.raw_mesh) + return ocaml.mesh_dim(self.raw_mesh) @property def surfaces(self): - return backend.mesh_plotinfo_surfaces_and_surfacesregions(self.raw_mesh)[0] + return ocaml.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 - ) + if 'point_regions' not in self._cache: + self._cache['point_regions'] = ocaml.mesh_plotinfo_pointsregions(self.raw_mesh) + return self._cache['point_regions'] @property def links(self): """Returns all links (pairs of point indices).""" - return self._cached_backend_value("links", backend.mesh_plotinfo_links) + if 'links' not in self._cache: + self._cache['links'] = ocaml.mesh_plotinfo_links(self.raw_mesh) + return self._cache['links'] @property def region_volumes(self): """Returns volume of each region.""" - return self._cached_backend_value( - "region_volumes", backend.mesh_plotinfo_regionvolumes - ) + if 'region_volumes' not in self._cache: + self._cache['region_volumes'] = ocaml.mesh_plotinfo_regionvolumes(self.raw_mesh) + return self._cache['region_volumes'] @property def num_regions(self): @@ -185,112 +259,74 @@ def num_regions(self): @property def periodic_point_indices(self): """Returns indices of periodic nodes.""" - return self._cached_backend_value( - "periodic_indices", - backend.mesh_plotinfo_periodic_points_indices, - ) + if 'periodic_indices' not in self._cache: + self._cache['periodic_indices'] = ocaml.mesh_plotinfo_periodic_points_indices(self.raw_mesh) + return self._cache['periodic_indices'] @property def permutation(self): """Returns the node permutation mapping.""" - return backend.mesh_get_permutation(self.raw_mesh) + return ocaml.mesh_get_permutation(self.raw_mesh) def set_vertex_distribution(self, dist): """Sets vertex distribution.""" - backend.mesh_set_vertex_distribution(self.raw_mesh, dist) + ocaml.mesh_set_vertex_distribution(self.raw_mesh, dist) 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, - ): + def __init__(self, bounding_box, objects=[], a0=1.0, density="", + periodic=[], fixed_points=[], mobile_points=[], simply_points=[], + callback=None, mesh_bounding_box=False, meshing_parameters=None, + cache_name="", hints=[], **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) + + bb = [[float(x) for x in p] for p in 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: + + if not objects 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: + # Store points lists to allow appending later (mimicking original API) + self._fixed_points = [list(map(float, p)) for p in fixed_points] + self._mobile_points = [list(map(float, p)) for p in mobile_points] + self._simply_points = [list(map(float, p)) for p in simply_points] + + for obj in objects: 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) - ) + periodic_floats = [1.0 if p else 0.0 for p in periodic] if periodic else [0.0] * dim + + cb_func, cb_interval = callback if callback else (lambda a,b,c: None, 1000000) 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, + driver = ocaml.make_mg_gendriver(cb_interval, cb_func) + mesher = ocaml.copy_mesher_defaults(ocaml.mesher_defaults) + params.pass_parameters_to_ocaml(mesher, dim) + + # Note: In the original code, mesh generation happens in __init__. + # Adding points via methods afterwards wouldn't affect the already generated mesh + # unless we regenerate or if those methods were meant for pre-generation setup. + # However, checking lib1.py, __init__ calls mesh_bodies_raw immediately. + # The methods fixed_points/mobile_points in lib1.py just append to self.fixed_points + # which seems useless after __init__ unless the user manually triggers something else. + # But we will preserve them for API compatibility. + + raw = ocaml.mesh_bodies_raw( + driver, mesher, bb[0], bb[1], mesh_ext, obj_bodies, float(a0), + density, self._fixed_points, self._mobile_points, self._simply_points, periodic_floats, + cache_name, hints ) - - if raw is None: - raise RuntimeError("Mesh generation failed.") + + if raw is None: raise RuntimeError("Mesh generation failed.") super().__init__(raw) def default_fun(self, nr_piece, n, mesh): @@ -302,66 +338,54 @@ def extended_fun_driver(self, nr_piece, iteration_nr, mesh): if hasattr(self, 'fun_driver'): self.fun_driver(nr_piece, iteration_nr, mesh) - def fixed_points(self, points: Sequence[Sequence[float]] | None): + def fixed_points(self, points: List[List[float]]): """Adds fixed points to the mesh configuration.""" if points: - self._fixed_points.extend(_as_float_points(points)) + self._fixed_points.extend(points) - def mobile_points(self, points: Sequence[Sequence[float]] | None): + def mobile_points(self, points: List[List[float]]): """Adds mobile points to the mesh configuration.""" if points: - self._mobile_points.extend(_as_float_points(points)) + self._mobile_points.extend(points) - def simply_points(self, points: Sequence[Sequence[float]] | None): + def simply_points(self, points: List[List[float]]): """Adds simply points to the mesh configuration.""" if points: - self._simply_points.extend(_as_float_points(points)) + self._simply_points.extend(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) - elif meshio_available(): - raw = load_raw_mesh_with_meshio(path) + if not path.exists(): raise FileNotFoundError(f"File {filename} not found") + + # Determine format + if _is_nmesh_ascii_file(filename): + raw = ocaml.mesh_readfile(str(path), reorder, distribute) + elif _is_nmesh_hdf5_file(filename): + # load_hdf5 logic would go here + raw = ocaml.mesh_readfile(str(path), reorder, distribute) else: - raise ValueError( - f"Unknown mesh file format or meshio is unavailable: {filename}" - ) - + raise ValueError(f"Unknown mesh file format: {filename}") + 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) + def __init__(self, points=[], simplices_indices=[], simplices_regions=[], + periodic_point_indices=[], initial=0, do_reorder=False, + do_distribute=True): + + # Adjust for 1-based indexing if initial=1 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, + simplices_indices = [[idx - 1 for idx in s] for s in simplices_indices] + + raw = ocaml.mesh_from_points_and_simplices( + len(points[0]) if points else 3, + [[float(x) for x in p] for p in points], + [[int(x) for x in s] for s in simplices_indices], + [int(r) for r in simplices_regions], + periodic_point_indices, do_reorder, do_distribute ) super().__init__(raw) @@ -369,7 +393,7 @@ 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): +def save(mesh: MeshBase, filename: Union[str, Path]): """Alias for mesh.save for backward compatibility.""" mesh.save(filename) @@ -382,141 +406,89 @@ def save(mesh: MeshBase, filename: str | Path): class MeshObject: """Base class for geometric primitives and CSG operations.""" - def __init__( - self, - dim: int, - fixed: Sequence[Sequence[float]] | None = None, - mobile: Sequence[Sequence[float]] | None = None, - ): + def __init__(self, dim, fixed=[], mobile=[]): self.dim = dim - self.fixed_points = _as_float_points(fixed) - self.mobile_points = _as_float_points(mobile) + self.fixed_points = fixed + self.mobile_points = mobile self.obj: Any = None def shift(self, vector, system_coords=True): - self.obj = (backend.body_shifted_sc if system_coords else backend.body_shifted_bc)(self.obj, vector) + self.obj = (ocaml.body_shifted_sc if system_coords else ocaml.body_shifted_bc)(self.obj, vector) def scale(self, factors): - self.obj = backend.body_scaled(self.obj, factors) + self.obj = ocaml.body_scaled(self.obj, factors) def rotate(self, a1, a2, angle, system_coords=True): rad = math.radians(angle) - self.obj = (backend.body_rotated_sc if system_coords else backend.body_rotated_bc)(self.obj, a1, a2, rad) + self.obj = (ocaml.body_rotated_sc if system_coords else ocaml.body_rotated_bc)(self.obj, a1, a2, rad) def rotate_3d(self, axis, angle, system_coords=True): rad = math.radians(angle) - self.obj = (backend.body_rotated_axis_sc if system_coords else backend.body_rotated_axis_bc)(self.obj, axis, rad) + self.obj = (ocaml.body_rotated_axis_sc if system_coords else ocaml.body_rotated_axis_bc)(self.obj, axis, rad) - def transform(self, transformations: Iterable[tuple] | None, system_coords=True): + def transform(self, transformations, system_coords=True): """Applies a list of transformation tuples.""" - for t in transformations or []: + for t in transformations: name, *args = t - match name: - case "shift": - self.shift(args[0], system_coords) - case "scale": - self.scale(args[0]) - case "rotate": - self.rotate(args[0][0], args[0][1], args[1], system_coords) - case "rotate2d": - self.rotate(0, 1, args[0], system_coords) - case "rotate3d": - self.rotate_3d(args[0], args[1], system_coords) - case _: - raise ValueError(f"Unknown transformation {name!r}") + if name == "shift": self.shift(args[0], system_coords) + elif name == "scale": self.scale(args[0]) + elif name == "rotate": self.rotate(args[0][0], args[0][1], args[1], system_coords) + elif name == "rotate2d": self.rotate(0, 1, args[0], system_coords) + elif name == "rotate3d": self.rotate_3d(args[0], args[1], system_coords) class Box(MeshObject): - def __init__( - self, - p1, - p2, - transform=None, - fixed=None, - mobile=None, - system_coords=True, - use_fixed_corners=False, - ): + def __init__(self, p1, p2, transform=[], fixed=[], mobile=[], system_coords=True, use_fixed_corners=False): dim = len(p1) - fixed_points = _as_float_points(fixed) if use_fixed_corners: - fixed_points.extend([list(c) for c in itertools.product(*zip(p1, p2))]) - super().__init__(dim, fixed_points, mobile) - self.obj = backend.body_box([float(x) for x in p1], [float(x) for x in p2]) + fixed.extend([list(c) for c in itertools.product(*zip(p1, p2))]) + super().__init__(dim, fixed, mobile) + self.obj = ocaml.body_box([float(x) for x in p1], [float(x) for x in p2]) self.transform(transform, system_coords) class Ellipsoid(MeshObject): - def __init__( - self, - lengths, - transform=None, - fixed=None, - mobile=None, - system_coords=True, - ): + def __init__(self, lengths, transform=[], fixed=[], mobile=[], system_coords=True): super().__init__(len(lengths), fixed, mobile) - self.obj = backend.body_ellipsoid([float(x) for x in lengths]) + self.obj = ocaml.body_ellipsoid([float(x) for x in lengths]) self.transform(transform, system_coords) class Conic(MeshObject): - def __init__( - self, - c1, - r1, - c2, - r2, - transform=None, - fixed=None, - mobile=None, - system_coords=True, - ): + def __init__(self, c1, r1, c2, r2, transform=[], fixed=[], mobile=[], system_coords=True): super().__init__(len(c1), fixed, mobile) - self.obj = backend.body_frustum(c1, r1, c2, r2) + self.obj = ocaml.body_frustum(c1, r1, c2, r2) self.transform(transform, system_coords) class Helix(MeshObject): - def __init__( - self, - c1, - r1, - c2, - r2, - transform=None, - fixed=None, - mobile=None, - system_coords=True, - ): + def __init__(self, c1, r1, c2, r2, transform=[], fixed=[], mobile=[], system_coords=True): super().__init__(len(c1), fixed, mobile) - self.obj = backend.body_helix(c1, r1, c2, r2) + self.obj = ocaml.body_helix(c1, r1, c2, r2) self.transform(transform, system_coords) # --- CSG --- -def union(objects: Sequence[MeshObject]) -> MeshObject: - if len(objects) < 2: - raise ValueError("Union requires at least two objects") +def union(objects: List[MeshObject]) -> MeshObject: + if len(objects) < 2: raise ValueError("Union requires at least two objects") res = MeshObject(objects[0].dim) for o in objects: res.fixed_points.extend(o.fixed_points) res.mobile_points.extend(o.mobile_points) - res.obj = backend.body_union([o.obj for o in objects]) + res.obj = ocaml.body_union([o.obj for o in objects]) return res -def difference(mother: MeshObject, subtract: Sequence[MeshObject]) -> MeshObject: +def difference(mother: MeshObject, subtract: List[MeshObject]) -> MeshObject: res = MeshObject(mother.dim, mother.fixed_points[:], mother.mobile_points[:]) for o in subtract: res.fixed_points.extend(o.fixed_points) res.mobile_points.extend(o.mobile_points) - res.obj = backend.body_difference(mother.obj, [o.obj for o in subtract]) + res.obj = ocaml.body_difference(mother.obj, [o.obj for o in subtract]) return res -def intersect(objects: Sequence[MeshObject]) -> MeshObject: - if len(objects) < 2: - raise ValueError("Intersection requires at least two objects") +def intersect(objects: List[MeshObject]) -> MeshObject: + if len(objects) < 2: raise ValueError("Intersection requires at least two objects") res = MeshObject(objects[0].dim) for o in objects: res.fixed_points.extend(o.fixed_points) res.mobile_points.extend(o.mobile_points) - res.obj = backend.body_intersection([o.obj for o in objects]) + res.obj = ocaml.body_intersection([o.obj for o in objects]) return res # --- Utilities --- @@ -524,31 +496,24 @@ def intersect(objects: Sequence[MeshObject]) -> MeshObject: def outer_corners(mesh: MeshBase): """Determines the bounding box of the mesh nodes.""" coords = mesh.points - if not coords: - return None, None + 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]]: +def generate_1d_mesh_components(regions: List[Tuple[float, float]], discretization: float) -> Tuple: """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) + points, simplices, regions_ids = [], [], [] + point_map = {} + + def get_idx(v): + vk = round(v, 8) if vk not in point_map: point_map[vk] = len(points) - points.append([float(value)]) + points.append([float(v)]) return point_map[vk] for rid, (start, end) in enumerate(regions, 1): - if start > end: - start, end = end, start + if start > end: start, end = end, start steps = max(1, int(abs((end - start) / discretization))) step = (end - start) / steps last = get_idx(start) @@ -557,13 +522,13 @@ def get_idx(value: float) -> int: simplices.append([last, curr]) regions_ids.append(rid) last = curr - + + # Note: original unidmesher also returned surfaces, but simplified here + # Standard format for mesh_from_points_and_simplices: + # simplices are list of point indices, regions are separate list return points, simplices, regions_ids -def generate_1d_mesh( - regions: Sequence[tuple[float, float]], - discretization: float, -) -> MeshBase: +def generate_1d_mesh(regions: List[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) @@ -574,43 +539,36 @@ def to_lists(mesh: MeshBase): tolists = to_lists -def write_mesh( - mesh_data, - out: str | Path | TextIO | None = None, - check=True, - float_fmt=" %f", -): +def write_mesh(mesh_data, out=None, check=True, float_fmt=" %f"): """ Writes mesh data (points, simplices, surfaces) to a file in nmesh format. mesh_data: (points, simplices, surfaces) """ 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(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" - + if out is None: print(content) elif isinstance(out, (str, Path)): - Path(out).write_text(content, encoding="utf-8") + Path(out).write_text(content) else: out.write(content) diff --git a/tests/nmesh/test_defaults.py b/tests/nmesh/test_defaults.py deleted file mode 100644 index 36ed82a..0000000 --- a/tests/nmesh/test_defaults.py +++ /dev/null @@ -1,90 +0,0 @@ -import pytest -from nmesh.mesher import MeshingParameters - -def test_meshing_parameters_defaults(): - params = MeshingParameters() - assert params["controller_shape_force_scale"] == 0.1 - assert params["controller_volume_force_scale"] == 0.0 - 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_legacy_setter_api(): - params = MeshingParameters() - for setter_name in ( - "set_shape_force_scale", - "set_volume_force_scale", - "set_neigh_force_scale", - "set_irrel_elem_force_scale", - "set_time_step_scale", - "set_thresh_add", - "set_thresh_del", - "set_topology_threshold", - "set_tolerated_rel_move", - "set_max_steps", - "set_initial_settling_steps", - "set_sliver_correction", - "set_smallest_volume_ratio", - "set_max_relaxation", - ): - 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 - assert params["controller_shape_force_scale"] == 0.9 - - params["max_steps"] = 1500 - assert params["controller_step_limit_max"] == 1500 - assert params["max_steps"] == 1500 - -def test_meshing_parameters_copy(): - params = MeshingParameters() - params["val"] = 1 - params2 = params.copy() - params2["val"] = 2 - assert params["val"] == 1 - assert params2["val"] == 2 - -def test_meshing_parameters_apply_to_mesher(): - params = MeshingParameters() - params["shape_force_scale"] = 0.5 - params["max_steps"] = 2000 - - mesher = {"parameters": {"existing": 1}} - params.apply_to_mesher(mesher, 3) - - assert mesher["parameters"]["existing"] == 1 - assert mesher["parameters"]["controller_shape_force_scale"] == 0.5 - assert mesher["parameters"]["controller_step_limit_max"] == 2000 - -def test_meshing_parameters_can_load_legacy_config_string(): - params = MeshingParameters( - string=""" -[nmesh-3D] -shape_force_scale : 0.25 -max_steps : 1500 -""" - ) - params.dim = 3 - - assert params["shape_force_scale"] == 0.25 - assert params["controller_shape_force_scale"] == 0.25 - assert params["max_steps"] == 1500 - assert params["controller_step_limit_max"] == 1500 diff --git a/tests/nmesh/test_driver.py b/tests/nmesh/test_driver.py deleted file mode 100644 index 297d03d..0000000 --- a/tests/nmesh/test_driver.py +++ /dev/null @@ -1,93 +0,0 @@ -from nmesh.mesher import make_mg_gendriver, MeshEngineStatus, MeshEngineCommand - - -def make_engine(limit, mesh_factory): - class Engine: - def __init__(self): - self.step = 0 - - def run(self, cmd): - if cmd == MeshEngineCommand.DO_STEP: - self.step += 1 - if self.step > limit: - return MeshEngineStatus.FINISHED_STEP_LIMIT_REACHED, None - return MeshEngineStatus.CAN_CONTINUE, self.run - - if cmd == MeshEngineCommand.DO_EXTRACT: - return MeshEngineStatus.PRODUCED_INTERMEDIATE_MESH, ( - mesh_factory(), - self.run, - ) - - raise AssertionError(f"Unexpected command: {cmd}") - - return Engine().run - - -def test_driver_cadence(): - steps = [] - payloads = [] - - def callback(step, mesh): - steps.append(step) - payloads.append(mesh) - - def mesh_factory(): - return [ - ("COORDS", "Coordinates", []), - ("LINKS", "Links", []), - ("SIMPLICES", "Simplex info", []), - ] - - driver = make_mg_gendriver(3, callback) - driver(make_engine(10, mesh_factory)) - - assert steps == [3, 6, 9] - for payload in payloads: - assert isinstance(payload, list) - assert payload[0][0] == "COORDS" - - -def test_driver_supports_legacy_callback_signature_and_piece_numbers(): - calls = [] - - class MockMesh: - points = [[0.0, 0.0, 0.0]] - links = [(0, 1)] - simplices = [([0, 1, 2, 3], (([], 0.0), ([], 0.0), 1))] - point_regions = [[1]] - surfaces = [([0, 1, 2], (([], 0.0), ([], 0.0), 1))] - region_volumes = [1.0] - - def callback(piece, step, mesh): - calls.append((piece, step, mesh)) - - driver = make_mg_gendriver(2, callback) - driver(7)(make_engine(5, MockMesh)) - - assert [call[:2] for call in calls] == [(7, 2), (7, 4)] - payload = calls[0][2] - assert [tag for tag, _, _ in payload] == [ - "COORDS", - "LINKS", - "SIMPLICES", - "POINT-BODIES", - "SURFACES", - "REGION-VOLUMES", - ] - assert payload[0][2] == [[0.0, 0.0, 0.0]] - - -def test_driver_handles_large_step_counts_without_recursion(): - steps = [] - - def callback(step, mesh): - steps.append(step) - - driver = make_mg_gendriver(100, callback) - status, _ = driver(make_engine(1100, lambda: [])) - - assert status == MeshEngineStatus.FINISHED_STEP_LIMIT_REACHED - assert steps[0] == 100 - assert steps[-1] == 1000 - assert len(steps) == 10 From e4c91cf6de96be6b49b8a007b225399de896f9f2 Mon Sep 17 00:00:00 2001 From: Emanuel Pituch <32016786+epituch@users.noreply.github.com> Date: Fri, 20 Mar 2026 17:34:16 -0700 Subject: [PATCH 11/14] Remove other un-needed files --- ...mesh-ocaml-to-python3-migration-plan-v2.md | 138 --------------- docs/nmesh-ocaml-to-python3-migration-plan.md | 166 ------------------ src/mock_features/__init__.py | 2 +- 3 files changed, 1 insertion(+), 305 deletions(-) delete mode 100644 docs/nmesh-ocaml-to-python3-migration-plan-v2.md delete mode 100644 docs/nmesh-ocaml-to-python3-migration-plan.md diff --git a/docs/nmesh-ocaml-to-python3-migration-plan-v2.md b/docs/nmesh-ocaml-to-python3-migration-plan-v2.md deleted file mode 100644 index 5757505..0000000 --- a/docs/nmesh-ocaml-to-python3-migration-plan-v2.md +++ /dev/null @@ -1,138 +0,0 @@ -# Refined NMesh OCaml to Python 3 Migration Plan (V3 - Comprehensive Engineering & Management) - -This plan details the migration of the `nmesh` library from a hybrid Python 2 / OCaml implementation to a pure Python 3 implementation, combining technical depth with clear work packages and acceptance criteria. - -## 1. Objectives -- **Pure Python 3:** Eliminate the OCaml dependency and the `ocaml` Python module. -- **NumPy-First Architecture:** Mandate NumPy as the foundation for all internal data storage and linear algebra. -- **Performance Parity:** Use vectorization and optimized libraries (`scipy`, `numba`) to ensure performance parity with OCaml. -- **API Parity:** Maintain backward compatibility with the existing `nmesh` Python API. -- **Modular Design:** Split the monolithic `nmesh.py` into maintainable sub-modules. -- **Incremental Verification:** Mandate unit tests for every module to ensure parity with the legacy implementation. - -## 2. Proposed Module Structure (`nmesh/` directory) -The `nmesh` package will be reorganized as follows: - -```text -nmesh/ -├── __init__.py # Exposed public API (Mesh, MeshFromFile, load, save, etc.) -├── core.py # RawMesh data model and core state management -├── geometry/ -│ ├── __init__.py # Geometry primitives (Box, Ellipsoid, etc.) -│ ├── csg.py # CSG operations (union, difference, intersect) -│ └── transform.py # Affine transformations and matrix logic -├── mesher/ -│ ├── __init__.py # High-level mesh_it_work coordination -│ ├── defaults.py # Mesher parameter management (MeshingParameters) -│ ├── driver.py # Callback driver semantics (make_mg_gendriver) -│ ├── forces.py # Physics/Force calculations (Shape, Volume, etc.) -│ ├── relaxation.py # Iterative relaxation loop and JIT-optimized logic -│ └── periodic.py # Periodic boundary condition bookkeeping -├── io/ -│ ├── __init__.py # Unified load/save interface -│ ├── ascii.py # # PYFEM 1.0 reader/writer -│ └── h5.py # h5py-based HDF5 operations -└── utils.py # Math/NumPy snippets and general helpers -``` - -## 3. Core Data Model (`RawMesh`) -| Field | Shape | Type | Description | -|-------|-------|------|-------------| -| `points` | `(N, dim)` | `float64` | Cartesian coordinates of all nodes. | -| `simplices` | `(M, dim+1)` | `int32` | Indices into `points` forming the mesh elements. | -| `regions` | `(M,)` | `int32` | Region ID for each simplex. | -| `point_regions` | `(N, K)` | `int32` | Sparse mapping or ragged list of regions each point belongs to. | -| `periodic_indices`| `(P, 2)` | `int32` | Pairs of indices representing periodic node equivalences. | -| `permutation` | `(N,)` | `int32` | Map from original input indices to current reordered indices. | - ---- - -## 4. Migration Sections - -### Section 1: Foundation & Utilities (Porting `snippets.ml`) -**Goal:** Port utility primitives and math required by geometry and mesher internals. -- **Work Packages:** - 1. Port array/list helpers (`filter`, `position`, `one_shorter`) using NumPy vectorization. - 2. Port numeric helpers (determinant, inverse, cross product) using `numpy.linalg`. - 3. Port timing/memory reporting equivalent for `time_vmem_rss`. -- **Acceptance:** - 1. Utility tests pass with deterministic inputs in `tests/nmesh/test_utils.py`. - 2. Numerical helpers match OCaml reference outputs within `1e-9` tolerance. - -### Section 2: Mesher Defaults & Driver (Porting `mesh.ml` / `lib1.py`) -**Goal:** Move mesher parameter behavior and callback driver semantics fully to Python. -- **Work Packages:** - 1. Mirror `opt_mesher_defaults` values and field structure in `nmesh.mesher.defaults`. - 2. Implement full `MeshingParameters` setter mapping in pure Python. - 3. Port callback cadence and payload flow used by `make_mg_gendriver`. -- **Acceptance:** - 1. Setter-based tests reproduce expected overrides in `tests/nmesh/test_defaults.py`. - 2. Callback interval tests verify invocation cadence and payload shape. - -### Section 3: Geometry & CSG (Porting `mesh.ml` / `lib1.py`) -**Goal:** Replace OCaml body primitive and transformation logic with Python equivalents. -- **Work Packages:** - 1. Port primitive boundary-condition builders: `bc_box`, `bc_ellipsoid`, `bc_frustum`, `bc_helix`. - 2. Transition to NumPy-compatible Signed Distance Functions (SDFs). - 3. Implement affine transform logic (`shift`, `scale`, `rotate`) using matrix multiplication. - 4. Implement CSG operations: `union`, `difference`, `intersection`. -- **Acceptance:** - 1. Geometry tests validate in/out classification in `tests/nmesh/test_geometry.py`. - 2. Transform order tests validate equivalence to OCaml semantics. - -### Section 4: Core Meshing Engine (Porting `mesh.ml`) -**Goal:** Implement pure-Python equivalent of `mesh_bodies_raw` and `mesh_it_work`. -- **Work Packages:** - 1. Port `fem_geometry_from_bodies` for bodies, hints, and density handling. - 2. Implement point preparation: fixed/mobile/simply filtering and initial distribution. - 3. Port periodic point bookkeeping and index mapping. - 4. Port **Iterative Relaxation Loop**: - - Shape, Volume, Neighbor, and Irrelevant Element force calculations. - - JIT-optimized smoothing loop using `numba`. - - Insertion/Deletion of points (Voronoi/Delaunay criteria). -- **Acceptance:** - 1. Deterministic meshing succeeds with fixed RNG seeds in `tests/nmesh/test_integration.py`. - 2. Periodic and hint-driven scenarios complete with valid topology. - -### Section 5: Query & Extraction APIs (Porting `mesh.ml`) -**Goal:** Replace `mesh_plotinfo*` and related mesh query accessors. -- **Work Packages:** - 1. Implement `points`, `simplices`, `regions`, `links`, and `surfaces` accessors. - 2. Use `scipy.spatial.Delaunay` for adjacency and surface extraction. - 3. Implement cache invalidation logic for `MeshBase`. -- **Acceptance:** - 1. Accessor tests pass for populated meshes and edge cases. - 2. Cache tests validate stale-data invalidation when nodes are scaled. - -### Section 6: I/O & Constructors (Porting `lib1.py`) -**Goal:** Complete pure-Python constructor and file I/O parity. -- **Work Packages:** - 1. Port ASCII `# PYFEM` reader/writer rules. - 2. Implement `MeshFromFile`, `load`, `save` without OCaml backend. - 3. Implement HDF5 compatibility via `h5py` for all core datasets. -- **Acceptance:** - 1. ASCII and HDF5 round-trip tests preserve topology and metadata in `tests/nmesh/test_io.py`. - -### Section 7: Optimization & Distribution (Porting `mesh.ml`) -**Goal:** Finish reorder/distribution behavior using Metis. -- **Work Packages:** - 1. Implement reorder strategy using `scipy.sparse.csgraph.reverse_cuthill_mckee`. - 2. Implement partitioning/distribution flow using `pymetis`. -- **Acceptance:** - 1. Distribution invariants and connectivity bandwidth reductions are validated. - ---- - -## 5. Performance Strategy -1. **Vectorization:** Avoid Python loops for force calculations; use NumPy broadcasting. -2. **JIT Compilation:** Apply `@numba.njit` to the relaxation loop and hot math functions. -3. **Sparse Structures:** Use `scipy.sparse` for large connectivity matrices. - -## 6. Technical Mapping Table -| OCaml Symbol | Python / NumPy / SciPy Equivalent | -|--------------|-----------------------------------| -| `Qhull.delaunay` | `scipy.spatial.Delaunay` | -| `determinant` | `numpy.linalg.det` | -| `Mt19937` | `numpy.random.Generator(PCG64)` | -| `Metis` / `Parmetis` | `pymetis.part_mesh` | -| `PyTables` | `h5py` | diff --git a/docs/nmesh-ocaml-to-python3-migration-plan.md b/docs/nmesh-ocaml-to-python3-migration-plan.md deleted file mode 100644 index 90cbe52..0000000 --- a/docs/nmesh-ocaml-to-python3-migration-plan.md +++ /dev/null @@ -1,166 +0,0 @@ -### NMesh OCaml to Python3 Migration Plan (Incremental, Section-by-Section) - -**Summary** -- Migrate [nmesh.py](/mnt/g/Code/nmag/nmag-python-3/src/nmesh/nmesh.py) from OCaml-backed calls to pure Python by porting logic from [mesh.ml](/mnt/g/Code/nmag/nmag-src/src/mesh.ml) and [snippets.ml](/mnt/g/Code/nmag/nmag-src/src/snippets.ml). -- Keep modern API only. -- Deliver full parity (not a reduced subset), but in controlled sections with explicit exit criteria. -- Use `scipy`, `h5py`, and `pymetis` as required dependencies. - -**Public Interfaces / Type Changes** -- Keep current Python entrypoints in `nmesh.py` (`MeshBase`, `Mesh`, `MeshFromFile`, `MeshObject`, primitives, CSG, `load/save`, `mesh_from_points_and_simplices`). -- Replace OCaml-pill-style `raw_mesh` internals with a Python `RawMesh` data model (points, simplices, regions, neighbors, periodic groups, permutation, distribution). -- Remove `OCamlStub` after parity is verified; do not add extra legacy-name compatibility surface. - -## Section 1: Backend seam + core data model - -### 1.1 Goal -Introduce internal pure-Python backend interfaces in `nmesh.py` so all `ocaml.*` calls are routed through a Python backend object. - -### 1.2 Work packages -1. Define backend protocol for mesh ops, body ops, defaults, and driver creation. -2. Implement `RawMesh`, `Body`, `MesherDefaults`, and `Driver` Python types. -3. Replace direct `ocaml.*` usage in classes/functions with backend calls. -4. Keep method signatures stable at the `nmesh.py` public boundary. - -### 1.3 Acceptance -1. `nmesh.py` imports and runs without an OCaml module. -2. All previous `ocaml.*` call sites are backend-routed. -3. Public constructor signatures remain unchanged. - -## Section 2: Port snippets foundations used by meshing - -### 2.1 Goal -Port utility primitives from `snippets.ml` required by geometry and mesher internals. - -### 2.2 Work packages -1. Port array/list helpers (`filter`, `position`, `one_shorter`, intersections, sorting checks). -2. Port numeric helpers (`mx_mult`, `mx_x_vec`, determinant/inverse wrappers). -3. Port timing/memory reporting equivalent for `time_vmem_rss`. -4. Add unit tests for utility parity and numerical tolerance behavior. - -### 2.3 Acceptance -1. Utility tests pass with deterministic inputs. -2. Numerical helpers match OCaml reference outputs within tolerance. -3. No utility dependency on OCaml runtime remains. - -## Section 3: Port body geometry + CSG - -### 3.1 Goal -Replace OCaml body primitive and transformation logic with Python equivalents. - -### 3.2 Work packages -1. Port primitive boundary-condition builders: `bc_box`, `bc_ellipsoid`, `bc_frustum`, `bc_helix`. -2. Port affine transform operations and composition order semantics. -3. Port CSG operations: `union`, `difference`, `intersection`. -4. Keep shifted/scaled/rotated variants behaviorally compatible with current `nmesh.py` API. - -### 3.3 Acceptance -1. Geometry tests validate in/out classification for representative points. -2. Transform order tests validate equivalence to OCaml semantics. -3. CSG tests confirm region and object composition behavior. - -## Section 4: Port mesher defaults + driver semantics - -### 4.1 Goal -Move mesher parameter behavior and callback driver semantics fully to Python. - -### 4.2 Work packages -1. Mirror `opt_mesher_defaults` values and field structure in Python. -2. Implement full setter mapping used by `MeshingParameters.pass_parameters_to_ocaml`. -3. Port callback cadence and payload flow used by `make_mg_gendriver`. -4. Preserve current modern API while internalizing behavior. - -### 4.3 Acceptance -1. Setter-based tests reproduce expected defaults/overrides. -2. Callback interval tests verify invocation cadence and payload shape. -3. No dependency on OCaml mesher-default objects remains. - -## Section 5: Port core meshing pipeline - -### 5.1 Goal -Implement pure-Python equivalent of `mesh_bodies_raw` and `mesh_it` flow. - -### 5.2 Work packages -1. Port `fem_geometry_from_bodies` behavior for bodies, hints, and density handling. -2. Port fixed/mobile/simply point filtering and initial point preparation. -3. Port periodic point bookkeeping and periodic index mapping behavior. -4. Port iterative relax/retriangulate loop and stop conditions. -5. Port connectivity/bookkeeping growth path needed by downstream plotinfo accessors. - -### 5.3 Acceptance -1. Deterministic 1D/2D/3D meshing succeeds with fixed RNG seeds. -2. Periodic and hint-driven scenarios complete with valid topology. -3. Mesh-generation failure modes raise consistent Python exceptions. - -## Section 6: Port mesh query/extraction APIs - -### 6.1 Goal -Replace `mesh_plotinfo*` and related mesh query accessors with pure Python implementations. - -### 6.2 Work packages -1. Implement `mesh_nr_points`, `mesh_nr_simplices`, `mesh_dim`. -2. Implement `mesh_plotinfo*` family (points, simplices, regions, links, surfaces, periodic indices, full plotinfo bundle). -3. Implement `mesh_get_permutation` and `mesh_set_vertex_distribution`. -4. Preserve and validate `MeshBase` cache invalidation behavior. - -### 6.3 Acceptance -1. Accessor tests pass for populated meshes and edge cases. -2. Cache tests validate stale-data invalidation when nodes are rescaled/updated. -3. Returned structures match expected shape/type contracts. - -## Section 7: Port I/O + constructors - -### 7.1 Goal -Complete pure-Python constructor and file I/O parity for ASCII and HDF5 paths. - -### 7.2 Work packages -1. Port ASCII `# PYFEM` reader/writer rules, including validation and orientation-sensitive output behavior. -2. Implement `mesh_from_points_and_simplices` constructor parity and index handling. -3. Implement `MeshFromFile`, `load`, `save` without OCaml backend. -4. Implement HDF5 compatibility via `h5py` for `/mesh/{points,simplices,simplicesregions,permutation,periodicpointindices}`. - -### 7.3 Acceptance -1. ASCII roundtrip tests preserve topology, region ids, and periodic groups. -2. HDF5 roundtrip tests preserve permutation and periodic data. -3. Constructor tests validate initial index modes and error handling. - -## Section 8: Reorder/distribute parity + cleanup - -### 8.1 Goal -Finish reorder/distribution behavior and remove migration scaffolding. - -### 8.2 Work packages -1. Implement reorder strategy using `scipy.sparse.csgraph`-based flow. -2. Implement partitioning/distribution flow using `pymetis`. -3. Validate `do_reorder` and `do_distribute` semantics across load/generation paths. -4. Remove `OCamlStub` and any obsolete shim code/comments. - -### 8.3 Acceptance -1. Distribution/permutation invariants are validated in tests. -2. Reorder/distribute behavior is consistent between constructors and file-load paths. -3. `nmesh.py` contains no OCaml stub backend path. - -**Test Plan** - -### T1 Fixtures -1. Build OCaml parity fixtures for canonical geometries (single body, CSG, periodic, mesh-from-points). -2. Store deterministic fixture metadata (seed, dimensions, expected invariants). - -### T2 Section-gated tests -1. Utilities/numerics tests for snippet ports. -2. Geometry/CSG tests for primitives, transforms, and CSG. -3. Defaults/driver tests for setter and callback behavior. -4. Core meshing tests for deterministic output and convergence behavior. -5. Query/extraction tests for `mesh_plotinfo*` and cache semantics. -6. ASCII/HDF5 I/O roundtrip tests. -7. Reorder/distribute tests with permutation integrity checks. - -### T3 CI policy -1. Run full CI with required native dependencies installed. -2. Fail build on parity regression or deterministic fixture drift. - -**Assumptions / Defaults Locked** -- Pure Python only target. -- Modern API only. -- Full parity required (not staged down to serial-only). -- New dependencies are allowed (`scipy`, `h5py`, `pymetis`). diff --git a/src/mock_features/__init__.py b/src/mock_features/__init__.py index fd5d7d5..d1d64c3 100644 --- a/src/mock_features/__init__.py +++ b/src/mock_features/__init__.py @@ -1 +1 @@ -from .mock_features import * \ No newline at end of file +from .mock_features import * From ce93c7bb738c53680dfcc9d9139eaa7390fca183 Mon Sep 17 00:00:00 2001 From: Emanuel Pituch <32016786+epituch@users.noreply.github.com> Date: Fri, 20 Mar 2026 17:41:43 -0700 Subject: [PATCH 12/14] Remove useless comments --- src/nmesh/nmesh.py | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/src/nmesh/nmesh.py b/src/nmesh/nmesh.py index d3ed2ea..6be5968 100644 --- a/src/nmesh/nmesh.py +++ b/src/nmesh/nmesh.py @@ -6,13 +6,10 @@ from mock_features import MockFeatures from . import utils -# Setup logging log = logging.getLogger(__name__) -# --- Stubs for External Dependencies --- - class OCamlStub: - """Stub for the OCaml backend interface.""" + """Stub for the OCaml backend interface, these will be removed soon once we have python equivalents.""" # Mesher defaults setters def mesher_defaults_set_shape_force_scale(self, mesher, scale): pass @@ -153,7 +150,6 @@ def pass_parameters_to_ocaml(self, mesher, dim): setter(mesher, float(val) if "steps" not in key else int(val)) def get_default_meshing_parameters(): - """Returns default meshing parameters.""" return MeshingParameters() # --- Loading Utilities --- @@ -165,11 +161,9 @@ def _is_nmesh_ascii_file(filename): except: return False def _is_nmesh_hdf5_file(filename): - # This would normally use tables.isPyTablesFile return str(filename).lower().endswith('.h5') def hdf5_mesh_get_permutation(filename): - """Stub for retrieving permutation from HDF5.""" log.warning("hdf5_mesh_get_permutation: HDF5 support is stubbed.") return None @@ -363,7 +357,6 @@ def __init__(self, filename, reorder=False, distribute=True): if _is_nmesh_ascii_file(filename): raw = ocaml.mesh_readfile(str(path), reorder, distribute) elif _is_nmesh_hdf5_file(filename): - # load_hdf5 logic would go here raw = ocaml.mesh_readfile(str(path), reorder, distribute) else: raise ValueError(f"Unknown mesh file format: {filename}") @@ -397,11 +390,6 @@ def save(mesh: MeshBase, filename: Union[str, Path]): """Alias for mesh.save for backward compatibility.""" mesh.save(filename) -# --- Exception Aliases --- -NmeshUserError = ValueError -NmeshIOError = IOError -NmeshStandardError = RuntimeError - # --- Geometry --- class MeshObject: @@ -523,9 +511,6 @@ def get_idx(v): regions_ids.append(rid) last = curr - # Note: original unidmesher also returned surfaces, but simplified here - # Standard format for mesh_from_points_and_simplices: - # simplices are list of point indices, regions are separate list return points, simplices, regions_ids def generate_1d_mesh(regions: List[Tuple[float, float]], discretization: float) -> MeshBase: From 0e7376b110a79d3ea8d07f673e04c523bcab0695 Mon Sep 17 00:00:00 2001 From: Emanuel Pituch <32016786+epituch@users.noreply.github.com> Date: Fri, 20 Mar 2026 17:51:58 -0700 Subject: [PATCH 13/14] Split utils up --- src/nmesh/utils.py | 105 ------------------ src/nmesh/utils/__init__.py | 2 + src/nmesh/utils/array_list_utils.py | 42 +++++++ src/nmesh/utils/timing_memory_utils.py | 48 ++++++++ tests/nmesh/test_utils.py | 55 --------- tests/nmesh/utils/test_array_list_utils.py | 55 +++++++++ tests/nmesh/utils/test_timing_memory_utils.py | 33 ++++++ 7 files changed, 180 insertions(+), 160 deletions(-) delete mode 100644 src/nmesh/utils.py create mode 100644 src/nmesh/utils/__init__.py create mode 100644 src/nmesh/utils/array_list_utils.py create mode 100644 src/nmesh/utils/timing_memory_utils.py delete mode 100644 tests/nmesh/test_utils.py create mode 100644 tests/nmesh/utils/test_array_list_utils.py create mode 100644 tests/nmesh/utils/test_timing_memory_utils.py diff --git a/src/nmesh/utils.py b/src/nmesh/utils.py deleted file mode 100644 index 0b230bc..0000000 --- a/src/nmesh/utils.py +++ /dev/null @@ -1,105 +0,0 @@ -import numpy as np -import time -import os -import re -import logging - -log = logging.getLogger(__name__) - -# --- Timing and Memory Utilities --- - -_time_zero = None - -def time_passed(): - """Returns elapsed time since the first call to this function.""" - global _time_zero - if _time_zero is None: - _time_zero = time.time() - return 0.0 - return time.time() - _time_zero - -def memstats(self_status_file="/proc/self/status"): - """Reads VmSize and VmRSS from /proc/self/status (Linux).""" - vmsize_vmrss = [0.0, 0.0] - if not os.path.exists(self_status_file): - return vmsize_vmrss - - # 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: - content = fd.read() - found = 0 - for match in re_pattern.finditer(content): - key = match.group(1) - value = float(match.group(2)) - if key == "VmSize": - vmsize_vmrss[0] = value - else: - vmsize_vmrss[1] = value - found += 1 - if found >= 2: - break - except Exception as e: - log.debug(f"Failed to read memstats: {e}") - pass - return vmsize_vmrss - -def time_vmem_rss(): - """Returns (elapsed_time, vmem, rss) where memory is in KB.""" - t = time_passed() - mem = memstats() - return t, mem[0], mem[1] - -# --- Array and List Helpers (NumPy Vectorized) --- - -def array_filter(p, arr): - """Port of Snippets.array_filter using NumPy boolean indexing.""" - arr = np.asanyarray(arr) - # Apply predicate to create a boolean mask - mask = np.vectorize(p)(arr) - return arr[mask] - -def array_position(x, arr, start=0): - """Port of Snippets.array_position.""" - arr = np.asanyarray(arr) - sub_arr = arr[start:] - indices = np.where(sub_arr == x)[0] - if len(indices) > 0: - return int(indices[0] + start) - return -1 - -def array_position_if(p, arr, start=0): - """Port of Snippets.array_position_if.""" - arr = np.asanyarray(arr) - sub_arr = arr[start:] - mask = np.vectorize(p)(sub_arr) - indices = np.where(mask)[0] - if len(indices) > 0: - return int(indices[0] + start) - return -1 - -def array_one_shorter(arr, pos): - """Port of Snippets.array_one_shorter.""" - return np.delete(np.asanyarray(arr), pos) - -# --- Numerical Helpers --- - -def determinant(mx): - """Port of Snippets.determinant using numpy.linalg.det.""" - return float(np.linalg.det(np.asanyarray(mx))) - -def inverse(mx): - """Port of Snippets.compute_inv_on_scratchpads using numpy.linalg.inv.""" - return np.linalg.inv(np.asanyarray(mx)) - -def det_and_inv(mx): - """Port of Snippets.det_and_inv.""" - arr = np.asanyarray(mx) - det = np.linalg.det(arr) - inv = np.linalg.inv(arr) - return float(det), inv - -def cross_product_3d(v1, v2): - """Port of Snippets.cross_product_3d.""" - return np.cross(np.asanyarray(v1), np.asanyarray(v2)) diff --git a/src/nmesh/utils/__init__.py b/src/nmesh/utils/__init__.py new file mode 100644 index 0000000..30aac80 --- /dev/null +++ b/src/nmesh/utils/__init__.py @@ -0,0 +1,2 @@ +from .array_list_utils import * +from .timing_memory_utils import * diff --git a/src/nmesh/utils/array_list_utils.py b/src/nmesh/utils/array_list_utils.py new file mode 100644 index 0000000..4a2bcb1 --- /dev/null +++ b/src/nmesh/utils/array_list_utils.py @@ -0,0 +1,42 @@ +import numpy as np + +def array_filter(p, arr): + arr = np.asanyarray(arr) + # Apply predicate to create a boolean mask. + mask = np.vectorize(p)(arr) + return arr[mask] + +def array_position(x, arr, start=0): + arr = np.asanyarray(arr) + sub_arr = arr[start:] + indices = np.where(sub_arr == x)[0] + if len(indices) > 0: + return int(indices[0] + start) + return -1 + +def array_position_if(p, arr, start=0): + arr = np.asanyarray(arr) + sub_arr = arr[start:] + mask = np.vectorize(p)(sub_arr) + indices = np.where(mask)[0] + if len(indices) > 0: + return int(indices[0] + start) + return -1 + +def array_one_shorter(arr, pos): + return np.delete(np.asanyarray(arr), pos) + +def determinant(mx): + return float(np.linalg.det(np.asanyarray(mx))) + +def inverse(mx): + return np.linalg.inv(np.asanyarray(mx)) + +def det_and_inv(mx): + arr = np.asanyarray(mx) + det = np.linalg.det(arr) + inv = np.linalg.inv(arr) + return float(det), inv + +def cross_product_3d(v1, v2): + return np.cross(np.asanyarray(v1), np.asanyarray(v2)) diff --git a/src/nmesh/utils/timing_memory_utils.py b/src/nmesh/utils/timing_memory_utils.py new file mode 100644 index 0000000..6892ffd --- /dev/null +++ b/src/nmesh/utils/timing_memory_utils.py @@ -0,0 +1,48 @@ +import logging +import os +import re +import time + +log = logging.getLogger(__name__) + +_time_zero = None + +def time_passed(): + """Returns elapsed time since the first call to this function.""" + global _time_zero + if _time_zero is None: + _time_zero = time.time() + return 0.0 + return time.time() - _time_zero + +def memstats(self_status_file="/proc/self/status"): + """Reads VmSize and VmRSS from /proc/self/status (Linux).""" + vmsize_vmrss = [0.0, 0.0] + if not os.path.exists(self_status_file): + return vmsize_vmrss + + # 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: + content = fd.read() + found = 0 + for match in re_pattern.finditer(content): + key = match.group(1) + value = float(match.group(2)) + if key == "VmSize": + vmsize_vmrss[0] = value + else: + vmsize_vmrss[1] = value + found += 1 + if found >= 2: + break + except Exception as e: + log.debug(f"Failed to read memstats: {e}") + return vmsize_vmrss + +def time_vmem_rss(): + """Returns (elapsed_time, vmem, rss) where memory is in KB.""" + t = time_passed() + mem = memstats() + return t, mem[0], mem[1] diff --git a/tests/nmesh/test_utils.py b/tests/nmesh/test_utils.py deleted file mode 100644 index ede0127..0000000 --- a/tests/nmesh/test_utils.py +++ /dev/null @@ -1,55 +0,0 @@ -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 - expected = [2, 4] - np.testing.assert_array_equal(utils.array_filter(p, arr), expected) - - def test_array_position(self): - arr = [10, 20, 30, 40, 30] - self.assertEqual(utils.array_position(30, arr), 2) - self.assertEqual(utils.array_position(30, arr, start=3), 4) - self.assertEqual(utils.array_position(50, arr), -1) - - def test_array_position_if(self): - arr = [1, 3, 5, 8, 10] - p = lambda x: x % 2 == 0 - self.assertEqual(utils.array_position_if(p, arr), 3) - self.assertEqual(utils.array_position_if(p, arr, start=4), 4) - - def test_array_one_shorter(self): - arr = [1, 2, 3, 4] - np.testing.assert_array_equal(utils.array_one_shorter(arr, 1), [1, 3, 4]) - - def test_determinant(self): - mx = [[1, 2], [3, 4]] - # det = 1*4 - 2*3 = -2 - self.assertAlmostEqual(utils.determinant(mx), -2.0, places=9) - - def test_inverse(self): - mx = [[1, 2], [3, 4]] - inv = utils.inverse(mx) - expected = [[-2.0, 1.0], [1.5, -0.5]] - np.testing.assert_array_almost_equal(inv, expected, decimal=9) - - def test_cross_product_3d(self): - v1 = [1, 0, 0] - v2 = [0, 1, 0] - expected = [0, 0, 1] - np.testing.assert_array_equal(utils.cross_product_3d(v1, v2), expected) - - def test_time_vmem_rss(self): - t, vmem, rss = utils.time_vmem_rss() - self.assertGreaterEqual(t, 0.0) - # On non-linux this might be 0.0, but if /proc/self/status exists it should be > 0 - if os.path.exists("/proc/self/status"): - self.assertGreater(vmem, 0.0) - self.assertGreater(rss, 0.0) - -if __name__ == '__main__': - unittest.main() diff --git a/tests/nmesh/utils/test_array_list_utils.py b/tests/nmesh/utils/test_array_list_utils.py new file mode 100644 index 0000000..3ed78eb --- /dev/null +++ b/tests/nmesh/utils/test_array_list_utils.py @@ -0,0 +1,55 @@ +import unittest + +import numpy as np +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 + expected = [2, 4] + np.testing.assert_array_equal(array_utils.array_filter(p, arr), expected) + + def test_array_position(self): + arr = [10, 20, 30, 40, 30] + self.assertEqual(array_utils.array_position(30, arr), 2) + self.assertEqual(array_utils.array_position(30, arr, start=3), 4) + self.assertEqual(array_utils.array_position(50, arr), -1) + + def test_array_position_if(self): + arr = [1, 3, 5, 8, 10] + p = lambda x: 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) + + def test_array_one_shorter(self): + arr = [1, 2, 3, 4] + np.testing.assert_array_equal(array_utils.array_one_shorter(arr, 1), [1, 3, 4]) + + def test_determinant(self): + mx = [[1, 2], [3, 4]] + self.assertAlmostEqual(array_utils.determinant(mx), -2.0, places=9) + + def test_inverse(self): + mx = [[1, 2], [3, 4]] + inv = array_utils.inverse(mx) + expected = [[-2.0, 1.0], [1.5, -0.5]] + np.testing.assert_array_almost_equal(inv, expected, decimal=9) + + def test_det_and_inv(self): + mx = [[1, 2], [3, 4]] + det, inv = array_utils.det_and_inv(mx) + self.assertAlmostEqual(det, -2.0, places=9) + expected = [[-2.0, 1.0], [1.5, -0.5]] + np.testing.assert_array_almost_equal(inv, expected, decimal=9) + + def test_cross_product_3d(self): + v1 = [1, 0, 0] + v2 = [0, 1, 0] + expected = [0, 0, 1] + np.testing.assert_array_equal(array_utils.cross_product_3d(v1, v2), expected) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/nmesh/utils/test_timing_memory_utils.py b/tests/nmesh/utils/test_timing_memory_utils.py new file mode 100644 index 0000000..3e26bbf --- /dev/null +++ b/tests/nmesh/utils/test_timing_memory_utils.py @@ -0,0 +1,33 @@ +import os +import tempfile +import unittest + +import nmesh.utils.timing_memory_utils as timing_utils + + +class TestTimingMemoryUtils(unittest.TestCase): + def setUp(self): + timing_utils._time_zero = None + + def test_time_vmem_rss(self): + t, vmem, rss = timing_utils.time_vmem_rss() + self.assertGreaterEqual(t, 0.0) + # On non-Linux this might be 0.0, but if /proc/self/status exists it should be > 0. + if os.path.exists("/proc/self/status"): + self.assertGreater(vmem, 0.0) + self.assertGreater(rss, 0.0) + + def test_memstats_from_file(self): + content = "Name:\tpython\nVmSize:\t 1234 kB\nVmRSS:\t 456 kB\n" + with tempfile.NamedTemporaryFile("w+", delete=False) as tmp: + tmp.write(content) + tmp_path = tmp.name + + try: + self.assertEqual(timing_utils.memstats(tmp_path), [1234.0, 456.0]) + finally: + os.unlink(tmp_path) + + +if __name__ == "__main__": + unittest.main() From 5b9799985391f9aa919dfdc4db2200a0d6dd3156 Mon Sep 17 00:00:00 2001 From: Emanuel Pituch <32016786+epituch@users.noreply.github.com> Date: Mon, 23 Mar 2026 18:54:57 -0700 Subject: [PATCH 14/14] Remove import of utils in nmesh module --- src/nmesh/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/nmesh/__init__.py b/src/nmesh/__init__.py index 836fe03..671625b 100644 --- a/src/nmesh/__init__.py +++ b/src/nmesh/__init__.py @@ -1,2 +1 @@ from .nmesh import * -from . import utils