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

View File

@ -4,12 +4,12 @@ Convert LTspice SPICE netlists to WireViz wiring diagrams.
## What it does
`spice2wireviz` reads SPICE netlist files (`.net`, `.cir`, `.sp`) and generates [WireViz](https://github.com/wireviz/WireViz) YAML that documents the physical wiring: connectors, test points, and inter-module cables.
`spice2wireviz` reads SPICE netlist files (`.net`, `.cir`, `.sp`) and LTspice schematics (`.asc`) and generates [WireViz](https://github.com/wireviz/WireViz) YAML that documents the physical wiring: connectors, test points, and inter-module cables.
Two operating modes:
- **Single module** External interface of one subcircuit (its connectors, test points, port interface)
- **Inter-module** How multiple subcircuits/boards connect to each other
- **Single module** -- External interface of one subcircuit (its connectors, test points, port interface)
- **Inter-module** -- How multiple subcircuits/boards connect to each other
## Install
@ -19,8 +19,16 @@ uv tool install spice2wireviz
pip install spice2wireviz
```
For `.asc` file metadata extraction (optional):
```bash
pip install spice2wireviz[asc]
```
## Usage
### From netlist files
```bash
# Inter-module wiring (auto-detected from top-level X instances)
spice2wireviz top_level.net -o wiring.yml --render
@ -37,6 +45,25 @@ spice2wireviz design.net --list-components
spice2wireviz design.net --dry-run
```
### From .asc schematics
LTspice `.asc` files are supported with tiered netlist resolution:
1. **Companion netlist** (automatic) -- If a `.net`, `.cir`, or `.sp` file exists alongside the `.asc` (same basename), it's used for full connectivity. LTspice generates these automatically when you run a simulation.
2. **LTspice generation** (opt-in) -- Pass `--generate-netlist` to invoke LTspice and produce a `.net` file.
3. **Metadata only** -- Without a netlist, only component refs/values are available. Diagram generation is blocked, but `--list-components` still works.
```bash
# .asc with companion .net in the same directory -- works like .net input
spice2wireviz schematic.asc -s amplifier_board -o amp.yml
# No companion .net -- invoke LTspice to generate one
spice2wireviz schematic.asc --generate-netlist -o wiring.yml
# Inspect component metadata (no .net required)
spice2wireviz schematic.asc --list-components
```
## Filtering
Cherry-pick what appears in the diagram:
@ -52,7 +79,11 @@ Cherry-pick what appears in the diagram:
## Development
```bash
uv sync --extra dev
uv sync --extra dev --extra asc
uv run pytest
uv run ruff check src/ tests/
```
## Repository
[git.supported.systems/warehack.ing/spice2wireviz](https://git.supported.systems/warehack.ing/spice2wireviz)

View File

@ -29,9 +29,9 @@ dependencies = [
]
[project.urls]
Homepage = "https://github.com/ryanmalloy/spice2wireviz"
Repository = "https://github.com/ryanmalloy/spice2wireviz"
Issues = "https://github.com/ryanmalloy/spice2wireviz/issues"
Homepage = "https://git.supported.systems/warehack.ing/spice2wireviz"
Repository = "https://git.supported.systems/warehack.ing/spice2wireviz"
Issues = "https://git.supported.systems/warehack.ing/spice2wireviz/issues"
[project.optional-dependencies]
asc = ["spicelib>=1.4.9"]

View File

@ -1,10 +1,9 @@
"""Click CLI for spice2wireviz.
Converts SPICE netlists to WireViz YAML wiring diagrams.
Converts SPICE netlists (.net/.cir/.sp) and LTspice schematics (.asc)
to WireViz YAML wiring diagrams.
"""
from __future__ import annotations
import sys
from pathlib import Path
@ -15,6 +14,7 @@ from .emitter.yaml_emitter import emit_yaml
from .filter import FilterConfig, apply_filters
from .mapper.inter_module import map_inter_module
from .mapper.single_module import map_single_module
from .parser.asc import DataCompleteness, parse_asc
from .parser.netlist import parse_netlist
@ -124,6 +124,11 @@ def _parse_comma_list(ctx: click.Context, param: click.Parameter, value: str | N
help="List all components matching filters and exit.",
)
@click.option("--dry-run", is_flag=True, help="Show mapping summary without generating YAML.")
@click.option(
"--generate-netlist",
is_flag=True,
help="For .asc files: invoke LTspice to generate a netlist if no companion .net exists.",
)
@click.version_option(version=__version__)
def main(
input_file: Path,
@ -146,16 +151,49 @@ def main(
list_subcircuits: bool,
list_components: bool,
dry_run: bool,
generate_netlist: bool,
) -> None:
"""Convert SPICE netlist to WireViz YAML wiring diagram."""
# Parse the netlist
# --- Parse input (format detection) ---
is_asc = input_file.suffix.lower() == ".asc"
completeness = DataCompleteness.FULL # default for .net files
try:
netlist = parse_netlist(input_file)
if is_asc:
asc_result = parse_asc(
input_file,
allow_ltspice_generation=generate_netlist,
)
netlist = asc_result.netlist
completeness = asc_result.completeness
if asc_result.source_net:
click.echo(
f"Using netlist: {asc_result.source_net}",
err=True,
)
for warning in asc_result.warnings:
click.echo(f"Warning: {warning}", err=True)
else:
netlist = parse_netlist(input_file)
except FileNotFoundError as exc:
click.echo(f"Error: {exc}", err=True)
click.echo(f"Error: file not found: {exc}", err=True)
sys.exit(1)
except PermissionError as exc:
click.echo(f"Error: permission denied: {exc}", err=True)
sys.exit(1)
except ValueError as exc:
click.echo(f"Error: invalid input: {exc}", err=True)
sys.exit(1)
except ImportError as exc:
click.echo(f"Error: missing dependency: {exc}", err=True)
sys.exit(1)
except OSError as exc:
click.echo(f"Error: I/O failure: {exc}", err=True)
sys.exit(1)
# --- Inspection commands ---
# --- Inspection commands (allowed even on METADATA_ONLY) ---
if list_subcircuits:
names = netlist.list_subcircuit_names()
if not names:
@ -196,6 +234,25 @@ def main(
click.echo(f" {inst.reference} ({inst.subcircuit_name}): {nets}")
return
# --- Safety gate: block diagram generation on METADATA_ONLY ---
if completeness == DataCompleteness.METADATA_ONLY:
click.echo(
"Error: Cannot generate wiring diagram — no connectivity data available.\n"
"\n"
"The .asc file was parsed but no companion netlist (.net/.cir/.sp) was found\n"
"in the same directory. Without connectivity data, wire routing is unknown.\n"
"\n"
"To fix this, either:\n"
" 1. Place the .net file alongside the .asc (LTspice generates this automatically)\n"
" 2. Use --generate-netlist to invoke LTspice (requires LTspice on PATH)\n"
"\n"
"For inspection without connectivity, use:\n"
" --list-components List component references and values\n"
" --list-subcircuits List subcircuit definitions",
err=True,
)
sys.exit(1)
# --- Auto-detect mode ---
if mode is None:
if subcircuit:

View File

@ -1,84 +1,363 @@
"""Optional .asc file parser using spicelib.
"""Tiered .asc file parser with companion netlist resolution.
LTspice .asc files are complex (coordinates, symbols, attributes) and
spicelib handles them well, but it pulls ~165MB of transitive deps.
This module is only imported when spicelib is available.
LTspice .asc schematic files contain component metadata (refs, values,
attributes) but NOT net connectivity. Connectivity lives in .net files.
Resolution tiers:
1. Companion .net file alongside the .asc (same basename, same directory)
2. LTspice-generated netlist via spicelib (opt-in, requires LTspice binary)
3. Metadata-only fallback from AscEditor (no connectivity)
Usage:
pip install spice2wireviz[asc]
"""
from __future__ import annotations
import sys
from dataclasses import dataclass, field
from enum import StrEnum
from pathlib import Path
from .models import ParsedNetlist, SpiceComponent, SubcircuitDef, SubcircuitInstance
from .netlist import BOUNDARY_PREFIXES, _extract_prefix
from .models import ParsedNetlist, SpiceComponent
from .netlist import BOUNDARY_PREFIXES, _extract_prefix, parse_netlist
# Extensions to check for companion netlists, in priority order
_NETLIST_EXTENSIONS = (".net", ".cir", ".sp")
def parse_asc(filepath: str | Path) -> ParsedNetlist:
"""Parse an LTspice .asc schematic file via spicelib.
class DataCompleteness(StrEnum):
"""Whether the parse result has full connectivity or just metadata."""
FULL = "full"
METADATA_ONLY = "metadata_only"
@dataclass
class AscParseResult:
"""Result of parsing an .asc file through the tiered resolution."""
netlist: ParsedNetlist
completeness: DataCompleteness
source_net: Path | None = None
warnings: list[str] = field(default_factory=list)
def parse_asc(
filepath: str | Path,
*,
allow_ltspice_generation: bool = False,
ltspice_timeout: float = 30.0,
) -> AscParseResult:
"""Parse an LTspice .asc schematic file with tiered netlist resolution.
Tier 1: Look for a companion .net/.cir/.sp beside the .asc.
Tier 2: Invoke LTspice to generate a netlist (opt-in).
Tier 3: Extract metadata only via spicelib AscEditor (no connectivity).
Args:
filepath: Path to .asc file.
allow_ltspice_generation: If True, attempt to invoke LTspice when no
companion netlist is found. Requires LTspice binary on PATH.
ltspice_timeout: Timeout in seconds for LTspice netlist generation.
Returns:
ParsedNetlist with extracted components and connectivity.
AscParseResult with the parsed netlist, completeness level, and
which .net file provided connectivity (if any).
Raises:
ImportError: If spicelib is not installed.
FileNotFoundError: If the .asc file doesn't exist.
"""
path = Path(filepath).resolve()
if not path.exists():
raise FileNotFoundError(f"ASC file not found: {path}")
if path.suffix.lower() != ".asc":
raise ValueError(f"Expected .asc file, got: {path.suffix}")
# Tier 1: companion netlist
result = _try_companion_netlist(path)
if result is not None:
return result
# Tier 2: LTspice generation (opt-in)
if allow_ltspice_generation:
result = _try_ltspice_generation(path, ltspice_timeout)
if result is not None:
return result
# Tier 3: metadata-only fallback
return _build_metadata_only_result(path)
def _try_companion_netlist(asc_path: Path) -> AscParseResult | None:
"""Tier 1: Find a companion .net/.cir/.sp file beside the .asc."""
for ext in _NETLIST_EXTENSIONS:
candidate = asc_path.with_suffix(ext)
if candidate.exists():
print(
f"Resolved companion netlist: {candidate.name}",
file=sys.stderr,
)
netlist = parse_netlist(candidate)
result = AscParseResult(
netlist=netlist,
completeness=DataCompleteness.FULL,
source_net=candidate,
)
# Try to enrich with .asc metadata (additive only)
_enrich_from_asc(result, asc_path)
return result
return None
def _try_ltspice_generation(
asc_path: Path, timeout: float
) -> AscParseResult | None:
"""Tier 2: Invoke LTspice to generate a netlist from the .asc file."""
try:
from spicelib.sim.simulator import Simulator
except ImportError:
return None
if not Simulator.is_available():
print(
"LTspice binary not found on PATH; skipping netlist generation.",
file=sys.stderr,
)
return None
net_path = asc_path.with_suffix(".net")
try:
Simulator.run(str(asc_path), timeout=timeout)
except TimeoutError as exc:
print(f"LTspice timed out after {timeout}s: {exc}", file=sys.stderr)
return None
except (OSError, RuntimeError) as exc:
print(f"LTspice invocation failed: {exc}", file=sys.stderr)
return None
except Exception as exc:
print(
f"Unexpected error invoking LTspice ({type(exc).__name__}): {exc}",
file=sys.stderr,
)
return None
if not net_path.exists():
print(
f"LTspice did not produce expected output: {net_path}",
file=sys.stderr,
)
return None
# Verify the generated .net is actually readable
try:
netlist = parse_netlist(net_path)
except Exception as exc:
print(f"Generated .net file is unreadable: {exc}", file=sys.stderr)
return None
print(
f"Generated netlist via LTspice: {net_path.name}",
file=sys.stderr,
)
result = AscParseResult(
netlist=netlist,
completeness=DataCompleteness.FULL,
source_net=net_path,
)
_enrich_from_asc(result, asc_path)
return result
def _build_metadata_only_result(asc_path: Path) -> AscParseResult:
"""Tier 3: Extract component metadata from .asc without connectivity."""
warnings: list[str] = []
metadata = _extract_asc_metadata(asc_path, warnings)
if metadata is None:
# spicelib not available or parsing failed — return empty with warning
warnings.append(
"No companion netlist found. Connectivity data is unavailable — "
"only component metadata was extracted from the .asc file."
)
return AscParseResult(
netlist=ParsedNetlist(),
completeness=DataCompleteness.METADATA_ONLY,
warnings=warnings,
)
netlist = _build_metadata_only_netlist(metadata, warnings)
return AscParseResult(
netlist=netlist,
completeness=DataCompleteness.METADATA_ONLY,
warnings=warnings,
)
@dataclass
class _AscMetadata:
"""Raw metadata extracted from an .asc file via AscEditor."""
components: list[dict[str, str]] # list of {ref, value, prefix, ...attrs}
def _extract_asc_metadata(
asc_path: Path, warnings: list[str]
) -> _AscMetadata | None:
"""Use spicelib AscEditor to extract component refs, values, attributes."""
try:
from spicelib import AscEditor
except ImportError:
raise ImportError(
"spicelib is required for .asc parsing. "
warnings.append(
"spicelib is required for .asc metadata extraction. "
"Install with: pip install spice2wireviz[asc]"
) from None
)
return None
path = Path(filepath)
if not path.exists():
raise FileNotFoundError(f"ASC file not found: {path}")
try:
asc = AscEditor(str(asc_path))
except Exception as exc:
warnings.append(f"AscEditor failed to parse {asc_path.name}: {exc}")
return None
asc = AscEditor(str(path))
subcircuit_defs: dict[str, SubcircuitDef] = {}
instances: list[SubcircuitInstance] = []
top_level_components: list[SpiceComponent] = []
all_nets: set[str] = set()
global_nets: set[str] = set()
for comp in asc.get_components():
ref = comp.get("ref", "")
value = comp.get("value", "")
components: list[dict[str, str]] = []
for ref in asc.get_components():
prefix = _extract_prefix(ref)
if not prefix:
continue
try:
value = asc.get_component_value(ref)
except Exception as exc:
value = ""
warnings.append(f"Could not extract value for {ref}: {exc}")
try:
info = asc.get_component_info(ref)
except Exception as exc:
info = {}
warnings.append(f"Could not extract attributes for {ref}: {exc}")
entry = {"ref": ref, "value": value, "prefix": prefix}
# Include extra attributes (skip internal WINDOW entries)
for k, v in info.items():
if k not in ("InstName", "Value", "Value2") and not k.startswith("WINDOW"):
entry[k] = str(v)
components.append(entry)
return _AscMetadata(components=components)
def _build_metadata_only_netlist(
metadata: _AscMetadata, warnings: list[str]
) -> ParsedNetlist:
"""Build a ParsedNetlist from metadata only — no connectivity data."""
from .models import SubcircuitInstance
instances = []
top_level_components = []
for comp in metadata.components:
ref = comp["ref"]
prefix = comp["prefix"]
value = comp.get("value", "")
attrs = {k: v for k, v in comp.items() if k not in ("ref", "value", "prefix")}
if prefix == "X":
# Subcircuit instance from .asc — limited port info available
inst = SubcircuitInstance(
reference=ref,
subcircuit_name=value,
port_to_net={},
instances.append(
SubcircuitInstance(
reference=ref,
subcircuit_name=value,
port_to_net={},
attributes=attrs,
)
)
instances.append(inst)
elif prefix in BOUNDARY_PREFIXES:
spice_comp = SpiceComponent(
reference=ref,
prefix=prefix,
value=value,
pins=[],
nodes=[],
top_level_components.append(
SpiceComponent(
reference=ref,
prefix=prefix,
value=value,
pins=[],
nodes=[],
attributes=attrs,
)
)
top_level_components.append(spice_comp)
warnings.append(
"No companion netlist found. Connectivity data is unavailable — "
"only component metadata was extracted from the .asc file."
)
return ParsedNetlist(
subcircuit_defs=subcircuit_defs,
instances=instances,
top_level_components=top_level_components,
all_nets=all_nets,
global_nets=global_nets,
)
def _enrich_from_asc(result: AscParseResult, asc_path: Path) -> None:
"""Enrich a FULL netlist with additional metadata from the .asc file.
This is additive only never overwrites connectivity (nodes, pins,
port_to_net). Only merges component attributes and values from the
.asc that aren't already present in the .net parse.
"""
warnings: list[str] = []
metadata = _extract_asc_metadata(asc_path, warnings)
if metadata is None:
return
_enrich_netlist_with_metadata(result.netlist, metadata)
result.warnings.extend(warnings)
# Field names that must never be injected as attributes from .asc metadata
_PROTECTED_FIELDS = frozenset({
"reference", "prefix", "value", "nodes", "pins",
"subcircuit_scope", "port_to_net", "subcircuit_name",
})
# Keys in the metadata dict that are structural, not attributes
_METADATA_KEYS = frozenset({"ref", "value", "prefix"})
def _enrich_netlist_with_metadata(
netlist: ParsedNetlist, metadata: _AscMetadata
) -> None:
"""Merge .asc component data into an existing ParsedNetlist.
Additive only: adds attributes and fills empty values. Never
overwrites nodes, pins, or port_to_net mappings. Protected field
names are rejected to prevent attribute injection.
"""
asc_by_ref = {c["ref"]: c for c in metadata.components}
for comp in netlist.top_level_components:
asc_comp = asc_by_ref.get(comp.reference)
if asc_comp is None:
continue
if not comp.value and asc_comp.get("value"):
comp.value = asc_comp["value"]
for k, v in asc_comp.items():
if k in _METADATA_KEYS or k in _PROTECTED_FIELDS:
continue
if k not in comp.attributes:
comp.attributes[k] = v
for subckt_def in netlist.subcircuit_defs.values():
for comp in subckt_def.boundary_components:
asc_comp = asc_by_ref.get(comp.reference)
if asc_comp is None:
continue
if not comp.value and asc_comp.get("value"):
comp.value = asc_comp["value"]
for k, v in asc_comp.items():
if k in _METADATA_KEYS or k in _PROTECTED_FIELDS:
continue
if k not in comp.attributes:
comp.attributes[k] = v
for inst in netlist.instances:
asc_comp = asc_by_ref.get(inst.reference)
if asc_comp is None:
continue
for k, v in asc_comp.items():
if k in _METADATA_KEYS or k in _PROTECTED_FIELDS:
continue
if k not in inst.attributes:
inst.attributes[k] = v

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]