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:
parent
ad03798b4d
commit
08c92bfefb
9 changed files with 833 additions and 61 deletions
29
tests/fixtures/simple_board.asc
vendored
Normal file
29
tests/fixtures/simple_board.asc
vendored
Normal 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
23
tests/fixtures/standalone.asc
vendored
Normal 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
281
tests/test_asc.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue