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.

""" anyplot.ai
bode-basic: Bode Plot for Frequency Response
Library: letsplot 4.10.1 | Python 3.13.14
Quality: 90/100 | Updated: 2026-06-17
"""
import os
import numpy as np
import pandas as pd
from lets_plot import *
from lets_plot import ggsave
from scipy import signal
LetsPlot.setup_html()
THEME = os.getenv("ANYPLOT_THEME", "light")
# Theme-adaptive chrome tokens
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"
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"
RULE = "rgba(26,26,23,0.15)" if THEME == "light" else "rgba(240,239,232,0.15)"
# Imprint palette — first series always #009E73
IMPRINT_PALETTE = ["#009E73", "#C475FD", "#4467A3", "#BD8233", "#AE3030", "#2ABCCD", "#954477", "#99B314"]
COLOR_MAIN = IMPRINT_PALETTE[0] # brand green — main frequency response line
COLOR_GM = IMPRINT_PALETTE[3] # ochre — gain margin annotation
COLOR_PM = IMPRINT_PALETTE[2] # blue — phase margin annotation
# Data: 3rd-order system G(s) = 500 / ((s+20)(s²+2s+25))
# Complex poles (wn=5, ζ=0.2) → resonance peak; stable with GM≈5 dB, PM≈18°
num = [500.0]
den = np.polymul([1, 20], [1, 2.0, 25])
system = signal.TransferFunction(num, den)
omega = np.logspace(-1, 2.5, 500)
_, mag, phase_deg = signal.bode(system, omega)
frequency_hz = omega / (2 * np.pi)
df_mag = pd.DataFrame({"frequency_hz": frequency_hz, "magnitude_db": mag})
df_phase = pd.DataFrame({"frequency_hz": frequency_hz, "phase_deg": phase_deg})
# Gain crossover: where magnitude = 0 dB
zero_crossings = np.where(np.diff(np.sign(mag)))[0]
if len(zero_crossings) > 0:
idx_gc = zero_crossings[0]
t = abs(mag[idx_gc]) / (abs(mag[idx_gc]) + abs(mag[idx_gc + 1]))
freq_gc = frequency_hz[idx_gc] + t * (frequency_hz[idx_gc + 1] - frequency_hz[idx_gc])
phase_at_gc = phase_deg[idx_gc] + t * (phase_deg[idx_gc + 1] - phase_deg[idx_gc])
phase_margin = 180 + phase_at_gc
else:
freq_gc = None
phase_margin = None
# Phase crossover: where phase = -180°
phase_crossings = np.where(np.diff(np.sign(phase_deg + 180)))[0]
if len(phase_crossings) > 0:
idx_pc = phase_crossings[0]
t_pc = abs(phase_deg[idx_pc] + 180) / (abs(phase_deg[idx_pc] + 180) + abs(phase_deg[idx_pc + 1] + 180))
freq_pc = frequency_hz[idx_pc] + t_pc * (frequency_hz[idx_pc + 1] - frequency_hz[idx_pc])
mag_at_pc = mag[idx_pc] + t_pc * (mag[idx_pc + 1] - mag[idx_pc])
gain_margin = -mag_at_pc
else:
freq_pc = None
gain_margin = None
# Theme-adaptive chrome
anyplot_theme = theme(
plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),
panel_background=element_rect(fill=PAGE_BG),
panel_grid_major=element_line(color=RULE, size=0.3),
panel_grid_minor=element_blank(),
panel_border=element_blank(),
axis_title=element_text(color=INK, size=12, face="bold"),
axis_text=element_text(color=INK_SOFT, size=10),
axis_line=element_line(color=INK_SOFT, size=0.5),
plot_title=element_text(color=INK, size=16, hjust=0.5, face="bold"),
plot_margin=[20, 20, 5, 15],
)
# --- Magnitude plot ---
mag_plot = (
ggplot(df_mag, aes(x="frequency_hz", y="magnitude_db"))
+ geom_line(
color=COLOR_MAIN,
size=1.0,
tooltips=layer_tooltips()
.format("frequency_hz", ".3f")
.format("magnitude_db", ".1f")
.line("Freq: @frequency_hz Hz")
.line("Mag: @magnitude_db dB"),
)
+ geom_hline(yintercept=0, color=INK_SOFT, size=0.5, linetype="dashed")
+ scale_x_log10()
+ scale_y_continuous(limits=[-35, max(mag) + 5])
+ labs(x="", y="Magnitude (dB)", title="bode-basic · python · letsplot · anyplot.ai")
+ anyplot_theme
+ ggsize(800, 216)
)
if freq_pc is not None:
gm_seg = pd.DataFrame({"x": [freq_pc], "y": [mag_at_pc], "xend": [freq_pc], "yend": [0.0]})
gm_pts = pd.DataFrame({"x": [freq_pc, freq_pc], "y": [0.0, mag_at_pc]})
gm_label = pd.DataFrame({"x": [freq_pc * 3.5], "y": [mag_at_pc - 4], "label": [f"GM = {gain_margin:.1f} dB"]})
mag_plot = (
mag_plot
+ geom_vline(xintercept=freq_pc, color=COLOR_GM, size=0.4, linetype="dotted", alpha=0.6)
+ geom_segment(
aes(x="x", y="y", xend="xend", yend="yend"),
data=gm_seg,
color=COLOR_GM,
size=2.5,
arrow=arrow(type="closed", length=7, ends="both"),
)
+ geom_point(aes(x="x", y="y"), data=gm_pts, color=COLOR_GM, size=8, shape=18)
+ geom_label(
aes(x="x", y="y", label="label"),
data=gm_label,
size=18,
color=COLOR_GM,
fill=ELEVATED_BG,
label_padding=0.3,
label_r=0.15,
fontface="bold",
)
)
if freq_gc is not None:
gc_mag_pt = pd.DataFrame({"x": [freq_gc], "y": [0.0]})
mag_plot = mag_plot + geom_point(
aes(x="x", y="y"),
data=gc_mag_pt,
color=COLOR_PM,
size=8,
shape=18,
)
# --- Phase plot ---
phase_plot = (
ggplot(df_phase, aes(x="frequency_hz", y="phase_deg"))
+ geom_line(
color=COLOR_MAIN,
size=1.0,
tooltips=layer_tooltips()
.format("frequency_hz", ".3f")
.format("phase_deg", ".1f")
.line("Freq: @frequency_hz Hz")
.line("Phase: @phase_deg°"),
)
+ geom_hline(yintercept=-180, color=INK_SOFT, size=0.5, linetype="dashed")
+ scale_x_log10()
+ labs(x="Frequency (Hz)", y="Phase (°)")
+ anyplot_theme
+ ggsize(800, 216)
)
if freq_gc is not None:
pm_seg = pd.DataFrame({"x": [freq_gc], "y": [phase_at_gc], "xend": [freq_gc], "yend": [-180.0]})
pm_pts = pd.DataFrame({"x": [freq_gc, freq_gc], "y": [-180.0, phase_at_gc]})
pm_label = pd.DataFrame({"x": [freq_gc * 2.5], "y": [phase_at_gc + 12], "label": [f"PM = {phase_margin:.1f}°"]})
phase_plot = (
phase_plot
+ geom_vline(xintercept=freq_gc, color=COLOR_PM, size=0.4, linetype="dotted", alpha=0.6)
+ geom_segment(
aes(x="x", y="y", xend="xend", yend="yend"),
data=pm_seg,
color=COLOR_PM,
size=2.5,
arrow=arrow(type="closed", length=7, ends="both"),
)
+ geom_point(aes(x="x", y="y"), data=pm_pts, color=COLOR_PM, size=8, shape=18)
+ geom_label(
aes(x="x", y="y", label="label"),
data=pm_label,
size=18,
color=COLOR_PM,
fill=ELEVATED_BG,
label_padding=0.3,
label_r=0.15,
fontface="bold",
)
)
if freq_pc is not None:
pc_phase_pt = pd.DataFrame({"x": [freq_pc], "y": [-180.0]})
phase_plot = phase_plot + geom_point(
aes(x="x", y="y"),
data=pc_phase_pt,
color=COLOR_GM,
size=8,
shape=18,
)
# Combine panels vertically; override the wrapper background so the inter-panel
# gap matches PAGE_BG instead of defaulting to white in dark mode
combined = ggbunch(
plots=[mag_plot, phase_plot], regions=[(0, 0, 1, 0.48, 0, 0), (0, 0.52, 1, 0.48, 0, 0)]
) + theme(plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG))
ggsave(combined, f"plot-{THEME}.png", path=".", w=3200, h=1800, unit="px")
ggsave(combined, f"plot-{THEME}.html", path=".")
Part of Bode Plot for Frequency Response on anyplot.ai.