Fix the two macOS field-test failures: backend order, RO board dir
Build PCM package / build (push) Successful in 5s

Second Mac run (KiCad demo board opened from the mounted installer
image) got past the dialog and died twice:

1. make_output_dir crashed with OSError 30: the demos volume is a
   read-only filesystem. Now falls back to a temp directory named
   after the board, with the path printed (the Messages panel showed
   it is actually read there).

2. The error figure - and every other figure - could never render:
   matplotlib refused with Cannot load backend TkAgg ... as qt is
   currently running. macOS bundled Python ships tkinter, so the
   old tk-first probe picked TkAgg while the PySide6 dialog and
   progress window had already made this a Qt process. Windows never
   saw it because KiCad Python there has no tkinter. Qt now comes
   first - PySide6 is a hard dependency, so it is always there.

Both covered by tests that fail on the old code: the dev env has both
toolkits installed, so the backend-order assertion is exercised for
real, and the RO fallback is simulated by denying mkdir under the
board dir. 140 passed on the dev stack and on the Python 3.9 +
numpy 2.0 / scipy 1.13 / matplotlib 3.9 / PySide6 6.10 stack that a
Mac venv resolves.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
janik
2026-07-23 13:29:22 +07:00
parent 346016ba8f
commit 23edb39f52
4 changed files with 78 additions and 12 deletions
+4 -1
View File
@@ -123,7 +123,10 @@ notes* below.
multi-layer pours at fine cell sizes may run for minutes (on our multi-layer pours at fine cell sizes may run for minutes (on our
test setup a typical real-board run finishes in ≈ 8 s). Then read test setup a typical real-board run finishes in ≈ 8 s). Then read
R / voltage drop / total power in the figure titles and status R / voltage drop / total power in the figure titles and status
bar. Outputs land in `<board dir>/fill_res_results/<timestamp>/`: bar. Outputs land in `<board dir>/fill_res_results/<timestamp>/`
(if the board directory is not writable — e.g. a demo project opened
straight from the mounted installer image — a temp directory is used
instead and its path printed to the Messages panel):
per-layer `1_raster_map` / `2_potential` / `3_current_density` / per-layer `1_raster_map` / `2_potential` / `3_current_density` /
`4_power_density` PNGs, `summary.txt` (incl. the busiest vias with `4_power_density` PNGs, `summary.txt` (incl. the busiest vias with
per-via current and dissipation, and the **current through each per-via current and dissipation, and the **current through each
+14 -9
View File
@@ -1,8 +1,9 @@
"""Figures: per-layer rasterized maps, potential, current density, power """Figures: per-layer rasterized maps, potential, current density, power
density, and the error figure. PNGs are saved BEFORE any window opens. density, and the error figure. PNGs are saved BEFORE any window opens.
Backend: interactive if a GUI toolkit exists (tkinter, else Qt), else Agg Backend: interactive if a GUI toolkit exists (Qt first, tkinter as a
with os.startfile on the saved PNGs so results are never silent. fallback), else Agg with the OS default viewer on the saved PNGs so
results are never silent.
""" """
from __future__ import annotations from __future__ import annotations
@@ -18,19 +19,23 @@ import numpy as np
def _pick_backend(): def _pick_backend():
"""matplotlib.use() is lazy and 'succeeds' for backends whose GUI """matplotlib.use() is lazy and 'succeeds' for backends whose GUI
toolkit is missing (KiCad's Python has no tkinter), so probe the toolkit is missing (KiCad's Windows Python has no tkinter), so probe
toolkits explicitly.""" the toolkits explicitly. Qt MUST come first: PySide6 is a hard
try: dependency and the selection dialog / progress window put a Qt event
import tkinter # noqa: F401 loop in this process, after which matplotlib refuses TkAgg
return "TkAgg" ("Cannot load backend 'TkAgg' ... as 'qt' is currently running") -
except Exception: exactly what happened on macOS, whose bundled Python ships tkinter."""
pass
for qt in ("PySide6", "PyQt6", "PyQt5", "PySide2"): for qt in ("PySide6", "PyQt6", "PyQt5", "PySide2"):
try: try:
__import__(qt) __import__(qt)
return "QtAgg" if qt in ("PySide6", "PyQt6") else "Qt5Agg" return "QtAgg" if qt in ("PySide6", "PyQt6") else "Qt5Agg"
except Exception: except Exception:
continue continue
try:
import tkinter # noqa: F401
return "TkAgg"
except Exception:
pass
return None return None
+14 -1
View File
@@ -1,6 +1,7 @@
"""Output directory, summary.txt, geometry dump, stdout one-liner.""" """Output directory, summary.txt, geometry dump, stdout one-liner."""
from __future__ import annotations from __future__ import annotations
import tempfile
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@@ -14,7 +15,19 @@ from .solver import Result
def make_output_dir(board_dir: Path) -> Path: def make_output_dir(board_dir: Path) -> Path:
stamp = datetime.now().strftime("%Y%m%d-%H%M%S") stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
out = Path(board_dir) / config.OUTPUT_DIRNAME / stamp board_dir = Path(board_dir)
out = board_dir / config.OUTPUT_DIRNAME / stamp
try:
out.mkdir(parents=True, exist_ok=True)
except OSError as e:
# The board can live somewhere unwritable - e.g. the demos
# folder on the mounted KiCad installer image (read-only, and
# how the first macOS field test was run). Results still have
# to land somewhere the figures/summary can be written.
out = (Path(tempfile.gettempdir()) / config.OUTPUT_DIRNAME
/ f"{board_dir.name}-{stamp}")
print(f"board directory not writable ({e}); saving results to "
f"{out}")
out.mkdir(parents=True, exist_ok=True) out.mkdir(parents=True, exist_ok=True)
return out return out
+45
View File
@@ -0,0 +1,45 @@
"""Regressions from the first macOS field test.
Two failures that only a non-Windows KiCad could produce: the board
directory was read-only (demo project opened straight from the mounted
installer image), and matplotlib picked TkAgg - macOS' bundled Python
ships tkinter, unlike KiCad's Windows Python - then refused to create
any figure because the PySide6 dialog already had a Qt event loop in
the process.
"""
import pathlib
from fill_resistance import plots, report
def test_backend_prefers_qt_over_tk():
# The dev environment has both toolkits installed, so this asserts
# the preference order for real: Qt must win, because the selection
# dialog / progress window make the process a Qt process before the
# first figure exists.
assert plots._pick_backend() in ("QtAgg", "Qt5Agg")
def test_output_dir_falls_back_when_board_dir_unwritable(
tmp_path, monkeypatch, capsys):
board_dir = tmp_path / "board"
board_dir.mkdir()
real_mkdir = pathlib.Path.mkdir
def deny_under_board(self, *args, **kwargs):
if str(self).startswith(str(board_dir)):
raise OSError(30, "Read-only file system", str(self))
return real_mkdir(self, *args, **kwargs)
monkeypatch.setattr(pathlib.Path, "mkdir", deny_under_board)
out = report.make_output_dir(board_dir)
assert out.is_dir()
assert not str(out).startswith(str(board_dir))
assert board_dir.name in out.name # traceable back to the board
assert "not writable" in capsys.readouterr().out
def test_output_dir_normal_case_unchanged(tmp_path):
out = report.make_output_dir(tmp_path)
assert out.is_dir()
assert out.parent.parent == tmp_path