Add IFA/meander/stub antenna templates with SVG and KiCad export.
Progetta now returns a parametric radiator geometry (segments + preview + .kicad_mod) so the layout can be replicated without inventing EM results. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 '<svg xmlns="http://www.w3.org/2000/svg" width="120" height="80"/>'
|
||||||
|
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'<path d="{" ".join(d_parts)}" fill="none" stroke="#1a1a1a" '
|
||||||
|
f'stroke-width="{sw:.3f}" stroke-linecap="round" '
|
||||||
|
f'stroke-linejoin="round"/>'
|
||||||
|
)
|
||||||
|
paths.append(
|
||||||
|
f'<circle cx="0" cy="0" r="{max(default_w, 0.3):.3f}" fill="#c45c26"/>'
|
||||||
|
)
|
||||||
|
vb = f"{xmin:.3f} {-ymax:.3f} {bw:.3f} {bh:.3f}"
|
||||||
|
body = "\n ".join(paths)
|
||||||
|
return (
|
||||||
|
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="{vb}" '
|
||||||
|
f'width="280" height="160" style="background:#f7f5f2">'
|
||||||
|
f"\n {body}\n</svg>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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"
|
||||||
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
Verify: matching topology from IC ANT/RF pin toward ANT footprint / ANT_FEED.
|
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
|
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
|
from __future__ import annotations
|
||||||
@@ -15,6 +16,11 @@ from typing import Any, Literal
|
|||||||
|
|
||||||
from pydantic import BaseModel
|
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.impedance import GeometryError, solve_width
|
||||||
from backend.pinscopex.models import (
|
from backend.pinscopex.models import (
|
||||||
ComponentType,
|
ComponentType,
|
||||||
@@ -87,6 +93,7 @@ class AntennaDesignRecipe(BaseModel):
|
|||||||
feed_point: dict[str, Any] | None = None
|
feed_point: dict[str, Any] | None = None
|
||||||
feed_line: AntennaFeedLine | None = None
|
feed_line: AntennaFeedLine | None = None
|
||||||
radiator: AntennaRadiator | None = None
|
radiator: AntennaRadiator | None = None
|
||||||
|
geometry: AntennaGeometry | None = None
|
||||||
zone: AntennaZoneInfo | None = None
|
zone: AntennaZoneInfo | None = None
|
||||||
keepout_checklist: list[str] = []
|
keepout_checklist: list[str] = []
|
||||||
detail: str = ""
|
detail: str = ""
|
||||||
@@ -111,6 +118,7 @@ def build_antenna_report(
|
|||||||
h_mm: float | None = None,
|
h_mm: float | None = None,
|
||||||
er: float | None = None,
|
er: float | None = None,
|
||||||
t_mm: float | None = None,
|
t_mm: float | None = None,
|
||||||
|
template: AntennaTemplate = "ifa",
|
||||||
) -> AntennaReport:
|
) -> AntennaReport:
|
||||||
verify = _verify(graph, layout, impedance_nets, target_z_ohm)
|
verify = _verify(graph, layout, impedance_nets, target_z_ohm)
|
||||||
design = build_design_recipe(
|
design = build_design_recipe(
|
||||||
@@ -118,6 +126,7 @@ def build_antenna_report(
|
|||||||
f0_mhz=f0_mhz,
|
f0_mhz=f0_mhz,
|
||||||
target_z_ohm=target_z_ohm,
|
target_z_ohm=target_z_ohm,
|
||||||
h_mm=h_mm,
|
h_mm=h_mm,
|
||||||
|
template=template,
|
||||||
er=er,
|
er=er,
|
||||||
t_mm=t_mm,
|
t_mm=t_mm,
|
||||||
)
|
)
|
||||||
@@ -133,6 +142,7 @@ def build_design_recipe(
|
|||||||
h_mm: float | None = None,
|
h_mm: float | None = None,
|
||||||
er: float | None = None,
|
er: float | None = None,
|
||||||
t_mm: float | None = None,
|
t_mm: float | None = None,
|
||||||
|
template: AntennaTemplate = "ifa",
|
||||||
) -> AntennaDesignRecipe:
|
) -> AntennaDesignRecipe:
|
||||||
marker = _find_marker(graph, layout)
|
marker = _find_marker(graph, layout)
|
||||||
zone = _find_antenna_zone(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.",
|
"Short GND return from the matching network to the RF reference.",
|
||||||
"Avoid long stubs and right angles on the 50 Ω feed.",
|
"Avoid long stubs and right angles on the 50 Ω feed.",
|
||||||
"Place matching parts close to the RF pin / feed point.",
|
"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)
|
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,
|
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(
|
return AntennaDesignRecipe(
|
||||||
status="ready",
|
status="ready",
|
||||||
feed_point=marker,
|
feed_point=marker,
|
||||||
feed_line=feed,
|
feed_line=feed,
|
||||||
radiator=radiator,
|
radiator=radiator,
|
||||||
|
geometry=geometry,
|
||||||
zone=zone,
|
zone=zone,
|
||||||
keepout_checklist=checklist,
|
keepout_checklist=checklist,
|
||||||
detail="Recipe ready — draw the feed at w_mm; radiator length is an estimate only.",
|
detail=detail,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ class AntennaDesignRequest(BaseModel):
|
|||||||
h: float | None = None
|
h: float | None = None
|
||||||
er: float | None = None
|
er: float | None = None
|
||||||
t: 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]:
|
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,
|
h_mm=body.h,
|
||||||
er=body.er,
|
er=body.er,
|
||||||
t_mm=body.t,
|
t_mm=body.t,
|
||||||
|
template=body.template,
|
||||||
)
|
)
|
||||||
# Recompute design with explicit params (same as report.design but ensure POST body wins)
|
# Recompute design with explicit params (same as report.design but ensure POST body wins)
|
||||||
report.design = build_design_recipe(
|
report.design = build_design_recipe(
|
||||||
@@ -200,5 +202,6 @@ async def post_project_antenna_design(
|
|||||||
h_mm=body.h,
|
h_mm=body.h,
|
||||||
er=body.er,
|
er=body.er,
|
||||||
t_mm=body.t,
|
t_mm=body.t,
|
||||||
|
template=body.template,
|
||||||
)
|
)
|
||||||
return report.model_dump(mode="json")
|
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** |
|
| 4 Filtri | `check_filters` (solo con poli/numeri in specs) | Niente \(f_c\) inventata | **OK** |
|
||||||
| 5 Capacità PI | Decoupling sulla net; derating V; DC-bias/ESR se c’è il numero; mm sul PCB (`PS-PLC-001`) | — | **OK** |
|
| 5 Capacità PI | Decoupling sulla net; derating V; DC-bias/ESR se c’è il numero; mm sul PCB (`PS-PLC-001`) | — | **OK** |
|
||||||
| 6 Elettrico | Pin mux; I2C/reset pull-up; LED; sequencing/IR/power margin se c’è il parametro | Senza numero in specs → skip | **OK** |
|
| 6 Elettrico | Pin mux; I2C/reset pull-up; LED; sequencing/IR/power margin se c’è il parametro | Senza numero in specs → skip | **OK** |
|
||||||
| 7 RF | Tab **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 | — |
|
| 8 HV / isolation | — | Serve `layout_rules` + V/mm dal datasheet, non IEC inventato | — |
|
||||||
| 9 Termico | `check_thermal` se θJA/I sono in specs; via courtyard vs `min_via_count` | Niente \(T_j\) senza parametro | **OK** |
|
| 9 Termico | `check_thermal` se θJA/I sono in specs; via courtyard vs `min_via_count` | Niente \(T_j\) senza parametro | **OK** |
|
||||||
| 10 SI / DNP | DNP enable; `PS-SI-001` solo con `length_match` mm | Niente 3W/crosstalk inventati | **OK** |
|
| 10 SI / DNP | DNP enable; `PS-SI-001` solo con `length_match` mm | Niente 3W/crosstalk inventati | **OK** |
|
||||||
@@ -428,7 +428,7 @@ Passi:
|
|||||||
|
|
||||||
1. Topologia π/T tra pin ANT e connettore/antenna (stesso matcher filtri). **OK** — sezione Verify in RF/Impedance.
|
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`).
|
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).
|
4. CPW clearance: **Wave G** (layout).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -2,6 +2,14 @@
|
|||||||
|
|
||||||
What's new in Pinscope.
|
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
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export function ImpedancePanel({
|
|||||||
const [extraNets, setExtraNets] = useState("");
|
const [extraNets, setExtraNets] = useState("");
|
||||||
const [antenna, setAntenna] = useState<AntennaReport | null>(null);
|
const [antenna, setAntenna] = useState<AntennaReport | null>(null);
|
||||||
const [f0, setF0] = useState("2440");
|
const [f0, setF0] = useState("2440");
|
||||||
|
const [template, setTemplate] = useState<"ifa" | "meander" | "stub">("ifa");
|
||||||
const [antBusy, setAntBusy] = useState(false);
|
const [antBusy, setAntBusy] = useState(false);
|
||||||
const [antError, setAntError] = useState<string | null>(null);
|
const [antError, setAntError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -163,6 +164,7 @@ export function ImpedancePanel({
|
|||||||
h: num(h),
|
h: num(h),
|
||||||
er: num(er),
|
er: num(er),
|
||||||
t: num(t),
|
t: num(t),
|
||||||
|
template,
|
||||||
});
|
});
|
||||||
setAntenna(report);
|
setAntenna(report);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -177,6 +179,19 @@ export function ImpedancePanel({
|
|||||||
void navigator.clipboard.writeText(JSON.stringify(antenna.design, null, 2));
|
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 needsGap = kind === "cpw" || kind === "diff";
|
||||||
const design = antenna?.design;
|
const design = antenna?.design;
|
||||||
|
|
||||||
@@ -243,13 +258,29 @@ export function ImpedancePanel({
|
|||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Mark the feed join in KiCad (<code className="text-xs">ANT*</code>{" "}
|
Mark the feed join in KiCad (<code className="text-xs">ANT*</code>{" "}
|
||||||
footprint or net <code className="text-xs">ANT_FEED</code>). Optional
|
footprint or net <code className="text-xs">ANT_FEED</code>). Optional
|
||||||
zone net <code className="text-xs">antenna</code>. Auto-draw in the
|
zone net <code className="text-xs">antenna</code>. Choose a template
|
||||||
zone comes later — this returns w / Z0 / length to draw by hand.
|
(IFA / meander / stub) to get polylines, SVG preview, and a{" "}
|
||||||
|
<code className="text-xs">.kicad_mod</code> — parametric routing aid,
|
||||||
|
not an EM result.
|
||||||
</p>
|
</p>
|
||||||
{!hasPcb && (
|
{!hasPcb && (
|
||||||
<PcbUploadButton projectId={projectId} onUploaded={onPcbUploaded} />
|
<PcbUploadButton projectId={projectId} onUploaded={onPcbUploaded} />
|
||||||
)}
|
)}
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
|
||||||
|
<label className="space-y-1">
|
||||||
|
<Label>Template</Label>
|
||||||
|
<select
|
||||||
|
className="h-8 w-full rounded-lg border border-input bg-transparent px-2 text-sm"
|
||||||
|
value={template}
|
||||||
|
onChange={(e) =>
|
||||||
|
setTemplate(e.target.value as "ifa" | "meander" | "stub")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="ifa">IFA</option>
|
||||||
|
<option value="meander">Meander</option>
|
||||||
|
<option value="stub">Stub</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<label className="space-y-1">
|
<label className="space-y-1">
|
||||||
<Label>f0 (MHz)</Label>
|
<Label>f0 (MHz)</Label>
|
||||||
<Input value={f0} onChange={(e) => setF0(e.target.value)} placeholder="2440" />
|
<Input value={f0} onChange={(e) => setF0(e.target.value)} placeholder="2440" />
|
||||||
@@ -278,12 +309,22 @@ export function ImpedancePanel({
|
|||||||
>
|
>
|
||||||
Copy JSON
|
Copy JSON
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={downloadKicadMod}
|
||||||
|
disabled={!design?.geometry?.kicad_mod}
|
||||||
|
>
|
||||||
|
Download .kicad_mod
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{antError && <p className="text-sm text-destructive">{antError}</p>}
|
{antError && <p className="text-sm text-destructive">{antError}</p>}
|
||||||
{design && (
|
{design && (
|
||||||
<div className="rounded-lg border p-3 text-sm space-y-2">
|
<div className="rounded-lg border p-3 text-sm space-y-2">
|
||||||
<div className="flex flex-wrap gap-2 items-center">
|
<div className="flex flex-wrap gap-2 items-center">
|
||||||
<Badge variant="secondary">{design.status}</Badge>
|
<Badge variant="secondary">{design.status}</Badge>
|
||||||
|
{design.geometry && (
|
||||||
|
<Badge variant="outline">{design.geometry.fit}</Badge>
|
||||||
|
)}
|
||||||
<span className="text-muted-foreground text-xs">{design.detail}</span>
|
<span className="text-muted-foreground text-xs">{design.detail}</span>
|
||||||
</div>
|
</div>
|
||||||
{design.feed_line && (
|
{design.feed_line && (
|
||||||
@@ -299,6 +340,28 @@ export function ImpedancePanel({
|
|||||||
{fmt(design.radiator.f0_mhz, 0)} MHz — {design.radiator.note}
|
{fmt(design.radiator.f0_mhz, 0)} MHz — {design.radiator.note}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{design.geometry?.svg && (
|
||||||
|
<div
|
||||||
|
className="overflow-auto rounded-md border bg-muted/30 p-2"
|
||||||
|
dangerouslySetInnerHTML={{ __html: design.geometry.svg }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{design.geometry && design.geometry.fit !== "need_f0" && (
|
||||||
|
<p className="text-xs text-muted-foreground tabular-nums">
|
||||||
|
{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}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{design.zone && (
|
{design.zone && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Zone {design.zone.net} on {design.zone.layer}
|
Zone {design.zone.net} on {design.zone.layer}
|
||||||
|
|||||||
@@ -747,6 +747,7 @@ export async function designAntenna(
|
|||||||
h?: number | null;
|
h?: number | null;
|
||||||
er?: number | null;
|
er?: number | null;
|
||||||
t?: number | null;
|
t?: number | null;
|
||||||
|
template?: "ifa" | "meander" | "stub";
|
||||||
},
|
},
|
||||||
): Promise<AntennaReport> {
|
): Promise<AntennaReport> {
|
||||||
const res = await authFetch(
|
const res = await authFetch(
|
||||||
|
|||||||
@@ -465,6 +465,20 @@ export interface AntennaVerifyRow {
|
|||||||
marker_ref?: string | null;
|
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 {
|
export interface AntennaDesignRecipe {
|
||||||
status: "ready" | "need_pcb" | "need_stackup" | "need_marker";
|
status: "ready" | "need_pcb" | "need_stackup" | "need_marker";
|
||||||
feed_point?: Record<string, unknown> | null;
|
feed_point?: Record<string, unknown> | null;
|
||||||
@@ -481,6 +495,7 @@ export interface AntennaDesignRecipe {
|
|||||||
f0_mhz?: number | null;
|
f0_mhz?: number | null;
|
||||||
note?: string;
|
note?: string;
|
||||||
} | null;
|
} | null;
|
||||||
|
geometry?: AntennaGeometry | null;
|
||||||
zone?: {
|
zone?: {
|
||||||
net: string;
|
net: string;
|
||||||
layer: string;
|
layer: string;
|
||||||
|
|||||||
@@ -177,3 +177,79 @@ def test_design_recipe_ready_with_ant_and_stackup():
|
|||||||
assert recipe.radiator.length_mm_suggest is not None
|
assert recipe.radiator.length_mm_suggest is not None
|
||||||
assert recipe.zone is not None
|
assert recipe.zone is not None
|
||||||
assert recipe.zone.bbox_mm 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 "<svg" in recipe.geometry.svg
|
||||||
|
assert recipe.geometry.kicad_mod and '(footprint "' in recipe.geometry.kicad_mod
|
||||||
|
assert "fp_line" in recipe.geometry.kicad_mod
|
||||||
|
|
||||||
|
|
||||||
|
def test_geometry_templates_produce_export():
|
||||||
|
from backend.pinscopex.antenna_geometry import build_geometry
|
||||||
|
|
||||||
|
for tmpl in ("ifa", "meander", "stub"):
|
||||||
|
geo = build_geometry(tmpl, f0_mhz=2440.0, w_mm=0.4, er=4.5)
|
||||||
|
assert geo.fit == "ok"
|
||||||
|
assert geo.total_length_mm is not None and geo.total_length_mm > 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
|
||||||
|
|||||||
Reference in New Issue
Block a user