Skip to content

Commit 2d80081

Browse files
committed
v0.4.1: AltiumSchematicParser DNP/MPN/pin-net + ParsedSchematicData converter
Closes #121. Production changes: - AltiumSchematicParser._parse_fileheader: PIN (#2), SHEET_SYMBOL (#15), SHEET_ENTRY (#16), FILE_NAME (#33), WIRE (#27), JUNCTION (#29) record-type branches. NET_LABEL (#25) now also captures coordinates. - Pin → net resolution: NetIdentifier wins, geometric fallback otherwise. - AltiumSheetSymbol dataclass + sheet_symbols / child_sheets fields on AltiumSchematicData. - altium_to_parsed_schematic() converter maps AltiumSchematicData → ParsedSchematicData with all DNP / MPN / Manufacturer / pins / child-sheet metadata preserved. - SchematicParserFactory.parse() now converts Altium results so downstream analyzers see the unified ParsedSchematicData shape. Refactor: - KiCadSchematicParser._resolve_pin_nets extracted to parsers/_pin_net_geometric.py:resolve_pins_by_geometry(); both parsers call the shared helper. Tests: - Rewrite tests/test_altium_parameter_records.py — 17 integration cases exercise the real _parse_fileheader via synthetic FileHeader byte streams. Drops the duplicated _apply_params mirror that previously shadowed production logic. All existing tests stay green (1616 passed, 11 skipped).
1 parent fb4f0fc commit 2d80081

7 files changed

Lines changed: 679 additions & 129 deletions

File tree

