{"spec_id":"bode-basic","library":"altair","language":"python","code":"\"\"\" anyplot.ai\nbode-basic: Bode Plot for Frequency Response\nLibrary: altair 6.2.1 | Python 3.13.14\nQuality: 91/100 | Updated: 2026-06-17\n\"\"\"\n\nimport os\nimport sys\n\n\n# Prevent self-import: 'python altair.py' adds this file's directory to\n# sys.path[0], which would shadow the installed altair package.\n_self_dir = os.path.dirname(os.path.abspath(__file__))\nsys.path = [p for p in sys.path if os.path.realpath(p or os.getcwd()) != os.path.realpath(_self_dir)]\ndel _self_dir\n\nimport altair as alt\nimport numpy as np\nimport pandas as pd\nfrom PIL import Image\n\n\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\n\n# Theme-adaptive chrome tokens (Imprint)\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 palette — first two series + annotation colors\nCLR_MAG = \"#009E73\"  # Imprint[0] brand green — magnitude (first series)\nCLR_PHASE = \"#C475FD\"  # Imprint[1] lavender — phase (second series)\nCLR_GM = \"#4467A3\"  # Imprint[2] blue — gain margin annotation\nCLR_PM = \"#BD8233\"  # Imprint[3] ochre — phase margin annotation\n\n# Data — third-order open-loop transfer function:\n# G(s) = 10 / (s+1)(s/10+1)(s/50+1); poles at -1, -10, -50; DC gain = 20 dB\nomega = np.logspace(-2, 3, 600)\ns = 1j * omega\n\nK = 10.0\nG = K / ((s / 1 + 1) * (s / 10 + 1) * (s / 50 + 1))\n\nmagnitude_db = 20 * np.log10(np.abs(G))\nphase_deg = np.degrees(np.unwrap(np.angle(G)))\n\ndf = pd.DataFrame({\"frequency\": omega, \"magnitude_db\": magnitude_db, \"phase_deg\": phase_deg})\n\n# Gain crossover frequency (|G| = 0 dB)\nsign_changes_mag = np.where(np.diff(np.sign(magnitude_db)))[0]\ngain_cross_idx = sign_changes_mag[0] if len(sign_changes_mag) > 0 else np.argmin(np.abs(magnitude_db))\ngain_cross_freq = omega[gain_cross_idx]\ngain_cross_phase = phase_deg[gain_cross_idx]\nphase_margin = 180 + gain_cross_phase\n\n# Phase crossover frequency (phase = -180°)\nsign_changes_phase = np.where(np.diff(np.sign(phase_deg - (-180))))[0]\nphase_cross_idx = sign_changes_phase[0] if len(sign_changes_phase) > 0 else np.argmin(np.abs(phase_deg + 180))\nphase_cross_freq = omega[phase_cross_idx]\nphase_cross_mag = magnitude_db[phase_cross_idx]\ngain_margin = -phase_cross_mag\n\n# Reference lines\nref_0db = pd.DataFrame({\"x\": [omega.min(), omega.max()], \"y\": [0, 0]})\nref_180 = pd.DataFrame({\"x\": [omega.min(), omega.max()], \"y\": [-180, -180]})\n\n# Gain margin annotation (on magnitude panel)\ngm_line = pd.DataFrame({\"frequency\": [phase_cross_freq, phase_cross_freq], \"magnitude_db\": [phase_cross_mag, 0]})\ngm_label = pd.DataFrame(\n    {\n        \"frequency\": [phase_cross_freq],\n        \"magnitude_db\": [phase_cross_mag / 2 + 2],\n        \"label\": [f\"GM = {gain_margin:.1f} dB\"],\n    }\n)\n\n# Phase margin annotation (on phase panel)\npm_line = pd.DataFrame({\"frequency\": [gain_cross_freq, gain_cross_freq], \"phase_deg\": [gain_cross_phase, -180]})\npm_label = pd.DataFrame(\n    {\n        \"frequency\": [gain_cross_freq],\n        \"phase_deg\": [(gain_cross_phase - 180) / 2 + 8],\n        \"label\": [f\"PM = {phase_margin:.1f}°\"],\n    }\n)\n\n# Crossover point markers\ngc_mag_pt = pd.DataFrame({\"frequency\": [gain_cross_freq], \"magnitude_db\": [0.0]})\npc_mag_pt = pd.DataFrame({\"frequency\": [phase_cross_freq], \"magnitude_db\": [phase_cross_mag]})\ngc_phase_pt = pd.DataFrame({\"frequency\": [gain_cross_freq], \"phase_deg\": [gain_cross_phase]})\npc_phase_pt = pd.DataFrame({\"frequency\": [phase_cross_freq], \"phase_deg\": [-180.0]})\n\n# Shared axis scales\nfreq_scale = alt.Scale(type=\"log\", domain=[0.01, 1000], nice=False)\ny_mag_scale = alt.Scale(domain=[-60, 30])\ny_phase_scale = alt.Scale(domain=[-280, 10])\n\n# Interactive crosshair — nearest point on hover\nnearest = alt.selection_point(nearest=True, on=\"pointerover\", fields=[\"frequency\"], empty=False)\n\n# ── Magnitude panel ──────────────────────────────────────────────────────────\n\nmag_line = (\n    alt.Chart(df)\n    .mark_line(strokeWidth=2.5, color=CLR_MAG, interpolate=\"monotone\", clip=True)\n    .encode(\n        x=alt.X(\"frequency:Q\", scale=freq_scale, axis=alt.Axis(labels=False, title=\"\", ticks=False)),\n        y=alt.Y(\"magnitude_db:Q\", title=\"Magnitude (dB)\", scale=y_mag_scale),\n        tooltip=[\n            alt.Tooltip(\"frequency:Q\", title=\"ω (rad/s)\", format=\".2f\"),\n            alt.Tooltip(\"magnitude_db:Q\", title=\"Magnitude (dB)\", format=\".1f\"),\n        ],\n    )\n)\n\nmag_selectable = (\n    alt.Chart(df)\n    .mark_point(opacity=0)\n    .encode(x=alt.X(\"frequency:Q\", scale=freq_scale), y=alt.Y(\"magnitude_db:Q\", scale=y_mag_scale))\n    .add_params(nearest)\n)\n\nmag_crosshair = (\n    alt.Chart(df)\n    .mark_rule(color=INK_MUTED, strokeWidth=0.8, strokeDash=[3, 3])\n    .encode(x=alt.X(\"frequency:Q\", scale=freq_scale))\n    .transform_filter(nearest)\n)\n\nmag_ref = (\n    alt.Chart(ref_0db)\n    .mark_line(strokeWidth=1.5, strokeDash=[8, 6], color=INK_SOFT, opacity=0.7, clip=True)\n    .encode(x=alt.X(\"x:Q\", scale=freq_scale), y=alt.Y(\"y:Q\", scale=y_mag_scale))\n)\n\nmag_gm_line = (\n    alt.Chart(gm_line)\n    .mark_line(strokeWidth=2.0, color=CLR_GM, strokeDash=[5, 3], clip=True)\n    .encode(x=alt.X(\"frequency:Q\", scale=freq_scale), y=alt.Y(\"magnitude_db:Q\", scale=y_mag_scale))\n)\n\nmag_gm_label = (\n    alt.Chart(gm_label)\n    .mark_text(fontSize=10, fontWeight=\"bold\", color=CLR_GM, align=\"left\", dx=10, font=\"monospace\")\n    .encode(x=alt.X(\"frequency:Q\", scale=freq_scale), y=alt.Y(\"magnitude_db:Q\", scale=y_mag_scale), text=\"label:N\")\n)\n\nmag_gc_point = (\n    alt.Chart(gc_mag_pt)\n    .mark_point(size=100, shape=\"circle\", filled=True, color=CLR_MAG, stroke=INK, strokeWidth=1.5, clip=True)\n    .encode(x=alt.X(\"frequency:Q\", scale=freq_scale), y=alt.Y(\"magnitude_db:Q\", scale=y_mag_scale))\n)\n\nmag_pc_point = (\n    alt.Chart(pc_mag_pt)\n    .mark_point(size=100, shape=\"diamond\", filled=True, color=CLR_GM, stroke=INK, strokeWidth=1.5, clip=True)\n    .encode(x=alt.X(\"frequency:Q\", scale=freq_scale), y=alt.Y(\"magnitude_db:Q\", scale=y_mag_scale))\n)\n\nmagnitude_chart = (\n    mag_line + mag_ref + mag_gm_line + mag_gm_label + mag_gc_point + mag_pc_point + mag_selectable + mag_crosshair\n).properties(\n    width=600,\n    height=100,\n    title=alt.Title(\n        \"bode-basic · python · altair · anyplot.ai\",\n        fontSize=16,\n        fontWeight=\"bold\",\n        color=INK,\n        subtitle=\"G(s) = 10 / (s+1)(s/10+1)(s/50+1)  ·  Open-Loop Frequency Response\",\n        subtitleFontSize=12,\n        subtitleColor=INK_SOFT,\n        subtitlePadding=6,\n        anchor=\"start\",\n        offset=8,\n    ),\n)\n\n# ── Phase panel ──────────────────────────────────────────────────────────────\n\nphase_line = (\n    alt.Chart(df)\n    .mark_line(strokeWidth=2.5, color=CLR_PHASE, interpolate=\"monotone\", clip=True)\n    .encode(\n        x=alt.X(\"frequency:Q\", scale=freq_scale, title=\"Frequency (rad/s)\"),\n        y=alt.Y(\"phase_deg:Q\", title=\"Phase (degrees)\", scale=y_phase_scale),\n        tooltip=[\n            alt.Tooltip(\"frequency:Q\", title=\"ω (rad/s)\", format=\".2f\"),\n            alt.Tooltip(\"phase_deg:Q\", title=\"Phase (°)\", format=\".1f\"),\n        ],\n    )\n)\n\nphase_ref = (\n    alt.Chart(ref_180)\n    .mark_line(strokeWidth=1.5, strokeDash=[8, 6], color=INK_SOFT, opacity=0.7, clip=True)\n    .encode(x=alt.X(\"x:Q\", scale=freq_scale), y=alt.Y(\"y:Q\", scale=y_phase_scale))\n)\n\nphase_pm_line = (\n    alt.Chart(pm_line)\n    .mark_line(strokeWidth=2.0, color=CLR_PM, strokeDash=[5, 3], clip=True)\n    .encode(x=alt.X(\"frequency:Q\", scale=freq_scale), y=alt.Y(\"phase_deg:Q\", scale=y_phase_scale))\n)\n\nphase_pm_label = (\n    alt.Chart(pm_label)\n    .mark_text(fontSize=10, fontWeight=\"bold\", color=CLR_PM, align=\"left\", dx=10, font=\"monospace\")\n    .encode(x=alt.X(\"frequency:Q\", scale=freq_scale), y=alt.Y(\"phase_deg:Q\", scale=y_phase_scale), text=\"label:N\")\n)\n\nphase_gc_point = (\n    alt.Chart(gc_phase_pt)\n    .mark_point(size=100, shape=\"circle\", filled=True, color=CLR_PHASE, stroke=INK, strokeWidth=1.5, clip=True)\n    .encode(x=alt.X(\"frequency:Q\", scale=freq_scale), y=alt.Y(\"phase_deg:Q\", scale=y_phase_scale))\n)\n\nphase_pc_point = (\n    alt.Chart(pc_phase_pt)\n    .mark_point(size=100, shape=\"diamond\", filled=True, color=CLR_PM, stroke=INK, strokeWidth=1.5, clip=True)\n    .encode(x=alt.X(\"frequency:Q\", scale=freq_scale), y=alt.Y(\"phase_deg:Q\", scale=y_phase_scale))\n)\n\nphase_chart = (phase_line + phase_ref + phase_pm_line + phase_pm_label + phase_gc_point + phase_pc_point).properties(\n    width=600, height=100\n)\n\n# ── Compose & configure ──────────────────────────────────────────────────────\n\nchart = (\n    alt.vconcat(magnitude_chart, phase_chart, spacing=8)\n    .configure_view(strokeWidth=0, fill=ELEVATED_BG)\n    .configure_axis(\n        domain=False,\n        tickColor=INK_SOFT,\n        gridColor=INK,\n        gridOpacity=0.10,\n        gridWidth=0.5,\n        labelColor=INK_SOFT,\n        titleColor=INK,\n        labelFontSize=10,\n        titleFontSize=12,\n        labelPadding=4,\n        tickSize=5,\n    )\n    .configure_title(color=INK, subtitleColor=INK_SOFT)\n    .configure_concat(spacing=8)\n    .configure(background=PAGE_BG, padding={\"left\": 20, \"right\": 30, \"top\": 10, \"bottom\": 10})\n)\n\n# ── Save PNG (canonical 3200×1800 landscape) ─────────────────────────────────\n\nchart.save(f\"plot-{THEME}.png\", scale_factor=4.0)\n\nTW, TH = 3200, 1800\n_img = Image.open(f\"plot-{THEME}.png\").convert(\"RGB\")\n_w, _h = _img.size\nif _w > TW or _h > TH:\n    raise SystemExit(\n        f\"altair vl-convert produced {_w}×{_h}, exceeds target {TW}×{TH}. \"\n        f\"Shrink chart .properties(width=, height=) values and re-render.\"\n    )\nif _w < TW or _h < TH:\n    _canvas = Image.new(\"RGB\", (TW, TH), PAGE_BG)\n    _canvas.paste(_img, ((TW - _w) // 2, (TH - _h) // 2))\n    _canvas.save(f\"plot-{THEME}.png\")\n\n# ── Save interactive HTML ────────────────────────────────────────────────────\n\nchart.save(f\"plot-{THEME}.html\")\n"}