diff --git a/backend/pinscopex/antenna_geometry.py b/backend/pinscopex/antenna_geometry.py new file mode 100644 index 0000000..d444c57 --- /dev/null +++ b/backend/pinscopex/antenna_geometry.py @@ -0,0 +1,321 @@ +"""Parametric PCB antenna templates → segments, SVG, KiCad footprint. + +Templates (IFA / meander / stub) use a documented λ/4 electrical length with +εeff≈(εr+1)/2. This is a routing-first drawing aid — not an EM / VSWR result. +""" + +from __future__ import annotations + +import math +from typing import Literal + +from pydantic import BaseModel, Field + +_C_MPS = 299_792_458.0 + +AntennaTemplate = Literal["ifa", "meander", "stub"] +FitStatus = Literal["ok", "scaled", "overflow", "need_f0"] + +_NOTE = ( + "Parametric template from λ/4 (εeff≈(εr+1)/2) — routing aid only, " + "not an EM / VSWR result. Tune matching on the board." +) + + +class AntennaSegment(BaseModel): + points: list[tuple[float, float]] # local mm, origin = feed + width_mm: float + + +class AntennaGeometry(BaseModel): + template: AntennaTemplate + fit: FitStatus + segments: list[AntennaSegment] = Field(default_factory=list) + total_length_mm: float | None = None + length_ideal_mm: float | None = None + scale: float = 1.0 + svg: str | None = None + kicad_mod: str | None = None + footprint_name: str | None = None + note: str = _NOTE + detail: str = "" + + +def quarter_wave_mm(f0_mhz: float, er: float) -> float: + """Electrical λ/4 in mm using εeff≈(εr+1)/2.""" + eeff = (er + 1.0) / 2.0 + f_hz = f0_mhz * 1e6 + return (_C_MPS / (4.0 * f_hz * math.sqrt(eeff))) * 1e3 + + +def build_geometry( + template: AntennaTemplate, + *, + f0_mhz: float | None, + w_mm: float, + er: float, + zone_bbox_mm: tuple[float, float, float, float] | None = None, + feed_xy: tuple[float, float] | None = None, +) -> AntennaGeometry: + if f0_mhz is None or f0_mhz <= 0: + return AntennaGeometry( + template=template, + fit="need_f0", + detail="Set f0 (MHz) to generate radiator geometry.", + ) + if w_mm <= 0: + return AntennaGeometry( + template=template, + fit="overflow", + detail="Feed width w_mm must be > 0.", + ) + + ideal = quarter_wave_mm(f0_mhz, er) + segs_local, length = _template_segments(template, ideal, w_mm) + fit: FitStatus = "ok" + scale = 1.0 + detail = f"{template.upper()} template at {f0_mhz:g} MHz." + + avail = _available_span(zone_bbox_mm, feed_xy) + if avail is not None: + need_w, need_h = _bbox_size(segs_local) + free_w, free_h = avail + max_span = max(free_w, free_h) + need_span = max(need_w, need_h) + if need_span > max_span + 1e-6 and max_span > 0: + scale = max_span / need_span + min_scale = 0.45 + if scale < min_scale: + return AntennaGeometry( + template=template, + fit="overflow", + length_ideal_mm=round(ideal, 2), + total_length_mm=None, + scale=round(scale, 4), + detail=( + f"Zone too small for {template.upper()} " + f"(need ~{need_span:.1f} mm, have {max_span:.1f} mm)." + ), + ) + segs_local = _scale_segments(segs_local, scale) + length *= scale + fit = "scaled" + detail = ( + f"Scaled to {scale:.2f}× to fit antenna zone " + f"({max_span:.1f} mm free). Retune matching." + ) + + name = f"Antenna_{template.upper()}_{int(round(f0_mhz))}" + svg = _segments_to_svg(segs_local, w_mm) + mod = _segments_to_kicad_mod(name, segs_local, w_mm, template) + + return AntennaGeometry( + template=template, + fit=fit, + segments=segs_local, + total_length_mm=round(length, 2), + length_ideal_mm=round(ideal, 2), + scale=round(scale, 4), + svg=svg, + kicad_mod=mod, + footprint_name=name, + detail=detail, + ) + + +def _template_segments( + template: AntennaTemplate, + length_mm: float, + w_mm: float, +) -> tuple[list[AntennaSegment], float]: + if template == "ifa": + return _ifa(length_mm, w_mm) + if template == "meander": + return _meander(length_mm, w_mm) + return _stub(length_mm, w_mm) + + +def _ifa(length_mm: float, w_mm: float) -> tuple[list[AntennaSegment], float]: + """Inverted-F: shorting stub + horizontal arm; feed on the arm at origin. + + Local: feed (0,0) on the arm. Shorting at x=-d toward -Y (GND edge). + Arm runs to +X. Proportions: stub ≈ 0.12 L, feed offset ≈ 0.15 L. + """ + L = max(length_mm, 4.0 * w_mm) + stub_h = max(0.12 * L, 2.0 * w_mm) + d = max(0.15 * L, 2.0 * w_mm) + open_x = L - d + segs = [ + AntennaSegment(points=[(-d, 0.0), (-d, -stub_h)], width_mm=w_mm), + AntennaSegment(points=[(-d, 0.0), (open_x, 0.0)], width_mm=w_mm), + ] + path = stub_h + L + return segs, path + + +def _meander(length_mm: float, w_mm: float) -> tuple[list[AntennaSegment], float]: + """Serpentine that consumes ~length_mm inside a compact bbox.""" + pitch = max(3.0 * w_mm, 1.2) + run = max(length_mm / 6.0, 4.0 * w_mm) + pts: list[tuple[float, float]] = [(0.0, 0.0)] + x = 0.0 + y = 0.0 + going_up = True + consumed = 0.0 + target = max(length_mm, 4.0 * w_mm) + guard = 0 + while consumed < target - 1e-6 and guard < 80: + guard += 1 + dy = run if going_up else -run + remain = target - consumed + if remain < abs(dy): + dy = math.copysign(remain, dy) + y2 = y + dy + pts.append((x, y2)) + consumed += abs(dy) + y = y2 + if consumed >= target - 1e-6: + break + remain = target - consumed + dx = min(pitch, remain) + x2 = x + dx + pts.append((x2, y)) + consumed += dx + x = x2 + going_up = not going_up + segs = [AntennaSegment(points=pts, width_mm=w_mm)] + return segs, consumed + + +def _stub(length_mm: float, w_mm: float) -> tuple[list[AntennaSegment], float]: + """Open L-stub monopole: short vertical then horizontal arm.""" + L = max(length_mm, 4.0 * w_mm) + h = max(0.2 * L, 2.0 * w_mm) + arm = max(L - h, 2.0 * w_mm) + segs = [ + AntennaSegment(points=[(0.0, 0.0), (0.0, -h)], width_mm=w_mm), + AntennaSegment(points=[(0.0, -h), (arm, -h)], width_mm=w_mm), + ] + return segs, h + arm + + +def _available_span( + zone_bbox: tuple[float, float, float, float] | None, + feed_xy: tuple[float, float] | None, +) -> tuple[float, float] | None: + """Free width/height from feed into the zone (mm).""" + if zone_bbox is None or feed_xy is None: + return None + xmin, ymin, xmax, ymax = zone_bbox + fx, fy = feed_xy + fx = min(max(fx, xmin), xmax) + fy = min(max(fy, ymin), ymax) + free_w = max(fx - xmin, xmax - fx) + free_h = max(fy - ymin, ymax - fy) + return free_w, free_h + + +def _bbox_size(segs: list[AntennaSegment]) -> tuple[float, float]: + xs: list[float] = [] + ys: list[float] = [] + for s in segs: + for x, y in s.points: + xs.append(x) + ys.append(y) + if not xs: + return 0.0, 0.0 + return max(xs) - min(xs), max(ys) - min(ys) + + +def _scale_segments( + segs: list[AntennaSegment], scale: float, +) -> list[AntennaSegment]: + out: list[AntennaSegment] = [] + for s in segs: + out.append( + AntennaSegment( + points=[(x * scale, y * scale) for x, y in s.points], + width_mm=s.width_mm, + ) + ) + return out + + +def _segments_to_svg(segs: list[AntennaSegment], default_w: float) -> str: + xs: list[float] = [] + ys: list[float] = [] + for s in segs: + for x, y in s.points: + xs.append(x) + ys.append(y) + if not xs: + return '' + pad = max(default_w * 2, 1.0) + xmin, xmax = min(xs) - pad, max(xs) + pad + ymin, ymax = min(ys) - pad, max(ys) + pad + bw = max(xmax - xmin, 1e-3) + bh = max(ymax - ymin, 1e-3) + paths: list[str] = [] + for s in segs: + if len(s.points) < 2: + continue + d_parts = [] + for i, (x, y) in enumerate(s.points): + cmd = "M" if i == 0 else "L" + d_parts.append(f"{cmd}{x:.3f},{-y:.3f}") + sw = s.width_mm + paths.append( + f'' + ) + paths.append( + f'' + ) + vb = f"{xmin:.3f} {-ymax:.3f} {bw:.3f} {bh:.3f}" + body = "\n ".join(paths) + return ( + f'' + f"\n {body}\n" + ) + + +def _segments_to_kicad_mod( + name: str, + segs: list[AntennaSegment], + w_mm: float, + template: AntennaTemplate, +) -> str: + lines = [ + f'(footprint "{name}"', + " (version 20240108)", + ' (generator "pinscope")', + ' (layer "F.Cu")', + f' (descr "Pinscope {template.upper()} PCB antenna template ' + f'— not EM-validated")', + " (attr smd)", + f' (pad "1" smd circle (at 0 0) (size {w_mm * 2:.4f} {w_mm * 2:.4f}) ' + f'(layers "F.Cu") (uuid 00000000-0000-4000-8000-000000000001))', + ] + if template == "ifa" and segs: + tip = segs[0].points[-1] + lines.append( + f' (pad "2" smd circle (at {tip[0]:.4f} {tip[1]:.4f}) ' + f"(size {w_mm * 2:.4f} {w_mm * 2:.4f}) " + f'(layers "F.Cu") (uuid 00000000-0000-4000-8000-000000000002))' + ) + uid = 10 + for s in segs: + pts = s.points + for i in range(len(pts) - 1): + x1, y1 = pts[i] + x2, y2 = pts[i + 1] + lines.append( + f" (fp_line (start {x1:.4f} {y1:.4f}) (end {x2:.4f} {y2:.4f}) " + f"(stroke (width {s.width_mm:.4f}) (type default)) " + f'(layer "F.Cu") (uuid 00000000-0000-4000-8000-{uid:012d}))' + ) + uid += 1 + lines.append(")") + return "\n".join(lines) + "\n" diff --git a/backend/pinscopex/antenna_rf.py b/backend/pinscopex/antenna_rf.py index cac398d..8868ca1 100644 --- a/backend/pinscopex/antenna_rf.py +++ b/backend/pinscopex/antenna_rf.py @@ -2,9 +2,10 @@ 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. +for target Z0 from stackup; optional λ/4 length if f0_mhz is given; +parametric IFA / meander / stub geometry (segments + SVG + .kicad_mod). -No EM/VSWR. No CPWG clearance. Length suggestion is a documented estimate only. +No EM/VSWR. No CPWG clearance. Geometry is a documented routing template only. """ from __future__ import annotations @@ -15,6 +16,11 @@ from typing import Any, Literal from pydantic import BaseModel +from backend.pinscopex.antenna_geometry import ( + AntennaGeometry, + AntennaTemplate, + build_geometry, +) from backend.pinscopex.impedance import GeometryError, solve_width from backend.pinscopex.models import ( ComponentType, @@ -87,6 +93,7 @@ class AntennaDesignRecipe(BaseModel): feed_point: dict[str, Any] | None = None feed_line: AntennaFeedLine | None = None radiator: AntennaRadiator | None = None + geometry: AntennaGeometry | None = None zone: AntennaZoneInfo | None = None keepout_checklist: list[str] = [] detail: str = "" @@ -111,6 +118,7 @@ def build_antenna_report( h_mm: float | None = None, er: float | None = None, t_mm: float | None = None, + template: AntennaTemplate = "ifa", ) -> AntennaReport: verify = _verify(graph, layout, impedance_nets, target_z_ohm) design = build_design_recipe( @@ -118,6 +126,7 @@ def build_antenna_report( f0_mhz=f0_mhz, target_z_ohm=target_z_ohm, h_mm=h_mm, + template=template, er=er, t_mm=t_mm, ) @@ -133,6 +142,7 @@ def build_design_recipe( h_mm: float | None = None, er: float | None = None, t_mm: float | None = None, + template: AntennaTemplate = "ifa", ) -> AntennaDesignRecipe: marker = _find_marker(graph, layout) zone = _find_antenna_zone(layout) @@ -141,6 +151,8 @@ def build_design_recipe( "Short GND return from the matching network to the RF reference.", "Avoid long stubs and right angles on the 50 Ω feed.", "Place matching parts close to the RF pin / feed point.", + "IFA pad 2 (shorting tip) must connect to RF ground / pour edge.", + "Place the footprint with feed (pad 1) on the ANT* / ANT_FEED join.", ] stack = _resolve_stackup(layout, h_mm=h_mm, er=er, t_mm=t_mm) @@ -197,14 +209,36 @@ def build_design_recipe( f0_mhz=f0_mhz, ) + feed_xy = None + if marker.get("x") is not None and marker.get("y") is not None: + feed_xy = (float(marker["x"]), float(marker["y"])) + zone_bbox = zone.bbox_mm if zone else None + geometry = build_geometry( + template, + f0_mhz=f0_mhz, + w_mm=float(feed.w_mm or 0), + er=er_v, + zone_bbox_mm=zone_bbox, + feed_xy=feed_xy, + ) + + detail = "Recipe ready — feed at w_mm; geometry is a parametric template (not EM)." + if geometry.fit == "need_f0": + detail = "Feed w ready — set f0 to generate IFA / meander / stub geometry." + elif geometry.fit == "scaled": + detail = geometry.detail + elif geometry.fit == "overflow": + detail = geometry.detail + return AntennaDesignRecipe( status="ready", feed_point=marker, feed_line=feed, radiator=radiator, + geometry=geometry, zone=zone, keepout_checklist=checklist, - detail="Recipe ready — draw the feed at w_mm; radiator length is an estimate only.", + detail=detail, ) diff --git a/backend/routers/impedance.py b/backend/routers/impedance.py index c7b3fc9..d120e46 100644 --- a/backend/routers/impedance.py +++ b/backend/routers/impedance.py @@ -133,6 +133,7 @@ class AntennaDesignRequest(BaseModel): h: float | None = None er: float | None = None t: float | None = None + template: Literal["ifa", "meander", "stub"] = "ifa" def _load_graph_layout(storage, prefix: str) -> tuple[DesignGraph | None, LayoutGraph | None, dict | None]: @@ -190,6 +191,7 @@ async def post_project_antenna_design( h_mm=body.h, er=body.er, t_mm=body.t, + template=body.template, ) # Recompute design with explicit params (same as report.design but ensure POST body wins) report.design = build_design_recipe( @@ -200,5 +202,6 @@ async def post_project_antenna_design( h_mm=body.h, er=body.er, t_mm=body.t, + template=body.template, ) return report.model_dump(mode="json") diff --git a/docs/piano-implementazione.md b/docs/piano-implementazione.md index dd04e53..8b286d2 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 **RF / Impedance**: verify matching + design recipe (w 50 Ω, λ/4 se f0); Z0 feed se PCB | CPWG / auto-draw radiatore dopo | **Improved** | +| 7 RF | Tab **RF / Impedance**: verify matching + template geometry (IFA/meander/stub → SVG + `.kicad_mod`); Z0 feed se PCB | CPWG / auto-place into `.kicad_pcb` 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** | @@ -428,7 +428,7 @@ Passi: 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). +3. Utility Progetta: marker KiCad `ANT*` / `ANT_FEED` + stackup → `w` microstrip; `f0` → template IFA / meander / stub (segmenti mm + SVG + `.kicad_mod`); zona `antenna` → fit/scale. **OK** (auto-place rame nel PCB = dopo). 4. CPW clearance: **Wave G** (layout). --- diff --git a/frontend/content/changelog.md b/frontend/content/changelog.md index ea62888..4fe93ab 100644 --- a/frontend/content/changelog.md +++ b/frontend/content/changelog.md @@ -2,6 +2,14 @@ What's new in Pinscope. +## 2.28.12 — 2026-09-13 — Antenna templates: IFA / meander / stub + .kicad_mod + +Progetta antenna now returns a full parametric drawing: polyline segments (mm), SVG preview, and a downloadable KiCad footprint. Choose IFA, meander, or stub; geometry scales to the `antenna` zone when present. Still a routing template — no EM/VSWR invented. + +- [New] `antenna_geometry.py` (IFA / meander / stub → segments + SVG + `.kicad_mod`). +- [Changed] `POST /antenna/design` accepts `template`; recipe includes `geometry`. +- [Changed] RF / Impedance Progetta UI: template select, SVG preview, download footprint. + ## 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. diff --git a/frontend/src/components/project/impedance-panel.tsx b/frontend/src/components/project/impedance-panel.tsx index 0db053c..d148636 100644 --- a/frontend/src/components/project/impedance-panel.tsx +++ b/frontend/src/components/project/impedance-panel.tsx @@ -55,6 +55,7 @@ export function ImpedancePanel({ const [extraNets, setExtraNets] = useState(""); const [antenna, setAntenna] = useState(null); const [f0, setF0] = useState("2440"); + const [template, setTemplate] = useState<"ifa" | "meander" | "stub">("ifa"); const [antBusy, setAntBusy] = useState(false); const [antError, setAntError] = useState(null); @@ -163,6 +164,7 @@ export function ImpedancePanel({ h: num(h), er: num(er), t: num(t), + template, }); setAntenna(report); } catch (e) { @@ -177,6 +179,19 @@ export function ImpedancePanel({ void navigator.clipboard.writeText(JSON.stringify(antenna.design, null, 2)); } + function downloadKicadMod() { + const mod = antenna?.design?.geometry?.kicad_mod; + const name = antenna?.design?.geometry?.footprint_name ?? "Antenna"; + if (!mod) return; + const blob = new Blob([mod], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${name}.kicad_mod`; + a.click(); + URL.revokeObjectURL(url); + } + const needsGap = kind === "cpw" || kind === "diff"; const design = antenna?.design; @@ -243,13 +258,29 @@ export function ImpedancePanel({

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. + zone net antenna. Choose a template + (IFA / meander / stub) to get polylines, SVG preview, and a{" "} + .kicad_mod — parametric routing aid, + not an EM result.

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

