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>
This commit is contained in:
2026-09-13 18:57:59 +02:00
co-authored by Cursor
parent 68af0fd432
commit 1d3cf12482
9 changed files with 1055 additions and 29 deletions
+507
View File
@@ -0,0 +1,507 @@
"""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
+79
View File
@@ -25,6 +25,8 @@ from backend.pinscopex.impedance_traces import (
NET_WALK_PITCH_MM, NET_WALK_PITCH_MM,
analyze_specified_nets, analyze_specified_nets,
) )
from backend.pinscopex.antenna_rf import build_antenna_report, build_design_recipe
from backend.pinscopex.models import DesignGraph, LayoutGraph
from backend.pinscopex.parsers_kicad_pcb import parse_kicad_pcb from backend.pinscopex.parsers_kicad_pcb import parse_kicad_pcb
from backend.routers.deps import get_storage, resolve_or_404 from backend.routers.deps import get_storage, resolve_or_404
from backend.services import projects as proj_svc from backend.services import projects as proj_svc
@@ -123,3 +125,80 @@ async def analyze_project_impedance_nets(
except GeometryError as exc: except GeometryError as exc:
raise HTTPException(400, str(exc)) from exc raise HTTPException(400, str(exc)) from exc
return {"pitch_mm": pitch, "nets": rows, "skipped": None} return {"pitch_mm": pitch, "nets": rows, "skipped": None}
class AntennaDesignRequest(BaseModel):
f0_mhz: float | None = None
target_z_ohm: float = 50.0
h: float | None = None
er: float | None = None
t: float | None = None
def _load_graph_layout(storage, prefix: str) -> tuple[DesignGraph | None, LayoutGraph | None, dict | None]:
graph = None
layout = None
znets = None
gk = f"{prefix}/design_graph.json"
if storage.exists(gk):
graph = DesignGraph.model_validate(storage.read_json(gk))
lk = f"{prefix}/layout_graph.json"
if storage.exists(lk):
layout = LayoutGraph.model_validate(storage.read_json(lk))
elif storage.exists(f"{prefix}/uploads/pcb.kicad_pcb"):
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".kicad_pcb")
try:
tmp.write(storage.read_bytes(f"{prefix}/uploads/pcb.kicad_pcb"))
tmp.close()
layout = parse_kicad_pcb(tmp.name)
finally:
os.unlink(tmp.name)
zk = f"{prefix}/impedance_nets.json"
if storage.exists(zk):
znets = storage.read_json(zk)
return graph, layout, znets
@router.get("/projects/{project_id}/antenna")
async def get_project_antenna(project_id: str, request: Request):
storage = get_storage(request)
owner, _ = await resolve_or_404(request, project_id)
prefix = proj_svc.project_prefix(owner, project_id)
graph, layout, znets = _load_graph_layout(storage, prefix)
if graph is None:
raise HTTPException(404, "design_graph.json not found — run analysis first")
report = build_antenna_report(graph, layout, impedance_nets=znets)
return report.model_dump(mode="json")
@router.post("/projects/{project_id}/antenna/design")
async def post_project_antenna_design(
project_id: str, body: AntennaDesignRequest, request: Request,
):
storage = get_storage(request)
owner, _ = await resolve_or_404(request, project_id)
prefix = proj_svc.project_prefix(owner, project_id)
graph, layout, znets = _load_graph_layout(storage, prefix)
if graph is None:
raise HTTPException(404, "design_graph.json not found — run analysis first")
report = build_antenna_report(
graph,
layout,
impedance_nets=znets,
f0_mhz=body.f0_mhz,
target_z_ohm=body.target_z_ohm,
h_mm=body.h,
er=body.er,
t_mm=body.t,
)
# Recompute design with explicit params (same as report.design but ensure POST body wins)
report.design = build_design_recipe(
graph,
layout,
f0_mhz=body.f0_mhz,
target_z_ohm=body.target_z_ohm,
h_mm=body.h,
er=body.er,
t_mm=body.t,
)
return report.model_dump(mode="json")
+5 -4
View File
@@ -159,7 +159,7 @@ Passi:
| 4 Filtri | `check_filters` (solo con poli/numeri in specs) | Niente \(f_c\) inventata | **OK** | | 4 Filtri | `check_filters` (solo con poli/numeri in specs) | Niente \(f_c\) inventata | **OK** |
| 5 Capacità PI | Decoupling sulla net; derating V; DC-bias/ESR se c’è il numero; mm sul PCB (`PS-PLC-001`) | — | **OK** | | 5 Capacità PI | Decoupling sulla net; derating V; DC-bias/ESR se c’è il numero; mm sul PCB (`PS-PLC-001`) | — | **OK** |
| 6 Elettrico | Pin mux; I2C/reset pull-up; LED; sequencing/IR/power margin se c’è il parametro | Senza numero in specs → skip | **OK** | | 6 Elettrico | Pin mux; I2C/reset pull-up; LED; sequencing/IR/power margin se c’è il parametro | Senza numero in specs → skip | **OK** |
| 7 RF | Tab impedenza 50 Ω; review ruolo parte | Nessun clearance CPW inventato | parziale | | 7 RF | Tab **RF / Impedance**: verify matching + design recipe (w 50 Ω, λ/4 se f0); Z0 feed se PCB | CPWG / auto-draw radiatore dopo | **Improved** |
| 8 HV / isolation | — | Serve `layout_rules` + V/mm dal datasheet, non IEC inventato | — | | 8 HV / isolation | — | Serve `layout_rules` + V/mm dal datasheet, non IEC inventato | — |
| 9 Termico | `check_thermal` se θJA/I sono in specs; via courtyard vs `min_via_count` | Niente \(T_j\) senza parametro | **OK** | | 9 Termico | `check_thermal` se θJA/I sono in specs; via courtyard vs `min_via_count` | Niente \(T_j\) senza parametro | **OK** |
| 10 SI / DNP | DNP enable; `PS-SI-001` solo con `length_match` mm | Niente 3W/crosstalk inventati | **OK** | | 10 SI / DNP | DNP enable; `PS-SI-001` solo con `length_match` mm | Niente 3W/crosstalk inventati | **OK** |
@@ -426,9 +426,10 @@ Passi:
Passi: Passi:
1. Topologia π/T tra pin ANT e connettore/antenna (stesso matcher filtri). 1. Topologia π/T tra pin ANT e connettore/antenna (stesso matcher filtri). **OK** — sezione Verify in RF/Impedance.
2. Target 50 Ω come **intento**, non misura: WARNING se manca rete e datasheet mostra matching. 2. Target 50 Ω come **intento**, non misura: WARNING se manca rete. **OK** (status warning su `missing`).
3. CPW clearance: **Wave G** (layout). 3. Utility Progetta: marker KiCad `ANT*` / `ANT_FEED` + stackup → `w` microstrip; `f0` → λ/4 stimata; zona `antenna` → bbox. **OK** (auto-draw rame = dopo).
4. CPW clearance: **Wave G** (layout).
--- ---
+8
View File
@@ -2,6 +2,14 @@
What's new in Pinscope. What's new in Pinscope.
## 2.28.11 — 2026-09-13 — RF / Impedance: verify antenna + design recipe
Project tab renamed **RF / Impedance**. Verify matching from IC ANT/RF pins toward an `ANT*` / `ANT_FEED` marker; Progetta returns microstrip `w` for 50 Ω (stackup or UI h/εr), optional λ/4 length from `f0`, and antenna-zone bbox. Auto-draw copper comes later — no EM/VSWR invented.
- [New] `antenna_rf.py` + `GET/POST /api/projects/{id}/antenna`.
- [New] Verify + Progetta cards on the impedance panel.
- [Changed] Sidebar label **RF / Impedance**.
## 2.28.10 — 2026-09-13 — Classify strap/bootstrap satellites; hide other ## 2.28.10 — 2026-09-13 — Classify strap/bootstrap satellites; hide other
Caps on EN/BOOT/REGN/PMID and bootstrap caps between IC pins get real roles. Unclassified `other` parts are dropped from satellites and assemble_order. Caps on EN/BOOT/REGN/PMID and bootstrap caps between IC pins get real roles. Unclassified `other` parts are dropped from satellites and assemble_order.
+1 -1
View File
@@ -191,7 +191,7 @@ const PROJECT_NAV_ITEMS: NavItem[] = [
{ type: "tab", tab: "domains", label: "Domains", icon: Boxes }, { type: "tab", tab: "domains", label: "Domains", icon: Boxes },
{ type: "tab", tab: "rails", label: "Power rails", icon: CircuitBoard }, { type: "tab", tab: "rails", label: "Power rails", icon: CircuitBoard },
{ type: "tab", tab: "derating", label: "Derating", icon: Zap }, { type: "tab", tab: "derating", label: "Derating", icon: Zap },
{ type: "tab", tab: "impedance", label: "Impedance", icon: Ruler }, { type: "tab", tab: "impedance", label: "RF / Impedance", icon: Ruler },
{ type: "tab", tab: "logs", label: "Logs", icon: ScrollText, adminOnly: true }, { type: "tab", tab: "logs", label: "Logs", icon: ScrollText, adminOnly: true },
{ type: "tab", tab: "settings", label: "Settings", icon: Settings }, { type: "tab", tab: "settings", label: "Settings", icon: Settings },
]; ];
@@ -5,13 +5,17 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { import {
analyzeImpedanceNets, analyzeImpedanceNets,
computeImpedance, computeImpedance,
designAntenna,
fetchAntennaReport,
fetchImpedanceNets, fetchImpedanceNets,
} from "@/lib/api"; } from "@/lib/api";
import { PcbUploadButton } from "@/components/project/pcb-upload"; import { PcbUploadButton } from "@/components/project/pcb-upload";
import type { import type {
AntennaReport,
ImpedanceKind, ImpedanceKind,
ImpedanceNetsReport, ImpedanceNetsReport,
ImpedanceStackupResult, ImpedanceStackupResult,
@@ -49,6 +53,10 @@ export function ImpedancePanel({
const [stackup, setStackup] = useState<ImpedanceStackupResult | null>(null); const [stackup, setStackup] = useState<ImpedanceStackupResult | null>(null);
const [boardNets, setBoardNets] = useState<ImpedanceNetsReport | null>(null); const [boardNets, setBoardNets] = useState<ImpedanceNetsReport | null>(null);
const [extraNets, setExtraNets] = useState(""); const [extraNets, setExtraNets] = useState("");
const [antenna, setAntenna] = useState<AntennaReport | null>(null);
const [f0, setF0] = useState("2440");
const [antBusy, setAntBusy] = useState(false);
const [antError, setAntError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -59,6 +67,13 @@ export function ImpedancePanel({
.catch(() => { .catch(() => {
if (!cancelled) setBoardNets(null); if (!cancelled) setBoardNets(null);
}); });
fetchAntennaReport(projectId)
.then((r) => {
if (!cancelled) setAntenna(r);
})
.catch(() => {
if (!cancelled) setAntenna(null);
});
return () => { return () => {
cancelled = true; cancelled = true;
}; };
@@ -137,10 +152,173 @@ export function ImpedancePanel({
} }
} }
async function runAntennaDesign() {
setAntBusy(true);
setAntError(null);
try {
const f0n = f0.trim() === "" ? null : num(f0);
const report = await designAntenna(projectId, {
f0_mhz: f0n != null && !Number.isNaN(f0n) ? f0n : null,
target_z_ohm: 50,
h: num(h),
er: num(er),
t: num(t),
});
setAntenna(report);
} catch (e) {
setAntError(e instanceof Error ? e.message : "Antenna design failed");
} finally {
setAntBusy(false);
}
}
function copyRecipe() {
if (!antenna?.design) return;
void navigator.clipboard.writeText(JSON.stringify(antenna.design, null, 2));
}
const needsGap = kind === "cpw" || kind === "diff"; const needsGap = kind === "cpw" || kind === "diff";
const design = antenna?.design;
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-sm">Verify antenna / RF feed</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-muted-foreground">
{antenna?.marker_help ??
"Matching from IC ANT/RF pins toward ANT* / ANT_FEED. Feed Z0 when PCB nets were analyzed."}
</p>
{antenna && antenna.verify.length === 0 && (
<p className="text-sm text-muted-foreground">
No RF ports detected on IC pins (ANT/RF). Modules with an internal
antenna may show nothing here use Progetta with an ANT* marker.
</p>
)}
{antenna && antenna.verify.length > 0 && (
<div className="space-y-2">
{antenna.verify.map((row) => (
<div
key={`${row.ic_ref}-${row.pin}-${row.net}`}
className="rounded-lg border p-3 text-sm space-y-1"
>
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono font-medium">
{row.ic_ref}.{row.pin}
</span>
<code className="text-xs">{row.net}</code>
<Badge variant="outline">{row.topology}</Badge>
<Badge
variant={row.status === "warning" ? "destructive" : "secondary"}
>
{row.status}
</Badge>
</div>
<p className="text-xs text-muted-foreground">{row.detail}</p>
{row.parts.length > 0 && (
<p className="text-xs text-muted-foreground">
Parts: {row.parts.join(", ")}
</p>
)}
{(row.feed_z0 != null || row.feed_length_mm != null) && (
<p className="text-xs tabular-nums">
Feed Z0 {fmt(row.feed_z0)} Ω · {fmt(row.feed_length_mm)} mm
{row.marker_ref ? ` · marker ${row.marker_ref}` : ""}
</p>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm">Progetta antenna (ricetta)</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-muted-foreground">
Mark the feed join in KiCad (<code className="text-xs">ANT*</code>{" "}
footprint or net <code className="text-xs">ANT_FEED</code>). Optional
zone net <code className="text-xs">antenna</code>. Auto-draw in the
zone comes later this returns w / Z0 / length to draw by hand.
</p>
{!hasPcb && (
<PcbUploadButton projectId={projectId} onUploaded={onPcbUploaded} />
)}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<label className="space-y-1">
<Label>f0 (MHz)</Label>
<Input value={f0} onChange={(e) => setF0(e.target.value)} placeholder="2440" />
</label>
<label className="space-y-1">
<Label>h (mm)</Label>
<Input value={h} onChange={(e) => setH(e.target.value)} />
</label>
<label className="space-y-1">
<Label>εr</Label>
<Input value={er} onChange={(e) => setEr(e.target.value)} />
</label>
<label className="space-y-1">
<Label>t (mm)</Label>
<Input value={t} onChange={(e) => setT(e.target.value)} />
</label>
</div>
<div className="flex flex-wrap gap-2">
<Button onClick={runAntennaDesign} disabled={antBusy}>
{antBusy ? "Computing…" : "Compute recipe"}
</Button>
<Button
variant="outline"
onClick={copyRecipe}
disabled={!design || design.status !== "ready"}
>
Copy JSON
</Button>
</div>
{antError && <p className="text-sm text-destructive">{antError}</p>}
{design && (
<div className="rounded-lg border p-3 text-sm space-y-2">
<div className="flex flex-wrap gap-2 items-center">
<Badge variant="secondary">{design.status}</Badge>
<span className="text-muted-foreground text-xs">{design.detail}</span>
</div>
{design.feed_line && (
<p className="tabular-nums">
Feed microstrip @ {design.feed_line.target_z_ohm} Ω w ={" "}
{fmt(design.feed_line.w_mm, 4)} mm (h={fmt(design.feed_line.h_mm)},
εr={fmt(design.feed_line.er)})
</p>
)}
{design.radiator?.length_mm_suggest != null && (
<p className="tabular-nums text-xs text-muted-foreground">
λ/4 suggest {fmt(design.radiator.length_mm_suggest)} mm at{" "}
{fmt(design.radiator.f0_mhz, 0)} MHz {design.radiator.note}
</p>
)}
{design.zone && (
<p className="text-xs text-muted-foreground">
Zone {design.zone.net} on {design.zone.layer}
{design.zone.bbox_mm
? ` · bbox [${design.zone.bbox_mm.map((n) => n.toFixed(1)).join(", ")}]`
: ""}
</p>
)}
{design.keepout_checklist.length > 0 && (
<ul className="list-disc pl-4 text-xs text-muted-foreground space-y-0.5">
{design.keepout_checklist.map((c) => (
<li key={c}>{c}</li>
))}
</ul>
)}
</div>
)}
</CardContent>
</Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-sm">Impedance calculator</CardTitle> <CardTitle className="text-sm">Impedance calculator</CardTitle>
@@ -297,30 +475,17 @@ export function ImpedancePanel({
<CardTitle className="text-sm">Suggested widths (apply in KiCad)</CardTitle> <CardTitle className="text-sm">Suggested widths (apply in KiCad)</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<table className="w-full text-sm"> <div className="grid gap-1 text-sm tabular-nums">
<thead> {Object.entries(stackup.targets).map(([k, v]) => (
<tr className="text-left text-muted-foreground"> <div key={k}>
<th className="py-1">Target</th> {k}: w={fmt(v.w_mm, 4)} mm
<th>w mm</th> {v.z0 != null && <> · Z0={fmt(v.z0)}</>}
<th>s mm</th> {v.zdiff != null && <> · Zdiff={fmt(v.zdiff)}</>}
<th>Z</th> </div>
</tr> ))}
</thead> </div>
<tbody> <Button variant="outline" size="sm" onClick={downloadDru}>
{Object.entries(stackup.targets).map(([key, row]) => ( Download .kicad_dru advice
<tr key={key} className="border-t border-border">
<td className="py-1">{key}</td>
<td className="tabular-nums">{fmt(row.w_mm, 4)}</td>
<td className="tabular-nums">{fmt(row.s_mm, 4)}</td>
<td className="tabular-nums">
{row.z0 != null ? `${fmt(row.z0)} Ω` : `${fmt(row.zdiff)} Ω diff`}
</td>
</tr>
))}
</tbody>
</table>
<Button variant="outline" onClick={downloadDru}>
Download pinscope.kicad_dru
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
+41
View File
@@ -14,6 +14,7 @@ import type {
ImpedanceNetsReport, ImpedanceNetsReport,
ImpedanceStackupResult, ImpedanceStackupResult,
ImpedanceTraceResult, ImpedanceTraceResult,
AntennaReport,
EdifSubDesign, EdifSubDesign,
FindingComment, FindingComment,
FindingReview, FindingReview,
@@ -725,6 +726,46 @@ export async function analyzeImpedanceNets(
return res.json(); return res.json();
} }
export async function fetchAntennaReport(
projectId: string,
): Promise<AntennaReport> {
const res = await authFetch(`${BASE}/api/projects/${projectId}/antenna`);
if (!res.ok) {
const detail = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(
typeof detail.detail === "string" ? detail.detail : "Antenna report failed",
);
}
return res.json();
}
export async function designAntenna(
projectId: string,
body: {
f0_mhz?: number | null;
target_z_ohm?: number;
h?: number | null;
er?: number | null;
t?: number | null;
},
): Promise<AntennaReport> {
const res = await authFetch(
`${BASE}/api/projects/${projectId}/antenna/design`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
if (!res.ok) {
const detail = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(
typeof detail.detail === "string" ? detail.detail : "Antenna design failed",
);
}
return res.json();
}
export async function fetchReport( export async function fetchReport(
projectId: string, projectId: string,
): Promise<ValidationReport> { ): Promise<ValidationReport> {
+46
View File
@@ -451,6 +451,52 @@ export interface ImpedanceNetsReport {
skipped: string | null; skipped: string | null;
} }
export interface AntennaVerifyRow {
ic_ref: string;
pin: string;
net: string;
topology: string;
parts: string[];
target_z_ohm: number;
status: "ok" | "warning" | "info";
detail: string;
feed_z0?: number | null;
feed_length_mm?: number | null;
marker_ref?: string | null;
}
export interface AntennaDesignRecipe {
status: "ready" | "need_pcb" | "need_stackup" | "need_marker";
feed_point?: Record<string, unknown> | null;
feed_line?: {
kind: string;
target_z_ohm: number;
w_mm?: number | null;
h_mm?: number | null;
er?: number | null;
t_mm?: number | null;
} | null;
radiator?: {
length_mm_suggest?: number | null;
f0_mhz?: number | null;
note?: string;
} | null;
zone?: {
net: string;
layer: string;
bbox_mm?: number[] | null;
area_mm2?: number | null;
} | null;
keepout_checklist: string[];
detail: string;
}
export interface AntennaReport {
verify: AntennaVerifyRow[];
design: AntennaDesignRecipe | null;
marker_help: string;
}
export interface NetlistPreviewDesignator { export interface NetlistPreviewDesignator {
ref: string; ref: string;
pins: { number: string; net_name: string }[]; pins: { number: string; net_name: string }[];
+179
View File
@@ -0,0 +1,179 @@
"""Antenna RF verify + design recipe — no invented EM."""
from __future__ import annotations
from backend.pinscopex.antenna_rf import build_antenna_report, build_design_recipe
from backend.pinscopex.models import (
CapacitorSpecs,
Component,
ComponentType,
DesignGraph,
InductorSpecs,
LayoutDielectric,
LayoutFootprint,
LayoutGraph,
LayoutPad,
LayoutStackup,
LayoutZone,
Net,
NetType,
PinConnection,
)
def _graph_with_pi_match():
components = {
"U1": Component(
reference="U1", value="RFIC", footprint="",
component_type=ComponentType.IC,
component_subtype="ic.rf.wifi_module",
pins={"1": "RF_ANT", "2": "GND"},
),
"L1": Component(
reference="L1", value="2.2n", footprint="",
component_type=ComponentType.INDUCTOR,
pins={"1": "RF_ANT", "2": "ANT_MID"},
specs=InductorSpecs(value_henries=2.2e-9, value_formatted="2.2nH"),
),
"C1": Component(
reference="C1", value="1p", footprint="",
component_type=ComponentType.CAPACITOR,
pins={"1": "RF_ANT", "2": "GND"},
specs=CapacitorSpecs(value_farads=1e-12, value_formatted="1pF"),
),
"C2": Component(
reference="C2", value="1p", footprint="",
component_type=ComponentType.CAPACITOR,
pins={"1": "ANT_MID", "2": "GND"},
specs=CapacitorSpecs(value_farads=1e-12, value_formatted="1pF"),
),
"ANT1": Component(
reference="ANT1", value="PCB_ANT", footprint="",
component_type=ComponentType.CONNECTOR,
pins={"1": "ANT_MID", "2": "GND"},
),
}
nets = {
"RF_ANT": Net(
name="RF_ANT", net_type=NetType.SIGNAL,
pins=[
PinConnection(component_ref="U1", pin_number="1"),
PinConnection(component_ref="L1", pin_number="1"),
PinConnection(component_ref="C1", pin_number="1"),
],
),
"ANT_MID": Net(
name="ANT_MID", net_type=NetType.SIGNAL,
pins=[
PinConnection(component_ref="L1", pin_number="2"),
PinConnection(component_ref="C2", pin_number="1"),
PinConnection(component_ref="ANT1", pin_number="1"),
],
),
"GND": Net(
name="GND", net_type=NetType.GROUND,
pins=[
PinConnection(component_ref="U1", pin_number="2"),
PinConnection(component_ref="C1", pin_number="2"),
PinConnection(component_ref="C2", pin_number="2"),
PinConnection(component_ref="ANT1", pin_number="2"),
],
),
}
return DesignGraph(components=components, nets=nets)
def test_verify_finds_matching_path_to_ant_footprint():
report = build_antenna_report(_graph_with_pi_match())
assert report.verify
row = report.verify[0]
assert row.ic_ref == "U1"
assert row.topology in ("pi", "LC", "series_L", "unknown")
assert "L1" in row.parts
assert row.status == "ok"
assert row.marker_ref == "ANT1"
def test_verify_missing_matching_is_warning():
components = {
"U1": Component(
reference="U1", value="RFIC", footprint="",
component_type=ComponentType.IC,
component_subtype="ic.rf.transceiver",
pins={"1": "RF_OUT", "2": "GND"},
),
}
nets = {
"RF_OUT": Net(
name="RF_OUT", net_type=NetType.SIGNAL,
pins=[PinConnection(component_ref="U1", pin_number="1")],
),
"GND": Net(
name="GND", net_type=NetType.GROUND,
pins=[PinConnection(component_ref="U1", pin_number="2")],
),
}
report = build_antenna_report(DesignGraph(components=components, nets=nets))
assert report.verify
assert report.verify[0].topology == "missing"
assert report.verify[0].status == "warning"
def test_design_recipe_needs_marker_without_ant():
g = DesignGraph(components={}, nets={})
layout = LayoutGraph(
stackup=LayoutStackup(
copper_layers=["F.Cu", "B.Cu"],
dielectrics=[LayoutDielectric(name="FR4", er=4.5, height_mm=0.2)],
copper_thickness_mm=0.035,
),
)
recipe = build_design_recipe(g, layout, f0_mhz=2440.0)
assert recipe.status == "need_marker"
def test_design_recipe_ready_with_ant_and_stackup():
g = DesignGraph(
components={
"ANT1": Component(
reference="ANT1", value="feed", footprint="",
component_type=ComponentType.CONNECTOR,
pins={"1": "ANT_FEED"},
),
},
nets={
"ANT_FEED": Net(
name="ANT_FEED", net_type=NetType.SIGNAL,
pins=[PinConnection(component_ref="ANT1", pin_number="1")],
),
},
)
layout = LayoutGraph(
footprints={
"ANT1": LayoutFootprint(
reference="ANT1", x=10.0, y=20.0, layer="F.Cu",
pads=[LayoutPad(number="1", x=10.0, y=20.0, net="ANT_FEED")],
),
},
nets={"ANT_FEED": 1, "antenna": 2},
stackup=LayoutStackup(
copper_layers=["F.Cu", "B.Cu"],
dielectrics=[LayoutDielectric(name="FR4", er=4.5, height_mm=0.2)],
copper_thickness_mm=0.035,
),
zones=[
LayoutZone(
net="antenna",
layer="F.Cu",
outlines=[[(0.0, 0.0), (10.0, 0.0), (10.0, 5.0), (0.0, 5.0)]],
),
],
)
recipe = build_design_recipe(g, layout, f0_mhz=2440.0, target_z_ohm=50.0)
assert recipe.status == "ready"
assert recipe.feed_line is not None
assert recipe.feed_line.w_mm is not None and recipe.feed_line.w_mm > 0
assert recipe.radiator is not None
assert recipe.radiator.length_mm_suggest is not None
assert recipe.zone is not None
assert recipe.zone.bbox_mm is not None