API Reference - Plot Module

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)

Automatically adjusts table column widths and row heights based on content.

Measures text extents using the matplotlib renderer and sets column widths proportional to content while enforcing minimum and maximum constraints. The "Name" column gets more space (18-30%) while numeric columns are constrained to prevent excessive whitespace. Row heights are uniform and based on the tallest content in each row.

Parameters:
  • ax

    matplotlib Axes object containing the table

  • table

    matplotlib Table object to be resized

  • col_labels

    List of column header labels (str)

  • table_data

    List of lists containing table cell data

  • table_width

    Total table width in axes coordinates (0-1)

  • table_height

    Total table height in axes coordinates (0-1)

  • min_row_frac

    Minimum row height as fraction of axes height (default: 0.02)

  • row_pad

    Padding factor applied to measured row heights (default: 1.35)

  • col_min_frac

    Minimum column width as fraction of table width for numeric columns (default: 0.08)

  • col_max_frac

    Maximum column width as fraction of table width for numeric columns (default: 0.15)

Returns:
  • None

Notes
  • The "Name" column is automatically left-aligned and gets 18-30% of table width
  • Numeric columns are center-aligned and constrained to 8-15% of table width
  • All three material table types (LEM, SEEP, FEM) use the same sizing parameters
Source code in xslope/plot.py
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):
    """
    Automatically adjusts table column widths and row heights based on content.

    Measures text extents using the matplotlib renderer and sets column widths proportional
    to content while enforcing minimum and maximum constraints. The "Name" column gets
    more space (18-30%) while numeric columns are constrained to prevent excessive whitespace.
    Row heights are uniform and based on the tallest content in each row.

    Parameters:
        ax: matplotlib Axes object containing the table
        table: matplotlib Table object to be resized
        col_labels: List of column header labels (str)
        table_data: List of lists containing table cell data
        table_width: Total table width in axes coordinates (0-1)
        table_height: Total table height in axes coordinates (0-1)
        min_row_frac: Minimum row height as fraction of axes height (default: 0.02)
        row_pad: Padding factor applied to measured row heights (default: 1.35)
        col_min_frac: Minimum column width as fraction of table width for numeric columns (default: 0.08)
        col_max_frac: Maximum column width as fraction of table width for numeric columns (default: 0.15)

    Returns:
        None

    Notes:
        - The "Name" column is automatically left-aligned and gets 18-30% of table width
        - Numeric columns are center-aligned and constrained to 8-15% of table width
        - All three material table types (LEM, SEEP, FEM) use the same sizing parameters
    """
    # Force draw to get a valid renderer
    try:
        ax.figure.canvas.draw()
        renderer = ax.figure.canvas.get_renderer()
    except Exception:
        renderer = None

    ncols = len(col_labels)
    nrows = len(table_data) + 1  # include header
    # Measure text widths per column in pixels
    widths_px = [1.0] * ncols
    if renderer is not None:
        for c in range(ncols):
            max_w = 1.0
            for r in range(nrows):
                cell = table[(r, c)]
                text = cell.get_text()
                try:
                    bbox = text.get_window_extent(renderer=renderer)
                    max_w = max(max_w, bbox.width)
                except Exception:
                    pass
            widths_px[c] = max_w
    total_w = sum(widths_px) if sum(widths_px) > 0 else float(ncols)
    col_fracs = [w / total_w for w in widths_px]
    # Clamp extreme column widths to keep numeric columns from becoming too wide
    clamped = []
    for i, frac in enumerate(col_fracs):
        label = str(col_labels[i]).lower()
        min_frac = col_min_frac
        max_frac = col_max_frac
        if label == "name":
            min_frac = 0.18
            max_frac = 0.30
        clamped.append(min(max(frac, min_frac), max_frac))
    # Re-normalize to sum to 1.0
    s = sum(clamped)
    if s > 0:
        col_fracs = [c / s for c in clamped]
    # Compute per-row pixel heights based on text extents, convert to axes fraction
    axes_h_px = None
    if renderer is not None:
        try:
            axes_h_px = ax.get_window_extent(renderer=renderer).height
        except Exception:
            axes_h_px = None
    # Fallback axes height if needed (avoid division by zero)
    if not axes_h_px or axes_h_px <= 0:
        axes_h_px = 800.0  # arbitrary but reasonable default
    row_heights_frac = []
    for r in range(nrows):
        max_h_px = 1.0
        if renderer is not None:
            for c in range(ncols):
                try:
                    bbox = table[(r, c)].get_text().get_window_extent(renderer=renderer)
                    max_h_px = max(max_h_px, bbox.height)
                except Exception:
                    pass
        # padding factor to provide breathing room around text
        padded_px = max_h_px * row_pad
        # Convert to axes fraction with minimum clamp
        rh = max(padded_px / axes_h_px, min_row_frac)
        row_heights_frac.append(rh)

    # Apply column widths and per-row heights
    for r in range(nrows):
        for c in range(ncols):
            cell = table[(r, c)]
            cell.set_width(table_width * col_fracs[c])
            cell.set_height(row_heights_frac[r])
            # Left-align the "Name" column if present
            label = str(col_labels[c]).lower()
            if label == "name":
                cell.get_text().set_ha('left')

compute_ylim(data, slice_df, scale_frac=0.5, pad_fraction=0.1)

Computes y-limits for plotting based on slice data.

Parameters:
  • data

    Input data

  • slice_df

    pandas.DataFrame with slice data, must have 'y_lt' and 'y_lb' for stress‐bar sizing

  • scale_frac

    fraction of max slice height used when drawing stress bars

  • pad_fraction

    fraction of total range to pad above/below finally

Returns:
  • (y_min, y_max) suitable for ax.set_ylim(...)

Source code in xslope/plot.py
def compute_ylim(data, slice_df, scale_frac=0.5, pad_fraction=0.1):
    """
    Computes y-limits for plotting based on slice data.

    Parameters:
        data: Input data
        slice_df: pandas.DataFrame with slice data, must have 'y_lt' and 'y_lb' for stress‐bar sizing
        scale_frac: fraction of max slice height used when drawing stress bars
        pad_fraction: fraction of total range to pad above/below finally

    Returns:
        (y_min, y_max) suitable for ax.set_ylim(...)
    """
    import numpy as np

    y_vals = []

    # 1) collect all profile line elevations
    for line in data.get('profile_lines', []):
        coords = line['coords']
        if hasattr(coords, "xy"):
            _, ys = coords.xy
        else:
            _, ys = zip(*coords)
        y_vals.extend(ys)

    # 2) explicitly include the deepest allowed depth
    if "max_depth" in data and data["max_depth"] is not None:
        y_vals.append(data["max_depth"])

    if not y_vals:
        return 0.0, 1.0

    y_min = min(y_vals)
    y_max = max(y_vals)

    # 3) ensure the largest stress bar will fit
    #    stress‐bar length = scale_frac * slice height
    heights = slice_df["y_lt"] - slice_df["y_lb"]
    if not heights.empty:
        max_bar = heights.max() * scale_frac
        y_min -= max_bar
        y_max += max_bar

    # 4) account for distributed loads extending above ground surface
    gamma_w = data.get('gamma_water', 62.4)
    for dloads in [data.get('dloads', []), data.get('dloads2', [])]:
        if dloads:
            for line in dloads:
                for pt in line:
                    # dload arrows extend above surface by load/gamma_w (water depth equivalent)
                    load = pt.get('Normal', 0)
                    if load > 0:
                        y_max = max(y_max, pt.get('Y', 0) + load / gamma_w)

    # 5) add a final small pad
    pad = (y_max - y_min) * pad_fraction
    return y_min - pad, y_max + pad

find_best_table_position(ax, materials, plot_elements_bounds)

Find the best position for the material table to avoid overlaps.

Parameters:
  • ax

    matplotlib Axes object

  • materials

    List of materials to determine table size

  • plot_elements_bounds

    List of (x_min, x_max, y_min, y_max) for existing elements

Returns:
  • (xloc, yloc) coordinates for table placement

Source code in xslope/plot.py
def find_best_table_position(ax, materials, plot_elements_bounds):
    """
    Find the best position for the material table to avoid overlaps.

    Parameters:
        ax: matplotlib Axes object
        materials: List of materials to determine table size
        plot_elements_bounds: List of (x_min, x_max, y_min, y_max) for existing elements

    Returns:
        (xloc, yloc) coordinates for table placement
    """
    # Calculate table size based on number of materials and columns
    num_materials = len(materials)
    has_d_psi = any(mat.get('d', 0) > 0 or mat.get('psi', 0) > 0 for mat in materials)
    table_height = 0.05 + 0.025 * num_materials  # Height per row
    table_width = 0.25 if has_d_psi else 0.2

    # Define candidate positions (priority order) - with margins from borders
    candidates = [
        (0.05, 0.70),  # upper left
        (0.70, 0.70),  # upper right  
        (0.05, 0.05),  # lower left
        (0.70, 0.05),  # lower right
        (0.35, 0.70),  # upper center
        (0.35, 0.05),  # lower center
        (0.05, 0.35),  # center left
        (0.70, 0.35),  # center right
        (0.35, 0.35),  # center
    ]

    # Check each candidate position for overlaps
    for xloc, yloc in candidates:
        table_bounds = (xloc, xloc + table_width, yloc - table_height, yloc)

        # Check if table overlaps with any plot elements
        overlap = False
        for elem_bounds in plot_elements_bounds:
            elem_x_min, elem_x_max, elem_y_min, elem_y_max = elem_bounds
            table_x_min, table_x_max, table_y_min, table_y_max = table_bounds

            # Check for overlap
            if not (table_x_max < elem_x_min or table_x_min > elem_x_max or
                   table_y_max < elem_y_min or table_y_min > elem_y_max):
                overlap = True
                break

        if not overlap:
            return xloc, yloc

    # If all positions have overlap, return the first candidate
    return candidates[0]

get_dload_legend_handler()

Creates and returns a custom legend entry for distributed loads. Returns a tuple of (handler_class, dummy_patch) for use in matplotlib legends.

Source code in xslope/plot.py
def get_dload_legend_handler():
    """
    Creates and returns a custom legend entry for distributed loads.
    Returns a tuple of (handler_class, dummy_patch) for use in matplotlib legends.
    """
    # Create a line with built-in arrow marker
    dummy_line = Line2D([0.0, 1.0], [0, 0],  # Two points to define line
                       color='purple', 
                       alpha=0.7, 
                       linewidth=2,
                       marker='>',  # Built-in right arrow marker
                       markersize=6,  # Smaller marker size
                       markerfacecolor='purple',
                       markeredgecolor='purple',
                       drawstyle='steps-post',  # Draw line then marker
                       solid_capstyle='butt')

    return None, dummy_line

get_plot_elements_bounds(ax, slope_data)

Get bounding boxes of existing plot elements to avoid overlaps.

Parameters:
  • ax

    matplotlib Axes object

  • slope_data

    Dictionary containing slope data

Returns:
  • List of (x_min, x_max, y_min, y_max) tuples for plot elements

Source code in xslope/plot.py
def get_plot_elements_bounds(ax, slope_data):
    """
    Get bounding boxes of existing plot elements to avoid overlaps.

    Parameters:
        ax: matplotlib Axes object
        slope_data: Dictionary containing slope data

    Returns:
        List of (x_min, x_max, y_min, y_max) tuples for plot elements
    """
    bounds = []

    # Get axis limits
    x_min, x_max = ax.get_xlim()
    y_min, y_max = ax.get_ylim()

    # Profile lines bounds
    if 'profile_lines' in slope_data:
        for line in slope_data['profile_lines']:
            if line:
                coords = line['coords']
                xs = [p[0] for p in coords]
                ys = [p[1] for p in coords]
                bounds.append((min(xs), max(xs), min(ys), max(ys)))

    # Distributed loads bounds
    if 'dloads' in slope_data and slope_data['dloads']:
        for dload_set in slope_data['dloads']:
            if dload_set:
                xs = [p['X'] for p in dload_set]
                ys = [p['Y'] for p in dload_set]
                bounds.append((min(xs), max(xs), min(ys), max(ys)))

    # Reinforcement lines bounds
    if 'reinforce_lines' in slope_data and slope_data['reinforce_lines']:
        for line in slope_data['reinforce_lines']:
            if line:
                xs = [p['X'] for p in line]
                ys = [p['Y'] for p in line]
                bounds.append((min(xs), max(xs), min(ys), max(ys)))

    return bounds

plot_base_stresses(ax, slice_df, scale_frac=0.5, alpha=0.3)

Plots base normal stresses for each slice as bars.

Parameters:
  • ax

    matplotlib Axes object

  • slice_df

    DataFrame containing slice data

  • scale_frac

    Fraction of plot height for bar scaling

  • alpha

    Transparency for bars

Returns:
  • None

