S-N fatigue curve
Mechanical Engineering — titanium alloy
Example from the compendium of canonical charts
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