Engineering Stress-Strain Curve — Altair

An engineering stress-strain curve visualizes the relationship between applied stress (MPa) and resulting strain (dimensionless) in a material under uniaxial tensile loading. The curve reveals distinct mechanical behavior regions — elastic deformation, yielding, strain hardening, and necking — culminating in fracture. It is the foundational plot for characterizing material mechanical properties and comparing material performance.

Engineering Stress-Strain Curve rendered with Altair

Python source (Altair)

""" anyplot.ai
line-stress-strain: Engineering Stress-Strain Curve
Library: altair 6.2.1 | Python 3.13.14
Quality: 91/100 | Updated: 2026-06-21
"""

import importlib
import os
import sys

from PIL import Image


# Remove the script directory from sys.path so `altair` resolves to the installed package, not this file
sys.path[:] = [p for p in sys.path if os.path.abspath(p or ".") != os.path.dirname(os.path.abspath(__file__))]
alt = importlib.import_module("altair")
np = importlib.import_module("numpy")
pd = importlib.import_module("pandas")

# Theme tokens — Imprint palette + theme-adaptive chrome
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"
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"
# Imprint categorical palette — position 1 (brand green) is always the primary series
IMPRINT_PALETTE = ["#009E73", "#C475FD", "#4467A3", "#BD8233", "#AE3030", "#2ABCCD", "#954477", "#99B314"]
BRAND = IMPRINT_PALETTE[0]  # main stress-strain curve

# Data — mild steel tensile test (realistic mechanical properties)
np.random.seed(42)
youngs_modulus = 210000  # MPa
yield_strength = 250  # MPa
uts = 400  # MPa (ultimate tensile strength)
uts_strain = 0.22
fracture_strain = 0.35
yield_strain = yield_strength / youngs_modulus  # ≈ 0.00119

# Build curve by region: elastic → yield plateau → strain hardening → necking
n_e, n_p, n_h, n_n = 60, 20, 120, 80
strain_e = np.linspace(0, yield_strain, n_e)
strain_p = np.linspace(yield_strain, 0.02, n_p)
strain_h = np.linspace(0.02, uts_strain, n_h)
strain_n = np.linspace(uts_strain, fracture_strain, n_n)

t_h = (strain_h - 0.02) / (uts_strain - 0.02)
t_n = (strain_n - uts_strain) / (fracture_strain - uts_strain)

stress_e = youngs_modulus * strain_e
stress_p = np.full(n_p, yield_strength) + np.random.normal(0, 1.5, n_p)
stress_h = yield_strength + (uts - yield_strength) * (1 - (1 - t_h) ** 0.45) + np.random.normal(0, 1.5, n_h)
stress_n = uts - (uts - 280) * t_n**1.5 + np.random.normal(0, 1.5, n_n)

strain_all = np.concatenate([strain_e, strain_p, strain_h, strain_n])
stress_all = np.concatenate([stress_e, stress_p, stress_h, stress_n])
df = pd.DataFrame({"Strain": strain_all, "Stress": stress_all})

# 0.2% offset line — parallel to elastic region, offset by 0.002 strain
offset = 0.002
offset_end = yield_strain + offset + 0.003
offset_df = pd.DataFrame({"Strain": [offset, offset_end], "Stress": [0.0, youngs_modulus * (offset_end - offset)]})

# Young's modulus slope indicator (visible dashed line in elastic region)
slope_df = pd.DataFrame({"Strain": [0.0, yield_strain * 0.75], "Stress": [0.0, youngs_modulus * yield_strain * 0.75]})

# Critical key points
uts_idx = int(np.argmax(stress_all))
key_points_df = pd.DataFrame(
    {
        "Strain": [offset + yield_strength / youngs_modulus, strain_all[uts_idx], strain_all[-1]],
        "Stress": [float(yield_strength), float(stress_all[uts_idx]), float(stress_all[-1])],
        "Point": ["Yield Point", "UTS", "Fracture"],
    }
)
kp = key_points_df.set_index("Point")

# Imprint point colors: lavender (yield), ochre (UTS peak), matte red (fracture = semantic failure)
point_scale = alt.Scale(
    domain=["Yield Point", "UTS", "Fracture"], range=[IMPRINT_PALETTE[1], IMPRINT_PALETTE[3], IMPRINT_PALETTE[4]]
)