CHANGELOG.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,42 @@ All notable changes to **mcp-pcb-emcopilot** are documented here.
55
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.4.1] — 2026-05-27
9+
10+
### Added
11+
- **AltiumSchematicParser**: PIN (#2), SHEET_SYMBOL (#15), SHEET_ENTRY
12+
(#16), FILE_NAME (#33), WIRE (#27), JUNCTION (#29) record-type
13+
handling. NET_LABEL (#25) records now also capture coordinates for
14+
geometric pin-net resolution.
15+
- **Pin → net resolution** for `.SchDoc` input: explicit `NetIdentifier`
16+
parameter on a pin record wins; pins without one fall back to the
17+
shared geometric resolver (label-anchor snap, same algorithm as
18+
KiCad).
19+
- **Sheet-symbol hierarchy**`AltiumSchematicData.sheet_symbols` and
20+
`child_sheets` populated from SHEET_SYMBOL + FILE_NAME owner-index
21+
linkage. Hierarchy now surfaces in `ParsedSchematicData.sheet_count`
22+
and `properties['child_sheets']`.
23+
- **`altium_to_parsed_schematic()`** converter (in `altium_parser.py`):
24+
maps `AltiumSchematicData` to the canonical `ParsedSchematicData`
25+
shape downstream analyzers expect. `SchematicParserFactory.parse()`
26+
wires the converter in so `.SchDoc` input flows through the same
27+
analyzer surface as `.kicad_sch` — schematic-aware analyzers
28+
(3-way cross-reference, signal-flow, schematic-layout validator)
29+
stop running in degraded mode against Altium files.
30+
- **17 integration tests** in `test_altium_parameter_records.py` that
31+
exercise the real `_parse_fileheader` against synthetic FileHeader
32+
byte streams. The previous test scaffolding duplicated the
33+
parameter-mapping logic inside the test file; that helper is gone.
34+
35+
### Changed
36+
- `KiCadSchematicParser._resolve_pin_nets` extracted to
37+
`parsers/_pin_net_geometric.py:resolve_pins_by_geometry()` so both
38+
parsers share one implementation.
39+
40+
### Closes
41+
- [#121](https://github.com/RFingAdam/mcp-pcb-emcopilot/issues/121)
42+
Extend AltiumSchematicParser for DNP / MPN / pin-net mapping.
43+
844
## [0.4.0] — 2026-05-27
945

1046
### Added

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "mcp-pcb-emcopilot"
3-
version = "0.4.0"
3+
version = "0.4.1"
44
description = "MCP server for PCB design review, EMC analysis, and signal integrity"
55
readme = "README.md"
66
license = "AGPL-3.0-or-later"
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
"""Shared geometric pin → net resolver.
2+
3+
Both KiCad and Altium schematic parsers face the same problem: a pin
4+
sits at a coordinate, a wire runs through it, and a net label
5+
anchored at a wire endpoint names the net. Neither tool exports an
6+
explicit netlist in the schematic file itself, so we infer
7+
connectivity from geometry.
8+
9+
This module is the algorithm in one place so both parsers can call
10+
it without drifting.
11+
"""
12+
from __future__ import annotations
13+
14+
from typing import Any, Iterable, Optional
15+
16+
DEFAULT_SNAP_MM = 0.51
17+
18+
19+
def resolve_pins_by_geometry(
20+
components: Iterable[Any],
21+
wires: list[dict[str, Any]],
22+
labels: list[dict[str, Any]],
23+
junctions: list[dict[str, Any]],
24+
nets: Optional[dict[str, Any]] = None,
25+
snap_mm: float = DEFAULT_SNAP_MM,
26+
) -> None:
27+
"""Tag each component pin with the net of the nearest label.
28+
29+
Args:
30+
components: iterable of objects with a ``pins`` list of dicts
31+
(``ParsedComponent`` or ``AltiumComponent``). Each pin dict
32+
should expose ``x_abs`` / ``y_abs`` (absolute coords in mm);
33+
if absent, the component's own coordinate is used.
34+
wires: list of ``{x1, y1, x2, y2}`` dicts in mm. Currently
35+
unused in the snap-to-label heuristic but reserved so the
36+
signature is stable as we add wire-walk net merging later.
37+
labels: list of ``{name, x, y}`` dicts in mm.
38+
junctions: list of ``{x, y}`` dicts in mm. Currently unused —
39+
reserved for future cross-wire merging.
40+
nets: optional mapping of ``{net_name -> net_object}`` where
41+
``net_object.pins`` is a list to append discovered pin
42+
connections to. Pass ``None`` to skip the back-reference.
43+
snap_mm: maximum distance from a pin to a label anchor for
44+
the pin to be considered connected to that label's net.
45+
46+
Side effects:
47+
- Each pin dict gets ``pin['net'] = label_name`` when within
48+
tolerance.
49+
- When ``nets`` is provided, the matching net object gets a
50+
``{'component': ref, 'pin_number': n}`` entry appended to
51+
its ``pins`` list.
52+
"""
53+
if not wires or not labels:
54+
# Mark these as deliberately observed — the snap-to-label
55+
# heuristic needs both to work, and the caller should not
56+
# assume any pins were resolved if either is empty.
57+
_ = wires, junctions
58+
return
59+
60+
snap_sq = snap_mm ** 2
61+
62+
# Pre-compute label name → anchor coordinate. If multiple labels
63+
# share a name (common for power nets like GND), the first wins;
64+
# downstream geometry won't care because they share the same name.
65+
label_anchors: dict[str, tuple[float, float]] = {}
66+
for label in labels:
67+
label_anchors.setdefault(label["name"], (label["x"], label["y"]))
68+
69+
for comp in components:
70+
pins = getattr(comp, "pins", None)
71+
if not pins:
72+
continue
73+
ref = getattr(comp, "reference", "")
74+
comp_x = getattr(comp, "x_coord", None)
75+
if comp_x is None:
76+
comp_x = getattr(comp, "x_mm", 0.0)
77+
comp_y = getattr(comp, "y_coord", None)
78+
if comp_y is None:
79+
comp_y = getattr(comp, "y_mm", 0.0)
80+
81+
for pin in pins:
82+
# Skip pins that already have an explicit NetIdentifier
83+
# — the caller resolved them ahead of the geometric pass.
84+
if pin.get("net"):
85+
continue
86+
87+
px = float(pin.get("x_abs", comp_x))
88+
py = float(pin.get("y_abs", comp_y))
89+
best_net: Optional[str] = None
90+
best_d2 = snap_sq
91+
92+
for name, (lx, ly) in label_anchors.items():
93+
d2 = (px - lx) ** 2 + (py - ly) ** 2
94+
if d2 <= best_d2:
95+
best_d2 = d2
96+
best_net = name
97+
98+
if best_net is not None:
99+
pin["net"] = best_net
100+
if nets is not None:
101+
net = nets.get(best_net)
102+
if net is not None and hasattr(net, "pins"):
103+
net.pins.append({
104+
"component": ref,
105+
"pin_number": pin.get("pin_number", ""),
106+
})
107+
108+
# Junctions reserved for future cross-wire net merging.
109+
_ = junctions

0 commit comments

Comments
 (0)