{"spec_id":"root-locus-basic","library":"letsplot","language":"python","code":"\"\"\" anyplot.ai\nroot-locus-basic: Root Locus Plot for Control Systems\nLibrary: letsplot 4.10.1 | Python 3.13.13\nQuality: 86/100 | Updated: 2026-06-18\n\"\"\"\n\nimport os\n\nimport numpy as np\nimport pandas as pd\nfrom lets_plot import *\nfrom lets_plot import aes  # explicit import to satisfy F405 for multi-line calls\nfrom lets_plot.export import ggsave\n\n\nLetsPlot.setup_html()\n\n# Theme-adaptive chrome — Imprint palette\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_RULE = \"rgba(26,26,23,0.18)\" if THEME == \"light\" else \"rgba(240,239,232,0.18)\"\n\n# Imprint categorical palette — hybrid-v3 canonical order\nIMPRINT_PALETTE = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\", \"#99B314\"]\n\n# Data — G(s) = (s + 3) / [s(s+1)(s+2)(s+4)]\n# Open-loop poles: 0, −1, −2, −4 | Open-loop zero: −3\nden_coeffs = np.polymul(np.polymul([1, 0], [1, 1]), np.polymul([1, 2], [1, 4]))\n\nopen_loop_poles = np.array([0.0, -1.0, -2.0, -4.0])\nopen_loop_zeros = np.array([-3.0])\n\n# Gain sweep: dense near origin to capture breakaway, sparser at high gain\nk_values = np.concatenate(\n    [np.linspace(0, 0.5, 200), np.linspace(0.5, 5, 400), np.linspace(5, 30, 400), np.linspace(30, 120, 400)]\n)\n\nall_real, all_imag, all_gain, all_branch = [], [], [], []\nfor k in k_values:\n    char_poly = np.polyadd(den_coeffs, k * np.array([0, 0, 0, 1, 3]))\n    roots = np.sort_complex(np.roots(char_poly))\n    for b, root in enumerate(roots):\n        all_real.append(root.real)\n        all_imag.append(root.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\": all_gain, \"branch\": all_branch})\n\n# Pole and zero marker data\npoles_df = pd.DataFrame({\"real\": open_loop_poles, \"imaginary\": 0.0})\nzeros_df = pd.DataFrame({\"real\": open_loop_zeros, \"imaginary\": 0.0})\n\n# Imaginary-axis stability crossings (real ≈ 0, nonzero imaginary part)\ncrossing_mask = (np.abs(df[\"real\"]) < 0.08) & (np.abs(df[\"imaginary\"]) > 0.3)\ncrossings = df[crossing_mask].copy()\nif len(crossings) > 0:\n    crossings = crossings.sort_values(\"imaginary\")\n    crossing_pts = pd.concat(\n        [crossings[crossings[\"imaginary\"] > 0].head(1), crossings[crossings[\"imaginary\"] < 0].head(1)]\n    )\nelse:\n    crossing_pts = pd.DataFrame(columns=df.columns)\n\n# Direction arrows at selected gain values\narrow_gains = [5, 15, 50]\narrow_rows = []\nfor ag in arrow_gains:\n    idx = np.argmin(np.abs(k_values - ag))\n    subset = df[(df[\"gain\"] >= k_values[max(0, idx - 1)]) & (df[\"gain\"] <= k_values[min(len(k_values) - 1, idx + 1)])]\n    for _, row in subset.drop_duplicates(subset=\"branch\").iterrows():\n        k_next = k_values[min(len(k_values) - 1, idx + 5)]\n        next_pts = df[(np.abs(df[\"gain\"] - k_next) < 1.0) & (df[\"branch\"] == row[\"branch\"])]\n        if len(next_pts) > 0:\n            npt = next_pts.iloc[0]\n            dx, dy = npt[\"real\"] - row[\"real\"], npt[\"imaginary\"] - row[\"imaginary\"]\n            mag = np.hypot(dx, dy)\n            if mag > 0.01:\n                s = 0.25 / mag\n                arrow_rows.append(\n                    {\n                        \"x\": row[\"real\"],\n                        \"y\": row[\"imaginary\"],\n                        \"xend\": row[\"real\"] + dx * s,\n                        \"yend\": row[\"imaginary\"] + dy * s,\n                    }\n                )\narrows_df = pd.DataFrame(arrow_rows) if arrow_rows else pd.DataFrame(columns=[\"x\", \"y\", \"xend\", \"yend\"])\n\n# Damping ratio guide lines (ζ = constant), radial from origin in LHP\nr_max = 5.8\nzeta_values = [0.2, 0.4, 0.6, 0.8]\nzeta_segs = []\nfor z in zeta_values:\n    theta = np.arccos(z)\n    zeta_segs += [\n        {\"x\": 0, \"y\": 0, \"xend\": -r_max * np.cos(theta), \"yend\": r_max * np.sin(theta)},\n        {\"x\": 0, \"y\": 0, \"xend\": -r_max * np.cos(theta), \"yend\": -r_max * np.sin(theta)},\n    ]\nzeta_df = pd.DataFrame(zeta_segs)\n\n# Zeta labels — staggered radial distances to avoid crowding in upper LHP\nzeta_labels = pd.DataFrame(\n    [\n        {\n            \"x\": -r_max * (0.36 + i * 0.14) * np.cos(np.arccos(z)),\n            \"y\": r_max * (0.36 + i * 0.14) * np.sin(np.arccos(z)),\n            \"label\": f\"ζ={z}\",\n        }\n        for i, z in enumerate(zeta_values)\n    ]\n)\n\n# Natural frequency arcs (ωn = constant semicircles, LHP only)\nwn_values = [1, 2, 3, 4]\nwn_rows = []\nfor wn in wn_values:\n    theta = np.linspace(np.pi / 2, 3 * np.pi / 2, 120)\n    wn_rows += [{\"real\": wn * np.cos(t), \"imaginary\": wn * np.sin(t), \"wn\": str(wn)} for t in theta]\nwn_df = pd.DataFrame(wn_rows)\n\n# Critical gain annotation at stability boundary\ncrit_k = crossing_pts[\"gain\"].iloc[0] if len(crossing_pts) > 0 else None\ncrit_y = float(crossing_pts[\"imaginary\"].max()) if len(crossing_pts) > 0 else 2.0\nannot_df = pd.DataFrame(\n    {\"x\": [0.2], \"y\": [crit_y + 0.45], \"label\": [f\"K ≈ {crit_k:.1f}\" if crit_k is not None else \"\"]}\n)\n\nplot = (\n    ggplot()\n    # ── Reference overlays (behind data) ───────────────────────────────\n    + geom_path(\n        aes(x=\"real\", y=\"imaginary\", group=\"wn\"),\n        data=wn_df,\n        color=GRID_RULE,\n        size=0.5,\n        linetype=\"dashed\",\n        tooltips=\"none\",\n    )\n    + geom_segment(\n        aes(x=\"x\", y=\"y\", xend=\"xend\", yend=\"yend\"),\n        data=zeta_df,\n        color=GRID_RULE,\n        size=0.5,\n        linetype=\"dashed\",\n        tooltips=\"none\",\n    )\n    # Stable LHP shading with Imprint brand green tint\n    + geom_rect(\n        aes(xmin=\"xmin\", ymin=\"ymin\", xmax=\"xmax\", ymax=\"ymax\"),\n        data=pd.DataFrame({\"xmin\": [-6.5], \"xmax\": [0.0], \"ymin\": [-5.5], \"ymax\": [5.5]}),\n        fill=IMPRINT_PALETTE[0],\n        alpha=0.07,\n        inherit_aes=False,\n        tooltips=\"none\",\n    )\n    # ── Axis reference lines ────────────────────────────────────────────\n    + geom_vline(xintercept=0, color=INK_SOFT, size=0.7)\n    + geom_hline(yintercept=0, color=INK_SOFT, size=0.4)\n    # ── Locus branches (Imprint palette, interactive tooltips) ──────────\n    + geom_path(\n        aes(x=\"real\", y=\"imaginary\", color=\"branch\"),\n        data=df,\n        size=2.0,\n        alpha=0.9,\n        tooltips=layer_tooltips()\n        .format(\"gain\", \".2f\")\n        .format(\"real\", \".3f\")\n        .format(\"imaginary\", \".3f\")\n        .line(\"Branch @branch\")\n        .line(\"K = @gain\")\n        .line(\"Re = @real\")\n        .line(\"Im = @imaginary\"),\n    )\n    + scale_color_manual(values=IMPRINT_PALETTE[:4], name=\"Locus branch\")\n    # ── Direction arrows (increasing gain) ─────────────────────────────\n    + geom_segment(\n        aes(x=\"x\", y=\"y\", xend=\"xend\", yend=\"yend\"),\n        data=arrows_df,\n        color=INK,\n        size=1.0,\n        inherit_aes=False,\n        tooltips=\"none\",\n        arrow=arrow(length=10, ends=\"last\", type=\"open\"),\n    )\n    # ── Open-loop poles (×) and zero (○) ───────────────────────────────\n    + geom_point(\n        aes(x=\"real\", y=\"imaginary\"),\n        data=poles_df,\n        shape=4,\n        size=6,\n        color=INK,\n        stroke=2.0,\n        inherit_aes=False,\n        tooltips=layer_tooltips().line(\"Pole: s = @real\"),\n    )\n    + geom_point(\n        aes(x=\"real\", y=\"imaginary\"),\n        data=zeros_df,\n        shape=1,\n        size=6,\n        color=INK,\n        stroke=2.0,\n        inherit_aes=False,\n        tooltips=layer_tooltips().line(\"Zero: s = @real\"),\n    )\n    # ── Stability-boundary crossings ────────────────────────────────────\n    + geom_point(\n        aes(x=\"real\", y=\"imaginary\"),\n        data=crossing_pts,\n        shape=18,\n        size=6,\n        color=IMPRINT_PALETTE[4],\n        inherit_aes=False,\n        tooltips=layer_tooltips().line(\"Stability boundary\").line(\"K ≈ @gain\"),\n    )\n    # ── Text annotations ────────────────────────────────────────────────\n    # Zeta labels — staggered for legibility\n    + geom_text(\n        aes(x=\"x\", y=\"y\", label=\"label\"),\n        data=zeta_labels,\n        size=3.5,\n        color=INK_MUTED,\n        inherit_aes=False,\n        family=\"monospace\",\n    )\n    # Complex-plane axis symbols\n    + geom_text(\n        aes(x=\"x\", y=\"y\", label=\"label\"),\n        data=pd.DataFrame({\"x\": [0.15], \"y\": [4.55], \"label\": [\"jω\"]}),\n        size=4.5,\n        color=INK_MUTED,\n        hjust=0,\n        family=\"serif\",\n        inherit_aes=False,\n    )\n    + geom_text(\n        aes(x=\"x\", y=\"y\", label=\"label\"),\n        data=pd.DataFrame({\"x\": [3.2], \"y\": [-0.22], \"label\": [\"σ\"]}),\n        size=4.5,\n        color=INK_MUTED,\n        hjust=1,\n        family=\"serif\",\n        inherit_aes=False,\n    )\n    # Stability region labels\n    + geom_text(\n        aes(x=\"x\", y=\"y\", label=\"label\"),\n        data=pd.DataFrame({\"x\": [-4.8], \"y\": [4.3], \"label\": [\"STABLE\"]}),\n        size=5.0,\n        color=IMPRINT_PALETTE[0],\n        alpha=0.55,\n        fontface=\"bold\",\n        family=\"monospace\",\n        inherit_aes=False,\n    )\n    + geom_text(\n        aes(x=\"x\", y=\"y\", label=\"label\"),\n        data=pd.DataFrame({\"x\": [0.6], \"y\": [4.3], \"label\": [\"UNSTABLE\"]}),\n        size=4.0,\n        color=IMPRINT_PALETTE[4],\n        alpha=0.5,\n        fontface=\"bold\",\n        family=\"monospace\",\n        inherit_aes=False,\n    )\n    # Critical gain annotation\n    + geom_text(\n        aes(x=\"x\", y=\"y\", label=\"label\"),\n        data=annot_df,\n        size=4.0,\n        color=IMPRINT_PALETTE[4],\n        hjust=0,\n        family=\"monospace\",\n        fontface=\"bold\",\n        inherit_aes=False,\n    )\n    # ── Title, axis labels, layout ──────────────────────────────────────\n    + labs(\n        x=\"Real axis (σ)\",\n        y=\"Imaginary axis (jω)\",\n        title=\"root-locus-basic · python · letsplot · anyplot.ai\",\n        caption=\"G(s) = (s+3)/[s(s+1)(s+2)(s+4)]  ·  × = poles  ·  ○ = zero  ·  ◆ = stability crossing\",\n    )\n    # Equal-axis scaling preserves geometry (circles stay circular)\n    + coord_fixed(ratio=1, xlim=[-5.5, 3.5], ylim=[-4.5, 4.5])\n    + ggsize(600, 600)\n    + theme_minimal()\n    + theme(\n        axis_text=element_text(size=10, color=INK_SOFT),\n        axis_title=element_text(size=12, color=INK, face=\"bold\"),\n        plot_title=element_text(size=16, color=INK, face=\"bold\"),\n        plot_caption=element_text(size=9, color=INK_MUTED, face=\"italic\"),\n        legend_text=element_text(size=10, color=INK_SOFT),\n        legend_title=element_text(size=11, color=INK, face=\"bold\"),\n        legend_position=\"right\",\n        panel_grid_major=element_blank(),\n        panel_grid_minor=element_blank(),\n        plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),\n        panel_background=element_rect(fill=PAGE_BG),\n        axis_line=element_line(color=INK_SOFT, size=0.5),\n        legend_background=element_rect(fill=ELEVATED_BG, color=INK_SOFT),\n        plot_margin=[20, 25, 15, 15],\n    )\n)\n\n# Save PNG (scale=4 → 2400 × 2400) and interactive HTML\nggsave(plot, f\"plot-{THEME}.png\", path=\".\", scale=4)\nggsave(plot, f\"plot-{THEME}.html\", path=\".\")\n"}