- 规则:铜层 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
554 行
29 KiB
Python
554 行
29 KiB
Python
# -*- coding: utf-8 -*-
|
||
u"""R2 底板 **A6-FAN** 页:风墙驱动(高边总开关 + 反相 PWM + 两路测速)+ 两路 NTC 测温。
|
||
|
||
/c/Python314/python .claude/r2/a6_fan.py # 只算 + 本地自检
|
||
/c/Python314/python .claude/r2/a6_fan.py --push # 清页 + 重画(幂等)
|
||
|
||
## 内容从哪来
|
||
|
||
| 块 | 来源 | 处理 |
|
||
|---|---|---|
|
||
| 风扇全部(`Q6~Q10` `D2` `DZ3` `J26/J27` `C738` `R733/R734/R746~R757`)| R1 `P6-FAN-EDL`(`_p6_live.json`)| **拓扑一字不改**,版图重排 |
|
||
| NTC(`RT601` `RT602` + 分压电阻 + 滤波电容)| R1 `P3-CONTROL`(`_p3_live.json`)| 拓扑照搬,**位号改号** |
|
||
|
||
⛔ **EDL 部分不在本页**(已随方案挪到刀片 `B3-PHONE`)。
|
||
实测 `_p6_live.json` 里 **一件 EDL 器件都没有** —— 24 个元件全是风扇块
|
||
(R1 早在 2026-09-06 就把第三片 74HC595 / `U33` 整条链挪到载板 `C6-KEYS` 了,
|
||
页面 `texts` 里还留着「已搬走」那两行说明)。所以没有「逐件挑掉」这一步,
|
||
`REF_KEEP` 就是 dump 里的全部 24 件,脚本用它做逐脚拓扑对账(见下)。
|
||
|
||
## 位号改号(NTC 那一块)
|
||
|
||
R1 P3 的 NTC 上臂用的是 `R601~R650` 那一档(`R618` / `R619`,10k `C25744`),
|
||
而契约 §2 把 `R601~R650` 整段划给了 **A3-CTRL**。⇒ 本页改用 A6 的新号段 `R660~R699`:
|
||
|
||
| R1 P3 | 本页 | 是什么 |
|
||
|---|---|---|
|
||
| `R618` | **`R660`** | NTC1 分压上臂 10k → `+3V3` |
|
||
| `R619` | **`R661`** | NTC2 分压上臂 10k → `+3V3` |
|
||
| `C613` | **`C736`** | NTC1 节点滤波 100nF |
|
||
| `C614` | **`C739`** | NTC2 节点滤波 100nF |
|
||
| `RT601` / `RT602` | 原名 | 契约 §2 明确点名给 A6 |
|
||
|
||
⚠ 契约 §2 只给了 A6 电阻新号段,**没给电容号段**(`C738` 是单点分配)。
|
||
`C736` / `C739` 是全表唯一两个没人认领的编号(A2 到 `C735`、A4 从 `C737`/`C740` 起、
|
||
A5 `C760~C790`),选它们既不撞车也不用改契约。**已在汇报里单列。**
|
||
|
||
## 版图为什么不是 1:1 平移
|
||
|
||
`schcheck.Sheet.place()` 的 `create(..., mirror=false)` 写死不支持镜像,
|
||
而 R1 P6 上 **`Q7` / `Q9` / `Q10` 三颗 MOS 是 `mirror=True`** 放的(栅极在右)。
|
||
⇒ 这三颗改用 **`rot=180` 不镜像**,栅极同样在右,但漏源上下互换:
|
||
|
||
AO3400A/AO3401A rot 0 : G(cx-20,cy) D(cx+10,cy+20) S(cx+10,cy-20)
|
||
rot180: G(cx+20,cy) D(cx-10,cy-20) S(cx-10,cy+20)
|
||
|
||
- `Q7`(P-MOS 高边)rot180 后 **S 在上 / D 在下**,正好还是「源极接 +12V 母线、
|
||
漏极向下出 `FAN_12V_SW`」,只是栅极网络要挪到它右边 ⇒ `DZ3` / `R746` / `R747`
|
||
从 x=150/240 挪到 x=380/470,`Q7` 留在 x=300。
|
||
- `Q9` / `Q10`(TACH 接收 N-MOS)rot180 后 **D 在下 / S 在上**,所以这两颗的
|
||
地旗在上(`Ground rot=180`)、`+3V3` 上拉旗在下(`Power rot=180`)。
|
||
看着别扭但电气一致;换成 rot0 会把栅极甩到左边,而原始 TACH 是从右边的风扇座
|
||
过来的,绕线要横穿 MOS 本体,更糟。**这是本页唯一一处违反「电源在上、地在下」的地方。**
|
||
|
||
改完靠 `topo_check()` 兜底:把 R1 dump 和本页各自**按几何反推一遍网表**,
|
||
再逐个 `位号.脚号` 比对分组,**完全不看坐标、不看网络名**。
|
||
只要拓扑动了一根线就会当场终止。
|
||
|
||
## 三条硬约束怎么落实的
|
||
|
||
1. **`FAN_EN` 复位态关电**(契约 §3 第 2 条):`FAN_EN` → `R748` 1k → `Q8` 栅极,
|
||
`R749` 100k 下拉;`Q8` 关 ⇒ `R746` 100k 把 `Q7` 栅极拉回源极 ⇒ P-MOS 截止。
|
||
ESP32 高阻 / 复位期间风扇不转。**拓扑照抄 R1,一点没简化。**
|
||
2. **`DZ3` 钳位**:`AO3401A` 的 `Vgs` 绝对最大 ±12V(`eda-parts` `C15127` 的 role),
|
||
13.2V 母线直接把栅极拉到地就超了。`DZ3`(BZT52C8V2) **K 接源极 / A 接栅极**。
|
||
3. **`D2`(SS34)**:**K 接 `FAN_12V_SW` / A 接 `GND`**,做负向钳位
|
||
(高边拓扑下它不再是低边续流二极管,见 `FAN_CIRCUIT.md`)。
|
||
|
||
⚠ 2 和 3 都是极性件,本项目已经在极性件上栽过三次(C60 电解、U5 镜像、C801)。
|
||
所以 `EXPECT` 里 **`D2` / `DZ3` 写死脚号→绝对坐标**(`'1': K 在上, '2': A 在下`),
|
||
不是 `two_pin()` 那种只比坐标集合的宽松判据 —— 翻 180° 会当场被 `place()` 判死。
|
||
`eda-parts` 的 role 说 `DZ3` 要 `create(rot=90)`、`D2` 要 `setState_Rotation(270)`,
|
||
两句话本身就打架;**本脚本一律传 270 并按脚号断言**,`place()` 的翻转重试循环
|
||
会把真实方向试出来,对不上就终止。方向表一张都别信(skill §6b/§6c)。
|
||
"""
|
||
import io
|
||
import json
|
||
import os
|
||
import sys
|
||
from collections import defaultdict
|
||
|
||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||
sys.path.insert(0, HERE)
|
||
from schcheck import Sheet, _on, call, PARTS # noqa: E402
|
||
|
||
PAGE, NAME = '68c9ffaa578572d3', 'A6-FAN'
|
||
R2_PROJECT = '05a65c22f14846b8b95360ef8d3c1e2e'
|
||
|
||
REF_P6 = os.path.join(HERE, '_p6_live.json') # R1 P6-FAN-EDL(实测只剩风扇)
|
||
REF_P3 = os.path.join(HERE, '_p3_live.json') # R1 P3-CONTROL(只取 NTC 六件)
|
||
|
||
# 器件键(`.claude/eda-parts.json`)
|
||
K_NMOS = 'C20917' # AO3400A
|
||
K_PMOS = 'C15127' # AO3401A(Vgs ±12V ⇒ 必配 DZ3)
|
||
K_SS34 = 'C8678' # SS34 肖特基
|
||
K_ZEN = 'C2891776' # BZT52C8V2 8.2V
|
||
K_FAN4 = 'C2847671' # 2510-4A 四线风扇座
|
||
K_22U = 'C12891' # 22uF/25V 1206(引脚 ±15,不是 ±20)
|
||
K_100N = 'C1525' # 100nF 0402
|
||
K_NTC = 'C13564' # NCP18XH103F03RB 10k B=3380K
|
||
K_1K = 'C11702'
|
||
K_10K = 'C25744'
|
||
K_15K = 'C25756' # TACH 栅极串阻(10k 会让高电平不够)
|
||
K_100K = 'C25741'
|
||
|
||
DY = -240 # 排风通道 = 进风通道整体下移
|
||
NTC_DX = 260 # NTC 第二路 = 第一路整体右移
|
||
|
||
# ── R1 → 本页的位号改号(NTC 那一块,见文档串)────────────────────────
|
||
NTC_REMAP = {'R618': 'R660', 'R619': 'R661', 'C613': 'C736', 'C614': 'C739'}
|
||
NTC_KEEP = set(['RT601', 'RT602', 'R660', 'R661', 'C736', 'C739'])
|
||
|
||
# ── 拓扑对账时要把 R1 侧的 1/2 脚对调的非极性件 ──────────────────────
|
||
# R1 上 R753/R757 的上拉在漏极**上方**(p2 接 +3V3);本页 Q9/Q10 转了 180°,
|
||
# 漏极在下,上拉挪到漏极**下方**(p1 接 +3V3)。电阻无极性,电气完全一致,
|
||
# 但按「位号.脚号」比对会不一致 ⇒ 在参照侧对调后再比。
|
||
REF_SWAP = set(['R753', 'R757'])
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# 引脚期望(skill §6b/§6c:方向表不可信,一律按脚号断言绝对坐标)
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
def rc(x, y, rot, half=20):
|
||
u"""两脚无极性件(R / C / NTC)。实测:rot0 → 1 在左;rot90 → 1 在下;rot270 → 1 在上。"""
|
||
if rot == 0:
|
||
return {'1': (x - half, y), '2': (x + half, y)}
|
||
if rot == 90:
|
||
return {'1': (x, y - half), '2': (x, y + half)}
|
||
if rot == 270:
|
||
return {'1': (x, y + half), '2': (x, y - half)}
|
||
raise AssertionError(u'rc 只支持 0/90/270')
|
||
|
||
|
||
def mos(x, y, rot):
|
||
u"""SOT-23 MOS:脚号 1=G 2=S 3=D(AO3400A 与 AO3401A 同一套偏移,实测)。"""
|
||
if rot == 0:
|
||
return {'1': (x - 20, y), '3': (x + 10, y + 20), '2': (x + 10, y - 20)}
|
||
if rot == 180:
|
||
return {'1': (x + 20, y), '3': (x - 10, y - 20), '2': (x - 10, y + 20)}
|
||
raise AssertionError(u'mos 只支持 0/180')
|
||
|
||
|
||
def diode_k_up(x, y):
|
||
u"""极性件:pin1 = K 在上,pin2 = A 在下。**写死脚号,不用坐标集合。**"""
|
||
return {'1': (x, y + 20), '2': (x, y - 20)}
|
||
|
||
|
||
def fan4(x, y):
|
||
u"""2510-4A:全部在左侧,1=GND 2=FAN_12V_SW 3=TACH 4=PWM(eda-parts role)。"""
|
||
return {'1': (x - 20, y + 15), '2': (x - 20, y + 5),
|
||
'3': (x - 20, y - 5), '4': (x - 20, y - 15)}
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# 放置表
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
def placement():
|
||
p = [
|
||
# ── A 高边总开关:FAN_EN → Q8 → Q7 → FAN_12V_SW ────────────────
|
||
['R748', K_1K, 200, 560, 0], # FAN_EN 栅极串阻
|
||
['R749', K_100K, 230, 510, 90], # Q8 栅极下拉(复位默认关电)
|
||
['Q8', K_NMOS, 280, 560, 0],
|
||
['R747', K_1K, 380, 660, 90], # Q8 漏极 → Q7 栅极
|
||
['DZ3', K_ZEN, 380, 750, 270], # K 接源极 / A 接栅极
|
||
['R746', K_100K, 470, 750, 90], # Q7 栅极拉回源极
|
||
['Q7', K_PMOS, 300, 730, 180], # rot180:G 在右 / S 在上 / D 在下
|
||
# ── C FAN_12V_SW 上的钳位与储能 ────────────────────────────────
|
||
['C738', K_22U, 190, 650, 270],
|
||
['D2', K_SS34, 250, 650, 270], # K 接 FAN_12V_SW / A 接 GND
|
||
# ── B PWM 反相器:FAN_PWM → Q6 → FAN_PWM_L(开漏)──────────────
|
||
['R733', K_1K, 200, 370, 0],
|
||
['R734', K_100K, 250, 310, 270],
|
||
['Q6', K_NMOS, 320, 370, 0],
|
||
# ── D 进风测速 + J26 ───────────────────────────────────────────
|
||
['Q9', K_NMOS, 750, 720, 180],
|
||
['R750', K_10K, 960, 750, 90], # TACH 上拉到 FAN_12V_SW
|
||
['R751', K_15K, 810, 720, 0], # 栅极串阻(10k 高电平不够)
|
||
['R752', K_10K, 790, 680, 90], # 栅极对地
|
||
['R753', K_10K, 740, 650, 90], # 漏极上拉 +3V3
|
||
['J26', K_FAN4, 1085, 725, 0],
|
||
# ── NTC1 / NTC2(从 R1 P3-CONTROL 搬来)────────────────────────
|
||
['RT601', K_NTC, 600, 270, 270],
|
||
['R660', K_10K, 600, 330, 270],
|
||
# ⚠ 从 x=660 挪到 700:RT601 的型号文字「NCP18XH103F03RB」画在符号右边、约 65 单位宽,
|
||
# 原来 60 的间距下正好压住 C736 的位号/值(渲染成 …F03RB0nF),和 A2-AUX 的
|
||
# C733「100uF0」是同一类事故 —— 六项几何验证与 flag_clash 都够不着,只有放大截图看得出来。
|
||
['C736', K_100N, 700, 270, 270],
|
||
]
|
||
# 排风通道 = 进风通道整体下移 DY,位号 +4
|
||
for d, k, x, y, r in [it for it in p if it[0] in
|
||
('Q9', 'R750', 'R751', 'R752', 'R753', 'J26')]:
|
||
out = {'Q9': 'Q10', 'R750': 'R754', 'R751': 'R755',
|
||
'R752': 'R756', 'R753': 'R757', 'J26': 'J27'}[d]
|
||
p.append([out, k, x, y + DY, r])
|
||
# NTC 第二路 = 第一路整体右移
|
||
for d, k, x, y, r in [it for it in p if it[0] in ('RT601', 'R660', 'C736')]:
|
||
out = {'RT601': 'RT602', 'R660': 'R661', 'C736': 'C739'}[d]
|
||
p.append([out, k, x + NTC_DX, y, r])
|
||
return p
|
||
|
||
|
||
def expect():
|
||
e = {}
|
||
for d, _k, x, y, r in placement():
|
||
if d in ('Q6', 'Q7', 'Q8', 'Q9', 'Q10'):
|
||
e[d] = mos(x, y, r)
|
||
elif d in ('D2', 'DZ3'):
|
||
e[d] = diode_k_up(x, y) # 极性件:写死脚号
|
||
elif d in ('J26', 'J27'):
|
||
e[d] = fan4(x, y)
|
||
elif d == 'C738':
|
||
e[d] = rc(x, y, r, 15) # 1206 是 ±15(skill §8d)
|
||
else:
|
||
e[d] = rc(x, y, r)
|
||
return e
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# 页面文字
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
TEXTS = [
|
||
(45, 245, u'【A6-FAN】风墙:高边总开关 + 反相 PWM + 两路独立测速 + 两路 NTC', 11),
|
||
(45, 225, u'FAN_EN 复位/高阻 → Q8 截止 → R746(100k) 把 Q7 栅极拉回源极 → 风扇断电', 10),
|
||
(45, 207, u'(契约 §3 第 2 条:复位态必须关电)R749 100k 保证 Q8 栅极不浮空', 10),
|
||
(45, 189, u'DZ3 BZT52C8V2:K 接 Q7 源极、A 接栅极,把 Vgs 钳在 -8.2V', 10),
|
||
(45, 171, u'AO3401A 的 Vgs 绝对最大 ±12V,13.2V 母线直接拉地就超了', 10),
|
||
(45, 153, u'Q6 开漏反相器:栅极高 = 风扇 PWM 线被拉低,固件 D_gpio = 1 - D_fan', 10),
|
||
(45, 135, u'0% PWM 风扇仍转约 3300RPM,要停转只能撤 FAN_EN,不能靠占空比', 10),
|
||
(45, 117, u'D2 SS34:K 接 FAN_12V_SW、A 接 GND,负向钳位(不是低边续流)', 10),
|
||
(45, 99, u'测速:10k 上拉到 FAN_12V_SW,15k/10k 分压 → 栅极 3.43V@12V;', 10),
|
||
(45, 81, u'漏极 10k 上拉 +3V3,反相输出。ESP32 只看到 0/3.3V,不接触 12V', 10),
|
||
(45, 63, u'首板只装一只风扇(方案 §0.2 A3):空着那一路 TACH 悬空,被 10k', 10),
|
||
(45, 45, u'拉到 12V → 接收 MOS 常通 → GPIO 恒低无跳变;关电时则恒高。', 10),
|
||
(45, 27, u'两种都是 0 脉冲,固件必须按配置屏蔽未装的那一路,否则误报停转', 10),
|
||
(410, 665, u'NTC:RT601/RT602 10k B=3380K,上臂 R660/R661 10k 接 +3V3', 10),
|
||
(410, 647, u'25℃ 分压 1.65V;NTC1/NTC2 必须进 ESP32 的 ADC1(IO1~IO10)', 10),
|
||
(410, 629, u'摆位:J26/J27 在底板 y>114.5,刀片投影外,可放高件(§6.6)', 10),
|
||
(410, 611, u'⚠ 四针针序与旧的 3 针风扇座不同,装配前先对丝印', 10),
|
||
(835, 615, u'J26 = 进风,J27 = 排风(2510-4A 四线直针)', 10),
|
||
(835, 595, u'1=GND(黑) 2=FAN_12V_SW(红) 3=TACH(黄) 4=PWM(棕)', 10),
|
||
]
|
||
|
||
# 每一列文字的右边界(`assert_grid` 只管标题栏那条,这里再管一次不许压到电路)
|
||
TEXT_CAP = [(200, 540), (800, 790), (10000, 1145)]
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# 连线
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
def build():
|
||
s = Sheet(PAGE, NAME)
|
||
w, g, p, pt, tx = s.w, s.gnd, s.pwr, s.port, s.txt
|
||
|
||
# ── A 高边总开关 ──────────────────────────────────────────────────
|
||
w('FAN_EN', (140, 560), (180, 560))
|
||
pt('IN', 'FAN_EN', 140, 560, 0) # IN + rot0 ⇒ 标签朝左
|
||
w('#Q8_G', (220, 560), (260, 560)) # R748.2 → Q8.G
|
||
w('#Q8_G', (230, 530), (230, 560)) # R749.2
|
||
w('GND', (230, 460), (230, 490))
|
||
g(230, 460, 0)
|
||
w('GND', (290, 500), (290, 540)) # Q8.S
|
||
g(290, 500, 0)
|
||
w('#Q8_D', (290, 580), (380, 580))
|
||
w('#Q8_D', (380, 580), (380, 640)) # → R747.1
|
||
w('#Q7_G', (380, 680), (380, 730)) # R747.2 → 栅极母线
|
||
w('#Q7_G', (320, 730), (470, 730)) # Q7.G / DZ3.A / R746.1
|
||
w('+12V', (290, 750), (290, 790)) # Q7.S
|
||
w('+12V', (290, 790), (545, 790))
|
||
w('+12V', (380, 770), (380, 790)) # DZ3.K
|
||
w('+12V', (470, 770), (470, 790)) # R746.2
|
||
p('+12V', 545, 790, 0)
|
||
|
||
# ── C FAN_12V_SW 母线 + 钳位 / 储能 ───────────────────────────────
|
||
w('FAN_12V_SW', (290, 690), (290, 710)) # Q7.D
|
||
w('FAN_12V_SW', (140, 690), (290, 690))
|
||
p('FAN_12V_SW', 140, 690, 0)
|
||
w('FAN_12V_SW', (190, 665), (190, 690)) # C738.1
|
||
w('GND', (190, 600), (190, 635))
|
||
g(190, 600, 0)
|
||
w('FAN_12V_SW', (250, 670), (250, 690)) # D2.1 = K
|
||
w('GND', (250, 600), (250, 630)) # D2.2 = A
|
||
g(250, 600, 0)
|
||
|
||
# ── B PWM 反相器 ──────────────────────────────────────────────────
|
||
w('FAN_PWM', (140, 370), (180, 370))
|
||
pt('IN', 'FAN_PWM', 140, 370, 0)
|
||
w('#Q6_G', (220, 370), (300, 370)) # R733.2 → Q6.G
|
||
w('#Q6_G', (250, 330), (250, 370)) # R734.1
|
||
w('GND', (250, 280), (250, 290))
|
||
g(250, 280, 0)
|
||
w('GND', (330, 350), (370, 350)) # Q6.S
|
||
w('GND', (370, 320), (370, 350))
|
||
g(370, 320, 0)
|
||
w('FAN_PWM_L', (330, 390), (330, 430)) # Q6.D(开漏,靠风扇内部上拉)
|
||
w('FAN_PWM_L', (330, 430), (400, 430))
|
||
pt('BI', 'FAN_PWM_L', 400, 430, 0)
|
||
|
||
# ── D/E 两路测速接收 + 风扇座 ─────────────────────────────────────
|
||
for dy, raw, out in ((0, 'FAN_IN_TACH_RAW', 'FAN_TACH_IN'),
|
||
(DY, 'FAN_OUT_TACH_RAW', 'FAN_TACH_OUT')):
|
||
gate = '#Q9_G' if dy == 0 else '#Q10_G'
|
||
w('GND', (1050, 740 + dy), (1065, 740 + dy)) # J.1
|
||
w('GND', (1050, 740 + dy), (1050, 770 + dy))
|
||
g(1050, 770 + dy, 180)
|
||
w('FAN_12V_SW', (1030, 730 + dy), (1065, 730 + dy)) # J.2
|
||
w('FAN_12V_SW', (1030, 730 + dy), (1030, 790 + dy))
|
||
p('FAN_12V_SW', 1030, 790 + dy, 0)
|
||
w('FAN_PWM_L', (1020, 710 + dy), (1065, 710 + dy)) # J.4
|
||
w('FAN_PWM_L', (1020, 670 + dy), (1020, 710 + dy))
|
||
pt('BI', 'FAN_PWM_L', 1020, 670 + dy, 180)
|
||
w(raw, (830, 720 + dy), (1065, 720 + dy)) # J.3 → R751.2
|
||
w(raw, (960, 720 + dy), (960, 730 + dy)) # R750.1
|
||
w(raw, (990, 650 + dy), (990, 720 + dy))
|
||
pt('BI', raw, 990, 650 + dy, 180)
|
||
w('FAN_12V_SW', (960, 770 + dy), (960, 790 + dy)) # R750.2
|
||
p('FAN_12V_SW', 960, 790 + dy, 0)
|
||
w(gate, (770, 720 + dy), (790, 720 + dy)) # Q9.G ← R751.1
|
||
w(gate, (790, 700 + dy), (790, 720 + dy)) # R752.2
|
||
w('GND', (790, 630 + dy), (790, 660 + dy)) # R752.1
|
||
g(790, 630 + dy, 0)
|
||
w('GND', (740, 740 + dy), (740, 770 + dy)) # Q9.S(rot180 ⇒ 在上)
|
||
g(740, 770 + dy, 180)
|
||
w(out, (740, 670 + dy), (740, 700 + dy)) # Q9.D ← → R753.2
|
||
w(out, (680, 690 + dy), (740, 690 + dy))
|
||
pt('OUT', out, 680, 690 + dy, 180)
|
||
w('+3V3', (740, 610 + dy), (740, 630 + dy)) # R753.1
|
||
p('+3V3', 740, 610 + dy, 180)
|
||
|
||
# ── NTC 两路(R1 P3-CONTROL 搬来,上臂改号 R660/R661)─────────────
|
||
for dx, net in ((0, 'NTC1'), (NTC_DX, 'NTC2')):
|
||
w('+3V3', (600 + dx, 350), (600 + dx, 360)) # R660.1
|
||
p('+3V3', 600 + dx, 360, 0)
|
||
w(net, (600 + dx, 290), (600 + dx, 310)) # RT601.1 ← → R660.2
|
||
w(net, (600 + dx, 300), (760 + dx, 300))
|
||
w(net, (700 + dx, 290), (700 + dx, 300)) # C736.1
|
||
pt('OUT', net, 760 + dx, 300, 0)
|
||
w('GND', (600 + dx, 220), (600 + dx, 250)) # RT601.2
|
||
g(600 + dx, 220, 0)
|
||
w('GND', (700 + dx, 220), (700 + dx, 250)) # C736.2
|
||
g(700 + dx, 220, 0)
|
||
|
||
for t in TEXTS:
|
||
tx(t[0], t[1], t[2], t[3])
|
||
return s
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# 本地自检:几何 + 与 R1 的逐脚拓扑对账
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
def _uf(segs):
|
||
n = len(segs)
|
||
par = list(range(n))
|
||
|
||
def find(i):
|
||
while par[i] != i:
|
||
par[i] = par[par[i]]
|
||
i = par[i]
|
||
return i
|
||
|
||
for i in range(n):
|
||
for j in range(i + 1, n):
|
||
a1, a2 = segs[i]
|
||
b1, b2 = segs[j]
|
||
if (_on(a1, b1, b2) or _on(a2, b1, b2)
|
||
or _on(b1, a1, a2) or _on(b2, a1, a2)):
|
||
ri, rj = find(i), find(j)
|
||
if ri != rj:
|
||
par[ri] = rj
|
||
return find
|
||
|
||
|
||
def partition(segs, named, pins, where, strict=True):
|
||
u"""纯几何反推网表:返回「同一网络的 `位号.脚号` 集合」的集合。
|
||
|
||
同名旗/端口所在的组按名字合并(`GND` / `+12V` 这类天然分块的网络);
|
||
无名组各自成网。**不看坐标、不看我自己声明的网络名** —— 这样才能用来
|
||
和 R1 的 dump 对账。
|
||
"""
|
||
find = _uf(segs)
|
||
gname, gpins = defaultdict(set), defaultdict(set)
|
||
for name, xy in named:
|
||
hit = [i for i in range(len(segs)) if _on(xy, segs[i][0], segs[i][1])]
|
||
if not hit:
|
||
assert not strict, u'%s:旗/端口 %s @%s 不在任何导线上' % (where, name, xy)
|
||
continue
|
||
gname[find(hit[0])].add(name)
|
||
for d, pd in pins.items():
|
||
for num, xy in pd.items():
|
||
hit = [i for i in range(len(segs)) if _on(tuple(xy), segs[i][0], segs[i][1])]
|
||
if not hit:
|
||
assert not strict, u'%s:悬空引脚 %s.%s @%s' % (where, d, num, xy)
|
||
continue
|
||
gpins[find(hit[0])].add('%s.%s' % (d, num))
|
||
merged = defaultdict(set)
|
||
for r, ps in gpins.items():
|
||
names = sorted(gname.get(r, set()))
|
||
assert len(names) <= 1, u'%s:一个组挂了多个名字 %s' % (where, names)
|
||
merged[names[0] if names else '#%d' % r] |= ps
|
||
return frozenset(frozenset(v) for v in merged.values())
|
||
|
||
|
||
def restrict(part, keep, remap=None, swap=()):
|
||
u"""只留下 keep 里的位号;顺便做位号改名与「非极性件 1/2 脚对调」。"""
|
||
flip = {'1': '2', '2': '1'}
|
||
out = set()
|
||
for grp in part:
|
||
cur = set()
|
||
for it in grp:
|
||
d, num = it.split('.')
|
||
if d in swap:
|
||
num = flip[num]
|
||
d = (remap or {}).get(d, d)
|
||
if d in keep:
|
||
cur.add('%s.%s' % (d, num))
|
||
if cur:
|
||
out.add(frozenset(cur))
|
||
return frozenset(out)
|
||
|
||
|
||
def load_ref(path):
|
||
src = json.load(io.open(path, encoding='utf-8'))
|
||
r = lambda v: int(round(v)) # noqa: E731
|
||
pts = src['wires']
|
||
segs = [((r(pts[i][0]), r(pts[i][1])), (r(pts[i + 1][0]), r(pts[i + 1][1])))
|
||
for i in range(0, len(pts), 2)]
|
||
segs = [t for t in segs if t[0] != t[1]]
|
||
named = ([(f[1], (r(f[2]), r(f[3]))) for f in src['flags']]
|
||
+ [(q[1], (r(q[2]), r(q[3]))) for q in src['ports']])
|
||
pins = dict((q[0], dict((str(z[0]), (r(z[2]), r(z[3]))) for z in q[9]))
|
||
for q in src['parts'])
|
||
return segs, named, pins, set(pins)
|
||
|
||
|
||
def topo_check(s, exp):
|
||
u"""把本页与 R1 两页的网表分组逐个比对。差一根线就报出来。"""
|
||
errs = []
|
||
mine = partition([(a, b) for a, b, _n in s.W],
|
||
[(it[1], (it[2], it[3])) for it in s.F + s.PT],
|
||
exp, u'本页')
|
||
|
||
segs, named, pins, allref = load_ref(REF_P6)
|
||
ref6 = partition(segs, named, pins, u'R1 P6', strict=False)
|
||
edl = sorted(d for d in allref if d.startswith('U4') or d.startswith('J6'))
|
||
assert not edl, u'_p6_live.json 里居然有 EDL 器件 %s,要逐件挑掉' % (edl,)
|
||
a = restrict(ref6, allref, swap=REF_SWAP)
|
||
b = restrict(mine, allref)
|
||
for grp in sorted(a - b, key=lambda z: sorted(z)):
|
||
errs.append(u'R1 P6 有、本页没有的网络分组: %s' % (sorted(grp),))
|
||
for grp in sorted(b - a, key=lambda z: sorted(z)):
|
||
errs.append(u'本页多出来的网络分组: %s' % (sorted(grp),))
|
||
|
||
segs, named, pins, _all3 = load_ref(REF_P3)
|
||
ref3 = partition(segs, named, pins, u'R1 P3', strict=False)
|
||
a = restrict(ref3, NTC_KEEP, remap=NTC_REMAP)
|
||
b = restrict(mine, NTC_KEEP)
|
||
for grp in sorted(a - b, key=lambda z: sorted(z)):
|
||
errs.append(u'R1 P3 的 NTC 块有、本页没有的分组: %s' % (sorted(grp),))
|
||
for grp in sorted(b - a, key=lambda z: sorted(z)):
|
||
errs.append(u'本页 NTC 块多出来的分组: %s' % (sorted(grp),))
|
||
return errs
|
||
|
||
|
||
def geom_check(s, exp):
|
||
u"""几件 `netcheck` 管不到、但本项目真踩过的事故。"""
|
||
errs = []
|
||
# skill §8a:旗/端口的引脚不许和元件引脚同坐标(零长度导线,重画就断)
|
||
pinxy = {}
|
||
for d, pd in exp.items():
|
||
for num, xy in pd.items():
|
||
key = tuple(xy)
|
||
assert key not in pinxy, u'引脚重叠: %s.%s 与 %s @%s' % (d, num, pinxy[key], key)
|
||
pinxy[key] = '%s.%s' % (d, num)
|
||
for it in s.F + s.PT:
|
||
if (it[2], it[3]) in pinxy:
|
||
errs.append(u'旗/端口 %s 压在元件引脚 %s 上(skill §8a)'
|
||
% (it[1], pinxy[(it[2], it[3])]))
|
||
# 文字右边界(分列各自的上限)
|
||
for x, y, txt, size in s.TX:
|
||
cjk = sum(1 for ch in txt if ord(ch) > 0x2E7F)
|
||
right = x + size * (cjk + 0.5 * (len(txt) - cjk))
|
||
cap = next(c for lo, c in TEXT_CAP if x < lo)
|
||
if right > cap:
|
||
errs.append(u'文字右边界 %.0f 超过本列上限 %d: %s' % (right, cap, txt[:22]))
|
||
return errs
|
||
|
||
|
||
def text_clash(s, exp):
|
||
u"""文字压在导线 / 引脚 / 旗端口上(同 a1_input.py / a2_aux.py)。"""
|
||
errs, boxes, pts = [], [], []
|
||
for x, y, txt, size in s.TX:
|
||
cjk = sum(1 for ch in txt if ord(ch) > 0x2E7F)
|
||
boxes.append((x, y - size * 0.8, x + size * (cjk + 0.5 * (len(txt) - cjk)),
|
||
y + size * 0.8, txt))
|
||
for d, ps in exp.items():
|
||
for num, xy in ps.items():
|
||
pts.append((xy[0], xy[1], u'引脚 %s.%s' % (d, num)))
|
||
for it in s.F + s.PT:
|
||
pts.append((it[2], it[3], u'旗/端口 %s' % it[1]))
|
||
for x0, y0, x1, y1, txt in boxes:
|
||
for px, py, what in pts:
|
||
if x0 <= px <= x1 and y0 <= py <= y1:
|
||
errs.append(u'文字压住 %s @(%d,%d): %s' % (what, px, py, txt[:22]))
|
||
for a, b, net in s.W:
|
||
if a[1] == b[1]:
|
||
if y0 <= a[1] <= y1 and min(a[0], b[0]) <= x1 and max(a[0], b[0]) >= x0:
|
||
errs.append(u'文字压住导线 %s y=%d: %s' % (net, a[1], txt[:22]))
|
||
elif x0 <= a[0] <= x1 and min(a[1], b[1]) <= y1 and max(a[1], b[1]) >= y0:
|
||
errs.append(u'文字压住导线 %s x=%d: %s' % (net, a[0], txt[:22]))
|
||
return sorted(set(errs))
|
||
|
||
|
||
def flag_clash(s, gx=20, gy=10):
|
||
errs, items = [], [(it[1], it[2], it[3]) for it in s.F + s.PT]
|
||
for i in range(len(items)):
|
||
for j in range(i + 1, len(items)):
|
||
(n1, x1, y1), (n2, x2, y2) = items[i], items[j]
|
||
if abs(x1 - x2) < gx and abs(y1 - y2) < gy:
|
||
errs.append(u'旗/端口 %s @(%d,%d) 与 %s @(%d,%d) 挨太近'
|
||
% (n1, x1, y1, n2, x2, y2))
|
||
return sorted(set(errs))
|
||
|
||
|
||
# `ALLOW_SPLIT` 只放行「靠同名旗/端口成网」的电源与三处开漏节点(契约 §7 第 10 条):
|
||
# GND / +3V3 / FAN_12V_SW —— 多面旗,导线上天然分块
|
||
# FAN_PWM_L —— Q6 漏极一处 + 两个风扇座各一处,共 3 个同名端口
|
||
ALLOW_SPLIT = set(['GND', '+3V3', 'FAN_12V_SW', 'FAN_PWM_L'])
|
||
NC = {} # 本页没有悬空不接的引脚
|
||
|
||
|
||
def main():
|
||
push = '--push' in sys.argv
|
||
s = build()
|
||
exp = expect()
|
||
s.assert_grid()
|
||
for d, _k, _x, _y, _r in placement():
|
||
assert _k in PARTS, u'eda-parts.json 里没有器件键 %s(%s)' % (_k, d)
|
||
errs = s.netcheck(exp, NC, ALLOW_SPLIT)
|
||
errs += geom_check(s, exp) + topo_check(s, exp)
|
||
errs += text_clash(s, exp) + flag_clash(s)
|
||
print(u'风扇块 %d 件(R1 P6 原样)+ NTC 块 6 件(R1 P3 搬来,上臂改号 R660/R661)'
|
||
% (len(placement()) - 6))
|
||
s.report(len(placement()), errs)
|
||
if not push:
|
||
return
|
||
call("await eda.dmt_Project.openProject('%s'); await new Promise(r=>setTimeout(r,2500)); return 1;"
|
||
% R2_PROJECT)
|
||
s.clear()
|
||
s.place(placement(), exp)
|
||
s.push_geometry()
|
||
print(u'接着跑 verify_sch.py --nets 和 .claude/xpage.py')
|
||
|
||
|
||
if __name__ == '__main__':
|
||
main()
|