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
24 changed files with 2630 additions and 0 deletions
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),
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue