Add BOM output, fix LTspice Tier 2 import, real .asc integration tests

- Fix _try_ltspice_generation() to use spicelib.simulators.ltspice_simulator.LTspice
  instead of the abstract Simulator base class (which always returned unavailable)
- Use LTspice.create_netlist() instead of Simulator.run() for correct netlist generation
- Add --ltspice-exe CLI option to specify LTspice binary path
- Add --bom flag for component BOM CSV output (works on any parse completeness)
- Add --bom-wiring flag for wiring BOM CSV from mapped output
- Add real 1002A.asc demo circuit and pre-generated .net as test fixtures
- Add @pytest.mark.ltspice marker for tests requiring LTspice binary
- Bump version to 2026.2.14
This commit is contained in:
Ryan Malloy 2026-02-13 07:00:39 -07:00
parent 08c92bfefb
commit 5a5337566c
11 changed files with 607 additions and 14 deletions

View file

@ -1,3 +1,3 @@
"""spice2wireviz — Convert LTspice SPICE netlists to WireViz wiring diagrams."""
__version__ = "2026.2.13"
__version__ = "2026.2.14"

View file

@ -10,6 +10,7 @@ from pathlib import Path
import click
from . import __version__
from .emitter.bom_emitter import emit_component_bom, emit_wiring_bom
from .emitter.yaml_emitter import emit_yaml
from .filter import FilterConfig, apply_filters
from .mapper.inter_module import map_inter_module
@ -129,6 +130,22 @@ def _parse_comma_list(ctx: click.Context, param: click.Parameter, value: str | N
is_flag=True,
help="For .asc files: invoke LTspice to generate a netlist if no companion .net exists.",
)
@click.option(
"--ltspice-exe",
type=click.Path(path_type=Path),
default=None,
help="Path to LTspice binary (for --generate-netlist). Auto-detected if omitted.",
)
@click.option(
"--bom",
is_flag=True,
help="Emit component BOM as CSV instead of WireViz YAML.",
)
@click.option(
"--bom-wiring",
is_flag=True,
help="Emit wiring BOM as CSV instead of WireViz YAML (requires full mapping).",
)
@click.version_option(version=__version__)
def main(
input_file: Path,
@ -152,8 +169,16 @@ def main(
list_components: bool,
dry_run: bool,
generate_netlist: bool,
ltspice_exe: Path | None,
bom: bool,
bom_wiring: bool,
) -> None:
"""Convert SPICE netlist to WireViz YAML wiring diagram."""
# --- Validate mutually exclusive BOM flags ---
if bom and bom_wiring:
click.echo("Error: --bom and --bom-wiring are mutually exclusive.", err=True)
sys.exit(1)
# --- Parse input (format detection) ---
is_asc = input_file.suffix.lower() == ".asc"
completeness = DataCompleteness.FULL # default for .net files
@ -163,6 +188,7 @@ def main(
asc_result = parse_asc(
input_file,
allow_ltspice_generation=generate_netlist,
ltspice_exe=str(ltspice_exe) if ltspice_exe else None,
)
netlist = asc_result.netlist
completeness = asc_result.completeness
@ -234,6 +260,16 @@ def main(
click.echo(f" {inst.reference} ({inst.subcircuit_name}): {nets}")
return
# --- Component BOM (works on any completeness level) ---
if bom:
csv_str = emit_component_bom(netlist, filter_config)
if output:
output.write_text(csv_str, encoding="utf-8")
click.echo(f"Wrote component BOM: {output}", err=True)
else:
click.echo(csv_str, nl=False)
return
# --- Safety gate: block diagram generation on METADATA_ONLY ---
if completeness == DataCompleteness.METADATA_ONLY:
click.echo(
@ -304,6 +340,16 @@ def main(
else:
wireviz_dict = map_inter_module(netlist, filter_config, meta)
# --- Wiring BOM (requires mapping) ---
if bom_wiring:
csv_str = emit_wiring_bom(wireviz_dict)
if output:
output.write_text(csv_str, encoding="utf-8")
click.echo(f"Wrote wiring BOM: {output}", err=True)
else:
click.echo(csv_str, nl=False)
return
# --- Dry run ---
if dry_run:
conn_count = len(wireviz_dict.get("connectors", {}))

View file

@ -0,0 +1,109 @@
"""Bill of Materials CSV output for spice2wireviz.
Two BOM types:
- Component BOM: boundary components (connectors, test points) from parsed netlist
- Wiring BOM: cables and wire connections from mapped WireViz output
"""
import csv
import io
from typing import Any
from ..filter import FilterConfig, filter_component, filter_instance
from ..parser.models import ParsedNetlist
def emit_component_bom(netlist: ParsedNetlist, config: FilterConfig) -> str:
"""Generate a CSV bill of materials for boundary components.
Lists connectors, test points, and subcircuit instances that pass
the filter configuration. Each row describes one component with its
reference, prefix, value, pin count, subcircuit scope, and attributes.
Returns:
CSV string with header row.
"""
buf = io.StringIO()
writer = csv.writer(buf, lineterminator="\n")
writer.writerow(["Reference", "Prefix", "Value", "Pins", "Subcircuit", "Attributes"])
for comp in netlist.top_level_components:
if not filter_component(comp, config, netlist):
continue
attrs = "; ".join(f"{k}={v}" for k, v in sorted(comp.attributes.items()))
writer.writerow([
comp.reference,
comp.prefix,
comp.value,
len(comp.pins) or len(comp.nodes),
comp.subcircuit_scope,
attrs,
])
# Include boundary components inside subcircuit definitions
for subckt_def in netlist.subcircuit_defs.values():
for comp in subckt_def.boundary_components:
if not filter_component(comp, config, netlist):
continue
attrs = "; ".join(f"{k}={v}" for k, v in sorted(comp.attributes.items()))
writer.writerow([
comp.reference,
comp.prefix,
comp.value,
len(comp.pins) or len(comp.nodes),
subckt_def.name,
attrs,
])
for inst in netlist.instances:
if not filter_instance(inst, config, netlist):
continue
attrs = "; ".join(f"{k}={v}" for k, v in sorted(inst.attributes.items()))
writer.writerow([
inst.reference,
"X",
inst.subcircuit_name,
len(inst.port_to_net),
"",
attrs,
])
return buf.getvalue()
def emit_wiring_bom(wireviz_dict: dict[str, Any]) -> str:
"""Generate a CSV bill of materials for cables/wires from mapped output.
Each row describes one cable with its name, wire count, net labels,
and connected endpoints.
Returns:
CSV string with header row.
"""
buf = io.StringIO()
writer = csv.writer(buf, lineterminator="\n")
writer.writerow(["Cable", "Wirecount", "Nets", "From", "To"])
cables = wireviz_dict.get("cables", {})
connections = wireviz_dict.get("connections", [])
# Build cable -> (from_connector, to_connector) mapping from connections
cable_endpoints: dict[str, tuple[str, str]] = {}
for conn_set in connections:
if len(conn_set) < 3:
continue
from_name = next(iter(conn_set[0]))
cable_name = next(iter(conn_set[1]))
to_name = next(iter(conn_set[2]))
cable_endpoints[cable_name] = (from_name, to_name)
for cable_name, cable_def in cables.items():
wirecount = cable_def.get("wirecount", 0)
if cable_def.get("colors"):
wirecount = max(wirecount, len(cable_def["colors"]))
wirelabels = cable_def.get("wirelabels", [])
nets = ", ".join(wirelabels) if wirelabels else ""
from_conn, to_conn = cable_endpoints.get(cable_name, ("", ""))
writer.writerow([cable_name, wirecount, nets, from_conn, to_conn])
return buf.getvalue()

View file

@ -46,6 +46,7 @@ def parse_asc(
*,
allow_ltspice_generation: bool = False,
ltspice_timeout: float = 30.0,
ltspice_exe: str | Path | None = None,
) -> AscParseResult:
"""Parse an LTspice .asc schematic file with tiered netlist resolution.
@ -58,6 +59,9 @@ def parse_asc(
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.
ltspice_exe: Explicit path to LTspice binary. When provided, calls
LTspice.create_from() to configure the simulator. When omitted,
spicelib uses its own auto-detection.
Returns:
AscParseResult with the parsed netlist, completeness level, and
@ -79,7 +83,7 @@ def parse_asc(
# Tier 2: LTspice generation (opt-in)
if allow_ltspice_generation:
result = _try_ltspice_generation(path, ltspice_timeout)
result = _try_ltspice_generation(path, ltspice_timeout, ltspice_exe)
if result is not None:
return result
@ -109,24 +113,34 @@ def _try_companion_netlist(asc_path: Path) -> AscParseResult | None:
def _try_ltspice_generation(
asc_path: Path, timeout: float
asc_path: Path,
timeout: float,
ltspice_exe: str | Path | None = None,
) -> AscParseResult | None:
"""Tier 2: Invoke LTspice to generate a netlist from the .asc file."""
try:
from spicelib.sim.simulator import Simulator
from spicelib.simulators.ltspice_simulator import LTspice
except ImportError:
return None
if not Simulator.is_available():
# Configure the binary path if explicitly provided
if ltspice_exe is not None:
exe_path = Path(ltspice_exe)
if not exe_path.exists():
print(f"LTspice binary not found: {exe_path}", file=sys.stderr)
return None
LTspice.create_from(str(exe_path))
if not LTspice.is_available():
print(
"LTspice binary not found on PATH; skipping netlist generation.",
"LTspice binary not found; skipping netlist generation. "
"Use --ltspice-exe to specify the path.",
file=sys.stderr,
)
return None
net_path = asc_path.with_suffix(".net")
try:
Simulator.run(str(asc_path), timeout=timeout)
net_path = LTspice.create_netlist(str(asc_path), timeout=timeout)
except TimeoutError as exc:
print(f"LTspice timed out after {timeout}s: {exc}", file=sys.stderr)
return None
@ -140,6 +154,7 @@ def _try_ltspice_generation(
)
return None
net_path = Path(net_path)
if not net_path.exists():
print(
f"LTspice did not produce expected output: {net_path}",