Add tiered .asc parser with companion netlist resolution

Implement three-tier resolution for LTspice .asc schematic files:

1. Companion netlist - finds .net/.cir/.sp beside the .asc (automatic)
2. LTspice generation - invokes LTspice binary (opt-in via --generate-netlist)
3. Metadata-only fallback - extracts component refs/values without connectivity

Safety: DataCompleteness enum forces callers to check completeness.
CLI blocks diagram generation on METADATA_ONLY with clear remediation.
Metadata enrichment is additive-only with protected field guards.

Also: update project URLs to Gitea, add .asc usage docs to README,
fix pre-existing ruff warning in test_single_module.py.
This commit is contained in:
Ryan Malloy 2026-02-13 04:59:03 -07:00
parent ad03798b4d
commit 08c92bfefb
9 changed files with 833 additions and 61 deletions

29
tests/fixtures/simple_board.asc vendored Normal file
View file

@ -0,0 +1,29 @@
Version 4
SHEET 1 880 680
SYMBOL res 192 160 R0
SYMATTR InstName R1
SYMATTR Value 10k
SYMBOL res 192 288 R0
SYMATTR InstName R2
SYMATTR Value 10k
SYMBOL Opamps\\opamp 336 256 R0
SYMATTR InstName U1
SYMATTR Value opamp
SYMBOL conn 48 144 R0
SYMATTR InstName J1
SYMATTR Value PWR_CONN
SYMBOL conn 48 288 R0
SYMATTR InstName J2
SYMATTR Value SIG_CONN
SYMBOL TestPoint 288 128 R0
SYMATTR InstName TP1
WIRE 96 176 192 176
WIRE 96 320 192 320
WIRE 192 288 192 256
WIRE 192 160 192 128
WIRE 288 128 192 128
WIRE 336 224 192 224
WIRE 336 288 336 320
WIRE 448 256 400 256
TEXT 48 432 Left 2 !.subckt amplifier_board VIN GND VOUT SIGNAL_IN
TEXT 48 464 Left 2 !.ends amplifier_board

23
tests/fixtures/standalone.asc vendored Normal file
View file

@ -0,0 +1,23 @@
Version 4
SHEET 1 880 680
SYMBOL res 192 160 R0
SYMATTR InstName R1
SYMATTR Value 4.7k
SYMBOL cap 320 160 R0
SYMATTR InstName C1
SYMATTR Value 100n
SYMBOL conn 48 144 R0
SYMATTR InstName J1
SYMATTR Value DB9_CONN
SYMBOL conn 48 288 R0
SYMATTR InstName J2
SYMATTR Value BARREL_JACK
SYMBOL TestPoint 480 128 R0
SYMATTR InstName TP1
SYMBOL voltage 576 240 R0
SYMATTR InstName V1
SYMATTR Value 12
WIRE 96 176 192 176
WIRE 96 320 192 320
WIRE 320 160 320 128
WIRE 480 128 320 128

281
tests/test_asc.py Normal file
View file

