Files
skills-win/altium-kicad/scripts/netcmp.py
T
2026-09-09 09:51:06 +08:00

224 lines
9.3 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.
"""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())