S-N Curve (Wöhler Curve) — Pygal

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 Pygal

Python source (Pygal)

""" anyplot.ai
sn-curve-basic: S-N Curve (Wöhler Curve)
Library: pygal 3.1.0 | Python 3.13.13
Quality: 86/100 | Updated: 2026-05-20
"""

import math
import os
import re
import sys

import numpy as np


THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"

IMPRINT = ("#009E73", "#C475FD", "#4467A3", "#BD8233", "#AE3030", "#2ABCCD", "#954477")

# Remove current dir from sys.path to avoid shadowing the pygal package
_cwd = sys.path[0] if sys.path[0] else "."
if _cwd in sys.path:
    sys.path.remove(_cwd)

import pygal
from cairosvg import svg2png as _svg2png
from pygal.style import Style


sys.path.insert(0, _cwd)

# ── Data ──────────────────────────────────────────────────────────────────────
np.random.seed(42)

stress_levels = np.array([450, 400, 350, 300, 275, 250, 225, 210, 200, 195])
base_cycles = np.array([1e2, 5e2, 2e3, 1e4, 3e4, 1e5, 4e5, 1e6, 5e6, 1e7])

cycles_data: list[float] = []
stress_data: list[float] = []

for stress, base_n in zip(stress_levels, base_cycles, strict=True):
    n_samples = np.random.randint(3, 6)
    scatter = np.exp(np.random.normal(0, 0.3, n_samples))
    cycles = base_n * scatter
    cycles_data.extend(cycles)
    stress_data.extend([stress] * n_samples)

cycles_arr = np.array(cycles_data)
stress_arr = np.array(stress_data)

# Basquin equation fit: S = A * N^b  (linear in log-log space)
log_cycles = np.log10(cycles_arr)
log_stress = np.log10(stress_arr)
coeffs = np.polyfit(log_cycles, log_stress, 1)
b = coeffs[0]
A = 10 ** coeffs[1]

fit_cycles = np.logspace(2, 7, 100)
fit_stress = A * (fit_cycles**b)

# Material reference values (MPa)
ultimate_strength = 520
yield_strength = 350
endurance_limit = 190

# pygal's logarithmic=True only applies to the x-axis in XY mode.
# Log10-transform all stress (y) values for a true log-log plot,
# then map explicit y_labels back to human-readable MPa values.
_zone = lambda c: (
    "Low-Cycle Fatigue" if c < 1e3 else ("High-Cycle Fatigue" if c < 1e6 else "Near Endurance Limit")
)

xy_points = [
    {
        "value": (float(c), math.log10(float(s))),
        "label": f"{_zone(float(c))}: {float(c):.2e} cycles @ {float(s):.0f} MPa",
    }
    for c, s in zip(cycles_arr, stress_arr, strict=True)
]
fit_points = [
    {"value": (float(c), math.log10(float(s))), "label": f"Fit: {float(c):.2e} → {float(s):.0f} MPa"}
    for c, s in zip(fit_cycles, fit_stress, strict=True)
]

ult_log = math.log10(ultimate_strength)
yld_log = math.log10(yield_strength)
end_log = math.log10(endurance_limit)

# ── Style ─────────────────────────────────────────────────────────────────────
custom_style = Style(
    background=PAGE_BG,
    plot_background=PAGE_BG,
    foreground=INK,
    foreground_strong=INK,
    foreground_subtle=INK_MUTED,
    colors=IMPRINT,
    title_font_size=66,
    label_font_size=56,
    major_label_font_size=44,
    legend_font_size=44,
    value_font_size=36,
    stroke_width=3,
    opacity=0.75,
    opacity_hover=1.0,
)

# ── Chart ─────────────────────────────────────────────────────────────────────
chart = pygal.XY(
    width=3200,
    height=1800,
    style=custom_style,
    title="Steel Fatigue · sn-curve-basic · python · pygal · anyplot.ai",
    x_title="Cycles to Failure (N)",
    y_title="Stress Amplitude (MPa)",
    logarithmic=True,
    show_dots=True,
    dots_size=12,
    stroke=True,
    show_x_guides=False,
    show_y_guides=True,
    x_label_rotation=45,
    legend_at_bottom=True,
    legend_box_size=44,
    margin=100,
    # Tighter y-range: start just below endurance limit to reduce empty bottom space
    range=(math.log10(165), math.log10(600)),
    value_formatter=lambda xy: f"{xy[0]:.2e} cycles, {10 ** xy[1]:.0f} MPa" if isinstance(xy, tuple) else str(xy),
)

# X-axis: major log decades
chart.x_labels = [100, 1_000, 10_000, 100_000, 1_000_000, 10_000_000]

# Y-axis: stress labels within the tightened range
y_tick_vals = [200, 250, 300, 350, 400, 450, 500, 550]
chart.y_labels = [{"value": math.log10(v), "label": str(v)} for v in y_tick_vals]

# Series: Test Data first (most prominent), then derived/reference
chart.add("Test Data", xy_points, dots_size=20, stroke=False, show_dots=True)
chart.add(
    "Basquin Fit (S = A·N^b)",
    fit_points,
    stroke=True,
    show_dots=False,
    stroke_style={"width": 9, "dasharray": "20, 10"},
)
# Distinct stroke styles per reference line: solid / long-dash / short-dash
chart.add(
    f"Ultimate Strength, Su = {ultimate_strength} MPa",
    [(100, ult_log), (1e7, ult_log)],
    stroke=True,
    show_dots=False,
    stroke_style={"width": 5, "opacity": 0.80},
)
chart.add(
    f"Yield Strength, Sy = {yield_strength} MPa",
    [(100, yld_log), (1e7, yld_log)],
    stroke=True,
    show_dots=False,
    stroke_style={"width": 5, "dasharray": "30, 8", "opacity": 0.80},
)
# Endurance limit: thicker + shorter dashes for better visibility on light bg
chart.add(
    f"Endurance Limit, Se = {endurance_limit} MPa",
    [(100, end_log), (1e7, end_log)],
    stroke=True,
    show_dots=False,
    stroke_style={"width": 12, "dasharray": "14, 5", "opacity": 0.90},
)