@ -0,0 +1,281 @@
"""Tests for the tiered .asc file parser."""
from pathlib import Path
from unittest.mock import patch
import pytest
from spice2wireviz.parser.asc import (
AscParseResult,
DataCompleteness,
_AscMetadata,
_build_metadata_only_netlist,
_enrich_netlist_with_metadata,
_try_companion_netlist,
parse_asc,
)
from spice2wireviz.parser.models import ParsedNetlist, SpiceComponent
FIXTURES = Path(__file__).parent / "fixtures"
class TestCompanionNetlistDetection:
"""Tier 1: find companion .net beside the .asc and get FULL connectivity."""
def test_finds_companion_net(self):
"""simple_board.asc has simple_board.net in the same directory."""
result = parse_asc(FIXTURES / "simple_board.asc")
assert result.completeness == DataCompleteness.FULL
assert result.source_net is not None
assert result.source_net.name == "simple_board.net"
def test_full_connectivity_from_companion(self):
"""The companion .net provides real subcircuit defs and connectivity."""
result = parse_asc(FIXTURES / "simple_board.asc")
netlist = result.netlist
assert "amplifier_board" in netlist.subcircuit_defs
subckt = netlist.subcircuit_defs["amplifier_board"]
assert len(subckt.port_names) == 4
assert len(subckt.boundary_components) >= 2 # J1, J2, TP1
def test_companion_priority_net_over_cir(self, tmp_path):
"""When both .net and .cir exist, .net wins (first in priority)."""
asc = tmp_path / "test.asc"
asc.write_text("Version 4\nSHEET 1 880 680\n")
net = tmp_path / "test.net"
net.write_text(
"* Test\n"
".subckt mymod A B\n"
"J1 A B CONN\n"
".ends mymod\n"
)
cir = tmp_path / "test.cir"
cir.write_text("* Different file\n.end\n")
result = parse_asc(asc)
assert result.source_net == net
def test_companion_cir_fallback(self, tmp_path):
"""When only .cir exists (no .net), it's used as companion."""
asc = tmp_path / "test.asc"
asc.write_text("Version 4\nSHEET 1 880 680\n")
cir = tmp_path / "test.cir"
cir.write_text(
"* Test\n"
".subckt mymod A\n"
"J1 A CONN\n"
".ends mymod\n"
)
result = parse_asc(asc)
assert result.completeness == DataCompleteness.FULL
assert result.source_net == cir
class TestMetadataEnrichment:
"""Verify .asc values/attrs merge into .net parse (additive only)."""
def test_enrichment_adds_attributes(self):
"""Metadata from .asc should add attributes to the netlist."""
netlist = ParsedNetlist(
top_level_components=[
SpiceComponent(reference="J1", prefix="J", value="", nodes=["VIN", "GND"]),
],
)
metadata = _AscMetadata(
components=[
{"ref": "J1", "prefix": "J", "value": "PWR_CONN", "SpiceModel": "DB9"},
]
)
_enrich_netlist_with_metadata(netlist, metadata)
j1 = netlist.top_level_components[0]
assert j1.value == "PWR_CONN" # filled empty value
assert j1.attributes["SpiceModel"] == "DB9" # new attribute added
def test_enrichment_never_overwrites_value(self):
"""If the .net parse already has a value, .asc should not overwrite it."""
netlist = ParsedNetlist(
top_level_components=[
SpiceComponent(reference="J1", prefix="J", value="EXISTING", nodes=["A"]),
],
)
metadata = _AscMetadata(
components=[{"ref": "J1", "prefix": "J", "value": "DIFFERENT"}]
)
_enrich_netlist_with_metadata(netlist, metadata)
assert netlist.top_level_components[0].value == "EXISTING"
def test_enrichment_never_overwrites_existing_attrs(self):
"""Existing attributes from .net parse must not be overwritten."""
netlist = ParsedNetlist(
top_level_components=[
SpiceComponent(
reference="J1", prefix="J", nodes=["A"],
attributes={"Footprint": "from-net"},
),
],
)
metadata = _AscMetadata(
components=[
{"ref": "J1", "prefix": "J", "value": "", "Footprint": "from-asc", "NewAttr": "v"},
]
)
_enrich_netlist_with_metadata(netlist, metadata)
j1 = netlist.top_level_components[0]
assert j1.attributes["Footprint"] == "from-net" # not overwritten
assert j1.attributes["NewAttr"] == "v" # new one added
def test_enrichment_handles_unmatched_refs(self):
"""Components in .asc that aren't in .net are silently skipped."""
netlist = ParsedNetlist(
top_level_components=[
SpiceComponent(reference="J1", prefix="J", nodes=["A"]),
],
)
metadata = _AscMetadata(
components=[
{"ref": "J99", "prefix": "J", "value": "PHANTOM"},
]
)
_enrich_netlist_with_metadata(netlist, metadata)
assert netlist.top_level_components[0].value == "" # unchanged
class TestMetadataOnlyParsing:
"""Tier 3: .asc with no companion .net produces METADATA_ONLY."""
def test_standalone_is_metadata_only(self):
"""standalone.asc has no companion .net — should be METADATA_ONLY."""
result = parse_asc(FIXTURES / "standalone.asc")
assert result.completeness == DataCompleteness.METADATA_ONLY
assert result.source_net is None
def test_metadata_only_has_warnings(self):
"""METADATA_ONLY results must include a warning about missing connectivity."""
result = parse_asc(FIXTURES / "standalone.asc")
warning_texts = " ".join(result.warnings)
assert "connectivity" in warning_texts.lower() or "companion" in warning_texts.lower()
def test_metadata_only_netlist_has_no_connectivity(self):
"""A metadata-only netlist should have empty connectivity fields."""
metadata = _AscMetadata(
components=[
{"ref": "J1", "prefix": "J", "value": "DB9"},
{"ref": "X1", "prefix": "X", "value": "OpAmpBoard"},
]
)
warnings: list[str] = []
netlist = _build_metadata_only_netlist(metadata, warnings)
assert len(netlist.top_level_components) == 1
assert netlist.top_level_components[0].reference == "J1"
assert netlist.top_level_components[0].nodes == []
assert netlist.top_level_components[0].pins == []
assert len(netlist.instances) == 1
assert netlist.instances[0].port_to_net == {}
def test_metadata_only_preserves_attributes(self):
"""Extra attributes from .asc should appear in the metadata-only netlist."""
metadata = _AscMetadata(
components=[
{"ref": "J1", "prefix": "J", "value": "DB9", "Footprint": "DSUB9"},
]
)
warnings: list[str] = []
netlist = _build_metadata_only_netlist(metadata, warnings)
j1 = netlist.top_level_components[0]
assert j1.attributes["Footprint"] == "DSUB9"
class TestLTspiceGeneration:
"""Tier 2: LTspice-generated netlist (mocked — no real LTspice in CI)."""
def test_ltspice_generation_opt_in(self, tmp_path):
"""LTspice generation is skipped unless allow_ltspice_generation=True."""
asc = tmp_path / "test.asc"
asc.write_text("Version 4\nSHEET 1 880 680\n")
# Without opt-in, should fall through to Tier 3
result = parse_asc(asc)
assert result.completeness == DataCompleteness.METADATA_ONLY
@patch("spice2wireviz.parser.asc.Simulator", create=True)
def test_ltspice_generation_success(self, mock_sim_cls, tmp_path):
"""Mocked LTspice successfully generates a .net file."""
asc = tmp_path / "test.asc"
asc.write_text("Version 4\nSHEET 1 880 680\n")
net = tmp_path / "test.net"
net.write_text(
"* Generated\n"
".subckt gen_mod A B\n"
"J1 A B CONN\n"
".ends gen_mod\n"
)
# Mock the import path inside _try_ltspice_generation
with patch(
"spice2wireviz.parser.asc._try_ltspice_generation"
) as mock_tier2:
mock_tier2.return_value = AscParseResult(
netlist=ParsedNetlist(),
completeness=DataCompleteness.FULL,
source_net=net,
)
result = parse_asc(asc, allow_ltspice_generation=True)
assert result.completeness == DataCompleteness.FULL
def test_ltspice_generation_fallback_on_failure(self, tmp_path):
"""If LTspice invocation fails, falls through to Tier 3."""
asc = tmp_path / "test.asc"
asc.write_text("Version 4\nSHEET 1 880 680\n")
# No LTspice binary available, so Tier 2 returns None
result = parse_asc(asc, allow_ltspice_generation=True)
assert result.completeness == DataCompleteness.METADATA_ONLY
class TestErrorHandling:
def test_nonexistent_file(self):
with pytest.raises(FileNotFoundError, match="ASC file not found"):
parse_asc("/nonexistent/path/board.asc")
def test_wrong_extension(self, tmp_path):
net = tmp_path / "board.net"
net.write_text("* netlist\n")
with pytest.raises(ValueError, match=r"Expected \.asc file"):
parse_asc(net)
def test_spicelib_not_required_for_companion(self):
"""Tier 1 works even without spicelib installed."""
# simple_board.asc has a companion .net — spicelib is only needed
# for enrichment, which gracefully degrades
result = parse_asc(FIXTURES / "simple_board.asc")
assert result.completeness == DataCompleteness.FULL
class TestCompanionNetlistInternalHelper:
"""Direct tests for _try_companion_netlist."""
def test_returns_none_when_no_companion(self, tmp_path):
asc = tmp_path / "isolated.asc"
asc.write_text("Version 4\nSHEET 1 880 680\n")
assert _try_companion_netlist(asc) is None
def test_returns_result_with_source_net(self, tmp_path):
asc = tmp_path / "board.asc"
asc.write_text("Version 4\n")
net = tmp_path / "board.net"
net.write_text("* test\n.subckt s A\nJ1 A C\n.ends s\n")
result = _try_companion_netlist(asc)
assert result is not None
assert result.completeness == DataCompleteness.FULL
assert result.source_net == net

