332 lines
13 KiB
Python
332 lines
13 KiB
Python
"""原理图验收:ERC + 网表器件数 + 子电路画法检查。
|
||
|
||
python -m schgen.check <根图.kicad_sch> [--expect-refs 位号清单文件或逗号串]
|
||
|
||
四件事:
|
||
1. kicad-cli ERC(错误/警告条数与分类);
|
||
2. 网表里的器件数与网络数——判断一页有没有真被 KiCad 读进去**看这个**,
|
||
不要看 ERC 的 0(G-0018:一页解析失败时 ERC 照样 0 条);位号重名会被静默合并(G-0028),
|
||
给了 --expect-refs 就逐个比对缺了哪些;
|
||
3. 子电路画法(G-0035/G-0037):
|
||
A 级:两脚件两端都只挂标签、连通域里没有别的元件引脚——看不出它串在哪/退耦谁;
|
||
B 级:连通关系(可穿过其它无源件)到不了任何芯片/连接器引脚,且离最近的芯片/连接器
|
||
引脚也超过 NEAR mm——说明它没画在所服务的子电路里。
|
||
连通性含丁字口(端点落在另一条线中段,G-0036)。引脚坐标取自图纸自带的 lib_symbols,
|
||
不依赖库表。
|
||
C 级:三条以上线端(或线端+引脚)汇于一点却没画接点——电气连通但图面看不出(G-0036 的反面)。
|
||
4. 封装:每个器件的封装文件存在,符号引脚号与焊盘号一一对应。
|
||
"""
|
||
import io
|
||
import math
|
||
import os
|
||
import re
|
||
import subprocess
|
||
import sys
|
||
import tempfile
|
||
import xml.etree.ElementTree as ET
|
||
|
||
from .env import kicad_cli
|
||
from .kicad_lib import balanced, rot_pt
|
||
|
||
TOL = 0.02
|
||
NEAR = 55.0
|
||
TWO_PIN_PREFIX = ("R", "C", "L", "FB", "JP", "D")
|
||
|
||
|
||
def sexp(text):
|
||
toks = re.findall(r'\(|\)|"(?:[^"\\]|\\.)*"|[^\s()]+', text)
|
||
stack, cur = [], []
|
||
for tk in toks:
|
||
if tk == '(':
|
||
stack.append(cur); cur = []
|
||
elif tk == ')':
|
||
done = cur; cur = stack.pop(); cur.append(done)
|
||
else:
|
||
cur.append(tk[1:-1].replace('\\"', '"') if tk.startswith('"') else tk)
|
||
return cur
|
||
|
||
|
||
def find(node, name):
|
||
return [c for c in node if isinstance(c, list) and c and c[0] == name]
|
||
|
||
|
||
def at(node):
|
||
a = find(node, "at")
|
||
if not a:
|
||
return None
|
||
v = a[0]
|
||
return (float(v[1]), float(v[2]), float(v[3]) if len(v) > 3 else 0.0)
|
||
|
||
|
||
def embedded_pins(text):
|
||
"""{lib_id: {unit: {pin: (dx, dy)}}},取自 .kicad_sch 的 lib_symbols。"""
|
||
i = text.find("(lib_symbols")
|
||
if i < 0:
|
||
return {}
|
||
ls = balanced(text, i)
|
||
res = {}
|
||
for m in re.finditer(r'\n\s*\(symbol "([^"]+:[^"]+)"', ls):
|
||
blk = balanced(ls, m.start() + ls[m.start():].find("("))
|
||
lid = m.group(1)
|
||
units = {}
|
||
for sm in re.finditer(r'\(symbol "[^"]*_(\d+)_\d+"', blk):
|
||
u = int(sm.group(1))
|
||
sub = balanced(blk, sm.start())
|
||
for pm in re.finditer(r'\(pin\s+\S+\s+\S+\s*\(at\s+([-\d.]+)\s+([-\d.]+)', sub):
|
||
seg = sub[pm.end():pm.end() + 400]
|
||
nm = re.search(r'\(number "([^"]*)"', seg)
|
||
if nm:
|
||
units.setdefault(u, {})[nm.group(1)] = (float(pm.group(1)), float(pm.group(2)))
|
||
res[lid] = units
|
||
return res
|
||
|
||
|
||
class DS:
|
||
def __init__(self):
|
||
self.p = {}
|
||
|
||
def find(self, k):
|
||
self.p.setdefault(k, k)
|
||
while self.p[k] != k:
|
||
self.p[k] = self.p[self.p[k]]
|
||
k = self.p[k]
|
||
return k
|
||
|
||
def union(self, a, b):
|
||
ra, rb = self.find(a), self.find(b)
|
||
if ra != rb:
|
||
self.p[ra] = rb
|
||
|
||
|
||
def key(x, y):
|
||
return (round(x / TOL), round(y / TOL))
|
||
|
||
|
||
def on_seg(px, py, x1, y1, x2, y2):
|
||
if abs((x2 - x1) * (py - y1) - (y2 - y1) * (px - x1)) > 0.05:
|
||
return False
|
||
return (min(x1, x2) - TOL <= px <= max(x1, x2) + TOL
|
||
and min(y1, y2) - TOL <= py <= max(y1, y2) + TOL)
|
||
|
||
|
||
def subckt(path):
|
||
text = io.open(path, encoding="utf-8").read()
|
||
lib = embedded_pins(text)
|
||
root = sexp(text)[0]
|
||
wires = []
|
||
for w in find(root, "wire"):
|
||
xy = [(float(p[1]), float(p[2])) for p in find(find(w, "pts")[0], "xy")]
|
||
if len(xy) == 2:
|
||
wires.append((xy[0][0], xy[0][1], xy[1][0], xy[1][1]))
|
||
labels = []
|
||
for kind in ("global_label", "hierarchical_label", "label"):
|
||
for lb in find(root, kind):
|
||
p = at(lb)
|
||
if p:
|
||
labels.append((p[0], p[1], lb[1]))
|
||
comps = []
|
||
for s in find(root, "symbol"):
|
||
lid = find(s, "lib_id")
|
||
pos = at(s)
|
||
if not lid or pos is None:
|
||
continue
|
||
ref = ""
|
||
for pr in find(s, "property"):
|
||
if len(pr) > 2 and pr[1] == "Reference":
|
||
ref = pr[2]
|
||
un = find(s, "unit")
|
||
u = int(un[0][1]) if un else 1
|
||
units = lib.get(lid[0][1], {})
|
||
pins = dict(units.get(0, {}))
|
||
pins.update(units.get(u, {}))
|
||
ab = {}
|
||
for n, (dx, dy) in pins.items():
|
||
rx, ry = rot_pt(dx, dy, pos[2])
|
||
ab[n] = (pos[0] + rx, pos[1] - ry)
|
||
comps.append(dict(ref=ref, pins=ab))
|
||
|
||
ds = DS()
|
||
nodes = [(px, py, "pin", "%s.%s" % (c["ref"], n)) for c in comps for n, (px, py) in c["pins"].items()]
|
||
nodes += [(x, y, "label", nm) for x, y, nm in labels]
|
||
for (x1, y1, x2, y2) in wires:
|
||
ds.union(key(x1, y1), key(x2, y2))
|
||
for w1 in wires:
|
||
for w in wires:
|
||
if w is w1:
|
||
continue
|
||
for (ex, ey) in ((w1[0], w1[1]), (w1[2], w1[3])):
|
||
if on_seg(ex, ey, *w):
|
||
ds.union(key(ex, ey), key(w[0], w[1]))
|
||
for (px, py, _, _) in nodes:
|
||
for w in wires:
|
||
if on_seg(px, py, *w):
|
||
ds.union(key(px, py), key(w[0], w[1]))
|
||
groups = {}
|
||
for (px, py, kind, nm) in nodes:
|
||
groups.setdefault(ds.find(key(px, py)), []).append((kind, nm))
|
||
|
||
def is_passive(c):
|
||
r = c["ref"]
|
||
return r and not r.startswith("#") and r.startswith(TWO_PIN_PREFIX) and len(c["pins"]) == 2
|
||
|
||
bad_a = []
|
||
for c in comps:
|
||
if not is_passive(c):
|
||
continue
|
||
ends = []
|
||
for n, (px, py) in c["pins"].items():
|
||
g = groups.get(ds.find(key(px, py)), [])
|
||
others = [m for m in g if m[0] == "pin" and not m[1].startswith(c["ref"] + ".")
|
||
and not m[1].startswith("#")]
|
||
ends.append((len(others), any(m[0] == "label" for m in g)))
|
||
if all(n == 0 and lb for n, lb in ends):
|
||
bad_a.append(c["ref"])
|
||
|
||
anchors, apts = set(), []
|
||
for c in comps:
|
||
if c["ref"] and not c["ref"].startswith("#") and not is_passive(c):
|
||
for n, pt in c["pins"].items():
|
||
anchors.add("%s.%s" % (c["ref"], n)); apts.append(pt)
|
||
du = DS()
|
||
passives = [c for c in comps if is_passive(c)]
|
||
for c in passives:
|
||
rs = [ds.find(key(px, py)) for px, py in c["pins"].values()]
|
||
du.union(rs[0], rs[1])
|
||
live = {du.find(r) for r, mem in groups.items() if any(k == "pin" and nm in anchors for k, nm in mem)}
|
||
bad_b = []
|
||
for c in passives:
|
||
ok = any(du.find(ds.find(key(px, py))) in live for px, py in c["pins"].values())
|
||
if not ok:
|
||
ok = any(math.hypot(px - ax, py - ay) <= NEAR
|
||
for px, py in c["pins"].values() for ax, ay in apts)
|
||
if not ok:
|
||
bad_b.append(c["ref"])
|
||
# C 级:三条及以上线端(或线端+引脚)汇在一点却没有接点。KiCad 电气上算连通,
|
||
# 但图面上看不出是连上的(DVBS2_dmod 第 3 轮 d20 抓到过两处)
|
||
jset = {key(float(j[1][1]), float(j[1][2])) for j in
|
||
[(x, find(x, "at")[0]) for x in find(root, "junction")]}
|
||
ends = {}
|
||
for (x1, y1, x2, y2) in wires:
|
||
for p in ((x1, y1), (x2, y2)):
|
||
ends.setdefault(key(*p), []).append(p)
|
||
pinpts = {key(px, py) for c in comps for (px, py) in c["pins"].values()}
|
||
bad_c = []
|
||
for k, pts in ends.items():
|
||
n = len(pts) + (1 if k in pinpts else 0)
|
||
mid = sum(1 for w in wires if on_seg(pts[0][0], pts[0][1], *w)
|
||
and key(w[0], w[1]) != k and key(w[2], w[3]) != k)
|
||
if n + 2 * mid >= 3 and k not in jset:
|
||
bad_c.append("(%.2f,%.2f)" % pts[0])
|
||
return len(comps), bad_a, bad_b, bad_c
|
||
|
||
|
||
def erc(root):
|
||
out = os.path.join(tempfile.mkdtemp(prefix="kf_erc_"), "erc.rpt")
|
||
subprocess.run([kicad_cli(), "sch", "erc", "--severity-error", "--severity-warning",
|
||
"-o", out, root], capture_output=True)
|
||
t = io.open(out, encoding="utf-8", errors="replace").read() if os.path.exists(out) else ""
|
||
kinds = {}
|
||
for m in re.finditer(r'\[(\w+)\]:\s*(.*)', t):
|
||
kinds.setdefault(m.group(1), []).append(m.group(2))
|
||
return t, kinds
|
||
|
||
|
||
def netlist(root):
|
||
out = os.path.join(tempfile.mkdtemp(prefix="kf_net_"), "n.xml")
|
||
subprocess.run([kicad_cli(), "sch", "export", "netlist", "--format", "kicadxml", "-o", out, root],
|
||
capture_output=True)
|
||
r = ET.parse(out).getroot()
|
||
comps = {c.get("ref"): (c.findtext("value"), c.findtext("footprint")) for c in r.iter("comp")}
|
||
nets = {}
|
||
for n in r.iter("net"):
|
||
nets[n.get("name")] = [(x.get("ref"), x.get("pin")) for x in n.iter("node")]
|
||
return comps, nets, out
|
||
|
||
|
||
def _fp_dirs(prj_dir):
|
||
"""库名 -> .pretty 目录:工程 fp-lib-table 优先,其余按 KiCad 自带库目录找。"""
|
||
from .env import kicad_footprint_dir
|
||
d = {}
|
||
t = os.path.join(prj_dir, "fp-lib-table")
|
||
if os.path.exists(t):
|
||
for m in re.finditer(r'\(name "([^"]+)"\).*?\(uri "([^"]+)"\)', io.open(t, encoding="utf-8").read()):
|
||
d[m.group(1)] = m.group(2).replace("${KIPRJMOD}", prj_dir)
|
||
return d, kicad_footprint_dir()
|
||
|
||
|
||
def fpcheck(net_xml, prj_dir):
|
||
"""每个器件:封装文件存在;符号引脚号 ⊆ 焊盘号;有编号的焊盘都有对应引脚(否则它挂不上网)。
|
||
返回问题清单。"""
|
||
r = ET.parse(net_xml).getroot()
|
||
pins = {}
|
||
for lp in r.iter("libpart"):
|
||
k = (lp.get("lib"), lp.get("part"))
|
||
pins[k] = {p.get("num") for p in lp.iter("pin")}
|
||
dirs, share = _fp_dirs(prj_dir)
|
||
bad = []
|
||
for c in r.iter("comp"):
|
||
ref, fp = c.get("ref"), c.findtext("footprint") or ""
|
||
if ref.startswith("#"):
|
||
continue
|
||
if not fp:
|
||
bad.append("%s 没有封装" % ref)
|
||
continue
|
||
lib, name = fp.split(":", 1)
|
||
base = dirs.get(lib) or os.path.join(share, lib + ".pretty")
|
||
f = os.path.join(base, name + ".kicad_mod")
|
||
if not os.path.exists(f):
|
||
bad.append("%s 的封装 %s 找不到(%s)" % (ref, fp, f))
|
||
continue
|
||
pads = set(re.findall(r'\(pad\s+"([^"]+)"', io.open(f, encoding="utf-8").read()))
|
||
ls = c.find("libsource")
|
||
sp = pins.get((ls.get("lib"), ls.get("part")), set()) if ls is not None else set()
|
||
miss = sorted(sp - pads)
|
||
extra = sorted(pads - sp)
|
||
if miss:
|
||
bad.append("%s(%s) 符号引脚在封装里没有:%s" % (ref, name, ",".join(miss)))
|
||
if extra and sp:
|
||
bad.append("%s(%s) 焊盘没有对应符号引脚:%s" % (ref, name, ",".join(extra)))
|
||
return bad
|
||
|
||
|
||
def main(argv):
|
||
root = argv[0]
|
||
expect = None
|
||
if "--expect-refs" in argv:
|
||
v = argv[argv.index("--expect-refs") + 1]
|
||
expect = set((io.open(v).read() if os.path.exists(v) else v).replace("\n", ",").split(","))
|
||
expect.discard("")
|
||
sys.stdout.reconfigure(encoding="utf-8")
|
||
rc = 0
|
||
rpt, kinds = erc(root)
|
||
print("ERC:", ", ".join("%s x%d" % (k, len(v)) for k, v in sorted(kinds.items())) or "0 条")
|
||
comps, nets, npath = netlist(root)
|
||
real = {k: v for k, v in comps.items() if not k.startswith("#")}
|
||
print("网表: 器件 %d 个,网络 %d 条 (%s)" % (len(real), len(nets), npath))
|
||
fb = fpcheck(npath, os.path.dirname(os.path.abspath(root)))
|
||
print("封装: %s" % ("全部 %d 个器件有封装且引脚/焊盘一一对应" % len(real) if not fb
|
||
else "%d 处问题" % len(fb)))
|
||
for b in fb:
|
||
print(" !! " + b)
|
||
if fb:
|
||
rc = 1
|
||
if expect is not None:
|
||
miss = sorted(expect - set(real))
|
||
if miss:
|
||
rc = 1
|
||
print(" !! 网表里缺: %s" % " ".join(miss))
|
||
d = os.path.dirname(os.path.abspath(root))
|
||
files = sorted(set(re.findall(r'\(property "Sheetfile" "([^"]+)"', io.open(root, encoding="utf-8").read())))
|
||
for f in files:
|
||
n, a, b, cj = subckt(os.path.join(d, f))
|
||
flag = "OK " if not a and not b and not cj else "!! "
|
||
if a or b or cj:
|
||
rc = 1
|
||
print("%s%-28s 元件 %3d A:两端只挂标签 %d B:没进子电路 %d C:丁字口缺接点 %d %s"
|
||
% (flag, f, n, len(a), len(b), len(cj), " ".join(sorted(set(a) | set(b)) + cj)))
|
||
return rc
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main(sys.argv[1:]))
|