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
@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "spice2wireviz"
|
name = "spice2wireviz"
|
||||||
version = "2026.2.13"
|
version = "2026.2.14"
|
||||||
description = "Convert LTspice SPICE netlists to WireViz wiring diagrams"
|
description = "Convert LTspice SPICE netlists to WireViz wiring diagrams"
|
||||||
authors = [{name = "Ryan Malloy", email = "ryan@supported.systems"}]
|
authors = [{name = "Ryan Malloy", email = "ryan@supported.systems"}]
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
@ -52,3 +52,4 @@ select = ["E", "F", "I", "W", "UP", "B", "SIM", "RUF"]
|
|||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
|
markers = ["ltspice: requires LTspice binary installed locally"]
|
||||||
|
|||||||
@ -1,3 +1,3 @@
|
|||||||
"""spice2wireviz — Convert LTspice SPICE netlists to WireViz wiring diagrams."""
|
"""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
|
import click
|
||||||
|
|
||||||
from . import __version__
|
from . import __version__
|
||||||
|
from .emitter.bom_emitter import emit_component_bom, emit_wiring_bom
|
||||||
from .emitter.yaml_emitter import emit_yaml
|
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
|
||||||
@ -129,6 +130,22 @@ def _parse_comma_list(ctx: click.Context, param: click.Parameter, value: str | N
|
|||||||
is_flag=True,
|
is_flag=True,
|
||||||
help="For .asc files: invoke LTspice to generate a netlist if no companion .net exists.",
|
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__)
|
@click.version_option(version=__version__)
|
||||||
def main(
|
def main(
|
||||||
input_file: Path,
|
input_file: Path,
|
||||||
@ -152,8 +169,16 @@ def main(
|
|||||||
list_components: bool,
|
list_components: bool,
|
||||||
dry_run: bool,
|
dry_run: bool,
|
||||||
generate_netlist: bool,
|
generate_netlist: bool,
|
||||||
|
ltspice_exe: Path | None,
|
||||||
|
bom: bool,
|
||||||
|
bom_wiring: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Convert SPICE netlist to WireViz YAML wiring diagram."""
|
"""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) ---
|
# --- Parse input (format detection) ---
|
||||||
is_asc = input_file.suffix.lower() == ".asc"
|
is_asc = input_file.suffix.lower() == ".asc"
|
||||||
completeness = DataCompleteness.FULL # default for .net files
|
completeness = DataCompleteness.FULL # default for .net files
|
||||||
@ -163,6 +188,7 @@ def main(
|
|||||||
asc_result = parse_asc(
|
asc_result = parse_asc(
|
||||||
input_file,
|
input_file,
|
||||||
allow_ltspice_generation=generate_netlist,
|
allow_ltspice_generation=generate_netlist,
|
||||||
|
ltspice_exe=str(ltspice_exe) if ltspice_exe else None,
|
||||||
)
|
)
|
||||||
netlist = asc_result.netlist
|
netlist = asc_result.netlist
|
||||||
completeness = asc_result.completeness
|
completeness = asc_result.completeness
|
||||||
@ -234,6 +260,16 @@ def main(
|
|||||||
click.echo(f" {inst.reference} ({inst.subcircuit_name}): {nets}")
|
click.echo(f" {inst.reference} ({inst.subcircuit_name}): {nets}")
|
||||||
return
|
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 ---
|
# --- Safety gate: block diagram generation on METADATA_ONLY ---
|
||||||
if completeness == DataCompleteness.METADATA_ONLY:
|
if completeness == DataCompleteness.METADATA_ONLY:
|
||||||
click.echo(
|
click.echo(
|
||||||
@ -304,6 +340,16 @@ def main(
|
|||||||
else:
|
else:
|
||||||
wireviz_dict = map_inter_module(netlist, filter_config, meta)
|
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 ---
|
# --- Dry run ---
|
||||||
if dry_run:
|
if dry_run:
|
||||||
conn_count = len(wireviz_dict.get("connectors", {}))
|
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,
|
allow_ltspice_generation: bool = False,
|
||||||
ltspice_timeout: float = 30.0,
|
ltspice_timeout: float = 30.0,
|
||||||
|
ltspice_exe: str | Path | None = None,
|
||||||
) -> AscParseResult:
|
) -> AscParseResult:
|
||||||
"""Parse an LTspice .asc schematic file with tiered netlist resolution.
|
"""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
|
allow_ltspice_generation: If True, attempt to invoke LTspice when no
|
||||||
companion netlist is found. Requires LTspice binary on PATH.
|
companion netlist is found. Requires LTspice binary on PATH.
|
||||||
ltspice_timeout: Timeout in seconds for LTspice netlist generation.
|
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:
|
Returns:
|
||||||
AscParseResult with the parsed netlist, completeness level, and
|
AscParseResult with the parsed netlist, completeness level, and
|
||||||
@ -79,7 +83,7 @@ def parse_asc(
|
|||||||
|
|
||||||
# Tier 2: LTspice generation (opt-in)
|
# Tier 2: LTspice generation (opt-in)
|
||||||
if allow_ltspice_generation:
|
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:
|
if result is not None:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@ -109,24 +113,34 @@ def _try_companion_netlist(asc_path: Path) -> AscParseResult | None:
|
|||||||
|
|
||||||
|
|
||||||
def _try_ltspice_generation(
|
def _try_ltspice_generation(
|
||||||
asc_path: Path, timeout: float
|
asc_path: Path,
|
||||||
|
timeout: float,
|
||||||
|
ltspice_exe: str | Path | None = None,
|
||||||
) -> AscParseResult | None:
|
) -> AscParseResult | None:
|
||||||
"""Tier 2: Invoke LTspice to generate a netlist from the .asc file."""
|
"""Tier 2: Invoke LTspice to generate a netlist from the .asc file."""
|
||||||
try:
|
try:
|
||||||
from spicelib.sim.simulator import Simulator
|
from spicelib.simulators.ltspice_simulator import LTspice
|
||||||
except ImportError:
|
except ImportError:
|
||||||
return None
|
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(
|
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,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
net_path = asc_path.with_suffix(".net")
|
|
||||||
try:
|
try:
|
||||||
Simulator.run(str(asc_path), timeout=timeout)
|
net_path = LTspice.create_netlist(str(asc_path), timeout=timeout)
|
||||||
except TimeoutError as exc:
|
except TimeoutError as exc:
|
||||||
print(f"LTspice timed out after {timeout}s: {exc}", file=sys.stderr)
|
print(f"LTspice timed out after {timeout}s: {exc}", file=sys.stderr)
|
||||||
return None
|
return None
|
||||||
@ -140,6 +154,7 @@ def _try_ltspice_generation(
|
|||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
net_path = Path(net_path)
|
||||||
if not net_path.exists():
|
if not net_path.exists():
|
||||||
print(
|
print(
|
||||||
f"LTspice did not produce expected output: {net_path}",
|
f"LTspice did not produce expected output: {net_path}",
|
||||||
|
|||||||
96
tests/fixtures/1002A.asc
vendored
Normal file
96
tests/fixtures/1002A.asc
vendored
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
Version 4
|
||||||
|
SHEET 1 896 680
|
||||||
|
WIRE -256 64 -256 48
|
||||||
|
WIRE -128 64 -128 48
|
||||||
|
WIRE 288 64 128 64
|
||||||
|
WIRE 400 64 368 64
|
||||||
|
WIRE 432 64 400 64
|
||||||
|
WIRE 544 64 512 64
|
||||||
|
WIRE 32 112 16 112
|
||||||
|
WIRE 128 112 128 64
|
||||||
|
WIRE 128 112 112 112
|
||||||
|
WIRE 160 112 128 112
|
||||||
|
WIRE 272 112 240 112
|
||||||
|
WIRE -256 160 -256 144
|
||||||
|
WIRE -128 160 -128 144
|
||||||
|
WIRE 192 208 192 192
|
||||||
|
WIRE 128 224 128 112
|
||||||
|
WIRE 160 224 128 224
|
||||||
|
WIRE 448 224 448 208
|
||||||
|
WIRE 272 240 272 112
|
||||||
|
WIRE 272 240 224 240
|
||||||
|
WIRE 304 240 272 240
|
||||||
|
WIRE 400 240 400 64
|
||||||
|
WIRE 400 240 384 240
|
||||||
|
WIRE 416 240 400 240
|
||||||
|
WIRE 64 256 -96 256
|
||||||
|
WIRE 160 256 64 256
|
||||||
|
WIRE 544 256 544 64
|
||||||
|
WIRE 544 256 480 256
|
||||||
|
WIRE 592 256 544 256
|
||||||
|
WIRE -96 272 -96 256
|
||||||
|
WIRE 416 272 400 272
|
||||||
|
WIRE 192 288 192 272
|
||||||
|
WIRE 448 304 448 288
|
||||||
|
WIRE -96 384 -96 352
|
||||||
|
WIRE 160 384 -96 384
|
||||||
|
WIRE 400 384 400 272
|
||||||
|
WIRE 400 384 160 384
|
||||||
|
WIRE -96 400 -96 384
|
||||||
|
WIRE -96 496 -96 480
|
||||||
|
FLAG -128 160 0
|
||||||
|
FLAG 192 192 +V
|
||||||
|
FLAG -128 48 +V
|
||||||
|
FLAG 192 288 -V
|
||||||
|
FLAG -256 160 0
|
||||||
|
FLAG -256 48 -V
|
||||||
|
FLAG 16 112 0
|
||||||
|
FLAG -96 496 0
|
||||||
|
FLAG 448 208 +V
|
||||||
|
FLAG 448 304 -V
|
||||||
|
FLAG 64 256 IN-
|
||||||
|
FLAG 160 384 IN+
|
||||||
|
FLAG 592 256 OUT
|
||||||
|
SYMBOL voltage -128 48 R0
|
||||||
|
SYMATTR InstName V1
|
||||||
|
SYMATTR Value 15
|
||||||
|
SYMBOL voltage -256 48 R0
|
||||||
|
SYMATTR InstName V2
|
||||||
|
SYMATTR Value -15
|
||||||
|
SYMBOL res 256 96 R90
|
||||||
|
WINDOW 0 0 56 VBottom 2
|
||||||
|
WINDOW 3 32 56 VTop 2
|
||||||
|
SYMATTR InstName R1
|
||||||
|
SYMATTR Value 10K
|
||||||
|
SYMBOL res 128 96 R90
|
||||||
|
WINDOW 0 0 56 VBottom 2
|
||||||
|
WINDOW 3 32 56 VTop 2
|
||||||
|
SYMATTR InstName R2
|
||||||
|
SYMATTR Value 100K
|
||||||
|
SYMBOL voltage -96 256 R0
|
||||||
|
SYMATTR InstName V3
|
||||||
|
SYMATTR Value SINE(0 1m 100)
|
||||||
|
SYMBOL res 384 48 R90
|
||||||
|
WINDOW 0 0 56 VBottom 2
|
||||||
|
WINDOW 3 32 56 VTop 2
|
||||||
|
SYMATTR InstName R3
|
||||||
|
SYMATTR Value 2.2K
|
||||||
|
SYMBOL res 400 224 R90
|
||||||
|
WINDOW 0 0 56 VBottom 2
|
||||||
|
WINDOW 3 32 56 VTop 2
|
||||||
|
SYMATTR InstName R4
|
||||||
|
SYMATTR Value 10K
|
||||||
|
SYMBOL res 528 48 R90
|
||||||
|
WINDOW 0 0 56 VBottom 2
|
||||||
|
WINDOW 3 32 56 VTop 2
|
||||||
|
SYMATTR InstName R5
|
||||||
|
SYMATTR Value 100K
|
||||||
|
SYMBOL voltage -96 384 R0
|
||||||
|
SYMATTR InstName V4
|
||||||
|
SYMATTR Value SINE(0 1 10)
|
||||||
|
SYMBOL opamps\\LT1002A 192 176 R0
|
||||||
|
SYMATTR InstName U1
|
||||||
|
SYMBOL opamps\\LT1002A 448 192 R0
|
||||||
|
SYMATTR InstName U2
|
||||||
|
TEXT 440 392 Left 2 !.tran 300m
|
||||||
|
TEXT -48 -16 Left 2 ;Two Op Amp Instrumentation Amplifier
|
||||||
19
tests/fixtures/1002A.net
vendored
Normal file
19
tests/fixtures/1002A.net
vendored
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
* Z:\tmp\1002A.asc
|
||||||
|
* Generated by LTspice 26.0.1 for Windows.
|
||||||
|
V1 +V 0 15
|
||||||
|
V2 -V 0 -15
|
||||||
|
R1 N003 N001 10K
|
||||||
|
R2 N001 0 100K
|
||||||
|
V3 IN- IN+ SINE(0 1m 100)
|
||||||
|
R3 N002 N001 2.2K
|
||||||
|
R4 N002 N003 10K
|
||||||
|
R5 OUT N002 100K
|
||||||
|
V4 IN+ 0 SINE(0 1 10)
|
||||||
|
X§U1 IN- N001 +V -V N003 LT1001 ;§pnba In+)In-)V+)V-)OUT
|
||||||
|
X§U2 IN+ N002 +V -V OUT LT1001 ;§pnba In+)In-)V+)V-)OUT
|
||||||
|
.tran 300m
|
||||||
|
* Two Op Amp Instrumentation Amplifier
|
||||||
|
* Library below included based on ModelFile attribute of instance X§U1, X§U2 (C:\users\rpm\AppData\Local\LTspice\lib\sym\OpAmps\LT1002A.asy)
|
||||||
|
.lib LTC.lib
|
||||||
|
.backanno
|
||||||
|
.end
|
||||||
@ -206,8 +206,7 @@ class TestLTspiceGeneration:
|
|||||||
result = parse_asc(asc)
|
result = parse_asc(asc)
|
||||||
assert result.completeness == DataCompleteness.METADATA_ONLY
|
assert result.completeness == DataCompleteness.METADATA_ONLY
|
||||||
|
|
||||||
@patch("spice2wireviz.parser.asc.Simulator", create=True)
|
def test_ltspice_generation_success(self, tmp_path):
|
||||||
def test_ltspice_generation_success(self, mock_sim_cls, tmp_path):
|
|
||||||
"""Mocked LTspice successfully generates a .net file."""
|
"""Mocked LTspice successfully generates a .net file."""
|
||||||
asc = tmp_path / "test.asc"
|
asc = tmp_path / "test.asc"
|
||||||
asc.write_text("Version 4\nSHEET 1 880 680\n")
|
asc.write_text("Version 4\nSHEET 1 880 680\n")
|
||||||
@ -220,7 +219,7 @@ class TestLTspiceGeneration:
|
|||||||
".ends gen_mod\n"
|
".ends gen_mod\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Mock the import path inside _try_ltspice_generation
|
# Mock the whole Tier 2 function
|
||||||
with patch(
|
with patch(
|
||||||
"spice2wireviz.parser.asc._try_ltspice_generation"
|
"spice2wireviz.parser.asc._try_ltspice_generation"
|
||||||
) as mock_tier2:
|
) as mock_tier2:
|
||||||
@ -279,3 +278,89 @@ class TestCompanionNetlistInternalHelper:
|
|||||||
assert result is not None
|
assert result is not None
|
||||||
assert result.completeness == DataCompleteness.FULL
|
assert result.completeness == DataCompleteness.FULL
|
||||||
assert result.source_net == net
|
assert result.source_net == net
|
||||||
|
|
||||||
|
|
||||||
|
class TestRealAscIntegration:
|
||||||
|
"""Integration tests with a real LTspice-generated .asc / .net pair.
|
||||||
|
|
||||||
|
1002A.asc is a two op-amp instrumentation amplifier from the LTspice
|
||||||
|
demo circuit archive. The companion 1002A.net was pre-generated by
|
||||||
|
LTspice so these tests work in CI without LTspice installed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_real_asc_companion_resolution(self):
|
||||||
|
"""Tier 1: 1002A.asc resolves to companion 1002A.net."""
|
||||||
|
result = parse_asc(FIXTURES / "1002A.asc")
|
||||||
|
assert result.completeness == DataCompleteness.FULL
|
||||||
|
assert result.source_net is not None
|
||||||
|
assert result.source_net.name == "1002A.net"
|
||||||
|
|
||||||
|
def test_real_asc_has_components(self):
|
||||||
|
"""The parsed netlist has X* instances (op-amps)."""
|
||||||
|
result = parse_asc(FIXTURES / "1002A.asc")
|
||||||
|
netlist = result.netlist
|
||||||
|
# 1002A.net has X§U1 and X§U2 (LTspice op-amp instances)
|
||||||
|
x_refs = [inst.reference for inst in netlist.instances]
|
||||||
|
assert len(x_refs) >= 2
|
||||||
|
# The § character is part of LTspice's hierarchical naming
|
||||||
|
assert any("U1" in ref for ref in x_refs)
|
||||||
|
assert any("U2" in ref for ref in x_refs)
|
||||||
|
|
||||||
|
def test_real_asc_has_nets(self):
|
||||||
|
"""The parsed netlist has known nets from the circuit."""
|
||||||
|
result = parse_asc(FIXTURES / "1002A.asc")
|
||||||
|
netlist = result.netlist
|
||||||
|
assert len(netlist.all_nets) > 0
|
||||||
|
# The circuit uses +V, -V, OUT, IN+, IN- as net names
|
||||||
|
net_names = {n.upper() for n in netlist.all_nets}
|
||||||
|
assert "+V" in net_names or "V+" in net_names or any("V" in n for n in net_names)
|
||||||
|
|
||||||
|
def test_real_asc_no_warnings_on_companion(self):
|
||||||
|
"""Companion resolution should produce no warnings."""
|
||||||
|
result = parse_asc(FIXTURES / "1002A.asc")
|
||||||
|
# Warnings are acceptable for enrichment, but not for core parsing
|
||||||
|
# Only check that we didn't get "no companion" warnings
|
||||||
|
for w in result.warnings:
|
||||||
|
assert "no companion" not in w.lower()
|
||||||
|
|
||||||
|
@pytest.mark.ltspice
|
||||||
|
def test_real_asc_ltspice_generation(self, tmp_path):
|
||||||
|
"""Tier 2: generate .net from .asc using LTspice binary.
|
||||||
|
|
||||||
|
Requires LTspice installed. Skipped in CI.
|
||||||
|
"""
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
ltspice_path = Path("/home/rpm/.local/bin/ltspice")
|
||||||
|
if not ltspice_path.exists():
|
||||||
|
pytest.skip("LTspice binary not found")
|
||||||
|
|
||||||
|
# Copy .asc to temp dir (avoid polluting fixtures)
|
||||||
|
asc_copy = tmp_path / "1002A.asc"
|
||||||
|
shutil.copy2(FIXTURES / "1002A.asc", asc_copy)
|
||||||
|
|
||||||
|
# Remove any existing .net so Tier 2 is forced
|
||||||
|
net_copy = tmp_path / "1002A.net"
|
||||||
|
if net_copy.exists():
|
||||||
|
net_copy.unlink()
|
||||||
|
|
||||||
|
result = parse_asc(
|
||||||
|
asc_copy,
|
||||||
|
allow_ltspice_generation=True,
|
||||||
|
ltspice_exe=str(ltspice_path),
|
||||||
|
)
|
||||||
|
assert result.completeness == DataCompleteness.FULL
|
||||||
|
assert result.source_net is not None
|
||||||
|
assert result.source_net.name == "1002A.net"
|
||||||
|
|
||||||
|
def test_ltspice_exe_nonexistent_path(self, tmp_path):
|
||||||
|
"""--ltspice-exe with a bad path falls through to Tier 3."""
|
||||||
|
asc = tmp_path / "test.asc"
|
||||||
|
asc.write_text("Version 4\nSHEET 1 880 680\n")
|
||||||
|
|
||||||
|
result = parse_asc(
|
||||||
|
asc,
|
||||||
|
allow_ltspice_generation=True,
|
||||||
|
ltspice_exe="/nonexistent/ltspice",
|
||||||
|
)
|
||||||
|
assert result.completeness == DataCompleteness.METADATA_ONLY
|
||||||
|
|||||||
222
tests/test_bom_emitter.py
Normal file
222
tests/test_bom_emitter.py
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
"""Tests for the BOM CSV emitter."""
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
from spice2wireviz.cli import main
|
||||||
|
from spice2wireviz.emitter.bom_emitter import emit_component_bom, emit_wiring_bom
|
||||||
|
from spice2wireviz.filter import FilterConfig
|
||||||
|
from spice2wireviz.mapper.inter_module import map_inter_module
|
||||||
|
from spice2wireviz.parser.models import (
|
||||||
|
ParsedNetlist,
|
||||||
|
SpiceComponent,
|
||||||
|
SpicePin,
|
||||||
|
)
|
||||||
|
from spice2wireviz.parser.netlist import parse_netlist
|
||||||
|
|
||||||
|
FIXTURES = Path(__file__).parent / "fixtures"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_csv(csv_str: str) -> list[dict[str, str]]:
|
||||||
|
"""Parse a CSV string into a list of dicts."""
|
||||||
|
reader = csv.DictReader(io.StringIO(csv_str))
|
||||||
|
return list(reader)
|
||||||
|
|
||||||
|
|
||||||
|
class TestComponentBom:
|
||||||
|
def test_simple_board_bom(self):
|
||||||
|
"""Component BOM from simple_board.net has expected connectors."""
|
||||||
|
netlist = parse_netlist(FIXTURES / "simple_board.net")
|
||||||
|
config = FilterConfig()
|
||||||
|
csv_str = emit_component_bom(netlist, config)
|
||||||
|
|
||||||
|
rows = _parse_csv(csv_str)
|
||||||
|
assert len(rows) > 0
|
||||||
|
|
||||||
|
# Check CSV is parseable and has expected columns
|
||||||
|
assert "Reference" in rows[0]
|
||||||
|
assert "Prefix" in rows[0]
|
||||||
|
assert "Value" in rows[0]
|
||||||
|
|
||||||
|
def test_bom_respects_filters(self):
|
||||||
|
"""Filter config limits which components appear in BOM."""
|
||||||
|
netlist = parse_netlist(FIXTURES / "multi_board.net")
|
||||||
|
# Only include J-prefix components
|
||||||
|
config = FilterConfig(include_prefixes=["J"])
|
||||||
|
csv_str = emit_component_bom(netlist, config)
|
||||||
|
|
||||||
|
rows = _parse_csv(csv_str)
|
||||||
|
for row in rows:
|
||||||
|
assert row["Prefix"] == "J"
|
||||||
|
|
||||||
|
def test_bom_includes_subcircuit_boundary_components(self):
|
||||||
|
"""Components inside .subckt definitions are included."""
|
||||||
|
netlist = parse_netlist(FIXTURES / "simple_board.net")
|
||||||
|
config = FilterConfig()
|
||||||
|
csv_str = emit_component_bom(netlist, config)
|
||||||
|
|
||||||
|
rows = _parse_csv(csv_str)
|
||||||
|
subcircuit_refs = [r for r in rows if r["Subcircuit"]]
|
||||||
|
assert len(subcircuit_refs) > 0
|
||||||
|
|
||||||
|
def test_bom_includes_instances(self):
|
||||||
|
"""X* instances appear in BOM with subcircuit name as value."""
|
||||||
|
netlist = parse_netlist(FIXTURES / "multi_board.net")
|
||||||
|
config = FilterConfig()
|
||||||
|
csv_str = emit_component_bom(netlist, config)
|
||||||
|
|
||||||
|
rows = _parse_csv(csv_str)
|
||||||
|
x_rows = [r for r in rows if r["Prefix"] == "X"]
|
||||||
|
assert len(x_rows) > 0
|
||||||
|
# Value column should contain the subcircuit name
|
||||||
|
for row in x_rows:
|
||||||
|
assert row["Value"] # not empty
|
||||||
|
|
||||||
|
def test_empty_netlist_produces_header_only(self):
|
||||||
|
"""An empty netlist produces CSV with just the header."""
|
||||||
|
netlist = ParsedNetlist()
|
||||||
|
config = FilterConfig()
|
||||||
|
csv_str = emit_component_bom(netlist, config)
|
||||||
|
|
||||||
|
rows = _parse_csv(csv_str)
|
||||||
|
assert len(rows) == 0
|
||||||
|
|
||||||
|
# But header should exist
|
||||||
|
lines = csv_str.strip().split("\n")
|
||||||
|
assert len(lines) == 1
|
||||||
|
assert "Reference" in lines[0]
|
||||||
|
|
||||||
|
def test_bom_preserves_attributes(self):
|
||||||
|
"""Component attributes appear in the Attributes column."""
|
||||||
|
netlist = ParsedNetlist(
|
||||||
|
top_level_components=[
|
||||||
|
SpiceComponent(
|
||||||
|
reference="J1", prefix="J", value="DB9", nodes=["A", "B"],
|
||||||
|
pins=[
|
||||||
|
SpicePin(name="A", index=1, net_name="A"),
|
||||||
|
SpicePin(name="B", index=2, net_name="B"),
|
||||||
|
],
|
||||||
|
attributes={"Footprint": "DSUB-9", "MPN": "DE-9S"},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
config = FilterConfig()
|
||||||
|
csv_str = emit_component_bom(netlist, config)
|
||||||
|
|
||||||
|
rows = _parse_csv(csv_str)
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert "Footprint=DSUB-9" in rows[0]["Attributes"]
|
||||||
|
assert "MPN=DE-9S" in rows[0]["Attributes"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestWiringBom:
|
||||||
|
def test_multi_board_wiring_bom(self):
|
||||||
|
"""Wiring BOM from mapped multi_board.net has cables."""
|
||||||
|
netlist = parse_netlist(FIXTURES / "multi_board.net")
|
||||||
|
config = FilterConfig()
|
||||||
|
wireviz_dict = map_inter_module(netlist, config)
|
||||||
|
csv_str = emit_wiring_bom(wireviz_dict)
|
||||||
|
|
||||||
|
rows = _parse_csv(csv_str)
|
||||||
|
assert len(rows) > 0
|
||||||
|
assert "Cable" in rows[0]
|
||||||
|
assert "Wirecount" in rows[0]
|
||||||
|
assert "From" in rows[0]
|
||||||
|
assert "To" in rows[0]
|
||||||
|
|
||||||
|
def test_wiring_bom_has_net_labels(self):
|
||||||
|
"""Cables include net name labels."""
|
||||||
|
netlist = parse_netlist(FIXTURES / "multi_board.net")
|
||||||
|
config = FilterConfig()
|
||||||
|
wireviz_dict = map_inter_module(netlist, config)
|
||||||
|
csv_str = emit_wiring_bom(wireviz_dict)
|
||||||
|
|
||||||
|
rows = _parse_csv(csv_str)
|
||||||
|
# At least some cables should have net labels
|
||||||
|
nets_found = [r for r in rows if r["Nets"]]
|
||||||
|
assert len(nets_found) > 0
|
||||||
|
|
||||||
|
def test_wiring_bom_endpoints(self):
|
||||||
|
"""Each cable connects two endpoints."""
|
||||||
|
netlist = parse_netlist(FIXTURES / "multi_board.net")
|
||||||
|
config = FilterConfig()
|
||||||
|
wireviz_dict = map_inter_module(netlist, config)
|
||||||
|
csv_str = emit_wiring_bom(wireviz_dict)
|
||||||
|
|
||||||
|
rows = _parse_csv(csv_str)
|
||||||
|
for row in rows:
|
||||||
|
assert row["From"] # not empty
|
||||||
|
assert row["To"] # not empty
|
||||||
|
|
||||||
|
def test_empty_wireviz_produces_header_only(self):
|
||||||
|
"""An empty wireviz dict produces CSV with just the header."""
|
||||||
|
csv_str = emit_wiring_bom({})
|
||||||
|
|
||||||
|
rows = _parse_csv(csv_str)
|
||||||
|
assert len(rows) == 0
|
||||||
|
|
||||||
|
lines = csv_str.strip().split("\n")
|
||||||
|
assert len(lines) == 1
|
||||||
|
assert "Cable" in lines[0]
|
||||||
|
|
||||||
|
|
||||||
|
class TestBomCli:
|
||||||
|
"""CLI integration for --bom and --bom-wiring flags."""
|
||||||
|
|
||||||
|
def test_bom_flag(self):
|
||||||
|
"""--bom produces CSV output."""
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
main, [str(FIXTURES / "simple_board.net"), "--bom"]
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Reference" in result.output
|
||||||
|
# Should be CSV, not YAML
|
||||||
|
assert "connectors:" not in result.output
|
||||||
|
|
||||||
|
def test_bom_wiring_flag(self):
|
||||||
|
"""--bom-wiring produces cable CSV."""
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
main,
|
||||||
|
[str(FIXTURES / "multi_board.net"), "--bom-wiring"],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Cable" in result.output
|
||||||
|
assert "Wirecount" in result.output
|
||||||
|
# Should be CSV, not YAML
|
||||||
|
assert "connectors:" not in result.output
|
||||||
|
|
||||||
|
def test_bom_wiring_with_subcircuit(self):
|
||||||
|
"""--bom-wiring with -s uses single-module mapping."""
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
main,
|
||||||
|
[str(FIXTURES / "simple_board.net"), "-s", "amplifier_board", "--bom-wiring"],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Cable" in result.output
|
||||||
|
|
||||||
|
def test_bom_and_bom_wiring_mutually_exclusive(self):
|
||||||
|
"""--bom and --bom-wiring cannot be used together."""
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
main,
|
||||||
|
[str(FIXTURES / "simple_board.net"), "--bom", "--bom-wiring"],
|
||||||
|
)
|
||||||
|
assert result.exit_code != 0
|
||||||
|
|
||||||
|
def test_bom_to_file(self, tmp_path):
|
||||||
|
"""--bom with -o writes to file."""
|
||||||
|
out = tmp_path / "bom.csv"
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
main, [str(FIXTURES / "simple_board.net"), "--bom", "-o", str(out)]
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert out.exists()
|
||||||
|
content = out.read_text()
|
||||||
|
assert "Reference" in content
|
||||||
@ -14,7 +14,7 @@ class TestCLIBasic:
|
|||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(main, ["--version"])
|
result = runner.invoke(main, ["--version"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "2026.2.13" in result.output
|
assert "2026.2.14" in result.output
|
||||||
|
|
||||||
def test_list_subcircuits(self):
|
def test_list_subcircuits(self):
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
|
|||||||
2
uv.lock
generated
2
uv.lock
generated
@ -994,7 +994,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "spice2wireviz"
|
name = "spice2wireviz"
|
||||||
version = "2026.2.13"
|
version = "2026.2.14"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "click" },
|
{ name = "click" },
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user