{"spec_id":"campbell-basic","library":"altair","language":"python","code":"\"\"\" anyplot.ai\ncampbell-basic: Campbell Diagram\nLibrary: altair 6.1.0 | Python 3.13.13\nQuality: 87/100 | Updated: 2026-05-28\n\"\"\"\n\nimport sys\n\n\nif sys.path and not sys.path[0].endswith(\"site-packages\"):\n    sys.path = [p for p in sys.path if \"implementations/python\" not in p]\n\nimport os\n\nimport altair as alt\nimport numpy as np\nimport pandas as pd\nfrom PIL import Image\n\n\n# Theme tokens\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\"\nIMPRINT_PALETTE = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\", \"#99B314\"]\n\n# Data — low-speed industrial machinery operating range 2500–4500 RPM\nnp.random.seed(42)\nspeeds = np.linspace(0, 6000, 80)\n\nmode_labels = [\"1st Bending\", \"2nd Bending\", \"1st Torsional\", \"Axial\", \"3rd Bending\"]\nbase_freqs = [45, 95, 130, 175, 220]\nslopes = [0.004, -0.003, 0.005, 0.001, -0.004]\ncurvatures = [5e-7, -4e-7, 2e-7, 1e-7, -3e-7]\n\nmode_rows = []\nfor label, base, slope, curv in zip(mode_labels, base_freqs, slopes, curvatures, strict=True):\n    freqs = base + slope * speeds + curv * speeds**2\n    for s, f in zip(speeds, freqs, strict=True):\n        mode_rows.append({\"RPM\": s, \"Hz\": f, \"Mode\": label})\n\nengine_orders = [1, 2, 3]\neo_rows = []\nfor order in engine_orders:\n    for s in speeds:\n        eo_rows.append({\"RPM\": s, \"Hz\": order * s / 60, \"EO\": f\"{order}x\"})\n\ndf_modes = pd.DataFrame(mode_rows)\ndf_eo = pd.DataFrame(eo_rows)\n\n# Dense sign-change detection for critical speed intersections\ncritical_rows = []\ndense_speeds = np.linspace(0, 6000, 5000)\nfor label, base, slope, curv in zip(mode_labels, base_freqs, slopes, curvatures, strict=True):\n    mode_freq = base + slope * dense_speeds + curv * dense_speeds**2\n    for order in engine_orders:\n        eo_freq = order * dense_speeds / 60\n        diff = mode_freq - eo_freq\n        sign_changes = np.where(np.diff(np.sign(diff)))[0]\n        for idx in sign_changes:\n            s_crit = dense_speeds[idx]\n            f_crit = eo_freq[idx]\n            if 100 < s_crit < 5900 and 5 < f_crit < 295:\n                in_op = 2500 <= s_crit <= 4500\n                critical_rows.append(\n                    {\"RPM\": round(s_crit), \"Hz\": round(f_crit, 1), \"Label\": f\"{label} / {order}x\", \"InOpRange\": in_op}\n                )\n\ndf_critical = pd.DataFrame(critical_rows)\ndf_crit_out = df_critical[~df_critical[\"InOpRange\"]]\ndf_crit_in = df_critical[df_critical[\"InOpRange\"]]\n\n# Select 2–3 key annotations: one outside + up to two within operating range\nkey_out = df_crit_out.sort_values(\"Hz\")\nkey_in = df_crit_in.sort_values(\"RPM\")\nannot_rows = []\nif len(key_out) > 0:\n    annot_rows.append(key_out.iloc[0].to_dict())\nif len(key_in) > 0:\n    annot_rows.append(key_in.iloc[0].to_dict())\nif len(key_in) > 2:\n    annot_rows.append(key_in.iloc[2].to_dict())\ndf_annot = pd.DataFrame(annot_rows) if annot_rows else pd.DataFrame(columns=df_critical.columns)\n\n# Chart scales\nop_min, op_max = 2500, 4500\nx_scale = alt.Scale(domain=[0, 6200], nice=False)\ny_scale = alt.Scale(domain=[0, 310])\n\n# Operating range shaded band\nop_band = (\n    alt.Chart(pd.DataFrame({\"x\": [op_min], \"x2\": [op_max]}))\n    .mark_rect(opacity=0.07, color=IMPRINT_PALETTE[2])\n    .encode(x=alt.X(\"x:Q\", scale=x_scale), x2=\"x2:Q\")\n)\n\n# Operating range label\nop_label = (\n    alt.Chart(pd.DataFrame({\"RPM\": [(op_min + op_max) / 2], \"Hz\": [10], \"label\": [\"Operating Range\"]}))\n    .mark_text(fontSize=10, fontStyle=\"italic\", fontWeight=\"bold\", color=IMPRINT_PALETTE[2])\n    .encode(x=alt.X(\"RPM:Q\", scale=x_scale), y=alt.Y(\"Hz:Q\", scale=y_scale), text=\"label:N\")\n)\n\n# Engine order excitation lines — dashed, muted, opacity raised from 0.55 to 0.68\neo_chart = (\n    alt.Chart(df_eo)\n    .mark_line(strokeWidth=1.8, strokeDash=[8, 6], opacity=0.68, color=INK_MUTED)\n    .encode(x=alt.X(\"RPM:Q\", scale=x_scale), y=alt.Y(\"Hz:Q\", scale=y_scale), detail=\"EO:N\")\n)\n\n# Engine order labels at right edge (direct labeling instead of legend)\neo_label_rows = []\nfor order in engine_orders:\n    end_hz = order * 6000 / 60\n    if end_hz <= 295:\n        eo_label_rows.append({\"RPM\": 6050, \"Hz\": end_hz, \"label\": f\"{order}x\"})\n    else:\n        cap_rpm = 285 * 60 / order\n        eo_label_rows.append({\"RPM\": cap_rpm, \"Hz\": 283, \"label\": f\"{order}x\"})\n\neo_labels = (\n    alt.Chart(pd.DataFrame(eo_label_rows))\n    .mark_text(fontSize=11, fontWeight=\"bold\", align=\"left\", dx=4, dy=-8, color=INK_SOFT)\n    .encode(x=alt.X(\"RPM:Q\", scale=x_scale), y=alt.Y(\"Hz:Q\", scale=y_scale), text=\"label:N\")\n)\n\n# Natural frequency mode curves\nmodes_chart = (\n    alt.Chart(df_modes)\n    .mark_line(strokeWidth=2.5)\n    .encode(\n        x=alt.X(\"RPM:Q\", title=\"Rotational Speed (RPM)\", scale=x_scale),\n        y=alt.Y(\"Hz:Q\", title=\"Frequency (Hz)\", scale=y_scale),\n        color=alt.Color(\n            \"Mode:N\",\n            scale=alt.Scale(domain=mode_labels, range=IMPRINT_PALETTE[:5]),\n            legend=alt.Legend(\n                title=\"Natural Frequencies\", titleFontSize=10, labelFontSize=10, symbolStrokeWidth=2.5, symbolSize=100\n            ),\n        ),\n    )\n)\n\n# Critical speed markers — outlined diamonds outside operating range\ncrit_out_chart = (\n    alt.Chart(df_crit_out)\n    .mark_point(size=130, shape=\"diamond\", filled=False, strokeWidth=1.8)\n    .encode(\n        x=alt.X(\"RPM:Q\", scale=x_scale),\n        y=alt.Y(\"Hz:Q\", scale=y_scale),\n        color=alt.value(IMPRINT_PALETTE[4]),\n        tooltip=[\"Label:N\", \"RPM:Q\", \"Hz:Q\"],\n    )\n)\n\n# Larger filled diamonds inside operating range — danger emphasis\ncrit_in_chart = (\n    alt.Chart(df_crit_in)\n    .mark_point(size=300, shape=\"diamond\", filled=True, stroke=PAGE_BG, strokeWidth=2.0)\n    .encode(\n        x=alt.X(\"RPM:Q\", scale=x_scale),\n        y=alt.Y(\"Hz:Q\", scale=y_scale),\n        color=alt.value(IMPRINT_PALETTE[4]),\n        tooltip=[\"Label:N\", \"RPM:Q\", \"Hz:Q\"],\n    )\n)\n\n# Consolidated annotation layer for key critical speeds (single layer, not per-row)\nannot_chart = (\n    alt.Chart(df_annot)\n    .mark_text(fontSize=11, fontWeight=\"bold\", align=\"left\", dx=8, dy=-20)\n    .encode(\n        x=alt.X(\"RPM:Q\", scale=x_scale),\n        y=alt.Y(\"Hz:Q\", scale=y_scale),\n        text=\"Label:N\",\n        color=alt.value(IMPRINT_PALETTE[4]),\n    )\n)\n\n# Compose layers\nlayers = [op_band, eo_chart, modes_chart, crit_out_chart, crit_in_chart, eo_labels, op_label]\nif len(df_annot) > 0:\n    layers.append(annot_chart)\n\ncombined = layers[0]\nfor lyr in layers[1:]:\n    combined = combined + lyr\n\ntitle_str = \"campbell-basic · python · altair · anyplot.ai\"\n\nchart = (\n    combined.properties(\n        width=620,\n        height=320,\n        background=PAGE_BG,\n        padding={\"left\": 0, \"right\": 0, \"top\": 0, \"bottom\": 0},\n        title=alt.Title(\n            title_str,\n            fontSize=16,\n            fontWeight=500,\n            anchor=\"start\",\n            color=INK,\n            subtitle=\"Natural Frequencies vs Engine Order Excitations\",\n            subtitleFontSize=11,\n            subtitleColor=INK_MUTED,\n        ),\n    )\n    .configure_view(fill=PAGE_BG, stroke=None)\n    .configure_axis(\n        domainColor=INK_SOFT,\n        tickColor=INK_SOFT,\n        gridColor=INK,\n        gridOpacity=0.15,\n        gridWidth=0.5,\n        labelColor=INK_SOFT,\n        labelFontSize=10,\n        titleColor=INK,\n        titleFontSize=12,\n        tickSize=5,\n    )\n    .configure_legend(\n        fillColor=ELEVATED_BG,\n        strokeColor=INK_SOFT,\n        labelColor=INK_SOFT,\n        labelFontSize=10,\n        titleColor=INK,\n        titleFontSize=10,\n        symbolStrokeWidth=2.5,\n        symbolSize=100,\n        orient=\"bottom\",\n        columns=5,\n    )\n    .configure_title(anchor=\"start\", offset=10, color=INK)\n)\n\n# Save — canvas gate: inner view 620×320 @ scale 4.0 → target 3200×1800 with padding\nTW, TH = 3200, 1800\nchart.save(f\"plot-{THEME}.png\", scale_factor=4.0)\n\n_img = Image.open(f\"plot-{THEME}.png\").convert(\"RGB\")\n_w, _h = _img.size\nif _w > TW or _h > TH:\n    raise SystemExit(\n        f\"altair vl-convert produced {_w}×{_h}, exceeds target {TW}×{TH}. \"\n        f\"Shrink chart .properties(width=, height=) values and re-render.\"\n    )\nif _w < TW or _h < TH:\n    _canvas = Image.new(\"RGB\", (TW, TH), PAGE_BG)\n    _canvas.paste(_img, ((TW - _w) // 2, (TH - _h) // 2))\n    _canvas.save(f\"plot-{THEME}.png\")\n\nchart.save(f\"plot-{THEME}.html\")\n"}