Source code in xslope/plot.py
def plot_base_stresses(ax, slice_df, scale_frac=0.5, alpha=0.3):
    """
    Plots base normal stresses for each slice as bars.

    Parameters:
        ax: matplotlib Axes object
        slice_df: DataFrame containing slice data
        scale_frac: Fraction of plot height for bar scaling
        alpha: Transparency for bars

    Returns:
        None
    """
    u = slice_df['u'].values  # pore pressure (stress)
    n_eff = slice_df['n_eff'].values / slice_df['dl'].values  # convert effective normal force to stress
    dl = slice_df['dl'].values
    heights = slice_df['y_ct'] - slice_df['y_cb']
    max_ht = heights.max() if not heights.empty else 1.0
    max_bar_len = max_ht * scale_frac

    max_stress = np.max(np.abs(n_eff)) if len(n_eff) > 0 else 1.0
    max_u = np.max(u) if len(u) > 0 else 1.0

    for i, (index, row) in enumerate(slice_df.iterrows()):
        if i >= len(n_eff):
            break

        x1, y1 = row['x_l'], row['y_lb']
        x2, y2 = row['x_r'], row['y_rb']

        stress = n_eff[i]
        pore = u[i]

        dx = x2 - x1
        dy = y2 - y1
        length = np.hypot(dx, dy)
        if length == 0:
            continue

        nx = -dy / length
        ny = dx / length

        # --- Normal stress trapezoid ---
        bar_len = (abs(stress) / max_stress) * max_bar_len
        direction = -np.sign(stress)

        x1_top = x1 + direction * bar_len * nx
        y1_top = y1 + direction * bar_len * ny
        x2_top = x2 + direction * bar_len * nx
        y2_top = y2 + direction * bar_len * ny

        poly_x = [x1, x2, x2_top, x1_top]
        poly_y = [y1, y2, y2_top, y1_top]

        ax.fill(poly_x, poly_y, facecolor='none', edgecolor='red' if stress <= 0 else 'limegreen', hatch='.....',
                linewidth=1)

        # --- Pore pressure trapezoid ---
        u_len = (pore / max_stress) * max_bar_len
        u_dir = -1  # always into the base

        ux1_top = x1 + u_dir * u_len * nx
        uy1_top = y1 + u_dir * u_len * ny
        ux2_top = x2 + u_dir * u_len * nx
        uy2_top = y2 + u_dir * u_len * ny

        poly_ux = [x1, x2, ux2_top, ux1_top]
        poly_uy = [y1, y2, uy2_top, uy1_top]

        ax.fill(poly_ux, poly_uy, color='blue', alpha=alpha, edgecolor='k', linewidth=1)

plot_circle_centers(ax, fs_cache)

Plots the centers of circular failure surfaces.

Parameters:
  • ax

    matplotlib Axes object

  • fs_cache

    List of dictionaries containing circle center data

Returns:
  • None

Source code in xslope/plot.py
def plot_circle_centers(ax, fs_cache):
    """
    Plots the centers of circular failure surfaces.

    Parameters:
        ax: matplotlib Axes object
        fs_cache: List of dictionaries containing circle center data

    Returns:
        None
    """
    for result in fs_cache:
        ax.plot(result['Xo'], result['Yo'], 'ko', markersize=3, alpha=0.6)

plot_circles(ax, slope_data)

Plots starting circles with center markers and arrows.

Parameters:
  • ax (matplotlib axis) –

    The plotting axis

  • slope_data (dict) –

    Slope data dictionary containing circles

Returns:
  • None

Source code in xslope/plot.py
def plot_circles(ax, slope_data):
    """
    Plots starting circles with center markers and arrows.

    Parameters:
        ax (matplotlib axis): The plotting axis
        slope_data (dict): Slope data dictionary containing circles

    Returns:
        None
    """
    circles = slope_data['circles']
    tcrack_depth = slope_data.get('tcrack_depth', 0)

    for i, circle in enumerate(circles):
        Xo = circle['Xo']
        Yo = circle['Yo']
        R = circle['R']
        # theta = np.linspace(0, 2 * np.pi, 100)
        # x_circle = Xo + R * np.cos(theta)
        # y_circle = Yo + R * np.sin(theta)
        # ax.plot(x_circle, y_circle, 'r--', label='Circle')

        # Plot the portion of the circle in the slope (clipped to tension crack if present)
        ground_surface = slope_data['ground_surface']
        success, result = generate_failure_surface(ground_surface, circular=True, circle=circle, tcrack_depth=tcrack_depth)
        if not success:
            print(f"Warning: Circle {i+1} (Xo={Xo:.2f}, Yo={Yo:.2f}, R={R:.2f}) could not be plotted: {result}")
            continue
        # result = (x_min, x_max, y_left, y_right, clipped_surface)
        x_min, x_max, y_left, y_right, clipped_surface = result
        if not isinstance(clipped_surface, LineString):
            clipped_surface = LineString(clipped_surface)
        x_clip, y_clip = zip(*clipped_surface.coords)
        ax.plot(x_clip, y_clip, 'r--', label="Circle")

        # Center marker
        ax.plot(Xo, Yo, 'r+', markersize=10)

        # Arrow direction: point from center to midpoint of failure surface
        mid_idx = len(x_clip) // 2
        x_mid = x_clip[mid_idx]
        y_mid = y_clip[mid_idx]

        dx = x_mid - Xo
        dy = y_mid - Yo

        # Normalize direction vector
        length = np.hypot(dx, dy)
        if length != 0:
            dx /= length
            dy /= length

        # Draw arrow with pixel-based head size
        ax.annotate('',
                    xy=(Xo + dx * R, Yo + dy * R),  # arrow tip
                    xytext=(Xo, Yo),                 # arrow start
                    arrowprops=dict(
                        arrowstyle='-|>',
                        color='red',
                        lw=1.0,            # shaft width in points
                        mutation_scale=20  # head size in points
                    ))

plot_circular_search_results(slope_data, fs_cache, search_path=None, circle_cache=None, highlight_fs=True, figsize=(12, 7), save_png=False, dpi=300)

Creates a plot showing the results of a circular failure surface search.

Parameters:
  • slope_data

    Dictionary containing plot data

  • fs_cache

    List of dictionaries containing failure surface data and FS values

  • search_path

    List of dictionaries containing search path coordinates

  • circle_cache

    List of dictionaries containing all tested circles (for plotting)

  • highlight_fs

    Boolean indicating whether to highlight the critical failure surface

  • figsize

    Tuple of (width, height) in inches for the plot

Returns:
  • None

Source code in xslope/plot.py
def plot_circular_search_results(slope_data, fs_cache, search_path=None, circle_cache=None, highlight_fs=True, figsize=(12, 7), save_png=False, dpi=300):
    """
    Creates a plot showing the results of a circular failure surface search.

    Parameters:
        slope_data: Dictionary containing plot data
        fs_cache: List of dictionaries containing failure surface data and FS values
        search_path: List of dictionaries containing search path coordinates
        circle_cache: List of dictionaries containing all tested circles (for plotting)
        highlight_fs: Boolean indicating whether to highlight the critical failure surface
        figsize: Tuple of (width, height) in inches for the plot

    Returns:
        None
    """
    fig, ax = plt.subplots(figsize=figsize)

    plot_profile_lines(ax, slope_data['profile_lines'], materials=slope_data.get('materials'))
    plot_max_depth(ax, slope_data['profile_lines'], slope_data['max_depth'])
    if any(m.get('u') == 'piezo' for m in slope_data.get('materials', [])):
        plot_piezo_line(ax, slope_data)
    plot_dloads(ax, slope_data)
    plot_tcrack_surface(ax, slope_data)

    # Plot all tested circles from circle_cache (light gray)
    if circle_cache:
        first_plotted = True
        for result in circle_cache:
            surface = result.get('failure_surface')
            if surface is None or surface.is_empty:
                continue
            x, y = zip(*surface.coords)
            label = 'Tested Circle' if first_plotted else None
            ax.plot(x, y, color='gray', linestyle='-', linewidth=0.5, alpha=0.5, label=label)
            first_plotted = False

    # Plot only the critical circle from fs_cache (red)
    if fs_cache:
        critical = fs_cache[0]
        surface = critical.get('failure_surface')
        if surface is not None and not surface.is_empty:
            x, y = zip(*surface.coords)
            ax.plot(x, y, color='red', linestyle='-', linewidth=2, label='Critical Circle')
        # Plot critical circle center
        ax.plot(critical['Xo'], critical['Yo'], 'ro', markersize=5)

    # Plot all circle centers from fs_cache
    plot_circle_centers(ax, fs_cache)

    if search_path:
        plot_search_path(ax, search_path)

    ax.set_aspect('equal')
    ax.set_xlabel("x")
    ax.set_ylabel("y")
    ax.grid(False)
    ax.legend()

    if highlight_fs and fs_cache:
        critical_fs = fs_cache[0]['FS']
        ax.set_title(f"Critical Factor of Safety = {critical_fs:.3f}")

    plt.tight_layout()

    if save_png:
        filename = 'plot_circular_search_results.png'
        plt.savefig(filename, dpi=dpi, bbox_inches='tight')

    plt.show()

plot_dloads(ax, slope_data)

Plots distributed loads as arrows along the surface.

Source code in xslope/plot.py
def plot_dloads(ax, slope_data):
    """
    Plots distributed loads as arrows along the surface.
    """
    gamma_w = slope_data['gamma_water']
    ground_surface = slope_data['ground_surface']

    def plot_single_dload_set(ax, dloads, color, label):
        """Internal function to plot a single set of distributed loads"""
        if not dloads:
            return

        # find the max horizontal length of the ground surface
        max_horizontal_length_ground = 0
        for pt in ground_surface.coords:
            max_horizontal_length_ground = max(max_horizontal_length_ground, pt[0])

        arrow_spacing = max_horizontal_length_ground / 60

        # find the max dload value
        max_dload = 0
        for line in dloads:
            max_dload = max(max_dload, max(pt['Normal'] for pt in line))

        arrow_height = max_dload / gamma_w
        head_length = arrow_height / 12
        head_width = head_length * 0.8

        # Find the maximum load value for scaling
        max_load = 0
        for line in dloads:
            max_load = max(max_load, max(pt['Normal'] for pt in line))

        for line in dloads:
            if len(line) < 2:
                continue

            xs = [pt['X'] for pt in line]
            ys = [pt['Y'] for pt in line]
            ns = [pt['Normal'] for pt in line]

            # Process line segments
            for i in range(len(line) - 1):
                x1, y1, n1 = xs[i], ys[i], ns[i]
                x2, y2, n2 = xs[i+1], ys[i+1], ns[i+1]

                # Calculate segment direction (perpendicular to this segment)
                dx = x2 - x1
                dy = y2 - y1
                segment_length = np.sqrt(dx**2 + dy**2)

                if segment_length == 0:
                    continue

                # Normalize the segment direction
                dx_norm = dx / segment_length
                dy_norm = dy / segment_length

                # Perpendicular direction (rotate 90 degrees CCW)
                perp_dx = -dy_norm
                perp_dy = dx_norm

                # Generate arrows along this segment
                dx_abs = abs(x2 - x1)
                num_arrows = max(1, int(round(dx_abs / arrow_spacing)))
                if dx_abs == 0:
                    t_values = np.array([0.0, 1.0])
                else:
                    t_values = np.linspace(0, 1, num_arrows + 1)

                # Store arrow top points for connecting line
                top_xs = []
                top_ys = []

                # Add start point if it's the first segment and load is zero
                if i == 0 and n1 == 0:
                    top_xs.append(x1)
                    top_ys.append(y1)

                for t in t_values:
                    # Interpolate position along segment
                    x = x1 + t * dx
                    y = y1 + t * dy

                    # Interpolate load value
                    n = n1 + t * (n2 - n1)

                    # Scale arrow height based on equivalent water depth
                    if max_load > 0:
                        water_depth = n / gamma_w
                        arrow_height = water_depth  # Direct water depth, not scaled relative to max
                    else:
                        arrow_height = 0

                    # For very small arrows, just store surface point for connecting line
                    if arrow_height < 0.5:
                        top_xs.append(x)
                        top_ys.append(y)
                        continue


                    # Calculate arrow start point (above surface)
                    arrow_start_x = x + perp_dx * arrow_height
                    arrow_start_y = y + perp_dy * arrow_height

                    # Store points for connecting line
                    top_xs.append(arrow_start_x)
                    top_ys.append(arrow_start_y)

                    # Draw arrow - extend all the way to surface point
                    arrow_length = np.sqrt((x - arrow_start_x)**2 + (y - arrow_start_y)**2)
                    if head_length > arrow_length:
                        # Draw a simple line without arrowhead
                        ax.plot([arrow_start_x, x], [arrow_start_y, y], 
                               color=color, linewidth=2, alpha=0.7)
                    else:
                        # Draw arrow with head
                        ax.arrow(arrow_start_x, arrow_start_y, 
                                x - arrow_start_x, y - arrow_start_y,
                                head_width=head_width, head_length=head_length, 
                                fc=color, ec=color, alpha=0.7,
                                length_includes_head=True)

                # Add end point if it's the last segment and load is zero
                if i == len(line) - 2 and n2 == 0:
                    top_xs.append(x2)
                    top_ys.append(y2)

                # Draw connecting line at arrow tops
                if top_xs:
                    ax.plot(top_xs, top_ys, color=color, linewidth=1.5, alpha=0.8)

            # Draw the surface line itself
            ax.plot(xs, ys, color=color, linewidth=1.5, alpha=0.8, label=label)

    dloads = slope_data['dloads']
    dloads2 = slope_data.get('dloads2', [])
    plot_single_dload_set(ax, dloads, 'purple', 'Distributed Load')
    plot_single_dload_set(ax, dloads2, 'orange', 'Distributed Load 2')

plot_failure_surface(ax, failure_surface)

Plots the failure surface as a black line.

Parameters:
  • ax

    matplotlib Axes object

  • failure_surface

    Shapely LineString representing the failure surface

Returns:
  • None

Source code in xslope/plot.py
def plot_failure_surface(ax, failure_surface):
    """
    Plots the failure surface as a black line.

    Parameters:
        ax: matplotlib Axes object
        failure_surface: Shapely LineString representing the failure surface

    Returns:
        None
    """
    if failure_surface:
        x_clip, y_clip = zip(*failure_surface.coords)
        ax.plot(x_clip, y_clip, 'k-', linewidth=2, label="Failure Surface")

plot_failure_surfaces(ax, fs_cache)

Plots all failure surfaces from the factor of safety cache.

Parameters:
  • ax

    matplotlib Axes object

  • fs_cache

    List of dictionaries containing failure surface data and FS values

Returns:
  • None

Source code in xslope/plot.py
def plot_failure_surfaces(ax, fs_cache):
    """
    Plots all failure surfaces from the factor of safety cache.

    Parameters:
        ax: matplotlib Axes object
        fs_cache: List of dictionaries containing failure surface data and FS values

    Returns:
        None
    """
    for i, result in reversed(list(enumerate(fs_cache))):
        surface = result['failure_surface']
        if surface is None or surface.is_empty:
            continue
        x, y = zip(*surface.coords)
        color = 'red' if i == 0 else 'gray'
        lw = 2 if i == 0 else 1
        ax.plot(x, y, color=color, linestyle='-', linewidth=lw, alpha=1.0 if i == 0 else 0.6)

