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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-10 23:14:57 +02:00
co-authored by Cursor
parent 3e43bb6ecb
commit 6fc2ac583d
30 changed files with 2219 additions and 2 deletions
+3
View File
@@ -25,6 +25,9 @@ COPY skills/ /app/skills/
# Changelog: single source of truth for the user-facing Pinscope version. # 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
View File
@@ -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
+182
View File
@@ -0,0 +1,182 @@
"""Pinscope facade over ImpedanceFinder's closed-form Z0 solver.
All Z0 numbers come from ImpedenceFinder (`vendor/impedancefinder`,
Hammerstad-Jensen / Cohn as in KiCad pcb_calculator). This module only
validates geometry, inverts width for a target Z, and exports KiCad
custom-rule advice. It never emits Findings. CPWG is not implemented
upstream — we raise instead of inventing a number.
"""
from __future__ import annotations
from dataclasses import dataclass
from backend.vendor_path import ensure_impedancefinder
ensure_impedancefinder()
from impedancefinder import zsolver
class GeometryError(ValueError):
"""Trace geometry is missing, non-physical, or unsupported."""
@dataclass(frozen=True)
class TraceGeometry:
h: float
er: float
t: float
w: float | None = None
s: float | None = None
@dataclass(frozen=True)
class ImpedanceResult:
kind: str
w_mm: float | None = None
s_mm: float | None = None
z0: float | None = None
zodd: float | None = None
zeven: float | None = None
zdiff: float | None = None
formula: str = "impedancefinder"
def _require_positive(name: str, value: float | None) -> float:
if value is None or value <= 0:
raise GeometryError(f"{name} must be > 0")
return float(value)
def microstrip_z0(geo: TraceGeometry) -> float:
h = _require_positive("h", geo.h)
er = _require_positive("er", geo.er)
w = _require_positive("w", geo.w)
t = geo.t
if t < 0:
raise GeometryError("t must be >= 0")
return zsolver.microstrip_z0(w, h, er, t)
def stripline_z0(geo: TraceGeometry) -> float:
h = _require_positive("h", geo.h)
er = _require_positive("er", geo.er)
w = _require_positive("w", geo.w)
t = _require_positive("t", geo.t)
try:
return zsolver.stripline_z0(w, h, er, t)
except ValueError as exc:
raise GeometryError(str(exc)) from exc
def coupled_diff_z(geo: TraceGeometry) -> tuple[float, float, float]:
"""Return (Zodd, Zeven, Zdiff) via ImpedanceFinder IPC-2141A odd-mode."""
s = _require_positive("s", geo.s)
h = _require_positive("h", geo.h)
z0 = microstrip_z0(geo)
zdiff = zsolver.diff_microstrip_z0(
_require_positive("w", geo.w), h, s, geo.er, geo.t
)
zodd = zdiff / 2.0
zeven = 2.0 * z0 - zodd
return (zodd, zeven, zdiff)
def cpw_z0(geo: TraceGeometry) -> float:
_require_positive("h", geo.h)
_require_positive("er", geo.er)
_require_positive("w", geo.w)
_require_positive("s", geo.s)
try:
return zsolver.cpwg_z0(geo.w, geo.h, geo.s, geo.er, geo.t)
except NotImplementedError as exc:
raise GeometryError(str(exc)) from exc
def solve_width(
kind: str,
target_z: float,
h: float,
er: float,
t: float,
s: float | None = None,
) -> float:
_require_positive("target_z", target_z)
_require_positive("h", h)
_require_positive("er", er)
if kind == "stripline":
_require_positive("t", t)
elif t < 0:
raise GeometryError("t must be >= 0")
def z_of(w: float) -> float:
geo = TraceGeometry(h=h, er=er, t=t, w=w, s=s)
if kind == "microstrip":
return microstrip_z0(geo)
if kind == "stripline":
return stripline_z0(geo)
if kind == "diff":
return coupled_diff_z(geo)[2]
if kind == "cpw":
return cpw_z0(geo)
raise GeometryError(f"unknown kind {kind}")
lo, hi = 0.01 * h, 40.0 * h
z_lo, z_hi = z_of(lo), z_of(hi)
if not (min(z_lo, z_hi) <= target_z <= max(z_lo, z_hi)):
raise GeometryError("target_z is outside the solvable width range")
for _ in range(48):
mid = 0.5 * (lo + hi)
zm = z_of(mid)
if zm > target_z:
lo = mid
else:
hi = mid
return 0.5 * (lo + hi)
def stackup_targets(
h: float,
er: float,
t: float,
s: float,
) -> dict[str, ImpedanceResult]:
w50 = solve_width("microstrip", 50.0, h, er, t)
w90 = solve_width("diff", 90.0, h, er, t, s=s)
w100 = solve_width("diff", 100.0, h, er, t, s=s)
z50 = microstrip_z0(TraceGeometry(h=h, er=er, t=t, w=w50))
_, _, zd90 = coupled_diff_z(TraceGeometry(h=h, er=er, t=t, w=w90, s=s))
_, _, zd100 = coupled_diff_z(TraceGeometry(h=h, er=er, t=t, w=w100, s=s))
return {
"microstrip_50": ImpedanceResult(kind="microstrip", w_mm=w50, z0=z50),
"diff_90": ImpedanceResult(kind="diff", w_mm=w90, s_mm=s, zdiff=zd90),
"diff_100": ImpedanceResult(kind="diff", w_mm=w100, s_mm=s, zdiff=zd100),
}
def export_kicad_dru(targets: dict[str, ImpedanceResult]) -> str:
"""KiCad custom-rule advice. The user applies it; Pinscope does not DRC the PCB."""
lines = [
"(version 1)",
"# Pinscope impedance advice (ImpedanceFinder solver) — apply in pcbnew.",
]
mapping = (
("microstrip_50", "PINSCOPE_50OHM", "50Ohm"),
("diff_90", "PINSCOPE_90OHM_USB", "90Ohm"),
("diff_100", "PINSCOPE_100OHM_DIFF", "100Ohm"),
)
for key, rule, netclass in mapping:
r = targets[key]
w = r.w_mm
if w is None:
continue
lines.append("")
lines.append(f"(rule {rule}")
lines.append(f' (constraint track_width (min {w:.4f}mm) (opt {w:.4f}mm) (max {w:.4f}mm))')
if r.s_mm:
lines.append(
f" (constraint diff_pair_gap (min {r.s_mm:.4f}mm) "
f"(opt {r.s_mm:.4f}mm) (max {r.s_mm:.4f}mm))"
)
lines.append(f' (condition "A.NetClass == \'{netclass}\'"))')
return "\n".join(lines) + "\n"
+2
View File
@@ -18,3 +18,5 @@ PyJWT[crypto]>=2.8
cryptography>=42.0 cryptography>=42.0
httpx>=0.27 httpx>=0.27
packaging>=24.0 packaging>=24.0
shapely>=2.0
PyYAML>=6.0
+75
View File
@@ -0,0 +1,75 @@
"""Standalone impedance calculator (no PCB, no findings)."""
from __future__ import annotations
from dataclasses import asdict
from typing import Literal
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from backend.pinscopex.impedance import (
GeometryError,
TraceGeometry,
coupled_diff_z,
cpw_z0,
export_kicad_dru,
microstrip_z0,
solve_width,
stackup_targets,
stripline_z0,
)
router = APIRouter(tags=["impedance"])
class ImpedanceRequest(BaseModel):
mode: Literal["trace", "stackup"] = "trace"
kind: Literal["microstrip", "stripline", "cpw", "diff"] | None = None
h: float
er: float
t: float = 0.035
w: float | None = None
s: float | None = None
target_z: float | None = None
def _z_for_kind(kind: str, geo: TraceGeometry) -> dict:
if kind == "microstrip":
return {"z0": microstrip_z0(geo), "w_mm": geo.w, "s_mm": geo.s, "kind": kind}
if kind == "stripline":
return {"z0": stripline_z0(geo), "w_mm": geo.w, "s_mm": geo.s, "kind": kind}
if kind == "cpw":
return {"z0": cpw_z0(geo), "w_mm": geo.w, "s_mm": geo.s, "kind": kind}
if kind == "diff":
zodd, zeven, zdiff = coupled_diff_z(geo)
return {
"z0": None,
"zodd": zodd,
"zeven": zeven,
"zdiff": zdiff,
"w_mm": geo.w,
"s_mm": geo.s,
"kind": kind,
}
raise GeometryError(f"unknown kind {kind}")
@router.post("/impedance")
def compute_impedance(body: ImpedanceRequest):
try:
if body.mode == "stackup":
s = body.s if body.s is not None else 0.2
targets = stackup_targets(h=body.h, er=body.er, t=body.t, s=s)
return {
"targets": {k: asdict(v) for k, v in targets.items()},
"kicad_dru": export_kicad_dru(targets),
}
kind = body.kind or "microstrip"
w = body.w
if body.target_z is not None:
w = solve_width(kind, body.target_z, body.h, body.er, body.t, s=body.s)
geo = TraceGeometry(h=body.h, er=body.er, t=body.t, w=w, s=body.s)
return _z_for_kind(kind, geo)
except GeometryError as exc:
raise HTTPException(400, str(exc)) from exc
+14
View File
@@ -0,0 +1,14 @@
"""Put vendored ImpedenceFinder on sys.path (closed-form package only)."""
from __future__ import annotations
import sys
from pathlib import Path
VENDOR_DIR = Path(__file__).resolve().parents[1] / "vendor"
def ensure_impedancefinder() -> None:
root = str(VENDOR_DIR)
if root not in sys.path:
sys.path.insert(0, root)
+8
View File
@@ -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 (HammerstadJensen / 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.
+3 -1
View File
@@ -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 / HammerstadJensen 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>
);
}
+25
View File
@@ -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> {
+26
View File
@@ -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 }[];
+4
View File
@@ -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):
View File
+53
View File
@@ -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),
)
+138
View File
@@ -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
+64
View File
@@ -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
+75
View File
@@ -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
+80
View File
@@ -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()
+167
View File
@@ -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
+12
View File
@@ -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`.
View File
+209
View File
@@ -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
)
+226
View File
@@ -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
View File
@@ -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))
+157
View File
@@ -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,
)
+121
View File
@@ -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 ()
+121
View File
@@ -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
View File
@@ -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
+144
View File
@@ -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")