Add IFA/meander/stub antenna templates with SVG and KiCad export.

Progetta now returns a parametric radiator geometry (segments + preview + .kicad_mod) so the layout can be replicated without inventing EM results.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-13 19:27:06 +02:00
co-authored by Cursor
parent 1d3cf12482
commit 556306bf4d
9 changed files with 529 additions and 8 deletions
+321
View File
@@ -0,0 +1,321 @@
"""Parametric PCB antenna templates → segments, SVG, KiCad footprint.
Templates (IFA / meander / stub) use a documented λ/4 electrical length with
εeff≈(εr+1)/2. This is a routing-first drawing aid — not an EM / VSWR result.
"""
from __future__ import annotations
import math
from typing import Literal
from pydantic import BaseModel, Field
_C_MPS = 299_792_458.0
AntennaTemplate = Literal["ifa", "meander", "stub"]
FitStatus = Literal["ok", "scaled", "overflow", "need_f0"]
_NOTE = (
"Parametric template from λ/4 (εeff≈(εr+1)/2) — routing aid only, "
"not an EM / VSWR result. Tune matching on the board."
)
class AntennaSegment(BaseModel):
points: list[tuple[float, float]] # local mm, origin = feed
width_mm: float
class AntennaGeometry(BaseModel):
template: AntennaTemplate
fit: FitStatus
segments: list[AntennaSegment] = Field(default_factory=list)
total_length_mm: float | None = None
length_ideal_mm: float | None = None
scale: float = 1.0
svg: str | None = None
kicad_mod: str | None = None
footprint_name: str | None = None
note: str = _NOTE
detail: str = ""
def quarter_wave_mm(f0_mhz: float, er: float) -> float:
"""Electrical λ/4 in mm using εeff≈(εr+1)/2."""
eeff = (er + 1.0) / 2.0
f_hz = f0_mhz * 1e6
return (_C_MPS / (4.0 * f_hz * math.sqrt(eeff))) * 1e3
def build_geometry(
template: AntennaTemplate,
*,
f0_mhz: float | None,
w_mm: float,
er: float,
zone_bbox_mm: tuple[float, float, float, float] | None = None,
feed_xy: tuple[float, float] | None = None,
) -> AntennaGeometry:
if f0_mhz is None or f0_mhz <= 0:
return AntennaGeometry(
template=template,
fit="need_f0",
detail="Set f0 (MHz) to generate radiator geometry.",
)
if w_mm <= 0:
return AntennaGeometry(
template=template,
fit="overflow",
detail="Feed width w_mm must be > 0.",
)
ideal = quarter_wave_mm(f0_mhz, er)
segs_local, length = _template_segments(template, ideal, w_mm)
fit: FitStatus = "ok"
scale = 1.0
detail = f"{template.upper()} template at {f0_mhz:g} MHz."
avail = _available_span(zone_bbox_mm, feed_xy)
if avail is not None:
need_w, need_h = _bbox_size(segs_local)
free_w, free_h = avail
max_span = max(free_w, free_h)
need_span = max(need_w, need_h)
if need_span > max_span + 1e-6 and max_span > 0:
scale = max_span / need_span
min_scale = 0.45
if scale < min_scale:
return AntennaGeometry(
template=template,
fit="overflow",
length_ideal_mm=round(ideal, 2),
total_length_mm=None,
scale=round(scale, 4),
detail=(
f"Zone too small for {template.upper()} "
f"(need ~{need_span:.1f} mm, have {max_span:.1f} mm)."
),
)
segs_local = _scale_segments(segs_local, scale)
length *= scale
fit = "scaled"
detail = (
f"Scaled to {scale:.2f}× to fit antenna zone "
f"({max_span:.1f} mm free). Retune matching."
)
name = f"Antenna_{template.upper()}_{int(round(f0_mhz))}"
svg = _segments_to_svg(segs_local, w_mm)
mod = _segments_to_kicad_mod(name, segs_local, w_mm, template)
return AntennaGeometry(
template=template,
fit=fit,
segments=segs_local,
total_length_mm=round(length, 2),
length_ideal_mm=round(ideal, 2),
scale=round(scale, 4),
svg=svg,
kicad_mod=mod,
footprint_name=name,
detail=detail,
)
def _template_segments(
template: AntennaTemplate,
length_mm: float,
w_mm: float,
) -> tuple[list[AntennaSegment], float]:
if template == "ifa":
return _ifa(length_mm, w_mm)
if template == "meander":
return _meander(length_mm, w_mm)
return _stub(length_mm, w_mm)
def _ifa(length_mm: float, w_mm: float) -> tuple[list[AntennaSegment], float]:
"""Inverted-F: shorting stub + horizontal arm; feed on the arm at origin.
Local: feed (0,0) on the arm. Shorting at x=-d toward -Y (GND edge).
Arm runs to +X. Proportions: stub ≈ 0.12 L, feed offset ≈ 0.15 L.
"""
L = max(length_mm, 4.0 * w_mm)
stub_h = max(0.12 * L, 2.0 * w_mm)
d = max(0.15 * L, 2.0 * w_mm)
open_x = L - d
segs = [
AntennaSegment(points=[(-d, 0.0), (-d, -stub_h)], width_mm=w_mm),
AntennaSegment(points=[(-d, 0.0), (open_x, 0.0)], width_mm=w_mm),
]
path = stub_h + L
return segs, path
def _meander(length_mm: float, w_mm: float) -> tuple[list[AntennaSegment], float]:
"""Serpentine that consumes ~length_mm inside a compact bbox."""
pitch = max(3.0 * w_mm, 1.2)
run = max(length_mm / 6.0, 4.0 * w_mm)
pts: list[tuple[float, float]] = [(0.0, 0.0)]
x = 0.0
y = 0.0
going_up = True
consumed = 0.0
target = max(length_mm, 4.0 * w_mm)
guard = 0
while consumed < target - 1e-6 and guard < 80:
guard += 1
dy = run if going_up else -run
remain = target - consumed
if remain < abs(dy):
dy = math.copysign(remain, dy)
y2 = y + dy
pts.append((x, y2))
consumed += abs(dy)
y = y2
if consumed >= target - 1e-6:
break
remain = target - consumed
dx = min(pitch, remain)
x2 = x + dx
pts.append((x2, y))
consumed += dx
x = x2
going_up = not going_up
segs = [AntennaSegment(points=pts, width_mm=w_mm)]
return segs, consumed
def _stub(length_mm: float, w_mm: float) -> tuple[list[AntennaSegment], float]:
"""Open L-stub monopole: short vertical then horizontal arm."""
L = max(length_mm, 4.0 * w_mm)
h = max(0.2 * L, 2.0 * w_mm)
arm = max(L - h, 2.0 * w_mm)
segs = [
AntennaSegment(points=[(0.0, 0.0), (0.0, -h)], width_mm=w_mm),
AntennaSegment(points=[(0.0, -h), (arm, -h)], width_mm=w_mm),
]
return segs, h + arm
def _available_span(
zone_bbox: tuple[float, float, float, float] | None,
feed_xy: tuple[float, float] | None,
) -> tuple[float, float] | None:
"""Free width/height from feed into the zone (mm)."""
if zone_bbox is None or feed_xy is None:
return None
xmin, ymin, xmax, ymax = zone_bbox
fx, fy = feed_xy
fx = min(max(fx, xmin), xmax)
fy = min(max(fy, ymin), ymax)
free_w = max(fx - xmin, xmax - fx)
free_h = max(fy - ymin, ymax - fy)
return free_w, free_h
def _bbox_size(segs: list[AntennaSegment]) -> tuple[float, float]:
xs: list[float] = []
ys: list[float] = []
for s in segs:
for x, y in s.points:
xs.append(x)
ys.append(y)
if not xs:
return 0.0, 0.0
return max(xs) - min(xs), max(ys) - min(ys)
def _scale_segments(
segs: list[AntennaSegment], scale: float,
) -> list[AntennaSegment]:
out: list[AntennaSegment] = []
for s in segs:
out.append(
AntennaSegment(
points=[(x * scale, y * scale) for x, y in s.points],
width_mm=s.width_mm,
)
)
return out
def _segments_to_svg(segs: list[AntennaSegment], default_w: float) -> str:
xs: list[float] = []
ys: list[float] = []
for s in segs:
for x, y in s.points:
xs.append(x)
ys.append(y)
if not xs:
return '<svg xmlns="http://www.w3.org/2000/svg" width="120" height="80"/>'
pad = max(default_w * 2, 1.0)
xmin, xmax = min(xs) - pad, max(xs) + pad
ymin, ymax = min(ys) - pad, max(ys) + pad
bw = max(xmax - xmin, 1e-3)
bh = max(ymax - ymin, 1e-3)
paths: list[str] = []
for s in segs:
if len(s.points) < 2:
continue
d_parts = []
for i, (x, y) in enumerate(s.points):
cmd = "M" if i == 0 else "L"
d_parts.append(f"{cmd}{x:.3f},{-y:.3f}")
sw = s.width_mm
paths.append(
f'<path d="{" ".join(d_parts)}" fill="none" stroke="#1a1a1a" '
f'stroke-width="{sw:.3f}" stroke-linecap="round" '
f'stroke-linejoin="round"/>'
)
paths.append(
f'<circle cx="0" cy="0" r="{max(default_w, 0.3):.3f}" fill="#c45c26"/>'
)
vb = f"{xmin:.3f} {-ymax:.3f} {bw:.3f} {bh:.3f}"
body = "\n ".join(paths)
return (
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="{vb}" '
f'width="280" height="160" style="background:#f7f5f2">'
f"\n {body}\n</svg>"
)
def _segments_to_kicad_mod(
name: str,
segs: list[AntennaSegment],
w_mm: float,
template: AntennaTemplate,
) -> str:
lines = [
f'(footprint "{name}"',
" (version 20240108)",
' (generator "pinscope")',
' (layer "F.Cu")',
f' (descr "Pinscope {template.upper()} PCB antenna template '
f'— not EM-validated")',
" (attr smd)",
f' (pad "1" smd circle (at 0 0) (size {w_mm * 2:.4f} {w_mm * 2:.4f}) '
f'(layers "F.Cu") (uuid 00000000-0000-4000-8000-000000000001))',
]
if template == "ifa" and segs:
tip = segs[0].points[-1]
lines.append(
f' (pad "2" smd circle (at {tip[0]:.4f} {tip[1]:.4f}) '
f"(size {w_mm * 2:.4f} {w_mm * 2:.4f}) "
f'(layers "F.Cu") (uuid 00000000-0000-4000-8000-000000000002))'
)
uid = 10
for s in segs:
pts = s.points
for i in range(len(pts) - 1):
x1, y1 = pts[i]
x2, y2 = pts[i + 1]
lines.append(
f" (fp_line (start {x1:.4f} {y1:.4f}) (end {x2:.4f} {y2:.4f}) "
f"(stroke (width {s.width_mm:.4f}) (type default)) "
f'(layer "F.Cu") (uuid 00000000-0000-4000-8000-{uid:012d}))'
)
uid += 1
lines.append(")")
return "\n".join(lines) + "\n"
+37 -3
View File
@@ -2,9 +2,10 @@
Verify: matching topology from IC ANT/RF pin toward ANT footprint / ANT_FEED.
Design: KiCad marker (ANT* footprint or ANT_FEED/RF_ANT net) → microstrip w
for target Z0 from stackup; optional λ/4 length if f0_mhz is given.
for target Z0 from stackup; optional λ/4 length if f0_mhz is given;
parametric IFA / meander / stub geometry (segments + SVG + .kicad_mod).
No EM/VSWR. No CPWG clearance. Length suggestion is a documented estimate only.
No EM/VSWR. No CPWG clearance. Geometry is a documented routing template only.
"""
from __future__ import annotations
@@ -15,6 +16,11 @@ from typing import Any, Literal
from pydantic import BaseModel
from backend.pinscopex.antenna_geometry import (
AntennaGeometry,
AntennaTemplate,
build_geometry,
)
from backend.pinscopex.impedance import GeometryError, solve_width
from backend.pinscopex.models import (
ComponentType,
@@ -87,6 +93,7 @@ class AntennaDesignRecipe(BaseModel):
feed_point: dict[str, Any] | None = None
feed_line: AntennaFeedLine | None = None
radiator: AntennaRadiator | None = None
geometry: AntennaGeometry | None = None
zone: AntennaZoneInfo | None = None
keepout_checklist: list[str] = []
detail: str = ""
@@ -111,6 +118,7 @@ def build_antenna_report(
h_mm: float | None = None,
er: float | None = None,
t_mm: float | None = None,
template: AntennaTemplate = "ifa",
) -> AntennaReport:
verify = _verify(graph, layout, impedance_nets, target_z_ohm)
design = build_design_recipe(
@@ -118,6 +126,7 @@ def build_antenna_report(
f0_mhz=f0_mhz,
target_z_ohm=target_z_ohm,
h_mm=h_mm,
template=template,
er=er,
t_mm=t_mm,
)
@@ -133,6 +142,7 @@ def build_design_recipe(
h_mm: float | None = None,
er: float | None = None,
t_mm: float | None = None,
template: AntennaTemplate = "ifa",
) -> AntennaDesignRecipe:
marker = _find_marker(graph, layout)
zone = _find_antenna_zone(layout)
@@ -141,6 +151,8 @@ def build_design_recipe(
"Short GND return from the matching network to the RF reference.",
"Avoid long stubs and right angles on the 50 Ω feed.",
"Place matching parts close to the RF pin / feed point.",
"IFA pad 2 (shorting tip) must connect to RF ground / pour edge.",
"Place the footprint with feed (pad 1) on the ANT* / ANT_FEED join.",
]
stack = _resolve_stackup(layout, h_mm=h_mm, er=er, t_mm=t_mm)
@@ -197,14 +209,36 @@ def build_design_recipe(
f0_mhz=f0_mhz,
)
feed_xy = None
if marker.get("x") is not None and marker.get("y") is not None:
feed_xy = (float(marker["x"]), float(marker["y"]))
zone_bbox = zone.bbox_mm if zone else None
geometry = build_geometry(
template,
f0_mhz=f0_mhz,
w_mm=float(feed.w_mm or 0),
er=er_v,
zone_bbox_mm=zone_bbox,
feed_xy=feed_xy,
)
detail = "Recipe ready — feed at w_mm; geometry is a parametric template (not EM)."
if geometry.fit == "need_f0":
detail = "Feed w ready — set f0 to generate IFA / meander / stub geometry."
elif geometry.fit == "scaled":
detail = geometry.detail
elif geometry.fit == "overflow":
detail = geometry.detail
return AntennaDesignRecipe(
status="ready",
feed_point=marker,
feed_line=feed,
radiator=radiator,
geometry=geometry,
zone=zone,
keepout_checklist=checklist,
detail="Recipe ready — draw the feed at w_mm; radiator length is an estimate only.",
detail=detail,
)
+3
View File
@@ -133,6 +133,7 @@ class AntennaDesignRequest(BaseModel):
h: float | None = None
er: float | None = None
t: float | None = None
template: Literal["ifa", "meander", "stub"] = "ifa"
def _load_graph_layout(storage, prefix: str) -> tuple[DesignGraph | None, LayoutGraph | None, dict | None]:
@@ -190,6 +191,7 @@ async def post_project_antenna_design(
h_mm=body.h,
er=body.er,
t_mm=body.t,
template=body.template,
)
# Recompute design with explicit params (same as report.design but ensure POST body wins)
report.design = build_design_recipe(
@@ -200,5 +202,6 @@ async def post_project_antenna_design(
h_mm=body.h,
er=body.er,
t_mm=body.t,
template=body.template,
)
return report.model_dump(mode="json")