Compare commits
8
Commits
b806d31a9a
...
v1.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24fed64f83 | ||
|
|
23edb39f52 | ||
|
|
346016ba8f | ||
|
|
f9abc06082 | ||
|
|
3c90f96a63 | ||
|
|
d05d523995 | ||
|
|
24a77da491 | ||
|
|
979b69960f |
@@ -30,14 +30,28 @@ SWIG API. Requires KiCad **10.0.1+**.
|
|||||||
|
|
||||||
## Setup (one-time)
|
## Setup (one-time)
|
||||||
|
|
||||||
|
The plugin is developed and tested on **Windows**; **macOS works**
|
||||||
|
(field-tested on KiCad 10 after a round of mac-specific fixes).
|
||||||
|
**Linux is expected to work but is untested so far** — the code and
|
||||||
|
the dependency stack have been audited (KiCad builds the plugin a
|
||||||
|
private Python venv from `requirements.txt` on every platform, from
|
||||||
|
pre-built wheels only, no compiler needed), but nobody has run the
|
||||||
|
plugin there yet. Reports welcome! Steps 1–4 are the same everywhere;
|
||||||
|
OS specifics are spelled out per step and in *Platform notes* below.
|
||||||
|
|
||||||
1. **Enable the API server**: KiCad → Preferences → Plugins → check
|
1. **Enable the API server**: KiCad → Preferences → Plugins → check
|
||||||
*Enable KiCad API*.
|
*Enable KiCad API*.
|
||||||
2. **Check the interpreter path** on the same page: should point at the
|
2. **Check the interpreter path** on the same page (after a 9→10
|
||||||
KiCad 10 Python, e.g. `C:\Program Files\KiCad\10.0\bin\pythonw.exe`
|
upgrade it can still point at KiCad 9):
|
||||||
on Windows or `/usr/bin/python3` on Linux (after a 9→10 upgrade it
|
- **Windows**: KiCad's own Python,
|
||||||
can point at KiCad 9).
|
`C:\Program Files\KiCad\10.0\bin\pythonw.exe`;
|
||||||
|
- **macOS**: the Python bundled inside the app,
|
||||||
|
`/Applications/KiCad/KiCad.app/Contents/Frameworks/Python.framework/Versions/Current/bin/python3`;
|
||||||
|
- **Linux**: the first `python3` on `PATH` — needs Python ≥ 3.9
|
||||||
|
with the `venv` module (Debian/Ubuntu:
|
||||||
|
`sudo apt install python3-venv`).
|
||||||
3. **Deploy** (dev checkout; end users install the PCM zip instead, see
|
3. **Deploy** (dev checkout; end users install the PCM zip instead, see
|
||||||
*Packaging / publishing*):
|
*Packaging / publishing*). Windows:
|
||||||
```powershell
|
```powershell
|
||||||
powershell -ExecutionPolicy Bypass -File deploy.ps1 # junction (dev)
|
powershell -ExecutionPolicy Bypass -File deploy.ps1 # junction (dev)
|
||||||
powershell -ExecutionPolicy Bypass -File deploy.ps1 -Mode Copy
|
powershell -ExecutionPolicy Bypass -File deploy.ps1 -Mode Copy
|
||||||
@@ -47,14 +61,37 @@ SWIG API. Requires KiCad **10.0.1+**.
|
|||||||
python3 tools/deploy.py # symlink (dev)
|
python3 tools/deploy.py # symlink (dev)
|
||||||
python3 tools/deploy.py --copy
|
python3 tools/deploy.py --copy
|
||||||
```
|
```
|
||||||
|
Plugin directory: `Documents/KiCad/10.0/plugins` on Windows and
|
||||||
|
macOS, `~/.local/share/kicad/10.0/plugins` on Linux.
|
||||||
4. **Restart KiCad**; first load builds the plugin venv (numpy, scipy,
|
4. **Restart KiCad**; first load builds the plugin venv (numpy, scipy,
|
||||||
matplotlib, PySide6 — takes minutes; the Ω button appears when done).
|
matplotlib, PySide6 — takes minutes; the Ω button appears when done).
|
||||||
If stuck: in the PCB editor, Preferences → *PCB Editor → Action
|
If stuck: in the PCB editor, Preferences → *PCB Editor → Action
|
||||||
Plugins*, **right-click** the plugin's row → *Recreate Plugin
|
Plugins*, **right-click** the plugin's row → *Recreate Plugin
|
||||||
Environment* (context menu only — there is no button). Manual
|
Environment* (context menu only — there is no button). Manual
|
||||||
equivalent: delete
|
equivalent: delete the plugin's venv and restart KiCad —
|
||||||
`%LOCALAPPDATA%\kicad\10.0\python-environments\th.co.b4l.fill-resistance`
|
- Windows: `%LOCALAPPDATA%\kicad\10.0\python-environments\th.co.b4l.fill-resistance`
|
||||||
and restart KiCad.
|
- macOS: `~/Library/Caches/kicad/10.0/python-environments/th.co.b4l.fill-resistance`
|
||||||
|
- Linux: `~/.cache/kicad/10.0/python-environments/th.co.b4l.fill-resistance`
|
||||||
|
|
||||||
|
### Platform notes
|
||||||
|
|
||||||
|
- **Windows** is the development and test platform — everything in
|
||||||
|
this README was exercised here. KiCad's bundled Python is 3.13, so
|
||||||
|
the venv gets the current dependency stack.
|
||||||
|
- **macOS** — **works** (field-tested on KiCad 10). Requires
|
||||||
|
macOS 12+ (KiCad's own minimum; Intel and Apple Silicon — the dmg
|
||||||
|
is universal). KiCad's bundled Python is **3.9**, so pip resolves
|
||||||
|
an older stack (numpy 2.0, scipy 1.13, matplotlib 3.9,
|
||||||
|
PySide6 6.9/6.10); the plugin code is kept 3.9-compatible (guarded
|
||||||
|
by a test) and the suite is also run against that older stack.
|
||||||
|
Plot and dialog windows may open **behind** the KiCad window (they
|
||||||
|
are raised best-effort) — check the Dock if nothing seems to appear
|
||||||
|
after a solve.
|
||||||
|
- **Linux** — **untested** (audited only, same caveat). The venv uses
|
||||||
|
the system Python (3.9+), so the stack matches your distribution.
|
||||||
|
On **ARM64 (aarch64)** there are no pyamg wheels —
|
||||||
|
`requirements.txt` skips pyamg there and the solver falls back to
|
||||||
|
Jacobi-CG: same results, noticeably slower on large grids.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@@ -86,7 +123,10 @@ SWIG API. Requires KiCad **10.0.1+**.
|
|||||||
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
|
||||||
@@ -292,9 +332,9 @@ accordingly more trustworthy than absolute numbers.
|
|||||||
|
|
||||||
Every run writes `geometry_dump.json`; re-solve without KiCad:
|
Every run writes `geometry_dump.json`; re-solve without KiCad:
|
||||||
|
|
||||||
```powershell
|
```sh
|
||||||
uv run python -m fill_resistance.standalone dump.json `
|
uv run python -m fill_resistance.standalone dump.json
|
||||||
[--current 40] [--cell-um 50] [--layers F.Cu,In1.Cu] [--no-show] `
|
[--current 40] [--cell-um 50] [--layers F.Cu,In1.Cu] [--no-show]
|
||||||
[--out DIR] [--force-iterative]
|
[--out DIR] [--force-iterative]
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -302,7 +342,7 @@ Dev environment, tests, headless extraction — [uv](https://docs.astral.sh/uv/)
|
|||||||
manages the venv from `pyproject.toml`/`uv.lock` (`requirements.txt`
|
manages the venv from `pyproject.toml`/`uv.lock` (`requirements.txt`
|
||||||
stays: KiCad builds the plugin's runtime venv from it):
|
stays: KiCad builds the plugin's runtime venv from it):
|
||||||
|
|
||||||
```powershell
|
```sh
|
||||||
uv sync # one-time env setup
|
uv sync # one-time env setup
|
||||||
uv run pytest -q # incl. exact analytic cases
|
uv run pytest -q # incl. exact analytic cases
|
||||||
uv run python tools/api_probe.py # IPC API probe vs live KiCad
|
uv run python tools/api_probe.py # IPC API probe vs live KiCad
|
||||||
@@ -332,7 +372,8 @@ GPL-3.0-or-later — see [LICENSE](LICENSE).
|
|||||||
- **No toolbar button**: venv still building (wait), or build failed →
|
- **No toolbar button**: venv still building (wait), or build failed →
|
||||||
*Recreate Plugin Environment* (right-click the plugin's row in
|
*Recreate Plugin Environment* (right-click the plugin's row in
|
||||||
Preferences → *PCB Editor → Action Plugins*); check the interpreter
|
Preferences → *PCB Editor → Action Plugins*); check the interpreter
|
||||||
path (setup 2).
|
path (setup 2); on Linux make sure `python3-venv` is installed. Last
|
||||||
|
resort: delete the venv directory by hand (setup 4) and restart.
|
||||||
- **"Could not connect to KiCad's IPC API"**: API server not enabled, or
|
- **"Could not connect to KiCad's IPC API"**: API server not enabled, or
|
||||||
KiCad not running (no headless mode in KiCad 10).
|
KiCad not running (no headless mode in KiCad 10).
|
||||||
- **"KiCad is busy"**: a modal dialog is open in KiCad — close it, rerun.
|
- **"KiCad is busy"**: a modal dialog is open in KiCad — close it, rerun.
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
Results are unchanged from 1.2.1 for the same board and settings. This
|
||||||
|
release is about what the plugin tells you while it works, and about no
|
||||||
|
longer overstating what a frequency result means.
|
||||||
|
|
||||||
|
Progress while solving:
|
||||||
|
|
||||||
|
- The dialog used to close on OK and leave nothing on screen until the
|
||||||
|
figures appeared - minutes, on a real board, with no sign the plugin
|
||||||
|
was doing anything. A small window now stays up for that whole
|
||||||
|
stretch: the stage running, elapsed seconds, and Cancel.
|
||||||
|
- It covers the figure work as well as the solve. Laying out labels and
|
||||||
|
writing the four PNGs at full resolution is seconds on a modest board
|
||||||
|
and 10-15 on a large one, and that used to be silent too.
|
||||||
|
- Cancel stops the solve and returns you to the board with no error
|
||||||
|
figure - the run simply reports that it was cancelled.
|
||||||
|
|
||||||
|
Frequency results are described honestly:
|
||||||
|
|
||||||
|
- Nothing advertises "AC resistance" any more. At f > 0 the plugin
|
||||||
|
applies the exact 1D foil and barrel skin-effect correction and
|
||||||
|
nothing else: proximity redistribution and inductance are not
|
||||||
|
modelled, so the number is a lower bound on the resistive rise, not
|
||||||
|
an AC impedance simulation. The README headline, the PCM and plugin
|
||||||
|
descriptions, the dialog note, the CLI help and the summary line all
|
||||||
|
say so now.
|
||||||
|
- The computation itself has not changed - only its description. A
|
||||||
|
frequency result from 1.2.1 is the same number, previously labelled
|
||||||
|
in a way that invited it to be read as an impedance.
|
||||||
|
|
||||||
|
Also in this release:
|
||||||
|
|
||||||
|
- The offline runner takes --progress, so the same busy window can be
|
||||||
|
used outside KiCad.
|
||||||
|
- The frequency field keeps its specific reason for rejecting an input
|
||||||
|
("1,500" is a thousands separator, "-5" is negative) instead of a
|
||||||
|
generic "cannot parse".
|
||||||
|
|
||||||
|
The in-KiCad |J| overlay push remains experimental and opt-in, off by
|
||||||
|
default. It writes reference images to User.9-User.12 and replaces what
|
||||||
|
is on those layers.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
The plugin now works on macOS. Results are unchanged from 1.2.2 for
|
||||||
|
the same board and settings - nothing in the numerics was touched;
|
||||||
|
this release is platform fixes and per-OS documentation.
|
||||||
|
|
||||||
|
macOS (field-tested on KiCad 10):
|
||||||
|
|
||||||
|
- Fixed a crash on launch. KiCad's macOS builds bundle Python 3.9,
|
||||||
|
and one module's type annotations were evaluated at import there
|
||||||
|
("unsupported operand type(s) for |: 'type' and 'NoneType'"). The
|
||||||
|
plugin now runs on 3.9, and a test walks every shipped module so
|
||||||
|
the incompatibility cannot silently return.
|
||||||
|
- Fixed every figure - the error figure included - refusing to render
|
||||||
|
with "Cannot load backend 'TkAgg' ... as 'qt' is currently
|
||||||
|
running". macOS' bundled Python ships tkinter, so matplotlib
|
||||||
|
preferred Tk while the selection dialog had already made the
|
||||||
|
process a Qt one. Qt (PySide6, a hard dependency) is now always
|
||||||
|
the first choice on every platform.
|
||||||
|
- A board in a read-only location - such as the demo projects opened
|
||||||
|
straight from the mounted installer image - no longer kills the run
|
||||||
|
when the results directory cannot be created next to the board.
|
||||||
|
Results fall back to a temp directory and the path is printed to
|
||||||
|
the Messages panel.
|
||||||
|
- The test suite additionally runs against the stack a Mac plugin
|
||||||
|
environment actually resolves (Python 3.9, numpy 2.0, scipy 1.13,
|
||||||
|
matplotlib 3.9, PySide6 6.10) - 140 tests on both stacks.
|
||||||
|
|
||||||
|
Linux:
|
||||||
|
|
||||||
|
- On ARM64 (aarch64) the plugin environment could never build: pyamg
|
||||||
|
publishes no wheels for that platform, KiCad installs wheels only,
|
||||||
|
and one unresolvable requirement fails the whole environment.
|
||||||
|
pyamg is now skipped there and the solver falls back to Jacobi-CG -
|
||||||
|
same results, noticeably slower on large grids. Linux as a whole
|
||||||
|
remains untested; reports welcome.
|
||||||
|
|
||||||
|
Documentation:
|
||||||
|
|
||||||
|
- Setup now gives dedicated instructions per operating system: which
|
||||||
|
interpreter path to check, how to deploy, and where the plugin's
|
||||||
|
Python environment lives on Windows, macOS and Linux (for the
|
||||||
|
delete-and-restart recovery). A platform-notes section records what
|
||||||
|
is actually tested on each OS and what to expect there.
|
||||||
@@ -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]))
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
A future version may read overrides from <project>/fill_res_config.json.
|
A future version may read overrides from <project>/fill_res_config.json.
|
||||||
"""
|
"""
|
||||||
|
from __future__ import annotations # KiCad's macOS Python is 3.9: without
|
||||||
|
# this, `float | None` annotations are
|
||||||
|
# evaluated at import and crash there
|
||||||
|
|
||||||
# --- Grid sizing ---
|
# --- Grid sizing ---
|
||||||
# Benchmarked on the VOUT+ plane (147x59 mm): R changes < 0.3% from
|
# Benchmarked on the VOUT+ plane (147x59 mm): R changes < 0.3% from
|
||||||
|
|||||||
@@ -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__":
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+24
-10
@@ -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
|
||||||
|
|
||||||
|
|
||||||
@@ -43,7 +48,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 +519,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 +540,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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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,8 +15,20 @@ 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.mkdir(parents=True, exist_ok=True)
|
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)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -52,6 +52,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); "
|
||||||
@@ -87,13 +90,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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@
|
|||||||
},
|
},
|
||||||
"versions": [
|
"versions": [
|
||||||
{
|
{
|
||||||
"version": "1.2.1",
|
"version": "1.3.0",
|
||||||
"status": "stable",
|
"status": "stable",
|
||||||
"kicad_version": "10.0",
|
"kicad_version": "10.0",
|
||||||
"runtime": "ipc"
|
"runtime": "ipc"
|
||||||
|
|||||||
+2
-2
@@ -3,7 +3,7 @@
|
|||||||
# the dependency list there in sync with [project.dependencies].
|
# the dependency list there in sync with [project.dependencies].
|
||||||
[project]
|
[project]
|
||||||
name = "fill-resistance"
|
name = "fill-resistance"
|
||||||
version = "1.2.1"
|
version = "1.3.0"
|
||||||
description = "DC resistance of copper zone fills and traces between two contacts (KiCad 10 plugin)"
|
description = "DC resistance of copper zone fills and traces between two contacts (KiCad 10 plugin)"
|
||||||
license = "GPL-3.0-or-later"
|
license = "GPL-3.0-or-later"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
@@ -11,7 +11,7 @@ dependencies = [
|
|||||||
"kicad-python>=0.7.0",
|
"kicad-python>=0.7.0",
|
||||||
"numpy",
|
"numpy",
|
||||||
"scipy",
|
"scipy",
|
||||||
"pyamg",
|
"pyamg ; sys_platform != 'linux' or platform_machine != 'aarch64'",
|
||||||
"matplotlib",
|
"matplotlib",
|
||||||
"PySide6",
|
"PySide6",
|
||||||
]
|
]
|
||||||
|
|||||||
+3
-1
@@ -1,6 +1,8 @@
|
|||||||
kicad-python>=0.7.0
|
kicad-python>=0.7.0
|
||||||
numpy
|
numpy
|
||||||
scipy
|
scipy
|
||||||
pyamg
|
# no pyamg wheels for Linux aarch64, and KiCad installs wheels-only
|
||||||
|
# (--only-binary): skip it there, the solver falls back to Jacobi-CG
|
||||||
|
pyamg ; sys_platform != "linux" or platform_machine != "aarch64"
|
||||||
matplotlib
|
matplotlib
|
||||||
PySide6
|
PySide6
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Python 3.9 compatibility tripwire.
|
||||||
|
|
||||||
|
KiCad's macOS builds bundle Python 3.9 and build the plugin venv with
|
||||||
|
it (README: Platform notes), while the dev environment runs a current
|
||||||
|
Python - so nothing else in the suite notices a construct that only
|
||||||
|
breaks on 3.9. The first real Mac run died at import: a module-level
|
||||||
|
`float | None` annotation in config.py, evaluated at runtime because
|
||||||
|
the file lacked the future import (PEP 604 unions need Python 3.10
|
||||||
|
unless annotations are deferred).
|
||||||
|
"""
|
||||||
|
import ast
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
SHIPPED = sorted((ROOT / "fill_resistance").glob("*.py"))
|
||||||
|
SHIPPED.append(ROOT / "fill_res_action.py")
|
||||||
|
|
||||||
|
|
||||||
|
def _has_future_annotations(tree: ast.Module) -> bool:
|
||||||
|
return any(isinstance(node, ast.ImportFrom)
|
||||||
|
and node.module == "__future__"
|
||||||
|
and any(alias.name == "annotations" for alias in node.names)
|
||||||
|
for node in tree.body)
|
||||||
|
|
||||||
|
|
||||||
|
def _uses_annotations(tree: ast.Module) -> bool:
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if isinstance(node, ast.AnnAssign):
|
||||||
|
return True
|
||||||
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||||
|
if node.returns is not None:
|
||||||
|
return True
|
||||||
|
a = node.args
|
||||||
|
args = (a.posonlyargs + a.args + a.kwonlyargs
|
||||||
|
+ ([a.vararg] if a.vararg else [])
|
||||||
|
+ ([a.kwarg] if a.kwarg else []))
|
||||||
|
if any(arg.annotation is not None for arg in args):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def test_annotated_modules_defer_annotations():
|
||||||
|
offenders = []
|
||||||
|
for path in SHIPPED:
|
||||||
|
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||||
|
if _uses_annotations(tree) and not _has_future_annotations(tree):
|
||||||
|
offenders.append(path.name)
|
||||||
|
assert not offenders, (
|
||||||
|
f"{offenders} use annotations without 'from __future__ import "
|
||||||
|
f"annotations': they are evaluated at import time and PEP 604 "
|
||||||
|
f"unions crash on KiCad's macOS Python 3.9.")
|
||||||
@@ -216,14 +216,14 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fill-resistance"
|
name = "fill-resistance"
|
||||||
version = "1.2.1"
|
version = "1.3.0"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "kicad-python" },
|
{ name = "kicad-python" },
|
||||||
{ name = "matplotlib" },
|
{ name = "matplotlib" },
|
||||||
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
|
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
|
||||||
{ name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
|
{ name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
|
||||||
{ name = "pyamg" },
|
{ name = "pyamg", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" },
|
||||||
{ name = "pyside6" },
|
{ name = "pyside6" },
|
||||||
{ name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
|
{ name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
|
||||||
{ name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
|
{ name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
|
||||||
@@ -239,7 +239,7 @@ requires-dist = [
|
|||||||
{ name = "kicad-python", specifier = ">=0.7.0" },
|
{ name = "kicad-python", specifier = ">=0.7.0" },
|
||||||
{ name = "matplotlib" },
|
{ name = "matplotlib" },
|
||||||
{ name = "numpy" },
|
{ name = "numpy" },
|
||||||
{ name = "pyamg" },
|
{ name = "pyamg", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" },
|
||||||
{ name = "pyside6" },
|
{ name = "pyside6" },
|
||||||
{ name = "scipy" },
|
{ name = "scipy" },
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user