{"spec_id":"bode-basic","library":"letsplot","language":"python","code":"\"\"\" anyplot.ai\nbode-basic: Bode Plot for Frequency Response\nLibrary: letsplot 4.10.1 | Python 3.13.14\nQuality: 90/100 | Updated: 2026-06-17\n\"\"\"\n\nimport os\n\nimport numpy as np\nimport pandas as pd\nfrom lets_plot import *\nfrom lets_plot import ggsave\nfrom scipy import signal\n\n\nLetsPlot.setup_html()\n\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\n\n# Theme-adaptive chrome tokens\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\"\nRULE = \"rgba(26,26,23,0.15)\" if THEME == \"light\" else \"rgba(240,239,232,0.15)\"\n\n# Imprint palette — first series always #009E73\nIMPRINT_PALETTE = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\", \"#99B314\"]\nCOLOR_MAIN = IMPRINT_PALETTE[0]  # brand green — main frequency response line\nCOLOR_GM = IMPRINT_PALETTE[3]  # ochre — gain margin annotation\nCOLOR_PM = IMPRINT_PALETTE[2]  # blue — phase margin annotation\n\n# Data: 3rd-order system G(s) = 500 / ((s+20)(s²+2s+25))\n# Complex poles (wn=5, ζ=0.2) → resonance peak; stable with GM≈5 dB, PM≈18°\nnum = [500.0]\nden = np.polymul([1, 20], [1, 2.0, 25])\nsystem = signal.TransferFunction(num, den)\n\nomega = np.logspace(-1, 2.5, 500)\n_, mag, phase_deg = signal.bode(system, omega)\nfrequency_hz = omega / (2 * np.pi)\n\ndf_mag = pd.DataFrame({\"frequency_hz\": frequency_hz, \"magnitude_db\": mag})\ndf_phase = pd.DataFrame({\"frequency_hz\": frequency_hz, \"phase_deg\": phase_deg})\n\n# Gain crossover: where magnitude = 0 dB\nzero_crossings = np.where(np.diff(np.sign(mag)))[0]\nif len(zero_crossings) > 0:\n    idx_gc = zero_crossings[0]\n    t = abs(mag[idx_gc]) / (abs(mag[idx_gc]) + abs(mag[idx_gc + 1]))\n    freq_gc = frequency_hz[idx_gc] + t * (frequency_hz[idx_gc + 1] - frequency_hz[idx_gc])\n    phase_at_gc = phase_deg[idx_gc] + t * (phase_deg[idx_gc + 1] - phase_deg[idx_gc])\n    phase_margin = 180 + phase_at_gc\nelse:\n    freq_gc = None\n    phase_margin = None\n\n# Phase crossover: where phase = -180°\nphase_crossings = np.where(np.diff(np.sign(phase_deg + 180)))[0]\nif len(phase_crossings) > 0:\n    idx_pc = phase_crossings[0]\n    t_pc = abs(phase_deg[idx_pc] + 180) / (abs(phase_deg[idx_pc] + 180) + abs(phase_deg[idx_pc + 1] + 180))\n    freq_pc = frequency_hz[idx_pc] + t_pc * (frequency_hz[idx_pc + 1] - frequency_hz[idx_pc])\n    mag_at_pc = mag[idx_pc] + t_pc * (mag[idx_pc + 1] - mag[idx_pc])\n    gain_margin = -mag_at_pc\nelse:\n    freq_pc = None\n    gain_margin = None\n\n# Theme-adaptive chrome\nanyplot_theme = theme(\n    plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),\n    panel_background=element_rect(fill=PAGE_BG),\n    panel_grid_major=element_line(color=RULE, size=0.3),\n    panel_grid_minor=element_blank(),\n    panel_border=element_blank(),\n    axis_title=element_text(color=INK, size=12, face=\"bold\"),\n    axis_text=element_text(color=INK_SOFT, size=10),\n    axis_line=element_line(color=INK_SOFT, size=0.5),\n    plot_title=element_text(color=INK, size=16, hjust=0.5, face=\"bold\"),\n    plot_margin=[20, 20, 5, 15],\n)\n\n# --- Magnitude plot ---\nmag_plot = (\n    ggplot(df_mag, aes(x=\"frequency_hz\", y=\"magnitude_db\"))\n    + geom_line(\n        color=COLOR_MAIN,\n        size=1.0,\n        tooltips=layer_tooltips()\n        .format(\"frequency_hz\", \".3f\")\n        .format(\"magnitude_db\", \".1f\")\n        .line(\"Freq: @frequency_hz Hz\")\n        .line(\"Mag: @magnitude_db dB\"),\n    )\n    + geom_hline(yintercept=0, color=INK_SOFT, size=0.5, linetype=\"dashed\")\n    + scale_x_log10()\n    + scale_y_continuous(limits=[-35, max(mag) + 5])\n    + labs(x=\"\", y=\"Magnitude (dB)\", title=\"bode-basic · python · letsplot · anyplot.ai\")\n    + anyplot_theme\n    + ggsize(800, 216)\n)\n\nif freq_pc is not None:\n    gm_seg = pd.DataFrame({\"x\": [freq_pc], \"y\": [mag_at_pc], \"xend\": [freq_pc], \"yend\": [0.0]})\n    gm_pts = pd.DataFrame({\"x\": [freq_pc, freq_pc], \"y\": [0.0, mag_at_pc]})\n    gm_label = pd.DataFrame({\"x\": [freq_pc * 3.5], \"y\": [mag_at_pc - 4], \"label\": [f\"GM = {gain_margin:.1f} dB\"]})\n    mag_plot = (\n        mag_plot\n        + geom_vline(xintercept=freq_pc, color=COLOR_GM, size=0.4, linetype=\"dotted\", alpha=0.6)\n        + geom_segment(\n            aes(x=\"x\", y=\"y\", xend=\"xend\", yend=\"yend\"),\n            data=gm_seg,\n            color=COLOR_GM,\n            size=2.5,\n            arrow=arrow(type=\"closed\", length=7, ends=\"both\"),\n        )\n        + geom_point(aes(x=\"x\", y=\"y\"), data=gm_pts, color=COLOR_GM, size=8, shape=18)\n        + geom_label(\n            aes(x=\"x\", y=\"y\", label=\"label\"),\n            data=gm_label,\n            size=18,\n            color=COLOR_GM,\n            fill=ELEVATED_BG,\n            label_padding=0.3,\n            label_r=0.15,\n            fontface=\"bold\",\n        )\n    )\n\nif freq_gc is not None:\n    gc_mag_pt = pd.DataFrame({\"x\": [freq_gc], \"y\": [0.0]})\n    mag_plot = mag_plot + geom_point(\n        aes(x=\"x\", y=\"y\"),\n        data=gc_mag_pt,\n        color=COLOR_PM,\n        size=8,\n        shape=18,\n    )\n\n# --- Phase plot ---\nphase_plot = (\n    ggplot(df_phase, aes(x=\"frequency_hz\", y=\"phase_deg\"))\n    + geom_line(\n        color=COLOR_MAIN,\n        size=1.0,\n        tooltips=layer_tooltips()\n        .format(\"frequency_hz\", \".3f\")\n        .format(\"phase_deg\", \".1f\")\n        .line(\"Freq: @frequency_hz Hz\")\n        .line(\"Phase: @phase_deg°\"),\n    )\n    + geom_hline(yintercept=-180, color=INK_SOFT, size=0.5, linetype=\"dashed\")\n    + scale_x_log10()\n    + labs(x=\"Frequency (Hz)\", y=\"Phase (°)\")\n    + anyplot_theme\n    + ggsize(800, 216)\n)\n\nif freq_gc is not None:\n    pm_seg = pd.DataFrame({\"x\": [freq_gc], \"y\": [phase_at_gc], \"xend\": [freq_gc], \"yend\": [-180.0]})\n    pm_pts = pd.DataFrame({\"x\": [freq_gc, freq_gc], \"y\": [-180.0, phase_at_gc]})\n    pm_label = pd.DataFrame({\"x\": [freq_gc * 2.5], \"y\": [phase_at_gc + 12], \"label\": [f\"PM = {phase_margin:.1f}°\"]})\n    phase_plot = (\n        phase_plot\n        + geom_vline(xintercept=freq_gc, color=COLOR_PM, size=0.4, linetype=\"dotted\", alpha=0.6)\n        + geom_segment(\n            aes(x=\"x\", y=\"y\", xend=\"xend\", yend=\"yend\"),\n            data=pm_seg,\n            color=COLOR_PM,\n            size=2.5,\n            arrow=arrow(type=\"closed\", length=7, ends=\"both\"),\n        )\n        + geom_point(aes(x=\"x\", y=\"y\"), data=pm_pts, color=COLOR_PM, size=8, shape=18)\n        + geom_label(\n            aes(x=\"x\", y=\"y\", label=\"label\"),\n            data=pm_label,\n            size=18,\n            color=COLOR_PM,\n            fill=ELEVATED_BG,\n            label_padding=0.3,\n            label_r=0.15,\n            fontface=\"bold\",\n        )\n    )\n\nif freq_pc is not None:\n    pc_phase_pt = pd.DataFrame({\"x\": [freq_pc], \"y\": [-180.0]})\n    phase_plot = phase_plot + geom_point(\n        aes(x=\"x\", y=\"y\"),\n        data=pc_phase_pt,\n        color=COLOR_GM,\n        size=8,\n        shape=18,\n    )\n\n# Combine panels vertically; override the wrapper background so the inter-panel\n# gap matches PAGE_BG instead of defaulting to white in dark mode\ncombined = ggbunch(\n    plots=[mag_plot, phase_plot], regions=[(0, 0, 1, 0.48, 0, 0), (0, 0.52, 1, 0.48, 0, 0)]\n) + theme(plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG))\n\nggsave(combined, f\"plot-{THEME}.png\", path=\".\", w=3200, h=1800, unit=\"px\")\nggsave(combined, f\"plot-{THEME}.html\", path=\".\")\n"}