{"spec_id":"bode-basic","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\nbode-basic: Bode Plot for Frequency Response\nLibrary: pygal 3.1.0 | Python 3.13.14\nQuality: 88/100 | Updated: 2026-06-17\n\"\"\"\n\nimport io\nimport os\n\nimport cairosvg\nimport numpy as np\nimport pygal\nfrom PIL import Image, ImageDraw, ImageFont\nfrom pygal.style import Style\n\n\n# Theme tokens — Imprint palette (theme-independent) + adaptive chrome\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\"\n\nBRAND = \"#009E73\"  # Imprint position 1 — ALWAYS first series\nIMPRINT_RED = \"#AE3030\"  # Imprint position 5 — stability-critical thresholds\nIMPRINT_BLUE = \"#4467A3\"  # Imprint position 3 — margin indicators\n\n# Data — Third-order system: G(s) = K·ωn² / ((s+p)·(s²+2ζωn·s+ωn²))\n# Natural frequency 5 Hz, damping 0.2 (clear resonance), extra pole at 50 Hz\nfrequency_hz = np.logspace(-1, 3, 500)\nomega = 2 * np.pi * frequency_hz\nwn = 2 * np.pi * 5.0\nzeta = 0.2\np = 2 * np.pi * 50.0\ns = 1j * omega\nG = (wn**2 * p) / ((s + p) * (s**2 + 2 * zeta * wn * s + wn**2))\n\nmagnitude_db = 20 * np.log10(np.abs(G))\nphase_deg = np.degrees(np.unwrap(np.angle(G)))\nlog_freq = np.log10(frequency_hz)\n\n# Gain crossover: where magnitude crosses 0 dB (after resonance peak)\npeak_idx = np.argmax(magnitude_db)\npeak_db = magnitude_db[peak_idx]\npeak_freq = frequency_hz[peak_idx]\nzero_crossings = np.where(np.diff(np.sign(magnitude_db[peak_idx:])))[0]\nif len(zero_crossings) > 0:\n    gc_idx = peak_idx + zero_crossings[0]\n    gc_freq = frequency_hz[gc_idx]\n    gc_phase = phase_deg[gc_idx]\n    phase_margin = 180 + gc_phase\nelse:\n    gc_freq = None\n    phase_margin = None\n\n# Phase crossover: where phase crosses −180°\npc_indices = np.where(np.diff(np.sign(phase_deg + 180)))[0]\ngain_margin = -magnitude_db[pc_indices[0]] if len(pc_indices) > 0 else None\npc_freq = frequency_hz[pc_indices[0]] if len(pc_indices) > 0 else None\n\n# X-axis tick positions — major decades + minor half-decades\nx_ticks_major = [0.1, 1, 10, 100, 1000]\nx_ticks_minor = [0.5, 5, 50, 500]\nx_tick_major_log = [np.log10(v) for v in x_ticks_major]\nx_tick_all_log = sorted([np.log10(v) for v in x_ticks_major + x_ticks_minor])\n\n# Subsample for performance\nstep = 2\nmag_pts = [(float(log_freq[i]), float(magnitude_db[i])) for i in range(0, len(log_freq), step)]\nphase_pts = [(float(log_freq[i]), float(phase_deg[i])) for i in range(0, len(log_freq), step)]\n\nx_lo, x_hi = float(log_freq[0]), float(log_freq[-1])\nref_0db = [(x_lo, 0.0), (x_hi, 0.0)]\nref_neg180 = [(x_lo, -180.0), (x_hi, -180.0)]\n\n# Margin visual lines — vertical segments between signal and reference\nphase_margin_line = None\nif gc_freq is not None and phase_margin is not None:\n    gc_log = np.log10(gc_freq)\n    phase_margin_line = [(float(gc_log), float(gc_phase)), (float(gc_log), -180.0)]\n\ngain_margin_line = None\nif pc_freq is not None and gain_margin is not None:\n    pc_log = np.log10(pc_freq)\n    pc_mag = magnitude_db[pc_indices[0]]\n    gain_margin_line = [(float(pc_log), float(pc_mag)), (float(pc_log), 0.0)]\n\n# Shared pygal style — Imprint palette + canonical sizing for 3200×1800 canvas\n_style = Style(\n    background=PAGE_BG,\n    plot_background=PAGE_BG,\n    foreground=INK,\n    foreground_strong=INK,\n    foreground_subtle=INK_MUTED,\n    colors=(BRAND, IMPRINT_RED, IMPRINT_BLUE, INK_MUTED),\n    title_font_size=66,\n    label_font_size=56,\n    major_label_font_size=44,\n    legend_font_size=44,\n    value_font_size=36,\n    stroke_width=2.5,\n    font_family=\"'Helvetica Neue', 'Helvetica', 'Arial', sans-serif\",\n    title_font_family=\"'Helvetica Neue', 'Helvetica', 'Arial', sans-serif\",\n    label_font_family=\"'Helvetica Neue', 'Helvetica', 'Arial', sans-serif\",\n    value_font_family=\"'Helvetica Neue', 'Helvetica', 'Arial', sans-serif\",\n    legend_font_family=\"'Helvetica Neue', 'Helvetica', 'Arial', sans-serif\",\n    opacity=1.0,\n    opacity_hover=0.85,\n    transition=\"200ms ease-in\",\n)\n\n# Magnitude chart — top panel (3200×900)\nmag_chart = pygal.XY(\n    width=3200,\n    height=900,\n    style=_style,\n    show_legend=True,\n    legend_at_bottom=True,\n    legend_at_bottom_columns=3,\n    show_y_guides=True,\n    show_x_guides=False,\n    margin=20,\n    margin_left=140,\n    margin_right=70,\n    margin_bottom=140,\n    margin_top=40,\n    dots_size=0,\n    stroke=True,\n    truncate_label=-1,\n    print_values=False,\n    x_value_formatter=lambda x: f\"{10**x:.4g}\",\n    tooltip_fancy_mode=True,\n    tooltip_border_radius=8,\n    title=\"bode-basic · python · pygal · anyplot.ai\",\n    x_title=\"\",\n    y_title=\"Magnitude (dB)\",\n    range=(-100.0, 20.0),\n    interpolate=\"cubic\",\n    show_minor_x_labels=True,\n)\nmag_chart.x_labels = x_tick_all_log\nmag_chart.x_labels_major = x_tick_major_log\nmag_chart.add(\n    \"Magnitude\",\n    mag_pts,\n    show_dots=False,\n    formatter=lambda x, y: f\"{10**x:.2g} Hz → {y:.1f} dB\",\n    stroke_style={\"width\": 5, \"linecap\": \"round\", \"linejoin\": \"round\"},\n)\nmag_chart.add(\"0 dB ref\", ref_0db, show_dots=False, stroke_style={\"width\": 2.5, \"dasharray\": \"18,10\"})\nif gain_margin_line:\n    mag_chart.add(\n        f\"GM: {gain_margin:.1f} dB @ {pc_freq:.1f} Hz\",\n        gain_margin_line,\n        show_dots=True,\n        dots_size=8,\n        stroke_style={\"width\": 3, \"dasharray\": \"8,5\"},\n    )\n# −3 dB reference — slightly more visible than previous (width 2 vs 1.5)\nbw_3db = [(x_lo, -3.0), (x_hi, -3.0)]\nmag_chart.add(\"−3 dB\", bw_3db, show_dots=False, stroke_style={\"width\": 2, \"dasharray\": \"5,8\"})\n\n# Phase chart — bottom panel (3200×900)\nphase_chart = pygal.XY(\n    width=3200,\n    height=900,\n    style=_style,\n    show_legend=True,\n    legend_at_bottom=True,\n    legend_at_bottom_columns=3,\n    show_y_guides=True,\n    show_x_guides=False,\n    margin=20,\n    margin_left=140,\n    margin_right=70,\n    margin_bottom=140,\n    margin_top=10,\n    dots_size=0,\n    stroke=True,\n    truncate_label=-1,\n    print_values=False,\n    x_value_formatter=lambda x: f\"{10**x:.4g}\",\n    tooltip_fancy_mode=True,\n    tooltip_border_radius=8,\n    title=\"\",\n    x_title=\"Frequency (Hz)\",\n    y_title=\"Phase (°)\",\n    range=(-280.0, 10.0),\n    interpolate=\"cubic\",\n    show_minor_x_labels=True,\n)\nphase_chart.x_labels = x_tick_all_log\nphase_chart.x_labels_major = x_tick_major_log\nphase_chart.add(\n    \"Phase\",\n    phase_pts,\n    show_dots=False,\n    formatter=lambda x, y: f\"{10**x:.2g} Hz → {y:.1f}°\",\n    stroke_style={\"width\": 5, \"linecap\": \"round\", \"linejoin\": \"round\"},\n)\nphase_chart.add(\"−180° ref\", ref_neg180, show_dots=False, stroke_style={\"width\": 2.5, \"dasharray\": \"18,10\"})\nif phase_margin_line:\n    phase_chart.add(\n        f\"PM: {phase_margin:.1f}° @ {gc_freq:.1f} Hz\",\n        phase_margin_line,\n        show_dots=True,\n        dots_size=8,\n        stroke_style={\"width\": 3, \"dasharray\": \"8,5\"},\n    )\nref_neg90 = [(x_lo, -90.0), (x_hi, -90.0)]\nphase_chart.add(\"−90° ref\", ref_neg90, show_dots=False, stroke_style={\"width\": 2, \"dasharray\": \"5,8\"})\n\n# Render each panel to PNG via cairosvg — each panel 3200×900\nmag_png = cairosvg.svg2png(bytestring=mag_chart.render(), output_width=3200, output_height=900)\nphase_png = cairosvg.svg2png(bytestring=phase_chart.render(), output_width=3200, output_height=900)\n\n# Compose 3200×1800 dual-panel image (landscape canonical canvas)\nmag_img = Image.open(io.BytesIO(mag_png))\nphase_img = Image.open(io.BytesIO(phase_png))\ncombined = Image.new(\"RGB\", (3200, 1800), PAGE_BG)\ncombined.paste(mag_img, (0, 0))\ncombined.paste(phase_img, (0, 900))\n\n# Refined panel divider\ndraw = ImageDraw.Draw(combined)\ndraw.line([(140, 900), (3070, 900)], fill=INK_MUTED, width=1)\n\n# Annotation fonts\ntry:\n    font_bold = ImageFont.truetype(\"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf\", 32)\n    font_reg = ImageFont.truetype(\"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf\", 26)\nexcept OSError:\n    font_bold = ImageFont.load_default()\n    font_reg = font_bold\n\n# Magnitude annotation panel — upper right of top panel, below title area\nann_x, ann_y = 1900, 130\nann_w, ann_h = 1250, 195\ndraw.rounded_rectangle(\n    [(ann_x, ann_y), (ann_x + ann_w, ann_y + ann_h)], radius=12, fill=ELEVATED_BG, outline=INK_MUTED, width=1\n)\ndraw.text((ann_x + 20, ann_y + 14), f\"▲ Peak: {peak_db:.1f} dB @ {peak_freq:.1f} Hz\", fill=BRAND, font=font_reg)\nif gain_margin is not None and pc_freq is not None:\n    draw.text(\n        (ann_x + 20, ann_y + 60),\n        f\"◆ Gain Margin: {gain_margin:.1f} dB @ {pc_freq:.1f} Hz\",\n        fill=IMPRINT_BLUE,\n        font=font_bold,\n    )\nelse:\n    draw.text((ann_x + 20, ann_y + 60), \"◆ Gain Margin: ∞\", fill=IMPRINT_BLUE, font=font_bold)\ndraw.text((ann_x + 20, ann_y + 118), \"H(s): 3rd-order · ωₙ=5 Hz · ζ=0.2\", fill=INK_MUTED, font=font_reg)\n\n# Phase annotation panel — upper right of bottom panel\nann2_x, ann2_y = 1900, 912\nann2_w, ann2_h = 1250, 120\ndraw.rounded_rectangle(\n    [(ann2_x, ann2_y), (ann2_x + ann2_w, ann2_y + ann2_h)], radius=12, fill=ELEVATED_BG, outline=INK_MUTED, width=1\n)\nif phase_margin is not None and gc_freq is not None:\n    draw.text(\n        (ann2_x + 20, ann2_y + 14),\n        f\"◆ Phase Margin: {phase_margin:.1f}° @ {gc_freq:.1f} Hz\",\n        fill=IMPRINT_BLUE,\n        font=font_bold,\n    )\n    stability = \"Stable\" if phase_margin > 0 else \"Unstable\"\n    stability_color = BRAND if phase_margin > 0 else IMPRINT_RED\n    draw.text((ann2_x + 20, ann2_y + 68), f\"Status: {stability}\", fill=stability_color, font=font_reg)\n\ncombined.save(f\"plot-{THEME}.png\", dpi=(300, 300))\n\n# HTML export — pygal native SVG interactivity with tooltips\nmag_svg = mag_chart.render(is_unicode=True).replace('<?xml version=\"1.0\" encoding=\"utf-8\"?>', \"\")\nphase_svg = phase_chart.render(is_unicode=True).replace('<?xml version=\"1.0\" encoding=\"utf-8\"?>', \"\")\n\nhtml_content = (\n    \"<!DOCTYPE html>\\n<html>\\n<head>\\n\"\n    \"    <title>bode-basic · python · pygal · anyplot.ai</title>\\n\"\n    \"    <style>\\n\"\n    f\"        body {{ font-family: 'Helvetica Neue', sans-serif; background: {PAGE_BG};\"\n    \" margin: 0; padding: 40px 20px; }\\n\"\n    \"        .container { max-width: 1200px; margin: 0 auto; }\\n\"\n    \"        .chart { width: 100%; margin: 8px 0; }\\n\"\n    f\"        .divider {{ border: none; border-top: 1px solid {INK_MUTED}; margin: 0; }}\\n\"\n    \"        .info { text-align: center; font-size: 13px; margin-top: 10px; \"\n    f\"color: {INK_MUTED}; }}\\n\"\n    \"    </style>\\n</head>\\n<body>\\n\"\n    \"    <div class='container'>\\n\"\n    f\"        <div class='chart'>{mag_svg}</div>\\n\"\n    \"        <hr class='divider'/>\\n\"\n    f\"        <div class='chart'>{phase_svg}</div>\\n\"\n    \"        <p class='info'>Hover over data points for frequency/value details</p>\\n\"\n    \"    </div>\\n</body>\\n</html>\"\n)\n\nwith open(f\"plot-{THEME}.html\", \"w\") as f:\n    f.write(html_content)\n"}