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
+3
View File
@@ -25,6 +25,9 @@ COPY skills/ /app/skills/
# Changelog: single source of truth for the user-facing Pinscope version.
COPY frontend/content/changelog.md /app/changelog.md
# ImpedenceFinder closed-form engine (no OpenEMS / pcbnew).
COPY vendor/ /app/vendor/
EXPOSE 8080
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8080"]
+2 -1
View File
@@ -10,7 +10,7 @@ from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
from backend.config import settings
from backend.routers import admin, contact, feedback, pipeline, projects, reports, survey
from backend.routers import admin, contact, feedback, impedance, pipeline, projects, reports, survey
from backend.services.projects import ProjectNotFound
from backend.services.storage import LocalStorageBackend
@@ -147,6 +147,7 @@ async def _project_not_found_handler(request: Request, exc: ProjectNotFound):
app.include_router(projects.router, prefix="/api")
app.include_router(pipeline.router, prefix="/api")
app.include_router(reports.router, prefix="/api")
app.include_router(impedance.router, prefix="/api")
app.include_router(admin.router, prefix="/api")
if settings.billing_enabled:
# Import guarded too: with billing disabled the core never loads the
+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"
+2
View File
@@ -18,3 +18,5 @@ PyJWT[crypto]>=2.8
cryptography>=42.0
httpx>=0.27
packaging>=24.0
shapely>=2.0
PyYAML>=6.0
+75
View File
@@ -0,0 +1,75 @@
"""Standalone impedance calculator (no PCB, no findings)."""
from __future__ import annotations
from dataclasses import asdict
from typing import Literal
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from backend.pinscopex.impedance import (
GeometryError,
TraceGeometry,
coupled_diff_z,
cpw_z0,
export_kicad_dru,
microstrip_z0,
solve_width,
stackup_targets,
stripline_z0,
)
router = APIRouter(tags=["impedance"])
class ImpedanceRequest(BaseModel):
mode: Literal["trace", "stackup"] = "trace"
kind: Literal["microstrip", "stripline", "cpw", "diff"] | None = None
h: float
er: float
t: float = 0.035
w: float | None = None
s: float | None = None
target_z: float | None = None
def _z_for_kind(kind: str, geo: TraceGeometry) -> dict:
if kind == "microstrip":
return {"z0": microstrip_z0(geo), "w_mm": geo.w, "s_mm": geo.s, "kind": kind}
if kind == "stripline":
return {"z0": stripline_z0(geo), "w_mm": geo.w, "s_mm": geo.s, "kind": kind}
if kind == "cpw":
return {"z0": cpw_z0(geo), "w_mm": geo.w, "s_mm": geo.s, "kind": kind}
if kind == "diff":
zodd, zeven, zdiff = coupled_diff_z(geo)
return {
"z0": None,
"zodd": zodd,
"zeven": zeven,
"zdiff": zdiff,
"w_mm": geo.w,
"s_mm": geo.s,
"kind": kind,
}
raise GeometryError(f"unknown kind {kind}")
@router.post("/impedance")
def compute_impedance(body: ImpedanceRequest):
try:
if body.mode == "stackup":
s = body.s if body.s is not None else 0.2
targets = stackup_targets(h=body.h, er=body.er, t=body.t, s=s)
return {
"targets": {k: asdict(v) for k, v in targets.items()},
"kicad_dru": export_kicad_dru(targets),
}
kind = body.kind or "microstrip"
w = body.w
if body.target_z is not None:
w = solve_width(kind, body.target_z, body.h, body.er, body.t, s=body.s)
geo = TraceGeometry(h=body.h, er=body.er, t=body.t, w=w, s=body.s)
return _z_for_kind(kind, geo)
except GeometryError as exc:
raise HTTPException(400, str(exc)) from exc
+14
View File
@@ -0,0 +1,14 @@
"""Put vendored ImpedenceFinder on sys.path (closed-form package only)."""
from __future__ import annotations
import sys
from pathlib import Path
VENDOR_DIR = Path(__file__).resolve().parents[1] / "vendor"
def ensure_impedancefinder() -> None:
root = str(VENDOR_DIR)
if root not in sys.path:
sys.path.insert(0, root)