128 files · 3.7 MB at 05eecdc Download ZIP
.dockerignore 72 B
.gitignore 170 B
CLAUDE.md 11.0 KB
Dockerfile 3.2 KB
fp-lib-table 152 B
pyproject.toml 497 B
QFP58-feed-RHCP-drc.rpt 553 B
QFP58-feed-RHCP.kicad_pcb 108 KB
QFP58-feed-RHCP.kicad_pro 9.6 KB
README.md 15.5 KB
run.sh 4.2 KB
uv.lock 189 KB
docs/
design_report.json 5.3 KB
preview_LHCP.png 195 KB
preview_RHCP.png 195 KB
docs/svg/
QFP58-feed-LHCP-B_Cu.svg 134 KB
QFP58-feed-LHCP-Edge_Cuts.svg 11.5 KB
QFP58-feed-LHCP-F_Cu.svg 61.9 KB
QFP58-feed-RHCP-B_Cu.svg 131 KB
QFP58-feed-RHCP-Edge_Cuts.svg 11.6 KB
QFP58-feed-RHCP-F_Cu.svg 61.9 KB
QFP58-patch-B_Cu.svg 37.2 KB
QFP58-patch-Edge_Cuts.svg 11.9 KB
QFP58-patch-F_Cu.svg 16.3 KB
gerbers/
QFP58-feed-LHCP-fab.zip 31.5 KB
QFP58-feed-RHCP-fab.zip 31.6 KB
QFP58-patch-d23.06-fab.zip 12.0 KB
QFP58-patch-d23.36-fab.zip 12.0 KB
QFP58-patch-fab.zip 11.7 KB
gerbers/QFP58-feed-LHCP/
QFP58-feed-LHCP-B_Cu.gbl 4.2 KB
QFP58-feed-LHCP-B_Mask.gbs 1.6 KB
QFP58-feed-LHCP-B_Silkscreen.gbo 41.5 KB
QFP58-feed-LHCP-Edge_Cuts.gm1 17.0 KB
QFP58-feed-LHCP-F_Cu.gtl 47.5 KB
QFP58-feed-LHCP-F_Mask.gts 1.2 KB
QFP58-feed-LHCP-F_Silkscreen.gto 1.2 KB
QFP58-feed-LHCP-job.gbrjob 2.4 KB
QFP58-feed-LHCP-NPTH.drl 383 B
QFP58-feed-LHCP-PTH.drl 530 B
gerbers/QFP58-feed-RHCP/
QFP58-feed-RHCP-B_Cu.gbl 4.2 KB
QFP58-feed-RHCP-B_Mask.gbs 1.6 KB
QFP58-feed-RHCP-B_Silkscreen.gbo 40.6 KB
QFP58-feed-RHCP-Edge_Cuts.gm1 17.0 KB
QFP58-feed-RHCP-F_Cu.gtl 47.5 KB
QFP58-feed-RHCP-F_Mask.gts 1.2 KB
QFP58-feed-RHCP-F_Silkscreen.gto 1.2 KB
QFP58-feed-RHCP-job.gbrjob 2.4 KB
QFP58-feed-RHCP-NPTH.drl 383 B
QFP58-feed-RHCP-PTH.drl 536 B
gerbers/QFP58-patch/
QFP58-patch-B_Cu.gbl 21.0 KB
QFP58-patch-B_Mask.gbs 653 B
QFP58-patch-B_Silkscreen.gbo 663 B
QFP58-patch-Edge_Cuts.gm1 640 B
QFP58-patch-F_Cu.gtl 937 B
QFP58-patch-F_Mask.gts 810 B
QFP58-patch-F_Silkscreen.gto 9.4 KB
QFP58-patch-job.gbrjob 2.4 KB
QFP58-patch-NPTH.drl 264 B
QFP58-patch-PTH.drl 375 B
gerbers/QFP58-patch-d23.06/
QFP58-patch-d23.06-B_Cu.gbl 21.0 KB
QFP58-patch-d23.06-B_Mask.gbs 660 B
QFP58-patch-d23.06-B_Silkscreen.gbo 670 B
QFP58-patch-d23.06-Edge_Cuts.gm1 647 B
QFP58-patch-d23.06-F_Cu.gtl 944 B
QFP58-patch-d23.06-F_Mask.gts 817 B
QFP58-patch-d23.06-F_Silkscreen.gto 10.5 KB
QFP58-patch-d23.06-job.gbrjob 2.4 KB
gerbers/QFP58-patch-d23.36/
QFP58-patch-d23.36-B_Cu.gbl 21.1 KB
QFP58-patch-d23.36-B_Mask.gbs 660 B
QFP58-patch-d23.36-B_Silkscreen.gbo 670 B
QFP58-patch-d23.36-Edge_Cuts.gm1 647 B
QFP58-patch-d23.36-F_Cu.gtl 944 B
QFP58-patch-d23.36-F_Mask.gts 817 B
QFP58-patch-d23.36-F_Silkscreen.gto 10.1 KB
QFP58-patch-d23.36-job.gbrjob 2.4 KB
kicad/QFP58-feed-LHCP/
fp-lib-table 149 B
QFP58-feed-LHCP-drc.rpt 553 B
QFP58-feed-LHCP.kicad_pcb 108 KB
QFP58-feed-LHCP.kicad_pro 9.6 KB
kicad/QFP58-patch/
fp-lib-table 149 B
QFP58-patch-drc.rpt 576 B
QFP58-patch.kicad_pcb 36.3 KB
QFP58-patch.kicad_pro 9.6 KB
kicad/QFP58-patch-d23.06/
fp-lib-table 149 B
QFP58-patch-d23.06-drc.rpt 528 B
QFP58-patch-d23.06.kicad_pcb 36.3 KB
QFP58-patch-d23.06.kicad_pro 9.5 KB
kicad/QFP58-patch-d23.36/
fp-lib-table 149 B
QFP58-patch-d23.36-drc.rpt 528 B
QFP58-patch-d23.36.kicad_pcb 36.4 KB
QFP58-patch-d23.36.kicad_pro 9.5 KB
kicad/QFP58.pretty/
CouplingDisk_3.6mm.kicad_mod 1.2 KB
FeedPin_1.1mm.kicad_mod 1.0 KB
MountingHole_2.2mm_M2.kicad_mod 1.0 KB
R_0603_Wilkinson.kicad_mod 1.2 KB
SMA_EdgeLaunch_1.0mm_generic.kicad_mod 1.7 KB
sim/full/
full_cuts_5.65GHz.png 86.6 KB
full_cuts_5.80GHz.png 87.0 KB
full_cuts_5.95GHz.png 88.6 KB
full_farfield_5.65GHz.npz 147 KB
full_farfield_5.80GHz.npz 147 KB
full_farfield_5.95GHz.npz 147 KB
full_pattern_5.65GHz.png 79.2 KB
full_pattern_5.80GHz.png 80.5 KB
full_pattern_5.95GHz.png 83.6 KB
full_pattern3d_5.65GHz.png 154 KB
full_pattern3d_5.80GHz.png 156 KB
full_pattern3d_5.95GHz.png 155 KB
full_sparams.csv 3.2 KB
full_sparams.png 30.3 KB
summary.json 1.5 KB
sim/network/
network_sparams.csv 12.9 KB
network_sparams.png 45.0 KB
summary.json 286 B
sim/patch/
patch_ideal_feed_pattern_5.65GHz.png 69.2 KB
patch_ideal_feed_pattern_5.80GHz.png 69.5 KB
patch_ideal_feed_pattern_5.95GHz.png 70.5 KB
patch_sparams.png 48.0 KB
summary.json 1.4 KB
src/
generate.py 1.9 KB
kicad_build.py 13.9 KB
qfp_geom.py 11.6 KB
qfp_layers.py 6.9 KB
qfp_sim.py 27.4 KB
#!/usr/bin/env python3
"""
QFP-58 : Quad-Feed sequential-rotation CP patch antenna, 5.8 GHz -- geometry core.
Concept inspired by the Triple Feed Patch (Maarten Baert & Robin Theunis,
https://www.maartenbaert.be/quadcopters/antennas/triple-feed-patch-antenna/, CC BY-SA 4.0).
No TFP design files were used; everything here is computed from first principles.
Coordinates: mm, origin = antenna axis, TOP VIEW (looking at the patch from the radiating side),
x right, y up. Bottom-layer features are given in top-view coordinates (normal Gerber/KiCad convention).
The feed network is described as primitives (straight segments + circular arcs with a width), so the
same description drives the KiCad board (native tracks/arcs), the SVG export and the preview.
"""
import math
import numpy as np
from scipy.optimize import brentq
import skrf as rf
C0 = 299792458.0
P = dict(
f0=5.8e9,
# --- substrate (both boards): 1.0 mm FR4, 1 oz
sub_er=4.4, sub_tand=0.02, sub_h=1.0, cu_t=0.035,
# --- feed board: network on B.Cu, solid ground on F.Cu (faces the patch)
board_d=60.0, flat_r=29.0, edge_pullback=0.25,
# --- patch (cavity model, air gap 4.0 mm copper-to-copper, eps_eff ~1.04)
gap_air=4.0, patch_d=22.76, patch_margin=0.6,
patch_variants=(23.06, 23.36), # extra patch boards: ~-60/-120 MHz each, to bracket FR4 and build spread
rp=4.0, # feed-pin radius; rev B from openEMS: active Z ~35-50 ohm per pin (4 seq. feeds)
pin_drill=1.1, pin_pad=1.8, gnd_antipad=2.6,
patch_pin_ring=1.4, # small annular ring around the plated pin hole in the patch board
patch_antipad=2.2, # clearance in patch copper around the pin (0.4 mm gap to the ring)
disk_d=3.6, # top-side coupling disk: ~0.33 pF series C through 1.0 mm FR4
# --- network impedances
z_in=50.0, z_br=60.0, z_out=50.0, # arms follow: z_w1=sqrt(2*z_in*z_br), z_w2=sqrt(2*z_br*z_out)
z_match=100.0, match_len=2.0, # series-inductive 100-ohm section at each pin (openEMS-tuned)
r1=9.0, # radius of the stage-2 isolation resistors
res='0603', res_pitch=1.65, res_pad=(0.80, 0.95), # pad centre spacing, pad (along, across)
lead_off=0.15, lead_len=1.0, # 45-deg diverging lead-outs from the resistor pads (stage 1)
lead_len2=1.6, lead_off2=0.35, # stage-2 lead-outs (wide 50-ohm outputs: more drop, start further out)
min_res_gap=0.4, # min copper gap across each isolation resistor
branch_clear=2.0, # edge clearance: stage-1 branch arcs <-> stage-2 rings (openEMS: less coupling)
junction_comp=True, # arm length measured from the edge of the feeding stem
phase_corr2=1.074, # rev C: stretch the lambda/4 (stage-2) differential arc (openEMS-calibrated)
phase_corr1=0.972, # rev C: shrink the lambda/2 (stage-1) differential arc (openEMS-calibrated)
# --- SMA edge launch (generic footprint for 1.0 mm boards, verify against your connector)
sma_sig_len=2.8, sma_gnd_off=2.9, sma_gnd_w=1.6, sma_gnd_len=3.2,
via_drill=0.4, via_pad=0.8,
# --- mechanics
mh_r=26.0, mh_drill=2.2, mh_clear=3.2,
)
# ----------------------------------------------------------------------------- microstrip (scikit-rf)
def mline(w_mm, f=None):
f = f or P['f0']
fr = rf.Frequency(f, f, 1, unit='Hz')
return rf.media.MLine(fr, w=w_mm*1e-3, h=P['sub_h']*1e-3, t=P['cu_t']*1e-3,
ep_r=P['sub_er'], tand=P['sub_tand'], f_epr_tand=P['f0'],
rough=1.0e-6, z0_port=50)
def ms_props(z):
w = brentq(lambda w: mline(w).z0[0].real - z, 0.05, 8.0)
m = mline(w)
eeff = m.ep_reff_f[0].real
lam = C0/P['f0']/math.sqrt(eeff)*1e3
loss = m.alpha[0]*8.686/1000 # dB/mm
return dict(z=z, w=w, eeff=eeff, lam=lam, loss=loss)
# ----------------------------------------------------------------------------- helpers
def u(a): return np.array([math.cos(a), math.sin(a)])
def seg(p0, p1, w, net='RF'):
return dict(kind='seg', p0=np.array(p0, float), p1=np.array(p1, float), w=w, net=net)
def arc(c, r, a0, a1, w, net='RF'):
return dict(kind='arc', c=np.array(c, float), r=r, a0=a0, a1=a1, w=w, net=net)
def prim_len(p):
return float(np.linalg.norm(p['p1']-p['p0'])) if p['kind'] == 'seg' else abs(p['a1']-p['a0'])*p['r']
def arc_points(p, step=0.05):
n = max(8, int(abs(p['a1']-p['a0'])*p['r']/step))
return [p['c'] + p['r']*u(a) for a in np.linspace(p['a0'], p['a1'], n)]
# ----------------------------------------------------------------------------- build
def build(handed='RHCP'):
Z = dict(z_in=P['z_in'], z_br=P['z_br'], z_out=P['z_out'], z_match=P['z_match'],
z_w1=math.sqrt(2*P['z_in']*P['z_br']), z_w2=math.sqrt(2*P['z_br']*P['z_out']))
ms = {k: ms_props(v) for k, v in Z.items()}
W = {k: ms[k]['w'] for k in ms}
LAM = {k: ms[k]['lam'] for k in ms}
d = P['res_pitch']/2
rp, r1, L, L2 = P['rp'], P['r1'], P['lead_len'], P['lead_len2']
c45 = s45 = math.sqrt(0.5)
prims, notes = [], {}
def ring_R(lam, stem_w):
target = lam/4 + (stem_w/2 if P['junction_comp'] else 0.0)
return brentq(lambda R: R*(math.pi - math.atan(d/R)) - target, 0.5, 12)
def wilkinson(theta, rg, R, w_arm, L, off, w_lead):
assert 2*(d + off - w_lead/2) >= P['min_res_gap'] - 1e-9, 'lead-outs too close across the resistor'
"""Wilkinson ring: resistor gap (inner point) at radius rg, input node at rg+2R (outer point)."""
t = u(theta + math.pi/2)
c = (rg + R)*u(theta)
pc = rg*u(theta)
al = math.atan(d/R)
# CCW-side arm (+t): pi-al -> 0 ; CW-side arm (-t): pi+al -> 2pi (angles relative to theta)
prims.append(arc(c, R, theta + math.pi - al, theta, w_arm))
prims.append(arc(c, R, theta + math.pi + al, theta + 2*math.pi, w_arm))
pads = {s: pc + s*d*t for s in (-1, +1)}
leads = {}
for s in (-1, +1):
p0 = pads[s] + s*off*t
p1 = p0 + L*(s*c45*t - s45*u(theta))
leads[s] = (p0, p1)
return pads, leads, (rg + 2*R)*u(theta), R*(math.pi - al)
# ---- stage 2 geometry (two rings, each feeding two adjacent pins)
lat_e2 = d + P['lead_off2'] + L2*c45
rb2 = math.hypot(lat_e2, r1 - L2*s45)
phi2 = math.atan2(lat_e2, r1 - L2*s45)
lat_e = d + P['lead_off'] + L*c45
dif2 = LAM['z_out']/4*P['phase_corr2']
thA = (math.pi/2 - dif2/rb2)/2 # P1 arc longer than P0 arc by lambda/4 (x phase_corr2)
thB = thA + math.pi
Rw2 = ring_R(LAM['z_w2'], W['z_br'])
r2 = r1 + 2*Rw2
assert thA - phi2 > 0.01, 'stage-2 short output arc collapsed'
res = [] # (ref, centre, angle, value)
for th, k0, ref in ((thA, 0, 'R2'), (thB, 2, 'R3')):
pads, leads, node, arm = wilkinson(th, r1, Rw2, W['z_w2'], L2, P['lead_off2'], W['z_out'])
res.append((ref, (pads[-1] + pads[1])/2, th + math.pi/2, f"{2*P['z_out']:.0f}"))
a0 = k0*math.pi/2
for s, a_end in ((-1, a0), (+1, a0 + math.pi/2)):
p0, p1 = leads[s]
prims.append(seg(p0, p1, W['z_out']))
prims.append(arc((0, 0), rb2, th + s*phi2, a_end, W['z_out']))
notes['w2_arm'] = arm
# pins + radial stubs
pins = [rp*u(k*math.pi/2) for k in range(4)]
rm = rp + P['match_len']
for k, p in enumerate(pins):
prims.append(seg(p, rm*u(k*math.pi/2), W['z_match']))
prims.append(seg(rm*u(k*math.pi/2), rb2*u(k*math.pi/2), W['z_out']))
# ---- stage 1 (splits into branch A -> ring at thA and branch B -> ring at thB)
rb1 = r2 + W['z_w2']/2 + P['branch_clear'] + W['z_br']/2
rg1 = L*s45 + math.sqrt(rb1**2 - lat_e**2)
phi1 = math.atan2(lat_e, rg1 - L*s45)
dif1 = LAM['z_br']/2*P['phase_corr1']
th1 = thA - math.pi/2 + dif1/(2*rb1) # branch B longer by lambda/2 (x phase_corr1)
Rw1 = ring_R(LAM['z_w1'], W['z_in'])
assert thA - (th1 + phi1) > 0.01, 'branch A collapsed'
pads1, leads1, node1, arm1 = wilkinson(th1, rg1, Rw1, W['z_w1'], L, P['lead_off'], W['z_br'])
res.append(('R1', (pads1[-1] + pads1[1])/2, th1 + math.pi/2, f"{2*P['z_br']:.0f}"))
for s, a_end in ((+1, thA), (-1, thB - 2*math.pi)):
p0, p1 = leads1[s]
prims.append(seg(p0, p1, W['z_br']))
prims.append(arc((0, 0), rb1, th1 + s*phi1, a_end, W['z_br']))
for th in (thA, thB): # radial spurs into the stage-2 input nodes
prims.append(seg(rb1*u(th), (r2 - W['z_w2']/2)*u(th), W['z_br']))
notes['w1_arm'] = arm1
# ---- input line + edge SMA
uu, tt = u(th1), u(th1 + math.pi/2)
edge_pt = P['flat_r']*uu
sig_c = edge_pt - (P['edge_pullback'] + P['sma_sig_len']/2)*uu
prims.append(seg(node1, sig_c, W['z_in']))
gnd_c = [edge_pt - (P['edge_pullback'] + P['sma_gnd_len']/2)*uu + s*P['sma_gnd_off']*tt for s in (-1, 1)]
vias = [tuple(g + k*0.9*uu) for g in gnd_c for k in (-1, 0, 1)]
# ---- path lengths (SMA pad centre -> pin) and loss
Lin = float(np.linalg.norm(node1 - sig_c))
Lbr_arc = {'A': rb1*(thA - th1 - phi1), 'B': rb1*(th1 - phi1 - (thB - 2*math.pi))}
Lspur = rb1 - (r2 - W['z_w2']/2)
Lout = {'short': rb2*(thA - phi2), 'long': rb2*(math.pi/2 - thA - phi2)}
Lstub = rb2 - rp - P['match_len']
loss = {k: ms[k]['loss'] for k in ms}
paths = {}
for pin, br, o in ((0, 'A', 'short'), (1, 'A', 'long'), (2, 'B', 'short'), (3, 'B', 'long')):
segs = [('z_in', Lin), ('z_w1', arm1), ('z_br', L + Lbr_arc[br] + Lspur),
('z_w2', notes['w2_arm']), ('z_out', L2 + Lout[o] + Lstub), ('z_match', P['match_len'])]
length = sum(l for _, l in segs)
phase = -sum(360*l/LAM[k] for k, l in segs)
dB = sum(loss[k]*l for k, l in segs)
paths[f'P{pin}'] = dict(length_mm=length, loss_dB=dB, phase_deg=phase)
ph0 = paths['P0']['phase_deg']
for k in paths:
paths[k]['rel_phase_deg'] = (paths[k]['phase_deg'] - ph0 + 180) % 360 - 180
feed = dict(prims=prims, pins=pins, res=res, sma=dict(sig_c=sig_c, gnd_c=gnd_c, u=uu, t=tt, edge=edge_pt),
vias=vias, mh=[P['mh_r']*u(k*math.pi/2) for k in range(4)], th1=th1)
info = dict(Z=Z, microstrip={k: {kk: round(float(vv), 4) for kk, vv in ms[k].items()} for k in ms},
Rw1=Rw1, Rw2=Rw2, r1=r1, rb2=rb2, r2=r2, rb1=rb1, rg1=rg1,
thA_deg=math.degrees(thA), thB_deg=math.degrees(thB), th1_deg=math.degrees(th1),
lengths=dict(input=Lin, w1_arm=arm1, w2_arm=notes['w2_arm'], branchA_arc=Lbr_arc['A'],
branchB_arc=Lbr_arc['B'], spur=Lspur, out_short_arc=Lout['short'],
out_long_arc=Lout['long'], pin_stub=Lstub, lead1=L, lead2=L2),
resistors={r[0]: r[3] + ' ohm' for r in res}, paths=paths)
if handed == 'LHCP':
feed = mirror_feed(feed)
return feed, info
def mirror_feed(f):
"""mirror about the x axis -> reversed phase progression = opposite hand"""
M = lambda p: np.array([p[0], -p[1]])
out = dict(f)
pr = []
for p in f['prims']:
q = dict(p)
if p['kind'] == 'seg':
q['p0'], q['p1'] = M(p['p0']), M(p['p1'])
else:
q['c'], q['a0'], q['a1'] = M(p['c']), -p['a0'], -p['a1']
pr.append(q)
out['prims'] = pr
out['pins'] = [M(p) for p in f['pins']]
out['res'] = [(r, M(c), -a, v) for r, c, a, v in f['res']]
s = f['sma']
out['sma'] = dict(sig_c=M(s['sig_c']), gnd_c=[M(g) for g in s['gnd_c']], u=M(s['u']), t=M(s['t']), edge=M(s['edge']))
out['vias'] = [tuple(M(v)) for v in f['vias']]
out['mh'] = [M(p) for p in f['mh']]
out['th1'] = -f['th1']
return out
if __name__ == '__main__':
import json
f, info = build('RHCP')
print(json.dumps(info, indent=1, default=float))