# Label positions — displaced away from crowded elastic zone near the y-axis
label_df = pd.DataFrame(
    {
        "Strain": [0.09, kp.loc["UTS", "Strain"] + 0.02, kp.loc["Fracture", "Strain"] - 0.005],
        "Stress": [
            kp.loc["Yield Point", "Stress"] + 60,
            kp.loc["UTS", "Stress"] + 30,
            kp.loc["Fracture", "Stress"] + 35,
        ],
        "text": ["Yield Point\n(0.2% offset)", f"UTS ({uts} MPa)", "Fracture"],
        "Point": ["Yield Point", "UTS", "Fracture"],
    }
)

# Connector lines from displaced labels to their key points
connector_df = pd.DataFrame(
    {
        "Strain": [kp.loc["Yield Point", "Strain"], 0.085, kp.loc["UTS", "Strain"], kp.loc["UTS", "Strain"] + 0.017],
        "Stress": [
            kp.loc["Yield Point", "Stress"],
            kp.loc["Yield Point", "Stress"] + 50,
            kp.loc["UTS", "Stress"],
            kp.loc["UTS", "Stress"] + 22,
        ],
        "group": [0, 0, 1, 1],
    }
)

# Region labels and subtle vertical separators
region_df = pd.DataFrame(
    {"Strain": [0.005, 0.11, 0.30], "Stress": [440, 440, 440], "text": ["Elastic", "Strain Hardening", "Necking"]}
)
region_sep_df = pd.DataFrame(
    {"Strain": [0.02, 0.02, uts_strain, uts_strain], "Stress": [425, 455, 425, 455], "sep": [0, 0, 1, 1]}
)

# Modulus annotation connector: slope midpoint → text label
modulus_conn_df = pd.DataFrame(
    {"Strain": [yield_strain * 0.5, 0.023], "Stress": [youngs_modulus * yield_strain * 0.5, 108]}
)

# Shared axis scales
x_scale = alt.Scale(domain=[-0.01, 0.38])
y_scale = alt.Scale(domain=[-10, 460])

# Title — 49 chars, under the 67-char baseline (no font-size scaling needed)
title_str = "line-stress-strain · python · altair · anyplot.ai"

# Nearest-point hover selection (enhances the interactive HTML experience)
nearest = alt.selection_point(nearest=True, on="pointerover", fields=["Strain"], empty=False)

# Chart layers
curve = (
    alt.Chart(df)
    .mark_line(strokeWidth=3, color=BRAND)
    .encode(
        x=alt.X("Strain:Q", scale=x_scale, title="Engineering Strain"),
        y=alt.Y("Stress:Q", scale=y_scale, title="Engineering Stress (MPa)"),
        tooltip=[alt.Tooltip("Strain:Q", format=".4f"), alt.Tooltip("Stress:Q", format=".1f", title="Stress (MPa)")],
    )
)

slope_line = (
    alt.Chart(slope_df)
    .mark_line(strokeWidth=2.5, strokeDash=[12, 4], color=BRAND)
    .encode(x=alt.X("Strain:Q", scale=x_scale), y=alt.Y("Stress:Q", scale=y_scale))
)

offset_line = (
    alt.Chart(offset_df)
    .mark_line(strokeWidth=2, strokeDash=[8, 6], color=INK_SOFT)
    .encode(x=alt.X("Strain:Q", scale=x_scale), y=alt.Y("Stress:Q", scale=y_scale))
)

connectors = (
    alt.Chart(connector_df)
    .mark_line(strokeWidth=1, strokeDash=[3, 3], color=INK_MUTED)
    .encode(x=alt.X("Strain:Q", scale=x_scale), y=alt.Y("Stress:Q", scale=y_scale), detail="group:N")
)

modulus_conn = (
    alt.Chart(modulus_conn_df)
    .mark_line(strokeWidth=1, strokeDash=[3, 3], color=BRAND)
    .encode(x=alt.X("Strain:Q", scale=x_scale), y=alt.Y("Stress:Q", scale=y_scale))
)

points = (
    alt.Chart(key_points_df)
    .mark_point(filled=True, size=450, stroke="white", strokeWidth=2.5)
    .encode(
        x=alt.X("Strain:Q", scale=x_scale),
        y=alt.Y("Stress:Q", scale=y_scale),
        color=alt.Color("Point:N", scale=point_scale, legend=None),
        opacity=alt.condition(nearest, alt.value(1.0), alt.value(0.85)),
        tooltip=[
            alt.Tooltip("Point:N"),
            alt.Tooltip("Strain:Q", format=".4f"),
            alt.Tooltip("Stress:Q", format=".1f", title="Stress (MPa)"),
        ],
    )
    .add_params(nearest)
)