# ── SVG injection: fatigue region bands ───────────────────────────────────────


def _get_plot_dims(svg: str) -> tuple[float, float]:
    """Return (width, height) of plot area from the inner background rect in pygal SVG.

    pygal renders <g class="plot" transform="translate(tx,ty)"> containing the inner
    background <rect x=0 y=0 width=W height=H/>.  Coordinates inside that group are
    relative; we only need W and H to map log-scale x positions.
    """
    # Find the opening tag of <g class="plot"> (exact class, not "plot overlay")
    plot_tag = re.search(r'<g[^>]*\bclass="plot"[^>]*>', svg)
    if plot_tag:
        # Find the first <rect> after the plot group tag (that's the background rect)
        rect_m = re.search(r"<rect\b[^>]+>", svg[plot_tag.end() :])
        if rect_m:
            attrs: dict[str, float] = {}
            for attr in ("width", "height"):
                am = re.search(rf'\b{attr}="([0-9.]+)"', rect_m.group(0))
                if am:
                    attrs[attr] = float(am.group(1))
            if "width" in attrs and "height" in attrs:
                return attrs["width"], attrs["height"]
    # Fallback for width=3200, height=1800, margin=100 with our font sizes
    return 2760.0, 1290.0


def _inject_region_bands(svg: str, theme: str) -> str:
    """Inject colored fatigue-region background bands and labels into pygal SVG.

    Elements are placed in the plot group's local coordinate space (origin = top-left
    of the inner plot area), so no absolute pixel offset calculation is needed.
    """
    pw, ph = _get_plot_dims(svg)

    # X-axis: log10(100)=2 → log10(1e7)=7, five decades mapped to [0, pw]
    def log_to_x(log_n: float) -> float:
        return (log_n - 2.0) / 5.0 * pw

    x_lcf = log_to_x(3.0)  # LCF | HCF boundary at N = 1 000
    x_hcf = log_to_x(6.0)  # HCF | Infinite Life boundary at N = 1 000 000

    # Semi-transparent fills (fill + fill-opacity for cairosvg compatibility)
    if theme == "light":
        region_fills = [("#CC8888", "0.10"), ("#88AA88", "0.10"), ("#8888BB", "0.10")]
        div_stroke = "#6B6A63"
        lbl_fill = "#6B6A63"
    else:
        region_fills = [("#BB5555", "0.09"), ("#55994A", "0.09"), ("#5577BB", "0.09")]
        div_stroke = "#A8A79F"
        lbl_fill = "#A8A79F"

    # Labels near the top of the plot area (above all data points)
    lbl_y = 55.0
    lbl_sz = 38

    parts = ['<g class="fatigue-regions">']

    # Three region fills
    for rx, rw, (rfill, rop) in [
        (0.0, x_lcf, region_fills[0]),
        (x_lcf, x_hcf - x_lcf, region_fills[1]),
        (x_hcf, pw - x_hcf, region_fills[2]),
    ]:
        parts.append(
            f'<rect x="{rx:.1f}" y="0" width="{rw:.1f}" height="{ph:.1f}" '
            f'fill="{rfill}" fill-opacity="{rop}" stroke="none"/>'
        )

    # Subtle vertical dividers at region boundaries
    for xd in (x_lcf, x_hcf):
        parts.append(
            f'<line x1="{xd:.1f}" y1="0" x2="{xd:.1f}" y2="{ph:.1f}" '
            f'stroke="{div_stroke}" stroke-width="2" stroke-dasharray="8,4" opacity="0.30"/>'
        )

    # Region labels centered in each band
    for lx, lbl in [
        (x_lcf / 2, "Low-Cycle"),
        ((x_lcf + x_hcf) / 2, "High-Cycle Fatigue"),
        ((x_hcf + pw) / 2, "Infinite Life"),
    ]:
        parts.append(
            f'<text x="{lx:.1f}" y="{lbl_y:.1f}" text-anchor="middle" '
            f'fill="{lbl_fill}" font-size="{lbl_sz}" font-family="sans-serif" '
            f'opacity="0.55">{lbl}</text>'
        )

    parts.append("</g>")
    region_block = "".join(parts)

    # Insert immediately after the plot background rect so bands appear above bg,
    # but behind grid guides and data series.
    plot_tag = re.search(r'<g[^>]*\bclass="plot"[^>]*>', svg)
    if plot_tag:
        after_tag = svg[plot_tag.end() :]
        bg_rect = re.search(r"<rect\b[^>]+>", after_tag)
        if bg_rect:
            insert_pos = plot_tag.end() + bg_rect.end()
            return svg[:insert_pos] + region_block + svg[insert_pos:]

    # Fallback: insert just before </svg>
    return svg.replace("</svg>", region_block + "</svg>")


# ── Render & save ─────────────────────────────────────────────────────────────
svg_raw = chart.render()

# Inject region bands into a copy of the SVG, then convert to PNG via cairosvg
svg_enhanced = _inject_region_bands(svg_raw.decode("utf-8"), THEME).encode("utf-8")
_svg2png(bytestring=svg_enhanced, write_to=f"plot-{THEME}.png")

# HTML export uses the original SVG (preserves full pygal interactivity)
with open(f"plot-{THEME}.html", "wb") as f:
    f.write(svg_raw)

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

Other implementations