Skip to content

Commit ed60c14

Browse files
feat(plotnine): implement bode-basic (#5159)
## Implementation: `bode-basic` - plotnine Implements the **plotnine** version of `bode-basic`. **File:** `plots/bode-basic/implementations/plotnine.py` **Parent Issue:** #4411 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/pyplots/actions/runs/23388395508)* --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 2d627f1 commit ed60c14

2 files changed

Lines changed: 437 additions & 0 deletions

File tree

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
""" pyplots.ai
2+
bode-basic: Bode Plot for Frequency Response
3+
Library: plotnine 0.15.3 | Python 3.14.3
4+
Quality: 81/100 | Created: 2026-03-21
5+
"""
6+
7+
import numpy as np
8+
import pandas as pd
9+
from plotnine import (
10+
aes,
11+
element_line,
12+
element_rect,
13+
element_text,
14+
facet_wrap,
15+
geom_hline,
16+
geom_line,
17+
geom_point,
18+
geom_segment,
19+
geom_text,
20+
geom_vline,
21+
ggplot,
22+
labs,
23+
scale_x_log10,
24+
scale_y_continuous,
25+
theme,
26+
theme_minimal,
27+
)
28+
29+
30+
# Data - Third-order open-loop transfer function:
31+
# G(s) = 5 / [(s+1)(0.5s+1)(0.2s+1)]
32+
# Poles at s = -1, -2, -5 — stable system with clear gain and phase margins
33+
frequency_hz = np.logspace(-1.5, 1.5, 600)
34+
omega = 2 * np.pi * frequency_hz
35+
jw = 1j * omega
36+
G = 5.0 / ((jw + 1) * (0.5 * jw + 1) * (0.2 * jw + 1))
37+
38+
magnitude_db = 20 * np.log10(np.abs(G))
39+
phase_deg = np.degrees(np.unwrap(np.angle(G)))
40+
41+
# Gain crossover: where magnitude crosses 0 dB
42+
gc_idx = np.argmin(np.abs(magnitude_db))
43+
gc_freq = frequency_hz[gc_idx]
44+
phase_at_gc = phase_deg[gc_idx]
45+
phase_margin = 180 + phase_at_gc
46+
47+
# Phase crossover: where phase crosses -180 degrees
48+
pc_idx = np.argmin(np.abs(phase_deg + 180))
49+
pc_freq = frequency_hz[pc_idx]
50+
mag_at_pc = magnitude_db[pc_idx]
51+
gain_margin = -mag_at_pc
52+
53+
# Limit magnitude display to relevant range (above -50 dB) to avoid
54+
# compressing the interesting region around 0 dB
55+
freq_mag = frequency_hz[magnitude_db >= -50]
56+
mag_display = magnitude_db[magnitude_db >= -50]
57+
58+
# Panel categories
59+
panels = ["Magnitude (dB)", "Phase (degrees)"]
60+
panel_cat = pd.CategoricalDtype(categories=panels, ordered=True)
61+
62+
# Long-format data for faceted plot
63+
df = pd.concat(
64+
[
65+
pd.DataFrame({"freq": freq_mag, "value": mag_display, "panel": "Magnitude (dB)"}),
66+
pd.DataFrame({"freq": frequency_hz, "value": phase_deg, "panel": "Phase (degrees)"}),
67+
],
68+
ignore_index=True,
69+
)
70+
df["panel"] = df["panel"].astype(panel_cat)
71+
72+
# Reference lines: 0 dB and -180°
73+
ref_lines = pd.DataFrame({"panel": pd.Categorical(panels, dtype=panel_cat), "yintercept": [0.0, -180.0]})
74+
75+
# Margin segments and crossover markers
76+
gm_seg = pd.DataFrame(
77+
{"x": [pc_freq], "ymin": [mag_at_pc], "ymax": [0.0], "panel": pd.Categorical(["Magnitude (dB)"], dtype=panel_cat)}
78+
)
79+
pm_seg = pd.DataFrame(
80+
{
81+
"x": [gc_freq],
82+
"ymin": [-180.0],
83+
"ymax": [phase_at_gc],
84+
"panel": pd.Categorical(["Phase (degrees)"], dtype=panel_cat),
85+
}
86+
)
87+
88+
markers = pd.DataFrame(
89+
{
90+
"freq": [gc_freq, gc_freq, pc_freq, pc_freq],
91+
"value": [0.0, phase_at_gc, mag_at_pc, -180.0],
92+
"panel": pd.Categorical(
93+
["Magnitude (dB)", "Phase (degrees)", "Magnitude (dB)", "Phase (degrees)"], dtype=panel_cat
94+
),
95+
"mtype": ["gc", "gc", "pc", "pc"],
96+
}
97+
)
98+
99+
# Annotation labels positioned to the right of margin segments
100+
gm_label = pd.DataFrame(
101+
{
102+
"freq": [pc_freq * 2.0],
103+
"value": [mag_at_pc / 2],
104+
"label": [f"GM = {gain_margin:.1f} dB"],
105+
"panel": pd.Categorical(["Magnitude (dB)"], dtype=panel_cat),
106+
}
107+
)
108+
pm_label = pd.DataFrame(
109+
{
110+
"freq": [gc_freq * 2.0],
111+
"value": [(phase_at_gc - 180) / 2],
112+
"label": [f"PM = {phase_margin:.0f}°"],
113+
"panel": pd.Categorical(["Phase (degrees)"], dtype=panel_cat),
114+
}
115+
)
116+
117+
# Colors
118+
PYTHON_BLUE = "#306998"
119+
GM_COLOR = "#D35400"
120+
PM_COLOR = "#7D3C98"
121+
DARK_TEXT = "#1A237E"
122+
MID_TEXT = "#37474F"
123+
LIGHT_TEXT = "#546E7A"
124+
125+
# Subtle vertical guides at crossover frequencies
126+
guides = pd.DataFrame(
127+
{
128+
"xintercept": [gc_freq, gc_freq, pc_freq, pc_freq],
129+
"panel": pd.Categorical(
130+
["Magnitude (dB)", "Phase (degrees)", "Magnitude (dB)", "Phase (degrees)"], dtype=panel_cat
131+
),
132+
}
133+
)
134+
135+
# Plot — landscape format for optimal log-frequency axis display
136+
plot = (
137+
ggplot(df, aes(x="freq", y="value"))
138+
+ geom_line(size=2.5, color=PYTHON_BLUE, alpha=0.92)
139+
# Reference lines
140+
+ geom_hline(ref_lines, aes(yintercept="yintercept"), linetype="dashed", color="#90A4AE", size=0.8)
141+
# Crossover guide lines
142+
+ geom_vline(guides, aes(xintercept="xintercept"), linetype="dotted", color="#B0BEC5", size=0.5)
143+
# Gain margin segment
144+
+ geom_segment(gm_seg, aes(x="x", xend="x", y="ymin", yend="ymax"), color=GM_COLOR, size=5.0, alpha=0.9)
145+
# Phase margin segment
146+
+ geom_segment(pm_seg, aes(x="x", xend="x", y="ymin", yend="ymax"), color=PM_COLOR, size=5.0, alpha=0.9)
147+
# Gain crossover markers (purple circles)
148+
+ geom_point(
149+
markers[markers["mtype"] == "gc"],
150+
aes(x="freq", y="value"),
151+
color=PM_COLOR,
152+
fill=PM_COLOR,
153+
size=7,
154+
shape="o",
155+
stroke=2.5,
156+
)
157+
# Phase crossover markers (orange squares)
158+
+ geom_point(
159+
markers[markers["mtype"] == "pc"],
160+
aes(x="freq", y="value"),
161+
color=GM_COLOR,
162+
fill=GM_COLOR,
163+
size=7,
164+
shape="s",
165+
stroke=2.5,
166+
)
167+
# Annotations
168+
+ geom_text(
169+
gm_label, aes(x="freq", y="value", label="label"), color=GM_COLOR, size=18, fontweight="bold", ha="left"
170+
)
171+
+ geom_text(
172+
pm_label, aes(x="freq", y="value", label="label"), color=PM_COLOR, size=18, fontweight="bold", ha="left"
173+
)
174+
+ facet_wrap("~panel", ncol=1, scales="free_y")
175+
+ scale_x_log10(
176+
breaks=[0.1, 1, 10], labels=["0.1", "1", "10"], minor_breaks=[0.03, 0.05, 0.2, 0.3, 0.5, 2, 3, 5, 20, 30]
177+
)
178+
+ scale_y_continuous(labels=lambda lst: [f"{v:.0f}" for v in lst])
179+
+ labs(x="Frequency (Hz)", y="", title="bode-basic · plotnine · pyplots.ai")
180+
+ theme_minimal()
181+
+ theme(
182+
figure_size=(16, 9),
183+
text=element_text(size=14, color=MID_TEXT),
184+
axis_title=element_text(size=20, color=MID_TEXT),
185+
axis_text=element_text(size=16, color=LIGHT_TEXT),
186+
axis_ticks=element_line(color="#CFD8DC", size=0.4),
187+
plot_title=element_text(size=24, weight="bold", ha="center", color=DARK_TEXT),
188+
strip_text=element_text(size=20, weight="bold", color=DARK_TEXT),
189+
strip_background=element_rect(fill="#E8EAF6", color="none"),
190+
panel_grid_major=element_line(color="#E0E0E0", size=0.25),
191+
panel_grid_minor=element_line(color="#F5F5F5", size=0.12),
192+
panel_spacing_y=0.35,
193+
plot_background=element_rect(fill="#FAFAFA", color="#FAFAFA"),
194+
panel_background=element_rect(fill="white", color="none"),
195+
)
196+
)
197+
198+
# Save
199+
plot.save("plot.png", dpi=300, verbose=False)

0 commit comments

Comments
 (0)