# -*- coding: utf-8 -*- u"""**在服务器上**扫「两只风扇座放哪里 + 这四个网能不能布通」—— 位置和可布性一起挑。 bash .claude/r2/remote_repair.sh --scan # 入口(本机) DCDC_ROUTE_SERVER=1 pypy3 .claude/r2/fan_scan.py # 服务器上实际执行的 ## 为什么非扫不可 J26 / J27 挪到顶面之后落在西北角,而那一带 **L1 整片是 `+12V` 大铜** (`pcb_base.l1_12v_rects()` 主块 x 0.5~27.8 / y 0.5~97.5)⇒ 过孔上不去, 等于**单层(L4)布线**,而 A6 的 L4 挤满了风扇驱动的那一窝件。 于是「几何上放得下」不等于「线接得上」:第一版落位 (7.5, 81.1) / (11.5, 75.1) 几何全过,补线却有 4 条没布通。 ⇒ 候选落位(`_base_fan_cands.json`,本机按几何 + 焊盘净距筛出来的)逐个真跑一遍补线, 按「未完成条数 → 总铜长」挑。单轮补线在 PyPy 上约 1.2 s,几十个候选一分钟就扫完。 输出 `_base_fan_scan.json`:每个候选的未完成数 / 总长,以及最优那个的完整段与孔。 """ import copy import io import json import os import sys import time 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'⛔ 布线搜索只在 10.5.5.13 上跑(CLAUDE.md):bash .claude/r2/remote_repair.sh --scan') import router as RT # noqa: E402 import route_base as RB # noqa: E402 import place_base as PL # noqa: E402 import pcb_base as B # noqa: E402 NETS = ['FAN_12V_SW', 'FAN_IN_TACH_RAW', 'FAN_OUT_TACH_RAW', 'FAN_PWM_L'] # `FAN_W=0.15`:见 repair_base.py —— 0402 两脚净距 0.30,默认宽度下 A* 找不到出口格点。 if os.environ.get('FAN_W'): _w = float(os.environ['FAN_W']) for _n in NETS: RT.WIDTH[_n] = _w _orig_rule_min = RT.rule_min RT.rule_min = lambda net, auto: (_w if net.startswith('FAN_') else _orig_rule_min(net, auto)) print(u'线宽覆盖:FAN_* = %.2f mm' % _w) LIMIT = int(os.environ.get('SCAN_LIMIT') or 0) cp = json.load(io.open(os.path.join(HERE, '_base_fan_input.json'), encoding='utf-8')) cands = json.load(io.open(os.path.join(HERE, '_base_fan_cands.json'), encoding='utf-8')) if LIMIT: cands = cands[:LIMIT] base_pads = RB.load_pads() nat = PL.native() def moved_pads(place): u"""把 J26 / J27 的焊盘搬到候选位置(只改这两件的脚,别的一只不动)。 ⚠ 通孔盘在 `route_base.load_pads()` 里已经被拆成 L4 可布 + L1 障碍两份 (`TH_SUFFIX`),两份都要搬,否则 L1 那份还挡在老地方。 """ out = [] for p in base_pads: ref = p[0] if ref not in ('J26', 'J27'): out.append(p) continue cx, cy, rot = place[ref] pn = str(p[1])[:-len(RB.TH_SUFFIX)] if str(p[1]).endswith(RB.TH_SUFFIX) else str(p[1]) src = [q for q in nat[ref] if str(q[0]) == pn] if not src: out.append(p) continue _pn, nx, ny, _w, _h, _net, _sh = src[0] dx, dy = PL.pad_off(nx, ny, rot, B.TOP) q = list(p) q[2], q[3] = round(cx + dx, 4), round(cy + dy, 4) out.append(q) return out def try_one(place): pads = moved_pads(place) 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']] bypad = {} for p in pads: if not p[7] or p[7] not in NETS or str(p[1]).endswith(RB.TH_SUFFIX): continue bypad.setdefault(p[7], []).append(p) n0, v0 = len(segs), len(vias) RT.AUTO_CACHE.clear() logs = RT.route_groups(bypad, pads, segs, vias, board_vias, {}, priority=tuple(NETS), only=set(NETS)) chk = [q for q in RB.check_pads(pads) if q[7] in set(NETS)] left = RT.check_connected(chk, segs, vias) new_s = [list(s) for s in segs[n0:]] new_v = [list(v) for v in vias[v0:]] length = sum(((s[3] - s[1]) ** 2 + (s[4] - s[2]) ** 2) ** 0.5 for s in new_s) return {'logs': list(logs), 'unconnected': list(left), 'segs': new_s, 'vias': new_v, 'len': round(length, 2)} BODY = (10.2, 5.0) # 2510-4A 本体(`place_base.BODY`) def overlap(a, b, gap=0.6): u"""⛔ 两只座的**本体**不许重叠(顶面同一面)。 2026-09-12 踩过:候选表只按「y 差 ≥ 5.6」过滤,J26 钉死在原位那一轮没带这条, 扫出来的「最优」是 J26=(13.5, 94.5) / J27=(12.0, 94.5) —— 两只座**摞在一起**, 布线代价当然最低。几何判据缺一条,搜索就会精准地钻这个空子。 """ ax0, ax1 = a[0] - BODY[0] / 2 - gap / 2, a[0] + BODY[0] / 2 + gap / 2 ay0, ay1 = a[1] - BODY[1] / 2 - gap / 2, a[1] + BODY[1] / 2 + gap / 2 bx0, bx1 = b[0] - BODY[0] / 2 + gap / 2, b[0] + BODY[0] / 2 - gap / 2 by0, by1 = b[1] - BODY[1] / 2 + gap / 2, b[1] + BODY[1] / 2 - gap / 2 return not (ax1 <= bx0 or ax0 >= bx1 or ay1 <= by0 or ay0 >= by1) rows = [] skipped = 0 t0 = time.time() for k, (a, b) in enumerate(cands): if overlap(a, b): skipped += 1 continue place = {'J26': (a[0], a[1], a[2]), 'J27': (b[0], b[1], b[2])} try: r = try_one(place) except Exception as e: # noqa: BLE001 print(u' [%d] %s / %s 跑挂了: %s' % (k, a, b, e)) continue rows.append({'J26': a, 'J27': b, 'open': len(r['logs']), 'split': len(r['unconnected']), 'len': r['len'], 'segs': r['segs'], 'vias': r['vias'], 'logs': r['logs'], 'unconnected': r['unconnected']}) if (k + 1) % 10 == 0: best = min(rows, key=lambda q: (q['open'], q['split'], q['len'])) print(u' 扫了 %d/%d(%.0f s);目前最好:未完成 %d / 断 %d / %.1f mm J26=%s J27=%s' % (k + 1, len(cands), time.time() - t0, best['open'], best['split'], best['len'], best['J26'], best['J27'])) sys.stdout.flush() rows.sort(key=lambda q: (q['open'], q['split'], q['len'])) print(u'\n扫完 %d 个候选,用时 %.0f s。前 8 名:' % (len(rows), time.time() - t0)) for q in rows[:8]: print(u' 未完成 %d / 断 %d / %6.1f mm J26=%s J27=%s' % (q['open'], q['split'], q['len'], q['J26'], q['J27'])) out = {'best': rows[0] if rows else None, 'top': [{k: v for k, v in q.items() if k not in ('segs', 'vias')} for q in rows[:30]], 'tried': len(rows)} json.dump(out, io.open(os.path.join(HERE, '_base_fan_scan.json'), 'w', encoding='utf-8'), ensure_ascii=False) print(u'已写 _base_fan_scan.json(best 里带完整段 / 孔)')