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:
parent
08c92bfefb
commit
5a5337566c
11 changed files with 607 additions and 14 deletions
|
|
@ -1,3 +1,3 @@
|
|||
"""spice2wireviz — Convert LTspice SPICE netlists to WireViz wiring diagrams."""
|
||||
|
||||
__version__ = "2026.2.13"
|
||||
__version__ = "2026.2.14"
|
||||
|
|
|
|||
|
|
@ -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", {}))
|
||||
|
|
|
|||
109
src/spice2wireviz/emitter/bom_emitter.py
Normal file
109
src/spice2wireviz/emitter/bom_emitter.py
Normal 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()
|
||||
|
|
@ -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}",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue