|
| 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