Skip to content

Commit 8167b46

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

2 files changed

Lines changed: 565 additions & 0 deletions

File tree

Lines changed: 333 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,333 @@
1+
""" pyplots.ai
2+
bode-basic: Bode Plot for Frequency Response
3+
Library: pygal 3.1.0 | Python 3.14.3
4+
Quality: 88/100 | Created: 2026-03-21
5+
"""
6+
7+
import io
8+
9+
import cairosvg
10+
import numpy as np
11+
import pygal
12+
from PIL import Image, ImageDraw, ImageFont
13+
from pygal.style import Style
14+
15+
16+
# Data — Third-order system with resonance: G(s) = K*wn^2 / ((s + p)*(s^2 + 2*zeta*wn*s + wn^2))
17+
# Natural frequency 5 Hz, damping 0.2 (clear resonance), extra pole at 50 Hz
18+
# This gives finite gain margin AND phase margin for a complete Bode demonstration
19+
frequency_hz = np.logspace(-1, 3, 500)
20+
omega = 2 * np.pi * frequency_hz
21+
wn = 2 * np.pi * 5.0
22+
zeta = 0.2
23+
p = 2 * np.pi * 50.0
24+
s = 1j * omega
25+
G = (wn**2 * p) / ((s + p) * (s**2 + 2 * zeta * wn * s + wn**2))
26+
27+
magnitude_db = 20 * np.log10(np.abs(G))
28+
phase_deg = np.degrees(np.unwrap(np.angle(G)))
29+
30+
# Log-transform x-axis
31+
log_freq = np.log10(frequency_hz)
32+
33+
# Find gain crossover: where magnitude crosses 0 dB (after resonance peak)
34+
peak_idx = np.argmax(magnitude_db)
35+
peak_db = magnitude_db[peak_idx]
36+
peak_freq = frequency_hz[peak_idx]
37+
zero_crossings = np.where(np.diff(np.sign(magnitude_db[peak_idx:])))[0]
38+
if len(zero_crossings) > 0:
39+
gc_idx = peak_idx + zero_crossings[0]
40+
gc_freq = frequency_hz[gc_idx]
41+
gc_phase = phase_deg[gc_idx]
42+
phase_margin = 180 + gc_phase
43+
else:
44+
gc_freq = None
45+
phase_margin = None
46+
47+
# Find phase crossover: where phase crosses -180°
48+
pc_indices = np.where(np.diff(np.sign(phase_deg + 180)))[0]
49+
gain_margin = -magnitude_db[pc_indices[0]] if len(pc_indices) > 0 else None
50+
51+
# Color palette — refined for publication quality
52+
line_blue = "#306998"
53+
ref_red = "#B03A2E"
54+
margin_purple = "#6C3483"
55+
margin_teal = "#117A65"
56+
bg_canvas = "#FAFCFF"
57+
bg_plot = "#F0F4F8"
58+
text_dark = "#1A1F36"
59+
grid_subtle = "#D5DAE2"
60+
accent_gold = "#D4A017"
61+
62+
# Shared style settings with larger legend for readability
63+
_style_common = {
64+
"background": bg_canvas,
65+
"plot_background": bg_plot,
66+
"foreground": text_dark,
67+
"foreground_strong": text_dark,
68+
"foreground_subtle": grid_subtle,
69+
"title_font_size": 56,
70+
"label_font_size": 30,
71+
"major_label_font_size": 28,
72+
"legend_font_size": 30,
73+
"value_font_size": 18,
74+
"stroke_width": 3,
75+
"font_family": "'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",
76+
"title_font_family": "'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",
77+
"label_font_family": "'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",
78+
"value_font_family": "'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",
79+
"legend_font_family": "'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",
80+
"opacity": 1.0,
81+
"opacity_hover": 0.85,
82+
"transition": "200ms ease-in",
83+
}
84+
85+
mag_style = Style(**_style_common, colors=(line_blue, ref_red, margin_purple, "#7F8C8D"))
86+
phase_style = Style(**_style_common, colors=(line_blue, ref_red, margin_teal, "#7F8C8D"))
87+
88+
# X-axis tick positions — major decade labels only for clean look
89+
x_ticks_major = [0.1, 1, 10, 100, 1000]
90+
x_ticks_minor = [0.5, 5, 50, 500]
91+
x_tick_major_log = [np.log10(v) for v in x_ticks_major]
92+
x_tick_all_log = sorted([np.log10(v) for v in x_ticks_major + x_ticks_minor])
93+
94+
# Subsample for performance
95+
step = 2
96+
mag_pts = [(float(log_freq[i]), float(magnitude_db[i])) for i in range(0, len(log_freq), step)]
97+
phase_pts = [(float(log_freq[i]), float(phase_deg[i])) for i in range(0, len(log_freq), step)]
98+
99+
# Reference lines as pygal series (horizontal lines spanning the full x range)
100+
x_lo, x_hi = float(log_freq[0]), float(log_freq[-1])
101+
ref_0db = [(x_lo, 0.0), (x_hi, 0.0)]
102+
ref_neg180 = [(x_lo, -180.0), (x_hi, -180.0)]
103+
104+
# Phase margin visual: vertical line segment at gain crossover frequency
105+
phase_margin_line = None
106+
if gc_freq is not None and phase_margin is not None:
107+
gc_log = np.log10(gc_freq)
108+
phase_margin_line = [(float(gc_log), float(gc_phase)), (float(gc_log), -180.0)]
109+
110+
# Gain margin visual: vertical line segment at phase crossover frequency
111+
gain_margin_line = None
112+
pc_freq = None
113+
if len(pc_indices) > 0 and gain_margin is not None:
114+
pc_freq = frequency_hz[pc_indices[0]]
115+
pc_log = np.log10(pc_freq)
116+
pc_mag = magnitude_db[pc_indices[0]]
117+
gain_margin_line = [(float(pc_log), float(pc_mag)), (float(pc_log), 0.0)]
118+
119+
120+
# Custom tooltip formatter for engineering context
121+
def mag_formatter(x, y):
122+
freq = 10**x
123+
return f"{freq:.2g} Hz → {y:.1f} dB"
124+
125+
126+
def phase_formatter(x, y):
127+
freq = 10**x
128+
return f"{freq:.2g} Hz → {y:.1f}°"
129+
130+
131+
# Magnitude chart — secondary y-guides for the -3dB bandwidth line
132+
mag_chart = pygal.XY(
133+
width=4800,
134+
height=1350,
135+
style=mag_style,
136+
show_legend=True,
137+
legend_at_bottom=True,
138+
legend_at_bottom_columns=3,
139+
show_y_guides=True,
140+
show_x_guides=False,
141+
margin=25,
142+
margin_left=160,
143+
margin_right=90,
144+
margin_bottom=100,
145+
margin_top=45,
146+
dots_size=0,
147+
stroke=True,
148+
truncate_label=-1,
149+
print_values=False,
150+
x_value_formatter=lambda x: f"{10**x:.4g}",
151+
tooltip_fancy_mode=True,
152+
tooltip_border_radius=8,
153+
title="bode-basic · pygal · pyplots.ai",
154+
x_title="",
155+
y_title="Magnitude (dB)",
156+
range=(-100.0, 20.0),
157+
interpolate="cubic",
158+
show_minor_x_labels=True,
159+
)
160+
mag_chart.x_labels = x_tick_all_log
161+
mag_chart.x_labels_major = x_tick_major_log
162+
mag_chart.add(
163+
"Magnitude",
164+
mag_pts,
165+
show_dots=False,
166+
formatter=mag_formatter,
167+
stroke_style={"width": 5, "linecap": "round", "linejoin": "round"},
168+
)
169+
mag_chart.add("0 dB Reference", ref_0db, show_dots=False, stroke_style={"width": 2, "dasharray": "18,10"})
170+
if gain_margin_line:
171+
mag_chart.add(
172+
f"Gain Margin: {gain_margin:.1f} dB @ {pc_freq:.1f} Hz",
173+
gain_margin_line,
174+
show_dots=True,
175+
dots_size=8,
176+
stroke_style={"width": 3, "dasharray": "8,5"},
177+
)
178+
179+
# -3 dB bandwidth line for additional engineering context
180+
bw_3db = [(x_lo, -3.0), (x_hi, -3.0)]
181+
mag_chart.add("−3 dB Bandwidth", bw_3db, show_dots=False, stroke_style={"width": 1.5, "dasharray": "4,6"})
182+
183+
# Phase chart
184+
phase_chart = pygal.XY(
185+
width=4800,
186+
height=1350,
187+
style=phase_style,
188+
show_legend=True,
189+
legend_at_bottom=True,
190+
legend_at_bottom_columns=3,
191+
show_y_guides=True,
192+
show_x_guides=False,
193+
margin=25,
194+
margin_left=160,
195+
margin_right=90,
196+
margin_bottom=100,
197+
margin_top=10,
198+
dots_size=0,
199+
stroke=True,
200+
truncate_label=-1,
201+
print_values=False,
202+
x_value_formatter=lambda x: f"{10**x:.4g}",
203+
tooltip_fancy_mode=True,
204+
tooltip_border_radius=8,
205+
title="",
206+
x_title="Frequency (Hz)",
207+
y_title="Phase (°)",
208+
range=(-280.0, 10.0),
209+
interpolate="cubic",
210+
show_minor_x_labels=True,
211+
)
212+
phase_chart.x_labels = x_tick_all_log
213+
phase_chart.x_labels_major = x_tick_major_log
214+
phase_chart.add(
215+
"Phase",
216+
phase_pts,
217+
show_dots=False,
218+
formatter=phase_formatter,
219+
stroke_style={"width": 5, "linecap": "round", "linejoin": "round"},
220+
)
221+
phase_chart.add("–180° Reference", ref_neg180, show_dots=False, stroke_style={"width": 2, "dasharray": "18,10"})
222+
if phase_margin_line:
223+
phase_chart.add(
224+
f"Phase Margin: {phase_margin:.1f}° @ {gc_freq:.1f} Hz",
225+
phase_margin_line,
226+
show_dots=True,
227+
dots_size=8,
228+
stroke_style={"width": 3, "dasharray": "8,5"},
229+
)
230+
231+
# -90° reference for additional context
232+
ref_neg90 = [(x_lo, -90.0), (x_hi, -90.0)]
233+
phase_chart.add("–90° Reference", ref_neg90, show_dots=False, stroke_style={"width": 1.5, "dasharray": "4,6"})
234+
235+
# Render to PNG via cairosvg
236+
mag_png = cairosvg.svg2png(bytestring=mag_chart.render(), output_width=4800, output_height=1350)
237+
phase_png = cairosvg.svg2png(bytestring=phase_chart.render(), output_width=4800, output_height=1350)
238+
239+
# Compose dual-panel image
240+
mag_img = Image.open(io.BytesIO(mag_png))
241+
phase_img = Image.open(io.BytesIO(phase_png))
242+
combined = Image.new("RGB", (4800, 2700), bg_canvas)
243+
combined.paste(mag_img, (0, 0))
244+
combined.paste(phase_img, (0, 1350))
245+
246+
# Draw refined panel divider with gradient effect
247+
draw = ImageDraw.Draw(combined)
248+
draw.line([(160, 1350), (4710, 1350)], fill="#B0BEC5", width=1)
249+
draw.line([(160, 1351), (4710, 1351)], fill="#CFD8DC", width=1)
250+
251+
# Load fonts for annotation overlay
252+
try:
253+
font_title = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 42)
254+
font_body = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 34)
255+
font_label = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 30)
256+
except OSError:
257+
font_title = ImageFont.load_default()
258+
font_body = font_title
259+
font_label = font_title
260+
261+
# Draw annotation panel on magnitude chart — rounded rectangle background
262+
ann_x, ann_y = 3200, 60
263+
ann_w, ann_h = 1500, 200
264+
draw.rounded_rectangle(
265+
[(ann_x, ann_y), (ann_x + ann_w, ann_y + ann_h)], radius=16, fill="#FFFFFF", outline="#D5DAE2", width=2
266+
)
267+
268+
# Resonance peak annotation
269+
draw.text(
270+
(ann_x + 24, ann_y + 16), f"▲ Resonance Peak: {peak_db:.1f} dB @ {peak_freq:.1f} Hz", fill=line_blue, font=font_body
271+
)
272+
273+
# Gain margin annotation
274+
if gain_margin is not None and pc_freq is not None:
275+
draw.text(
276+
(ann_x + 24, ann_y + 64),
277+
f"◆ Gain Margin: {gain_margin:.1f} dB @ {pc_freq:.1f} Hz",
278+
fill=margin_purple,
279+
font=font_title,
280+
)
281+
else:
282+
draw.text((ann_x + 24, ann_y + 64), "◆ Gain Margin: ∞", fill=margin_purple, font=font_title)
283+
284+
# System description
285+
draw.text((ann_x + 24, ann_y + 124), "H(s): 3rd-order, ωn=5 Hz, ζ=0.2", fill="#5D6D7E", font=font_label)
286+
287+
# Phase margin annotation panel
288+
ann2_x, ann2_y = 3200, 1410
289+
ann2_w, ann2_h = 1500, 130
290+
draw.rounded_rectangle(
291+
[(ann2_x, ann2_y), (ann2_x + ann2_w, ann2_y + ann2_h)], radius=16, fill="#FFFFFF", outline="#D5DAE2", width=2
292+
)
293+
294+
if phase_margin is not None:
295+
draw.text(
296+
(ann2_x + 24, ann2_y + 16),
297+
f"◆ Phase Margin: {phase_margin:.1f}° @ {gc_freq:.1f} Hz",
298+
fill=margin_teal,
299+
font=font_title,
300+
)
301+
stability = "Stable" if phase_margin > 0 else "Unstable"
302+
stability_color = "#1E8449" if phase_margin > 0 else "#C0392B"
303+
draw.text(
304+
(ann2_x + 24, ann2_y + 72), f"System Status: {stability} (PM > 0°)", fill=stability_color, font=font_label
305+
)
306+
307+
combined.save("plot.png", dpi=(300, 300))
308+
309+
# HTML version leveraging pygal's native SVG interactivity with tooltips
310+
mag_svg = mag_chart.render(is_unicode=True).replace('<?xml version="1.0" encoding="utf-8"?>', "")
311+
phase_svg = phase_chart.render(is_unicode=True).replace('<?xml version="1.0" encoding="utf-8"?>', "")
312+
313+
html_content = (
314+
"<!DOCTYPE html>\n<html>\n<head>\n"
315+
" <title>bode-basic · pygal · pyplots.ai</title>\n"
316+
" <style>\n"
317+
f" body {{ font-family: 'Helvetica Neue', sans-serif; background: {bg_canvas};"
318+
" margin: 0; padding: 40px 20px; }\n"
319+
" .container { max-width: 1200px; margin: 0 auto; }\n"
320+
" .chart { width: 100%; margin: 8px 0; }\n"
321+
" .divider { border: none; border-top: 1px solid #CFD8DC; margin: 0; }\n"
322+
" .info { text-align: center; color: #5D6D7E; font-size: 14px; margin-top: 12px; }\n"
323+
" </style>\n</head>\n<body>\n"
324+
" <div class='container'>\n"
325+
f" <div class='chart'>{mag_svg}</div>\n"
326+
" <hr class='divider'/>\n"
327+
f" <div class='chart'>{phase_svg}</div>\n"
328+
" <p class='info'>Hover over data points for frequency/value details</p>\n"
329+
" </div>\n</body>\n</html>"
330+
)
331+
332+
with open("plot.html", "w") as f:
333+
f.write(html_content)

0 commit comments

Comments
 (0)