Mohr's Circle for Stress Analysis — lets-plot

Mohr's Circle is a graphical method used to determine principal stresses, maximum shear stress, and stress transformations from a given 2D stress state. A circle is drawn on a normal stress (σ) vs. shear stress (τ) plane, with the center at ((σx + σy) / 2, 0) and a radius derived from the stress components. It is an essential tool in mechanical and civil engineering for visualizing how stress components change under coordinate rotation.

Mohr's Circle for Stress Analysis rendered with lets-plot

Python source (lets-plot)

""" anyplot.ai
mohr-circle: Mohr's Circle for Stress Analysis
Library: letsplot 4.10.1 | Python 3.13.13
Quality: 87/100 | Updated: 2026-05-30
"""

import os

import numpy as np
import pandas as pd
from lets_plot import (
    LetsPlot,
    aes,
    coord_fixed,
    element_blank,
    element_line,
    element_rect,
    element_text,
    geom_line,
    geom_path,
    geom_point,
    geom_polygon,
    geom_text,
    ggplot,
    ggsave,
    ggsize,
    labs,
    layer_tooltips,
    scale_color_manual,
    scale_shape_manual,
    theme,
    theme_minimal,
)


LetsPlot.setup_html()

# 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 palette — semantic assignments for stress diagram elements
C_CIRCLE = "#4467A3"  # blue — structural circle outline
C_INPUT = "#AE3030"  # matte red — applied input stress points (semantic: danger/input)
C_PRINCIPAL = "#009E73"  # brand green — principal stress results (primary outcome)
C_SHEAR = "#BD8233"  # ochre — shear stress components

# Category constants for legend
CAT_INPUT = "Input Point"
CAT_PRINCIPAL = "Principal Stress"
CAT_SHEAR = "Max Shear"

# Data — steel column under combined axial and bending load
sigma_x = 80.0  # MPa, axial + bending normal stress
sigma_y = -30.0  # MPa, lateral normal stress
tau_xy = 50.0  # MPa, shear stress from torsion

# Mohr's Circle geometry
center = (sigma_x + sigma_y) / 2.0
radius = np.sqrt(((sigma_x - sigma_y) / 2.0) ** 2 + tau_xy**2)
sigma_1 = center + radius
sigma_2 = center - radius
tau_max = radius
theta_p = 0.5 * np.degrees(np.arctan2(2 * tau_xy, sigma_x - sigma_y))

# Circle path
theta = np.linspace(0, 2 * np.pi, 360)
df_circle = pd.DataFrame({"sigma": center + radius * np.cos(theta), "tau": radius * np.sin(theta)})

# Input stress points A and B
df_input = pd.DataFrame(
    {
        "sigma": [sigma_x, sigma_y],
        "tau": [tau_xy, -tau_xy],
        "category": [CAT_INPUT, CAT_INPUT],
        "label": [f"A (σx={sigma_x:.0f}, τxy={tau_xy:.0f})", f"B (σy={sigma_y:.0f}, −τxy={-tau_xy:.0f})"],
        "detail": [
            f"Normal: {sigma_x:.0f} MPa | Shear: {tau_xy:.0f} MPa",
            f"Normal: {sigma_y:.0f} MPa | Shear: {-tau_xy:.0f} MPa",
        ],
    }
)

# Principal stress points (on the horizontal axis)
df_principal = pd.DataFrame(
    {
        "sigma": [sigma_1, sigma_2],
        "tau": [0.0, 0.0],
        "category": [CAT_PRINCIPAL, CAT_PRINCIPAL],
        "label": [f"σ₁ = {sigma_1:.1f}", f"σ₂ = {sigma_2:.1f}"],
        "detail": [f"Max principal: {sigma_1:.1f} MPa", f"Min principal: {sigma_2:.1f} MPa"],
    }
)

