初始导入:PhoneRail 五路手机直供电源板

原理图/PCB 已在 EasyEDA 里做到 12 页跨页 0 漏接,固件刚完成骨架包 A1。
这次是把已有的一切一次性纳入版本控制,没有改动任何设计内容。

纳入:
- 设计与需求文档、PROGRESS.md、小米8 参考原理图 PDF 与追脚脚本
- .claude/ 下的 EasyEDA 桥接工具链与几何/网表缓存(eda-parts.json、
  pcb-parts.json、pcb-pads.json、p2-ch1.json 等,是重画各页的唯一事实来源)
- 117 张验证截图(本项目所有方向问题都是靠人眼看截图发现的,属于证据不是垃圾)
- firmware/ 的 A1 骨架与 site/ 文档站源码

.gitignore 排除:构建产物(firmware/build、host/build、sdkconfig)、
site/node_modules、__pycache__、桥接日志 _bridge.log、脚本自标的 *.stale。

.gitattributes 默认 `* -text`:现有 .py/.json 大多是 CRLF,原样存,
不让 git 重写行尾。唯一例外是 *.sh 强制 LF —— 带 \r 的 shell 脚本 bash 跑不了。

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QYuExfgyYa6DzRRcUso8M8
这个提交包含在:
zhou yang
2026-09-06 18:18:14 +08:00
共同撰写人 Claude Opus 5
当前提交 2bfe308b30
共修改 526 个文件,包含 80633 行新增和 0 行删除
+706
查看文件
@@ -0,0 +1,706 @@
# -*- coding: utf-8 -*-
u"""下板 PCB1 五路功率级的**手工布线** —— §10.2 那四条关键回路的事实来源。
/c/Python314/python .claude/pcb1_route_pwr.py # 计划 + 全部校核
/c/Python314/python .claude/pcb1_route_pwr.py --push # 校核跑绿才下发,完事自动回读
/c/Python314/python .claude/pcb1_route_pwr.py --verify # 只按板上实测铜再核一遍
/c/Python314/python .claude/pcb1_route_pwr.py --clean --push # 先删掉上一版再重画
/c/Python314/python .claude/pcb1_route_pwr.py --ch 1 # 只看某一路(调试用)
## 为什么这几条不交给栅格布线器
§10.2 的四条:热回路面积、SW 铜箔面积、FB 短且远离 SW、分流电阻的 Kelvin 四线 ——
**每一条的判据都不是「连通」**,栅格布线器优化的是长度和过孔数,
它给出的解可以完全连通而四条全不满足。所以这四条按母版手写坐标,
剩下的细信号(EN 分压、补偿、PG、按键驱动、eFuse 编程电阻)留给后面的信号布线。
## 坐标系
路径全部写在 **CH1 母版的局部坐标**里(和 `pcb1_place.CH1` 同一套),
再按 `pcb1_place.CH_PLACE` 仿射到五路:rot 0 是平移,rot 180 是 `(bx-lx, by-ly)`。
⇒ **改一处等于五路一起改**,这也是 §26.3「不做 y 镜像,只转 180°」的用处:
母版里手调出来的关键回路原样保留。
## 网络名一律反查,不写死
四条主干里有三条挂在自动命名网上(SW=`$2N665`、eFuse 输出=`$2N699`、BOOT=`$2N667`),
而且**每一路的编号都不一样**(CH1 是 `$2N…`、CH2 是 `$8N…`、CH4 是 `$12N…`)。
写死等于静默失效(`pcb1_rules.py` 文件头第 4 条踩过)。
这里按 `(位号模板, 引脚号)` 到 `pcb1-parts.json` 里现查。
## 校核(下发前后走同一段代码)
1. 每条路径至少落在一个**本网焊盘**里,并列出它实际盖住了哪些焊盘
2. 逐段「胶囊 vs 轴对齐框」净距 ≥ 0.2 mm:异网焊盘 / 异网过孔 / 同层异网走线 / 本轮其它异网段
⚠️ **线的端点不是铜的端点**:圆头端帽往外再探半个线宽(§26.11 那一条),
所以这里一律按胶囊算,不按线段算。
3. 拐角角度:< 90° 的尖角报出来(EasyEDA 的 45° 化只处理直角)
4. 载流:按 IPC-2221 外层 1 oz 反算每一段的 ΔT,超过 30 °C 的报出来
"""
import io
import json
import math
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import pcb1_place as PL # noqa: E402
from eda import run # noqa: E402
PCB = '7e9415a16a909c51'
MIL = PL.MIL
L_TOP, L_BOT = 1, 2
CLR = 0.2 # pcb1_rules.MIN_CLEARANCE
VIA_R = 0.305 # 过孔铜箔半径(外径 0.61)
EPS = 1e-6
SNAP = os.path.join(HERE, '_pcb1_place_now.json')
# ══════════════════ 母版路径(CH1 局部坐标,mm)══════════════════
# net: ('pad', 位号模板, 引脚号) —— 到 pcb1-parts.json 里按这一路的位号反查网名
# pts: [(x, y), ...];w 是整条路径的线宽(要变宽就拆成两条)
R = []
def add(name, net, w, pts, i=0.0, layer=L_TOP, why=u''):
u"""i = 这一段实际要过多少电流(A),0 = 细信号不算温升。"""
R.append(dict(name=name, net=net, w=w, pts=pts, i=i, layer=layer, why=why))
# ── ① 热回路:+12V 走顶层,地在正下方的 Inner1 整平面上回流 ────────────
# §10.2-1「2 颗 10 µF MLCC 必须同层紧贴 IC 的 VIN/GND 引脚」。
# ⚠️ **地不另画顶层线**:Inner1 就在顶层下面 0.2104 mm,回流电流是镜像电流,
# 它贴着顶层这条 +12V 走,回路面积 = 走线长 x 0.21 mm。
# 要是让 +12V 下到 Inner2(L3)再回来,回路高度变成 L2-L3 的 1.13 mm,**差 5 倍**。
_12V = ('pad', 'C%01', '1')
add('12V-C101->U1', _12V, 1.0, [(4.001, 10.299), (6.600, 11.000)], i=1.6,
why=u'C%01 的 +12V 脚直接上 U%1 的 PVIN(p6),全程顶层')
add('12V-U1-tie', _12V, 0.4, [(6.600, 11.000), (6.600, 11.950)], i=1.1,
why=u'把 PVIN 三个脚 p6/p5/p4 串起来;北端停在 11.95,'
u'离 p3(GND, 底 12.361) 还有 0.211')
# ⚠️ C%02 / C%03 到 U%1 的顶层直路是**堵死的**:C%01 的 GND 扇出孔 (5.475,12.300)
# 和 U%1.2 的 (5.922,13.354) 把那条 1.7 mm 宽的走廊切成 0.148 mm。
# ⇒ 从西边绕一条母线,把三颗罐的 +12V 脚在顶层并成一个节点再一起进 U%1。
add('12V-bus', _12V, 0.8, [(2.550, 8.701), (2.550, 13.949)], i=1.1,
why=u'西侧 +12V 母线:C%03 / C%01 / C%02 三颗的 +12V 脚并到一起')
add('12V-bus-C103', _12V, 0.8, [(2.550, 8.701), (4.001, 8.701)], i=0.6, why=u'母线 -> C%03.2')
add('12V-bus-C101', _12V, 0.8, [(2.550, 10.299), (4.001, 10.299)], i=1.1, why=u'母线 -> C%01.1')
add('12V-bus-C102', _12V, 0.8, [(2.550, 13.949), (4.001, 13.949)], i=0.6, why=u'母线 -> C%02.1')
# ── ② SW 节点:面积压到刚好够载流(§10.2-2)────────────────────────────
# ⚠️ U%1 的 SW 脚(p11/p12)夹在 p10(EN) 与 p13(BOOT) 中间,0.5 mm 间距,
# 只能先用窄线往东出 1 mm,出了焊盘区再放宽。
# ⚠️ 窄到 **0.30 就到底了**,不是 0.28:`pcb1_rules.TRACK_RULES` 里
# SWNODE / PWR4A 的 `strokeWidthMin` 都是 0.30(= 11.8 mil)。
# 先按 0.28 下发过一次,DRC 立刻报 50 条**物理错误**「线宽 11mil,应该 ≥ 11.8mil」——
# 间距错误一条没有、连接性也降了,光看那两支会以为全过。
# 0.30 的线从 0.28 高的焊盘出来,上下各留 0.21,正好还够。
_SW = ('pad', 'L%01', '1')
add('SW-p11', _SW, 0.30, [(10.224, 12.001), (11.200, 12.001)], i=2.0, why=u'U%1.11 出线(短桩)')
# ⚠️ p12 的短桩往东多走 0.3(11.200 -> 11.500):`SW-run` 那条 1.2 宽的线,
# 西端圆头端帽半径 0.6,起点放 11.200 时端帽离 **U%1.13(BOOT) 焊盘**只有 0.080。
# 起点东移到 11.500 之后是 0.347。短桩本身 0.30 宽、离 BOOT 焊盘 0.21,没变。
add('SW-p12', _SW, 0.30, [(10.224, 12.501), (11.500, 12.501)], i=2.0, why=u'U%1.12 出线(短桩)')
add('SW-tie', _SW, 0.30, [(11.200, 12.001), (11.200, 12.501)], i=2.0, why=u'两个 SW 脚并起来(短桩)')
# ⚠️⚠️ **SW 不再往南潜了(2026-09-06 第二十一轮)。** 老版本是
# `SW-down (11.200,12.250)->(11.750,11.600)` + `SW-run` 东行再 45° 抬回来,
# 目的是从 C%05.1(BOOT) 焊盘**下面**绕过去(那时它的焊盘底在 12.732,缝只有 0.581)。
# 代价是那一潜正好压在 **p9(COMP) / p10(EN) 唯一的出线走廊**上 ——
# 1.5 mm 宽的 `SW-run` 西端圆头端帽在 y = 11.5 那一行往西探到 x 11.007,
# 而两个脚要并排出来至少需要到 x 11.62。⇒ 两个脚只能活一个,`EN` 五路全布不通
# (`pcb1_escape_audit.py` 实测:两层加起来只能走 0.28 mm²)。
# ⇒ **把 C%05 北移 0.6 mm**(见 `pcb1_place.CH1` 里那一段),BOOT 焊盘底变 13.332,
# SW 就能平着走 y 12.500(1.2 宽,顶 13.10,离 BOOT 焊盘 0.232)。
# y < 11.9 整片空出来:COMP 走 y 11.001、EN 走 y 11.501,各走自己那一行。
# 顺带:SW 铜面积 10.9 -> 9.5 mm²、节点长度 9.6 -> 8.9 mm,**§10.2-2 要的就是小**。
# ⚠️ 立管 x 13.500 宽 1.50 ⇒ 铜跨 12.75~14.25,**顺路盖住 C%05.2(SW) 的焊盘**
# (12.570~13.070),所以不用再单画一段去接自举电容。
add('SW-run', _SW, 1.20, [(11.500, 12.500), (13.500, 12.500)], i=4.0,
why=u'东行段:平着走 p12 那一行,从 C%05.1(BOOT,底 13.332) 下面过,净距 0.232')
add('SW-riser', _SW, 1.50, [(13.500, 12.500), (13.500, 16.600)], i=4.0,
why=u'立管直上进 L%01 的 SW 脚(焊盘 12.501~14.501 / 16.301~19.701),'
u'顺路盖住 C%05 的 SW 脚 (12.820,13.001)')
# ── BOOT:自举电容,贴着 p13 平着往东 1.8 mm ───────────────────────────
_BOOT = ('pad', 'C%05', '1')
# 警告:BOOT 不能和 SW 走同一条水平缝。原来两条都在 y≈12.5~13.0 那一带,
# SW-down 放宽到 1.0 之后圆头端帽顶到 12.75,离 BOOT 只剩 0.101。
# 改成 BOOT 先往**北**抬到 13.75(U%1 北边是空的,p14 在 x<=9.391),
# 平着过去再往南扎进 C%05.1,两条从此不共用任何一条缝。
# C%05 北移 0.6 之后 C%05.1 的焊盘是 y 13.332~13.872,(11.979,13.750) 直接落在里面,
# 不用再往南扎一段。
add('BOOT', _BOOT, 0.30, [(10.224, 13.001), (10.900, 13.750), (11.979, 13.750)],
why=u'U%1.13 -> C%05.1,从 U%1 北边绕过去;终点落在焊盘 (13.332~13.872) 正中')
# ── ③ VOn:电感 -> 三颗输出电容 -> eFuse 输入 ─────────────────────────
_VO = ('pad', 'L%01', '2')
add('VO-up', _VO, 1.50, [(7.900, 18.400), (7.900, 19.400)], i=4.0, why=u'L%01.2 焊盘内往北')
add('VO-diag', _VO, 1.50, [(7.900, 19.400), (6.100, 21.200), (5.200, 22.100)], i=4.0,
why=u'两段 45° 转到 x=5.200 这一列')
add('VO-col', _VO, 1.35, [(5.200, 22.100), (5.200, 28.400)], i=4.0,
why=u'VO 立列:正好穿过 C%07.1 / C%06.1 的焊盘,北端进 U%14 的 p11/p12/p13')
add('VO-C108', _VO, 0.50, [(5.200, 22.700), (7.979, 22.501)], i=0.5,
why=u'支到 C%08.1(高频去耦,只过纹波)')
add('VO-U14w', _VO, 0.30, [(5.200, 28.400), (4.300, 28.400), (4.001, 28.700),
(4.001, 30.000)], i=1.0,
why=u'补 U%14 西列的两个 VO 脚 p10/p9;绕开散热盘西缘 4.700')
# ── ④ eFuse 输出 $2N699 -> 分流电阻上游 ───────────────────────────────
_F = ('pad', 'Rs%01', '2')
add('F-row', _F, 1.00, [(5.399, 31.840), (3.650, 31.840)], i=2.5,
why=u'U%14 北排三个输出脚;东端停在 5.550,离 p3($2N698, 西 6.099) 0.249')
add('F-p78', _F, 0.60, [(4.001, 30.750), (4.001, 31.840)], i=1.5,
why=u'补西列的 p8/p7;南端停在 30.650,离 p9(VO, 顶 30.200) 0.25')
add('F-corner', _F, 1.20, [(3.650, 31.840), (3.050, 32.440)], i=4.0, why=u'45° 转北')
# ⚠️ 北端 39.300 -> 40.100:Rs%01 北移 0.8(见 pcb1_place 里那一段)。
add('F-trunk', _F, 1.20, [(3.050, 32.440), (3.050, 40.100)], i=4.0,
why=u'主干北上,走 U%8 西边;离 U%8.1 的 GND 扇出孔 (4.279,34.696) 0.324')
# ── ⑤ Kelvin 四线(§10.2-4)──────────────────────────────────────────
# ⚠️ 两条取样线**并排往北走 1.7 mm 再分开**,这是这一轮把 Rs%01 转 180° 换来的:
# 转之前「西 = VO_P / 东 = $2N699」而 INA 的 IN+ 在西、IN− 在东,两条必然交叉。
# ⚠️ IN− 那条必须从**电阻本体下面**(两个焊盘之间)横过去,而且要绕到
# U%8.7 的 GND 扇出孔 (6.500,39.732) **北边** —— 它南边那条缝只有 0.208 mm。
# ⚠️ 这两条整体 +0.8(U%8 和 Rs%01 一起北移,相对关系一格没变)。
add('K-IN+', _F, 0.30, [(4.960, 38.409), (4.960, 39.900), (4.760, 40.100),
(3.300, 40.100)],
why=u'U%8.10(IN+) -> Rs%01.2 的焊盘。取样点在焊盘上,不在主干上')
_VOP = ('pad', 'Rs%01', '1')
add('K-IN-', _VOP, 0.30, [(5.500, 38.409), (5.500, 40.300), (5.700, 40.500),
(8.600, 40.500)],
why=u'U%8.9(IN−) -> Rs%01.1 的焊盘。**U%8.9 没有下到 VO_P 小岛的过孔**'
u'(pcb1_pour.ROUTED_PADS 里挡掉的),不然取样点会落到岛上')
# ── ⑥ FB:短、且远离 SW(§10.2-3)────────────────────────────────────
_FB = ('pad', 'R%01', '2')
# ⚠️ 不能从 x≈6.07 直下:那条线正压在 R%08.1 ($2N655) 的焊盘上;
# 往西绕也不行 —— C%03 的东缘 4.676 到它自己那个 GND 扇出孔的西缘 5.170
# 只剩 0.494 mm 的缝,0.25 的线要 0.65。⇒ 走 R%08 **东边**,到 y≈4.5 再往西折。
add('FB-U1', _FB, 0.25, [(7.750, 10.276), (7.750, 4.500), (7.150, 3.900),
(6.066, 3.900), (6.066, 3.001)],
why=u'U%1.7(FB) 往南出,贴着散热盘南爪西边 0.224 下来,全程在 SW 的**反面**')
add('FB-R101', _FB, 0.25, [(6.066, 3.001), (3.432, 3.001)], why=u'R%07.1 -> R%01.2')
add('FB-R102', _FB, 0.20, [(3.432, 3.001), (3.432, 4.000), (2.566, 4.866),
(2.566, 5.201)],
why=u'R%01.2 -> R%02.1,绕开 R%01.1(VO) 和 R%02.2(GND)')
# ══════════════════ 几何 ══════════════════
def parts():
return {c['d']: c for c in json.load(
io.open(os.path.join(HERE, 'pcb1-parts.json'), encoding='utf-8'))}
PARTS = parts()
def net_of(spec, n):
u"""('pad', 位号模板, 引脚号) -> 这一路上那个焊盘的网名(现查,不写死)。"""
_, tok, pin = spec
des = PL.ch_designator(tok, n)
p = PARTS.get(des)
if not p:
sys.exit(u'反查网名失败:没有位号 %s' % des)
for num, _lx, _ly, _w, _h, net in p['pads']:
if str(num) == pin:
if not net:
sys.exit(u'反查网名失败:%s.%s 没有网络' % (des, pin))
return net, '%s.%s' % (des, pin)
sys.exit(u'反查网名失败:%s 没有引脚 %s' % (des, pin))
def xf(n, x, y):
u"""母版局部坐标 -> 板坐标。rot 180 的两路是纯旋转,不是镜像(§26.3)。"""
rot, bx, by = PL.CH_PLACE[n]
return (bx + x, by + y) if rot == 0 else (bx - x, by - y)
def board_paths(only=None):
u"""展开成板上的实际路径 [(名字, 网名, 层, 线宽, [(x,y)...], 通道, 为什么)]。"""
out = []
for n in sorted(PL.CH_PLACE):
if only and n != only:
continue
for r in R:
net, anchor = net_of(r['net'], n)
pts = [xf(n, x, y) for x, y in r['pts']]
out.append(dict(name='%s@CH%d' % (r['name'], n), net=net, layer=r['layer'],
w=r['w'], pts=pts, ch=n, anchor=anchor, i=r['i'],
why=r['why'].replace('%', str(n))))
return out
COPPER = os.path.join(HERE, 'pcb-copper.json')
def poly_pads():
u"""板上 POLYGON 焊盘的**真实轮廓**(板坐标 mm),键是 `位号.引脚`。
⚠️ 非要真轮廓不可:U%1 的散热盘是个**十字** —— 中间 2.03 x 2.03 的方块,
上下各两根 0.24 mm 宽的指头伸到 ±2.16。按包围盒算的话,
FB 那条从 U%1.7 往南出的线(x 7.75)会被判成压在散热盘上,
而实际上它离最近的那根指头还有 0.224 mm。反过来也一样危险:
包围盒宽出来的那一圈会**掩盖**真正贴到指头上的走线。
"""
d = json.load(io.open(COPPER, encoding='utf-8'))
out = {}
for q in d['pads']:
sh = q.get('shape')
if not sh or sh[0] != 'POLYGON' or not q.get('des'):
continue
pts = [v for v in sh[1] if isinstance(v, (int, float))]
out['%s.%s' % (q['des'], q['num'])] = list(zip(
[v / MIL for v in pts[0::2]], [v / MIL for v in pts[1::2]]))
return out
POLY = poly_pads()
def all_pads():
u"""板上全部焊盘 [(x0,y0,x1,y1,net,tag,poly_or_None)]。"""
out = []
for d, (x, y, rot) in PL.POS.items():
a = math.radians(rot)
ct, st = math.cos(a), math.sin(a)
p = PARTS.get(d)
if not p:
continue
for num, lx, ly, w, h, net in p['pads']:
lx, ly, w, h = lx / MIL, ly / MIL, w / MIL, h / MIL
if rot in (90, 270):
w, h = h, w
px, py = x + lx * ct - ly * st, y + lx * st + ly * ct
tag = '%s.%s' % (d, num)
out.append((px - w / 2.0, py - h / 2.0, px + w / 2.0, py + h / 2.0,
net or '', tag, POLY.get(tag)))
return out
def seg_poly_dist(ax, ay, bx, by, poly):
u"""线段到多边形的最短距离(端点在里面 -> 0)。"""
def inside(px, py):
c = False
n = len(poly)
for i in range(n):
x1, y1 = poly[i]
x2, y2 = poly[(i + 1) % n]
if (y1 > py) != (y2 > py) and px < (x2 - x1) * (py - y1) / (y2 - y1) + x1:
c = not c
return c
if inside(ax, ay) or inside(bx, by):
return 0.0
d = 1e9
n = len(poly)
for i in range(n):
x1, y1 = poly[i]
x2, y2 = poly[(i + 1) % n]
d = min(d, seg_seg_dist(ax, ay, bx, by, x1, y1, x2, y2))
return d
def seg_pad_dist(ax, ay, bx, by, pad):
poly = pad[6]
if poly:
return seg_poly_dist(ax, ay, bx, by, poly)
return seg_box_dist(ax, ay, bx, by, pad[:4])
def snapshot(refresh=False):
if refresh or not os.path.exists(SNAP):
import pcb1_pour as P
return P.snapshot(refresh=True)
return json.load(io.open(SNAP, encoding='utf-8'))
def seg_box_dist(ax, ay, bx, by, b):
u"""线段到轴对齐框的最短距离(0 = 相交)。"""
x0, y0, x1, y1 = b
def pt(px, py):
dx = max(x0 - px, 0.0, px - x1)
dy = max(y0 - py, 0.0, py - y1)
return math.hypot(dx, dy)
# 端点到框 + 框的四条边到线段
d = min(pt(ax, ay), pt(bx, by))
for cx, cy, dx, dy in ((x0, y0, x1, y0), (x1, y0, x1, y1),
(x1, y1, x0, y1), (x0, y1, x0, y0)):
d = min(d, seg_seg_dist(ax, ay, bx, by, cx, cy, dx, dy))
return d
def seg_seg_dist(ax, ay, bx, by, cx, cy, dx, dy):
def d_pt_seg(px, py, x1, y1, x2, y2):
vx, vy = x2 - x1, y2 - y1
L = vx * vx + vy * vy
t = 0.0 if L == 0 else max(0.0, min(1.0, ((px - x1) * vx + (py - y1) * vy) / L))
return math.hypot(px - (x1 + t * vx), py - (y1 + t * vy))
def cr(ox, oy, px, py, qx, qy):
return (px - ox) * (qy - oy) - (py - oy) * (qx - ox)
d1 = cr(ax, ay, bx, by, cx, cy)
d2 = cr(ax, ay, bx, by, dx, dy)
d3 = cr(cx, cy, dx, dy, ax, ay)
d4 = cr(cx, cy, dx, dy, bx, by)
if d1 * d2 < 0 and d3 * d4 < 0:
return 0.0
return min(d_pt_seg(ax, ay, cx, cy, dx, dy), d_pt_seg(bx, by, cx, cy, dx, dy),
d_pt_seg(cx, cy, ax, ay, bx, by), d_pt_seg(dx, dy, ax, ay, bx, by))
def amps(w_mm, dT=20.0):
u"""外层 1 oz 的载流(IPC-2221,常数 0.048 / 铜厚 1.37 mil)。"""
A = w_mm / 0.0254 * 1.37
return 0.048 * (dT ** 0.44) * (A ** 0.725)
def dT_for(w_mm, i_amp):
a20 = amps(w_mm, 20.0)
return 20.0 * (i_amp / a20) ** (1.0 / 0.44) if a20 > 0 else 999.0
# 短于这个长度的段不查温升。IPC-2221 那条式子假定的是**长而均匀**的导体,
# 而 QFN 0.5 mm 间距的脚只能用 0.28 的窄线出 1 mm —— 两端都焊在大铜上,
# 拿它去套公式会算出 265 °C 这种没有物理意义的数。真实做法就是这样出线。
STUB_LEN = 2.0
DT_LIMIT = 30.0
def check(paths, refresh=False, use_board=False):
u"""下发前 / 下发后共用的一整套校核。返回 (错误列表, 统计)。"""
errs, notes = [], []
pads = all_pads()
snap = snapshot(refresh)
vias = [(v[0] or '', v[1], v[2], (v[3] or 0.61) / 2.0) for v in snap.get('via', ())]
lines = [(l[0] or '', l[1], l[2], l[3], l[4], l[5], l[6] or 0.0)
for l in snap.get('line', ())]
# 1) 落脚 + 组内连通。
# 警告:判据是**按 (通道, 网络) 分组**算的,不是每条路径各自算。
# 像 SW-tie / 12V-bus 这种中间段本来就不碰焊盘,逐条查会刷一屏假错误;
# 真正要保证的是「这一组铜连成一片,而且落在本网的焊盘上」。
for p in paths:
hit = []
for pad in pads:
if pad[4] != p['net']:
continue
for i in range(len(p['pts']) - 1):
ax, ay = p['pts'][i]
bx, by = p['pts'][i + 1]
if seg_pad_dist(ax, ay, bx, by, pad) < p['w'] / 2.0 - EPS:
hit.append(pad[5])
break
p['hit'] = sorted(set(hit))
groups = {}
for p in paths:
groups.setdefault((p['ch'], p['net']), []).append(p)
for (ch, net), g in sorted(groups.items()):
if not any(p['hit'] for p in g):
errs.append(u'CH%d %-9s 整组一个本网焊盘都没盖到' % (ch, net))
par = list(range(len(g)))
def find(i):
while par[i] != i:
par[i] = par[par[i]]
i = par[i]
return i
for i in range(len(g)):
for j in range(i + 1, len(g)):
touch = False
for a in range(len(g[i]['pts']) - 1):
for b in range(len(g[j]['pts']) - 1):
d = seg_seg_dist(g[i]['pts'][a][0], g[i]['pts'][a][1],
g[i]['pts'][a + 1][0], g[i]['pts'][a + 1][1],
g[j]['pts'][b][0], g[j]['pts'][b][1],
g[j]['pts'][b + 1][0], g[j]['pts'][b + 1][1])
if d < (g[i]['w'] + g[j]['w']) / 2.0 - EPS:
touch = True
break
if touch:
break
if touch:
par[find(i)] = find(j)
for i in range(len(g)):
for j in range(i + 1, len(g)):
if set(g[i]['hit']) & set(g[j]['hit']):
par[find(i)] = find(j)
ncomp = len(set(find(i) for i in range(len(g))))
if ncomp > 1:
errs.append(u'CH%d %-9s 这一组铜断成 %d 块(%s)'
% (ch, net, ncomp,
' '.join(p['name'].split('@')[0] for p in g)))
# 2) 净距
def seg_clear(p, ax, ay, bx, by):
m = p['w'] / 2.0 + CLR
bad = []
for pad in pads:
if pad[4] == p['net']:
continue
d = seg_pad_dist(ax, ay, bx, by, pad) - p['w'] / 2.0
if d < CLR - 1e-4:
bad.append((d, u'焊盘 %s(%s)' % (pad[5], pad[4] or '-')))
for net, vx, vy, vr in vias:
if net == p['net']:
continue
d = seg_box_dist(ax, ay, bx, by, (vx - vr, vy - vr, vx + vr, vy + vr)) \
- p['w'] / 2.0
if d < CLR - 1e-4:
bad.append((d, u'过孔 %s @(%.2f,%.2f)' % (net or '-', vx, vy)))
for net, lay, lx1, ly1, lx2, ly2, lw in lines:
if net == p['net'] or lay != p['layer']:
continue
d = seg_seg_dist(ax, ay, bx, by, lx1, ly1, lx2, ly2) \
- p['w'] / 2.0 - lw / 2.0
if d < CLR - 1e-4:
bad.append((d, u'走线 %s @(%.2f,%.2f)' % (net or '-', lx1, ly1)))
for q in paths:
if q is p or q['net'] == p['net'] or q['layer'] != p['layer']:
continue
for j in range(len(q['pts']) - 1):
cx, cy = q['pts'][j]
dx, dy = q['pts'][j + 1]
d = seg_seg_dist(ax, ay, bx, by, cx, cy, dx, dy) \
- p['w'] / 2.0 - q['w'] / 2.0
if d < CLR - 1e-4:
bad.append((d, u'本轮 %s(%s)' % (q['name'], q['net'])))
return bad
for p in paths:
for i in range(len(p['pts']) - 1):
ax, ay = p['pts'][i]
bx, by = p['pts'][i + 1]
for d, who in sorted(seg_clear(p, ax, ay, bx, by))[:3]:
errs.append(u'%-22s 第%d段 净距 %.3f mm(要 %.2f)-> %s'
% (p['name'], i + 1, d, CLR, who))
# 3) 尖角
for p in paths:
for i in range(1, len(p['pts']) - 1):
x0, y0 = p['pts'][i - 1]
x1, y1 = p['pts'][i]
x2, y2 = p['pts'][i + 1]
a1 = math.atan2(y0 - y1, x0 - x1)
a2 = math.atan2(y2 - y1, x2 - x1)
ang = abs(math.degrees(a1 - a2)) % 360
ang = min(ang, 360 - ang)
if ang < 89.0:
errs.append(u'%-22s 第%d个拐点是 %.0f° 的尖角' % (p['name'], i, ang))
# 4) 载流
for p in paths:
if p['i'] <= 0:
continue
p['dT'] = dT_for(p['w'], p['i'])
if p['dT'] > DT_LIMIT and length(p) > STUB_LEN:
errs.append(u'%-22s 线宽 %.2f mm 带 %.1f A 温升 %.0f °C(上限 %.0f)'
% (p['name'], p['w'], p['i'], p['dT'], DT_LIMIT))
elif p['dT'] > DT_LIMIT:
notes.append(u'%-22s %.2f mm / %.1f A / 长 %.2f mm —— 短桩,不套 IPC 公式'
% (p['name'], p['w'], p['i'], length(p)))
return errs, notes
def length(p):
return sum(math.hypot(p['pts'][i + 1][0] - p['pts'][i][0],
p['pts'][i + 1][1] - p['pts'][i][1])
for i in range(len(p['pts']) - 1))
def report(paths):
print(u'下板 PCB1 五路功率级手工布线(§10.2 的四条关键回路)')
print(u' 母版 %d 条路径 x 5 路 = %d 条;全部顶层' % (len(R), len(paths)))
ch1 = [p for p in paths if p['ch'] == 1]
print(u'')
print(u' %-16s %-9s %5s %5s %6s %7s %s'
% (u'路径(CH1)', u'网络', u'线宽', u'电流', u'长度', u'ΔT', u'盖到的焊盘'))
for p in ch1:
dt = (u'%5.0f°C' % p['dT']) if p['i'] > 0 and 'dT' in p else u' -'
print(u' %-16s %-9s %5.2f %5.1f %6.2f %7s %s'
% (p['name'].split('@')[0], p['net'], p['w'], p['i'], length(p), dt,
' '.join(p['hit'])[:44]))
tot = sum(length(p) for p in ch1)
sw = sum(length(p) for p in ch1 if p['name'].startswith('SW'))
sw_a = sum(length(p) * p['w'] for p in ch1 if p['name'].startswith('SW'))
print(u'')
print(u' 一路合计 %.1f mm;其中 **SW 节点 %.1f mm / 铜面积 %.1f mm²**(§10.2-2)'
% (tot, sw, sw_a))
# ══════════════════ 下发 ══════════════════
def push(paths):
seg = []
for p in paths:
for i in range(len(p['pts']) - 1):
(ax, ay), (bx, by) = p['pts'][i], p['pts'][i + 1]
seg.append({'net': p['net'], 'lay': p['layer'],
'x1': ax * MIL, 'y1': ay * MIL,
'x2': bx * MIL, 'y2': by * MIL, 'w': p['w'] * MIL})
JS = u"""
await eda.dmt_EditorControl.openDocument('%s');
await new Promise(r => setTimeout(r, 1500));
const S = %s;
let n = 0;
for (const s of S) {
const ln = await eda.pcb_PrimitiveLine.create(s.net, s.lay, s.x1, s.y1, s.x2, s.y2, s.w);
if (ln) n++;
}
await eda.pcb_Document.save();
return {n, totalLine: (await eda.pcb_PrimitiveLine.getAll()).length};
""" % (PCB, json.dumps(seg))
r = run(JS, timeout=900)
if not r.get('success'):
print(u'失败:', json.dumps(r, ensure_ascii=False)[:500])
sys.exit(1)
print(u' 下发:新建走线 %d 段,板上走线总数 %d'
% (r['result']['n'], r['result']['totalLine']))
return len(seg)
def verify(n_expect=None):
u"""按板上**实测**的线再核一遍。
警告:判据是**覆盖**,不是「端点对得上」。
EasyEDA 会在 T 形接点上把已有的线**劈成两条** —— 西侧那条 +12V 母线
(2.550, 8.701)->(2.550, 13.949) 下发之后,板上是
(2.550,8.701)-(2.550,10.299) 和 (2.550,10.299)-(2.550,13.949) 两条,
因为 C%01 那条支线正好接在中间。按端点比会报「40 段对不上」,
而板上的铜一点没少。⇒ 沿着计划的每一段每 0.2 mm 取一个点,
要求都被某条同网同层同线宽的实测线盖住。
"""
snap = snapshot(refresh=True)
on = [(l[0] or '', l[1], l[2], l[3], l[4], l[5], round(l[6] or 0, 3))
for l in snap.get('line', ())]
paths = board_paths()
miss, nseg = [], 0
for p in paths:
# ⚠️ **不能只认「同宽」的线。** 一段窄线整个落在一条宽线的圆头端帽里时,
# EasyEDA 会把它**直接吞掉**(实测:SW-p12 东端那 0.3 mm 落在 SW-run
# 1.2 mm 的西端帽里,板上就没有那一小段了)—— 铜一点没少,按同宽比却报 5 条。
# 正确判据是「计划的那段铜**被实测铜包住**」:实测线不窄于计划线,
# 且中心距 ≤ (o_w − p_w) / 2。
cand = [o for o in on
if o[0] == p['net'] and o[1] == p['layer'] and o[6] >= p['w'] - 0.011]
for i in range(len(p['pts']) - 1):
nseg += 1
(ax, ay), (bx, by) = p['pts'][i], p['pts'][i + 1]
L = math.hypot(bx - ax, by - ay)
n = max(2, int(L / 0.2) + 1)
bad = 0
for k in range(n + 1):
t = k / float(n)
px, py = ax + (bx - ax) * t, ay + (by - ay) * t
if not any(_d_pt_seg(px, py, o[2], o[3], o[4], o[5])
<= (o[6] - p['w']) / 2.0 + 0.02 for o in cand):
bad += 1
if bad:
miss.append(u'%s 第%d段(%d/%d 个采样点没被盖住)'
% (p['name'], i + 1, bad, n + 1))
print(u' 回读:板上 %d 条走线;本轮 %d 段里没被实测铜盖住的 %d 条'
% (len(on), nseg, len(miss)))
for m in miss[:10]:
print(u' [!!] %s' % m)
return miss
def _d_pt_seg(px, py, x1, y1, x2, y2):
vx, vy = x2 - x1, y2 - y1
L = vx * vx + vy * vy
t = 0.0 if L == 0 else max(0.0, min(1.0, ((px - x1) * vx + (py - y1) * vy) / L))
return math.hypot(px - (x1 + t * vx), py - (y1 + t * vy))
def clean(paths):
u"""只删**本轮画的**那些线。
警告:不能按线宽删。板上同一个网络上还有扇出的短线(`pcb_fanout` 的
TRK_W_TIGHT 也用 0.20 / 0.30),按宽度一刀切会把它们一起端掉。
判据是**两个端点都落在计划路径的走廊里**(0.02 mm)——
EasyEDA 会在 T 形接点上劈线,所以劈出来的两段也照样被认出来。
"""
snap = snapshot(refresh=True)
kill = []
for idx, (net, lay, x1, y1, x2, y2, w) in enumerate(
[(l[0] or '', l[1], l[2], l[3], l[4], l[5], round(l[6] or 0, 3))
for l in snap.get('line', ())]):
for p in paths:
if p['net'] != net or p['layer'] != lay or abs(p['w'] - w) > 0.01:
continue
for i in range(len(p['pts']) - 1):
(ax, ay), (bx, by) = p['pts'][i], p['pts'][i + 1]
if (_d_pt_seg(x1, y1, ax, ay, bx, by) < 0.02
and _d_pt_seg(x2, y2, ax, ay, bx, by) < 0.02):
kill.append((net, lay, x1, y1, x2, y2, w))
break
else:
continue
break
if not kill:
print(u' 清理:板上没有本轮画过的线')
return 0
JS = u"""
await eda.dmt_EditorControl.openDocument('%s');
await new Promise(r => setTimeout(r, 1500));
const K = %s, M = 39.3701, q = v => Math.round(v / M * 1e4) / 1e4;
const del = [];
for (const l of await eda.pcb_PrimitiveLine.getAll()) {
for (const k of K) {
if ((l.net || '') === k[0] && l.layer === k[1]
&& Math.abs(q(l.startX) - k[2]) < 0.005 && Math.abs(q(l.startY) - k[3]) < 0.005
&& Math.abs(q(l.endX) - k[4]) < 0.005 && Math.abs(q(l.endY) - k[5]) < 0.005) {
del.push(l.primitiveId); break;
}
}
}
if (del.length) await eda.pcb_PrimitiveLine.delete(del);
await eda.pcb_Document.save();
return {n: del.length, left: (await eda.pcb_PrimitiveLine.getAll()).length};
""" % (PCB, json.dumps(kill))
r = run(JS, timeout=900)
if not r.get('success'):
print(u'失败:', json.dumps(r, ensure_ascii=False)[:400])
sys.exit(1)
print(u' 清理:删掉本轮的 %d 条(认出 %d 条),板上还剩 %d 条走线'
% (r['result']['n'], len(kill), r['result']['left']))
return r['result']['n']
def main():
only = None
if '--ch' in sys.argv:
only = int(sys.argv[sys.argv.index('--ch') + 1])
paths = board_paths(only)
errs, notes = check(paths, refresh='--push' in sys.argv or '--verify' in sys.argv)
report(paths)
print(u'')
if errs:
print(u'!! %d 个问题:' % len(errs))
for e in errs[:40]:
print(u' ' + e)
if len(errs) > 40:
print(u' ... 还有 %d 条' % (len(errs) - 40))
sys.exit(1)
if notes:
print(u' 提示 %d 条:' % len(notes))
for t in notes:
print(u' · ' + t)
print(u'✅ 全部校核通过(本网落脚 / 组内连通 / 净距 / 拐角 / 载流)')
if '--verify' in sys.argv:
verify()
return
if '--push' in sys.argv:
if '--clean' in sys.argv:
clean(paths)
push(paths)
verify()
else:
print(u'(未加 --push,没有下发)')
if __name__ == '__main__':
main()