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
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:
@@ -179,8 +179,13 @@ spelled out per step and in *Platform notes* below.
|
||||
`python tools/kicad_heatmap_overlay.py --net X --amps 10`.
|
||||
6. **Experimental — low-current copper marking** (dialog checkbox,
|
||||
default off): after the solve, the copper whose |J| is **below a
|
||||
threshold** (dialog-settable, default 10 % of the mean |J| over all
|
||||
solved copper) is outlined as **filled graphic polygons** on
|
||||
threshold** is outlined as **filled graphic polygons** on user
|
||||
layers. The threshold is dialog-settable in one of two units
|
||||
(selector next to the field): **relative** — % of the mean |J| over
|
||||
all solved copper (default, 10 %) — or **absolute** in **A/mm²**;
|
||||
since |J| scales with the test current, the absolute variant is
|
||||
meant to be used with the real operating current entered as test
|
||||
current. Polygons land on
|
||||
`User.5`…`User.8` (`TRIM_LAYERS` in `fill_resistance/config.py`;
|
||||
enable them in Board Setup), copper layers mapped in stackup order,
|
||||
top first. Marked specks under `TRIM_MIN_AREA_MM2` (0.5 mm²) are
|
||||
|
||||
@@ -102,10 +102,17 @@ TRIM_ENABLED = False # dialog default: mark the copper below
|
||||
# cut list: copper carries little current
|
||||
# BECAUSE the rest carries it - removal
|
||||
# redistributes |J|, re-run after changes
|
||||
TRIM_THRESHOLD_PCT = 10.0 # threshold as % of the mean |J| over all
|
||||
# solved copper cells (mean, not max:
|
||||
# contact-corner spikes would dwarf a
|
||||
# max-relative threshold); dialog-settable
|
||||
TRIM_MODE = "pct" # dialog default for the threshold unit:
|
||||
# "pct" (% of the mean |J|) or "abs"
|
||||
# (A/mm2)
|
||||
TRIM_THRESHOLD_PCT = 10.0 # relative threshold: % of the mean |J|
|
||||
# over all solved copper cells (mean, not
|
||||
# max: contact-corner spikes would dwarf
|
||||
# a max-relative threshold)
|
||||
TRIM_THRESHOLD_A_MM2 = 1.0 # absolute threshold [A/mm2]. |J| scales
|
||||
# with the test current, so this is only
|
||||
# meaningful with the real operating
|
||||
# current entered as test current
|
||||
TRIM_LAYERS = ("User.5", "User.6", "User.7", "User.8")
|
||||
# copper layers map here in stackup order
|
||||
# (top first); existing polygons on these
|
||||
|
||||
+42
-12
@@ -9,9 +9,9 @@ from dataclasses import dataclass
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import (QApplication, QCheckBox, QComboBox, QDialog,
|
||||
QDialogButtonBox, QFormLayout, QLabel,
|
||||
QLineEdit, QListWidget, QListWidgetItem,
|
||||
QVBoxLayout)
|
||||
QDialogButtonBox, QFormLayout, QHBoxLayout,
|
||||
QLabel, QLineEdit, QListWidget,
|
||||
QListWidgetItem, QVBoxLayout, QWidget)
|
||||
|
||||
from . import config, skin
|
||||
|
||||
@@ -41,7 +41,8 @@ class Selection:
|
||||
adaptive: bool = True
|
||||
push_overlays: bool = False # EXPERIMENTAL in-KiCad |J| overlays
|
||||
trim_enabled: bool = False # EXPERIMENTAL low-current copper marking
|
||||
trim_pct: float = 10.0 # threshold as % of the mean |J|
|
||||
trim_mode: str = "pct" # "pct" (% of the mean |J|) or "abs" (A/mm2)
|
||||
trim_value: float = 10.0 # threshold in the unit trim_mode names
|
||||
|
||||
|
||||
class _Dialog(QDialog):
|
||||
@@ -140,10 +141,23 @@ class _Dialog(QDialog):
|
||||
self.trim_check.setChecked(config.TRIM_ENABLED)
|
||||
form.addRow("Low-current copper:", self.trim_check)
|
||||
|
||||
self.trim_edit = QLineEdit(f"{config.TRIM_THRESHOLD_PCT:g}")
|
||||
self.trim_edit.setEnabled(config.TRIM_ENABLED)
|
||||
self.trim_check.toggled.connect(self.trim_edit.setEnabled)
|
||||
form.addRow("Threshold [% of mean |J|]:", self.trim_edit)
|
||||
self.trim_mode_box = QComboBox()
|
||||
self.trim_mode_box.addItem("% of mean |J|", "pct")
|
||||
self.trim_mode_box.addItem("A/mm²", "abs")
|
||||
self.trim_mode_box.setCurrentIndex(1 if config.TRIM_MODE == "abs"
|
||||
else 0)
|
||||
self.trim_edit = QLineEdit(self._trim_default())
|
||||
for w in (self.trim_edit, self.trim_mode_box):
|
||||
w.setEnabled(config.TRIM_ENABLED)
|
||||
self.trim_check.toggled.connect(w.setEnabled)
|
||||
self.trim_mode_box.currentIndexChanged.connect(
|
||||
self._trim_mode_changed)
|
||||
trim_row = QWidget()
|
||||
trim_lay = QHBoxLayout(trim_row)
|
||||
trim_lay.setContentsMargins(0, 0, 0, 0)
|
||||
trim_lay.addWidget(self.trim_edit, 1)
|
||||
trim_lay.addWidget(self.trim_mode_box)
|
||||
form.addRow("Threshold:", trim_row)
|
||||
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||
buttons.accepted.connect(self._try_accept)
|
||||
@@ -172,6 +186,19 @@ class _Dialog(QDialog):
|
||||
self.net_box.currentTextChanged.connect(self._refresh)
|
||||
self._refresh()
|
||||
|
||||
def _trim_default(self, mode: str | None = None) -> str:
|
||||
mode = mode or self.trim_mode_box.currentData()
|
||||
return (f"{config.TRIM_THRESHOLD_A_MM2:g}" if mode == "abs"
|
||||
else f"{config.TRIM_THRESHOLD_PCT:g}")
|
||||
|
||||
def _trim_mode_changed(self):
|
||||
# swap in the new unit's default, but never clobber a number the
|
||||
# user typed themselves
|
||||
mode = self.trim_mode_box.currentData()
|
||||
other = "pct" if mode == "abs" else "abs"
|
||||
if self.trim_edit.text().strip() in ("", self._trim_default(other)):
|
||||
self.trim_edit.setText(self._trim_default(mode))
|
||||
|
||||
def _refresh(self):
|
||||
self.error_label.setVisible(False)
|
||||
net = self.net_box.currentText()
|
||||
@@ -242,12 +269,15 @@ class _Dialog(QDialog):
|
||||
extra_cu = number(self.extracu_edit, "Extra Cu")
|
||||
if extra_cu < 0:
|
||||
raise ValueError("Extra Cu must be ≥ 0 µm.")
|
||||
trim_pct = config.TRIM_THRESHOLD_PCT
|
||||
trim_mode = self.trim_mode_box.currentData()
|
||||
trim_value = float(self._trim_default(trim_mode))
|
||||
if self.trim_check.isChecked():
|
||||
trim_pct = number(self.trim_edit, "Trim threshold")
|
||||
if not 0 < trim_pct < 100:
|
||||
trim_value = number(self.trim_edit, "Trim threshold")
|
||||
if trim_mode == "pct" and not 0 < trim_value < 100:
|
||||
raise ValueError("Trim threshold must be between 0 and "
|
||||
"100 (% of the mean |J|).")
|
||||
if trim_mode == "abs" and trim_value <= 0:
|
||||
raise ValueError("Trim threshold must be > 0 A/mm².")
|
||||
cap_max_drill = config.CAP_MAX_DRILL_MM
|
||||
if self.capped_check.isChecked():
|
||||
cap_max_drill = number(self.cap_drill_edit, "Capped up to drill")
|
||||
@@ -274,7 +304,7 @@ class _Dialog(QDialog):
|
||||
adaptive=self.adaptive_check.isChecked(),
|
||||
push_overlays=self.overlay_check.isChecked(),
|
||||
trim_enabled=self.trim_check.isChecked(),
|
||||
trim_pct=trim_pct)
|
||||
trim_mode=trim_mode, trim_value=trim_value)
|
||||
|
||||
def _try_accept(self) -> None:
|
||||
try:
|
||||
|
||||
@@ -137,15 +137,19 @@ def main() -> None:
|
||||
def overlay_cb(stack, result):
|
||||
board_io.push_result_overlays(board, stack, result)
|
||||
trim_cb = None
|
||||
trim_pct = trim_abs = None
|
||||
if selection.trim_enabled:
|
||||
def trim_cb(tr):
|
||||
board_io.push_trim_polygons(board, tr)
|
||||
if selection.trim_mode == "abs":
|
||||
trim_abs = selection.trim_value
|
||||
else:
|
||||
trim_pct = selection.trim_value
|
||||
pipeline.run(problem, outdir, show=True, i_test=selection.current_a,
|
||||
freq_hz=selection.freq_hz,
|
||||
contact_model=selection.contact_model,
|
||||
overlay=overlay_cb,
|
||||
trim_pct=(selection.trim_pct if selection.trim_enabled
|
||||
else None),
|
||||
trim_pct=trim_pct, trim_abs=trim_abs,
|
||||
trim_push=trim_cb)
|
||||
except progress.Cancelled:
|
||||
print("cancelled") # user's own doing: no error figure
|
||||
|
||||
@@ -13,11 +13,13 @@ from .solver import Result
|
||||
def run(problem: Problem, outdir: Path | None, show: bool = True,
|
||||
i_test: float | None = None, freq_hz: float = 0.0,
|
||||
contact_model: str | None = None, overlay=None,
|
||||
trim_pct: float | None = None, trim_push=None) -> Result:
|
||||
trim_pct: float | None = None, trim_abs: float | None = None,
|
||||
trim_push=None) -> Result:
|
||||
"""overlay: optional callback(stack, result) run after the solve
|
||||
(EXPERIMENTAL in-KiCad overlays); its failures are non-fatal.
|
||||
trim_pct: mark copper below this % of the mean |J| (None = off):
|
||||
per-layer areas are printed, polygons saved to
|
||||
trim_pct / trim_abs: mark copper below this threshold (% of the
|
||||
mean |J| / absolute A/mm2; at most one, both None = off): per-layer
|
||||
areas are printed, polygons saved to
|
||||
<outdir>/low_current_copper.json and handed to trim_push, an
|
||||
optional callback(trim_result) that pushes them into the board
|
||||
(failures non-fatal)."""
|
||||
@@ -57,8 +59,8 @@ def run(problem: Problem, outdir: Path | None, show: bool = True,
|
||||
except Exception as e:
|
||||
print(f"overlay push failed: {e}")
|
||||
|
||||
if trim_pct is not None:
|
||||
tr = trim.compute(result, stack, trim_pct)
|
||||
if trim_pct is not None or trim_abs is not None:
|
||||
tr = trim.compute(result, stack, pct=trim_pct, abs_a_mm2=trim_abs)
|
||||
print(trim.summary_line(tr))
|
||||
if outdir is not None:
|
||||
trim.write_json(outdir, tr)
|
||||
|
||||
+34
-16
@@ -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 "
|
||||
|
||||
+39
-2
@@ -22,13 +22,31 @@ def test_low_current_mask_threshold():
|
||||
J = np.full((1, 4, 4), np.nan)
|
||||
J[0, :2, :] = 1.0 # 8 cells carrying little
|
||||
J[0, 2, :2] = 100.0 # 2 hot cells; mean = 20.8
|
||||
mask, thr = trim.low_current_mask(J, 10.0)
|
||||
mask, thr = trim.low_current_mask(J, pct=10.0)
|
||||
assert thr == pytest.approx(2.08)
|
||||
assert mask[0, :2, :].all()
|
||||
assert not mask[0, 2, :2].any()
|
||||
assert not mask[0, 3, :].any() # NaN = no copper, never marked
|
||||
|
||||
|
||||
def test_low_current_mask_absolute():
|
||||
J = np.full((1, 3, 3), np.nan)
|
||||
J[0, 0, :] = 0.5e6 # 0.5 A/mm2 in A/m2
|
||||
J[0, 1, :] = 2.0e6 # 2 A/mm2
|
||||
mask, thr = trim.low_current_mask(J, abs_a_mm2=1.0)
|
||||
assert thr == pytest.approx(1.0e6)
|
||||
assert mask[0, 0, :].all()
|
||||
assert not mask[0, 1, :].any()
|
||||
|
||||
|
||||
def test_low_current_mask_needs_exactly_one_threshold():
|
||||
J = np.ones((1, 2, 2))
|
||||
with pytest.raises(ValueError):
|
||||
trim.low_current_mask(J)
|
||||
with pytest.raises(ValueError):
|
||||
trim.low_current_mask(J, pct=10.0, abs_a_mm2=1.0)
|
||||
|
||||
|
||||
def test_mask_rectangle_polygon():
|
||||
m = np.zeros((20, 30), dtype=bool)
|
||||
m[5:15, 4:9] = True
|
||||
@@ -80,7 +98,8 @@ def test_compute_and_json(tmp_path):
|
||||
J[0, :, :5] = 0.01 # half of the top layer nearly dead
|
||||
J[1, :, :] = 10.0
|
||||
stack = _stack(names=["F.Cu", "B.Cu"], h_nm=1_000_000)
|
||||
tr = trim.compute(SimpleNamespace(Jmag=J), stack, 10.0)
|
||||
tr = trim.compute(SimpleNamespace(Jmag=J), stack, pct=10.0)
|
||||
assert tr.mode == "pct" and tr.value == 10.0
|
||||
assert [lt.layer for lt in tr.layers] == ["F.Cu", "B.Cu"]
|
||||
assert tr.layers[0].polygons and not tr.layers[1].polygons
|
||||
assert tr.layers[0].marked_mm2 == pytest.approx(50.0)
|
||||
@@ -94,6 +113,24 @@ def test_compute_and_json(tmp_path):
|
||||
ring = doc["layers"][0]["polygons"][0]["outline_mm"]
|
||||
assert all(0 <= x <= 5.5 and 0 <= y <= 10.0 for x, y in ring)
|
||||
assert "F.Cu" in trim.summary_line(tr)
|
||||
assert "% of mean" in trim.summary_line(tr)
|
||||
|
||||
|
||||
def test_compute_absolute_mode(tmp_path):
|
||||
J = np.full((1, 10, 10), np.nan)
|
||||
J[0, :, :] = 10.0e6 # 10 A/mm2
|
||||
J[0, :, :5] = 0.1e6 # 0.1 A/mm2: below 1 A/mm2
|
||||
stack = _stack(names=["F.Cu"], h_nm=1_000_000)
|
||||
tr = trim.compute(SimpleNamespace(Jmag=J), stack, abs_a_mm2=1.0)
|
||||
assert tr.mode == "abs" and tr.value == 1.0
|
||||
assert tr.threshold_a_mm2 == pytest.approx(1.0)
|
||||
assert tr.layers[0].marked_mm2 == pytest.approx(50.0)
|
||||
line = trim.summary_line(tr)
|
||||
assert "|J| < 1 A/mm2" in line and "% of mean" not in line
|
||||
doc = json.loads(trim.write_json(tmp_path, tr)
|
||||
.read_text(encoding="utf-8"))
|
||||
assert doc["threshold_mode"] == "absolute"
|
||||
assert doc["threshold_value"] == 1.0
|
||||
|
||||
|
||||
def test_trim_shape_proto():
|
||||
|
||||
Reference in New Issue
Block a user