S-N Curve (Wöhler Curve) — Makie.jl

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.

S-N Curve (Wöhler Curve) rendered with Makie.jl

Renders

Julia source (Makie.jl)

# anyplot.ai
# sn-curve-basic: S-N Curve (Wöhler Curve)
# Library: makie 0.21.9 | Julia 1.11.9
# Quality: 91/100 | Created: 2026-09-02

using CairoMakie
using Colors
using Random
using Statistics

Random.seed!(42)

# --- Theme tokens -----------------------------------------------------------
const THEME    = get(ENV, "ANYPLOT_THEME", "light")
const PAGE_BG  = THEME == "light" ? colorant"#FAF8F1" : colorant"#1A1A17"
const INK      = THEME == "light" ? colorant"#1A1A17" : colorant"#F0EFE8"
const INK_SOFT = THEME == "light" ? colorant"#4A4A44" : colorant"#B8B7B0"
const IMPRINT_PALETTE = [
    colorant"#009E73", colorant"#C475FD", colorant"#4467A3", colorant"#BD8233",
    colorant"#AE3030", colorant"#2ABCCD", colorant"#954477", colorant"#99B314",
]

# --- Data: fatigue test coupons across 8 stress levels, 4 specimens each ---
stress_levels = [550.0, 500.0, 450.0, 400.0, 350.0, 300.0, 275.0, 260.0]
specimens_per_level = 4
sigma_f_prime = 900.0   # Basquin fatigue strength coefficient (MPa)
basquin_b = -0.09       # Basquin fatigue strength exponent

stress = Float64[]
cycles = Float64[]
for s in stress_levels
    mean_n = 0.5 * (s / sigma_f_prime)^(1 / basquin_b)
    for _ in 1:specimens_per_level
        n_i = mean_n * 10^(0.09 * randn())
        push!(stress, s)
        push!(cycles, n_i)
    end
end

# Basquin power-law fit (least squares in log-log space)
log_n = log10.(cycles)
log_s = log10.(stress)
slope = sum((log_n .- mean(log_n)) .* (log_s .- mean(log_s))) / sum((log_n .- mean(log_n)) .^ 2)
intercept = mean(log_s) - slope * mean(log_n)

fit_cycles = exp10.(range(log10(50.0), log10(1.5e6); length = 200))
fit_stress = exp10.(intercept .+ slope .* log10.(fit_cycles))

ultimate_strength = 600.0
yield_strength = 450.0
endurance_limit = 250.0

# Fatigue-region boundaries: low-cycle/high-cycle split at the conventional
# 10^3-cycle mark; high-cycle/infinite-life split at the cycle count where the
# Basquin fit crosses the endurance limit.
xlim_lo, xlim_hi = 50.0, 1.5e6
low_cycle_boundary = 1.0e3
infinite_life_boundary = clamp(
    exp10((log10(endurance_limit) - intercept) / slope),
    low_cycle_boundary * 1.5,
    xlim_hi * 0.98,
)

# --- Plot --------------------------------------------------------------------
fig = Figure(
    size            = (1600, 900),
    fontsize        = 14,
    backgroundcolor = PAGE_BG,
)

ax = Axis(
    fig[1, 1];
    title             = "sn-curve-basic · julia · makie · anyplot.ai",
    titlesize         = 20,
    titlecolor        = INK,
    xlabel            = "Cycles to Failure, N",
    ylabel            = "Stress Amplitude (MPa)",
    xlabelsize        = 14,
    ylabelsize        = 14,
    xlabelcolor       = INK,
    ylabelcolor       = INK,
    xticklabelsize    = 12,
    yticklabelsize    = 12,
    xticklabelcolor   = INK_SOFT,
    yticklabelcolor   = INK_SOFT,
    xtickcolor        = INK_SOFT,
    ytickcolor        = INK_SOFT,
    xscale            = log10,
    yscale            = log10,
    yticks            = [200, 250, 300, 400, 500, 600, 700],
    ytickformat       = ys -> string.(round.(Int, ys)),
    backgroundcolor   = PAGE_BG,
    topspinevisible    = false,
    rightspinevisible  = false,
    leftspinecolor     = INK_SOFT,
    bottomspinecolor   = INK_SOFT,
    xgridcolor         = RGBAf(INK.r, INK.g, INK.b, 0.15),
    ygridcolor         = RGBAf(INK.r, INK.g, INK.b, 0.15),
    xminorgridvisible  = false,
    yminorgridvisible  = false,
)

