R reuter /

QFP58

CC BY-SA 4.0 Native

Vibe engineered circular polarized PCB antenna, inspired by the Triple Feed Patch.

Rendered
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
QFP58-feed-RHCP-drc.rpt 553 B
README.md 15.5 KB
run.sh 4.2 KB
uv.lock 189 KB
docs/
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
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
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
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
kicad/QFP58-patch/
fp-lib-table 149 B
QFP58-patch-drc.rpt 576 B
kicad/QFP58-patch-d23.06/
fp-lib-table 149 B
QFP58-patch-d23.06-drc.rpt 528 B
kicad/QFP58-patch-d23.36/
fp-lib-table 149 B
QFP58-patch-d23.36-drc.rpt 528 B
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.png 30.3 KB
summary.json 1.5 KB
sim/network/
network_sparams.png 45.0 KB
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
qfp_geom.py 11.6 KB
qfp_sim.py 27.4 KB
src/qfp_geom.py
#!/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))