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

@ -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