|
1 | 1 | """ anyplot.ai |
2 | 2 | sn-curve-basic: S-N Curve (Wöhler Curve) |
3 | 3 | Library: plotly 6.7.0 | Python 3.13.13 |
4 | | -Quality: 80/100 | Updated: 2026-05-20 |
| 4 | +Quality: 89/100 | Updated: 2026-05-20 |
5 | 5 | """ |
6 | 6 |
|
| 7 | +import os |
| 8 | + |
7 | 9 | import numpy as np |
8 | 10 | import plotly.graph_objects as go |
9 | 11 |
|
10 | 12 |
|
11 | | -# Data: Simulated fatigue test results for steel |
| 13 | +# Theme |
| 14 | +THEME = os.getenv("ANYPLOT_THEME", "light") |
| 15 | +PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17" |
| 16 | +INK = "#1A1A17" if THEME == "light" else "#F0EFE8" |
| 17 | +INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0" |
| 18 | +GRID = "rgba(26,26,23,0.10)" if THEME == "light" else "rgba(240,239,232,0.10)" |
| 19 | + |
| 20 | +# Okabe-Ito palette |
| 21 | +BRAND = "#009E73" # position 1 — Basquin fit line |
| 22 | +C2 = "#D55E00" # position 2 — test data markers |
| 23 | +C3 = "#0072B2" # position 3 — ultimate strength |
| 24 | +C4 = "#CC79A7" # position 4 — yield strength |
| 25 | +C5 = "#E69F00" # position 5 — endurance limit |
| 26 | + |
| 27 | +# Data: Steel fatigue test data (Basquin model) |
12 | 28 | np.random.seed(42) |
13 | 29 |
|
14 | | -# Generate realistic S-N curve data with scatter |
15 | | -# Basquin equation: S = A * N^b (where b is negative) |
16 | | -A = 1200 # Material constant (MPa) |
| 30 | +A = 1200 # Fatigue coefficient (MPa) |
17 | 31 | b = -0.12 # Fatigue strength exponent |
18 | 32 |
|
19 | | -# Generate stress levels and corresponding cycles with scatter |
20 | 33 | stress_levels = np.array([600, 550, 500, 450, 400, 350, 320, 300, 280, 260, 250, 240]) |
21 | 34 | cycles_base = (stress_levels / A) ** (1 / b) |
22 | 35 |
|
23 | | -# Add scatter (multiple specimens at each stress level) |
24 | 36 | cycles = [] |
25 | 37 | stress = [] |
26 | 38 | for s, n_base in zip(stress_levels, cycles_base, strict=False): |
27 | | - n_samples = np.random.randint(2, 5) # 2-4 specimens per stress level |
28 | | - scatter = 10 ** (np.random.normal(0, 0.15, n_samples)) # Log-normal scatter |
| 39 | + n_samples = np.random.randint(2, 5) |
| 40 | + scatter = 10 ** (np.random.normal(0, 0.15, n_samples)) |
29 | 41 | for factor in scatter: |
30 | 42 | cycles.append(n_base * factor) |
31 | 43 | stress.append(s) |
32 | 44 |
|
33 | 45 | cycles = np.array(cycles) |
34 | 46 | stress = np.array(stress) |
35 | 47 |
|
36 | | -# Material properties for reference lines |
| 48 | +# Material properties |
37 | 49 | ultimate_strength = 650 # MPa |
38 | 50 | yield_strength = 450 # MPa |
39 | 51 | endurance_limit = 230 # MPa |
40 | 52 |
|
41 | | -# Fit line data (smooth curve) |
42 | | -fit_cycles = np.logspace(2, 8, 100) |
| 53 | +# Basquin fit line |
| 54 | +fit_cycles = np.logspace(2, 7, 100) |
43 | 55 | fit_stress = A * fit_cycles**b |
44 | 56 |
|
45 | | -# Create figure |
| 57 | +# Plot |
46 | 58 | fig = go.Figure() |
47 | 59 |
|
48 | | -# Add S-N curve fit line |
49 | 60 | fig.add_trace( |
50 | | - go.Scatter(x=fit_cycles, y=fit_stress, mode="lines", name="Basquin Fit", line={"color": "#306998", "width": 4}) |
| 61 | + go.Scatter( |
| 62 | + x=fit_cycles, |
| 63 | + y=fit_stress, |
| 64 | + mode="lines", |
| 65 | + name="Basquin Fit", |
| 66 | + line={"color": BRAND, "width": 3}, |
| 67 | + hovertemplate="Cycles: %{x:.2e}<br>Stress: %{y:.0f} MPa<extra>Basquin Fit</extra>", |
| 68 | + ) |
51 | 69 | ) |
52 | 70 |
|
53 | | -# Add fatigue test data points |
54 | 71 | fig.add_trace( |
55 | 72 | go.Scatter( |
56 | 73 | x=cycles, |
57 | 74 | y=stress, |
58 | 75 | mode="markers", |
59 | 76 | name="Test Data", |
60 | | - marker={"color": "#FFD43B", "size": 14, "line": {"color": "#306998", "width": 2}}, |
| 77 | + marker={"color": C2, "size": 12, "line": {"color": PAGE_BG, "width": 1.5}}, |
| 78 | + hovertemplate="Cycles: %{x:.2e}<br>Stress: %{y:.0f} MPa<extra>Test Data</extra>", |
61 | 79 | ) |
62 | 80 | ) |
63 | 81 |
|
64 | | -# Add horizontal reference lines as traces for legend visibility |
65 | | -x_range = [100, 1e8] |
| 82 | +x_range = [100, 1e7] |
| 83 | + |
| 84 | +fig.add_hrect(y0=200, y1=endurance_limit, opacity=0.05, fillcolor=BRAND, layer="below") |
66 | 85 |
|
67 | 86 | fig.add_trace( |
68 | 87 | go.Scatter( |
69 | 88 | x=x_range, |
70 | 89 | y=[ultimate_strength, ultimate_strength], |
71 | 90 | mode="lines", |
72 | 91 | name=f"Ultimate Strength ({ultimate_strength} MPa)", |
73 | | - line={"color": "#D62728", "width": 3, "dash": "dash"}, |
| 92 | + line={"color": C3, "width": 2, "dash": "dash"}, |
| 93 | + hovertemplate=f"Ultimate Strength: {ultimate_strength} MPa<extra></extra>", |
74 | 94 | ) |
75 | 95 | ) |
76 | 96 |
|
|
80 | 100 | y=[yield_strength, yield_strength], |
81 | 101 | mode="lines", |
82 | 102 | name=f"Yield Strength ({yield_strength} MPa)", |
83 | | - line={"color": "#2CA02C", "width": 3, "dash": "dash"}, |
| 103 | + line={"color": C4, "width": 2, "dash": "dash"}, |
| 104 | + hovertemplate=f"Yield Strength: {yield_strength} MPa<extra></extra>", |
84 | 105 | ) |
85 | 106 | ) |
86 | 107 |
|
|
90 | 111 | y=[endurance_limit, endurance_limit], |
91 | 112 | mode="lines", |
92 | 113 | name=f"Endurance Limit ({endurance_limit} MPa)", |
93 | | - line={"color": "#9467BD", "width": 3, "dash": "dash"}, |
| 114 | + line={"color": C5, "width": 2, "dash": "dash"}, |
| 115 | + hovertemplate=f"Endurance Limit: {endurance_limit} MPa<extra></extra>", |
94 | 116 | ) |
95 | 117 | ) |
96 | 118 |
|
97 | | -# Update layout |
| 119 | +# Style |
98 | 120 | fig.update_layout( |
99 | | - title={"text": "sn-curve-basic · plotly · pyplots.ai", "font": {"size": 32}, "x": 0.5, "xanchor": "center"}, |
| 121 | + autosize=False, |
| 122 | + paper_bgcolor=PAGE_BG, |
| 123 | + plot_bgcolor=PAGE_BG, |
| 124 | + hovermode="closest", |
| 125 | + title={ |
| 126 | + "text": "sn-curve-basic · python · plotly · anyplot.ai", |
| 127 | + "font": {"size": 16, "color": INK}, |
| 128 | + "x": 0.5, |
| 129 | + "xanchor": "center", |
| 130 | + }, |
100 | 131 | xaxis={ |
101 | | - "title": {"text": "Cycles to Failure (N)", "font": {"size": 24}}, |
102 | | - "tickfont": {"size": 18}, |
| 132 | + "title": {"text": "Cycles to Failure (N)", "font": {"size": 12, "color": INK}}, |
| 133 | + "tickfont": {"size": 10, "color": INK_SOFT}, |
103 | 134 | "type": "log", |
104 | | - "showgrid": True, |
105 | | - "gridwidth": 1, |
106 | | - "gridcolor": "rgba(0,0,0,0.1)", |
| 135 | + "showgrid": False, |
107 | 136 | "showline": True, |
108 | | - "linewidth": 2, |
109 | | - "linecolor": "black", |
110 | | - "range": [2, 8], # 10^2 to 10^8 |
| 137 | + "linewidth": 1, |
| 138 | + "linecolor": INK_SOFT, |
| 139 | + "mirror": False, |
| 140 | + "range": [2, 7], |
| 141 | + "zerolinecolor": GRID, |
111 | 142 | }, |
112 | 143 | yaxis={ |
113 | | - "title": {"text": "Stress Amplitude (MPa)", "font": {"size": 24}}, |
114 | | - "tickfont": {"size": 18}, |
| 144 | + "title": {"text": "Stress Amplitude (MPa)", "font": {"size": 12, "color": INK}}, |
| 145 | + "tickfont": {"size": 10, "color": INK_SOFT}, |
115 | 146 | "type": "log", |
116 | 147 | "showgrid": True, |
117 | 148 | "gridwidth": 1, |
118 | | - "gridcolor": "rgba(0,0,0,0.1)", |
| 149 | + "gridcolor": GRID, |
119 | 150 | "showline": True, |
120 | | - "linewidth": 2, |
121 | | - "linecolor": "black", |
122 | | - "range": [2.3, 2.9], # ~200 to ~800 MPa |
| 151 | + "linewidth": 1, |
| 152 | + "linecolor": INK_SOFT, |
| 153 | + "mirror": False, |
| 154 | + "range": [2.3, 2.9], |
| 155 | + "zerolinecolor": GRID, |
123 | 156 | }, |
124 | 157 | legend={ |
125 | | - "font": {"size": 18}, |
| 158 | + "font": {"size": 10, "color": INK_SOFT}, |
126 | 159 | "x": 0.95, |
127 | 160 | "y": 0.95, |
128 | 161 | "xanchor": "right", |
129 | 162 | "yanchor": "top", |
130 | | - "bgcolor": "rgba(255,255,255,0.8)", |
131 | | - "bordercolor": "black", |
132 | | - "borderwidth": 1, |
| 163 | + "bgcolor": "rgba(0,0,0,0)", |
| 164 | + "borderwidth": 0, |
133 | 165 | }, |
134 | | - template="plotly_white", |
135 | | - margin={"l": 100, "r": 150, "t": 100, "b": 100}, |
| 166 | + margin={"l": 80, "r": 40, "t": 80, "b": 60}, |
136 | 167 | ) |
137 | 168 |
|
138 | | -# Save as PNG (4800 x 2700 px) |
139 | | -fig.write_image("plot.png", width=1600, height=900, scale=3) |
140 | | - |
141 | | -# Save as HTML for interactive viewing |
142 | | -fig.write_html("plot.html") |
| 169 | +# Save |
| 170 | +fig.write_image(f"plot-{THEME}.png", width=800, height=450, scale=4) |
| 171 | +fig.write_html(f"plot-{THEME}.html", include_plotlyjs="cdn") |
0 commit comments