xslope 0.1.11__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/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
- tableau_colors = plt.get_cmap('tab10').colors # 10 distinct colors
33
- return tableau_colors[idx % len(tableau_colors)]
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
- f = interp1d(piezo_xs, piezo_ys, kind='linear', bounds_error=False, fill_value='extrapolate')
192
- mid_y = f(mid_x)
193
- ax.plot(mid_x, mid_y + 6, marker='v', color=color, markersize=8)
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 plot_material_table(ax, materials, xloc=0.6, yloc=0.7):
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
- xloc: x-location of table (0-1)
434
- yloc: y-location of table (0-1)
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, 0.25])
682
+ bbox=[xloc, yloc, table_width, table_height])
683
+ table.auto_set_font_size(False)
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])
515
797
  table.auto_set_font_size(False)
516
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,7 +1104,19 @@ def plot_reinforcement_lines(ax, slope_data):
717
1104
  tension_points_plotted = True
718
1105
 
719
1106
 
720
- def plot_inputs(slope_data, title="Slope Geometry and Inputs", figsize=(12, 6), mat_table=True, save_png=False, dpi=300):
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
 
@@ -726,11 +1125,32 @@ def plot_inputs(slope_data, title="Slope Geometry and Inputs", figsize=(12, 6),
726
1125
  title: Title for the plot
727
1126
  figsize: Tuple of (width, height) in inches for the plot
728
1127
  mat_table: Controls material table display. Can be:
729
- - True: Auto-position material table to avoid overlaps
1128
+ - True: Use tab_loc for positioning (default)
730
1129
  - False: Don't show material table
731
- - 'auto': Auto-position material table to avoid overlaps
732
- - String: Specific location for material table ('upper left', 'upper right', 'upper center',
733
- 'lower left', 'lower right', 'lower center', 'center left', 'center right', 'center')
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).
734
1154
 
735
1155
  Returns:
736
1156
  None
@@ -741,6 +1161,8 @@ def plot_inputs(slope_data, title="Slope Geometry and Inputs", figsize=(12, 6),
741
1161
  plot_profile_lines(ax, slope_data['profile_lines'])
742
1162
  plot_max_depth(ax, slope_data['profile_lines'], slope_data['max_depth'])
743
1163
  plot_piezo_line(ax, slope_data)
1164
+ if mode == "seep":
1165
+ plot_seepage_bc_lines(ax, slope_data)
744
1166
  plot_dloads(ax, slope_data)
745
1167
  plot_tcrack_surface(ax, slope_data['tcrack_surface'])
746
1168
  plot_reinforcement_lines(ax, slope_data)
@@ -752,32 +1174,116 @@ def plot_inputs(slope_data, title="Slope Geometry and Inputs", figsize=(12, 6),
752
1174
 
753
1175
  # Handle material table display
754
1176
  if mat_table:
755
- if isinstance(mat_table, str) and mat_table != 'auto':
756
- # Convert location string to xloc, yloc coordinates (inside plot area with margins)
757
- location_map = {
758
- 'upper left': (0.05, 0.70),
759
- 'upper right': (0.70, 0.70),
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)),
760
1238
  'upper center': (0.35, 0.70),
761
1239
  'lower left': (0.05, 0.05),
762
1240
  'lower right': (0.70, 0.05),
763
1241
  'lower center': (0.35, 0.05),
764
1242
  'center left': (0.05, 0.35),
765
1243
  'center right': (0.70, 0.35),
766
- 'center': (0.35, 0.35)
1244
+ 'center': (0.35, 0.35),
1245
+ 'top': ((1.0 - width) / 2.0, 1.16)
767
1246
  }
768
- if mat_table in location_map:
769
- xloc, yloc = location_map[mat_table]
770
- plot_material_table(ax, slope_data['materials'], xloc=xloc, yloc=yloc)
771
- else:
772
- # Default to upper right if invalid location
773
- plot_material_table(ax, slope_data['materials'], xloc=0.75, yloc=0.75)
774
- else:
775
- # Auto-position or default: find best location
776
- plot_elements_bounds = get_plot_elements_bounds(ax, slope_data)
777
- xloc, yloc = find_best_table_position(ax, slope_data['materials'], plot_elements_bounds)
778
- plot_material_table(ax, slope_data['materials'], xloc=xloc, yloc=yloc)
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)
779
1279
 
780
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)
781
1287
  ax.set_xlabel("x")
782
1288
  ax.set_ylabel("y")
783
1289
  ax.grid(False)
