{"spec_id":"root-locus-basic","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\nroot-locus-basic: Root Locus Plot for Control Systems\nLibrary: pygal 3.1.0 | Python 3.13.13\nQuality: 87/100 | Updated: 2026-06-18\n\"\"\"\n\nimport os\n\nimport numpy as np\nimport pygal\nfrom pygal.style import Style\n\n\n# Theme tokens\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n\n# Data — root locus for G(s) = (s+3) / [s(s+1)(s+2)(s+4)]\n# Open-loop poles: 0, -1, -2, -4  |  Open-loop zero: -3\nnum = np.array([1, 3])\nden = np.polymul(np.polymul([1, 0], [1, 1]), np.polymul([1, 2], [1, 4]))\n\nol_poles = np.sort(np.roots(den).real)\nol_zeros = np.sort(np.roots(num).real)\nn_branches = len(den) - 1\nnum_padded = np.zeros(len(den))\nnum_padded[-len(num) :] = num\n\n# Gain sweep with variable density: finer near breakaway and jω crossing\ngains = np.concatenate(\n    [np.linspace(0, 2, 200), np.linspace(2, 15, 300), np.linspace(15, 80, 200), np.linspace(80, 500, 150)]\n)\n\n# Compute closed-loop poles via nearest-neighbor tracking\nloci = np.zeros((len(gains), n_branches), dtype=complex)\nfor i, K in enumerate(gains):\n    roots = np.roots(den + K * num_padded)\n    if i == 0:\n        loci[i] = roots[np.argsort(roots.real)]\n    else:\n        prev = loci[i - 1]\n        available = list(range(n_branches))\n        for j in range(n_branches):\n            dists = [abs(roots[k] - prev[j]) if k in available else np.inf for k in range(n_branches)]\n            best = int(np.argmin(dists))\n            loci[i, j] = roots[best]\n            available.remove(best)\n\n# Imaginary axis crossings (stability boundary)\njw_crossings = []\nfor b in range(n_branches):\n    reals = loci[:, b].real\n    for i in range(len(reals) - 1):\n        if reals[i] * reals[i + 1] < 0 and abs(loci[i, b].imag) > 0.1:\n            frac = abs(reals[i]) / (abs(reals[i]) + abs(reals[i + 1]))\n            im = float(loci[i, b].imag + frac * (loci[i + 1, b].imag - loci[i, b].imag))\n            K_cross = float(gains[i] + frac * (gains[i + 1] - gains[i]))\n            jw_crossings.append((round(im, 3), round(K_cross, 2)))\n\n# Breakaway point between poles at -1 and -2\ns_test = np.linspace(-1.01, -1.99, 500)\nratio = np.polyval(den, s_test) / np.polyval(num, s_test)\nbreakaway_idx = np.argmin(np.abs(np.gradient(ratio, s_test)))\nbreakaway_s = round(float(s_test[breakaway_idx]), 3)\nbreakaway_K = round(float(-np.polyval(den, breakaway_s) / np.polyval(num, breakaway_s)), 2)\n\n# Real-axis locus segments (left of odd number of real poles/zeros)\nreal_segments = [(0, -1), (-2, -3), (-4, -6)]\n\n# Guide parameters\nzeta_values = [0.2, 0.4, 0.6, 0.8]\nguide_extent = 5.5\nwn_values = [1, 2, 3, 4, 5]\n\n# Style — Imprint palette with theme-adaptive chrome\n# Color order: INK_MUTED for guides, then Imprint positions 1→6 for data series\ncustom_style = Style(\n    background=PAGE_BG,\n    plot_background=PAGE_BG,\n    foreground=INK,\n    foreground_strong=INK,\n    foreground_subtle=INK_MUTED,\n    colors=(\n        INK_MUTED,  # ζ/ωn reference lines (muted, background layer)\n        \"#009E73\",  # Root Locus — Imprint palette position 1 (ALWAYS first data series)\n        \"#BD8233\",  # Real-Axis Locus — Imprint ochre\n        \"#AE3030\",  # Poles — semantic red (critical system points)\n        \"#4467A3\",  # Zero — Imprint blue\n        \"#DDCC77\",  # Breakaway — amber (caution marker)\n        \"#2ABCCD\",  # Stability Boundary — Imprint cyan\n        \"#009E73\",  # Direction arrows — same color as root locus\n    ),\n    title_font_size=66,\n    label_font_size=56,\n    major_label_font_size=44,\n    legend_font_size=44,\n    value_font_size=36,\n    stroke_width=2.5,\n)\n\n# Chart — 2400×2400 square canvas (root locus is symmetric in the complex plane)\nchart = pygal.XY(\n    width=2400,\n    height=2400,\n    style=custom_style,\n    title=\"root-locus-basic · python · pygal · anyplot.ai\",\n    x_title=\"Real Axis (σ)\",\n    y_title=\"Imaginary Axis (jω)\",\n    show_legend=True,\n    legend_at_bottom=True,\n    legend_at_bottom_columns=4,\n    legend_box_size=20,\n    stroke=True,\n    dots_size=0,\n    show_x_guides=True,\n    show_y_guides=True,\n    x_value_formatter=lambda v: f\"{v:.1f}\",\n    value_formatter=lambda v: f\"{v:.1f}\",\n    margin_bottom=90,\n    margin_left=80,\n    margin_right=50,\n    margin_top=55,\n    xrange=(-6, 4),\n    range=(-5, 5),\n    print_values=False,\n    print_zeroes=False,\n    js=[],\n    truncate_legend=-1,\n    include_x_axis=True,\n    allow_interruptions=True,\n    spacing=18,\n)\n\n# Reference guide lines — ζ (damping ratio) rays and ωn (natural frequency) arcs combined\n# into one subtle series to reduce legend clutter (was two separate series in prior version)\nguide_pts = []\nfor zeta in zeta_values:\n    theta = np.arccos(zeta)\n    for t in np.linspace(0, guide_extent, 25):\n        guide_pts.append((round(-t * np.cos(theta), 3), round(t * np.sin(theta), 3)))\n    guide_pts.append(None)\n    for t in np.linspace(0, guide_extent, 25):\n        guide_pts.append((round(-t * np.cos(theta), 3), round(-t * np.sin(theta), 3)))\n    guide_pts.append(None)\nfor wn in wn_values:\n    angles = np.linspace(np.pi / 2, 3 * np.pi / 2, 50)\n    for a in angles:\n        guide_pts.append((round(wn * np.cos(a), 3), round(wn * np.sin(a), 3)))\n    guide_pts.append(None)\n\nchart.add(\n    \"ζ/ωn Reference Lines\",\n    guide_pts,\n    stroke_style={\"width\": 1.0, \"dasharray\": \"5, 5\"},\n    show_dots=False,\n    allow_interruptions=True,\n)\n\n# Root locus branches — primary data series (Imprint brand green #009E73)\nzero_exclusion_radius = 0.35\nlocus_pts = []\nfor b in range(n_branches):\n    branch_data = []\n    for i in range(len(gains)):\n        r, im = float(loci[i, b].real), float(loci[i, b].imag)\n        if -6 <= r <= 4 and -5 <= im <= 5:\n            near_zero = any(\n                abs(r - float(z)) < zero_exclusion_radius and abs(im) < zero_exclusion_radius for z in ol_zeros\n            )\n            if near_zero:\n                if branch_data:\n                    locus_pts.extend(branch_data)\n                    locus_pts.append(None)\n                    branch_data = []\n            else:\n                branch_data.append({\"value\": (round(r, 4), round(im, 4)), \"label\": f\"K = {gains[i]:.2f}\"})\n    if branch_data:\n        locus_pts.extend(branch_data)\n    locus_pts.append(None)\n\nchart.add(\n    \"Root Locus\", locus_pts, stroke_style={\"width\": 9, \"linecap\": \"round\"}, show_dots=False, allow_interruptions=True\n)\n\n# Real-axis locus segments — ochre, thick to distinguish from guides\nreal_pts = []\nfor seg_start, seg_end in real_segments:\n    for x in np.linspace(seg_start, seg_end, 60):\n        real_pts.append((round(float(x), 3), 0.0))\n    real_pts.append(None)\nchart.add(\n    \"Real-Axis Locus\",\n    real_pts,\n    stroke_style={\"width\": 10, \"linecap\": \"round\"},\n    show_dots=True,\n    dots_size=7,\n    allow_interruptions=True,\n)\n\n# Open-loop poles (×) — semantic red for critical control points\npole_pts = [{\"value\": (round(float(p), 2), 0.0), \"label\": f\"Pole at s = {p:.0f}\"} for p in ol_poles]\nchart.add(\"Poles (×)\", pole_pts, stroke=False, dots_size=15)\n\n# Open-loop zero (○) — Imprint blue, distinct from poles\nzero_pts = [{\"value\": (round(float(z), 2), 0.0), \"label\": f\"Zero at s = {z:.0f}\"} for z in ol_zeros]\nchart.add(\"Zero (○)\", zero_pts, stroke=False, dots_size=18)\n\n# Breakaway point — amber caution marker\nbreakaway_pts = [{\"value\": (breakaway_s, 0.0), \"label\": f\"Breakaway: s = {breakaway_s:.3f}, K = {breakaway_K}\"}]\nchart.add(\"Breakaway\", breakaway_pts, stroke=False, dots_size=20)\n\n# Stability boundary (jω axis crossings) — cyan, clearly marks instability threshold\njw_pts = [{\"value\": (0.0, im), \"label\": f\"jω crossing: s = {im:+.3f}j, K = {K:.2f}\"} for im, K in jw_crossings]\nchart.add(\"Stability Boundary\", jw_pts, stroke=False, dots_size=17)\n\n# Direction arrows along complex locus branches — V-shaped tick marks indicating increasing K\narrow_pts = []\narrow_target_gains = [3, 8, 20, 60, 180]\narrow_size = 0.28\nfor b in range(n_branches):\n    for target_K in arrow_target_gains:\n        idx = int(np.argmin(np.abs(gains - target_K)))\n        if idx < 3:\n            continue\n        x, y = float(loci[idx, b].real), float(loci[idx, b].imag)\n        if abs(y) < 0.25 or not (-5.8 <= x <= 3.8 and -4.8 <= y <= 4.8):\n            continue\n        dx = float(loci[idx, b].real - loci[idx - 3, b].real)\n        dy = float(loci[idx, b].imag - loci[idx - 3, b].imag)\n        length = np.sqrt(dx**2 + dy**2)\n        if length < 1e-6:\n            continue\n        dx, dy = dx / length, dy / length\n        px, py = -dy, dx\n        bx = x - dx * arrow_size\n        by = y - dy * arrow_size\n        arrow_pts.extend(\n            [\n                (round(bx + px * arrow_size * 0.55, 3), round(by + py * arrow_size * 0.55, 3)),\n                (round(x, 3), round(y, 3)),\n                (round(bx - px * arrow_size * 0.55, 3), round(by - py * arrow_size * 0.55, 3)),\n            ]\n        )\n        arrow_pts.append(None)\n\nif arrow_pts:\n    chart.add(\n        \"→ increasing gain\",\n        arrow_pts,\n        stroke_style={\"width\": 4, \"linecap\": \"round\"},\n        show_dots=False,\n        stroke=True,\n        allow_interruptions=True,\n    )\n\n# Save\nchart.render_to_png(f\"plot-{THEME}.png\")\nwith open(f\"plot-{THEME}.html\", \"wb\") as f:\n    f.write(chart.render())\n"}