{"spec_id":"root-locus-basic","library":"altair","language":"python","code":"\"\"\" anyplot.ai\nroot-locus-basic: Root Locus Plot for Control Systems\nLibrary: altair 6.2.1 | Python 3.13.13\nQuality: 88/100 | Updated: 2026-06-18\n\"\"\"\n\nimport os\nimport sys\n\n\n# Prevent this file from shadowing the installed altair package when Python\n# prepends the script's directory to sys.path (standard `python altair.py` behavior).\n_impl_dir = os.path.dirname(os.path.abspath(__file__))\nsys.path = [p for p in sys.path if os.path.abspath(p if p else \".\") != _impl_dir]\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\"\n\n# Imprint palette — branch colors (positions 1→3)\nIMPRINT_PALETTE = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\", \"#99B314\"]\nPOLE_COLOR = \"#AE3030\"  # semantic anchor: marks critical/boundary locations\n\n# Data — G(s) = 1 / [s(s+1)(s+2)], open-loop poles at 0, −1, −2\nden_coeffs = [1.0, 3.0, 2.0, 0.0]\n\ngains = np.concatenate(\n    [\n        np.linspace(0.001, 0.5, 150),\n        np.linspace(0.5, 2, 200),\n        np.linspace(2, 6, 150),\n        np.linspace(6, 20, 150),\n        np.linspace(20, 80, 100),\n    ]\n)\nn_roots = 3\nall_roots = np.zeros((len(gains), n_roots), dtype=complex)\n\nfor i, k in enumerate(gains):\n    poly = np.array(den_coeffs, dtype=float)\n    poly[-1] += k\n    all_roots[i] = np.roots(poly)\n\n# Sort into continuous branches via nearest-neighbor matching\nall_roots[0] = np.sort(all_roots[0].real)\nfor i in range(1, len(gains)):\n    prev, curr = all_roots[i - 1], all_roots[i]\n    dist = np.abs(prev[:, None] - curr[None, :])\n    order = np.zeros(n_roots, dtype=int)\n    used = set()\n    for j in range(n_roots):\n        dists = [(dist[j, m], m) for m in range(n_roots) if m not in used]\n        _, best = min(dists)\n        used.add(best)\n        order[j] = best\n    all_roots[i] = curr[order]\n\n# Build branch dataframe\nrows = []\nfor b in range(n_roots):\n    for i in range(len(gains)):\n        rows.append(\n            {\n                \"real\": float(all_roots[i, b].real),\n                \"imaginary\": float(all_roots[i, b].imag),\n                \"gain\": float(gains[i]),\n                \"branch\": f\"Branch {b + 1}\",\n                \"idx\": i,\n            }\n        )\nlocus_df = pd.DataFrame(rows)\n\n# Open-loop poles (× markers)\npoles_df = pd.DataFrame(\n    {\"real\": [0.0, -1.0, -2.0], \"imaginary\": [0.0, 0.0, 0.0], \"label\": [\"Pole (s=0)\", \"Pole (s=−1)\", \"Pole (s=−2)\"]}\n)\n\n# Imaginary axis crossing: ω = √2, K = 6\nomega_cross = np.sqrt(2)\ncrossing_df = pd.DataFrame(\n    {\"real\": [0.0, 0.0], \"imaginary\": [omega_cross, -omega_cross], \"label\": [\"jω = j√2 (K=6)\", \"jω = −j√2 (K=6)\"]}\n)\n\n# Breakaway point: 3s²+6s+2 = 0 → s ≈ −0.423\nbreakaway_df = pd.DataFrame({\"bx\": [(-6 + np.sqrt(12)) / 6], \"by\": [0.0], \"label\": [\"Breakaway (s ≈ −0.42)\"]})\n\n# Damping ratio guide lines — all four values, both half-planes\ndamping_rows = []\nfor zeta in [0.2, 0.4, 0.6, 0.8]:\n    angle = np.pi - np.arccos(zeta)\n    for side, sign in [(\"upper\", 1), (\"lower\", -1)]:\n        seg = f\"zeta_{zeta}_{side}\"\n        damping_rows.append({\"gx\": 0.0, \"gy\": 0.0, \"seg\": seg, \"ord\": 0})\n        damping_rows.append({\"gx\": 4.6 * np.cos(angle), \"gy\": sign * 4.6 * np.sin(angle), \"seg\": seg, \"ord\": 1})\ndamping_df = pd.DataFrame(damping_rows)\n\n# Damping labels — all 4 values on upper side\ndamping_label_rows = []\nfor zeta in [0.2, 0.4, 0.6, 0.8]:\n    angle = np.pi - np.arccos(zeta)\n    damping_label_rows.append({\"lx\": 4.0 * np.cos(angle), \"ly\": 4.0 * np.sin(angle), \"label\": f\"ζ={zeta}\"})\ndamping_label_df = pd.DataFrame(damping_label_rows)\n\n# Natural frequency arcs (ωn = 1, 2, 3, 4) — left half-plane semicircles\nwn_rows = []\nfor wn in [1.0, 2.0, 3.0, 4.0]:\n    theta = np.linspace(np.pi / 2, 3 * np.pi / 2, 60)\n    for j, t in enumerate(theta):\n        wn_rows.append({\"gx\": wn * np.cos(t), \"gy\": wn * np.sin(t), \"wn\": f\"ωn={wn}\", \"ord\": j})\nwn_df = pd.DataFrame(wn_rows)\n\n# Real axis segments: (−1, 0) and (−∞, −2)\nreal_axis_df = pd.DataFrame(\n    {\n        \"rx\": [-1.0, 0.0, -5.0, -2.0],\n        \"ry\": [0.0, 0.0, 0.0, 0.0],\n        \"seg\": [\"seg1\", \"seg1\", \"seg2\", \"seg2\"],\n        \"ord\": [0, 1, 0, 1],\n    }\n)\n\n# Gain-direction arrows on complex branches\narrows = []\nfor b in range(n_roots):\n    for idx in [350, 500]:\n        if idx + 5 < len(gains):\n            r0 = all_roots[idx, b]\n            if abs(r0.imag) > 0.3:\n                arrows.append({\"ax\": float(r0.real), \"ay\": float(r0.imag), \"branch\": f\"Branch {b + 1}\"})\narrow_df = pd.DataFrame(arrows) if arrows else pd.DataFrame({\"ax\": [], \"ay\": [], \"branch\": []})\n\n# Stability boundary at imaginary axis (x=0) — key insight for non-control-theory readers\nstability_line_df = pd.DataFrame({\"sx\": [0.0, 0.0], \"sy\": [-4.5, 4.5], \"ord\": [0, 1]})\nstability_label_df = pd.DataFrame({\"sx\": [-0.12], \"sy\": [3.5], \"label\": [\"Stability boundary\"]})\n\n# Scales — shift x domain left to reduce empty right-half-plane space\nx_scale = alt.Scale(domain=[-5.0, 2.0], nice=False)\ny_scale = alt.Scale(domain=[-4.5, 4.5], nice=False)\n\nbranch_domain = [\"Branch 1\", \"Branch 2\", \"Branch 3\"]\nbranch_palette = IMPRINT_PALETTE[:3]\n\n# Title — length 48, under 67 baseline, no scaling needed\ntitle_str = \"root-locus-basic · python · altair · anyplot.ai\"\n\n# Axis config for locus layer (font sizes; colors via configure_axis)\nax_cfg = {\"labelFontSize\": 10, \"titleFontSize\": 12, \"grid\": False, \"titlePadding\": 10}\n\n# Layers\nlocus_layer = (\n    alt.Chart(locus_df)\n    .mark_line(strokeWidth=2.5, opacity=0.92)\n    .encode(\n        x=alt.X(\"real:Q\", scale=x_scale, title=\"Real Axis (σ)\", axis=alt.Axis(**ax_cfg)),\n        y=alt.Y(\"imaginary:Q\", scale=y_scale, title=\"Imaginary Axis (jω)\", axis=alt.Axis(**ax_cfg)),\n        color=alt.Color(\n            \"branch:N\",\n            scale=alt.Scale(domain=branch_domain, range=branch_palette),\n            legend=alt.Legend(\n                title=\"Branch\",\n                titleFontSize=10,\n                labelFontSize=10,\n                symbolSize=150,\n                symbolStrokeWidth=2.5,\n                orient=\"top-right\",\n                offset=4,\n            ),\n        ),\n        order=\"idx:Q\",\n        tooltip=[\n            alt.Tooltip(\"branch:N\", title=\"Branch\"),\n            alt.Tooltip(\"real:Q\", title=\"σ\", format=\".3f\"),\n            alt.Tooltip(\"imaginary:Q\", title=\"jω\", format=\".3f\"),\n            alt.Tooltip(\"gain:Q\", title=\"Gain K\", format=\".2f\"),\n        ],\n    )\n)\n\ndamping_layer = (\n    alt.Chart(damping_df)\n    .mark_line(strokeWidth=0.7, strokeDash=[5, 4], color=INK_MUTED, opacity=0.55)\n    .encode(x=alt.X(\"gx:Q\", scale=x_scale), y=alt.Y(\"gy:Q\", scale=y_scale), detail=\"seg:N\", order=\"ord:Q\")\n)\n\ndamping_label_layer = (\n    alt.Chart(damping_label_df)\n    .mark_text(fontSize=11, color=INK_MUTED, fontStyle=\"italic\", align=\"center\")\n    .encode(x=alt.X(\"lx:Q\", scale=x_scale), y=alt.Y(\"ly:Q\", scale=y_scale), text=\"label:N\")\n)\n\nwn_layer = (\n    alt.Chart(wn_df)\n    .mark_line(strokeWidth=0.7, strokeDash=[3, 4], color=INK_MUTED, opacity=0.55)\n    .encode(x=alt.X(\"gx:Q\", scale=x_scale), y=alt.Y(\"gy:Q\", scale=y_scale), detail=\"wn:N\", order=\"ord:Q\")\n)\n\nreal_axis_layer = (\n    alt.Chart(real_axis_df)\n    .mark_line(strokeWidth=4, color=IMPRINT_PALETTE[0], opacity=0.2)\n    .encode(x=alt.X(\"rx:Q\", scale=x_scale), y=alt.Y(\"ry:Q\", scale=y_scale), detail=\"seg:N\", order=\"ord:Q\")\n)\n\nstability_line_layer = (\n    alt.Chart(stability_line_df)\n    .mark_line(strokeWidth=1.3, strokeDash=[7, 4], color=INK_SOFT, opacity=0.65)\n    .encode(x=alt.X(\"sx:Q\", scale=x_scale), y=alt.Y(\"sy:Q\", scale=y_scale), order=\"ord:Q\")\n)\n\nstability_label_layer = (\n    alt.Chart(stability_label_df)\n    .mark_text(fontSize=10, color=INK_SOFT, align=\"right\", fontStyle=\"italic\", dx=-6)\n    .encode(x=alt.X(\"sx:Q\", scale=x_scale), y=alt.Y(\"sy:Q\", scale=y_scale), text=\"label:N\")\n)\n\npoles_layer = (\n    alt.Chart(poles_df)\n    .mark_point(shape=\"cross\", size=420, strokeWidth=3, color=POLE_COLOR, filled=False)\n    .encode(\n        x=alt.X(\"real:Q\", scale=x_scale),\n        y=alt.Y(\"imaginary:Q\", scale=y_scale),\n        tooltip=[alt.Tooltip(\"label:N\", title=\"\")],\n    )\n)\n\ncrossing_layer = (\n    alt.Chart(crossing_df)\n    .mark_point(shape=\"diamond\", size=360, strokeWidth=2, color=POLE_COLOR, filled=True)\n    .encode(\n        x=alt.X(\"real:Q\", scale=x_scale),\n        y=alt.Y(\"imaginary:Q\", scale=y_scale),\n        tooltip=[alt.Tooltip(\"label:N\", title=\"Crossing\")],\n    )\n)\n\ncrossing_text = (\n    alt.Chart(crossing_df)\n    .mark_text(fontSize=10, fontWeight=\"bold\", color=POLE_COLOR, align=\"left\", dx=18)\n    .encode(x=alt.X(\"real:Q\", scale=x_scale), y=alt.Y(\"imaginary:Q\", scale=y_scale), text=\"label:N\")\n)\n\nbreakaway_layer = (\n    alt.Chart(breakaway_df)\n    .mark_point(shape=\"square\", size=180, color=INK_SOFT, filled=True, opacity=0.7)\n    .encode(\n        x=alt.X(\"bx:Q\", scale=x_scale), y=alt.Y(\"by:Q\", scale=y_scale), tooltip=[alt.Tooltip(\"label:N\", title=\"Point\")]\n    )\n)\n\narrow_up_df = arrow_df[arrow_df[\"ay\"] > 0] if len(arrow_df) > 0 else arrow_df\narrow_down_df = arrow_df[arrow_df[\"ay\"] <= 0] if len(arrow_df) > 0 else arrow_df\n\narrow_up_layer = (\n    alt.Chart(arrow_up_df)\n    .mark_point(shape=\"triangle-up\", size=200, filled=True, opacity=0.8)\n    .encode(\n        x=alt.X(\"ax:Q\", scale=x_scale),\n        y=alt.Y(\"ay:Q\", scale=y_scale),\n        color=alt.Color(\"branch:N\", scale=alt.Scale(domain=branch_domain, range=branch_palette), legend=None),\n    )\n)\n\narrow_down_layer = (\n    alt.Chart(arrow_down_df)\n    .mark_point(shape=\"triangle-down\", size=200, filled=True, opacity=0.8)\n    .encode(\n        x=alt.X(\"ax:Q\", scale=x_scale),\n        y=alt.Y(\"ay:Q\", scale=y_scale),\n        color=alt.Color(\"branch:N\", scale=alt.Scale(domain=branch_domain, range=branch_palette), legend=None),\n    )\n)\n\n# Compose all layers\nchart = (\n    (\n        stability_line_layer\n        + stability_label_layer\n        + locus_layer\n        + damping_layer\n        + damping_label_layer\n        + wn_layer\n        + real_axis_layer\n        + poles_layer\n        + crossing_layer\n        + crossing_text\n        + breakaway_layer\n        + arrow_up_layer\n        + arrow_down_layer\n    )\n    .properties(\n        width=500,\n        height=460,\n        background=PAGE_BG,\n        title=alt.Title(\n            title_str,\n            fontSize=16,\n            fontWeight=\"bold\",\n            color=INK,\n            subtitle=\"G(s) = 1/[s(s+1)(s+2)]  ·  Closed-Loop Poles vs Gain K\",\n            subtitleFontSize=11,\n            subtitleColor=INK_SOFT,\n            subtitlePadding=8,\n            anchor=\"start\",\n            offset=8,\n        ),\n    )\n    .configure_view(fill=PAGE_BG, stroke=None, strokeWidth=0)\n    .configure_axis(domain=False, tickColor=INK_SOFT, labelColor=INK_SOFT, titleColor=INK)\n    .configure_title(color=INK)\n    .configure_legend(fillColor=ELEVATED_BG, strokeColor=INK_SOFT, labelColor=INK_SOFT, titleColor=INK)\n    .interactive()\n)\n\n# Save PNG\nchart.save(f\"plot-{THEME}.png\", scale_factor=4.0)\n\n# Pad to exact 2400×2400 (square target for root locus)\nTW, TH = 2400, 2400\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\n# Save HTML (interactive)\nchart.save(f\"plot-{THEME}.html\")\n"}