Skip to content

Commit 6a8ff7a

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

2 files changed

Lines changed: 527 additions & 0 deletions

File tree

Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
""" pyplots.ai
2+
campbell-basic: Campbell Diagram
3+
Library: bokeh 3.8.2 | Python 3.14.3
4+
Quality: 95/100 | Created: 2026-02-15
5+
"""
6+
7+
import numpy as np
8+
from bokeh.io import export_png, output_file, save
9+
from bokeh.models import BoxAnnotation, ColumnDataSource, HoverTool, Label, Legend, LegendItem, Range1d, Span
10+
from bokeh.plotting import figure
11+
12+
13+
# Data
14+
speeds = np.linspace(0, 6000, 100)
15+
16+
# Natural frequency modes (Hz) with pronounced rotordynamic behavior
17+
mode_1_bending = 25 + 0.008 * speeds + 3.5 * np.sin(speeds / 2800 * np.pi)
18+
mode_2_bending = 62 - 0.006 * speeds + 2.0 * np.sin(speeds / 2200 * np.pi)
19+
mode_1_torsional = 85 + 0.005 * speeds
20+
mode_axial = 110 - 0.004 * speeds + 2.5 * np.cos(speeds / 3200 * np.pi)
21+
mode_3_bending = 130 + 0.010 * speeds - 4.0 * np.cos(speeds / 2600 * np.pi)
22+
23+
modes = {
24+
"1st Bending": mode_1_bending,
25+
"2nd Bending": mode_2_bending,
26+
"1st Torsional": mode_1_torsional,
27+
"Axial": mode_axial,
28+
"3rd Bending": mode_3_bending,
29+
}
30+
31+
# Engine order lines: frequency = order * speed / 60
32+
engine_orders = [1, 2, 3]
33+
eo_frequencies = {order: order * speeds / 60 for order in engine_orders}
34+
35+
# Find critical speed intersections via sign-change interpolation
36+
critical_speeds_rpm = []
37+
critical_speeds_freq = []
38+
critical_speed_labels = []
39+
critical_in_operating = []
40+
41+
for order in engine_orders:
42+
eo_freq = eo_frequencies[order]
43+
for mode_name, mode_freq in modes.items():
44+
diff = eo_freq - mode_freq
45+
sign_changes = np.where(np.diff(np.sign(diff)))[0]
46+
for idx in sign_changes:
47+
denom = abs(diff[idx]) + abs(diff[idx + 1])
48+
if denom == 0:
49+
continue
50+
frac = abs(diff[idx]) / denom
51+
rpm_val = speeds[idx] + frac * (speeds[idx + 1] - speeds[idx])
52+
freq_val = mode_freq[idx] + frac * (mode_freq[idx + 1] - mode_freq[idx])
53+
if 100 < rpm_val < 5900 and 5 < freq_val < 195:
54+
critical_speeds_rpm.append(rpm_val)
55+
critical_speeds_freq.append(freq_val)
56+
critical_speed_labels.append(f"{order}x × {mode_name}")
57+
critical_in_operating.append(3000 <= rpm_val <= 5000)
58+
59+
# Compute y-range
60+
all_freqs = np.concatenate(list(modes.values()))
61+
y_max_data = max(np.max(all_freqs), max(critical_speeds_freq) if critical_speeds_freq else 0)
62+
y_max = min(int(np.ceil(y_max_data / 10) * 10) + 15, 200)
63+
64+
# Plot
65+
p = figure(
66+
width=4800,
67+
height=2700,
68+
title="campbell-basic · bokeh · pyplots.ai",
69+
x_axis_label="Rotational Speed (RPM)",
70+
y_axis_label="Frequency (Hz)",
71+
x_range=Range1d(-100, 6300),
72+
y_range=Range1d(0, y_max),
73+
)
74+
75+
# Operating range shading (3000–5000 RPM)
76+
operating_zone = BoxAnnotation(
77+
left=3000,
78+
right=5000,
79+
fill_color="#306998",
80+
fill_alpha=0.06,
81+
line_color="#306998",
82+
line_alpha=0.3,
83+
line_dash="dotted",
84+
line_width=2,
85+
)
86+
p.add_layout(operating_zone)
87+
88+
# Operating range boundary lines for crisp delineation
89+
for rpm_boundary in [3000, 5000]:
90+
boundary_line = Span(
91+
location=rpm_boundary,
92+
dimension="height",
93+
line_color="#306998",
94+
line_alpha=0.35,
95+
line_width=2,
96+
line_dash="dashed",
97+
)
98+
p.add_layout(boundary_line)
99+
100+
# Operating range label
101+
op_label = Label(
102+
x=4000,
103+
y=y_max * 0.04,
104+
text="Operating Range (3000–5000 RPM)",
105+
text_font_size="18pt",
106+
text_color="#306998",
107+
text_alpha=0.8,
108+
text_align="center",
109+
text_font_style="bold italic",
110+
)
111+
p.add_layout(op_label)
112+
113+
# Danger zone highlighting around critical speeds within operating range
114+
for rpm_val, freq_val, in_op in zip(critical_speeds_rpm, critical_speeds_freq, critical_in_operating, strict=True):
115+
if in_op:
116+
danger = BoxAnnotation(
117+
left=rpm_val - 120,
118+
right=rpm_val + 120,
119+
bottom=freq_val - 4,
120+
top=freq_val + 4,
121+
fill_color="#C44E52",
122+
fill_alpha=0.10,
123+
line_color="#C44E52",
124+
line_alpha=0.25,
125+
line_width=1,
126+
)
127+
p.add_layout(danger)
128+
129+
# Natural frequency mode colors — distinct, colorblind-safe palette
130+
# Python Blue, amber, rose, violet, teal — all perceptually distinct
131+
mode_colors = ["#306998", "#E8A838", "#C44E52", "#7A68A6", "#48A9A6"]
132+
133+
# Plot natural frequency curves
134+
legend_items = []
135+
for i, (mode_name, mode_freq) in enumerate(modes.items()):
136+
source = ColumnDataSource(data={"speed": speeds, "freq": mode_freq})
137+
line = p.line(x="speed", y="freq", source=source, line_width=4, line_color=mode_colors[i], line_alpha=0.9)
138+
legend_items.append(LegendItem(label=mode_name, renderers=[line]))
139+
140+
# Engine order lines (lighter, thinner to reduce visual clutter)
141+
eo_color = "#666666"
142+
143+
for order in engine_orders:
144+
eo_freq = eo_frequencies[order]
145+
mask = eo_freq <= y_max
146+
clipped_speeds = speeds[mask]
147+
clipped_freq = eo_freq[mask]
148+
149+
source = ColumnDataSource(data={"speed": clipped_speeds, "freq": clipped_freq})
150+
line = p.line(
151+
x="speed", y="freq", source=source, line_width=2, line_color=eo_color, line_dash=[12, 8], line_alpha=0.6
152+
)
153+
legend_items.append(LegendItem(label=f"{order}x EO", renderers=[line]))
154+
155+
# Engine order labels — positioned at right edge, offset to avoid crowding
156+
for order in engine_orders:
157+
freq_at_max = order * 6000 / 60
158+
if freq_at_max > y_max - 5:
159+
label_rpm = (y_max - 12) * 60 / order
160+
label_freq = y_max - 10
161+
else:
162+
label_rpm = 5850
163+
label_freq = freq_at_max
164+
165+
label = Label(
166+
x=label_rpm,
167+
y=label_freq,
168+
text=f" {order}x",
169+
text_font_size="20pt",
170+
text_color="#555555",
171+
text_font_style="bold",
172+
text_baseline="middle",
173+
)
174+
p.add_layout(label)
175+
176+
# Critical speed markers — dark orange, clearly distinct from crimson mode lines
177+
if critical_speeds_rpm:
178+
crit_source = ColumnDataSource(
179+
data={
180+
"rpm": critical_speeds_rpm,
181+
"freq": critical_speeds_freq,
182+
"label": critical_speed_labels,
183+
"rpm_display": [f"{r:.0f}" for r in critical_speeds_rpm],
184+
"freq_display": [f"{f:.1f}" for f in critical_speeds_freq],
185+
"in_operating": ["YES — CAUTION" if op else "No" for op in critical_in_operating],
186+
}
187+
)
188+
crit_scatter = p.scatter(
189+
x="rpm",
190+
y="freq",
191+
source=crit_source,
192+
marker="diamond",
193+
size=28,
194+
fill_color="#E65100",
195+
line_color="#FFFFFF",
196+
line_width=2.5,
197+
fill_alpha=0.9,
198+
)
199+
legend_items.append(LegendItem(label="Critical Speed", renderers=[crit_scatter]))
200+
201+
# HoverTool with enriched tooltips (Bokeh distinctive feature)
202+
hover = HoverTool(
203+
renderers=[crit_scatter],
204+
tooltips=[
205+
("Intersection", "@label"),
206+
("RPM", "@rpm_display"),
207+
("Frequency", "@freq_display Hz"),
208+
("In Operating Range?", "@in_operating"),
209+
],
210+
mode="mouse",
211+
)
212+
p.add_tools(hover)
213+
214+
# Annotate the most critical intersection in the operating range
215+
op_crits = [
216+
(r, f, lbl)
217+
for r, f, lbl, op in zip(
218+
critical_speeds_rpm, critical_speeds_freq, critical_speed_labels, critical_in_operating, strict=True
219+
)
220+
if op
221+
]
222+
if op_crits:
223+
op_crits.sort(key=lambda x: x[1])
224+
worst_rpm, worst_freq, worst_label = op_crits[0]
225+
annotation = Label(
226+
x=worst_rpm + 280,
227+
y=worst_freq + 8,
228+
text=f"⚠ {worst_label} @ {worst_rpm:.0f} RPM",
229+
text_font_size="15pt",
230+
text_color="#E65100",
231+
text_font_style="bold",
232+
text_alpha=0.85,
233+
)
234+
p.add_layout(annotation)
235+
236+
# Legend — positioned top-left, compact to minimize data overlap
237+
legend = Legend(
238+
items=legend_items,
239+
location="top_left",
240+
label_text_font_size="16pt",
241+
glyph_width=50,
242+
glyph_height=26,
243+
spacing=10,
244+
padding=16,
245+
background_fill_color="#FFFFFF",
246+
background_fill_alpha=0.88,
247+
border_line_color="#CCCCCC",
248+
border_line_alpha=0.25,
249+
border_line_width=1,
250+
)
251+
p.add_layout(legend)
252+
253+
# Title styling
254+
p.title.text_font_size = "28pt"
255+
p.title.align = "center"
256+
p.title.text_color = "#222222"
257+
258+
# Axis styling
259+
p.xaxis.axis_label_text_font_size = "22pt"
260+
p.yaxis.axis_label_text_font_size = "22pt"
261+
p.xaxis.major_label_text_font_size = "18pt"
262+
p.yaxis.major_label_text_font_size = "18pt"
263+
p.xaxis.axis_label_text_color = "#333333"
264+
p.yaxis.axis_label_text_color = "#333333"
265+
p.xaxis.major_label_text_color = "#555555"
266+
p.yaxis.major_label_text_color = "#555555"
267+
p.xaxis.axis_line_color = "#BBBBBB"
268+
p.yaxis.axis_line_color = "#BBBBBB"
269+
p.xaxis.major_tick_line_color = "#BBBBBB"
270+
p.yaxis.major_tick_line_color = "#BBBBBB"
271+
p.xaxis.minor_tick_line_color = None
272+
p.yaxis.minor_tick_line_color = None
273+
274+
# Grid styling (subtle, refined)
275+
p.xgrid.grid_line_color = "#E0E0E0"
276+
p.xgrid.grid_line_alpha = 0.4
277+
p.ygrid.grid_line_color = "#E0E0E0"
278+
p.ygrid.grid_line_alpha = 0.4
279+
280+
# Background and frame
281+
p.background_fill_color = "#FAFAFA"
282+
p.border_fill_color = "#FFFFFF"
283+
p.outline_line_color = None
284+
285+
# Save
286+
export_png(p, filename="plot.png")
287+
288+
output_file("plot.html")
289+
save(p)

0 commit comments

Comments
 (0)