文件

206 行
7.7 KiB
Python

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
# -*- 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())