|
| 1 | +""" pyplots.ai |
| 2 | +campbell-basic: Campbell Diagram |
| 3 | +Library: plotly 6.5.2 | Python 3.14.3 |
| 4 | +Quality: 88/100 | Created: 2026-02-15 |
| 5 | +""" |
| 6 | + |
| 7 | +import numpy as np |
| 8 | +import plotly.graph_objects as go |
| 9 | + |
| 10 | + |
| 11 | +# Data |
| 12 | +np.random.seed(42) |
| 13 | +speed_rpm = np.linspace(0, 6000, 80) |
| 14 | +speed_hz = speed_rpm / 60 |
| 15 | + |
| 16 | +# Natural frequency modes (Hz) — enhanced gyroscopic effects for realistic variation |
| 17 | +mode_1_bending = 22 + 0.004 * speed_rpm + np.sin(speed_rpm / 1200) * 1.2 # forward whirl stiffening |
| 18 | +mode_2_bending = 48 - 0.003 * speed_rpm + np.cos(speed_rpm / 1800) * 1.4 # backward whirl softening |
| 19 | +mode_1_torsional = 55 + 0.0004 * speed_rpm # torsional: nearly speed-independent |
| 20 | +mode_axial = 75 - 0.004 * speed_rpm + np.sin(speed_rpm / 1000) * 2.0 # axial with bearing coupling |
| 21 | + |
| 22 | +# Engine order lines: frequency = order * speed_rpm / 60 |
| 23 | +orders = [1, 2, 3] |
| 24 | +order_freq = {order: order * speed_hz for order in orders} |
| 25 | + |
| 26 | +# Find critical speed intersections (engine order line crosses natural frequency curve) |
| 27 | +modes = { |
| 28 | + "1st Bending": mode_1_bending, |
| 29 | + "2nd Bending": mode_2_bending, |
| 30 | + "1st Torsional": mode_1_torsional, |
| 31 | + "Axial": mode_axial, |
| 32 | +} |
| 33 | + |
| 34 | +critical_speeds = [] |
| 35 | +critical_freqs = [] |
| 36 | +critical_labels = [] |
| 37 | +for order in orders: |
| 38 | + eo_freq = order_freq[order] |
| 39 | + for mode_name, mode_freq in modes.items(): |
| 40 | + diff = mode_freq - eo_freq |
| 41 | + sign_changes = np.where(np.diff(np.sign(diff)))[0] |
| 42 | + for idx in sign_changes: |
| 43 | + frac = abs(diff[idx]) / (abs(diff[idx]) + abs(diff[idx + 1])) |
| 44 | + crit_rpm = speed_rpm[idx] + frac * (speed_rpm[idx + 1] - speed_rpm[idx]) |
| 45 | + crit_hz = crit_rpm / 60 |
| 46 | + crit_freq = order * crit_hz |
| 47 | + critical_speeds.append(crit_rpm) |
| 48 | + critical_freqs.append(crit_freq) |
| 49 | + critical_labels.append(f"{mode_name} × {order}x") |
| 50 | + |
| 51 | +# Colorblind-safe palette (avoids red-green confusion) |
| 52 | +mode_colors = ["#306998", "#D4A017", "#9467BD", "#E07B39"] |
| 53 | +eo_color = "#7F7F7F" |
| 54 | +critical_color = "#C44E52" |
| 55 | + |
| 56 | +fig = go.Figure() |
| 57 | + |
| 58 | +# Track trace count for dynamic visibility toggling |
| 59 | +trace_count_before_modes = 0 |
| 60 | + |
| 61 | +# Shaded bands at critical speed zones |
| 62 | +for cs_rpm in critical_speeds: |
| 63 | + fig.add_vrect(x0=cs_rpm - 80, x1=cs_rpm + 80, fillcolor="rgba(196, 78, 82, 0.10)", line_width=0, layer="below") |
| 64 | + |
| 65 | +# Natural frequency curves with distinct dash patterns per mode type |
| 66 | +line_dashes = ["solid", "dash", "dot", "dashdot"] |
| 67 | +mode_trace_start = len(fig.data) |
| 68 | +for i, (mode_name, mode_freq) in enumerate(modes.items()): |
| 69 | + fig.add_trace( |
| 70 | + go.Scatter( |
| 71 | + x=speed_rpm, |
| 72 | + y=mode_freq, |
| 73 | + mode="lines", |
| 74 | + name=mode_name, |
| 75 | + line={"color": mode_colors[i], "width": 3.5, "dash": line_dashes[i]}, |
| 76 | + hovertemplate=f"<b>{mode_name}</b><br>Speed: %{{x:.0f}} RPM<br>Freq: %{{y:.1f}} Hz<extra></extra>", |
| 77 | + ) |
| 78 | + ) |
| 79 | +n_mode_traces = len(fig.data) - mode_trace_start |
| 80 | + |
| 81 | +# Engine order lines (clipped to visible y-axis range) |
| 82 | +y_max = 110 |
| 83 | +eo_trace_start = len(fig.data) |
| 84 | +for order in orders: |
| 85 | + label = f"{order}x" |
| 86 | + eo_y = order_freq[order] |
| 87 | + mask = eo_y <= y_max |
| 88 | + fig.add_trace( |
| 89 | + go.Scatter( |
| 90 | + x=speed_rpm[mask], |
| 91 | + y=eo_y[mask], |
| 92 | + mode="lines", |
| 93 | + name=f"EO {label}", |
| 94 | + line={"color": eo_color, "width": 2.4, "dash": "dash"}, |
| 95 | + hovertemplate=f"<b>EO {label}</b><br>Speed: %{{x:.0f}} RPM<br>Freq: %{{y:.1f}} Hz<extra></extra>", |
| 96 | + ) |
| 97 | + ) |
| 98 | +n_eo_traces = len(fig.data) - eo_trace_start |
| 99 | + |
| 100 | +# Engine order labels placed within the visible plot area |
| 101 | +for order in orders: |
| 102 | + label = f"{order}x" |
| 103 | + eo_y = order_freq[order] |
| 104 | + visible_mask = eo_y <= y_max |
| 105 | + visible_indices = np.where(visible_mask)[0] |
| 106 | + label_idx = visible_indices[int(len(visible_indices) * 0.75)] |
| 107 | + ann_x = speed_rpm[label_idx] |
| 108 | + ann_y = eo_y[label_idx] |
| 109 | + fig.add_annotation( |
| 110 | + x=ann_x, |
| 111 | + y=ann_y, |
| 112 | + text=f"<b>{label}</b>", |
| 113 | + showarrow=False, |
| 114 | + xanchor="left", |
| 115 | + yanchor="bottom", |
| 116 | + xshift=8, |
| 117 | + yshift=4, |
| 118 | + font={"size": 16, "color": eo_color, "family": "Arial, sans-serif"}, |
| 119 | + bgcolor="rgba(255,255,255,0.85)", |
| 120 | + borderpad=3, |
| 121 | + ) |
| 122 | + |
| 123 | +# Critical speed markers with descriptive hover |
| 124 | +crit_trace_start = len(fig.data) |
| 125 | +fig.add_trace( |
| 126 | + go.Scatter( |
| 127 | + x=critical_speeds, |
| 128 | + y=critical_freqs, |
| 129 | + mode="markers", |
| 130 | + name="Critical Speed", |
| 131 | + marker={"size": 14, "color": critical_color, "symbol": "diamond", "line": {"width": 2, "color": "white"}}, |
| 132 | + customdata=critical_labels, |
| 133 | + hovertemplate="<b>Critical Speed</b><br>%{customdata}<br>Speed: %{x:.0f} RPM<br>Freq: %{y:.1f} Hz<extra></extra>", |
| 134 | + ) |
| 135 | +) |
| 136 | +n_crit_traces = len(fig.data) - crit_trace_start |
| 137 | + |
| 138 | +# Annotate the most critical intersection (highest frequency crossing = most dangerous) |
| 139 | +if critical_speeds: |
| 140 | + max_idx = int(np.argmax(critical_freqs)) |
| 141 | + fig.add_annotation( |
| 142 | + x=critical_speeds[max_idx], |
| 143 | + y=critical_freqs[max_idx], |
| 144 | + text=f"<b>⚠ {critical_freqs[max_idx]:.0f} Hz</b><br>{critical_labels[max_idx]}", |
| 145 | + showarrow=True, |
| 146 | + arrowhead=2, |
| 147 | + arrowsize=1.2, |
| 148 | + arrowcolor=critical_color, |
| 149 | + arrowwidth=2, |
| 150 | + ax=50, |
| 151 | + ay=-45, |
| 152 | + font={"size": 13, "color": critical_color, "family": "Arial, sans-serif"}, |
| 153 | + bgcolor="rgba(255,255,255,0.92)", |
| 154 | + bordercolor=critical_color, |
| 155 | + borderwidth=1.5, |
| 156 | + borderpad=5, |
| 157 | + ) |
| 158 | + # Annotate the lowest-RPM critical speed (first resonance encountered during run-up) |
| 159 | + min_rpm_idx = int(np.argmin(critical_speeds)) |
| 160 | + if min_rpm_idx != max_idx: |
| 161 | + fig.add_annotation( |
| 162 | + x=critical_speeds[min_rpm_idx], |
| 163 | + y=critical_freqs[min_rpm_idx], |
| 164 | + text=f"<b>1st critical</b><br>{critical_speeds[min_rpm_idx]:.0f} RPM", |
| 165 | + showarrow=True, |
| 166 | + arrowhead=2, |
| 167 | + arrowsize=1.2, |
| 168 | + arrowcolor=critical_color, |
| 169 | + arrowwidth=2, |
| 170 | + ax=-55, |
| 171 | + ay=40, |
| 172 | + font={"size": 13, "color": critical_color, "family": "Arial, sans-serif"}, |
| 173 | + bgcolor="rgba(255,255,255,0.92)", |
| 174 | + bordercolor=critical_color, |
| 175 | + borderwidth=1.5, |
| 176 | + borderpad=5, |
| 177 | + ) |
| 178 | + |
| 179 | +# Build dynamic visibility arrays for toggle buttons |
| 180 | +total_traces = len(fig.data) |
| 181 | +all_visible = [True] * total_traces |
| 182 | +modes_only = [] |
| 183 | +for i in range(total_traces): |
| 184 | + if eo_trace_start <= i < eo_trace_start + n_eo_traces: |
| 185 | + modes_only.append(False) |
| 186 | + else: |
| 187 | + modes_only.append(True) |
| 188 | + |
| 189 | +# Layout — compact legend, tight margins |
| 190 | +fig.update_layout( |
| 191 | + title={ |
| 192 | + "text": "campbell-basic · plotly · pyplots.ai", |
| 193 | + "font": {"size": 28, "color": "#1A2A3A", "family": "Arial Black, Arial, sans-serif"}, |
| 194 | + "x": 0.5, |
| 195 | + "xanchor": "center", |
| 196 | + "y": 0.97, |
| 197 | + }, |
| 198 | + xaxis={ |
| 199 | + "title": { |
| 200 | + "text": "Rotational Speed (RPM)", |
| 201 | + "font": {"size": 22, "color": "#333", "family": "Arial, sans-serif"}, |
| 202 | + "standoff": 10, |
| 203 | + }, |
| 204 | + "tickfont": {"size": 18, "color": "#444"}, |
| 205 | + "showgrid": True, |
| 206 | + "gridwidth": 1, |
| 207 | + "gridcolor": "rgba(0,0,0,0.05)", |
| 208 | + "zeroline": False, |
| 209 | + "range": [0, 6100], |
| 210 | + "dtick": 1000, |
| 211 | + "showline": True, |
| 212 | + "linewidth": 1, |
| 213 | + "linecolor": "rgba(0,0,0,0.12)", |
| 214 | + "mirror": False, |
| 215 | + "spikemode": "across", |
| 216 | + "spikethickness": 1, |
| 217 | + "spikecolor": "rgba(0,0,0,0.3)", |
| 218 | + "spikedash": "dot", |
| 219 | + }, |
| 220 | + yaxis={ |
| 221 | + "title": { |
| 222 | + "text": "Frequency (Hz)", |
| 223 | + "font": {"size": 22, "color": "#333", "family": "Arial, sans-serif"}, |
| 224 | + "standoff": 10, |
| 225 | + }, |
| 226 | + "tickfont": {"size": 18, "color": "#444"}, |
| 227 | + "showgrid": True, |
| 228 | + "gridwidth": 1, |
| 229 | + "gridcolor": "rgba(0,0,0,0.05)", |
| 230 | + "zeroline": False, |
| 231 | + "range": [0, y_max], |
| 232 | + "dtick": 10, |
| 233 | + "showline": True, |
| 234 | + "linewidth": 1, |
| 235 | + "linecolor": "rgba(0,0,0,0.12)", |
| 236 | + "mirror": False, |
| 237 | + "spikemode": "across", |
| 238 | + "spikethickness": 1, |
| 239 | + "spikecolor": "rgba(0,0,0,0.3)", |
| 240 | + "spikedash": "dot", |
| 241 | + }, |
| 242 | + legend={ |
| 243 | + "font": {"size": 14, "family": "Arial, sans-serif", "color": "#333"}, |
| 244 | + "bgcolor": "rgba(255,255,255,0.92)", |
| 245 | + "bordercolor": "rgba(0,0,0,0.08)", |
| 246 | + "borderwidth": 1, |
| 247 | + "x": 0.01, |
| 248 | + "y": 0.99, |
| 249 | + "xanchor": "left", |
| 250 | + "yanchor": "top", |
| 251 | + "tracegroupgap": 1, |
| 252 | + "itemsizing": "constant", |
| 253 | + "itemwidth": 30, |
| 254 | + "orientation": "h", |
| 255 | + }, |
| 256 | + template="plotly_white", |
| 257 | + margin={"l": 75, "r": 30, "t": 70, "b": 65}, |
| 258 | + plot_bgcolor="white", |
| 259 | + paper_bgcolor="#F8F9FA", |
| 260 | + hoverlabel={"bgcolor": "white", "font_size": 14, "bordercolor": "#DDD"}, |
| 261 | + hovermode="closest", |
| 262 | + dragmode="zoom", |
| 263 | + updatemenus=[ |
| 264 | + { |
| 265 | + "type": "buttons", |
| 266 | + "direction": "left", |
| 267 | + "x": 1.0, |
| 268 | + "y": 1.05, |
| 269 | + "xanchor": "right", |
| 270 | + "yanchor": "top", |
| 271 | + "buttons": [ |
| 272 | + {"label": "All Modes", "method": "update", "args": [{"visible": all_visible}]}, |
| 273 | + {"label": "Modes Only", "method": "update", "args": [{"visible": modes_only}]}, |
| 274 | + ], |
| 275 | + "font": {"size": 12}, |
| 276 | + "bgcolor": "rgba(255,255,255,0.9)", |
| 277 | + "bordercolor": "rgba(0,0,0,0.1)", |
| 278 | + } |
| 279 | + ], |
| 280 | +) |
| 281 | + |
| 282 | +# Save |
| 283 | +fig.write_image("plot.png", width=1600, height=900, scale=3) |
| 284 | +fig.write_html("plot.html", include_plotlyjs="cdn") |
0 commit comments