Skip to content

Commit 31276e3

Browse files
feat(altair): implement smith-chart-basic (#3872)
## Implementation: `smith-chart-basic` - altair Implements the **altair** version of `smith-chart-basic`. **File:** `plots/smith-chart-basic/implementations/altair.py` **Parent Issue:** #3792 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/pyplots/actions/runs/21047488931)* --------- 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 40db34f commit 31276e3

File tree

2 files changed

+408
-0
lines changed

2 files changed

+408
-0
lines changed
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
""" pyplots.ai
2+
smith-chart-basic: Smith Chart for RF/Impedance
3+
Library: altair 6.0.0 | Python 3.13.11
4+
Quality: 91/100 | Created: 2026-01-15
5+
"""
6+
7+
import altair as alt
8+
import numpy as np
9+
import pandas as pd
10+
11+
12+
# Reference impedance
13+
Z0 = 50 # ohms
14+
15+
# Generate Smith chart grid - constant resistance circles
16+
theta = np.linspace(0, 2 * np.pi, 100)
17+
18+
resistance_circles = []
19+
resistance_values = [0, 0.2, 0.5, 1.0, 2.0, 5.0]
20+
for r in resistance_values:
21+
center_x = r / (r + 1)
22+
radius = 1 / (r + 1)
23+
x = center_x + radius * np.cos(theta)
24+
y = radius * np.sin(theta)
25+
mask = x**2 + y**2 <= 1.0
26+
for i in range(len(x)):
27+
if mask[i]:
28+
resistance_circles.append({"x": x[i], "y": y[i], "group": f"r_{r}", "idx": i})
29+
30+
resistance_df = pd.DataFrame(resistance_circles)
31+
32+
# Generate constant reactance arcs
33+
reactance_arcs = []
34+
reactance_values = [0.2, 0.5, 1.0, 2.0, 5.0]
35+
arc_theta = np.linspace(-np.pi, np.pi, 100)
36+
37+
for x_val in reactance_values:
38+
# Positive reactance (upper half)
39+
center_y = 1 / x_val
40+
radius = 1 / x_val
41+
x = 1 + radius * np.cos(arc_theta)
42+
y = center_y + radius * np.sin(arc_theta)
43+
mask = (x**2 + y**2 <= 1.0) & (x >= -0.01)
44+
for i in range(len(x)):
45+
if mask[i]:
46+
reactance_arcs.append({"x": x[i], "y": y[i], "group": f"x_pos_{x_val}", "idx": i})
47+
48+
# Negative reactance (lower half)
49+
y_neg = -center_y - radius * np.sin(arc_theta)
50+
for i in range(len(x)):
51+
if mask[i]:
52+
reactance_arcs.append({"x": x[i], "y": y_neg[i], "group": f"x_neg_{x_val}", "idx": i})
53+
54+
# Zero reactance line (horizontal axis)
55+
x_line = np.linspace(-1, 1, 50)
56+
for i, xi in enumerate(x_line):
57+
reactance_arcs.append({"x": xi, "y": 0, "group": "x_zero", "idx": i})
58+
59+
reactance_df = pd.DataFrame(reactance_arcs)
60+
61+
# Unit circle boundary
62+
unit_theta = np.linspace(0, 2 * np.pi, 100)
63+
unit_circle_df = pd.DataFrame({"x": np.cos(unit_theta), "y": np.sin(unit_theta), "idx": range(len(unit_theta))})
64+
65+
# Generate sample impedance data - antenna impedance sweep from 1-6 GHz
66+
np.random.seed(42)
67+
n_points = 50
68+
frequency = np.linspace(1e9, 6e9, n_points)
69+
70+
# Simulate antenna impedance that traces a spiral pattern on Smith chart
71+
t = np.linspace(0, 2.5 * np.pi, n_points)
72+
z_real = 50 * (1 - 0.7 * np.exp(-t / 3))
73+
z_imag = 40 * np.sin(t) * np.exp(-t / 4)
74+
75+
# Normalize impedance and convert to reflection coefficient (gamma)
76+
z_norm = (z_real + 1j * z_imag) / Z0
77+
gamma = (z_norm - 1) / (z_norm + 1)
78+
79+
impedance_df = pd.DataFrame(
80+
{
81+
"x": gamma.real,
82+
"y": gamma.imag,
83+
"frequency_ghz": frequency / 1e9,
84+
"z_real": z_real,
85+
"z_imag": z_imag,
86+
"idx": range(n_points),
87+
}
88+
)
89+
90+
# Add frequency labels at key points
91+
label_indices = [0, 12, 24, 36, 49]
92+
labels_df = impedance_df.iloc[label_indices].copy()
93+
labels_df["label"] = labels_df["frequency_ghz"].apply(lambda f: f"{f:.1f} GHz")
94+
95+
# Scale domain for consistent axes
96+
scale_x = alt.Scale(domain=[-1.2, 1.2])
97+
scale_y = alt.Scale(domain=[-1.2, 1.2])
98+
99+
# Unit circle boundary
100+
boundary = (
101+
alt.Chart(unit_circle_df)
102+
.mark_line(color="#306998", strokeWidth=4)
103+
.encode(x=alt.X("x:Q", scale=scale_x), y=alt.Y("y:Q", scale=scale_y), order="idx:O")
104+
)
105+
106+
# Resistance circles
107+
res_circles = (
108+
alt.Chart(resistance_df)
109+
.mark_line(strokeWidth=1.5, opacity=0.4, color="#666666")
110+
.encode(x=alt.X("x:Q", scale=scale_x), y=alt.Y("y:Q", scale=scale_y), detail="group:N", order="idx:O")
111+
)
112+
113+
# Reactance arcs
114+
react_arcs = (
115+
alt.Chart(reactance_df)
116+
.mark_line(strokeWidth=1.5, opacity=0.4, color="#888888")
117+
.encode(x=alt.X("x:Q", scale=scale_x), y=alt.Y("y:Q", scale=scale_y), detail="group:N", order="idx:O")
118+
)
119+
120+
# Impedance locus curve
121+
impedance_line = (
122+
alt.Chart(impedance_df)
123+
.mark_line(strokeWidth=5, color="#FFD43B")
124+
.encode(x=alt.X("x:Q", scale=scale_x), y=alt.Y("y:Q", scale=scale_y), order="idx:O")
125+
)
126+
127+
# Impedance data points
128+
impedance_points = (
129+
alt.Chart(impedance_df)
130+
.mark_circle(size=120, color="#FFD43B", stroke="#306998", strokeWidth=1)
131+
.encode(
132+
x=alt.X("x:Q", scale=scale_x),
133+
y=alt.Y("y:Q", scale=scale_y),
134+
tooltip=[
135+
alt.Tooltip("frequency_ghz:Q", title="Frequency (GHz)", format=".2f"),
136+
alt.Tooltip("z_real:Q", title="R (Ω)", format=".1f"),
137+
alt.Tooltip("z_imag:Q", title="X (Ω)", format=".1f"),
138+
],
139+
)
140+
)
141+
142+
# Frequency labels
143+
freq_labels = (
144+
alt.Chart(labels_df)
145+
.mark_text(fontSize=18, fontWeight="bold", color="#306998", dx=18, dy=-18)
146+
.encode(x=alt.X("x:Q", scale=scale_x), y=alt.Y("y:Q", scale=scale_y), text="label:N")
147+
)
148+
149+
# Center point marker (matched condition Z = Z0)
150+
center_df = pd.DataFrame({"x": [0], "y": [0]})
151+
center_point = (
152+
alt.Chart(center_df)
153+
.mark_point(size=300, shape="cross", color="#306998", strokeWidth=3)
154+
.encode(x=alt.X("x:Q", scale=scale_x), y=alt.Y("y:Q", scale=scale_y))
155+
)
156+
157+
# Resistance value labels
158+
r_labels_data = [
159+
{"x": 0.0, "y": 0.08, "label": "0"},
160+
{"x": 0.17, "y": 0.08, "label": "0.2"},
161+
{"x": 0.33, "y": 0.08, "label": "0.5"},
162+
{"x": 0.5, "y": 0.08, "label": "1"},
163+
{"x": 0.67, "y": 0.08, "label": "2"},
164+
{"x": 0.83, "y": 0.08, "label": "5"},
165+
]
166+
r_labels_df = pd.DataFrame(r_labels_data)
167+
168+
r_labels = (
169+
alt.Chart(r_labels_df)
170+
.mark_text(fontSize=14, color="#444444", fontWeight="bold")
171+
.encode(x=alt.X("x:Q", scale=scale_x), y=alt.Y("y:Q", scale=scale_y), text="label:N")
172+
)
173+
174+
# Combine all layers
175+
chart = (
176+
alt.layer(res_circles, react_arcs, boundary, center_point, impedance_line, impedance_points, freq_labels, r_labels)
177+
.properties(
178+
width=1200,
179+
height=1200,
180+
title=alt.Title(
181+
"smith-chart-basic · altair · pyplots.ai",
182+
fontSize=28,
183+
anchor="middle",
184+
subtitle="Antenna Impedance Sweep (1-6 GHz, Z₀ = 50Ω)",
185+
subtitleFontSize=20,
186+
subtitleColor="#666666",
187+
),
188+
)
189+
.configure_view(strokeWidth=0)
190+
.configure_axis(grid=False, domain=False, labels=False, ticks=False, title=None)
191+
)
192+
193+
# Save outputs
194+
chart.save("plot.png", scale_factor=3.0)
195+
chart.save("plot.html")

0 commit comments

Comments
 (0)