Integrate ImpedenceFinder closed-form Z0 into the project Impedance tab.

Use the vendored Hammerstad-Jensen/Cohn engine for microstrip, stripline, and coupled-diff advice. Skip OpenEMS/pcbnew and refuse CPWG rather than inventing a number.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-10 23:14:57 +02:00
co-authored by Cursor
parent 3e43bb6ecb
commit 6fc2ac583d
30 changed files with 2219 additions and 2 deletions
+182
View File
@@ -0,0 +1,182 @@
"""Pinscope facade over ImpedanceFinder's closed-form Z0 solver.
All Z0 numbers come from ImpedenceFinder (`vendor/impedancefinder`,
Hammerstad-Jensen / Cohn as in KiCad pcb_calculator). This module only
validates geometry, inverts width for a target Z, and exports KiCad
custom-rule advice. It never emits Findings. CPWG is not implemented
upstream — we raise instead of inventing a number.
"""
from __future__ import annotations
from dataclasses import dataclass
from backend.vendor_path import ensure_impedancefinder
ensure_impedancefinder()
from impedancefinder import zsolver
class GeometryError(ValueError):
"""Trace geometry is missing, non-physical, or unsupported."""
@dataclass(frozen=True)
class TraceGeometry:
h: float
er: float
t: float
w: float | None = None
s: float | None = None
@dataclass(frozen=True)
class ImpedanceResult:
kind: str
w_mm: float | None = None
s_mm: float | None = None
z0: float | None = None
zodd: float | None = None
zeven: float | None = None
zdiff: float | None = None
formula: str = "impedancefinder"
def _require_positive(name: str, value: float | None) -> float:
if value is None or value <= 0:
raise GeometryError(f"{name} must be > 0")
return float(value)
def microstrip_z0(geo: TraceGeometry) -> float:
h = _require_positive("h", geo.h)
er = _require_positive("er", geo.er)
w = _require_positive("w", geo.w)
t = geo.t
if t < 0:
raise GeometryError("t must be >= 0")
return zsolver.microstrip_z0(w, h, er, t)
def stripline_z0(geo: TraceGeometry) -> float:
h = _require_positive("h", geo.h)
er = _require_positive("er", geo.er)
w = _require_positive("w", geo.w)
t = _require_positive("t", geo.t)
try:
return zsolver.stripline_z0(w, h, er, t)
except ValueError as exc:
raise GeometryError(str(exc)) from exc
def coupled_diff_z(geo: TraceGeometry) -> tuple[float, float, float]:
"""Return (Zodd, Zeven, Zdiff) via ImpedanceFinder IPC-2141A odd-mode."""
s = _require_positive("s", geo.s)
h = _require_positive("h", geo.h)
z0 = microstrip_z0(geo)
zdiff = zsolver.diff_microstrip_z0(
_require_positive("w", geo.w), h, s, geo.er, geo.t
)
zodd = zdiff / 2.0
zeven = 2.0 * z0 - zodd
return (zodd, zeven, zdiff)
def cpw_z0(geo: TraceGeometry) -> float:
_require_positive("h", geo.h)
_require_positive("er", geo.er)
_require_positive("w", geo.w)
_require_positive("s", geo.s)
try:
return zsolver.cpwg_z0(geo.w, geo.h, geo.s, geo.er, geo.t)
except NotImplementedError as exc:
raise GeometryError(str(exc)) from exc
def solve_width(
kind: str,
target_z: float,
h: float,
er: float,
t: float,
s: float | None = None,
) -> float:
_require_positive("target_z", target_z)
_require_positive("h", h)
_require_positive("er", er)
if kind == "stripline":
_require_positive("t", t)
elif t < 0:
raise GeometryError("t must be >= 0")
def z_of(w: float) -> float:
geo = TraceGeometry(h=h, er=er, t=t, w=w, s=s)
if kind == "microstrip":
return microstrip_z0(geo)
if kind == "stripline":
return stripline_z0(geo)
if kind == "diff":
return coupled_diff_z(geo)[2]
if kind == "cpw":
return cpw_z0(geo)
raise GeometryError(f"unknown kind {kind}")
lo, hi = 0.01 * h, 40.0 * h
z_lo, z_hi = z_of(lo), z_of(hi)
if not (min(z_lo, z_hi) <= target_z <= max(z_lo, z_hi)):
raise GeometryError("target_z is outside the solvable width range")
for _ in range(48):
mid = 0.5 * (lo + hi)
zm = z_of(mid)
if zm > target_z:
lo = mid
else:
hi = mid
return 0.5 * (lo + hi)
def stackup_targets(
h: float,
er: float,
t: float,
s: float,
) -> dict[str, ImpedanceResult]:
w50 = solve_width("microstrip", 50.0, h, er, t)
w90 = solve_width("diff", 90.0, h, er, t, s=s)
w100 = solve_width("diff", 100.0, h, er, t, s=s)
z50 = microstrip_z0(TraceGeometry(h=h, er=er, t=t, w=w50))
_, _, zd90 = coupled_diff_z(TraceGeometry(h=h, er=er, t=t, w=w90, s=s))
_, _, zd100 = coupled_diff_z(TraceGeometry(h=h, er=er, t=t, w=w100, s=s))
return {
"microstrip_50": ImpedanceResult(kind="microstrip", w_mm=w50, z0=z50),
"diff_90": ImpedanceResult(kind="diff", w_mm=w90, s_mm=s, zdiff=zd90),
"diff_100": ImpedanceResult(kind="diff", w_mm=w100, s_mm=s, zdiff=zd100),
}
def export_kicad_dru(targets: dict[str, ImpedanceResult]) -> str:
"""KiCad custom-rule advice. The user applies it; Pinscope does not DRC the PCB."""
lines = [
"(version 1)",
"# Pinscope impedance advice (ImpedanceFinder solver) — apply in pcbnew.",
]
mapping = (
("microstrip_50", "PINSCOPE_50OHM", "50Ohm"),
("diff_90", "PINSCOPE_90OHM_USB", "90Ohm"),
("diff_100", "PINSCOPE_100OHM_DIFF", "100Ohm"),
)
for key, rule, netclass in mapping:
r = targets[key]
w = r.w_mm
if w is None:
continue
lines.append("")
lines.append(f"(rule {rule}")
lines.append(f' (constraint track_width (min {w:.4f}mm) (opt {w:.4f}mm) (max {w:.4f}mm))')
if r.s_mm:
lines.append(
f" (constraint diff_pair_gap (min {r.s_mm:.4f}mm) "
f"(opt {r.s_mm:.4f}mm) (max {r.s_mm:.4f}mm))"
)
lines.append(f' (condition "A.NetClass == \'{netclass}\'"))')
return "\n".join(lines) + "\n"