{"spec_id":"bode-basic","library":"plotnine","language":"python","code":"\"\"\" anyplot.ai\nbode-basic: Bode Plot for Frequency Response\nLibrary: plotnine 0.15.7 | Python 3.13.14\nQuality: 90/100 | Updated: 2026-06-17\n\"\"\"\n\nimport os\nimport pathlib\nimport sys\n\n\nscript_dir = str(pathlib.Path(__file__).parent)\nsys.path = [p for p in sys.path if os.path.abspath(p) != os.path.abspath(script_dir)]\n\nimport numpy as np\nimport pandas as pd\nfrom plotnine import (\n    aes,\n    element_line,\n    element_rect,\n    element_text,\n    facet_wrap,\n    geom_hline,\n    geom_line,\n    geom_point,\n    geom_segment,\n    geom_text,\n    geom_vline,\n    ggplot,\n    labs,\n    scale_x_log10,\n    scale_y_continuous,\n    theme,\n    theme_minimal,\n)\n\n\n# Theme tokens\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\"\nGRID_MAJOR = \"#D8D7D0\" if THEME == \"light\" else \"#3A3A36\"\nGRID_MINOR = \"#E8E7E0\" if THEME == \"light\" else \"#2E2E2B\"\n\n# Imprint palette — first series always #009E73\nIMPRINT_PALETTE = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\", \"#99B314\"]\nBRAND = IMPRINT_PALETTE[0]  # #009E73 — transfer function line\nMARGIN_RED = IMPRINT_PALETTE[4]  # #AE3030 — gain margin (instability distance)\nMARGIN_BLUE = IMPRINT_PALETTE[2]  # #4467A3 — phase margin\n\n# Data — third-order open-loop TF: G(s) = 5 / [(s+1)(0.5s+1)(0.2s+1)]\n# Poles at s=-1, -2, -5; stable system with clear gain and phase margins\nfrequency_hz = np.logspace(-1.5, 1.5, 600)\nomega = 2 * np.pi * frequency_hz\njw = 1j * omega\nG = 5.0 / ((jw + 1) * (0.5 * jw + 1) * (0.2 * jw + 1))\n\nmagnitude_db = 20 * np.log10(np.abs(G))\nphase_deg = np.degrees(np.unwrap(np.angle(G)))\n\n# Clip display range so both panels are visually balanced (no empty white space)\nmag_display = np.clip(magnitude_db, -65.0, 25.0)\nphase_display = np.clip(phase_deg, -290.0, 10.0)\n\n# Gain crossover: where magnitude crosses 0 dB\ngc_idx = np.argmin(np.abs(magnitude_db))\ngc_freq = frequency_hz[gc_idx]\nphase_at_gc = phase_deg[gc_idx]\nphase_margin = 180 + phase_at_gc\n\n# Phase crossover: where phase crosses -180 degrees\npc_idx = np.argmin(np.abs(phase_deg + 180))\npc_freq = frequency_hz[pc_idx]\nmag_at_pc = magnitude_db[pc_idx]\ngain_margin = -mag_at_pc\n\n# Panel ordering\npanels = [\"Magnitude (dB)\", \"Phase (degrees)\"]\npanel_cat = pd.CategoricalDtype(categories=panels, ordered=True)\n\n# Long-format DataFrame for faceted plot\ndf = pd.concat(\n    [\n        pd.DataFrame({\"freq\": frequency_hz, \"value\": mag_display, \"panel\": \"Magnitude (dB)\"}),\n        pd.DataFrame({\"freq\": frequency_hz, \"value\": phase_display, \"panel\": \"Phase (degrees)\"}),\n    ],\n    ignore_index=True,\n)\ndf[\"panel\"] = df[\"panel\"].astype(panel_cat)\n\n# Reference lines: 0 dB in magnitude panel, -180° in phase panel\nref_lines = pd.DataFrame({\"panel\": pd.Categorical(panels, dtype=panel_cat), \"yintercept\": [0.0, -180.0]})\n\n# Margin annotation segments (clipped to display range)\ngm_seg = pd.DataFrame(\n    {\n        \"x\": [pc_freq],\n        \"ymin\": [max(mag_at_pc, -65.0)],\n        \"ymax\": [0.0],\n        \"panel\": pd.Categorical([\"Magnitude (dB)\"], dtype=panel_cat),\n    }\n)\npm_seg = pd.DataFrame(\n    {\n        \"x\": [gc_freq],\n        \"ymin\": [-180.0],\n        \"ymax\": [min(phase_at_gc, 0.0)],\n        \"panel\": pd.Categorical([\"Phase (degrees)\"], dtype=panel_cat),\n    }\n)\n\n# Crossover markers\nmarkers = pd.DataFrame(\n    {\n        \"freq\": [gc_freq, gc_freq, pc_freq, pc_freq],\n        \"value\": [0.0, phase_at_gc, mag_at_pc, -180.0],\n        \"panel\": pd.Categorical(\n            [\"Magnitude (dB)\", \"Phase (degrees)\", \"Magnitude (dB)\", \"Phase (degrees)\"], dtype=panel_cat\n        ),\n        \"mtype\": [\"gc\", \"gc\", \"pc\", \"pc\"],\n    }\n)\n\n# Annotation labels (GM/PM values placed to the right of margin segments)\ngm_label = pd.DataFrame(\n    {\n        \"freq\": [pc_freq * 1.8],\n        \"value\": [mag_at_pc / 2],\n        \"label\": [f\"GM = {gain_margin:.1f} dB\"],\n        \"panel\": pd.Categorical([\"Magnitude (dB)\"], dtype=panel_cat),\n    }\n)\npm_label = pd.DataFrame(\n    {\n        \"freq\": [gc_freq * 1.8],\n        \"value\": [(phase_at_gc - 180) / 2],\n        \"label\": [f\"PM = {phase_margin:.0f}°\"],\n        \"panel\": pd.Categorical([\"Phase (degrees)\"], dtype=panel_cat),\n    }\n)\n\n# Vertical crossover guides in both panels\nguides = pd.DataFrame(\n    {\n        \"xintercept\": [gc_freq, gc_freq, pc_freq, pc_freq],\n        \"panel\": pd.Categorical(\n            [\"Magnitude (dB)\", \"Phase (degrees)\", \"Magnitude (dB)\", \"Phase (degrees)\"], dtype=panel_cat\n        ),\n    }\n)\n\n# Title — 43 chars, well under the 67-char baseline, no scaling needed\ntitle = \"bode-basic · python · plotnine · anyplot.ai\"\n\n# Plot — landscape format for optimal log-frequency axis display\nplot = (\n    ggplot(df, aes(x=\"freq\", y=\"value\"))\n    + geom_line(size=1.5, color=BRAND, alpha=0.92)\n    + geom_hline(ref_lines, aes(yintercept=\"yintercept\"), linetype=\"dashed\", color=INK_SOFT, size=0.6, alpha=0.65)\n    + geom_vline(guides, aes(xintercept=\"xintercept\"), linetype=\"dotted\", color=INK_MUTED, size=0.45, alpha=0.55)\n    + geom_segment(gm_seg, aes(x=\"x\", xend=\"x\", y=\"ymin\", yend=\"ymax\"), color=MARGIN_RED, size=4.0, alpha=0.85)\n    + geom_segment(pm_seg, aes(x=\"x\", xend=\"x\", y=\"ymin\", yend=\"ymax\"), color=MARGIN_BLUE, size=4.0, alpha=0.85)\n    + geom_point(\n        markers[markers[\"mtype\"] == \"pc\"],\n        aes(x=\"freq\", y=\"value\"),\n        color=MARGIN_RED,\n        fill=MARGIN_RED,\n        size=5,\n        shape=\"s\",\n        stroke=2.0,\n    )\n    + geom_point(\n        markers[markers[\"mtype\"] == \"gc\"],\n        aes(x=\"freq\", y=\"value\"),\n        color=MARGIN_BLUE,\n        fill=MARGIN_BLUE,\n        size=5,\n        shape=\"o\",\n        stroke=2.0,\n    )\n    + geom_text(\n        gm_label, aes(x=\"freq\", y=\"value\", label=\"label\"), color=MARGIN_RED, size=4, fontweight=\"bold\", ha=\"left\"\n    )\n    + geom_text(\n        pm_label, aes(x=\"freq\", y=\"value\", label=\"label\"), color=MARGIN_BLUE, size=4, fontweight=\"bold\", ha=\"left\"\n    )\n    + facet_wrap(\"~panel\", ncol=1, scales=\"free_y\")\n    + scale_x_log10(\n        breaks=[0.1, 1, 10], labels=[\"0.1\", \"1\", \"10\"], minor_breaks=[0.03, 0.05, 0.2, 0.3, 0.5, 2, 3, 5, 20, 30]\n    )\n    + scale_y_continuous(labels=lambda lst: [f\"{v:.0f}\" for v in lst])\n    + labs(x=\"Frequency (Hz)\", y=\"\", title=title)\n    + theme_minimal()\n    + theme(\n        figure_size=(8, 4.5),\n        text=element_text(size=7, color=INK_SOFT),\n        axis_title=element_text(size=10, color=INK),\n        axis_text=element_text(size=8, color=INK_SOFT),\n        axis_ticks=element_line(color=INK_MUTED, size=0.3),\n        plot_title=element_text(size=12, color=INK),\n        strip_text=element_text(size=9, color=INK),\n        strip_background=element_rect(fill=ELEVATED_BG, color=INK_SOFT),\n        panel_grid_major=element_line(color=GRID_MAJOR, size=0.25),\n        panel_grid_minor=element_line(color=GRID_MINOR, size=0.12),\n        panel_border=element_rect(color=INK_SOFT, fill=None),\n        panel_spacing_y=0.05,\n        plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),\n        panel_background=element_rect(fill=PAGE_BG, color=\"none\"),\n        legend_background=element_rect(fill=ELEVATED_BG, color=INK_SOFT),\n        legend_text=element_text(color=INK_SOFT),\n    )\n)\n\n# Save\nplot.save(f\"plot-{THEME}.png\", dpi=400, width=8, height=4.5, units=\"in\", verbose=False)\n"}