{"spec_id":"campbell-basic","library":"matplotlib","language":"python","code":"\"\"\" anyplot.ai\ncampbell-basic: Campbell Diagram\nLibrary: matplotlib 3.10.9 | Python 3.13.13\nQuality: 90/100 | Updated: 2026-05-28\n\"\"\"\n\nimport os\n\nimport matplotlib.pyplot as plt\nimport numpy as np\nfrom matplotlib.lines import Line2D\nfrom matplotlib.patches import Patch\nfrom matplotlib.ticker import FuncFormatter\n\n\n# Theme\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nELEVATED_BG = \"#FFFDF6\" if THEME == \"light\" else \"#242420\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_SOFT = \"#4A4A44\" if THEME == \"light\" else \"#B8B7B0\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\nANYPLOT_AMBER = \"#DDCC77\"  # warning / caution — semantic anchor for critical condition\n\n# Imprint palette positions 1–5 for mode curves\nmode_colors = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\"]\n\n# Data\nspeed_rpm = np.linspace(0, 6000, 200)\nspeed_hz = speed_rpm / 60\n\n# Natural frequency modes (Hz) — realistic gyroscopic effects\nmode_1_bending = 18 + 0.004 * speed_rpm - 1.5e-7 * speed_rpm**2\nmode_2_bending = 48 - 0.003 * speed_rpm + 2.0e-7 * speed_rpm**2\nmode_1_torsional = 58 + 0.0004 * speed_rpm\nmode_axial = 78 - 0.005 * speed_rpm + 4.0e-7 * speed_rpm**2\nmode_3_bending = 92 + 0.005 * speed_rpm - 3.5e-7 * speed_rpm**2\n\nmodes = [mode_1_bending, mode_2_bending, mode_1_torsional, mode_axial, mode_3_bending]\nmode_labels = [\"1st Bending\", \"2nd Bending\", \"1st Torsional\", \"Axial\", \"3rd Bending\"]\n\nengine_orders = [1, 2, 3]\neo_freq = {eo: eo * speed_hz for eo in engine_orders}\n\n# Find critical speed intersections via linear interpolation\nop_min, op_max = 2500, 4500\ncritical_speeds, critical_freqs, critical_mlabels = [], [], []\nfor mode, mlabel in zip(modes, mode_labels, strict=True):\n    for eo in engine_orders:\n        diff = mode - eo * speed_hz\n        for idx in np.where(np.diff(np.sign(diff)))[0]:\n            t = abs(diff[idx]) / (abs(diff[idx]) + abs(diff[idx + 1]))\n            rpm = speed_rpm[idx] + t * (speed_rpm[idx + 1] - speed_rpm[idx])\n            freq = mode[idx] + t * (mode[idx + 1] - mode[idx])\n            if 100 < rpm < 5900:\n                critical_speeds.append(rpm)\n                critical_freqs.append(freq)\n                critical_mlabels.append(mlabel)\n\n# Plot\nfig, ax = plt.subplots(figsize=(8, 4.5), dpi=400, facecolor=PAGE_BG)\nax.set_facecolor(PAGE_BG)\ny_max = 120\n\n# Operating range shading\nax.axvspan(op_min, op_max, alpha=0.06, color=INK, zorder=0)\nax.axvline(op_min, color=INK_SOFT, linewidth=1.0, linestyle=\":\", alpha=0.5, zorder=1)\nax.axvline(op_max, color=INK_SOFT, linewidth=1.0, linestyle=\":\", alpha=0.5, zorder=1)\nax.text(\n    (op_min + op_max) / 2,\n    3,\n    \"Operating Range\",\n    fontsize=7,\n    color=INK_SOFT,\n    ha=\"center\",\n    va=\"bottom\",\n    fontstyle=\"italic\",\n)\n\n# Mode curves\nfor mode, color in zip(modes, mode_colors, strict=True):\n    ax.plot(speed_rpm, mode, linewidth=2.5, color=color, zorder=3, solid_capstyle=\"round\")\n\n# End-of-line labels — de-collide vertically (increased min_gap for cleaner separation)\nend_vals = [(mode[-1], label, color) for mode, label, color in zip(modes, mode_labels, mode_colors, strict=True)]\nend_vals.sort(key=lambda x: x[0])\nmin_gap = 7.5\npositions = [v[0] for v in end_vals]\nfor i in range(1, len(positions)):\n    if positions[i] - positions[i - 1] < min_gap:\n        positions[i] = positions[i - 1] + min_gap\nfor y_pos, (_, label, color) in zip(positions, end_vals, strict=True):\n    ax.annotate(\n        label,\n        xy=(speed_rpm[-1], y_pos),\n        xytext=(6, 0),\n        textcoords=\"offset points\",\n        fontsize=7,\n        color=color,\n        fontweight=\"bold\",\n        va=\"center\",\n        zorder=4,\n        annotation_clip=False,\n    )\n\n# Engine order lines with rotated labels\n# slope in display inches: (eo/60 Hz/RPM) × (axes_h_in / y_range) / (axes_w_in / x_range)\nax_h_frac = 0.78  # top(0.91) - bottom(0.13)\nax_w_frac = 0.75  # right(0.84) - left(0.09)\nax_h_in = 4.5 * ax_h_frac\nax_w_in = 8.0 * ax_w_frac\n\nfor eo in engine_orders:\n    eo_line = eo_freq[eo]\n    visible = eo_line <= y_max\n    ax.plot(\n        speed_rpm[visible], eo_line[visible], linewidth=1.5, color=INK_SOFT, linestyle=(0, (8, 4)), alpha=0.7, zorder=2\n    )\n    target_freq = y_max * 0.28\n    target_rpm = target_freq * 60 / eo\n    if target_rpm < 5800:\n        slope_display = (eo / 60) * (ax_h_in / y_max) / (ax_w_in / 6000)\n        angle_deg = np.degrees(np.arctan(slope_display))\n        ax.annotate(\n            f\"{eo}×\",\n            xy=(target_rpm, target_freq),\n            fontsize=9,\n            color=INK_SOFT,\n            fontweight=\"bold\",\n            ha=\"center\",\n            va=\"bottom\",\n            rotation=angle_deg,\n            rotation_mode=\"anchor\",\n            zorder=4,\n            bbox={\"boxstyle\": \"round,pad=0.15\", \"facecolor\": ELEVATED_BG, \"edgecolor\": \"none\", \"alpha\": 0.85},\n        )\n\n# Critical speed markers\ncs_arr, cf_arr = np.array(critical_speeds), np.array(critical_freqs)\nin_op = (cs_arr >= op_min) & (cs_arr <= op_max)\n\nif np.any(~in_op):\n    ax.scatter(\n        cs_arr[~in_op], cf_arr[~in_op], s=80, color=INK_MUTED, edgecolors=PAGE_BG, linewidth=0.8, zorder=5, alpha=0.55\n    )\n\nif np.any(in_op):\n    ax.scatter(\n        cs_arr[in_op], cf_arr[in_op], s=110, color=ANYPLOT_AMBER, edgecolors=INK, linewidth=1.0, zorder=6, marker=\"D\"\n    )\n    op_s = cs_arr[in_op]\n    op_f = cf_arr[in_op]\n    op_m = np.array(critical_mlabels)[in_op]\n    order_idx = np.argsort(op_f)\n    n = len(order_idx)\n    for rank, si in enumerate(order_idx):\n        sign = 1 if rank % 2 == 0 else -1\n        dx = sign * 22\n        dy = -18 + rank * (36 / max(n - 1, 1))\n        ax.annotate(\n            op_m[si],\n            xy=(op_s[si], op_f[si]),\n            xytext=(dx, dy),\n            textcoords=\"offset points\",\n            fontsize=7,\n            color=INK,\n            fontweight=\"bold\",\n            arrowprops={\"arrowstyle\": \"-|>\", \"color\": INK_SOFT, \"lw\": 0.8, \"shrinkB\": 3},\n            zorder=7,\n            bbox={\n                \"boxstyle\": \"round,pad=0.2\",\n                \"facecolor\": ELEVATED_BG,\n                \"edgecolor\": ANYPLOT_AMBER,\n                \"alpha\": 0.9,\n                \"linewidth\": 0.7,\n            },\n        )\n\n# Style\ntitle = \"campbell-basic · python · matplotlib · anyplot.ai\"\ntitle_fontsize = max(8, round(12 * 67 / len(title))) if len(title) > 67 else 12\n\nax.set_xlabel(\"Rotational Speed (RPM)\", fontsize=10, color=INK)\nax.set_ylabel(\"Frequency (Hz)\", fontsize=10, color=INK)\nax.set_title(title, fontsize=title_fontsize, fontweight=\"medium\", color=INK, pad=10)\nax.tick_params(axis=\"both\", labelsize=8, colors=INK_SOFT, labelcolor=INK_SOFT)\nax.xaxis.set_major_formatter(FuncFormatter(lambda x, _: f\"{x:,.0f}\"))\n\nfor spine in (\"top\", \"right\"):\n    ax.spines[spine].set_visible(False)\nfor spine in (\"left\", \"bottom\"):\n    ax.spines[spine].set_linewidth(0.6)\n    ax.spines[spine].set_color(INK_SOFT)\n\nax.set_xlim(0, 6000)\nax.set_ylim(0, y_max)\nax.yaxis.grid(True, alpha=0.15, linewidth=0.6, color=INK)\nax.set_axisbelow(True)\n\n# Legend\neo_handle = Line2D([0], [0], color=INK_SOFT, linewidth=1.5, linestyle=(0, (8, 4)), alpha=0.7)\ncrit_outside = Line2D(\n    [0], [0], marker=\"o\", color=\"none\", markerfacecolor=INK_MUTED, markeredgecolor=PAGE_BG, markersize=7, alpha=0.5\n)\ncrit_inside = Line2D(\n    [0], [0], marker=\"D\", color=\"none\", markerfacecolor=ANYPLOT_AMBER, markeredgecolor=INK, markersize=7\n)\nop_handle = Patch(facecolor=INK, alpha=0.25, edgecolor=\"none\")\n\nhandles = [Line2D([0], [0], color=c, linewidth=2.5) for c in mode_colors] + [\n    eo_handle,\n    crit_outside,\n    crit_inside,\n    op_handle,\n]\nlegend_labels = mode_labels + [\"Engine Order (1×–3×)\", \"Critical Speed\", \"Critical (op. range)\", \"Operating Range\"]\n\nleg = ax.legend(\n    handles,\n    legend_labels,\n    fontsize=8,\n    loc=\"upper left\",\n    ncol=2,\n    framealpha=0.92,\n    edgecolor=INK_SOFT,\n    borderpad=0.5,\n    labelspacing=0.4,\n    handlelength=1.4,\n    columnspacing=1.0,\n)\nleg.get_frame().set_facecolor(ELEVATED_BG)\nplt.setp(leg.get_texts(), color=INK_SOFT)\n\nfig.subplots_adjust(left=0.09, right=0.86, top=0.91, bottom=0.13)\n\n# Save\nplt.savefig(f\"plot-{THEME}.png\", dpi=400, facecolor=PAGE_BG)\n"}