{"spec_id":"root-locus-basic","library":"plotnine","language":"python","code":"\"\"\" anyplot.ai\nroot-locus-basic: Root Locus Plot for Control Systems\nLibrary: plotnine 0.15.7 | Python 3.13.13\nQuality: 89/100 | Updated: 2026-06-18\n\"\"\"\n\nimport sys\n\n\n# sys.path[0] is the script directory — remove it so 'plotnine' resolves to the installed package\nsys.path.pop(0)\n\nimport os\n\nimport numpy as np\nimport pandas as pd\nfrom mizani.formatters import custom_format\nfrom plotnine import (\n    aes,\n    annotate,\n    arrow,\n    coord_fixed,\n    element_blank,\n    element_line,\n    element_rect,\n    element_text,\n    geom_hline,\n    geom_path,\n    geom_point,\n    geom_segment,\n    geom_text,\n    geom_vline,\n    ggplot,\n    guide_legend,\n    guides,\n    labs,\n    scale_color_manual,\n    scale_shape_manual,\n    scale_x_continuous,\n    scale_y_continuous,\n    theme,\n    theme_minimal,\n)\n\n\n# Theme tokens — Imprint style guide\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 categorical palette — branch colors at positions 1, 2, 3\nIMPRINT_PALETTE = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\", \"#99B314\"]\nbranch_colors = IMPRINT_PALETTE[:3]\n\n# Stability region shading — theme-adaptive warm tints\nSTABLE_FILL = \"#E8F5E9\" if THEME == \"light\" else \"#0F2015\"\nUNSTABLE_FILL = \"#FFEBEE\" if THEME == \"light\" else \"#200F0F\"\n\n# Transfer function G(s) = 1 / [s(s+1)(s+3)]\n# Open-loop poles at s=0, -1, -3; no open-loop zeros\n# Characteristic equation: s^3 + 4s^2 + 3s + K = 0\nopen_loop_poles = np.array([0.0, -1.0, -3.0])\nopen_loop_zeros = np.array([])\n\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, 2, 300),\n        np.linspace(2, 6, 400),\n        np.linspace(6, 20, 400),\n        np.linspace(20, 80, 300),\n    ]\n)\n\nbranch_data = []\nfor K in gains:\n    char_eq = den_coeffs.copy()\n    char_eq[-1] += K * num_coeffs[-1]\n    roots = np.roots(char_eq)\n    roots = np.sort_complex(roots)\n    for branch_idx, root in enumerate(roots):\n        branch_data.append({\"real\": root.real, \"imaginary\": root.imag, \"gain\": K, \"branch\": f\"Branch {branch_idx + 1}\"})\n\ndf = pd.DataFrame(branch_data)\n\n# Imaginary axis crossings (stability boundary)\ncrossings = []\nfor branch in df[\"branch\"].unique():\n    branch_df = df[df[\"branch\"] == branch].reset_index(drop=True)\n    for i in range(1, len(branch_df)):\n        r0 = branch_df.loc[i - 1, \"real\"]\n        r1 = branch_df.loc[i, \"real\"]\n        if r0 * r1 < 0:\n            frac = abs(r0) / (abs(r0) + abs(r1))\n            cross_imag = branch_df.loc[i - 1, \"imaginary\"] + frac * (\n                branch_df.loc[i, \"imaginary\"] - branch_df.loc[i - 1, \"imaginary\"]\n            )\n            cross_gain = branch_df.loc[i - 1, \"gain\"] + frac * (branch_df.loc[i, \"gain\"] - branch_df.loc[i - 1, \"gain\"])\n            crossings.append({\"real\": 0.0, \"imaginary\": cross_imag, \"gain\": cross_gain})\n\n# Breakaway point: dK/ds = 0  →  -(3s^2 + 8s + 3) = 0\nbreakaway_roots = np.roots([3, 8, 3])\nbreakaway_s = breakaway_roots[(breakaway_roots > -1) & (breakaway_roots < 0)][0]\nbreakaway_K = -(breakaway_s**3 + 4 * breakaway_s**2 + 3 * breakaway_s)\n\n# Real axis segments on the root locus (left of odd count of poles+zeros)\nreal_features = np.sort(np.concatenate([open_loop_poles, open_loop_zeros]))\nreal_axis_segs = []\nfor x in np.linspace(-5.0, 1.0, 2000):\n    if np.sum(real_features >= x) % 2 == 1:\n        real_axis_segs.append(x)\n\nseg_intervals = []\nif real_axis_segs:\n    seg_start = real_axis_segs[0]\n    for i in range(1, len(real_axis_segs)):\n        if real_axis_segs[i] - real_axis_segs[i - 1] > 0.01:\n            seg_intervals.append((seg_start, real_axis_segs[i - 1]))\n            seg_start = real_axis_segs[i]\n    seg_intervals.append((seg_start, real_axis_segs[-1]))\n\nseg_df = pd.DataFrame(seg_intervals, columns=[\"x_start\", \"x_end\"])\nseg_df[\"y\"] = 0.0\n\n# Direction-of-increasing-gain arrows on each branch\narrows = []\nfor branch in df[\"branch\"].unique():\n    b_df = df[df[\"branch\"] == branch].reset_index(drop=True)\n    mid = len(b_df) * 2 // 5\n    if mid > 0:\n        arrows.append(\n            {\n                \"x\": b_df.loc[mid - 1, \"real\"],\n                \"y\": b_df.loc[mid - 1, \"imaginary\"],\n                \"xend\": b_df.loc[mid, \"real\"],\n                \"yend\": b_df.loc[mid, \"imaginary\"],\n            }\n        )\n\narrow_df = pd.DataFrame(arrows)\n\n# Markers: open-loop poles and breakaway point\npole_df = pd.DataFrame({\"real\": open_loop_poles, \"imaginary\": np.zeros(len(open_loop_poles)), \"type\": \"Open-loop Pole\"})\nbreakaway_df = pd.DataFrame([{\"real\": breakaway_s, \"imaginary\": 0.0, \"type\": \"Breakaway Point\"}])\nmarker_df = pd.concat([pole_df, breakaway_df], ignore_index=True)\n\ncrossing_df = pd.DataFrame(crossings)\ncrossing_label_df = crossing_df.copy()\ncrossing_label_df[\"label\"] = crossing_label_df[\"gain\"].apply(lambda g: f\"K={g:.1f}\")\n\n# Damping ratio guide lines radiating from origin into left half-plane\nradius = 4.8\ndamp_lines = []\nfor zeta in [0.2, 0.4, 0.6, 0.8]:\n    theta = np.arccos(zeta)\n    x_end = -radius * zeta\n    y_pos = radius * np.sin(theta)\n    damp_lines += [\n        {\"x\": 0, \"y\": 0, \"xend\": x_end, \"yend\": y_pos, \"label\": f\"ζ={zeta}\"},\n        {\"x\": 0, \"y\": 0, \"xend\": x_end, \"yend\": -y_pos, \"label\": f\"ζ={zeta}\"},\n    ]\n\ndamp_df = pd.DataFrame(damp_lines)\ndamp_label_df = damp_df[damp_df[\"yend\"] > 0].copy()\ndamp_label_df[\"lx\"] = damp_label_df[\"xend\"] * 0.75\ndamp_label_df[\"ly\"] = damp_label_df[\"yend\"] * 0.75\n\n# Natural frequency circles\nwn_data = []\nfor wn in [1.0, 2.0, 3.0, 4.0]:\n    for t in np.linspace(0, 2 * np.pi, 100):\n        wn_data.append({\"real\": wn * np.cos(t), \"imaginary\": wn * np.sin(t), \"wn\": f\"ωn={wn}\"})\n\nwn_df = pd.DataFrame(wn_data)\nwn_label_df = pd.DataFrame(\n    [{\"real\": -0.5, \"imaginary\": wn + 0.2, \"label\": f\"ωn={int(wn)}\"} for wn in [1.0, 2.0, 3.0, 4.0]]\n)\n\n# Axis label formatters (mizani)\nsigma_fmt = custom_format(\"{:.0f}\")\n\n\n# Plot\nplot = (\n    ggplot()\n    # Stability region shading\n    + annotate(\"rect\", xmin=-5.5, xmax=0, ymin=-5, ymax=5, fill=STABLE_FILL, alpha=0.45)\n    + annotate(\"rect\", xmin=0, xmax=2.5, ymin=-5, ymax=5, fill=UNSTABLE_FILL, alpha=0.45)\n    + annotate(\"text\", x=-4.6, y=4.3, label=\"Stable\", color=\"#009E73\", size=3.5, fontstyle=\"italic\")\n    + annotate(\"text\", x=1.3, y=4.3, label=\"Unstable\", color=\"#AE3030\", size=3.5, fontstyle=\"italic\")\n    # Damping ratio guide lines\n    + geom_segment(\n        damp_df, aes(x=\"x\", y=\"y\", xend=\"xend\", yend=\"yend\"), color=INK_SOFT, linetype=\"dashed\", size=0.45, alpha=0.5\n    )\n    + geom_text(\n        damp_label_df, aes(x=\"lx\", y=\"ly\", label=\"label\"), color=INK_MUTED, size=3.2, fontstyle=\"italic\", ha=\"center\"\n    )\n    # Natural frequency circles\n    + geom_path(\n        wn_df, aes(x=\"real\", y=\"imaginary\", group=\"wn\"), color=INK_SOFT, linetype=\"dotted\", size=0.35, alpha=0.5\n    )\n    + geom_text(wn_label_df, aes(x=\"real\", y=\"imaginary\", label=\"label\"), color=INK_MUTED, size=3.2, fontstyle=\"italic\")\n    # Real axis segments\n    + geom_segment(\n        seg_df, aes(x=\"x_start\", y=\"y\", xend=\"x_end\", yend=\"y\"), color=INK_SOFT, size=2.0, alpha=0.45, linetype=\"solid\"\n    )\n    # Root locus branches — Imprint palette positions 1, 2, 3\n    + geom_path(df, aes(x=\"real\", y=\"imaginary\", color=\"branch\", group=\"branch\"), size=1.1, alpha=0.9)\n    # Direction-of-increasing-gain arrows\n    + geom_segment(arrow_df, aes(x=\"x\", y=\"y\", xend=\"xend\", yend=\"yend\"), color=INK, size=0.9, arrow=arrow(length=0.15))\n    # Open-loop poles (×) and breakaway point (□)\n    + geom_point(marker_df, aes(x=\"real\", y=\"imaginary\", shape=\"type\"), size=4, color=INK, stroke=1.5, fill=INK)\n    + scale_shape_manual(values={\"Open-loop Pole\": \"x\", \"Breakaway Point\": \"s\"}, name=\"Markers\")\n    # Imaginary axis crossings — Imprint matte red: semantic role (instability boundary)\n    + geom_point(crossing_df, aes(x=\"real\", y=\"imaginary\"), shape=\"D\", size=4, color=\"#AE3030\", stroke=1.2)\n    + geom_text(\n        crossing_label_df,\n        aes(x=\"real\", y=\"imaginary\", label=\"label\"),\n        color=\"#AE3030\",\n        size=3.0,\n        ha=\"left\",\n        nudge_x=0.4,\n        nudge_y=0.3,\n        fontweight=\"bold\",\n    )\n    # Breakaway point annotation\n    + annotate(\n        \"text\",\n        x=breakaway_s - 0.8,\n        y=-0.7,\n        label=f\"Breakaway\\nK={breakaway_K:.2f}\",\n        color=INK_SOFT,\n        size=3.0,\n        ha=\"center\",\n        fontweight=\"bold\",\n    )\n    # Axes (real and imaginary)\n    + geom_hline(yintercept=0, color=INK_SOFT, size=0.4)\n    + geom_vline(xintercept=0, color=INK_SOFT, size=0.4)\n    + scale_color_manual(values=branch_colors)\n    + scale_x_continuous(labels=sigma_fmt, breaks=[-5, -4, -3, -2, -1, 0, 1, 2])\n    + scale_y_continuous(\n        labels=lambda vs: [\"0\" if int(round(v)) == 0 else f\"{int(round(v))}j\" for v in vs],\n        breaks=[-4, -3, -2, -1, 0, 1, 2, 3, 4],\n    )\n    + coord_fixed(ratio=1, xlim=(-5.2, 2.2), ylim=(-4.8, 4.8))\n    + labs(\n        title=\"root-locus-basic · python · plotnine · anyplot.ai\",\n        x=\"Real Axis (σ)\",\n        y=\"Imaginary Axis (jω)\",\n        color=\"Branch\",\n    )\n    + guides(\n        shape=guide_legend(order=1, override_aes={\"size\": 4}), color=guide_legend(order=2, override_aes={\"size\": 2})\n    )\n    + theme_minimal()\n    + theme(\n        figure_size=(6, 6),\n        plot_title=element_text(size=12, weight=\"bold\", ha=\"center\", color=INK),\n        axis_title=element_text(size=10, color=INK),\n        axis_text=element_text(size=8, color=INK_SOFT),\n        legend_title=element_text(size=10, weight=\"bold\", color=INK),\n        legend_text=element_text(size=8, color=INK_SOFT),\n        legend_position=\"right\",\n        legend_background=element_rect(fill=ELEVATED_BG, color=INK_SOFT, size=0.5),\n        legend_key_size=14,\n        panel_grid_major=element_line(color=INK, size=0.2, alpha=0.12),\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        panel_border=element_rect(color=INK_SOFT, fill=None),\n        axis_line=element_line(color=INK_SOFT, size=0.4),\n    )\n)\n\nplot.save(f\"plot-{THEME}.png\", dpi=400, width=6, height=6, units=\"in\", verbose=False)\n"}