diff --git a/backend/Dockerfile b/backend/Dockerfile index b8f30dd..32c0e14 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/main.py b/backend/main.py index d5dfc09..47d4481 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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 diff --git a/backend/pinscopex/impedance.py b/backend/pinscopex/impedance.py new file mode 100644 index 0000000..7e91784 --- /dev/null +++ b/backend/pinscopex/impedance.py @@ -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" diff --git a/backend/requirements.txt b/backend/requirements.txt index 284f9ab..6113ba2 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -18,3 +18,5 @@ PyJWT[crypto]>=2.8 cryptography>=42.0 httpx>=0.27 packaging>=24.0 +shapely>=2.0 +PyYAML>=6.0 diff --git a/backend/routers/impedance.py b/backend/routers/impedance.py new file mode 100644 index 0000000..bf9d784 --- /dev/null +++ b/backend/routers/impedance.py @@ -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 diff --git a/backend/vendor_path.py b/backend/vendor_path.py new file mode 100644 index 0000000..dc31989 --- /dev/null +++ b/backend/vendor_path.py @@ -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) diff --git a/frontend/content/changelog.md b/frontend/content/changelog.md index 5fe0a76..cedcc6e 100644 --- a/frontend/content/changelog.md +++ b/frontend/content/changelog.md @@ -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. diff --git a/frontend/src/app/(app)/project/[id]/page.tsx b/frontend/src/app/(app)/project/[id]/page.tsx index f455d59..b6c314b 100644 --- a/frontend/src/app/(app)/project/[id]/page.tsx +++ b/frontend/src/app/(app)/project/[id]/page.tsx @@ -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" && } + {tab === "logs" && ( )} diff --git a/frontend/src/components/layout/sidebar.tsx b/frontend/src/components/layout/sidebar.tsx index 37a1c32..6442a9c 100644 --- a/frontend/src/components/layout/sidebar.tsx +++ b/frontend/src/components/layout/sidebar.tsx @@ -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 }, ]; diff --git a/frontend/src/components/project/impedance-panel.tsx b/frontend/src/components/project/impedance-panel.tsx new file mode 100644 index 0000000..96312ae --- /dev/null +++ b/frontend/src/components/project/impedance-panel.tsx @@ -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("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(null); + const [busy, setBusy] = useState(false); + const [trace, setTrace] = useState(null); + const [stackup, setStackup] = useState(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 ( +
+ + + Impedance calculator + + +

+ 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. +

+
+ + + + + + + +
+
+ + +
+ {error &&

{error}

} + {trace && ( +

+ {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} +

+ )} +
+
+ + {stackup && ( + + + Suggested widths (apply in KiCad) + + + + + + + + + + + + + {Object.entries(stackup.targets).map(([key, row]) => ( + + + + + + + ))} + +
Targetw mms mmZ
{key}{fmt(row.w_mm, 4)}{fmt(row.s_mm, 4)} + {row.z0 != null ? `${fmt(row.z0)} Ω` : `${fmt(row.zdiff)} Ω diff`} +
+ +
+
+ )} +
+ ); +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index c58a530..7453f65 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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 { + 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 { diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index adbd291..e8e4373 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -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; + kicad_dru: string; +} + export interface NetlistPreviewDesignator { ref: string; pins: { number: string; net_name: string }[]; diff --git a/tests/conftest.py b/tests/conftest.py index 2718106..101e7d7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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): diff --git a/tests/impedancefinder/__init__.py b/tests/impedancefinder/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/impedancefinder/conftest.py b/tests/impedancefinder/conftest.py new file mode 100644 index 0000000..71dd302 --- /dev/null +++ b/tests/impedancefinder/conftest.py @@ -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), + ) diff --git a/tests/impedancefinder/test_geometry.py b/tests/impedancefinder/test_geometry.py new file mode 100644 index 0000000..96f248e --- /dev/null +++ b/tests/impedancefinder/test_geometry.py @@ -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 diff --git a/tests/impedancefinder/test_net_walk.py b/tests/impedancefinder/test_net_walk.py new file mode 100644 index 0000000..a7288c5 --- /dev/null +++ b/tests/impedancefinder/test_net_walk.py @@ -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 diff --git a/tests/impedancefinder/test_planes.py b/tests/impedancefinder/test_planes.py new file mode 100644 index 0000000..b7ec271 --- /dev/null +++ b/tests/impedancefinder/test_planes.py @@ -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 diff --git a/tests/impedancefinder/test_zsolver.py b/tests/impedancefinder/test_zsolver.py new file mode 100644 index 0000000..266148f --- /dev/null +++ b/tests/impedancefinder/test_zsolver.py @@ -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() diff --git a/tests/test_impedance.py b/tests/test_impedance.py new file mode 100644 index 0000000..c98d281 --- /dev/null +++ b/tests/test_impedance.py @@ -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 diff --git a/vendor/impedancefinder/SOURCE.md b/vendor/impedancefinder/SOURCE.md new file mode 100644 index 0000000..f8138fa --- /dev/null +++ b/vendor/impedancefinder/SOURCE.md @@ -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`. diff --git a/vendor/impedancefinder/__init__.py b/vendor/impedancefinder/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vendor/impedancefinder/geometry.py b/vendor/impedancefinder/geometry.py new file mode 100644 index 0000000..1ea36cb --- /dev/null +++ b/vendor/impedancefinder/geometry.py @@ -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 pairs with NET, 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 + ) diff --git a/vendor/impedancefinder/model.py b/vendor/impedancefinder/model.py new file mode 100644 index 0000000..971c2aa --- /dev/null +++ b/vendor/impedancefinder/model.py @@ -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] diff --git a/vendor/impedancefinder/net_analysis.py b/vendor/impedancefinder/net_analysis.py new file mode 100644 index 0000000..b02fc81 --- /dev/null +++ b/vendor/impedancefinder/net_analysis.py @@ -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)) diff --git a/vendor/impedancefinder/net_walk.py b/vendor/impedancefinder/net_walk.py new file mode 100644 index 0000000..4b01676 --- /dev/null +++ b/vendor/impedancefinder/net_walk.py @@ -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, + ) diff --git a/vendor/impedancefinder/planes.py b/vendor/impedancefinder/planes.py new file mode 100644 index 0000000..a6e8fad --- /dev/null +++ b/vendor/impedancefinder/planes.py @@ -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 () diff --git a/vendor/impedancefinder/report.py b/vendor/impedancefinder/report.py new file mode 100644 index 0000000..fa83590 --- /dev/null +++ b/vendor/impedancefinder/report.py @@ -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") diff --git a/vendor/impedancefinder/stackup.schema.yml b/vendor/impedancefinder/stackup.schema.yml new file mode 100644 index 0000000..7136f90 --- /dev/null +++ b/vendor/impedancefinder/stackup.schema.yml @@ -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 diff --git a/vendor/impedancefinder/zsolver.py b/vendor/impedancefinder/zsolver.py new file mode 100644 index 0000000..840b0bd --- /dev/null +++ b/vendor/impedancefinder/zsolver.py @@ -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")