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