sync: HP_BOWEI 2026-09-09

This commit is contained in:
Myfro9
2026-09-09 09:51:06 +08:00
parent 01b7a00b12
commit 170f4eb53f
10 changed files with 1926 additions and 5 deletions
+296
View File
@@ -0,0 +1,296 @@
---
name: altium-kicad
description: >
把 Altium Designer 的 PCB 设计工程整体搬到 KiCad,并验证搬完的板与原设计电气
等价、制造等价。驱动 E:\LangGraph_Design\Altium-Kicad 里的 LangGraph 工作流
(11 节点 / 4 个判定回环),进程内 + SQLite 持久化,不用 langgraph dev。图在每
个需要 MCP、CLI 或人在 GUI 里点的地方 interrupt 回到 Claude 会话,由本 skill
执行真实动作再回填结论推进。附带把两次真实迁移里反复重写的脚本通用化:ASCII
转换脚本生成、BGA 平面焊盘网络回填、覆铜 zone clearance 批改、逐页网表
partition 比对、走线/过孔矢量等价比对。
只要用户提到 Altium 转 KiCad、AD 转 KiCad、把 PrjPCB 导入 KiCad、迁移/搬移
PCB 工程、altium-kicad 工作流、"Header already parsed"、原理图转 ASCII、
导入后 DRC 一堆短路、BGA 焊盘丢网络、铺铜避让变大、网表比对、Gerber 比对、
或者说 "启动 altium-kicad"、"跑迁移流程"、"继续上次那个迁移"、"convert my
Altium project to KiCad"、"import PrjPCB into KiCad"、"migrate PCB from
Altium",就必须用这个 skill —— 哪怕他们没说 skill、没提 LangGraph,只说了
"把这块板转成 KiCad"、"这板子迁移到一半,接着弄"。
---
# Altium → KiCad 工程迁移
## 这个 skill 是什么
一次完整迁移 = 5 个阶段:**桥接 → 转 ASCII 并导入 → 修 PCB → 网表对齐 → 几何对齐 → 验收**。
这些步骤有严格的先后依赖、有失败要退回重做的回环、单次会话跑不完(用户要去
Altium 和 KiCad 里手动点很多次)。所以状态由 LangGraph 图持久化在 SQLite 里,
你负责把图停下来的每一步翻译成真实动作,做完回填结论让它往下走。
**MCP 边界(必须理解,否则会白折腾)**:eda-agent(altium MCP)和 KiCAD-MCP-Server
跑在**你这个 Claude 会话进程里**,不在 LangGraph 的 Python 进程里。所以图不可能
自己去 ping Altium、自己去数文件、自己去改板 —— 它只做编排,凡是要碰真实世界的
地方一律 `interrupt()` 交回给你。这是设计,不是缺陷。
## 环境
| 项 | 值 |
|---|---|
| 图工程根 | `E:\LangGraph_Design\Altium-Kicad`(可用 `--project-root` 或 `AK_PROJECT_ROOT` 改) |
| Python | conda env `langgraph`(`~\skills_env.md` 的 `CONDA_LANGGRAPH_PYTHON`) |
| KiCad python | `~\skills_env.md` 的 `KICAD_PYTHON`,本机 `D:\KiCad\10.0\bin\python.exe` |
| checkpoint | `<图工程根>\runs\skill_checkpoint.sqlite`,按 thread 隔离 |
| thread 命名 | 由 target_dir 目录名推导,如 `E:\Kicad_design\XMOD_V1p0` → `ak-xmod-v1p0` |
| 驱动脚本 | `%USERPROFILE%\.claude\skills\altium-kicad\scripts\ak_flow.py` |
需要的 MCP:**eda-agent**(Altium 侧取数)、**kicad**(可选,KiCad 侧大多走
`kicad-cli` 和 pcbnew 更稳)。
## 驱动命令
统一用 `conda run -n langgraph python` 跑 `ak_flow.py`。**退出码 2 = 停在
interrupt 等你回填**(不是错误),0 = 图跑完,1 = 出错。
```powershell
$AK = "$env:USERPROFILE\.claude\skills\altium-kicad\scripts\ak_flow.py"
# 新建 / 续跑(同一个 target-dir 重复跑就是续跑,不会重置)
conda run -n langgraph python $AK start `
--source-prj "E:\Altium_design\FOO\FOO.PrjPCB" `
--target-dir "E:\Kicad_design\FOO"
# 看现在停在哪
conda run -n langgraph python $AK status --target-dir "E:\Kicad_design\FOO"
# 回填结论、推进(--set 免转义,true/false 转 bool,纯数字转 int)
conda run -n langgraph python $AK resume --target-dir "E:\Kicad_design\FOO" `
--set bridge_ok=true
# 列出所有迁移线程 / 清掉某个线程重来
conda run -n langgraph python $AK list
conda run -n langgraph python $AK reset --target-dir "E:\Kicad_design\FOO"
```
`--set` 的值含空格要整体加引号:`--set "report_path=E:\My Board\report.md"`。
`start` 还可以调回环的重试上限(默认 桥接/导入 3 次、网表/几何 5 次,超限走 safety_end):
`--max-bridge-retry N` `--max-import-retry N` `--max-netlist-retry N` `--max-gerber-retry N`。
`[INTERRUPT]` 后面的 JSON 里 `action` 字段告诉你该做哪一步,下面逐个说。
> **回填不能是空的**。langgraph 把 falsy 的 resume 当作「没提供」,会在同一个
> 节点再中断一次,看起来像卡住。至少给一个键。
---
## 逐步执行
开始之前先问清楚两件事(没有默认值,猜错了会把工程写到别处):
**源 `.PrjPCB` 的绝对路径**、**目标 KiCad 目录的绝对路径**。
### `prompt_altium_mcp` — 让用户开启 Altium 桥接
告诉用户在 Altium 里:`DXP/Preferences → Scripting System → Global Projects →
Install` 选 `Altium_API.PrjScr`,然后 `File → Run Script → Dispatcher.pas →
StartMCPServer → Run`,状态窗显示 `MCP: idle` 即通。
提醒两条脾气:桥接**约 10 分钟空闲自停**,**一次只能跑一个脚本**。
用户确认后:`--set acknowledged=true`
### `check_altium_bridge` — 验证桥接
调 eda-agent 的 `app_ping`。通了 `--set bridge_ok=true`,不通就 `false`
(图会回到上一步让用户重开,最多 3 次)。
### `convert_ascii` — 转 ASCII(顺带做基线快照)
**先做基线快照,趁桥接还活着。** 后面 `check_file_count` 要页数、`rebuild_rules`
要设计规则、网表比对要真值网表,这些全都只能从 Altium 侧取;而转 ASCII 的第一步
就是停掉 eda-agent,停了再想取就得重开桥接。所以顺序是:
1. 用 eda-agent 落盘基线到 `<target_dir>\_migration\altium_baseline\`:
`proj_get_stats` `proj_get_nets` `proj_get_bom` `pcb_get_board_statistics`
`pcb_get_layer_stackup` `pcb_get_design_rules`(存成 `pcb_design_rules.json`,
后面查 InPolygon clearance 要用)
2. `proj_export_netlist`(**tabular**)导到 `_migration\reference\netlist_tabular.csv`
—— 这是后面回填焊盘网络和比网表的**唯一真值**,别漏
3. 记下原理图页数和 PCB 数(`check_file_count` 要用)
然后转 ASCII:
```powershell
python "$env:USERPROFILE\.claude\skills\altium-kicad\scripts\stage_ascii.py" stage `
--source-prj "E:\Altium_design\FOO\FOO.PrjPCB" `
--migration-dir "E:\Kicad_design\FOO\_migration"
```
它把整个工程复制到 `<工程目录>_ascii` staging(**原始工程一个字节都不动**),
枚举出实际的 `.SchDoc` 并生成 `convert_to_ascii.pas`。
接着让用户:**先停掉 eda-agent**(不停会和 StartMCPServer 脚本冲突挂起),再在
Altium 里 `File → Run Script` → 选生成的 `.pas` → `ConvertSchToAscii` → Run。
跑完验证:
```powershell
python ...\stage_ascii.py verify --staging-dir "E:\Altium_design\FOO_ascii"
```
**verify 通过了再回填**:`--set ascii_ok=true --set ascii_staging_dir=E:\Altium_design\FOO_ascii`
> ⚠️ `convert_ascii` 后面没有判定回环,回填 `ascii_ok=false` 图照样往下走。
> 没转干净就进 KiCad 导入必然报 `Header already parsed`,白跑一轮。
### `prompt_kicad_import` — 用户在 KiCad 里手动导入
这一步**没有任何 CLI/MCP 通道**,只能人点。给用户精确路径:
打开 **KiCad 主程序(工程管理器)** → `File → Import Non-KiCad Project →
Altium Project...`
- 源:staging 里的 **`_ascii` 版** `.PrjPCB`(选错成原始工程就前功尽弃)
- 目标:target_dir
- 点 OK 等待,十几页 + 多层 PCB 要几十秒到几分钟
导完常见现象:报告里只有 `IEEE_SYMBOL` 未处理记录(无害);结构是扁平的,
可能多出重复的 `.kicad_sch`(确认无引用后删掉)。
用户确认导完:`--set imported=true`
### `check_file_count` — 文件数核对
Altium 侧基线(原理图页数 + 1 块 PCB)对比 target_dir 下
`*.kicad_sch` + `*.kicad_pcb` 的计数。payload 里的 `kicad_glob_hint` 是图那侧
glob 出来的预填值(`-1` 表示它数不到),你要自己核实。
`--set altium_count=16 --set kicad_count=16`
不一致会退回重导(最多 3 次)。不一致时先看是不是多了重复页,或者某页导入失败。
### `rebuild_rules` — 设计规则重建 + PCB 修复
三件事,顺序不能反:
**1) 改 `.kicad_pro` 的规则**:按基线 `pcb_design_rules.json` 设间隙/线宽/过孔/
孔/网络类。导入只搬几何,规则用的是 KiCad 默认(比 Altium 严),不改的话 DRC
会有几千条误报。
**2) 回填焊盘网络 + 批改 zone clearance**(先干跑看量):
```powershell
$KP = "D:\KiCad\10.0\bin\python.exe" # ~\skills_env.md 的 KICAD_PYTHON
& $KP "...\scripts\fix_pcb.py" --board "E:\Kicad_design\FOO\FOO.kicad_pcb" `
--netlist-csv "E:\Kicad_design\FOO\_migration\reference\netlist_tabular.csv" `
--zone-clearance-mm 0.254 --dry-run
```
`--zone-clearance-mm` 要去基线 `pcb_design_rules.json` 查 **InPolygon 规则**的
polygon clearance,别照抄 0.254。确认后去掉 `--dry-run` 真改(会自动备份板文件)。
**3) 重填铺铜**:让用户在 KiCad GUI 里按 `B`,或走 IPC 后端。
**大板不要用 SWIG/pcbnew refill**,会超时甚至段错误。
跑一次 DRC 确认数字降下来了(`run_drc` 或 `kicad-cli pcb drc`),
然后 `--set rules_ok=true`。
> 这两个坑的根因写在 `references/playbook.md` §4,值得先读:
> **BGA 平面焊盘丢网络**(假短路的真凶,Altium 靠负片内电层隐式连接)、
> **zone 本地 clearance 被写成 0.5mm 且会盖过网络类**(只改网络类完全无效)。
> `rebuild_rules` 后面同样没有回环,别在没修好的时候回填 true。
### `compare_netlist` — 网表对齐(可回环 5 次)
```powershell
conda run -n langgraph python "...\scripts\netcmp.py" `
--kicad-dir "E:\Kicad_design\FOO" `
--netlist-csv "E:\Kicad_design\FOO\_migration\reference\netlist_tabular.csv" `
--out "E:\Kicad_design\FOO\_migration\netcmp"
```
脚本会**逐页**导出 kicadxml(扁平结构下整体导出会漏页)、剥掉 KiCad 网名的前导
`/`、去掉 Altium 的 `net=?` 重复行,然后按 **net partition** 比等价类 —— 不比
网络名,两个工具命名规则不同,逐名比必然一片红。
一致(退出码 0)→ `--set netlist_identical=true`。
不一致 → 看 `diff.json` 改 KiCad 对齐,`--set netlist_identical=false` 会回到这一步
再来一轮。改不动的时候,节点级差异往往不是真错,先按 playbook §5 的三条归一化
自查。超过 5 轮会走 safety_end。
> payload 里写的是「调 `/goal` 让 Claude Code 或 Codex 自主迭代」。愿意的话可以
> 那样做;直接用 `netcmp.py` + 自己改板通常更快也更可控。
### `compare_gerber` — 几何等价(可回环 5 次)
判定基准先说死:**逐层制造几何等价**。离散铜(走线/过孔/焊盘/孔/板框)要零差异;
铺铜纹理、丝印字形、负片内电层是**工具固有容差项,不是缺陷、修不掉**。
决定性证据走矢量比对,不是栅格 XOR:
```powershell
& $KP "...\scripts\dump_kicad_vec.py" --board "...\FOO.kicad_pcb" `
--out "...\_migration\gerber\kicad_vec"
# Altium 侧用 eda-agent 导同名 JSON 到 altium_vec\(格式见 references\vector-compare.md)
python "...\scripts\cmp_vec.py" --altium-vec ...\altium_vec --kicad-vec ...\kicad_vec `
--layer-map ...\layer_map.json --report ...\vec_result.json
```
`TOTAL unmatched primitives: 0` → `--set gerber_aligned=true`。
要出 Gerber 图片证据的话:KiCad 侧 `kicad-cli pcb export gerbers/drill`;
**Altium 侧 eda-agent 生不了 Gerber**(工程没有 OutJob,`proj_run_output` 是空操作),
必须让用户在 `File → Fabrication Outputs → Gerber Files` 手动导,参数见
playbook §7。
### `acceptance_report` — 验收报告
写 `<migration_dir>\verification_report.md`,必须包含:
1. 各阶段 PASS/FAIL 与关键数字(页数、元件数、节点数、走线/过孔匹配数)
2. **明确列出三项工具固有差异**(负片内电层 / 铺铜填充纹理 / 丝印字体)
及其根因,注明是容差项不是缺陷 —— 不写清楚,后面看报告的人会当成没做完
3. 证据文件索引
`--set report_path=E:\Kicad_design\FOO\_migration\verification_report.md`
回填后图结束,`ak_flow.py` 打印 `[DONE]`。
---
## 铁律速查
1. **原理图必须先转 ASCII**。AD26 二进制 `.SchDoc` 会让 KiCad 报
`Header already parsed`。PCB 侧没这问题,只有原理图。
2. **转 ASCII 之前先停 eda-agent**。Altium 一次只能跑一个脚本,桥接自己就是个脚本。
3. **基线快照要趁桥接活着做完**,尤其 tabular 网表 —— 后面回填网络和比网表都靠它。
4. **KiCad 导入只能人工点**,源必须选 `_ascii` 版。
5. **`kicad-cli pcb import` 不能替换主板**,那会切断原理图↔PCB 关联,只能当参考。
6. **zone 的本地 clearance 会盖过网络类**,只改 `.kicad_pro` 无效。
7. **网表比 partition 不比网络名**,且必须逐页导 kicadxml。
8. **跨工具像素级一致不可能**,判定基准是离散几何等价 + 三项固有容差。
9. **原始 Altium 工程只读**,所有写操作只作用于 staging 副本和 target_dir。
## 排错
| 症状 | 处理 |
|---|---|
| resume 后又停在同一个节点 | 回填是空的或全 falsy。langgraph 把它当没提供。加一个非空键。 |
| `app_ping` 突然不通 | 桥接空闲 10 分钟自停了。让用户重跑 `StartMCPServer`。 |
| Altium 脚本跑起来卡死 | eda-agent 没停,两个脚本冲突。停掉再跑。 |
| 导入报 `Header already parsed` | ASCII 没转干净,或者源选成了原始工程。`stage_ascii.py verify` 查。 |
| 导入后 DRC 几千条 | 正常。先按基线改 `.kicad_pro` 规则,再回填焊盘网络、重填铺铜。 |
| 一堆 "GND vs 空网络" 短路 | BGA 平面焊盘丢网络。`fix_pcb.py` 回填。playbook §4.1。 |
| 改了网络类但铺铜避让没变 | zone 本地 clearance 盖过了。`fix_pcb.py --zone-clearance-mm`。playbook §4.2。 |
| refill 超时/段错误 | 大板别用 SWIG。GUI 里按 `B`,或 IPC 后端。 |
| 网表比对差一堆节点 | 多半是某页 kicadxml 没导出成功,看 `netcmp.py` 的 `[export] N/M`。 |
| 矢量比对某层全 unmatched | `layer_map.json` 配错了。不带 `--layer-map` 跑一次看两侧每层走线数。 |
| `[SAFETY-END]` | 某个回环超了重试上限,图优雅退出了。修好卡住的那步再 `start --restart` 重跑;确实需要更多轮次就加 `--max-netlist-retry 8` 这类参数。 |
| kicad-cli 输出 `UnicodeDecodeError` | 别用 `text=True` 收 kicad-cli 的输出,中文 Windows 会按 GBK 解 UTF-8。`netcmp.py` 已处理。 |
| `ModuleNotFoundError: langgraph` | `conda run -n langgraph pip install langgraph langgraph-checkpoint-sqlite` |
| `ImportError: pcbnew` | `fix_pcb.py` / `dump_kicad_vec.py` 必须用 KiCad 自带 python 跑,不是 conda 的。 |
## 深入
- `references/playbook.md` — 每个坑的根因、XMOD/GQ 两次迁移的实测数字、
各阶段该有的判定基准和证据目录约定。卡住了先读这个。
- `references/vector-compare.md` — `altium_vec/` 各 JSON 的字段格式、
`layer_map.json` 怎么定、结果怎么读。
- 图的拓扑与 interrupt 契约:`E:\LangGraph_Design\Altium-Kicad\Notes\Task04.md`
+227
View File
@@ -0,0 +1,227 @@
# Altium → KiCad 迁移 Playbook
两次真实迁移(XMOD_V1.0:10层/1191元件/15页;GQ_EXCT7G_V1:ADRV9009 板)踩出来的东西。
SKILL.md 只写「怎么走」,这里写「为什么」和「踩了会怎样」。遇到卡壳先回来查这里。
---
## 1. 为什么必须先转 ASCII
KiCad 10.0.4 的 Altium **原理图**解析器不支持 AD26 的二进制 `.SchDoc`。直接
`File → Import Non-KiCad Project → Altium Project` 会报:
```
Header already parsed
```
PCB 解析器是好的,只有原理图这一侧不行。二进制 `.SchDoc` 是 CFB 复合文档
(文件头 `D0 CF 11 E0`),ASCII 版则以 `|HEADER=Protel for Windows` 开头。
Altium 侧的出路是 `IServerDocument.DoFileSave('ASCII')`,没有 CLI,只能跑
DelphiScript。两个坑:
- **不要经 MCP 驱动这个脚本**。`obj_run_process(ScriptingSystem:RunScript)` 会与
桥接自己的 `StartMCPServer` 脚本冲突挂起 —— Altium 一次只能跑一个脚本。
正确姿势:**先停掉 eda-agent**,再在 Altium GUI 里 `File → Run Script` 手动跑。
- **DelphiScript 的 `FindFirst` 枚举不可靠**,脚本里的文件名必须是硬编码列表。
`stage_ascii.py stage` 就是干这个的:用 Python 枚举出实际的 `.SchDoc`,把名字
灌进生成的 `.pas` 里。
转换只对 staging 副本做,原始工程一个字节都不能动。
---
## 2. eda-agent(altium MCP)桥接的脾气
桥接不是常驻服务,是 Altium GUI 里跑的一个轮询 DelphiScript:
- **约 10 分钟空闲会自己停**。跑长任务中途回来发现 `app_ping` 不通是常态,
重跑 `Dispatcher.pas → StartMCPServer` 即可,图里 `check_altium_bridge` 的
回环就是为这个留的。
- **一次只能跑一个脚本**,所以任何别的 DelphiScript 都要先停桥接。
- 开启路径:`DXP/Preferences → Scripting System → Global Projects → Install`
选 `Altium_API.PrjScr`,然后 `File → Run Script → Dispatcher.pas →
StartMCPServer → Run`,状态窗出现 `MCP: idle` 就是通了。
- **eda-agent 生不了 Gerber**。`proj_run_output(gerber)` 在没有 OutJob 的工程上
是空操作。Altium 侧的 Gerber 只能让用户在 GUI 里
`File → Fabrication Outputs → Gerber Files` 手动导。
---
## 3. KiCad 导入这一步没有自动化通道
整工程导入必须在 **KiCad 主程序(工程管理器)** 里点:
`File → Import Non-KiCad Project → Altium Project...`
源选 **staging 里的 `_ascii` 版 `.PrjPCB`**,目标选 target_dir。
`kicad-cli pcb import --format altium` 只能转 PCB 单板,**不要拿它替换主板** ——
那样会切断原理图↔PCB 的关联。它有用的地方是另转一份到 `_migration/cli_pcb/`
当几何参考/对照。
导入完成后常见的两个结构问题:
- **扁平层次**:导入器不生成 sheet 符号,所有页平铺;还可能把某一页当成根图。
Save All 时可能生成重复的 `.kicad_sch`(例如 `12.XC7Z100_HP.kicad_sch` 多一份)。
确认无引用后删掉重复文件即可,页数要能对回基线。
- **`IEEE_SYMBOL` 未处理记录**:IEEE 装饰符号被跳过,无害,不用管。
文件数核对就是拿「Altium 原理图页数 + 1 块 PCB」对
`target_dir` 下的 `*.kicad_sch` + `*.kicad_pcb` 计数。
---
## 4. 导入后 PCB 的两个必修项
### 4.1 BGA / 平面焊盘丢网络(假短路的真凶)
Altium 用**负片内电层**隐式连接电源和地,焊盘本身不带网络信息。KiCad 没有这套
机制,导入时这些焊盘就空着网络。XMOD 实测 **527 个 BGA 电源/地焊盘丢网络**,
DRC 里表现为 103 个「GND vs 空网络」短路。
修法:拿阶段 0 导出的 Altium 网表真值(`netlist_tabular.csv` 的
component/pin/net)程序化回填。`fix_pcb.py` 就是干这个的,先 `--dry-run` 看量。
回填后还会剩一批查不到真值的空网络焊盘 —— 多是 RJ45 屏蔽壳、机械/安装孔焊盘,
按实际情况单独指派 GND(XMOD 是 18 个)。
### 4.2 覆铜 zone 的本地 clearance(异 net 避让偏大的真凶)
这是最容易白费力气的一个坑。Altium 导入器把**每一块 zone 的本地 Clearance
写成 KiCad 默认的 0.5mm**,落在 `.kicad_pcb` 里长这样:
```
(zone ... (connect_pads (clearance 0.5)) ...)
```
而 **zone 的本地值会盖过网络类 clearance**。所以只改 `.kicad_pro` 的网络类
(比如 0.2mm)或板级规则,铺铜避让一点都不会变 —— 必须逐块 zone 改。
目标值取 Altium 的 **polygon clearance**(InPolygon 规则,去基线
`pcb_design_rules.json` 查;XMOD 和 GQ 实测都是 **0.254mm**,但别照抄)。
**热焊盘不用动**:KiCad 的 `thermal_gap` 0.1651mm 已经匹配 Altium
`PolygonConnect` 的 AirGap 0.165mm。
改完必须重填铺铜。大板上 **SWIG/pcbnew 的 refill 容易超时甚至段错误**,
去 KiCad GUI 里按 `B`,或者走 IPC 后端。
### 4.3 DRC 数字的正常轨迹
导入只搬几何,规则用的是 KiCad 默认(比 Altium 严),所以初始 DRC 会很吓人。
XMOD 的轨迹供参考:按基线改完 `.kicad_pro` 规则 **2878 → 1481**(错误
1810→414,track/via/drill 三类规则误报各 199 清零);再回填 527 个焊盘网络 +
重填铺铜后 clearance 282→0、via_dangling 199→15、shorting 103→92。
残留的 92 是板角铺铜畸变和连接器,不是真短路。
---
## 5. 网表比对:比 partition,不比网络名
两个工具的命名规则不同,逐名比对必然一片红。要比的是**等价类**:把网表归约成
「每个引脚 → 它所在网络的全部节点集合」,两侧集合相等即电气一致。
三个归一化,少一个就出假差异:
1. **KiCad 网名有前导 `/`**(层次路径),Altium 没有 → 剥掉。
XMOD 初比出的 16 个差异全是 `R_I-` vs `/R_I-` 这种。
2. **Altium tabular 导出有 `net=?` 的重复行**(隐藏的电源/平面引脚被重复列出)。
XMOD 原始 9511 行里有 4500 行是 `?`,去重后才是 5011 个真实节点。
3. **KiCad 扁平结构下整体导出网表会漏页** —— XMOD 整体导出只拿到根图的 98 个
节点。必须**逐页** `kicad-cli sch export netlist --format kicadxml` 再按网名合并
(5011 节点 / 1186 元件 / 1477 网)。格式必须是 **kicadxml**;PADS 格式会漏掉
单节点网(只得 4154)。
XMOD 终判:5011=5011 节点全同、0 单边节点、等价类集合完全相等(1473=1473)、
0 实质差异 → **网表 100% 一致**。
`netcmp.py` 把上面全部实现了,包括逐页导出。
---
## 6. Gerber / 几何比对:什么叫「100% 一致」
**跨工具 byte/像素级一致不可能**,这不是没做好,是物理上做不到:aperture、
坐标格式、填充算法、字体引擎、正负片全都不同。所以判定基准必须先界定清楚:
**判定 = 逐层制造几何等价。** 离散铜(走线/焊盘/过孔/孔/板框)要零差异;
铺铜填充纹理、丝印字形、负片内电层属于**工具固有容差项**。
### 6.1 决定性证据是矢量比对,不是栅格 XOR
栅格 XOR(pygerber 渲染后逐像素)只能给「像什么程度」的百分比,说服力有限。
矢量比对是逐条逐个匹配走线和过孔,对上就是同一块板。
XMOD 的矢量结论:**走线 12144=12144、过孔 4808=4808 逐条逐个相同,
`TOTAL_DISCRETE_DIFF=0`**。
坐标变换只有 2 个自由参数(平移),由两侧过孔质心解出;用 2 个参数去对上
几千个图元,对上了就是实证而不是曲线拟合。Altium 与 KiCad 之间是**纯平移,
无翻转无镜像**(XMOD 实测 `KiCad = Altium + (38.501, −180.004)`)。
注意 Altium 的 y 轴向上、KiCad 向下,变换里 y 要取负。
容差取 **30um**:eda-agent 读出的 Altium 坐标是整数 mil,读数精度本身
±12.7um,两端各一次就接近 25um;KiCad 侧是精确的。
`dump_kicad_vec.py` + `cmp_vec.py` 实现这一套,Altium 侧的 JSON 格式见
`vector-compare.md`。
### 6.2 栅格比对的参考数值(XMOD 逐层)
| 层 | 差异 | 判定 |
|----|------|------|
| 焊盘 Paste | 0.035% | 完全一致(也用来验证平移变换的精度) |
| 板框 Profile | 0.34% | 一致 |
| 外层铜 F_Cu | 1.04% | 基本一致 |
| 阻焊 Mask | ~2% | 扩展量微差 |
| 丝印 Silk | 42–70% | 字体引擎不同,固有 |
| 内电层 GND/POWER ×5 | 反相后 ~12% | Altium 负片 vs KiCad 正片 |
| 内层信号 ×3 | 高 | 铺铜覆盖/填充差异 |
看到丝印 70% 不要慌,那是字体,不是错。负片内电层必须**先反相**才能比。
### 6.3 三个不可消除的工具固有差异(已查明根因,不是缺陷)
1. **负片内电层**:KiCad 架构里没有「内电层」这种层类型,也没有负片输出能力。
与 Altium 的负片格式无法一致,但**物理铜等价**——制板厂显影结果相同。
2. **铺铜填充纹理**:填充算法不同(热焊盘辐条/网格/反焊盘),即使轮廓和网络
完全相同也不可能逐像素一致。
3. **丝印字体**:字体引擎不同。
验收报告里要把这三条明确列为容差项,别当缺陷去修 —— 修不掉。
---
## 7. Altium 侧 Gerber 手动导出参数
eda-agent 生不了,让用户在 Altium 里导,参数要对齐 KiCad 侧:
- `File → Fabrication Outputs → Gerber Files`:单位 mm、精度 4:6、
Embedded RS274X、**不镜像**、**绝对原点**
- `File → Fabrication Outputs → NC Drill Files`
- 输出落在 `Project Outputs for <工程名>\`,复制到 `_migration/gerber/altium/`
KiCad 侧对应:`kicad-cli pcb export gerbers`(精度 6、绝对原点)+
`kicad-cli pcb export drill`(Excellon、mm)。
---
## 8. 证据目录约定
全部落在 `<target_dir>\_migration\`:
```
altium_baseline/ 阶段0 基线真值:stats / nets / bom / stackup / pcb_design_rules.json
reference/ 参考网表(netlist_tabular.csv) + 原理图 PDF
altium_source_snapshot/ 只读源副本
convert_to_ascii.pas 由 stage_ascii.py 生成
ascii_convert_log.txt DelphiScript 跑完的日志
cli_pcb/ kicad-cli 忠实导入的 PCB(仅作几何参考,不替换主板)
netcmp/ xml_*.xml 逐页网表 + diff.json + 报告
gerber/ altium_vec/ kicad_vec/ layer_map.json + 比对报告
backup/ 每次改板前的 .kicad_pcb 快照
verification_report.md 最终验收报告
```
XMOD 的基线真值(作为「基线该长什么样」的样例):15 页 / 元件 1191(PCB 1186)/
管脚 9511 / 网络 1474 / 10 层 / 走线 21296 · 过孔 4808 · 焊盘 5154 /
未布线 0 / 224×150mm / 41 条设计规则。
+125
View File
@@ -0,0 +1,125 @@
# 矢量几何比对:Altium 侧 JSON 怎么导
`cmp_vec.py` 要两个目录:`kicad_vec/` 由 `dump_kicad_vec.py` 生成;
`altium_vec/` 得你在 Claude 会话里用 eda-agent 的 MCP 工具查出来再写盘 ——
LangGraph 进程里没有 MCP,这一步必须由会话来做。
---
## 目录结构
```
_migration\gerber\
├── altium_vec\ meta.json vias.json pads.json tracks.json
├── kicad_vec\ meta.json vias.json pads.json tracks.json (dump_kicad_vec.py 生成)
└── layer_map.json
```
---
## altium_vec 各文件的格式
`cmp_vec.py` 的 `load()` 容忍三种外壳:裸 list、`{"vias": [...]}`、`{"objects": [...]}`,
所以直接把 MCP 返回的结果落盘一般就能用。字段名**区分大小写**,按下表来。
### meta.json
```json
{
"unit_note": "eda-agent 返回的是整数 mil,读数精度 ±0.5mil = 12.7um",
"scale_to_mm": 0.0254,
"counts": {"tracks": 10206, "arcs": 108, "pads": 3010, "vias": 2209}
}
```
`scale_to_mm` 是 `cmp_vec.py` 唯一会读的字段(也可以用 `--scale-to-mm` 覆盖)。
eda-agent 返回整数 mil,所以是 `0.0254`。
### vias.json — 来自 `pcb_get_vias`
小写字段:
```json
{"vias": [{"x": 22046, "y": 33005, "size": 20, "hole_size": 12,
"net": "VCC3V3", "low_layer": "TopLayer", "high_layer": "BottomLayer"}]}
```
`cmp_vec.py` 只用 `x` / `y`,其余留着供人核对。
### tracks.json — 来自 `obj_query`(PCB 文档,Track 对象)
**大写**字段(obj_query 原样返回 Altium 的属性名),值可以是字符串:
```json
{"objects": [{"Layer": "TopLayer", "X1": "21739", "Y1": "33141",
"X2": "21859", "Y2": "33141", "Width": "6", "Net": "PS232_RX"}]}
```
`cmp_vec.py` 用 `Layer` / `X1,Y1` / `X2,Y2`,按中点逐层匹配。
### pads.json — 来自 `obj_query`(Pad 对象)
```json
{"objects": [{"Name": "", "X": "22666", "Y": "32072", "Layer": "TopLayer",
"Net": "GND", "HoleSize": "0", "TopXSize": "59",
"TopYSize": "118", "Rotation": "90"}]}
```
用 `X` / `Y`。
---
## layer_map.json
Altium 层名 → KiCad 层名。**必须逐工程写,不能套公式** —— `MidLayerN` 到
`InN.Cu` 的对应取决于叠层顺序,想当然会全错。
```json
{
"TopLayer": "F.Cu",
"MidLayer1": "In2.Cu",
"MidLayer3": "In5.Cu",
"MidLayer2": "In7.Cu",
"BottomLayer": "B.Cu"
}
```
(上面是 GQ_EXCT7G_V1 的真实映射,注意 MidLayer2/3 是反的。)
怎么定:不带 `--layer-map` 跑一次 `cmp_vec.py`,它会打印两侧每层的走线条数,
按计数配对;再用 `pcb_get_layer_stackup`(Altium 侧)和 `get_layer_list`
(KiCad 侧)核对叠层顺序。内电层(负片平面)通常没有走线,不进 map。
---
## 跑一遍
```powershell
# 1) KiCad 侧(下面的 KiCad python 路径取自 ~/skills_env.md 的 KICAD_PYTHON)
& "D:\KiCad\10.0\bin\python.exe" dump_kicad_vec.py `
--board "E:\Kicad_design\FOO\FOO.kicad_pcb" `
--out "E:\Kicad_design\FOO\_migration\gerber\kicad_vec"
# 2) Altium 侧:在会话里用 eda-agent 查,按上面的格式写进 altium_vec\
# 3) 先不给 layer_map,看两侧每层走线数,定映射
python cmp_vec.py --altium-vec ...\altium_vec --kicad-vec ...\kicad_vec
# 4) 写好 layer_map.json 再跑完整比对
python cmp_vec.py --altium-vec ...\altium_vec --kicad-vec ...\kicad_vec `
--layer-map ...\layer_map.json --report ...\vec_result.json
```
退出码 0 = 全部图元匹配。
---
## 结果怎么读
- `unmatched_altium` 和 `unmatched_kicad` **都要是 0**。只看一边会漏掉
「KiCad 多出来的图元」。
- `residual mm` 的 max 应该在容差内且远小于容差(XMOD/GQ 实测中位数在 1um 量级)。
如果 max 贴着容差上限,多半是平移标定被拉偏了 —— 先查两侧过孔数是否相等。
- 两侧过孔数不同 → 质心标定必然偏。先查是导入丢了过孔,还是 Altium 侧
`obj_query` 漏了层,别硬比。
- 某一层大面积 unmatched,而其它层是 0 → 十有八九是 `layer_map.json` 配错了。
+337
View File
@@ -0,0 +1,337 @@
"""ak_flow.py — 驱动 Altium→KiCad 迁移的 LangGraph 图(进程内,无 langgraph dev)。
图本体在 ~/skills_env.md 的 ALTIUM_KICAD_GRAPH_ROOT(app/main_graph.py),本脚本只做三件事:
1) 用 SqliteSaver 打开持久化 checkpoint,按 thread_id 续跑;
2) 把当前挂起的 interrupt 以结构化 JSON 打印给 Claude 会话;
3) 把 Claude 执行完真实动作后的结论 Command(resume=...) 回填、推进到下一个 interrupt。
为什么不直接用工程里的 run.py:run.py 的 resume 只接受一个位置参数 JSON 字符串,
在 PowerShell 里转义引号极易出错;且 thread 固定为 AK_THREAD 单值,多工程并发会串。
本脚本用 --set key=value 免转义回填,thread 由 target_dir 推导,DB 沿用
runs/skill_checkpoint.sqlite(已跑完的 ak-xmod-v1p0 / ak-gq-exct7g-v1 仍可续)。
退出码:0 = 图已结束(done);2 = 停在 interrupt 等回填(waiting);1 = 出错。
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sqlite3
import sys
from pathlib import Path
def _env_lookup(key: str, default: str | None = None) -> str:
"""环境变量优先,其次 ~/skills_env.md 的 `KEY = value` 行(机器相关参数的真源)。"""
v = os.environ.get(key)
if v:
return v
path = os.path.join(os.path.expanduser("~"), "skills_env.md")
try:
with open(path, encoding="utf-8") as f:
for line in f:
m = re.match(rf"\s*{key}\s*=\s*(\S+)", line)
if m:
return m.group(1)
except OSError:
pass
if default is None:
sys.exit(f"missing {key}: add it to ~/skills_env.md or set it as an env var")
return default
# 图工程根是机器相关的:真源是 ~/skills_env.md 的 ALTIUM_KICAD_GRAPH_ROOT。
DEFAULT_PROJECT_ROOT = _env_lookup("ALTIUM_KICAD_GRAPH_ROOT", r"E:\LangGraph_Design\Altium-Kicad")
DB_RELATIVE = ("runs", "skill_checkpoint.sqlite")
# --------------------------------------------------------------------------- utils
def _out(*parts: str) -> None:
print(*parts, flush=True)
def _fatal(msg: str) -> "None":
_out(f"[ERROR] {msg}")
sys.exit(1)
def slug_for(target_dir: str) -> str:
"""E:\\Kicad_design\\XMOD_V1p0 -> ak-xmod-v1p0 (与既有线程命名一致)。"""
name = Path(target_dir.rstrip("\\/")).name or "run"
name = re.sub(r"[^0-9A-Za-z]+", "-", name).strip("-").lower()
return f"ak-{name or 'run'}"
def coerce(raw: str):
"""--set 的值做最小类型推断:true/false→bool,纯数字→int,其余原样字符串。"""
low = raw.strip().lower()
if low in ("true", "yes", "1"):
return True
if low in ("false", "no", "0"):
return False
if re.fullmatch(r"-?\d+", raw.strip()):
return int(raw.strip())
return raw
def pending_interrupt(snap):
for task in snap.tasks:
if task.interrupts:
return task.interrupts[0].value
return None
def load_graph(project_root: Path):
if not (project_root / "app" / "main_graph.py").is_file():
_fatal(f"工程根不对:{project_root} 下没有 app\\main_graph.py。"
f"用 --project-root 指定,设环境变量 AK_PROJECT_ROOT,或改 ~/skills_env.md 的 ALTIUM_KICAD_GRAPH_ROOT。")
sys.path.insert(0, str(project_root))
from app.main_graph import build_graph # noqa: E402
return build_graph
RETRY_LIMITS = {"bridge": 3, "import": 3, "netlist": 5, "gerber": 5}
def init_state(source_prj: str, target_dir: str, limits: dict[str, int]) -> dict:
state = {
"source_prj": source_prj,
"target_dir": target_dir,
"migration_dir": str(Path(target_dir) / "_migration"),
"history": [],
}
for name, default in RETRY_LIMITS.items():
state[f"{name}_retry"] = 0
state[f"max_{name}_retry"] = limits.get(name) or default
return state
# --------------------------------------------------------------------------- report
def report(graph, thread: dict, before_len: int) -> int:
"""打印当前状态;返回进程退出码。"""
snap = graph.get_state(thread)
values = snap.values or {}
history = values.get("history", [])
walked = [h.get("node") for h in history[before_len:]]
if walked:
_out("[NODES] " + " -> ".join(str(n) for n in walked))
_out(f"[THREAD] {thread['configurable']['thread_id']}")
if values.get("source_prj"):
_out(f"[PATHS] source_prj = {values['source_prj']}")
_out(f"[PATHS] target_dir = {values['target_dir']}")
_out(f"[PATHS] migration = {values.get('migration_dir', '')}")
retries = {k: values.get(k) for k in
("bridge_retry", "import_retry", "netlist_retry", "gerber_retry")
if values.get(k)}
if retries:
_out("[RETRY] " + json.dumps(retries))
if snap.next:
payload = pending_interrupt(snap)
_out(f"[WAITING] next node: {', '.join(snap.next)}")
if payload is not None:
_out("[INTERRUPT] " + json.dumps(payload, ensure_ascii=False, indent=2))
else:
_out("[INTERRUPT] (无 payload — 直接 resume 推进)")
return 2
if any(h.get("node") == "safety_exit" for h in history):
_out("[SAFETY-END] 某个判定回环超过重试上限,图已优雅退出。"
"先修好卡住的那一步,再用 restart 重跑,或调高对应 max_*_retry。")
return 0
_out("[DONE] 图已跑完。")
_out(f"[REPORT] {values.get('report_path', '(未回填 report_path)')}")
return 0
# --------------------------------------------------------------------------- commands
def cmd_start(args, graph_builder, db_path: Path):
from langgraph.checkpoint.sqlite import SqliteSaver
source = args.source_prj
target = args.target_dir
if not source or not target:
_fatal("start 需要 --source-prj 与 --target-dir(均为绝对路径,无默认值)。")
if not Path(source).is_file():
_fatal(f"源工程不存在:{source}(应指向 .PrjPCB 文件本身)")
Path(target).mkdir(parents=True, exist_ok=True)
thread_id = args.thread or slug_for(target)
thread = {"configurable": {"thread_id": thread_id}}
with SqliteSaver.from_conn_string(str(db_path)) as cp:
graph = graph_builder(checkpointer=cp)
existing = graph.get_state(thread)
if (existing.next or existing.values) and not args.restart:
_out(f"[INFO] 线程 {thread_id} 已存在,续跑(不重置)。要从头开始请加 --restart。")
before = len(existing.values.get("history", []))
if existing.next and pending_interrupt(existing) is not None:
# 已经停在 interrupt 上,什么都不推进,直接把 payload 亮出来
return report(graph, thread, before)
graph.invoke(None, thread)
return report(graph, thread, before)
if args.restart:
_out(f"[INFO] --restart:清空线程 {thread_id} 的 checkpoint。")
wipe_thread(db_path, thread_id)
limits = {name: getattr(args, f"max_{name}_retry", None) for name in RETRY_LIMITS}
_out(f"[INFO] 新建线程 {thread_id}")
graph.invoke(init_state(source, target, limits), thread)
return report(graph, thread, 0)
def cmd_status(args, graph_builder, db_path: Path):
from langgraph.checkpoint.sqlite import SqliteSaver
thread_id = resolve_thread(args)
thread = {"configurable": {"thread_id": thread_id}}
with SqliteSaver.from_conn_string(str(db_path)) as cp:
graph = graph_builder(checkpointer=cp)
snap = graph.get_state(thread)
if not snap.values and not snap.next:
_fatal(f"线程 {thread_id} 没有任何 checkpoint。先跑 start。")
return report(graph, thread, len(snap.values.get("history", [])))
def cmd_resume(args, graph_builder, db_path: Path):
from langgraph.checkpoint.sqlite import SqliteSaver
from langgraph.types import Command
payload: dict = {}
if args.json:
try:
payload = json.loads(args.json)
except json.JSONDecodeError as exc:
_fatal(f"--json 不是合法 JSON:{exc}")
for item in args.set or []:
if "=" not in item:
_fatal(f"--set 要写成 key=value,收到:{item}")
key, raw = item.split("=", 1)
payload[key.strip()] = coerce(raw)
if not payload:
_fatal("resume 的回填不能为空——langgraph 会把 falsy 的 resume 当作『没提供』"
"从而在同一个节点再次中断。至少给一个键,例如 --set acknowledged=true。")
thread_id = resolve_thread(args)
thread = {"configurable": {"thread_id": thread_id}}
with SqliteSaver.from_conn_string(str(db_path)) as cp:
graph = graph_builder(checkpointer=cp)
snap = graph.get_state(thread)
if not snap.next:
_fatal(f"线程 {thread_id} 没有挂起的 interrupt(图已结束或未启动)。先跑 status。")
before = len(snap.values.get("history", []))
_out("[RESUME] " + json.dumps(payload, ensure_ascii=False))
graph.invoke(Command(resume=payload), thread)
return report(graph, thread, before)
def cmd_list(args, graph_builder, db_path: Path):
from langgraph.checkpoint.sqlite import SqliteSaver
if not db_path.is_file():
_out("[INFO] 还没有任何 checkpoint 数据库。")
return 0
conn = sqlite3.connect(str(db_path))
try:
threads = sorted({r[0] for r in conn.execute("select distinct thread_id from checkpoints")})
finally:
conn.close()
if not threads:
_out("[INFO] 数据库里没有线程。")
return 0
with SqliteSaver.from_conn_string(str(db_path)) as cp:
graph = graph_builder(checkpointer=cp)
for tid in threads:
snap = graph.get_state({"configurable": {"thread_id": tid}})
v = snap.values or {}
state = f"waiting@{','.join(snap.next)}" if snap.next else "done"
_out(f"[THREAD] {tid:<24} {state:<28} target={v.get('target_dir', '?')}")
return 0
def cmd_reset(args, graph_builder, db_path: Path):
thread_id = resolve_thread(args)
n = wipe_thread(db_path, thread_id)
_out(f"[INFO] 已删除线程 {thread_id} 的 {n} 条 checkpoint 记录。")
return 0
def wipe_thread(db_path: Path, thread_id: str) -> int:
if not db_path.is_file():
return 0
conn = sqlite3.connect(str(db_path))
try:
n = conn.execute("delete from checkpoints where thread_id=?", (thread_id,)).rowcount
conn.execute("delete from writes where thread_id=?", (thread_id,))
conn.commit()
finally:
conn.close()
return n
def resolve_thread(args) -> str:
if getattr(args, "thread", None):
return args.thread
if getattr(args, "target_dir", None):
return slug_for(args.target_dir)
_fatal("需要 --thread <id> 或 --target-dir <目标 KiCad 目录> 来定位线程。用 list 看所有线程。")
# --------------------------------------------------------------------------- main
def main() -> int:
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
ap = argparse.ArgumentParser(description="Drive the Altium->KiCad migration LangGraph flow.")
ap.add_argument("--project-root", default=os.environ.get("AK_PROJECT_ROOT", DEFAULT_PROJECT_ROOT))
sub = ap.add_subparsers(dest="cmd", required=True)
p = sub.add_parser("start", help="新建或续跑一次迁移")
p.add_argument("--source-prj", help="源 Altium .PrjPCB 绝对路径")
p.add_argument("--target-dir", help="目标 KiCad 工程目录绝对路径")
p.add_argument("--thread", help="线程 id(默认由 target-dir 推导)")
p.add_argument("--restart", action="store_true", help="清空该线程重新开始")
for name, default in RETRY_LIMITS.items():
p.add_argument(f"--max-{name}-retry", type=int,
help=f"{name} 回环的重试上限(默认 {default},超限走 safety_end)")
p.set_defaults(func=cmd_start)
p = sub.add_parser("status", help="看当前停在哪个 interrupt")
p.add_argument("--thread")
p.add_argument("--target-dir")
p.set_defaults(func=cmd_status)
p = sub.add_parser("resume", help="回填结论并推进到下一个 interrupt")
p.add_argument("--thread")
p.add_argument("--target-dir")
p.add_argument("--set", action="append", metavar="KEY=VALUE",
help="免转义回填,可重复;true/false→bool,纯数字→int")
p.add_argument("--json", help="直接给 JSON 对象(与 --set 可混用)")
p.set_defaults(func=cmd_resume)
p = sub.add_parser("list", help="列出所有迁移线程")
p.set_defaults(func=cmd_list)
p = sub.add_parser("reset", help="删除某个线程的 checkpoint")
p.add_argument("--thread")
p.add_argument("--target-dir")
p.set_defaults(func=cmd_reset)
args = ap.parse_args()
root = Path(args.project_root)
db_path = root.joinpath(*DB_RELATIVE)
db_path.parent.mkdir(parents=True, exist_ok=True)
graph_builder = load_graph(root)
return args.func(args, graph_builder, db_path)
if __name__ == "__main__":
sys.exit(main())
+200
View File
@@ -0,0 +1,200 @@
"""cmp_vec.py — Altium 与 KiCad 的离散版图几何等价判定(阶段 3 的决定性证据)。
为什么这个比栅格 Gerber XOR 更权威:跨工具的 Gerber 不可能像素级一致
(aperture / 坐标格式 / 填充算法 / 字体 / 负片都不同),栅格 XOR 只能给出
「像什么程度」。而走线/过孔/焊盘是离散图元,可以逐条逐个匹配 —— 匹配上了
就是同一块板,匹配不上就是真差异,没有解释空间。
坐标变换只有两个自由参数,不是曲线拟合:
kx = s*ax + tx
ky = -s*ay + ty (Altium y 向上,KiCad y 向下)
s 由单位固定(Altium API 返回整数 mil -> mm 就是 0.0254),(tx,ty) 由两侧过孔
质心解出。用 2 个参数去对上几千个过孔+焊盘+走线,对上了就是实证。
容差默认 30um:eda-agent 读出的 Altium 坐标是**整数 mil**,读数精度本身就有
±12.7um,两端各一次就接近 25um。KiCad 侧是精确的(导入器直接解析二进制 PcbDoc)。
用法:
python cmp_vec.py --altium-vec .../altium_vec --kicad-vec .../kicad_vec \\
--layer-map .../layer_map.json [--tol-mm 0.030] [--report r.json]
退出码 0 = 全部图元匹配;1 = 有未匹配图元。
"""
from __future__ import annotations
import argparse
import collections
import json
import math
import sys
from pathlib import Path
def load(path: Path, *containers: str):
"""容忍两种外壳:裸 list,或 {"vias": [...]} / {"objects": [...]}。"""
data = json.loads(path.read_text(encoding="utf-8"))
if isinstance(data, dict):
for key in containers + ("objects", "vias", "items"):
if key in data and isinstance(data[key], list):
return data[key]
raise SystemExit(f"[ERROR] {path} 是 dict 但找不到列表字段(试过 {containers + ('objects',)})")
return data
def num(v) -> float:
return float(v)
def match(a_pts, k_pts, tol, label, out_lines):
"""网格加速的最近邻一对一匹配。返回 (未匹配的A, 未匹配的K)。"""
if not a_pts and not k_pts:
return 0, 0
cell = max(tol * 4, 0.05)
grid = collections.defaultdict(list)
for i, p in enumerate(k_pts):
grid[(int(p[0] / cell), int(p[1] / cell))].append(i)
used, res, unmatched = set(), [], 0
for p in a_pts:
gx, gy = int(p[0] / cell), int(p[1] / cell)
best, bd = None, 1e9
for dx in (-1, 0, 1):
for dy in (-1, 0, 1):
for i in grid.get((gx + dx, gy + dy), ()):
if i in used:
continue
d = math.hypot(k_pts[i][0] - p[0], k_pts[i][1] - p[1])
if d < bd:
bd, best = d, i
if best is None or bd > tol:
unmatched += 1
else:
used.add(best)
res.append(bd)
line = (f"{label}: altium={len(a_pts)} kicad={len(k_pts)} matched={len(res)} "
f"unmatched_altium={unmatched} unmatched_kicad={len(k_pts) - len(used)}")
if res:
res.sort()
line += (f"\n residual mm: max={res[-1]:.6f} "
f"p99={res[min(int(len(res) * 0.99), len(res) - 1)]:.6f} "
f"median={res[len(res) // 2]:.6f}")
print("\n" + line)
out_lines.append(line)
return unmatched, len(k_pts) - len(used)
def suggest_layer_map(a_tracks, k_tracks):
"""没给 layer_map 时,按每层走线条数给个候选映射,供人核对。"""
a_cnt = collections.Counter(str(t.get("Layer", t.get("layer"))) for t in a_tracks)
k_cnt = collections.Counter(t["layer"] for t in k_tracks)
print("\n[layer] Altium 各层走线数: " + json.dumps(a_cnt.most_common(), ensure_ascii=False))
print("[layer] KiCad 各层走线数: " + json.dumps(k_cnt.most_common(), ensure_ascii=False))
print("[layer] 没有 --layer-map。请对照上面两组计数写一个 layer_map.json,例如\n"
' {"TopLayer": "F.Cu", "MidLayer1": "In2.Cu", "BottomLayer": "B.Cu"}\n'
" 注意层序按叠层走,不要想当然 MidLayerN -> InN.Cu。")
def main() -> int:
ap = argparse.ArgumentParser(description="Vector geometry equivalence: Altium vs KiCad")
ap.add_argument("--altium-vec", required=True)
ap.add_argument("--kicad-vec", required=True)
ap.add_argument("--layer-map", help="{Altium层名: KiCad层名} 的 JSON;不给则只比过孔和焊盘并给出建议")
ap.add_argument("--tol-mm", type=float, default=0.030)
ap.add_argument("--scale-to-mm", type=float,
help="Altium 原始单位 -> mm。默认读 altium_vec/meta.json,再默认 0.0254(mil)")
ap.add_argument("--report", help="把结论写成 JSON")
args = ap.parse_args()
av, kv = Path(args.altium_vec), Path(args.kicad_vec)
scale = args.scale_to_mm
if scale is None:
meta_path = av / "meta.json"
if meta_path.is_file():
scale = json.loads(meta_path.read_text(encoding="utf-8")).get("scale_to_mm")
scale = scale or 0.0254
print(f"scale_to_mm : {scale} tol_mm : {args.tol_mm}")
a_vias = load(av / "vias.json", "vias")
k_vias = load(kv / "vias.json", "vias")
print(f"vias: altium={len(a_vias)} kicad={len(k_vias)}")
if not a_vias or not k_vias:
raise SystemExit("[ERROR] 两侧都必须有过孔才能标定平移(过孔是最干净的基准)。")
if len(a_vias) != len(k_vias):
print("[WARN] 两侧过孔数不同 —— 质心标定会被拉偏。先查清是导入丢了过孔,"
"还是 Altium 侧导出漏了层,再比。")
ax = sum(num(v["x"]) * scale for v in a_vias) / len(a_vias)
ay = sum(num(v["y"]) * scale for v in a_vias) / len(a_vias)
kx = sum(v["x"] for v in k_vias) / len(k_vias)
ky = sum(v["y"] for v in k_vias) / len(k_vias)
TX, TY = kx - ax, ky + ay
print(f"transform : kx = ax*{scale} + {TX:.6f} ky = -ay*{scale} + {TY:.6f}")
def T(o, kx_="x", ky_="y"):
return (num(o[kx_]) * scale + TX, TY - num(o[ky_]) * scale)
lines: list[str] = []
bad = 0
skipped_tracks = False
bad += sum(match([T(v) for v in a_vias],
[(v["x"], v["y"]) for v in k_vias], args.tol_mm, "VIAS", lines))
a_pads_p, k_pads_p = av / "pads.json", kv / "pads.json"
if a_pads_p.is_file() and k_pads_p.is_file():
a_pads, k_pads = load(a_pads_p), load(k_pads_p)
bad += sum(match([T(p, "X", "Y") for p in a_pads],
[(p["x"], p["y"]) for p in k_pads], args.tol_mm, "PADS", lines))
a_tr_p, k_tr_p = av / "tracks.json", kv / "tracks.json"
if a_tr_p.is_file() and k_tr_p.is_file():
a_tr, k_tr = load(a_tr_p), load(k_tr_p)
if not args.layer_map:
skipped_tracks = True
suggest_layer_map(a_tr, k_tr)
else:
laymap = json.loads(Path(args.layer_map).read_text(encoding="utf-8"))
a_cu = [t for t in a_tr if str(t.get("Layer")) in laymap]
print(f"\naltium tracks total={len(a_tr)} on mapped copper={len(a_cu)} "
f"kicad copper tracks={len(k_tr)}")
def mid_a(t):
p1, p2 = T(t, "X1", "Y1"), T(t, "X2", "Y2")
return ((p1[0] + p2[0]) / 2, (p1[1] + p2[1]) / 2)
for alay, klay in sorted(laymap.items()):
# 逐层比,否则相邻层上位置重合的走线会互相错配
aa = [mid_a(t) for t in a_cu if str(t.get("Layer")) == alay]
kk = [((t["x1"] + t["x2"]) / 2, (t["y1"] + t["y2"]) / 2)
for t in k_tr if t["layer"] == klay]
if not aa and not kk:
continue
bad += sum(match(aa, kk, args.tol_mm, f"TRACKS {alay} -> {klay}", lines))
equivalent = bad == 0 and not skipped_tracks
print("\n" + "=" * 60)
print(f"TOTAL unmatched primitives: {bad}"
+ (" (走线未比对)" if skipped_tracks else ""))
print("=" * 60)
if skipped_tracks:
print("[INCOMPLETE] 没给 --layer-map,走线整个跳过了 —— 上面这个 0 只覆盖过孔和焊盘,"
"不能当作等价结论。按上面的层计数写好 layer_map.json 再跑一次。")
elif equivalent:
print("离散几何 100% 等价 —— 走线/过孔/焊盘逐条对上。铺铜纹理、丝印字形、"
"负片内电层的差异是工具固有项,不计入本判定(理由见 references\\playbook.md)。")
if args.report:
Path(args.report).write_text(json.dumps({
"unmatched_total": bad, "equivalent": equivalent,
"tracks_compared": not skipped_tracks,
"scale_to_mm": scale, "tol_mm": args.tol_mm,
"transform": {"tx": TX, "ty": TY},
"details": lines,
}, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"[report] {args.report}")
return 0 if equivalent else 1
if __name__ == "__main__":
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
sys.exit(main())
+100
View File
@@ -0,0 +1,100 @@
"""dump_kicad_vec.py — 把 KiCad 板的离散几何导成 JSON,供 cmp_vec.py 比对。必须用 KiCad python 跑。
输出(单位一律 mm,KiCad 原生坐标系:y 向下):
vias.json [{x, y, drill, top, bot, net}]
pads.json [{x, y, layer, net, ref, pad, drill}]
tracks.json [{x1, y1, x2, y2, width, layer, net}]
meta.json {counts, layers}
用法(KiCad python 路径见 ~/skills_env.md 的 KICAD_PYTHON):
D:\\KiCad\\10.0\\bin\\python.exe dump_kicad_vec.py \\
--board E:\\Kicad_design\\FOO\\FOO.kicad_pcb \\
--out E:\\Kicad_design\\FOO\\_migration\\gerber\\kicad_vec
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
def main() -> int:
ap = argparse.ArgumentParser(description="Dump KiCad board discrete geometry to JSON (mm)")
ap.add_argument("--board", required=True)
ap.add_argument("--out", required=True)
args = ap.parse_args()
try:
import pcbnew
except ImportError:
print("[ERROR] 导不到 pcbnew —— 用 KiCad 自带 python 跑(~\\skills_env.md 的 KICAD_PYTHON)。")
return 1
out = Path(args.out)
out.mkdir(parents=True, exist_ok=True)
board = pcbnew.LoadBoard(args.board)
mm = pcbnew.ToMM
def via_size(v) -> float:
"""KiCad 10 起 PCB_VIA::GetWidth() 必须带层参数,不带会断言中止进程。
老版本又只接受无参形式,所以两种都试。size 只是给人看的,取不到就算了。"""
for call in (lambda: v.GetWidth(v.TopLayer()), lambda: v.GetWidth()):
try:
return mm(call())
except Exception:
continue
return 0.0
vias, tracks = [], []
for t in board.GetTracks():
if isinstance(t, pcbnew.PCB_VIA):
vias.append({
"x": mm(t.GetPosition().x), "y": mm(t.GetPosition().y),
"drill": mm(t.GetDrill()), "size": via_size(t),
"top": board.GetLayerName(t.TopLayer()),
"bot": board.GetLayerName(t.BottomLayer()),
"net": t.GetNetname(),
})
else:
tracks.append({
"x1": mm(t.GetStart().x), "y1": mm(t.GetStart().y),
"x2": mm(t.GetEnd().x), "y2": mm(t.GetEnd().y),
"width": mm(t.GetWidth()),
"layer": board.GetLayerName(t.GetLayer()),
"net": t.GetNetname(),
})
pads = []
for fp in board.GetFootprints():
ref = fp.GetReference()
for p in fp.Pads():
pads.append({
"x": mm(p.GetPosition().x), "y": mm(p.GetPosition().y),
"layer": board.GetLayerName(p.GetLayer()),
"net": p.GetNetname(), "ref": ref, "pad": p.GetNumber(),
"drill": mm(p.GetDrillSizeX()),
})
layers = sorted({t["layer"] for t in tracks})
for name, data in (("vias", vias), ("tracks", tracks), ("pads", pads)):
(out / f"{name}.json").write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
(out / "meta.json").write_text(json.dumps({
"units": "mm", "y_axis": "down (KiCad native)",
"counts": {"vias": len(vias), "tracks": len(tracks), "pads": len(pads)},
"copper_layers_with_tracks": layers,
}, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"[dump] vias={len(vias)} tracks={len(tracks)} pads={len(pads)} -> {out}")
print(f"[layers] 有走线的铜层:{', '.join(layers)}")
print("[next] Altium 侧用 eda-agent 导同名 JSON 到 altium_vec/(格式见 "
"references\\vector-compare.md),然后跑 cmp_vec.py。")
return 0
if __name__ == "__main__":
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
sys.exit(main())
+183
View File
@@ -0,0 +1,183 @@
"""fix_pcb.py — 导入后 PCB 修复(阶段 1.5 的两个硬骨头)。必须用 KiCad 自带 python 跑。
A) BGA / 平面焊盘丢网络回填
Altium 用负片内电层隐式连接电源地,KiCad 导入时这些焊盘拿不到网络,
表现为成百上千个 "GND vs 空网络" 假短路。用阶段 0 导出的 Altium 网表真值
(component,pin,net) 把空网络焊盘重新指派回去。
B) 覆铜 zone 的本地 clearance
Altium 导入器把【每块 zone 的本地 Clearance 写成 KiCad 默认 0.5mm】
((zone ... (connect_pads (clearance 0.5)))),而本地值会盖过网络类 clearance
—— 这就是导入后异 net 避让间隔明显偏大的根因。只改 .kicad_pro 的网络类
或板级规则没有用,必须逐块 zone 改。目标值取 Altium 的 InPolygon 规则
(XMOD/GQ 两个工程实测都是 0.254mm,但别照抄,去基线 pcb_design_rules.json 里查)。
热焊盘 thermal_gap 通常已经对上 Altium PolygonConnect AirGap(0.165mm),不要动。
本脚本改完不 refill。大板上 SWIG refill 容易超时甚至段错误,去 KiCad GUI 里按 B
填充,或走 IPC 后端。
用法(KICAD_PYTHON 见 ~\\skills_env.md):
# 先干跑看看要动多少
D:\\KiCad\\10.0\\bin\\python.exe fix_pcb.py --board X.kicad_pcb --netlist-csv t.csv \\
--zone-clearance-mm 0.254 --dry-run
# 确认后真改(会先备份 .kicad_pcb 到 <board>.bak_fixpcb)
D:\\KiCad\\10.0\\bin\\python.exe fix_pcb.py --board X.kicad_pcb --netlist-csv t.csv \\
--zone-clearance-mm 0.254
"""
from __future__ import annotations
import argparse
import csv
import json
import shutil
import sys
from pathlib import Path
COMP_KEYS = ("component", "designator", "ref", "reference")
PIN_KEYS = ("pin", "pin_number", "pad", "pin designator")
NET_KEYS = ("net", "netname", "net name")
def pick(fieldnames, candidates) -> str | None:
lowered = {(f or "").strip().lower(): f for f in fieldnames}
for c in candidates:
if c in lowered:
return lowered[c]
return None
def load_truth(csv_path: Path) -> dict[tuple[str, str], str]:
"""Altium 网表真值 (component, pin) -> net。
Altium 的 tabular 导出会把隐藏的电源/平面引脚重复列成 net='?' 的行,
这些行没有信息量,直接丢掉。
"""
with csv_path.open(newline="", encoding="utf-8-sig") as f:
reader = csv.DictReader(f)
c_key = pick(reader.fieldnames or [], COMP_KEYS)
p_key = pick(reader.fieldnames or [], PIN_KEYS)
n_key = pick(reader.fieldnames or [], NET_KEYS)
if not (c_key and p_key and n_key):
raise SystemExit(
f"[ERROR] 认不出 CSV 的列名:{reader.fieldnames}\n"
f" 需要 component / pin / net 三列(大小写和别名可容忍)。")
truth: dict[tuple[str, str], str] = {}
dropped = 0
for row in reader:
comp = (row.get(c_key) or "").strip()
pin = (row.get(p_key) or "").strip()
net = (row.get(n_key) or "").strip()
if not (comp and pin):
continue
if not net or net == "?":
dropped += 1
continue
truth[(comp, pin)] = net
print(f"[csv] 真值引脚 {len(truth)} 条(丢弃 net='?'/空 的重复行 {dropped} 条)")
return truth
def main() -> int:
ap = argparse.ArgumentParser(description="Post-import KiCad PCB repair (nets + zone clearance)")
ap.add_argument("--board", required=True, help=".kicad_pcb 路径")
ap.add_argument("--netlist-csv", required=True,
help="Altium 侧网表真值 CSV(阶段0 proj_export_netlist tabular)")
ap.add_argument("--zone-clearance-mm", type=float, required=True,
help="Altium InPolygon 规则的 polygon clearance,去基线 pcb_design_rules.json 查")
ap.add_argument("--dry-run", action="store_true", help="只统计不落盘")
ap.add_argument("--report", help="把统计写成 JSON")
ap.add_argument("--skip-nets", action="store_true", help="只改 zone clearance,不动焊盘网络")
ap.add_argument("--skip-zones", action="store_true", help="只回填焊盘网络,不动 zone clearance")
args = ap.parse_args()
try:
import pcbnew
except ImportError:
print("[ERROR] 导不到 pcbnew —— 本脚本必须用 KiCad 自带的 python 跑,"
"例如 D:\\KiCad\\10.0\\bin\\python.exe(见 ~\\skills_env.md 的 KICAD_PYTHON)。")
return 1
board_path = Path(args.board)
if not board_path.is_file():
print(f"[ERROR] 板文件不存在:{board_path}")
return 1
truth = load_truth(Path(args.netlist_csv)) if not args.skip_nets else {}
board = pcbnew.LoadBoard(str(board_path))
stats = {"assigned": 0, "no_truth": 0, "no_ref": 0, "already": 0,
"zones_fixed": 0, "zones_skipped": 0}
missing_examples: list[str] = []
# ---- A) 焊盘网络回填 ----
if not args.skip_nets:
def get_net(name):
ni = board.FindNet(name)
if ni is None:
ni = pcbnew.NETINFO_ITEM(board, name)
board.Add(ni)
return ni
for fp in board.GetFootprints():
ref = fp.GetReference()
for pad in fp.Pads():
if pad.GetNetCode() != 0 and pad.GetNetname() != "":
stats["already"] += 1
continue
if not ref:
stats["no_ref"] += 1
continue
net = truth.get((ref, pad.GetNumber()))
if not net:
stats["no_truth"] += 1
if len(missing_examples) < 20:
missing_examples.append(f"{ref}.{pad.GetNumber()}")
continue
if not args.dry_run:
pad.SetNet(get_net(net))
stats["assigned"] += 1
print(f"[nets] 待回填={stats['assigned']} 真值里查不到={stats['no_truth']} "
f"无位号={stats['no_ref']} 本来就有网络={stats['already']}")
if missing_examples:
print(f"[nets] 查不到真值的空网络焊盘举例:{', '.join(missing_examples)}")
print(" 多为机械/屏蔽/安装孔焊盘。确认属实的话,按需单独指派 GND。")
# ---- B) zone 本地 clearance ----
if not args.skip_zones:
clr = pcbnew.FromMM(args.zone_clearance_mm)
for z in board.Zones():
if z.GetIsRuleArea() or not z.IsOnCopperLayer():
stats["zones_skipped"] += 1
continue
if not args.dry_run:
z.SetLocalClearance(clr)
stats["zones_fixed"] += 1
print(f"[zones] 铺铜 zone 本地 clearance -> {args.zone_clearance_mm}mm "
f"共 {stats['zones_fixed']} 块;跳过 {stats['zones_skipped']} 块(规则区/非铜层)")
if args.dry_run:
print("[dry-run] 没有写盘。去掉 --dry-run 才会真正修改。")
else:
backup = board_path.with_suffix(board_path.suffix + ".bak_fixpcb")
shutil.copy2(board_path, backup)
board.Save(str(board_path))
print(f"[save] 已写回 {board_path}(原文件备份为 {backup.name})")
print("[next] 还要重填铺铜:KiCad GUI 里按 B,或走 IPC 后端。"
"大板不要用 SWIG refill,会超时/段错误。")
if args.report:
Path(args.report).write_text(
json.dumps({**stats, "zone_clearance_mm": args.zone_clearance_mm,
"dry_run": args.dry_run,
"no_truth_examples": missing_examples},
ensure_ascii=False, indent=2), encoding="utf-8")
print(f"[report] {args.report}")
return 0
if __name__ == "__main__":
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
sys.exit(main())
+223
View File
@@ -0,0 +1,223 @@
"""netcmp.py — 原理图网表比对(阶段 2)。判定「电气连接是否与 Altium 完全一致」。
比对方法(两次迁移验证过,别改成比网络名):
两个工具的网络命名规则不同,逐名比对必然一片红。真正要比的是 **net partition**:
把每份网表归约成「每个引脚 -> 它所在网络的全部节点集合」,两侧的等价类集合相等
即电气连接一致。
三个必须做的归一化,少一个就会出假差异:
1. KiCad 网名带前导 '/'(层次路径),Altium 不带 -> 剥掉前导 '/'。
2. Altium tabular 导出把隐藏的电源/平面引脚重复列成 net='?' 的行 -> 去掉。
3. KiCad 扁平导入后,整体导出网表只能拿到根图的一小部分 -> 必须**逐页**
`kicad-cli sch export netlist --format kicadxml` 再按网名合并。
(格式必须是 kicadxml;PADS 格式会漏掉单节点网。)
用法:
python netcmp.py --kicad-dir E:\\Kicad_design\\FOO \\
--netlist-csv E:\\Kicad_design\\FOO\\_migration\\reference\\netlist_tabular.csv \\
--out E:\\Kicad_design\\FOO\\_migration\\netcmp
退出码 0 = 100% 一致;1 = 有差异(详情看 diff.json / 终端前 30 条)。
"""
from __future__ import annotations
import argparse
import collections
import csv
import glob
import json
import os
import re
import subprocess
import sys
import xml.etree.ElementTree as ET
from pathlib import Path
COMP_KEYS = ("component", "designator", "ref", "reference")
PIN_KEYS = ("pin", "pin_number", "pad", "pin designator")
NET_KEYS = ("net", "netname", "net name")
def pick(fieldnames, candidates):
lowered = {(f or "").strip().lower(): f for f in fieldnames}
for c in candidates:
if c in lowered:
return lowered[c]
return None
def norm_net(name: str | None) -> str | None:
"""剥掉 KiCad 层次网名的前导 '/',让两侧命名可比。"""
if name is None:
return None
return name.strip().lstrip("/")
def _env_lookup(key: str, default: str | None = None) -> str:
"""环境变量优先,其次 ~/skills_env.md 的 `KEY = value` 行(机器相关参数的真源)。"""
v = os.environ.get(key)
if v:
return v
path = os.path.join(os.path.expanduser("~"), "skills_env.md")
try:
with open(path, encoding="utf-8") as f:
for line in f:
m = re.match(rf"\s*{key}\s*=\s*(\S+)", line)
if m:
return m.group(1)
except OSError:
pass
if default is None:
sys.exit(f"missing {key}: add it to ~/skills_env.md or set it as an env var")
return default
def find_kicad_cli(explicit: str | None) -> str:
if explicit:
return explicit
# 安装路径是机器相关的:真源是 ~/skills_env.md 的 KICAD_ROOT。
kicad_root = _env_lookup("KICAD_ROOT", "")
for cand in (os.environ.get("KICAD_CLI"),
os.path.join(kicad_root, "bin", "kicad-cli.exe") if kicad_root else None,
"kicad-cli"):
if not cand:
continue
if cand == "kicad-cli" or Path(cand).is_file():
return cand
return "kicad-cli"
def export_pages(kicad_dir: Path, out_dir: Path, cli: str) -> list[Path]:
"""逐页导出 kicadxml。整体导出在扁平结构下会漏页,所以必须一页一页来。"""
sheets = sorted(kicad_dir.glob("*.kicad_sch"))
if not sheets:
raise SystemExit(f"[ERROR] {kicad_dir} 下没有 .kicad_sch")
out_dir.mkdir(parents=True, exist_ok=True)
made = []
for sch in sheets:
dst = out_dir / f"xml_{sch.stem}.xml"
cmd = [cli, "sch", "export", "netlist", "--format", "kicadxml",
"-o", str(dst), str(sch)]
# 不能用 text=True:那会按系统区域编码(中文 Windows 是 GBK)解码,
# 而 kicad-cli 输出 UTF-8,遇到非 ASCII 直接 UnicodeDecodeError。
proc = subprocess.run(cmd, capture_output=True,
encoding="utf-8", errors="replace")
if proc.returncode != 0 or not dst.is_file():
print(f"[WARN] 导出失败 {sch.name}: {proc.stderr.strip()[:200]}")
continue
made.append(dst)
print(f"[export] {len(made)}/{len(sheets)} 页导出成功 -> {out_dir}")
return made
def load_altium(csv_path: Path) -> dict[str, str]:
with csv_path.open(newline="", encoding="utf-8-sig") as f:
reader = csv.DictReader(f)
c_key = pick(reader.fieldnames or [], COMP_KEYS)
p_key = pick(reader.fieldnames or [], PIN_KEYS)
n_key = pick(reader.fieldnames or [], NET_KEYS)
if not (c_key and p_key and n_key):
raise SystemExit(f"[ERROR] 认不出 CSV 列名:{reader.fieldnames}(需要 component/pin/net)")
real = collections.defaultdict(set)
rows = 0
for row in reader:
rows += 1
comp = (row.get(c_key) or "").strip()
pin = (row.get(p_key) or "").strip()
net = (row.get(n_key) or "").strip()
if comp and pin and net and net != "?":
real[(comp, pin)].add(norm_net(net))
print(f"[altium] 原始 {rows} 行 -> 去 '?' 重复后 {len(real)} 个唯一节点")
return {f"{c}.{p}": sorted(v)[0] for (c, p), v in real.items()}
def load_kicad(xml_paths: list[Path]) -> dict[str, str]:
nn = collections.defaultdict(set)
for fn in xml_paths:
root = ET.parse(fn).getroot()
for net in root.iter("net"):
nm = norm_net(net.get("name"))
for nd in net.findall("node"):
nn[f"{nd.get('ref')}.{nd.get('pin')}"].add(nm)
print(f"[kicad] 合并 {len(xml_paths)} 页 -> {len(nn)} 个节点")
# 同一节点在多页上出现在同名网络里是正常的;名字冲突时保留全部以便暴露问题
return {nd: (sorted(names)[0] if len(names) == 1 else "|".join(sorted(names)))
for nd, names in nn.items()}
def partition(node_net: dict[str, str]):
groups = collections.defaultdict(set)
for node, net in node_net.items():
groups[net].add(node)
return {node: frozenset(groups[net]) for node, net in node_net.items()}, groups
def main() -> int:
ap = argparse.ArgumentParser(description="Altium vs KiCad schematic netlist partition compare")
ap.add_argument("--kicad-dir", required=True, help="KiCad 工程目录(含 *.kicad_sch)")
ap.add_argument("--netlist-csv", required=True, help="Altium tabular 网表真值 CSV")
ap.add_argument("--out", required=True, help="输出目录,放 xml_*.xml / diff.json / 报告")
ap.add_argument("--kicad-cli", help="kicad-cli 路径,默认自动找")
ap.add_argument("--skip-export", action="store_true", help="复用 --out 下已有的 xml_*.xml")
args = ap.parse_args()
out_dir = Path(args.out)
out_dir.mkdir(parents=True, exist_ok=True)
if args.skip_export:
xmls = [Path(p) for p in sorted(glob.glob(str(out_dir / "xml_*.xml")))]
if not xmls:
raise SystemExit(f"[ERROR] --skip-export 但 {out_dir} 下没有 xml_*.xml")
print(f"[export] 跳过,复用已有 {len(xmls)} 个 xml")
else:
xmls = export_pages(Path(args.kicad_dir), out_dir, find_kicad_cli(args.kicad_cli))
node_net_a = load_altium(Path(args.netlist_csv))
node_net_k = load_kicad(xmls)
pa, ga = partition(node_net_a)
pk, gk = partition(node_net_k)
na, nk = set(node_net_a), set(node_net_k)
only_a, only_k = sorted(na - nk), sorted(nk - na)
mismatch = sorted(nd for nd in (na & nk) if pa[nd] != pk[nd])
classes_a = {frozenset(v) for v in ga.values()}
classes_k = {frozenset(v) for v in gk.values()}
identical = (classes_a == classes_k) and not only_a and not only_k
print(f"\n节点 Altium={len(na)} KiCad={len(nk)} 只在A={len(only_a)} 只在K={len(only_k)}")
print(f"网络 Altium={len(ga)} KiCad={len(gk)} 等价类集合相等={classes_a == classes_k}")
print(f"同伴集不同的节点:{len(mismatch)}")
for nd in only_a[:15]:
print(f" 只在Altium {nd}: net={node_net_a[nd]}")
for nd in only_k[:15]:
print(f" 只在KiCad {nd}: net={node_net_k[nd]}")
for nd in mismatch[:30]:
am, km = pa[nd] - {nd}, pk[nd] - {nd}
print(f" {nd}: A={node_net_a[nd]} K={node_net_k[nd]} | "
f"只A的同伴:{sorted(am - km)[:5]} 只K的同伴:{sorted(km - am)[:5]}")
diff_path = out_dir / "diff.json"
diff_path.write_text(json.dumps({
"identical": identical,
"altium_nodes": len(na), "kicad_nodes": len(nk),
"altium_nets": len(ga), "kicad_nets": len(gk),
"onlyA_nodes": only_a, "onlyK_nodes": only_k,
"mismatch_nodes": mismatch,
"onlyA_classes": len(classes_a - classes_k),
"onlyK_classes": len(classes_k - classes_a),
}, ensure_ascii=False, indent=1), encoding="utf-8")
print(f"\n[diff] {diff_path}")
print(f"identical: {identical}")
if not identical:
print("差异不一定是真错:先确认 KiCad 网名前导 '/' 已剥、Altium 的 '?' 行已去,"
"再看是不是某页 kicadxml 没导出成功(上面 [export] 的比例)。")
return 0 if identical else 1
if __name__ == "__main__":
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
sys.exit(main())
+208
View File
@@ -0,0 +1,208 @@
"""stage_ascii.py — convert_ascii 这一步的机械部分(阶段 1.2)。
做三件确定性的事,剩下的「在 Altium GUI 里跑脚本」只能人来点:
stage 把整个 Altium 工程复制到 <工程目录>_ascii staging,并按实际枚举到的
.SchDoc 生成 convert_to_ascii.pas(DelphiScript 的 FindFirst 在
Altium 里枚举不可靠,所以文件名必须在生成期硬编码进脚本)。
verify 检查 staging 里每个 .SchDoc 的文件头,判断是否真的转成了 ASCII。
ASCII 版以 "|HEADER=Protel for Windows" 开头;AD26 二进制是 CFB
复合文档,以 D0 CF 11 E0 开头 —— 后者会让 KiCad 导入器报
"Header already parsed" 而失败。
原始工程绝不改动:所有操作只作用于 staging 副本。
用法:
python stage_ascii.py stage --source-prj E:\\Altium_design\\FOO\\FOO.PrjPCB \\
--migration-dir E:\\Kicad_design\\FOO\\_migration
python stage_ascii.py verify --staging-dir E:\\Altium_design\\FOO_ascii
"""
from __future__ import annotations
import argparse
import shutil
import sys
from pathlib import Path
ASCII_MAGIC = b"|HEADER=Protel for Windows"
CFB_MAGIC = b"\xd0\xcf\x11\xe0"
PAS_TEMPLATE = """{{ convert_to_ascii.pas — 由 stage_ascii.py 生成,请勿手改文件列表。
把 staging 副本里的 {count} 个 .SchDoc 全部另存为 ASCII 格式,绕开 KiCad 导入器
对 AD26 二进制原理图的 "Header already parsed" 失败。
运行方式(必须在 Altium GUI 内手动跑,不要经 eda-agent/MCP 驱动——
MCP 的 StartMCPServer 与本脚本会互相冲突挂起):
先停掉 eda-agent -> Altium: File > Run Script > 选本文件 > ConvertSchToAscii > Run
只作用于 staging:{dir}
原始工程不受影响。
}}
Procedure ConvertSchToAscii;
Var
Names : TStringList;
LogList : TStringList;
Dir : String;
Path : String;
SD : IServerDocument;
i : Integer;
Found : Integer;
Failed : Integer;
Begin
Dir := '{dir}\\';
Names := TStringList.Create;
{names}
LogList := TStringList.Create;
Found := 0;
Failed := 0;
For i := 0 To Names.Count - 1 Do
Begin
Path := Dir + Names[i];
Try
SD := Client.OpenDocument('SCH', Path);
If SD <> Nil Then
Begin
Client.ShowDocument(SD);
SD.SetModified(True);
SD.DoFileSave('ASCII');
Client.CloseDocument(SD);
LogList.Add('OK ' + Names[i]);
Inc(Found);
End
Else
Begin
LogList.Add('FAIL(open) ' + Names[i]);
Inc(Failed);
End;
Except
LogList.Add('FAIL(exc) ' + Names[i]);
Inc(Failed);
End;
End;
LogList.Insert(0, 'ASCII conversion: ' + IntToStr(Found) + ' OK, ' + IntToStr(Failed) + ' failed');
LogList.SaveToFile('{log}');
Names.Free;
ShowMessage('ASCII done: ' + IntToStr(Found) + ' OK, ' + IntToStr(Failed) + ' failed');
LogList.Free;
End;
"""
def sch_docs(folder: Path) -> list[Path]:
"""staging 里的原理图页,按名字排序(含子目录,Altium 工程偶尔分目录放)。"""
return sorted(folder.rglob("*.SchDoc"), key=lambda p: str(p).lower())
def cmd_stage(args) -> int:
source = Path(args.source_prj)
if not source.is_file():
print(f"[ERROR] 源工程不存在:{source}")
return 1
src_dir = source.parent
staging = Path(args.staging_dir) if args.staging_dir else src_dir.parent / (src_dir.name + "_ascii")
migration = Path(args.migration_dir)
migration.mkdir(parents=True, exist_ok=True)
if staging.exists():
if not args.force:
print(f"[ERROR] staging 已存在:{staging}\n"
f" 加 --force 覆盖重建(会先删除该目录),或用 --staging-dir 换个位置。")
return 1
print(f"[INFO] --force:删除已有 staging {staging}")
shutil.rmtree(staging)
print(f"[COPY] {src_dir} -> {staging}")
shutil.copytree(src_dir, staging)
docs = sch_docs(staging)
if not docs:
print(f"[ERROR] staging 里没找到任何 .SchDoc:{staging}")
return 1
names = "".join(
f" Names.Add('{d.relative_to(staging).as_posix().replace('/', chr(92))}');\n"
for d in docs
)
log_path = migration / "ascii_convert_log.txt"
# DelphiScript 的字符串字面量不做反斜杠转义,路径按原样写进去
pas = PAS_TEMPLATE.format(count=len(docs), dir=str(staging), names=names,
log=str(log_path))
pas_path = migration / "convert_to_ascii.pas"
pas_path.write_text(pas, encoding="utf-8")
print(f"[SCHDOC] 枚举到 {len(docs)} 张原理图页:")
for d in docs:
print(f" {d.relative_to(staging)}")
print(f"[SCRIPT] {pas_path}")
print(f"[LOG] 脚本跑完会写到 {log_path}")
print(f"[STAGING] {staging}")
print("\n下一步(必须人工在 Altium GUI 内做,不要走 MCP):"
"\n 1. 停掉 eda-agent(避免与 StartMCPServer 脚本冲突挂起)"
f"\n 2. Altium: File > Run Script > 选 {pas_path} > ConvertSchToAscii > Run"
"\n 3. 回来跑 `python stage_ascii.py verify --staging-dir \"%s\"` 确认" % staging)
return 0
def cmd_verify(args) -> int:
staging = Path(args.staging_dir)
if not staging.is_dir():
print(f"[ERROR] staging 目录不存在:{staging}")
return 1
docs = sch_docs(staging)
if not docs:
print(f"[ERROR] staging 里没有 .SchDoc:{staging}")
return 1
ascii_n, binary_n, unknown = 0, 0, []
for d in docs:
head = d.open("rb").read(64)
if head.startswith(ASCII_MAGIC):
ascii_n += 1
elif head.startswith(CFB_MAGIC):
binary_n += 1
unknown.append((d, "binary CFB — 未转换"))
else:
unknown.append((d, f"未知文件头 {head[:12]!r}"))
print(f"[VERIFY] {staging}")
print(f" ASCII: {ascii_n} / {len(docs)}")
for d, why in unknown:
print(f" [!] {d.relative_to(staging)} {why}")
if ascii_n == len(docs):
print("[OK] 全部原理图已是 ASCII,可以进 KiCad 导入了。")
return 0
print("[FAIL] 还有页没转成 ASCII —— 直接导入会报 'Header already parsed'。"
"\n 回 Altium 重跑 convert_to_ascii.pas(注意 eda-agent 要停掉)。")
return 1
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
sub = ap.add_subparsers(dest="cmd", required=True)
p = sub.add_parser("stage", help="复制到 _ascii staging 并生成 convert_to_ascii.pas")
p.add_argument("--source-prj", required=True)
p.add_argument("--migration-dir", required=True)
p.add_argument("--staging-dir", help="默认 <工程目录>_ascii,与原工程同级")
p.add_argument("--force", action="store_true", help="staging 已存在时先删除重建")
p.set_defaults(func=cmd_stage)
p = sub.add_parser("verify", help="检查 staging 里 .SchDoc 是否都成了 ASCII")
p.add_argument("--staging-dir", required=True)
p.set_defaults(func=cmd_verify)
args = ap.parse_args()
return args.func(args)
if __name__ == "__main__":
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
sys.exit(main())
+27 -5
View File
@@ -121,13 +121,35 @@ def main() -> int:
os.close(fd)
last_message_path = Path(last_message_name)
try:
# read-only sandbox: review needs no writes, and on Windows the
# config.toml "elevated" sandbox hangs every shell command when codex
# runs outside the ChatGPT app (probed 2026-08-26). Use -c because
# "codex exec resume" does not accept the --sandbox flag.
# Sandbox mode: default "read-only" -- review needs no writes, and on
# Windows the config.toml "elevated" sandbox hangs every shell command
# when codex runs outside the ChatGPT app (probed 2026-08-26).
# Pass it with -c, NOT -s: `codex exec` accepts -s but `codex exec
# resume` does not, and the second and later review passes go through
# resume -- an -s here fails with a bare
# For more information, try '--help'
# two seconds in, on exactly the passes that matter.
# (unquoted value: fails TOML parse -> codex falls back to the raw
# string, which survives cmd /c quoting untouched)
flags = ["--json", "--skip-git-repo-check", "-c", "sandbox_mode=read-only",
#
# Overridable via CODEX_SANDBOX because codex's Windows sandbox cannot
# start on every machine. Observed with codex-cli 0.147.0:
#
# execution error: Io(Custom { kind: Other, error: "windows sandbox:
# orchestrator_helper_launch_canceled: ShellExecuteExW failed to
# launch setup helper: 1223" })
#
# 1223 is ERROR_CANCELLED -- the helper's UAC elevation prompt was
# declined. The sandbox then never comes up, every shell command the
# reviewer issues fails, and codex answers "Unable to read file."
#
# THAT FAILURE IS INDISTINGUISHABLE FROM A CLEAN REVIEW unless someone
# reads the discuss file: the workflow still completes, still writes a
# discuss file, and the text inside is a polite sentence rather than an
# error. Same class as the known "instant NO_SERIOUS_ISSUE" trap.
# On such a machine set CODEX_SANDBOX=danger-full-access.
sandbox = os.environ.get("CODEX_SANDBOX", "").strip() or "read-only"
flags = ["--json", "--skip-git-repo-check", "-c", "sandbox_mode=" + sandbox,
"--output-last-message", str(last_message_path)]
if codex_session_id:
command_args = ["cmd", "/c", codex, "exec", "resume"] + flags + [codex_session_id, "-"]