xslope 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
xslope/plot.py ADDED
@@ -0,0 +1,1484 @@
1
+ # Copyright 2025 Norman L. Jones
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ import matplotlib.pyplot as plt
16
+ import numpy as np
17
+ from matplotlib.lines import Line2D
18
+ from matplotlib.path import Path
19
+ from shapely.geometry import LineString
20
+
21
+ from .slice import generate_failure_surface
22
+
23
+ # Configure matplotlib for better text rendering
24
+ plt.rcParams.update({
25
+ "text.usetex": False,
26
+ "font.family": "sans-serif",
27
+ "font.size": 10
28
+ })
29
+
30
+ # Consistent color for materials (Tableau tab10)
31
+ def get_material_color(idx):
32
+ tableau_colors = plt.get_cmap('tab10').colors # 10 distinct colors
33
+ return tableau_colors[idx % len(tableau_colors)]
34
+
35
+ def get_dload_legend_handler():
36
+ """
37
+ Creates and returns a custom legend entry for distributed loads.
38
+ Returns a tuple of (handler_class, dummy_patch) for use in matplotlib legends.
39
+ """
40
+ # Create a line with built-in arrow marker
41
+ dummy_line = Line2D([0.0, 1.0], [0, 0], # Two points to define line
42
+ color='purple',
43
+ alpha=0.7,
44
+ linewidth=2,
45
+ marker='>', # Built-in right arrow marker
46
+ markersize=6, # Smaller marker size
47
+ markerfacecolor='purple',
48
+ markeredgecolor='purple',
49
+ drawstyle='steps-post', # Draw line then marker
50
+ solid_capstyle='butt')
51
+
52
+ return None, dummy_line
53
+
54
+
55
+ def plot_profile_lines(ax, profile_lines):
56
+ """
57
+ Plots the profile lines for each material in the slope.
58
+
59
+ Parameters:
60
+ ax: matplotlib Axes object
61
+ profile_lines: List of line coordinates representing material boundaries
62
+
63
+ Returns:
64
+ None
65
+ """
66
+ for i, line in enumerate(profile_lines):
67
+ xs, ys = zip(*line)
68
+ ax.plot(xs, ys, color=get_material_color(i), linewidth=1, label=f'Profile {i+1}')
69
+
70
+ def plot_max_depth(ax, profile_lines, max_depth):
71
+ """
72
+ Plots a horizontal line representing the maximum depth limit with hash marks.
73
+
74
+ Parameters:
75
+ ax: matplotlib Axes object
76
+ profile_lines: List of line coordinates representing material boundaries
77
+ max_depth: Maximum allowed depth for analysis
78
+
79
+ Returns:
80
+ None
81
+ """
82
+ if max_depth is None:
83
+ return
84
+ x_vals = [x for line in profile_lines for x, _ in line]
85
+ x_min = min(x_vals)
86
+ x_max = max(x_vals)
87
+ ax.hlines(max_depth, x_min, x_max, colors='black', linewidth=1.5, label='Max Depth')
88
+
89
+ spacing = 5
90
+ length = 4
91
+ angle_rad = np.radians(60)
92
+ dx = length * np.cos(angle_rad)
93
+ dy = length * np.sin(angle_rad)
94
+ x_hashes = np.arange(x_min, x_max, spacing)[1:]
95
+ for x in x_hashes:
96
+ ax.plot([x, x - dx], [max_depth, max_depth - dy], color='black', linewidth=1)
97
+
98
+ def plot_failure_surface(ax, failure_surface):
99
+ """
100
+ Plots the failure surface as a black line.
101
+
102
+ Parameters:
103
+ ax: matplotlib Axes object
104
+ failure_surface: Shapely LineString representing the failure surface
105
+
106
+ Returns:
107
+ None
108
+ """
109
+ if failure_surface:
110
+ x_clip, y_clip = zip(*failure_surface.coords)
111
+ ax.plot(x_clip, y_clip, 'k-', linewidth=2, label="Failure Surface")
112
+
113
+ def plot_slices(ax, slice_df, fill=True):
114
+ """
115
+ Plots the slices used in the analysis.
116
+
117
+ Parameters:
118
+ ax: matplotlib Axes object
119
+ slice_df: DataFrame containing slice data
120
+ fill: Boolean indicating whether to fill the slices with color
121
+
122
+ Returns:
123
+ None
124
+ """
125
+ if slice_df is not None:
126
+ for _, row in slice_df.iterrows():
127
+ if fill:
128
+ xs = [row['x_l'], row['x_l'], row['x_r'], row['x_r'], row['x_l']]
129
+ ys = [row['y_lb'], row['y_lt'], row['y_rt'], row['y_rb'], row['y_lb']]
130
+ ax.plot(xs, ys, 'r-')
131
+ ax.fill(xs, ys, color='red', alpha=0.1)
132
+ else:
133
+ ax.plot([row['x_l'], row['x_l']], [row['y_lb'], row['y_lt']], 'k-', linewidth=0.5)
134
+ ax.plot([row['x_r'], row['x_r']], [row['y_rb'], row['y_rt']], 'k-', linewidth=0.5)
135
+
136
+ def plot_slice_numbers(ax, slice_df):
137
+ """
138
+ Plots the slice number in the middle of each slice at the middle height.
139
+ Numbers are 1-indexed.
140
+
141
+ Parameters:
142
+ ax: matplotlib Axes object
143
+ slice_df: DataFrame containing slice data
144
+
145
+ Returns:
146
+ None
147
+ """
148
+ if slice_df is not None:
149
+ for _, row in slice_df.iterrows():
150
+ # Calculate middle x-coordinate of the slice
151
+ x_middle = row['x_c']
152
+
153
+ # Calculate middle height of the slice
154
+ y_middle = (row['y_cb'] + row['y_ct']) / 2
155
+
156
+ # Plot the slice number (1-indexed)
157
+ slice_number = int(row['slice #'])
158
+ ax.text(x_middle, y_middle, str(slice_number),
159
+ ha='center', va='center', fontsize=8, fontweight='bold',
160
+ bbox=dict(boxstyle="round,pad=0.2", facecolor='white', alpha=0.8))
161
+
162
+ def plot_piezo_line(ax, slope_data):
163
+ """
164
+ Plots the piezometric line(s) with markers at their midpoints.
165
+
166
+ Parameters:
167
+ ax: matplotlib Axes object
168
+ data: Dictionary containing plot data with 'piezo_line' and optionally 'piezo_line2'
169
+
170
+ Returns:
171
+ None
172
+ """
173
+
174
+ def plot_single_piezo_line(ax, piezo_line, color, label):
175
+ """Internal function to plot a single piezometric line"""
176
+ if not piezo_line:
177
+ return
178
+
179
+ piezo_xs, piezo_ys = zip(*piezo_line)
180
+ ax.plot(piezo_xs, piezo_ys, color=color, linewidth=2, label=label)
181
+
182
+ # Find middle x-coordinate and corresponding y value
183
+ x_min, x_max = min(piezo_xs), max(piezo_xs)
184
+ mid_x = (x_min + x_max) / 2
185
+
186
+ # Interpolate y value at mid_x
187
+ from scipy.interpolate import interp1d
188
+ if len(piezo_xs) > 1:
189
+ f = interp1d(piezo_xs, piezo_ys, kind='linear', bounds_error=False, fill_value='extrapolate')
190
+ mid_y = f(mid_x)
191
+ ax.plot(mid_x, mid_y + 6, marker='v', color=color, markersize=8)
192
+
193
+ # Plot both piezometric lines
194
+ plot_single_piezo_line(ax, slope_data.get('piezo_line'), 'b', "Piezometric Line")
195
+ plot_single_piezo_line(ax, slope_data.get('piezo_line2'), 'skyblue', "Piezometric Line 2")
196
+
197
+ def plot_tcrack_surface(ax, tcrack_surface):
198
+ """
199
+ Plots the tension crack surface as a thin dashed red line.
200
+
201
+ Parameters:
202
+ ax: matplotlib Axes object
203
+ tcrack_surface: Shapely LineString
204
+
205
+ Returns:
206
+ None
207
+ """
208
+ if tcrack_surface is None:
209
+ return
210
+
211
+ x_vals, y_vals = tcrack_surface.xy
212
+ ax.plot(x_vals, y_vals, linestyle='--', color='red', linewidth=1.0, label='Tension Crack Depth')
213
+
214
+ def plot_dloads(ax, slope_data):
215
+ """
216
+ Plots distributed loads as arrows along the surface.
217
+ """
218
+ gamma_w = slope_data['gamma_water']
219
+ ground_surface = slope_data['ground_surface']
220
+
221
+ def plot_single_dload_set(ax, dloads, color, label):
222
+ """Internal function to plot a single set of distributed loads"""
223
+ if not dloads:
224
+ return
225
+
226
+ # find the max horizontal length of the ground surface
227
+ max_horizontal_length_ground = 0
228
+ for pt in ground_surface.coords:
229
+ max_horizontal_length_ground = max(max_horizontal_length_ground, pt[0])
230
+
231
+ arrow_spacing = max_horizontal_length_ground / 60
232
+
233
+ # find the max dload value
234
+ max_dload = 0
235
+ for line in dloads:
236
+ max_dload = max(max_dload, max(pt['Normal'] for pt in line))
237
+
238
+ arrow_height = max_dload / gamma_w
239
+ head_length = arrow_height / 12
240
+ head_width = head_length * 0.8
241
+
242
+ # Find the maximum load value for scaling
243
+ max_load = 0
244
+ for line in dloads:
245
+ max_load = max(max_load, max(pt['Normal'] for pt in line))
246
+
247
+ for line in dloads:
248
+ if len(line) < 2:
249
+ continue
250
+
251
+ xs = [pt['X'] for pt in line]
252
+ ys = [pt['Y'] for pt in line]
253
+ ns = [pt['Normal'] for pt in line]
254
+
255
+ # Process line segments
256
+ for i in range(len(line) - 1):
257
+ x1, y1, n1 = xs[i], ys[i], ns[i]
258
+ x2, y2, n2 = xs[i+1], ys[i+1], ns[i+1]
259
+
260
+ # Calculate segment direction (perpendicular to this segment)
261
+ dx = x2 - x1
262
+ dy = y2 - y1
263
+ segment_length = np.sqrt(dx**2 + dy**2)
264
+
265
+ if segment_length == 0:
266
+ continue
267
+
268
+ # Normalize the segment direction
269
+ dx_norm = dx / segment_length
270
+ dy_norm = dy / segment_length
271
+
272
+ # Perpendicular direction (rotate 90 degrees CCW)
273
+ perp_dx = -dy_norm
274
+ perp_dy = dx_norm
275
+
276
+ # Generate arrows along this segment
277
+ dx_abs = abs(x2 - x1)
278
+ num_arrows = max(1, int(round(dx_abs / arrow_spacing)))
279
+ if dx_abs == 0:
280
+ t_values = np.array([0.0, 1.0])
281
+ else:
282
+ t_values = np.linspace(0, 1, num_arrows + 1)
283
+
284
+ # Store arrow top points for connecting line
285
+ top_xs = []
286
+ top_ys = []
287
+
288
+ # Add start point if it's the first segment and load is zero
289
+ if i == 0 and n1 == 0:
290
+ top_xs.append(x1)
291
+ top_ys.append(y1)
292
+
293
+ for t in t_values:
294
+ # Interpolate position along segment
295
+ x = x1 + t * dx
296
+ y = y1 + t * dy
297
+
298
+ # Interpolate load value
299
+ n = n1 + t * (n2 - n1)
300
+
301
+ # Scale arrow height based on equivalent water depth
302
+ if max_load > 0:
303
+ water_depth = n / gamma_w
304
+ arrow_height = water_depth # Direct water depth, not scaled relative to max
305
+ else:
306
+ arrow_height = 0
307
+
308
+ # For very small arrows, just store surface point for connecting line
309
+ if arrow_height < 0.5:
310
+ top_xs.append(x)
311
+ top_ys.append(y)
312
+ continue
313
+
314
+
315
+ # Calculate arrow start point (above surface)
316
+ arrow_start_x = x + perp_dx * arrow_height
317
+ arrow_start_y = y + perp_dy * arrow_height
318
+
319
+ # Store points for connecting line
320
+ top_xs.append(arrow_start_x)
321
+ top_ys.append(arrow_start_y)
322
+
323
+ # Draw arrow - extend all the way to surface point
324
+ arrow_length = np.sqrt((x - arrow_start_x)**2 + (y - arrow_start_y)**2)
325
+ if head_length > arrow_length:
326
+ # Draw a simple line without arrowhead
327
+ ax.plot([arrow_start_x, x], [arrow_start_y, y],
328
+ color=color, linewidth=2, alpha=0.7)
329
+ else:
330
+ # Draw arrow with head
331
+ ax.arrow(arrow_start_x, arrow_start_y,
332
+ x - arrow_start_x, y - arrow_start_y,
333
+ head_width=head_width, head_length=head_length,
334
+ fc=color, ec=color, alpha=0.7,
335
+ length_includes_head=True)
336
+
337
+ # Add end point if it's the last segment and load is zero
338
+ if i == len(line) - 2 and n2 == 0:
339
+ top_xs.append(x2)
340
+ top_ys.append(y2)
341
+
342
+ # Draw connecting line at arrow tops
343
+ if top_xs:
344
+ ax.plot(top_xs, top_ys, color=color, linewidth=1.5, alpha=0.8)
345
+
346
+ # Draw the surface line itself
347
+ ax.plot(xs, ys, color=color, linewidth=1.5, alpha=0.8, label=label)
348
+
349
+ dloads = slope_data['dloads']
350
+ dloads2 = slope_data.get('dloads2', [])
351
+ plot_single_dload_set(ax, dloads, 'purple', 'Distributed Load')
352
+ plot_single_dload_set(ax, dloads2, 'orange', 'Distributed Load 2')
353
+
354
+ def plot_circles(ax, slope_data):
355
+ """
356
+ Plots starting circles with center markers and arrows.
357
+
358
+ Parameters:
359
+ ax (matplotlib axis): The plotting axis
360
+ slope_data (dict): Slope data dictionary containing circles
361
+
362
+ Returns:
363
+ None
364
+ """
365
+ circles = slope_data['circles']
366
+ for circle in circles:
367
+ Xo = circle['Xo']
368
+ Yo = circle['Yo']
369
+ R = circle['R']
370
+ # theta = np.linspace(0, 2 * np.pi, 100)
371
+ # x_circle = Xo + R * np.cos(theta)
372
+ # y_circle = Yo + R * np.sin(theta)
373
+ # ax.plot(x_circle, y_circle, 'r--', label='Circle')
374
+
375
+ # Plot the portion of the circle in the slope
376
+ ground_surface = slope_data['ground_surface']
377
+ success, result = generate_failure_surface(ground_surface, circular=True, circle=circle)
378
+ if not success:
379
+ continue # or handle error
380
+ # result = (x_min, x_max, y_left, y_right, clipped_surface)
381
+ x_min, x_max, y_left, y_right, clipped_surface = result
382
+ x_clip, y_clip = zip(*clipped_surface.coords)
383
+ ax.plot(x_clip, y_clip, 'r--', label="Circle")
384
+
385
+ # Center marker
386
+ ax.plot(Xo, Yo, 'r+', markersize=10)
387
+
388
+ # Arrow direction: point from center to midpoint of failure surface
389
+ mid_idx = len(x_clip) // 2
390
+ x_mid = x_clip[mid_idx]
391
+ y_mid = y_clip[mid_idx]
392
+
393
+ dx = x_mid - Xo
394
+ dy = y_mid - Yo
395
+
396
+ # Normalize direction vector
397
+ length = np.hypot(dx, dy)
398
+ if length != 0:
399
+ dx /= length
400
+ dy /= length
401
+
402
+ # Shorten shaft length slightly
403
+ shaft_length = R - 5
404
+
405
+ ax.arrow(Xo, Yo, dx * shaft_length, dy * shaft_length,
406
+ head_width=5, head_length=5, fc='red', ec='red')
407
+
408
+ def plot_non_circ(ax, non_circ):
409
+ """
410
+ Plots a non-circular failure surface.
411
+
412
+ Parameters:
413
+ ax: matplotlib Axes object
414
+ non_circ: List of coordinates representing the non-circular failure surface
415
+
416
+ Returns:
417
+ None
418
+ """
419
+ xs, ys = zip(*non_circ)
420
+ ax.plot(xs, ys, 'r--', label='Non-Circular Surface')
421
+
422
+ def plot_material_table(ax, materials, xloc=0.6, yloc=0.7):
423
+ """
424
+ Adds a material properties table to the plot.
425
+
426
+ Parameters:
427
+ ax: matplotlib Axes object
428
+ materials: List of material property dictionaries
429
+ xloc: x-location of table (0-1)
430
+ yloc: y-location of table (0-1)
431
+
432
+ Returns:
433
+ None
434
+ """
435
+ if not materials:
436
+ return
437
+
438
+ # Check if any materials have non-zero d and psi values
439
+ has_d_psi = any(mat.get('d', 0) > 0 or mat.get('psi', 0) > 0 for mat in materials)
440
+
441
+ # Check material options
442
+ options = set(mat['option'] for mat in materials)
443
+
444
+ # Decide column headers
445
+ if options == {'mc'}:
446
+ if has_d_psi:
447
+ col_labels = ["Mat", "Name", "γ", "c", "φ", "d", "ψ"]
448
+ else:
449
+ col_labels = ["Mat", "Name", "γ", "c", "φ"]
450
+ elif options == {'cp'}:
451
+ if has_d_psi:
452
+ col_labels = ["Mat", "Name", "γ", "cp", "rₑ", "d", "ψ"]
453
+ else:
454
+ col_labels = ["Mat", "Name", "γ", "cp", "rₑ"]
455
+ else:
456
+ if has_d_psi:
457
+ col_labels = ["Mat", "Name", "γ", "c / cp", "φ / rₑ", "d", "ψ"]
458
+ else:
459
+ col_labels = ["Mat", "Name", "γ", "c / cp", "φ / rₑ"]
460
+
461
+ # Build table rows
462
+ table_data = []
463
+ for idx, mat in enumerate(materials):
464
+ name = mat['name']
465
+ gamma = mat['gamma']
466
+ option = mat['option']
467
+
468
+ if option == 'mc':
469
+ c = mat['c']
470
+ phi = mat['phi']
471
+ if has_d_psi:
472
+ d = mat.get('d', 0)
473
+ psi = mat.get('psi', 0)
474
+ d_str = f"{d:.1f}" if d > 0 or psi > 0 else "-"
475
+ psi_str = f"{psi:.1f}" if d > 0 or psi > 0 else "-"
476
+ row = [idx+1, name, f"{gamma:.1f}", f"{c:.1f}", f"{phi:.1f}", d_str, psi_str]
477
+ else:
478
+ row = [idx+1, name, f"{gamma:.1f}", f"{c:.1f}", f"{phi:.1f}"]
479
+ elif option == 'cp':
480
+ cp = mat['cp']
481
+ r_elev = mat['r_elev']
482
+ if has_d_psi:
483
+ d = mat.get('d', 0)
484
+ psi = mat.get('psi', 0)
485
+ d_str = f"{d:.1f}" if d > 0 or psi > 0 else "-"
486
+ psi_str = f"{psi:.1f}" if d > 0 or psi > 0 else "-"
487
+ row = [idx+1, name, f"{gamma:.1f}", f"{cp:.2f}", f"{r_elev:.1f}", d_str, psi_str]
488
+ else:
489
+ row = [idx+1, name, f"{gamma:.1f}", f"{cp:.2f}", f"{r_elev:.1f}"]
490
+ else:
491
+ if has_d_psi:
492
+ d = mat.get('d', 0)
493
+ psi = mat.get('psi', 0)
494
+ d_str = f"{d:.1f}" if d > 0 or psi > 0 else "-"
495
+ psi_str = f"{psi:.1f}" if d > 0 or psi > 0 else "-"
496
+ row = [idx+1, name, f"{gamma:.1f}", "-", "-", d_str, psi_str]
497
+ else:
498
+ row = [idx+1, name, f"{gamma:.1f}", "-", "-"]
499
+ table_data.append(row)
500
+
501
+ # Adjust table width based on number of columns
502
+ table_width = 0.25 if has_d_psi else 0.2
503
+
504
+ # Add the table
505
+ table = ax.table(cellText=table_data,
506
+ colLabels=col_labels,
507
+ loc='upper right',
508
+ colLoc='center',
509
+ cellLoc='center',
510
+ bbox=[xloc, yloc, table_width, 0.25])
511
+ table.auto_set_font_size(False)
512
+ table.set_fontsize(8)
513
+
514
+ def plot_base_stresses(ax, slice_df, scale_frac=0.5, alpha=0.3):
515
+ """
516
+ Plots base normal stresses for each slice as bars.
517
+
518
+ Parameters:
519
+ ax: matplotlib Axes object
520
+ slice_df: DataFrame containing slice data
521
+ scale_frac: Fraction of plot height for bar scaling
522
+ alpha: Transparency for bars
523
+
524
+ Returns:
525
+ None
526
+ """
527
+ u = slice_df['u'].values
528
+ n_eff = slice_df['n_eff'].values
529
+ dl = slice_df['dl'].values
530
+ heights = slice_df['y_ct'] - slice_df['y_cb']
531
+ max_ht = heights.max() if not heights.empty else 1.0
532
+ max_bar_len = max_ht * scale_frac
533
+
534
+ max_stress = np.max(np.abs(n_eff)) if len(n_eff) > 0 else 1.0
535
+ max_u = np.max(u) if len(u) > 0 else 1.0
536
+
537
+ for i, (index, row) in enumerate(slice_df.iterrows()):
538
+ if i >= len(n_eff):
539
+ break
540
+
541
+ x1, y1 = row['x_l'], row['y_lb']
542
+ x2, y2 = row['x_r'], row['y_rb']
543
+
544
+ stress = n_eff[i]
545
+ pore = u[i]
546
+
547
+ dx = x2 - x1
548
+ dy = y2 - y1
549
+ length = np.hypot(dx, dy)
550
+ if length == 0:
551
+ continue
552
+
553
+ nx = -dy / length
554
+ ny = dx / length
555
+
556
+ # --- Normal stress trapezoid ---
557
+ bar_len = (abs(stress) / max_stress) * max_bar_len
558
+ direction = -np.sign(stress)
559
+
560
+ x1_top = x1 + direction * bar_len * nx
561
+ y1_top = y1 + direction * bar_len * ny
562
+ x2_top = x2 + direction * bar_len * nx
563
+ y2_top = y2 + direction * bar_len * ny
564
+
565
+ poly_x = [x1, x2, x2_top, x1_top]
566
+ poly_y = [y1, y2, y2_top, y1_top]
567
+
568
+ ax.fill(poly_x, poly_y, facecolor='none', edgecolor='red' if stress <= 0 else 'limegreen', hatch='.....',
569
+ linewidth=1)
570
+
571
+ # --- Pore pressure trapezoid ---
572
+ u_len = (pore / max_stress) * max_bar_len
573
+ u_dir = -1 # always into the base
574
+
575
+ ux1_top = x1 + u_dir * u_len * nx
576
+ uy1_top = y1 + u_dir * u_len * ny
577
+ ux2_top = x2 + u_dir * u_len * nx
578
+ uy2_top = y2 + u_dir * u_len * ny
579
+
580
+ poly_ux = [x1, x2, ux2_top, ux1_top]
581
+ poly_uy = [y1, y2, uy2_top, uy1_top]
582
+
583
+ ax.fill(poly_ux, poly_uy, color='blue', alpha=alpha, edgecolor='k', linewidth=1)
584
+
585
+
586
+ def plot_thrust_line_from_df(ax, slice_df,
587
+ color: str = 'red',
588
+ linestyle: str = '--',
589
+ linewidth: float = 1,
590
+ label: str = 'Line of Thrust'):
591
+ """
592
+ Plots the line of thrust from the slice dataframe.
593
+
594
+ Parameters:
595
+ ax: matplotlib Axes object
596
+ slice_df: DataFrame containing slice data with 'yt_l' and 'yt_r' columns
597
+ color: Color of the line
598
+ linestyle: Style of the line
599
+ linewidth: Width of the line
600
+ label: Label for the line in the legend
601
+
602
+ Returns:
603
+ None
604
+ """
605
+ # Check if required columns exist
606
+ if 'yt_l' not in slice_df.columns or 'yt_r' not in slice_df.columns:
607
+ return
608
+
609
+ # Create thrust line coordinates from slice data
610
+ thrust_xs = []
611
+ thrust_ys = []
612
+
613
+ for _, row in slice_df.iterrows():
614
+ # Add left point of current slice
615
+ thrust_xs.append(row['x_l'])
616
+ thrust_ys.append(row['yt_l'])
617
+
618
+ # Add right point of current slice (same as left point of next slice)
619
+ thrust_xs.append(row['x_r'])
620
+ thrust_ys.append(row['yt_r'])
621
+
622
+ # Plot the thrust line
623
+ ax.plot(thrust_xs, thrust_ys,
624
+ color=color,
625
+ linestyle=linestyle,
626
+ linewidth=linewidth,
627
+ label=label)
628
+
629
+ def compute_ylim(data, slice_df, scale_frac=0.5, pad_fraction=0.1):
630
+ """
631
+ Computes y-limits for plotting based on slice data.
632
+
633
+ Parameters:
634
+ data: Input data
635
+ slice_df: pandas.DataFrame with slice data, must have 'y_lt' and 'y_lb' for stress‐bar sizing
636
+ scale_frac: fraction of max slice height used when drawing stress bars
637
+ pad_fraction: fraction of total range to pad above/below finally
638
+
639
+ Returns:
640
+ (y_min, y_max) suitable for ax.set_ylim(...)
641
+ """
642
+ import numpy as np
643
+
644
+ y_vals = []
645
+
646
+ # 1) collect all profile line elevations
647
+ for line in data.get('profile_lines', []):
648
+ if hasattr(line, "xy"):
649
+ _, ys = line.xy
650
+ else:
651
+ _, ys = zip(*line)
652
+ y_vals.extend(ys)
653
+
654
+ # 2) explicitly include the deepest allowed depth
655
+ if "max_depth" in data and data["max_depth"] is not None:
656
+ y_vals.append(data["max_depth"])
657
+
658
+ if not y_vals:
659
+ return 0.0, 1.0
660
+
661
+ y_min = min(y_vals)
662
+ y_max = max(y_vals)
663
+
664
+ # 3) ensure the largest stress bar will fit
665
+ # stress‐bar length = scale_frac * slice height
666
+ heights = slice_df["y_lt"] - slice_df["y_lb"]
667
+ if not heights.empty:
668
+ max_bar = heights.max() * scale_frac
669
+ y_min -= max_bar
670
+ y_max += max_bar
671
+
672
+ # 4) add a final small pad
673
+ pad = (y_max - y_min) * pad_fraction
674
+ return y_min - pad, y_max + pad
675
+
676
+ # ========== FOR PLOTTING INPUT DATA =========
677
+
678
+ def plot_reinforcement_lines(ax, slope_data):
679
+ """
680
+ Plots the reinforcement lines from slope_data.
681
+
682
+ Parameters:
683
+ ax: matplotlib Axes object
684
+ slope_data: Dictionary containing slope data with 'reinforce_lines' key
685
+
686
+ Returns:
687
+ None
688
+ """
689
+ if 'reinforce_lines' not in slope_data or not slope_data['reinforce_lines']:
690
+ return
691
+
692
+ tension_points_plotted = False # Track if tension points have been added to legend
693
+
694
+ for i, line in enumerate(slope_data['reinforce_lines']):
695
+ # Extract x and y coordinates from the line points
696
+ xs = [point['X'] for point in line]
697
+ ys = [point['Y'] for point in line]
698
+
699
+ # Plot the reinforcement line with a distinctive style
700
+ ax.plot(xs, ys, color='darkgray', linewidth=3, linestyle='-',
701
+ alpha=0.8, label='Reinforcement Line' if i == 0 else "")
702
+
703
+ # Add markers at each point to show tension values
704
+ for j, point in enumerate(line):
705
+ tension = point.get('T', 0.0)
706
+ if tension > 0:
707
+ # Use smaller marker size proportional to tension (normalized)
708
+ max_tension = max(p.get('T', 0.0) for p in line)
709
+ marker_size = 10 + 15 * (tension / max_tension) if max_tension > 0 else 10
710
+ ax.scatter(point['X'], point['Y'], s=marker_size,
711
+ color='red', alpha=0.7, zorder=5,
712
+ label='Tension Points' if not tension_points_plotted else "")
713
+ tension_points_plotted = True
714
+
715
+
716
+ def plot_inputs(slope_data, title="Slope Geometry and Inputs", width=12, height=6, mat_table=True):
717
+ """
718
+ Creates a plot showing the slope geometry and input parameters.
719
+
720
+ Parameters:
721
+ slope_data: Dictionary containing plot data
722
+ title: Title for the plot
723
+ width: Width of the plot in inches
724
+ height: Height of the plot in inches
725
+ mat_table: Controls material table display. Can be:
726
+ - True: Auto-position material table to avoid overlaps
727
+ - False: Don't show material table
728
+ - 'auto': Auto-position material table to avoid overlaps
729
+ - String: Specific location for material table ('upper left', 'upper right', 'upper center',
730
+ 'lower left', 'lower right', 'lower center', 'center left', 'center right', 'center')
731
+
732
+ Returns:
733
+ None
734
+ """
735
+ fig, ax = plt.subplots(figsize=(width, height))
736
+
737
+ # Plot contents
738
+ plot_profile_lines(ax, slope_data['profile_lines'])
739
+ plot_max_depth(ax, slope_data['profile_lines'], slope_data['max_depth'])
740
+ plot_piezo_line(ax, slope_data)
741
+ plot_dloads(ax, slope_data)
742
+ plot_tcrack_surface(ax, slope_data['tcrack_surface'])
743
+ plot_reinforcement_lines(ax, slope_data)
744
+
745
+ if slope_data['circular']:
746
+ plot_circles(ax, slope_data)
747
+ else:
748
+ plot_non_circ(ax, slope_data['non_circ'])
749
+
750
+ # Handle material table display
751
+ if mat_table:
752
+ if isinstance(mat_table, str) and mat_table != 'auto':
753
+ # Convert location string to xloc, yloc coordinates (inside plot area with margins)
754
+ location_map = {
755
+ 'upper left': (0.05, 0.70),
756
+ 'upper right': (0.70, 0.70),
757
+ 'upper center': (0.35, 0.70),
758
+ 'lower left': (0.05, 0.05),
759
+ 'lower right': (0.70, 0.05),
760
+ 'lower center': (0.35, 0.05),
761
+ 'center left': (0.05, 0.35),
762
+ 'center right': (0.70, 0.35),
763
+ 'center': (0.35, 0.35)
764
+ }
765
+ if mat_table in location_map:
766
+ xloc, yloc = location_map[mat_table]
767
+ plot_material_table(ax, slope_data['materials'], xloc=xloc, yloc=yloc)
768
+ else:
769
+ # Default to upper right if invalid location
770
+ plot_material_table(ax, slope_data['materials'], xloc=0.75, yloc=0.75)
771
+ else:
772
+ # Auto-position or default: find best location
773
+ plot_elements_bounds = get_plot_elements_bounds(ax, slope_data)
774
+ xloc, yloc = find_best_table_position(ax, slope_data['materials'], plot_elements_bounds)
775
+ plot_material_table(ax, slope_data['materials'], xloc=xloc, yloc=yloc)
776
+
777
+ ax.set_aspect('equal') # ✅ Equal aspect
778
+ ax.set_xlabel("x")
779
+ ax.set_ylabel("y")
780
+ ax.grid(False)
781
+
782
+ # Get legend handles and labels
783
+ handles, labels = ax.get_legend_handles_labels()
784
+
785
+ # Add distributed load to legend if present
786
+ if slope_data['dloads']:
787
+ handler_class, dummy_line = get_dload_legend_handler()
788
+ handles.append(dummy_line)
789
+ labels.append('Distributed Load')
790
+
791
+ ax.legend(
792
+ handles=handles,
793
+ labels=labels,
794
+ loc='upper center',
795
+ bbox_to_anchor=(0.5, -0.12),
796
+ ncol=2
797
+ )
798
+
799
+ ax.set_title(title)
800
+
801
+ plt.tight_layout()
802
+ plt.show()
803
+
804
+ # ========== Main Plotting Function =========
805
+
806
+ def plot_solution(slope_data, slice_df, failure_surface, results, width=12, height=7, slice_numbers=False):
807
+ """
808
+ Plots the full solution including slices, numbers, thrust line, and base stresses.
809
+
810
+ Parameters:
811
+ data: Input data
812
+ slice_df: DataFrame containing slice data
813
+ failure_surface: Failure surface geometry
814
+ results: Solution results
815
+ width: Width of the plot in inches
816
+ height: Height of the plot in inches
817
+
818
+ Returns:
819
+ None
820
+ """
821
+ fig, ax = plt.subplots(figsize=(width, height))
822
+ ax.set_xlabel("x")
823
+ ax.set_ylabel("y")
824
+ ax.grid(False)
825
+
826
+ plot_profile_lines(ax, slope_data['profile_lines'])
827
+ plot_max_depth(ax, slope_data['profile_lines'], slope_data['max_depth'])
828
+ plot_slices(ax, slice_df, fill=False)
829
+ plot_failure_surface(ax, failure_surface)
830
+ plot_piezo_line(ax, slope_data)
831
+ plot_dloads(ax, slope_data)
832
+ plot_tcrack_surface(ax, slope_data['tcrack_surface'])
833
+ plot_reinforcement_lines(ax, slope_data)
834
+ if slice_numbers:
835
+ plot_slice_numbers(ax, slice_df)
836
+ # plot_material_table(ax, data['materials'], xloc=0.75) # Adjust this so that it fits with the legend
837
+
838
+ alpha = 0.3
839
+ if results['method'] == 'spencer':
840
+ plot_thrust_line_from_df(ax, slice_df)
841
+
842
+ plot_base_stresses(ax, slice_df, alpha=alpha)
843
+
844
+ import matplotlib.patches as mpatches
845
+ normal_patch = mpatches.Patch(facecolor='none', edgecolor='green', hatch='.....', label="Eff Normal Stress (σ')")
846
+ pore_patch = mpatches.Patch(color='blue', alpha=alpha, label='Pore Pressure (u)')
847
+
848
+ # Get legend handles and labels
849
+ handles, labels = ax.get_legend_handles_labels()
850
+ handles.extend([normal_patch, pore_patch])
851
+ labels.extend(["Eff Normal Stress (σ')", 'Pore Pressure (u)'])
852
+
853
+ # Add distributed load to legend if present
854
+ if slope_data['dloads']:
855
+ handler_class, dummy_line = get_dload_legend_handler()
856
+ handles.append(dummy_line)
857
+ labels.append('Distributed Load')
858
+
859
+ ax.legend(
860
+ handles=handles,
861
+ labels=labels,
862
+ loc='upper center',
863
+ bbox_to_anchor=(0.5, -0.15),
864
+ ncol=3
865
+ )
866
+
867
+ # Add vertical space below for the legend
868
+ plt.subplots_adjust(bottom=0.2)
869
+ ax.set_aspect('equal')
870
+
871
+ fs = results['FS']
872
+ method = results['method']
873
+ if method == 'oms':
874
+ title = f'OMS: FS = {fs:.3f}'
875
+ elif method == 'bishop':
876
+ title = f'Bishop: FS = {fs:.3f}'
877
+ elif method == 'spencer':
878
+ theta = results['theta']
879
+ title = f'Spencer: FS = {fs:.3f}, θ = {theta:.2f}°'
880
+ elif method == 'janbu':
881
+ fo = results['fo']
882
+ title = f'Janbu-Corrected: FS = {fs:.3f}, fo = {fo:.2f}'
883
+ elif method == 'corps_engineers':
884
+ theta = results['theta']
885
+ title = f'Corps Engineers: FS = {fs:.3f}, θ = {theta:.2f}°'
886
+ elif method == 'lowe_karafiath':
887
+ title = f'Lowe & Karafiath: FS = {fs:.3f}'
888
+ ax.set_title(title)
889
+
890
+ # zoom y‐axis to just cover the slope and depth, with a little breathing room (thrust line can be outside)
891
+ ymin, ymax = compute_ylim(slope_data, slice_df, pad_fraction=0.05)
892
+ ax.set_ylim(ymin, ymax)
893
+
894
+ plt.tight_layout()
895
+ plt.show()
896
+
897
+ # ========== Functions for Search Results =========
898
+
899
+ def plot_failure_surfaces(ax, fs_cache):
900
+ """
901
+ Plots all failure surfaces from the factor of safety cache.
902
+
903
+ Parameters:
904
+ ax: matplotlib Axes object
905
+ fs_cache: List of dictionaries containing failure surface data and FS values
906
+
907
+ Returns:
908
+ None
909
+ """
910
+ for i, result in reversed(list(enumerate(fs_cache))):
911
+ surface = result['failure_surface']
912
+ if surface is None or surface.is_empty:
913
+ continue
914
+ x, y = zip(*surface.coords)
915
+ color = 'red' if i == 0 else 'gray'
916
+ lw = 2 if i == 0 else 1
917
+ ax.plot(x, y, color=color, linestyle='-', linewidth=lw, alpha=1.0 if i == 0 else 0.6)
918
+
919
+ def plot_circle_centers(ax, fs_cache):
920
+ """
921
+ Plots the centers of circular failure surfaces.
922
+
923
+ Parameters:
924
+ ax: matplotlib Axes object
925
+ fs_cache: List of dictionaries containing circle center data
926
+
927
+ Returns:
928
+ None
929
+ """
930
+ for result in fs_cache:
931
+ ax.plot(result['Xo'], result['Yo'], 'ko', markersize=3, alpha=0.6)
932
+
933
+ def plot_search_path(ax, search_path):
934
+ """
935
+ Plots the search path used to find the critical failure surface.
936
+
937
+ Parameters:
938
+ ax: matplotlib Axes object
939
+ search_path: List of dictionaries containing search path coordinates
940
+
941
+ Returns:
942
+ None
943
+ """
944
+ if len(search_path) < 2:
945
+ return # need at least two points to draw an arrow
946
+
947
+ for i in range(len(search_path) - 1):
948
+ start = search_path[i]
949
+ end = search_path[i + 1]
950
+ dx = end['x'] - start['x']
951
+ dy = end['y'] - start['y']
952
+ ax.arrow(start['x'], start['y'], dx, dy,
953
+ head_width=1, head_length=2, fc='green', ec='green', length_includes_head=True)
954
+
955
+ def plot_circular_search_results(slope_data, fs_cache, search_path=None, highlight_fs=True, width=12, height=7):
956
+ """
957
+ Creates a plot showing the results of a circular failure surface search.
958
+
959
+ Parameters:
960
+ slope_data: Dictionary containing plot data
961
+ fs_cache: List of dictionaries containing failure surface data and FS values
962
+ search_path: List of dictionaries containing search path coordinates
963
+ highlight_fs: Boolean indicating whether to highlight the critical failure surface
964
+ width: Width of the plot in inches
965
+ height: Height of the plot in inches
966
+
967
+ Returns:
968
+ None
969
+ """
970
+ fig, ax = plt.subplots(figsize=(width, height))
971
+
972
+ plot_profile_lines(ax, slope_data['profile_lines'])
973
+ plot_max_depth(ax, slope_data['profile_lines'], slope_data['max_depth'])
974
+ plot_piezo_line(ax, slope_data)
975
+ plot_dloads(ax, slope_data)
976
+ plot_tcrack_surface(ax, slope_data['tcrack_surface'])
977
+
978
+ plot_failure_surfaces(ax, fs_cache)
979
+ plot_circle_centers(ax, fs_cache)
980
+ if search_path:
981
+ plot_search_path(ax, search_path)
982
+
983
+ ax.set_aspect('equal')
984
+ ax.set_xlabel("x")
985
+ ax.set_ylabel("y")
986
+ ax.grid(False)
987
+ ax.legend()
988
+
989
+ if highlight_fs and fs_cache:
990
+ critical_fs = fs_cache[0]['FS']
991
+ ax.set_title(f"Critical Factor of Safety = {critical_fs:.3f}")
992
+
993
+ plt.tight_layout()
994
+ plt.show()
995
+
996
+ def plot_noncircular_search_results(slope_data, fs_cache, search_path=None, highlight_fs=True, width=12, height=7):
997
+ """
998
+ Creates a plot showing the results of a non-circular failure surface search.
999
+
1000
+ Parameters:
1001
+ slope_data: Dictionary containing plot data
1002
+ fs_cache: List of dictionaries containing failure surface data and FS values
1003
+ search_path: List of dictionaries containing search path coordinates
1004
+ highlight_fs: Boolean indicating whether to highlight the critical failure surface
1005
+ width: Width of the plot in inches
1006
+ height: Height of the plot in inches
1007
+
1008
+ Returns:
1009
+ None
1010
+ """
1011
+ fig, ax = plt.subplots(figsize=(width, height))
1012
+
1013
+ # Plot basic profile elements
1014
+ plot_profile_lines(ax, slope_data['profile_lines'])
1015
+ plot_max_depth(ax, slope_data['profile_lines'], slope_data['max_depth'])
1016
+ plot_piezo_line(ax, slope_data)
1017
+ plot_dloads(ax, slope_data)
1018
+ plot_tcrack_surface(ax, slope_data['tcrack_surface'])
1019
+
1020
+ # Plot all failure surfaces from cache
1021
+ for i, result in reversed(list(enumerate(fs_cache))):
1022
+ surface = result['failure_surface']
1023
+ if surface is None or surface.is_empty:
1024
+ continue
1025
+ x, y = zip(*surface.coords)
1026
+ color = 'red' if i == 0 else 'gray'
1027
+ lw = 2 if i == 0 else 1
1028
+ ax.plot(x, y, color=color, linestyle='-', linewidth=lw, alpha=1.0 if i == 0 else 0.6)
1029
+
1030
+ # Plot search path if provided
1031
+ if search_path:
1032
+ for i in range(len(search_path) - 1):
1033
+ start = search_path[i]
1034
+ end = search_path[i + 1]
1035
+ # For non-circular search, we need to plot the movement of each point
1036
+ start_points = np.array(start['points'])
1037
+ end_points = np.array(end['points'])
1038
+
1039
+ # Plot arrows for each moving point
1040
+ for j in range(len(start_points)):
1041
+ dx = end_points[j, 0] - start_points[j, 0]
1042
+ dy = end_points[j, 1] - start_points[j, 1]
1043
+ if abs(dx) > 1e-6 or abs(dy) > 1e-6: # Only plot if point moved
1044
+ ax.arrow(start_points[j, 0], start_points[j, 1], dx, dy,
1045
+ head_width=1, head_length=2, fc='green', ec='green',
1046
+ length_includes_head=True, alpha=0.6)
1047
+
1048
+ ax.set_aspect('equal')
1049
+ ax.set_xlabel("x")
1050
+ ax.set_ylabel("y")
1051
+ ax.grid(False)
1052
+ ax.legend()
1053
+
1054
+ if highlight_fs and fs_cache:
1055
+ critical_fs = fs_cache[0]['FS']
1056
+ ax.set_title(f"Critical Factor of Safety = {critical_fs:.3f}")
1057
+
1058
+ plt.tight_layout()
1059
+ plt.show()
1060
+
1061
+ def plot_reliability_results(slope_data, reliability_data, width=12, height=7):
1062
+ """
1063
+ Creates a plot showing the results of reliability analysis.
1064
+
1065
+ Parameters:
1066
+ slope_data: Dictionary containing plot data
1067
+ reliability_data: Dictionary containing reliability analysis results
1068
+ width: Width of the plot in inches
1069
+ height: Height of the plot in inches
1070
+
1071
+ Returns:
1072
+ None
1073
+ """
1074
+ fig, ax = plt.subplots(figsize=(width, height))
1075
+
1076
+ # Plot basic slope elements (same as other search functions)
1077
+ plot_profile_lines(ax, slope_data['profile_lines'])
1078
+ plot_max_depth(ax, slope_data['profile_lines'], slope_data['max_depth'])
1079
+ plot_piezo_line(ax, slope_data)
1080
+ plot_dloads(ax, slope_data)
1081
+ plot_tcrack_surface(ax, slope_data['tcrack_surface'])
1082
+
1083
+ # Plot reliability-specific failure surfaces
1084
+ fs_cache = reliability_data['fs_cache']
1085
+
1086
+ # Plot all failure surfaces
1087
+ for i, fs_data in enumerate(fs_cache):
1088
+ result = fs_data['result']
1089
+ name = fs_data['name']
1090
+ failure_surface = result['failure_surface']
1091
+
1092
+ # Convert failure surface to coordinates
1093
+ if hasattr(failure_surface, 'coords'):
1094
+ coords = list(failure_surface.coords)
1095
+ else:
1096
+ coords = failure_surface
1097
+
1098
+ x_coords = [pt[0] for pt in coords]
1099
+ y_coords = [pt[1] for pt in coords]
1100
+
1101
+ # Color and styling based on surface type
1102
+ if name == "MLV":
1103
+ # Highlight the MLV (critical) surface in red
1104
+ ax.plot(x_coords, y_coords, color='red', linewidth=3,
1105
+ label=f'$F_{{MLV}}$ Surface (FS={result["FS"]:.3f})', zorder=10)
1106
+ else:
1107
+ # Other surfaces in different colors
1108
+ if '+' in name:
1109
+ color = 'blue'
1110
+ alpha = 0.7
1111
+ label = f'$F^+$ ({name}) (FS={result["FS"]:.3f})'
1112
+ else: # '-' in name
1113
+ color = 'green'
1114
+ alpha = 0.7
1115
+ label = f'$F^-$ ({name}) (FS={result["FS"]:.3f})'
1116
+
1117
+ ax.plot(x_coords, y_coords, color=color, linewidth=1.5,
1118
+ alpha=alpha, label=label, zorder=5)
1119
+
1120
+
1121
+
1122
+ # Standard finalization
1123
+ ax.set_aspect('equal')
1124
+ ax.set_xlabel("x")
1125
+ ax.set_ylabel("y")
1126
+ ax.grid(False)
1127
+ ax.legend(bbox_to_anchor=(1.05, 1), loc='upper left')
1128
+
1129
+ # Title with reliability statistics using mathtext
1130
+ F_MLV = reliability_data['F_MLV']
1131
+ sigma_F = reliability_data['sigma_F']
1132
+ COV_F = reliability_data['COV_F']
1133
+ reliability = reliability_data['reliability']
1134
+ prob_failure = reliability_data['prob_failure']
1135
+
1136
+ ax.set_title(f"Reliability Analysis Results\n"
1137
+ f"$F_{{MLV}}$ = {F_MLV:.3f}, $\\sigma_F$ = {sigma_F:.3f}, "
1138
+ f"$COV_F$ = {COV_F:.3f}\n"
1139
+ f"Reliability = {reliability*100:.2f}%, $P_f$ = {prob_failure*100:.2f}%")
1140
+
1141
+ plt.tight_layout()
1142
+ plt.show()
1143
+
1144
+ def plot_mesh(mesh, materials=None, figsize=(14, 6), pad_frac=0.05, show_nodes=True, label_elements=False, label_nodes=False):
1145
+ """
1146
+ Plot the finite element mesh with material regions.
1147
+
1148
+ Parameters:
1149
+ mesh: Mesh dictionary with 'nodes', 'elements', 'element_types', and 'element_materials' keys
1150
+ materials: Optional list of material dictionaries for legend labels
1151
+ figsize: Figure size tuple
1152
+ pad_frac: Fraction of mesh size to use for padding around plot
1153
+ show_nodes: If True, plot points at node locations
1154
+ label_elements: If True, label each element with its number at its centroid
1155
+ label_nodes: If True, label each node with its number
1156
+ """
1157
+ import matplotlib.pyplot as plt
1158
+ from matplotlib.patches import Patch
1159
+ from matplotlib.collections import PolyCollection
1160
+ import numpy as np
1161
+
1162
+ nodes = mesh["nodes"]
1163
+ elements = mesh["elements"]
1164
+ element_types = mesh["element_types"]
1165
+ mat_ids = mesh["element_materials"]
1166
+
1167
+ fig, ax = plt.subplots(figsize=figsize)
1168
+
1169
+ # Group elements by material ID
1170
+ material_elements = {}
1171
+ for i, (element, elem_type, mid) in enumerate(zip(elements, element_types, mat_ids)):
1172
+ if mid not in material_elements:
1173
+ material_elements[mid] = []
1174
+
1175
+ # Only process 2D elements (skip 1D elements which have elem_type 2)
1176
+ if elem_type == 2: # Skip 1D elements
1177
+ continue
1178
+
1179
+ # Use corner nodes to define element boundary (no subdivision needed)
1180
+ if elem_type in [3, 6]: # Triangular elements (linear or quadratic)
1181
+ element_coords = [nodes[element[0]], nodes[element[1]], nodes[element[2]]]
1182
+ elif elem_type in [4, 8, 9]: # Quadrilateral elements (linear or quadratic)
1183
+ element_coords = [nodes[element[0]], nodes[element[1]], nodes[element[2]], nodes[element[3]]]
1184
+ else:
1185
+ continue # Skip unknown element types
1186
+
1187
+ material_elements[mid].append(element_coords)
1188
+
1189
+ legend_elements = []
1190
+
1191
+ # Plot 1D elements FIRST (bottom layer) if present in mesh
1192
+ if "elements_1d" in mesh and "element_types_1d" in mesh and "element_materials_1d" in mesh:
1193
+ elements_1d = mesh["elements_1d"]
1194
+ element_types_1d = mesh["element_types_1d"]
1195
+ mat_ids_1d = mesh["element_materials_1d"]
1196
+
1197
+ # Group 1D elements by material ID
1198
+ material_lines = {}
1199
+ for i, (element_1d, elem_type_1d, mid_1d) in enumerate(zip(elements_1d, element_types_1d, mat_ids_1d)):
1200
+ if mid_1d not in material_lines:
1201
+ material_lines[mid_1d] = []
1202
+
1203
+ # Get line coordinates based on actual number of nodes
1204
+ # elem_type_1d contains the number of nodes (2 for linear, 3 for quadratic)
1205
+ if elem_type_1d == 2: # Linear 1D element (2 nodes)
1206
+ # Skip zero-padded elements
1207
+ if element_1d[1] != 0: # Valid second node
1208
+ line_coords = [nodes[element_1d[0]], nodes[element_1d[1]]]
1209
+ else:
1210
+ continue # Skip invalid element
1211
+ elif elem_type_1d == 3: # Quadratic 1D element (3 nodes)
1212
+ # For visualization, connect all three nodes or just endpoints
1213
+ line_coords = [nodes[element_1d[0]], nodes[element_1d[1]], nodes[element_1d[2]]]
1214
+ else:
1215
+ continue # Skip unknown 1D element types
1216
+
1217
+ material_lines[mid_1d].append(line_coords)
1218
+
1219
+ # Plot 1D elements with distinctive style
1220
+ for mid_1d, lines_list in material_lines.items():
1221
+ for line_coords in lines_list:
1222
+ xs = [coord[0] for coord in line_coords]
1223
+ ys = [coord[1] for coord in line_coords]
1224
+ ax.plot(xs, ys, color='red', linewidth=3, alpha=0.8, solid_capstyle='round')
1225
+
1226
+ # Add 1D elements to legend
1227
+ if material_lines:
1228
+ legend_elements.append(plt.Line2D([0], [0], color='red', linewidth=3,
1229
+ alpha=0.8, label='1D Elements'))
1230
+
1231
+ # Plot 2D elements SECOND (middle layer)
1232
+ for mid, elements_list in material_elements.items():
1233
+ # Create polygon collection for this material
1234
+ poly_collection = PolyCollection(elements_list,
1235
+ facecolor=get_material_color(mid),
1236
+ edgecolor='k',
1237
+ alpha=0.4,
1238
+ linewidth=0.5)
1239
+ ax.add_collection(poly_collection)
1240
+
1241
+ # Add to legend
1242
+ if materials and mid <= len(materials) and materials[mid-1].get('name'):
1243
+ label = materials[mid-1]['name'] # Convert to 0-based indexing
1244
+ else:
1245
+ label = f'Material {mid}'
1246
+
1247
+ legend_elements.append(Patch(facecolor=get_material_color(mid),
1248
+ edgecolor='k',
1249
+ alpha=0.4,
1250
+ label=label))
1251
+
1252
+ # Label 2D elements if requested
1253
+ if label_elements:
1254
+ for idx, (element, element_type) in enumerate(zip(elements, element_types)):
1255
+ # Calculate element centroid based on element type
1256
+ if element_type == 3: # 3-node triangle
1257
+ element_coords = nodes[element[:3]]
1258
+ elif element_type == 6: # 6-node triangle
1259
+ element_coords = nodes[element[:6]]
1260
+ elif element_type == 4: # 4-node quad
1261
+ element_coords = nodes[element[:4]]
1262
+ elif element_type == 8: # 8-node quad
1263
+ element_coords = nodes[element[:8]]
1264
+ elif element_type == 9: # 9-node quad
1265
+ element_coords = nodes[element[:9]]
1266
+ else:
1267
+ continue # Skip unknown element types
1268
+
1269
+ centroid = np.mean(element_coords, axis=0)
1270
+ ax.text(centroid[0], centroid[1], str(idx+1),
1271
+ ha='center', va='center', fontsize=6, color='black', alpha=0.7,
1272
+ zorder=12)
1273
+
1274
+ # Label 1D elements if requested (with different color)
1275
+ if label_elements and "elements_1d" in mesh:
1276
+ elements_1d = mesh["elements_1d"]
1277
+ element_types_1d = mesh["element_types_1d"]
1278
+
1279
+ for idx, (element_1d, elem_type_1d) in enumerate(zip(elements_1d, element_types_1d)):
1280
+ # Skip zero-padded elements
1281
+ if elem_type_1d == 2 and element_1d[1] != 0: # Linear 1D element
1282
+ # Calculate midpoint of line element
1283
+ coord1 = nodes[element_1d[0]]
1284
+ coord2 = nodes[element_1d[1]]
1285
+ midpoint = (coord1 + coord2) / 2
1286
+ ax.text(midpoint[0], midpoint[1], f"1D{idx+1}",
1287
+ ha='center', va='center', fontsize=6, color='black', alpha=0.9,
1288
+ zorder=13)
1289
+ elif elem_type_1d == 3 and element_1d[2] != 0: # Quadratic 1D element
1290
+ # Use middle node as label position (if it exists)
1291
+ midpoint = nodes[element_1d[1]]
1292
+ ax.text(midpoint[0], midpoint[1], f"1D{idx+1}",
1293
+ ha='center', va='center', fontsize=6, color='black', alpha=0.9,
1294
+ zorder=13)
1295
+
1296
+ # Plot nodes LAST (top layer) if requested
1297
+ if show_nodes:
1298
+ # Plot all nodes - if meshing is correct, all nodes should be used
1299
+ ax.plot(nodes[:, 0], nodes[:, 1], 'k.', markersize=2)
1300
+ # Add to legend
1301
+ legend_elements.append(plt.Line2D([0], [0], marker='o', color='w',
1302
+ markerfacecolor='k', markersize=6,
1303
+ label=f'Nodes ({len(nodes)})', linestyle='None'))
1304
+
1305
+ # Label nodes if requested
1306
+ if label_nodes:
1307
+ # Label all nodes
1308
+ for i, (x, y) in enumerate(nodes):
1309
+ ax.text(x + 0.5, y + 0.5, str(i+1), fontsize=6, color='blue', alpha=0.7,
1310
+ ha='left', va='bottom', zorder=14)
1311
+
1312
+ ax.set_aspect('equal')
1313
+ ax.set_title("Finite Element Mesh with Material Regions (Triangles and Quads)")
1314
+
1315
+ # Add legend if we have materials
1316
+ if legend_elements:
1317
+ ax.legend(handles=legend_elements, loc='upper center', bbox_to_anchor=(0.5, -0.05), ncol=min(len(legend_elements), 4))
1318
+
1319
+ # Add cushion
1320
+ x_min, x_max = nodes[:, 0].min(), nodes[:, 0].max()
1321
+ y_min, y_max = nodes[:, 1].min(), nodes[:, 1].max()
1322
+ x_pad = (x_max - x_min) * pad_frac
1323
+ y_pad = (y_max - y_min) * pad_frac
1324
+ ax.set_xlim(x_min - x_pad, x_max + x_pad)
1325
+ ax.set_ylim(y_min - y_pad, y_max + y_pad)
1326
+
1327
+ # Add extra cushion for legend space
1328
+ ax.set_ylim(y_min - y_pad, y_max + y_pad)
1329
+
1330
+ plt.tight_layout()
1331
+ plt.show()
1332
+
1333
+
1334
+ def plot_polygons(polygons, title="Material Zone Polygons"):
1335
+ """
1336
+ Plot all material zone polygons in a single figure.
1337
+
1338
+ Parameters:
1339
+ polygons: List of polygon coordinate lists
1340
+ title: Plot title
1341
+ """
1342
+ import matplotlib.pyplot as plt
1343
+
1344
+ fig, ax = plt.subplots(figsize=(12, 8))
1345
+ for i, polygon in enumerate(polygons):
1346
+ xs = [x for x, y in polygon]
1347
+ ys = [y for x, y in polygon]
1348
+ ax.fill(xs, ys, color=get_material_color(i), alpha=0.6, label=f'Material {i}')
1349
+ ax.plot(xs, ys, color=get_material_color(i), linewidth=1)
1350
+ ax.set_xlabel('X Coordinate')
1351
+ ax.set_ylabel('Y Coordinate')
1352
+ ax.set_title(title)
1353
+ ax.legend()
1354
+ ax.grid(True, alpha=0.3)
1355
+ ax.set_aspect('equal')
1356
+ plt.tight_layout()
1357
+ plt.show()
1358
+
1359
+
1360
+ def plot_polygons_separately(polygons, title_prefix='Material Zone'):
1361
+ """
1362
+ Plot each polygon in a separate matplotlib frame (subplot), with vertices as round dots.
1363
+
1364
+ Parameters:
1365
+ polygons: List of polygon coordinate lists
1366
+ title_prefix: Prefix for each subplot title
1367
+ """
1368
+ import matplotlib.pyplot as plt
1369
+
1370
+ n = len(polygons)
1371
+ fig, axes = plt.subplots(n, 1, figsize=(8, 3 * n), squeeze=False)
1372
+ for i, polygon in enumerate(polygons):
1373
+ xs = [x for x, y in polygon]
1374
+ ys = [y for x, y in polygon]
1375
+ ax = axes[i, 0]
1376
+ ax.fill(xs, ys, color=get_material_color(i), alpha=0.6, label=f'Material {i}')
1377
+ ax.plot(xs, ys, color=get_material_color(i), linewidth=1)
1378
+ ax.scatter(xs, ys, color='k', s=30, marker='o', zorder=3, label='Vertices')
1379
+ ax.set_xlabel('X Coordinate')
1380
+ ax.set_ylabel('Y Coordinate')
1381
+ ax.set_title(f'{title_prefix} {i}')
1382
+ ax.grid(True, alpha=0.3)
1383
+ ax.set_aspect('equal')
1384
+ ax.legend()
1385
+ plt.tight_layout()
1386
+ plt.show()
1387
+
1388
+
1389
+ def find_best_table_position(ax, materials, plot_elements_bounds):
1390
+ """
1391
+ Find the best position for the material table to avoid overlaps.
1392
+
1393
+ Parameters:
1394
+ ax: matplotlib Axes object
1395
+ materials: List of materials to determine table size
1396
+ plot_elements_bounds: List of (x_min, x_max, y_min, y_max) for existing elements
1397
+
1398
+ Returns:
1399
+ (xloc, yloc) coordinates for table placement
1400
+ """
1401
+ # Calculate table size based on number of materials and columns
1402
+ num_materials = len(materials)
1403
+ has_d_psi = any(mat.get('d', 0) > 0 or mat.get('psi', 0) > 0 for mat in materials)
1404
+ table_height = 0.05 + 0.025 * num_materials # Height per row
1405
+ table_width = 0.25 if has_d_psi else 0.2
1406
+
1407
+ # Define candidate positions (priority order) - with margins from borders
1408
+ candidates = [
1409
+ (0.05, 0.70), # upper left
1410
+ (0.70, 0.70), # upper right
1411
+ (0.05, 0.05), # lower left
1412
+ (0.70, 0.05), # lower right
1413
+ (0.35, 0.70), # upper center
1414
+ (0.35, 0.05), # lower center
1415
+ (0.05, 0.35), # center left
1416
+ (0.70, 0.35), # center right
1417
+ (0.35, 0.35), # center
1418
+ ]
1419
+
1420
+ # Check each candidate position for overlaps
1421
+ for xloc, yloc in candidates:
1422
+ table_bounds = (xloc, xloc + table_width, yloc - table_height, yloc)
1423
+
1424
+ # Check if table overlaps with any plot elements
1425
+ overlap = False
1426
+ for elem_bounds in plot_elements_bounds:
1427
+ elem_x_min, elem_x_max, elem_y_min, elem_y_max = elem_bounds
1428
+ table_x_min, table_x_max, table_y_min, table_y_max = table_bounds
1429
+
1430
+ # Check for overlap
1431
+ if not (table_x_max < elem_x_min or table_x_min > elem_x_max or
1432
+ table_y_max < elem_y_min or table_y_min > elem_y_max):
1433
+ overlap = True
1434
+ break
1435
+
1436
+ if not overlap:
1437
+ return xloc, yloc
1438
+
1439
+ # If all positions have overlap, return the first candidate
1440
+ return candidates[0]
1441
+
1442
+
1443
+ def get_plot_elements_bounds(ax, slope_data):
1444
+ """
1445
+ Get bounding boxes of existing plot elements to avoid overlaps.
1446
+
1447
+ Parameters:
1448
+ ax: matplotlib Axes object
1449
+ slope_data: Dictionary containing slope data
1450
+
1451
+ Returns:
1452
+ List of (x_min, x_max, y_min, y_max) tuples for plot elements
1453
+ """
1454
+ bounds = []
1455
+
1456
+ # Get axis limits
1457
+ x_min, x_max = ax.get_xlim()
1458
+ y_min, y_max = ax.get_ylim()
1459
+
1460
+ # Profile lines bounds
1461
+ if 'profile_lines' in slope_data:
1462
+ for line in slope_data['profile_lines']:
1463
+ if line:
1464
+ xs = [p[0] for p in line]
1465
+ ys = [p[1] for p in line]
1466
+ bounds.append((min(xs), max(xs), min(ys), max(ys)))
1467
+
1468
+ # Distributed loads bounds
1469
+ if 'dloads' in slope_data and slope_data['dloads']:
1470
+ for dload_set in slope_data['dloads']:
1471
+ if dload_set:
1472
+ xs = [p['X'] for p in dload_set]
1473
+ ys = [p['Y'] for p in dload_set]
1474
+ bounds.append((min(xs), max(xs), min(ys), max(ys)))
1475
+
1476
+ # Reinforcement lines bounds
1477
+ if 'reinforce_lines' in slope_data and slope_data['reinforce_lines']:
1478
+ for line in slope_data['reinforce_lines']:
1479
+ if line:
1480
+ xs = [p['X'] for p in line]
1481
+ ys = [p['Y'] for p in line]
1482
+ bounds.append((min(xs), max(xs), min(ys), max(ys)))
1483
+
1484
+ return bounds