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 行删除
+403
查看文件
@@ -0,0 +1,403 @@
# -*- coding: utf-8 -*-
u"""R2 刀片 **B1-POWER** 页:12V 入口保护 → Buck → eFuse → 检流 → `VO_P`。
/c/Python314/python .claude/r2/b1_power.py # 只算 + 本地自检
/c/Python314/python .claude/r2/b1_power.py --push # 清页 + 重画(幂等)
## 版图从哪来
**不是重新排的** —— 直接搬 R1 已经验证过六项的 `P2-CHANNEL`
(`.claude/r2/dump_page.py` 读回的 `_p2_live.json`:40 元件 / 213 段导线 /
29 旗 / 12 端口),只做三件事:
1. **位号映射**(`DES`):`C1xx→Cx` / `R1xx→Rx` / `Rs101→Rs1` / `L101→L1` /
`Q101→Q1` / `Q102→Q2`;芯片按 R2 刀片的编号 `U1` Buck / **`U8→U3`** INA226 /
**`U14→U2`** eFuse。刀片只有一路,不再需要「通道号 = 位号百位」那套规则。
2. **网络名去掉通道后缀**(`NET`):`VO1→VO`、`VDAC1→VDAC`、`EFUSE_EN1→EFUSE_EN` …
`GND` / `+3V3` / `BUCK_PG` / `INA_ALERT` / `SDA` / `SCL` 原样。
3. **新增 12V 入口保护块**(方案文档 D10):`+12V`(金手指) → `F1` 2A 慢断 →
`+12V_F`(刀片内部 12V 轨)→ Buck;`D1` TVS 从 `+12V_F` 钳到地。
原来那两个 `+12V` 电源旗因此改叫 `+12V_F`。
## ⚠️ 跨工程重放必须断言引脚坐标
skill §6b/§6c/§8d:`create()` 的 rotation 语义**同一个料号在不同页都能不一样**,
方向表一张都别信。所以放完之后逐个元件把 `getAllPins()` 读回来,
和 `_p2_live.json` 里 R1 的引脚坐标**逐脚比对**,差一个单位就终止 ——
坐标一致才能保证照搬过来的 213 段导线还落在引脚上。
## ✅ T7 已结(2026-09-08):F1 = `F1206SB2000V063TM`(`C310998`)
原来这里写「库里搜不到 2A 的 1206 保险丝,`lib_Device.search` 只回 38 条」——
**那个结论是错的**:`search` 的完整签名是
`search(key, libraryUuid, classification, symbolType, itemsOfPage, page)`,
**带上 `itemsOfPage` / `page` 就能翻页**(单次请求仍受桥接 30 s 限制,一个关键词最多翻 3 页)。
翻页之后 AEM 的 `F1206` 全系都在库里,型号规则是 `F1206` + 系列 + 电流 + `V` + 耐压 + `TM`:
| 系列 | 含义 | 2A 那一颗 |
|---|---|---|
| **`SB`** | **Slow Blow(慢断)** | **`F1206SB2000V063TM` = `C310998`,63V / 分断 50A / I²t 0.63** ✅ 本页用它 |
| `HI` | High Inrush(耐涌,也是延时型)| `F1206HI2000V063TM` = `C246768`,I²t 0.8 |
| `FA` | Fast Acting(快断)| —— D10 要慢断,不能用 |
| `HC` | High Current | 占位用过的 10A(`C310989`)、底板 F1 的 12A(`C310990`)就是这一系 |
⚠ **封装 uuid 和占位件不一样**(`bceff889…` vs `66116fae…`,10A/150A 分断那颗焊盘更大)。
刀片 PCB 还没布(M4),所以没有影响;原理图这一侧的导线本来就是**按读回的引脚坐标**算的,自动跟随。
⚠ **裕量只有一档**:刀片满载约 1.45 A(16 W 手机 ÷ 92% ÷ 12 V)= 额定的 73%,
正压在保险丝「连续 ≤ 75%」的降额线上,机箱内 40~50 ℃ 还要再降。
要更大裕量就换 `F1206SB2500V032TM`(`C310999`,2.5A / 32V / I²t 0.9)—— 改 `FUSE` 一个常量即可。
"""
import io
import json
import os
import sys
import time
HERE = os.path.dirname(os.path.abspath(__file__))
# ⚠ `.claude` 下有和 r2 同名的 R1 脚本(pcb_pour / pcb_rules / pcb_place)。
# 只插父目录的话,**后面任何一个 `import pcb_rules` 都会拿到 R1 那一份**,
# 而且不报错,只在调到 R2 专有的函数时才炸(2026-09-08 踩过)。
# 规矩:两行都要写,HERE 后插(在 sys.path 最前)。
sys.path.insert(0, os.path.dirname(HERE))
sys.path.insert(0, HERE)
from eda import run # noqa: E402
PAGE = '5338949298dcff35' # BLADE / B1-POWER
PAGE_NAME = 'B1-POWER'
PROJ = '05a65c22f14846b8b95360ef8d3c1e2e'
SRC = os.path.join(HERE, '_p2_live.json')
PARTS = json.load(io.open(os.path.join(os.path.dirname(HERE), 'eda-parts.json'),
encoding='utf-8'))['parts']
# ── 1. 位号映射 ───────────────────────────────────────────────────────
CHIP = {'U1': 'U1', 'U8': 'U3', 'U14': 'U2'}
def des(d):
if d in CHIP:
return CHIP[d]
pre = 'Rs' if d.startswith('Rs') else d[0]
num = d[len(pre):]
assert num.isdigit() and len(num) == 3 and num[0] == '1', d
return '%s%d' % (pre, int(num[1:]))
# ── 2. 网络名映射 ─────────────────────────────────────────────────────
NET = {
'VO1': 'VO', 'VO1_P': 'VO_P', 'VDAC1': 'VDAC',
'EFUSE_EN1': 'EFUSE_EN', 'EFUSE_FLT1': 'EFUSE_FLT', 'EFUSE_PG1': 'EFUSE_PG',
'+12V': '+12V_F', # Buck 侧吃的是保险丝之后那一段
}
# ── 3. 新增:12V 入口保护块 ───────────────────────────────────────────
# 位置选在 y 200~340 / x 480~960 那片空地(R1 页上唯一一块连片的空区,
# 标题栏占 x>455 且 y<192,所以主线放 y=250、TVS 朝上挂)。
F1_XY = (580, 250)
D1_XY = (680, 290)
IN_PORT = (520, 250) # '+12V' 端口引脚
NODE_X = 680 # +12V_F 节点的竖线 x
FLAG_X = 760 # +12V_F 电源旗
GND_Y = 335 # TVS 上方的地旗(rot 180:符号在上、引脚在下)
FUSE = 'C310998' # F1206SB2000V063TM:2A 慢断(AEM SB 系列),1206/63V,T7 已结
TVS = 'C163651' # SMBJ13A
def lcsc_of(name):
u"""器件名 -> 立创编号。R1 页上的名字带 `_C8545` 这种后缀,去掉再比。"""
base = name.split('_C')[0] if '_C' in name else name
for k, v in PARTS.items():
if v['name'] == name or v['name'] == base:
return k
raise SystemExit(u'eda-parts.json 里找不到器件 %r' % name)
def plan():
src = json.load(io.open(SRC, encoding='utf-8'))
pts = src['wires']
segs = [(tuple(pts[i]), tuple(pts[i + 1])) for i in range(0, len(pts), 2)]
place, want_pins = [], {}
for p in src['parts']:
d0, name, _sub, x, y, rot, mir = p[0], p[1], p[2], p[3], p[4], p[5], p[6]
k = lcsc_of(name)
d = des(d0)
place.append([d, PARTS[k]['uuid'], x, y, rot, PARTS[k]['lib'], bool(mir)])
want_pins[d] = {str(q[0]): (q[2], q[3]) for q in p[9]}
# 新增两件
place.append(['F1', PARTS[FUSE]['uuid'], F1_XY[0], F1_XY[1], 0, PARTS[FUSE]['lib'], False])
# ⚠ create(90) 实测让 pin1(K) 落在上方 —— TVS 的 K 要朝下接 +12V_F,所以用 270。
# (entry_block() 里有硬断言,方向反了直接终止,见 skill §6c)
place.append(['D1', PARTS[TVS]['uuid'], D1_XY[0], D1_XY[1], 270, PARTS[TVS]['lib'], False])
W = [[int(round(a[0])), int(round(a[1])), int(round(b[0])), int(round(b[1]))]
for a, b in segs]
# 旗的符号名读回来是 'Ground-GND' / 'Power-5V',而 createNetFlag 的第一个参数
# 只认 'Ground' / 'Power' —— 取前缀。('Power-5V' 里的 5V 只是符号默认网络名,
# 见 skill §3b:移动电源旗会掉回这个默认值。)
# ⚠️ rot=180 的旗/端口读回来带浮点尾巴(789.9999999999999),必须先取整 ——
# 否则「坐标必须是 5 的倍数」和「必须落在导线端点」两道自检全部误报。
F = [[f[0].split('-')[0], NET.get(f[1], f[1]), int(round(f[2])), int(round(f[3])), f[4]]
for f in src['flags']]
PT = [[p[0].replace('Netport-', ''), NET.get(p[1], p[1]), int(round(p[2])), int(round(p[3])), p[4]]
for p in src['ports']]
return place, want_pins, W, F, PT
def entry_block(pins, pnames):
u"""按读回的真实引脚坐标补出入口保护块的导线 / 旗 / 端口。"""
f = pins['F1']
assert len(f) == 2, u'F1 应该是 2 脚,实测 %s' % (f,)
(fa, fb) = sorted(f.values()) # 左脚、右脚
assert fa[1] == fb[1] == F1_XY[1], u'F1 两脚必须同 y(横放),实测 %s' % (f,)
d = pins['D1']
assert sorted(d) == ['1', '2'], u'D1 应该是 2 脚,实测 %s' % (d,)
assert d['1'][0] == d['2'][0] == D1_XY[0], u'D1 两脚必须同 x(竖放),实测 %s' % (d,)
# ⚠️ 极性件按**脚名**认,不按脚号(skill §6c:方向表一张都别信)。
# SMBJ13A 实测脚名是 'C'(阴极) / 'A'(阳极),阴极要朝下接 +12V_F。
nm = pnames['D1']
kk = [n for n in d if nm.get(n, '').startswith(('C', 'K'))]
aa = [n for n in d if nm.get(n, '').startswith('A')]
assert len(kk) == 1 and len(aa) == 1, u'D1 脚名认不出阴阳极:%s' % (nm,)
dk, da = d[kk[0]], d[aa[0]]
assert dk[1] < da[1], \
u'D1 是 TVS,阴极必须在下(接 +12V_F),实测 K=%s A=%s ⇒ 把 rot 翻 180 重放' % (dk, da)
W = [
[IN_PORT[0], IN_PORT[1], fa[0], fa[1]], # 端口 → F1 左脚
[fb[0], fb[1], NODE_X, F1_XY[1]], # F1 右脚 → 节点
[NODE_X, F1_XY[1], FLAG_X, F1_XY[1]], # 节点 → +12V_F 旗
[NODE_X, F1_XY[1], dk[0], dk[1]], # 节点 → D1 阴极
[da[0], da[1], NODE_X, GND_Y], # D1 阳极 → 地旗
]
F = [['Power', '+12V_F', FLAG_X, F1_XY[1], 0],
['Ground', 'GND', NODE_X, GND_Y, 180]]
PT = [['IN', '+12V', IN_PORT[0], IN_PORT[1], 0]]
return W, F, PT
TEXTS = [
(185, 785, u'B1-POWER 刀片功率级:12V 入口保护 → Buck → eFuse → 检流 → VO_P(5 片刀片完全相同)', 16),
(480, 212, u'【12V 入口保护 · 方案 R2 §D10】金手指 +12V → F1 2A 慢断 → +12V_F 刀片内部轨;D1 TVS 钳位', 11),
(60, 170, u'TPS54622: Vref 0.6V, fsw 480kHz(R3=100k), Vout = 4.222 - 0.2813*Vd', 11),
(60, 150, u'TPS25940A: OVP 4.36V(R9/R10), ILIM 4.45A(R11), dVdT 33nF -> 0.36V/ms', 11),
(60, 130, u'缓启动 11.5ms / 涌流 0.8A ; INA226 五片刀片同址 A1=A0=GND -> 0x40(靠底板 TCA9548A 分通道)', 11),
(60, 110, u'放电互锁: EFUSE_EN 低 -> Q1 截止 -> Q2 导通 -> R19 10R 泄放手机侧 2200uF', 11),
(60, 90, u'✅ T7 已结:F1 = F1206SB2000V063TM (C310998) 2A 慢断 1206/63V;满载 1.45A = 额定 73%,裕量只有一档', 11),
(60, 70, u'⚠ EFUSE_EN 走金手指专用针直连底板 ESP32 GPIO,不经 PCA9555(安全链第三层)', 11),
]
def selfcheck(W, F, PT, pins):
bad = []
for a, b, c, e in W:
if (a == c) == (b == e):
bad.append(u'导线不是横平竖直或零长度: %s' % ((a, b, c, e),))
for v in (a, b, c, e):
if v % 5:
bad.append(u'导线坐标不是 5 的倍数: %s' % ((a, b, c, e),))
for it in F + PT:
if it[2] % 5 or it[3] % 5:
bad.append(u'旗/端口坐标不是 5 的倍数: %s' % (it,))
# 旗 / 端口的引脚必须落在某条导线段的**端点**(skill §8a-2)
ends = set()
for a, b, c, e in W:
ends.add((a, b)); ends.add((c, e))
for it in F + PT:
if (it[2], it[3]) not in ends:
bad.append(u'旗/端口不在导线端点上: %s' % (it,))
# 每个元件引脚必须落在某条导线段上
def on(p, s):
(px, py), (ax, ay, bx, by) = p, s
if ax == bx:
return px == ax and min(ay, by) <= py <= max(ay, by)
return py == ay and min(ax, bx) <= px <= max(ax, bx)
for d, ps in sorted(pins.items()):
for n, xy in sorted(ps.items()):
if not any(on(xy, s) for s in W):
bad.append(u'悬空引脚 %s.%s @%s' % (d, n, (xy,)))
return bad
def call(js, timeout=180):
r = run(js, timeout=timeout)
if not r.get('success'):
raise SystemExit(json.dumps(r, ensure_ascii=False)[:600])
return r['result']
READ_PINS_JS = """
const out = {};
for (const c of await eda.sch_PrimitiveComponent.getAll()) {
if (c.getState_ComponentType() !== 'part') continue;
out[c.getState_Designator()] = (await c.getAllPins()).map(
p => [p.getState_PinNumber(), (p.getState_PinName ? p.getState_PinName() : ''),
Math.round(p.getState_X()), Math.round(p.getState_Y())]);
}
return out;
"""
FLIP_JS = """
const P = %s;
const by = {};
for (const c of await eda.sch_PrimitiveComponent.getAll())
if (c.getState_ComponentType() === 'part') by[c.getState_Designator()] = c;
for (const [d, x, y, r] of P) {
const c = by[d]; if (!c) continue;
await c.toAsync().setState_Rotation(r).done();
await c.toAsync().setState_X(x).setState_Y(y).done();
}
return P.length;
"""
OPEN = ("await eda.dmt_EditorControl.openDocument('%s');"
"await new Promise(r=>setTimeout(r,1800));"
"const cur=await eda.dmt_Schematic.getCurrentSchematicPageInfo();"
"if(!cur||cur.name!=='%s') throw new Error('打错页了: '+(cur&&cur.name));"
% (PAGE, PAGE_NAME))
def main():
push = '--push' in sys.argv
place, want, W0, F0, PT0 = plan()
if not push:
print(u'元件 %d(含新增 F1/D1) 导线 %d 旗 %d 端口 %d 文字 %d'
% (len(place), len(W0) + 5, len(F0) + 2, len(PT0) + 1, len(TEXTS)))
nets = sorted({f[1] for f in F0} | {p[1] for p in PT0} | {'+12V', '+12V_F'})
print(u'网络名: ' + ' '.join(nets))
print(u'位号: ' + ' '.join(p[0] for p in place))
print(u'(只算不下发。要下发加 --push)')
return
call("await eda.dmt_Project.openProject('%s');"
"await new Promise(r=>setTimeout(r,2500)); return 1;" % PROJ)
# 清页(幂等)
call(OPEN + """
for (const c of await eda.sch_PrimitiveComponent.getAll())
await eda.sch_PrimitiveComponent.delete(c.getState_PrimitiveId());
for (const w of await eda.sch_PrimitiveWire.getAll())
await eda.sch_PrimitiveWire.delete(w.getState_PrimitiveId());
for (const t of await eda.sch_PrimitiveText.getAll())
await eda.sch_PrimitiveText.delete(t.getState_PrimitiveId());
return 1;
""")
for it in place:
assert len(it) == 7 and it[1] and it[5], u'放置表字段不全: %s' % (it,)
call(OPEN + """
globalThis.__pl = {done: 0, err: null, n: %d};
(async () => {
for (const [d, uuid, x, y, rot, lib, mir] of %s) {
try { await eda.sch_PrimitiveComponent.create({libraryUuid: lib, uuid}, x, y, undefined, rot, mir, true, true); }
catch (e) { globalThis.__pl.err = d + ': ' + String(e).slice(0, 150); }
globalThis.__pl.done++;
}
})();
return 'fired';
""" % (len(place), json.dumps(place)))
for _ in range(120):
st = call("return globalThis.__pl;")
if st['err']:
raise SystemExit(u'放置失败: %s' % st['err'])
if st['done'] >= st['n']:
break
time.sleep(2)
else:
raise SystemExit(u'放置超时 %d/%d' % (st['done'], st['n']))
# ⚠️ `create()` 会把元件坐标吸附(实测吸到 5 的倍数)——
# R1 页上 679/639/654/589 这些位置放下去会差 1~2 个单位,而整页 213 段导线
# 是按 R1 的坐标算的,差 1 就全断。skill §3 只说了旗/端口会吸附,元件也会。
# ⇒ 认领位号按**就近**匹配(容差 3),认完再 setState_X/Y 拉回精确坐标。
# (移动元件是安全的;不安全的是移动旗/端口 §3b、和 setState_Rotation §6b。)
named = call("""
const P = %s;
let k = 0, moved = 0;
const cs = (await eda.sch_PrimitiveComponent.getAll()).filter(c => c.getState_ComponentType() === 'part');
const used = new Set();
for (const [d, x, y] of P) {
let best = null, bd = 4;
for (const c of cs) {
const id = c.getState_PrimitiveId();
if (used.has(id)) continue;
const dd = Math.abs(Math.round(c.getState_X()) - x) + Math.abs(Math.round(c.getState_Y()) - y);
if (dd < bd) { bd = dd; best = c; }
}
if (!best) continue;
used.add(best.getState_PrimitiveId());
await best.toAsync().setState_Designator(d).done();
if (bd > 0) { await best.toAsync().setState_X(x).setState_Y(y).done(); moved++; }
k++;
}
return k;
""" % json.dumps([[p[0], p[2], p[3]] for p in place]))
if named != len(place):
raise SystemExit(u'只认领到 %d/%d 个位号' % (named, len(place)))
raw = call(READ_PINS_JS)
pins = {k: {str(n): (x, y) for n, _nm, x, y in v} for k, v in raw.items()}
pnames = {k: {str(n): (_nm or '').upper() for n, _nm, x, y in v} for k, v in raw.items()}
# ⚠️ 与 R1 逐脚比对(§6b/§6c:rotation 语义不可推,只能读回来断言)
# 实测这一页 42 件里有 27 件的 rot=270 与 R1 语义相反:
# · 25 件是两脚对称件(0402 R/C),只有引脚**编号**互换,坐标集相同;
# · **C10 / C14 两颗 33nF 是 +20/−15 的不对称跨距**,翻过来坐标真的差 5,
# 照 R1 的坐标画线会**断在那两颗上** —— 正是 skill §8d 那条「跨距同族也不统一」。
# ⇒ 对不上就 setState_Rotation(rot+180) 再回读,直到逐脚(含编号)与 R1 一致。
tgt = {q[0]: [q[2], q[3], q[4]] for q in place}
for _round in range(4):
miss = [d for d, wp in want.items()
if pins.get(d) != {n: tuple(xy) for n, xy in wp.items()}]
if not miss:
break
flip = []
for d in sorted(miss):
tgt[d][2] = (tgt[d][2] + 180) % 360
flip.append([d, tgt[d][0], tgt[d][1], tgt[d][2]])
# ⚠️ 第一轮往往「看起来没生效」:create(270) 与 setState_Rotation(270)
# 差 180°(skill §6b),所以要翻两次才落到 R1 的语义上。别以为卡住了。
print(u' 翻转 %d 件重试:%s' % (len(flip), ' '.join(f[0] for f in flip)))
call(FLIP_JS % json.dumps(flip))
raw = call(READ_PINS_JS)
pins = {k: {str(n): (x, y) for n, _nm, x, y in v} for k, v in raw.items()}
pnames = {k: {str(n): (_nm or '').upper() for n, _nm, x, y in v} for k, v in raw.items()}
bad = []
for d, wp in sorted(want.items()):
got = pins.get(d)
if not got:
bad.append(u'%s 没放上' % d); continue
for n, xy in sorted(wp.items()):
if got.get(n) != tuple(xy):
bad.append(u'%s.%s 期望 %s 实测 %s' % (d, n, tuple(xy), got.get(n)))
if bad:
raise SystemExit(u'引脚坐标与 R1 对不上(%d 处):\n ' % len(bad) + '\n '.join(bad[:20]))
W1, F1_, PT1 = entry_block(pins, pnames)
W, F, PT = W0 + W1, F0 + F1_, PT0 + PT1
err = selfcheck(W, F, PT, pins)
if err:
raise SystemExit(u'本地自检失败(%d 条):\n ' % len(err) + '\n '.join(err[:25]))
def batch(items, size, js):
for i in range(0, len(items), size):
call(OPEN + js % json.dumps(items[i:i + size], ensure_ascii=False))
batch(W, 40, "for (const s of %s) await eda.sch_PrimitiveWire.create(s); return 1;")
batch(PT, 25, "for (const p of %s) await eda.sch_PrimitiveComponent.createNetPort(p[0],p[1],p[2],p[3],p[4],false); return 1;")
batch(F, 25, "for (const f of %s) await eda.sch_PrimitiveComponent.createNetFlag(f[0],f[1],f[2],f[3],f[4],false); return 1;")
batch([list(t) for t in TEXTS], 15,
"for (const t of %s) await eda.sch_PrimitiveText.create(t[0],t[1],t[2],0,null,null,t[3],false,false,false,0); return 1;")
print(u'%s: 元件 %d 导线 %d 旗 %d 端口 %d 文字 %d'
% (PAGE_NAME, len(place), len(W), len(F), len(PT), len(TEXTS)))
if __name__ == '__main__':
main()