Skip to content

Commit 5f00b9f

Browse files
feat(matplotlib): implement campbell-basic (#4246)
## Implementation: `campbell-basic` - matplotlib Implements the **matplotlib** version of `campbell-basic`. **File:** `plots/campbell-basic/implementations/matplotlib.py` **Parent Issue:** #4241 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/pyplots/actions/runs/22043026882)* --------- 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 3f04abd commit 5f00b9f

2 files changed

Lines changed: 447 additions & 0 deletions

File tree

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
""" pyplots.ai
2+
campbell-basic: Campbell Diagram
3+
Library: matplotlib 3.10.8 | Python 3.14.3
4+
Quality: 90/100 | Created: 2026-02-15
5+
"""
6+
7+
import matplotlib.pyplot as plt
8+
import numpy as np
9+
from matplotlib.lines import Line2D
10+
from matplotlib.patches import Patch
11+
from matplotlib.ticker import FuncFormatter
12+
13+
14+
# Data
15+
speed_rpm = np.linspace(0, 6000, 200)
16+
speed_hz = speed_rpm / 60
17+
18+
# Natural frequency modes (Hz) - realistic gyroscopic effects
19+
mode_1_bending = 18 + 0.004 * speed_rpm - 1.5e-7 * speed_rpm**2
20+
mode_2_bending = 48 - 0.003 * speed_rpm + 2.0e-7 * speed_rpm**2
21+
mode_1_torsional = 58 + 0.0004 * speed_rpm
22+
mode_axial = 78 - 0.005 * speed_rpm + 4.0e-7 * speed_rpm**2
23+
mode_3_bending = 92 + 0.005 * speed_rpm - 3.5e-7 * speed_rpm**2
24+
25+
modes = [mode_1_bending, mode_2_bending, mode_1_torsional, mode_axial, mode_3_bending]
26+
mode_labels = ["1st Bending", "2nd Bending", "1st Torsional", "Axial", "3rd Bending"]
27+
mode_colors = ["#306998", "#E8833A", "#2B9EB3", "#984EA3", "#A65628"]
28+
29+
engine_orders = [1, 2, 3]
30+
eo_freq = {eo: eo * speed_hz for eo in engine_orders}
31+
32+
# Find critical speed intersections via sign changes
33+
op_min, op_max = 2500, 4500
34+
critical_speeds, critical_freqs, critical_mlabels = [], [], []
35+
for mode, mlabel in zip(modes, mode_labels, strict=True):
36+
for eo in engine_orders:
37+
diff = mode - eo * speed_hz
38+
for idx in np.where(np.diff(np.sign(diff)))[0]:
39+
t = abs(diff[idx]) / (abs(diff[idx]) + abs(diff[idx + 1]))
40+
rpm = speed_rpm[idx] + t * (speed_rpm[idx + 1] - speed_rpm[idx])
41+
freq = mode[idx] + t * (mode[idx + 1] - mode[idx])
42+
if 100 < rpm < 5900:
43+
critical_speeds.append(rpm)
44+
critical_freqs.append(freq)
45+
critical_mlabels.append(mlabel)
46+
47+
# Plot
48+
fig, ax = plt.subplots(figsize=(16, 9))
49+
y_max = 120
50+
51+
# Operating range shading
52+
ax.axvspan(op_min, op_max, alpha=0.07, color="#306998", zorder=0)
53+
ax.axvline(op_min, color="#306998", linewidth=1.2, linestyle=":", alpha=0.5, zorder=1)
54+
ax.axvline(op_max, color="#306998", linewidth=1.2, linestyle=":", alpha=0.5, zorder=1)
55+
ax.text(
56+
(op_min + op_max) / 2,
57+
3,
58+
"Operating Range",
59+
fontsize=13,
60+
color="#306998",
61+
ha="center",
62+
va="bottom",
63+
fontstyle="italic",
64+
alpha=0.6,
65+
)
66+
67+
# Mode curves
68+
for mode, _label, color in zip(modes, mode_labels, mode_colors, strict=True):
69+
ax.plot(speed_rpm, mode, linewidth=2.8, color=color, zorder=3, solid_capstyle="round")
70+
71+
# End-of-line labels with vertical de-collision
72+
end_vals = [(mode[-1], label, color) for mode, label, color in zip(modes, mode_labels, mode_colors, strict=True)]
73+
end_vals.sort(key=lambda x: x[0])
74+
min_gap = 4.5 # minimum Hz gap between adjacent labels
75+
positions = [v[0] for v in end_vals]
76+
for i in range(1, len(positions)):
77+
if positions[i] - positions[i - 1] < min_gap:
78+
positions[i] = positions[i - 1] + min_gap
79+
for y_pos, (_, label, color) in zip(positions, end_vals, strict=True):
80+
ax.annotate(
81+
label,
82+
xy=(speed_rpm[-1], y_pos),
83+
xytext=(8, 0),
84+
textcoords="offset points",
85+
fontsize=10,
86+
color=color,
87+
fontweight="bold",
88+
va="center",
89+
zorder=4,
90+
)
91+
92+
# Engine order lines with rotated labels
93+
for eo in engine_orders:
94+
eo_line = eo_freq[eo]
95+
visible = eo_line <= y_max
96+
ax.plot(
97+
speed_rpm[visible], eo_line[visible], linewidth=1.8, color="#AAAAAA", linestyle=(0, (8, 4)), alpha=0.6, zorder=2
98+
)
99+
target_freq = y_max * 0.28
100+
target_rpm = target_freq * 60 / eo
101+
if target_rpm < 5800:
102+
slope_display = (eo / 60) * (9 / y_max) / (16 / 6000)
103+
angle_deg = np.degrees(np.arctan(slope_display))
104+
ax.annotate(
105+
f"{eo}×",
106+
xy=(target_rpm, target_freq),
107+
fontsize=14,
108+
color="#777777",
109+
fontweight="bold",
110+
ha="center",
111+
va="bottom",
112+
rotation=angle_deg,
113+
rotation_mode="anchor",
114+
zorder=4,
115+
bbox={"boxstyle": "round,pad=0.15", "facecolor": "white", "edgecolor": "none", "alpha": 0.8},
116+
)
117+
118+
# Critical speed markers
119+
cs_arr, cf_arr = np.array(critical_speeds), np.array(critical_freqs)
120+
in_op = (cs_arr >= op_min) & (cs_arr <= op_max)
121+
122+
if np.any(~in_op):
123+
ax.scatter(
124+
cs_arr[~in_op], cf_arr[~in_op], s=200, color="#D62728", edgecolors="white", linewidth=1.5, zorder=5, alpha=0.45
125+
)
126+
127+
if np.any(in_op):
128+
ax.scatter(
129+
cs_arr[in_op], cf_arr[in_op], s=350, color="#D62728", edgecolors="white", linewidth=2, zorder=6, marker="D"
130+
)
131+
# Annotate critical intersections inside operating range with well-separated offsets
132+
op_s, op_f, op_m = cs_arr[in_op], cf_arr[in_op], np.array(critical_mlabels)[in_op]
133+
order = np.argsort(op_f)
134+
n = len(order)
135+
for rank, si in enumerate(order):
136+
# Alternate left/right with increasing vertical spread to avoid overlap
137+
sign = 1 if rank % 2 == 0 else -1
138+
dx = sign * 35
139+
dy = -30 + rank * (60 / max(n - 1, 1))
140+
ax.annotate(
141+
op_m[si],
142+
xy=(op_s[si], op_f[si]),
143+
xytext=(dx, dy),
144+
textcoords="offset points",
145+
fontsize=11,
146+
color="#B71C1C",
147+
fontweight="bold",
148+
arrowprops={"arrowstyle": "-|>", "color": "#B71C1C", "lw": 1.0, "shrinkB": 4},
149+
zorder=7,
150+
bbox={
151+
"boxstyle": "round,pad=0.25",
152+
"facecolor": "#FFF3F3",
153+
"edgecolor": "#B71C1C",
154+
"alpha": 0.9,
155+
"linewidth": 0.7,
156+
},
157+
)
158+
159+
# Style
160+
ax.set_xlabel("Rotational Speed (RPM)", fontsize=20)
161+
ax.set_ylabel("Frequency (Hz)", fontsize=20)
162+
ax.set_title("campbell-basic · matplotlib · pyplots.ai", fontsize=24, fontweight="medium", pad=16)
163+
ax.tick_params(axis="both", labelsize=16)
164+
for spine in ("top", "right"):
165+
ax.spines[spine].set_visible(False)
166+
for spine in ("left", "bottom"):
167+
ax.spines[spine].set_linewidth(0.6)
168+
ax.spines[spine].set_color("#555555")
169+
ax.set_xlim(0, 6000)
170+
ax.set_ylim(0, y_max)
171+
ax.yaxis.grid(True, alpha=0.15, linewidth=0.6, color="#CCCCCC")
172+
ax.xaxis.grid(True, alpha=0.08, linewidth=0.4, color="#CCCCCC")
173+
174+
# Format x-axis with thousand separator for readability
175+
ax.xaxis.set_major_formatter(FuncFormatter(lambda x, _: f"{x:,.0f}"))
176+
177+
# Compact two-column legend positioned in upper-left to avoid covering data
178+
eo_handle = Line2D([0], [0], color="#AAAAAA", linewidth=1.8, linestyle=(0, (8, 4)), alpha=0.6)
179+
crit_outside = Line2D(
180+
[0], [0], marker="o", color="none", markerfacecolor="#D62728", markeredgecolor="white", markersize=10, alpha=0.5
181+
)
182+
crit_inside = Line2D(
183+
[0], [0], marker="D", color="none", markerfacecolor="#D62728", markeredgecolor="white", markersize=10
184+
)
185+
op_handle = Patch(facecolor="#306998", alpha=0.12, edgecolor="none")
186+
187+
handles = [Line2D([0], [0], color=c, linewidth=2.8) for c in mode_colors] + [
188+
eo_handle,
189+
crit_outside,
190+
crit_inside,
191+
op_handle,
192+
]
193+
labels = mode_labels + ["Engine Order (1×–3×)", "Critical Speed", "Critical (op. range)", "Operating Range"]
194+
195+
ax.legend(
196+
handles,
197+
labels,
198+
fontsize=11,
199+
loc="upper left",
200+
ncol=2,
201+
framealpha=0.92,
202+
edgecolor="#DDDDDD",
203+
borderpad=0.5,
204+
labelspacing=0.4,
205+
handlelength=1.4,
206+
columnspacing=1.0,
207+
)
208+
209+
plt.tight_layout()
210+
plt.savefig("plot.png", dpi=300, bbox_inches="tight")

0 commit comments

Comments
 (0)