point_labels = (
    alt.Chart(label_df)
    .mark_text(fontSize=17, fontWeight="bold", lineBreak="\n")
    .encode(
        x=alt.X("Strain:Q", scale=x_scale),
        y=alt.Y("Stress:Q", scale=y_scale),
        text="text:N",
        color=alt.Color("Point:N", scale=point_scale, legend=None),
    )
)

region_text = (
    alt.Chart(region_df)
    .mark_text(fontSize=16, fontStyle="italic", fontWeight=500, color=INK_MUTED)
    .encode(x=alt.X("Strain:Q", scale=x_scale), y=alt.Y("Stress:Q", scale=y_scale), text="text:N")
)

region_seps = (
    alt.Chart(region_sep_df)
    .mark_line(strokeWidth=0.8, strokeDash=[4, 3], color=INK_MUTED)
    .encode(x=alt.X("Strain:Q", scale=x_scale), y=alt.Y("Stress:Q", scale=y_scale), detail="sep:N")
)

modulus_label = pd.DataFrame({"Strain": [0.025], "Stress": [100], "text": [f"E = {youngs_modulus:,} MPa"]})
modulus_text = (
    alt.Chart(modulus_label)
    .mark_text(fontSize=16, align="left", fontWeight="bold", color=BRAND)
    .encode(x=alt.X("Strain:Q", scale=x_scale), y=alt.Y("Stress:Q", scale=y_scale), text="text:N")
)

offset_label = pd.DataFrame({"Strain": [offset + 0.005], "Stress": [40], "text": ["0.2% offset"]})
offset_text = (
    alt.Chart(offset_label)
    .mark_text(fontSize=15, align="left", fontStyle="italic", color=INK_SOFT)
    .encode(x=alt.X("Strain:Q", scale=x_scale), y=alt.Y("Stress:Q", scale=y_scale), text="text:N")
)

# Combine all layers
chart = (
    alt.layer(
        curve,
        slope_line,
        offset_line,
        connectors,
        modulus_conn,
        points,
        point_labels,
        region_seps,
        region_text,
        modulus_text,
        offset_text,
    )
    .properties(
        width=620,
        height=320,
        background=PAGE_BG,
        padding={"left": 0, "right": 0, "top": 0, "bottom": 0},
        title=alt.Title(
            title_str,
            fontSize=16,
            subtitle="Engineering stress-strain response: elastic, strain hardening, and necking regions",
            subtitleFontSize=12,
            subtitleColor=INK_MUTED,
            anchor="start",
            offset=12,
        ),
    )
    .configure_view(fill=PAGE_BG, strokeWidth=0, continuousWidth=620, continuousHeight=320)
    .configure_axis(
        labelFontSize=10,
        titleFontSize=12,
        labelColor=INK_SOFT,
        titleColor=INK,
        domainColor=INK_SOFT,
        tickColor=INK_SOFT,
        grid=False,
    )
    .configure_title(color=INK)
    .configure_legend(fillColor=ELEVATED_BG, strokeColor=INK_SOFT, labelColor=INK_SOFT, titleColor=INK)
    .interactive()
)

# Save PNG with canvas-size enforcement (3200 × 1800 landscape target)
chart.save(f"plot-{THEME}.png", scale_factor=4.0)

TW, TH = 3200, 1800
_img = Image.open(f"plot-{THEME}.png").convert("RGB")
_w, _h = _img.size
if _w > TW or _h > TH:
    raise SystemExit(
        f"altair vl-convert produced {_w}×{_h}, exceeds target {TW}×{TH}. "
        f"Shrink chart .properties(width=, height=) values and re-render."
    )
if _w < TW or _h < TH:
    _bg_hex = PAGE_BG.lstrip("#")
    _bg_rgb = (int(_bg_hex[0:2], 16), int(_bg_hex[2:4], 16), int(_bg_hex[4:6], 16))
    _canvas = Image.new("RGB", (TW, TH), _bg_rgb)
    _canvas.paste(_img, ((TW - _w) // 2, (TH - _h) // 2))
    _canvas.save(f"plot-{THEME}.png")

chart.save(f"plot-{THEME}.html")

Part of Engineering Stress-Strain Curve on anyplot.ai.

Other implementations