Rebrand Pinscope to Periscope across product and codebase.
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>
This commit is contained in:
@@ -0,0 +1,541 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user