From 5189b3466aef8e420e29db1c09812469cb71e9ac Mon Sep 17 00:00:00 2001 From: Julian Rimoli Date: Tue, 22 Sep 2026 20:02:12 -0700 Subject: [PATCH 01/42] Fix geometry bugs: ellipsoid axis ordering, exact limits, keywords - Ellipsoid.approximate: correct axis mapping for the c >= a >= b ordering and sort the b >= c >= a ordering explicitly (grains were tessellated with the wrong orientation) - Ellipsoid.limits: exact bounding box for rotated ellipsoids - Ellipsoid: fix the (c, ratio_bc) solver, write rot_seq with quoted axes - Ellipse: axes keyword, orthonormality check of matrix/orientation, 'axes' in area_expectation - Ellipse/Ellipsoid.reflect: broadcasting and unscaling - *_expectation: accept numpy scalars; seed the Monte-Carlo estimates - NBox.within honours the rotation; NBox.__str__ full precision - Rectangle(length=...) alone; Square.area_expectation return value - Sphere.plot on a fresh figure; NSphere.best_fit with integer points - __eq__ for Ellipse, Ellipsoid and NBox Co-Authored-By: Claude Fable 5.1 --- src/microstructpy/geometry/ellipse.py | 63 +++++-- src/microstructpy/geometry/ellipsoid.py | 83 +++++---- src/microstructpy/geometry/n_box.py | 28 ++- src/microstructpy/geometry/n_sphere.py | 4 +- src/microstructpy/geometry/rectangle.py | 8 +- src/microstructpy/geometry/sphere.py | 22 +-- tests/geometry/test_geometry_fixes.py | 227 ++++++++++++++++++++++++ 7 files changed, 360 insertions(+), 75 deletions(-) create mode 100644 tests/geometry/test_geometry_fixes.py diff --git a/src/microstructpy/geometry/ellipse.py b/src/microstructpy/geometry/ellipse.py index 05013765..93300bd1 100644 --- a/src/microstructpy/geometry/ellipse.py +++ b/src/microstructpy/geometry/ellipse.py @@ -53,7 +53,7 @@ def __init__(self, **kwargs): if kw in kwargs and kwargs[kw] <= 0: raise ValueError(kw + ' should be positive.') if 'axes' in kwargs: - for i, ax in kwargs['axes']: + for i, ax in enumerate(kwargs['axes']): if ax <= 0: raise ValueError('axes[{}] should be positive'.format(i)) @@ -62,7 +62,7 @@ def __init__(self, **kwargs): m = np.array(kwargs[kw]) if m.shape != (2, 2): raise ValueError(kw + ' should be 2x2.') - if not np.all(np.isclose(m * m.T, np.eye(2))): + if not np.all(np.isclose(m.dot(m.T), np.eye(2))): raise ValueError(kw + ' should be orthonormal.') # position @@ -252,6 +252,24 @@ def __repr__(self): repr_str += ')' return repr_str + # ----------------------------------------------------------------------- # + # Equality # + # ----------------------------------------------------------------------- # + def __eq__(self, other): + if not isinstance(other, Ellipse): + return False + c1 = np.array(self.center, dtype='float') + c2 = np.array(other.center, dtype='float') + if c1.shape != c2.shape or not np.allclose(c1, c2): + return False + if not np.allclose([self.a, self.b], [other.a, other.b]): + return False + d_ang = (self.angle - other.angle + 180) % 360 - 180 + return np.isclose(d_ang, 0) + + def __ne__(self, other): + return not self.__eq__(other) + # ----------------------------------------------------------------------- # # Size and Orientation Getters # # ----------------------------------------------------------------------- # @@ -368,10 +386,10 @@ def area_expectation(cls, **kwargs): """ # NOQA: E501 if 'size' in kwargs: s_dist = kwargs['size'] - - if type(s_dist) in (float, int): + try: + return 0.25 * np.pi * s_dist.moment(2) + except AttributeError: return 0.25 * np.pi * s_dist * s_dist - return 0.25 * np.pi * s_dist.moment(2) if 'area' in kwargs: a_dist = kwargs['area'] @@ -381,16 +399,11 @@ def area_expectation(cls, **kwargs): a_exp = a_dist return a_exp + if 'axes' in kwargs: + return np.pi * _prod_exp(*kwargs['axes']) + if ('a' in kwargs) and ('b' in kwargs): - exp = np.pi - for kw in ('a', 'b'): - dist = kwargs[kw] - if type(dist) in (float, int): - mu = dist - else: - mu = dist.moment(1) - exp *= mu - return exp + return np.pi * _prod_exp(kwargs['a'], kwargs['b']) if ('b' in kwargs) and ('aspect_ratio' in kwargs): exp = np.pi @@ -407,13 +420,14 @@ def area_expectation(cls, **kwargs): if ('a' in kwargs) and ('aspect_ratio' in kwargs): n = 1000 + rng = np.random.RandomState(0) try: - a = kwargs['a'].rvs(size=n) + a = kwargs['a'].rvs(size=n, random_state=rng) except AttributeError: a = np.full(n, kwargs['a']) try: - k = kwargs['aspect_ratio'].rvs(size=n) + k = kwargs['aspect_ratio'].rvs(size=n, random_state=rng) except AttributeError: k = np.full(n, kwargs['aspect_ratio']) return np.pi * np.mean((a * a) / k) @@ -689,11 +703,24 @@ def reflect(self, points): new_dist = 2 - dist[mask] scl = new_dist / dist[mask] - new_scl_pos = scl_pos[mask] * scl - new_rel_pos = new_scl_pos.dot(self.orientation.T) + new_scl_pos = scl_pos[mask] * scl.reshape(-1, 1) + new_rot_pos = new_scl_pos * np.array(self.axes).reshape(1, -1) + new_rel_pos = new_rot_pos.dot(self.orientation.T) new_pos = new_rel_pos + np.array(self.center) if single_pt: return new_pos[0] else: return new_pos + + +def _prod_exp(*args): + """Product of the expected values of constants and distributions.""" + prod = 1 + for arg in args: + try: + arg_mu = arg.moment(1) + except AttributeError: + arg_mu = arg + prod *= arg_mu + return prod diff --git a/src/microstructpy/geometry/ellipsoid.py b/src/microstructpy/geometry/ellipsoid.py index d767e0d3..6fd08c0c 100644 --- a/src/microstructpy/geometry/ellipsoid.py +++ b/src/microstructpy/geometry/ellipsoid.py @@ -137,7 +137,7 @@ def __init__(self, **kwargs): if (ratio_ab is not None) and (self.a is not None): self.b = self.a / ratio_ab elif (ratio_bc is not None) and (self.c is not None): - self.b = ratio_bc * self.a + self.b = ratio_bc * self.c if self.c is None: if (ratio_ac is not None) and (self.a is not None): @@ -338,13 +338,10 @@ def __str__(self): str_str += 'b: ' + str(self.b) + '\n' str_str += 'c: ' + str(self.c) if len(self.rot_seq) > 0: - str_str += '\nrot_seq: (' - for i, (ax, ang) in enumerate(self.rot_seq): - str_str += '(' + str(ax) + ', ' + str(ang) + ')' - if i < len(self.rot_seq) - 1: - str_str += ', ' - else: - str_str += ')' + # axis names must be quoted so that the string can be parsed + # back (see Seed.from_str) + rot_seq = tuple([(ax, float(ang)) for ax, ang in self.rot_seq]) + str_str += '\nrot_seq: ' + repr(rot_seq) return str_str def __repr__(self): @@ -356,6 +353,23 @@ def __repr__(self): repr_str += ')' return repr_str + # ----------------------------------------------------------------------- # + # Equality # + # ----------------------------------------------------------------------- # + def __eq__(self, other): + if not isinstance(other, Ellipsoid): + return False + c1 = np.array(self.center, dtype='float') + c2 = np.array(other.center, dtype='float') + if c1.shape != c2.shape or not np.allclose(c1, c2): + return False + if not np.allclose(self.axes, other.axes): + return False + return np.allclose(self.matrix, other.matrix) + + def __ne__(self, other): + return not self.__eq__(other) + # ----------------------------------------------------------------------- # # Size and Orientation Getters # # ----------------------------------------------------------------------- # @@ -531,9 +545,10 @@ def volume_expectation(cls, **kwargs): # Check for size distribution if 'size' in kwargs: s_dist = kwargs['size'] - if type(s_dist) in (float, int): + try: + return 0.5 * np.pi * s_dist.moment(3) / 3 + except AttributeError: return 0.5 * np.pi * s_dist * s_dist * s_dist / 3 - return 0.5 * np.pi * s_dist.moment(3) / 3 if 'volume' in kwargs: v_dist = kwargs['volume'] @@ -559,14 +574,15 @@ def volume_expectation(cls, **kwargs): # Use Monte Carlo to determine expected volume n_trials = 1000 - kws = set(kwargs.keys()) - set(_misc.ori_kws) + kws = sorted(set(kwargs.keys()) - set(_misc.ori_kws)) + rng = np.random.RandomState(0) total_vol = 0 for i in range(n_trials): params = {} for kw in kws: try: - params[kw] = kwargs[kw].rvs() + params[kw] = kwargs[kw].rvs(random_state=rng) except AttributeError: params[kw] = kwargs[kw] total_vol += Ellipsoid(**params).volume @@ -635,12 +651,17 @@ def approximate(self, x1=None): a = self.c b = self.a c = self.b - inds = [0, 2, 1] - else: + inds = [1, 2, 0] + elif (self.c >= self.b) and (self.b >= self.a): a = self.c b = self.b c = self.a inds = [2, 1, 0] + else: # (self.b >= self.c) and (self.c >= self.a) + a = self.b + b = self.c + c = self.a + inds = [2, 0, 1] # Prolate Ellipsoid if np.isclose(b, c): @@ -761,7 +782,7 @@ def plot(self, **kwargs): mod_kwargs = {} for key, val in kwargs.items(): - if key == 'facecolors' and type(val) != list: + if key == 'facecolors' and not isinstance(val, list): mod_kwargs['color'] = val else: mod_kwargs[key] = val @@ -773,26 +794,13 @@ def plot(self, **kwargs): @property def limits(self): """list: List of (lower, upper) bounds for the bounding box""" - if np.all(np.isclose(self.matrix, np.eye(3))): - ax = np.array(self.axes) - cen = np.array(self.center) - return [(x - r, x + r) for x, r in zip(cen, ax)] - - n = 4 - u = np.linspace(0, 2 * np.pi, 1 + 4 * n) - cv = np.linspace(-1, 1, 1 + 2 * n) - uu, cvv = np.meshgrid(u, cv) - svv = np.sin(np.arccos(cvv)) - - xp = self.a * np.cos(uu) * svv - yp = self.b * np.sin(uu) * svv - zp = self.c * cvv - - pts = np.array([xp.flatten(), yp.flatten(), zp.flatten()]) - r_pts = self.matrix.dot(pts) - lbs = r_pts.min(axis=-1) + np.array(self.center) - ubs = r_pts.max(axis=-1) + np.array(self.center) - return list(zip(lbs, ubs)) + # The i-th coordinate of a surface point is sum_j R_ij a_j u_j with + # |u| = 1, so the half-extent along axis i is the 2-norm of the + # vector (R_ij a_j)_j. + scl_mat = np.array(self.matrix) * np.array(self.axes).reshape(1, -1) + half = np.sqrt(np.sum(scl_mat * scl_mat, axis=1)) + cen = np.array(self.center) + return [(x - r, x + r) for x, r in zip(cen, half)] @property def sample_limits(self): @@ -867,8 +875,9 @@ def reflect(self, points): new_dist = 2 - dist[mask] scl = new_dist / dist[mask] - new_scl_pos = scl_pos[mask] * scl - new_rel_pos = new_scl_pos.dot(self.orientation.T) + new_scl_pos = scl_pos[mask] * scl.reshape(-1, 1) + new_rot_pos = new_scl_pos * np.array(self.axes).reshape(1, -1) + new_rel_pos = new_rot_pos.dot(self.orientation.T) new_pos = new_rel_pos + np.array(self.center) if single_pt: diff --git a/src/microstructpy/geometry/n_box.py b/src/microstructpy/geometry/n_box.py index 80598a81..ef72fcf4 100644 --- a/src/microstructpy/geometry/n_box.py +++ b/src/microstructpy/geometry/n_box.py @@ -88,21 +88,37 @@ def __init__(self, **kwargs): # String and Representation Functions # # ----------------------------------------------------------------------- # def __str__(self): - cen = np.array(self.center) - sides = np.array(self.side_lengths) - cen_str = np.array2string(cen, separator=', ') - sides_str = np.array2string(sides, separator=', ') + # full precision, in a form that ast.literal_eval can parse back + cen_str = repr(tuple([float(x) for x in self.center])) + sides_str = repr(tuple([float(x) for x in self.side_lengths])) str_str = 'Center: ' + cen_str + '\n' str_str += 'Side Lengths: ' + sides_str + '\n' str_str += 'Matrix: (' for row in self.matrix: str_str += '(' - str_str += ', '.join([str(val) for val in row]) + str_str += ', '.join([repr(float(val)) for val in row]) str_str += '),' str_str = str_str[:-1] + ')' return str_str + # ----------------------------------------------------------------------- # + # Equality # + # ----------------------------------------------------------------------- # + def __eq__(self, other): + if not isinstance(other, NBox): + return False + c1 = np.array(self.center, dtype='float') + c2 = np.array(other.center, dtype='float') + if c1.shape != c2.shape or not np.allclose(c1, c2): + return False + if not np.allclose(self.side_lengths, other.side_lengths): + return False + return np.allclose(self.matrix, other.matrix) + + def __ne__(self, other): + return not self.__eq__(other) + def __repr__(self): repr_str = 'NBox(' repr_str += 'center=' + repr(tuple(self.center)) + ', ' @@ -187,6 +203,8 @@ def within(self, points): pts = pts.reshape(1, -1) rel_pos = pts - np.array(self.center) + # rotate into the local (box-aligned) frame + rel_pos = rel_pos.dot(np.array(self.matrix)) min_dist = 0.5 * np.array(self.side_lengths) mask = np.all(np.abs(rel_pos) <= min_dist, axis=-1) diff --git a/src/microstructpy/geometry/n_sphere.py b/src/microstructpy/geometry/n_sphere.py index f68b737b..ec588602 100644 --- a/src/microstructpy/geometry/n_sphere.py +++ b/src/microstructpy/geometry/n_sphere.py @@ -78,7 +78,7 @@ def best_fit(cls, points): https://dtcenter.org/met/users/docs/write_ups/circle_fit.pdf """ # NOQA: E501 # convert points to numpy array - pts = np.array(points) + pts = np.array(points, dtype='float') n_pts, n_dim = pts.shape if n_pts <= n_dim: mid = pts.mean(axis=0) @@ -103,7 +103,7 @@ def best_fit(cls, points): # Solve linear system for the center try: cen_b = np.linalg.solve(mat, vec) - except np.linalg.linalg.LinAlgError: + except np.linalg.LinAlgError: cen_b = pts.mean(axis=0) cen = cen_b + bcenter diff --git a/src/microstructpy/geometry/rectangle.py b/src/microstructpy/geometry/rectangle.py index eb31f8e9..b64f09a2 100644 --- a/src/microstructpy/geometry/rectangle.py +++ b/src/microstructpy/geometry/rectangle.py @@ -43,8 +43,10 @@ class Rectangle(NBox): """ def __init__(self, **kwargs): - if 'length' in kwargs and 'width' in kwargs: - kwargs['side_lengths'] = [kwargs['length'], kwargs['width']] + if 'length' in kwargs or 'width' in kwargs: + # consistent with area_expectation, a missing side defaults to 1 + kwargs['side_lengths'] = [kwargs.get('length', 1), + kwargs.get('width', 1)] if 'angle' in kwargs: cp = np.cos(np.radians(kwargs['angle'])) @@ -484,7 +486,7 @@ def area_expectation(cls, **kwargs): area_exp = len_dist * len_dist return area_exp - Rectangle.area_expectation(**kwargs) + return Rectangle.area_expectation(**kwargs) # ----------------------------------------------------------------------- # # Circle Approximation # diff --git a/src/microstructpy/geometry/sphere.py b/src/microstructpy/geometry/sphere.py index a2dd3c5d..c20c6a84 100644 --- a/src/microstructpy/geometry/sphere.py +++ b/src/microstructpy/geometry/sphere.py @@ -115,10 +115,11 @@ def volume_expectation(cls, **kwargs): elif 'r' in kwargs: r_dist = kwargs['r'] - if type(r_dist) in (float, int): - return 4 * np.pi * r_dist * r_dist * r_dist / 3 - elif r_dist is not None: - return 4 * np.pi * r_dist.moment(3) / 3 + if r_dist is not None: + try: + return 4 * np.pi * r_dist.moment(3) / 3 + except AttributeError: + return 4 * np.pi * r_dist * r_dist * r_dist / 3 # Check for diameter distribution d_dist = None @@ -127,10 +128,11 @@ def volume_expectation(cls, **kwargs): d_dist = kwargs[d_kw] break - if type(d_dist) in (float, int): - return 0.5 * np.pi * d_dist * d_dist * d_dist / 3 - elif d_dist is not None: - return 0.5 * np.pi * d_dist.moment(3) / 3 + if d_dist is not None: + try: + return 0.5 * np.pi * d_dist.moment(3) / 3 + except AttributeError: + return 0.5 * np.pi * d_dist * d_dist * d_dist / 3 if 'volume' in kwargs: v_dist = kwargs['volume'] @@ -163,7 +165,7 @@ def plot(self, **kwargs): if plt.gcf().axes: ax = plt.gca() else: - ax = plt.add_subplot(projection=Axes3D.name) + ax = plt.gcf().add_subplot(projection=Axes3D.name) u = np.linspace(0, 2 * np.pi, 11) cv = np.linspace(-1, 1, 12) @@ -179,7 +181,7 @@ def plot(self, **kwargs): mod_kwargs = {} for key, val in kwargs.items(): - if key == 'facecolors' and type(val) != list: + if key == 'facecolors' and not isinstance(val, list): mod_kwargs['color'] = val else: mod_kwargs[key] = val diff --git a/tests/geometry/test_geometry_fixes.py b/tests/geometry/test_geometry_fixes.py new file mode 100644 index 00000000..fbdaa03d --- /dev/null +++ b/tests/geometry/test_geometry_fixes.py @@ -0,0 +1,227 @@ +"""Regression tests for the geometry module bug fixes.""" +import itertools + +import matplotlib +import numpy as np +import pytest +import scipy.stats +from matplotlib import pyplot as plt +from pyquaternion import Quaternion + +from microstructpy.geometry import Box +from microstructpy.geometry import Circle +from microstructpy.geometry import Ellipse +from microstructpy.geometry import Ellipsoid +from microstructpy.geometry import Rectangle +from microstructpy.geometry import Sphere +from microstructpy.geometry import Square + +matplotlib.use('agg') + + +def _rot2d(deg): + t = np.radians(deg) + return np.array([[np.cos(t), -np.sin(t)], [np.sin(t), np.cos(t)]]) + + +# --------------------------------------------------------------------------- # +# Ellipse # +# --------------------------------------------------------------------------- # +def test_ellipse_axes_keyword(): + e = Ellipse(axes=[2, 1]) + assert e.a == 2 and e.b == 1 + with pytest.raises(ValueError): + Ellipse(axes=[2, -1]) + + +def test_ellipse_matrix_keyword_accepts_rotations(): + for deg in (0, 30, 90, -135): + e = Ellipse(a=2, b=1, matrix=_rot2d(deg)) + assert np.isclose((e.angle - deg + 180) % 360 - 180, 0) + e = Ellipse(a=2, b=1, orientation=_rot2d(deg)) + assert np.isclose((e.angle - deg + 180) % 360 - 180, 0) + with pytest.raises(ValueError): + Ellipse(matrix=[[1, 1], [0, 1]]) + + +def test_ellipse_reflect_broadcasting(): + e = Ellipse(a=2, b=1) + pts = e.reflect([[1, 0], [0, 0.5], [2, 2]]) + assert pts.shape == (3, 2) + # a point on the boundary is its own reflection + assert np.allclose(e.reflect([2, 0]), [2, 0]) + + +def test_ellipse_area_expectation_numpy_scalars(): + exp = 0.25 * np.pi * 4 + assert np.isclose(Ellipse.area_expectation(size=np.float64(2), + aspect_ratio=2), exp) + assert np.isclose(Ellipse.area_expectation(axes=[2, np.float32(1)]), + 2 * np.pi) + assert np.isclose(Ellipse.area_expectation(a=np.float64(2), b=1), + 2 * np.pi) + + +def test_ellipse_area_expectation_deterministic(): + kw = {'a': scipy.stats.uniform(1, 1), + 'aspect_ratio': scipy.stats.uniform(1, 2)} + assert Ellipse.area_expectation(**kw) == Ellipse.area_expectation(**kw) + + +def test_ellipse_equality(): + e1 = Ellipse(a=2, b=1, angle_deg=30, center=(1, 2)) + e2 = Ellipse(a=2, b=1, angle_deg=390, center=(1, 2)) + e3 = Ellipse(a=2, b=1.1, angle_deg=30, center=(1, 2)) + assert e1 == e2 + assert e1 != e3 + assert e1 != Circle(r=1) + + +# --------------------------------------------------------------------------- # +# Ellipsoid # +# --------------------------------------------------------------------------- # +def test_ellipsoid_ratio_bc_with_c(): + e = Ellipsoid(c=1, ratio_bc=2) + assert np.allclose(e.axes, (1, 2, 1)) + e = Ellipsoid(size=2, c=0.5, ratio_bc=2) + assert np.isclose(e.ratio_bc, 2) + assert np.isclose(e.size, 2) + + +def test_ellipsoid_str_round_trip_rot_seq(): + import ast + e = Ellipsoid(a=3, b=2, c=1, matrix=Quaternion.random().rotation_matrix) + line = [ln for ln in str(e).split('\n') if ln.startswith('rot_seq')][0] + rot_seq = ast.literal_eval(line.split(':', 1)[1].strip()) + e2 = Ellipsoid(a=3, b=2, c=1, rot_seq=list(rot_seq)) + assert np.allclose(e2.matrix, e.matrix) + + +@pytest.mark.parametrize('axes', list(itertools.permutations([5, 3, 1]))) +def test_ellipsoid_approximate_all_axis_orderings(axes): + e = Ellipsoid(a=axes[0], b=axes[1], c=axes[2]) + sph = e.approximate() + cen, r = sph[:, :3], sph[:, 3] + # union of spheres spans exactly the ellipsoid extents + assert np.allclose(np.max(np.abs(cen) + r[:, None], axis=0), axes) + # every sphere lies inside the ellipsoid + rng = np.random.RandomState(0) + u = rng.normal(size=(200, 3)) + u /= np.linalg.norm(u, axis=1).reshape(-1, 1) + worst = 0 + for c, rad in zip(cen, r): + p = c + rad * u + worst = max(worst, np.max(np.sum((p / np.array(axes)) ** 2, axis=1))) + assert worst < 1.01 + + +def test_ellipsoid_limits_exact_for_rotations(): + rng = np.random.RandomState(1) + for _ in range(20): + axes = rng.uniform(0.2, 5, 3) + R = Quaternion.random().rotation_matrix + cen = rng.uniform(-2, 2, 3) + e = Ellipsoid(axes=axes, matrix=R, center=cen) + lims = np.array(e.limits) + # brute force over a fine sampling of the surface + u = rng.normal(size=(20000, 3)) + u /= np.linalg.norm(u, axis=1).reshape(-1, 1) + pts = (u * axes).dot(R.T) + cen + lo, hi = pts.min(axis=0), pts.max(axis=0) + assert np.all(lims[:, 0] <= lo + 1e-12) + assert np.all(lims[:, 1] >= hi - 1e-12) + assert np.allclose(lims[:, 0], lo, atol=0.02 * axes.max()) + assert np.allclose(lims[:, 1], hi, atol=0.02 * axes.max()) + e = Ellipsoid(a=3, b=2, c=1, center=(1, 1, 1)) + assert np.allclose(e.limits, [(-2, 4), (-1, 3), (0, 2)]) + + +def test_ellipsoid_volume_expectation_numpy_scalars(): + v = Ellipsoid.volume_expectation(size=np.float64(2), ratio_ab=2) + assert np.isclose(v, 4 * np.pi / 3) + + +def test_ellipsoid_volume_expectation_deterministic(): + kw = {'a': scipy.stats.uniform(1, 1), + 'ratio_ab': scipy.stats.uniform(1, 2), + 'ratio_ac': scipy.stats.uniform(1, 2)} + v1 = Ellipsoid.volume_expectation(**kw) + v2 = Ellipsoid.volume_expectation(**kw) + assert v1 == v2 + + +def test_ellipsoid_reflect_broadcasting(): + e = Ellipsoid(a=2, b=1, c=1) + pts = e.reflect(np.zeros((5, 3)) + [1, 0, 0]) + assert pts.shape == (5, 3) + assert np.allclose(pts, [3, 0, 0]) + + +def test_ellipsoid_equality(): + R = Quaternion.random().rotation_matrix + e1 = Ellipsoid(a=3, b=2, c=1, matrix=R, center=(1, 2, 3)) + e2 = Ellipsoid(axes=(3, 2, 1), matrix=R, center=(1, 2, 3)) + assert e1 == e2 + assert e1 != Ellipsoid(a=3, b=2, c=1.5, matrix=R, center=(1, 2, 3)) + + +# --------------------------------------------------------------------------- # +# Spheres, boxes, rectangles # +# --------------------------------------------------------------------------- # +def test_sphere_volume_expectation_numpy_scalars(): + assert np.isclose(Sphere.volume_expectation(r=np.float64(1)), + 4 * np.pi / 3) + assert np.isclose(Sphere.volume_expectation(size=np.float64(2)), + 4 * np.pi / 3) + + +def test_sphere_plot_on_fresh_figure(): + plt.figure() + Sphere(r=1).plot() + plt.close('all') + + +def test_circle_best_fit_singular(): + # collinear points: the linear system is singular, must not raise + pts = [[0, 0], [1, 0], [2, 0], [3, 0]] + c = Circle.best_fit(pts) + assert np.isfinite(c.r) + + +def test_rectangle_within_respects_rotation(): + r = Rectangle(length=4, width=1, angle=90) + assert r.within([0, 1.5]) + assert not r.within([1.5, 0]) + assert np.all(r.within([[0, 1.9], [0.4, -1.9]])) + r = Rectangle(length=4, width=1) + assert r.within([1.5, 0]) and not r.within([0, 1.5]) + + +def test_rectangle_length_only_matches_expectation(): + r = Rectangle(length=2) + assert np.allclose(r.side_lengths, [2, 1]) + assert np.isclose(r.area, Rectangle.area_expectation(length=2)) + + +def test_square_area_expectation_returns_value(): + assert np.isclose(Square.area_expectation(side_lengths=[2, 2]), 4) + assert np.isclose(Square.area_expectation(side_length=2), 4) + + +def test_box_str_round_trip_precision(): + import ast + b = Box(center=(0.1, 0.2, 0.3), side_lengths=(1 / 3, 2 / 3, 1.1)) + vals = {} + for line in str(b).split('\n'): + k, v = line.split(':', 1) + vals[k.strip().lower().replace(' ', '_')] = ast.literal_eval(v.strip()) + assert vals['center'] == (0.1, 0.2, 0.3) + assert vals['side_lengths'] == (1 / 3, 2 / 3, 1.1) + assert Box(**vals) == b + + +def test_nbox_equality(): + r1 = Rectangle(center=(1, 1), length=2, width=1, angle=30) + r2 = Rectangle(center=(1, 1), side_lengths=(2, 1), angle=30) + assert r1 == r2 + assert r1 != Rectangle(center=(1, 1), length=2, width=1, angle=31) From db9a238357f8fc7cbd771b6a59fef6b0eba6e4a7 Mon Sep 17 00:00:00 2001 From: Julian Rimoli Date: Tue, 22 Sep 2026 20:02:12 -0700 Subject: [PATCH 02/42] Fix seeding bugs: reproducibility, file round trips, positioning - from_info: iterate keywords in sorted order (the RNG chain depended on the per-process set order) and do not modify the rng_seeds argument - calc_rtol is the single implementation of the overlap-tolerance fit (position() used a duplicate); handles a single seed - Seed: breakdowns are float arrays also when read from a file (seeds loaded from seeds.txt could not be repositioned); a geometry center or position is respected; update_breakdown(); robust __eq__; clear error for unsupported shapes; parseable __str__/__repr__ - position(): 'random' entries in per-axis position distributions - sample_pos_within raises instead of looping forever - SeedList(seeds=None); numpy arrays as per-item plot keywords; plot_breakdown in 3D on a fresh figure; remove unused sampling helpers Co-Authored-By: Claude Fable 5.1 --- src/microstructpy/seeding/seed.py | 115 ++++++++----- src/microstructpy/seeding/seedlist.py | 211 ++++++++--------------- tests/seeding/test_seeding_fixes.py | 236 ++++++++++++++++++++++++++ 3 files changed, 385 insertions(+), 177 deletions(-) create mode 100644 tests/seeding/test_seeding_fixes.py diff --git a/src/microstructpy/seeding/seed.py b/src/microstructpy/seeding/seed.py index 4cc89326..f9d728bc 100644 --- a/src/microstructpy/seeding/seed.py +++ b/src/microstructpy/seeding/seed.py @@ -66,15 +66,39 @@ def __init__(self, seed_geometry, phase=0, breakdown=None, position=None): self.geometry = seed_geometry self.phase = phase - if position is None and self.geometry is not None: - self.position = [0 for _ in range(self.geometry.n_dim)] + if self.geometry is None: + geom_cen = None else: - self.position = position + geom_cen = [float(x) for x in self.geometry.center] + + # A generated breakdown lies at the geometry center; a breakdown + # given by the caller is assumed to already be at ``position``. + generated = breakdown is None + if generated: + if self.geometry is None: + breakdown = [] + else: + breakdown = self.geometry.approximate() + self.breakdown = _breakdown_array(breakdown) - if breakdown is None: - self.breakdown = seed_geometry.approximate() + if position is None: + position = [] if geom_cen is None else geom_cen + if generated and geom_cen is not None: + self._position = geom_cen else: - self.breakdown = breakdown + self._position = [float(x) for x in position] + self.position = position + + # ----------------------------------------------------------------------- # + # Update Breakdown # + # ----------------------------------------------------------------------- # + def update_breakdown(self): + """Recompute the breakdown from the geometry. + + The circles/spheres are recomputed with the geometry's + ``approximate`` method, at the current position of the seed. + """ + self.breakdown = _breakdown_array(self.geometry.approximate()) # ----------------------------------------------------------------------- # # Factory Method # @@ -137,15 +161,11 @@ def factory(cls, seed_type, phase=0, breakdown=None, position=None, geom = None else: geom = geometry.factory(seed_type, **kwargs) - - if breakdown is None: - if seed_type in ('circle', 'sphere'): - breakdown = np.append(geom.center, geom.r).reshape(1, -1) - else: - breakdown = geom.approximate() - - if position is None: - position = [0 for _ in range(geom.n_dim)] + if not hasattr(geom, 'approximate'): + e_str = 'Seeds of type ' + seed_type + ' are not supported,' + e_str += ' since the geometry cannot be approximated by' + e_str += ' circles/spheres.' + raise ValueError(e_str) return cls(geom, phase, breakdown, position) @@ -190,8 +210,9 @@ def from_str(cls, seed_str): if 'breakdown' in str_dict: breakdown = str_dict['breakdown'] - if not isinstance(breakdown[0], tuple): + if not isinstance(breakdown[0], (tuple, list)): breakdown = (breakdown,) + breakdown = _breakdown_array(breakdown) del str_dict['breakdown'] else: breakdown = None @@ -212,11 +233,14 @@ def __str__(self): str_str = 'Geometry: ' + geom_name + '\n' str_str += str(self.geometry) + '\n' str_str += 'Phase: ' + str(self.phase) + '\n' - bkdwn_str = ', '.join([str(tuple(b)) for b in self.breakdown]) + # plain floats, so that the string can be parsed back exactly + bkdwn_str = ', '.join([repr(tuple([float(x) for x in b])) for b in + self.breakdown]) if len(self.breakdown) == 1: bkdwn_str += ',' # breakdowns will be a tuple of length 1 str_str += 'Breakdown: (' + bkdwn_str + ')\n' - str_str += 'Position: (' + ', '.join([str(x) for x in self.position]) + str_str += 'Position: (' + str_str += ', '.join([repr(float(x)) for x in self.position]) str_str += ')' return str_str @@ -224,9 +248,13 @@ def __repr__(self): repr_str = 'Seed(' repr_str += repr(self.geometry) + ', ' repr_str += 'phase=' + repr(self.phase) + ', ' - bkdwn_str = ', '.join([repr(tuple(b)) for b in self.breakdown]) + bkdwn_str = ', '.join([repr(tuple([float(x) for x in b])) for b in + self.breakdown]) + if len(self.breakdown) == 1: + bkdwn_str += ',' repr_str += 'breakdown=(' + bkdwn_str + '), ' - repr_str += 'position=(' + ', '.join([repr(x) for x in self.position]) + repr_str += 'position=(' + repr_str += ', '.join([repr(float(x)) for x in self.position]) repr_str += ')' repr_str += ')' return repr_str @@ -261,17 +289,24 @@ def __eq__(self, seed): if seed.phase != self.phase: return False - if not np.all(np.isclose(seed.breakdown, self.breakdown)): + b1 = np.array(self.breakdown, dtype='float') + b2 = np.array(seed.breakdown, dtype='float') + if b1.shape != b2.shape or not np.allclose(b1, b2): return False if seed.geometry != self.geometry: return False - if not np.all(np.isclose(seed.position, self.position)): + p1 = np.array(self.position, dtype='float') + p2 = np.array(seed.position, dtype='float') + if p1.shape != p2.shape or not np.allclose(p1, p2): return False return True + def __ne__(self, seed): + return not self.__eq__(seed) + # ----------------------------------------------------------------------- # # Position Getter/Setter # # ----------------------------------------------------------------------- # @@ -291,26 +326,20 @@ def position(self): @position.setter def position(self, pos): - try: - old_pos = np.array(self.position) - except AttributeError: + pos = [float(x) for x in pos] + old_pos = getattr(self, '_position', None) + if old_pos is None or len(old_pos) != len(pos): old_pos = np.zeros(len(pos)) - try: - displace = np.array(pos) - old_pos - for i, bkdwn in enumerate(self.breakdown): - coords = bkdwn[:-1] - rad = bkdwn[-1] - new_coords = [x + d for x, d in zip(coords, displace)] - new_bkdwn = new_coords + [rad] - self.breakdown[i] = new_bkdwn - except AttributeError: - pass - - try: + breakdown = getattr(self, 'breakdown', None) + if breakdown is not None and len(breakdown) > 0 and len(pos) > 0: + displace = np.array(pos) - np.array(old_pos, dtype='float') + new_breakdown = _breakdown_array(breakdown) + new_breakdown[:, :-1] += displace.reshape(1, -1) + self.breakdown = new_breakdown + + if self.geometry is not None: self.geometry.center = pos - except AttributeError: - pass self._position = pos @@ -379,3 +408,11 @@ def plot_breakdown(self, **kwargs): else: [geometry.Sphere(r=r, center=(x, y, z)).plot(**kwargs) for x, y, z, r in self.breakdown] + + +def _breakdown_array(breakdown): + """Breakdown as an N x (d + 1) array of floats (N x 0 if empty).""" + arr = np.array(breakdown, dtype='float') + if arr.size == 0: + return np.zeros((0, 0)) + return arr.reshape(-1, arr.shape[-1]) diff --git a/src/microstructpy/seeding/seedlist.py b/src/microstructpy/seeding/seedlist.py index 0aade885..43cf724f 100644 --- a/src/microstructpy/seeding/seedlist.py +++ b/src/microstructpy/seeding/seedlist.py @@ -52,8 +52,8 @@ class SeedList(object): # ----------------------------------------------------------------------- # # Constructors # # ----------------------------------------------------------------------- # - def __init__(self, seeds=[]): - self.seeds = seeds + def __init__(self, seeds=None): + self.seeds = [] if seeds is None else seeds @classmethod def from_file(cls, filename): @@ -145,6 +145,10 @@ def from_info(cls, phases, volume, rng_seeds={}): phase['shape'] = default_shapes[n_dim] # compute volume of each phase + # work on a copy: the seeds are updated below and the caller's + # dictionary must not change + rng_seeds = dict(rng_seeds) + vol_rng = rng_seeds.get('fraction', 0) np.random.seed(vol_rng) @@ -187,7 +191,9 @@ def from_info(cls, phases, volume, rng_seeds={}): seed_shape = phase['shape'] seed_args = {'phase': phase_num} kw_n = 0 - for kw in set(phase) - set(_misc.gen_kws): + # sorted: the RNG seed chain depends on the keyword order, and + # set iteration order changes from one process to the next + for kw in sorted(set(phase) - set(_misc.gen_kws)): # set the RNG seed rng_seed = rng_seeds.get(kw, 0) np.random.seed(rng_seed) @@ -295,7 +301,7 @@ def __add__(self, seedlist): .. versionadded:: 1.1 """ - if type(self) == type(seedlist): + if isinstance(seedlist, SeedList): return SeedList(self.seeds + seedlist.seeds) else: return SeedList(self.seeds + seedlist) @@ -441,7 +447,7 @@ def plot(self, index_by='seed', material=[], loc=0, **kwargs): for seed_num, seed in enumerate(self): phase_num = seed.phase for key, val in kwargs.items(): - if type(val) in (list, np.array): + if isinstance(val, (list, np.ndarray)): if index_by == 'seed' and len(val) > seed_num: seed_args[seed_num][key] = val[seed_num] elif index_by == 'material' and len(val) > phase_num: @@ -575,7 +581,8 @@ def plot(self, index_by='seed', material=[], loc=0, **kwargs): rects = [Rectangle(xy=xyi, width=wi, height=hi, angle=ai) for xyi, wi, hi, ai in zip(rect_data['xy'], rect_data['w'], rect_data['h'], rect_data['angle'])] - rc = collections.PatchCollection(rects, match_original=False, **rect_kwargs) + rc = collections.PatchCollection(rects, match_original=False, + **rect_kwargs) ax.add_collection(rc) # Plot Polygons @@ -593,7 +600,7 @@ def plot(self, index_by='seed', material=[], loc=0, **kwargs): p_kwargs[p].update(seed_kwargs) else: for key, val in kwargs.items(): - if type(val) in (list, np.array): + if isinstance(val, (list, np.ndarray)): for i, elem in enumerate(val): p_kwargs[i][key] = elem else: @@ -661,7 +668,7 @@ def plot_breakdown(self, index_by='seed', material=[], loc=0, **kwargs): for seed_num, seed in enumerate(self): phase_num = seed.phase for key, val in kwargs.items(): - if type(val) in (list, np.array): + if isinstance(val, (list, np.ndarray)): if index_by == 'seed' and len(val) > seed_num: seed_args[seed_num][key] = val[seed_num] elif index_by == 'material' and len(val) > phase_num: @@ -670,7 +677,7 @@ def plot_breakdown(self, index_by='seed', material=[], loc=0, **kwargs): seed_args[seed_num][key] = val n = self[0].geometry.n_dim - if n == 2 or plt.gca().get_axes(): + if n == 2 or plt.gcf().axes: ax = plt.gca() else: ax = plt.gcf().add_subplot(projection=Axes3D.name) @@ -730,7 +737,7 @@ def plot_breakdown(self, index_by='seed', material=[], loc=0, **kwargs): p_kwargs[p].update(seed_kwargs) else: for key, val in kwargs.items(): - if type(val) in (list, np.array): + if isinstance(val, (list, np.ndarray)): for i, elem in enumerate(val): p_kwargs[i][key] = elem else: @@ -842,7 +849,12 @@ def position(self, domain, pos_dists={}, rng_seed=0, hold=[], distribs = [] n_phases = max([s.phase for s in self]) + 1 for i in range(n_phases): - distribs.append(pos_dists.get(i, u_dist)) + dist = pos_dists.get(i, u_dist) + if isinstance(dist, (list, tuple)): + # 'random' along an axis means uniform across the domain + dist = [u if (isinstance(d, str) and d.lower() == 'random') + else d for d, u in zip(dist, u_dist)] + distribs.append(dist) # Add hold seeds n_seeds = len(self) @@ -860,15 +872,8 @@ def position(self, domain, pos_dists={}, rng_seed=0, hold=[], i_position = i_sort[~posd_sort] # allowable overlap, relative to radius - cv = scipy.stats.variation(vols) - if domain.n_dim == 2 and rtol == 'fit': - numer = 0.362954 * cv * cv - 0.419069 * cv + .184959 - denom = cv * cv - 1.05989 * cv + 0.365096 - rtol = numer / denom - elif rtol == 'fit': - numer = 0.471115 * cv * cv - 0.602324 * cv + 0.297562 - denom = cv * cv - 1.08469 * cv + 0.428216 - rtol = numer / denom + if isinstance(rtol, str) and rtol == 'fit': + rtol = calc_rtol(self) # position the remaining seeds i_reject = [] @@ -950,118 +955,12 @@ def position(self, domain, pos_dists={}, rng_seed=0, hold=[], self.seeds = self[keep_mask].seeds -def _get_n_dim(phases): - n_dim = None - for phase in phases: - if 'shape' in phase: - n_dim = geometry.factory(phase['shape']).n_dim - if n_dim is None: - e_str = 'Number of dimensions could not be determined from phase ' - e_str += 'shapes. Consider setting the shape of a phase, or' - e_str += ' specifying the number of dimensions.' - raise ValueError(e_str) - return n_dim - - -def _set_sample_rng_seeds(phases, rng_seeds, maxint): - rng_keys = list({k for p in phases for k in p} - set(_misc.gen_kws)) - rng_keys.extend(['fraction', 'phase']) - - n_keys = len(rng_keys) - int_step = maxint / n_keys - sample_seeds = {} - for i, k in enumerate(rng_keys): - rng_seed = int(rng_seeds.get(k, 0) + i * int_step) - sample_seeds[k] = rng_seed % maxint - return sample_seeds - - -def _calc_pop_fracs(n_dim, phases, sample_rng_seeds, max_int): - # compute volume of each phase - vol_rng = sample_rng_seeds['fraction'] - n_phases = len(phases) - rel_vols = np.ones(n_phases) - for i, phase in enumerate(phases): - vol = phase.get('fraction', 1) - try: - v_sample = -1 - while v_sample < 0: - v_sample = vol.rvs(random_state=vol_rng) - vol_rng = (vol_rng + 1) % max_int - rel_vols[i] = v_sample - except AttributeError: - rel_vols[i] = vol - vol_fracs = rel_vols / sum(rel_vols) - - # Compute the average grain volume of each phase - if n_dim == 2: - avg_vols = [geometry.factory(p['shape']).area_expectation(**p) - for p in phases] - else: - avg_vols = [geometry.factory(p['shape']).volume_expectation(**p) - for p in phases] - weights = vol_fracs / np.array(avg_vols) - pop_fracs = weights / sum(weights) - return pop_fracs - - -def _sample_phase_args(phase, sample_rng_seeds, n_dim, maxint): - seed_kwargs = {} - for kw in set(phase) - set(_misc.gen_kws): - rng_seed = sample_rng_seeds[kw] - - # Sample, with special cases for orientation - if kw not in _misc.ori_kws: - try: - val = phase[kw].rvs(random_state=rng_seed) - except AttributeError: - val = phase[kw] - seed_kwargs[kw] = val - elif (phase[kw] == 'random') and (n_dim == 2): - np.random.seed(rng_seed) - ang_dist = scipy.stats.uniform(loc=0, scale=360) - seed_kwargs['angle_deg'] = ang_dist.rvs(random_state=rng_seed) - elif phase[kw] == 'random': - quat_dist = scipy.stats.norm() - elems = quat_dist.rvs(4, random_state=rng_seed) - mag = np.linalg.norm(elems) - elems /= mag - val = Quaternion(elems).rotation_matrix - seed_kwargs[kw] = val - elif kw in ['rot_seq', 'rot_seq_deg', 'rot_seq_rad']: - seq = [] - val = phase[kw] - if not isinstance(val, list): - val = [val] - for rot_i, rotation in enumerate(val): - rot_dict = {str(kw): rotation[kw] for kw in rotation} - ax = rot_dict.get('axis', 'x') - ang_dist = rot_dict.get('angle', 0) - rot_rng = (rng_seed + rot_i) % maxint - try: - ang = ang_dist.rvs(random_state=rot_rng) - except AttributeError: - ang = ang_dist - seq.append((ax, ang)) - seed_kwargs[kw] = seq - else: - try: - val = phase[kw].rvs(random_state=rng_seed) - except AttributeError: - val = phase[kw] - seed_kwargs[kw] = val - - # Update the RNG seed - sample_rng_seeds[kw] = (rng_seed + 1) % maxint - return seed_kwargs - - def _plt_args(seeds, index_by, kwargs): seed_args = [{} for seed in seeds] for seed_num, seed in enumerate(seeds): phase_num = seed.phase for key, val in kwargs.items(): - if type(val) in (list, np.array): + if isinstance(val, (list, np.ndarray)): if index_by == 'seed' and len(val) > seed_num: seed_args[seed_num][key] = val[seed_num] elif index_by == 'material' and len(val) > phase_num: @@ -1161,7 +1060,8 @@ def _plot_2d(ax, seeds, seed_args): # Plot Rectangles rects = [Rectangle(**rect_inps) for rect_inps in rect_data] - rc = collections.PatchCollection(rects, match_original=False, **rect_kwargs) + rc = collections.PatchCollection(rects, match_original=False, + **rect_kwargs) ax.add_collection(rc) ax.autoscale_view() @@ -1204,7 +1104,7 @@ def _add_legend(ax, material, seeds, seed_args, kwargs, index_by, loc): p_kwargs[seed.phase].update(seed_kwargs) else: for key, val in kwargs.items(): - if type(val) in (list, np.array): + if isinstance(val, (list, np.ndarray)): for i, elem in enumerate(val): p_kwargs[i][key] = elem else: @@ -1222,20 +1122,32 @@ def _add_legend(ax, material, seeds, seed_args, kwargs, index_by, loc): def calc_rtol(seeds): - """Calculate relative overlap tolerance.""" - cv = scipy.stats.variation([s.volume for s in seeds]) + """Calculate relative overlap tolerance. + + The tolerance is a rational polynomial fit to the coefficient of + variation in seed area/volume, which minimizes the error between the + input and output size distributions (Hart and Rimoli, *Comput. Methods + Appl. Mech. Engrg.* 370 (2020) 113242). + + Args: + seeds (SeedList or list): The seeds, used for their volumes and + number of dimensions. + + Returns: + float: The relative overlap tolerance, between 0 and 1. + """ + vols = [s.volume for s in seeds] + cv = scipy.stats.variation(vols) if len(vols) > 1 else 0.0 n_dim = seeds[0].geometry.n_dim if n_dim == 2: numer = 0.362954 * cv * cv - 0.419069 * cv + .184959 denom = cv * cv - 1.05989 * cv + 0.365096 - rtol = numer / denom elif n_dim == 3: numer = 0.471115 * cv * cv - 0.602324 * cv + 0.297562 denom = cv * cv - 1.08469 * cv + 0.428216 - rtol = numer / denom else: raise ValueError('Cannot calculate rtol for {}-D.'.format(n_dim)) - return rtol + return numer / denom def sample_pos(distribution, n=1): @@ -1285,12 +1197,35 @@ def sample_pos(distribution, n=1): return pos -def sample_pos_within(distribution, n, domain): +def sample_pos_within(distribution, n, domain, max_rounds=1000): + """Sample a position distribution, rejecting points outside the domain. + + Args: + distribution (list or scipy.stats distribution): The position + distribution, see :func:`sample_pos`. + n (int): Number of samples. + domain (from :mod:`microstructpy.geometry`): The domain. + max_rounds (int): *(optional)* Maximum number of rejection-sampling + rounds of ``n`` samples each before giving up. + + Returns: + numpy.ndarray: An n x d array of positions within the domain. + + Raises: + ValueError: If no sample fell within the domain after ``max_rounds`` + rounds, which indicates that the distribution does not cover + the domain. + """ pos = [] + n_rounds = 0 while len(pos) < n: - samples = sample_pos(distribution, n) + if n_rounds >= max_rounds: + e_str = 'Could not sample positions within the domain after ' + e_str += str(max_rounds) + ' rounds. Check that the position ' + e_str += 'distribution overlaps the domain.' + raise ValueError(e_str) + samples = np.array(sample_pos(distribution, n)).reshape(n, -1) mask = domain.within(samples) pos.extend(samples[mask]) - if n == 1: - return pos + n_rounds += 1 return np.array(pos[:n]) diff --git a/tests/seeding/test_seeding_fixes.py b/tests/seeding/test_seeding_fixes.py new file mode 100644 index 00000000..1aaaf181 --- /dev/null +++ b/tests/seeding/test_seeding_fixes.py @@ -0,0 +1,236 @@ +"""Regression tests for the seeding module bug fixes.""" +import copy + +import matplotlib +import numpy as np +import pytest +import scipy.stats +from matplotlib import pyplot as plt + +import microstructpy as msp +from microstructpy.seeding import Seed +from microstructpy.seeding import SeedList +from microstructpy.seeding.seedlist import calc_rtol +from microstructpy.seeding.seedlist import sample_pos_within + +matplotlib.use('agg') + + +def _phases_2d(): + return [{'shape': 'ellipse', 'size': scipy.stats.lognorm(s=0.3, scale=0.5), + 'aspect_ratio': scipy.stats.uniform(1, 2), + 'angle_deg': scipy.stats.uniform(0, 180), 'fraction': 0.6}, + {'shape': 'circle', 'size': scipy.stats.uniform(0.2, 0.3), + 'fraction': 0.4}] + + +def _signature(seeds): + return [(s.phase, round(s.volume, 10), tuple(np.round(s.position, 8))) + for s in seeds] + + +# --------------------------------------------------------------------------- # +# Seed generation # +# --------------------------------------------------------------------------- # +def test_from_info_keyword_order_independent(): + """The RNG chain must not depend on the (hash-dependent) set order.""" + phases = _phases_2d() + seeds_1 = SeedList.from_info(copy.deepcopy(phases), 30.0) + # same phases with the dictionary keys inserted in another order + reordered = [dict(reversed(list(p.items()))) for p in phases] + seeds_2 = SeedList.from_info(reordered, 30.0) + assert _signature(seeds_1) == _signature(seeds_2) + + +def test_from_info_repeated_calls_identical(): + phases = _phases_2d() + seeds_1 = SeedList.from_info(copy.deepcopy(phases), 30.0) + seeds_2 = SeedList.from_info(copy.deepcopy(phases), 30.0) + assert _signature(seeds_1) == _signature(seeds_2) + + +def test_from_info_does_not_mutate_rng_seeds(): + rng_seeds = {'size': 3, 'fraction': 1} + SeedList.from_info(_phases_2d(), 20.0, rng_seeds) + assert rng_seeds == {'size': 3, 'fraction': 1} + assert SeedList.from_info.__func__.__defaults__[0] == {} + + +# --------------------------------------------------------------------------- # +# Overlap tolerance # +# --------------------------------------------------------------------------- # +def test_calc_rtol_values(): + rng = np.random.RandomState(0) + areas = np.exp(-9 + 0.5 * rng.normal(size=4000)) + seeds = [Seed.factory('circle', area=a) for a in areas] + cv_s = scipy.stats.variation(areas) + expected = ((0.362954 * cv_s ** 2 - 0.419069 * cv_s + 0.184959) / + (cv_s ** 2 - 1.05989 * cv_s + 0.365096)) + assert np.isclose(calc_rtol(seeds), expected) + + seeds_3d = [Seed.factory('sphere', volume=a) for a in areas] + expected_3d = ((0.471115 * cv_s ** 2 - 0.602324 * cv_s + 0.297562) / + (cv_s ** 2 - 1.08469 * cv_s + 0.428216)) + assert np.isclose(calc_rtol(seeds_3d), expected_3d) + + # constant sizes: cv = 0 (a single seed as well) + same = [Seed.factory('circle', r=1) for _ in range(3)] + assert np.isclose(calc_rtol(same), 0.184959 / 0.365096) + assert np.isclose(calc_rtol(same[:1]), 0.184959 / 0.365096) + + +def test_position_uses_calc_rtol(monkeypatch): + seen = {} + real = msp.seeding.seedlist.calc_rtol + + def spy(seeds): + seen['rtol'] = real(seeds) + return seen['rtol'] + + monkeypatch.setattr(msp.seeding.seedlist, 'calc_rtol', spy) + domain = msp.geometry.Square(side_length=4) + seeds = SeedList.from_info(_phases_2d(), domain.area) + seeds.position(domain) + assert np.isclose(seen['rtol'], real(seeds)) + + +# --------------------------------------------------------------------------- # +# Seeds and files # +# --------------------------------------------------------------------------- # +def test_seed_factory_position_and_center_consistent(): + s = Seed.factory('circle', r=0.5, position=(1, 2)) + assert np.allclose(s.breakdown, [[1, 2, 0.5]]) + assert np.allclose(s.geometry.center, [1, 2]) + + s = Seed.factory('circle', r=0.5, center=(3, 4)) + assert np.allclose(s.position, [3, 4]) + assert np.allclose(s.breakdown, [[3, 4, 0.5]]) + + s.position = (5, 5) + assert np.allclose(s.breakdown, [[5, 5, 0.5]]) + assert np.allclose(s.geometry.center, [5, 5]) + + e = Seed.factory('ellipse', a=2, b=1, angle_deg=30, position=(1, -1)) + assert np.allclose(np.mean(np.array(e.breakdown)[:, :2], axis=0), + [1, -1]) + + +def test_seed_update_breakdown_exists(): + s = Seed.factory('ellipse', a=2, b=1) + s.breakdown = [] + s.update_breakdown() + assert len(s.breakdown) > 1 + + +def test_seed_factory_unsupported_shape(): + with pytest.raises(ValueError): + Seed.factory('box', side_lengths=(1, 1, 1)) + + +def test_seed_equality_different_breakdowns(): + s1 = Seed.factory('ellipse', a=2, b=1) + s2 = Seed.factory('ellipse', a=3, b=1) + assert s1 != s2 + assert s1 == Seed.factory('ellipse', a=2, b=1) + + +def test_seedlist_mutable_default(): + x = SeedList() + x.append(Seed.factory('circle', r=1)) + assert len(SeedList()) == 0 + + +def test_seed_file_round_trip_and_reposition(tmp_path): + phases = [{'shape': 'circle', 'size': 0.3}, + {'shape': 'ellipse', 'size': 0.4, 'aspect_ratio': 2, + 'angle_deg': scipy.stats.uniform(0, 180)}, + {'shape': 'rectangle', 'length': 0.3, 'width': 0.2, + 'angle_deg': scipy.stats.uniform(0, 90)}, + {'shape': 'square', 'side_length': 0.25}] + domain = msp.geometry.Square(side_length=3) + seeds = SeedList.from_info(phases, 0.5 * domain.area) + seeds.position(domain) + fname = str(tmp_path / 'seeds.txt') + seeds.write(fname) + loaded = SeedList.from_file(fname) + assert loaded == seeds + for s1, s2 in zip(seeds, loaded): + assert np.allclose(s1.breakdown, s2.breakdown) + assert s1.geometry == s2.geometry + + # seeds loaded from a file can be repositioned + loaded[0].position = [1.0, 2.0] + assert np.allclose(np.array(loaded[0].breakdown)[0, :2], [1.0, 2.0]) + loaded.position(domain, rng_seed=1) + + +def test_ellipsoid_seed_file_round_trip(tmp_path): + phases = [{'shape': 'ellipsoid', 'size': 0.5, 'ratio_ab': 2, + 'ratio_ac': 1.5, 'orientation': 'random'}, + {'shape': 'sphere', 'size': 0.4}] + seeds = SeedList.from_info(phases, 1.0) + for i, s in enumerate(seeds): + s.position = [0.1 * i, -0.2 * i, 0.3 * i] + fname = str(tmp_path / 'seeds.txt') + seeds.write(fname) + loaded = SeedList.from_file(fname) + assert loaded == seeds + for s1, s2 in zip(seeds, loaded): + if isinstance(s1.geometry, msp.geometry.Ellipsoid): + assert np.allclose(s1.geometry.matrix, s2.geometry.matrix) + assert np.allclose(s1.geometry.limits, s2.geometry.limits) + + +# --------------------------------------------------------------------------- # +# Positioning # +# --------------------------------------------------------------------------- # +def test_position_random_axis_distribution(): + phases = [{'shape': 'circle', 'size': 0.3}, + {'shape': 'circle', 'size': 0.3}] + domain = msp.geometry.Square(side_length=5) + seeds = SeedList.from_info(phases, 0.3 * domain.area) + pos_dists = {1: ['random', scipy.stats.norm(0, 0.3)]} + seeds.position(domain, pos_dists=pos_dists, rng_seed=0) + pos = np.array([s.position for s in seeds if s.phase == 1]) + assert pos[:, 0].std() > 1.0 + assert pos[:, 1].std() < 0.8 + assert np.abs(pos[:, 1]).mean() < 0.7 + + +def test_sample_pos_within_raises_when_unreachable(): + domain = msp.geometry.Square(side_length=2) + with pytest.raises(ValueError): + sample_pos_within([5.0, 5.0], 3, domain, max_rounds=10) + mvn = scipy.stats.multivariate_normal([50, 50], np.eye(2)) + with pytest.raises(ValueError): + sample_pos_within(mvn, 3, domain, max_rounds=10) + pts = sample_pos_within([scipy.stats.uniform(-1, 2), + scipy.stats.uniform(-1, 2)], 5, domain) + assert pts.shape == (5, 2) + assert np.all(domain.within(pts)) + + +# --------------------------------------------------------------------------- # +# Plotting # +# --------------------------------------------------------------------------- # +def test_plot_breakdown_3d_fresh_figure(): + seeds = SeedList([Seed.factory('sphere', r=0.5, position=(0, 0, 0)), + Seed.factory('ellipsoid', a=1, b=0.5, c=0.5, + position=(2, 0, 0))]) + plt.figure() + seeds.plot_breakdown() + plt.close('all') + plt.figure() + seeds.plot() + plt.close('all') + + +def test_plot_accepts_numpy_array_colors(): + seeds = SeedList([Seed.factory('circle', r=0.5, position=(i, 0)) + for i in range(3)]) + plt.figure() + seeds.plot(facecolors=np.array(['r', 'g', 'b'])) + colls = [c for c in plt.gca().collections + if type(c).__name__ == 'EllipseCollection'] + assert len(colls[0].get_facecolors()) == 3 + plt.close('all') From ba996f40a2f136d33fb1754d2c13e086d205442f Mon Sep 17 00:00:00 2001 From: Julian Rimoli Date: Tue, 22 Sep 2026 20:02:12 -0700 Subject: [PATCH 03/42] Fix PolyMesh bugs: clipping to circular domains, edge_opt, I/O - Cells that intersect a circular/elliptical domain without a vertex inside it are no longer dropped; cells cut twice are clipped correctly; a cell containing the domain becomes a boundary polygon - _loop_area indexes the loop (stored areas of clipped cells were wrong) - _segment_cross uses a fixed number of bisections (hung for large coordinates) - edge_opt works on a copy, displaces seeds rigidly and applies only the accepted moves to the caller's seeds; quiet unless verbose - Full-precision points in text files; write(format='poly') writes the file; plot() in 3D on a fresh figure; numpy arrays as per-item plot keywords; __eq__ is silent and linear Co-Authored-By: Claude Fable 5.1 --- src/microstructpy/meshing/polymesh.py | 509 +++++++++++++++++--------- tests/meshing/test_polymesh_fixes.py | 362 ++++++++++++++++++ 2 files changed, 707 insertions(+), 164 deletions(-) create mode 100644 tests/meshing/test_polymesh_fixes.py diff --git a/src/microstructpy/meshing/polymesh.py b/src/microstructpy/meshing/polymesh.py index ea7d062f..330bff85 100644 --- a/src/microstructpy/meshing/polymesh.py +++ b/src/microstructpy/meshing/polymesh.py @@ -13,6 +13,7 @@ from __future__ import division from __future__ import print_function +import copy import os import subprocess import sys @@ -199,12 +200,12 @@ def __repr__(self): def __str__(self): nv = len(self.points) - nd = len(self.points[0]) - pt_fmt = '\t' - pt_fmt += ', '.join(['{pt[' + str(i) + ']: e}' for i in range(nd)]) + # points are written with full precision (repr of a float is the + # shortest string that round-trips exactly) str_str = 'Mesh Points: ' + str(nv) + '\n' - str_str += ''.join([pt_fmt.format(pt=p) + '\n' for p in self.points]) + str_str += ''.join(['\t' + ', '.join([repr(float(x)) for x in p]) + + '\n' for p in self.points]) str_str += 'Mesh Facets: ' + str(len(self.facets)) + '\n' str_str += ''.join(['\t' + str(tuple(f))[1:-1] + '\n' @@ -269,14 +270,17 @@ def write(self, filename, format='txt'): poly += ''.join([' '.join([str(n) for n in (nv + i, k1, k2)]) + '\n' for i, (k1, k2) in enumerate(self.facets)]) + with open(filename, 'w') as f: + f.write(poly) + elif format == 'ply': nv = len(self.points) nd = len(self.points[0]) nf = len(self.facets) nr = len(self.regions) assert nd <= 3 - - # Force 3D points + + # Force 3D points pts = np.zeros((nv, 3)) pts[:, :nd] = self.points axes = ['x', 'y', 'z'] @@ -646,9 +650,19 @@ def from_seeds(cls, seedlist, domain, edge_opt=False, n_iter=100, cell_mask = np.full(len(bkdwn2seed), True, dtype='bool') rect_doms = ['square', 'cube', 'rectangle', 'box', 'nbox'] if type(domain).__name__.lower() not in rect_doms: - for cell_num, cell in enumerate(voro): - cell_pts = np.array(cell['vertices']) - cell_mask[cell_num] = np.any(domain.within(cell_pts)) + if n_dim == 2: + # Clip the cells to the domain. Cells that do not intersect + # the domain are removed. + for cell_num, cell in enumerate(voro): + clipped_cell = _clip_cell(cell, domain) + if clipped_cell is None: + cell_mask[cell_num] = False + else: + voro[cell_num] = clipped_cell + else: + for cell_num, cell in enumerate(voro): + cell_pts = np.array(cell['vertices']) + cell_mask[cell_num] = np.any(domain.within(cell_pts)) bkdwn2seed = bkdwn2seed[cell_mask] new_cell_nums = np.full(len(cell_mask), -1, dtype='int') @@ -669,8 +683,11 @@ def from_seeds(cls, seedlist, domain, edge_opt=False, n_iter=100, if cell_mask[old_cell_num]: reduced_voro.append(cell) - # Clip cells to domain - voro = [_clip_cell(c, domain) for c in reduced_voro] + # Clip cells to domain (2D cells have already been clipped) + if n_dim == 2: + voro = reduced_voro + else: + voro = [_clip_cell(c, domain) for c in reduced_voro] # create global key point and facet lists pts_global = [] @@ -747,11 +764,7 @@ def from_seeds(cls, seedlist, domain, edge_opt=False, n_iter=100, # short edge optimization if edge_opt: - seed2bkdwn = {i: [] for i in range(len(seedlist))} - for i, n in enumerate(pmesh.seed_numbers): - seed2bkdwn[n].append(i) - - # Find the shorted edge + # Find the shortest edge edge_lens = _edge_lengths(pmesh) min_edge = _shortest_edge(edge_lens) min_len = edge_lens[min_edge]['length'] @@ -768,33 +781,39 @@ def from_seeds(cls, seedlist, domain, edge_opt=False, n_iter=100, i_n_attempts = 0 while i_n_attempts < n_iter: - print(v_fmt.format(min_len, min_edge, i_n_attempts)) - # Create Displacement - max_step_size = float('inf') - step_fracs = 2 * np.random.rand(3) - 1 # [-1, 1] - new_cens = np.copy(cens) - - e_neighs = edge_lens[min_edge]['regions'] + if verbose: + print(v_fmt.format(min_len, min_edge, i_n_attempts)) + + # Seeds adjacent to the shortest edge + e_regions = [r for r in edge_lens[min_edge]['regions'] + if r >= 0] + e_seeds = sorted({int(pmesh.seed_numbers[r]) + for r in e_regions}) edge_pts = np.array(pmesh.points)[list(min_edge)] - for region_num in e_neighs: - if region_num >= 0: - step_size = 0.1 * rads[region_num] - max_step_size = min(max_step_size, step_size) - for f, region_num in zip(step_fracs, e_neighs): - if region_num >= 0: - e_norm_vec = _point_line_vec(cens[region_num], - edge_pts) - step = f * max_step_size * e_norm_vec - new_cens[region_num] += step - - # Update Seeds - new_bkdwns = [list(c) + [r] for c, r in zip(new_cens, rads)] - for i, seed in enumerate(seedlist): - seed.breakdown = [new_bkdwns[j] for j in seed2bkdwn[i]] + + # Displace the seeds rigidly, on a copy of the seed list. + # The step is a random fraction of 0.1 x the equivalent + # radius of the seed, normal to the edge. + trial_seeds = copy.deepcopy(seedlist) + steps = {} + for seed_num in e_seeds: + seed = seedlist[seed_num] + pos = np.array(seed.position, dtype='float') + with np.errstate(divide='ignore', invalid='ignore'): + e_norm_vec = _point_line_vec(pos, edge_pts) + if not np.all(np.isfinite(e_norm_vec)): + continue + if n_dim == 2: + r_eq = np.sqrt(seed.volume / np.pi) + else: + r_eq = np.cbrt(3 * seed.volume / (4 * np.pi)) + step_frac = 2 * np.random.rand() - 1 # [-1, 1] + steps[seed_num] = 0.1 * step_frac * r_eq * e_norm_vec + _displace_seed(trial_seeds[seed_num], steps[seed_num]) # Create New Polygonal Mesh try: - new_pmesh = cls.from_seeds(seedlist, domain, + new_pmesh = cls.from_seeds(trial_seeds, domain, edge_opt=False) except AssertionError: i_n_attempts += 1 @@ -805,6 +824,11 @@ def from_seeds(cls, seedlist, domain, edge_opt=False, n_iter=100, new_min_len = new_edge_lens[new_min_edge]['length'] if new_min_len > min_len: + # Accept the trial: apply the same displacements to the + # caller's seeds, so that they reproduce the new mesh + for seed_num, step in steps.items(): + _displace_seed(seedlist[seed_num], step) + if new_min_edge != min_edge: i_n_attempts = 0 else: @@ -813,7 +837,6 @@ def from_seeds(cls, seedlist, domain, edge_opt=False, n_iter=100, pmesh = new_pmesh min_len = new_min_len min_edge = new_min_edge - cens = new_cens else: i_n_attempts += 1 return pmesh @@ -852,7 +875,7 @@ def plot(self, index_by='seed', material=[], loc=0, **kwargs): """ n_dim = len(self.points[0]) - if n_dim == 2 or plt.gca().axes: + if n_dim == 2 or plt.gcf().axes: ax = plt.gca() else: ax = plt.gcf().add_subplot(projection=Axes3D.name) @@ -872,7 +895,7 @@ def plot(self, index_by='seed', material=[], loc=0, **kwargs): plt_kwargs = {} for key, value in kwargs.items(): - if type(value) in (list, np.array): + if isinstance(value, (list, np.ndarray)): plt_value = [] for s, p in zip(self.seed_numbers, self.phase_numbers): if index_by == 'material': @@ -904,7 +927,7 @@ def plot(self, index_by='seed', material=[], loc=0, **kwargs): p_kwargs = [{'label': m} for m in material] s2p = {s: p for s, p in zip(self.seed_numbers, self.phase_numbers)} for key, value in kwargs.items(): - if type(value) in (list, np.array): + if isinstance(value, (list, np.ndarray)): if index_by == 'material': for p, v in enumerate(value): p_kwargs[p][key] = v @@ -970,7 +993,7 @@ def plot_facets(self, index_by='seed', hide_interior=True, **kwargs): """ f_kwargs = {} for key, value in kwargs.items(): - if type(value) in (list, np.array): + if isinstance(value, (list, np.ndarray)): f_values = [] for fn in range(len(self.facets)): neighs = self.facet_neighbors[fn] @@ -1059,7 +1082,6 @@ def plot_facets(self, index_by='seed', hide_interior=True, **kwargs): def __eq__(self, other_mesh): # check type if type(other_mesh) is not PolyMesh: - print('not same type') return False # check that the lengths are all the same @@ -1070,7 +1092,6 @@ def __eq__(self, other_mesh): same &= len(self.seed_numbers) == len(other_mesh.seed_numbers) same &= len(self.phase_numbers) == len(other_mesh.phase_numbers) if not same: - print('not same length') return False # check that the vertices have the same coordinates @@ -1080,59 +1101,53 @@ def __eq__(self, other_mesh): same &= np.all(same_ints.sum(axis=0) == 1) same &= np.all(same_ints.sum(axis=1) == 1) if not same: - print('not same verts') return False - kp_conv = np.argwhere(same_pt) - kp_other = kp_conv[:, 1] - print('transform') - print(np.array(kp_other)) + # kp_other[i] is the point in other_mesh that matches point i + kp_other = np.argmax(same_pt, axis=1) # check that the facets are the same - facets_in_other_kps = [[kp_other[kp] for kp in f] for f in self.facets] - o_fnum = [] - for i, s_facet in enumerate(facets_in_other_kps): - for j, o_facet in enumerate(other_mesh.facets): - if j in o_fnum: - continue - else: - if set(s_facet) == set(o_facet): - o_fnum.append(j) - break - - if len(o_fnum) != i + 1: - print('not same facets') - return False + o_fnum = _match_index_sets( + [[kp_other[kp] for kp in f] for f in self.facets], + other_mesh.facets) + if o_fnum is None: + return False # check that the regions are the same - regions_in_other_fnums = [[o_fnum[f] for f in r] for r in self.regions] - o_rnum = [] - for i, s_region in enumerate(regions_in_other_fnums): - for j, o_region in enumerate(other_mesh.regions): - if j in o_rnum: - continue - else: - if set(s_region) == set(o_region): - o_rnum.append(j) - break - - if len(o_rnum) != i + 1: - print('not same regions') - return False + o_rnum = _match_index_sets( + [[o_fnum[f] for f in r] for r in self.regions], + other_mesh.regions) + if o_rnum is None: + return False # check that the seed numbers are the same s_seed_nums = np.array(self.seed_numbers) o_seed_nums = np.array(other_mesh.seed_numbers) same &= np.all(s_seed_nums == o_seed_nums[o_rnum]) - print('checking seed numbers', same) # check that the phase numbers are the same s_phase_nums = np.array(self.phase_numbers) o_phase_nums = np.array(other_mesh.phase_numbers) same &= np.all(s_phase_nums == o_phase_nums[o_rnum]) - print('checking phase numbers', same) - return same + return bool(same) + + +def _match_index_sets(items, other_items): + """Match each item to an unused item of ``other_items`` with the same + set of indices. Returns the list of matched positions, or None if any + item has no match.""" + unused = {} + for j, other_item in enumerate(other_items): + unused.setdefault(frozenset(other_item), []).append(j) + + matches = [] + for item in items: + candidates = unused.get(frozenset(item), []) + if not candidates: + return None + matches.append(candidates.pop(0)) + return matches def kp_loop(kp_pairs): @@ -1150,112 +1165,266 @@ def kp_loop(kp_pairs): def _clip_cell(cell_data, domain): + """Clip a Voronoi cell to the domain. + + Rectangular domains do not require clipping and the cell is returned + unchanged. In 2D, the (convex) cell is clipped to the (convex) domain + and ``None`` is returned if the cell does not intersect the domain. + Non-rectangular 3D domains are not supported: a warning is raised and + the cell is returned unchanged. + + Args: + cell_data (dict): A cell from pyvoro, with the 'vertices', 'faces', + 'adjacency', 'original', and 'volume' keys. + domain (from :mod:`microstructpy.geometry`): The domain. + + Returns: + dict or None: The clipped cell, or None if it is outside the domain. + + """ domain_name = type(domain).__name__.lower() if domain_name in ['rectangle', 'square', 'box', 'cube']: return cell_data if domain.n_dim == 2: - pts = np.array(cell_data['vertices']) - if np.all(domain.within(pts)): - return cell_data - - # split the edges that contain the boundary - new_adj = np.copy(cell_data['adjacency']) - new_faces = [] - new_pts = np.copy(cell_data['vertices']) - new_kps = [] - - for face in cell_data['faces']: - adj_cell = face['adjacent_cell'] - verts = face['vertices'] - face_pts = pts[verts] - pts_within = domain.within(face_pts) - if np.all(pts_within) or np.all(~pts_within): - new_faces.append(face) - continue - crossing_pt = _segment_cross(face_pts, domain) - - # Add point to list of vertices and face to list of faces - crossing_kp = len(new_pts) - new_pts = np.vstack((new_pts, crossing_pt.reshape(1, -1))) - new_kps.append(crossing_kp) - - for kp_i, kp in enumerate(verts): - kp_other = verts[1 - kp_i] - new_adj[kp] = [kp_other, crossing_kp] - - new_verts = [kp, crossing_kp] - new_faces.append({'adjacent_cell': adj_cell, - 'vertices': new_verts}) - - # add divider face - new_faces.append({'adjacent_cell': -1, 'vertices': new_kps}) - - # Create cell within the domain - new_within = domain.within(new_pts) - new_within[new_kps] = True - - within_pts = new_pts[new_within] - kp_conv = np.full(len(new_pts), -1, dtype='int') - kp_conv[new_within] = np.arange(np.sum(new_within)) - - within_adj = [[] for pt in within_pts] - within_faces = [] - for face in new_faces: - adj_cell = face['adjacent_cell'] - old_verts = face['vertices'] - new_verts = [kp_conv[v] for v in old_verts] - within_face = {'adjacent_cell': adj_cell, 'vertices': new_verts} - if all([v >= 0 for v in new_verts]): - within_faces.append(within_face) - within_adj[new_verts[0]].append(new_verts[1]) - within_adj[new_verts[1]].append(new_verts[0]) - - # Compute cell area - within_loop = kp_loop([f['vertices'] for f in within_faces]) - within_area = _loop_area(within_pts, within_loop) - - new_cell_data = {'adjacency': within_adj, - 'faces': within_faces, - 'original': cell_data['original'], - 'vertices': within_pts, - 'volume': within_area} - - return new_cell_data + return _clip_cell_2d(cell_data, domain) w_str = 'Cannot clip cells to fit to a ' + domain_name + '.' - w_str = ' Currently 3D geometries are not supported, other than boxes.' + w_str += ' Currently 3D geometries are not supported, other than boxes.' warnings.warn(w_str, RuntimeWarning) return cell_data -def _segment_cross(pts, domain): - end_pts = np.copy(pts) - ds = np.inf - while ds > 1e-12: - within = domain.within(end_pts) +def _clip_cell_2d(cell_data, domain, n_samples=64, n_bnd_pts=64): + """Clip a convex 2D cell to a convex domain. + + The vertex loop of the cell is walked and the points inside the domain + are kept: the vertices within the domain and the points where the edges + cross the domain boundary (an edge with both ends outside the domain can + cross it twice). Consecutive kept points that lie on the same edge of the + cell are joined by that edge, every other gap is an arc of the domain + boundary and is closed by a boundary face (``'adjacent_cell': -1``). + If the domain lies entirely within the cell, the cell becomes a polygon + that approximates the domain boundary. + + Args: + cell_data (dict): A cell from pyvoro. + domain (from :mod:`microstructpy.geometry`): The 2D domain. + n_samples (int): Number of points sampled along an edge with both + ends outside the domain, to detect a double crossing. + n_bnd_pts (int): Number of points on the domain boundary, when the + domain is entirely within the cell. + + Returns: + dict or None: The clipped cell, or None if it is outside the domain. + + """ + pts = np.array(cell_data['vertices'], dtype='float') + faces = cell_data['faces'] + within = domain.within(pts) + if np.all(within): + return cell_data + + # order the vertices of the cell in a loop + loop = kp_loop([f['vertices'] for f in faces]) + n_kp = len(loop) + edge_faces = {frozenset(f['vertices']): f for f in faces} + + # points closer than this are considered coincident + tol = 1e-12 * max(np.max(np.abs(pts)), np.finfo(float).tiny) + + # walk the loop and collect the points inside the domain, and for each + # point the face that joins it to the next point (None: domain boundary) + kept_pts = [] + kept_faces = [] + + def add_point(pt, face): + if kept_pts and np.linalg.norm(pt - kept_pts[-1]) <= tol: + kept_faces[-1] = face + else: + kept_pts.append(pt) + kept_faces.append(face) + + for i in range(n_kp): + kp_a = loop[i] + kp_b = loop[(i + 1) % n_kp] + face = edge_faces[frozenset((kp_a, kp_b))] + pt_a = pts[kp_a] + pt_b = pts[kp_b] + if within[kp_a] and within[kp_b]: + add_point(pt_a, face) + elif within[kp_a]: + add_point(pt_a, face) + add_point(_segment_cross([pt_a, pt_b], domain), None) + elif within[kp_b]: + add_point(_segment_cross([pt_a, pt_b], domain), face) + else: + crossings = _segment_double_cross(pt_a, pt_b, domain, n_samples, + tol) + if crossings is not None: + add_point(crossings[0], face) + add_point(crossings[1], None) + + if len(kept_pts) > 1: + if np.linalg.norm(kept_pts[-1] - kept_pts[0]) <= tol: + kept_pts.pop() + kept_faces.pop() + + if len(kept_pts) == 0: + # the cell is either outside the domain, or contains the domain + if not _point_in_convex_loop(domain.center, pts[loop]): + return None + kept_pts = list(_domain_boundary(domain, n_bnd_pts)) + kept_faces = [None for _ in kept_pts] + + elif len(kept_pts) == 2: + # the domain crosses a single edge of the cell, so the clipped cell + # is bounded by that edge and an arc; the midpoint of the arc is added + # so that the cell has a non-zero area + n_faces = sum([f is not None for f in kept_faces]) + if n_faces != 1: + return None + if kept_faces[0] is None: + kept_pts.reverse() + kept_faces.reverse() + pt_a, pt_b = kept_pts + mid_pt = 0.5 * (pt_a + pt_b) + d_pt = pt_b - pt_a + n_vec = np.array([-d_pt[1], d_pt[0]]) + if np.dot(n_vec, pts.mean(axis=0) - mid_pt) < 0: + n_vec *= -1 + n_vec /= np.linalg.norm(n_vec) + far_pt = mid_pt + _domain_extent(domain) * n_vec + kept_pts.append(_segment_cross([mid_pt, far_pt], domain)) + kept_faces.append(None) + + n_new = len(kept_pts) + if n_new < 3: + return None + + new_pts = np.array(kept_pts) + new_faces = [] + for k, face in enumerate(kept_faces): + verts = [k, (k + 1) % n_new] + if face is None: + new_faces.append({'adjacent_cell': -1, 'vertices': verts}) + else: + new_faces.append({'adjacent_cell': face['adjacent_cell'], + 'vertices': verts}) + new_adj = [[(k - 1) % n_new, (k + 1) % n_new] for k in range(n_new)] + + new_cell_data = {'adjacency': new_adj, + 'faces': new_faces, + 'original': cell_data['original'], + 'vertices': new_pts.tolist(), + 'volume': _loop_area(new_pts, list(range(n_new)))} + return new_cell_data + + +def _segment_cross(pts, domain, n_iter=60): + """Find the point where a segment crosses the domain boundary. + + One end of the segment must be inside the domain and the other outside. + The crossing is found by bisection with a fixed number of iterations, + so the result is accurate to machine precision for any magnitude of the + coordinates. The result does not depend on the order of the end points. + + Args: + pts (list or numpy.ndarray): The two end points of the segment. + domain (from :mod:`microstructpy.geometry`): The domain. + n_iter (int): Number of bisection iterations. + + Returns: + numpy.ndarray: The crossing point. + + """ + end_pts = np.array(pts, dtype='float') + within = domain.within(end_pts) + for _ in range(n_iter): pt = end_pts.mean(axis=0) - pt_within = domain.within(pt) - if within[0] == pt_within: + if domain.within(pt) == within[0]: end_pts[0] = pt else: end_pts[1] = pt - dx = end_pts[1] - end_pts[0] - ds = np.linalg.norm(dx) - return pt + return end_pts.mean(axis=0) + + +def _segment_double_cross(pt_a, pt_b, domain, n_samples=64, tol=0): + """Find where a segment with both ends outside the domain crosses it. + + The segment is sampled to detect whether it passes through the (convex) + domain. The end points are put in a canonical order before sampling, so + that the two cells sharing an edge compute identical crossing points. + + Returns: + tuple or None: The crossing points nearest to ``pt_a`` and ``pt_b``, + or None if the segment does not cross the domain. + + """ + end_pts = np.array([pt_a, pt_b], dtype='float') + order = np.lexsort((end_pts[:, 1], end_pts[:, 0])) + pt_0 = end_pts[order[0]] + pt_1 = end_pts[order[1]] + + t = np.arange(1, n_samples + 1) / (n_samples + 1) + samples = pt_0 + t.reshape(-1, 1) * (pt_1 - pt_0) + inside = domain.within(samples) + if not np.any(inside): + return None + + pt_in = samples[np.argmax(inside)] + cross_0 = _segment_cross([pt_in, pt_0], domain) + cross_1 = _segment_cross([pt_in, pt_1], domain) + if np.linalg.norm(cross_1 - cross_0) <= tol: + return None # the segment only touches the domain + + crossings = [None, None] + crossings[order[0]] = cross_0 + crossings[order[1]] = cross_1 + return tuple(crossings) + + +def _domain_extent(domain): + """A distance that leaves the domain from any point within it.""" + return 2 * max([ub - lb for lb, ub in domain.limits]) + + +def _domain_boundary(domain, n_pts=64): + """Points on the boundary of a convex 2D domain, in loop order. + + The points are found along rays from the center of the domain. + """ + cen = np.array(domain.center, dtype='float') + ext = _domain_extent(domain) + t = np.linspace(0, 2 * np.pi, n_pts, endpoint=False) + dirs = np.column_stack((np.cos(t), np.sin(t))) + return np.array([_segment_cross([cen, cen + ext * u], domain) + for u in dirs]) + + +def _point_in_convex_loop(pt, loop_pts): + """Test whether a point is within a convex polygon.""" + pt = np.array(pt, dtype='float') + loop_pts = np.array(loop_pts, dtype='float') + d_edge = np.roll(loop_pts, -1, axis=0) - loop_pts + d_pt = pt - loop_pts + cross = d_edge[:, 0] * d_pt[:, 1] - d_edge[:, 1] * d_pt[:, 0] + return bool(np.all(cross >= 0) or np.all(cross <= 0)) def _loop_area(pts, loop): + """Area of the polygon with vertices ``pts[loop[0]]``, ``pts[loop[1]]``, + ... (shoelace formula).""" double_area = 0 n = len(loop) for i in range(n): ip1 = (i + 1) % n - xi = pts[i][0] - yi = pts[i][1] - xip1 = pts[ip1][0] - yip1 = pts[ip1][1] + xi = pts[loop[i]][0] + yi = pts[loop[i]][1] + xip1 = pts[loop[ip1]][0] + yip1 = pts[loop[ip1]][1] det = xi * yip1 - xip1 * yi double_area += det @@ -1330,3 +1499,15 @@ def _point_line_vec(pt, line_pts): u_vec = dist_vec / np.linalg.norm(dist_vec) return u_vec + + +def _displace_seed(seed, step): + """Translate a seed rigidly by ``step``. + + The position setter of the seed translates the geometry and the + breakdown along with the position. + """ + if isinstance(seed.breakdown, tuple): + seed.breakdown = [list(b) for b in seed.breakdown] + pos = np.array(seed.position, dtype='float') + seed.position = (pos + np.array(step, dtype='float')).tolist() diff --git a/tests/meshing/test_polymesh_fixes.py b/tests/meshing/test_polymesh_fixes.py new file mode 100644 index 00000000..ec93dd62 --- /dev/null +++ b/tests/meshing/test_polymesh_fixes.py @@ -0,0 +1,362 @@ +"""Tests for the PolyMesh bug fixes. + +Each test covers one of the fixes to :mod:`microstructpy.meshing.polymesh`: +the area of a vertex loop, the clipping of cells to non-rectangular 2D +domains, the short-edge optimization, the precision of the text file, +the poly file writer, the segment/boundary crossing at large coordinates, +3D plotting on a fresh figure, per-region numpy arrays in plot keyword +arguments, and the silence of the mesh comparison. +""" +from __future__ import division + +import copy +import os +import time + +import numpy as np +from matplotlib import path as mpath +from matplotlib import pyplot as plt + +from microstructpy import geometry +from microstructpy.meshing.polymesh import PolyMesh +from microstructpy.meshing.polymesh import _edge_lengths +from microstructpy.meshing.polymesh import _loop_area +from microstructpy.meshing.polymesh import _segment_cross +from microstructpy.meshing.polymesh import _shortest_edge +from microstructpy.meshing.polymesh import kp_loop +from microstructpy.seeding import Seed +from microstructpy.seeding import SeedList + + +# --------------------------------------------------------------------------- # +# Helpers # +# --------------------------------------------------------------------------- # +def _circle_seeds(positions, r=0.3): + """Circle seeds at the given positions (breakdowns translated).""" + seeds = SeedList([]) + for pos in positions: + seed = Seed.factory('circle', r=r) + seed.position = list(pos) + seeds.append(seed) + return seeds + + +def _random_seeds(domain, n_seeds, r=0.1, rng_seed=0): + seeds = SeedList([Seed.factory('circle', r=r) for _ in range(n_seeds)]) + seeds.position(domain, rng_seed=rng_seed) + return seeds + + +def _region_paths(pmesh): + pts = np.array(pmesh.points) + loops = [kp_loop([pmesh.facets[f] for f in r]) for r in pmesh.regions] + return [mpath.Path(pts[loop]) for loop in loops] + + +def _random_interior_points(domain, n_pts, frac=0.9, rng_seed=1): + """Random points within ``frac`` of the domain boundary.""" + rng = np.random.RandomState(rng_seed) + pts = [] + while len(pts) < n_pts: + pt = rng.uniform(-1, 1, 2) + if np.linalg.norm(pt) >= frac: + continue + if isinstance(domain, geometry.Ellipse): + pt = domain.matrix.dot(pt * np.array([domain.a, domain.b])) + else: + pt = pt * domain.r + pts.append(np.array(domain.center) + pt) + return pts + + +def _check_partition(pmesh, domain, n_seeds): + """Check that the mesh partitions the domain.""" + pts = np.array(pmesh.points) + vols = np.array(pmesh.volumes) + assert len(pmesh.regions) == n_seeds + assert set(pmesh.seed_numbers) == set(range(n_seeds)) + assert np.all(np.isfinite(pts)) + assert np.all(vols > 0) + assert abs(vols.sum() - domain.area) < 0.02 * domain.area + + # the stored volumes match the polygons + recomputed = PolyMesh(pmesh.points, pmesh.facets, pmesh.regions).volumes + assert np.allclose(vols, recomputed) + + # each interior point is in exactly one region + paths = _region_paths(pmesh) + for pt in _random_interior_points(domain, 200): + n_in = sum([path.contains_point(pt) for path in paths]) + assert n_in == 1 + + +def _min_edge_length(pmesh): + edge_lens = _edge_lengths(pmesh) + return edge_lens[_shortest_edge(edge_lens)]['length'] + + +# --------------------------------------------------------------------------- # +# 1. _loop_area # +# --------------------------------------------------------------------------- # +def test_loop_area_unordered_points(): + # unit square, vertices stored out of loop order + pts = [(0, 0), (1, 1), (1, 0), (0, 1)] + loop = [0, 2, 1, 3] + assert np.isclose(_loop_area(pts, loop), 1.0) + assert np.isclose(_loop_area(pts, loop[::-1]), 1.0) + + +# --------------------------------------------------------------------------- # +# 2. Clipping cells to circular and elliptical domains # +# --------------------------------------------------------------------------- # +def test_clip_cells_without_interior_vertices(): + # the top and bottom cells have no vertex within the circle + domain = geometry.Circle(r=1) + seeds = _circle_seeds([(0, 0.4), (0, -0.4), (0, 0)]) + pmesh = PolyMesh.from_seeds(seeds, domain) + + assert len(pmesh.regions) == 3 + assert sorted(pmesh.seed_numbers) == [0, 1, 2] + assert np.all(np.isfinite(np.array(pmesh.points))) + assert all([len(r) >= 3 for r in pmesh.regions]) + assert np.all(np.array(pmesh.volumes) > 0) + + # the boundary arcs are approximated by chords (one per gap in the + # cell), which under-estimates the area - significantly with only + # three cells + total_area = sum(pmesh.volumes) + assert 0.7 * np.pi < total_area < np.pi + + +def test_clip_domain_within_cell(): + # a single seed: the Voronoi cell is the bounding box of the circle + domain = geometry.Circle(r=2) + pmesh = PolyMesh.from_seeds(_circle_seeds([(0, 0)]), domain) + + assert len(pmesh.regions) == 1 + assert abs(pmesh.volumes[0] - 4 * np.pi) < 0.01 * 4 * np.pi + assert len(pmesh.regions[0]) >= 3 + assert np.allclose(np.linalg.norm(np.array(pmesh.points), axis=1), 2) + + +def test_clip_single_edge_crossing(): + # the cell of the second seed is a circular segment: the circle crosses + # a single edge of the cell twice + domain = geometry.Circle(r=1) + seeds = _circle_seeds([(0, 0), (0, -0.99)], r=0.3) + pmesh = PolyMesh.from_seeds(seeds, domain) + + assert len(pmesh.regions) == 2 + assert sorted(pmesh.seed_numbers) == [0, 1] + assert all([len(r) >= 3 for r in pmesh.regions]) + assert np.all(np.array(pmesh.volumes) > 0) + + +def test_clip_partition_circle(): + domain = geometry.Circle(r=1) + n_seeds = 60 + seeds = _random_seeds(domain, n_seeds) + pmesh = PolyMesh.from_seeds(seeds, domain) + _check_partition(pmesh, domain, n_seeds) + + +def test_clip_partition_ellipse(): + domain = geometry.Ellipse(a=1.5, b=1) + n_seeds = 100 + seeds = _random_seeds(domain, n_seeds) + pmesh = PolyMesh.from_seeds(seeds, domain) + _check_partition(pmesh, domain, n_seeds) + + +def test_clip_partition_rotated_ellipse(): + domain = geometry.Ellipse(a=1.5, b=1, angle=30, center=(0.3, -0.2)) + n_seeds = 100 + seeds = _random_seeds(domain, n_seeds) + pmesh = PolyMesh.from_seeds(seeds, domain) + _check_partition(pmesh, domain, n_seeds) + + +def test_rectangular_domain_unchanged(): + domain = geometry.Square(side_length=2) + seeds = _random_seeds(domain, 30) + pmesh = PolyMesh.from_seeds(seeds, domain) + + assert len(pmesh.regions) == 30 + assert abs(sum(pmesh.volumes) - domain.area) < 1e-10 + + +# --------------------------------------------------------------------------- # +# 3. Short edge optimization # +# --------------------------------------------------------------------------- # +def test_edge_opt(capsys): + np.random.seed(0) + domain = geometry.Square(side_length=2) + seeds = _random_seeds(domain, 12, r=0.15, rng_seed=3) + seeds_orig = copy.deepcopy(seeds) + + pmesh_0 = PolyMesh.from_seeds(seeds, domain) + min_len_0 = _min_edge_length(pmesh_0) + + capsys.readouterr() + pmesh = PolyMesh.from_seeds(seeds, domain, edge_opt=True, n_iter=10, + verbose=False) + captured = capsys.readouterr() + assert captured.out == '' + + # the minimum edge length does not decrease + assert _min_edge_length(pmesh) >= min_len_0 + + # the seeds are in the accepted state: re-tessellating them + # reproduces the returned mesh + pmesh_re = PolyMesh.from_seeds(seeds, domain) + assert len(pmesh_re.regions) == len(pmesh.regions) + assert np.allclose(np.sort(pmesh_re.volumes), np.sort(pmesh.volumes), + rtol=0, atol=1e-9) + + # the seeds were displaced rigidly + for seed in seeds: + assert np.allclose(seed.breakdown[0][:-1], seed.position) + assert np.allclose(seed.geometry.center, seed.position) + + # the seeds were displaced (the mesh changed) + n_moved = sum([not np.allclose(s.position, s0.position) + for s, s0 in zip(seeds, seeds_orig)]) + assert n_moved > 0 + assert n_moved <= 2 * 10 * 2 # at most 2 seeds per accepted trial + + +# --------------------------------------------------------------------------- # +# 4. Full precision text files # +# --------------------------------------------------------------------------- # +def test_write_read_full_precision(tmp_path): + domain = geometry.Square(side_length=1.7) + seeds = _random_seeds(domain, 15, r=0.1) + pmesh = PolyMesh.from_seeds(seeds, domain) + + filename = str(tmp_path / 'polymesh.txt') + pmesh.write(filename) + pmesh_rw = PolyMesh.from_file(filename) + + assert np.array_equal(np.array(pmesh.points, dtype='float'), + np.array(pmesh_rw.points, dtype='float')) + assert np.array_equal(np.array(pmesh.volumes, dtype='float'), + np.array(pmesh_rw.volumes, dtype='float')) + assert pmesh == pmesh_rw + + +# --------------------------------------------------------------------------- # +# 5. Poly file writer # +# --------------------------------------------------------------------------- # +def test_write_poly(tmp_path): + pts = [(0, 0), (1, 0), (1, 1), (0, 1), (1.5, 0)] + facets = [(0, 1), (1, 2), (2, 3), (3, 0), (1, 4), (4, 2)] + regions = [(0, 1, 2, 3), (4, 5, 1)] + pmesh = PolyMesh(pts, facets, regions) + + filename = str(tmp_path / 'polymesh.poly') + pmesh.write(filename, format='poly') + + assert os.path.exists(filename) + with open(filename, 'r') as file: + lines = [ln for ln in file.read().split('\n') if ln.strip()] + data_lines = [ln for ln in lines if not ln.startswith('#')] + assert int(data_lines[0].split()[0]) == len(pts) + + +# --------------------------------------------------------------------------- # +# 6. Segment crossing at large coordinates # +# --------------------------------------------------------------------------- # +def test_segment_cross_large_coordinates(): + center = np.array([1e5, 1e5]) + domain = geometry.Circle(center=center, r=1) + pts = [center, center + np.array([2, 0])] + + t_start = time.time() + crossing = _segment_cross(pts, domain) + elapsed = time.time() - t_start + + expected = center + np.array([1, 0]) + assert np.linalg.norm(crossing - expected) < 1e-9 * np.linalg.norm( + expected) + assert elapsed < 1 + + # the result does not depend on the order of the end points + assert np.array_equal(_segment_cross(pts[::-1], domain), crossing) + + +# --------------------------------------------------------------------------- # +# 7. 3D plot on a fresh figure # +# --------------------------------------------------------------------------- # +def test_plot_3d_fresh_figure(): + domain = geometry.Cube(side_length=1) + positions = [(-0.2, -0.2, -0.2), (0.2, 0.2, 0.2), (0.2, -0.2, 0.1)] + seeds = SeedList([Seed.factory('sphere', r=0.25, position=p) + for p in positions]) + pmesh = PolyMesh.from_seeds(seeds, domain) + plt.close('all') + plt.figure() + try: + pmesh.plot() + assert plt.gca().name == '3d' + finally: + plt.close('all') + + +# --------------------------------------------------------------------------- # +# 8. Per-region numpy arrays in plot keyword arguments # +# --------------------------------------------------------------------------- # +def test_plot_facecolors_array(): + domain = geometry.Square(side_length=2) + seeds = _random_seeds(domain, 8, r=0.15) + phases = [0, 0, 0, 1, 1, 1, 1, 1] + for seed, phase in zip(seeds, phases): + seed.phase = phase + pmesh = PolyMesh.from_seeds(seeds, domain) + n_regions = len(pmesh.regions) + + # one colour per region, indexed by seed + colors = np.array([plt.cm.viridis(i / n_regions) + for i in range(n_regions)]) + plt.close('all') + plt.figure() + try: + pmesh.plot(facecolors=colors) + collection = plt.gca().collections[-1] + assert len(collection.get_facecolors()) == n_regions + assert np.allclose(collection.get_facecolors(), + colors[pmesh.seed_numbers]) + finally: + plt.close('all') + + # one colour per material phase + colors = np.array([[1, 0, 0, 1], [0, 0, 1, 1]], dtype='float') + plt.figure() + try: + pmesh.plot(index_by='material', facecolors=colors) + collection = plt.gca().collections[-1] + assert len(collection.get_facecolors()) == n_regions + assert np.allclose(collection.get_facecolors(), + colors[pmesh.phase_numbers]) + finally: + plt.close('all') + + +# --------------------------------------------------------------------------- # +# 9. Mesh comparison is silent # +# --------------------------------------------------------------------------- # +def test_eq_silent(capsys): + pts = [(0, 0), (1, 0), (1, 1), (0, 1), (1.5, 0)] + facets = [(0, 1), (1, 2), (2, 3), (3, 0), (1, 4), (4, 2)] + regions = [(0, 1, 2, 3), (4, 5, 1)] + pmesh = PolyMesh(pts, facets, regions, [0, 1], [2, 2]) + pmesh_2 = PolyMesh(pts, facets, [(0, 3, 2, 1), (4, 1, 5)], [0, 1], + [2, 2]) + pmesh_3 = PolyMesh(pts, facets, regions, [0, 1], [2, 3]) + + capsys.readouterr() + assert pmesh == pmesh_2 + assert pmesh != pmesh_3 + assert pmesh != pts + captured = capsys.readouterr() + assert captured.out == '' + assert captured.err == '' From b49bb90d7b9ebbc1cf2fffe57905e4027f419547 Mon Sep 17 00:00:00 2001 From: Julian Rimoli Date: Tue, 22 Sep 2026 20:02:12 -0700 Subject: [PATCH 04/42] Fix TriMesh/RasterMesh bugs: mesh size limits, raster meshes, writers - 3D: honour mesh_max_volume and per-phase max_volume (TetGen never saw them); 2D: per-phase values are not capped by the global one and an infinite global value is not passed as the mis-parsed switch 'ainf' - RasterMesh: 'ij' grid indexing (elements were clockwise / inverted), facets derived from cell differences, correct Abaqus face ids, vtk output (also with voids), 3D plot - tet/tri writer: valid .ele/.edge/.face files - Abaqus: exterior surface unions only reference defined surfaces; polymesh optional - Full-precision points in text files; no dangling headers; clear errors for unknown meshers and non-simplex elements; gmsh path with default phases and without unused nodes; _sort_facets reports disjoint loops Co-Authored-By: Claude Fable 5.1 --- src/microstructpy/meshing/trimesh.py | 1194 ++++++++++++++------------ tests/meshing/test_trimesh_fixes.py | 679 +++++++++++++++ 2 files changed, 1330 insertions(+), 543 deletions(-) create mode 100644 tests/meshing/test_trimesh_fixes.py diff --git a/src/microstructpy/meshing/trimesh.py b/src/microstructpy/meshing/trimesh.py index c63e7bc5..2cad17d1 100644 --- a/src/microstructpy/meshing/trimesh.py +++ b/src/microstructpy/meshing/trimesh.py @@ -198,12 +198,16 @@ def from_polymesh(cls, polymesh, phases=None, mesher='Triangle/Tetgen', this control. """ - key = mesher.lower().strip() + key = str(mesher).lower().strip() if key in ('triangle/tetgen', 'triangle', 'tetgen'): tri_args = _call_meshpy(polymesh, phases, min_angle, max_volume, max_edge_length) elif key == 'gmsh': tri_args = _call_gmsh(polymesh, phases, mesh_size, max_edge_length) + else: + e_str = 'Unknown mesher ' + repr(mesher) + '. Options are ' + e_str += "'Triangle/TetGen', 'Triangle', 'TetGen', and 'gmsh'." + raise ValueError(e_str) return cls(*tri_args) @@ -212,39 +216,36 @@ def from_polymesh(cls, polymesh, phases=None, mesher='Triangle/Tetgen', # ----------------------------------------------------------------------- # def __str__(self): nv = len(self.points) - nd = len(self.points[0]) - pt_fmt = '\t' - pt_fmt += ', '.join(['{pt[' + str(i) + ']: e}' for i in range(nd)]) + # Points are written with the shortest representation that + # round-trips exactly (repr of a float), so that a mesh read back + # from the file is identical to the one written. str_str = 'Mesh Points: ' + str(nv) + '\n' - str_str += ''.join([pt_fmt.format(pt=p) + '\n' for p in self.points]) + str_str += ''.join(['\t' + ', '.join([repr(float(x)) for x in p]) + + '\n' for p in self.points]) str_str += 'Mesh Elements: ' + str(len(self.elements)) + '\n' - str_str += '\n'.join(['\t' + str(tuple(e))[1:-1] for e in - self.elements]) + str_str += '\n'.join(['\t' + ', '.join([str(int(kp)) for kp in e]) + for e in self.elements]) - try: + # Optional attributes and facets: only write the sections that exist, + # so that the file never contains a dangling header. + if self.element_attributes is not None: str_str += '\nElement Attributes: ' str_str += str(len(self.element_attributes)) + '\n' str_str += '\n'.join(['\t' + str(a) for a in self.element_attributes]) - except TypeError: - pass - try: + if self.facets is not None: str_str += '\nFacets: ' + str(len(self.facets)) + '\n' - str_str += '\n'.join(['\t' + str(tuple(f))[1:-1] for f in - self.facets]) - except TypeError: - pass + str_str += '\n'.join(['\t' + ', '.join([str(int(kp)) for kp in + f]) for f in self.facets]) - try: + if self.facet_attributes is not None: str_str += '\nFacet Attributes: ' str_str += str(len(self.facet_attributes)) + '\n' str_str += '\n'.join(['\t' + str(a) for a in self.facet_attributes]) - except TypeError: - pass return str_str @@ -284,6 +285,18 @@ def write(self, filename, format='txt', seeds=None, polymesh=None): """ # NOQA: E501 fmt = format.lower() + if fmt in ('abaqus', 'tet/tri', 'vtk'): + # These formats infer the element type from the number of nodes + # per element, so make sure the elements are simplices. + n_dim = len(self.points[0]) + n_kp = len(self.elements[0]) + if n_kp != n_dim + 1: + e_str = 'TriMesh elements must be triangles/tetrahedra with ' + e_str += str(n_dim + 1) + ' nodes each to write the ' + e_str += repr(format) + ' format, but the elements have ' + e_str += str(n_kp) + ' nodes.' + raise ValueError(e_str) + if fmt == 'abaqus': # write top matter abaqus = '*Heading\n' @@ -310,7 +323,10 @@ def write(self, filename, format='txt', seeds=None, polymesh=None): # Element sets - seed number elset_n_per = 16 - elem_atts = np.array(self.element_attributes) + if self.element_attributes is None: + elem_atts = np.array([]) + else: + elem_atts = np.array(self.element_attributes) for att in np.unique(elem_atts): elset_name = 'Set-E-Seed-' + str(att) elset_str = '*Elset, elset=' + elset_name + '\n' @@ -350,42 +366,37 @@ def write(self, filename, format='txt', seeds=None, polymesh=None): abaqus += elset_str # Surfaces - Exterior and Interior - facets = np.array(self.facets) - facet_atts = np.array(self.facet_attributes) - - face_ids = {2: [2, 3, 1], 3: [3, 4, 2, 1]}[n_dim] - - for att in np.unique(facet_atts): - facet_name = 'Surface-' + str(att) - surf_str = '*Surface, name=' + facet_name + ', type=element\n' - - att_facets = facets[facet_atts == att] - for facet in att_facets: - mask = np.isin(self.elements, facet) - n_match = mask.astype('int').sum(axis=1) - i_elem = np.argmax(n_match) - elem_id = i_elem + 1 - - i_missing = np.argmin(mask[i_elem]) - face_id = face_ids[i_missing] - - surf_str += str(elem_id) + ', S' + str(face_id) + '\n' - - abaqus += surf_str - - # Surfaces - Exterior - poly_neighbors = np.array(polymesh.facet_neighbors) - poly_mask = np.any(poly_neighbors < 0, axis=1) - neigh_nums = np.min(poly_neighbors, axis=1) - u_neighs = np.unique(neigh_nums[poly_mask]) - for neigh_num in u_neighs: - mask = neigh_nums == neigh_num - facet_name = 'Ext-Surface-' + str(-neigh_num) - surf_str = '*Surface, name=' + facet_name + ', combine=union\n' - for i, flag in enumerate(mask): - if flag: - surf_str += 'Surface-' + str(i) + '\n' - abaqus += surf_str + defined_surfs = set() + has_facets = self.facets is not None and len(self.facets) > 0 + if has_facets and self.facet_attributes is not None: + facets = np.array(self.facets) + facet_atts = np.array(self.facet_attributes) + + face_ids = {2: [2, 3, 1], 3: [3, 4, 2, 1]}[n_dim] + + for att in np.unique(facet_atts): + facet_name = 'Surface-' + str(att) + surf_str = '*Surface, name=' + facet_name + surf_str += ', type=element\n' + + att_facets = facets[facet_atts == att] + for facet in att_facets: + mask = np.isin(self.elements, facet) + n_match = mask.astype('int').sum(axis=1) + i_elem = np.argmax(n_match) + elem_id = i_elem + 1 + + i_missing = np.argmin(mask[i_elem]) + face_id = face_ids[i_missing] + + surf_str += str(elem_id) + ', S' + str(face_id) + '\n' + + abaqus += surf_str + defined_surfs.add(int(att)) + + # Surfaces - Exterior (unions of the surfaces on each domain face) + if polymesh is not None: + abaqus += _abaqus_exterior_unions(polymesh, defined_surfs) # End Part abaqus += '*End Part\n\n' @@ -413,29 +424,24 @@ def write(self, filename, format='txt', seeds=None, polymesh=None): file.write(str(self) + '\n') elif fmt == 'tet/tri': - # create boundary markers - bnd_mkrs = np.full(len(self.points), 0, dtype='int') - - facet_arr = np.array(self.facets) - f_bnd_mkrs = np.full(len(self.facets), 0, dtype='int') + n_pts, n_dim = np.array(self.points).shape elem_arr = np.array(self.elements) - for elem in self.elements: - for i in range(len(elem)): - e_facet = np.delete(elem, i) - f_mask = np.full(elem_arr.shape[0], True) - for kp in e_facet: - f_mask &= np.any(elem_arr == kp, axis=-1) + n_ele, n_kp = elem_arr.shape - if np.sum(f_mask) == 1: - bnd_mkrs[e_facet] = 1 + # Boundary markers: the faces of the elements that belong to a + # single element are on the boundary of the mesh. + face_counts = {} + for elem in elem_arr: + for i in range(n_kp): + key = tuple(sorted(np.delete(elem, i).tolist())) + face_counts[key] = face_counts.get(key, 0) + 1 - f_mask = np.full(facet_arr.shape[0], True) - for kp in e_facet: - f_mask &= np.any(facet_arr == kp, axis=-1) - f_bnd_mkrs[f_mask] = 1 + bnd_mkrs = np.full(n_pts, 0, dtype='int') + for key, count in face_counts.items(): + if count == 1: + bnd_mkrs[list(key)] = 1 # write vertices - n_pts, n_dim = np.array(self.points).shape nodes = ' '.join([str(n) for n in (n_pts, n_dim, 0, 1)]) + '\n' nodes += ''.join([str(i) + ''.join([' ' + str(x) for x in pt]) + ' ' + str(bnd_mkrs[i]) + '\n' for i, pt in @@ -445,12 +451,12 @@ def write(self, filename, format='txt', seeds=None, polymesh=None): file.write(nodes) # write elements - n_ele, n_kp = np.array(self.elements).shape is_att = self.element_attributes is not None n_att = int(is_att) eles = ' '.join([str(n) for n in (n_ele, n_kp, n_att)]) + '\n' for i, simplex in enumerate(self.elements): - e_str = ' '.join([str(kp) for kp in simplex]) + e_str = str(i) + ''.join([' ' + str(int(kp)) for kp in + simplex]) if is_att: e_str += ' ' + str(self.element_attributes[i]) e_str += '\n' @@ -460,14 +466,18 @@ def write(self, filename, format='txt', seeds=None, polymesh=None): file.write(eles) # Write edges/faces - if self.facets is not None: + # Format: '<# of edges/faces> <# of boundary markers (0 or 1)>' + # followed by ' [] ' lines. + if self.facets is not None and len(self.facets) > 0: ext = {2: '.edge', 3: '.face'}[n_dim] - n_facet, n_kp = np.array(self.facets).shape - edge = ' '.join([str(n) for n in (n_facet, n_kp, 1)]) - edge += ''.join([str(i) + ''.join([' ' + str(k) for k in f]) + - ' ' + str(mkr) + '\n' for f, mkr in - zip(self.facets, f_bnd_mkrs)]) + n_facet = len(self.facets) + edge = str(n_facet) + ' 1\n' + for i, facet in enumerate(self.facets): + key = tuple(sorted([int(kp) for kp in facet])) + mkr = int(face_counts.get(key, 0) == 1) + edge += str(i) + ''.join([' ' + str(k) for k in facet]) + edge += ' ' + str(mkr) + '\n' with open(filename + ext, 'w') as file: file.write(edge) @@ -503,24 +513,26 @@ def write(self, filename, format='txt', seeds=None, polymesh=None): vtk += ''.join(n_elem * [cell_type + '\n']) # write element attributes - try: - int(self.element_attributes[0]) - att_type = 'int' - except TypeError: - att_type = 'float' - - vtk += '\nCELL_DATA ' + str(n_elem) + '\n' - vtk += 'SCALARS element_attributes ' + att_type + ' 1 \n' - vtk += 'LOOKUP_TABLE element_attributes\n' - vtk += ''.join([str(a) + '\n' for a in self.element_attributes]) - - # Write phase numbers - if seeds is not None: - vtk += '\nSCALARS phase_numbers int 1 \n' - vtk += 'LOOKUP_TABLE phase_numbers\n' - vtk += ''.join([str(seeds[a].phase) + '\n' for a in + if self.element_attributes is not None: + try: + int(self.element_attributes[0]) + att_type = 'int' + except TypeError: + att_type = 'float' + + vtk += '\nCELL_DATA ' + str(n_elem) + '\n' + vtk += 'SCALARS element_attributes ' + att_type + ' 1 \n' + vtk += 'LOOKUP_TABLE element_attributes\n' + vtk += ''.join([str(a) + '\n' for a in self.element_attributes]) + # Write phase numbers + if seeds is not None: + vtk += '\nSCALARS phase_numbers int 1 \n' + vtk += 'LOOKUP_TABLE phase_numbers\n' + vtk += ''.join([str(seeds[a].phase) + '\n' for a in + self.element_attributes]) + with open(filename, 'w') as file: file.write(vtk) @@ -589,7 +601,7 @@ def plot(self, index_by='element', material=[], loc=0, **kwargs): plt_kwargs = {} for key, value in kwargs.items(): - if type(value) in (list, np.array): + if isinstance(value, (list, np.ndarray)): plt_value = [] for f_num, f_att in enumerate(self.facet_attributes): if index_by == 'element': @@ -614,12 +626,12 @@ def plot(self, index_by='element', material=[], loc=0, **kwargs): if material and index_by == 'attribute': p_kwargs = [{'label': m} for m in material] for key, value in kwargs.items(): - if type(value) not in (list, np.array): + if not isinstance(value, (list, np.ndarray)): for kws in p_kwargs: kws[key] = value for i, m in enumerate(material): - if type(value) in (list, np.array): + if isinstance(value, (list, np.ndarray)): p_kwargs[i][key] = value[i] else: p_kwargs[i][key] = value @@ -652,7 +664,7 @@ def plot(self, index_by='element', material=[], loc=0, **kwargs): # --------------------------------------------------------------------------- # # # -# RasterMesh Class # +# RasterMesh Class # # # # --------------------------------------------------------------------------- # class RasterMesh(TriMesh): @@ -734,264 +746,100 @@ def from_polymesh(cls, polymesh, mesh_size, phases=None): options for each phase. Default is ``{'material_type': 'solid', 'max_volume': float('inf')}``. - """ + if phases is None: + phases = _default_phases(polymesh) + # 1. Create node and element grids p_pts = np.array(polymesh.points) mins = p_pts.min(axis=0) maxs = p_pts.max(axis=0) - lens = (maxs - mins)*(1 + 1e-9) + lens = (maxs - mins) * (1 + 1e-9) sides = [lb + np.arange(0, dlen, mesh_size) for lb, dlen in zip(mins, lens)] - mgrid = np.meshgrid(*sides) + + n_dim = len(mins) + if n_dim not in _RASTER_CORNERS: + e_str = 'Cannot create a raster mesh in ' + str(n_dim) + 'D.' + raise NotImplementedError(e_str) + + # 'ij' indexing: node_nums[i, j(, k)] is the node at + # (sides[0][i], sides[1][j](, sides[2][k])) + mgrid = np.meshgrid(*sides, indexing='ij') nodes = np.array([g.flatten() for g in mgrid]).T node_nums = np.arange(mgrid[0].size).reshape(mgrid[0].shape) - - n_dim = len(mins) - if n_dim == 2: - m, n = node_nums.shape - kp1 = node_nums[:(m-1), :(n-1)].flatten() - kp2 = node_nums[1:m, :(n-1)].flatten() - kp3 = node_nums[1:m, 1:n].flatten() - kp4 = node_nums[:(m-1), 1:n].flatten() - elems = np.array([kp1, kp2, kp3, kp4]).T - elif n_dim == 3: - m, n, p = node_nums.shape - kp1 = node_nums[:(m-1), :(n-1), :(p-1)].flatten() - kp2 = node_nums[1:m, :(n-1), :(p-1)].flatten() - kp3 = node_nums[1:m, 1:n, :(p-1)].flatten() - kp4 = node_nums[:(m-1), 1:n, :(p-1)].flatten() - kp5 = node_nums[:(m-1), :(n-1), 1:p].flatten() - kp6 = node_nums[1:m, :(n-1), 1:p].flatten() - kp7 = node_nums[1:m, 1:n, 1:p].flatten() - kp8 = node_nums[:(m-1), 1:n, 1:p].flatten() - elems = np.array([kp1, kp2, kp3, kp4, kp5, kp6, kp7, kp8]).T - else: - raise NotImplementedError + # Elements are counter-clockwise (2D) / right-handed with nodes 1-4 + # on the bottom face and 5-8 on the top face (3D). + pix_shape = tuple([n - 1 for n in node_nums.shape]) + kp_cols = [] + for offset in _RASTER_CORNERS[n_dim]: + slices = [slice(o, o + n) for o, n in zip(offset, pix_shape)] + kp_cols.append(node_nums[tuple(slices)].flatten()) + elems = np.array(kp_cols).T + n_elems = elems.shape[0] + elem_grid = np.arange(n_elems).reshape(pix_shape) # 2. Compute element centers cens = nodes[elems[:, 0]] + 0.5 * mesh_size - # 3. For each region: - i_remain = np.arange(cens.shape[0]) - elem_regs = np.full(cens.shape[0], -1) - elem_atts = np.full(cens.shape[0], -1) - for r_num, region in enumerate(polymesh.regions): - # A. Create a bounding box - r_kps = np.unique([k for f in region for k in polymesh.facets[f]]) - r_pts = p_pts[r_kps] - r_mins = r_pts.min(axis=0) - r_maxs = r_pts.max(axis=0) - - # B. Isolate element centers with box - r_i_remain = np.copy(i_remain) - for i, lb in enumerate(r_mins): - ub = r_maxs[i] - x = cens[r_i_remain, i] - in_range = (x >= lb) & (x <= ub) - r_i_remain = r_i_remain[in_range] - - # C. For each facet, remove centers on the wrong side - # note: regions are convex, so mean pt is on correct side of facets - r_cen = r_pts.mean(axis=0) - for f in region: - f_kps = polymesh.facets[f] - f_pts = p_pts[f_kps] - u_in, f_cen = _facet_in_normal(f_pts, r_cen) - - rel_pos = cens[r_i_remain] - f_cen - dp = rel_pos.dot(u_in) - inside = dp >= 0 - r_i_remain = r_i_remain[inside] - - # D. Assign remaining centers to region + # 3. For each region: assign the pixels/voxels with centers inside + cell_geom = _CellGeometry(polymesh, p_pts) + i_remain = np.arange(n_elems) + elem_regs = np.full(n_elems, -1) + seed_nums = np.full(n_elems, -1) + for r_num in range(len(polymesh.regions)): + # A. Isolate element centers with the bounding box of the cell + r_mins, r_maxs = cell_geom.limits(r_num) + r_cens = cens[i_remain] + in_box = np.all((r_cens >= r_mins) & (r_cens <= r_maxs), axis=1) + r_i_remain = i_remain[in_box] + + # B. Remove centers on the wrong side of the facets + # note: regions are convex, so mean pt is on correct side + _, normals, centers = cell_geom.facets(r_num) + rel_pos = cens[r_i_remain][:, np.newaxis, :] - centers + dp = np.einsum('efd,fd->ef', rel_pos, normals) + r_i_remain = r_i_remain[np.all(dp >= 0, axis=1)] + + # C. Assign remaining centers to region elem_regs[r_i_remain] = r_num - elem_atts[r_i_remain] = polymesh.seed_numbers[r_num] + seed_nums[r_i_remain] = polymesh.seed_numbers[r_num] i_remain = np.setdiff1d(i_remain, r_i_remain) # 4. Combine regions of the same seed number - if phases is not None: - conv_dict = _amorphous_seed_numbers(polymesh, phases) - elem_atts = np.array([conv_dict.get(s, s) for s in elem_atts]) - - # 5. Define remaining facets, inherit their attributes - facets = [] - facet_atts = [] - for f_num, f_neighs in enumerate(polymesh.facet_neighbors): - n1, n2 = f_neighs - if n1 >= 0: - e1 = elems[elem_regs == n1] - e2 = elems[elem_regs == n2] - - # Shift +x - e1_s = e1[:, 1] - e2_s = e2[:, 0] - mask = np.isin(e1_s, e2_s) - for elem in e1[mask]: - if n_dim == 2: - facet = elem[[1, 2]] - else: - facet = elem[[1, 2, 6, 5]] - facets.append(facet) - facet_atts.append(f_num) - - # Shift -x - e1_s = e1[:, 0] - e2_s = e2[:, 1] - mask = np.isin(e1_s, e2_s) - for elem in e1[mask]: - if n_dim == 2: - facet = elem[[3, 0]] - else: - facet = elem[[0, 4, 7, 3]] - facets.append(facet) - facet_atts.append(f_num) - - # Shift +y - e1_s = e1[:, 3] - e2_s = e2[:, 0] - mask = np.isin(e1_s, e2_s) - for elem in e1[mask]: - if n_dim == 2: - facet = elem[[2, 3]] - else: - facet = elem[[2, 3, 7, 6]] - facets.append(facet) - facet_atts.append(f_num) - - # Shift -y - e1_s = e1[:, 0] - e2_s = e2[:, 3] - mask = np.isin(e1_s, e2_s) - for elem in e1[mask]: - if n_dim == 2: - facet = elem[[0, 1]] - else: - facet = elem[[0, 1, 5, 4]] - facets.append(facet) - facet_atts.append(f_num) - - if n_dim < 3: - continue - - # Shift +z - e1_s = e1[:, 4] - e2_s = e1[:, 0] - mask = np.isin(e1_s, e2_s) - for elem in e1[mask]: - facet = elem[[4, 5, 6, 7]] - facets.append(facet) - facet_atts.append(f_num) - - # Shift -z - e1_s = e1[:, 0] - e2_s = e1[:, 4] - mask = np.isin(e1_s, e2_s) - for elem in e1[mask]: - facet = elem[[0, 1, 2, 3]] - facets.append(facet) - facet_atts.append(f_num) - - elif n1 == -1: - # -x face - e2 = elems[elem_regs == n2] - x2 = nodes[e2[:, 0], 0] - mask = np.isclose(x2, mins[0]) - for elem in e2[mask]: - if n_dim == 2: - facet = elem[[3, 0]] - else: - facet = elem[[0, 4, 7, 3]] - facets.append(facet) - facet_atts.append(f_num) - - elif n1 == -2: - # +x face - e2 = elems[elem_regs == n2] - x2 = nodes[e2[:, 1], 0] - mask = np.isclose(x2, maxs[0]) - for elem in e2[mask]: - if n_dim == 2: - facet = elem[[1, 2]] - else: - facet = elem[[1, 2, 6, 5]] - facets.append(facet) - facet_atts.append(f_num) - - elif n1 == -3: - # -y face - e2 = elems[elem_regs == n2] - x2 = nodes[e2[:, 0], 1] - mask = np.isclose(x2, mins[1]) - for elem in e2[mask]: - if n_dim == 2: - facet = elem[[0, 1]] - else: - facet = elem[[0, 1, 5, 4]] - facets.append(facet) - facet_atts.append(f_num) - - elif n1 == -4: - # +y face - e2 = elems[elem_regs == n2] - x2 = nodes[e2[:, 2], 1] - mask = np.isclose(x2, maxs[1]) - for elem in e2[mask]: - if n_dim == 2: - facet = elem[[2, 3]] - else: - facet = elem[[2, 3, 7, 6]] - facets.append(facet) - facet_atts.append(f_num) - - elif n1 == -5: - # -z face - e2 = elems[elem_regs == n2] - x2 = nodes[e2[:, 0], 2] - mask = np.isclose(x2, mins[2]) - for elem in e2[mask]: - facet = elem[[0, 1, 2, 3]] - facets.append(facet) - facet_atts.append(f_num) - - elif n1 == -6: - # +z face - e2 = elems[elem_regs == n2] - x2 = nodes[e2[:, 4], 2] - mask = x2 == maxs[2] - for elem in e2[mask]: - facet = elem[[4, 5, 6, 7]] - facets.append(facet) - facet_atts.append(f_num) - - # 6. Remove voids and excess cells - if phases is not None: - att_rm = [-1] - for i, phase in enumerate(phases): - if phase.get('material_type', 'solid') in _misc.kw_void: - r_mask = np.array(polymesh.phase_numbers) == i - seeds = np.unique(np.array(polymesh.seed_numbers)[r_mask]) - att_rm.extend(list(seeds)) - - # Remove elements - rm_mask = np.isin(elem_atts, att_rm) - elems = elems[~rm_mask] - elem_atts = elem_atts[~rm_mask] - - # Re-number nodes - nodes_mask = np.isin(np.arange(nodes.shape[0]), elems) - n_remain = np.sum(nodes_mask) - node_n_conv = np.arange(nodes.shape[0]) - node_n_conv[nodes_mask] = np.arange(n_remain) - - nodes = nodes[nodes_mask] - elems = node_n_conv[elems] - if len(facets) > 0: - f_keep = np.all(nodes_mask[facets], axis=1) - facets = node_n_conv[np.array(facets)[f_keep, :]] - facet_atts = np.array(facet_atts)[f_keep] + conv_dict = _amorphous_seed_numbers(polymesh, phases) + elem_atts = np.array([conv_dict.get(s, s) for s in seed_nums]) + + # 5. Elements to keep: inside a cell of the polymesh and not void + void_seeds = [] + for seed_num, phase_num in zip(polymesh.seed_numbers, + polymesh.phase_numbers): + mat_type = phases[phase_num].get('material_type', 'solid') + if mat_type in _misc.kw_void: + void_seeds.append(seed_num) + keep = (elem_regs >= 0) & ~np.isin(seed_nums, void_seeds) + + # 6. Facets: faces between pixels of different cells, and faces on + # the boundary of the domain, with the polymesh facet number as + # the attribute + facets, facet_atts = _raster_facets(polymesh, phases, cell_geom, + elems, elem_grid, elem_regs, + keep, cens, mesh_size) + + # 7. Remove voids and excess cells, re-number nodes + elems = elems[keep] + elem_atts = elem_atts[keep] + + nodes_mask = np.full(nodes.shape[0], False) + nodes_mask[elems] = True + node_n_conv = np.full(nodes.shape[0], -1) + node_n_conv[nodes_mask] = np.arange(np.sum(nodes_mask)) + + nodes = nodes[nodes_mask] + elems = node_n_conv[elems] + facets = node_n_conv[facets] return cls(nodes, elems, elem_atts, facets, facet_atts) @@ -1057,13 +905,16 @@ def write(self, filename, format='txt', seeds=None, polymesh=None): elem_type = {2: 'CPS4', 3: 'C3D8'}[n_dim] abaqus += '*Element, type=' + elem_type + '\n' - abaqus += ''.join([str(i + 1) + ''.join([', ' + str(kp + 1) for kp - in elem]) + '\n' for - i, elem in enumerate(self.elements)]) + abaqus += ''.join([str(i + 1) + ''.join([', ' + str(int(kp) + 1) + for kp in elem]) + '\n' + for i, elem in enumerate(self.elements)]) # Element sets - seed number elset_n_per = 16 - elem_atts = np.array(self.element_attributes) + if self.element_attributes is None: + elem_atts = np.array([]) + else: + elem_atts = np.array(self.element_attributes) for att in np.unique(elem_atts): elset_name = 'Set-E-Seed-' + str(att) elset_str = '*Elset, elset=' + elset_name + '\n' @@ -1103,42 +954,40 @@ def write(self, filename, format='txt', seeds=None, polymesh=None): abaqus += elset_str # Surfaces - Exterior and Interior - facets = np.array(self.facets) - facet_atts = np.array(self.facet_attributes) - - face_ids = {2: [2, 3, 1], 3: [3, 4, 2, 1]}[n_dim] - - for att in np.unique(facet_atts): - facet_name = 'Surface-' + str(att) - surf_str = '*Surface, name=' + facet_name + ', type=element\n' - - att_facets = facets[facet_atts == att] - for facet in att_facets: - mask = np.isin(self.elements, facet) - n_match = mask.astype('int').sum(axis=1) - i_elem = np.argmax(n_match) - elem_id = i_elem + 1 - - i_missing = np.argmin(mask[i_elem]) - face_id = face_ids[i_missing] - - surf_str += str(elem_id) + ', S' + str(face_id) + '\n' - - abaqus += surf_str - - # Surfaces - Exterior - poly_neighbors = np.array(polymesh.facet_neighbors) - poly_mask = np.any(poly_neighbors < 0, axis=1) - neigh_nums = np.min(poly_neighbors, axis=1) - u_neighs = np.unique(neigh_nums[poly_mask]) - for neigh_num in u_neighs: - mask = neigh_nums == neigh_num - facet_name = 'Ext-Surface-' + str(-neigh_num) - surf_str = '*Surface, name=' + facet_name + ', combine=union\n' - for i, flag in enumerate(mask): - if flag: - surf_str += 'Surface-' + str(i) + '\n' - abaqus += surf_str + # Each facet is the face of a pixel/voxel. The Abaqus face id + # (S1, S2, ...) is found from the local node numbers of the face. + defined_surfs = set() + has_facets = self.facets is not None and len(self.facets) > 0 + if has_facets and self.facet_attributes is not None: + elem_faces = {} + face_ids = _ABAQUS_FACE_IDS[n_dim] + for i, elem in enumerate(self.elements): + for face, local_kps in _RASTER_FACES[n_dim].items(): + key = tuple(sorted([int(elem[k]) for k in local_kps])) + elem_faces.setdefault(key, (i + 1, face_ids[face])) + + facets = np.array(self.facets) + facet_atts = np.array(self.facet_attributes) + for att in np.unique(facet_atts): + facet_name = 'Surface-' + str(att) + surf_str = '*Surface, name=' + facet_name + surf_str += ', type=element\n' + + for facet in facets[facet_atts == att]: + key = tuple(sorted([int(kp) for kp in facet])) + if key not in elem_faces: + e_str = 'Facet ' + str(list(key)) + e_str += ' is not a face of any element.' + raise ValueError(e_str) + elem_id, face_id = elem_faces[key] + surf_str += str(elem_id) + ', S' + str(face_id) + '\n' + + abaqus += surf_str + defined_surfs.add(int(att)) + + # Surfaces - Exterior (unions of the surfaces on each domain face) + if polymesh is not None: + abaqus += _abaqus_exterior_unions(polymesh, defined_surfs) # End Part abaqus += '*End Part\n\n' @@ -1167,16 +1016,21 @@ def write(self, filename, format='txt', seeds=None, polymesh=None): elif fmt == 'vtk': n_kp = len(self.elements[0]) mesh_type = {4: 'Pixel', 8: 'Voxel'}[n_kp] - pt_fmt = '{: f} {: f} {: f}\n' + + # Element attributes on the full grid, -1 where there is no + # element (voids, outside the domain) + has_atts = self.element_attributes is not None + arr = self.as_array(element_attributes=has_atts) # Dimensions pts = np.array(self.points) - coords = [np.unique(ax) for ax in pts.T] + mins = pts.min(axis=0) + sz = self.mesh_size + coords = [mins[i] + sz * np.arange(n + 1) for i, n in + enumerate(arr.shape)] if len(coords) < 3: - coords.append([0]) # force z=0 for 2D meshes + coords.append(np.array([0.0])) # force z=0 for 2D meshes dims = [len(c) for c in coords] - n_dim = len(dims) - # write heading vtk = '# vtk DataFile Version 2.0\n' @@ -1188,119 +1042,34 @@ def write(self, filename, format='txt', seeds=None, polymesh=None): # write points for ind, ax in enumerate(['X', 'Y', 'Z']): vtk += '{}_COORDINATES {} float\n'.format(ax, dims[ind]) - line = '' - for x in coords[ind]: - x_str = '{:f}'.format(x) - if len(line) == 0: - line = x_str - elif len(line) + len(' ') + len(x_str) < 80: - line += ' ' + x_str - else: - vtk += line + '\n' - line = x_str - vtk += line + '\n' - - # write element attributes - vtk += 'CELL_DATA {}\n'.format(len(self.element_attributes)) - vtk += 'SCALARS element_attributes float\n' - vtk += 'LOOKUP_TABLE default\n' - line = '' - phase_nums = '' - phase_line = '' - pts = np.array(self.points) - elems = np.sort(self.elements) - if len(coords[-1]) == 1: # 2D - for y_ind in range(len(coords[1][:-1])): - y_mask_ind = pts[:, 1] == coords[1][y_ind] - y_mask_ip1 = pts[:, 1] == coords[1][y_ind] - y_mask = y_mask_ind | y_mask_ip1 - - for x_ind in range(len(coords[0][:-1])): - # mask self.points - x_mask_ind = pts[:, 0] == coords[0][x_ind] - x_mask_ip1 = pts[:, 0] == coords[0][x_ind + 1] - x_mask = x_mask_ind | x_mask_ip1 - - mask = x_mask & y_mask - el = np.where(mask) - e_ind = np.where(np.all(elems == el, axis=1))[0][0] - - # element attribute - att = self.element_attributes[e_ind] - att_str = '{:f}'.format(att) - if len(line) == 0: - line += att_str - elif len(line) + len(' ') + len(att_str) < 80: - line += ' ' + att_str - else: - vtk += line + '\n' - line = att_str - - # phase number - if seeds is not None: - phase = seeds[att].phase - p_str = str(int(phase)) - if len(phase_line) == 0: - phase_line = p_str - elif len(line) + len(' ') + len(p_str) < 80: - phase_line += ' ' + p_str - else: - phase_nums += phase_line + '\n' - phase_line = p_str - vtk += line + '\n' - if seeds is not None: - vtk += 'SCALARS phase_numbers int\n' - vtk += 'LOOKUP_TABLE default\n' - vtk += phase_nums + phase_line + '\n' + vtk += _vtk_lines(['{:f}'.format(x) for x in coords[ind]]) + # write element attributes, in the order VTK expects the cells + # (x index varying fastest, then y, then z) + vals = arr.flatten(order='F') + if np.issubdtype(arr.dtype, np.integer): + att_type = 'int' + att_strs = [str(int(v)) for v in vals] else: - for z_ind in range(len(coords[2][:-1])): - z_mask_ind = pts[:, 2] == coords[2][z_ind] - z_mask_ip1 = pts[:, 2] == coords[2][z_ind + 1] - z_mask = z_mask_ind | z_mask_ip1 - - for y_ind in range(len(coords[1][:-1])): - y_mask_ind = pts[:, 1] == coords[1][y_ind] - y_mask_ip1 = pts[:, 1] == coords[1][y_ind + 1] - y_mask = y_mask_ind | y_mask_ip1 - - for x_ind in range(len(coords[0][:-1])): - # mask self.points - x_mask_ind = pts[:, 0] == coords[0][x_ind] - x_mask_ip1 = pts[:, 0] == coords[0][x_ind + 1] - x_mask = x_mask_ind | x_mask_ip1 - - mask = x_mask & y_mask & z_mask - el = np.where(mask) - e_ind = np.where(np.all(elems == el, axis=1))[0][0] - - # element attribute - att = self.element_attributes[e_ind] - att_str = '{:f}'.format(att) - if len(line) == 0: - line += att_str - elif len(line) + len(' ') + len(att_str) < 80: - line += ' ' + att_str - else: - vtk += line + '\n' - line = att_str - - # phase number - if seeds is not None: - phase = seeds[att].phase - p_str = str(int(phase)) - if len(phase_line) == 0: - phase_line = p_str - elif len(line) + len(' ') + len(p_str) < 80: - phase_line += ' ' + p_str - else: - phase_nums += phase_line + '\n' - phase_line = p_str - vtk += line + '\n' - if seeds is not None: - vtk += 'SCALARS phase_numbers int\n' - vtk += 'LOOKUP_TABLE default\n' - vtk += phase_nums + phase_line + '\n' + att_type = 'float' + att_strs = ['{:f}'.format(v) for v in vals] + + vtk += 'CELL_DATA {}\n'.format(len(vals)) + vtk += 'SCALARS element_attributes {} 1\n'.format(att_type) + vtk += 'LOOKUP_TABLE default\n' + vtk += _vtk_lines(att_strs) + + # write phase numbers + if seeds is not None and has_atts: + phase_strs = [] + for v in vals: + if v < 0: + phase_strs.append('-1') + else: + phase_strs.append(str(int(seeds[int(v)].phase))) + vtk += 'SCALARS phase_numbers int 1\n' + vtk += 'LOOKUP_TABLE default\n' + vtk += _vtk_lines(phase_strs) with open(filename, 'w') as file: file.write(vtk) @@ -1344,18 +1113,19 @@ def as_array(self, element_attributes=True): # 2. Create array full of -1 values inds_maxs = elem_tups.max(axis=0) - arr = np.full(inds_maxs + 1, -1) - - # 3. For each element: populate array with element attributes if element_attributes: - vals = self.element_attributes + vals = np.asarray(self.element_attributes) else: vals = np.arange(elem_tups.shape[0]) - for t, v in zip(elem_tups, vals): - arr[tuple(t)] = v + if vals.dtype.kind in 'biu': + arr = np.full(inds_maxs + 1, -1) + else: + arr = np.full(inds_maxs + 1, -1, dtype=vals.dtype) - return arr + # 3. Populate array with element attributes (or indices) + arr[tuple(elem_tups.T)] = vals + return arr # ----------------------------------------------------------------------- # # Plot Function # @@ -1393,10 +1163,10 @@ def plot(self, index_by='element', material=[], loc=0, **kwargs): """ n_dim = len(self.points[0]) - if n_dim == 2: + if n_dim == 2 or plt.gcf().axes: ax = plt.gca() else: - ax = plt.gcf().gca(projection=Axes3D.name) + ax = plt.gcf().add_subplot(projection=Axes3D.name) n_obj = _misc.ax_objects(ax) if n_obj > 0: xlim = ax.get_xlim() @@ -1412,15 +1182,15 @@ def plot(self, index_by='element', material=[], loc=0, **kwargs): else: zlim = [float('inf'), -float('inf')] - inds = self.as_array(element_attributes=index_by=='attribute') + inds = self.as_array(element_attributes=index_by == 'attribute') plt_kwargs = {} for key, value in kwargs.items(): - if type(value) in (list, np.array): + if isinstance(value, (list, np.ndarray)): plt_value = np.empty(inds.shape, dtype=object) for i, val_i in enumerate(value): plt_value[inds == i] = val_i if 'color' in key: - unset_mask = plt_value == None + unset_mask = np.equal(plt_value, None) plt_value[unset_mask] = 'k' inds[unset_mask] = -1 @@ -1428,28 +1198,25 @@ def plot(self, index_by='element', material=[], loc=0, **kwargs): plt_value = value plt_kwargs[key] = plt_value - # Scale axes + # Corners of the voxels pts = np.array(self.points) mins = pts.min(axis=0) sz = self.mesh_size - pt_tups = np.round((pts - mins) / sz).astype(int) - maxs = pt_tups.max(axis=0) - grids = np.indices(maxs + 1, dtype=float) - for pt, pt_tup in zip(pts, pt_tups): - for i, x in enumerate(pt): - grids[i][tuple(pt_tup)] = x + axes = [mins[i] + sz * np.arange(n + 1) for i, n in + enumerate(inds.shape)] + grids = np.meshgrid(*axes, indexing='ij') ax.voxels(*grids, inds >= 0, **plt_kwargs) # Add legend if material and index_by == 'attribute': p_kwargs = [{'label': m} for m in material] for key, value in kwargs.items(): - if type(value) not in (list, np.array): + if not isinstance(value, (list, np.ndarray)): for kws in p_kwargs: kws[key] = value for i, m in enumerate(material): - if type(value) in (list, np.array): + if isinstance(value, (list, np.ndarray)): p_kwargs[i][key] = value[i] else: p_kwargs[i][key] = value @@ -1513,10 +1280,7 @@ def _call_meshpy(polymesh, phases=None, min_angle=0, max_volume=float('inf'), # condition the phases input if phases is None: - default_dict = {'material_type': 'solid', - 'max_volume': float('inf')} - n_phases = int(np.max(polymesh.phase_numbers)) + 1 - phases = [default_dict for _ in range(n_phases)] + phases = _default_phases(polymesh) # create point and facet lists kps = {} @@ -1622,18 +1386,24 @@ def _call_meshpy(polymesh, phases=None, min_angle=0, max_volume=float('inf'), info.regions[i] = tuple(r) # run MeshPy + # The maximum element volume is set per region above, using the global + # value as the default for the phases that do not set their own. Only + # these regional constraints are passed to Triangle/TetGen: a fixed + # (global) constraint would cap the per-phase values and, in 2D, an + # infinite one is formatted as 'ainf', which Triangle reads as the + # switches -a -i -n -f. if n_dim == 2: tri_mesh = meshpy.triangle.build(info, attributes=True, volume_constraints=True, - max_volume=max_volume, + max_volume=None, min_angle=min_angle, generate_faces=True) else: opts = meshpy.tet.Options('pq') opts.mindihedral = min_angle - opts.maxvolume = float('inf') - opts.fixedvolume = 1 + opts.varvolume = 1 + opts.fixedvolume = 0 opts.regionattrib = 1 opts.facesout = 1 tri_mesh = meshpy.tet.build(info, options=opts) @@ -1654,6 +1424,8 @@ def _call_meshpy(polymesh, phases=None, min_angle=0, max_volume=float('inf'), def _call_gmsh(pmesh, phases, res, edge_res): + if phases is None: + phases = _default_phases(pmesh) if res == float('inf'): res = None # If edge length not specified, default to mesh size input @@ -1721,7 +1493,7 @@ def _call_gmsh(pmesh, phases, res, edge_res): # ---------------------------------------------------------------------- # # CREATE GEOMETRY - # ---------------------------------------------------------------------- + # ---------------------------------------------------------------------- # with pg.geo.Geometry() as geom: # Add points pt_arr = np.array(pmesh.points) @@ -1794,7 +1566,8 @@ def _call_gmsh(pmesh, phases, res, edge_res): for i in facets_info: info = facets_info[i] facet_seeds = info['seeds'] - to_add = len(facet_seeds) < 2 or facet_seeds[0] != facet_seeds[1] + to_add = len(facet_seeds) < 2 + to_add |= facet_seeds[0] != facet_seeds[1] if not to_add: surfs.append('') continue @@ -1839,7 +1612,8 @@ def _call_gmsh(pmesh, phases, res, edge_res): pt = geom.add_point(_pt3d(cen), res) geom.in_volume(pt, volumes[-1]) else: - raise ValueError('Points cannot have dimension ' + str(n_dim) + '.') + e_str = 'Points cannot have dimension ' + str(n_dim) + '.' + raise ValueError(e_str) mesh = geom.generate_mesh() @@ -1881,6 +1655,18 @@ def _call_gmsh(pmesh, phases, res, edge_res): facets = facets[facet_set] facet_atts = facet_atts[facet_set] + # Remove the points that are not in any element (e.g. inside voids) + # and re-number the remaining ones + used = np.unique(tets) + kp_conv = np.full(len(pts), -1, dtype='int') + kp_conv[used] = np.arange(len(used)) + pts = pts[used] + tets = kp_conv[tets] + if len(facets) > 0: + f_keep = np.all(kp_conv[facets] >= 0, axis=1) + facets = kp_conv[facets[f_keep]] + facet_atts = facet_atts[f_keep] + tri_args = (pts, tets, tet_atts, facets, facet_atts) return tri_args @@ -1913,8 +1699,11 @@ def _sort_element(elem_pts): def _sort_facets(pairs): + """Chain the edges of a closed loop so that each one starts where the + previous one ends. Raises ValueError if the edges do not form a single + loop (e.g. the boundary of a region that is not simply connected). + """ remaining_inds = [i for i in range(1, len(pairs))] - sorted_inds = [0] s_pairs = [pairs[0]] while remaining_inds: last_kp = s_pairs[-1][-1] @@ -1922,7 +1711,13 @@ def _sort_facets(pairs): pair = pairs[i] if last_kp in pair: break - sorted_inds.append(i) + else: + e_str = 'The facets do not form a single closed loop: none of ' + e_str += 'the ' + str(len(remaining_inds)) + ' remaining facets ' + e_str += 'contains point ' + str(last_kp) + '. The boundary of ' + e_str += 'a region with holes, or of a region made of ' + e_str += 'disconnected cells, cannot be sorted.' + raise ValueError(e_str) del remaining_inds[ind] if last_kp == pair[0]: s_pairs.append(pair) @@ -1956,6 +1751,14 @@ def _amorphous_seed_numbers(pmesh, phases): if s1 != s2} return conv_dict + +def _default_phases(polymesh): + """Default phases: one solid phase per phase number of the polymesh.""" + n_phases = int(np.max(polymesh.phase_numbers)) + 1 + return [{'material_type': 'solid', 'max_volume': float('inf')} + for _ in range(n_phases)] + + def _pt3d(pt): pt3d = np.zeros(3) pt3d[:len(pt)] = pt @@ -1963,24 +1766,329 @@ def _pt3d(pt): def _facet_in_normal(pts, cen_pt): + """Inward unit normal and center of a facet of a convex cell. + + Args: + pts (numpy.ndarray): Vertices of the facet. + cen_pt (numpy.ndarray): A point inside the cell. + + Returns: + tuple: The unit normal pointing into the cell and the center of + the facet. + + """ + pts = np.asarray(pts, dtype='float') + f_cen = pts.mean(axis=0) n_dim = len(cen_pt) if n_dim == 2: - ptA = pts[0] - ptB = pts[1] - vt = ptB - ptA + vt = pts[1] - pts[0] vn = np.array([-vt[1], vt[0]]) else: - ptA = pts[0] - ptB = pts[1] - ptC = pts[2] - v1 = ptB - ptA - v2 = ptC - ptA - vn = np.cross(v1, v2) - - sgn = vn.dot(cen_pt - ptA) - vn *= sgn # flip so center is inward + # Newell's method, which is robust to collinear vertices + rel_pts = pts - f_cen + vn = np.zeros(3) + for i in range(len(rel_pts)): + vn += np.cross(rel_pts[i - 1], rel_pts[i]) + + if vn.dot(cen_pt - f_cen) < 0: + vn = -vn # flip so center is inward un = vn / np.linalg.norm(vn) - return un, pts.mean(axis=0) + return un, f_cen + + +def _abaqus_exterior_unions(polymesh, defined_surfs): + """Abaqus surfaces that combine the facet surfaces on each domain face. + + Args: + polymesh (PolyMesh): The polygon mesh, whose facet neighbors + identify the facets on each face of the domain. + defined_surfs (set): Facet numbers for which a 'Surface-' + surface has been written. Facets without elements in the mesh + (e.g. on the boundary of voids) have no surface and are not + included in the unions. + + Returns: + str: The '*Surface, combine=union' blocks. + + """ + abaqus = '' + poly_neighbors = np.array(polymesh.facet_neighbors) + poly_mask = np.any(poly_neighbors < 0, axis=1) + neigh_nums = np.min(poly_neighbors, axis=1) + u_neighs = np.unique(neigh_nums[poly_mask]) + for neigh_num in u_neighs: + f_nums = np.nonzero(neigh_nums == neigh_num)[0] + members = [int(i) for i in f_nums if int(i) in defined_surfs] + if not members: + continue + facet_name = 'Ext-Surface-' + str(-neigh_num) + abaqus += '*Surface, name=' + facet_name + ', combine=union\n' + abaqus += ''.join(['Surface-' + str(i) + '\n' for i in members]) + return abaqus + + +def _vtk_lines(values): + """Join strings into lines of fewer than 80 characters.""" + lines = [] + line = '' + for v_str in values: + if not line: + line = v_str + elif len(line) + 1 + len(v_str) < 80: + line += ' ' + v_str + else: + lines.append(line) + line = v_str + lines.append(line) + return '\n'.join(lines) + '\n' + + +# --------------------------------------------------------------------------- # +# # +# Raster Mesh Helpers # +# # +# --------------------------------------------------------------------------- # +# Offsets of the corner nodes of a pixel/voxel from its minimum corner, in +# element node order: counter-clockwise in 2D and, in 3D, nodes 1-4 on the +# bottom (-z) face counter-clockwise followed by nodes 5-8 on the top face, +# so that the element is right-handed (Abaqus CPS4 / C3D8 ordering). +_RASTER_CORNERS = { + 2: [(0, 0), (1, 0), (1, 1), (0, 1)], + 3: [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0), + (0, 0, 1), (1, 0, 1), (1, 1, 1), (0, 1, 1)], +} + +# Local node numbers of the faces of a pixel/voxel, keyed by (axis, side), +# where side 0 is the face at the minimum of the axis and side 1 the face +# at its maximum. The node order follows the Abaqus face definitions. +_RASTER_FACES = { + 2: {(0, 0): [3, 0], (0, 1): [1, 2], (1, 0): [0, 1], (1, 1): [2, 3]}, + 3: {(0, 0): [3, 7, 4, 0], (0, 1): [1, 5, 6, 2], + (1, 0): [0, 4, 5, 1], (1, 1): [2, 6, 7, 3], + (2, 0): [0, 1, 2, 3], (2, 1): [4, 7, 6, 5]}, +} + +# Abaqus face ids (S1, S2, ...) of the faces above. +# CPS4: S1 = 1-2, S2 = 2-3, S3 = 3-4, S4 = 4-1 +# C3D8: S1 = 1-2-3-4, S2 = 5-8-7-6, S3 = 1-5-6-2, S4 = 2-6-7-3, +# S5 = 3-7-8-4, S6 = 4-8-5-1 +_ABAQUS_FACE_IDS = { + 2: {(0, 0): 4, (0, 1): 2, (1, 0): 1, (1, 1): 3}, + 3: {(0, 0): 6, (0, 1): 4, (1, 0): 3, (1, 1): 5, (2, 0): 1, (2, 1): 2}, +} + + +class _CellGeometry(object): + """Geometry of the (convex) cells of a polymesh, computed on demand. + + For each cell, the bounding box and the inward unit normals and centers + of its facets are cached the first time they are needed. + + Args: + polymesh (PolyMesh): The polygon/polyhedron mesh. + p_pts (numpy.ndarray): The points of the polymesh, as an array. + + """ + def __init__(self, polymesh, p_pts): + self.polymesh = polymesh + self.p_pts = p_pts + self._cache = {} + + def _compute(self, cell): + region = self.polymesh.regions[cell] + facets = self.polymesh.facets + r_kps = np.unique([k for f in region for k in facets[f]]) + r_pts = self.p_pts[r_kps] + r_cen = r_pts.mean(axis=0) + + normals = [] + centers = [] + for f in region: + u_in, f_cen = _facet_in_normal(self.p_pts[facets[f]], r_cen) + normals.append(u_in) + centers.append(f_cen) + limits = (r_pts.min(axis=0), r_pts.max(axis=0)) + self._cache[cell] = (np.array(region), np.array(normals), + np.array(centers), limits) + + def facets(self, cell): + """Facet numbers, inward unit normals, and facet centers of a cell. + """ + if cell not in self._cache: + self._compute(cell) + return self._cache[cell][:3] + + def limits(self, cell): + """Bounding box (mins, maxs) of a cell.""" + if cell not in self._cache: + self._compute(cell) + return self._cache[cell][3] + + def exit_facet(self, cell, origin, direction): + """Facet through which the ray origin + t * direction leaves a cell. + + Returns: + tuple: The facet number and the value of t at the crossing, or + (None, None) if the ray does not leave the cell. + + """ + f_nums, normals, centers = self.facets(cell) + denom = normals.dot(direction) + exiting = denom < 0 + if not np.any(exiting): + return None, None + + t_vals = np.full(len(f_nums), float('inf')) + rel_pos = centers[exiting] - origin + t_vals[exiting] = np.einsum('ij,ij->i', rel_pos, normals[exiting]) + t_vals[exiting] /= denom[exiting] + i_min = np.argmin(t_vals) + return int(f_nums[i_min]), float(t_vals[i_min]) + + +def _raster_facet_number(r1, r2, c1, c2, polymesh, phases, cell_geom, + pair_facets): + """Polymesh facet approximated by the face between two pixels/voxels. + + The first pixel is centered at ``c1``, inside cell ``r1``, and the second + at ``c2``, inside cell ``r2`` (``r2 < 0`` if it is not in any cell). + If the cells are neighbors, the facet between them is returned. + Otherwise, the facets crossed by the segment from ``c1`` to ``c2`` are + found by walking through the cells of the polymesh, and the one that + the mesh keeps (see :func:`facet_check`) closest to the face between + the pixels is returned. Returns None if no facet is found. + """ + if r2 >= 0: + key = (min(r1, r2), max(r1, r2)) + if key in pair_facets: + return pair_facets[key] + + direction = np.asarray(c2, dtype='float') - np.asarray(c1, dtype='float') + cell = r1 + t_prev = 0 + crossed = [] + for _ in range(len(polymesh.regions)): + f_num, t = cell_geom.exit_facet(cell, c1, direction) + if f_num is None or t < t_prev - 1e-9: + break + if r2 >= 0 and t > 1 + 1e-6: + break + crossed.append((t, f_num)) + t_prev = t + + neighs = polymesh.facet_neighbors[f_num] + if cell not in neighs: + break + nxt = neighs[1] if neighs[0] == cell else neighs[0] + if nxt < 0 or nxt == r2: + break + cell = nxt + + if not crossed: + return None + ranked = [(abs(t - 0.5), f) for t, f in crossed if + facet_check(polymesh.facet_neighbors[f], polymesh, phases)] + if not ranked: + ranked = [(abs(t - 0.5), f) for t, f in crossed] + return min(ranked)[1] + + +def _raster_facets(polymesh, phases, cell_geom, elems, elem_grid, elem_regs, + keep, cens, mesh_size): + """Facets of a raster mesh, with polymesh facet numbers as attributes. + + A facet is created on the face between two kept pixels of different + cells when the polymesh facet between them is kept in the mesh (see + :func:`facet_check`), on the face between a kept pixel and a removed + one (void, or outside the domain), and on the faces of the kept pixels + on the boundary of the grid. + + Args: + polymesh (PolyMesh): The polygon/polyhedron mesh. + phases (list): Phase dictionaries. + cell_geom (_CellGeometry): Geometry of the cells of the polymesh. + elems (numpy.ndarray): Nodes of each element, in face order. + elem_grid (numpy.ndarray): Element numbers on the pixel grid. + elem_regs (numpy.ndarray): Polymesh cell of each element (-1 if + the center is not in any cell). + keep (numpy.ndarray): Mask of the elements kept in the mesh. + cens (numpy.ndarray): Centers of the elements. + mesh_size (float): Side length of the pixels/voxels. + + Returns: + tuple: Arrays of facets and facet attributes. + + """ + n_dim = elem_grid.ndim + faces = _RASTER_FACES[n_dim] + + # Polymesh facets between pairs of cells and on the domain boundary + pair_facets = {} + bnd_facets = {} + for f_num, neighs in enumerate(polymesh.facet_neighbors): + n1, n2 = neighs + if min(n1, n2) < 0: + bnd_facets[(max(n1, n2), min(n1, n2))] = f_num + else: + pair_facets[(min(n1, n2), max(n1, n2))] = f_num + args = (polymesh, phases, cell_geom, pair_facets) + + facets = [] + facet_atts = [] + for axis in range(n_dim): + direction = np.zeros(n_dim) + direction[axis] = 1 + + # Faces between neighboring pixels along this axis + sl_lo = [slice(None)] * n_dim + sl_hi = [slice(None)] * n_dim + sl_lo[axis] = slice(0, -1) + sl_hi[axis] = slice(1, None) + e_lo = elem_grid[tuple(sl_lo)].ravel() + e_hi = elem_grid[tuple(sl_hi)].ravel() + r_lo = elem_regs[e_lo] + r_hi = elem_regs[e_hi] + mask = (keep[e_lo] | keep[e_hi]) & (r_lo != r_hi) + for e1, e2 in zip(e_lo[mask], e_hi[mask]): + r1 = elem_regs[e1] + r2 = elem_regs[e2] + if keep[e1] and keep[e2]: + if not facet_check([r1, r2], polymesh, phases): + continue + if keep[e1]: + f_num = _raster_facet_number(r1, r2, cens[e1], cens[e2], + *args) + facet = elems[e1][faces[(axis, 1)]] + else: + f_num = _raster_facet_number(r2, r1, cens[e2], cens[e1], + *args) + facet = elems[e2][faces[(axis, 0)]] + if f_num is not None: + facets.append(facet) + facet_atts.append(f_num) + + # Faces on the boundary of the grid: the neighbor id of the domain + # boundary facets is -1 (-x), -2 (+x), -3 (-y), ..., -6 (+z) + for side in (0, 1): + sl_bnd = [slice(None)] * n_dim + sl_bnd[axis] = -side + e_bnd = elem_grid[tuple(sl_bnd)].ravel() + face_id = -(2 * axis + 1 + side) + sgn = 2 * side - 1 + for e1 in e_bnd[keep[e_bnd]]: + r1 = elem_regs[e1] + f_num = bnd_facets.get((r1, face_id)) + if f_num is None: + c2 = cens[e1] + sgn * mesh_size * direction + f_num = _raster_facet_number(r1, -1, cens[e1], c2, *args) + if f_num is not None: + facets.append(elems[e1][faces[(axis, side)]]) + facet_atts.append(f_num) + + n_fkp = len(faces[(0, 0)]) + facets = np.array(facets, dtype='int').reshape(-1, n_fkp) + facet_atts = np.array(facet_atts, dtype='int') + return facets, facet_atts def _plot_2d(ax, mesh, index_by, **kwargs): @@ -1990,7 +2098,7 @@ def _plot_2d(ax, mesh, index_by, **kwargs): plt_kwargs = {} for key, value in kwargs.items(): - if type(value) in (list, np.array): + if isinstance(value, (list, np.ndarray)): plt_value = [] for e_num, e_att in enumerate(mesh.element_attributes): if index_by == 'element': diff --git a/tests/meshing/test_trimesh_fixes.py b/tests/meshing/test_trimesh_fixes.py new file mode 100644 index 00000000..0e690675 --- /dev/null +++ b/tests/meshing/test_trimesh_fixes.py @@ -0,0 +1,679 @@ +"""Tests for the bug fixes in microstructpy.meshing.trimesh. + +Each test corresponds to a defect that was fixed in the TriMesh and +RasterMesh classes: mesh size controls that were ignored, raster meshes +with inverted elements and wrong facets, corrupt or incomplete output +files, and robustness of the constructors and writers. +""" +from __future__ import division + +import copy +import re + +import meshpy.triangle +import numpy as np +import pytest +import scipy.stats +from matplotlib import colors as mcolors +from matplotlib import pyplot as plt + +import microstructpy as msp +from microstructpy.meshing import trimesh as trimesh_module +from microstructpy.meshing.trimesh import RasterMesh +from microstructpy.meshing.trimesh import TriMesh + +# Abaqus element face definitions (local node numbers, 0-based) +# CPS4: S1 = 1-2, S2 = 2-3, S3 = 3-4, S4 = 4-1 +# C3D8: S1 = 1-2-3-4, S2 = 5-8-7-6, S3 = 1-5-6-2, S4 = 2-6-7-3, +# S5 = 3-7-8-4, S6 = 4-8-5-1 +ABAQUS_FACES = { + 4: {1: (0, 1), 2: (1, 2), 3: (2, 3), 4: (3, 0)}, + 8: {1: (0, 1, 2, 3), 2: (4, 7, 6, 5), 3: (0, 4, 5, 1), + 4: (1, 5, 6, 2), 5: (2, 6, 7, 3), 6: (3, 7, 4, 0)}, +} + +MESH_SIZE = 0.1 + + +# --------------------------------------------------------------------------- # +# Fixtures # +# --------------------------------------------------------------------------- # +@pytest.fixture(scope='module') +def case_2d(): + """2D microstructure with crystalline, amorphous, and void phases.""" + np.random.seed(1) + phases = [{'shape': 'circle', 'size': scipy.stats.uniform(0.15, 0.1), + 'material_type': 'crystalline', 'fraction': 0.5}, + {'shape': 'ellipse', 'size': 0.25, 'aspect_ratio': 2, + 'angle_deg': scipy.stats.uniform(0, 180), + 'material_type': 'amorphous', 'fraction': 0.35}, + {'shape': 'circle', 'size': 0.2, 'material_type': 'void', + 'fraction': 0.15}] + domain = msp.geometry.Square(side_length=2) + seeds = msp.seeding.SeedList.from_info(phases, domain.area) + seeds.position(domain) + pmesh = msp.meshing.PolyMesh.from_seeds(seeds, domain) + return pmesh, phases, seeds + + +@pytest.fixture(scope='module') +def case_3d(): + """3D microstructure with crystalline, amorphous, and void phases.""" + np.random.seed(1) + phases = [{'shape': 'sphere', 'size': scipy.stats.uniform(0.3, 0.2), + 'material_type': 'crystalline', 'fraction': 0.5}, + {'shape': 'sphere', 'size': 0.4, 'material_type': 'amorphous', + 'fraction': 0.35}, + {'shape': 'sphere', 'size': 0.35, 'material_type': 'void', + 'fraction': 0.15}] + domain = msp.geometry.Cube(side_length=1.5) + seeds = msp.seeding.SeedList.from_info(phases, domain.volume) + seeds.position(domain) + pmesh = msp.meshing.PolyMesh.from_seeds(seeds, domain) + return pmesh, phases, seeds + + +@pytest.fixture(scope='module') +def tri_2d(case_2d): + pmesh, phases, _ = case_2d + return TriMesh.from_polymesh(pmesh, phases, min_angle=20) + + +@pytest.fixture(scope='module') +def tri_3d(case_3d): + pmesh, phases, _ = case_3d + return TriMesh.from_polymesh(pmesh, phases, min_angle=10) + + +@pytest.fixture(scope='module') +def raster_2d(case_2d): + pmesh, phases, _ = case_2d + return RasterMesh.from_polymesh(pmesh, MESH_SIZE, phases) + + +@pytest.fixture(scope='module') +def raster_3d(case_3d): + pmesh, phases, _ = case_3d + return RasterMesh.from_polymesh(pmesh, MESH_SIZE, phases) + + +# --------------------------------------------------------------------------- # +# Helpers # +# --------------------------------------------------------------------------- # +def simplex_volumes(mesh): + """Areas of the triangles or volumes of the tetrahedra of a mesh.""" + pts = np.array(mesh.points) + elems = np.array(mesh.elements) + rel = pts[elems[:, 1:]] - pts[elems[:, :1]] + if pts.shape[1] == 2: + return 0.5 * np.abs(np.linalg.det(rel)) + return np.abs(np.linalg.det(rel)) / 6 + + +def element_phases(mesh, seeds): + """Phase number of each element, from the seed numbers.""" + seed_phases = np.array([seed.phase for seed in seeds]) + return seed_phases[np.array(mesh.element_attributes)] + + +def facet_elements(mesh): + """Set of elements that contain all the nodes of each facet.""" + node_elems = {} + for e_num, elem in enumerate(mesh.elements): + for kp in elem: + node_elems.setdefault(int(kp), set()).add(e_num) + return [set.intersection(*[node_elems[int(kp)] for kp in facet]) + for facet in mesh.facets] + + +def domain_face(pts, facet): + """Voro++ id of the domain face all the points of a facet lie on. + + Returns -1/-2 for the -x/+x faces, -3/-4 for y, -5/-6 for z, and None + if the facet is not on the boundary of the bounding box of the points. + """ + mins = pts.min(axis=0) + maxs = pts.max(axis=0) + f_pts = pts[list(facet)] + for axis in range(pts.shape[1]): + if np.allclose(f_pts[:, axis], mins[axis]): + return -(2 * axis + 1) + if np.allclose(f_pts[:, axis], maxs[axis]): + return -(2 * axis + 2) + return None + + +def parse_abaqus(filename): + """Nodes, elements, element surfaces, and surface unions of a deck.""" + nodes = {} + elems = {} + surfaces = {} + unions = {} + block = None + name = None + with open(filename, 'r') as file: + for line in file: + line = line.strip() + if not line or line.startswith('**'): + continue + if line.startswith('*'): + key = line.split(',')[0].lower() + if key == '*node': + block = 'node' + elif key == '*element': + block = 'element' + elif key == '*surface': + name = re.search(r'name=([^,]+)', line).group(1) + if 'combine=union' in line: + block = 'union' + unions[name] = [] + else: + block = 'surface' + surfaces[name] = [] + else: + block = None + continue + + vals = [v.strip() for v in line.split(',')] + if block == 'node': + nodes[int(vals[0])] = [float(v) for v in vals[1:]] + elif block == 'element': + elems[int(vals[0])] = [int(v) for v in vals[1:]] + elif block == 'surface': + surfaces[name].append((int(vals[0]), int(vals[1][1:]))) + elif block == 'union': + unions[name].append(vals[0]) + return nodes, elems, surfaces, unions + + +def check_raster_facets(mesh, pmesh): + """Checks on the facets of a raster mesh built from a polymesh.""" + pts = np.array(mesh.points) + facets = np.array(mesh.facets) + facet_atts = np.array(mesh.facet_attributes) + elem_atts = np.array(mesh.element_attributes) + n_kp = 2 * (pts.shape[1] - 1) + + assert facets.shape == (len(facet_atts), n_kp) + assert len(facets) > 0 + + # Attributes are polymesh facet numbers + assert np.all(facet_atts >= 0) + assert np.all(facet_atts < len(pmesh.facets)) + + # No duplicate facets + keys = [tuple(sorted(f)) for f in facets] + assert len(set(keys)) == len(keys) + + poly_neighbors = np.array(pmesh.facet_neighbors) + n_interior = 0 + n_boundary = 0 + for facet, att, elems in zip(facets, facet_atts, facet_elements(mesh)): + neighs = poly_neighbors[att] + on_face = domain_face(pts, facet) + if len(elems) == 2: + # interface: a shared face between pixels of different seeds + # (or merged amorphous regions), approximating an interior facet + e1, e2 = elems + assert elem_atts[e1] != elem_atts[e2] + assert np.min(neighs) >= 0 + n_interior += 1 + else: + # boundary of the mesh: the domain boundary or a void + assert len(elems) == 1 + if np.min(neighs) < 0: + assert on_face == np.min(neighs) + n_boundary += 1 + else: + assert on_face is None + assert n_interior > 0 + assert n_boundary > 0 + + # Every face of the mesh boundary is a facet + face_counts = {} + for elem in mesh.elements: + for local_kps in ABAQUS_FACES[len(elem)].values(): + key = tuple(sorted([int(elem[k]) for k in local_kps])) + face_counts[key] = face_counts.get(key, 0) + 1 + boundary_faces = {k for k, n in face_counts.items() if n == 1} + assert boundary_faces <= set(keys) + + +def check_raster_abaqus(mesh, pmesh, filename): + """Checks on the Abaqus deck of a raster mesh.""" + mesh.write(filename, 'abaqus', polymesh=pmesh) + nodes, elems, surfaces, unions = parse_abaqus(filename) + + assert len(nodes) == len(mesh.points) + assert len(elems) == len(mesh.elements) + + facet_sets = {} + for facet, att in zip(mesh.facets, mesh.facet_attributes): + key = frozenset([int(kp) + 1 for kp in facet]) + facet_sets.setdefault(int(att), set()).add(key) + + n_entries = 0 + for name, entries in surfaces.items(): + att = int(name.split('-')[1]) + for elem_id, face_id in entries: + elem = elems[elem_id] + face = ABAQUS_FACES[len(elem)][face_id] + face_nodes = frozenset([elem[k] for k in face]) + assert face_nodes in facet_sets[att] + n_entries += 1 + assert n_entries == len(mesh.facets) + + assert len(unions) > 0 + for members in unions.values(): + assert len(members) > 0 + for member in members: + assert member in surfaces + + +def parse_vtk_rectilinear(filename): + """Dimensions and scalar cell data arrays of a rectilinear grid.""" + with open(filename, 'r') as file: + text = file.read() + dims = [int(n) for n in re.search(r'DIMENSIONS (\d+) (\d+) (\d+)', + text).groups()] + n_cells = int(re.search(r'CELL_DATA (\d+)', text).group(1)) + scalars = {} + for block in text.split('SCALARS ')[1:]: + name = block.split()[0] + values = block.split('LOOKUP_TABLE default')[1].split() + scalars[name] = [float(v) for v in values] + return dims, n_cells, scalars + + +# --------------------------------------------------------------------------- # +# 1. TetGen maximum volume # +# --------------------------------------------------------------------------- # +def test_tetgen_global_max_volume(case_3d): + pmesh, phases, _ = case_3d + free = TriMesh.from_polymesh(pmesh, phases, min_angle=10) + # a bound below the largest unconstrained element must refine the mesh + max_volume = 0.5 * simplex_volumes(free).max() + mesh = TriMesh.from_polymesh(pmesh, phases, min_angle=10, + max_volume=max_volume) + + vols = simplex_volumes(mesh) + assert np.all(vols <= max_volume + 1e-9) + assert len(mesh.elements) > len(free.elements) + + +def test_tetgen_per_phase_max_volume(case_3d): + pmesh, phases, seeds = case_3d + max_volume = 5e-4 + + # Only the phase with a maximum volume is refined + phases = copy.deepcopy(phases) + phases[0]['max_volume'] = max_volume + mesh = TriMesh.from_polymesh(pmesh, phases, min_angle=10) + vols = simplex_volumes(mesh) + elem_phases = element_phases(mesh, seeds) + assert np.all(vols[elem_phases == 0] <= max_volume + 1e-9) + assert np.max(vols[elem_phases != 0]) > max_volume + + # A per-phase maximum larger than the global default is not capped + phases[0]['max_volume'] = 1e-2 + mesh = TriMesh.from_polymesh(pmesh, phases, min_angle=10, + max_volume=max_volume) + vols = simplex_volumes(mesh) + elem_phases = element_phases(mesh, seeds) + assert np.all(vols[elem_phases != 0] <= max_volume + 1e-9) + assert np.max(vols[elem_phases == 0]) > max_volume + assert np.all(vols[elem_phases == 0] <= 1e-2 + 1e-9) + + +# --------------------------------------------------------------------------- # +# 2. Triangle maximum area # +# --------------------------------------------------------------------------- # +def test_triangle_per_phase_max_volume_exceeds_global(case_2d): + pmesh, phases, seeds = case_2d + phases = copy.deepcopy(phases) + phases[0]['max_volume'] = 1e-2 + mesh = TriMesh.from_polymesh(pmesh, phases, min_angle=20, + max_volume=1e-3) + + areas = simplex_volumes(mesh) + elem_phases = element_phases(mesh, seeds) + assert np.max(areas[elem_phases == 0]) > 1e-3 + assert np.all(areas[elem_phases == 0] <= 1e-2 + 1e-9) + assert np.all(areas[elem_phases != 0] <= 1e-3 + 1e-9) + + +def test_triangle_infinite_max_volume_not_passed(case_2d, monkeypatch): + pmesh, phases, _ = case_2d + captured = {} + orig_build = meshpy.triangle.build + + def build(info, **kwargs): + captured['info'] = info + captured['kwargs'] = kwargs + return orig_build(info, **kwargs) + + monkeypatch.setattr(meshpy.triangle, 'build', build) + mesh = TriMesh.from_polymesh(pmesh, phases, min_angle=20) + + # An infinite area would be formatted as the switch 'ainf' + assert captured['kwargs']['max_volume'] is None + assert captured['kwargs']['volume_constraints'] + + # Without an area constraint the mesh is the min_angle-only mesh + ref_mesh = orig_build(captured['info'], attributes=True, + volume_constraints=False, max_volume=None, + min_angle=20, generate_faces=True) + n_ref = len(ref_mesh.elements) + assert abs(len(mesh.elements) - n_ref) <= 0.05 * n_ref + + +# --------------------------------------------------------------------------- # +# 3. Raster meshes # +# --------------------------------------------------------------------------- # +def test_raster_2d_elements_counter_clockwise(raster_2d): + pts = np.array(raster_2d.points) + elems = np.array(raster_2d.elements) + assert elems.shape[1] == 4 + + x = pts[elems, 0] + y = pts[elems, 1] + x_next = np.roll(x, -1, axis=1) + y_next = np.roll(y, -1, axis=1) + signed_areas = 0.5 * np.sum(x * y_next - x_next * y, axis=1) + assert np.all(signed_areas > 0) + assert np.allclose(signed_areas, MESH_SIZE ** 2) + + +def test_raster_3d_elements_right_handed(raster_3d): + pts = np.array(raster_3d.points) + elems = np.array(raster_3d.elements) + assert elems.shape[1] == 8 + + # nodes 1-4 on the bottom face, 5-8 on the top face + z = pts[elems, 2] + assert np.allclose(z[:, :4], z[:, :1]) + assert np.allclose(z[:, 4:], z[:, :1] + MESH_SIZE) + + # scalar triple product of the edges at node 1 + p1 = pts[elems[:, 0]] + v12 = pts[elems[:, 1]] - p1 + v14 = pts[elems[:, 3]] - p1 + v15 = pts[elems[:, 4]] - p1 + triple = np.einsum('ij,ij->i', np.cross(v12, v14), v15) + assert np.all(triple > 0) + assert np.allclose(triple, MESH_SIZE ** 3) + + +def test_raster_2d_facets(raster_2d, case_2d): + check_raster_facets(raster_2d, case_2d[0]) + + +def test_raster_3d_facets(raster_3d, case_3d): + check_raster_facets(raster_3d, case_3d[0]) + + +def test_raster_default_phases(case_2d): + pmesh = case_2d[0] + mesh = RasterMesh.from_polymesh(pmesh, MESH_SIZE) + assert len(mesh.elements) == 400 + assert np.all(np.array(mesh.element_attributes) >= 0) + assert len(mesh.facets) > 0 + + +def test_raster_vtk_2d(raster_2d, case_2d, tmp_path): + filename = str(tmp_path / 'raster_2d.vtk') + raster_2d.write(filename, 'vtk', seeds=case_2d[2]) + + dims, n_cells, scalars = parse_vtk_rectilinear(filename) + assert dims == [21, 21, 1] + assert n_cells == 400 + assert len(scalars['element_attributes']) == n_cells + assert len(scalars['phase_numbers']) == n_cells + + # Void pixels are marked with -1, the others have their attributes + atts = np.array(scalars['element_attributes']) + assert np.sum(atts >= 0) == len(raster_2d.elements) + assert set(atts[atts >= 0]) == set(raster_2d.element_attributes) + + +def test_raster_vtk_3d_with_void(raster_3d, case_3d, tmp_path): + filename = str(tmp_path / 'raster_3d.vtk') + raster_3d.write(filename, 'vtk', seeds=case_3d[2]) + + dims, n_cells, scalars = parse_vtk_rectilinear(filename) + assert dims == [16, 16, 16] + assert n_cells == 15 ** 3 + assert len(scalars['element_attributes']) == n_cells + assert len(scalars['phase_numbers']) == n_cells + + atts = np.array(scalars['element_attributes']) + assert np.sum(atts < 0) > 0 # voids + assert np.sum(atts >= 0) == len(raster_3d.elements) + + +def test_raster_abaqus_2d(raster_2d, case_2d, tmp_path): + check_raster_abaqus(raster_2d, case_2d[0], str(tmp_path / 'r2d.inp')) + + +def test_raster_abaqus_3d(raster_3d, case_3d, tmp_path): + check_raster_abaqus(raster_3d, case_3d[0], str(tmp_path / 'r3d.inp')) + + +def test_raster_plot_3d_fresh_figure(raster_3d): + n_att = int(np.max(raster_3d.element_attributes)) + 1 + facecolors = np.array(['C' + str(i % 10) for i in range(n_att)]) + plt.close('all') + fig = plt.figure() + try: + raster_3d.plot(index_by='attribute', facecolors=facecolors) + assert len(fig.axes) == 1 + assert fig.axes[0].name == '3d' + finally: + plt.close('all') + + +# --------------------------------------------------------------------------- # +# 4. Triangle/TetGen file format # +# --------------------------------------------------------------------------- # +@pytest.mark.parametrize('n_dim', [2, 3]) +def test_tet_tri_files(n_dim, tri_2d, tri_3d, tmp_path): + mesh = {2: tri_2d, 3: tri_3d}[n_dim] + basename = str(tmp_path / 'mesh') + mesh.write(basename, 'tet/tri') + + # edge/face file + ext = {2: '.edge', 3: '.face'}[n_dim] + with open(basename + ext, 'r') as file: + lines = file.read().strip().split('\n') + header = lines[0].split() + assert len(header) == 2 + n_facets, n_markers = [int(n) for n in header] + assert n_facets == len(mesh.facets) + assert n_markers == 1 + assert len(lines) == n_facets + 1 + for i, (line, facet) in enumerate(zip(lines[1:], mesh.facets)): + vals = [int(v) for v in line.split()] + assert len(vals) == n_dim + 2 + assert vals[0] == i + assert vals[1:-1] == [int(kp) for kp in facet] + assert vals[-1] in (0, 1) + + # element file + with open(basename + '.ele', 'r') as file: + lines = file.read().strip().split('\n') + n_elems, n_kp, n_atts = [int(n) for n in lines[0].split()] + assert (n_elems, n_kp, n_atts) == (len(mesh.elements), n_dim + 1, 1) + assert len(lines) == n_elems + 1 + for i, (line, elem) in enumerate(zip(lines[1:], mesh.elements)): + vals = line.split() + assert int(vals[0]) == i + assert [int(v) for v in vals[1:1 + n_kp]] == [int(k) for k in elem] + + # node file + with open(basename + '.node', 'r') as file: + lines = file.read().strip().split('\n') + assert [int(n) for n in lines[0].split()] == [len(mesh.points), n_dim, + 0, 1] + assert len(lines) == len(mesh.points) + 1 + + +# --------------------------------------------------------------------------- # +# 5. Abaqus exterior surfaces # +# --------------------------------------------------------------------------- # +def test_abaqus_exterior_unions_defined(tri_2d, case_2d, tmp_path): + pmesh, _, seeds = case_2d + filename = str(tmp_path / 'tri_2d.inp') + tri_2d.write(filename, 'abaqus', seeds=seeds, polymesh=pmesh) + + _, elems, surfaces, unions = parse_abaqus(filename) + assert len(elems) == len(tri_2d.elements) + assert len(unions) == 4 # one per side of the square + for members in unions.values(): + assert len(members) > 0 + for member in members: + assert member in surfaces + + # Facets of the polymesh on the boundary of voids have no surface + poly_neighbors = np.array(pmesh.facet_neighbors) + n_boundary = np.sum(np.any(poly_neighbors < 0, axis=1)) + n_union = sum([len(members) for members in unions.values()]) + assert n_union < n_boundary + + +# --------------------------------------------------------------------------- # +# 6. Text format precision # +# --------------------------------------------------------------------------- # +def test_txt_round_trip_exact(tri_2d, tmp_path): + filename = str(tmp_path / 'tri_2d.txt') + tri_2d.write(filename, 'txt') + mesh = TriMesh.from_file(filename) + + assert np.array_equal(np.array(mesh.points), np.array(tri_2d.points)) + assert np.array_equal(np.array(mesh.elements), + np.array(tri_2d.elements)) + assert np.array_equal(np.array(mesh.element_attributes), + np.array(tri_2d.element_attributes)) + assert np.array_equal(np.array(mesh.facets), np.array(tri_2d.facets)) + assert np.array_equal(np.array(mesh.facet_attributes), + np.array(tri_2d.facet_attributes)) + + +# --------------------------------------------------------------------------- # +# 7. Robustness # +# --------------------------------------------------------------------------- # +def test_str_without_optional_attributes(tri_2d, tmp_path): + mesh = TriMesh(tri_2d.points, tri_2d.elements) + mesh_str = str(mesh) + assert 'Element Attributes' not in mesh_str + assert 'Facet' not in mesh_str + assert mesh_str.endswith(', '.join([str(k) for k in mesh.elements[-1]])) + + filename = str(tmp_path / 'no_atts.txt') + mesh.write(filename) + read_mesh = TriMesh.from_file(filename) + assert np.array_equal(np.array(read_mesh.points), + np.array(mesh.points)) + assert np.array_equal(np.array(read_mesh.elements), + np.array(mesh.elements)) + + # Facets without facet attributes + mesh = TriMesh(tri_2d.points, tri_2d.elements, facets=tri_2d.facets) + mesh_str = str(mesh) + assert 'Facets: ' + str(len(tri_2d.facets)) in mesh_str + assert 'Facet Attributes' not in mesh_str + mesh.write(filename) + read_mesh = TriMesh.from_file(filename) + assert np.array_equal(np.array(read_mesh.facets), + np.array(mesh.facets)) + + +def test_abaqus_without_polymesh(tri_2d, tmp_path): + filename = str(tmp_path / 'no_polymesh.inp') + tri_2d.write(filename, 'abaqus') + + _, elems, surfaces, unions = parse_abaqus(filename) + assert len(elems) == len(tri_2d.elements) + assert len(surfaces) == len(np.unique(tri_2d.facet_attributes)) + assert len(unions) == 0 + + +def test_unknown_mesher_raises(case_2d): + pmesh, phases, _ = case_2d + with pytest.raises(ValueError) as excinfo: + TriMesh.from_polymesh(pmesh, phases, mesher='bogus') + assert 'Triangle/TetGen' in str(excinfo.value) + assert 'gmsh' in str(excinfo.value) + + # the comparison is case-insensitive + mesh = TriMesh.from_polymesh(pmesh, phases, mesher=' TRIANGLE ', + min_angle=20) + assert len(mesh.elements) > 0 + + +def test_gmsh_default_phases(case_2d): + pmesh = case_2d[0] + mesh = TriMesh.from_polymesh(pmesh, mesher='gmsh') + assert len(mesh.elements) > 0 + assert len(mesh.element_attributes) == len(mesh.elements) + assert set(mesh.element_attributes) <= set(pmesh.seed_numbers) + + +def test_gmsh_no_unused_points(case_2d): + pmesh, phases, _ = case_2d + mesh = TriMesh.from_polymesh(pmesh, phases, mesher='gmsh') + + n_pts = len(mesh.points) + used = np.unique(np.array(mesh.elements)) + assert np.array_equal(used, np.arange(n_pts)) + assert np.all(np.array(mesh.facets) < n_pts) + assert len(mesh.facets) > 0 + + +def test_sort_facets_disjoint_loops_raises(): + loop = [[0, 1], [2, 0], [1, 2]] + assert trimesh_module._sort_facets(loop) == [[0, 1], [1, 2], [2, 0]] + + two_loops = [[0, 1], [1, 2], [2, 0], [3, 4], [4, 5], [5, 3]] + with pytest.raises(ValueError) as excinfo: + trimesh_module._sort_facets(two_loops) + assert 'loop' in str(excinfo.value) + + +# --------------------------------------------------------------------------- # +# 8. Plot keyword arguments as numpy arrays # +# --------------------------------------------------------------------------- # +def test_plot_numpy_facecolors_per_attribute(tri_2d): + atts = np.array(tri_2d.element_attributes) + n_att = int(np.max(atts)) + 1 + facecolors = np.array(['C' + str(i % 10) for i in range(n_att)]) + + plt.close('all') + plt.figure() + try: + ax = plt.gca() + tri_2d.plot(index_by='attribute', facecolors=facecolors) + pc = ax.collections[-1] + fc = pc.get_facecolor() + assert fc.shape == (len(tri_2d.elements), 4) + expected = mcolors.to_rgba_array(facecolors[atts]) + assert np.allclose(fc, expected) + finally: + plt.close('all') + + +# --------------------------------------------------------------------------- # +# 10. Element type checks in the writers # +# --------------------------------------------------------------------------- # +@pytest.mark.parametrize('fmt', ['abaqus', 'vtk', 'tet/tri']) +def test_write_rejects_non_simplex_elements(fmt, raster_2d, tmp_path): + mesh = TriMesh(raster_2d.points, raster_2d.elements, + raster_2d.element_attributes, raster_2d.facets, + raster_2d.facet_attributes) + with pytest.raises(ValueError) as excinfo: + mesh.write(str(tmp_path / 'quads'), fmt) + assert '4 nodes' in str(excinfo.value) + + # the text format does not depend on the element type + mesh.write(str(tmp_path / 'quads.txt'), 'txt') + read_mesh = TriMesh.from_file(str(tmp_path / 'quads.txt')) + assert len(read_mesh.elements) == len(mesh.elements) From ae2b00bba09b3387bf9b5c3d81782afd3a550ab9 Mon Sep 17 00:00:00 2001 From: Julian Rimoli Date: Tue, 22 Sep 2026 20:02:12 -0700 Subject: [PATCH 05/42] Fix CLI, input parsing and verification bugs - cdf inputs: pass density=False to rv_histogram (distributions were distorted for unevenly spaced x); 'pdf' as an alias of 'histogram' - plot_tri: the 3D visibility walk terminates when voids touch the boundary (it looped forever) - dict_convert passes the input file path into repeated tags (relative filenames failed with more than one material) - from_str: no infinite recursion on 'true'/'false' substrings; inf/nan - run(): no mutable defaults, arguments are not modified; unsupported output types and missing input files reported clearly; mesher names case-insensitive - : repeated materials are concatenated instead of discarded - verification: angle_rad inputs are kept, 'random' orientations and vector-valued parameters work, unknown phase fields are ignored, the caller's phases are not modified; single-material colour maps Co-Authored-By: Claude Fable 5.1 --- src/microstructpy/_misc.py | 50 ++-- src/microstructpy/cli.py | 286 +++++++++++++----- src/microstructpy/verification.py | 236 +++++++++++---- tests/cli/test_cli_fixes.py | 465 ++++++++++++++++++++++++++++++ tests/test_verification_fixes.py | 243 ++++++++++++++++ 5 files changed, 1124 insertions(+), 156 deletions(-) create mode 100644 tests/cli/test_cli_fixes.py create mode 100644 tests/test_verification_fixes.py diff --git a/src/microstructpy/_misc.py b/src/microstructpy/_misc.py index 61f425f7..840bc7cd 100644 --- a/src/microstructpy/_misc.py +++ b/src/microstructpy/_misc.py @@ -4,6 +4,7 @@ """ import ast +import re import numpy as np @@ -40,6 +41,12 @@ def from_str(string): This function takes a string and converts it into a number or a list. + Booleans are recognized regardless of case (``true``, ``FALSE``, ...), + on their own or inside a tuple/list such as ``(true, false)``. + Strings that merely contain these words (``true_cdf.csv``) are returned + unchanged. Values that Python does not accept as literals but ``float`` + does, such as ``inf``, ``-inf`` and ``nan``, are converted to floats. + Args: string (str): The string. @@ -49,25 +56,32 @@ def from_str(string): """ s = string.strip() try: - val = ast.literal_eval(s) + return ast.literal_eval(s) except (ValueError, SyntaxError): - if 'true' in s.lower(): - tmp_s = s.lower().replace('true', 'True') - tmp_val = from_str(tmp_s) - if tmp_val != tmp_s: - val = tmp_val - else: - val = s - elif 'false' in s.lower(): - tmp_s = s.lower().replace('false', 'False') - tmp_val = from_str(tmp_s) - if tmp_val != tmp_s: - val = tmp_val - else: - val = s - else: - val = s - return val + pass + + # Booleans, case-insensitive + if s.lower() in ('true', 'false'): + return s.lower() == 'true' + + # Booleans inside a literal, e.g. '(true, False)' + norm_s = _bool_re.sub(lambda m: m.group(0).capitalize(), s) + if norm_s != s: + try: + return ast.literal_eval(norm_s) + except (ValueError, SyntaxError): + pass + + # Floats that are not Python literals: inf, -inf, nan + try: + return float(s) + except ValueError: + pass + + return s + + +_bool_re = re.compile(r'\b(true|false)\b', flags=re.IGNORECASE) # --------------------------------------------------------------------------- # diff --git a/src/microstructpy/cli.py b/src/microstructpy/cli.py index 23ca2928..ba04c56e 100644 --- a/src/microstructpy/cli.py +++ b/src/microstructpy/cli.py @@ -11,12 +11,13 @@ from __future__ import print_function import argparse -import ast import collections +import copy import glob import os import shutil import subprocess +import sys import numpy as np import scipy.stats @@ -67,7 +68,14 @@ def main(): args = parser.parse_args() # run user-generated files - user_files = [f for fnames in args.user_files for f in glob.glob(fnames)] + user_files = [] + for pattern in args.user_files: + matches = glob.glob(pattern) + if not matches: + e_str = 'Error: no input file matches ' + repr(pattern) + '.' + print(e_str, file=sys.stderr) + sys.exit(1) + user_files.extend(matches) for filename in set(user_files): run_file(filename) @@ -197,7 +205,7 @@ def input2dict(filename, root_tag='input'): def _include_expand(inp, filename, key): - if isinstance(inp, str): + if inp is None or isinstance(inp, str): return inp if isinstance(inp, list): return [_include_expand(inp_i, filename, key) for inp_i in inp] @@ -210,25 +218,56 @@ def _include_expand(inp, filename, key): if not isinstance(includes, list): includes = [includes] for inc_filename in includes: - inc_fname = os.path.expanduser(inc_filename) + inc_fname = os.path.expanduser(inc_filename.strip()) if os.path.isabs(inc_fname): fname = inc_fname else: fname = os.path.join(file_path, inc_fname) inc_dict = input2dict(fname, key) - exp_dict.update(inc_dict[key]) + for inc_key, inc_val in inc_dict[key].items(): + _include_merge(exp_dict, inc_key, inc_val) else: - exp_dict[inp_key] = _include_expand(inp_val, filename, inp_key) + exp_val = _include_expand(inp_val, filename, inp_key) + _include_merge(exp_dict, inp_key, exp_val) return exp_dict +def _include_merge(exp_dict, key, val): + """Add a value to the dictionary of an expanded input file. + + When the same tag comes from an ```` and from the including + file (or from two includes), structured values (dictionaries and lists) + are concatenated into a list - the same as xmltodict does for repeated + tags - so that, for example, no ```` is discarded. + Scalar values are overridden by the later occurrence. + """ + if key not in exp_dict: + exp_dict[key] = val + return + + old_val = exp_dict[key] + repeated = (key == 'material' or isinstance(old_val, list) or + isinstance(val, list)) + if repeated and isinstance(old_val, (dict, list)) and \ + isinstance(val, (dict, list)): + old_list = old_val if isinstance(old_val, list) else [old_val] + new_list = val if isinstance(val, list) else [val] + exp_dict[key] = old_list + new_list + else: + exp_dict[key] = val + + +_tri_exts = {'abaqus': '.inp', 'txt': '.txt', 'str': '.txt', 'tet/tri': '', + 'vtk': '.vtk'} + + def run(phases, domain, verbose=False, restart=True, directory='.', - filetypes={}, rng_seeds={}, plot_axes=True, rtol='fit', edge_opt=False, - edge_opt_n_iter=100, mesher='Triangle/TetGen', + filetypes=None, rng_seeds=None, plot_axes=True, rtol='fit', + edge_opt=False, edge_opt_n_iter=100, mesher='Triangle/TetGen', mesh_max_volume=float('inf'), mesh_min_angle=0, mesh_max_edge_length=float('inf'), mesh_size=float('inf'), verify=False, color_by='material', colormap='viridis', - seeds_kwargs={}, poly_kwargs={}, tri_kwargs={}): + seeds_kwargs=None, poly_kwargs=None, tri_kwargs=None): r"""Run MicroStructPy This is the primary run function for the package. It performs these steps: @@ -353,6 +392,14 @@ def run(phases, domain, verbose=False, restart=True, directory='.', # Settings # -------- + # Work on copies of the dictionaries: the caller's arguments are never + # modified, so repeated calls with the same inputs give the same results. + filetypes = copy.deepcopy(filetypes) if filetypes is not None else {} + rng_seeds = copy.deepcopy(rng_seeds) if rng_seeds is not None else {} + seeds_kwargs = dict(seeds_kwargs) if seeds_kwargs is not None else {} + poly_kwargs = dict(poly_kwargs) if poly_kwargs is not None else {} + tri_kwargs = dict(tri_kwargs) if tri_kwargs is not None else {} + # filetypes if restart: for kw in ('seeds', 'poly', 'tri'): @@ -364,6 +411,20 @@ def run(phases, domain, verbose=False, restart=True, directory='.', else: filetypes[kw] = [filetypes[kw], 'txt'] + # Check the triangular mesh output types before doing any work + tri_types = filetypes.get('tri', []) + if not isinstance(tri_types, list): + tri_types = [tri_types] + for tri_type in tri_types: + if tri_type not in _tri_exts: + e_str = 'Unsupported output type ' + repr(tri_type) + '. ' + e_str += 'Supported types are: ' + e_str += ', '.join([repr(t) for t in sorted(_tri_exts)]) + '.' + raise ValueError(e_str) + + # mesher + raster = mesher.strip().lower() == 'raster' + if verbose: print('Running MicroStructPy in verbose mode.') @@ -406,10 +467,10 @@ def run(phases, domain, verbose=False, restart=True, directory='.', # Write seeds seeds_types = filetypes.get('seeds', []) - if type(seeds_types) != list: + if not isinstance(seeds_types, list): seeds_types = [seeds_types] for seeds_type in seeds_types: - fname = seed_filename.rstrip('.txt') + '.' + seeds_type + fname = os.path.splitext(seed_filename)[0] + '.' + seeds_type if seeds_created or not os.path.exists(fname): seeds.write(fname, format=seeds_type) @@ -460,11 +521,11 @@ def run(phases, domain, verbose=False, restart=True, directory='.', # Write polymesh poly_types = filetypes.get('poly', []) - if type(poly_types) != list: + if not isinstance(poly_types, list): poly_types = [poly_types] for poly_type in poly_types: - fname = poly_filename.replace('.txt', '.' + poly_type) + fname = os.path.splitext(poly_filename)[0] + '.' + poly_type if poly_created or not os.path.exists(fname): pmesh.write(fname, poly_type) @@ -493,14 +554,11 @@ def run(phases, domain, verbose=False, restart=True, directory='.', # ----------------------------------------------------------------------- # # Create Triangular Mesh # # ----------------------------------------------------------------------- # - raster = mesher == 'raster' if raster: tri_basename = 'rastermesh.txt' else: tri_basename = 'trimesh.txt' tri_filename = os.path.join(directory, tri_basename) - exts = {'abaqus': '.inp', 'txt': '.txt', 'str': '.txt', 'tet/tri': '', - 'vtk': '.vtk'} if restart and os.path.exists(tri_filename) and not poly_created: # Read triangle mesh @@ -530,12 +588,8 @@ def run(phases, domain, verbose=False, restart=True, directory='.', mesh_max_edge_length, mesh_size) # Write triangular mesh - tri_types = filetypes.get('tri', []) - if type(tri_types) != list: - tri_types = [tri_types] - for tri_type in tri_types: - fname = tri_filename.replace('.txt', exts[tri_type]) + fname = os.path.splitext(tri_filename)[0] + _tri_exts[tri_type] if tri_created or not os.path.exists(fname): tmesh.write(fname, tri_type, seeds, pmesh) @@ -645,7 +699,9 @@ def run(phases, domain, verbose=False, restart=True, directory='.', # Created Unpositioned List of Seeds # # # # --------------------------------------------------------------------------- # -def _unpositioned_seeds(phases, domain, rng_seeds={}): +def _unpositioned_seeds(phases, domain, rng_seeds=None): + if rng_seeds is None: + rng_seeds = {} if domain.n_dim == 2: dom_vol = domain.area else: @@ -684,7 +740,6 @@ def plot_seeds(seeds, phases, domain, plot_files=[], plot_axes=True, :meth:`.SeedList.plot`. """ - print('plot files seeds', plot_files) if not plot_files: plot_files = ['seeds.png'] @@ -703,7 +758,8 @@ def plot_seeds(seeds, phases, domain, plot_files=[], plot_axes=True, plt.clf() plt.close('all') fig = plt.figure() - ax = fig.add_subplot(projection={2: None, 3: Axes3D.name}[n_dim], label='seeds') + projection = {2: None, 3: Axes3D.name}[n_dim] + ax = fig.add_subplot(projection=projection, label='seeds') if not plot_axes: if n_dim == 2: @@ -738,7 +794,8 @@ def plot_seeds(seeds, phases, domain, plot_files=[], plot_axes=True, for fname in plot_files: if n_dim == 3: _misc.axisEqual3D(ax) - plt.subplots_adjust(left=0, bottom=.05, right=1, top=1, wspace=0, hspace=0) + plt.subplots_adjust(left=0, bottom=.05, right=1, top=1, + wspace=0, hspace=0) plt.savefig(fname) else: plt.savefig(fname, bbox_inches='tight', pad_inches=0) @@ -751,10 +808,10 @@ def _seed_colors(seeds, phases, color_by='material', colormap='viridis'): return [_phase_color(s.phase, phases) for s in seeds] elif color_by == 'seed number': n = len(seeds) - return [_cm_color(i / (n - 1), colormap) for i in range(n)] + return [_cm_color(_cm_frac(i, n), colormap) for i in range(n)] elif color_by == 'material number': n = len(phases) - return [_cm_color(s.phase / (n - 1), colormap) for s in seeds] + return [_cm_color(_cm_frac(s.phase, n), colormap) for s in seeds] def _phase_color(i, phases): @@ -766,7 +823,12 @@ def _phase_color_by(i, phases, color_by='material', colormap='viridis'): return phases[i].get('color', 'C' + str(i % 10)) elif color_by == 'material number': n = len(phases) - return _cm_color(i / (n - 1), colormap) + return _cm_color(_cm_frac(i, n), colormap) + + +def _cm_frac(i, n): + """Position of item i of n in the colormap, in [0, 1]""" + return i / max(n - 1, 1) def _cm_color(f, colormap='viridis'): @@ -824,7 +886,8 @@ def plot_poly(pmesh, phases, plot_files=['polymesh.png'], plot_axes=True, plt.clf() plt.close('all') fig = plt.figure() - ax = fig.add_subplot(projection={2: None, 3: Axes3D.name}[n_dim], label='poly') + projection = {2: None, 3: Axes3D.name}[n_dim] + ax = fig.add_subplot(projection=projection, label='poly') if not plot_axes: if n_dim == 2: @@ -842,7 +905,10 @@ def plot_poly(pmesh, phases, plot_files=['polymesh.png'], plot_axes=True, else: pmesh.plot(facecolors=fcs) + # The edge color is applied per facet below, so remove both the + # plural and the singular matplotlib keywords from the pass-through. edge_color = edge_kwargs.pop('edgecolors', (0, 0, 0, 1)) + edge_color = edge_kwargs.pop('edgecolor', edge_color) facet_colors = [] for neigh_pair in pmesh.facet_neighbors: if facet_check(neigh_pair, pmesh, phases): @@ -864,7 +930,8 @@ def plot_poly(pmesh, phases, plot_files=['polymesh.png'], plot_axes=True, for fname in plot_files: if n_dim == 3: _misc.axisEqual3D(ax) - plt.subplots_adjust(left=0, bottom=.05, right=1, top=1, wspace=0, hspace=0) + plt.subplots_adjust(left=0, bottom=.05, right=1, top=1, + wspace=0, hspace=0) plt.savefig(fname) else: plt.tight_layout() @@ -878,11 +945,11 @@ def _poly_colors(pmesh, phases, color_by, colormap, n_dim): r_colors = [_phase_color(n, phases) for n in pmesh.phase_numbers] elif color_by == 'seed number': n = max(pmesh.seed_numbers) + 1 - r_colors = [_cm_color(s / (n - 1), colormap) for s in + r_colors = [_cm_color(_cm_frac(s, n), colormap) for s in pmesh.seed_numbers] elif color_by == 'material number': n = len(phases) - r_colors = [_cm_color(p / (n - 1), colormap) for p in + r_colors = [_cm_color(_cm_frac(p, n), colormap) for p in pmesh.phase_numbers] n_seeds = max(pmesh.seed_numbers) + 1 s_colors = ['none' for i in range(n_seeds)] @@ -898,10 +965,10 @@ def _poly_colors(pmesh, phases, color_by, colormap, n_dim): phase_num = s2p[s] color = _phase_color(phase_num, phases) elif color_by == 'seed number': - color = _cm_color(s / (n - 1), colormap) + color = _cm_color(_cm_frac(s, n), colormap) elif color_by == 'material number': n_phases = len(phases) - color = _cm_color(s2p[s] / (n_phases - 1), colormap) + color = _cm_color(_cm_frac(s2p[s], n_phases), colormap) else: color = 'none' colors.append(color) @@ -960,7 +1027,8 @@ def plot_tri(tmesh, phases, seeds, pmesh, plot_files=[], plot_axes=True, plt.clf() plt.close('all') fig = plt.figure() - ax = fig.add_subplot(projection={2: None, 3: Axes3D.name}[n_dim], label='tri') + projection = {2: None, 3: Axes3D.name}[n_dim] + ax = fig.add_subplot(projection=projection, label='tri') if not plot_axes: if n_dim == 2: @@ -970,40 +1038,23 @@ def plot_tri(tmesh, phases, seeds, pmesh, plot_files=[], plot_axes=True, else: ax._axis3don = False - # Determine which facets are visible - vis_regions = set() + # Determine which regions are visible invis_regions = set(range(-6, 0)) - f_front = set([i for i, fn in enumerate(pmesh.facet_neighbors) - if min(fn) < 0]) - while f_front and n_dim > 2: - new_front = set() - for f in f_front: - neighs = set(pmesh.facet_neighbors[f]) - for n in neighs - invis_regions: - p = pmesh.phase_numbers[n] - p_type = phases[p].get('material_type', 'solid') - if p_type in _misc.kw_void: - new_front |= set(pmesh.regions[n]) - else: - vis_regions.add(n) - new_front -= f_front - f_front = new_front - if n_dim < 3: - vis_regions = set(range(len(pmesh.regions))) + vis_regions = _visible_regions(pmesh, phases) # Determine facet colors based on visibility seed_colors = _seed_colors(seeds, phases, color_by, colormap) facet_colors = [] facet_phases = [] - for i, fn in enumerate(pmesh.facet_neighbors): - if _f_plottable(fn, vis_regions, invis_regions): - r = list(set(fn) - invis_regions)[0] + for fn in pmesh.facet_neighbors: + r = _visible_neighbor(fn, vis_regions, invis_regions) + if r is None: + color = 'none' + phase = -1 + else: s = pmesh.seed_numbers[r] color = seed_colors[s] phase = seeds[s].phase - else: - color = 'none' - phase = -1 facet_colors.append(color) facet_phases.append(phase) @@ -1042,7 +1093,8 @@ def plot_tri(tmesh, phases, seeds, pmesh, plot_files=[], plot_axes=True, for fname in plot_files: if n_dim == 3: _misc.axisEqual3D(ax) - plt.subplots_adjust(left=0, bottom=.05, right=1, top=1, wspace=0, hspace=0) + plt.subplots_adjust(left=0, bottom=.05, right=1, top=1, + wspace=0, hspace=0) plt.savefig(fname) else: plt.tight_layout() @@ -1051,6 +1103,65 @@ def plot_tri(tmesh, phases, seeds, pmesh, plot_files=[], plot_axes=True, plt.close('all') +def _visible_regions(pmesh, phases): + """Determine the regions visible from outside the domain + + In 3D, the exterior facets are walked inward: a non-void region behind + a facet is visible, while the facets of a void region are added to the + front, since the regions behind a void can be seen through it. + Each facet is visited at most once, so the walk always terminates, + even when a void region touches the domain boundary. + In 2D, all regions are visible. + + Args: + pmesh (PolyMesh): Polygonal/polyhedral mesh. + phases (list): List of phase dictionaries. + + Returns: + set: Numbers of the visible regions. + + """ + n_dim = len(pmesh.points[0]) + if n_dim < 3: + return set(range(len(pmesh.regions))) + + vis_regions = set() + checked_regions = set() + front = set([i for i, fn in enumerate(pmesh.facet_neighbors) + if min(fn) < 0]) + visited = set(front) + while front: + new_front = set() + for f in front: + for n in pmesh.facet_neighbors[f]: + if n < 0 or n in checked_regions: + continue + checked_regions.add(n) + p = pmesh.phase_numbers[n] + p_type = phases[p].get('material_type', 'solid') + if p_type in _misc.kw_void: + new_front |= set(pmesh.regions[n]) - visited + else: + vis_regions.add(n) + visited |= new_front + front = new_front + return vis_regions + + +def _visible_neighbor(n_pair, vis, invis): + """Visible (non-void, non-wall) region on either side of a facet + + Returns None if the facet is not plottable or neither neighbor + is a visible region. + """ + if not _f_plottable(n_pair, vis, invis): + return None + for n in n_pair: + if n in vis: + return n + return None + + def _f_plottable(n_pair, vis, invis): if set(n_pair) <= vis or set(n_pair) <= invis: return False @@ -1077,11 +1188,11 @@ def dict_convert(dictionary, filepath='.'): First, if the value of ``dist_type`` is ``cdf``, then the remaining key should be ``filename`` and its value should be the path to a CSV file, where each row contains the (x, CDF) points along the CDF curve. - Second, if the value of ``dist_type`` is ``histogram``, then the remaining - key should also be ``filename`` and its value should be the path to a CSV - file. - For the histogram, the first row of this CDF should be the *n* bin heights - and the second row should be the *n+1* bin locations. + Second, if the value of ``dist_type`` is ``histogram`` (or its alias + ``pdf``), then the remaining key should also be ``filename`` and its value + should be the path to a CSV file. + For the histogram, the first row of this file should be the *n* bin + heights and the second row should be the *n+1* bin locations. Additionally, if a key in the dictionary contains ``filename`` or ``directory`` and the value associated with that key is a relative path, @@ -1105,7 +1216,7 @@ def dict_convert(dictionary, filepath='.'): # Convert lists if isinstance(dictionary, list): - return [dict_convert(d) for d in dictionary] + return [dict_convert(d, filepath) for d in dictionary] # Convert strings if isinstance(dictionary, str): @@ -1150,23 +1261,46 @@ def _dist_convert(dist_dict): del params['dist_type'] if dist_type == 'cdf': - cdf_filename = params['filename'] - with open(cdf_filename, 'r') as file: - cdf = [[float(s) for s in line.split(',')] for line in file] + cdf = _read_csv(params['filename']) bin_bnds = [x for x, _ in cdf] bin_cnts = [cdf[i + 1][1] - cdf[i][1] for i in range(len(cdf) - 1)] - return scipy.stats.rv_histogram(tuple([bin_cnts, bin_bnds])) + return _rv_histogram(bin_cnts, bin_bnds, density=False) - elif dist_type == 'histogram': - hist_filename = params['filename'] - with open(hist_filename, 'r') as file: - hist = [[float(s) for s in line.split(',')] for line in file] - return scipy.stats.rv_histogram(tuple(hist)) + elif dist_type in ('histogram', 'pdf'): + bin_hgts, bin_bnds = _read_csv(params['filename']) + return _rv_histogram(bin_hgts, bin_bnds, density=True) else: return scipy.stats.__dict__[dist_type](**params) +def _read_csv(filename): + """Read the numbers in a CSV file, skipping blank lines""" + with open(filename, 'r') as file: + lines = [line for line in file if line.strip()] + return [[float(s) for s in line.split(',')] for line in lines] + + +def _rv_histogram(bin_vals, bin_bnds, density): + """Histogram distribution from bin values and boundaries + + The CDF increments of a ``cdf`` file are probability masses, so they + are passed with ``density=False``; otherwise SciPy would re-weight the + bins by their widths whenever the bin boundaries are not evenly spaced. + The bin heights of a ``pdf``/``histogram`` file are densities. + """ + hist = (list(bin_vals), list(bin_bnds)) + try: + return scipy.stats.rv_histogram(hist, density=density) + except TypeError: + # SciPy < 1.11 has no density keyword and treats the values as + # densities, so masses are converted to densities beforehand. + if not density: + widths = np.diff(np.array(bin_bnds, dtype='float')) + hist = (list(np.array(bin_vals, dtype='float') / widths), hist[1]) + return scipy.stats.rv_histogram(hist) + + if __name__ == '__main__': main() diff --git a/src/microstructpy/verification.py b/src/microstructpy/verification.py index c31c1b09..49445bfc 100644 --- a/src/microstructpy/verification.py +++ b/src/microstructpy/verification.py @@ -410,10 +410,7 @@ def plot_distributions(seeds, phases, dirname='.', ext='png', poly_mesh=None, plt.gca().add_artist(color_legend) plt.grid(True) - xlbl = ' '.join([s.capitalize() for s in kw.split('_')]) - xlbl = xlbl.replace('Rad', '(radians)').replace('Deg', '(degrees)') - xlbl = xlbl.replace('Orientation', 'Orientation (degrees)') - plt.xlabel(xlbl) + plt.xlabel(_axis_label(kw)) plt.ylabel('Probability Density Function') plt.ylim([0, 1.1 * ymax]) @@ -456,10 +453,7 @@ def plot_distributions(seeds, phases, dirname='.', ext='png', poly_mesh=None, plt.gca().add_artist(color_legend) plt.grid(True) - xlbl = ' '.join([s.capitalize() for s in kw.split('_')]) - xlbl = xlbl.replace('Rad', '(radians)').replace('Deg', '(degrees)') - xlbl = xlbl.replace('Orientation', 'Orientation (degrees)') - plt.xlabel(xlbl) + plt.xlabel(_axis_label(kw)) plt.ylabel('Cumulative Distribution Function') plt.ylim([0, 1]) @@ -478,23 +472,34 @@ def plot_distributions(seeds, phases, dirname='.', ext='png', poly_mesh=None, plt.close() +def _axis_label(kw): + """Axis label for a phase keyword, e.g. 'angle_rad' -> 'Angle (radians)' + """ + units = {'rad': '(radians)', 'deg': '(degrees)'} + words = [units.get(s, s.capitalize()) for s in kw.split('_')] + label = ' '.join(words) + if kw == 'orientation': + label += ' (degrees)' + return label + + def _plot_inp_pdf(kw, i, phase): ymax = 0 inp_dist = phase[kw] color = phase.get('color', 'C' + str(i % 10)) - if kw in ori_deg_kws and phase[kw] == 'random': + if kw in ori_deg_kws and _is_random(inp_dist): x_plt = [0, 360] y_plt = [1 / 360, 1 / 360] plt.plot(x_plt, y_plt, color=color, ls=':') ymax = 1 / 360 - elif kw in ori_rad_kws and phase[kw] == 'random': + elif kw in ori_rad_kws and _is_random(inp_dist): x_plt = [0, 2 * np.pi] y_plt = [0.5 / np.pi, 0.5 / np.pi] plt.plot(x_plt, y_plt, color=color, ls=':') ymax = y_plt[0] - elif phase[kw] == 'random': + elif _is_random(inp_dist): e_str = 'Cannot create PDF for random setting' e_str += ' of keyword <' + str(kw) + '>' raise NotImplementedError(e_str) @@ -505,7 +510,7 @@ def _plot_inp_pdf(kw, i, phase): inp_deg = np.rad2deg(np.arctan2(st, ct)) plt.plot([inp_deg, inp_deg], [0, 1e12], color=color, ls=':') - elif isinstance(inp_dist, list): + elif _is_vector(inp_dist): for j, dist in enumerate(inp_dist): try: lb = dist.ppf(1e-3) @@ -540,16 +545,16 @@ def _plot_inp_cdf(kw, i, phase): inp_dist = phase[kw] color = phase.get('color', 'C' + str(i % 10)) - if kw in ori_deg_kws and phase[kw] == 'random': + if kw in ori_deg_kws and _is_random(inp_dist): x_plt = [0, 360] y_plt = [0, 1] plt.plot(x_plt, y_plt, color=color, ls=':') - elif kw in ori_rad_kws and phase[kw] == 'random': + elif kw in ori_rad_kws and _is_random(inp_dist): x_plt = [0, 2 * np.pi] y_plt = [0, 1] plt.plot(x_plt, y_plt, color=color, ls=':') - elif phase[kw] == 'random': + elif _is_random(inp_dist): e_str = 'Cannot create CDF for random setting' e_str += ' of keyword <' + str(kw) + '>' raise NotImplementedError(e_str) @@ -560,7 +565,7 @@ def _plot_inp_cdf(kw, i, phase): inp_deg = np.rad2deg(np.arctan2(st, ct)) plt.plot([inp_deg, inp_deg], [0, 1], color=color, ls=':') - elif isinstance(inp_dist, list): + elif _is_vector(inp_dist): for j, dist in enumerate(inp_dist): try: x_plt = dist.ppf(quants) @@ -610,8 +615,9 @@ def _plot_out_pdf(kw, i, phase, comp_phase): line_colors.append(Line2D([0], [0], color=color)) line_labels.append(name) - elif isinstance(inp_dist, list): - for j, vals in enumerate(comp_vals): + elif _is_vector(inp_dist): + # vector-valued parameter: one histogram per component + for j, vals in enumerate(comp_vals.T): ys, xbs, _ = plt.hist(vals, density=True, histtype='step', color=color) ymax = max(ymax, np.max(ys)) @@ -656,8 +662,9 @@ def _plot_out_cdf(kw, i, phase, comp_phase): line_colors.append(Line2D([0], [0], color=color)) line_labels.append(name) - elif isinstance(inp_dist, list): - for j, vals in enumerate(comp_vals): + elif _is_vector(inp_dist): + # vector-valued parameter: one curve per component + for j, vals in enumerate(comp_vals.T): x_plt = np.quantile(vals, quants) y_plt = quants @@ -779,9 +786,10 @@ def write_mle_phases(inp_phases, out_phases, filename='mles.txt'): row_dict = {'i': i, 'name': name, 'kw': kw} rows_dict.append(row_dict) continue - if isinstance(inp_dist, list): + if _is_vector(inp_dist): for j in range(len(inp_dist)): - row_dict = {'i': i, 'name': name, 'kw': kw + '[' + j + ']'} + kw_j = kw + '[' + str(j) + ']' + row_dict = {'i': i, 'name': name, 'kw': kw_j} inp_dict = _dist_dict(inp_dist[j]) out_dict = _dist_dict(out_dist[j]) for key in inp_dict: @@ -845,10 +853,10 @@ def _mle_hdr(all_kws): h2 = 'Parameter' elif kw.endswith('_inp'): h1 = 'Input' - h2 = kw.rstrip('_inp') + h2 = kw[:-len('_inp')] elif kw.endswith('_out'): h1 = 'Output' - h2 = kw.rstrip('_out') + h2 = kw[:-len('_out')] else: raise ValueError('Cannot creating heading for keyword ' + str(kw)) hdr1.append(h1) @@ -878,55 +886,117 @@ def error_stats(fit_seeds, seeds, phases, poly_mesh=None, verif_mask=None): Returns: list: List with the same size and dictionary keywords as phases, but with error statistics dictionaries in each entry. + Vector-valued parameters (e.g. ``side_lengths``) get a list with + one dictionary per component. + + .. note:: + + In 2D, a ``random`` orientation (``angle``, ``angle_deg``, + ``angle_rad`` or ``orientation``) is compared against a uniform + distribution over the full circle. Orientation matrices are compared + through their rotation angle, in degrees. In 3D, the statistics of + the ``orientation`` are not computed. """ if verif_mask is None: verif_mask = np.full(len(seeds), True) - # Organize the geometry values - init_phases = _phase_values(seeds, phases, verif_mask=verif_mask) - outp_phases = _phase_values(fit_seeds, phases, poly_mesh, verif_mask) + n_dim = len(seeds[0].position) - err_phases = [] - for i in range(len(phases)): - i_phase = init_phases[i] - o_phase = outp_phases[i] - phase = phases[i] + # Work on a copy of the phases: random orientations are replaced by + # their distributions without altering the caller's input. + phases = copy.deepcopy(phases) + for phase in phases: for kw in phase: - if kw in ('angle', 'angle_deg') and phase[kw] == 'random': + if not _is_random(phase[kw]): + continue + if kw in ('angle', 'angle_deg'): phase[kw] = scipy.stats.uniform(loc=0, scale=360) - if kw == 'angle_rad': + elif kw == 'angle_rad': phase[kw] = scipy.stats.uniform(loc=0, scale=2 * np.pi) - err_io = {kw: _kw_errs(i_phase[kw], o_phase[kw]) for kw in i_phase} - err_po = {kw: _kw_stats(phase[kw], o_phase[kw]) for kw in o_phase} + # Organize the geometry values + init_phases = _phase_values(seeds, phases, verif_mask=verif_mask) + outp_phases = _phase_values(fit_seeds, phases, poly_mesh, verif_mask) + err_phases = [] + for i_phase, o_phase, phase in zip(init_phases, outp_phases, phases): err_phase = {} for kw in i_phase: - if kw == 'orientation': - err_phase[kw] = {} - continue - val = err_io[kw].copy() - val.update(err_po[kw]) - err_phase[kw] = val + i_vals = i_phase[kw] + o_vals = o_phase.get(kw, []) + inp_dist = phase[kw] + + if kw in ('orientation', 'matrix'): + if n_dim != 2: + err_phase[kw] = {} + continue + + # 2D: compare the rotation angles, in degrees + if _is_random(inp_dist): + inp_dist = scipy.stats.uniform(loc=0, scale=360) + i_vals = _matrix_angles(i_vals, wrap=True) + o_vals = _matrix_angles(o_vals, wrap=True) + else: + inp_dist = _matrix_angles([inp_dist])[0] + i_vals = _matrix_angles(i_vals) + o_vals = _matrix_angles(o_vals) + + errs = _kw_errs(i_vals, o_vals) + stats = _kw_stats(inp_dist, o_vals) + err_phase[kw] = _merge_stats(errs, stats) err_phases.append(err_phase) return err_phases -def _kw_errs(y_exp, y_act): - if np.array(y_exp).ndim > 1: - return [_kw_errs(*tup) for tup in zip(y_exp, y_act)] +def _matrix_angles(matrices, wrap=False): + """Rotation angles, in degrees, of 2D rotation matrices + + None entries are preserved. If ``wrap`` is True, the angles are in + [0, 360), otherwise in (-180, 180]. + """ + angles = [] + for m in matrices: + if m is None: + angles.append(None) + continue + ang = np.rad2deg(np.arctan2(m[1][0], m[0][0])) + if wrap: + ang = np.mod(ang, 360) + angles.append(ang) + return angles + + +def _merge_stats(errs, stats): + """Merge error and statistics dictionaries (or per-component lists)""" + if isinstance(errs, list) or isinstance(stats, list): + if not isinstance(errs, list): + errs = [errs for _ in stats] + if not isinstance(stats, list): + stats = [stats for _ in errs] + return [_merge_stats(e, s) for e, s in zip(errs, stats)] + merged = dict(errs) + merged.update(stats) + return merged + + +def _kw_errs(y_exp, y_act): errs = {} - mask = np.array([y_a is not None for y_a in y_act]) - if not np.any(mask): + pairs = [(y_e, y_a) for y_e, y_a in zip(y_exp, y_act) + if y_e is not None and y_a is not None] + if not pairs: return errs - y_expect = np.array(y_exp)[mask] - y_actual = np.array([y_a for y_a in y_act if y_a is not None]) + y_expect = np.array([y_e for y_e, _ in pairs], dtype='float') + y_actual = np.array([y_a for _, y_a in pairs], dtype='float') + + if y_expect.ndim > 1: + # vector-valued parameter: errors per component + return [_kw_errs(*tup) for tup in zip(y_expect.T, y_actual.T)] r = y_actual - y_expect @@ -960,16 +1030,28 @@ def _r2(y_act, y_exp): r_ybar = y_act - y_bar mse_baseline = np.mean(r_ybar * r_ybar) + if mse_baseline == 0: + # constant actual values: R^2 is 1 for a perfect match and + # undefined otherwise + return 1.0 if mse == 0 else float('nan') + coeff_det = 1 - (mse / mse_baseline) return coeff_det def _kw_stats(dist_exp, y_act): - if isinstance(dist_exp, list): - return [_kw_stats(*tup) for tup in zip(dist_exp, y_act)] + y_actual = [y_a for y_a in y_act if y_a is not None] + + if _is_vector(dist_exp): + # vector-valued parameter: statistics per component + if y_actual: + comps = np.array(y_actual, dtype='float').T + else: + comps = [[] for _ in dist_exp] + return [_kw_stats(*tup) for tup in zip(dist_exp, comps)] stats = {} - y_actual = np.array([y_a for y_a in y_act if y_a is not None]) + y_actual = np.array(y_actual, dtype='float') if len(y_actual) == 0: return stats @@ -1027,13 +1109,14 @@ def write_error_stats(errs, phases, filename='error_stats.txt'): name = phase.get('name', 'Material ' + str(i + 1)) kws = set(err_dict.keys()) - set(_misc.gen_kws) - for kw in kws: + for kw in sorted(kws): err_metrics = err_dict[kw] - inp_dist = phase[kw] - if isinstance(inp_dist, list): - for j in range(len(inp_dist)): - row_dict = {'i': i, 'name': name, 'kw': kw + '[' + j + ']'} - row_dict.update(err_metrics[j]) + if isinstance(err_metrics, list): + # vector-valued parameter: one row per component + for j, metrics in enumerate(err_metrics): + kw_j = kw + '[' + str(j) + ']' + row_dict = {'i': i, 'name': name, 'kw': kw_j} + row_dict.update(metrics) rows_dict.append(row_dict) else: row_dict = {'i': i, 'name': name, 'kw': kw} @@ -1110,7 +1193,8 @@ def _safe_mean(x): def _safe_rvs(x, size=1): - if isinstance(x, list): + if _is_vector(x): + # vector-valued parameter: samples are (size, n_components) return np.array([_safe_rvs(xi, size) for xi in x]).T try: @@ -1120,9 +1204,34 @@ def _safe_rvs(x, size=1): return samples +def _is_random(val): + """True if the value is the string 'random'""" + return isinstance(val, str) and val.strip().lower() == 'random' + + +def _is_vector(val): + """True if the value has one entry per component (list, tuple, array)""" + return isinstance(val, (list, tuple, np.ndarray)) + + +def _numeric_values(vals): + """True if the values contain at least one numeric (non-None) entry""" + known = [v for v in vals if v is not None] + if not known: + return False + try: + np.asarray(known, dtype='float') + except (TypeError, ValueError): + return False + return True + + def _phase_values(seeds, phases, poly_mesh=None, verif_mask=None): """Takes the properties of the seeds and organizes them like the phases + Phase keywords that are not (numeric) geometry attributes, such as + ``max_volume`` or free-text fields, are ignored. + """ if verif_mask is None: verif_mask = np.full(len(seeds), True) @@ -1152,7 +1261,8 @@ def _phase_values(seeds, phases, poly_mesh=None, verif_mask=None): else: vals = [_getattr(s.geometry, kw) for s in phase_seeds] - comp_phase[kw] = vals + if _numeric_values(vals): + comp_phase[kw] = vals except AttributeError: pass @@ -1169,8 +1279,10 @@ def _getattr(inst, kw): def _mle_dist(values, dist): - if isinstance(dist, list): - return [_mle_dist(*tup) for tup in zip(values, dist)] + if _is_vector(dist): + # vector-valued parameter: one MLE per component + comps = np.array(values, dtype='float').T + return [_mle_dist(*tup) for tup in zip(comps, dist)] if not (hasattr(dist, 'dist') or isinstance(dist, hist_class)): return np.mean(values) diff --git a/tests/cli/test_cli_fixes.py b/tests/cli/test_cli_fixes.py new file mode 100644 index 00000000..370e9ceb --- /dev/null +++ b/tests/cli/test_cli_fixes.py @@ -0,0 +1,465 @@ +"""Tests for the CLI and input parsing fixes (NOTES.md 5.4, table D)""" + +import contextlib +import copy +import os +import shutil +import signal +import sys + +import numpy as np +import pytest + +from microstructpy import _misc +from microstructpy import cli +from microstructpy import geometry +from microstructpy import seeding +from microstructpy.meshing import PolyMesh +from microstructpy.meshing import TriMesh +from microstructpy.seeding import Seed + +PKG_EXAMPLES = os.path.join(os.path.dirname(cli.__file__), 'examples') + +HIST_CSV = '0.5, 1\n1, 2, 2.5\n' +CDF_CSV = '1, 0\n2, 0.5\n2.5, 1\n' + +MATERIAL_XML = """ + + {name} + circle + {size} + +""" + +CDF_MATERIAL_XML = """ + + {name} + circle + + cdf + {filename} + + +""" + +DOMAIN_XML = """ + + square + 1 + +""" + + +def _tiny_case(): + """A 2D case with a handful of circles, runs in a fraction of a second""" + phases = [{'shape': 'circle', 'size': 0.4}] + domain = geometry.factory('square', side_length=1) + return phases, domain + + +def _box_polymesh(seed_numbers, phase_numbers): + """A 2x1x1 box split into two unit cubes, both touching the boundary + + Point index = 4 x + 2 y + z, for x in {0, 1, 2} and y, z in {0, 1}. + """ + points = [[x, y, z] for x in range(3) for y in range(2) + for z in range(2)] + facets = [[0, 2, 3, 1], # 0: x = 0 + [0, 4, 5, 1], # 1: y = 0, cube 0 + [2, 6, 7, 3], # 2: y = 1, cube 0 + [0, 4, 6, 2], # 3: z = 0, cube 0 + [1, 5, 7, 3], # 4: z = 1, cube 0 + [4, 6, 7, 5], # 5: x = 1, shared + [4, 8, 9, 5], # 6: y = 0, cube 1 + [6, 10, 11, 7], # 7: y = 1, cube 1 + [4, 8, 10, 6], # 8: z = 0, cube 1 + [5, 9, 11, 7], # 9: z = 1, cube 1 + [8, 10, 11, 9]] # 10: x = 2 + regions = [[0, 1, 2, 3, 4, 5], [5, 6, 7, 8, 9, 10]] + facet_neighbors = [[0, -1], [0, -3], [0, -4], [0, -5], [0, -6], [0, 1], + [1, -3], [1, -4], [1, -5], [1, -6], [1, -2]] + volumes = [1.0, 1.0] + return PolyMesh(points, facets, regions, seed_numbers, phase_numbers, + facet_neighbors, volumes) + + +def _cube_trimesh(): + """Tetrahedral mesh of the first unit cube of :func:`_box_polymesh` + + The element attributes are the seed number (0) and the facet attributes + are the numbers of the polymesh facets the triangles belong to. + """ + def ind(x, y, z): + return 4 * x + 2 * y + z + + points = [[x, y, z] for x in range(2) for y in range(2) + for z in range(2)] + o, e = ind(0, 0, 0), ind(1, 1, 1) + path = [ind(1, 0, 0), ind(1, 1, 0), ind(0, 1, 0), ind(0, 1, 1), + ind(0, 0, 1), ind(1, 0, 1)] + elements = [[o, path[i], path[(i + 1) % 6], e] for i in range(6)] + + quads = {0: [0, 2, 3, 1], 1: [0, 4, 5, 1], 2: [2, 6, 7, 3], + 3: [0, 4, 6, 2], 4: [1, 5, 7, 3], 5: [4, 6, 7, 5]} + facets = [] + facet_attributes = [] + for f_num, quad in quads.items(): + facets.append(quad[:3]) + facets.append([quad[0], quad[2], quad[3]]) + facet_attributes.extend([f_num, f_num]) + return TriMesh(points, elements, [0] * len(elements), facets, + facet_attributes) + + +@contextlib.contextmanager +def _time_limit(seconds): + """Fail if the block runs longer than the given time (POSIX only) + + An alarm signal interrupts an endless loop in pure Python code, which + is how the visibility walk used to fail. + """ + if not hasattr(signal, 'SIGALRM'): + yield + return + + def handler(signum, frame): + raise RuntimeError('the function did not terminate') + + old_handler = signal.signal(signal.SIGALRM, handler) + signal.setitimer(signal.ITIMER_REAL, seconds) + try: + yield + finally: + signal.setitimer(signal.ITIMER_REAL, 0) + signal.signal(signal.SIGALRM, old_handler) + + +# --------------------------------------------------------------------------- # +# D1: cdf bins are masses, not densities # +# --------------------------------------------------------------------------- # +def test_cdf_matches_equivalent_histogram(tmp_path): + hist_file = tmp_path / 'hist.csv' + hist_file.write_text(HIST_CSV) + cdf_file = tmp_path / 'cdf.csv' + cdf_file.write_text(CDF_CSV) + + d_hist = cli._dist_convert({'dist_type': 'histogram', + 'filename': str(hist_file)}) + d_cdf = cli._dist_convert({'dist_type': ' cdf ', + 'filename': str(cdf_file)}) + for dist in (d_hist, d_cdf): + assert np.isclose(dist.cdf(2), 0.5) + assert np.isclose(dist.mean(), 1.875) + + +def test_cdf_file_is_reproduced(tmp_path): + basename = 'aphanitic_cdf.csv' + src = os.path.join(PKG_EXAMPLES, basename) + if not os.path.exists(src): + src = os.path.join(PKG_EXAMPLES, basename) + if not os.path.exists(src): + pytest.skip(basename + ' not available') + dst = tmp_path / basename + shutil.copy(src, str(dst)) + + xs, cdf_exp = np.loadtxt(str(dst), delimiter=',').T + dist = cli._dist_convert({'dist_type': 'cdf', 'filename': str(dst)}) + cdf_act = dist.cdf(xs) + + # The first value can be non-zero in the file while the histogram + # starts at 0 there; the remaining mismatch is the normalization. + assert np.max(np.abs(cdf_act - cdf_exp)) < 2e-2 + assert np.abs(dist.mean() - np.trapz(1 - cdf_exp, xs) - xs[0]) < 1e-2 + + +def test_pdf_is_alias_of_histogram(tmp_path): + hist_file = tmp_path / 'hist.csv' + hist_file.write_text(HIST_CSV) + + dist = cli._dist_convert({'dist_type': ' PDF ', + 'filename': str(hist_file)}) + assert np.isclose(dist.cdf(2), 0.5) + assert np.isclose(dist.mean(), 1.875) + + +# --------------------------------------------------------------------------- # +# D2: plot_tri visibility walk with a void region on the boundary # +# --------------------------------------------------------------------------- # +def test_visible_regions_void_on_boundary(): + pmesh = _box_polymesh([0, 1], [0, 1]) + phases = [{'material_type': 'solid'}, {'material_type': 'void'}] + + with _time_limit(10): + vis = cli._visible_regions(pmesh, phases) + assert vis == {0} + + # the shared facet shows the solid region, the void's exterior nothing + invis = set(range(-6, 0)) + assert cli._visible_neighbor([0, 1], vis, invis) == 0 + assert cli._visible_neighbor([1, 0], vis, invis) == 0 + assert cli._visible_neighbor([1, -2], vis, invis) is None + assert cli._visible_neighbor([0, -1], vis, invis) == 0 + + +def test_visible_regions_all_solid_and_2d(): + pmesh = _box_polymesh([0, 1], [0, 0]) + assert cli._visible_regions(pmesh, [{}]) == {0, 1} + + points = [[0, 0], [1, 0], [1, 1], [0, 1]] + facets = [[0, 1], [1, 2], [2, 3], [3, 0]] + pmesh_2d = PolyMesh(points, facets, [[0, 1, 2, 3]], [0], [0], + [[0, -3], [0, -2], [0, -4], [0, -1]], [1.0]) + assert cli._visible_regions(pmesh_2d, [{}]) == {0} + + +def test_plot_tri_void_on_boundary(tmp_path): + pmesh = _box_polymesh([0, 1], [0, 1]) + tmesh = _cube_trimesh() + seeds = seeding.SeedList([Seed.factory('sphere', phase=0, r=0.5), + Seed.factory('sphere', phase=1, r=0.5)]) + phases = [{'name': 'Solid', 'material_type': 'solid', 'color': 'C0'}, + {'name': 'Hole', 'material_type': 'void', 'color': 'C1'}] + + plot_file = tmp_path / 'trimesh.png' + with _time_limit(30): + cli.plot_tri(tmesh, phases, seeds, pmesh, [str(plot_file)]) + assert plot_file.exists() + + +# --------------------------------------------------------------------------- # +# D3: relative filenames inside repeated tags # +# --------------------------------------------------------------------------- # +def test_relative_filename_in_repeated_materials(tmp_path, monkeypatch): + (tmp_path / 'sizes_cdf.csv').write_text(CDF_CSV) + mats = ''.join([CDF_MATERIAL_XML.format(name=n, filename='sizes_cdf.csv') + for n in ('A', 'B')]) + xml_file = tmp_path / 'input.xml' + xml_file.write_text('' + mats + DOMAIN_XML + '') + + elsewhere = tmp_path / 'elsewhere' + elsewhere.mkdir() + monkeypatch.chdir(str(elsewhere)) + + in_data = cli.read_input(str(xml_file)) + phases = in_data['material'] + assert len(phases) == 2 + for phase in phases: + assert np.isclose(phase['size'].cdf(2), 0.5) + + +# --------------------------------------------------------------------------- # +# D8, D9: from_str booleans, infinities # +# --------------------------------------------------------------------------- # +def test_from_str_words_containing_booleans(): + assert _misc.from_str('true_cdf.csv') == 'true_cdf.csv' + assert _misc.from_str('Falsework') == 'Falsework' + assert _misc.from_str('/data/true_north/cdf.csv') == \ + '/data/true_north/cdf.csv' + + +def test_from_str_booleans(): + assert _misc.from_str('True') is True + assert _misc.from_str('true') is True + assert _misc.from_str('FALSE') is False + assert _misc.from_str(' false ') is False + assert _misc.from_str('(true, false)') == (True, False) + assert _misc.from_str('[True, FALSE]') == [True, False] + + +def test_from_str_special_floats(): + assert _misc.from_str('inf') == float('inf') + assert _misc.from_str(' inf ') == float('inf') + assert _misc.from_str('-inf') == -float('inf') + assert np.isnan(_misc.from_str('nan')) + assert _misc.from_str('1e-3') == 1e-3 + assert isinstance(_misc.from_str('2'), int) + + +def test_dict_convert_inf_setting(): + settings = cli.dict_convert({'settings': {'mesh_max_volume': ' inf '}}) + val = settings['settings']['mesh_max_volume'] + assert isinstance(val, float) + assert val == float('inf') + + +# --------------------------------------------------------------------------- # +# D10: run() must not modify the caller's dictionaries # +# --------------------------------------------------------------------------- # +def test_run_does_not_mutate_arguments(tmp_path): + phases, domain = _tiny_case() + rng_seeds = {'position': 1, 'size': 2} + filetypes = {'seeds': ['txt'], 'seeds_plot': [], 'poly_plot': [], + 'tri_plot': []} + rng_exp = copy.deepcopy(rng_seeds) + ft_exp = copy.deepcopy(filetypes) + + for dirname, restart in (('a', True), ('b', False)): + cli.run(phases, domain, restart=restart, + directory=str(tmp_path / dirname), filetypes=filetypes, + rng_seeds=rng_seeds, verify=False) + assert rng_seeds == rng_exp + assert filetypes == ft_exp + + seeds_a = (tmp_path / 'a' / 'seeds.txt').read_text() + seeds_b = (tmp_path / 'b' / 'seeds.txt').read_text() + assert seeds_a == seeds_b + assert not (tmp_path / 'a' / 'seeds.png').exists() + + +def test_run_default_dicts_are_fresh(tmp_path): + phases, domain = _tiny_case() + for dirname in ('a', 'b'): + cli.run(phases, domain, restart=False, + directory=str(tmp_path / dirname), + filetypes={'seeds': 'txt', 'seeds_plot': [], + 'poly_plot': [], 'tri_plot': []}) + seeds_a = (tmp_path / 'a' / 'seeds.txt').read_text() + seeds_b = (tmp_path / 'b' / 'seeds.txt').read_text() + assert seeds_a == seeds_b + + +# --------------------------------------------------------------------------- # +# D11: plus own # +# --------------------------------------------------------------------------- # +def test_include_and_own_materials(tmp_path): + inc_mats = ''.join([MATERIAL_XML.format(name=n, size=0.1) + for n in ('A', 'B')]) + (tmp_path / 'materials.xml').write_text('' + inc_mats + '') + + own_mat = MATERIAL_XML.format(name='C', size=0.3) + xml_file = tmp_path / 'input.xml' + xml_file.write_text('\n materials.xml \n' + + own_mat + DOMAIN_XML + '') + + in_data = cli.read_input(str(xml_file)) + names = [p['name'] for p in in_data['material']] + assert names == ['A', 'B', 'C'] + assert [p['size'] for p in in_data['material']] == [0.1, 0.1, 0.3] + + +def test_two_includes_with_materials(tmp_path): + for fname, name in (('a.xml', 'A'), ('b.xml', 'B')): + mat = MATERIAL_XML.format(name=name, size=0.1) + (tmp_path / fname).write_text('' + mat + '') + xml_file = tmp_path / 'input.xml' + xml_file.write_text(' a.xml ' + + ' b.xml ' + DOMAIN_XML + + '') + + in_data = cli.read_input(str(xml_file)) + assert [p['name'] for p in in_data['material']] == ['A', 'B'] + + +def test_include_scalar_override(tmp_path): + (tmp_path / 'base.xml').write_text(' circle ' + ' 0.1 ') + xml_file = tmp_path / 'input.xml' + xml_file.write_text(' base.xml ' + ' 0.3 ' + DOMAIN_XML + + '') + in_data = cli.read_input(str(xml_file)) + assert in_data['material']['shape'] == 'circle' + assert in_data['material']['size'] == 0.3 + + +def test_empty_tag_is_allowed(tmp_path): + xml_file = tmp_path / 'input.xml' + xml_file.write_text(' ' + ' False ') + file_dict = cli.input2dict(str(xml_file)) + in_data = cli.dict_convert(file_dict['input'], str(tmp_path)) + assert in_data['settings']['seeds_kwargs'] == {} + assert in_data['settings']['verbose'] is False + + +# --------------------------------------------------------------------------- # +# D12: coloring by number with a single material or seed # +# --------------------------------------------------------------------------- # +def test_color_by_number_single_item(): + color = cli._phase_color_by(0, [{'color': 'r'}], + color_by='material number') + assert len(color) == 4 + + seeds = seeding.SeedList([Seed.factory('circle', phase=0, r=1)]) + for color_by in ('seed number', 'material number'): + colors = cli._seed_colors(seeds, [{}], color_by=color_by) + assert len(colors) == 1 + assert len(colors[0]) == 4 + + # 3D and 2D polygon meshes with a single seed and phase + pmesh = _box_polymesh([0, 0], [0, 0]) + for color_by in ('seed number', 'material number'): + colors = cli._poly_colors(pmesh, [{}], color_by, 'viridis', 3) + assert len(colors) == 1 + + points = [[0, 0], [1, 0], [1, 1], [0, 1]] + facets = [[0, 1], [1, 2], [2, 3], [3, 0]] + pmesh_2d = PolyMesh(points, facets, [[0, 1, 2, 3]], [0], [0], + [[0, -3], [0, -2], [0, -4], [0, -1]], [1.0]) + for color_by in ('seed number', 'material number'): + colors = cli._poly_colors(pmesh_2d, [{}], color_by, 'viridis', 2) + assert len(colors) == 1 + + +# --------------------------------------------------------------------------- # +# Minor: main() exit code, types, mesher case, edgecolor # +# --------------------------------------------------------------------------- # +def test_main_exits_on_missing_input_file(tmp_path, monkeypatch, capsys): + pattern = str(tmp_path / 'missing*.xml') + monkeypatch.setattr(sys, 'argv', ['microstructpy', pattern]) + with pytest.raises(SystemExit) as exc_info: + cli.main() + assert exc_info.value.code != 0 + assert 'missing' in capsys.readouterr().err + + +def test_unsupported_tri_type_error(tmp_path): + phases, domain = _tiny_case() + with pytest.raises(ValueError, match='nonsense'): + cli.run(phases, domain, restart=False, directory=str(tmp_path), + filetypes={'tri': 'nonsense'}) + + +def test_mesher_name_case_insensitive(tmp_path, monkeypatch): + calls = [] + + class FakeRaster(object): + @classmethod + def from_polymesh(cls, *args, **kwargs): + calls.append('raster') + return cls() + + class FakeTri(object): + @classmethod + def from_polymesh(cls, *args, **kwargs): + calls.append('tri') + return cls() + + monkeypatch.setattr(cli, 'RasterMesh', FakeRaster) + monkeypatch.setattr(cli, 'TriMesh', FakeTri) + + phases, domain = _tiny_case() + filetypes = {'seeds_plot': [], 'poly_plot': [], 'tri_plot': []} + cli.run(phases, domain, restart=False, directory=str(tmp_path), + filetypes=filetypes, mesher=' Raster ') + assert calls == ['raster'] + + +def test_plot_poly_pops_singular_edgecolor(tmp_path, monkeypatch): + points = [[0, 0], [1, 0], [1, 1], [0, 1]] + facets = [[0, 1], [1, 2], [2, 3], [3, 0]] + pmesh = PolyMesh(points, facets, [[0, 1, 2, 3]], [0], [0], + [[0, -3], [0, -2], [0, -4], [0, -1]], [1.0]) + received = {} + + def fake_plot_facets(self, **kwargs): + received.update(kwargs) + + monkeypatch.setattr(PolyMesh, 'plot_facets', fake_plot_facets) + monkeypatch.setattr(PolyMesh, 'plot', lambda self, **kwargs: None) + + plot_file = str(tmp_path / 'polymesh.png') + cli.plot_poly(pmesh, [{}], [plot_file], edgecolor='none') + assert 'edgecolor' not in received + assert 'edgecolors' not in received + assert received['color'] == ['none'] * 4 diff --git a/tests/test_verification_fixes.py b/tests/test_verification_fixes.py new file mode 100644 index 00000000..5fef93c4 --- /dev/null +++ b/tests/test_verification_fixes.py @@ -0,0 +1,243 @@ +"""Tests for the verification fixes (NOTES.md 5.4, table D, items D4-D7)""" + +import numpy as np +import scipy.stats + +from microstructpy import seeding +from microstructpy import verification +from microstructpy.seeding import Seed + + +def _ellipse_seeds(angles, rng=None, angle_kw='angle_rad'): + """Ellipse seeds with the given angles, optionally perturbed""" + seeds = [] + for angle in angles: + size = 1.0 + if rng is not None: + size += 0.02 * rng.randn() + angle += 0.02 * rng.randn() + kwargs = {'size': size, 'aspect_ratio': 2, angle_kw: angle} + seeds.append(Seed.factory('ellipse', phase=0, **kwargs)) + return seeding.SeedList(seeds) + + +def _rectangle_seeds(n, rng=None): + seeds = [] + for _ in range(n): + lengths = np.array([0.5, 0.25]) + if rng is not None: + lengths *= 1 + 0.05 * rng.randn(2) + seeds.append(Seed.factory('rectangle', phase=0, + side_lengths=tuple(lengths))) + return seeding.SeedList(seeds) + + +# --------------------------------------------------------------------------- # +# D4: angle_rad distributions are kept, phases are not modified # +# --------------------------------------------------------------------------- # +def test_error_stats_keeps_angle_rad_distribution(): + dist = scipy.stats.uniform(loc=-0.5, scale=1.0) + rng = np.random.RandomState(0) + angles = dist.rvs(size=60, random_state=rng) + seeds = _ellipse_seeds(angles) + fit_seeds = _ellipse_seeds(angles, rng) + phases = [{'shape': 'ellipse', 'size': 1, 'aspect_ratio': 2, + 'angle_rad': dist}] + + errs = verification.error_stats(fit_seeds, seeds, phases) + + assert phases[0]['angle_rad'] is dist + assert errs[0]['angle_rad']['ks_statistic'] < 0.5 + assert errs[0]['angle_rad']['mae'] < 0.1 + + +def test_error_stats_random_angle_rad(): + rng = np.random.RandomState(1) + angles = 2 * np.pi * rng.rand(60) + seeds = _ellipse_seeds(angles) + phases = [{'shape': 'ellipse', 'size': 1, 'aspect_ratio': 2, + 'angle_rad': 'random'}] + + errs = verification.error_stats(seeds, seeds, phases) + assert phases[0]['angle_rad'] == 'random' + assert errs[0]['angle_rad']['ks_statistic'] < 0.5 + + +# --------------------------------------------------------------------------- # +# D5: random # +# --------------------------------------------------------------------------- # +def test_error_stats_random_orientation(tmp_path): + rng = np.random.RandomState(2) + angles = 360 * rng.rand(60) + seeds = _ellipse_seeds(angles, angle_kw='angle_deg') + fit_seeds = _ellipse_seeds(angles, rng, angle_kw='angle_deg') + phases = [{'shape': 'ellipse', 'size': 1, 'aspect_ratio': 2, + 'orientation': 'random'}] + + errs = verification.error_stats(fit_seeds, seeds, phases) + assert phases[0]['orientation'] == 'random' + stats = errs[0]['orientation'] + assert stats['ks_statistic'] < 0.5 + assert stats['mae'] < 5 + + fname = tmp_path / 'err_stats.txt' + verification.write_error_stats(errs, phases, str(fname)) + assert 'orientation' in fname.read_text() + + +def test_error_stats_matrix_orientation(): + angles = np.full(20, 30.0) + seeds = _ellipse_seeds(angles, angle_kw='angle_deg') + fit_seeds = _ellipse_seeds(angles, np.random.RandomState(3), + angle_kw='angle_deg') + ct, st = np.cos(np.radians(30)), np.sin(np.radians(30)) + phases = [{'shape': 'ellipse', 'size': 1, 'aspect_ratio': 2, + 'orientation': np.array([[ct, -st], [st, ct]])}] + + errs = verification.error_stats(fit_seeds, seeds, phases) + assert errs[0]['orientation']['mae'] < 5 + + +def test_error_stats_orientation_skipped_in_3d(): + seeds = seeding.SeedList([Seed.factory('ellipsoid', phase=0, a=1, + b=0.5, c=0.5) for _ in range(5)]) + phases = [{'shape': 'ellipsoid', 'a': 1, 'b': 0.5, 'c': 0.5, + 'orientation': 'random'}] + errs = verification.error_stats(seeds, seeds, phases) + assert errs[0]['orientation'] == {} + + +# --------------------------------------------------------------------------- # +# D6: vector-valued parameters are handled per component # +# --------------------------------------------------------------------------- # +def test_mle_dist_per_component(): + values = np.array([[0.5, 0.25], [0.52, 0.24], [0.48, 0.26], [0.5, 0.25]]) + dists = [scipy.stats.norm(0.5, 0.1), scipy.stats.norm(0.25, 0.1)] + + mles = verification._mle_dist(values, dists) + assert len(mles) == 2 + assert abs(mles[0].mean() - 0.5) < 0.03 + assert abs(mles[1].mean() - 0.25) < 0.03 + + # tuples of constants give the component means + means = verification._mle_dist(values, (0.5, 0.25)) + assert np.allclose(means, [0.5, 0.25]) + + +def test_safe_rvs_tuple(): + samples = verification._safe_rvs((0.4, 0.2), 5) + assert samples.shape == (5, 2) + assert np.allclose(samples[:, 0], 0.4) + assert np.allclose(samples[:, 1], 0.2) + + dists = [scipy.stats.uniform(0, 1), 3] + samples = verification._safe_rvs(dists, 7) + assert samples.shape == (7, 2) + assert np.allclose(samples[:, 1], 3) + + +def test_write_mle_phases_vector(tmp_path): + inp_phases = [{'name': 'Bricks', 'side_lengths': (0.5, 0.25)}] + out_phases = [{'name': 'Bricks', 'side_lengths': [0.51, 0.24]}] + fname = tmp_path / 'mles.txt' + verification.write_mle_phases(inp_phases, out_phases, str(fname)) + + text = fname.read_text() + assert 'side_lengths[0]' in text + assert 'side_lengths[1]' in text + assert '0.51' in text + + +def test_verification_rectangle_side_lengths(tmp_path): + rng = np.random.RandomState(4) + seeds = _rectangle_seeds(30) + fit_seeds = _rectangle_seeds(30, rng) + phases = [{'shape': 'rectangle', 'side_lengths': (0.5, 0.25)}] + + errs = verification.error_stats(fit_seeds, seeds, phases) + assert len(errs[0]['side_lengths']) == 2 + for j, length in enumerate((0.5, 0.25)): + assert errs[0]['side_lengths'][j]['mae'] < 0.1 * length + assert 'ks_statistic' in errs[0]['side_lengths'][j] + + err_file = tmp_path / 'err_stats.txt' + verification.write_error_stats(errs, phases, str(err_file)) + text = err_file.read_text() + assert 'side_lengths[0]' in text + assert 'side_lengths[1]' in text + + mles = verification.mle_phases(fit_seeds, phases) + lengths = np.array([s.geometry.side_lengths for s in fit_seeds]) + assert np.allclose(mles[0]['side_lengths'], lengths.mean(axis=0)) + verification.write_mle_phases(phases, mles, str(tmp_path / 'mles.txt')) + + verification.plot_distributions(fit_seeds, phases, str(tmp_path), 'png') + assert (tmp_path / 'side_lengths_pdf.png').exists() + assert (tmp_path / 'side_lengths_cdf.png').exists() + + +def test_verification_distributed_axes(tmp_path): + dists = [scipy.stats.uniform(0.8, 0.4), scipy.stats.uniform(0.4, 0.2)] + rng = np.random.RandomState(5) + seeds = seeding.SeedList([ + Seed.factory('ellipse', phase=0, axes=[d.rvs(random_state=rng) + for d in dists]) + for _ in range(40)]) + phases = [{'shape': 'ellipse', 'axes': dists}] + + errs = verification.error_stats(seeds, seeds, phases) + assert len(errs[0]['axes']) == 2 + assert errs[0]['axes'][1]['ks_statistic'] < 0.5 + + mles = verification.mle_phases(seeds, phases) + assert abs(mles[0]['axes'][0].mean() - 1.0) < 0.1 + assert abs(mles[0]['axes'][1].mean() - 0.5) < 0.1 + + verification.plot_distributions(seeds, phases, str(tmp_path), 'png') + assert (tmp_path / 'axes_pdf.png').exists() + + +# --------------------------------------------------------------------------- # +# D7: extra phase fields are ignored # +# --------------------------------------------------------------------------- # +def test_extra_phase_fields_ignored(tmp_path): + dist = scipy.stats.uniform(loc=0.5, scale=0.5) + rng = np.random.RandomState(6) + sizes = dist.rvs(size=30, random_state=rng) + seeds = seeding.SeedList([Seed.factory('circle', phase=0, size=s) + for s in sizes]) + phases = [{'shape': 'circle', 'size': dist, 'notes': 'x', + 'max_volume': 0.1, 'color': 'C2'}] + + verification.plot_distributions(seeds, phases, str(tmp_path), 'png') + names = [p.name for p in tmp_path.iterdir()] + assert 'size_pdf.png' in names + assert 'size_cdf.png' in names + assert not any(n.startswith(('notes', 'max_volume')) for n in names) + + mles = verification.mle_phases(seeds, phases) + assert set(mles[0].keys()) == {'size'} + verification.write_mle_phases(phases, mles, str(tmp_path / 'mles.txt')) + + errs = verification.error_stats(seeds, seeds, phases) + assert set(errs[0].keys()) == {'size'} + assert errs[0]['size']['ks_statistic'] < 0.5 + + +# --------------------------------------------------------------------------- # +# Minor: axis labels and table headings # +# --------------------------------------------------------------------------- # +def test_axis_labels(): + assert verification._axis_label('radius') == 'Radius' + assert verification._axis_label('angle_rad') == 'Angle (radians)' + assert verification._axis_label('angle_deg') == 'Angle (degrees)' + assert verification._axis_label('orientation') == \ + 'Orientation (degrees)' + assert verification._axis_label('side_lengths') == 'Side Lengths' + + +def test_mle_headings_strip_suffix(): + hdr1, hdr2 = verification._mle_hdr(['i', 'name', 'kw', 'nu_inp', + 'nu_out', 's_inp', 's_out']) + assert hdr1 == ['', '', '', 'Input', 'Output', 'Input', 'Output'] + assert hdr2 == ['#', 'Name', 'Parameter', 'nu', 'nu', 's', 's'] From 25cff3160d97246259dcae296ae74090ae47f2cf Mon Sep 17 00:00:00 2001 From: Julian Rimoli Date: Tue, 22 Sep 2026 20:02:13 -0700 Subject: [PATCH 06/42] Add changelog entries for the bug fixes Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.rst | 68 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 6c44047d..9a0f565d 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -6,6 +6,74 @@ All notable changes to this project will be documented in this file. The format is based on `Keep a Changelog`_, and this project adheres to `Semantic Versioning`_. +Unreleased +---------- +Fixed +''''' +- Seed generation is reproducible: the RNG seed chain no longer depends on + the (hash-randomized) iteration order of the phase keywords, and + ``SeedList.from_info`` and ``cli.run`` no longer modify the ``rng_seeds`` + and ``filetypes`` arguments (or their mutable defaults). +- `` cdf `` inputs are no longer distorted when the + x-values in the CSV file are not evenly spaced (``density=False`` is now + passed to ``scipy.stats.rv_histogram``); ``pdf`` is accepted as an alias + of ``histogram``. +- 3D ``mesh_max_volume`` and per-phase ``max_volume`` are now honored by + TetGen; in 2D a per-phase ``max_volume`` larger than the global value is + no longer capped, and an infinite ``mesh_max_volume`` is no longer passed + to Triangle as the (mis-parsed) switch ``ainf``. +- ``Ellipsoid.approximate`` mapped the axes incorrectly for the ordering + c >= a >= b, so those grains were tessellated with the wrong orientation; + the b >= c >= a ordering is now sorted explicitly as well. +- Seeds read back from ``seeds.txt`` can be repositioned; ellipsoid seeds + with a rotation sequence are written in a form that can be read back. +- Cells that intersect a circular or elliptical domain without having a + vertex inside it are no longer dropped, cells cut twice by the boundary + are clipped correctly, and the stored areas of clipped cells are correct + (``PolyMesh.volumes``, ``verification.volume_fractions``). +- ``_segment_cross`` no longer hangs for large coordinate values; + ``sample_pos_within`` raises instead of looping forever when the position + distribution does not cover the domain. +- ``cli.plot_tri`` no longer hangs in 3D when a void grain touches the + boundary of the domain. +- Relative ```` and ```` paths inside repeated tags + (e.g. several ```` blocks) are resolved relative to the input + file; a top-level ```` no longer discards materials; values such + as ``true_cdf.csv`` no longer cause infinite recursion; ``inf`` is parsed + as a float. +- Verification: ``angle_rad`` inputs are no longer replaced by a uniform + distribution, `` random `` and vector-valued + parameters (``side_lengths``, ``axes``) no longer crash, unknown phase + fields are ignored, the caller's phases are not modified. +- ``RasterMesh``: elements are counter-clockwise / right-handed (valid for + Abaqus CPS4/C3D8), facets and their attributes are correct, ``vtk`` and + ``abaqus`` output work (including with voids), 3D plotting works. +- ``TriMesh.write``: valid ``.ele``/``.edge``/``.face`` files, Abaqus + exterior surface unions reference only defined surfaces, full-precision + points in text files, no dangling headers for meshes without attributes. +- ``PolyMesh.from_seeds(edge_opt=True)`` leaves the seed list in the + accepted state (positions and breakdowns consistent) and is quiet unless + ``verbose``; ``PolyMesh.write(format='poly')`` writes the file; + ``PolyMesh.__eq__`` is silent and no longer cubic. +- ``Ellipse(axes=...)``, ``Ellipse(matrix=...)``, ``Ellipsoid(c=.., + ratio_bc=..)``, ``Square.area_expectation(side_lengths=...)``, the + ``*_expectation`` methods with numpy scalars, ``Sphere.plot`` and 3D + ``PolyMesh.plot``/``SeedList.plot_breakdown`` on a fresh figure, + ``Rectangle.within`` for rotated rectangles, ``reflect`` for ellipses + and ellipsoids, single-material ``color_by`` settings, numpy arrays as + per-item plot keywords. + +Changed +''''''' +- ``Ellipsoid.limits`` is exact for rotated ellipsoids (it was sampled). +- A ``Seed`` created with a ``position`` (or a geometry with a center) has + its breakdown at that position; the geometry center is no longer reset + to the origin. +- ``Ellipse``, ``Ellipsoid`` and ``NBox`` geometries compare equal when + their parameters are equal. +- Unused sampling helpers were removed from ``seeding.seedlist``. + + `1.5.9`_ - 2023-10-05 -------------------------- Added From 9f4b213b1598cfc13b9f010d0fdf00a7c656e9b4 Mon Sep 17 00:00:00 2001 From: Julian Rimoli Date: Tue, 22 Sep 2026 20:30:44 -0700 Subject: [PATCH 07/42] Add periodic seed placement (per-axis) SeedList.position(periodic=...) accepts True, a list of booleans or axis names ('x', 'xy', ...). A seed that crosses a periodic face of a rectangular domain takes part in the overlap test through its images translated across the domain (all combinations of the crossed faces, so edges and corners are covered); placed seeds enter the AABB tree with their images. _misc.periodic_axes parses the specification and _misc.periodic_domain_limits checks the domain. Co-Authored-By: Claude Fable 5.1 --- src/microstructpy/_misc.py | 83 +++++++++++ src/microstructpy/seeding/seedlist.py | 125 +++++++++++++---- tests/seeding/test_periodic_seeding.py | 182 +++++++++++++++++++++++++ 3 files changed, 365 insertions(+), 25 deletions(-) create mode 100644 tests/seeding/test_periodic_seeding.py diff --git a/src/microstructpy/_misc.py b/src/microstructpy/_misc.py index 840bc7cd..1d0a4acb 100644 --- a/src/microstructpy/_misc.py +++ b/src/microstructpy/_misc.py @@ -178,3 +178,86 @@ def ax_objects(ax): for att in ['collections', 'images', 'lines', 'patches', 'texts']: n += len(getattr(ax, att)) return n + + +# --------------------------------------------------------------------------- # +# # +# Periodicity # +# # +# --------------------------------------------------------------------------- # +def periodic_axes(periodic, n_dim): + """Per-axis periodicity flags. + + The periodicity of a microstructure can be given as a boolean (all axes + or none), a list of booleans (one per axis), or a string with the names + of the periodic axes, such as ``'x'``, ``'xy'`` or ``'xz'``. + + Args: + periodic (bool, list, or str): The periodicity specification. + n_dim (int): Number of dimensions of the domain. + + Returns: + list: ``n_dim`` booleans, True for the periodic axes. + + Raises: + ValueError: If the specification cannot be interpreted. + + """ + if periodic is None: + return [False for _ in range(n_dim)] + + if isinstance(periodic, (bool, np.bool_)): + return [bool(periodic) for _ in range(n_dim)] + + axis_names = 'xyz'[:n_dim] + if isinstance(periodic, str): + text = periodic.strip().lower() + if text in ('true', 'all', 'yes'): + return [True for _ in range(n_dim)] + if text in ('false', 'none', 'no', ''): + return [False for _ in range(n_dim)] + flags = [False for _ in range(n_dim)] + for word in text.replace(',', ' ').split(): + for char in word: + if char not in axis_names: + e_str = 'Cannot interpret periodic axes ' + repr(periodic) + e_str += '. Use a boolean, a list of ' + str(n_dim) + e_str += ' booleans, or axis names such as ' + e_str += repr(axis_names) + '.' + raise ValueError(e_str) + flags[axis_names.index(char)] = True + return flags + + flags = [bool(f) for f in periodic] + if len(flags) != n_dim: + e_str = 'Expected ' + str(n_dim) + ' periodicity flags, got ' + e_str += str(len(flags)) + ': ' + repr(periodic) + '.' + raise ValueError(e_str) + return flags + + +def periodic_domain_limits(domain): + """(lower, upper) bounds of a rectangular, axis-aligned domain. + + Periodic microstructures are only supported in such domains. + + Args: + domain (from :mod:`microstructpy.geometry`): The domain. + + Returns: + list: One (lower, upper) tuple per axis. + + Raises: + ValueError: If the domain is not a rectangle, square, box, or cube, + or if it is rotated. + + """ + name = type(domain).__name__.lower() + if name not in ('rectangle', 'square', 'box', 'cube'): + e_str = 'Periodic microstructures require a rectangular domain ' + e_str += '(Rectangle, Square, Box, or Cube), not ' + name + '.' + raise ValueError(e_str) + if not np.allclose(np.array(domain.matrix), np.eye(domain.n_dim)): + e_str = 'Periodic microstructures require an axis-aligned domain.' + raise ValueError(e_str) + return [(float(lb), float(ub)) for lb, ub in domain.limits] diff --git a/src/microstructpy/seeding/seedlist.py b/src/microstructpy/seeding/seedlist.py index 43cf724f..ef9fb719 100644 --- a/src/microstructpy/seeding/seedlist.py +++ b/src/microstructpy/seeding/seedlist.py @@ -11,6 +11,7 @@ from __future__ import division from __future__ import print_function +import itertools import warnings import aabbtree @@ -777,7 +778,8 @@ def plot_breakdown(self, index_by='seed', material=[], loc=0, **kwargs): # Position Function # # ----------------------------------------------------------------------- # def position(self, domain, pos_dists={}, rng_seed=0, hold=[], - max_attempts=10000, rtol='fit', verbose=False): + max_attempts=10000, rtol='fit', verbose=False, + periodic=False): """Position seeds in a domain This method positions the seeds within a domain. The "domain" should be @@ -837,11 +839,26 @@ def position(self, domain, pos_dists={}, rng_seed=0, hold=[], verbose (bool): *(optional)* This option will print a running counter of how many seeds have been positioned. Defaults to False. + periodic (bool, list, or str): *(optional)* Periodicity of the + microstructure: True for all axes, a list of booleans (one + per axis), or the names of the periodic axes such as + ``'x'`` or ``'xy'``. A seed that crosses a periodic face of + the domain is checked for overlap on both sides, through its + periodic images. Requires a rectangular domain. + Defaults to False. """ # NOQA: E501 if len(hold) == 0: hold = [False for seed in self] + # Periodicity: seeds that cross a periodic face take part in the + # overlap test through their images across the domain + per_axes = _misc.periodic_axes(periodic, domain.n_dim) + if any(per_axes): + dom_lims = _misc.periodic_domain_limits(domain) + else: + dom_lims = None + # set the spatial distributions u_dist = [scipy.stats.uniform(lb, ub - lb) for lb, ub in domain.sample_limits] @@ -861,9 +878,7 @@ def position(self, domain, pos_dists={}, rng_seed=0, hold=[], tree = aabbtree.AABBTree() for i in range(n_seeds): if hold[i]: - # add to tree - aabb = aabbtree.AABB(self[i].geometry.limits) - tree.add(aabb, i) + _add_to_tree(tree, self[i], i, dom_lims, per_axes) positioned = np.array(hold) vols = np.array([s.volume for s in self]) @@ -905,26 +920,28 @@ def position(self, domain, pos_dists={}, rng_seed=0, hold=[], bkdwn = np.array(seed.breakdown) cens = bkdwn[:, :-1] rads = bkdwn[:, -1].reshape(-1, 1) + limits = seed.geometry.limits - aabb = aabbtree.AABB(seed.geometry.limits) - olap_inds = tree.overlap_values(aabb, method='BFS') - olap_seeds = self[olap_inds] + # The seed and its periodic images are tested against the + # placed seeds and their images (the tree holds both) clears = True - for olap_seed in olap_seeds: - o_bkdwn = np.array(olap_seed.breakdown) - o_cens = o_bkdwn[:, :-1] - o_rads = o_bkdwn[:, -1].reshape(1, -1) - - if len(rads) > 1: - dists = distance.cdist(cens, o_cens) - else: - rel_pos = o_cens - cens - rp2 = rel_pos * rel_pos - dists = np.sqrt(np.sum(rp2, axis=1)) - tol = rtol * np.minimum(rads, o_rads) - total_dists = dists + tol - rads - o_rads - if np.any(total_dists < 0): - clears = False + images = _periodic_images(limits, dom_lims, per_axes, + include_zero=True) + for t_seed in images: + aabb = _translated_aabb(limits, t_seed) + s_cens = cens + np.array(t_seed) + for j, t_other in tree.overlap_values(aabb, method='BFS'): + o_bkdwn = np.array(self[j].breakdown) + o_cens = o_bkdwn[:, :-1] + np.array(t_other) + o_rads = o_bkdwn[:, -1].reshape(1, -1) + + dists = distance.cdist(s_cens, o_cens) + tol = rtol * np.minimum(rads, o_rads) + total_dists = dists + tol - rads - o_rads + if np.any(total_dists < 0): + clears = False + break + if not clears: break searching = not clears @@ -935,9 +952,8 @@ def position(self, domain, pos_dists={}, rng_seed=0, hold=[], positioned[i] = True self[i] = seed - # add to tree - aabb = aabbtree.AABB(seed.geometry.limits) - tree.add(aabb, i) + # add to tree, with periodic images + _add_to_tree(tree, seed, i, dom_lims, per_axes) keep_mask = np.array(n_seeds * [True]) keep_mask[i_reject] = False @@ -955,6 +971,65 @@ def position(self, domain, pos_dists={}, rng_seed=0, hold=[], self.seeds = self[keep_mask].seeds +def _periodic_images(limits, dom_lims, per_axes, include_zero=False): + """Translations of the periodic images of a shape. + + A shape whose bounding box ``limits`` crosses a periodic face of the + domain has an image translated by the domain length across that axis; + crossing several faces (edges, corners) gives every combination. + + Args: + limits (list): (lower, upper) bounds of the shape, per axis. + dom_lims (list or None): (lower, upper) bounds of the domain, or + None for a non-periodic domain. + per_axes (list): Periodicity flag of each axis. + include_zero (bool): Whether to include the zero translation (the + shape itself) as the first entry. + + Returns: + list: Translation tuples. + + """ + n_dim = len(limits) + zero = tuple([0.0 for _ in range(n_dim)]) + images = [zero] if include_zero else [] + if dom_lims is None: + return images + + options = [] + for i in range(n_dim): + opts = [0.0] + if per_axes[i]: + lb, ub = dom_lims[i] + length = ub - lb + if limits[i][0] < lb: + opts.append(length) + if limits[i][1] > ub: + opts.append(-length) + options.append(opts) + for t in itertools.product(*options): + if any([x != 0 for x in t]): + images.append(tuple([float(x) for x in t])) + return images + + +def _translated_aabb(limits, translation): + """Axis-aligned bounding box of a shape translated by a vector.""" + return aabbtree.AABB([(lb + t, ub + t) for (lb, ub), t in + zip(limits, translation)]) + + +def _add_to_tree(tree, seed, index, dom_lims, per_axes): + """Add a seed and its periodic images to an AABB tree. + + The values stored in the tree are (seed index, translation) pairs. + """ + limits = seed.geometry.limits + images = _periodic_images(limits, dom_lims, per_axes, include_zero=True) + for t in images: + tree.add(_translated_aabb(limits, t), (index, t)) + + def _plt_args(seeds, index_by, kwargs): seed_args = [{} for seed in seeds] for seed_num, seed in enumerate(seeds): diff --git a/tests/seeding/test_periodic_seeding.py b/tests/seeding/test_periodic_seeding.py new file mode 100644 index 00000000..7e691e35 --- /dev/null +++ b/tests/seeding/test_periodic_seeding.py @@ -0,0 +1,182 @@ +"""Tests for the periodic placement of seeds.""" +import itertools + +import numpy as np +import pytest +import scipy.stats +from scipy.spatial import distance + +import microstructpy as msp +from microstructpy import _misc +from microstructpy.seeding import Seed +from microstructpy.seeding import SeedList +from microstructpy.seeding.seedlist import _periodic_images + + +# --------------------------------------------------------------------------- # +# Helpers # +# --------------------------------------------------------------------------- # +def _translations(lengths, per_axes): + """All translations by 0 or +/- the domain length along periodic axes.""" + options = [[0.0, length, -length] if flag else [0.0] + for length, flag in zip(lengths, per_axes)] + return [np.array(t) for t in itertools.product(*options)] + + +def _worst_overlap(seeds, lengths, per_axes, rtol=0.0): + """Largest violation of the overlap condition over all pairs of seeds, + including the periodic images of the second seed (Eq. 3 of the paper). + Positive values are overlaps beyond the tolerance.""" + bkdwns = [np.array(s.breakdown) for s in seeds] + n_dim = bkdwns[0].shape[1] - 1 + worst = -np.inf + for i, j in itertools.combinations(range(len(seeds)), 2): + c_i, r_i = bkdwns[i][:, :n_dim], bkdwns[i][:, n_dim].reshape(-1, 1) + c_j, r_j = bkdwns[j][:, :n_dim], bkdwns[j][:, n_dim].reshape(1, -1) + for t in _translations(lengths, per_axes): + dists = distance.cdist(c_i, c_j + t) + viol = r_i + r_j - rtol * np.minimum(r_i, r_j) - dists + worst = max(worst, viol.max()) + return worst + + +def _cross_face_overlap(seeds, lengths, per_axes): + """Largest overlap of a seed with a periodic image (non-zero + translation) of another seed.""" + bkdwns = [np.array(s.breakdown) for s in seeds] + n_dim = bkdwns[0].shape[1] - 1 + worst = -np.inf + for i, j in itertools.permutations(range(len(seeds)), 2): + c_i, r_i = bkdwns[i][:, :n_dim], bkdwns[i][:, n_dim].reshape(-1, 1) + c_j, r_j = bkdwns[j][:, :n_dim], bkdwns[j][:, n_dim].reshape(1, -1) + for t in _translations(lengths, per_axes): + if not np.any(t): + continue + dists = distance.cdist(c_i, c_j + t) + worst = max(worst, (r_i + r_j - dists).max()) + return worst + + +# --------------------------------------------------------------------------- # +# Periodicity specification # +# --------------------------------------------------------------------------- # +def test_periodic_axes_parsing(): + assert _misc.periodic_axes(True, 2) == [True, True] + assert _misc.periodic_axes(False, 3) == [False, False, False] + assert _misc.periodic_axes(None, 3) == [False, False, False] + assert _misc.periodic_axes('xy', 3) == [True, True, False] + assert _misc.periodic_axes('z', 3) == [False, False, True] + assert _misc.periodic_axes('x, z', 3) == [True, False, True] + assert _misc.periodic_axes('True', 2) == [True, True] + assert _misc.periodic_axes([True, False], 2) == [True, False] + assert _misc.periodic_axes(np.array([0, 1, 1]), 3) == [False, True, True] + with pytest.raises(ValueError): + _misc.periodic_axes('xw', 3) + with pytest.raises(ValueError): + _misc.periodic_axes('z', 2) + with pytest.raises(ValueError): + _misc.periodic_axes([True], 2) + + +def test_periodic_images_translations(): + dom_lims = [(0.0, 2.0), (0.0, 3.0)] + # crosses the -x face and the +y face: 3 images (edge, edge, corner) + limits = [(-0.1, 0.5), (2.5, 3.2)] + images = _periodic_images(limits, dom_lims, [True, True]) + assert sorted(images) == sorted([(2.0, 0.0), (0.0, -3.0), (2.0, -3.0)]) + # only x periodic: one image + assert _periodic_images(limits, dom_lims, [True, False]) == [(2.0, 0.0)] + # inside the domain: no images + assert _periodic_images([(0.5, 1.0), (0.5, 1.0)], dom_lims, + [True, True]) == [] + # non-periodic domain + assert _periodic_images(limits, None, [False, False], + include_zero=True) == [(0.0, 0.0)] + + +# --------------------------------------------------------------------------- # +# Positioning # +# --------------------------------------------------------------------------- # +def _phases_2d(): + return [{'shape': 'circle', 'size': scipy.stats.uniform(0.15, 0.15)}, + {'shape': 'ellipse', 'size': scipy.stats.uniform(0.2, 0.15), + 'aspect_ratio': scipy.stats.uniform(1.5, 1.5), + 'angle_deg': scipy.stats.uniform(0, 180)}] + + +def test_periodic_position_2d_no_overlaps(): + domain = msp.geometry.Square(side_length=2.5, corner=(1, -1)) + lengths = domain.side_lengths + + seeds = SeedList.from_info(_phases_2d(), 0.6 * domain.area) + seeds.position(domain, rtol=0.0, rng_seed=1, periodic=True) + # seed centers stay inside the domain + assert np.all(domain.within([s.position for s in seeds])) + # no overlaps, including through the periodic faces + assert _worst_overlap(seeds, lengths, [True, True]) <= 1e-9 + # some seeds do cross the faces (the test is not vacuous) + lims = np.array([s.geometry.limits for s in seeds]) + dom = np.array(domain.limits) + crossing = np.any((lims[:, :, 0] < dom[:, 0]) | + (lims[:, :, 1] > dom[:, 1]), axis=1) + assert crossing.sum() > 0 + + # without periodicity, the same packing overlaps through the faces + seeds_np = SeedList.from_info(_phases_2d(), 0.6 * domain.area) + seeds_np.position(domain, rtol=0.0, rng_seed=1) + assert _cross_face_overlap(seeds_np, lengths, [True, True]) > 1e-6 + + +def test_periodic_position_2d_single_axis(): + domain = msp.geometry.Rectangle(length=3, width=2) + lengths = domain.side_lengths + seeds = SeedList.from_info(_phases_2d(), 0.6 * domain.area) + seeds.position(domain, rtol=0.0, rng_seed=2, periodic='x') + # no overlaps through the x faces (translations along x only) + assert _worst_overlap(seeds, lengths, [True, False]) <= 1e-9 + + +def test_periodic_position_with_tolerance(): + domain = msp.geometry.Square(side_length=2) + seeds = SeedList.from_info(_phases_2d(), domain.area) + seeds.position(domain, rtol=0.3, rng_seed=3, periodic=[True, True]) + assert _worst_overlap(seeds, domain.side_lengths, [True, True], + rtol=0.3) <= 1e-9 + + +def test_periodic_position_held_seed_images(): + domain = msp.geometry.Square(side_length=2) + # a held seed in the corner, crossing the -x and -y faces + held = Seed.factory('circle', r=0.3, position=(0.05, 0.05)) + others = SeedList.from_info([{'shape': 'circle', 'size': 0.3}], + 0.5 * domain.area) + others.position(domain, rtol=0.0, rng_seed=0, periodic=True) + seeds = SeedList([held]) + others + hold = [True] + [False for _ in others] + seeds.position(domain, rtol=0.0, rng_seed=4, hold=hold, + periodic=True) + assert np.allclose(seeds[0].position, [0.05, 0.05]) + assert _worst_overlap(seeds, domain.side_lengths, [True, True]) <= 1e-9 + + +def test_periodic_position_3d(): + domain = msp.geometry.Cube(side_length=2) + phases = [{'shape': 'sphere', 'size': scipy.stats.uniform(0.4, 0.3)}] + seeds = SeedList.from_info(phases, 0.45 * domain.volume) + seeds.position(domain, rtol=0.0, rng_seed=5, periodic=True) + assert _worst_overlap(seeds, domain.side_lengths, + [True, True, True]) <= 1e-9 + seeds.position(domain, rtol=0.0, rng_seed=6, periodic='xz') + assert _worst_overlap(seeds, domain.side_lengths, + [True, False, True]) <= 1e-9 + + +def test_periodic_requires_rectangular_domain(): + seeds = SeedList.from_info([{'shape': 'circle', 'size': 0.3}], 2.0) + with pytest.raises(ValueError): + seeds.position(msp.geometry.Circle(r=1), periodic=True) + with pytest.raises(ValueError): + seeds.position(msp.geometry.Rectangle(length=2, width=2, angle=30), + periodic=True) + # a non-periodic call on a circular domain still works + seeds.position(msp.geometry.Circle(r=1), periodic=False) From 13da40430550dc9a057b5bcab45b8986c4be0037 Mon Sep 17 00:00:00 2001 From: Julian Rimoli Date: Tue, 22 Sep 2026 20:30:44 -0700 Subject: [PATCH 08/42] Add periodic 2D tessellation PolyMesh.from_seeds(periodic=...) computes the Laguerre tessellation in Voro++'s periodic mode (breakdown centers wrapped into the domain), cuts each wrapped cell at the periodic faces and translates the outside pieces into the domain, so the pieces of a seed tile the unit cell. Cut edges become wall facets; neighbors are resolved to the pieces sharing the edge. Points and facets on opposite faces are paired, snapped to exact translates, and stored in periodic_axes / periodic_points / periodic_facets, which the text format persists. 3D raises NotImplementedError for now. Co-Authored-By: Claude Fable 5.1 --- src/microstructpy/meshing/polymesh.py | 404 +++++++++++++++++++++++- tests/meshing/test_periodic_polymesh.py | 234 ++++++++++++++ 2 files changed, 631 insertions(+), 7 deletions(-) create mode 100644 tests/meshing/test_periodic_polymesh.py diff --git a/src/microstructpy/meshing/polymesh.py b/src/microstructpy/meshing/polymesh.py index 330bff85..f398f343 100644 --- a/src/microstructpy/meshing/polymesh.py +++ b/src/microstructpy/meshing/polymesh.py @@ -95,12 +95,20 @@ class PolyMesh(object): # Constructors # # ----------------------------------------------------------------------- # def __init__(self, points, facets, regions, seed_numbers=None, - phase_numbers=None, facet_neighbors=None, volumes=None): + phase_numbers=None, facet_neighbors=None, volumes=None, + periodic_axes=None, periodic_points=None, + periodic_facets=None): self.points = points self.facets = facets self.regions = regions + # Periodicity: flags per axis, and the pairs of (low face, high face) + # points and facets that are periodic images of each other, per axis + self.periodic_axes = periodic_axes + self.periodic_points = periodic_points + self.periodic_facets = periodic_facets + if facet_neighbors is None: # Find facet neighbors facet_neighs = [[-1, -1] for _ in facets] @@ -227,6 +235,18 @@ def __str__(self): str_str += 'Volumes: ' + str(len(self.volumes)) + '\n' str_str += '\n'.join(['\t' + str(v) for v in self.volumes]) + + if self.periodic_axes is not None and any(self.periodic_axes): + flags = [int(bool(f)) for f in self.periodic_axes] + str_str += '\nPeriodic Axes: ' + str(len(flags)) + '\n' + str_str += '\t' + ', '.join([str(f) for f in flags]) + for name, pairs in (('Periodic Points', self.periodic_points), + ('Periodic Facets', self.periodic_facets)): + rows = [(ax, lo, hi) for ax in sorted(pairs or {}) + for lo, hi in pairs[ax]] + str_str += '\n' + name + ': ' + str(len(rows)) + str_str += ''.join(['\n\t' + ', '.join([str(n) for n in row]) + for row in rows]) return str_str # ----------------------------------------------------------------------- # @@ -447,8 +467,17 @@ def from_file(cls, filename): seed_numbers = [] phase_numbers = [] volumes = [] + per_axes = None + per_pts = [] + per_fts = [] for line in file.readlines(): - if 'Mesh Points'.lower() in line.lower(): + if 'Periodic Axes'.lower() in line.lower(): + stage = 'periodic axes' + elif 'Periodic Points'.lower() in line.lower(): + stage = 'periodic points' + elif 'Periodic Facets'.lower() in line.lower(): + stage = 'periodic facets' + elif 'Mesh Points'.lower() in line.lower(): n_pts = int(line.split(':')[1]) stage = 'points' elif 'Mesh Facets'.lower() in line.lower(): @@ -484,6 +513,12 @@ def from_file(cls, filename): phase_numbers.append(_misc.from_str(line)) elif stage == 'volumes': volumes.append(_misc.from_str(line)) + elif stage == 'periodic axes': + per_axes = [bool(int(f)) for f in line.split(',')] + elif stage == 'periodic points': + per_pts.append([int(n) for n in line.split(',')]) + elif stage == 'periodic facets': + per_fts.append([int(n) for n in line.split(',')]) else: pass @@ -500,15 +535,27 @@ def from_file(cls, filename): else: assert len(f_neighbors) == n_nns + per_points = None + per_facets = None + if per_axes is not None: + per_points = {ax: [] for ax, f in enumerate(per_axes) if f} + per_facets = {ax: [] for ax, f in enumerate(per_axes) if f} + for ax, lo, hi in per_pts: + per_points[ax].append((lo, hi)) + for ax, lo, hi in per_fts: + per_facets[ax].append((lo, hi)) + return cls(pts, facets, regions, seed_numbers, phase_numbers, - volumes=volumes, facet_neighbors=f_neighbors) + volumes=volumes, facet_neighbors=f_neighbors, + periodic_axes=per_axes, periodic_points=per_points, + periodic_facets=per_facets) # ----------------------------------------------------------------------- # # Construct from Seed List # # ----------------------------------------------------------------------- # @classmethod def from_seeds(cls, seedlist, domain, edge_opt=False, n_iter=100, - verbose=False): + verbose=False, periodic=False): """Create from :class:`.SeedList` and a domain. This function creates a polygon/polyhedron mesh from a seed list and @@ -539,6 +586,15 @@ def from_seeds(cls, seedlist, domain, edge_opt=False, n_iter=100, Defaults to 100. verbose (bool): *(optional)* Print status of edge optimization to screen. Defaults to False. + periodic (bool, list, or str): *(optional)* Periodicity of the + microstructure: True for all axes, a list of booleans (one + per axis), or the names of the periodic axes such as + ``'x'`` or ``'xy'``. The tessellation is then periodic across + those faces of the (rectangular) domain: cells that cross a + periodic face are split into pieces that tile the domain, + and the points and facets on opposite faces are paired + (see ``periodic_points`` and ``periodic_facets``). + Defaults to False. Returns: PolyMesh: A polygon/polyhedron mesh. @@ -546,6 +602,15 @@ def from_seeds(cls, seedlist, domain, edge_opt=False, n_iter=100, .. _`Voro++`: http://math.lbl.gov/voro++/ """ + per_axes = _misc.periodic_axes(periodic, domain.n_dim) + is_periodic = any(per_axes) + if is_periodic: + dom_lims = _misc.periodic_domain_limits(domain) + if domain.n_dim != 2: + e_str = 'Periodic tessellations are currently supported in ' + e_str += '2D only.' + raise NotImplementedError(e_str) + # Collect all breakdowns bkdwn2seed = np.array([], dtype='int') bkdwns = np.array([]) @@ -553,6 +618,10 @@ def from_seeds(cls, seedlist, domain, edge_opt=False, n_iter=100, if len(seed.breakdown) == 0: seed.update_breakdown() bkdwn = np.array(seed.breakdown).reshape(-1, domain.n_dim + 1) + if is_periodic: + # centers outside the domain along a periodic axis are + # wrapped into it (Voro++ needs the particles in the box) + bkdwn = _wrap_points(bkdwn, dom_lims, per_axes) in_mask = domain.within(bkdwn[:, :-1]) breakdown = bkdwn[in_mask] @@ -607,7 +676,8 @@ def from_seeds(cls, seedlist, domain, edge_opt=False, n_iter=100, call_str += 'pyvoro.compute_' if n_dim == 2: call_str += '2d_' - call_str += 'voronoi(pts, lims, sz, rads)\n' + call_str += 'voronoi(pts, lims, sz, rads, periodic=' + call_str += str([bool(f) for f in per_axes]) + ')\n' file = tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) @@ -644,7 +714,15 @@ def from_seeds(cls, seedlist, domain, edge_opt=False, n_iter=100, # compute voronoi diagram voro_fun = {2: pyvoro.compute_2d_voronoi, 3: pyvoro.compute_voronoi}[n_dim] - voro = voro_fun(cens, lims, sz, rads) + voro = voro_fun(cens, lims, sz, rads, + periodic=[bool(f) for f in per_axes]) + + if is_periodic: + # Cells of a periodic tessellation wrap across the periodic + # faces: split them at those faces and translate the outside + # pieces into the domain + voro, bkdwn2seed = _periodic_pieces_2d(voro, bkdwn2seed, lims, + per_axes) # Get only the cells within the domain cell_mask = np.full(len(bkdwn2seed), True, dtype='bool') @@ -761,6 +839,8 @@ def from_seeds(cls, seedlist, domain, edge_opt=False, n_iter=100, # Create initial mesh pmesh = cls(pts_global, facet_list, region_list, bkdwn2seed, phase_nums, facet_neighbor_list, vols) + if is_periodic: + pmesh._set_periodic_pairs(per_axes, dom_lims) # short edge optimization if edge_opt: @@ -814,7 +894,8 @@ def from_seeds(cls, seedlist, domain, edge_opt=False, n_iter=100, # Create New Polygonal Mesh try: new_pmesh = cls.from_seeds(trial_seeds, domain, - edge_opt=False) + edge_opt=False, + periodic=periodic) except AssertionError: i_n_attempts += 1 continue @@ -841,6 +922,94 @@ def from_seeds(cls, seedlist, domain, edge_opt=False, n_iter=100, i_n_attempts += 1 return pmesh + # ----------------------------------------------------------------------- # + # Periodicity # + # ----------------------------------------------------------------------- # + def _set_periodic_pairs(self, per_axes, dom_lims): + """Pair the points and facets on opposite periodic faces. + + For each periodic axis, every point on the lower face is matched + with its image on the upper face; the coordinates of the pair are + snapped so that the image is exactly the point translated by the + domain length. Facets lying on the faces are paired likewise. + The results are stored in ``periodic_axes``, ``periodic_points`` + (dict: axis -> list of (lower, upper) point numbers) and + ``periodic_facets`` (dict: axis -> list of (lower, upper) facet + numbers). + + Raises: + ValueError: If a point or facet on a periodic face has no + image on the opposite face. + + """ + pts = np.array(self.points, dtype='float') + n_dim = pts.shape[1] + lengths = [ub - lb for lb, ub in dom_lims] + tol = 1e-8 * max(lengths) + + per_points = {} + per_facets = {} + for axis, flag in enumerate(per_axes): + if not flag: + continue + lb, ub = dom_lims[axis] + shift = np.zeros(n_dim) + shift[axis] = ub - lb + others = [i for i in range(n_dim) if i != axis] + + low = np.nonzero(np.abs(pts[:, axis] - lb) <= tol)[0] + high = np.nonzero(np.abs(pts[:, axis] - ub) <= tol)[0] + if len(low) != len(high): + e_str = 'The periodic faces along axis ' + str(axis) + e_str += ' have different numbers of points (' + e_str += str(len(low)) + ' and ' + str(len(high)) + ').' + raise ValueError(e_str) + + pairs = [] + if len(low) > 0: + dists = distance.cdist(pts[low][:, others], + pts[high][:, others]) + for i_low, kp_low in enumerate(low): + i_high = int(np.argmin(dists[i_low])) + if dists[i_low, i_high] > tol: + e_str = 'Point ' + str(kp_low) + ' on the lower ' + e_str += 'periodic face of axis ' + str(axis) + e_str += ' has no image on the upper face.' + raise ValueError(e_str) + dists[:, i_high] = np.inf # one-to-one + kp_high = int(high[i_high]) + # snap the pair to exact periodic images + pts[kp_low, axis] = lb + pts[kp_high] = pts[kp_low] + shift + pairs.append((int(kp_low), kp_high)) + per_points[axis] = pairs + + # facets on the faces + kp_map = {lo: hi for lo, hi in pairs} + low_set = set(kp_map) + high_set = set(kp_map.values()) + high_facets = {} + for f_num, facet in enumerate(self.facets): + if all([kp in high_set for kp in facet]): + high_facets[frozenset(facet)] = f_num + f_pairs = [] + for f_num, facet in enumerate(self.facets): + if not all([kp in low_set for kp in facet]): + continue + key = frozenset([kp_map[kp] for kp in facet]) + if key not in high_facets: + e_str = 'Facet ' + str(f_num) + ' on the lower periodic' + e_str += ' face of axis ' + str(axis) + ' has no image' + e_str += ' on the upper face.' + raise ValueError(e_str) + f_pairs.append((f_num, high_facets[key])) + per_facets[axis] = f_pairs + + self.points = pts.tolist() + self.periodic_axes = [bool(f) for f in per_axes] + self.periodic_points = per_points + self.periodic_facets = per_facets + # ----------------------------------------------------------------------- # # Plot Mesh # # ----------------------------------------------------------------------- # @@ -1133,6 +1302,227 @@ def __eq__(self, other_mesh): return bool(same) +def _wrap_points(bkdwn, dom_lims, per_axes): + """Wrap the centers of a breakdown into the domain along periodic axes. + + Args: + bkdwn (numpy.ndarray): N x (d + 1) array of (center, radius) rows. + dom_lims (list): (lower, upper) bounds of the domain, per axis. + per_axes (list): Periodicity flag of each axis. + + Returns: + numpy.ndarray: The wrapped breakdown. + + """ + bkdwn = np.array(bkdwn, dtype='float') + for axis, flag in enumerate(per_axes): + if not flag: + continue + lb, ub = dom_lims[axis] + bkdwn[:, axis] = lb + np.mod(bkdwn[:, axis] - lb, ub - lb) + return bkdwn + + +def _cell_loop(cell): + """Vertex loop of a 2D pyvoro cell and the adjacent cell of each edge. + + Returns: + tuple: The vertices in loop order (N x 2 array) and a list with the + adjacent cell of the edge that starts at each vertex. + + """ + faces = cell['faces'] + loop = kp_loop([f['vertices'] for f in faces]) + edge_adj = {frozenset(f['vertices']): f['adjacent_cell'] for f in faces} + pts = np.array(cell['vertices'], dtype='float')[loop] + n_kp = len(loop) + adj = [edge_adj[frozenset((loop[k], loop[(k + 1) % n_kp]))] + for k in range(n_kp)] + return pts, adj + + +def _clip_loop(pts, adj, axis, value, keep_below, wall, tol): + """Clip a convex polygon by an axis-aligned line (Sutherland-Hodgman). + + Args: + pts (numpy.ndarray): Vertices of the polygon, in loop order. + adj (list): Adjacent cell of the edge starting at each vertex. + axis (int): Axis of the clipping line. + value (float): Position of the clipping line along the axis. + keep_below (bool): Keep the side below the line (True) or above it. + wall (int): Adjacent cell id given to the edges created on the + line (a negative wall id). + tol (float): Points within this distance of the line are on it. + + Returns: + tuple: The clipped vertices and their edge adjacencies (empty if + the polygon lies entirely on the other side). + + """ + n_kp = len(pts) + if keep_below: + inside = pts[:, axis] <= value + tol + else: + inside = pts[:, axis] >= value - tol + + new_pts = [] + new_adj = [] + for k in range(n_kp): + k1 = (k + 1) % n_kp + p, q = pts[k], pts[k1] + if inside[k]: + new_pts.append(p) + new_adj.append(adj[k]) + if inside[k] != inside[k1]: + t = (value - p[axis]) / (q[axis] - p[axis]) + x = p + t * (q - p) + x[axis] = value + new_pts.append(x) + # leaving the kept side: the next edge lies on the line; + # entering it: the edge from the crossing to q is the original + new_adj.append(wall if inside[k] else adj[k]) + + if len(new_pts) < 3: + return np.zeros((0, pts.shape[1])), [] + + # edges that lie on the line are walls + new_pts = np.array(new_pts) + for k in range(len(new_pts)): + k1 = (k + 1) % len(new_pts) + if (abs(new_pts[k, axis] - value) <= tol and + abs(new_pts[k1, axis] - value) <= tol): + new_adj[k] = wall + return new_pts, new_adj + + +def _periodic_pieces_2d(voro, bkdwn2seed, lims, per_axes): + """Split the cells of a periodic 2D tessellation at the periodic faces. + + The cells computed by Voro++ in periodic mode wrap across the periodic + faces of the domain. Each cell is cut at those faces and the pieces + outside the domain are translated into it, so that the pieces tile the + domain. The cut edges become domain boundary facets (Voro++ wall ids + -1/-2 for the x faces, -3/-4 for the y faces) and the adjacent cell of + every other edge is resolved to the piece that shares it. + + Args: + voro (list): The cells from pyvoro. + bkdwn2seed (numpy.ndarray): Seed number of each cell. + lims (list): (lower, upper) bounds of the domain, per axis. + per_axes (list): Periodicity flag of each axis. + + Returns: + tuple: The pieces, in the pyvoro cell format, and the seed number + of each piece. + + Raises: + ValueError: If a cell is wider than the domain (too few seeds for + a periodic tessellation) or an edge cannot be matched. + + """ + lengths = [ub - lb for lb, ub in lims] + tol = 1e-10 * max(lengths) + area_tol = 1e-12 * np.prod(lengths) + + # Cut the cells at the periodic faces + pieces = [] # (cell number, vertices, edge adjacencies) + for cell_num, cell in enumerate(voro): + parts = [_cell_loop(cell)] + for axis, flag in enumerate(per_axes): + if not flag: + continue + lb, ub = lims[axis] + length = ub - lb + wall_lo = -(2 * axis + 1) + wall_hi = -(2 * axis + 2) + new_parts = [] + for pts, adj in parts: + extent = pts[:, axis].max() - pts[:, axis].min() + if extent > length + tol: + e_str = 'A cell of the periodic tessellation is wider ' + e_str += 'than the domain along axis ' + str(axis) + e_str += '. More seeds are needed for a periodic ' + e_str += 'microstructure.' + raise ValueError(e_str) + # part below the lower face, translated to the upper side + below = _clip_loop(pts, adj, axis, lb, True, wall_hi, tol) + rest = _clip_loop(pts, adj, axis, lb, False, wall_lo, tol) + if len(rest[0]) == 0: + inner, above = rest, rest + else: + inner = _clip_loop(rest[0], rest[1], axis, ub, True, + wall_hi, tol) + above = _clip_loop(rest[0], rest[1], axis, ub, False, + wall_lo, tol) + for (p_pts, p_adj), shift in ((below, length), (inner, 0), + (above, -length)): + if len(p_pts) < 3: + continue + if _loop_area(p_pts, list(range(len(p_pts)))) < area_tol: + continue + p_pts = np.array(p_pts) + p_pts[:, axis] += shift + new_parts.append((p_pts, p_adj)) + parts = new_parts + for pts, adj in parts: + pieces.append((cell_num, pts, adj)) + + # Resolve the adjacent cells of the edges to pieces + cell_pieces = {} + for piece_num, (cell_num, _, _) in enumerate(pieces): + cell_pieces.setdefault(cell_num, []).append(piece_num) + + new_voro = [] + for piece_num, (cell_num, pts, adj) in enumerate(pieces): + n_kp = len(pts) + faces = [] + for k in range(n_kp): + k1 = (k + 1) % n_kp + adj_cell = adj[k] + if adj_cell >= 0: + candidates = [p for p in cell_pieces.get(adj_cell, []) + if p != piece_num] + adj_cell = _matching_piece(pts[k], pts[k1], candidates, + pieces, tol) + if adj_cell is None: + adj_cell = _wall_of_edge(pts[k], pts[k1], lims, tol) + faces.append({'adjacent_cell': int(adj_cell), + 'vertices': [k, k1]}) + new_voro.append({'vertices': pts.tolist(), + 'faces': faces, + 'adjacency': [[(k - 1) % n_kp, (k + 1) % n_kp] + for k in range(n_kp)], + 'original': voro[cell_num]['original'], + 'volume': _loop_area(pts, list(range(n_kp)))}) + new_bkdwn2seed = np.array([bkdwn2seed[cell_num] + for cell_num, _, _ in pieces], dtype='int') + return new_voro, new_bkdwn2seed + + +def _matching_piece(pt_a, pt_b, candidates, pieces, tol): + """Piece among the candidates that has vertices at both points.""" + for piece_num in candidates: + pts = pieces[piece_num][1] + d_a = np.min(np.linalg.norm(pts - pt_a, axis=1)) + d_b = np.min(np.linalg.norm(pts - pt_b, axis=1)) + if d_a <= tol and d_b <= tol: + return piece_num + return None + + +def _wall_of_edge(pt_a, pt_b, lims, tol): + """Wall id of an edge lying on a face of the domain.""" + for axis, (lb, ub) in enumerate(lims): + if abs(pt_a[axis] - lb) <= tol and abs(pt_b[axis] - lb) <= tol: + return -(2 * axis + 1) + if abs(pt_a[axis] - ub) <= tol and abs(pt_b[axis] - ub) <= tol: + return -(2 * axis + 2) + e_str = 'Cannot resolve the neighbor of the edge between ' + e_str += str(pt_a.tolist()) + ' and ' + str(pt_b.tolist()) + e_str += ' in the periodic tessellation.' + raise ValueError(e_str) + + def _match_index_sets(items, other_items): """Match each item to an unused item of ``other_items`` with the same set of indices. Returns the list of matched positions, or None if any diff --git a/tests/meshing/test_periodic_polymesh.py b/tests/meshing/test_periodic_polymesh.py new file mode 100644 index 00000000..3612075b --- /dev/null +++ b/tests/meshing/test_periodic_polymesh.py @@ -0,0 +1,234 @@ +"""Tests for periodic polygonal meshes (2D).""" +import copy +import itertools + +import numpy as np +import pytest +import scipy.stats + +import microstructpy as msp +from microstructpy.meshing import PolyMesh +from microstructpy.meshing.polymesh import kp_loop +from microstructpy.seeding import Seed +from microstructpy.seeding import SeedList + + +# --------------------------------------------------------------------------- # +# Helpers # +# --------------------------------------------------------------------------- # +def _phases(): + return [{'shape': 'circle', 'size': scipy.stats.uniform(0.15, 0.15)}, + {'shape': 'ellipse', 'size': scipy.stats.uniform(0.2, 0.15), + 'aspect_ratio': scipy.stats.uniform(1.5, 1.5), + 'angle_deg': scipy.stats.uniform(0, 180)}] + + +def _periodic_seeds(domain, per_axes, rng_seed=0, fill=0.55): + seeds = SeedList.from_info(_phases(), fill * domain.area) + seeds.position(domain, rtol=0.0, rng_seed=rng_seed, periodic=per_axes) + return seeds + + +def _seed_areas(pmesh, n_seeds): + areas = np.zeros(n_seeds) + for seed_num, vol in zip(pmesh.seed_numbers, pmesh.volumes): + areas[seed_num] += vol + return areas + + +def _tiled_reference_areas(seeds, domain, per_axes): + """Areas of the cells of the seeds in a periodic tessellation, computed + as a non-periodic tessellation of the seeds tiled across the periodic + axes (3 copies per periodic axis).""" + lims = np.array(domain.limits) + lengths = lims[:, 1] - lims[:, 0] + options = [[-length, 0.0, length] if flag else [0.0] + for length, flag in zip(lengths, per_axes)] + tiled = SeedList() + for t in itertools.product(*options): + for seed in seeds: + copy_seed = copy.deepcopy(seed) + copy_seed.position = list(np.array(seed.position) + np.array(t)) + tiled.append(copy_seed) + n_seeds = len(seeds) + big_lims = [(lb - length, ub + length) if flag else (lb, ub) + for (lb, ub), length, flag in zip(lims, lengths, per_axes)] + big_domain = msp.geometry.Rectangle(limits=big_lims) + pmesh = PolyMesh.from_seeds(tiled, big_domain) + # the cells of the original copies (the zero translation) + i_zero = [i for i, t in enumerate(itertools.product(*options)) + if not any(t)][0] + areas = np.zeros(n_seeds) + for seed_num, vol in zip(pmesh.seed_numbers, pmesh.volumes): + block, local = divmod(seed_num, n_seeds) + if block == i_zero: + areas[local] += vol + return areas + + +def _check_periodic_structure(pmesh, domain, per_axes): + """Points and facets on the periodic faces are paired and are exact + translates of each other.""" + pts = np.array(pmesh.points) + lims = np.array(domain.limits) + lengths = lims[:, 1] - lims[:, 0] + assert pmesh.periodic_axes == list(per_axes) + for axis, flag in enumerate(per_axes): + if not flag: + assert axis not in pmesh.periodic_points + continue + lb, ub = lims[axis] + shift = np.zeros(2) + shift[axis] = lengths[axis] + pairs = pmesh.periodic_points[axis] + low = set(np.nonzero(np.isclose(pts[:, axis], lb))[0]) + high = set(np.nonzero(np.isclose(pts[:, axis], ub))[0]) + assert len(pairs) == len(low) == len(high) + assert set([lo for lo, _ in pairs]) == low + assert set([hi for _, hi in pairs]) == high + for lo, hi in pairs: + assert np.array_equal(pts[hi], pts[lo] + shift) + # facets on the lower face are paired with facets on the upper face + kp_map = dict(pairs) + f_pairs = dict(pmesh.periodic_facets[axis]) + for f_num, facet in enumerate(pmesh.facets): + if all([kp in low for kp in facet]): + assert f_num in f_pairs + image = pmesh.facets[f_pairs[f_num]] + assert set(image) == set([kp_map[kp] for kp in facet]) + # every boundary facet on the faces is a wall facet + for f_num, neighs in enumerate(pmesh.facet_neighbors): + facet = pmesh.facets[f_num] + if all([kp in low for kp in facet]): + assert min(neighs) == -(2 * axis + 1) + if all([kp in high for kp in facet]): + assert min(neighs) == -(2 * axis + 2) + + +def _region_loops(pmesh): + pts = np.array(pmesh.points) + return [pts[kp_loop([pmesh.facets[f] for f in r])] + for r in pmesh.regions] + + +# --------------------------------------------------------------------------- # +# Analytic cases # +# --------------------------------------------------------------------------- # +def test_two_seeds_periodic_in_x(): + domain = msp.geometry.Square(side_length=1, corner=(0, 0)) + seeds = SeedList([Seed.factory('circle', r=0.2, position=(0.1, 0.5)), + Seed.factory('circle', r=0.2, position=(0.6, 0.5))]) + pmesh = PolyMesh.from_seeds(seeds, domain, periodic='x') + + # cell 0 is cut by the periodic face: [0, 0.35] and [0.85, 1] + assert len(pmesh.regions) == 3 + assert sorted(pmesh.seed_numbers) == [0, 0, 1] + areas = _seed_areas(pmesh, 2) + assert np.allclose(areas, [0.5, 0.5]) + pieces = sorted([v for v, s in zip(pmesh.volumes, pmesh.seed_numbers) + if s == 0]) + assert np.allclose(pieces, [0.15, 0.35]) + _check_periodic_structure(pmesh, domain, [True, False]) + assert len(pmesh.periodic_points[0]) == 2 + assert len(pmesh.periodic_facets[0]) == 1 + + +def test_single_seed_tiles_the_domain(): + domain = msp.geometry.Square(side_length=1, corner=(0, 0)) + seeds = SeedList([Seed.factory('circle', r=0.2, position=(0.3, 0.7))]) + pmesh = PolyMesh.from_seeds(seeds, domain, periodic=True) + assert len(pmesh.regions) == 4 + assert np.isclose(sum(pmesh.volumes), 1.0) + assert np.allclose(sorted(pmesh.volumes), [0.04, 0.16, 0.16, 0.64]) + _check_periodic_structure(pmesh, domain, [True, True]) + + +def test_non_periodic_unchanged(): + domain = msp.geometry.Square(side_length=1, corner=(0, 0)) + seeds = SeedList([Seed.factory('circle', r=0.2, position=(0.1, 0.5)), + Seed.factory('circle', r=0.2, position=(0.6, 0.5))]) + pmesh = PolyMesh.from_seeds(seeds, domain) + assert pmesh.periodic_axes is None + assert pmesh.periodic_points is None + assert len(pmesh.regions) == 2 + assert np.allclose(sorted(pmesh.volumes), [0.35, 0.65]) + + +# --------------------------------------------------------------------------- # +# Random microstructures against a tiled reference # +# --------------------------------------------------------------------------- # +@pytest.mark.parametrize('per_axes', [[True, True], [True, False], + [False, True]]) +def test_periodic_matches_tiled_reference(per_axes): + domain = msp.geometry.Rectangle(limits=[(-1, 2), (0.5, 2.5)]) + seeds = _periodic_seeds(domain, per_axes, rng_seed=1) + pmesh = PolyMesh.from_seeds(seeds, domain, periodic=per_axes) + + assert np.isclose(sum(pmesh.volumes), domain.area) + assert np.all(np.array(pmesh.volumes) > 0) + assert set(pmesh.seed_numbers) == set(range(len(seeds))) + areas = _seed_areas(pmesh, len(seeds)) + ref = _tiled_reference_areas(seeds, domain, per_axes) + assert np.allclose(areas, ref, rtol=1e-9, atol=1e-12) + _check_periodic_structure(pmesh, domain, per_axes) + + # every piece is a convex polygon inside the domain + lims = np.array(domain.limits) + for loop in _region_loops(pmesh): + assert np.all(loop >= lims[:, 0] - 1e-9) + assert np.all(loop <= lims[:, 1] + 1e-9) + d_edge = np.roll(loop, -1, axis=0) - loop + d_next = np.roll(d_edge, -1, axis=0) + cross = d_edge[:, 0] * d_next[:, 1] - d_edge[:, 1] * d_next[:, 0] + assert np.all(cross >= -1e-12) or np.all(cross <= 1e-12) + + +def test_periodic_seeds_crossing_faces_are_split(): + domain = msp.geometry.Square(side_length=2) + seeds = _periodic_seeds(domain, [True, True], rng_seed=2) + pmesh = PolyMesh.from_seeds(seeds, domain, periodic=True) + lims = np.array(domain.limits) + n_pieces = np.bincount(pmesh.seed_numbers, minlength=len(seeds)) + # seeds crossing a face have more than one piece + crossing = 0 + for seed_num, seed in enumerate(seeds): + s_lims = np.array(seed.geometry.limits) + if np.any(s_lims[:, 0] < lims[:, 0]) or \ + np.any(s_lims[:, 1] > lims[:, 1]): + crossing += 1 + assert n_pieces[seed_num] >= 2 + assert crossing > 0 + + +# --------------------------------------------------------------------------- # +# Files and errors # +# --------------------------------------------------------------------------- # +def test_periodic_file_round_trip(tmp_path): + domain = msp.geometry.Square(side_length=2) + seeds = _periodic_seeds(domain, [True, False], rng_seed=3, fill=0.5) + pmesh = PolyMesh.from_seeds(seeds, domain, periodic='x') + fname = str(tmp_path / 'polymesh.txt') + pmesh.write(fname) + loaded = PolyMesh.from_file(fname) + assert loaded == pmesh + assert np.array_equal(np.array(loaded.points), np.array(pmesh.points)) + assert loaded.periodic_axes == pmesh.periodic_axes + assert {k: [tuple(p) for p in v] for k, v in + loaded.periodic_points.items()} == pmesh.periodic_points + assert {k: [tuple(p) for p in v] for k, v in + loaded.periodic_facets.items()} == pmesh.periodic_facets + + # a non-periodic mesh has no periodic sections + pmesh_np = PolyMesh.from_seeds(seeds, domain) + assert 'Periodic' not in str(pmesh_np) + + +def test_periodic_errors(): + seeds = SeedList([Seed.factory('circle', r=0.2, position=(0.5, 0.5))]) + with pytest.raises(ValueError): + PolyMesh.from_seeds(seeds, msp.geometry.Circle(r=1), periodic=True) + seeds_3d = SeedList([Seed.factory('sphere', r=0.2, + position=(0.5, 0.5, 0.5))]) + with pytest.raises(NotImplementedError): + PolyMesh.from_seeds(seeds_3d, msp.geometry.Cube(side_length=2), + periodic=True) From b5e169dff04df876e20cf54f3f7c95d79fa331f9 Mon Sep 17 00:00:00 2001 From: Julian Rimoli Date: Tue, 22 Sep 2026 20:49:26 -0700 Subject: [PATCH 09/42] Add periodic 2D triangular and raster meshes A periodic PolyMesh gives a periodic TriMesh: the facets on opposite periodic faces are subdivided identically and Triangle runs with -Y (no Steiner points on the boundary), so the nodes on opposite faces are images of each other. Nodes and facets are paired, snapped to exact translates and stored in periodic_axes / periodic_nodes / periodic_facets, persisted in the text format and written to Abaqus as node sets in matching order. Raster meshes of periodic polymeshes are paired too (the mesh size must divide the domain length). gmsh is not supported for periodic meshes. The pairing helpers live in _misc and are shared with PolyMesh. Co-Authored-By: Claude Fable 5.1 --- src/microstructpy/_misc.py | 154 ++++++++++++++++++++ src/microstructpy/meshing/polymesh.py | 65 +-------- src/microstructpy/meshing/trimesh.py | 185 ++++++++++++++++++++++- tests/meshing/test_periodic_trimesh.py | 194 +++++++++++++++++++++++++ 4 files changed, 528 insertions(+), 70 deletions(-) create mode 100644 tests/meshing/test_periodic_trimesh.py diff --git a/src/microstructpy/_misc.py b/src/microstructpy/_misc.py index 1d0a4acb..4080e213 100644 --- a/src/microstructpy/_misc.py +++ b/src/microstructpy/_misc.py @@ -261,3 +261,157 @@ def periodic_domain_limits(domain): e_str = 'Periodic microstructures require an axis-aligned domain.' raise ValueError(e_str) return [(float(lb), float(ub)) for lb, ub in domain.limits] + + +def periodic_bounds(points, per_axes): + """(lower, upper) bounds of the domain of a periodic mesh. + + The points of a mesh that fills a rectangular domain span the domain, + so its bounds are the extents of the points. + + Args: + points (list or numpy.ndarray): The points of the mesh. + per_axes (list): Periodicity flag of each axis. + + Returns: + list: One (lower, upper) tuple per axis. + + """ + pts = np.array(points, dtype='float') + return [(float(lb), float(ub)) for lb, ub in + zip(pts.min(axis=0), pts.max(axis=0))] + + +def pair_periodic_points(points, per_axes, dom_lims, rel_tol=1e-8): + """Pair the points on opposite periodic faces of a domain. + + For each periodic axis, every point on the lower face is matched with + its image on the upper face, and the coordinates of the pair are + snapped so that the image is exactly the point translated by the + domain length. + + Args: + points (list or numpy.ndarray): The points. + per_axes (list): Periodicity flag of each axis. + dom_lims (list): (lower, upper) bounds of the domain, per axis. + rel_tol (float): Matching tolerance, relative to the largest + domain length. + + Returns: + tuple: The snapped points (numpy.ndarray) and a dictionary that + maps each periodic axis to a list of (lower, upper) point numbers. + + Raises: + ValueError: If a point on a periodic face has no image on the + opposite face. + + """ + pts = np.array(points, dtype='float') + n_dim = pts.shape[1] + lengths = [ub - lb for lb, ub in dom_lims] + tol = rel_tol * max(lengths) + + pairs = {} + for axis, flag in enumerate(per_axes): + if not flag: + continue + lb, ub = dom_lims[axis] + shift = np.zeros(n_dim) + shift[axis] = ub - lb + others = [i for i in range(n_dim) if i != axis] + + low = np.nonzero(np.abs(pts[:, axis] - lb) <= tol)[0] + high = np.nonzero(np.abs(pts[:, axis] - ub) <= tol)[0] + if len(low) != len(high): + e_str = 'The periodic faces along axis ' + str(axis) + e_str += ' have different numbers of points (' + e_str += str(len(low)) + ' and ' + str(len(high)) + ').' + raise ValueError(e_str) + + axis_pairs = [] + if len(low) > 0: + rel = pts[low][:, None, :][:, :, others] + rel = rel - pts[high][None, :, :][:, :, others] + dists = np.sqrt(np.sum(rel * rel, axis=-1)) + for i_low, kp_low in enumerate(low): + i_high = int(np.argmin(dists[i_low])) + if dists[i_low, i_high] > tol: + e_str = 'Point ' + str(kp_low) + ' on the lower ' + e_str += 'periodic face of axis ' + str(axis) + e_str += ' has no image on the upper face.' + raise ValueError(e_str) + dists[:, i_high] = np.inf # one-to-one + kp_high = int(high[i_high]) + pts[kp_low, axis] = lb + pts[kp_high] = pts[kp_low] + shift + axis_pairs.append((int(kp_low), kp_high)) + pairs[axis] = axis_pairs + return pts, pairs + + +def pair_periodic_facets(facets, point_pairs): + """Pair the facets lying on opposite periodic faces. + + Args: + facets (list): Facets (lists of point numbers). + point_pairs (dict): Output of :func:`pair_periodic_points`. + + Returns: + dict: Maps each periodic axis to a list of (lower, upper) facet + numbers. + + Raises: + ValueError: If a facet on a periodic face has no image. + + """ + pairs = {} + for axis, axis_pairs in point_pairs.items(): + kp_map = dict(axis_pairs) + low_set = set(kp_map) + high_set = set(kp_map.values()) + high_facets = {} + for f_num, facet in enumerate(facets): + if len(facet) > 0 and all([kp in high_set for kp in facet]): + high_facets[frozenset(facet)] = f_num + f_pairs = [] + for f_num, facet in enumerate(facets): + if len(facet) == 0 or not all([kp in low_set for kp in facet]): + continue + key = frozenset([kp_map[kp] for kp in facet]) + if key not in high_facets: + e_str = 'Facet ' + str(f_num) + ' on the lower periodic' + e_str += ' face of axis ' + str(axis) + ' has no image' + e_str += ' on the upper face.' + raise ValueError(e_str) + f_pairs.append((f_num, high_facets[key])) + pairs[axis] = f_pairs + return pairs + + +def unwrap_points(points, center, per_axes, dom_lims): + """Translate points by domain lengths to the image nearest a center. + + Used to reassemble a grain that a periodic domain splits into pieces: + along each periodic axis, every point is moved by a multiple of the + domain length so that it lies within half a length of the center. + + Args: + points (list or numpy.ndarray): The points. + center (list or numpy.ndarray): The reference point (e.g. the seed + position). + per_axes (list): Periodicity flag of each axis. + dom_lims (list): (lower, upper) bounds of the domain, per axis. + + Returns: + numpy.ndarray: The unwrapped points. + + """ + pts = np.array(points, dtype='float') + cen = np.array(center, dtype='float') + for axis, flag in enumerate(per_axes): + if not flag: + continue + length = dom_lims[axis][1] - dom_lims[axis][0] + n_shift = np.round((cen[axis] - pts[:, axis]) / length) + pts[:, axis] += n_shift * length + return pts diff --git a/src/microstructpy/meshing/polymesh.py b/src/microstructpy/meshing/polymesh.py index f398f343..d58ff49d 100644 --- a/src/microstructpy/meshing/polymesh.py +++ b/src/microstructpy/meshing/polymesh.py @@ -942,68 +942,9 @@ def _set_periodic_pairs(self, per_axes, dom_lims): image on the opposite face. """ - pts = np.array(self.points, dtype='float') - n_dim = pts.shape[1] - lengths = [ub - lb for lb, ub in dom_lims] - tol = 1e-8 * max(lengths) - - per_points = {} - per_facets = {} - for axis, flag in enumerate(per_axes): - if not flag: - continue - lb, ub = dom_lims[axis] - shift = np.zeros(n_dim) - shift[axis] = ub - lb - others = [i for i in range(n_dim) if i != axis] - - low = np.nonzero(np.abs(pts[:, axis] - lb) <= tol)[0] - high = np.nonzero(np.abs(pts[:, axis] - ub) <= tol)[0] - if len(low) != len(high): - e_str = 'The periodic faces along axis ' + str(axis) - e_str += ' have different numbers of points (' - e_str += str(len(low)) + ' and ' + str(len(high)) + ').' - raise ValueError(e_str) - - pairs = [] - if len(low) > 0: - dists = distance.cdist(pts[low][:, others], - pts[high][:, others]) - for i_low, kp_low in enumerate(low): - i_high = int(np.argmin(dists[i_low])) - if dists[i_low, i_high] > tol: - e_str = 'Point ' + str(kp_low) + ' on the lower ' - e_str += 'periodic face of axis ' + str(axis) - e_str += ' has no image on the upper face.' - raise ValueError(e_str) - dists[:, i_high] = np.inf # one-to-one - kp_high = int(high[i_high]) - # snap the pair to exact periodic images - pts[kp_low, axis] = lb - pts[kp_high] = pts[kp_low] + shift - pairs.append((int(kp_low), kp_high)) - per_points[axis] = pairs - - # facets on the faces - kp_map = {lo: hi for lo, hi in pairs} - low_set = set(kp_map) - high_set = set(kp_map.values()) - high_facets = {} - for f_num, facet in enumerate(self.facets): - if all([kp in high_set for kp in facet]): - high_facets[frozenset(facet)] = f_num - f_pairs = [] - for f_num, facet in enumerate(self.facets): - if not all([kp in low_set for kp in facet]): - continue - key = frozenset([kp_map[kp] for kp in facet]) - if key not in high_facets: - e_str = 'Facet ' + str(f_num) + ' on the lower periodic' - e_str += ' face of axis ' + str(axis) + ' has no image' - e_str += ' on the upper face.' - raise ValueError(e_str) - f_pairs.append((f_num, high_facets[key])) - per_facets[axis] = f_pairs + pts, per_points = _misc.pair_periodic_points(self.points, per_axes, + dom_lims) + per_facets = _misc.pair_periodic_facets(self.facets, per_points) self.points = pts.tolist() self.periodic_axes = [bool(f) for f in per_axes] diff --git a/src/microstructpy/meshing/trimesh.py b/src/microstructpy/meshing/trimesh.py index 2cad17d1..a757c96f 100644 --- a/src/microstructpy/meshing/trimesh.py +++ b/src/microstructpy/meshing/trimesh.py @@ -67,13 +67,20 @@ class TriMesh(object): # Constructors # # ----------------------------------------------------------------------- # def __init__(self, points, elements, element_attributes=None, facets=None, - facet_attributes=None): + facet_attributes=None, periodic_axes=None, + periodic_nodes=None, periodic_facets=None): self.points = points self.elements = elements self.element_attributes = element_attributes self.facets = facets self.facet_attributes = facet_attributes + # Periodicity: flags per axis, and the pairs of (low face, high face) + # nodes and facets that are periodic images of each other, per axis + self.periodic_axes = periodic_axes + self.periodic_nodes = periodic_nodes + self.periodic_facets = periodic_facets + @classmethod def from_file(cls, filename): """Read TriMesh from file. @@ -100,8 +107,17 @@ def from_file(cls, filename): n_eas = 0 n_facets = 0 n_fas = 0 + per_axes = None + per_nodes = [] + per_fts = [] for line in file.readlines(): - if 'Mesh Points'.lower() in line.lower(): + if 'Periodic Axes'.lower() in line.lower(): + stage = 'periodic axes' + elif 'Periodic Nodes'.lower() in line.lower(): + stage = 'periodic nodes' + elif 'Periodic Facets'.lower() in line.lower(): + stage = 'periodic facets' + elif 'Mesh Points'.lower() in line.lower(): n_pts = int(line.split(':')[1]) stage = 'points' elif 'Mesh Elements'.lower() in line.lower(): @@ -129,6 +145,12 @@ def from_file(cls, filename): elif stage == 'facet attributes': if n_fas > 0: facet_atts.append(_misc.from_str(line)) + elif stage == 'periodic axes': + per_axes = [bool(int(f)) for f in line.split(',')] + elif stage == 'periodic nodes': + per_nodes.append([int(n) for n in line.split(',')]) + elif stage == 'periodic facets': + per_fts.append([int(n) for n in line.split(',')]) else: pass @@ -139,7 +161,19 @@ def from_file(cls, filename): assert len(facets) == n_facets assert len(facet_atts) == n_fas - return cls(pts, elems, elem_atts, facets, facet_atts) + per_node_pairs = None + per_facet_pairs = None + if per_axes is not None: + per_node_pairs = {ax: [] for ax, f in enumerate(per_axes) if f} + per_facet_pairs = {ax: [] for ax, f in enumerate(per_axes) if f} + for ax, lo, hi in per_nodes: + per_node_pairs[ax].append((lo, hi)) + for ax, lo, hi in per_fts: + per_facet_pairs[ax].append((lo, hi)) + + return cls(pts, elems, elem_atts, facets, facet_atts, + periodic_axes=per_axes, periodic_nodes=per_node_pairs, + periodic_facets=per_facet_pairs) @classmethod def from_polymesh(cls, polymesh, phases=None, mesher='Triangle/Tetgen', @@ -198,18 +232,60 @@ def from_polymesh(cls, polymesh, phases=None, mesher='Triangle/Tetgen', this control. """ + # A periodic polygon mesh gives a periodic triangular mesh: the + # nodes on opposite periodic faces are images of each other + per_axes = getattr(polymesh, 'periodic_axes', None) + periodic = per_axes is not None and any(per_axes) + key = str(mesher).lower().strip() if key in ('triangle/tetgen', 'triangle', 'tetgen'): tri_args = _call_meshpy(polymesh, phases, min_angle, max_volume, - max_edge_length) + max_edge_length, periodic=periodic) elif key == 'gmsh': + if periodic: + e_str = 'Periodic meshes are not supported with gmsh; use ' + e_str += 'the Triangle/TetGen mesher.' + raise NotImplementedError(e_str) tri_args = _call_gmsh(polymesh, phases, mesh_size, max_edge_length) else: e_str = 'Unknown mesher ' + repr(mesher) + '. Options are ' e_str += "'Triangle/TetGen', 'Triangle', 'TetGen', and 'gmsh'." raise ValueError(e_str) - return cls(*tri_args) + mesh = cls(*tri_args) + if periodic: + dom_lims = _misc.periodic_bounds(polymesh.points, per_axes) + mesh._set_periodic_pairs(per_axes, dom_lims) + return mesh + + # ----------------------------------------------------------------------- # + # Periodicity # + # ----------------------------------------------------------------------- # + def _set_periodic_pairs(self, per_axes, dom_lims): + """Pair the nodes and facets on opposite periodic faces. + + The nodes on the lower face of each periodic axis are matched with + their images on the upper face and snapped to exact translates; + the facets on the faces are paired likewise. The results are stored + in ``periodic_axes``, ``periodic_nodes`` (dict: axis -> list of + (lower, upper) node numbers) and ``periodic_facets`` (dict: axis -> + list of (lower, upper) facet numbers). + + Raises: + ValueError: If a node or facet on a periodic face has no image. + + """ + pts, per_nodes = _misc.pair_periodic_points(self.points, per_axes, + dom_lims) + if self.facets is None: + per_facets = {axis: [] for axis in per_nodes} + else: + per_facets = _misc.pair_periodic_facets(self.facets, per_nodes) + + self.points = pts + self.periodic_axes = [bool(f) for f in per_axes] + self.periodic_nodes = per_nodes + self.periodic_facets = per_facets # ----------------------------------------------------------------------- # # String and Representation Functions # @@ -247,6 +323,18 @@ def __str__(self): str_str += '\n'.join(['\t' + str(a) for a in self.facet_attributes]) + if self.periodic_axes is not None and any(self.periodic_axes): + flags = [int(bool(f)) for f in self.periodic_axes] + str_str += '\nPeriodic Axes: ' + str(len(flags)) + '\n' + str_str += '\t' + ', '.join([str(f) for f in flags]) + for name, pairs in (('Periodic Nodes', self.periodic_nodes), + ('Periodic Facets', self.periodic_facets)): + rows = [(ax, lo, hi) for ax in sorted(pairs or {}) + for lo, hi in pairs[ax]] + str_str += '\n' + name + ': ' + str(len(rows)) + str_str += ''.join(['\n\t' + ', '.join([str(n) for n in row]) + for row in rows]) + return str_str def __repr__(self): @@ -321,6 +409,9 @@ def write(self, filename, format='txt', seeds=None, polymesh=None): for kp in elm]) + '\n' for i, elm in enumerate(self.elements)]) + # Node sets - periodic faces (in paired order) + abaqus += _abaqus_periodic_nsets(self) + # Element sets - seed number elset_n_per = 16 if self.element_attributes is None: @@ -759,6 +850,24 @@ def from_polymesh(cls, polymesh, mesh_size, phases=None): sides = [lb + np.arange(0, dlen, mesh_size) for lb, dlen in zip(mins, lens)] + # A periodic polymesh gives a periodic raster mesh: the grid must + # then reach the opposite faces exactly + per_axes = getattr(polymesh, 'periodic_axes', None) + periodic = per_axes is not None and any(per_axes) + if periodic: + for axis, flag in enumerate(per_axes): + if not flag: + continue + length = maxs[axis] - mins[axis] + n_pix = int(round(length / mesh_size)) + misfit = abs(n_pix * mesh_size - length) + if n_pix < 1 or misfit > 1e-8 * length: + e_str = 'The mesh size of a periodic raster mesh must ' + e_str += 'divide the domain length along axis ' + e_str += str(axis) + ' (' + str(length) + ').' + raise ValueError(e_str) + sides[axis] = np.linspace(mins[axis], maxs[axis], n_pix + 1) + n_dim = len(mins) if n_dim not in _RASTER_CORNERS: e_str = 'Cannot create a raster mesh in ' + str(n_dim) + 'D.' @@ -841,7 +950,11 @@ def from_polymesh(cls, polymesh, mesh_size, phases=None): elems = node_n_conv[elems] facets = node_n_conv[facets] - return cls(nodes, elems, elem_atts, facets, facet_atts) + mesh = cls(nodes, elems, elem_atts, facets, facet_atts) + if periodic: + dom_lims = [(float(lb), float(ub)) for lb, ub in zip(mins, maxs)] + mesh._set_periodic_pairs(per_axes, dom_lims) + return mesh # ----------------------------------------------------------------------- # # String and Representation Functions # @@ -909,6 +1022,9 @@ def write(self, filename, format='txt', seeds=None, polymesh=None): for kp in elem]) + '\n' for i, elem in enumerate(self.elements)]) + # Node sets - periodic faces (in paired order) + abaqus += _abaqus_periodic_nsets(self) + # Element sets - seed number elset_n_per = 16 if self.element_attributes is None: @@ -1276,7 +1392,7 @@ def _pt_ab(i, pt): def _call_meshpy(polymesh, phases=None, min_angle=0, max_volume=float('inf'), - max_edge_length=float('inf')): + max_edge_length=float('inf'), periodic=False): # condition the phases input if phases is None: @@ -1316,6 +1432,19 @@ def _call_meshpy(polymesh, phases=None, min_angle=0, max_volume=float('inf'), n_float = np.linalg.norm(rel_pos) / max_edge_length n_int = max(1, np.ceil(n_float)) n_subs[i] = n_int + + # Facets on opposite periodic faces are subdivided identically, so + # that their nodes are images of each other + if periodic: + f_index = {f_num - 1: i for i, f_num in enumerate(facet_nums)} + for axis_pairs in (polymesh.periodic_facets or {}).values(): + for f_lo, f_hi in axis_pairs: + if f_lo in f_index and f_hi in f_index: + n_max = max(n_subs[f_index[f_lo]], + n_subs[f_index[f_hi]]) + n_subs[f_index[f_lo]] = n_max + n_subs[f_index[f_hi]] = n_max + sub_out = meshpy.triangle.subdivide_facets(n_subs, pts, facets, facet_nums) pts, facets, facet_nums = sub_out @@ -1392,13 +1521,16 @@ def _call_meshpy(polymesh, phases=None, min_angle=0, max_volume=float('inf'), # (global) constraint would cap the per-phase values and, in 2D, an # infinite one is formatted as 'ainf', which Triangle reads as the # switches -a -i -n -f. + # A periodic mesh must keep the nodes of the boundary facets as they are + # (Triangle's -Y switch), so that opposite faces have matching nodes. if n_dim == 2: tri_mesh = meshpy.triangle.build(info, attributes=True, volume_constraints=True, max_volume=None, min_angle=min_angle, - generate_faces=True) + generate_faces=True, + allow_boundary_steiner=not periodic) else: opts = meshpy.tet.Options('pq') opts.mindihedral = min_angle @@ -1796,6 +1928,43 @@ def _facet_in_normal(pts, cen_pt): return un, f_cen +def _abaqus_periodic_nsets(mesh): + """Abaqus node sets of the periodic faces of a mesh. + + For each periodic axis, two unsorted node sets are written, + ``Set-N-Periodic--Low`` and ``Set-N-Periodic--High``, whose + n-th entries are periodic images of each other (so that the pairs can + be tied by equations). + + Args: + mesh (TriMesh): The mesh. + + Returns: + str: The ``*Nset`` blocks, or an empty string for a non-periodic + mesh. + + """ + per_nodes = getattr(mesh, 'periodic_nodes', None) + if not per_nodes: + return '' + abaqus = '' + n_per = 16 + for axis in sorted(per_nodes): + pairs = per_nodes[axis] + if not pairs: + continue + axis_name = 'XYZ'[axis] + for side, kps in (('Low', [lo for lo, _ in pairs]), + ('High', [hi for _, hi in pairs])): + name = 'Set-N-Periodic-' + axis_name + '-' + side + abaqus += '*Nset, nset=' + name + ', unsorted\n' + for i in range(0, len(kps), n_per): + chunk = kps[i:i + n_per] + abaqus += ', '.join([str(int(kp) + 1) for kp in chunk]) + abaqus += '\n' + return abaqus + + def _abaqus_exterior_unions(polymesh, defined_surfs): """Abaqus surfaces that combine the facet surfaces on each domain face. diff --git a/tests/meshing/test_periodic_trimesh.py b/tests/meshing/test_periodic_trimesh.py new file mode 100644 index 00000000..eb93fafc --- /dev/null +++ b/tests/meshing/test_periodic_trimesh.py @@ -0,0 +1,194 @@ +"""Tests for periodic triangular and raster meshes (2D).""" +import numpy as np +import pytest +import scipy.stats + +import microstructpy as msp +from microstructpy.meshing import PolyMesh +from microstructpy.meshing import RasterMesh +from microstructpy.meshing import TriMesh +from microstructpy.seeding import SeedList + + +# --------------------------------------------------------------------------- # +# Helpers # +# --------------------------------------------------------------------------- # +def _phases(): + return [{'shape': 'circle', 'size': scipy.stats.uniform(0.15, 0.15), + 'material_type': 'crystalline'}, + {'shape': 'ellipse', 'size': scipy.stats.uniform(0.2, 0.15), + 'aspect_ratio': scipy.stats.uniform(1.5, 1.5), + 'angle_deg': scipy.stats.uniform(0, 180), + 'material_type': 'amorphous'}] + + +@pytest.fixture(scope='module') +def periodic_case(): + domain = msp.geometry.Square(side_length=2, corner=(0, 0)) + phases = _phases() + seeds = SeedList.from_info(phases, 0.55 * domain.area) + seeds.position(domain, rtol=0.0, rng_seed=1, periodic=True) + pmesh = PolyMesh.from_seeds(seeds, domain, periodic=True) + return domain, phases, seeds, pmesh + + +def _element_areas(mesh): + pts = np.array(mesh.points) + elems = np.array(mesh.elements) + p0, p1, p2 = pts[elems[:, 0]], pts[elems[:, 1]], pts[elems[:, 2]] + return 0.5 * ((p1[:, 0] - p0[:, 0]) * (p2[:, 1] - p0[:, 1]) - + (p2[:, 0] - p0[:, 0]) * (p1[:, 1] - p0[:, 1])) + + +def _check_periodic_mesh(mesh, domain, per_axes): + pts = np.array(mesh.points) + lims = np.array(domain.limits) + assert mesh.periodic_axes == list(per_axes) + for axis, flag in enumerate(per_axes): + if not flag: + assert axis not in mesh.periodic_nodes + continue + lb, ub = lims[axis] + shift = np.zeros(2) + shift[axis] = ub - lb + pairs = mesh.periodic_nodes[axis] + low = set(np.nonzero(np.isclose(pts[:, axis], lb))[0]) + high = set(np.nonzero(np.isclose(pts[:, axis], ub))[0]) + # every node on a periodic face is paired, exactly + assert len(pairs) == len(low) == len(high) > 0 + assert set([lo for lo, _ in pairs]) == low + assert set([hi for _, hi in pairs]) == high + for lo, hi in pairs: + assert np.array_equal(pts[hi], pts[lo] + shift) + # facets (edges) on the faces are paired + kp_map = dict(pairs) + f_pairs = dict(mesh.periodic_facets[axis]) + n_low = 0 + for f_num, facet in enumerate(mesh.facets): + if all([kp in low for kp in facet]): + n_low += 1 + assert f_num in f_pairs + image = mesh.facets[f_pairs[f_num]] + assert set(image) == set([kp_map[kp] for kp in facet]) + # the edges on a face connect its nodes in a chain + assert n_low == len(pairs) - 1 + + +# --------------------------------------------------------------------------- # +# Triangular meshes # +# --------------------------------------------------------------------------- # +def test_periodic_trimesh_nodes_match(periodic_case): + domain, phases, seeds, pmesh = periodic_case + mesh = TriMesh.from_polymesh(pmesh, phases, min_angle=20) + _check_periodic_mesh(mesh, domain, [True, True]) + areas = _element_areas(mesh) + assert np.all(areas > 0) + assert np.isclose(areas.sum(), domain.area) + # element attributes are seed numbers of the mesh + assert set(mesh.element_attributes) <= set(pmesh.seed_numbers) + + +def test_periodic_trimesh_edge_subdivision(periodic_case): + domain, phases, seeds, pmesh = periodic_case + mesh = TriMesh.from_polymesh(pmesh, phases, min_angle=20, + max_edge_length=0.08) + _check_periodic_mesh(mesh, domain, [True, True]) + pts = np.array(mesh.points) + facets = np.array(mesh.facets) + lengths = np.linalg.norm(pts[facets[:, 0]] - pts[facets[:, 1]], axis=1) + assert lengths.max() <= 0.08 + 1e-9 + # the boundary was subdivided (more node pairs than polymesh points + # on the face) + poly_pts = np.array(pmesh.points) + n_poly = np.sum(np.isclose(poly_pts[:, 0], 0)) + assert len(mesh.periodic_nodes[0]) > n_poly + + +def test_periodic_trimesh_single_axis(): + domain = msp.geometry.Rectangle(length=3, width=2, corner=(0, 0)) + phases = _phases() + seeds = SeedList.from_info(phases, 0.55 * domain.area) + seeds.position(domain, rtol=0.0, rng_seed=2, periodic='x') + pmesh = PolyMesh.from_seeds(seeds, domain, periodic='x') + mesh = TriMesh.from_polymesh(pmesh, phases, min_angle=20) + _check_periodic_mesh(mesh, domain, [True, False]) + assert np.isclose(_element_areas(mesh).sum(), domain.area) + + +def test_non_periodic_trimesh_unchanged(periodic_case): + domain, phases, seeds, pmesh = periodic_case + pmesh_np = PolyMesh.from_seeds(seeds, domain) + mesh = TriMesh.from_polymesh(pmesh_np, phases, min_angle=20) + assert mesh.periodic_axes is None + assert mesh.periodic_nodes is None + assert 'Periodic' not in str(mesh) + + +def test_periodic_trimesh_file_round_trip(periodic_case, tmp_path): + domain, phases, seeds, pmesh = periodic_case + mesh = TriMesh.from_polymesh(pmesh, phases, min_angle=20) + fname = str(tmp_path / 'trimesh.txt') + mesh.write(fname) + loaded = TriMesh.from_file(fname) + assert np.array_equal(np.array(loaded.points), np.array(mesh.points)) + assert np.array_equal(np.array(loaded.elements), + np.array(mesh.elements)) + assert loaded.periodic_axes == mesh.periodic_axes + assert {k: [tuple(p) for p in v] for k, v in + loaded.periodic_nodes.items()} == mesh.periodic_nodes + assert {k: [tuple(p) for p in v] for k, v in + loaded.periodic_facets.items()} == mesh.periodic_facets + + +def test_periodic_trimesh_abaqus_node_sets(periodic_case, tmp_path): + domain, phases, seeds, pmesh = periodic_case + mesh = TriMesh.from_polymesh(pmesh, phases, min_angle=20) + fname = str(tmp_path / 'mesh.inp') + mesh.write(fname, 'abaqus', seeds, pmesh) + with open(fname, 'r') as file: + lines = file.read().splitlines() + + def nset(name): + i = lines.index('*Nset, nset=' + name + ', unsorted') + 1 + nodes = [] + while i < len(lines) and not lines[i].startswith('*'): + nodes.extend([int(n) for n in lines[i].split(',')]) + i += 1 + return nodes + + pts = np.array(mesh.points) + for axis, name in enumerate('XY'): + low = nset('Set-N-Periodic-' + name + '-Low') + high = nset('Set-N-Periodic-' + name + '-High') + pairs = mesh.periodic_nodes[axis] + assert low == [lo + 1 for lo, _ in pairs] + assert high == [hi + 1 for _, hi in pairs] + shift = np.zeros(2) + shift[axis] = 2 + for lo, hi in zip(low, high): + assert np.array_equal(pts[hi - 1], pts[lo - 1] + shift) + + +def test_periodic_gmsh_not_supported(periodic_case): + domain, phases, seeds, pmesh = periodic_case + with pytest.raises(NotImplementedError): + TriMesh.from_polymesh(pmesh, phases, mesher='gmsh', mesh_size=0.1) + + +# --------------------------------------------------------------------------- # +# Raster meshes # +# --------------------------------------------------------------------------- # +def test_periodic_raster_mesh(periodic_case): + domain, phases, seeds, pmesh = periodic_case + mesh = RasterMesh.from_polymesh(pmesh, 0.05, phases) + pts = np.array(mesh.points) + assert mesh.periodic_axes == [True, True] + for axis in (0, 1): + pairs = mesh.periodic_nodes[axis] + assert len(pairs) == 41 + shift = np.zeros(2) + shift[axis] = 2 + for lo, hi in pairs: + assert np.array_equal(pts[hi], pts[lo] + shift) + with pytest.raises(ValueError): + RasterMesh.from_polymesh(pmesh, 0.07, phases) From d243eb7af9a516342d46596cb6fc3c938c7341bb Mon Sep 17 00:00:00 2001 From: Julian Rimoli Date: Tue, 22 Sep 2026 20:49:26 -0700 Subject: [PATCH 10/42] Add the periodic option to the CLI and unwrap split grains in verification is a field of in the XML input (True, a list of flags, or axis names such as 'xy'); cli.run(periodic=...) passes it to the seed placement and the tessellation, the meshes inherit it. seeds_of_best_fit unwraps the points of grains split by the periodic faces around their seed before fitting. Documented in the domain page and the changelog. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.rst | 12 ++++ docs/source/cli/domain.rst | 43 +++++++++++++ src/microstructpy/cli.py | 20 +++++- src/microstructpy/verification.py | 12 ++++ tests/cli/test_periodic_cli.py | 97 +++++++++++++++++++++++++++++ tests/test_periodic_verification.py | 49 +++++++++++++++ 6 files changed, 230 insertions(+), 3 deletions(-) create mode 100644 tests/cli/test_periodic_cli.py create mode 100644 tests/test_periodic_verification.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 9a0f565d..23b0ec70 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -8,6 +8,18 @@ and this project adheres to `Semantic Versioning`_. Unreleased ---------- +Added +''''' +- Periodic microstructures (2D): the ```` field of the domain (or + the ``periodic`` argument of ``cli.run``, ``SeedList.position`` and + ``PolyMesh.from_seeds``) selects the periodic axes. Seeds crossing a + periodic face are placed without overlapping the opposite side, the + Laguerre tessellation is periodic across those faces, and the triangular + and raster meshes have matching nodes on opposite faces. The pairs of + periodic points/nodes and facets are stored in the meshes and their text + files; the Abaqus output has a node set per periodic face in matching + order; the verification unwraps grains that are split by the faces. + Fixed ''''' - Seed generation is reproducible: the RNG seed chain no longer depends on diff --git a/docs/source/cli/domain.rst b/docs/source/cli/domain.rst index 40de86a9..8df3a0b4 100644 --- a/docs/source/cli/domain.rst +++ b/docs/source/cli/domain.rst @@ -298,3 +298,46 @@ Below are some example square domain definitions.
5, 0
+ +Periodicity +^^^^^^^^^^^ + +Rectangular domains (rectangle, square, box, cube) can be made periodic with +the ```` field, which lists the periodic axes. Seeds that cross a +periodic face are placed so that they do not overlap seeds on the opposite +side, the tessellation is periodic across those faces (a grain that crosses a +face continues on the opposite side), and the triangular mesh has matching +nodes on opposite faces. The pairs of periodic nodes are written with the +meshes, and the Abaqus output contains a node set per periodic face, in +matching order. + +.. code-block:: XML + + + + + + square + 10 + + True + + + + rectangle + 10, 5 + + x + + + + cube + 10 + + xz + + + +The mesher must be Triangle/TetGen for periodic microstructures (with gmsh the +nodes on opposite faces are not guaranteed to match), and the mesh size of a +raster mesh must divide the domain length along the periodic axes. diff --git a/src/microstructpy/cli.py b/src/microstructpy/cli.py index ba04c56e..008af8c1 100644 --- a/src/microstructpy/cli.py +++ b/src/microstructpy/cli.py @@ -168,11 +168,15 @@ def read_input(filename): domain_data = in_data['domain'] domain_shape = domain_data['shape'] domain_kwargs = {k: v for k, v in domain_data.items() if k != 'shape'} + # periodicity is a property of the run, not of the geometry + periodic = domain_kwargs.pop('periodic', None) domain = geometry.factory(domain_shape, **domain_kwargs) in_data['domain'] = domain # Default settings kwargs = in_data.get('settings', {}) + if periodic is not None: + kwargs['periodic'] = periodic run_dir = kwargs.get('directory', '.') if not os.path.isabs(run_dir): rel_path = os.path.join(file_path, run_dir) @@ -267,7 +271,8 @@ def run(phases, domain, verbose=False, restart=True, directory='.', mesh_max_volume=float('inf'), mesh_min_angle=0, mesh_max_edge_length=float('inf'), mesh_size=float('inf'), verify=False, color_by='material', colormap='viridis', - seeds_kwargs=None, poly_kwargs=None, tri_kwargs=None): + seeds_kwargs=None, poly_kwargs=None, tri_kwargs=None, + periodic=False): r"""Run MicroStructPy This is the primary run function for the package. It performs these steps: @@ -377,6 +382,14 @@ def run(phases, domain, verbose=False, restart=True, directory='.', :meth:`.PolyMesh.plot` in 3D. tri_kwargs (dict): Additional keyword arguments that will be passed to :meth:`.TriMesh.plot`. + periodic (bool, list, or str): *(optional)* Periodicity of the + microstructure: True for all axes, a list of booleans (one per + axis), or the names of the periodic axes such as ``'x'`` or + ``'xy'``. Seeds are placed, the domain is tessellated and the + mesh is generated so that opposite faces of the (rectangular) + domain match; the pairs of periodic nodes are stored in the + meshes. In the XML input, ```` is a field of + ````. Defaults to False. .. _`Specifying Colors`: https://matplotlib.org/users/colors.html .. _`Choosing Colormaps in Matplotlib`: https://matplotlib.org/tutorials/colors/colormaps.html @@ -463,7 +476,8 @@ def run(phases, domain, verbose=False, restart=True, directory='.', kw = 'position' rng_seed = rng_seeds.get(kw, 0) pos_dists = {i: p[kw] for i, p in enumerate(phases) if kw in p} - seeds.position(domain, pos_dists, rng_seed, rtol=rtol, verbose=verbose) + seeds.position(domain, pos_dists, rng_seed, rtol=rtol, verbose=verbose, + periodic=periodic) # Write seeds seeds_types = filetypes.get('seeds', []) @@ -517,7 +531,7 @@ def run(phases, domain, verbose=False, restart=True, directory='.', print('Creating polygon mesh.') pmesh = PolyMesh.from_seeds(seeds, domain, edge_opt, edge_opt_n_iter, - verbose) + verbose, periodic=periodic) # Write polymesh poly_types = filetypes.get('poly', []) diff --git a/src/microstructpy/verification.py b/src/microstructpy/verification.py index 49445bfc..fe186d09 100644 --- a/src/microstructpy/verification.py +++ b/src/microstructpy/verification.py @@ -281,6 +281,14 @@ def seeds_of_best_fit(seeds, phases, pmesh, tmesh): poly_facet_is_ext = np.min(poly_facet_neigh_seeds, axis=-1) < 0 n_dim = seeds[0].geometry.n_dim + + # In a periodic domain, a grain can be split into pieces on opposite + # faces: its points are unwrapped around the seed before fitting + per_axes = getattr(pmesh, 'periodic_axes', None) + periodic = per_axes is not None and any(per_axes) + if periodic: + dom_lims = _misc.periodic_bounds(pmesh.points, per_axes) + fit_seeds = [] for i, seed in enumerate(seeds): p = seed.phase @@ -310,6 +318,10 @@ def seeds_of_best_fit(seeds, phases, pmesh, tmesh): kps = np.unique([kp for f in seed_facets for kp in f]) seed_pts = poly_pts[kps.astype('int')] + if periodic: + seed_pts = _misc.unwrap_points(seed_pts, seed.position, + per_axes, dom_lims) + try: fit_geom = seed.geometry.best_fit(seed_pts) except ValueError: diff --git a/tests/cli/test_periodic_cli.py b/tests/cli/test_periodic_cli.py new file mode 100644 index 00000000..67c786ee --- /dev/null +++ b/tests/cli/test_periodic_cli.py @@ -0,0 +1,97 @@ +"""End-to-end test of a periodic microstructure through the CLI.""" +import os + +import numpy as np + +from microstructpy import cli +from microstructpy.meshing import PolyMesh +from microstructpy.meshing import TriMesh + +PERIODIC_XML = """ + + + Matrix + circle + + uniform + 0.25 + 0.15 + + 2 + + + Inclusions + ellipse + 0.4 + 2 + + uniform + 0 + 180 + + 1 + + + + square + 3 + 0, 0 + {periodic} + + + + {directory} + False + 20 + 0.1 + True + + +""" + + +def _run(tmp_path, periodic): + out_dir = tmp_path / 'out' + xml = tmp_path / 'input.xml' + xml.write_text(PERIODIC_XML.format(periodic=periodic, + directory=str(out_dir))) + cli.run_file(str(xml)) + return out_dir + + +def test_periodic_input_read(): + in_data = cli.dict_convert({'domain': {'shape': 'square', + 'periodic': ' xy '}}) + assert in_data['domain']['periodic'].strip() == 'xy' + + +def test_periodic_run(tmp_path): + out_dir = _run(tmp_path, 'xy') + for name in ('seeds.txt', 'polymesh.txt', 'trimesh.txt', 'seeds.png', + 'polymesh.png', 'trimesh.png'): + assert os.path.exists(str(out_dir / name)) + + pmesh = PolyMesh.from_file(str(out_dir / 'polymesh.txt')) + tmesh = TriMesh.from_file(str(out_dir / 'trimesh.txt')) + assert pmesh.periodic_axes == [True, True] + assert tmesh.periodic_axes == [True, True] + assert np.isclose(sum(pmesh.volumes), 9.0) + + pts = np.array(tmesh.points) + for axis in (0, 1): + shift = np.zeros(2) + shift[axis] = 3 + pairs = tmesh.periodic_nodes[axis] + assert len(pairs) == np.sum(np.isclose(pts[:, axis], 0)) + for lo, hi in pairs: + assert np.array_equal(pts[hi], pts[lo] + shift) + + # verification ran (grains split by the faces are unwrapped) + assert os.path.exists(str(out_dir / 'verification' / 'mles.txt')) + + +def test_periodic_run_single_axis(tmp_path): + out_dir = _run(tmp_path, 'y') + tmesh = TriMesh.from_file(str(out_dir / 'trimesh.txt')) + assert tmesh.periodic_axes == [False, True] + assert list(tmesh.periodic_nodes) == [1] diff --git a/tests/test_periodic_verification.py b/tests/test_periodic_verification.py new file mode 100644 index 00000000..3c630118 --- /dev/null +++ b/tests/test_periodic_verification.py @@ -0,0 +1,49 @@ +"""Verification of periodic microstructures: split grains are unwrapped.""" +import numpy as np +import scipy.stats + +import microstructpy as msp +from microstructpy import _misc +from microstructpy import verification +from microstructpy.meshing import PolyMesh +from microstructpy.meshing import TriMesh +from microstructpy.seeding import SeedList + + +def test_unwrap_points(): + dom_lims = [(0.0, 2.0), (0.0, 3.0)] + pts = [[1.9, 0.1], [0.1, 2.9], [1.0, 1.0]] + out = _misc.unwrap_points(pts, [0.1, 0.2], [True, True], dom_lims) + assert np.allclose(out, [[-0.1, 0.1], [0.1, -0.1], [1.0, 1.0]]) + out = _misc.unwrap_points(pts, [0.1, 0.2], [True, False], dom_lims) + assert np.allclose(out, [[-0.1, 0.1], [0.1, 2.9], [1.0, 1.0]]) + + +def test_split_grains_fit_after_unwrapping(): + phases = [{'shape': 'circle', 'size': scipy.stats.uniform(0.2, 0.2), + 'material_type': 'crystalline'}] + domain = msp.geometry.Square(side_length=3, corner=(0, 0)) + seeds = SeedList.from_info(phases, domain.area) + seeds.position(domain, rng_seed=1, periodic=True) + pmesh = PolyMesh.from_seeds(seeds, domain, periodic=True) + tmesh = TriMesh.from_polymesh(pmesh, phases, min_angle=20) + + fit = verification.seeds_of_best_fit(seeds, phases, pmesh, tmesh) + n_pieces = np.bincount(pmesh.seed_numbers, minlength=len(seeds)) + split = n_pieces > 1 + assert split.sum() > 0 + assert all([s.geometry is not None for s in fit]) + + r_in = np.array([s.geometry.r for s in seeds]) + r_fit = np.array([s.geometry.r for s in fit]) + rel_err = np.abs(r_fit - r_in) / r_in + # the fits of the split grains are as good as the others, and their + # centers stay near the seeds (not at the average of two images) + assert rel_err[split].mean() < rel_err[~split].mean() + 0.05 + # without unwrapping, the center of a split grain would lie between + # its pieces, about half a domain length away from the seed + for seed, fit_seed, is_split in zip(seeds, fit, split): + if not is_split: + continue + d_cen = np.array(fit_seed.geometry.center) - np.array(seed.position) + assert np.linalg.norm(d_cen) < seed.geometry.r From d09ad032d1b80c4fdc4dcd7af2ae7da90d02924f Mon Sep 17 00:00:00 2001 From: Julian Rimoli Date: Tue, 22 Sep 2026 21:25:35 -0700 Subject: [PATCH 11/42] Add periodic 3D tessellation Cells that cross a periodic face of a box are cut by the face planes (convex polyhedron clipping, with the cap face taken as the convex hull of the cut points), the pieces are translated back into the domain and their neighbours are resolved, so the polyhedral mesh tiles space along the periodic axes. Vertices within a small tolerance of a cut plane are snapped onto it before cutting and nearly coincident points are merged globally afterwards, which keeps the cuts of the two cells sharing a face consistent and lets the points and facets on opposite faces be paired exactly, as in 2D. Pieces are dropped only when they are flat. Validated against the non-periodic tessellation of the 3x3x3 tiled seeds (per-seed volumes) and against a single sphere tiling the cube. Co-Authored-By: Claude Fable 5.1 --- src/microstructpy/meshing/polymesh.py | 424 +++++++++++++++++++++++- tests/meshing/test_periodic_polymesh.py | 136 +++++++- 2 files changed, 546 insertions(+), 14 deletions(-) diff --git a/src/microstructpy/meshing/polymesh.py b/src/microstructpy/meshing/polymesh.py index d58ff49d..3c0bf8a4 100644 --- a/src/microstructpy/meshing/polymesh.py +++ b/src/microstructpy/meshing/polymesh.py @@ -27,6 +27,9 @@ from matplotlib import pyplot as plt from mpl_toolkits.mplot3d import Axes3D from mpl_toolkits.mplot3d.art3d import Poly3DCollection +from scipy.spatial import ConvexHull +from scipy.spatial import QhullError +from scipy.spatial import cKDTree from scipy.spatial import distance from microstructpy import _misc @@ -606,10 +609,6 @@ def from_seeds(cls, seedlist, domain, edge_opt=False, n_iter=100, is_periodic = any(per_axes) if is_periodic: dom_lims = _misc.periodic_domain_limits(domain) - if domain.n_dim != 2: - e_str = 'Periodic tessellations are currently supported in ' - e_str += '2D only.' - raise NotImplementedError(e_str) # Collect all breakdowns bkdwn2seed = np.array([], dtype='int') @@ -721,8 +720,9 @@ def from_seeds(cls, seedlist, domain, edge_opt=False, n_iter=100, # Cells of a periodic tessellation wrap across the periodic # faces: split them at those faces and translate the outside # pieces into the domain - voro, bkdwn2seed = _periodic_pieces_2d(voro, bkdwn2seed, lims, - per_axes) + pieces_fun = {2: _periodic_pieces_2d, 3: _periodic_pieces_3d} + voro, bkdwn2seed = pieces_fun[n_dim](voro, bkdwn2seed, lims, + per_axes) # Get only the cells within the domain cell_mask = np.full(len(bkdwn2seed), True, dtype='bool') @@ -837,6 +837,16 @@ def from_seeds(cls, seedlist, domain, edge_opt=False, n_iter=100, vols = [cell['volume'] for cell in voro] # Create initial mesh + if is_periodic: + # merge clusters of nearly coincident points, consistently on + # both periodic faces + eps = _MERGE_TOL * max([ub - lb for lb, ub in dom_lims]) + collapsed = _collapse_close_points(pts_global, facet_list, + facet_neighbor_list, + region_list, eps) + pts_global, facet_list, facet_neighbor_list, region_list = \ + collapsed + pmesh = cls(pts_global, facet_list, region_list, bkdwn2seed, phase_nums, facet_neighbor_list, vols) if is_periodic: @@ -1264,6 +1274,95 @@ def _wrap_points(bkdwn, dom_lims, per_axes): return bkdwn +# Vertices closer than this fraction of the largest domain length to a +# periodic face are snapped onto it before the cells are cut there. +_SNAP_TOL = 1e-5 + + +def _snap_to_planes(pts, axis, values, snap_tol): + """Snap the coordinates along an axis that are within a tolerance of + the given values onto those values (returns a copy).""" + pts = np.array(pts, dtype='float') + for value in values: + mask = np.abs(pts[:, axis] - value) <= snap_tol + pts[mask, axis] = value + return pts + + +# Points closer than this fraction of the largest domain length are merged +# in a periodic mesh (Voro++ can produce clusters of nearly coincident +# vertices, which the periodic faces must share consistently). +_MERGE_TOL = 1e-6 + + +def _collapse_close_points(pts, facets, facet_neighbors, regions, eps): + """Merge the points of a mesh that are closer than ``eps``. + + Clusters of close points are replaced by their mean. Facets left with + fewer than ``n_dim`` distinct points are removed, along with their + entries in the regions. + + Args: + pts (list): The points. + facets (list): Facets (lists of point numbers). + facet_neighbors (list): Neighbors of each facet. + regions (list): Regions (lists of facet numbers). + eps (float): Merging distance. + + Returns: + tuple: The new points, facets, facet neighbors and regions. + + """ + pts = np.array(pts, dtype='float') + n_pts, n_dim = pts.shape + pairs = cKDTree(pts).query_pairs(eps) + if not pairs: + return pts.tolist(), facets, facet_neighbors, regions + + parent = list(range(n_pts)) + + def find(i): + while parent[i] != i: + parent[i] = parent[parent[i]] + i = parent[i] + return i + + for i, j in pairs: + ri, rj = find(i), find(j) + if ri != rj: + parent[max(ri, rj)] = min(ri, rj) + roots = [find(i) for i in range(n_pts)] + + clusters = {} + for i, root in enumerate(roots): + clusters.setdefault(root, []).append(i) + new_pts = [] + root_ids = {} + for root in sorted(clusters): + root_ids[root] = len(new_pts) + new_pts.append(pts[clusters[root]].mean(axis=0)) + kp_new = [root_ids[roots[i]] for i in range(n_pts)] + + new_facets = [] + new_neighs = [] + f_new = {} + for f_num, facet in enumerate(facets): + loop = [] + for kp in facet: + kp_n = kp_new[kp] + if not loop or loop[-1] != kp_n: + loop.append(kp_n) + if len(loop) > 1 and loop[0] == loop[-1]: + loop.pop() + if len(set(loop)) >= n_dim: + f_new[f_num] = len(new_facets) + new_facets.append(loop) + new_neighs.append(facet_neighbors[f_num]) + new_regions = [[f_new[f] for f in region if f in f_new] + for region in regions] + return np.array(new_pts).tolist(), new_facets, new_neighs, new_regions + + def _cell_loop(cell): """Vertex loop of a 2D pyvoro cell and the adjacent cell of each edge. @@ -1301,10 +1400,11 @@ def _clip_loop(pts, adj, axis, value, keep_below, wall, tol): """ n_kp = len(pts) + on_line = np.abs(pts[:, axis] - value) <= tol if keep_below: - inside = pts[:, axis] <= value + tol + inside = (pts[:, axis] <= value + tol) | on_line else: - inside = pts[:, axis] >= value - tol + inside = (pts[:, axis] >= value - tol) | on_line new_pts = [] new_adj = [] @@ -1315,6 +1415,12 @@ def _clip_loop(pts, adj, axis, value, keep_below, wall, tol): new_pts.append(p) new_adj.append(adj[k]) if inside[k] != inside[k1]: + if inside[k] and on_line[k]: + # p itself is the crossing point; the next edge is the cut + new_adj[-1] = wall + continue + if inside[k1] and on_line[k1]: + continue # q itself is the crossing point t = (value - p[axis]) / (q[axis] - p[axis]) x = p + t * (q - p) x[axis] = value @@ -1363,7 +1469,7 @@ def _periodic_pieces_2d(voro, bkdwn2seed, lims, per_axes): """ lengths = [ub - lb for lb, ub in lims] tol = 1e-10 * max(lengths) - area_tol = 1e-12 * np.prod(lengths) + snap_tol = _SNAP_TOL * max(lengths) # Cut the cells at the periodic faces pieces = [] # (cell number, vertices, edge adjacencies) @@ -1378,6 +1484,10 @@ def _periodic_pieces_2d(voro, bkdwn2seed, lims, per_axes): wall_hi = -(2 * axis + 2) new_parts = [] for pts, adj in parts: + # vertices next to a cut line are snapped onto it, so that + # the two cells sharing an edge are cut consistently and + # no sliver pieces are created + pts = _snap_to_planes(pts, axis, (lb, ub), snap_tol) extent = pts[:, axis].max() - pts[:, axis].min() if extent > length + tol: e_str = 'A cell of the periodic tessellation is wider ' @@ -1399,9 +1509,9 @@ def _periodic_pieces_2d(voro, bkdwn2seed, lims, per_axes): (above, -length)): if len(p_pts) < 3: continue - if _loop_area(p_pts, list(range(len(p_pts)))) < area_tol: - continue p_pts = np.array(p_pts) + if p_pts[:, axis].max() - p_pts[:, axis].min() <= tol: + continue # flat piece, lies on the cut line p_pts[:, axis] += shift new_parts.append((p_pts, p_adj)) parts = new_parts @@ -1451,6 +1561,298 @@ def _matching_piece(pt_a, pt_b, candidates, pieces, tol): return None +# --------------------------------------------------------------------------- # +# # +# Periodic Tessellation - 3D # +# # +# --------------------------------------------------------------------------- # +def _clip_polyhedron(verts, faces, axis, value, keep_below, wall, tol): + """Clip a convex polyhedron by an axis-aligned plane. + + Args: + verts (numpy.ndarray): N x 3 vertices. + faces (list): (vertex loop, adjacent cell) pairs. + axis (int): Axis of the clipping plane. + value (float): Position of the plane along the axis. + keep_below (bool): Keep the side below the plane (True) or above. + wall (int): Adjacent cell id of the face created on the plane. + tol (float): Vertices within this distance of the plane are on it. + + Returns: + tuple: The clipped vertices and faces (empty if nothing is kept). + + """ + on_plane = np.abs(verts[:, axis] - value) <= tol + if keep_below: + inside = (verts[:, axis] <= value + tol) | on_plane + else: + inside = (verts[:, axis] >= value - tol) | on_plane + if np.all(inside): + return verts, faces + if not np.any(inside): + return np.zeros((0, 3)), [] + + new_verts = [] + kp_map = {} + for kp, is_in in enumerate(inside): + if is_in: + kp_map[kp] = len(new_verts) + new_verts.append(verts[kp]) + + # intersection points are computed once per edge, so that the two + # faces sharing the edge use the same point + edge_cut = {} + + def cut_point(kp_a, kp_b): + key = (min(kp_a, kp_b), max(kp_a, kp_b)) + if key not in edge_cut: + p, q = verts[key[0]], verts[key[1]] + t = (value - p[axis]) / (q[axis] - p[axis]) + x = p + t * (q - p) + x[axis] = value + edge_cut[key] = len(new_verts) + new_verts.append(x) + return edge_cut[key] + + new_faces = [] + for loop, adj in faces: + n_kp = len(loop) + new_loop = [] + for k in range(n_kp): + kp_a, kp_b = loop[k], loop[(k + 1) % n_kp] + if inside[kp_a]: + new_loop.append(kp_map[kp_a]) + if inside[kp_a] != inside[kp_b]: + # A vertex on the plane is itself the crossing point: no + # new (coincident) vertex is created for it. + if inside[kp_a] and not on_plane[kp_a]: + new_loop.append(cut_point(kp_a, kp_b)) + elif inside[kp_b] and not on_plane[kp_b]: + new_loop.append(cut_point(kp_a, kp_b)) + # drop repeated consecutive vertices (edges lying on the plane) + loop_out = [] + for kp in new_loop: + if not loop_out or loop_out[-1] != kp: + loop_out.append(kp) + if len(loop_out) > 1 and loop_out[0] == loop_out[-1]: + loop_out.pop() + if len(loop_out) >= 3: + new_faces.append((loop_out, adj)) + + new_verts = np.array(new_verts) + + # The cap face is the cross-section of the (convex) polyhedron by the + # plane: the convex hull of the cut points and the vertices on the + # plane. This does not depend on the orientation of the faces. + cap_ids = set(edge_cut.values()) + cap_ids |= set([kp_map[kp] for kp in range(len(verts)) + if inside[kp] and on_plane[kp]]) + cap_loop = _plane_hull_loop(new_verts, sorted(cap_ids), axis, tol) + if len(cap_loop) >= 3: + new_faces.append((cap_loop, wall)) + # faces lying on the plane are walls + for i, (loop, adj) in enumerate(new_faces): + if np.all(np.abs(new_verts[loop, axis] - value) <= tol): + new_faces[i] = (loop, wall) + return new_verts, new_faces + + +def _plane_hull_loop(verts, ids, axis, tol): + """Loop of the points (given by id) that bound the convex hull of a set + of coplanar points, in a plane normal to ``axis``. + + Points that lie on an edge of the hull (collinear) are included, so + that the loop shares every vertex with the faces around it. + + Returns: + list: The point ids in loop order (empty if fewer than 3 points + span the hull). + + """ + if len(ids) < 3: + return [] + others = [i for i in range(verts.shape[1]) if i != axis] + pts_2d = verts[ids][:, others] + try: + hull = ConvexHull(pts_2d) + except QhullError: + return [] # collinear points: the cross-section is degenerate + hull_ids = [int(i) for i in hull.vertices] + loop = [ids[i] for i in hull_ids] + + # insert the points lying on hull edges + on_hull = set(hull_ids) + rest = [i for i in range(len(ids)) if i not in on_hull] + if rest: + new_loop = [] + n_hull = len(hull_ids) + for k in range(n_hull): + i_a, i_b = hull_ids[k], hull_ids[(k + 1) % n_hull] + p_a, p_b = pts_2d[i_a], pts_2d[i_b] + d_ab = p_b - p_a + length = np.linalg.norm(d_ab) + new_loop.append(ids[i_a]) + on_edge = [] + for i in rest: + rel = pts_2d[i] - p_a + t = np.dot(rel, d_ab) / (length * length) + if -1e-12 < t < 1 + 1e-12: + dist = abs(rel[0] * d_ab[1] - rel[1] * d_ab[0]) / length + if dist <= tol: + on_edge.append((t, ids[i])) + new_loop.extend([kp for _, kp in sorted(on_edge)]) + loop = new_loop + return loop + + +def _polyhedron_volume(verts, faces): + """Volume of a convex polyhedron given by its faces (fan from the + centroid of the vertices).""" + cen = verts.mean(axis=0) + volume = 0.0 + for loop, _ in faces: + p0 = verts[loop[0]] + for k in range(1, len(loop) - 1): + p1, p2 = verts[loop[k]], verts[loop[k + 1]] + volume += abs(np.dot(np.cross(p1 - p0, p2 - p0), cen - p0)) + return volume / 6.0 + + +def _periodic_pieces_3d(voro, bkdwn2seed, lims, per_axes): + """Split the cells of a periodic 3D tessellation at the periodic faces. + + The 3D counterpart of :func:`_periodic_pieces_2d`: each wrapped cell is + clipped by the planes of the periodic faces, the outside pieces are + translated into the domain, cut faces become domain boundary facets + (Voro++ wall ids -1 ... -6) and the adjacent cells of the other faces + are resolved to the pieces that share them. + + Args: + voro (list): The cells from pyvoro. + bkdwn2seed (numpy.ndarray): Seed number of each cell. + lims (list): (lower, upper) bounds of the domain, per axis. + per_axes (list): Periodicity flag of each axis. + + Returns: + tuple: The pieces, in the pyvoro cell format, and the seed number + of each piece. + + """ + lengths = [ub - lb for lb, ub in lims] + tol = 1e-10 * max(lengths) + snap_tol = _SNAP_TOL * max(lengths) + + pieces = [] # (cell number, vertices, faces) + for cell_num, cell in enumerate(voro): + verts = np.array(cell['vertices'], dtype='float') + faces = [(list(f['vertices']), f['adjacent_cell']) + for f in cell['faces']] + parts = [(verts, faces)] + for axis, flag in enumerate(per_axes): + if not flag: + continue + lb, ub = lims[axis] + length = ub - lb + wall_lo = -(2 * axis + 1) + wall_hi = -(2 * axis + 2) + new_parts = [] + for p_verts, p_faces in parts: + # vertices next to a cut plane are snapped onto it (see + # _periodic_pieces_2d) + p_verts = _snap_to_planes(p_verts, axis, (lb, ub), snap_tol) + extent = p_verts[:, axis].max() - p_verts[:, axis].min() + if extent > length + tol: + e_str = 'A cell of the periodic tessellation is wider ' + e_str += 'than the domain along axis ' + str(axis) + e_str += '. More seeds are needed for a periodic ' + e_str += 'microstructure.' + raise ValueError(e_str) + below = _clip_polyhedron(p_verts, p_faces, axis, lb, True, + wall_hi, tol) + rest = _clip_polyhedron(p_verts, p_faces, axis, lb, False, + wall_lo, tol) + if len(rest[0]) == 0: + inner, above = rest, rest + else: + inner = _clip_polyhedron(rest[0], rest[1], axis, ub, + True, wall_hi, tol) + above = _clip_polyhedron(rest[0], rest[1], axis, ub, + False, wall_lo, tol) + for (q_verts, q_faces), shift in ((below, length), + (inner, 0), + (above, -length)): + if len(q_verts) < 4 or len(q_faces) < 4: + continue + q_verts = np.array(q_verts) + if q_verts[:, axis].max() - q_verts[:, axis].min() <= tol: + continue # flat piece, lies on the cut plane + q_verts[:, axis] += shift + new_parts.append((q_verts, q_faces)) + parts = new_parts + for p_verts, p_faces in parts: + pieces.append((cell_num, p_verts, p_faces)) + + # Resolve the adjacent cells of the faces to pieces + cell_pieces = {} + for piece_num, (cell_num, _, _) in enumerate(pieces): + cell_pieces.setdefault(cell_num, []).append(piece_num) + + new_voro = [] + for piece_num, (cell_num, verts, faces) in enumerate(pieces): + out_faces = [] + for loop, adj_cell in faces: + if adj_cell >= 0: + candidates = [p for p in cell_pieces.get(adj_cell, []) + if p != piece_num] + adj_cell = _matching_piece_3d(verts[loop], candidates, + pieces, tol) + if adj_cell is None: + adj_cell = _wall_of_face(verts[loop], lims, tol) + out_faces.append({'adjacent_cell': int(adj_cell), + 'vertices': list(loop)}) + adjacency = [[] for _ in range(len(verts))] + for face in out_faces: + loop = face['vertices'] + for k in range(len(loop)): + kp_a, kp_b = loop[k], loop[(k + 1) % len(loop)] + if kp_b not in adjacency[kp_a]: + adjacency[kp_a].append(kp_b) + if kp_a not in adjacency[kp_b]: + adjacency[kp_b].append(kp_a) + new_voro.append({'vertices': verts.tolist(), + 'faces': out_faces, + 'adjacency': adjacency, + 'original': voro[cell_num]['original'], + 'volume': _polyhedron_volume(verts, faces)}) + new_bkdwn2seed = np.array([bkdwn2seed[cell_num] + for cell_num, _, _ in pieces], dtype='int') + return new_voro, new_bkdwn2seed + + +def _matching_piece_3d(face_pts, candidates, pieces, tol): + """Piece among the candidates that has vertices at all the points.""" + for piece_num in candidates: + pts = pieces[piece_num][1] + dists = np.linalg.norm(face_pts[:, None, :] - pts[None, :, :], + axis=2) + if np.all(dists.min(axis=1) <= tol): + return piece_num + return None + + +def _wall_of_face(face_pts, lims, tol): + """Wall id of a face lying on a face of the domain.""" + for axis, (lb, ub) in enumerate(lims): + if np.all(np.abs(face_pts[:, axis] - lb) <= tol): + return -(2 * axis + 1) + if np.all(np.abs(face_pts[:, axis] - ub) <= tol): + return -(2 * axis + 2) + e_str = 'Cannot resolve the neighbor of a face of the periodic ' + e_str += 'tessellation at ' + str(np.round(face_pts.mean(axis=0), 6)) + e_str += '.' + raise ValueError(e_str) + + def _wall_of_edge(pt_a, pt_b, lims, tol): """Wall id of an edge lying on a face of the domain.""" for axis, (lb, ub) in enumerate(lims): diff --git a/tests/meshing/test_periodic_polymesh.py b/tests/meshing/test_periodic_polymesh.py index 3612075b..55c8c378 100644 --- a/tests/meshing/test_periodic_polymesh.py +++ b/tests/meshing/test_periodic_polymesh.py @@ -1,4 +1,4 @@ -"""Tests for periodic polygonal meshes (2D).""" +"""Tests for periodic polygonal and polyhedral meshes.""" import copy import itertools @@ -227,8 +227,138 @@ def test_periodic_errors(): seeds = SeedList([Seed.factory('circle', r=0.2, position=(0.5, 0.5))]) with pytest.raises(ValueError): PolyMesh.from_seeds(seeds, msp.geometry.Circle(r=1), periodic=True) + # only boxes can be periodic in 3D seeds_3d = SeedList([Seed.factory('sphere', r=0.2, position=(0.5, 0.5, 0.5))]) - with pytest.raises(NotImplementedError): - PolyMesh.from_seeds(seeds_3d, msp.geometry.Cube(side_length=2), + with pytest.raises(ValueError): + PolyMesh.from_seeds(seeds_3d, msp.geometry.Sphere(r=1), periodic=True) + + +# --------------------------------------------------------------------------- # +# 3D # +# --------------------------------------------------------------------------- # +def _seed_volumes(pmesh, n_seeds): + vols = np.zeros(n_seeds) + for seed_num, vol in zip(pmesh.seed_numbers, pmesh.volumes): + vols[seed_num] += vol + return vols + + +def _tiled_reference_volumes(seeds, domain, per_axes): + lims = np.array(domain.limits) + lengths = lims[:, 1] - lims[:, 0] + options = [[-length, 0.0, length] if flag else [0.0] + for length, flag in zip(lengths, per_axes)] + tiled = SeedList() + for t in itertools.product(*options): + for seed in seeds: + copy_seed = copy.deepcopy(seed) + copy_seed.position = list(np.array(seed.position) + np.array(t)) + tiled.append(copy_seed) + big_lims = [(lb - length, ub + length) if flag else (lb, ub) + for (lb, ub), length, flag in zip(lims, lengths, per_axes)] + pmesh = PolyMesh.from_seeds(tiled, msp.geometry.Box(limits=big_lims)) + i_zero = [i for i, t in enumerate(itertools.product(*options)) + if not any(t)][0] + n_seeds = len(seeds) + vols = np.zeros(n_seeds) + for seed_num, vol in zip(pmesh.seed_numbers, pmesh.volumes): + block, local = divmod(seed_num, n_seeds) + if block == i_zero: + vols[local] += vol + return vols + + +def _check_periodic_structure_3d(pmesh, domain, per_axes): + pts = np.array(pmesh.points) + lims = np.array(domain.limits) + lengths = lims[:, 1] - lims[:, 0] + assert pmesh.periodic_axes == list(per_axes) + for axis, flag in enumerate(per_axes): + if not flag: + assert axis not in pmesh.periodic_points + continue + lb, ub = lims[axis] + shift = np.zeros(3) + shift[axis] = lengths[axis] + pairs = pmesh.periodic_points[axis] + low = set(np.nonzero(np.isclose(pts[:, axis], lb))[0]) + high = set(np.nonzero(np.isclose(pts[:, axis], ub))[0]) + assert len(pairs) == len(low) == len(high) > 0 + for lo, hi in pairs: + assert np.array_equal(pts[hi], pts[lo] + shift) + kp_map = dict(pairs) + f_pairs = dict(pmesh.periodic_facets[axis]) + n_low = 0 + for f_num, facet in enumerate(pmesh.facets): + if all([kp in low for kp in facet]): + n_low += 1 + assert f_num in f_pairs + image = pmesh.facets[f_pairs[f_num]] + assert set(image) == set([kp_map[kp] for kp in facet]) + assert min(pmesh.facet_neighbors[f_num]) == -(2 * axis + 1) + assert n_low > 0 + + +def test_two_spheres_periodic_in_x(): + domain = msp.geometry.Cube(side_length=1, corner=(0, 0, 0)) + seeds = SeedList([Seed.factory('sphere', r=0.2, position=(0.2, .5, .5)), + Seed.factory('sphere', r=0.2, position=(0.7, .5, .5))]) + pmesh = PolyMesh.from_seeds(seeds, domain, periodic='x') + assert sorted(pmesh.seed_numbers) == [0, 0, 1] + assert np.allclose(_seed_volumes(pmesh, 2), [0.5, 0.5]) + pieces = sorted([v for v, s in zip(pmesh.volumes, pmesh.seed_numbers) + if s == 0]) + assert np.allclose(pieces, [0.05, 0.45]) + _check_periodic_structure_3d(pmesh, domain, [True, False, False]) + + +def test_single_sphere_tiles_the_cube(): + domain = msp.geometry.Cube(side_length=1, corner=(0, 0, 0)) + seeds = SeedList([Seed.factory('sphere', r=0.2, position=(.3, .6, .8))]) + pmesh = PolyMesh.from_seeds(seeds, domain, periodic=True) + assert len(pmesh.regions) == 8 + assert len(pmesh.points) == 27 + assert len(pmesh.facets) == 36 + assert np.isclose(sum(pmesh.volumes), 1.0) + _check_periodic_structure_3d(pmesh, domain, [True, True, True]) + for axis in range(3): + assert len(pmesh.periodic_points[axis]) == 9 + assert len(pmesh.periodic_facets[axis]) == 4 + + +@pytest.mark.parametrize('per_axes', [[True, True, True], + [True, False, True]]) +def test_periodic_3d_matches_tiled_reference(per_axes): + phases = [{'shape': 'sphere', 'size': scipy.stats.uniform(0.3, 0.2)}, + {'shape': 'ellipsoid', 'size': scipy.stats.uniform(0.35, 0.15), + 'ratio_ab': 2, 'ratio_ac': 1.5, 'orientation': 'random'}] + domain = msp.geometry.Box(limits=[(0, 2), (-1, 1), (0.5, 2.5)]) + seeds = SeedList.from_info(phases, 0.4 * domain.volume) + seeds.position(domain, rtol=0.0, rng_seed=1, periodic=per_axes) + pmesh = PolyMesh.from_seeds(seeds, domain, periodic=per_axes) + + assert np.isclose(sum(pmesh.volumes), domain.volume) + assert np.all(np.array(pmesh.volumes) > 0) + assert set(pmesh.seed_numbers) == set(range(len(seeds))) + vols = _seed_volumes(pmesh, len(seeds)) + ref = _tiled_reference_volumes(seeds, domain, per_axes) + # vertices within 1e-5 of the faces are snapped onto them + assert np.allclose(vols, ref, rtol=1e-6, atol=1e-6) + _check_periodic_structure_3d(pmesh, domain, per_axes) + + +def test_periodic_3d_file_round_trip(tmp_path): + domain = msp.geometry.Cube(side_length=2) + phases = [{'shape': 'sphere', 'size': scipy.stats.uniform(0.4, 0.2)}] + seeds = SeedList.from_info(phases, 0.4 * domain.volume) + seeds.position(domain, rtol=0.0, rng_seed=2, periodic='xy') + pmesh = PolyMesh.from_seeds(seeds, domain, periodic='xy') + fname = str(tmp_path / 'polymesh.txt') + pmesh.write(fname) + loaded = PolyMesh.from_file(fname) + assert loaded == pmesh + assert loaded.periodic_axes == [True, True, False] + assert {k: [tuple(p) for p in v] for k, v in + loaded.periodic_points.items()} == pmesh.periodic_points From 939bd01a2ef66cf621adc0e2efbce5d7189cc386 Mon Sep 17 00:00:00 2001 From: Julian Rimoli Date: Tue, 22 Sep 2026 21:25:35 -0700 Subject: [PATCH 12/42] Add periodic 3D tetrahedral meshes The polygons on the periodic faces of the polyhedral mesh are fan-triangulated and their images on the opposite face receive the corresponding triangles, then TetGen runs with -Y (no Steiner points on the boundary), so the nodes and the surface triangles on opposite faces coincide. Raster meshes, the periodic node/facet pairs in the text files and the Abaqus periodic node sets already worked in 3D and are now tested there. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.rst | 20 +++-- src/microstructpy/meshing/trimesh.py | 57 +++++++++++++ tests/meshing/test_periodic_trimesh.py | 108 ++++++++++++++++++++++++- 3 files changed, 176 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 23b0ec70..1082952f 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -10,15 +10,19 @@ Unreleased ---------- Added ''''' -- Periodic microstructures (2D): the ```` field of the domain (or - the ``periodic`` argument of ``cli.run``, ``SeedList.position`` and - ``PolyMesh.from_seeds``) selects the periodic axes. Seeds crossing a +- Periodic microstructures, in 2D and 3D: the ```` field of the + domain (or the ``periodic`` argument of ``cli.run``, ``SeedList.position`` + and ``PolyMesh.from_seeds``) selects the periodic axes. Seeds crossing a periodic face are placed without overlapping the opposite side, the - Laguerre tessellation is periodic across those faces, and the triangular - and raster meshes have matching nodes on opposite faces. The pairs of - periodic points/nodes and facets are stored in the meshes and their text - files; the Abaqus output has a node set per periodic face in matching - order; the verification unwraps grains that are split by the faces. + Laguerre tessellation is periodic across those faces (cells crossing a + face are cut and their pieces tile the domain), and the triangular, + tetrahedral and raster meshes have matching nodes on opposite faces + (Triangle/TetGen run with ``-Y``; the polygons on opposite faces are + triangulated identically). The pairs of periodic points/nodes and facets + are stored in the meshes and their text files; the Abaqus output has a + node set per periodic face in matching order; the verification unwraps + grains that are split by the faces. gmsh is not supported for periodic + meshes. Fixed ''''' diff --git a/src/microstructpy/meshing/trimesh.py b/src/microstructpy/meshing/trimesh.py index a757c96f..c4b96e29 100644 --- a/src/microstructpy/meshing/trimesh.py +++ b/src/microstructpy/meshing/trimesh.py @@ -1448,6 +1448,12 @@ def _call_meshpy(polymesh, phases=None, min_angle=0, max_volume=float('inf'), sub_out = meshpy.triangle.subdivide_facets(n_subs, pts, facets, facet_nums) pts, facets, facet_nums = sub_out + elif periodic: + # TetGen triangulates polygonal facets itself, so the facets on + # opposite periodic faces are triangulated here, identically, and + # passed as triangles + facets, facet_nums = _triangulate_periodic_facets(polymesh, kps, + facets, facet_nums) # create groups/regions pts_arr = np.array(polymesh.points) @@ -1538,6 +1544,8 @@ def _call_meshpy(polymesh, phases=None, min_angle=0, max_volume=float('inf'), opts.fixedvolume = 0 opts.regionattrib = 1 opts.facesout = 1 + if periodic: + opts.nobisect = 1 # -Y: keep the boundary facets as given tri_mesh = meshpy.tet.build(info, options=opts) # return mesh @@ -1928,6 +1936,55 @@ def _facet_in_normal(pts, cen_pt): return un, f_cen +def _triangulate_periodic_facets(polymesh, kps, facets, facet_nums): + """Triangulate the facets on the periodic faces of a 3D polymesh. + + Each facet on a lower periodic face is split into a fan of triangles + and its image on the upper face into the corresponding triangles (the + images of the same points), so that TetGen, which keeps the boundary + facets as given with the -Y switch, produces matching triangles on + opposite faces. + + Args: + polymesh (PolyMesh): The periodic polymesh. + kps (dict): Maps polymesh point numbers to the point numbers of the + mesher input. + facets (list): Facets of the mesher input (lists of point numbers). + facet_nums (list): Polymesh facet number + 1 of each facet. + + Returns: + tuple: The new facets and facet numbers. + + """ + f_index = {f_num - 1: i for i, f_num in enumerate(facet_nums)} + replaced = {} + for axis, f_pairs in (polymesh.periodic_facets or {}).items(): + kp_map = dict(polymesh.periodic_points[axis]) + for f_lo, f_hi in f_pairs: + if f_lo not in f_index or f_hi not in f_index: + continue + loop_lo = polymesh.facets[f_lo] + loop_hi = [kp_map[kp] for kp in loop_lo] + tris_lo = [[kps[loop_lo[0]], kps[loop_lo[k]], kps[loop_lo[k + 1]]] + for k in range(1, len(loop_lo) - 1)] + tris_hi = [[kps[loop_hi[0]], kps[loop_hi[k]], kps[loop_hi[k + 1]]] + for k in range(1, len(loop_hi) - 1)] + replaced[f_index[f_lo]] = tris_lo + replaced[f_index[f_hi]] = tris_hi + + new_facets = [] + new_nums = [] + for i, (facet, f_num) in enumerate(zip(facets, facet_nums)): + if i in replaced: + for tri in replaced[i]: + new_facets.append(tri) + new_nums.append(f_num) + else: + new_facets.append(facet) + new_nums.append(f_num) + return new_facets, new_nums + + def _abaqus_periodic_nsets(mesh): """Abaqus node sets of the periodic faces of a mesh. diff --git a/tests/meshing/test_periodic_trimesh.py b/tests/meshing/test_periodic_trimesh.py index eb93fafc..380fe6b7 100644 --- a/tests/meshing/test_periodic_trimesh.py +++ b/tests/meshing/test_periodic_trimesh.py @@ -1,4 +1,4 @@ -"""Tests for periodic triangular and raster meshes (2D).""" +"""Tests for periodic triangular, tetrahedral and raster meshes.""" import numpy as np import pytest import scipy.stats @@ -192,3 +192,109 @@ def test_periodic_raster_mesh(periodic_case): assert np.array_equal(pts[hi], pts[lo] + shift) with pytest.raises(ValueError): RasterMesh.from_polymesh(pmesh, 0.07, phases) + + +# --------------------------------------------------------------------------- # +# 3D # +# --------------------------------------------------------------------------- # +@pytest.fixture(scope='module') +def periodic_case_3d(): + domain = msp.geometry.Cube(side_length=1.5, corner=(0, 0, 0)) + phases = [{'shape': 'sphere', 'size': scipy.stats.uniform(0.35, 0.2), + 'material_type': 'crystalline'}, + {'shape': 'sphere', 'size': 0.4, 'material_type': 'amorphous'}] + seeds = SeedList.from_info(phases, 0.5 * domain.volume) + seeds.position(domain, rtol=0.0, rng_seed=1, periodic=True) + pmesh = PolyMesh.from_seeds(seeds, domain, periodic=True) + return domain, phases, seeds, pmesh + + +def _element_volumes(mesh): + pts = np.array(mesh.points) + elems = np.array(mesh.elements) + rel = pts[elems[:, 1:]] - pts[elems[:, :1]] + return np.linalg.det(rel) / 6.0 + + +def _check_periodic_mesh_3d(mesh, domain, per_axes): + pts = np.array(mesh.points) + lims = np.array(domain.limits) + assert mesh.periodic_axes == list(per_axes) + for axis, flag in enumerate(per_axes): + if not flag: + assert axis not in mesh.periodic_nodes + continue + lb, ub = lims[axis] + shift = np.zeros(3) + shift[axis] = ub - lb + pairs = mesh.periodic_nodes[axis] + low = set(np.nonzero(np.isclose(pts[:, axis], lb))[0]) + high = set(np.nonzero(np.isclose(pts[:, axis], ub))[0]) + assert len(pairs) == len(low) == len(high) > 0 + assert set([lo for lo, _ in pairs]) == low + assert set([hi for _, hi in pairs]) == high + for lo, hi in pairs: + assert np.array_equal(pts[hi], pts[lo] + shift) + # the triangles on the faces are paired + kp_map = dict(pairs) + f_pairs = dict(mesh.periodic_facets[axis]) + n_low = 0 + for f_num, facet in enumerate(mesh.facets): + if all([kp in low for kp in facet]): + n_low += 1 + assert f_num in f_pairs + image = mesh.facets[f_pairs[f_num]] + assert set(image) == set([kp_map[kp] for kp in facet]) + assert n_low > 0 + + +def test_periodic_tetmesh_nodes_match(periodic_case_3d): + domain, phases, seeds, pmesh = periodic_case_3d + mesh = TriMesh.from_polymesh(pmesh, phases, min_angle=10) + _check_periodic_mesh_3d(mesh, domain, [True, True, True]) + vols = _element_volumes(mesh) + assert np.all(np.abs(vols) > 0) + assert np.isclose(np.abs(vols).sum(), domain.volume) + assert set(mesh.element_attributes) <= set(pmesh.seed_numbers) + + +def test_periodic_tetmesh_single_axis(): + domain = msp.geometry.Box(limits=[(0, 1.5), (0, 1), (0, 1)]) + phases = [{'shape': 'sphere', 'size': scipy.stats.uniform(0.3, 0.2)}] + seeds = SeedList.from_info(phases, 0.5 * domain.volume) + seeds.position(domain, rtol=0.0, rng_seed=3, periodic='z') + pmesh = PolyMesh.from_seeds(seeds, domain, periodic='z') + mesh = TriMesh.from_polymesh(pmesh, phases, min_angle=10) + _check_periodic_mesh_3d(mesh, domain, [False, False, True]) + assert np.isclose(np.abs(_element_volumes(mesh)).sum(), domain.volume) + + +def test_periodic_tetmesh_file_and_abaqus(periodic_case_3d, tmp_path): + domain, phases, seeds, pmesh = periodic_case_3d + mesh = TriMesh.from_polymesh(pmesh, phases, min_angle=10) + fname = str(tmp_path / 'trimesh.txt') + mesh.write(fname) + loaded = TriMesh.from_file(fname) + assert np.array_equal(np.array(loaded.points), np.array(mesh.points)) + assert {k: [tuple(p) for p in v] for k, v in + loaded.periodic_nodes.items()} == mesh.periodic_nodes + mesh.write(str(tmp_path / 'mesh.inp'), 'abaqus', seeds, pmesh) + with open(str(tmp_path / 'mesh.inp'), 'r') as file: + text = file.read() + for name in 'XYZ': + assert '*Nset, nset=Set-N-Periodic-' + name + '-Low' in text + assert '*Nset, nset=Set-N-Periodic-' + name + '-High' in text + + +def test_periodic_raster_mesh_3d(periodic_case_3d): + domain, phases, seeds, pmesh = periodic_case_3d + mesh = RasterMesh.from_polymesh(pmesh, 0.15, phases) + assert mesh.periodic_axes == [True, True, True] + pts = np.array(mesh.points) + for axis in range(3): + pairs = mesh.periodic_nodes[axis] + assert len(pairs) == 11 * 11 + shift = np.zeros(3) + shift[axis] = 1.5 + for lo, hi in pairs: + assert np.array_equal(pts[hi], pts[lo] + shift) From a39cd17677dc9bd938d7de6ef10308b3bde569b6 Mon Sep 17 00:00:00 2001 From: Julian Rimoli Date: Tue, 22 Sep 2026 22:01:40 -0700 Subject: [PATCH 13/42] Label merged amorphous cells consistently in periodic meshes Cells of the same amorphous phase that share a facet are merged into one region of the mesh. The Triangle/TetGen mesher labelled the region with the seed number of its first cell while the raster mesher, the gmsh mesher and the Abaqus writer used the smallest seed number of the region. Both conventions agree when the regions of the polymesh are in seed order, but not in a periodic mesh, where the pieces of the cells that cross the periodic faces reorder the regions. A single helper now computes the label of every region: the smallest seed number of the merged region, which in a periodic mesh also joins the pieces of one seed and the amorphous cells that touch across a periodic face, since they form one region of the periodic medium. The new tests check that the cells of a periodic mesh partition the domain (closed convex cells, volumes adding up to the domain, random points in exactly one cell) and that the elements partition the cells (positive volumes, manifold faces, attribute volumes equal to the cell volumes, element centroids inside the cells of their attribute). Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.rst | 7 +- src/microstructpy/meshing/trimesh.py | 88 +++++++-- tests/meshing/test_periodic_geometry.py | 244 ++++++++++++++++++++++++ 3 files changed, 317 insertions(+), 22 deletions(-) create mode 100644 tests/meshing/test_periodic_geometry.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 1082952f..9e681335 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -21,8 +21,11 @@ Added triangulated identically). The pairs of periodic points/nodes and facets are stored in the meshes and their text files; the Abaqus output has a node set per periodic face in matching order; the verification unwraps - grains that are split by the faces. gmsh is not supported for periodic - meshes. + grains that are split by the faces. Cells of the same amorphous phase + that touch across a periodic face are merged into one region, like cells + that share a facet, and the merged region is labelled with the smallest + seed number among its cells by every mesher and writer. gmsh is not + supported for periodic meshes. Fixed ''''' diff --git a/src/microstructpy/meshing/trimesh.py b/src/microstructpy/meshing/trimesh.py index c4b96e29..67847709 100644 --- a/src/microstructpy/meshing/trimesh.py +++ b/src/microstructpy/meshing/trimesh.py @@ -1460,6 +1460,11 @@ def _call_meshpy(polymesh, phases=None, min_angle=0, max_volume=float('inf'), regions = [] holes = [] + # Merged cells are labelled with the smallest seed number among them + # (the same convention as the other writers and meshers), which is not + # the seed number of the first cell of the group in a periodic mesh. + labels = _merged_seed_numbers(polymesh, phases) + ungrouped = np.full(len(polymesh.regions), True, dtype='?') while np.any(ungrouped): cell_ind = np.argmax(ungrouped) @@ -1470,7 +1475,7 @@ def _call_meshpy(polymesh, phases=None, min_angle=0, max_volume=float('inf'), cell_cen = pts_arr[list(cell_kps)].mean(axis=0) # seed number and phase type - seed_num = int(polymesh.seed_numbers[cell_ind]) + seed_num = int(labels[cell_ind]) phase_num = polymesh.phase_numbers[cell_ind] phase = phases[phase_num] phase_type = phase.get('material_type', 'crystalline') @@ -1866,30 +1871,73 @@ def _sort_facets(pairs): return s_pairs -def _amorphous_seed_numbers(pmesh, phases): +def _merged_seed_numbers(pmesh, phases): + """Label (seed number) of each region after merging amorphous cells. + + Cells of the same amorphous phase that share a facet are merged into a + single region of the mesh, labelled with the smallest seed number among + them. In a periodic mesh, the pieces of one seed and the cells that touch + across a periodic face belong to the same region. + + Returns: + numpy.ndarray: The label of each region of the polymesh. + """ + seed_nums = np.array(pmesh.seed_numbers) phase_nums = np.array(pmesh.phase_numbers) is_amorph = np.array([p.get('material_type', 'solid') in _misc.kw_amorph for p in phases]) amorph_mask = is_amorph[phase_nums] - neighs = np.array(pmesh.facet_neighbors) - neighs = neighs[np.min(neighs, axis=1) >= 0] - neighs_mask = phase_nums[neighs[:, 0]] == phase_nums[neighs[:, 1]] - neighs_mask &= amorph_mask[neighs[:, 0]] - amorph_neighs = neighs[neighs_mask] - - new_seed_numbers = np.array(pmesh.seed_numbers) - changes_made = True - while changes_made: - changes_made = False - for pair in amorph_neighs: - seeds = new_seed_numbers[pair] - if seeds[0] != seeds[1]: - changes_made = True - new_seed_numbers[pair] = np.min(seeds) - conv_dict = {s1: s2 for s1, s2 in zip(pmesh.seed_numbers, new_seed_numbers) - if s1 != s2} - return conv_dict + parent = np.arange(len(seed_nums)) + + def find(i): + while parent[i] != i: + parent[i] = parent[parent[i]] + i = parent[i] + return i + + def union(i, j): + r_i, r_j = find(i), find(j) + if r_i != r_j: + parent[max(r_i, r_j)] = min(r_i, r_j) + + pairs = [tuple(neighs) for neighs in pmesh.facet_neighbors] + per_facets = getattr(pmesh, 'periodic_facets', None) or {} + for axis_pairs in per_facets.values(): + for f_lo, f_hi in axis_pairs: + pairs.append((max(pmesh.facet_neighbors[f_lo]), + max(pmesh.facet_neighbors[f_hi]))) + for r_a, r_b in pairs: + if r_a < 0 or r_b < 0: + continue + if amorph_mask[r_a] and phase_nums[r_a] == phase_nums[r_b]: + union(r_a, r_b) + + first_region = {} + for r, s in enumerate(seed_nums): + if s in first_region: + union(first_region[s], r) + else: + first_region[s] = r + + roots = np.array([find(r) for r in range(len(seed_nums))]) + labels = seed_nums.copy() + for root in np.unique(roots): + members = roots == root + labels[members] = seed_nums[members].min() + return labels + + +def _amorphous_seed_numbers(pmesh, phases): + """Seed numbers that change when amorphous cells are merged. + + Returns: + dict: Maps the seed number of each merged cell to the label of its + merged region (see :func:`_merged_seed_numbers`). + """ + labels = _merged_seed_numbers(pmesh, phases) + return {int(s): int(lbl) for s, lbl in zip(pmesh.seed_numbers, labels) + if s != lbl} def _default_phases(polymesh): diff --git a/tests/meshing/test_periodic_geometry.py b/tests/meshing/test_periodic_geometry.py new file mode 100644 index 00000000..bbeb9da5 --- /dev/null +++ b/tests/meshing/test_periodic_geometry.py @@ -0,0 +1,244 @@ +"""Geometric checks of periodic meshes. + +The cells of a periodic polygonal/polyhedral mesh must partition the +domain: each cell is a closed convex polytope inside the domain, the cell +volumes add up to the domain volume and every point of the domain lies in +exactly one cell (no gaps, no overlaps). The elements of the triangular/ +tetrahedral mesh must partition the cells in the same way. +""" +from collections import Counter + +import numpy as np +import pytest +import scipy.stats + +import microstructpy as msp +from microstructpy.meshing import PolyMesh +from microstructpy.meshing import TriMesh +from microstructpy.meshing.trimesh import _amorphous_seed_numbers +from microstructpy.seeding import SeedList + +PHASES = { + 2: [{'shape': 'circle', 'size': scipy.stats.uniform(0.15, 0.15), + 'material_type': 'crystalline'}, + {'shape': 'ellipse', 'size': scipy.stats.uniform(0.2, 0.15), + 'aspect_ratio': scipy.stats.uniform(1.5, 1.5), + 'angle_deg': scipy.stats.uniform(0, 180), + 'material_type': 'amorphous'}], + 3: [{'shape': 'sphere', 'size': scipy.stats.uniform(0.3, 0.2), + 'material_type': 'crystalline'}, + {'shape': 'sphere', 'size': 0.4, 'material_type': 'amorphous'}], +} + +CASES = [ + ('square-xy', msp.geometry.Square(side_length=2, corner=(0, 0)), 1, True), + ('rect-x', msp.geometry.Rectangle(length=3, width=2, corner=(0, 0)), 2, + 'x'), + ('cube-xyz-1', msp.geometry.Cube(side_length=1.5, corner=(0, 0, 0)), 1, + True), + ('cube-xyz-2', msp.geometry.Cube(side_length=1.5, corner=(0, 0, 0)), 2, + True), + ('cube-xz', msp.geometry.Cube(side_length=1.5, corner=(0, 0, 0)), 3, + 'xz'), + ('box-y', msp.geometry.Box(limits=[(0, 2), (0, 1), (0, 1.3)]), 7, 'y'), + ('box-none', msp.geometry.Box(limits=[(0, 2), (0, 1), (0, 1.3)]), 9, + False), +] + + +@pytest.fixture(scope='module', params=CASES, ids=[c[0] for c in CASES]) +def case(request): + name, domain, rng_seed, periodic = request.param + n_dim = len(domain.limits) + phases = PHASES[n_dim] + seeds = SeedList.from_info(phases, 0.5 * domain.n_vol) + seeds.position(domain, rtol=0.0, rng_seed=rng_seed, periodic=periodic) + pmesh = PolyMesh.from_seeds(seeds, domain, periodic=periodic) + return domain, phases, seeds, pmesh + + +# --------------------------------------------------------------------------- # +# Helpers # +# --------------------------------------------------------------------------- # +def _facet_normal(loop): + """Outward-agnostic normal of a facet, scaled by its measure.""" + if loop.shape[1] == 2: + t = loop[1] - loop[0] + return np.array([t[1], -t[0]]) + n = np.zeros(3) + for i in range(len(loop)): + n += np.cross(loop[i], loop[(i + 1) % len(loop)]) + return 0.5 * n + + +def _cell_planes(pts, pmesh, reg): + """Outward unit normals, offsets and measures of the facets of a cell.""" + verts = np.unique(np.concatenate([pmesh.facets[f] + for f in pmesh.regions[reg]])) + center = pts[verts].mean(axis=0) + planes = [] + for f in pmesh.regions[reg]: + loop = pts[pmesh.facets[f]] + n = _facet_normal(loop) + measure = np.linalg.norm(n) + assert measure > 0 + n = n / measure + p0 = loop.mean(axis=0) + if np.dot(n, p0 - center) < 0: + n = -n + planes.append((n, np.dot(n, p0), measure, p0)) + return planes, verts + + +def _check_closed(pmesh, reg): + """Each vertex (2D) or edge (3D) is shared by exactly two facets.""" + counts = Counter() + for f in pmesh.regions[reg]: + facet = pmesh.facets[f] + if len(facet) == 2: + counts.update(facet) + else: + for i in range(len(facet)): + counts[tuple(sorted((facet[i], + facet[(i + 1) % len(facet)])))] += 1 + assert all([c == 2 for c in counts.values()]) + if len(pmesh.facets[0]) > 2: + verts = set() + for f in pmesh.regions[reg]: + verts.update(pmesh.facets[f]) + assert len(verts) - len(counts) + len(pmesh.regions[reg]) == 2 + + +def _inside(planes, points, tol=1e-9): + normals = np.array([p[0] for p in planes]) + offsets = np.array([p[1] for p in planes]) + return np.all(points @ normals.T - offsets <= tol, axis=1) + + +# --------------------------------------------------------------------------- # +# Polygonal / polyhedral meshes # +# --------------------------------------------------------------------------- # +def test_cells_partition_the_domain(case): + domain, phases, seeds, pmesh = case + pts = np.array(pmesh.points) + lims = np.array(domain.limits) + n_dim = len(lims) + scale = np.max(lims[:, 1] - lims[:, 0]) + assert np.all(pts >= lims[:, 0] - 1e-9) + assert np.all(pts <= lims[:, 1] + 1e-9) + # no duplicate points + assert len(np.unique(np.round(pts / scale, 9), axis=0)) == len(pts) + + # facets are planar with a non-zero measure and distinct points + for facet in pmesh.facets: + assert len(set(facet)) == len(facet) >= n_dim + loop = pts[facet] + n = _facet_normal(loop) + assert np.linalg.norm(n) > 1e-12 + n /= np.linalg.norm(n) + assert np.abs((loop - loop[0]) @ n).max() < 1e-8 * scale + + # facet neighbors and regions agree; wall facets are on their wall + for f, neighs in enumerate(pmesh.facet_neighbors): + for r in neighs: + if r >= 0: + assert f in pmesh.regions[r] + else: + axis, side = divmod(-r - 1, 2) + assert np.allclose(pts[pmesh.facets[f], axis], + lims[axis][side], atol=1e-9) + for r, region in enumerate(pmesh.regions): + for f in region: + assert r in pmesh.facet_neighbors[f] + + # every cell is closed and convex; volumes add up to the domain volume + vols = np.zeros(len(pmesh.regions)) + all_planes = [] + for r in range(len(pmesh.regions)): + _check_closed(pmesh, r) + planes, verts = _cell_planes(pts, pmesh, r) + all_planes.append(planes) + vols[r] = sum([np.dot(n, p0) * m for n, _, m, p0 in planes]) / n_dim + assert vols[r] > 0 + assert np.all(_inside(planes, pts[verts], tol=1e-8 * scale)) + assert np.isclose(vols.sum(), domain.n_vol, rtol=1e-9) + assert np.allclose(pmesh.volumes, vols, rtol=1e-9, atol=1e-12) + + # random points of the domain lie in exactly one cell + rng = np.random.default_rng(0) + lengths = lims[:, 1] - lims[:, 0] + sample = lims[:, 0] + rng.random((20000, n_dim)) * lengths + counts = np.zeros(len(sample), dtype=int) + for planes in all_planes: + counts += _inside(planes, sample, tol=1e-12) + assert np.all(counts == 1) + + +# --------------------------------------------------------------------------- # +# Triangular / tetrahedral meshes # +# --------------------------------------------------------------------------- # +def test_elements_partition_the_cells(case): + domain, phases, seeds, pmesh = case + mesh = TriMesh.from_polymesh(pmesh, phases, min_angle=10) + pts = np.array(mesh.points) + elems = np.array(mesh.elements) + lims = np.array(domain.limits) + n_dim = len(lims) + + # positively oriented elements that add up to the domain volume + rel = pts[elems[:, 1:]] - pts[elems[:, :1]] + svol = np.linalg.det(rel) / (2.0 if n_dim == 2 else 6.0) + assert np.all(svol > 0) + assert np.isclose(svol.sum(), domain.n_vol, rtol=1e-9) + + # every element face is shared by two elements or lies on the boundary + faces = Counter() + for e in elems: + for i in range(n_dim + 1): + faces[tuple(sorted(np.delete(e, i)))] += 1 + assert max(faces.values()) == 2 + for face, c in faces.items(): + if c == 1: + fp = pts[list(face)] + assert any([np.allclose(fp[:, ax], lims[ax][k], atol=1e-9) + for ax in range(n_dim) for k in range(2)]) + + # element attributes: cells of the same amorphous phase are merged and + # labelled with one seed number; the element volumes of each attribute + # add up to the volume of its cells and every element lies in one of them + attrs = np.array(mesh.element_attributes) + conv = _amorphous_seed_numbers(pmesh, phases) + att_of_reg = np.array([conv.get(s, s) for s in pmesh.seed_numbers]) + assert set(attrs.tolist()) == set(att_of_reg.tolist()) + ppts = np.array(pmesh.points) + cell_vols = np.array(pmesh.volumes) + cents = pts[elems].mean(axis=1) + for att in np.unique(att_of_reg): + regs = np.nonzero(att_of_reg == att)[0] + mask = attrs == att + assert np.isclose(svol[mask].sum(), cell_vols[regs].sum(), rtol=1e-9) + inside = np.zeros(np.sum(mask), dtype=bool) + for r in regs: + planes, _ = _cell_planes(ppts, pmesh, r) + inside |= _inside(planes, cents[mask]) + assert np.all(inside) + + # the labels: crystalline cells keep their seed number, amorphous cells + # that share a facet (also across a periodic face) share a label, which + # is the seed number of one of them + seed_nums = np.array(pmesh.seed_numbers) + phase_nums = np.array(pmesh.phase_numbers) + amorph = np.array([phases[p]['material_type'] == 'amorphous' + for p in phase_nums]) + assert np.array_equal(att_of_reg[~amorph], seed_nums[~amorph]) + assert set(att_of_reg[amorph]) <= set(seed_nums[amorph]) + pairs = [tuple(n) for n in pmesh.facet_neighbors] + for axis_pairs in (pmesh.periodic_facets or {}).values(): + pairs += [(max(pmesh.facet_neighbors[lo]), + max(pmesh.facet_neighbors[hi])) for lo, hi in axis_pairs] + n_merged = 0 + for r_a, r_b in pairs: + if min(r_a, r_b) >= 0 and amorph[r_a] and amorph[r_b]: + assert att_of_reg[r_a] == att_of_reg[r_b] + n_merged += 1 + assert n_merged > 0 From f8a399f96d209dd6314dac7889bafc8294c94a96 Mon Sep 17 00:00:00 2001 From: Julian Rimoli Date: Tue, 22 Sep 2026 22:18:27 -0700 Subject: [PATCH 14/42] Take the attributes and facets of periodic meshes from the polymesh With -Y (no Steiner points on the boundary), TetGen can leave some of the sub-faces of a facet unmarked even though the tetrahedra conform to it. The region attribute of the cell on one side then floods through the unmarked sub-faces into the cell on the other side, and the facet is incomplete in the output. This happened for one facet in 30 random periodic meshes (two tetrahedra with the wrong seed number). For periodic meshes, the element attributes and the facets are now computed from the geometry of the polymesh: each element gets the label of the convex cell that contains its centroid, the facets are the faces between elements with different labels and the faces on the boundary, and each is numbered by the polymesh facet it lies on, found from the cells that contain the center of the face (merged amorphous cells are not separated by facets of the mesh, so an element may span several of them). A mesh that does not conform to the polymesh raises an error instead of being mislabelled silently. The geometry tests now also check that every face between elements with different attributes is a facet of the mesh and that the facets cover the facets of the polymesh exactly. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.rst | 7 +- src/microstructpy/meshing/trimesh.py | 151 ++++++++++++++++++++++++ tests/meshing/test_periodic_geometry.py | 40 ++++++- 3 files changed, 190 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 9e681335..9321207e 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -24,8 +24,11 @@ Added grains that are split by the faces. Cells of the same amorphous phase that touch across a periodic face are merged into one region, like cells that share a facet, and the merged region is labelled with the smallest - seed number among its cells by every mesher and writer. gmsh is not - supported for periodic meshes. + seed number among its cells by every mesher and writer. The element + attributes and the facets of periodic meshes are computed from the + geometry of the polymesh, since TetGen can leave sub-faces of a facet + unmarked when it may not modify the boundary and its region attributes + then leak between cells. gmsh is not supported for periodic meshes. Fixed ''''' diff --git a/src/microstructpy/meshing/trimesh.py b/src/microstructpy/meshing/trimesh.py index 67847709..1c239220 100644 --- a/src/microstructpy/meshing/trimesh.py +++ b/src/microstructpy/meshing/trimesh.py @@ -1564,6 +1564,12 @@ def _call_meshpy(polymesh, phases=None, min_angle=0, max_volume=float('inf'), tri_f = tri_faces[f_mask] tri_fa = tri_f_atts[f_mask] - 1 + if periodic: + # With -Y, TetGen can leave sub-faces unmarked and its region + # attributes then leak between cells: use the geometry instead + tri_e_atts, tri_f, tri_fa = _attributes_from_polymesh( + tri_pts, tri_elems, polymesh, labels) + tri_args = (tri_pts, tri_elems, tri_e_atts, tri_f, tri_fa) return tri_args @@ -2033,6 +2039,151 @@ def _triangulate_periodic_facets(polymesh, kps, facets, facet_nums): return new_facets, new_nums +def _attributes_from_polymesh(tri_pts, tri_elems, polymesh, labels): + """Element attributes and facets of a mesh, from the polymesh geometry. + + Each element belongs to the (convex) cell of the polymesh that contains + its centroid and its attribute is the label of that cell. The facets of + the mesh are the faces between elements of cells with different labels + and the faces on the boundary of the mesh; their attributes are the + numbers of the polymesh facets they lie on. + + TetGen can leave some sub-faces of a facet unmarked when it may not + modify the boundary (option -Y, used for periodic meshes). The region + attributes it assigns then leak between the cells on either side of + the facet and the facet is incomplete in its output. The geometry of + the polymesh does not have this problem. + + Args: + tri_pts (numpy.ndarray): The points of the mesh. + tri_elems (numpy.ndarray): The elements of the mesh. + polymesh (PolyMesh): The polygon/polyhedron mesh. + labels (numpy.ndarray): The label of each region of the polymesh. + + Returns: + tuple: The element attributes, the facets and the facet attributes. + + Raises: + RuntimeError: If the mesh does not conform to the polymesh, i.e. an + element centroid lies outside every cell or a face between two + cells does not lie on a facet of the polymesh. + """ + n_dim = tri_pts.shape[1] + p_pts = np.array(polymesh.points) + scale = np.max(p_pts.max(axis=0) - p_pts.min(axis=0)) + tol = 1e-9 * scale + cell_geom = _CellGeometry(polymesh, p_pts) + labels = np.array(labels) + + # 1. Cell containing the centroid of each element + cens = tri_pts[tri_elems].mean(axis=1) + elem_regs = np.full(len(tri_elems), -1) + i_remain = np.arange(len(tri_elems)) + for r_num in range(len(polymesh.regions)): + r_mins, r_maxs = cell_geom.limits(r_num) + r_cens = cens[i_remain] + in_box = np.all((r_cens >= r_mins - tol) & (r_cens <= r_maxs + tol), + axis=1) + r_i = i_remain[in_box] + if len(r_i) == 0: + continue + _, normals, centers = cell_geom.facets(r_num) + rel_pos = cens[r_i][:, np.newaxis, :] - centers + dp = np.einsum('efd,fd->ef', rel_pos, normals) + r_i = r_i[np.all(dp >= -tol, axis=1)] + elem_regs[r_i] = r_num + i_remain = np.setdiff1d(i_remain, r_i) + if len(i_remain) > 0: + e_str = 'The mesh does not conform to the polymesh: the centroids ' + e_str += 'of ' + str(len(i_remain)) + ' elements are outside every ' + e_str += 'cell.' + raise RuntimeError(e_str) + elem_atts = labels[elem_regs] + + # 2. Faces of the elements, with the cells on either side + n_elems = len(tri_elems) + faces = np.concatenate([np.delete(tri_elems, k, axis=1) + for k in range(n_dim + 1)]) + faces.sort(axis=1) + owners = np.tile(np.arange(n_elems), n_dim + 1) + u_faces, inv, counts = np.unique(faces, axis=0, return_inverse=True, + return_counts=True) + if np.any(counts > 2): + e_str = 'The mesh is not a manifold: a face is shared by more ' + e_str += 'than two elements.' + raise RuntimeError(e_str) + order = np.argsort(inv.reshape(-1), kind='stable') + starts = np.cumsum(counts) - counts + two = counts == 2 + r1 = elem_regs[owners[order[starts]]] + r2 = np.full(len(u_faces), -1) + r2[two] = elem_regs[owners[order[starts[two] + 1]]] + + # 3. Facets: faces between cells with different labels and faces on the + # boundary, numbered by the polymesh facet they lie on. Cells with the + # same label are not separated by facets of the mesh, so an element can + # span several of them: the facet is found from the cells that contain + # the center of the face, which lies on that facet. + is_facet = np.full(len(u_faces), True) + is_facet[two] = labels[r1[two]] != labels[r2[two]] + f_ids = np.nonzero(is_facet)[0] + f_cens = tri_pts[u_faces[f_ids]].mean(axis=1) + claims = [[] for _ in f_ids] + for r_num in range(len(polymesh.regions)): + r_mins, r_maxs = cell_geom.limits(r_num) + in_box = np.all((f_cens >= r_mins - tol) & (f_cens <= r_maxs + tol), + axis=1) + c_i = np.nonzero(in_box)[0] + if len(c_i) == 0: + continue + _, normals, centers = cell_geom.facets(r_num) + rel_pos = f_cens[c_i][:, np.newaxis, :] - centers + dp = np.einsum('efd,fd->ef', rel_pos, normals) + for j in c_i[np.all(dp >= -tol, axis=1)]: + claims[j].append(r_num) + + pair_facets = {} + for f_num, neighs in enumerate(polymesh.facet_neighbors): + if min(neighs) >= 0: + pair_facets[(min(neighs), max(neighs))] = f_num + e_str = 'The mesh does not conform to the polymesh: a face of the mesh ' + e_str += 'is not on a facet of the polymesh.' + facets = [] + facet_atts = [] + for j, i in enumerate(f_ids): + face = u_faces[i] + f_pts = tri_pts[face] + best_dist = float('inf') + best_f = None + for c_1 in [c for c in claims[j] if labels[c] == labels[r1[i]]]: + f_nums, normals, centers = cell_geom.facets(c_1) + rel_pos = f_pts[:, np.newaxis, :] - centers + dists = np.abs(np.einsum('pfd,fd->pf', rel_pos, + normals)).max(axis=0) + if two[i]: + cands = [pair_facets.get((min(c_1, c_2), max(c_1, c_2))) + for c_2 in claims[j] if labels[c_2] == labels[r2[i]]] + else: + # on the boundary of the domain, or of a void cell + cands = [f for f in f_nums if + min(polymesh.facet_neighbors[f]) < 0 or + labels[polymesh.facet_neighbors[f][0]] != + labels[polymesh.facet_neighbors[f][1]]] + for f_num in cands: + if f_num is None: + continue + k = np.nonzero(f_nums == f_num)[0][0] + if dists[k] < best_dist: + best_dist = dists[k] + best_f = int(f_num) + if best_f is None or best_dist > 1e-8 * scale: + raise RuntimeError(e_str) + facets.append(face) + facet_atts.append(best_f) + facets = np.array(facets, dtype='int').reshape(-1, n_dim) + return elem_atts, facets, np.array(facet_atts, dtype='int') + + def _abaqus_periodic_nsets(mesh): """Abaqus node sets of the periodic faces of a mesh. diff --git a/tests/meshing/test_periodic_geometry.py b/tests/meshing/test_periodic_geometry.py index bbeb9da5..e7b24009 100644 --- a/tests/meshing/test_periodic_geometry.py +++ b/tests/meshing/test_periodic_geometry.py @@ -38,6 +38,8 @@ True), ('cube-xyz-2', msp.geometry.Cube(side_length=1.5, corner=(0, 0, 0)), 2, True), + ('cube-xyz-4', msp.geometry.Cube(side_length=1.5, corner=(0, 0, 0)), 4, + True), ('cube-xz', msp.geometry.Cube(side_length=1.5, corner=(0, 0, 0)), 3, 'xz'), ('box-y', msp.geometry.Box(limits=[(0, 2), (0, 1), (0, 1.3)]), 7, 'y'), @@ -192,13 +194,14 @@ def test_elements_partition_the_cells(case): assert np.isclose(svol.sum(), domain.n_vol, rtol=1e-9) # every element face is shared by two elements or lies on the boundary - faces = Counter() - for e in elems: + face_elems = {} + for e_num, e in enumerate(elems): for i in range(n_dim + 1): - faces[tuple(sorted(np.delete(e, i)))] += 1 - assert max(faces.values()) == 2 - for face, c in faces.items(): - if c == 1: + face_elems.setdefault(tuple(sorted(np.delete(e, i))), + []).append(e_num) + assert max([len(e) for e in face_elems.values()]) == 2 + for face, e_nums in face_elems.items(): + if len(e_nums) == 1: fp = pts[list(face)] assert any([np.allclose(fp[:, ax], lims[ax][k], atol=1e-9) for ax in range(n_dim) for k in range(2)]) @@ -210,7 +213,32 @@ def test_elements_partition_the_cells(case): conv = _amorphous_seed_numbers(pmesh, phases) att_of_reg = np.array([conv.get(s, s) for s in pmesh.seed_numbers]) assert set(attrs.tolist()) == set(att_of_reg.tolist()) + + # the facets of the mesh: every face between elements with different + # attributes is a facet, and the facets cover each facet of the polymesh + # that separates different attributes (or is on the boundary) exactly + mesh_facets = {tuple(sorted(f)): a for f, a in + zip(mesh.facets, mesh.facet_attributes)} + for face, e_nums in face_elems.items(): + if len(e_nums) == 2 and attrs[e_nums[0]] != attrs[e_nums[1]]: + assert face in mesh_facets + if len(e_nums) == 1: + assert face in mesh_facets ppts = np.array(pmesh.points) + covered = np.zeros(len(pmesh.facets)) + for face, f_num in mesh_facets.items(): + fp = pts[list(face)] + covered[f_num] += np.linalg.norm(_facet_normal(fp)) + loop = ppts[pmesh.facets[f_num]] + n = _facet_normal(loop) + n /= np.linalg.norm(n) + assert np.abs((fp - loop[0]) @ n).max() < 1e-8 + for f_num, (r_a, r_b) in enumerate(pmesh.facet_neighbors): + measure = np.linalg.norm(_facet_normal(ppts[pmesh.facets[f_num]])) + if min(r_a, r_b) < 0 or att_of_reg[r_a] != att_of_reg[r_b]: + assert np.isclose(covered[f_num], measure, atol=1e-9) + else: + assert covered[f_num] == 0 cell_vols = np.array(pmesh.volumes) cents = pts[elems].mean(axis=1) for att in np.unique(att_of_reg): From 24fa757b2370313c15657a22d7887a3de25227b7 Mon Sep 17 00:00:00 2001 From: Julian Rimoli Date: Wed, 23 Sep 2026 00:10:03 -0700 Subject: [PATCH 15/42] Mesh periodic domains with the quality and size settings of other meshes The quality and size settings of the triangular/tetrahedral mesh (min_angle, max_volume, the max_volume of each phase, max_edge_length) now act on periodic meshes as on non-periodic ones. Fixing the boundary with -Y is not an option: the bundled Triangle then degrades every wall-adjacent triangle, and the bundled TetGen refuses any Steiner point that encroaches a sub-face, interior facets included, unless every facet is already at the target size. In 2D, the cells that touch the periodic faces are copied outside the faces while meshing, so that Triangle sees the same geometry on both sides of each face and refines both faces of a pair the same way. The mesh is built without -Y; when some nodes on the faces have no image, the points Triangle added on the faces are put on both faces (as fine as the finest of the two, not their union) and the mesh is built again, which converges in one to three passes. The elements outside the domain are removed, and the rare nodes still without an image are mirrored by splitting the element behind them. The quality equals that of the free mesh: the triangles below the minimum angle are those at cell corners with smaller angles, in both. In 3D, the domain is meshed once as usual (with all the facets given as constrained Delaunay triangulations, since TetGen 1.5 crashes on polygon facets with collinear vertices), then every facet is triangulated with the points TetGen added on it, the facets on opposite periodic faces with the points of both faces (merged by a spacing-aware rule) and with a minimum angle of 20 degrees and the area given by the size settings, and the mesh is built again with the facets fixed. Compared with the free mesh of the same periodic polymesh, the mesh has 20-25 % more tetrahedra and a 5th-percentile dihedral angle of 18-19 instead of 20-21 degrees; the maximum volumes, per phase too, and the maximum edge length on the faces are met, and the nodes on opposite faces match exactly. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.rst | 10 +- docs/source/cli/domain.rst | 11 + docs/source/cli/settings.rst | 3 +- src/microstructpy/cli.py | 3 +- src/microstructpy/meshing/trimesh.py | 1041 ++++++++++++++++++++++-- tests/meshing/test_periodic_quality.py | 196 +++++ 6 files changed, 1187 insertions(+), 77 deletions(-) create mode 100644 tests/meshing/test_periodic_quality.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 9321207e..91e76e17 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -17,8 +17,14 @@ Added Laguerre tessellation is periodic across those faces (cells crossing a face are cut and their pieces tile the domain), and the triangular, tetrahedral and raster meshes have matching nodes on opposite faces - (Triangle/TetGen run with ``-Y``; the polygons on opposite faces are - triangulated identically). The pairs of periodic points/nodes and facets + with the quality and size settings (``min_angle``, ``max_volume``, the + ``max_volume`` of each phase, ``max_edge_length``) acting as on + non-periodic meshes: in 2D the cells next to the periodic faces are + copied outside the faces while meshing, so that Triangle refines both + faces the same way; in 3D the facets are triangulated with the points + TetGen adds on them in a first pass, identically on opposite faces, and + the mesh is built again with the facets fixed. The pairs of periodic + points/nodes and facets are stored in the meshes and their text files; the Abaqus output has a node set per periodic face in matching order; the verification unwraps grains that are split by the faces. Cells of the same amorphous phase diff --git a/docs/source/cli/domain.rst b/docs/source/cli/domain.rst index 8df3a0b4..94553f28 100644 --- a/docs/source/cli/domain.rst +++ b/docs/source/cli/domain.rst @@ -341,3 +341,14 @@ matching order. The mesher must be Triangle/TetGen for periodic microstructures (with gmsh the nodes on opposite faces are not guaranteed to match), and the mesh size of a raster mesh must divide the domain length along the periodic axes. +The mesh quality and size settings (``mesh_min_angle``, ``mesh_max_volume``, +the ``max_volume`` of each phase and ``mesh_max_edge_length``) apply to +periodic meshes as to non-periodic ones. In 2D, the cells next to the +periodic faces are copied outside the faces while meshing, so that Triangle +refines both faces of a pair the same way, and the mesh is built again with +the points it added on the faces when some nodes have no image. In 3D, the +mesh is built once as usual, then the facets are triangulated with the points +TetGen added on them (the facets on opposite periodic faces with the points +of both) and the mesh is built again with the facets fixed; the elements next +to the periodic faces are slightly more numerous and of slightly lower +quality than in a non-periodic mesh. diff --git a/docs/source/cli/settings.rst b/docs/source/cli/settings.rst index 95037f16..894879aa 100644 --- a/docs/source/cli/settings.rst +++ b/docs/source/cli/settings.rst @@ -348,7 +348,8 @@ This field defines the maximum edge length along a grain boundary in a 2D triangular mesh. A small maximum edge length will increase resolution of the mesh at grain boundaries. -Currently this feature has no equivalent in 3D. +In 3D, it applies only to the triangles on the boundary of a periodic domain +(the interior of a 3D mesh is controlled by the maximum volume). The default value is `` inf ``, which effectively turns off the edge length quality control. diff --git a/src/microstructpy/cli.py b/src/microstructpy/cli.py index 008af8c1..4215ca07 100644 --- a/src/microstructpy/cli.py +++ b/src/microstructpy/cli.py @@ -359,7 +359,8 @@ def run(phases, domain, verbose=False, restart=True, directory='.', Defaults to 0, which turns off the angle quality constraint. Value should be in the range 0-60. mesh_max_edge_length (float): *(optional)* The maximum edge length of - elements along grain boundaries. Currently only supported in 2D. + elements along grain boundaries. Supported in 2D and, for the + triangles on the boundary of a periodic domain, in 3D. mesh_size (float): The target size of the mesh elements. This option is used with gmsh. Default is infinity, whihch turns off this control. diff --git a/src/microstructpy/meshing/trimesh.py b/src/microstructpy/meshing/trimesh.py index 1c239220..f7d24b20 100644 --- a/src/microstructpy/meshing/trimesh.py +++ b/src/microstructpy/meshing/trimesh.py @@ -13,10 +13,13 @@ from __future__ import division from __future__ import print_function +import itertools + import meshpy.tet import meshpy.triangle import numpy as np import pygmsh as pg +from scipy.spatial import cKDTree from matplotlib import collections from matplotlib import patches from matplotlib import pyplot as plt @@ -226,7 +229,9 @@ def from_polymesh(cls, polymesh, phases=None, mesher='Triangle/Tetgen', TetGen. Defaults to infinity, which turns off this control. max_edge_length (float): The maximum edge length of elements along grain boundaries. This option is used with Triangle - and gmsh. Defaults to infinity, which turns off this control. + and gmsh, and in 3D for the triangles on the boundary of a + periodic domain. Defaults to infinity, which turns off this + control. mesh_size (float): The target size of the mesh elements. This option is used with gmsh. Default is infinity, whihch turns off this control. @@ -1448,12 +1453,6 @@ def _call_meshpy(polymesh, phases=None, min_angle=0, max_volume=float('inf'), sub_out = meshpy.triangle.subdivide_facets(n_subs, pts, facets, facet_nums) pts, facets, facet_nums = sub_out - elif periodic: - # TetGen triangulates polygonal facets itself, so the facets on - # opposite periodic faces are triangulated here, identically, and - # passed as triangles - facets, facet_nums = _triangulate_periodic_facets(polymesh, kps, - facets, facet_nums) # create groups/regions pts_arr = np.array(polymesh.points) @@ -1511,20 +1510,6 @@ def _call_meshpy(polymesh, phases=None, min_angle=0, max_volume=float('inf'), else: regions.append(cell_cen.tolist() + [seed_num, phase_vol]) - # build inputs - if n_dim == 2: - info = meshpy.triangle.MeshInfo() - else: - info = meshpy.tet.MeshInfo() - - info.set_points(pts) - info.set_facets(facets, facet_nums) - info.set_holes(holes) - - info.regions.resize(len(regions)) - for i, r in enumerate(regions): - info.regions[i] = tuple(r) - # run MeshPy # The maximum element volume is set per region above, using the global # value as the default for the phases that do not set their own. Only @@ -1532,16 +1517,16 @@ def _call_meshpy(polymesh, phases=None, min_angle=0, max_volume=float('inf'), # (global) constraint would cap the per-phase values and, in 2D, an # infinite one is formatted as 'ainf', which Triangle reads as the # switches -a -i -n -f. - # A periodic mesh must keep the nodes of the boundary facets as they are - # (Triangle's -Y switch), so that opposite faces have matching nodes. + # A periodic mesh is built like a non-periodic one, then made periodic + # (see _build_periodic_2d and _build_periodic_3d). if n_dim == 2: - tri_mesh = meshpy.triangle.build(info, - attributes=True, - volume_constraints=True, - max_volume=None, - min_angle=min_angle, - generate_faces=True, - allow_boundary_steiner=not periodic) + if periodic: + tri_pts, tri_elems, tri_e_atts = _build_periodic_2d( + polymesh, phases, labels, kps, pts, facets, facet_nums, + holes, regions, min_angle, max_volume) + else: + tri_mesh = _build_2d(pts, facets, facet_nums, holes, regions, + min_angle, True) else: opts = meshpy.tet.Options('pq') opts.mindihedral = min_angle @@ -1550,25 +1535,33 @@ def _call_meshpy(polymesh, phases=None, min_angle=0, max_volume=float('inf'), opts.regionattrib = 1 opts.facesout = 1 if periodic: - opts.nobisect = 1 # -Y: keep the boundary facets as given - tri_mesh = meshpy.tet.build(info, options=opts) + tri_mesh = _build_periodic_3d(polymesh, phases, kps, pts, + facet_nums, holes, regions, opts, + max_volume, max_edge_length) + else: + info = _tet_info(pts, facets, facet_nums, holes, regions) + tri_mesh = meshpy.tet.build(info, options=opts) # return mesh - tri_pts = np.array(tri_mesh.points) - tri_elems = np.array(tri_mesh.elements) - tri_e_atts = np.array(tri_mesh.element_attributes, dtype='int') - - tri_faces = np.array(tri_mesh.faces) - tri_f_atts = np.array(tri_mesh.face_markers) - f_mask = tri_f_atts > 0 - tri_f = tri_faces[f_mask] - tri_fa = tri_f_atts[f_mask] - 1 - if periodic: - # With -Y, TetGen can leave sub-faces unmarked and its region - # attributes then leak between cells: use the geometry instead + # The element attributes and the facets are taken from the + # geometry of the polymesh (TetGen can leave sub-faces unmarked + # when it may not modify the facets, and its region attributes + # then leak between cells) + if n_dim == 3: + tri_pts = np.array(tri_mesh.points) + tri_elems = np.array(tri_mesh.elements) tri_e_atts, tri_f, tri_fa = _attributes_from_polymesh( tri_pts, tri_elems, polymesh, labels) + else: + tri_pts = np.array(tri_mesh.points) + tri_elems = np.array(tri_mesh.elements) + tri_e_atts = np.array(tri_mesh.element_attributes, dtype='int') + tri_faces = np.array(tri_mesh.faces) + tri_f_atts = np.array(tri_mesh.face_markers) + f_mask = tri_f_atts > 0 + tri_f = tri_faces[f_mask] + tri_fa = tri_f_atts[f_mask] - 1 tri_args = (tri_pts, tri_elems, tri_e_atts, tri_f, tri_fa) return tri_args @@ -1990,53 +1983,955 @@ def _facet_in_normal(pts, cen_pt): return un, f_cen -def _triangulate_periodic_facets(polymesh, kps, facets, facet_nums): - """Triangulate the facets on the periodic faces of a 3D polymesh. +_FACE_MIN_ANGLE = 20.0 # quality of the triangles on the periodic faces (3D) +_MAX_EDGE_SUBDIVISIONS = 400 +_MAX_PERIODIC_PASSES = 8 + + +def _facet_sizes(polymesh, phases, facet_nums, max_volume, max_edge_length): + """Target edge length of the elements on each facet. + + The facets on the periodic faces are triangulated before meshing: to + the maximum edge length, and to the edge length of the regular + tetrahedron with the maximum volume of the phase of the cell on the + facet. + + Returns: + dict: Maps the polymesh facet number to the edge length. + + """ + n_dim = len(polymesh.points[0]) + h_facets = {} + for f_num in facet_nums: + h_val = max_edge_length + for reg in polymesh.facet_neighbors[f_num - 1]: + if reg < 0: + continue + phase = phases[polymesh.phase_numbers[reg]] + vol = phase.get('max_volume', max_volume) + if np.isfinite(vol): + if n_dim == 2: + h_val = min(h_val, np.sqrt(4 * vol / np.sqrt(3))) + else: + h_val = min(h_val, (6 * np.sqrt(2) * vol) ** (1.0 / 3)) + h_facets[f_num - 1] = h_val + return h_facets + + +def _edge_key(kp_a, kp_b): + return (min(kp_a, kp_b), max(kp_a, kp_b)) + + +def _points_on_segment(new_pts, pt_a, pt_b): + """Parameters (0 < t < 1) of the points that lie on a segment.""" + if len(new_pts) == 0: + return [] + rel = np.array(new_pts) - pt_a + seg = pt_b - pt_a + len2 = np.dot(seg, seg) + t_vals = rel.dot(seg) / len2 + dists = np.linalg.norm(rel - np.outer(t_vals, seg), axis=1) + on_seg = (t_vals > 1e-9) & (t_vals < 1 - 1e-9) + on_seg &= dists <= 1e-9 * np.sqrt(len2) + return sorted(t_vals[on_seg].tolist()) + + +def _merge_params(t_vals, sides=None, n_max=_MAX_EDGE_SUBDIVISIONS, + min_gap=1e-6): + """Subdivision of a segment from the parameters of points on it. + + The points come from the refinement of the facets that share the + segment and of its periodic images, so each face of a periodic pair + contributes a set of points. Points closer than ``min_gap`` (relative + to the segment) are merged, and a point is dropped when a point of + another side (``sides``, one value per parameter) is kept closer than + 0.4 times the gap to the next point: the subdivision is as fine as the + finest side, not the union of the sides. + + Returns: + list: The sorted parameters (0 < t < 1) of the subdivision points, + at most n_max - 1 of them. + + """ + if sides is None: + sides = [None] * len(t_vals) + order = np.argsort(t_vals) + ts = [t_vals[i] for i in order] + ss = [sides[i] for i in order] + kept_t = [0.0] + kept_s = [None] + for i, (t_val, side) in enumerate(zip(ts, ss)): + if not (0 < t_val < 1 - min_gap) or t_val - kept_t[-1] <= min_gap: + continue + gap_next = (ts[i + 1] if i + 1 < len(ts) else 1.0) - t_val + if (side is not None and kept_s[-1] is not None and + kept_s[-1] != side and t_val - kept_t[-1] < 0.4 * gap_next): + continue + kept_t.append(t_val) + kept_s.append(side) + merged = kept_t[1:] + if len(merged) >= n_max: + merged = [i / n_max for i in range(1, n_max)] + return merged + + +def _triangle_polygon(loop_pts, h_val, allow_boundary_steiner, extra_pts=(), + quality=True): + """Triangulate a planar convex polygon in 3D with Triangle. + + With ``quality``, the triangles have a minimum angle of 20 degrees + and, if ``h_val`` is finite, at most the area of the equilateral + triangle with that edge length; Steiner points are added on the edges + of the polygon only if allowed. Without it, the triangulation is the + constrained Delaunay triangulation of the points. The extra points, + inside the polygon, are vertices of the triangulation. - Each facet on a lower periodic face is split into a fan of triangles - and its image on the upper face into the corresponding triangles (the - images of the same points), so that TetGen, which keeps the boundary - facets as given with the -Y switch, produces matching triangles on - opposite faces. + Returns: + tuple: The points (the polygon points, then the extra points, in + order, then the new ones) as an array, and the triangles (lists of + point indices). + + """ + loop_pts = np.asarray(loop_pts, dtype='float') + n_pts = len(loop_pts) + extra_pts = np.asarray(extra_pts, dtype='float').reshape(-1, 3) + n_in = n_pts + len(extra_pts) + + # orthonormal basis of the plane of the polygon + normal = np.zeros(3) + for i in range(n_pts): + normal += np.cross(loop_pts[i - 1], loop_pts[i]) + normal /= np.linalg.norm(normal) + edges = np.roll(loop_pts, -1, axis=0) - loop_pts + u_vec = edges[np.argmax(np.linalg.norm(edges, axis=1))] + u_vec = u_vec - np.dot(u_vec, normal) * normal + u_vec /= np.linalg.norm(u_vec) + v_vec = np.cross(normal, u_vec) + origin = loop_pts[0] + in_pts = np.vstack([loop_pts, extra_pts]) + rel = in_pts - origin + pts_2d = np.column_stack([rel.dot(u_vec), rel.dot(v_vec)]) + + info = meshpy.triangle.MeshInfo() + info.set_points(pts_2d.tolist()) + info.set_facets([(i, (i + 1) % n_pts) for i in range(n_pts)]) + max_area = None + min_angle = None + if quality: + min_angle = _FACE_MIN_ANGLE + if np.isfinite(h_val): + max_area = 0.25 * np.sqrt(3) * h_val * h_val + tri = meshpy.triangle.build(info, max_volume=max_area, + min_angle=min_angle, quality_meshing=quality, + allow_boundary_steiner=allow_boundary_steiner) + + # Triangle keeps the input points first, in order + out_2d = np.array(tri.points) + if len(out_2d) < n_in or not np.allclose(out_2d[:n_in], pts_2d): + raise RuntimeError('Triangle did not keep the input points of a ' + 'facet.') + out_pts = origin + np.outer(out_2d[:, 0], u_vec) + out_pts += np.outer(out_2d[:, 1], v_vec) + out_pts[:n_in] = in_pts + for axis in range(3): + if np.ptp(loop_pts[:, axis]) <= 1e-12: + out_pts[:, axis] = loop_pts[0, axis] + tris = [list(elem) for elem in np.array(tri.elements)] + return out_pts, tris + + +def _triangulate_facets_3d(polymesh, phases, kps, pts, facet_nums, max_volume, + max_edge_length, edge_t, face_pts): + """Triangulate the facets of a periodic 3D polymesh. + + Each facet is triangulated with Triangle (with a minimum angle of 20 + degrees and at most the area given by the mesh size, see + :func:`_facet_sizes`). The edges of the facets are subdivided where + Triangle refines them and at the extra parameters in ``edge_t``, and + the triangulations contain the extra points in ``face_pts``. An edge + on a periodic face is subdivided identically to its periodic images, + and the facets on the upper periodic faces get the images of the + points and triangles of the facets on the lower faces, so that the + nodes on opposite faces match. Args: polymesh (PolyMesh): The periodic polymesh. + phases (list): The phases. kps (dict): Maps polymesh point numbers to the point numbers of the mesher input. - facets (list): Facets of the mesher input (lists of point numbers). - facet_nums (list): Polymesh facet number + 1 of each facet. + pts (list): Points of the mesher input. + facet_nums (list): Polymesh facet number + 1 of each facet of the + mesher input. + max_volume (float): The default maximum volume of the elements. + max_edge_length (float): The maximum edge length. + edge_t (dict): Maps an edge (pair of polymesh point numbers, in + increasing order) to parameters of extra points on it. + face_pts (dict): Maps a facet number to extra points in the facet + (for a facet on an upper periodic face, they are stored with + the facet on the lower face). + + Returns: + tuple: The new points, facets (triangles) and facet numbers. + + """ + pts = [list(p) for p in pts] + p_arr = np.array(polymesh.points) + n_dim = p_arr.shape[1] + lengths = p_arr.max(axis=0) - p_arr.min(axis=0) + scale = lengths.max() + per_pts = polymesh.periodic_points or {} + per_facets = polymesh.periodic_facets or {} + lo_hi = {axis: dict(pairs) for axis, pairs in per_pts.items()} + hi_lo = {axis: {b: a for a, b in pairs} for axis, pairs in + per_pts.items()} + h_facets = _facet_sizes(polymesh, phases, facet_nums, max_volume, + max_edge_length) + + upper = {} + for axis, f_pairs in per_facets.items(): + for f_lo, f_hi in f_pairs: + upper[f_lo] = (axis, f_hi) + is_upper = set([f_hi for _, f_hi in upper.values()]) + + # 1. Edges of the facets; an edge and its periodic images are + # subdivided identically + edge_keys = set() + for f_num in facet_nums: + loop = polymesh.facets[f_num - 1] + for i in range(len(loop)): + edge_keys.add(_edge_key(loop[i - 1], loop[i])) + + parent = {key: key for key in edge_keys} + + def find(key): + while parent[key] != key: + parent[key] = parent[parent[key]] + key = parent[key] + return key + + for kp_map in lo_hi.values(): + for key in edge_keys: + if key[0] in kp_map and key[1] in kp_map: + image = _edge_key(kp_map[key[0]], kp_map[key[1]]) + if image in edge_keys: + parent[find(key)] = find(image) + + def to_root(key, t_vals): + # the parameters along key, in the orientation of its class root + root = find(key) + seg = p_arr[key[1]] - p_arr[key[0]] + seg_root = p_arr[root[1]] - p_arr[root[0]] + if np.dot(seg, seg_root) >= 0: + return list(t_vals) + return [1 - t for t in t_vals] + + # 2. Parameters of the points on the edges: the edges of the periodic + # facets are subdivided to the maximum edge length, and all the edges + # at the extra parameters + periodic_facets = set(upper) | is_upper + splits = {} + for f_num in facet_nums: + f = f_num - 1 + if f not in periodic_facets or not np.isfinite(max_edge_length): + continue + loop = polymesh.facets[f] + for i in range(len(loop)): + key = _edge_key(loop[i - 1], loop[i]) + edge_len = np.linalg.norm(p_arr[key[1]] - p_arr[key[0]]) + n_sub = int(np.ceil(edge_len / max_edge_length)) + t_vals = [k / n_sub for k in range(1, n_sub)] + splits.setdefault(find(key), []).extend( + [(t, None) for t in to_root(key, t_vals)]) + for key, vals in edge_t.items(): + if key in edge_keys: + ts = to_root(key, [t for t, _ in vals]) + splits.setdefault(find(key), []).extend( + zip(ts, [s for _, s in vals])) + + # 3. Subdivide the edges; the images of an edge get translated copies of + # its points, recorded in image_map (lower point -> upper point) + edge_pts = {} + image_map = {axis: {kps[a]: kps[b] for a, b in pairs} for axis, pairs in + per_pts.items()} + for key in sorted(edge_keys): + if key in edge_pts: + continue + pt_a, pt_b = p_arr[key[0]], p_arr[key[1]] + edge_len = np.linalg.norm(pt_b - pt_a) + min_gap = max(1e-6, 1e-6 * scale / edge_len) + vals = splits.get(find(key), []) + t_vals = _merge_params([t for t, _ in vals], [s for _, s in vals], + min_gap=min_gap) + t_vals = sorted(to_root(key, t_vals)) + ids = [] + for t_val in t_vals: + ids.append(len(pts)) + pts.append((pt_a + t_val * (pt_b - pt_a)).tolist()) + edge_pts[key] = ids + + queue = [key] + while queue: + kp_a, kp_b = queue.pop() + ids = edge_pts[(kp_a, kp_b)] + for axis in lo_hi: + for kp_map, sign in ((lo_hi[axis], 1), (hi_lo[axis], -1)): + if kp_a not in kp_map or kp_b not in kp_map: + continue + im_a, im_b = kp_map[kp_a], kp_map[kp_b] + im_key = _edge_key(im_a, im_b) + if im_key not in edge_keys: + continue + if im_key not in edge_pts: + shift = np.zeros(n_dim) + shift[axis] = sign * lengths[axis] + im_ids = [] + for pid in ids: + im_ids.append(len(pts)) + pts.append((np.array(pts[pid]) + shift).tolist()) + if im_a != im_key[0]: + im_ids = im_ids[::-1] + edge_pts[im_key] = im_ids + queue.append(im_key) + im_ids = edge_pts[im_key] + if im_a != im_key[0]: + im_ids = im_ids[::-1] + for pid, im_pid in zip(ids, im_ids): + if sign > 0: + image_map[axis][pid] = im_pid + else: + image_map[axis][im_pid] = pid + + # 4. Facet loops with the new points + loops = {} + for f_num in facet_nums: + loop = polymesh.facets[f_num - 1] + new_loop = [] + for i in range(len(loop)): + kp_a, kp_b = loop[i], loop[(i + 1) % len(loop)] + new_loop.append(kps[kp_a]) + key = _edge_key(kp_a, kp_b) + ids = edge_pts[key] + new_loop.extend(ids if kp_a == key[0] else ids[::-1]) + loops[f_num - 1] = new_loop + + # 5. Triangulate the facets with their edges fixed; the facets on the + # upper periodic faces are the images of those on the lower faces. The + # facets on the periodic faces are refined to the mesh size when one is + # given (TetGen cannot refine them afterwards), the others are the + # constrained Delaunay triangulations of their points. + new_facets = [] + new_nums = [] + for f_num in facet_nums: + f = f_num - 1 + if f in is_upper: + continue + loop_ids = loops[f] + extra = face_pts.get(f, []) + quality = f in periodic_facets + h_val = h_facets[f] + if quality and np.isfinite(h_val): + # the area bound is met by equilateral triangles of that edge + # length; a smaller area keeps the edges of the other triangles + # at about the maximum edge length, and the elements on the + # faces, which TetGen may not split, below the maximum volume + h_val = 0.75 * h_val + out_pts, tris = _triangle_polygon([pts[k] for k in loop_ids], + h_val, False, extra, quality) + ids = list(loop_ids) + new_ids = [] + for pt in out_pts[len(loop_ids):]: + new_ids.append(len(pts)) + ids.append(len(pts)) + pts.append(pt.tolist()) + tris = [[ids[k] for k in tri] for tri in tris] + new_facets.extend(tris) + new_nums.extend([f_num] * len(tris)) + if f not in upper: + continue + + p_axis, f_hi = upper[f] + kp_map = image_map[p_axis] + shift = np.zeros(n_dim) + shift[p_axis] = lengths[p_axis] + for pid in new_ids: + kp_map[pid] = len(pts) + pts.append((np.array(pts[pid]) + shift).tolist()) + new_facets.extend([[kp_map[k] for k in tri] for tri in tris]) + new_nums.extend([f_hi + 1] * len(tris)) + return pts, new_facets, new_nums + + +def _merge_face_points(raw, loop_pts, tol_dup): + """Points inside a facet from the refinement of both periodic faces. + + A point of one side is dropped when a point of another side is kept + within 0.4 times its distance to the nearest point of its own side (or + vertex of the facet), so that the facet is as refined as the finest + side, not the union of the sides. Points closer than ``tol_dup`` are + merged. + + Args: + raw (list): Pairs of a point and its side. + loop_pts (numpy.ndarray): The vertices of the facet. + tol_dup (float): Distance below which points are the same. + + Returns: + list: The points kept. + + """ + by_side = {} + for pt, side in raw: + by_side.setdefault(side, []).append(pt) + kept = [] + kept_side = [] + for side in sorted(by_side, key=lambda s: -len(by_side[s])): + pts_s = np.array(by_side[side]) + tree = cKDTree(np.vstack([pts_s, loop_pts])) + d_own = tree.query(pts_s, k=2)[0][:, 1] + for pt, s_own in zip(pts_s, d_own): + if kept: + dists = np.linalg.norm(np.array(kept) - pt, axis=1) + j = int(np.argmin(dists)) + if dists[j] <= tol_dup: + continue + if kept_side[j] != side and dists[j] < 0.4 * s_own: + continue + kept.append(pt.tolist()) + kept_side.append(side) + return kept + + +def _collect_facet_points_3d(new_pts, polymesh, edge_t, face_pts): + """Record the points that TetGen added on the facets of a polymesh. + + A point on an edge of a facet is added to the parameters of that edge + (``edge_t``), a point inside a facet to the extra points of the facet + (``face_pts``); points inside the cells are ignored. A point on an + upper periodic face is moved to the lower face and recorded with the + facet there, so that the next triangulation of the facets has the + point, and its images, on both faces. Each point is recorded with its + side (the periodic faces it was on), and the points of the two faces + of a pair are merged so that the facets are as refined as the finest + side (see :func:`_merge_params` and :func:`_merge_face_points`). Returns: - tuple: The new facets and facet numbers. + int: The number of points recorded. """ - f_index = {f_num - 1: i for i, f_num in enumerate(facet_nums)} - replaced = {} + p_arr = np.array(polymesh.points) + mins = p_arr.min(axis=0) + lengths = p_arr.max(axis=0) - mins + maxs = mins + lengths + scale = lengths.max() + tol = 1e-9 * scale + tol_dup = 1e-6 * scale + per_axes = polymesh.periodic_axes + to_lower = {} for axis, f_pairs in (polymesh.periodic_facets or {}).items(): - kp_map = dict(polymesh.periodic_points[axis]) for f_lo, f_hi in f_pairs: - if f_lo not in f_index or f_hi not in f_index: + to_lower[f_hi] = (axis, f_lo) + + new_pts = np.asarray(new_pts, dtype='float').reshape(-1, 3) + if len(new_pts) == 0: + return 0 + + # 1. Facets whose plane contains each point, among the facets of the + # cells that contain it + cell_geom = _CellGeometry(polymesh, p_arr) + point_facets = {} + for r_num in range(len(polymesh.regions)): + r_mins, r_maxs = cell_geom.limits(r_num) + in_box = np.all((new_pts >= r_mins - tol) & (new_pts <= r_maxs + tol), + axis=1) + cand = np.nonzero(in_box)[0] + if len(cand) == 0: + continue + f_nums, normals, centers = cell_geom.facets(r_num) + rel_pos = new_pts[cand][:, np.newaxis, :] - centers + dp = np.einsum('efd,fd->ef', rel_pos, normals) + inside = np.all(dp >= -tol, axis=1) + for i, row in zip(cand[inside], dp[inside]): + for k in np.nonzero(np.abs(row) <= tol)[0]: + point_facets.setdefault(i, set()).add(int(f_nums[k])) + + # 2. Record the points on edges and inside facets, with their sides + n_found = 0 + raw_face = {} + for i, f_set in point_facets.items(): + pt = np.array(new_pts[i]) + side = tuple([int(bool(per_axes[k]) and abs(pt[k] - maxs[k]) <= tol) + for k in range(3)]) + f_list = sorted(f_set) + if len(f_list) > 1: + loop = polymesh.facets[f_list[0]] + for k in range(len(loop)): + key = _edge_key(loop[k - 1], loop[k]) + t_vals = _points_on_segment([pt], p_arr[key[0]], + p_arr[key[1]]) + if t_vals: + known = edge_t.setdefault(key, []) + edge_len = np.linalg.norm(p_arr[key[1]] - p_arr[key[0]]) + if all([abs(t_vals[0] - t) * edge_len > tol_dup + for t, _ in known]): + known.append((t_vals[0], side)) + n_found += 1 + break + else: + f = f_list[0] + if f in to_lower: + axis, f = to_lower[f] + pt[axis] -= lengths[axis] + raw_face.setdefault(f, []).append((pt, side)) + for f, raw in raw_face.items(): + merged = _merge_face_points(raw, p_arr[polymesh.facets[f]], tol_dup) + face_pts.setdefault(f, []).extend(merged) + n_found += len(merged) + return n_found + + +def _tet_info(pts, facets, facet_nums, holes, regions): + """Build the TetGen input.""" + info = meshpy.tet.MeshInfo() + info.set_points(pts) + info.set_facets(facets, facet_nums) + info.set_holes(holes) + info.regions.resize(len(regions)) + for i, region in enumerate(regions): + info.regions[i] = tuple(region) + return info + + +def _build_periodic_3d(polymesh, phases, kps, pts, facet_nums, holes, regions, + opts, max_volume, max_edge_length): + """Build a periodic tetrahedral mesh with TetGen, in two passes. + + In the first pass, the facets are triangulated (identically on + opposite periodic faces) and TetGen meshes the domain as usual, adding + points on the facets where its quality and size settings require it. + The facets are then triangulated again with these points, the facets + on opposite periodic faces getting the points of both, and TetGen + meshes the domain without changing the facets (-Y): the mesh then has + matching nodes on opposite faces, facets refined as in the first pass, + and TetGen still refines the interior of the cells. + + Returns: + The mesh built by MeshPy. + + """ + pts_1, facets_1, nums_1 = _triangulate_facets_3d( + polymesh, phases, kps, pts, facet_nums, max_volume, max_edge_length, + {}, {}) + info = _tet_info(pts_1, facets_1, nums_1, holes, regions) + tri_mesh = meshpy.tet.build(info, options=opts) + tri_pts = np.array(tri_mesh.points) + n_in = len(pts_1) + if len(tri_pts) < n_in or not np.allclose(tri_pts[:n_in], pts_1): + raise RuntimeError('TetGen did not keep the input points.') + + edge_t = {} + face_pts = {} + _collect_facet_points_3d(tri_pts[n_in:], polymesh, edge_t, face_pts) + pts_2, facets_2, nums_2 = _triangulate_facets_3d( + polymesh, phases, kps, pts, facet_nums, max_volume, max_edge_length, + edge_t, face_pts) + info = _tet_info(pts_2, facets_2, nums_2, holes, regions) + opts.nobisect = 1 + return meshpy.tet.build(info, options=opts) + + +def _build_2d(pts, facets, facet_nums, holes, regions, min_angle, + allow_boundary_steiner): + """Build a 2D mesh with Triangle.""" + info = meshpy.triangle.MeshInfo() + info.set_points(pts) + info.set_facets(facets, facet_nums) + info.set_holes(holes) + info.regions.resize(len(regions)) + for i, region in enumerate(regions): + info.regions[i] = tuple(region) + return meshpy.triangle.build(info, attributes=True, + volume_constraints=True, max_volume=None, + min_angle=min_angle, generate_faces=True, + allow_boundary_steiner=allow_boundary_steiner) + + +def _split_periodic_boundary_2d(tri_pts, pts, facets, facet_nums, polymesh): + """Add the points that Triangle put on the periodic faces to the facets. + + Triangle refines the boundary segments of a mesh where its quality and + size settings require it, but not the same way on opposite periodic + faces. The points it added on a facet of a periodic face and on the + image of the facet on the opposite face are inserted in both facets, + as images of each other, so that the next mesh has matching nodes on + opposite faces. The points of the two facets are merged so that the + facets are as refined as the finest of the two (see + :func:`_merge_params`). + + Args: + tri_pts (numpy.ndarray): Points of the mesh built by Triangle. + pts (list): Points of the mesher input. + facets (list): Facets (segments) of the mesher input. + facet_nums (list): Polymesh facet number + 1 of each facet. + polymesh (PolyMesh): The periodic polymesh. + + Returns: + tuple: The new points, facets and facet numbers, and the number of + points that Triangle added on the periodic faces. + + """ + pts = [list(p) for p in pts] + new_pts = np.array(tri_pts)[len(pts):] + p_arr = np.array(polymesh.points) + mins = p_arr.min(axis=0) + lengths = p_arr.max(axis=0) - mins + scale = lengths.max() + tol = 1e-9 * scale + per_axes = polymesh.periodic_axes + + # facets on the periodic faces, with the parameters of the new points + seg_t = {} + ends = {} + for i, f_num in enumerate(facet_nums): + if f_num <= 0: + continue # a facet of a copied cell, outside the domain + wall = min(polymesh.facet_neighbors[f_num - 1]) + if wall >= 0 or not per_axes[(-wall - 1) // 2]: + continue + pt_a, pt_b = np.array(pts[facets[i][0]]), np.array(pts[facets[i][1]]) + seg_t[i] = _points_on_segment(new_pts, pt_a, pt_b) + ends[i] = (pt_a, pt_b) + n_new = sum([len(t_vals) for t_vals in seg_t.values()]) + if n_new == 0: + return pts, [list(f) for f in facets], list(facet_nums), 0 + + # a facet on a lower periodic face and its image on the upper face, + # matched by their midpoints + seg_ids = sorted(ends) + mids = np.array([0.5 * (ends[i][0] + ends[i][1]) for i in seg_ids]) + tree = cKDTree(mids) + pair_of = {} + for i in seg_ids: + pt_a, pt_b = ends[i] + for axis, flag in enumerate(per_axes): + if not flag or not np.allclose([pt_a[axis], pt_b[axis]], + mins[axis], atol=tol): continue - loop_lo = polymesh.facets[f_lo] - loop_hi = [kp_map[kp] for kp in loop_lo] - tris_lo = [[kps[loop_lo[0]], kps[loop_lo[k]], kps[loop_lo[k + 1]]] - for k in range(1, len(loop_lo) - 1)] - tris_hi = [[kps[loop_hi[0]], kps[loop_hi[k]], kps[loop_hi[k + 1]]] - for k in range(1, len(loop_hi) - 1)] - replaced[f_index[f_lo]] = tris_lo - replaced[f_index[f_hi]] = tris_hi + shift = np.zeros(len(mins)) + shift[axis] = lengths[axis] + dist, k = tree.query(0.5 * (pt_a + pt_b) + shift) + j = seg_ids[k] + if dist <= tol and j != i: + same = np.allclose(ends[j][0], pt_a + shift, atol=tol) + pair_of[i] = (j, shift, same) + is_upper = set([j for j, _, _ in pair_of.values()]) + + def chain(kp_a, ids, kp_b): + kp_list = [kp_a] + ids + [kp_b] + return [[kp_list[k], kp_list[k + 1]] for k in range(len(kp_list) - 1)] new_facets = [] new_nums = [] for i, (facet, f_num) in enumerate(zip(facets, facet_nums)): - if i in replaced: - for tri in replaced[i]: - new_facets.append(tri) - new_nums.append(f_num) - else: - new_facets.append(facet) + if i not in seg_t: + new_facets.append(list(facet)) new_nums.append(f_num) - return new_facets, new_nums + continue + if i in is_upper: + continue + pt_a, pt_b = ends[i] + t_vals = list(seg_t[i]) + sides = [0] * len(t_vals) + if i in pair_of: + j, shift, same = pair_of[i] + t_vals += [t if same else 1 - t for t in seg_t[j]] + sides += [1] * len(seg_t[j]) + ids = [] + for t_val in _merge_params(t_vals, sides): + ids.append(len(pts)) + pts.append((pt_a + t_val * (pt_b - pt_a)).tolist()) + new_facets.extend(chain(facet[0], ids, facet[1])) + new_nums.extend([f_num] * (len(ids) + 1)) + if i in pair_of: + j, shift, same = pair_of[i] + im_ids = [] + for pid in ids: + im_ids.append(len(pts)) + pts.append((np.array(pts[pid]) + shift).tolist()) + if not same: + im_ids = im_ids[::-1] + new_facets.extend(chain(facets[j][0], im_ids, facets[j][1])) + new_nums.extend([facet_nums[j]] * (len(im_ids) + 1)) + return pts, new_facets, new_nums, n_new + + +def _unmatched_periodic_nodes(pts, polymesh): + """Nodes on a periodic face without an image on the opposite face. + + Returns: + list: Tuples of the node number, the axis and the value of the + coordinate of the opposite face. + + """ + pts = np.asarray(pts, dtype='float') + p_arr = np.array(polymesh.points) + mins = p_arr.min(axis=0) + lengths = p_arr.max(axis=0) - mins + tol = 1e-9 * lengths.max() + unmatched = [] + for axis, flag in enumerate(polymesh.periodic_axes): + if not flag: + continue + others = [i for i in range(len(mins)) if i != axis] + for value, opposite in ((mins[axis], mins[axis] + lengths[axis]), + (mins[axis] + lengths[axis], mins[axis])): + on_face = np.nonzero(np.abs(pts[:, axis] - value) <= tol)[0] + on_opp = np.nonzero(np.abs(pts[:, axis] - opposite) <= tol)[0] + if len(on_opp) == 0: + unmatched.extend([(kp, axis, opposite) for kp in on_face]) + continue + tree = cKDTree(pts[on_opp][:, others]) + dists, _ = tree.query(pts[on_face][:, others]) + for kp, dist in zip(on_face, dists): + if dist > tol: + unmatched.append((kp, axis, opposite)) + return unmatched + + +def _mirror_boundary_points_2d(tri_pts, tri_elems, tri_e_atts, polymesh): + """Give every node on a periodic face an image on the opposite face. + + A node without an image is mirrored by splitting the boundary edge of + the opposite face, and the triangle behind it, at the image. This is + only needed for the few points that Triangle keeps adding on the + periodic faces when their refinement does not converge. + + Returns: + tuple: The points, elements and element attributes, and the number + of nodes mirrored. + + """ + pts = [list(p) for p in tri_pts] + elems = [list(e) for e in tri_elems] + atts = list(tri_e_atts) + p_arr = np.array(polymesh.points) + tol = 1e-9 * (p_arr.max(axis=0) - p_arr.min(axis=0)).max() + n_mirrored = 0 + for kp, axis, opposite in _unmatched_periodic_nodes(tri_pts, polymesh): + other = 1 - axis + image = np.array(pts[kp]) + image[axis] = opposite + # a node added by the mirroring of another node may be its image + arr = np.array(pts) + on_opp = np.abs(arr[:, axis] - opposite) <= tol + if np.any(np.abs(arr[on_opp, other] - image[other]) <= tol): + continue + new_kp = len(pts) + split = False + for e_num, elem in enumerate(elems): + e_pts = np.array([pts[k] for k in elem]) + on_line = np.abs(e_pts[:, axis] - opposite) <= tol + if np.sum(on_line) != 2: + continue + k_a, k_b = [elem[k] for k in np.nonzero(on_line)[0]] + v_a, v_b = pts[k_a][other], pts[k_b][other] + if not (min(v_a, v_b) + tol < image[other] < + max(v_a, v_b) - tol): + continue + pts.append(image.tolist()) + # keep the orientation of the split triangle + order = list(elem) + i_a, i_b = order.index(k_a), order.index(k_b) + tri_1 = list(order) + tri_1[i_b] = new_kp + tri_2 = list(order) + tri_2[i_a] = new_kp + elems[e_num] = tri_1 + elems.append(tri_2) + atts.append(atts[e_num]) + split = True + break + if not split: + e_str = 'A node on a periodic face has no image and cannot be ' + e_str += 'mirrored.' + raise RuntimeError(e_str) + n_mirrored += 1 + return (np.array(pts), np.array(elems), np.array(atts, dtype='int'), + n_mirrored) + + +def _ghost_layer(polymesh, phases, labels, kps, pts, facets, facet_nums, + regions, holes, max_volume): + """Add periodic images of the cells outside the periodic faces. + + The cells that touch a periodic face are copied outside that face, + translated by the length of the domain along the axis (and, for the + cells that touch several periodic faces, along each combination of + the axes). The mesher then sees the same geometry on both sides of a + periodic face, and around both faces of a pair, and refines them the + same way. The elements outside the domain are removed after meshing. + + Args: + polymesh (PolyMesh): The periodic polymesh. + phases (list): The phases. + labels (numpy.ndarray): The label of each region of the polymesh. + kps (dict): Maps polymesh point numbers to mesher point numbers. + pts (list): Points of the mesher input. + facets (list): Facets of the mesher input. + facet_nums (list): Polymesh facet number + 1 of each facet. + regions (list): Region points of the mesher input. + holes (list): Hole points of the mesher input. + max_volume (float): The default maximum volume of the elements. + + Returns: + tuple: The points, facets, facet numbers (0 for the facets of the + copies), region points and holes, extended with the copies. + + """ + pts = [list(p) for p in pts] + facets = [list(f) for f in facets] + facet_nums = list(facet_nums) + regions = [list(r) for r in regions] + holes = [list(h) for h in holes] + p_arr = np.array(polymesh.points) + n_dim = p_arr.shape[1] + lengths = p_arr.max(axis=0) - p_arr.min(axis=0) + per_axes = polymesh.periodic_axes + + # the periodic faces touched by each cell, and the translations of its + # copies: +1 moves the cell by the domain length, from the lower face + touched = {} + for f_num, neighs in enumerate(polymesh.facet_neighbors): + wall = min(neighs) + if wall < 0: + axis, side = divmod(-wall - 1, 2) + if per_axes[axis]: + sign = 1 if side == 0 else -1 + touched.setdefault(max(neighs), {})[axis] = sign + copies = {} + for reg, signs in touched.items(): + axes = sorted(signs) + copies[reg] = [] + for n_sel in range(1, len(axes) + 1): + for combo in itertools.combinations(axes, n_sel): + copies[reg].append(tuple([signs[a] if a in combo else 0 + for a in range(n_dim)])) + + # points by location, so that a location reached from a point and from + # its periodic image, with different translations, is one point + scale = lengths.max() + by_location = {} + for i, pt in enumerate(pts): + by_location.setdefault(tuple(np.round(np.array(pt) / scale, 9)), i) + ghost_pts = {} + + def image_id(kp, trans): + # the mesher point of a polymesh point moved by a translation + key = (kp, trans) + if key in ghost_pts: + return ghost_pts[key] + shift = np.array([s * lengths[a] for a, s in enumerate(trans)]) + new_pt = p_arr[kp] + shift + loc = tuple(np.round(new_pt / scale, 9)) + if loc not in by_location: + by_location[loc] = len(pts) + pts.append(new_pt.tolist()) + ghost_pts[key] = by_location[loc] + return ghost_pts[key] + + existing = set([tuple(sorted(f)) for f in facets]) + done = set() + for reg, trans_list in copies.items(): + phase = phases[polymesh.phase_numbers[reg]] + mat_type = phase.get('material_type', 'solid') + reg_kps = set([kp for f in polymesh.regions[reg] + for kp in polymesh.facets[f]]) + center = p_arr[sorted(reg_kps)].mean(axis=0) + for trans in trans_list: + shift = np.array([s * lengths[a] for a, s in enumerate(trans)]) + cen = (center + shift).tolist() + if mat_type in _misc.kw_void: + holes.append(cen) + else: + regions.append(cen + [int(labels[reg]), + phase.get('max_volume', max_volume)]) + for f in polymesh.regions[reg]: + if (f, trans) in done: + continue + done.add((f, trans)) + neighs = polymesh.facet_neighbors[f] + other = neighs[0] if neighs[1] == reg else neighs[1] + # a facet removed between merged cells is removed between + # their copies too, and the copy of a facet on a periodic + # face is the facet on the opposite face (possibly + # subdivided), which the input already has + if (other >= 0 and trans in copies.get(other, []) and + not facet_check(neighs, polymesh, phases)): + continue + if other < 0 and per_axes[(-other - 1) // 2]: + continue + ids = [image_id(kp, trans) for kp in polymesh.facets[f]] + key = tuple(sorted(ids)) + if key in existing: + continue + existing.add(key) + facets.append(ids) + facet_nums.append(0) + return pts, facets, facet_nums, regions, holes + + +def _build_periodic_2d(polymesh, phases, labels, kps, pts, facets, + facet_nums, holes, regions, min_angle, max_volume): + """Build a periodic triangular mesh with Triangle. + + The cells next to the periodic faces are copied outside the faces + (see :func:`_ghost_layer`) and the mesh is built like a non-periodic + one; Triangle then refines both faces of a pair the same way, up to + the order of its operations. If some nodes on the periodic faces have + no image on the opposite face, the points Triangle added on the faces + and their images are put on both faces and the mesh is built again. + The elements outside the domain are removed, and the few nodes that + may remain without an image are mirrored by splitting the elements + behind them. + + Returns: + tuple: The points, elements and element attributes. + + """ + pts, facets, facet_nums, regions, holes = _ghost_layer( + polymesh, phases, labels, kps, pts, facets, facet_nums, regions, + holes, max_volume) + p_arr = np.array(polymesh.points) + mins = p_arr.min(axis=0) + maxs = p_arr.max(axis=0) + tol = 1e-9 * (maxs - mins).max() + for _ in range(_MAX_PERIODIC_PASSES): + tri_mesh = _build_2d(pts, facets, facet_nums, holes, regions, + min_angle, True) + + # the elements inside the domain + tri_pts = np.array(tri_mesh.points) + tri_elems = np.array(tri_mesh.elements) + tri_e_atts = np.array(tri_mesh.element_attributes, dtype='int') + cens = tri_pts[tri_elems].mean(axis=1) + inside = np.all((cens >= mins - tol) & (cens <= maxs + tol), axis=1) + tri_elems = tri_elems[inside] + tri_e_atts = tri_e_atts[inside] + used = np.unique(tri_elems) + renum = np.full(len(tri_pts), -1) + renum[used] = np.arange(len(used)) + tri_pts = tri_pts[used] + tri_elems = renum[tri_elems] + + if not _unmatched_periodic_nodes(tri_pts, polymesh): + break + pts, facets, facet_nums, n_new = _split_periodic_boundary_2d( + np.array(tri_mesh.points), pts, facets, facet_nums, polymesh) + if n_new == 0: + break + + tri_pts, tri_elems, tri_e_atts, _ = _mirror_boundary_points_2d( + tri_pts, tri_elems, tri_e_atts, polymesh) + return tri_pts, tri_elems, tri_e_atts def _attributes_from_polymesh(tri_pts, tri_elems, polymesh, labels): diff --git a/tests/meshing/test_periodic_quality.py b/tests/meshing/test_periodic_quality.py new file mode 100644 index 00000000..cac08d69 --- /dev/null +++ b/tests/meshing/test_periodic_quality.py @@ -0,0 +1,196 @@ +"""Quality and size control of periodic meshes. + +The quality and size settings of the mesh (min_angle, max_volume, the +max_volume of each phase, max_edge_length) must act on periodic meshes as +on non-periodic ones, and the nodes on opposite faces must still match. +""" +import numpy as np +import pytest +import scipy.stats + +import microstructpy as msp +from microstructpy.meshing import PolyMesh +from microstructpy.meshing import TriMesh +from microstructpy.seeding import SeedList + +PHASES_2D = [{'shape': 'circle', 'size': scipy.stats.uniform(0.15, 0.15), + 'material_type': 'crystalline', 'max_volume': 1e-3}, + {'shape': 'ellipse', 'size': scipy.stats.uniform(0.2, 0.15), + 'aspect_ratio': scipy.stats.uniform(1.5, 1.5), + 'angle_deg': scipy.stats.uniform(0, 180), + 'material_type': 'amorphous'}] +PHASES_3D = [{'shape': 'sphere', 'size': scipy.stats.uniform(0.3, 0.2), + 'material_type': 'crystalline', 'max_volume': 5e-4}, + {'shape': 'sphere', 'size': 0.4, 'material_type': 'amorphous'}] + + +@pytest.fixture(scope='module') +def case_2d(): + domain = msp.geometry.Square(side_length=2, corner=(0, 0)) + seeds = SeedList.from_info(PHASES_2D, 0.55 * domain.area) + seeds.position(domain, rtol=0.0, rng_seed=3, periodic=True) + return domain, PolyMesh.from_seeds(seeds, domain, periodic=True) + + +@pytest.fixture(scope='module') +def case_3d(): + domain = msp.geometry.Cube(side_length=1.5, corner=(0, 0, 0)) + seeds = SeedList.from_info(PHASES_3D, 0.5 * domain.volume) + seeds.position(domain, rtol=0.0, rng_seed=1, periodic=True) + return domain, PolyMesh.from_seeds(seeds, domain, periodic=True) + + +def _min_angles_2d(pts, elems): + p = pts[elems] + angs = [] + for k in range(3): + a = p[:, k] - p[:, (k + 1) % 3] + b = p[:, k] - p[:, (k + 2) % 3] + cos = np.einsum('ij,ij->i', a, b) + cos /= np.linalg.norm(a, axis=1) * np.linalg.norm(b, axis=1) + angs.append(np.degrees(np.arccos(np.clip(cos, -1, 1)))) + return np.min(angs, axis=0) + + +def _min_dihedrals(pts, tets): + p = pts[tets] + faces = [(1, 2, 3), (0, 3, 2), (0, 1, 3), (0, 2, 1)] + normals = [] + for f in faces: + n = np.cross(p[:, f[1]] - p[:, f[0]], p[:, f[2]] - p[:, f[0]]) + normals.append(n / np.linalg.norm(n, axis=1)[:, None]) + dih = [] + for i in range(4): + for j in range(i + 1, 4): + cos = np.einsum('ij,ij->i', normals[i], normals[j]) + dih.append(180 - np.degrees(np.arccos(np.clip(cos, -1, 1)))) + return np.min(dih, axis=0) + + +def _corner_angles_2d(pmesh): + """Interior angles of the cells of a 2D polymesh.""" + pts = np.array(pmesh.points) + angs = [] + for region in pmesh.regions: + kps = sorted(set([kp for f in region for kp in pmesh.facets[f]])) + cen = pts[kps].mean(axis=0) + order = np.argsort(np.arctan2(pts[kps][:, 1] - cen[1], + pts[kps][:, 0] - cen[0])) + loop = pts[np.array(kps)[order]] + for i in range(len(loop)): + a = loop[i - 1] - loop[i] + b = loop[(i + 1) % len(loop)] - loop[i] + cos = np.dot(a, b) / np.linalg.norm(a) / np.linalg.norm(b) + angs.append(np.degrees(np.arccos(np.clip(cos, -1, 1)))) + return np.array(angs) + + +def _check_pairs(mesh, domain): + pts = np.array(mesh.points) + lims = np.array(domain.limits) + for axis, (lb, ub) in enumerate(lims): + pairs = mesh.periodic_nodes[axis] + n_low = np.sum(np.isclose(pts[:, axis], lb)) + n_high = np.sum(np.isclose(pts[:, axis], ub)) + assert len(pairs) == n_low == n_high > 0 + shift = np.zeros(len(lims)) + shift[axis] = ub - lb + for lo, hi in pairs: + assert np.array_equal(pts[hi], pts[lo] + shift) + + +def _attribute_phases(mesh, pmesh): + phase_of_seed = {} + for seed_num, phase_num in zip(pmesh.seed_numbers, pmesh.phase_numbers): + phase_of_seed[seed_num] = phase_num + return np.array([phase_of_seed[a] for a in mesh.element_attributes]) + + +# --------------------------------------------------------------------------- # +# 2D # +# --------------------------------------------------------------------------- # +def test_2d_min_angle(case_2d): + domain, pmesh = case_2d + mesh = TriMesh.from_polymesh(pmesh, PHASES_2D, min_angle=25) + pts, elems = np.array(mesh.points), np.array(mesh.elements) + angs = _min_angles_2d(pts, elems) + # Triangle does not improve the angles of the cells themselves + n_small_corners = np.sum(_corner_angles_2d(pmesh) < 25) + assert np.sum(angs < 25 - 1e-6) <= n_small_corners + assert angs.min() >= 10 + _check_pairs(mesh, domain) + + +def test_2d_max_volume(case_2d): + domain, pmesh = case_2d + mesh = TriMesh.from_polymesh(pmesh, PHASES_2D, min_angle=25, + max_volume=4e-3) + pts, elems = np.array(mesh.points), np.array(mesh.elements) + areas = np.linalg.det(pts[elems[:, 1:]] - pts[elems[:, :1]]) / 2.0 + assert np.all(areas > 0) + assert areas.max() <= 4e-3 * (1 + 1e-9) + # the maximum volume of the first phase + phases = _attribute_phases(mesh, pmesh) + assert areas[phases == 0].max() <= 1e-3 * (1 + 1e-9) + assert areas[phases == 1].max() > 1e-3 + assert np.isclose(areas.sum(), domain.area) + _check_pairs(mesh, domain) + + +def test_2d_max_edge_length(case_2d): + domain, pmesh = case_2d + mesh = TriMesh.from_polymesh(pmesh, PHASES_2D, min_angle=25, + max_edge_length=0.06) + pts = np.array(mesh.points) + facets = np.array(mesh.facets) + lengths = np.linalg.norm(pts[facets[:, 0]] - pts[facets[:, 1]], axis=1) + assert lengths.max() <= 0.06 * (1 + 1e-9) + _check_pairs(mesh, domain) + + +# --------------------------------------------------------------------------- # +# 3D # +# --------------------------------------------------------------------------- # +def test_3d_min_dihedral(case_3d): + domain, pmesh = case_3d + mesh = TriMesh.from_polymesh(pmesh, PHASES_3D, min_angle=15) + pts, elems = np.array(mesh.points), np.array(mesh.elements) + dih = _min_dihedrals(pts, elems) + assert np.percentile(dih, 5) >= 15 + assert np.mean(dih < 10) <= 0.02 + _check_pairs(mesh, domain) + + +def test_3d_max_volume(case_3d): + domain, pmesh = case_3d + mesh = TriMesh.from_polymesh(pmesh, PHASES_3D, min_angle=15, + max_volume=2e-3) + pts, elems = np.array(mesh.points), np.array(mesh.elements) + vols = np.linalg.det(pts[elems[:, 1:]] - pts[elems[:, :1]]) / 6.0 + assert np.all(vols > 0) + assert vols.max() <= 2e-3 * (1 + 1e-9) + phases = _attribute_phases(mesh, pmesh) + assert vols[phases == 0].max() <= 5e-4 * (1 + 1e-9) + assert vols[phases == 1].max() > 5e-4 + assert np.isclose(vols.sum(), domain.volume) + _check_pairs(mesh, domain) + + +def test_3d_max_edge_length(case_3d): + domain, pmesh = case_3d + mesh = TriMesh.from_polymesh(pmesh, PHASES_3D, min_angle=15, + max_edge_length=0.1) + pts = np.array(mesh.points) + lims = np.array(domain.limits) + facets = np.array(mesh.facets) + on_wall = np.zeros(len(facets), dtype=bool) + for axis in range(3): + for value in lims[axis]: + on_wall |= np.all(np.isclose(pts[facets][:, :, axis], value), + axis=1) + tris = facets[on_wall] + edges = np.concatenate([np.linalg.norm(pts[tris[:, i]] - + pts[tris[:, (i + 1) % 3]], axis=1) + for i in range(3)]) + assert edges.max() <= 0.1 * 1.1 + _check_pairs(mesh, domain) From 4070fef67f34b6c38f93805a02a6edb9bbea6b9f Mon Sep 17 00:00:00 2001 From: Julian Rimoli Date: Wed, 23 Sep 2026 07:03:08 -0700 Subject: [PATCH 16/42] Add examples of periodic microstructures Two CLI examples, periodic_2D.xml (a square periodic in both directions, with circular and elliptical grains, edge optimization and mesh quality settings) and periodic_3D.xml (a cube of two phases of spherical grains, periodic in the three directions), and a package example, periodic_tiling.py, which builds a 2D and a 3D periodic microstructure with the API and plots them tiled, so that the grains continue across the periodic faces and the paired nodes of the triangular mesh are visible. Each has a page in the examples of the documentation. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.rst | 4 +- docs/source/examples/cli/periodic_2d.rst | 86 +++++++++ docs/source/examples/cli/periodic_3d.rst | 80 ++++++++ docs/source/examples/index.rst | 18 ++ .../examples/package/periodic_tiling.rst | 68 +++++++ src/microstructpy/examples/periodic_2D.xml | 61 ++++++ src/microstructpy/examples/periodic_3D.xml | 52 ++++++ src/microstructpy/examples/periodic_tiling.py | 174 ++++++++++++++++++ 8 files changed, 542 insertions(+), 1 deletion(-) create mode 100644 docs/source/examples/cli/periodic_2d.rst create mode 100644 docs/source/examples/cli/periodic_3d.rst create mode 100644 docs/source/examples/package/periodic_tiling.rst create mode 100644 src/microstructpy/examples/periodic_2D.xml create mode 100644 src/microstructpy/examples/periodic_3D.xml create mode 100644 src/microstructpy/examples/periodic_tiling.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 91e76e17..b11d5019 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -27,7 +27,9 @@ Added points/nodes and facets are stored in the meshes and their text files; the Abaqus output has a node set per periodic face in matching order; the verification unwraps - grains that are split by the faces. Cells of the same amorphous phase + grains that are split by the faces. The examples ``periodic_2D.xml``, + ``periodic_3D.xml`` and ``periodic_tiling.py`` demonstrate periodic + microstructures. Cells of the same amorphous phase that touch across a periodic face are merged into one region, like cells that share a facet, and the merged region is labelled with the smallest seed number among its cells by every mesher and writer. The element diff --git a/docs/source/examples/cli/periodic_2d.rst b/docs/source/examples/cli/periodic_2d.rst new file mode 100644 index 00000000..322cccbb --- /dev/null +++ b/docs/source/examples/cli/periodic_2d.rst @@ -0,0 +1,86 @@ +.. _ex_periodic_2d: + +=================== +Periodic 2D Example +=================== + +XML Input File +============== + +The basename for this file is ``periodic_2D.xml``. +The file can be run using this command:: + + microstructpy --demo=periodic_2D.xml + +The full text of the file is: + +.. literalinclude:: ../../../../src/microstructpy/examples/periodic_2D.xml + :language: xml + + +Materials +========= + +The first material makes up two thirds of the area, with circular grains +whose diameters are uniformly distributed between 0.2 and 0.4. + +The second material makes up the remaining third, with elliptical grains +of aspect ratio 2 and random orientations. + +Domain Geometry +=============== + +The materials fill a square domain of side length 2, which is periodic in +both directions: the ```` field of the domain is ``True``. +The periodicity can also be restricted to some of the axes, for example +`` x ``. + +Grains that cross a periodic face of the domain continue on the opposite +face, and the meshes have matching nodes on opposite faces. +The seeds are positioned without overlapping the grains on the other side, +the polygonal mesh is periodic across the faces, and the triangular mesh has +the same nodes on opposite faces, so that periodic boundary conditions can +be applied to it directly. +The pairs of periodic nodes are stored in the mesh (in its text file and, +for Abaqus, in node sets named ``Set-N-Periodic-X-Low`` and +``Set-N-Periodic-X-High``). + +Settings +======== + +The random number generator seeds make the microstructure repeatable. + +The mesh has a minimum angle of 25 degrees and a maximum element area of +0.004. The quality and size settings act on a periodic mesh as on a +non-periodic one. + +The plots are colored by seed number, so that the pieces of a grain on +opposite faces of the domain have the same color. + + +Output Files +============ + +The three plots that this file generates are the seeding, the polygon mesh, +and the triangular mesh. +These three plots are shown in :numref:`f_ex_per2d_seeds` - +:numref:`f_ex_per2d_tri`. +The grains cut by the faces of the domain continue on the opposite faces. + +.. _f_ex_per2d_seeds: +.. figure:: ../../../../src/microstructpy/examples/periodic_2D/seeds.png + :alt: Seed geometries. + + Periodic 2D example - seed geometries. + +.. _f_ex_per2d_poly: +.. figure:: ../../../../src/microstructpy/examples/periodic_2D/polymesh.png + :alt: Polygonal mesh. + + Periodic 2D example - polygonal mesh. + +.. _f_ex_per2d_tri: +.. figure:: ../../../../src/microstructpy/examples/periodic_2D/trimesh.png + :alt: Triangular mesh. + + Periodic 2D example - triangular mesh. diff --git a/docs/source/examples/cli/periodic_3d.rst b/docs/source/examples/cli/periodic_3d.rst new file mode 100644 index 00000000..c12db635 --- /dev/null +++ b/docs/source/examples/cli/periodic_3d.rst @@ -0,0 +1,80 @@ +.. _ex_periodic_3d: + +=================== +Periodic 3D Example +=================== + +XML Input File +============== + +The basename for this file is ``periodic_3D.xml``. +The file can be run using this command:: + + microstructpy --demo=periodic_3D.xml + +The full text of the file is: + +.. literalinclude:: ../../../../src/microstructpy/examples/periodic_3D.xml + :language: xml + + +Materials +========= + +The two materials make up half of the volume each. +The first has spherical grains whose diameters are uniformly distributed +between 0.8 and 1.4, the second has spherical grains of diameter 1.2. + +Domain Geometry +=============== + +The materials fill a cube of side length 4, which is periodic in the three +directions: the ```` field of the domain is ``xyz``. +Any subset of the axes can be given, for example ``xz`` to leave the y +direction free. + +The polyhedral mesh is periodic across the faces (a grain cut by a face +continues on the opposite face) and the tetrahedral mesh has the same nodes +on opposite faces, stored as pairs in the mesh. + +Settings +======== + +The random number generator seeds make the microstructure repeatable. + +The mesh has a minimum dihedral angle of 15 degrees and a maximum element +volume of 0.02. +In 3D, the domain is meshed once as usual, then the facets are +triangulated with the points TetGen added on them, identically on opposite +faces, and the mesh is built again with these facets; the quality and size +settings act as on a non-periodic mesh. + +The plots are colored by seed number and the line widths are reduced to +make the grains visible. + + +Output Files +============ + +The three plots that this file generates are the seeding, the polyhedral +mesh, and the tetrahedral mesh. +These three plots are shown in :numref:`f_ex_per3d_seeds` - +:numref:`f_ex_per3d_tri`. + +.. _f_ex_per3d_seeds: +.. figure:: ../../../../src/microstructpy/examples/periodic_3D/seeds.png + :alt: Seed geometries. + + Periodic 3D example - seed geometries. + +.. _f_ex_per3d_poly: +.. figure:: ../../../../src/microstructpy/examples/periodic_3D/polymesh.png + :alt: Polyhedral mesh. + + Periodic 3D example - polyhedral mesh. + +.. _f_ex_per3d_tri: +.. figure:: ../../../../src/microstructpy/examples/periodic_3D/trimesh.png + :alt: Tetrahedral mesh. + + Periodic 3D example - tetrahedral mesh. diff --git a/docs/source/examples/index.rst b/docs/source/examples/index.rst index 537d708d..41b57bea 100644 --- a/docs/source/examples/index.rst +++ b/docs/source/examples/index.rst @@ -75,6 +75,8 @@ CLI Examples cli/basalt cli/two_phase_3d cli/colormap + cli/periodic_2d + cli/periodic_3d .. only:: html @@ -103,6 +105,16 @@ CLI Examples :width: 32% :target: cli/colormap.html + .. image:: ../../../src/microstructpy/examples/periodic_2D/trimesh.png + :alt: Triangular mesh from periodic 2D example. + :width: 32% + :target: cli/periodic_2d.html + + .. image:: ../../../src/microstructpy/examples/periodic_3D/polymesh.png + :alt: Polyhedral mesh from periodic 3D example. + :width: 32% + :target: cli/periodic_3d.html + .. _package_examples: @@ -120,6 +132,7 @@ Python Package Examples package/grain_neighborhoods package/from_image package/mesh_process + package/periodic_tiling .. only:: html @@ -156,3 +169,8 @@ Python Package Examples .. image:: ../../../src/microstructpy/examples/docs_banner/banner.png :alt: Microstructure meshing process.. :target: package/mesh_process.html + + .. image:: ../../../src/microstructpy/examples/periodic_tiling/tiled_2D.png + :alt: Periodic microstructure, tiled 2 x 2. + :height: 210px + :target: package/periodic_tiling.html diff --git a/docs/source/examples/package/periodic_tiling.rst b/docs/source/examples/package/periodic_tiling.rst new file mode 100644 index 00000000..ef83b009 --- /dev/null +++ b/docs/source/examples/package/periodic_tiling.rst @@ -0,0 +1,68 @@ +.. _ex_periodic_tiling: + +======================================= +Periodic Microstructures and Their Tiles +======================================= + +Python Script +============= + +The basename for this file is ``periodic_tiling.py``. +The file can be run using this command:: + + microstructpy --demo=periodic_tiling.py + +The full text of the script is: + +.. literalinclude:: ../../../../src/microstructpy/examples/periodic_tiling.py + :language: python + +Periodic Microstructure in 2D +============================= + +The domain is a :class:`.Square` of side length 2 and the two phases are +circular grains and elliptical inclusions. +The seeds are created with :func:`~microstructpy.seeding.SeedList.from_info` +and positioned with :func:`~microstructpy.seeding.SeedList.position`, with +``periodic=True``: a seed that crosses a face of the domain is also checked +for overlaps on the opposite face. +The polygonal mesh is created with +:func:`~microstructpy.meshing.PolyMesh.from_seeds`, again with +``periodic=True``, and the triangular mesh with +:func:`~microstructpy.meshing.TriMesh.from_polymesh`, which reads the +periodicity from the polygonal mesh. +The periodicity can be restricted to some axes, for example +``periodic='x'``. + +The polygonal mesh and the triangular mesh are then drawn four times, in a +2 x 2 tiling of the domain. +The grains cut by the faces of the domain are colored by seed number and +continue across the faces, and the nodes of the triangular mesh on the face +``x = 0`` (red) have their images on the face ``x = 2`` (blue), listed in +``periodic_nodes[0]`` of the mesh. +The tiling is shown in :numref:`f_ex_tiling_2d`. + +.. _f_ex_tiling_2d: +.. figure:: ../../../../src/microstructpy/examples/periodic_tiling/tiled_2D.png + :alt: Periodic 2D microstructure, tiled 2 x 2. + + Periodic polygonal and triangular meshes, tiled 2 x 2. + +Periodic Microstructure in 3D +============================= + +The domain is a :class:`.Cube` of side length 4, filled with two phases of +spherical grains, periodic in the three directions. +The script prints the number of pairs of nodes on opposite faces of the +tetrahedral mesh, which are exact images of each other along each axis. + +The faces of the polyhedral mesh that are visible from the viewpoint are +drawn for the domain and for a copy of the domain translated along ``x``, +in :numref:`f_ex_tiling_3d`: the grains continue across the periodic +face. + +.. _f_ex_tiling_3d: +.. figure:: ../../../../src/microstructpy/examples/periodic_tiling/tiled_3D.png + :alt: Periodic 3D microstructure, tiled twice along x. + + Periodic polyhedral mesh, tiled twice along x. diff --git a/src/microstructpy/examples/periodic_2D.xml b/src/microstructpy/examples/periodic_2D.xml new file mode 100644 index 00000000..87229122 --- /dev/null +++ b/src/microstructpy/examples/periodic_2D.xml @@ -0,0 +1,61 @@ + + + + Grains + 2 + circle + + uniform + 0.2 + 0.2 + + + + + Inclusions + 1 + ellipse + + uniform + 0.25 + 0.15 + + 2 + + uniform + 0 + 180 + + + + + square + 2 + (0, 0) + + True + + + + periodic_2D + True + + 1 + 1 + + + + True + 25 + + 25 + 0.004 + + seed number + tab20 + + 0.2 + + + diff --git a/src/microstructpy/examples/periodic_3D.xml b/src/microstructpy/examples/periodic_3D.xml new file mode 100644 index 00000000..13da823f --- /dev/null +++ b/src/microstructpy/examples/periodic_3D.xml @@ -0,0 +1,52 @@ + + + + Phase 1 + 1 + sphere + + uniform + 0.8 + 0.6 + + + + + Phase 2 + 1 + sphere + 1.2 + + + + cube + 4 + (0, 0, 0) + + xyz + + + + periodic_3D + True + + 2 + 2 + + + 15 + 0.02 + + seed number + tab20 + + 0.2 + + + 0.2 + + + 0.1 + + + diff --git a/src/microstructpy/examples/periodic_tiling.py b/src/microstructpy/examples/periodic_tiling.py new file mode 100644 index 00000000..93e6e559 --- /dev/null +++ b/src/microstructpy/examples/periodic_tiling.py @@ -0,0 +1,174 @@ +from __future__ import division + +import os + +import numpy as np +import scipy.stats +from matplotlib import collections +from matplotlib import pyplot as plt +from mpl_toolkits.mplot3d.art3d import Poly3DCollection + +import microstructpy as msp + +# ------------------------------------------------------------------------ # +# # +# 2D: a periodic microstructure and its 2 x 2 tiling # +# # +# ------------------------------------------------------------------------ # + +# Create domain +domain_2d = msp.geometry.Square(side_length=2, corner=(0, 0)) + +# Create phases: a matrix of circular grains and elliptical inclusions +phases_2d = [ + {'shape': 'circle', 'size': scipy.stats.uniform(loc=0.2, scale=0.2), + 'material_type': 'crystalline', 'fraction': 2}, + {'shape': 'ellipse', 'size': scipy.stats.uniform(loc=0.25, scale=0.15), + 'aspect_ratio': 2, 'angle_deg': scipy.stats.uniform(loc=0, scale=180), + 'material_type': 'crystalline', 'fraction': 1}, +] + +# Create seeds and position them, periodic in x and y (the seeds fill 90% +# of the area, so that all of them can be placed) +seeds_2d = msp.seeding.SeedList.from_info(phases_2d, 0.9 * domain_2d.area, + rng_seeds={'size': 1}) +seeds_2d.position(domain_2d, rng_seed=1, periodic=True) + +# Create the polygonal and triangular meshes. The edge optimization moves +# the seeds slightly to remove the shortest edges of the polygonal mesh, +# which would otherwise force very small triangles in the mesh. +pmesh_2d = msp.meshing.PolyMesh.from_seeds(seeds_2d, domain_2d, + periodic=True, edge_opt=True, + n_iter=25) +tmesh_2d = msp.meshing.TriMesh.from_polymesh(pmesh_2d, phases_2d, + min_angle=25, max_volume=0.004) + +# Plot the tiled polygonal mesh, with each grain in one color, and the +# tiled triangular mesh, with the matching nodes on the periodic faces +pts = np.array(pmesh_2d.points) +cmap = plt.get_cmap('tab20') +colors = [cmap(s % 20) for s in pmesh_2d.seed_numbers] +tile_x, tile_y = domain_2d.side_length, domain_2d.side_length +offsets = [(i * tile_x, j * tile_y) for i in (0, 1) for j in (0, 1)] + + +def cell_polygon(region): + # the points of a (convex) cell, in order around its center + kps = sorted(set([kp for f in region for kp in pmesh_2d.facets[f]])) + center = pts[kps].mean(axis=0) + angles = np.arctan2(pts[kps, 1] - center[1], pts[kps, 0] - center[0]) + return pts[np.array(kps)[np.argsort(angles)]] + + +# the cells, and the grain boundaries (an elliptical grain is made of +# several cells, whose common facets are not grain boundaries) +polys = [cell_polygon(region) for region in pmesh_2d.regions] +seed_nums = np.array(pmesh_2d.seed_numbers) +boundaries = [] +for facet, (n_1, n_2) in zip(pmesh_2d.facets, pmesh_2d.facet_neighbors): + if min(n_1, n_2) < 0 or seed_nums[n_1] != seed_nums[n_2]: + boundaries.append(pts[facet]) +fig, axes = plt.subplots(1, 2, figsize=(12, 6)) +for offset in offsets: + axes[0].add_collection(collections.PolyCollection( + [poly + offset for poly in polys], facecolors=colors, + edgecolors=colors, linewidths=0.5)) + axes[0].add_collection(collections.LineCollection( + [line + offset for line in boundaries], colors='k', linewidths=0.6)) +axes[0].set_title('Polygonal mesh, tiled 2 x 2') + +t_pts = np.array(tmesh_2d.points) +t_elems = np.array(tmesh_2d.elements) +for offset in offsets: + axes[1].triplot(t_pts[:, 0] + offset[0], t_pts[:, 1] + offset[1], + t_elems, color='0.3', linewidth=0.25) +lows = [lo for lo, hi in tmesh_2d.periodic_nodes[0]] +highs = [hi for lo, hi in tmesh_2d.periodic_nodes[0]] +axes[1].plot(t_pts[lows, 0], t_pts[lows, 1], 'r.', markersize=4, + label='nodes on x = 0') +axes[1].plot(t_pts[highs, 0], t_pts[highs, 1], 'b.', markersize=4, + label='their images on x = 2') +axes[1].legend(loc='upper right') +axes[1].set_title('Triangular mesh, tiled 2 x 2') + +for ax in axes: + ax.axvline(tile_x, color='w', linewidth=1.2) + ax.axhline(tile_y, color='w', linewidth=1.2) + ax.set_aspect('equal') + ax.set_xlim(0, 2 * tile_x) + ax.set_ylim(0, 2 * tile_y) + +file_dir = os.path.dirname(os.path.realpath(__file__)) +out_dir = os.path.join(file_dir, 'periodic_tiling') +if not os.path.exists(out_dir): + os.makedirs(out_dir) +plt.savefig(os.path.join(out_dir, 'tiled_2D.png'), bbox_inches='tight', + pad_inches=0.1) +plt.close(fig) + +# ------------------------------------------------------------------------ # +# # +# 3D: a periodic microstructure and its 2 x 1 x 1 tiling # +# # +# ------------------------------------------------------------------------ # + +# Create domain +side = 4 +domain_3d = msp.geometry.Cube(side_length=side, corner=(0, 0, 0)) + +# Create phases +phases_3d = [ + {'shape': 'sphere', 'size': scipy.stats.uniform(loc=0.8, scale=0.6), + 'fraction': 1}, + {'shape': 'sphere', 'size': 1.2, 'fraction': 1}, +] + +# Create seeds, position them and mesh the domain, periodic in x, y and z +# (the seeds fill 90% of the volume, so that all of them can be placed) +seeds_3d = msp.seeding.SeedList.from_info(phases_3d, 0.9 * domain_3d.volume, + rng_seeds={'size': 2}) +seeds_3d.position(domain_3d, rng_seed=2, periodic=True) +pmesh_3d = msp.meshing.PolyMesh.from_seeds(seeds_3d, domain_3d, + periodic=True) +tmesh_3d = msp.meshing.TriMesh.from_polymesh(pmesh_3d, phases_3d, + min_angle=15, max_volume=0.02) + +# The nodes on opposite faces of the domain are images of each other +for axis, pairs in sorted(tmesh_3d.periodic_nodes.items()): + print('axis ' + 'xyz'[axis] + ': ' + str(len(pairs)) + ' pairs of nodes') + +# Plot the faces of the polyhedral mesh that are visible from the viewpoint +# (y = 0, x = side and z = side), for the domain and for a copy translated +# along x: the grains continue across the periodic face +pts = np.array(pmesh_3d.points) +colors = [cmap(s % 20) for s in pmesh_3d.seed_numbers] +visible = {-3: 0.85, -2: 0.7, -6: 1.0} # wall number: shading + + +def wall_polygons(offset, walls): + polys, facecolors = [], [] + for facet, neighs in zip(pmesh_3d.facets, pmesh_3d.facet_neighbors): + wall = min(neighs) + if wall in walls: + region = max(neighs) + polys.append(pts[facet] + offset) + facecolors.append(np.array(colors[region]) * walls[wall]) + return polys, facecolors + + +fig = plt.figure(figsize=(12, 6)) +ax = fig.add_subplot(projection='3d') +ax.set_position([0, 0, 1, 1]) +polys, facecolors = wall_polygons(np.zeros(3), {-3: 0.85, -6: 1.0}) +polys_2, facecolors_2 = wall_polygons(np.array([side, 0, 0]), visible) +ax.add_collection3d(Poly3DCollection(polys + polys_2, + facecolors=facecolors + facecolors_2, + edgecolors='k', linewidths=0.3)) +ax.set_xlim(0, 2 * side) +ax.set_ylim(0, side) +ax.set_zlim(0, side) +ax.set_box_aspect((2, 1, 1)) +ax.view_init(elev=22, azim=-55) +ax.set_title('Polyhedral mesh, tiled twice along x') +plt.savefig(os.path.join(out_dir, 'tiled_3D.png'), bbox_inches='tight', + pad_inches=0.1) From f33514130547ce60e4d7a438bd64b1a8f748fb02 Mon Sep 17 00:00:00 2001 From: Julian Rimoli Date: Tue, 22 Sep 2026 20:03:11 -0700 Subject: [PATCH 17/42] Use the published overlap-tolerance fit (CMAME 370, Eqs. 14-15) The coefficients of the rtol='fit' rational polynomial dated from the original submission of the paper; these are the published ones. For very wide size distributions they allow less overlap (2D asymptote 0.18 instead of 0.36), so some seeds of high-cv inputs may be rejected during placement (basalt example: 41 of 1300). Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.rst | 5 +++++ src/microstructpy/seeding/seedlist.py | 16 ++++++++-------- tests/seeding/test_seeding_fixes.py | 21 ++++++++++++++------- 3 files changed, 27 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index b11d5019..d8488699 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -95,6 +95,11 @@ Fixed Changed ''''''' +- The overlap tolerance fit ``rtol='fit'`` uses the coefficients published + in Hart and Rimoli, CMAME 370 (2020) 113242, Eqs. (14) and (15). For very + wide size distributions this allows less overlap than before (2D + asymptote 0.18 instead of 0.36), so some seeds of high-cv inputs may be + rejected during placement. - ``Ellipsoid.limits`` is exact for rotated ellipsoids (it was sampled). - A ``Seed`` created with a ``position`` (or a geometry with a center) has its breakdown at that position; the geometry center is no longer reset diff --git a/src/microstructpy/seeding/seedlist.py b/src/microstructpy/seeding/seedlist.py index ef9fb719..688309f8 100644 --- a/src/microstructpy/seeding/seedlist.py +++ b/src/microstructpy/seeding/seedlist.py @@ -1199,10 +1199,10 @@ def _add_legend(ax, material, seeds, seed_args, kwargs, index_by, loc): def calc_rtol(seeds): """Calculate relative overlap tolerance. - The tolerance is a rational polynomial fit to the coefficient of - variation in seed area/volume, which minimizes the error between the - input and output size distributions (Hart and Rimoli, *Comput. Methods - Appl. Mech. Engrg.* 370 (2020) 113242). + The tolerance is the error-minimizing rational polynomial fit to the + coefficient of variation in seed area/volume, Eqs. (14) and (15) of + Hart and Rimoli, *Comput. Methods Appl. Mech. Engrg.* 370 (2020) + 113242. Args: seeds (SeedList or list): The seeds, used for their volumes and @@ -1215,11 +1215,11 @@ def calc_rtol(seeds): cv = scipy.stats.variation(vols) if len(vols) > 1 else 0.0 n_dim = seeds[0].geometry.n_dim if n_dim == 2: - numer = 0.362954 * cv * cv - 0.419069 * cv + .184959 - denom = cv * cv - 1.05989 * cv + 0.365096 + numer = 0.182 * cv * cv - 0.0135 * cv + 0.198 + denom = cv * cv - 0.613 * cv + 0.390 elif n_dim == 3: - numer = 0.471115 * cv * cv - 0.602324 * cv + 0.297562 - denom = cv * cv - 1.08469 * cv + 0.428216 + numer = 0.457 * cv * cv - 0.575 * cv + 0.253 + denom = cv * cv - 1.07 * cv + 0.419 else: raise ValueError('Cannot calculate rtol for {}-D.'.format(n_dim)) return numer / denom diff --git a/tests/seeding/test_seeding_fixes.py b/tests/seeding/test_seeding_fixes.py index 1aaaf181..f99d872b 100644 --- a/tests/seeding/test_seeding_fixes.py +++ b/tests/seeding/test_seeding_fixes.py @@ -59,24 +59,31 @@ def test_from_info_does_not_mutate_rng_seeds(): # --------------------------------------------------------------------------- # # Overlap tolerance # # --------------------------------------------------------------------------- # -def test_calc_rtol_values(): +def test_calc_rtol_matches_paper(): + # Eq. (14): sigma = 0.5 -> cv = 0.53 -> alpha = 0.70 (paper, Sec. 3.1) + cv = np.sqrt(np.exp(0.25) - 1) + numer = 0.182 * cv * cv - 0.0135 * cv + 0.198 + denom = cv * cv - 0.613 * cv + 0.390 + assert np.isclose(numer / denom, 0.70, atol=0.005) + + # build seeds with exactly that coefficient of variation rng = np.random.RandomState(0) areas = np.exp(-9 + 0.5 * rng.normal(size=4000)) seeds = [Seed.factory('circle', area=a) for a in areas] cv_s = scipy.stats.variation(areas) - expected = ((0.362954 * cv_s ** 2 - 0.419069 * cv_s + 0.184959) / - (cv_s ** 2 - 1.05989 * cv_s + 0.365096)) + expected = ((0.182 * cv_s ** 2 - 0.0135 * cv_s + 0.198) / + (cv_s ** 2 - 0.613 * cv_s + 0.390)) assert np.isclose(calc_rtol(seeds), expected) seeds_3d = [Seed.factory('sphere', volume=a) for a in areas] - expected_3d = ((0.471115 * cv_s ** 2 - 0.602324 * cv_s + 0.297562) / - (cv_s ** 2 - 1.08469 * cv_s + 0.428216)) + expected_3d = ((0.457 * cv_s ** 2 - 0.575 * cv_s + 0.253) / + (cv_s ** 2 - 1.07 * cv_s + 0.419)) assert np.isclose(calc_rtol(seeds_3d), expected_3d) # constant sizes: cv = 0 (a single seed as well) same = [Seed.factory('circle', r=1) for _ in range(3)] - assert np.isclose(calc_rtol(same), 0.184959 / 0.365096) - assert np.isclose(calc_rtol(same[:1]), 0.184959 / 0.365096) + assert np.isclose(calc_rtol(same), 0.198 / 0.390) + assert np.isclose(calc_rtol(same[:1]), 0.198 / 0.390) def test_position_uses_calc_rtol(monkeypatch): From 6b32d1521365cf10934d83ccc133a59e9e27ca54 Mon Sep 17 00:00:00 2001 From: Julian Rimoli Date: Wed, 23 Sep 2026 08:49:57 -0700 Subject: [PATCH 18/42] Size the triangles of all facets with max_edge_length in 3D max_edge_length now acts in 3D on the grain boundaries: when it is set, the facets of the polyhedral mesh are triangulated to that edge length (with Triangle, minimum angle 20 degrees) before TetGen meshes the cells, for periodic and non-periodic meshes alike, so that the elements can be smaller at the interfaces than inside the grains. TetGen has no such control of its own. Meshing a periodic polymesh with many small cells showed that the geometric tests of a mesh against its polymesh were too strict: the snapping of the points to the periodic faces makes some facets non-planar by up to a few 1e-7 of the domain size, more than the tolerances of 1e-9 and 1e-8 used to find the cell of an element and the facet of a face, so that a conforming mesh was rejected as non-conforming. The tolerance is now four times the largest non-planarity of the facets, each element is assigned to the cell in which its centroid is deepest, and the extra points of a facet are projected on its plane. Co-Authored-By: Claude Fable 5.1 --- docs/source/cli/settings.rst | 11 ++-- src/microstructpy/cli.py | 4 +- src/microstructpy/meshing/trimesh.py | 91 ++++++++++++++++++++-------- 3 files changed, 73 insertions(+), 33 deletions(-) diff --git a/docs/source/cli/settings.rst b/docs/source/cli/settings.rst index 894879aa..6b42be93 100644 --- a/docs/source/cli/settings.rst +++ b/docs/source/cli/settings.rst @@ -344,12 +344,13 @@ turns off the angle quality control. mesh_max_edge_length -------------------- -This field defines the maximum edge length along a grain boundary in a 2D -triangular mesh. +This field defines the maximum edge length along a grain boundary: of the +segments of a 2D triangular mesh, and of the triangles on the facets of a 3D +tetrahedral mesh. A small maximum edge length will increase resolution of the mesh at grain -boundaries. -In 3D, it applies only to the triangles on the boundary of a periodic domain -(the interior of a 3D mesh is controlled by the maximum volume). +boundaries, while the size of the elements inside the grains is controlled +by the maximum volume (see the :ref:`ex_pbx_interface_2d` and +:ref:`ex_pbx_interface_3d` examples). The default value is `` inf ``, which effectively turns off the edge length quality control. diff --git a/src/microstructpy/cli.py b/src/microstructpy/cli.py index 4215ca07..dc5cc640 100644 --- a/src/microstructpy/cli.py +++ b/src/microstructpy/cli.py @@ -359,8 +359,8 @@ def run(phases, domain, verbose=False, restart=True, directory='.', Defaults to 0, which turns off the angle quality constraint. Value should be in the range 0-60. mesh_max_edge_length (float): *(optional)* The maximum edge length of - elements along grain boundaries. Supported in 2D and, for the - triangles on the boundary of a periodic domain, in 3D. + elements along grain boundaries: of the segments in 2D and of + the triangles on the facets in 3D. mesh_size (float): The target size of the mesh elements. This option is used with gmsh. Default is infinity, whihch turns off this control. diff --git a/src/microstructpy/meshing/trimesh.py b/src/microstructpy/meshing/trimesh.py index f7d24b20..b9142d6f 100644 --- a/src/microstructpy/meshing/trimesh.py +++ b/src/microstructpy/meshing/trimesh.py @@ -228,10 +228,10 @@ def from_polymesh(cls, polymesh, phases=None, mesher='Triangle/Tetgen', is not set for each phase. This option is used with Triangle or TetGen. Defaults to infinity, which turns off this control. max_edge_length (float): The maximum edge length of elements - along grain boundaries. This option is used with Triangle - and gmsh, and in 3D for the triangles on the boundary of a - periodic domain. Defaults to infinity, which turns off this - control. + along grain boundaries: of the segments in 2D and of the + triangles on the facets in 3D. This option is used with + Triangle/TetGen and gmsh. Defaults to infinity, which turns + off this control. mesh_size (float): The target size of the mesh elements. This option is used with gmsh. Default is infinity, whihch turns off this control. @@ -1539,6 +1539,13 @@ def _call_meshpy(polymesh, phases=None, min_angle=0, max_volume=float('inf'), facet_nums, holes, regions, opts, max_volume, max_edge_length) else: + if np.isfinite(max_edge_length): + # the facets are triangulated to the maximum edge length + # (TetGen has no such control) and TetGen refines the + # interior of the cells to the maximum volume + pts, facets, facet_nums = _triangulate_facets_3d( + polymesh, phases, kps, pts, facet_nums, max_volume, + max_edge_length, {}, {}) info = _tet_info(pts, facets, facet_nums, holes, regions) tri_mesh = meshpy.tet.build(info, options=opts) @@ -2132,7 +2139,7 @@ def _triangle_polygon(loop_pts, h_val, allow_boundary_steiner, extra_pts=(), 'facet.') out_pts = origin + np.outer(out_2d[:, 0], u_vec) out_pts += np.outer(out_2d[:, 1], v_vec) - out_pts[:n_in] = in_pts + out_pts[:n_pts] = loop_pts # the extra points are projected on the plane for axis in range(3): if np.ptp(loop_pts[:, axis]) <= 1e-12: out_pts[:, axis] = loop_pts[0, axis] @@ -2225,14 +2232,13 @@ def to_root(key, t_vals): return list(t_vals) return [1 - t for t in t_vals] - # 2. Parameters of the points on the edges: the edges of the periodic - # facets are subdivided to the maximum edge length, and all the edges - # at the extra parameters + # 2. Parameters of the points on the edges: the edges are subdivided + # to the maximum edge length, and at the extra parameters periodic_facets = set(upper) | is_upper splits = {} for f_num in facet_nums: f = f_num - 1 - if f not in periodic_facets or not np.isfinite(max_edge_length): + if not np.isfinite(max_edge_length): continue loop = polymesh.facets[f] for i in range(len(loop)): @@ -2317,7 +2323,8 @@ def to_root(key, t_vals): # 5. Triangulate the facets with their edges fixed; the facets on the # upper periodic faces are the images of those on the lower faces. The # facets on the periodic faces are refined to the mesh size when one is - # given (TetGen cannot refine them afterwards), the others are the + # given (TetGen cannot refine them afterwards), and all the facets to + # the maximum edge length when it is given; the others are the # constrained Delaunay triangulations of their points. new_facets = [] new_nums = [] @@ -2327,7 +2334,7 @@ def to_root(key, t_vals): continue loop_ids = loops[f] extra = face_pts.get(f, []) - quality = f in periodic_facets + quality = f in periodic_facets or np.isfinite(max_edge_length) h_val = h_facets[f] if quality and np.isfinite(h_val): # the area bound is met by equilateral triangles of that edge @@ -2423,7 +2430,8 @@ def _collect_facet_points_3d(new_pts, polymesh, edge_t, face_pts): lengths = p_arr.max(axis=0) - mins maxs = mins + lengths scale = lengths.max() - tol = 1e-9 * scale + tol = max(1e-9, 4 * _facet_nonplanarity(polymesh)) * scale + tol_face = 1e-9 * scale tol_dup = 1e-6 * scale per_axes = polymesh.periodic_axes to_lower = {} @@ -2459,7 +2467,8 @@ def _collect_facet_points_3d(new_pts, polymesh, edge_t, face_pts): raw_face = {} for i, f_set in point_facets.items(): pt = np.array(new_pts[i]) - side = tuple([int(bool(per_axes[k]) and abs(pt[k] - maxs[k]) <= tol) + side = tuple([int(bool(per_axes[k]) and + abs(pt[k] - maxs[k]) <= tol_face) for k in range(3)]) f_list = sorted(f_set) if len(f_list) > 1: @@ -2934,6 +2943,35 @@ def _build_periodic_2d(polymesh, phases, labels, kps, pts, facets, return tri_pts, tri_elems, tri_e_atts +def _facet_nonplanarity(polymesh): + """Largest distance of a vertex to the plane of its facet. + + The facets of a periodic polymesh are planar only within the tolerance + of the snapping of the points to the periodic faces. The geometric + tests of the meshes against the polymesh use this distance as their + tolerance. + + Returns: + float: The distance, relative to the size of the domain. + + """ + pts = np.array(polymesh.points) + scale = np.max(pts.max(axis=0) - pts.min(axis=0)) + max_dev = 0.0 + for facet in polymesh.facets: + if len(facet) < 4: + continue + loop = pts[facet] + normal = np.zeros(3) + for i in range(len(loop)): + normal += np.cross(loop[i - 1], loop[i]) + norm = np.linalg.norm(normal) + if norm > 0: + dev = np.abs((loop - loop[0]).dot(normal / norm)).max() + max_dev = max(max_dev, dev) + return max_dev / scale + + def _attributes_from_polymesh(tri_pts, tri_elems, polymesh, labels): """Element attributes and facets of a mesh, from the polymesh geometry. @@ -2966,31 +3004,32 @@ def _attributes_from_polymesh(tri_pts, tri_elems, polymesh, labels): n_dim = tri_pts.shape[1] p_pts = np.array(polymesh.points) scale = np.max(p_pts.max(axis=0) - p_pts.min(axis=0)) - tol = 1e-9 * scale + tol = max(1e-9, 4 * _facet_nonplanarity(polymesh)) * scale cell_geom = _CellGeometry(polymesh, p_pts) labels = np.array(labels) - # 1. Cell containing the centroid of each element + # 1. Cell containing the centroid of each element: the cell in which + # the centroid is deepest cens = tri_pts[tri_elems].mean(axis=1) elem_regs = np.full(len(tri_elems), -1) - i_remain = np.arange(len(tri_elems)) + depths = np.full(len(tri_elems), -np.inf) for r_num in range(len(polymesh.regions)): r_mins, r_maxs = cell_geom.limits(r_num) - r_cens = cens[i_remain] - in_box = np.all((r_cens >= r_mins - tol) & (r_cens <= r_maxs + tol), + in_box = np.all((cens >= r_mins - tol) & (cens <= r_maxs + tol), axis=1) - r_i = i_remain[in_box] + r_i = np.nonzero(in_box)[0] if len(r_i) == 0: continue _, normals, centers = cell_geom.facets(r_num) rel_pos = cens[r_i][:, np.newaxis, :] - centers - dp = np.einsum('efd,fd->ef', rel_pos, normals) - r_i = r_i[np.all(dp >= -tol, axis=1)] - elem_regs[r_i] = r_num - i_remain = np.setdiff1d(i_remain, r_i) - if len(i_remain) > 0: + depth = np.einsum('efd,fd->ef', rel_pos, normals).min(axis=1) + deeper = depth > depths[r_i] + elem_regs[r_i[deeper]] = r_num + depths[r_i[deeper]] = depth[deeper] + n_outside = int(np.sum(depths < -tol)) + if n_outside > 0: e_str = 'The mesh does not conform to the polymesh: the centroids ' - e_str += 'of ' + str(len(i_remain)) + ' elements are outside every ' + e_str += 'of ' + str(n_outside) + ' elements are outside every ' e_str += 'cell.' raise RuntimeError(e_str) elem_atts = labels[elem_regs] @@ -3071,7 +3110,7 @@ def _attributes_from_polymesh(tri_pts, tri_elems, polymesh, labels): if dists[k] < best_dist: best_dist = dists[k] best_f = int(f_num) - if best_f is None or best_dist > 1e-8 * scale: + if best_f is None or best_dist > max(1e-8 * scale, tol): raise RuntimeError(e_str) facets.append(face) facet_atts.append(best_f) From f066632ce19edfbae888adc5dc4d1d6e2917c8b8 Mon Sep 17 00:00:00 2001 From: Julian Rimoli Date: Wed, 23 Sep 2026 09:05:38 -0700 Subject: [PATCH 19/42] Unify the vertices of the cells before cutting them at the periodic faces Voro++ computes each cell on its own, so two cells that share a vertex hold copies of it that differ by its precision (a few 1e-9 of the domain here), and in periodic mode the copies can lie in different images of the domain. The pieces of the two cells were matched along their shared edges with a tolerance of 1e-10, which failed for such a vertex during an edge optimization: "Cannot resolve the neighbor of the edge ...". The copies of the vertices that coincide, modulo the length of the domain along the periodic axes, are now replaced by their mean before the cells are cut, and snapped onto the periodic faces once for all cells, so that the cells are cut consistently and their pieces match exactly; the pieces are matched with the merge tolerance of the points. Verified on the failing case, the tiled-reference tests and a stress test of random realizations with edge optimization in 2D and 3D. Co-Authored-By: Claude Fable 5.1 --- src/microstructpy/meshing/polymesh.py | 82 ++++++++++++++++++++++++++- 1 file changed, 80 insertions(+), 2 deletions(-) diff --git a/src/microstructpy/meshing/polymesh.py b/src/microstructpy/meshing/polymesh.py index 3c0bf8a4..f86a086b 100644 --- a/src/microstructpy/meshing/polymesh.py +++ b/src/microstructpy/meshing/polymesh.py @@ -1442,6 +1442,80 @@ def _clip_loop(pts, adj, axis, value, keep_below, wall, tol): return new_pts, new_adj +def _unify_cell_vertices(voro, lims, per_axes, merge_tol, snap_tol): + """Give the cells identical coordinates for their shared vertices. + + Voro++ computes each cell on its own, so two cells that share a vertex + hold copies of it that differ by its precision, and in periodic mode + the copies may lie in different images of the domain. The copies that + coincide, modulo the length of the domain along the periodic axes, are + replaced by their mean, snapped onto the periodic faces when within + the snapping tolerance, so that the cells are cut consistently at the + faces and their pieces match exactly. + + Args: + voro (list): The cells from pyvoro. + lims (list): (lower, upper) bounds of the domain, per axis. + per_axes (list): Periodicity flag of each axis. + merge_tol (float): Distance below which copies are one vertex. + snap_tol (float): Distance below which a vertex is on a face. + + Returns: + list: The cells, with the unified vertices. + + """ + lb = np.array([lim[0] for lim in lims], dtype='float') + lengths = np.array([ub - lo for lo, ub in lims], dtype='float') + counts = [len(cell['vertices']) for cell in voro] + all_pts = np.vstack([np.array(cell['vertices'], dtype='float') + for cell in voro]) + + # the images of the vertices in the domain, along the periodic axes + shifts = np.zeros_like(all_pts) + for axis, flag in enumerate(per_axes): + if flag: + n_img = np.floor((all_pts[:, axis] - lb[axis]) / lengths[axis]) + shifts[:, axis] = n_img * lengths[axis] + wrapped = all_pts - shifts + + # coincident copies are one vertex + parent = np.arange(len(wrapped)) + + def find(i): + while parent[i] != i: + parent[i] = parent[parent[i]] + i = parent[i] + return i + + for i, j in cKDTree(wrapped).query_pairs(merge_tol): + r_i, r_j = find(i), find(j) + if r_i != r_j: + parent[max(r_i, r_j)] = min(r_i, r_j) + roots = np.array([find(i) for i in range(len(wrapped))]) + unified = wrapped.copy() + for root in np.unique(roots): + members = roots == root + unified[members] = wrapped[members].mean(axis=0) + + # vertices next to a periodic face are on it + for axis, flag in enumerate(per_axes): + if not flag: + continue + for value in (lb[axis], lb[axis] + lengths[axis]): + on_face = np.abs(unified[:, axis] - value) <= snap_tol + unified[on_face, axis] = value + unified += shifts + + new_voro = [] + start = 0 + for cell, count in zip(voro, counts): + new_cell = dict(cell) + new_cell['vertices'] = unified[start:start + count].tolist() + new_voro.append(new_cell) + start += count + return new_voro + + def _periodic_pieces_2d(voro, bkdwn2seed, lims, per_axes): """Split the cells of a periodic 2D tessellation at the periodic faces. @@ -1469,7 +1543,9 @@ def _periodic_pieces_2d(voro, bkdwn2seed, lims, per_axes): """ lengths = [ub - lb for lb, ub in lims] tol = 1e-10 * max(lengths) + merge_tol = _MERGE_TOL * max(lengths) snap_tol = _SNAP_TOL * max(lengths) + voro = _unify_cell_vertices(voro, lims, per_axes, merge_tol, snap_tol) # Cut the cells at the periodic faces pieces = [] # (cell number, vertices, edge adjacencies) @@ -1534,7 +1610,7 @@ def _periodic_pieces_2d(voro, bkdwn2seed, lims, per_axes): candidates = [p for p in cell_pieces.get(adj_cell, []) if p != piece_num] adj_cell = _matching_piece(pts[k], pts[k1], candidates, - pieces, tol) + pieces, merge_tol) if adj_cell is None: adj_cell = _wall_of_edge(pts[k], pts[k1], lims, tol) faces.append({'adjacent_cell': int(adj_cell), @@ -1740,7 +1816,9 @@ def _periodic_pieces_3d(voro, bkdwn2seed, lims, per_axes): """ lengths = [ub - lb for lb, ub in lims] tol = 1e-10 * max(lengths) + merge_tol = _MERGE_TOL * max(lengths) snap_tol = _SNAP_TOL * max(lengths) + voro = _unify_cell_vertices(voro, lims, per_axes, merge_tol, snap_tol) pieces = [] # (cell number, vertices, faces) for cell_num, cell in enumerate(voro): @@ -1805,7 +1883,7 @@ def _periodic_pieces_3d(voro, bkdwn2seed, lims, per_axes): candidates = [p for p in cell_pieces.get(adj_cell, []) if p != piece_num] adj_cell = _matching_piece_3d(verts[loop], candidates, - pieces, tol) + pieces, merge_tol) if adj_cell is None: adj_cell = _wall_of_face(verts[loop], lims, tol) out_faces.append({'adjacent_cell': int(adj_cell), From 31517cbff2232378d099949b7d8cd6ed6cba3c42 Mon Sep 17 00:00:00 2001 From: Julian Rimoli Date: Wed, 23 Sep 2026 09:09:25 -0700 Subject: [PATCH 20/42] Add examples of a periodic binder-inclusion composite and of interface refinement pbx_2D.xml and pbx_3D.xml build a periodic particulate composite, such as a plastic-bonded explosive: crystalline inclusions with lognormal sizes in a binder, which is a matrix phase seeded with small circles or spheres. pbx_interface_2D.xml and pbx_interface_3D.xml mesh the same material with elements refined along the grain boundaries (the binder-inclusion interfaces) and coarse inside the grains, through max_edge_length and a larger max_volume. Each has a page in the examples of the documentation. Co-Authored-By: Claude Fable 5.1 --- docs/source/examples/cli/pbx_2d.rst | 82 +++++++++++++++++++ docs/source/examples/cli/pbx_3d.rst | 69 ++++++++++++++++ docs/source/examples/cli/pbx_interface_2d.rst | 55 +++++++++++++ docs/source/examples/cli/pbx_interface_3d.rst | 56 +++++++++++++ docs/source/examples/index.rst | 24 ++++++ src/microstructpy/examples/pbx_2D.xml | 63 ++++++++++++++ src/microstructpy/examples/pbx_3D.xml | 60 ++++++++++++++ .../examples/pbx_interface_2D.xml | 67 +++++++++++++++ .../examples/pbx_interface_3D.xml | 64 +++++++++++++++ 9 files changed, 540 insertions(+) create mode 100644 docs/source/examples/cli/pbx_2d.rst create mode 100644 docs/source/examples/cli/pbx_3d.rst create mode 100644 docs/source/examples/cli/pbx_interface_2d.rst create mode 100644 docs/source/examples/cli/pbx_interface_3d.rst create mode 100644 src/microstructpy/examples/pbx_2D.xml create mode 100644 src/microstructpy/examples/pbx_3D.xml create mode 100644 src/microstructpy/examples/pbx_interface_2D.xml create mode 100644 src/microstructpy/examples/pbx_interface_3D.xml diff --git a/docs/source/examples/cli/pbx_2d.rst b/docs/source/examples/cli/pbx_2d.rst new file mode 100644 index 00000000..5f95de40 --- /dev/null +++ b/docs/source/examples/cli/pbx_2d.rst @@ -0,0 +1,82 @@ +.. _ex_pbx_2d: + +============================= +Binder and Inclusions Example +============================= + +XML Input File +============== + +The basename for this file is ``pbx_2D.xml``. +The file can be run using this command:: + + microstructpy --demo=pbx_2D.xml + +The full text of the file is: + +.. literalinclude:: ../../../../src/microstructpy/examples/pbx_2D.xml + :language: xml + + +Materials +========= + +This microstructure is a particulate composite, such as a plastic-bonded +explosive: crystalline inclusions in a polymer binder. + +The first material is the binder, a ``matrix`` phase that makes up 35% of the +area. +Its seeds are small circles: the cells of a matrix phase are merged into one +region, so the seeds only need to fill the space between the inclusions. + +The second material is the inclusions, a ``crystalline`` phase that makes up +65% of the area, with circular seeds whose diameters follow a lognormal +distribution. +Each seed of a crystalline phase becomes one polygonal grain of the mesh. + +Domain Geometry +=============== + +The materials fill a square domain of side length 3, periodic in both +directions. + +Settings +======== + +The overlap tolerance ``rtol`` is set to 0.5: with the default fitted value +some of the small binder seeds do not fit between the inclusions and are +rejected. +The random number generator seeds make the microstructure repeatable, and +the edge optimization removes the shortest edges of the polygonal mesh. + +The mesh has a minimum angle of 25 degrees and a maximum element area of +0.01. + + +Output Files +============ + +The three plots that this file generates are the seeding, the polygon mesh, +and the triangular mesh. +These three plots are shown in :numref:`f_ex_pbx2d_seeds` - +:numref:`f_ex_pbx2d_tri`. +The binder is one region of the mesh; its boundaries with the inclusions are +facets of the mesh. + +.. _f_ex_pbx2d_seeds: +.. figure:: ../../../../src/microstructpy/examples/pbx_2D/seeds.png + :alt: Seed geometries. + + Binder and inclusions example - seed geometries. + +.. _f_ex_pbx2d_poly: +.. figure:: ../../../../src/microstructpy/examples/pbx_2D/polymesh.png + :alt: Polygonal mesh. + + Binder and inclusions example - polygonal mesh. + +.. _f_ex_pbx2d_tri: +.. figure:: ../../../../src/microstructpy/examples/pbx_2D/trimesh.png + :alt: Triangular mesh. + + Binder and inclusions example - triangular mesh. diff --git a/docs/source/examples/cli/pbx_3d.rst b/docs/source/examples/cli/pbx_3d.rst new file mode 100644 index 00000000..9162c97e --- /dev/null +++ b/docs/source/examples/cli/pbx_3d.rst @@ -0,0 +1,69 @@ +.. _ex_pbx_3d: + +================================ +Binder and Inclusions 3D Example +================================ + +XML Input File +============== + +The basename for this file is ``pbx_3D.xml``. +The file can be run using this command:: + + microstructpy --demo=pbx_3D.xml + +The full text of the file is: + +.. literalinclude:: ../../../../src/microstructpy/examples/pbx_3D.xml + :language: xml + + +Materials +========= + +This is the 3D version of the :ref:`ex_pbx_2d` example: crystalline +inclusions (65% of the volume, spherical seeds with lognormal diameters) in +a binder (a ``matrix`` phase, 35% of the volume, seeded with small spheres). + +Domain Geometry +=============== + +The materials fill a cube of side length 3, periodic in the three +directions. + +Settings +======== + +The overlap tolerance ``rtol`` is set to 0.5 so that the small binder seeds +can be placed between the inclusions, and the random number generator seeds +make the microstructure repeatable. + +The mesh has a minimum dihedral angle of 15 degrees and a maximum element +volume of 0.02. + + +Output Files +============ + +The three plots that this file generates are the seeding, the polyhedral +mesh, and the tetrahedral mesh. +These three plots are shown in :numref:`f_ex_pbx3d_seeds` - +:numref:`f_ex_pbx3d_tri`. + +.. _f_ex_pbx3d_seeds: +.. figure:: ../../../../src/microstructpy/examples/pbx_3D/seeds.png + :alt: Seed geometries. + + Binder and inclusions 3D example - seed geometries. + +.. _f_ex_pbx3d_poly: +.. figure:: ../../../../src/microstructpy/examples/pbx_3D/polymesh.png + :alt: Polyhedral mesh. + + Binder and inclusions 3D example - polyhedral mesh. + +.. _f_ex_pbx3d_tri: +.. figure:: ../../../../src/microstructpy/examples/pbx_3D/trimesh.png + :alt: Tetrahedral mesh. + + Binder and inclusions 3D example - tetrahedral mesh. diff --git a/docs/source/examples/cli/pbx_interface_2d.rst b/docs/source/examples/cli/pbx_interface_2d.rst new file mode 100644 index 00000000..84e64b64 --- /dev/null +++ b/docs/source/examples/cli/pbx_interface_2d.rst @@ -0,0 +1,55 @@ +.. _ex_pbx_interface_2d: + +============================ +Interface Refinement Example +============================ + +XML Input File +============== + +The basename for this file is ``pbx_interface_2D.xml``. +The file can be run using this command:: + + microstructpy --demo=pbx_interface_2D.xml + +The full text of the file is: + +.. literalinclude:: ../../../../src/microstructpy/examples/pbx_interface_2D.xml + :language: xml + + +Materials and Domain +==================== + +The materials and the domain are those of the :ref:`ex_pbx_2d` example: +crystalline inclusions in a binder, in a square domain periodic in both +directions. + +Settings +======== + +The mesh is refined along the grain boundaries, which are the interfaces +between the binder and the inclusions (and between neighboring inclusions), +and coarse inside the grains. + +``mesh_max_edge_length`` sets the maximum length of the element edges along +the grain boundaries, 0.03 here, while ``mesh_max_volume`` sets the maximum +area of the elements, 0.02 here, which is the area of a triangle with edges +about seven times longer. +Triangle grades the element size between the two. + +The mesh has a minimum angle of 25 degrees. + + +Output Files +============ + +The three plots that this file generates are the seeding, the polygon mesh, +and the triangular mesh. +The triangular mesh is shown in :numref:`f_ex_pbxint2d_tri`. + +.. _f_ex_pbxint2d_tri: +.. figure:: ../../../../src/microstructpy/examples/pbx_interface_2D/trimesh.png + :alt: Triangular mesh. + + Interface refinement example - triangular mesh. diff --git a/docs/source/examples/cli/pbx_interface_3d.rst b/docs/source/examples/cli/pbx_interface_3d.rst new file mode 100644 index 00000000..2a83c3dd --- /dev/null +++ b/docs/source/examples/cli/pbx_interface_3d.rst @@ -0,0 +1,56 @@ +.. _ex_pbx_interface_3d: + +=============================== +Interface Refinement 3D Example +=============================== + +XML Input File +============== + +The basename for this file is ``pbx_interface_3D.xml``. +The file can be run using this command:: + + microstructpy --demo=pbx_interface_3D.xml + +The full text of the file is: + +.. literalinclude:: ../../../../src/microstructpy/examples/pbx_interface_3D.xml + :language: xml + + +Materials and Domain +==================== + +The materials and the domain are those of the :ref:`ex_pbx_3d` example: +crystalline inclusions in a binder, in a cube periodic in the three +directions. + +Settings +======== + +The mesh is refined on the grain boundaries, which are the interfaces +between the binder and the inclusions (and between neighboring inclusions), +and coarse inside the grains. + +In 3D, ``mesh_max_edge_length`` sets the maximum edge length of the +triangles on the facets of the polyhedral mesh, 0.15 here: the facets are +triangulated to that size before TetGen meshes the cells. +``mesh_max_volume`` sets the maximum volume of the tetrahedra, 0.05 here, +which is the volume of a regular tetrahedron with edges about five times +longer, and TetGen grades the element size between the two. + +The mesh has a minimum dihedral angle of 15 degrees. + + +Output Files +============ + +The three plots that this file generates are the seeding, the polyhedral +mesh, and the tetrahedral mesh. +The tetrahedral mesh (its facets) is shown in :numref:`f_ex_pbxint3d_tri`. + +.. _f_ex_pbxint3d_tri: +.. figure:: ../../../../src/microstructpy/examples/pbx_interface_3D/trimesh.png + :alt: Tetrahedral mesh. + + Interface refinement 3D example - tetrahedral mesh. diff --git a/docs/source/examples/index.rst b/docs/source/examples/index.rst index 41b57bea..946f9579 100644 --- a/docs/source/examples/index.rst +++ b/docs/source/examples/index.rst @@ -77,6 +77,10 @@ CLI Examples cli/colormap cli/periodic_2d cli/periodic_3d + cli/pbx_2d + cli/pbx_3d + cli/pbx_interface_2d + cli/pbx_interface_3d .. only:: html @@ -115,6 +119,26 @@ CLI Examples :width: 32% :target: cli/periodic_3d.html + .. image:: ../../../src/microstructpy/examples/pbx_2D/trimesh.png + :alt: Triangular mesh of binder and inclusions. + :width: 32% + :target: cli/pbx_2d.html + + .. image:: ../../../src/microstructpy/examples/pbx_3D/polymesh.png + :alt: Polyhedral mesh of binder and inclusions. + :width: 32% + :target: cli/pbx_3d.html + + .. image:: ../../../src/microstructpy/examples/pbx_interface_2D/trimesh.png + :alt: Triangular mesh refined at the interfaces. + :width: 32% + :target: cli/pbx_interface_2d.html + + .. image:: ../../../src/microstructpy/examples/pbx_interface_3D/trimesh.png + :alt: Tetrahedral mesh refined at the interfaces. + :width: 32% + :target: cli/pbx_interface_3d.html + .. _package_examples: diff --git a/src/microstructpy/examples/pbx_2D.xml b/src/microstructpy/examples/pbx_2D.xml new file mode 100644 index 00000000..0f9dea59 --- /dev/null +++ b/src/microstructpy/examples/pbx_2D.xml @@ -0,0 +1,63 @@ + + + + Binder + matrix + 35 + circle + + uniform + 0.08 + 0.08 + + lightgray + + + + Inclusions + crystalline + 65 + circle + + lognorm + 0.35 + 0.35 + + darkorange + + + + square + 3 + (0, 0) + True + + + + pbx_2D + True + + 1 + 1 + + + + 0.5 + + True + 25 + + 25 + 0.01 + + + 0.3 + + + 0.3 + + + 0.15 + + + diff --git a/src/microstructpy/examples/pbx_3D.xml b/src/microstructpy/examples/pbx_3D.xml new file mode 100644 index 00000000..d540337f --- /dev/null +++ b/src/microstructpy/examples/pbx_3D.xml @@ -0,0 +1,60 @@ + + + + Binder + matrix + 35 + sphere + + uniform + 0.45 + 0.25 + + lightgray + + + + Inclusions + crystalline + 65 + sphere + + lognorm + 0.8 + 0.3 + + darkorange + + + + cube + 3 + (0, 0, 0) + True + + + + pbx_3D + True + + 1 + 1 + + + + 0.7 + + 15 + 0.02 + + + 0.2 + + + 0.2 + + + 0.1 + + + diff --git a/src/microstructpy/examples/pbx_interface_2D.xml b/src/microstructpy/examples/pbx_interface_2D.xml new file mode 100644 index 00000000..1ab66ba2 --- /dev/null +++ b/src/microstructpy/examples/pbx_interface_2D.xml @@ -0,0 +1,67 @@ + + + + Binder + matrix + 35 + circle + + uniform + 0.08 + 0.08 + + lightgray + + + + Inclusions + crystalline + 65 + circle + + lognorm + 0.35 + 0.35 + + darkorange + + + + square + 3 + (0, 0) + True + + + + pbx_interface_2D + True + + 1 + 1 + + + + 0.5 + + True + 25 + + + 25 + 0.03 + 0.02 + + + 0.3 + + + 0.3 + + + 0.15 + + + diff --git a/src/microstructpy/examples/pbx_interface_3D.xml b/src/microstructpy/examples/pbx_interface_3D.xml new file mode 100644 index 00000000..bcaa89ca --- /dev/null +++ b/src/microstructpy/examples/pbx_interface_3D.xml @@ -0,0 +1,64 @@ + + + + Binder + matrix + 35 + sphere + + uniform + 0.45 + 0.25 + + lightgray + + + + Inclusions + crystalline + 65 + sphere + + lognorm + 0.8 + 0.3 + + darkorange + + + + cube + 3 + (0, 0, 0) + True + + + + pbx_interface_3D + True + + 1 + 1 + + + + + 0.7 + + 15 + 0.2 + 0.05 + + + 0.2 + + + 0.2 + + + 0.1 + + + From f8ae0a21a87830c15c1cd503bc486ed9c34c809b Mon Sep 17 00:00:00 2001 From: Julian Rimoli Date: Wed, 23 Sep 2026 09:09:26 -0700 Subject: [PATCH 21/42] Add a margin between the seeds and the periodic faces A seed that barely crosses a periodic face, or ends just inside it, leaves a thin piece of its grain on the opposite face, and the mesh has elements much smaller than its target size there. The new periodic_margin option of SeedList.position (and setting of the CLI) rejects the positions where a seed ends within the margin of a periodic face or crosses it by less than the margin, and tries another position; 'auto' in the CLI sets the margin to half the target edge length of the mesh, from mesh_max_edge_length or mesh_max_volume. Over eight realizations of the periodic 2D example, a margin of half the target edge length halves the number of boundary triangles with edges below a quarter of the target and removes a third of the thin pieces; the edge optimization, which does not act on the thin pieces, reduces the small elements elsewhere, and the two combine. The thin pieces that remain come from corners of cells crossing the faces, which the geometry of the seeds cannot predict. The periodic 2D example uses the margin. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.rst | 21 +++++++++- docs/source/cli/domain.rst | 5 +++ docs/source/cli/settings.rst | 15 +++++++ docs/source/examples/cli/periodic_2d.rst | 8 ++++ src/microstructpy/cli.py | 42 ++++++++++++++++++- src/microstructpy/examples/periodic_2D.xml | 5 +++ src/microstructpy/seeding/seedlist.py | 48 +++++++++++++++++++++- tests/cli/test_periodic_cli.py | 15 +++++++ tests/seeding/test_periodic_seeding.py | 38 +++++++++++++++++ 9 files changed, 193 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index d8488699..98374f8c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -29,7 +29,10 @@ Added node set per periodic face in matching order; the verification unwraps grains that are split by the faces. The examples ``periodic_2D.xml``, ``periodic_3D.xml`` and ``periodic_tiling.py`` demonstrate periodic - microstructures. Cells of the same amorphous phase + microstructures, ``pbx_2D.xml`` and ``pbx_3D.xml`` a periodic + particulate composite (crystalline inclusions in a binder), and + ``pbx_interface_2D.xml`` and ``pbx_interface_3D.xml`` meshes refined at + the grain boundaries. Cells of the same amorphous phase that touch across a periodic face are merged into one region, like cells that share a facet, and the merged region is labelled with the smallest seed number among its cells by every mesher and writer. The element @@ -37,6 +40,12 @@ Added geometry of the polymesh, since TetGen can leave sub-faces of a facet unmarked when it may not modify the boundary and its region attributes then leak between cells. gmsh is not supported for periodic meshes. +- ``periodic_margin`` (a setting, and an argument of ``SeedList.position`` + and ``cli.run``): the minimum distance between the surface of a seed and + a periodic face. A seed that ends within the margin of a face, or crosses + it by less, is placed elsewhere, since it would leave a thin piece of its + grain on the opposite face and elements much smaller than the target size + of the mesh there. ``auto`` uses half the target edge length of the mesh. Fixed ''''' @@ -95,6 +104,16 @@ Fixed Changed ''''''' +- ``max_edge_length`` (``mesh_max_edge_length``) acts in 3D on the triangles + of the grain boundaries: when it is set, the facets of the polyhedral mesh + are triangulated to that edge length (with Triangle, minimum angle 20 + degrees) before TetGen meshes the cells, for periodic and non-periodic + meshes alike, so that the elements can be smaller at the interfaces than + inside the grains (see the ``pbx_interface_3D.xml`` example). The + geometric tests of a mesh against its polymesh use the non-planarity of + the facets (from the snapping of the points to the periodic faces) as + their tolerance, and each element is assigned to the cell in which its + centroid is deepest. - The overlap tolerance fit ``rtol='fit'`` uses the coefficients published in Hart and Rimoli, CMAME 370 (2020) 113242, Eqs. (14) and (15). For very wide size distributions this allows less overlap than before (2D diff --git a/docs/source/cli/domain.rst b/docs/source/cli/domain.rst index 94553f28..f5f04440 100644 --- a/docs/source/cli/domain.rst +++ b/docs/source/cli/domain.rst @@ -338,6 +338,11 @@ matching order. +A seed that barely crosses a periodic face, or ends just inside it, leaves a +thin piece of its grain on the opposite face and very small elements there; +the ``periodic_margin`` setting rejects such positions (see +:ref:`cli_settings`). + The mesher must be Triangle/TetGen for periodic microstructures (with gmsh the nodes on opposite faces are not guaranteed to match), and the mesh size of a raster mesh must divide the domain length along the periodic axes. diff --git a/docs/source/cli/settings.rst b/docs/source/cli/settings.rst index 6b42be93..adc32ee9 100644 --- a/docs/source/cli/settings.rst +++ b/docs/source/cli/settings.rst @@ -341,6 +341,21 @@ This setting controls the aspect ratio of the elements, with angles between The default is `` 0 ``, which effectively turns off the angle quality control. +periodic_margin +--------------- + +This field sets the minimum distance between the surface of a seed and a +periodic face of the domain (see :ref:`cli_domain` for periodic domains). +A position where a seed ends within this distance inside a face, or crosses +a face by less than this distance, is rejected and another one is tried: +such seeds leave thin pieces of grains on the faces of the domain, and +elements much smaller than the target size of the mesh there. +The value ``auto`` uses half the target edge length of the mesh, taken from +``mesh_max_edge_length`` or, if it is not set, from ``mesh_max_volume``. +A large margin makes the seeds harder to place near the faces. +The default is `` 0 ``, which turns off +the margin. It has no effect on non-periodic domains. + mesh_max_edge_length -------------------- diff --git a/docs/source/examples/cli/periodic_2d.rst b/docs/source/examples/cli/periodic_2d.rst index 322cccbb..64d1fe4b 100644 --- a/docs/source/examples/cli/periodic_2d.rst +++ b/docs/source/examples/cli/periodic_2d.rst @@ -54,6 +54,14 @@ The mesh has a minimum angle of 25 degrees and a maximum element area of 0.004. The quality and size settings act on a periodic mesh as on a non-periodic one. +A grain that barely crosses a periodic face, or ends just inside it, leaves +a thin piece of itself on the opposite face and very small elements there. +``periodic_margin`` rejects the positions where a seed ends within the +margin of a periodic face or crosses it by less than the margin; ``auto`` +sets the margin to half the target edge length of the mesh. +The edge optimization removes the shortest edges of the polygonal mesh, for +the same reason. + The plots are colored by seed number, so that the pieces of a grain on opposite faces of the domain have the same color. diff --git a/src/microstructpy/cli.py b/src/microstructpy/cli.py index dc5cc640..a1297f6e 100644 --- a/src/microstructpy/cli.py +++ b/src/microstructpy/cli.py @@ -265,6 +265,33 @@ def _include_merge(exp_dict, key, val): 'vtk': '.vtk'} +def _periodic_margin(periodic_margin, n_dim, max_volume, max_edge_length): + """Margin between the seeds and the periodic faces, from the settings. + + ``'auto'`` is half the target edge length of the mesh: the maximum + edge length if it is set, otherwise the edge of the equilateral + triangle (2D) or regular tetrahedron (3D) with the maximum volume. + """ + if not isinstance(periodic_margin, str): + return float(periodic_margin) + key = periodic_margin.strip().lower() + if key in ('none', 'false', 'no', ''): + return 0.0 + if key not in ('auto', 'fit', 'true'): + e_str = 'Cannot interpret periodic_margin ' + repr(periodic_margin) + e_str += ". Use a length or 'auto'." + raise ValueError(e_str) + h_val = float(max_edge_length) + if np.isfinite(max_volume): + if n_dim == 2: + h_val = min(h_val, np.sqrt(4 * max_volume / np.sqrt(3))) + else: + h_val = min(h_val, (6 * np.sqrt(2) * max_volume) ** (1.0 / 3)) + if not np.isfinite(h_val): + return 0.0 + return 0.5 * h_val + + def run(phases, domain, verbose=False, restart=True, directory='.', filetypes=None, rng_seeds=None, plot_axes=True, rtol='fit', edge_opt=False, edge_opt_n_iter=100, mesher='Triangle/TetGen', @@ -272,7 +299,7 @@ def run(phases, domain, verbose=False, restart=True, directory='.', mesh_max_edge_length=float('inf'), mesh_size=float('inf'), verify=False, color_by='material', colormap='viridis', seeds_kwargs=None, poly_kwargs=None, tri_kwargs=None, - periodic=False): + periodic=False, periodic_margin=0.0): r"""Run MicroStructPy This is the primary run function for the package. It performs these steps: @@ -391,6 +418,15 @@ def run(phases, domain, verbose=False, restart=True, directory='.', domain match; the pairs of periodic nodes are stored in the meshes. In the XML input, ```` is a field of ````. Defaults to False. + periodic_margin (float or str): *(optional)* Minimum distance + between the surface of a seed and a periodic face: a seed that + ends within this distance inside a face, or crosses a face by + less than this distance, is placed elsewhere, since it would + give elements much smaller than the target size of the mesh. + ``'auto'`` uses half the target edge length of the mesh + (``mesh_max_edge_length`` or, from ``mesh_max_volume``, the + edge of the equilateral triangle or regular tetrahedron of that + volume). Defaults to 0 (no margin). .. _`Specifying Colors`: https://matplotlib.org/users/colors.html .. _`Choosing Colormaps in Matplotlib`: https://matplotlib.org/tutorials/colors/colormaps.html @@ -477,8 +513,10 @@ def run(phases, domain, verbose=False, restart=True, directory='.', kw = 'position' rng_seed = rng_seeds.get(kw, 0) pos_dists = {i: p[kw] for i, p in enumerate(phases) if kw in p} + margin = _periodic_margin(periodic_margin, domain.n_dim, + mesh_max_volume, mesh_max_edge_length) seeds.position(domain, pos_dists, rng_seed, rtol=rtol, verbose=verbose, - periodic=periodic) + periodic=periodic, periodic_margin=margin) # Write seeds seeds_types = filetypes.get('seeds', []) diff --git a/src/microstructpy/examples/periodic_2D.xml b/src/microstructpy/examples/periodic_2D.xml index 87229122..9e229145 100644 --- a/src/microstructpy/examples/periodic_2D.xml +++ b/src/microstructpy/examples/periodic_2D.xml @@ -44,6 +44,11 @@ 1 + + auto + True diff --git a/src/microstructpy/seeding/seedlist.py b/src/microstructpy/seeding/seedlist.py index 688309f8..a2f1bae0 100644 --- a/src/microstructpy/seeding/seedlist.py +++ b/src/microstructpy/seeding/seedlist.py @@ -779,7 +779,7 @@ def plot_breakdown(self, index_by='seed', material=[], loc=0, **kwargs): # ----------------------------------------------------------------------- # def position(self, domain, pos_dists={}, rng_seed=0, hold=[], max_attempts=10000, rtol='fit', verbose=False, - periodic=False): + periodic=False, periodic_margin=0.0): """Position seeds in a domain This method positions the seeds within a domain. The "domain" should be @@ -846,6 +846,14 @@ def position(self, domain, pos_dists={}, rng_seed=0, hold=[], the domain is checked for overlap on both sides, through its periodic images. Requires a rectangular domain. Defaults to False. + periodic_margin (float): *(optional)* Minimum distance between + the surface of a seed and a periodic face: a position where + a seed ends within this distance inside a face, or crosses + a face by less than this distance, is rejected and another + one is tried. Such seeds give thin pieces of cells on the + faces, and elements much smaller than the target size of the + mesh; a margin of about half the target edge length avoids + most of them. Defaults to 0 (no margin). """ # NOQA: E501 if len(hold) == 0: @@ -922,6 +930,12 @@ def position(self, domain, pos_dists={}, rng_seed=0, hold=[], rads = bkdwn[:, -1].reshape(-1, 1) limits = seed.geometry.limits + # A seed that ends within the margin of a periodic face, or + # crosses it by less than the margin, is placed elsewhere + if periodic_margin > 0 and not _clears_faces( + limits, dom_lims, per_axes, periodic_margin): + continue + # The seed and its periodic images are tested against the # placed seeds and their images (the tree holds both) clears = True @@ -971,6 +985,38 @@ def position(self, domain, pos_dists={}, rng_seed=0, hold=[], self.seeds = self[keep_mask].seeds +def _clears_faces(limits, dom_lims, per_axes, margin): + """Whether a seed keeps its surface away from the periodic faces. + + The surface of the seed must either stay at least ``margin`` inside + the domain or cross the periodic face by at least ``margin``. A seed + that ends just inside a face, or barely crosses it, gives a thin piece + of a cell on one of the two faces of the pair and elements much + smaller than the target size of the mesh there. + + Args: + limits (list): Bounding box of the seed, as (min, max) per axis. + dom_lims (list): Limits of the domain, as (min, max) per axis. + per_axes (list): Periodicity flags, one per axis. + margin (float): The margin. + + Returns: + bool: True if the seed clears the periodic faces. + + """ + for axis, flag in enumerate(per_axes): + if not flag: + continue + lb, ub = dom_lims[axis] + lo, hi = limits[axis] + # signed distances of the ends of the seed to the faces: positive + # inside the domain, negative when the seed crosses the face + for gap in (lo - lb, ub - hi): + if abs(gap) < margin: + return False + return True + + def _periodic_images(limits, dom_lims, per_axes, include_zero=False): """Translations of the periodic images of a shape. diff --git a/tests/cli/test_periodic_cli.py b/tests/cli/test_periodic_cli.py index 67c786ee..a610a3b8 100644 --- a/tests/cli/test_periodic_cli.py +++ b/tests/cli/test_periodic_cli.py @@ -2,6 +2,7 @@ import os import numpy as np +import pytest from microstructpy import cli from microstructpy.meshing import PolyMesh @@ -95,3 +96,17 @@ def test_periodic_run_single_axis(tmp_path): tmesh = TriMesh.from_file(str(out_dir / 'trimesh.txt')) assert tmesh.periodic_axes == [False, True] assert list(tmesh.periodic_nodes) == [1] + + +def test_periodic_margin_setting(): + inf = float('inf') + h_2d = np.sqrt(4 * 0.004 / np.sqrt(3)) + assert np.isclose(cli._periodic_margin('auto', 2, 0.004, inf), 0.5 * h_2d) + assert np.isclose(cli._periodic_margin('auto', 2, 0.004, 0.05), 0.025) + h_3d = (6 * np.sqrt(2) * 0.02) ** (1.0 / 3) + assert np.isclose(cli._periodic_margin('auto', 3, 0.02, inf), 0.5 * h_3d) + assert cli._periodic_margin('auto', 3, inf, inf) == 0 + assert cli._periodic_margin(0.03, 2, 0.004, inf) == 0.03 + assert cli._periodic_margin('none', 2, 0.004, inf) == 0 + with pytest.raises(ValueError): + cli._periodic_margin('big', 2, 1, 1) diff --git a/tests/seeding/test_periodic_seeding.py b/tests/seeding/test_periodic_seeding.py index 7e691e35..3b2c9907 100644 --- a/tests/seeding/test_periodic_seeding.py +++ b/tests/seeding/test_periodic_seeding.py @@ -180,3 +180,41 @@ def test_periodic_requires_rectangular_domain(): periodic=True) # a non-periodic call on a circular domain still works seeds.position(msp.geometry.Circle(r=1), periodic=False) + + +def test_periodic_margin(): + domain = msp.geometry.Square(side_length=2, corner=(0, 0)) + phases = [{'shape': 'circle', 'size': scipy.stats.uniform(0.2, 0.2)}] + margin = 0.1 + seeds = SeedList.from_info(phases, 0.6 * domain.area) + seeds.position(domain, rtol=0.0, rng_seed=5, periodic=True, + periodic_margin=margin) + # every seed either stays a margin away from a periodic face, on the + # inside, or crosses it by at least the margin + for seed in seeds: + for axis, (lb, ub) in enumerate(domain.limits): + lo, hi = seed.geometry.limits[axis] + for gap in (lo - lb, ub - hi): + assert abs(gap) >= margin - 1e-12 + # without the margin, some seeds do not + seeds = SeedList.from_info(phases, 0.6 * domain.area) + seeds.position(domain, rtol=0.0, rng_seed=5, periodic=True) + gaps = [] + for seed in seeds: + for axis, (lb, ub) in enumerate(domain.limits): + lo, hi = seed.geometry.limits[axis] + gaps.extend([abs(lo - lb), abs(ub - hi)]) + assert min(gaps) < margin + # the margin only applies to the periodic axes + seeds = SeedList.from_info(phases, 0.6 * domain.area) + seeds.position(domain, rtol=0.0, rng_seed=5, periodic='x', + periodic_margin=margin) + gaps_y = [] + for seed in seeds: + lo, hi = seed.geometry.limits[1] + lb, ub = domain.limits[1] + gaps_y.extend([abs(lo - lb), abs(ub - hi)]) + lo, hi = seed.geometry.limits[0] + lb, ub = domain.limits[0] + assert min(abs(lo - lb), abs(ub - hi)) >= margin - 1e-12 + assert min(gaps_y) < margin From 9cce19a4924954d4e17ed2147c4b0afd4ca953f5 Mon Sep 17 00:00:00 2001 From: Julian Rimoli Date: Wed, 23 Sep 2026 09:25:56 -0700 Subject: [PATCH 22/42] Use the snapping tolerance in the periodic geometry tests The points of a periodic polymesh are snapped onto the periodic faces within _SNAP_TOL of the domain size, which makes some facets non-planar by up to that distance and the cell volumes of the two sides of such a facet differ at the same level. The partition tests now use twice the snapping tolerance for planarity, convexity and containment, compare the volume sums at 1e-6 and check for gaps and overlaps with widened and shrunk cells respectively, instead of tolerances of 1e-8 and 1e-9 that a snapped vertex can exceed. A cube case with such a vertex is added. Co-Authored-By: Claude Fable 5.1 --- tests/meshing/test_periodic_geometry.py | 35 ++++++++++++++++++------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/tests/meshing/test_periodic_geometry.py b/tests/meshing/test_periodic_geometry.py index e7b24009..35c24fd2 100644 --- a/tests/meshing/test_periodic_geometry.py +++ b/tests/meshing/test_periodic_geometry.py @@ -5,6 +5,11 @@ volumes add up to the domain volume and every point of the domain lies in exactly one cell (no gaps, no overlaps). The elements of the triangular/ tetrahedral mesh must partition the cells in the same way. + +The points of a periodic polymesh are snapped onto the periodic faces +within ``_SNAP_TOL`` (relative to the size of the domain), which makes some +facets non-planar by up to that distance: the geometric tests use it as +their tolerance. """ from collections import Counter @@ -15,6 +20,7 @@ import microstructpy as msp from microstructpy.meshing import PolyMesh from microstructpy.meshing import TriMesh +from microstructpy.meshing.polymesh import _SNAP_TOL from microstructpy.meshing.trimesh import _amorphous_seed_numbers from microstructpy.seeding import SeedList @@ -42,6 +48,8 @@ True), ('cube-xz', msp.geometry.Cube(side_length=1.5, corner=(0, 0, 0)), 3, 'xz'), + ('cube-xz-10', msp.geometry.Cube(side_length=1.5, corner=(0, 0, 0)), 10, + 'xz'), ('box-y', msp.geometry.Box(limits=[(0, 2), (0, 1), (0, 1.3)]), 7, 'y'), ('box-none', msp.geometry.Box(limits=[(0, 2), (0, 1), (0, 1.3)]), 9, False), @@ -126,6 +134,7 @@ def test_cells_partition_the_domain(case): lims = np.array(domain.limits) n_dim = len(lims) scale = np.max(lims[:, 1] - lims[:, 0]) + geom_tol = 2 * _SNAP_TOL * scale assert np.all(pts >= lims[:, 0] - 1e-9) assert np.all(pts <= lims[:, 1] + 1e-9) # no duplicate points @@ -138,7 +147,7 @@ def test_cells_partition_the_domain(case): n = _facet_normal(loop) assert np.linalg.norm(n) > 1e-12 n /= np.linalg.norm(n) - assert np.abs((loop - loop[0]) @ n).max() < 1e-8 * scale + assert np.abs((loop - loop[0]) @ n).max() < geom_tol # facet neighbors and regions agree; wall facets are on their wall for f, neighs in enumerate(pmesh.facet_neighbors): @@ -162,18 +171,23 @@ def test_cells_partition_the_domain(case): all_planes.append(planes) vols[r] = sum([np.dot(n, p0) * m for n, _, m, p0 in planes]) / n_dim assert vols[r] > 0 - assert np.all(_inside(planes, pts[verts], tol=1e-8 * scale)) - assert np.isclose(vols.sum(), domain.n_vol, rtol=1e-9) - assert np.allclose(pmesh.volumes, vols, rtol=1e-9, atol=1e-12) + assert np.all(_inside(planes, pts[verts], tol=geom_tol)) + assert np.isclose(vols.sum(), domain.n_vol, rtol=1e-6) + assert np.allclose(pmesh.volumes, vols, rtol=1e-6, atol=1e-9) # random points of the domain lie in exactly one cell rng = np.random.default_rng(0) lengths = lims[:, 1] - lims[:, 0] sample = lims[:, 0] + rng.random((20000, n_dim)) * lengths - counts = np.zeros(len(sample), dtype=int) + # (no gaps: every point is in a cell widened by the tolerance; no + # overlaps: at most one cell contains it when the cells are shrunk) + n_loose = np.zeros(len(sample), dtype=int) + n_strict = np.zeros(len(sample), dtype=int) for planes in all_planes: - counts += _inside(planes, sample, tol=1e-12) - assert np.all(counts == 1) + n_loose += _inside(planes, sample, tol=geom_tol) + n_strict += _inside(planes, sample, tol=-geom_tol) + assert np.all(n_loose >= 1) + assert np.all(n_strict <= 1) # --------------------------------------------------------------------------- # @@ -186,6 +200,7 @@ def test_elements_partition_the_cells(case): elems = np.array(mesh.elements) lims = np.array(domain.limits) n_dim = len(lims) + geom_tol = 2 * _SNAP_TOL * np.max(lims[:, 1] - lims[:, 0]) # positively oriented elements that add up to the domain volume rel = pts[elems[:, 1:]] - pts[elems[:, :1]] @@ -232,7 +247,7 @@ def test_elements_partition_the_cells(case): loop = ppts[pmesh.facets[f_num]] n = _facet_normal(loop) n /= np.linalg.norm(n) - assert np.abs((fp - loop[0]) @ n).max() < 1e-8 + assert np.abs((fp - loop[0]) @ n).max() < geom_tol for f_num, (r_a, r_b) in enumerate(pmesh.facet_neighbors): measure = np.linalg.norm(_facet_normal(ppts[pmesh.facets[f_num]])) if min(r_a, r_b) < 0 or att_of_reg[r_a] != att_of_reg[r_b]: @@ -244,11 +259,11 @@ def test_elements_partition_the_cells(case): for att in np.unique(att_of_reg): regs = np.nonzero(att_of_reg == att)[0] mask = attrs == att - assert np.isclose(svol[mask].sum(), cell_vols[regs].sum(), rtol=1e-9) + assert np.isclose(svol[mask].sum(), cell_vols[regs].sum(), rtol=1e-6) inside = np.zeros(np.sum(mask), dtype=bool) for r in regs: planes, _ = _cell_planes(ppts, pmesh, r) - inside |= _inside(planes, cents[mask]) + inside |= _inside(planes, cents[mask], tol=geom_tol) assert np.all(inside) # the labels: crystalline cells keep their seed number, amorphous cells From 8fde1e0e59e7a887358d4b29ebd6646111af27c9 Mon Sep 17 00:00:00 2001 From: Julian Rimoli Date: Wed, 23 Sep 2026 10:24:54 -0700 Subject: [PATCH 23/42] Optimize the thin pieces of periodic cells with edge_opt The edge optimization now works on the features of the mesh: its edges and, in periodic meshes, the thickness of every piece of a cell at a periodic face. With a positive periodic_margin (a new argument of PolyMesh.from_seeds, passed on by the CLI from its setting), every piece thinner than the margin is a target besides the shortest feature: the seed of the piece is pushed so that the piece reaches the margin or retracted so that the cell ends a margin inside the face, then it and the seeds of its neighbors are moved randomly normal to the face and to their facets with it. A trial is accepted when the shortest feature that it changes gets longer (the features of the two meshes are matched by their geometry), which is the previous criterion for the shortest edge and lets the optimization move on to the next target when one is stuck. Seeds moved across a periodic face are wrapped back into the domain. The CLI writes and plots the seeds again after the optimization, so that the seed file reproduces the polygonal mesh. The periodic tiling example uses the margin, and the documentation describes the optimization. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.rst | 12 + docs/source/cli/domain.rst | 3 +- docs/source/cli/settings.rst | 19 +- docs/source/examples/cli/periodic_2d.rst | 5 +- .../examples/package/periodic_tiling.rst | 6 + src/microstructpy/cli.py | 61 ++- src/microstructpy/examples/periodic_tiling.py | 20 +- src/microstructpy/meshing/polymesh.py | 400 ++++++++++++++---- tests/cli/test_periodic_cli.py | 32 +- tests/meshing/test_edge_opt_pieces.py | 266 ++++++++++++ 10 files changed, 709 insertions(+), 115 deletions(-) create mode 100644 tests/meshing/test_edge_opt_pieces.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 98374f8c..e4fb7442 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -46,6 +46,18 @@ Added it by less, is placed elsewhere, since it would leave a thin piece of its grain on the opposite face and elements much smaller than the target size of the mesh there. ``auto`` uses half the target edge length of the mesh. +- ``PolyMesh.from_seeds(edge_opt=True, periodic_margin=...)``: the edge + optimization of periodic meshes treats the thickness of each piece of a + cell at a periodic face as a feature like an edge, and moves the seeds of + the pieces thinner than the margin (and of their neighbors) normal to the + face until the piece reaches the margin or the cell no longer crosses the + face. The CLI passes its ``periodic_margin`` setting on. A trial of the + optimization is now kept when the shortest feature that it changes gets + longer (for the shortest edge of the mesh, the criterion is unchanged), + a target that does not improve in ``n_iter`` trials is left alone and the + next one is taken, and the seeds moved across a periodic face are wrapped + back into the domain. The CLI writes and plots the seeds again after the + optimization, so that the seed files match the polygonal mesh. Fixed ''''' diff --git a/docs/source/cli/domain.rst b/docs/source/cli/domain.rst index f5f04440..8ccbaf9b 100644 --- a/docs/source/cli/domain.rst +++ b/docs/source/cli/domain.rst @@ -340,7 +340,8 @@ matching order. A seed that barely crosses a periodic face, or ends just inside it, leaves a thin piece of its grain on the opposite face and very small elements there; -the ``periodic_margin`` setting rejects such positions (see +the ``periodic_margin`` setting rejects such positions and, with +``edge_opt``, moves the seeds of the cells that still leave such pieces (see :ref:`cli_settings`). The mesher must be Triangle/TetGen for periodic microstructures (with gmsh the diff --git a/docs/source/cli/settings.rst b/docs/source/cli/settings.rst index adc32ee9..7090c5ed 100644 --- a/docs/source/cli/settings.rst +++ b/docs/source/cli/settings.rst @@ -277,11 +277,23 @@ This optimization process, however, will increase the time to generate a polygonal mesh. To track the progress of the optimizer, set ``verbose`` to ``True``. +In a periodic domain (see :ref:`cli_domain`), the cells that cross a periodic +face are split into pieces, and a cell that barely crosses a face leaves a +thin piece on the opposite face, which forces very small elements there. +With ``edge_opt`` and a positive ``periodic_margin``, the optimizer also +treats the thickness of each piece at a periodic face as a feature of the +mesh, like an edge, and moves the seeds of every piece thinner than the +margin (and of its neighbors) normal to the face, until the piece is at least +as thick as the margin or the cell no longer crosses the face. +A change is kept when the shortest feature it modifies gets longer, so the +shorter features of the mesh never get worse. + edge_opt_n_iter --------------- This field specifies how many times the optimizer should attempt to increase -the length of the shortest edge in the polygonal mesh. +the length of the shortest edge in the polygonal mesh (or the thickness of a +piece at a periodic face). The default is `` 100 ``, which limits the optimizer to 100 attempts per edge. This field is ignored if ``edge_opt`` is set to ``False``. @@ -353,6 +365,11 @@ elements much smaller than the target size of the mesh there. The value ``auto`` uses half the target edge length of the mesh, taken from ``mesh_max_edge_length`` or, if it is not set, from ``mesh_max_volume``. A large margin makes the seeds harder to place near the faces. +The margin also applies to the cells: with ``edge_opt``, the optimizer +thickens or removes the pieces of the cells at the periodic faces that are +thinner than the margin, which the placement of the seeds alone cannot +prevent (a cell extends beyond its seed, and its corners can cross a face +by a small amount). The default is `` 0 ``, which turns off the margin. It has no effect on non-periodic domains. diff --git a/docs/source/examples/cli/periodic_2d.rst b/docs/source/examples/cli/periodic_2d.rst index 64d1fe4b..c89a0c99 100644 --- a/docs/source/examples/cli/periodic_2d.rst +++ b/docs/source/examples/cli/periodic_2d.rst @@ -60,7 +60,10 @@ a thin piece of itself on the opposite face and very small elements there. margin of a periodic face or crosses it by less than the margin; ``auto`` sets the margin to half the target edge length of the mesh. The edge optimization removes the shortest edges of the polygonal mesh, for -the same reason. +the same reason, and with the margin it also thickens or removes the pieces +of the grains at the periodic faces that are thinner than the margin, which +the placement of the seeds alone cannot prevent: a grain extends beyond its +seed, and its corners can cross a face by a small amount. The plots are colored by seed number, so that the pieces of a grain on opposite faces of the domain have the same color. diff --git a/docs/source/examples/package/periodic_tiling.rst b/docs/source/examples/package/periodic_tiling.rst index ef83b009..9d98e654 100644 --- a/docs/source/examples/package/periodic_tiling.rst +++ b/docs/source/examples/package/periodic_tiling.rst @@ -26,11 +26,17 @@ The seeds are created with :func:`~microstructpy.seeding.SeedList.from_info` and positioned with :func:`~microstructpy.seeding.SeedList.position`, with ``periodic=True``: a seed that crosses a face of the domain is also checked for overlaps on the opposite face. +The ``periodic_margin`` keeps the seeds from ending within half a target +edge length of a face, or crossing one by less, since such seeds leave thin +pieces of grains on the opposite face and very small triangles there. The polygonal mesh is created with :func:`~microstructpy.meshing.PolyMesh.from_seeds`, again with ``periodic=True``, and the triangular mesh with :func:`~microstructpy.meshing.TriMesh.from_polymesh`, which reads the periodicity from the polygonal mesh. +The edge optimization of the polygonal mesh (``edge_opt``) lengthens its +shortest edges and, with the same margin, thickens or removes the pieces of +the grains at the faces that are thinner than the margin. The periodicity can be restricted to some axes, for example ``periodic='x'``. diff --git a/src/microstructpy/cli.py b/src/microstructpy/cli.py index a1297f6e..2119095c 100644 --- a/src/microstructpy/cli.py +++ b/src/microstructpy/cli.py @@ -369,10 +369,12 @@ def run(phases, domain, verbose=False, restart=True, directory='.', edge length in the PolyMesh. The seeds associated with the shortest edge are displaced randomly to find improvement and this process iterates until `n_iter` attempts have been made - for a given edge. Defaults to False. + for a given edge. In periodic domains with a `periodic_margin`, + the pieces of the cells at the periodic faces that are thinner + than the margin are optimized too. Defaults to False. edge_opt_n_iter (int): *(optional)* Maximum number of iterations per - edge during optimization. Ignored if `edge_opt` set to False. - Defaults to 100. + edge (or thin piece) during optimization. Ignored if `edge_opt` + set to False. Defaults to 100. mesher (str): {'raster' | 'Triangle/TetGen' | 'Triangle' | 'TetGen' | 'gmsh'} specify the mesh generator. Default is 'Triangle/TetGen'. @@ -426,7 +428,9 @@ def run(phases, domain, verbose=False, restart=True, directory='.', ``'auto'`` uses half the target edge length of the mesh (``mesh_max_edge_length`` or, from ``mesh_max_volume``, the edge of the equilateral triangle or regular tetrahedron of that - volume). Defaults to 0 (no margin). + volume). With `edge_opt`, the pieces of the cells at the + periodic faces thinner than the margin are thickened or + removed by moving their seeds. Defaults to 0 (no margin). .. _`Specifying Colors`: https://matplotlib.org/users/colors.html .. _`Choosing Colormaps in Matplotlib`: https://matplotlib.org/tutorials/colors/colormaps.html @@ -491,6 +495,8 @@ def run(phases, domain, verbose=False, restart=True, directory='.', # ----------------------------------------------------------------------- # seed_basename = 'seeds.txt' seed_filename = os.path.join(directory, seed_basename) + margin = _periodic_margin(periodic_margin, domain.n_dim, + mesh_max_volume, mesh_max_edge_length) if restart and os.path.exists(seed_filename): # Read seeds from file if verbose: @@ -513,8 +519,6 @@ def run(phases, domain, verbose=False, restart=True, directory='.', kw = 'position' rng_seed = rng_seeds.get(kw, 0) pos_dists = {i: p[kw] for i, p in enumerate(phases) if kw in p} - margin = _periodic_margin(periodic_margin, domain.n_dim, - mesh_max_volume, mesh_max_edge_length) seeds.position(domain, pos_dists, rng_seed, rtol=rtol, verbose=verbose, periodic=periodic, periodic_margin=margin) @@ -522,10 +526,14 @@ def run(phases, domain, verbose=False, restart=True, directory='.', seeds_types = filetypes.get('seeds', []) if not isinstance(seeds_types, list): seeds_types = [seeds_types] - for seeds_type in seeds_types: - fname = os.path.splitext(seed_filename)[0] + '.' + seeds_type - if seeds_created or not os.path.exists(fname): - seeds.write(fname, format=seeds_type) + + def write_seeds(force=False): + for seeds_type in seeds_types: + fname = os.path.splitext(seed_filename)[0] + '.' + seeds_type + if force or seeds_created or not os.path.exists(fname): + seeds.write(fname, format=seeds_type) + + write_seeds() # ----------------------------------------------------------------------- # # Plot Seeds # @@ -536,18 +544,21 @@ def run(phases, domain, verbose=False, restart=True, directory='.', elif type(plot_types) is not list: plot_types = [plot_types] - plot_files = [] - for ext in plot_types: - fname = os.path.join(directory, 'seeds.' + str(ext)) - if seeds_created or not os.path.exists(fname): - plot_files.append(fname) + def plot_seed_files(force=False): + plot_files = [] + for ext in plot_types: + fname = os.path.join(directory, 'seeds.' + str(ext)) + if force or seeds_created or not os.path.exists(fname): + plot_files.append(fname) - if plot_files and verbose: - print('Plotting seeds.') + if plot_files and verbose: + print('Plotting seeds.') - if plot_files: - plot_seeds(seeds, phases, domain, plot_files, plot_axes, color_by, - colormap, **seeds_kwargs) + if plot_files: + plot_seeds(seeds, phases, domain, plot_files, plot_axes, color_by, + colormap, **seeds_kwargs) + + plot_seed_files() # ----------------------------------------------------------------------- # # Create Polygon Mesh # @@ -570,7 +581,15 @@ def run(phases, domain, verbose=False, restart=True, directory='.', print('Creating polygon mesh.') pmesh = PolyMesh.from_seeds(seeds, domain, edge_opt, edge_opt_n_iter, - verbose, periodic=periodic) + verbose, periodic=periodic, + periodic_margin=margin) + if edge_opt: + # the optimization moved seeds: their files and plots are + # updated to the seeds that produce the polygon mesh + if verbose: + print('Updating the seeds moved by the edge optimization.') + write_seeds(force=True) + plot_seed_files(force=True) # Write polymesh poly_types = filetypes.get('poly', []) diff --git a/src/microstructpy/examples/periodic_tiling.py b/src/microstructpy/examples/periodic_tiling.py index 93e6e559..ed1470bb 100644 --- a/src/microstructpy/examples/periodic_tiling.py +++ b/src/microstructpy/examples/periodic_tiling.py @@ -29,19 +29,29 @@ ] # Create seeds and position them, periodic in x and y (the seeds fill 90% -# of the area, so that all of them can be placed) +# of the area, so that all of them can be placed). The margin keeps the +# seeds from ending within half a target edge length of a periodic face, +# or crossing one by less, which would leave thin pieces of grains on the +# opposite face and very small triangles there. +max_volume = 0.004 +h_target = np.sqrt(4 * max_volume / np.sqrt(3)) +margin = 0.5 * h_target seeds_2d = msp.seeding.SeedList.from_info(phases_2d, 0.9 * domain_2d.area, rng_seeds={'size': 1}) -seeds_2d.position(domain_2d, rng_seed=1, periodic=True) +seeds_2d.position(domain_2d, rng_seed=1, periodic=True, + periodic_margin=margin) # Create the polygonal and triangular meshes. The edge optimization moves # the seeds slightly to remove the shortest edges of the polygonal mesh, -# which would otherwise force very small triangles in the mesh. +# which would otherwise force very small triangles in the mesh; with the +# margin, it also thickens or removes the pieces of the grains at the +# periodic faces that are thinner than the margin. pmesh_2d = msp.meshing.PolyMesh.from_seeds(seeds_2d, domain_2d, periodic=True, edge_opt=True, - n_iter=25) + n_iter=25, periodic_margin=margin) tmesh_2d = msp.meshing.TriMesh.from_polymesh(pmesh_2d, phases_2d, - min_angle=25, max_volume=0.004) + min_angle=25, + max_volume=max_volume) # Plot the tiled polygonal mesh, with each grain in one color, and the # tiled triangular mesh, with the matching nodes on the periodic faces diff --git a/src/microstructpy/meshing/polymesh.py b/src/microstructpy/meshing/polymesh.py index f86a086b..079b78af 100644 --- a/src/microstructpy/meshing/polymesh.py +++ b/src/microstructpy/meshing/polymesh.py @@ -558,7 +558,7 @@ def from_file(cls, filename): # ----------------------------------------------------------------------- # @classmethod def from_seeds(cls, seedlist, domain, edge_opt=False, n_iter=100, - verbose=False, periodic=False): + verbose=False, periodic=False, periodic_margin=0.0): """Create from :class:`.SeedList` and a domain. This function creates a polygon/polyhedron mesh from a seed list and @@ -574,6 +574,11 @@ def from_seeds(cls, seedlist, domain, edge_opt=False, n_iter=100, in the polygonal/polyhedral mesh. Short edges cause numerical issues in finite element analysis - setting `edge_opt` to True can improve mesh quality with minimal changes to the microstructure. + In periodic meshes, the cells that cross a periodic face are split + into pieces, and a piece that is thin (the cell barely crosses the + face) forces very small elements: with a positive `periodic_margin`, + the optimization also thickens or removes the pieces thinner than + the margin. Args: seedlist (SeedList): A list of seeds in the microstructure. @@ -583,10 +588,15 @@ def from_seeds(cls, seedlist, domain, edge_opt=False, n_iter=100, edge length in the PolyMesh. The seeds associated with the shortest edge are displaced randomly to find improvement and this process iterates until `n_iter` attempts have been made - for a given edge. Defaults to False. + for a given edge. A trial is kept when the shortest feature + that it changes (an edge, or the thickness of a piece at a + periodic face) gets longer: the features that it creates + are all longer than the shortest one that it removes. The + accepted displacements are applied to `seedlist`. Defaults + to False. n_iter (int): *(optional)* Maximum number of iterations per edge - during optimization. Ignored if `edge_opt` set to False. - Defaults to 100. + (or per thin piece) during optimization. Ignored if + `edge_opt` set to False. Defaults to 100. verbose (bool): *(optional)* Print status of edge optimization to screen. Defaults to False. periodic (bool, list, or str): *(optional)* Periodicity of the @@ -598,6 +608,14 @@ def from_seeds(cls, seedlist, domain, edge_opt=False, n_iter=100, and the points and facets on opposite faces are paired (see ``periodic_points`` and ``periodic_facets``). Defaults to False. + periodic_margin (float): *(optional)* With `edge_opt`, the + minimum thickness of the pieces of the cells at the + periodic faces (their extent normal to the face). The seeds + of a thinner piece and of its neighbors are moved, normal + to the face, until the piece is at least this thick or the + cell no longer crosses the face. Ignored if `edge_opt` is + False or the mesh is not periodic. Defaults to 0 (only the + shortest edge is optimized). Returns: PolyMesh: A polygon/polyhedron mesh. @@ -852,84 +870,10 @@ def from_seeds(cls, seedlist, domain, edge_opt=False, n_iter=100, if is_periodic: pmesh._set_periodic_pairs(per_axes, dom_lims) - # short edge optimization + # short edge (and thin periodic piece) optimization if edge_opt: - # Find the shortest edge - edge_lens = _edge_lengths(pmesh) - min_edge = _shortest_edge(edge_lens) - min_len = edge_lens[min_edge]['length'] - - # Format verbose print string - n_kps = len(pmesh.points) - n_kp_space = int(np.log10(n_kps)) + 1 - n_iter_space = int(np.log10(n_iter)) - v_fmt = 'min length: {0:.3e} | ' - v_fmt += 'edge: {1[0]:' + str(n_kp_space) + 'd}, ' - v_fmt += '{1[1]:' + str(n_kp_space) + 'd} | ' - v_fmt += 'n iter: {2:' + str(n_iter_space) + 'd} / ' - v_fmt += str(n_iter) - - i_n_attempts = 0 - while i_n_attempts < n_iter: - if verbose: - print(v_fmt.format(min_len, min_edge, i_n_attempts)) - - # Seeds adjacent to the shortest edge - e_regions = [r for r in edge_lens[min_edge]['regions'] - if r >= 0] - e_seeds = sorted({int(pmesh.seed_numbers[r]) - for r in e_regions}) - edge_pts = np.array(pmesh.points)[list(min_edge)] - - # Displace the seeds rigidly, on a copy of the seed list. - # The step is a random fraction of 0.1 x the equivalent - # radius of the seed, normal to the edge. - trial_seeds = copy.deepcopy(seedlist) - steps = {} - for seed_num in e_seeds: - seed = seedlist[seed_num] - pos = np.array(seed.position, dtype='float') - with np.errstate(divide='ignore', invalid='ignore'): - e_norm_vec = _point_line_vec(pos, edge_pts) - if not np.all(np.isfinite(e_norm_vec)): - continue - if n_dim == 2: - r_eq = np.sqrt(seed.volume / np.pi) - else: - r_eq = np.cbrt(3 * seed.volume / (4 * np.pi)) - step_frac = 2 * np.random.rand() - 1 # [-1, 1] - steps[seed_num] = 0.1 * step_frac * r_eq * e_norm_vec - _displace_seed(trial_seeds[seed_num], steps[seed_num]) - - # Create New Polygonal Mesh - try: - new_pmesh = cls.from_seeds(trial_seeds, domain, - edge_opt=False, - periodic=periodic) - except AssertionError: - i_n_attempts += 1 - continue - - new_edge_lens = _edge_lengths(new_pmesh) - new_min_edge = _shortest_edge(new_edge_lens) - new_min_len = new_edge_lens[new_min_edge]['length'] - - if new_min_len > min_len: - # Accept the trial: apply the same displacements to the - # caller's seeds, so that they reproduce the new mesh - for seed_num, step in steps.items(): - _displace_seed(seedlist[seed_num], step) - - if new_min_edge != min_edge: - i_n_attempts = 0 - else: - i_n_attempts += 1 - edge_lens = new_edge_lens - pmesh = new_pmesh - min_len = new_min_len - min_edge = new_min_edge - else: - i_n_attempts += 1 + pmesh = _optimize_features(cls, pmesh, seedlist, domain, n_iter, + verbose, periodic, periodic_margin) return pmesh # ----------------------------------------------------------------------- # @@ -2312,13 +2256,301 @@ def _point_line_vec(pt, line_pts): return u_vec -def _displace_seed(seed, step): +def _displace_seed(seed, step, dom_lims=None, per_axes=None): """Translate a seed rigidly by ``step``. The position setter of the seed translates the geometry and the - breakdown along with the position. + breakdown along with the position. Along the periodic axes (when + ``dom_lims`` and ``per_axes`` are given), the position is wrapped back + into the domain: a seed translated by the period is the same seed. """ if isinstance(seed.breakdown, tuple): seed.breakdown = [list(b) for b in seed.breakdown] pos = np.array(seed.position, dtype='float') - seed.position = (pos + np.array(step, dtype='float')).tolist() + pos += np.array(step, dtype='float') + if dom_lims is not None: + for axis, flag in enumerate(per_axes): + if flag: + lb, ub = dom_lims[axis] + pos[axis] = lb + (pos[axis] - lb) % (ub - lb) + seed.position = pos.tolist() + + +# --------------------------------------------------------------------------- # +# Edge / thin piece optimization # +# --------------------------------------------------------------------------- # +_MAX_OPT_TRIALS_PER_ITER = 50 # safety cap: total trials <= this * n_iter + + +def _optimize_features(cls, pmesh, seedlist, domain, n_iter, verbose, + periodic, periodic_margin): + """Lengthen the shortest features of a mesh by moving its seeds. + + The features are the edges of the mesh and, in periodic meshes, the + thicknesses of the pieces of the cells at the periodic faces. The + target is the shortest feature or, with a positive margin, the + thinnest piece under the margin. The seeds around the target are + displaced on a copy of the seed list and the trial is kept when the + shortest feature that it changes gets longer: every feature that it + creates is longer than the shortest one that it removes (for the + shortest edge of the mesh, this is the usual criterion that the + shortest edge gets longer). A target that does not improve in + ``n_iter`` consecutive trials is left alone and the next one is taken; + the optimization ends when no target is left. The accepted + displacements are applied to ``seedlist``. + + Returns: + PolyMesh: The optimized mesh. + + """ + n_dim = domain.n_dim + per_axes = _misc.periodic_axes(periodic, n_dim) + dom_lims = None + if any(per_axes): + dom_lims = _misc.periodic_domain_limits(domain) + scale = max([ub - lb for lb, ub in domain.limits]) + tol = 1e-9 * scale + + features = _mesh_features(pmesh, per_axes, dom_lims, scale) + n_kp_space = int(np.log10(max(len(pmesh.points), 1))) + 1 + n_iter_space = int(np.log10(max(n_iter, 1))) + 1 + + stuck = set() + last_key = None + n_attempts = 0 + n_trials = 0 + max_trials = _MAX_OPT_TRIALS_PER_ITER * n_iter + while n_trials < max_trials: + target = _select_target(features, periodic_margin, stuck) + if target is None: + break + if target['key'] != last_key: + last_key = target['key'] + n_attempts = 0 + if verbose: + print(_target_string(target, n_attempts, n_iter, n_kp_space, + n_iter_space)) + + steps = _trial_steps(target, seedlist, n_attempts, periodic_margin, + dom_lims, per_axes, n_dim) + n_trials += 1 + accepted = False + if steps: + trial_seeds = copy.deepcopy(seedlist) + for seed_num, step in steps.items(): + _displace_seed(trial_seeds[seed_num], step, dom_lims, + per_axes) + try: + new_pmesh = cls.from_seeds(trial_seeds, domain, + edge_opt=False, periodic=periodic) + except (AssertionError, ValueError): + new_pmesh = None + if new_pmesh is not None: + new_features = _mesh_features(new_pmesh, per_axes, dom_lims, + scale) + accepted = _accept_trial(new_features, features, tol) + + if accepted: + # apply the same displacements to the caller's seeds, so that + # they reproduce the new mesh + for seed_num, step in steps.items(): + _displace_seed(seedlist[seed_num], step, dom_lims, per_axes) + pmesh = new_pmesh + features = new_features + n_attempts = 0 + else: + n_attempts += 1 + if n_attempts >= n_iter or not steps: + stuck.add(target['key']) + return pmesh + + +def _mesh_features(pmesh, per_axes, dom_lims, scale): + """Edges of the mesh and pieces of the cells at the periodic faces. + + Each feature is a dict with its ``kind`` ('edge' or 'piece'), ``size`` + (length or thickness normal to the face), a ``key`` that identifies it + geometrically across re-tessellations and the ``seeds`` around it. + """ + pts = np.array(pmesh.points, dtype='float') + seed_nums = [int(s) for s in pmesh.seed_numbers] + features = [] + edge_lens = _edge_lengths(pmesh) + for (kp1, kp2), info in edge_lens.items(): + regions = [r for r in info['regions'] if r >= 0] + mid = 0.5 * (pts[kp1] + pts[kp2]) + features.append({ + 'kind': 'edge', + 'size': info['length'], + 'key': ('edge',) + tuple(np.round(mid / scale, 6)), + 'seeds': sorted({seed_nums[r] for r in regions}), + 'kps': (kp1, kp2), + 'pts': pts[[kp1, kp2]], + }) + if dom_lims is None: + return features + + for r, region in enumerate(pmesh.regions): + walls = set() + neighs = set() + for f in region: + for n in pmesh.facet_neighbors[f]: + if n < 0: + walls.add(n) + elif n != r: + neighs.add(seed_nums[n]) + if not walls: + continue + kps = sorted({kp for f in region for kp in pmesh.facets[f]}) + cen = pts[kps].mean(axis=0) + for wall in sorted(walls): + axis, side = divmod(-wall - 1, 2) + if not per_axes[axis]: + continue + # the region touches the wall: its extent normal to the wall + # is its thickness + features.append({ + 'kind': 'piece', + 'size': np.ptp(pts[kps, axis]), + 'key': ('piece', seed_nums[r], axis, side) + + tuple(np.round(cen / scale, 6)), + 'seeds': sorted(neighs | {seed_nums[r]}), + 'seed': seed_nums[r], + 'axis': axis, + 'side': side, + }) + return features + + +def _select_target(features, margin, stuck): + """The shortest feature, or the thinnest piece under the margin, that + is not stuck; None when there is no such target.""" + sizes = np.array([f['size'] for f in features]) + order = np.argsort(sizes, kind='stable') + cands = [order[0]] + cands += [i for i in order + if features[i]['kind'] == 'piece' and sizes[i] < margin] + for i in cands: + if features[i]['key'] not in stuck: + return features[i] + return None + + +def _trial_steps(target, seedlist, n_attempts, margin, dom_lims, per_axes, + n_dim): + """Displacements of the seeds around the target for one trial. + + Edge: each seed is moved normal to the edge by a random fraction of + 0.1 times its equivalent radius. Piece: the first trial pushes the + cell of the piece so that the piece reaches the margin, the second + retracts it so that the cell ends a margin inside the face (the cell + boundary moves by about half the displacement of the seed), and the + others move the seed of the piece normal to the face, and the seeds + of its neighbors along their lines to the seed of the piece (normal to + their facets with it), by random fractions of the margin or of 0.1 + times their equivalent radii, whichever is larger. + """ + steps = {} + if target['kind'] == 'piece': + axis = target['axis'] + grow = np.zeros(n_dim) + grow[axis] = 1.0 if target['side'] == 0 else -1.0 + thickness = target['size'] + if n_attempts == 0 and margin > thickness: + steps[target['seed']] = 2.2 * (margin - thickness) * grow + return steps + if n_attempts == 1: + steps[target['seed']] = -2 * (thickness + margin) * grow + return steps + pos_piece = np.array(seedlist[target['seed']].position, + dtype='float') + + for seed_num in target['seeds']: + seed = seedlist[seed_num] + pos = np.array(seed.position, dtype='float') + if n_dim == 2: + r_eq = np.sqrt(seed.volume / np.pi) + else: + r_eq = np.cbrt(3 * seed.volume / (4 * np.pi)) + step_max = 0.1 * r_eq + if target['kind'] == 'piece': + step_max = max(step_max, margin) + if seed_num == target['seed']: + u_vec = grow + else: + ref = _nearest_image(pos_piece.reshape(1, -1), pos, + dom_lims, per_axes)[0] + u_vec = ref - pos + if np.linalg.norm(u_vec) == 0: + continue + u_vec /= np.linalg.norm(u_vec) + else: + edge_pts = target['pts'] + if dom_lims is not None: + edge_pts = _nearest_image(edge_pts, pos, dom_lims, per_axes) + with np.errstate(divide='ignore', invalid='ignore'): + u_vec = _point_line_vec(pos, edge_pts) + if not np.all(np.isfinite(u_vec)): + continue + step_frac = 2 * np.random.rand() - 1 # [-1, 1] + steps[seed_num] = step_frac * step_max * u_vec + return steps + + +def _nearest_image(pts, ref, dom_lims, per_axes): + """Translate ``pts`` (as a whole) by periods so that their center is + closest to ``ref``.""" + pts = np.array(pts, dtype='float') + cen = pts.mean(axis=0) + shift = np.zeros(len(ref)) + for axis, flag in enumerate(per_axes): + if flag: + length = dom_lims[axis][1] - dom_lims[axis][0] + shift[axis] = length * np.round((ref[axis] - cen[axis]) / length) + return pts + shift + + +def _accept_trial(new_features, old_features, tol): + """Whether a trial improves the features that it changes: the shortest + feature that it creates is longer than the shortest one that it + removes (the features are matched by their keys, and a removed and a + created feature of the same size cancel out).""" + old_keys = set([f['key'] for f in old_features]) + new_keys = set([f['key'] for f in new_features]) + removed = sorted([f['size'] for f in old_features + if f['key'] not in new_keys]) + added = sorted([f['size'] for f in new_features + if f['key'] not in old_keys]) + i = j = 0 + kept_removed = [] + kept_added = [] + while i < len(removed) and j < len(added): + if abs(removed[i] - added[j]) <= tol: + i += 1 + j += 1 + elif removed[i] < added[j]: + kept_removed.append(removed[i]) + i += 1 + else: + kept_added.append(added[j]) + j += 1 + kept_removed += removed[i:] + kept_added += added[j:] + if not kept_removed: + return False + if not kept_added: + return True + return kept_added[0] > kept_removed[0] + tol + + +def _target_string(target, n_attempts, n_iter, n_kp_space, n_iter_space): + if target['kind'] == 'edge': + kp_fmt = '{0:' + str(n_kp_space) + 'd}' + s = 'min length: {0:.3e} | edge: '.format(target['size']) + s += ', '.join([kp_fmt.format(kp) for kp in target['kps']]) + else: + face = 'xyz'[target['axis']] + '-+'[target['side']] + s = 'thickness: {0:.3e} | piece: seed {1:d}, face {2}' + s = s.format(target['size'], target['seed'], face) + s += ' | n iter: {0:' + str(n_iter_space) + 'd} / {1:d}' + return s.format(n_attempts, n_iter) diff --git a/tests/cli/test_periodic_cli.py b/tests/cli/test_periodic_cli.py index a610a3b8..6b33e920 100644 --- a/tests/cli/test_periodic_cli.py +++ b/tests/cli/test_periodic_cli.py @@ -4,9 +4,11 @@ import numpy as np import pytest +import microstructpy as msp from microstructpy import cli from microstructpy.meshing import PolyMesh from microstructpy.meshing import TriMesh +from microstructpy.seeding import SeedList PERIODIC_XML = """ @@ -46,16 +48,17 @@ 20 0.1 True + {extra} """ -def _run(tmp_path, periodic): +def _run(tmp_path, periodic, extra=''): out_dir = tmp_path / 'out' xml = tmp_path / 'input.xml' xml.write_text(PERIODIC_XML.format(periodic=periodic, - directory=str(out_dir))) + directory=str(out_dir), extra=extra)) cli.run_file(str(xml)) return out_dir @@ -98,6 +101,31 @@ def test_periodic_run_single_axis(tmp_path): assert list(tmesh.periodic_nodes) == [1] +def test_periodic_margin_edge_opt(tmp_path): + # the margin is passed to the edge optimization: no piece thinner than + # the margin (half of mesh_max_edge_length) is left at the faces, + # unless the optimizer could not fix it, and the mesh is periodic + extra = ' True \n 3 ' + extra += '\n auto ' + out_dir = _run(tmp_path, 'xy', extra) + pmesh = PolyMesh.from_file(str(out_dir / 'polymesh.txt')) + tmesh = TriMesh.from_file(str(out_dir / 'trimesh.txt')) + assert pmesh.periodic_axes == [True, True] + assert np.isclose(sum(pmesh.volumes), 9.0) + pts = np.array(tmesh.points) + for axis in (0, 1): + for lo, hi in tmesh.periodic_nodes[axis]: + shift = np.zeros(2) + shift[axis] = 3 + assert np.array_equal(pts[hi], pts[lo] + shift) + # the seeds written are the optimized ones: they reproduce the mesh + seeds = SeedList.from_file(str(out_dir / 'seeds.txt')) + domain = msp.geometry.Square(side_length=3, corner=(0, 0)) + pmesh_re = PolyMesh.from_seeds(seeds, domain, periodic=True) + assert np.allclose(np.sort(pmesh_re.volumes), np.sort(pmesh.volumes), + rtol=0, atol=1e-9) + + def test_periodic_margin_setting(): inf = float('inf') h_2d = np.sqrt(4 * 0.004 / np.sqrt(3)) diff --git a/tests/meshing/test_edge_opt_pieces.py b/tests/meshing/test_edge_opt_pieces.py new file mode 100644 index 00000000..3f3da5db --- /dev/null +++ b/tests/meshing/test_edge_opt_pieces.py @@ -0,0 +1,266 @@ +"""Tests of the edge optimization with thin pieces at the periodic faces.""" +import copy + +import numpy as np + +from microstructpy import geometry +from microstructpy.meshing import PolyMesh +from microstructpy.meshing.polymesh import _accept_trial +from microstructpy.meshing.polymesh import _displace_seed +from microstructpy.meshing.polymesh import _edge_lengths +from microstructpy.meshing.polymesh import _mesh_features +from microstructpy.meshing.polymesh import _nearest_image +from microstructpy.meshing.polymesh import _select_target +from microstructpy.seeding import Seed +from microstructpy.seeding import SeedList + + +# --------------------------------------------------------------------------- # +# Helpers # +# --------------------------------------------------------------------------- # +def _grid_seeds_2d(x_shift, rng_seed=0): + """Circles on a 3 x 3 grid (spacing 1, at 0.75, 1.75, 2.75) in a + periodic square of side 3: the cells cross the faces by 0.25. The seed + at (0.75, 1.75) is moved to ``x_shift``: its cell then crosses the + face x = 0 by about (0.25 - x_shift) / 2 and leaves a piece that thick + on the face x = 3. The other rows are jittered to avoid degenerate + vertices.""" + rng = np.random.RandomState(rng_seed) + seeds = SeedList() + for i in range(3): + for j in range(3): + x, y = 0.75 + i, 0.75 + j + if (i, j) == (0, 1): + x = x_shift + else: + y += 0.03 * (2 * rng.rand() - 1) + seeds.append(Seed.factory('circle', r=0.2, position=[x, y])) + return seeds + + +def _grid_seeds_3d(x_shift, rng_seed=0): + """Spheres on a 2 x 2 x 2 grid (at 0.75 and 1.75) in a periodic cube + of side 2, with the seed at (0.75, 0.75, 0.75) moved to ``x_shift``.""" + rng = np.random.RandomState(rng_seed) + seeds = SeedList() + for i in range(2): + for j in range(2): + for k in range(2): + x, y, z = 0.75 + i, 0.75 + j, 0.75 + k + if (i, j, k) == (0, 0, 0): + x = x_shift + else: + y += 0.03 * (2 * rng.rand() - 1) + z += 0.03 * (2 * rng.rand() - 1) + seeds.append(Seed.factory('sphere', r=0.2, + position=[x, y, z])) + return seeds + + +def _pieces(pmesh, domain): + n_dim = domain.n_dim + scale = max([ub - lb for lb, ub in domain.limits]) + feats = _mesh_features(pmesh, [True] * n_dim, domain.limits, scale) + return [f for f in feats if f['kind'] == 'piece'] + + +def _min_edge(pmesh): + return min([e['length'] for e in _edge_lengths(pmesh).values()]) + + +def _feature(kind, size, key, seeds=()): + return {'kind': kind, 'size': size, 'key': key, 'seeds': list(seeds)} + + +# --------------------------------------------------------------------------- # +# Features # +# --------------------------------------------------------------------------- # +def test_piece_features_2d(): + domain = geometry.Square(side_length=3, corner=(0, 0)) + seeds = _grid_seeds_2d(0.05) + pmesh = PolyMesh.from_seeds(seeds, domain, periodic=True) + pieces = _pieces(pmesh, domain) + + # the shifted seed (number 1) has a piece about 0.1 thick on the face + # x = 3 (the part of its cell beyond x = 0) and its main part on the + # face x = 0, about 0.9 thick; the other pieces are about 0.25 thick + thin = [p for p in pieces if p['seed'] == 1 and p['axis'] == 0 + and p['side'] == 1] + assert len(thin) == 1 + assert np.isclose(thin[0]['size'], 0.1, atol=0.02) + assert 1 in thin[0]['seeds'] + assert len(thin[0]['seeds']) > 1 # the neighbors across its facets + main = [p for p in pieces if p['seed'] == 1 and p['axis'] == 0 + and p['side'] == 0] + assert len(main) == 1 + assert np.isclose(main[0]['size'], 0.9, atol=0.02) + others = [p['size'] for p in pieces if p['seed'] != 1] + assert np.all(np.array(others) > 0.15) + + # every piece touches its face, and the keys are distinct + pts = np.array(pmesh.points) + lims = np.array(domain.limits) + for p in pieces: + assert p['size'] > 0 + keys = [p['key'] for p in pieces] + assert len(set(keys)) == len(keys) + for f, neighs in enumerate(pmesh.facet_neighbors): + if min(neighs) < 0: + axis, side = divmod(-min(neighs) - 1, 2) + assert np.allclose(pts[pmesh.facets[f], axis], lims[axis, side]) + + # edge features cover every edge of the mesh + scale = 3.0 + feats = _mesh_features(pmesh, [True, True], domain.limits, scale) + n_edges = len([f for f in feats if f['kind'] == 'edge']) + assert n_edges == len(_edge_lengths(pmesh)) + + +def test_no_piece_features_without_periodicity(): + domain = geometry.Square(side_length=3, corner=(0, 0)) + seeds = _grid_seeds_2d(0.05) + pmesh = PolyMesh.from_seeds(seeds, domain) + feats = _mesh_features(pmesh, [False, False], None, 3.0) + assert all([f['kind'] == 'edge' for f in feats]) + # pieces on the periodic axes only + pmesh_x = PolyMesh.from_seeds(seeds, domain, periodic='x') + feats = _mesh_features(pmesh_x, [True, False], domain.limits, 3.0) + assert all([f['axis'] == 0 for f in feats if f['kind'] == 'piece']) + + +# --------------------------------------------------------------------------- # +# Acceptance and target selection # +# --------------------------------------------------------------------------- # +def test_accept_trial(): + old = [_feature('edge', 0.5, 'a'), _feature('edge', 0.02, 'b'), + _feature('piece', 0.01, 'c'), _feature('edge', 0.3, 'd')] + # the thin piece is replaced by longer features: accepted + new = [_feature('edge', 0.5, 'a'), _feature('edge', 0.02, 'b'), + _feature('piece', 0.05, 'e'), _feature('edge', 0.25, 'f')] + assert _accept_trial(new, old, 1e-9) + # a feature shorter than the removed ones appears: rejected + new = [_feature('edge', 0.5, 'a'), _feature('edge', 0.02, 'b'), + _feature('piece', 0.05, 'e'), _feature('edge', 0.005, 'f')] + assert not _accept_trial(new, old, 1e-9) + # nothing changes: rejected + assert not _accept_trial(copy.deepcopy(old), old, 1e-9) + # a feature is only removed: accepted; only added: rejected + assert _accept_trial(old[:3], old, 1e-9) + assert not _accept_trial(old + [_feature('edge', 0.9, 'g')], old, 1e-9) + # a removed and an added feature of the same size cancel out, so the + # comparison is between the piece (0.01) and the new edge (0.015) + new = [_feature('edge', 0.5, 'a'), _feature('edge', 0.02, 'b2'), + _feature('edge', 0.015, 'e'), _feature('edge', 0.3, 'd')] + assert _accept_trial(new, old, 1e-9) + new[2]['size'] = 0.009 + assert not _accept_trial(new, old, 1e-9) + + +def test_select_target(): + feats = [_feature('edge', 0.5, 'a'), _feature('edge', 0.02, 'b'), + _feature('piece', 0.03, 'c'), _feature('piece', 0.08, 'd'), + _feature('piece', 0.2, 'e')] + # without a margin: the shortest feature only + assert _select_target(feats, 0.0, set())['key'] == 'b' + assert _select_target(feats, 0.0, {'b'}) is None + # with a margin: the shortest feature, then the pieces under the + # margin from the thinnest + assert _select_target(feats, 0.1, set())['key'] == 'b' + assert _select_target(feats, 0.1, {'b'})['key'] == 'c' + assert _select_target(feats, 0.1, {'b', 'c'})['key'] == 'd' + assert _select_target(feats, 0.1, {'b', 'c', 'd'}) is None + # a piece that is the shortest feature is the target even without a + # margin + feats[2]['size'] = 0.01 + assert _select_target(feats, 0.0, set())['key'] == 'c' + + +def test_displace_seed_wraps(): + seed = Seed.factory('circle', r=0.2, position=[2.9, 1.0]) + seed.update_breakdown() + dom_lims = [(0.0, 3.0), (0.0, 3.0)] + _displace_seed(seed, [0.3, -1.2], dom_lims, [True, False]) + assert np.allclose(seed.position, [0.2, -0.2]) + assert np.allclose(seed.geometry.center, seed.position) + assert np.allclose(seed.breakdown[0][:-1], seed.position) + # without periodicity, no wrapping + _displace_seed(seed, [3.0, 0.0]) + assert np.allclose(seed.position, [3.2, -0.2]) + + +def test_nearest_image(): + dom_lims = [(0.0, 3.0), (0.0, 3.0)] + pts = np.array([[2.9, 1.0], [2.8, 1.5]]) + ref = np.array([0.1, 1.2]) + image = _nearest_image(pts, ref, dom_lims, [True, True]) + assert np.allclose(image, [[-0.1, 1.0], [-0.2, 1.5]]) + image = _nearest_image(pts, ref, dom_lims, [False, True]) + assert np.allclose(image, pts) + + +# --------------------------------------------------------------------------- # +# Optimization # +# --------------------------------------------------------------------------- # +def test_edge_opt_fixes_thin_piece_2d(): + np.random.seed(0) + domain = geometry.Square(side_length=3, corner=(0, 0)) + margin = 0.1 + seeds = _grid_seeds_2d(0.2) + seeds_orig = copy.deepcopy(seeds) + pmesh_0 = PolyMesh.from_seeds(seeds, domain, periodic=True) + thin_0 = [p['size'] for p in _pieces(pmesh_0, domain) + if p['size'] < margin] + assert len(thin_0) == 1 + assert np.isclose(thin_0[0], 0.025, atol=0.01) + min_edge_0 = _min_edge(pmesh_0) + + pmesh = PolyMesh.from_seeds(seeds, domain, periodic=True, edge_opt=True, + n_iter=2, periodic_margin=margin) + + # no piece thinner than the margin is left, and the shortest edge of + # the mesh did not get shorter + thin = [p['size'] for p in _pieces(pmesh, domain) if p['size'] < margin] + assert thin == [] + assert _min_edge(pmesh) >= min_edge_0 - 1e-9 + + # the mesh is periodic and the seeds reproduce it + assert pmesh.periodic_axes == [True, True] + pmesh_re = PolyMesh.from_seeds(seeds, domain, periodic=True) + assert np.allclose(np.sort(pmesh_re.volumes), np.sort(pmesh.volumes), + rtol=0, atol=1e-9) + assert np.isclose(sum(pmesh.volumes), domain.area) + + # the shifted seed moved (into the domain, normal to the face) and + # every seed is in the domain + assert not np.allclose(seeds[1].position, seeds_orig[1].position) + assert np.isclose(seeds[1].position[1], seeds_orig[1].position[1]) + for seed in seeds: + assert np.all(np.array(seed.position) >= 0) + assert np.all(np.array(seed.position) <= 3) + assert np.allclose(seed.geometry.center, seed.position) + + +def test_edge_opt_fixes_thin_piece_3d(): + np.random.seed(0) + domain = geometry.Cube(side_length=2, corner=(0, 0, 0)) + margin = 0.1 + seeds = _grid_seeds_3d(0.2) + pmesh_0 = PolyMesh.from_seeds(seeds, domain, periodic=True) + # the piece of the shifted seed, and a corner of its neighbor along x + # that pokes through the face x = 2 (the bisector with the image of + # the shifted seed is tilted by the jitter) + thin_0 = [p['size'] for p in _pieces(pmesh_0, domain) + if p['size'] < margin] + assert len(thin_0) == 2 + assert min(thin_0) < 0.01 + min_edge_0 = _min_edge(pmesh_0) + + pmesh = PolyMesh.from_seeds(seeds, domain, periodic=True, edge_opt=True, + n_iter=5, periodic_margin=margin) + thin = [p['size'] for p in _pieces(pmesh, domain) if p['size'] < margin] + assert thin == [] + assert _min_edge(pmesh) >= min_edge_0 - 1e-9 + assert np.isclose(sum(pmesh.volumes), domain.volume) + pmesh_re = PolyMesh.from_seeds(seeds, domain, periodic=True) + assert np.allclose(np.sort(pmesh_re.volumes), np.sort(pmesh.volumes), + rtol=0, atol=1e-9) From 294c129fdc1f56b6afc970584c5e2d4f7806b1fc Mon Sep 17 00:00:00 2001 From: Julian Rimoli Date: Wed, 23 Sep 2026 11:01:22 -0700 Subject: [PATCH 24/42] Cap the automatic periodic margin by the size of the smallest seed The 'auto' periodic_margin is now the smaller of half the target edge length of the mesh and an eighth of the size of the smallest seed (its smallest diameter or side): the smallest particle needs about four elements across it, so the mesh cannot be coarser than a quarter of it, and a seed cannot satisfy a margin larger than itself, which rejected the small seeds near the faces at placement. The periodic examples ship with periodic_margin auto and edge_opt (10 iterations in 3D, 25 in 2D), and the tiling example computes the same margin from its seeds. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.rst | 8 ++- docs/source/cli/settings.rst | 10 +++- docs/source/examples/cli/pbx_2d.rst | 9 +++- docs/source/examples/cli/pbx_3d.rst | 6 +++ docs/source/examples/cli/pbx_interface_2d.rst | 6 +++ docs/source/examples/cli/pbx_interface_3d.rst | 6 +++ docs/source/examples/cli/periodic_2d.rst | 3 +- docs/source/examples/cli/periodic_3d.rst | 6 +++ .../examples/package/periodic_tiling.rst | 4 +- src/microstructpy/cli.py | 52 ++++++++++++++----- src/microstructpy/examples/pbx_2D.xml | 4 ++ src/microstructpy/examples/pbx_3D.xml | 7 +++ .../examples/pbx_interface_2D.xml | 4 ++ .../examples/pbx_interface_3D.xml | 7 +++ src/microstructpy/examples/periodic_3D.xml | 7 +++ src/microstructpy/examples/periodic_tiling.py | 8 ++- tests/cli/test_periodic_cli.py | 24 +++++++++ 17 files changed, 149 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index e4fb7442..7401c5e6 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -45,7 +45,10 @@ Added a periodic face. A seed that ends within the margin of a face, or crosses it by less, is placed elsewhere, since it would leave a thin piece of its grain on the opposite face and elements much smaller than the target size - of the mesh there. ``auto`` uses half the target edge length of the mesh. + of the mesh there. ``auto`` uses half the target edge length of the mesh, + or an eighth of the size of the smallest seed if that is smaller (the + smallest seed needs about four elements across it, and cannot satisfy a + margin larger than itself). - ``PolyMesh.from_seeds(edge_opt=True, periodic_margin=...)``: the edge optimization of periodic meshes treats the thickness of each piece of a cell at a periodic face as a feature like an edge, and moves the seeds of @@ -57,7 +60,8 @@ Added a target that does not improve in ``n_iter`` trials is left alone and the next one is taken, and the seeds moved across a periodic face are wrapped back into the domain. The CLI writes and plots the seeds again after the - optimization, so that the seed files match the polygonal mesh. + optimization, so that the seed files match the polygonal mesh. The + periodic examples use ``periodic_margin`` ``auto`` and ``edge_opt``. Fixed ''''' diff --git a/docs/source/cli/settings.rst b/docs/source/cli/settings.rst index 7090c5ed..71bb0443 100644 --- a/docs/source/cli/settings.rst +++ b/docs/source/cli/settings.rst @@ -362,8 +362,14 @@ A position where a seed ends within this distance inside a face, or crosses a face by less than this distance, is rejected and another one is tried: such seeds leave thin pieces of grains on the faces of the domain, and elements much smaller than the target size of the mesh there. -The value ``auto`` uses half the target edge length of the mesh, taken from -``mesh_max_edge_length`` or, if it is not set, from ``mesh_max_volume``. +The value ``auto`` uses the smaller of two lengths: half the target edge +length of the mesh, taken from ``mesh_max_edge_length`` or, if it is not set, +from ``mesh_max_volume``; and an eighth of the size of the smallest seed (its +smallest diameter or side). +The smallest seeds need about four elements across them, so the mesh cannot +be coarser than a quarter of their size, and a seed cannot satisfy a margin +larger than itself: with such a margin, the positions near the faces would +all be rejected and the seeds dropped. A large margin makes the seeds harder to place near the faces. The margin also applies to the cells: with ``edge_opt``, the optimizer thickens or removes the pieces of the cells at the periodic faces that are diff --git a/docs/source/examples/cli/pbx_2d.rst b/docs/source/examples/cli/pbx_2d.rst index 5f95de40..2fa2dd16 100644 --- a/docs/source/examples/cli/pbx_2d.rst +++ b/docs/source/examples/cli/pbx_2d.rst @@ -46,8 +46,13 @@ Settings The overlap tolerance ``rtol`` is set to 0.5: with the default fitted value some of the small binder seeds do not fit between the inclusions and are rejected. -The random number generator seeds make the microstructure repeatable, and -the edge optimization removes the shortest edges of the polygonal mesh. +The random number generator seeds make the microstructure repeatable. + +The seeds are placed a margin away from the periodic faces (``periodic_margin`` +set to ``auto``: half the target edge length, or an eighth of the smallest +grain if that is smaller) and the edge optimization moves the seeds that +leave a thin piece of a grain on a face, or a very short edge, since either +forces very small triangles. The mesh has a minimum angle of 25 degrees and a maximum element area of 0.01. diff --git a/docs/source/examples/cli/pbx_3d.rst b/docs/source/examples/cli/pbx_3d.rst index 9162c97e..2ee63036 100644 --- a/docs/source/examples/cli/pbx_3d.rst +++ b/docs/source/examples/cli/pbx_3d.rst @@ -41,6 +41,12 @@ make the microstructure repeatable. The mesh has a minimum dihedral angle of 15 degrees and a maximum element volume of 0.02. +The seeds are placed a margin away from the periodic faces (``periodic_margin`` +set to ``auto``: half the target edge length, or an eighth of the smallest +grain if that is smaller) and the edge optimization moves the seeds that +leave a thin piece of a grain on a face, or a very short edge, since either +forces very small tetrahedra. + Output Files ============ diff --git a/docs/source/examples/cli/pbx_interface_2d.rst b/docs/source/examples/cli/pbx_interface_2d.rst index 84e64b64..f522d0cd 100644 --- a/docs/source/examples/cli/pbx_interface_2d.rst +++ b/docs/source/examples/cli/pbx_interface_2d.rst @@ -40,6 +40,12 @@ Triangle grades the element size between the two. The mesh has a minimum angle of 25 degrees. +The seeds are placed a margin away from the periodic faces (``periodic_margin`` +set to ``auto``: half the target edge length, or an eighth of the smallest +grain if that is smaller) and the edge optimization moves the seeds that +leave a thin piece of a grain on a face, or a very short edge, since either +forces very small triangles. + Output Files ============ diff --git a/docs/source/examples/cli/pbx_interface_3d.rst b/docs/source/examples/cli/pbx_interface_3d.rst index 2a83c3dd..6d0eac24 100644 --- a/docs/source/examples/cli/pbx_interface_3d.rst +++ b/docs/source/examples/cli/pbx_interface_3d.rst @@ -41,6 +41,12 @@ longer, and TetGen grades the element size between the two. The mesh has a minimum dihedral angle of 15 degrees. +The seeds are placed a margin away from the periodic faces (``periodic_margin`` +set to ``auto``: half the target edge length, or an eighth of the smallest +grain if that is smaller) and the edge optimization moves the seeds that +leave a thin piece of a grain on a face, or a very short edge, since either +forces very small tetrahedra. + Output Files ============ diff --git a/docs/source/examples/cli/periodic_2d.rst b/docs/source/examples/cli/periodic_2d.rst index c89a0c99..b37bbe11 100644 --- a/docs/source/examples/cli/periodic_2d.rst +++ b/docs/source/examples/cli/periodic_2d.rst @@ -58,7 +58,8 @@ A grain that barely crosses a periodic face, or ends just inside it, leaves a thin piece of itself on the opposite face and very small elements there. ``periodic_margin`` rejects the positions where a seed ends within the margin of a periodic face or crosses it by less than the margin; ``auto`` -sets the margin to half the target edge length of the mesh. +sets the margin to half the target edge length of the mesh, or to an eighth +of the smallest grain if that is smaller. The edge optimization removes the shortest edges of the polygonal mesh, for the same reason, and with the margin it also thickens or removes the pieces of the grains at the periodic faces that are thinner than the margin, which diff --git a/docs/source/examples/cli/periodic_3d.rst b/docs/source/examples/cli/periodic_3d.rst index c12db635..2ad91e9f 100644 --- a/docs/source/examples/cli/periodic_3d.rst +++ b/docs/source/examples/cli/periodic_3d.rst @@ -49,6 +49,12 @@ triangulated with the points TetGen added on them, identically on opposite faces, and the mesh is built again with these facets; the quality and size settings act as on a non-periodic mesh. +The seeds are placed a margin away from the periodic faces (``periodic_margin`` +set to ``auto``: half the target edge length, or an eighth of the smallest +grain if that is smaller) and the edge optimization moves the seeds that +leave a thin piece of a grain on a face, or a very short edge, since either +forces very small tetrahedra. + The plots are colored by seed number and the line widths are reduced to make the grains visible. diff --git a/docs/source/examples/package/periodic_tiling.rst b/docs/source/examples/package/periodic_tiling.rst index 9d98e654..dd105707 100644 --- a/docs/source/examples/package/periodic_tiling.rst +++ b/docs/source/examples/package/periodic_tiling.rst @@ -28,7 +28,9 @@ and positioned with :func:`~microstructpy.seeding.SeedList.position`, with for overlaps on the opposite face. The ``periodic_margin`` keeps the seeds from ending within half a target edge length of a face, or crossing one by less, since such seeds leave thin -pieces of grains on the opposite face and very small triangles there. +pieces of grains on the opposite face and very small triangles there; it is +capped at an eighth of the smallest grain, which needs about four elements +across it. The polygonal mesh is created with :func:`~microstructpy.meshing.PolyMesh.from_seeds`, again with ``periodic=True``, and the triangular mesh with diff --git a/src/microstructpy/cli.py b/src/microstructpy/cli.py index 2119095c..dbef5687 100644 --- a/src/microstructpy/cli.py +++ b/src/microstructpy/cli.py @@ -265,12 +265,17 @@ def _include_merge(exp_dict, key, val): 'vtk': '.vtk'} -def _periodic_margin(periodic_margin, n_dim, max_volume, max_edge_length): +def _periodic_margin(periodic_margin, n_dim, max_volume, max_edge_length, + seeds=None): """Margin between the seeds and the periodic faces, from the settings. - ``'auto'`` is half the target edge length of the mesh: the maximum - edge length if it is set, otherwise the edge of the equilateral - triangle (2D) or regular tetrahedron (3D) with the maximum volume. + ``'auto'`` is the smaller of half the target edge length of the mesh + (the maximum edge length if it is set, otherwise the edge of the + equilateral triangle (2D) or regular tetrahedron (3D) with the maximum + volume) and an eighth of the size of the smallest seed (its smallest + diameter or side): the smallest seed needs about four elements across + it, so the mesh cannot be coarser than a quarter of it, and a margin + larger than the seed could not be satisfied by the seed anyway. """ if not isinstance(periodic_margin, str): return float(periodic_margin) @@ -287,11 +292,27 @@ def _periodic_margin(periodic_margin, n_dim, max_volume, max_edge_length): h_val = min(h_val, np.sqrt(4 * max_volume / np.sqrt(3))) else: h_val = min(h_val, (6 * np.sqrt(2) * max_volume) ** (1.0 / 3)) + if seeds: + h_val = min(h_val, 0.25 * min([_seed_size(s) for s in seeds])) if not np.isfinite(h_val): return 0.0 return 0.5 * h_val +def _seed_size(seed): + """Smallest dimension of a seed: the smallest diameter of an ellipse + or ellipsoid, the shortest side of a box, the diameter of a circle or + sphere; infinity if the geometry has none of these.""" + geom = seed.geometry + if hasattr(geom, 'axes'): + return 2 * min(geom.axes) + if hasattr(geom, 'side_lengths'): + return min(geom.side_lengths) + if hasattr(geom, 'r'): + return 2 * geom.r + return getattr(geom, 'size', float('inf')) + + def run(phases, domain, verbose=False, restart=True, directory='.', filetypes=None, rng_seeds=None, plot_axes=True, rtol='fit', edge_opt=False, edge_opt_n_iter=100, mesher='Triangle/TetGen', @@ -425,12 +446,16 @@ def run(phases, domain, verbose=False, restart=True, directory='.', ends within this distance inside a face, or crosses a face by less than this distance, is placed elsewhere, since it would give elements much smaller than the target size of the mesh. - ``'auto'`` uses half the target edge length of the mesh - (``mesh_max_edge_length`` or, from ``mesh_max_volume``, the - edge of the equilateral triangle or regular tetrahedron of that - volume). With `edge_opt`, the pieces of the cells at the - periodic faces thinner than the margin are thickened or - removed by moving their seeds. Defaults to 0 (no margin). + ``'auto'`` uses the smaller of half the target edge length of + the mesh (``mesh_max_edge_length`` or, from + ``mesh_max_volume``, the edge of the equilateral triangle or + regular tetrahedron of that volume) and an eighth of the size + of the smallest seed (its smallest diameter or side), since + the smallest seed needs about four elements across it and + cannot satisfy a margin larger than itself. With `edge_opt`, + the pieces of the cells at the periodic faces thinner than the + margin are thickened or removed by moving their seeds. + Defaults to 0 (no margin). .. _`Specifying Colors`: https://matplotlib.org/users/colors.html .. _`Choosing Colormaps in Matplotlib`: https://matplotlib.org/tutorials/colors/colormaps.html @@ -495,8 +520,6 @@ def run(phases, domain, verbose=False, restart=True, directory='.', # ----------------------------------------------------------------------- # seed_basename = 'seeds.txt' seed_filename = os.path.join(directory, seed_basename) - margin = _periodic_margin(periodic_margin, domain.n_dim, - mesh_max_volume, mesh_max_edge_length) if restart and os.path.exists(seed_filename): # Read seeds from file if verbose: @@ -519,8 +542,13 @@ def run(phases, domain, verbose=False, restart=True, directory='.', kw = 'position' rng_seed = rng_seeds.get(kw, 0) pos_dists = {i: p[kw] for i, p in enumerate(phases) if kw in p} + margin = _periodic_margin(periodic_margin, domain.n_dim, + mesh_max_volume, mesh_max_edge_length, + seeds) seeds.position(domain, pos_dists, rng_seed, rtol=rtol, verbose=verbose, periodic=periodic, periodic_margin=margin) + margin = _periodic_margin(periodic_margin, domain.n_dim, mesh_max_volume, + mesh_max_edge_length, seeds) # Write seeds seeds_types = filetypes.get('seeds', []) diff --git a/src/microstructpy/examples/pbx_2D.xml b/src/microstructpy/examples/pbx_2D.xml index 0f9dea59..5a9a930f 100644 --- a/src/microstructpy/examples/pbx_2D.xml +++ b/src/microstructpy/examples/pbx_2D.xml @@ -44,6 +44,10 @@ 0.5 + + auto True 25 diff --git a/src/microstructpy/examples/pbx_3D.xml b/src/microstructpy/examples/pbx_3D.xml index d540337f..489f50c6 100644 --- a/src/microstructpy/examples/pbx_3D.xml +++ b/src/microstructpy/examples/pbx_3D.xml @@ -44,6 +44,13 @@ 0.7 + + auto + True + 10 + 15 0.02 diff --git a/src/microstructpy/examples/pbx_interface_2D.xml b/src/microstructpy/examples/pbx_interface_2D.xml index 1ab66ba2..d1c9a1ac 100644 --- a/src/microstructpy/examples/pbx_interface_2D.xml +++ b/src/microstructpy/examples/pbx_interface_2D.xml @@ -44,6 +44,10 @@ 0.5 + + auto True 25 diff --git a/src/microstructpy/examples/pbx_interface_3D.xml b/src/microstructpy/examples/pbx_interface_3D.xml index bcaa89ca..0ef6b199 100644 --- a/src/microstructpy/examples/pbx_interface_3D.xml +++ b/src/microstructpy/examples/pbx_interface_3D.xml @@ -47,6 +47,13 @@ 0.7 + + auto + True + 10 + 15 0.2 0.05 diff --git a/src/microstructpy/examples/periodic_3D.xml b/src/microstructpy/examples/periodic_3D.xml index 13da823f..5f414df3 100644 --- a/src/microstructpy/examples/periodic_3D.xml +++ b/src/microstructpy/examples/periodic_3D.xml @@ -34,6 +34,13 @@ 2 + + auto + True + 10 + 15 0.02 diff --git a/src/microstructpy/examples/periodic_tiling.py b/src/microstructpy/examples/periodic_tiling.py index ed1470bb..6c89609e 100644 --- a/src/microstructpy/examples/periodic_tiling.py +++ b/src/microstructpy/examples/periodic_tiling.py @@ -32,12 +32,16 @@ # of the area, so that all of them can be placed). The margin keeps the # seeds from ending within half a target edge length of a periodic face, # or crossing one by less, which would leave thin pieces of grains on the -# opposite face and very small triangles there. +# opposite face and very small triangles there. The smallest grain needs +# about four elements across it, so the margin is at most an eighth of its +# smallest diameter (the CLI setting periodic_margin = auto does the same). max_volume = 0.004 h_target = np.sqrt(4 * max_volume / np.sqrt(3)) -margin = 0.5 * h_target seeds_2d = msp.seeding.SeedList.from_info(phases_2d, 0.9 * domain_2d.area, rng_seeds={'size': 1}) +d_min = min([2 * min(getattr(s.geometry, 'axes', (s.geometry.size / 2,))) + for s in seeds_2d]) +margin = min(0.5 * h_target, d_min / 8) seeds_2d.position(domain_2d, rng_seed=1, periodic=True, periodic_margin=margin) diff --git a/tests/cli/test_periodic_cli.py b/tests/cli/test_periodic_cli.py index 6b33e920..334f01ba 100644 --- a/tests/cli/test_periodic_cli.py +++ b/tests/cli/test_periodic_cli.py @@ -8,6 +8,7 @@ from microstructpy import cli from microstructpy.meshing import PolyMesh from microstructpy.meshing import TriMesh +from microstructpy.seeding import Seed from microstructpy.seeding import SeedList PERIODIC_XML = """ @@ -138,3 +139,26 @@ def test_periodic_margin_setting(): assert cli._periodic_margin('none', 2, 0.004, inf) == 0 with pytest.raises(ValueError): cli._periodic_margin('big', 2, 1, 1) + + # the smallest seed limits the margin to an eighth of its size (its + # smallest diameter or side), so that it keeps four elements across + # it and can satisfy the margin + big = Seed.factory('circle', r=0.5) + small = Seed.factory('circle', r=0.05) + seeds = SeedList([big, small]) + assert np.isclose(cli._periodic_margin('auto', 2, 0.004, inf, [big]), + 0.5 * h_2d) + assert np.isclose(cli._periodic_margin('auto', 2, 0.004, inf, seeds), + 0.1 / 8) + assert np.isclose(cli._periodic_margin('auto', 2, inf, inf, seeds), + 0.1 / 8) + ellipse = Seed.factory('ellipse', a=0.5, b=0.02) + assert np.isclose(cli._periodic_margin('auto', 2, inf, inf, [ellipse]), + 0.04 / 8) + box = Seed.factory('rectangle', side_lengths=[0.3, 0.08]) + assert np.isclose(cli._periodic_margin('auto', 2, inf, inf, [box]), + 0.08 / 8) + sphere = Seed.factory('sphere', r=0.2) + assert np.isclose(cli._periodic_margin('auto', 3, 0.02, inf, [sphere]), + 0.4 / 8) + assert cli._periodic_margin(0.03, 2, 0.004, inf, seeds) == 0.03 From 414ffa0d781283be91b50942fb7a33cc3d0dbca0 Mon Sep 17 00:00:00 2001 From: Julian Rimoli Date: Wed, 23 Sep 2026 11:59:53 -0700 Subject: [PATCH 25/42] Open the narrow corners of the cells at the periodic faces with edge_opt A corner of a cell at a periodic face that is narrower than the minimum angle of the mesh cannot be meshed to that angle: the mesher refines it in shells of very small elements instead (in the periodic meshes, each pass deepens the shells further). Such corners are now features of the edge optimization, with the size of the elements they force (a quarter of the thickness of the wedge at the end of its shorter side), and are targets like thin pieces when under the margin. The facet turns with the line between the seeds on its two sides, so the first trial moves them along the facet, in opposite directions, by the amount that opens the corner to the minimum angle plus two degrees, and the others move them along the facet randomly. PolyMesh.from_seeds takes the minimum angle of the mesh to be built as min_angle; the CLI passes its mesh_min_angle setting, so no new setting is needed. The tiling example passes it too. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.rst | 6 + docs/source/cli/settings.rst | 5 + src/microstructpy/cli.py | 7 +- src/microstructpy/examples/periodic_tiling.py | 10 +- src/microstructpy/meshing/polymesh.py | 201 ++++++++++++++++-- tests/meshing/test_edge_opt_pieces.py | 164 ++++++++++++++ 6 files changed, 369 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 7401c5e6..f67951fb 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -62,6 +62,12 @@ Added back into the domain. The CLI writes and plots the seeds again after the optimization, so that the seed files match the polygonal mesh. The periodic examples use ``periodic_margin`` ``auto`` and ``edge_opt``. + With ``min_angle`` (the minimum angle of the mesh to be built, passed by + the CLI from ``mesh_min_angle``), the corners of the cells at the + periodic faces narrower than that angle are features too, since the + mesher cannot reach the minimum angle there and fills them with shells of + very small elements: the seeds on both sides of the facet are moved along + it to open the corner. Fixed ''''' diff --git a/docs/source/cli/settings.rst b/docs/source/cli/settings.rst index 71bb0443..4ee6aeba 100644 --- a/docs/source/cli/settings.rst +++ b/docs/source/cli/settings.rst @@ -285,6 +285,11 @@ treats the thickness of each piece at a periodic face as a feature of the mesh, like an edge, and moves the seeds of every piece thinner than the margin (and of its neighbors) normal to the face, until the piece is at least as thick as the margin or the cell no longer crosses the face. +A corner of a cell at a periodic face that is narrower than ``mesh_min_angle`` +is treated the same way: the mesher cannot reach the minimum angle in such a +corner and fills it with shells of very small elements instead, so the +optimizer moves the seeds on both sides of the facet along it to open the +corner. A change is kept when the shortest feature it modifies gets longer, so the shorter features of the mesh never get worse. diff --git a/src/microstructpy/cli.py b/src/microstructpy/cli.py index dbef5687..d61057b7 100644 --- a/src/microstructpy/cli.py +++ b/src/microstructpy/cli.py @@ -392,7 +392,9 @@ def run(phases, domain, verbose=False, restart=True, directory='.', this process iterates until `n_iter` attempts have been made for a given edge. In periodic domains with a `periodic_margin`, the pieces of the cells at the periodic faces that are thinner - than the margin are optimized too. Defaults to False. + than the margin, and the corners of the cells at the faces + narrower than `mesh_min_angle`, are optimized too. Defaults to + False. edge_opt_n_iter (int): *(optional)* Maximum number of iterations per edge (or thin piece) during optimization. Ignored if `edge_opt` set to False. Defaults to 100. @@ -610,7 +612,8 @@ def plot_seed_files(force=False): pmesh = PolyMesh.from_seeds(seeds, domain, edge_opt, edge_opt_n_iter, verbose, periodic=periodic, - periodic_margin=margin) + periodic_margin=margin, + min_angle=mesh_min_angle) if edge_opt: # the optimization moved seeds: their files and plots are # updated to the seeds that produce the polygon mesh diff --git a/src/microstructpy/examples/periodic_tiling.py b/src/microstructpy/examples/periodic_tiling.py index 6c89609e..0bc417a1 100644 --- a/src/microstructpy/examples/periodic_tiling.py +++ b/src/microstructpy/examples/periodic_tiling.py @@ -49,12 +49,16 @@ # the seeds slightly to remove the shortest edges of the polygonal mesh, # which would otherwise force very small triangles in the mesh; with the # margin, it also thickens or removes the pieces of the grains at the -# periodic faces that are thinner than the margin. +# periodic faces that are thinner than the margin, and opens the corners +# of the grains at the faces that are narrower than the minimum angle of +# the triangles, which the mesher would fill with very small triangles. +min_angle = 25 pmesh_2d = msp.meshing.PolyMesh.from_seeds(seeds_2d, domain_2d, periodic=True, edge_opt=True, - n_iter=25, periodic_margin=margin) + n_iter=25, periodic_margin=margin, + min_angle=min_angle) tmesh_2d = msp.meshing.TriMesh.from_polymesh(pmesh_2d, phases_2d, - min_angle=25, + min_angle=min_angle, max_volume=max_volume) # Plot the tiled polygonal mesh, with each grain in one color, and the diff --git a/src/microstructpy/meshing/polymesh.py b/src/microstructpy/meshing/polymesh.py index 079b78af..96a09525 100644 --- a/src/microstructpy/meshing/polymesh.py +++ b/src/microstructpy/meshing/polymesh.py @@ -558,7 +558,8 @@ def from_file(cls, filename): # ----------------------------------------------------------------------- # @classmethod def from_seeds(cls, seedlist, domain, edge_opt=False, n_iter=100, - verbose=False, periodic=False, periodic_margin=0.0): + verbose=False, periodic=False, periodic_margin=0.0, + min_angle=0.0): """Create from :class:`.SeedList` and a domain. This function creates a polygon/polyhedron mesh from a seed list and @@ -578,7 +579,11 @@ def from_seeds(cls, seedlist, domain, edge_opt=False, n_iter=100, into pieces, and a piece that is thin (the cell barely crosses the face) forces very small elements: with a positive `periodic_margin`, the optimization also thickens or removes the pieces thinner than - the margin. + the margin. A corner of a cell at a periodic face that is narrower + than the minimum angle of the mesh (`min_angle`) forces very small + elements too, since the mesher cannot reach that angle in the + corner and refines it in shells instead: such corners are opened + by the optimization like thin pieces. Args: seedlist (SeedList): A list of seeds in the microstructure. @@ -616,6 +621,17 @@ def from_seeds(cls, seedlist, domain, edge_opt=False, n_iter=100, cell no longer crosses the face. Ignored if `edge_opt` is False or the mesh is not periodic. Defaults to 0 (only the shortest edge is optimized). + min_angle (float): *(optional)* The minimum angle (2D) or + dihedral angle (3D) of the mesh that will be built from + this one, in degrees (the `min_angle` of + :meth:`.TriMesh.from_polymesh`). With `edge_opt` in + periodic meshes, a corner of a cell at a periodic face that + is narrower than this angle (between a facet and the face) + is a feature like a thin piece, with the size of the small + elements that the mesher would put in it (a quarter of the + thickness of the wedge at the end of its shorter side), and + the seeds on both sides of the facet are moved along it to + open the corner. Defaults to 0 (no such corners). Returns: PolyMesh: A polygon/polyhedron mesh. @@ -873,7 +889,8 @@ def from_seeds(cls, seedlist, domain, edge_opt=False, n_iter=100, # short edge (and thin periodic piece) optimization if edge_opt: pmesh = _optimize_features(cls, pmesh, seedlist, domain, n_iter, - verbose, periodic, periodic_margin) + verbose, periodic, periodic_margin, + min_angle) return pmesh # ----------------------------------------------------------------------- # @@ -2283,13 +2300,14 @@ def _displace_seed(seed, step, dom_lims=None, per_axes=None): def _optimize_features(cls, pmesh, seedlist, domain, n_iter, verbose, - periodic, periodic_margin): + periodic, periodic_margin, min_angle=0.0): """Lengthen the shortest features of a mesh by moving its seeds. The features are the edges of the mesh and, in periodic meshes, the - thicknesses of the pieces of the cells at the periodic faces. The + thicknesses of the pieces of the cells at the periodic faces and the + corners of the cells at the faces narrower than ``min_angle``. The target is the shortest feature or, with a positive margin, the - thinnest piece under the margin. The seeds around the target are + thinnest piece or corner under the margin. The seeds around the target are displaced on a copy of the seed list and the trial is kept when the shortest feature that it changes gets longer: every feature that it creates is longer than the shortest one that it removes (for the @@ -2311,7 +2329,7 @@ def _optimize_features(cls, pmesh, seedlist, domain, n_iter, verbose, scale = max([ub - lb for lb, ub in domain.limits]) tol = 1e-9 * scale - features = _mesh_features(pmesh, per_axes, dom_lims, scale) + features = _mesh_features(pmesh, per_axes, dom_lims, scale, min_angle) n_kp_space = int(np.log10(max(len(pmesh.points), 1))) + 1 n_iter_space = int(np.log10(max(n_iter, 1))) + 1 @@ -2347,7 +2365,7 @@ def _optimize_features(cls, pmesh, seedlist, domain, n_iter, verbose, new_pmesh = None if new_pmesh is not None: new_features = _mesh_features(new_pmesh, per_axes, dom_lims, - scale) + scale, min_angle) accepted = _accept_trial(new_features, features, tol) if accepted: @@ -2365,11 +2383,13 @@ def _optimize_features(cls, pmesh, seedlist, domain, n_iter, verbose, return pmesh -def _mesh_features(pmesh, per_axes, dom_lims, scale): - """Edges of the mesh and pieces of the cells at the periodic faces. +def _mesh_features(pmesh, per_axes, dom_lims, scale, min_angle=0.0): + """Edges of the mesh, pieces of the cells at the periodic faces and + corners of the cells at the faces narrower than ``min_angle``. - Each feature is a dict with its ``kind`` ('edge' or 'piece'), ``size`` - (length or thickness normal to the face), a ``key`` that identifies it + Each feature is a dict with its ``kind`` ('edge', 'piece' or 'wedge'), + ``size`` (length, thickness normal to the face, or the size of the + elements that the corner forces), a ``key`` that identifies it geometrically across re-tessellations and the ``seeds`` around it. """ pts = np.array(pmesh.points, dtype='float') @@ -2419,17 +2439,134 @@ def _mesh_features(pmesh, per_axes, dom_lims, scale): 'axis': axis, 'side': side, }) + features += _wedge_features(pmesh, pts, seed_nums, per_axes, scale, + min_angle) return features +def _wedge_features(pmesh, pts, seed_nums, per_axes, scale, min_angle): + """Corners of the cells at the periodic faces narrower than + ``min_angle`` (degrees): the angle between a facet of the cell and the + face, at a vertex on the face in 2D and along an edge on the face in + 3D. The mesher cannot reach the minimum angle in such a corner and + refines it in shells of elements down to about a quarter of the + thickness of the wedge at the end of its shorter side, which is the + ``size`` of the feature. The facet turns when the seeds on its two + sides move along it: ``u_vec`` is that direction, away from the + face.""" + q_min = np.radians(min_angle) + if q_min <= 0: + return [] + n_dim = pts.shape[1] + features = [] + for r, region in enumerate(pmesh.regions): + walls = [] + inner = [] + for f in region: + neighs = pmesh.facet_neighbors[f] + if min(neighs) < 0: + axis, side = divmod(-min(neighs) - 1, 2) + if per_axes[axis]: + walls.append((f, axis, side)) + else: + inner.append(f) + if not walls: + continue + kps = sorted({kp for f in region for kp in pmesh.facets[f]}) + cen = pts[kps].mean(axis=0) + for f_wall, axis, side in walls: + wall_set = set(pmesh.facets[f_wall]) + for f in inner: + shared = [kp for kp in pmesh.facets[f] if kp in wall_set] + if len(shared) != n_dim - 1: + continue + wedge = _wedge_geometry(pts, pmesh.facets[f_wall], + pmesh.facets[f], shared, cen) + if wedge is None: + continue + angle, length, u_vec, where = wedge + if not angle < q_min: + continue + n_r = [n for n in pmesh.facet_neighbors[f] if n != r][0] + if seed_nums[n_r] == seed_nums[r]: + continue + features.append({ + 'kind': 'wedge', + 'size': 0.25 * length * np.sin(angle), + 'key': ('wedge', seed_nums[r], axis, side) + + tuple(np.round(where / scale, 6)), + 'seeds': sorted({seed_nums[r], seed_nums[n_r]}), + 'seed': seed_nums[r], + 'neighbor': seed_nums[n_r], + 'axis': axis, + 'side': side, + 'angle': angle, + 'min_angle': q_min, + 'u_vec': u_vec, + }) + return features + + +def _wedge_geometry(pts, wall_facet, facet, shared, cen): + """Angle of the corner between a wall facet and a facet of a convex + cell at their shared vertex (2D) or edge (3D), the shorter extent of + the two facets from it, the unit vector along the facet away from the + wall, and the location of the corner; None if degenerate.""" + if len(shared) == 1: + kp = shared[0] + a_vec = pts[[k for k in wall_facet if k != kp][0]] - pts[kp] + b_vec = pts[[k for k in facet if k != kp][0]] - pts[kp] + len_a = np.linalg.norm(a_vec) + len_b = np.linalg.norm(b_vec) + if len_a == 0 or len_b == 0: + return None + cos_ang = np.dot(a_vec, b_vec) / (len_a * len_b) + angle = np.arccos(np.clip(cos_ang, -1, 1)) + return angle, min(len_a, len_b), b_vec / len_b, pts[kp] + + e_pt = pts[shared[0]] + e_vec = pts[shared[1]] - e_pt + e_len = np.linalg.norm(e_vec) + if e_len == 0: + return None + e_vec /= e_len + normals = [] + extents = [] + for loop in (wall_facet, facet): + loop_pts = pts[loop] + n_vec = np.zeros(3) + for i in range(len(loop_pts)): + n_vec += np.cross(loop_pts[i], loop_pts[(i + 1) % len(loop_pts)]) + n_len = np.linalg.norm(n_vec) + if n_len == 0: + return None + n_vec /= n_len + if np.dot(n_vec, loop_pts.mean(axis=0) - cen) < 0: + n_vec = -n_vec # outward from the cell + normals.append(n_vec) + rel = loop_pts - e_pt + rel -= np.outer(rel @ e_vec, e_vec) + extents.append(np.max(np.linalg.norm(rel, axis=1))) + # the interior dihedral angle, from the outward normals + cos_ang = np.dot(normals[0], normals[1]) + angle = np.pi - np.arccos(np.clip(cos_ang, -1, 1)) + u_vec = pts[facet].mean(axis=0) - e_pt + u_vec -= np.dot(u_vec, e_vec) * e_vec + u_len = np.linalg.norm(u_vec) + if u_len == 0 or min(extents) == 0: + return None + where = 0.5 * (pts[shared[0]] + pts[shared[1]]) + return angle, min(extents), u_vec / u_len, where + + def _select_target(features, margin, stuck): - """The shortest feature, or the thinnest piece under the margin, that - is not stuck; None when there is no such target.""" + """The shortest feature, or the thinnest piece or corner under the + margin, that is not stuck; None when there is no such target.""" sizes = np.array([f['size'] for f in features]) order = np.argsort(sizes, kind='stable') cands = [order[0]] - cands += [i for i in order - if features[i]['kind'] == 'piece' and sizes[i] < margin] + cands += [i for i in order if features[i]['kind'] in ('piece', 'wedge') + and sizes[i] < margin] for i in cands: if features[i]['key'] not in stuck: return features[i] @@ -2448,9 +2585,27 @@ def _trial_steps(target, seedlist, n_attempts, margin, dom_lims, per_axes, others move the seed of the piece normal to the face, and the seeds of its neighbors along their lines to the seed of the piece (normal to their facets with it), by random fractions of the margin or of 0.1 - times their equivalent radii, whichever is larger. + times their equivalent radii, whichever is larger. Wedge: the facet + turns with the line between the seeds on its two sides, so the first + trial moves them along the facet, in opposite directions, by the + amount that opens the corner to the minimum angle plus 2 degrees, and + the others move them along the facet by random fractions as above. """ steps = {} + if target['kind'] == 'wedge': + u_vec = target['u_vec'] + seed_w, seed_n = target['seed'], target['neighbor'] + if n_attempts == 0: + pos_w = np.array(seedlist[seed_w].position, dtype='float') + pos_n = np.array(seedlist[seed_n].position, dtype='float') + if dom_lims is not None: + pos_n = _nearest_image(pos_n.reshape(1, -1), pos_w, + dom_lims, per_axes)[0] + phi = target['min_angle'] + np.radians(2) - target['angle'] + delta = 0.5 * phi * np.linalg.norm(pos_n - pos_w) + # the corner opens when the facet turns away from the wall: + # the line between the seeds turns towards the facet + return {seed_w: delta * u_vec, seed_n: -delta * u_vec} if target['kind'] == 'piece': axis = target['axis'] grow = np.zeros(n_dim) @@ -2473,7 +2628,10 @@ def _trial_steps(target, seedlist, n_attempts, margin, dom_lims, per_axes, else: r_eq = np.cbrt(3 * seed.volume / (4 * np.pi)) step_max = 0.1 * r_eq - if target['kind'] == 'piece': + if target['kind'] == 'wedge': + step_max = max(step_max, margin) + u_vec = target['u_vec'] + elif target['kind'] == 'piece': step_max = max(step_max, margin) if seed_num == target['seed']: u_vec = grow @@ -2548,9 +2706,14 @@ def _target_string(target, n_attempts, n_iter, n_kp_space, n_iter_space): kp_fmt = '{0:' + str(n_kp_space) + 'd}' s = 'min length: {0:.3e} | edge: '.format(target['size']) s += ', '.join([kp_fmt.format(kp) for kp in target['kps']]) - else: + elif target['kind'] == 'piece': face = 'xyz'[target['axis']] + '-+'[target['side']] s = 'thickness: {0:.3e} | piece: seed {1:d}, face {2}' s = s.format(target['size'], target['seed'], face) + else: + face = 'xyz'[target['axis']] + '-+'[target['side']] + s = 'corner: {0:.3e} | wedge: seed {1:d}, face {2}, {3:.1f} deg' + s = s.format(target['size'], target['seed'], face, + np.degrees(target['angle'])) s += ' | n iter: {0:' + str(n_iter_space) + 'd} / {1:d}' return s.format(n_attempts, n_iter) diff --git a/tests/meshing/test_edge_opt_pieces.py b/tests/meshing/test_edge_opt_pieces.py index 3f3da5db..9f022f93 100644 --- a/tests/meshing/test_edge_opt_pieces.py +++ b/tests/meshing/test_edge_opt_pieces.py @@ -11,6 +11,7 @@ from microstructpy.meshing.polymesh import _mesh_features from microstructpy.meshing.polymesh import _nearest_image from microstructpy.meshing.polymesh import _select_target +from microstructpy.meshing.polymesh import _wedge_geometry from microstructpy.seeding import Seed from microstructpy.seeding import SeedList @@ -264,3 +265,166 @@ def test_edge_opt_fixes_thin_piece_3d(): pmesh_re = PolyMesh.from_seeds(seeds, domain, periodic=True) assert np.allclose(np.sort(pmesh_re.volumes), np.sort(pmesh.volumes), rtol=0, atol=1e-9) + + +# --------------------------------------------------------------------------- # +# Wedges (corners at the periodic faces narrower than the mesh angle) # +# --------------------------------------------------------------------------- # +def _wedge_seeds_2d(angle_deg=15.0): + """Circles in a square of side 3, periodic in x. The seeds A and B are + 0.5 apart along a line tilted by ``angle_deg`` from the x axis, so + their facet (normal to that line) meets the face x = 3 at that angle, + at about (3, 0.1): the cell of B has a wedge there. The other seeds + form a jittered grid away from them.""" + rng = np.random.RandomState(0) + ang = np.radians(angle_deg) + positions = [[2.5, 1.0], + [2.5 + 0.5 * np.cos(ang), 1.0 + 0.5 * np.sin(ang)]] + for x in (0.75, 1.75): + for y in (0.75, 1.75, 2.75): + if (x, y) == (0.75, 0.75): + continue # its image would cut the corner of the wedge + positions.append([x + 0.03 * (2 * rng.rand() - 1), + y + 0.03 * (2 * rng.rand() - 1)]) + positions.append([2.75, 2.75]) + return SeedList([Seed.factory('circle', r=0.2, position=p) + for p in positions]) + + +def _wedge_seeds_3d(angle_deg=15.0): + """The 2D configuration extruded along z in a cube of side 3, periodic + in x: the facet of A and B contains the z direction and meets the + face x = 3 along a line, with a dihedral angle of ``angle_deg``.""" + rng = np.random.RandomState(0) + ang = np.radians(angle_deg) + positions = [[2.5, 1.0, 1.5], + [2.5 + 0.5 * np.cos(ang), 1.0 + 0.5 * np.sin(ang), 1.5]] + for x in (0.75, 1.75): + for y in (0.75, 1.75, 2.75): + for z in (0.75, 2.25): + positions.append([x + 0.03 * (2 * rng.rand() - 1), + y + 0.03 * (2 * rng.rand() - 1), + z + 0.03 * (2 * rng.rand() - 1)]) + positions.append([2.75, 2.75, 0.75]) + positions.append([2.75, 2.75, 2.25]) + return SeedList([Seed.factory('sphere', r=0.2, position=p) + for p in positions]) + + +def _wedges(pmesh, domain, per_axes, min_angle): + scale = max([ub - lb for lb, ub in domain.limits]) + feats = _mesh_features(pmesh, per_axes, domain.limits, scale, min_angle) + return [f for f in feats if f['kind'] == 'wedge'] + + +def test_wedge_geometry(): + # 2D: a vertex on the wall, the wall edge along -x and a facet at 20 + # degrees from it, 0.5 long + ang = np.radians(20) + pts = np.array([[0.0, 0.0], [-1.0, 0.0], + [-0.5 * np.cos(ang), 0.5 * np.sin(ang)]]) + angle, length, u_vec, where = _wedge_geometry(pts, [0, 1], [0, 2], [0], + np.array([-0.5, 0.05])) + assert np.isclose(angle, ang) + assert np.isclose(length, 0.5) + assert np.allclose(u_vec, [-np.cos(ang), np.sin(ang)]) + assert np.allclose(where, [0, 0]) + + # 3D: the wall facet in the plane y = 0, the cell above it, and a + # facet leaving their common edge (along x) at 20 degrees + d = np.array([0.0, np.sin(ang), np.cos(ang)]) + pts = np.array([[0, 0, 0], [1, 0, 0], [1, 0, 1], [0, 0, 1], + [1, 0, 0] + 0.5 * d, [0, 0, 0] + 0.5 * d], dtype=float) + cen = np.array([0.5, 0.1, 0.6]) + angle, length, u_vec, where = _wedge_geometry(pts, [0, 1, 2, 3], + [0, 1, 4, 5], [0, 1], cen) + assert np.isclose(angle, ang) + assert np.isclose(length, 0.5) + assert np.allclose(u_vec, d) + assert np.allclose(where, [0.5, 0, 0]) + # the same with the facets listed in the other order round the edge + angle_2, _, _, _ = _wedge_geometry(pts, [3, 2, 1, 0], [5, 4, 1, 0], + [1, 0], cen) + assert np.isclose(angle_2, ang) + + +def test_wedge_features_2d(): + domain = geometry.Square(side_length=3, corner=(0, 0)) + seeds = _wedge_seeds_2d(15.0) + pmesh = PolyMesh.from_seeds(seeds, domain, periodic='x') + # no wedges narrower than 10 degrees; two narrower than 25: the corner + # of the cell of B (seed 1) on the face x = 3, and its image on the + # face x = 0, where the facet continues into the piece of A (seed 0) + assert _wedges(pmesh, domain, [True, False], 10.0) == [] + wedges = _wedges(pmesh, domain, [True, False], 25.0) + assert len(wedges) == 2 + by_seed = {w['seed']: w for w in wedges} + assert set(by_seed) == {0, 1} + w = by_seed[1] + assert w['neighbor'] == 0 + assert (w['axis'], w['side']) == (0, 1) + assert np.isclose(np.degrees(w['angle']), 15.0, atol=1.0) + assert 0 < w['size'] < 0.25 * 3 * np.sin(w['angle']) + # the direction is along the facet, away from the face + assert w['u_vec'][0] < 0 + assert np.isclose(np.linalg.norm(w['u_vec']), 1) + w_0 = by_seed[0] + assert w_0['neighbor'] == 1 + assert (w_0['axis'], w_0['side']) == (0, 0) + assert np.isclose(np.degrees(w_0['angle']), 15.0, atol=1.0) + assert w_0['u_vec'][0] > 0 + # no wedges at all without a minimum angle + assert _wedges(pmesh, domain, [True, False], 0.0) == [] + # no wedges on non-periodic faces (y is not periodic) + assert all([f['axis'] == 0 for f in wedges]) + + +def test_wedge_features_3d(): + domain = geometry.Cube(side_length=3, corner=(0, 0, 0)) + seeds = _wedge_seeds_3d(15.0) + pmesh = PolyMesh.from_seeds(seeds, domain, periodic='x') + wedges = _wedges(pmesh, domain, [True, False, False], 25.0) + mine = [w for w in wedges if w['seed'] == 1] + assert len(mine) >= 1 + for w in mine: + assert w['neighbor'] == 0 + assert (w['axis'], w['side']) == (0, 1) + assert np.isclose(np.degrees(w['angle']), 15.0, atol=1.0) + assert w['u_vec'][0] < 0 + assert np.isclose(w['u_vec'][2], 0, atol=0.05) + assert _wedges(pmesh, domain, [True, False, False], 10.0) == [] + + +def test_edge_opt_opens_wedge_2d(): + np.random.seed(0) + domain = geometry.Square(side_length=3, corner=(0, 0)) + seeds = _wedge_seeds_2d(15.0) + pmesh_0 = PolyMesh.from_seeds(seeds, domain, periodic='x') + assert len(_wedges(pmesh_0, domain, [True, False], 25.0)) == 2 + min_edge_0 = _min_edge(pmesh_0) + + pmesh = PolyMesh.from_seeds(seeds, domain, periodic='x', edge_opt=True, + n_iter=5, periodic_margin=0.05, + min_angle=25.0) + assert _wedges(pmesh, domain, [True, False], 25.0) == [] + assert _min_edge(pmesh) >= min_edge_0 - 1e-9 + assert np.isclose(sum(pmesh.volumes), domain.area) + pmesh_re = PolyMesh.from_seeds(seeds, domain, periodic='x') + assert np.allclose(np.sort(pmesh_re.volumes), np.sort(pmesh.volumes), + rtol=0, atol=1e-9) + + +def test_edge_opt_opens_wedge_3d(): + np.random.seed(0) + domain = geometry.Cube(side_length=3, corner=(0, 0, 0)) + seeds = _wedge_seeds_3d(15.0) + pmesh_0 = PolyMesh.from_seeds(seeds, domain, periodic='x') + n_0 = len(_wedges(pmesh_0, domain, [True, False, False], 25.0)) + assert n_0 >= 1 + min_edge_0 = _min_edge(pmesh_0) + pmesh = PolyMesh.from_seeds(seeds, domain, periodic='x', edge_opt=True, + n_iter=5, periodic_margin=0.05, + min_angle=25.0) + assert len(_wedges(pmesh, domain, [True, False, False], 25.0)) < n_0 + assert _min_edge(pmesh) >= min_edge_0 - 1e-9 + assert np.isclose(sum(pmesh.volumes), domain.volume) From 628d4e68f5f5b99ce1d77d5a728def02686b550f Mon Sep 17 00:00:00 2001 From: Julian Rimoli Date: Wed, 23 Sep 2026 13:55:06 -0700 Subject: [PATCH 26/42] Stop the passes of the 2D periodic mesher from deepening the corners When the nodes on the periodic faces did not match after a pass, the next pass put the points that Triangle had added on the faces back into the facets and meshed the cells again from scratch. In a corner narrower than the minimum angle, where Triangle refines in shells of small elements, each pass then split the segment at the tip once more, one level per pass, down to very small elements. Now all the points of the previous mesh are the input of the next pass, with the points on a periodic face and on its image merged and put on both faces, so that Triangle only refines the mesh around the merged points, in the same surroundings (the copies of the cells outside the faces) that produced them. The faces match after two passes in the examples, nothing is mirrored, and the quality is that of the first pass. The copies of the cells at the corners of the domain, moved along both axes, were open on the side of the periodic wall facet that was skipped as if it lay on the opposite face; Triangle discarded part of them. The copies are closed now, which also makes the first pass match the faces better, and the mesher checks that Triangle kept its input points. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.rst | 8 ++ docs/source/cli/domain.rst | 6 +- src/microstructpy/meshing/trimesh.py | 139 ++++++++++++++++--------- tests/meshing/test_periodic_trimesh.py | 77 ++++++++++++++ 4 files changed, 179 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index f67951fb..f0bfdfad 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -68,6 +68,14 @@ Added mesher cannot reach the minimum angle there and fills them with shells of very small elements: the seeds on both sides of the facet are moved along it to open the corner. +- When the nodes on the periodic faces of a 2D mesh do not match after the + first pass, the next pass starts from all the points of the mesh (those on + a periodic face and on its image merged and put on both faces), so that + Triangle only refines it around the merged points, instead of meshing the + cells again with the points on the faces only, which split the narrow + corners of the cells again at every pass, down to very small elements. + The copies of the cells at the corners of the domain, used while meshing, + were open on one side and partly discarded by Triangle. Fixed ''''' diff --git a/docs/source/cli/domain.rst b/docs/source/cli/domain.rst index 8ccbaf9b..d5782d2a 100644 --- a/docs/source/cli/domain.rst +++ b/docs/source/cli/domain.rst @@ -351,8 +351,10 @@ The mesh quality and size settings (``mesh_min_angle``, ``mesh_max_volume``, the ``max_volume`` of each phase and ``mesh_max_edge_length``) apply to periodic meshes as to non-periodic ones. In 2D, the cells next to the periodic faces are copied outside the faces while meshing, so that Triangle -refines both faces of a pair the same way, and the mesh is built again with -the points it added on the faces when some nodes have no image. In 3D, the +refines both faces of a pair the same way; if some nodes on the faces have +no image, the mesh is built again from all of its points, with the points +of a periodic face and of its image merged and put on both faces, so that +Triangle only refines it around those points. In 3D, the mesh is built once as usual, then the facets are triangulated with the points TetGen added on them (the facets on opposite periodic faces with the points of both) and the mesh is built again with the facets fixed; the elements next diff --git a/src/microstructpy/meshing/trimesh.py b/src/microstructpy/meshing/trimesh.py index b9142d6f..68898132 100644 --- a/src/microstructpy/meshing/trimesh.py +++ b/src/microstructpy/meshing/trimesh.py @@ -1992,7 +1992,7 @@ def _facet_in_normal(pts, cen_pt): _FACE_MIN_ANGLE = 20.0 # quality of the triangles on the periodic faces (3D) _MAX_EDGE_SUBDIVISIONS = 400 -_MAX_PERIODIC_PASSES = 8 +_MAX_PERIODIC_PASSES = 4 def _facet_sizes(polymesh, phases, facet_nums, max_volume, max_edge_length): @@ -2029,10 +2029,11 @@ def _edge_key(kp_a, kp_b): return (min(kp_a, kp_b), max(kp_a, kp_b)) -def _points_on_segment(new_pts, pt_a, pt_b): - """Parameters (0 < t < 1) of the points that lie on a segment.""" +def _points_on_segment(new_pts, pt_a, pt_b, with_ids=False): + """Parameters (0 < t < 1) of the points that lie on a segment, sorted; + with ``with_ids``, the indices of the points in the same order too.""" if len(new_pts) == 0: - return [] + return ([], []) if with_ids else [] rel = np.array(new_pts) - pt_a seg = pt_b - pt_a len2 = np.dot(seg, seg) @@ -2040,7 +2041,12 @@ def _points_on_segment(new_pts, pt_a, pt_b): dists = np.linalg.norm(rel - np.outer(t_vals, seg), axis=1) on_seg = (t_vals > 1e-9) & (t_vals < 1 - 1e-9) on_seg &= dists <= 1e-9 * np.sqrt(len2) - return sorted(t_vals[on_seg].tolist()) + ids = np.nonzero(on_seg)[0] + order = np.argsort(t_vals[ids]) + ids = ids[order] + if with_ids: + return t_vals[ids].tolist(), ids.tolist() + return t_vals[ids].tolist() def _merge_params(t_vals, sides=None, n_max=_MAX_EDGE_SUBDIVISIONS, @@ -2564,17 +2570,22 @@ def _build_2d(pts, facets, facet_nums, holes, regions, min_angle, allow_boundary_steiner=allow_boundary_steiner) -def _split_periodic_boundary_2d(tri_pts, pts, facets, facet_nums, polymesh): - """Add the points that Triangle put on the periodic faces to the facets. +def _split_periodic_boundary_2d(tri_pts, pts, facets, facet_nums, polymesh, + n_input=None): + """Add the points that Triangle put on the facets to the facets. - Triangle refines the boundary segments of a mesh where its quality and - size settings require it, but not the same way on opposite periodic - faces. The points it added on a facet of a periodic face and on the - image of the facet on the opposite face are inserted in both facets, - as images of each other, so that the next mesh has matching nodes on - opposite faces. The points of the two facets are merged so that the - facets are as refined as the finest of the two (see - :func:`_merge_params`). + Triangle refines the segments of a mesh where its quality and size + settings require it, but not the same way on opposite periodic faces. + The points it added on a facet of a periodic face and on the image of + the facet on the opposite face are inserted in both facets, as images + of each other, so that the next mesh has matching nodes on opposite + faces. The points of the two facets are merged so that the facets are + as refined as the finest of the two (see :func:`_merge_params`). The + points added on the other facets (the other walls, the facets between + cells, and the facets of the copies of the cells outside the domain) + are inserted as they are, and the points added inside the cells are + appended as points of the input, so that the next mesh contains the + previous one. Args: tri_pts (numpy.ndarray): Points of the mesh built by Triangle. @@ -2582,6 +2593,9 @@ def _split_periodic_boundary_2d(tri_pts, pts, facets, facet_nums, polymesh): facets (list): Facets (segments) of the mesher input. facet_nums (list): Polymesh facet number + 1 of each facet. polymesh (PolyMesh): The periodic polymesh. + n_input (int): *(optional)* Number of points of the input of the + mesh that Triangle built; the points after them are the ones + it added. Defaults to the number of ``pts``. Returns: tuple: The new points, facets and facet numbers, and the number of @@ -2589,7 +2603,9 @@ def _split_periodic_boundary_2d(tri_pts, pts, facets, facet_nums, polymesh): """ pts = [list(p) for p in pts] - new_pts = np.array(tri_pts)[len(pts):] + if n_input is None: + n_input = len(pts) + new_pts = np.array(tri_pts)[n_input:] p_arr = np.array(polymesh.points) mins = p_arr.min(axis=0) lengths = p_arr.max(axis=0) - mins @@ -2597,27 +2613,33 @@ def _split_periodic_boundary_2d(tri_pts, pts, facets, facet_nums, polymesh): tol = 1e-9 * scale per_axes = polymesh.periodic_axes - # facets on the periodic faces, with the parameters of the new points + # the facets (those of the copied cells too), with the parameters of + # the new points on them seg_t = {} ends = {} + periodic_segs = set() + on_facets = set() for i, f_num in enumerate(facet_nums): + pt_a, pt_b = np.array(pts[facets[i][0]]), np.array(pts[facets[i][1]]) + seg_t[i], ids = _points_on_segment(new_pts, pt_a, pt_b, True) + on_facets.update(ids) + ends[i] = (pt_a, pt_b) if f_num <= 0: continue # a facet of a copied cell, outside the domain wall = min(polymesh.facet_neighbors[f_num - 1]) - if wall >= 0 or not per_axes[(-wall - 1) // 2]: - continue - pt_a, pt_b = np.array(pts[facets[i][0]]), np.array(pts[facets[i][1]]) - seg_t[i] = _points_on_segment(new_pts, pt_a, pt_b) - ends[i] = (pt_a, pt_b) - n_new = sum([len(t_vals) for t_vals in seg_t.values()]) - if n_new == 0: - return pts, [list(f) for f in facets], list(facet_nums), 0 + if wall < 0 and per_axes[(-wall - 1) // 2]: + periodic_segs.add(i) + n_new = sum([len(seg_t[i]) for i in periodic_segs]) + + # the points that are not on a facet + free = [k for k in range(len(new_pts)) if k not in on_facets] + free_pts = new_pts[free].tolist() # a facet on a lower periodic face and its image on the upper face, # matched by their midpoints - seg_ids = sorted(ends) + seg_ids = sorted(periodic_segs) mids = np.array([0.5 * (ends[i][0] + ends[i][1]) for i in seg_ids]) - tree = cKDTree(mids) + tree = cKDTree(mids) if seg_ids else None pair_of = {} for i in seg_ids: pt_a, pt_b = ends[i] @@ -2670,7 +2692,7 @@ def chain(kp_a, ids, kp_b): im_ids = im_ids[::-1] new_facets.extend(chain(facets[j][0], im_ids, facets[j][1])) new_nums.extend([facet_nums[j]] * (len(im_ids) + 1)) - return pts, new_facets, new_nums, n_new + return pts + free_pts, new_facets, new_nums, n_new def _unmatched_periodic_nodes(pts, polymesh): @@ -2871,13 +2893,19 @@ def image_id(kp, trans): other = neighs[0] if neighs[1] == reg else neighs[1] # a facet removed between merged cells is removed between # their copies too, and the copy of a facet on a periodic - # face is the facet on the opposite face (possibly - # subdivided), which the input already has + # face, moved along the axis of that face only, is the + # facet on the opposite face (possibly subdivided), which + # the input already has; moved along other axes too, it + # lies outside the domain and closes the copy if (other >= 0 and trans in copies.get(other, []) and not facet_check(neighs, polymesh, phases)): continue - if other < 0 and per_axes[(-other - 1) // 2]: - continue + if other < 0: + axis = (-other - 1) // 2 + if (per_axes[axis] and trans[axis] != 0 and + not any([t for a, t in enumerate(trans) + if a != axis])): + continue ids = [image_id(kp, trans) for kp in polymesh.facets[f]] key = tuple(sorted(ids)) if key in existing: @@ -2890,17 +2918,24 @@ def image_id(kp, trans): def _build_periodic_2d(polymesh, phases, labels, kps, pts, facets, facet_nums, holes, regions, min_angle, max_volume): - """Build a periodic triangular mesh with Triangle. + """Build a periodic triangular mesh with Triangle, in two passes. The cells next to the periodic faces are copied outside the faces (see :func:`_ghost_layer`) and the mesh is built like a non-periodic - one; Triangle then refines both faces of a pair the same way, up to - the order of its operations. If some nodes on the periodic faces have - no image on the opposite face, the points Triangle added on the faces - and their images are put on both faces and the mesh is built again. - The elements outside the domain are removed, and the few nodes that - may remain without an image are mirrored by splitting the elements - behind them. + one, so that Triangle refines both faces of a pair the same way, up + to the order of its operations. If some nodes on the periodic faces + have no image on the opposite face, all the points of the mesh become + the input of the next pass: the points on the facets are put in the + facets (those on a periodic face and on its image merged and put on + both, as images of each other) and the others are points of the + input, so that Triangle only refines the mesh around the points + brought from the opposite faces, in the same surroundings (the copies) + that produced them. Passing only the points on the faces back, and + meshing the cells again from scratch, made Triangle split the narrow + corners of the cells again at every pass, down to very small + elements. The elements outside the domain are removed, and the few + nodes that may remain without an image are mirrored by splitting the + elements behind them. Returns: tuple: The points, elements and element attributes. @@ -2909,34 +2944,40 @@ def _build_periodic_2d(polymesh, phases, labels, kps, pts, facets, pts, facets, facet_nums, regions, holes = _ghost_layer( polymesh, phases, labels, kps, pts, facets, facet_nums, regions, holes, max_volume) + tri_mesh = _build_2d(pts, facets, facet_nums, holes, regions, min_angle, + True) p_arr = np.array(polymesh.points) mins = p_arr.min(axis=0) maxs = p_arr.max(axis=0) tol = 1e-9 * (maxs - mins).max() for _ in range(_MAX_PERIODIC_PASSES): - tri_mesh = _build_2d(pts, facets, facet_nums, holes, regions, - min_angle, True) - # the elements inside the domain - tri_pts = np.array(tri_mesh.points) + all_pts = np.array(tri_mesh.points) tri_elems = np.array(tri_mesh.elements) tri_e_atts = np.array(tri_mesh.element_attributes, dtype='int') - cens = tri_pts[tri_elems].mean(axis=1) + cens = all_pts[tri_elems].mean(axis=1) inside = np.all((cens >= mins - tol) & (cens <= maxs + tol), axis=1) tri_elems = tri_elems[inside] tri_e_atts = tri_e_atts[inside] used = np.unique(tri_elems) - renum = np.full(len(tri_pts), -1) + renum = np.full(len(all_pts), -1) renum[used] = np.arange(len(used)) - tri_pts = tri_pts[used] + tri_pts = all_pts[used] tri_elems = renum[tri_elems] - if not _unmatched_periodic_nodes(tri_pts, polymesh): break + + # all the points of the mesh become the input of the next pass + n_input = len(pts) + if (len(all_pts) < n_input or + not np.allclose(all_pts[:n_input], pts, atol=tol)): + raise RuntimeError('Triangle did not keep the input points.') pts, facets, facet_nums, n_new = _split_periodic_boundary_2d( - np.array(tri_mesh.points), pts, facets, facet_nums, polymesh) + all_pts, pts, facets, facet_nums, polymesh, n_input) if n_new == 0: break + tri_mesh = _build_2d(pts, facets, facet_nums, holes, regions, + min_angle, True) tri_pts, tri_elems, tri_e_atts, _ = _mirror_boundary_points_2d( tri_pts, tri_elems, tri_e_atts, polymesh) diff --git a/tests/meshing/test_periodic_trimesh.py b/tests/meshing/test_periodic_trimesh.py index 380fe6b7..514338e9 100644 --- a/tests/meshing/test_periodic_trimesh.py +++ b/tests/meshing/test_periodic_trimesh.py @@ -7,6 +7,8 @@ from microstructpy.meshing import PolyMesh from microstructpy.meshing import RasterMesh from microstructpy.meshing import TriMesh +from microstructpy.meshing import trimesh as trimesh_module +from microstructpy.seeding import Seed from microstructpy.seeding import SeedList @@ -169,6 +171,81 @@ def nset(name): assert np.array_equal(pts[hi - 1], pts[lo - 1] + shift) +def _wedge_polymesh(angle_deg): + """A square of side 3, periodic in x, with a facet meeting the face + x = 3 at ``angle_deg`` (see the tests of the edge optimization).""" + rng = np.random.RandomState(0) + ang = np.radians(angle_deg) + positions = [[2.5, 1.0], + [2.5 + 0.5 * np.cos(ang), 1.0 + 0.5 * np.sin(ang)]] + for x in (0.75, 1.75): + for y in (0.75, 1.75, 2.75): + if (x, y) != (0.75, 0.75): + positions.append([x + 0.03 * (2 * rng.rand() - 1), + y + 0.03 * (2 * rng.rand() - 1)]) + positions.append([2.75, 2.75]) + seeds = SeedList([Seed.factory('circle', r=0.2, position=p) + for p in positions]) + domain = msp.geometry.Square(side_length=3, corner=(0, 0)) + return PolyMesh.from_seeds(seeds, domain, periodic='x') + + +def _min_edge(mesh): + pts = np.array(mesh.points) + elems = np.array(mesh.elements) + lengths = [np.linalg.norm(pts[elems[:, i]] - pts[elems[:, (i + 1) % 3]], + axis=1) for i in range(3)] + return np.min(lengths) + + +def test_periodic_trimesh_no_cascade_at_wedges(): + # a corner narrower than the minimum angle makes Triangle refine it in + # shells of small elements; the passes that match the periodic faces + # must not deepen the shells (they did, one level per pass) + phases = [{'shape': 'circle', 'size': 0.4}] + for angle_deg, min_angle in ((15.0, 20), (15.0, 25)): + pmesh = _wedge_polymesh(angle_deg) + mesh = TriMesh.from_polymesh(pmesh, phases, min_angle=min_angle, + max_volume=0.05) + pmesh.periodic_axes = [False, False] + pmesh.periodic_points = {} + pmesh.periodic_facets = {} + plain = TriMesh.from_polymesh(pmesh, phases, min_angle=min_angle, + max_volume=0.05) + # at most one level of shells beyond the non-periodic mesh (the + # shells of both faces are put together), with some slack + assert _min_edge(mesh) >= 0.3 * _min_edge(plain) + + +def test_ghost_layer_copies_are_closed(periodic_case): + # the copies of the cells outside the periodic faces are closed + # polygons, including those of the cells at the corners of the domain, + # which are moved along both axes: every point of a copy is shared by + # at least two facets, so that Triangle does not eat into the copies + domain, phases, seeds, pmesh = periodic_case + pts = [list(p) for p in pmesh.points] + kps = {i: i for i in range(len(pts))} + facets = [list(f) for f in pmesh.facets] + facet_nums = [f + 1 for f in range(len(facets))] + labels = np.arange(len(pmesh.regions)) + out = trimesh_module._ghost_layer(pmesh, phases, labels, kps, pts, + facets, facet_nums, [], [], np.inf) + g_pts, g_facets, g_nums = out[0], out[1], out[2] + assert len(g_pts) > len(pts) + assert any([n == 0 for n in g_nums]) + degree = np.zeros(len(g_pts), dtype=int) + for facet in g_facets: + for kp in facet: + degree[kp] += 1 + assert np.all(degree[len(pts):] >= 2) + # the copies cover the corners of the domain: points outside along + # both axes exist + arr = np.array(g_pts) + lims = np.array(domain.limits) + outside = (arr < lims[:, 0] - 1e-9) | (arr > lims[:, 1] + 1e-9) + assert np.any(np.all(outside, axis=1)) + + def test_periodic_gmsh_not_supported(periodic_case): domain, phases, seeds, pmesh = periodic_case with pytest.raises(NotImplementedError): From 5d74cd001ca0dff71f5cb9192cb3de385ee8048c Mon Sep 17 00:00:00 2001 From: Julian Rimoli Date: Wed, 23 Sep 2026 14:00:58 -0700 Subject: [PATCH 27/42] Present the periodic examples like the others The pages of the interface refinement examples have the Materials and Domain Geometry sections of the other pages and point to the seed and polygonal mesh figures of the binder and inclusions examples, which they share; the tiling example page has the sections of the other package examples (Domain, Phases, Seeds, Meshing, Plotting). Two numbers in the pages that no longer matched the input files (the overlap tolerance of the 3D binder example, the maximum edge length of the 3D interface example) are corrected, the pages mention the narrow corners that the optimization opens, the comments of the periodic 2D input file describe the current margin rule, and the mesh comment of the 3D interface input file sits next to the mesh settings. Co-Authored-By: Claude Fable 5.1 --- docs/source/examples/cli/pbx_2d.rst | 5 +- docs/source/examples/cli/pbx_3d.rst | 7 +-- docs/source/examples/cli/pbx_interface_2d.rst | 24 ++++++--- docs/source/examples/cli/pbx_interface_3d.rst | 28 +++++++---- docs/source/examples/cli/periodic_2d.rst | 6 ++- docs/source/examples/cli/periodic_3d.rst | 5 +- .../examples/package/periodic_tiling.rst | 50 ++++++++++++++----- .../examples/pbx_interface_3D.xml | 6 +-- src/microstructpy/examples/periodic_2D.xml | 10 ++-- 9 files changed, 96 insertions(+), 45 deletions(-) diff --git a/docs/source/examples/cli/pbx_2d.rst b/docs/source/examples/cli/pbx_2d.rst index 2fa2dd16..34ded8bc 100644 --- a/docs/source/examples/cli/pbx_2d.rst +++ b/docs/source/examples/cli/pbx_2d.rst @@ -51,8 +51,9 @@ The random number generator seeds make the microstructure repeatable. The seeds are placed a margin away from the periodic faces (``periodic_margin`` set to ``auto``: half the target edge length, or an eighth of the smallest grain if that is smaller) and the edge optimization moves the seeds that -leave a thin piece of a grain on a face, or a very short edge, since either -forces very small triangles. +leave a thin piece of a grain on a face, a corner narrower than the minimum +angle of the mesh, or a very short edge, since any of these forces very +small triangles. The mesh has a minimum angle of 25 degrees and a maximum element area of 0.01. diff --git a/docs/source/examples/cli/pbx_3d.rst b/docs/source/examples/cli/pbx_3d.rst index 2ee63036..6f1f491a 100644 --- a/docs/source/examples/cli/pbx_3d.rst +++ b/docs/source/examples/cli/pbx_3d.rst @@ -34,7 +34,7 @@ directions. Settings ======== -The overlap tolerance ``rtol`` is set to 0.5 so that the small binder seeds +The overlap tolerance ``rtol`` is set to 0.7 so that the small binder seeds can be placed between the inclusions, and the random number generator seeds make the microstructure repeatable. @@ -44,8 +44,9 @@ volume of 0.02. The seeds are placed a margin away from the periodic faces (``periodic_margin`` set to ``auto``: half the target edge length, or an eighth of the smallest grain if that is smaller) and the edge optimization moves the seeds that -leave a thin piece of a grain on a face, or a very short edge, since either -forces very small tetrahedra. +leave a thin piece of a grain on a face, a corner narrower than the minimum +angle of the mesh, or a very short edge, since any of these forces very +small tetrahedra. Output Files diff --git a/docs/source/examples/cli/pbx_interface_2d.rst b/docs/source/examples/cli/pbx_interface_2d.rst index f522d0cd..5181fbb7 100644 --- a/docs/source/examples/cli/pbx_interface_2d.rst +++ b/docs/source/examples/cli/pbx_interface_2d.rst @@ -18,11 +18,17 @@ The full text of the file is: :language: xml -Materials and Domain -==================== +Materials +========= -The materials and the domain are those of the :ref:`ex_pbx_2d` example: -crystalline inclusions in a binder, in a square domain periodic in both +The materials are those of the :ref:`ex_pbx_2d` example: crystalline +inclusions (65% of the area, circular seeds with lognormal diameters) in a +binder (a ``matrix`` phase, 35% of the area, seeded with small circles). + +Domain Geometry +=============== + +The materials fill a square domain of side length 3, periodic in both directions. Settings @@ -43,8 +49,9 @@ The mesh has a minimum angle of 25 degrees. The seeds are placed a margin away from the periodic faces (``periodic_margin`` set to ``auto``: half the target edge length, or an eighth of the smallest grain if that is smaller) and the edge optimization moves the seeds that -leave a thin piece of a grain on a face, or a very short edge, since either -forces very small triangles. +leave a thin piece of a grain on a face, a corner narrower than the minimum +angle of the mesh, or a very short edge, since any of these forces very +small triangles. Output Files @@ -52,7 +59,10 @@ Output Files The three plots that this file generates are the seeding, the polygon mesh, and the triangular mesh. -The triangular mesh is shown in :numref:`f_ex_pbxint2d_tri`. +The seeds and the polygonal mesh are those of the :ref:`ex_pbx_2d` example +(:numref:`f_ex_pbx2d_seeds` and :numref:`f_ex_pbx2d_poly`), since the seeds +and the settings of the optimization are the same; only the triangular mesh +differs, shown in :numref:`f_ex_pbxint2d_tri`. .. _f_ex_pbxint2d_tri: .. figure:: ../../../../src/microstructpy/examples/pbx_interface_2D/trimesh.png diff --git a/docs/source/examples/cli/pbx_interface_3d.rst b/docs/source/examples/cli/pbx_interface_3d.rst index 6d0eac24..117a5419 100644 --- a/docs/source/examples/cli/pbx_interface_3d.rst +++ b/docs/source/examples/cli/pbx_interface_3d.rst @@ -18,11 +18,17 @@ The full text of the file is: :language: xml -Materials and Domain -==================== +Materials +========= -The materials and the domain are those of the :ref:`ex_pbx_3d` example: -crystalline inclusions in a binder, in a cube periodic in the three +The materials are those of the :ref:`ex_pbx_3d` example: crystalline +inclusions (65% of the volume, spherical seeds with lognormal diameters) in +a binder (a ``matrix`` phase, 35% of the volume, seeded with small spheres). + +Domain Geometry +=============== + +The materials fill a cube of side length 3, periodic in the three directions. Settings @@ -33,10 +39,10 @@ between the binder and the inclusions (and between neighboring inclusions), and coarse inside the grains. In 3D, ``mesh_max_edge_length`` sets the maximum edge length of the -triangles on the facets of the polyhedral mesh, 0.15 here: the facets are +triangles on the facets of the polyhedral mesh, 0.2 here: the facets are triangulated to that size before TetGen meshes the cells. ``mesh_max_volume`` sets the maximum volume of the tetrahedra, 0.05 here, -which is the volume of a regular tetrahedron with edges about five times +which is the volume of a regular tetrahedron with edges about four times longer, and TetGen grades the element size between the two. The mesh has a minimum dihedral angle of 15 degrees. @@ -44,8 +50,9 @@ The mesh has a minimum dihedral angle of 15 degrees. The seeds are placed a margin away from the periodic faces (``periodic_margin`` set to ``auto``: half the target edge length, or an eighth of the smallest grain if that is smaller) and the edge optimization moves the seeds that -leave a thin piece of a grain on a face, or a very short edge, since either -forces very small tetrahedra. +leave a thin piece of a grain on a face, a corner narrower than the minimum +angle of the mesh, or a very short edge, since any of these forces very +small tetrahedra. Output Files @@ -53,7 +60,10 @@ Output Files The three plots that this file generates are the seeding, the polyhedral mesh, and the tetrahedral mesh. -The tetrahedral mesh (its facets) is shown in :numref:`f_ex_pbxint3d_tri`. +The seeds and the polyhedral mesh are those of the :ref:`ex_pbx_3d` example +(:numref:`f_ex_pbx3d_seeds` and :numref:`f_ex_pbx3d_poly`), since the seeds +and the settings of the optimization are the same; only the tetrahedral +mesh differs, shown (its facets) in :numref:`f_ex_pbxint3d_tri`. .. _f_ex_pbxint3d_tri: .. figure:: ../../../../src/microstructpy/examples/pbx_interface_3D/trimesh.png diff --git a/docs/source/examples/cli/periodic_2d.rst b/docs/source/examples/cli/periodic_2d.rst index b37bbe11..01d2baa5 100644 --- a/docs/source/examples/cli/periodic_2d.rst +++ b/docs/source/examples/cli/periodic_2d.rst @@ -63,8 +63,10 @@ of the smallest grain if that is smaller. The edge optimization removes the shortest edges of the polygonal mesh, for the same reason, and with the margin it also thickens or removes the pieces of the grains at the periodic faces that are thinner than the margin, which -the placement of the seeds alone cannot prevent: a grain extends beyond its -seed, and its corners can cross a face by a small amount. +the placement of the seeds alone cannot prevent (a grain extends beyond its +seed, and its corners can cross a face by a small amount), and opens the +corners of the grains at the faces that are narrower than the minimum angle +of the mesh. The plots are colored by seed number, so that the pieces of a grain on opposite faces of the domain have the same color. diff --git a/docs/source/examples/cli/periodic_3d.rst b/docs/source/examples/cli/periodic_3d.rst index 2ad91e9f..a2b3bdf0 100644 --- a/docs/source/examples/cli/periodic_3d.rst +++ b/docs/source/examples/cli/periodic_3d.rst @@ -52,8 +52,9 @@ settings act as on a non-periodic mesh. The seeds are placed a margin away from the periodic faces (``periodic_margin`` set to ``auto``: half the target edge length, or an eighth of the smallest grain if that is smaller) and the edge optimization moves the seeds that -leave a thin piece of a grain on a face, or a very short edge, since either -forces very small tetrahedra. +leave a thin piece of a grain on a face, a corner narrower than the minimum +angle of the mesh, or a very short edge, since any of these forces very +small tetrahedra. The plots are colored by seed number and the line widths are reduced to make the grains visible. diff --git a/docs/source/examples/package/periodic_tiling.rst b/docs/source/examples/package/periodic_tiling.rst index dd105707..2003b6ac 100644 --- a/docs/source/examples/package/periodic_tiling.rst +++ b/docs/source/examples/package/periodic_tiling.rst @@ -1,8 +1,8 @@ .. _ex_periodic_tiling: -======================================= +======================================== Periodic Microstructures and Their Tiles -======================================= +======================================== Python Script ============= @@ -17,32 +17,56 @@ The full text of the script is: .. literalinclude:: ../../../../src/microstructpy/examples/periodic_tiling.py :language: python -Periodic Microstructure in 2D -============================= +Domain +====== + +The domain of the microstructure is a :class:`.Square` of side length 2, +with its bottom left corner at the origin. +The microstructure is periodic in both directions: a grain that crosses a +face of the domain continues on the opposite face, and the meshes have +matching nodes on opposite faces. +The periodicity can be restricted to some axes, for example +``periodic='x'``. + +Phases +====== + +There are two phases: circular grains, which occupy two thirds of the +domain, and elliptical inclusions of aspect ratio 2, which occupy the +remaining third. + +Seeds +===== -The domain is a :class:`.Square` of side length 2 and the two phases are -circular grains and elliptical inclusions. The seeds are created with :func:`~microstructpy.seeding.SeedList.from_info` -and positioned with :func:`~microstructpy.seeding.SeedList.position`, with +to fill 90% of the area, so that all of them can be placed, and positioned +with :func:`~microstructpy.seeding.SeedList.position` with ``periodic=True``: a seed that crosses a face of the domain is also checked for overlaps on the opposite face. The ``periodic_margin`` keeps the seeds from ending within half a target edge length of a face, or crossing one by less, since such seeds leave thin pieces of grains on the opposite face and very small triangles there; it is capped at an eighth of the smallest grain, which needs about four elements -across it. +across it (the ``auto`` value of the CLI setting does the same). + +Polygon and Triangle Meshing +============================ + The polygonal mesh is created with :func:`~microstructpy.meshing.PolyMesh.from_seeds`, again with ``periodic=True``, and the triangular mesh with :func:`~microstructpy.meshing.TriMesh.from_polymesh`, which reads the periodicity from the polygonal mesh. The edge optimization of the polygonal mesh (``edge_opt``) lengthens its -shortest edges and, with the same margin, thickens or removes the pieces of -the grains at the faces that are thinner than the margin. -The periodicity can be restricted to some axes, for example -``periodic='x'``. +shortest edges and, with the same margin and the minimum angle of the +triangular mesh, thickens or removes the pieces of the grains at the faces +that are thinner than the margin and opens the corners of the grains at the +faces that are narrower than the minimum angle. + +Plotting +======== -The polygonal mesh and the triangular mesh are then drawn four times, in a +The polygonal mesh and the triangular mesh are drawn four times, in a 2 x 2 tiling of the domain. The grains cut by the faces of the domain are colored by seed number and continue across the faces, and the nodes of the triangular mesh on the face diff --git a/src/microstructpy/examples/pbx_interface_3D.xml b/src/microstructpy/examples/pbx_interface_3D.xml index 0ef6b199..a5962531 100644 --- a/src/microstructpy/examples/pbx_interface_3D.xml +++ b/src/microstructpy/examples/pbx_interface_3D.xml @@ -41,9 +41,6 @@ 1 - 0.7 @@ -54,6 +51,9 @@ True 10 + 15 0.2 0.05 diff --git a/src/microstructpy/examples/periodic_2D.xml b/src/microstructpy/examples/periodic_2D.xml index 9e229145..68f79f8b 100644 --- a/src/microstructpy/examples/periodic_2D.xml +++ b/src/microstructpy/examples/periodic_2D.xml @@ -44,13 +44,15 @@ 1 - + auto + polygonal mesh and, with the margin, the thin pieces and the + narrow corners of the grains at the periodic faces, which + would force very small triangles --> True 25 From df42a6835223f76d2b943b09b30c0c925d4bf3c6 Mon Sep 17 00:00:00 2001 From: Julian Rimoli Date: Wed, 23 Sep 2026 14:35:14 -0700 Subject: [PATCH 28/42] Fix two cross-reference labels in the CLI documentation The periodic domain and settings pages referred to each other with the labels cli_domain and cli_settings, which did not exist. The labels are added at the top of the two pages; the documentation now builds without warnings. The outputs that the documentation build writes into the package (the example results, the gallery and the copied CSV files) are ignored by git. Co-Authored-By: Claude Fable 5.1 --- .gitignore | 4 ++++ docs/source/cli/domain.rst | 2 ++ docs/source/cli/settings.rst | 2 ++ 3 files changed, 8 insertions(+) diff --git a/.gitignore b/.gitignore index af8719d0..aee735e7 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,10 @@ test_reports/ .vscode/ docs/build*/ +# generated by the documentation build (the examples are run by sphinx-gallery) +docs/source/auto_examples/ +docs/source/sphinx_gallery/*.csv +src/microstructpy/examples/*/ ############################################################################### # # diff --git a/docs/source/cli/domain.rst b/docs/source/cli/domain.rst index d5782d2a..6e42d9fa 100644 --- a/docs/source/cli/domain.rst +++ b/docs/source/cli/domain.rst @@ -1,3 +1,5 @@ +.. _cli_domain: + ==================================== ```` - Microstructure Domain ==================================== diff --git a/docs/source/cli/settings.rst b/docs/source/cli/settings.rst index 4ee6aeba..77ca97fa 100644 --- a/docs/source/cli/settings.rst +++ b/docs/source/cli/settings.rst @@ -1,3 +1,5 @@ +.. _cli_settings: + ========================= ```` - Settings ========================= From 86d8dfa90aa409a7ccc73e7ef5892e3baea5257f Mon Sep 17 00:00:00 2001 From: Julian Rimoli Date: Wed, 23 Sep 2026 14:36:28 -0700 Subject: [PATCH 29/42] Do not repeat 'example' after the titles of the referenced example pages Co-Authored-By: Claude Fable 5.1 --- docs/source/cli/settings.rst | 2 +- docs/source/examples/cli/pbx_3d.rst | 2 +- docs/source/examples/cli/pbx_interface_2d.rst | 4 ++-- docs/source/examples/cli/pbx_interface_3d.rst | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/source/cli/settings.rst b/docs/source/cli/settings.rst index 77ca97fa..52d566c3 100644 --- a/docs/source/cli/settings.rst +++ b/docs/source/cli/settings.rst @@ -395,7 +395,7 @@ tetrahedral mesh. A small maximum edge length will increase resolution of the mesh at grain boundaries, while the size of the elements inside the grains is controlled by the maximum volume (see the :ref:`ex_pbx_interface_2d` and -:ref:`ex_pbx_interface_3d` examples). +:ref:`ex_pbx_interface_3d`). The default value is `` inf ``, which effectively turns off the edge length quality control. diff --git a/docs/source/examples/cli/pbx_3d.rst b/docs/source/examples/cli/pbx_3d.rst index 6f1f491a..1659eaac 100644 --- a/docs/source/examples/cli/pbx_3d.rst +++ b/docs/source/examples/cli/pbx_3d.rst @@ -21,7 +21,7 @@ The full text of the file is: Materials ========= -This is the 3D version of the :ref:`ex_pbx_2d` example: crystalline +This is the 3D version of the :ref:`ex_pbx_2d`: crystalline inclusions (65% of the volume, spherical seeds with lognormal diameters) in a binder (a ``matrix`` phase, 35% of the volume, seeded with small spheres). diff --git a/docs/source/examples/cli/pbx_interface_2d.rst b/docs/source/examples/cli/pbx_interface_2d.rst index 5181fbb7..5f01d950 100644 --- a/docs/source/examples/cli/pbx_interface_2d.rst +++ b/docs/source/examples/cli/pbx_interface_2d.rst @@ -21,7 +21,7 @@ The full text of the file is: Materials ========= -The materials are those of the :ref:`ex_pbx_2d` example: crystalline +The materials are those of the :ref:`ex_pbx_2d`: crystalline inclusions (65% of the area, circular seeds with lognormal diameters) in a binder (a ``matrix`` phase, 35% of the area, seeded with small circles). @@ -59,7 +59,7 @@ Output Files The three plots that this file generates are the seeding, the polygon mesh, and the triangular mesh. -The seeds and the polygonal mesh are those of the :ref:`ex_pbx_2d` example +The seeds and the polygonal mesh are those of the :ref:`ex_pbx_2d` (:numref:`f_ex_pbx2d_seeds` and :numref:`f_ex_pbx2d_poly`), since the seeds and the settings of the optimization are the same; only the triangular mesh differs, shown in :numref:`f_ex_pbxint2d_tri`. diff --git a/docs/source/examples/cli/pbx_interface_3d.rst b/docs/source/examples/cli/pbx_interface_3d.rst index 117a5419..5c9935ac 100644 --- a/docs/source/examples/cli/pbx_interface_3d.rst +++ b/docs/source/examples/cli/pbx_interface_3d.rst @@ -21,7 +21,7 @@ The full text of the file is: Materials ========= -The materials are those of the :ref:`ex_pbx_3d` example: crystalline +The materials are those of the :ref:`ex_pbx_3d`: crystalline inclusions (65% of the volume, spherical seeds with lognormal diameters) in a binder (a ``matrix`` phase, 35% of the volume, seeded with small spheres). @@ -60,7 +60,7 @@ Output Files The three plots that this file generates are the seeding, the polyhedral mesh, and the tetrahedral mesh. -The seeds and the polyhedral mesh are those of the :ref:`ex_pbx_3d` example +The seeds and the polyhedral mesh are those of the :ref:`ex_pbx_3d` (:numref:`f_ex_pbx3d_seeds` and :numref:`f_ex_pbx3d_poly`), since the seeds and the settings of the optimization are the same; only the tetrahedral mesh differs, shown (its facets) in :numref:`f_ex_pbxint3d_tri`. From 22fc0795cafb995bf5f0bab148cbe62fb5ba1e04 Mon Sep 17 00:00:00 2001 From: Julian Rimoli Date: Wed, 23 Sep 2026 15:10:19 -0700 Subject: [PATCH 30/42] Share the small idioms of the periodic code The wall ids of a rectangular domain are decoded by one helper, the check that a cell of a periodic tessellation is narrower than the domain is one function for 2D and 3D, the points of a region are collected by one helper, the CLI evaluates the periodic margin once, and the shortest edge finder that the optimizer no longer uses is removed. No arithmetic changes: the meshes of the examples and of the regression cases are identical. Co-Authored-By: Claude Fable 5.1 --- src/microstructpy/_misc.py | 17 +++++++++ src/microstructpy/cli.py | 9 +++-- src/microstructpy/meshing/polymesh.py | 52 ++++++++++++--------------- src/microstructpy/meshing/trimesh.py | 6 ++-- tests/meshing/test_polymesh_fixes.py | 4 +-- 5 files changed, 48 insertions(+), 40 deletions(-) diff --git a/src/microstructpy/_misc.py b/src/microstructpy/_misc.py index 4080e213..509b3f78 100644 --- a/src/microstructpy/_misc.py +++ b/src/microstructpy/_misc.py @@ -236,6 +236,23 @@ def periodic_axes(periodic, n_dim): return flags +def wall_axis_side(wall): + """Axis and side of a wall id of a rectangular domain. + + The walls are numbered -1, -2 for the lower and upper faces along x, + -3, -4 along y and -5, -6 along z (the convention of Voro++). + + Args: + wall (int): The wall id (negative). + + Returns: + tuple: The axis (0, 1 or 2) and the side (0 for the lower face, 1 + for the upper face). + + """ + return divmod(-wall - 1, 2) + + def periodic_domain_limits(domain): """(lower, upper) bounds of a rectangular, axis-aligned domain. diff --git a/src/microstructpy/cli.py b/src/microstructpy/cli.py index d61057b7..88d8fc07 100644 --- a/src/microstructpy/cli.py +++ b/src/microstructpy/cli.py @@ -537,6 +537,10 @@ def run(phases, domain, verbose=False, restart=True, directory='.', seeds = _unpositioned_seeds(phases, domain, rng_seeds) + # the margin between the seeds (and their cells) and the periodic faces + margin = _periodic_margin(periodic_margin, domain.n_dim, mesh_max_volume, + mesh_max_edge_length, seeds) + if seeds_created: if verbose: print('There are ' + str(len(seeds)) + ' seeds.') print('Positioning seeds in domain.') @@ -544,13 +548,8 @@ def run(phases, domain, verbose=False, restart=True, directory='.', kw = 'position' rng_seed = rng_seeds.get(kw, 0) pos_dists = {i: p[kw] for i, p in enumerate(phases) if kw in p} - margin = _periodic_margin(periodic_margin, domain.n_dim, - mesh_max_volume, mesh_max_edge_length, - seeds) seeds.position(domain, pos_dists, rng_seed, rtol=rtol, verbose=verbose, periodic=periodic, periodic_margin=margin) - margin = _periodic_margin(periodic_margin, domain.n_dim, mesh_max_volume, - mesh_max_edge_length, seeds) # Write seeds seeds_types = filetypes.get('seeds', []) diff --git a/src/microstructpy/meshing/polymesh.py b/src/microstructpy/meshing/polymesh.py index 96a09525..26e9285c 100644 --- a/src/microstructpy/meshing/polymesh.py +++ b/src/microstructpy/meshing/polymesh.py @@ -1525,13 +1525,7 @@ def _periodic_pieces_2d(voro, bkdwn2seed, lims, per_axes): # the two cells sharing an edge are cut consistently and # no sliver pieces are created pts = _snap_to_planes(pts, axis, (lb, ub), snap_tol) - extent = pts[:, axis].max() - pts[:, axis].min() - if extent > length + tol: - e_str = 'A cell of the periodic tessellation is wider ' - e_str += 'than the domain along axis ' + str(axis) - e_str += '. More seeds are needed for a periodic ' - e_str += 'microstructure.' - raise ValueError(e_str) + _check_cell_width(pts, axis, length, tol) # part below the lower face, translated to the upper side below = _clip_loop(pts, adj, axis, lb, True, wall_hi, tol) rest = _clip_loop(pts, adj, axis, lb, False, wall_lo, tol) @@ -1587,6 +1581,18 @@ def _periodic_pieces_2d(voro, bkdwn2seed, lims, per_axes): return new_voro, new_bkdwn2seed +def _check_cell_width(pts, axis, length, tol): + """Raise if a cell is wider than the domain along an axis: it cannot + be cut into pieces that tile the domain (too few seeds).""" + extent = pts[:, axis].max() - pts[:, axis].min() + if extent > length + tol: + e_str = 'A cell of the periodic tessellation is wider ' + e_str += 'than the domain along axis ' + str(axis) + e_str += '. More seeds are needed for a periodic ' + e_str += 'microstructure.' + raise ValueError(e_str) + + def _matching_piece(pt_a, pt_b, candidates, pieces, tol): """Piece among the candidates that has vertices at both points.""" for piece_num in candidates: @@ -1799,13 +1805,7 @@ def _periodic_pieces_3d(voro, bkdwn2seed, lims, per_axes): # vertices next to a cut plane are snapped onto it (see # _periodic_pieces_2d) p_verts = _snap_to_planes(p_verts, axis, (lb, ub), snap_tol) - extent = p_verts[:, axis].max() - p_verts[:, axis].min() - if extent > length + tol: - e_str = 'A cell of the periodic tessellation is wider ' - e_str += 'than the domain along axis ' + str(axis) - e_str += '. More seeds are needed for a periodic ' - e_str += 'microstructure.' - raise ValueError(e_str) + _check_cell_width(p_verts, axis, length, tol) below = _clip_polyhedron(p_verts, p_faces, axis, lb, True, wall_hi, tol) rest = _clip_polyhedron(p_verts, p_faces, axis, lb, False, @@ -2250,17 +2250,6 @@ def _edge_lengths(pmesh): return edge_lens -def _shortest_edge(edge_lens): - min_len = float('inf') - min_pair = (-1, -1) - for pair in edge_lens: - length = edge_lens[pair]['length'] - if length < min_len: - min_len = length - min_pair = pair - return min_pair - - def _point_line_vec(pt, line_pts): ptA, ptB = line_pts n_vec = (ptB - ptA) / np.linalg.norm(ptB - ptA) @@ -2383,6 +2372,11 @@ def _optimize_features(cls, pmesh, seedlist, domain, n_iter, verbose, return pmesh +def _region_points(pmesh, region): + """Sorted point numbers of a region (a list of facet numbers).""" + return sorted({kp for f in region for kp in pmesh.facets[f]}) + + def _mesh_features(pmesh, per_axes, dom_lims, scale, min_angle=0.0): """Edges of the mesh, pieces of the cells at the periodic faces and corners of the cells at the faces narrower than ``min_angle``. @@ -2421,10 +2415,10 @@ def _mesh_features(pmesh, per_axes, dom_lims, scale, min_angle=0.0): neighs.add(seed_nums[n]) if not walls: continue - kps = sorted({kp for f in region for kp in pmesh.facets[f]}) + kps = _region_points(pmesh, region) cen = pts[kps].mean(axis=0) for wall in sorted(walls): - axis, side = divmod(-wall - 1, 2) + axis, side = _misc.wall_axis_side(wall) if not per_axes[axis]: continue # the region touches the wall: its extent normal to the wall @@ -2465,14 +2459,14 @@ def _wedge_features(pmesh, pts, seed_nums, per_axes, scale, min_angle): for f in region: neighs = pmesh.facet_neighbors[f] if min(neighs) < 0: - axis, side = divmod(-min(neighs) - 1, 2) + axis, side = _misc.wall_axis_side(min(neighs)) if per_axes[axis]: walls.append((f, axis, side)) else: inner.append(f) if not walls: continue - kps = sorted({kp for f in region for kp in pmesh.facets[f]}) + kps = _region_points(pmesh, region) cen = pts[kps].mean(axis=0) for f_wall, axis, side in walls: wall_set = set(pmesh.facets[f_wall]) diff --git a/src/microstructpy/meshing/trimesh.py b/src/microstructpy/meshing/trimesh.py index 68898132..ac092ccf 100644 --- a/src/microstructpy/meshing/trimesh.py +++ b/src/microstructpy/meshing/trimesh.py @@ -2627,7 +2627,7 @@ def _split_periodic_boundary_2d(tri_pts, pts, facets, facet_nums, polymesh, if f_num <= 0: continue # a facet of a copied cell, outside the domain wall = min(polymesh.facet_neighbors[f_num - 1]) - if wall < 0 and per_axes[(-wall - 1) // 2]: + if wall < 0 and per_axes[_misc.wall_axis_side(wall)[0]]: periodic_segs.add(i) n_new = sum([len(seg_t[i]) for i in periodic_segs]) @@ -2834,7 +2834,7 @@ def _ghost_layer(polymesh, phases, labels, kps, pts, facets, facet_nums, for f_num, neighs in enumerate(polymesh.facet_neighbors): wall = min(neighs) if wall < 0: - axis, side = divmod(-wall - 1, 2) + axis, side = _misc.wall_axis_side(wall) if per_axes[axis]: sign = 1 if side == 0 else -1 touched.setdefault(max(neighs), {})[axis] = sign @@ -2901,7 +2901,7 @@ def image_id(kp, trans): not facet_check(neighs, polymesh, phases)): continue if other < 0: - axis = (-other - 1) // 2 + axis = _misc.wall_axis_side(other)[0] if (per_axes[axis] and trans[axis] != 0 and not any([t for a, t in enumerate(trans) if a != axis])): diff --git a/tests/meshing/test_polymesh_fixes.py b/tests/meshing/test_polymesh_fixes.py index ec93dd62..6a81582d 100644 --- a/tests/meshing/test_polymesh_fixes.py +++ b/tests/meshing/test_polymesh_fixes.py @@ -22,7 +22,6 @@ from microstructpy.meshing.polymesh import _edge_lengths from microstructpy.meshing.polymesh import _loop_area from microstructpy.meshing.polymesh import _segment_cross -from microstructpy.meshing.polymesh import _shortest_edge from microstructpy.meshing.polymesh import kp_loop from microstructpy.seeding import Seed from microstructpy.seeding import SeedList @@ -91,8 +90,7 @@ def _check_partition(pmesh, domain, n_seeds): def _min_edge_length(pmesh): - edge_lens = _edge_lengths(pmesh) - return edge_lens[_shortest_edge(edge_lens)]['length'] + return min([e['length'] for e in _edge_lengths(pmesh).values()]) # --------------------------------------------------------------------------- # From 7b3d734044f843971c022053c862e3257a0a1ff0 Mon Sep 17 00:00:00 2001 From: Julian Rimoli Date: Wed, 23 Sep 2026 15:22:05 -0700 Subject: [PATCH 31/42] Share the helpers of the periodic tests The wedge configuration, the shortest edge of a polygonal mesh, the volumes of the cells of each seed, the tiled reference tessellation and the check of the pairs on the periodic faces were written twice, for 2D and 3D or in two test files. They are one module now, and the 2D and 3D tests use the same checks. Co-Authored-By: Claude Fable 5.1 --- tests/meshing/periodic_helpers.py | 135 ++++++++++++++++++++ tests/meshing/test_edge_opt_pieces.py | 45 ++----- tests/meshing/test_periodic_polymesh.py | 156 +++--------------------- tests/meshing/test_periodic_trimesh.py | 95 +++------------ tests/meshing/test_polymesh_fixes.py | 10 +- 5 files changed, 185 insertions(+), 256 deletions(-) create mode 100644 tests/meshing/periodic_helpers.py diff --git a/tests/meshing/periodic_helpers.py b/tests/meshing/periodic_helpers.py new file mode 100644 index 00000000..7627673c --- /dev/null +++ b/tests/meshing/periodic_helpers.py @@ -0,0 +1,135 @@ +"""Helpers shared by the tests of periodic microstructures.""" +import copy +import itertools + +import numpy as np + +import microstructpy as msp +from microstructpy.meshing import PolyMesh +from microstructpy.meshing.polymesh import _edge_lengths +from microstructpy.seeding import Seed +from microstructpy.seeding import SeedList + + +def min_edge(pmesh): + """Length of the shortest edge of a polygonal/polyhedral mesh.""" + return min([e['length'] for e in _edge_lengths(pmesh).values()]) + + +def wedge_seeds_2d(angle_deg): + """Circles in a square of side 3 to be tessellated periodic in x. + + The seeds A and B are 0.5 apart along a line tilted by ``angle_deg`` + from the x axis, so their facet (normal to that line) meets the face + x = 3 at that angle, at about (3, 0.1): the cell of B (seed 1) has a + wedge there, and the cell of A (seed 0) its image on the face x = 0. + The other seeds form a jittered grid away from them. + """ + rng = np.random.RandomState(0) + ang = np.radians(angle_deg) + positions = [[2.5, 1.0], + [2.5 + 0.5 * np.cos(ang), 1.0 + 0.5 * np.sin(ang)]] + for x in (0.75, 1.75): + for y in (0.75, 1.75, 2.75): + if (x, y) == (0.75, 0.75): + continue # its image would cut the corner of the wedge + positions.append([x + 0.03 * (2 * rng.rand() - 1), + y + 0.03 * (2 * rng.rand() - 1)]) + positions.append([2.75, 2.75]) + return SeedList([Seed.factory('circle', r=0.2, position=p) + for p in positions]) + + +def check_periodic_pairs(points, facets, point_pairs, facet_pairs, per_axes, + domain): + """Check the pairs of points and facets on the periodic faces. + + Every point on a periodic face is paired, exactly once, with a point + on the opposite face that is its exact translate by the domain + length, and every facet whose points all lie on a face is paired with + the facet made of their images. + + Args: + points (list or numpy.ndarray): The points of the mesh. + facets (list): The facets of the mesh. + point_pairs (dict): Axis -> list of (lower, upper) point numbers. + facet_pairs (dict): Axis -> list of (lower, upper) facet numbers. + per_axes (list): The periodicity flags. + domain (from :mod:`microstructpy.geometry`): The domain. + + Returns: + dict: Axis -> (set of the points on the lower face, set of the + points on the upper face, number of facets on the lower face). + + """ + pts = np.array(points) + lims = np.array(domain.limits) + n_dim = len(lims) + faces = {} + for axis, flag in enumerate(per_axes): + if not flag: + assert axis not in point_pairs + continue + lb, ub = lims[axis] + shift = np.zeros(n_dim) + shift[axis] = ub - lb + pairs = point_pairs[axis] + low = set(np.nonzero(np.isclose(pts[:, axis], lb))[0]) + high = set(np.nonzero(np.isclose(pts[:, axis], ub))[0]) + assert len(pairs) == len(low) == len(high) > 0 + assert set([lo for lo, _ in pairs]) == low + assert set([hi for _, hi in pairs]) == high + for lo, hi in pairs: + assert np.array_equal(pts[hi], pts[lo] + shift) + kp_map = dict(pairs) + f_pairs = dict(facet_pairs[axis]) + n_low = 0 + for f_num, facet in enumerate(facets): + if all([kp in low for kp in facet]): + n_low += 1 + assert f_num in f_pairs + image = facets[f_pairs[f_num]] + assert set(image) == set([kp_map[kp] for kp in facet]) + faces[axis] = (low, high, n_low) + return faces + + +def seed_volumes(pmesh, n_seeds): + """Volume (area) of the cells of each seed, pieces added up.""" + vols = np.zeros(n_seeds) + for seed_num, vol in zip(pmesh.seed_numbers, pmesh.volumes): + vols[seed_num] += vol + return vols + + +def tiled_reference_volumes(seeds, domain, per_axes): + """Volumes of the cells of the seeds in a periodic tessellation, + computed as a non-periodic tessellation of the seeds tiled across the + periodic axes (3 copies per periodic axis).""" + lims = np.array(domain.limits) + lengths = lims[:, 1] - lims[:, 0] + options = [[-length, 0.0, length] if flag else [0.0] + for length, flag in zip(lengths, per_axes)] + tiled = SeedList() + for t in itertools.product(*options): + for seed in seeds: + copy_seed = copy.deepcopy(seed) + copy_seed.position = list(np.array(seed.position) + np.array(t)) + tiled.append(copy_seed) + n_seeds = len(seeds) + big_lims = [(lb - length, ub + length) if flag else (lb, ub) + for (lb, ub), length, flag in zip(lims, lengths, per_axes)] + if len(lims) == 2: + big_domain = msp.geometry.Rectangle(limits=big_lims) + else: + big_domain = msp.geometry.Box(limits=big_lims) + pmesh = PolyMesh.from_seeds(tiled, big_domain) + # the cells of the original copies (the zero translation) + i_zero = [i for i, t in enumerate(itertools.product(*options)) + if not any(t)][0] + vols = np.zeros(n_seeds) + for seed_num, vol in zip(pmesh.seed_numbers, pmesh.volumes): + block, local = divmod(seed_num, n_seeds) + if block == i_zero: + vols[local] += vol + return vols diff --git a/tests/meshing/test_edge_opt_pieces.py b/tests/meshing/test_edge_opt_pieces.py index 9f022f93..d1a407f7 100644 --- a/tests/meshing/test_edge_opt_pieces.py +++ b/tests/meshing/test_edge_opt_pieces.py @@ -14,6 +14,8 @@ from microstructpy.meshing.polymesh import _wedge_geometry from microstructpy.seeding import Seed from microstructpy.seeding import SeedList +from periodic_helpers import min_edge +from periodic_helpers import wedge_seeds_2d # --------------------------------------------------------------------------- # @@ -65,10 +67,6 @@ def _pieces(pmesh, domain): return [f for f in feats if f['kind'] == 'piece'] -def _min_edge(pmesh): - return min([e['length'] for e in _edge_lengths(pmesh).values()]) - - def _feature(kind, size, key, seeds=()): return {'kind': kind, 'size': size, 'key': key, 'seeds': list(seeds)} @@ -213,7 +211,7 @@ def test_edge_opt_fixes_thin_piece_2d(): if p['size'] < margin] assert len(thin_0) == 1 assert np.isclose(thin_0[0], 0.025, atol=0.01) - min_edge_0 = _min_edge(pmesh_0) + min_edge_0 = min_edge(pmesh_0) pmesh = PolyMesh.from_seeds(seeds, domain, periodic=True, edge_opt=True, n_iter=2, periodic_margin=margin) @@ -222,7 +220,7 @@ def test_edge_opt_fixes_thin_piece_2d(): # the mesh did not get shorter thin = [p['size'] for p in _pieces(pmesh, domain) if p['size'] < margin] assert thin == [] - assert _min_edge(pmesh) >= min_edge_0 - 1e-9 + assert min_edge(pmesh) >= min_edge_0 - 1e-9 # the mesh is periodic and the seeds reproduce it assert pmesh.periodic_axes == [True, True] @@ -254,13 +252,13 @@ def test_edge_opt_fixes_thin_piece_3d(): if p['size'] < margin] assert len(thin_0) == 2 assert min(thin_0) < 0.01 - min_edge_0 = _min_edge(pmesh_0) + min_edge_0 = min_edge(pmesh_0) pmesh = PolyMesh.from_seeds(seeds, domain, periodic=True, edge_opt=True, n_iter=5, periodic_margin=margin) thin = [p['size'] for p in _pieces(pmesh, domain) if p['size'] < margin] assert thin == [] - assert _min_edge(pmesh) >= min_edge_0 - 1e-9 + assert min_edge(pmesh) >= min_edge_0 - 1e-9 assert np.isclose(sum(pmesh.volumes), domain.volume) pmesh_re = PolyMesh.from_seeds(seeds, domain, periodic=True) assert np.allclose(np.sort(pmesh_re.volumes), np.sort(pmesh.volumes), @@ -270,25 +268,6 @@ def test_edge_opt_fixes_thin_piece_3d(): # --------------------------------------------------------------------------- # # Wedges (corners at the periodic faces narrower than the mesh angle) # # --------------------------------------------------------------------------- # -def _wedge_seeds_2d(angle_deg=15.0): - """Circles in a square of side 3, periodic in x. The seeds A and B are - 0.5 apart along a line tilted by ``angle_deg`` from the x axis, so - their facet (normal to that line) meets the face x = 3 at that angle, - at about (3, 0.1): the cell of B has a wedge there. The other seeds - form a jittered grid away from them.""" - rng = np.random.RandomState(0) - ang = np.radians(angle_deg) - positions = [[2.5, 1.0], - [2.5 + 0.5 * np.cos(ang), 1.0 + 0.5 * np.sin(ang)]] - for x in (0.75, 1.75): - for y in (0.75, 1.75, 2.75): - if (x, y) == (0.75, 0.75): - continue # its image would cut the corner of the wedge - positions.append([x + 0.03 * (2 * rng.rand() - 1), - y + 0.03 * (2 * rng.rand() - 1)]) - positions.append([2.75, 2.75]) - return SeedList([Seed.factory('circle', r=0.2, position=p) - for p in positions]) def _wedge_seeds_3d(angle_deg=15.0): @@ -350,7 +329,7 @@ def test_wedge_geometry(): def test_wedge_features_2d(): domain = geometry.Square(side_length=3, corner=(0, 0)) - seeds = _wedge_seeds_2d(15.0) + seeds = wedge_seeds_2d(15.0) pmesh = PolyMesh.from_seeds(seeds, domain, periodic='x') # no wedges narrower than 10 degrees; two narrower than 25: the corner # of the cell of B (seed 1) on the face x = 3, and its image on the @@ -398,16 +377,16 @@ def test_wedge_features_3d(): def test_edge_opt_opens_wedge_2d(): np.random.seed(0) domain = geometry.Square(side_length=3, corner=(0, 0)) - seeds = _wedge_seeds_2d(15.0) + seeds = wedge_seeds_2d(15.0) pmesh_0 = PolyMesh.from_seeds(seeds, domain, periodic='x') assert len(_wedges(pmesh_0, domain, [True, False], 25.0)) == 2 - min_edge_0 = _min_edge(pmesh_0) + min_edge_0 = min_edge(pmesh_0) pmesh = PolyMesh.from_seeds(seeds, domain, periodic='x', edge_opt=True, n_iter=5, periodic_margin=0.05, min_angle=25.0) assert _wedges(pmesh, domain, [True, False], 25.0) == [] - assert _min_edge(pmesh) >= min_edge_0 - 1e-9 + assert min_edge(pmesh) >= min_edge_0 - 1e-9 assert np.isclose(sum(pmesh.volumes), domain.area) pmesh_re = PolyMesh.from_seeds(seeds, domain, periodic='x') assert np.allclose(np.sort(pmesh_re.volumes), np.sort(pmesh.volumes), @@ -421,10 +400,10 @@ def test_edge_opt_opens_wedge_3d(): pmesh_0 = PolyMesh.from_seeds(seeds, domain, periodic='x') n_0 = len(_wedges(pmesh_0, domain, [True, False, False], 25.0)) assert n_0 >= 1 - min_edge_0 = _min_edge(pmesh_0) + min_edge_0 = min_edge(pmesh_0) pmesh = PolyMesh.from_seeds(seeds, domain, periodic='x', edge_opt=True, n_iter=5, periodic_margin=0.05, min_angle=25.0) assert len(_wedges(pmesh, domain, [True, False, False], 25.0)) < n_0 - assert _min_edge(pmesh) >= min_edge_0 - 1e-9 + assert min_edge(pmesh) >= min_edge_0 - 1e-9 assert np.isclose(sum(pmesh.volumes), domain.volume) diff --git a/tests/meshing/test_periodic_polymesh.py b/tests/meshing/test_periodic_polymesh.py index 55c8c378..f389c6c2 100644 --- a/tests/meshing/test_periodic_polymesh.py +++ b/tests/meshing/test_periodic_polymesh.py @@ -1,7 +1,4 @@ """Tests for periodic polygonal and polyhedral meshes.""" -import copy -import itertools - import numpy as np import pytest import scipy.stats @@ -11,6 +8,9 @@ from microstructpy.meshing.polymesh import kp_loop from microstructpy.seeding import Seed from microstructpy.seeding import SeedList +from periodic_helpers import check_periodic_pairs +from periodic_helpers import seed_volumes +from periodic_helpers import tiled_reference_volumes # --------------------------------------------------------------------------- # @@ -29,74 +29,15 @@ def _periodic_seeds(domain, per_axes, rng_seed=0, fill=0.55): return seeds -def _seed_areas(pmesh, n_seeds): - areas = np.zeros(n_seeds) - for seed_num, vol in zip(pmesh.seed_numbers, pmesh.volumes): - areas[seed_num] += vol - return areas - - -def _tiled_reference_areas(seeds, domain, per_axes): - """Areas of the cells of the seeds in a periodic tessellation, computed - as a non-periodic tessellation of the seeds tiled across the periodic - axes (3 copies per periodic axis).""" - lims = np.array(domain.limits) - lengths = lims[:, 1] - lims[:, 0] - options = [[-length, 0.0, length] if flag else [0.0] - for length, flag in zip(lengths, per_axes)] - tiled = SeedList() - for t in itertools.product(*options): - for seed in seeds: - copy_seed = copy.deepcopy(seed) - copy_seed.position = list(np.array(seed.position) + np.array(t)) - tiled.append(copy_seed) - n_seeds = len(seeds) - big_lims = [(lb - length, ub + length) if flag else (lb, ub) - for (lb, ub), length, flag in zip(lims, lengths, per_axes)] - big_domain = msp.geometry.Rectangle(limits=big_lims) - pmesh = PolyMesh.from_seeds(tiled, big_domain) - # the cells of the original copies (the zero translation) - i_zero = [i for i, t in enumerate(itertools.product(*options)) - if not any(t)][0] - areas = np.zeros(n_seeds) - for seed_num, vol in zip(pmesh.seed_numbers, pmesh.volumes): - block, local = divmod(seed_num, n_seeds) - if block == i_zero: - areas[local] += vol - return areas - - def _check_periodic_structure(pmesh, domain, per_axes): """Points and facets on the periodic faces are paired and are exact - translates of each other.""" - pts = np.array(pmesh.points) - lims = np.array(domain.limits) - lengths = lims[:, 1] - lims[:, 0] + translates of each other, and the facets on the faces are wall + facets.""" assert pmesh.periodic_axes == list(per_axes) - for axis, flag in enumerate(per_axes): - if not flag: - assert axis not in pmesh.periodic_points - continue - lb, ub = lims[axis] - shift = np.zeros(2) - shift[axis] = lengths[axis] - pairs = pmesh.periodic_points[axis] - low = set(np.nonzero(np.isclose(pts[:, axis], lb))[0]) - high = set(np.nonzero(np.isclose(pts[:, axis], ub))[0]) - assert len(pairs) == len(low) == len(high) - assert set([lo for lo, _ in pairs]) == low - assert set([hi for _, hi in pairs]) == high - for lo, hi in pairs: - assert np.array_equal(pts[hi], pts[lo] + shift) - # facets on the lower face are paired with facets on the upper face - kp_map = dict(pairs) - f_pairs = dict(pmesh.periodic_facets[axis]) - for f_num, facet in enumerate(pmesh.facets): - if all([kp in low for kp in facet]): - assert f_num in f_pairs - image = pmesh.facets[f_pairs[f_num]] - assert set(image) == set([kp_map[kp] for kp in facet]) - # every boundary facet on the faces is a wall facet + faces = check_periodic_pairs(pmesh.points, pmesh.facets, + pmesh.periodic_points, + pmesh.periodic_facets, per_axes, domain) + for axis, (low, high, _) in faces.items(): for f_num, neighs in enumerate(pmesh.facet_neighbors): facet = pmesh.facets[f_num] if all([kp in low for kp in facet]): @@ -123,7 +64,7 @@ def test_two_seeds_periodic_in_x(): # cell 0 is cut by the periodic face: [0, 0.35] and [0.85, 1] assert len(pmesh.regions) == 3 assert sorted(pmesh.seed_numbers) == [0, 0, 1] - areas = _seed_areas(pmesh, 2) + areas = seed_volumes(pmesh, 2) assert np.allclose(areas, [0.5, 0.5]) pieces = sorted([v for v, s in zip(pmesh.volumes, pmesh.seed_numbers) if s == 0]) @@ -167,8 +108,8 @@ def test_periodic_matches_tiled_reference(per_axes): assert np.isclose(sum(pmesh.volumes), domain.area) assert np.all(np.array(pmesh.volumes) > 0) assert set(pmesh.seed_numbers) == set(range(len(seeds))) - areas = _seed_areas(pmesh, len(seeds)) - ref = _tiled_reference_areas(seeds, domain, per_axes) + areas = seed_volumes(pmesh, len(seeds)) + ref = tiled_reference_volumes(seeds, domain, per_axes) assert np.allclose(areas, ref, rtol=1e-9, atol=1e-12) _check_periodic_structure(pmesh, domain, per_axes) @@ -238,67 +179,6 @@ def test_periodic_errors(): # --------------------------------------------------------------------------- # # 3D # # --------------------------------------------------------------------------- # -def _seed_volumes(pmesh, n_seeds): - vols = np.zeros(n_seeds) - for seed_num, vol in zip(pmesh.seed_numbers, pmesh.volumes): - vols[seed_num] += vol - return vols - - -def _tiled_reference_volumes(seeds, domain, per_axes): - lims = np.array(domain.limits) - lengths = lims[:, 1] - lims[:, 0] - options = [[-length, 0.0, length] if flag else [0.0] - for length, flag in zip(lengths, per_axes)] - tiled = SeedList() - for t in itertools.product(*options): - for seed in seeds: - copy_seed = copy.deepcopy(seed) - copy_seed.position = list(np.array(seed.position) + np.array(t)) - tiled.append(copy_seed) - big_lims = [(lb - length, ub + length) if flag else (lb, ub) - for (lb, ub), length, flag in zip(lims, lengths, per_axes)] - pmesh = PolyMesh.from_seeds(tiled, msp.geometry.Box(limits=big_lims)) - i_zero = [i for i, t in enumerate(itertools.product(*options)) - if not any(t)][0] - n_seeds = len(seeds) - vols = np.zeros(n_seeds) - for seed_num, vol in zip(pmesh.seed_numbers, pmesh.volumes): - block, local = divmod(seed_num, n_seeds) - if block == i_zero: - vols[local] += vol - return vols - - -def _check_periodic_structure_3d(pmesh, domain, per_axes): - pts = np.array(pmesh.points) - lims = np.array(domain.limits) - lengths = lims[:, 1] - lims[:, 0] - assert pmesh.periodic_axes == list(per_axes) - for axis, flag in enumerate(per_axes): - if not flag: - assert axis not in pmesh.periodic_points - continue - lb, ub = lims[axis] - shift = np.zeros(3) - shift[axis] = lengths[axis] - pairs = pmesh.periodic_points[axis] - low = set(np.nonzero(np.isclose(pts[:, axis], lb))[0]) - high = set(np.nonzero(np.isclose(pts[:, axis], ub))[0]) - assert len(pairs) == len(low) == len(high) > 0 - for lo, hi in pairs: - assert np.array_equal(pts[hi], pts[lo] + shift) - kp_map = dict(pairs) - f_pairs = dict(pmesh.periodic_facets[axis]) - n_low = 0 - for f_num, facet in enumerate(pmesh.facets): - if all([kp in low for kp in facet]): - n_low += 1 - assert f_num in f_pairs - image = pmesh.facets[f_pairs[f_num]] - assert set(image) == set([kp_map[kp] for kp in facet]) - assert min(pmesh.facet_neighbors[f_num]) == -(2 * axis + 1) - assert n_low > 0 def test_two_spheres_periodic_in_x(): @@ -307,11 +187,11 @@ def test_two_spheres_periodic_in_x(): Seed.factory('sphere', r=0.2, position=(0.7, .5, .5))]) pmesh = PolyMesh.from_seeds(seeds, domain, periodic='x') assert sorted(pmesh.seed_numbers) == [0, 0, 1] - assert np.allclose(_seed_volumes(pmesh, 2), [0.5, 0.5]) + assert np.allclose(seed_volumes(pmesh, 2), [0.5, 0.5]) pieces = sorted([v for v, s in zip(pmesh.volumes, pmesh.seed_numbers) if s == 0]) assert np.allclose(pieces, [0.05, 0.45]) - _check_periodic_structure_3d(pmesh, domain, [True, False, False]) + _check_periodic_structure(pmesh, domain, [True, False, False]) def test_single_sphere_tiles_the_cube(): @@ -322,7 +202,7 @@ def test_single_sphere_tiles_the_cube(): assert len(pmesh.points) == 27 assert len(pmesh.facets) == 36 assert np.isclose(sum(pmesh.volumes), 1.0) - _check_periodic_structure_3d(pmesh, domain, [True, True, True]) + _check_periodic_structure(pmesh, domain, [True, True, True]) for axis in range(3): assert len(pmesh.periodic_points[axis]) == 9 assert len(pmesh.periodic_facets[axis]) == 4 @@ -342,11 +222,11 @@ def test_periodic_3d_matches_tiled_reference(per_axes): assert np.isclose(sum(pmesh.volumes), domain.volume) assert np.all(np.array(pmesh.volumes) > 0) assert set(pmesh.seed_numbers) == set(range(len(seeds))) - vols = _seed_volumes(pmesh, len(seeds)) - ref = _tiled_reference_volumes(seeds, domain, per_axes) + vols = seed_volumes(pmesh, len(seeds)) + ref = tiled_reference_volumes(seeds, domain, per_axes) # vertices within 1e-5 of the faces are snapped onto them assert np.allclose(vols, ref, rtol=1e-6, atol=1e-6) - _check_periodic_structure_3d(pmesh, domain, per_axes) + _check_periodic_structure(pmesh, domain, per_axes) def test_periodic_3d_file_round_trip(tmp_path): diff --git a/tests/meshing/test_periodic_trimesh.py b/tests/meshing/test_periodic_trimesh.py index 514338e9..73a3266c 100644 --- a/tests/meshing/test_periodic_trimesh.py +++ b/tests/meshing/test_periodic_trimesh.py @@ -8,8 +8,9 @@ from microstructpy.meshing import RasterMesh from microstructpy.meshing import TriMesh from microstructpy.meshing import trimesh as trimesh_module -from microstructpy.seeding import Seed from microstructpy.seeding import SeedList +from periodic_helpers import check_periodic_pairs +from periodic_helpers import wedge_seeds_2d # --------------------------------------------------------------------------- # @@ -43,37 +44,18 @@ def _element_areas(mesh): def _check_periodic_mesh(mesh, domain, per_axes): - pts = np.array(mesh.points) - lims = np.array(domain.limits) + """Every node on a periodic face is paired with its exact image, and + the facets on the faces (edges in 2D, triangles in 3D) are paired.""" assert mesh.periodic_axes == list(per_axes) - for axis, flag in enumerate(per_axes): - if not flag: - assert axis not in mesh.periodic_nodes - continue - lb, ub = lims[axis] - shift = np.zeros(2) - shift[axis] = ub - lb - pairs = mesh.periodic_nodes[axis] - low = set(np.nonzero(np.isclose(pts[:, axis], lb))[0]) - high = set(np.nonzero(np.isclose(pts[:, axis], ub))[0]) - # every node on a periodic face is paired, exactly - assert len(pairs) == len(low) == len(high) > 0 - assert set([lo for lo, _ in pairs]) == low - assert set([hi for _, hi in pairs]) == high - for lo, hi in pairs: - assert np.array_equal(pts[hi], pts[lo] + shift) - # facets (edges) on the faces are paired - kp_map = dict(pairs) - f_pairs = dict(mesh.periodic_facets[axis]) - n_low = 0 - for f_num, facet in enumerate(mesh.facets): - if all([kp in low for kp in facet]): - n_low += 1 - assert f_num in f_pairs - image = mesh.facets[f_pairs[f_num]] - assert set(image) == set([kp_map[kp] for kp in facet]) - # the edges on a face connect its nodes in a chain - assert n_low == len(pairs) - 1 + faces = check_periodic_pairs(mesh.points, mesh.facets, + mesh.periodic_nodes, mesh.periodic_facets, + per_axes, domain) + for axis, (low, high, n_low) in faces.items(): + if domain.n_dim == 2: + # the edges on a face connect its nodes in a chain + assert n_low == len(low) - 1 + else: + assert n_low > 0 # --------------------------------------------------------------------------- # @@ -174,20 +156,9 @@ def nset(name): def _wedge_polymesh(angle_deg): """A square of side 3, periodic in x, with a facet meeting the face x = 3 at ``angle_deg`` (see the tests of the edge optimization).""" - rng = np.random.RandomState(0) - ang = np.radians(angle_deg) - positions = [[2.5, 1.0], - [2.5 + 0.5 * np.cos(ang), 1.0 + 0.5 * np.sin(ang)]] - for x in (0.75, 1.75): - for y in (0.75, 1.75, 2.75): - if (x, y) != (0.75, 0.75): - positions.append([x + 0.03 * (2 * rng.rand() - 1), - y + 0.03 * (2 * rng.rand() - 1)]) - positions.append([2.75, 2.75]) - seeds = SeedList([Seed.factory('circle', r=0.2, position=p) - for p in positions]) domain = msp.geometry.Square(side_length=3, corner=(0, 0)) - return PolyMesh.from_seeds(seeds, domain, periodic='x') + return PolyMesh.from_seeds(wedge_seeds_2d(angle_deg), domain, + periodic='x') def _min_edge(mesh): @@ -293,42 +264,10 @@ def _element_volumes(mesh): return np.linalg.det(rel) / 6.0 -def _check_periodic_mesh_3d(mesh, domain, per_axes): - pts = np.array(mesh.points) - lims = np.array(domain.limits) - assert mesh.periodic_axes == list(per_axes) - for axis, flag in enumerate(per_axes): - if not flag: - assert axis not in mesh.periodic_nodes - continue - lb, ub = lims[axis] - shift = np.zeros(3) - shift[axis] = ub - lb - pairs = mesh.periodic_nodes[axis] - low = set(np.nonzero(np.isclose(pts[:, axis], lb))[0]) - high = set(np.nonzero(np.isclose(pts[:, axis], ub))[0]) - assert len(pairs) == len(low) == len(high) > 0 - assert set([lo for lo, _ in pairs]) == low - assert set([hi for _, hi in pairs]) == high - for lo, hi in pairs: - assert np.array_equal(pts[hi], pts[lo] + shift) - # the triangles on the faces are paired - kp_map = dict(pairs) - f_pairs = dict(mesh.periodic_facets[axis]) - n_low = 0 - for f_num, facet in enumerate(mesh.facets): - if all([kp in low for kp in facet]): - n_low += 1 - assert f_num in f_pairs - image = mesh.facets[f_pairs[f_num]] - assert set(image) == set([kp_map[kp] for kp in facet]) - assert n_low > 0 - - def test_periodic_tetmesh_nodes_match(periodic_case_3d): domain, phases, seeds, pmesh = periodic_case_3d mesh = TriMesh.from_polymesh(pmesh, phases, min_angle=10) - _check_periodic_mesh_3d(mesh, domain, [True, True, True]) + _check_periodic_mesh(mesh, domain, [True, True, True]) vols = _element_volumes(mesh) assert np.all(np.abs(vols) > 0) assert np.isclose(np.abs(vols).sum(), domain.volume) @@ -342,7 +281,7 @@ def test_periodic_tetmesh_single_axis(): seeds.position(domain, rtol=0.0, rng_seed=3, periodic='z') pmesh = PolyMesh.from_seeds(seeds, domain, periodic='z') mesh = TriMesh.from_polymesh(pmesh, phases, min_angle=10) - _check_periodic_mesh_3d(mesh, domain, [False, False, True]) + _check_periodic_mesh(mesh, domain, [False, False, True]) assert np.isclose(np.abs(_element_volumes(mesh)).sum(), domain.volume) diff --git a/tests/meshing/test_polymesh_fixes.py b/tests/meshing/test_polymesh_fixes.py index 6a81582d..16fc6e61 100644 --- a/tests/meshing/test_polymesh_fixes.py +++ b/tests/meshing/test_polymesh_fixes.py @@ -19,12 +19,12 @@ from microstructpy import geometry from microstructpy.meshing.polymesh import PolyMesh -from microstructpy.meshing.polymesh import _edge_lengths from microstructpy.meshing.polymesh import _loop_area from microstructpy.meshing.polymesh import _segment_cross from microstructpy.meshing.polymesh import kp_loop from microstructpy.seeding import Seed from microstructpy.seeding import SeedList +from periodic_helpers import min_edge # --------------------------------------------------------------------------- # @@ -89,10 +89,6 @@ def _check_partition(pmesh, domain, n_seeds): assert n_in == 1 -def _min_edge_length(pmesh): - return min([e['length'] for e in _edge_lengths(pmesh).values()]) - - # --------------------------------------------------------------------------- # # 1. _loop_area # # --------------------------------------------------------------------------- # @@ -193,7 +189,7 @@ def test_edge_opt(capsys): seeds_orig = copy.deepcopy(seeds) pmesh_0 = PolyMesh.from_seeds(seeds, domain) - min_len_0 = _min_edge_length(pmesh_0) + min_len_0 = min_edge(pmesh_0) capsys.readouterr() pmesh = PolyMesh.from_seeds(seeds, domain, edge_opt=True, n_iter=10, @@ -202,7 +198,7 @@ def test_edge_opt(capsys): assert captured.out == '' # the minimum edge length does not decrease - assert _min_edge_length(pmesh) >= min_len_0 + assert min_edge(pmesh) >= min_len_0 # the seeds are in the accepted state: re-tessellating them # reproduces the returned mesh From f99c00d6bd0f0aa8dbd40f9c1952535de3072d4b Mon Sep 17 00:00:00 2001 From: Julian Rimoli Date: Wed, 23 Sep 2026 15:22:06 -0700 Subject: [PATCH 32/42] One union-find and one point clustering for the periodic code The disjoint sets of the point mergers, of the merging of amorphous cells and of the classes of periodic edges are one class, and the clustering of close points (the pairs of a KD-tree, the sets, the mean of each cluster) is one function used by both point mergers. The partitions and the means are computed the same way as before: the meshes of the regression cases are identical. Co-Authored-By: Claude Fable 5.1 --- src/microstructpy/_misc.py | 31 +++++++++++++ src/microstructpy/meshing/polymesh.py | 64 +++++++++++---------------- src/microstructpy/meshing/trimesh.py | 31 +++---------- 3 files changed, 64 insertions(+), 62 deletions(-) diff --git a/src/microstructpy/_misc.py b/src/microstructpy/_misc.py index 509b3f78..a08f7e14 100644 --- a/src/microstructpy/_misc.py +++ b/src/microstructpy/_misc.py @@ -236,6 +236,37 @@ def periodic_axes(periodic, n_dim): return flags +class UnionFind(object): + """Disjoint sets of hashable items, with path halving. + + Args: + items (iterable): The items, each initially in its own set. + + """ + def __init__(self, items): + self.parent = {item: item for item in items} + + def find(self, item): + """Root of the set of an item.""" + parent = self.parent + while parent[item] != item: + parent[item] = parent[parent[item]] + item = parent[item] + return item + + def union(self, item_a, item_b): + """Join the sets of two items under the smaller of their roots, so + that the root of a set of integers is its smallest member.""" + r_a, r_b = self.find(item_a), self.find(item_b) + if r_a != r_b: + self.parent[max(r_a, r_b)] = min(r_a, r_b) + + def attach(self, item_a, item_b): + """Put the root of the set of the first item under the root of the + set of the second one.""" + self.parent[self.find(item_a)] = self.find(item_b) + + def wall_axis_side(wall): """Axis and side of a wall id of a rectangular domain. diff --git a/src/microstructpy/meshing/polymesh.py b/src/microstructpy/meshing/polymesh.py index 26e9285c..e27031ae 100644 --- a/src/microstructpy/meshing/polymesh.py +++ b/src/microstructpy/meshing/polymesh.py @@ -1276,32 +1276,15 @@ def _collapse_close_points(pts, facets, facet_neighbors, regions, eps): """ pts = np.array(pts, dtype='float') n_pts, n_dim = pts.shape - pairs = cKDTree(pts).query_pairs(eps) - if not pairs: + roots, means = _cluster_points(pts, eps) + if len(means) == n_pts: return pts.tolist(), facets, facet_neighbors, regions - parent = list(range(n_pts)) - - def find(i): - while parent[i] != i: - parent[i] = parent[parent[i]] - i = parent[i] - return i - - for i, j in pairs: - ri, rj = find(i), find(j) - if ri != rj: - parent[max(ri, rj)] = min(ri, rj) - roots = [find(i) for i in range(n_pts)] - - clusters = {} - for i, root in enumerate(roots): - clusters.setdefault(root, []).append(i) new_pts = [] root_ids = {} - for root in sorted(clusters): + for root in sorted(means): root_ids[root] = len(new_pts) - new_pts.append(pts[clusters[root]].mean(axis=0)) + new_pts.append(means[root]) kp_new = [root_ids[roots[i]] for i in range(n_pts)] new_facets = [] @@ -1324,6 +1307,26 @@ def find(i): return np.array(new_pts).tolist(), new_facets, new_neighs, new_regions +def _cluster_points(pts, tol): + """Clusters of points closer than ``tol`` to each other (transitively). + + Returns: + tuple: The root of the cluster of each point (its smallest point + number), as an array, and a dictionary that maps each root to the + mean of the points of its cluster. + + """ + n_pts = len(pts) + sets = _misc.UnionFind(range(n_pts)) + for i, j in cKDTree(pts).query_pairs(tol): + sets.union(i, j) + roots = np.array([sets.find(i) for i in range(n_pts)]) + means = {} + for root in np.unique(roots): + means[root] = pts[np.nonzero(roots == root)[0]].mean(axis=0) + return roots, means + + def _cell_loop(cell): """Vertex loop of a 2D pyvoro cell and the adjacent cell of each edge. @@ -1440,23 +1443,8 @@ def _unify_cell_vertices(voro, lims, per_axes, merge_tol, snap_tol): wrapped = all_pts - shifts # coincident copies are one vertex - parent = np.arange(len(wrapped)) - - def find(i): - while parent[i] != i: - parent[i] = parent[parent[i]] - i = parent[i] - return i - - for i, j in cKDTree(wrapped).query_pairs(merge_tol): - r_i, r_j = find(i), find(j) - if r_i != r_j: - parent[max(r_i, r_j)] = min(r_i, r_j) - roots = np.array([find(i) for i in range(len(wrapped))]) - unified = wrapped.copy() - for root in np.unique(roots): - members = roots == root - unified[members] = wrapped[members].mean(axis=0) + roots, means = _cluster_points(wrapped, merge_tol) + unified = np.array([means[root] for root in roots]) # vertices next to a periodic face are on it for axis, flag in enumerate(per_axes): diff --git a/src/microstructpy/meshing/trimesh.py b/src/microstructpy/meshing/trimesh.py index ac092ccf..fc19be01 100644 --- a/src/microstructpy/meshing/trimesh.py +++ b/src/microstructpy/meshing/trimesh.py @@ -1894,18 +1894,7 @@ def _merged_seed_numbers(pmesh, phases): for p in phases]) amorph_mask = is_amorph[phase_nums] - parent = np.arange(len(seed_nums)) - - def find(i): - while parent[i] != i: - parent[i] = parent[parent[i]] - i = parent[i] - return i - - def union(i, j): - r_i, r_j = find(i), find(j) - if r_i != r_j: - parent[max(r_i, r_j)] = min(r_i, r_j) + sets = _misc.UnionFind(range(len(seed_nums))) pairs = [tuple(neighs) for neighs in pmesh.facet_neighbors] per_facets = getattr(pmesh, 'periodic_facets', None) or {} @@ -1917,16 +1906,16 @@ def union(i, j): if r_a < 0 or r_b < 0: continue if amorph_mask[r_a] and phase_nums[r_a] == phase_nums[r_b]: - union(r_a, r_b) + sets.union(r_a, r_b) first_region = {} for r, s in enumerate(seed_nums): if s in first_region: - union(first_region[s], r) + sets.union(first_region[s], r) else: first_region[s] = r - roots = np.array([find(r) for r in range(len(seed_nums))]) + roots = np.array([sets.find(r) for r in range(len(seed_nums))]) labels = seed_nums.copy() for root in np.unique(roots): members = roots == root @@ -2214,20 +2203,14 @@ def _triangulate_facets_3d(polymesh, phases, kps, pts, facet_nums, max_volume, for i in range(len(loop)): edge_keys.add(_edge_key(loop[i - 1], loop[i])) - parent = {key: key for key in edge_keys} - - def find(key): - while parent[key] != key: - parent[key] = parent[parent[key]] - key = parent[key] - return key - + classes = _misc.UnionFind(edge_keys) + find = classes.find for kp_map in lo_hi.values(): for key in edge_keys: if key[0] in kp_map and key[1] in kp_map: image = _edge_key(kp_map[key[0]], kp_map[key[1]]) if image in edge_keys: - parent[find(key)] = find(image) + classes.attach(key, image) def to_root(key, t_vals): # the parameters along key, in the orientation of its class root From 387cce5ea839c9fbbfcddf8b106bd2eb5bcadb14 Mon Sep 17 00:00:00 2001 From: Julian Rimoli Date: Wed, 23 Sep 2026 15:29:47 -0700 Subject: [PATCH 33/42] One piece matcher and one wall lookup for 2D and 3D Matching a piece by the two ends of an edge is matching it by the vertices of a face applied to two points, and the wall on which an edge lies is found the way the wall of a face is: the 3D helpers serve both dimensions. Same distances, same tests: the regression cases are identical. Co-Authored-By: Claude Fable 5.1 --- src/microstructpy/meshing/polymesh.py | 48 ++++++++------------------- 1 file changed, 13 insertions(+), 35 deletions(-) diff --git a/src/microstructpy/meshing/polymesh.py b/src/microstructpy/meshing/polymesh.py index e27031ae..03e773a4 100644 --- a/src/microstructpy/meshing/polymesh.py +++ b/src/microstructpy/meshing/polymesh.py @@ -1552,10 +1552,10 @@ def _periodic_pieces_2d(voro, bkdwn2seed, lims, per_axes): if adj_cell >= 0: candidates = [p for p in cell_pieces.get(adj_cell, []) if p != piece_num] - adj_cell = _matching_piece(pts[k], pts[k1], candidates, - pieces, merge_tol) + adj_cell = _matching_piece(pts[[k, k1]], candidates, pieces, + merge_tol) if adj_cell is None: - adj_cell = _wall_of_edge(pts[k], pts[k1], lims, tol) + adj_cell = _wall_of_points(pts[[k, k1]], lims, tol) faces.append({'adjacent_cell': int(adj_cell), 'vertices': [k, k1]}) new_voro.append({'vertices': pts.tolist(), @@ -1581,17 +1581,6 @@ def _check_cell_width(pts, axis, length, tol): raise ValueError(e_str) -def _matching_piece(pt_a, pt_b, candidates, pieces, tol): - """Piece among the candidates that has vertices at both points.""" - for piece_num in candidates: - pts = pieces[piece_num][1] - d_a = np.min(np.linalg.norm(pts - pt_a, axis=1)) - d_b = np.min(np.linalg.norm(pts - pt_b, axis=1)) - if d_a <= tol and d_b <= tol: - return piece_num - return None - - # --------------------------------------------------------------------------- # # # # Periodic Tessellation - 3D # @@ -1831,10 +1820,10 @@ def _periodic_pieces_3d(voro, bkdwn2seed, lims, per_axes): if adj_cell >= 0: candidates = [p for p in cell_pieces.get(adj_cell, []) if p != piece_num] - adj_cell = _matching_piece_3d(verts[loop], candidates, - pieces, merge_tol) + adj_cell = _matching_piece(verts[loop], candidates, pieces, + merge_tol) if adj_cell is None: - adj_cell = _wall_of_face(verts[loop], lims, tol) + adj_cell = _wall_of_points(verts[loop], lims, tol) out_faces.append({'adjacent_cell': int(adj_cell), 'vertices': list(loop)}) adjacency = [[] for _ in range(len(verts))] @@ -1856,8 +1845,9 @@ def _periodic_pieces_3d(voro, bkdwn2seed, lims, per_axes): return new_voro, new_bkdwn2seed -def _matching_piece_3d(face_pts, candidates, pieces, tol): - """Piece among the candidates that has vertices at all the points.""" +def _matching_piece(face_pts, candidates, pieces, tol): + """Piece among the candidates that has vertices at all the points (the + two ends of an edge in 2D, the vertices of a face in 3D).""" for piece_num in candidates: pts = pieces[piece_num][1] dists = np.linalg.norm(face_pts[:, None, :] - pts[None, :, :], @@ -1867,32 +1857,20 @@ def _matching_piece_3d(face_pts, candidates, pieces, tol): return None -def _wall_of_face(face_pts, lims, tol): - """Wall id of a face lying on a face of the domain.""" +def _wall_of_points(face_pts, lims, tol): + """Wall id of the face of the domain on which all the points lie (the + ends of an edge in 2D, the vertices of a face in 3D).""" for axis, (lb, ub) in enumerate(lims): if np.all(np.abs(face_pts[:, axis] - lb) <= tol): return -(2 * axis + 1) if np.all(np.abs(face_pts[:, axis] - ub) <= tol): return -(2 * axis + 2) - e_str = 'Cannot resolve the neighbor of a face of the periodic ' + e_str = 'Cannot resolve the neighbor of a facet of the periodic ' e_str += 'tessellation at ' + str(np.round(face_pts.mean(axis=0), 6)) e_str += '.' raise ValueError(e_str) -def _wall_of_edge(pt_a, pt_b, lims, tol): - """Wall id of an edge lying on a face of the domain.""" - for axis, (lb, ub) in enumerate(lims): - if abs(pt_a[axis] - lb) <= tol and abs(pt_b[axis] - lb) <= tol: - return -(2 * axis + 1) - if abs(pt_a[axis] - ub) <= tol and abs(pt_b[axis] - ub) <= tol: - return -(2 * axis + 2) - e_str = 'Cannot resolve the neighbor of the edge between ' - e_str += str(pt_a.tolist()) + ' and ' + str(pt_b.tolist()) - e_str += ' in the periodic tessellation.' - raise ValueError(e_str) - - def _match_index_sets(items, other_items): """Match each item to an unused item of ``other_items`` with the same set of indices. Returns the list of matched positions, or None if any From c3044152245e76eb3629e275154fd9c2cc971dc6 Mon Sep 17 00:00:00 2001 From: Julian Rimoli Date: Wed, 23 Sep 2026 15:37:32 -0700 Subject: [PATCH 34/42] One function pairs the points and facets of a periodic mesh The polygonal and the triangular meshes paired the points and the facets on their periodic faces with the same two calls, the triangular mesh allowing for a missing facet list; that is one function of the helpers module now. Co-Authored-By: Claude Fable 5.1 --- src/microstructpy/_misc.py | 25 +++++++++++++++++++++++++ src/microstructpy/meshing/polymesh.py | 6 ++---- src/microstructpy/meshing/trimesh.py | 9 ++------- 3 files changed, 29 insertions(+), 11 deletions(-) diff --git a/src/microstructpy/_misc.py b/src/microstructpy/_misc.py index a08f7e14..a3d1944a 100644 --- a/src/microstructpy/_misc.py +++ b/src/microstructpy/_misc.py @@ -397,6 +397,31 @@ def pair_periodic_points(points, per_axes, dom_lims, rel_tol=1e-8): return pts, pairs +def pair_periodic_mesh(points, facets, per_axes, dom_lims): + """Pair the points and the facets of a mesh on opposite periodic faces. + + See :func:`pair_periodic_points` and :func:`pair_periodic_facets`. + + Args: + points (list or numpy.ndarray): The points. + facets (list or None): The facets, or None if the mesh has none. + per_axes (list): Periodicity flag of each axis. + dom_lims (list): (lower, upper) bounds of the domain, per axis. + + Returns: + tuple: The snapped points (numpy.ndarray), the point pairs and the + facet pairs (dictionaries: axis -> list of (lower, upper) numbers; + the facet pairs are empty lists if the mesh has no facets). + + """ + pts, point_pairs = pair_periodic_points(points, per_axes, dom_lims) + if facets is None: + facet_pairs = {axis: [] for axis in point_pairs} + else: + facet_pairs = pair_periodic_facets(facets, point_pairs) + return pts, point_pairs, facet_pairs + + def pair_periodic_facets(facets, point_pairs): """Pair the facets lying on opposite periodic faces. diff --git a/src/microstructpy/meshing/polymesh.py b/src/microstructpy/meshing/polymesh.py index 03e773a4..238d1df2 100644 --- a/src/microstructpy/meshing/polymesh.py +++ b/src/microstructpy/meshing/polymesh.py @@ -913,10 +913,8 @@ def _set_periodic_pairs(self, per_axes, dom_lims): image on the opposite face. """ - pts, per_points = _misc.pair_periodic_points(self.points, per_axes, - dom_lims) - per_facets = _misc.pair_periodic_facets(self.facets, per_points) - + pts, per_points, per_facets = _misc.pair_periodic_mesh( + self.points, self.facets, per_axes, dom_lims) self.points = pts.tolist() self.periodic_axes = [bool(f) for f in per_axes] self.periodic_points = per_points diff --git a/src/microstructpy/meshing/trimesh.py b/src/microstructpy/meshing/trimesh.py index fc19be01..43cee0df 100644 --- a/src/microstructpy/meshing/trimesh.py +++ b/src/microstructpy/meshing/trimesh.py @@ -280,13 +280,8 @@ def _set_periodic_pairs(self, per_axes, dom_lims): ValueError: If a node or facet on a periodic face has no image. """ - pts, per_nodes = _misc.pair_periodic_points(self.points, per_axes, - dom_lims) - if self.facets is None: - per_facets = {axis: [] for axis in per_nodes} - else: - per_facets = _misc.pair_periodic_facets(self.facets, per_nodes) - + pts, per_nodes, per_facets = _misc.pair_periodic_mesh( + self.points, self.facets, per_axes, dom_lims) self.points = pts self.periodic_axes = [bool(f) for f in per_axes] self.periodic_nodes = per_nodes From e4677493b42ca6c87ace987c55954356d4d64290 Mon Sep 17 00:00:00 2001 From: Julian Rimoli Date: Wed, 23 Sep 2026 15:45:16 -0700 Subject: [PATCH 35/42] The cell geometry helper answers which cells contain which points The loop over the cells whose bounding box contains some points, with the signed distances of those points to the facet planes, was written three times in the attribute assignment and in the collection of the facet points of the 3D meshes; it is one method of the cell geometry helper now, with the same products in the same order. Co-Authored-By: Claude Fable 5.1 --- src/microstructpy/meshing/trimesh.py | 60 ++++++++++++++-------------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/src/microstructpy/meshing/trimesh.py b/src/microstructpy/meshing/trimesh.py index 43cee0df..11f8418c 100644 --- a/src/microstructpy/meshing/trimesh.py +++ b/src/microstructpy/meshing/trimesh.py @@ -2431,16 +2431,8 @@ def _collect_facet_points_3d(new_pts, polymesh, edge_t, face_pts): # cells that contain it cell_geom = _CellGeometry(polymesh, p_arr) point_facets = {} - for r_num in range(len(polymesh.regions)): - r_mins, r_maxs = cell_geom.limits(r_num) - in_box = np.all((new_pts >= r_mins - tol) & (new_pts <= r_maxs + tol), - axis=1) - cand = np.nonzero(in_box)[0] - if len(cand) == 0: - continue - f_nums, normals, centers = cell_geom.facets(r_num) - rel_pos = new_pts[cand][:, np.newaxis, :] - centers - dp = np.einsum('efd,fd->ef', rel_pos, normals) + for r_num, cand, dp in cell_geom.containing(new_pts, tol): + f_nums = cell_geom.facets(r_num)[0] inside = np.all(dp >= -tol, axis=1) for i, row in zip(cand[inside], dp[inside]): for k in np.nonzero(np.abs(row) <= tol)[0]: @@ -3032,16 +3024,8 @@ def _attributes_from_polymesh(tri_pts, tri_elems, polymesh, labels): cens = tri_pts[tri_elems].mean(axis=1) elem_regs = np.full(len(tri_elems), -1) depths = np.full(len(tri_elems), -np.inf) - for r_num in range(len(polymesh.regions)): - r_mins, r_maxs = cell_geom.limits(r_num) - in_box = np.all((cens >= r_mins - tol) & (cens <= r_maxs + tol), - axis=1) - r_i = np.nonzero(in_box)[0] - if len(r_i) == 0: - continue - _, normals, centers = cell_geom.facets(r_num) - rel_pos = cens[r_i][:, np.newaxis, :] - centers - depth = np.einsum('efd,fd->ef', rel_pos, normals).min(axis=1) + for r_num, r_i, dp in cell_geom.containing(cens, tol): + depth = dp.min(axis=1) deeper = depth > depths[r_i] elem_regs[r_i[deeper]] = r_num depths[r_i[deeper]] = depth[deeper] @@ -3082,16 +3066,7 @@ def _attributes_from_polymesh(tri_pts, tri_elems, polymesh, labels): f_ids = np.nonzero(is_facet)[0] f_cens = tri_pts[u_faces[f_ids]].mean(axis=1) claims = [[] for _ in f_ids] - for r_num in range(len(polymesh.regions)): - r_mins, r_maxs = cell_geom.limits(r_num) - in_box = np.all((f_cens >= r_mins - tol) & (f_cens <= r_maxs + tol), - axis=1) - c_i = np.nonzero(in_box)[0] - if len(c_i) == 0: - continue - _, normals, centers = cell_geom.facets(r_num) - rel_pos = f_cens[c_i][:, np.newaxis, :] - centers - dp = np.einsum('efd,fd->ef', rel_pos, normals) + for r_num, c_i, dp in cell_geom.containing(f_cens, tol): for j in c_i[np.all(dp >= -tol, axis=1)]: claims[j].append(r_num) @@ -3302,6 +3277,31 @@ def limits(self, cell): self._compute(cell) return self._cache[cell][3] + def containing(self, points, tol): + """Cells whose bounding box contains some of the points. + + Args: + points (numpy.ndarray): The points. + tol (float): Tolerance of the bounding box test. + + Yields: + tuple: The cell number, the indices of the points in its + bounding box, and the signed distances of those points to the + planes of the facets of the cell (one row per point, one + column per facet, positive inside the cell). + + """ + for cell in range(len(self.polymesh.regions)): + r_mins, r_maxs = self.limits(cell) + in_box = np.all((points >= r_mins - tol) & + (points <= r_maxs + tol), axis=1) + cand = np.nonzero(in_box)[0] + if len(cand) == 0: + continue + _, normals, centers = self.facets(cell) + rel_pos = points[cand][:, np.newaxis, :] - centers + yield cell, cand, np.einsum('efd,fd->ef', rel_pos, normals) + def exit_facet(self, cell, origin, direction): """Facet through which the ray origin + t * direction leaves a cell. From 6bf0dcbb49914bf9319c5b2f2c866ca46e19bbec Mon Sep 17 00:00:00 2001 From: Julian Rimoli Date: Wed, 23 Sep 2026 15:53:02 -0700 Subject: [PATCH 36/42] One cutting loop for the 2D and 3D periodic pieces Cutting a cell at the periodic faces (snap the vertices, check the width, clip below and above each face, drop the flat pieces, translate the outside pieces into the domain) was written once with the loop clipper and once with the polyhedron clipper; the loop takes the clipper as an argument now. Same operations in the same order: the regression cases are identical. Co-Authored-By: Claude Fable 5.1 --- src/microstructpy/meshing/polymesh.py | 135 ++++++++++++-------------- 1 file changed, 64 insertions(+), 71 deletions(-) diff --git a/src/microstructpy/meshing/polymesh.py b/src/microstructpy/meshing/polymesh.py index 238d1df2..b1a72dd3 100644 --- a/src/microstructpy/meshing/polymesh.py +++ b/src/microstructpy/meshing/polymesh.py @@ -1463,6 +1463,66 @@ def _unify_cell_vertices(voro, lims, per_axes, merge_tol, snap_tol): return new_voro +def _cut_cell_at_faces(parts, per_axes, lims, tol, snap_tol, clip, n_min): + """Cut the parts of a cell at the periodic faces, axis by axis. + + The part below the lower face of an axis is translated to the upper + side of the domain, the part above the upper face to the lower side, + and the part in between stays. The vertices next to a cut line/plane + are snapped onto it first, so that the two cells sharing an edge/face + are cut consistently and no sliver pieces are created; flat pieces on + a cut are dropped. + + Args: + parts (list): (vertices, boundary) tuples: the vertex loop and the + adjacent cell of each edge in 2D, the vertices and the faces + (vertex lists with their adjacent cells) in 3D. + per_axes (list): Periodicity flag of each axis. + lims (list): (lower, upper) bounds of the domain, per axis. + tol (float): Geometric tolerance. + snap_tol (float): Snapping distance to the faces. + clip (callable): The clipping function (:func:`_clip_loop` or + :func:`_clip_polyhedron`), called as + ``clip(vertices, boundary, axis, value, keep_below, wall, tol)``. + n_min (int): Minimum number of vertices (and of faces, in 3D) of a + piece. + + Returns: + list: The pieces, as (vertices, boundary) tuples. + + """ + for axis, flag in enumerate(per_axes): + if not flag: + continue + lb, ub = lims[axis] + length = ub - lb + wall_lo = -(2 * axis + 1) + wall_hi = -(2 * axis + 2) + new_parts = [] + for pts, bnd in parts: + pts = _snap_to_planes(pts, axis, (lb, ub), snap_tol) + _check_cell_width(pts, axis, length, tol) + # part below the lower face, translated to the upper side + below = clip(pts, bnd, axis, lb, True, wall_hi, tol) + rest = clip(pts, bnd, axis, lb, False, wall_lo, tol) + if len(rest[0]) == 0: + inner, above = rest, rest + else: + inner = clip(rest[0], rest[1], axis, ub, True, wall_hi, tol) + above = clip(rest[0], rest[1], axis, ub, False, wall_lo, tol) + for (p_pts, p_bnd), shift in ((below, length), (inner, 0), + (above, -length)): + if len(p_pts) < n_min or (n_min > 3 and len(p_bnd) < n_min): + continue + p_pts = np.array(p_pts) + if p_pts[:, axis].max() - p_pts[:, axis].min() <= tol: + continue # flat piece, lies on the cut + p_pts[:, axis] += shift + new_parts.append((p_pts, p_bnd)) + parts = new_parts + return parts + + def _periodic_pieces_2d(voro, bkdwn2seed, lims, per_axes): """Split the cells of a periodic 2D tessellation at the periodic faces. @@ -1497,41 +1557,8 @@ def _periodic_pieces_2d(voro, bkdwn2seed, lims, per_axes): # Cut the cells at the periodic faces pieces = [] # (cell number, vertices, edge adjacencies) for cell_num, cell in enumerate(voro): - parts = [_cell_loop(cell)] - for axis, flag in enumerate(per_axes): - if not flag: - continue - lb, ub = lims[axis] - length = ub - lb - wall_lo = -(2 * axis + 1) - wall_hi = -(2 * axis + 2) - new_parts = [] - for pts, adj in parts: - # vertices next to a cut line are snapped onto it, so that - # the two cells sharing an edge are cut consistently and - # no sliver pieces are created - pts = _snap_to_planes(pts, axis, (lb, ub), snap_tol) - _check_cell_width(pts, axis, length, tol) - # part below the lower face, translated to the upper side - below = _clip_loop(pts, adj, axis, lb, True, wall_hi, tol) - rest = _clip_loop(pts, adj, axis, lb, False, wall_lo, tol) - if len(rest[0]) == 0: - inner, above = rest, rest - else: - inner = _clip_loop(rest[0], rest[1], axis, ub, True, - wall_hi, tol) - above = _clip_loop(rest[0], rest[1], axis, ub, False, - wall_lo, tol) - for (p_pts, p_adj), shift in ((below, length), (inner, 0), - (above, -length)): - if len(p_pts) < 3: - continue - p_pts = np.array(p_pts) - if p_pts[:, axis].max() - p_pts[:, axis].min() <= tol: - continue # flat piece, lies on the cut line - p_pts[:, axis] += shift - new_parts.append((p_pts, p_adj)) - parts = new_parts + parts = _cut_cell_at_faces([_cell_loop(cell)], per_axes, lims, tol, + snap_tol, _clip_loop, 3) for pts, adj in parts: pieces.append((cell_num, pts, adj)) @@ -1767,42 +1794,8 @@ def _periodic_pieces_3d(voro, bkdwn2seed, lims, per_axes): verts = np.array(cell['vertices'], dtype='float') faces = [(list(f['vertices']), f['adjacent_cell']) for f in cell['faces']] - parts = [(verts, faces)] - for axis, flag in enumerate(per_axes): - if not flag: - continue - lb, ub = lims[axis] - length = ub - lb - wall_lo = -(2 * axis + 1) - wall_hi = -(2 * axis + 2) - new_parts = [] - for p_verts, p_faces in parts: - # vertices next to a cut plane are snapped onto it (see - # _periodic_pieces_2d) - p_verts = _snap_to_planes(p_verts, axis, (lb, ub), snap_tol) - _check_cell_width(p_verts, axis, length, tol) - below = _clip_polyhedron(p_verts, p_faces, axis, lb, True, - wall_hi, tol) - rest = _clip_polyhedron(p_verts, p_faces, axis, lb, False, - wall_lo, tol) - if len(rest[0]) == 0: - inner, above = rest, rest - else: - inner = _clip_polyhedron(rest[0], rest[1], axis, ub, - True, wall_hi, tol) - above = _clip_polyhedron(rest[0], rest[1], axis, ub, - False, wall_lo, tol) - for (q_verts, q_faces), shift in ((below, length), - (inner, 0), - (above, -length)): - if len(q_verts) < 4 or len(q_faces) < 4: - continue - q_verts = np.array(q_verts) - if q_verts[:, axis].max() - q_verts[:, axis].min() <= tol: - continue # flat piece, lies on the cut plane - q_verts[:, axis] += shift - new_parts.append((q_verts, q_faces)) - parts = new_parts + parts = _cut_cell_at_faces([(verts, faces)], per_axes, lims, tol, + snap_tol, _clip_polyhedron, 4) for p_verts, p_faces in parts: pieces.append((cell_num, p_verts, p_faces)) From ee870b1f0e0bf8ff1f3dcc564727d0b4a16bada4 Mon Sep 17 00:00:00 2001 From: Julian Rimoli Date: Wed, 23 Sep 2026 16:37:11 -0700 Subject: [PATCH 37/42] Sort the facets of the meshes created from a polygonal mesh Triangle and TetGen list the edges/faces of a mesh in an order, and with an orientation, that vary from one run to the next, so the mesh files of otherwise identical runs differed in their facet lists. The facets are now sorted when the mesh is created, whatever the mesher: nodes in ascending order within a facet and facets in lexicographic order, with their attributes carried along. The orientation of a facet is not used by the package. The facets of the periodic meshes, computed from the polygonal mesh, were already in that order and are unchanged. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.rst | 6 +++ docs/source/file_formats.rst | 3 ++ src/microstructpy/meshing/trimesh.py | 36 +++++++++++++++++- tests/meshing/test_trimesh_fixes.py | 55 ++++++++++++++++++++++++++++ 4 files changed, 99 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index f0bfdfad..d2e4c7a0 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -134,6 +134,12 @@ Fixed Changed ''''''' +- The facets of the triangular and tetrahedral meshes created from a + polygonal mesh are sorted (nodes in ascending order within a facet, + facets in lexicographic order), whatever the mesher. Triangle and TetGen + list the edges/faces of a mesh in an order, and with an orientation, that + vary from one run to the next, so the mesh files of otherwise identical + runs differed in the order of their facets. - ``max_edge_length`` (``mesh_max_edge_length``) acts in 3D on the triangles of the grain boundaries: when it is set, the facets of the polyhedral mesh are triangulated to that edge length (with Triangle, minimum angle 20 diff --git a/docs/source/file_formats.rst b/docs/source/file_formats.rst index 716bbd1d..4c55e56d 100644 --- a/docs/source/file_formats.rst +++ b/docs/source/file_formats.rst @@ -219,6 +219,9 @@ element. The facet attribute is the facet number from the polygonal mesh, so all of the triangular mesh facets with the same attribute make up a polygonal mesh facet. +The facets of a mesh created from a polygonal mesh are listed with their +nodes in ascending order and in lexicographic order, so that the file is +the same from one run to the next. .. note:: diff --git a/src/microstructpy/meshing/trimesh.py b/src/microstructpy/meshing/trimesh.py index 11f8418c..14f034ee 100644 --- a/src/microstructpy/meshing/trimesh.py +++ b/src/microstructpy/meshing/trimesh.py @@ -236,6 +236,13 @@ def from_polymesh(cls, polymesh, phases=None, mesher='Triangle/Tetgen', option is used with gmsh. Default is infinity, whihch turns off this control. + Note: + The facets of the mesh are listed with their nodes in ascending + order and in lexicographic order, whatever the mesher: the order + in which Triangle and TetGen list the edges/faces of a mesh + varies from one run to the next, and the sorted facets make the + mesh, and its files, reproducible. + """ # A periodic polygon mesh gives a periodic triangular mesh: the # nodes on opposite periodic faces are images of each other @@ -257,7 +264,9 @@ def from_polymesh(cls, polymesh, phases=None, mesher='Triangle/Tetgen', e_str += "'Triangle/TetGen', 'Triangle', 'TetGen', and 'gmsh'." raise ValueError(e_str) - mesh = cls(*tri_args) + tri_pts, tri_elems, tri_e_atts, tri_f, tri_fa = tri_args + tri_f, tri_fa = _sorted_facets(tri_f, tri_fa) + mesh = cls(tri_pts, tri_elems, tri_e_atts, tri_f, tri_fa) if periodic: dom_lims = _misc.periodic_bounds(polymesh.points, per_axes) mesh._set_periodic_pairs(per_axes, dom_lims) @@ -1569,6 +1578,31 @@ def _call_meshpy(polymesh, phases=None, min_angle=0, max_volume=float('inf'), return tri_args +def _sorted_facets(facets, facet_atts): + """Facets with their nodes in ascending order, in lexicographic order. + + Triangle and TetGen list the edges/faces of a mesh in an order, and + with an orientation, that vary from one run to the next; the sorted + facets make a mesh, and its files, reproducible. MicroStructPy does not + use the orientation of the facets. + + Args: + facets (list or numpy.ndarray): The facets (node numbers). + facet_atts (list or numpy.ndarray): The attribute of each facet. + + Returns: + tuple: The sorted facets and their attributes, as arrays. + + """ + facets = np.array(facets, dtype='int') + facet_atts = np.array(facet_atts) + if facets.size == 0: + return facets, facet_atts + facets = np.sort(facets, axis=1) + order = np.lexsort(facets.T[::-1]) + return facets[order], facet_atts[order] + + def _call_gmsh(pmesh, phases, res, edge_res): if phases is None: phases = _default_phases(pmesh) diff --git a/tests/meshing/test_trimesh_fixes.py b/tests/meshing/test_trimesh_fixes.py index 0e690675..dbe0623d 100644 --- a/tests/meshing/test_trimesh_fixes.py +++ b/tests/meshing/test_trimesh_fixes.py @@ -677,3 +677,58 @@ def test_write_rejects_non_simplex_elements(fmt, raster_2d, tmp_path): mesh.write(str(tmp_path / 'quads.txt'), 'txt') read_mesh = TriMesh.from_file(str(tmp_path / 'quads.txt')) assert len(read_mesh.elements) == len(mesh.elements) + + +# --------------------------------------------------------------------------- # +# Sorted facets # +# --------------------------------------------------------------------------- # +def test_sorted_facets_helper(): + facets = [[5, 2], [1, 9], [2, 5], [3, 1], [9, 1]] + atts = [10, 11, 12, 13, 14] + out_f, out_a = trimesh_module._sorted_facets(facets, atts) + assert out_f.tolist() == [[1, 3], [1, 9], [1, 9], [2, 5], [2, 5]] + # the attributes travel with their facets + assert out_a.tolist() == [13, 11, 14, 10, 12] + # triangles, and an empty list + out_f, out_a = trimesh_module._sorted_facets([[7, 3, 5], [2, 9, 1]], + [1, 2]) + assert out_f.tolist() == [[1, 2, 9], [3, 5, 7]] + assert out_a.tolist() == [2, 1] + out_f, out_a = trimesh_module._sorted_facets([], []) + assert len(out_f) == 0 and len(out_a) == 0 + + +def _assert_facets_sorted(mesh): + facets = np.array(mesh.facets) + assert np.all(np.diff(facets, axis=1) > 0) + keys = [tuple(f) for f in facets] + assert keys == sorted(keys) + + +def test_facets_sorted_2d_and_3d(): + # 2D, Triangle, and a periodic mesh (whose facets come from the + # polymesh geometry) + domain = msp.geometry.Square(side_length=2, corner=(0, 0)) + seeds = msp.seeding.SeedList.from_info( + [{'shape': 'circle', 'size': 0.4}], 0.9 * domain.area) + seeds.position(domain, rng_seed=0, rtol=0.0) + pmesh = msp.meshing.PolyMesh.from_seeds(seeds, domain) + mesh = TriMesh.from_polymesh(pmesh, min_angle=20, max_volume=0.05) + assert len(mesh.facets) > 0 + _assert_facets_sorted(mesh) + assert set(np.unique(mesh.facet_attributes)) <= set( + range(len(pmesh.facets))) + seeds.position(domain, rng_seed=0, rtol=0.0, periodic=True) + pmesh_per = msp.meshing.PolyMesh.from_seeds(seeds, domain, periodic=True) + mesh_per = TriMesh.from_polymesh(pmesh_per, min_angle=20) + _assert_facets_sorted(mesh_per) + + # 3D, TetGen + domain = msp.geometry.Cube(side_length=2, corner=(0, 0, 0)) + seeds = msp.seeding.SeedList.from_info( + [{'shape': 'sphere', 'size': 0.8}], 0.9 * domain.volume) + seeds.position(domain, rng_seed=0, rtol=0.0) + pmesh = msp.meshing.PolyMesh.from_seeds(seeds, domain) + mesh = TriMesh.from_polymesh(pmesh, min_angle=10) + assert len(mesh.facets) > 0 + _assert_facets_sorted(mesh) From f6f25436339d90f352b309edafc815b8b59a0f10 Mon Sep 17 00:00:00 2001 From: Julian Rimoli Date: Wed, 23 Sep 2026 19:02:46 -0700 Subject: [PATCH 38/42] Sort the imports as the package's isort configuration requires The CI import-order check (isort, force_single_line, microstructpy as the first-party section) rejected five files: the scipy import of the mesh module belonged after the matplotlib imports, and the imports of the shared test helpers belong with the third-party section. Co-Authored-By: Claude Fable 5.1 --- src/microstructpy/meshing/trimesh.py | 2 +- tests/meshing/test_edge_opt_pieces.py | 4 ++-- tests/meshing/test_periodic_polymesh.py | 6 +++--- tests/meshing/test_periodic_trimesh.py | 4 ++-- tests/meshing/test_polymesh_fixes.py | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/microstructpy/meshing/trimesh.py b/src/microstructpy/meshing/trimesh.py index 14f034ee..33903f42 100644 --- a/src/microstructpy/meshing/trimesh.py +++ b/src/microstructpy/meshing/trimesh.py @@ -19,12 +19,12 @@ import meshpy.triangle import numpy as np import pygmsh as pg -from scipy.spatial import cKDTree from matplotlib import collections from matplotlib import patches from matplotlib import pyplot as plt from mpl_toolkits.mplot3d import Axes3D from mpl_toolkits.mplot3d.art3d import Poly3DCollection +from scipy.spatial import cKDTree from microstructpy import _misc diff --git a/tests/meshing/test_edge_opt_pieces.py b/tests/meshing/test_edge_opt_pieces.py index d1a407f7..3527e765 100644 --- a/tests/meshing/test_edge_opt_pieces.py +++ b/tests/meshing/test_edge_opt_pieces.py @@ -2,6 +2,8 @@ import copy import numpy as np +from periodic_helpers import min_edge +from periodic_helpers import wedge_seeds_2d from microstructpy import geometry from microstructpy.meshing import PolyMesh @@ -14,8 +16,6 @@ from microstructpy.meshing.polymesh import _wedge_geometry from microstructpy.seeding import Seed from microstructpy.seeding import SeedList -from periodic_helpers import min_edge -from periodic_helpers import wedge_seeds_2d # --------------------------------------------------------------------------- # diff --git a/tests/meshing/test_periodic_polymesh.py b/tests/meshing/test_periodic_polymesh.py index f389c6c2..c65031e8 100644 --- a/tests/meshing/test_periodic_polymesh.py +++ b/tests/meshing/test_periodic_polymesh.py @@ -2,15 +2,15 @@ import numpy as np import pytest import scipy.stats +from periodic_helpers import check_periodic_pairs +from periodic_helpers import seed_volumes +from periodic_helpers import tiled_reference_volumes import microstructpy as msp from microstructpy.meshing import PolyMesh from microstructpy.meshing.polymesh import kp_loop from microstructpy.seeding import Seed from microstructpy.seeding import SeedList -from periodic_helpers import check_periodic_pairs -from periodic_helpers import seed_volumes -from periodic_helpers import tiled_reference_volumes # --------------------------------------------------------------------------- # diff --git a/tests/meshing/test_periodic_trimesh.py b/tests/meshing/test_periodic_trimesh.py index 73a3266c..c553be2e 100644 --- a/tests/meshing/test_periodic_trimesh.py +++ b/tests/meshing/test_periodic_trimesh.py @@ -2,6 +2,8 @@ import numpy as np import pytest import scipy.stats +from periodic_helpers import check_periodic_pairs +from periodic_helpers import wedge_seeds_2d import microstructpy as msp from microstructpy.meshing import PolyMesh @@ -9,8 +11,6 @@ from microstructpy.meshing import TriMesh from microstructpy.meshing import trimesh as trimesh_module from microstructpy.seeding import SeedList -from periodic_helpers import check_periodic_pairs -from periodic_helpers import wedge_seeds_2d # --------------------------------------------------------------------------- # diff --git a/tests/meshing/test_polymesh_fixes.py b/tests/meshing/test_polymesh_fixes.py index 16fc6e61..02d95722 100644 --- a/tests/meshing/test_polymesh_fixes.py +++ b/tests/meshing/test_polymesh_fixes.py @@ -16,6 +16,7 @@ import numpy as np from matplotlib import path as mpath from matplotlib import pyplot as plt +from periodic_helpers import min_edge from microstructpy import geometry from microstructpy.meshing.polymesh import PolyMesh @@ -24,7 +25,6 @@ from microstructpy.meshing.polymesh import kp_loop from microstructpy.seeding import Seed from microstructpy.seeding import SeedList -from periodic_helpers import min_edge # --------------------------------------------------------------------------- # From e618e2513e2fab2bbcd35f47042b3b6ca1d3127c Mon Sep 17 00:00:00 2001 From: Julian Rimoli Date: Wed, 23 Sep 2026 19:14:29 -0700 Subject: [PATCH 39/42] Let the tests run in the continuous integration The workflow installed pytest 6.2.5 and a current pytest-cov, then the requirements pinned tox 3.14, whose pluggy < 1 requirement downgraded pluggy to 0.13; pytest-cov then failed to load ("unexpected keyword argument 'wrapper'") and no test ran. The workflow installs the current pytest, tox is no longer a requirement of the package (nothing imports it; tox.ini stays for local use), and the jobs of the test matrix no longer cancel each other when one fails, so that every platform reports its own result. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/python_package.yaml | 3 ++- CHANGELOG.rst | 4 ++++ requirements.txt | 1 - 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python_package.yaml b/.github/workflows/python_package.yaml index b29b8bf6..dd6f9b04 100644 --- a/.github/workflows/python_package.yaml +++ b/.github/workflows/python_package.yaml @@ -8,6 +8,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: + fail-fast: false matrix: python-version: [3.9, '3.10', '3.11', '3.12'] os: ['ubuntu-latest', 'macos-latest', 'windows-latest'] @@ -30,7 +31,7 @@ jobs: run: | python -m pip install --upgrade pip pip install setuptools wheel - pip install flake8 pytest==6.2.5 pytest-cov coveralls + pip install flake8 pytest pytest-cov coveralls - name: Install package requirements run: pip install -r requirements.txt - name: Install package diff --git a/CHANGELOG.rst b/CHANGELOG.rst index d2e4c7a0..98cb9242 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -134,6 +134,10 @@ Fixed Changed ''''''' +- The continuous integration installs the current pytest and no longer + installs tox from the requirements: the pinned tox 3.14 forced an old + pluggy that the current pytest-cov cannot load, so no test could run. + The jobs of the test matrix no longer cancel each other on a failure. - The facets of the triangular and tetrahedral meshes created from a polygonal mesh are sorted (nodes in ascending order within a facet, facets in lexicographic order), whatever the mesher. Triangle and TetGen diff --git a/requirements.txt b/requirements.txt index 8298e352..732f89e6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,6 +9,5 @@ pyvoro-mmalahe==1.3.4 scipy>=1.10.1 setuptools>=70.0.0 xmltodict==0.12.0 -tox==3.14.0 lsq-ellipse==2.0.1 zipp>=3.19.1 # not directly required, pinned by Snyk to avoid a vulnerability From 295931117a2f192fddea0f25ef1a5299c4e49ddf Mon Sep 17 00:00:00 2001 From: Julian Rimoli Date: Tue, 29 Sep 2026 21:15:20 -0700 Subject: [PATCH 40/42] Install pyvoro from pyvoro-rimoli, which bundles the current Voro++ pyvoro-mmalahe bundles Voro++ 0.4.6, whose radical (Laguerre) tessellation can return a cell uncut: the cells then overlap, and Triangle and TetGen can crash on the polygonal mesh. This is why the tests fail on Linux and macOS in the continuous integration. The pyvoro-rimoli fork provides the same pyvoro module with the current Voro++, where this is fixed, and has wheels for Linux, macOS and Windows. The README and the installation guide tell users to uninstall pyvoro-mmalahe before upgrading, since both packages install the same files. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.rst | 9 +++++++++ README.rst | 3 +++ docs/source/getting_started.rst | 9 +++++++++ docs/source/package_guide.rst | 2 +- requirements.txt | 2 +- setup.py | 2 +- 6 files changed, 24 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 98cb9242..6d661e43 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -134,6 +134,15 @@ Fixed Changed ''''''' +- pyvoro is installed from the ``pyvoro-rimoli`` package instead of + ``pyvoro-mmalahe``. Both provide the same ``pyvoro`` module, but + pyvoro-mmalahe bundles Voro++ 0.4.6, whose radical (Laguerre) + tessellation can return a cell uncut: the cells then overlap, and + Triangle and TetGen can crash on the resulting polygonal mesh. + pyvoro-rimoli bundles the current Voro++, where this is fixed, and has + wheels for Linux, macOS and Windows. Uninstall pyvoro-mmalahe before + upgrading (``pip uninstall pyvoro-mmalahe``), since the two packages + install the same files. - The continuous integration installs the current pytest and no longer installs tox from the requirements: the pinned tox 3.14 forced an old pluggy that the current pytest-cov cannot load, so no test could run. diff --git a/README.rst b/README.rst index e26fdb1f..bd5641cd 100644 --- a/README.rst +++ b/README.rst @@ -44,6 +44,9 @@ To install MicroStructPy, download it from PyPI using:: If there is an error with the install, try ``pip install pybind11`` first, then install MicroStructPy. +When upgrading from an earlier version, run ``pip uninstall pyvoro-mmalahe`` +first: MicroStructPy now uses ``pyvoro-rimoli``, which installs the same +``pyvoro`` module. MicroStructPy can also be installed from source:: diff --git a/docs/source/getting_started.rst b/docs/source/getting_started.rst index 93446cfa..8eca756b 100644 --- a/docs/source/getting_started.rst +++ b/docs/source/getting_started.rst @@ -32,6 +32,15 @@ For Windows, it may be in a path similar to If the install fails and the last several error messages reference ``pybind11``, run ``pip install pybind11`` first then install MicroStructPy. +.. note:: + MicroStructPy computes the tessellations with the ``pyvoro`` module of the + ``pyvoro-rimoli`` package. Earlier versions used ``pyvoro-mmalahe``, which + installs the same module with an older Voro++. When upgrading, remove it + first:: + + pip uninstall pyvoro-mmalahe + pip install --upgrade microstructpy + Running Demonstrations ---------------------- diff --git a/docs/source/package_guide.rst b/docs/source/package_guide.rst index 40e60407..fc8b03ad 100644 --- a/docs/source/package_guide.rst +++ b/docs/source/package_guide.rst @@ -293,7 +293,7 @@ phase-specific. .. _`MeshPy`: https://mathema.tician.de/software/meshpy/ .. _`Power Diagram`: https://en.wikipedia.org/wiki/Power_diagram .. _`pygmsh`: https://pygmsh.readthedocs.io -.. _`pyvoro`: https://github.com/mmalahe/pyvoro +.. _`pyvoro`: https://github.com/rimoli/pyvoro .. _`TetGen`: http://wias-berlin.de/software/tetgen/ .. _`Triangle`: https://www.cs.cmu.edu/~quake/triangle.html .. _`Voro++`: http://math.lbl.gov/voro++/ diff --git a/requirements.txt b/requirements.txt index 732f89e6..61c48b75 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ pygmsh==7.1.17 MeshPy==2022.1.3 numpy>=1.24.4,<2.0 pyquaternion==0.9.5 -pyvoro-mmalahe==1.3.4 +pyvoro-rimoli==1.4.0 scipy>=1.10.1 setuptools>=70.0.0 xmltodict==0.12.0 diff --git a/setup.py b/setup.py index 2aa562c0..12e16d27 100644 --- a/setup.py +++ b/setup.py @@ -85,7 +85,7 @@ def find_version(*fname): 'numpy>=1.22.2', 'pygmsh>=7.0.2', 'pyquaternion', - 'pyvoro-mmalahe>=1.3.4', # install issue with pyvoro + 'pyvoro-rimoli>=1.4.0', # pyvoro with the current Voro++ 'scipy', 'xmltodict' ], From 654fa27dab7ee1f90c6634aa5b92a2a662681026 Mon Sep 17 00:00:00 2001 From: Julian Rimoli Date: Tue, 29 Sep 2026 23:24:57 -0700 Subject: [PATCH 41/42] Keep the tiled reference of the 3D periodic tessellation test small test_periodic_3d_matches_tiled_reference compares the periodic tessellation of 83 seeds (about 3000 breakdown spheres) with a non-periodic tessellation of the seeds copied across the periodic faces. With all 27 copies, that reference needs about 10 GB of memory (peak footprint on macOS, where memory compression hides part of it from the resident size), in the test process and again in the pyvoro probe subprocess. The Linux runners of the continuous integration (16 GB, no memory compression) ran out of memory there and were shut down, which is why the Linux test jobs failed. tiled_reference_volumes takes an optional margin: only the copies whose center is within that distance of the domain are kept, and the tiled domain ends there, plus the extent of the largest breakdown. The 3D test uses a margin of 1, half the side of the domain; the reference volumes are the same as with all the copies to rounding (relative difference 3e-15; a margin of 0.7 is not enough). The peak memory of the test file goes from 10 GB to 3.3 GB and its time from 77 s to 32 s. The 2D tests keep all the copies. Co-Authored-By: Claude Opus 5.5 --- tests/meshing/periodic_helpers.py | 44 +++++++++++++++++-------- tests/meshing/test_periodic_polymesh.py | 6 +++- 2 files changed, 36 insertions(+), 14 deletions(-) diff --git a/tests/meshing/periodic_helpers.py b/tests/meshing/periodic_helpers.py index 7627673c..b9297819 100644 --- a/tests/meshing/periodic_helpers.py +++ b/tests/meshing/periodic_helpers.py @@ -102,23 +102,41 @@ def seed_volumes(pmesh, n_seeds): return vols -def tiled_reference_volumes(seeds, domain, per_axes): +def tiled_reference_volumes(seeds, domain, per_axes, margin=None): """Volumes of the cells of the seeds in a periodic tessellation, computed as a non-periodic tessellation of the seeds tiled across the - periodic axes (3 copies per periodic axis).""" + periodic axes (3 copies per periodic axis). + + With a ``margin``, only the copies whose center is within that distance + of the domain are kept, and the tiled domain ends there (plus the extent + of the largest breakdown): the cells of the original seeds only reach + seeds that close. In 3D this makes the reference tessellation several + times smaller; with all the copies, it needs gigabytes of memory. + """ lims = np.array(domain.limits) lengths = lims[:, 1] - lims[:, 0] options = [[-length, 0.0, length] if flag else [0.0] for length, flag in zip(lengths, per_axes)] + if margin is None: + reach = lengths + else: + extent = max([np.max(np.abs(np.array(s.breakdown)[:, :-1] + - s.position)) for s in seeds]) + reach = np.full(len(lengths), margin + extent) tiled = SeedList() - for t in itertools.product(*options): - for seed in seeds: + copies = [] # (translation number, seed number) of each copy + for i_t, t in enumerate(itertools.product(*options)): + for seed_num, seed in enumerate(seeds): + pos = np.array(seed.position) + np.array(t) + if margin is not None and np.any((pos < lims[:, 0] - margin) + | (pos > lims[:, 1] + margin)): + continue copy_seed = copy.deepcopy(seed) - copy_seed.position = list(np.array(seed.position) + np.array(t)) + copy_seed.position = list(pos) tiled.append(copy_seed) - n_seeds = len(seeds) - big_lims = [(lb - length, ub + length) if flag else (lb, ub) - for (lb, ub), length, flag in zip(lims, lengths, per_axes)] + copies.append((i_t, seed_num)) + big_lims = [(lb - r, ub + r) if flag else (lb, ub) + for (lb, ub), r, flag in zip(lims, reach, per_axes)] if len(lims) == 2: big_domain = msp.geometry.Rectangle(limits=big_lims) else: @@ -127,9 +145,9 @@ def tiled_reference_volumes(seeds, domain, per_axes): # the cells of the original copies (the zero translation) i_zero = [i for i, t in enumerate(itertools.product(*options)) if not any(t)][0] - vols = np.zeros(n_seeds) - for seed_num, vol in zip(pmesh.seed_numbers, pmesh.volumes): - block, local = divmod(seed_num, n_seeds) - if block == i_zero: - vols[local] += vol + vols = np.zeros(len(seeds)) + for tiled_num, vol in zip(pmesh.seed_numbers, pmesh.volumes): + i_t, seed_num = copies[tiled_num] + if i_t == i_zero: + vols[seed_num] += vol return vols diff --git a/tests/meshing/test_periodic_polymesh.py b/tests/meshing/test_periodic_polymesh.py index c65031e8..64729894 100644 --- a/tests/meshing/test_periodic_polymesh.py +++ b/tests/meshing/test_periodic_polymesh.py @@ -223,7 +223,11 @@ def test_periodic_3d_matches_tiled_reference(per_axes): assert np.all(np.array(pmesh.volumes) > 0) assert set(pmesh.seed_numbers) == set(range(len(seeds))) vols = seed_volumes(pmesh, len(seeds)) - ref = tiled_reference_volumes(seeds, domain, per_axes) + # The copies within 1 of the domain are enough for these seeds: the + # volumes are the same as with all the copies, to rounding. With all + # of them, the reference tessellation (27 copies of about 3000 + # breakdown spheres) needs about 10 GB of memory. + ref = tiled_reference_volumes(seeds, domain, per_axes, margin=1) # vertices within 1e-5 of the faces are snapped onto them assert np.allclose(vols, ref, rtol=1e-6, atol=1e-6) _check_periodic_structure(pmesh, domain, per_axes) From 5b26948d574d876360388df30026a4a98270cf38 Mon Sep 17 00:00:00 2001 From: Julian Rimoli Date: Tue, 29 Sep 2026 23:26:40 -0700 Subject: [PATCH 42/42] Build the documentation on Read the Docs with Python 3.10 Read the Docs used Python 3.8, for which pyvoro-rimoli has no release (it supports Python 3.9 and later), so the build stopped when installing the package; it already failed on 3.8 for other pull requests, when a pinned dependency dropped 3.8. It now uses Python 3.10 and installs requirements.txt as well, like the documentation check of the continuous integration, so that the same versions (numpy < 2 among them) are used. Co-Authored-By: Claude Opus 5.5 --- .readthedocs.yaml | 3 ++- CHANGELOG.rst | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 8a52c7f9..d71c95c1 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -10,7 +10,7 @@ formats: all build: os: ubuntu-22.04 tools: - python: "3.8" + python: "3.10" apt_packages: - freeglut3-dev @@ -24,5 +24,6 @@ sphinx: python: install: - requirements: docs/requirements.txt + - requirements: requirements.txt - method: pip path: . diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 6d661e43..8772d1cf 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -147,6 +147,10 @@ Changed installs tox from the requirements: the pinned tox 3.14 forced an old pluggy that the current pytest-cov cannot load, so no test could run. The jobs of the test matrix no longer cancel each other on a failure. +- Read the Docs builds the documentation with Python 3.10 instead of 3.8, + which pyvoro-rimoli and the current versions of other dependencies do not + support, and installs ``requirements.txt`` like the documentation check + of the continuous integration. - The facets of the triangular and tetrahedral meshes created from a polygonal mesh are sorted (nodes in ascending order within a facet, facets in lexicographic order), whatever the mesher. Triangle and TetGen