Skip to content

Commit 4518025

Browse files
feat(bokeh): implement bode-basic (#5153)
## Implementation: `bode-basic` - bokeh Implements the **bokeh** version of `bode-basic`. **File:** `plots/bode-basic/implementations/bokeh.py` **Parent Issue:** #4411 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/pyplots/actions/runs/23388395472)* --------- 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 480f645 commit 4518025

2 files changed

Lines changed: 462 additions & 0 deletions

File tree

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
""" pyplots.ai
2+
bode-basic: Bode Plot for Frequency Response
3+
Library: bokeh 3.9.0 | Python 3.14.3
4+
Quality: 90/100 | Created: 2026-03-21
5+
"""
6+
7+
import numpy as np
8+
from bokeh.io import export_png, save
9+
from bokeh.layouts import column
10+
from bokeh.models import BoxAnnotation, ColumnDataSource, HoverTool, Label, Span
11+
from bokeh.plotting import figure
12+
from bokeh.resources import CDN
13+
14+
15+
# Data - Third-order open-loop transfer function:
16+
# H(s) = K / (s * (s/w1 + 1) * (s/w2 + 1))
17+
# Classic control system with integrator + two real poles
18+
K = 100
19+
w1 = 2 * np.pi * 5 # Pole at 5 Hz
20+
w2 = 2 * np.pi * 50 # Pole at 50 Hz
21+
22+
frequency_hz = np.logspace(-1, 3, 500)
23+
omega = 2 * np.pi * frequency_hz
24+
s = 1j * omega
25+
26+
H = K / (s * (s / w1 + 1) * (s / w2 + 1))
27+
magnitude_db = 20 * np.log10(np.abs(H))
28+
phase_deg = np.degrees(np.unwrap(np.angle(H)))
29+
30+
# Gain crossover: where magnitude crosses 0 dB
31+
sign_changes = np.diff(np.sign(magnitude_db))
32+
gc_indices = np.where(sign_changes != 0)[0]
33+
gain_cross_idx = gc_indices[0] if len(gc_indices) > 0 else np.argmin(np.abs(magnitude_db))
34+
gain_cross_freq = frequency_hz[gain_cross_idx]
35+
phase_at_gain_cross = phase_deg[gain_cross_idx]
36+
phase_margin = 180 + phase_at_gain_cross
37+
38+
# Phase crossover: where phase crosses -180 degrees
39+
phase_shifted = phase_deg + 180
40+
sign_changes_phase = np.diff(np.sign(phase_shifted))
41+
pc_indices = np.where(sign_changes_phase != 0)[0]
42+
phase_cross_idx = pc_indices[0] if len(pc_indices) > 0 else np.argmin(np.abs(phase_deg + 180))
43+
phase_cross_freq = frequency_hz[phase_cross_idx]
44+
mag_at_phase_cross = magnitude_db[phase_cross_idx]
45+
gain_margin = -mag_at_phase_cross
46+
47+
# Colors - colorblind-safe: Python Blue, Vermillion, Blue-Purple (Wong palette)
48+
CURVE_COLOR = "#306998"
49+
GM_COLOR = "#D55E00" # Vermillion
50+
PM_COLOR = "#7570B3" # Blue-purple
51+
REF_COLOR = "#555555"
52+
BG_COLOR = "#FAFAFA"
53+
AXIS_COLOR = "#444444"
54+
55+
source = ColumnDataSource(data={"frequency": frequency_hz, "magnitude": magnitude_db, "phase": phase_deg})
56+
57+
# Magnitude plot
58+
p_mag = figure(
59+
width=4800,
60+
height=1350,
61+
x_axis_type="log",
62+
x_axis_label="",
63+
y_axis_label="Magnitude (dB)",
64+
title="bode-basic · bokeh · pyplots.ai",
65+
toolbar_location=None,
66+
)
67+
68+
# Stability region shading (above 0 dB = high gain region)
69+
p_mag.add_layout(BoxAnnotation(bottom=0, fill_color=CURVE_COLOR, fill_alpha=0.025))
70+
71+
p_mag.line("frequency", "magnitude", source=source, line_width=4, color=CURVE_COLOR)
72+
73+
# 0 dB reference line
74+
p_mag.add_layout(
75+
Span(location=0, dimension="width", line_color=REF_COLOR, line_width=2, line_dash="dashed", line_alpha=0.7)
76+
)
77+
p_mag.add_layout(
78+
Label(
79+
x=0.12,
80+
y=1.5,
81+
text="0 dB",
82+
text_font_size="16pt",
83+
text_color=REF_COLOR,
84+
text_alpha=0.7,
85+
text_font_style="italic",
86+
)
87+
)
88+
89+
# Gain margin annotation
90+
p_mag.scatter([phase_cross_freq], [mag_at_phase_cross], size=16, color=GM_COLOR, marker="circle")
91+
p_mag.scatter([phase_cross_freq], [0], size=16, color=GM_COLOR, marker="circle")
92+
p_mag.segment(
93+
x0=[phase_cross_freq],
94+
y0=[mag_at_phase_cross],
95+
x1=[phase_cross_freq],
96+
y1=[0],
97+
line_width=3,
98+
color=GM_COLOR,
99+
line_dash="dotted",
100+
)
101+
p_mag.add_layout(
102+
Label(
103+
x=phase_cross_freq,
104+
y=mag_at_phase_cross / 2,
105+
text=f"GM = {gain_margin:.1f} dB",
106+
text_font_size="22pt",
107+
text_font_style="bold",
108+
text_color=GM_COLOR,
109+
x_offset=18,
110+
)
111+
)
112+
113+
# Gain crossover marker on magnitude plot
114+
p_mag.scatter([gain_cross_freq], [0], size=16, color=PM_COLOR, marker="circle")
115+
116+
# Phase plot
117+
p_phase = figure(
118+
width=4800,
119+
height=1350,
120+
x_axis_type="log",
121+
x_axis_label="Frequency (Hz)",
122+
y_axis_label="Phase (°)",
123+
x_range=p_mag.x_range,
124+
toolbar_location=None,
125+
)
126+
127+
# Instability region shading (below -180°)
128+
p_phase.add_layout(BoxAnnotation(top=-180, fill_color=GM_COLOR, fill_alpha=0.025))
129+
130+
p_phase.line("frequency", "phase", source=source, line_width=4, color=CURVE_COLOR)
131+
132+
# -180° reference line
133+
p_phase.add_layout(
134+
Span(location=-180, dimension="width", line_color=REF_COLOR, line_width=2, line_dash="dashed", line_alpha=0.7)
135+
)
136+
p_phase.add_layout(
137+
Label(
138+
x=0.12,
139+
y=-177,
140+
text="-180°",
141+
text_font_size="16pt",
142+
text_color=REF_COLOR,
143+
text_alpha=0.7,
144+
text_font_style="italic",
145+
)
146+
)
147+
148+
# Phase margin annotation
149+
p_phase.scatter([gain_cross_freq], [phase_at_gain_cross], size=16, color=PM_COLOR, marker="circle")
150+
p_phase.scatter([gain_cross_freq], [-180], size=16, color=PM_COLOR, marker="circle")
151+
p_phase.segment(
152+
x0=[gain_cross_freq],
153+
y0=[phase_at_gain_cross],
154+
x1=[gain_cross_freq],
155+
y1=[-180],
156+
line_width=3,
157+
color=PM_COLOR,
158+
line_dash="dotted",
159+
)
160+
p_phase.add_layout(
161+
Label(
162+
x=gain_cross_freq,
163+
y=(phase_at_gain_cross - 180) / 2,
164+
text=f"PM = {phase_margin:.1f}°",
165+
text_font_size="22pt",
166+
text_font_style="bold",
167+
text_color=PM_COLOR,
168+
x_offset=18,
169+
)
170+
)
171+
172+
# Phase crossover marker on phase plot
173+
p_phase.scatter([phase_cross_freq], [-180], size=16, color=GM_COLOR, marker="circle")
174+
175+
# HoverTool for interactive HTML output (distinctive Bokeh feature)
176+
p_mag.add_tools(
177+
HoverTool(
178+
tooltips=[("Frequency", "@frequency{0.00} Hz"), ("Magnitude", "@magnitude{0.0} dB")],
179+
mode="vline",
180+
line_policy="nearest",
181+
)
182+
)
183+
p_phase.add_tools(
184+
HoverTool(
185+
tooltips=[("Frequency", "@frequency{0.00} Hz"), ("Phase", "@phase{0.0}°")], mode="vline", line_policy="nearest"
186+
)
187+
)
188+
189+
190+
# Style helper
191+
def style_plot(p, is_top=False):
192+
p.yaxis.axis_label_text_font_size = "22pt"
193+
p.xaxis.axis_label_text_font_size = "22pt"
194+
p.xaxis.major_label_text_font_size = "18pt"
195+
p.yaxis.major_label_text_font_size = "18pt"
196+
p.xaxis.axis_line_color = AXIS_COLOR
197+
p.yaxis.axis_line_color = AXIS_COLOR
198+
p.xaxis.axis_line_width = 1.5
199+
p.yaxis.axis_line_width = 1.5
200+
p.xaxis.major_tick_line_color = None
201+
p.yaxis.major_tick_line_color = None
202+
p.xaxis.minor_tick_line_color = None
203+
p.yaxis.minor_tick_line_color = None
204+
p.outline_line_color = None
205+
p.background_fill_color = BG_COLOR
206+
p.border_fill_color = "#FFFFFF"
207+
p.ygrid.grid_line_alpha = 0.25
208+
p.ygrid.grid_line_width = 1
209+
p.ygrid.grid_line_dash = [4, 4]
210+
p.xgrid.grid_line_alpha = 0.15
211+
p.xgrid.grid_line_width = 1
212+
p.xgrid.grid_line_dash = [4, 4]
213+
p.min_border_left = 120
214+
p.min_border_right = 80
215+
if is_top:
216+
p.min_border_bottom = 20
217+
else:
218+
p.min_border_top = 20
219+
220+
221+
style_plot(p_mag, is_top=True)
222+
style_plot(p_phase, is_top=False)
223+
224+
# Title styling
225+
p_mag.title.text_font_size = "28pt"
226+
p_mag.title.text_font_style = "normal"
227+
p_mag.title.text_color = "#333333"
228+
229+
# Layout with tight spacing
230+
layout = column(p_mag, p_phase, spacing=0)
231+
232+
# Save
233+
export_png(layout, filename="plot.png")
234+
save(layout, filename="plot.html", resources=CDN, title="bode-basic · bokeh · pyplots.ai")

0 commit comments

Comments
 (0)