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
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue