xslope 0.1.11__py3-none-any.whl → 0.1.13__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/_version.py +1 -1
- xslope/advanced.py +3 -3
- xslope/fem.py +3 -3
- xslope/fileio.py +463 -206
- xslope/mesh.py +189 -31
- xslope/plot.py +775 -75
- xslope/plot_fem.py +1 -69
- xslope/plot_seep.py +17 -75
- xslope/seep.py +20 -15
- xslope/slice.py +31 -9
- xslope/solve.py +20 -8
- {xslope-0.1.11.dist-info → xslope-0.1.13.dist-info}/METADATA +1 -1
- xslope-0.1.13.dist-info/RECORD +21 -0
- xslope-0.1.11.dist-info/RECORD +0 -21
- {xslope-0.1.11.dist-info → xslope-0.1.13.dist-info}/LICENSE +0 -0
- {xslope-0.1.11.dist-info → xslope-0.1.13.dist-info}/NOTICE +0 -0
- {xslope-0.1.11.dist-info → xslope-0.1.13.dist-info}/WHEEL +0 -0
- {xslope-0.1.11.dist-info → xslope-0.1.13.dist-info}/top_level.txt +0 -0
xslope/plot.py
CHANGED
|
@@ -17,6 +17,7 @@ import numpy as np
|
|
|
17
17
|
from matplotlib.lines import Line2D
|
|
18
18
|
from matplotlib.path import Path
|
|
19
19
|
from shapely.geometry import LineString
|
|
20
|
+
import math
|
|
20
21
|
|
|
21
22
|
from .slice import generate_failure_surface
|
|
22
23
|
|
|
@@ -29,8 +30,9 @@ plt.rcParams.update({
|
|
|
29
30
|
|
|
30
31
|
# Consistent color for materials (Tableau tab10)
|
|
31
32
|
def get_material_color(idx):
|
|
32
|
-
|
|
33
|
-
|
|
33
|
+
cmap = plt.get_cmap("tab10")
|
|
34
|
+
# Use the colormap callable rather than relying on the (typing-unknown) `.colors` attribute.
|
|
35
|
+
return cmap(idx % getattr(cmap, "N", 10))
|
|
34
36
|
|
|
35
37
|
def get_dload_legend_handler():
|
|
36
38
|
"""
|
|
@@ -52,20 +54,142 @@ def get_dload_legend_handler():
|
|
|
52
54
|
return None, dummy_line
|
|
53
55
|
|
|
54
56
|
|
|
55
|
-
def plot_profile_lines(ax, profile_lines):
|
|
57
|
+
def plot_profile_lines(ax, profile_lines, materials=None, labels=False):
|
|
56
58
|
"""
|
|
57
59
|
Plots the profile lines for each material in the slope.
|
|
58
60
|
|
|
59
61
|
Parameters:
|
|
60
62
|
ax: matplotlib Axes object
|
|
61
|
-
profile_lines: List of line
|
|
63
|
+
profile_lines: List of profile line dicts, each with 'coords' and 'mat_id' keys
|
|
64
|
+
materials: List of material dictionaries (optional, for color mapping)
|
|
65
|
+
labels: If True, add index labels to each profile line (default: False)
|
|
62
66
|
|
|
63
67
|
Returns:
|
|
64
68
|
None
|
|
65
69
|
"""
|
|
66
70
|
for i, line in enumerate(profile_lines):
|
|
67
|
-
|
|
68
|
-
|
|
71
|
+
coords = line['coords']
|
|
72
|
+
xs, ys = zip(*coords)
|
|
73
|
+
|
|
74
|
+
# Get material index from mat_id (already 0-based)
|
|
75
|
+
if materials and line.get('mat_id') is not None:
|
|
76
|
+
mat_idx = line['mat_id']
|
|
77
|
+
if 0 <= mat_idx < len(materials):
|
|
78
|
+
color = get_material_color(mat_idx)
|
|
79
|
+
else:
|
|
80
|
+
# Fallback to index-based color if mat_id out of range
|
|
81
|
+
color = get_material_color(i)
|
|
82
|
+
else:
|
|
83
|
+
# Fallback to index-based color if no materials or mat_id
|
|
84
|
+
color = get_material_color(i)
|
|
85
|
+
|
|
86
|
+
ax.plot(xs, ys, color=color, linewidth=1, label=f'Profile {i+1}')
|
|
87
|
+
|
|
88
|
+
if labels:
|
|
89
|
+
_add_profile_index_label(ax, coords, i + 1, color)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _add_profile_index_label(ax, line, index, color):
|
|
93
|
+
"""
|
|
94
|
+
Adds an index label to a profile line, positioned on a suitable segment.
|
|
95
|
+
|
|
96
|
+
Parameters:
|
|
97
|
+
ax: matplotlib Axes object
|
|
98
|
+
line: List of (x, y) coordinates for the profile line
|
|
99
|
+
index: The index number to display (1-based)
|
|
100
|
+
color: Color for the label text
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
None
|
|
104
|
+
"""
|
|
105
|
+
if len(line) < 2:
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
# Build list of segments with their properties
|
|
109
|
+
segments = []
|
|
110
|
+
for j in range(len(line) - 1):
|
|
111
|
+
x1, y1 = line[j]
|
|
112
|
+
x2, y2 = line[j + 1]
|
|
113
|
+
dx = x2 - x1
|
|
114
|
+
dy = y2 - y1
|
|
115
|
+
length = np.sqrt(dx**2 + dy**2)
|
|
116
|
+
|
|
117
|
+
if length < 1e-9:
|
|
118
|
+
continue
|
|
119
|
+
|
|
120
|
+
# Calculate how horizontal the segment is (1.0 = perfectly horizontal)
|
|
121
|
+
horizontalness = abs(dx) / length
|
|
122
|
+
|
|
123
|
+
# Midpoint of the segment
|
|
124
|
+
mid_x = (x1 + x2) / 2
|
|
125
|
+
mid_y = (y1 + y2) / 2
|
|
126
|
+
|
|
127
|
+
segments.append({
|
|
128
|
+
'length': length,
|
|
129
|
+
'horizontalness': horizontalness,
|
|
130
|
+
'mid_x': mid_x,
|
|
131
|
+
'mid_y': mid_y,
|
|
132
|
+
'position': j # segment index in the line
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
if not segments:
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
# Calculate the total line length and find segments in the middle third
|
|
139
|
+
total_length = sum(s['length'] for s in segments)
|
|
140
|
+
|
|
141
|
+
# Score segments: prefer longer, more horizontal segments near the middle
|
|
142
|
+
# Avoid very short segments (less than 5% of total length)
|
|
143
|
+
min_length_threshold = 0.05 * total_length
|
|
144
|
+
|
|
145
|
+
n_segments = len(segments)
|
|
146
|
+
best_segment = None
|
|
147
|
+
best_score = -1
|
|
148
|
+
|
|
149
|
+
for seg in segments:
|
|
150
|
+
# Skip very short segments
|
|
151
|
+
if seg['length'] < min_length_threshold:
|
|
152
|
+
continue
|
|
153
|
+
|
|
154
|
+
# Calculate how close to the middle this segment is (0-1 scale, 1 = center)
|
|
155
|
+
position_ratio = (seg['position'] + 0.5) / n_segments
|
|
156
|
+
middle_score = 1.0 - 2.0 * abs(position_ratio - 0.5) # 1.0 at center, 0.0 at ends
|
|
157
|
+
|
|
158
|
+
# Score: weight length, horizontalness, and middle position
|
|
159
|
+
# Length is normalized by total length
|
|
160
|
+
length_score = seg['length'] / total_length
|
|
161
|
+
|
|
162
|
+
# Combined score: prioritize horizontalness, then middle position, then length
|
|
163
|
+
score = (seg['horizontalness'] * 2.0 +
|
|
164
|
+
middle_score * 1.5 +
|
|
165
|
+
length_score * 1.0)
|
|
166
|
+
|
|
167
|
+
if score > best_score:
|
|
168
|
+
best_score = score
|
|
169
|
+
best_segment = seg
|
|
170
|
+
|
|
171
|
+
# Fallback: if no segment passed the threshold, use the longest one
|
|
172
|
+
if best_segment is None:
|
|
173
|
+
best_segment = max(segments, key=lambda s: s['length'])
|
|
174
|
+
|
|
175
|
+
# Place the label at the midpoint of the chosen segment
|
|
176
|
+
ax.text(
|
|
177
|
+
best_segment['mid_x'],
|
|
178
|
+
best_segment['mid_y'],
|
|
179
|
+
str(index),
|
|
180
|
+
fontsize=7,
|
|
181
|
+
color=color,
|
|
182
|
+
fontfamily='monospace',
|
|
183
|
+
ha='center',
|
|
184
|
+
va='center',
|
|
185
|
+
bbox=dict(
|
|
186
|
+
boxstyle='round,pad=0.3',
|
|
187
|
+
facecolor='white',
|
|
188
|
+
edgecolor=color,
|
|
189
|
+
linewidth=0.8
|
|
190
|
+
),
|
|
191
|
+
zorder=10
|
|
192
|
+
)
|
|
69
193
|
|
|
70
194
|
def plot_max_depth(ax, profile_lines, max_depth):
|
|
71
195
|
"""
|
|
@@ -73,7 +197,7 @@ def plot_max_depth(ax, profile_lines, max_depth):
|
|
|
73
197
|
|
|
74
198
|
Parameters:
|
|
75
199
|
ax: matplotlib Axes object
|
|
76
|
-
profile_lines: List of line
|
|
200
|
+
profile_lines: List of profile line dicts, each with 'coords' key containing coordinate tuples
|
|
77
201
|
max_depth: Maximum allowed depth for analysis
|
|
78
202
|
|
|
79
203
|
Returns:
|
|
@@ -81,7 +205,7 @@ def plot_max_depth(ax, profile_lines, max_depth):
|
|
|
81
205
|
"""
|
|
82
206
|
if max_depth is None:
|
|
83
207
|
return
|
|
84
|
-
x_vals = [x for line in profile_lines for x, _ in line]
|
|
208
|
+
x_vals = [x for line in profile_lines for x, _ in line['coords']]
|
|
85
209
|
x_min = min(x_vals)
|
|
86
210
|
x_max = max(x_vals)
|
|
87
211
|
ax.hlines(max_depth, x_min, x_max, colors='black', linewidth=1.5, label='Max Depth')
|
|
@@ -173,6 +297,26 @@ def plot_piezo_line(ax, slope_data):
|
|
|
173
297
|
None
|
|
174
298
|
"""
|
|
175
299
|
|
|
300
|
+
def _plot_touching_v_marker(ax, x, y, color, markersize=8, extra_gap_points=0.0):
|
|
301
|
+
"""
|
|
302
|
+
Place an inverted triangle marker so its tip visually touches the line at (x, y).
|
|
303
|
+
We do this in display coordinates (points/pixels) so it scales consistently.
|
|
304
|
+
"""
|
|
305
|
+
from matplotlib.markers import MarkerStyle
|
|
306
|
+
from matplotlib.transforms import offset_copy
|
|
307
|
+
|
|
308
|
+
# Compute the distance (in "marker units") from the marker origin to the tip.
|
|
309
|
+
# Matplotlib scales marker vertices by `markersize` (in points) for Line2D.
|
|
310
|
+
ms = MarkerStyle("v")
|
|
311
|
+
path = ms.get_path().transformed(ms.get_transform())
|
|
312
|
+
verts = np.asarray(path.vertices)
|
|
313
|
+
min_y = float(verts[:, 1].min()) # tip is the lowest y
|
|
314
|
+
tip_offset_points = (-min_y) * float(markersize) + float(extra_gap_points)
|
|
315
|
+
|
|
316
|
+
# Offset the marker center upward in point units so the tip lands at (x, y).
|
|
317
|
+
trans = offset_copy(ax.transData, fig=ax.figure, x=0.0, y=tip_offset_points, units="points")
|
|
318
|
+
ax.plot([x], [y], marker="v", color=color, markersize=markersize, linestyle="None", transform=trans)
|
|
319
|
+
|
|
176
320
|
def plot_single_piezo_line(ax, piezo_line, color, label):
|
|
177
321
|
"""Internal function to plot a single piezometric line"""
|
|
178
322
|
if not piezo_line:
|
|
@@ -182,20 +326,146 @@ def plot_piezo_line(ax, slope_data):
|
|
|
182
326
|
ax.plot(piezo_xs, piezo_ys, color=color, linewidth=2, label=label)
|
|
183
327
|
|
|
184
328
|
# Find middle x-coordinate and corresponding y value
|
|
185
|
-
x_min, x_max = min(piezo_xs), max(piezo_xs)
|
|
186
|
-
mid_x = (x_min + x_max) / 2
|
|
187
|
-
|
|
188
|
-
# Interpolate y value at mid_x
|
|
189
|
-
from scipy.interpolate import interp1d
|
|
190
329
|
if len(piezo_xs) > 1:
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
330
|
+
# Sort by x to ensure monotonic input for interpolation
|
|
331
|
+
pairs = sorted(zip(piezo_xs, piezo_ys), key=lambda p: p[0])
|
|
332
|
+
sx, sy = zip(*pairs)
|
|
333
|
+
x_min, x_max = min(sx), max(sx)
|
|
334
|
+
mid_x = (x_min + x_max) / 2
|
|
335
|
+
mid_y = float(np.interp(mid_x, sx, sy))
|
|
336
|
+
# Slight negative gap so the marker visually "touches" the line (not floating above it)
|
|
337
|
+
_plot_touching_v_marker(ax, mid_x, mid_y, color=color, markersize=8, extra_gap_points=2.0)
|
|
194
338
|
|
|
195
339
|
# Plot both piezometric lines
|
|
196
340
|
plot_single_piezo_line(ax, slope_data.get('piezo_line'), 'b', "Piezometric Line")
|
|
197
341
|
plot_single_piezo_line(ax, slope_data.get('piezo_line2'), 'skyblue', "Piezometric Line 2")
|
|
198
342
|
|
|
343
|
+
def plot_seepage_bc_lines(ax, slope_data):
|
|
344
|
+
"""
|
|
345
|
+
Plots seep boundary-condition lines for seep-only workflows.
|
|
346
|
+
|
|
347
|
+
- Specified head geometry: solid dark blue, thicker than profile lines
|
|
348
|
+
- Exit face geometry: solid red
|
|
349
|
+
- Derived "water level" line for each specified head: y = h
|
|
350
|
+
plotted as a lighter blue solid line with an inverted triangle marker
|
|
351
|
+
(styled similarly to piezometric line markers).
|
|
352
|
+
"""
|
|
353
|
+
def _plot_touching_v_marker(ax, x, y, color, markersize=8, extra_gap_points=2.0):
|
|
354
|
+
"""Place an inverted triangle so its tip visually sits on the line at (x, y)."""
|
|
355
|
+
from matplotlib.markers import MarkerStyle
|
|
356
|
+
from matplotlib.transforms import offset_copy
|
|
357
|
+
|
|
358
|
+
ms = MarkerStyle("v")
|
|
359
|
+
path = ms.get_path().transformed(ms.get_transform())
|
|
360
|
+
verts = np.asarray(path.vertices)
|
|
361
|
+
min_y = float(verts[:, 1].min())
|
|
362
|
+
tip_offset_points = (-min_y) * float(markersize) + float(extra_gap_points)
|
|
363
|
+
trans = offset_copy(ax.transData, fig=ax.figure, x=0.0, y=tip_offset_points, units="points")
|
|
364
|
+
ax.plot([x], [y], marker="v", color=color, markersize=markersize, linestyle="None", transform=trans)
|
|
365
|
+
|
|
366
|
+
# Geometry x-extent (used for vertical-head-line derived segment length / side)
|
|
367
|
+
x_vals = []
|
|
368
|
+
for line in slope_data.get("profile_lines", []):
|
|
369
|
+
try:
|
|
370
|
+
xs_line, _ = zip(*line['coords'])
|
|
371
|
+
x_vals.extend(xs_line)
|
|
372
|
+
except Exception:
|
|
373
|
+
pass
|
|
374
|
+
gs = slope_data.get("ground_surface", None)
|
|
375
|
+
if not x_vals and gs is not None and hasattr(gs, "coords"):
|
|
376
|
+
x_vals.extend([x for x, _ in gs.coords])
|
|
377
|
+
x_min_geom = min(x_vals) if x_vals else 0.0
|
|
378
|
+
x_max_geom = max(x_vals) if x_vals else 1.0
|
|
379
|
+
geom_width = max(1e-9, x_max_geom - x_min_geom)
|
|
380
|
+
|
|
381
|
+
seepage_bc = slope_data.get("seepage_bc") or {}
|
|
382
|
+
specified_heads = seepage_bc.get("specified_heads") or []
|
|
383
|
+
exit_face = seepage_bc.get("exit_face") or []
|
|
384
|
+
|
|
385
|
+
# --- Specified head lines + derived water-level lines ---
|
|
386
|
+
for i, sh in enumerate(specified_heads):
|
|
387
|
+
coords = sh.get("coords") or []
|
|
388
|
+
if len(coords) < 2:
|
|
389
|
+
continue
|
|
390
|
+
|
|
391
|
+
xs, ys = zip(*coords)
|
|
392
|
+
ax.plot(
|
|
393
|
+
xs,
|
|
394
|
+
ys,
|
|
395
|
+
color="darkblue",
|
|
396
|
+
linewidth=3,
|
|
397
|
+
linestyle="--",
|
|
398
|
+
label="Specified Head Line" if i == 0 else "",
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
# Head values may be scalar (typical) or per-point array-like
|
|
402
|
+
head_val = sh.get("head", None)
|
|
403
|
+
if head_val is None:
|
|
404
|
+
continue
|
|
405
|
+
|
|
406
|
+
if isinstance(head_val, (list, tuple, np.ndarray)):
|
|
407
|
+
if len(head_val) != len(coords):
|
|
408
|
+
continue
|
|
409
|
+
heads = [float(h) for h in head_val]
|
|
410
|
+
else:
|
|
411
|
+
try:
|
|
412
|
+
head_scalar = float(head_val)
|
|
413
|
+
except (TypeError, ValueError):
|
|
414
|
+
continue
|
|
415
|
+
heads = [head_scalar] * len(coords)
|
|
416
|
+
|
|
417
|
+
# If specified-head geometry is vertical, draw a short horizontal derived line outside the boundary.
|
|
418
|
+
# This avoids drawing a (nearly) vertical derived line that doesn't convey a water level.
|
|
419
|
+
tol = 1e-6
|
|
420
|
+
is_vertical = (max(xs) - min(xs)) <= tol
|
|
421
|
+
if is_vertical:
|
|
422
|
+
x0 = float(xs[0])
|
|
423
|
+
y_head = float(heads[0])
|
|
424
|
+
seg_len = 0.04 * geom_width
|
|
425
|
+
gap = 0.01 * geom_width
|
|
426
|
+
is_right = x0 >= 0.5 * (x_min_geom + x_max_geom)
|
|
427
|
+
if is_right:
|
|
428
|
+
wl_xs = [x0 + gap, x0 + gap + seg_len]
|
|
429
|
+
else:
|
|
430
|
+
wl_xs = [x0 - gap - seg_len, x0 - gap]
|
|
431
|
+
wl_ys = [y_head, y_head]
|
|
432
|
+
else:
|
|
433
|
+
wl_xs = list(xs)
|
|
434
|
+
wl_ys = heads
|
|
435
|
+
|
|
436
|
+
ax.plot(
|
|
437
|
+
wl_xs,
|
|
438
|
+
wl_ys,
|
|
439
|
+
color="lightskyblue",
|
|
440
|
+
linewidth=2,
|
|
441
|
+
linestyle="-",
|
|
442
|
+
label="Specified Head Water Level" if i == 0 else "",
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
# Inverted triangle marker near the midpoint (like piezometric lines)
|
|
446
|
+
if len(wl_xs) > 1:
|
|
447
|
+
try:
|
|
448
|
+
pairs = sorted(zip(wl_xs, wl_ys), key=lambda p: p[0])
|
|
449
|
+
sx, sy = zip(*pairs)
|
|
450
|
+
mid_x = 0.5 * (min(sx) + max(sx))
|
|
451
|
+
mid_y = float(np.interp(mid_x, sx, sy))
|
|
452
|
+
_plot_touching_v_marker(ax, mid_x, mid_y, color="lightskyblue", markersize=8, extra_gap_points=2.0)
|
|
453
|
+
except Exception:
|
|
454
|
+
# If interpolation fails for any reason, skip marker
|
|
455
|
+
pass
|
|
456
|
+
|
|
457
|
+
# --- Exit face line ---
|
|
458
|
+
if len(exit_face) >= 2:
|
|
459
|
+
ex_xs, ex_ys = zip(*exit_face)
|
|
460
|
+
ax.plot(
|
|
461
|
+
ex_xs,
|
|
462
|
+
ex_ys,
|
|
463
|
+
color="red",
|
|
464
|
+
linewidth=3,
|
|
465
|
+
linestyle="--",
|
|
466
|
+
label="Exit Face",
|
|
467
|
+
)
|
|
468
|
+
|
|
199
469
|
def plot_tcrack_surface(ax, tcrack_surface):
|
|
200
470
|
"""
|
|
201
471
|
Plots the tension crack surface as a thin dashed red line.
|
|
@@ -381,6 +651,8 @@ def plot_circles(ax, slope_data):
|
|
|
381
651
|
continue # or handle error
|
|
382
652
|
# result = (x_min, x_max, y_left, y_right, clipped_surface)
|
|
383
653
|
x_min, x_max, y_left, y_right, clipped_surface = result
|
|
654
|
+
if not isinstance(clipped_surface, LineString):
|
|
655
|
+
clipped_surface = LineString(clipped_surface)
|
|
384
656
|
x_clip, y_clip = zip(*clipped_surface.coords)
|
|
385
657
|
ax.plot(x_clip, y_clip, 'r--', label="Circle")
|
|
386
658
|
|
|
@@ -423,15 +695,28 @@ def plot_non_circ(ax, non_circ):
|
|
|
423
695
|
xs, ys = zip(*non_circ)
|
|
424
696
|
ax.plot(xs, ys, 'r--', label='Non-Circular Surface')
|
|
425
697
|
|
|
426
|
-
def
|
|
698
|
+
def plot_lem_material_table(ax, materials, xloc=0.6, yloc=0.7):
|
|
427
699
|
"""
|
|
428
|
-
Adds a material properties table to the plot.
|
|
700
|
+
Adds a limit equilibrium material properties table to the plot.
|
|
701
|
+
|
|
702
|
+
Displays soil properties for limit equilibrium analysis including unit weight (γ),
|
|
703
|
+
cohesion (c), friction angle (φ), and optionally dilation angle (d) and
|
|
704
|
+
dilatancy angle (ψ). Supports both Mohr-Coulomb (mc) and constant-phi (cp) options.
|
|
429
705
|
|
|
430
706
|
Parameters:
|
|
431
|
-
ax: matplotlib Axes object
|
|
432
|
-
materials: List of material property dictionaries
|
|
433
|
-
|
|
434
|
-
|
|
707
|
+
ax: matplotlib Axes object to add the table to
|
|
708
|
+
materials: List of material property dictionaries with keys:
|
|
709
|
+
- 'name': Material name (str)
|
|
710
|
+
- 'gamma': Unit weight (float)
|
|
711
|
+
- 'option': Material model - 'mc' or 'cp' (str)
|
|
712
|
+
- 'c': Cohesion for mc option (float)
|
|
713
|
+
- 'phi': Friction angle for mc option (float)
|
|
714
|
+
- 'cp': Constant phi for cp option (float)
|
|
715
|
+
- 'r_elev': Reference elevation for cp option (float)
|
|
716
|
+
- 'd': Dilation angle, optional (float)
|
|
717
|
+
- 'psi': Dilatancy angle, optional (float)
|
|
718
|
+
xloc: x-location of table bottom-left corner in axes coordinates (0-1, default: 0.6)
|
|
719
|
+
yloc: y-location of table bottom-left corner in axes coordinates (0-1, default: 0.7)
|
|
435
720
|
|
|
436
721
|
Returns:
|
|
437
722
|
None
|
|
@@ -505,15 +790,239 @@ def plot_material_table(ax, materials, xloc=0.6, yloc=0.7):
|
|
|
505
790
|
# Adjust table width based on number of columns
|
|
506
791
|
table_width = 0.25 if has_d_psi else 0.2
|
|
507
792
|
|
|
793
|
+
# Choose table height based on number of materials (uniform across table types)
|
|
794
|
+
num_rows = max(1, len(materials))
|
|
795
|
+
table_height = 0.06 + 0.035 * num_rows # header + per-row estimate
|
|
796
|
+
table_height = min(0.35, table_height) # cap to avoid overflows for many rows
|
|
797
|
+
|
|
508
798
|
# Add the table
|
|
509
799
|
table = ax.table(cellText=table_data,
|
|
510
800
|
colLabels=col_labels,
|
|
511
801
|
loc='upper right',
|
|
512
802
|
colLoc='center',
|
|
513
803
|
cellLoc='center',
|
|
514
|
-
bbox=[xloc, yloc, table_width,
|
|
804
|
+
bbox=[xloc, yloc, table_width, table_height])
|
|
515
805
|
table.auto_set_font_size(False)
|
|
516
806
|
table.set_fontsize(8)
|
|
807
|
+
# Auto layout based on content (shared method for all table types)
|
|
808
|
+
auto_size_table_to_content(ax, table, col_labels, table_data, table_width, table_height)
|
|
809
|
+
|
|
810
|
+
def plot_seep_material_table(ax, seep_data, xloc=0.6, yloc=0.7):
|
|
811
|
+
"""
|
|
812
|
+
Adds a seep material properties table to the plot.
|
|
813
|
+
|
|
814
|
+
Displays hydraulic properties for seep analysis including hydraulic conductivities
|
|
815
|
+
(k₁, k₂), anisotropy angle, and unsaturated flow parameters (kr₀, h₀).
|
|
816
|
+
|
|
817
|
+
Parameters:
|
|
818
|
+
ax: matplotlib Axes object to add the table to
|
|
819
|
+
seep_data: Dictionary containing seep material properties with keys:
|
|
820
|
+
- 'k1_by_mat': List of primary hydraulic conductivity values (float)
|
|
821
|
+
- 'k2_by_mat': List of secondary hydraulic conductivity values (float)
|
|
822
|
+
- 'angle_by_mat': List of anisotropy angles in degrees (float)
|
|
823
|
+
- 'kr0_by_mat': List of relative permeability at residual saturation (float)
|
|
824
|
+
- 'h0_by_mat': List of pressure head parameters (float)
|
|
825
|
+
- 'material_names': List of material names (str), optional
|
|
826
|
+
xloc: x-location of table bottom-left corner in axes coordinates (0-1, default: 0.6)
|
|
827
|
+
yloc: y-location of table bottom-left corner in axes coordinates (0-1, default: 0.7)
|
|
828
|
+
|
|
829
|
+
Returns:
|
|
830
|
+
None
|
|
831
|
+
"""
|
|
832
|
+
k1_by_mat = seep_data.get("k1_by_mat")
|
|
833
|
+
k2_by_mat = seep_data.get("k2_by_mat")
|
|
834
|
+
angle_by_mat = seep_data.get("angle_by_mat")
|
|
835
|
+
kr0_by_mat = seep_data.get("kr0_by_mat")
|
|
836
|
+
h0_by_mat = seep_data.get("h0_by_mat")
|
|
837
|
+
material_names = seep_data.get("material_names", [])
|
|
838
|
+
if k1_by_mat is None or len(k1_by_mat) == 0:
|
|
839
|
+
return
|
|
840
|
+
col_labels = ["Mat", "Name", "k₁", "k₂", "Angle", "kr₀", "h₀"]
|
|
841
|
+
table_data = []
|
|
842
|
+
for idx in range(len(k1_by_mat)):
|
|
843
|
+
k1 = k1_by_mat[idx]
|
|
844
|
+
k2 = k2_by_mat[idx] if k2_by_mat is not None else 0.0
|
|
845
|
+
angle = angle_by_mat[idx] if angle_by_mat is not None else 0.0
|
|
846
|
+
kr0 = kr0_by_mat[idx] if kr0_by_mat is not None else 0.0
|
|
847
|
+
h0 = h0_by_mat[idx] if h0_by_mat is not None else 0.0
|
|
848
|
+
material_name = material_names[idx] if idx < len(material_names) else f"Material {idx+1}"
|
|
849
|
+
row = [idx + 1, material_name, f"{k1:.3f}", f"{k2:.3f}", f"{angle:.1f}", f"{kr0:.4f}", f"{h0:.2f}"]
|
|
850
|
+
table_data.append(row)
|
|
851
|
+
# Dimensions
|
|
852
|
+
num_rows = max(1, len(k1_by_mat))
|
|
853
|
+
table_width = 0.45
|
|
854
|
+
table_height = 0.10 + 0.06 * num_rows
|
|
855
|
+
table_height = min(0.50, table_height)
|
|
856
|
+
table = ax.table(cellText=table_data,
|
|
857
|
+
colLabels=col_labels,
|
|
858
|
+
loc='upper right',
|
|
859
|
+
colLoc='center',
|
|
860
|
+
cellLoc='center',
|
|
861
|
+
bbox=[xloc, yloc, table_width, table_height])
|
|
862
|
+
table.auto_set_font_size(False)
|
|
863
|
+
table.set_fontsize(8)
|
|
864
|
+
auto_size_table_to_content(ax, table, col_labels, table_data, table_width, table_height)
|
|
865
|
+
|
|
866
|
+
def plot_fem_material_table(ax, fem_data, xloc=0.6, yloc=0.7, width=0.6, height=None):
|
|
867
|
+
"""
|
|
868
|
+
Adds a finite element material properties table to the plot.
|
|
869
|
+
|
|
870
|
+
Displays material properties for FEM analysis including unit weight (γ), cohesion (c),
|
|
871
|
+
friction angle (φ), Young's modulus (E), and Poisson's ratio (ν).
|
|
872
|
+
|
|
873
|
+
Parameters:
|
|
874
|
+
ax: matplotlib Axes object to add the table to
|
|
875
|
+
fem_data: Dictionary containing FEM material properties with keys:
|
|
876
|
+
- 'c_by_mat': List of cohesion values (float)
|
|
877
|
+
- 'phi_by_mat': List of friction angle values in degrees (float)
|
|
878
|
+
- 'E_by_mat': List of Young's modulus values (float)
|
|
879
|
+
- 'nu_by_mat': List of Poisson's ratio values (float)
|
|
880
|
+
- 'gamma_by_mat': List of unit weight values (float)
|
|
881
|
+
- 'material_names': List of material names (str), optional
|
|
882
|
+
xloc: x-location of table bottom-left corner in axes coordinates (0-1, default: 0.6)
|
|
883
|
+
yloc: y-location of table bottom-left corner in axes coordinates (0-1, default: 0.7)
|
|
884
|
+
width: Table width in axes coordinates (0-1, default: 0.6)
|
|
885
|
+
height: Table height in axes coordinates (0-1, default: auto-calculated)
|
|
886
|
+
|
|
887
|
+
Returns:
|
|
888
|
+
None
|
|
889
|
+
"""
|
|
890
|
+
c_by_mat = fem_data.get("c_by_mat")
|
|
891
|
+
phi_by_mat = fem_data.get("phi_by_mat")
|
|
892
|
+
E_by_mat = fem_data.get("E_by_mat")
|
|
893
|
+
nu_by_mat = fem_data.get("nu_by_mat")
|
|
894
|
+
gamma_by_mat = fem_data.get("gamma_by_mat")
|
|
895
|
+
material_names = fem_data.get("material_names", [])
|
|
896
|
+
if c_by_mat is None or len(c_by_mat) == 0:
|
|
897
|
+
return
|
|
898
|
+
col_labels = ["Mat", "Name", "γ", "c", "φ", "E", "ν"]
|
|
899
|
+
table_data = []
|
|
900
|
+
for idx in range(len(c_by_mat)):
|
|
901
|
+
c = c_by_mat[idx]
|
|
902
|
+
phi = phi_by_mat[idx] if phi_by_mat is not None else 0.0
|
|
903
|
+
E = E_by_mat[idx] if E_by_mat is not None else 0.0
|
|
904
|
+
nu = nu_by_mat[idx] if nu_by_mat is not None else 0.0
|
|
905
|
+
gamma = gamma_by_mat[idx] if gamma_by_mat is not None else 0.0
|
|
906
|
+
material_name = material_names[idx] if idx < len(material_names) else f"Material {idx+1}"
|
|
907
|
+
row = [idx + 1, material_name, f"{gamma:.1f}", f"{c:.1f}", f"{phi:.1f}", f"{E:.0f}", f"{nu:.2f}"]
|
|
908
|
+
table_data.append(row)
|
|
909
|
+
if height is None:
|
|
910
|
+
num_rows = max(1, len(c_by_mat))
|
|
911
|
+
height = 0.06 + 0.035 * num_rows
|
|
912
|
+
height = min(0.32, height)
|
|
913
|
+
table = ax.table(cellText=table_data,
|
|
914
|
+
colLabels=col_labels,
|
|
915
|
+
loc='upper right',
|
|
916
|
+
colLoc='center',
|
|
917
|
+
cellLoc='center',
|
|
918
|
+
bbox=[xloc, yloc, width, height])
|
|
919
|
+
table.auto_set_font_size(False)
|
|
920
|
+
table.set_fontsize(8)
|
|
921
|
+
auto_size_table_to_content(ax, table, col_labels, table_data, width, height)
|
|
922
|
+
def auto_size_table_to_content(ax, table, col_labels, table_data, table_width, table_height, min_row_frac=0.02, row_pad=1.35, col_min_frac=0.08, col_max_frac=0.15):
|
|
923
|
+
"""
|
|
924
|
+
Automatically adjusts table column widths and row heights based on content.
|
|
925
|
+
|
|
926
|
+
Measures text extents using the matplotlib renderer and sets column widths proportional
|
|
927
|
+
to content while enforcing minimum and maximum constraints. The "Name" column gets
|
|
928
|
+
more space (18-30%) while numeric columns are constrained to prevent excessive whitespace.
|
|
929
|
+
Row heights are uniform and based on the tallest content in each row.
|
|
930
|
+
|
|
931
|
+
Parameters:
|
|
932
|
+
ax: matplotlib Axes object containing the table
|
|
933
|
+
table: matplotlib Table object to be resized
|
|
934
|
+
col_labels: List of column header labels (str)
|
|
935
|
+
table_data: List of lists containing table cell data
|
|
936
|
+
table_width: Total table width in axes coordinates (0-1)
|
|
937
|
+
table_height: Total table height in axes coordinates (0-1)
|
|
938
|
+
min_row_frac: Minimum row height as fraction of axes height (default: 0.02)
|
|
939
|
+
row_pad: Padding factor applied to measured row heights (default: 1.35)
|
|
940
|
+
col_min_frac: Minimum column width as fraction of table width for numeric columns (default: 0.08)
|
|
941
|
+
col_max_frac: Maximum column width as fraction of table width for numeric columns (default: 0.15)
|
|
942
|
+
|
|
943
|
+
Returns:
|
|
944
|
+
None
|
|
945
|
+
|
|
946
|
+
Notes:
|
|
947
|
+
- The "Name" column is automatically left-aligned and gets 18-30% of table width
|
|
948
|
+
- Numeric columns are center-aligned and constrained to 8-15% of table width
|
|
949
|
+
- All three material table types (LEM, SEEP, FEM) use the same sizing parameters
|
|
950
|
+
"""
|
|
951
|
+
# Force draw to get a valid renderer
|
|
952
|
+
try:
|
|
953
|
+
ax.figure.canvas.draw()
|
|
954
|
+
renderer = ax.figure.canvas.get_renderer()
|
|
955
|
+
except Exception:
|
|
956
|
+
renderer = None
|
|
957
|
+
|
|
958
|
+
ncols = len(col_labels)
|
|
959
|
+
nrows = len(table_data) + 1 # include header
|
|
960
|
+
# Measure text widths per column in pixels
|
|
961
|
+
widths_px = [1.0] * ncols
|
|
962
|
+
if renderer is not None:
|
|
963
|
+
for c in range(ncols):
|
|
964
|
+
max_w = 1.0
|
|
965
|
+
for r in range(nrows):
|
|
966
|
+
cell = table[(r, c)]
|
|
967
|
+
text = cell.get_text()
|
|
968
|
+
try:
|
|
969
|
+
bbox = text.get_window_extent(renderer=renderer)
|
|
970
|
+
max_w = max(max_w, bbox.width)
|
|
971
|
+
except Exception:
|
|
972
|
+
pass
|
|
973
|
+
widths_px[c] = max_w
|
|
974
|
+
total_w = sum(widths_px) if sum(widths_px) > 0 else float(ncols)
|
|
975
|
+
col_fracs = [w / total_w for w in widths_px]
|
|
976
|
+
# Clamp extreme column widths to keep numeric columns from becoming too wide
|
|
977
|
+
clamped = []
|
|
978
|
+
for i, frac in enumerate(col_fracs):
|
|
979
|
+
label = str(col_labels[i]).lower()
|
|
980
|
+
min_frac = col_min_frac
|
|
981
|
+
max_frac = col_max_frac
|
|
982
|
+
if label == "name":
|
|
983
|
+
min_frac = 0.18
|
|
984
|
+
max_frac = 0.30
|
|
985
|
+
clamped.append(min(max(frac, min_frac), max_frac))
|
|
986
|
+
# Re-normalize to sum to 1.0
|
|
987
|
+
s = sum(clamped)
|
|
988
|
+
if s > 0:
|
|
989
|
+
col_fracs = [c / s for c in clamped]
|
|
990
|
+
# Compute per-row pixel heights based on text extents, convert to axes fraction
|
|
991
|
+
axes_h_px = None
|
|
992
|
+
if renderer is not None:
|
|
993
|
+
try:
|
|
994
|
+
axes_h_px = ax.get_window_extent(renderer=renderer).height
|
|
995
|
+
except Exception:
|
|
996
|
+
axes_h_px = None
|
|
997
|
+
# Fallback axes height if needed (avoid division by zero)
|
|
998
|
+
if not axes_h_px or axes_h_px <= 0:
|
|
999
|
+
axes_h_px = 800.0 # arbitrary but reasonable default
|
|
1000
|
+
row_heights_frac = []
|
|
1001
|
+
for r in range(nrows):
|
|
1002
|
+
max_h_px = 1.0
|
|
1003
|
+
if renderer is not None:
|
|
1004
|
+
for c in range(ncols):
|
|
1005
|
+
try:
|
|
1006
|
+
bbox = table[(r, c)].get_text().get_window_extent(renderer=renderer)
|
|
1007
|
+
max_h_px = max(max_h_px, bbox.height)
|
|
1008
|
+
except Exception:
|
|
1009
|
+
pass
|
|
1010
|
+
# padding factor to provide breathing room around text
|
|
1011
|
+
padded_px = max_h_px * row_pad
|
|
1012
|
+
# Convert to axes fraction with minimum clamp
|
|
1013
|
+
rh = max(padded_px / axes_h_px, min_row_frac)
|
|
1014
|
+
row_heights_frac.append(rh)
|
|
1015
|
+
|
|
1016
|
+
# Apply column widths and per-row heights
|
|
1017
|
+
for r in range(nrows):
|
|
1018
|
+
for c in range(ncols):
|
|
1019
|
+
cell = table[(r, c)]
|
|
1020
|
+
cell.set_width(table_width * col_fracs[c])
|
|
1021
|
+
cell.set_height(row_heights_frac[r])
|
|
1022
|
+
# Left-align the "Name" column if present
|
|
1023
|
+
label = str(col_labels[c]).lower()
|
|
1024
|
+
if label == "name":
|
|
1025
|
+
cell.get_text().set_ha('left')
|
|
517
1026
|
|
|
518
1027
|
def plot_base_stresses(ax, slice_df, scale_frac=0.5, alpha=0.3):
|
|
519
1028
|
"""
|
|
@@ -649,10 +1158,11 @@ def compute_ylim(data, slice_df, scale_frac=0.5, pad_fraction=0.1):
|
|
|
649
1158
|
|
|
650
1159
|
# 1) collect all profile line elevations
|
|
651
1160
|
for line in data.get('profile_lines', []):
|
|
652
|
-
|
|
653
|
-
|
|
1161
|
+
coords = line['coords']
|
|
1162
|
+
if hasattr(coords, "xy"):
|
|
1163
|
+
_, ys = coords.xy
|
|
654
1164
|
else:
|
|
655
|
-
_, ys = zip(*
|
|
1165
|
+
_, ys = zip(*coords)
|
|
656
1166
|
y_vals.extend(ys)
|
|
657
1167
|
|
|
658
1168
|
# 2) explicitly include the deepest allowed depth
|
|
@@ -717,7 +1227,19 @@ def plot_reinforcement_lines(ax, slope_data):
|
|
|
717
1227
|
tension_points_plotted = True
|
|
718
1228
|
|
|
719
1229
|
|
|
720
|
-
def plot_inputs(
|
|
1230
|
+
def plot_inputs(
|
|
1231
|
+
slope_data,
|
|
1232
|
+
title="Slope Geometry and Inputs",
|
|
1233
|
+
figsize=(12, 6),
|
|
1234
|
+
mat_table=True,
|
|
1235
|
+
save_png=False,
|
|
1236
|
+
dpi=300,
|
|
1237
|
+
mode="lem",
|
|
1238
|
+
tab_loc="upper left",
|
|
1239
|
+
legend_ncol="auto",
|
|
1240
|
+
legend_max_cols=6,
|
|
1241
|
+
legend_max_rows=4,
|
|
1242
|
+
):
|
|
721
1243
|
"""
|
|
722
1244
|
Creates a plot showing the slope geometry and input parameters.
|
|
723
1245
|
|
|
@@ -726,11 +1248,32 @@ def plot_inputs(slope_data, title="Slope Geometry and Inputs", figsize=(12, 6),
|
|
|
726
1248
|
title: Title for the plot
|
|
727
1249
|
figsize: Tuple of (width, height) in inches for the plot
|
|
728
1250
|
mat_table: Controls material table display. Can be:
|
|
729
|
-
- True:
|
|
1251
|
+
- True: Use tab_loc for positioning (default)
|
|
730
1252
|
- False: Don't show material table
|
|
731
|
-
- 'auto':
|
|
732
|
-
- String: Specific location
|
|
733
|
-
|
|
1253
|
+
- 'auto': Use tab_loc for positioning
|
|
1254
|
+
- String: Specific location from valid placements (see tab_loc)
|
|
1255
|
+
save_png: If True, save plot as PNG file (default: False)
|
|
1256
|
+
dpi: Resolution for saved PNG file (default: 300)
|
|
1257
|
+
mode: Which material properties table to display:
|
|
1258
|
+
- "lem": Limit equilibrium materials (γ, c, φ, optional d/ψ)
|
|
1259
|
+
- "seep": Seepage properties (k₁, k₂, Angle, kr₀, h₀)
|
|
1260
|
+
- "fem": FEM properties (γ, c, φ, E, ν)
|
|
1261
|
+
tab_loc: Table placement when mat_table is True or 'auto'. Valid options:
|
|
1262
|
+
- "upper left": Top-left corner of plot area
|
|
1263
|
+
- "upper right": Top-right corner of plot area
|
|
1264
|
+
- "upper center": Top-center of plot area
|
|
1265
|
+
- "lower left": Bottom-left corner of plot area
|
|
1266
|
+
- "lower right": Bottom-right corner of plot area
|
|
1267
|
+
- "lower center": Bottom-center of plot area
|
|
1268
|
+
- "center left": Middle-left of plot area
|
|
1269
|
+
- "center right": Middle-right of plot area
|
|
1270
|
+
- "center": Center of plot area
|
|
1271
|
+
- "top": Above plot area, horizontally centered
|
|
1272
|
+
legend_ncol: Legend column count. Use "auto" (default) to choose a value that
|
|
1273
|
+
keeps the legend from getting too tall, or pass an int to force a width.
|
|
1274
|
+
legend_max_cols: When legend_ncol="auto", cap the number of columns (default: 6).
|
|
1275
|
+
legend_max_rows: When legend_ncol="auto", try to keep legend rows <= this value
|
|
1276
|
+
by increasing columns (default: 4).
|
|
734
1277
|
|
|
735
1278
|
Returns:
|
|
736
1279
|
None
|
|
@@ -738,9 +1281,11 @@ def plot_inputs(slope_data, title="Slope Geometry and Inputs", figsize=(12, 6),
|
|
|
738
1281
|
fig, ax = plt.subplots(figsize=figsize)
|
|
739
1282
|
|
|
740
1283
|
# Plot contents
|
|
741
|
-
plot_profile_lines(ax, slope_data['profile_lines'])
|
|
1284
|
+
plot_profile_lines(ax, slope_data['profile_lines'], materials=slope_data.get('materials'), labels=True)
|
|
742
1285
|
plot_max_depth(ax, slope_data['profile_lines'], slope_data['max_depth'])
|
|
743
1286
|
plot_piezo_line(ax, slope_data)
|
|
1287
|
+
if mode == "seep":
|
|
1288
|
+
plot_seepage_bc_lines(ax, slope_data)
|
|
744
1289
|
plot_dloads(ax, slope_data)
|
|
745
1290
|
plot_tcrack_surface(ax, slope_data['tcrack_surface'])
|
|
746
1291
|
plot_reinforcement_lines(ax, slope_data)
|
|
@@ -752,32 +1297,116 @@ def plot_inputs(slope_data, title="Slope Geometry and Inputs", figsize=(12, 6),
|
|
|
752
1297
|
|
|
753
1298
|
# Handle material table display
|
|
754
1299
|
if mat_table:
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
1300
|
+
# Helpers to adapt slope_data materials into formats expected by table functions
|
|
1301
|
+
def _build_seep_data():
|
|
1302
|
+
materials = slope_data.get('materials', [])
|
|
1303
|
+
return {
|
|
1304
|
+
"k1_by_mat": [m.get('k1', 0.0) for m in materials],
|
|
1305
|
+
"k2_by_mat": [m.get('k2', 0.0) for m in materials],
|
|
1306
|
+
"angle_by_mat": [m.get('alpha', 0.0) for m in materials],
|
|
1307
|
+
"kr0_by_mat": [m.get('kr0', 0.0) for m in materials],
|
|
1308
|
+
"h0_by_mat": [m.get('h0', 0.0) for m in materials],
|
|
1309
|
+
"material_names": [m.get('name', f"Material {i+1}") for i, m in enumerate(materials)],
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
def _build_fem_data():
|
|
1313
|
+
materials = slope_data.get('materials', [])
|
|
1314
|
+
return {
|
|
1315
|
+
"c_by_mat": [m.get('c', 0.0) for m in materials],
|
|
1316
|
+
"phi_by_mat": [m.get('phi', 0.0) for m in materials],
|
|
1317
|
+
"E_by_mat": [m.get('E', 0.0) for m in materials],
|
|
1318
|
+
"nu_by_mat": [m.get('nu', 0.0) for m in materials],
|
|
1319
|
+
"gamma_by_mat": [m.get('gamma', 0.0) for m in materials],
|
|
1320
|
+
"material_names": [m.get('name', f"Material {i+1}") for i, m in enumerate(materials)],
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
def _estimate_table_dims():
|
|
1324
|
+
"""Estimate table dimensions based on mode and materials."""
|
|
1325
|
+
materials = slope_data.get('materials', [])
|
|
1326
|
+
num_rows = max(1, len(materials))
|
|
1327
|
+
|
|
1328
|
+
if mode == "lem":
|
|
1329
|
+
has_d_psi = any(mat.get('d', 0) > 0 or mat.get('psi', 0) > 0 for mat in materials)
|
|
1330
|
+
width = 0.25 if has_d_psi else 0.2
|
|
1331
|
+
height = min(0.35, 0.06 + 0.035 * num_rows)
|
|
1332
|
+
elif mode == "fem":
|
|
1333
|
+
width = 0.60
|
|
1334
|
+
height = min(0.32, 0.06 + 0.035 * num_rows)
|
|
1335
|
+
elif mode == "seep":
|
|
1336
|
+
width = 0.45
|
|
1337
|
+
height = min(0.50, 0.10 + 0.06 * num_rows)
|
|
1338
|
+
else:
|
|
1339
|
+
raise ValueError(f"Unknown mode '{mode}'. Expected one of: 'lem', 'seep', 'fem'.")
|
|
1340
|
+
|
|
1341
|
+
return width, height
|
|
1342
|
+
|
|
1343
|
+
def _plot_table(ax, xloc, yloc):
|
|
1344
|
+
"""Plot the appropriate material table based on mode."""
|
|
1345
|
+
if mode == "lem":
|
|
1346
|
+
plot_lem_material_table(ax, slope_data['materials'], xloc=xloc, yloc=yloc)
|
|
1347
|
+
elif mode == "seep":
|
|
1348
|
+
plot_seep_material_table(ax, _build_seep_data(), xloc=xloc, yloc=yloc)
|
|
1349
|
+
elif mode == "fem":
|
|
1350
|
+
width, height = _estimate_table_dims()
|
|
1351
|
+
plot_fem_material_table(ax, _build_fem_data(), xloc=xloc, yloc=yloc, width=width, height=height)
|
|
1352
|
+
|
|
1353
|
+
def _calculate_position(location, margin=0.03):
|
|
1354
|
+
"""Calculate xloc, yloc for a given location string."""
|
|
1355
|
+
width, height = _estimate_table_dims()
|
|
1356
|
+
|
|
1357
|
+
# Location map with default coordinates
|
|
1358
|
+
position_map = {
|
|
1359
|
+
'upper left': (margin, max(0.0, 1.0 - margin - height)),
|
|
1360
|
+
'upper right': (max(0.0, 1.0 - width - margin), max(0.0, 1.0 - margin - height)),
|
|
760
1361
|
'upper center': (0.35, 0.70),
|
|
761
1362
|
'lower left': (0.05, 0.05),
|
|
762
1363
|
'lower right': (0.70, 0.05),
|
|
763
1364
|
'lower center': (0.35, 0.05),
|
|
764
1365
|
'center left': (0.05, 0.35),
|
|
765
1366
|
'center right': (0.70, 0.35),
|
|
766
|
-
'center': (0.35, 0.35)
|
|
1367
|
+
'center': (0.35, 0.35),
|
|
1368
|
+
'top': ((1.0 - width) / 2.0, 1.16)
|
|
767
1369
|
}
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
1370
|
+
|
|
1371
|
+
return position_map.get(location, position_map['upper right'])
|
|
1372
|
+
|
|
1373
|
+
# Determine which location to use
|
|
1374
|
+
placement = mat_table if isinstance(mat_table, str) and mat_table != 'auto' else tab_loc
|
|
1375
|
+
|
|
1376
|
+
# Validate placement
|
|
1377
|
+
valid_placements = ['upper left', 'upper right', 'upper center', 'lower left',
|
|
1378
|
+
'lower right', 'lower center', 'center left', 'center right', 'center', 'top']
|
|
1379
|
+
if placement not in valid_placements:
|
|
1380
|
+
raise ValueError(f"Unknown placement '{placement}'. Expected one of: {', '.join(valid_placements)}.")
|
|
1381
|
+
|
|
1382
|
+
# Calculate position and plot table
|
|
1383
|
+
xloc, yloc = _calculate_position(placement)
|
|
1384
|
+
_plot_table(ax, xloc, yloc)
|
|
1385
|
+
|
|
1386
|
+
# Adjust y-limits to prevent table overlap with plot data
|
|
1387
|
+
if placement in ("upper left", "upper right", "upper center"):
|
|
1388
|
+
_, height = _estimate_table_dims()
|
|
1389
|
+
margin = 0.03
|
|
1390
|
+
bottom_fraction = max(0.0, 1.0 - margin - height)
|
|
1391
|
+
|
|
1392
|
+
y_min_curr, y_max_curr = ax.get_ylim()
|
|
1393
|
+
y_range = y_max_curr - y_min_curr
|
|
1394
|
+
if y_range > 0:
|
|
1395
|
+
elem_bounds = get_plot_elements_bounds(ax, slope_data)
|
|
1396
|
+
if elem_bounds:
|
|
1397
|
+
y_top = max(b[3] for b in elem_bounds)
|
|
1398
|
+
y_norm = (y_top - y_min_curr) / y_range
|
|
1399
|
+
if y_norm >= bottom_fraction and bottom_fraction > 0:
|
|
1400
|
+
y_max_new = y_min_curr + (y_top - y_min_curr) / bottom_fraction
|
|
1401
|
+
ax.set_ylim(y_min_curr, y_max_new)
|
|
779
1402
|
|
|
780
1403
|
ax.set_aspect('equal') # ✅ Equal aspect
|
|
1404
|
+
|
|
1405
|
+
# Add a bit of headroom so plotted lines/markers don't touch the top border
|
|
1406
|
+
y0, y1 = ax.get_ylim()
|
|
1407
|
+
if y1 > y0:
|
|
1408
|
+
pad = 0.05 * (y1 - y0)
|
|
1409
|
+
ax.set_ylim(y0, y1 + pad)
|
|
781
1410
|
ax.set_xlabel("x")
|
|
782
1411
|
ax.set_ylabel("y")
|
|
783
1412
|
ax.grid(False)
|
|
@@ -791,16 +1420,38 @@ def plot_inputs(slope_data, title="Slope Geometry and Inputs", figsize=(12, 6),
|
|
|
791
1420
|
handles.append(dummy_line)
|
|
792
1421
|
labels.append('Distributed Load')
|
|
793
1422
|
|
|
1423
|
+
# --- Legend layout ---
|
|
1424
|
+
# Historically this legend used ncol=2 and was anchored below the axes.
|
|
1425
|
+
# If there are many entries, that makes the legend tall and it can fall
|
|
1426
|
+
# off the bottom of the window. We auto-increase columns to cap row count,
|
|
1427
|
+
# and we also reserve bottom margin so the legend stays visible.
|
|
1428
|
+
n_items = len(labels)
|
|
1429
|
+
if legend_ncol == "auto":
|
|
1430
|
+
# Choose enough columns to keep row count <= legend_max_rows (as best we can),
|
|
1431
|
+
# but never exceed legend_max_cols.
|
|
1432
|
+
required_cols = max(1, math.ceil(n_items / max(1, int(legend_max_rows))))
|
|
1433
|
+
ncol = min(int(legend_max_cols), required_cols)
|
|
1434
|
+
# Keep at least 2 columns once there's more than one entry (matches prior look).
|
|
1435
|
+
if n_items > 1:
|
|
1436
|
+
ncol = max(2, ncol)
|
|
1437
|
+
else:
|
|
1438
|
+
ncol = max(1, int(legend_ncol))
|
|
1439
|
+
|
|
1440
|
+
n_rows = max(1, math.ceil(n_items / max(1, ncol)))
|
|
1441
|
+
# Reserve a bit more space as the legend grows so it doesn't get clipped.
|
|
1442
|
+
bottom_margin = min(0.45, 0.10 + 0.04 * n_rows)
|
|
1443
|
+
|
|
794
1444
|
ax.legend(
|
|
795
1445
|
handles=handles,
|
|
796
1446
|
labels=labels,
|
|
797
|
-
loc=
|
|
1447
|
+
loc="upper center",
|
|
798
1448
|
bbox_to_anchor=(0.5, -0.12),
|
|
799
|
-
ncol=
|
|
1449
|
+
ncol=ncol,
|
|
800
1450
|
)
|
|
801
1451
|
|
|
802
1452
|
ax.set_title(title)
|
|
803
1453
|
|
|
1454
|
+
plt.subplots_adjust(bottom=bottom_margin)
|
|
804
1455
|
plt.tight_layout()
|
|
805
1456
|
|
|
806
1457
|
if save_png:
|
|
@@ -830,7 +1481,7 @@ def plot_solution(slope_data, slice_df, failure_surface, results, figsize=(12, 7
|
|
|
830
1481
|
ax.set_ylabel("y")
|
|
831
1482
|
ax.grid(False)
|
|
832
1483
|
|
|
833
|
-
plot_profile_lines(ax, slope_data['profile_lines'])
|
|
1484
|
+
plot_profile_lines(ax, slope_data['profile_lines'], materials=slope_data.get('materials'))
|
|
834
1485
|
plot_max_depth(ax, slope_data['profile_lines'], slope_data['max_depth'])
|
|
835
1486
|
plot_slices(ax, slice_df, fill=False)
|
|
836
1487
|
plot_failure_surface(ax, failure_surface)
|
|
@@ -892,6 +1543,8 @@ def plot_solution(slope_data, slice_df, failure_surface, results, figsize=(12, 7
|
|
|
892
1543
|
title = f'Corps Engineers: FS = {fs:.3f}, θ = {theta:.2f}°'
|
|
893
1544
|
elif method == 'lowe_karafiath':
|
|
894
1545
|
title = f'Lowe & Karafiath: FS = {fs:.3f}'
|
|
1546
|
+
else:
|
|
1547
|
+
title = f'{method}: FS = {fs:.3f}'
|
|
895
1548
|
ax.set_title(title)
|
|
896
1549
|
|
|
897
1550
|
# zoom y‐axis to just cover the slope and depth, with a little breathing room (thrust line can be outside)
|
|
@@ -980,7 +1633,7 @@ def plot_circular_search_results(slope_data, fs_cache, search_path=None, highlig
|
|
|
980
1633
|
"""
|
|
981
1634
|
fig, ax = plt.subplots(figsize=figsize)
|
|
982
1635
|
|
|
983
|
-
plot_profile_lines(ax, slope_data['profile_lines'])
|
|
1636
|
+
plot_profile_lines(ax, slope_data['profile_lines'], materials=slope_data.get('materials'))
|
|
984
1637
|
plot_max_depth(ax, slope_data['profile_lines'], slope_data['max_depth'])
|
|
985
1638
|
plot_piezo_line(ax, slope_data)
|
|
986
1639
|
plot_dloads(ax, slope_data)
|
|
@@ -1026,7 +1679,7 @@ def plot_noncircular_search_results(slope_data, fs_cache, search_path=None, high
|
|
|
1026
1679
|
fig, ax = plt.subplots(figsize=figsize)
|
|
1027
1680
|
|
|
1028
1681
|
# Plot basic profile elements
|
|
1029
|
-
plot_profile_lines(ax, slope_data['profile_lines'])
|
|
1682
|
+
plot_profile_lines(ax, slope_data['profile_lines'], materials=slope_data.get('materials'))
|
|
1030
1683
|
plot_max_depth(ax, slope_data['profile_lines'], slope_data['max_depth'])
|
|
1031
1684
|
plot_piezo_line(ax, slope_data)
|
|
1032
1685
|
plot_dloads(ax, slope_data)
|
|
@@ -1093,7 +1746,7 @@ def plot_reliability_results(slope_data, reliability_data, figsize=(12, 7), save
|
|
|
1093
1746
|
fig, ax = plt.subplots(figsize=figsize)
|
|
1094
1747
|
|
|
1095
1748
|
# Plot basic slope elements (same as other search functions)
|
|
1096
|
-
plot_profile_lines(ax, slope_data['profile_lines'])
|
|
1749
|
+
plot_profile_lines(ax, slope_data['profile_lines'], materials=slope_data.get('materials'))
|
|
1097
1750
|
plot_max_depth(ax, slope_data['profile_lines'], slope_data['max_depth'])
|
|
1098
1751
|
plot_piezo_line(ax, slope_data)
|
|
1099
1752
|
plot_dloads(ax, slope_data)
|
|
@@ -1360,26 +2013,56 @@ def plot_mesh(mesh, materials=None, figsize=(14, 6), pad_frac=0.05, show_nodes=T
|
|
|
1360
2013
|
plt.show()
|
|
1361
2014
|
|
|
1362
2015
|
|
|
1363
|
-
def plot_polygons(
|
|
2016
|
+
def plot_polygons(
|
|
2017
|
+
polygons,
|
|
2018
|
+
materials=None,
|
|
2019
|
+
nodes=False,
|
|
2020
|
+
legend=True,
|
|
2021
|
+
title="Material Zone Polygons",
|
|
2022
|
+
figsize=(10, 6),
|
|
2023
|
+
save_png=False,
|
|
2024
|
+
dpi=300,
|
|
2025
|
+
):
|
|
1364
2026
|
"""
|
|
1365
2027
|
Plot all material zone polygons in a single figure.
|
|
1366
2028
|
|
|
1367
2029
|
Parameters:
|
|
1368
|
-
polygons: List of polygon coordinate lists
|
|
2030
|
+
polygons: List of polygon coordinate lists or dicts with "coords"/"mat_id"
|
|
2031
|
+
materials: Optional list of material dicts (with key "name") or list of material
|
|
2032
|
+
name strings. If provided, the material name will be used in the legend.
|
|
2033
|
+
nodes: If True, plot each polygon vertex as a dot.
|
|
2034
|
+
legend: If True, show the legend.
|
|
1369
2035
|
title: Plot title
|
|
2036
|
+
figsize: Matplotlib figure size tuple, e.g. (10, 6)
|
|
1370
2037
|
"""
|
|
1371
2038
|
import matplotlib.pyplot as plt
|
|
1372
2039
|
|
|
1373
|
-
fig, ax = plt.subplots(figsize=
|
|
2040
|
+
fig, ax = plt.subplots(figsize=figsize)
|
|
1374
2041
|
for i, polygon in enumerate(polygons):
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
2042
|
+
coords = polygon.get("coords", []) if isinstance(polygon, dict) else polygon
|
|
2043
|
+
xs = [x for x, y in coords]
|
|
2044
|
+
ys = [y for x, y in coords]
|
|
2045
|
+
mat_idx = polygon.get("mat_id") if isinstance(polygon, dict) else i
|
|
2046
|
+
if mat_idx is None:
|
|
2047
|
+
mat_idx = i
|
|
2048
|
+
mat_name = None
|
|
2049
|
+
if materials is not None and 0 <= mat_idx < len(materials):
|
|
2050
|
+
item = materials[mat_idx]
|
|
2051
|
+
if isinstance(item, dict):
|
|
2052
|
+
mat_name = item.get("name", None)
|
|
2053
|
+
elif isinstance(item, str):
|
|
2054
|
+
mat_name = item
|
|
2055
|
+
label = mat_name if mat_name else f"Material {mat_idx}"
|
|
2056
|
+
ax.fill(xs, ys, color=get_material_color(mat_idx), alpha=0.6, label=label)
|
|
2057
|
+
ax.plot(xs, ys, color=get_material_color(mat_idx), linewidth=1)
|
|
2058
|
+
if nodes:
|
|
2059
|
+
# Avoid legend clutter by not adding a label here.
|
|
2060
|
+
ax.scatter(xs, ys, color='k', s=30, marker='o', zorder=3)
|
|
1379
2061
|
ax.set_xlabel('X Coordinate')
|
|
1380
2062
|
ax.set_ylabel('Y Coordinate')
|
|
1381
2063
|
ax.set_title(title)
|
|
1382
|
-
|
|
2064
|
+
if legend:
|
|
2065
|
+
ax.legend()
|
|
1383
2066
|
ax.grid(True, alpha=0.3)
|
|
1384
2067
|
ax.set_aspect('equal')
|
|
1385
2068
|
plt.tight_layout()
|
|
@@ -1391,35 +2074,51 @@ def plot_polygons(polygons, title="Material Zone Polygons", save_png=False, dpi=
|
|
|
1391
2074
|
plt.show()
|
|
1392
2075
|
|
|
1393
2076
|
|
|
1394
|
-
def plot_polygons_separately(polygons,
|
|
2077
|
+
def plot_polygons_separately(polygons, materials=None, save_png=False, dpi=300):
|
|
1395
2078
|
"""
|
|
1396
2079
|
Plot each polygon in a separate matplotlib frame (subplot), with vertices as round dots.
|
|
1397
2080
|
|
|
1398
2081
|
Parameters:
|
|
1399
|
-
polygons: List of polygon coordinate lists
|
|
1400
|
-
|
|
2082
|
+
polygons: List of polygon coordinate lists or dicts with "coords"/"mat_id"
|
|
2083
|
+
materials: Optional list of material dicts (with key "name") or list of material
|
|
2084
|
+
name strings. If provided, the material name will be included in each subplot title.
|
|
1401
2085
|
"""
|
|
1402
2086
|
import matplotlib.pyplot as plt
|
|
1403
2087
|
|
|
1404
2088
|
n = len(polygons)
|
|
1405
2089
|
fig, axes = plt.subplots(n, 1, figsize=(8, 3 * n), squeeze=False)
|
|
1406
2090
|
for i, polygon in enumerate(polygons):
|
|
1407
|
-
|
|
1408
|
-
|
|
2091
|
+
coords = polygon.get("coords", []) if isinstance(polygon, dict) else polygon
|
|
2092
|
+
xs = [x for x, y in coords]
|
|
2093
|
+
ys = [y for x, y in coords]
|
|
1409
2094
|
ax = axes[i, 0]
|
|
1410
|
-
|
|
1411
|
-
|
|
2095
|
+
mat_idx = polygon.get("mat_id") if isinstance(polygon, dict) else i
|
|
2096
|
+
if mat_idx is None:
|
|
2097
|
+
mat_idx = i
|
|
2098
|
+
ax.fill(xs, ys, color=get_material_color(mat_idx), alpha=0.6, label=f'Material {mat_idx}')
|
|
2099
|
+
ax.plot(xs, ys, color=get_material_color(mat_idx), linewidth=1)
|
|
1412
2100
|
ax.scatter(xs, ys, color='k', s=30, marker='o', zorder=3, label='Vertices')
|
|
1413
2101
|
ax.set_xlabel('X Coordinate')
|
|
1414
2102
|
ax.set_ylabel('Y Coordinate')
|
|
1415
|
-
|
|
2103
|
+
mat_name = None
|
|
2104
|
+
if materials is not None and 0 <= mat_idx < len(materials):
|
|
2105
|
+
item = materials[mat_idx]
|
|
2106
|
+
if isinstance(item, dict):
|
|
2107
|
+
mat_name = item.get("name", None)
|
|
2108
|
+
elif isinstance(item, str):
|
|
2109
|
+
mat_name = item
|
|
2110
|
+
if mat_name:
|
|
2111
|
+
ax.set_title(f'Material {mat_idx}: {mat_name}')
|
|
2112
|
+
else:
|
|
2113
|
+
ax.set_title(f'Material {mat_idx}')
|
|
1416
2114
|
ax.grid(True, alpha=0.3)
|
|
1417
2115
|
ax.set_aspect('equal')
|
|
1418
|
-
|
|
2116
|
+
# Intentionally no legend: these plots are typically used for debugging geometry,
|
|
2117
|
+
# and legends can obscure key vertices/edges.
|
|
1419
2118
|
plt.tight_layout()
|
|
1420
2119
|
|
|
1421
2120
|
if save_png:
|
|
1422
|
-
filename = '
|
|
2121
|
+
filename = 'plot_polygons_separately.png'
|
|
1423
2122
|
plt.savefig(filename, dpi=dpi, bbox_inches='tight')
|
|
1424
2123
|
|
|
1425
2124
|
plt.show()
|
|
@@ -1500,8 +2199,9 @@ def get_plot_elements_bounds(ax, slope_data):
|
|
|
1500
2199
|
if 'profile_lines' in slope_data:
|
|
1501
2200
|
for line in slope_data['profile_lines']:
|
|
1502
2201
|
if line:
|
|
1503
|
-
|
|
1504
|
-
|
|
2202
|
+
coords = line['coords']
|
|
2203
|
+
xs = [p[0] for p in coords]
|
|
2204
|
+
ys = [p[1] for p in coords]
|
|
1505
2205
|
bounds.append((min(xs), max(xs), min(ys), max(ys)))
|
|
1506
2206
|
|
|
1507
2207
|
# Distributed loads bounds
|