Skip to content

Commit 1d47a18

Browse files
feat(letsplot): implement line-stress-strain (#5131)
## Implementation: `line-stress-strain` - letsplot Implements the **letsplot** version of `line-stress-strain`. **File:** `plots/line-stress-strain/implementations/letsplot.py` **Parent Issue:** #4413 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/pyplots/actions/runs/23363004980)* --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 23d1031 commit 1d47a18

2 files changed

Lines changed: 470 additions & 0 deletions

File tree

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
""" pyplots.ai
2+
line-stress-strain: Engineering Stress-Strain Curve
3+
Library: letsplot 4.9.0 | Python 3.14.3
4+
Quality: 92/100 | Created: 2026-03-20
5+
"""
6+
7+
import numpy as np
8+
import pandas as pd
9+
from lets_plot import * # noqa: F403
10+
from lets_plot.export import ggsave as export_ggsave
11+
12+
13+
LetsPlot.setup_html() # noqa: F405
14+
15+
# Data - Mild steel tensile test simulation
16+
np.random.seed(42)
17+
18+
# Material properties for mild steel
19+
youngs_modulus = 210000 # MPa
20+
yield_strength = 250 # MPa
21+
uts = 400 # MPa (ultimate tensile strength)
22+
fracture_strain = 0.35
23+
uts_strain = 0.22
24+
yield_strain = yield_strength / youngs_modulus # ~0.00119
25+
26+
# Elastic region (0 to yield)
27+
n_elastic = 60
28+
strain_elastic = np.linspace(0, yield_strain, n_elastic)
29+
stress_elastic = youngs_modulus * strain_elastic
30+
31+
# Yield plateau (mild steel has a distinct yield point)
32+
n_plateau = 20
33+
strain_plateau = np.linspace(yield_strain, 0.015, n_plateau)
34+
stress_plateau = yield_strength + np.random.normal(0, 1.5, n_plateau)
35+
36+
# Strain hardening region (from end of plateau to UTS)
37+
n_hardening = 120
38+
strain_hardening = np.linspace(0.015, uts_strain, n_hardening)
39+
stress_hardening = yield_strength + (uts - yield_strength) * (
40+
1 - np.exp(-8 * (strain_hardening - 0.015) / (uts_strain - 0.015))
41+
)
42+
stress_hardening += np.random.normal(0, 1.0, n_hardening)
43+
44+
# Necking region (UTS to fracture)
45+
n_necking = 60
46+
strain_necking = np.linspace(uts_strain, fracture_strain, n_necking)
47+
stress_necking = uts - (uts - 280) * ((strain_necking - uts_strain) / (fracture_strain - uts_strain)) ** 1.5
48+
stress_necking += np.random.normal(0, 1.5, n_necking)
49+
50+
# Combine all regions
51+
strain = np.concatenate([strain_elastic, strain_plateau, strain_hardening, strain_necking])
52+
stress = np.concatenate([stress_elastic, stress_plateau, stress_hardening, stress_necking])
53+
54+
df = pd.DataFrame({"strain": strain, "stress": stress})
55+
56+
# 0.2% offset line for yield point determination
57+
offset_val = 0.002
58+
offset_line_strain = np.linspace(offset_val, offset_val + yield_strength / youngs_modulus + 0.003, 50)
59+
offset_line_stress = youngs_modulus * (offset_line_strain - offset_val)
60+
offset_line_stress = np.clip(offset_line_stress, 0, yield_strength + 30)
61+
df_offset = pd.DataFrame({"strain": offset_line_strain, "stress": offset_line_stress})
62+
63+
# Key points
64+
yield_point_strain = offset_val + yield_strength / youngs_modulus
65+
yield_point_stress = yield_strength
66+
fracture_stress = stress_necking[-1]
67+
68+
df_points = pd.DataFrame(
69+
{
70+
"strain": [yield_point_strain, uts_strain, fracture_strain],
71+
"stress": [yield_point_stress, uts, fracture_stress],
72+
"label": [f"Yield Point ({yield_strength} MPa)", f"UTS ({uts} MPa)", f"Fracture ({fracture_stress:.0f} MPa)"],
73+
"type": ["Yield", "UTS", "Fracture"],
74+
}
75+
)
76+
77+
# Consolidated annotations DataFrame
78+
df_annotations = pd.DataFrame(
79+
{
80+
"x": [yield_point_strain + 0.012, uts_strain + 0.015, fracture_strain - 0.045, 0.008, 0.007, 0.005, 0.11, 0.29],
81+
"y": [yield_point_stress + 15, uts + 10, fracture_stress - 30, 130, 60, 350, 350, 350],
82+
"label": [
83+
f"Yield Point\n({yield_strength} MPa)",
84+
f"UTS ({uts} MPa)",
85+
"Fracture",
86+
f"E = {youngs_modulus // 1000} GPa",
87+
"0.2% offset",
88+
"Elastic",
89+
"Strain Hardening",
90+
"Necking",
91+
],
92+
"group": ["yield", "uts", "fracture", "modulus", "offset", "region", "region", "region"],
93+
}
94+
)
95+
96+
# Colorblind-safe palette: blue, purple, gray (avoids orange/green pair)
97+
color_yield = "#9467BD" # purple
98+
color_uts = "#D62728" # red
99+
color_fracture = "#7F7F7F" # gray
100+
color_main = "#306998" # Python blue
101+
color_offset = "#E377C2" # pink
102+
103+
# Segment connector lines from key points to annotations (distinctive lets-plot feature)
104+
df_segments = pd.DataFrame(
105+
{
106+
"x": [yield_point_strain, uts_strain, fracture_strain],
107+
"y": [yield_point_stress, uts, fracture_stress],
108+
"xend": [yield_point_strain + 0.011, uts_strain + 0.014, fracture_strain - 0.035],
109+
"yend": [yield_point_stress + 12, uts + 8, fracture_stress - 22],
110+
}
111+
)
112+
113+
# Plot
114+
plot = (
115+
ggplot()
116+
# Region background bands using geom_rect (distinctive lets-plot feature)
117+
+ geom_rect(
118+
aes(xmin="xmin", xmax="xmax", ymin="ymin", ymax="ymax", fill="region"),
119+
data=pd.DataFrame(
120+
{
121+
"xmin": [0, 0.015, uts_strain],
122+
"xmax": [0.015, uts_strain, fracture_strain],
123+
"ymin": [0, 0, 0],
124+
"ymax": [460, 460, 460],
125+
"region": ["Elastic", "Strain Hardening", "Necking"],
126+
}
127+
),
128+
alpha=0.35,
129+
)
130+
+ scale_fill_manual(
131+
values={
132+
"Elastic": "#DAE8FC",
133+
"Strain Hardening": "#FFF2CC",
134+
"Necking": "#F8D7DA",
135+
"Yield": color_yield,
136+
"UTS": color_uts,
137+
"Fracture": color_fracture,
138+
}
139+
)
140+
# Main stress-strain curve with tooltips (distinctive lets-plot feature)
141+
+ geom_line(
142+
aes(x="strain", y="stress"),
143+
data=df,
144+
color=color_main,
145+
size=2.0,
146+
tooltips=layer_tooltips()
147+
.format("strain", ".4f")
148+
.format("stress", ".1f")
149+
.line("Strain: @strain")
150+
.line("Stress: @stress MPa"),
151+
)
152+
# 0.2% offset line
153+
+ geom_line(aes(x="strain", y="stress"), data=df_offset, color=color_offset, size=1.2, linetype="dashed")
154+
# Segment connectors from points to labels (geom_segment - distinctive feature)
155+
+ geom_segment(
156+
aes(x="x", y="y", xend="xend", yend="yend"), data=df_segments, color="#999999", size=0.6, linetype="dotted"
157+
)
158+
# Key points with tooltips (distinctive lets-plot feature)
159+
+ geom_point(
160+
aes(x="strain", y="stress", fill="type"),
161+
data=df_points,
162+
color="white",
163+
size=7,
164+
shape=21,
165+
stroke=1.2,
166+
tooltips=layer_tooltips().line("@type").line("Strain: @strain").line("Stress: @stress MPa"),
167+
)
168+
+ guides(fill="none")
169+
# Annotations - key points
170+
+ geom_text(
171+
aes(x="x", y="y", label="label"),
172+
data=df_annotations.query("group == 'yield'"),
173+
size=11,
174+
color=color_yield,
175+
hjust=0,
176+
)
177+
+ geom_text(
178+
aes(x="x", y="y", label="label"), data=df_annotations.query("group == 'uts'"), size=11, color=color_uts, hjust=0
179+
)
180+
+ geom_text(
181+
aes(x="x", y="y", label="label"),
182+
data=df_annotations.query("group == 'fracture'"),
183+
size=11,
184+
color=color_fracture,
185+
hjust=0.5,
186+
)
187+
# Elastic modulus annotation
188+
+ geom_text(
189+
aes(x="x", y="y", label="label"),
190+
data=df_annotations.query("group == 'modulus'"),
191+
size=10,
192+
color=color_main,
193+
hjust=0,
194+
fontface="italic",
195+
)
196+
# Offset label
197+
+ geom_text(
198+
aes(x="x", y="y", label="label"),
199+
data=df_annotations.query("group == 'offset'"),
200+
size=9,
201+
color=color_offset,
202+
hjust=0,
203+
fontface="italic",
204+
)
205+
# Region labels
206+
+ geom_text(
207+
aes(x="x", y="y", label="label"),
208+
data=df_annotations.query("group == 'region'"),
209+
size=12,
210+
color="#666666",
211+
fontface="italic",
212+
)
213+
# Styling
214+
+ labs(
215+
x="Engineering Strain",
216+
y="Engineering Stress (MPa)",
217+
title="line-stress-strain \u00b7 letsplot \u00b7 pyplots.ai",
218+
)
219+
+ scale_x_continuous(breaks=[0, 0.05, 0.10, 0.15, 0.20, 0.25, 0.30, 0.35])
220+
+ scale_y_continuous(breaks=[0, 50, 100, 150, 200, 250, 300, 350, 400, 450])
221+
+ ggsize(1600, 900)
222+
+ theme_minimal()
223+
+ theme(
224+
axis_text=element_text(size=16, color="#555555"),
225+
axis_title=element_text(size=20, color="#333333"),
226+
plot_title=element_text(size=24, color="#222222", face="bold"),
227+
panel_grid_major_x=element_blank(),
228+
panel_grid_major_y=element_line(color="#E0E0E0", size=0.3),
229+
panel_grid_minor=element_blank(),
230+
plot_background=element_rect(fill="#FAFAFA", color="#FAFAFA"),
231+
panel_background=element_rect(fill="transparent", color="transparent"),
232+
axis_ticks=element_blank(),
233+
axis_ticks_length=0,
234+
plot_margin=[30, 40, 20, 20],
235+
)
236+
)
237+
238+
# Save
239+
export_ggsave(plot, filename="plot.png", path=".", scale=3)
240+
export_ggsave(plot, filename="plot.html", path=".")

0 commit comments

Comments
 (0)