plot_fem_material_table(ax, fem_data, xloc=0.6, yloc=0.7, width=0.6, height=None)

Adds a finite element material properties table to the plot.

Displays material properties for FEM analysis including unit weight (γ), cohesion (c), friction angle (φ), Young's modulus (E), and Poisson's ratio (ν).

Parameters:
  • ax

    matplotlib Axes object to add the table to

  • fem_data

    Dictionary containing FEM material properties with keys: - 'c_by_mat': List of cohesion values (float) - 'phi_by_mat': List of friction angle values in degrees (float) - 'E_by_mat': List of Young's modulus values (float) - 'nu_by_mat': List of Poisson's ratio values (float) - 'gamma_by_mat': List of unit weight values (float) - 'material_names': List of material names (str), optional

  • xloc

    x-location of table bottom-left corner in axes coordinates (0-1, default: 0.6)

  • yloc

    y-location of table bottom-left corner in axes coordinates (0-1, default: 0.7)

  • width

    Table width in axes coordinates (0-1, default: 0.6)

  • height

    Table height in axes coordinates (0-1, default: auto-calculated)

Returns:
  • None

Source code in xslope/plot.py
def plot_fem_material_table(ax, fem_data, xloc=0.6, yloc=0.7, width=0.6, height=None):
    """
    Adds a finite element material properties table to the plot.

    Displays material properties for FEM analysis including unit weight (γ), cohesion (c),
    friction angle (φ), Young's modulus (E), and Poisson's ratio (ν).

    Parameters:
        ax: matplotlib Axes object to add the table to
        fem_data: Dictionary containing FEM material properties with keys:
            - 'c_by_mat': List of cohesion values (float)
            - 'phi_by_mat': List of friction angle values in degrees (float)
            - 'E_by_mat': List of Young's modulus values (float)
            - 'nu_by_mat': List of Poisson's ratio values (float)
            - 'gamma_by_mat': List of unit weight values (float)
            - 'material_names': List of material names (str), optional
        xloc: x-location of table bottom-left corner in axes coordinates (0-1, default: 0.6)
        yloc: y-location of table bottom-left corner in axes coordinates (0-1, default: 0.7)
        width: Table width in axes coordinates (0-1, default: 0.6)
        height: Table height in axes coordinates (0-1, default: auto-calculated)

    Returns:
        None
    """
    c_by_mat = fem_data.get("c_by_mat")
    phi_by_mat = fem_data.get("phi_by_mat")
    E_by_mat = fem_data.get("E_by_mat")
    nu_by_mat = fem_data.get("nu_by_mat")
    gamma_by_mat = fem_data.get("gamma_by_mat")
    material_names = fem_data.get("material_names", [])
    if c_by_mat is None or len(c_by_mat) == 0:
        return
    col_labels = ["Mat", "Name", "γ", "c", "φ", "E", "ν"]
    table_data = []
    for idx in range(len(c_by_mat)):
        c = c_by_mat[idx]
        phi = phi_by_mat[idx] if phi_by_mat is not None else 0.0
        E = E_by_mat[idx] if E_by_mat is not None else 0.0
        nu = nu_by_mat[idx] if nu_by_mat is not None else 0.0
        gamma = gamma_by_mat[idx] if gamma_by_mat is not None else 0.0
        material_name = material_names[idx] if idx < len(material_names) else f"Material {idx+1}"
        row = [idx + 1, material_name, f"{gamma:.1f}", f"{c:.1f}", f"{phi:.1f}", f"{E:.0f}", f"{nu:.2f}"]
        table_data.append(row)
    if height is None:
        num_rows = max(1, len(c_by_mat))
        height = 0.06 + 0.035 * num_rows
        height = min(0.32, height)
    table = ax.table(cellText=table_data,
                     colLabels=col_labels,
                     loc='upper right',
                     colLoc='center',
                     cellLoc='center',
                     bbox=[xloc, yloc, width, height])
    table.auto_set_font_size(False)
    table.set_fontsize(8)
    auto_size_table_to_content(ax, table, col_labels, table_data, width, height)

plot_inputs(slope_data, title='Slope Geometry and Inputs', figsize=(12, 6), mat_table=False, save_png=False, dpi=300, mode='lem', tab_loc='top', legend_ncol='auto', legend_max_cols=6, legend_max_rows=4)

Creates a plot showing the slope geometry and input parameters.

Parameters:
  • slope_data

    Dictionary containing plot data

  • title

    Title for the plot

  • figsize

    Tuple of (width, height) in inches for the plot

  • mat_table

    Controls material table display. Can be: - True: Use tab_loc for positioning (default) - False: Don't show material table - 'auto': Use tab_loc for positioning - String: Specific location from valid placements (see tab_loc)

  • save_png

    If True, save plot as PNG file (default: False)

  • dpi

    Resolution for saved PNG file (default: 300)

  • mode

    Which material properties table to display: - "lem": Limit equilibrium materials (γ, c, φ, optional d/ψ) - "seep": Seepage properties (k₁, k₂, Angle, kr₀, h₀) - "fem": FEM properties (γ, c, φ, E, ν)

  • tab_loc

    Table placement when mat_table is True or 'auto'. Valid options: - "upper left": Top-left corner of plot area - "upper right": Top-right corner of plot area - "upper center": Top-center of plot area - "lower left": Bottom-left corner of plot area - "lower right": Bottom-right corner of plot area - "lower center": Bottom-center of plot area - "center left": Middle-left of plot area - "center right": Middle-right of plot area - "center": Center of plot area - "top": Above plot area, horizontally centered

  • legend_ncol

    Legend column count. Use "auto" (default) to choose a value that keeps the legend from getting too tall, or pass an int to force a width.

  • legend_max_cols

    When legend_ncol="auto", cap the number of columns (default: 6).

  • legend_max_rows

    When legend_ncol="auto", try to keep legend rows <= this value by increasing columns (default: 4).

Returns:
  • None

Source code in xslope/plot.py
def plot_inputs(
    slope_data,
    title="Slope Geometry and Inputs",
    figsize=(12, 6),
    mat_table=False,
    save_png=False,
    dpi=300,
    mode="lem",
    tab_loc="top",
    legend_ncol="auto",
    legend_max_cols=6,
    legend_max_rows=4,
):
    """
    Creates a plot showing the slope geometry and input parameters.

    Parameters:
        slope_data: Dictionary containing plot data
        title: Title for the plot
        figsize: Tuple of (width, height) in inches for the plot
        mat_table: Controls material table display. Can be:
            - True: Use tab_loc for positioning (default)
            - False: Don't show material table
            - 'auto': Use tab_loc for positioning
            - String: Specific location from valid placements (see tab_loc)
        save_png: If True, save plot as PNG file (default: False)
        dpi: Resolution for saved PNG file (default: 300)
        mode: Which material properties table to display:
            - "lem": Limit equilibrium materials (γ, c, φ, optional d/ψ)
            - "seep": Seepage properties (k₁, k₂, Angle, kr₀, h₀)
            - "fem": FEM properties (γ, c, φ, E, ν)
        tab_loc: Table placement when mat_table is True or 'auto'. Valid options:
            - "upper left": Top-left corner of plot area
            - "upper right": Top-right corner of plot area
            - "upper center": Top-center of plot area
            - "lower left": Bottom-left corner of plot area
            - "lower right": Bottom-right corner of plot area
            - "lower center": Bottom-center of plot area
            - "center left": Middle-left of plot area
            - "center right": Middle-right of plot area
            - "center": Center of plot area
            - "top": Above plot area, horizontally centered
        legend_ncol: Legend column count. Use "auto" (default) to choose a value that
            keeps the legend from getting too tall, or pass an int to force a width.
        legend_max_cols: When legend_ncol="auto", cap the number of columns (default: 6).
        legend_max_rows: When legend_ncol="auto", try to keep legend rows <= this value
            by increasing columns (default: 4).

    Returns:
        None
    """
    fig, ax = plt.subplots(figsize=figsize)

    # Plot mesh in background if available
    mesh = slope_data.get('mesh')
    if mesh is not None:
        from matplotlib.collections import LineCollection
        m_nodes = mesh["nodes"]
        m_elements = mesh["elements"]
        m_etypes = mesh["element_types"]
        lines = []
        for elem, etype in zip(m_elements, m_etypes):
            if etype in (3, 6):  # tri3 / tri6 – corner edges only
                edges = [(elem[0], elem[1]), (elem[1], elem[2]), (elem[2], elem[0])]
            elif etype in (4, 8, 9):  # quad4 / quad8 / quad9
                edges = [(elem[0], elem[1]), (elem[1], elem[2]),
                         (elem[2], elem[3]), (elem[3], elem[0])]
            else:
                continue
            for n0, n1 in edges:
                lines.append(m_nodes[[n0, n1]])
        if lines:
            lc = LineCollection(lines, colors='gray', alpha=0.25, linewidths=0.5)
            ax.add_collection(lc)

    # Plot contents
    plot_profile_lines(ax, slope_data['profile_lines'], materials=slope_data.get('materials'), labels=True)
    plot_max_depth(ax, slope_data['profile_lines'], slope_data['max_depth'])
    if mode == "fem" or (mode == "lem" and any(m.get('u') == 'piezo' for m in slope_data.get('materials', []))):
        plot_piezo_line(ax, slope_data)
    if mode == "seep":
        plot_seepage_bc_lines(ax, slope_data)
    if mode != "seep":
        plot_dloads(ax, slope_data)
    plot_tcrack_surface(ax, slope_data)
    plot_reinforcement_lines(ax, slope_data)
    plot_piles(ax, slope_data)

    if mode == "lem":
        if slope_data['circular']:
            plot_circles(ax, slope_data)
        elif slope_data.get('non_circ') and len(slope_data['non_circ']) > 0:
            plot_non_circ(ax, slope_data['non_circ'])

    # Seismic coefficient annotation
    k_seismic = slope_data.get('k_seismic', 0.0)
    if k_seismic and mode in ("lem", "fem"):
        if mode == "fem":
            # FEM: sign of k directly gives direction (+x → right, -x → left)
            arrow = "\u2192" if k_seismic > 0 else "\u2190"
        else:
            # LEM: k acts toward the toe (downslope), infer from ground surface
            gs = slope_data.get('ground_surface')
            if gs is not None and not gs.is_empty:
                coords = list(gs.coords)
                y_left = coords[0][1]
                y_right = coords[-1][1]
                y_peak = max(c[1] for c in coords)
                # Dam detection: both ends are substantially lower than the peak
                threshold = 0.3 * (y_peak - min(y_left, y_right))
                if (y_peak - y_left) > threshold and (y_peak - y_right) > threshold:
                    arrow = "\u2194"  # ↔  both faces
                elif y_left > y_right:
                    arrow = "\u2192"  # →  toe on right
                else:
                    arrow = "\u2190"  # ←  toe on left
            else:
                arrow = ""
        k_text = f"k = {abs(k_seismic):g} {arrow}".strip()
        ax.text(0.98, 0.97, k_text, transform=ax.transAxes,
                fontsize=10, fontweight='bold', ha='right', va='top',
                bbox=dict(boxstyle='round,pad=0.3', facecolor='lightyellow',
                          edgecolor='orange', linewidth=1.0, alpha=0.9))

    # Handle material table display
    if mat_table:
        # Helpers to adapt slope_data materials into formats expected by table functions
        def _build_seep_data():
            materials = slope_data.get('materials', [])
            return {
                "k1_by_mat": [m.get('k1', 0.0) for m in materials],
                "k2_by_mat": [m.get('k2', 0.0) for m in materials],
                "angle_by_mat": [m.get('alpha', 0.0) for m in materials],
                "kr0_by_mat": [m.get('kr0', 0.0) for m in materials],
                "h0_by_mat": [m.get('h0', 0.0) for m in materials],
                "material_names": [m.get('name', f"Material {i+1}") for i, m in enumerate(materials)],
            }

        def _build_fem_data():
            materials = slope_data.get('materials', [])
            return {
                "c_by_mat": [m.get('c', 0.0) for m in materials],
                "phi_by_mat": [m.get('phi', 0.0) for m in materials],
                "E_by_mat": [m.get('E', 0.0) for m in materials],
                "nu_by_mat": [m.get('nu', 0.0) for m in materials],
                "gamma_by_mat": [m.get('gamma', 0.0) for m in materials],
                "material_names": [m.get('name', f"Material {i+1}") for i, m in enumerate(materials)],
            }

        def _estimate_table_dims():
            """Estimate table dimensions based on mode and materials."""
            materials = slope_data.get('materials', [])
            num_rows = max(1, len(materials))

            if mode == "lem":
                has_d_psi = any(mat.get('d', 0) > 0 or mat.get('psi', 0) > 0 for mat in materials)
                width = 0.25 if has_d_psi else 0.2
                height = min(0.35, 0.06 + 0.035 * num_rows)
            elif mode == "fem":
                width = 0.60
                height = min(0.32, 0.06 + 0.035 * num_rows)
            elif mode == "seep":
                width = 0.45
                height = min(0.50, 0.10 + 0.06 * num_rows)
            else:
                raise ValueError(f"Unknown mode '{mode}'. Expected one of: 'lem', 'seep', 'fem'.")

            return width, height

        def _plot_table(ax, xloc, yloc):
            """Plot the appropriate material table based on mode."""
            if mode == "lem":
                plot_lem_material_table(ax, slope_data['materials'], xloc=xloc, yloc=yloc)
            elif mode == "seep":
                plot_seep_material_table(ax, _build_seep_data(), xloc=xloc, yloc=yloc)
            elif mode == "fem":
                width, height = _estimate_table_dims()
                plot_fem_material_table(ax, _build_fem_data(), xloc=xloc, yloc=yloc, width=width, height=height)

        def _calculate_position(location, margin=0.03):
            """Calculate xloc, yloc for a given location string."""
            width, height = _estimate_table_dims()

            # Location map with default coordinates
            position_map = {
                'upper left': (margin, max(0.0, 1.0 - margin - height)),
                'upper right': (max(0.0, 1.0 - width - margin), max(0.0, 1.0 - margin - height)),
                'upper center': (0.35, 0.70),
                'lower left': (0.05, 0.05),
                'lower right': (0.70, 0.05),
                'lower center': (0.35, 0.05),
                'center left': (0.05, 0.35),
                'center right': (0.70, 0.35),
                'center': (0.35, 0.35),
                'top': ((1.0 - width) / 2.0, 1.16)
            }

            return position_map.get(location, position_map['upper right'])

        # Determine which location to use
        placement = mat_table if isinstance(mat_table, str) and mat_table != 'auto' else tab_loc

        # Validate placement
        valid_placements = ['upper left', 'upper right', 'upper center', 'lower left',
                          'lower right', 'lower center', 'center left', 'center right', 'center', 'top']
        if placement not in valid_placements:
            raise ValueError(f"Unknown placement '{placement}'. Expected one of: {', '.join(valid_placements)}.")

        # Calculate position and plot table
        xloc, yloc = _calculate_position(placement)
        _plot_table(ax, xloc, yloc)

        # Adjust y-limits to prevent table overlap with plot data
        if placement in ("upper left", "upper right", "upper center"):
            _, height = _estimate_table_dims()
            margin = 0.03
            bottom_fraction = max(0.0, 1.0 - margin - height)

            y_min_curr, y_max_curr = ax.get_ylim()
            y_range = y_max_curr - y_min_curr
            if y_range > 0:
                elem_bounds = get_plot_elements_bounds(ax, slope_data)
                if elem_bounds:
                    y_top = max(b[3] for b in elem_bounds)
                    y_norm = (y_top - y_min_curr) / y_range
                    if y_norm >= bottom_fraction and bottom_fraction > 0:
                        y_max_new = y_min_curr + (y_top - y_min_curr) / bottom_fraction
                        ax.set_ylim(y_min_curr, y_max_new)

    ax.set_aspect('equal')  # ✅ Equal aspect

    # Add a bit of headroom so plotted lines/markers don't touch the top border
    y0, y1 = ax.get_ylim()
    if y1 > y0:
        pad = 0.05 * (y1 - y0)
        ax.set_ylim(y0, y1 + pad)
    ax.set_xlabel("x")
    ax.set_ylabel("y")
    ax.grid(False)

    # Get legend handles and labels
    handles, labels = ax.get_legend_handles_labels()

    # Add distributed load to legend if present
    if slope_data['dloads']:
        handler_class, dummy_line = get_dload_legend_handler()
        handles.append(dummy_line)
        labels.append('Distributed Load')

    # --- Legend layout ---
    # Historically this legend used ncol=2 and was anchored below the axes.
    # If there are many entries, that makes the legend tall and it can fall
    # off the bottom of the window. We auto-increase columns to cap row count,
    # and we also reserve bottom margin so the legend stays visible.
    n_items = len(labels)
    if legend_ncol == "auto":
        # Choose enough columns to keep row count <= legend_max_rows (as best we can),
        # but never exceed legend_max_cols.
        required_cols = max(1, math.ceil(n_items / max(1, int(legend_max_rows))))
        ncol = min(int(legend_max_cols), required_cols)
        # Keep at least 2 columns once there's more than one entry (matches prior look).
        if n_items > 1:
            ncol = max(2, ncol)
    else:
        ncol = max(1, int(legend_ncol))

    n_rows = max(1, math.ceil(n_items / max(1, ncol)))
    # Reserve a bit more space as the legend grows so it doesn't get clipped.
    bottom_margin = min(0.45, 0.10 + 0.04 * n_rows)

    ax.legend(
        handles=handles,
        labels=labels,
        loc="upper center",
        bbox_to_anchor=(0.5, -0.12),
        ncol=ncol,
    )

    ax.set_title(title)

    plt.subplots_adjust(bottom=bottom_margin)
    plt.tight_layout()

    if save_png:
        filename = 'plot_' + title.lower().replace(' ', '_').replace(':', '').replace(',', '') + '.png'
        plt.savefig(filename, dpi=dpi, bbox_inches='tight')

    plt.show()

