Build PCM package / build (push) Successful in 6s
tests / ubuntu-latest · py3.11 (push) Successful in 54s
tests / fedora:latest (push) Successful in 1m11s
tests / ubuntu:24.04 (push) Successful in 1m22s
tests / NixOS (FHS wrapper from docs/NIXOS.md) (push) Has been skipped
tests / ubuntu-latest · py3.13 (push) Successful in 2m42s
tests / archlinux:latest (push) Successful in 55s
tests / debian:12 (push) Successful in 3m5s
The dialog threshold field gets a unit selector: % of mean |J| (default, as before) or absolute A/mm2. The absolute variant applies at the solved operating point - |J| scales with the test current, so it is meant to be used with the real operating current entered as the test current (config comment, README and dialog say so). Switching units swaps in the other unit config default (TRIM_THRESHOLD_A_MM2 = 1.0 for absolute) but never clobbers a number the user typed. Validation stays per unit: 0..100 for %, > 0 for A/mm2. pipeline.run takes trim_pct/trim_abs (at most one), compute() mirrors that, and the JSON records threshold_mode + threshold_value next to the resolved threshold_a_per_mm2. Suite on dev 3.13 and the Python 3.9 mac-stack venv: 151 passed each; dialog wiring exercised offscreen (defaults, unit swap, validation). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
166 lines
6.7 KiB
Python
166 lines
6.7 KiB
Python
"""Top-level orchestration for the KiCad-launched action.
|
||
|
||
Flow: connect -> read the two selected contacts (rectangles/pads) ->
|
||
gather fills -> selection dialog (net, layers, contacts, current, cell)
|
||
-> extract vias -> solve -> figures + report.
|
||
|
||
Every failure is reported twice: on stdout (lands in the KiCad status-bar
|
||
warning list) and as a matplotlib error figure, so it cannot be missed.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import sys
|
||
import traceback
|
||
|
||
from . import config, pipeline, progress, report
|
||
from .errors import CandidateError, UserFacingError
|
||
|
||
|
||
def _fail(message: str, outdir) -> None:
|
||
print(f"ERROR: {message}")
|
||
try:
|
||
if outdir is None:
|
||
# A failure before the run has an output directory (a broken
|
||
# plugin environment throws on import) would otherwise save
|
||
# no PNG - and with no GUI toolkit, plots falls back to
|
||
# opening the saved PNGs, so the figure would never be shown
|
||
# either. Exactly the case the docstring promises to cover.
|
||
import tempfile
|
||
from pathlib import Path
|
||
outdir = Path(tempfile.gettempdir()) / "fill-resistance-error"
|
||
from . import plots
|
||
fig = plots.fig_error(message)
|
||
plots.save_and_show([(fig, "error")], outdir)
|
||
except Exception: # reporting must not mask the fault
|
||
traceback.print_exc()
|
||
sys.exit(1)
|
||
|
||
|
||
def main() -> None:
|
||
outdir = None
|
||
try:
|
||
try:
|
||
from kipy.errors import ApiError
|
||
|
||
from . import board_io, dialog
|
||
except ImportError as e:
|
||
if "cannot open shared object file" not in str(e):
|
||
raise
|
||
# pip's Linux wheels link against FHS system libraries;
|
||
# on NixOS those paths don't exist and PySide6/pynng die
|
||
# exactly like this. Nothing inside the venv can fix it.
|
||
raise UserFacingError(
|
||
f"A compiled dependency cannot load its system "
|
||
f"libraries: {e}\nThe plugin venv is built from pip "
|
||
f"wheels, which expect standard (FHS) library paths. "
|
||
f"On NixOS, run KiCad inside an FHS environment "
|
||
f"(buildFHSEnv wrapper, or steam-run for a quick "
|
||
f"test) - see docs/NIXOS.md in the plugin repo."
|
||
)
|
||
try:
|
||
kicad, board = board_io.connect()
|
||
stackup = board_io.get_stackup_info(board)
|
||
es1, es2, net_hint = board_io.get_electrodes(board, stackup)
|
||
if board_io.any_zone_unfilled(board) or config.ALWAYS_REFILL:
|
||
board_io.refill(board)
|
||
fills = board_io.gather_net_fills(board)
|
||
tracks = board_io.gather_net_tracks(board)
|
||
copper = board_io.merge_copper(
|
||
fills, board_io.tracks_as_polygons(tracks))
|
||
candidate_nets = board_io.nets_overlapping(copper, es1, es2)
|
||
buildups = board_io.gather_mask_buildups(board)
|
||
except ApiError as e:
|
||
raise UserFacingError(
|
||
f"KiCad API error: {e}\nIf KiCad is showing a dialog, close "
|
||
f"it and run again."
|
||
)
|
||
|
||
if not candidate_nets:
|
||
raise CandidateError(
|
||
"No copper (zone fill or trace) overlaps both contacts. "
|
||
"Check that both sit over copper of the same net and that "
|
||
"the fills are up to date (press B in the board editor)."
|
||
)
|
||
|
||
def group_label(parts):
|
||
names = [p.label for p in parts[:3]]
|
||
more = f" +{len(parts) - 3}" if len(parts) > 3 else ""
|
||
return f"{len(parts)}× " + ", ".join(names) + more
|
||
|
||
def group_contact(parts):
|
||
contacts = {p.contact for p in parts}
|
||
return contacts.pop() if len(contacts) == 1 else "auto"
|
||
|
||
default_net = (net_hint if net_hint in candidate_nets
|
||
else candidate_nets[0])
|
||
selection = dialog.ask(
|
||
candidates={n: list(copper[n].keys()) for n in candidate_nets},
|
||
layer_order=stackup.names,
|
||
default_net=default_net,
|
||
e1_label=group_label(es1), e2_label=group_label(es2),
|
||
contact1=group_contact(es1), contact2=group_contact(es2),
|
||
buildup_layers=sorted(buildups.keys()),
|
||
)
|
||
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:
|
||
e.contact = selection.contact1
|
||
if selection.contact2 != "auto":
|
||
for e in es2:
|
||
e.contact = selection.contact2
|
||
if selection.cell_um is not None:
|
||
config.CELL_UM_OVERRIDE = selection.cell_um
|
||
config.ADAPTIVE_CELLS = selection.adaptive
|
||
|
||
try:
|
||
problem = board_io.build_problem(
|
||
board, selection.net, selection.layers, es1, es2, stackup,
|
||
fills,
|
||
buildups=(buildups if selection.include_buildup else None),
|
||
extra_cu_um=selection.extra_cu_um,
|
||
tracks=(tracks if selection.include_tracks else None),
|
||
vias_capped=selection.vias_capped,
|
||
cap_max_drill_mm=selection.cap_max_drill_mm)
|
||
outdir = report.make_output_dir(board_io.board_dir(board))
|
||
except ApiError as e:
|
||
raise UserFacingError(f"KiCad API error: {e}")
|
||
|
||
report.write_geometry_dump(outdir, problem)
|
||
overlay_cb = None
|
||
if selection.push_overlays:
|
||
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=trim_pct, trim_abs=trim_abs,
|
||
trim_push=trim_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__":
|
||
main()
|