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/seep.py ADDED
@@ -0,0 +1,2080 @@
1
+ # Copyright 2025 Norman L. Jones
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ import matplotlib.pyplot as plt
16
+ import numpy as np
17
+ from scipy.sparse import lil_matrix, csr_matrix
18
+ from scipy.sparse.linalg import spsolve
19
+ from shapely.geometry import LineString, Point
20
+
21
+
22
+ def build_seep_data(mesh, slope_data):
23
+ """
24
+ Build a seep_data dictionary from a mesh and data dictionary.
25
+
26
+ This function takes a mesh dictionary (from build_mesh_from_polygons) and a data dictionary
27
+ (from load_slope_data) and constructs a seep_data dictionary suitable for seepage analysis.
28
+
29
+ The function:
30
+ 1. Extracts mesh information (nodes, elements, element types, element materials)
31
+ 2. Builds material property arrays (k1, k2, alpha, kr0, h0) from the materials table
32
+ 3. Constructs boundary conditions by finding nodes that intersect with specified head
33
+ and seepage face lines from the data dictionary
34
+
35
+ Parameters:
36
+ mesh (dict): Mesh dictionary from build_mesh_from_polygons containing:
37
+ - nodes: np.ndarray (n_nodes, 2) of node coordinates
38
+ - elements: np.ndarray (n_elements, 3 or 4) of element node indices
39
+ - element_types: np.ndarray (n_elements,) indicating 3 for triangles, 4 for quads
40
+ - element_materials: np.ndarray (n_elements,) of material IDs (1-based)
41
+ data (dict): Data dictionary from load_slope_data containing:
42
+ - materials: list of material dictionaries with k1, k2, alpha, kr0, h0 properties
43
+ - seepage_bc: dictionary with "specified_heads" and "exit_face" boundary conditions
44
+ - gamma_water: unit weight of water
45
+
46
+ Returns:
47
+ dict: seep_data dictionary with the following structure:
48
+ - nodes: np.ndarray (n_nodes, 2) of node coordinates
49
+ - elements: np.ndarray (n_elements, 3 or 4) of element node indices
50
+ - element_types: np.ndarray (n_elements,) indicating 3 for triangles, 4 for quads
51
+ - element_materials: np.ndarray (n_elements,) of material IDs (1-based)
52
+ - bc_type: np.ndarray (n_nodes,) of boundary condition flags (0=free, 1=fixed head, 2=exit face)
53
+ - bc_values: np.ndarray (n_nodes,) of boundary condition values
54
+ - k1_by_mat: np.ndarray (n_materials,) of major conductivity values
55
+ - k2_by_mat: np.ndarray (n_materials,) of minor conductivity values
56
+ - angle_by_mat: np.ndarray (n_materials,) of angle values (degrees)
57
+ - kr0_by_mat: np.ndarray (n_materials,) of relative conductivity values
58
+ - h0_by_mat: np.ndarray (n_materials,) of suction head values
59
+ - unit_weight: float, unit weight of water
60
+ """
61
+
62
+ # Extract mesh data
63
+ nodes = mesh["nodes"]
64
+ elements = mesh["elements"]
65
+ element_types = mesh["element_types"]
66
+ element_materials = mesh["element_materials"]
67
+
68
+ # Initialize boundary condition arrays
69
+ n_nodes = len(nodes)
70
+ bc_type = np.zeros(n_nodes, dtype=int) # 0 = free, 1 = fixed head, 2 = exit face
71
+ bc_values = np.zeros(n_nodes)
72
+
73
+ # Build material property arrays
74
+ materials = slope_data["materials"]
75
+ n_materials = len(materials)
76
+
77
+ k1_by_mat = np.zeros(n_materials)
78
+ k2_by_mat = np.zeros(n_materials)
79
+ angle_by_mat = np.zeros(n_materials)
80
+ kr0_by_mat = np.zeros(n_materials)
81
+ h0_by_mat = np.zeros(n_materials)
82
+ material_names = []
83
+
84
+ for i, material in enumerate(materials):
85
+ k1_by_mat[i] = material.get("k1", 1.0)
86
+ k2_by_mat[i] = material.get("k2", 1.0)
87
+ angle_by_mat[i] = material.get("alpha", 0.0)
88
+ kr0_by_mat[i] = material.get("kr0", 0.001)
89
+ h0_by_mat[i] = material.get("h0", -1.0)
90
+ material_names.append(material.get("name", f"Material {i+1}"))
91
+
92
+ # Process boundary conditions
93
+ seepage_bc = slope_data.get("seepage_bc", {})
94
+
95
+ # Calculate appropriate tolerance based on mesh size
96
+ # Use a fraction of the typical element size
97
+ x_range = np.max(nodes[:, 0]) - np.min(nodes[:, 0])
98
+ y_range = np.max(nodes[:, 1]) - np.min(nodes[:, 1])
99
+ typical_element_size = min(x_range, y_range) / np.sqrt(len(nodes)) # Approximate element size
100
+ tolerance = typical_element_size * 0.1 # 10% of typical element size
101
+
102
+ print(f"Mesh tolerance for boundary conditions: {tolerance:.6f}")
103
+
104
+ # Process specified head boundary conditions
105
+ specified_heads = seepage_bc.get("specified_heads", [])
106
+ for bc in specified_heads:
107
+ head_value = bc["head"]
108
+ coords = bc["coords"]
109
+
110
+ if len(coords) < 2:
111
+ continue
112
+
113
+ # Create LineString from boundary condition coordinates
114
+ bc_line = LineString(coords)
115
+
116
+ # Find nodes that are close to this line (within tolerance)
117
+ for i, node_coord in enumerate(nodes):
118
+ node_point = Point(node_coord)
119
+
120
+ # Check if node is on or very close to the boundary condition line
121
+ if bc_line.distance(node_point) <= tolerance:
122
+ bc_type[i] = 1 # Fixed head
123
+ bc_values[i] = head_value
124
+
125
+ # Process seepage face (exit face) boundary conditions
126
+ exit_face_coords = seepage_bc.get("exit_face", [])
127
+ if len(exit_face_coords) >= 2:
128
+ # Create LineString from exit face coordinates
129
+ exit_face_line = LineString(exit_face_coords)
130
+
131
+ # Find nodes that are close to this line
132
+ for i, node_coord in enumerate(nodes):
133
+ node_point = Point(node_coord)
134
+
135
+ # Check if node is on or very close to the exit face line
136
+ if exit_face_line.distance(node_point) <= tolerance:
137
+ bc_type[i] = 2 # Exit face
138
+ bc_values[i] = node_coord[1] # Use node's y-coordinate as elevation
139
+
140
+ # Get unit weight of water
141
+ unit_weight = slope_data.get("gamma_water", 9.81)
142
+
143
+ # Construct seep_data dictionary
144
+ seep_data = {
145
+ "nodes": nodes,
146
+ "elements": elements,
147
+ "element_types": element_types,
148
+ "element_materials": element_materials,
149
+ "bc_type": bc_type,
150
+ "bc_values": bc_values,
151
+ "k1_by_mat": k1_by_mat,
152
+ "k2_by_mat": k2_by_mat,
153
+ "angle_by_mat": angle_by_mat,
154
+ "kr0_by_mat": kr0_by_mat,
155
+ "h0_by_mat": h0_by_mat,
156
+ "material_names": material_names,
157
+ "unit_weight": unit_weight
158
+ }
159
+
160
+ return seep_data
161
+
162
+
163
+ def import_seep2d(filepath):
164
+ """
165
+ Reads SEEP2D .s2d input file and returns mesh, materials, and BC data.
166
+ Supports both triangular and quadrilateral elements.
167
+ Uses implicit numbering (0-based array indices) instead of explicit node IDs.
168
+
169
+ Note: All node indices in elements are converted to 0-based indexing during import.
170
+ Material IDs remain 1-based as they appear in the SEEP2D file.
171
+
172
+ Returns:
173
+ {
174
+ "nodes": np.ndarray (n_nodes, 2),
175
+ "bc_type": np.ndarray (n_nodes,), # boundary condition flags
176
+ "bc_values": np.ndarray (n_nodes,), # boundary condition values (head or elevation)
177
+ "elements": np.ndarray (n_elements, 3 or 4), # triangle or quad node indices (0-based)
178
+ "element_types": np.ndarray (n_elements,), # 3 for triangles, 4 for quads
179
+ "element_materials": np.ndarray (n_elements,) # material IDs (1-based)
180
+ }
181
+ """
182
+ import re
183
+
184
+ with open(filepath, "r", encoding="latin-1") as f:
185
+ lines = [line.rstrip() for line in f if line.strip()]
186
+
187
+ title = lines[0] # First line is the title (any text)
188
+ parts = lines[1].split() # Second line contains analysis parameters
189
+
190
+ num_nodes = int(parts[0]) # Number of nodes
191
+ num_elements = int(parts[1]) # Number of elements
192
+ num_materials = int(parts[2]) # Number of materials
193
+ datum = float(parts[3]) # Datum elevation (not used, assume 0.0)
194
+
195
+ problem_type = parts[4] # "PLNE" = planar, otherwise axisymmetric (we only support "PLNE")
196
+ analysis_flag = parts[5] # Unknown integer (ignore)
197
+ flow_flag = parts[6] # "F" or "T" = compute flowlines (ignore)
198
+ unit_weight = float(parts[7]) # Unit weight of water (e.g. 62.4 lb/ft³ or 9.81 kN/m³)
199
+ model_type = int(parts[8]) # 1 = linear front, 2 = van Genuchten (we only support 0)
200
+
201
+ assert problem_type == "PLNE", "Only planar problems are supported"
202
+ assert model_type == 1, "Only linear front models are supported"
203
+
204
+ unit_weight = float(parts[7]) # the unit weight
205
+ mat_props = []
206
+ line_offset = 2
207
+ while len(mat_props) < num_materials:
208
+ nums = [float(n) if '.' in n or 'e' in n.lower() else int(n)
209
+ for n in re.findall(r'[-+]?\d*\.?\d+(?:[eE][-+]?\d+)?', lines[line_offset])]
210
+ if len(nums) >= 6:
211
+ mat_props.append(nums[:6])
212
+ line_offset += 1
213
+ mat_props = np.array(mat_props)
214
+ k1_array = mat_props[:, 1]
215
+ k2_array = mat_props[:, 2]
216
+ angle_array = mat_props[:, 3]
217
+ kr0_array = mat_props[:, 4]
218
+ h0_array = mat_props[:, 5]
219
+ node_lines = lines[line_offset:line_offset + num_nodes]
220
+ element_lines = lines[line_offset + num_nodes:]
221
+
222
+ coords = []
223
+ bc_type = []
224
+ bc_values = []
225
+
226
+ for line in node_lines:
227
+ try:
228
+ node_id = int(line[0:5])
229
+ bc_type_val = int(line[7:10])
230
+ x = float(line[10:25])
231
+ y = float(line[25:40])
232
+
233
+ if bc_type_val == 1 and len(line) >= 41:
234
+ bc_value = float(line[40:55])
235
+ elif bc_type_val == 2:
236
+ bc_value = y
237
+ else:
238
+ bc_value = 0.0
239
+
240
+ bc_type.append(bc_type_val)
241
+ bc_values.append(bc_value)
242
+ coords.append((x, y))
243
+
244
+ except Exception as e:
245
+ print(f"Warning: skipping node due to error: {e}")
246
+
247
+ elements = []
248
+ element_mats = []
249
+ element_types = []
250
+
251
+ for line in element_lines:
252
+ nums = [int(n) for n in re.findall(r'\d+', line)]
253
+ if len(nums) >= 6:
254
+ _, n1, n2, n3, n4, mat = nums[:6]
255
+
256
+ # Convert to 0-based indexing during reading
257
+ n1, n2, n3, n4 = n1 - 1, n2 - 1, n3 - 1, n4 - 1
258
+
259
+ # Check if this is a triangle (n3 == n4) or quad (n3 != n4)
260
+ if n3 == n4:
261
+ # Triangle: repeat the last node to create 4-node format
262
+ elements.append([n1, n2, n3, n3])
263
+ element_types.append(3)
264
+ else:
265
+ # Quadrilateral: use all 4 nodes
266
+ elements.append([n1, n2, n3, n4])
267
+ element_types.append(4)
268
+
269
+ element_mats.append(mat)
270
+
271
+ return {
272
+ "nodes": np.array(coords),
273
+ "bc_type": np.array(bc_type, dtype=int),
274
+ "bc_values": np.array(bc_values),
275
+ "elements": np.array(elements, dtype=int), # Already 0-based
276
+ "element_types": np.array(element_types, dtype=int),
277
+ "element_materials": np.array(element_mats),
278
+ "k1_by_mat": k1_array,
279
+ "k2_by_mat": k2_array,
280
+ "angle_by_mat": angle_array,
281
+ "kr0_by_mat": kr0_array,
282
+ "h0_by_mat": h0_array,
283
+ "unit_weight": unit_weight
284
+ }
285
+
286
+
287
+ def solve_confined(nodes, elements, bc_type, dirichlet_bcs, k1_vals, k2_vals, angles=None, element_types=None):
288
+ """
289
+ FEM solver for confined seepage with anisotropic conductivity.
290
+ Supports triangular and quadrilateral elements with both linear and quadratic shape functions.
291
+
292
+ Parameters:
293
+ nodes : (n_nodes, 2) array of node coordinates
294
+ elements : (n_elements, 9) element node indices (padded with zeros for unused nodes)
295
+ bc_type : (n_nodes,) array of boundary condition flags
296
+ dirichlet_bcs : list of (node_id, head_value)
297
+ k1_vals : (n_elements,) or scalar, major axis conductivity
298
+ k2_vals : (n_elements,) or scalar, minor axis conductivity
299
+ angles : (n_elements,) or scalar, angle in degrees (from x-axis)
300
+ element_types : (n_elements,) array indicating:
301
+ 3 = 3-node triangle (linear)
302
+ 4 = 4-node quadrilateral (bilinear)
303
+ 6 = 6-node triangle (quadratic)
304
+ 8 = 8-node quadrilateral (serendipity)
305
+ 9 = 9-node quadrilateral (Lagrange)
306
+ Returns:
307
+ head : (n_nodes,) array of nodal heads
308
+ """
309
+
310
+ # If element_types is not provided, assume all triangles (backward compatibility)
311
+ if element_types is None:
312
+ element_types = np.full(len(elements), 3)
313
+
314
+ n_nodes = nodes.shape[0]
315
+ A = lil_matrix((n_nodes, n_nodes))
316
+ b = np.zeros(n_nodes)
317
+
318
+ for idx, element_nodes in enumerate(elements):
319
+ element_type = element_types[idx]
320
+
321
+ # Get anisotropic conductivity for this element
322
+ k1 = k1_vals[idx]
323
+ k2 = k2_vals[idx]
324
+ theta = angles[idx]
325
+ theta_rad = np.radians(theta)
326
+ c, s = np.cos(theta_rad), np.sin(theta_rad)
327
+ R = np.array([[c, s], [-s, c]])
328
+ Kmat = R.T @ np.diag([k1, k2]) @ R
329
+
330
+ if element_type == 3:
331
+ # 3-node triangle (linear)
332
+ i, j, k = element_nodes[:3]
333
+ xi, yi = nodes[i]
334
+ xj, yj = nodes[j]
335
+ xk, yk = nodes[k]
336
+
337
+ area = 0.5 * np.linalg.det([[1, xi, yi], [1, xj, yj], [1, xk, yk]])
338
+ if area <= 0:
339
+ continue
340
+
341
+ beta = np.array([yj - yk, yk - yi, yi - yj])
342
+ gamma = np.array([xk - xj, xi - xk, xj - xi])
343
+ grad = np.array([beta, gamma]) / (2 * area)
344
+
345
+ ke = area * grad.T @ Kmat @ grad
346
+
347
+ for a in range(3):
348
+ for b_ in range(3):
349
+ A[element_nodes[a], element_nodes[b_]] += ke[a, b_]
350
+
351
+ elif element_type == 4:
352
+ # 4-node quadrilateral (bilinear)
353
+ i, j, k, l = element_nodes[:4]
354
+ nodes_elem = nodes[[i, j, k, l], :]
355
+ ke = quad4_stiffness_matrix(nodes_elem, Kmat)
356
+ for a in range(4):
357
+ for b_ in range(4):
358
+ A[element_nodes[a], element_nodes[b_]] += ke[a, b_]
359
+
360
+ elif element_type == 6:
361
+ # 6-node triangle (quadratic) - True quadratic shape functions
362
+ nodes_elem = nodes[element_nodes[:6], :]
363
+ ke = tri6_stiffness_matrix(nodes_elem, Kmat)
364
+ for a in range(6):
365
+ for b_ in range(6):
366
+ A[element_nodes[a], element_nodes[b_]] += ke[a, b_]
367
+
368
+ elif element_type == 8:
369
+ # 8-node quadrilateral (serendipity) - True quadratic shape functions
370
+ nodes_elem = nodes[element_nodes[:8], :]
371
+ ke = quad8_stiffness_matrix(nodes_elem, Kmat)
372
+ for a in range(8):
373
+ for b_ in range(8):
374
+ A[element_nodes[a], element_nodes[b_]] += ke[a, b_]
375
+
376
+ elif element_type == 9:
377
+ # 9-node quadrilateral (Lagrange) - True quadratic shape functions
378
+ nodes_elem = nodes[element_nodes[:9], :]
379
+ ke = quad9_stiffness_matrix(nodes_elem, Kmat)
380
+ for a in range(9):
381
+ for b_ in range(9):
382
+ A[element_nodes[a], element_nodes[b_]] += ke[a, b_]
383
+ else:
384
+ print(f"Warning: Unknown element type {element_type} for element {idx}, skipping")
385
+
386
+ A_full = A.copy() # Keep original matrix for computing q
387
+
388
+ for node, value in dirichlet_bcs:
389
+ A[node, :] = 0
390
+ A[node, node] = 1
391
+ b[node] = value
392
+
393
+ head = spsolve(A.tocsr(), b)
394
+ q = A_full.tocsr() @ head
395
+
396
+ total_flow = 0.0
397
+
398
+ for node_idx in range(len(bc_type)):
399
+ if q[node_idx] > 0: # Positive flow
400
+ total_flow += q[node_idx]
401
+
402
+ return head, A, q, total_flow
403
+
404
+
405
+ def solve_unsaturated(nodes, elements, bc_type, bc_values, kr0=0.001, h0=-1.0,
406
+ k1_vals=1.0, k2_vals=1.0, angles=0.0,
407
+ max_iter=200, tol=1e-4, element_types=None):
408
+ """
409
+ Iterative FEM solver for unconfined flow using linear kr frontal function.
410
+ Supports triangular and quadrilateral elements with both linear and quadratic shape functions.
411
+
412
+ Parameters:
413
+ element_types : (n_elements,) array indicating:
414
+ 3 = 3-node triangle (linear)
415
+ 4 = 4-node quadrilateral (bilinear)
416
+ 6 = 6-node triangle (quadratic)
417
+ 8 = 8-node quadrilateral (serendipity)
418
+ 9 = 9-node quadrilateral (Lagrange)
419
+ Note: Quadratic elements currently use linear/bilinear approximation pending full implementation.
420
+ """
421
+
422
+ # If element_types is not provided, assume all triangles (backward compatibility)
423
+ if element_types is None:
424
+ element_types = np.full(len(elements), 3)
425
+
426
+ n_nodes = nodes.shape[0]
427
+ y = nodes[:, 1]
428
+
429
+ # Initialize heads
430
+ h = np.zeros(n_nodes)
431
+ for node_idx in range(n_nodes):
432
+ if bc_type[node_idx] == 1:
433
+ h[node_idx] = bc_values[node_idx]
434
+ elif bc_type[node_idx] == 2:
435
+ h[node_idx] = y[node_idx]
436
+ else:
437
+ fixed_heads = bc_values[bc_type == 1]
438
+ h[node_idx] = np.mean(fixed_heads) if len(fixed_heads) > 0 else np.mean(y)
439
+
440
+ # Track which exit face nodes are active (saturated)
441
+ exit_face_active = np.ones(n_nodes, dtype=bool)
442
+ exit_face_active[bc_type != 2] = False
443
+
444
+ # Store previous iteration values
445
+ h_last = h.copy()
446
+
447
+ # Get material properties per element
448
+ if np.isscalar(kr0):
449
+ kr0 = np.full(len(elements), kr0)
450
+ if np.isscalar(h0):
451
+ h0 = np.full(len(elements), h0)
452
+
453
+ # Set convergence tolerance based on domain height
454
+ ymin, ymax = np.min(y), np.max(y)
455
+ eps = (ymax - ymin) * tol
456
+
457
+ print("Starting unsaturated flow iteration...")
458
+ print(f"Convergence tolerance: {eps:.6e}")
459
+
460
+ # Track convergence history
461
+ residuals = []
462
+ relax = 1.0 # Initial relaxation factor
463
+ prev_residual = float('inf')
464
+
465
+ for iteration in range(1, max_iter + 1):
466
+ # Reset diagnostics for this iteration
467
+ kr_diagnostics = []
468
+
469
+ # Build global stiffness matrix
470
+ A = lil_matrix((n_nodes, n_nodes))
471
+ b = np.zeros(n_nodes)
472
+
473
+ # Compute pressure head at nodes
474
+ p_nodes = h - y
475
+
476
+ # Element assembly with element-wise kr computation
477
+ for idx, element_nodes in enumerate(elements):
478
+ element_type = element_types[idx]
479
+
480
+ if element_type == 3:
481
+ # Triangle: use first 3 nodes (4th node is repeated)
482
+ i, j, k = element_nodes[:3]
483
+ xi, yi = nodes[i]
484
+ xj, yj = nodes[j]
485
+ xk, yk = nodes[k]
486
+
487
+ # Element area
488
+ area = 0.5 * abs((xj - xi) * (yk - yi) - (xk - xi) * (yj - yi))
489
+ if area <= 0:
490
+ continue
491
+
492
+ # Shape function derivatives
493
+ beta = np.array([yj - yk, yk - yi, yi - yj])
494
+ gamma = np.array([xk - xj, xi - xk, xj - xi])
495
+ grad = np.array([beta, gamma]) / (2 * area)
496
+
497
+ # Get material properties for this element
498
+ k1 = k1_vals[idx] if hasattr(k1_vals, '__len__') else k1_vals
499
+ k2 = k2_vals[idx] if hasattr(k2_vals, '__len__') else k2_vals
500
+ theta = angles[idx] if hasattr(angles, '__len__') else angles
501
+
502
+ # Anisotropic conductivity matrix
503
+ theta_rad = np.radians(theta)
504
+ c, s = np.cos(theta_rad), np.sin(theta_rad)
505
+ R = np.array([[c, s], [-s, c]])
506
+ Kmat = R.T @ np.diag([k1, k2]) @ R
507
+
508
+ # Compute element pressure (centroid)
509
+ p_elem = (p_nodes[i] + p_nodes[j] + p_nodes[k]) / 3.0
510
+
511
+ # Get kr for this element based on its material properties
512
+ kr_elem = kr_frontal(p_elem, kr0[idx], h0[idx])
513
+
514
+ # Element stiffness matrix with kr
515
+ ke = kr_elem * area * grad.T @ Kmat @ grad
516
+
517
+ # Assembly
518
+ for row in range(3):
519
+ for col in range(3):
520
+ A[element_nodes[row], element_nodes[col]] += ke[row, col]
521
+
522
+ elif element_type == 6:
523
+ # 6-node triangle (quadratic)
524
+ nodes_elem = nodes[element_nodes[:6], :]
525
+
526
+ # Get material properties for this element
527
+ k1 = k1_vals[idx] if hasattr(k1_vals, '__len__') else k1_vals
528
+ k2 = k2_vals[idx] if hasattr(k2_vals, '__len__') else k2_vals
529
+ theta = angles[idx] if hasattr(angles, '__len__') else angles
530
+ theta_rad = np.radians(theta)
531
+ c, s = np.cos(theta_rad), np.sin(theta_rad)
532
+ R = np.array([[c, s], [-s, c]])
533
+ Kmat = R.T @ np.diag([k1, k2]) @ R
534
+
535
+ # Compute element pressure using quadratic shape functions at centroid
536
+ p_elem = compute_tri6_centroid_pressure(p_nodes, element_nodes)
537
+ kr_elem = kr_frontal(p_elem, kr0[idx], h0[idx])
538
+
539
+ # Get stiffness matrix and scale by kr
540
+ ke = kr_elem * tri6_stiffness_matrix(nodes_elem, Kmat)
541
+
542
+ # Assembly
543
+ for a in range(6):
544
+ for b_ in range(6):
545
+ A[element_nodes[a], element_nodes[b_]] += ke[a, b_]
546
+
547
+ elif element_type == 4:
548
+ # Quadrilateral: use all 4 nodes
549
+ i, j, k, l = element_nodes[:4]
550
+ nodes_elem = nodes[[i, j, k, l], :]
551
+ k1 = k1_vals[idx] if hasattr(k1_vals, '__len__') else k1_vals
552
+ k2 = k2_vals[idx] if hasattr(k2_vals, '__len__') else k2_vals
553
+ theta = angles[idx] if hasattr(angles, '__len__') else angles
554
+ theta_rad = np.radians(theta)
555
+ c, s = np.cos(theta_rad), np.sin(theta_rad)
556
+ R = np.array([[c, s], [-s, c]])
557
+ Kmat = R.T @ np.diag([k1, k2]) @ R
558
+ # Compute element pressure (centroid)
559
+ p_elem = (p_nodes[i] + p_nodes[j] + p_nodes[k] + p_nodes[l]) / 4.0
560
+ kr_elem = kr_frontal(p_elem, kr0[idx], h0[idx])
561
+
562
+ ke = kr_elem * quad4_stiffness_matrix(nodes_elem, Kmat)
563
+
564
+ for a in range(4):
565
+ for b_ in range(4):
566
+ A[element_nodes[a], element_nodes[b_]] += ke[a, b_]
567
+
568
+ elif element_type == 8:
569
+ # 8-node quadrilateral (serendipity)
570
+ nodes_elem = nodes[element_nodes[:8], :]
571
+ k1 = k1_vals[idx] if hasattr(k1_vals, '__len__') else k1_vals
572
+ k2 = k2_vals[idx] if hasattr(k2_vals, '__len__') else k2_vals
573
+ theta = angles[idx] if hasattr(angles, '__len__') else angles
574
+ theta_rad = np.radians(theta)
575
+ c, s = np.cos(theta_rad), np.sin(theta_rad)
576
+ R = np.array([[c, s], [-s, c]])
577
+ Kmat = R.T @ np.diag([k1, k2]) @ R
578
+
579
+ # Compute element pressure using serendipity shape functions at centroid
580
+ p_elem = compute_quad8_centroid_pressure(p_nodes, element_nodes)
581
+ kr_elem = kr_frontal(p_elem, kr0[idx], h0[idx])
582
+
583
+ ke = kr_elem * quad8_stiffness_matrix(nodes_elem, Kmat)
584
+
585
+ for a in range(8):
586
+ for b_ in range(8):
587
+ A[element_nodes[a], element_nodes[b_]] += ke[a, b_]
588
+
589
+ elif element_type == 9:
590
+ # 9-node quadrilateral (Lagrange)
591
+ nodes_elem = nodes[element_nodes[:9], :]
592
+ k1 = k1_vals[idx] if hasattr(k1_vals, '__len__') else k1_vals
593
+ k2 = k2_vals[idx] if hasattr(k2_vals, '__len__') else k2_vals
594
+ theta = angles[idx] if hasattr(angles, '__len__') else angles
595
+ theta_rad = np.radians(theta)
596
+ c, s = np.cos(theta_rad), np.sin(theta_rad)
597
+ R = np.array([[c, s], [-s, c]])
598
+ Kmat = R.T @ np.diag([k1, k2]) @ R
599
+
600
+ # Compute element pressure using biquadratic shape functions at centroid
601
+ p_elem = compute_quad9_centroid_pressure(p_nodes, element_nodes)
602
+ kr_elem = kr_frontal(p_elem, kr0[idx], h0[idx])
603
+
604
+ ke = kr_elem * quad9_stiffness_matrix(nodes_elem, Kmat)
605
+
606
+ for a in range(9):
607
+ for b_ in range(9):
608
+ A[element_nodes[a], element_nodes[b_]] += ke[a, b_]
609
+
610
+ # Store unmodified matrix for flow computation
611
+ A_full = A.tocsr()
612
+
613
+ # Apply boundary conditions
614
+ for node_idx in range(n_nodes):
615
+ if bc_type[node_idx] == 1:
616
+ A[node_idx, :] = 0
617
+ A[node_idx, node_idx] = 1
618
+ b[node_idx] = bc_values[node_idx]
619
+ elif bc_type[node_idx] == 2 and exit_face_active[node_idx]:
620
+ A[node_idx, :] = 0
621
+ A[node_idx, node_idx] = 1
622
+ b[node_idx] = y[node_idx]
623
+
624
+ # Convert to CSR and solve
625
+ A_csr = A.tocsr()
626
+ h_new = spsolve(A_csr, b)
627
+
628
+ # FORTRAN-style relaxation strategy
629
+ if iteration > 20:
630
+ relax = 0.5
631
+ if iteration > 40:
632
+ relax = 0.2
633
+ if iteration > 60:
634
+ relax = 0.1
635
+ if iteration > 80:
636
+ relax = 0.05
637
+ if iteration > 100:
638
+ relax = 0.02
639
+ if iteration > 120:
640
+ relax = 0.01
641
+
642
+ # Apply relaxation
643
+ h_new = relax * h_new + (1 - relax) * h_last
644
+
645
+ # Compute flows at all nodes (not used for closure, but for exit face logic)
646
+ q = A_full @ h_new
647
+
648
+ # Update exit face boundary conditions with hysteresis
649
+ n_active_before = np.sum(exit_face_active)
650
+ hyst = 0.001 * (ymax - ymin) # Hysteresis threshold
651
+
652
+ for node_idx in range(n_nodes):
653
+ if bc_type[node_idx] == 2:
654
+ if exit_face_active[node_idx]:
655
+ # Check if node should become inactive
656
+ if h_new[node_idx] < y[node_idx] - hyst or q[node_idx] > 0:
657
+ exit_face_active[node_idx] = False
658
+ else:
659
+ # Check if node should become active again
660
+ if h_new[node_idx] >= y[node_idx] + hyst and q[node_idx] <= 0:
661
+ exit_face_active[node_idx] = True
662
+ h_new[node_idx] = y[node_idx] # Reset to elevation
663
+
664
+ n_active_after = np.sum(exit_face_active)
665
+
666
+ # Compute relative residual
667
+ residual = np.max(np.abs(h_new - h)) / (np.max(np.abs(h)) + 1e-10)
668
+ residuals.append(residual)
669
+
670
+ # Print detailed iteration info
671
+ if iteration <= 3 or iteration % 5 == 0 or n_active_before != n_active_after:
672
+ print(f"Iteration {iteration}: residual = {residual:.6e}, relax = {relax:.3f}, {n_active_after}/{np.sum(bc_type == 2)} exit face active")
673
+ #print(f" BCs: {np.sum(bc_type == 1)} fixed head, {n_active_after}/{np.sum(bc_type == 2)} exit face active")
674
+
675
+ # Check convergence
676
+ if residual < eps:
677
+ print(f"Converged in {iteration} iterations")
678
+ break
679
+
680
+ # Update for next iteration
681
+ h = h_new.copy()
682
+ h_last = h_new.copy()
683
+
684
+ else:
685
+ print(f"Warning: Did not converge in {max_iter} iterations")
686
+ print("\nConvergence history:")
687
+ for i, r in enumerate(residuals):
688
+ if i % 20 == 0 or i == len(residuals) - 1:
689
+ print(f" Iteration {i+1}: residual = {r:.6e}")
690
+
691
+
692
+ q_final = q
693
+
694
+ # Flow potential closure check - FORTRAN-style
695
+ total_inflow = 0.0
696
+ total_outflow = 0.0
697
+
698
+ for node_idx in range(n_nodes):
699
+ if bc_type[node_idx] == 1: # Fixed head boundary
700
+ if q_final[node_idx] > 0:
701
+ total_inflow += q_final[node_idx]
702
+ elif q_final[node_idx] < 0:
703
+ total_outflow -= q_final[node_idx]
704
+ elif bc_type[node_idx] == 2 and exit_face_active[node_idx]: # Active exit face
705
+ if q_final[node_idx] < 0:
706
+ total_outflow -= q_final[node_idx]
707
+
708
+ closure_error = abs(total_inflow - total_outflow)
709
+ print(f"Flow potential closure check: error = {closure_error:.6e}")
710
+ print(f"Total inflow: {total_inflow:.6e}")
711
+ print(f"Total outflow: {total_outflow:.6e}")
712
+
713
+ if closure_error > 0.01 * max(abs(total_inflow), abs(total_outflow)):
714
+ print(f"Warning: Large flow potential closure error = {closure_error:.6e}")
715
+ print("This may indicate:")
716
+ print(" - Non-conservative flow field")
717
+ print(" - Incorrect boundary identification")
718
+ print(" - Numerical issues in the flow solution")
719
+
720
+
721
+ return h, A, q_final, total_inflow
722
+
723
+ def compute_tri6_centroid_pressure(p_nodes, element_nodes):
724
+ """
725
+ Compute pressure at the centroid of a tri6 element using quadratic shape functions.
726
+
727
+ For GMSH tri6 ordering at centroid (L1=L2=L3=1/3):
728
+ - Corner nodes (0,1,2): N = L*(2*L-1) = 1/3*(2/3-1) = -1/9
729
+ - Edge midpoint nodes (3,4,5): N = 4*L1*L2 = 4*(1/3)*(1/3) = 4/9
730
+ """
731
+ p_elem_nodes = p_nodes[element_nodes[:6]]
732
+ # Shape function values at centroid for GMSH tri6 ordering
733
+ N_corner = -1.0/9.0 # For nodes 0, 1, 2
734
+ N_edge = 4.0/9.0 # For nodes 3, 4, 5
735
+
736
+ p_centroid = (N_corner * (p_elem_nodes[0] + p_elem_nodes[1] + p_elem_nodes[2]) +
737
+ N_edge * (p_elem_nodes[3] + p_elem_nodes[4] + p_elem_nodes[5]))
738
+ return p_centroid
739
+
740
+ def compute_quad8_centroid_pressure(p_nodes, element_nodes):
741
+ """
742
+ Compute pressure at the centroid of a quad8 element using serendipity shape functions.
743
+ At centroid (xi=0, eta=0), only corner nodes contribute equally.
744
+ """
745
+ valid_nodes = element_nodes[:8][element_nodes[:8] != 0]
746
+ p_elem_nodes = p_nodes[valid_nodes]
747
+ # For serendipity quad8 at center, corner nodes have N=1/4, edge nodes have N=0
748
+ if len(valid_nodes) == 8:
749
+ # Corner nodes (0,1,2,3) contribute 1/4 each, edge nodes (4,5,6,7) contribute 0
750
+ return 0.25 * (p_elem_nodes[0] + p_elem_nodes[1] + p_elem_nodes[2] + p_elem_nodes[3])
751
+ else:
752
+ return np.mean(p_elem_nodes) # Fallback for incomplete elements
753
+
754
+ def compute_quad9_centroid_pressure(p_nodes, element_nodes):
755
+ """
756
+ Compute pressure at the centroid of a quad9 element using biquadratic shape functions.
757
+ At centroid (xi=0, eta=0), only the center node contributes.
758
+ """
759
+ p_elem_nodes = p_nodes[element_nodes[:9]]
760
+ # For biquadratic quad9 at center, only center node (node 8) has N=1, all others have N=0
761
+ return p_elem_nodes[8]
762
+
763
+ def kr_frontal(p, kr0, h0):
764
+ """
765
+ Fortran-compatible relative permeability function (front model).
766
+ This matches the fkrelf function in the Fortran code exactly.
767
+ """
768
+ if p >= 0.0:
769
+ return 1.0
770
+ elif p > h0: # when h0 < p < 0
771
+ return kr0 + (1.0 - kr0) * (p - h0) / (-h0)
772
+ else:
773
+ return kr0
774
+
775
+
776
+ def diagnose_exit_face(nodes, bc_type, h, q, bc_values):
777
+ """
778
+ Diagnostic function to understand exit face behavior
779
+ """
780
+
781
+ print("\n=== Exit Face Diagnostics ===")
782
+ exit_nodes = np.where(bc_type == 2)[0]
783
+ y = nodes[:, 1]
784
+
785
+ print(f"Total exit face nodes: {len(exit_nodes)}")
786
+ print("\nNode | x | y | h | h-y | q | Status")
787
+ print("-" * 65)
788
+
789
+ for node in exit_nodes:
790
+ x_coord = nodes[node, 0]
791
+ y_coord = y[node]
792
+ head = h[node]
793
+ pressure = head - y_coord
794
+ flow = q[node]
795
+
796
+ if head >= y_coord:
797
+ status = "SATURATED"
798
+ else:
799
+ status = "UNSATURATED"
800
+
801
+ print(f"{node:4d} | {x_coord:6.2f} | {y_coord:6.2f} | {head:6.3f} | {pressure:6.3f} | {flow:8.3e} | {status}")
802
+
803
+ # Summary statistics
804
+ saturated = np.sum(h[exit_nodes] >= y[exit_nodes])
805
+ print(f"\nSaturated nodes: {saturated}/{len(exit_nodes)}")
806
+
807
+ # Check phreatic surface
808
+ print("\n=== Phreatic Surface Location ===")
809
+ # Find where the phreatic surface intersects the exit face
810
+ for i in range(len(exit_nodes) - 1):
811
+ n1, n2 = exit_nodes[i], exit_nodes[i + 1]
812
+ if (h[n1] >= y[n1]) and (h[n2] < y[n2]):
813
+ # Interpolate intersection point
814
+ y1, y2 = y[n1], y[n2]
815
+ h1, h2 = h[n1], h[n2]
816
+ y_intersect = y1 + (y2 - y1) * (h1 - y1) / (h1 - y1 - h2 + y2)
817
+ print(f"Phreatic surface exits between nodes {n1} and {n2}")
818
+ print(f"Approximate exit elevation: {y_intersect:.3f}")
819
+ break
820
+
821
+ def create_flow_potential_bc(nodes, elements, q, debug=False, element_types=None):
822
+ """
823
+ Generates Dirichlet BCs for flow potential φ by marching around the boundary
824
+ and accumulating q to assign φ, ensuring closed-loop conservation.
825
+
826
+ Improved version that handles numerical noise and different boundary types.
827
+ Supports both triangular and quadrilateral elements.
828
+
829
+ Parameters:
830
+ nodes : (n_nodes, 2) array of node coordinates
831
+ elements : (n_elements, 3 or 4) triangle or quad node indices
832
+ q : (n_nodes,) nodal flow vector
833
+ debug : bool, if True prints detailed diagnostic information
834
+ element_types : (n_elements,) array indicating 3 for triangles, 4 for quads
835
+
836
+ Returns:
837
+ List of (node_id, phi_value) tuples
838
+ """
839
+
840
+ from collections import defaultdict
841
+
842
+ # If element_types is not provided, assume all triangles (backward compatibility)
843
+ if element_types is None:
844
+ element_types = np.full(len(elements), 3)
845
+
846
+ if debug:
847
+ print("=== FLOW POTENTIAL BC DEBUG ===")
848
+
849
+ # Step 1: Build edge dictionary and count how many times each edge appears
850
+ edge_counts = defaultdict(list)
851
+ for idx, element_nodes in enumerate(elements):
852
+ element_type = element_types[idx]
853
+
854
+ if element_type in [3, 6]:
855
+ # Triangular elements: 3 edges (use corner nodes only for boundary detection)
856
+ i, j, k = element_nodes[:3]
857
+ edges = [(i, j), (j, k), (k, i)]
858
+ elif element_type in [4, 8, 9]:
859
+ # Quadrilateral elements: 4 edges (use corner nodes only for boundary detection)
860
+ i, j, k, l = element_nodes[:4]
861
+ edges = [(i, j), (j, k), (k, l), (l, i)]
862
+ else:
863
+ continue # Skip unknown element types
864
+
865
+ for a, b in edges:
866
+ edge = tuple(sorted((a, b)))
867
+ edge_counts[edge].append(idx)
868
+
869
+ # Step 2: Extract boundary edges (appear only once)
870
+ boundary_edges = [edge for edge, elems in edge_counts.items() if len(elems) == 1]
871
+
872
+ if debug:
873
+ print(f"Found {len(boundary_edges)} boundary edges")
874
+
875
+ # Step 3: Build connectivity for the boundary edges
876
+ neighbor_map = defaultdict(list)
877
+ for a, b in boundary_edges:
878
+ neighbor_map[a].append(b)
879
+ neighbor_map[b].append(a)
880
+
881
+ # Step 4: Walk the boundary in order (clockwise or counterclockwise)
882
+ start_node = boundary_edges[0][0]
883
+ ordered_nodes = [start_node]
884
+ visited = {start_node}
885
+ current = start_node
886
+
887
+ while True:
888
+ neighbors = [n for n in neighbor_map[current] if n not in visited]
889
+ if not neighbors:
890
+ break
891
+ next_node = neighbors[0]
892
+ ordered_nodes.append(next_node)
893
+ visited.add(next_node)
894
+ current = next_node
895
+ if next_node == start_node:
896
+ break # closed loop
897
+
898
+ # Debug boundary flow statistics
899
+ if debug:
900
+ boundary_nodes = sorted(set(ordered_nodes))
901
+ print(f"Boundary nodes: {len(boundary_nodes)}")
902
+ print(f"Flow statistics on boundary:")
903
+ q_boundary = [q[node] for node in boundary_nodes]
904
+ print(f" Min q: {min(q_boundary):.6e}")
905
+ print(f" Max q: {max(q_boundary):.6e}")
906
+ print(f" Mean |q|: {np.mean([abs(qval) for qval in q_boundary]):.6e}")
907
+ print(f" Std |q|: {np.std([abs(qval) for qval in q_boundary]):.6e}")
908
+
909
+ # Count "small" flows
910
+ thresholds = [1e-12, 1e-10, 1e-8, 1e-6, 1e-4]
911
+ for thresh in thresholds:
912
+ count = sum(1 for qval in q_boundary if abs(qval) < thresh)
913
+ print(f" Nodes with |q| < {thresh:.0e}: {count}/{len(boundary_nodes)}")
914
+
915
+ # Step 5: Find starting point - improved algorithm
916
+ start_idx = None
917
+ n = len(ordered_nodes)
918
+
919
+ # Define threshold for "effectively zero" flow based on the magnitude of flows
920
+ q_boundary = [abs(q[node]) for node in ordered_nodes]
921
+ q_max = max(q_boundary) if q_boundary else 1.0
922
+ q_threshold = max(1e-10, q_max * 1e-6) # Adaptive threshold
923
+
924
+ if debug:
925
+ print(f"Flow analysis: max |q| = {q_max:.3e}, threshold = {q_threshold:.3e}")
926
+
927
+ # Find the boundary node with maximum positive flow (main inlet)
928
+ max_positive_q = -float('inf')
929
+ max_positive_idx = None
930
+
931
+ for i in range(n):
932
+ node = ordered_nodes[i]
933
+ if q[node] > max_positive_q:
934
+ max_positive_q = q[node]
935
+ max_positive_idx = i
936
+
937
+ if max_positive_idx is not None and max_positive_q > q_threshold:
938
+ start_idx = max_positive_idx
939
+ if debug:
940
+ print(f"Starting at maximum inflow node {ordered_nodes[start_idx]} (q = {max_positive_q:.6f})")
941
+ else:
942
+ # Fallback: start at first node
943
+ start_idx = 0
944
+ if debug:
945
+ print(f"No significant positive flow found, starting at first boundary node {ordered_nodes[start_idx]}")
946
+
947
+ # Step 6: Assign flow potential values by walking from inlet to exit
948
+ phi = {}
949
+
950
+ # Calculate total flow to determine starting phi value
951
+ total_q = sum(abs(q[node]) for node in ordered_nodes if q[node] > 0)
952
+ phi_val = total_q # Start with total flow at inlet
953
+
954
+ if debug:
955
+ print(f"Starting flow potential calculation at node {ordered_nodes[start_idx]}")
956
+ print(f"Total positive flow: {total_q:.6f}, starting phi: {phi_val:.6f}")
957
+
958
+ for i in range(n):
959
+ idx = (start_idx + i) % n
960
+ node = ordered_nodes[idx]
961
+ phi[node] = phi_val
962
+ phi_val -= q[node] # Subtract flow as we move toward exit
963
+
964
+ if debug and (i < 5 or i >= n - 5): # Print first and last few for debugging
965
+ print(f" Node {node}: φ = {phi[node]:.6f}, q = {q[node]:.6f}")
966
+
967
+ # Check closure - should be close to zero for a proper flow field
968
+ # After walking around the complete boundary, phi_val should equal the starting phi value
969
+ starting_phi = phi[ordered_nodes[start_idx]]
970
+ closure_error = phi_val - starting_phi
971
+
972
+ if debug or abs(closure_error) > 1e-3:
973
+ print(f"Flow potential closure check: error = {closure_error:.6e}")
974
+
975
+ if abs(closure_error) > 1e-3:
976
+ print(f"Warning: Large flow potential closure error = {closure_error:.6e}")
977
+ print("This may indicate:")
978
+ print(" - Non-conservative flow field")
979
+ print(" - Incorrect boundary identification")
980
+ print(" - Numerical issues in the flow solution")
981
+
982
+ if debug:
983
+ print("✓ Flow potential BC creation succeeded")
984
+
985
+ return list(phi.items())
986
+
987
+ def solve_flow_function_confined(nodes, elements, k1_vals, k2_vals, angles, dirichlet_nodes, element_types=None):
988
+ """
989
+ Solves Laplace equation for flow function Phi on the same mesh,
990
+ assigning Dirichlet values along no-flow boundaries.
991
+ Assembles the element matrix using the inverse of Kmat for each element.
992
+ Supports both triangular and quadrilateral elements.
993
+
994
+ Parameters:
995
+ nodes : (n_nodes, 2) array of node coordinates
996
+ elements : (n_elements, 3 or 4) triangle or quad node indices
997
+ k1_vals : (n_elements,) or scalar, major axis conductivity
998
+ k2_vals : (n_elements,) or scalar, minor axis conductivity
999
+ angles : (n_elements,) or scalar, angle in degrees (from x-axis)
1000
+ dirichlet_nodes : list of (node_id, phi_value)
1001
+ element_types : (n_elements,) array indicating 3 for triangles, 4 for quads
1002
+ Returns:
1003
+ phi : (n_nodes,) stream function (flow function) values
1004
+ """
1005
+
1006
+ # If element_types is not provided, assume all triangles (backward compatibility)
1007
+ if element_types is None:
1008
+ element_types = np.full(len(elements), 3)
1009
+
1010
+ n_nodes = nodes.shape[0]
1011
+ A = lil_matrix((n_nodes, n_nodes))
1012
+ b = np.zeros(n_nodes)
1013
+
1014
+ for idx, element_nodes in enumerate(elements):
1015
+ element_type = element_types[idx]
1016
+
1017
+ if element_type == 3:
1018
+ # Triangle: use first 3 nodes (4th node is repeated)
1019
+ i, j, k = element_nodes[:3]
1020
+ xi, yi = nodes[i]
1021
+ xj, yj = nodes[j]
1022
+ xk, yk = nodes[k]
1023
+
1024
+ area = 0.5 * np.linalg.det([[1, xi, yi], [1, xj, yj], [1, xk, yk]])
1025
+ if area <= 0:
1026
+ continue
1027
+
1028
+ beta = np.array([yj - yk, yk - yi, yi - yj])
1029
+ gamma = np.array([xk - xj, xi - xk, xj - xi])
1030
+ grad = np.array([beta, gamma]) / (2 * area)
1031
+
1032
+ # Get anisotropic conductivity for this element
1033
+ k1 = k1_vals[idx] if hasattr(k1_vals, '__len__') else k1_vals
1034
+ k2 = k2_vals[idx] if hasattr(k2_vals, '__len__') else k2_vals
1035
+ theta = angles[idx] if hasattr(angles, '__len__') else angles
1036
+
1037
+ theta_rad = np.radians(theta)
1038
+ c, s = np.cos(theta_rad), np.sin(theta_rad)
1039
+ R = np.array([[c, s], [-s, c]])
1040
+ Kmat = R.T @ np.diag([k1, k2]) @ R
1041
+
1042
+ # Assemble using the inverse of Kmat
1043
+ ke = area * grad.T @ np.linalg.inv(Kmat) @ grad
1044
+
1045
+ for a in range(3):
1046
+ for b_ in range(3):
1047
+ A[element_nodes[a], element_nodes[b_]] += ke[a, b_]
1048
+
1049
+ elif element_type == 6:
1050
+ # 6-node triangle (quadratic)
1051
+ nodes_elem = nodes[element_nodes[:6], :]
1052
+
1053
+ # Get anisotropic conductivity for this element
1054
+ k1 = k1_vals[idx] if hasattr(k1_vals, '__len__') else k1_vals
1055
+ k2 = k2_vals[idx] if hasattr(k2_vals, '__len__') else k2_vals
1056
+ theta = angles[idx] if hasattr(angles, '__len__') else angles
1057
+ theta_rad = np.radians(theta)
1058
+ c, s = np.cos(theta_rad), np.sin(theta_rad)
1059
+ R = np.array([[c, s], [-s, c]])
1060
+ Kmat = R.T @ np.diag([k1, k2]) @ R
1061
+ Kmat_inv = np.linalg.inv(Kmat)
1062
+
1063
+ ke = tri6_stiffness_matrix_inverse_k(nodes_elem, Kmat_inv)
1064
+ for a in range(6):
1065
+ for b_ in range(6):
1066
+ A[element_nodes[a], element_nodes[b_]] += ke[a, b_]
1067
+
1068
+ elif element_type == 4:
1069
+ # 4-node quadrilateral (bilinear)
1070
+ i, j, k, l = element_nodes[:4]
1071
+ nodes_elem = nodes[[i, j, k, l], :]
1072
+
1073
+ # Get anisotropic conductivity for this element
1074
+ k1 = k1_vals[idx] if hasattr(k1_vals, '__len__') else k1_vals
1075
+ k2 = k2_vals[idx] if hasattr(k2_vals, '__len__') else k2_vals
1076
+ theta = angles[idx] if hasattr(angles, '__len__') else angles
1077
+ theta_rad = np.radians(theta)
1078
+ c, s = np.cos(theta_rad), np.sin(theta_rad)
1079
+ R = np.array([[c, s], [-s, c]])
1080
+ Kmat = R.T @ np.diag([k1, k2]) @ R
1081
+ Kmat_inv = np.linalg.inv(Kmat)
1082
+
1083
+ ke = quad4_stiffness_matrix(nodes_elem, Kmat_inv)
1084
+ for a in range(4):
1085
+ for b_ in range(4):
1086
+ A[element_nodes[a], element_nodes[b_]] += ke[a, b_]
1087
+
1088
+ elif element_type == 8:
1089
+ # 8-node quadrilateral (serendipity)
1090
+ nodes_elem = nodes[element_nodes[:8], :]
1091
+
1092
+ # Get anisotropic conductivity for this element
1093
+ k1 = k1_vals[idx] if hasattr(k1_vals, '__len__') else k1_vals
1094
+ k2 = k2_vals[idx] if hasattr(k2_vals, '__len__') else k2_vals
1095
+ theta = angles[idx] if hasattr(angles, '__len__') else angles
1096
+ theta_rad = np.radians(theta)
1097
+ c, s = np.cos(theta_rad), np.sin(theta_rad)
1098
+ R = np.array([[c, s], [-s, c]])
1099
+ Kmat = R.T @ np.diag([k1, k2]) @ R
1100
+ Kmat_inv = np.linalg.inv(Kmat)
1101
+
1102
+ ke = quad8_stiffness_matrix_inverse_k(nodes_elem, Kmat_inv)
1103
+ for a in range(8):
1104
+ for b_ in range(8):
1105
+ A[element_nodes[a], element_nodes[b_]] += ke[a, b_]
1106
+
1107
+ elif element_type == 9:
1108
+ # 9-node quadrilateral (Lagrange)
1109
+ nodes_elem = nodes[element_nodes[:9], :]
1110
+
1111
+ # Get anisotropic conductivity for this element
1112
+ k1 = k1_vals[idx] if hasattr(k1_vals, '__len__') else k1_vals
1113
+ k2 = k2_vals[idx] if hasattr(k2_vals, '__len__') else k2_vals
1114
+ theta = angles[idx] if hasattr(angles, '__len__') else angles
1115
+ theta_rad = np.radians(theta)
1116
+ c, s = np.cos(theta_rad), np.sin(theta_rad)
1117
+ R = np.array([[c, s], [-s, c]])
1118
+ Kmat = R.T @ np.diag([k1, k2]) @ R
1119
+ Kmat_inv = np.linalg.inv(Kmat)
1120
+
1121
+ ke = quad9_stiffness_matrix_inverse_k(nodes_elem, Kmat_inv)
1122
+ for a in range(9):
1123
+ for b_ in range(9):
1124
+ A[element_nodes[a], element_nodes[b_]] += ke[a, b_]
1125
+
1126
+ for node, phi_value in dirichlet_nodes:
1127
+ A[node, :] = 0
1128
+ A[node, node] = 1
1129
+ b[node] = phi_value
1130
+
1131
+ phi = spsolve(A.tocsr(), b)
1132
+ return phi
1133
+
1134
+ def solve_flow_function_unsaturated(nodes, elements, head, k1_vals, k2_vals, angles, kr0, h0, dirichlet_nodes, element_types=None):
1135
+ """
1136
+ Solves the flow function Phi using the correct ke for unsaturated flow.
1137
+ For flowlines, assemble the element matrix using the inverse of kr_elem and Kmat, matching the FORTRAN logic.
1138
+ Supports both triangular and quadrilateral elements.
1139
+ """
1140
+
1141
+ # If element_types is not provided, assume all triangles (backward compatibility)
1142
+ if element_types is None:
1143
+ element_types = np.full(len(elements), 3)
1144
+
1145
+ n_nodes = nodes.shape[0]
1146
+ A = lil_matrix((n_nodes, n_nodes))
1147
+ b = np.zeros(n_nodes)
1148
+
1149
+ y = nodes[:, 1]
1150
+ p_nodes = head - y
1151
+
1152
+ for idx, element_nodes in enumerate(elements):
1153
+ element_type = element_types[idx]
1154
+
1155
+ if element_type == 3:
1156
+ # Triangle: use first 3 nodes (4th node is repeated)
1157
+ i, j, k = element_nodes[:3]
1158
+ xi, yi = nodes[i]
1159
+ xj, yj = nodes[j]
1160
+ xk, yk = nodes[k]
1161
+
1162
+ area = 0.5 * abs((xj - xi) * (yk - yi) - (xk - xi) * (yj - yi))
1163
+ if area <= 0:
1164
+ continue
1165
+
1166
+ beta = np.array([yj - yk, yk - yi, yi - yj])
1167
+ gamma = np.array([xk - xj, xi - xk, xj - xi])
1168
+ grad = np.array([beta, gamma]) / (2 * area) # grad is (2,3)
1169
+
1170
+ # Get material properties for this element
1171
+ k1 = k1_vals[idx] if hasattr(k1_vals, '__len__') else k1_vals
1172
+ k2 = k2_vals[idx] if hasattr(k2_vals, '__len__') else k2_vals
1173
+ theta = angles[idx] if hasattr(angles, '__len__') else angles
1174
+
1175
+ theta_rad = np.radians(theta)
1176
+ c, s = np.cos(theta_rad), np.sin(theta_rad)
1177
+ R = np.array([[c, s], [-s, c]])
1178
+ Kmat = R.T @ np.diag([k1, k2]) @ R # Kmat is (2,2)
1179
+
1180
+ # Compute element pressure (centroid)
1181
+ p_elem = (p_nodes[i] + p_nodes[j] + p_nodes[k]) / 3.0
1182
+ kr_elem = kr_frontal(p_elem, kr0[idx], h0[idx])
1183
+
1184
+ # Assemble using the inverse of kr_elem and Kmat
1185
+ # If kr_elem is very small, avoid division by zero
1186
+ if kr_elem > 1e-12:
1187
+ ke = (1.0 / kr_elem) * area * grad.T @ np.linalg.inv(Kmat) @ grad
1188
+ else:
1189
+ ke = 1e12 * area * grad.T @ np.linalg.inv(Kmat) @ grad # Large value for near-zero kr
1190
+
1191
+ for a in range(3):
1192
+ for b_ in range(3):
1193
+ A[element_nodes[a], element_nodes[b_]] += ke[a, b_]
1194
+
1195
+ elif element_type == 6:
1196
+ # 6-node triangle (quadratic)
1197
+ nodes_elem = nodes[element_nodes[:6], :]
1198
+
1199
+ # Get material properties for this element
1200
+ k1 = k1_vals[idx] if hasattr(k1_vals, '__len__') else k1_vals
1201
+ k2 = k2_vals[idx] if hasattr(k2_vals, '__len__') else k2_vals
1202
+ theta = angles[idx] if hasattr(angles, '__len__') else angles
1203
+ theta_rad = np.radians(theta)
1204
+ c, s = np.cos(theta_rad), np.sin(theta_rad)
1205
+ R = np.array([[c, s], [-s, c]])
1206
+ Kmat = R.T @ np.diag([k1, k2]) @ R
1207
+ Kmat_inv = np.linalg.inv(Kmat)
1208
+
1209
+ # Compute element pressure using quadratic shape functions at centroid
1210
+ p_elem = compute_tri6_centroid_pressure(p_nodes, element_nodes)
1211
+ kr_elem = kr_frontal(p_elem, kr0[idx], h0[idx])
1212
+
1213
+ if kr_elem > 1e-12:
1214
+ ke = (1.0 / kr_elem) * tri6_stiffness_matrix_inverse_k(nodes_elem, Kmat_inv)
1215
+ else:
1216
+ ke = 1e12 * tri6_stiffness_matrix_inverse_k(nodes_elem, Kmat_inv)
1217
+
1218
+ for a in range(6):
1219
+ for b_ in range(6):
1220
+ A[element_nodes[a], element_nodes[b_]] += ke[a, b_]
1221
+
1222
+ elif element_type == 4:
1223
+ # 4-node quadrilateral (bilinear)
1224
+ i, j, k, l = element_nodes[:4]
1225
+ nodes_elem = nodes[[i, j, k, l], :]
1226
+
1227
+ # Get material properties for this element
1228
+ k1 = k1_vals[idx] if hasattr(k1_vals, '__len__') else k1_vals
1229
+ k2 = k2_vals[idx] if hasattr(k2_vals, '__len__') else k2_vals
1230
+ theta = angles[idx] if hasattr(angles, '__len__') else angles
1231
+ theta_rad = np.radians(theta)
1232
+ c, s = np.cos(theta_rad), np.sin(theta_rad)
1233
+ R = np.array([[c, s], [-s, c]])
1234
+ Kmat = R.T @ np.diag([k1, k2]) @ R
1235
+ Kmat_inv = np.linalg.inv(Kmat)
1236
+
1237
+ # Get kr for this element based on its material properties (use centroid)
1238
+ p_elem = (p_nodes[i] + p_nodes[j] + p_nodes[k] + p_nodes[l]) / 4.0
1239
+ kr_elem = kr_frontal(p_elem, kr0[idx], h0[idx])
1240
+
1241
+ # Assemble using the inverse of kr_elem and Kmat
1242
+ if kr_elem > 1e-12:
1243
+ ke = (1.0 / kr_elem) * quad4_stiffness_matrix(nodes_elem, Kmat_inv)
1244
+ else:
1245
+ ke = 1e12 * quad4_stiffness_matrix(nodes_elem, Kmat_inv)
1246
+ for a in range(4):
1247
+ for b_ in range(4):
1248
+ A[element_nodes[a], element_nodes[b_]] += ke[a, b_]
1249
+
1250
+ elif element_type == 8:
1251
+ # 8-node quadrilateral (serendipity)
1252
+ nodes_elem = nodes[element_nodes[:8], :]
1253
+
1254
+ # Get material properties for this element
1255
+ k1 = k1_vals[idx] if hasattr(k1_vals, '__len__') else k1_vals
1256
+ k2 = k2_vals[idx] if hasattr(k2_vals, '__len__') else k2_vals
1257
+ theta = angles[idx] if hasattr(angles, '__len__') else angles
1258
+ theta_rad = np.radians(theta)
1259
+ c, s = np.cos(theta_rad), np.sin(theta_rad)
1260
+ R = np.array([[c, s], [-s, c]])
1261
+ Kmat = R.T @ np.diag([k1, k2]) @ R
1262
+ Kmat_inv = np.linalg.inv(Kmat)
1263
+
1264
+ # Compute element pressure using serendipity shape functions at centroid
1265
+ p_elem = compute_quad8_centroid_pressure(p_nodes, element_nodes)
1266
+ kr_elem = kr_frontal(p_elem, kr0[idx], h0[idx])
1267
+
1268
+ if kr_elem > 1e-12:
1269
+ ke = (1.0 / kr_elem) * quad8_stiffness_matrix_inverse_k(nodes_elem, Kmat_inv)
1270
+ else:
1271
+ ke = 1e12 * quad8_stiffness_matrix_inverse_k(nodes_elem, Kmat_inv)
1272
+
1273
+ for a in range(8):
1274
+ for b_ in range(8):
1275
+ A[element_nodes[a], element_nodes[b_]] += ke[a, b_]
1276
+
1277
+ elif element_type == 9:
1278
+ # 9-node quadrilateral (Lagrange)
1279
+ nodes_elem = nodes[element_nodes[:9], :]
1280
+
1281
+ # Get material properties for this element
1282
+ k1 = k1_vals[idx] if hasattr(k1_vals, '__len__') else k1_vals
1283
+ k2 = k2_vals[idx] if hasattr(k2_vals, '__len__') else k2_vals
1284
+ theta = angles[idx] if hasattr(angles, '__len__') else angles
1285
+ theta_rad = np.radians(theta)
1286
+ c, s = np.cos(theta_rad), np.sin(theta_rad)
1287
+ R = np.array([[c, s], [-s, c]])
1288
+ Kmat = R.T @ np.diag([k1, k2]) @ R
1289
+ Kmat_inv = np.linalg.inv(Kmat)
1290
+
1291
+ # Compute element pressure using biquadratic shape functions at centroid
1292
+ p_elem = compute_quad9_centroid_pressure(p_nodes, element_nodes)
1293
+ kr_elem = kr_frontal(p_elem, kr0[idx], h0[idx])
1294
+
1295
+ if kr_elem > 1e-12:
1296
+ ke = (1.0 / kr_elem) * quad9_stiffness_matrix_inverse_k(nodes_elem, Kmat_inv)
1297
+ else:
1298
+ ke = 1e12 * quad9_stiffness_matrix_inverse_k(nodes_elem, Kmat_inv)
1299
+
1300
+ for a in range(9):
1301
+ for b_ in range(9):
1302
+ A[element_nodes[a], element_nodes[b_]] += ke[a, b_]
1303
+
1304
+ for node, phi_value in dirichlet_nodes:
1305
+ A[node, :] = 0
1306
+ A[node, node] = 1
1307
+ b[node] = phi_value
1308
+
1309
+ phi = spsolve(A.tocsr(), b)
1310
+ return phi
1311
+
1312
+
1313
+ def compute_velocity(nodes, elements, head, k1_vals, k2_vals, angles, kr0=None, h0=None, element_types=None):
1314
+ """
1315
+ Compute nodal velocities by averaging element-wise Darcy velocities.
1316
+ If kr0 and h0 are provided, compute kr_elem using kr_frontal; otherwise, kr_elem = 1.0.
1317
+ Supports both triangular and quadrilateral elements.
1318
+ For quads, velocity is computed at Gauss points and averaged to nodes.
1319
+
1320
+ Parameters:
1321
+ nodes : (n_nodes, 2) array of node coordinates
1322
+ elements : (n_elements, 3 or 4) triangle or quad node indices
1323
+ head : (n_nodes,) nodal head solution
1324
+ k1_vals, k2_vals, angles : per-element anisotropic properties (or scalar)
1325
+ kr0 : (n_elements,) or scalar, relative permeability parameter (optional)
1326
+ h0 : (n_elements,) or scalar, pressure head parameter (optional)
1327
+ element_types : (n_elements,) array indicating 3 for triangles, 4 for quads
1328
+
1329
+ Returns:
1330
+ velocity : (n_nodes, 2) array of nodal velocity vectors [vx, vy]
1331
+ """
1332
+ # If element_types is not provided, assume all triangles (backward compatibility)
1333
+ if element_types is None:
1334
+ element_types = np.full(len(elements), 3)
1335
+
1336
+ n_nodes = nodes.shape[0]
1337
+ velocity = np.zeros((n_nodes, 2))
1338
+ count = np.zeros(n_nodes)
1339
+
1340
+ scalar_k = np.isscalar(k1_vals)
1341
+ scalar_kr = kr0 is not None and np.isscalar(kr0)
1342
+
1343
+ y = nodes[:, 1]
1344
+ p_nodes = head - y
1345
+
1346
+ for idx, element_nodes in enumerate(elements):
1347
+ element_type = element_types[idx]
1348
+
1349
+ if element_type == 3:
1350
+ # Triangle: use first 3 nodes (4th node is repeated)
1351
+ i, j, k = element_nodes[:3]
1352
+ xi, yi = nodes[i]
1353
+ xj, yj = nodes[j]
1354
+ xk, yk = nodes[k]
1355
+
1356
+ area = 0.5 * np.linalg.det([[1, xi, yi], [1, xj, yj], [1, xk, yk]])
1357
+ if area <= 0:
1358
+ continue
1359
+
1360
+ beta = np.array([yj - yk, yk - yi, yi - yj])
1361
+ gamma = np.array([xk - xj, xi - xk, xj - xi])
1362
+ grad = np.array([beta, gamma]) / (2 * area)
1363
+
1364
+ h_vals = head[[i, j, k]]
1365
+ grad_h = grad @ h_vals
1366
+
1367
+ if scalar_k:
1368
+ k1 = k1_vals
1369
+ k2 = k2_vals
1370
+ theta = angles
1371
+ else:
1372
+ k1 = k1_vals[idx]
1373
+ k2 = k2_vals[idx]
1374
+ theta = angles[idx]
1375
+
1376
+ theta_rad = np.radians(theta)
1377
+ c, s = np.cos(theta_rad), np.sin(theta_rad)
1378
+ R = np.array([[c, s], [-s, c]])
1379
+ K = R.T @ np.diag([k1, k2]) @ R
1380
+
1381
+ # Compute kr_elem if kr0 and h0 are provided
1382
+ if kr0 is not None and h0 is not None:
1383
+ p_elem = (p_nodes[i] + p_nodes[j] + p_nodes[k]) / 3.0
1384
+ kr_elem = kr_frontal(p_elem, kr0[idx] if not scalar_kr else kr0, h0[idx] if not scalar_kr else h0)
1385
+ else:
1386
+ kr_elem = 1.0
1387
+
1388
+ v_elem = -kr_elem * K @ grad_h
1389
+
1390
+ for node in element_nodes[:3]: # Only use first 3 nodes for triangles
1391
+ velocity[node] += v_elem
1392
+ count[node] += 1
1393
+ elif element_type == 4:
1394
+ # Quadrilateral: use first 4 nodes
1395
+ i, j, k, l = element_nodes[:4]
1396
+ nodes_elem = nodes[[i, j, k, l], :]
1397
+ h_elem = head[[i, j, k, l]]
1398
+ if scalar_k:
1399
+ k1 = k1_vals
1400
+ k2 = k2_vals
1401
+ theta = angles
1402
+ else:
1403
+ k1 = k1_vals[idx]
1404
+ k2 = k2_vals[idx]
1405
+ theta = angles[idx]
1406
+ theta_rad = np.radians(theta)
1407
+ c, s = np.cos(theta_rad), np.sin(theta_rad)
1408
+ R = np.array([[c, s], [-s, c]])
1409
+ K = R.T @ np.diag([k1, k2]) @ R
1410
+ if kr0 is not None and h0 is not None:
1411
+ p_elem = np.mean(p_nodes[[i, j, k, l]])
1412
+ kr_elem = kr_frontal(p_elem, kr0[idx] if not scalar_kr else kr0, h0[idx] if not scalar_kr else h0)
1413
+ else:
1414
+ kr_elem = 1.0
1415
+ # 2x2 Gauss points and weights
1416
+ gauss_pts = [(-1/np.sqrt(3), -1/np.sqrt(3)),
1417
+ (1/np.sqrt(3), -1/np.sqrt(3)),
1418
+ (1/np.sqrt(3), 1/np.sqrt(3)),
1419
+ (-1/np.sqrt(3), 1/np.sqrt(3))]
1420
+ Nvals = [
1421
+ lambda xi, eta: np.array([(1-xi)*(1-eta), (1+xi)*(1-eta), (1+xi)*(1+eta), (1-xi)*(1+eta)]) * 0.25
1422
+ for _ in range(4)
1423
+ ]
1424
+ for (xi, eta) in gauss_pts:
1425
+ # Shape function derivatives w.r.t. natural coords
1426
+ dN_dxi = np.array([-(1-eta), (1-eta), (1+eta), -(1+eta)]) * 0.25
1427
+ dN_deta = np.array([-(1-xi), -(1+xi), (1+xi), (1-xi)]) * 0.25
1428
+ # Jacobian
1429
+ J = np.zeros((2,2))
1430
+ for a in range(4):
1431
+ J[0,0] += dN_dxi[a] * nodes_elem[a,0]
1432
+ J[0,1] += dN_dxi[a] * nodes_elem[a,1]
1433
+ J[1,0] += dN_deta[a] * nodes_elem[a,0]
1434
+ J[1,1] += dN_deta[a] * nodes_elem[a,1]
1435
+ detJ = np.linalg.det(J)
1436
+ if detJ <= 0:
1437
+ continue
1438
+ Jinv = np.linalg.inv(J)
1439
+ # Shape function derivatives w.r.t. x,y
1440
+ dN_dx = Jinv[0,0]*dN_dxi + Jinv[0,1]*dN_deta
1441
+ dN_dy = Jinv[1,0]*dN_dxi + Jinv[1,1]*dN_deta
1442
+ gradN = np.vstack((dN_dx, dN_dy)) # shape (2,4)
1443
+ # Compute grad(h) at this Gauss point
1444
+ grad_h = gradN @ h_elem
1445
+ v_gp = -kr_elem * K @ grad_h # Darcy velocity at Gauss point
1446
+ # Distribute/average to nodes (simple: add to all 4 nodes)
1447
+ for node in element_nodes[:4]: # Only use first 4 nodes for quad4
1448
+ velocity[node] += v_gp
1449
+ count[node] += 1
1450
+ elif element_type == 6:
1451
+ # 6-node triangle (quadratic): compute velocity using 3-point Gauss quadrature
1452
+ nodes_elem = nodes[element_nodes[:6], :]
1453
+ h_elem = head[element_nodes[:6]]
1454
+ p_nodes = h_elem - nodes_elem[:, 1] # pressure = head - y
1455
+
1456
+ if scalar_k:
1457
+ k1 = k1_vals
1458
+ k2 = k2_vals
1459
+ theta = angles
1460
+ else:
1461
+ k1 = k1_vals[idx]
1462
+ k2 = k2_vals[idx]
1463
+ theta = angles[idx]
1464
+ theta_rad = np.radians(theta)
1465
+ c, s = np.cos(theta_rad), np.sin(theta_rad)
1466
+ R = np.array([[c, s], [-s, c]])
1467
+ K = R.T @ np.diag([k1, k2]) @ R
1468
+
1469
+ if kr0 is not None and h0 is not None:
1470
+ p_elem = compute_tri6_centroid_pressure(p_nodes, np.arange(6)) # Use local indices
1471
+ kr_elem = kr_frontal(p_elem, kr0[idx] if not scalar_kr else kr0, h0[idx] if not scalar_kr else h0)
1472
+ else:
1473
+ kr_elem = 1.0
1474
+
1475
+ # 3-point Gauss quadrature for triangles (same as stiffness matrix)
1476
+ gauss_pts = [(1/6, 1/6, 2/3), (1/6, 2/3, 1/6), (2/3, 1/6, 1/6)]
1477
+ weights = [1/3, 1/3, 1/3]
1478
+
1479
+ for (L1, L2, L3), w in zip(gauss_pts, weights):
1480
+ # Shape function derivatives w.r.t. area coordinates
1481
+ dN_dL1 = np.array([4*L1-1, 0, 0, 4*L2, 0, 4*L3])
1482
+ dN_dL2 = np.array([0, 4*L2-1, 0, 4*L1, 4*L3, 0])
1483
+ dN_dL3 = np.array([0, 0, 4*L3-1, 0, 4*L2, 4*L1])
1484
+
1485
+ # Jacobian transformation (same as in stiffness matrix)
1486
+ x0, y0 = nodes_elem[0]
1487
+ x1, y1 = nodes_elem[1]
1488
+ x2, y2 = nodes_elem[2]
1489
+
1490
+ J = np.array([[x0 - x2, x1 - x2],
1491
+ [y0 - y2, y1 - y2]])
1492
+
1493
+ detJ = np.linalg.det(J)
1494
+ if abs(detJ) < 1e-10:
1495
+ continue
1496
+
1497
+ Jinv = np.linalg.inv(J)
1498
+ total_area = 0.5 * abs(detJ)
1499
+
1500
+ # Transform derivatives to global coordinates
1501
+ dN_dx = Jinv[0,0] * (dN_dL1 - dN_dL3) + Jinv[0,1] * (dN_dL2 - dN_dL3)
1502
+ dN_dy = Jinv[1,0] * (dN_dL1 - dN_dL3) + Jinv[1,1] * (dN_dL2 - dN_dL3)
1503
+ gradN = np.vstack((dN_dx, dN_dy)) # shape (2,6)
1504
+
1505
+ # Compute grad(h) at this Gauss point
1506
+ grad_h = gradN @ h_elem
1507
+ v_gp = -kr_elem * K @ grad_h # Darcy velocity at Gauss point
1508
+
1509
+ # Distribute velocity to all 6 nodes of tri6 element
1510
+ for node in element_nodes[:6]:
1511
+ velocity[node] += v_gp * w # Weight by Gauss weight
1512
+ count[node] += w
1513
+
1514
+ count[count == 0] = 1 # Avoid division by zero
1515
+ velocity /= count[:, None]
1516
+ return velocity
1517
+
1518
+ def tri3_stiffness_matrix(nodes_elem, Kmat):
1519
+ """
1520
+ Compute the 3x3 local stiffness matrix for a 3-node triangular element.
1521
+
1522
+ Args:
1523
+ nodes_elem: (3,2) array of nodal coordinates
1524
+ Kmat: (2,2) conductivity matrix for the element
1525
+ Returns:
1526
+ ke: (3,3) element stiffness matrix
1527
+ """
1528
+ xi, yi = nodes_elem[0]
1529
+ xj, yj = nodes_elem[1]
1530
+ xk, yk = nodes_elem[2]
1531
+
1532
+ area = 0.5 * np.linalg.det([[1, xi, yi], [1, xj, yj], [1, xk, yk]])
1533
+ if area <= 0:
1534
+ return np.zeros((3, 3))
1535
+
1536
+ beta = np.array([yj - yk, yk - yi, yi - yj])
1537
+ gamma = np.array([xk - xj, xi - xk, xj - xi])
1538
+ grad = np.array([beta, gamma]) / (2 * area)
1539
+
1540
+ ke = area * grad.T @ Kmat @ grad
1541
+ return ke
1542
+
1543
+
1544
+ def tri6_stiffness_matrix(nodes_elem, Kmat):
1545
+ """
1546
+ Compute the 6x6 local stiffness matrix for a 6-node quadratic triangular element.
1547
+ Uses 3-point Gaussian quadrature and quadratic shape functions.
1548
+
1549
+ GMSH tri6 node ordering:
1550
+ 0,1,2: corner vertices
1551
+ 3: midpoint of edge 0-1
1552
+ 4: midpoint of edge 1-2
1553
+ 5: midpoint of edge 2-0
1554
+
1555
+ Args:
1556
+ nodes_elem: (6,2) array of nodal coordinates
1557
+ Kmat: (2,2) conductivity matrix for the element
1558
+ Returns:
1559
+ ke: (6,6) element stiffness matrix
1560
+ """
1561
+ # 3-point Gauss quadrature for triangles (exact for degree 2 polynomials)
1562
+ gauss_pts = [(1/6, 1/6, 2/3), (1/6, 2/3, 1/6), (2/3, 1/6, 1/6)]
1563
+ weights = [1/3, 1/3, 1/3] # Standard weights for unit triangle
1564
+
1565
+ ke = np.zeros((6, 6))
1566
+
1567
+ for (L1, L2, L3), w in zip(gauss_pts, weights):
1568
+ # Quadratic shape functions in area coordinates for standard GMSH tri6 ordering
1569
+ # N0 = L1*(2*L1-1), N1 = L2*(2*L2-1), N2 = L3*(2*L3-1)
1570
+ # N3 = 4*L1*L2 (edge 0-1), N4 = 4*L2*L3 (edge 1-2), N5 = 4*L3*L1 (edge 2-0)
1571
+
1572
+ # Shape function derivatives w.r.t. area coordinates (standard GMSH ordering)
1573
+ dN_dL1 = np.array([4*L1-1, 0, 0, 4*L2, 0, 4*L3]) # dN/dL1
1574
+ dN_dL2 = np.array([0, 4*L2-1, 0, 4*L1, 4*L3, 0]) # dN/dL2
1575
+ dN_dL3 = np.array([0, 0, 4*L3-1, 0, 4*L2, 4*L1]) # dN/dL3
1576
+
1577
+ # Transform from area coordinates to Cartesian coordinates
1578
+ # We need the Jacobian: J = [dx/dL1, dx/dL2; dy/dL1, dy/dL2]
1579
+ # where L3 = 1 - L1 - L2 is eliminated
1580
+
1581
+ # Calculate coordinate derivatives directly from nodal coordinates (now properly oriented)
1582
+ x0, y0 = nodes_elem[0] # Vertex L1=1
1583
+ x1, y1 = nodes_elem[1] # Vertex L2=1
1584
+ x2, y2 = nodes_elem[2] # Vertex L3=1
1585
+
1586
+ # Jacobian matrix (from area to global coordinates)
1587
+ # Since x = L1*x0 + L2*x1 + L3*x2 and L3 = 1-L1-L2:
1588
+ # dx/dL1 = x0-x2, dx/dL2 = x1-x2, dy/dL1 = y0-y2, dy/dL2 = y1-y2
1589
+ J = np.array([[x0 - x2, x1 - x2],
1590
+ [y0 - y2, y1 - y2]])
1591
+
1592
+ detJ = np.linalg.det(J)
1593
+ if abs(detJ) < 1e-10:
1594
+ continue
1595
+
1596
+ # Handle clockwise node ordering by using signed determinant
1597
+ # If detJ < 0, the nodes are ordered clockwise, but we still need proper transformation
1598
+ Jinv = np.linalg.inv(J)
1599
+
1600
+ # Transform shape function derivatives from area coordinates to global coordinates
1601
+ # Use direct method based on area coordinate derivatives
1602
+
1603
+ # Total triangle area
1604
+ total_area = 0.5 * abs(detJ)
1605
+
1606
+ # Direct computation of area coordinate derivatives (exact formulas)
1607
+ dL1_dx = (y1 - y2) / (2 * total_area)
1608
+ dL1_dy = (x2 - x1) / (2 * total_area)
1609
+ dL2_dx = (y2 - y0) / (2 * total_area)
1610
+ dL2_dy = (x0 - x2) / (2 * total_area)
1611
+ dL3_dx = (y0 - y1) / (2 * total_area)
1612
+ dL3_dy = (x1 - x0) / (2 * total_area)
1613
+
1614
+ # Transform to global coordinates using chain rule:
1615
+ # dNi/dx = (dNi/dL1)*(dL1/dx) + (dNi/dL2)*(dL2/dx) + (dNi/dL3)*(dL3/dx)
1616
+ # dNi/dy = (dNi/dL1)*(dL1/dy) + (dNi/dL2)*(dL2/dy) + (dNi/dL3)*(dL3/dy)
1617
+
1618
+ gradN = np.zeros((2, 6)) # [dN/dx; dN/dy] for 6 shape functions
1619
+
1620
+ for i in range(6):
1621
+ gradN[0, i] = dN_dL1[i]*dL1_dx + dN_dL2[i]*dL2_dx + dN_dL3[i]*dL3_dx # dNi/dx
1622
+ gradN[1, i] = dN_dL1[i]*dL1_dy + dN_dL2[i]*dL2_dy + dN_dL3[i]*dL3_dy # dNi/dy
1623
+
1624
+ # Element stiffness contribution at this Gauss point
1625
+ # Scale by triangle area (detJ = 2 * area for area coordinate mapping)
1626
+ triangle_area = 0.5 * abs(detJ)
1627
+ ke += (gradN.T @ Kmat @ gradN) * triangle_area * w
1628
+
1629
+ return ke
1630
+
1631
+
1632
+ def quad8_stiffness_matrix(nodes_elem, Kmat):
1633
+ """
1634
+ Compute the 8x8 local stiffness matrix for an 8-node serendipity quadrilateral element.
1635
+ Uses 3x3 Gaussian quadrature and serendipity shape functions.
1636
+
1637
+ Args:
1638
+ nodes_elem: (8,2) array of nodal coordinates
1639
+ Kmat: (2,2) conductivity matrix for the element
1640
+ Returns:
1641
+ ke: (8,8) element stiffness matrix
1642
+ """
1643
+ # 3x3 Gauss quadrature points and weights
1644
+ pts_1d = [-np.sqrt(3/5), 0, np.sqrt(3/5)]
1645
+ wts_1d = [5/9, 8/9, 5/9]
1646
+
1647
+ ke = np.zeros((8, 8))
1648
+
1649
+ for i, xi in enumerate(pts_1d):
1650
+ for j, eta in enumerate(pts_1d):
1651
+ w = wts_1d[i] * wts_1d[j]
1652
+
1653
+ # Serendipity shape function derivatives for CCW node ordering
1654
+ # Corner nodes: 0(-1,-1), 1(1,-1), 2(1,1), 3(-1,1)
1655
+ # Edge nodes: 4(0,-1), 5(1,0), 6(0,1), 7(-1,0)
1656
+ dN_dxi = np.array([
1657
+ -0.25*(1-eta)*(-xi-eta-1) - 0.25*(1-xi)*(1-eta), # Node 0: corner (-1,-1)
1658
+ 0.25*(1-eta)*(xi-eta-1) + 0.25*(1+xi)*(1-eta), # Node 1: corner (1,-1)
1659
+ 0.25*(1+eta)*(xi+eta-1) + 0.25*(1+xi)*(1+eta), # Node 2: corner (1,1)
1660
+ -0.25*(1+eta)*(-xi+eta-1) - 0.25*(1-xi)*(1+eta), # Node 3: corner (-1,1)
1661
+ -xi*(1-eta), # Node 4: edge (0,-1)
1662
+ 0.5*(1-eta*eta), # Node 5: edge (1,0)
1663
+ -xi*(1+eta), # Node 6: edge (0,1)
1664
+ -0.5*(1-eta*eta) # Node 7: edge (-1,0)
1665
+ ])
1666
+
1667
+ dN_deta = np.array([
1668
+ -0.25*(1-xi)*(-xi-eta-1) - 0.25*(1-xi)*(1-eta), # Node 0: corner (-1,-1)
1669
+ -0.25*(1+xi)*(xi-eta-1) - 0.25*(1+xi)*(1-eta), # Node 1: corner (1,-1)
1670
+ 0.25*(1+xi)*(xi+eta-1) + 0.25*(1+xi)*(1+eta), # Node 2: corner (1,1)
1671
+ 0.25*(1-xi)*(-xi+eta-1) + 0.25*(1-xi)*(1+eta), # Node 3: corner (-1,1)
1672
+ -0.5*(1-xi*xi), # Node 4: edge (0,-1)
1673
+ -eta*(1+xi), # Node 5: edge (1,0)
1674
+ 0.5*(1-xi*xi), # Node 6: edge (0,1)
1675
+ -eta*(1-xi) # Node 7: edge (-1,0)
1676
+ ])
1677
+
1678
+ # Jacobian
1679
+ J = np.zeros((2, 2))
1680
+ for a in range(8):
1681
+ J[0,0] += dN_dxi[a] * nodes_elem[a,0]
1682
+ J[0,1] += dN_dxi[a] * nodes_elem[a,1]
1683
+ J[1,0] += dN_deta[a] * nodes_elem[a,0]
1684
+ J[1,1] += dN_deta[a] * nodes_elem[a,1]
1685
+
1686
+ detJ = np.linalg.det(J)
1687
+ if detJ <= 0:
1688
+ continue
1689
+
1690
+ Jinv = np.linalg.inv(J)
1691
+
1692
+ # Shape function derivatives w.r.t. x,y
1693
+ dN_dx = Jinv[0,0]*dN_dxi + Jinv[0,1]*dN_deta
1694
+ dN_dy = Jinv[1,0]*dN_dxi + Jinv[1,1]*dN_deta
1695
+ gradN = np.vstack((dN_dx, dN_dy)) # shape (2,8)
1696
+
1697
+ # Element stiffness contribution at this Gauss point
1698
+ ke += (gradN.T @ Kmat @ gradN) * detJ * w
1699
+
1700
+ return ke
1701
+
1702
+
1703
+ def tri6_stiffness_matrix_inverse_k(nodes_elem, Kmat_inv):
1704
+ """
1705
+ Compute the 6x6 local stiffness matrix for a 6-node quadratic triangular element
1706
+ using the inverse conductivity matrix (for flow function computation).
1707
+ """
1708
+ return tri6_stiffness_matrix(nodes_elem, Kmat_inv)
1709
+
1710
+
1711
+ def quad8_stiffness_matrix_inverse_k(nodes_elem, Kmat_inv):
1712
+ """
1713
+ Compute the 8x8 local stiffness matrix for an 8-node serendipity quadrilateral element
1714
+ using the inverse conductivity matrix (for flow function computation).
1715
+ """
1716
+ return quad8_stiffness_matrix(nodes_elem, Kmat_inv)
1717
+
1718
+
1719
+ def quad9_stiffness_matrix_inverse_k(nodes_elem, Kmat_inv):
1720
+ """
1721
+ Compute the 9x9 local stiffness matrix for a 9-node Lagrange quadrilateral element
1722
+ using the inverse conductivity matrix (for flow function computation).
1723
+ """
1724
+ return quad9_stiffness_matrix(nodes_elem, Kmat_inv)
1725
+
1726
+
1727
+ def quad9_stiffness_matrix(nodes_elem, Kmat):
1728
+ """
1729
+ Compute the 9x9 local stiffness matrix for a 9-node Lagrange quadrilateral element.
1730
+ Uses 3x3 Gaussian quadrature and biquadratic Lagrange shape functions.
1731
+
1732
+ Args:
1733
+ nodes_elem: (9,2) array of nodal coordinates
1734
+ Kmat: (2,2) conductivity matrix for the element
1735
+ Returns:
1736
+ ke: (9,9) element stiffness matrix
1737
+ """
1738
+ # 3x3 Gauss quadrature points and weights
1739
+ pts_1d = [-np.sqrt(3/5), 0, np.sqrt(3/5)]
1740
+ wts_1d = [5/9, 8/9, 5/9]
1741
+
1742
+ ke = np.zeros((9, 9))
1743
+
1744
+ for i, xi in enumerate(pts_1d):
1745
+ for j, eta in enumerate(pts_1d):
1746
+ w = wts_1d[i] * wts_1d[j]
1747
+
1748
+ # Lagrange shape function derivatives (biquadratic) for CCW node ordering
1749
+ # Corner nodes: 0(-1,-1), 1(1,-1), 2(1,1), 3(-1,1)
1750
+ # Edge nodes: 4(0,-1), 5(1,0), 6(0,1), 7(-1,0)
1751
+ # Center node: 8(0,0)
1752
+ dN_dxi = np.array([
1753
+ 0.25*(2*xi-1)*eta*(eta-1), # Node 0: corner (-1,-1)
1754
+ 0.25*(2*xi+1)*eta*(eta-1), # Node 1: corner (1,-1)
1755
+ 0.25*(2*xi+1)*eta*(eta+1), # Node 2: corner (1,1)
1756
+ 0.25*(2*xi-1)*eta*(eta+1), # Node 3: corner (-1,1)
1757
+ -xi*eta*(eta-1), # Node 4: edge (0,-1)
1758
+ 0.5*(2*xi+1)*(1-eta*eta), # Node 5: edge (1,0)
1759
+ -xi*eta*(eta+1), # Node 6: edge (0,1)
1760
+ 0.5*(2*xi-1)*(1-eta*eta), # Node 7: edge (-1,0)
1761
+ -2*xi*(1-eta*eta) # Node 8: center (0,0)
1762
+ ])
1763
+
1764
+ dN_deta = np.array([
1765
+ 0.25*xi*(xi-1)*(2*eta-1), # Node 0: corner (-1,-1)
1766
+ 0.25*xi*(xi+1)*(2*eta-1), # Node 1: corner (1,-1)
1767
+ 0.25*xi*(xi+1)*(2*eta+1), # Node 2: corner (1,1)
1768
+ 0.25*xi*(xi-1)*(2*eta+1), # Node 3: corner (-1,1)
1769
+ 0.5*(1-xi*xi)*(2*eta-1), # Node 4: edge (0,-1)
1770
+ -eta*xi*(xi+1), # Node 5: edge (1,0)
1771
+ 0.5*(1-xi*xi)*(2*eta+1), # Node 6: edge (0,1)
1772
+ -eta*xi*(xi-1), # Node 7: edge (-1,0)
1773
+ -2*eta*(1-xi*xi) # Node 8: center (0,0)
1774
+ ])
1775
+
1776
+ # Jacobian
1777
+ J = np.zeros((2, 2))
1778
+ for a in range(9):
1779
+ J[0,0] += dN_dxi[a] * nodes_elem[a,0]
1780
+ J[0,1] += dN_dxi[a] * nodes_elem[a,1]
1781
+ J[1,0] += dN_deta[a] * nodes_elem[a,0]
1782
+ J[1,1] += dN_deta[a] * nodes_elem[a,1]
1783
+
1784
+ detJ = np.linalg.det(J)
1785
+ if detJ <= 0:
1786
+ continue
1787
+
1788
+ Jinv = np.linalg.inv(J)
1789
+
1790
+ # Shape function derivatives w.r.t. x,y
1791
+ dN_dx = Jinv[0,0]*dN_dxi + Jinv[0,1]*dN_deta
1792
+ dN_dy = Jinv[1,0]*dN_dxi + Jinv[1,1]*dN_deta
1793
+ gradN = np.vstack((dN_dx, dN_dy)) # shape (2,9)
1794
+
1795
+ # Element stiffness contribution at this Gauss point
1796
+ ke += (gradN.T @ Kmat @ gradN) * detJ * w
1797
+
1798
+ return ke
1799
+
1800
+
1801
+ def quad4_stiffness_matrix(nodes_elem, Kmat):
1802
+ """
1803
+ Compute the 4x4 local stiffness matrix for a 4-node quadrilateral element
1804
+ using 2x2 Gauss quadrature and bilinear shape functions.
1805
+ nodes_elem: (4,2) array of nodal coordinates (in order: [i,j,k,l])
1806
+ Kmat: (2,2) conductivity matrix for the element
1807
+ Returns:
1808
+ ke: (4,4) element stiffness matrix
1809
+ """
1810
+ # 2x2 Gauss points and weights
1811
+ gauss_pts = [(-1/np.sqrt(3), -1/np.sqrt(3)),
1812
+ (1/np.sqrt(3), -1/np.sqrt(3)),
1813
+ (1/np.sqrt(3), 1/np.sqrt(3)),
1814
+ (-1/np.sqrt(3), 1/np.sqrt(3))]
1815
+ weights = [1, 1, 1, 1]
1816
+ ke = np.zeros((4, 4))
1817
+
1818
+ for gp_idx, ((xi, eta), w) in enumerate(zip(gauss_pts, weights)):
1819
+ # Shape function derivatives w.r.t. natural coords
1820
+ dN_dxi = np.array([
1821
+ [-(1-eta), (1-eta), (1+eta), -(1+eta)]
1822
+ ]) * 0.25
1823
+ dN_deta = np.array([
1824
+ [-(1-xi), -(1+xi), (1+xi), (1-xi)]
1825
+ ]) * 0.25
1826
+ dN_dxi = dN_dxi.flatten()
1827
+ dN_deta = dN_deta.flatten()
1828
+
1829
+ # Jacobian
1830
+ J = np.zeros((2,2))
1831
+ for a in range(4):
1832
+ J[0,0] += dN_dxi[a] * nodes_elem[a,0]
1833
+ J[0,1] += dN_dxi[a] * nodes_elem[a,1]
1834
+ J[1,0] += dN_deta[a] * nodes_elem[a,0]
1835
+ J[1,1] += dN_deta[a] * nodes_elem[a,1]
1836
+
1837
+ detJ = np.linalg.det(J)
1838
+ if detJ <= 0:
1839
+ continue
1840
+ Jinv = np.linalg.inv(J)
1841
+ # Shape function derivatives w.r.t. x,y
1842
+ dN_dx = Jinv[0,0]*dN_dxi + Jinv[0,1]*dN_deta
1843
+ dN_dy = Jinv[1,0]*dN_dxi + Jinv[1,1]*dN_deta
1844
+ gradN = np.vstack((dN_dx, dN_dy)) # shape (2,4)
1845
+ # Element stiffness contribution at this Gauss point
1846
+ ke += (gradN.T @ Kmat @ gradN) * detJ * w
1847
+
1848
+ return ke
1849
+
1850
+ def run_seepage_analysis(seep_data):
1851
+ """
1852
+ Standalone function to run seepage analysis.
1853
+
1854
+ Args:
1855
+ seep_data: Dictionary containing all the seepage data
1856
+
1857
+ Returns:
1858
+ Dictionary containing solution results
1859
+ """
1860
+ # Extract data from seep_data
1861
+ nodes = seep_data["nodes"]
1862
+ elements = seep_data["elements"]
1863
+ bc_type = seep_data["bc_type"]
1864
+ bc_values = seep_data["bc_values"]
1865
+ element_materials = seep_data["element_materials"]
1866
+ element_types = seep_data.get("element_types", None) # New field for element types
1867
+ k1_by_mat = seep_data["k1_by_mat"]
1868
+ k2_by_mat = seep_data["k2_by_mat"]
1869
+ angle_by_mat = seep_data["angle_by_mat"]
1870
+ kr0_by_mat = seep_data["kr0_by_mat"]
1871
+ h0_by_mat = seep_data["h0_by_mat"]
1872
+ unit_weight = seep_data["unit_weight"]
1873
+
1874
+ # Determine if unconfined flow
1875
+ is_unconfined = np.any(bc_type == 2)
1876
+ flow_type = "unconfined" if is_unconfined else "confined"
1877
+ print(f"Solving {flow_type.upper()} seepage problem...")
1878
+ print("Number of fixed-head nodes:", np.sum(bc_type == 1))
1879
+ print("Number of exit face nodes:", np.sum(bc_type == 2))
1880
+
1881
+ # Dirichlet BCs: fixed head (bc_type == 1) and possibly exit face (bc_type == 2)
1882
+ bcs = [(i, bc_values[i]) for i in range(len(bc_type)) if bc_type[i] in (1, 2)]
1883
+
1884
+ # Material properties (per element)
1885
+ mat_ids = element_materials - 1
1886
+ k1 = k1_by_mat[mat_ids]
1887
+ k2 = k2_by_mat[mat_ids]
1888
+ angle = angle_by_mat[mat_ids]
1889
+
1890
+ # Solve for head, stiffness matrix A, and nodal flow vector q
1891
+ if is_unconfined:
1892
+ # Get kr0 and h0 values per element based on material
1893
+ kr0_per_element = kr0_by_mat[mat_ids]
1894
+ h0_per_element = h0_by_mat[mat_ids]
1895
+
1896
+ head, A, q, total_flow = solve_unsaturated(
1897
+ nodes=nodes,
1898
+ elements=elements,
1899
+ bc_type=bc_type,
1900
+ bc_values=bc_values,
1901
+ kr0=kr0_per_element,
1902
+ h0=h0_per_element,
1903
+ k1_vals=k1,
1904
+ k2_vals=k2,
1905
+ angles=angle,
1906
+ element_types=element_types
1907
+ )
1908
+ # Solve for potential function φ for flow lines
1909
+ dirichlet_phi_bcs = create_flow_potential_bc(nodes, elements, q, element_types=element_types)
1910
+ phi = solve_flow_function_unsaturated(nodes, elements, head, k1, k2, angle, kr0_per_element, h0_per_element, dirichlet_phi_bcs, element_types)
1911
+ print(f"phi min: {np.min(phi):.3f}, max: {np.max(phi):.3f}")
1912
+ # Compute velocity, pass element-level kr0 and h0
1913
+ velocity = compute_velocity(nodes, elements, head, k1, k2, angle, kr0_per_element, h0_per_element, element_types)
1914
+ else:
1915
+ head, A, q, total_flow = solve_confined(nodes, elements, bc_type, bcs, k1, k2, angle, element_types)
1916
+ # Solve for potential function φ for flow lines
1917
+ dirichlet_phi_bcs = create_flow_potential_bc(nodes, elements, q, element_types=element_types)
1918
+ phi = solve_flow_function_confined(nodes, elements, k1, k2, angle, dirichlet_phi_bcs, element_types)
1919
+ print(f"phi min: {np.min(phi):.3f}, max: {np.max(phi):.3f}")
1920
+ # Compute velocity, don't pass kr0 and h0
1921
+ velocity = compute_velocity(nodes, elements, head, k1, k2, angle, element_types=element_types)
1922
+
1923
+ gamma_w = unit_weight
1924
+ u = gamma_w * (head - nodes[:, 1])
1925
+
1926
+ solution = {
1927
+ "head": head,
1928
+ "u": u,
1929
+ "velocity": velocity,
1930
+ "q": q,
1931
+ "phi": phi,
1932
+ "flowrate": total_flow
1933
+ }
1934
+
1935
+ return solution
1936
+
1937
+ def export_seep_solution(seep_data, solution, filename):
1938
+ """Exports nodal results to a CSV file.
1939
+
1940
+ Args:
1941
+ filename: Path to the output CSV file
1942
+ seep_data: Dictionary containing seepage data
1943
+ solution: Dictionary containing solution results from run_analysis
1944
+ """
1945
+ import pandas as pd
1946
+ n_nodes = len(seep_data["nodes"])
1947
+ df = pd.DataFrame({
1948
+ "node_id": np.arange(1, n_nodes + 1), # Generate 1-based node IDs for output
1949
+ "head": solution["head"],
1950
+ "u": solution["u"],
1951
+ "v_x": solution["velocity"][:, 0],
1952
+ "v_y": solution["velocity"][:, 1],
1953
+ "v_mag": np.linalg.norm(solution["velocity"], axis=1),
1954
+ "q": solution["q"],
1955
+ "phi": solution["phi"]
1956
+ })
1957
+ # Write to file, then append flowrate as comment
1958
+ with open(filename, "w") as f:
1959
+ df.to_csv(f, index=False)
1960
+ f.write(f"# Total Flowrate: {solution['flowrate']:.6f}\n")
1961
+
1962
+ print(f"Exported solution to {filename}")
1963
+
1964
+ def print_seep_data_diagnostics(seep_data):
1965
+ """
1966
+ Diagnostic function to print out the contents of seep_data after loading.
1967
+
1968
+ Args:
1969
+ seep_data: Dictionary containing seepage data
1970
+ """
1971
+ print("\n" + "="*60)
1972
+ print("SEEP DATA DIAGNOSTICS")
1973
+ print("="*60)
1974
+
1975
+ # Basic problem information
1976
+ print(f"Number of nodes: {len(seep_data['nodes'])}")
1977
+ print(f"Number of elements: {len(seep_data['elements'])}")
1978
+ print(f"Number of materials: {len(seep_data['k1_by_mat'])}")
1979
+ print(f"Unit weight of water: {seep_data['unit_weight']}")
1980
+
1981
+ # Element type information
1982
+ element_types = seep_data.get('element_types', None)
1983
+ if element_types is not None:
1984
+ num_triangles = np.sum(element_types == 3)
1985
+ num_quads = np.sum(element_types == 4)
1986
+ print(f"Element types: {num_triangles} triangles, {num_quads} quadrilaterals")
1987
+ else:
1988
+ print("Element types: All triangles (legacy format)")
1989
+
1990
+ # Coordinate ranges
1991
+ coords = seep_data['nodes']
1992
+ print(f"\nCoordinate ranges:")
1993
+ print(f" X: {coords[:, 0].min():.3f} to {coords[:, 0].max():.3f}")
1994
+ print(f" Y: {coords[:, 1].min():.3f} to {coords[:, 1].max():.3f}")
1995
+
1996
+ # Boundary conditions
1997
+ bc_type = seep_data['bc_type']
1998
+ bc_values = seep_data['bc_values']
1999
+ print(f"\nBoundary conditions:")
2000
+ print(f" Fixed head nodes (bc_type=1): {np.sum(bc_type == 1)}")
2001
+ print(f" Exit face nodes (bc_type=2): {np.sum(bc_type == 2)}")
2002
+ print(f" Free nodes (bc_type=0): {np.sum(bc_type == 0)}")
2003
+
2004
+ if np.sum(bc_type == 1) > 0:
2005
+ fixed_head_nodes = np.where(bc_type == 1)[0]
2006
+ print(f" Fixed head values: {bc_values[fixed_head_nodes]}")
2007
+
2008
+ if np.sum(bc_type == 2) > 0:
2009
+ exit_face_nodes = np.where(bc_type == 2)[0]
2010
+ print(f" Exit face elevations: {bc_values[exit_face_nodes]}")
2011
+
2012
+ # Material properties
2013
+ print(f"\nMaterial properties:")
2014
+ for i in range(len(seep_data['k1_by_mat'])):
2015
+ print(f" Material {i+1}:")
2016
+ print(f" k1 (major conductivity): {seep_data['k1_by_mat'][i]:.6f}")
2017
+ print(f" k2 (minor conductivity): {seep_data['k2_by_mat'][i]:.6f}")
2018
+ print(f" angle (degrees): {seep_data['angle_by_mat'][i]:.1f}")
2019
+ print(f" kr0 (relative conductivity): {seep_data['kr0_by_mat'][i]:.6f}")
2020
+ print(f" h0 (suction head): {seep_data['h0_by_mat'][i]:.3f}")
2021
+
2022
+ # Element material distribution
2023
+ element_materials = seep_data['element_materials']
2024
+ unique_materials, counts = np.unique(element_materials, return_counts=True)
2025
+ print(f"\nElement material distribution:")
2026
+ for mat_id, count in zip(unique_materials, counts):
2027
+ print(f" Material {mat_id}: {count} elements")
2028
+
2029
+ # Check for potential issues
2030
+ print(f"\nData validation:")
2031
+ if np.any(seep_data['k1_by_mat'] <= 0):
2032
+ print(" WARNING: Some k1 values are <= 0")
2033
+ if np.any(seep_data['k2_by_mat'] <= 0):
2034
+ print(" WARNING: Some k2 values are <= 0")
2035
+ if np.any(seep_data['k1_by_mat'] < seep_data['k2_by_mat']):
2036
+ print(" WARNING: Some k1 values are less than k2 (should be major >= minor)")
2037
+
2038
+ # Flow type determination
2039
+ is_unconfined = np.any(bc_type == 2)
2040
+ flow_type = "unconfined" if is_unconfined else "confined"
2041
+ print(f" Flow type: {flow_type}")
2042
+
2043
+ print("="*60 + "\n")
2044
+
2045
+ def save_seep_data_to_json(seep_data, filename):
2046
+ """Save seep_data dictionary to JSON file."""
2047
+ import json
2048
+ import numpy as np
2049
+
2050
+ # Convert numpy arrays to lists for JSON serialization
2051
+ seep_data_json = {}
2052
+ for key, value in seep_data.items():
2053
+ if isinstance(value, np.ndarray):
2054
+ seep_data_json[key] = value.tolist()
2055
+ else:
2056
+ seep_data_json[key] = value
2057
+
2058
+ with open(filename, 'w') as f:
2059
+ json.dump(seep_data_json, f, indent=2)
2060
+
2061
+ print(f"Seepage data saved to {filename}")
2062
+
2063
+ def load_seep_data_from_json(filename):
2064
+ """Load seep_data dictionary from JSON file."""
2065
+ import json
2066
+ import numpy as np
2067
+
2068
+ with open(filename, 'r') as f:
2069
+ seep_data_json = json.load(f)
2070
+
2071
+ # Convert lists back to numpy arrays
2072
+ seep_data = {}
2073
+ for key, value in seep_data_json.items():
2074
+ if isinstance(value, list):
2075
+ seep_data[key] = np.array(value)
2076
+ else:
2077
+ seep_data[key] = value
2078
+
2079
+ return seep_data
2080
+