nmag-python-3 0.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. anisotropy/__init__.py +28 -0
  2. anisotropy/anisotropy.py +13 -0
  3. anisotropy/evaluation.py +70 -0
  4. anisotropy/model.py +200 -0
  5. anisotropy/predefined.py +202 -0
  6. anisotropy/py.typed +1 -0
  7. anisotropy/values.py +84 -0
  8. mag_material/__init__.py +3 -0
  9. mag_material/mag_material.py +231 -0
  10. mag_material/parameters.py +150 -0
  11. mag_material/py.typed +1 -0
  12. nmag/__init__.py +36 -0
  13. nmag/backends.py +493 -0
  14. nmag/checkpoint.py +327 -0
  15. nmag/config.py +174 -0
  16. nmag/demag/__init__.py +39 -0
  17. nmag/demag/bem_operator.py +149 -0
  18. nmag/demag/geometry.py +134 -0
  19. nmag/demag/lindholm.py +133 -0
  20. nmag/demag/lindholm_fast.py +463 -0
  21. nmag/demag/linear.py +489 -0
  22. nmag/dynamics/__init__.py +255 -0
  23. nmag/output.py +27 -0
  24. nmag/parallel.py +45 -0
  25. nmag/py.typed +1 -0
  26. nmag/resources.py +38 -0
  27. nmag/simulation/__init__.py +519 -0
  28. nmag/simulation/anisotropy/__init__.py +5 -0
  29. nmag/simulation/anisotropy/fields.py +56 -0
  30. nmag/simulation/anisotropy/materials.py +138 -0
  31. nmag/simulation/demag/__init__.py +1 -0
  32. nmag/simulation/demag/bem/__init__.py +11 -0
  33. nmag/simulation/demag/bem/diagnostics.py +73 -0
  34. nmag/simulation/demag/bem/dirichlet.py +85 -0
  35. nmag/simulation/demag/bem/hierarchical.py +74 -0
  36. nmag/simulation/demag/bem/operator.py +292 -0
  37. nmag/simulation/demag/fem/__init__.py +11 -0
  38. nmag/simulation/demag/fem/assembly.py +114 -0
  39. nmag/simulation/demag/fem/charges.py +38 -0
  40. nmag/simulation/demag/fem/geometry.py +261 -0
  41. nmag/simulation/demag/fields/__init__.py +11 -0
  42. nmag/simulation/demag/fields/auxiliary.py +186 -0
  43. nmag/simulation/demag/fields/probe.py +77 -0
  44. nmag/simulation/demag/fields/recovery.py +207 -0
  45. nmag/simulation/demag/solver.py +17 -0
  46. nmag/simulation/dynamics/__init__.py +92 -0
  47. nmag/simulation/dynamics/advance.py +199 -0
  48. nmag/simulation/dynamics/integrator.py +263 -0
  49. nmag/simulation/exchange/__init__.py +13 -0
  50. nmag/simulation/exchange/coefficients.py +185 -0
  51. nmag/simulation/exchange/fields.py +106 -0
  52. nmag/simulation/exchange/llg_rhs.py +218 -0
  53. nmag/simulation/fields/__init__.py +19 -0
  54. nmag/simulation/fields/arrays.py +111 -0
  55. nmag/simulation/fields/availability.py +188 -0
  56. nmag/simulation/fields/averages.py +293 -0
  57. nmag/simulation/fields/derived.py +164 -0
  58. nmag/simulation/fields/maxangle.py +163 -0
  59. nmag/simulation/fields/probes.py +106 -0
  60. nmag/simulation/implicit_dynamics.py +221 -0
  61. nmag/simulation/mesh/__init__.py +13 -0
  62. nmag/simulation/mesh/geometry.py +153 -0
  63. nmag/simulation/mesh/materials.py +299 -0
  64. nmag/simulation/mesh/probe.py +234 -0
  65. nmag/simulation/restart.py +103 -0
  66. nmag/simulation/support.py +224 -0
  67. nmag_python_3-0.0.2.dist-info/METADATA +157 -0
  68. nmag_python_3-0.0.2.dist-info/RECORD +147 -0
  69. nmag_python_3-0.0.2.dist-info/WHEEL +5 -0
  70. nmag_python_3-0.0.2.dist-info/licenses/LICENSE +339 -0
  71. nmag_python_3-0.0.2.dist-info/top_level.txt +8 -0
  72. nmesh/__init__.py +130 -0
  73. nmesh/backend.py +286 -0
  74. nmesh/geometry/__init__.py +52 -0
  75. nmesh/geometry/boolean_operations.py +157 -0
  76. nmesh/geometry/primitives.py +453 -0
  77. nmesh/geometry/transform.py +126 -0
  78. nmesh/io/__init__.py +50 -0
  79. nmesh/io/ascii.py +132 -0
  80. nmesh/io/legacy_nmesh_hdf5.py +318 -0
  81. nmesh/io/meshio_support.py +170 -0
  82. nmesh/mesh_generation.py +182 -0
  83. nmesh/mesh_io.py +227 -0
  84. nmesh/mesh_model.py +147 -0
  85. nmesh/mesh_utilities.py +79 -0
  86. nmesh/mesher/__init__.py +21 -0
  87. nmesh/mesher/driver.py +146 -0
  88. nmesh/mesher/meshing_defaults.py +252 -0
  89. nmesh/mesher/meshing_parameters.py +185 -0
  90. nmesh/mesher/parity.py +21 -0
  91. nmesh/mesher/parity_canonical.py +142 -0
  92. nmesh/mesher/parity_comparison.py +191 -0
  93. nmesh/mesher/parity_metrics.py +114 -0
  94. nmesh/mesher/periodic.py +97 -0
  95. nmesh/mesher/relaxation/__init__.py +14 -0
  96. nmesh/mesher/relaxation/_constants.py +20 -0
  97. nmesh/mesher/relaxation/_types.py +15 -0
  98. nmesh/mesher/relaxation/density.py +170 -0
  99. nmesh/mesher/relaxation/engine/__init__.py +18 -0
  100. nmesh/mesher/relaxation/engine/state.py +155 -0
  101. nmesh/mesher/relaxation/engine/steps.py +248 -0
  102. nmesh/mesher/relaxation/engine/topology.py +230 -0
  103. nmesh/mesher/relaxation/forces/__init__.py +96 -0
  104. nmesh/mesher/relaxation/forces/jit.py +102 -0
  105. nmesh/mesher/relaxation/forces/neighbors.py +186 -0
  106. nmesh/mesher/relaxation/forces/simplex.py +302 -0
  107. nmesh/mesher/relaxation/forces/summary.py +207 -0
  108. nmesh/mesher/relaxation/forces/types.py +92 -0
  109. nmesh/mesher/relaxation/geometry/__init__.py +6 -0
  110. nmesh/mesher/relaxation/geometry/builder.py +154 -0
  111. nmesh/mesher/relaxation/geometry/model.py +194 -0
  112. nmesh/mesher/relaxation/seeding/__init__.py +74 -0
  113. nmesh/mesher/relaxation/seeding/periodic.py +88 -0
  114. nmesh/mesher/relaxation/seeding/points.py +88 -0
  115. nmesh/mesher/relaxation/seeding/sampling.py +142 -0
  116. nmesh/mesher/relaxation/topology/__init__.py +297 -0
  117. nmesh/mesher/relaxation/topology/finalize.py +78 -0
  118. nmesh/mesher/relaxation/topology/recovery.py +310 -0
  119. nmesh/mesher/sectioned_config.py +70 -0
  120. nmesh/nmesh.py +99 -0
  121. nmesh/py.typed +1 -0
  122. nmesh/utils/__init__.py +33 -0
  123. nmesh/utils/array_list_utils.py +128 -0
  124. nmesh/utils/constants.py +22 -0
  125. nmesh/utils/timing_memory_utils.py +51 -0
  126. nmesh/utils/types.py +13 -0
  127. si/constants.py +49 -0
  128. si/physical.py +722 -0
  129. si/py.typed +1 -0
  130. simulation/__init__.py +1 -0
  131. simulation/clock.py +237 -0
  132. simulation/data_writer.py +273 -0
  133. simulation/data_writer_collection.py +267 -0
  134. simulation/hysteresis.py +74 -0
  135. simulation/hysteresis_runner.py +286 -0
  136. simulation/hysteresis_schedule.py +180 -0
  137. simulation/inference/__init__.py +3 -0
  138. simulation/inference/inference.py +95 -0
  139. simulation/py.typed +1 -0
  140. simulation/quantity.py +88 -0
  141. simulation/simulation_core.py +458 -0
  142. throttler/__init__.py +3 -0
  143. throttler/py.typed +1 -0
  144. throttler/throttler.py +55 -0
  145. when/__init__.py +3 -0
  146. when/py.typed +1 -0
  147. when/when.py +416 -0
