Bode Plot for Frequency Response — Plotly

A Bode plot displays a system's frequency response as two vertically aligned panels: magnitude (in decibels) on top and phase (in degrees) on the bottom, both plotted against frequency on a shared logarithmic scale. This visualization is fundamental in control systems engineering and signal processing for analyzing how a system responds to sinusoidal inputs across a range of frequencies, revealing stability characteristics, bandwidth, and resonance behavior.

Bode Plot for Frequency Response rendered with Plotly

Python source (Plotly)

""" anyplot.ai
bode-basic: Bode Plot for Frequency Response
Library: plotly 6.8.0 | Python 3.13.14
Quality: 93/100 | Updated: 2026-06-17
"""

import os
import sys


# This file is named plotly.py — remove its directory from sys.path so
# 'import plotly' resolves to the installed package, not this script.
_here = os.path.dirname(os.path.abspath(__file__))
sys.path = [p for p in sys.path if os.path.abspath(p or os.getcwd()) != _here]

import numpy as np
import plotly.graph_objects as go
from plotly.subplots import make_subplots


# Theme — Imprint palette chrome tokens
THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
GRID = "rgba(26,26,23,0.15)" if THEME == "light" else "rgba(240,239,232,0.15)"
GRID_MINOR = "rgba(26,26,23,0.07)" if THEME == "light" else "rgba(240,239,232,0.07)"

# Imprint palette colors — positions 1→3 in canonical order
CLR_MAIN = "#009E73"  # Imprint green (position 1) — main Bode curve
CLR_GAIN = "#C475FD"  # Imprint lavender (position 2) — gain margin annotation
CLR_PHASE = "#4467A3"  # Imprint blue (position 3) — phase margin annotation

# Data — Third-order transfer function: H(s) = K / [(s/p1+1)(s²/wn²+2ζs/wn+1)]
# Complex pair at wn=10 Hz with ζ=0.2 produces a clear resonance peak;
# single real pole at 200 Hz ensures a finite gain margin (phase crosses -180°).
K = 2
p1 = 2 * np.pi * 200  # real pole at 200 Hz (well above resonance)
wn = 2 * np.pi * 10  # resonance at 10 Hz
zeta = 0.2  # underdamped — visible resonance peak

frequency_hz = np.logspace(-1, 4, 800)
omega = 2 * np.pi * frequency_hz
s = 1j * omega

H = K / ((s / p1 + 1) * (s**2 / wn**2 + 2 * zeta * s / wn + 1))
magnitude_db = 20 * np.log10(np.abs(H))
phase_deg = np.degrees(np.unwrap(np.angle(H)))

# Gain crossover: where magnitude crosses 0 dB
gain_cross_idx = np.where(np.diff(np.sign(magnitude_db)))[0]
gc_found = len(gain_cross_idx) > 0
if gc_found:
    gc_idx = gain_cross_idx[-1]
    gc_freq = frequency_hz[gc_idx]
    gc_phase = phase_deg[gc_idx]
    phase_margin = 180 + gc_phase

# Phase crossover: where phase crosses -180 degrees
phase_cross_idx = np.where(np.diff(np.sign(phase_deg + 180)))[0]
pc_found = len(phase_cross_idx) > 0
if pc_found:
    pc_idx = phase_cross_idx[0]
    pc_freq = frequency_hz[pc_idx]
    pc_mag = magnitude_db[pc_idx]
    gain_margin = -pc_mag

# Figure — dual-panel Bode layout
fig = make_subplots(rows=2, cols=1, shared_xaxes=True, vertical_spacing=0.08, row_heights=[0.55, 0.45])

# Magnitude trace (Imprint green — first series)
fig.add_trace(
    go.Scatter(
        x=frequency_hz,
        y=magnitude_db,
        mode="lines",
        line={"color": CLR_MAIN, "width": 2.5},
        name="Magnitude",
        showlegend=False,
    ),
    row=1,
    col=1,
)

# 0 dB reference line (dashed — zeroline suppressed on y-axis to avoid solid overlay)
fig.add_hline(y=0, row=1, col=1, line={"color": INK_SOFT, "width": 1, "dash": "dash"})

# Phase trace (same Imprint green — single-system Bode)
fig.add_trace(
    go.Scatter(
        x=frequency_hz,
        y=phase_deg,
        mode="lines",
        line={"color": CLR_MAIN, "width": 2.5},
        name="Phase",
        showlegend=False,
    ),
    row=2,
    col=1,
)

# -180 degree reference line
fig.add_hline(y=-180, row=2, col=1, line={"color": INK_SOFT, "width": 1, "dash": "dash"})

