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
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:
parent
1fd3886077
commit
12d6002068
@ -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.
|
||||
@ -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.
|
||||
@ -1623,20 +1623,59 @@ def _run_erc_raw(
|
||||
|
||||
|
||||
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:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "kicad-sch-api not installed",
|
||||
"error": "kicad-cli and kicad-sch-api both unavailable",
|
||||
"net_count": 0,
|
||||
"connection_count": 0,
|
||||
"unconnected_pins": 0,
|
||||
}
|
||||
|
||||
expanded = _expand(schematic_path)
|
||||
try:
|
||||
sch = _ksa_load(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),
|
||||
"connection_count": total_connections,
|
||||
"unconnected_pins": len(unconnected),
|
||||
"engine": "kicad-sch-api",
|
||||
}
|
||||
except Exception as exc:
|
||||
return {
|
||||
@ -1740,11 +1780,19 @@ def validate_schematic(
|
||||
# --- Connectivity ---
|
||||
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] = {
|
||||
"net_count": conn_result.get("net_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"):
|
||||
connectivity["error"] = conn_result.get("error", "unknown")
|
||||
|
||||
|
||||
@ -564,17 +564,34 @@ class TestValidateSchematic:
|
||||
}
|
||||
return str(sch), erc_json
|
||||
|
||||
def _patch_erc(self, erc_json):
|
||||
"""Return a combined context manager that mocks kicad-cli ERC."""
|
||||
def _patch_erc(self, erc_json, netlist_xml=None):
|
||||
"""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 json
|
||||
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):
|
||||
out_idx = cmd.index("-o")
|
||||
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:
|
||||
returncode = 0
|
||||
@ -775,3 +792,85 @@ class TestValidateSchematic:
|
||||
assert "net_count" in result["connectivity"]
|
||||
assert "connection_count" 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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user