chilmesh 0.1.0__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.
chilmesh/CHILmesh.py ADDED
@@ -0,0 +1,933 @@
1
+ from pathlib import Path
2
+
3
+ from .utils.plot_utils import CHILmeshPlotMixin
4
+
5
+ import numpy as np
6
+ import matplotlib.pyplot as plt
7
+ import matplotlib.cm as cm
8
+ from scipy.spatial import Delaunay
9
+ from typing import List, Tuple, Optional as Opt, Dict, Set, Union, Any
10
+ from scipy.sparse import lil_matrix
11
+ from scipy.sparse.linalg import spsolve
12
+ from copy import deepcopy
13
+
14
+ __all__ = ['CHILmesh', 'write_fort14']
15
+
16
+ class CHILmesh(CHILmeshPlotMixin):
17
+ """
18
+ A class for triangular, quadrilateral, or mixed-element meshes in 2D.
19
+
20
+ This Python implementation is based on the MATLAB CHILmesh class from the
21
+ Computational Hydrodynamics & Informatics Laboratory (CHIL) at The Ohio State University,
22
+ focusing on the mesh layers approach described in Mattioli's thesis.
23
+ """
24
+
25
+ @property
26
+ def grid_name(self):
27
+ """Grid name property."""
28
+ return self._grid_name
29
+
30
+ @grid_name.setter
31
+ def grid_name(self, value):
32
+ """Set grid name."""
33
+ self._grid_name = value
34
+
35
+ @property
36
+ def Layers(self):
37
+ """
38
+ Backwards compatibility property to access layers with uppercase name.
39
+
40
+ Returns:
41
+ The layers dictionary
42
+ """
43
+ return self.layers
44
+
45
+ def change_points(self, new_points, acknowledge_change=False):
46
+ """
47
+ Change the mesh's (x,y,z) locations of its points.
48
+
49
+ Parameters:
50
+ new_points: New coordinates for the points
51
+ acknowledge_change: If True, acknowledges the change in the mesh
52
+ """
53
+ assert acknowledge_change, "acknowledge_change must be True to change points -- this will change the mesh, make sure you understand this before using this method within a broader algorithm."
54
+ if new_points.shape[1] == 2: self.points[:, :2] = new_points
55
+ elif new_points.shape[1] == 3: self.points = new_points
56
+ else: raise ValueError("new_points must have 2 or 3 columns")
57
+
58
+ def __init__( self, connectivity: Opt[np.ndarray] = None, points: Opt[np.ndarray] = None, grid_name: Opt[str] = None ) -> None:
59
+ """
60
+ Initialize a CHILmesh object.
61
+
62
+ Parameters:
63
+ connectivity: Element connectivity list
64
+ points: Vertex coordinates
65
+ grid_name: Name of the mesh
66
+ """
67
+ # Public properties
68
+ self.grid_name = grid_name
69
+ self.points = points
70
+ self.connectivity_list = connectivity
71
+ self.boundary_condition = None
72
+
73
+ # Hidden properties
74
+ self.adjacencies: Dict[str, Any] = {}
75
+ self.n_verts: int = 0
76
+ self.n_elems: int = 0
77
+ self.n_edges: int = 0
78
+ self.n_layers: int = 0
79
+ self.layers: Dict[str, List] = {"OE": [], "IE": [], "OV": [], "IV": [], "bEdgeIDs": []}
80
+ self.type: Opt[str] = None
81
+
82
+ # If no inputs are provided, create a random Delaunay triangulation
83
+ if connectivity is None and points is None:
84
+ self._create_random_triangulation()
85
+
86
+ # Initialize the mesh
87
+ self._initialize_mesh()
88
+
89
+ def _create_random_triangulation( self ) -> None:
90
+ """Create a random Delaunay triangulation for testing"""
91
+ # Generate random points in a 10x10 domain
92
+ points = np.random.rand( 20, 2 ) * 10
93
+ # Create Delaunay triangulation
94
+ tri = Delaunay( points )
95
+ # Store points and connectivity
96
+ self.points = np.column_stack( ( tri.points, np.zeros( tri.points.shape[0] ) ) )
97
+ self.connectivity_list = tri.simplices
98
+ self.grid_name = "Random Delaunay"
99
+
100
+ def _initialize_mesh( self ) -> None:
101
+ """Initialize the mesh properties"""
102
+ if self.points is not None and self.connectivity_list is not None:
103
+ self.n_verts = self.points.shape[0]
104
+ self.n_elems = self.connectivity_list.shape[0]
105
+
106
+ # Ensure points have z-coordinate
107
+ if self.points.shape[1] == 2:
108
+ self.points = np.column_stack( ( self.points, np.zeros( self.n_verts ) ) )
109
+
110
+ # Check connectivity orientation and correct if needed
111
+ self._ensure_ccw_orientation()
112
+
113
+ # Build adjacency lists
114
+ self._build_adjacencies()
115
+
116
+ # Identify mesh layers
117
+ self._mesh_layers()
118
+
119
+ def _ensure_ccw_orientation( self ) -> None:
120
+ """Ensure counter-clockwise orientation of elements"""
121
+ # Calculate signed area of each element
122
+ areas = self.signed_area()
123
+
124
+ # Find elements with clockwise orientation (negative area)
125
+ cw_elements = np.where( areas < 0 )[0]
126
+
127
+ # Flip orientation of clockwise elements
128
+ for elem_id in cw_elements:
129
+ # For triangular elements (3 vertices)
130
+ if self.connectivity_list.shape[1] == 3:
131
+ self.connectivity_list[elem_id] = self.connectivity_list[elem_id, [0, 2, 1]]
132
+ # For quadrilateral elements (4 vertices)
133
+ elif self.connectivity_list.shape[1] == 4:
134
+ self.connectivity_list[elem_id] = self.connectivity_list[elem_id, [0, 3, 2, 1]]
135
+
136
+ def signed_area( self, elem_ids: Opt[Union[int, List[int], np.ndarray]] = None ) -> np.ndarray:
137
+ """
138
+ Calculate the signed area of elements.
139
+
140
+ Parameters:
141
+ elem_ids: Indices of elements to evaluate.
142
+ If None, all elements are evaluated.
143
+
144
+ Returns:
145
+ Signed areas of elements
146
+ """
147
+ if elem_ids is None:
148
+ elem_ids = np.arange( self.n_elems )
149
+
150
+ if np.isscalar( elem_ids ):
151
+ elem_ids = [elem_ids]
152
+
153
+ areas = np.zeros( len( elem_ids ) )
154
+
155
+ # Determine element types
156
+ tri_elems, quad_elems = self._elem_type( elem_ids )
157
+
158
+ # Calculate areas for triangular elements
159
+ for i, elem_id in enumerate( elem_ids ):
160
+ if elem_id in tri_elems:
161
+ vertices = self.connectivity_list[elem_id][:3] # First 3 vertices for triangles
162
+ x = self.points[vertices, 0]
163
+ y = self.points[vertices, 1]
164
+ # Shoelace formula for triangle
165
+ areas[i] = 0.5 * ((x[0]*(y[1]-y[2]) + x[1]*(y[2]-y[0]) + x[2]*(y[0]-y[1])))
166
+ elif elem_id in quad_elems:
167
+ vertices = self.connectivity_list[elem_id]
168
+ x = self.points[vertices, 0]
169
+ y = self.points[vertices, 1]
170
+ # Shoelace formula for quadrilateral
171
+ areas[i] = 0.5 * ((x[0]*(y[1]-y[3]) + x[1]*(y[2]-y[0]) +
172
+ x[2]*(y[3]-y[1]) + x[3]*(y[0]-y[2])))
173
+
174
+ return areas
175
+
176
+ def _elem_type( self, elem_ids: Opt[Union[int, List[int], np.ndarray]] = None
177
+ ) -> Tuple[np.ndarray, np.ndarray]:
178
+ """
179
+ Identify triangular and quadrilateral elements.
180
+
181
+ Parameters:
182
+ elem_ids: Indices of elements to check.
183
+ If None, all elements are checked.
184
+
185
+ Returns:
186
+ Tuple of (tri_elems, quad_elems) arrays of element indices
187
+ """
188
+ if elem_ids is None:
189
+ elem_ids = np.arange( self.n_elems )
190
+
191
+ # Convert to numpy array if not already
192
+ elem_ids = np.array( elem_ids )
193
+
194
+ if self.connectivity_list.shape[1] == 3:
195
+ # All triangular elements
196
+ return elem_ids, np.array( [] )
197
+
198
+ # Check for triangular elements in a quad/mixed-element mesh
199
+ tri_mask = np.zeros( len( elem_ids ), dtype=bool )
200
+
201
+ for i, elem_id in enumerate( elem_ids ):
202
+ # Check for redundant vertices or zero-valued vertices
203
+ vertices = self.connectivity_list[elem_id]
204
+ if (vertices[0] == vertices[1] or
205
+ vertices[1] == vertices[2] or
206
+ vertices[2] == vertices[3] or
207
+ vertices[3] == vertices[0] or
208
+ vertices[3] == 0):
209
+ tri_mask[i] = True
210
+
211
+ tri_elems = elem_ids[tri_mask]
212
+ quad_elems = elem_ids[~tri_mask]
213
+
214
+ return tri_elems, quad_elems
215
+
216
+ def _build_adjacencies( self ) -> None:
217
+ """Build adjacency lists for the mesh"""
218
+ # Identify triangular and quadrilateral elements
219
+ tri_elems, quad_elems = self._elem_type()
220
+
221
+ # Set mesh type based on element types
222
+ if len( quad_elems ) == 0:
223
+ self.type = "Triangular"
224
+ elif len( tri_elems ) == 0:
225
+ self.type = "Quadrilateral"
226
+ else:
227
+ self.type = "Mixed-Element"
228
+ # For mixed-element mesh, triangles may need adjustment for consistency
229
+ if self.connectivity_list.shape[1] == 4: # Already have space for 4 vertices
230
+ for elem_id in tri_elems:
231
+ self.connectivity_list[elem_id, 3] = self.connectivity_list[elem_id, 0]
232
+
233
+ # Identify edges of the mesh
234
+ edges = self._identify_edges()
235
+ edge2vert = np.array( edges )
236
+ self.n_edges = len( edge2vert )
237
+
238
+ # Build Elem2Edge
239
+ elem2edge = self._build_elem2edge( edge2vert )
240
+
241
+ # Build Vert2Edge
242
+ vert2edge = self._build_vert2edge( edge2vert )
243
+
244
+ # Build Vert2Elem
245
+ vert2elem = self._build_vert2elem()
246
+
247
+ # Build Edge2Elem
248
+ edge2elem = self._build_edge2elem( edge2vert )
249
+
250
+ # Store adjacencies
251
+ self.adjacencies = {
252
+ "Elem2Vert": self.connectivity_list,
253
+ "Edge2Vert": edge2vert,
254
+ "Elem2Edge": elem2edge,
255
+ "Vert2Edge": vert2edge,
256
+ "Vert2Elem": vert2elem,
257
+ "Edge2Elem": edge2elem
258
+ }
259
+
260
+ def _identify_edges( self ) -> List[Tuple[int, int]]:
261
+ """
262
+ Identify edges of the mesh.
263
+
264
+ Returns:
265
+ List of edges, where each edge is a tuple of two vertex indices
266
+ """
267
+ edges = set()
268
+
269
+ for elem_id in range( self.n_elems ):
270
+ vertices = self.connectivity_list[elem_id]
271
+ n_vertices = 3 if self.type == "Triangular" else 4
272
+
273
+ for i in range( n_vertices ):
274
+ v1 = vertices[i]
275
+ v2 = vertices[(i+1) % n_vertices]
276
+
277
+ # Skip invalid edges (negative vertex ids)
278
+ # In MATLAB the value 0 signified a placeholder for a missing
279
+ # vertex in mixed element meshes. In this Python port we use
280
+ # 0-based indexing, therefore vertex id ``0`` is valid and
281
+ # should not be discarded. Only negative ids are considered
282
+ # invalid.
283
+ if v1 < 0 or v2 < 0:
284
+ continue
285
+
286
+ # Store edge as a sorted tuple to avoid duplicates
287
+ edge = tuple( sorted( [int(v1), int(v2)] ) )
288
+ edges.add( edge )
289
+
290
+ return list( edges )
291
+
292
+ def _build_elem2edge( self, edge2vert: np.ndarray ) -> np.ndarray:
293
+ """
294
+ Build Elem2Edge adjacency.
295
+
296
+ Parameters:
297
+ edge2vert: Edge-to-vertex adjacency
298
+
299
+ Returns:
300
+ Element-to-edge adjacency
301
+ """
302
+ max_edges_per_elem = 4 if self.type != "Triangular" else 3
303
+ elem2edge = np.zeros( ( self.n_elems, max_edges_per_elem ), dtype=int )
304
+
305
+ # For each element
306
+ for elem_id in range( self.n_elems ):
307
+ vertices = self.connectivity_list[elem_id]
308
+ n_vertices = 3 if self.type == "Triangular" else 4
309
+
310
+ # For each edge of the element
311
+ for i in range( n_vertices ):
312
+ v1 = vertices[i]
313
+ v2 = vertices[(i+1) % n_vertices]
314
+
315
+ # Skip invalid edges (negative vertex ids)
316
+ if v1 < 0 or v2 < 0:
317
+ continue
318
+
319
+ # Find the edge index
320
+ edge = tuple( sorted( [int(v1), int(v2)] ) )
321
+ for j, e in enumerate( edge2vert ):
322
+ if set( e ) == set( edge ):
323
+ elem2edge[elem_id, i] = j
324
+ break
325
+
326
+ return elem2edge
327
+
328
+ def _build_vert2edge( self, edge2vert: np.ndarray ) -> List[List[int]]:
329
+ """
330
+ Build Vert2Edge adjacency.
331
+
332
+ Parameters:
333
+ edge2vert: Edge-to-vertex adjacency
334
+
335
+ Returns:
336
+ Vertex-to-edge adjacency as list of lists
337
+ """
338
+ # Initialize with empty lists for each vertex
339
+ vert2edge = [[] for _ in range( self.n_verts )]
340
+
341
+ # Populate the lists
342
+ for edge_id, (v1, v2) in enumerate( edge2vert ):
343
+ vert2edge[v1].append( edge_id )
344
+ vert2edge[v2].append( edge_id )
345
+
346
+ return vert2edge
347
+
348
+ def _build_vert2elem( self ) -> List[List[int]]:
349
+ """
350
+ Build Vert2Elem adjacency.
351
+
352
+ Returns:
353
+ Vertex-to-element adjacency as list of lists
354
+ """
355
+ # Initialize with empty lists for each vertex
356
+ vert2elem = [[] for _ in range( self.n_verts )]
357
+
358
+ # Populate the lists
359
+ for elem_id in range( self.n_elems ):
360
+ vertices = self.connectivity_list[elem_id]
361
+ for v in vertices:
362
+ # Skip invalid vertex ids (negative values represent
363
+ # placeholders in mixed-element meshes). Zero is a valid
364
+ # vertex index in this Python implementation.
365
+ if v >= 0:
366
+ vert2elem[v].append( elem_id )
367
+
368
+ return vert2elem
369
+
370
+ def _build_edge2elem( self, edge2vert: np.ndarray ) -> np.ndarray:
371
+ """
372
+ Build Edge2Elem adjacency.
373
+
374
+ Parameters:
375
+ edge2vert: Edge-to-vertex adjacency
376
+
377
+ Returns:
378
+ Edge-to-element adjacency
379
+ """
380
+ # Initialize with zeros - at most 2 elements per edge
381
+ edge2elem = np.zeros( ( self.n_edges, 2 ), dtype=int )
382
+
383
+ # Iterate through elements and their edges
384
+ for elem_id in range( self.n_elems ):
385
+ vertices = self.connectivity_list[elem_id]
386
+ n_vertices = 3 if self.type == "Triangular" else 4
387
+
388
+ # For each edge of the element
389
+ for i in range( n_vertices ):
390
+ v1 = vertices[i]
391
+ v2 = vertices[(i+1) % n_vertices]
392
+
393
+ # Skip invalid edges (negative vertex ids)
394
+ if v1 < 0 or v2 < 0:
395
+ continue
396
+
397
+ # Find the edge index
398
+ edge = tuple( sorted( [int(v1), int(v2)] ) )
399
+ for edge_id, e in enumerate( edge2vert ):
400
+ if set( e ) == set( edge ):
401
+ # Check if edge already has an element assigned
402
+ if edge2elem[edge_id, 0] == 0:
403
+ edge2elem[edge_id, 0] = elem_id + 1 # +1 to avoid 0
404
+ else:
405
+ edge2elem[edge_id, 1] = elem_id + 1
406
+ break
407
+
408
+ # Adjust indices (remove the +1 offset)
409
+ edge2elem[edge2elem > 0] -= 1
410
+
411
+ return edge2elem
412
+
413
+ def boundary_edges( self ) -> np.ndarray:
414
+ """
415
+ Identify boundary edges of the mesh.
416
+
417
+ Returns:
418
+ Indices of boundary edges
419
+ """
420
+ # Boundary edges have only one adjacent element
421
+ edge2elem = self.adjacencies["Edge2Elem"]
422
+ boundary_mask = (edge2elem[:, 1] == 0) # Second element is zero
423
+ return np.where( boundary_mask )[0]
424
+
425
+ def edge2vert( self, edge_ids: Opt[Union[int, List[int], np.ndarray]] = None ) -> np.ndarray:
426
+ """
427
+ Get vertices that define specified edges.
428
+
429
+ Parameters:
430
+ edge_ids: Indices of edges to query.
431
+ If None, all edges are queried.
432
+
433
+ Returns:
434
+ Array of vertex indices for each edge
435
+ """
436
+ if edge_ids is None:
437
+ edge_ids = np.arange( self.n_edges )
438
+
439
+ if np.isscalar( edge_ids ):
440
+ edge_ids = [edge_ids]
441
+
442
+ return self.adjacencies["Edge2Vert"][edge_ids]
443
+
444
+ def elem2edge( self, elem_ids: Opt[Union[int, List[int], np.ndarray]] = None ) -> np.ndarray:
445
+ """
446
+ Get edges that define specified elements.
447
+
448
+ Parameters:
449
+ elem_ids: Indices of elements to query.
450
+ If None, all elements are queried.
451
+
452
+ Returns:
453
+ Array of edge indices for each element
454
+ """
455
+ if elem_ids is None:
456
+ elem_ids = np.arange( self.n_elems )
457
+
458
+ if np.isscalar( elem_ids ):
459
+ elem_ids = [elem_ids]
460
+
461
+ return self.adjacencies["Elem2Edge"][elem_ids]
462
+
463
+ def edge2elem( self, edge_ids: Opt[Union[int, List[int], np.ndarray]] = None ) -> np.ndarray:
464
+ """
465
+ Get elements adjacent to specified edges.
466
+
467
+ Parameters:
468
+ edge_ids: Indices of edges to query.
469
+ If None, all edges are queried.
470
+
471
+ Returns:
472
+ Array of element indices for each edge
473
+ """
474
+ if edge_ids is None:
475
+ edge_ids = np.arange( self.n_edges )
476
+
477
+ if np.isscalar( edge_ids ):
478
+ edge_ids = [edge_ids]
479
+
480
+ return self.adjacencies["Edge2Elem"][edge_ids]
481
+
482
+ def _mesh_layers(self) -> None:
483
+ """
484
+ Discretize the mesh into layers starting from the boundary.
485
+ This implements the mesh layers approach described in Mattioli's thesis.
486
+ """
487
+ # Reset layers
488
+ self.layers = {"OE": [], "IE": [], "OV": [], "IV": [], "bEdgeIDs": []}
489
+
490
+ # Get boundary edges (edges with only one adjacent element)
491
+ edge2elem = self.adjacencies["Edge2Elem"]
492
+ boundary_mask = (edge2elem[:, 1] == 0)
493
+ boundary_edges = np.where(boundary_mask)[0]
494
+
495
+ # Keep track of which elements have been assigned to a layer
496
+ remaining_elements = set(range(self.n_elems))
497
+
498
+ # Get element-to-element connectivity using edge2elem
499
+ elem2elem = [[] for _ in range(self.n_elems)]
500
+ for edge_idx, (e1, e2) in enumerate(edge2elem):
501
+ if e1 >= 0 and e2 >= 0: # Both elements exist
502
+ elem2elem[e1].append(e2)
503
+ elem2elem[e2].append(e1)
504
+
505
+ # Process layers from the boundary inward
506
+ layer_idx = 0
507
+ while remaining_elements and len(boundary_edges) > 0:
508
+ # Get boundary vertices
509
+ edge2vert = self.adjacencies["Edge2Vert"]
510
+ outer_vertices = np.array(list(set(edge2vert[boundary_edges].flatten())))
511
+ self.layers["OV"].append(outer_vertices)
512
+ self.layers["bEdgeIDs"].append(boundary_edges)
513
+
514
+ # Get outer elements (elements adjacent to boundary edges)
515
+ outer_elems = []
516
+ for edge_idx in boundary_edges:
517
+ elems = edge2elem[edge_idx]
518
+ for elem in elems:
519
+ if elem >= 0 and elem in remaining_elements:
520
+ outer_elems.append(elem)
521
+
522
+ # Convert to numpy array of integers
523
+ outer_elems = np.array(list(set(outer_elems)), dtype=int)
524
+
525
+ # Skip if no outer elements found
526
+ if len(outer_elems) == 0:
527
+ break
528
+
529
+ self.layers["OE"].append(outer_elems)
530
+ for elem in outer_elems:
531
+ remaining_elements.remove(elem)
532
+
533
+ # Get inner elements (neighbors of outer elements that haven't been assigned yet)
534
+ inner_elems = []
535
+ for elem in outer_elems:
536
+ for neighbor in elem2elem[elem]:
537
+ if neighbor in remaining_elements:
538
+ inner_elems.append(neighbor)
539
+
540
+ # Convert to numpy array of integers and remove duplicates
541
+ inner_elems = np.array(list(set(inner_elems)), dtype=int)
542
+
543
+ # Store inner elements
544
+ self.layers["IE"].append(inner_elems)
545
+ for elem in inner_elems:
546
+ if elem in remaining_elements:
547
+ remaining_elements.remove(elem)
548
+
549
+ # Get inner vertices
550
+ all_vertices = set()
551
+ for elem in np.concatenate((outer_elems, inner_elems)):
552
+ vertices = self.connectivity_list[elem]
553
+ for v in vertices:
554
+ # Ignore negative placeholders (if any). Vertex index
555
+ # 0 is valid in this implementation.
556
+ if v >= 0:
557
+ all_vertices.add(v)
558
+
559
+ inner_vertices = np.array(list(all_vertices - set(outer_vertices)), dtype=int)
560
+ self.layers["IV"].append(inner_vertices)
561
+
562
+ # Get new boundary by finding edges that have one element in the remaining set
563
+ # and one element in the processed set
564
+ boundary_edges = []
565
+ for edge_idx, (e1, e2) in enumerate(edge2elem):
566
+ # Skip boundary edges of the original mesh
567
+ if e2 < 0:
568
+ continue
569
+
570
+ # An edge is a boundary if exactly one of its adjacent elements
571
+ # is in the remaining set
572
+ if ((e1 in remaining_elements) != (e2 in remaining_elements)):
573
+ boundary_edges.append(edge_idx)
574
+
575
+ boundary_edges = np.array(boundary_edges, dtype=int)
576
+
577
+ # Move to next layer
578
+ layer_idx += 1
579
+
580
+ # Set number of layers
581
+ self.n_layers = layer_idx
582
+
583
+ # # Print summary
584
+ # print(f"Created {self.n_layers} mesh layers")
585
+ # for i in range(self.n_layers):
586
+ # print(f" Layer {i}: {len(self.layers['OE'][i])} outer elements, {len(self.layers['IE'][i])} inner elements")
587
+
588
+ def get_layer( self, layer_idx: int ) -> Dict[str, np.ndarray]:
589
+ """
590
+ Get the components of a specific mesh layer.
591
+
592
+ Parameters:
593
+ layer_idx: Index of the layer to retrieve
594
+
595
+ Returns:
596
+ Dictionary with outer elements (OE), inner elements (IE),
597
+ outer vertices (OV), and inner vertices (IV) of the layer
598
+ """
599
+ if layer_idx < 0 or layer_idx >= self.n_layers:
600
+ raise ValueError( f"Layer index {layer_idx} out of range [0, {self.n_layers-1}]" )
601
+
602
+ return {
603
+ "OE": self.layers["OE"][layer_idx],
604
+ "IE": self.layers["IE"][layer_idx],
605
+ "OV": self.layers["OV"][layer_idx],
606
+ "IV": self.layers["IV"][layer_idx],
607
+ "bEdgeIDs": self.layers["bEdgeIDs"][layer_idx]
608
+ }
609
+
610
+
611
+ @staticmethod
612
+ def read_from_fort14(full_file_name: Path) -> "CHILmesh":
613
+ """
614
+ Load a mesh from a FORT.14 file.
615
+
616
+ Parameters:
617
+ full_file_name: Path object pointing to the FORT.14 file
618
+
619
+ Returns:
620
+ A CHILmesh object
621
+ """
622
+ with open(full_file_name, 'r') as f:
623
+ # Read header
624
+ header = f.readline().strip()
625
+
626
+ # Read element and node counts
627
+ counts = f.readline().strip().split()
628
+ n_elements = int(counts[0])
629
+ n_nodes = int(counts[1])
630
+
631
+ # Read nodes
632
+ points = np.zeros((n_nodes, 3)) # x, y, z
633
+ for i in range(n_nodes):
634
+ line = f.readline().strip().split()
635
+ points[i] = [float(line[1]), float(line[2]), float(line[3])]
636
+
637
+ # Read elements
638
+ elements = np.zeros((n_elements, 3), dtype=int)
639
+ for i in range(n_elements):
640
+ line = f.readline().strip().split()
641
+ num_nodes = int(line[1])
642
+ if num_nodes != 3:
643
+ raise ValueError(f"Only triangular elements supported, found element with {num_nodes} nodes.")
644
+ node_indices = [int(line[j+2]) - 1 for j in range(num_nodes)]
645
+ elements[i] = node_indices
646
+ return CHILmesh( connectivity=elements, points=points, grid_name=header )
647
+
648
+
649
+ def write_to_fort14( self, filename: str, grid_name: Opt[str] = "CHILmesh Grid") -> bool:
650
+ """
651
+ Export the current mesh to ADCIRC .fort.14 format.
652
+
653
+ Parameters:
654
+ filename: Path to save the file
655
+ grid_name: Optional title for the mesh
656
+ """
657
+ return CHILmesh.write_to_fort14(filename, self.points, self.connectivity_list, grid_name)
658
+
659
+ def interior_angles(self, elem_ids=None) -> np.ndarray:
660
+ """
661
+ Calculate interior angles of mesh elements.
662
+
663
+ Parameters:
664
+ elem_ids: Indices of elements to evaluate.
665
+ If None, all elements are evaluated.
666
+
667
+ Returns:
668
+ Array of interior angles for each element
669
+ """
670
+ if elem_ids is None:
671
+ elem_ids = np.arange(self.n_elems)
672
+
673
+ if np.isscalar(elem_ids):
674
+ elem_ids = [elem_ids]
675
+
676
+ # Determine element types
677
+ tri_elems, quad_elems = self._elem_type(elem_ids)
678
+
679
+ # Maximum number of angles per element
680
+ max_angles = 4 if len(quad_elems) > 0 else 3
681
+
682
+ # Initialize angles array
683
+ angles = np.zeros((len(elem_ids), max_angles))
684
+
685
+ # Calculate angles for each element
686
+ for i, elem_id in enumerate(elem_ids):
687
+ if elem_id in tri_elems:
688
+ # Triangle angles
689
+ vertices = self.connectivity_list[elem_id][:3] # First 3 vertices for triangles
690
+ coords = self.points[vertices, :2] # Get x,y coordinates
691
+
692
+ # Calculate angles at each vertex
693
+ for j in range(3):
694
+ v1 = coords[(j+1)%3] - coords[j]
695
+ v2 = coords[(j-1)%3] - coords[j]
696
+
697
+ # Normalize vectors safely to avoid runtime warnings of NaN
698
+ # v1_norm = v1 / np.linalg.norm(v1)
699
+ # v2_norm = v2 / np.linalg.norm(v2)
700
+ v1_norm = v1 / (np.linalg.norm(v1) + 1e-12)
701
+ v2_norm = v2 / (np.linalg.norm(v2) + 1e-12)
702
+
703
+ # Calculate angle in degrees
704
+ dot_product = np.clip(np.dot(v1_norm, v2_norm), -1.0, 1.0)
705
+ angle = np.arccos(dot_product) * 180 / np.pi
706
+ angles[i, j] = angle
707
+
708
+ elif elem_id in quad_elems:
709
+ # Quadrilateral angles
710
+ vertices = self.connectivity_list[elem_id] # All 4 vertices
711
+ coords = self.points[vertices, :2] # Get x,y coordinates
712
+
713
+ # Calculate angles at each vertex
714
+ for j in range(4):
715
+ v1 = coords[(j+1)%4] - coords[j]
716
+ v2 = coords[(j-1)%4] - coords[j]
717
+
718
+ # Normalize vectors
719
+ v1_norm = v1 / np.linalg.norm(v1)
720
+ v2_norm = v2 / np.linalg.norm(v2)
721
+
722
+ # Calculate angle in degrees
723
+ dot_product = np.clip(np.dot(v1_norm, v2_norm), -1.0, 1.0)
724
+ angle = np.arccos(dot_product) * 180 / np.pi
725
+ angles[i, j] = angle
726
+ return angles
727
+
728
+ def elem_quality(self, elem_ids=None, quality_type='skew') -> Tuple[np.ndarray, np.ndarray, dict]:
729
+ """
730
+ Calculate the quality of mesh elements.
731
+
732
+ Parameters:
733
+ elem_ids: Indices of elements to evaluate.
734
+ If None, all elements are evaluated.
735
+ quality_type: Type of quality metric to use.
736
+ 'skew', 'skewness', 'angular skewness': Measures deviation from ideal angles
737
+
738
+ Returns:
739
+ Tuple of (Quality, Angles) where:
740
+ - Quality: Array of quality measurements for each element
741
+ - Angles: Array of interior angles for each element
742
+ """
743
+ if elem_ids is None:
744
+ elem_ids = np.arange(self.n_elems)
745
+
746
+ if np.isscalar(elem_ids):
747
+ elem_ids = [elem_ids]
748
+
749
+ # Determine element types
750
+ tri_elems, quad_elems = self._elem_type(elem_ids)
751
+
752
+ # Calculate interior angles
753
+ angles = self.interior_angles(elem_ids)
754
+
755
+ # Initialize quality array
756
+ quality = np.zeros(len(elem_ids))
757
+
758
+ # Compute quality based on the selected metric
759
+ if quality_type in ['skew', 'skewness', 'angular skewness']:
760
+ # Process triangular elements
761
+ tri_mask = np.array([elem_id in tri_elems for elem_id in elem_ids])
762
+ if np.any(tri_mask):
763
+ # Get angles for triangular elements
764
+ tri_angles = angles[tri_mask, :3]
765
+
766
+ # Calculate max and min angles
767
+ tri_max = np.max(tri_angles, axis=1)
768
+ tri_min = np.min(tri_angles, axis=1)
769
+
770
+ # Equiangular skew for triangles (ideal angle = 60°)
771
+ quality[tri_mask] = 1 - np.maximum(
772
+ (tri_max - 60) / (180 - 60),
773
+ (60 - tri_min) / 60
774
+ )
775
+
776
+ # Process quadrilateral elements
777
+ quad_mask = np.array([elem_id in quad_elems for elem_id in elem_ids])
778
+ if np.any(quad_mask):
779
+ # Get angles for quadrilateral elements
780
+ quad_angles = angles[quad_mask, :]
781
+
782
+ # Calculate max and min angles
783
+ quad_max = np.max(quad_angles, axis=1)
784
+ quad_min = np.min(quad_angles, axis=1)
785
+
786
+ # Equiangular skew for quads (ideal angle = 90°)
787
+ quality[quad_mask] = 1 - np.maximum(
788
+ (quad_max - 90) / (180 - 90),
789
+ (90 - quad_min) / 90
790
+ )
791
+
792
+ # Handle poor angle calculations (concave elements, etc.)
793
+ # For triangles, sum of angles should be close to 180°
794
+ tri_sum_mask = tri_mask & (np.sum(angles[:, :3], axis=1) <= 179.99)
795
+ quality[tri_sum_mask] = 0
796
+
797
+ # For quads, sum of angles should be close to 360°
798
+ quad_sum_mask = quad_mask & (np.sum(angles, axis=1) <= 359.99)
799
+ quality[quad_sum_mask] = 0
800
+
801
+ else:
802
+ raise ValueError(f"Unknown quality type: {quality_type}")
803
+
804
+ # Calculate statistics for the computed quality
805
+ stats = {
806
+ 'mean': float(np.mean(quality)),
807
+ 'median': float(np.median(quality)),
808
+ 'min': float(np.min(quality)),
809
+ 'max': float(np.max(quality)),
810
+ 'std': float(np.std(quality))
811
+ }
812
+ return quality, angles, stats
813
+
814
+ def smooth_mesh(self, method: str, acknowledge_change: bool=False, *kwargs) -> np.ndarray:
815
+ """
816
+ Perform mesh smoothing using a modified FEM-based approach.
817
+
818
+ Parameters:
819
+ method: Smoothing method ('FEM','angle-based')
820
+ acknowledge_change: If True, acknowledges the change in the mesh
821
+ """
822
+ assert acknowledge_change, "acknowledge_change must be True to change mesh -- this will change the mesh, make sure you understand this before using this method within a broader algorithm."
823
+ if method.lower() == 'fem':
824
+ new_points = self.direct_smoother( *kwargs )
825
+ elif method.lower() == 'angle-based':
826
+ new_points = self.angle_based_smoother( *kwargs )
827
+ else:
828
+ raise ValueError(f"Unknown smoothing method: {method}")
829
+ self.change_points( new_points, acknowledge_change=True )
830
+ return new_points
831
+
832
+ def angle_based_smoother( self, angle_limit: float = 30.0 ) -> np.ndarray:
833
+ """
834
+ Perform angle-based smoothing of the mesh.
835
+ Based on this: https://www.andrew.cmu.edu/user/shimada/papers/00-imr-zhou.pdf
836
+ Parameters:
837
+ angle_limit: Maximum allowable angle deviation in degrees
838
+ """
839
+ # Placeholder for angle-based smoothing logic
840
+ # This would involve checking angles and adjusting points accordingly
841
+ # For now, just return the original points
842
+ raise NotImplementedError("Angle-based smoothing not implemented yet.")
843
+ return self.points
844
+
845
+ def direct_smoother( self, kinf=1e12 ) -> np.ndarray:
846
+ """
847
+ Perform direct (non-iterative) FEM smoothing with fixed boundary nodes.
848
+ Based on the triangle stiffness formulation in Balendran (2006).
849
+
850
+ Parameters:
851
+ kinf: Large stiffness value for fixed boundary vertices
852
+
853
+ Reference:
854
+ Zhou, M., & Shimada, K. (2000).
855
+ "An angle-based approach to two-dimensional mesh smoothing".
856
+ In *Proceedings of the 9th International Meshing Roundtable*, 373–384.
857
+ Sandia National Laboratories.
858
+ https://api.semanticscholar.org/CorpusID:34335417
859
+ """
860
+ import numpy as np
861
+ from scipy.sparse import csr_matrix
862
+ from scipy.sparse.linalg import spsolve
863
+
864
+ p = self.points[:, :2] # Only use x, y
865
+ t = self.connectivity_list[:, :3] # Assume triangles only
866
+
867
+ # Identify boundary nodes
868
+ edge_verts = self.edge2vert(self.boundary_edges())
869
+ boundary_nodes = np.unique(edge_verts.flatten())
870
+
871
+ n = self.n_verts
872
+ D = 2.0 * np.eye(2)
873
+ T = np.array([[-1, -np.sqrt(3)], [np.sqrt(3), -1]])
874
+
875
+ rows, cols, data = [], [], []
876
+ for tri in t:
877
+ for i in range(3):
878
+ for j in range(3):
879
+ block = D if i == j else T if j == (i+1)%3 else T.T
880
+ for di in range(2):
881
+ for dj in range(2):
882
+ rows.append(2*tri[i]+di)
883
+ cols.append(2*tri[j]+dj)
884
+ data.append(block[di, dj])
885
+
886
+ K = csr_matrix((data, (rows, cols)), shape=(2*n, 2*n))
887
+ F = np.zeros(2*n)
888
+
889
+ # Apply boundary constraints
890
+ for v in boundary_nodes:
891
+ F[2*v:2*v+2] = kinf * p[v]
892
+ K[2*v, 2*v] = kinf
893
+ K[2*v+1, 2*v+1] = kinf
894
+
895
+ c = spsolve(K, F)
896
+ new_points = np.zeros_like(self.points)
897
+ new_points[:, :2] = c.reshape(-1, 2)
898
+ new_points[:, 2] = self.points[:, 2] # preserve z if needed
899
+ return new_points
900
+
901
+
902
+ def copy( self ) -> "CHILmesh":
903
+ """ Returns: a deep copy of the new CHILmesh object with the same properties."""
904
+ return deepcopy(self)
905
+
906
+
907
+ def write_fort14( filename: Path, points: np.ndarray, elements: np.ndarray, grid_name: str ) -> bool:
908
+ """
909
+ Write mesh data to a .fort.14 ADCIRC file.
910
+
911
+ Parameters:
912
+ filename: Output path
913
+ points: (n_nodes, 2 or 3) numpy array of node coordinates
914
+ elements: (n_elems, 3) array of triangle vertex indices (0-based)
915
+ grid_name: Header string
916
+ """
917
+ try:
918
+ with open(filename, 'w') as f:
919
+ f.write(f"{grid_name}\n")
920
+ f.write(f"{len(elements)} {len(points)}\n")
921
+
922
+ for i, pt in enumerate(points, start=1):
923
+ x, y = pt[:2]
924
+ z = pt[2] if len(pt) == 3 else 0.0
925
+ f.write(f"{i} {x:.8f} {y:.8f} {z:.8f}\n")
926
+
927
+ for i, tri in enumerate(elements, start=1):
928
+ n1, n2, n3 = tri + 1 # switch to 1-based indexing
929
+ f.write(f"{i} 3 {n1} {n2} {n3}\n")
930
+ return True
931
+ except Exception as e:
932
+ print(f"Error writing fort14 file {filename}: {e}")
933
+ return False