Files
periscope/backend/pinscopex/antenna_rf.py
T
micheleandCursor 1d3cf12482 Add RF antenna verify and design recipe to Impedance tab.
Detect matching toward ANT*/ANT_FEED, compute 50 Ω feed width from stackup, and optional λ/4 length from f0 — before auto-draw or F2 packing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-13 18:57:59 +02:00

508 lines
16 KiB
Python

"""RF antenna verify + design recipe (schema + optional PCB).
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.
No EM/VSWR. No CPWG clearance. Length suggestion is a documented estimate only.
"""
from __future__ import annotations
import math
import re
from typing import Any, Literal
from pydantic import BaseModel
from backend.pinscopex.impedance import GeometryError, solve_width
from backend.pinscopex.models import (
ComponentType,
DesignGraph,
LayoutGraph,
)
_C_MPS = 299_792_458.0
_ANT_PIN_RE = re.compile(
r"(?:^|[_/\-])(ANT|ANTENNA|RF(?:IO|OUT|IN)?|RF_OUT|RF_IN|LNA|TX|RX)(?:$|[_/\-\d])",
re.IGNORECASE,
)
_FEED_NET_RE = re.compile(
r"^(?:ANT_FEED|ANTENNA_FEED)$",
re.IGNORECASE,
)
_ZONE_NET_RE = re.compile(
r"(?:^|[_/\-])(antenna|ant_zone|rf_antenna)(?:$|[_/\-])",
re.IGNORECASE,
)
Topology = Literal[
"direct", "series_L", "LC", "pi", "T", "unknown", "missing",
]
Status = Literal["ok", "warning", "info"]
DesignStatus = Literal["ready", "need_pcb", "need_stackup", "need_marker"]
class AntennaVerifyRow(BaseModel):
ic_ref: str
pin: str
net: str
topology: Topology
parts: list[str] = []
target_z_ohm: float = 50.0
status: Status = "info"
detail: str = ""
feed_z0: float | None = None
feed_length_mm: float | None = None
marker_ref: str | None = None
class AntennaFeedLine(BaseModel):
kind: str = "microstrip"
target_z_ohm: float = 50.0
w_mm: float | None = None
h_mm: float | None = None
er: float | None = None
t_mm: float | None = None
class AntennaRadiator(BaseModel):
length_mm_suggest: float | None = None
f0_mhz: float | None = None
note: str = (
"λ/4 estimate using εeff≈(εr+1)/2 — routing-first only, not an EM result."
)
class AntennaZoneInfo(BaseModel):
net: str
layer: str
bbox_mm: tuple[float, float, float, float] | None = None # xmin,ymin,xmax,ymax
area_mm2: float | None = None
class AntennaDesignRecipe(BaseModel):
status: DesignStatus
feed_point: dict[str, Any] | None = None
feed_line: AntennaFeedLine | None = None
radiator: AntennaRadiator | None = None
zone: AntennaZoneInfo | None = None
keepout_checklist: list[str] = []
detail: str = ""
class AntennaReport(BaseModel):
verify: list[AntennaVerifyRow] = []
design: AntennaDesignRecipe | None = None
marker_help: str = (
"Mark the feed join in KiCad: footprint Ref starting with ANT, "
"or net named ANT_FEED / RF_ANT. Optional zone net 'antenna' for the canvas."
)
def build_antenna_report(
graph: DesignGraph,
layout: LayoutGraph | None = None,
*,
impedance_nets: dict | None = None,
f0_mhz: float | None = None,
target_z_ohm: float = 50.0,
h_mm: float | None = None,
er: float | None = None,
t_mm: float | None = None,
) -> AntennaReport:
verify = _verify(graph, layout, impedance_nets, target_z_ohm)
design = build_design_recipe(
graph, layout,
f0_mhz=f0_mhz,
target_z_ohm=target_z_ohm,
h_mm=h_mm,
er=er,
t_mm=t_mm,
)
return AntennaReport(verify=verify, design=design)
def build_design_recipe(
graph: DesignGraph,
layout: LayoutGraph | None = None,
*,
f0_mhz: float | None = None,
target_z_ohm: float = 50.0,
h_mm: float | None = None,
er: float | None = None,
t_mm: float | None = None,
) -> AntennaDesignRecipe:
marker = _find_marker(graph, layout)
zone = _find_antenna_zone(layout)
checklist = [
"Keep copper / pours out of the antenna keepout unless the antenna datasheet allows it.",
"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.",
]
stack = _resolve_stackup(layout, h_mm=h_mm, er=er, t_mm=t_mm)
if marker is None and layout is None:
return AntennaDesignRecipe(
status="need_pcb",
zone=zone,
keepout_checklist=checklist,
detail="Upload a .kicad_pcb (and mark ANT* / ANT_FEED) to compute feed width.",
)
if marker is None:
return AntennaDesignRecipe(
status="need_marker",
zone=zone,
keepout_checklist=checklist,
detail="No ANT* footprint or ANT_FEED/RF_ANT net found.",
)
if stack is None:
return AntennaDesignRecipe(
status="need_stackup",
feed_point=marker,
zone=zone,
keepout_checklist=checklist,
detail="PCB stackup missing εr/h — set stackup in KiCad or pass h/er in the request.",
)
h, er_v, t = stack
try:
w = solve_width("microstrip", target_z_ohm, h, er_v, t, s=None)
except GeometryError as exc:
return AntennaDesignRecipe(
status="need_stackup",
feed_point=marker,
zone=zone,
keepout_checklist=checklist,
detail=str(exc),
)
feed = AntennaFeedLine(
kind="microstrip",
target_z_ohm=target_z_ohm,
w_mm=round(w, 4),
h_mm=h,
er=er_v,
t_mm=t,
)
radiator = None
if f0_mhz is not None and f0_mhz > 0:
eeff = (er_v + 1.0) / 2.0
f_hz = f0_mhz * 1e6
length_m = _C_MPS / (4.0 * f_hz * math.sqrt(eeff))
radiator = AntennaRadiator(
length_mm_suggest=round(length_m * 1e3, 2),
f0_mhz=f0_mhz,
)
return AntennaDesignRecipe(
status="ready",
feed_point=marker,
feed_line=feed,
radiator=radiator,
zone=zone,
keepout_checklist=checklist,
detail="Recipe ready — draw the feed at w_mm; radiator length is an estimate only.",
)
def _verify(
graph: DesignGraph,
layout: LayoutGraph | None,
impedance_nets: dict | None,
target_z: float,
) -> list[AntennaVerifyRow]:
z_by_net = _z0_index(impedance_nets)
rows: list[AntennaVerifyRow] = []
for ref, comp in sorted(graph.components.items()):
if comp.component_type != ComponentType.IC:
continue
for pin_num, net in comp.pins.items():
if not net or not _looks_rf_pin(graph, ref, pin_num, net, comp.component_subtype):
continue
topo, parts, marker, detail, status = _classify_path(graph, ref, net)
z0, length = None, None
if net in z_by_net:
z0 = z_by_net[net].get("z0_avg_ohms")
length = z_by_net[net].get("length_mm")
elif marker and marker.get("net") and marker["net"] in z_by_net:
info = z_by_net[marker["net"]]
z0 = info.get("z0_avg_ohms")
length = info.get("length_mm")
rows.append(AntennaVerifyRow(
ic_ref=ref,
pin=str(pin_num),
net=net,
topology=topo,
parts=parts,
target_z_ohm=target_z,
status=status,
detail=detail,
feed_z0=z0,
feed_length_mm=length,
marker_ref=marker.get("ref") if marker else None,
))
return rows
def _looks_rf_pin(
graph: DesignGraph,
ref: str,
pin_num: str,
net: str,
subtype: str | None,
) -> bool:
if _FEED_NET_RE.match(net or ""):
return True
if _ANT_PIN_RE.search(net or ""):
return True
# Pin name from netlist is often just the net; subtype helps for modules.
sub = (subtype or "").lower()
if sub.startswith("ic.rf") and _ANT_PIN_RE.search(net or ""):
return True
if sub.startswith("ic.rf"):
# Common module pad names appear as nets
u = (net or "").upper()
if any(k in u for k in ("ANT", "RF", "LNA", "WIFI")):
return True
return bool(_ANT_PIN_RE.search(str(pin_num)))
def _classify_path(
graph: DesignGraph,
ic_ref: str,
start_net: str,
) -> tuple[Topology, list[str], dict | None, str, Status]:
"""BFS a few hops of passives toward ANT marker / connector."""
marker = _marker_on_net(graph, start_net)
if marker:
return "direct", [], marker, "Feed net is the antenna marker.", "ok"
parts: list[str] = []
kinds: list[str] = []
visited_nets = {start_net}
frontier = [start_net]
found_marker: dict | None = None
found_connector = False
for _ in range(4):
next_frontier: list[str] = []
for net in frontier:
for cref in _passives_on_net(graph, net):
if cref in parts:
continue
other = graph.components[cref]
ctype = other.component_type
if ctype == ComponentType.CONNECTOR:
found_connector = True
parts.append(cref)
continue
if cref.upper().startswith("ANT"):
found_marker = {"ref": cref, "net": net, "kind": "footprint"}
parts.append(cref)
continue
if ctype not in (
ComponentType.RESISTOR,
ComponentType.CAPACITOR,
ComponentType.INDUCTOR,
):
continue
parts.append(cref)
if ctype == ComponentType.INDUCTOR:
kinds.append("L")
elif ctype == ComponentType.CAPACITOR:
kinds.append("C")
elif ctype == ComponentType.RESISTOR:
kinds.append("R")
for n2 in other.pins.values():
if not n2 or n2 in visited_nets:
continue
visited_nets.add(n2)
next_frontier.append(n2)
m = _marker_on_net(graph, n2)
if m:
found_marker = m
frontier = next_frontier
if found_marker or (found_connector and not frontier):
break
if found_marker or found_connector:
topo = _topo_from_kinds(kinds)
who = found_marker.get("ref") if found_marker else "connector"
return topo, parts, found_marker, f"Path to {who}: {topo}.", "ok"
if parts:
return (
"unknown",
parts,
None,
"Passives on RF net but no ANT* / ANT_FEED / connector reached.",
"warning",
)
return (
"missing",
[],
None,
"No matching network found between RF pin and antenna marker.",
"warning",
)
def _topo_from_kinds(kinds: list[str]) -> Topology:
s = "".join(kinds)
if not s:
return "direct"
if s in ("L",):
return "series_L"
if s in ("LC", "CL"):
return "LC"
if s.count("C") >= 2 and "L" in s:
return "pi"
if s.count("L") >= 2 and "C" in s:
return "T"
if "L" in s and "C" in s:
return "LC"
if "L" in s:
return "series_L"
return "unknown"
def _passives_on_net(graph: DesignGraph, net: str) -> list[str]:
out: list[str] = []
net_obj = graph.nets.get(net)
if not net_obj:
return out
for pc in net_obj.pins:
cref = pc.component_ref
comp = graph.components.get(cref)
if not comp or comp.component_type == ComponentType.IC:
continue
out.append(cref)
return sorted(set(out))
def _marker_on_net(graph: DesignGraph, net: str) -> dict | None:
if _FEED_NET_RE.match(net or ""):
return {"ref": None, "net": net, "kind": "net"}
# Dedicated join alias only when an ANT* part sits on the net.
for cref in _passives_on_net(graph, net):
if cref.upper().startswith("ANT"):
return {"ref": cref, "net": net, "kind": "footprint"}
comp = graph.components[cref]
if comp.component_type == ComponentType.CONNECTOR and (
cref.upper().startswith("ANT")
or _ANT_PIN_RE.search((comp.value or "") + cref)
):
return {"ref": cref, "net": net, "kind": "connector"}
return None
def _find_marker(graph: DesignGraph, layout: LayoutGraph | None) -> dict | None:
# Prefer layout footprints ANT*
if layout:
for ref, fp in sorted(layout.footprints.items()):
if ref.upper().startswith("ANT"):
net = next((p.net for p in fp.pads if p.net), None)
return {
"ref": ref,
"net": net,
"kind": "footprint",
"x": fp.x,
"y": fp.y,
"layer": fp.layer,
}
for net_name in layout.nets:
if _FEED_NET_RE.match(net_name) or net_name.upper() == "RF_ANT":
# RF_ANT as board join only if ANT* footprint uses it
if net_name.upper() == "RF_ANT":
if not any(
r.upper().startswith("ANT")
for r, fp in layout.footprints.items()
if any(p.net == net_name for p in fp.pads)
):
continue
return {"ref": None, "net": net_name, "kind": "net"}
for ref, comp in sorted(graph.components.items()):
if ref.upper().startswith("ANT"):
nets = [n for n in comp.pins.values() if n]
return {
"ref": ref,
"net": nets[0] if nets else None,
"kind": "footprint",
}
for net in comp.pins.values():
if net and _FEED_NET_RE.match(net):
return {"ref": ref if comp.component_type != ComponentType.IC else None,
"net": net, "kind": "net"}
return None
def _find_antenna_zone(layout: LayoutGraph | None) -> AntennaZoneInfo | None:
if not layout:
return None
for z in layout.zones:
if not _ZONE_NET_RE.search(z.net or ""):
continue
bbox, area = _outline_metrics(z.outlines)
return AntennaZoneInfo(
net=z.net,
layer=z.layer,
bbox_mm=bbox,
area_mm2=area,
)
return None
def _outline_metrics(
outlines: list[list[tuple[float, float]]],
) -> tuple[tuple[float, float, float, float] | None, float | None]:
pts: list[tuple[float, float]] = []
for ring in outlines:
pts.extend(ring)
if len(pts) < 3:
return None, None
xs = [p[0] for p in pts]
ys = [p[1] for p in pts]
bbox = (min(xs), min(ys), max(xs), max(ys))
# Shoelace on first ring only
ring = outlines[0]
area = 0.0
for i in range(len(ring)):
x1, y1 = ring[i]
x2, y2 = ring[(i + 1) % len(ring)]
area += x1 * y2 - x2 * y1
return bbox, abs(area) / 2.0
def _resolve_stackup(
layout: LayoutGraph | None,
*,
h_mm: float | None,
er: float | None,
t_mm: float | None,
) -> tuple[float, float, float] | None:
if h_mm and er and h_mm > 0 and er > 0:
return float(h_mm), float(er), float(t_mm or 0.035)
if not layout or not layout.stackup or not layout.stackup.dielectrics:
return None
d = layout.stackup.dielectrics[0]
if d.height_mm <= 0 or d.er <= 0:
return None
t = layout.stackup.copper_thickness_mm
return float(d.height_mm), float(d.er), float(t if t and t > 0 else 0.035)
def _z0_index(impedance_nets: dict | None) -> dict[str, dict]:
if not impedance_nets:
return {}
rows = impedance_nets.get("nets") or []
out: dict[str, dict] = {}
for row in rows:
name = row.get("net_name") or row.get("net")
if name:
out[str(name)] = row
return out