From e20a956f51a6809a53065b3ddd1c0f4b136184d2 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Fri, 13 Feb 2026 01:24:41 -0700 Subject: [PATCH] Initial project structure for spice2wireviz SPICE netlist to WireViz YAML converter with: - Custom lightweight netlist parser (.net/.cir/.sp) - Single-module mapper (subcircuit external interface) - Inter-module mapper (multi-board wiring) - Filter engine with glob patterns - Click CLI with auto-detection, inspection commands - Optional .asc parser via spicelib - Comprehensive test suite with fixtures --- pyproject.toml | 37 +++ src/spice2wireviz/__init__.py | 3 + src/spice2wireviz/cli.py | 313 +++++++++++++++++++ src/spice2wireviz/emitter/__init__.py | 0 src/spice2wireviz/emitter/yaml_emitter.py | 166 ++++++++++ src/spice2wireviz/filter.py | 164 ++++++++++ src/spice2wireviz/mapper/__init__.py | 0 src/spice2wireviz/mapper/inter_module.py | 183 +++++++++++ src/spice2wireviz/mapper/single_module.py | 148 +++++++++ src/spice2wireviz/parser/__init__.py | 0 src/spice2wireviz/parser/asc.py | 90 ++++++ src/spice2wireviz/parser/models.py | 103 +++++++ src/spice2wireviz/parser/netlist.py | 356 ++++++++++++++++++++++ tests/__init__.py | 0 tests/fixtures/hierarchical.net | 40 +++ tests/fixtures/multi_board.net | 27 ++ tests/fixtures/simple_board.net | 14 + tests/test_cli.py | 130 ++++++++ tests/test_filter.py | 167 ++++++++++ tests/test_inter_module.py | 145 +++++++++ tests/test_netlist_parser.py | 171 +++++++++++ tests/test_roundtrip.py | 145 +++++++++ tests/test_single_module.py | 104 +++++++ tests/test_yaml_emitter.py | 124 ++++++++ 24 files changed, 2630 insertions(+) create mode 100644 pyproject.toml create mode 100644 src/spice2wireviz/__init__.py create mode 100644 src/spice2wireviz/cli.py create mode 100644 src/spice2wireviz/emitter/__init__.py create mode 100644 src/spice2wireviz/emitter/yaml_emitter.py create mode 100644 src/spice2wireviz/filter.py create mode 100644 src/spice2wireviz/mapper/__init__.py create mode 100644 src/spice2wireviz/mapper/inter_module.py create mode 100644 src/spice2wireviz/mapper/single_module.py create mode 100644 src/spice2wireviz/parser/__init__.py create mode 100644 src/spice2wireviz/parser/asc.py create mode 100644 src/spice2wireviz/parser/models.py create mode 100644 src/spice2wireviz/parser/netlist.py create mode 100644 tests/__init__.py create mode 100644 tests/fixtures/hierarchical.net create mode 100644 tests/fixtures/multi_board.net create mode 100644 tests/fixtures/simple_board.net create mode 100644 tests/test_cli.py create mode 100644 tests/test_filter.py create mode 100644 tests/test_inter_module.py create mode 100644 tests/test_netlist_parser.py create mode 100644 tests/test_roundtrip.py create mode 100644 tests/test_single_module.py create mode 100644 tests/test_yaml_emitter.py diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4ea078b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,37 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "spice2wireviz" +version = "2026-02-13" +description = "Convert LTspice SPICE netlists to WireViz wiring diagrams" +authors = [{name = "Ryan Malloy", email = "ryan@supported.systems"}] +requires-python = ">=3.11" +license = {text = "MIT"} +readme = "README.md" +dependencies = [ + "pyyaml>=6.0", + "click>=8.0", + "pydantic>=2.0", +] + +[project.optional-dependencies] +asc = ["spicelib>=1.4.9"] +dev = ["ruff", "pytest", "pytest-cov"] + +[project.scripts] +spice2wireviz = "spice2wireviz.cli:main" + +[tool.hatch.build.targets.wheel] +packages = ["src/spice2wireviz"] + +[tool.ruff] +target-version = "py311" +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "I", "W", "UP", "B", "SIM", "RUF"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/src/spice2wireviz/__init__.py b/src/spice2wireviz/__init__.py new file mode 100644 index 0000000..8cd59ad --- /dev/null +++ b/src/spice2wireviz/__init__.py @@ -0,0 +1,3 @@ +"""spice2wireviz — Convert LTspice SPICE netlists to WireViz wiring diagrams.""" + +__version__ = "2026-02-13" diff --git a/src/spice2wireviz/cli.py b/src/spice2wireviz/cli.py new file mode 100644 index 0000000..c50f255 --- /dev/null +++ b/src/spice2wireviz/cli.py @@ -0,0 +1,313 @@ +"""Click CLI for spice2wireviz. + +Converts SPICE netlists to WireViz YAML wiring diagrams. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import click + +from . import __version__ +from .emitter.yaml_emitter import emit_yaml +from .filter import FilterConfig, apply_filters +from .mapper.inter_module import map_inter_module +from .mapper.single_module import map_single_module +from .parser.netlist import parse_netlist + + +def _parse_comma_list(ctx: click.Context, param: click.Parameter, value: str | None) -> list[str]: + """Click callback to parse comma-separated values.""" + if not value: + return [] + return [v.strip() for v in value.split(",") if v.strip()] + + +@click.command() +@click.argument("input_file", type=click.Path(exists=True, path_type=Path)) +@click.option( + "-m", + "--mode", + type=click.Choice(["single", "inter"], case_sensitive=False), + default=None, + help="Operating mode (auto-detect if omitted).", +) +@click.option( + "-s", + "--subcircuit", + default=None, + help="Subcircuit name for single-module mode.", +) +@click.option( + "--include-prefixes", + default=None, + callback=_parse_comma_list, + help="Component prefixes to include (default: J,TP,P,X).", +) +@click.option( + "--exclude-prefixes", + default=None, + callback=_parse_comma_list, + help="Component prefixes to exclude.", +) +@click.option( + "--include-refs", + default=None, + callback=_parse_comma_list, + help="Specific references to include.", +) +@click.option( + "--exclude-refs", + default=None, + callback=_parse_comma_list, + help="Specific references to exclude.", +) +@click.option( + "--include-nets", + default=None, + callback=_parse_comma_list, + help="Net name glob patterns to include.", +) +@click.option( + "--exclude-nets", + default=None, + callback=_parse_comma_list, + help="Net name glob patterns to exclude.", +) +@click.option( + "--include-subcircuits", + default=None, + callback=_parse_comma_list, + help="Subcircuit names to include.", +) +@click.option( + "--exclude-subcircuits", + default=None, + callback=_parse_comma_list, + help="Subcircuit names to exclude.", +) +@click.option("--no-ground", is_flag=True, help="Hide GND connections.") +@click.option("--no-power", is_flag=True, help="Hide power connections.") +@click.option( + "--no-group", + is_flag=True, + help="Don't group parallel wires into multi-wire cables.", +) +@click.option( + "-o", + "--output", + type=click.Path(path_type=Path), + default=None, + help="Output YAML file (default: stdout).", +) +@click.option( + "--render", + is_flag=True, + help="Also run WireViz to generate diagram.", +) +@click.option( + "--format", + "output_formats", + default="hps", + help="WireViz output format flags with --render (h=html, p=png, s=svg, default: hps).", +) +@click.option( + "--list-subcircuits", + is_flag=True, + help="List all .subckt definitions and exit.", +) +@click.option( + "--list-components", + is_flag=True, + help="List all components matching filters and exit.", +) +@click.option("--dry-run", is_flag=True, help="Show mapping summary without generating YAML.") +@click.version_option(version=__version__) +def main( + input_file: Path, + mode: str | None, + subcircuit: str | None, + include_prefixes: list[str], + exclude_prefixes: list[str], + include_refs: list[str], + exclude_refs: list[str], + include_nets: list[str], + exclude_nets: list[str], + include_subcircuits: list[str], + exclude_subcircuits: list[str], + no_ground: bool, + no_power: bool, + no_group: bool, + output: Path | None, + render: bool, + output_formats: str, + list_subcircuits: bool, + list_components: bool, + dry_run: bool, +) -> None: + """Convert SPICE netlist to WireViz YAML wiring diagram.""" + # Parse the netlist + try: + netlist = parse_netlist(input_file) + except FileNotFoundError as exc: + click.echo(f"Error: {exc}", err=True) + sys.exit(1) + + # --- Inspection commands --- + if list_subcircuits: + names = netlist.list_subcircuit_names() + if not names: + click.echo("No subcircuit definitions found.") + else: + for name in names: + subckt = netlist.subcircuit_defs[name] + ports = ", ".join(subckt.port_names) + boundary_count = len(subckt.boundary_components) + click.echo(f" {name}: ports=[{ports}], boundary_components={boundary_count}") + return + + # Build filter config + filter_config = FilterConfig( + include_prefixes=include_prefixes or ["J", "TP", "P", "X"], + exclude_prefixes=exclude_prefixes, + include_refs=include_refs, + exclude_refs=exclude_refs, + include_nets=include_nets, + exclude_nets=exclude_nets, + include_subcircuits=include_subcircuits, + exclude_subcircuits=exclude_subcircuits, + show_ground=not no_ground, + show_power=not no_power, + group_parallel_wires=not no_group, + ) + + if list_components: + filtered = apply_filters(netlist, filter_config) + if filtered.top_level_components: + click.echo("Top-level components:") + for comp in filtered.top_level_components: + click.echo(f" {comp.reference} ({comp.prefix}): nodes={comp.nodes}") + if filtered.instances: + click.echo("Subcircuit instances:") + for inst in filtered.instances: + nets = ", ".join(f"{k}={v}" for k, v in inst.port_to_net.items()) + click.echo(f" {inst.reference} ({inst.subcircuit_name}): {nets}") + return + + # --- Auto-detect mode --- + if mode is None: + if subcircuit: + mode = "single" + elif netlist.instances: + mode = "inter" + elif netlist.subcircuit_defs: + # Has subcircuit defs but no top-level instances — default to first subcircuit + mode = "single" + subcircuit = netlist.list_subcircuit_names()[0] + click.echo( + f"Auto-detected single-module mode for subcircuit '{subcircuit}'", + err=True, + ) + else: + click.echo( + "Error: no subcircuit instances or definitions found. " + "Use --mode and --subcircuit to specify explicitly.", + err=True, + ) + sys.exit(1) + + # --- Build metadata --- + meta = { + "title": f"Wiring diagram: {input_file.stem}", + "source": str(input_file), + "generator": f"spice2wireviz {__version__}", + } + + # --- Map to WireViz --- + if mode == "single": + if not subcircuit: + names = netlist.list_subcircuit_names() + if len(names) == 1: + subcircuit = names[0] + else: + click.echo( + "Error: single-module mode requires --subcircuit. " + f"Available: {', '.join(names)}", + err=True, + ) + sys.exit(1) + + try: + wireviz_dict = map_single_module(netlist, subcircuit, filter_config, meta) + except ValueError as exc: + click.echo(f"Error: {exc}", err=True) + sys.exit(1) + else: + wireviz_dict = map_inter_module(netlist, filter_config, meta) + + # --- Dry run --- + if dry_run: + conn_count = len(wireviz_dict.get("connectors", {})) + cable_count = len(wireviz_dict.get("cables", {})) + conn_set_count = len(wireviz_dict.get("connections", [])) + click.echo(f"Mode: {mode}") + click.echo(f"Connectors: {conn_count}") + click.echo(f"Cables: {cable_count}") + click.echo(f"Connection sets: {conn_set_count}") + if wireviz_dict.get("connectors"): + click.echo("Connector names: " + ", ".join(wireviz_dict["connectors"].keys())) + return + + # --- Emit YAML --- + yaml_str = emit_yaml(wireviz_dict) + + if output: + output.write_text(yaml_str, encoding="utf-8") + click.echo(f"Wrote {output}", err=True) + else: + click.echo(yaml_str) + + # --- Optional render --- + if render: + _render_wireviz(wireviz_dict, output, output_formats) + + +def _render_wireviz( + wireviz_dict: dict, output: Path | None, format_flags: str +) -> None: + """Invoke WireViz to render the diagram.""" + try: + from wireviz.wireviz import parse as wv_parse + except ImportError: + click.echo( + "Error: WireViz not installed. Install it with: pip install wireviz", + err=True, + ) + sys.exit(1) + + # Map format flags to WireViz output_formats + format_map = {"h": "html", "p": "png", "s": "svg", "g": "gv", "c": "csv", "t": "tsv"} + formats = tuple(format_map[f] for f in format_flags.lower() if f in format_map) + + if not formats: + formats = ("html", "png", "svg") + + output_dir = output.parent if output else Path.cwd() + output_name = output.stem if output else "spice2wireviz_output" + + try: + wv_parse( + wireviz_dict, + output_formats=formats, + output_dir=str(output_dir), + output_name=output_name, + ) + click.echo( + f"Rendered: {', '.join(f'{output_name}.{fmt}' for fmt in formats)}", + err=True, + ) + except Exception as exc: + click.echo(f"WireViz render error: {exc}", err=True) + sys.exit(1) diff --git a/src/spice2wireviz/emitter/__init__.py b/src/spice2wireviz/emitter/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/spice2wireviz/emitter/yaml_emitter.py b/src/spice2wireviz/emitter/yaml_emitter.py new file mode 100644 index 0000000..3192780 --- /dev/null +++ b/src/spice2wireviz/emitter/yaml_emitter.py @@ -0,0 +1,166 @@ +"""WireViz YAML generation from mapped connector/cable/connection data. + +Produces deterministic YAML output: sorted keys, stable ordering, +byte-identical output for identical input. Every generated element +includes traceability notes with the source SPICE reference and net. +""" + +from __future__ import annotations + +from collections import OrderedDict +from typing import Any + +import yaml + + +class _OrderedDumper(yaml.SafeDumper): + """YAML dumper that preserves dict insertion order and uses block style.""" + pass + + +def _dict_representer(dumper: yaml.Dumper, data: dict) -> yaml.Node: + return dumper.represent_mapping(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, data.items()) + + +_OrderedDumper.add_representer(dict, _dict_representer) +_OrderedDumper.add_representer(OrderedDict, _dict_representer) + + +def emit_yaml(wireviz_dict: dict[str, Any]) -> str: + """Serialize a WireViz dict to deterministic YAML string. + + Args: + wireviz_dict: Dict with keys like 'connectors', 'cables', 'connections', etc. + + Returns: + YAML string ready to write to file or feed to wireviz.parse(). + """ + return yaml.dump( + wireviz_dict, + Dumper=_OrderedDumper, + default_flow_style=False, + sort_keys=False, + allow_unicode=True, + width=120, + ) + + +def build_connector( + name: str, + *, + pinlabels: list[str] | None = None, + pincount: int | None = None, + pins: list[int] | None = None, + connector_type: str = "", + subtype: str = "", + notes: str = "", + style: str = "", + color: str = "", +) -> dict[str, Any]: + """Build a WireViz connector definition dict. + + At least one of pinlabels, pincount, or pins must be provided. + """ + conn: dict[str, Any] = {} + + if connector_type: + conn["type"] = connector_type + if subtype: + conn["subtype"] = subtype + if color: + conn["color"] = color + if style: + conn["style"] = style + if pinlabels: + conn["pinlabels"] = pinlabels + elif pins: + conn["pins"] = pins + elif pincount: + conn["pincount"] = pincount + if notes: + conn["notes"] = notes + + return conn + + +def build_cable( + name: str, + *, + wirecount: int | None = None, + colors: list[str] | None = None, + wirelabels: list[str] | None = None, + cable_type: str = "", + category: str = "", + notes: str = "", +) -> dict[str, Any]: + """Build a WireViz cable definition dict.""" + cable: dict[str, Any] = {} + + if category: + cable["category"] = category + if cable_type: + cable["type"] = cable_type + if colors: + cable["colors"] = colors + elif wirecount: + cable["wirecount"] = wirecount + if wirelabels: + cable["wirelabels"] = wirelabels + if notes: + cable["notes"] = notes + + return cable + + +def build_connection( + from_connector: str, + from_pins: list[int], + cable_name: str, + cable_wires: list[int], + to_connector: str, + to_pins: list[int], +) -> list[dict[str, Any]]: + """Build a WireViz connection set (one entry in the connections list). + + Returns a list of dicts representing [connector:pins, cable:wires, connector:pins]. + """ + conn_set: list[dict[str, Any]] = [] + + # From connector + if len(from_pins) == 1: + conn_set.append({from_connector: from_pins[0]}) + else: + conn_set.append({from_connector: from_pins}) + + # Cable + if len(cable_wires) == 1: + conn_set.append({cable_name: cable_wires[0]}) + else: + conn_set.append({cable_name: cable_wires}) + + # To connector + if len(to_pins) == 1: + conn_set.append({to_connector: to_pins[0]}) + else: + conn_set.append({to_connector: to_pins}) + + return conn_set + + +def assemble_wireviz_doc( + connectors: dict[str, dict[str, Any]], + cables: dict[str, dict[str, Any]], + connections: list[list[dict[str, Any]]], + metadata: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Assemble the full WireViz document dict.""" + doc: dict[str, Any] = {} + + if metadata: + doc["metadata"] = metadata + + doc["connectors"] = connectors + doc["cables"] = cables + doc["connections"] = connections + + return doc diff --git a/src/spice2wireviz/filter.py b/src/spice2wireviz/filter.py new file mode 100644 index 0000000..451676a --- /dev/null +++ b/src/spice2wireviz/filter.py @@ -0,0 +1,164 @@ +"""Filter configuration and matching engine for spice2wireviz. + +Allows users to cherry-pick which components, nets, and subcircuits +appear in the generated wiring diagram. Uses fnmatch for glob patterns. +""" + +from __future__ import annotations + +import sys +from fnmatch import fnmatch + +from pydantic import BaseModel, Field + +from .parser.models import ParsedNetlist, SpiceComponent, SubcircuitInstance + + +class FilterConfig(BaseModel): + """Configuration for filtering netlist elements.""" + + include_prefixes: list[str] = Field( + default_factory=lambda: ["J", "TP", "P", "X"], + description="Component prefixes to include", + ) + exclude_prefixes: list[str] = Field( + default_factory=list, description="Component prefixes to exclude" + ) + include_refs: list[str] = Field( + default_factory=list, description="Specific references to include (empty = all)" + ) + exclude_refs: list[str] = Field( + default_factory=list, description="Specific references to exclude" + ) + include_nets: list[str] = Field( + default_factory=list, description="Net name glob patterns to include (empty = all)" + ) + exclude_nets: list[str] = Field( + default_factory=list, description="Net name glob patterns to exclude" + ) + include_subcircuits: list[str] = Field( + default_factory=list, description="Subcircuit names to include (empty = all)" + ) + exclude_subcircuits: list[str] = Field( + default_factory=list, description="Subcircuit names to exclude" + ) + show_ground: bool = True + show_power: bool = True + group_parallel_wires: bool = True + + +def _matches_any_glob(value: str, patterns: list[str]) -> bool: + """Check if value matches any of the glob patterns.""" + return any(fnmatch(value, pat) for pat in patterns) + + +def filter_component(comp: SpiceComponent, config: FilterConfig, netlist: ParsedNetlist) -> bool: + """Determine whether a component should be included in the output. + + Returns True if the component passes all filters. + """ + prefix = comp.prefix.upper() + + # Prefix filtering + if config.exclude_prefixes and prefix in {p.upper() for p in config.exclude_prefixes}: + return False + if config.include_prefixes and prefix not in {p.upper() for p in config.include_prefixes}: + return False + + # Specific reference filtering + if config.exclude_refs and comp.reference in config.exclude_refs: + return False + if config.include_refs and comp.reference not in config.include_refs: + return False + + return True + + +def filter_instance( + inst: SubcircuitInstance, config: FilterConfig, netlist: ParsedNetlist +) -> bool: + """Determine whether a subcircuit instance should be included.""" + # Prefix filtering (X prefix) + if config.exclude_prefixes and "X" in {p.upper() for p in config.exclude_prefixes}: + return False + if config.include_prefixes and "X" not in {p.upper() for p in config.include_prefixes}: + return False + + # Reference filtering + if config.exclude_refs and inst.reference in config.exclude_refs: + return False + if config.include_refs and inst.reference not in config.include_refs: + return False + + # Subcircuit name filtering + if config.exclude_subcircuits and inst.subcircuit_name in config.exclude_subcircuits: + return False + if config.include_subcircuits and inst.subcircuit_name not in config.include_subcircuits: + return False + + return True + + +def filter_net(net: str, config: FilterConfig, netlist: ParsedNetlist) -> bool: + """Determine whether a net should be included in connections. + + Warns on stderr when ground/power nets are filtered out. + """ + # Ground filtering + if not config.show_ground and netlist.is_ground_net(net): + return False + + # Power filtering + if not config.show_power and netlist.is_power_net(net): + return False + + # Glob pattern filtering + if config.exclude_nets and _matches_any_glob(net, config.exclude_nets): + return False + if config.include_nets and not _matches_any_glob(net, config.include_nets): + # Warn if we're excluding power/ground via glob patterns + if netlist.is_ground_net(net): + print(f"Warning: ground net '{net}' excluded by --include-nets filter", file=sys.stderr) + elif netlist.is_power_net(net): + print(f"Warning: power net '{net}' excluded by --include-nets filter", file=sys.stderr) + return False + + return True + + +def apply_filters(netlist: ParsedNetlist, config: FilterConfig) -> ParsedNetlist: + """Return a new ParsedNetlist with filters applied. + + Produces warnings on stderr for significant exclusions. + """ + filtered_components = [ + comp + for comp in netlist.top_level_components + if filter_component(comp, config, netlist) + ] + + filtered_instances = [ + inst for inst in netlist.instances if filter_instance(inst, config, netlist) + ] + + # Filter nets within surviving components/instances + excluded_net_count = 0 + for comp in filtered_components: + original_count = len(comp.nodes) + comp = comp.model_copy() + new_nodes = [n for n in comp.nodes if filter_net(n, config, netlist)] + excluded_net_count += original_count - len(new_nodes) + + if excluded_net_count > 0: + print( + f"Note: {excluded_net_count} net connections hidden by filters", + file=sys.stderr, + ) + + return ParsedNetlist( + subcircuit_defs=netlist.subcircuit_defs, + instances=filtered_instances, + top_level_components=filtered_components, + all_nets=netlist.all_nets, + global_nets=netlist.global_nets, + ) diff --git a/src/spice2wireviz/mapper/__init__.py b/src/spice2wireviz/mapper/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/spice2wireviz/mapper/inter_module.py b/src/spice2wireviz/mapper/inter_module.py new file mode 100644 index 0000000..9ca816f --- /dev/null +++ b/src/spice2wireviz/mapper/inter_module.py @@ -0,0 +1,183 @@ +"""Inter-module mapper: multi-subcircuit wiring to WireViz. + +Given a top-level netlist with X* instances (subcircuit instantiations), +creates one connector per module and traces shared nets between them. +Parallel wires between the same pair of modules are grouped into +multi-wire cables for cleaner diagrams. + +Also includes top-level J*/TP*/P* connectors directly. +""" + +from __future__ import annotations + +from collections import defaultdict +from typing import Any + +from ..emitter.yaml_emitter import assemble_wireviz_doc, build_cable, build_connection, build_connector +from ..filter import FilterConfig, filter_component, filter_instance, filter_net +from ..parser.models import ParsedNetlist, SubcircuitInstance + + +def _net_wire_color(net: str, netlist: ParsedNetlist) -> str: + if netlist.is_ground_net(net): + return "BK" + if netlist.is_power_net(net): + return "RD" + return "" + + +def _make_connector_name(ref: str) -> str: + """Sanitize a reference for use as a WireViz connector name.""" + return ref.replace(" ", "_") + + +def map_inter_module( + netlist: ParsedNetlist, + config: FilterConfig | None = None, + metadata: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Map inter-module wiring to a WireViz document dict. + + Creates connectors for each subcircuit instance and top-level boundary + component, then traces shared nets to create cables between them. + + Args: + netlist: Parsed SPICE netlist. + config: Optional filter configuration. + metadata: Optional WireViz metadata dict. + + Returns: + WireViz document dict ready for YAML emission. + """ + if config is None: + config = FilterConfig() + + connectors: dict[str, dict[str, Any]] = {} + cables: dict[str, dict[str, Any]] = {} + connections: list[list[dict[str, Any]]] = [] + + # Build a map of net -> [(connector_name, pin_index)] + # This tracks every endpoint connected to each net + net_endpoints: dict[str, list[tuple[str, int, str]]] = defaultdict(list) + + # --- Create connectors for X* instances --- + filtered_instances = [ + inst for inst in netlist.instances if filter_instance(inst, config, netlist) + ] + + for inst in filtered_instances: + conn_name = _make_connector_name(inst.reference) + port_labels = list(inst.port_to_net.keys()) + filtered_ports: list[str] = [] + filtered_nets: list[str] = [] + + for port_name, net in inst.port_to_net.items(): + if filter_net(net, config, netlist): + filtered_ports.append(port_name) + filtered_nets.append(net) + + if not filtered_ports: + continue + + connectors[conn_name] = build_connector( + conn_name, + pinlabels=filtered_ports, + connector_type=inst.subcircuit_name, + notes=f"SPICE instance: {inst.reference} ({inst.subcircuit_name})", + ) + + # Register net endpoints + for pin_idx, (port, net) in enumerate(zip(filtered_ports, filtered_nets), 1): + net_endpoints[net].append((conn_name, pin_idx, port)) + + # --- Create connectors for top-level J*/TP*/P* --- + filtered_components = [ + comp + for comp in netlist.top_level_components + if filter_component(comp, config, netlist) + ] + + for comp in filtered_components: + conn_name = _make_connector_name(comp.reference) + pin_labels = [p.name for p in comp.pins] + style = "simple" if comp.prefix.upper() == "TP" and len(pin_labels) == 1 else "" + + connectors[conn_name] = build_connector( + conn_name, + pinlabels=pin_labels if pin_labels else None, + pincount=len(comp.nodes) if not pin_labels else None, + connector_type=comp.value or comp.prefix, + style=style, + notes=f"SPICE ref: {comp.reference}", + ) + + for pin_idx, node in enumerate(comp.nodes, 1): + if filter_net(node, config, netlist): + net_endpoints[node].append((conn_name, pin_idx, node)) + + # --- Trace connections via shared nets --- + # Group connections by (connector_a, connector_b) pair for parallel wire grouping + pair_wires: dict[tuple[str, str], list[tuple[int, int, str]]] = defaultdict(list) + + for net, endpoints in sorted(net_endpoints.items()): + if len(endpoints) < 2: + continue + + # Connect all pairs (for nets shared by >2 endpoints, use star topology from first) + anchor = endpoints[0] + for other in endpoints[1:]: + # Ensure consistent ordering for the pair key + a, b = anchor, other + if a[0] > b[0]: + a, b = b, a + + pair_wires[(a[0], b[0])].append((a[1], b[1], net)) + + # --- Generate cables and connections --- + cable_counter = 0 + for (conn_a, conn_b), wires in sorted(pair_wires.items()): + cable_counter += 1 + + if config.group_parallel_wires and len(wires) > 1: + # Multi-wire cable + cable_name = f"W{cable_counter}" + wire_colors = [_net_wire_color(net, netlist) for _, _, net in wires] + wire_labels = [net for _, _, net in wires] + + cables[cable_name] = build_cable( + cable_name, + wirecount=len(wires), + colors=wire_colors if any(wire_colors) else None, + wirelabels=wire_labels, + category="bundle", + notes=f"Nets: {', '.join(wire_labels)}", + ) + + a_pins = [w[0] for w in wires] + b_pins = [w[1] for w in wires] + cable_wires = list(range(1, len(wires) + 1)) + + connections.append( + build_connection(conn_a, a_pins, cable_name, cable_wires, conn_b, b_pins) + ) + else: + # Individual wires (one cable per wire) + for a_pin, b_pin, net in wires: + cable_counter_inner = cable_counter + cable_name = f"W{cable_counter}" + wire_color = _net_wire_color(net, netlist) + + cables[cable_name] = build_cable( + cable_name, + wirecount=1, + colors=[wire_color] if wire_color else None, + wirelabels=[net], + notes=f"Net: {net}", + ) + + connections.append( + build_connection(conn_a, [a_pin], cable_name, [1], conn_b, [b_pin]) + ) + cable_counter += 1 + + return assemble_wireviz_doc(connectors, cables, connections, metadata) diff --git a/src/spice2wireviz/mapper/single_module.py b/src/spice2wireviz/mapper/single_module.py new file mode 100644 index 0000000..6a98c5e --- /dev/null +++ b/src/spice2wireviz/mapper/single_module.py @@ -0,0 +1,148 @@ +"""Single-module mapper: subcircuit external interface to WireViz. + +Given a subcircuit name, extracts its boundary components (J*, TP*, P* +defined inside it) and port interface. Generates: + +- One "module header" connector representing the subcircuit's ports +- One connector per boundary component (J*, TP*, P*) +- Cables connecting the module header to boundary components via shared nets + +This shows the external interface of a single board/module. +""" + +from __future__ import annotations + +from typing import Any + +from ..emitter.yaml_emitter import assemble_wireviz_doc, build_cable, build_connection, build_connector +from ..filter import FilterConfig, filter_component, filter_net +from ..parser.models import ParsedNetlist, SpiceComponent, SubcircuitDef + + +def _net_wire_color(net: str, netlist: ParsedNetlist) -> str: + """Assign a wire color based on net type.""" + if netlist.is_ground_net(net): + return "BK" + if netlist.is_power_net(net): + return "RD" + return "" + + +def map_single_module( + netlist: ParsedNetlist, + subcircuit_name: str, + config: FilterConfig | None = None, + metadata: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Map a subcircuit's external interface to a WireViz document dict. + + Args: + netlist: Parsed SPICE netlist. + subcircuit_name: Name of the .subckt to map. + config: Optional filter configuration. + metadata: Optional WireViz metadata dict. + + Returns: + WireViz document dict ready for YAML emission. + + Raises: + ValueError: If subcircuit not found in netlist. + """ + if config is None: + config = FilterConfig() + + subckt = netlist.get_subcircuit(subcircuit_name) + if subckt is None: + available = ", ".join(netlist.list_subcircuit_names()) or "(none)" + raise ValueError( + f"Subcircuit '{subcircuit_name}' not found. Available: {available}" + ) + + connectors: dict[str, dict[str, Any]] = {} + cables: dict[str, dict[str, Any]] = {} + connections: list[list[dict[str, Any]]] = [] + + # Module header connector: represents the subcircuit's port interface + header_name = subcircuit_name + port_labels = [p for p in subckt.port_names if filter_net(p, config, netlist)] + if port_labels: + connectors[header_name] = build_connector( + header_name, + pinlabels=port_labels, + connector_type="Module Interface", + notes=f"SPICE subcircuit: .subckt {subcircuit_name}", + ) + + # Boundary components (J*, TP*, P* inside this subcircuit) + boundary = [ + comp + for comp in subckt.boundary_components + if filter_component(comp, config, netlist) + ] + + for comp in boundary: + comp_name = comp.reference + pin_labels = [p.name for p in comp.pins] + style = "simple" if comp.prefix.upper() == "TP" and len(pin_labels) == 1 else "" + + connectors[comp_name] = build_connector( + comp_name, + pinlabels=pin_labels if pin_labels else None, + pincount=len(comp.nodes) if not pin_labels else None, + connector_type=comp.value or comp.prefix, + style=style, + notes=f"SPICE ref: {comp.reference}, nets: {', '.join(comp.nodes)}", + ) + + # Find shared nets between this component and the subcircuit ports + shared_nets = _find_shared_nets(subckt, comp, config, netlist) + + if shared_nets and header_name in connectors: + cable_name = f"W_{comp_name}" + wire_colors = [_net_wire_color(net, netlist) for net in shared_nets] + wire_labels = list(shared_nets) + + cables[cable_name] = build_cable( + cable_name, + wirecount=len(shared_nets), + colors=wire_colors if any(wire_colors) else None, + wirelabels=wire_labels, + category="bundle", + notes=f"Nets: {', '.join(shared_nets)}", + ) + + # Map pin indices for connection + header_pins = [port_labels.index(net) + 1 for net in shared_nets if net in port_labels] + comp_pins = [ + comp.nodes.index(net) + 1 for net in shared_nets if net in comp.nodes + ] + cable_wires = list(range(1, len(shared_nets) + 1)) + + if header_pins and comp_pins and len(header_pins) == len(comp_pins): + connections.append( + build_connection(header_name, header_pins, cable_name, cable_wires, comp_name, comp_pins) + ) + + return assemble_wireviz_doc(connectors, cables, connections, metadata) + + +def _find_shared_nets( + subckt: SubcircuitDef, + comp: SpiceComponent, + config: FilterConfig, + netlist: ParsedNetlist, +) -> list[str]: + """Find nets shared between a subcircuit's ports and a component's nodes. + + Returns nets in deterministic order (port order). + """ + port_set = set(subckt.port_names) + comp_nodes = set(comp.nodes) + shared = port_set & comp_nodes + + # Filter and sort by port order for determinism + return [ + net + for net in subckt.port_names + if net in shared and filter_net(net, config, netlist) + ] diff --git a/src/spice2wireviz/parser/__init__.py b/src/spice2wireviz/parser/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/spice2wireviz/parser/asc.py b/src/spice2wireviz/parser/asc.py new file mode 100644 index 0000000..f642d0f --- /dev/null +++ b/src/spice2wireviz/parser/asc.py @@ -0,0 +1,90 @@ +"""Optional .asc file parser using spicelib. + +LTspice .asc files are complex (coordinates, symbols, attributes) and +spicelib handles them well, but it pulls ~165MB of transitive deps. +This module is only imported when spicelib is available. + +Usage: + pip install spice2wireviz[asc] +""" + +from __future__ import annotations + +from pathlib import Path + +from .models import ( + ParsedNetlist, + SpiceComponent, + SpicePin, + SubcircuitDef, + SubcircuitInstance, +) +from .netlist import BOUNDARY_PREFIXES, _extract_prefix + + +def parse_asc(filepath: str | Path) -> ParsedNetlist: + """Parse an LTspice .asc schematic file via spicelib. + + Args: + filepath: Path to .asc file. + + Returns: + ParsedNetlist with extracted components and connectivity. + + Raises: + ImportError: If spicelib is not installed. + FileNotFoundError: If the .asc file doesn't exist. + """ + try: + from spicelib import AscEditor + except ImportError: + raise ImportError( + "spicelib is required for .asc parsing. " + "Install with: pip install spice2wireviz[asc]" + ) from None + + path = Path(filepath) + if not path.exists(): + raise FileNotFoundError(f"ASC file not found: {path}") + + asc = AscEditor(str(path)) + + subcircuit_defs: dict[str, SubcircuitDef] = {} + instances: list[SubcircuitInstance] = [] + top_level_components: list[SpiceComponent] = [] + all_nets: set[str] = set() + global_nets: set[str] = set() + + for comp in asc.get_components(): + ref = comp.get("ref", "") + value = comp.get("value", "") + prefix = _extract_prefix(ref) + + if not prefix: + continue + + if prefix == "X": + # Subcircuit instance from .asc — limited port info available + inst = SubcircuitInstance( + reference=ref, + subcircuit_name=value, + port_to_net={}, + ) + instances.append(inst) + elif prefix in BOUNDARY_PREFIXES: + spice_comp = SpiceComponent( + reference=ref, + prefix=prefix, + value=value, + pins=[], + nodes=[], + ) + top_level_components.append(spice_comp) + + return ParsedNetlist( + subcircuit_defs=subcircuit_defs, + instances=instances, + top_level_components=top_level_components, + all_nets=all_nets, + global_nets=global_nets, + ) diff --git a/src/spice2wireviz/parser/models.py b/src/spice2wireviz/parser/models.py new file mode 100644 index 0000000..2bb6e19 --- /dev/null +++ b/src/spice2wireviz/parser/models.py @@ -0,0 +1,103 @@ +"""Pydantic models representing the external interface of SPICE circuits. + +These models capture the subset of SPICE netlist data relevant to +wiring documentation: subcircuit port interfaces, connector-like +components (J*, TP*, P*), subcircuit instances (X*), and net connectivity. +""" + +from __future__ import annotations + +from enum import Enum + +from pydantic import BaseModel, Field + + +class PinDirection(str, Enum): + UNKNOWN = "unknown" + INPUT = "input" + OUTPUT = "output" + BIDIRECTIONAL = "bidirectional" + + +class SpicePin(BaseModel): + """A single pin on a component or subcircuit port.""" + + name: str + index: int = Field(ge=1, description="1-based pin index") + direction: PinDirection = PinDirection.UNKNOWN + net_name: str = "" + + +class SpiceComponent(BaseModel): + """A component in the netlist (J*, TP*, P*, or other).""" + + reference: str = Field(description="Full reference designator, e.g. J1, TP3") + prefix: str = Field(description="Component prefix, e.g. J, TP, P") + value: str = "" + pins: list[SpicePin] = Field(default_factory=list) + nodes: list[str] = Field(default_factory=list, description="Net names connected to each pin") + attributes: dict[str, str] = Field(default_factory=dict) + subcircuit_scope: str = Field( + default="", description="Name of .subckt this component is inside, empty for top-level" + ) + + +class SubcircuitDef(BaseModel): + """A .subckt definition with its port interface and internal boundary components.""" + + name: str + port_names: list[str] = Field(description="Ordered port names from .subckt line") + boundary_components: list[SpiceComponent] = Field( + default_factory=list, + description="J*/TP*/P* components defined inside this subcircuit", + ) + parameters: dict[str, str] = Field(default_factory=dict) + + +class SubcircuitInstance(BaseModel): + """An X* instantiation of a subcircuit.""" + + reference: str = Field(description="Instance reference, e.g. X1") + subcircuit_name: str + port_to_net: dict[str, str] = Field( + description="Mapping of subcircuit port name -> net name at instantiation site" + ) + attributes: dict[str, str] = Field(default_factory=dict) + + +class ParsedNetlist(BaseModel): + """Complete parsed representation of a SPICE netlist's external interface.""" + + subcircuit_defs: dict[str, SubcircuitDef] = Field( + default_factory=dict, description="Subcircuit name -> definition" + ) + instances: list[SubcircuitInstance] = Field( + default_factory=list, description="All X* instances at top level" + ) + top_level_components: list[SpiceComponent] = Field( + default_factory=list, description="J*/TP*/P* components at top level" + ) + all_nets: set[str] = Field(default_factory=set, description="All net names encountered") + global_nets: set[str] = Field( + default_factory=set, description="Nets declared .global or with $G_ prefix" + ) + + model_config = {"arbitrary_types_allowed": True} + + def get_subcircuit(self, name: str) -> SubcircuitDef | None: + return self.subcircuit_defs.get(name) + + def list_subcircuit_names(self) -> list[str]: + return sorted(self.subcircuit_defs.keys()) + + def get_components_by_prefix(self, *prefixes: str) -> list[SpiceComponent]: + prefixes_upper = {p.upper() for p in prefixes} + return [c for c in self.top_level_components if c.prefix.upper() in prefixes_upper] + + def is_power_net(self, net: str) -> bool: + upper = net.upper() + return upper in {"VCC", "VDD", "V+", "V-", "VEE", "VSS", "AVCC", "AVDD", "DVCC", "DVDD"} + + def is_ground_net(self, net: str) -> bool: + upper = net.upper() + return upper in {"GND", "AGND", "DGND", "GND!", "EARTH", "VSS"} or net == "0" diff --git a/src/spice2wireviz/parser/netlist.py b/src/spice2wireviz/parser/netlist.py new file mode 100644 index 0000000..9868a71 --- /dev/null +++ b/src/spice2wireviz/parser/netlist.py @@ -0,0 +1,356 @@ +"""Custom lightweight SPICE netlist parser. + +Extracts subcircuit definitions, instances, and connector-like components +from .net/.cir/.sp files. Only parses the structural elements needed for +wiring documentation — ignores simulation directives, models, etc. + +SPICE netlist format reference: +- Lines starting with * are comments +- ; starts an inline comment +- + at line start is a continuation of the previous line +- .subckt Name port1 port2 ... portN [params] +- .ends [Name] +- X node1 node2 ... nodeN SubcircuitName [params] +- Component lines: node1 node2 ... [value] [params] +- .global net1 net2 ... +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +from .models import ( + ParsedNetlist, + PinDirection, + SpiceComponent, + SpicePin, + SubcircuitDef, + SubcircuitInstance, +) + +# Prefixes that represent physical connectors / test points +BOUNDARY_PREFIXES = {"J", "TP", "P"} + +# Prefixes we recognize as external-facing components +RECOGNIZED_PREFIXES = {"J", "TP", "P", "X"} + +# Known power net patterns +_POWER_PATTERN = re.compile( + r"^(V(CC|DD|EE|SS|[+-])|A?V(CC|DD)|D?V(CC|DD))$", re.IGNORECASE +) + +# Known ground net patterns +_GROUND_PATTERN = re.compile(r"^(GND!?|AGND|DGND|EARTH|VSS|0)$", re.IGNORECASE) + + +def _strip_comment(line: str) -> str: + """Remove inline ; comments, respecting that ; inside quotes is literal.""" + # Simple approach: split on first ; not inside quotes + in_quote = False + for i, ch in enumerate(line): + if ch == '"': + in_quote = not in_quote + elif ch == ";" and not in_quote: + return line[:i].rstrip() + return line.rstrip() + + +def _join_continuation_lines(lines: list[str]) -> list[str]: + """Join + continuation lines with their predecessor.""" + result: list[str] = [] + for line in lines: + stripped = line.lstrip() + if stripped.startswith("+") and result: + # Continuation: append to previous line + result[-1] = result[-1] + " " + stripped[1:].lstrip() + else: + result.append(line) + return result + + +def _extract_prefix(reference: str) -> str: + """Extract the alphabetic prefix from a reference designator. + + Examples: J1 -> J, TP3 -> TP, X_amp -> X, R12 -> R + """ + match = re.match(r"^([A-Za-z]+)", reference) + return match.group(1).upper() if match else "" + + +def _parse_params(tokens: list[str]) -> dict[str, str]: + """Extract key=value parameters from token list.""" + params: dict[str, str] = {} + for token in tokens: + if "=" in token: + key, _, val = token.partition("=") + params[key.strip()] = val.strip() + return params + + +def _detect_pin_direction(name: str) -> PinDirection: + """Heuristic pin direction from name.""" + upper = name.upper() + if any(tag in upper for tag in ("_IN", "INPUT", "RXD", "MISO", "SDI")): + return PinDirection.INPUT + if any(tag in upper for tag in ("_OUT", "OUTPUT", "TXD", "MOSI", "SDO")): + return PinDirection.OUTPUT + if any(tag in upper for tag in ("SDA", "SCL", "IO", "BIDIR")): + return PinDirection.BIDIRECTIONAL + return PinDirection.UNKNOWN + + +def parse_netlist(source: str | Path) -> ParsedNetlist: + """Parse a SPICE netlist file or string into a ParsedNetlist. + + Args: + source: File path (.net, .cir, .sp) or raw SPICE text. + + Returns: + ParsedNetlist with subcircuit defs, instances, and components. + """ + if isinstance(source, Path) or ( + isinstance(source, str) and not "\n" in source and Path(source).suffix in {".net", ".cir", ".sp"} + ): + path = Path(source) + if path.exists(): + text = path.read_text(encoding="utf-8", errors="replace") + else: + raise FileNotFoundError(f"Netlist file not found: {path}") + else: + text = source + + return _parse_text(text) + + +def _parse_text(text: str) -> ParsedNetlist: + """Parse raw SPICE netlist text.""" + # Normalize line endings and strip comments + raw_lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n") + + # Strip full-line comments (lines starting with *) + cleaned: list[str] = [] + for line in raw_lines: + stripped = line.strip() + if not stripped or stripped.startswith("*"): + continue + cleaned.append(_strip_comment(line)) + + # Join continuation lines + lines = _join_continuation_lines(cleaned) + + subcircuit_defs: dict[str, SubcircuitDef] = {} + instances: list[SubcircuitInstance] = [] + top_level_components: list[SpiceComponent] = [] + all_nets: set[str] = set() + global_nets: set[str] = set() + + # Track scope: None = top-level, str = inside .subckt + current_subckt: SubcircuitDef | None = None + + for line in lines: + stripped = line.strip() + if not stripped: + continue + + tokens = stripped.split() + directive = tokens[0].lower() + + # --- Directives --- + if directive == ".subckt": + name, ports, params = _parse_subckt_line(tokens) + current_subckt = SubcircuitDef( + name=name, port_names=ports, parameters=params + ) + all_nets.update(ports) + continue + + if directive in (".ends", ".end"): + if current_subckt: + subcircuit_defs[current_subckt.name] = current_subckt + current_subckt = None + continue + + if directive == ".global": + for net in tokens[1:]: + global_nets.add(net) + all_nets.add(net) + continue + + # Skip other directives (.model, .param, .lib, .include, .tran, etc.) + if directive.startswith("."): + continue + + # --- Component / Instance lines --- + ref = tokens[0] + prefix = _extract_prefix(ref) + + if not prefix: + continue + + if prefix == "X": + # Subcircuit instance: X node1 node2 ... SubcircuitName [params] + instance = _parse_x_instance(tokens, subcircuit_defs) + if instance: + if current_subckt is None: + instances.append(instance) + # Track nets + for net in instance.port_to_net.values(): + all_nets.add(net) + if net.startswith("$G_"): + global_nets.add(net) + + elif prefix in BOUNDARY_PREFIXES: + # Connector/test point/plug component + comp = _parse_boundary_component(tokens, prefix) + if comp: + comp.subcircuit_scope = current_subckt.name if current_subckt else "" + if current_subckt: + current_subckt.boundary_components.append(comp) + else: + top_level_components.append(comp) + # Track nets + for node in comp.nodes: + all_nets.add(node) + if node.startswith("$G_"): + global_nets.add(node) + + # Post-processing: warn about undefined subcircuits + known_subckt_names = set(subcircuit_defs.keys()) + for inst in instances: + if inst.subcircuit_name not in known_subckt_names: + print( + f"Warning: subcircuit '{inst.subcircuit_name}' referenced by " + f"{inst.reference} is not defined in this netlist", + file=sys.stderr, + ) + + return ParsedNetlist( + subcircuit_defs=subcircuit_defs, + instances=instances, + top_level_components=top_level_components, + all_nets=all_nets, + global_nets=global_nets, + ) + + +def _parse_subckt_line(tokens: list[str]) -> tuple[str, list[str], dict[str, str]]: + """Parse a .subckt line into (name, port_names, parameters). + + Format: .subckt Name port1 port2 ... [param1=val1 ...] + """ + name = tokens[1] + ports: list[str] = [] + param_tokens: list[str] = [] + + for token in tokens[2:]: + if "=" in token: + param_tokens.append(token) + else: + ports.append(token) + + return name, ports, _parse_params(param_tokens) + + +def _parse_x_instance( + tokens: list[str], known_subcircuits: dict[str, SubcircuitDef] +) -> SubcircuitInstance | None: + """Parse an X instance line. + + Format: X node1 node2 ... SubcircuitName [param1=val1 ...] + + The subcircuit name is the last non-parameter token. Nodes are everything + between the reference and the subcircuit name. + """ + ref = tokens[0] + + # Separate parameter tokens (key=value) from positional tokens + positional: list[str] = [] + params: list[str] = [] + for token in tokens[1:]: + if "=" in token: + params.append(token) + else: + positional.append(token) + + if len(positional) < 1: + print(f"Warning: malformed X instance line: {' '.join(tokens)}", file=sys.stderr) + return None + + # Last positional token is the subcircuit name + subckt_name = positional[-1] + nodes = positional[:-1] + + # Build port-to-net mapping using subcircuit definition if available + port_to_net: dict[str, str] = {} + subckt_def = known_subcircuits.get(subckt_name) + if subckt_def: + for i, port_name in enumerate(subckt_def.port_names): + if i < len(nodes): + port_to_net[port_name] = nodes[i] + else: + # No definition available; use positional indices as port names + for i, node in enumerate(nodes): + port_to_net[f"port{i + 1}"] = node + + return SubcircuitInstance( + reference=ref, + subcircuit_name=subckt_name, + port_to_net=port_to_net, + attributes=_parse_params(params), + ) + + +def _parse_boundary_component(tokens: list[str], prefix: str) -> SpiceComponent | None: + """Parse a J*/TP*/P* component line. + + Generic SPICE component format: + node1 [node2 ...] [value] [params] + + For connectors, we treat all non-parameter tokens after the ref as nodes, + except the last one which may be a model/value name (if it doesn't look + like a net name). + """ + ref = tokens[0] + positional: list[str] = [] + param_tokens: list[str] = [] + + for token in tokens[1:]: + if "=" in token: + param_tokens.append(token) + else: + positional.append(token) + + # Heuristic: if last positional looks like a model name (contains only + # alphanumeric/underscore and doesn't match net patterns), treat it as value + value = "" + nodes = positional + + if len(positional) >= 2: + last = positional[-1] + # If it's not purely numeric and not a known net pattern, it's likely a value/model + if re.match(r"^[A-Za-z_]\w*$", last) and not _POWER_PATTERN.match( + last + ) and not _GROUND_PATTERN.match(last): + value = last + nodes = positional[:-1] + + pins = [ + SpicePin( + name=node, + index=i + 1, + direction=_detect_pin_direction(node), + net_name=node, + ) + for i, node in enumerate(nodes) + ] + + return SpiceComponent( + reference=ref, + prefix=prefix, + value=value, + pins=pins, + nodes=nodes, + attributes=_parse_params(param_tokens), + ) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/hierarchical.net b/tests/fixtures/hierarchical.net new file mode 100644 index 0000000..274d91b --- /dev/null +++ b/tests/fixtures/hierarchical.net @@ -0,0 +1,40 @@ +* Hierarchical netlist with nested subcircuits and global nets +* Tests continuation lines, parameters, .global directive + +.global VCC GND + +.subckt regulator VIN VOUT GND ++ ENABLE=1 +R1 VIN N001 100 +C1 N001 GND 10u +U1 N001 VOUT GND VCC LDO EN=ENABLE +J1 VIN GND INPUT_CONN +TP1 VOUT +.ends regulator + +.subckt sensor_module SDA SCL VCC GND ALERT +* I2C sensor with pullups and test points +R_SDA SDA VCC 4.7k +R_SCL SCL VCC 4.7k +U1 SDA SCL VCC GND ALERT BME280 +J1 SDA SCL VCC GND ALERT I2C_HDR +TP1 ALERT +.ends sensor_module + +.subckt main_board VCC GND SDA SCL ALERT USB_D+ USB_D- +; Main MCU board with USB and I2C +U1 VCC GND SDA SCL USB_D+ USB_D- MCU +J1 USB_D+ USB_D- GND USB_CONN +J2 SDA SCL VCC GND I2C_CONN +P1 VCC GND POWER_PLUG +.ends main_board + +* Top-level system wiring +X_REG VIN_RAW V3V3 GND regulator ENABLE=1 +X_SENSOR I2C_SDA I2C_SCL V3V3 GND ALERT_SIG sensor_module +X_MAIN V3V3 GND I2C_SDA I2C_SCL ALERT_SIG USB_DP USB_DM main_board + +* External connectors +J_PWR VIN_RAW GND BARREL_JACK +J_USB USB_DP USB_DM GND USB_B_CONN +TP_3V3 V3V3 diff --git a/tests/fixtures/multi_board.net b/tests/fixtures/multi_board.net new file mode 100644 index 0000000..5290d35 --- /dev/null +++ b/tests/fixtures/multi_board.net @@ -0,0 +1,27 @@ +* Multi-board system: power supply, amplifier, and I/O board +* Demonstrates inter-module wiring + +.subckt power_supply VCC GND +J1 AC_IN AC_GND AC_INLET +.ends power_supply + +.subckt amplifier VIN GND VOUT +R1 VIN N001 10k +C1 N001 GND 100n +J1 VIN GND INPUT_CONN +TP1 VOUT +.ends amplifier + +.subckt io_board SIG_IN SIG_OUT GND CTRL +J1 SIG_IN SIG_OUT DB9_CONN +J2 CTRL GND CTRL_CONN +.ends io_board + +* Top-level instantiation +X1 VCC GND power_supply +X2 VCC GND AUDIO_OUT amplifier +X3 AUDIO_OUT CTRL_SIG GND ENABLE io_board + +* Top-level connectors +J_CHASSIS GND EARTH chassis_gnd +TP_VCC VCC diff --git a/tests/fixtures/simple_board.net b/tests/fixtures/simple_board.net new file mode 100644 index 0000000..58c7d51 --- /dev/null +++ b/tests/fixtures/simple_board.net @@ -0,0 +1,14 @@ +* Simple board netlist with one subcircuit +* Has connectors J1 (power), J2 (signal I/O), and test point TP1 + +.subckt amplifier_board VIN GND VOUT SIGNAL_IN +* Internal components (passives, ICs — not relevant to wiring) +R1 VIN N001 10k +R2 N001 GND 10k +U1 N001 VOUT GND VCC opamp + +* Boundary components (connectors/test points visible at board edge) +J1 VIN GND PWR_CONN +J2 SIGNAL_IN VOUT SIG_CONN +TP1 N001 +.ends amplifier_board diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..44f44a8 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,130 @@ +"""Tests for the Click CLI.""" + +from pathlib import Path + +from click.testing import CliRunner + +from spice2wireviz.cli import main + +FIXTURES = Path(__file__).parent / "fixtures" + + +class TestCLIBasic: + def test_version(self): + runner = CliRunner() + result = runner.invoke(main, ["--version"]) + assert result.exit_code == 0 + assert "2026-02-13" in result.output + + def test_list_subcircuits(self): + runner = CliRunner() + result = runner.invoke(main, [str(FIXTURES / "multi_board.net"), "--list-subcircuits"]) + assert result.exit_code == 0 + assert "power_supply" in result.output + assert "amplifier" in result.output + assert "io_board" in result.output + + def test_list_components(self): + runner = CliRunner() + result = runner.invoke(main, [str(FIXTURES / "multi_board.net"), "--list-components"]) + assert result.exit_code == 0 + assert "J_CHASSIS" in result.output + assert "TP_VCC" in result.output + + def test_nonexistent_file(self): + runner = CliRunner() + result = runner.invoke(main, ["/nonexistent/file.net"]) + assert result.exit_code != 0 + + +class TestCLISingleModule: + def test_single_mode_explicit(self): + runner = CliRunner() + result = runner.invoke( + main, + [str(FIXTURES / "simple_board.net"), "-m", "single", "-s", "amplifier_board"], + ) + assert result.exit_code == 0 + assert "connectors:" in result.output + assert "amplifier_board" in result.output + + def test_single_mode_auto_detect(self): + runner = CliRunner() + result = runner.invoke( + main, [str(FIXTURES / "simple_board.net"), "-s", "amplifier_board"] + ) + assert result.exit_code == 0 + assert "connectors:" in result.output + + def test_single_mode_only_subcircuit(self): + """When netlist has one subcircuit and no instances, auto-detect single mode.""" + runner = CliRunner() + result = runner.invoke(main, [str(FIXTURES / "simple_board.net")]) + assert result.exit_code == 0 + assert "connectors:" in result.output + + def test_dry_run(self): + runner = CliRunner() + result = runner.invoke( + main, + [str(FIXTURES / "simple_board.net"), "-s", "amplifier_board", "--dry-run"], + ) + assert result.exit_code == 0 + assert "Mode: single" in result.output + assert "Connectors:" in result.output + + +class TestCLIInterModule: + def test_inter_mode_explicit(self): + runner = CliRunner() + result = runner.invoke( + main, [str(FIXTURES / "multi_board.net"), "-m", "inter"] + ) + assert result.exit_code == 0 + assert "connectors:" in result.output + assert "cables:" in result.output + + def test_inter_mode_auto_detect(self): + runner = CliRunner() + result = runner.invoke(main, [str(FIXTURES / "multi_board.net")]) + assert result.exit_code == 0 + assert "connectors:" in result.output + + def test_output_to_file(self, tmp_path): + out = tmp_path / "output.yml" + runner = CliRunner() + result = runner.invoke( + main, [str(FIXTURES / "multi_board.net"), "-o", str(out)] + ) + assert result.exit_code == 0 + assert out.exists() + content = out.read_text() + assert "connectors:" in content + + +class TestCLIFiltering: + def test_no_ground(self): + runner = CliRunner() + result = runner.invoke( + main, [str(FIXTURES / "multi_board.net"), "--no-ground"] + ) + assert result.exit_code == 0 + # GND should not appear in pinlabels + assert "- GND" not in result.output + + def test_include_prefixes(self): + runner = CliRunner() + result = runner.invoke( + main, + [str(FIXTURES / "multi_board.net"), "--include-prefixes", "J"], + ) + assert result.exit_code == 0 + + def test_exclude_refs(self): + runner = CliRunner() + result = runner.invoke( + main, + [str(FIXTURES / "multi_board.net"), "--exclude-refs", "X1,TP_VCC"], + ) + assert result.exit_code == 0 + assert "X1:" not in result.output diff --git a/tests/test_filter.py b/tests/test_filter.py new file mode 100644 index 0000000..8c07abd --- /dev/null +++ b/tests/test_filter.py @@ -0,0 +1,167 @@ +"""Tests for the filter engine.""" + +from spice2wireviz.filter import FilterConfig, apply_filters, filter_component, filter_instance, filter_net +from spice2wireviz.parser.models import ( + ParsedNetlist, + SpiceComponent, + SpicePin, + SubcircuitInstance, +) + + +def _make_netlist(**kwargs) -> ParsedNetlist: + defaults = { + "subcircuit_defs": {}, + "instances": [], + "top_level_components": [], + "all_nets": set(), + "global_nets": set(), + } + defaults.update(kwargs) + return ParsedNetlist(**defaults) + + +def _make_component(ref: str, prefix: str, nodes: list[str] | None = None) -> SpiceComponent: + nodes = nodes or [] + return SpiceComponent( + reference=ref, + prefix=prefix, + nodes=nodes, + pins=[SpicePin(name=n, index=i + 1, net_name=n) for i, n in enumerate(nodes)], + ) + + +def _make_instance(ref: str, subckt: str, port_to_net: dict[str, str]) -> SubcircuitInstance: + return SubcircuitInstance(reference=ref, subcircuit_name=subckt, port_to_net=port_to_net) + + +class TestComponentFilter: + def test_default_includes_boundary(self): + config = FilterConfig() + netlist = _make_netlist() + assert filter_component(_make_component("J1", "J"), config, netlist) + assert filter_component(_make_component("TP1", "TP"), config, netlist) + assert filter_component(_make_component("P1", "P"), config, netlist) + + def test_exclude_prefix(self): + config = FilterConfig(exclude_prefixes=["TP"]) + netlist = _make_netlist() + assert filter_component(_make_component("J1", "J"), config, netlist) + assert not filter_component(_make_component("TP1", "TP"), config, netlist) + + def test_include_prefix_restricts(self): + config = FilterConfig(include_prefixes=["J"]) + netlist = _make_netlist() + assert filter_component(_make_component("J1", "J"), config, netlist) + assert not filter_component(_make_component("TP1", "TP"), config, netlist) + + def test_exclude_ref(self): + config = FilterConfig(exclude_refs=["J2"]) + netlist = _make_netlist() + assert filter_component(_make_component("J1", "J"), config, netlist) + assert not filter_component(_make_component("J2", "J"), config, netlist) + + def test_include_ref(self): + config = FilterConfig(include_refs=["J1"]) + netlist = _make_netlist() + assert filter_component(_make_component("J1", "J"), config, netlist) + assert not filter_component(_make_component("J2", "J"), config, netlist) + + +class TestInstanceFilter: + def test_default_includes_x(self): + config = FilterConfig() + netlist = _make_netlist() + inst = _make_instance("X1", "amp", {"VIN": "NET1"}) + assert filter_instance(inst, config, netlist) + + def test_exclude_subcircuit(self): + config = FilterConfig(exclude_subcircuits=["power_supply"]) + netlist = _make_netlist() + assert not filter_instance( + _make_instance("X1", "power_supply", {}), config, netlist + ) + assert filter_instance( + _make_instance("X2", "amplifier", {}), config, netlist + ) + + def test_include_subcircuit(self): + config = FilterConfig(include_subcircuits=["amplifier"]) + netlist = _make_netlist() + assert filter_instance( + _make_instance("X2", "amplifier", {}), config, netlist + ) + assert not filter_instance( + _make_instance("X1", "power_supply", {}), config, netlist + ) + + def test_exclude_ref(self): + config = FilterConfig(exclude_refs=["X3"]) + netlist = _make_netlist() + assert not filter_instance( + _make_instance("X3", "io_board", {}), config, netlist + ) + + +class TestNetFilter: + def test_default_shows_all(self): + config = FilterConfig() + netlist = _make_netlist() + assert filter_net("VCC", config, netlist) + assert filter_net("GND", config, netlist) + assert filter_net("SIGNAL", config, netlist) + + def test_hide_ground(self): + config = FilterConfig(show_ground=False) + netlist = _make_netlist() + assert not filter_net("GND", config, netlist) + assert not filter_net("AGND", config, netlist) + assert filter_net("VCC", config, netlist) + + def test_hide_power(self): + config = FilterConfig(show_power=False) + netlist = _make_netlist() + assert not filter_net("VCC", config, netlist) + assert not filter_net("VDD", config, netlist) + assert filter_net("GND", config, netlist) + + def test_include_nets_glob(self): + config = FilterConfig(include_nets=["SIG_*"]) + netlist = _make_netlist() + assert filter_net("SIG_IN", config, netlist) + assert filter_net("SIG_OUT", config, netlist) + assert not filter_net("VCC", config, netlist) + + def test_exclude_nets_glob(self): + config = FilterConfig(exclude_nets=["N0*"]) + netlist = _make_netlist() + assert not filter_net("N001", config, netlist) + assert filter_net("SIGNAL", config, netlist) + + +class TestApplyFilters: + def test_filters_components(self): + comps = [ + _make_component("J1", "J", ["NET1"]), + _make_component("TP1", "TP", ["NET2"]), + _make_component("R1", "R", ["NET3"]), + ] + netlist = _make_netlist(top_level_components=comps) + config = FilterConfig() # default: J, TP, P, X + filtered = apply_filters(netlist, config) + refs = [c.reference for c in filtered.top_level_components] + assert "J1" in refs + assert "TP1" in refs + assert "R1" not in refs # R not in default include + + def test_filters_instances(self): + insts = [ + _make_instance("X1", "power", {"VCC": "NET1"}), + _make_instance("X2", "amp", {"VIN": "NET2"}), + ] + netlist = _make_netlist(instances=insts) + config = FilterConfig(exclude_refs=["X1"]) + filtered = apply_filters(netlist, config) + refs = [i.reference for i in filtered.instances] + assert "X1" not in refs + assert "X2" in refs diff --git a/tests/test_inter_module.py b/tests/test_inter_module.py new file mode 100644 index 0000000..a25a63c --- /dev/null +++ b/tests/test_inter_module.py @@ -0,0 +1,145 @@ +"""Tests for the inter-module mapper.""" + +from pathlib import Path + +from spice2wireviz.filter import FilterConfig +from spice2wireviz.mapper.inter_module import map_inter_module +from spice2wireviz.parser.netlist import parse_netlist + +FIXTURES = Path(__file__).parent / "fixtures" + + +class TestInterModuleMapping: + def test_multi_board_connectors(self): + netlist = parse_netlist(FIXTURES / "multi_board.net") + result = map_inter_module(netlist) + + connectors = result["connectors"] + # Should have connectors for each X instance and top-level components + assert "X1" in connectors + assert "X2" in connectors + assert "X3" in connectors + assert "J_CHASSIS" in connectors + assert "TP_VCC" in connectors + + def test_instance_connector_type(self): + netlist = parse_netlist(FIXTURES / "multi_board.net") + result = map_inter_module(netlist) + + x1 = result["connectors"]["X1"] + assert x1["type"] == "power_supply" + + def test_instance_pinlabels(self): + netlist = parse_netlist(FIXTURES / "multi_board.net") + result = map_inter_module(netlist) + + x2 = result["connectors"]["X2"] + assert "VIN" in x2["pinlabels"] + assert "GND" in x2["pinlabels"] + assert "VOUT" in x2["pinlabels"] + + def test_cables_for_shared_nets(self): + netlist = parse_netlist(FIXTURES / "multi_board.net") + result = map_inter_module(netlist) + + # Should have cables connecting modules via shared nets + cables = result["cables"] + assert len(cables) >= 1 + + def test_connections_exist(self): + netlist = parse_netlist(FIXTURES / "multi_board.net") + result = map_inter_module(netlist) + + connections = result["connections"] + assert len(connections) >= 1 + + def test_traceability_notes(self): + netlist = parse_netlist(FIXTURES / "multi_board.net") + result = map_inter_module(netlist) + + x1 = result["connectors"]["X1"] + assert "SPICE instance" in x1["notes"] + + def test_top_level_test_point_style(self): + netlist = parse_netlist(FIXTURES / "multi_board.net") + result = map_inter_module(netlist) + + tp = result["connectors"]["TP_VCC"] + assert tp.get("style") == "simple" + + +class TestInterModuleFiltering: + def test_exclude_instance(self): + netlist = parse_netlist(FIXTURES / "multi_board.net") + config = FilterConfig(exclude_refs=["X1"]) + result = map_inter_module(netlist, config) + + assert "X1" not in result["connectors"] + assert "X2" in result["connectors"] + + def test_exclude_subcircuit(self): + netlist = parse_netlist(FIXTURES / "multi_board.net") + config = FilterConfig(exclude_subcircuits=["power_supply"]) + result = map_inter_module(netlist, config) + + assert "X1" not in result["connectors"] + + def test_no_ground_filter(self): + netlist = parse_netlist(FIXTURES / "multi_board.net") + config = FilterConfig(show_ground=False) + result = map_inter_module(netlist, config) + + # GND should not appear in any connector's pinlabels + for name, conn in result["connectors"].items(): + if "pinlabels" in conn: + assert "GND" not in conn["pinlabels"], f"GND found in {name}" + + def test_no_power_filter(self): + netlist = parse_netlist(FIXTURES / "multi_board.net") + config = FilterConfig(show_power=False) + result = map_inter_module(netlist, config) + + for name, conn in result["connectors"].items(): + if "pinlabels" in conn: + assert "VCC" not in conn["pinlabels"], f"VCC found in {name}" + + +class TestHierarchicalInterModule: + def test_hierarchical_instances(self): + netlist = parse_netlist(FIXTURES / "hierarchical.net") + result = map_inter_module(netlist) + + connectors = result["connectors"] + assert "X_REG" in connectors + assert "X_SENSOR" in connectors + assert "X_MAIN" in connectors + + def test_external_connectors(self): + netlist = parse_netlist(FIXTURES / "hierarchical.net") + result = map_inter_module(netlist) + + connectors = result["connectors"] + assert "J_PWR" in connectors + assert "J_USB" in connectors + assert "TP_3V3" in connectors + + def test_grouped_parallel_wires(self): + """Parallel wires between same modules should be grouped.""" + netlist = parse_netlist(FIXTURES / "hierarchical.net") + config = FilterConfig(group_parallel_wires=True) + result = map_inter_module(netlist, config) + + # With grouping, fewer cables than total net connections + cables = result["cables"] + connections = result["connections"] + assert len(cables) == len(connections) + + def test_ungrouped_wires(self): + """Without grouping, each wire gets its own cable.""" + netlist = parse_netlist(FIXTURES / "hierarchical.net") + config = FilterConfig(group_parallel_wires=False) + result = map_inter_module(netlist, config) + + # Each cable should have wirecount=1 + for cable in result["cables"].values(): + assert cable.get("wirecount", 1) == 1 or len(cable.get("colors", [])) == 1 diff --git a/tests/test_netlist_parser.py b/tests/test_netlist_parser.py new file mode 100644 index 0000000..5f7db27 --- /dev/null +++ b/tests/test_netlist_parser.py @@ -0,0 +1,171 @@ +"""Tests for the SPICE netlist parser.""" + +from pathlib import Path + +import pytest + +from spice2wireviz.parser.netlist import parse_netlist + +FIXTURES = Path(__file__).parent / "fixtures" + + +class TestBasicParsing: + def test_parse_simple_board(self): + netlist = parse_netlist(FIXTURES / "simple_board.net") + assert "amplifier_board" in netlist.subcircuit_defs + subckt = netlist.subcircuit_defs["amplifier_board"] + assert subckt.port_names == ["VIN", "GND", "VOUT", "SIGNAL_IN"] + + def test_parse_boundary_components(self): + netlist = parse_netlist(FIXTURES / "simple_board.net") + subckt = netlist.subcircuit_defs["amplifier_board"] + refs = [c.reference for c in subckt.boundary_components] + assert "J1" in refs + assert "J2" in refs + assert "TP1" in refs + assert len(subckt.boundary_components) == 3 + + def test_boundary_component_nodes(self): + netlist = parse_netlist(FIXTURES / "simple_board.net") + subckt = netlist.subcircuit_defs["amplifier_board"] + j1 = next(c for c in subckt.boundary_components if c.reference == "J1") + assert j1.nodes == ["VIN", "GND"] + assert j1.value == "PWR_CONN" + + def test_test_point_single_node(self): + netlist = parse_netlist(FIXTURES / "simple_board.net") + subckt = netlist.subcircuit_defs["amplifier_board"] + tp1 = next(c for c in subckt.boundary_components if c.reference == "TP1") + assert tp1.nodes == ["N001"] + assert tp1.prefix == "TP" + + +class TestMultiBoardParsing: + def test_parse_multi_board(self): + netlist = parse_netlist(FIXTURES / "multi_board.net") + assert len(netlist.subcircuit_defs) == 3 + assert "power_supply" in netlist.subcircuit_defs + assert "amplifier" in netlist.subcircuit_defs + assert "io_board" in netlist.subcircuit_defs + + def test_instances(self): + netlist = parse_netlist(FIXTURES / "multi_board.net") + refs = [inst.reference for inst in netlist.instances] + assert "X1" in refs + assert "X2" in refs + assert "X3" in refs + + def test_instance_port_mapping(self): + netlist = parse_netlist(FIXTURES / "multi_board.net") + x2 = next(i for i in netlist.instances if i.reference == "X2") + assert x2.subcircuit_name == "amplifier" + assert x2.port_to_net == {"VIN": "VCC", "GND": "GND", "VOUT": "AUDIO_OUT"} + + def test_top_level_components(self): + netlist = parse_netlist(FIXTURES / "multi_board.net") + refs = [c.reference for c in netlist.top_level_components] + assert "J_CHASSIS" in refs + assert "TP_VCC" in refs + + def test_top_level_connector_nodes(self): + netlist = parse_netlist(FIXTURES / "multi_board.net") + j_chassis = next(c for c in netlist.top_level_components if c.reference == "J_CHASSIS") + assert "GND" in j_chassis.nodes + assert "EARTH" in j_chassis.nodes + + +class TestHierarchicalParsing: + def test_global_nets(self): + netlist = parse_netlist(FIXTURES / "hierarchical.net") + assert "VCC" in netlist.global_nets + assert "GND" in netlist.global_nets + + def test_continuation_lines(self): + netlist = parse_netlist(FIXTURES / "hierarchical.net") + reg = netlist.subcircuit_defs["regulator"] + # The continuation line should have been joined + assert "ENABLE" in reg.parameters or len(reg.port_names) >= 3 + + def test_inline_comments(self): + """Inline ; comments should be stripped.""" + netlist = parse_netlist(FIXTURES / "hierarchical.net") + assert "main_board" in netlist.subcircuit_defs + + def test_all_subcircuits_found(self): + netlist = parse_netlist(FIXTURES / "hierarchical.net") + assert len(netlist.subcircuit_defs) == 3 + assert "regulator" in netlist.subcircuit_defs + assert "sensor_module" in netlist.subcircuit_defs + assert "main_board" in netlist.subcircuit_defs + + +class TestStringParsing: + def test_parse_from_string(self): + text = """\ +.subckt simple A B +J1 A B CONN +.ends simple +""" + netlist = parse_netlist(text) + assert "simple" in netlist.subcircuit_defs + subckt = netlist.subcircuit_defs["simple"] + assert subckt.port_names == ["A", "B"] + assert len(subckt.boundary_components) == 1 + + def test_empty_netlist(self): + netlist = parse_netlist("* Just a comment\n") + assert len(netlist.subcircuit_defs) == 0 + assert len(netlist.instances) == 0 + + def test_continuation_line_joining(self): + text = """\ +.subckt wide A B C ++ D E F +J1 A B CONN +.ends wide +""" + netlist = parse_netlist(text) + subckt = netlist.subcircuit_defs["wide"] + assert subckt.port_names == ["A", "B", "C", "D", "E", "F"] + + +class TestNetClassification: + def test_ground_nets(self): + text = ".subckt test GND AGND DGND VCC\n.ends test\n" + netlist = parse_netlist(text) + assert netlist.is_ground_net("GND") + assert netlist.is_ground_net("AGND") + assert netlist.is_ground_net("0") + assert not netlist.is_ground_net("VCC") + + def test_power_nets(self): + text = ".subckt test VCC VDD GND\n.ends test\n" + netlist = parse_netlist(text) + assert netlist.is_power_net("VCC") + assert netlist.is_power_net("VDD") + assert not netlist.is_power_net("GND") + assert not netlist.is_power_net("SIGNAL") + + +class TestEdgeCases: + def test_nonexistent_file(self): + with pytest.raises(FileNotFoundError): + parse_netlist(Path("/nonexistent/file.net")) + + def test_missing_subcircuit_warning(self, capsys): + text = """\ +X1 A B C undefined_subckt +""" + netlist = parse_netlist(text) + assert len(netlist.instances) == 1 + captured = capsys.readouterr() + assert "undefined_subckt" in captured.err + + def test_x_instance_without_subckt_def(self): + text = "X1 NET1 NET2 NET3 mystery_chip\n" + netlist = parse_netlist(text) + inst = netlist.instances[0] + assert inst.subcircuit_name == "mystery_chip" + # Without definition, uses positional port names + assert "port1" in inst.port_to_net + assert inst.port_to_net["port1"] == "NET1" diff --git a/tests/test_roundtrip.py b/tests/test_roundtrip.py new file mode 100644 index 0000000..9b97f69 --- /dev/null +++ b/tests/test_roundtrip.py @@ -0,0 +1,145 @@ +"""Roundtrip tests: parse SPICE -> emit YAML -> validate with WireViz. + +These tests verify the generated YAML is structurally valid by feeding +it to wireviz.wireviz.parse() and checking it doesn't raise exceptions. +""" + +from pathlib import Path + +import pytest +import yaml + +from spice2wireviz.emitter.yaml_emitter import emit_yaml +from spice2wireviz.filter import FilterConfig +from spice2wireviz.mapper.inter_module import map_inter_module +from spice2wireviz.mapper.single_module import map_single_module +from spice2wireviz.parser.netlist import parse_netlist + +FIXTURES = Path(__file__).parent / "fixtures" + + +def _has_wireviz() -> bool: + try: + from wireviz.wireviz import parse # noqa: F401 + return True + except ImportError: + return False + + +class TestYamlValidity: + """Verify generated YAML is valid YAML and has required WireViz keys.""" + + def test_single_module_yaml_structure(self): + netlist = parse_netlist(FIXTURES / "simple_board.net") + result = map_single_module(netlist, "amplifier_board") + yaml_str = emit_yaml(result) + parsed = yaml.safe_load(yaml_str) + + assert "connectors" in parsed + assert "cables" in parsed + assert "connections" in parsed + assert isinstance(parsed["connectors"], dict) + assert isinstance(parsed["cables"], dict) + assert isinstance(parsed["connections"], list) + + def test_inter_module_yaml_structure(self): + netlist = parse_netlist(FIXTURES / "multi_board.net") + result = map_inter_module(netlist) + yaml_str = emit_yaml(result) + parsed = yaml.safe_load(yaml_str) + + assert "connectors" in parsed + assert "cables" in parsed + assert "connections" in parsed + + def test_hierarchical_yaml_structure(self): + netlist = parse_netlist(FIXTURES / "hierarchical.net") + result = map_inter_module(netlist) + yaml_str = emit_yaml(result) + parsed = yaml.safe_load(yaml_str) + + assert "connectors" in parsed + assert len(parsed["connectors"]) >= 6 # 3 instances + 3 top-level + + def test_connector_has_pin_spec(self): + """Every connector must have at least one of pincount/pins/pinlabels.""" + netlist = parse_netlist(FIXTURES / "multi_board.net") + result = map_inter_module(netlist) + yaml_str = emit_yaml(result) + parsed = yaml.safe_load(yaml_str) + + for name, conn in parsed["connectors"].items(): + has_pins = any(k in conn for k in ("pincount", "pins", "pinlabels")) + assert has_pins, f"Connector '{name}' missing pin specification" + + def test_cable_has_wire_spec(self): + """Every cable must have wirecount or colors.""" + netlist = parse_netlist(FIXTURES / "multi_board.net") + result = map_inter_module(netlist) + yaml_str = emit_yaml(result) + parsed = yaml.safe_load(yaml_str) + + for name, cable in parsed["cables"].items(): + has_wires = "wirecount" in cable or "colors" in cable + assert has_wires, f"Cable '{name}' missing wire specification" + + def test_connection_set_structure(self): + """Each connection set should be a list of dicts.""" + netlist = parse_netlist(FIXTURES / "multi_board.net") + result = map_inter_module(netlist) + yaml_str = emit_yaml(result) + parsed = yaml.safe_load(yaml_str) + + for i, conn_set in enumerate(parsed["connections"]): + assert isinstance(conn_set, list), f"Connection set {i} is not a list" + assert len(conn_set) == 3, f"Connection set {i} doesn't have 3 elements" + for entry in conn_set: + assert isinstance(entry, dict), f"Connection set {i} entry is not a dict" + + +@pytest.mark.skipif(not _has_wireviz(), reason="WireViz not installed") +class TestWireVizRoundtrip: + """Feed generated YAML to WireViz's parse() to verify full compatibility.""" + + def test_single_module_roundtrip(self): + from wireviz.wireviz import parse as wv_parse + + netlist = parse_netlist(FIXTURES / "simple_board.net") + result = map_single_module(netlist, "amplifier_board") + # WireViz parse() accepts dicts directly + harness = wv_parse(result, return_types="harness") + assert harness is not None + + def test_inter_module_roundtrip(self): + from wireviz.wireviz import parse as wv_parse + + netlist = parse_netlist(FIXTURES / "multi_board.net") + result = map_inter_module(netlist) + harness = wv_parse(result, return_types="harness") + assert harness is not None + + def test_hierarchical_roundtrip(self): + from wireviz.wireviz import parse as wv_parse + + netlist = parse_netlist(FIXTURES / "hierarchical.net") + result = map_inter_module(netlist) + harness = wv_parse(result, return_types="harness") + assert harness is not None + + def test_filtered_roundtrip(self): + from wireviz.wireviz import parse as wv_parse + + netlist = parse_netlist(FIXTURES / "hierarchical.net") + config = FilterConfig(show_ground=False, show_power=False) + result = map_inter_module(netlist, config) + harness = wv_parse(result, return_types="harness") + assert harness is not None + + def test_render_svg(self, tmp_path): + from wireviz.wireviz import parse as wv_parse + + netlist = parse_netlist(FIXTURES / "multi_board.net") + result = map_inter_module(netlist) + svg = wv_parse(result, return_types="svg") + assert svg is not None + assert len(svg) > 100 # Should be a real SVG diff --git a/tests/test_single_module.py b/tests/test_single_module.py new file mode 100644 index 0000000..40e9292 --- /dev/null +++ b/tests/test_single_module.py @@ -0,0 +1,104 @@ +"""Tests for the single-module mapper.""" + +from pathlib import Path + +import pytest + +from spice2wireviz.filter import FilterConfig +from spice2wireviz.mapper.single_module import map_single_module +from spice2wireviz.parser.netlist import parse_netlist + +FIXTURES = Path(__file__).parent / "fixtures" + + +class TestSingleModuleMapping: + def test_simple_board_connectors(self): + netlist = parse_netlist(FIXTURES / "simple_board.net") + result = map_single_module(netlist, "amplifier_board") + + connectors = result["connectors"] + assert "amplifier_board" in connectors # module header + assert "J1" in connectors + assert "J2" in connectors + assert "TP1" in connectors + + def test_module_header_pinlabels(self): + netlist = parse_netlist(FIXTURES / "simple_board.net") + result = map_single_module(netlist, "amplifier_board") + + header = result["connectors"]["amplifier_board"] + assert header["pinlabels"] == ["VIN", "GND", "VOUT", "SIGNAL_IN"] + assert header["type"] == "Module Interface" + + def test_connector_pinlabels(self): + netlist = parse_netlist(FIXTURES / "simple_board.net") + result = map_single_module(netlist, "amplifier_board") + + j1 = result["connectors"]["J1"] + assert j1["pinlabels"] == ["VIN", "GND"] + + def test_test_point_style(self): + netlist = parse_netlist(FIXTURES / "simple_board.net") + result = map_single_module(netlist, "amplifier_board") + + tp1 = result["connectors"]["TP1"] + assert tp1.get("style") == "simple" + + def test_cables_created(self): + netlist = parse_netlist(FIXTURES / "simple_board.net") + result = map_single_module(netlist, "amplifier_board") + + cables = result["cables"] + assert len(cables) >= 2 # At least cables for J1 and J2 connections + + def test_connections_created(self): + netlist = parse_netlist(FIXTURES / "simple_board.net") + result = map_single_module(netlist, "amplifier_board") + + connections = result["connections"] + assert len(connections) >= 2 + + def test_traceability_notes(self): + netlist = parse_netlist(FIXTURES / "simple_board.net") + result = map_single_module(netlist, "amplifier_board") + + header = result["connectors"]["amplifier_board"] + assert "SPICE subcircuit" in header["notes"] + + def test_nonexistent_subcircuit_raises(self): + netlist = parse_netlist(FIXTURES / "simple_board.net") + with pytest.raises(ValueError, match="not found"): + map_single_module(netlist, "nonexistent") + + def test_metadata_passthrough(self): + netlist = parse_netlist(FIXTURES / "simple_board.net") + meta = {"title": "Test Diagram", "author": "Test"} + result = map_single_module(netlist, "amplifier_board", metadata=meta) + assert result["metadata"]["title"] == "Test Diagram" + + +class TestSingleModuleFiltering: + def test_no_ground_filter(self): + netlist = parse_netlist(FIXTURES / "simple_board.net") + config = FilterConfig(show_ground=False) + result = map_single_module(netlist, "amplifier_board", config) + + header = result["connectors"]["amplifier_board"] + assert "GND" not in header["pinlabels"] + + def test_no_power_filter(self): + """When power is hidden, VIN should still appear as it's a port name, not a power net.""" + netlist = parse_netlist(FIXTURES / "simple_board.net") + config = FilterConfig(show_power=False) + result = map_single_module(netlist, "amplifier_board", config) + + # VIN is a port name — it's not VCC/VDD so it stays + header = result["connectors"]["amplifier_board"] + assert "VIN" in header["pinlabels"] + + def test_exclude_component_ref(self): + netlist = parse_netlist(FIXTURES / "simple_board.net") + config = FilterConfig(exclude_refs=["TP1"]) + result = map_single_module(netlist, "amplifier_board", config) + + assert "TP1" not in result["connectors"] diff --git a/tests/test_yaml_emitter.py b/tests/test_yaml_emitter.py new file mode 100644 index 0000000..a235e7d --- /dev/null +++ b/tests/test_yaml_emitter.py @@ -0,0 +1,124 @@ +"""Tests for the YAML emitter.""" + +import yaml + +from spice2wireviz.emitter.yaml_emitter import ( + assemble_wireviz_doc, + build_cable, + build_connection, + build_connector, + emit_yaml, +) + + +class TestBuildConnector: + def test_minimal_with_pinlabels(self): + conn = build_connector("J1", pinlabels=["A", "B"]) + assert conn["pinlabels"] == ["A", "B"] + + def test_minimal_with_pincount(self): + conn = build_connector("J1", pincount=4) + assert conn["pincount"] == 4 + + def test_with_type_and_notes(self): + conn = build_connector("J1", pinlabels=["A"], connector_type="DB9", notes="test") + assert conn["type"] == "DB9" + assert conn["notes"] == "test" + + def test_simple_style(self): + conn = build_connector("TP1", pinlabels=["SIG"], style="simple") + assert conn["style"] == "simple" + + def test_empty_strings_omitted(self): + conn = build_connector("J1", pinlabels=["A"]) + assert "type" not in conn + assert "style" not in conn + assert "notes" not in conn + + +class TestBuildCable: + def test_with_wirecount(self): + cable = build_cable("W1", wirecount=3) + assert cable["wirecount"] == 3 + + def test_with_colors(self): + cable = build_cable("W1", colors=["BK", "RD", ""]) + assert cable["colors"] == ["BK", "RD", ""] + + def test_with_labels(self): + cable = build_cable("W1", wirecount=2, wirelabels=["GND", "VCC"]) + assert cable["wirelabels"] == ["GND", "VCC"] + + def test_bundle_category(self): + cable = build_cable("W1", wirecount=2, category="bundle") + assert cable["category"] == "bundle" + + +class TestBuildConnection: + def test_single_wire(self): + conn = build_connection("J1", [1], "W1", [1], "J2", [1]) + assert len(conn) == 3 + assert conn[0] == {"J1": 1} + assert conn[1] == {"W1": 1} + assert conn[2] == {"J2": 1} + + def test_multi_wire(self): + conn = build_connection("J1", [1, 2, 3], "W1", [1, 2, 3], "J2", [1, 2, 3]) + assert conn[0] == {"J1": [1, 2, 3]} + assert conn[1] == {"W1": [1, 2, 3]} + assert conn[2] == {"J2": [1, 2, 3]} + + +class TestAssembleDoc: + def test_minimal_doc(self): + doc = assemble_wireviz_doc( + connectors={"J1": {"pincount": 2}}, + cables={"W1": {"wirecount": 1}}, + connections=[[{"J1": 1}, {"W1": 1}, {"J2": 1}]], + ) + assert "connectors" in doc + assert "cables" in doc + assert "connections" in doc + assert "metadata" not in doc + + def test_with_metadata(self): + doc = assemble_wireviz_doc( + connectors={}, + cables={}, + connections=[], + metadata={"title": "Test"}, + ) + assert doc["metadata"]["title"] == "Test" + + +class TestEmitYaml: + def test_produces_valid_yaml(self): + doc = assemble_wireviz_doc( + connectors={"J1": {"pinlabels": ["A", "B"]}, "J2": {"pinlabels": ["C", "D"]}}, + cables={"W1": {"wirecount": 2}}, + connections=[[{"J1": [1, 2]}, {"W1": [1, 2]}, {"J2": [1, 2]}]], + ) + yaml_str = emit_yaml(doc) + parsed = yaml.safe_load(yaml_str) + assert parsed["connectors"]["J1"]["pinlabels"] == ["A", "B"] + + def test_deterministic_output(self): + doc = assemble_wireviz_doc( + connectors={"Z": {"pincount": 1}, "A": {"pincount": 1}}, + cables={"W1": {"wirecount": 1}}, + connections=[], + ) + yaml1 = emit_yaml(doc) + yaml2 = emit_yaml(doc) + assert yaml1 == yaml2 # Byte-identical + + def test_preserves_insertion_order(self): + doc = assemble_wireviz_doc( + connectors={"Z_conn": {"pincount": 1}, "A_conn": {"pincount": 1}}, + cables={}, + connections=[], + ) + yaml_str = emit_yaml(doc) + z_pos = yaml_str.index("Z_conn") + a_pos = yaml_str.index("A_conn") + assert z_pos < a_pos # Order preserved, not sorted alphabetically