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