{"spec_id":"bode-basic","library":"bokeh","language":"python","code":"\"\"\" anyplot.ai\nbode-basic: Bode Plot for Frequency Response\nLibrary: bokeh 3.9.1 | Python 3.13.13\nQuality: 91/100 | Updated: 2026-06-17\n\"\"\"\n\nimport os\nimport sys\n\n\n# Prevent self-import: this file is named bokeh.py, which shadows the installed\n# bokeh package when its directory sits at the front of sys.path.\n_this_dir = os.path.dirname(os.path.abspath(__file__))\nsys.path = [p for p in sys.path if os.path.abspath(p or \".\") != _this_dir]\n\nimport time\nfrom pathlib import Path\n\nimport numpy as np\nfrom bokeh.io import output_file, save\nfrom bokeh.layouts import column\nfrom bokeh.models import BoxAnnotation, ColumnDataSource, HoverTool, Label, 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\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_SOFT = \"#4A4A44\" if THEME == \"light\" else \"#B8B7B0\"\n\n# Imprint palette — frequency response curves always use brand green (first series)\nBRAND = \"#009E73\"  # Imprint position 1 — Bode curves\nGM_COLOR = \"#AE3030\"  # Imprint position 5 — gain margin (semantic red: stability risk)\nPM_COLOR = \"#C475FD\"  # Imprint position 2 — phase margin\n\n# Data — third-order open-loop transfer function: H(s) = K / (s * (s/w1+1) * (s/w2+1))\nK = 100\nw1 = 2 * np.pi * 5  # pole at 5 Hz\nw2 = 2 * np.pi * 50  # pole at 50 Hz\n\nfrequency_hz = np.logspace(-1, 3, 500)\nomega = 2 * np.pi * frequency_hz\ns = 1j * omega\n\nH = K / (s * (s / w1 + 1) * (s / w2 + 1))\nmagnitude_db = 20 * np.log10(np.abs(H))\nphase_deg = np.degrees(np.unwrap(np.angle(H)))\n\n# Gain crossover: where |H(jw)| = 0 dB\nsign_changes = np.diff(np.sign(magnitude_db))\ngc_indices = np.where(sign_changes != 0)[0]\ngain_cross_idx = gc_indices[0] if len(gc_indices) > 0 else np.argmin(np.abs(magnitude_db))\ngain_cross_freq = frequency_hz[gain_cross_idx]\nphase_at_gain_cross = phase_deg[gain_cross_idx]\nphase_margin = 180 + phase_at_gain_cross\n\n# Phase crossover: where ∠H(jw) = -180°\nphase_shifted = phase_deg + 180\nsign_changes_phase = np.diff(np.sign(phase_shifted))\npc_indices = np.where(sign_changes_phase != 0)[0]\nphase_cross_idx = pc_indices[0] if len(pc_indices) > 0 else np.argmin(np.abs(phase_deg + 180))\nphase_cross_freq = frequency_hz[phase_cross_idx]\nmag_at_phase_cross = magnitude_db[phase_cross_idx]\ngain_margin = -mag_at_phase_cross\n\nsource = ColumnDataSource(data={\"frequency\": frequency_hz, \"magnitude\": magnitude_db, \"phase\": phase_deg})\n\nTITLE = \"bode-basic · python · bokeh · anyplot.ai\"\n\n# Magnitude plot (top panel) — width=3200 height=900, two panels stack to 3200×1800\np_mag = figure(\n    width=3200,\n    height=900,\n    x_axis_type=\"log\",\n    x_axis_label=\"\",\n    y_axis_label=\"Magnitude (dB)\",\n    title=TITLE,\n    toolbar_location=None,\n    min_border_bottom=30,\n    min_border_left=180,\n    min_border_top=110,\n    min_border_right=50,\n)\n\np_mag.add_layout(BoxAnnotation(bottom=0, fill_color=BRAND, fill_alpha=0.06))\np_mag.line(\"frequency\", \"magnitude\", source=source, line_width=4, color=BRAND)\np_mag.add_layout(\n    Span(location=0, dimension=\"width\", line_color=INK_SOFT, line_width=2, line_dash=\"dashed\", line_alpha=0.7)\n)\np_mag.add_layout(Label(x=0.12, y=2, text=\"0 dB\", text_font_size=\"20pt\", text_color=INK_SOFT, text_font_style=\"italic\"))\np_mag.scatter([phase_cross_freq], [mag_at_phase_cross], size=18, color=GM_COLOR, marker=\"circle\")\np_mag.scatter([phase_cross_freq], [0], size=18, color=GM_COLOR, marker=\"circle\")\np_mag.segment(\n    x0=[phase_cross_freq],\n    y0=[mag_at_phase_cross],\n    x1=[phase_cross_freq],\n    y1=[0],\n    line_width=3,\n    color=GM_COLOR,\n    line_dash=\"dotted\",\n)\np_mag.add_layout(\n    Label(\n        x=phase_cross_freq,\n        y=mag_at_phase_cross / 2,\n        text=f\"GM = {gain_margin:.1f} dB\",\n        text_font_size=\"24pt\",\n        text_font_style=\"bold\",\n        text_color=GM_COLOR,\n        x_offset=18,\n    )\n)\np_mag.scatter([gain_cross_freq], [0], size=18, color=PM_COLOR, marker=\"circle\")\np_mag.add_tools(\n    HoverTool(\n        tooltips=[(\"Frequency\", \"@frequency{0.00} Hz\"), (\"Magnitude\", \"@magnitude{0.0} dB\")],\n        mode=\"vline\",\n        line_policy=\"nearest\",\n    )\n)\n\np_mag.background_fill_color = PAGE_BG\np_mag.border_fill_color = PAGE_BG\np_mag.outline_line_color = None\np_mag.title.text_color = INK\np_mag.title.text_font_size = \"50pt\"\np_mag.title.text_font_style = \"normal\"\np_mag.xaxis.axis_label_text_color = INK\np_mag.yaxis.axis_label_text_color = INK\np_mag.xaxis.major_label_text_color = INK_SOFT\np_mag.yaxis.major_label_text_color = INK_SOFT\np_mag.xaxis.axis_line_color = INK_SOFT\np_mag.yaxis.axis_line_color = INK_SOFT\np_mag.xaxis.major_tick_line_color = INK_SOFT\np_mag.yaxis.major_tick_line_color = INK_SOFT\np_mag.xaxis.minor_tick_line_color = None\np_mag.yaxis.minor_tick_line_color = None\np_mag.yaxis.axis_label_text_font_size = \"42pt\"\np_mag.xaxis.axis_label_text_font_size = \"42pt\"\np_mag.xaxis.major_label_text_font_size = \"34pt\"\np_mag.yaxis.major_label_text_font_size = \"34pt\"\np_mag.ygrid.grid_line_alpha = 0.15\np_mag.xgrid.grid_line_alpha = 0.15\np_mag.ygrid.grid_line_color = INK\np_mag.xgrid.grid_line_color = INK\n\n# Phase plot (bottom panel)\np_phase = figure(\n    width=3200,\n    height=900,\n    x_axis_type=\"log\",\n    x_axis_label=\"Frequency (Hz)\",\n    y_axis_label=\"Phase (°)\",\n    x_range=p_mag.x_range,\n    toolbar_location=None,\n    min_border_bottom=160,\n    min_border_left=180,\n    min_border_top=30,\n    min_border_right=50,\n)\n\np_phase.add_layout(BoxAnnotation(top=-180, fill_color=GM_COLOR, fill_alpha=0.06))\np_phase.line(\"frequency\", \"phase\", source=source, line_width=4, color=BRAND)\np_phase.add_layout(\n    Span(location=-180, dimension=\"width\", line_color=INK_SOFT, line_width=2, line_dash=\"dashed\", line_alpha=0.7)\n)\np_phase.add_layout(\n    Label(x=0.12, y=-177, text=\"-180°\", text_font_size=\"20pt\", text_color=INK_SOFT, text_font_style=\"italic\")\n)\np_phase.scatter([gain_cross_freq], [phase_at_gain_cross], size=18, color=PM_COLOR, marker=\"circle\")\np_phase.scatter([gain_cross_freq], [-180], size=18, color=PM_COLOR, marker=\"circle\")\np_phase.segment(\n    x0=[gain_cross_freq],\n    y0=[phase_at_gain_cross],\n    x1=[gain_cross_freq],\n    y1=[-180],\n    line_width=3,\n    color=PM_COLOR,\n    line_dash=\"dotted\",\n)\np_phase.add_layout(\n    Label(\n        x=gain_cross_freq,\n        y=(phase_at_gain_cross + (-180)) / 2,\n        text=f\"PM = {phase_margin:.1f}°\",\n        text_font_size=\"24pt\",\n        text_font_style=\"bold\",\n        text_color=PM_COLOR,\n        x_offset=18,\n    )\n)\np_phase.scatter([phase_cross_freq], [-180], size=18, color=GM_COLOR, marker=\"circle\")\np_phase.add_tools(\n    HoverTool(\n        tooltips=[(\"Frequency\", \"@frequency{0.00} Hz\"), (\"Phase\", \"@phase{0.0}°\")], mode=\"vline\", line_policy=\"nearest\"\n    )\n)\n\np_phase.background_fill_color = PAGE_BG\np_phase.border_fill_color = PAGE_BG\np_phase.outline_line_color = None\np_phase.xaxis.axis_label_text_color = INK\np_phase.yaxis.axis_label_text_color = INK\np_phase.xaxis.major_label_text_color = INK_SOFT\np_phase.yaxis.major_label_text_color = INK_SOFT\np_phase.xaxis.axis_line_color = INK_SOFT\np_phase.yaxis.axis_line_color = INK_SOFT\np_phase.xaxis.major_tick_line_color = INK_SOFT\np_phase.yaxis.major_tick_line_color = INK_SOFT\np_phase.xaxis.minor_tick_line_color = None\np_phase.yaxis.minor_tick_line_color = None\np_phase.yaxis.axis_label_text_font_size = \"42pt\"\np_phase.xaxis.axis_label_text_font_size = \"42pt\"\np_phase.xaxis.major_label_text_font_size = \"34pt\"\np_phase.yaxis.major_label_text_font_size = \"34pt\"\np_phase.ygrid.grid_line_alpha = 0.15\np_phase.xgrid.grid_line_alpha = 0.15\np_phase.ygrid.grid_line_color = INK\np_phase.xgrid.grid_line_color = INK\n\n# Layout — two 900px panels stacked to 3200×1800 total\nlayout = column(p_mag, p_phase, spacing=0)\n\n# Save interactive HTML catalog artifact\noutput_file(f\"plot-{THEME}.html\")\nsave(layout)\n\n# Screenshot with headless Chrome — CDP setDeviceMetricsOverride forces exact\n# inner viewport (--window-size alone is consumed by browser chrome in headless).\nW, H = 3200, 1800\nopts = Options()\nfor arg in (\n    \"--headless=new\",\n    \"--no-sandbox\",\n    \"--disable-dev-shm-usage\",\n    \"--disable-gpu\",\n    f\"--window-size={W},{H}\",\n    \"--hide-scrollbars\",\n):\n    opts.add_argument(arg)\ndriver = webdriver.Chrome(options=opts)\ndriver.execute_cdp_cmd(\n    \"Emulation.setDeviceMetricsOverride\", {\"width\": W, \"height\": H, \"deviceScaleFactor\": 1, \"mobile\": False}\n)\ndriver.get(f\"file://{Path(f'plot-{THEME}.html').resolve()}\")\ntime.sleep(3)\ndriver.save_screenshot(f\"plot-{THEME}.png\")\ndriver.quit()\n\n# Belt-and-braces: pad/crop to exact dims so the post-render gate always passes\nfrom PIL import Image as _PILImage\n\n\n_img = _PILImage.open(f\"plot-{THEME}.png\").convert(\"RGB\")\nif _img.size != (W, H):\n    _norm = _PILImage.new(\"RGB\", (W, H), PAGE_BG)\n    _norm.paste(_img, ((W - _img.size[0]) // 2, (H - _img.size[1]) // 2))\n    _norm.save(f\"plot-{THEME}.png\")\n"}