Chris Parmer — home

S-N fatigue curve

Mechanical Engineering — titanium alloy

Example from the compendium of canonical charts

Mechanical Engineering — S-N fatigue curve (titanium alloy)

Python Code

"""Mechanical Engineering — S-N fatigue curve (titanium alloy)."""


import numpy as np
import plotly.graph_objects as go


URL = (
    "https://raw.githubusercontent.com/avakanski/Fatigue-Life-Prediction/main/"
    "Uncertainty%20Quantification%20ML%20Models/Titanium%20_Alloys/Data/"
    "Titanium_Alloy_UC.csv"
)


def load_data():
    try:
        df = fetch_csv(URL)
        print(f"  columns: {list(df.columns)}")
        print(f"  shape: {df.shape}")
        return df
    except Exception as e:
        print(f"  data load failed ({e})")
        return None


def find_col(df, candidates):
    for cand in candidates:
        for col in df.columns:
            if cand.lower() in str(col).strip().lower():
                return col
    return None


def generate():
    import pandas as pd

    df = load_data()
    synthetic = False

    if df is not None:
        stress_col  = find_col(df, ["stress", "sa", "amplitude", "max"])
        cycles_col  = find_col(df, ["cycle", "nf", "n_f", "life", "number"])
        r_col       = find_col(df, [" r", "r_ratio", "ratio", "r"])

        print(f"  stress_col={stress_col}, cycles_col={cycles_col}, r_col={r_col}")

        if stress_col is None or cycles_col is None:
            df = None

    if df is None:
        print("  using synthetic S-N data for Ti-6Al-4V")
        synthetic = True

        rng = np.random.default_rng(42)
        # S-N curve: S = C * N^(-b), with C=1200 MPa, b=0.1
        C, b = 1200, 0.095
        n_pts = 60
        log_N = rng.uniform(3, 8, n_pts)
        S_mean = C * 10 ** (-b * log_N)
        scatter = rng.normal(0, 20, n_pts)
        S = np.clip(S_mean + scatter, 200, 1200)

        # Some runouts (censored) at high cycle count
        runout_mask = (log_N > 7.0) & (S < 500)
        N_vals = 10 ** log_N
        # Stress ratios: two groups
        R_vals = rng.choice([-1.0, 0.1], size=n_pts)

        df = pd.DataFrame({"Stress": S, "Cycles": N_vals, "R": R_vals, "_runout": runout_mask})
        stress_col = "Stress"
        cycles_col = "Cycles"
        r_col      = "R"
        runout_col = "_runout"
    else:
        df[stress_col] = pd.to_numeric(df[stress_col], errors="coerce")
        df[cycles_col] = pd.to_numeric(df[cycles_col], errors="coerce")
        df = df.dropna(subset=[stress_col, cycles_col])
        df = df[(df[stress_col] > 0) & (df[cycles_col] > 0)]
        print(f"  valid rows: {len(df)}")

        # Flag runouts: cycles = max value in dataset
        max_cyc = df[cycles_col].max()
        df["_runout"] = df[cycles_col] >= max_cyc * 0.99
        runout_col = "_runout"

    # Separate normal and runout data
    run = df[df[runout_col] == True]
    non = df[df[runout_col] == False]

    fig = go.Figure()

    # Color by R ratio if available
    if r_col and r_col in df.columns:
        r_unique = sorted(df[r_col].dropna().unique())
        colors_by_r = {rv: c for rv, c in zip(r_unique, [VIOLET, "#55B685", "#E9A23B", "#DA5597"])}
    else:
        r_unique = [None]
        colors_by_r = {None: VIOLET}

    # Normal failures, grouped by R
    for rv in r_unique:
        if rv is not None:
            sub = non[non[r_col] == rv]
            cname = f"R = {rv:+.1f}"
        else:
            sub = non
            cname = "Failures"

        if len(sub) == 0:
            continue
        col = colors_by_r.get(rv, VIOLET)

        fig.add_trace(go.Scatter(
            x=sub[cycles_col],
            y=sub[stress_col],
            mode="markers",
            name=cname,
            marker=dict(
                color=col,
                size=7,
                opacity=0.8,
                line=dict(color="white", width=0.5),
            ),
            hovertemplate="N=%{x:.2e}<br>S=%{y:.1f} MPa<extra></extra>",
        ))

    # Runouts
    if len(run) > 0:
        fig.add_trace(go.Scatter(
            x=run[cycles_col],
            y=run[stress_col],
            mode="markers",
            name="Runout (no failure)",
            marker=dict(
                symbol="triangle-right",
                color="gray",
                size=9,
                opacity=0.7,
                line=dict(color="white", width=0.5),
            ),
            hovertemplate="Runout N≥%{x:.2e}<br>S=%{y:.1f} MPa<extra></extra>",
        ))

    # Fit power-law through non-runout data
    try:
        log_N_fit = np.log10(non[cycles_col].values.astype(float))
        log_S_fit = np.log10(non[stress_col].values.astype(float))
        coeffs = np.polyfit(log_N_fit, log_S_fit, 1)
        N_line = np.logspace(
            np.log10(df[cycles_col].min()),
            np.log10(df[cycles_col].max()),
            200,
        )
        S_line = 10 ** (coeffs[1] + coeffs[0] * np.log10(N_line))
        fig.add_trace(go.Scatter(
            x=N_line, y=S_line,
            mode="lines",
            name="Power-law fit",
            line=dict(color=MUTED, width=2, dash="dash"),
            hoverinfo="skip",
        ))
    except Exception as e:
        print(f"  fit failed: {e}")

    fig.update_layout(
        xaxis=dict(
            title="Cycles to Failure (N)",
            type="log",
            showgrid=True,
        ),
        yaxis=dict(
            title="Stress Amplitude (MPa)",
            showgrid=True,
        ),
        legend=dict(
            x=1.02, y=1.0,
            xanchor="left",
            font=dict(size=10),
        ),
        margin=dict(t=20, b=60, l=70, r=140),
        annotations=[
            dict(
                text="Ti alloy" + (" (synthetic)" if synthetic else ""),
                x=0.02, y=0.05,
                xref="paper", yref="paper",
                showarrow=False,
                font=dict(size=10, color=MUTED),
            )
        ],
    )

    return fig


if __name__ == "__main__":
    generate()

Made with Plotly