Skip to content

Commit 7b28e7e

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

2 files changed

Lines changed: 467 additions & 0 deletions

File tree

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
""" pyplots.ai
2+
campbell-basic: Campbell Diagram
3+
Library: plotnine 0.15.3 | Python 3.14.3
4+
Quality: 90/100 | Created: 2026-02-15
5+
"""
6+
7+
import numpy as np
8+
import pandas as pd
9+
from plotnine import (
10+
aes,
11+
annotate,
12+
coord_cartesian,
13+
element_blank,
14+
element_line,
15+
element_rect,
16+
element_text,
17+
geom_line,
18+
geom_point,
19+
geom_rect,
20+
geom_text,
21+
ggplot,
22+
guide_legend,
23+
guides,
24+
labs,
25+
scale_color_manual,
26+
scale_linetype_manual,
27+
scale_size_identity,
28+
scale_x_continuous,
29+
scale_y_continuous,
30+
theme,
31+
theme_minimal,
32+
)
33+
34+
35+
# Data — Natural frequencies vs rotational speed for rotating machinery
36+
np.random.seed(42)
37+
speed = np.linspace(0, 6000, 80)
38+
39+
# Natural frequency modes with pronounced gyroscopic speed dependence
40+
modes = {
41+
"1st Bending": 18 + speed * 0.0015 + np.random.normal(0, 0.12, len(speed)),
42+
"2nd Bending": 45 - speed * 0.002 + np.random.normal(0, 0.12, len(speed)),
43+
"1st Torsional": 52 + speed * 0.0025 + np.random.normal(0, 0.12, len(speed)),
44+
"2nd Torsional": 75 + speed * 0.001 + np.random.normal(0, 0.12, len(speed)),
45+
"Axial": 90 - speed * 0.0004 + np.random.normal(0, 0.12, len(speed)),
46+
}
47+
48+
# Colorblind-safe palette starting with Python Blue
49+
palette = ["#306998", "#E69F00", "#882D9E", "#D55E00", "#009E73"]
50+
mode_names = list(modes.keys())
51+
mode_colors = dict(zip(mode_names, palette, strict=True))
52+
eo_color = "#888888"
53+
54+
# Long-format DataFrame for natural frequency curves
55+
df_modes = pd.DataFrame(
56+
[
57+
{"Speed": s, "Frequency": f, "Mode": name}
58+
for name, freqs in modes.items()
59+
for s, f in zip(speed, freqs, strict=True)
60+
]
61+
)
62+
63+
# Engine order lines: frequency = order × speed / 60
64+
engine_orders = [1, 2, 3]
65+
eo_names = [f"{o}x EO" for o in engine_orders]
66+
df_eo = pd.DataFrame(
67+
[{"Speed": s, "Frequency": order * s / 60, "Mode": f"{order}x EO"} for order in engine_orders for s in speed]
68+
)
69+
70+
# Critical speed intersections (EO line crosses natural frequency curve)
71+
critical_points = []
72+
for order in engine_orders:
73+
eo_freq = order * speed / 60
74+
for _mode_name, freq_values in modes.items():
75+
diff = eo_freq - freq_values
76+
sign_changes = np.where(np.diff(np.sign(diff)))[0]
77+
for idx in sign_changes:
78+
s0, s1 = speed[idx], speed[idx + 1]
79+
f0_eo, f1_eo = eo_freq[idx], eo_freq[idx + 1]
80+
f0_m, f1_m = freq_values[idx], freq_values[idx + 1]
81+
t = (f0_m - f0_eo) / ((f1_eo - f0_eo) - (f1_m - f0_m))
82+
cs, cf = s0 + t * (s1 - s0), f0_eo + t * (f1_eo - f0_eo)
83+
if 0 < cs < 6000 and 0 < cf < 110:
84+
critical_points.append({"Speed": cs, "Frequency": cf})
85+
df_critical = pd.DataFrame(critical_points)
86+
87+
# Storytelling: 1x / 1st Bending critical speed (most operationally significant)
88+
eo1_freq = speed / 60
89+
diff_1b = eo1_freq - modes["1st Bending"]
90+
sc_idx = np.where(np.diff(np.sign(diff_1b)))[0]
91+
annot_speed = annot_freq = None
92+
if len(sc_idx) > 0:
93+
idx = sc_idx[0]
94+
t = (modes["1st Bending"][idx] - eo1_freq[idx]) / (
95+
(eo1_freq[idx + 1] - eo1_freq[idx]) - (modes["1st Bending"][idx + 1] - modes["1st Bending"][idx])
96+
)
97+
annot_speed = speed[idx] + t * (speed[idx + 1] - speed[idx])
98+
annot_freq = eo1_freq[idx] + t * (eo1_freq[idx + 1] - eo1_freq[idx])
99+
100+
# Combine all line data and add line weight column for size differentiation
101+
df_lines = pd.concat([df_modes, df_eo], ignore_index=True)
102+
df_lines["_lw"] = df_lines["Mode"].apply(lambda m: 2.0 if "EO" not in m else 1.0)
103+
104+
# Legend mappings — consolidated EO into one entry
105+
color_map = {**mode_colors, **dict.fromkeys(eo_names, eo_color)}
106+
ltype_map = {**dict.fromkeys(mode_names, "solid"), **dict.fromkeys(eo_names, "dashed")}
107+
breaks = mode_names + eo_names[:1]
108+
labels = mode_names + ["Engine Order (1×, 2×, 3×)"]
109+
110+
# Operating range band (nominal: 2000–4500 RPM)
111+
df_band = pd.DataFrame([{"xmin": 2000, "xmax": 4500, "ymin": 0, "ymax": 110}])
112+
113+
# EO labels positioned along lines
114+
eo_labels = pd.DataFrame(
115+
[
116+
{"Speed": 4500, "Frequency": 4500 / 60 + 3, "label": "1×"},
117+
{"Speed": 2200, "Frequency": 2 * 2200 / 60 + 3, "label": "2×"},
118+
{"Speed": 1500, "Frequency": 3 * 1500 / 60 + 3, "label": "3×"},
119+
]
120+
)
121+
122+
# Plot — grammar of graphics layer composition
123+
plot = (
124+
ggplot(df_lines, aes("Speed", "Frequency", color="Mode", linetype="Mode", group="Mode"))
125+
# Operating range shading
126+
+ geom_rect(
127+
df_band,
128+
aes(xmin="xmin", xmax="xmax", ymin="ymin", ymax="ymax"),
129+
fill="#306998",
130+
alpha=0.04,
131+
color="none",
132+
inherit_aes=False,
133+
)
134+
# Natural frequency + EO lines with size-identity for weight differentiation
135+
+ geom_line(aes(size="_lw"))
136+
+ scale_size_identity()
137+
# Critical speed markers
138+
+ geom_point(
139+
df_critical,
140+
aes("Speed", "Frequency"),
141+
color="#C62828",
142+
fill="#EF5350",
143+
size=4.5,
144+
shape="D",
145+
stroke=0.7,
146+
inherit_aes=False,
147+
show_legend=False,
148+
)
149+
# EO line labels
150+
+ geom_text(
151+
eo_labels,
152+
aes("Speed", "Frequency", label="label"),
153+
color="#555555",
154+
size=11,
155+
fontstyle="italic",
156+
fontweight="bold",
157+
inherit_aes=False,
158+
show_legend=False,
159+
)
160+
# Unified legend via scale_manual with custom breaks/labels
161+
+ scale_color_manual(values=color_map, breaks=breaks, labels=labels)
162+
+ scale_linetype_manual(values=ltype_map, breaks=breaks, labels=labels)
163+
+ guides(color=guide_legend(override_aes={"size": [1.8] * 5 + [1.0]}), linetype=guide_legend())
164+
# coord_cartesian for zoom without data removal
165+
+ scale_x_continuous(breaks=range(0, 7000, 1000))
166+
+ scale_y_continuous(breaks=range(0, 111, 10))
167+
+ coord_cartesian(xlim=(0, 6200), ylim=(0, 108))
168+
+ labs(x="Rotational Speed (RPM)", y="Natural Frequency (Hz)", title="campbell-basic · plotnine · pyplots.ai")
169+
# Publication-quality theme
170+
+ theme_minimal(base_size=14)
171+
+ theme(
172+
figure_size=(16, 9),
173+
text=element_text(family="sans-serif", color="#333333"),
174+
plot_title=element_text(size=24, ha="center", face="bold", color="#1a1a1a"),
175+
axis_title_x=element_text(size=20, face="bold", color="#222222"),
176+
axis_title_y=element_text(size=20, face="bold", color="#222222"),
177+
axis_text=element_text(size=16, color="#555555"),
178+
legend_text=element_text(size=13),
179+
legend_title=element_blank(),
180+
legend_position="bottom",
181+
legend_direction="horizontal",
182+
legend_background=element_rect(fill="white", alpha=0.9, color="#CCCCCC", size=0.4),
183+
legend_key_width=35,
184+
legend_key_height=18,
185+
panel_grid_major=element_line(color="#E5E5E5", size=0.25),
186+
panel_grid_minor=element_blank(),
187+
plot_background=element_rect(fill="white", color="white"),
188+
panel_background=element_rect(fill="#FAFAFA", color="#E0E0E0", size=0.3),
189+
axis_line=element_line(color="#CCCCCC", size=0.4),
190+
plot_margin=0.02,
191+
)
192+
)
193+
194+
# Storytelling: annotate the most significant critical speed
195+
if annot_speed is not None:
196+
plot = (
197+
plot
198+
+ annotate(
199+
"segment",
200+
x=annot_speed,
201+
xend=annot_speed,
202+
y=0,
203+
yend=annot_freq,
204+
color="#C62828",
205+
linetype="dotted",
206+
size=0.7,
207+
alpha=0.6,
208+
)
209+
+ annotate(
210+
"text",
211+
x=annot_speed + 180,
212+
y=annot_freq + 5,
213+
label=f"Critical: {int(round(annot_speed))} RPM",
214+
color="#C62828",
215+
size=9,
216+
ha="left",
217+
fontstyle="italic",
218+
fontweight="bold",
219+
)
220+
)
221+
222+
# Operating range label
223+
plot = plot + annotate(
224+
"text", x=3250, y=104, label="Operating Range", color="#306998", size=8, alpha=0.5, fontweight="bold"
225+
)
226+
227+
plot.save("plot.png", dpi=300, verbose=False)

0 commit comments

Comments
 (0)