Show a busy window while the solve runs

On OK the dialog closed and nothing appeared until the figures did,
which on a real board is minutes of looking like the plugin did
nothing. Put a small always-on-top window up for that stretch: the
stage now running, elapsed seconds, and Cancel.

Qt only repaints while the event loop runs and the solve owns the
thread, so the window pumps events itself - from inside the CG/AMG
iteration callback, which is where the time actually goes. That is
also where Cancel is noticed. The state is module-level because the
tick happens several frames deep in scipy/pyamg, and threading a
handle through those signatures for a progress bar is not worth it;
it stays inert until start(), so the standalone runner and the tests
are unaffected.

The window covers the figure work too, not just the solve: laying out
labels and writing four PNGs at full DPI is seconds on a real board -
10-15 of them on a large one - and closing before that left the same
silent gap one step later.
This commit is contained in:
2026-07-22 16:54:29 +07:00
parent d7c3089031
commit 979b69960f
7 changed files with 191 additions and 14 deletions
+4 -2
View File
@@ -34,7 +34,7 @@ import numpy as np
from scipy import sparse from scipy import sparse
from scipy.sparse import csgraph from scipy.sparse import csgraph
from . import config, quadtree, skin from . import config, progress, quadtree, skin
from . import solver as sv from . import solver as sv
from .errors import ConnectivityError from .errors import ConnectivityError
from .geometry import Problem from .geometry import Problem
@@ -300,9 +300,11 @@ def run_solve_adaptive(problem: Problem, stack: RasterStack,
corr = np.zeros(len(edges.a)) corr = np.zeros(len(edges.a))
faces = e_axis >= 0 faces = e_axis >= 0
fa, fb = edges.a[faces], edges.b[faces] 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(): if not faces.any():
break break
progress.stage(f"correction pass {p + 1}/{passes} ...")
gx, gy = _leaf_gradients(N, fa, fb, cxg, cyg, Vflat) gx, gy = _leaf_gradients(N, fa, fb, cxg, cyg, Vflat)
gt = np.where(e_axis[faces] == 0, 0.5 * (gy[fa] + gy[fb]), gt = np.where(e_axis[faces] == 0, 0.5 * (gy[fa] + gy[fb]),
0.5 * (gx[fa] + gx[fb])) 0.5 * (gx[fa] + gx[fb]))
+8 -1
View File
@@ -12,7 +12,7 @@ from __future__ import annotations
import sys import sys
import traceback import traceback
from . import config, pipeline, report from . import config, pipeline, progress, report
from .errors import CandidateError, UserFacingError from .errors import CandidateError, UserFacingError
@@ -89,6 +89,9 @@ def main() -> None:
if selection is None: if selection is None:
print("cancelled") print("cancelled")
return 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": if selection.contact1 != "auto":
for e in es1: for e in es1:
@@ -122,10 +125,14 @@ def main() -> None:
freq_hz=selection.freq_hz, freq_hz=selection.freq_hz,
contact_model=selection.contact_model, contact_model=selection.contact_model,
overlay=overlay_cb) overlay=overlay_cb)
except progress.Cancelled:
print("cancelled") # user's own doing: no error figure
except UserFacingError as e: except UserFacingError as e:
_fail(str(e), outdir) _fail(str(e), outdir)
except Exception: except Exception:
_fail(traceback.format_exc(), outdir) _fail(traceback.format_exc(), outdir)
finally:
progress.done() # also on the error paths
if __name__ == "__main__": if __name__ == "__main__":
+7 -6
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from . import config, plots, raster, report, solver from . import config, plots, progress, raster, report, solver
from .errors import UserFacingError from .errors import UserFacingError
from .geometry import Problem from .geometry import Problem
from .solver import Result from .solver import Result
@@ -20,8 +20,8 @@ def run(problem: Problem, outdir: Path | None, show: bool = True,
if i_test <= 0: if i_test <= 0:
raise UserFacingError(f"Test current must be > 0 A (got {i_test:g}).") raise UserFacingError(f"Test current must be > 0 A (got {i_test:g}).")
h = raster.choose_cell_size(problem.copper_bbox(), len(problem.layers)) h = raster.choose_cell_size(problem.copper_bbox(), len(problem.layers))
print(f"rasterizing {len(problem.layers)} layer(s) at cell size " progress.stage(f"rasterizing {len(problem.layers)} layer(s) at cell "
f"{h / 1000:.1f} um ...") f"size {h / 1000:.1f} um ...")
stack = raster.rasterize_stack(problem, h) stack = raster.rasterize_stack(problem, h)
print(f"grid {stack.shape2d[1]}x{stack.shape2d[0]}x{stack.nlayers}, " print(f"grid {stack.shape2d[1]}x{stack.shape2d[0]}x{stack.nlayers}, "
f"{int(stack.masks.sum())} copper cells, {len(problem.vias)} " 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) e1, e2 = raster.electrode_masks(stack, problem)
parts1, parts2 = raster.electrode_partition(stack, problem) parts1, parts2 = raster.electrode_partition(stack, problem)
print(f"solving @ {i_test:g} A" progress.stage(f"solving @ {i_test:g} A"
+ (f", {freq_hz:g} Hz" if freq_hz > 0 else " DC") + " ...") + (f", {freq_hz:g} Hz" if freq_hz > 0 else " DC") + " ...")
result = solver.run_solve(problem, stack, e1, e2, i_test, freq_hz, result = solver.run_solve(problem, stack, e1, e2, i_test, freq_hz,
contact_model, parts1, parts2) contact_model, parts1, parts2)
for prefix, pcs in (("P", result.part_currents1), 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: except Exception as e:
print(f"overlay push failed: {e}") print(f"overlay push failed: {e}")
progress.stage("rendering figures ...")
figs = [ figs = [
(plots.fig_raster(stack, e1, e2, problem, result), "1_raster_map"), (plots.fig_raster(stack, e1, e2, problem, result), "1_raster_map"),
(plots.fig_potential(result, stack, e1, e2, problem), "2_potential"), (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"), "3_current_density"),
(plots.fig_power(result, stack, e1, e2, problem), "4_power_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 return result
+10 -1
View File
@@ -43,7 +43,7 @@ from matplotlib.gridspec import GridSpec # noqa: E402
from matplotlib.patches import Patch # noqa: E402 from matplotlib.patches import Patch # noqa: E402
from matplotlib.widgets import CheckButtons # noqa: E402 from matplotlib.widgets import CheckButtons # noqa: E402
from . import config # noqa: E402 from . import config, progress # noqa: E402
_BG = "#f5f3f0" _BG = "#f5f3f0"
_COPPER = "#c98b4e" _COPPER = "#c98b4e"
@@ -514,11 +514,15 @@ def save_and_show(figs_named: list[tuple], outdir: Path | None,
show: bool = True) -> list[Path]: show: bool = True) -> list[Path]:
"""figs_named: [(figure, basename), ...]. Saves first, then shows.""" """figs_named: [(figure, basename), ...]. Saves first, then shows."""
saved = [] saved = []
progress.stage("laying out figures ...", echo=False)
for fig, _ in figs_named: for fig, _ in figs_named:
_resolve_label_overlaps(fig) _resolve_label_overlaps(fig)
if outdir is not None: if outdir is not None:
outdir.mkdir(parents=True, exist_ok=True) outdir.mkdir(parents=True, exist_ok=True)
for fig, name in figs_named: 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) panel = getattr(fig, "_layer_panel", None)
if panel is not None: if panel is not None:
panel.set_visible(False) # PNGs carry no checkboxes 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}") print(f"saved {p}")
if show and config.INTERACTIVE: if show and config.INTERACTIVE:
if INTERACTIVE_BACKEND: if INTERACTIVE_BACKEND:
progress.stage("opening the figure windows ...", echo=False)
for fig, _ in figs_named: for fig, _ in figs_named:
_fit_to_screen(fig) _fit_to_screen(fig)
progress.done() # last thing before the figures are up
_raise_windows() _raise_windows()
plt.show() plt.show()
else: else:
progress.done()
for p in saved: for p in saved:
_open_in_viewer(p) _open_in_viewer(p)
else:
progress.done()
plt.close("all") plt.close("all")
return saved return saved
+145
View File
@@ -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
+6 -3
View File
@@ -45,7 +45,7 @@ from scipy import sparse
from scipy.sparse import csgraph from scipy.sparse import csgraph
from scipy.sparse import linalg as sla from scipy.sparse import linalg as sla
from . import config, skin from . import config, progress, skin
from .errors import ConnectivityError, ElectrodeError, SolverError from .errors import ConnectivityError, ElectrodeError, SolverError
from .geometry import Problem, slot_distance from .geometry import Problem, slot_distance
from .raster import RasterStack, electrodes_touch from .raster import RasterStack, electrodes_touch
@@ -375,12 +375,14 @@ class PreparedSolver:
def solve(self, b: np.ndarray) -> tuple[np.ndarray, SolveInfo]: def solve(self, b: np.ndarray) -> tuple[np.ndarray, SolveInfo]:
if self._lu is not None: if self._lu is not None:
progress.tick() # direct solve: one shot, no iterations
return self._lu.solve(b), SolveInfo(method="spsolve", return self._lu.solve(b), SolveInfo(method="spsolve",
n_unknowns=self.n) n_unknowns=self.n)
if self._ml is not None: if self._ml is not None:
residuals: list[float] = [] residuals: list[float] = []
x = self._ml.solve(b, tol=config.AMG_TOL, maxiter=300, 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) res = float(np.linalg.norm(b - self._A @ x)
/ max(np.linalg.norm(b), 1e-300)) / max(np.linalg.norm(b), 1e-300))
if not np.isfinite(res) or res > 1e-6: 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) ml = pyamg.smoothed_aggregation_solver(A.tocsr(), max_coarse=500)
residuals: list[float] = [] residuals: list[float] = []
x = ml.solve(b, tol=config.AMG_TOL, maxiter=300, accel="cg", 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)) res = float(np.linalg.norm(b - A @ x) / max(np.linalg.norm(b), 1e-300))
if not np.isfinite(res) or res > 1e-6: if not np.isfinite(res) or res > 1e-6:
raise SolverError( raise SolverError(
@@ -428,6 +430,7 @@ def _solve_cg_jacobi(A: sparse.csr_matrix, b: np.ndarray) -> tuple[np.ndarray, S
def count(_): def count(_):
nonlocal iters nonlocal iters
iters += 1 iters += 1
progress.tick()
try: try:
x, code = sla.cg(A, b, M=M, rtol=config.CG_TOL, x, code = sla.cg(A, b, M=M, rtol=config.CG_TOL,
+11 -1
View File
@@ -13,7 +13,7 @@ import argparse
import sys import sys
from pathlib import Path from pathlib import Path
from . import config, pipeline from . import config, pipeline, progress
from .errors import UserFacingError from .errors import UserFacingError
from .geometry import load_problem from .geometry import load_problem
from .skin import parse_frequency from .skin import parse_frequency
@@ -51,6 +51,9 @@ def main(argv=None) -> int:
ap.add_argument("--force-iterative", action="store_true", ap.add_argument("--force-iterative", action="store_true",
help="use the iterative solver (AMG-CG, or Jacobi-CG " help="use the iterative solver (AMG-CG, or Jacobi-CG "
"without pyamg) regardless of problem size") "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, ap.add_argument("--adaptive", action=argparse.BooleanOptionalAction,
default=None, default=None,
help="adaptive quadtree grid (coarse plane interiors); " help="adaptive quadtree grid (coarse plane interiors); "
@@ -86,13 +89,20 @@ def main(argv=None) -> int:
return 1 return 1
outdir = args.out if args.out is not None else args.dump.parent outdir = args.out if args.out is not None else args.dump.parent
if args.progress:
progress.start()
try: try:
pipeline.run(problem, outdir, show=not args.no_show, pipeline.run(problem, outdir, show=not args.no_show,
i_test=args.current, freq_hz=args.freq, i_test=args.current, freq_hz=args.freq,
contact_model=args.contact_model) contact_model=args.contact_model)
except progress.Cancelled:
print("cancelled")
return 1
except UserFacingError as e: except UserFacingError as e:
print(f"ERROR: {e}", file=sys.stderr) print(f"ERROR: {e}", file=sys.stderr)
return 1 return 1
finally:
progress.done()
return 0 return 0