diff --git a/altium-kicad/SKILL.md b/altium-kicad/SKILL.md new file mode 100644 index 0000000..3d364dd --- /dev/null +++ b/altium-kicad/SKILL.md @@ -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 落盘基线到 `\_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` — 验收报告 + +写 `\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` diff --git a/altium-kicad/references/playbook.md b/altium-kicad/references/playbook.md new file mode 100644 index 0000000..003f56f --- /dev/null +++ b/altium-kicad/references/playbook.md @@ -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. 证据目录约定 + +全部落在 `\_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 条设计规则。 diff --git a/altium-kicad/references/vector-compare.md b/altium-kicad/references/vector-compare.md new file mode 100644 index 0000000..7299b24 --- /dev/null +++ b/altium-kicad/references/vector-compare.md @@ -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` 配错了。 diff --git a/altium-kicad/scripts/ak_flow.py b/altium-kicad/scripts/ak_flow.py new file mode 100644 index 0000000..76972ba --- /dev/null +++ b/altium-kicad/scripts/ak_flow.py @@ -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 或 --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()) diff --git a/altium-kicad/scripts/cmp_vec.py b/altium-kicad/scripts/cmp_vec.py new file mode 100644 index 0000000..ea84356 --- /dev/null +++ b/altium-kicad/scripts/cmp_vec.py @@ -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()) diff --git a/altium-kicad/scripts/dump_kicad_vec.py b/altium-kicad/scripts/dump_kicad_vec.py new file mode 100644 index 0000000..b8e3d5b --- /dev/null +++ b/altium-kicad/scripts/dump_kicad_vec.py @@ -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()) diff --git a/altium-kicad/scripts/fix_pcb.py b/altium-kicad/scripts/fix_pcb.py new file mode 100644 index 0000000..5288305 --- /dev/null +++ b/altium-kicad/scripts/fix_pcb.py @@ -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 到 .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()) diff --git a/altium-kicad/scripts/netcmp.py b/altium-kicad/scripts/netcmp.py new file mode 100644 index 0000000..084027e --- /dev/null +++ b/altium-kicad/scripts/netcmp.py @@ -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()) diff --git a/altium-kicad/scripts/stage_ascii.py b/altium-kicad/scripts/stage_ascii.py new file mode 100644 index 0000000..5ea2722 --- /dev/null +++ b/altium-kicad/scripts/stage_ascii.py @@ -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()) diff --git a/langgraph-plan-review/scripts/review_task.py b/langgraph-plan-review/scripts/review_task.py index 7dec965..52ee7fa 100644 --- a/langgraph-plan-review/scripts/review_task.py +++ b/langgraph-plan-review/scripts/review_task.py @@ -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, "-"]