Skip to content

Commit 41d6c03

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

2 files changed

Lines changed: 430 additions & 0 deletions

File tree

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
""" pyplots.ai
2+
campbell-basic: Campbell Diagram
3+
Library: letsplot 4.8.2 | Python 3.14.3
4+
Quality: 88/100 | Created: 2026-02-15
5+
"""
6+
7+
import numpy as np
8+
import pandas as pd
9+
from lets_plot import *
10+
11+
12+
LetsPlot.setup_html()
13+
14+
# Data
15+
np.random.seed(42)
16+
speed_rpm = np.linspace(0, 6000, 80)
17+
max_freq = 120
18+
19+
# Natural frequency modes (Hz) with realistic speed-dependent variation
20+
# Gyroscopic effects: forward whirl modes increase, backward whirl modes decrease
21+
modes = {
22+
"1st Bending": 25 + 0.0008 * speed_rpm + np.random.normal(0, 0.15, 80),
23+
"1st Torsional": 48 - 0.0005 * speed_rpm + np.random.normal(0, 0.12, 80),
24+
"2nd Bending": 72 + 0.0025 * speed_rpm + np.random.normal(0, 0.18, 80),
25+
"2nd Torsional": 88 + 0.0006 * speed_rpm + np.random.normal(0, 0.14, 80),
26+
"Axial": 105 - 0.0004 * speed_rpm + np.random.normal(0, 0.10, 80),
27+
}
28+
29+
# Build long-format DataFrame for natural frequency modes
30+
modes_df = pd.concat(
31+
[pd.DataFrame({"Speed": speed_rpm, "Frequency": freq, "Mode": name}) for name, freq in modes.items()],
32+
ignore_index=True,
33+
)
34+
35+
# Engine order lines: frequency = order * speed_rpm / 60
36+
orders = [1, 2, 3]
37+
order_labels = ["1\u00d7", "2\u00d7", "3\u00d7"]
38+
39+
eo_df = pd.concat(
40+
[
41+
pd.DataFrame(
42+
{
43+
"Speed": np.linspace(0, min(6000, max_freq * 60 / o), 80),
44+
"Frequency": o * np.linspace(0, min(6000, max_freq * 60 / o), 80) / 60,
45+
"Order": lbl,
46+
}
47+
)
48+
for o, lbl in zip(orders, order_labels)
49+
],
50+
ignore_index=True,
51+
)
52+
53+
# Find critical speed intersections (EO lines crossing mode curves)
54+
critical_rows = []
55+
for order, olabel in zip(orders, order_labels):
56+
eo_at = order * speed_rpm / 60
57+
for mname, mfreq in modes.items():
58+
diff = eo_at - mfreq
59+
for idx in np.where(np.diff(np.sign(diff)))[0]:
60+
t = abs(diff[idx]) / (abs(diff[idx]) + abs(diff[idx + 1]))
61+
cs = speed_rpm[idx] + t * (speed_rpm[idx + 1] - speed_rpm[idx])
62+
cf = order * cs / 60
63+
if cf <= max_freq:
64+
critical_rows.append({"Speed": cs, "Frequency": cf, "Intersection": f"{mname} \u00d7 {olabel}"})
65+
66+
crit_df = pd.DataFrame(critical_rows)
67+
68+
# Highlight zones around critical speeds
69+
zone_df = pd.DataFrame(
70+
[
71+
{"xmin": r["Speed"] - 150, "xmax": r["Speed"] + 150, "ymin": r["Frequency"] - 3.5, "ymax": r["Frequency"] + 3.5}
72+
for _, r in crit_df.iterrows()
73+
]
74+
)
75+
76+
# EO label positions along each line
77+
eo_label_df = pd.DataFrame(
78+
[
79+
{"Speed": spd, "Frequency": eo * spd / 60 + 2.5, "Label": lbl}
80+
for eo, lbl, spd in zip(orders, order_labels, [5200, 3400, 2200])
81+
]
82+
)
83+
84+
# Colorblind-safe palette (blue-teal-amber-purple-gray) — avoids red-green confusion
85+
mode_colors = ["#306998", "#17A589", "#D4A017", "#8E44AD", "#5D6D7E"]
86+
mode_order = list(modes.keys())
87+
eo_color = "#AAAAAA"
88+
crit_color = "#C0392B"
89+
90+
# Annotate the most dangerous critical speed (highest frequency intersection)
91+
danger_idx = crit_df["Frequency"].idxmax()
92+
danger_row = crit_df.loc[danger_idx]
93+
annot_df = pd.DataFrame(
94+
[
95+
{
96+
"Speed": danger_row["Speed"] + 250,
97+
"Frequency": danger_row["Frequency"] + 5,
98+
"Label": f"\u2190 {danger_row['Intersection']}\n ({int(danger_row['Speed'])} RPM)",
99+
}
100+
]
101+
)
102+
103+
# Plot
104+
plot = (
105+
ggplot()
106+
+ geom_rect(
107+
data=zone_df,
108+
mapping=aes(xmin="xmin", xmax="xmax", ymin="ymin", ymax="ymax"),
109+
fill=crit_color,
110+
alpha=0.08,
111+
color="transparent",
112+
)
113+
+ geom_line(data=eo_df, mapping=aes(x="Speed", y="Frequency", linetype="Order"), color=eo_color, size=0.9)
114+
+ geom_line(
115+
data=modes_df,
116+
mapping=aes(x="Speed", y="Frequency", color="Mode"),
117+
size=2.5,
118+
tooltips=layer_tooltips()
119+
.line("@{Mode}")
120+
.line("Speed|@{Speed} RPM")
121+
.line("Freq|@{Frequency} Hz")
122+
.format("Speed", ",.0f")
123+
.format("Frequency", ".1f"),
124+
)
125+
+ geom_point(
126+
data=crit_df,
127+
mapping=aes(x="Speed", y="Frequency"),
128+
color=crit_color,
129+
fill=crit_color,
130+
size=8,
131+
shape=18,
132+
tooltips=layer_tooltips()
133+
.title("Critical Speed")
134+
.line("@{Intersection}")
135+
.line("Speed|@{Speed} RPM")
136+
.line("Freq|@{Frequency} Hz")
137+
.format("Speed", ",.0f")
138+
.format("Frequency", ".1f"),
139+
)
140+
+ geom_text(
141+
data=eo_label_df,
142+
mapping=aes(x="Speed", y="Frequency", label="Label"),
143+
color="#777777",
144+
size=14,
145+
fontface="bold",
146+
)
147+
+ geom_text(
148+
data=annot_df,
149+
mapping=aes(x="Speed", y="Frequency", label="Label"),
150+
color=crit_color,
151+
size=12,
152+
fontface="italic",
153+
hjust=0,
154+
)
155+
+ scale_color_manual(name="Natural Frequency", values=mode_colors, limits=mode_order)
156+
+ scale_linetype_manual(name="Engine Order", values=["dashed", "dashed", "dashed"])
157+
+ scale_y_continuous(limits=[0, max_freq], expand=[0, 2])
158+
+ scale_x_continuous(limits=[0, 6200], format=",d")
159+
+ guides(linetype=guide_legend(override_aes={"color": eo_color}))
160+
+ labs(
161+
title="campbell-basic \u00b7 letsplot \u00b7 pyplots.ai",
162+
subtitle="Red diamonds mark critical speeds where engine order excitations cross natural frequency modes",
163+
x="Rotational Speed (RPM)",
164+
y="Frequency (Hz)",
165+
caption="Data: synthetic rotordynamic model | Highlight zones indicate resonance risk regions",
166+
)
167+
+ flavor_high_contrast_light()
168+
+ theme(
169+
plot_title=element_text(size=28, hjust=0.5, face="bold"),
170+
plot_subtitle=element_text(size=16, hjust=0.5, color="#555555"),
171+
plot_caption=element_text(size=13, color="#888888", face="italic"),
172+
axis_title=element_text(size=22),
173+
axis_text=element_text(size=18),
174+
legend_title=element_text(size=18, face="bold"),
175+
legend_text=element_text(size=16),
176+
legend_position=[0.02, 0.98],
177+
legend_justification=[0, 1],
178+
legend_background=element_rect(fill="white", color="#CCCCCC", size=0.5),
179+
panel_grid_major=element_line(color="#E5E5E5", size=0.3),
180+
panel_grid_minor=element_blank(),
181+
axis_line=element_line(color="#BBBBBB", size=0.5),
182+
plot_margin=[40, 20, 20, 20],
183+
)
184+
+ ggsize(1600, 900)
185+
)
186+
187+
# Save
188+
ggsave(plot, "plot.png", path=".", scale=3)
189+
ggsave(plot, "plot.html", path=".")

0 commit comments

Comments
 (0)