Skip to content

Commit e21f243

Browse files
Merge branch 'main' into implementation/heatmap-basic/seaborn
2 parents 71000af + 47fccdc commit e21f243

10 files changed

Lines changed: 2038 additions & 182 deletions

File tree

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
""" pyplots.ai
2+
campbell-basic: Campbell Diagram
3+
Library: altair 6.0.0 | Python 3.14.3
4+
Quality: 85/100 | Created: 2026-02-15
5+
"""
6+
7+
import altair as alt
8+
import numpy as np
9+
import pandas as pd
10+
11+
12+
# Data
13+
np.random.seed(42)
14+
speeds = np.linspace(0, 6000, 80)
15+
16+
mode_labels = ["1st Bending", "2nd Bending", "1st Torsional", "Axial", "3rd Bending"]
17+
base_freqs = [45, 95, 130, 175, 220]
18+
slopes = [0.004, -0.003, 0.005, 0.001, -0.004]
19+
curvatures = [5e-7, -4e-7, 2e-7, 1e-7, -3e-7]
20+
21+
mode_rows = []
22+
for label, base, slope, curv in zip(mode_labels, base_freqs, slopes, curvatures, strict=True):
23+
freqs = base + slope * speeds + curv * speeds**2
24+
for s, f in zip(speeds, freqs, strict=True):
25+
mode_rows.append({"RPM": s, "Hz": f, "Mode": label})
26+
27+
engine_orders = [1, 2, 3]
28+
eo_rows = []
29+
for order in engine_orders:
30+
for s in speeds:
31+
eo_rows.append({"RPM": s, "Hz": order * s / 60, "EO": f"{order}x"})
32+
33+
df_modes = pd.DataFrame(mode_rows)
34+
df_eo = pd.DataFrame(eo_rows)
35+
36+
# Find critical speed intersections
37+
critical_rows = []
38+
dense_speeds = np.linspace(0, 6000, 5000)
39+
for label, base, slope, curv in zip(mode_labels, base_freqs, slopes, curvatures, strict=True):
40+
mode_freq = base + slope * dense_speeds + curv * dense_speeds**2
41+
for order in engine_orders:
42+
eo_freq = order * dense_speeds / 60
43+
diff = mode_freq - eo_freq
44+
sign_changes = np.where(np.diff(np.sign(diff)))[0]
45+
for idx in sign_changes:
46+
s_crit = dense_speeds[idx]
47+
f_crit = eo_freq[idx]
48+
if 100 < s_crit < 5900 and 5 < f_crit < 295:
49+
in_op_range = 3000 <= s_crit <= 5000
50+
critical_rows.append(
51+
{
52+
"RPM": round(s_crit),
53+
"Hz": round(f_crit, 1),
54+
"Label": f"{label} / {order}x",
55+
"InOpRange": in_op_range,
56+
}
57+
)
58+
59+
df_critical = pd.DataFrame(critical_rows)
60+
61+
# Select well-spaced key critical speeds for annotation
62+
key_in_range = df_critical[df_critical["InOpRange"]]
63+
key_outside = df_critical[~df_critical["InOpRange"]].sort_values("Hz")
64+
# Pick annotations that are well separated: one low outside, one mid in-range, one high in-range
65+
df_annot = pd.DataFrame(
66+
[
67+
{**key_outside.iloc[0].to_dict(), "dx": 12, "dy": -18}, # 1st Bending / 3x (~988 RPM, ~49 Hz)
68+
{**key_in_range.iloc[0].to_dict(), "dx": -130, "dy": -20}, # 1st Bending / 1x (~4273 RPM, ~71 Hz) — left
69+
{**key_in_range.iloc[2].to_dict(), "dx": 14, "dy": -18}, # 1st Torsional / 2x (~4747 RPM, ~158 Hz)
70+
]
71+
)
72+
73+
# Operating range
74+
op_min, op_max = 3000, 5000
75+
76+
# Color palette — colorblind-safe, high contrast between all modes
77+
mode_palette = ["#306998", "#E8833A", "#55A868", "#BA6BC9", "#C44E52"]
78+
79+
x_scale = alt.Scale(domain=[0, 6200], nice=False)
80+
y_scale = alt.Scale(domain=[0, 310])
81+
82+
# Operating range shaded band
83+
op_band = (
84+
alt.Chart(pd.DataFrame({"x": [op_min], "x2": [op_max]}))
85+
.mark_rect(opacity=0.08, color="#306998")
86+
.encode(x=alt.X("x:Q", scale=x_scale), x2="x2:Q")
87+
)
88+
89+
# Operating range label
90+
op_label = (
91+
alt.Chart(pd.DataFrame({"RPM": [(op_min + op_max) / 2], "Hz": [8], "label": ["Operating Range"]}))
92+
.mark_text(fontSize=15, fontStyle="italic", color="#306998", fontWeight="bold")
93+
.encode(x=alt.X("RPM:Q", scale=x_scale), y=alt.Y("Hz:Q", scale=y_scale), text="label:N")
94+
)
95+
96+
# Engine order lines — no legend (direct labels at right edge are cleaner)
97+
eo_chart = (
98+
alt.Chart(df_eo)
99+
.mark_line(strokeWidth=1.5, strokeDash=[8, 6], color="#999999", opacity=0.55)
100+
.encode(x=alt.X("RPM:Q", scale=x_scale), y=alt.Y("Hz:Q", scale=y_scale), detail="EO:N")
101+
)
102+
103+
# Engine order text labels near right edge
104+
eo_label_rows = []
105+
for order in engine_orders:
106+
max_freq = order * 6000 / 60
107+
if max_freq <= 295:
108+
eo_label_rows.append({"RPM": 6080, "Hz": order * 6080 / 60, "label": f"{order}x"})
109+
else:
110+
target_speed = 290 * 60 / order
111+
eo_label_rows.append({"RPM": target_speed + 80, "Hz": 295, "label": f"{order}x"})
112+
113+
eo_label_chart = (
114+
alt.Chart(pd.DataFrame(eo_label_rows))
115+
.mark_text(fontSize=15, fontWeight="bold", color="#777777", align="left", dy=-8)
116+
.encode(x=alt.X("RPM:Q", scale=x_scale), y=alt.Y("Hz:Q", scale=y_scale), text="label:N")
117+
)
118+
119+
# Natural frequency mode lines
120+
modes_chart = (
121+
alt.Chart(df_modes)
122+
.mark_line(strokeWidth=3)
123+
.encode(
124+
x=alt.X("RPM:Q", title="Rotational Speed (RPM)", scale=x_scale),
125+
y=alt.Y("Hz:Q", title="Frequency (Hz)", scale=y_scale),
126+
color=alt.Color(
127+
"Mode:N",
128+
scale=alt.Scale(domain=mode_labels, range=mode_palette),
129+
legend=alt.Legend(
130+
title="Natural Frequencies", titleFontSize=14, labelFontSize=13, symbolStrokeWidth=3, symbolSize=150
131+
),
132+
),
133+
)
134+
)
135+
136+
# Critical speed markers — size-differentiated for operating range emphasis
137+
crit_outside_chart = (
138+
alt.Chart(df_critical[~df_critical["InOpRange"]])
139+
.mark_point(size=200, shape="diamond", filled=True, color="#D62728", stroke="white", strokeWidth=1.5)
140+
.encode(x=alt.X("RPM:Q", scale=x_scale), y=alt.Y("Hz:Q", scale=y_scale), tooltip=["Label:N", "RPM:Q", "Hz:Q"])
141+
)
142+
143+
crit_inside_chart = (
144+
alt.Chart(df_critical[df_critical["InOpRange"]])
145+
.mark_point(size=380, shape="diamond", filled=True, color="#D62728", stroke="white", strokeWidth=2.5)
146+
.encode(x=alt.X("RPM:Q", scale=x_scale), y=alt.Y("Hz:Q", scale=y_scale), tooltip=["Label:N", "RPM:Q", "Hz:Q"])
147+
)
148+
149+
# Annotations for key critical speeds — single consolidated layer per offset group
150+
annot_layers = []
151+
for _, row in df_annot.iterrows():
152+
annot_layers.append(
153+
alt.Chart(pd.DataFrame([row]))
154+
.mark_text(fontSize=13, color="#8B0000", fontWeight="bold", align="left", dx=row["dx"], dy=row["dy"])
155+
.encode(x=alt.X("RPM:Q", scale=x_scale), y=alt.Y("Hz:Q", scale=y_scale), text="Label:N")
156+
)
157+
158+
# Compose chart
159+
combined = op_band + eo_chart + modes_chart + crit_outside_chart + crit_inside_chart + eo_label_chart + op_label
160+
for layer in annot_layers:
161+
combined = combined + layer
162+
163+
chart = (
164+
combined.properties(
165+
width=1600,
166+
height=900,
167+
title=alt.Title(
168+
"campbell-basic · altair · pyplots.ai",
169+
fontSize=28,
170+
fontWeight=500,
171+
anchor="start",
172+
subtitle="Natural Frequency Modes vs Engine Order Excitations",
173+
subtitleFontSize=16,
174+
subtitleColor="#666666",
175+
),
176+
)
177+
.configure_axis(
178+
labelFontSize=18,
179+
titleFontSize=22,
180+
titleColor="#333333",
181+
labelColor="#444444",
182+
grid=True,
183+
gridOpacity=0.10,
184+
gridWidth=0.5,
185+
gridColor="#cccccc",
186+
domainColor="#aaaaaa",
187+
domainWidth=0.8,
188+
tickColor="#aaaaaa",
189+
tickSize=6,
190+
)
191+
.configure_view(strokeWidth=0)
192+
.configure_legend(orient="right", padding=6, offset=2, titlePadding=4, rowPadding=2)
193+
.configure_title(anchor="start", offset=10)
194+
)
195+
196+
# Save
197+
chart.save("plot.png", scale_factor=3.0)
198+
chart.save("plot.html")

0 commit comments

Comments
 (0)