Files
kicad-zone-resistance/fill_resistance/main.py
T
janikandClaude Fable 5 634df87fe0
tests / fedora:latest (push) Successful in 1m15s
tests / ubuntu-latest · py3.11 (push) Successful in 1m32s
tests / ubuntu-latest · py3.13 (push) Successful in 1m1s
tests / archlinux:latest (push) Successful in 53s
tests / debian:12 (push) Successful in 51s
tests / ubuntu:24.04 (push) Successful in 1m8s
tests / NixOS (FHS wrapper from docs/NIXOS.md) (push) Skipped
Build PCM package / build (push) Successful in 10s
Release 1.4.2: load-only dialog when nothing is selected
With neither mode derivable (nothing selected, no marker rectangles,
no config) the dialog now opens anyway instead of failing with an
error figure: both radios disabled with their reasons, OK/Save
disabled, Load config… live - a saved config can bootstrap the run.

README: note that macOS/NixOS field tests last ran with plugin v1.3.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HjSRLWQ8ywBBYyakvr3YTx
2026-08-31 11:42:51 +07:00

535 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Top-level orchestration for the KiCad-launched action.
Flow: connect -> load the config named "default" (or the board-specific
one) -> derive BOTH modes' terminals (classic: selection / marker
rectangles / config refs; PDN: per-rectangle marker scan, or the
config's terminal set) -> gather fills -> dialog with a Classic/PDN
mode selector (classic: the two-contact form; PDN: editable per-role
terminal tables) -> extract vias -> solve -> figures + report. The
dialog's "Load config…" button loops back to the derivation with the
picked file, so a run can be set up from any saved config - and the
dialog opens even when NEITHER mode is derivable (nothing selected, no
marker rectangles, no config): load-only, both radios disabled with
their reasons, so a saved config can bootstrap the run.
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 pathlib import Path
from . import config, pipeline, progress, report
from .errors import ConfigError, SelectionError, UserFacingError
from .geometry import Terminal
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)
# config globals a config file may override; "Load config…" re-derives
# from a fresh baseline so one file's physics/marker layers never leak
# into the next
_CFG_GLOBALS = ("RHO_CU_OHM_M", "COPPER_THICKNESS_UM", "VIA_PLATING_UM",
"ELECTRODE_POS_LAYER", "ELECTRODE_NEG_LAYER",
"ELECTRODE_PDN_LAYER")
def main() -> None:
outdir = None
try:
try:
from kipy.errors import ApiError
from . import board_io, configfile, 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)
except ApiError as e:
raise UserFacingError(
f"KiCad API error: {e}\nIf KiCad is showing a dialog, close "
f"it and run again."
)
cfg_path = configfile.find_config(
board_io.board_dir(board),
getattr(board, "name", "") or "")
base_globals = {name: getattr(config, name)
for name in _CFG_GLOBALS}
while True:
for name, value in base_globals.items():
setattr(config, name, value)
try:
cfg = configfile.load_config(cfg_path) if cfg_path else None
# a config with a terminals section is the PDN source;
# cfg.mode is only the STARTING mode - nothing is
# pinned, the dialog switches modes and nets freely
pdn_cfg = cfg is not None and bool(cfg.terminals)
if cfg is not None:
print(f"using config {cfg_path.name} ({cfg.mode} mode)")
# before any geometry: the marker layers steer
# get_electrodes, the physics steers build_problem
configfile.apply_physics(cfg)
# BOTH terminal derivations always run; a failure only
# disables that mode's radio (with the reason shown).
# Even with neither mode possible the dialog still
# opens - load-only - so "Load config…" works without
# first selecting pads or drawing marker rectangles
classic_reason = pdn_reason = None
es1: list = []
es2: list = []
net_hint = None
terminals: list = [] # resolved config terminals
marker_terms = None # live-scan MarkerTerminal list
new_terms: list = [] # rects not in the config yet
merge_note = ""
pdn_groups: list = [] # electrode groups, either way
pdn_hints: list = [] # per-terminal Component text
term_nets: list = [] # per-terminal overlapped nets
has_selection = bool(list(board.get_selection()))
try:
if cfg is not None and cfg.pos_parts is not None:
es1, es2 = board_io.resolve_classic_parts(
board, stackup, cfg.pos_parts,
cfg.neg_parts, cfg.net)
net_hint = cfg.net
else:
es1, es2, net_hint = board_io.get_electrodes(
board, stackup)
except SelectionError as e:
classic_reason = str(e)
try:
if pdn_cfg:
# config refs resolve against run.net; a broken
# ref disables PDN mode instead of killing the
# launch (classic may still work)
if not cfg.net:
raise ConfigError(
f"{cfg_path.name}: run.net is required "
f"to resolve the config terminals")
terminals = board_io.resolve_terminal_specs(
board, stackup, cfg.terminals, cfg.net)
# rectangles drawn since the save become NEW
# terminals - the file freezes nothing. A scan
# problem only forfeits the new ones, never
# the config set
try:
new_terms = board_io.new_marker_terminals(
cfg.terminals,
board_io.scan_marker_terminals(
board, require_both=False))
except (SelectionError, ConfigError) as e:
merge_note = (f"rectangle scan failed ({e})"
f" - new rectangles not "
f"offered")
print(f"note: {merge_note}")
pdn_groups = (
[t.electrodes for t in terminals]
+ [mt.electrodes for mt in new_terms])
else:
marker_terms = board_io.scan_marker_terminals(
board)
pdn_groups = [mt.electrodes
for mt in marker_terms]
pdn_hints = board_io.component_hints(board,
pdn_groups)
except (SelectionError, ConfigError) as e:
pdn_reason = str(e)
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))
classic_nets: list = []
pdn_nets: list = []
if classic_reason is None:
classic_nets = board_io.nets_overlapping(
copper, es1, es2)
if not classic_nets:
classic_reason = (
"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).")
if pdn_reason is None:
# per-terminal net sets drive BOTH the candidate
# list (a net qualifies with >= 1 supply and >= 1
# load terminal on it) and the dialog's row filter
# (only terminals on the selected net are shown
# and solved) - config and live sources alike
term_nets = board_io.group_nets(copper, pdn_groups)
roles = (([t.role for t in terminals]
+ [mt.role for mt in new_terms]) if pdn_cfg
else [mt.role for mt in marker_terms])
sup_nets: set = set()
load_nets: set = set()
for role, tn in zip(roles, term_nets):
(sup_nets if role == "supply"
else load_nets).update(tn)
pdn_nets = sorted(sup_nets & load_nets)
if not pdn_nets:
pdn_reason = (
"no net's copper overlaps at least one "
"supply and one load "
+ (f"terminal of {cfg_path.name}"
if pdn_cfg else "rectangle"))
elif pdn_cfg and cfg.net not in pdn_nets:
print(f"note: run.net '{cfg.net}' has no "
f"workable supply+load copper; PDN "
f"candidates: {', '.join(pdn_nets)}")
if classic_reason is not None and pdn_reason is not None:
# nothing runnable right now: the dialog opens with
# both radios and OK/Save disabled, offering only
# Load config… (and Cancel) - a loaded file then
# re-derives everything through the loop below
print(f"note: classic mode unavailable: "
f"{classic_reason}")
print(f"note: PDN mode unavailable: {pdn_reason}")
buildups = board_io.gather_mask_buildups(board)
except ApiError as e:
raise UserFacingError(
f"KiCad API error: {e}\nIf KiCad is showing a dialog, "
f"close it and run again."
)
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"
def rect_desc(e):
r = e.rect
return (f"rect ({r.x0 / 1e6:.1f}, {r.y0 / 1e6:.1f}).."
f"({r.x1 / 1e6:.1f}, {r.y1 / 1e6:.1f}) mm")
def marker_desc(mt):
if len(mt.electrodes) == 1:
return rect_desc(mt.electrodes[0])
# same-named rectangles grouped into one terminal (the
# Bonded checkbox shows/controls the lug behavior)
return (f"{len(mt.electrodes)}× "
f"{rect_desc(mt.electrodes[0])} …")
def live_row(mt, hint, tn):
return dialog.PdnTerminalRow(
name=mt.name, role=mt.role,
resolved=marker_desc(mt), component=hint,
bonded=mt.bonded, nets=tn)
defaults = configfile.dialog_defaults(cfg)
pdn_setup = None
if pdn_cfg and pdn_reason is None:
n_cfg = len(terminals)
rows = [dialog.PdnTerminalRow(
name=t.label, role=t.role,
resolved=group_label(t.electrodes),
component=hint,
i_draw_a=(t.i_draw_a if t.role == "load"
else None),
r_out_ohm=(t.r_out_ohm if t.role == "supply"
else None),
v_oc=t.v_oc, bonded=t.bonded,
contact=spec.contact or "auto",
active=spec.active, comment=spec.comment,
nets=tn, from_config=True)
for t, spec, hint, tn in zip(
terminals, cfg.terminals, pdn_hints[:n_cfg],
term_nets[:n_cfg])]
# newly drawn rectangles append as live rows: a save
# writes them into the config alongside the file's set
rows += [live_row(mt, hint, tn)
for mt, hint, tn in zip(new_terms,
pdn_hints[n_cfg:],
term_nets[n_cfg:])]
notes = []
if new_terms:
notes.append(f"{len(new_terms)} new rectangle(s) "
f"not in {cfg_path.name} yet - "
f"Save config… adds them")
if merge_note:
notes.append(merge_note)
pdn_setup = dialog.PdnSetup(
rows=rows, source=cfg_path.name, from_config=True,
note="; ".join(notes))
elif pdn_reason is None:
note = ""
if has_selection:
note = ("board selection ignored in PDN mode - "
"terminals are the marker rectangles")
print(f"note: {note}")
pdn_setup = dialog.PdnSetup(
rows=[live_row(mt, hint, tn)
for mt, hint, tn in zip(marker_terms,
pdn_hints,
term_nets)],
source=(f"marker rectangles on "
f"{config.ELECTRODE_POS_LAYER}/"
f"{config.ELECTRODE_NEG_LAYER}"),
note=note)
# cfg.mode is only the starting radio - never a pin
start_pdn = ((cfg is not None and cfg.mode == "pdn"
and pdn_reason is None)
or classic_reason is not None)
if start_pdn:
default_net = (defaults.net if defaults.net in pdn_nets
else (pdn_nets[0] if pdn_nets else ""))
else:
default_net = (defaults.net if defaults.net in classic_nets
else net_hint if net_hint in classic_nets
else classic_nets[0])
def rect_infos(terms):
# unlabeled terminals are always single rectangles, so
# freezing the first rect's coordinates is exact
return [(mt.labeled,
(mt.electrodes[0].rect.x0 / 1e6,
mt.electrodes[0].rect.y0 / 1e6,
mt.electrodes[0].rect.x1 / 1e6,
mt.electrodes[0].rect.y1 / 1e6))
for mt in terms]
def save_cb(sel, target, cfg=cfg, cfg_path=cfg_path,
pdn_cfg=pdn_cfg, marker_terms=marker_terms,
new_terms=new_terms):
if sel.mode == "classic":
configfile.save_classic_config(target, sel)
elif pdn_cfg:
# the config rows update positionally; newly drawn
# rectangles append as fresh terminal entries
n = len(cfg.raw["terminals"])
tj = configfile.updated_terminals_json(
cfg.raw["terminals"], sel.pdn_rows[:n])
if sel.pdn_rows[n:]:
tj += configfile.rect_terminals_json(
sel.pdn_rows[n:], rect_infos(new_terms))
print(f"note: {len(sel.pdn_rows[n:])} new "
f"terminal(s) added to the config")
configfile.save_pdn_config(target, sel, tj)
else:
# EVERY row is saved - off-net ones arrive from the
# dialog as active: false (nothing drawn on the
# board is lost by a save); labeled (possibly
# grouped) rectangles save as rect:NAME
configfile.save_pdn_config(
target, sel,
configfile.rect_terminals_json(
sel.pdn_rows, rect_infos(marker_terms)))
print("note: the saved config now provides the "
"terminal set on later launches - labeled "
"rectangles stay live (rect:NAME), unlabeled "
"ones were frozen as coordinates; remove the "
"terminals section (or the file) to return "
"to the live rectangle scan")
print(f"config saved to {target}")
# only "default" (or its legacy plain spelling) and the
# board-stem name load on launch; other names need the
# Load config… button - say so before it surprises
auto = {config.CONFIG_FILENAME,
configfile.named_config_filename("default")}
stem = Path(getattr(board, "name", "") or "").stem
if stem:
auto.add(f"{stem}.{config.CONFIG_FILENAME}")
if target.name not in auto:
print("note: this name does not load automatically "
"- pull it in with Load config…")
return target.name
selection = dialog.ask(
candidates={n: list(copper[n].keys())
for n in classic_nets},
layer_order=stackup.names,
default_net=default_net,
e1_label=(group_label(es1) if es1 else ""),
e2_label=(group_label(es2) if es2 else ""),
contact1=((defaults.contact1 or group_contact(es1))
if es1 else "auto"),
contact2=((defaults.contact2 or group_contact(es2))
if es2 else "auto"),
buildup_layers=sorted(buildups.keys()),
defaults=defaults, pdn=pdn_setup,
pdn_candidates={n: list(copper[n].keys())
for n in pdn_nets},
classic_reason=classic_reason, pdn_reason=pdn_reason,
save_callback=save_cb,
save_target=(cfg_path if cfg_path is not None else
board_io.board_dir(board)
/ config.CONFIG_FILENAME),
load_dir=board_io.board_dir(board),
start_mode=("pdn" if start_pdn else "classic"),
)
if isinstance(selection, dialog.LoadRequest):
# re-derive everything from the picked file; its validity
# was already checked by the dialog before it closed
cfg_path = selection.path
continue
break
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()
run_pdn = selection.mode == "pdn"
if run_pdn:
def live_terminal(mt, row):
if row.contact not in ("", "all", "auto"):
# dialog Layer pick: this terminal's rectangles
# contact only that copper layer
for e in mt.electrodes:
e.contact = row.contact
return Terminal(
role=row.role, electrodes=mt.electrodes,
label=row.name,
i_draw_a=(row.i_draw_a
if row.i_draw_a is not None else 0.0),
r_out_ohm=(row.r_out_ohm
if row.r_out_ohm is not None else 0.0),
v_oc=row.v_oc, bonded=row.bonded,
component=row.component, comment=row.comment)
if pdn_cfg:
cfg_rows = selection.pdn_rows[:len(terminals)]
new_rows = selection.pdn_rows[len(terminals):]
# a changed Layer scope is geometry: push it onto the
# specs and re-resolve (part-level contacts inside the
# file still win, exactly as the schema promises)
changed = False
for spec, row in zip(cfg.terminals, cfg_rows):
if (row.contact or "auto") != (spec.contact or "auto"):
spec.contact = row.contact
changed = True
if changed:
try:
terminals = board_io.resolve_terminal_specs(
board, stackup, cfg.terminals, cfg.net)
except ApiError as e:
raise UserFacingError(f"KiCad API error: {e}")
# dialog value edits win for the run: write them back
# onto the resolved terminals (positional, same order),
# then drop the unchecked ones - they stay in the file
# but take no part in the solve; newly drawn
# rectangles run as live terminals
for t, row in zip(terminals, cfg_rows):
if t.role == "load":
t.i_draw_a = row.i_draw_a
else:
t.r_out_ohm = row.r_out_ohm
t.v_oc = row.v_oc
t.bonded = row.bonded
t.component = row.component
t.comment = row.comment
terminals = (
[t for t, row in zip(terminals, cfg_rows)
if row.active]
+ [live_terminal(mt, row)
for mt, row in zip(new_terms, new_rows)
if row.active])
else:
terminals = [live_terminal(mt, row)
for mt, row in zip(marker_terms,
selection.pdn_rows)
if row.active]
else:
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,
([] if run_pdn else es1), ([] if run_pdn else 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,
terminals=(terminals if run_pdn else None))
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=(None if run_pdn else selection.current_a),
freq_hz=selection.freq_hz,
contact_model=(None if run_pdn
else selection.contact_model),
overlay=overlay_cb,
trim_pct=trim_pct, trim_abs=trim_abs,
trim_push=trim_cb,
v_nominal=(selection.v_nominal if run_pdn else None))
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()