xslope 0.1.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.
xslope/plot_seep.py ADDED
@@ -0,0 +1,634 @@
1
+ import matplotlib.pyplot as plt
2
+ import matplotlib.tri as tri
3
+ from matplotlib.ticker import MaxNLocator
4
+ import numpy as np
5
+
6
+
7
+ def plot_seep_data(seep_data, figsize=(14, 6), show_nodes=False, show_bc=False, material_table=False, label_elements=False, label_nodes=False, alpha=0.4):
8
+ """
9
+ Plots a mesh colored by material zone.
10
+ Supports both triangular and quadrilateral elements.
11
+
12
+ Args:
13
+ seep_data: Dictionary containing seepage data from import_seep2d
14
+ show_nodes: If True, plot node points
15
+ show_bc: If True, plot boundary condition nodes
16
+ material_table: If True, show material table
17
+ label_elements: If True, label each element with its number at its centroid
18
+ label_nodes: If True, label each node with its number just above and to the right
19
+ """
20
+
21
+ from matplotlib.patches import Polygon
22
+
23
+ # Extract data from seep_data
24
+ nodes = seep_data["nodes"]
25
+ elements = seep_data["elements"]
26
+ element_materials = seep_data["element_materials"]
27
+ element_types = seep_data.get("element_types", None) # New field for element types
28
+ bc_type = seep_data["bc_type"]
29
+
30
+ fig, ax = plt.subplots(figsize=figsize)
31
+ materials = np.unique(element_materials)
32
+
33
+ # Import get_material_color to ensure consistent colors with plot_mesh
34
+ from .plot import get_material_color
35
+ mat_to_color = {mat: get_material_color(mat) for mat in materials}
36
+
37
+ # If element_types is not provided, assume all triangles (backward compatibility)
38
+ if element_types is None:
39
+ element_types = np.full(len(elements), 3)
40
+
41
+ for idx, element_nodes in enumerate(elements):
42
+ element_type = element_types[idx]
43
+ color = mat_to_color[element_materials[idx]]
44
+
45
+ if element_type == 3: # Linear triangle
46
+ polygon_coords = nodes[element_nodes[:3]]
47
+ polygon = Polygon(polygon_coords, edgecolor='k', facecolor=color, linewidth=0.5, alpha=alpha)
48
+ ax.add_patch(polygon)
49
+
50
+ elif element_type == 6: # Quadratic triangle - subdivide into 4 sub-triangles
51
+ # Corner nodes
52
+ n0, n1, n2 = nodes[element_nodes[0]], nodes[element_nodes[1]], nodes[element_nodes[2]]
53
+ # Midpoint nodes - standard GMSH pattern: n3=edge 0-1, n4=edge 1-2, n5=edge 2-0
54
+ n3, n4, n5 = nodes[element_nodes[3]], nodes[element_nodes[4]], nodes[element_nodes[5]]
55
+
56
+ # Create 4 sub-triangles with standard GMSH connectivity
57
+ sub_triangles = [
58
+ [n0, n3, n5], # Corner triangle at node 0 (uses midpoints 0-1 and 2-0)
59
+ [n3, n1, n4], # Corner triangle at node 1 (uses midpoints 0-1 and 1-2)
60
+ [n5, n4, n2], # Corner triangle at node 2 (uses midpoints 2-0 and 1-2)
61
+ [n3, n4, n5] # Center triangle (connects all midpoints)
62
+ ]
63
+
64
+ # Add all sub-triangles without internal edges
65
+ for sub_tri in sub_triangles:
66
+ polygon = Polygon(sub_tri, edgecolor='none', facecolor=color, alpha=alpha)
67
+ ax.add_patch(polygon)
68
+
69
+ # Add outer boundary of the tri6 element
70
+ outer_boundary = [n0, n1, n2, n0] # Close the triangle
71
+ ax.plot([p[0] for p in outer_boundary], [p[1] for p in outer_boundary],
72
+ 'k-', linewidth=0.5)
73
+
74
+ elif element_type == 4: # Linear quadrilateral
75
+ polygon_coords = nodes[element_nodes[:4]]
76
+ polygon = Polygon(polygon_coords, edgecolor='k', facecolor=color, linewidth=0.5, alpha=alpha)
77
+ ax.add_patch(polygon)
78
+
79
+ elif element_type == 8: # Quadratic quadrilateral - subdivide into 4 sub-quads
80
+ # Corner nodes
81
+ n0, n1, n2, n3 = nodes[element_nodes[0]], nodes[element_nodes[1]], nodes[element_nodes[2]], nodes[element_nodes[3]]
82
+ # Midpoint nodes
83
+ n4, n5, n6, n7 = nodes[element_nodes[4]], nodes[element_nodes[5]], nodes[element_nodes[6]], nodes[element_nodes[7]]
84
+
85
+ # Calculate center point (average of all 8 nodes)
86
+ center = ((n0[0] + n1[0] + n2[0] + n3[0] + n4[0] + n5[0] + n6[0] + n7[0]) / 8,
87
+ (n0[1] + n1[1] + n2[1] + n3[1] + n4[1] + n5[1] + n6[1] + n7[1]) / 8)
88
+
89
+ # Create 4 sub-quadrilaterals
90
+ sub_quads = [
91
+ [n0, n4, center, n7], # Sub-quad at corner 0
92
+ [n4, n1, n5, center], # Sub-quad at corner 1
93
+ [center, n5, n2, n6], # Sub-quad at corner 2
94
+ [n7, center, n6, n3] # Sub-quad at corner 3
95
+ ]
96
+
97
+ # Add all sub-quads without internal edges
98
+ for sub_quad in sub_quads:
99
+ polygon = Polygon(sub_quad, edgecolor='none', facecolor=color, alpha=alpha)
100
+ ax.add_patch(polygon)
101
+
102
+ # Add outer boundary of the quad8 element
103
+ outer_boundary = [n0, n1, n2, n3, n0] # Close the quadrilateral
104
+ ax.plot([p[0] for p in outer_boundary], [p[1] for p in outer_boundary],
105
+ 'k-', linewidth=0.5)
106
+
107
+ elif element_type == 9: # 9-node quadrilateral - subdivide using actual center node
108
+ # Corner nodes
109
+ n0, n1, n2, n3 = nodes[element_nodes[0]], nodes[element_nodes[1]], nodes[element_nodes[2]], nodes[element_nodes[3]]
110
+ # Midpoint nodes
111
+ n4, n5, n6, n7 = nodes[element_nodes[4]], nodes[element_nodes[5]], nodes[element_nodes[6]], nodes[element_nodes[7]]
112
+ # Center node
113
+ center = nodes[element_nodes[8]]
114
+
115
+ # Create 4 sub-quadrilaterals using the actual center node
116
+ sub_quads = [
117
+ [n0, n4, center, n7], # Sub-quad at corner 0
118
+ [n4, n1, n5, center], # Sub-quad at corner 1
119
+ [center, n5, n2, n6], # Sub-quad at corner 2
120
+ [n7, center, n6, n3] # Sub-quad at corner 3
121
+ ]
122
+
123
+ # Add all sub-quads without internal edges
124
+ for sub_quad in sub_quads:
125
+ polygon = Polygon(sub_quad, edgecolor='none', facecolor=color, alpha=alpha)
126
+ ax.add_patch(polygon)
127
+
128
+ # Add outer boundary of the quad9 element
129
+ outer_boundary = [n0, n1, n2, n3, n0] # Close the quadrilateral
130
+ ax.plot([p[0] for p in outer_boundary], [p[1] for p in outer_boundary],
131
+ 'k-', linewidth=0.5)
132
+
133
+ # Label element number at centroid if requested
134
+ if label_elements:
135
+ # Calculate centroid based on element type
136
+ if element_type in [3, 4]:
137
+ # For linear elements, use the polygon_coords
138
+ if element_type == 3:
139
+ element_coords = nodes[element_nodes[:3]]
140
+ else:
141
+ element_coords = nodes[element_nodes[:4]]
142
+ else:
143
+ # For quadratic elements, use all nodes to calculate centroid
144
+ if element_type == 6:
145
+ element_coords = nodes[element_nodes[:6]]
146
+ elif element_type == 8:
147
+ element_coords = nodes[element_nodes[:8]]
148
+ else: # element_type == 9
149
+ element_coords = nodes[element_nodes[:9]]
150
+
151
+ centroid = np.mean(element_coords, axis=0)
152
+ ax.text(centroid[0], centroid[1], str(idx+1),
153
+ ha='center', va='center', fontsize=6, color='black', alpha=0.4,
154
+ zorder=10)
155
+
156
+ if show_nodes:
157
+ ax.plot(nodes[:, 0], nodes[:, 1], 'k.', markersize=2)
158
+
159
+ # Label node numbers if requested
160
+ if label_nodes:
161
+ for i, (x, y) in enumerate(nodes):
162
+ ax.text(x + 0.5, y + 0.5, str(i+1), fontsize=6, color='blue', alpha=0.7,
163
+ ha='left', va='bottom', zorder=11)
164
+
165
+ # Get material names if available
166
+ material_names = seep_data.get("material_names", [])
167
+
168
+ legend_handles = []
169
+ for mat in materials:
170
+ # Use material name if available, otherwise use "Material {mat}"
171
+ if material_names and mat <= len(material_names):
172
+ label = material_names[mat - 1] # Convert to 0-based index
173
+ else:
174
+ label = f"Material {mat}"
175
+
176
+ legend_handles.append(
177
+ plt.Line2D([0], [0], color=mat_to_color[mat], lw=4, label=label)
178
+ )
179
+
180
+ if show_bc:
181
+ bc1 = nodes[bc_type == 1]
182
+ bc2 = nodes[bc_type == 2]
183
+ if len(bc1) > 0:
184
+ h1, = ax.plot(bc1[:, 0], bc1[:, 1], 'ro', label="Fixed Head (bc_type=1)")
185
+ legend_handles.append(h1)
186
+ if len(bc2) > 0:
187
+ h2, = ax.plot(bc2[:, 0], bc2[:, 1], 'bs', label="Exit Face (bc_type=2)")
188
+ legend_handles.append(h2)
189
+
190
+ # Single combined legend outside the plot
191
+ ax.legend(
192
+ handles=legend_handles,
193
+ loc='upper center',
194
+ bbox_to_anchor=(0.5, -0.1),
195
+ ncol=3, # or more, depending on how many items you have
196
+ frameon=False
197
+ )
198
+ ax.set_aspect("equal")
199
+
200
+ # Count element types for title
201
+ num_triangles = np.sum(element_types == 3)
202
+ num_quads = np.sum(element_types == 4)
203
+ if num_triangles > 0 and num_quads > 0:
204
+ title = f"SEEP2D Mesh with Material Zones ({num_triangles} triangles, {num_quads} quads)"
205
+ elif num_quads > 0:
206
+ title = f"SEEP2D Mesh with Material Zones ({num_quads} quadrilaterals)"
207
+ else:
208
+ title = f"SEEP2D Mesh with Material Zones ({num_triangles} triangles)"
209
+
210
+ # Place the table in the upper left
211
+ if material_table:
212
+ plot_seep_material_table(ax, seep_data, xloc=0.3, yloc=1.1) # upper left
213
+
214
+ ax.set_title(title)
215
+ # plt.subplots_adjust(bottom=0.2) # Add vertical cushion
216
+ plt.tight_layout()
217
+ plt.show()
218
+
219
+
220
+ def plot_seep_solution(seep_data, solution, figsize=(14, 6), levels=20, base_mat=1, fill_contours=True, phreatic=True, alpha=0.4, pad_frac=0.05, show_mesh=True):
221
+ """
222
+ Plots head contours and optionally overlays flowlines (phi) based on flow function.
223
+ Fixed version that properly handles mesh aspect ratio and doesn't clip the plot.
224
+ Supports both triangular and quadrilateral elements.
225
+
226
+ Arguments:
227
+ seep_data: Dictionary containing seepage data from import_seep2d
228
+ solution: Dictionary containing solution results from run_analysis
229
+ levels: number of head contour levels
230
+ base_mat: material ID (1-based) used to compute k for flow function
231
+ fill_contours: bool, if True shows filled contours, if False only black solid lines
232
+ phreatic: bool, if True plots phreatic surface (pressure head = 0) as thick red line
233
+ show_mesh: bool, if True overlays element edges in light gray
234
+ """
235
+ import matplotlib.pyplot as plt
236
+ import matplotlib.tri as tri
237
+ from matplotlib.ticker import MaxNLocator
238
+ from matplotlib.patches import Polygon
239
+ import numpy as np
240
+
241
+ # Extract data from seep_data and solution
242
+ nodes = seep_data["nodes"]
243
+ elements = seep_data["elements"]
244
+ element_materials = seep_data["element_materials"]
245
+ element_types = seep_data.get("element_types", None) # New field for element types
246
+ k1_by_mat = seep_data.get("k1_by_mat") # Use .get() in case it's not present
247
+ head = solution["head"]
248
+ phi = solution.get("phi")
249
+ flowrate = solution.get("flowrate")
250
+
251
+
252
+ # Use constrained_layout for best layout
253
+ fig, ax = plt.subplots(figsize=figsize, constrained_layout=True)
254
+
255
+ # If element_types is not provided, assume all triangles (backward compatibility)
256
+ if element_types is None:
257
+ element_types = np.full(len(elements), 3)
258
+
259
+ # Count element types
260
+ tri3_count = np.sum(element_types == 3)
261
+ tri6_count = np.sum(element_types == 6)
262
+ quad4_count = np.sum(element_types == 4)
263
+ quad8_count = np.sum(element_types == 8)
264
+ quad9_count = np.sum(element_types == 9)
265
+
266
+ print(f"Plotting {tri3_count} linear triangles, {tri6_count} quadratic triangles, "
267
+ f"{quad4_count} linear quads, {quad8_count} 8-node quads, {quad9_count} 9-node quads")
268
+
269
+ # Plot material zones first (if element_materials provided)
270
+ if element_materials is not None:
271
+ materials = np.unique(element_materials)
272
+
273
+ # Import get_material_color to ensure consistent colors with plot_mesh
274
+ from .plot import get_material_color
275
+ mat_to_color = {mat: get_material_color(mat) for mat in materials}
276
+
277
+ # Plot all elements with proper subdivision for quadratic elements
278
+ for idx, element_nodes in enumerate(elements):
279
+ element_type = element_types[idx]
280
+ color = mat_to_color[element_materials[idx]]
281
+
282
+ if element_type == 3: # Linear triangle
283
+ polygon = nodes[element_nodes[:3]]
284
+ ax.fill(*zip(*polygon), edgecolor='none', facecolor=color, alpha=alpha)
285
+
286
+ elif element_type == 6: # Quadratic triangle - subdivide into 4 sub-triangles
287
+ # Corner nodes
288
+ n0, n1, n2 = nodes[element_nodes[0]], nodes[element_nodes[1]], nodes[element_nodes[2]]
289
+ # Midpoint nodes - standard GMSH pattern: n3=edge 0-1, n4=edge 1-2, n5=edge 2-0
290
+ n3, n4, n5 = nodes[element_nodes[3]], nodes[element_nodes[4]], nodes[element_nodes[5]]
291
+
292
+ # Create 4 sub-triangles with standard GMSH connectivity
293
+ sub_triangles = [
294
+ [n0, n3, n5], # Corner triangle at node 0 (uses midpoints 0-1 and 2-0)
295
+ [n3, n1, n4], # Corner triangle at node 1 (uses midpoints 0-1 and 1-2)
296
+ [n5, n4, n2], # Corner triangle at node 2 (uses midpoints 2-0 and 1-2)
297
+ [n3, n4, n5] # Center triangle (connects all midpoints)
298
+ ]
299
+
300
+ # Plot all sub-triangles
301
+ for sub_tri in sub_triangles:
302
+ ax.fill(*zip(*sub_tri), edgecolor='none', facecolor=color, alpha=alpha)
303
+
304
+ elif element_type == 4: # Linear quadrilateral
305
+ polygon = nodes[element_nodes[:4]]
306
+ ax.fill(*zip(*polygon), edgecolor='none', facecolor=color, alpha=alpha)
307
+
308
+ elif element_type == 8: # Quadratic quadrilateral - subdivide into 4 sub-quads
309
+ # Corner nodes
310
+ n0, n1, n2, n3 = nodes[element_nodes[0]], nodes[element_nodes[1]], nodes[element_nodes[2]], nodes[element_nodes[3]]
311
+ # Midpoint nodes
312
+ n4, n5, n6, n7 = nodes[element_nodes[4]], nodes[element_nodes[5]], nodes[element_nodes[6]], nodes[element_nodes[7]]
313
+
314
+ # Calculate center point (average of all 8 nodes)
315
+ center = ((n0[0] + n1[0] + n2[0] + n3[0] + n4[0] + n5[0] + n6[0] + n7[0]) / 8,
316
+ (n0[1] + n1[1] + n2[1] + n3[1] + n4[1] + n5[1] + n6[1] + n7[1]) / 8)
317
+
318
+ # Create 4 sub-quadrilaterals
319
+ sub_quads = [
320
+ [n0, n4, center, n7], # Sub-quad at corner 0
321
+ [n4, n1, n5, center], # Sub-quad at corner 1
322
+ [center, n5, n2, n6], # Sub-quad at corner 2
323
+ [n7, center, n6, n3] # Sub-quad at corner 3
324
+ ]
325
+
326
+ # Plot all sub-quads
327
+ for sub_quad in sub_quads:
328
+ ax.fill(*zip(*sub_quad), edgecolor='none', facecolor=color, alpha=alpha)
329
+
330
+ elif element_type == 9: # 9-node quadrilateral - subdivide using actual center node
331
+ # Corner nodes
332
+ n0, n1, n2, n3 = nodes[element_nodes[0]], nodes[element_nodes[1]], nodes[element_nodes[2]], nodes[element_nodes[3]]
333
+ # Midpoint nodes
334
+ n4, n5, n6, n7 = nodes[element_nodes[4]], nodes[element_nodes[5]], nodes[element_nodes[6]], nodes[element_nodes[7]]
335
+ # Center node
336
+ center = nodes[element_nodes[8]]
337
+
338
+ # Create 4 sub-quadrilaterals using the actual center node
339
+ sub_quads = [
340
+ [n0, n4, center, n7], # Sub-quad at corner 0
341
+ [n4, n1, n5, center], # Sub-quad at corner 1
342
+ [center, n5, n2, n6], # Sub-quad at corner 2
343
+ [n7, center, n6, n3] # Sub-quad at corner 3
344
+ ]
345
+
346
+ # Plot all sub-quads
347
+ for sub_quad in sub_quads:
348
+ ax.fill(*zip(*sub_quad), edgecolor='none', facecolor=color, alpha=alpha)
349
+
350
+ vmin = np.min(head)
351
+ vmax = np.max(head)
352
+ hdrop = vmax - vmin
353
+ contour_levels = np.linspace(vmin, vmax, levels)
354
+
355
+ # For contouring, subdivide tri6 elements into 4 subtriangles
356
+ all_triangles_for_contouring = []
357
+ for idx, element_nodes in enumerate(elements):
358
+ element_type = element_types[idx]
359
+ if element_type == 3: # Linear triangular elements
360
+ all_triangles_for_contouring.append(element_nodes[:3])
361
+ elif element_type == 6: # Quadratic triangular elements
362
+ # Standard GMSH tri6 ordering: 3 = edge 0-1; 4 = edge 1-2; 5 = edge 2-0
363
+ # Create 4 subtriangles: 0-3-5, 3-1-4, 5-4-2, 3-4-5
364
+ subtriangles = [
365
+ [element_nodes[0], element_nodes[3], element_nodes[5]], # 0-3-5 (corner at 0)
366
+ [element_nodes[3], element_nodes[1], element_nodes[4]], # 3-1-4 (corner at 1)
367
+ [element_nodes[5], element_nodes[4], element_nodes[2]], # 5-4-2 (corner at 2)
368
+ [element_nodes[3], element_nodes[4], element_nodes[5]] # 3-4-5 (center)
369
+ ]
370
+ all_triangles_for_contouring.extend(subtriangles)
371
+ elif element_type in [4, 8, 9]: # Quadrilateral elements
372
+ tri1 = [element_nodes[0], element_nodes[1], element_nodes[2]]
373
+ tri2 = [element_nodes[0], element_nodes[2], element_nodes[3]]
374
+ all_triangles_for_contouring.extend([tri1, tri2])
375
+ triang = tri.Triangulation(nodes[:, 0], nodes[:, 1], all_triangles_for_contouring)
376
+
377
+ # Filled contours (only if fill_contours=True)
378
+ if fill_contours:
379
+ contourf = ax.tricontourf(triang, head, levels=contour_levels, cmap="Spectral_r", vmin=vmin, vmax=vmax, alpha=0.5)
380
+ cbar = plt.colorbar(contourf, ax=ax, label="Total Head", shrink=0.8, pad=0.02)
381
+ cbar.locator = MaxNLocator(nbins=10, steps=[1, 2, 5])
382
+ cbar.update_ticks()
383
+
384
+ # Solid lines for head contours
385
+ ax.tricontour(triang, head, levels=contour_levels, colors="k", linewidths=0.5)
386
+
387
+ # Phreatic surface (pressure head = 0)
388
+ if phreatic:
389
+ elevation = nodes[:, 1] # y-coordinate is elevation
390
+ pressure_head = head - elevation
391
+ ax.tricontour(triang, pressure_head, levels=[0], colors="red", linewidths=2.0)
392
+
393
+ # Overlay flowlines if phi is available
394
+ if phi is not None and flowrate is not None and k1_by_mat is not None:
395
+ if base_mat > len(k1_by_mat):
396
+ print(f"Warning: base_mat={base_mat} is larger than number of materials ({len(k1_by_mat)}). Using material 1.")
397
+ base_mat = 1
398
+ elif base_mat < 1:
399
+ print(f"Warning: base_mat={base_mat} is less than 1. Using material 1.")
400
+ base_mat = 1
401
+ base_k = k1_by_mat[base_mat - 1]
402
+ ne = levels - 1
403
+ nf = (flowrate * ne) / (base_k * hdrop)
404
+ phi_levels = round(nf) + 1
405
+ print(f"Computed nf: {nf:.2f}, using {phi_levels} φ contours (flowrate={flowrate:.3f}, base k={base_k}, head drop={hdrop:.3f})")
406
+ phi_contours = np.linspace(np.min(phi), np.max(phi), phi_levels)
407
+ ax.tricontour(triang, phi, levels=phi_contours, colors="blue", linewidths=0.7, linestyles="solid")
408
+
409
+ # Plot element edges if requested
410
+ if show_mesh:
411
+ # Draw all element edges
412
+ for element, elem_type in zip(elements, element_types if element_types is not None else [3]*len(elements)):
413
+ if elem_type == 3:
414
+ # Triangle: connect nodes 0-1-2-0
415
+ edge_nodes = [element[0], element[1], element[2], element[0]]
416
+ elif elem_type == 4:
417
+ # Quadrilateral: connect nodes 0-1-2-3-0
418
+ edge_nodes = [element[0], element[1], element[2], element[3], element[0]]
419
+ elif elem_type == 6:
420
+ # 6-node triangle: only connect corner nodes 0-1-2-0
421
+ edge_nodes = [element[0], element[1], element[2], element[0]]
422
+ elif elem_type in [8, 9]:
423
+ # Higher-order quads: only connect corner nodes 0-1-2-3-0
424
+ edge_nodes = [element[0], element[1], element[2], element[3], element[0]]
425
+ else:
426
+ continue # Skip unknown element types
427
+
428
+ # Get coordinates of edge nodes
429
+ edge_coords = nodes[edge_nodes]
430
+ ax.plot(edge_coords[:, 0], edge_coords[:, 1], color="darkgray", linewidth=0.5, alpha=0.7)
431
+
432
+ # Plot the mesh boundary
433
+ try:
434
+ boundary = get_ordered_mesh_boundary(nodes, elements, element_types)
435
+ ax.plot(boundary[:, 0], boundary[:, 1], color="black", linewidth=1.0, label="Mesh Boundary")
436
+ except Exception as e:
437
+ print(f"Warning: Could not plot mesh boundary: {e}")
438
+
439
+ # Add cushion around the mesh
440
+ x_min, x_max = nodes[:, 0].min(), nodes[:, 0].max()
441
+ y_min, y_max = nodes[:, 1].min(), nodes[:, 1].max()
442
+ x_pad = (x_max - x_min) * pad_frac
443
+ y_pad = (y_max - y_min) * pad_frac
444
+ ax.set_xlim(x_min - x_pad, x_max + x_pad)
445
+ ax.set_ylim(y_min - y_pad, y_max + y_pad)
446
+
447
+ title = "Flow Net: Head Contours"
448
+ if phi is not None:
449
+ title += " and Flowlines"
450
+ if phreatic:
451
+ title += " with Phreatic Surface"
452
+ if flowrate is not None:
453
+ title += f" — Total Flowrate: {flowrate:.3f}"
454
+ ax.set_title(title)
455
+
456
+ # Set equal aspect ratio AFTER setting limits
457
+ ax.set_aspect("equal")
458
+
459
+ # Remove tight_layout and subplots_adjust for best constrained layout
460
+ # plt.tight_layout()
461
+ # plt.subplots_adjust(top=0.78)
462
+ plt.show()
463
+
464
+
465
+ def plot_seep_material_table(ax, seep_data, xloc=0.6, yloc=0.7):
466
+ """
467
+ Adds a seepage material properties table to the plot.
468
+
469
+ Parameters:
470
+ ax: matplotlib Axes object
471
+ seep_data: Dictionary containing seepage data with material properties
472
+ xloc: x-location of table (0-1)
473
+ yloc: y-location of table (0-1)
474
+
475
+ Returns:
476
+ None
477
+ """
478
+ # Extract material properties from seep_data
479
+ k1_by_mat = seep_data.get("k1_by_mat")
480
+ k2_by_mat = seep_data.get("k2_by_mat")
481
+ angle_by_mat = seep_data.get("angle_by_mat")
482
+ kr0_by_mat = seep_data.get("kr0_by_mat")
483
+ h0_by_mat = seep_data.get("h0_by_mat")
484
+ material_names = seep_data.get("material_names", [])
485
+
486
+ if k1_by_mat is None or len(k1_by_mat) == 0:
487
+ return
488
+
489
+ # Column headers for seepage properties
490
+ col_labels = ["Mat", "Name", "k₁", "k₂", "Angle", "kr₀", "h₀"]
491
+
492
+ # Build table rows
493
+ table_data = []
494
+ for idx in range(len(k1_by_mat)):
495
+ k1 = k1_by_mat[idx]
496
+ k2 = k2_by_mat[idx] if k2_by_mat is not None else 0.0
497
+ angle = angle_by_mat[idx] if angle_by_mat is not None else 0.0
498
+ kr0 = kr0_by_mat[idx] if kr0_by_mat is not None else 0.0
499
+ h0 = h0_by_mat[idx] if h0_by_mat is not None else 0.0
500
+
501
+ # Get material name, use default if not available
502
+ material_name = material_names[idx] if idx < len(material_names) else f"Material {idx+1}"
503
+
504
+ # Format values with appropriate precision
505
+ row = [
506
+ idx + 1, # Material number (1-based)
507
+ material_name, # Material name
508
+ f"{k1:.3f}", # k1 in scientific notation
509
+ f"{k2:.3f}", # k2 in scientific notation
510
+ f"{angle:.1f}", # angle in degrees
511
+ f"{kr0:.4f}", # kr0
512
+ f"{h0:.2f}" # h0
513
+ ]
514
+ table_data.append(row)
515
+
516
+ # Add the table
517
+ table = ax.table(cellText=table_data,
518
+ colLabels=col_labels,
519
+ loc='upper right',
520
+ colLoc='center',
521
+ cellLoc='center',
522
+ bbox=[xloc, yloc, 0.45, 0.25]) # Increased width to accommodate name column
523
+ table.auto_set_font_size(False)
524
+ table.set_fontsize(8)
525
+
526
+
527
+ def get_ordered_mesh_boundary(nodes, elements, element_types=None):
528
+ """
529
+ Extracts the outer boundary of the mesh and returns it as an ordered array of points.
530
+ Supports both triangular and quadrilateral elements.
531
+
532
+ Returns:
533
+ np.ndarray of shape (N, 2): boundary coordinates in order (closed loop)
534
+ """
535
+ import numpy as np
536
+ from collections import defaultdict, deque
537
+
538
+ # If element_types is not provided, assume all triangles (backward compatibility)
539
+ if element_types is None:
540
+ element_types = np.full(len(elements), 3)
541
+
542
+ # Step 1: Count all edges
543
+ edge_count = defaultdict(int)
544
+ edge_to_nodes = {}
545
+
546
+ for i, element_nodes in enumerate(elements):
547
+ element_type = element_types[i]
548
+
549
+ if element_type == 3:
550
+ # Triangle: 3 edges
551
+ for j in range(3):
552
+ a, b = sorted((element_nodes[j], element_nodes[(j + 1) % 3]))
553
+ edge_count[(a, b)] += 1
554
+ edge_to_nodes[(a, b)] = (element_nodes[j], element_nodes[(j + 1) % 3]) # preserve direction
555
+ elif element_type == 4:
556
+ # Quadrilateral: 4 edges
557
+ for j in range(4):
558
+ a, b = sorted((element_nodes[j], element_nodes[(j + 1) % 4]))
559
+ edge_count[(a, b)] += 1
560
+ edge_to_nodes[(a, b)] = (element_nodes[j], element_nodes[(j + 1) % 4]) # preserve direction
561
+ elif element_type == 6:
562
+ # 6-node triangle: 3 edges (use only corner nodes 0,1,2)
563
+ for j in range(3):
564
+ a, b = sorted((element_nodes[j], element_nodes[(j + 1) % 3]))
565
+ edge_count[(a, b)] += 1
566
+ edge_to_nodes[(a, b)] = (element_nodes[j], element_nodes[(j + 1) % 3]) # preserve direction
567
+ elif element_type in [8, 9]:
568
+ # Higher-order quadrilaterals: 4 edges (use only corner nodes 0,1,2,3)
569
+ for j in range(4):
570
+ a, b = sorted((element_nodes[j], element_nodes[(j + 1) % 4]))
571
+ edge_count[(a, b)] += 1
572
+ edge_to_nodes[(a, b)] = (element_nodes[j], element_nodes[(j + 1) % 4]) # preserve direction
573
+
574
+ # Step 2: Keep only boundary edges (appear once)
575
+ boundary_edges = [edge_to_nodes[e] for e, count in edge_count.items() if count == 1]
576
+
577
+ if not boundary_edges:
578
+ raise ValueError("No boundary edges found.")
579
+
580
+ # Step 3: Build adjacency for boundary walk
581
+ adj = defaultdict(list)
582
+ for a, b in boundary_edges:
583
+ adj[a].append(b)
584
+ adj[b].append(a)
585
+
586
+ # Step 4: Walk all boundary segments
587
+ all_boundary_nodes = []
588
+ remaining_edges = set(boundary_edges)
589
+
590
+ while remaining_edges:
591
+ # Start a new boundary segment
592
+ start_edge = remaining_edges.pop()
593
+ start_node = start_edge[0]
594
+ current_node = start_edge[1]
595
+
596
+ segment = [start_node, current_node]
597
+ remaining_edges.discard((current_node, start_node)) # Remove reverse edge if present
598
+
599
+ # Walk this segment until we can't continue
600
+ while True:
601
+ # Find next edge from current node
602
+ next_edge = None
603
+ for edge in remaining_edges:
604
+ if edge[0] == current_node:
605
+ next_edge = edge
606
+ break
607
+ elif edge[1] == current_node:
608
+ next_edge = (edge[1], edge[0]) # Reverse the edge
609
+ break
610
+
611
+ if next_edge is None:
612
+ break
613
+
614
+ next_node = next_edge[1]
615
+ segment.append(next_node)
616
+ remaining_edges.discard(next_edge)
617
+ remaining_edges.discard((next_node, current_node)) # Remove reverse edge if present
618
+ current_node = next_node
619
+
620
+ # Check if we've closed the loop
621
+ if current_node == start_node:
622
+ break
623
+
624
+ all_boundary_nodes.extend(segment)
625
+
626
+ # If we have multiple segments, we need to handle them properly
627
+ # For now, just return the first complete segment
628
+ if all_boundary_nodes:
629
+ # Ensure the boundary is closed
630
+ if all_boundary_nodes[0] != all_boundary_nodes[-1]:
631
+ all_boundary_nodes.append(all_boundary_nodes[0])
632
+ return nodes[all_boundary_nodes]
633
+ else:
634
+ raise ValueError("No boundary nodes found.")