From 08c92bfefbc533854209b2ae8db3182d19b7e868 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Fri, 13 Feb 2026 04:59:03 -0700 Subject: [PATCH] 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. --- README.md | 39 +++- pyproject.toml | 6 +- src/spice2wireviz/cli.py | 71 +++++- src/spice2wireviz/parser/asc.py | 371 ++++++++++++++++++++++++++++---- tests/fixtures/simple_board.asc | 29 +++ tests/fixtures/standalone.asc | 23 ++ tests/test_asc.py | 281 ++++++++++++++++++++++++ tests/test_cli.py | 72 +++++++ tests/test_single_module.py | 2 +- 9 files changed, 833 insertions(+), 61 deletions(-) create mode 100644 tests/fixtures/simple_board.asc create mode 100644 tests/fixtures/standalone.asc create mode 100644 tests/test_asc.py diff --git a/README.md b/README.md index 1926184..1e9665a 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/pyproject.toml b/pyproject.toml index 373777f..04b3ba5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/src/spice2wireviz/cli.py b/src/spice2wireviz/cli.py index 85f26c5..e1b5f64 100644 --- a/src/spice2wireviz/cli.py +++ b/src/spice2wireviz/cli.py @@ -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: diff --git a/src/spice2wireviz/parser/asc.py b/src/spice2wireviz/parser/asc.py index ef1a62e..718619f 100644 --- a/src/spice2wireviz/parser/asc.py +++ b/src/spice2wireviz/parser/asc.py @@ -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 diff --git a/tests/fixtures/simple_board.asc b/tests/fixtures/simple_board.asc new file mode 100644 index 0000000..0b88605 --- /dev/null +++ b/tests/fixtures/simple_board.asc @@ -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 diff --git a/tests/fixtures/standalone.asc b/tests/fixtures/standalone.asc new file mode 100644 index 0000000..77a6fbe --- /dev/null +++ b/tests/fixtures/standalone.asc @@ -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 diff --git a/tests/test_asc.py b/tests/test_asc.py new file mode 100644 index 0000000..e92c468 --- /dev/null +++ b/tests/test_asc.py @@ -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 diff --git a/tests/test_cli.py b/tests/test_cli.py index acfbd98..65d39cf 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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 diff --git a/tests/test_single_module.py b/tests/test_single_module.py index 21ff9cf..5a709e2 100644 --- a/tests/test_single_module.py +++ b/tests/test_single_module.py @@ -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]