R2 底板改 4 层(D15):规则 / 几何 / 背面摆位 / 4 层布线与铺铜,已下发

- 规则:铜层 2→4,差分 0.28/0.18,间距 0.15,过孔 0.3/0.2(rules_base)
- 几何:98×98 板框 + 4×M3,槽阵居中,L1 12V 大铜 + 指头(pcb_base)
- 摆位:五插槽留 L1,其余 133 件翻到 L4;背面镜像规则实测;Hub 放走廊 2、
  东移 2.5 mm;扇出圈 / 小件间距 1.5(去耦例外)/ 9 A 过孔阵禁区(place_base)
- 叠层按用户决定改为 L1 槽面 / L2 整层 +3V3 / L3 整层 GND / L4 器件面
- 布线:D± 七对按构造布通(USB3 掉头、USB1/2 北去掉头、USB4/5 东绕、
  J23 桥),+3V3 / GND / +12V 平面网每脚一孔,0.125 网格 + PathFinder
  开关(route_base、remote_route.ps1);剩 37 个网待手工补
- 铺铜:4 层重写,入口链自南向北切段 + 断面校核(pour_base);
  已下发布线 / 缝合孔 / 铜面轮廓,填铜未成功(待查)
- 工具:plot_base / plot_routes / shot_base,board_check 通孔两层都算

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0168Bu6SEx3cXXCJLkdnE6zB
这个提交包含在:
zhou yang
2026-09-10 21:02:10 +08:00
共同撰写人 Claude Opus 5
父节点 18d3d69856
当前提交 efcba30a0c
共修改 280 个文件,包含 92007 行新增和 15053 行删除
+382
查看文件
@@ -0,0 +1,382 @@
# -*- coding: utf-8 -*-
u"""R2 刀片 PCB 摆位(M4c-2)。
/c/Python314/python .claude/r2/pcb_place.py # 只算 + 自检(不下发)
/c/Python314/python .claude/r2/pcb_place.py --push # 下发摆位
/c/Python314/python .claude/r2/pcb_place.py --verify # 读回校验
## 三类件,三种定位方式
1. **机械锚定件**(位置由小米8 主板决定,不许挪):`K1~K4` 侧键触点盘、
`J5` 电池排线焊盘、`J4` BM24 尾插座。坐标从 **`.claude/mi8_mech.json`** 换算,
不手抄。手机主板在刀片上的落点是 `PHONE_ORG`(方案 §3.1:x 3~70.09 / y 29~73.66,
长边水平、**接口边朝下**)。
2. **手工钉死件**:`J1` 金手指(`pcb_blade.J1_X`)、`C60` Ø10.3 的 2200 µF、
`J2` D± 退路座、`J3` EDL 座 —— 各有各的理由,见 `ANCHORS` 的注释。
3. **其余 70 件**:按功能分组、按「电流流向」排序,塞进四个区(`REGIONS`),
用一个简单的货架式排布器摆。**顺序是设计意图,坐标不是** ——
要改布局改 `GROUPS` 里的顺序和区,不要去改具体坐标。
## 分区(方案 §3.1,坐标见 `pcb_blade.py`)
| 区 | 范围 | 放什么 |
|---|---|---|
| 金手指区 | y 0~11 | **只有 J1,其余一件不放**(插槽体 ~9 mm 要让开)|
| 服务带 | y 12~28 | 电池/尾插/EDL/侧键 MOS/阻尼组 —— 全部接口都在这一带 |
| 手机投影 | x 3~70.1 / y 29~73.7 | **只有 K1~K4 四个裸铜盘**(螺柱间隙只有 1.0 mm,放不下别的)|
| 功率列 | x 72~98 | Buck / eFuse / 检流 / PCA9555。**按「离手机越远越吵」内外分两条** |
功率列内部:外条(x 86~98,靠板边)放 SW 节点、电感、Cin —— 最吵的;
内条(x 72~85,贴手机)放 FB 分压、DAC 低通、INA226、PCA9555 —— 最怕吵的。
这是方案 §3.1 的「按离手机越远越吵排」和风险 R-12。
## B 面(底层)留空
方案 §3.2 的高度表把 `L1`(SPM6530T,3.0 mm)算作「B 面最高件」,但同一张表里
**A 面功率列可用 11.7~13.7 mm** —— 3.0 的电感放 A 面绰绰有余,而且能贴着 `U1` 摆,
**SW 节点不用打过孔**(§7.2 第二条:SW 面积最小化)。所以这一版 **83 件全在 A 面**,
B 面空着。这让 `h_B` 从 3.0 变成 0,槽距只会更宽裕 —— 是放松,不是违反。
"""
import io
import json
import os
import sys
sys.stdout.reconfigure(encoding='utf-8')
HERE = os.path.dirname(os.path.abspath(__file__))
# ⚠ 顺序不能反:`.claude` 下有同名的 R1 脚本(pcb_pour / pcb_rules / pcb_place),
# 先插 .claude 再插 HERE 的话,`import pcb_pour` 会拿到 R1 那一份,
# 而且**不报错**,只在调到 R2 专有的函数时才炸。
sys.path.insert(0, os.path.dirname(HERE))
sys.path.insert(0, HERE)
from eda import run # noqa: E402
import pcb_blade as B # noqa: E402
MIL = B.MIL
PCB = B.PCB
BBOX = os.path.join(HERE, '_blade_bbox.json')
MECH = os.path.join(os.path.dirname(HERE), 'mi8_mech.json')
GAP = 0.6 # 件间净距(mm)
# 手机主板在刀片上的落点:本体 (u,v) -> 板 (x,y) = (PHONE_ORG + (u,v))
# 方案 §3.1:主板 67.09 × 44.66,长边水平、**接口边(v=0 那条,J600/J1401/侧键都在上面)朝下**。
# ⚠ 事实来源在 pcb_blade(板级),这里只做别名,不要抄第二份
PHONE_ORG = B.PHONE_ORG
PHONE_WH = B.PHONE_WH
# 焊盘外框量不到本体的几个件:给真实本体尺寸(mm)
BODY = {
'C60': (11.0, 11.0), # Ø10.3 铝电解,2200 µF(T2 = 高 10.5)
'L1': (7.6, 7.0), # SPM6530T-4R7M
'J4': (13.2, 3.6), # BM24-50DS
'U4': (8.0, 8.0),
}
# ⚠ **全部放 A 面,B 面留空。** 方案 §3.2 的高度表把 `L1`(SPM6530T 3.0 mm)算成
# 「B 面最高件」,但那张表同时写着 **A 面功率列可用 11.7~13.7 mm** —— 3.0 的电感
# 放 A 面绰绰有余,而且能贴着 U1 摆、SW 节点不用打过孔(§7.2 第二条:SW 面积最小化)。
# B 面空着只会让槽距更宽裕(h_B 从 3.0 变 0),是**放松**不是违反。
BOTTOM = []
def phone(u, v):
return (round(PHONE_ORG[0] + u, 3), round(PHONE_ORG[1] + v, 3))
def anchors():
u"""机械锚定 + 手工钉死的件 -> (x, y, rot)。"""
m = json.load(io.open(MECH, encoding='utf-8'))
sk = m['side_key_contacts']
a = {}
# ── 侧键触点盘:主板自己的弹片直接压在这四个裸铜盘上(R1 §22.3e 中框式)──
# 位置**完全由主板决定**,Ø1.5 的盘只容许 0.50 mm 位置误差(pcb2_place 的算账)。
for ref, key in (('K1', 'PWR'), ('K2', 'VUP'), ('K3', 'VDN'), ('K4', 'GND')):
a[ref] = phone(*sk[key]) + (0,)
# ── 电池排线焊盘:排线从 J600 翻过接口边折下来,落在接口边正下方 ──
a['J5'] = (phone(m['contacts_up']['J600'][0], 0)[0], 25.6, 0)
# ── BM24 尾插座:同理,落在 J1401 正下方 ──
a['J4'] = (phone(m['contacts_up']['J1401'][0], 0)[0], 21.0, 0)
# ── 手工钉死的四个 ──
# C60:Ø10.3 / 高 10.5,**只能放没有手机压着的地方**(§21.2),
# 且要紧挨 J5(它是 VO_P 上的阻尼组主电容,回路越短越好)。
a['C60'] = (37.5, 19.0, 0)
# J2:D± 退路座。要靠近金手指的 D± 针(B14/B15,x≈62/63),
# **金手指到 J2 之间要留割断位**(D12 / §17.4),所以摆在指区正上方偏右。
a['J2'] = (62.0, 15.0, 0)
# J3:EDL 座。跟着 U6(TS5A3157 模拟开关)走 —— 它是手机侧信号,
# 放到功率列去的话 EDL 线要横穿 40 mm。摆在服务带右段、U6 上方。
a['J3'] = (66.5, 25.0, 0)
return a
# ── 分组:**顺序 = 电流/信号流向**,排布器按这个顺序摆 ──────────────────
GROUPS = [
# (区, 组名, [位号...])
# ⚠ 功率列**自下而上 = 输出端 → 输入端**(和电流方向相反)。理由:
# `VO_P` 是 4 A,出口必须离服务带的 `C60`/`J5` 越近越好;`+12V` 只有 1.45 A,
# 而且走 L3 灌铜,从金手指爬到板子上半段无所谓。反过来排就是拿 4 A 换 1.45 A。
# ⚠ `U3` INA226 和 `Rs1` **必须同组且相邻**(§7.2 第四条:取样脚落在 Rs 的
# Kelvin 焊盘上,不许就近扎孔)。放到内条去就隔了整整一列。
('POWER_OUT', u'eFuse + 检流 + INA226', ['U2', 'C14', 'Rs1', 'U3', 'C13',
'R19', 'Q2']),
('POWER_OUT', u'Buck 输出', ['C6', 'C7', 'C8']),
('POWER_OUT', u'Buck 本体 + SW + L', ['U1', 'C5', 'L1', 'C9']),
('POWER_OUT', u'12V 入口 + Buck 输入', ['F1', 'D1', 'C1', 'C2', 'C3', 'C4']),
# 内条按同样的自下而上顺序,让每一级和外条大致齐平
('POWER_IN', u'PCA9555 + I2C 上拉', ['U4', 'C40', 'R40', 'R41']),
('POWER_IN', u'eFuse 外围', ['R9', 'R10', 'R11', 'R12', 'R16', 'R17',
'R13', 'R15', 'R14', 'Q1', 'R18']),
('POWER_IN', u'FB 分压 + DAC 低通', ['R1', 'R2', 'R7', 'R8', 'C12']),
('POWER_IN', u'Buck 外围', ['R3', 'R4', 'R5', 'R6', 'C10', 'C11']),
('SERVICE_L', u'侧键 MOS(贴着 K1~K3)', ['Q3', 'R42', 'R43', 'Q4', 'R44', 'R45',
'Q5', 'R46', 'R47']),
('SERVICE_M', u'VO_P 阻尼组 + 电池外围', ['C61', 'C62', 'C63', 'C64', 'C65',
'R60', 'R61']),
('SERVICE_R', u'尾插 USB + VBUS 开关', ['U7', 'U5', 'C66', 'C67', 'C68',
'R62', 'R64', 'R65']),
('SERVICE_R', u'EDL 模拟开关', ['U6', 'R63', 'C69']),
]
# ── 区(x0, y0, x1, y1),排布器在里面自下而上、自左而右铺 ────────────────
REGIONS = {
'POWER_OUT': (85.8, 12.0, 97.5, 74.0),
'POWER_IN': (72.3, 12.0, 85.0, 74.0),
'SERVICE_L': (2.5, 12.0, 19.5, 28.0),
'SERVICE_M': (20.0, 12.0, 41.5, 28.0),
'SERVICE_R': (42.0, 12.0, 71.0, 28.0),
}
def sizes():
d = json.load(io.open(BBOX, encoding='utf-8'))
out = {}
for ref, v in d.items():
w, h = BODY.get(ref, (v['w'], v['h']))
out[ref] = (max(w, 0.6), max(h, 0.6))
return out
def overlaps(r, rects):
for q in rects:
if r[0] < q[2] and q[0] < r[2] and r[1] < q[3] and q[1] < r[3]:
return True
return False
def plan():
u"""返回 {位号: (x, y, rot)} 和占位矩形表。"""
sz = sizes()
pos = dict(anchors())
pos['J1'] = (B.J1_X, B.J1_Y, B.J1_ROT)
rects = []
for ref, (x, y, _r) in pos.items():
w, h = sz[ref]
rects.append((x - w / 2 - GAP / 2, y - h / 2 - GAP / 2,
x + w / 2 + GAP / 2, y + h / 2 + GAP / 2))
# 每个区一个 y 游标:**一个组占一条完整的横带**,下一组从带子上沿重新开始。
# 不这么做的话小件会回填到前面组的空隙里,功能级在 y 上互相咬 ——
# 后面 L3 要按「哪一段是 12V、哪一段是 VO」切铜面就切不出来了。
cursor = {k: v[1] for k, v in REGIONS.items()}
unplaced = []
for region, _name, refs in GROUPS:
x0, _y0, x1, y1 = REGIONS[region]
y0 = cursor[region]
band_top = y0
for ref in refs:
if ref in pos:
continue
w, h = sz[ref]
w += GAP
h += GAP
placed = False
# 自下而上扫,步长 0.5;每一行自左而右扫
yy = y0
while yy + h <= y1 + 1e-9 and not placed:
xx = x0
while xx + w <= x1 + 1e-9:
r = (xx, yy, xx + w, yy + h)
if not overlaps(r, rects):
rects.append(r)
pos[ref] = (round(xx + w / 2, 3), round(yy + h / 2, 3), 0)
band_top = max(band_top, yy + h)
placed = True
break
xx += 0.5
yy += 0.5
if not placed:
unplaced.append((ref, region))
cursor[region] = band_top + 0.4
return pos, rects, unplaced, sz
def bands():
u"""每个组占的 y 带 —— L3 切铜面按这个来,别手抄坐标。"""
pos, _r, _u, sz = plan()
out = []
for region, name, refs in GROUPS:
ys = [(pos[r][1] - sz[r][1] / 2, pos[r][1] + sz[r][1] / 2) for r in refs if r in pos]
if ys:
out.append((region, name, round(min(a for a, _ in ys), 2),
round(max(b for _, b in ys), 2)))
return out
def selfcheck(pos, rects, unplaced, sz):
bad = []
if unplaced:
bad.append(u'放不下: %s' % ', '.join(u'%s(%s)' % t for t in unplaced))
d = json.load(io.open(BBOX, encoding='utf-8'))
missing = sorted(set(d) - set(pos))
if missing:
bad.append(u'没安排位置: %s' % ' '.join(missing))
# 重叠。⚠ K1~K4 例外:四个盘的位置是**主板定的**(实测最小间距 1.78 mm、
# 盘 Ø1.5 ⇒ 净距 0.28),通用的 GAP=0.6 会把它们互判成重叠。
# 它们之间改按「铜净距 ≥ 0.2」单独校核。
import math
keys = ['K1', 'K2', 'K3', 'K4']
rmap = {}
for ref in pos:
if ref in keys:
continue
x, y, _ = pos[ref]
w, h = sz[ref]
rmap[ref] = (x - w / 2 - GAP / 2, y - h / 2 - GAP / 2,
x + w / 2 + GAP / 2, y + h / 2 + GAP / 2)
rl = sorted(rmap.items())
for i in range(len(rl)):
for j in range(i + 1, len(rl)):
a, b = rl[i][1], rl[j][1]
if a[0] < b[2] - 1e-6 and b[0] < a[2] - 1e-6 and \
a[1] < b[3] - 1e-6 and b[1] < a[3] - 1e-6:
bad.append(u'%s 和 %s 重叠' % (rl[i][0], rl[j][0]))
PAD_D = 1.5
for i in range(len(keys)):
for j in range(i + 1, len(keys)):
ax, ay, _ = pos[keys[i]]
bx, by, _ = pos[keys[j]]
gap = math.hypot(ax - bx, ay - by) - PAD_D
if gap < 0.2:
bad.append(u'%s/%s 铜净距 %.2f < 0.2' % (keys[i], keys[j], gap))
# 全部在板内
for ref, (x, y, _r) in pos.items():
w, h = sz[ref]
if ref == 'J1':
continue
if x - w / 2 < 0.5 or x + w / 2 > B.BW - 0.5 or \
y - h / 2 < 0.5 or y + h / 2 > B.BH - 0.5:
bad.append(u'%s 出板框: (%.2f,%.2f) %.1fx%.1f' % (ref, x, y, w, h))
# 金手指区(y<11)除 J1 外一件不许有
for ref, (x, y, _r) in pos.items():
if ref == 'J1':
continue
if y - sz[ref][1] / 2 < 11.0:
bad.append(u'%s 侵入金手指区(y=%.2f,插槽体要 ~9 mm)' % (ref, y))
# 手机投影里只许 K1~K4
px0, py0 = PHONE_ORG
px1, py1 = px0 + PHONE_WH[0], py0 + PHONE_WH[1]
for ref, (x, y, _r) in pos.items():
if ref.startswith('K'):
continue
w, h = sz[ref]
if x + w / 2 > px0 and x - w / 2 < px1 and y + h / 2 > py0 and y - h / 2 < py1:
bad.append(u'%s 落进手机投影(螺柱间隙只有 1.0 mm)' % ref)
# ⛔ 螺钉头避让:任何元件离主板定位孔中心都要 >= HOLE_KEEP。
# M4c 第一版把这七个孔整个忘了 —— 高度预算和 K1~K4 的接触压力全靠它们。
for hname, (hx, hy) in B.phone_holes().items():
for ref, (x, y, _r) in pos.items():
w, h = sz[ref]
dx = max(abs(hx - x) - w / 2.0, 0.0)
dy = max(abs(hy - y) - h / 2.0, 0.0)
if math.hypot(dx, dy) < B.HOLE_KEEP:
bad.append(u'%s 离定位孔 %s 只有 %.2f < %.1f(螺钉头压上去)'
% (ref, hname, math.hypot(dx, dy), B.HOLE_KEEP))
# C60 不许在手机底下(已由上一条覆盖),另外校核它确实在服务带/功率列
cx, cy, _ = pos['C60']
if not (12.0 <= cy - BODY['C60'][1] / 2 and cy + BODY['C60'][1] / 2 <= 28.0):
bad.append(u'C60 不在服务带里(可用高度 11.7~13.7 只有这一带和功率列有)')
return bad
PUSH_JS = u"""
const MIL = %s, P = %s, BOT = %s;
await eda.dmt_EditorControl.openDocument('%s');
await new Promise(r=>setTimeout(r,2500));
const bot = new Set(BOT);
let n = 0, miss = [];
for (const c of await eda.pcb_PrimitiveComponent.getAll()) {
const d = c.designator;
if (!P[d]) { miss.push(d); continue; }
const id = c.getState_PrimitiveId();
await eda.pcb_PrimitiveComponent.modify(id, {layer: bot.has(d) ? 2 : 1});
await eda.pcb_PrimitiveComponent.modify(id,
{x: P[d][0]*MIL, y: P[d][1]*MIL, rotation: P[d][2]});
n++;
}
await eda.pcb_Document.save();
return {n, miss};
"""
def push(pos):
r = run(PUSH_JS % (MIL, json.dumps(pos), json.dumps(BOTTOM), PCB), timeout=600)
if not r.get('success'):
sys.exit(json.dumps(r, ensure_ascii=False)[:600])
print(u'已摆 %d 件;没找到位置的: %s' % (r['result']['n'], r['result']['miss'] or u'无'))
def verify(pos, sz):
r = run("await eda.dmt_EditorControl.openDocument('%s');"
"await new Promise(r=>setTimeout(r,2000));"
"return (await eda.pcb_PrimitiveComponent.getAll()).map("
" c=>[c.designator, c.x, c.y, c.rotation, c.layer]);" % PCB, timeout=300)
if not r.get('success'):
sys.exit(json.dumps(r, ensure_ascii=False)[:600])
got = {a[0]: (a[1] / MIL, a[2] / MIL, a[3], a[4]) for a in r['result']}
bad = []
for ref, (x, y, rot) in pos.items():
if ref not in got:
bad.append(u'板上没有 %s' % ref)
continue
gx, gy, grot, glay = got[ref]
if abs(gx - x) > 0.02 or abs(gy - y) > 0.02:
bad.append(u'%s 在 (%.2f,%.2f),应为 (%.2f,%.2f)' % (ref, gx, gy, x, y))
want_lay = 2 if ref in BOTTOM else 1
if glay != want_lay:
bad.append(u'%s 在 layer %s,应为 %s' % (ref, glay, want_lay))
print(u'读回 %d 件' % len(got))
if bad:
print(u'[X] %d 条不过:' % len(bad))
for b in bad[:20]:
print(u' ' + b)
else:
print(u'[OK] %d 件位置 / 层 全部对上' % len(got))
return bad
if __name__ == '__main__':
pos, rects, unplaced, sz = plan()
bad = selfcheck(pos, rects, unplaced, sz)
print(u'安排了 %d 件(含 J1)' % len(pos))
for region, name, refs in GROUPS:
xs = [pos[r] for r in refs if r in pos]
if xs:
print(u' %-11s %-22s x %5.1f~%5.1f y %5.1f~%5.1f %d 件'
% (region, name, min(p[0] for p in xs), max(p[0] for p in xs),
min(p[1] for p in xs), max(p[1] for p in xs), len(xs)))
print(u' 锚定件: ' + ' '.join(u'%s(%.1f,%.1f)' % (k, pos[k][0], pos[k][1])
for k in ('K1', 'K2', 'K3', 'K4', 'J5', 'J4',
'C60', 'J2', 'J3')))
if bad:
print(u'[X] 自检不过 (%d):' % len(bad))
for b in bad[:20]:
print(u' ' + b)
if '--push' in sys.argv:
sys.exit(1)
else:
print(u'[OK] 自检通过:无重叠 / 全在板内 / 金手指区干净 / 手机投影只有 K1~K4')
if '--push' in sys.argv:
push(pos)
verify(pos, sz)
elif '--verify' in sys.argv:
verify(pos, sz)