# -*- coding: utf-8 -*- u"""PCB1 设计规则 + D± 差分对。 做四件事: 1. **换掉规则配置**。转板时挂上的是 `JLCPCB Capability(Two Layers Board)` —— 这是块四层板,用两层板的能力集是错的。换成 `Multiple Layers Board`。 2. 加两条线宽规则并按网络指派:4A 通道输出、12V/5V 母线。 3. 差分对几何按 90 Ω 设:**W 0.28 / S 0.18 mm**(推导见下),线长容差 0.5 mm。 4. 建 7 对差分对:USB1~5(Hub 到五路出线座)、UPSTREAM(J23 到 Hub)、 DEBUG(J22 到 ESP32)。 ## 线宽哪来的(IPC-2221,外层,ΔT=20 °C) A = (I / (0.048 · ΔT^0.44))^(1/0.725) [mil²], W = A / 铜厚 4 A -> A = 72.5 mil² -> 1 oz(1.37 mil) 需 53 mil = 1.34 mm 8 A -> A = 188 mil² -> 1 oz 需 138 mil = 3.49 mm 外层已定 **1 oz**(§10.1)。4A 那条给 **1.5 mm**(温升 16.7 °C); 12V 分支给 **2.0 mm**,**总干那 8 A 走灌铜**,不当走线画。 ## 90 Ω 是怎么算的 —— ⚠️ 必须拿板厂叠层复核 微带近似,按嘉立创四层 1.6 mm 标准叠层 L1-L2 介质 h=0.2104 mm、εr≈4.3、 **1 oz 外层(已定稿)**: Z0 = 87/√(εr+1.41) · ln(5.98h / (0.8W+t)) Zdiff = 2·Z0·(1 − 0.48·e^(−0.96·S/h)) W=0.28 mm, S=0.18 mm -> Z0≈57.5 Ω, Zdiff≈90.5 Ω ✓ (回到 2 oz 的话同样几何只剩 83 Ω,W 要收到约 0.24 mm —— 铜厚进了 0.8W+t。) 叠层一变(板厚、prepreg 型号、内层铜厚)这两个数就得重算, **打样前一定要用板厂的阻抗计算器复核**,别信这里的近似公式。 ## 规则配置怎么下发(v3.2.149 实测,2026-09-03 摸清楚) 一度以为“只能手动”,**其实是参数形状给错了**。两个坑: 1. `overwriteCurrentRuleConfiguration(x)` 要的是 **config 本体**, 不是 `getRuleConfiguration()` 返回的 `{name, config}` 包装。 给包装它返回 `undefined` 且什么也不做;给本体返回 `true`。 2. **两种 config 形状不一样,不能混用。** - `getRuleConfiguration('<系统能力集>')` → **平形状**: `form.strokeWidthDefault` / `Safe Spacing[*].table` - `getCurrentRuleConfiguration()` → **嵌套形状**(文档内部表示): `form.data['1'].defaultValue` / `Safe Spacing[*].tables['1'].content` 把平形状丢进 `overwriteCurrentRuleConfiguration` 会被默默吃掉。 **正确做法:拿 `getCurrentRuleConfiguration().config` 改,原形状送回去。** `saveRuleConfiguration(cfg, name, allowOverwrite)` 两种形状都返回 `false`, 真的不能用——但没关系,我们不需要存命名配置,只需要当前文档的规则对。 ### “切到多层板能力集”实际就是五件事 把两个能力集逐值 diff 过:**只差 3 个数 + 2 条默认不启用的内层条目**, 其余 300 多个数值一模一样。所以直接把它们搬过来就行: | 项 | Two Layers | Multiple Layers | |---|---|---| | `Track/copperThickness1oz` 最小线宽 | 0.127 | **0.102** | | 过孔外径最小 | 0.5 | **0.3** | | 过孔内径最小 | 0.3 | **0.2** | | `Track/copperThickness0.5oz` | 无 | 有(min 0.089,不启用)| | `Safe Spacing/copperThicknessInner0.5oz` | 无 | 有(不启用)| ⚠️ **下完之后别再去 GUI 里换“配置”下拉框** —— 换一下就是整套重置, PWR4A / PWR12V 会被冲掉,12 个网络的指派又变成悬空的。 对话框里**显示的名字依旧是 `JLCPCB Capability(Two Layers Board)`**(名字改不了, API 里没有 setCurrentRuleConfigurationName),**下发之后名字会自动变成 “自定义配置”** —— 这是对的, 说明它已经从系统能力集上摘下来了,以 `--check` 的实测值为准。 ⚠️ 还有一条:**写进去的 mm 值会被 EasyEDA 换算成 mil 再换回来**, 0.2 回读是 `0.19999959999999997`。任何几何值都别做精确比较,容差留到 1e-4。 用法: /c/Python314/python .claude/pcb_rules.py [--push | --check] """ import io, json, os, sys HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, HERE) from eda import run PCB = '7e9415a16a909c51' # 多层板能力集只当**数据源**用(取内层 0.5 oz 那张间距表),不切换配置本身 BASE_CFG = 'JLCPCB Capability(Multiple Layers Board)' # ── 线宽规则(mm)────────────────────────────────────────────────────── # 外层 **1 oz**(§10.1,用户 2026-09-03 拍板)。 # ⚠️ PWR12V 的 2.0 mm 是给**分支**的:12V 总干那 8 A 走 2.0 mm 温升 35 °C, # 不能当走线画,必须走灌铜(Inner2-PWR 0.5 oz 需 ≥18 mm 宽)。 # 最小值不敢设成 3.5 mm —— +12V 上还有一堆进 0402/SOT-23 焊盘的短支线, # 卡 3.5 会刷出几百条假 DRC 错误,规则就没人看了。**载流靠灌铜保证,不靠 DRC。** TRACK_RULES = { # ⚠️ 最小值 1.0 → **0.3**(2026-09-03 扇出时改的,不是放松要求)。 # 规则是**按网络**生效的,`+12V` 上除了母线还挂着 R104 那种 0402 分压抽头: # 0402 焊盘才 0.57 x 0.54 mm,1.0 mm 的线比焊盘还宽,根本引不出来 —— # 实测卡 1.0 时有 29 个焊盘扇不出去,硬扇出去的又被判「线宽 11.8mil,应 >= 39.4mil」。 # 这正是 §10.1 已经写过的那条道理的延伸:**载流靠灌铜保证,不靠 DRC**。 # 默认值仍是 1.5 / 2.0,交互布线画出来的还是宽线;0.3 只是不再误伤短支线。 'PWR4A': {'strokeWidthDefault': 1.5, 'strokeWidthMin': 0.3, 'strokeWidthMax': 6.0}, 'PWR12V': {'strokeWidthDefault': 2.0, 'strokeWidthMin': 0.3, 'strokeWidthMax': 10.0}, } NET_TRACK = {} for _n in range(1, 6): NET_TRACK['VO%d' % _n] = 'PWR4A' # Buck 输出 -> eFuse NET_TRACK['VO%d_P' % _n] = 'PWR4A' # 分流之后 -> 出线座 for _n in ('+12V', '+5V'): NET_TRACK[_n] = 'PWR12V' # ── 差分对几何(mm)──────────────────────────────────────────────────── # ⚠️ 长度容差 0.5 -> **5.0 mm**(2026-09-04 用户拍板)。 # USB 2.0 HS 的**对内**偏斜预算按 100 ps 算、FR4 上约 15 mm;5 mm ≈ 30 ps 仍有大余量。 # 卡 0.5 mm 时 USB1/USB2 被判违规(实测偏差 1.64 / 1.88 mm ≈ 10 ps)—— # 为了凑这个数去绕蛇形线,是拿真实的串扰与占板面积去换一个好看的 DRC 数字。 DIFF = {'strokeWidthDefault': 0.28, 'strokeWidthMin': 0.20, 'strokeWidthMax': 0.60, 'differentailPairSpacingDefault': 0.18, 'differentailPairSpacingMin': 0.15, 'differentailPairLenTolerMax': 5.0} # ── 差分对**对内**间距规则(网络与网络之间)────────────────────────── # 对内 0.28/0.18 是按 90 Ω 微带算的,紧耦合是设计意图;但 DP 与 DM 是两个不同的网络, # 通用的 0.2 mm 净间距规则不认「这两个是一对」,在过孔换层处判了 4 条违规 # (实测 6~7 mil / 0.15~0.18 mm)。把线拉开阻抗就不是 90 Ω 了,所以**不能拉开**。 # 正确做法:建一条 0.15 mm 的安全间距条目,只挂在**每一对自己的两个网络之间** # (`overwriteNetByNetRules`),对其它网络仍然是 0.2 mm。嘉立创四层工艺下限 0.1 mm。 PAIR_RULE = 'USBPAIR' PAIR_CLEARANCE = 0.15 PAIRS = [('USB%d' % n, 'USB%d_DP' % n, 'USB%d_DM' % n) for n in range(1, 6)] PAIRS += [('UPSTREAM', 'UP_DP', 'UP_DM'), ('DEBUG', 'USB_DP', 'USB_DM')] # 安全间距的**下限**:嘉立创能力集给到 0.102 mm(4 mil),那是工艺极限不是设计目标。 # 全表抬到 0.2 mm,DRC 才拦得住无意中走太近的线。 MIN_CLEARANCE = 0.2 OPEN = ("await eda.dmt_EditorControl.openDocument('%s');" "await new Promise(r=>setTimeout(r,1500));" % PCB) def call(body, timeout=240): r = run(OPEN + body, timeout=timeout) if not r.get('success'): print(u'失败:', json.dumps(r, ensure_ascii=False)[:600]); sys.exit(1) return r['result'] CAP_JS = u""" const D = eda.pcb_Drc; const cur = await D.getCurrentRuleConfiguration(); const c = JSON.parse(JSON.stringify(cur.config)); // 嵌套形状,原样送回 const multi = (await D.getRuleConfiguration( 'JLCPCB Capability(Multiple Layers Board)')).config; // 平形状,只当数据源 const TRACKS = %s, DW = %s, MINC = %s, PAIRRULE = %s, PAIRC = %s; // 嵌套形状里一条线宽规则是 form.data{'1':{minValue,defaultValue,maxValue}} const setTrack = (e, mn, df, mx) => { for (const k of Object.keys(e.form.data)) Object.assign(e.form.data[k], {minValue: mn, defaultValue: df, maxValue: mx}); }; const T = c['Physics']['Track'], SS = c['Spacing']['Safe Spacing']; // ── 1) 多层板能力集:三个放宽值 + 两条内层条目 ── setTrack(T['copperThickness1oz'], 0.102, 0.254, 2.54); if (!T['copperThickness0.5oz']) { const e = JSON.parse(JSON.stringify(T['copperThickness1oz'])); e.editName = 'copperThickness0.5oz'; e.isSetDefault = false; setTrack(e, 0.089, 0.254, 2.54); T['copperThickness0.5oz'] = e; } Object.assign(c['Physics']['Via Size']['viaSize'].form, {viaOuterdiameterMin: 0.3, viaInnerdiameterMin: 0.2}); if (!SS['copperThicknessInner0.5oz']) { const e = JSON.parse(JSON.stringify(SS['copperThickness1oz'])); e.editName = 'copperThicknessInner0.5oz'; e.isSetDefault = false; const src = multi['Spacing']['Safe Spacing']['copperThicknessInner0.5oz'].table; for (const k of Object.keys(e.tables)) e.tables[k].content = JSON.parse(JSON.stringify(src)); SS['copperThicknessInner0.5oz'] = e; } // ── 2) 两条功率线宽规则(名字必须和 NET_TRACK 里指的一模一样)── for (const name of Object.keys(TRACKS)) { const f = TRACKS[name]; const e = JSON.parse(JSON.stringify(T['copperThickness1oz'])); e.editName = name; e.isSetDefault = false; e.unit = 'mm'; setTrack(e, f.strokeWidthMin, f.strokeWidthDefault, f.strokeWidthMax); T[name] = e; } // ── 3) 差分对几何 ── const dp = c['Physics']['Differential Pair']['differentialPair'].form; for (const k of Object.keys(dp.strokeWidthTables.data)) Object.assign(dp.strokeWidthTables.data[k], {minValue: DW.strokeWidthMin, defaultValue: DW.strokeWidthDefault, maxValue: DW.strokeWidthMax}); for (const k of Object.keys(dp.diffPairSpacingTables.data)) Object.assign(dp.diffPairSpacingTables.data[k], {minValue: DW.differentailPairSpacingMin, defaultValue: DW.differentailPairSpacingDefault}); dp.differentailPairLenTolerMax = DW.differentailPairLenTolerMax; // ── 3b) 差分对对内间距条目(只给 net-by-net 规则用,不设为默认)── if (!SS[PAIRRULE]) { const e = JSON.parse(JSON.stringify(SS['copperThickness1oz'])); e.editName = PAIRRULE; e.isSetDefault = false; for (const tk of Object.keys(e.tables || {})) for (const row of (e.tables[tk].content || [])) for (let i = 0; i < row.length; i++) if (typeof row[i] === 'number') row[i] = PAIRC; SS[PAIRRULE] = e; } // ── 4) 安全间距全表抬到下限 ── let bumped = 0; for (const key of Object.keys(SS)) if (key !== PAIRRULE) // ⚠️ 对内那条 0.15 不能被抬回 0.2 for (const tk of Object.keys(SS[key].tables || {})) for (const row of (SS[key].tables[tk].content || [])) for (let i = 0; i < row.length; i++) if (typeof row[i] === 'number' && row[i] < MINC) { row[i] = MINC; bumped++; } const ok = await D.overwriteCurrentRuleConfiguration(c); // ⚠️ 传本体,不传 {name,config} return {ok: ok === true, bumped: bumped, name: cur.name, track: Object.keys(T), spacing: Object.keys(SS)}; """ def push_config(): u"""把规则配置整套下发到**当前文档**。见文件头的两个坑。""" r = call(CAP_JS % (json.dumps(TRACK_RULES), json.dumps(DIFF), MIN_CLEARANCE, json.dumps(PAIR_RULE), PAIR_CLEARANCE)) if not r.get('ok'): print(u' [!!] overwriteCurrentRuleConfiguration 返回非 true,没生效'); sys.exit(1) print(u' 规则配置已下发:线宽条目 %s' % ' '.join(r['track'])) print(u' 安全间距抬高 %d 格,间距条目 %s' % (r['bumped'], ' '.join(r['spacing']))) print(u' 配置名现在是 “%s”(下发前是系统能力集,改过就自动摘出来了)' % r['name']) def caveat(): print(u'') print(u'──── 一条必须记住的事 ────') print(u'⚠️ 别再去 GUI 的 设计 → 设计规则… 里换“配置”下拉框。') print(u' 换一下就是整套重置,PWR4A / PWR12V 两条线宽规则会被冲掉,') print(u' 12 个功率网络的指派立刻变成悬空的(DRC 不报错,线宽也就不生效)。') print(u' 要改规则就改这个脚本再 --push。') print(u' 下发之后配置名会自动变成 “自定义配置” —— 这是对的,说明它不再挂在系统能力集上。') def _nums(o): u"""递归掏出一个子树里所有的数字。规则配置的结构**每个配置都不一样**: 两层板的差分对是 `form.strokeWidthTables.data['1'].defaultValue`, 多层板的是 `form.strokeWidthDefault`。别按固定路径取,会静默拿到 undefined。""" if isinstance(o, bool): return [] if isinstance(o, (int, float)): return [float(o)] if isinstance(o, dict): return [v for x in o.values() for v in _nums(x)] if isinstance(o, list): return [v for x in o for v in _nums(x)] return [] def _find(o, key_sub): u"""按键名子串找第一个数字。""" if isinstance(o, dict): for k, v in o.items(): if key_sub.lower() in str(k).lower() and isinstance(v, (int, float)) and not isinstance(v, bool): return float(v) for v in o.values(): r = _find(v, key_sub) if r is not None: return r elif isinstance(o, list): for v in o: r = _find(v, key_sub) if r is not None: return r return None def audit(): JS = """ await new Promise(r=>setTimeout(r,2000)); const c = await eda.pcb_Drc.getCurrentRuleConfiguration(); const rules = await eda.pcb_Drc.getNetRules(); return { cfgName: c && c.name, track: Object.keys((c && c.config && c.config.Physics && c.config.Physics['Track']) || {}), cap: { track1oz: c.config.Physics['Track']['copperThickness1oz'], via: c.config.Physics['Via Size']['viaSize'], half: !!c.config.Physics['Track']['copperThickness0.5oz'], inner: !!c.config.Spacing['Safe Spacing']['copperThicknessInner0.5oz'], }, diff: c && c.config && c.config.Physics && c.config.Physics['Differential Pair'], spacing: c && c.config && c.config.Spacing && c.config.Spacing['Safe Spacing'], pairs: (await eda.pcb_Drc.getAllDifferentialPairs()).map(d => d.name).sort(), assigned: rules.filter(r => r['Track'] && r['Track'] !== 'default') .map(r => [r.name, r['Track']]).sort(), }; """ r = call(JS) bad = [] def chk(cond, good, msg): print((u' [OK] ' if cond else u' [!!] ') + (good if cond else msg)) if not cond: bad.append(msg) print(u'──── 审计 ────') # 配置的**名字**改不了(永远显示 Two Layers Board),所以查的是四层板能力集的数值。 # 两个能力集逐值 diff 只差这三个数 + 两条不启用的内层条目。 cap = r['cap'] tmin = _find(cap['track1oz'], 'minValue') if tmin is None: tmin = _find(cap['track1oz'], 'strokeWidthMin') vout = _find(cap['via'], 'viaOuterdiameterMin') vin = _find(cap['via'], 'viaInnerdiameterMin') okcap = (tmin is not None and abs(tmin - 0.102) < 5e-4 and vout is not None and abs(vout - 0.3) < 5e-4 and vin is not None and abs(vin - 0.2) < 5e-4 and cap['half'] and cap['inner']) chk(okcap, u'四层板能力集已就位(最小线宽 %.3f / 过孔 %.2f-%.2f,含 0.5oz 内层条目)' % (tmin or -1, vout or -1, vin or -1), u'还是两层板能力集:最小线宽 %.3f(应 0.102)/ 过孔 %.2f-%.2f(应 0.30-0.20)' u' / 0.5oz 线宽条目 %s / 内层间距条目 %s' % (tmin if tmin is not None else -1, vout if vout is not None else -1, vin if vin is not None else -1, u'有' if cap['half'] else u'无', u'有' if cap['inner'] else u'无')) print(u' (配置名 “%s”)' % r['cfgName']) for name, f in sorted(TRACK_RULES.items()): chk(name in r['track'], u'线宽规则 %s 存在' % name, u'线宽规则 %s **不存在** —— 有 %d 个网络指着它,现在是悬空的' % (name, sum(1 for _, t in r['assigned'] if t == name))) w = _find(r['diff'], 'strokeWidthDefault') if w is None: w = _find(r['diff'], 'defaultValue') sp = _find(r['diff'], 'PairSpacingDefault') if sp is None: d2 = (r['diff'] or {}).get('differentialPair', {}).get('form', {}) .get('diffPairSpacingTables') sp = _find(d2, 'defaultValue') chk(w is not None and abs(w - DIFF['strokeWidthDefault']) < 5e-3 and sp is not None and abs(sp - DIFF['differentailPairSpacingDefault']) < 5e-3, u'差分对几何 W %.3f / S %.3f mm' % (w or -1, sp or -1), u'差分对几何是 W %.3f / S %.3f,应为 W %.2f / S %.2f mm' % (w if w is not None else -1, sp if sp is not None else -1, DIFF['strokeWidthDefault'], DIFF['differentailPairSpacingDefault'])) # ⚠️ 容差不能收到 1e-9:写进去的 mm 会被 EasyEDA 换算成 mil 再换回来, # 0.2 回读是 0.19999959999999997(差 4e-7 mm = 0.4 nm)。任何几何值都别做精确比较。 vals = [v for v in _nums(r['spacing']) if 0 < v < 10] mn = min(vals) if vals else -1 chk(mn >= MIN_CLEARANCE - 1e-4, u'安全间距下限 %.4f mm' % mn, u'安全间距下限只有 %.4f mm,应抬到 %.2f mm' % (mn, MIN_CLEARANCE)) want = sorted(n for n, _, _ in PAIRS) chk(r['pairs'] == want, u'差分对 %d 对: %s' % (len(r['pairs']), ' '.join(r['pairs'])), u'差分对对不上:有 %s,应为 %s' % (r['pairs'], want)) chk(len(r['assigned']) == len(NET_TRACK), u'线宽指派 %d 个网络' % len(r['assigned']), u'线宽指派只有 %d 个,应为 %d' % (len(r['assigned']), len(NET_TRACK))) if bad: caveat() return not bad def main(): print(u'目标:线宽规则 %d 条(%s),安全间距下限 %.2f mm,四层板能力集' % (len(TRACK_RULES), ' '.join(sorted(TRACK_RULES)), MIN_CLEARANCE)) print(u'差分对:W %.2f / S %.2f / 线长容差 %.2f mm,共 %d 对' % (DIFF['strokeWidthDefault'], DIFF['differentailPairSpacingDefault'], DIFF['differentailPairLenTolerMax'], len(PAIRS))) if '--check' in sys.argv: sys.exit(0 if audit() else 1) if '--push' not in sys.argv: print(u'(未加 --push,没有下发;--check 审计当前状态)') return push_config() n = call(""" const P = %s; let k = 0; for (const d of await eda.pcb_Drc.getAllDifferentialPairs()) await eda.pcb_Drc.deleteDifferentialPair(d.name); for (const [name, p, m] of P) { if (await eda.pcb_Drc.createDifferentialPair(name, p, m)) k++; } return k; """ % json.dumps(PAIRS)) print(u' 差分对 %d / %d 对' % (n, len(PAIRS))) n = call(""" const T = %s; const rules = await eda.pcb_Drc.getNetRules(); let k = 0; for (const r of rules) { if (r.type === 'net' && T[r.name]) { r['Track'] = T[r.name]; k++; } } await eda.pcb_Drc.overwriteNetRules(rules); return k; """ % json.dumps(NET_TRACK, ensure_ascii=False)) print(u' 线宽指派 %d / %d 个网络' % (n, len(NET_TRACK))) # ── 差分对**对内**间距:只在每一对自己的两个网络之间挂 USBPAIR(0.15 mm)── # 形状是从 pcb.js 的 getBetweenNetDataItem() 反出来的: # {net1:{type:'net',name}, net2:{type:'net',name}, rule:<安全间距条目名>, # copperRuleName:null, planeRuleName:null} # ⚠️ overwriteNetByNetRules 是**整表覆盖**,要先把现有的读回来再合并。 n = call(""" const PAIRS = %s, RULE = %s; const cur = await eda.pcb_Drc.getNetByNetRules(); const list = (cur && cur['Safe Spacing']) ? cur['Safe Spacing'].slice() : []; const key = e => [e.net1.name, e.net2.name].sort().join('|'); const have = new Set(list.map(key)); let k = 0; for (const [name, dp, dm] of PAIRS) { const e = {net1: {type: 'net', name: dp}, net2: {type: 'net', name: dm}, rule: RULE, copperRuleName: null, planeRuleName: null}; if (have.has(key(e))) continue; list.push(e); k++; } const ok = await eda.pcb_Drc.overwriteNetByNetRules( {'Safe Spacing': list, 'Creepage Distance': (cur && cur['Creepage Distance']) || []}); const back = await eda.pcb_Drc.getNetByNetRules(); return {added: k, ok: ok === true, now: (back['Safe Spacing'] || []).length}; """ % (json.dumps(PAIRS), json.dumps(PAIR_RULE))) print(u' 对内间距规则:新增 %d 条,现共 %d 条(%s = %.2f mm)' % (n['added'], n['now'], PAIR_RULE, PAIR_CLEARANCE)) if n['now'] != len(PAIRS): print(u' [!!] 应为 %d 条' % len(PAIRS)) call("await eda.pcb_Document.save(); return 1;") print(u' 已保存') caveat() if __name__ == '__main__': main()