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]:
|
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")
|
||||||
|
|
||||||
|
|||||||
@ -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]
|
||||||
|
|
||||||
|
if "erc" in cmd:
|
||||||
with open(out_path, "w") as f:
|
with open(out_path, "w") as f:
|
||||||
json.dump(erc_json, 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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user