sync: HP_BOWEI 2026-09-09

This commit is contained in:
Myfro9
2026-09-09 09:51:06 +08:00
parent 01b7a00b12
commit 170f4eb53f
10 changed files with 1926 additions and 5 deletions
+223
View File
@@ -0,0 +1,223 @@
"""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())