diff --git a/README.md b/README.md index fa9e618..cde2415 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/fill_resistance/config.py b/fill_resistance/config.py index c5e5b1f..3970f8b 100644 --- a/fill_resistance/config.py +++ b/fill_resistance/config.py @@ -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 diff --git a/fill_resistance/dialog.py b/fill_resistance/dialog.py index e732310..2afdfa7 100644 --- a/fill_resistance/dialog.py +++ b/fill_resistance/dialog.py @@ -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: diff --git a/fill_resistance/main.py b/fill_resistance/main.py index af9d02a..9f96bbd 100644 --- a/fill_resistance/main.py +++ b/fill_resistance/main.py @@ -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 diff --git a/fill_resistance/pipeline.py b/fill_resistance/pipeline.py index a6228e9..1c4d011 100644 --- a/fill_resistance/pipeline.py +++ b/fill_resistance/pipeline.py @@ -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 /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) diff --git a/fill_resistance/trim.py b/fill_resistance/trim.py index 5c3816e..70e6bf9 100644 --- a/fill_resistance/trim.py +++ b/fill_resistance/trim.py @@ -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 " diff --git a/tests/test_trim.py b/tests/test_trim.py index c182cd5..581a500 100644 --- a/tests/test_trim.py +++ b/tests/test_trim.py @@ -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():