{"spec_id":"nyquist-basic","library":"bokeh","language":"python","code":"\"\"\" anyplot.ai\nnyquist-basic: Nyquist Plot for Control Systems\nLibrary: bokeh 3.9.1 | Python 3.13.13\nQuality: 88/100 | Updated: 2026-06-17\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\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\n\n# Theme-adaptive chrome tokens (Imprint palette)\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 — canonical order, theme-independent\nIMPRINT = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\", \"#99B314\"]\n\nCOLOR_POS = IMPRINT[0]  # brand green — positive frequency arc (first series)\nCOLOR_NEG = IMPRINT[2]  # blue — negative frequency mirror arc\nCOLOR_CRITICAL = IMPRINT[4]  # matte red — critical point (-1, 0), semantic danger anchor\nCOLOR_GM_LINE = IMPRINT[3]  # ochre — gain margin reference segment\n\n# ── Data ─────────────────────────────────────────────────────────────────────\n# Transfer function: G(s) = 50 / ((s+1)(s+2)(s+5)) — third-order stable system\npoles = np.array([-1.0, -2.0, -5.0])\ngain_k = 50.0\n\nfreq = np.concatenate(\n    [np.logspace(-3, -1, 100), np.logspace(-1, 0.5, 300), np.logspace(0.5, 1.5, 200), np.logspace(1.5, 3, 100)]\n)\n\njw = 1j * freq\nG = gain_k / np.prod(np.array([jw - p for p in poles]), axis=0)\nreal_part = G.real\nimag_part = G.imag\n\n# Conjugate-symmetric mirror for negative frequencies\nreal_mirror = real_part[::-1]\nimag_mirror = -imag_part[::-1]\n\nmagnitude = np.abs(G)\nphase_deg = np.degrees(np.arctan2(imag_part, real_part))\n\n# Gain crossover: |G(jω)| = 1\ngain_cross_idx = None\nfor i in range(len(magnitude) - 1):\n    if (magnitude[i] - 1) * (magnitude[i + 1] - 1) < 0:\n        gain_cross_idx = i\n        break\n\n# Phase crossover: Im(G) = 0 with Re(G) < 0\nphase_cross_idx = None\nfor i in range(1, len(imag_part) - 1):\n    if imag_part[i] * imag_part[i + 1] < 0 and real_part[i] < 0:\n        phase_cross_idx = i\n        break\n\ngain_margin_db = None\nphase_margin_deg = None\nif phase_cross_idx is not None:\n    gain_margin_db = 20 * np.log10(-1.0 / real_part[phase_cross_idx])\nif gain_cross_idx is not None:\n    phase_margin_deg = 180 + phase_deg[gain_cross_idx]\n\nsource_pos = ColumnDataSource(\n    data={\"real\": real_part, \"imag\": imag_part, \"freq\": freq, \"mag\": magnitude, \"phase\": phase_deg}\n)\nsource_neg = ColumnDataSource(data={\"real\": real_mirror, \"imag\": imag_mirror})\n\ntheta = np.linspace(0, 2 * np.pi, 200)\nunit_x = np.cos(theta).tolist()\nunit_y = np.sin(theta).tolist()\n\n# ── Canvas ────────────────────────────────────────────────────────────────────\n# Square 2400×2400: spec requires 1:1 aspect ratio so the unit circle is circular.\n# Equal x/y data extents (same span) on a square canvas gives 1:1 pixel scaling\n# without match_aspect=True (which causes Bokeh to shrink the rendered height).\nW, H = 2400, 2400\n\nx_lo_raw = min(real_part.min(), -1.5) * 1.2\nx_hi_raw = max(real_part.max(), 1.0) * 1.15\ny_ext = max(abs(imag_part).max(), 1.2) * 1.2\n\n# Force equal data extents so 1 unit on x == 1 unit on y (circle appears circular)\nspan = max(x_hi_raw - x_lo_raw, 2 * y_ext) * 1.02\nx_center = (x_lo_raw + x_hi_raw) / 2\nx_lo = x_center - span / 2\nx_hi = x_center + span / 2\ny_lo = -span / 2\ny_hi = span / 2\n\np = figure(\n    width=W,\n    height=H,\n    title=\"nyquist-basic · python · bokeh · anyplot.ai\",\n    x_axis_label=\"Real\",\n    y_axis_label=\"Imaginary\",\n    x_range=Range1d(x_lo, x_hi),\n    y_range=Range1d(y_lo, y_hi),\n    toolbar_location=None,  # prevents toolbar from adding height to the PNG\n    min_border_bottom=160,\n    min_border_left=180,\n    min_border_top=110,\n    min_border_right=50,\n)\n\n# ── Reference elements ────────────────────────────────────────────────────────\np.line(\n    unit_x, unit_y, line_color=INK_SOFT, line_width=2, line_dash=\"dashed\", line_alpha=0.45, legend_label=\"Unit circle\"\n)\n\np.add_layout(Span(location=0, dimension=\"width\", line_color=INK_SOFT, line_width=1.5, line_alpha=0.35))\np.add_layout(Span(location=0, dimension=\"height\", line_color=INK_SOFT, line_width=1.5, line_alpha=0.35))\n\nif phase_cross_idx is not None:\n    p.line(\n        [real_part[phase_cross_idx], -1.0],\n        [0.0, 0.0],\n        line_color=COLOR_GM_LINE,\n        line_width=3,\n        line_dash=\"dotted\",\n        line_alpha=0.75,\n    )\n\n# ── Nyquist curves ────────────────────────────────────────────────────────────\nline_pos = p.line(\n    x=\"real\", y=\"imag\", source=source_pos, line_color=COLOR_POS, line_width=4, line_alpha=0.95, legend_label=\"ω > 0\"\n)\np.line(\n    x=\"real\",\n    y=\"imag\",\n    source=source_neg,\n    line_color=COLOR_NEG,\n    line_width=2.5,\n    line_dash=\"dashed\",\n    line_alpha=0.65,\n    legend_label=\"ω < 0\",\n)\n\n# ── Critical point (-1, 0) ────────────────────────────────────────────────────\np.scatter([-1], [0], size=38, marker=\"x\", color=COLOR_CRITICAL, line_width=7, legend_label=\"Critical point (−1, 0)\")\n\n# ── Direction arrows — positive frequency arc ─────────────────────────────────\nfor idx in [len(freq) // 6, len(freq) // 3, len(freq) * 2 // 3]:\n    if idx < len(freq) - 5:\n        dx = real_part[idx + 5] - real_part[idx]\n        dy = imag_part[idx + 5] - imag_part[idx]\n        p.scatter(\n            [real_part[idx]],\n            [imag_part[idx]],\n            size=20,\n            marker=\"triangle\",\n            color=COLOR_POS,\n            angle=[np.arctan2(dy, dx) - np.pi / 2],\n            alpha=0.9,\n        )\n\n# ── Direction arrows — negative frequency mirror arc ──────────────────────────\nfor idx in [len(freq) // 6, len(freq) // 3, len(freq) * 2 // 3]:\n    ridx = len(freq) - 1 - idx\n    if ridx > 5 and ridx + 5 < len(real_mirror):\n        dx = real_mirror[ridx + 5] - real_mirror[ridx]\n        dy = imag_mirror[ridx + 5] - imag_mirror[ridx]\n        if abs(dx) + abs(dy) > 1e-8:\n            p.scatter(\n                [real_mirror[ridx]],\n                [imag_mirror[ridx]],\n                size=18,\n                marker=\"triangle\",\n                color=COLOR_NEG,\n                angle=[np.arctan2(dy, dx) - np.pi / 2],\n                alpha=0.65,\n            )\n\n# ── Crossover annotations ─────────────────────────────────────────────────────\nif gain_cross_idx is not None:\n    gc_freq = freq[gain_cross_idx]\n    p.scatter(\n        [real_part[gain_cross_idx]],\n        [imag_part[gain_cross_idx]],\n        size=28,\n        marker=\"circle\",\n        color=COLOR_POS,\n        line_color=PAGE_BG,\n        line_width=3,\n    )\n    pm_text = f\"  PM={phase_margin_deg:.1f}°\" if phase_margin_deg is not None else \"\"\n    p.add_layout(\n        Label(\n            x=real_part[gain_cross_idx] + 0.10,\n            y=imag_part[gain_cross_idx] - 0.25,\n            text=f\"Gain x-over ω={gc_freq:.2f} rad/s{pm_text}\",\n            text_font_size=\"22pt\",\n            text_color=COLOR_POS,\n            text_font_style=\"bold\",\n        )\n    )\n\nif phase_cross_idx is not None:\n    pc_freq = freq[phase_cross_idx]\n    p.scatter(\n        [real_part[phase_cross_idx]],\n        [imag_part[phase_cross_idx]],\n        size=28,\n        marker=\"diamond\",\n        color=COLOR_CRITICAL,\n        line_color=PAGE_BG,\n        line_width=3,\n    )\n    gm_text = f\"  GM={gain_margin_db:.1f} dB\" if gain_margin_db is not None else \"\"\n    p.add_layout(\n        Label(\n            x=real_part[phase_cross_idx] - 0.10,\n            y=imag_part[phase_cross_idx] + 0.40,\n            text=f\"Phase x-over ω={pc_freq:.2f}{gm_text}\",\n            text_font_size=\"22pt\",\n            text_color=COLOR_CRITICAL,\n            text_font_style=\"bold\",\n        )\n    )\n\np.add_layout(\n    Label(\n        x=real_part[0] + 0.04,\n        y=imag_part[0] - 0.10,\n        text=\"ω → 0\",\n        text_font_size=\"22pt\",\n        text_color=INK_MUTED,\n        text_font_style=\"italic\",\n    )\n)\np.add_layout(\n    Label(\n        x=real_part[-1] + 0.24,\n        y=imag_part[-1] - 0.14,\n        text=\"ω → ∞\",\n        text_font_size=\"22pt\",\n        text_color=INK_MUTED,\n        text_font_style=\"italic\",\n    )\n)\n\n# Selected frequency labels at key points on the outer locus\nfor kf, off_x, off_y in [(0.1, 0.15, 0.20), (1.0, 0.15, 0.18), (10.0, 0.12, -0.30)]:\n    idx = int(np.argmin(np.abs(freq - kf)))\n    p.add_layout(\n        Label(\n            x=real_part[idx] + off_x,\n            y=imag_part[idx] + off_y,\n            text=f\"ω={kf}\",\n            text_font_size=\"20pt\",\n            text_color=INK_MUTED,\n            text_font_style=\"italic\",\n        )\n    )\n\n# ── Hover tool ────────────────────────────────────────────────────────────────\np.add_tools(\n    HoverTool(\n        renderers=[line_pos],\n        tooltips=[\n            (\"ω\", \"@freq{0.000} rad/s\"),\n            (\"G(jω)\", \"@real{0.000} + @imag{0.000}j\"),\n            (\"|G(jω)|\", \"@mag{0.000}\"),\n            (\"Phase\", \"@phase{0.0}°\"),\n        ],\n        point_policy=\"snap_to_data\",\n        mode=\"mouse\",\n    )\n)\n\n# ── Chrome styling ────────────────────────────────────────────────────────────\np.title.text_font_size = \"50pt\"\np.title.text_color = INK\np.title.offset = 12\n\np.xaxis.axis_label_text_font_size = \"42pt\"\np.yaxis.axis_label_text_font_size = \"42pt\"\np.xaxis.axis_label_text_color = INK\np.yaxis.axis_label_text_color = INK\np.xaxis.axis_label_text_font_style = \"normal\"\np.yaxis.axis_label_text_font_style = \"normal\"\n\np.xaxis.major_label_text_font_size = \"34pt\"\np.yaxis.major_label_text_font_size = \"34pt\"\np.xaxis.major_label_text_color = INK_SOFT\np.yaxis.major_label_text_color = INK_SOFT\n\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\np.xaxis.major_tick_out = 0\np.yaxis.major_tick_out = 0\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\n\np.background_fill_color = PAGE_BG\np.border_fill_color = PAGE_BG\np.outline_line_color = None\n\np.legend.location = \"top_right\"\np.legend.label_text_font_size = \"34pt\"\np.legend.label_text_color = INK_SOFT\np.legend.background_fill_color = ELEVATED_BG\np.legend.background_fill_alpha = 0.92\np.legend.border_line_color = INK_SOFT\np.legend.border_line_width = 1.5\np.legend.glyph_width = 36\np.legend.glyph_height = 36\np.legend.spacing = 8\np.legend.padding = 14\np.legend.margin = 20\n\n# ── Save ──────────────────────────────────────────────────────────────────────\noutput_file(f\"plot-{THEME}.html\")\nsave(p)\n\n# Chrome's viewport is typically ~143 px shorter than the OS window due to browser chrome\n# overhead even in headless mode. Add a vertical buffer, then crop the screenshot to the\n# exact canvas dimensions so the post-render gate sees the right size.\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\n# Crop to exact canvas dimensions\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"}