An S-N curve (also known as a Wöhler curve) visualizes the relationship between alternating stress amplitude and the number of cycles to failure for a material under fatigue loading. Both axes typically use logarithmic scales, with stress on the y-axis and cycle count on the x-axis. This plot is fundamental for predicting material fatigue life and identifying key material properties such as ultimate strength, yield strength, and endurance limit.

""" anyplot.ai
sn-curve-basic: S-N Curve (Wöhler Curve)
Library: letsplot 4.10.0 | Python 3.13.13
Quality: 89/100 | Updated: 2026-05-20
"""
import os
import numpy as np
import pandas as pd
from lets_plot import *
from lets_plot.export import ggsave
LetsPlot.setup_html()
THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
GRID = "#E4E2DB" if THEME == "light" else "#2F2F2C"
# Okabe-Ito palette — positions 1-4
BRAND = "#009E73" # position 1 — data points and fit line
OI_2 = "#C475FD" # vermillion — Ultimate Strength
OI_3 = "#4467A3" # blue — Yield Strength
OI_4 = "#BD8233" # reddish purple — Endurance Limit
# Generate realistic S-N curve data for structural steel
np.random.seed(42)
ultimate_strength = 500
yield_strength = 350
endurance_limit = 200
stress_levels = np.array([450, 400, 350, 320, 300, 280, 260, 240, 220, 210])
# Basquin equation: S = A * N^b → N = (S/A)^(1/b)
A = 800
b = -0.10
base_cycles = (stress_levels / A) ** (1 / b)
all_stress = []
all_cycles = []
for stress, base_N in zip(stress_levels, base_cycles, strict=True):
n_specimens = np.random.randint(3, 6)
scatter = np.random.lognormal(0, 0.15, n_specimens)
cycles = base_N * scatter
all_stress.extend([stress] * n_specimens)
all_cycles.extend(cycles)
df = pd.DataFrame({"stress": all_stress, "cycles": all_cycles})
# Fit line starting at ~300 cycles to avoid crowding near Ultimate Strength at low N
fit_cycles = np.logspace(2.5, 7, 100)
fit_stress = A * fit_cycles**b
df_fit = pd.DataFrame({"cycles": fit_cycles, "stress": fit_stress})
# Fatigue regime zones: infinite life / high-cycle / low-cycle
df_zone_infinite = pd.DataFrame({"xmin": [100.0], "xmax": [1e8], "ymin": [100.0], "ymax": [float(endurance_limit)]})
df_zone_highcycle = pd.DataFrame(
{"xmin": [100.0], "xmax": [1e8], "ymin": [float(endurance_limit)], "ymax": [float(yield_strength)]}
)
df_zone_lowcycle = pd.DataFrame(
{"xmin": [100.0], "xmax": [1e8], "ymin": [float(yield_strength)], "ymax": [float(ultimate_strength)]}
)
anyplot_theme = theme(
plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),
panel_background=element_rect(fill=PAGE_BG),
panel_grid_major=element_line(color=GRID, size=0.3),
panel_grid_minor=element_blank(),
axis_title=element_text(color=INK, size=12),
axis_text=element_text(color=INK_SOFT, size=10),
axis_line=element_line(color=INK_SOFT),
plot_title=element_text(color=INK, size=16),
legend_background=element_rect(fill=ELEVATED_BG, color=INK_SOFT),
legend_text=element_text(color=INK_SOFT, size=10),
legend_title=element_text(color=INK),
)
plot = (
ggplot()
# Zone shading — demarcates fatigue regimes (rendered first, behind data)
+ geom_rect(
data=df_zone_lowcycle,
mapping=aes(xmin="xmin", xmax="xmax", ymin="ymin", ymax="ymax"),
fill=OI_2,
alpha=0.07,
color="transparent",
)
+ geom_rect(
data=df_zone_highcycle,
mapping=aes(xmin="xmin", xmax="xmax", ymin="ymin", ymax="ymax"),
fill=OI_3,
alpha=0.07,
color="transparent",
)
+ geom_rect(
data=df_zone_infinite,
mapping=aes(xmin="xmin", xmax="xmax", ymin="ymin", ymax="ymax"),
fill=OI_4,
alpha=0.07,
color="transparent",
)
# Basquin power-law fit line — thicker (size=1.5) to stand apart from scatter points
+ geom_line(
data=df_fit,
mapping=aes(x="cycles", y="stress"),
color=BRAND,
size=1.5,
alpha=0.9,
)
# Test specimen data points with interactive tooltips
+ geom_point(
data=df,
mapping=aes(x="cycles", y="stress"),
color=BRAND,
size=3.5,
alpha=0.85,
tooltips=layer_tooltips()
.line("Cycles to failure|@cycles{,.0f}")
.line("Stress amplitude|@stress MPa"),
)
# Reference lines — distinct linetypes for CVD accessibility
+ geom_hline(yintercept=ultimate_strength, color=OI_2, size=0.9, linetype="dashed")
+ geom_hline(yintercept=yield_strength, color=OI_3, size=0.9, linetype="dotted")
+ geom_hline(yintercept=endurance_limit, color=OI_4, size=0.9, linetype="dotdash")
# Inline labels at right side of chart (x=2e6, hjust=1) — well clear of the fit line
+ geom_text(
data=pd.DataFrame({"cycles": [2e6], "stress": [ultimate_strength * 0.97], "label": ["Ultimate Strength"]}),
mapping=aes(x="cycles", y="stress", label="label"),
color=OI_2,
size=11,
hjust=1,
)
+ geom_text(
data=pd.DataFrame({"cycles": [2e6], "stress": [yield_strength * 1.04], "label": ["Yield Strength"]}),
mapping=aes(x="cycles", y="stress", label="label"),
color=OI_3,
size=11,
hjust=1,
)
+ geom_text(
data=pd.DataFrame({"cycles": [2e6], "stress": [endurance_limit * 1.04], "label": ["Endurance Limit"]}),
mapping=aes(x="cycles", y="stress", label="label"),
color=OI_4,
size=11,
hjust=1,
)
+ scale_x_log10()
+ scale_y_log10()
+ labs(
title="sn-curve-basic · python · letsplot · anyplot.ai",
x="Number of Cycles to Failure (N)",
y="Stress Amplitude (MPa)",
)
+ theme_minimal()
+ anyplot_theme
+ ggsize(800, 450)
)
ggsave(plot, f"plot-{THEME}.png", path=".", scale=4)
ggsave(plot, f"plot-{THEME}.html", path=".")
Part of S-N Curve (Wöhler Curve) on anyplot.ai.