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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -18,3 +18,5 @@ PyJWT[crypto]>=2.8
|
||||
cryptography>=42.0
|
||||
httpx>=0.27
|
||||
packaging>=24.0
|
||||
shapely>=2.0
|
||||
PyYAML>=6.0
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user