Trim threshold: absolute A/mm2 variant next to the relative one
Build PCM package / build (push) Successful in 6s
tests / ubuntu-latest · py3.11 (push) Successful in 54s
tests / fedora:latest (push) Successful in 1m11s
tests / ubuntu:24.04 (push) Successful in 1m22s
tests / NixOS (FHS wrapper from docs/NIXOS.md) (push) Has been skipped
tests / ubuntu-latest · py3.13 (push) Successful in 2m42s
tests / archlinux:latest (push) Successful in 55s
tests / debian:12 (push) Successful in 3m5s

The dialog threshold field gets a unit selector: % of mean |J|
(default, as before) or absolute A/mm2. The absolute variant applies
at the solved operating point - |J| scales with the test current, so
it is meant to be used with the real operating current entered as the
test current (config comment, README and dialog say so).

Switching units swaps in the other unit config default
(TRIM_THRESHOLD_A_MM2 = 1.0 for absolute) but never clobbers a number
the user typed. Validation stays per unit: 0..100 for %, > 0 for
A/mm2. pipeline.run takes trim_pct/trim_abs (at most one), compute()
mirrors that, and the JSON records threshold_mode + threshold_value
next to the resolved threshold_a_per_mm2.

Suite on dev 3.13 and the Python 3.9 mac-stack venv: 151 passed each;
dialog wiring exercised offscreen (defaults, unit swap, validation).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
janik
2026-07-24 14:21:13 +07:00
co-authored by Claude Fable 5
parent 6d8634802d
commit 8aff1c0be9
7 changed files with 146 additions and 43 deletions
+34 -16
View File
@@ -43,22 +43,34 @@ class LayerTrim:
@dataclass
class TrimResult:
threshold_pct: float
mode: str # "pct" (of the mean |J|) or "abs"
value: float # as entered: % or A/mm2
threshold_a_mm2: float # the absolute threshold this run used
layers: list[LayerTrim] # stackup order, top first
def low_current_mask(Jmag: np.ndarray,
threshold_pct: float) -> tuple[np.ndarray, float]:
def low_current_mask(Jmag: np.ndarray, pct: float | None = None,
abs_a_mm2: float | None = None
) -> tuple[np.ndarray, float]:
"""(L, ny, nx) |J| in A/m2 with NaN outside copper -> boolean mask of
the copper cells below threshold_pct % of the mean |J|, plus the
absolute threshold (A/m2). The mean is global over all layers: a
layer that carries little current overall is exactly the copper the
mask should show, not a reason to lower its own threshold."""
the copper cells below the threshold, plus the absolute threshold
(A/m2). Exactly one of the two threshold forms:
pct - % of the mean |J| over ALL layers' copper. Global on purpose:
a layer that carries little current overall is exactly the copper
the mask should show, not a reason to lower its own threshold.
abs_a_mm2 - absolute A/mm2. |J| scales with the test current, so
this applies at the chosen operating point.
"""
if (pct is None) == (abs_a_mm2 is None):
raise ValueError("exactly one of pct / abs_a_mm2 must be given")
copper = np.isfinite(Jmag)
if not copper.any():
raise ValueError("no copper cells in the solved field")
thr = float(np.nanmean(Jmag)) * threshold_pct / 100.0
if pct is not None:
thr = float(np.nanmean(Jmag)) * pct / 100.0
else:
thr = abs_a_mm2 * 1e6 # A/mm2 -> A/m2
below = np.zeros(Jmag.shape, dtype=bool)
below[copper] = Jmag[copper] < thr
return below, thr
@@ -131,10 +143,12 @@ def mask_to_polygons(mask2: np.ndarray, x0_nm: float, y0_nm: float,
return out
def compute(result, stack, threshold_pct: float) -> TrimResult:
"""Threshold the solved |J| and vectorize the below-threshold copper
of every layer; areas are cell counts (exact for the model)."""
below, thr = low_current_mask(result.Jmag, threshold_pct)
def compute(result, stack, pct: float | None = None,
abs_a_mm2: float | None = None) -> TrimResult:
"""Threshold the solved |J| (exactly one of pct / abs_a_mm2, see
low_current_mask) and vectorize the below-threshold copper of every
layer; areas are cell counts (exact for the model)."""
below, thr = low_current_mask(result.Jmag, pct=pct, abs_a_mm2=abs_a_mm2)
cell_mm2 = (stack.h_nm * 1e-6) ** 2
layers = []
for li, name in enumerate(stack.layer_names):
@@ -144,7 +158,8 @@ def compute(result, stack, threshold_pct: float) -> TrimResult:
layer=name, polygons=polys,
marked_mm2=float(below[li].sum()) * cell_mm2,
copper_mm2=float(np.isfinite(result.Jmag[li]).sum()) * cell_mm2))
return TrimResult(threshold_pct=threshold_pct,
return TrimResult(mode=("pct" if pct is not None else "abs"),
value=(pct if pct is not None else abs_a_mm2),
threshold_a_mm2=thr * 1e-6, layers=layers)
@@ -154,8 +169,9 @@ def summary_line(trim: TrimResult) -> str:
pct = (f" ({100.0 * lt.marked_mm2 / lt.copper_mm2:.0f}%)"
if lt.copper_mm2 else "")
parts.append(f"{lt.layer} {lt.marked_mm2:.1f} mm2{pct}")
return (f"low-current copper (|J| < {trim.threshold_pct:g}% of mean "
f"= {trim.threshold_a_mm2:.3g} A/mm2): " + "; ".join(parts))
head = (f"|J| < {trim.value:g}% of mean = {trim.threshold_a_mm2:.3g}"
if trim.mode == "pct" else f"|J| < {trim.threshold_a_mm2:g}")
return f"low-current copper ({head} A/mm2): " + "; ".join(parts)
def write_json(outdir: Path, trim: TrimResult) -> Path:
@@ -165,7 +181,9 @@ def write_json(outdir: Path, trim: TrimResult) -> Path:
p = Path(outdir) / JSON_NAME
doc = {
"threshold_pct_of_mean_J": trim.threshold_pct,
"threshold_mode": ("pct_of_mean_J" if trim.mode == "pct"
else "absolute"),
"threshold_value": trim.value,
"threshold_a_per_mm2": trim.threshold_a_mm2,
"note": ("marked = copper below the threshold at the solved "
"operating point; removing copper redistributes the "