From 1d3cf12482ed7e4d8291e8d8d1dd0b5c669523e2 Mon Sep 17 00:00:00 2001 From: Michele Bigi Date: Sun, 13 Sep 2026 18:57:59 +0200 Subject: [PATCH] Add RF antenna verify and design recipe to Impedance tab. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/pinscopex/antenna_rf.py | 507 ++++++++++++++++++ backend/routers/impedance.py | 79 +++ docs/piano-implementazione.md | 9 +- frontend/content/changelog.md | 8 + frontend/src/components/layout/sidebar.tsx | 2 +- .../components/project/impedance-panel.tsx | 213 +++++++- frontend/src/lib/api.ts | 41 ++ frontend/src/lib/types.ts | 46 ++ tests/test_antenna_rf.py | 179 +++++++ 9 files changed, 1055 insertions(+), 29 deletions(-) create mode 100644 backend/pinscopex/antenna_rf.py create mode 100644 tests/test_antenna_rf.py diff --git a/backend/pinscopex/antenna_rf.py b/backend/pinscopex/antenna_rf.py new file mode 100644 index 0000000..cac398d --- /dev/null +++ b/backend/pinscopex/antenna_rf.py @@ -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 diff --git a/backend/routers/impedance.py b/backend/routers/impedance.py index a186fa5..c7b3fc9 100644 --- a/backend/routers/impedance.py +++ b/backend/routers/impedance.py @@ -25,6 +25,8 @@ from backend.pinscopex.impedance_traces import ( NET_WALK_PITCH_MM, 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.routers.deps import get_storage, resolve_or_404 from backend.services import projects as proj_svc @@ -123,3 +125,80 @@ async def analyze_project_impedance_nets( except GeometryError as exc: raise HTTPException(400, str(exc)) from exc 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") diff --git a/docs/piano-implementazione.md b/docs/piano-implementazione.md index 40cd625..dd04e53 100644 --- a/docs/piano-implementazione.md +++ b/docs/piano-implementazione.md @@ -159,7 +159,7 @@ Passi: | 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** | | 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 | — | | 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** | @@ -426,9 +426,10 @@ Passi: Passi: -1. Topologia π/T tra pin ANT e connettore/antenna (stesso matcher filtri). -2. Target 50 Ω come **intento**, non misura: WARNING se manca rete e datasheet mostra matching. -3. CPW clearance: **Wave G** (layout). +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. **OK** (status warning su `missing`). +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). --- diff --git a/frontend/content/changelog.md b/frontend/content/changelog.md index bb065b0..ea62888 100644 --- a/frontend/content/changelog.md +++ b/frontend/content/changelog.md @@ -2,6 +2,14 @@ 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 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. diff --git a/frontend/src/components/layout/sidebar.tsx b/frontend/src/components/layout/sidebar.tsx index c73bd31..1891874 100644 --- a/frontend/src/components/layout/sidebar.tsx +++ b/frontend/src/components/layout/sidebar.tsx @@ -191,7 +191,7 @@ const PROJECT_NAV_ITEMS: NavItem[] = [ { type: "tab", tab: "domains", label: "Domains", icon: Boxes }, { type: "tab", tab: "rails", label: "Power rails", icon: CircuitBoard }, { 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: "settings", label: "Settings", icon: Settings }, ]; diff --git a/frontend/src/components/project/impedance-panel.tsx b/frontend/src/components/project/impedance-panel.tsx index 6a1cea6..0db053c 100644 --- a/frontend/src/components/project/impedance-panel.tsx +++ b/frontend/src/components/project/impedance-panel.tsx @@ -5,13 +5,17 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; import { analyzeImpedanceNets, computeImpedance, + designAntenna, + fetchAntennaReport, fetchImpedanceNets, } from "@/lib/api"; import { PcbUploadButton } from "@/components/project/pcb-upload"; import type { + AntennaReport, ImpedanceKind, ImpedanceNetsReport, ImpedanceStackupResult, @@ -49,6 +53,10 @@ export function ImpedancePanel({ const [stackup, setStackup] = useState(null); const [boardNets, setBoardNets] = useState(null); const [extraNets, setExtraNets] = useState(""); + const [antenna, setAntenna] = useState(null); + const [f0, setF0] = useState("2440"); + const [antBusy, setAntBusy] = useState(false); + const [antError, setAntError] = useState(null); useEffect(() => { let cancelled = false; @@ -59,6 +67,13 @@ export function ImpedancePanel({ .catch(() => { if (!cancelled) setBoardNets(null); }); + fetchAntennaReport(projectId) + .then((r) => { + if (!cancelled) setAntenna(r); + }) + .catch(() => { + if (!cancelled) setAntenna(null); + }); return () => { 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 design = antenna?.design; return (
+ + + Verify antenna / RF feed + + +

+ {antenna?.marker_help ?? + "Matching from IC ANT/RF pins toward ANT* / ANT_FEED. Feed Z0 when PCB nets were analyzed."} +

+ {antenna && antenna.verify.length === 0 && ( +

+ No RF ports detected on IC pins (ANT/RF…). Modules with an internal + antenna may show nothing here — use Progetta with an ANT* marker. +

+ )} + {antenna && antenna.verify.length > 0 && ( +
+ {antenna.verify.map((row) => ( +
+
+ + {row.ic_ref}.{row.pin} + + {row.net} + {row.topology} + + {row.status} + +
+

{row.detail}

+ {row.parts.length > 0 && ( +

+ Parts: {row.parts.join(", ")} +

+ )} + {(row.feed_z0 != null || row.feed_length_mm != null) && ( +

+ Feed Z0 {fmt(row.feed_z0)} Ω · {fmt(row.feed_length_mm)} mm + {row.marker_ref ? ` · marker ${row.marker_ref}` : ""} +

+ )} +
+ ))} +
+ )} +
+
+ + + + Progetta antenna (ricetta) + + +

+ Mark the feed join in KiCad (ANT*{" "} + footprint or net ANT_FEED). Optional + zone net antenna. Auto-draw in the + zone comes later — this returns w / Z0 / length to draw by hand. +

+ {!hasPcb && ( + + )} +
+ + + + +
+
+ + +
+ {antError &&

{antError}

} + {design && ( +
+
+ {design.status} + {design.detail} +
+ {design.feed_line && ( +

+ 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)}) +

+ )} + {design.radiator?.length_mm_suggest != null && ( +

+ λ/4 suggest ≈ {fmt(design.radiator.length_mm_suggest)} mm at{" "} + {fmt(design.radiator.f0_mhz, 0)} MHz — {design.radiator.note} +

+ )} + {design.zone && ( +

+ Zone {design.zone.net} on {design.zone.layer} + {design.zone.bbox_mm + ? ` · bbox [${design.zone.bbox_mm.map((n) => n.toFixed(1)).join(", ")}]` + : ""} +

+ )} + {design.keepout_checklist.length > 0 && ( +
    + {design.keepout_checklist.map((c) => ( +
  • {c}
  • + ))} +
+ )} +
+ )} +
+
+ Impedance calculator @@ -297,30 +475,17 @@ export function ImpedancePanel({ Suggested widths (apply in KiCad) - - - - - - - - - - - {Object.entries(stackup.targets).map(([key, row]) => ( - - - - - - - ))} - -
Targetw mms mmZ
{key}{fmt(row.w_mm, 4)}{fmt(row.s_mm, 4)} - {row.z0 != null ? `${fmt(row.z0)} Ω` : `${fmt(row.zdiff)} Ω diff`} -
-
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 4e6f49b..1d7b982 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -14,6 +14,7 @@ import type { ImpedanceNetsReport, ImpedanceStackupResult, ImpedanceTraceResult, + AntennaReport, EdifSubDesign, FindingComment, FindingReview, @@ -725,6 +726,46 @@ export async function analyzeImpedanceNets( return res.json(); } +export async function fetchAntennaReport( + projectId: string, +): Promise { + 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 { + 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( projectId: string, ): Promise { diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 3af9834..f3185bf 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -451,6 +451,52 @@ export interface ImpedanceNetsReport { 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 | 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 { ref: string; pins: { number: string; net_name: string }[]; diff --git a/tests/test_antenna_rf.py b/tests/test_antenna_rf.py new file mode 100644 index 0000000..145be44 --- /dev/null +++ b/tests/test_antenna_rf.py @@ -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