@@ -0,0 +1,142 @@
1
+ """Density-weighted random sampling helpers for relaxation seed points."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from typing import Any
7
+
8
+ import numpy as np
9
+
10
+ from .._constants import DENSITY_EPSILON
11
+ from .._types import FloatArray
12
+ from ..geometry import FemGeometry
13
+ from .points import _dedupe_points, _filter_relevant_points, _point_key
14
+
15
+
16
+ def _box_volume(geometry: FemGeometry) -> float:
17
+ return float(np.prod(np.maximum(geometry.bbox_max - geometry.bbox_min, 0.0)))
18
+
19
+
20
+ def _random_point_in_box(geometry: FemGeometry, rng: np.random.Generator) -> FloatArray:
21
+ return geometry.bbox_min + rng.random(geometry.dim) * (geometry.bbox_max - geometry.bbox_min)
22
+
23
+
24
+ def _sampling_density(geometry: FemGeometry, point: FloatArray) -> float:
25
+ if geometry.classify_points(np.asarray(point, dtype=float)[np.newaxis, :])[0] < 0:
26
+ return 0.0
27
+ return geometry.density_at(point)
28
+
29
+
30
+ def _estimate_density_max_and_average(
31
+ geometry: FemGeometry,
32
+ nr_probes: int,
33
+ rng: np.random.Generator,
34
+ *,
35
+ conservative_factor: float = 1.12,
36
+ ) -> tuple[float, float]:
37
+ """Estimate the legacy random-sampling maximum and average density."""
38
+
39
+ if nr_probes <= 0:
40
+ return 0.0, 0.0
41
+ max_seen = _sampling_density(geometry, _random_point_in_box(geometry, rng))
42
+ density_sum = 0.0
43
+ for _ in range(1, nr_probes):
44
+ value = _sampling_density(geometry, _random_point_in_box(geometry, rng))
45
+ max_seen = max(max_seen, value)
46
+ density_sum += value
47
+ return max_seen * conservative_factor, density_sum / float(nr_probes)
48
+
49
+
50
+ def _sphere_volume(dim: int) -> float:
51
+ return (math.pi ** (0.5 * dim)) / math.gamma(1.0 + 0.5 * dim)
52
+
53
+
54
+ def _sphere_packing_ratio_lattice_type_d(dim: int) -> float:
55
+ if dim <= 1:
56
+ return 1.0
57
+ lattice_vectors = np.zeros((dim, dim), dtype=float)
58
+ for row in range(dim):
59
+ if row == dim - 1:
60
+ lattice_vectors[row, max(dim - 2, 0) :] = 1.0
61
+ else:
62
+ lattice_vectors[row, row] = 1.0
63
+ lattice_vectors[row, row + 1] = -1.0
64
+ lattice_cell_volume = abs(float(np.linalg.det(lattice_vectors)))
65
+ if lattice_cell_volume <= DENSITY_EPSILON:
66
+ return 1.0
67
+ return ((0.5 * math.sqrt(2.0)) ** dim) * _sphere_volume(dim) / lattice_cell_volume
68
+
69
+
70
+ def _estimate_initial_point_count(
71
+ geometry: FemGeometry, a0: float, fixed_points: FloatArray, average_density: float
72
+ ) -> int:
73
+ _ = fixed_points
74
+ node_volume = (
75
+ _sphere_volume(geometry.dim)
76
+ * ((a0 * 0.5 * 0.7) ** geometry.dim)
77
+ / max(_sphere_packing_ratio_lattice_type_d(geometry.dim), DENSITY_EPSILON)
78
+ )
79
+ estimated_nodes = average_density * _box_volume(geometry) / max(node_volume, DENSITY_EPSILON)
80
+ return min(10_000, max(geometry.dim + 1 + 5, int(estimated_nodes)))
81
+
82
+
83
+ def _distribute_points_randomly(
84
+ geometry: FemGeometry, nr_points: int, max_density: float, rng: np.random.Generator
85
+ ) -> FloatArray:
86
+ if nr_points <= 0 or max_density <= DENSITY_EPSILON:
87
+ return np.empty((0, geometry.dim), dtype=float)
88
+ _ = _random_point_in_box(geometry, rng)
89
+ result = np.empty((nr_points, geometry.dim), dtype=float)
90
+ accepted = 0
91
+ while accepted < nr_points:
92
+ point = _random_point_in_box(geometry, rng)
93
+ if rng.random() * max_density <= _sampling_density(geometry, point):
94
+ result[accepted] = point
95
+ accepted += 1
96
+ return result
97
+
98
+
99
+ def _select_generated_points(
100
+ geometry: FemGeometry,
101
+ a0: float,
102
+ fixed_points: FloatArray,
103
+ mobile_points: FloatArray,
104
+ simply_points: FloatArray,
105
+ rng: np.random.Generator,
106
+ params: dict[str, Any],
107
+ ) -> FloatArray:
108
+ """Generate density-weighted random seed points like the legacy mesher."""
109
+
110
+ max_density, average_density = _estimate_density_max_and_average(
111
+ geometry, int(params.get("nr_probes_for_determining_volume", 100_000)), rng
112
+ )
113
+ candidates = _distribute_points_randomly(
114
+ geometry,
115
+ _estimate_initial_point_count(geometry, a0, fixed_points, average_density),
116
+ max_density,
117
+ rng,
118
+ )
119
+ candidate_keys = {_point_key(point) for point in fixed_points}
120
+ candidate_keys.update(_point_key(point) for point in mobile_points)
121
+ candidate_keys.update(_point_key(point) for point in simply_points)
122
+ selected: list[FloatArray] = []
123
+ for point in candidates:
124
+ key = _point_key(point)
125
+ if (
126
+ key not in candidate_keys
127
+ and geometry.classify_points(np.asarray(point, dtype=float)[np.newaxis, :])[0] >= 0
128
+ ):
129
+ selected.append(point)
130
+ candidate_keys.add(key)
131
+ return (
132
+ np.asarray(selected, dtype=float) if selected else np.empty((0, geometry.dim), dtype=float)
133
+ )
134
+
135
+
136
+ def _collect_hint_points(geometry: FemGeometry) -> FloatArray:
137
+ """Merge, deduplicate, and filter hint points from all geometry pieces."""
138
+
139
+ hint_points = [points for points in geometry.piece_hints if len(points) > 0]
140
+ if not hint_points:
141
+ return np.empty((0, geometry.dim), dtype=float)
142
+ return _filter_relevant_points(geometry, _dedupe_points(np.vstack(hint_points)))
@@ -0,0 +1,297 @@
1
+ """Topology extraction and RawMesh assembly helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from itertools import combinations
7
+ from typing import Any
8
+
9
+ import numpy as np
10
+ from scipy.spatial import Delaunay, QhullError
11
+
12
+ from ....backend import RawMesh
13
+ from ....utils.types import IntArray
14
+ from ...periodic import build_periodic_groups
15
+ from .._constants import BOUNDARY_FUZZ, STATE_BOUNDARY, STATE_MOBILE
16
+ from .._types import FloatArray
17
+ from ..geometry import FemGeometry
18
+
19
+
20
+ def _simplex_measures(points: FloatArray, simplices: np.ndarray, dim: int) -> FloatArray:
21
+ """Compute 1D lengths or higher-dimensional simplex volumes."""
22
+
23
+ if len(simplices) == 0:
24
+ return np.empty(0, dtype=float)
25
+
26
+ if dim == 1:
27
+ return np.abs(points[simplices[:, 1], 0] - points[simplices[:, 0], 0])
28
+
29
+ matrices = points[simplices[:, 1:]] - points[simplices[:, [0]]]
30
+ determinants = np.linalg.det(matrices)
31
+ return np.abs(determinants) / math.factorial(dim)
32
+
33
+
34
+ def _surface_faces(simplices: np.ndarray, regions: np.ndarray, dim: int) -> list[list[int]]:
35
+ """Extract boundary faces by counting face ownership across simplices."""
36
+
37
+ if len(simplices) == 0:
38
+ return []
39
+
40
+ face_map: dict[tuple[int, ...], set[int]] = {}
41
+ face_size = dim
42
+ for simplex, region in zip(simplices, regions, strict=True):
43
+ for face in combinations(simplex.tolist(), face_size):
44
+ key = tuple(sorted(face))
45
+ face_map.setdefault(key, set()).add(int(region))
46
+
47
+ counts: dict[tuple[int, ...], int] = {}
48
+ for simplex in simplices:
49
+ for face in combinations(simplex.tolist(), face_size):
50
+ key = tuple(sorted(face))
51
+ counts[key] = counts.get(key, 0) + 1
52
+
53
+ surfaces: list[list[int]] = []
54
+ for face, count in counts.items():
55
+ if count == 1 or len(face_map.get(face, set())) > 1:
56
+ surfaces.append(list(face))
57
+ return surfaces
58
+
59
+
60
+ def _unique_links(simplices: np.ndarray) -> list[tuple[int, int]]:
61
+ """Return the sorted unique undirected edges induced by the simplices."""
62
+
63
+ links: set[tuple[int, int]] = set()
64
+ for simplex in simplices:
65
+ for edge_start, edge_end in combinations(simplex.tolist(), 2):
66
+ start = int(edge_start)
67
+ end = int(edge_end)
68
+ if start <= end:
69
+ links.add((start, end))
70
+ else:
71
+ links.add((end, start))
72
+ return sorted(links)
73
+
74
+
75
+ def _point_regions(point_count: int, simplices: np.ndarray, regions: np.ndarray) -> list[list[int]]:
76
+ """Build the region-membership list for each mesh point."""
77
+
78
+ memberships: list[set[int]] = [set() for _ in range(point_count)]
79
+ for simplex, region in zip(simplices, regions, strict=True):
80
+ for point_index in simplex:
81
+ memberships[int(point_index)].add(int(region))
82
+ return [sorted(group) for group in memberships]
83
+
84
+
85
+ def _region_volumes(region_ids: np.ndarray, measures: FloatArray) -> list[float]:
86
+ """Aggregate simplex measures into per-region total volumes."""
87
+
88
+ if len(region_ids) == 0:
89
+ return []
90
+
91
+ order = sorted({int(region) for region in region_ids})
92
+ totals = {region: 0.0 for region in order}
93
+ for region, measure in zip(region_ids, measures, strict=True):
94
+ totals[int(region)] += float(measure)
95
+ return [totals[region] for region in order]
96
+
97
+
98
+ def _regular_boundary_ratio(dim: int) -> float:
99
+ """Return the normalized volume-order ratio of an ideal regular simplex."""
100
+
101
+ if dim <= 0:
102
+ return 1.0
103
+ return ((dim + 1) ** ((dim + 1) / 2.0)) / (math.factorial(dim) * (dim ** (dim / 2.0)))
104
+
105
+
106
+ def _simplex_volume_order_ratio(points: FloatArray, simplices: np.ndarray, dim: int) -> FloatArray:
107
+ """Return simplex volume divided by the local length scale raised to ``dim``."""
108
+
109
+ if len(simplices) == 0:
110
+ return np.empty(0, dtype=float)
111
+
112
+ centroids = np.mean(points[simplices], axis=1)
113
+ offsets = points[simplices] - centroids[:, None, :]
114
+ max_radius = np.max(np.linalg.norm(offsets, axis=2), axis=1)
115
+ measures = _simplex_measures(points, simplices, dim)
116
+ order_scale = np.maximum(max_radius, BOUNDARY_FUZZ) ** dim
117
+ return measures / order_scale
118
+
119
+
120
+ def _classify_simplices_with_probes(
121
+ points: FloatArray,
122
+ simplices: np.ndarray,
123
+ geometry: FemGeometry,
124
+ ) -> tuple[np.ndarray, np.ndarray]:
125
+ """Classify simplices using centroid and near-vertex probe points."""
126
+
127
+ if len(simplices) == 0:
128
+ return np.empty(0, dtype=int), np.empty(0, dtype=bool)
129
+
130
+ centroids = np.mean(points[simplices], axis=1)
131
+ region_ids = geometry.classify_points(centroids)
132
+ offsets = points[simplices] - centroids[:, None, :]
133
+ probes = (centroids[:, None, :] + 0.9 * offsets).reshape(-1, geometry.dim)
134
+ flat_probe_regions = geometry.classify_points(probes)
135
+ probe_regions = np.asarray(
136
+ [
137
+ flat_probe_regions[index : index + geometry.dim + 1]
138
+ for index in range(0, len(flat_probe_regions), geometry.dim + 1)
139
+ ],
140
+ dtype=int,
141
+ )
142
+ single_region = (not geometry.mesh_exterior) and (len(set(geometry.region_ids)) <= 1)
143
+ if single_region:
144
+ consistent = (region_ids >= 0) & np.all(probe_regions == region_ids[:, None], axis=1)
145
+ else:
146
+ consistent = (region_ids >= 0) & np.all(probe_regions >= 0, axis=1)
147
+ return region_ids, consistent
148
+
149
+
150
+ def _triangulate_points(
151
+ points: FloatArray, dim: int, states: np.ndarray | None = None
152
+ ) -> np.ndarray:
153
+ """Triangulate the point cloud, retrying with light jitter for degenerate inputs."""
154
+
155
+ if len(points) < dim + 1:
156
+ return np.empty((0, dim + 1), dtype=int)
157
+
158
+ if dim == 1:
159
+ order = np.argsort(points[:, 0], kind="mergesort")
160
+ return np.column_stack((order[:-1], order[1:])).astype(int)
161
+
162
+ try:
163
+ return Delaunay(points).simplices.astype(int, copy=False)
164
+ except QhullError:
165
+ jittered = np.array(points, copy=True)
166
+ movable = (
167
+ np.ones(len(points), dtype=bool)
168
+ if states is None
169
+ else np.isin(states, [STATE_MOBILE, STATE_BOUNDARY])
170
+ )
171
+ if np.any(movable):
172
+ amplitudes = np.linspace(1.0e-9, 1.0e-8, np.count_nonzero(movable))
173
+ offsets = np.column_stack(
174
+ [
175
+ amplitudes * np.sin(np.arange(1, len(amplitudes) + 1) * (axis + 1))
176
+ for axis in range(dim)
177
+ ]
178
+ )
179
+ jittered[movable] += offsets
180
+ try:
181
+ return Delaunay(jittered).simplices.astype(int, copy=False)
182
+ except QhullError:
183
+ return np.empty((0, dim + 1), dtype=int)
184
+
185
+
186
+ def _orient_simplices_positive(points: FloatArray, simplices: np.ndarray, dim: int) -> np.ndarray:
187
+ """Return simplices ordered with positive legacy simplex orientation."""
188
+
189
+ if dim <= 1 or len(simplices) == 0:
190
+ return simplices
191
+
192
+ oriented = np.array(simplices, copy=True)
193
+ matrices = points[oriented[:, 1:]] - points[oriented[:, [0]]]
194
+ determinants = np.linalg.det(matrices)
195
+ negative = np.flatnonzero(determinants < 0.0)
196
+ if len(negative) > 0:
197
+ first_nodes: IntArray = np.array(oriented[negative, 0], dtype=np.int_, copy=True)
198
+ oriented[negative, 0] = oriented[negative, 1]
199
+ oriented[negative, 1] = first_nodes
200
+ return oriented
201
+
202
+
203
+ def assemble_raw_mesh(
204
+ points: FloatArray,
205
+ geometry: FemGeometry,
206
+ periodic: list[float] | list[bool],
207
+ *,
208
+ states: np.ndarray | None = None,
209
+ params: dict[str, Any] | None = None,
210
+ ) -> RawMesh:
211
+ """Assemble a ``RawMesh`` from relaxed points and geometry classification."""
212
+
213
+ coords = np.asarray(points, dtype=float)
214
+ dim = geometry.dim
215
+ simplices = _triangulate_points(coords, dim, states)
216
+ simplices = _orient_simplices_positive(coords, simplices, dim)
217
+
218
+ if len(simplices) > 0:
219
+ region_ids, probe_consistent = _classify_simplices_with_probes(coords, simplices, geometry)
220
+ measures = _simplex_measures(coords, simplices, dim)
221
+ boundary_mask = _boundary_state_mask(
222
+ coords,
223
+ states,
224
+ geometry,
225
+ )
226
+ all_boundary = np.all(boundary_mask[simplices], axis=1)
227
+ boundary_ratio = _simplex_volume_order_ratio(coords, simplices, dim)
228
+ smallest_allowed_ratio = float(
229
+ (params or {}).get("controller_smallest_allowed_volume_ratio", 1.0)
230
+ )
231
+ flat_boundary = all_boundary & (boundary_ratio < smallest_allowed_ratio)
232
+ keep = probe_consistent & (measures > BOUNDARY_FUZZ) & ~flat_boundary
233
+ simplices = simplices[keep]
234
+ region_ids = region_ids[keep]
235
+ measures = measures[keep]
236
+ else:
237
+ region_ids = np.empty(0, dtype=int)
238
+ measures = np.empty(0, dtype=float)
239
+
240
+ surfaces = _surface_faces(simplices, region_ids, dim)
241
+ links = _unique_links(simplices)
242
+ point_regions = _point_regions(len(coords), simplices, region_ids)
243
+ region_volumes = _region_volumes(region_ids, measures)
244
+ periodic_groups = build_periodic_groups(
245
+ coords,
246
+ geometry.bbox_min,
247
+ geometry.bbox_max,
248
+ periodic,
249
+ tolerance=BOUNDARY_FUZZ,
250
+ )
251
+
252
+ return RawMesh(
253
+ points=coords.tolist(),
254
+ simplices=simplices.tolist(),
255
+ regions=region_ids.astype(int).tolist(),
256
+ point_regions=point_regions,
257
+ surfaces=surfaces,
258
+ links=links,
259
+ region_volumes=region_volumes,
260
+ periodic_point_indices=periodic_groups,
261
+ permutation=list(range(len(coords))),
262
+ dim=dim,
263
+ )
264
+
265
+
266
+ def _boundary_state_mask(
267
+ coords: FloatArray,
268
+ states: np.ndarray | None,
269
+ geometry: FemGeometry,
270
+ ) -> np.ndarray:
271
+ """Return the point mask used for legacy flat-boundary simplex rejection."""
272
+
273
+ if states is not None and len(states) == len(coords):
274
+ return np.asarray(states, dtype=int) == STATE_BOUNDARY
275
+ return geometry.boundary_mask(
276
+ coords,
277
+ tolerance=max(BOUNDARY_FUZZ * 10.0, 1.0e-5),
278
+ )
279
+
280
+
281
+ def _callback_mesh_info(raw_mesh: RawMesh) -> list[list[Any]]:
282
+ """Build the legacy-style callback payload expected by old consumers."""
283
+
284
+ simplices: list[list[Any]] = [
285
+ [simplex, (([], 0.0), ([], 0.0), region)]
286
+ for simplex, region in zip(raw_mesh.simplices, raw_mesh.regions, strict=True)
287
+ ]
288
+ surfaces: list[list[Any]] = [
289
+ [surface, (([], 0.0), ([], 0.0), 1)] for surface in raw_mesh.surfaces
290
+ ]
291
+ return [
292
+ ["COORDS", "Node coordinates", raw_mesh.points],
293
+ ["LINKS", "Mesh links", raw_mesh.links],
294
+ ["POINT-BODIES", "Point region memberships", raw_mesh.point_regions],
295
+ ["SIMPLICES", "Simplex connectivity", simplices],
296
+ ["SURFACES", "Surface connectivity", surfaces],
297
+ ]
@@ -0,0 +1,78 @@
1
+ """Final mesh cleanup helpers for the relaxation meshing pipeline."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import itertools
6
+ from typing import Any
7
+
8
+ import numpy as np
9
+
10
+ from .._constants import BOUNDARY_FUZZ, STATE_BOUNDARY, STATE_FIXED, STATE_MOBILE, STATE_SIMPLE
11
+ from .._types import FloatArray
12
+ from ..geometry import FemGeometry
13
+ from . import _triangulate_points
14
+
15
+
16
+ def snap_final_boundary_points(
17
+ points: FloatArray,
18
+ states: np.ndarray,
19
+ geometry: FemGeometry,
20
+ a0: float,
21
+ params: dict[str, Any],
22
+ ) -> tuple[FloatArray, np.ndarray]:
23
+ """Snap final points near fixed/boundary neighbors onto implicit boundaries."""
24
+
25
+ if len(points) < geometry.dim + 1:
26
+ return points, states
27
+
28
+ simplices = _triangulate_points(points, geometry.dim, states)
29
+ if len(simplices) == 0:
30
+ return points, states
31
+
32
+ neighbor_map = _build_neighbor_map(len(points), simplices)
33
+ cleaned_points = np.array(points, copy=True)
34
+ cleaned_states = np.array(states, copy=True)
35
+ acceptable_fuzz = float(params.get("boundary_condition_acceptable_fuzz", BOUNDARY_FUZZ))
36
+ max_steps = int(params.get("boundary_condition_max_nr_correction_steps", 200))
37
+
38
+ for point_index, neighbors in enumerate(neighbor_map):
39
+ if not neighbors or not _has_boundary_like_neighbor(cleaned_states, neighbors):
40
+ continue
41
+
42
+ original = cleaned_points[point_index]
43
+ snapped = geometry.project_point_to_boundary_from_inside(
44
+ original,
45
+ acceptable_fuzz=acceptable_fuzz,
46
+ max_steps=max_steps,
47
+ )
48
+ if geometry.boundary_distance(snapped) > max(acceptable_fuzz, BOUNDARY_FUZZ):
49
+ continue
50
+ if geometry.classify_points(snapped[np.newaxis, :])[0] < 0:
51
+ continue
52
+ local_rod = a0 / (geometry.density_at(original) ** (1.0 / max(geometry.dim, 1)))
53
+ if float(np.linalg.norm(snapped - original)) < 0.2 * local_rod:
54
+ cleaned_points[point_index] = snapped
55
+ if cleaned_states[point_index] in (STATE_MOBILE, STATE_BOUNDARY):
56
+ cleaned_states[point_index] = STATE_BOUNDARY
57
+
58
+ return cleaned_points, cleaned_states
59
+
60
+
61
+ def _build_neighbor_map(point_count: int, simplices: np.ndarray) -> list[list[int]]:
62
+ """Build undirected point adjacency from simplices."""
63
+
64
+ neighbors: list[set[int]] = [set() for _ in range(point_count)]
65
+ for simplex in simplices:
66
+ for left, right in itertools.combinations(simplex.tolist(), 2):
67
+ left_index = int(left)
68
+ right_index = int(right)
69
+ neighbors[left_index].add(right_index)
70
+ neighbors[right_index].add(left_index)
71
+ return [sorted(group) for group in neighbors]
72
+
73
+
74
+ def _has_boundary_like_neighbor(states: np.ndarray, neighbors: list[int]) -> bool:
75
+ """Return whether any neighbor should trigger final boundary snapping."""
76
+
77
+ boundary_like_states = {STATE_FIXED, STATE_BOUNDARY, STATE_SIMPLE}
78
+ return any(int(states[neighbor]) in boundary_like_states for neighbor in neighbors)