Skip to content

Commit edf47c7

Browse files
feat(plotly): implement campbell-basic (#4244)
## Implementation: `campbell-basic` - plotly Implements the **plotly** version of `campbell-basic`. **File:** `plots/campbell-basic/implementations/plotly.py` **Parent Issue:** #4241 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/pyplots/actions/runs/22043026911)* --------- 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 90fc2cc commit edf47c7

2 files changed

Lines changed: 528 additions & 0 deletions

File tree

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
""" pyplots.ai
2+
campbell-basic: Campbell Diagram
3+
Library: plotly 6.5.2 | Python 3.14.3
4+
Quality: 88/100 | Created: 2026-02-15
5+
"""
6+
7+
import numpy as np
8+
import plotly.graph_objects as go
9+
10+
11+
# Data
12+
np.random.seed(42)
13+
speed_rpm = np.linspace(0, 6000, 80)
14+
speed_hz = speed_rpm / 60
15+
16+
# Natural frequency modes (Hz) — enhanced gyroscopic effects for realistic variation
17+
mode_1_bending = 22 + 0.004 * speed_rpm + np.sin(speed_rpm / 1200) * 1.2 # forward whirl stiffening
18+
mode_2_bending = 48 - 0.003 * speed_rpm + np.cos(speed_rpm / 1800) * 1.4 # backward whirl softening
19+
mode_1_torsional = 55 + 0.0004 * speed_rpm # torsional: nearly speed-independent
20+
mode_axial = 75 - 0.004 * speed_rpm + np.sin(speed_rpm / 1000) * 2.0 # axial with bearing coupling
21+
22+
# Engine order lines: frequency = order * speed_rpm / 60
23+
orders = [1, 2, 3]
24+
order_freq = {order: order * speed_hz for order in orders}
25+
26+
# Find critical speed intersections (engine order line crosses natural frequency curve)
27+
modes = {
28+
"1st Bending": mode_1_bending,
29+
"2nd Bending": mode_2_bending,
30+
"1st Torsional": mode_1_torsional,
31+
"Axial": mode_axial,
32+
}
33+
34+
critical_speeds = []
35+
critical_freqs = []
36+
critical_labels = []
37+
for order in orders:
38+
eo_freq = order_freq[order]
39+
for mode_name, mode_freq in modes.items():
40+
diff = mode_freq - eo_freq
41+
sign_changes = np.where(np.diff(np.sign(diff)))[0]
42+
for idx in sign_changes:
43+
frac = abs(diff[idx]) / (abs(diff[idx]) + abs(diff[idx + 1]))
44+
crit_rpm = speed_rpm[idx] + frac * (speed_rpm[idx + 1] - speed_rpm[idx])
45+
crit_hz = crit_rpm / 60
46+
crit_freq = order * crit_hz
47+
critical_speeds.append(crit_rpm)
48+
critical_freqs.append(crit_freq)
49+
critical_labels.append(f"{mode_name} × {order}x")
50+
51+
# Colorblind-safe palette (avoids red-green confusion)
52+
mode_colors = ["#306998", "#D4A017", "#9467BD", "#E07B39"]
53+
eo_color = "#7F7F7F"
54+
critical_color = "#C44E52"
55+
56+
fig = go.Figure()
57+
58+
# Track trace count for dynamic visibility toggling
59+
trace_count_before_modes = 0
60+
61+
# Shaded bands at critical speed zones
62+
for cs_rpm in critical_speeds:
63+
fig.add_vrect(x0=cs_rpm - 80, x1=cs_rpm + 80, fillcolor="rgba(196, 78, 82, 0.10)", line_width=0, layer="below")
64+
65+
# Natural frequency curves with distinct dash patterns per mode type
66+
line_dashes = ["solid", "dash", "dot", "dashdot"]
67+
mode_trace_start = len(fig.data)
68+
for i, (mode_name, mode_freq) in enumerate(modes.items()):
69+
fig.add_trace(
70+
go.Scatter(
71+
x=speed_rpm,
72+
y=mode_freq,
73+
mode="lines",
74+
name=mode_name,
75+
line={"color": mode_colors[i], "width": 3.5, "dash": line_dashes[i]},
76+
hovertemplate=f"<b>{mode_name}</b><br>Speed: %{{x:.0f}} RPM<br>Freq: %{{y:.1f}} Hz<extra></extra>",
77+
)
78+
)
79+
n_mode_traces = len(fig.data) - mode_trace_start
80+
81+
# Engine order lines (clipped to visible y-axis range)
82+
y_max = 110
83+
eo_trace_start = len(fig.data)
84+
for order in orders:
85+
label = f"{order}x"
86+
eo_y = order_freq[order]
87+
mask = eo_y <= y_max
88+
fig.add_trace(
89+
go.Scatter(
90+
x=speed_rpm[mask],
91+
y=eo_y[mask],
92+
mode="lines",
93+
name=f"EO {label}",
94+
line={"color": eo_color, "width": 2.4, "dash": "dash"},
95+
hovertemplate=f"<b>EO {label}</b><br>Speed: %{{x:.0f}} RPM<br>Freq: %{{y:.1f}} Hz<extra></extra>",
96+
)
97+
)
98+
n_eo_traces = len(fig.data) - eo_trace_start
99+
100+
# Engine order labels placed within the visible plot area
101+
for order in orders:
102+
label = f"{order}x"
103+
eo_y = order_freq[order]
104+
visible_mask = eo_y <= y_max
105+
visible_indices = np.where(visible_mask)[0]
106+
label_idx = visible_indices[int(len(visible_indices) * 0.75)]
107+
ann_x = speed_rpm[label_idx]
108+
ann_y = eo_y[label_idx]
109+
fig.add_annotation(
110+
x=ann_x,
111+
y=ann_y,
112+
text=f"<b>{label}</b>",
113+
showarrow=False,
114+
xanchor="left",
115+
yanchor="bottom",
116+
xshift=8,
117+
yshift=4,
118+
font={"size": 16, "color": eo_color, "family": "Arial, sans-serif"},
119+
bgcolor="rgba(255,255,255,0.85)",
120+
borderpad=3,
121+
)
122+
123+
# Critical speed markers with descriptive hover
124+
crit_trace_start = len(fig.data)
125+
fig.add_trace(
126+
go.Scatter(
127+
x=critical_speeds,
128+
y=critical_freqs,
129+
mode="markers",
130+
name="Critical Speed",
131+
marker={"size": 14, "color": critical_color, "symbol": "diamond", "line": {"width": 2, "color": "white"}},
132+
customdata=critical_labels,
133+
hovertemplate="<b>Critical Speed</b><br>%{customdata}<br>Speed: %{x:.0f} RPM<br>Freq: %{y:.1f} Hz<extra></extra>",
134+
)
135+
)
136+
n_crit_traces = len(fig.data) - crit_trace_start
137+
138+
# Annotate the most critical intersection (highest frequency crossing = most dangerous)
139+
if critical_speeds:
140+
max_idx = int(np.argmax(critical_freqs))
141+
fig.add_annotation(
142+
x=critical_speeds[max_idx],
143+
y=critical_freqs[max_idx],
144+
text=f"<b>⚠ {critical_freqs[max_idx]:.0f} Hz</b><br>{critical_labels[max_idx]}",
145+
showarrow=True,
146+
arrowhead=2,
147+
arrowsize=1.2,
148+
arrowcolor=critical_color,
149+
arrowwidth=2,
150+
ax=50,
151+
ay=-45,
152+
font={"size": 13, "color": critical_color, "family": "Arial, sans-serif"},
153+
bgcolor="rgba(255,255,255,0.92)",
154+
bordercolor=critical_color,
155+
borderwidth=1.5,
156+
borderpad=5,
157+
)
158+
# Annotate the lowest-RPM critical speed (first resonance encountered during run-up)
159+
min_rpm_idx = int(np.argmin(critical_speeds))
160+
if min_rpm_idx != max_idx:
161+
fig.add_annotation(
162+
x=critical_speeds[min_rpm_idx],
163+
y=critical_freqs[min_rpm_idx],
164+
text=f"<b>1st critical</b><br>{critical_speeds[min_rpm_idx]:.0f} RPM",
165+
showarrow=True,
166+
arrowhead=2,
167+
arrowsize=1.2,
168+
arrowcolor=critical_color,
169+
arrowwidth=2,
170+
ax=-55,
171+
ay=40,
172+
font={"size": 13, "color": critical_color, "family": "Arial, sans-serif"},
173+
bgcolor="rgba(255,255,255,0.92)",
174+
bordercolor=critical_color,
175+
borderwidth=1.5,
176+
borderpad=5,
177+
)
178+
179+
# Build dynamic visibility arrays for toggle buttons
180+
total_traces = len(fig.data)
181+
all_visible = [True] * total_traces
182+
modes_only = []
183+
for i in range(total_traces):
184+
if eo_trace_start <= i < eo_trace_start + n_eo_traces:
185+
modes_only.append(False)
186+
else:
187+
modes_only.append(True)
188+
189+
# Layout — compact legend, tight margins
190+
fig.update_layout(
191+
title={
192+
"text": "campbell-basic · plotly · pyplots.ai",
193+
"font": {"size": 28, "color": "#1A2A3A", "family": "Arial Black, Arial, sans-serif"},
194+
"x": 0.5,
195+
"xanchor": "center",
196+
"y": 0.97,
197+
},
198+
xaxis={
199+
"title": {
200+
"text": "Rotational Speed (RPM)",
201+
"font": {"size": 22, "color": "#333", "family": "Arial, sans-serif"},
202+
"standoff": 10,
203+
},
204+
"tickfont": {"size": 18, "color": "#444"},
205+
"showgrid": True,
206+
"gridwidth": 1,
207+
"gridcolor": "rgba(0,0,0,0.05)",
208+
"zeroline": False,
209+
"range": [0, 6100],
210+
"dtick": 1000,
211+
"showline": True,
212+
"linewidth": 1,
213+
"linecolor": "rgba(0,0,0,0.12)",
214+
"mirror": False,
215+
"spikemode": "across",
216+
"spikethickness": 1,
217+
"spikecolor": "rgba(0,0,0,0.3)",
218+
"spikedash": "dot",
219+
},
220+
yaxis={
221+
"title": {
222+
"text": "Frequency (Hz)",
223+
"font": {"size": 22, "color": "#333", "family": "Arial, sans-serif"},
224+
"standoff": 10,
225+
},
226+
"tickfont": {"size": 18, "color": "#444"},
227+
"showgrid": True,
228+
"gridwidth": 1,
229+
"gridcolor": "rgba(0,0,0,0.05)",
230+
"zeroline": False,
231+
"range": [0, y_max],
232+
"dtick": 10,
233+
"showline": True,
234+
"linewidth": 1,
235+
"linecolor": "rgba(0,0,0,0.12)",
236+
"mirror": False,
237+
"spikemode": "across",
238+
"spikethickness": 1,
239+
"spikecolor": "rgba(0,0,0,0.3)",
240+
"spikedash": "dot",
241+
},
242+
legend={
243+
"font": {"size": 14, "family": "Arial, sans-serif", "color": "#333"},
244+
"bgcolor": "rgba(255,255,255,0.92)",
245+
"bordercolor": "rgba(0,0,0,0.08)",
246+
"borderwidth": 1,
247+
"x": 0.01,
248+
"y": 0.99,
249+
"xanchor": "left",
250+
"yanchor": "top",
251+
"tracegroupgap": 1,
252+
"itemsizing": "constant",
253+
"itemwidth": 30,
254+
"orientation": "h",
255+
},
256+
template="plotly_white",
257+
margin={"l": 75, "r": 30, "t": 70, "b": 65},
258+
plot_bgcolor="white",
259+
paper_bgcolor="#F8F9FA",
260+
hoverlabel={"bgcolor": "white", "font_size": 14, "bordercolor": "#DDD"},
261+
hovermode="closest",
262+
dragmode="zoom",
263+
updatemenus=[
264+
{
265+
"type": "buttons",
266+
"direction": "left",
267+
"x": 1.0,
268+
"y": 1.05,
269+
"xanchor": "right",
270+
"yanchor": "top",
271+
"buttons": [
272+
{"label": "All Modes", "method": "update", "args": [{"visible": all_visible}]},
273+
{"label": "Modes Only", "method": "update", "args": [{"visible": modes_only}]},
274+
],
275+
"font": {"size": 12},
276+
"bgcolor": "rgba(255,255,255,0.9)",
277+
"bordercolor": "rgba(0,0,0,0.1)",
278+
}
279+
],
280+
)
281+
282+
# Save
283+
fig.write_image("plot.png", width=1600, height=900, scale=3)
284+
fig.write_html("plot.html", include_plotlyjs="cdn")

0 commit comments

Comments
 (0)