Skip to content

Commit 480f645

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

2 files changed

Lines changed: 480 additions & 0 deletions

File tree

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
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

Comments
 (0)