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
+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())