|
| 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