plot_lem_material_table(ax, materials, xloc=0.6, yloc=0.7)

Adds a limit equilibrium material properties table to the plot.

Displays soil properties for limit equilibrium analysis including unit weight (γ), cohesion (c), friction angle (φ), and optionally dilation angle (d) and dilatancy angle (ψ). Supports both Mohr-Coulomb (mc) and constant-phi (cp) options.

Parameters:
  • ax

    matplotlib Axes object to add the table to

  • materials

    List of material property dictionaries with keys: - 'name': Material name (str) - 'gamma': Unit weight (float) - 'option': Material model - 'mc' or 'cp' (str) - 'c': Cohesion for mc option (float) - 'phi': Friction angle for mc option (float) - 'cp': Constant phi for cp option (float) - 'r_elev': Reference elevation for cp option (float) - 'd': Dilation angle, optional (float) - 'psi': Dilatancy angle, optional (float)

  • xloc

    x-location of table bottom-left corner in axes coordinates (0-1, default: 0.6)

  • yloc

    y-location of table bottom-left corner in axes coordinates (0-1, default: 0.7)

Returns:
  • None

Source code in xslope/plot.py
def plot_lem_material_table(ax, materials, xloc=0.6, yloc=0.7):
    """
    Adds a limit equilibrium material properties table to the plot.

    Displays soil properties for limit equilibrium analysis including unit weight (γ),
    cohesion (c), friction angle (φ), and optionally dilation angle (d) and
    dilatancy angle (ψ). Supports both Mohr-Coulomb (mc) and constant-phi (cp) options.

    Parameters:
        ax: matplotlib Axes object to add the table to
        materials: List of material property dictionaries with keys:
            - 'name': Material name (str)
            - 'gamma': Unit weight (float)
            - 'option': Material model - 'mc' or 'cp' (str)
            - 'c': Cohesion for mc option (float)
            - 'phi': Friction angle for mc option (float)
            - 'cp': Constant phi for cp option (float)
            - 'r_elev': Reference elevation for cp option (float)
            - 'd': Dilation angle, optional (float)
            - 'psi': Dilatancy angle, optional (float)
        xloc: x-location of table bottom-left corner in axes coordinates (0-1, default: 0.6)
        yloc: y-location of table bottom-left corner in axes coordinates (0-1, default: 0.7)

    Returns:
        None
    """
    if not materials:
        return

    # Check if any materials have non-zero d and psi values
    has_d_psi = any(mat.get('d', 0) > 0 or mat.get('psi', 0) > 0 for mat in materials)

    # Check material options
    options = set(mat['option'] for mat in materials)

    # Decide column headers
    if options == {'mc'}:
        if has_d_psi:
            col_labels = ["Mat", "Name", "γ", "c", "φ", "d", "ψ"]
        else:
            col_labels = ["Mat", "Name", "γ", "c", "φ"]
    elif options == {'cp'}:
        if has_d_psi:
            col_labels = ["Mat", "Name", "γ", "cp", "rₑ", "d", "ψ"]
        else:
            col_labels = ["Mat", "Name", "γ", "cp", "rₑ"]
    else:
        if has_d_psi:
            col_labels = ["Mat", "Name", "γ", "c / cp", "φ / rₑ", "d", "ψ"]
        else:
            col_labels = ["Mat", "Name", "γ", "c / cp", "φ / rₑ"]

    # Build table rows
    table_data = []
    for idx, mat in enumerate(materials):
        name = mat['name']
        gamma = mat['gamma']
        option = mat['option']

        if option == 'mc':
            c = mat['c']
            phi = mat['phi']
            if has_d_psi:
                d = mat.get('d', 0)
                psi = mat.get('psi', 0)
                d_str = f"{d:.1f}" if d > 0 or psi > 0 else "-"
                psi_str = f"{psi:.1f}" if d > 0 or psi > 0 else "-"
                row = [idx+1, name, f"{gamma:.1f}", f"{c:.1f}", f"{phi:.1f}", d_str, psi_str]
            else:
                row = [idx+1, name, f"{gamma:.1f}", f"{c:.1f}", f"{phi:.1f}"]
        elif option == 'cp':
            cp = mat['cp']
            r_elev = mat['r_elev']
            if has_d_psi:
                d = mat.get('d', 0)
                psi = mat.get('psi', 0)
                d_str = f"{d:.1f}" if d > 0 or psi > 0 else "-"
                psi_str = f"{psi:.1f}" if d > 0 or psi > 0 else "-"
                row = [idx+1, name, f"{gamma:.1f}", f"{cp:.2f}", f"{r_elev:.1f}", d_str, psi_str]
            else:
                row = [idx+1, name, f"{gamma:.1f}", f"{cp:.2f}", f"{r_elev:.1f}"]
        else:
            if has_d_psi:
                d = mat.get('d', 0)
                psi = mat.get('psi', 0)
                d_str = f"{d:.1f}" if d > 0 or psi > 0 else "-"
                psi_str = f"{psi:.1f}" if d > 0 or psi > 0 else "-"
                row = [idx+1, name, f"{gamma:.1f}", "-", "-", d_str, psi_str]
            else:
                row = [idx+1, name, f"{gamma:.1f}", "-", "-"]
        table_data.append(row)

    # Adjust table width based on number of columns
    table_width = 0.25 if has_d_psi else 0.2

    # Choose table height based on number of materials (uniform across table types)
    num_rows = max(1, len(materials))
    table_height = 0.06 + 0.035 * num_rows  # header + per-row estimate
    table_height = min(0.35, table_height)  # cap to avoid overflows for many rows

    # Add the table
    table = ax.table(cellText=table_data,
                     colLabels=col_labels,
                     loc='upper right',
                     colLoc='center',
                     cellLoc='center',
                     bbox=[xloc, yloc, table_width, table_height])
    table.auto_set_font_size(False)
    table.set_fontsize(8)
    # Auto layout based on content (shared method for all table types)
    auto_size_table_to_content(ax, table, col_labels, table_data, table_width, table_height)

plot_max_depth(ax, profile_lines, max_depth)

Plots a horizontal line representing the maximum depth limit with hash marks.

Parameters:
  • ax

    matplotlib Axes object

  • profile_lines

    List of profile line dicts, each with 'coords' key containing coordinate tuples

  • max_depth

    Maximum allowed depth for analysis

Returns:
  • None

Source code in xslope/plot.py
def plot_max_depth(ax, profile_lines, max_depth):
    """
    Plots a horizontal line representing the maximum depth limit with hash marks.

    Parameters:
        ax: matplotlib Axes object
        profile_lines: List of profile line dicts, each with 'coords' key containing coordinate tuples
        max_depth: Maximum allowed depth for analysis

    Returns:
        None
    """
    if max_depth is None:
        return
    x_vals = [x for line in profile_lines for x, _ in line['coords']]
    x_min = min(x_vals)
    x_max = max(x_vals)
    ax.hlines(max_depth, x_min, x_max, colors='black', linewidth=1.5, label='Max Depth')

    x_diff = x_max - x_min
    spacing = x_diff / 100
    length = x_diff / 80

    angle_rad = np.radians(60)
    dx = length * np.cos(angle_rad)
    dy = length * np.sin(angle_rad)
    x_hashes = np.arange(x_min, x_max, spacing)[1:]
    for x in x_hashes:
        ax.plot([x, x - dx], [max_depth, max_depth - dy], color='black', linewidth=1)

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)

Plot the finite element mesh with material regions.

Parameters:
  • mesh

    Mesh dictionary with 'nodes', 'elements', 'element_types', and 'element_materials' keys

  • materials

    Optional list of material dictionaries for legend labels

  • figsize

    Figure size tuple

  • pad_frac

    Fraction of mesh size to use for padding around plot

  • show_nodes

    If True, plot points at node locations

  • label_elements

    If True, label each element with its number at its centroid

  • label_nodes

    If True, label each node with its number

