{"spec_id":"root-locus-basic","library":"matplotlib","language":"python","code":"\"\"\" anyplot.ai\nroot-locus-basic: Root Locus Plot for Control Systems\nLibrary: matplotlib 3.11.0 | Python 3.13.13\nQuality: 92/100 | Updated: 2026-06-17\n\"\"\"\n\nimport os\n\nimport matplotlib.patches as mpatches\nimport matplotlib.patheffects as pe\nimport matplotlib.pyplot as plt\nimport numpy as np\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\"\n\n# Imprint palette — branches follow canonical order, first always #009E73\nIMPRINT = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\", \"#99B314\"]\nAMBER = \"#DDCC77\"  # semantic anchor: stability-boundary warning markers\n\n# Data — G(s) = (s+2) / [s(s+1)(s+3)(s+5)], DC servo position control loop\nopen_loop_poles = np.array([0.0, -1.0, -3.0, -5.0])\nopen_loop_zeros = np.array([-2.0])\n\n# Denominator: s(s+1)(s+3)(s+5) = s^4 + 9s^3 + 23s^2 + 15s\nden_coeffs = np.array([1.0, 9.0, 23.0, 15.0, 0.0])\nnum_coeffs = np.array([1.0, 2.0])  # (s+2)\n\n# Vary gain K from 0 → 1000, dense sampling near origin for smooth branches\ngains = np.concatenate(\n    [\n        np.linspace(0, 1, 200),\n        np.linspace(1, 10, 300),\n        np.linspace(10, 50, 300),\n        np.linspace(50, 200, 300),\n        np.linspace(200, 1000, 400),\n    ]\n)\n\nn_poles = len(den_coeffs) - 1\nlocus = np.full((len(gains), n_poles), np.nan + 1j * np.nan)\n\nfor i, K in enumerate(gains):\n    num_padded = np.zeros(len(den_coeffs))\n    num_padded[-len(num_coeffs) :] = num_coeffs\n    char_poly = den_coeffs + K * num_padded\n    roots = np.roots(char_poly)\n    roots = np.sort_complex(roots)\n    locus[i, :] = roots\n\n# Sort branches by continuity (greedy nearest-neighbour tracking)\nfor i in range(1, len(gains)):\n    prev = locus[i - 1, :]\n    curr = locus[i, :].copy()\n    used = np.zeros(n_poles, dtype=bool)\n    order = np.zeros(n_poles, dtype=int)\n    for j in range(n_poles):\n        dists = np.abs(curr - prev[j])\n        dists[used] = np.inf\n        best = np.argmin(dists)\n        order[j] = best\n        used[best] = True\n    locus[i, :] = curr[order]\n\n# Find imaginary-axis crossings (stability boundary)\ncrossings = []\nfor branch in range(n_poles):\n    real_parts = locus[:, branch].real\n    for i in range(1, len(real_parts)):\n        if real_parts[i - 1] * real_parts[i] < 0:\n            t = abs(real_parts[i - 1]) / (abs(real_parts[i - 1]) + abs(real_parts[i]))\n            cross_point = locus[i - 1, branch] + t * (locus[i, branch] - locus[i - 1, branch])\n            crossings.append(cross_point)\n\n# Canvas — 2400×2400 px (square: equal-aspect root locus, symmetric about real axis)\nfig, ax = plt.subplots(figsize=(6, 6), dpi=400, facecolor=PAGE_BG)\nax.set_facecolor(PAGE_BG)\n\n# Path-effect stroke uses PAGE_BG so it works in both light and dark\nstroke = pe.withStroke(linewidth=2, foreground=PAGE_BG)\n\n# Stable / unstable region shading — Imprint teal + ochre (colorblind-safe, no red/green conflict)\nax.axvspan(-10, 0, color=IMPRINT[0], alpha=0.04, zorder=0)\nax.axvspan(0, 5, color=IMPRINT[3], alpha=0.04, zorder=0)\nax.text(-6.2, -4.4, \"Stable\", fontsize=8, color=IMPRINT[0], alpha=0.85, path_effects=[stroke])\nax.text(0.3, -4.4, \"Unstable\", fontsize=8, color=IMPRINT[3], alpha=0.85, path_effects=[stroke])\n\n# Constant damping-ratio lines (ζ = 0.2, 0.4, 0.6, 0.8)\nmax_r = 7\nfor zeta in [0.2, 0.4, 0.6, 0.8]:\n    angle = np.arccos(zeta)\n    r = np.linspace(0, max_r, 100)\n    lx = -r * np.cos(np.pi - angle)\n    ly = r * np.sin(np.pi - angle)\n    ax.plot(lx, ly, \"--\", color=INK_MUTED, linewidth=0.5, alpha=0.45)\n    ax.plot(lx, -ly, \"--\", color=INK_MUTED, linewidth=0.5, alpha=0.45)\n    lr = 3.0 if zeta >= 0.8 else 3.8\n    tx = -lr * np.cos(np.pi - angle) - 0.15\n    ty = lr * np.sin(np.pi - angle) + 0.18\n    ax.text(tx, ty, f\"ζ={zeta}\", fontsize=7, color=INK_MUTED, alpha=0.9, path_effects=[stroke])\n\n# Constant natural-frequency arcs (ωn = 1 … 6), left half-plane only\nfor wn in range(1, 7):\n    theta = np.linspace(np.pi / 2, 3 * np.pi / 2, 200)\n    ax.plot(wn * np.cos(theta), wn * np.sin(theta), \"--\", color=INK_MUTED, linewidth=0.5, alpha=0.45)\n\n# Branch colors: Imprint canonical order\nbranch_colors = [IMPRINT[i % len(IMPRINT)] for i in range(n_poles)]\n\n# Draw locus branches\nfor branch in range(n_poles):\n    ax.plot(\n        locus[:, branch].real, locus[:, branch].imag, color=branch_colors[branch], linewidth=2.0, alpha=0.9, zorder=3\n    )\n\n# Directional arrows (increasing gain)\nfor branch in range(n_poles):\n    n_pts = len(gains)\n    idx = n_pts // 3\n    if idx + 5 < n_pts:\n        p1 = locus[idx, branch]\n        p2 = locus[idx + 5, branch]\n        dx, dy = p2.real - p1.real, p2.imag - p1.imag\n        if np.hypot(dx, dy) > 0.005:\n            ax.add_patch(\n                mpatches.FancyArrowPatch(\n                    (p1.real, p1.imag),\n                    (p2.real, p2.imag),\n                    arrowstyle=\"-|>\",\n                    color=branch_colors[branch],\n                    linewidth=1.5,\n                    mutation_scale=12,\n                    zorder=4,\n                    path_effects=[pe.withStroke(linewidth=3, foreground=PAGE_BG, alpha=0.5)],\n                )\n            )\n\n# Open-loop poles (×) and zeros (○) — INK colour so they flip with theme\nax.scatter(\n    open_loop_poles.real,\n    np.zeros_like(open_loop_poles),\n    marker=\"x\",\n    s=120,\n    color=INK,\n    linewidths=2.0,\n    zorder=5,\n    label=\"Open-loop poles\",\n)\nax.scatter(\n    open_loop_zeros.real,\n    np.zeros_like(open_loop_zeros),\n    marker=\"o\",\n    s=100,\n    facecolors=\"none\",\n    edgecolors=INK,\n    linewidths=2.0,\n    zorder=5,\n    label=\"Open-loop zeros\",\n)\n\n# Imaginary-axis crossings: amber diamonds (semantic: stability-boundary warning)\nfor cp in crossings:\n    ax.scatter(cp.real, cp.imag, marker=\"D\", s=80, color=AMBER, edgecolors=INK_SOFT, linewidths=0.8, zorder=6)\n    ax.annotate(\n        f\"jω≈{cp.imag:+.1f}\",\n        xy=(cp.real, cp.imag),\n        xytext=(10, 5),\n        textcoords=\"offset points\",\n        fontsize=7,\n        color=AMBER,\n        fontweight=\"bold\",\n        path_effects=[stroke],\n        zorder=7,\n    )\n\n# Real-axis segments (left of an odd count of real poles+zeros)\nreal_pts = np.sort(np.concatenate([open_loop_poles.real, open_loop_zeros.real]))\nx_range = np.linspace(-8, 1, 5000)\non_locus = np.array([np.sum(real_pts >= x) % 2 == 1 for x in x_range])\n\nsegments = []\nin_seg = False\nfor i, val in enumerate(on_locus):\n    if val and not in_seg:\n        seg_start = x_range[i]\n        in_seg = True\n    elif not val and in_seg:\n        segments.append((seg_start, x_range[i - 1]))\n        in_seg = False\nif in_seg:\n    segments.append((seg_start, x_range[-1]))\n\nfor s0, s1 in segments:\n    ax.plot([s0, s1], [0, 0], color=IMPRINT[0], linewidth=3.5, alpha=0.22, zorder=2)\n\n# Origin cross-hair — INK_SOFT so it adapts to theme\nax.axhline(y=0, color=INK_SOFT, linewidth=0.6, zorder=1)\nax.axvline(x=0, color=INK_SOFT, linewidth=0.6, zorder=1)\n\n# Axis chrome — all tokens theme-adaptive\ntitle = \"root-locus-basic · python · matplotlib · anyplot.ai\"\ntitle_fontsize = max(8, round(12 * 67 / len(title))) if len(title) > 67 else 12\nax.set_title(title, fontsize=title_fontsize, fontweight=\"medium\", color=INK)\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, labelcolor=INK_SOFT)\nfor spine in (\"left\", \"bottom\"):\n    ax.spines[spine].set_color(INK_SOFT)\nax.spines[\"top\"].set_visible(False)\nax.spines[\"right\"].set_visible(False)\n\nax.set_aspect(\"equal\")\nax.set_xlim(-7, 2.5)\nax.set_ylim(-5, 5)\n\n# Legend\nleg = ax.legend(fontsize=8, loc=\"upper left\", framealpha=0.9)\nif leg:\n    leg.get_frame().set_facecolor(ELEVATED_BG)\n    leg.get_frame().set_edgecolor(INK_SOFT)\n    plt.setp(leg.get_texts(), color=INK_SOFT)\n\nfig.subplots_adjust(left=0.12, right=0.96, top=0.93, bottom=0.09)\nplt.savefig(f\"plot-{THEME}.png\", dpi=400, facecolor=PAGE_BG)\n"}