"""原理图验收: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:]))