{"spec_id":"root-locus-basic","library":"seaborn","language":"python","code":"\"\"\" anyplot.ai\nroot-locus-basic: Root Locus Plot for Control Systems\nLibrary: seaborn 0.13.2 | Python 3.13.13\nQuality: 92/100 | Updated: 2026-06-17\n\"\"\"\n\nimport os\n\nimport matplotlib.pyplot as plt\nimport numpy as np\nimport pandas as pd\nimport seaborn as sns\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 — canonical order, first series always #009E73\nIMPRINT_PALETTE = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\", \"#99B314\"]\nBRANCH_PALETTE = IMPRINT_PALETTE[:3]  # three branches\nSTABILITY_RED = IMPRINT_PALETTE[4]  # matte red — stability boundary semantic role\n\n# Data — Transfer function G(s) = 1 / [s(s+1)(s+3)]\n# Open-loop poles at s = 0, -1, -3; no finite zeros\nopen_loop_poles = np.array([0.0, -1.0, -3.0])\nnum_coeffs = np.array([1.0])\nden_coeffs = np.poly(open_loop_poles)\n\ngains = np.concatenate(\n    [\n        np.linspace(0, 0.5, 200),\n        np.linspace(0.5, 4.0, 400),\n        np.linspace(4.0, 12.0, 400),\n        np.linspace(12.0, 50.0, 300),\n        np.linspace(50.0, 200.0, 200),\n    ]\n)\n\nn_poles = len(open_loop_poles)\nall_real = []\nall_imag = []\nall_gain = []\nall_branch = []\n\nprev_roots = np.sort(open_loop_poles).astype(complex)\n\nfor k in gains:\n    char_poly = den_coeffs.copy()\n    char_poly[-1] += k * num_coeffs[-1]\n    roots = np.roots(char_poly)\n\n    sorted_roots = np.empty_like(roots)\n    available = list(range(len(roots)))\n    for i in range(len(prev_roots)):\n        distances = np.abs(roots[available] - prev_roots[i])\n        best = np.argmin(distances)\n        sorted_roots[i] = roots[available[best]]\n        available.pop(best)\n    prev_roots = sorted_roots\n\n    for b in range(n_poles):\n        all_real.append(sorted_roots[b].real)\n        all_imag.append(sorted_roots[b].imag)\n        all_gain.append(k)\n        all_branch.append(f\"Branch {b + 1}\")\n\ndf = pd.DataFrame({\"Real\": all_real, \"Imaginary\": all_imag, \"Gain K\": all_gain, \"Branch\": all_branch})\n\n# Imaginary axis crossings — K_critical = 12 by Routh criterion\nk_crit = 12.0\nchar_at_crit = den_coeffs.copy()\nchar_at_crit[-1] += k_crit * num_coeffs[-1]\ncrit_roots = np.roots(char_at_crit)\nimag_crossings = crit_roots[np.abs(crit_roots.real) < 0.05]\n\ndf_poles = pd.DataFrame({\"Real\": open_loop_poles.real, \"Imaginary\": np.zeros_like(open_loop_poles)})\n\ncrossing_pts = [(c.real, c.imag) for c in imag_crossings if np.abs(c.imag) > 0.01]\ndf_crossings = pd.DataFrame(crossing_pts, columns=[\"Real\", \"Imaginary\"])\n\n# Constant damping ratio reference lines\nr_line = np.linspace(0, 6, 150)\ndamping_rows = []\nfor zeta in [0.3, 0.5, 0.7, 0.9]:\n    angle = np.arccos(zeta)\n    for r in r_line:\n        x = -r * np.cos(angle)\n        y_pos = r * np.sin(angle)\n        damping_rows.append({\"Real\": x, \"Imaginary\": y_pos, \"zeta\": f\"ζ={zeta}\", \"half\": \"upper\"})\n        damping_rows.append({\"Real\": x, \"Imaginary\": -y_pos, \"zeta\": f\"ζ={zeta}\", \"half\": \"lower\"})\ndf_damping = pd.DataFrame(damping_rows)\n\n# Constant natural frequency semicircles\nwn_rows = []\nfor wn in [2, 4]:\n    theta = np.linspace(np.pi / 2, np.pi, 80)\n    for t in theta:\n        wn_rows.append({\"Real\": wn * np.cos(t), \"Imaginary\": wn * np.sin(t), \"wn\": f\"ωn={wn}\", \"half\": \"upper\"})\n        wn_rows.append({\"Real\": wn * np.cos(t), \"Imaginary\": -wn * np.sin(t), \"wn\": f\"ωn={wn}\", \"half\": \"lower\"})\ndf_wn = pd.DataFrame(wn_rows)\n\n# Plot — square canvas (2400 × 2400 px) suits equal-aspect root locus geometry\nsns.set_theme(\n    style=\"ticks\",\n    rc={\n        \"figure.facecolor\": PAGE_BG,\n        \"axes.facecolor\": PAGE_BG,\n        \"axes.edgecolor\": INK_SOFT,\n        \"axes.labelcolor\": INK,\n        \"text.color\": INK,\n        \"xtick.color\": INK_SOFT,\n        \"ytick.color\": INK_SOFT,\n        \"grid.color\": INK,\n        \"grid.alpha\": 0.12,\n        \"legend.facecolor\": ELEVATED_BG,\n        \"legend.edgecolor\": INK_SOFT,\n    },\n)\n\nfig, ax = plt.subplots(figsize=(6, 6), dpi=400, facecolor=PAGE_BG)\nax.set_facecolor(PAGE_BG)\n\n# Reference grid — damping ratio lines\nfor half in [\"upper\", \"lower\"]:\n    subset = df_damping[df_damping[\"half\"] == half]\n    sns.lineplot(\n        data=subset,\n        x=\"Real\",\n        y=\"Imaginary\",\n        hue=\"zeta\",\n        palette=[INK_MUTED] * 4,\n        linewidth=0.6,\n        linestyle=\"--\",\n        alpha=0.35,\n        sort=False,\n        legend=False,\n        ax=ax,\n    )\n\n# Reference grid — natural frequency semicircles\nfor half in [\"upper\", \"lower\"]:\n    subset = df_wn[df_wn[\"half\"] == half]\n    sns.lineplot(\n        data=subset,\n        x=\"Real\",\n        y=\"Imaginary\",\n        hue=\"wn\",\n        palette=[INK_MUTED] * 2,\n        linewidth=0.6,\n        linestyle=\":\",\n        alpha=0.3,\n        sort=False,\n        legend=False,\n        ax=ax,\n    )\n\n# Real-axis locus segments (highlighted as thick semi-transparent bands)\nax.plot([-8, -3], [0, 0], color=BRANCH_PALETTE[0], linewidth=6, alpha=0.18, solid_capstyle=\"round\")\nax.plot([-1, 0], [0, 0], color=BRANCH_PALETTE[0], linewidth=6, alpha=0.18, solid_capstyle=\"round\")\n\n# Main locus branches — seaborn lineplot with hue grouping\nsns.lineplot(\n    data=df,\n    x=\"Real\",\n    y=\"Imaginary\",\n    hue=\"Branch\",\n    palette=BRANCH_PALETTE,\n    linewidth=2.5,\n    alpha=0.9,\n    sort=False,\n    legend=True,\n    ax=ax,\n)\n\n# Direction arrows for increasing gain\nfor b_idx, branch_name in enumerate(df[\"Branch\"].unique()):\n    branch_data = df[df[\"Branch\"] == branch_name]\n    n_pts = len(branch_data)\n    arrow_idx = int(n_pts * 0.4)\n    if arrow_idx + 5 < n_pts:\n        x0 = branch_data.iloc[arrow_idx][\"Real\"]\n        y0 = branch_data.iloc[arrow_idx][\"Imaginary\"]\n        x1 = branch_data.iloc[arrow_idx + 5][\"Real\"]\n        y1 = branch_data.iloc[arrow_idx + 5][\"Imaginary\"]\n        dx, dy = x1 - x0, y1 - y0\n        ax.annotate(\n            \"\",\n            xy=(x0 + dx * 0.5, y0 + dy * 0.5),\n            xytext=(x0, y0),\n            arrowprops={\"arrowstyle\": \"-|>\", \"color\": BRANCH_PALETTE[b_idx], \"lw\": 2.5, \"mutation_scale\": 20},\n        )\n\n# Open-loop poles (×) — theme-adaptive ink color\nsns.scatterplot(\n    data=df_poles,\n    x=\"Real\",\n    y=\"Imaginary\",\n    color=INK,\n    marker=\"X\",\n    s=350,\n    edgecolor=PAGE_BG,\n    linewidth=1.5,\n    zorder=10,\n    legend=False,\n    ax=ax,\n)\n\n# Stability boundary crossings (◆) — matte red semantic anchor\nsns.scatterplot(\n    data=df_crossings,\n    x=\"Real\",\n    y=\"Imaginary\",\n    color=STABILITY_RED,\n    marker=\"D\",\n    s=300,\n    edgecolor=PAGE_BG,\n    linewidth=1.5,\n    zorder=10,\n    legend=False,\n    ax=ax,\n)\n\n# Annotate stability crossing\ncrossing_y = np.sqrt(3)\nax.annotate(\n    f\"K = {k_crit:.0f}  |  jω = ±{crossing_y:.2f}\",\n    xy=(0.0, crossing_y),\n    xytext=(2.5, crossing_y + 1.8),\n    fontsize=8,\n    color=STABILITY_RED,\n    fontweight=\"bold\",\n    ha=\"center\",\n    bbox={\n        \"boxstyle\": \"round,pad=0.3\",\n        \"facecolor\": ELEVATED_BG,\n        \"edgecolor\": STABILITY_RED,\n        \"alpha\": 0.9,\n        \"linewidth\": 1,\n    },\n    arrowprops={\"arrowstyle\": \"->\", \"color\": STABILITY_RED, \"lw\": 1.5, \"connectionstyle\": \"arc3,rad=0.15\"},\n)\n\n# Damping ratio labels\nfor zeta in [0.5, 0.9]:\n    angle = np.arccos(zeta)\n    label_r = 4.2\n    lx = -label_r * np.cos(angle)\n    ly = label_r * np.sin(angle)\n    ax.text(lx - 0.1, ly + 0.2, f\"ζ={zeta}\", fontsize=8, color=INK_MUTED, alpha=0.85, style=\"italic\")\n\n# Axis reference lines (real and imaginary axes)\nax.axhline(0, color=INK_SOFT, linewidth=0.5, alpha=0.5)\nax.axvline(0, color=INK_SOFT, linewidth=0.5, alpha=0.5)\n\n# Style\ntitle = \"root-locus-basic · python · seaborn · anyplot.ai\"\nn = len(title)\nratio = 67 / n if n > 67 else 1.0\ntitle_fontsize = max(8, round(12 * ratio))\n\nax.set_title(title, fontsize=title_fontsize, color=INK, pad=12)\nax.set_xlabel(\"Real Axis (σ)\", fontsize=10, color=INK)\nax.set_ylabel(\"Imaginary Axis (jω)\", fontsize=10, color=INK)\nax.tick_params(axis=\"both\", labelsize=8, colors=INK_SOFT)\nax.set_xlim(-7, 3.5)\nax.set_ylim(-5.5, 5.5)\nax.set_aspect(\"equal\")\nsns.despine(ax=ax)\n\n# Refine legend with transfer function context\nlegend = ax.get_legend()\nif legend:\n    legend.set_title(\"G(s) = 1 / [s(s+1)(s+3)]\")\n    legend.get_title().set_fontsize(8)\n    legend.get_title().set_fontstyle(\"italic\")\n    legend.get_title().set_color(INK)\n    for text in legend.get_texts():\n        text.set_fontsize(8)\n        text.set_color(INK_SOFT)\n    legend.set_frame_on(True)\n    legend.get_frame().set_facecolor(ELEVATED_BG)\n    legend.get_frame().set_edgecolor(INK_SOFT)\n    legend.get_frame().set_linewidth(0.5)\n\n# Save\nplt.savefig(f\"plot-{THEME}.png\", dpi=400, facecolor=PAGE_BG)\nplt.close()\n"}