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/__init__.py +1 -0
- xslope/_version.py +4 -0
- xslope/advanced.py +460 -0
- xslope/fem.py +2753 -0
- xslope/fileio.py +671 -0
- xslope/global_config.py +59 -0
- xslope/mesh.py +2719 -0
- xslope/plot.py +1484 -0
- xslope/plot_fem.py +1658 -0
- xslope/plot_seep.py +634 -0
- xslope/search.py +416 -0
- xslope/seep.py +2080 -0
- xslope/slice.py +1075 -0
- xslope/solve.py +1259 -0
- xslope-0.1.2.dist-info/LICENSE +196 -0
- xslope-0.1.2.dist-info/METADATA +56 -0
- xslope-0.1.2.dist-info/NOTICE +14 -0
- xslope-0.1.2.dist-info/RECORD +20 -0
- xslope-0.1.2.dist-info/WHEEL +5 -0
- xslope-0.1.2.dist-info/top_level.txt +1 -0
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
|
+
|