# Maximum shear stress points (top and bottom of circle)
df_shear = pd.DataFrame(
    {
        "sigma": [center, center],
        "tau": [tau_max, -tau_max],
        "category": [CAT_SHEAR, CAT_SHEAR],
        "label": [f"τmax = {tau_max:.1f}", f"−τmax = {tau_max:.1f}"],
        "detail": [f"Max shear: {tau_max:.1f} MPa", f"Min shear: {-tau_max:.1f} MPa"],
    }
)

# Combined points for legend-mapped layer (Input, Principal, Max Shear)
df_legend_points = pd.concat([df_input, df_principal, df_shear], ignore_index=True)

# Center point (separate layer — different size, no legend entry needed)
df_center = pd.DataFrame(
    {
        "sigma": [center],
        "tau": [0.0],
        "label": [f"C ({center:.0f}, 0)"],
        "detail": [f"Mean stress: {center:.1f} MPa | Radius: {radius:.1f} MPa"],
    }
)

# Reference lines through center
pad = radius * 0.45
df_hline = pd.DataFrame({"sigma": [center - radius - pad, center + radius + pad], "tau": [0.0, 0.0]})
df_vline = pd.DataFrame({"sigma": [center, center], "tau": [-radius - pad, radius + pad]})
df_diameter = pd.DataFrame({"sigma": [sigma_x, sigma_y], "tau": [tau_xy, -tau_xy]})

# Angle arc for 2θp (principal plane angle)
arc_r = radius * 0.35
arc_t = np.linspace(0, np.radians(2 * theta_p), 50)
df_arc = pd.DataFrame({"sigma": center + arc_r * np.cos(arc_t), "tau": arc_r * np.sin(arc_t)})

# Theme-adaptive circle fill alpha — slightly stronger on dark for interior visibility
circle_fill_alpha = 0.12 if THEME == "dark" else 0.07

# Title
title = "mohr-circle · python · letsplot · anyplot.ai"