Source code in xslope/plot.py
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):
    """
    Plot the finite element mesh with material regions.

    Parameters:
        mesh: Mesh dictionary with 'nodes', 'elements', 'element_types', and 'element_materials' keys
        materials: Optional list of material dictionaries for legend labels
        figsize: Figure size tuple
        pad_frac: Fraction of mesh size to use for padding around plot
        show_nodes: If True, plot points at node locations
        label_elements: If True, label each element with its number at its centroid
        label_nodes: If True, label each node with its number
    """
    import matplotlib.pyplot as plt
    from matplotlib.patches import Patch
    from matplotlib.collections import PolyCollection
    import numpy as np

    nodes = mesh["nodes"]
    elements = mesh["elements"]
    element_types = mesh["element_types"]
    mat_ids = mesh["element_materials"]

    fig, ax = plt.subplots(figsize=figsize)

    # Group elements by material ID
    material_elements = {}
    for i, (element, elem_type, mid) in enumerate(zip(elements, element_types, mat_ids)):
        if mid not in material_elements:
            material_elements[mid] = []

        # Only process 2D elements (skip 1D elements which have elem_type 2)
        if elem_type == 2:  # Skip 1D elements
            continue

        # Use corner nodes to define element boundary (no subdivision needed)
        if elem_type in [3, 6]:  # Triangular elements (linear or quadratic)
            element_coords = [nodes[element[0]], nodes[element[1]], nodes[element[2]]]
        elif elem_type in [4, 8, 9]:  # Quadrilateral elements (linear or quadratic)
            element_coords = [nodes[element[0]], nodes[element[1]], nodes[element[2]], nodes[element[3]]]
        else:
            continue  # Skip unknown element types

        material_elements[mid].append(element_coords)

    legend_elements = []

    # Plot 1D elements FIRST (bottom layer) if present in mesh
    if "elements_1d" in mesh and "element_types_1d" in mesh and "element_materials_1d" in mesh:
        elements_1d = mesh["elements_1d"]
        element_types_1d = mesh["element_types_1d"]
        mat_ids_1d = mesh["element_materials_1d"]

        # Group 1D elements by material ID
        material_lines = {}
        for i, (element_1d, elem_type_1d, mid_1d) in enumerate(zip(elements_1d, element_types_1d, mat_ids_1d)):
            if mid_1d not in material_lines:
                material_lines[mid_1d] = []

            # Get line coordinates based on actual number of nodes
            # elem_type_1d contains the number of nodes (2 for linear, 3 for quadratic)
            if elem_type_1d == 2:  # Linear 1D element (2 nodes)
                # Skip zero-padded elements
                if element_1d[1] != 0:  # Valid second node
                    line_coords = [nodes[element_1d[0]], nodes[element_1d[1]]]
                else:
                    continue  # Skip invalid element
            elif elem_type_1d == 3:  # Quadratic 1D element (3 nodes)
                # For visualization, connect all three nodes or just endpoints
                line_coords = [nodes[element_1d[0]], nodes[element_1d[1]], nodes[element_1d[2]]]
            else:
                continue  # Skip unknown 1D element types

            material_lines[mid_1d].append(line_coords)

        # Plot 1D elements with distinctive style
        for mid_1d, lines_list in material_lines.items():
            for line_coords in lines_list:
                xs = [coord[0] for coord in line_coords]
                ys = [coord[1] for coord in line_coords]
                ax.plot(xs, ys, color='red', linewidth=3, alpha=0.8, solid_capstyle='round')

        # Add 1D elements to legend
        if material_lines:
            legend_elements.append(plt.Line2D([0], [0], color='red', linewidth=3, 
                                            alpha=0.8, label='1D Elements'))

    # Plot 2D elements SECOND (middle layer)
    for mid, elements_list in material_elements.items():
        # Create polygon collection for this material
        poly_collection = PolyCollection(elements_list, 
                                       facecolor=get_material_color(mid),
                                       edgecolor='k',
                                       alpha=0.4,
                                       linewidth=0.5)
        ax.add_collection(poly_collection)

        # Add to legend
        if materials and mid <= len(materials) and materials[mid-1].get('name'):
            label = materials[mid-1]['name']  # Convert to 0-based indexing
        else:
            label = f'Material {mid}'

        legend_elements.append(Patch(facecolor=get_material_color(mid), 
                                   edgecolor='k', 
                                   alpha=0.4, 
                                   label=label))

    # Label 2D elements if requested
    if label_elements:
        for idx, (element, element_type) in enumerate(zip(elements, element_types)):
            # Calculate element centroid based on element type
            if element_type == 3:  # 3-node triangle
                element_coords = nodes[element[:3]]
            elif element_type == 6:  # 6-node triangle
                element_coords = nodes[element[:6]]
            elif element_type == 4:  # 4-node quad
                element_coords = nodes[element[:4]]
            elif element_type == 8:  # 8-node quad
                element_coords = nodes[element[:8]]
            elif element_type == 9:  # 9-node quad
                element_coords = nodes[element[:9]]
            else:
                continue  # Skip unknown element types

            centroid = np.mean(element_coords, axis=0)
            ax.text(centroid[0], centroid[1], str(idx+1),
                    ha='center', va='center', fontsize=6, color='black', alpha=0.7,
                    zorder=12)

    # Label 1D elements if requested (with different color)
    if label_elements and "elements_1d" in mesh:
        elements_1d = mesh["elements_1d"]
        element_types_1d = mesh["element_types_1d"]

        for idx, (element_1d, elem_type_1d) in enumerate(zip(elements_1d, element_types_1d)):
            # Skip zero-padded elements
            if elem_type_1d == 2 and element_1d[1] != 0:  # Linear 1D element
                # Calculate midpoint of line element
                coord1 = nodes[element_1d[0]]
                coord2 = nodes[element_1d[1]]
                midpoint = (coord1 + coord2) / 2
                ax.text(midpoint[0], midpoint[1], f"1D{idx+1}",
                        ha='center', va='center', fontsize=6, color='black', alpha=0.9,
                        zorder=13)
            elif elem_type_1d == 3 and element_1d[2] != 0:  # Quadratic 1D element
                # Use middle node as label position (if it exists)
                midpoint = nodes[element_1d[1]]
                ax.text(midpoint[0], midpoint[1], f"1D{idx+1}",
                        ha='center', va='center', fontsize=6, color='black', alpha=0.9,
                        zorder=13)

    # Plot nodes LAST (top layer) if requested
    if show_nodes:
        # Plot all nodes - if meshing is correct, all nodes should be used
        ax.plot(nodes[:, 0], nodes[:, 1], 'k.', markersize=2)
        # Add to legend
        legend_elements.append(plt.Line2D([0], [0], marker='o', color='w', 
                                        markerfacecolor='k', markersize=6, 
                                        label=f'Nodes ({len(nodes)})', linestyle='None'))

    # Label nodes if requested
    if label_nodes:
        # Label all nodes
        for i, (x, y) in enumerate(nodes):
            ax.text(x + 0.5, y + 0.5, str(i+1), fontsize=6, color='blue', alpha=0.7,
                    ha='left', va='bottom', zorder=14)

    ax.set_aspect('equal')
    ax.set_title("Finite Element Mesh with Material Regions (Triangles and Quads)")

    # Add legend if we have materials
    if legend_elements:
        ax.legend(handles=legend_elements, loc='upper center', bbox_to_anchor=(0.5, -0.05), ncol=min(len(legend_elements), 4))

    # Add cushion
    x_min, x_max = nodes[:, 0].min(), nodes[:, 0].max()
    y_min, y_max = nodes[:, 1].min(), nodes[:, 1].max()
    x_pad = (x_max - x_min) * pad_frac
    y_pad = (y_max - y_min) * pad_frac
    ax.set_xlim(x_min - x_pad, x_max + x_pad)
    ax.set_ylim(y_min - y_pad, y_max + y_pad)

    # Add extra cushion for legend space
    ax.set_ylim(y_min - y_pad, y_max + y_pad)

    plt.tight_layout()

    if save_png:
        filename = 'plot_mesh.png'
        plt.savefig(filename, dpi=dpi, bbox_inches='tight')

    plt.show()

plot_non_circ(ax, non_circ)

Plots a non-circular failure surface.

Parameters:
  • ax

    matplotlib Axes object

  • non_circ

    List of coordinates representing the non-circular failure surface

Returns:
  • None

Source code in xslope/plot.py
def plot_non_circ(ax, non_circ):
    """
    Plots a non-circular failure surface.

    Parameters:
        ax: matplotlib Axes object
        non_circ: List of coordinates representing the non-circular failure surface

    Returns:
        None
    """
    if not non_circ or len(non_circ) == 0:
        return
    # Handle both dict format {'X': x, 'Y': y} and tuple format (x, y)
    if isinstance(non_circ[0], dict):
        xs = [p['X'] for p in non_circ]
        ys = [p['Y'] for p in non_circ]
    else:
        xs, ys = zip(*non_circ)
    ax.plot(xs, ys, 'r--', label='Non-Circular Surface')

plot_noncircular_search_results(slope_data, fs_cache, search_path=None, highlight_fs=True, figsize=(12, 7), save_png=False, dpi=300)

Creates a plot showing the results of a non-circular failure surface search.

Parameters:
  • slope_data

    Dictionary containing plot data

  • fs_cache

    List of dictionaries containing failure surface data and FS values

  • search_path

    List of dictionaries containing search path coordinates

  • highlight_fs

    Boolean indicating whether to highlight the critical failure surface

  • figsize

    Tuple of (width, height) in inches for the plot

Returns:
  • None

Source code in xslope/plot.py
def plot_noncircular_search_results(slope_data, fs_cache, search_path=None, highlight_fs=True, figsize=(12, 7), save_png=False, dpi=300):
    """
    Creates a plot showing the results of a non-circular failure surface search.

    Parameters:
        slope_data: Dictionary containing plot data
        fs_cache: List of dictionaries containing failure surface data and FS values
        search_path: List of dictionaries containing search path coordinates
        highlight_fs: Boolean indicating whether to highlight the critical failure surface
        figsize: Tuple of (width, height) in inches for the plot

    Returns:
        None
    """
    fig, ax = plt.subplots(figsize=figsize)

    # Plot basic profile elements
    plot_profile_lines(ax, slope_data['profile_lines'], materials=slope_data.get('materials'))
    plot_max_depth(ax, slope_data['profile_lines'], slope_data['max_depth'])
    if any(m.get('u') == 'piezo' for m in slope_data.get('materials', [])):
        plot_piezo_line(ax, slope_data)
    plot_dloads(ax, slope_data)
    plot_tcrack_surface(ax, slope_data)

    # Plot all failure surfaces from cache
    first_tested = True
    for i, result in reversed(list(enumerate(fs_cache))):
        surface = result['failure_surface']
        if surface is None or surface.is_empty:
            continue
        x, y = zip(*surface.coords)
        if i == 0:
            # Critical surface
            ax.plot(x, y, color='red', linestyle='-', linewidth=2, alpha=1.0, label='Critical Surface')
        else:
            # Tested surfaces
            label = 'Tested Surface' if first_tested else None
            ax.plot(x, y, color='gray', linestyle='-', linewidth=1, alpha=0.6, label=label)
            first_tested = False

    # Plot search path if provided
    if search_path:
        for i in range(len(search_path) - 1):
            start = search_path[i]
            end = search_path[i + 1]
            # For non-circular search, we need to plot the movement of each point
            start_points = np.array(start['points'])
            end_points = np.array(end['points'])

            # Plot arrows for each moving point
            for j in range(len(start_points)):
                dx = end_points[j, 0] - start_points[j, 0]
                dy = end_points[j, 1] - start_points[j, 1]
                if abs(dx) > 1e-6 or abs(dy) > 1e-6:  # Only plot if point moved
                    ax.arrow(start_points[j, 0], start_points[j, 1], dx, dy,
                            head_width=1, head_length=2, fc='green', ec='green',
                            length_includes_head=True, alpha=0.6)

    ax.set_aspect('equal')
    ax.set_xlabel("x")
    ax.set_ylabel("y")
    ax.grid(False)
    ax.legend(loc='upper center', bbox_to_anchor=(0.5, -0.15), ncol=4)

    if highlight_fs and fs_cache:
        critical_fs = fs_cache[0]['FS']
        ax.set_title(f"Critical Factor of Safety = {critical_fs:.3f}")

    plt.tight_layout()

    if save_png:
        filename = 'plot_noncircular_search_results.png'
        plt.savefig(filename, dpi=dpi, bbox_inches='tight')

    plt.show()

plot_piezo_line(ax, slope_data)

Plots the piezometric line(s) with markers at their midpoints.

Parameters:
  • ax

    matplotlib Axes object

  • data

    Dictionary containing plot data with 'piezo_line' and optionally 'piezo_line2'

Returns:
  • None

Source code in xslope/plot.py
def plot_piezo_line(ax, slope_data):
    """
    Plots the piezometric line(s) with markers at their midpoints.

    Parameters:
        ax: matplotlib Axes object
        data: Dictionary containing plot data with 'piezo_line' and optionally 'piezo_line2'

    Returns:
        None
    """

    def _plot_touching_v_marker(ax, x, y, color, markersize=8, extra_gap_points=0.0):
        """
        Place an inverted triangle marker so its tip visually touches the line at (x, y).
        We do this in display coordinates (points/pixels) so it scales consistently.
        """
        from matplotlib.markers import MarkerStyle
        from matplotlib.transforms import offset_copy

        # Compute the distance (in "marker units") from the marker origin to the tip.
        # Matplotlib scales marker vertices by `markersize` (in points) for Line2D.
        ms = MarkerStyle("v")
        path = ms.get_path().transformed(ms.get_transform())
        verts = np.asarray(path.vertices)
        min_y = float(verts[:, 1].min())  # tip is the lowest y
        tip_offset_points = (-min_y) * float(markersize) + float(extra_gap_points)

        # Offset the marker center upward in point units so the tip lands at (x, y).
        trans = offset_copy(ax.transData, fig=ax.figure, x=0.0, y=tip_offset_points, units="points")
        ax.plot([x], [y], marker="v", color=color, markersize=markersize, linestyle="None", transform=trans)

    def plot_single_piezo_line(ax, piezo_line, color, label):
        """Internal function to plot a single piezometric line"""
        if not piezo_line:
            return

        piezo_xs, piezo_ys = zip(*piezo_line)
        ax.plot(piezo_xs, piezo_ys, color=color, linewidth=2, label=label)

        # Find middle x-coordinate and corresponding y value
        if len(piezo_xs) > 1:
            # Sort by x to ensure monotonic input for interpolation
            pairs = sorted(zip(piezo_xs, piezo_ys), key=lambda p: p[0])
            sx, sy = zip(*pairs)
            x_min, x_max = min(sx), max(sx)
            mid_x = (x_min + x_max) / 2
            mid_y = float(np.interp(mid_x, sx, sy))
            # Slight negative gap so the marker visually "touches" the line (not floating above it)
            _plot_touching_v_marker(ax, mid_x, mid_y, color=color, markersize=8, extra_gap_points=2.0)

    # Plot both piezometric lines
    plot_single_piezo_line(ax, slope_data.get('piezo_line'), 'b', "Piezometric Line")
    plot_single_piezo_line(ax, slope_data.get('piezo_line2'), 'skyblue', "Piezometric Line 2")

plot_piles(ax, slope_data, slice_df=None)

Plots pile lines from slope_data and optionally marks failure surface intersections.

Parameters:
  • ax

    matplotlib Axes object

  • slope_data

    Dictionary containing slope data with 'pile_lines' key

  • slice_df

    Optional DataFrame — if provided, marks pile-failure surface intersection points

