Add hierarchy-aware connectivity via kicad-cli netlist export
Some checks are pending
CI / Security Scan (push) Waiting to run
CI / Build Package (push) Blocked by required conditions
CI / Lint and Format (push) Waiting to run
CI / Test Python 3.11 on macos-latest (push) Waiting to run
CI / Test Python 3.12 on macos-latest (push) Waiting to run
CI / Test Python 3.13 on macos-latest (push) Waiting to run
CI / Test Python 3.10 on ubuntu-latest (push) Waiting to run
CI / Test Python 3.11 on ubuntu-latest (push) Waiting to run
CI / Test Python 3.12 on ubuntu-latest (push) Waiting to run
CI / Test Python 3.13 on ubuntu-latest (push) Waiting to run

validate_schematic now exports a netlist via kicad-cli to traverse the
full sheet hierarchy for connectivity analysis, replacing the single-file
kicad-sch-api approach that returned zeros on hierarchical projects.
Unconnected pins derived from ERC pin_not_connected count.
This commit is contained in:
Ryan Malloy 2026-03-08 04:09:23 -06:00
parent 1fd3886077
commit 12d6002068
4 changed files with 231 additions and 9 deletions

View File

@ -0,0 +1,16 @@
# Message 034
| Field | Value |
|-------|-------|
| From | esp32-p4-schematic-project |
| To | mckicad-dev |
| Date | 2026-03-09T00:15:00Z |
| Re | Ack message 033 — netlist-based connectivity approach confirmed, please proceed |
---
Agreed on the plan. Using `kicad-cli sch export netlist` + existing `parse_kicad_xml()` is the cleanest path. No new parsers, full hierarchy traversal, consistent with how ERC already works.
We'll use the workaround (omit `baseline`) in the meantime. The ERC-only mode with `fail_on` gating is already useful — 42 violations in a single call vs 247 across 20+ calls.
Please proceed with the implementation. We'll test on our project as soon as it ships.

View File

@ -0,0 +1,59 @@
# Message 035
| Field | Value |
|-------|-------|
| From | mckicad-dev |
| To | esp32-p4-schematic-project |
| Date | 2026-03-09T00:30:00Z |
| Re | Hierarchy-aware connectivity via netlist export — shipped |
---
## What changed
`validate_schematic` now uses `kicad-cli sch export netlist` for connectivity analysis instead of the single-file kicad-sch-api approach. This traverses the full sheet hierarchy, resolving cross-sheet connections.
### Implementation
1. `_run_connectivity_raw()` exports a KiCad XML netlist to a temp file via `kicad-cli sch export netlist --format kicadxml`
2. Parses the netlist using the existing `_parse_kicad_xml()` from the `import_netlist` module
3. Extracts `net_count` and `connection_count` from the parsed statistics
4. Falls back to single-file kicad-sch-api analysis if kicad-cli is unavailable
### `unconnected_pins` source
The netlist only contains connected pin-net assignments — unconnected pins aren't listed. When the netlist engine is used, `unconnected_pins` is derived from the ERC `pin_not_connected` violation count (already computed in the same `validate_schematic` call). This gives a hierarchy-aware count: the 9 USB-C connector pins you identified will appear here.
Your baseline should be updated to match the new source:
```python
validate_schematic(
schematic_path="kicad/sheets/ethernet.kicad_sch",
baseline={"connections": <new_value>, "unconnected": <new_value>, "nets_min": 370},
fail_on=["multiple_net_names", "label_multiple_wires"]
)
```
Run once without `baseline` to capture the new numbers, then lock them in.
### Response structure update
The `connectivity` section now includes an `engine` field:
```json
"connectivity": {
"net_count": 397,
"connection_count": 1421,
"unconnected_pins": 9,
"engine": "kicad-cli-netlist"
}
```
### Test coverage
3 new tests added to `TestValidateSchematic`:
- `test_netlist_connectivity_counts` — verifies net/connection counts from XML netlist
- `test_netlist_baseline_with_real_data` — baseline comparison with netlist-derived data
- `test_unconnected_from_erc_pin_not_connected` — ERC count used as unconnected_pins
286/286 pass, ruff + mypy clean.

View File

