xslope 0.1.10__py3-none-any.whl → 0.1.12__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/fileio.py +130 -60
- xslope/mesh.py +109 -4
- xslope/plot.py +661 -68
- xslope/plot_fem.py +25 -73
- xslope/plot_seep.py +21 -69
- xslope/seep.py +11 -6
- {xslope-0.1.10.dist-info → xslope-0.1.12.dist-info}/METADATA +1 -1
- xslope-0.1.12.dist-info/RECORD +21 -0
- xslope-0.1.10.dist-info/RECORD +0 -21
- {xslope-0.1.10.dist-info → xslope-0.1.12.dist-info}/LICENSE +0 -0
- {xslope-0.1.10.dist-info → xslope-0.1.12.dist-info}/NOTICE +0 -0
- {xslope-0.1.10.dist-info → xslope-0.1.12.dist-info}/WHEEL +0 -0
- {xslope-0.1.10.dist-info → xslope-0.1.12.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
|
"""
|
|
@@ -173,6 +175,26 @@ def plot_piezo_line(ax, slope_data):
|
|
|
173
175
|
None
|
|
174
176
|
"""
|
|
175
177
|
|
|
178
|
+
def _plot_touching_v_marker(ax, x, y, color, markersize=8, extra_gap_points=0.0):
|
|
179
|
+
"""
|
|
180
|
+
Place an inverted triangle marker so its tip visually touches the line at (x, y).
|
|
181
|
+
We do this in display coordinates (points/pixels) so it scales consistently.
|
|
182
|
+
"""
|
|
183
|
+
from matplotlib.markers import MarkerStyle
|
|
184
|
+
from matplotlib.transforms import offset_copy
|
|
185
|
+
|
|
186
|
+
# Compute the distance (in "marker units") from the marker origin to the tip.
|
|
187
|
+
# Matplotlib scales marker vertices by `markersize` (in points) for Line2D.
|
|
188
|
+
ms = MarkerStyle("v")
|
|
189
|
+
path = ms.get_path().transformed(ms.get_transform())
|
|
190
|
+
verts = np.asarray(path.vertices)
|
|
191
|
+
min_y = float(verts[:, 1].min()) # tip is the lowest y
|
|
192
|
+
tip_offset_points = (-min_y) * float(markersize) + float(extra_gap_points)
|
|
193
|
+
|
|
194
|
+
# Offset the marker center upward in point units so the tip lands at (x, y).
|
|
195
|
+
trans = offset_copy(ax.transData, fig=ax.figure, x=0.0, y=tip_offset_points, units="points")
|
|
196
|
+
ax.plot([x], [y], marker="v", color=color, markersize=markersize, linestyle="None", transform=trans)
|
|
197
|
+
|
|
176
198
|
def plot_single_piezo_line(ax, piezo_line, color, label):
|
|
177
199
|
"""Internal function to plot a single piezometric line"""
|
|
178
200
|
if not piezo_line:
|
|
@@ -182,20 +204,146 @@ def plot_piezo_line(ax, slope_data):
|
|
|
182
204
|
ax.plot(piezo_xs, piezo_ys, color=color, linewidth=2, label=label)
|
|
183
205
|
|
|
184
206
|
# 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
207
|
if len(piezo_xs) > 1:
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
208
|
+
# Sort by x to ensure monotonic input for interpolation
|
|
209
|
+
pairs = sorted(zip(piezo_xs, piezo_ys), key=lambda p: p[0])
|
|
210
|
+
sx, sy = zip(*pairs)
|
|
211
|
+
x_min, x_max = min(sx), max(sx)
|
|
212
|
+
mid_x = (x_min + x_max) / 2
|
|
213
|
+
mid_y = float(np.interp(mid_x, sx, sy))
|
|
214
|
+
# Slight negative gap so the marker visually "touches" the line (not floating above it)
|
|
215
|
+
_plot_touching_v_marker(ax, mid_x, mid_y, color=color, markersize=8, extra_gap_points=2.0)
|
|
194
216
|
|
|
195
217
|
# Plot both piezometric lines
|
|
196
218
|
plot_single_piezo_line(ax, slope_data.get('piezo_line'), 'b', "Piezometric Line")
|
|
197
219
|
plot_single_piezo_line(ax, slope_data.get('piezo_line2'), 'skyblue', "Piezometric Line 2")
|
|
198
220
|
|
|
221
|
+
def plot_seepage_bc_lines(ax, slope_data):
|
|
222
|
+
"""
|
|
223
|
+
Plots seepage boundary-condition lines for seepage-only workflows.
|
|
224
|
+
|
|
225
|
+
- Specified head geometry: solid dark blue, thicker than profile lines
|
|
226
|
+
- Exit face geometry: solid red
|
|
227
|
+
- Derived "water level" line for each specified head: y = h
|
|
228
|
+
plotted as a lighter blue solid line with an inverted triangle marker
|
|
229
|
+
(styled similarly to piezometric line markers).
|
|
230
|
+
"""
|
|
231
|
+
def _plot_touching_v_marker(ax, x, y, color, markersize=8, extra_gap_points=2.0):
|
|
232
|
+
"""Place an inverted triangle so its tip visually sits on the line at (x, y)."""
|
|
233
|
+
from matplotlib.markers import MarkerStyle
|
|
234
|
+
from matplotlib.transforms import offset_copy
|
|
235
|
+
|
|
236
|
+
ms = MarkerStyle("v")
|
|
237
|
+
path = ms.get_path().transformed(ms.get_transform())
|
|
238
|
+
verts = np.asarray(path.vertices)
|
|
239
|
+
min_y = float(verts[:, 1].min())
|
|
240
|
+
tip_offset_points = (-min_y) * float(markersize) + float(extra_gap_points)
|
|
241
|
+
trans = offset_copy(ax.transData, fig=ax.figure, x=0.0, y=tip_offset_points, units="points")
|
|
242
|
+
ax.plot([x], [y], marker="v", color=color, markersize=markersize, linestyle="None", transform=trans)
|
|
243
|
+
|
|
244
|
+
# Geometry x-extent (used for vertical-head-line derived segment length / side)
|
|
245
|
+
x_vals = []
|
|
246
|
+
for line in slope_data.get("profile_lines", []):
|
|
247
|
+
try:
|
|
248
|
+
xs_line, _ = zip(*line)
|
|
249
|
+
x_vals.extend(xs_line)
|
|
250
|
+
except Exception:
|
|
251
|
+
pass
|
|
252
|
+
gs = slope_data.get("ground_surface", None)
|
|
253
|
+
if not x_vals and gs is not None and hasattr(gs, "coords"):
|
|
254
|
+
x_vals.extend([x for x, _ in gs.coords])
|
|
255
|
+
x_min_geom = min(x_vals) if x_vals else 0.0
|
|
256
|
+
x_max_geom = max(x_vals) if x_vals else 1.0
|
|
257
|
+
geom_width = max(1e-9, x_max_geom - x_min_geom)
|
|
258
|
+
|
|
259
|
+
seepage_bc = slope_data.get("seepage_bc") or {}
|
|
260
|
+
specified_heads = seepage_bc.get("specified_heads") or []
|
|
261
|
+
exit_face = seepage_bc.get("exit_face") or []
|
|
262
|
+
|
|
263
|
+
# --- Specified head lines + derived water-level lines ---
|
|
264
|
+
for i, sh in enumerate(specified_heads):
|
|
265
|
+
coords = sh.get("coords") or []
|
|
266
|
+
if len(coords) < 2:
|
|
267
|
+
continue
|
|
268
|
+
|
|
269
|
+
xs, ys = zip(*coords)
|
|
270
|
+
ax.plot(
|
|
271
|
+
xs,
|
|
272
|
+
ys,
|
|
273
|
+
color="darkblue",
|
|
274
|
+
linewidth=3,
|
|
275
|
+
linestyle="--",
|
|
276
|
+
label="Specified Head Line" if i == 0 else "",
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
# Head values may be scalar (typical) or per-point array-like
|
|
280
|
+
head_val = sh.get("head", None)
|
|
281
|
+
if head_val is None:
|
|
282
|
+
continue
|
|
283
|
+
|
|
284
|
+
if isinstance(head_val, (list, tuple, np.ndarray)):
|
|
285
|
+
if len(head_val) != len(coords):
|
|
286
|
+
continue
|
|
287
|
+
heads = [float(h) for h in head_val]
|
|
288
|
+
else:
|
|
289
|
+
try:
|
|
290
|
+
head_scalar = float(head_val)
|
|
291
|
+
except (TypeError, ValueError):
|
|
292
|
+
continue
|
|
293
|
+
heads = [head_scalar] * len(coords)
|
|
294
|
+
|
|
295
|
+
# If specified-head geometry is vertical, draw a short horizontal derived line outside the boundary.
|
|
296
|
+
# This avoids drawing a (nearly) vertical derived line that doesn't convey a water level.
|
|
297
|
+
tol = 1e-6
|
|
298
|
+
is_vertical = (max(xs) - min(xs)) <= tol
|
|
299
|
+
if is_vertical:
|
|
300
|
+
x0 = float(xs[0])
|
|
301
|
+
y_head = float(heads[0])
|
|
302
|
+
seg_len = 0.04 * geom_width
|
|
303
|
+
gap = 0.01 * geom_width
|
|
304
|
+
is_right = x0 >= 0.5 * (x_min_geom + x_max_geom)
|
|
305
|
+
if is_right:
|
|
306
|
+
wl_xs = [x0 + gap, x0 + gap + seg_len]
|
|
307
|
+
else:
|
|
308
|
+
wl_xs = [x0 - gap - seg_len, x0 - gap]
|
|
309
|
+
wl_ys = [y_head, y_head]
|
|
310
|
+
else:
|
|
311
|
+
wl_xs = list(xs)
|
|
312
|
+
wl_ys = heads
|
|
313
|
+
|
|
314
|
+
ax.plot(
|
|
315
|
+
wl_xs,
|
|
316
|
+
wl_ys,
|
|
317
|
+
color="lightskyblue",
|
|
318
|
+
linewidth=2,
|
|
319
|
+
linestyle="-",
|
|
320
|
+
label="Specified Head Water Level" if i == 0 else "",
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
# Inverted triangle marker near the midpoint (like piezometric lines)
|
|
324
|
+
if len(wl_xs) > 1:
|
|
325
|
+
try:
|
|
326
|
+
pairs = sorted(zip(wl_xs, wl_ys), key=lambda p: p[0])
|
|
327
|
+
sx, sy = zip(*pairs)
|
|
328
|
+
mid_x = 0.5 * (min(sx) + max(sx))
|
|
329
|
+
mid_y = float(np.interp(mid_x, sx, sy))
|
|
330
|
+
_plot_touching_v_marker(ax, mid_x, mid_y, color="lightskyblue", markersize=8, extra_gap_points=2.0)
|
|
331
|
+
except Exception:
|
|
332
|
+
# If interpolation fails for any reason, skip marker
|
|
333
|
+
pass
|
|
334
|
+
|
|
335
|
+
# --- Exit face line ---
|
|
336
|
+
if len(exit_face) >= 2:
|
|
337
|
+
ex_xs, ex_ys = zip(*exit_face)
|
|
338
|
+
ax.plot(
|
|
339
|
+
ex_xs,
|
|
340
|
+
ex_ys,
|
|
341
|
+
color="red",
|
|
342
|
+
linewidth=3,
|
|
343
|
+
linestyle="--",
|
|
344
|
+
label="Exit Face",
|
|
345
|
+
)
|
|
346
|
+
|
|
199
347
|
def plot_tcrack_surface(ax, tcrack_surface):
|
|
200
348
|
"""
|
|
201
349
|
Plots the tension crack surface as a thin dashed red line.
|
|
@@ -381,6 +529,8 @@ def plot_circles(ax, slope_data):
|
|
|
381
529
|
continue # or handle error
|
|
382
530
|
# result = (x_min, x_max, y_left, y_right, clipped_surface)
|
|
383
531
|
x_min, x_max, y_left, y_right, clipped_surface = result
|
|
532
|
+
if not isinstance(clipped_surface, LineString):
|
|
533
|
+
clipped_surface = LineString(clipped_surface)
|
|
384
534
|
x_clip, y_clip = zip(*clipped_surface.coords)
|
|
385
535
|
ax.plot(x_clip, y_clip, 'r--', label="Circle")
|
|
386
536
|
|
|
@@ -423,15 +573,28 @@ def plot_non_circ(ax, non_circ):
|
|
|
423
573
|
xs, ys = zip(*non_circ)
|
|
424
574
|
ax.plot(xs, ys, 'r--', label='Non-Circular Surface')
|
|
425
575
|
|
|
426
|
-
def
|
|
576
|
+
def plot_lem_material_table(ax, materials, xloc=0.6, yloc=0.7):
|
|
427
577
|
"""
|
|
428
|
-
Adds a material properties table to the plot.
|
|
578
|
+
Adds a limit equilibrium material properties table to the plot.
|
|
579
|
+
|
|
580
|
+
Displays soil properties for limit equilibrium analysis including unit weight (γ),
|
|
581
|
+
cohesion (c), friction angle (φ), and optionally dilation angle (d) and
|
|
582
|
+
dilatancy angle (ψ). Supports both Mohr-Coulomb (mc) and constant-phi (cp) options.
|
|
429
583
|
|
|
430
584
|
Parameters:
|
|
431
|
-
ax: matplotlib Axes object
|
|
432
|
-
materials: List of material property dictionaries
|
|
433
|
-
|
|
434
|
-
|
|
585
|
+
ax: matplotlib Axes object to add the table to
|
|
586
|
+
materials: List of material property dictionaries with keys:
|
|
587
|
+
- 'name': Material name (str)
|
|
588
|
+
- 'gamma': Unit weight (float)
|
|
589
|
+
- 'option': Material model - 'mc' or 'cp' (str)
|
|
590
|
+
- 'c': Cohesion for mc option (float)
|
|
591
|
+
- 'phi': Friction angle for mc option (float)
|
|
592
|
+
- 'cp': Constant phi for cp option (float)
|
|
593
|
+
- 'r_elev': Reference elevation for cp option (float)
|
|
594
|
+
- 'd': Dilation angle, optional (float)
|
|
595
|
+
- 'psi': Dilatancy angle, optional (float)
|
|
596
|
+
xloc: x-location of table bottom-left corner in axes coordinates (0-1, default: 0.6)
|
|
597
|
+
yloc: y-location of table bottom-left corner in axes coordinates (0-1, default: 0.7)
|
|
435
598
|
|
|
436
599
|
Returns:
|
|
437
600
|
None
|
|
@@ -505,15 +668,239 @@ def plot_material_table(ax, materials, xloc=0.6, yloc=0.7):
|
|
|
505
668
|
# Adjust table width based on number of columns
|
|
506
669
|
table_width = 0.25 if has_d_psi else 0.2
|
|
507
670
|
|
|
671
|
+
# Choose table height based on number of materials (uniform across table types)
|
|
672
|
+
num_rows = max(1, len(materials))
|
|
673
|
+
table_height = 0.06 + 0.035 * num_rows # header + per-row estimate
|
|
674
|
+
table_height = min(0.35, table_height) # cap to avoid overflows for many rows
|
|
675
|
+
|
|
508
676
|
# Add the table
|
|
509
677
|
table = ax.table(cellText=table_data,
|
|
510
678
|
colLabels=col_labels,
|
|
511
679
|
loc='upper right',
|
|
512
680
|
colLoc='center',
|
|
513
681
|
cellLoc='center',
|
|
514
|
-
bbox=[xloc, yloc, table_width,
|
|
682
|
+
bbox=[xloc, yloc, table_width, table_height])
|
|
515
683
|
table.auto_set_font_size(False)
|
|
516
684
|
table.set_fontsize(8)
|
|
685
|
+
# Auto layout based on content (shared method for all table types)
|
|
686
|
+
auto_size_table_to_content(ax, table, col_labels, table_data, table_width, table_height)
|
|
687
|
+
|
|
688
|
+
def plot_seep_material_table(ax, seep_data, xloc=0.6, yloc=0.7):
|
|
689
|
+
"""
|
|
690
|
+
Adds a seepage material properties table to the plot.
|
|
691
|
+
|
|
692
|
+
Displays hydraulic properties for seepage analysis including hydraulic conductivities
|
|
693
|
+
(k₁, k₂), anisotropy angle, and unsaturated flow parameters (kr₀, h₀).
|
|
694
|
+
|
|
695
|
+
Parameters:
|
|
696
|
+
ax: matplotlib Axes object to add the table to
|
|
697
|
+
seep_data: Dictionary containing seepage material properties with keys:
|
|
698
|
+
- 'k1_by_mat': List of primary hydraulic conductivity values (float)
|
|
699
|
+
- 'k2_by_mat': List of secondary hydraulic conductivity values (float)
|
|
700
|
+
- 'angle_by_mat': List of anisotropy angles in degrees (float)
|
|
701
|
+
- 'kr0_by_mat': List of relative permeability at residual saturation (float)
|
|
702
|
+
- 'h0_by_mat': List of pressure head parameters (float)
|
|
703
|
+
- 'material_names': List of material names (str), optional
|
|
704
|
+
xloc: x-location of table bottom-left corner in axes coordinates (0-1, default: 0.6)
|
|
705
|
+
yloc: y-location of table bottom-left corner in axes coordinates (0-1, default: 0.7)
|
|
706
|
+
|
|
707
|
+
Returns:
|
|
708
|
+
None
|
|
709
|
+
"""
|
|
710
|
+
k1_by_mat = seep_data.get("k1_by_mat")
|
|
711
|
+
k2_by_mat = seep_data.get("k2_by_mat")
|
|
712
|
+
angle_by_mat = seep_data.get("angle_by_mat")
|
|
713
|
+
kr0_by_mat = seep_data.get("kr0_by_mat")
|
|
714
|
+
h0_by_mat = seep_data.get("h0_by_mat")
|
|
715
|
+
material_names = seep_data.get("material_names", [])
|
|
716
|
+
if k1_by_mat is None or len(k1_by_mat) == 0:
|
|
717
|
+
return
|
|
718
|
+
col_labels = ["Mat", "Name", "k₁", "k₂", "Angle", "kr₀", "h₀"]
|
|
719
|
+
table_data = []
|
|
720
|
+
for idx in range(len(k1_by_mat)):
|
|
721
|
+
k1 = k1_by_mat[idx]
|
|
722
|
+
k2 = k2_by_mat[idx] if k2_by_mat is not None else 0.0
|
|
723
|
+
angle = angle_by_mat[idx] if angle_by_mat is not None else 0.0
|
|
724
|
+
kr0 = kr0_by_mat[idx] if kr0_by_mat is not None else 0.0
|
|
725
|
+
h0 = h0_by_mat[idx] if h0_by_mat is not None else 0.0
|
|
726
|
+
material_name = material_names[idx] if idx < len(material_names) else f"Material {idx+1}"
|
|
727
|
+
row = [idx + 1, material_name, f"{k1:.3f}", f"{k2:.3f}", f"{angle:.1f}", f"{kr0:.4f}", f"{h0:.2f}"]
|
|
728
|
+
table_data.append(row)
|
|
729
|
+
# Dimensions
|
|
730
|
+
num_rows = max(1, len(k1_by_mat))
|
|
731
|
+
table_width = 0.45
|
|
732
|
+
table_height = 0.10 + 0.06 * num_rows
|
|
733
|
+
table_height = min(0.50, table_height)
|
|
734
|
+
table = ax.table(cellText=table_data,
|
|
735
|
+
colLabels=col_labels,
|
|
736
|
+
loc='upper right',
|
|
737
|
+
colLoc='center',
|
|
738
|
+
cellLoc='center',
|
|
739
|
+
bbox=[xloc, yloc, table_width, table_height])
|
|
740
|
+
table.auto_set_font_size(False)
|
|
741
|
+
table.set_fontsize(8)
|
|
742
|
+
auto_size_table_to_content(ax, table, col_labels, table_data, table_width, table_height)
|
|
743
|
+
|
|
744
|
+
def plot_fem_material_table(ax, fem_data, xloc=0.6, yloc=0.7, width=0.6, height=None):
|
|
745
|
+
"""
|
|
746
|
+
Adds a finite element material properties table to the plot.
|
|
747
|
+
|
|
748
|
+
Displays material properties for FEM analysis including unit weight (γ), cohesion (c),
|
|
749
|
+
friction angle (φ), Young's modulus (E), and Poisson's ratio (ν).
|
|
750
|
+
|
|
751
|
+
Parameters:
|
|
752
|
+
ax: matplotlib Axes object to add the table to
|
|
753
|
+
fem_data: Dictionary containing FEM material properties with keys:
|
|
754
|
+
- 'c_by_mat': List of cohesion values (float)
|
|
755
|
+
- 'phi_by_mat': List of friction angle values in degrees (float)
|
|
756
|
+
- 'E_by_mat': List of Young's modulus values (float)
|
|
757
|
+
- 'nu_by_mat': List of Poisson's ratio values (float)
|
|
758
|
+
- 'gamma_by_mat': List of unit weight values (float)
|
|
759
|
+
- 'material_names': List of material names (str), optional
|
|
760
|
+
xloc: x-location of table bottom-left corner in axes coordinates (0-1, default: 0.6)
|
|
761
|
+
yloc: y-location of table bottom-left corner in axes coordinates (0-1, default: 0.7)
|
|
762
|
+
width: Table width in axes coordinates (0-1, default: 0.6)
|
|
763
|
+
height: Table height in axes coordinates (0-1, default: auto-calculated)
|
|
764
|
+
|
|
765
|
+
Returns:
|
|
766
|
+
None
|
|
767
|
+
"""
|
|
768
|
+
c_by_mat = fem_data.get("c_by_mat")
|
|
769
|
+
phi_by_mat = fem_data.get("phi_by_mat")
|
|
770
|
+
E_by_mat = fem_data.get("E_by_mat")
|
|
771
|
+
nu_by_mat = fem_data.get("nu_by_mat")
|
|
772
|
+
gamma_by_mat = fem_data.get("gamma_by_mat")
|
|
773
|
+
material_names = fem_data.get("material_names", [])
|
|
774
|
+
if c_by_mat is None or len(c_by_mat) == 0:
|
|
775
|
+
return
|
|
776
|
+
col_labels = ["Mat", "Name", "γ", "c", "φ", "E", "ν"]
|
|
777
|
+
table_data = []
|
|
778
|
+
for idx in range(len(c_by_mat)):
|
|
779
|
+
c = c_by_mat[idx]
|
|
780
|
+
phi = phi_by_mat[idx] if phi_by_mat is not None else 0.0
|
|
781
|
+
E = E_by_mat[idx] if E_by_mat is not None else 0.0
|
|
782
|
+
nu = nu_by_mat[idx] if nu_by_mat is not None else 0.0
|
|
783
|
+
gamma = gamma_by_mat[idx] if gamma_by_mat is not None else 0.0
|
|
784
|
+
material_name = material_names[idx] if idx < len(material_names) else f"Material {idx+1}"
|
|
785
|
+
row = [idx + 1, material_name, f"{gamma:.1f}", f"{c:.1f}", f"{phi:.1f}", f"{E:.0f}", f"{nu:.2f}"]
|
|
786
|
+
table_data.append(row)
|
|
787
|
+
if height is None:
|
|
788
|
+
num_rows = max(1, len(c_by_mat))
|
|
789
|
+
height = 0.06 + 0.035 * num_rows
|
|
790
|
+
height = min(0.32, height)
|
|
791
|
+
table = ax.table(cellText=table_data,
|
|
792
|
+
colLabels=col_labels,
|
|
793
|
+
loc='upper right',
|
|
794
|
+
colLoc='center',
|
|
795
|
+
cellLoc='center',
|
|
796
|
+
bbox=[xloc, yloc, width, height])
|
|
797
|
+
table.auto_set_font_size(False)
|
|
798
|
+
table.set_fontsize(8)
|
|
799
|
+
auto_size_table_to_content(ax, table, col_labels, table_data, width, height)
|
|
800
|
+
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):
|
|
801
|
+
"""
|
|
802
|
+
Automatically adjusts table column widths and row heights based on content.
|
|
803
|
+
|
|
804
|
+
Measures text extents using the matplotlib renderer and sets column widths proportional
|
|
805
|
+
to content while enforcing minimum and maximum constraints. The "Name" column gets
|
|
806
|
+
more space (18-30%) while numeric columns are constrained to prevent excessive whitespace.
|
|
807
|
+
Row heights are uniform and based on the tallest content in each row.
|
|
808
|
+
|
|
809
|
+
Parameters:
|
|
810
|
+
ax: matplotlib Axes object containing the table
|
|
811
|
+
table: matplotlib Table object to be resized
|
|
812
|
+
col_labels: List of column header labels (str)
|
|
813
|
+
table_data: List of lists containing table cell data
|
|
814
|
+
table_width: Total table width in axes coordinates (0-1)
|
|
815
|
+
table_height: Total table height in axes coordinates (0-1)
|
|
816
|
+
min_row_frac: Minimum row height as fraction of axes height (default: 0.02)
|
|
817
|
+
row_pad: Padding factor applied to measured row heights (default: 1.35)
|
|
818
|
+
col_min_frac: Minimum column width as fraction of table width for numeric columns (default: 0.08)
|
|
819
|
+
col_max_frac: Maximum column width as fraction of table width for numeric columns (default: 0.15)
|
|
820
|
+
|
|
821
|
+
Returns:
|
|
822
|
+
None
|
|
823
|
+
|
|
824
|
+
Notes:
|
|
825
|
+
- The "Name" column is automatically left-aligned and gets 18-30% of table width
|
|
826
|
+
- Numeric columns are center-aligned and constrained to 8-15% of table width
|
|
827
|
+
- All three material table types (LEM, SEEP, FEM) use the same sizing parameters
|
|
828
|
+
"""
|
|
829
|
+
# Force draw to get a valid renderer
|
|
830
|
+
try:
|
|
831
|
+
ax.figure.canvas.draw()
|
|
832
|
+
renderer = ax.figure.canvas.get_renderer()
|
|
833
|
+
except Exception:
|
|
834
|
+
renderer = None
|
|
835
|
+
|
|
836
|
+
ncols = len(col_labels)
|
|
837
|
+
nrows = len(table_data) + 1 # include header
|
|
838
|
+
# Measure text widths per column in pixels
|
|
839
|
+
widths_px = [1.0] * ncols
|
|
840
|
+
if renderer is not None:
|
|
841
|
+
for c in range(ncols):
|
|
842
|
+
max_w = 1.0
|
|
843
|
+
for r in range(nrows):
|
|
844
|
+
cell = table[(r, c)]
|
|
845
|
+
text = cell.get_text()
|
|
846
|
+
try:
|
|
847
|
+
bbox = text.get_window_extent(renderer=renderer)
|
|
848
|
+
max_w = max(max_w, bbox.width)
|
|
849
|
+
except Exception:
|
|
850
|
+
pass
|
|
851
|
+
widths_px[c] = max_w
|
|
852
|
+
total_w = sum(widths_px) if sum(widths_px) > 0 else float(ncols)
|
|
853
|
+
col_fracs = [w / total_w for w in widths_px]
|
|
854
|
+
# Clamp extreme column widths to keep numeric columns from becoming too wide
|
|
855
|
+
clamped = []
|
|
856
|
+
for i, frac in enumerate(col_fracs):
|
|
857
|
+
label = str(col_labels[i]).lower()
|
|
858
|
+
min_frac = col_min_frac
|
|
859
|
+
max_frac = col_max_frac
|
|
860
|
+
if label == "name":
|
|
861
|
+
min_frac = 0.18
|
|
862
|
+
max_frac = 0.30
|
|
863
|
+
clamped.append(min(max(frac, min_frac), max_frac))
|
|
864
|
+
# Re-normalize to sum to 1.0
|
|
865
|
+
s = sum(clamped)
|
|
866
|
+
if s > 0:
|
|
867
|
+
col_fracs = [c / s for c in clamped]
|
|
868
|
+
# Compute per-row pixel heights based on text extents, convert to axes fraction
|
|
869
|
+
axes_h_px = None
|
|
870
|
+
if renderer is not None:
|
|
871
|
+
try:
|
|
872
|
+
axes_h_px = ax.get_window_extent(renderer=renderer).height
|
|
873
|
+
except Exception:
|
|
874
|
+
axes_h_px = None
|
|
875
|
+
# Fallback axes height if needed (avoid division by zero)
|
|
876
|
+
if not axes_h_px or axes_h_px <= 0:
|
|
877
|
+
axes_h_px = 800.0 # arbitrary but reasonable default
|
|
878
|
+
row_heights_frac = []
|
|
879
|
+
for r in range(nrows):
|
|
880
|
+
max_h_px = 1.0
|
|
881
|
+
if renderer is not None:
|
|
882
|
+
for c in range(ncols):
|
|
883
|
+
try:
|
|
884
|
+
bbox = table[(r, c)].get_text().get_window_extent(renderer=renderer)
|
|
885
|
+
max_h_px = max(max_h_px, bbox.height)
|
|
886
|
+
except Exception:
|
|
887
|
+
pass
|
|
888
|
+
# padding factor to provide breathing room around text
|
|
889
|
+
padded_px = max_h_px * row_pad
|
|
890
|
+
# Convert to axes fraction with minimum clamp
|
|
891
|
+
rh = max(padded_px / axes_h_px, min_row_frac)
|
|
892
|
+
row_heights_frac.append(rh)
|
|
893
|
+
|
|
894
|
+
# Apply column widths and per-row heights
|
|
895
|
+
for r in range(nrows):
|
|
896
|
+
for c in range(ncols):
|
|
897
|
+
cell = table[(r, c)]
|
|
898
|
+
cell.set_width(table_width * col_fracs[c])
|
|
899
|
+
cell.set_height(row_heights_frac[r])
|
|
900
|
+
# Left-align the "Name" column if present
|
|
901
|
+
label = str(col_labels[c]).lower()
|
|
902
|
+
if label == "name":
|
|
903
|
+
cell.get_text().set_ha('left')
|
|
517
904
|
|
|
518
905
|
def plot_base_stresses(ax, slice_df, scale_frac=0.5, alpha=0.3):
|
|
519
906
|
"""
|
|
@@ -717,31 +1104,65 @@ def plot_reinforcement_lines(ax, slope_data):
|
|
|
717
1104
|
tension_points_plotted = True
|
|
718
1105
|
|
|
719
1106
|
|
|
720
|
-
def plot_inputs(
|
|
1107
|
+
def plot_inputs(
|
|
1108
|
+
slope_data,
|
|
1109
|
+
title="Slope Geometry and Inputs",
|
|
1110
|
+
figsize=(12, 6),
|
|
1111
|
+
mat_table=True,
|
|
1112
|
+
save_png=False,
|
|
1113
|
+
dpi=300,
|
|
1114
|
+
mode="lem",
|
|
1115
|
+
tab_loc="upper left",
|
|
1116
|
+
legend_ncol="auto",
|
|
1117
|
+
legend_max_cols=6,
|
|
1118
|
+
legend_max_rows=4,
|
|
1119
|
+
):
|
|
721
1120
|
"""
|
|
722
1121
|
Creates a plot showing the slope geometry and input parameters.
|
|
723
1122
|
|
|
724
1123
|
Parameters:
|
|
725
1124
|
slope_data: Dictionary containing plot data
|
|
726
1125
|
title: Title for the plot
|
|
727
|
-
|
|
728
|
-
height: Height of the plot in inches
|
|
1126
|
+
figsize: Tuple of (width, height) in inches for the plot
|
|
729
1127
|
mat_table: Controls material table display. Can be:
|
|
730
|
-
- True:
|
|
1128
|
+
- True: Use tab_loc for positioning (default)
|
|
731
1129
|
- False: Don't show material table
|
|
732
|
-
- 'auto':
|
|
733
|
-
- String: Specific location
|
|
734
|
-
|
|
1130
|
+
- 'auto': Use tab_loc for positioning
|
|
1131
|
+
- String: Specific location from valid placements (see tab_loc)
|
|
1132
|
+
save_png: If True, save plot as PNG file (default: False)
|
|
1133
|
+
dpi: Resolution for saved PNG file (default: 300)
|
|
1134
|
+
mode: Which material properties table to display:
|
|
1135
|
+
- "lem": Limit equilibrium materials (γ, c, φ, optional d/ψ)
|
|
1136
|
+
- "seep": Seepage properties (k₁, k₂, Angle, kr₀, h₀)
|
|
1137
|
+
- "fem": FEM properties (γ, c, φ, E, ν)
|
|
1138
|
+
tab_loc: Table placement when mat_table is True or 'auto'. Valid options:
|
|
1139
|
+
- "upper left": Top-left corner of plot area
|
|
1140
|
+
- "upper right": Top-right corner of plot area
|
|
1141
|
+
- "upper center": Top-center of plot area
|
|
1142
|
+
- "lower left": Bottom-left corner of plot area
|
|
1143
|
+
- "lower right": Bottom-right corner of plot area
|
|
1144
|
+
- "lower center": Bottom-center of plot area
|
|
1145
|
+
- "center left": Middle-left of plot area
|
|
1146
|
+
- "center right": Middle-right of plot area
|
|
1147
|
+
- "center": Center of plot area
|
|
1148
|
+
- "top": Above plot area, horizontally centered
|
|
1149
|
+
legend_ncol: Legend column count. Use "auto" (default) to choose a value that
|
|
1150
|
+
keeps the legend from getting too tall, or pass an int to force a width.
|
|
1151
|
+
legend_max_cols: When legend_ncol="auto", cap the number of columns (default: 6).
|
|
1152
|
+
legend_max_rows: When legend_ncol="auto", try to keep legend rows <= this value
|
|
1153
|
+
by increasing columns (default: 4).
|
|
735
1154
|
|
|
736
1155
|
Returns:
|
|
737
1156
|
None
|
|
738
1157
|
"""
|
|
739
|
-
fig, ax = plt.subplots(figsize=
|
|
1158
|
+
fig, ax = plt.subplots(figsize=figsize)
|
|
740
1159
|
|
|
741
1160
|
# Plot contents
|
|
742
1161
|
plot_profile_lines(ax, slope_data['profile_lines'])
|
|
743
1162
|
plot_max_depth(ax, slope_data['profile_lines'], slope_data['max_depth'])
|
|
744
1163
|
plot_piezo_line(ax, slope_data)
|
|
1164
|
+
if mode == "seep":
|
|
1165
|
+
plot_seepage_bc_lines(ax, slope_data)
|
|
745
1166
|
plot_dloads(ax, slope_data)
|
|
746
1167
|
plot_tcrack_surface(ax, slope_data['tcrack_surface'])
|
|
747
1168
|
plot_reinforcement_lines(ax, slope_data)
|
|
@@ -753,32 +1174,116 @@ def plot_inputs(slope_data, title="Slope Geometry and Inputs", width=12, height=
|
|
|
753
1174
|
|
|
754
1175
|
# Handle material table display
|
|
755
1176
|
if mat_table:
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
1177
|
+
# Helpers to adapt slope_data materials into formats expected by table functions
|
|
1178
|
+
def _build_seep_data():
|
|
1179
|
+
materials = slope_data.get('materials', [])
|
|
1180
|
+
return {
|
|
1181
|
+
"k1_by_mat": [m.get('k1', 0.0) for m in materials],
|
|
1182
|
+
"k2_by_mat": [m.get('k2', 0.0) for m in materials],
|
|
1183
|
+
"angle_by_mat": [m.get('alpha', 0.0) for m in materials],
|
|
1184
|
+
"kr0_by_mat": [m.get('kr0', 0.0) for m in materials],
|
|
1185
|
+
"h0_by_mat": [m.get('h0', 0.0) for m in materials],
|
|
1186
|
+
"material_names": [m.get('name', f"Material {i+1}") for i, m in enumerate(materials)],
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
def _build_fem_data():
|
|
1190
|
+
materials = slope_data.get('materials', [])
|
|
1191
|
+
return {
|
|
1192
|
+
"c_by_mat": [m.get('c', 0.0) for m in materials],
|
|
1193
|
+
"phi_by_mat": [m.get('phi', 0.0) for m in materials],
|
|
1194
|
+
"E_by_mat": [m.get('E', 0.0) for m in materials],
|
|
1195
|
+
"nu_by_mat": [m.get('nu', 0.0) for m in materials],
|
|
1196
|
+
"gamma_by_mat": [m.get('gamma', 0.0) for m in materials],
|
|
1197
|
+
"material_names": [m.get('name', f"Material {i+1}") for i, m in enumerate(materials)],
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
def _estimate_table_dims():
|
|
1201
|
+
"""Estimate table dimensions based on mode and materials."""
|
|
1202
|
+
materials = slope_data.get('materials', [])
|
|
1203
|
+
num_rows = max(1, len(materials))
|
|
1204
|
+
|
|
1205
|
+
if mode == "lem":
|
|
1206
|
+
has_d_psi = any(mat.get('d', 0) > 0 or mat.get('psi', 0) > 0 for mat in materials)
|
|
1207
|
+
width = 0.25 if has_d_psi else 0.2
|
|
1208
|
+
height = min(0.35, 0.06 + 0.035 * num_rows)
|
|
1209
|
+
elif mode == "fem":
|
|
1210
|
+
width = 0.60
|
|
1211
|
+
height = min(0.32, 0.06 + 0.035 * num_rows)
|
|
1212
|
+
elif mode == "seep":
|
|
1213
|
+
width = 0.45
|
|
1214
|
+
height = min(0.50, 0.10 + 0.06 * num_rows)
|
|
1215
|
+
else:
|
|
1216
|
+
raise ValueError(f"Unknown mode '{mode}'. Expected one of: 'lem', 'seep', 'fem'.")
|
|
1217
|
+
|
|
1218
|
+
return width, height
|
|
1219
|
+
|
|
1220
|
+
def _plot_table(ax, xloc, yloc):
|
|
1221
|
+
"""Plot the appropriate material table based on mode."""
|
|
1222
|
+
if mode == "lem":
|
|
1223
|
+
plot_lem_material_table(ax, slope_data['materials'], xloc=xloc, yloc=yloc)
|
|
1224
|
+
elif mode == "seep":
|
|
1225
|
+
plot_seep_material_table(ax, _build_seep_data(), xloc=xloc, yloc=yloc)
|
|
1226
|
+
elif mode == "fem":
|
|
1227
|
+
width, height = _estimate_table_dims()
|
|
1228
|
+
plot_fem_material_table(ax, _build_fem_data(), xloc=xloc, yloc=yloc, width=width, height=height)
|
|
1229
|
+
|
|
1230
|
+
def _calculate_position(location, margin=0.03):
|
|
1231
|
+
"""Calculate xloc, yloc for a given location string."""
|
|
1232
|
+
width, height = _estimate_table_dims()
|
|
1233
|
+
|
|
1234
|
+
# Location map with default coordinates
|
|
1235
|
+
position_map = {
|
|
1236
|
+
'upper left': (margin, max(0.0, 1.0 - margin - height)),
|
|
1237
|
+
'upper right': (max(0.0, 1.0 - width - margin), max(0.0, 1.0 - margin - height)),
|
|
761
1238
|
'upper center': (0.35, 0.70),
|
|
762
1239
|
'lower left': (0.05, 0.05),
|
|
763
1240
|
'lower right': (0.70, 0.05),
|
|
764
1241
|
'lower center': (0.35, 0.05),
|
|
765
1242
|
'center left': (0.05, 0.35),
|
|
766
1243
|
'center right': (0.70, 0.35),
|
|
767
|
-
'center': (0.35, 0.35)
|
|
1244
|
+
'center': (0.35, 0.35),
|
|
1245
|
+
'top': ((1.0 - width) / 2.0, 1.16)
|
|
768
1246
|
}
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
1247
|
+
|
|
1248
|
+
return position_map.get(location, position_map['upper right'])
|
|
1249
|
+
|
|
1250
|
+
# Determine which location to use
|
|
1251
|
+
placement = mat_table if isinstance(mat_table, str) and mat_table != 'auto' else tab_loc
|
|
1252
|
+
|
|
1253
|
+
# Validate placement
|
|
1254
|
+
valid_placements = ['upper left', 'upper right', 'upper center', 'lower left',
|
|
1255
|
+
'lower right', 'lower center', 'center left', 'center right', 'center', 'top']
|
|
1256
|
+
if placement not in valid_placements:
|
|
1257
|
+
raise ValueError(f"Unknown placement '{placement}'. Expected one of: {', '.join(valid_placements)}.")
|
|
1258
|
+
|
|
1259
|
+
# Calculate position and plot table
|
|
1260
|
+
xloc, yloc = _calculate_position(placement)
|
|
1261
|
+
_plot_table(ax, xloc, yloc)
|
|
1262
|
+
|
|
1263
|
+
# Adjust y-limits to prevent table overlap with plot data
|
|
1264
|
+
if placement in ("upper left", "upper right", "upper center"):
|
|
1265
|
+
_, height = _estimate_table_dims()
|
|
1266
|
+
margin = 0.03
|
|
1267
|
+
bottom_fraction = max(0.0, 1.0 - margin - height)
|
|
1268
|
+
|
|
1269
|
+
y_min_curr, y_max_curr = ax.get_ylim()
|
|
1270
|
+
y_range = y_max_curr - y_min_curr
|
|
1271
|
+
if y_range > 0:
|
|
1272
|
+
elem_bounds = get_plot_elements_bounds(ax, slope_data)
|
|
1273
|
+
if elem_bounds:
|
|
1274
|
+
y_top = max(b[3] for b in elem_bounds)
|
|
1275
|
+
y_norm = (y_top - y_min_curr) / y_range
|
|
1276
|
+
if y_norm >= bottom_fraction and bottom_fraction > 0:
|
|
1277
|
+
y_max_new = y_min_curr + (y_top - y_min_curr) / bottom_fraction
|
|
1278
|
+
ax.set_ylim(y_min_curr, y_max_new)
|
|
780
1279
|
|
|
781
1280
|
ax.set_aspect('equal') # ✅ Equal aspect
|
|
1281
|
+
|
|
1282
|
+
# Add a bit of headroom so plotted lines/markers don't touch the top border
|
|
1283
|
+
y0, y1 = ax.get_ylim()
|
|
1284
|
+
if y1 > y0:
|
|
1285
|
+
pad = 0.05 * (y1 - y0)
|
|
1286
|
+
ax.set_ylim(y0, y1 + pad)
|
|
782
1287
|
ax.set_xlabel("x")
|
|
783
1288
|
ax.set_ylabel("y")
|
|
784
1289
|
ax.grid(False)
|
|
@@ -792,22 +1297,49 @@ def plot_inputs(slope_data, title="Slope Geometry and Inputs", width=12, height=
|
|
|
792
1297
|
handles.append(dummy_line)
|
|
793
1298
|
labels.append('Distributed Load')
|
|
794
1299
|
|
|
1300
|
+
# --- Legend layout ---
|
|
1301
|
+
# Historically this legend used ncol=2 and was anchored below the axes.
|
|
1302
|
+
# If there are many entries, that makes the legend tall and it can fall
|
|
1303
|
+
# off the bottom of the window. We auto-increase columns to cap row count,
|
|
1304
|
+
# and we also reserve bottom margin so the legend stays visible.
|
|
1305
|
+
n_items = len(labels)
|
|
1306
|
+
if legend_ncol == "auto":
|
|
1307
|
+
# Choose enough columns to keep row count <= legend_max_rows (as best we can),
|
|
1308
|
+
# but never exceed legend_max_cols.
|
|
1309
|
+
required_cols = max(1, math.ceil(n_items / max(1, int(legend_max_rows))))
|
|
1310
|
+
ncol = min(int(legend_max_cols), required_cols)
|
|
1311
|
+
# Keep at least 2 columns once there's more than one entry (matches prior look).
|
|
1312
|
+
if n_items > 1:
|
|
1313
|
+
ncol = max(2, ncol)
|
|
1314
|
+
else:
|
|
1315
|
+
ncol = max(1, int(legend_ncol))
|
|
1316
|
+
|
|
1317
|
+
n_rows = max(1, math.ceil(n_items / max(1, ncol)))
|
|
1318
|
+
# Reserve a bit more space as the legend grows so it doesn't get clipped.
|
|
1319
|
+
bottom_margin = min(0.45, 0.10 + 0.04 * n_rows)
|
|
1320
|
+
|
|
795
1321
|
ax.legend(
|
|
796
1322
|
handles=handles,
|
|
797
1323
|
labels=labels,
|
|
798
|
-
loc=
|
|
1324
|
+
loc="upper center",
|
|
799
1325
|
bbox_to_anchor=(0.5, -0.12),
|
|
800
|
-
ncol=
|
|
1326
|
+
ncol=ncol,
|
|
801
1327
|
)
|
|
802
1328
|
|
|
803
1329
|
ax.set_title(title)
|
|
804
1330
|
|
|
1331
|
+
plt.subplots_adjust(bottom=bottom_margin)
|
|
805
1332
|
plt.tight_layout()
|
|
1333
|
+
|
|
1334
|
+
if save_png:
|
|
1335
|
+
filename = 'plot_' + title.lower().replace(' ', '_').replace(':', '').replace(',', '') + '.png'
|
|
1336
|
+
plt.savefig(filename, dpi=dpi, bbox_inches='tight')
|
|
1337
|
+
|
|
806
1338
|
plt.show()
|
|
807
1339
|
|
|
808
1340
|
# ========== Main Plotting Function =========
|
|
809
1341
|
|
|
810
|
-
def plot_solution(slope_data, slice_df, failure_surface, results,
|
|
1342
|
+
def plot_solution(slope_data, slice_df, failure_surface, results, figsize=(12, 7), slice_numbers=False, save_png=False, dpi=300):
|
|
811
1343
|
"""
|
|
812
1344
|
Plots the full solution including slices, numbers, thrust line, and base stresses.
|
|
813
1345
|
|
|
@@ -816,13 +1348,12 @@ def plot_solution(slope_data, slice_df, failure_surface, results, width=12, heig
|
|
|
816
1348
|
slice_df: DataFrame containing slice data
|
|
817
1349
|
failure_surface: Failure surface geometry
|
|
818
1350
|
results: Solution results
|
|
819
|
-
|
|
820
|
-
height: Height of the plot in inches
|
|
1351
|
+
figsize: Tuple of (width, height) in inches for the plot
|
|
821
1352
|
|
|
822
1353
|
Returns:
|
|
823
1354
|
None
|
|
824
1355
|
"""
|
|
825
|
-
fig, ax = plt.subplots(figsize=
|
|
1356
|
+
fig, ax = plt.subplots(figsize=figsize)
|
|
826
1357
|
ax.set_xlabel("x")
|
|
827
1358
|
ax.set_ylabel("y")
|
|
828
1359
|
ax.grid(False)
|
|
@@ -889,6 +1420,8 @@ def plot_solution(slope_data, slice_df, failure_surface, results, width=12, heig
|
|
|
889
1420
|
title = f'Corps Engineers: FS = {fs:.3f}, θ = {theta:.2f}°'
|
|
890
1421
|
elif method == 'lowe_karafiath':
|
|
891
1422
|
title = f'Lowe & Karafiath: FS = {fs:.3f}'
|
|
1423
|
+
else:
|
|
1424
|
+
title = f'{method}: FS = {fs:.3f}'
|
|
892
1425
|
ax.set_title(title)
|
|
893
1426
|
|
|
894
1427
|
# zoom y‐axis to just cover the slope and depth, with a little breathing room (thrust line can be outside)
|
|
@@ -896,6 +1429,11 @@ def plot_solution(slope_data, slice_df, failure_surface, results, width=12, heig
|
|
|
896
1429
|
ax.set_ylim(ymin, ymax)
|
|
897
1430
|
|
|
898
1431
|
plt.tight_layout()
|
|
1432
|
+
|
|
1433
|
+
if save_png:
|
|
1434
|
+
filename = 'plot_' + title.lower().replace(' ', '_').replace(':', '').replace(',', '').replace('°', 'deg') + '.png'
|
|
1435
|
+
plt.savefig(filename, dpi=dpi, bbox_inches='tight')
|
|
1436
|
+
|
|
899
1437
|
plt.show()
|
|
900
1438
|
|
|
901
1439
|
# ========== Functions for Search Results =========
|
|
@@ -956,7 +1494,7 @@ def plot_search_path(ax, search_path):
|
|
|
956
1494
|
ax.arrow(start['x'], start['y'], dx, dy,
|
|
957
1495
|
head_width=1, head_length=2, fc='green', ec='green', length_includes_head=True)
|
|
958
1496
|
|
|
959
|
-
def plot_circular_search_results(slope_data, fs_cache, search_path=None, highlight_fs=True,
|
|
1497
|
+
def plot_circular_search_results(slope_data, fs_cache, search_path=None, highlight_fs=True, figsize=(12, 7), save_png=False, dpi=300):
|
|
960
1498
|
"""
|
|
961
1499
|
Creates a plot showing the results of a circular failure surface search.
|
|
962
1500
|
|
|
@@ -965,13 +1503,12 @@ def plot_circular_search_results(slope_data, fs_cache, search_path=None, highlig
|
|
|
965
1503
|
fs_cache: List of dictionaries containing failure surface data and FS values
|
|
966
1504
|
search_path: List of dictionaries containing search path coordinates
|
|
967
1505
|
highlight_fs: Boolean indicating whether to highlight the critical failure surface
|
|
968
|
-
|
|
969
|
-
height: Height of the plot in inches
|
|
1506
|
+
figsize: Tuple of (width, height) in inches for the plot
|
|
970
1507
|
|
|
971
1508
|
Returns:
|
|
972
1509
|
None
|
|
973
1510
|
"""
|
|
974
|
-
fig, ax = plt.subplots(figsize=
|
|
1511
|
+
fig, ax = plt.subplots(figsize=figsize)
|
|
975
1512
|
|
|
976
1513
|
plot_profile_lines(ax, slope_data['profile_lines'])
|
|
977
1514
|
plot_max_depth(ax, slope_data['profile_lines'], slope_data['max_depth'])
|
|
@@ -995,9 +1532,14 @@ def plot_circular_search_results(slope_data, fs_cache, search_path=None, highlig
|
|
|
995
1532
|
ax.set_title(f"Critical Factor of Safety = {critical_fs:.3f}")
|
|
996
1533
|
|
|
997
1534
|
plt.tight_layout()
|
|
1535
|
+
|
|
1536
|
+
if save_png:
|
|
1537
|
+
filename = 'plot_circular_search_results.png'
|
|
1538
|
+
plt.savefig(filename, dpi=dpi, bbox_inches='tight')
|
|
1539
|
+
|
|
998
1540
|
plt.show()
|
|
999
1541
|
|
|
1000
|
-
def plot_noncircular_search_results(slope_data, fs_cache, search_path=None, highlight_fs=True,
|
|
1542
|
+
def plot_noncircular_search_results(slope_data, fs_cache, search_path=None, highlight_fs=True, figsize=(12, 7), save_png=False, dpi=300):
|
|
1001
1543
|
"""
|
|
1002
1544
|
Creates a plot showing the results of a non-circular failure surface search.
|
|
1003
1545
|
|
|
@@ -1006,13 +1548,12 @@ def plot_noncircular_search_results(slope_data, fs_cache, search_path=None, high
|
|
|
1006
1548
|
fs_cache: List of dictionaries containing failure surface data and FS values
|
|
1007
1549
|
search_path: List of dictionaries containing search path coordinates
|
|
1008
1550
|
highlight_fs: Boolean indicating whether to highlight the critical failure surface
|
|
1009
|
-
|
|
1010
|
-
height: Height of the plot in inches
|
|
1551
|
+
figsize: Tuple of (width, height) in inches for the plot
|
|
1011
1552
|
|
|
1012
1553
|
Returns:
|
|
1013
1554
|
None
|
|
1014
1555
|
"""
|
|
1015
|
-
fig, ax = plt.subplots(figsize=
|
|
1556
|
+
fig, ax = plt.subplots(figsize=figsize)
|
|
1016
1557
|
|
|
1017
1558
|
# Plot basic profile elements
|
|
1018
1559
|
plot_profile_lines(ax, slope_data['profile_lines'])
|
|
@@ -1060,22 +1601,26 @@ def plot_noncircular_search_results(slope_data, fs_cache, search_path=None, high
|
|
|
1060
1601
|
ax.set_title(f"Critical Factor of Safety = {critical_fs:.3f}")
|
|
1061
1602
|
|
|
1062
1603
|
plt.tight_layout()
|
|
1604
|
+
|
|
1605
|
+
if save_png:
|
|
1606
|
+
filename = 'plot_noncircular_search_results.png'
|
|
1607
|
+
plt.savefig(filename, dpi=dpi, bbox_inches='tight')
|
|
1608
|
+
|
|
1063
1609
|
plt.show()
|
|
1064
1610
|
|
|
1065
|
-
def plot_reliability_results(slope_data, reliability_data,
|
|
1611
|
+
def plot_reliability_results(slope_data, reliability_data, figsize=(12, 7), save_png=False, dpi=300):
|
|
1066
1612
|
"""
|
|
1067
1613
|
Creates a plot showing the results of reliability analysis.
|
|
1068
1614
|
|
|
1069
1615
|
Parameters:
|
|
1070
1616
|
slope_data: Dictionary containing plot data
|
|
1071
1617
|
reliability_data: Dictionary containing reliability analysis results
|
|
1072
|
-
|
|
1073
|
-
height: Height of the plot in inches
|
|
1618
|
+
figsize: Tuple of (width, height) in inches for the plot
|
|
1074
1619
|
|
|
1075
1620
|
Returns:
|
|
1076
1621
|
None
|
|
1077
1622
|
"""
|
|
1078
|
-
fig, ax = plt.subplots(figsize=
|
|
1623
|
+
fig, ax = plt.subplots(figsize=figsize)
|
|
1079
1624
|
|
|
1080
1625
|
# Plot basic slope elements (same as other search functions)
|
|
1081
1626
|
plot_profile_lines(ax, slope_data['profile_lines'])
|
|
@@ -1143,9 +1688,14 @@ def plot_reliability_results(slope_data, reliability_data, width=12, height=7):
|
|
|
1143
1688
|
f"Reliability = {reliability*100:.2f}%, $P_f$ = {prob_failure*100:.2f}%")
|
|
1144
1689
|
|
|
1145
1690
|
plt.tight_layout()
|
|
1691
|
+
|
|
1692
|
+
if save_png:
|
|
1693
|
+
filename = 'plot_reliability_results.png'
|
|
1694
|
+
plt.savefig(filename, dpi=dpi, bbox_inches='tight')
|
|
1695
|
+
|
|
1146
1696
|
plt.show()
|
|
1147
1697
|
|
|
1148
|
-
def plot_mesh(mesh, materials=None, figsize=(14, 6), pad_frac=0.05, show_nodes=True, label_elements=False, label_nodes=False):
|
|
1698
|
+
def plot_mesh(mesh, materials=None, figsize=(14, 6), pad_frac=0.05, show_nodes=True, label_elements=False, label_nodes=False, save_png=False, dpi=300):
|
|
1149
1699
|
"""
|
|
1150
1700
|
Plot the finite element mesh with material regions.
|
|
1151
1701
|
|
|
@@ -1332,15 +1882,24 @@ def plot_mesh(mesh, materials=None, figsize=(14, 6), pad_frac=0.05, show_nodes=T
|
|
|
1332
1882
|
ax.set_ylim(y_min - y_pad, y_max + y_pad)
|
|
1333
1883
|
|
|
1334
1884
|
plt.tight_layout()
|
|
1885
|
+
|
|
1886
|
+
if save_png:
|
|
1887
|
+
filename = 'plot_mesh.png'
|
|
1888
|
+
plt.savefig(filename, dpi=dpi, bbox_inches='tight')
|
|
1889
|
+
|
|
1335
1890
|
plt.show()
|
|
1336
1891
|
|
|
1337
1892
|
|
|
1338
|
-
def plot_polygons(polygons, title="Material Zone Polygons"):
|
|
1893
|
+
def plot_polygons(polygons, materials=None, nodes=False, legend=True, title="Material Zone Polygons", save_png=False, dpi=300):
|
|
1339
1894
|
"""
|
|
1340
1895
|
Plot all material zone polygons in a single figure.
|
|
1341
1896
|
|
|
1342
1897
|
Parameters:
|
|
1343
1898
|
polygons: List of polygon coordinate lists
|
|
1899
|
+
materials: Optional list of material dicts (with key "name") or list of material
|
|
1900
|
+
name strings. If provided, the material name will be used in the legend.
|
|
1901
|
+
nodes: If True, plot each polygon vertex as a dot.
|
|
1902
|
+
legend: If True, show the legend.
|
|
1344
1903
|
title: Plot title
|
|
1345
1904
|
"""
|
|
1346
1905
|
import matplotlib.pyplot as plt
|
|
@@ -1349,25 +1908,43 @@ def plot_polygons(polygons, title="Material Zone Polygons"):
|
|
|
1349
1908
|
for i, polygon in enumerate(polygons):
|
|
1350
1909
|
xs = [x for x, y in polygon]
|
|
1351
1910
|
ys = [y for x, y in polygon]
|
|
1352
|
-
|
|
1911
|
+
mat_name = None
|
|
1912
|
+
if materials is not None and i < len(materials):
|
|
1913
|
+
item = materials[i]
|
|
1914
|
+
if isinstance(item, dict):
|
|
1915
|
+
mat_name = item.get("name", None)
|
|
1916
|
+
elif isinstance(item, str):
|
|
1917
|
+
mat_name = item
|
|
1918
|
+
label = mat_name if mat_name else f"Material {i}"
|
|
1919
|
+
ax.fill(xs, ys, color=get_material_color(i), alpha=0.6, label=label)
|
|
1353
1920
|
ax.plot(xs, ys, color=get_material_color(i), linewidth=1)
|
|
1921
|
+
if nodes:
|
|
1922
|
+
# Avoid legend clutter by not adding a label here.
|
|
1923
|
+
ax.scatter(xs, ys, color='k', s=30, marker='o', zorder=3)
|
|
1354
1924
|
ax.set_xlabel('X Coordinate')
|
|
1355
1925
|
ax.set_ylabel('Y Coordinate')
|
|
1356
1926
|
ax.set_title(title)
|
|
1357
|
-
|
|
1927
|
+
if legend:
|
|
1928
|
+
ax.legend()
|
|
1358
1929
|
ax.grid(True, alpha=0.3)
|
|
1359
1930
|
ax.set_aspect('equal')
|
|
1360
1931
|
plt.tight_layout()
|
|
1932
|
+
|
|
1933
|
+
if save_png:
|
|
1934
|
+
filename = 'plot_' + title.lower().replace(' ', '_').replace(':', '').replace(',', '') + '.png'
|
|
1935
|
+
plt.savefig(filename, dpi=dpi, bbox_inches='tight')
|
|
1936
|
+
|
|
1361
1937
|
plt.show()
|
|
1362
1938
|
|
|
1363
1939
|
|
|
1364
|
-
def plot_polygons_separately(polygons,
|
|
1940
|
+
def plot_polygons_separately(polygons, materials=None, save_png=False, dpi=300):
|
|
1365
1941
|
"""
|
|
1366
1942
|
Plot each polygon in a separate matplotlib frame (subplot), with vertices as round dots.
|
|
1367
1943
|
|
|
1368
1944
|
Parameters:
|
|
1369
1945
|
polygons: List of polygon coordinate lists
|
|
1370
|
-
|
|
1946
|
+
materials: Optional list of material dicts (with key "name") or list of material
|
|
1947
|
+
name strings. If provided, the material name will be included in each subplot title.
|
|
1371
1948
|
"""
|
|
1372
1949
|
import matplotlib.pyplot as plt
|
|
1373
1950
|
|
|
@@ -1382,11 +1959,27 @@ def plot_polygons_separately(polygons, title_prefix='Material Zone'):
|
|
|
1382
1959
|
ax.scatter(xs, ys, color='k', s=30, marker='o', zorder=3, label='Vertices')
|
|
1383
1960
|
ax.set_xlabel('X Coordinate')
|
|
1384
1961
|
ax.set_ylabel('Y Coordinate')
|
|
1385
|
-
|
|
1962
|
+
mat_name = None
|
|
1963
|
+
if materials is not None and i < len(materials):
|
|
1964
|
+
item = materials[i]
|
|
1965
|
+
if isinstance(item, dict):
|
|
1966
|
+
mat_name = item.get("name", None)
|
|
1967
|
+
elif isinstance(item, str):
|
|
1968
|
+
mat_name = item
|
|
1969
|
+
if mat_name:
|
|
1970
|
+
ax.set_title(f'Material {i}: {mat_name}')
|
|
1971
|
+
else:
|
|
1972
|
+
ax.set_title(f'Material {i}')
|
|
1386
1973
|
ax.grid(True, alpha=0.3)
|
|
1387
1974
|
ax.set_aspect('equal')
|
|
1388
|
-
|
|
1975
|
+
# Intentionally no legend: these plots are typically used for debugging geometry,
|
|
1976
|
+
# and legends can obscure key vertices/edges.
|
|
1389
1977
|
plt.tight_layout()
|
|
1978
|
+
|
|
1979
|
+
if save_png:
|
|
1980
|
+
filename = 'plot_polygons_separately.png'
|
|
1981
|
+
plt.savefig(filename, dpi=dpi, bbox_inches='tight')
|
|
1982
|
+
|
|
1390
1983
|
plt.show()
|
|
1391
1984
|
|
|
1392
1985
|
|