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:
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
@@ -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<ImpedanceStackupResult | null>(null);
|
||||
const [boardNets, setBoardNets] = useState<ImpedanceNetsReport | null>(null);
|
||||
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(() => {
|
||||
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 (
|
||||
<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>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">Impedance calculator</CardTitle>
|
||||
@@ -297,30 +475,17 @@ export function ImpedancePanel({
|
||||
<CardTitle className="text-sm">Suggested widths (apply in KiCad)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-muted-foreground">
|
||||
<th className="py-1">Target</th>
|
||||
<th>w mm</th>
|
||||
<th>s mm</th>
|
||||
<th>Z</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(stackup.targets).map(([key, row]) => (
|
||||
<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
|
||||
<div className="grid gap-1 text-sm tabular-nums">
|
||||
{Object.entries(stackup.targets).map(([k, v]) => (
|
||||
<div key={k}>
|
||||
{k}: w={fmt(v.w_mm, 4)} mm
|
||||
{v.z0 != null && <> · Z0={fmt(v.z0)}</>}
|
||||
{v.zdiff != null && <> · Zdiff={fmt(v.zdiff)}</>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={downloadDru}>
|
||||
Download .kicad_dru advice
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -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<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(
|
||||
projectId: string,
|
||||
): Promise<ValidationReport> {
|
||||
|
||||
@@ -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<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 {
|
||||
ref: string;
|
||||
pins: { number: string; net_name: string }[];
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user