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.
|
# Changelog: single source of truth for the user-facing Pinscope version.
|
||||||
COPY frontend/content/changelog.md /app/changelog.md
|
COPY frontend/content/changelog.md /app/changelog.md
|
||||||
|
|
||||||
|
# ImpedenceFinder closed-form engine (no OpenEMS / pcbnew).
|
||||||
|
COPY vendor/ /app/vendor/
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "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 starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
|
||||||
from backend.config import settings
|
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.projects import ProjectNotFound
|
||||||
from backend.services.storage import LocalStorageBackend
|
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(projects.router, prefix="/api")
|
||||||
app.include_router(pipeline.router, prefix="/api")
|
app.include_router(pipeline.router, prefix="/api")
|
||||||
app.include_router(reports.router, prefix="/api")
|
app.include_router(reports.router, prefix="/api")
|
||||||
|
app.include_router(impedance.router, prefix="/api")
|
||||||
app.include_router(admin.router, prefix="/api")
|
app.include_router(admin.router, prefix="/api")
|
||||||
if settings.billing_enabled:
|
if settings.billing_enabled:
|
||||||
# Import guarded too: with billing disabled the core never loads the
|
# 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
|
cryptography>=42.0
|
||||||
httpx>=0.27
|
httpx>=0.27
|
||||||
packaging>=24.0
|
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)
|
||||||
@@ -2,6 +2,14 @@
|
|||||||
|
|
||||||
What's new in Pinscope.
|
What's new in Pinscope.
|
||||||
|
|
||||||
|
## 2.18.0 — 2026-09-10 — ImpedenceFinder calculator
|
||||||
|
|
||||||
|
The Impedance tab uses the closed-form engine from ImpedenceFinder (Hammerstad–Jensen / Cohn), not a second formula set and not OpenEMS.
|
||||||
|
|
||||||
|
- [New] Project tab Impedance: microstrip, stripline, coupled-diff Z0, stackup → 50/90/100 Ω widths, download `.kicad_dru` advice.
|
||||||
|
- [New] Vendored ImpedenceFinder core under `vendor/impedancefinder/` (no gerber2ems, no pcbnew).
|
||||||
|
- CPWG stays unimplemented — no invented number.
|
||||||
|
|
||||||
## 2.17.0 — 2026-09-10 — Lifecycle and datasheet extras
|
## 2.17.0 — 2026-09-10 — Lifecycle and datasheet extras
|
||||||
|
|
||||||
Distributor lifecycle is a cached check, not a review scrape. Errata and layout_rules stay structured and skip when the catalog or the PDF has no number.
|
Distributor lifecycle is a cached check, not a review scrape. Errata and layout_rules stay structured and skip when the catalog or the PDF has no number.
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ import {
|
|||||||
Upload,
|
Upload,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useOptionalUser } from "@/hooks/use-optional-auth";
|
import { useOptionalUser } from "@/hooks/use-optional-auth";
|
||||||
import { PdfViewerSheet } from "@/components/pdf/pdf-viewer-sheet";
|
import { ImpedancePanel } from "@/components/project/impedance-panel";
|
||||||
|
|
||||||
export default function ProjectDetailPage({
|
export default function ProjectDetailPage({
|
||||||
params,
|
params,
|
||||||
@@ -347,6 +347,8 @@ export default function ProjectDetailPage({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{tab === "impedance" && <ImpedancePanel />}
|
||||||
|
|
||||||
{tab === "logs" && (
|
{tab === "logs" && (
|
||||||
<ApiLogsSection logs={logs} />
|
<ApiLogsSection logs={logs} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
TableProperties,
|
TableProperties,
|
||||||
Loader2,
|
Loader2,
|
||||||
Zap,
|
Zap,
|
||||||
|
Ruler,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
MessageSquareWarning,
|
MessageSquareWarning,
|
||||||
Library,
|
Library,
|
||||||
@@ -179,6 +180,7 @@ const PROJECT_NAV_ITEMS: NavItem[] = [
|
|||||||
{ type: "route", path: "/report", label: "Report", icon: ClipboardList },
|
{ type: "route", path: "/report", label: "Report", icon: ClipboardList },
|
||||||
{ type: "tab", tab: "bom", label: "BOM", icon: TableProperties },
|
{ type: "tab", tab: "bom", label: "BOM", icon: TableProperties },
|
||||||
{ type: "tab", tab: "derating", label: "Derating", icon: Zap },
|
{ type: "tab", tab: "derating", label: "Derating", icon: Zap },
|
||||||
|
{ type: "tab", tab: "impedance", label: "Impedance", icon: Ruler },
|
||||||
{ type: "tab", tab: "logs", label: "Logs", icon: ScrollText, adminOnly: true },
|
{ type: "tab", tab: "logs", label: "Logs", icon: ScrollText, adminOnly: true },
|
||||||
{ type: "tab", tab: "settings", label: "Settings", icon: Settings },
|
{ type: "tab", tab: "settings", label: "Settings", icon: Settings },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { computeImpedance } from "@/lib/api";
|
||||||
|
import type {
|
||||||
|
ImpedanceKind,
|
||||||
|
ImpedanceStackupResult,
|
||||||
|
ImpedanceTraceResult,
|
||||||
|
} from "@/lib/types";
|
||||||
|
|
||||||
|
function num(v: string): number {
|
||||||
|
return Number.parseFloat(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(n: number | null | undefined, digits = 2): string {
|
||||||
|
if (n == null || Number.isNaN(n)) return "—";
|
||||||
|
return n.toFixed(digits);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImpedancePanel() {
|
||||||
|
const [kind, setKind] = useState<ImpedanceKind>("microstrip");
|
||||||
|
const [h, setH] = useState("0.20");
|
||||||
|
const [er, setEr] = useState("4.5");
|
||||||
|
const [t, setT] = useState("0.035");
|
||||||
|
const [w, setW] = useState("0.35");
|
||||||
|
const [s, setS] = useState("0.20");
|
||||||
|
const [targetZ, setTargetZ] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [trace, setTrace] = useState<ImpedanceTraceResult | null>(null);
|
||||||
|
const [stackup, setStackup] = useState<ImpedanceStackupResult | null>(null);
|
||||||
|
|
||||||
|
async function runTrace() {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const tz = targetZ.trim() === "" ? null : num(targetZ);
|
||||||
|
const result = (await computeImpedance({
|
||||||
|
mode: "trace",
|
||||||
|
kind,
|
||||||
|
h: num(h),
|
||||||
|
er: num(er),
|
||||||
|
t: num(t),
|
||||||
|
w: tz != null ? null : num(w),
|
||||||
|
s: kind === "microstrip" || kind === "stripline" ? null : num(s),
|
||||||
|
target_z: tz,
|
||||||
|
})) as ImpedanceTraceResult;
|
||||||
|
setTrace(result);
|
||||||
|
if (result.w_mm != null) setW(result.w_mm.toFixed(4));
|
||||||
|
} catch (e) {
|
||||||
|
setTrace(null);
|
||||||
|
setError(e instanceof Error ? e.message : "Compute failed");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runStackup() {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = (await computeImpedance({
|
||||||
|
mode: "stackup",
|
||||||
|
h: num(h),
|
||||||
|
er: num(er),
|
||||||
|
t: num(t),
|
||||||
|
s: num(s),
|
||||||
|
})) as ImpedanceStackupResult;
|
||||||
|
setStackup(result);
|
||||||
|
} catch (e) {
|
||||||
|
setStackup(null);
|
||||||
|
setError(e instanceof Error ? e.message : "Stackup failed");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadDru() {
|
||||||
|
if (!stackup?.kicad_dru) return;
|
||||||
|
const blob = new Blob([stackup.kicad_dru], { type: "text/plain" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = "pinscope.kicad_dru";
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
const needsGap = kind === "cpw" || kind === "diff";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm">Impedance calculator</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
IPC-2141 / Hammerstad–Jensen from ImpedenceFinder (same as KiCad
|
||||||
|
pcb_calculator). Advice only — this tab does not sample the PCB
|
||||||
|
and does not emit findings. CPWG is not implemented.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||||
|
<label className="space-y-1">
|
||||||
|
<Label>Kind</Label>
|
||||||
|
<select
|
||||||
|
className="h-8 w-full rounded-lg border border-input bg-transparent px-2 text-sm"
|
||||||
|
value={kind}
|
||||||
|
onChange={(e) => setKind(e.target.value as ImpedanceKind)}
|
||||||
|
>
|
||||||
|
<option value="microstrip">Microstrip</option>
|
||||||
|
<option value="stripline">Stripline</option>
|
||||||
|
<option value="diff">Coupled diff</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="space-y-1">
|
||||||
|
<Label>h (mm)</Label>
|
||||||
|
<Input value={h} onChange={(e) => setH(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="space-y-1">
|
||||||
|
<Label>εr</Label>
|
||||||
|
<Input value={er} onChange={(e) => setEr(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="space-y-1">
|
||||||
|
<Label>t (mm)</Label>
|
||||||
|
<Input value={t} onChange={(e) => setT(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="space-y-1">
|
||||||
|
<Label>w (mm)</Label>
|
||||||
|
<Input value={w} onChange={(e) => setW(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="space-y-1">
|
||||||
|
<Label>s gap (mm)</Label>
|
||||||
|
<Input
|
||||||
|
value={s}
|
||||||
|
onChange={(e) => setS(e.target.value)}
|
||||||
|
disabled={!needsGap && !stackup}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="space-y-1 col-span-2 sm:col-span-1">
|
||||||
|
<Label>Target Z0 (optional)</Label>
|
||||||
|
<Input
|
||||||
|
value={targetZ}
|
||||||
|
onChange={(e) => setTargetZ(e.target.value)}
|
||||||
|
placeholder="solve w"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button onClick={runTrace} disabled={busy}>
|
||||||
|
Compute Z
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={runStackup} disabled={busy}>
|
||||||
|
Stackup 50 / 90 / 100
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
|
{trace && (
|
||||||
|
<p className="text-sm tabular-nums">
|
||||||
|
{trace.z0 != null && <>Z0 = {fmt(trace.z0)} Ω</>}
|
||||||
|
{trace.zdiff != null && (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
Zdiff = {fmt(trace.zdiff)} Ω (odd {fmt(trace.zodd)}, even{" "}
|
||||||
|
{fmt(trace.zeven)})
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{trace.w_mm != null && <> · w = {fmt(trace.w_mm, 4)} mm</>}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{stackup && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm">Suggested widths (apply in KiCad)</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-muted-foreground">
|
||||||
|
<th className="py-1">Target</th>
|
||||||
|
<th>w mm</th>
|
||||||
|
<th>s mm</th>
|
||||||
|
<th>Z</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{Object.entries(stackup.targets).map(([key, row]) => (
|
||||||
|
<tr key={key} className="border-t border-border">
|
||||||
|
<td className="py-1">{key}</td>
|
||||||
|
<td className="tabular-nums">{fmt(row.w_mm, 4)}</td>
|
||||||
|
<td className="tabular-nums">{fmt(row.s_mm, 4)}</td>
|
||||||
|
<td className="tabular-nums">
|
||||||
|
{row.z0 != null ? `${fmt(row.z0)} Ω` : `${fmt(row.zdiff)} Ω diff`}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<Button variant="outline" onClick={downloadDru}>
|
||||||
|
Download pinscope.kicad_dru
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,9 @@ import type {
|
|||||||
CreditSnapshot,
|
CreditSnapshot,
|
||||||
DesignGraph,
|
DesignGraph,
|
||||||
DeratingRow,
|
DeratingRow,
|
||||||
|
ImpedanceKind,
|
||||||
|
ImpedanceStackupResult,
|
||||||
|
ImpedanceTraceResult,
|
||||||
EdifSubDesign,
|
EdifSubDesign,
|
||||||
FindingComment,
|
FindingComment,
|
||||||
LcscPayload,
|
LcscPayload,
|
||||||
@@ -582,6 +585,28 @@ export async function fetchDerating(
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function computeImpedance(body: {
|
||||||
|
mode: "trace" | "stackup";
|
||||||
|
kind?: ImpedanceKind;
|
||||||
|
h: number;
|
||||||
|
er: number;
|
||||||
|
t: number;
|
||||||
|
w?: number | null;
|
||||||
|
s?: number | null;
|
||||||
|
target_z?: number | null;
|
||||||
|
}): Promise<ImpedanceTraceResult | ImpedanceStackupResult> {
|
||||||
|
const res = await authFetch(`${BASE}/api/impedance`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const detail = await res.json().catch(() => ({ detail: res.statusText }));
|
||||||
|
throw new Error(typeof detail.detail === "string" ? detail.detail : "Impedance compute failed");
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchReport(
|
export async function fetchReport(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
): Promise<ValidationReport> {
|
): Promise<ValidationReport> {
|
||||||
|
|||||||
@@ -320,6 +320,32 @@ export interface DeratingSettings {
|
|||||||
electrolytic: number;
|
electrolytic: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ImpedanceKind = "microstrip" | "stripline" | "cpw" | "diff";
|
||||||
|
|
||||||
|
export interface ImpedanceTraceResult {
|
||||||
|
kind: ImpedanceKind;
|
||||||
|
z0?: number | null;
|
||||||
|
zodd?: number | null;
|
||||||
|
zeven?: number | null;
|
||||||
|
zdiff?: number | null;
|
||||||
|
w_mm?: number | null;
|
||||||
|
s_mm?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImpedanceTarget {
|
||||||
|
kind: string;
|
||||||
|
w_mm: number | null;
|
||||||
|
s_mm: number | null;
|
||||||
|
z0: number | null;
|
||||||
|
zdiff: number | null;
|
||||||
|
formula: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImpedanceStackupResult {
|
||||||
|
targets: Record<string, ImpedanceTarget>;
|
||||||
|
kicad_dru: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface NetlistPreviewDesignator {
|
export interface NetlistPreviewDesignator {
|
||||||
ref: string;
|
ref: string;
|
||||||
pins: { number: string; net_name: string }[];
|
pins: { number: string; net_name: string }[];
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ REPO_ROOT = Path(__file__).resolve().parents[1]
|
|||||||
if str(REPO_ROOT) not in sys.path:
|
if str(REPO_ROOT) not in sys.path:
|
||||||
sys.path.insert(0, str(REPO_ROOT))
|
sys.path.insert(0, str(REPO_ROOT))
|
||||||
|
|
||||||
|
from backend.vendor_path import ensure_impedancefinder
|
||||||
|
|
||||||
|
ensure_impedancefinder()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def _disable_llm_post_passes(monkeypatch):
|
def _disable_llm_post_passes(monkeypatch):
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""Synthetic fixtures for the pure engine — no board_model, no pcbnew."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from impedancefinder.model import DielectricLayer, Point2D, Stackup, TraceSegment, ZonePolygon
|
||||||
|
|
||||||
|
|
||||||
|
def rect(x0: float, y0: float, x1: float, y1: float) -> tuple[Point2D, ...]:
|
||||||
|
return (Point2D(x0, y0), Point2D(x1, y0), Point2D(x1, y1), Point2D(x0, y1))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def stackup_4layer() -> Stackup:
|
||||||
|
return Stackup(
|
||||||
|
copper_layer_names=("F.Cu", "In1.Cu", "In2.Cu", "B.Cu"),
|
||||||
|
dielectrics=(
|
||||||
|
DielectricLayer("prepreg_top", 4.3, 0.15),
|
||||||
|
DielectricLayer("core", 4.4, 0.7),
|
||||||
|
DielectricLayer("prepreg_bottom", 4.3, 0.15),
|
||||||
|
),
|
||||||
|
copper_thickness_mm=0.035,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def full_ground_plane() -> ZonePolygon:
|
||||||
|
"""A ground pour on In1.Cu with no voids, spanning the whole test area."""
|
||||||
|
return ZonePolygon(net="GND", layer="In1.Cu", outlines_mm=(rect(-5, -5, 50, 5),))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def split_ground_plane() -> ZonePolygon:
|
||||||
|
"""A ground pour on In1.Cu with a gap between x=4mm and x=6mm."""
|
||||||
|
return ZonePolygon(
|
||||||
|
net="GND",
|
||||||
|
layer="In1.Cu",
|
||||||
|
outlines_mm=(rect(-5, -5, 4, 5), rect(6, -5, 50, 5)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def clean_run_segment() -> TraceSegment:
|
||||||
|
return TraceSegment(net="SIG", layer="F.Cu", start=Point2D(0, 0), end=Point2D(10, 0), width_mm=0.2)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def neckdown_segments() -> tuple[TraceSegment, ...]:
|
||||||
|
"""A trace that narrows partway along its run."""
|
||||||
|
return (
|
||||||
|
TraceSegment(net="SIG", layer="F.Cu", start=Point2D(0, 0), end=Point2D(5, 0), width_mm=0.3),
|
||||||
|
TraceSegment(net="SIG", layer="F.Cu", start=Point2D(5, 0), end=Point2D(10, 0), width_mm=0.12),
|
||||||
|
)
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from impedancefinder import geometry, net_walk, planes
|
||||||
|
from impedancefinder.model import Point2D, SamplePoint, Stackup, Topology, TraceSegment, ZonePolygon
|
||||||
|
|
||||||
|
from .conftest import rect
|
||||||
|
|
||||||
|
|
||||||
|
def _sample(layer: str = "F.Cu", x_mm: float = 0.0, width_mm: float = 0.2) -> SamplePoint:
|
||||||
|
return SamplePoint(net="SIG", layer=layer, distance_along_net_mm=x_mm, position=Point2D(x_mm, 0), width_mm=width_mm)
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_outer_layer_with_plane_is_microstrip(stackup_4layer, full_ground_plane):
|
||||||
|
sample = _sample()
|
||||||
|
context = planes.resolve_reference_planes(stackup_4layer, (full_ground_plane,), sample)
|
||||||
|
topology = geometry.classify_topology(sample, stackup_4layer, (full_ground_plane,), context)
|
||||||
|
assert topology is Topology.MICROSTRIP
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_inner_layer_is_stripline(stackup_4layer):
|
||||||
|
top_plane = ZonePolygon(net="GND", layer="F.Cu", outlines_mm=(rect(-5, -5, 50, 5),))
|
||||||
|
bottom_plane = ZonePolygon(net="GND", layer="In2.Cu", outlines_mm=(rect(-5, -5, 50, 5),))
|
||||||
|
sample = _sample(layer="In1.Cu", width_mm=0.15)
|
||||||
|
zones = (top_plane, bottom_plane)
|
||||||
|
context = planes.resolve_reference_planes(stackup_4layer, zones, sample)
|
||||||
|
topology = geometry.classify_topology(sample, stackup_4layer, zones, context)
|
||||||
|
assert topology is Topology.STRIPLINE
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_with_no_adjacent_copper_layer_is_unknown():
|
||||||
|
# A genuine single-copper-layer board: no adjacent layer can exist at
|
||||||
|
# all, unlike a 2-layer board whose reference plane merely has a void
|
||||||
|
# (which still counts as "a plane" for classification, just flagged).
|
||||||
|
stackup = Stackup(copper_layer_names=("F.Cu",), dielectrics=())
|
||||||
|
sample = _sample()
|
||||||
|
context = planes.resolve_reference_planes(stackup, (), sample)
|
||||||
|
topology = geometry.classify_topology(sample, stackup, (), context)
|
||||||
|
assert topology is Topology.UNKNOWN
|
||||||
|
|
||||||
|
|
||||||
|
def test_cpwg_classification_surfaces_not_implemented_flag(stackup_4layer, full_ground_plane):
|
||||||
|
# Coplanar ground pour on the trace's own layer, close enough to count.
|
||||||
|
coplanar_gnd = ZonePolygon(net="GND", layer="F.Cu", outlines_mm=(rect(0.3, -5, 50, 5),))
|
||||||
|
sample = _sample()
|
||||||
|
zones = (full_ground_plane, coplanar_gnd)
|
||||||
|
context = planes.resolve_reference_planes(stackup_4layer, zones, sample)
|
||||||
|
topology = geometry.classify_topology(sample, stackup_4layer, zones, context)
|
||||||
|
assert topology is Topology.COPLANAR_GROUNDED
|
||||||
|
|
||||||
|
impedance_sample = geometry.compute_sample_impedance(sample, stackup_4layer, context, topology)
|
||||||
|
assert impedance_sample.z0_ohms is None
|
||||||
|
assert "topology_not_supported" in impedance_sample.flags
|
||||||
|
|
||||||
|
|
||||||
|
def _flat_samples(branches):
|
||||||
|
return [sample for branch in branches for sample in branch.samples]
|
||||||
|
|
||||||
|
|
||||||
|
def test_clean_microstrip_run_has_no_flags(stackup_4layer, full_ground_plane, clean_run_segment):
|
||||||
|
branches = net_walk.sample_net((clean_run_segment,), pitch_mm=2.0)
|
||||||
|
results = [geometry.analyze_sample(s, stackup_4layer, (full_ground_plane,)) for s in _flat_samples(branches)]
|
||||||
|
assert results # sanity: the fixture actually produced samples
|
||||||
|
assert all(not result.flags for result in results)
|
||||||
|
assert all(result.topology is Topology.MICROSTRIP for result in results)
|
||||||
|
|
||||||
|
|
||||||
|
def test_neckdown_is_caught_as_a_higher_impedance(stackup_4layer, full_ground_plane, neckdown_segments):
|
||||||
|
branches = net_walk.sample_net(neckdown_segments, pitch_mm=1.0)
|
||||||
|
results = [geometry.analyze_sample(s, stackup_4layer, (full_ground_plane,)) for s in _flat_samples(branches)]
|
||||||
|
wide_z0 = [r.z0_ohms for r in results if r.width_mm == 0.3]
|
||||||
|
narrow_z0 = [r.z0_ohms for r in results if r.width_mm == 0.12]
|
||||||
|
assert wide_z0 and narrow_z0
|
||||||
|
assert min(narrow_z0) > max(wide_z0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_void_crossing_trace_is_flagged(stackup_4layer, split_ground_plane, clean_run_segment):
|
||||||
|
branches = net_walk.sample_net((clean_run_segment,), pitch_mm=0.5)
|
||||||
|
results = [geometry.analyze_sample(s, stackup_4layer, (split_ground_plane,)) for s in _flat_samples(branches)]
|
||||||
|
assert any("plane_broken" in result.flags for result in results)
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_pair_net_name_suffix_conventions():
|
||||||
|
assert geometry.find_pair_net_name("USB_D_P") == "USB_D_N"
|
||||||
|
assert geometry.find_pair_net_name("USB_D_N") == "USB_D_P"
|
||||||
|
assert geometry.find_pair_net_name("D+") == "D-"
|
||||||
|
assert geometry.find_pair_net_name("D-") == "D+"
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_pair_net_name_returns_none_for_unpaired_nets():
|
||||||
|
assert geometry.find_pair_net_name("GND") is None
|
||||||
|
assert geometry.find_pair_net_name("3V3") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_differential_sample_impedance_is_between_single_and_twice_single(
|
||||||
|
stackup_4layer, full_ground_plane
|
||||||
|
):
|
||||||
|
# Two parallel vertical traces 0.4mm apart center-to-center, 0.2mm wide
|
||||||
|
# each -> 0.2mm edge-to-edge gap.
|
||||||
|
sample = SamplePoint(
|
||||||
|
net="D_P", layer="F.Cu", distance_along_net_mm=0, position=Point2D(0, 5), width_mm=0.2
|
||||||
|
)
|
||||||
|
partner_segments = (
|
||||||
|
TraceSegment(net="D_N", layer="F.Cu", start=Point2D(0.4, 0), end=Point2D(0.4, 10), width_mm=0.2),
|
||||||
|
)
|
||||||
|
single_ended = geometry.analyze_sample(sample, stackup_4layer, (full_ground_plane,))
|
||||||
|
differential = geometry.analyze_differential_sample(
|
||||||
|
sample, partner_segments, stackup_4layer, (full_ground_plane,)
|
||||||
|
)
|
||||||
|
assert single_ended.z0_ohms < differential.z0_ohms < 2 * single_ended.z0_ohms
|
||||||
|
|
||||||
|
|
||||||
|
def test_differential_sample_falls_back_to_single_ended_with_no_partner_segments(
|
||||||
|
stackup_4layer, full_ground_plane
|
||||||
|
):
|
||||||
|
sample = SamplePoint(
|
||||||
|
net="D_P", layer="F.Cu", distance_along_net_mm=0, position=Point2D(0, 5), width_mm=0.2
|
||||||
|
)
|
||||||
|
single_ended = geometry.analyze_sample(sample, stackup_4layer, (full_ground_plane,))
|
||||||
|
differential = geometry.analyze_differential_sample(sample, (), stackup_4layer, (full_ground_plane,))
|
||||||
|
assert differential.z0_ohms == single_ended.z0_ohms
|
||||||
|
|
||||||
|
|
||||||
|
def test_differential_sample_falls_back_to_single_ended_when_partner_is_far_away(
|
||||||
|
stackup_4layer, full_ground_plane
|
||||||
|
):
|
||||||
|
# Partner net exists but its nearest point is 50mm away -- clearly not a
|
||||||
|
# coupled pair at this sample, e.g. before the pair converges.
|
||||||
|
sample = SamplePoint(
|
||||||
|
net="D_P", layer="F.Cu", distance_along_net_mm=0, position=Point2D(0, 5), width_mm=0.2
|
||||||
|
)
|
||||||
|
far_partner = (
|
||||||
|
TraceSegment(net="D_N", layer="F.Cu", start=Point2D(50, 0), end=Point2D(50, 10), width_mm=0.2),
|
||||||
|
)
|
||||||
|
single_ended = geometry.analyze_sample(sample, stackup_4layer, (full_ground_plane,))
|
||||||
|
differential = geometry.analyze_differential_sample(
|
||||||
|
sample, far_partner, stackup_4layer, (full_ground_plane,)
|
||||||
|
)
|
||||||
|
assert differential.z0_ohms == single_ended.z0_ohms
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from impedancefinder import net_walk
|
||||||
|
from impedancefinder.model import Point2D, TraceSegment
|
||||||
|
|
||||||
|
|
||||||
|
def test_pitch_must_be_positive():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
net_walk.sample_net((), pitch_mm=0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bend_chains_into_one_continuous_branch():
|
||||||
|
# Two segments sharing an exact endpoint at (5, 0) -- a bend, not a via.
|
||||||
|
first = TraceSegment(net="SIG", layer="F.Cu", start=Point2D(0, 0), end=Point2D(5, 0), width_mm=0.2)
|
||||||
|
second = TraceSegment(net="SIG", layer="F.Cu", start=Point2D(5, 0), end=Point2D(5, 5), width_mm=0.2)
|
||||||
|
branches = net_walk.sample_net((first, second), pitch_mm=1.0)
|
||||||
|
assert len(branches) == 1
|
||||||
|
distances = [s.distance_along_net_mm for s in branches[0].samples]
|
||||||
|
assert distances == sorted(distances)
|
||||||
|
assert distances[0] == 0.0
|
||||||
|
assert distances[-1] == pytest.approx(10.0) # 5mm + 5mm, continuous
|
||||||
|
|
||||||
|
|
||||||
|
def test_via_like_layer_change_stays_continuous():
|
||||||
|
# A segment on F.Cu ending exactly where a segment on In1.Cu begins --
|
||||||
|
# this is what a via looks like geometrically, with no ViaSpan needed
|
||||||
|
# for net_walk to treat it as one continuous run.
|
||||||
|
top = TraceSegment(net="SIG", layer="F.Cu", start=Point2D(0, 0), end=Point2D(3, 0), width_mm=0.2)
|
||||||
|
bottom = TraceSegment(net="SIG", layer="In1.Cu", start=Point2D(3, 0), end=Point2D(7, 0), width_mm=0.2)
|
||||||
|
branches = net_walk.sample_net((top, bottom), pitch_mm=1.0)
|
||||||
|
assert len(branches) == 1
|
||||||
|
assert branches[0].samples[-1].distance_along_net_mm == pytest.approx(7.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_t_junction_splits_into_three_branches_zeroed_at_the_junction():
|
||||||
|
junction = Point2D(0, 0)
|
||||||
|
spoke_a = TraceSegment(net="SIG", layer="F.Cu", start=junction, end=Point2D(3, 0), width_mm=0.2)
|
||||||
|
spoke_b = TraceSegment(net="SIG", layer="F.Cu", start=junction, end=Point2D(0, 4), width_mm=0.2)
|
||||||
|
spoke_c = TraceSegment(net="SIG", layer="F.Cu", start=Point2D(-5, 0), end=junction, width_mm=0.2)
|
||||||
|
branches = net_walk.sample_net((spoke_a, spoke_b, spoke_c), pitch_mm=1.0)
|
||||||
|
assert len(branches) == 3
|
||||||
|
lengths = sorted(branch.samples[-1].distance_along_net_mm for branch in branches)
|
||||||
|
assert lengths == pytest.approx([3.0, 4.0, 5.0])
|
||||||
|
# every branch must start at the junction, not at its far leaf
|
||||||
|
assert all(branch.samples[0].distance_along_net_mm == 0.0 for branch in branches)
|
||||||
|
|
||||||
|
|
||||||
|
def test_disconnected_segments_become_separate_branches():
|
||||||
|
isolated_a = TraceSegment(net="SIG", layer="F.Cu", start=Point2D(0, 0), end=Point2D(2, 0), width_mm=0.2)
|
||||||
|
isolated_b = TraceSegment(net="SIG", layer="F.Cu", start=Point2D(100, 0), end=Point2D(103, 0), width_mm=0.2)
|
||||||
|
branches = net_walk.sample_net((isolated_a, isolated_b), pitch_mm=1.0)
|
||||||
|
assert len(branches) == 2
|
||||||
|
lengths = sorted(branch.samples[-1].distance_along_net_mm for branch in branches)
|
||||||
|
assert lengths == pytest.approx([2.0, 3.0])
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero_length_segment_produces_a_single_sample():
|
||||||
|
point_segment = TraceSegment(net="SIG", layer="F.Cu", start=Point2D(1, 1), end=Point2D(1, 1), width_mm=0.2)
|
||||||
|
branches = net_walk.sample_net((point_segment,), pitch_mm=1.0)
|
||||||
|
assert len(branches) == 1
|
||||||
|
assert len(branches[0].samples) == 1
|
||||||
|
assert branches[0].samples[0].distance_along_net_mm == 0.0
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from impedancefinder import planes
|
||||||
|
from impedancefinder.model import Point2D, SamplePoint, ZonePolygon
|
||||||
|
|
||||||
|
from .conftest import rect
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_at(x_mm: float, width_mm: float = 0.2) -> SamplePoint:
|
||||||
|
return SamplePoint(
|
||||||
|
net="SIG", layer="F.Cu", distance_along_net_mm=x_mm, position=Point2D(x_mm, 0), width_mm=width_mm
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_full_coverage_has_no_flags(full_ground_plane):
|
||||||
|
sample = _sample_at(2.0)
|
||||||
|
coverage = planes.coverage_at(sample, (full_ground_plane,), "In1.Cu")
|
||||||
|
assert coverage.is_covered
|
||||||
|
assert planes._flags_for(coverage, sample.width_mm) == ()
|
||||||
|
|
||||||
|
|
||||||
|
def test_void_directly_under_trace_flags_broken(split_ground_plane):
|
||||||
|
sample = _sample_at(5.0) # inside the 4..6mm gap
|
||||||
|
coverage = planes.coverage_at(sample, (split_ground_plane,), "In1.Cu")
|
||||||
|
assert not coverage.is_covered
|
||||||
|
assert planes._flags_for(coverage, sample.width_mm) == ("plane_broken",)
|
||||||
|
|
||||||
|
|
||||||
|
def test_void_near_but_not_under_trace_flags_proximity_only(split_ground_plane):
|
||||||
|
sample = _sample_at(3.9, width_mm=0.5) # covered, close to the gap edge at x=4
|
||||||
|
coverage = planes.coverage_at(sample, (split_ground_plane,), "In1.Cu")
|
||||||
|
assert coverage.is_covered
|
||||||
|
assert planes._flags_for(coverage, sample.width_mm) == ("plane_split_nearby",)
|
||||||
|
|
||||||
|
|
||||||
|
def test_far_from_void_has_no_proximity_flag(split_ground_plane):
|
||||||
|
sample = _sample_at(0.0)
|
||||||
|
coverage = planes.coverage_at(sample, (split_ground_plane,), "In1.Cu")
|
||||||
|
assert coverage.is_covered
|
||||||
|
assert planes._flags_for(coverage, sample.width_mm) == ()
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_plane_layer_reports_uncovered_not_a_crash():
|
||||||
|
sample = _sample_at(0.0)
|
||||||
|
coverage = planes.coverage_at(sample, (), "In1.Cu")
|
||||||
|
assert not coverage.is_covered
|
||||||
|
assert coverage.distance_to_void_mm is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_reference_planes_outer_layer_has_only_below(stackup_4layer, full_ground_plane):
|
||||||
|
sample = _sample_at(2.0)
|
||||||
|
context = planes.resolve_reference_planes(stackup_4layer, (full_ground_plane,), sample)
|
||||||
|
assert context.above is None
|
||||||
|
assert context.below is not None
|
||||||
|
assert context.reference_plane_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_reference_planes_inner_layer_has_both(stackup_4layer):
|
||||||
|
top_plane = ZonePolygon(net="GND", layer="F.Cu", outlines_mm=(rect(-5, -5, 50, 5),))
|
||||||
|
bottom_plane = ZonePolygon(net="GND", layer="In2.Cu", outlines_mm=(rect(-5, -5, 50, 5),))
|
||||||
|
sample = SamplePoint(
|
||||||
|
net="SIG", layer="In1.Cu", distance_along_net_mm=0, position=Point2D(0, 0), width_mm=0.15
|
||||||
|
)
|
||||||
|
context = planes.resolve_reference_planes(stackup_4layer, (top_plane, bottom_plane), sample)
|
||||||
|
assert context.above is not None and context.above.is_covered
|
||||||
|
assert context.below is not None and context.below.is_covered
|
||||||
|
assert context.reference_plane_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_exclude_net_ignores_the_traces_own_copper():
|
||||||
|
own_net_pour = ZonePolygon(net="SIG", layer="F.Cu", outlines_mm=(rect(-5, -5, 50, 5),))
|
||||||
|
sample = _sample_at(0.0)
|
||||||
|
coverage = planes.coverage_at(sample, (own_net_pour,), "F.Cu", exclude_net="SIG")
|
||||||
|
assert not coverage.is_covered
|
||||||
|
assert coverage.distance_to_void_mm is None
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""zsolver validation: a published reference point plus monotonicity checks
|
||||||
|
against the underlying physics, so the tests don't just re-derive whatever
|
||||||
|
the implementation happens to compute.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from impedancefinder import zsolver
|
||||||
|
|
||||||
|
|
||||||
|
def test_microstrip_matches_classic_50ohm_fr4_rule_of_thumb():
|
||||||
|
# ~3mm trace on 1.6mm FR4 (er~4.5) is the textbook "50 ohm microstrip"
|
||||||
|
# widely quoted in PCB fab application notes.
|
||||||
|
z0 = zsolver.microstrip_z0(width_mm=3.0, height_mm=1.6, er=4.5, t_mm=0.035)
|
||||||
|
assert z0 == pytest.approx(50.0, rel=0.05)
|
||||||
|
|
||||||
|
|
||||||
|
def test_microstrip_z0_decreases_with_width():
|
||||||
|
narrow = zsolver.microstrip_z0(0.2, 0.15, 4.3, 0.035)
|
||||||
|
wide = zsolver.microstrip_z0(0.6, 0.15, 4.3, 0.035)
|
||||||
|
assert wide < narrow
|
||||||
|
|
||||||
|
|
||||||
|
def test_microstrip_z0_increases_with_dielectric_height():
|
||||||
|
thin = zsolver.microstrip_z0(0.3, 0.1, 4.3, 0.035)
|
||||||
|
thick = zsolver.microstrip_z0(0.3, 0.3, 4.3, 0.035)
|
||||||
|
assert thick > thin
|
||||||
|
|
||||||
|
|
||||||
|
def test_microstrip_z0_decreases_with_er():
|
||||||
|
low_er = zsolver.microstrip_z0(0.3, 0.15, 3.0, 0.035)
|
||||||
|
high_er = zsolver.microstrip_z0(0.3, 0.15, 5.0, 0.035)
|
||||||
|
assert high_er < low_er
|
||||||
|
|
||||||
|
|
||||||
|
def test_microstrip_z0_finite_thickness_correction_is_a_small_effect():
|
||||||
|
with_thickness = zsolver.microstrip_z0(0.3, 0.15, 4.3, 0.035)
|
||||||
|
without_thickness = zsolver.microstrip_z0(0.3, 0.15, 4.3, 0.0)
|
||||||
|
assert without_thickness == pytest.approx(with_thickness, rel=0.15)
|
||||||
|
|
||||||
|
|
||||||
|
def test_stripline_requires_positive_copper_thickness():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
zsolver.stripline_z0(0.15, 0.3, 4.4, 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_stripline_z0_decreases_with_width():
|
||||||
|
narrow = zsolver.stripline_z0(0.1, 0.5, 4.4, 0.035)
|
||||||
|
wide = zsolver.stripline_z0(0.3, 0.5, 4.4, 0.035)
|
||||||
|
assert wide < narrow
|
||||||
|
|
||||||
|
|
||||||
|
def test_stripline_z0_increases_with_plane_spacing():
|
||||||
|
tight = zsolver.stripline_z0(0.15, 0.3, 4.4, 0.035)
|
||||||
|
loose = zsolver.stripline_z0(0.15, 0.6, 4.4, 0.035)
|
||||||
|
assert loose > tight
|
||||||
|
|
||||||
|
|
||||||
|
def test_diff_microstrip_is_between_single_ended_and_twice_single_ended():
|
||||||
|
single = zsolver.microstrip_z0(0.2, 0.15, 4.3, 0.035)
|
||||||
|
diff = zsolver.diff_microstrip_z0(0.2, 0.15, 0.2, 4.3, 0.035)
|
||||||
|
assert single < diff < 2 * single
|
||||||
|
|
||||||
|
|
||||||
|
def test_diff_microstrip_approaches_twice_single_ended_as_spacing_grows():
|
||||||
|
single = zsolver.microstrip_z0(0.2, 0.15, 4.3, 0.035)
|
||||||
|
wide_gap = zsolver.diff_microstrip_z0(0.2, 0.15, 5.0, 4.3, 0.035)
|
||||||
|
assert wide_gap == pytest.approx(2 * single, rel=0.02)
|
||||||
|
|
||||||
|
|
||||||
|
def test_diff_stripline_approaches_twice_single_ended_as_spacing_grows():
|
||||||
|
single = zsolver.stripline_z0(0.15, 0.3, 4.4, 0.035)
|
||||||
|
wide_gap = zsolver.diff_stripline_z0(0.15, 0.3, 5.0, 4.4, 0.035)
|
||||||
|
assert wide_gap == pytest.approx(2 * single, rel=0.02)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cpwg_is_not_implemented():
|
||||||
|
with pytest.raises(NotImplementedError):
|
||||||
|
zsolver.cpwg_z0()
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
"""D1 impedance — ImpedenceFinder closed forms, no second formula set.
|
||||||
|
|
||||||
|
Favor: Pinscope Z0 equals vendored ImpedenceFinder bit-for-bit; classic
|
||||||
|
3 mm / 1.6 mm FR4 is ~50 Ω; solve_width round-trips.
|
||||||
|
Against: h<=0 invents nothing; CPWG stays unimplemented; stripline t=0
|
||||||
|
raises; calculator emits no findings. OpenEMS is not imported.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from impedancefinder import zsolver as ifz
|
||||||
|
from backend.pinscopex.impedance import (
|
||||||
|
GeometryError,
|
||||||
|
TraceGeometry,
|
||||||
|
coupled_diff_z,
|
||||||
|
cpw_z0,
|
||||||
|
export_kicad_dru,
|
||||||
|
microstrip_z0,
|
||||||
|
solve_width,
|
||||||
|
stackup_targets,
|
||||||
|
stripline_z0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_microstrip_matches_impedancefinder_bit_for_bit():
|
||||||
|
geo = TraceGeometry(h=0.15, er=4.3, t=0.035, w=0.30)
|
||||||
|
ours = microstrip_z0(geo)
|
||||||
|
theirs = ifz.microstrip_z0(0.30, 0.15, 4.3, 0.035)
|
||||||
|
assert ours == theirs
|
||||||
|
|
||||||
|
|
||||||
|
def test_classic_fr4_50ohm_rule_of_thumb():
|
||||||
|
z = microstrip_z0(TraceGeometry(h=1.6, er=4.5, t=0.035, w=3.0))
|
||||||
|
assert z == pytest.approx(50.0, rel=0.05)
|
||||||
|
|
||||||
|
|
||||||
|
def test_microstrip_zero_height_does_not_invent_z():
|
||||||
|
with pytest.raises(GeometryError):
|
||||||
|
microstrip_z0(TraceGeometry(h=0.0, er=4.5, t=0.035, w=0.35))
|
||||||
|
|
||||||
|
|
||||||
|
def test_stripline_matches_impedancefinder():
|
||||||
|
geo = TraceGeometry(h=0.5, er=4.4, t=0.035, w=0.15)
|
||||||
|
assert stripline_z0(geo) == ifz.stripline_z0(0.15, 0.5, 4.4, 0.035)
|
||||||
|
|
||||||
|
|
||||||
|
def test_stripline_zero_thickness_does_not_invent_z():
|
||||||
|
with pytest.raises(GeometryError):
|
||||||
|
stripline_z0(TraceGeometry(h=0.4, er=4.5, t=0.0, w=0.12))
|
||||||
|
|
||||||
|
|
||||||
|
def test_stripline_missing_width_is_invalid():
|
||||||
|
with pytest.raises(GeometryError):
|
||||||
|
stripline_z0(TraceGeometry(h=0.4, er=4.5, t=0.035, w=None))
|
||||||
|
|
||||||
|
|
||||||
|
def test_diff_matches_impedancefinder():
|
||||||
|
geo = TraceGeometry(h=0.15, er=4.3, t=0.035, w=0.20, s=0.20)
|
||||||
|
_, _, zdiff = coupled_diff_z(geo)
|
||||||
|
assert zdiff == ifz.diff_microstrip_z0(0.20, 0.15, 0.20, 4.3, 0.035)
|
||||||
|
|
||||||
|
|
||||||
|
def test_wider_gap_raises_zdiff():
|
||||||
|
tight = coupled_diff_z(TraceGeometry(h=0.15, er=4.3, t=0.035, w=0.20, s=0.08))
|
||||||
|
loose = coupled_diff_z(TraceGeometry(h=0.15, er=4.3, t=0.035, w=0.20, s=0.40))
|
||||||
|
assert loose[2] > tight[2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_coupled_diff_without_gap_is_invalid():
|
||||||
|
with pytest.raises(GeometryError):
|
||||||
|
coupled_diff_z(TraceGeometry(h=0.15, er=4.3, t=0.035, w=0.20, s=None))
|
||||||
|
|
||||||
|
|
||||||
|
def test_cpwg_is_not_invented():
|
||||||
|
with pytest.raises(GeometryError, match="not implemented"):
|
||||||
|
cpw_z0(TraceGeometry(h=0.15, er=4.3, t=0.035, w=0.20, s=0.15))
|
||||||
|
|
||||||
|
|
||||||
|
def test_solve_width_roundtrips_50_ohm_microstrip():
|
||||||
|
w = solve_width("microstrip", target_z=50.0, h=1.6, er=4.5, t=0.035)
|
||||||
|
z = microstrip_z0(TraceGeometry(h=1.6, er=4.5, t=0.035, w=w))
|
||||||
|
assert z == pytest.approx(50.0, rel=0.01)
|
||||||
|
assert w > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_solve_width_rejects_non_positive_target():
|
||||||
|
with pytest.raises(GeometryError):
|
||||||
|
solve_width("microstrip", target_z=0.0, h=1.6, er=4.5, t=0.035)
|
||||||
|
|
||||||
|
|
||||||
|
def test_stackup_suggests_50_90_100_without_findings():
|
||||||
|
out = stackup_targets(h=0.20, er=4.5, t=0.035, s=0.20)
|
||||||
|
assert out["microstrip_50"].z0 == pytest.approx(50.0, rel=0.02)
|
||||||
|
assert out["diff_90"].zdiff == pytest.approx(90.0, rel=0.02)
|
||||||
|
assert out["diff_100"].zdiff == pytest.approx(100.0, rel=0.02)
|
||||||
|
assert "finding" not in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_stackup_rejects_non_positive_h():
|
||||||
|
with pytest.raises(GeometryError):
|
||||||
|
stackup_targets(h=0.0, er=4.5, t=0.035, s=0.2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_kicad_dru_is_advice_not_a_finding():
|
||||||
|
dru = export_kicad_dru(stackup_targets(h=0.20, er=4.5, t=0.035, s=0.20))
|
||||||
|
assert "(rule PINSCOPE_50OHM" in dru
|
||||||
|
assert "PS-Z" not in dru
|
||||||
|
|
||||||
|
|
||||||
|
def _impedance_client():
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from backend.main import app
|
||||||
|
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_microstrip_equals_impedancefinder():
|
||||||
|
res = _impedance_client().post("/api/impedance", json={
|
||||||
|
"mode": "trace",
|
||||||
|
"kind": "microstrip",
|
||||||
|
"h": 1.6, "er": 4.5, "t": 0.035, "w": 3.0,
|
||||||
|
})
|
||||||
|
assert res.status_code == 200
|
||||||
|
body = res.json()
|
||||||
|
assert body["z0"] == ifz.microstrip_z0(3.0, 1.6, 4.5, 0.035)
|
||||||
|
assert "findings" not in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_zero_height_is_400():
|
||||||
|
res = _impedance_client().post("/api/impedance", json={
|
||||||
|
"mode": "trace",
|
||||||
|
"kind": "microstrip",
|
||||||
|
"h": 0, "er": 4.5, "t": 0.035, "w": 0.35,
|
||||||
|
})
|
||||||
|
assert res.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_cpw_is_400_not_a_fake_number():
|
||||||
|
res = _impedance_client().post("/api/impedance", json={
|
||||||
|
"mode": "trace",
|
||||||
|
"kind": "cpw",
|
||||||
|
"h": 0.15, "er": 4.3, "t": 0.035, "w": 0.2, "s": 0.15,
|
||||||
|
})
|
||||||
|
assert res.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_openems_is_not_on_the_impedancefinder_package():
|
||||||
|
import impedancefinder
|
||||||
|
import pkgutil
|
||||||
|
|
||||||
|
names = {m.name for m in pkgutil.iter_modules(impedancefinder.__path__)}
|
||||||
|
assert "gerber2ems_export" not in names
|
||||||
|
assert "board_model" not in names
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_stackup_returns_dru_not_findings():
|
||||||
|
res = _impedance_client().post("/api/impedance", json={
|
||||||
|
"mode": "stackup",
|
||||||
|
"h": 0.20, "er": 4.5, "t": 0.035, "s": 0.20,
|
||||||
|
})
|
||||||
|
assert res.status_code == 200
|
||||||
|
body = res.json()
|
||||||
|
assert body["targets"]["microstrip_50"]["z0"] == pytest.approx(50.0, rel=0.02)
|
||||||
|
assert "(rule PINSCOPE_50OHM" in body["kicad_dru"]
|
||||||
|
assert "findings" not in body
|
||||||
Vendored
+12
@@ -0,0 +1,12 @@
|
|||||||
|
Vendored snapshot of https://github.com/manvalan/ImpedenceFinder
|
||||||
|
|
||||||
|
Commit: a0c8d0ec37c9a1b099082926e50a245778ec8d6e
|
||||||
|
|
||||||
|
Included: closed-form core (`zsolver`, `geometry`, `planes`, `model`,
|
||||||
|
`net_walk`, `net_analysis`, `report`).
|
||||||
|
|
||||||
|
Excluded on purpose:
|
||||||
|
- `gerber2ems_export.py`, `prepare_simulation.py`, `crop_board.py`,
|
||||||
|
`simulate_net.sh` (OpenEMS / field-solver export)
|
||||||
|
- `board_model.py` and `plugin/` (pcbnew). Pinscope does not load KiCad's
|
||||||
|
Python; PCB ingest stays in `pinscopex.parsers_kicad_pcb`.
|
||||||
Vendored
+209
@@ -0,0 +1,209 @@
|
|||||||
|
"""Topology classification and dispatch to the closed-form solvers.
|
||||||
|
|
||||||
|
Classifies each sample as microstrip, stripline, or grounded-coplanar
|
||||||
|
(CPWG) from the vertical plane structure (planes.py) and, for CPWG, a
|
||||||
|
same-layer copper-proximity heuristic — then calls the matching zsolver
|
||||||
|
function.
|
||||||
|
|
||||||
|
Differential pairs are supported via analyze_differential_sample: pairing is
|
||||||
|
name-based (NET_P/NET_N or NET+/NET-), and the edge-to-edge spacing is
|
||||||
|
measured geometrically against the nearest point on the partner net's
|
||||||
|
segments — there's no assumption that the two nets share a common,
|
||||||
|
continuous distance axis (net_walk.py's per-segment sampling doesn't
|
||||||
|
guarantee that yet; see its module docstring).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from impedancefinder import planes, zsolver
|
||||||
|
from impedancefinder.model import (
|
||||||
|
ImpedanceSample,
|
||||||
|
Point2D,
|
||||||
|
SamplePoint,
|
||||||
|
Stackup,
|
||||||
|
Topology,
|
||||||
|
TraceSegment,
|
||||||
|
ZonePolygon,
|
||||||
|
)
|
||||||
|
from impedancefinder.planes import PlaneContext
|
||||||
|
|
||||||
|
# Same-layer copper (a different net) closer than this many trace-widths is
|
||||||
|
# treated as a coplanar ground gap, i.e. CPWG rather than plain microstrip.
|
||||||
|
_CPWG_GAP_WIDTH_MULTIPLE = 5.0
|
||||||
|
|
||||||
|
# NET<positive> pairs with NET<negative>, tried in order; the first suffix
|
||||||
|
# match wins (checked longest-first isn't needed since "_P"/"_N" and "+"/"-"
|
||||||
|
# can't collide on the same net name).
|
||||||
|
_DIFF_PAIR_SUFFIX_PAIRS = (("_P", "_N"), ("+", "-"))
|
||||||
|
|
||||||
|
# A same-named-pair net whose nearest routed point is farther than this many
|
||||||
|
# trace-widths away isn't genuinely coupled here (e.g. before the pair
|
||||||
|
# converges near a connector) -- treat the sample as single-ended instead.
|
||||||
|
_DIFF_PAIR_MAX_GAP_WIDTH_MULTIPLE = 10.0
|
||||||
|
|
||||||
|
|
||||||
|
def classify_topology(
|
||||||
|
sample: SamplePoint,
|
||||||
|
stackup: Stackup,
|
||||||
|
zone_polygons: tuple[ZonePolygon, ...],
|
||||||
|
context: PlaneContext,
|
||||||
|
) -> Topology:
|
||||||
|
if context.reference_plane_count == 0:
|
||||||
|
return Topology.UNKNOWN
|
||||||
|
if not stackup.is_outer_layer(sample.layer):
|
||||||
|
return Topology.STRIPLINE
|
||||||
|
if _has_coplanar_ground(sample, zone_polygons):
|
||||||
|
return Topology.COPLANAR_GROUNDED
|
||||||
|
return Topology.MICROSTRIP
|
||||||
|
|
||||||
|
|
||||||
|
def _has_coplanar_ground(sample: SamplePoint, zone_polygons: tuple[ZonePolygon, ...]) -> bool:
|
||||||
|
gap = planes.coverage_at(
|
||||||
|
sample, zone_polygons, sample.layer, exclude_net=sample.net
|
||||||
|
).distance_to_void_mm
|
||||||
|
return gap is not None and gap < _CPWG_GAP_WIDTH_MULTIPLE * sample.width_mm
|
||||||
|
|
||||||
|
|
||||||
|
def compute_sample_impedance(
|
||||||
|
sample: SamplePoint,
|
||||||
|
stackup: Stackup,
|
||||||
|
context: PlaneContext,
|
||||||
|
topology: Topology,
|
||||||
|
spacing_mm: Optional[float] = None,
|
||||||
|
) -> ImpedanceSample:
|
||||||
|
"""spacing_mm is the edge-to-edge gap to a differential partner trace;
|
||||||
|
leave it None for single-ended analysis."""
|
||||||
|
z0_ohms, solver_flags = _solve_z0(sample, stackup, context, topology, spacing_mm)
|
||||||
|
flags = planes.flags_for_context(context, sample.width_mm) + solver_flags
|
||||||
|
return ImpedanceSample(
|
||||||
|
distance_along_net_mm=sample.distance_along_net_mm,
|
||||||
|
position=sample.position,
|
||||||
|
layer=sample.layer,
|
||||||
|
width_mm=sample.width_mm,
|
||||||
|
topology=topology,
|
||||||
|
z0_ohms=z0_ohms,
|
||||||
|
flags=flags,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_sample(
|
||||||
|
sample: SamplePoint, stackup: Stackup, zone_polygons: tuple[ZonePolygon, ...]
|
||||||
|
) -> ImpedanceSample:
|
||||||
|
"""Convenience wrapper: resolve planes, classify, and solve in one call
|
||||||
|
— what cli.py and the plugin use per single-ended sample."""
|
||||||
|
context = planes.resolve_reference_planes(stackup, zone_polygons, sample)
|
||||||
|
topology = classify_topology(sample, stackup, zone_polygons, context)
|
||||||
|
return compute_sample_impedance(sample, stackup, context, topology)
|
||||||
|
|
||||||
|
|
||||||
|
def find_pair_net_name(net_name: str) -> Optional[str]:
|
||||||
|
"""Guess a differential partner's net name from common KiCad naming
|
||||||
|
conventions (NET_P/NET_N, NET+/NET-). Returns None if net_name matches
|
||||||
|
neither — callers should then treat it as single-ended."""
|
||||||
|
for positive_suffix, negative_suffix in _DIFF_PAIR_SUFFIX_PAIRS:
|
||||||
|
if net_name.endswith(positive_suffix):
|
||||||
|
return net_name[: -len(positive_suffix)] + negative_suffix
|
||||||
|
if net_name.endswith(negative_suffix):
|
||||||
|
return net_name[: -len(negative_suffix)] + positive_suffix
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_differential_sample(
|
||||||
|
sample: SamplePoint,
|
||||||
|
partner_segments: tuple[TraceSegment, ...],
|
||||||
|
stackup: Stackup,
|
||||||
|
zone_polygons: tuple[ZonePolygon, ...],
|
||||||
|
) -> ImpedanceSample:
|
||||||
|
"""Like analyze_sample, but measures the edge-to-edge gap to the nearest
|
||||||
|
point on partner_segments (the paired net's routed segments) and
|
||||||
|
dispatches to zsolver's diff_* solvers instead of the single-ended
|
||||||
|
ones. Falls back to single-ended analysis if partner_segments is empty
|
||||||
|
or too far away to plausibly be a coupled pair."""
|
||||||
|
context = planes.resolve_reference_planes(stackup, zone_polygons, sample)
|
||||||
|
topology = classify_topology(sample, stackup, zone_polygons, context)
|
||||||
|
spacing_mm = _nearest_partner_gap_mm(sample, partner_segments)
|
||||||
|
return compute_sample_impedance(sample, stackup, context, topology, spacing_mm)
|
||||||
|
|
||||||
|
|
||||||
|
def _nearest_partner_gap_mm(
|
||||||
|
sample: SamplePoint, partner_segments: tuple[TraceSegment, ...]
|
||||||
|
) -> Optional[float]:
|
||||||
|
if not partner_segments:
|
||||||
|
return None
|
||||||
|
nearest_segment, center_distance_mm = min(
|
||||||
|
((segment, _distance_to_segment(sample.position, segment)) for segment in partner_segments),
|
||||||
|
key=lambda pair: pair[1],
|
||||||
|
)
|
||||||
|
gap_mm = max(0.0, center_distance_mm - sample.width_mm / 2.0 - nearest_segment.width_mm / 2.0)
|
||||||
|
if gap_mm > _DIFF_PAIR_MAX_GAP_WIDTH_MULTIPLE * sample.width_mm:
|
||||||
|
return None
|
||||||
|
return gap_mm
|
||||||
|
|
||||||
|
|
||||||
|
def _distance_to_segment(point: Point2D, segment: TraceSegment) -> float:
|
||||||
|
return point.distance_to(_nearest_point_on_segment(point, segment))
|
||||||
|
|
||||||
|
|
||||||
|
def _nearest_point_on_segment(point: Point2D, segment: TraceSegment) -> Point2D:
|
||||||
|
start, end = segment.start, segment.end
|
||||||
|
dx, dy = end.x_mm - start.x_mm, end.y_mm - start.y_mm
|
||||||
|
length_sq = dx * dx + dy * dy
|
||||||
|
if length_sq == 0:
|
||||||
|
return start
|
||||||
|
t = ((point.x_mm - start.x_mm) * dx + (point.y_mm - start.y_mm) * dy) / length_sq
|
||||||
|
t = max(0.0, min(1.0, t))
|
||||||
|
return Point2D(start.x_mm + t * dx, start.y_mm + t * dy)
|
||||||
|
|
||||||
|
|
||||||
|
def _solve_z0(
|
||||||
|
sample: SamplePoint,
|
||||||
|
stackup: Stackup,
|
||||||
|
context: PlaneContext,
|
||||||
|
topology: Topology,
|
||||||
|
spacing_mm: Optional[float] = None,
|
||||||
|
) -> tuple[Optional[float], tuple[str, ...]]:
|
||||||
|
try:
|
||||||
|
if topology is Topology.MICROSTRIP:
|
||||||
|
return _microstrip_z0(sample, stackup, context, spacing_mm), ()
|
||||||
|
if topology is Topology.STRIPLINE:
|
||||||
|
return _stripline_z0(sample, stackup, context, spacing_mm), ()
|
||||||
|
if topology is Topology.COPLANAR_GROUNDED:
|
||||||
|
return zsolver.cpwg_z0(), ()
|
||||||
|
except NotImplementedError:
|
||||||
|
return None, ("topology_not_supported",)
|
||||||
|
return None, ("topology_unknown",)
|
||||||
|
|
||||||
|
|
||||||
|
def _microstrip_z0(
|
||||||
|
sample: SamplePoint, stackup: Stackup, context: PlaneContext, spacing_mm: Optional[float]
|
||||||
|
) -> float:
|
||||||
|
dielectric = context.dielectric_below or context.dielectric_above
|
||||||
|
if spacing_mm is None:
|
||||||
|
return zsolver.microstrip_z0(
|
||||||
|
width_mm=sample.width_mm,
|
||||||
|
height_mm=dielectric.height_mm,
|
||||||
|
er=dielectric.er,
|
||||||
|
t_mm=stackup.copper_thickness_mm,
|
||||||
|
)
|
||||||
|
return zsolver.diff_microstrip_z0(
|
||||||
|
width_mm=sample.width_mm,
|
||||||
|
height_mm=dielectric.height_mm,
|
||||||
|
spacing_mm=spacing_mm,
|
||||||
|
er=dielectric.er,
|
||||||
|
t_mm=stackup.copper_thickness_mm,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _stripline_z0(
|
||||||
|
sample: SamplePoint, stackup: Stackup, context: PlaneContext, spacing_mm: Optional[float]
|
||||||
|
) -> float:
|
||||||
|
b_mm = context.dielectric_above.height_mm + context.dielectric_below.height_mm
|
||||||
|
er = context.dielectric_below.er # assumes one uniform dielectric between both planes
|
||||||
|
if spacing_mm is None:
|
||||||
|
return zsolver.stripline_z0(
|
||||||
|
width_mm=sample.width_mm, b_mm=b_mm, er=er, t_mm=stackup.copper_thickness_mm
|
||||||
|
)
|
||||||
|
return zsolver.diff_stripline_z0(
|
||||||
|
width_mm=sample.width_mm, b_mm=b_mm, spacing_mm=spacing_mm, er=er, t_mm=stackup.copper_thickness_mm
|
||||||
|
)
|
||||||
Vendored
+226
@@ -0,0 +1,226 @@
|
|||||||
|
"""Pure, pcbnew-free domain model for ImpedanceFinder.
|
||||||
|
|
||||||
|
Every value here is a plain dataclass in millimetres. Nothing in this module
|
||||||
|
imports pcbnew, performs I/O, or calls a solver — it only describes shapes
|
||||||
|
that are valid by construction (invalid states can't be built).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum, auto
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Point2D:
|
||||||
|
x_mm: float
|
||||||
|
y_mm: float
|
||||||
|
|
||||||
|
def distance_to(self, other: "Point2D") -> float:
|
||||||
|
return ((self.x_mm - other.x_mm) ** 2 + (self.y_mm - other.y_mm) ** 2) ** 0.5
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DielectricLayer:
|
||||||
|
name: str
|
||||||
|
er: float
|
||||||
|
height_mm: float
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.er <= 0:
|
||||||
|
raise ValueError(f"er must be positive, got {self.er}")
|
||||||
|
if self.height_mm <= 0:
|
||||||
|
raise ValueError(f"height_mm must be positive, got {self.height_mm}")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Stackup:
|
||||||
|
"""Copper layers top-to-bottom, with one dielectric between each pair."""
|
||||||
|
|
||||||
|
copper_layer_names: tuple[str, ...]
|
||||||
|
dielectrics: tuple[DielectricLayer, ...]
|
||||||
|
copper_thickness_mm: float = 0.035 # 1 oz/ft^2 copper, the common PCB default
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
expected = len(self.copper_layer_names) - 1
|
||||||
|
if len(self.dielectrics) != expected:
|
||||||
|
raise ValueError(
|
||||||
|
f"expected {expected} dielectrics between "
|
||||||
|
f"{len(self.copper_layer_names)} copper layers, "
|
||||||
|
f"got {len(self.dielectrics)}"
|
||||||
|
)
|
||||||
|
if self.copper_thickness_mm <= 0:
|
||||||
|
raise ValueError(f"copper_thickness_mm must be positive, got {self.copper_thickness_mm}")
|
||||||
|
|
||||||
|
def dielectric_between(self, top_layer: str, bottom_layer: str) -> DielectricLayer:
|
||||||
|
top_index = self.copper_layer_names.index(top_layer)
|
||||||
|
bottom_index = self.copper_layer_names.index(bottom_layer)
|
||||||
|
if bottom_index != top_index + 1:
|
||||||
|
raise ValueError(f"{top_layer!r} and {bottom_layer!r} are not adjacent")
|
||||||
|
return self.dielectrics[top_index]
|
||||||
|
|
||||||
|
def is_outer_layer(self, layer_name: str) -> bool:
|
||||||
|
return layer_name in (self.copper_layer_names[0], self.copper_layer_names[-1])
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TraceSegment:
|
||||||
|
net: str
|
||||||
|
layer: str
|
||||||
|
start: Point2D
|
||||||
|
end: Point2D
|
||||||
|
width_mm: float
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.width_mm <= 0:
|
||||||
|
raise ValueError(f"width_mm must be positive, got {self.width_mm}")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def length_mm(self) -> float:
|
||||||
|
return self.start.distance_to(self.end)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ViaSpan:
|
||||||
|
net: str
|
||||||
|
position: Point2D
|
||||||
|
top_layer: str
|
||||||
|
bottom_layer: str
|
||||||
|
drill_mm: float
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.drill_mm <= 0:
|
||||||
|
raise ValueError(f"drill_mm must be positive, got {self.drill_mm}")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SamplePoint:
|
||||||
|
"""One point along a net's routed length, before plane/impedance
|
||||||
|
analysis has been attached (see planes.py, geometry.py)."""
|
||||||
|
|
||||||
|
net: str
|
||||||
|
layer: str
|
||||||
|
distance_along_net_mm: float
|
||||||
|
position: Point2D
|
||||||
|
width_mm: float
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.width_mm <= 0:
|
||||||
|
raise ValueError(f"width_mm must be positive, got {self.width_mm}")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class NetBranch:
|
||||||
|
"""One continuous, ordered run of samples with a monotonic distance
|
||||||
|
axis. A net with a single point-to-point route is one branch; a
|
||||||
|
T-topology net (one fan-out point) is split into one branch per spoke,
|
||||||
|
each restarting its distance axis at the fan-out point. See
|
||||||
|
net_walk.sample_net."""
|
||||||
|
|
||||||
|
samples: tuple[SamplePoint, ...]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PlaneCoverage:
|
||||||
|
"""Whether a reference plane actually covers a sample point, and if not,
|
||||||
|
how close the nearest plane edge/void is (None when covered and the
|
||||||
|
distance wasn't computed)."""
|
||||||
|
|
||||||
|
layer: str
|
||||||
|
is_covered: bool
|
||||||
|
distance_to_void_mm: Optional[float] = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.distance_to_void_mm is not None and self.distance_to_void_mm < 0:
|
||||||
|
raise ValueError("distance_to_void_mm must be >= 0")
|
||||||
|
|
||||||
|
|
||||||
|
class Topology(Enum):
|
||||||
|
MICROSTRIP = auto()
|
||||||
|
STRIPLINE = auto()
|
||||||
|
COPLANAR_GROUNDED = auto() # CPWG
|
||||||
|
UNKNOWN = auto()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ImpedanceSample:
|
||||||
|
distance_along_net_mm: float
|
||||||
|
position: Point2D
|
||||||
|
layer: str
|
||||||
|
width_mm: float
|
||||||
|
topology: Topology
|
||||||
|
z0_ohms: Optional[float]
|
||||||
|
flags: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.width_mm <= 0:
|
||||||
|
raise ValueError(f"width_mm must be positive, got {self.width_mm}")
|
||||||
|
if self.z0_ohms is not None and self.z0_ohms <= 0:
|
||||||
|
raise ValueError(f"z0_ohms must be positive, got {self.z0_ohms}")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class NetProfile:
|
||||||
|
net_name: str
|
||||||
|
samples: tuple[ImpedanceSample, ...]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_flags(self) -> bool:
|
||||||
|
return any(sample.flags for sample in self.samples)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class NetSummary:
|
||||||
|
"""One row of a batch report (board_report.py): length + impedance
|
||||||
|
range for a whole net, collapsed from its per-sample ImpedanceSample
|
||||||
|
profile. topologies/flags are the distinct values seen, in first-seen
|
||||||
|
order, so a net that changes layer (MICROSTRIP -> STRIPLINE) or crosses
|
||||||
|
a plane void is still visible in one row instead of only in the
|
||||||
|
full per-sample CSV."""
|
||||||
|
|
||||||
|
net_name: str
|
||||||
|
length_mm: float
|
||||||
|
branch_count: int
|
||||||
|
is_differential: bool
|
||||||
|
partner_net_name: Optional[str]
|
||||||
|
topologies: tuple[str, ...]
|
||||||
|
z0_min_ohms: Optional[float]
|
||||||
|
z0_max_ohms: Optional[float]
|
||||||
|
z0_avg_ohms: Optional[float]
|
||||||
|
flags: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ZonePolygon:
|
||||||
|
"""A filled zone's outline(s) on one copper layer, in mm. Each entry in
|
||||||
|
outlines_mm is one closed ring (KiCad's SHAPE_POLY_SET "outline"); a
|
||||||
|
zone with disjoint copper islands has more than one. Extracted by
|
||||||
|
board_model.py, consumed by planes.py — pure data, no pcbnew handle."""
|
||||||
|
|
||||||
|
net: str
|
||||||
|
layer: str
|
||||||
|
outlines_mm: tuple[tuple[Point2D, ...], ...]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BoardOutline:
|
||||||
|
"""Bounding box of the board's Edge.Cuts outline, in mm, in pcbnew's own
|
||||||
|
coordinate convention (Y increases downward, matching the screen) --
|
||||||
|
NOT necessarily the same convention a Gerber-consuming tool expects.
|
||||||
|
See gerber2ems_export.board_origin_mm's docstring before using this as
|
||||||
|
a "bottom-left" origin for anything outside pcbnew."""
|
||||||
|
|
||||||
|
min_corner: Point2D
|
||||||
|
max_corner: Point2D
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BoardData:
|
||||||
|
"""Everything the pure engine needs from a routed board, in mm."""
|
||||||
|
|
||||||
|
segments: tuple[TraceSegment, ...]
|
||||||
|
vias: tuple[ViaSpan, ...]
|
||||||
|
zone_polygons: tuple[ZonePolygon, ...]
|
||||||
|
copper_layer_names: tuple[str, ...]
|
||||||
|
stackup: Optional[Stackup]
|
||||||
|
outline: Optional[BoardOutline]
|
||||||
+62
@@ -0,0 +1,62 @@
|
|||||||
|
"""Shared net-analysis orchestration used by both cli.py (one net, full
|
||||||
|
per-sample detail) and board_report.py (many nets, summarized). Pure: works
|
||||||
|
on an already-loaded BoardData/Stackup, no pcbnew import needed here, so
|
||||||
|
it's testable with no KiCad installed.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from impedancefinder import geometry, net_walk
|
||||||
|
from impedancefinder.model import BoardData, ImpedanceSample, NetBranch, Stackup, TraceSegment
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class NetAnalysisResult:
|
||||||
|
samples: tuple[ImpedanceSample, ...]
|
||||||
|
branch_count: int
|
||||||
|
partner_net_name: Optional[str] # None means single-ended
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_differential(self) -> bool:
|
||||||
|
return self.partner_net_name is not None
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_net(
|
||||||
|
board_data: BoardData, stackup: Stackup, net_name: str, pitch_mm: float, single_ended: bool = False
|
||||||
|
) -> NetAnalysisResult:
|
||||||
|
"""Raises ValueError if the net has no routed segments on this board."""
|
||||||
|
segments = segments_for(board_data, net_name)
|
||||||
|
if not segments:
|
||||||
|
raise ValueError(f"no segments found on net {net_name!r}")
|
||||||
|
branches = net_walk.sample_net(segments, pitch_mm)
|
||||||
|
partner_net = None if single_ended else geometry.find_pair_net_name(net_name)
|
||||||
|
partner_segments = segments_for(board_data, partner_net) if partner_net else ()
|
||||||
|
if not partner_segments:
|
||||||
|
partner_net = None
|
||||||
|
results: list[ImpedanceSample] = []
|
||||||
|
for branch in branches:
|
||||||
|
results.extend(_analyze_branch(branch, partner_segments, stackup, board_data))
|
||||||
|
return NetAnalysisResult(samples=tuple(results), branch_count=len(branches), partner_net_name=partner_net)
|
||||||
|
|
||||||
|
|
||||||
|
def _analyze_branch(
|
||||||
|
branch: NetBranch, partner_segments: tuple[TraceSegment, ...], stackup: Stackup, board_data: BoardData
|
||||||
|
) -> tuple[ImpedanceSample, ...]:
|
||||||
|
if not partner_segments:
|
||||||
|
return tuple(
|
||||||
|
geometry.analyze_sample(sample, stackup, board_data.zone_polygons) for sample in branch.samples
|
||||||
|
)
|
||||||
|
return tuple(
|
||||||
|
geometry.analyze_differential_sample(sample, partner_segments, stackup, board_data.zone_polygons)
|
||||||
|
for sample in branch.samples
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def segments_for(board_data: BoardData, net_name: str) -> tuple[TraceSegment, ...]:
|
||||||
|
return tuple(segment for segment in board_data.segments if segment.net == net_name)
|
||||||
|
|
||||||
|
|
||||||
|
def net_length_mm(board_data: BoardData, net_name: str) -> float:
|
||||||
|
return sum(segment.length_mm for segment in segments_for(board_data, net_name))
|
||||||
Vendored
+157
@@ -0,0 +1,157 @@
|
|||||||
|
"""Samples a net's routed segments into evenly-spaced points along a
|
||||||
|
continuous distance axis.
|
||||||
|
|
||||||
|
Segments are chained by endpoint coincidence: two segments that share an
|
||||||
|
exact (x, y) point are treated as connected, regardless of layer. This
|
||||||
|
means a via is handled for free -- the segment ending on one layer and the
|
||||||
|
segment starting on the other share the via's exact position, so the
|
||||||
|
distance axis carries straight through without any via-specific code.
|
||||||
|
|
||||||
|
A net with a single point-to-point route becomes one NetBranch. A
|
||||||
|
T-topology net (any point where 3+ segments meet) is split into one branch
|
||||||
|
per spoke leaving that point, each restarting its distance axis at zero
|
||||||
|
there -- callers that want a single unified axis across the whole net will
|
||||||
|
need to stitch branches together themselves; this module only guarantees
|
||||||
|
that each individual branch's axis is correct and continuous.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from impedancefinder.model import NetBranch, Point2D, SamplePoint, TraceSegment
|
||||||
|
|
||||||
|
_COORDINATE_PRECISION_MM = 6 # matches pcbnew's nm-to-mm conversion exactly
|
||||||
|
|
||||||
|
|
||||||
|
def sample_net(segments: tuple[TraceSegment, ...], pitch_mm: float) -> tuple[NetBranch, ...]:
|
||||||
|
"""Sample every branch of a net at pitch_mm, plus each segment's exact
|
||||||
|
endpoint. All segments are assumed to belong to the same net; callers
|
||||||
|
should pre-filter board_model.BoardData.segments by net name first.
|
||||||
|
"""
|
||||||
|
if pitch_mm <= 0:
|
||||||
|
raise ValueError(f"pitch_mm must be positive, got {pitch_mm}")
|
||||||
|
branches = _group_into_branches(segments)
|
||||||
|
return tuple(_sample_branch(branch, pitch_mm) for branch in branches)
|
||||||
|
|
||||||
|
|
||||||
|
def _endpoint_key(point: Point2D) -> tuple[float, float]:
|
||||||
|
return (round(point.x_mm, _COORDINATE_PRECISION_MM), round(point.y_mm, _COORDINATE_PRECISION_MM))
|
||||||
|
|
||||||
|
|
||||||
|
def _build_adjacency(
|
||||||
|
segments: tuple[TraceSegment, ...]
|
||||||
|
) -> dict[tuple[float, float], list[TraceSegment]]:
|
||||||
|
adjacency: dict[tuple[float, float], list[TraceSegment]] = {}
|
||||||
|
for segment in segments:
|
||||||
|
for endpoint in (segment.start, segment.end):
|
||||||
|
adjacency.setdefault(_endpoint_key(endpoint), []).append(segment)
|
||||||
|
return adjacency
|
||||||
|
|
||||||
|
|
||||||
|
def _orient_from(segment: TraceSegment, from_key: tuple[float, float]) -> TraceSegment:
|
||||||
|
if _endpoint_key(segment.start) == from_key:
|
||||||
|
return segment
|
||||||
|
return TraceSegment(
|
||||||
|
net=segment.net, layer=segment.layer, start=segment.end, end=segment.start, width_mm=segment.width_mm
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _walk_branch(
|
||||||
|
entry_key: tuple[float, float],
|
||||||
|
entry_segment: TraceSegment,
|
||||||
|
adjacency: dict[tuple[float, float], list[TraceSegment]],
|
||||||
|
visited: set,
|
||||||
|
) -> tuple[TraceSegment, ...]:
|
||||||
|
ordered: list[TraceSegment] = []
|
||||||
|
current_key, current_segment = entry_key, entry_segment
|
||||||
|
while True:
|
||||||
|
visited.add(id(current_segment))
|
||||||
|
oriented = _orient_from(current_segment, current_key)
|
||||||
|
ordered.append(oriented)
|
||||||
|
next_key = _endpoint_key(oriented.end)
|
||||||
|
neighbors = [s for s in adjacency[next_key] if id(s) not in visited]
|
||||||
|
if len(neighbors) != 1 or len(adjacency[next_key]) != 2:
|
||||||
|
break
|
||||||
|
current_key, current_segment = next_key, neighbors[0]
|
||||||
|
return tuple(ordered)
|
||||||
|
|
||||||
|
|
||||||
|
def _group_into_branches(segments: tuple[TraceSegment, ...]) -> tuple[tuple[TraceSegment, ...], ...]:
|
||||||
|
# Junctions (degree >= 3) are walked in a full first pass, before any
|
||||||
|
# leaf is considered -- otherwise a leaf reached first in dict-iteration
|
||||||
|
# order would claim a spoke and the branch would start at the leaf
|
||||||
|
# instead of the junction, leaving sibling spokes of the same junction
|
||||||
|
# inconsistently zeroed (one from the leaf, the rest from the junction).
|
||||||
|
adjacency = _build_adjacency(segments)
|
||||||
|
visited: set = set()
|
||||||
|
branches = []
|
||||||
|
for key, segments_at_node in adjacency.items():
|
||||||
|
if len(segments_at_node) >= 3:
|
||||||
|
branches.extend(_walk_unvisited(key, segments_at_node, adjacency, visited))
|
||||||
|
for key, segments_at_node in adjacency.items():
|
||||||
|
if len(segments_at_node) == 1:
|
||||||
|
branches.extend(_walk_unvisited(key, segments_at_node, adjacency, visited))
|
||||||
|
branches.extend(_group_remaining_loops(segments, adjacency, visited))
|
||||||
|
return tuple(branches)
|
||||||
|
|
||||||
|
|
||||||
|
def _walk_unvisited(
|
||||||
|
key: tuple[float, float],
|
||||||
|
segments_at_node: list[TraceSegment],
|
||||||
|
adjacency: dict[tuple[float, float], list[TraceSegment]],
|
||||||
|
visited: set,
|
||||||
|
) -> list[tuple[TraceSegment, ...]]:
|
||||||
|
return [
|
||||||
|
_walk_branch(key, segment, adjacency, visited)
|
||||||
|
for segment in segments_at_node
|
||||||
|
if id(segment) not in visited
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _group_remaining_loops(
|
||||||
|
segments: tuple[TraceSegment, ...],
|
||||||
|
adjacency: dict[tuple[float, float], list[TraceSegment]],
|
||||||
|
visited: set,
|
||||||
|
) -> tuple[tuple[TraceSegment, ...], ...]:
|
||||||
|
# Anything left unvisited lies entirely on degree-2 nodes -- a pure loop
|
||||||
|
# with no leaf or junction to start from. Walk each remaining loop once,
|
||||||
|
# starting arbitrarily from one of its segments.
|
||||||
|
loops = []
|
||||||
|
for segment in segments:
|
||||||
|
if id(segment) not in visited:
|
||||||
|
loops.append(_walk_branch(_endpoint_key(segment.start), segment, adjacency, visited))
|
||||||
|
return tuple(loops)
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_branch(branch_segments: tuple[TraceSegment, ...], pitch_mm: float) -> NetBranch:
|
||||||
|
samples: list[SamplePoint] = []
|
||||||
|
cumulative_mm = 0.0
|
||||||
|
for segment in branch_segments:
|
||||||
|
samples.extend(_sample_segment(segment, pitch_mm, cumulative_mm))
|
||||||
|
cumulative_mm += segment.length_mm
|
||||||
|
return NetBranch(samples=tuple(samples))
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_segment(
|
||||||
|
segment: TraceSegment, pitch_mm: float, offset_mm: float
|
||||||
|
) -> tuple[SamplePoint, ...]:
|
||||||
|
length_mm = segment.length_mm
|
||||||
|
if length_mm == 0:
|
||||||
|
return (_sample_at(segment, 0.0, offset_mm),)
|
||||||
|
step_count = max(1, int(length_mm // pitch_mm))
|
||||||
|
local_distances = [i * pitch_mm for i in range(step_count + 1) if i * pitch_mm < length_mm]
|
||||||
|
local_distances.append(length_mm)
|
||||||
|
return tuple(_sample_at(segment, distance, offset_mm + distance) for distance in local_distances)
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_at(segment: TraceSegment, local_distance_mm: float, cumulative_distance_mm: float) -> SamplePoint:
|
||||||
|
fraction = 0.0 if segment.length_mm == 0 else local_distance_mm / segment.length_mm
|
||||||
|
position = Point2D(
|
||||||
|
x_mm=segment.start.x_mm + fraction * (segment.end.x_mm - segment.start.x_mm),
|
||||||
|
y_mm=segment.start.y_mm + fraction * (segment.end.y_mm - segment.start.y_mm),
|
||||||
|
)
|
||||||
|
return SamplePoint(
|
||||||
|
net=segment.net,
|
||||||
|
layer=segment.layer,
|
||||||
|
distance_along_net_mm=cumulative_distance_mm,
|
||||||
|
position=position,
|
||||||
|
width_mm=segment.width_mm,
|
||||||
|
)
|
||||||
Vendored
+121
@@ -0,0 +1,121 @@
|
|||||||
|
"""Reference-plane resolution, coverage, and void proximity.
|
||||||
|
|
||||||
|
This is the crux module: it's what lets the tool catch a broken or split
|
||||||
|
reference plane under a trace, not just a nominal width-based Z0. Pure and
|
||||||
|
pcbnew-free — it works entirely off the ZonePolygon outline points that
|
||||||
|
board_model.py already extracted (see that module's docstring for why
|
||||||
|
containment/distance are done here with shapely rather than by calling back
|
||||||
|
into pcbnew's HitTestFilledArea/Contains).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from shapely.geometry import Point as ShapelyPoint
|
||||||
|
from shapely.geometry import Polygon as ShapelyPolygon
|
||||||
|
|
||||||
|
from impedancefinder.model import (
|
||||||
|
DielectricLayer,
|
||||||
|
PlaneCoverage,
|
||||||
|
SamplePoint,
|
||||||
|
Stackup,
|
||||||
|
ZonePolygon,
|
||||||
|
)
|
||||||
|
|
||||||
|
# A covered sample within this many trace-widths of the plane's edge is
|
||||||
|
# flagged as approaching a split, even before it fully crosses one.
|
||||||
|
_VOID_PROXIMITY_WIDTH_MULTIPLE = 3.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PlaneContext:
|
||||||
|
"""Which reference plane(s) back a sample, and the dielectric between
|
||||||
|
the trace and each one. Either side is None when the trace is on an
|
||||||
|
outer layer (no plane above) or the stackup has no layer beyond it."""
|
||||||
|
|
||||||
|
above: Optional[PlaneCoverage]
|
||||||
|
below: Optional[PlaneCoverage]
|
||||||
|
dielectric_above: Optional[DielectricLayer]
|
||||||
|
dielectric_below: Optional[DielectricLayer]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def reference_plane_count(self) -> int:
|
||||||
|
return sum(1 for coverage in (self.above, self.below) if coverage is not None)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_reference_planes(
|
||||||
|
stackup: Stackup, zone_polygons: tuple[ZonePolygon, ...], sample: SamplePoint
|
||||||
|
) -> PlaneContext:
|
||||||
|
"""Find the copper layer(s) adjacent to the sample's layer and check
|
||||||
|
whether each one actually has copper under/over this point."""
|
||||||
|
layer_index = stackup.copper_layer_names.index(sample.layer)
|
||||||
|
below_layer = _layer_at(stackup, layer_index + 1)
|
||||||
|
above_layer = _layer_at(stackup, layer_index - 1)
|
||||||
|
return PlaneContext(
|
||||||
|
above=coverage_at(sample, zone_polygons, above_layer) if above_layer else None,
|
||||||
|
below=coverage_at(sample, zone_polygons, below_layer) if below_layer else None,
|
||||||
|
dielectric_above=stackup.dielectric_between(above_layer, sample.layer) if above_layer else None,
|
||||||
|
dielectric_below=stackup.dielectric_between(sample.layer, below_layer) if below_layer else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _layer_at(stackup: Stackup, index: int) -> Optional[str]:
|
||||||
|
if 0 <= index < len(stackup.copper_layer_names):
|
||||||
|
return stackup.copper_layer_names[index]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def coverage_at(
|
||||||
|
sample: SamplePoint,
|
||||||
|
zone_polygons: tuple[ZonePolygon, ...],
|
||||||
|
layer: str,
|
||||||
|
exclude_net: Optional[str] = None,
|
||||||
|
) -> PlaneCoverage:
|
||||||
|
"""Is `layer` actually covered by copper under/over the sample, and how
|
||||||
|
close is the nearest plane edge (a covered point's distance to falling
|
||||||
|
off the plane, or an uncovered point's distance to landing on one)?
|
||||||
|
|
||||||
|
exclude_net skips zones on the trace's own net — geometry.py reuses this
|
||||||
|
to measure the gap to same-layer *coplanar ground* copper, where the
|
||||||
|
trace's own copper obviously shouldn't count.
|
||||||
|
"""
|
||||||
|
polygons = [
|
||||||
|
polygon
|
||||||
|
for zone_polygon in zone_polygons
|
||||||
|
if zone_polygon.layer == layer and zone_polygon.net != exclude_net
|
||||||
|
for polygon in _to_shapely_polygons(zone_polygon)
|
||||||
|
]
|
||||||
|
if not polygons:
|
||||||
|
return PlaneCoverage(layer=layer, is_covered=False, distance_to_void_mm=None)
|
||||||
|
point = ShapelyPoint(sample.position.x_mm, sample.position.y_mm)
|
||||||
|
is_covered = any(polygon.contains(point) for polygon in polygons)
|
||||||
|
distance_mm = min(polygon.boundary.distance(point) for polygon in polygons)
|
||||||
|
return PlaneCoverage(layer=layer, is_covered=is_covered, distance_to_void_mm=distance_mm)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_shapely_polygons(zone_polygon: ZonePolygon) -> tuple[ShapelyPolygon, ...]:
|
||||||
|
# Each outline is treated as its own simple polygon; nested cutouts
|
||||||
|
# within one filled zone island aren't modeled separately in this pass.
|
||||||
|
return tuple(
|
||||||
|
ShapelyPolygon([(point.x_mm, point.y_mm) for point in outline])
|
||||||
|
for outline in zone_polygon.outlines_mm
|
||||||
|
if len(outline) >= 3
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def flags_for_context(context: PlaneContext, width_mm: float) -> tuple[str, ...]:
|
||||||
|
"""Plane-health flags for a sample, deduplicated across above/below."""
|
||||||
|
flags = _flags_for(context.below, width_mm) + _flags_for(context.above, width_mm)
|
||||||
|
return tuple(dict.fromkeys(flags))
|
||||||
|
|
||||||
|
|
||||||
|
def _flags_for(coverage: Optional[PlaneCoverage], width_mm: float) -> tuple[str, ...]:
|
||||||
|
if coverage is None:
|
||||||
|
return ()
|
||||||
|
if not coverage.is_covered:
|
||||||
|
return ("plane_broken",)
|
||||||
|
threshold_mm = _VOID_PROXIMITY_WIDTH_MULTIPLE * width_mm
|
||||||
|
if coverage.distance_to_void_mm is not None and coverage.distance_to_void_mm < threshold_mm:
|
||||||
|
return ("plane_split_nearby",)
|
||||||
|
return ()
|
||||||
Vendored
+121
@@ -0,0 +1,121 @@
|
|||||||
|
"""Profile assembly and export.
|
||||||
|
|
||||||
|
build_profile is pure. to_csv is this module's impure edge (writes a file).
|
||||||
|
|
||||||
|
gerber2ems export/import now lives in gerber2ems_export.py, not here (see
|
||||||
|
docs/field-solver-export-plan.md for why it grew into its own module and
|
||||||
|
what was verified against the real installed tool). to_rf2dfieldsolver
|
||||||
|
below is still a stub -- RF2DFieldSolver is GUI-only with no batch mode
|
||||||
|
(verified against its main.cpp), so an automated export-and-read-back loop
|
||||||
|
isn't possible for it the way it is for gerber2ems; see the plan doc for
|
||||||
|
the full comparison.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
|
||||||
|
from impedancefinder import net_analysis
|
||||||
|
from impedancefinder.model import BoardData, ImpedanceSample, NetProfile, NetSummary
|
||||||
|
from impedancefinder.net_analysis import NetAnalysisResult
|
||||||
|
|
||||||
|
_CSV_COLUMNS = (
|
||||||
|
"distance_along_net_mm",
|
||||||
|
"x_mm",
|
||||||
|
"y_mm",
|
||||||
|
"layer",
|
||||||
|
"width_mm",
|
||||||
|
"topology",
|
||||||
|
"z0_ohms",
|
||||||
|
"flags",
|
||||||
|
)
|
||||||
|
|
||||||
|
_SUMMARY_CSV_COLUMNS = (
|
||||||
|
"net_name",
|
||||||
|
"length_mm",
|
||||||
|
"branch_count",
|
||||||
|
"is_differential",
|
||||||
|
"partner_net_name",
|
||||||
|
"topologies",
|
||||||
|
"z0_min_ohms",
|
||||||
|
"z0_max_ohms",
|
||||||
|
"z0_avg_ohms",
|
||||||
|
"flags",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_profile(net_name: str, samples: tuple[ImpedanceSample, ...]) -> NetProfile:
|
||||||
|
return NetProfile(net_name=net_name, samples=samples)
|
||||||
|
|
||||||
|
|
||||||
|
def to_csv(profile: NetProfile, path: str) -> None:
|
||||||
|
with open(path, "w", newline="", encoding="utf-8") as handle:
|
||||||
|
writer = csv.writer(handle)
|
||||||
|
writer.writerow(_CSV_COLUMNS)
|
||||||
|
for sample in profile.samples:
|
||||||
|
writer.writerow(_csv_row(sample))
|
||||||
|
|
||||||
|
|
||||||
|
def _csv_row(sample: ImpedanceSample) -> tuple:
|
||||||
|
return (
|
||||||
|
sample.distance_along_net_mm,
|
||||||
|
sample.position.x_mm,
|
||||||
|
sample.position.y_mm,
|
||||||
|
sample.layer,
|
||||||
|
sample.width_mm,
|
||||||
|
sample.topology.name,
|
||||||
|
"" if sample.z0_ohms is None else sample.z0_ohms,
|
||||||
|
";".join(sample.flags),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def summarize_net(net_name: str, board_data: BoardData, result: NetAnalysisResult) -> NetSummary:
|
||||||
|
"""Collapse one net's per-sample analysis into a single report row —
|
||||||
|
used by board_report.py for a batch of nets, one closed-form pass each,
|
||||||
|
no openEMS involved."""
|
||||||
|
z0_values = tuple(sample.z0_ohms for sample in result.samples if sample.z0_ohms is not None)
|
||||||
|
return NetSummary(
|
||||||
|
net_name=net_name,
|
||||||
|
length_mm=net_analysis.net_length_mm(board_data, net_name),
|
||||||
|
branch_count=result.branch_count,
|
||||||
|
is_differential=result.is_differential,
|
||||||
|
partner_net_name=result.partner_net_name,
|
||||||
|
topologies=_unique_in_order(sample.topology.name for sample in result.samples),
|
||||||
|
z0_min_ohms=min(z0_values) if z0_values else None,
|
||||||
|
z0_max_ohms=max(z0_values) if z0_values else None,
|
||||||
|
z0_avg_ohms=sum(z0_values) / len(z0_values) if z0_values else None,
|
||||||
|
flags=_unique_in_order(flag for sample in result.samples for flag in sample.flags),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _unique_in_order(values) -> tuple[str, ...]:
|
||||||
|
return tuple(dict.fromkeys(values))
|
||||||
|
|
||||||
|
|
||||||
|
def summaries_to_csv(summaries: tuple[NetSummary, ...], path: str) -> None:
|
||||||
|
with open(path, "w", newline="", encoding="utf-8") as handle:
|
||||||
|
writer = csv.writer(handle)
|
||||||
|
writer.writerow(_SUMMARY_CSV_COLUMNS)
|
||||||
|
for summary in summaries:
|
||||||
|
writer.writerow(_summary_csv_row(summary))
|
||||||
|
|
||||||
|
|
||||||
|
def _summary_csv_row(summary: NetSummary) -> tuple:
|
||||||
|
return (
|
||||||
|
summary.net_name,
|
||||||
|
summary.length_mm,
|
||||||
|
summary.branch_count,
|
||||||
|
summary.is_differential,
|
||||||
|
summary.partner_net_name or "",
|
||||||
|
";".join(summary.topologies),
|
||||||
|
"" if summary.z0_min_ohms is None else summary.z0_min_ohms,
|
||||||
|
"" if summary.z0_max_ohms is None else summary.z0_max_ohms,
|
||||||
|
"" if summary.z0_avg_ohms is None else summary.z0_avg_ohms,
|
||||||
|
";".join(summary.flags),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def to_rf2dfieldsolver(profile: NetProfile, path: str) -> None:
|
||||||
|
"""Export a 2D cross-section for RF2DFieldSolver at a flagged region.
|
||||||
|
Not implemented yet — see the module docstring and
|
||||||
|
docs/field-solver-export-plan.md."""
|
||||||
|
raise NotImplementedError("RF2DFieldSolver export is not implemented yet")
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
# Manual stackup description consumed by impedancefinder.board_model.load_manual_stackup().
|
||||||
|
#
|
||||||
|
# This is the primary way to supply Er/height data today: KiCad's Python API
|
||||||
|
# (verified against KiCad 10.0.4) does not expose BOARD_STACKUP to scripting,
|
||||||
|
# so Board Setup's physical stackup cannot be read back reliably. Fill this
|
||||||
|
# file in from the fabricator's stackup table instead.
|
||||||
|
#
|
||||||
|
# copper_layers: ordered top-to-bottom, using KiCad's canonical layer names.
|
||||||
|
# dielectrics: ordered top-to-bottom, one entry between each pair of adjacent
|
||||||
|
# copper layers (len(dielectrics) == len(copper_layers) - 1).
|
||||||
|
# copper_thickness_mm: optional, defaults to 0.035mm (1oz copper) if omitted.
|
||||||
|
|
||||||
|
copper_thickness_mm: 0.035
|
||||||
|
|
||||||
|
copper_layers:
|
||||||
|
- F.Cu
|
||||||
|
- In1.Cu
|
||||||
|
- In2.Cu
|
||||||
|
- B.Cu
|
||||||
|
|
||||||
|
dielectrics:
|
||||||
|
- name: core
|
||||||
|
er: 4.4
|
||||||
|
height_mm: 0.2
|
||||||
|
- name: prepreg
|
||||||
|
er: 4.1
|
||||||
|
height_mm: 1.0
|
||||||
|
- name: core
|
||||||
|
er: 4.4
|
||||||
|
height_mm: 0.2
|
||||||
Vendored
+144
@@ -0,0 +1,144 @@
|
|||||||
|
"""Closed-form characteristic-impedance solvers.
|
||||||
|
|
||||||
|
Static (zero-frequency) Hammerstad-Jensen microstrip and Cohn/IPC-2141
|
||||||
|
stripline formulas, ported term-for-term from KiCad's own pcb_calculator
|
||||||
|
engine (common/transline_calculations/{microstrip,stripline}.cpp, verified
|
||||||
|
against the KiCad 10.0.4 source tag) with the frequency-dispersion, cover,
|
||||||
|
and conductor/dielectric-loss terms dropped — this tool needs the static Z0
|
||||||
|
for post-route verification, not a full RF loss/dispersion analysis.
|
||||||
|
Differential corrections use the separate, simpler IPC-2141A empirical
|
||||||
|
odd-mode formulas rather than KiCad's full coupled-line even/odd-mode solver.
|
||||||
|
|
||||||
|
All functions are pure: same inputs always give the same output, no state,
|
||||||
|
no I/O. Dimensions are millimetres, er is dimensionless, results are ohms.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
_FREE_SPACE_IMPEDANCE_OHMS = 376.730313668 # NIST CODATA Z0
|
||||||
|
|
||||||
|
|
||||||
|
def _thickness_width_correction(u: float, t_h: float, er: float) -> float:
|
||||||
|
"""Hammerstad-Jensen effective-width correction for finite copper
|
||||||
|
thickness (delta_u in microstrip.cpp)."""
|
||||||
|
if t_h <= 0:
|
||||||
|
return 0.0
|
||||||
|
delta_u = (t_h / math.pi) * math.log(
|
||||||
|
1.0 + (4.0 * math.e) * math.tanh(math.sqrt(6.517 * u)) ** 2 / t_h
|
||||||
|
)
|
||||||
|
return 0.5 * delta_u * (1.0 + 1.0 / math.cosh(math.sqrt(er - 1.0)))
|
||||||
|
|
||||||
|
|
||||||
|
def _homogeneous_impedance_ohms(u: float) -> float:
|
||||||
|
"""Hammerstad's single-formula air-filled microstrip impedance for
|
||||||
|
shape ratio u = W/H, valid across the full range of u."""
|
||||||
|
shape = 6.0 + (2.0 * math.pi - 6.0) * math.exp(-((30.666 / u) ** 0.7528))
|
||||||
|
return (_FREE_SPACE_IMPEDANCE_OHMS / (2.0 * math.pi)) * math.log(
|
||||||
|
shape / u + math.sqrt(1.0 + 4.0 / (u * u))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _filling_factor(u: float, er: float) -> float:
|
||||||
|
"""Hammerstad-Jensen dielectric filling factor q for shape ratio u."""
|
||||||
|
u2, u3, u4 = u * u, u**3, u**4
|
||||||
|
a = (
|
||||||
|
1.0
|
||||||
|
+ math.log((u4 + u2 / 2704.0) / (u4 + 0.432)) / 49.0
|
||||||
|
+ math.log(1.0 + u3 / 5929.741) / 18.7
|
||||||
|
)
|
||||||
|
b = 0.564 * ((er - 0.9) / (er + 3.0)) ** 0.053
|
||||||
|
return (1.0 + 10.0 / u) ** (-a * b)
|
||||||
|
|
||||||
|
|
||||||
|
def microstrip_z0(width_mm: float, height_mm: float, er: float, t_mm: float = 0.0) -> float:
|
||||||
|
"""Single-ended microstrip Z0 via Hammerstad-Jensen, static (f=0).
|
||||||
|
|
||||||
|
width_mm: trace width. height_mm: dielectric height to the reference
|
||||||
|
plane below the trace. er: dielectric relative permittivity. t_mm:
|
||||||
|
copper thickness (0 disables the thickness correction).
|
||||||
|
"""
|
||||||
|
u = width_mm / height_mm
|
||||||
|
t_h = t_mm / height_mm
|
||||||
|
|
||||||
|
u_er = u + _thickness_width_correction(u, t_h, er)
|
||||||
|
z0_dielectric = _homogeneous_impedance_ohms(u_er)
|
||||||
|
|
||||||
|
q = _filling_factor(u_er, er) - (2.0 * math.log(2.0) / math.pi) * (t_h / math.sqrt(u_er))
|
||||||
|
er_eff = 0.5 * (er + 1.0) + 0.5 * q * (er - 1.0)
|
||||||
|
|
||||||
|
return z0_dielectric / math.sqrt(er_eff)
|
||||||
|
|
||||||
|
|
||||||
|
def _stripline_line_impedance_ohms(
|
||||||
|
plane_spacing_mm: float, width_mm: float, t_mm: float, er: float
|
||||||
|
) -> float:
|
||||||
|
"""Cohn's stripline formula as used by KiCad's stripline.cpp, specialized
|
||||||
|
to the width-dominated (>=0.35) and narrow-trace regimes."""
|
||||||
|
hmt = plane_spacing_mm - t_mm
|
||||||
|
if width_mm / hmt >= 0.35:
|
||||||
|
wide = width_mm + (
|
||||||
|
2.0 * plane_spacing_mm * math.log((2.0 * plane_spacing_mm - t_mm) / hmt)
|
||||||
|
- t_mm * math.log(plane_spacing_mm**2 / hmt**2 - 1.0)
|
||||||
|
) / math.pi
|
||||||
|
return _FREE_SPACE_IMPEDANCE_OHMS * hmt / math.sqrt(er) / 4.0 / wide
|
||||||
|
|
||||||
|
ratio = t_mm / width_mm
|
||||||
|
if ratio > 1.0:
|
||||||
|
ratio = width_mm / t_mm
|
||||||
|
effective_diameter = (
|
||||||
|
1.0 + ratio / math.pi * (1.0 + math.log(4.0 * math.pi / ratio)) + 0.236 * ratio**1.65
|
||||||
|
)
|
||||||
|
effective_diameter *= (t_mm / 2.0) if (t_mm / width_mm) > 1.0 else (width_mm / 2.0)
|
||||||
|
return (
|
||||||
|
_FREE_SPACE_IMPEDANCE_OHMS
|
||||||
|
/ (2.0 * math.pi * math.sqrt(er))
|
||||||
|
* math.log(4.0 * plane_spacing_mm / math.pi / effective_diameter)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def stripline_z0(width_mm: float, b_mm: float, er: float, t_mm: float) -> float:
|
||||||
|
"""Symmetric (centered) stripline Z0, ported from KiCad's Cohn-derived
|
||||||
|
stripline.cpp, specialized to a trace centered between two reference
|
||||||
|
planes spaced b_mm apart.
|
||||||
|
|
||||||
|
t_mm must be > 0: the formula divides by hmt = b_mm - t_mm and takes a
|
||||||
|
log that is singular at t_mm == 0. Real copper always has finite
|
||||||
|
thickness, so callers must supply it (e.g. 0.035 mm for 1 oz copper).
|
||||||
|
"""
|
||||||
|
if t_mm <= 0:
|
||||||
|
raise ValueError("stripline_z0 requires t_mm > 0 (finite copper thickness)")
|
||||||
|
return _stripline_line_impedance_ohms(b_mm, width_mm, t_mm, er)
|
||||||
|
|
||||||
|
|
||||||
|
def diff_microstrip_z0(
|
||||||
|
width_mm: float, height_mm: float, spacing_mm: float, er: float, t_mm: float = 0.0
|
||||||
|
) -> float:
|
||||||
|
"""Edge-coupled differential microstrip Z0: IPC-2141A's empirical
|
||||||
|
odd-mode correction applied to the single-ended Hammerstad-Jensen value.
|
||||||
|
|
||||||
|
spacing_mm: edge-to-edge gap between the two traces of the pair.
|
||||||
|
"""
|
||||||
|
z0 = microstrip_z0(width_mm, height_mm, er, t_mm)
|
||||||
|
return 2.0 * z0 * (1.0 - 0.48 * math.exp(-0.96 * spacing_mm / height_mm))
|
||||||
|
|
||||||
|
|
||||||
|
def diff_stripline_z0(
|
||||||
|
width_mm: float, b_mm: float, spacing_mm: float, er: float, t_mm: float
|
||||||
|
) -> float:
|
||||||
|
"""Edge-coupled differential stripline Z0: IPC-2141A's empirical
|
||||||
|
odd-mode correction applied to the single-ended stripline value."""
|
||||||
|
z0 = stripline_z0(width_mm, b_mm, er, t_mm)
|
||||||
|
return 2.0 * z0 * (1.0 - 0.347 * math.exp(-2.9 * spacing_mm / b_mm))
|
||||||
|
|
||||||
|
|
||||||
|
def cpwg_z0(*_args, **_kwargs) -> float:
|
||||||
|
"""Grounded coplanar waveguide Z0 — not implemented yet.
|
||||||
|
|
||||||
|
CPWG needs the coplanar-ground-gap geometry in addition to the
|
||||||
|
reference-plane height, which isn't modeled by this solver set yet.
|
||||||
|
Raises explicitly so a CPWG classification surfaces as a clear
|
||||||
|
"not supported" flag (see geometry.classify_topology) instead of a
|
||||||
|
silently wrong number.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError("CPWG closed-form solver is not implemented yet")
|
||||||
Reference in New Issue
Block a user