206 行
7.7 KiB
Python
206 行
7.7 KiB
Python
# -*- coding: utf-8 -*-
|
||
u"""PCB 布线质量:**绕行比** —— 不用截图,把「连上了但绕了一圈」判出来。
|
||
|
||
/c/Python314/python .claude/pcb_detour.py # 当前打开的那一页
|
||
/c/Python314/python .claude/pcb_detour.py --pcb PCB1 # 先打开再看
|
||
/c/Python314/python .claude/pcb_detour.py --net SDA SCL # 只看这几个
|
||
/c/Python314/python .claude/pcb_detour.py --top 30 # 最差的 30 个
|
||
/c/Python314/python .claude/pcb_detour.py --json out.json
|
||
|
||
## 判据
|
||
|
||
绕行比 = 实际走线总长 / 该网络焊盘的**最小生成树**长度
|
||
|
||
分母用 MST 而不是「最大两两跨距」:跨距只在 2 脚网络上是对的,
|
||
3 脚以上网络本来就需要比跨距更多的铜,用跨距会把正常走线全判成绕远路。
|
||
2 脚时 MST 恰好等于跨距,跟外部来源的原始判据一致
|
||
(见 `easyeda-sch-geometry` skill §9af,来自 v0id-byte/easyeda-pro-claude-skill)。
|
||
|
||
1.0 ~ 1.5 直连,正常
|
||
1.5 ~ 2.0 偏绕,看一眼
|
||
> 2.0 **绕远路**,八成能重画短一半
|
||
|
||
⚠️ 长度本身没有判别力(跨距大的网络本来就长),要判的是**路径**不是长度。
|
||
|
||
## 不适用的网络
|
||
|
||
铺铜承载的网络(GND / 电源)走线极少甚至没有,绕行比无意义 —— 默认跳过,
|
||
`--include-poured` 可以强制看。单焊盘网络、没有走线的网络单独列。
|
||
"""
|
||
from __future__ import print_function
|
||
|
||
import argparse
|
||
import json
|
||
import math
|
||
import os
|
||
import sys
|
||
|
||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||
sys.path.insert(0, HERE)
|
||
|
||
from eda import run # noqa: E402
|
||
|
||
COPPER = (1, 2, 15, 16) # Top / Bottom / Inner1 / Inner2(skill §10 层号)
|
||
|
||
# 铺铜承载、不按走线判的网络名(前缀匹配,大小写不敏感)
|
||
POURED_PREFIX = ('GND', 'AGND', 'PGND', 'VBAT', 'VBUS', 'VIN', 'VCC', 'VDD',
|
||
'+12V', '12V', 'VSYS', 'VO', 'PWR')
|
||
|
||
JS_OPEN = u"await eda.dmt_EditorControl.openDocument('%s');" \
|
||
u"await new Promise(r => setTimeout(r, 1200));"
|
||
|
||
JS_FETCH = u"""
|
||
const M = 39.3701, q = v => Math.round(v / M * 1e4) / 1e4; // mil -> mm
|
||
const lines = (await eda.pcb_PrimitiveLine.getAll())
|
||
.filter(l => [1,2,15,16].includes(l.layer))
|
||
.map(l => ({net: l.net || '', layer: l.layer,
|
||
x1: q(l.startX), y1: q(l.startY), x2: q(l.endX), y2: q(l.endY)}));
|
||
const vias = (await eda.pcb_PrimitiveVia.getAll())
|
||
.map(v => ({net: v.net || '', x: q(v.x), y: q(v.y)}));
|
||
const pads = (await eda.pcb_PrimitivePad.getAll())
|
||
.map(p => ({net: p.net || '', x: q(p.x), y: q(p.y)}));
|
||
return {lines, vias, pads};
|
||
"""
|
||
|
||
|
||
def dist(a, b):
|
||
return math.hypot(a[0] - b[0], a[1] - b[1])
|
||
|
||
|
||
def mst_length(pts):
|
||
u"""Prim。返回最小生成树总长;1 个点回 0,2 个点回两点距离。"""
|
||
n = len(pts)
|
||
if n < 2:
|
||
return 0.0
|
||
inside = [False] * n
|
||
best = [float('inf')] * n
|
||
best[0] = 0.0
|
||
total = 0.0
|
||
for _ in range(n):
|
||
k = min((i for i in range(n) if not inside[i]), key=lambda i: best[i])
|
||
inside[k] = True
|
||
total += best[k]
|
||
for j in range(n):
|
||
if not inside[j]:
|
||
d = dist(pts[k], pts[j])
|
||
if d < best[j]:
|
||
best[j] = d
|
||
return total
|
||
|
||
|
||
def is_poured(net):
|
||
u = net.upper()
|
||
return any(u.startswith(p) for p in POURED_PREFIX)
|
||
|
||
|
||
def main():
|
||
ap = argparse.ArgumentParser()
|
||
ap.add_argument('--pcb', help=u'先 openDocument 这一页;不给就用当前打开的')
|
||
ap.add_argument('--net', nargs='*', help=u'只看这几个网络')
|
||
ap.add_argument('--top', type=int, default=20)
|
||
ap.add_argument('--warn', type=float, default=1.5)
|
||
ap.add_argument('--fail', type=float, default=2.0)
|
||
ap.add_argument('--include-poured', action='store_true')
|
||
ap.add_argument('--json')
|
||
a = ap.parse_args()
|
||
|
||
js = (JS_OPEN % a.pcb if a.pcb else u'') + JS_FETCH
|
||
r = run(js, timeout=300)
|
||
if not r.get('success'):
|
||
print(u'桥接调用失败:', json.dumps(r, ensure_ascii=False)[:400])
|
||
return 2
|
||
d = r['result']
|
||
lines, vias, pads = d['lines'], d['vias'], d['pads']
|
||
|
||
# ⚠️ skill §9ae:关掉再打开 PCB 标签页会让桥接失去同步,API 读回 0 图元
|
||
# 而画面上明明有线。0 走线时先怀疑桥接,不要当成空板。
|
||
if not lines:
|
||
print(u'⚠️ 一条走线都没读到。')
|
||
print(u' 如果板子上明明有线 —— 是桥接失去同步(skill §9ae),')
|
||
print(u' 去 EDA 里 `API Gateway → 重新连接`,别当成空板继续。')
|
||
return 2
|
||
|
||
by_net = {}
|
||
for l in lines:
|
||
if not l['net']:
|
||
continue
|
||
e = by_net.setdefault(l['net'], {'len': 0.0, 'pads': [], 'vias': 0})
|
||
e['len'] += math.hypot(l['x2'] - l['x1'], l['y2'] - l['y1'])
|
||
for p in pads:
|
||
if p['net'] in by_net or p['net']:
|
||
by_net.setdefault(p['net'], {'len': 0.0, 'pads': [], 'vias': 0})
|
||
by_net[p['net']]['pads'].append((p['x'], p['y']))
|
||
for v in vias:
|
||
if v['net'] in by_net:
|
||
by_net[v['net']]['vias'] += 1
|
||
|
||
want = set(a.net) if a.net else None
|
||
rows, skipped, unrouted = [], [], []
|
||
for net, e in by_net.items():
|
||
if want and net not in want:
|
||
continue
|
||
if not want and is_poured(net) and not a.include_poured:
|
||
skipped.append(net)
|
||
continue
|
||
pts = e['pads']
|
||
if len(pts) < 2:
|
||
continue
|
||
base = mst_length(pts)
|
||
if base < 1e-6:
|
||
continue
|
||
if e['len'] < 1e-6:
|
||
unrouted.append((net, len(pts), base))
|
||
continue
|
||
rows.append({'net': net, 'ratio': e['len'] / base, 'len_mm': e['len'],
|
||
'mst_mm': base, 'pads': len(pts), 'vias': e['vias']})
|
||
|
||
rows.sort(key=lambda x: -x['ratio'])
|
||
|
||
print(u'读到 %d 条走线 / %d 个过孔 / %d 个焊盘,参与判定的网络 %d 个'
|
||
% (len(lines), len(vias), len(pads), len(rows)))
|
||
if skipped:
|
||
print(u'跳过铺铜承载的网络 %d 个(%s…)—— 绕行比对它们没意义,'
|
||
u'要看加 --include-poured' % (len(skipped), ', '.join(sorted(skipped)[:4])))
|
||
print()
|
||
|
||
print(u' %-22s %7s %9s %9s %5s %5s %s'
|
||
% (u'网络', u'绕行比', u'走线mm', u'MST mm', u'焊盘', u'过孔', u'判定'))
|
||
print(u' ' + u'-' * 76)
|
||
n_fail = n_warn = 0
|
||
for x in rows[:a.top]:
|
||
if x['ratio'] >= a.fail:
|
||
tag, n_fail = u'← 绕远路', n_fail + 1
|
||
elif x['ratio'] >= a.warn:
|
||
tag, n_warn = u' 偏绕', n_warn + 1
|
||
else:
|
||
tag = u''
|
||
print(u' %-22s %7.2f %9.1f %9.1f %5d %5d %s'
|
||
% (x['net'][:22], x['ratio'], x['len_mm'], x['mst_mm'],
|
||
x['pads'], x['vias'], tag))
|
||
for x in rows[a.top:]:
|
||
if x['ratio'] >= a.fail:
|
||
n_fail += 1
|
||
elif x['ratio'] >= a.warn:
|
||
n_warn += 1
|
||
|
||
if unrouted:
|
||
print(u'\n 未布线(有焊盘无走线)%d 个:%s'
|
||
% (len(unrouted), ', '.join(n for n, _, _ in sorted(unrouted)[:12])))
|
||
|
||
print(u'\n合计:绕远路(≥%.1f) %d 个,偏绕(≥%.1f) %d 个,其余 %d 个正常。'
|
||
% (a.fail, n_fail, a.warn, n_warn, len(rows) - n_fail - n_warn))
|
||
if n_fail:
|
||
print(u'⚠️ 绕行比 ≥ %.1f 的通常能重画短一半 —— 先看过孔数最多的那几个。' % a.fail)
|
||
|
||
if a.json:
|
||
with open(a.json, 'w') as f:
|
||
json.dump({'rows': rows, 'unrouted': unrouted, 'skipped': skipped},
|
||
f, ensure_ascii=False, indent=1)
|
||
print(u'明细已写 %s' % a.json)
|
||
|
||
return 1 if n_fail else 0
|
||
|
||
|
||
if __name__ == '__main__':
|
||
sys.exit(main())
|