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
39
README.md
39
README.md
@ -4,12 +4,12 @@ Convert LTspice SPICE netlists to WireViz wiring diagrams.
|
|||||||
|
|
||||||
## What it does
|
## 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:
|
Two operating modes:
|
||||||
|
|
||||||
- **Single module** — External interface of one subcircuit (its connectors, test points, port interface)
|
- **Single module** -- External interface of one subcircuit (its connectors, test points, port interface)
|
||||||
- **Inter-module** — How multiple subcircuits/boards connect to each other
|
- **Inter-module** -- How multiple subcircuits/boards connect to each other
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
@ -19,8 +19,16 @@ uv tool install spice2wireviz
|
|||||||
pip install spice2wireviz
|
pip install spice2wireviz
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For `.asc` file metadata extraction (optional):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install spice2wireviz[asc]
|
||||||
|
```
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
|
### From netlist files
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Inter-module wiring (auto-detected from top-level X instances)
|
# Inter-module wiring (auto-detected from top-level X instances)
|
||||||
spice2wireviz top_level.net -o wiring.yml --render
|
spice2wireviz top_level.net -o wiring.yml --render
|
||||||
@ -37,6 +45,25 @@ spice2wireviz design.net --list-components
|
|||||||
spice2wireviz design.net --dry-run
|
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
|
## Filtering
|
||||||
|
|
||||||
Cherry-pick what appears in the diagram:
|
Cherry-pick what appears in the diagram:
|
||||||
@ -52,7 +79,11 @@ Cherry-pick what appears in the diagram:
|
|||||||
## Development
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv sync --extra dev
|
uv sync --extra dev --extra asc
|
||||||
uv run pytest
|
uv run pytest
|
||||||
uv run ruff check src/ tests/
|
uv run ruff check src/ tests/
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Repository
|
||||||
|
|
||||||
|
[git.supported.systems/warehack.ing/spice2wireviz](https://git.supported.systems/warehack.ing/spice2wireviz)
|
||||||
|
|||||||
@ -29,9 +29,9 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
Homepage = "https://github.com/ryanmalloy/spice2wireviz"
|
Homepage = "https://git.supported.systems/warehack.ing/spice2wireviz"
|
||||||
Repository = "https://github.com/ryanmalloy/spice2wireviz"
|
Repository = "https://git.supported.systems/warehack.ing/spice2wireviz"
|
||||||
Issues = "https://github.com/ryanmalloy/spice2wireviz/issues"
|
Issues = "https://git.supported.systems/warehack.ing/spice2wireviz/issues"
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
asc = ["spicelib>=1.4.9"]
|
asc = ["spicelib>=1.4.9"]
|
||||||
|
|||||||
@ -1,10 +1,9 @@
|
|||||||
"""Click CLI for spice2wireviz.
|
"""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
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@ -15,6 +14,7 @@ from .emitter.yaml_emitter import emit_yaml
|
|||||||
from .filter import FilterConfig, apply_filters
|
from .filter import FilterConfig, apply_filters
|
||||||
from .mapper.inter_module import map_inter_module
|
from .mapper.inter_module import map_inter_module
|
||||||
from .mapper.single_module import map_single_module
|
from .mapper.single_module import map_single_module
|
||||||
|
from .parser.asc import DataCompleteness, parse_asc
|
||||||
from .parser.netlist import parse_netlist
|
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.",
|
help="List all components matching filters and exit.",
|
||||||
)
|
)
|
||||||
@click.option("--dry-run", is_flag=True, help="Show mapping summary without generating YAML.")
|
@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__)
|
@click.version_option(version=__version__)
|
||||||
def main(
|
def main(
|
||||||
input_file: Path,
|
input_file: Path,
|
||||||
@ -146,16 +151,49 @@ def main(
|
|||||||
list_subcircuits: bool,
|
list_subcircuits: bool,
|
||||||
list_components: bool,
|
list_components: bool,
|
||||||
dry_run: bool,
|
dry_run: bool,
|
||||||
|
generate_netlist: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Convert SPICE netlist to WireViz YAML wiring diagram."""
|
"""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:
|
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:
|
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)
|
sys.exit(1)
|
||||||
|
|
||||||
# --- Inspection commands ---
|
# --- Inspection commands (allowed even on METADATA_ONLY) ---
|
||||||
if list_subcircuits:
|
if list_subcircuits:
|
||||||
names = netlist.list_subcircuit_names()
|
names = netlist.list_subcircuit_names()
|
||||||
if not names:
|
if not names:
|
||||||
@ -196,6 +234,25 @@ def main(
|
|||||||
click.echo(f" {inst.reference} ({inst.subcircuit_name}): {nets}")
|
click.echo(f" {inst.reference} ({inst.subcircuit_name}): {nets}")
|
||||||
return
|
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 ---
|
# --- Auto-detect mode ---
|
||||||
if mode is None:
|
if mode is None:
|
||||||
if subcircuit:
|
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
|
LTspice .asc schematic files contain component metadata (refs, values,
|
||||||
spicelib handles them well, but it pulls ~165MB of transitive deps.
|
attributes) but NOT net connectivity. Connectivity lives in .net files.
|
||||||
This module is only imported when spicelib is available.
|
|
||||||
|
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:
|
Usage:
|
||||||
pip install spice2wireviz[asc]
|
pip install spice2wireviz[asc]
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
import sys
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import StrEnum
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .models import ParsedNetlist, SpiceComponent, SubcircuitDef, SubcircuitInstance
|
from .models import ParsedNetlist, SpiceComponent
|
||||||
from .netlist import BOUNDARY_PREFIXES, _extract_prefix
|
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:
|
class DataCompleteness(StrEnum):
|
||||||
"""Parse an LTspice .asc schematic file via spicelib.
|
"""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:
|
Args:
|
||||||
filepath: Path to .asc file.
|
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:
|
Returns:
|
||||||
ParsedNetlist with extracted components and connectivity.
|
AscParseResult with the parsed netlist, completeness level, and
|
||||||
|
which .net file provided connectivity (if any).
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ImportError: If spicelib is not installed.
|
|
||||||
FileNotFoundError: If the .asc file doesn't exist.
|
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:
|
try:
|
||||||
from spicelib import AscEditor
|
from spicelib import AscEditor
|
||||||
except ImportError:
|
except ImportError:
|
||||||
raise ImportError(
|
warnings.append(
|
||||||
"spicelib is required for .asc parsing. "
|
"spicelib is required for .asc metadata extraction. "
|
||||||
"Install with: pip install spice2wireviz[asc]"
|
"Install with: pip install spice2wireviz[asc]"
|
||||||
) from None
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
path = Path(filepath)
|
try:
|
||||||
if not path.exists():
|
asc = AscEditor(str(asc_path))
|
||||||
raise FileNotFoundError(f"ASC file not found: {path}")
|
except Exception as exc:
|
||||||
|
warnings.append(f"AscEditor failed to parse {asc_path.name}: {exc}")
|
||||||
|
return None
|
||||||
|
|
||||||
asc = AscEditor(str(path))
|
components: list[dict[str, str]] = []
|
||||||
|
for ref in asc.get_components():
|
||||||
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", "")
|
|
||||||
prefix = _extract_prefix(ref)
|
prefix = _extract_prefix(ref)
|
||||||
|
|
||||||
if not prefix:
|
if not prefix:
|
||||||
continue
|
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":
|
if prefix == "X":
|
||||||
# Subcircuit instance from .asc — limited port info available
|
instances.append(
|
||||||
inst = SubcircuitInstance(
|
SubcircuitInstance(
|
||||||
reference=ref,
|
reference=ref,
|
||||||
subcircuit_name=value,
|
subcircuit_name=value,
|
||||||
port_to_net={},
|
port_to_net={},
|
||||||
|
attributes=attrs,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
instances.append(inst)
|
|
||||||
elif prefix in BOUNDARY_PREFIXES:
|
elif prefix in BOUNDARY_PREFIXES:
|
||||||
spice_comp = SpiceComponent(
|
top_level_components.append(
|
||||||
reference=ref,
|
SpiceComponent(
|
||||||
prefix=prefix,
|
reference=ref,
|
||||||
value=value,
|
prefix=prefix,
|
||||||
pins=[],
|
value=value,
|
||||||
nodes=[],
|
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(
|
return ParsedNetlist(
|
||||||
subcircuit_defs=subcircuit_defs,
|
|
||||||
instances=instances,
|
instances=instances,
|
||||||
top_level_components=top_level_components,
|
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
29
tests/fixtures/simple_board.asc
vendored
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
Version 4
|
||||||
|
SHEET 1 880 680
|
||||||
|
SYMBOL res 192 160 R0
|
||||||
|
SYMATTR InstName R1
|
||||||
|
SYMATTR Value 10k
|
||||||
|
SYMBOL res 192 288 R0
|
||||||
|
SYMATTR InstName R2
|
||||||
|
SYMATTR Value 10k
|
||||||
|
SYMBOL Opamps\\opamp 336 256 R0
|
||||||
|
SYMATTR InstName U1
|
||||||
|
SYMATTR Value opamp
|
||||||
|
SYMBOL conn 48 144 R0
|
||||||
|
SYMATTR InstName J1
|
||||||
|
SYMATTR Value PWR_CONN
|
||||||
|
SYMBOL conn 48 288 R0
|
||||||
|
SYMATTR InstName J2
|
||||||
|
SYMATTR Value SIG_CONN
|
||||||
|
SYMBOL TestPoint 288 128 R0
|
||||||
|
SYMATTR InstName TP1
|
||||||
|
WIRE 96 176 192 176
|
||||||
|
WIRE 96 320 192 320
|
||||||
|
WIRE 192 288 192 256
|
||||||
|
WIRE 192 160 192 128
|
||||||
|
WIRE 288 128 192 128
|
||||||
|
WIRE 336 224 192 224
|
||||||
|
WIRE 336 288 336 320
|
||||||
|
WIRE 448 256 400 256
|
||||||
|
TEXT 48 432 Left 2 !.subckt amplifier_board VIN GND VOUT SIGNAL_IN
|
||||||
|
TEXT 48 464 Left 2 !.ends amplifier_board
|
||||||
23
tests/fixtures/standalone.asc
vendored
Normal file
23
tests/fixtures/standalone.asc
vendored
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
Version 4
|
||||||
|
SHEET 1 880 680
|
||||||
|
SYMBOL res 192 160 R0
|
||||||
|
SYMATTR InstName R1
|
||||||
|
SYMATTR Value 4.7k
|
||||||
|
SYMBOL cap 320 160 R0
|
||||||
|
SYMATTR InstName C1
|
||||||
|
SYMATTR Value 100n
|
||||||
|
SYMBOL conn 48 144 R0
|
||||||
|
SYMATTR InstName J1
|
||||||
|
SYMATTR Value DB9_CONN
|
||||||
|
SYMBOL conn 48 288 R0
|
||||||
|
SYMATTR InstName J2
|
||||||
|
SYMATTR Value BARREL_JACK
|
||||||
|
SYMBOL TestPoint 480 128 R0
|
||||||
|
SYMATTR InstName TP1
|
||||||
|
SYMBOL voltage 576 240 R0
|
||||||
|
SYMATTR InstName V1
|
||||||
|
SYMATTR Value 12
|
||||||
|
WIRE 96 176 192 176
|
||||||
|
WIRE 96 320 192 320
|
||||||
|
WIRE 320 160 320 128
|
||||||
|
WIRE 480 128 320 128
|
||||||
281
tests/test_asc.py
Normal file
281
tests/test_asc.py
Normal file
@ -0,0 +1,281 @@
|
|||||||
|
"""Tests for the tiered .asc file parser."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from spice2wireviz.parser.asc import (
|
||||||
|
AscParseResult,
|
||||||
|
DataCompleteness,
|
||||||
|
_AscMetadata,
|
||||||
|
_build_metadata_only_netlist,
|
||||||
|
_enrich_netlist_with_metadata,
|
||||||
|
_try_companion_netlist,
|
||||||
|
parse_asc,
|
||||||
|
)
|
||||||
|
from spice2wireviz.parser.models import ParsedNetlist, SpiceComponent
|
||||||
|
|
||||||
|
FIXTURES = Path(__file__).parent / "fixtures"
|
||||||
|
|
||||||
|
|
||||||
|
class TestCompanionNetlistDetection:
|
||||||
|
"""Tier 1: find companion .net beside the .asc and get FULL connectivity."""
|
||||||
|
|
||||||
|
def test_finds_companion_net(self):
|
||||||
|
"""simple_board.asc has simple_board.net in the same directory."""
|
||||||
|
result = parse_asc(FIXTURES / "simple_board.asc")
|
||||||
|
assert result.completeness == DataCompleteness.FULL
|
||||||
|
assert result.source_net is not None
|
||||||
|
assert result.source_net.name == "simple_board.net"
|
||||||
|
|
||||||
|
def test_full_connectivity_from_companion(self):
|
||||||
|
"""The companion .net provides real subcircuit defs and connectivity."""
|
||||||
|
result = parse_asc(FIXTURES / "simple_board.asc")
|
||||||
|
netlist = result.netlist
|
||||||
|
assert "amplifier_board" in netlist.subcircuit_defs
|
||||||
|
subckt = netlist.subcircuit_defs["amplifier_board"]
|
||||||
|
assert len(subckt.port_names) == 4
|
||||||
|
assert len(subckt.boundary_components) >= 2 # J1, J2, TP1
|
||||||
|
|
||||||
|
def test_companion_priority_net_over_cir(self, tmp_path):
|
||||||
|
"""When both .net and .cir exist, .net wins (first in priority)."""
|
||||||
|
asc = tmp_path / "test.asc"
|
||||||
|
asc.write_text("Version 4\nSHEET 1 880 680\n")
|
||||||
|
net = tmp_path / "test.net"
|
||||||
|
net.write_text(
|
||||||
|
"* Test\n"
|
||||||
|
".subckt mymod A B\n"
|
||||||
|
"J1 A B CONN\n"
|
||||||
|
".ends mymod\n"
|
||||||
|
)
|
||||||
|
cir = tmp_path / "test.cir"
|
||||||
|
cir.write_text("* Different file\n.end\n")
|
||||||
|
|
||||||
|
result = parse_asc(asc)
|
||||||
|
assert result.source_net == net
|
||||||
|
|
||||||
|
def test_companion_cir_fallback(self, tmp_path):
|
||||||
|
"""When only .cir exists (no .net), it's used as companion."""
|
||||||
|
asc = tmp_path / "test.asc"
|
||||||
|
asc.write_text("Version 4\nSHEET 1 880 680\n")
|
||||||
|
cir = tmp_path / "test.cir"
|
||||||
|
cir.write_text(
|
||||||
|
"* Test\n"
|
||||||
|
".subckt mymod A\n"
|
||||||
|
"J1 A CONN\n"
|
||||||
|
".ends mymod\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
result = parse_asc(asc)
|
||||||
|
assert result.completeness == DataCompleteness.FULL
|
||||||
|
assert result.source_net == cir
|
||||||
|
|
||||||
|
|
||||||
|
class TestMetadataEnrichment:
|
||||||
|
"""Verify .asc values/attrs merge into .net parse (additive only)."""
|
||||||
|
|
||||||
|
def test_enrichment_adds_attributes(self):
|
||||||
|
"""Metadata from .asc should add attributes to the netlist."""
|
||||||
|
netlist = ParsedNetlist(
|
||||||
|
top_level_components=[
|
||||||
|
SpiceComponent(reference="J1", prefix="J", value="", nodes=["VIN", "GND"]),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
metadata = _AscMetadata(
|
||||||
|
components=[
|
||||||
|
{"ref": "J1", "prefix": "J", "value": "PWR_CONN", "SpiceModel": "DB9"},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
_enrich_netlist_with_metadata(netlist, metadata)
|
||||||
|
|
||||||
|
j1 = netlist.top_level_components[0]
|
||||||
|
assert j1.value == "PWR_CONN" # filled empty value
|
||||||
|
assert j1.attributes["SpiceModel"] == "DB9" # new attribute added
|
||||||
|
|
||||||
|
def test_enrichment_never_overwrites_value(self):
|
||||||
|
"""If the .net parse already has a value, .asc should not overwrite it."""
|
||||||
|
netlist = ParsedNetlist(
|
||||||
|
top_level_components=[
|
||||||
|
SpiceComponent(reference="J1", prefix="J", value="EXISTING", nodes=["A"]),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
metadata = _AscMetadata(
|
||||||
|
components=[{"ref": "J1", "prefix": "J", "value": "DIFFERENT"}]
|
||||||
|
)
|
||||||
|
|
||||||
|
_enrich_netlist_with_metadata(netlist, metadata)
|
||||||
|
assert netlist.top_level_components[0].value == "EXISTING"
|
||||||
|
|
||||||
|
def test_enrichment_never_overwrites_existing_attrs(self):
|
||||||
|
"""Existing attributes from .net parse must not be overwritten."""
|
||||||
|
netlist = ParsedNetlist(
|
||||||
|
top_level_components=[
|
||||||
|
SpiceComponent(
|
||||||
|
reference="J1", prefix="J", nodes=["A"],
|
||||||
|
attributes={"Footprint": "from-net"},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
metadata = _AscMetadata(
|
||||||
|
components=[
|
||||||
|
{"ref": "J1", "prefix": "J", "value": "", "Footprint": "from-asc", "NewAttr": "v"},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
_enrich_netlist_with_metadata(netlist, metadata)
|
||||||
|
j1 = netlist.top_level_components[0]
|
||||||
|
assert j1.attributes["Footprint"] == "from-net" # not overwritten
|
||||||
|
assert j1.attributes["NewAttr"] == "v" # new one added
|
||||||
|
|
||||||
|
def test_enrichment_handles_unmatched_refs(self):
|
||||||
|
"""Components in .asc that aren't in .net are silently skipped."""
|
||||||
|
netlist = ParsedNetlist(
|
||||||
|
top_level_components=[
|
||||||
|
SpiceComponent(reference="J1", prefix="J", nodes=["A"]),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
metadata = _AscMetadata(
|
||||||
|
components=[
|
||||||
|
{"ref": "J99", "prefix": "J", "value": "PHANTOM"},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
_enrich_netlist_with_metadata(netlist, metadata)
|
||||||
|
assert netlist.top_level_components[0].value == "" # unchanged
|
||||||
|
|
||||||
|
|
||||||
|
class TestMetadataOnlyParsing:
|
||||||
|
"""Tier 3: .asc with no companion .net produces METADATA_ONLY."""
|
||||||
|
|
||||||
|
def test_standalone_is_metadata_only(self):
|
||||||
|
"""standalone.asc has no companion .net — should be METADATA_ONLY."""
|
||||||
|
result = parse_asc(FIXTURES / "standalone.asc")
|
||||||
|
assert result.completeness == DataCompleteness.METADATA_ONLY
|
||||||
|
assert result.source_net is None
|
||||||
|
|
||||||
|
def test_metadata_only_has_warnings(self):
|
||||||
|
"""METADATA_ONLY results must include a warning about missing connectivity."""
|
||||||
|
result = parse_asc(FIXTURES / "standalone.asc")
|
||||||
|
warning_texts = " ".join(result.warnings)
|
||||||
|
assert "connectivity" in warning_texts.lower() or "companion" in warning_texts.lower()
|
||||||
|
|
||||||
|
def test_metadata_only_netlist_has_no_connectivity(self):
|
||||||
|
"""A metadata-only netlist should have empty connectivity fields."""
|
||||||
|
metadata = _AscMetadata(
|
||||||
|
components=[
|
||||||
|
{"ref": "J1", "prefix": "J", "value": "DB9"},
|
||||||
|
{"ref": "X1", "prefix": "X", "value": "OpAmpBoard"},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
warnings: list[str] = []
|
||||||
|
netlist = _build_metadata_only_netlist(metadata, warnings)
|
||||||
|
|
||||||
|
assert len(netlist.top_level_components) == 1
|
||||||
|
assert netlist.top_level_components[0].reference == "J1"
|
||||||
|
assert netlist.top_level_components[0].nodes == []
|
||||||
|
assert netlist.top_level_components[0].pins == []
|
||||||
|
|
||||||
|
assert len(netlist.instances) == 1
|
||||||
|
assert netlist.instances[0].port_to_net == {}
|
||||||
|
|
||||||
|
def test_metadata_only_preserves_attributes(self):
|
||||||
|
"""Extra attributes from .asc should appear in the metadata-only netlist."""
|
||||||
|
metadata = _AscMetadata(
|
||||||
|
components=[
|
||||||
|
{"ref": "J1", "prefix": "J", "value": "DB9", "Footprint": "DSUB9"},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
warnings: list[str] = []
|
||||||
|
netlist = _build_metadata_only_netlist(metadata, warnings)
|
||||||
|
|
||||||
|
j1 = netlist.top_level_components[0]
|
||||||
|
assert j1.attributes["Footprint"] == "DSUB9"
|
||||||
|
|
||||||
|
|
||||||
|
class TestLTspiceGeneration:
|
||||||
|
"""Tier 2: LTspice-generated netlist (mocked — no real LTspice in CI)."""
|
||||||
|
|
||||||
|
def test_ltspice_generation_opt_in(self, tmp_path):
|
||||||
|
"""LTspice generation is skipped unless allow_ltspice_generation=True."""
|
||||||
|
asc = tmp_path / "test.asc"
|
||||||
|
asc.write_text("Version 4\nSHEET 1 880 680\n")
|
||||||
|
|
||||||
|
# Without opt-in, should fall through to Tier 3
|
||||||
|
result = parse_asc(asc)
|
||||||
|
assert result.completeness == DataCompleteness.METADATA_ONLY
|
||||||
|
|
||||||
|
@patch("spice2wireviz.parser.asc.Simulator", create=True)
|
||||||
|
def test_ltspice_generation_success(self, mock_sim_cls, tmp_path):
|
||||||
|
"""Mocked LTspice successfully generates a .net file."""
|
||||||
|
asc = tmp_path / "test.asc"
|
||||||
|
asc.write_text("Version 4\nSHEET 1 880 680\n")
|
||||||
|
|
||||||
|
net = tmp_path / "test.net"
|
||||||
|
net.write_text(
|
||||||
|
"* Generated\n"
|
||||||
|
".subckt gen_mod A B\n"
|
||||||
|
"J1 A B CONN\n"
|
||||||
|
".ends gen_mod\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mock the import path inside _try_ltspice_generation
|
||||||
|
with patch(
|
||||||
|
"spice2wireviz.parser.asc._try_ltspice_generation"
|
||||||
|
) as mock_tier2:
|
||||||
|
mock_tier2.return_value = AscParseResult(
|
||||||
|
netlist=ParsedNetlist(),
|
||||||
|
completeness=DataCompleteness.FULL,
|
||||||
|
source_net=net,
|
||||||
|
)
|
||||||
|
result = parse_asc(asc, allow_ltspice_generation=True)
|
||||||
|
assert result.completeness == DataCompleteness.FULL
|
||||||
|
|
||||||
|
def test_ltspice_generation_fallback_on_failure(self, tmp_path):
|
||||||
|
"""If LTspice invocation fails, falls through to Tier 3."""
|
||||||
|
asc = tmp_path / "test.asc"
|
||||||
|
asc.write_text("Version 4\nSHEET 1 880 680\n")
|
||||||
|
|
||||||
|
# No LTspice binary available, so Tier 2 returns None
|
||||||
|
result = parse_asc(asc, allow_ltspice_generation=True)
|
||||||
|
assert result.completeness == DataCompleteness.METADATA_ONLY
|
||||||
|
|
||||||
|
|
||||||
|
class TestErrorHandling:
|
||||||
|
def test_nonexistent_file(self):
|
||||||
|
with pytest.raises(FileNotFoundError, match="ASC file not found"):
|
||||||
|
parse_asc("/nonexistent/path/board.asc")
|
||||||
|
|
||||||
|
def test_wrong_extension(self, tmp_path):
|
||||||
|
net = tmp_path / "board.net"
|
||||||
|
net.write_text("* netlist\n")
|
||||||
|
with pytest.raises(ValueError, match=r"Expected \.asc file"):
|
||||||
|
parse_asc(net)
|
||||||
|
|
||||||
|
def test_spicelib_not_required_for_companion(self):
|
||||||
|
"""Tier 1 works even without spicelib installed."""
|
||||||
|
# simple_board.asc has a companion .net — spicelib is only needed
|
||||||
|
# for enrichment, which gracefully degrades
|
||||||
|
result = parse_asc(FIXTURES / "simple_board.asc")
|
||||||
|
assert result.completeness == DataCompleteness.FULL
|
||||||
|
|
||||||
|
|
||||||
|
class TestCompanionNetlistInternalHelper:
|
||||||
|
"""Direct tests for _try_companion_netlist."""
|
||||||
|
|
||||||
|
def test_returns_none_when_no_companion(self, tmp_path):
|
||||||
|
asc = tmp_path / "isolated.asc"
|
||||||
|
asc.write_text("Version 4\nSHEET 1 880 680\n")
|
||||||
|
assert _try_companion_netlist(asc) is None
|
||||||
|
|
||||||
|
def test_returns_result_with_source_net(self, tmp_path):
|
||||||
|
asc = tmp_path / "board.asc"
|
||||||
|
asc.write_text("Version 4\n")
|
||||||
|
net = tmp_path / "board.net"
|
||||||
|
net.write_text("* test\n.subckt s A\nJ1 A C\n.ends s\n")
|
||||||
|
|
||||||
|
result = _try_companion_netlist(asc)
|
||||||
|
assert result is not None
|
||||||
|
assert result.completeness == DataCompleteness.FULL
|
||||||
|
assert result.source_net == net
|
||||||
@ -128,3 +128,75 @@ class TestCLIFiltering:
|
|||||||
)
|
)
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "X1:" not in result.output
|
assert "X1:" not in result.output
|
||||||
|
|
||||||
|
|
||||||
|
class TestCLIAscInput:
|
||||||
|
"""Tests for .asc file input through the CLI."""
|
||||||
|
|
||||||
|
def test_asc_with_companion_works(self):
|
||||||
|
"""simple_board.asc has companion .net — full pipeline should work."""
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
main,
|
||||||
|
[str(FIXTURES / "simple_board.asc"), "-s", "amplifier_board"],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "connectors:" in result.output
|
||||||
|
assert "amplifier_board" in result.output
|
||||||
|
|
||||||
|
def test_asc_companion_resolution_in_stderr(self):
|
||||||
|
"""CLI should report which .net was resolved to stderr."""
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
main,
|
||||||
|
[str(FIXTURES / "simple_board.asc"), "-s", "amplifier_board"],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "simple_board.net" in result.stderr
|
||||||
|
|
||||||
|
def test_asc_without_companion_errors(self):
|
||||||
|
"""standalone.asc has no companion .net — diagram generation should fail."""
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, [str(FIXTURES / "standalone.asc")])
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "connectivity" in result.output.lower() or "connectivity" in (
|
||||||
|
getattr(result, "stderr", "") or ""
|
||||||
|
).lower()
|
||||||
|
|
||||||
|
def test_asc_without_companion_shows_remediation(self):
|
||||||
|
"""Error message should tell the user how to fix it."""
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, [str(FIXTURES / "standalone.asc")])
|
||||||
|
assert result.exit_code != 0
|
||||||
|
stderr = result.stderr
|
||||||
|
assert "--generate-netlist" in stderr or "--list-components" in stderr
|
||||||
|
|
||||||
|
def test_asc_list_components_on_metadata_only(self):
|
||||||
|
"""--list-components should work even without connectivity."""
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
main,
|
||||||
|
[str(FIXTURES / "standalone.asc"), "--list-components"],
|
||||||
|
)
|
||||||
|
# Should succeed (exit 0) — inspection doesn't need connectivity
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
def test_asc_list_subcircuits_with_companion(self):
|
||||||
|
"""--list-subcircuits through .asc with companion works."""
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
main,
|
||||||
|
[str(FIXTURES / "simple_board.asc"), "--list-subcircuits"],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "amplifier_board" in result.output
|
||||||
|
|
||||||
|
def test_asc_dry_run_with_companion(self):
|
||||||
|
"""--dry-run through .asc with companion produces summary."""
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
main,
|
||||||
|
[str(FIXTURES / "simple_board.asc"), "-s", "amplifier_board", "--dry-run"],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Mode: single" in result.output
|
||||||
|
|||||||
@ -186,7 +186,7 @@ class TestSingleModuleLayoutOptimization:
|
|||||||
[{"H": [1, 3]}, {"W1": [1, 2]}, {"CA": [1, 2]}],
|
[{"H": [1, 3]}, {"W1": [1, 2]}, {"CA": [1, 2]}],
|
||||||
[{"H": [2, 4]}, {"W2": [1, 2]}, {"CB": [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
|
"H", connectors, {}, connections
|
||||||
)
|
)
|
||||||
# Header should be regrouped: [A1, A2, B1, B2]
|
# Header should be regrouped: [A1, A2, B1, B2]
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user