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,230 @@
1
+ """Topology refresh and point insertion/deletion for relaxation meshing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ import numpy as np
9
+
10
+ from ...meshing_parameters import PointFate, default_handle_point_density_fun
11
+ from .._constants import BOUNDARY_FUZZ, STATE_BOUNDARY, STATE_MOBILE
12
+ from .._types import FloatArray
13
+ from ..forces import ForceSummary
14
+ from ..seeding import _classify_dynamic_states, _dedupe_points
15
+ from ..topology.recovery import mirror_surface_recovery_points
16
+
17
+
18
+ class RelaxationEngineTopologyMixin:
19
+ if TYPE_CHECKING:
20
+
21
+ def __getattr__(self, name: str) -> Any: ...
22
+
23
+ def _mark_topology_stale(self) -> None:
24
+ """Request a fresh Delaunay triangulation before the next force step."""
25
+
26
+ self.current_simplices = None
27
+ self.max_rel_movement_since_last_triangulation = 0.0
28
+ self.points_at_last_triangulation = np.array(self.points, copy=True)
29
+
30
+ def _refresh_boundary_states(self) -> None:
31
+ """Refresh dynamic points between mobile and boundary states."""
32
+
33
+ dynamic_mask = np.isin(self.states, [STATE_MOBILE, STATE_BOUNDARY])
34
+ if not np.any(dynamic_mask):
35
+ return
36
+ refreshed = _classify_dynamic_states(self.geometry, self.points[dynamic_mask], self.a0)
37
+ self.states[dynamic_mask] = refreshed
38
+
39
+ def _effective_time_step(self, force_summary: ForceSummary) -> float:
40
+ """Compute the controller-like time step for the current force field."""
41
+
42
+ freedom = float(self.params.get("controller_movement_max_freedom", 3.0))
43
+ movement_weight = self._initial_relaxation_weight(freedom, 1.0)
44
+ capped_max_time_step = movement_weight * self.max_time_step
45
+ if force_summary.max_effective_force <= BOUNDARY_FUZZ:
46
+ return capped_max_time_step
47
+ return min(
48
+ capped_max_time_step,
49
+ (movement_weight * self.time_step_scale) / force_summary.max_effective_force,
50
+ )
51
+
52
+ def _initial_relaxation_weight(self, init_val: float, final_val: float) -> float:
53
+ """Evaluate the legacy initial-relaxation ramp for this step."""
54
+
55
+ settling_steps = int(self.params.get("controller_initial_settling_steps", 100))
56
+ weight_fun = self.params.get("initial_relaxation_weight_fun")
57
+ if callable(weight_fun):
58
+ result: Any = weight_fun(self.step, settling_steps, init_val, final_val)
59
+ return float(result)
60
+ fraction = min(1.0, float(self.step) / max(float(settling_steps), 1.0))
61
+ return init_val + (final_val - init_val) * fraction
62
+
63
+ def _density_thresholds(self) -> tuple[float, float]:
64
+ """Return add/delete thresholds with the legacy initial relaxation ramp."""
65
+
66
+ freedom = float(self.params.get("controller_movement_max_freedom", 3.0))
67
+ thresh_add = self._initial_relaxation_weight(-0.1 * freedom, 0.0) + float(
68
+ self.params.get("controller_thresh_add", 1.0)
69
+ )
70
+ thresh_del = self._initial_relaxation_weight(0.1 * freedom, 0.0) + float(
71
+ self.params.get("controller_thresh_del", 2.0)
72
+ )
73
+ return thresh_add, thresh_del
74
+
75
+ @staticmethod
76
+ def _is_positive_square_number(value: int) -> bool:
77
+ """Return whether ``value`` is a positive perfect square."""
78
+
79
+ if value <= 0:
80
+ return False
81
+ root = int(math.sqrt(value) + 0.5)
82
+ return root * root == value
83
+
84
+ def _should_attempt_point_change(self) -> bool:
85
+ """Return whether the legacy controller schedules point fate changes."""
86
+
87
+ return self.step < self.max_steps and self._is_positive_square_number(self.step - 10)
88
+
89
+ def _step_limit_reached(self) -> bool:
90
+ """Return whether the legacy max-step and post-change settling rules are met."""
91
+
92
+ return (
93
+ self.step >= self.max_steps
94
+ and self.step >= self.last_addition_deletion_step + self.post_change_settling_steps
95
+ )
96
+
97
+ def _topology_threshold(self) -> float:
98
+ """Return the relaxed legacy threshold for topology refresh."""
99
+
100
+ freedom = float(self.params.get("controller_movement_max_freedom", 3.0))
101
+ threshold = float(self.params.get("controller_topology_threshold", 0.2))
102
+ return self._initial_relaxation_weight(freedom, 1.0) * threshold
103
+
104
+ def _record_triangulation(self, force_summary: ForceSummary) -> None:
105
+ """Cache the current topology after a force calculation builds it."""
106
+
107
+ if self.current_simplices is None:
108
+ self.current_simplices = np.asarray(force_summary.simplices, dtype=int)
109
+ self.points_at_last_triangulation = np.array(self.points, copy=True)
110
+ self.max_rel_movement_since_last_triangulation = 0.0
111
+
112
+ def _update_topology_movement(self) -> None:
113
+ """Track movement since the last triangulation in OCaml controller units."""
114
+
115
+ if len(self.points) != len(self.points_at_last_triangulation):
116
+ self._mark_topology_stale()
117
+ return
118
+
119
+ if len(self.points) == 0:
120
+ self.max_rel_movement_since_last_triangulation = 0.0
121
+ return
122
+
123
+ displacements = np.linalg.norm(self.points - self.points_at_last_triangulation, axis=1)
124
+ density_scale = np.asarray(
125
+ [
126
+ self.geometry.density_at(point) ** (1.0 / max(self.geometry.dim, 1))
127
+ for point in self.points
128
+ ],
129
+ dtype=float,
130
+ )
131
+ scaled = displacements * density_scale / max(self.a0, BOUNDARY_FUZZ)
132
+ self.max_rel_movement_since_last_triangulation = max(
133
+ self.max_rel_movement_since_last_triangulation,
134
+ float(np.max(scaled, initial=0.0)),
135
+ )
136
+
137
+ def _refresh_topology_if_needed(self) -> None:
138
+ """Invalidate cached topology when movement exceeds the legacy threshold."""
139
+
140
+ if self.max_rel_movement_since_last_triangulation > self._topology_threshold():
141
+ self._mark_topology_stale()
142
+
143
+ def _attempt_add_delete_points(self, force_summary: ForceSummary) -> None:
144
+ """Apply the mesher density heuristic to add or remove mobile points."""
145
+
146
+ if len(self.points) == 0:
147
+ return
148
+
149
+ additions, removals = self._evaluate_point_densities(force_summary)
150
+ recovery_points = mirror_surface_recovery_points(
151
+ self.points,
152
+ self.states,
153
+ self.geometry,
154
+ self.a0,
155
+ force_summary.simplices,
156
+ )
157
+ if len(recovery_points) > 0:
158
+ additions.extend(recovery_points)
159
+ self._apply_point_changes(additions, removals)
160
+ self._refresh_boundary_states()
161
+
162
+ def _evaluate_point_densities(
163
+ self,
164
+ force_summary: ForceSummary,
165
+ ) -> tuple[list[FloatArray], list[int]]:
166
+ """Decide which points to add or remove from the current cloud."""
167
+
168
+ handler = self.params.get("handle_point_density_fun", default_handle_point_density_fun)
169
+ thresh_add, thresh_del = self._density_thresholds()
170
+ additions: list[FloatArray] = []
171
+ removals: list[int] = []
172
+
173
+ for index, state in enumerate(self.states):
174
+ if state not in (STATE_MOBILE, STATE_BOUNDARY) or index in removals:
175
+ continue
176
+
177
+ neigh = force_summary.neighbor_map[index]
178
+ if not neigh:
179
+ continue
180
+
181
+ point = self.points[index]
182
+ avg_density = float(force_summary.point_density[index])
183
+ avg_force = float(force_summary.point_average_force[index])
184
+
185
+ fate = handler(self.rng, (avg_density, avg_force), thresh_add, thresh_del)
186
+ if fate == PointFate.ADD_ANOTHER:
187
+ additions.append(self._random_point_close_to(point))
188
+ elif (
189
+ fate == PointFate.DELETE
190
+ and len(self.points) - len(removals) > self.geometry.dim + 1
191
+ ):
192
+ removals.append(index)
193
+
194
+ return additions, removals
195
+
196
+ def _random_point_close_to(self, point: FloatArray) -> FloatArray:
197
+ """Return a Gaussian insertion candidate using the legacy rod-length scale."""
198
+
199
+ density_here = self.geometry.density_at(point)
200
+ effective_rod_length = self.a0 * (density_here ** (-1.0 / max(self.geometry.dim, 1)))
201
+ candidate = np.asarray(
202
+ self.rng.normal(loc=np.asarray(point, dtype=float), scale=effective_rod_length),
203
+ dtype=float,
204
+ )
205
+ if self.geometry.classify_points(candidate[np.newaxis, :])[0] >= 0:
206
+ return candidate
207
+ return self.geometry.project_segment_to_domain(np.asarray(point, dtype=float), candidate)
208
+
209
+ def _apply_point_changes(self, additions: list[FloatArray], removals: list[int]) -> None:
210
+ """Mutate the point cloud according to the evaluated add/remove plan."""
211
+
212
+ if removals:
213
+ keep = np.ones(len(self.points), dtype=bool)
214
+ keep[np.asarray(removals, dtype=int)] = False
215
+ self.points = self.points[keep]
216
+ self.states = self.states[keep]
217
+ self._mark_topology_stale()
218
+
219
+ if additions:
220
+ additions_arr = _dedupe_points(np.asarray(additions, dtype=float))
221
+ if len(additions_arr) > 0:
222
+ self.points = np.vstack((self.points, additions_arr))
223
+ addition_states = _classify_dynamic_states(self.geometry, additions_arr, self.a0)
224
+ self.states = np.concatenate(
225
+ (
226
+ self.states,
227
+ addition_states,
228
+ )
229
+ )
230
+ self._mark_topology_stale()
@@ -0,0 +1,96 @@
1
+ """Compatibility facade and orchestration for relaxation meshing forces."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import numpy as np
8
+
9
+ from .._types import FloatArray
10
+ from ..geometry import FemGeometry
11
+ from ..topology import _triangulate_points
12
+ from .neighbors import (
13
+ _build_neighbor_map,
14
+ _compute_neighbor_forces,
15
+ )
16
+ from .simplex import (
17
+ _classify_relevant_simplices,
18
+ _compute_simplex_forces,
19
+ _simplex_incidence_data,
20
+ )
21
+ from .summary import _finalize_force_summary
22
+ from .types import (
23
+ ForceParameters,
24
+ ForceSummary,
25
+ _extract_force_parameters,
26
+ )
27
+
28
+ __all__ = [
29
+ "ForceParameters",
30
+ "ForceSummary",
31
+ "_classify_relevant_simplices",
32
+ "_extract_force_parameters",
33
+ "compute_forces",
34
+ ]
35
+
36
+
37
+ def compute_forces(
38
+ points: FloatArray,
39
+ states: np.ndarray,
40
+ geometry: FemGeometry,
41
+ a0: float,
42
+ params: dict[str, Any],
43
+ step: int,
44
+ simplices: np.ndarray | None = None,
45
+ ) -> ForceSummary:
46
+ """Compute neighbor, shape, volume, and irrelevant-element forces."""
47
+ point_count = len(points)
48
+ dim = geometry.dim
49
+ if simplices is None:
50
+ simplices = _triangulate_points(points, dim, states)
51
+ neighbor_map = _build_neighbor_map(point_count, simplices)
52
+ config = _extract_force_parameters(params, step)
53
+ point_densities = np.asarray([geometry.density_at(point) for point in points], dtype=float)
54
+
55
+ total_forces, neighbor_force_sums, neighbor_force_counts = _compute_neighbor_forces(
56
+ points,
57
+ states,
58
+ simplices,
59
+ neighbor_map,
60
+ point_densities,
61
+ dim,
62
+ a0,
63
+ config,
64
+ )
65
+ simplex_measures, incident_simplices, angle_sums = _simplex_incidence_data(
66
+ points, simplices, dim
67
+ )
68
+ _compute_simplex_forces(
69
+ total_forces,
70
+ points,
71
+ states,
72
+ geometry,
73
+ simplices,
74
+ point_densities,
75
+ simplex_measures,
76
+ angle_sums,
77
+ dim,
78
+ a0,
79
+ config,
80
+ )
81
+ return _finalize_force_summary(
82
+ total_forces=total_forces,
83
+ neighbor_map=neighbor_map,
84
+ simplices=simplices,
85
+ point_densities=point_densities,
86
+ neighbor_force_sums=neighbor_force_sums,
87
+ neighbor_force_counts=neighbor_force_counts,
88
+ simplex_measures=simplex_measures,
89
+ incident_simplices=incident_simplices,
90
+ angle_sums=angle_sums,
91
+ points=points,
92
+ states=states,
93
+ geometry=geometry,
94
+ a0=a0,
95
+ dim=dim,
96
+ )
@@ -0,0 +1,102 @@
1
+ """Numba-accelerated kernels for the relaxation meshing pipeline."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+
7
+ import numpy as np
8
+ from numba import njit
9
+
10
+ from ....utils.constants import MIN_DIVISION_MAGNITUDE
11
+ from .._constants import DENSITY_EPSILON, STATE_BOUNDARY, STATE_FIXED, STATE_MOBILE, STATE_SIMPLE
12
+
13
+
14
+ @njit
15
+ def _default_relaxation_force(reduced_distance: float) -> float:
16
+ """Return the legacy default mobile-mobile force law."""
17
+
18
+ if reduced_distance > 1.0:
19
+ return 0.0
20
+ return 1.0 - reduced_distance
21
+
22
+
23
+ @njit
24
+ def _default_boundary_force(reduced_distance: float) -> float:
25
+ """Return the legacy default boundary interaction force law."""
26
+
27
+ if reduced_distance > 1.0:
28
+ return 0.0
29
+ if reduced_distance < MIN_DIVISION_MAGNITUDE:
30
+ return 1.0e12
31
+ return 1.0 / reduced_distance - 1.0
32
+
33
+
34
+ @njit
35
+ def accumulate_neighbor_forces_default(
36
+ points: np.ndarray,
37
+ states: np.ndarray,
38
+ point_densities: np.ndarray,
39
+ neighbor_pairs: np.ndarray,
40
+ a0: float,
41
+ neigh_force_scale: float,
42
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
43
+ """Accumulate default neighbor forces and controller statistics."""
44
+
45
+ point_count, dim = points.shape
46
+ total_forces = np.zeros((point_count, dim), dtype=np.float64)
47
+ neighbor_force_sums = np.zeros(point_count, dtype=np.float64)
48
+ neighbor_force_counts = np.zeros(point_count, dtype=np.int64)
49
+ inv_dim = 1.0 / float(max(dim, 1))
50
+ a0_safe = max(a0, DENSITY_EPSILON)
51
+
52
+ for pair_index in range(len(neighbor_pairs)):
53
+ left = int(neighbor_pairs[pair_index, 0])
54
+ right = int(neighbor_pairs[pair_index, 1])
55
+ state_left = int(states[left])
56
+ state_right = int(states[right])
57
+ left_dynamic = state_left == STATE_MOBILE or state_left == STATE_BOUNDARY
58
+ right_dynamic = state_right == STATE_MOBILE or state_right == STATE_BOUNDARY
59
+ if not left_dynamic and not right_dynamic:
60
+ continue
61
+
62
+ true_distance_sq = 0.0
63
+ for axis in range(dim):
64
+ delta = points[right, axis] - points[left, axis]
65
+ true_distance_sq += delta * delta
66
+ true_distance = math.sqrt(true_distance_sq)
67
+ if true_distance <= DENSITY_EPSILON:
68
+ continue
69
+
70
+ avg_density = 0.5 * (point_densities[left] + point_densities[right])
71
+ inv_length_scale = math.pow(avg_density, inv_dim) / a0_safe
72
+ reduced_distance = true_distance * inv_length_scale
73
+ boundary_interaction = (
74
+ state_left == STATE_FIXED
75
+ or state_left == STATE_BOUNDARY
76
+ or state_left == STATE_SIMPLE
77
+ or state_right == STATE_FIXED
78
+ or state_right == STATE_BOUNDARY
79
+ or state_right == STATE_SIMPLE
80
+ )
81
+ scalar_force = (
82
+ _default_boundary_force(reduced_distance)
83
+ if boundary_interaction
84
+ else _default_relaxation_force(reduced_distance)
85
+ )
86
+
87
+ scaled_force = neigh_force_scale * abs(scalar_force)
88
+ neighbor_force_sums[left] += scaled_force
89
+ neighbor_force_sums[right] += scaled_force
90
+ neighbor_force_counts[left] += 1
91
+ neighbor_force_counts[right] += 1
92
+
93
+ if scalar_force == 0.0:
94
+ continue
95
+
96
+ force_factor = neigh_force_scale * (-scalar_force)
97
+ for axis in range(dim):
98
+ contribution = force_factor * (points[right, axis] - points[left, axis])
99
+ total_forces[left, axis] += contribution
100
+ total_forces[right, axis] -= contribution
101
+
102
+ return total_forces, neighbor_force_sums, neighbor_force_counts
@@ -0,0 +1,186 @@
1
+ """Neighbor-pair force calculations for the relaxation mesher."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from itertools import combinations
6
+ from typing import Any
7
+
8
+ import numpy as np
9
+
10
+ from ...meshing_parameters import default_boundary_node_force_fun, default_relaxation_force_fun
11
+ from .._constants import DENSITY_EPSILON, STATE_BOUNDARY, STATE_FIXED, STATE_MOBILE, STATE_SIMPLE
12
+ from .._types import FloatArray
13
+ from .jit import accumulate_neighbor_forces_default
14
+ from .types import ForceParameters
15
+
16
+
17
+ def _build_neighbor_pairs(simplices: np.ndarray) -> np.ndarray:
18
+ """Return sorted unique point-pair edges induced by the simplices."""
19
+ if len(simplices) == 0:
20
+ return np.empty((0, 2), dtype=np.int64)
21
+
22
+ pairs: set[tuple[int, int]] = set()
23
+ for simplex in simplices:
24
+ for left, right in combinations(simplex.tolist(), 2):
25
+ i = int(left)
26
+ j = int(right)
27
+ pairs.add((i, j) if i <= j else (j, i))
28
+ return np.asarray(sorted(pairs), dtype=np.int64)
29
+
30
+
31
+ def _is_dynamic_state(state: int) -> bool:
32
+ """Return whether the state participates in relaxation movement."""
33
+ return state in (STATE_MOBILE, STATE_BOUNDARY)
34
+
35
+
36
+ def _is_boundary_interaction(state_a: int, state_b: int) -> bool:
37
+ """Return whether a neighbor interaction should use the boundary law."""
38
+ return state_a in (STATE_FIXED, STATE_BOUNDARY, STATE_SIMPLE) or state_b in (
39
+ STATE_FIXED,
40
+ STATE_BOUNDARY,
41
+ STATE_SIMPLE,
42
+ )
43
+
44
+
45
+ def _neighbor_forces_python(
46
+ points: FloatArray,
47
+ states: np.ndarray,
48
+ point_densities: FloatArray,
49
+ neighbor_map: list[list[int]],
50
+ dim: int,
51
+ a0: float,
52
+ neigh_force_scale: float,
53
+ force_fun: Any,
54
+ boundary_force_fun: Any,
55
+ ) -> tuple[FloatArray, FloatArray, np.ndarray]:
56
+ """Fallback neighbor-force path for custom Python force callbacks."""
57
+ total_forces = np.zeros((len(points), dim), dtype=float)
58
+ neighbor_force_sums = np.zeros(len(points), dtype=float)
59
+ neighbor_force_counts = np.zeros(len(points), dtype=int)
60
+
61
+ for left, neighbors in enumerate(neighbor_map):
62
+ point_left = points[left]
63
+ state_left = int(states[left])
64
+ for right in neighbors:
65
+ if right <= left:
66
+ continue
67
+ contribution, scalar_force = _apply_pairwise_force(
68
+ point_left=point_left,
69
+ point_right=points[right],
70
+ state_left=state_left,
71
+ state_right=int(states[right]),
72
+ density_left=float(point_densities[left]),
73
+ density_right=float(point_densities[right]),
74
+ dim=dim,
75
+ a0=a0,
76
+ neigh_force_scale=neigh_force_scale,
77
+ force_fun=force_fun,
78
+ boundary_force_fun=boundary_force_fun,
79
+ )
80
+ if scalar_force is None:
81
+ continue
82
+ neighbor_force_sums[left] += abs(scalar_force)
83
+ neighbor_force_sums[right] += abs(scalar_force)
84
+ neighbor_force_counts[left] += 1
85
+ neighbor_force_counts[right] += 1
86
+ if contribution is None:
87
+ continue
88
+ total_forces[left] += contribution
89
+ total_forces[right] -= contribution
90
+
91
+ return total_forces, neighbor_force_sums, neighbor_force_counts
92
+
93
+
94
+ def _apply_pairwise_force(
95
+ *,
96
+ point_left: FloatArray,
97
+ point_right: FloatArray,
98
+ state_left: int,
99
+ state_right: int,
100
+ density_left: float,
101
+ density_right: float,
102
+ dim: int,
103
+ a0: float,
104
+ neigh_force_scale: float,
105
+ force_fun: Any,
106
+ boundary_force_fun: Any,
107
+ ) -> tuple[FloatArray | None, float | None]:
108
+ """Compute the pairwise neighbor contribution for one point pair."""
109
+ if not _is_dynamic_state(state_left) and not _is_dynamic_state(state_right):
110
+ return None, None
111
+
112
+ delta = point_right - point_left
113
+ true_distance = float(np.linalg.norm(delta))
114
+ if true_distance <= DENSITY_EPSILON:
115
+ return None, None
116
+
117
+ avg_density = 0.5 * (density_left + density_right)
118
+ inv_length_scale = (avg_density ** (1.0 / max(dim, 1))) / max(a0, DENSITY_EPSILON)
119
+ reduced_distance = true_distance * inv_length_scale
120
+ scalar_force = float(
121
+ (boundary_force_fun if _is_boundary_interaction(state_left, state_right) else force_fun)(
122
+ reduced_distance
123
+ )
124
+ )
125
+ scaled_force = neigh_force_scale * abs(scalar_force)
126
+ if scalar_force == 0.0:
127
+ return None, scaled_force
128
+ return neigh_force_scale * (-scalar_force) * delta, scaled_force
129
+
130
+
131
+ def _build_neighbor_map(point_count: int, simplices: np.ndarray) -> list[list[int]]:
132
+ """Build undirected point adjacency from simplices."""
133
+ neighbors: list[set[int]] = [set() for _ in range(point_count)]
134
+ for simplex in simplices:
135
+ for left, right in combinations(simplex.tolist(), 2):
136
+ i = int(left)
137
+ j = int(right)
138
+ neighbors[i].add(j)
139
+ neighbors[j].add(i)
140
+ return [sorted(group) for group in neighbors]
141
+
142
+
143
+ def _compute_neighbor_forces(
144
+ points: FloatArray,
145
+ states: np.ndarray,
146
+ simplices: np.ndarray,
147
+ neighbor_map: list[list[int]],
148
+ point_densities: FloatArray,
149
+ dim: int,
150
+ a0: float,
151
+ config: ForceParameters,
152
+ ) -> tuple[FloatArray, FloatArray, np.ndarray]:
153
+ """Compute neighbor-force contributions through the JIT or Python path."""
154
+ point_count = len(points)
155
+ if len(simplices) == 0:
156
+ return (
157
+ np.zeros((point_count, dim), dtype=float),
158
+ np.zeros(point_count, dtype=float),
159
+ np.zeros(point_count, dtype=int),
160
+ )
161
+
162
+ if (
163
+ config.force_fun is default_relaxation_force_fun
164
+ and config.boundary_force_fun is default_boundary_node_force_fun
165
+ ):
166
+ neighbor_pairs = _build_neighbor_pairs(simplices)
167
+ return accumulate_neighbor_forces_default(
168
+ np.asarray(points, dtype=np.float64),
169
+ np.asarray(states, dtype=np.int64),
170
+ point_densities.astype(np.float64, copy=False),
171
+ neighbor_pairs,
172
+ float(a0),
173
+ config.neigh_force_scale,
174
+ )
175
+
176
+ return _neighbor_forces_python(
177
+ points,
178
+ states,
179
+ point_densities,
180
+ neighbor_map,
181
+ dim,
182
+ a0,
183
+ config.neigh_force_scale,
184
+ config.force_fun,
185
+ config.boundary_force_fun,
186
+ )