xlims!(ax, xlim_lo, xlim_hi)
ylims!(ax, 200.0, 700.0)

# Fatigue-region demarcation: subtle dotted separators + bottom-edge labels,
# called out per the spec's low-cycle / high-cycle / infinite-life regions.
vlines!(ax, [low_cycle_boundary, infinite_life_boundary];
        color = RGBAf(INK_SOFT.r, INK_SOFT.g, INK_SOFT.b, 0.4),
        linestyle = :dot, linewidth = 1.5)

region_label_y = 207.0
mid_low  = exp10(0.5 * (log10(xlim_lo) + log10(low_cycle_boundary)))
mid_high = exp10(0.5 * (log10(low_cycle_boundary) + log10(infinite_life_boundary)))
mid_inf  = exp10(0.5 * (log10(infinite_life_boundary) + log10(xlim_hi)))
text!(ax, mid_low, region_label_y; text = "Low-Cycle", fontsize = 11,
      color = INK_SOFT, align = (:center, :bottom))
text!(ax, mid_high, region_label_y; text = "High-Cycle", fontsize = 11,
      color = INK_SOFT, align = (:center, :bottom))
text!(ax, mid_inf, region_label_y; text = "Infinite Life", fontsize = 11,
      color = INK_SOFT, align = (:center, :bottom))

hlines!(ax, [ultimate_strength]; color = IMPRINT_PALETTE[2], linestyle = :dash,
        linewidth = 2.5, label = "Ultimate Strength")
hlines!(ax, [yield_strength]; color = IMPRINT_PALETTE[3], linestyle = :dash,
        linewidth = 2.5, label = "Yield Strength")
hlines!(ax, [endurance_limit]; color = IMPRINT_PALETTE[4], linestyle = :dash,
        linewidth = 2.5, label = "Endurance Limit")

lines!(ax, fit_cycles, fit_stress; color = INK_SOFT, linewidth = 3,
       label = "Basquin fit")

scatter!(ax, cycles, stress; color = IMPRINT_PALETTE[1], markersize = 16,
         strokewidth = 1.5, strokecolor = PAGE_BG, label = "Test specimens")

# Annotate where the Basquin fit crosses the endurance limit — the point
# beyond which infinite fatigue life is predicted.
n_label = infinite_life_boundary >= 1e6 ?
    string(round(infinite_life_boundary / 1e6; digits = 2), "M") :
    string(round(Int, infinite_life_boundary / 1e3), "k")
text!(ax, infinite_life_boundary * 0.97, endurance_limit * 1.1;
      text = "fit crosses endurance limit\nN ≈ $(n_label)",
      fontsize = 11, color = INK_SOFT, align = (:right, :bottom))

axislegend(ax; position = :rt, labelcolor = INK_SOFT, framevisible = false,
           labelsize = 12)

# --- Save --------------------------------------------------------------------
save("plot-$(THEME).png", fig; px_per_unit = 2)

Retrieve this implementation

Runnable source as JSON, for any HTTP client: https://api.anyplot.ai/specs/sn-curve-basic/makie/code. Any spec id and library id listed in llms-full.txt fit the same URL shape; every URL below is complete and callable.

{
  "spec_id": "sn-curve-basic",
  "language": "julia",
  "library": "makie",
  "page": "https://anyplot.ai/sn-curve-basic/julia/makie",
  "hub": "https://anyplot.ai/sn-curve-basic",
  "code_json": "https://api.anyplot.ai/specs/sn-curve-basic/makie/code",
  "spec_json": "https://api.anyplot.ai/specs/sn-curve-basic",
  "render_light_png": "https://storage.googleapis.com/anyplot-images/plots/sn-curve-basic/julia/makie/plot-light.png",
  "render_dark_png": "https://storage.googleapis.com/anyplot-images/plots/sn-curve-basic/julia/makie/plot-dark.png",
  "quality_score": 91.0,
  "license": "MIT",
  "guide": "https://anyplot.ai/llms.txt"
}

Part of S-N Curve (Wöhler Curve) on anyplot.ai.

Other implementations