Source code in xslope/plot.py
def plot_piles(ax, slope_data, slice_df=None):
    """
    Plots pile lines from slope_data and optionally marks failure surface intersections.

    Parameters:
        ax: matplotlib Axes object
        slope_data: Dictionary containing slope data with 'pile_lines' key
        slice_df: Optional DataFrame — if provided, marks pile-failure surface intersection points
    """
    if 'pile_lines' not in slope_data or not slope_data['pile_lines']:
        return

    for i, pile in enumerate(slope_data['pile_lines']):
        xs = [pile['x1'], pile['x2']]
        ys = [pile['y1'], pile['y2']]
        ax.plot(xs, ys, color='green', linewidth=4, linestyle='-',
                alpha=0.9, solid_capstyle='butt',
                label='Pile' if i == 0 else "")
        # Annotate with H value
        if pile.get('H') is not None:
            mid_x = (pile['x1'] + pile['x2']) / 2
            mid_y = (pile['y1'] + pile['y2']) / 2
            ax.annotate(f"H={pile['H']:.0f}", (mid_x, mid_y),
                        textcoords="offset points", xytext=(8, 0),
                        fontsize=8, color='green', fontweight='bold')

    # Mark failure surface intersection points from slice_df
    if slice_df is not None and 'h_pile' in slice_df.columns:
        pile_slices = slice_df[slice_df['h_pile'] > 0]
        if not pile_slices.empty:
            ax.scatter(pile_slices['x_pile'], pile_slices['y_pile'],
                       marker='o', s=40, color='red', zorder=6,
                       label='Pile-Surface Intersection')

plot_polygons(polygons, materials=None, nodes=False, legend=True, title='Material Zone Polygons', figsize=(10, 6), save_png=False, dpi=300)

Plot all material zone polygons in a single figure.

Parameters:
  • polygons

    List of polygon coordinate lists or dicts with "coords"/"mat_id"

  • materials

    Optional list of material dicts (with key "name") or list of material name strings. If provided, the material name will be used in the legend.

  • nodes

    If True, plot each polygon vertex as a dot.

  • legend

    If True, show the legend.

  • title

    Plot title

  • figsize

    Matplotlib figure size tuple, e.g. (10, 6)

Source code in xslope/plot.py
def plot_polygons(
    polygons,
    materials=None,
    nodes=False,
    legend=True,
    title="Material Zone Polygons",
    figsize=(10, 6),
    save_png=False,
    dpi=300,
):
    """
    Plot all material zone polygons in a single figure.

    Parameters:
        polygons: List of polygon coordinate lists or dicts with "coords"/"mat_id"
        materials: Optional list of material dicts (with key "name") or list of material
            name strings. If provided, the material name will be used in the legend.
        nodes: If True, plot each polygon vertex as a dot.
        legend: If True, show the legend.
        title: Plot title
        figsize: Matplotlib figure size tuple, e.g. (10, 6)
    """
    import matplotlib.pyplot as plt

    fig, ax = plt.subplots(figsize=figsize)
    for i, polygon in enumerate(polygons):
        coords = polygon.get("coords", []) if isinstance(polygon, dict) else polygon
        xs = [x for x, y in coords]
        ys = [y for x, y in coords]
        mat_idx = polygon.get("mat_id") if isinstance(polygon, dict) else i
        if mat_idx is None:
            mat_idx = i
        mat_name = None
        if materials is not None and 0 <= mat_idx < len(materials):
            item = materials[mat_idx]
            if isinstance(item, dict):
                mat_name = item.get("name", None)
            elif isinstance(item, str):
                mat_name = item
        label = mat_name if mat_name else f"Material {mat_idx}"
        ax.fill(xs, ys, color=get_material_color(mat_idx), alpha=0.6, label=label)
        ax.plot(xs, ys, color=get_material_color(mat_idx), linewidth=1)
        if nodes:
            # Avoid legend clutter by not adding a label here.
            ax.scatter(xs, ys, color='k', s=30, marker='o', zorder=3)
    ax.set_xlabel('X Coordinate')
    ax.set_ylabel('Y Coordinate')
    ax.set_title(title)
    if legend:
        ax.legend()
    ax.grid(True, alpha=0.3)
    ax.set_aspect('equal')
    plt.tight_layout()

    if save_png:
        filename = 'plot_' + title.lower().replace(' ', '_').replace(':', '').replace(',', '') + '.png'
        plt.savefig(filename, dpi=dpi, bbox_inches='tight')

    plt.show()

plot_polygons_separately(polygons, materials=None, save_png=False, dpi=300)

Plot each polygon in a separate matplotlib frame (subplot), with vertices as round dots.

Parameters:
  • polygons

    List of polygon coordinate lists or dicts with "coords"/"mat_id"

  • materials

    Optional list of material dicts (with key "name") or list of material name strings. If provided, the material name will be included in each subplot title.

Source code in xslope/plot.py
def plot_polygons_separately(polygons, materials=None, save_png=False, dpi=300):
    """
    Plot each polygon in a separate matplotlib frame (subplot), with vertices as round dots.

    Parameters:
        polygons: List of polygon coordinate lists or dicts with "coords"/"mat_id"
        materials: Optional list of material dicts (with key "name") or list of material
            name strings. If provided, the material name will be included in each subplot title.
    """
    import matplotlib.pyplot as plt

    n = len(polygons)
    fig, axes = plt.subplots(n, 1, figsize=(8, 3 * n), squeeze=False)
    for i, polygon in enumerate(polygons):
        coords = polygon.get("coords", []) if isinstance(polygon, dict) else polygon
        xs = [x for x, y in coords]
        ys = [y for x, y in coords]
        ax = axes[i, 0]
        mat_idx = polygon.get("mat_id") if isinstance(polygon, dict) else i
        if mat_idx is None:
            mat_idx = i
        ax.fill(xs, ys, color=get_material_color(mat_idx), alpha=0.6, label=f'Material {mat_idx}')
        ax.plot(xs, ys, color=get_material_color(mat_idx), linewidth=1)
        ax.scatter(xs, ys, color='k', s=30, marker='o', zorder=3, label='Vertices')
        ax.set_xlabel('X Coordinate')
        ax.set_ylabel('Y Coordinate')
        mat_name = None
        if materials is not None and 0 <= mat_idx < len(materials):
            item = materials[mat_idx]
            if isinstance(item, dict):
                mat_name = item.get("name", None)
            elif isinstance(item, str):
                mat_name = item
        if mat_name:
            ax.set_title(f'Material {mat_idx}: {mat_name}')
        else:
            ax.set_title(f'Material {mat_idx}')
        ax.grid(True, alpha=0.3)
        ax.set_aspect('equal')
        # Intentionally no legend: these plots are typically used for debugging geometry,
        # and legends can obscure key vertices/edges.
    plt.tight_layout()

    if save_png:
        filename = 'plot_polygons_separately.png'
        plt.savefig(filename, dpi=dpi, bbox_inches='tight')

    plt.show()

plot_profile_lines(ax, profile_lines, materials=None, labels=False)

Plots the profile lines for each material in the slope.

Parameters:
  • ax

    matplotlib Axes object

  • profile_lines

    List of profile line dicts, each with 'coords' and 'mat_id' keys

  • materials

    List of material dictionaries (optional, for color mapping)

  • labels

    If True, add index labels to each profile line (default: False)

Returns:
  • None

Source code in xslope/plot.py
def plot_profile_lines(ax, profile_lines, materials=None, labels=False):
    """
    Plots the profile lines for each material in the slope.

    Parameters:
        ax: matplotlib Axes object
        profile_lines: List of profile line dicts, each with 'coords' and 'mat_id' keys
        materials: List of material dictionaries (optional, for color mapping)
        labels: If True, add index labels to each profile line (default: False)

    Returns:
        None
    """
    for i, line in enumerate(profile_lines):
        coords = line['coords']
        xs, ys = zip(*coords)

        # Get material index from mat_id (already 0-based)
        if materials and line.get('mat_id') is not None:
            mat_idx = line['mat_id']
            if 0 <= mat_idx < len(materials):
                color = get_material_color(mat_idx)
            else:
                # Fallback to index-based color if mat_id out of range
                color = get_material_color(i)
        else:
            # Fallback to index-based color if no materials or mat_id
            color = get_material_color(i)

        ax.plot(xs, ys, color=color, linewidth=1, label=f'Profile {i+1}')

        if labels:
            _add_profile_index_label(ax, coords, i + 1, color)

plot_reinforcement_lines(ax, slope_data)

Plots the reinforcement lines from slope_data.

Parameters:
  • ax

    matplotlib Axes object

  • slope_data

    Dictionary containing slope data with 'reinforce_lines' key

Returns:
  • None

Source code in xslope/plot.py
def plot_reinforcement_lines(ax, slope_data):
    """
    Plots the reinforcement lines from slope_data.

    Parameters:
        ax: matplotlib Axes object
        slope_data: Dictionary containing slope data with 'reinforce_lines' key

    Returns:
        None
    """
    if 'reinforce_lines' not in slope_data or not slope_data['reinforce_lines']:
        return

    tension_points_plotted = False  # Track if tension points have been added to legend

    for i, line in enumerate(slope_data['reinforce_lines']):
        # Extract x and y coordinates from the line points
        xs = [point['X'] for point in line]
        ys = [point['Y'] for point in line]

        # Plot the reinforcement line with a distinctive style
        ax.plot(xs, ys, color='darkgray', linewidth=3, linestyle='-', 
                alpha=0.8, label='Reinforcement Line' if i == 0 else "")

        # Add markers at each point to show tension values
        for j, point in enumerate(line):
            tension = point.get('T', 0.0)
            if tension > 0:
                # Use smaller marker size proportional to tension (normalized)
                max_tension = max(p.get('T', 0.0) for p in line)
                marker_size = 10 + 15 * (tension / max_tension) if max_tension > 0 else 10
                ax.scatter(point['X'], point['Y'], s=marker_size, 
                          color='red', alpha=0.7, zorder=5,
                          label='Tension Points' if not tension_points_plotted else "")
                tension_points_plotted = True

plot_reliability_results(slope_data, reliability_data, figsize=(12, 7), save_png=False, dpi=300)

Creates a plot showing the results of reliability analysis.

Parameters:
  • slope_data

    Dictionary containing plot data

  • reliability_data

    Dictionary containing reliability analysis results

  • figsize

    Tuple of (width, height) in inches for the plot

Returns:
  • None

Source code in xslope/plot.py
def plot_reliability_results(slope_data, reliability_data, figsize=(12, 7), save_png=False, dpi=300):
    """
    Creates a plot showing the results of reliability analysis.

    Parameters:
        slope_data: Dictionary containing plot data
        reliability_data: Dictionary containing reliability analysis results
        figsize: Tuple of (width, height) in inches for the plot

    Returns:
        None
    """
    fig, ax = plt.subplots(figsize=figsize)

    # Plot basic slope elements (same as other search functions)
    plot_profile_lines(ax, slope_data['profile_lines'], materials=slope_data.get('materials'))
    plot_max_depth(ax, slope_data['profile_lines'], slope_data['max_depth'])
    if any(m.get('u') == 'piezo' for m in slope_data.get('materials', [])):
        plot_piezo_line(ax, slope_data)
    plot_dloads(ax, slope_data)
    plot_tcrack_surface(ax, slope_data)

    # Plot reliability-specific failure surfaces
    fs_cache = reliability_data['fs_cache']

    # Plot all failure surfaces
    added_plus_legend = False
    added_minus_legend = False
    for i, fs_data in enumerate(fs_cache):
        result = fs_data['result']
        name = fs_data['name']
        failure_surface = result['failure_surface']

        # Convert failure surface to coordinates
        if hasattr(failure_surface, 'coords'):
            coords = list(failure_surface.coords)
        else:
            coords = failure_surface

        x_coords = [pt[0] for pt in coords]
        y_coords = [pt[1] for pt in coords]

        # Color and styling based on surface type
        if name == "MLV":
            # Highlight the MLV (critical) surface in red
            ax.plot(x_coords, y_coords, color='red', linewidth=3,
                   label=f'$F_{{MLV}}$ Surface (FS={result["FS"]:.3f})', zorder=10)
        else:
            # Other surfaces in different colors
            if '+' in name:
                color = 'blue'
                alpha = 0.7
                label = '$F^+$ surfaces' if not added_plus_legend else None
                added_plus_legend = True
            else:  # '-' in name
                color = 'green'
                alpha = 0.7
                label = '$F^-$ surfaces' if not added_minus_legend else None
                added_minus_legend = True

            ax.plot(x_coords, y_coords, color=color, linewidth=1.5,
                   alpha=alpha, label=label, zorder=5)



    # Standard finalization
    ax.set_aspect('equal')
    ax.set_xlabel("x")
    ax.set_ylabel("y")
    ax.grid(False)
    ax.legend(bbox_to_anchor=(1.05, 1), loc='upper left')

    # Title with reliability statistics using mathtext
    F_MLV = reliability_data['F_MLV']
    sigma_F = reliability_data['sigma_F']
    COV_F = reliability_data['COV_F']
    reliability = reliability_data['reliability']
    prob_failure = reliability_data['prob_failure']

    ax.set_title(f"Reliability Analysis Results\n"
                f"$F_{{MLV}}$ = {F_MLV:.3f}, $\\sigma_F$ = {sigma_F:.3f}, "
                f"$COV_F$ = {COV_F:.3f}\n"
                f"Reliability = {reliability*100:.2f}%, $P_f$ = {prob_failure*100:.2f}%")

    plt.tight_layout()

    if save_png:
        filename = 'plot_reliability_results.png'
        plt.savefig(filename, dpi=dpi, bbox_inches='tight')

    plt.show()

plot_search_path(ax, search_path)

Plots the search path used to find the critical failure surface.

Parameters:
  • ax

    matplotlib Axes object

  • search_path

    List of dictionaries containing search path coordinates

Returns:
  • None

Source code in xslope/plot.py
def plot_search_path(ax, search_path):
    """
    Plots the search path used to find the critical failure surface.

    Parameters:
        ax: matplotlib Axes object
        search_path: List of dictionaries containing search path coordinates

    Returns:
        None
    """
    if len(search_path) < 2:
        return  # need at least two points to draw an arrow

    for i in range(len(search_path) - 1):
        start = search_path[i]
        end = search_path[i + 1]
        dx = end['x'] - start['x']
        dy = end['y'] - start['y']
        ax.arrow(start['x'], start['y'], dx, dy,
                 head_width=1, head_length=2, fc='green', ec='green', length_includes_head=True)

plot_seep_material_table(ax, seep_data, xloc=0.6, yloc=0.7)

Adds a seep material properties table to the plot.

Displays hydraulic properties for seep analysis including hydraulic conductivities (k₁, k₂), anisotropy angle, and unsaturated flow parameters (kr₀, h₀).

Parameters:
  • ax

    matplotlib Axes object to add the table to

  • seep_data

    Dictionary containing seep material properties with keys: - 'k1_by_mat': List of primary hydraulic conductivity values (float) - 'k2_by_mat': List of secondary hydraulic conductivity values (float) - 'angle_by_mat': List of anisotropy angles in degrees (float) - 'kr0_by_mat': List of relative permeability at residual saturation (float) - 'h0_by_mat': List of pressure head parameters (float) - 'material_names': List of material names (str), optional

  • xloc

    x-location of table bottom-left corner in axes coordinates (0-1, default: 0.6)

  • yloc

    y-location of table bottom-left corner in axes coordinates (0-1, default: 0.7)

Returns:
  • None

Source code in xslope/plot.py
def plot_seep_material_table(ax, seep_data, xloc=0.6, yloc=0.7):
    """
    Adds a seep material properties table to the plot.

    Displays hydraulic properties for seep analysis including hydraulic conductivities
    (k₁, k₂), anisotropy angle, and unsaturated flow parameters (kr₀, h₀).

    Parameters:
        ax: matplotlib Axes object to add the table to
        seep_data: Dictionary containing seep material properties with keys:
            - 'k1_by_mat': List of primary hydraulic conductivity values (float)
            - 'k2_by_mat': List of secondary hydraulic conductivity values (float)
            - 'angle_by_mat': List of anisotropy angles in degrees (float)
            - 'kr0_by_mat': List of relative permeability at residual saturation (float)
            - 'h0_by_mat': List of pressure head parameters (float)
            - 'material_names': List of material names (str), optional
        xloc: x-location of table bottom-left corner in axes coordinates (0-1, default: 0.6)
        yloc: y-location of table bottom-left corner in axes coordinates (0-1, default: 0.7)

    Returns:
        None
    """
    k1_by_mat = seep_data.get("k1_by_mat")
    k2_by_mat = seep_data.get("k2_by_mat")
    angle_by_mat = seep_data.get("angle_by_mat")
    kr0_by_mat = seep_data.get("kr0_by_mat")
    h0_by_mat = seep_data.get("h0_by_mat")
    material_names = seep_data.get("material_names", [])
    if k1_by_mat is None or len(k1_by_mat) == 0:
        return
    col_labels = ["Mat", "Name", "k₁", "k₂", "Angle", "kr₀", "h₀"]
    table_data = []
    for idx in range(len(k1_by_mat)):
        k1 = k1_by_mat[idx]
        k2 = k2_by_mat[idx] if k2_by_mat is not None else 0.0
        angle = angle_by_mat[idx] if angle_by_mat is not None else 0.0
        kr0 = kr0_by_mat[idx] if kr0_by_mat is not None else 0.0
        h0 = h0_by_mat[idx] if h0_by_mat is not None else 0.0
        material_name = material_names[idx] if idx < len(material_names) else f"Material {idx+1}"
        row = [idx + 1, material_name, f"{k1:.3f}", f"{k2:.3f}", f"{angle:.1f}", f"{kr0:.4f}", f"{h0:.2f}"]
        table_data.append(row)
    # Dimensions
    num_rows = max(1, len(k1_by_mat))
    table_width = 0.45
    table_height = 0.10 + 0.06 * num_rows
    table_height = min(0.50, table_height)
    table = ax.table(cellText=table_data,
                     colLabels=col_labels,
                     loc='upper right',
                     colLoc='center',
                     cellLoc='center',
                     bbox=[xloc, yloc, table_width, table_height])
    table.auto_set_font_size(False)
    table.set_fontsize(8)
    auto_size_table_to_content(ax, table, col_labels, table_data, table_width, table_height)

plot_seepage_bc_lines(ax, slope_data)

Plots seep boundary-condition lines for seep-only workflows.

Plots the primary seepage BCs (seepage_bc) and, if present, the second set (seepage_bc2) with different colors to distinguish them.

Source code in xslope/plot.py
def plot_seepage_bc_lines(ax, slope_data):
    """
    Plots seep boundary-condition lines for seep-only workflows.

    Plots the primary seepage BCs (seepage_bc) and, if present, the second set
    (seepage_bc2) with different colors to distinguish them.
    """
    def _plot_touching_v_marker(ax, x, y, color, markersize=8, extra_gap_points=2.0):
        """Place an inverted triangle so its tip visually sits on the line at (x, y)."""
        from matplotlib.markers import MarkerStyle
        from matplotlib.transforms import offset_copy

        ms = MarkerStyle("v")
        path = ms.get_path().transformed(ms.get_transform())
        verts = np.asarray(path.vertices)
        min_y = float(verts[:, 1].min())
        tip_offset_points = (-min_y) * float(markersize) + float(extra_gap_points)
        trans = offset_copy(ax.transData, fig=ax.figure, x=0.0, y=tip_offset_points, units="points")
        ax.plot([x], [y], marker="v", color=color, markersize=markersize, linestyle="None", transform=trans)

    def _plot_one_bc_set(ax, seepage_bc, geom_width, x_min_geom, x_max_geom,
                         head_line_color, water_level_color, exit_face_color, label_suffix=""):
        """Plot a single set of seepage boundary conditions."""
        specified_heads = seepage_bc.get("specified_heads") or []
        exit_face = seepage_bc.get("exit_face") or []

        for i, sh in enumerate(specified_heads):
            coords = sh.get("coords") or []
            if len(coords) < 2:
                continue

            xs, ys = zip(*coords)
            ax.plot(
                xs, ys,
                color=head_line_color, linewidth=3, linestyle="--",
                label=f"Specified Head Line{label_suffix}" if i == 0 else "",
            )

            head_val = sh.get("head", None)
            if head_val is None:
                continue

            if isinstance(head_val, (list, tuple, np.ndarray)):
                if len(head_val) != len(coords):
                    continue
                heads = [float(h) for h in head_val]
            else:
                try:
                    head_scalar = float(head_val)
                except (TypeError, ValueError):
                    continue
                heads = [head_scalar] * len(coords)

            tol = 1e-6
            is_vertical = (max(xs) - min(xs)) <= tol
            if is_vertical:
                x0 = float(xs[0])
                y_head = float(heads[0])
                seg_len = 0.04 * geom_width
                gap = 0.01 * geom_width
                is_right = x0 >= 0.5 * (x_min_geom + x_max_geom)
                if is_right:
                    wl_xs = [x0 + gap, x0 + gap + seg_len]
                else:
                    wl_xs = [x0 - gap - seg_len, x0 - gap]
                wl_ys = [y_head, y_head]
            else:
                wl_xs = list(xs)
                wl_ys = heads

            ax.plot(
                wl_xs, wl_ys,
                color=water_level_color, linewidth=2, linestyle="-",
                label=f"Specified Head Water Level{label_suffix}" if i == 0 else "",
            )

            if len(wl_xs) > 1:
                try:
                    pairs = sorted(zip(wl_xs, wl_ys), key=lambda p: p[0])
                    sx, sy = zip(*pairs)
                    mid_x = 0.5 * (min(sx) + max(sx))
                    mid_y = float(np.interp(mid_x, sx, sy))
                    _plot_touching_v_marker(ax, mid_x, mid_y, color=water_level_color, markersize=8, extra_gap_points=2.0)
                except Exception:
                    pass

        if len(exit_face) >= 2:
            ex_xs, ex_ys = zip(*exit_face)
            ax.plot(
                ex_xs, ex_ys,
                color=exit_face_color, linewidth=3, linestyle="--",
                label=f"Exit Face{label_suffix}",
            )

    # Geometry x-extent (used for vertical-head-line derived segment length / side)
    x_vals = []
    for line in slope_data.get("profile_lines", []):
        try:
            xs_line, _ = zip(*line['coords'])
            x_vals.extend(xs_line)
        except Exception:
            pass
    gs = slope_data.get("ground_surface", None)
    if not x_vals and gs is not None and hasattr(gs, "coords"):
        x_vals.extend([x for x, _ in gs.coords])
    x_min_geom = min(x_vals) if x_vals else 0.0
    x_max_geom = max(x_vals) if x_vals else 1.0
    geom_width = max(1e-9, x_max_geom - x_min_geom)

    # Plot primary BCs
    seepage_bc = slope_data.get("seepage_bc") or {}
    has_bc2 = slope_data.get("has_seepage_bc2", False)
    label_suffix = " (BC 1)" if has_bc2 else ""
    _plot_one_bc_set(ax, seepage_bc, geom_width, x_min_geom, x_max_geom,
                     head_line_color="darkblue", water_level_color="lightskyblue",
                     exit_face_color="red", label_suffix=label_suffix)

    # Plot second set of BCs if present
    if has_bc2:
        seepage_bc2 = slope_data.get("seepage_bc2") or {}
        _plot_one_bc_set(ax, seepage_bc2, geom_width, x_min_geom, x_max_geom,
                         head_line_color="steelblue", water_level_color="powderblue",
                         exit_face_color="orangered", label_suffix=" (BC 2)")

plot_slice_numbers(ax, slice_df)

Plots the slice number in the middle of each slice at the middle height. Numbers are 1-indexed.

Parameters:
  • ax

    matplotlib Axes object

  • slice_df

    DataFrame containing slice data

Returns:
  • None

Source code in xslope/plot.py
def plot_slice_numbers(ax, slice_df):
    """
    Plots the slice number in the middle of each slice at the middle height.
    Numbers are 1-indexed.

    Parameters:
        ax: matplotlib Axes object
        slice_df: DataFrame containing slice data

    Returns:
        None
    """
    if slice_df is not None:
        for _, row in slice_df.iterrows():
            # Calculate middle x-coordinate of the slice
            x_middle = row['x_c']

            # Calculate middle height of the slice
            y_middle = (row['y_cb'] + row['y_ct']) / 2

            # Plot the slice number (1-indexed)
            slice_number = int(row['slice #'])
            ax.text(x_middle, y_middle, str(slice_number), 
                   ha='center', va='center', fontsize=8, fontweight='bold',
                   bbox=dict(boxstyle="round,pad=0.2", facecolor='white', alpha=0.8))

plot_slices(ax, slice_df, fill=True)

Plots the slices used in the analysis.

Parameters:
  • ax

    matplotlib Axes object

  • slice_df

    DataFrame containing slice data

  • fill

    Boolean indicating whether to fill the slices with color

Returns:
  • None

Source code in xslope/plot.py
def plot_slices(ax, slice_df, fill=True):
    """
    Plots the slices used in the analysis.

    Parameters:
        ax: matplotlib Axes object
        slice_df: DataFrame containing slice data
        fill: Boolean indicating whether to fill the slices with color

    Returns:
        None
    """
    if slice_df is not None:
        for _, row in slice_df.iterrows():
            if fill:
                xs = [row['x_l'], row['x_l'], row['x_r'], row['x_r'], row['x_l']]
                ys = [row['y_lb'], row['y_lt'], row['y_rt'], row['y_rb'], row['y_lb']]
                ax.plot(xs, ys, 'r-')
                ax.fill(xs, ys, color='red', alpha=0.1)
            else:
                ax.plot([row['x_l'], row['x_l']], [row['y_lb'], row['y_lt']], 'k-', linewidth=0.5)
                ax.plot([row['x_r'], row['x_r']], [row['y_rb'], row['y_rt']], 'k-', linewidth=0.5)

plot_solution(slope_data, slice_df, failure_surface, results, figsize=(12, 7), slice_numbers=False, seep_contours=True, save_png=False, dpi=300)

Plots the full solution including slices, numbers, thrust line, and base stresses.

Parameters:
  • data

    Input data

  • slice_df

    DataFrame containing slice data

  • failure_surface

    Failure surface geometry

  • results

    Solution results

  • figsize

    Tuple of (width, height) in inches for the plot

Returns:
  • None