{antError}

} {design && (
{design.status} + {design.geometry && ( + {design.geometry.fit} + )} {design.detail}
{design.feed_line && ( @@ -299,6 +340,28 @@ export function ImpedancePanel({ {fmt(design.radiator.f0_mhz, 0)} MHz — {design.radiator.note}

)} + {design.geometry?.svg && ( +
+ )} + {design.geometry && design.geometry.fit !== "need_f0" && ( +

+ {design.geometry.template.toUpperCase()} + {design.geometry.total_length_mm != null + ? ` · path ${fmt(design.geometry.total_length_mm)} mm` + : ""} + {design.geometry.length_ideal_mm != null + ? ` · ideal λ/4 ${fmt(design.geometry.length_ideal_mm)} mm` + : ""} + {design.geometry.segments.length + ? ` · ${design.geometry.segments.length} segment(s)` + : ""} + {" — "} + {design.geometry.note} +

+ )} {design.zone && (

Zone {design.zone.net} on {design.zone.layer} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 1d7b982..40fac79 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -747,6 +747,7 @@ export async function designAntenna( h?: number | null; er?: number | null; t?: number | null; + template?: "ifa" | "meander" | "stub"; }, ): Promise { const res = await authFetch( diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index f3185bf..131bcce 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -465,6 +465,20 @@ export interface AntennaVerifyRow { marker_ref?: string | null; } +export interface AntennaGeometry { + template: "ifa" | "meander" | "stub"; + fit: "ok" | "scaled" | "overflow" | "need_f0"; + segments: { points: number[][]; width_mm: number }[]; + total_length_mm?: number | null; + length_ideal_mm?: number | null; + scale?: number; + svg?: string | null; + kicad_mod?: string | null; + footprint_name?: string | null; + note?: string; + detail?: string; +} + export interface AntennaDesignRecipe { status: "ready" | "need_pcb" | "need_stackup" | "need_marker"; feed_point?: Record | null; @@ -481,6 +495,7 @@ export interface AntennaDesignRecipe { f0_mhz?: number | null; note?: string; } | null; + geometry?: AntennaGeometry | null; zone?: { net: string; layer: string; diff --git a/tests/test_antenna_rf.py b/tests/test_antenna_rf.py index 145be44..cd46cff 100644 --- a/tests/test_antenna_rf.py +++ b/tests/test_antenna_rf.py @@ -177,3 +177,79 @@ def test_design_recipe_ready_with_ant_and_stackup(): assert recipe.radiator.length_mm_suggest is not None assert recipe.zone is not None assert recipe.zone.bbox_mm is not None + assert recipe.geometry is not None + assert recipe.geometry.template == "ifa" + assert recipe.geometry.fit in ("ok", "scaled") + assert len(recipe.geometry.segments) >= 2 + assert recipe.geometry.svg and " 10 + assert geo.length_ideal_mm is not None + assert geo.total_length_mm >= geo.length_ideal_mm * 0.9 + assert geo.svg and "path" in geo.svg + assert geo.kicad_mod and '(pad "1"' in geo.kicad_mod + assert len(geo.segments) >= 1 + + +def test_geometry_overflow_tiny_zone(): + from backend.pinscopex.antenna_geometry import build_geometry + + geo = build_geometry( + "ifa", + f0_mhz=2440.0, + w_mm=0.4, + er=4.5, + zone_bbox_mm=(0.0, 0.0, 2.0, 1.0), + feed_xy=(0.0, 0.0), + ) + assert geo.fit == "overflow" + assert not geo.segments + + +def test_design_recipe_meander_template(): + 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=0.0, y=0.0, layer="F.Cu", + pads=[LayoutPad(number="1", x=0.0, y=0.0, net="ANT_FEED")], + ), + }, + nets={"ANT_FEED": 1}, + 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, template="meander", + ) + assert recipe.status == "ready" + assert recipe.geometry is not None + assert recipe.geometry.template == "meander" + assert recipe.geometry.fit == "ok" + assert recipe.geometry.kicad_mod is not None