# Gain margin annotation (Imprint lavender — position 2)
if pc_found:
    fig.add_shape(
        type="rect",
        x0=pc_freq * 0.8,
        x1=pc_freq * 1.25,
        y0=pc_mag,
        y1=0,
        fillcolor=CLR_GAIN,
        opacity=0.12,
        line={"width": 0},
        row=1,
        col=1,
    )
    fig.add_trace(
        go.Scatter(
            x=[pc_freq, pc_freq],
            y=[pc_mag, 0],
            mode="lines+markers",
            line={"color": CLR_GAIN, "width": 2},
            marker={"size": 8, "symbol": "diamond"},
            showlegend=False,
        ),
        row=1,
        col=1,
    )
    fig.add_annotation(
        x=np.log10(pc_freq),
        y=(pc_mag + 0) / 2,
        text=f"<b>GM = {gain_margin:.1f} dB</b>",
        showarrow=True,
        arrowhead=0,
        arrowwidth=1.5,
        arrowcolor=CLR_GAIN,
        ax=65,
        ay=0,
        font={"size": 12, "color": CLR_GAIN},
        bgcolor=ELEVATED_BG,
        bordercolor=CLR_GAIN,
        borderwidth=1.5,
        borderpad=4,
        xref="x",
        yref="y",
        row=1,
        col=1,
    )
    # Cross-panel marker on phase plot
    fig.add_trace(
        go.Scatter(
            x=[pc_freq],
            y=[-180],
            mode="markers",
            marker={"size": 10, "color": CLR_GAIN, "symbol": "diamond"},
            showlegend=False,
        ),
        row=2,
        col=1,
    )

# Phase margin annotation (Imprint blue — position 3)
if gc_found:
    fig.add_shape(
        type="rect",
        x0=gc_freq * 0.8,
        x1=gc_freq * 1.25,
        y0=gc_phase,
        y1=-180,
        fillcolor=CLR_PHASE,
        opacity=0.12,
        line={"width": 0},
        row=2,
        col=1,
    )
    fig.add_trace(
        go.Scatter(
            x=[gc_freq, gc_freq],
            y=[gc_phase, -180],
            mode="lines+markers",
            line={"color": CLR_PHASE, "width": 2, "dash": "dash"},
            marker={"size": 8, "symbol": "square"},
            showlegend=False,
        ),
        row=2,
        col=1,
    )
    fig.add_annotation(
        x=np.log10(gc_freq),
        y=(gc_phase + (-180)) / 2,
        text=f"<b>PM = {phase_margin:.1f}°</b>",
        showarrow=True,
        arrowhead=0,
        arrowwidth=1.5,
        arrowcolor=CLR_PHASE,
        ax=-65,
        ay=0,
        font={"size": 12, "color": CLR_PHASE},
        bgcolor=ELEVATED_BG,
        bordercolor=CLR_PHASE,
        borderwidth=1.5,
        borderpad=4,
        xref="x2",
        yref="y2",
        row=2,
        col=1,
    )
    # Cross-panel marker on magnitude plot
    fig.add_trace(
        go.Scatter(
            x=[gc_freq],
            y=[0],
            mode="markers",
            marker={"size": 10, "color": CLR_PHASE, "symbol": "square"},
            showlegend=False,
        ),
        row=1,
        col=1,
    )

# Layout — theme-adaptive chrome
fig.update_layout(
    autosize=False,
    title={
        "text": "bode-basic · python · plotly · anyplot.ai",
        "font": {"size": 16, "color": INK},
        "x": 0.5,
        "y": 0.98,
    },
    paper_bgcolor=PAGE_BG,
    plot_bgcolor=PAGE_BG,
    font={"color": INK},
    showlegend=False,
    margin={"l": 80, "r": 40, "t": 70, "b": 60},
)

# X-axes (log scale — shared between both panels)
fig.update_xaxes(
    type="log",
    row=2,
    col=1,
    title={"text": "Frequency (Hz)", "font": {"size": 12, "color": INK}},
    tickfont={"size": 10, "color": INK_SOFT},
    showgrid=True,
    gridcolor=GRID,
    gridwidth=1,
    showline=True,
    linecolor=INK_SOFT,
    mirror=False,
    zeroline=False,
    minor={"showgrid": True, "gridcolor": GRID_MINOR},
)
fig.update_xaxes(
    type="log",
    row=1,
    col=1,
    tickfont={"size": 10, "color": INK_SOFT},
    showgrid=True,
    gridcolor=GRID,
    gridwidth=1,
    showline=False,
    linecolor=INK_SOFT,
    mirror=False,
    zeroline=False,
    minor={"showgrid": True, "gridcolor": GRID_MINOR},
)

# Y-axes
fig.update_yaxes(
    row=1,
    col=1,
    title={"text": "Magnitude (dB)", "font": {"size": 12, "color": INK}},
    tickfont={"size": 10, "color": INK_SOFT},
    showgrid=True,
    gridcolor=GRID,
    gridwidth=1,
    showline=True,
    linecolor=INK_SOFT,
    mirror=False,
    zeroline=False,  # suppress solid zeroline so only the dashed add_hline renders
    range=[-80, 25],
)
fig.update_yaxes(
    row=2,
    col=1,
    title={"text": "Phase (degrees)", "font": {"size": 12, "color": INK}},
    tickfont={"size": 10, "color": INK_SOFT},
    showgrid=True,
    gridcolor=GRID,
    gridwidth=1,
    showline=True,
    linecolor=INK_SOFT,
    mirror=False,
    zeroline=False,
)

# Save — landscape 3200 × 1800 (width=800, height=450, scale=4)
fig.write_image(f"plot-{THEME}.png", width=800, height=450, scale=4)
fig.write_html(f"plot-{THEME}.html", include_plotlyjs="cdn")

Part of Bode Plot for Frequency Response on anyplot.ai.

Other implementations