View file

@ -128,3 +128,75 @@ class TestCLIFiltering:
)
assert result.exit_code == 0
assert "X1:" not in result.output
class TestCLIAscInput:
"""Tests for .asc file input through the CLI."""
def test_asc_with_companion_works(self):
"""simple_board.asc has companion .net — full pipeline should work."""
runner = CliRunner()
result = runner.invoke(
main,
[str(FIXTURES / "simple_board.asc"), "-s", "amplifier_board"],
)
assert result.exit_code == 0
assert "connectors:" in result.output
assert "amplifier_board" in result.output
def test_asc_companion_resolution_in_stderr(self):
"""CLI should report which .net was resolved to stderr."""
runner = CliRunner()
result = runner.invoke(
main,
[str(FIXTURES / "simple_board.asc"), "-s", "amplifier_board"],
)
assert result.exit_code == 0
assert "simple_board.net" in result.stderr
def test_asc_without_companion_errors(self):
"""standalone.asc has no companion .net — diagram generation should fail."""
runner = CliRunner()
result = runner.invoke(main, [str(FIXTURES / "standalone.asc")])
assert result.exit_code != 0
assert "connectivity" in result.output.lower() or "connectivity" in (
getattr(result, "stderr", "") or ""
).lower()
def test_asc_without_companion_shows_remediation(self):
"""Error message should tell the user how to fix it."""
runner = CliRunner()
result = runner.invoke(main, [str(FIXTURES / "standalone.asc")])
assert result.exit_code != 0
stderr = result.stderr
assert "--generate-netlist" in stderr or "--list-components" in stderr
def test_asc_list_components_on_metadata_only(self):
"""--list-components should work even without connectivity."""
runner = CliRunner()
result = runner.invoke(
main,
[str(FIXTURES / "standalone.asc"), "--list-components"],
)
# Should succeed (exit 0) — inspection doesn't need connectivity
assert result.exit_code == 0
def test_asc_list_subcircuits_with_companion(self):
"""--list-subcircuits through .asc with companion works."""
runner = CliRunner()
result = runner.invoke(
main,
[str(FIXTURES / "simple_board.asc"), "--list-subcircuits"],
)
assert result.exit_code == 0
assert "amplifier_board" in result.output
def test_asc_dry_run_with_companion(self):
"""--dry-run through .asc with companion produces summary."""
runner = CliRunner()
result = runner.invoke(
main,
[str(FIXTURES / "simple_board.asc"), "-s", "amplifier_board", "--dry-run"],
)
assert result.exit_code == 0
assert "Mode: single" in result.output

View file

@ -186,7 +186,7 @@ class TestSingleModuleLayoutOptimization:
[{"H": [1, 3]}, {"W1": [1, 2]}, {"CA": [1, 2]}],
[{"H": [2, 4]}, {"W2": [1, 2]}, {"CB": [1, 2]}],
]
result_c, _, result_cn = _optimize_single_layout(
result_c, _, _result_cn = _optimize_single_layout(
"H", connectors, {}, connections
)
# Header should be regrouped: [A1, A2, B1, B2]