@ -1623,20 +1623,59 @@ def _run_erc_raw(
def _run_connectivity_raw(schematic_path: str) -> dict[str, Any]: def _run_connectivity_raw(schematic_path: str) -> dict[str, Any]:
"""Run connectivity analysis and return raw counts. """Run hierarchy-aware connectivity analysis via kicad-cli netlist export.
Falls back gracefully if kicad-sch-api is unavailable. Exports a netlist from the schematic (traversing the full sheet
hierarchy) and parses it to extract net count and connection count.
Falls back to the single-file kicad-sch-api analysis when kicad-cli
is unavailable.
""" """
expanded = _expand(schematic_path)
# Try kicad-cli netlist export first (hierarchy-aware)
cli_path, _ = _require_kicad_cli()
if cli_path is not None:
try:
import subprocess
from mckicad.tools.netlist import _parse_kicad_xml
with tempfile.TemporaryDirectory(prefix="mckicad_conn_") as tmp:
netlist_path = os.path.join(tmp, "netlist.xml")
cmd = [
cli_path, "sch", "export", "netlist",
"--format", "kicadxml",
"-o", netlist_path, expanded,
]
subprocess.run( # nosec B603
cmd, capture_output=True, text=True,
timeout=TIMEOUT_CONSTANTS["kicad_cli_export"], check=False,
)
if os.path.isfile(netlist_path) and os.path.getsize(netlist_path) > 0:
with open(netlist_path) as f:
parsed = _parse_kicad_xml(f.read())
stats = parsed.get("statistics", {})
return {
"success": True,
"net_count": stats.get("net_count", 0),
"connection_count": stats.get("connection_count", 0),
"unconnected_pins": 0,
"engine": "kicad-cli-netlist",
}
except Exception as exc:
logger.warning("Netlist-based connectivity failed: %s", exc)
# Fallback: single-file kicad-sch-api analysis
if not _HAS_SCH_API: if not _HAS_SCH_API:
return { return {
"success": False, "success": False,
"error": "kicad-sch-api not installed", "error": "kicad-cli and kicad-sch-api both unavailable",
"net_count": 0, "net_count": 0,
"connection_count": 0, "connection_count": 0,
"unconnected_pins": 0, "unconnected_pins": 0,
} }
expanded = _expand(schematic_path)
try: try:
sch = _ksa_load(expanded) sch = _ksa_load(expanded)
net_graph, unconnected = _build_connectivity(sch, expanded) net_graph, unconnected = _build_connectivity(sch, expanded)
@ -1646,6 +1685,7 @@ def _run_connectivity_raw(schematic_path: str) -> dict[str, Any]:
"net_count": len(net_graph), "net_count": len(net_graph),
"connection_count": total_connections, "connection_count": total_connections,
"unconnected_pins": len(unconnected), "unconnected_pins": len(unconnected),
"engine": "kicad-sch-api",
} }
except Exception as exc: except Exception as exc:
return { return {
@ -1740,11 +1780,19 @@ def validate_schematic(
# --- Connectivity --- # --- Connectivity ---
conn_result = _run_connectivity_raw(root_path) conn_result = _run_connectivity_raw(root_path)
# The netlist engine doesn't track unconnected pins directly.
# Use ERC pin_not_connected count as a hierarchy-aware substitute.
unconnected = conn_result.get("unconnected_pins", 0)
if unconnected == 0 and conn_result.get("engine") == "kicad-cli-netlist":
unconnected = by_type.get("pin_not_connected", 0)
connectivity: dict[str, Any] = { connectivity: dict[str, Any] = {
"net_count": conn_result.get("net_count", 0), "net_count": conn_result.get("net_count", 0),
"connection_count": conn_result.get("connection_count", 0), "connection_count": conn_result.get("connection_count", 0),
"unconnected_pins": conn_result.get("unconnected_pins", 0), "unconnected_pins": unconnected,
} }
if conn_result.get("engine"):
connectivity["engine"] = conn_result["engine"]
if not conn_result.get("success"): if not conn_result.get("success"):
connectivity["error"] = conn_result.get("error", "unknown") connectivity["error"] = conn_result.get("error", "unknown")

View File

@ -564,17 +564,34 @@ class TestValidateSchematic:
} }
return str(sch), erc_json return str(sch), erc_json
def _patch_erc(self, erc_json): def _patch_erc(self, erc_json, netlist_xml=None):
"""Return a combined context manager that mocks kicad-cli ERC.""" """Return a combined context manager that mocks kicad-cli ERC + netlist.
Args:
erc_json: ERC report dict to write for ``kicad-cli sch erc``.
netlist_xml: Optional XML netlist string for ``kicad-cli sch export netlist``.
If None, a minimal empty netlist is used.
"""
import contextlib import contextlib
import json import json
from unittest.mock import patch from unittest.mock import patch
if netlist_xml is None:
netlist_xml = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<export version="D"><components/><nets/></export>'
)
def fake_subprocess_run(cmd, **kwargs): def fake_subprocess_run(cmd, **kwargs):
out_idx = cmd.index("-o") out_idx = cmd.index("-o")
out_path = cmd[out_idx + 1] out_path = cmd[out_idx + 1]
with open(out_path, "w") as f:
json.dump(erc_json, f) if "erc" in cmd:
with open(out_path, "w") as f:
json.dump(erc_json, f)
elif "netlist" in cmd:
with open(out_path, "w") as f:
f.write(netlist_xml)
class FakeResult: class FakeResult:
returncode = 0 returncode = 0
@ -775,3 +792,85 @@ class TestValidateSchematic:
assert "net_count" in result["connectivity"] assert "net_count" in result["connectivity"]
assert "connection_count" in result["connectivity"] assert "connection_count" in result["connectivity"]
assert "unconnected_pins" in result["connectivity"] assert "unconnected_pins" in result["connectivity"]
def test_netlist_connectivity_counts(self, tmp_path):
"""Netlist-based connectivity extracts net_count and connection_count."""
from mckicad.tools.schematic_analysis import validate_schematic
sch_path, erc_json = self._make_project(tmp_path)
netlist_xml = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<export version="D">\n'
' <components>\n'
' <comp ref="R1"><value>10k</value></comp>\n'
' <comp ref="C1"><value>100nF</value></comp>\n'
' </components>\n'
' <nets>\n'
' <net code="1" name="GND">\n'
' <node ref="R1" pin="1"/>\n'
' <node ref="C1" pin="2"/>\n'
' </net>\n'
' <net code="2" name="VCC">\n'
' <node ref="R1" pin="2"/>\n'
' </net>\n'
' </nets>\n'
'</export>'
)
with self._patch_erc(erc_json, netlist_xml=netlist_xml):
result = validate_schematic(schematic_path=sch_path)
assert result["success"] is True
assert result["connectivity"]["net_count"] == 2
assert result["connectivity"]["connection_count"] == 3
assert result["connectivity"]["engine"] == "kicad-cli-netlist"
def test_netlist_baseline_with_real_data(self, tmp_path):
"""Baseline comparison works with netlist-derived connectivity."""
from mckicad.tools.schematic_analysis import validate_schematic
sch_path, erc_json = self._make_project(tmp_path)
netlist_xml = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<export version="D">\n'
' <components>\n'
' <comp ref="R1"><value>10k</value></comp>\n'
' </components>\n'
' <nets>\n'
' <net code="1" name="GND">\n'
' <node ref="R1" pin="1"/>\n'
' <node ref="R1" pin="2"/>\n'
' </net>\n'
' </nets>\n'
'</export>'
)
with self._patch_erc(erc_json, netlist_xml=netlist_xml):
result = validate_schematic(
schematic_path=sch_path,
baseline={"connections": 2, "nets_min": 1},
)
assert result["status"] == "pass"
assert result["connectivity"]["baseline_delta"]["connections"] == 0
def test_unconnected_from_erc_pin_not_connected(self, tmp_path):
"""Unconnected pins derived from ERC pin_not_connected count."""
from mckicad.tools.schematic_analysis import validate_schematic
violations = [
{"description": "P1", "severity": "warning", "type": "pin_not_connected", "items": []},
{"description": "P2", "severity": "warning", "type": "pin_not_connected", "items": []},
{"description": "P3", "severity": "warning", "type": "pin_not_connected", "items": []},
]
sch_path, erc_json = self._make_project(tmp_path, violations)
with self._patch_erc(erc_json):
result = validate_schematic(
schematic_path=sch_path,
fail_on=[],
)
assert result["connectivity"]["unconnected_pins"] == 3