Rename the core package to periscopex, update UI/docs/Docker/deploy defaults to periscope.michelebigi.it, and keep legacy version/storage key aliases so existing projects keep working. Co-authored-by: Cursor <cursoragent@cursor.com>
542 lines
17 KiB
Python
542 lines
17 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;
|
|
parametric IFA / meander / stub geometry (segments + SVG + .kicad_mod).
|
|
|
|
No EM/VSWR. No CPWG clearance. Geometry is a documented routing template only.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
import re
|
|
from typing import Any, Literal
|
|
|
|
from pydantic import BaseModel
|
|
|
|
from backend.periscopex.antenna_geometry import (
|
|
AntennaGeometry,
|
|
AntennaTemplate,
|
|
build_geometry,
|
|
)
|
|
from backend.periscopex.impedance import GeometryError, solve_width
|
|
from backend.periscopex.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
|
|
geometry: AntennaGeometry | 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,
|
|
template: AntennaTemplate = "ifa",
|
|
) -> 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,
|
|
template=template,
|
|
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,
|
|
template: AntennaTemplate = "ifa",
|
|
) -> 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.",
|
|
"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)
|
|
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,
|
|
)
|
|
|
|
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=detail,
|
|
)
|
|
|
|
|
|
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
|