Integrate ImpedenceFinder closed-form Z0 into the project Impedance tab.
Use the vendored Hammerstad-Jensen/Cohn engine for microstrip, stripline, and coupled-diff advice. Skip OpenEMS/pcbnew and refuse CPWG rather than inventing a number. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -25,6 +25,9 @@ COPY skills/ /app/skills/
|
||||
# Changelog: single source of truth for the user-facing Pinscope version.
|
||||
COPY frontend/content/changelog.md /app/changelog.md
|
||||
|
||||
# ImpedenceFinder closed-form engine (no OpenEMS / pcbnew).
|
||||
COPY vendor/ /app/vendor/
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||
|
||||
+2
-1
@@ -10,7 +10,7 @@ from fastapi.responses import JSONResponse
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from backend.config import settings
|
||||
from backend.routers import admin, contact, feedback, pipeline, projects, reports, survey
|
||||
from backend.routers import admin, contact, feedback, impedance, pipeline, projects, reports, survey
|
||||
from backend.services.projects import ProjectNotFound
|
||||
from backend.services.storage import LocalStorageBackend
|
||||
|
||||
@@ -147,6 +147,7 @@ async def _project_not_found_handler(request: Request, exc: ProjectNotFound):
|
||||
app.include_router(projects.router, prefix="/api")
|
||||
app.include_router(pipeline.router, prefix="/api")
|
||||
app.include_router(reports.router, prefix="/api")
|
||||
app.include_router(impedance.router, prefix="/api")
|
||||
app.include_router(admin.router, prefix="/api")
|
||||
if settings.billing_enabled:
|
||||
# Import guarded too: with billing disabled the core never loads the
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
"""Pinscope facade over ImpedanceFinder's closed-form Z0 solver.
|
||||
|
||||
All Z0 numbers come from ImpedenceFinder (`vendor/impedancefinder`,
|
||||
Hammerstad-Jensen / Cohn as in KiCad pcb_calculator). This module only
|
||||
validates geometry, inverts width for a target Z, and exports KiCad
|
||||
custom-rule advice. It never emits Findings. CPWG is not implemented
|
||||
upstream — we raise instead of inventing a number.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from backend.vendor_path import ensure_impedancefinder
|
||||
|
||||
ensure_impedancefinder()
|
||||
from impedancefinder import zsolver
|
||||
|
||||
|
||||
class GeometryError(ValueError):
|
||||
"""Trace geometry is missing, non-physical, or unsupported."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TraceGeometry:
|
||||
h: float
|
||||
er: float
|
||||
t: float
|
||||
w: float | None = None
|
||||
s: float | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ImpedanceResult:
|
||||
kind: str
|
||||
w_mm: float | None = None
|
||||
s_mm: float | None = None
|
||||
z0: float | None = None
|
||||
zodd: float | None = None
|
||||
zeven: float | None = None
|
||||
zdiff: float | None = None
|
||||
formula: str = "impedancefinder"
|
||||
|
||||
|
||||
def _require_positive(name: str, value: float | None) -> float:
|
||||
if value is None or value <= 0:
|
||||
raise GeometryError(f"{name} must be > 0")
|
||||
return float(value)
|
||||
|
||||
|
||||
def microstrip_z0(geo: TraceGeometry) -> float:
|
||||
h = _require_positive("h", geo.h)
|
||||
er = _require_positive("er", geo.er)
|
||||
w = _require_positive("w", geo.w)
|
||||
t = geo.t
|
||||
if t < 0:
|
||||
raise GeometryError("t must be >= 0")
|
||||
return zsolver.microstrip_z0(w, h, er, t)
|
||||
|
||||
|
||||
def stripline_z0(geo: TraceGeometry) -> float:
|
||||
h = _require_positive("h", geo.h)
|
||||
er = _require_positive("er", geo.er)
|
||||
w = _require_positive("w", geo.w)
|
||||
t = _require_positive("t", geo.t)
|
||||
try:
|
||||
return zsolver.stripline_z0(w, h, er, t)
|
||||
except ValueError as exc:
|
||||
raise GeometryError(str(exc)) from exc
|
||||
|
||||
|
||||
def coupled_diff_z(geo: TraceGeometry) -> tuple[float, float, float]:
|
||||
"""Return (Zodd, Zeven, Zdiff) via ImpedanceFinder IPC-2141A odd-mode."""
|
||||
s = _require_positive("s", geo.s)
|
||||
h = _require_positive("h", geo.h)
|
||||
z0 = microstrip_z0(geo)
|
||||
zdiff = zsolver.diff_microstrip_z0(
|
||||
_require_positive("w", geo.w), h, s, geo.er, geo.t
|
||||
)
|
||||
zodd = zdiff / 2.0
|
||||
zeven = 2.0 * z0 - zodd
|
||||
return (zodd, zeven, zdiff)
|
||||
|
||||
|
||||
def cpw_z0(geo: TraceGeometry) -> float:
|
||||
_require_positive("h", geo.h)
|
||||
_require_positive("er", geo.er)
|
||||
_require_positive("w", geo.w)
|
||||
_require_positive("s", geo.s)
|
||||
try:
|
||||
return zsolver.cpwg_z0(geo.w, geo.h, geo.s, geo.er, geo.t)
|
||||
except NotImplementedError as exc:
|
||||
raise GeometryError(str(exc)) from exc
|
||||
|
||||
|
||||
def solve_width(
|
||||
kind: str,
|
||||
target_z: float,
|
||||
h: float,
|
||||
er: float,
|
||||
t: float,
|
||||
s: float | None = None,
|
||||
) -> float:
|
||||
_require_positive("target_z", target_z)
|
||||
_require_positive("h", h)
|
||||
_require_positive("er", er)
|
||||
if kind == "stripline":
|
||||
_require_positive("t", t)
|
||||
elif t < 0:
|
||||
raise GeometryError("t must be >= 0")
|
||||
|
||||
def z_of(w: float) -> float:
|
||||
geo = TraceGeometry(h=h, er=er, t=t, w=w, s=s)
|
||||
if kind == "microstrip":
|
||||
return microstrip_z0(geo)
|
||||
if kind == "stripline":
|
||||
return stripline_z0(geo)
|
||||
if kind == "diff":
|
||||
return coupled_diff_z(geo)[2]
|
||||
if kind == "cpw":
|
||||
return cpw_z0(geo)
|
||||
raise GeometryError(f"unknown kind {kind}")
|
||||
|
||||
lo, hi = 0.01 * h, 40.0 * h
|
||||
z_lo, z_hi = z_of(lo), z_of(hi)
|
||||
if not (min(z_lo, z_hi) <= target_z <= max(z_lo, z_hi)):
|
||||
raise GeometryError("target_z is outside the solvable width range")
|
||||
for _ in range(48):
|
||||
mid = 0.5 * (lo + hi)
|
||||
zm = z_of(mid)
|
||||
if zm > target_z:
|
||||
lo = mid
|
||||
else:
|
||||
hi = mid
|
||||
return 0.5 * (lo + hi)
|
||||
|
||||
|
||||
def stackup_targets(
|
||||
h: float,
|
||||
er: float,
|
||||
t: float,
|
||||
s: float,
|
||||
) -> dict[str, ImpedanceResult]:
|
||||
w50 = solve_width("microstrip", 50.0, h, er, t)
|
||||
w90 = solve_width("diff", 90.0, h, er, t, s=s)
|
||||
w100 = solve_width("diff", 100.0, h, er, t, s=s)
|
||||
z50 = microstrip_z0(TraceGeometry(h=h, er=er, t=t, w=w50))
|
||||
_, _, zd90 = coupled_diff_z(TraceGeometry(h=h, er=er, t=t, w=w90, s=s))
|
||||
_, _, zd100 = coupled_diff_z(TraceGeometry(h=h, er=er, t=t, w=w100, s=s))
|
||||
return {
|
||||
"microstrip_50": ImpedanceResult(kind="microstrip", w_mm=w50, z0=z50),
|
||||
"diff_90": ImpedanceResult(kind="diff", w_mm=w90, s_mm=s, zdiff=zd90),
|
||||
"diff_100": ImpedanceResult(kind="diff", w_mm=w100, s_mm=s, zdiff=zd100),
|
||||
}
|
||||
|
||||
|
||||
def export_kicad_dru(targets: dict[str, ImpedanceResult]) -> str:
|
||||
"""KiCad custom-rule advice. The user applies it; Pinscope does not DRC the PCB."""
|
||||
lines = [
|
||||
"(version 1)",
|
||||
"# Pinscope impedance advice (ImpedanceFinder solver) — apply in pcbnew.",
|
||||
]
|
||||
mapping = (
|
||||
("microstrip_50", "PINSCOPE_50OHM", "50Ohm"),
|
||||
("diff_90", "PINSCOPE_90OHM_USB", "90Ohm"),
|
||||
("diff_100", "PINSCOPE_100OHM_DIFF", "100Ohm"),
|
||||
)
|
||||
for key, rule, netclass in mapping:
|
||||
r = targets[key]
|
||||
w = r.w_mm
|
||||
if w is None:
|
||||
continue
|
||||
lines.append("")
|
||||
lines.append(f"(rule {rule}")
|
||||
lines.append(f' (constraint track_width (min {w:.4f}mm) (opt {w:.4f}mm) (max {w:.4f}mm))')
|
||||
if r.s_mm:
|
||||
lines.append(
|
||||
f" (constraint diff_pair_gap (min {r.s_mm:.4f}mm) "
|
||||
f"(opt {r.s_mm:.4f}mm) (max {r.s_mm:.4f}mm))"
|
||||
)
|
||||
lines.append(f' (condition "A.NetClass == \'{netclass}\'"))')
|
||||
return "\n".join(lines) + "\n"
|
||||
@@ -18,3 +18,5 @@ PyJWT[crypto]>=2.8
|
||||
cryptography>=42.0
|
||||
httpx>=0.27
|
||||
packaging>=24.0
|
||||
shapely>=2.0
|
||||
PyYAML>=6.0
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Standalone impedance calculator (no PCB, no findings)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.pinscopex.impedance import (
|
||||
GeometryError,
|
||||
TraceGeometry,
|
||||
coupled_diff_z,
|
||||
cpw_z0,
|
||||
export_kicad_dru,
|
||||
microstrip_z0,
|
||||
solve_width,
|
||||
stackup_targets,
|
||||
stripline_z0,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["impedance"])
|
||||
|
||||
|
||||
class ImpedanceRequest(BaseModel):
|
||||
mode: Literal["trace", "stackup"] = "trace"
|
||||
kind: Literal["microstrip", "stripline", "cpw", "diff"] | None = None
|
||||
h: float
|
||||
er: float
|
||||
t: float = 0.035
|
||||
w: float | None = None
|
||||
s: float | None = None
|
||||
target_z: float | None = None
|
||||
|
||||
|
||||
def _z_for_kind(kind: str, geo: TraceGeometry) -> dict:
|
||||
if kind == "microstrip":
|
||||
return {"z0": microstrip_z0(geo), "w_mm": geo.w, "s_mm": geo.s, "kind": kind}
|
||||
if kind == "stripline":
|
||||
return {"z0": stripline_z0(geo), "w_mm": geo.w, "s_mm": geo.s, "kind": kind}
|
||||
if kind == "cpw":
|
||||
return {"z0": cpw_z0(geo), "w_mm": geo.w, "s_mm": geo.s, "kind": kind}
|
||||
if kind == "diff":
|
||||
zodd, zeven, zdiff = coupled_diff_z(geo)
|
||||
return {
|
||||
"z0": None,
|
||||
"zodd": zodd,
|
||||
"zeven": zeven,
|
||||
"zdiff": zdiff,
|
||||
"w_mm": geo.w,
|
||||
"s_mm": geo.s,
|
||||
"kind": kind,
|
||||
}
|
||||
raise GeometryError(f"unknown kind {kind}")
|
||||
|
||||
|
||||
@router.post("/impedance")
|
||||
def compute_impedance(body: ImpedanceRequest):
|
||||
try:
|
||||
if body.mode == "stackup":
|
||||
s = body.s if body.s is not None else 0.2
|
||||
targets = stackup_targets(h=body.h, er=body.er, t=body.t, s=s)
|
||||
return {
|
||||
"targets": {k: asdict(v) for k, v in targets.items()},
|
||||
"kicad_dru": export_kicad_dru(targets),
|
||||
}
|
||||
kind = body.kind or "microstrip"
|
||||
w = body.w
|
||||
if body.target_z is not None:
|
||||
w = solve_width(kind, body.target_z, body.h, body.er, body.t, s=body.s)
|
||||
geo = TraceGeometry(h=body.h, er=body.er, t=body.t, w=w, s=body.s)
|
||||
return _z_for_kind(kind, geo)
|
||||
except GeometryError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Put vendored ImpedenceFinder on sys.path (closed-form package only)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
VENDOR_DIR = Path(__file__).resolve().parents[1] / "vendor"
|
||||
|
||||
|
||||
def ensure_impedancefinder() -> None:
|
||||
root = str(VENDOR_DIR)
|
||||
if root not in sys.path:
|
||||
sys.path.insert(0, root)
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
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
|
||||
|
||||
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,
|
||||
} from "lucide-react";
|
||||
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({
|
||||
params,
|
||||
@@ -347,6 +347,8 @@ export default function ProjectDetailPage({
|
||||
/>
|
||||
)}
|
||||
|
||||
{tab === "impedance" && <ImpedancePanel />}
|
||||
|
||||
{tab === "logs" && (
|
||||
<ApiLogsSection logs={logs} />
|
||||
)}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
TableProperties,
|
||||
Loader2,
|
||||
Zap,
|
||||
Ruler,
|
||||
ScrollText,
|
||||
MessageSquareWarning,
|
||||
Library,
|
||||
@@ -179,6 +180,7 @@ const PROJECT_NAV_ITEMS: NavItem[] = [
|
||||
{ type: "route", path: "/report", label: "Report", icon: ClipboardList },
|
||||
{ type: "tab", tab: "bom", label: "BOM", icon: TableProperties },
|
||||
{ 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: "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,
|
||||
DesignGraph,
|
||||
DeratingRow,
|
||||
ImpedanceKind,
|
||||
ImpedanceStackupResult,
|
||||
ImpedanceTraceResult,
|
||||
EdifSubDesign,
|
||||
FindingComment,
|
||||
LcscPayload,
|
||||
@@ -582,6 +585,28 @@ export async function fetchDerating(
|
||||
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(
|
||||
projectId: string,
|
||||
): Promise<ValidationReport> {
|
||||
|
||||
@@ -320,6 +320,32 @@ export interface DeratingSettings {
|
||||
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 {
|
||||
ref: 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:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
from backend.vendor_path import ensure_impedancefinder
|
||||
|
||||
ensure_impedancefinder()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
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