@@ -791,16 +1297,38 @@ def plot_inputs(slope_data, title="Slope Geometry and Inputs", figsize=(12, 6),
791
1297
  handles.append(dummy_line)
792
1298
  labels.append('Distributed Load')
793
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
+
794
1321
  ax.legend(
795
1322
  handles=handles,
796
1323
  labels=labels,
797
- loc='upper center',
1324
+ loc="upper center",
798
1325
  bbox_to_anchor=(0.5, -0.12),
799
- ncol=2
1326
+ ncol=ncol,
800
1327
  )
801
1328
 
802
1329
  ax.set_title(title)
803
1330
 
1331
+ plt.subplots_adjust(bottom=bottom_margin)
804
1332
  plt.tight_layout()
805
1333
 
806
1334
  if save_png:
@@ -892,6 +1420,8 @@ def plot_solution(slope_data, slice_df, failure_surface, results, figsize=(12, 7
892
1420
  title = f'Corps Engineers: FS = {fs:.3f}, θ = {theta:.2f}°'
893
1421
  elif method == 'lowe_karafiath':
894
1422
  title = f'Lowe & Karafiath: FS = {fs:.3f}'
1423
+ else:
1424
+ title = f'{method}: FS = {fs:.3f}'
895
1425
  ax.set_title(title)
896
1426
 
897
1427
  # zoom y‐axis to just cover the slope and depth, with a little breathing room (thrust line can be outside)
@@ -1360,12 +1890,16 @@ def plot_mesh(mesh, materials=None, figsize=(14, 6), pad_frac=0.05, show_nodes=T
1360
1890
  plt.show()
1361
1891
 
1362
1892
 
1363
- def plot_polygons(polygons, title="Material Zone Polygons", save_png=False, dpi=300):
1893
+ def plot_polygons(polygons, materials=None, nodes=False, legend=True, title="Material Zone Polygons", save_png=False, dpi=300):
1364
1894
  """
1365
1895
  Plot all material zone polygons in a single figure.
1366
1896
 
1367
1897
  Parameters:
1368
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.
1369
1903
  title: Plot title
1370
1904
  """
1371
1905
  import matplotlib.pyplot as plt
@@ -1374,12 +1908,24 @@ def plot_polygons(polygons, title="Material Zone Polygons", save_png=False, dpi=
1374
1908
  for i, polygon in enumerate(polygons):
1375
1909
  xs = [x for x, y in polygon]
1376
1910
  ys = [y for x, y in polygon]
1377
- ax.fill(xs, ys, color=get_material_color(i), alpha=0.6, label=f'Material {i}')
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)
1378
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)
1379
1924
  ax.set_xlabel('X Coordinate')
1380
1925
  ax.set_ylabel('Y Coordinate')
1381
1926
  ax.set_title(title)
1382
- ax.legend()
1927
+ if legend:
1928
+ ax.legend()
1383
1929
  ax.grid(True, alpha=0.3)
1384
1930
  ax.set_aspect('equal')
1385
1931
  plt.tight_layout()
@@ -1391,13 +1937,14 @@ def plot_polygons(polygons, title="Material Zone Polygons", save_png=False, dpi=
1391
1937
  plt.show()
1392
1938
 
1393
1939
 
1394
- def plot_polygons_separately(polygons, title_prefix='Material Zone', save_png=False, dpi=300):
1940
+ def plot_polygons_separately(polygons, materials=None, save_png=False, dpi=300):
1395
1941
  """
1396
1942
  Plot each polygon in a separate matplotlib frame (subplot), with vertices as round dots.
1397
1943
 
1398
1944
  Parameters:
1399
1945
  polygons: List of polygon coordinate lists
1400
- title_prefix: Prefix for each subplot title
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.
1401
1948
  """
1402
1949
  import matplotlib.pyplot as plt
1403
1950
 
@@ -1412,14 +1959,25 @@ def plot_polygons_separately(polygons, title_prefix='Material Zone', save_png=Fa
1412
1959
  ax.scatter(xs, ys, color='k', s=30, marker='o', zorder=3, label='Vertices')
1413
1960
  ax.set_xlabel('X Coordinate')
1414
1961
  ax.set_ylabel('Y Coordinate')
1415
- ax.set_title(f'{title_prefix} {i}')
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}')
1416
1973
  ax.grid(True, alpha=0.3)
1417
1974
  ax.set_aspect('equal')
1418
- ax.legend()
1975
+ # Intentionally no legend: these plots are typically used for debugging geometry,
1976
+ # and legends can obscure key vertices/edges.
1419
1977
  plt.tight_layout()
1420
1978
 
1421
1979
  if save_png:
1422
- filename = 'plot_' + title_prefix.lower().replace(' ', '_').replace(':', '').replace(',', '') + '_separate.png'
1980
+ filename = 'plot_polygons_separately.png'
1423
1981
  plt.savefig(filename, dpi=dpi, bbox_inches='tight')
1424
1982
 
1425
1983
  plt.show()