{"spec_id":"bode-basic","library":"seaborn","language":"python","code":"\"\"\" anyplot.ai\nbode-basic: Bode Plot for Frequency Response\nLibrary: seaborn 0.13.2 | Python 3.13.14\nQuality: 90/100 | Updated: 2026-06-17\n\"\"\"\n\nimport os\n\nimport matplotlib.pyplot as plt\nimport numpy as np\nimport pandas as pd\nimport seaborn as sns\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\"\n\n# Imprint palette — canonical order, first series always #009E73\nIMPRINT_PALETTE = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\", \"#99B314\"]\nLINE_COLOR = IMPRINT_PALETTE[0]  # brand green — frequency response line\nGM_COLOR = IMPRINT_PALETTE[2]  # blue — gain margin\nPM_COLOR = IMPRINT_PALETTE[3]  # ochre — phase margin\n\nsns.set_theme(\n    context=\"notebook\",\n    style=\"ticks\",\n    rc={\n        \"figure.facecolor\": PAGE_BG,\n        \"axes.facecolor\": PAGE_BG,\n        \"axes.edgecolor\": INK_SOFT,\n        \"axes.labelcolor\": INK,\n        \"text.color\": INK,\n        \"xtick.color\": INK_SOFT,\n        \"ytick.color\": INK_SOFT,\n        \"grid.color\": INK,\n        \"grid.alpha\": 0.15,\n        \"legend.facecolor\": ELEVATED_BG,\n        \"legend.edgecolor\": INK_SOFT,\n    },\n)\n\n# Data — Type-1 open-loop transfer function: H(s) = K·p1·p2 / (s·(s+p1)·(s+p2))\nK = 40.0\np1 = 2 * np.pi * 5  # pole at 5 Hz\np2 = 2 * np.pi * 50  # pole at 50 Hz\n\nfrequency_hz = np.logspace(-1, 3, 600)\nomega = 2 * np.pi * frequency_hz\ns = 1j * omega\n\nH = K * p1 * p2 / (s * (s + p1) * (s + p2))\nmagnitude_db = 20 * np.log10(np.abs(H))\nphase_deg = np.degrees(np.unwrap(np.angle(H)))\n\n# Gain crossover — magnitude crosses 0 dB (interpolated)\nsign_changes = np.diff(np.sign(magnitude_db))\ngc_indices = np.where(sign_changes != 0)[0]\nidx = gc_indices[0]\nfrac = -magnitude_db[idx] / (magnitude_db[idx + 1] - magnitude_db[idx])\ngain_cross_freq = frequency_hz[idx] * (frequency_hz[idx + 1] / frequency_hz[idx]) ** frac\nphase_at_gain_cross = phase_deg[idx] + frac * (phase_deg[idx + 1] - phase_deg[idx])\nphase_margin = 180 + phase_at_gain_cross\n\n# Phase crossover — phase crosses −180° (interpolated)\nphase_shift = phase_deg + 180\nsign_changes_ph = np.diff(np.sign(phase_shift))\npc_indices = np.where(sign_changes_ph != 0)[0]\nidx = pc_indices[0]\nfrac = -phase_shift[idx] / (phase_shift[idx + 1] - phase_shift[idx])\nphase_cross_freq = frequency_hz[idx] * (frequency_hz[idx + 1] / frequency_hz[idx]) ** frac\nmag_at_phase_cross = magnitude_db[idx] + frac * (magnitude_db[idx + 1] - magnitude_db[idx])\ngain_margin = -mag_at_phase_cross\n\n# Long-form DataFrame for seaborn faceting\ndf_mag = pd.DataFrame({\"Frequency (Hz)\": frequency_hz, \"value\": magnitude_db, \"panel\": \"Magnitude (dB)\"})\ndf_phase = pd.DataFrame({\"Frequency (Hz)\": frequency_hz, \"value\": phase_deg, \"panel\": \"Phase (°)\"})\ndf = pd.concat([df_mag, df_phase], ignore_index=True)\n\n# FacetGrid — idiomatic seaborn dual-panel layout\ng = sns.FacetGrid(\n    df, row=\"panel\", height=2.25, aspect=8 / 2.25, sharex=True, sharey=False, gridspec_kws={\"hspace\": 0.25}\n)\ng.map_dataframe(sns.lineplot, x=\"Frequency (Hz)\", y=\"value\", color=LINE_COLOR, linewidth=2.5)\ng.figure.set_dpi(400)\ng.figure.set_facecolor(PAGE_BG)\n\nax_mag = g.axes[0, 0]\nax_phase = g.axes[1, 0]\n\ng.set_titles(\"\")\ng.set_axis_labels(\"\", \"\")\n\nfor ax in g.axes.flat:\n    ax.set_xscale(\"log\")\n    ax.set_facecolor(PAGE_BG)\n\n# Reference lines at 0 dB and −180°\nax_mag.axhline(0, color=INK_SOFT, linewidth=1.0, linestyle=\"--\", alpha=0.6)\nax_phase.axhline(-180, color=INK_SOFT, linewidth=1.0, linestyle=\"--\", alpha=0.6)\n\n# Crossover vertical markers tying both panels together\nfor ax in [ax_mag, ax_phase]:\n    ax.axvline(gain_cross_freq, color=PM_COLOR, linewidth=0.9, linestyle=\":\", alpha=0.45)\n    ax.axvline(phase_cross_freq, color=GM_COLOR, linewidth=0.9, linestyle=\":\", alpha=0.45)\n\n# Gain margin — markers and annotation in the magnitude panel\nax_mag.vlines(phase_cross_freq, mag_at_phase_cross, 0, color=GM_COLOR, linewidth=2.0, alpha=0.85)\nax_mag.plot(phase_cross_freq, mag_at_phase_cross, \"o\", color=GM_COLOR, markersize=7, zorder=5)\nax_mag.plot(phase_cross_freq, 0, \"o\", color=GM_COLOR, markersize=7, zorder=5)\ngm_mid_y = (mag_at_phase_cross + 0) / 2\nax_mag.annotate(\n    f\"GM\\n{gain_margin:.1f} dB\",\n    xy=(phase_cross_freq, gm_mid_y),\n    xytext=(phase_cross_freq * 6, gm_mid_y + 14),\n    fontsize=9,\n    fontweight=\"bold\",\n    multialignment=\"center\",\n    color=GM_COLOR,\n    arrowprops={\"arrowstyle\": \"->\", \"color\": GM_COLOR, \"lw\": 1.4},\n    bbox={\"boxstyle\": \"round,pad=0.4\", \"fc\": ELEVATED_BG, \"ec\": GM_COLOR, \"alpha\": 0.92, \"lw\": 1.5},\n)\n\n# Phase margin — markers and annotation in the phase panel\nax_phase.vlines(gain_cross_freq, -180, phase_at_gain_cross, color=PM_COLOR, linewidth=2.0, alpha=0.85)\nax_phase.plot(gain_cross_freq, phase_at_gain_cross, \"o\", color=PM_COLOR, markersize=7, zorder=5)\nax_phase.plot(gain_cross_freq, -180, \"o\", color=PM_COLOR, markersize=7, zorder=5)\npm_mid_y = (phase_at_gain_cross + (-180)) / 2\nax_phase.annotate(\n    f\"PM\\n{phase_margin:.1f}°\",\n    xy=(gain_cross_freq, pm_mid_y),\n    xytext=(gain_cross_freq * 5, pm_mid_y + 20),\n    fontsize=9,\n    fontweight=\"bold\",\n    multialignment=\"center\",\n    color=PM_COLOR,\n    arrowprops={\"arrowstyle\": \"->\", \"color\": PM_COLOR, \"lw\": 1.4},\n    bbox={\"boxstyle\": \"round,pad=0.4\", \"fc\": ELEVATED_BG, \"ec\": PM_COLOR, \"alpha\": 0.92, \"lw\": 1.5},\n)\n\n# Title — length check for fontsize scaling (baseline 67 chars)\ntitle = \"bode-basic · python · seaborn · anyplot.ai\"\ntitle_fs = round(12 * (67 / len(title))) if len(title) > 67 else 12\n\nax_mag.set_title(title, fontsize=title_fs, fontweight=\"medium\", pad=10, color=INK)\nax_mag.set_ylabel(\"Magnitude (dB)\", fontsize=10, color=INK)\nax_mag.tick_params(axis=\"both\", labelsize=8)\n\nax_phase.set_xlabel(\"Frequency (Hz)\", fontsize=10, color=INK)\nax_phase.set_ylabel(\"Phase (°)\", fontsize=10, color=INK)\nax_phase.tick_params(axis=\"both\", labelsize=8)\nax_phase.set_yticks([-90, -135, -180, -225, -270])\n\n# Spine removal — offset=8 adds the characteristic seaborn axis-spine gap\nsns.despine(ax=ax_mag, offset=8)\nsns.despine(ax=ax_phase, offset=8)\nfor ax in [ax_mag, ax_phase]:\n    for spine in ax.spines.values():\n        spine.set_color(INK_SOFT)\n\n# Grid — subtle, both axes for frequency response charts\nfor ax in [ax_mag, ax_phase]:\n    ax.yaxis.grid(True, alpha=0.15, linewidth=0.8, color=INK)\n    ax.xaxis.grid(True, alpha=0.12, linewidth=0.5, color=INK)\n\n# Canvas — exactly 3200×1800 px (8 in × 4.5 in @ dpi=400)\ng.figure.set_size_inches(8, 4.5)\n\n# Save — no bbox_inches='tight' to preserve exact canvas dimensions\nplt.savefig(f\"plot-{THEME}.png\", dpi=400, facecolor=PAGE_BG)\nplt.close()\n"}