# -*- 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)