# Build plot
plot = (
    ggplot()
    # Reference lines through center
    + geom_line(data=df_hline, mapping=aes(x="sigma", y="tau"), color=INK_MUTED, size=0.6, linetype="dashed")
    + geom_line(data=df_vline, mapping=aes(x="sigma", y="tau"), color=INK_MUTED, size=0.6, linetype="dashed")
    # Circle fill (subtle, theme-adaptive) then solid outline
    + geom_polygon(
        data=df_circle, mapping=aes(x="sigma", y="tau"), fill=C_CIRCLE, color=C_CIRCLE, alpha=circle_fill_alpha, size=0
    )
    + geom_path(data=df_circle, mapping=aes(x="sigma", y="tau"), color=C_CIRCLE, size=2.0, alpha=0.9)
    # Diameter line A–B
    + geom_line(data=df_diameter, mapping=aes(x="sigma", y="tau"), color=INK_MUTED, size=0.8, linetype="dashed")
    # Principal plane angle arc
    + geom_path(data=df_arc, mapping=aes(x="sigma", y="tau"), color=C_SHEAR, size=1.5)
    # Stress points with mapped color + shape — drives the built-in legend panel
    + geom_point(
        data=df_legend_points,
        mapping=aes(x="sigma", y="tau", color="category", shape="category"),
        size=7,
        tooltips=layer_tooltips().line("@label").line("@detail"),
    )
    + scale_color_manual(values={CAT_INPUT: C_INPUT, CAT_PRINCIPAL: C_PRINCIPAL, CAT_SHEAR: C_SHEAR}, name="")
    + scale_shape_manual(values={CAT_INPUT: 16, CAT_PRINCIPAL: 18, CAT_SHEAR: 17}, name="")
    # Center marker (X cross — fixed aesthetics, separate layer)
    + geom_point(
        data=df_center,
        mapping=aes(x="sigma", y="tau"),
        color=INK_SOFT,
        size=5,
        shape=4,
        tooltips=layer_tooltips().line("@label").line("@detail"),
    )
    # Point A label — nudged right and up
    + geom_text(
        data=df_input.iloc[[0]],
        mapping=aes(x="sigma", y="tau", label="label"),
        color=C_INPUT,
        size=5,
        hjust=0,
        nudge_x=radius * 0.05,
        nudge_y=radius * 0.10,
    )
    # Point B label — nudged left and down
    + geom_text(
        data=df_input.iloc[[1]],
        mapping=aes(x="sigma", y="tau", label="label"),
        color=C_INPUT,
        size=5,
        hjust=1,
        nudge_x=-radius * 0.05,
        nudge_y=-radius * 0.14,
    )
    # σ₁ label — below the point
    + geom_text(
        data=df_principal.iloc[[0]],
        mapping=aes(x="sigma", y="tau", label="label"),
        color=C_PRINCIPAL,
        size=5,
        hjust=0.5,
        nudge_y=-radius * 0.14,
    )
    # σ₂ label — nudged upward (above x-axis) to clear the B label in the lower-left
    + geom_text(
        data=df_principal.iloc[[1]],
        mapping=aes(x="sigma", y="tau", label="label"),
        color=C_PRINCIPAL,
        size=5,
        hjust=0.5,
        nudge_y=radius * 0.18,
    )
    # τmax label — nudged right, above the top marker
    + geom_text(
        data=df_shear.iloc[[0]],
        mapping=aes(x="sigma", y="tau", label="label"),
        color=C_SHEAR,
        size=5,
        hjust=0,
        nudge_x=radius * 0.12,
        nudge_y=radius * 0.07,
    )
    # −τmax label
    + geom_text(
        data=df_shear.iloc[[1]],
        mapping=aes(x="sigma", y="tau", label="label"),
        color=C_SHEAR,
        size=5,
        hjust=0,
        nudge_x=radius * 0.12,
        nudge_y=-radius * 0.07,
    )
    # Principal plane angle annotation
    + geom_text(
        data=pd.DataFrame(
            {
                "sigma": [center + arc_r * 1.5 * np.cos(np.radians(theta_p))],
                "tau": [arc_r * 1.5 * np.sin(np.radians(theta_p))],
                "label": [f"2θp = {2 * theta_p:.1f}°"],
            }
        ),
        mapping=aes(x="sigma", y="tau", label="label"),
        color=C_SHEAR,
        size=5,
        hjust=0,
    )
    # Center label
    + geom_text(
        data=df_center, mapping=aes(x="sigma", y="tau", label="label"), color=INK_SOFT, size=5, nudge_y=-radius * 0.13
    )
    # Axes, title, subtitle
    + labs(
        x="Normal Stress σ (MPa)",
        y="Shear Stress τ (MPa)",
        title=title,
        subtitle=(
            f"σx = {sigma_x:.0f}, σy = {sigma_y:.0f},"
            f" τxy = {tau_xy:.0f} MPa"
            f" | σ₁ = {sigma_1:.1f}, σ₂ = {sigma_2:.1f},"
            f" τmax = {tau_max:.1f} MPa"
        ),
    )
    + coord_fixed()
    + theme_minimal()
    + theme(
        plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),
        panel_background=element_rect(fill=PAGE_BG),
        panel_grid_major=element_line(color=INK_MUTED, size=0.3),
        panel_grid_minor=element_blank(),
        axis_title=element_text(size=12, color=INK),
        axis_text=element_text(size=10, color=INK_SOFT),
        axis_line=element_line(color=INK_SOFT),
        plot_title=element_text(size=16, face="bold", color=INK),
        plot_subtitle=element_text(size=10, face="italic", color=INK_SOFT),
        legend_background=element_rect(fill=ELEVATED_BG, color=INK_SOFT),
        legend_text=element_text(color=INK_SOFT),
        legend_title=element_text(color=INK),
    )
    + ggsize(600, 600)
)

# Save
ggsave(plot, f"plot-{THEME}.png", scale=4, path=".")
ggsave(plot, f"plot-{THEME}.html", path=".")

Part of Mohr's Circle for Stress Analysis on anyplot.ai.

Other implementations