Source code in xslope/plot.py
def plot_solution(slope_data, slice_df, failure_surface, results, figsize=(12, 7), slice_numbers=False, seep_contours=True, save_png=False, dpi=300):
    """
    Plots the full solution including slices, numbers, thrust line, and base stresses.

    Parameters:
        data: Input data
        slice_df: DataFrame containing slice data
        failure_surface: Failure surface geometry
        results: Solution results
        figsize: Tuple of (width, height) in inches for the plot

    Returns:
        None
    """
    fig, ax = plt.subplots(figsize=figsize)
    ax.set_xlabel("x")
    ax.set_ylabel("y")
    ax.grid(False)

    plot_profile_lines(ax, slope_data['profile_lines'], materials=slope_data.get('materials'))
    plot_max_depth(ax, slope_data['profile_lines'], slope_data['max_depth'])
    plot_slices(ax, slice_df, fill=False)
    plot_failure_surface(ax, failure_surface)
    if any(m.get('u') == 'piezo' for m in slope_data.get('materials', [])):
        plot_piezo_line(ax, slope_data)

    # Seep overlays: head contours and phreatic surface when any material uses seep
    has_seep = any(m.get('u') == 'seep' for m in slope_data.get('materials', []))
    mesh = slope_data.get('mesh')
    seep_u = slope_data.get('seep_u')
    if seep_contours and has_seep and mesh is not None and seep_u is not None:
        import matplotlib.tri as mtri
        m_nodes = mesh['nodes']
        m_elements = mesh['elements']
        m_etypes = mesh.get('element_types', np.full(len(m_elements), 3))
        gamma_w = slope_data.get('gamma_water', 62.4)
        head = seep_u / gamma_w + m_nodes[:, 1]

        # Build triangulation for contouring (subdivide higher-order elements)
        all_tris = []
        for idx, elem in enumerate(m_elements):
            etype = m_etypes[idx]
            if etype == 3:
                all_tris.append(elem[:3])
            elif etype == 6:
                all_tris.append([elem[0], elem[3], elem[5]])
                all_tris.append([elem[3], elem[1], elem[4]])
                all_tris.append([elem[5], elem[4], elem[2]])
                all_tris.append([elem[3], elem[4], elem[5]])
            elif etype in (4, 8, 9):
                all_tris.append([elem[0], elem[1], elem[2]])
                all_tris.append([elem[0], elem[2], elem[3]])
        if all_tris:
            triang = mtri.Triangulation(m_nodes[:, 0], m_nodes[:, 1], all_tris)
            # Head contours
            levels = np.linspace(np.min(head), np.max(head), 20)
            ax.tricontour(triang, head, levels=levels, colors='k', linewidths=0.5, alpha=0.5)
            # Phreatic surface (u = 0)
            if np.min(seep_u) < 0:
                cs_phreatic = ax.tricontour(triang, seep_u, levels=[0], colors='black', linewidths=2.0, alpha=0.5)
                # Place inverted triangle marker at the midpoint of the phreatic contour
                from matplotlib.markers import MarkerStyle
                from matplotlib.transforms import offset_copy
                for seg in cs_phreatic.allsegs[0]:
                    if len(seg) > 1:
                        # Compute cumulative arc length along the segment
                        diffs = np.diff(seg, axis=0)
                        arc = np.concatenate([[0], np.cumsum(np.hypot(diffs[:, 0], diffs[:, 1]))])
                        mid_arc = arc[-1] / 2.0
                        mid_x = float(np.interp(mid_arc, arc, seg[:, 0]))
                        mid_y = float(np.interp(mid_arc, arc, seg[:, 1]))
                        # Offset marker so tip touches the line (same technique as piezo line)
                        ms = MarkerStyle("v")
                        path = ms.get_path().transformed(ms.get_transform())
                        tip_offset = (-float(np.asarray(path.vertices)[:, 1].min())) * 8.0 + 2.0
                        trans = offset_copy(ax.transData, fig=ax.figure, x=0.0, y=tip_offset, units="points")
                        ax.plot([mid_x], [mid_y], marker="v", color="black", markersize=8,
                                linestyle="None", transform=trans, alpha=0.5)
                        break  # marker on the longest/first segment only

    plot_dloads(ax, slope_data)
    plot_tcrack_surface(ax, slope_data)
    plot_tcrack_water_force(ax, slice_df, slope_data)
    plot_reinforcement_lines(ax, slope_data)
    plot_piles(ax, slope_data, slice_df=slice_df)
    if slice_numbers:
        plot_slice_numbers(ax, slice_df)
    # plot_material_table(ax, data['materials'], xloc=0.75) # Adjust this so that it fits with the legend

    alpha = 0.3
    if results['method'] == 'spencer':
        plot_thrust_line_from_df(ax, slice_df)

    plot_base_stresses(ax, slice_df, alpha=alpha)

    import matplotlib.patches as mpatches
    normal_patch = mpatches.Patch(facecolor='none', edgecolor='green', hatch='.....', label="Eff Normal Stress (σ')")
    pore_patch = mpatches.Patch(color='blue', alpha=alpha, label='Pore Pressure (u)')

    # Get legend handles and labels
    handles, labels = ax.get_legend_handles_labels()
    handles.extend([normal_patch, pore_patch])
    labels.extend(["Eff Normal Stress (σ')", 'Pore Pressure (u)'])

    # Add distributed load to legend if present
    if slope_data['dloads']:
        handler_class, dummy_line = get_dload_legend_handler()
        handles.append(dummy_line)
        labels.append('Distributed Load')

    ax.legend(
        handles=handles,
        labels=labels,
        loc='upper center',
        bbox_to_anchor=(0.5, -0.15),
        ncol=3
    )

    # Add vertical space below for the legend
    plt.subplots_adjust(bottom=0.2)
    ax.set_aspect('equal')

    fs = results['FS']
    method = results['method']
    if method == 'oms':
        title = f'OMS: FS = {fs:.3f}'
    elif method == 'bishop':
        title = f'Bishop: FS = {fs:.3f}'
    elif method == 'spencer':
        theta = results['theta']
        title = f'Spencer: FS = {fs:.3f}, θ = {theta:.2f}°'
    elif method == 'janbu':
        fo = results['fo']
        title = f'Janbu-Corrected: FS = {fs:.3f}, fo = {fo:.2f}'
    elif method == 'corps_engineers':
        theta = results['theta']
        title = f'Corps Engineers: FS = {fs:.3f}, θ = {theta:.2f}°'
    elif method == 'lowe_karafiath':
        title = f'Lowe & Karafiath: FS = {fs:.3f}'
    else:
        title = f'{method}: FS = {fs:.3f}'
    ax.set_title(title)

    # zoom y‐axis to just cover the slope and depth, with a little breathing room (thrust line can be outside)
    ymin, ymax = compute_ylim(slope_data, slice_df, pad_fraction=0.05)
    ax.set_ylim(ymin, ymax)

    plt.tight_layout()

    if save_png:
        filename = 'plot_' + title.lower().replace(' ', '_').replace(':', '').replace(',', '').replace('°', 'deg') + '.png'
        plt.savefig(filename, dpi=dpi, bbox_inches='tight')

    plt.show()

plot_tcrack_surface(ax, slope_data)

Plots the tension crack surface as a thin dashed red line, clipped to max_depth.

Parameters:
  • ax

    matplotlib Axes object

  • slope_data

    Dictionary containing tcrack_surface and max_depth

Returns:
  • None

Source code in xslope/plot.py
def plot_tcrack_surface(ax, slope_data):
    """
    Plots the tension crack surface as a thin dashed red line, clipped to max_depth.

    Parameters:
        ax: matplotlib Axes object
        slope_data: Dictionary containing tcrack_surface and max_depth

    Returns:
        None
    """
    tcrack_surface = slope_data.get('tcrack_surface')
    if tcrack_surface is None:
        return

    color = 'red'
    linestyle = ':'
    linewidth = 1.5

    max_depth = slope_data.get('max_depth')
    if max_depth is None:
        # No clipping needed
        x_vals, y_vals = tcrack_surface.xy
        ax.plot(x_vals, y_vals, linestyle=linestyle, color=color, linewidth=linewidth, label='Tension Crack Depth')
        return

    # Get coordinates and clip to max_depth with interpolation
    coords = list(tcrack_surface.coords)
    x_clipped = []
    y_clipped = []

    for i in range(len(coords)):
        x1, y1 = coords[i]

        if y1 >= max_depth:
            # Point is above max_depth, include it
            x_clipped.append(x1)
            y_clipped.append(y1)

        # Check if segment crosses max_depth (need to interpolate)
        if i < len(coords) - 1:
            x2, y2 = coords[i + 1]
            # Check if segment crosses max_depth
            if (y1 < max_depth and y2 >= max_depth) or (y1 >= max_depth and y2 < max_depth):
                # Interpolate to find crossing point
                t = (max_depth - y1) / (y2 - y1)
                x_cross = x1 + t * (x2 - x1)
                x_clipped.append(x_cross)
                y_clipped.append(max_depth)

    if x_clipped:
        ax.plot(x_clipped, y_clipped, linestyle=linestyle, color=color, linewidth=linewidth, label='Tension Crack Depth')

plot_tcrack_water_force(ax, slice_df, slope_data)

Plots the triangular water pressure distribution on the tension crack face.

The water in the tension crack creates a triangular pressure distribution acting horizontally on the side of the top slice. Pressure is zero at the water surface and maximum (gamma_w * water_depth) at the bottom. The triangle is drawn on the outside of the slice, with arrows pointing toward the slice to show force direction. The base of the triangle is scaled to equal the water depth.

Parameters:
  • ax

    matplotlib Axes object

  • slice_df

    DataFrame containing slice data with 't' and 'y_t' columns

  • slope_data

    Dictionary containing slope data including tcrack_water

Returns:
  • None

Source code in xslope/plot.py
def plot_tcrack_water_force(ax, slice_df, slope_data):
    """
    Plots the triangular water pressure distribution on the tension crack face.

    The water in the tension crack creates a triangular pressure distribution
    acting horizontally on the side of the top slice. Pressure is zero at the
    water surface and maximum (gamma_w * water_depth) at the bottom.
    The triangle is drawn on the outside of the slice, with arrows pointing
    toward the slice to show force direction. The base of the triangle is
    scaled to equal the water depth.

    Parameters:
        ax: matplotlib Axes object
        slice_df: DataFrame containing slice data with 't' and 'y_t' columns
        slope_data: Dictionary containing slope data including tcrack_water

    Returns:
        None
    """
    tcrack_water = slope_data.get('tcrack_water', 0)
    if tcrack_water <= 0:
        return

    # Find the slice with the tension crack force
    t_forces = slice_df['t'].abs()
    if t_forces.max() == 0:
        return

    # Get the slice with the tension crack force
    tcrack_slice_idx = t_forces.idxmax()
    tcrack_slice = slice_df.loc[tcrack_slice_idx]

    t_force = tcrack_slice['t']
    y_rb = tcrack_slice['y_rb']
    y_rt = tcrack_slice['y_rt']

    # Determine if right-facing or left-facing based on sign of t
    # Negative t means right-facing (force acts to the right, on left side of first slice)
    # Positive t means left-facing (force acts to the left, on right side of last slice)
    right_facing = t_force < 0

    if right_facing:
        # Water on left side of slice, triangle extends left (outside), arrows point right (into slice)
        x_base = tcrack_slice['x_l']
        triangle_direction = -1  # triangle extends left (outside the slice)
        arrow_direction = 1      # arrows point right (into the slice)
    else:
        # Water on right side of slice, triangle extends right (outside), arrows point left (into slice)
        x_base = tcrack_slice['x_r']
        triangle_direction = 1   # triangle extends right (outside the slice)
        arrow_direction = -1     # arrows point left (into the slice)

    # Water surface is at ground level, bottom of water is at y_rb
    y_water_top = y_rt  # top of water (at ground surface)
    y_water_bottom = y_rb  # bottom of water (at failure surface)
    water_depth = y_water_top - y_water_bottom

    if water_depth <= 0:
        return

    # Scale so that the base of the triangle equals the water depth
    max_length = tcrack_water  # base of triangle = water depth

    # Arrow head dimensions (same style as distributed loads)
    head_length = max_length / 8
    head_width = head_length * 0.8

    # Draw triangular pressure distribution (on outside of slice)
    num_arrows = 5
    y_positions = np.linspace(y_water_bottom, y_water_top, num_arrows + 1)[:-1]

    for y_pos in y_positions:
        # Arrow length proportional to depth (0 at top, max_length at bottom)
        depth_from_surface = y_water_top - y_pos
        arrow_length = max_length * (depth_from_surface / water_depth)

        if arrow_length < 0.1:
            continue  # Skip very short arrows

        # Arrow starts from outside (triangle edge) and points toward slice
        x_start = x_base + arrow_length * triangle_direction
        dx = -arrow_length * triangle_direction  # direction toward slice

        # Draw arrow using same style as distributed loads
        if head_length > arrow_length:
            # Draw a simple line without arrowhead for short arrows
            ax.plot([x_start, x_base], [y_pos, y_pos],
                   color='blue', linewidth=2, alpha=0.7)
        else:
            ax.arrow(x_start, y_pos, dx, 0,
                    head_width=head_width, head_length=head_length,
                    fc='blue', ec='blue', alpha=0.7,
                    length_includes_head=True)

    # Draw the triangular outline (pressure diagram) on outside of slice
    triangle_x = [x_base, x_base + max_length * triangle_direction, x_base]
    triangle_y = [y_water_top, y_water_bottom, y_water_bottom]
    ax.fill(triangle_x, triangle_y, color='lightblue', alpha=0.3, edgecolor='blue', linewidth=1)

plot_thrust_line_from_df(ax, slice_df, color='red', linestyle='--', linewidth=1, label='Line of Thrust')

Plots the line of thrust from the slice dataframe.

Parameters:
  • ax

    matplotlib Axes object

  • slice_df

    DataFrame containing slice data with 'yt_l' and 'yt_r' columns

  • color (str, default: 'red' ) –

    Color of the line

  • linestyle (str, default: '--' ) –

    Style of the line

  • linewidth (float, default: 1 ) –

    Width of the line

  • label (str, default: 'Line of Thrust' ) –

    Label for the line in the legend

Returns:
  • None

Source code in xslope/plot.py
def plot_thrust_line_from_df(ax, slice_df,
                            color: str = 'red',
                            linestyle: str = '--',
                            linewidth: float = 1,
                            label: str = 'Line of Thrust'):
    """
    Plots the line of thrust from the slice dataframe.

    Parameters:
        ax: matplotlib Axes object
        slice_df: DataFrame containing slice data with 'yt_l' and 'yt_r' columns
        color: Color of the line
        linestyle: Style of the line
        linewidth: Width of the line
        label: Label for the line in the legend

    Returns:
        None
    """
    # Check if required columns exist
    if 'yt_l' not in slice_df.columns or 'yt_r' not in slice_df.columns:
        return

    # Create thrust line coordinates from slice data
    thrust_xs = []
    thrust_ys = []

    for _, row in slice_df.iterrows():
        # Add left point of current slice
        thrust_xs.append(row['x_l'])
        thrust_ys.append(row['yt_l'])

        # Add right point of current slice (same as left point of next slice)
        thrust_xs.append(row['x_r'])
        thrust_ys.append(row['yt_r'])

    # Plot the thrust line
    ax.plot(thrust_xs, thrust_ys,
            color=color,
            linestyle=linestyle,
            linewidth=linewidth,
            label=label)