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
This commit is contained in:
commit
e20a956f51
37
pyproject.toml
Normal file
37
pyproject.toml
Normal file
@ -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"]
|
||||
3
src/spice2wireviz/__init__.py
Normal file
3
src/spice2wireviz/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
"""spice2wireviz — Convert LTspice SPICE netlists to WireViz wiring diagrams."""
|
||||
|
||||
__version__ = "2026-02-13"
|
||||
313
src/spice2wireviz/cli.py
Normal file
313
src/spice2wireviz/cli.py
Normal file
@ -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)
|
||||
0
src/spice2wireviz/emitter/__init__.py
Normal file
0
src/spice2wireviz/emitter/__init__.py
Normal file
166
src/spice2wireviz/emitter/yaml_emitter.py
Normal file
166
src/spice2wireviz/emitter/yaml_emitter.py
Normal file
@ -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
|
||||
164
src/spice2wireviz/filter.py
Normal file
164
src/spice2wireviz/filter.py
Normal file
@ -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,
|
||||
)
|
||||
0
src/spice2wireviz/mapper/__init__.py
Normal file
0
src/spice2wireviz/mapper/__init__.py
Normal file
183
src/spice2wireviz/mapper/inter_module.py
Normal file
183
src/spice2wireviz/mapper/inter_module.py
Normal file
@ -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)
|
||||
148
src/spice2wireviz/mapper/single_module.py
Normal file
148
src/spice2wireviz/mapper/single_module.py
Normal file
@ -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)
|
||||
]
|
||||
0
src/spice2wireviz/parser/__init__.py
Normal file
0
src/spice2wireviz/parser/__init__.py
Normal file
90
src/spice2wireviz/parser/asc.py
Normal file
90
src/spice2wireviz/parser/asc.py
Normal file
@ -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,
|
||||
)
|
||||
103
src/spice2wireviz/parser/models.py
Normal file
103
src/spice2wireviz/parser/models.py
Normal file
@ -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"
|
||||
356
src/spice2wireviz/parser/netlist.py
Normal file
356
src/spice2wireviz/parser/netlist.py
Normal file
@ -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<ref> node1 node2 ... nodeN SubcircuitName [params]
|
||||
- Component lines: <prefix><ref> 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 <name>
|
||||
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<ref> 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<ref> 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:
|
||||
<ref> 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),
|
||||
)
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
40
tests/fixtures/hierarchical.net
vendored
Normal file
40
tests/fixtures/hierarchical.net
vendored
Normal file
@ -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
|
||||
27
tests/fixtures/multi_board.net
vendored
Normal file
27
tests/fixtures/multi_board.net
vendored
Normal file
@ -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
|
||||
14
tests/fixtures/simple_board.net
vendored
Normal file
14
tests/fixtures/simple_board.net
vendored
Normal file
@ -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
|
||||
130
tests/test_cli.py
Normal file
130
tests/test_cli.py
Normal file
@ -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
|
||||
167
tests/test_filter.py
Normal file
167
tests/test_filter.py
Normal file
@ -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
|
||||
145
tests/test_inter_module.py
Normal file
145
tests/test_inter_module.py
Normal file
@ -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
|
||||
171
tests/test_netlist_parser.py
Normal file
171
tests/test_netlist_parser.py
Normal file
@ -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"
|
||||
145
tests/test_roundtrip.py
Normal file
145
tests/test_roundtrip.py
Normal file
@ -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
|
||||
104
tests/test_single_module.py
Normal file
104
tests/test_single_module.py
Normal file
@ -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"]
|
||||
124
tests/test_yaml_emitter.py
Normal file
124
tests/test_yaml_emitter.py
Normal file
@ -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
|
||||
Loading…
x
Reference in New Issue
Block a user