|
| 1 | +""" pyplots.ai |
| 2 | +bode-basic: Bode Plot for Frequency Response |
| 3 | +Library: altair 6.0.0 | Python 3.14.3 |
| 4 | +Quality: 91/100 | Created: 2026-03-21 |
| 5 | +""" |
| 6 | + |
| 7 | +import altair as alt |
| 8 | +import numpy as np |
| 9 | +import pandas as pd |
| 10 | + |
| 11 | + |
| 12 | +# Data - Third-order open-loop transfer function: |
| 13 | +# G(s) = 10 / (s/1 + 1)(s/10 + 1)(s/50 + 1) |
| 14 | +# Poles at s = -1, -10, -50; DC gain = 20 dB |
| 15 | +omega = np.logspace(-2, 3, 600) |
| 16 | +s = 1j * omega |
| 17 | + |
| 18 | +K = 10.0 |
| 19 | +G = K / ((s / 1 + 1) * (s / 10 + 1) * (s / 50 + 1)) |
| 20 | + |
| 21 | +magnitude_db = 20 * np.log10(np.abs(G)) |
| 22 | +phase_deg = np.degrees(np.unwrap(np.angle(G))) |
| 23 | + |
| 24 | +df = pd.DataFrame({"frequency": omega, "magnitude_db": magnitude_db, "phase_deg": phase_deg}) |
| 25 | + |
| 26 | +# Find gain crossover frequency (|G| = 0 dB) |
| 27 | +sign_changes_mag = np.where(np.diff(np.sign(magnitude_db)))[0] |
| 28 | +gain_cross_idx = sign_changes_mag[0] if len(sign_changes_mag) > 0 else np.argmin(np.abs(magnitude_db)) |
| 29 | +gain_cross_freq = omega[gain_cross_idx] |
| 30 | +gain_cross_phase = phase_deg[gain_cross_idx] |
| 31 | +phase_margin = 180 + gain_cross_phase |
| 32 | + |
| 33 | +# Find phase crossover frequency (phase = -180°) |
| 34 | +sign_changes_phase = np.where(np.diff(np.sign(phase_deg - (-180))))[0] |
| 35 | +phase_cross_idx = sign_changes_phase[0] if len(sign_changes_phase) > 0 else np.argmin(np.abs(phase_deg + 180)) |
| 36 | +phase_cross_freq = omega[phase_cross_idx] |
| 37 | +phase_cross_mag = magnitude_db[phase_cross_idx] |
| 38 | +gain_margin = -phase_cross_mag |
| 39 | + |
| 40 | +# Colors - colorblind-safe palette (no red-green pairing) |
| 41 | +CLR_MAG = "#306998" # Python Blue - magnitude curve |
| 42 | +CLR_PHASE = "#E8833A" # Orange - phase curve |
| 43 | +CLR_GM = "#7B2D8E" # Purple - gain margin annotation |
| 44 | +CLR_PM = "#1B7FA3" # Teal/cerulean - phase margin annotation |
| 45 | +CLR_REF = "#888888" # Gray - reference lines |
| 46 | +CLR_GRID = "#d0d0d0" # Light gray - grid |
| 47 | +CLR_TITLE = "#1a1a1a" # Near-black - title |
| 48 | +CLR_SUBTITLE = "#555555" # Medium gray - subtitle |
| 49 | +CLR_AXIS = "#333333" # Dark gray - axis labels |
| 50 | +CLR_TICK = "#555555" # Medium gray - tick labels |
| 51 | +CLR_BG = "#FAFBFC" # Very light blue-gray - background |
| 52 | + |
| 53 | +# Reference line data |
| 54 | +ref_0db = pd.DataFrame({"x": [omega.min(), omega.max()], "y": [0, 0]}) |
| 55 | +ref_180 = pd.DataFrame({"x": [omega.min(), omega.max()], "y": [-180, -180]}) |
| 56 | + |
| 57 | +# Gain margin vertical line data (on magnitude plot) |
| 58 | +gm_line = pd.DataFrame({"frequency": [phase_cross_freq, phase_cross_freq], "magnitude_db": [phase_cross_mag, 0]}) |
| 59 | +gm_label = pd.DataFrame( |
| 60 | + { |
| 61 | + "frequency": [phase_cross_freq], |
| 62 | + "magnitude_db": [phase_cross_mag / 2 + 2], |
| 63 | + "label": [f"GM = {gain_margin:.1f} dB"], |
| 64 | + } |
| 65 | +) |
| 66 | + |
| 67 | +# Phase margin vertical line data (on phase plot) |
| 68 | +pm_line = pd.DataFrame({"frequency": [gain_cross_freq, gain_cross_freq], "phase_deg": [gain_cross_phase, -180]}) |
| 69 | +pm_label = pd.DataFrame( |
| 70 | + { |
| 71 | + "frequency": [gain_cross_freq], |
| 72 | + "phase_deg": [(gain_cross_phase - 180) / 2 + 8], |
| 73 | + "label": [f"PM = {phase_margin:.1f}°"], |
| 74 | + } |
| 75 | +) |
| 76 | + |
| 77 | +# Crossover point markers |
| 78 | +gc_mag_pt = pd.DataFrame({"frequency": [gain_cross_freq], "magnitude_db": [0.0]}) |
| 79 | +pc_mag_pt = pd.DataFrame({"frequency": [phase_cross_freq], "magnitude_db": [phase_cross_mag]}) |
| 80 | +gc_phase_pt = pd.DataFrame({"frequency": [gain_cross_freq], "phase_deg": [gain_cross_phase]}) |
| 81 | +pc_phase_pt = pd.DataFrame({"frequency": [phase_cross_freq], "phase_deg": [-180.0]}) |
| 82 | + |
| 83 | +# Stability region shading (magnitude plot: above 0 dB band) |
| 84 | +stability_band_mag = pd.DataFrame({"x": [omega.min(), omega.max()], "y1": [0, 0], "y2": [5, 5]}) |
| 85 | +stability_band_phase = pd.DataFrame({"x": [omega.min(), omega.max()], "y1": [-180, -180], "y2": [-170, -170]}) |
| 86 | + |
| 87 | +# Shared axis config |
| 88 | +freq_scale = alt.Scale(type="log", domain=[0.01, 1000], nice=False) |
| 89 | +y_mag_scale = alt.Scale(domain=[-60, 30]) |
| 90 | +y_phase_scale = alt.Scale(domain=[-280, 10]) |
| 91 | + |
| 92 | +axis_config_x = { |
| 93 | + "labelFontSize": 16, |
| 94 | + "titleFontSize": 20, |
| 95 | + "titleFontWeight": "bold", |
| 96 | + "titleColor": CLR_AXIS, |
| 97 | + "labelColor": CLR_TICK, |
| 98 | + "gridOpacity": 0.25, |
| 99 | + "gridWidth": 0.5, |
| 100 | + "gridColor": CLR_GRID, |
| 101 | + "domainColor": "#bbbbbb", |
| 102 | + "domainWidth": 1.5, |
| 103 | + "tickColor": "#bbbbbb", |
| 104 | + "tickSize": 6, |
| 105 | + "labelPadding": 6, |
| 106 | +} |
| 107 | + |
| 108 | +axis_config_y = {**axis_config_x, "titlePadding": 14} |
| 109 | + |
| 110 | +# --- Nearest-point selection for interactive crosshair tooltip --- |
| 111 | +nearest = alt.selection_point(nearest=True, on="pointerover", fields=["frequency"], empty=False) |
| 112 | + |
| 113 | +# Magnitude plot |
| 114 | +mag_line = ( |
| 115 | + alt.Chart(df) |
| 116 | + .mark_line(strokeWidth=3, color=CLR_MAG, interpolate="monotone") |
| 117 | + .encode( |
| 118 | + x=alt.X("frequency:Q", scale=freq_scale, axis=alt.Axis(labels=False, title="", ticks=False, **axis_config_x)), |
| 119 | + y=alt.Y("magnitude_db:Q", title="Magnitude (dB)", scale=y_mag_scale, axis=alt.Axis(**axis_config_y)), |
| 120 | + tooltip=[ |
| 121 | + alt.Tooltip("frequency:Q", title="ω (rad/s)", format=".2f"), |
| 122 | + alt.Tooltip("magnitude_db:Q", title="Magnitude (dB)", format=".1f"), |
| 123 | + ], |
| 124 | + ) |
| 125 | +) |
| 126 | + |
| 127 | +# Invisible selection layer for crosshair |
| 128 | +mag_selectable = ( |
| 129 | + alt.Chart(df) |
| 130 | + .mark_point(opacity=0) |
| 131 | + .encode(x=alt.X("frequency:Q", scale=freq_scale), y=alt.Y("magnitude_db:Q", scale=y_mag_scale)) |
| 132 | + .add_params(nearest) |
| 133 | +) |
| 134 | + |
| 135 | +# Crosshair vertical rule |
| 136 | +mag_crosshair = ( |
| 137 | + alt.Chart(df) |
| 138 | + .mark_rule(color="#666666", strokeWidth=0.8, strokeDash=[3, 3]) |
| 139 | + .encode(x=alt.X("frequency:Q", scale=freq_scale)) |
| 140 | + .transform_filter(nearest) |
| 141 | +) |
| 142 | + |
| 143 | +mag_ref = ( |
| 144 | + alt.Chart(ref_0db) |
| 145 | + .mark_line(strokeWidth=1.5, strokeDash=[8, 6], color=CLR_REF, opacity=0.5) |
| 146 | + .encode(x=alt.X("x:Q", scale=freq_scale), y=alt.Y("y:Q", scale=y_mag_scale)) |
| 147 | +) |
| 148 | + |
| 149 | +mag_gm_line = ( |
| 150 | + alt.Chart(gm_line) |
| 151 | + .mark_line(strokeWidth=2.5, color=CLR_GM, strokeDash=[5, 3]) |
| 152 | + .encode(x=alt.X("frequency:Q", scale=freq_scale), y=alt.Y("magnitude_db:Q", scale=y_mag_scale)) |
| 153 | +) |
| 154 | + |
| 155 | +mag_gm_label = ( |
| 156 | + alt.Chart(gm_label) |
| 157 | + .mark_text(fontSize=15, fontWeight="bold", color=CLR_GM, align="left", dx=14, font="monospace") |
| 158 | + .encode(x=alt.X("frequency:Q", scale=freq_scale), y=alt.Y("magnitude_db:Q", scale=y_mag_scale), text="label:N") |
| 159 | +) |
| 160 | + |
| 161 | +mag_gc_point = ( |
| 162 | + alt.Chart(gc_mag_pt) |
| 163 | + .mark_point(size=220, shape="circle", filled=True, color=CLR_MAG, stroke="white", strokeWidth=2.5) |
| 164 | + .encode(x=alt.X("frequency:Q", scale=freq_scale), y=alt.Y("magnitude_db:Q", scale=y_mag_scale)) |
| 165 | +) |
| 166 | + |
| 167 | +mag_pc_point = ( |
| 168 | + alt.Chart(pc_mag_pt) |
| 169 | + .mark_point(size=220, shape="diamond", filled=True, color=CLR_GM, stroke="white", strokeWidth=2.5) |
| 170 | + .encode(x=alt.X("frequency:Q", scale=freq_scale), y=alt.Y("magnitude_db:Q", scale=y_mag_scale)) |
| 171 | +) |
| 172 | + |
| 173 | +magnitude_chart = ( |
| 174 | + mag_line + mag_ref + mag_gm_line + mag_gm_label + mag_gc_point + mag_pc_point + mag_selectable + mag_crosshair |
| 175 | +).properties( |
| 176 | + width=1600, |
| 177 | + height=420, |
| 178 | + title=alt.Title( |
| 179 | + "bode-basic · altair · pyplots.ai", |
| 180 | + fontSize=28, |
| 181 | + fontWeight="bold", |
| 182 | + color=CLR_TITLE, |
| 183 | + subtitle="G(s) = 10 / (s+1)(s/10+1)(s/50+1) · Open-Loop Frequency Response", |
| 184 | + subtitleFontSize=18, |
| 185 | + subtitleColor=CLR_SUBTITLE, |
| 186 | + subtitlePadding=10, |
| 187 | + anchor="start", |
| 188 | + offset=12, |
| 189 | + ), |
| 190 | +) |
| 191 | + |
| 192 | +# Phase plot |
| 193 | +phase_line = ( |
| 194 | + alt.Chart(df) |
| 195 | + .mark_line(strokeWidth=3, color=CLR_PHASE, interpolate="monotone") |
| 196 | + .encode( |
| 197 | + x=alt.X("frequency:Q", scale=freq_scale, title="Frequency (rad/s)", axis=alt.Axis(**axis_config_x)), |
| 198 | + y=alt.Y("phase_deg:Q", title="Phase (degrees)", scale=y_phase_scale, axis=alt.Axis(**axis_config_y)), |
| 199 | + tooltip=[ |
| 200 | + alt.Tooltip("frequency:Q", title="ω (rad/s)", format=".2f"), |
| 201 | + alt.Tooltip("phase_deg:Q", title="Phase (°)", format=".1f"), |
| 202 | + ], |
| 203 | + ) |
| 204 | +) |
| 205 | + |
| 206 | +phase_ref = ( |
| 207 | + alt.Chart(ref_180) |
| 208 | + .mark_line(strokeWidth=1.5, strokeDash=[8, 6], color=CLR_REF, opacity=0.5) |
| 209 | + .encode(x=alt.X("x:Q", scale=freq_scale), y=alt.Y("y:Q", scale=y_phase_scale)) |
| 210 | +) |
| 211 | + |
| 212 | +phase_pm_line = ( |
| 213 | + alt.Chart(pm_line) |
| 214 | + .mark_line(strokeWidth=2.5, color=CLR_PM, strokeDash=[5, 3]) |
| 215 | + .encode(x=alt.X("frequency:Q", scale=freq_scale), y=alt.Y("phase_deg:Q", scale=y_phase_scale)) |
| 216 | +) |
| 217 | + |
| 218 | +phase_pm_label = ( |
| 219 | + alt.Chart(pm_label) |
| 220 | + .mark_text(fontSize=15, fontWeight="bold", color=CLR_PM, align="left", dx=14, font="monospace") |
| 221 | + .encode(x=alt.X("frequency:Q", scale=freq_scale), y=alt.Y("phase_deg:Q", scale=y_phase_scale), text="label:N") |
| 222 | +) |
| 223 | + |
| 224 | +phase_gc_point = ( |
| 225 | + alt.Chart(gc_phase_pt) |
| 226 | + .mark_point(size=220, shape="circle", filled=True, color=CLR_PHASE, stroke="white", strokeWidth=2.5) |
| 227 | + .encode(x=alt.X("frequency:Q", scale=freq_scale), y=alt.Y("phase_deg:Q", scale=y_phase_scale)) |
| 228 | +) |
| 229 | + |
| 230 | +phase_pc_point = ( |
| 231 | + alt.Chart(pc_phase_pt) |
| 232 | + .mark_point(size=220, shape="diamond", filled=True, color=CLR_PM, stroke="white", strokeWidth=2.5) |
| 233 | + .encode(x=alt.X("frequency:Q", scale=freq_scale), y=alt.Y("phase_deg:Q", scale=y_phase_scale)) |
| 234 | +) |
| 235 | + |
| 236 | +phase_chart = (phase_line + phase_ref + phase_pm_line + phase_pm_label + phase_gc_point + phase_pc_point).properties( |
| 237 | + width=1600, height=420 |
| 238 | +) |
| 239 | + |
| 240 | +# Combine vertically with refined global config |
| 241 | +chart = ( |
| 242 | + alt.vconcat(magnitude_chart, phase_chart, spacing=16) |
| 243 | + .configure_view(strokeWidth=0, fill=CLR_BG, cornerRadius=4) |
| 244 | + .configure_concat(spacing=16) |
| 245 | + .configure(background="#FFFFFF", padding={"left": 20, "right": 30, "top": 10, "bottom": 10}) |
| 246 | +) |
| 247 | + |
| 248 | +# Save |
| 249 | +chart.save("plot.png", scale_factor=3.0) |
| 250 | +chart.save("plot.html") |
0 commit comments