布线(窗口整体重布,已下发): - 拆窗口内 43 段(用户手布的那批,用户选「拆掉重来」),补 139 段 / 7 孔, GND 回流孔 10 个;fan_check 对板上真实快照逐项净距达标(含「新孔 vs 内层线」)。 - ⛔ 根因:pcb_base.PD_L1 里的 L1 走廊是按**旧摆位**开的(注释坐标全是旧的), 用户重摆后与件对不上 ⇒ 四条长途网必然布不通。重开六条走廊后未完成 4 → 2。 走廊必须盖住焊盘正上方,只盖沿途不行。断面:几何 11.80 / 铺铜后实测 9.82(要 8.0)。 - 与拆线前逐网对比:没引入任何新的连通性问题(GND 未接 58→48、+3V3 2→0、 两路 TACH 2/1→0、+12V 一字不差)。 定版:板上铜以**用户自己布好的那版**为准,不再改动。 丝印(silk_base.py,已下发并读回核对 150/150): - 压焊盘 128→4、压过孔 48→8、越框 8→3、互相重叠 34 对→0;字号不变。 - 三条坑:rotation 是度且不归一化(900=180°);别拿「本体≥5mm」当保留白名单 (会豁免掉 R9A/D3/U13 这些该重排的件);「离本体」必须设硬上限 + 降级策略。 新工具:silk_base.py(丝印重排)、l1_section.py(L1 12V 主块逐 y 断面核算)、 fan_rip.py(按窗口拆线,拆前后比对全板线/孔总数防连带删除)。 Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_0168Bu6SEx3cXXCJLkdnE6zB
304 行
17 KiB
Python
304 行
17 KiB
Python
# -*- coding: utf-8 -*-
|
||
u"""**窗口内整体重布**:把一块矩形区域里的几个网连同周边 `+3V3` / `GND` 局部一起重布。
|
||
|
||
bash .claude/r2/remote_repair_window.sh # 入口(本机)
|
||
DCDC_ROUTE_SERVER=1 pypy3 .claude/r2/repair_window.py
|
||
|
||
## 和 `repair_base.py` 的区别(为什么要单独一份)
|
||
|
||
`repair_base` 是**按网**补:给它 `+3V3`,它就在**全板**范围重布这个网 —— 2026-09-12 实测,
|
||
它把新的 `+3V3` 线从 **J34~J37 插槽针排的焊盘中间穿过去**(`fan_check` 十几处 −0.2,
|
||
还压 U13.7 −0.449)。插槽 1 mm 间距、手焊,那是致命的。
|
||
|
||
这一份改成**按窗口**补,三条约束缺一不可:
|
||
|
||
1. **`bypad` 只装窗口内的脚**。窗口外的同网脚一概不参与 ⇒ 布线器不会去动全板的主干。
|
||
2. **窗口外的已有铜照常留在占用图里**。它们是「本网已有的铜」,A* 从窗口内的脚出发,
|
||
碰到就算到达 —— 这就是和外面主干的接口,不需要显式指定接口点。
|
||
3. **窗口外加高过路费**(`OUT_PEN`)。不是封死:万一窗口内绕不出去,它还能出去一点点,
|
||
但代价高到不会乱跑。封死会让本来能绕的网直接报「没布通」。
|
||
|
||
拆线(窗口内目标网的旧铜)由本机 `remote_repair_window.sh` 先做掉,这里只读拆完的板面快照。
|
||
|
||
输入:`_base_fan_input.json`(板上现有铜)+ `_base_window.json`(窗口与网名,本机写)。
|
||
输出:`_base_window_repair.json`(只有新增的段与孔)。
|
||
"""
|
||
import io
|
||
import json
|
||
import math
|
||
import os
|
||
import sys
|
||
|
||
sys.stdout.reconfigure(encoding='utf-8')
|
||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||
sys.path.insert(0, os.path.dirname(HERE))
|
||
sys.path.insert(0, HERE)
|
||
|
||
if not os.environ.get('DCDC_ROUTE_SERVER'):
|
||
sys.exit(u'⛔ 布线(A*)只在 10.5.5.13 上跑(CLAUDE.md):bash .claude/r2/remote_repair_window.sh')
|
||
|
||
import router as RT # noqa: E402
|
||
import route_base as RB # noqa: E402
|
||
|
||
CFG = json.load(io.open(os.path.join(HERE, '_base_window.json'), encoding='utf-8'))
|
||
WIN = tuple(CFG['window']) # (x0, y0, x1, y1)
|
||
NETS = [n.replace('@', '$') for n in CFG['nets']]
|
||
OUT_PEN = int(CFG.get('out_pen', 40))
|
||
# 朝插槽那一侧的过路费:必须高到 A* 不会为了抄近路钻进槽区(槽体 x 28~70,手焊 1 mm 针排)
|
||
SLOT_PEN = int(CFG.get('slot_pen', 200))
|
||
# 虚拟落点沿主干往窗内退多少(避开压在主干线上导致起步格点为 0)
|
||
RETREAT = float(CFG.get('retreat', 0.75))
|
||
# 窗口内「别的网」的焊盘外扩多少封死(收费挡不住,见 _pen 的注释)
|
||
PAD_KEEP = float(CFG.get('pad_keep', 0.10))
|
||
cp = json.load(io.open(os.path.join(HERE, '_base_fan_input.json'), encoding='utf-8'))
|
||
|
||
# ── L1 走廊(`pcb_base.PD_L1` 的运行时追加)─────────────────────────────────
|
||
# ⛔ A6 那一带 L1 整片是 `+12V` 主块(x 0.5~27.8)⇒ 过孔上得去但走不动,等于单层,
|
||
# 而 L4 已被 22 件占满。`PD_L1` 里那几条走廊是**按旧摆位**开的(注释里写的坐标就是旧的),
|
||
# 用户重摆之后与件对不上 ⇒ 长途网(FAN_EN / 两路 TACH / FAN_PWM_L)必然布不通。
|
||
# 这里让走廊跟着 `_base_window.json` 走,免得每试一组就改 `pcb_base.py`。
|
||
# ⚠ 判据(§6.4):逐 y 的水平断面上,Σ 走廊 x 跨度 ≤ 27.30 − 8.00 = 19.30 mm。
|
||
# 本机 `l1_section.py` 核;走廊 x 一律 ≤ 27.5,槽指头在 x ≥ 27.5,天然不碰。
|
||
_extra = CFG.get('pd_l1_extra')
|
||
if _extra is not None:
|
||
_keep = [(n, r) for n, r in RB.B.PD_L1 if not n.startswith('FAN')]
|
||
RB.B.PD_L1 = _keep + [(n, tuple(r)) for n, r in _extra]
|
||
print(u'L1 走廊:保留 %d 条非风扇 + 本轮 %d 条 %s'
|
||
% (len(_keep), len(_extra), ' '.join(sorted(set(n for n, _ in _extra)))))
|
||
|
||
pads = RB.load_pads()
|
||
|
||
|
||
def in_win(x, y, m=0.0):
|
||
return WIN[0] - m <= x <= WIN[2] + m and WIN[1] - m <= y <= WIN[3] + m
|
||
|
||
|
||
# ── 窗口外加过路费:包住整块板,挖掉窗口 ──────────────────────────────────
|
||
_orig_pen = RT.pen_pads
|
||
|
||
|
||
def _toll_pads():
|
||
u"""窗口内**不属待布网**的焊盘 —— 必须一起收过路费,否则新线会贴着它们走。
|
||
|
||
2026-09-12 实测:不收的话,`+3V3` 贴 `Q8.3` 只剩 **0.003**、`FAN_OUT_TACH_RAW` 贴
|
||
`Q10.2` 只剩 **0.027**(判据 0.15),`fan_check` 一跑就拦下来、整版不能下发。
|
||
`route_base._pen` 本来就是靠 `GND_PEN_PADS` / `LATER_PEN_PADS` 收这笔费的,
|
||
我覆盖 `RT.pen_pads` 时漏了这一步。
|
||
|
||
⛔ 判据是「**不是当前正在布的那个网**」,不是「不在待布网集合里」。第一版写成后者,
|
||
于是 `Q8.3`(网 `$6N48`,本身也在待布名单里)被漏掉 —— 实测它周围过路费始终是 0,
|
||
而 `+3V3` 的新线就贴着它走到 **0.003**。同属待布名单的别的网,对当前这个网来说一样是障碍。
|
||
"""
|
||
out = []
|
||
for p in pads:
|
||
if p[6] not in RT.LAY or str(p[1]).endswith(RB.TH_SUFFIX):
|
||
continue
|
||
if in_win(p[2], p[3]):
|
||
out.append(p)
|
||
return out
|
||
|
||
|
||
def _pen(g, ps, halfw):
|
||
u"""窗口外收过路费。⛔ **不能各向同性**(2026-09-12 实测)。
|
||
|
||
朝插槽那一侧(x > 窗口东界)必须近乎封死:`OUT_PEN` 从 40 降到 12 的那一轮,
|
||
`$6N64` / `FAN_EN` 立刻跑到 x 28.75~32.50 —— **正是槽体 x 28~70 的插槽区**,
|
||
183 段里 50 段跑出窗口(之前每轮都是 0)。而降它本是为了让跨窗口的网够得着 U20 那边的主干。
|
||
⇒ 分开定价:东侧(插槽方向)用 `SLOT_PEN`,其余三面用 `OUT_PEN`。
|
||
"""
|
||
_orig_pen(g, list(ps) + _toll_pads(), halfw)
|
||
# ⛔ 光收过路费**挡不住**(2026-09-12 第二次印证,同「插槽方向」那一条):
|
||
# 实测 `Q8.3` 周围过路费确实是 10,A* 照样贴着它走到 **0.003**;`Q10.2` 同理 0.027。
|
||
# `penalize` 只是定价,`block_rect` 才是墙 ⇒ 窗口内**别的网**的焊盘按 `PAD_KEEP` 外扩封死。
|
||
# 代价实测很小:外扩 0.10 时窗口内可走格点 7297 → 6802(72% → 68%,少 495 个)。
|
||
# ⛔ 外扩量必须按**当前这一档的线宽**算,不能写死。2026-09-12 实测:按 0.15 细线定的
|
||
# `PAD_KEEP=0.10` 挡不住 0.40 的 `+3V3`(它半宽就有 0.20)—— 照样贴 `Q8.3` 到 **0.003**。
|
||
# `route_groups` 是按线宽分档建图的,每档传进来的 `halfw` 正是「半宽 + 净距」,直接用它。
|
||
# (一刀切取最粗的 0.56 会把细线通道也削掉 1/3:窗口内可走格点 68% → 52%。)
|
||
r = max(PAD_KEEP, halfw)
|
||
for p in _toll_pads():
|
||
g.block_rect(p[6], p[2] - p[4] / 2 - r, p[3] - p[5] / 2 - r,
|
||
p[2] + p[4] / 2 + r, p[3] + p[5] / 2 + r, net=p[7])
|
||
for l in RT.LAY:
|
||
for r in RB.B.rect_minus((0.0, 0.0, RB.B.BW, RB.B.BH), [WIN]):
|
||
if r[0] >= WIN[2] - 1e-6:
|
||
continue # 东侧(插槽方向)下面单独封死,不是加价
|
||
g.penalize(l, r[0], r[1], r[2], r[3], OUT_PEN)
|
||
# ⛔ 插槽方向必须**封死**,不能只加价:本机实测过路费确实加到了 200
|
||
# (窗口内 0 / 插槽区 200),A* 照样钻 —— `$6N64` 宁可付 200×若干格也要从
|
||
# x 25.75 抄到 27.75,因为窗口内绕不过去。`penalize` 只是定价,`block_rect` 才是墙。
|
||
# 槽体 x 28~70 是 1 mm 间距的手焊针排,走线进去是致命的。
|
||
g.block_rect(l, WIN[2], 0.0, RB.B.BW, RB.B.BH, net='#slotwall')
|
||
|
||
|
||
RT.pen_pads = _pen
|
||
|
||
segs = [tuple(s) for s in cp['segs']]
|
||
vias = [tuple(v) for v in cp['vias']]
|
||
board_vias = [tuple(v) for v in cp['board_vias']]
|
||
|
||
# ⛔ 只装**窗口内**的脚;窗口外的同网铜留在 segs 里当「已有铜」。
|
||
bypad = {}
|
||
outside = {}
|
||
for p in pads:
|
||
if not p[7] or p[7] not in NETS or str(p[1]).endswith(RB.TH_SUFFIX):
|
||
continue
|
||
if in_win(p[2], p[3]):
|
||
bypad.setdefault(p[7], []).append(p)
|
||
else:
|
||
outside[p[7]] = outside.get(p[7], 0) + 1
|
||
|
||
# ⛔⛔ 2026-09-12 实测(第一版**漏了这一步,是设计缺陷**):
|
||
# 「窗口外的同网铜留在占用图里,A* 碰到就算到达」——**不成立**。
|
||
# `route_groups` 布的是 `bypad[net]` 里的脚**两两相连**,窗外主干根本不是它的终点判据。
|
||
# 于是「窗外还有脚」的网全军覆没:`FAN_PWM` / `FAN_EN` 窗内只剩 1 只脚 ⇒ 跨度 0、无事可做;
|
||
# `FAN_TACH_IN` / `FAN_TACH_OUT` / `+3V3` 窗内那两只脚彼此并不需要连(各自要去的是窗外主干)
|
||
# ⇒ 实测这 5 个网**一段都没产出**,而窗外脚数为 0 的 8 个网全部正常产出。
|
||
# ⇒ 把主干在**窗口边界处的端点**做成虚拟焊盘塞进 bypad,A* 才有终点可去
|
||
# (同 `route_base` 用 `finger_escape()` 的 launch 点喂金手指的办法)。
|
||
def _clip(v, lo, hi):
|
||
return max(lo, min(hi, v))
|
||
|
||
|
||
edge_pts = {}
|
||
for s in cp['segs']:
|
||
lay, x1, y1, x2, y2, w, net = s[0], s[1], s[2], s[3], s[4], s[5], s[6]
|
||
if net not in NETS or lay not in RT.LAY:
|
||
continue
|
||
a, b = in_win(x1, y1), in_win(x2, y2)
|
||
if a == b:
|
||
continue # 整段在窗内或整段在窗外,都不是接口
|
||
if lay != RB.B.BOTTOM:
|
||
# ⛔ 落点不能取在 **L1**:那一层在这一带整片是 `+12V` 大铜,格点是 255(多网叠加,
|
||
# 谁都不能走)。实测 `FAN_EN` 的落点取到 L1 之后「起步格点 0 个」,直接报「出不来」。
|
||
continue
|
||
ix, iy = (x1, y1) if a else (x2, y2) # 取落在窗内的那一头
|
||
# ⛔ 端点正好压在主干线上(`FAN_PWM` 实测净距 −0.127)⇒ 起步格点也是 0。
|
||
# 沿这一段往**窗内**退 RETREAT,让出一个能起步的格点。
|
||
ox, oy = (x2, y2) if a else (x1, y1) # 段的另一头(窗外那一侧)
|
||
seg_len = math.hypot(ix - ox, iy - oy)
|
||
if seg_len > 1e-6:
|
||
ux, uy = (ix - ox) / seg_len, (iy - oy) / seg_len
|
||
ix, iy = ix + ux * min(RETREAT, seg_len * 0.4), iy + uy * min(RETREAT, seg_len * 0.4)
|
||
edge_pts.setdefault(net, []).append((lay, round(ix, 4), round(iy, 4), w))
|
||
# 主干整条都在窗外的网:取离窗口最近的那个端点,夹到窗边上当落点
|
||
for net in NETS:
|
||
if net in edge_pts or not outside.get(net):
|
||
continue
|
||
best = None
|
||
for s in cp['segs']:
|
||
# ⛔ 同样只在 L4 找:窗缘落点分支第一版漏了这条,`FAN_EN` 的落点取到 **L1**,
|
||
# 而那一层这一带整片是 `+12V` 大铜、格点是 255(多网叠加,谁都不能走),
|
||
# 于是「起步格点 0 个 ⇒ 出不来」,本机单独验那个坐标却有 16 个可起步 —— 差别就在层。
|
||
if s[6] != net or s[0] != RB.B.BOTTOM:
|
||
continue
|
||
for (px, py) in ((s[1], s[2]), (s[3], s[4])):
|
||
cx = _clip(px, WIN[0], WIN[2])
|
||
cy = _clip(py, WIN[1], WIN[3])
|
||
d = ((px - cx) ** 2 + (py - cy) ** 2) ** 0.5
|
||
if best is None or d < best[0]:
|
||
best = (d, s[0], px, py, s[5])
|
||
if best and best[0] <= 6.0: # 主干就在窗边上:直接拿它当落点
|
||
edge_pts.setdefault(net, []).append((best[1], round(best[2], 4), round(best[3], 4), best[4]))
|
||
elif best:
|
||
# ⛔ 主干远在窗外(`FAN_EN` / `FAN_TACH_OUT` 的最近端点在 x≈42~43,**在槽区里面**,
|
||
# 离窗 14.5~15.6 mm)。**不能**放宽上限去接它 —— 那等于让 A* 从窗口里拉一条 15 mm 的线
|
||
# 横穿插槽区,正是设窗口要避免的事(上一版 `+3V3` 穿针排就是这么来的)。
|
||
# ⇒ 在**窗口边缘**给它一个落点:窗内这一段布到边上为止,窗外那一小段单独接。
|
||
ex = _clip(best[2], WIN[0] + 0.3, WIN[2] - 0.3)
|
||
ey = _clip(best[3], WIN[1] + 0.3, WIN[3] - 0.3)
|
||
edge_pts.setdefault(net, []).append((best[1], round(ex, 4), round(ey, 4), best[4]))
|
||
print(u' ⚠ %s 的主干离窗 %.1f mm(在槽区里),只布到窗缘 (%.2f, %.2f),窗外那段要另接'
|
||
% (net, best[0], ex, ey))
|
||
|
||
n_virt = 0
|
||
for net, pts in edge_pts.items():
|
||
pts.sort(key=lambda q: -q[3]) # 宽的(主干)优先
|
||
for (lay, px, py, w) in pts[:2]: # 每个网最多给两个落点,够 A* 挑
|
||
bypad.setdefault(net, []).append(
|
||
['#trunk', 'edge%d' % n_virt, px, py, 0.3, 0.3, lay, net, 'RECT'])
|
||
n_virt += 1
|
||
print(u'给跨窗口的网补了 %d 个「主干接口」虚拟落点:%s'
|
||
% (n_virt, ' '.join(sorted(edge_pts)) or u'无'))
|
||
print(u'窗口 x %.2f~%.2f y %.2f~%.2f;窗口外过路费 %d' % (WIN[0], WIN[2], WIN[1], WIN[3], OUT_PEN))
|
||
for n in NETS:
|
||
print(u' %-18s 窗口内 %d 脚 / 窗口外 %d 脚(外面的不参与,只当已有铜的接口)'
|
||
% (n, len(bypad.get(n, [])), outside.get(n, 0)))
|
||
drop = [n for n in NETS if not bypad.get(n)]
|
||
if drop:
|
||
print(u' (这些网窗口内一只脚都没有,跳过:%s)' % ' '.join(drop))
|
||
|
||
print(u'现有铜 %d 段 / %d 孔' % (len(segs), len(vias)))
|
||
n0, v0 = len(segs), len(vias)
|
||
RT.AUTO_CACHE.clear()
|
||
|
||
# ⛔ 诊断:`$6N49` / `$6N74` / `$6N45` 这几条链一路报「一根铜都没有」,而窗口空着 85%、
|
||
# 焊盘尺寸与 width_of 都正常 —— 看着不像拥塞,像**布线器压根没处理它们**。
|
||
# `route_groups` 按线宽分档、逐档调 A*,这里把「每一档收了哪些网」和每档前后的段数打出来,
|
||
# 一眼就能看出某个网是不是从头到尾没进过任何一档。
|
||
_orig_rg = RT.route_groups
|
||
|
||
|
||
def _rg(bp, pd, sg, vi, bv, auto, priority=(), only=None):
|
||
want = set(only) if only is not None else set(bp)
|
||
seen = {}
|
||
for net, ps in bp.items():
|
||
if only is not None and net not in only:
|
||
continue
|
||
seen[net] = round(RT.width_of(net, auto, ps), 3)
|
||
buckets = {}
|
||
for net, w in seen.items():
|
||
buckets.setdefault(w, []).append(net)
|
||
print(u' 分档(线宽 → 网):')
|
||
for w in sorted(buckets, reverse=True):
|
||
print(u' %.3f mm: %s' % (w, ' '.join(sorted(buckets[w]))))
|
||
miss = sorted(want - set(seen))
|
||
if miss:
|
||
print(u' ⚠ 这些网在 only 里却没进任何一档:%s' % ' '.join(miss))
|
||
before = len(sg)
|
||
out = _orig_rg(bp, pd, sg, vi, bv, auto, priority=priority, only=only)
|
||
print(u' route_groups 共产出 %d 段' % (len(sg) - before))
|
||
by = {}
|
||
for s in sg[before:]:
|
||
by[s[6]] = by.get(s[6], 0) + 1
|
||
for net in sorted(seen):
|
||
print(u' %-18s %s' % (net, (u'%d 段' % by[net]) if net in by else u'⛔ 0 段'))
|
||
return out
|
||
|
||
|
||
RT.route_groups = _rg
|
||
# ⛔ 顺序决定成败:`route_groups` 按「出口数少 → 跨度大」排,`$6N49` 排在 `FAN_12V_SW`(37 段) 之后,
|
||
# 走廊被先布的线占掉 ⇒ 它四只脚**两两直连全被挡**(本机实测 −0.499 ~ +0.139,最好的一对差 0.011),
|
||
# 报「一根铜都没有」,看着像板子挤,其实是顺序。router 自己的注释里记过同一个坑
|
||
# ("同样的摆位,先布它就通")。⇒ 把这几条挤在一起的本地链**顶到 priority 最前面**。
|
||
PRI = tuple(CFG.get('priority') or ()) + tuple(n for n in NETS if n not in (CFG.get('priority') or ()))
|
||
print(u'布线优先级:%s' % ' '.join(PRI[:6]))
|
||
logs = RT.route_groups(bypad, pads, segs, vias, board_vias, {},
|
||
priority=PRI, only=set(bypad))
|
||
if logs and '--repair' in sys.argv:
|
||
print(u' 还剩 %d 条没布通,拆线重布(只拆本次新布的)' % len(logs))
|
||
segs, vias, logs = RT.repair(bypad, pads, segs, vias, logs, board_vias, {}, n0, v0)
|
||
|
||
new_s = [list(s) for s in segs[n0:]]
|
||
new_v = [list(v) for v in vias[v0:]]
|
||
print(u'新增 %d 段 / %d 孔' % (len(new_s), len(new_v)))
|
||
for m in logs:
|
||
print(u' [!] ' + m)
|
||
|
||
# 连通性只看窗口内的脚(窗口外的主干本来就通)
|
||
chk = [q for q in RB.check_pads(pads) if q[7] in set(bypad) and in_win(q[2], q[3])]
|
||
left = RT.check_connected(chk, segs, vias)
|
||
print(u'连通性(窗口内口径):%s' % (u'全通' if not left else left))
|
||
|
||
# 跑出窗口的新铜要点名 —— 这是这套办法最该盯的失败模式
|
||
stray = [s for s in new_s if not (in_win(s[1], s[2], 0.5) and in_win(s[3], s[4], 0.5))]
|
||
print(u'跑出窗口的新线段:%d%s' % (len(stray), u' ⚠' if stray else u' ✓'))
|
||
for s in stray[:8]:
|
||
print(u' %s L%s (%.2f,%.2f)->(%.2f,%.2f)' % (s[6], s[0], s[1], s[2], s[3], s[4]))
|
||
|
||
json.dump({'window': list(WIN), 'nets': NETS, 'segs': new_s, 'vias': new_v,
|
||
'logs': list(logs), 'unconnected': list(left), 'stray': len(stray)},
|
||
io.open(os.path.join(HERE, '_base_window_repair.json'), 'w', encoding='utf-8'),
|
||
ensure_ascii=False)
|
||
print(u'已写 _base_window_repair.json')
|