diff --git a/fill_resistance/adaptive.py b/fill_resistance/adaptive.py index 22d1cfd..e6011f7 100644 --- a/fill_resistance/adaptive.py +++ b/fill_resistance/adaptive.py @@ -34,7 +34,7 @@ import numpy as np from scipy import sparse from scipy.sparse import csgraph -from . import config, quadtree, skin +from . import config, progress, quadtree, skin from . import solver as sv from .errors import ConnectivityError from .geometry import Problem @@ -300,9 +300,11 @@ def run_solve_adaptive(problem: Problem, stack: RasterStack, corr = np.zeros(len(edges.a)) faces = e_axis >= 0 fa, fb = edges.a[faces], edges.b[faces] - for _ in range(max(0, int(config.ADAPTIVE_CORRECTION_PASSES))): + passes = max(0, int(config.ADAPTIVE_CORRECTION_PASSES)) + for p in range(passes): if not faces.any(): break + progress.stage(f"correction pass {p + 1}/{passes} ...") gx, gy = _leaf_gradients(N, fa, fb, cxg, cyg, Vflat) gt = np.where(e_axis[faces] == 0, 0.5 * (gy[fa] + gy[fb]), 0.5 * (gx[fa] + gx[fb])) diff --git a/fill_resistance/main.py b/fill_resistance/main.py index 7df40db..e702c8e 100644 --- a/fill_resistance/main.py +++ b/fill_resistance/main.py @@ -12,7 +12,7 @@ from __future__ import annotations import sys import traceback -from . import config, pipeline, report +from . import config, pipeline, progress, report from .errors import CandidateError, UserFacingError @@ -89,6 +89,9 @@ def main() -> None: if selection is None: print("cancelled") return + # the solve owns the thread from here; without this the plugin + # looks like it did nothing until the figures appear + progress.start() if selection.contact1 != "auto": for e in es1: @@ -122,10 +125,14 @@ def main() -> None: freq_hz=selection.freq_hz, contact_model=selection.contact_model, overlay=overlay_cb) + except progress.Cancelled: + print("cancelled") # user's own doing: no error figure except UserFacingError as e: _fail(str(e), outdir) except Exception: _fail(traceback.format_exc(), outdir) + finally: + progress.done() # also on the error paths if __name__ == "__main__": diff --git a/fill_resistance/pipeline.py b/fill_resistance/pipeline.py index fc0392d..45d07ad 100644 --- a/fill_resistance/pipeline.py +++ b/fill_resistance/pipeline.py @@ -4,7 +4,7 @@ from __future__ import annotations from pathlib import Path -from . import config, plots, raster, report, solver +from . import config, plots, progress, raster, report, solver from .errors import UserFacingError from .geometry import Problem from .solver import Result @@ -20,8 +20,8 @@ def run(problem: Problem, outdir: Path | None, show: bool = True, if i_test <= 0: raise UserFacingError(f"Test current must be > 0 A (got {i_test:g}).") h = raster.choose_cell_size(problem.copper_bbox(), len(problem.layers)) - print(f"rasterizing {len(problem.layers)} layer(s) at cell size " - f"{h / 1000:.1f} um ...") + progress.stage(f"rasterizing {len(problem.layers)} layer(s) at cell " + f"size {h / 1000:.1f} um ...") stack = raster.rasterize_stack(problem, h) print(f"grid {stack.shape2d[1]}x{stack.shape2d[0]}x{stack.nlayers}, " f"{int(stack.masks.sum())} copper cells, {len(problem.vias)} " @@ -30,8 +30,8 @@ def run(problem: Problem, outdir: Path | None, show: bool = True, e1, e2 = raster.electrode_masks(stack, problem) parts1, parts2 = raster.electrode_partition(stack, problem) - print(f"solving @ {i_test:g} A" - + (f", {freq_hz:g} Hz" if freq_hz > 0 else " DC") + " ...") + progress.stage(f"solving @ {i_test:g} A" + + (f", {freq_hz:g} Hz" if freq_hz > 0 else " DC") + " ...") result = solver.run_solve(problem, stack, e1, e2, i_test, freq_hz, contact_model, parts1, parts2) for prefix, pcs in (("P", result.part_currents1), @@ -51,6 +51,7 @@ def run(problem: Problem, outdir: Path | None, show: bool = True, except Exception as e: print(f"overlay push failed: {e}") + progress.stage("rendering figures ...") figs = [ (plots.fig_raster(stack, e1, e2, problem, result), "1_raster_map"), (plots.fig_potential(result, stack, e1, e2, problem), "2_potential"), @@ -58,5 +59,5 @@ def run(problem: Problem, outdir: Path | None, show: bool = True, "3_current_density"), (plots.fig_power(result, stack, e1, e2, problem), "4_power_density"), ] - plots.save_and_show(figs, outdir, show=show) + plots.save_and_show(figs, outdir, show=show) # closes the window itself return result diff --git a/fill_resistance/plots.py b/fill_resistance/plots.py index e643208..67cccae 100644 --- a/fill_resistance/plots.py +++ b/fill_resistance/plots.py @@ -43,7 +43,7 @@ from matplotlib.gridspec import GridSpec # noqa: E402 from matplotlib.patches import Patch # noqa: E402 from matplotlib.widgets import CheckButtons # noqa: E402 -from . import config # noqa: E402 +from . import config, progress # noqa: E402 _BG = "#f5f3f0" _COPPER = "#c98b4e" @@ -514,11 +514,15 @@ def save_and_show(figs_named: list[tuple], outdir: Path | None, show: bool = True) -> list[Path]: """figs_named: [(figure, basename), ...]. Saves first, then shows.""" saved = [] + progress.stage("laying out figures ...", echo=False) for fig, _ in figs_named: _resolve_label_overlaps(fig) if outdir is not None: outdir.mkdir(parents=True, exist_ok=True) for fig, name in figs_named: + # full-DPI savefig with tight bounding boxes is seconds per + # figure - the progress window has to stay up for it + progress.stage(f"saving {name}.png ...", echo=False) panel = getattr(fig, "_layer_panel", None) if panel is not None: panel.set_visible(False) # PNGs carry no checkboxes @@ -531,13 +535,18 @@ def save_and_show(figs_named: list[tuple], outdir: Path | None, print(f"saved {p}") if show and config.INTERACTIVE: if INTERACTIVE_BACKEND: + progress.stage("opening the figure windows ...", echo=False) for fig, _ in figs_named: _fit_to_screen(fig) + progress.done() # last thing before the figures are up _raise_windows() plt.show() else: + progress.done() for p in saved: _open_in_viewer(p) + else: + progress.done() plt.close("all") return saved diff --git a/fill_resistance/progress.py b/fill_resistance/progress.py new file mode 100644 index 0000000..6af805b --- /dev/null +++ b/fill_resistance/progress.py @@ -0,0 +1,145 @@ +"""Busy window for the stretch between the dialog closing and the +figures appearing. + +The solve is seconds to minutes on a real board, and until now nothing +was on screen for it: the dialog vanished on OK and the plugin looked +like it had done nothing. This puts a small always-on-top window up for +that stretch - current stage, elapsed time, and a Cancel button. + +The state is module-level rather than an object threaded through the +call chain: the linear solve is where the time actually goes, and it +calls tick() from inside a scipy/pyamg iteration callback several +frames deep. Inactive until start() succeeds, so every call is a no-op +for the standalone runner and the tests. + +Qt only repaints when the event loop runs, and the solve owns the +thread, so tick() pumps events itself. That is also where a click on +Cancel is noticed - it raises Cancelled at the next tick. +""" +from __future__ import annotations + +import time + +_win = None +_label = None +_text = "" +_t0 = 0.0 +_last = 0.0 +_cancelled = False + +TICK_INTERVAL_S = 0.05 # ~20 fps: enough to look alive, cheap + + +class Cancelled(Exception): + """The user closed the progress window. Not a failure - the caller + reports it like a cancelled dialog, with no error figure.""" + + +def start(title: str = "Fill Resistance") -> bool: + """Show the window. False (and inert) if Qt is unavailable.""" + global _win, _label, _t0, _last, _cancelled, _text + if _win is not None: + return True + try: + from PySide6.QtCore import Qt + from PySide6.QtWidgets import (QApplication, QDialog, + QDialogButtonBox, QLabel, + QProgressBar, QVBoxLayout) + except Exception: + return False + try: + app = QApplication.instance() or QApplication([]) + win = QDialog() + win.setWindowTitle(title) + win.setWindowFlag(Qt.WindowStaysOnTopHint, True) + # no close button: closing is Cancel, and Cancel is the only way + # to stop a solve that owns the thread + win.setWindowFlag(Qt.WindowCloseButtonHint, False) + + label = QLabel("starting ...") + bar = QProgressBar() + bar.setRange(0, 0) # indeterminate: no total to show + buttons = QDialogButtonBox(QDialogButtonBox.Cancel) + + layout = QVBoxLayout() + layout.addWidget(label) + layout.addWidget(bar) + layout.addWidget(buttons) + win.setLayout(layout) + + buttons.rejected.connect(_cancel) + win.rejected.connect(_cancel) + win.setMinimumWidth(340) + win.show() + win.raise_() + win.activateWindow() + app.processEvents() + except Exception: + return False + + _win, _label, _t0, _last, _cancelled, _text = win, label, \ + time.monotonic(), 0.0, False, "" + return True + + +def _cancel() -> None: + global _cancelled + _cancelled = True + + +def stage(text: str, echo: bool = True) -> None: + """Name the phase now running. Always repaints - stages are rare. + + echo=False for phases that already print their own line (saving a + PNG prints the path), so the window updates without doubling stdout. + """ + global _text + _text = text + if echo: + print(text) + if _win is not None: + _refresh() + + +def tick() -> None: + """Called from inside the solve. Throttled, so it is safe to call + every iteration.""" + global _last + if _win is None: + return + now = time.monotonic() + if now - _last < TICK_INTERVAL_S: + return + _last = now + _refresh() + + +def _refresh() -> None: + from PySide6.QtWidgets import QApplication + + elapsed = time.monotonic() - _t0 + if _label is not None: + _label.setText(f"{_text}\n{elapsed:.0f} s elapsed") + app = QApplication.instance() + if app is not None: + app.processEvents() + if _cancelled: + raise Cancelled() + + +def done() -> None: + """Take the window down. Idempotent - callers use it in a finally.""" + global _win, _label, _text, _cancelled + win, _win, _label, _text = _win, None, None, "" + _cancelled = False + if win is None: + return + try: + win.close() + win.deleteLater() + from PySide6.QtWidgets import QApplication + app = QApplication.instance() + if app is not None: + app.processEvents() + except Exception: + pass diff --git a/fill_resistance/solver.py b/fill_resistance/solver.py index b3030db..6e77508 100644 --- a/fill_resistance/solver.py +++ b/fill_resistance/solver.py @@ -45,7 +45,7 @@ from scipy import sparse from scipy.sparse import csgraph from scipy.sparse import linalg as sla -from . import config, skin +from . import config, progress, skin from .errors import ConnectivityError, ElectrodeError, SolverError from .geometry import Problem, slot_distance from .raster import RasterStack, electrodes_touch @@ -375,12 +375,14 @@ class PreparedSolver: def solve(self, b: np.ndarray) -> tuple[np.ndarray, SolveInfo]: if self._lu is not None: + progress.tick() # direct solve: one shot, no iterations return self._lu.solve(b), SolveInfo(method="spsolve", n_unknowns=self.n) if self._ml is not None: residuals: list[float] = [] x = self._ml.solve(b, tol=config.AMG_TOL, maxiter=300, - accel="cg", residuals=residuals) + accel="cg", residuals=residuals, + callback=lambda _: progress.tick()) res = float(np.linalg.norm(b - self._A @ x) / max(np.linalg.norm(b), 1e-300)) if not np.isfinite(res) or res > 1e-6: @@ -404,7 +406,7 @@ def _solve_amg(A: sparse.csr_matrix, b: np.ndarray) -> tuple[np.ndarray, SolveIn ml = pyamg.smoothed_aggregation_solver(A.tocsr(), max_coarse=500) residuals: list[float] = [] x = ml.solve(b, tol=config.AMG_TOL, maxiter=300, accel="cg", - residuals=residuals) + residuals=residuals, callback=lambda _: progress.tick()) res = float(np.linalg.norm(b - A @ x) / max(np.linalg.norm(b), 1e-300)) if not np.isfinite(res) or res > 1e-6: raise SolverError( @@ -428,6 +430,7 @@ def _solve_cg_jacobi(A: sparse.csr_matrix, b: np.ndarray) -> tuple[np.ndarray, S def count(_): nonlocal iters iters += 1 + progress.tick() try: x, code = sla.cg(A, b, M=M, rtol=config.CG_TOL, diff --git a/fill_resistance/standalone.py b/fill_resistance/standalone.py index 4c732bd..1c62452 100644 --- a/fill_resistance/standalone.py +++ b/fill_resistance/standalone.py @@ -13,7 +13,7 @@ import argparse import sys from pathlib import Path -from . import config, pipeline +from . import config, pipeline, progress from .errors import UserFacingError from .geometry import load_problem from .skin import parse_frequency @@ -51,6 +51,9 @@ def main(argv=None) -> int: ap.add_argument("--force-iterative", action="store_true", help="use the iterative solver (AMG-CG, or Jacobi-CG " "without pyamg) regardless of problem size") + ap.add_argument("--progress", action="store_true", + help="show the busy window during the solve, as the " + "KiCad plugin does (needs a GUI)") ap.add_argument("--adaptive", action=argparse.BooleanOptionalAction, default=None, help="adaptive quadtree grid (coarse plane interiors); " @@ -86,13 +89,20 @@ def main(argv=None) -> int: return 1 outdir = args.out if args.out is not None else args.dump.parent + if args.progress: + progress.start() try: pipeline.run(problem, outdir, show=not args.no_show, i_test=args.current, freq_hz=args.freq, contact_model=args.contact_model) + except progress.Cancelled: + print("cancelled") + return 1 except UserFacingError as e: print(f"ERROR: {e}", file=sys.stderr) return 1 + finally: + progress.done() return 0