{"spec_id":"root-locus-basic","library":"bokeh","language":"python","code":"\"\"\" anyplot.ai\nroot-locus-basic: Root Locus Plot for Control Systems\nLibrary: bokeh 3.9.1 | Python 3.13.14\nQuality: 89/100 | Updated: 2026-06-18\n\"\"\"\n\n# Remove the script's own directory from sys.path so that `import bokeh` resolves\n# to the installed package rather than this file (which shares the name \"bokeh.py\").\nimport os as _os\nimport sys as _sys\n\n\n_here = _os.path.dirname(_os.path.abspath(__file__))\n_sys.path = [p for p in _sys.path if not p or _os.path.abspath(p) != _here]\ndel _sys, _os, _here\n\nimport os\nimport time\nfrom pathlib import Path\n\nimport numpy as np\nfrom bokeh.io import output_file, save\nfrom bokeh.models import ColumnDataSource, HoverTool, Label, Range1d, Span\nfrom bokeh.plotting import figure\nfrom selenium import webdriver\nfrom selenium.webdriver.chrome.options import Options\n\n\n# Theme tokens (Imprint palette — theme-adaptive chrome)\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 — hybrid-v3 sort order\nBRAND = \"#009E73\"  # position 1 — branch 1 (real-axis locus)\nLAVENDER = \"#C475FD\"  # position 2 — branches 2 & 3 (complex conjugate pair)\nBLUE = \"#4467A3\"  # position 3 — breakaway annotation\nMATTE_RED = \"#AE3030\"  # position 5 — stability boundary (semantic: error/bad)\n\n# Data — Transfer function G(s) = 1 / (s(s+1)(s+3))\n# Open-loop poles at s = 0, -1, -3; no zeros\nopen_loop_poles = np.array([0.0, -1.0, -3.0])\n\n# Characteristic equation: s³ + 4s² + 3s + K = 0\ngains = np.concatenate(\n    [np.linspace(0, 0.5, 200), np.linspace(0.5, 5, 600), np.linspace(5, 20, 600), np.linspace(20, 80, 600)]\n)\n\nall_roots = np.zeros((len(gains), 3), dtype=complex)\nfor i, k in enumerate(gains):\n    all_roots[i] = np.sort_complex(np.roots([1, 4, 3, k]))\n\n# Organise into branches\nn_branches = 3\nbranch_real, branch_imag, branch_gain, branch_id = [], [], [], []\nbranch_colors_map = [BRAND, LAVENDER, LAVENDER]\n\nfor b in range(n_branches):\n    branch_real.extend(all_roots[:, b].real)\n    branch_imag.extend(all_roots[:, b].imag)\n    branch_gain.extend(gains)\n    branch_id.extend([f\"Branch {b + 1}\"] * len(gains))\n\nbranch_real = np.array(branch_real)\nbranch_imag = np.array(branch_imag)\nbranch_gain = np.array(branch_gain)\ncolors = [c for b in range(n_branches) for c in [branch_colors_map[b]] * len(gains)]\n\nsource = ColumnDataSource(\n    data={\"real\": branch_real, \"imag\": branch_imag, \"gain\": branch_gain, \"branch\": branch_id, \"color\": colors}\n)\npole_source = ColumnDataSource(data={\"real\": open_loop_poles, \"imag\": np.zeros_like(open_loop_poles)})\n\n# Key engineering values (Routh criterion for s³ + 4s² + 3s + K = 0)\nw_crit = np.sqrt(3)\ncrossing_source = ColumnDataSource(data={\"real\": [0.0, 0.0], \"imag\": [w_crit, -w_crit]})\n\n# Breakaway point: dK/ds = 0 → 3s² + 8s + 3 = 0\nbreakaway_s = (-8 + np.sqrt(28)) / 6\nbreakaway_K = -(breakaway_s**3 + 4 * breakaway_s**2 + 3 * breakaway_s)\ncentroid = open_loop_poles.sum() / len(open_loop_poles)\n\n# Title — adaptive fontsize for longer strings (46 chars < 67 → default 50pt)\ntitle = \"root-locus-basic · python · bokeh · anyplot.ai\"\nn = len(title)\ntitle_fontsize = f\"{round(50 * 67 / n)}pt\" if n > 67 else \"50pt\"\n\ny_max = max(abs(branch_imag)) * 1.15\n\n# Plot\np = figure(\n    width=3200,\n    height=1800,\n    title=title,\n    x_axis_label=\"Real Axis (σ)\",\n    y_axis_label=\"Imaginary Axis (jω)\",\n    x_range=Range1d(-5.5, 1.5),\n    y_range=Range1d(-y_max, y_max),\n    match_aspect=True,\n    toolbar_location=None,  # prevents toolbar adding ~30-50px above canvas in PNG\n    min_border_bottom=160,  # room for 34pt x-tick labels + 42pt x-axis label\n    min_border_left=180,  # room for 34pt y-tick labels + 42pt y-axis label\n    min_border_top=110,  # room for 50pt title\n    min_border_right=50,\n)\n\n# Damping ratio guide lines — increased alpha (was 0.25, now 0.5) for better utility\nfor zeta in [0.2, 0.4, 0.6, 0.8]:\n    r_max = 5.0\n    x_end = -r_max * zeta\n    y_end = r_max * np.sqrt(1 - zeta**2)\n    p.line(x=[0, x_end], y=[0, y_end], line_color=INK_MUTED, line_width=1.5, line_alpha=0.5, line_dash=\"dotted\")\n    p.line(x=[0, x_end], y=[0, -y_end], line_color=INK_MUTED, line_width=1.5, line_alpha=0.5, line_dash=\"dotted\")\n    lx = -4.5 * zeta\n    ly = 4.5 * np.sqrt(1 - zeta**2)\n    p.add_layout(\n        Label(x=lx, y=ly + 0.15, text=f\"ζ={zeta}\", text_font_size=\"24pt\", text_color=INK_MUTED, text_alpha=0.9)\n    )\n\n# Natural frequency arcs — labels placed at arc midpoint to avoid legend overlap\nfor wn in [1, 2, 3, 4]:\n    theta = np.linspace(np.pi / 2, np.pi, 60)\n    arc_x = wn * np.cos(theta)\n    arc_y = wn * np.sin(theta)\n    p.line(\n        x=arc_x.tolist(), y=arc_y.tolist(), line_color=INK_MUTED, line_width=1.5, line_alpha=0.45, line_dash=\"dotted\"\n    )\n    p.line(\n        x=arc_x.tolist(), y=(-arc_y).tolist(), line_color=INK_MUTED, line_width=1.5, line_alpha=0.45, line_dash=\"dotted\"\n    )\n    # Label at leftmost point of arc; ωn=1 shifted up to avoid overlap with centroid annotation\n    p.add_layout(\n        Label(\n            x=-wn - 0.1,\n            y=(0.55 if wn == 1 else 0.25),\n            text=f\"ωn={wn}\",\n            text_font_size=\"22pt\",\n            text_color=INK_MUTED,\n            text_alpha=0.9,\n        )\n    )\n\n# Real-axis locus segments: [-1, 0] and (-∞, -3]\np.segment(\n    x0=[-1], y0=[0], x1=[0], y1=[0], line_color=BRAND, line_width=6, line_alpha=0.55, legend_label=\"Real-axis locus\"\n)\np.segment(x0=[-5.5], y0=[0], x1=[-3], y1=[0], line_color=BRAND, line_width=6, line_alpha=0.55)\n\n# Locus branches (size increased from 6 to 10 for better visibility)\nscatter = p.scatter(\n    x=\"real\",\n    y=\"imag\",\n    source=source,\n    size=10,\n    color=\"color\",\n    alpha=0.75,\n    line_color=None,\n    legend_label=\"Complex branches\",\n)\n\n# Open-loop poles (× markers)\np.scatter(\n    x=\"real\", y=\"imag\", source=pole_source, size=45, marker=\"x\", color=INK, line_width=5, legend_label=\"Open-loop poles\"\n)\n\n# Stability boundary crossings\np.scatter(\n    x=\"real\",\n    y=\"imag\",\n    source=crossing_source,\n    size=35,\n    marker=\"diamond\",\n    color=MATTE_RED,\n    line_color=PAGE_BG,\n    line_width=3,\n    legend_label=\"Stability crossing (K=12)\",\n)\n\n# Breakaway point\np.scatter(\n    x=[breakaway_s],\n    y=[0],\n    size=30,\n    marker=\"square\",\n    color=BLUE,\n    line_color=PAGE_BG,\n    line_width=3,\n    legend_label=f\"Breakaway (K={breakaway_K:.2f})\",\n)\n\n# Imaginary axis — stability boundary\np.add_layout(\n    Span(location=0, dimension=\"height\", line_color=MATTE_RED, line_width=2.5, line_alpha=0.25, line_dash=\"dashed\")\n)\n\n# Direction arrows indicating increasing gain on each branch\nfor b in range(n_branches):\n    arrow_idx = len(gains) * 2 // 3\n    r = all_roots[arrow_idx, b]\n    r_next = all_roots[min(arrow_idx + 20, len(gains) - 1), b]\n    dx = r_next.real - r.real\n    dy = r_next.imag - r.imag\n    length = np.sqrt(dx**2 + dy**2)\n    if length > 0.01:\n        p.scatter(\n            x=[r.real],\n            y=[r.imag],\n            size=28,\n            marker=\"triangle\",\n            color=branch_colors_map[b],\n            angle=[np.arctan2(dy, dx) - np.pi / 2],\n            alpha=0.9,\n        )\n\n# Engineering annotations at key control theory points\np.add_layout(\n    Label(\n        x=-1.5,\n        y=w_crit + 0.3,\n        text=f\"jω-crossing: K=12, ω=√3≈{w_crit:.2f}\",\n        text_font_size=\"26pt\",\n        text_color=MATTE_RED,\n        text_font_style=\"bold\",\n    )\n)\n\np.add_layout(\n    Label(\n        x=breakaway_s + 0.05,\n        y=-0.55,\n        text=f\"Breakaway: σ={breakaway_s:.3f}, K={breakaway_K:.2f}\",\n        text_font_size=\"24pt\",\n        text_color=BLUE,\n        text_font_style=\"bold\",\n    )\n)\n\np.add_layout(\n    Label(\n        x=centroid - 0.05,\n        y=0.35,\n        text=f\"Centroid σ={centroid:.2f}\",\n        text_font_size=\"22pt\",\n        text_color=INK_MUTED,\n        text_font_style=\"italic\",\n    )\n)\np.scatter(x=[centroid], y=[0], size=18, marker=\"circle\", color=INK_MUTED, line_color=PAGE_BG, line_width=2, alpha=0.7)\n\n# HoverTool (active in HTML preview)\np.add_tools(\n    HoverTool(\n        renderers=[scatter],\n        tooltips=[(\"Pole\", \"@real{0.00} + @imag{0.00}j\"), (\"Gain K\", \"@gain{0.00}\"), (\"Branch\", \"@branch\")],\n        point_policy=\"snap_to_data\",\n        mode=\"mouse\",\n    )\n)\n\n# Theme-adaptive chrome\np.background_fill_color = PAGE_BG\np.border_fill_color = PAGE_BG\np.outline_line_color = INK_SOFT\n\np.title.text_font_size = title_fontsize\np.title.text_color = INK\n\np.xaxis.axis_label_text_font_size = \"42pt\"\np.yaxis.axis_label_text_font_size = \"42pt\"\np.xaxis.major_label_text_font_size = \"34pt\"\np.yaxis.major_label_text_font_size = \"34pt\"\np.xaxis.axis_label_text_color = INK\np.yaxis.axis_label_text_color = INK\np.xaxis.major_label_text_color = INK_SOFT\np.yaxis.major_label_text_color = INK_SOFT\np.xaxis.axis_line_color = INK_SOFT\np.yaxis.axis_line_color = INK_SOFT\np.xaxis.major_tick_line_color = INK_SOFT\np.yaxis.major_tick_line_color = INK_SOFT\np.xaxis.minor_tick_line_color = None\np.yaxis.minor_tick_line_color = None\n\np.xgrid.grid_line_color = INK\np.ygrid.grid_line_color = INK\np.xgrid.grid_line_alpha = 0.12\np.ygrid.grid_line_alpha = 0.12\np.xgrid.grid_line_width = 1.0\np.ygrid.grid_line_width = 1.0\n\np.xaxis.ticker.desired_num_ticks = 10\np.yaxis.ticker.desired_num_ticks = 10\n\np.legend.location = \"top_right\"\np.legend.label_text_font_size = \"28pt\"\np.legend.label_text_color = INK_SOFT\np.legend.background_fill_alpha = 0.92\np.legend.background_fill_color = ELEVATED_BG\np.legend.border_line_color = INK_SOFT\np.legend.border_line_width = 2\np.legend.glyph_width = 40\np.legend.glyph_height = 40\np.legend.spacing = 8\np.legend.padding = 15\np.legend.margin = 20\n\n# Save HTML (interactive catalog artifact)\noutput_file(f\"plot-{THEME}.html\")\nsave(p)\n\n# Screenshot with headless Chrome — Chrome's viewport is ~143 px shorter than the OS\n# window due to browser chrome overhead even in headless mode; add a vertical buffer\n# then crop to the exact canvas size so the post-render gate sees the right dimensions.\nW, H = 3200, 1800\nRENDER_H = H + 300\n\nopts = Options()\nfor arg in (\n    \"--headless=new\",\n    \"--no-sandbox\",\n    \"--disable-dev-shm-usage\",\n    \"--disable-gpu\",\n    f\"--window-size={W},{RENDER_H}\",\n    \"--hide-scrollbars\",\n):\n    opts.add_argument(arg)\n\ndriver = webdriver.Chrome(options=opts)\ndriver.set_window_size(W, RENDER_H)\ndriver.get(f\"file://{Path(f'plot-{THEME}.html').resolve()}\")\ntime.sleep(3)\nraw_path = f\"plot-{THEME}_raw.png\"\ndriver.save_screenshot(raw_path)\ndriver.quit()\n\nfrom PIL import Image\n\n\nimg = Image.open(raw_path)\nimg = img.crop((0, 0, W, H))\nimg.save(f\"plot-{THEME}.png\")\nPath(raw_path).unlink()\n"}