Files
kicad-zone-resistance/fill_resistance/main.py
T
janikandClaude Fable 5 19327c23b1
tests / ubuntu-latest · py3.11 (push) Successful in 54s
tests / ubuntu-latest · py3.13 (push) Successful in 58s
tests / archlinux:latest (push) Successful in 41s
tests / debian:12 (push) Successful in 1m10s
tests / fedora:latest (push) Successful in 42m6s
tests / ubuntu:24.04 (push) Successful in 1m15s
tests / NixOS (FHS wrapper from docs/NIXOS.md) (push) Skipped
Build PCM package / build (push) Successful in 11s
Release 1.4.1: a Bonded checkbox in the terminal tables
The config's per-terminal bonded key becomes editable in the dialog
(previously file-only, shown as a text suffix): checked, the
terminal's contacts short into one internally joined lug - the total
value stays prescribed, the per-contact split is a solve outcome.
Same-name rectangle groups seed it checked as before; unchecking one
falls back to the per-cell area share, and checking a single-contact
terminal gives it an equipotential-lug contact instead of uniform
injection. Save config... writes the flag back (removed when
unchecked - false is the schema default). With this, every
non-structural terminal option (active, values, v_oc, contact layer,
bonded, comment) is table-editable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 17:20:35 +07:00

526 lines
25 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.
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) - the
# launch dies only when neither mode is possible
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:
raise SelectionError(
f"{classic_reason}\n(PDN mode is also "
f"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()