tools/epro2: add ProjectRelations for cross-document resolution
per-doc Relations 在大量 cross-doc 引用前是不够的:PCB 的 PAD_NET 复合
id [PAD_NET, comp, pin, pad] 里的 pad 实际是 FOOTPRINT 文档里的 pad
实例;SCH_PAGE 的 COMPONENT.partId 指向某个 SYMBOL 文档的 PART.id。
ProjectRelations 在 per-doc Relations 之上做项目级聚合,把这些跨文档
引用拼起来。
Probe 阶段(ESP-VoCat)发现的映射规则(已写入 docstring):
1. SCH_PAGE COMPONENT.partId === PART.id in some SYMBOL doc
- 命名两种风格:'pid<hex>' (anonymous/系统 part) + '<name>.<n>' (具
名 SKU),但都直接相等 PART.id,**不**是不同 namespace
- 同一 PART.id 可能出现在多个 SYMBOL 文档里(库快照),
parts_by_id 保留全部,consumer 通常取第一个
2. PCB COMPONENT.id → FOOTPRINT 文档 UUID via 单独 ATTR op:
ATTR(parentId=<comp>, key="Footprint", value=<fp_doc_uuid>)
COMPONENT.attrs 子 dict 只有内务字段(Unique ID / Channel ID / ...),
**不**含 footprint 引用。这跟 schematic 的 partId 在 COMPONENT 上的
做法不一样,是 EPRO2 流的一处不对称
3. PCB PAD_NET[comp,pin,pad] 里的 pad 是 FOOTPRINT 文档内部的 pad id;
解析链: comp → ATTR Footprint → FOOTPRINT relations.pads[pad]
API:
ProjectRelations.build(project) — 单遍构建
resolve_symbol_docs(sch_uuid, comp_id) → [SYMBOL doc uuids]
resolve_footprint_doc(pcb_uuid, comp_id) → FOOTPRINT doc uuid | None
pad_in_footprint(fp_uuid, pad_id) → PAD payload | None
resolve_pcb_pad_net(pcb_uuid, comp, pin, pad) → {footprint, pad} | None
attrs_for_pcb_component(pcb_uuid, comp_id) → {key: value} 折叠
CLI 加 --project-relations,跑 ESP-VoCat:
documents 278
distinct_parts 87
duplicated_parts 9
pcb_components_with_footprint 206
pcb_components_unresolved_footprint 0
sch_components_with_partid 572
sch_components_unresolved_part 0
PCB 样本验证:comp=e0 → fp=1069352d81c6 Designator='U8',
PAD_NET pin=1 pad=e7 net=GND 跨文档解到坐标 (-37.4,-45.24)。
测试:6 个新单测覆盖 partId→symbol、comp→footprint、PAD_NET 跨文档、
attrs 折叠、unresolved 计数。parser + relations + project_relations
共 21/21 通过。
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
129
tools/epro2/tests/test_project_relations.py
Normal file
129
tools/epro2/tests/test_project_relations.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""ProjectRelations regression tests with synthetic micro-projects.
|
||||
|
||||
Covers the cross-document resolution rules empirically derived from ESP-VoCat:
|
||||
- SCH COMPONENT.partId → SYMBOL doc(s) with matching PART.id
|
||||
- PCB COMPONENT → FOOTPRINT doc via ATTR(parent=comp, key=Footprint, value=fp_uuid)
|
||||
- PCB PAD_NET[comp,pin,pad] → pad payload inside FOOTPRINT (cross-doc)
|
||||
"""
|
||||
|
||||
from tools.epro2.project_relations import ProjectRelations
|
||||
from tools.epro2.replay import Document, Project
|
||||
|
||||
|
||||
def _doc(uuid, doc_type, objs):
|
||||
d = Document(doc_uuid=uuid, doc_type=doc_type)
|
||||
for k, v in objs:
|
||||
d.objects[k] = v
|
||||
return d
|
||||
|
||||
|
||||
def _project(*docs):
|
||||
p = Project(project_uuid="testproj")
|
||||
for d in docs:
|
||||
p.documents[d.doc_uuid] = d
|
||||
return p
|
||||
|
||||
|
||||
def test_partid_to_symbol_resolution():
|
||||
sym1 = _doc("sym_uuid_1", "SYMBOL", [
|
||||
("MyPart.1", {"_type": "PART", "BBOX": [0, 0, 10, 10], "title": "MyPart.1"}),
|
||||
])
|
||||
sym2 = _doc("sym_uuid_2", "SYMBOL", [
|
||||
("MyPart.1", {"_type": "PART", "BBOX": [0, 0, 10, 10], "title": "MyPart.1"}), # dup
|
||||
])
|
||||
sym3 = _doc("sym_uuid_3", "SYMBOL", [
|
||||
("OtherPart.1", {"_type": "PART", "title": "OtherPart.1"}),
|
||||
])
|
||||
sch = _doc("sch_uuid_1", "SCH_PAGE", [
|
||||
("e1", {"_type": "COMPONENT", "partId": "MyPart.1", "x": 0, "y": 0}),
|
||||
("e2", {"_type": "COMPONENT", "partId": "OtherPart.1", "x": 5, "y": 5}),
|
||||
])
|
||||
pr = ProjectRelations.build(_project(sym1, sym2, sym3, sch))
|
||||
|
||||
syms = pr.resolve_symbol_docs("sch_uuid_1", "e1")
|
||||
assert sorted(syms) == ["sym_uuid_1", "sym_uuid_2"]
|
||||
assert pr.resolve_symbol_docs("sch_uuid_1", "e2") == ["sym_uuid_3"]
|
||||
s = pr.summary()
|
||||
assert s["distinct_parts"] == 2
|
||||
assert s["duplicated_parts"] == 1 # MyPart.1 lives in 2 syms
|
||||
assert s["sch_components_with_partid"] == 2
|
||||
assert s["sch_components_unresolved_part"] == 0
|
||||
|
||||
|
||||
def test_pcb_component_to_footprint():
|
||||
fp = _doc("fp_uuid_X", "FOOTPRINT", [
|
||||
("e7", {"_type": "PAD", "centerX": 1.0, "centerY": 2.0, "layerId": 1}),
|
||||
])
|
||||
pcb = _doc("pcb_uuid_1", "PCB", [
|
||||
("e0", {"_type": "COMPONENT", "x": 0, "y": 0, "attrs": {}}),
|
||||
("attr_fp", {"_type": "ATTR", "parentId": "e0", "key": "Footprint", "value": "fp_uuid_X"}),
|
||||
("attr_des", {"_type": "ATTR", "parentId": "e0", "key": "Designator", "value": "R1"}),
|
||||
('["PAD_NET","e0","1","e7"]', {"_type": "PAD_NET", "padNet": "GND"}),
|
||||
])
|
||||
pr = ProjectRelations.build(_project(fp, pcb))
|
||||
|
||||
assert pr.resolve_footprint_doc("pcb_uuid_1", "e0") == "fp_uuid_X"
|
||||
assert pr.attrs_for_pcb_component("pcb_uuid_1", "e0") == {
|
||||
"Footprint": "fp_uuid_X",
|
||||
"Designator": "R1",
|
||||
}
|
||||
|
||||
|
||||
def test_pad_net_cross_doc_resolution():
|
||||
fp = _doc("fp_uuid_X", "FOOTPRINT", [
|
||||
("e7", {"_type": "PAD", "centerX": 1.5, "centerY": -2.5, "num": "1"}),
|
||||
("e8", {"_type": "PAD", "centerX": 3.0, "centerY": 0.0, "num": "2"}),
|
||||
])
|
||||
pcb = _doc("pcb_uuid_1", "PCB", [
|
||||
("e0", {"_type": "COMPONENT", "x": 0, "y": 0}),
|
||||
("attr_fp", {"_type": "ATTR", "parentId": "e0", "key": "Footprint", "value": "fp_uuid_X"}),
|
||||
('["PAD_NET","e0","1","e7"]', {"_type": "PAD_NET", "padNet": "GND"}),
|
||||
('["PAD_NET","e0","2","e8"]', {"_type": "PAD_NET", "padNet": "VCC"}),
|
||||
])
|
||||
pr = ProjectRelations.build(_project(fp, pcb))
|
||||
|
||||
pad1 = pr.resolve_pcb_pad_net("pcb_uuid_1", "e0", "1", "e7")
|
||||
assert pad1 is not None
|
||||
assert pad1["footprint"] == "fp_uuid_X"
|
||||
assert pad1["pad"]["centerX"] == 1.5
|
||||
|
||||
pad2 = pr.resolve_pcb_pad_net("pcb_uuid_1", "e0", "2", "e8")
|
||||
assert pad2["pad"]["centerY"] == 0.0
|
||||
|
||||
# Unknown pad on known footprint → None
|
||||
assert pr.resolve_pcb_pad_net("pcb_uuid_1", "e0", "99", "e_ghost") is None
|
||||
|
||||
|
||||
def test_unresolved_counts_get_recorded():
|
||||
# PCB component without Footprint ATTR → should count unresolved
|
||||
pcb = _doc("pcb_uuid_1", "PCB", [
|
||||
("e0", {"_type": "COMPONENT", "x": 0, "y": 0}), # no ATTR Footprint
|
||||
])
|
||||
sch = _doc("sch_uuid_1", "SCH_PAGE", [
|
||||
("e1", {"_type": "COMPONENT", "partId": "GhostPart.1"}),
|
||||
])
|
||||
pr = ProjectRelations.build(_project(pcb, sch))
|
||||
s = pr.summary()
|
||||
assert s["pcb_components_unresolved_footprint"] == 1
|
||||
assert s["sch_components_unresolved_part"] == 1
|
||||
|
||||
|
||||
def test_attrs_for_pcb_component_collapses_multiple_attrs():
|
||||
pcb = _doc("pcb_uuid_1", "PCB", [
|
||||
("e0", {"_type": "COMPONENT", "x": 0, "y": 0}),
|
||||
("a1", {"_type": "ATTR", "parentId": "e0", "key": "Designator", "value": "R1"}),
|
||||
("a2", {"_type": "ATTR", "parentId": "e0", "key": "Value", "value": "10kΩ"}),
|
||||
("a3", {"_type": "ATTR", "parentId": "e0", "key": "Designator", "value": "R2"}), # later wins
|
||||
])
|
||||
pr = ProjectRelations.build(_project(pcb))
|
||||
flat = pr.attrs_for_pcb_component("pcb_uuid_1", "e0")
|
||||
assert flat == {"Designator": "R2", "Value": "10kΩ"}
|
||||
|
||||
|
||||
def test_summary_keys_present():
|
||||
pr = ProjectRelations.build(_project())
|
||||
s = pr.summary()
|
||||
for k in ("documents", "doc_types", "distinct_parts", "duplicated_parts",
|
||||
"pcb_components_with_footprint", "pcb_components_unresolved_footprint",
|
||||
"sch_components_with_partid", "sch_components_unresolved_part"):
|
||||
assert k in s
|
||||
Reference in New Issue
Block a user