Coverage for dragonfly_sim/core/mesh_manager.py: 92%
379 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-17 03:37 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-17 03:37 +0000
1from dataclasses import dataclass
2import warnings
4import numpy as np
5from mpi4py import MPI
6from petsc4py import PETSc
7import ufl
8import ufl.classes
9from scipy.spatial import KDTree
10import basix.ufl
11import dolfinx
12import dolfinx.fem.petsc
14from dragonfly_sim.utils.mpi_utils import allgather_numpy, assign_global_dof_offset
15from dragonfly_sim.utils.meshwarping_utils import find_local_surface_nodes
16from dragonfly_sim.core.form_manager import FormManager
17from dragonfly_sim.utils.filewriter import FileWriter
20@dataclass(frozen=True)
21class BoundaryNodeSet:
22 """Owned nodes on tagged boundary facets, numbered globally in rank-block order.
24 Built by ``Mesh.boundary_node_set``. ``local_idx`` are this rank's OWNED
25 geometry-node indices; they carry the global ids ``global_idx`` =
26 ``offset + arange(n_local)``, so the ranks' blocks tile ``[0, n_global)``
27 in rank order. ``coords`` and ``geom_gids`` (dolfinx global geometry ids)
28 are replicated on every rank, row for row in that same order.
29 shape_parameterization.WallFFD relies on this numbering.
30 """
31 tags: tuple
32 local_idx: np.ndarray
33 offset: int
34 n_global: int
35 coords: np.ndarray
36 geom_gids: np.ndarray
38 @property
39 def n_local(self):
40 return int(self.local_idx.size)
42 @property
43 def global_idx(self):
44 return np.arange(self.offset, self.offset + self.n_local, dtype=np.int64)
47class Mesh():
48 # DOLFINx returns quadrilateral entity nodes in tensor ("Z") order, which
49 # traverses the quad as a bowtie; this permutation makes it cyclic.
50 _QUAD_CYCLIC_PERM = np.array([0, 1, 3, 2], dtype=np.int64)
52 def __init__(self, inp_mesh, inner_bdry_function=None, node_match_tol=1e-10,
53 wall_tags=None, symmetry_tags=None, facet_tags=None):
54 """
55 The wall (the body) is found in one of two ways:
57 inner_bdry_function a function of the boundary facet midpoints, shape
58 (dim, n), returning a boolean mask: True for the wall. Used if given.
59 wall_tags the tags of the mesh's own boundary facet tags that
60 are the wall (a tag value or name, or a sequence of them), and
61 symmetry_tags those that are symmetry planes. Used if there is no
62 function. Every other boundary facet is far field.
64 The facet tags are `facet_tags` (a dolfinx MeshTags of the facets) or,
65 if None, `inp_mesh.facet_tags`, which utils/mesh_io_utils.py:load_dolfinx_mesh
66 sets (with `inp_mesh.facet_tag_names`, a {name: value} dict). If the
67 function is used and the mesh also has tags, a UserWarning lists them.
68 With the function, symmetry planes are not marked here (3D: y = 0 in
69 DG_windtunnel_model); `symmetry_facet_mask` is None.
70 """
71 # define input Dolfinx mesh object
72 self.mesh = inp_mesh
74 self.baseline_nodes = self.mesh.geometry.x.copy()
75 self.n_owned_nodes = int(self.mesh.geometry.index_map().size_local)
76 self.node_match_tol = node_match_tol
77 self.is_valid = True
78 # design-parameter vector the current node coordinates were built for
79 # (see apply_node_motions); None at baseline or when it is unknown
80 self._deformation_design = None
81 self._coordinate_space = None
82 self._dof_to_node_map = None
83 self._nodal_function = None
84 self._quality = None
85 self._node_sets = {}
86 self.output_suffix = None
87 self._deformation_writer = None
88 self._quality_writers = None
89 # callables run after every change of the node coordinates (see
90 # add_geometry_listener)
91 self._geometry_listeners = []
93 self.n = ufl.FacetNormal(self.mesh) # cell boundary normal vector
95 # Volume-equivalent cell length |K|^(1/d). ufl.CellVolume is not
96 # lowered for quads/hexes and FFCx cannot compile it, so it is written
97 # pointwise from the Jacobian instead: exact on parallelogram cells,
98 # the pointwise density elsewhere. Being a plain Jacobian expression,
99 # it also differentiates w.r.t. the mesh coordinates, which an
100 # assembled DG0 field would not.
101 tdim = self.mesh.topology.dim
102 self.cell_volume = (abs(ufl.JacobianDeterminant(self.mesh))
103 * ufl.classes.ReferenceCellVolume(self.mesh))
104 self.h_vol = self.cell_volume ** (1.0 / tdim)
105 # Shortest edge, for the local pseudo-time step of PTC
106 # (core/time_integration.py); never part of the residual.
107 self.h_min = ufl.MinCellEdgeLength(self.mesh)
109 # The boundary queries below, and the wall-node lookups done later on
110 # (entities_to_geometry) need these to exist already: the facet
111 # entities, facet-to-cell connectivity (which also stores its
112 # transpose), facet-to-vertex and vertex-to-cell connectivity
113 self.mesh.topology.create_entities(tdim - 1)
114 self.mesh.topology.create_connectivity(tdim - 1, tdim)
115 self.mesh.topology.create_connectivity(tdim - 1, 0)
116 self.mesh.topology.create_connectivity(0, tdim)
118 # we compute which facets form the mesh boundaries and their midpoints
119 self.bdry_facets = dolfinx.mesh.exterior_facet_indices(self.mesh.topology)
120 self.bdry_midpoints = dolfinx.mesh.compute_midpoints(
121 self.mesh, self.mesh.topology.dim-1, self.bdry_facets)
123 # from all boundary facets, isolate the boundary of the wing/airfoil/etc.
124 self.inner_bdry_facet_mask, self.symmetry_facet_mask = self._wall_and_symmetry_masks(
125 inner_bdry_function, wall_tags, symmetry_tags, facet_tags)
127 # Initialize the meshtags object; used to define integration domains for boundary terms
128 self.meshtags = None
130 # Instantiate FormManager for handling UFL forms and integration measures
131 self.form_manager = FormManager(self)
133 # ------------------------------------------------------------------
134 # Wall and symmetry boundaries: from a function or from the mesh's tags
135 # ------------------------------------------------------------------
136 def _tag_summary(self, facet_tags, names):
137 """
138 '{value: (name, number of boundary facets)}' lines for the tags on the
139 exterior facets, the same on every rank. Owned facets only, so a facet
140 shared between ranks is counted once.
141 """
142 comm = self.mesh.comm
143 n_owned = self.mesh.topology.index_map(self.mesh.topology.dim - 1).size_local
144 on_bdry = np.isin(facet_tags.indices, self.bdry_facets) & (facet_tags.indices < n_owned)
145 values, counts = np.unique(facet_tags.values[on_bdry], return_counts=True)
146 total = {}
147 for v_c in comm.allgather(dict(zip(values.tolist(), counts.tolist()))):
148 for v, c in v_c.items():
149 total[v] = total.get(v, 0) + c
150 by_value = {v: n for n, v in names.items()}
151 return {v: (by_value.get(v), total[v]) for v in sorted(total)}
153 @staticmethod
154 def _describe(summary):
155 return ", ".join("{}{} ({} facets)".format(v, " '{}'".format(n) if n else "", c)
156 for v, (n, c) in summary.items())
158 def _resolve_tags(self, tags, summary, names, what):
159 """Tag values from a value, a name, or a sequence of them; checked to be present."""
160 if tags is None:
161 return set()
162 tags = [tags] if isinstance(tags, (int, np.integer, str)) else list(tags)
163 values = set()
164 for t in tags:
165 if isinstance(t, str):
166 if t not in names:
167 raise ValueError("{}: the mesh has no boundary tag named '{}' (named tags: {})".format(
168 what, t, sorted(names) or "none"))
169 t = names[t]
170 t = int(t)
171 if t not in summary:
172 raise ValueError("{}: tag {} is not on the mesh's boundary facets (tags present: {})".format(
173 what, t, self._describe(summary) or "none"))
174 values.add(t)
175 return values
177 def _wall_and_symmetry_masks(self, inner_bdry_function, wall_tags, symmetry_tags, facet_tags):
178 """(wall mask, symmetry mask or None) over self.bdry_facets; see __init__."""
179 facet_tags = facet_tags if facet_tags is not None else getattr(self.mesh, "facet_tags", None)
180 names = getattr(self.mesh, "facet_tag_names", None) or {}
181 summary = self._tag_summary(facet_tags, names) if facet_tags is not None else {}
183 if inner_bdry_function is not None:
184 if self.mesh.comm.rank == 0 and (summary or wall_tags is not None
185 or symmetry_tags is not None):
186 if summary:
187 message = ("The input mesh has boundary facet tags: {}. The boundary function "
188 "is used to find the wall, so the boundary facet tags are "
189 "currently unused.{} To use the tags "
190 "instead, leave out the boundary function and pass wall_tags (the "
191 "tags of the body) and, if there is a symmetry plane, "
192 "symmetry_tags; all other boundaries are far field.").format(
193 self._describe(summary),
194 " wall_tags and symmetry_tags are ignored." if (
195 wall_tags is not None or symmetry_tags is not None) else "")
196 else:
197 message = ("wall_tags and symmetry_tags are ignored: the boundary function "
198 "defines the wall.")
199 warnings.warn(message, UserWarning, stacklevel=4)
200 return np.asarray(inner_bdry_function(self.bdry_midpoints.T), dtype=bool), None
202 if wall_tags is None:
203 if summary:
204 raise ValueError(
205 "No boundary function was given, and wall_tags was not set. The mesh has "
206 "boundary facet tags: {}. Pass wall_tags (and symmetry_tags for symmetry "
207 "planes); the other boundaries are far field.".format(self._describe(summary)))
208 raise ValueError("Provide either a boundary function (facet midpoints -> wall mask) "
209 "or wall_tags with a mesh that has boundary facet tags.")
210 if facet_tags is None:
211 raise ValueError("wall_tags was given but the mesh has no boundary facet tags (read it "
212 "with load_dolfinx_mesh, or pass facet_tags)")
213 wall = self._resolve_tags(wall_tags, summary, names, "wall_tags")
214 sym = self._resolve_tags(symmetry_tags, summary, names, "symmetry_tags")
215 if not wall:
216 raise ValueError("wall_tags is empty")
217 if wall & sym:
218 raise ValueError("tags {} are both wall and symmetry".format(sorted(wall & sym)))
220 # tag value of every boundary facet of this rank (0: untagged)
221 n_facets = self.mesh.topology.index_map(self.mesh.topology.dim - 1)
222 value = np.zeros(n_facets.size_local + n_facets.num_ghosts, dtype=np.int64)
223 value[facet_tags.indices] = facet_tags.values
224 value = value[self.bdry_facets]
225 wall_mask = np.isin(value, list(wall))
226 sym_mask = np.isin(value, list(sym)) if sym else None
227 return wall_mask, sym_mask
229 def tag_boundary_facets(self, bdry_facet_value_tuples):
230 # Construct the boundary mesh tags from a list of tuples (boundary facets, domain_boundary_ID)
231 values = np.zeros(self.bdry_midpoints.shape[0], dtype=np.int32)
232 for facets, value in bdry_facet_value_tuples:
233 values[facets] = value
235 self.meshtags = dolfinx.mesh.meshtags(
236 self.mesh, self.mesh.topology.dim - 1, self.bdry_facets, values)
237 return self.meshtags
239 def _wall_boundary_node_coords(self, wall_tag_value):
240 if self.meshtags is None:
241 raise RuntimeError("Mesh tags not yet constructed. "
242 "Call tag_boundary_facets first.")
244 wall_facet_indices = self.meshtags.find(wall_tag_value)
246 self.mesh.topology.create_connectivity(self.mesh.topology.dim - 1, 0)
247 facet_to_node = self.mesh.topology.connectivity(self.mesh.topology.dim - 1, 0)
249 if len(wall_facet_indices) > 0:
250 wall_vertex_topo_idxs = np.unique(np.concatenate(
251 [facet_to_node.links(facet) for facet in wall_facet_indices]))
252 wall_node_geom_idxs = dolfinx.cpp.mesh.entities_to_geometry(
253 self.mesh._cpp_object, 0, wall_vertex_topo_idxs, False)
254 return self.mesh.geometry.x[wall_node_geom_idxs[:, 0], :self.mesh.topology.dim]
256 return np.empty(shape=(0, self.mesh.topology.dim))
258 def _global_bounding_box(self, coords):
259 if coords.shape[0] > 0:
260 local_mins = coords.min(axis=0)
261 local_maxs = coords.max(axis=0)
262 else:
263 local_mins = np.full(self.mesh.topology.dim, np.inf)
264 local_maxs = np.full(self.mesh.topology.dim, -np.inf)
266 return [(self.mesh.comm.allreduce(local_mins[i], op=MPI.MIN),
267 self.mesh.comm.allreduce(local_maxs[i], op=MPI.MAX))
268 for i in range(self.mesh.topology.dim)]
270 def compute_wall_bounding_box(self, wall_tag_value):
271 # Safe to call every iteration on the current (possibly deformed) mesh.
272 coords = self._wall_boundary_node_coords(wall_tag_value)
273 return self._global_bounding_box(coords)
276 # ==================================================================
277 # Geometry nodes and the CG1 coordinate space
278 # ==================================================================
280 @property
281 def coordinate_space(self):
282 """Vector CG1 space whose dofs coincide with the geometry nodes.
284 The space mesh-coordinate derivatives (dR/dx_mesh) are taken in.
285 Built on first use.
286 """
287 if self._coordinate_space is None:
288 element = basix.ufl.element(
289 "Lagrange", self.mesh.basix_cell(), 1,
290 shape=(self.mesh.geometry.dim,), dtype=dolfinx.default_real_type)
291 self._coordinate_space = dolfinx.fem.functionspace(self.mesh, element)
292 return self._coordinate_space
294 @property
295 def dof_to_node_map(self):
296 """(owned CG1 dofs) x (owned geometry nodes) 0/1 permutation matrix.
298 The CG1 vector dofs correspond 1:1 with the geometry nodes but are
299 numbered differently: ``dof_to_node_map @ node_values`` reorders an
300 (n_owned_nodes, gdim) nodal array into dof order, and its transpose
301 maps dof-ordered derivatives back to geometry-node order. Matched by
302 coordinates within ``node_match_tol`` -- an exact-match test with room
303 for round-off. Built on first use.
304 """
305 if self._dof_to_node_map is None:
306 n = self.n_owned_nodes
307 dof_tree = KDTree(self.coordinate_space.tabulate_dof_coordinates()[:n, :])
308 node_tree = KDTree(self.mesh.geometry.x[:n, :])
309 perm = dof_tree.sparse_distance_matrix(
310 node_tree, max_distance=self.node_match_tol).tocsr()
311 perm.data[:] = 1.
312 self._dof_to_node_map = perm
313 return self._dof_to_node_map
315 def nodal_function(self, node_values):
316 """An (n_owned_nodes, gdim) nodal array as a Function on coordinate_space.
318 The Function is reused between calls.
319 """
320 V = self.coordinate_space
321 if self._nodal_function is None:
322 self._nodal_function = dolfinx.fem.Function(V)
323 n_local_dofs = V.dofmap.index_map.size_local * V.dofmap.index_map_bs
324 self._nodal_function.x.array[:n_local_dofs] = \
325 (self.dof_to_node_map @ node_values).reshape(-1)
326 self._nodal_function.x.scatter_forward()
327 return self._nodal_function
329 # ==================================================================
330 # Deformation
331 # ==================================================================
333 def add_geometry_listener(self, callback):
334 """Run `callback()` (collective) after every reset_nodes and every
335 apply_node_motions that moves the nodes (not one skipped for an
336 unchanged design), e.g. to refresh fields computed from the node
337 coordinates (cell centres, wall distance)."""
338 self._geometry_listeners.append(callback)
340 def _notify_geometry_listeners(self):
341 for callback in self._geometry_listeners:
342 callback()
344 @property
345 def deformation_design(self):
346 """Copy of the design vector the mesh is currently deformed for, or None."""
347 return None if self._deformation_design is None else self._deformation_design.copy()
349 def reset_nodes(self, notify=True):
350 """Put every local node, ghosts included, back at its baseline position.
352 The callbacks no longer call this: the mesh stays deformed until
353 apply_node_motions is given a different design.
354 """
355 gdim = self.mesh.geometry.dim
356 self.mesh.geometry.x[:, :gdim] = self.baseline_nodes[:, :gdim]
357 self._deformation_design = None
358 if notify:
359 self._notify_geometry_listeners()
361 def _is_deformed_for(self, design):
362 """True on every rank if the mesh already holds the deformation of ``design``.
364 ``design`` is replicated across ranks, but the comparison is reduced
365 anyway: apply_node_motions is collective, so the ranks must agree on
366 whether to skip it.
367 """
368 stored = self._deformation_design
369 same = (stored is not None and stored.shape == design.shape
370 and bool(np.array_equal(stored, design)))
371 return bool(self.mesh.comm.allreduce(same, op=MPI.MIN))
373 def apply_node_motions(self, node_motions, design=None):
374 """Move the mesh to baseline + ``node_motions`` and re-check its cells.
376 ``node_motions`` is (n_owned_nodes, gdim) in geometry-node order.
377 Starting from the baseline makes the result depend on this call only,
378 so repeated calls do not accumulate. Ghost nodes are updated from
379 their owners. Sets ``is_valid`` (no inverted or zero-volume cell) and
380 returns it. Collective.
382 ``design`` is the (replicated) design-parameter vector that
383 ``node_motions`` was computed from. The mesh stays deformed after this
384 call and remembers ``design``; a later call with an equal ``design``
385 returns the current ``is_valid`` without touching the mesh, so the
386 deformation is built once per design. A different ``design`` resets
387 the nodes and warps again. ``design=None`` always warps.
388 """
389 if design is not None:
390 design = np.array(design, dtype=np.float64, copy=True).ravel()
391 if self._is_deformed_for(design):
392 PETSc.Sys.Print("Mesh already deformed for this design: reusing it")
393 return self.is_valid
395 # unknown until the new deformation is complete, so a failure part-way
396 # never leaves a key that matches a half-moved mesh
397 self._deformation_design = None
398 self.reset_nodes(notify=False)
399 n, gdim = self.n_owned_nodes, self.mesh.geometry.dim
400 x = self.mesh.geometry.x
401 x[:n, :gdim] += node_motions[:, :gdim]
403 # geometry.x has no scatter_forward of its own: route the owned
404 # coordinates through a dolfinx.la.Vector on the geometry index map.
405 geom_vec = dolfinx.la.vector(self.mesh.geometry.index_map(), bs=3)
406 geom_vec.array[:n * 3] = x[:n, :].ravel()
407 geom_vec.scatter_forward()
408 x[:] = geom_vec.array.reshape(-1, 3)
410 self._update_quality()
411 self._notify_geometry_listeners()
412 self._deformation_design = design
413 return self.is_valid
415 # ==================================================================
416 # Mesh quality and output files
417 # ==================================================================
419 def _setup_quality(self):
420 """DG0 fields for the quality metrics apply_node_motions reports."""
421 if self._quality is not None:
422 return self._quality
423 msh = self.mesh
424 V0 = dolfinx.fem.functionspace(msh, ("DG", 0))
425 pts = V0.element.interpolation_points
426 q = {"V": V0,
427 "vol_form": dolfinx.fem.form(ufl.TestFunction(V0) * ufl.dx),
428 "vol": dolfinx.fem.Function(V0)}
429 # Per-cell shape metric, as distinct from the whole-mesh max/min edge
430 # ratio also reported: it is the per-cell value that says how badly an
431 # isotropic length scale misrepresents a cell.
432 for name, expr in (("jac", ufl.JacobianDeterminant(msh)),
433 ("min_edge", ufl.MinCellEdgeLength(msh)),
434 ("max_edge", ufl.MaxCellEdgeLength(msh)),
435 ("cell_aspect_ratio",
436 ufl.MaxCellEdgeLength(msh) / ufl.MinCellEdgeLength(msh))):
437 q[name + "_expr"] = dolfinx.fem.Expression(expr, pts)
438 q[name] = dolfinx.fem.Function(V0)
439 self._quality = q
440 return q
442 def _update_quality(self):
443 """Evaluate, print and (if configured) write the quality metrics; set is_valid."""
444 q = self._setup_quality()
445 comm = self.mesh.comm
446 with q["vol"].x.petsc_vec.localForm() as loc:
447 loc.set(0.0)
448 dolfinx.fem.petsc.assemble_vector(q["vol"].x.petsc_vec, q["vol_form"])
449 q["vol"].x.scatter_forward()
450 for name in ("jac", "min_edge", "max_edge", "cell_aspect_ratio"):
451 q[name].interpolate(q[name + "_expr"])
453 if self._quality_writers is not None:
454 for name, writer in self._quality_writers.items():
455 writer.interpolate_and_write(q[name])
457 def global_stat(name, fn, op, empty):
458 a = q[name].x.array
459 return comm.allreduce(fn(a) if a.size > 0 else empty, op=op)
461 min_vol = global_stat("vol", np.min, MPI.MIN, np.inf)
462 max_vol = global_stat("vol", np.max, MPI.MAX, -np.inf)
463 min_jac = global_stat("jac", np.min, MPI.MIN, np.inf)
464 max_jac = global_stat("jac", np.max, MPI.MAX, -np.inf)
465 min_edge = global_stat("min_edge", np.min, MPI.MIN, np.inf)
466 max_edge = global_stat("max_edge", np.max, MPI.MAX, -np.inf)
467 max_cell_ar = global_stat("cell_aspect_ratio", np.max, MPI.MAX, -np.inf)
468 cell_ar_sum = global_stat("cell_aspect_ratio", np.sum, MPI.SUM, 0.0)
469 n_cells = comm.allreduce(q["cell_aspect_ratio"].x.array.size, op=MPI.SUM)
470 mean_cell_ar = cell_ar_sum / n_cells if n_cells > 0 else np.inf
471 aspect_ratio = max_edge / min_edge if min_edge > 0 else np.inf
473 PETSc.Sys.Print("--- Mesh Quality Metrics ---")
474 PETSc.Sys.Print("Min cell volume: {:.6e}".format(min_vol))
475 PETSc.Sys.Print("Max cell volume: {:.6e}".format(max_vol))
476 PETSc.Sys.Print("Min Jacobian determinant: {:.6e}".format(min_jac))
477 PETSc.Sys.Print("Max Jacobian determinant: {:.6e}".format(max_jac))
478 PETSc.Sys.Print("Min edge length: {:.6e}".format(min_edge))
479 PETSc.Sys.Print("Max edge length: {:.6e}".format(max_edge))
480 PETSc.Sys.Print("Max per-cell aspect ratio: {:.6e}".format(max_cell_ar))
481 PETSc.Sys.Print("Mean per-cell aspect ratio: {:.6e}".format(mean_cell_ar))
482 PETSc.Sys.Print("Max Global Aspect Ratio proxy (max_edge/min_edge over whole mesh): {:.6e}".format(aspect_ratio))
483 self.is_valid = bool(min_jac > 0)
484 if not self.is_valid:
485 PETSc.Sys.Print("WARNING: Mesh has inverted or zero-volume cells!")
486 PETSc.Sys.Print("----------------------------")
488 def configure_output(self, suffix, deformation=True, quality=False):
489 """Enable the per-evaluation mesh output files, named with ``suffix``.
491 ``deformation`` writes mesh_deformation_<suffix>.bp (through
492 write_deformation_output) on the baseline mesh, so ParaView's Warp By
493 Vector with it reproduces the deformed mesh; ``quality`` writes the DG0
494 quality fields mesh_{vol,jac,min_edge,max_edge,cell_aspect_ratio}_<suffix>.bp
495 on the deformed mesh on every apply_node_motions. ``suffix`` also names
496 the memory logs of the classes that use this mesh. Collective.
497 """
498 comm = self.mesh.comm
499 self.output_suffix = suffix
500 self._deformation_writer = (
501 FileWriter("mesh_deformation_{}".format(suffix), comm, self.coordinate_space,
502 mesh_deformation=False)
503 if deformation else None)
504 self._quality_writers = None
505 if quality:
506 V0 = self._setup_quality()["V"]
507 self._quality_writers = {
508 name: FileWriter("mesh_{}_{}".format(name, suffix), comm, V0, mesh_deformation=True)
509 for name in ("vol", "jac", "min_edge", "max_edge", "cell_aspect_ratio")}
511 def write_deformation_output(self, node_motions, write_counter):
512 """Write (n_owned_nodes, gdim) node motions to mesh_deformation_<suffix>.bp.
514 A no-op unless configure_output enabled it. Called once per design
515 evaluation with write_counter = eval_idx, so this file and the solution
516 file share one index axis.
517 """
518 if self._deformation_writer is None:
519 return
520 self._deformation_writer.interpolate_and_write(
521 self.nodal_function(node_motions), write_counter=write_counter)
523 # ==================================================================
524 # Tagged boundary node sets and facets
525 # ==================================================================
527 def boundary_node_set(self, tags, exclude=None):
528 """Owned nodes on facets tagged with any of ``tags``, numbered globally.
530 ``exclude`` (another BoundaryNodeSet) removes its nodes -- e.g. the
531 wall from the farfield anchors, so a node shared by both stays with the
532 wall. See BoundaryNodeSet for the numbering. Cached per (tags,
533 exclude), so every caller gets the same numbering. Collective on first
534 call; requires tag_boundary_facets.
535 """
536 if self.meshtags is None:
537 raise RuntimeError("Mesh tags not yet constructed. "
538 "Call tag_boundary_facets first.")
539 tags = tuple(int(t) for t in tags)
540 key = (tags, None if exclude is None else exclude.tags)
541 if key in self._node_sets:
542 return self._node_sets[key]
544 msh = self.mesh
545 comm = msh.comm
546 gdim = msh.geometry.dim
547 self._check_vertex_geometry_numbering()
549 parts = [find_local_surface_nodes(msh, self.meshtags, tag) for tag in tags]
550 local_idx = (np.unique(np.concatenate(parts)) if parts
551 else np.zeros((0,), dtype=np.int64))
552 if exclude is not None:
553 local_idx = np.setdiff1d(local_idx, exclude.local_idx)
554 local_idx = local_idx.astype(np.int64)
556 offset, n_global = assign_global_dof_offset(comm, local_idx.size)
557 coords = allgather_numpy(
558 comm, self.baseline_nodes[:self.n_owned_nodes, :gdim][local_idx].ravel(),
559 n_global * gdim).reshape(n_global, gdim)
560 # comm.allgather rather than allgather_numpy: these are int64 global
561 # ids and allgather_numpy is float64-only.
562 gids = msh.geometry.index_map().local_to_global(
563 local_idx.astype(np.int32)).astype(np.int64)
564 geom_gids = np.concatenate(comm.allgather(gids) + [np.zeros(0, dtype=np.int64)])
565 if np.unique(geom_gids).size != geom_gids.size:
566 raise RuntimeError(
567 "Mesh.boundary_node_set{}: a geometry node appears twice; each "
568 "node should be owned by exactly one rank.".format(tags))
570 node_set = BoundaryNodeSet(tags=tags, local_idx=local_idx, offset=int(offset),
571 n_global=int(n_global), coords=coords,
572 geom_gids=geom_gids)
573 self._node_sets[key] = node_set
574 return node_set
576 def _check_vertex_geometry_numbering(self):
577 """Owned vertex indices must equal owned geometry-node indices.
579 find_local_surface_nodes works in topology vertex indices, the facet
580 connectivity in geometry node indices. For first-order meshes the two
581 coincide over the OWNED range, and only that range is used, so the node
582 sets and the connectivity stay consistent.
584 They do NOT coincide over the ghost range -- every rank has a few
585 hundred ghosts numbered differently in the two index spaces. That is
586 fine: ghosts only enter through the facet connectivity, which is
587 converted to global geometry ids via geometry.index_map().local_to_global
588 before being used, and that map is valid for ghosts.
589 """
590 msh = self.mesh
591 n_owned_verts = msh.topology.index_map(0).size_local
592 msh.topology.create_connectivity(0, msh.topology.dim)
593 vertex_to_geom = dolfinx.mesh.entities_to_geometry(
594 msh, 0, np.arange(n_owned_verts, dtype=np.int32)).reshape(-1)
595 if not np.array_equal(vertex_to_geom, np.arange(n_owned_verts, dtype=np.int32)):
596 raise RuntimeError(
597 "Mesh: owned vertex indices and owned geometry node indices do "
598 "not coincide (they do for first-order dolfinx meshes), so "
599 "boundary node sets and facet connectivity would be indexed "
600 "inconsistently.")
602 def tagged_facets(self, tags, node_gids):
603 """This rank's OWNED facets tagged with any of ``tags``, as node-id rows.
605 A node id is the node's position in ``node_gids``, a replicated array
606 of global geometry ids (a BoundaryNodeSet's ``geom_gids``, or several
607 concatenated). Quad facets come back in cyclic vertex order. Only owned
608 facets are taken: a facet shared between two ranks appears in both
609 ranks' meshtags, and counting it twice would double it.
611 Returns ``(conn, facets)``: (n_facets, nodes_per_facet) int64 node ids
612 and the local facet indices.
613 """
614 msh = self.mesh
615 fdim = msh.topology.dim - 1
616 msh.topology.create_entities(fdim)
617 msh.topology.create_connectivity(fdim, msh.topology.dim)
618 msh.topology.create_connectivity(fdim, 0)
619 empty = (np.zeros((0, 0), dtype=np.int64), np.zeros((0,), dtype=np.int32))
621 parts = [self.meshtags.find(int(tag)) for tag in tags]
622 if not parts:
623 return empty
624 facets = np.unique(np.concatenate(parts)).astype(np.int32)
625 facets = facets[facets < msh.topology.index_map(fdim).size_local]
626 if facets.size == 0:
627 return empty
629 facet_geom = dolfinx.mesh.entities_to_geometry(msh, fdim, facets, False)
630 gids = msh.geometry.index_map().local_to_global(
631 facet_geom.reshape(-1).astype(np.int32))
633 node_gids = np.asarray(node_gids, dtype=np.int64)
634 order = np.argsort(node_gids)
635 sorted_gids = node_gids[order]
636 if np.unique(sorted_gids).size != sorted_gids.size:
637 raise RuntimeError("Mesh.tagged_facets: node_gids contains duplicates.")
638 pos = np.clip(np.searchsorted(sorted_gids, gids), 0, max(sorted_gids.size - 1, 0))
639 found = sorted_gids.size > 0 and np.all(sorted_gids[pos] == gids)
640 if not found:
641 raise RuntimeError(
642 "Mesh.tagged_facets: {} facet node(s) are not in node_gids. This "
643 "happens when a rank owns a tagged facet whose node is owned by a "
644 "rank that saw no facet with that tag.".format(
645 int((sorted_gids[pos] != gids).sum()) if sorted_gids.size else gids.size))
646 conn = order[pos].reshape(facet_geom.shape).astype(np.int64)
647 if fdim == 2 and conn.shape[1] == 4:
648 conn = conn[:, self._QUAD_CYCLIC_PERM]
649 return conn, facets
651 def facet_cell_midpoints(self, facets):
652 """(n_facets, 3) baseline midpoint of the single cell attached to each exterior facet."""
653 msh = self.mesh
654 tdim = msh.topology.dim
655 f_to_c = msh.topology.connectivity(tdim - 1, tdim)
656 c_to_g = msh.geometry.dofmap
657 x = self.baseline_nodes
658 mids = np.empty((facets.size, 3), dtype=np.float64)
659 for k, f in enumerate(facets):
660 mids[k] = x[c_to_g[f_to_c.links(int(f))[0]]].mean(axis=0)
661 return mids
663 @staticmethod
664 def newell_normals(face_coords):
665 """Newell's method: (n_faces, k, 3) -> (n_faces, 3), twice the area vector."""
666 rolled = np.roll(face_coords, -1, axis=1)
667 return np.cross(face_coords, rolled).sum(axis=1)
669 @staticmethod
670 def orient_facets(conn, coords, cell_mids):
671 """Reverse the traversal of faces whose Newell normal points into the fluid.
673 After this every face normal points out of its adjacent fluid cell.
674 DOLFINx orders facet vertices from the sorted global vertex numbering,
675 which fixes neither the traversal order nor a consistent winding.
676 Measured on the 3D wing mesh: without this, only ~51% of facets come
677 out consistently oriented. Returns ``(conn, n_flipped)``.
678 """
679 face_coords = coords[conn]
680 normals = Mesh.newell_normals(face_coords)
681 outward = np.einsum("ij,ij->i", normals, face_coords.mean(axis=1) - cell_mids)
682 flip = outward < 0.0
683 conn = conn.copy()
684 conn[flip] = conn[flip][:, ::-1]
685 return conn, int(flip.sum())
687 def wall_facets(self, wall_tag):
688 """Replicated wall facets as rows of wall node ids in ``[0, n_global)``.
690 Node ids are the ``boundary_node_set((wall_tag,))`` numbering. Segments
691 in 2D, triangles or quads (cyclic vertex order) in 3D, in rank order, so
692 every rank holds the same array. Consistently wound: the facet normal --
693 Newell's in 3D, ``(dy, -dx)`` of the traversal in 2D -- points out of
694 the adjacent fluid cell, i.e. into the body. Collective.
695 """
696 comm = self.mesh.comm
697 gdim = self.mesh.geometry.dim
698 wall = self.boundary_node_set((wall_tag,))
699 local_faces, local_facets = self.tagged_facets((wall_tag,), wall.geom_gids)
700 if local_faces.size:
701 coords = np.zeros((wall.n_global, 3), dtype=np.float64)
702 coords[:, :gdim] = wall.coords
703 cell_mids = self.facet_cell_midpoints(local_facets)
704 if gdim == 2:
705 a, b = local_faces[:, 0], local_faces[:, 1]
706 tangent = coords[b] - coords[a]
707 normal = np.column_stack([tangent[:, 1], -tangent[:, 0]])
708 to_face = 0.5 * (coords[a] + coords[b]) - cell_mids
709 flip = np.einsum("ij,ij->i", normal, to_face[:, :2]) < 0.0
710 conn = local_faces.copy()
711 conn[flip] = conn[flip][:, ::-1]
712 else:
713 conn, _ = self.orient_facets(local_faces, coords, cell_mids)
714 else:
715 conn = np.zeros((0, 0), dtype=np.int64)
716 gathered = [g for g in comm.allgather(np.ascontiguousarray(conn, dtype=np.int64))
717 if g.size]
718 if not gathered:
719 raise RuntimeError("Mesh.wall_facets: no wall facets found")
720 return np.concatenate(gathered, axis=0)