"""netcmp.py — 原理图网表比对(阶段 2)。判定「电气连接是否与 Altium 完全一致」。 比对方法(两次迁移验证过,别改成比网络名): 两个工具的网络命名规则不同,逐名比对必然一片红。真正要比的是 **net partition**: 把每份网表归约成「每个引脚 -> 它所在网络的全部节点集合」,两侧的等价类集合相等 即电气连接一致。 三个必须做的归一化,少一个就会出假差异: 1. KiCad 网名带前导 '/'(层次路径),Altium 不带 -> 剥掉前导 '/'。 2. Altium tabular 导出把隐藏的电源/平面引脚重复列成 net='?' 的行 -> 去掉。 3. KiCad 扁平导入后,整体导出网表只能拿到根图的一小部分 -> 必须**逐页** `kicad-cli sch export netlist --format kicadxml` 再按网名合并。 (格式必须是 kicadxml;PADS 格式会漏掉单节点网。) 用法: python netcmp.py --kicad-dir E:\\Kicad_design\\FOO \\ --netlist-csv E:\\Kicad_design\\FOO\\_migration\\reference\\netlist_tabular.csv \\ --out E:\\Kicad_design\\FOO\\_migration\\netcmp 退出码 0 = 100% 一致;1 = 有差异(详情看 diff.json / 终端前 30 条)。 """ from __future__ import annotations import argparse import collections import csv import glob import json import os import re import subprocess import sys import xml.etree.ElementTree as ET from pathlib import Path COMP_KEYS = ("component", "designator", "ref", "reference") PIN_KEYS = ("pin", "pin_number", "pad", "pin designator") NET_KEYS = ("net", "netname", "net name") def pick(fieldnames, candidates): lowered = {(f or "").strip().lower(): f for f in fieldnames} for c in candidates: if c in lowered: return lowered[c] return None def norm_net(name: str | None) -> str | None: """剥掉 KiCad 层次网名的前导 '/',让两侧命名可比。""" if name is None: return None return name.strip().lstrip("/") def _env_lookup(key: str, default: str | None = None) -> str: """环境变量优先,其次 ~/skills_env.md 的 `KEY = value` 行(机器相关参数的真源)。""" v = os.environ.get(key) if v: return v path = os.path.join(os.path.expanduser("~"), "skills_env.md") try: with open(path, encoding="utf-8") as f: for line in f: m = re.match(rf"\s*{key}\s*=\s*(\S+)", line) if m: return m.group(1) except OSError: pass if default is None: sys.exit(f"missing {key}: add it to ~/skills_env.md or set it as an env var") return default def find_kicad_cli(explicit: str | None) -> str: if explicit: return explicit # 安装路径是机器相关的:真源是 ~/skills_env.md 的 KICAD_ROOT。 kicad_root = _env_lookup("KICAD_ROOT", "") for cand in (os.environ.get("KICAD_CLI"), os.path.join(kicad_root, "bin", "kicad-cli.exe") if kicad_root else None, "kicad-cli"): if not cand: continue if cand == "kicad-cli" or Path(cand).is_file(): return cand return "kicad-cli" def export_pages(kicad_dir: Path, out_dir: Path, cli: str) -> list[Path]: """逐页导出 kicadxml。整体导出在扁平结构下会漏页,所以必须一页一页来。""" sheets = sorted(kicad_dir.glob("*.kicad_sch")) if not sheets: raise SystemExit(f"[ERROR] {kicad_dir} 下没有 .kicad_sch") out_dir.mkdir(parents=True, exist_ok=True) made = [] for sch in sheets: dst = out_dir / f"xml_{sch.stem}.xml" cmd = [cli, "sch", "export", "netlist", "--format", "kicadxml", "-o", str(dst), str(sch)] # 不能用 text=True:那会按系统区域编码(中文 Windows 是 GBK)解码, # 而 kicad-cli 输出 UTF-8,遇到非 ASCII 直接 UnicodeDecodeError。 proc = subprocess.run(cmd, capture_output=True, encoding="utf-8", errors="replace") if proc.returncode != 0 or not dst.is_file(): print(f"[WARN] 导出失败 {sch.name}: {proc.stderr.strip()[:200]}") continue made.append(dst) print(f"[export] {len(made)}/{len(sheets)} 页导出成功 -> {out_dir}") return made def load_altium(csv_path: Path) -> dict[str, str]: with csv_path.open(newline="", encoding="utf-8-sig") as f: reader = csv.DictReader(f) c_key = pick(reader.fieldnames or [], COMP_KEYS) p_key = pick(reader.fieldnames or [], PIN_KEYS) n_key = pick(reader.fieldnames or [], NET_KEYS) if not (c_key and p_key and n_key): raise SystemExit(f"[ERROR] 认不出 CSV 列名:{reader.fieldnames}(需要 component/pin/net)") real = collections.defaultdict(set) rows = 0 for row in reader: rows += 1 comp = (row.get(c_key) or "").strip() pin = (row.get(p_key) or "").strip() net = (row.get(n_key) or "").strip() if comp and pin and net and net != "?": real[(comp, pin)].add(norm_net(net)) print(f"[altium] 原始 {rows} 行 -> 去 '?' 重复后 {len(real)} 个唯一节点") return {f"{c}.{p}": sorted(v)[0] for (c, p), v in real.items()} def load_kicad(xml_paths: list[Path]) -> dict[str, str]: nn = collections.defaultdict(set) for fn in xml_paths: root = ET.parse(fn).getroot() for net in root.iter("net"): nm = norm_net(net.get("name")) for nd in net.findall("node"): nn[f"{nd.get('ref')}.{nd.get('pin')}"].add(nm) print(f"[kicad] 合并 {len(xml_paths)} 页 -> {len(nn)} 个节点") # 同一节点在多页上出现在同名网络里是正常的;名字冲突时保留全部以便暴露问题 return {nd: (sorted(names)[0] if len(names) == 1 else "|".join(sorted(names))) for nd, names in nn.items()} def partition(node_net: dict[str, str]): groups = collections.defaultdict(set) for node, net in node_net.items(): groups[net].add(node) return {node: frozenset(groups[net]) for node, net in node_net.items()}, groups def main() -> int: ap = argparse.ArgumentParser(description="Altium vs KiCad schematic netlist partition compare") ap.add_argument("--kicad-dir", required=True, help="KiCad 工程目录(含 *.kicad_sch)") ap.add_argument("--netlist-csv", required=True, help="Altium tabular 网表真值 CSV") ap.add_argument("--out", required=True, help="输出目录,放 xml_*.xml / diff.json / 报告") ap.add_argument("--kicad-cli", help="kicad-cli 路径,默认自动找") ap.add_argument("--skip-export", action="store_true", help="复用 --out 下已有的 xml_*.xml") args = ap.parse_args() out_dir = Path(args.out) out_dir.mkdir(parents=True, exist_ok=True) if args.skip_export: xmls = [Path(p) for p in sorted(glob.glob(str(out_dir / "xml_*.xml")))] if not xmls: raise SystemExit(f"[ERROR] --skip-export 但 {out_dir} 下没有 xml_*.xml") print(f"[export] 跳过,复用已有 {len(xmls)} 个 xml") else: xmls = export_pages(Path(args.kicad_dir), out_dir, find_kicad_cli(args.kicad_cli)) node_net_a = load_altium(Path(args.netlist_csv)) node_net_k = load_kicad(xmls) pa, ga = partition(node_net_a) pk, gk = partition(node_net_k) na, nk = set(node_net_a), set(node_net_k) only_a, only_k = sorted(na - nk), sorted(nk - na) mismatch = sorted(nd for nd in (na & nk) if pa[nd] != pk[nd]) classes_a = {frozenset(v) for v in ga.values()} classes_k = {frozenset(v) for v in gk.values()} identical = (classes_a == classes_k) and not only_a and not only_k print(f"\n节点 Altium={len(na)} KiCad={len(nk)} 只在A={len(only_a)} 只在K={len(only_k)}") print(f"网络 Altium={len(ga)} KiCad={len(gk)} 等价类集合相等={classes_a == classes_k}") print(f"同伴集不同的节点:{len(mismatch)}") for nd in only_a[:15]: print(f" 只在Altium {nd}: net={node_net_a[nd]}") for nd in only_k[:15]: print(f" 只在KiCad {nd}: net={node_net_k[nd]}") for nd in mismatch[:30]: am, km = pa[nd] - {nd}, pk[nd] - {nd} print(f" {nd}: A={node_net_a[nd]} K={node_net_k[nd]} | " f"只A的同伴:{sorted(am - km)[:5]} 只K的同伴:{sorted(km - am)[:5]}") diff_path = out_dir / "diff.json" diff_path.write_text(json.dumps({ "identical": identical, "altium_nodes": len(na), "kicad_nodes": len(nk), "altium_nets": len(ga), "kicad_nets": len(gk), "onlyA_nodes": only_a, "onlyK_nodes": only_k, "mismatch_nodes": mismatch, "onlyA_classes": len(classes_a - classes_k), "onlyK_classes": len(classes_k - classes_a), }, ensure_ascii=False, indent=1), encoding="utf-8") print(f"\n[diff] {diff_path}") print(f"identical: {identical}") if not identical: print("差异不一定是真错:先确认 KiCad 网名前导 '/' 已剥、Altium 的 '?' 行已去," "再看是不是某页 kicadxml 没导出成功(上面 [export] 的比例)。") return 0 if identical else 1 if __name__ == "__main__": try: sys.stdout.reconfigure(encoding="utf-8") except Exception: pass sys.exit(main())