{"spec_id":"root-locus-basic","library":"plotly","language":"python","code":"\"\"\" anyplot.ai\nroot-locus-basic: Root Locus Plot for Control Systems\nLibrary: plotly 6.8.0 | Python 3.13.14\nQuality: 86/100 | Updated: 2026-06-18\n\"\"\"\n\nimport os\n\nimport numpy as np\nimport plotly.graph_objects as go\n\n\n# Theme tokens — Imprint palette, theme-adaptive chrome\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\"\nGRID = \"rgba(26,26,23,0.15)\" if THEME == \"light\" else \"rgba(240,239,232,0.15)\"\nREF_LINE = \"rgba(107,106,99,0.28)\" if THEME == \"light\" else \"rgba(168,167,159,0.28)\"\n\n# Imprint categorical palette — positions 1–3 for the three root locus branches\nBRANCH_COLORS = [\"#009E73\", \"#C475FD\", \"#4467A3\"]\n\n# Data: G(s) = 1 / (s(s+1)(s+3))\n# Open-loop poles at s = 0, -1, -3; no finite zeros\n# Characteristic equation: s^3 + 4s^2 + 3s + K = 0\nopen_loop_poles = np.array([0.0, -1.0, -3.0])\n\ngains = np.concatenate(\n    [\n        np.linspace(0, 0.5, 200),\n        np.linspace(0.5, 4, 400),\n        np.linspace(4, 12, 400),\n        np.linspace(12, 50, 300),\n        np.linspace(50, 200, 200),\n    ]\n)\n\nbranches = {i: {\"real\": [], \"imag\": [], \"gain\": []} for i in range(3)}\nprev_roots = open_loop_poles.copy().astype(complex)\n\nfor K in gains:\n    roots = np.roots([1, 4, 3, K])\n    roots = np.sort_complex(roots)\n    used = [False] * 3\n    assignment = [0] * 3\n    for i in range(3):\n        best_j, best_dist = -1, np.inf\n        for j in range(3):\n            if not used[j]:\n                d = abs(prev_roots[i] - roots[j])\n                if d < best_dist:\n                    best_dist = d\n                    best_j = j\n        used[best_j] = True\n        assignment[i] = best_j\n    for i in range(3):\n        r = roots[assignment[i]]\n        branches[i][\"real\"].append(r.real)\n        branches[i][\"imag\"].append(r.imag)\n        branches[i][\"gain\"].append(K)\n    prev_roots = np.array([roots[assignment[i]] for i in range(3)])\n\nbranch_names = [\"Branch 1 (from s=0)\", \"Branch 2 (from s=−1)\", \"Branch 3 (from s=−3)\"]\n\n# Plot\nfig = go.Figure()\n\n# Real axis root locus segments: [−1, 0] and (−∞, −3]\nfor seg in [[-1, 0], [-5.5, -3]]:\n    fig.add_trace(\n        go.Scatter(\n            x=seg,\n            y=[0, 0],\n            mode=\"lines\",\n            line={\"width\": 7, \"color\": \"rgba(0,158,115,0.18)\"},\n            showlegend=False,\n            hoverinfo=\"skip\",\n        )\n    )\n\n# Constant damping ratio lines (ζ = 0.2, 0.4, 0.6, 0.8)\nr_max = 5.3\nfor zeta in [0.2, 0.4, 0.6, 0.8]:\n    r_line = np.linspace(0, r_max, 2)\n    x_vals = -r_line * zeta\n    y_vals = r_line * np.sqrt(1 - zeta**2)\n    for sign in [1, -1]:\n        fig.add_trace(\n            go.Scatter(\n                x=x_vals,\n                y=sign * y_vals,\n                mode=\"lines\",\n                line={\"width\": 1, \"color\": REF_LINE, \"dash\": \"dash\"},\n                showlegend=False,\n                hoverinfo=\"skip\",\n            )\n        )\n    fig.add_annotation(\n        x=x_vals[-1], y=y_vals[-1] + 0.12, text=f\"ζ={zeta}\", showarrow=False, font={\"size\": 10, \"color\": INK_MUTED}\n    )\n\n# Constant natural frequency arcs (ωn = 1, 2, 3, 4, 5)\ntheta = np.linspace(np.pi / 2, np.pi, 100)\nfor wn in [1, 2, 3, 4, 5]:\n    for sign in [1, -1]:\n        fig.add_trace(\n            go.Scatter(\n                x=wn * np.cos(theta),\n                y=sign * wn * np.sin(theta),\n                mode=\"lines\",\n                line={\"width\": 1, \"color\": REF_LINE, \"dash\": \"dot\"},\n                showlegend=False,\n                hoverinfo=\"skip\",\n            )\n        )\n    fig.add_annotation(\n        x=wn * np.cos(np.pi * 0.55),\n        y=wn * np.sin(np.pi * 0.55) + 0.12,\n        text=f\"ωn={wn}\",\n        showarrow=False,\n        font={\"size\": 10, \"color\": INK_MUTED},\n    )\n\n# Stability boundary — imaginary axis shaded band\nfig.add_shape(\n    type=\"line\", x0=0, x1=0, y0=-5.5, y1=5.5, line={\"color\": \"rgba(174,48,48,0.15)\", \"width\": 20}, layer=\"below\"\n)\nfig.add_annotation(\n    x=0.4,\n    y=4.7,\n    text=\"Stability<br>Boundary\",\n    showarrow=False,\n    font={\"size\": 10, \"color\": \"rgba(174,48,48,0.6)\", \"family\": \"Arial, sans-serif\"},\n)\n\n# Root locus branches\nfor i in range(3):\n    fig.add_trace(\n        go.Scatter(\n            x=branches[i][\"real\"],\n            y=branches[i][\"imag\"],\n            mode=\"lines\",\n            line={\"width\": 2.5, \"color\": BRANCH_COLORS[i]},\n            name=branch_names[i],\n            legendgroup=f\"branch{i}\",\n            hovertemplate=(\n                f\"<b>Branch {i + 1}</b><br>σ = %{{x:.3f}}<br>jω = %{{y:.3f}}<br>K = %{{customdata:.2f}}<extra></extra>\"\n            ),\n            customdata=branches[i][\"gain\"],\n        )\n    )\n\n# Direction arrows indicating increasing gain\nfor i in range(3):\n    n = len(branches[i][\"real\"])\n    for frac in [0.3, 0.65]:\n        idx = int(n * frac)\n        if idx < n - 5:\n            dx = branches[i][\"real\"][idx + 5] - branches[i][\"real\"][idx]\n            dy = branches[i][\"imag\"][idx + 5] - branches[i][\"imag\"][idx]\n            norm = np.sqrt(dx**2 + dy**2)\n            if norm > 1e-6:\n                fig.add_annotation(\n                    x=branches[i][\"real\"][idx],\n                    y=branches[i][\"imag\"][idx],\n                    ax=-dx / norm * 25,\n                    ay=dy / norm * 25,\n                    xref=\"x\",\n                    yref=\"y\",\n                    axref=\"pixel\",\n                    ayref=\"pixel\",\n                    showarrow=True,\n                    arrowhead=3,\n                    arrowsize=1.8,\n                    arrowwidth=2,\n                    arrowcolor=BRANCH_COLORS[i],\n                    text=\"\",\n                )\n\n# Open-loop poles (× markers)\nfig.add_trace(\n    go.Scatter(\n        x=open_loop_poles,\n        y=np.zeros(3),\n        mode=\"markers+text\",\n        marker={\"symbol\": \"x-thin\", \"size\": 16, \"color\": INK, \"line\": {\"width\": 3}},\n        text=[\"s=0\", \"s=−1\", \"s=−3\"],\n        textposition=\"top center\",\n        textfont={\"size\": 10, \"color\": INK},\n        name=\"Open-loop poles\",\n        hovertemplate=\"Pole at s = %{x:.1f}<extra></extra>\",\n    )\n)\n\n# jω-axis crossing (Routh–Hurwitz: K_crit = 12, roots at ±j√3)\nK_crit = 12.0\njw_cross = np.sqrt(3)\nfig.add_trace(\n    go.Scatter(\n        x=[0, 0],\n        y=[jw_cross, -jw_cross],\n        mode=\"markers\",\n        marker={\"symbol\": \"diamond\", \"size\": 14, \"color\": \"#AE3030\", \"line\": {\"width\": 2, \"color\": PAGE_BG}},\n        name=f\"jω crossing (K={K_crit:.0f})\",\n        hovertemplate=\"s = %{y:+.3f}j<br>K = 12 (critical gain)<extra></extra>\",\n    )\n)\nfig.add_annotation(\n    x=0,\n    y=jw_cross,\n    text=f\"  K={K_crit:.0f}\",\n    showarrow=True,\n    arrowhead=0,\n    arrowwidth=1,\n    arrowcolor=\"#AE3030\",\n    ax=50,\n    ay=-20,\n    font={\"size\": 10, \"color\": \"#AE3030\"},\n)\n\n# Breakaway point (σ ≈ −0.451)\ns_break = -0.451\nK_break = -(s_break**3 + 4 * s_break**2 + 3 * s_break)\nfig.add_trace(\n    go.Scatter(\n        x=[s_break],\n        y=[0],\n        mode=\"markers\",\n        marker={\"symbol\": \"star\", \"size\": 18, \"color\": \"#BD8233\", \"line\": {\"width\": 2, \"color\": PAGE_BG}},\n        name=f\"Breakaway (K≈{K_break:.2f})\",\n        hovertemplate=\"Breakaway point<br>s ≈ −0.451<br>K ≈ %{customdata:.2f}<extra></extra>\",\n        customdata=[K_break],\n    )\n)\nfig.add_annotation(\n    x=s_break,\n    y=0,\n    text=f\"  K≈{K_break:.2f}\",\n    showarrow=True,\n    arrowhead=0,\n    arrowwidth=1,\n    arrowcolor=\"#BD8233\",\n    ax=-55,\n    ay=30,\n    font={\"size\": 10, \"color\": \"#BD8233\"},\n)\n\n# Layout — square canvas preserves equal axis scaling for the complex plane\naxis_range = 5.5\nfig.update_layout(\n    autosize=False,\n    title={\n        \"text\": \"root-locus-basic · python · plotly · anyplot.ai\",\n        \"font\": {\"size\": 16, \"color\": INK, \"family\": \"Arial, sans-serif\"},\n        \"x\": 0.5,\n        \"xanchor\": \"center\",\n        \"y\": 0.975,\n    },\n    xaxis={\n        \"title\": {\n            \"text\": \"Real Axis (σ)\",\n            \"font\": {\"size\": 12, \"color\": INK, \"family\": \"Arial, sans-serif\"},\n            \"standoff\": 12,\n        },\n        \"tickfont\": {\"size\": 10, \"color\": INK_SOFT},\n        \"zeroline\": True,\n        \"zerolinewidth\": 1.5,\n        \"zerolinecolor\": INK_SOFT,\n        \"showgrid\": True,\n        \"gridwidth\": 1,\n        \"gridcolor\": GRID,\n        \"range\": [-axis_range, axis_range],\n        \"constrain\": \"domain\",\n        \"dtick\": 1,\n        \"linecolor\": INK_SOFT,\n    },\n    yaxis={\n        \"title\": {\n            \"text\": \"Imaginary Axis (jω)\",\n            \"font\": {\"size\": 12, \"color\": INK, \"family\": \"Arial, sans-serif\"},\n            \"standoff\": 12,\n        },\n        \"tickfont\": {\"size\": 10, \"color\": INK_SOFT},\n        \"zeroline\": True,\n        \"zerolinewidth\": 1.5,\n        \"zerolinecolor\": INK_SOFT,\n        \"showgrid\": True,\n        \"gridwidth\": 1,\n        \"gridcolor\": GRID,\n        \"range\": [-axis_range, axis_range],\n        \"scaleanchor\": \"x\",\n        \"scaleratio\": 1,\n        \"dtick\": 1,\n        \"linecolor\": INK_SOFT,\n    },\n    paper_bgcolor=PAGE_BG,\n    plot_bgcolor=PAGE_BG,\n    font={\"color\": INK},\n    legend={\n        \"font\": {\"size\": 10, \"color\": INK_SOFT, \"family\": \"Arial, sans-serif\"},\n        \"bgcolor\": ELEVATED_BG,\n        \"bordercolor\": INK_SOFT,\n        \"borderwidth\": 1,\n        \"x\": 0.99,\n        \"y\": 0.01,\n        \"xanchor\": \"right\",\n        \"yanchor\": \"bottom\",\n        \"itemsizing\": \"constant\",\n    },\n    margin={\"l\": 80, \"r\": 40, \"t\": 80, \"b\": 60},\n    hoverlabel={\"bgcolor\": ELEVATED_BG, \"font_size\": 12, \"bordercolor\": INK_SOFT},\n)\n\n# Transfer function subtitle — lower-left, clear of legend (lower-right) and ζ labels (upper-left)\nfig.add_annotation(\n    text=\"G(s) = 1 / s(s+1)(s+3)\",\n    xref=\"paper\",\n    yref=\"paper\",\n    x=0.03,\n    y=0.03,\n    xanchor=\"left\",\n    yanchor=\"bottom\",\n    showarrow=False,\n    font={\"size\": 10, \"color\": INK_MUTED, \"family\": \"Courier New, monospace\"},\n)\n\n# Save — square canvas (2400×2400)\nfig.write_image(f\"plot-{THEME}.png\", width=600, height=600, scale=4)\nfig.write_html(f\"plot-{THEME}.html\", include_plotlyjs=\"cdn\")\n"}