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