Fix linting issues and ground/power filtering in inter-module mapper

- Use CalVer dot notation (2026.2.13) for PEP 440 compliance
- Filter pinlabels for top-level components when ground/power hidden
- Fix unused variables, long lines, import ordering (ruff clean)
- Use StrEnum for PinDirection (Python 3.11+)
- Add .gitignore and README.md
- All 105 tests pass including WireViz roundtrip validation
This commit is contained in:
Ryan Malloy 2026-02-13 01:27:45 -07:00
parent e20a956f51
commit eb3ad60bd1
13 changed files with 1250 additions and 38 deletions

12
.gitignore vendored Normal file
View File

@ -0,0 +1,12 @@
__pycache__/
*.pyc
*.pyo
.venv/
*.egg-info/
dist/
build/
.pytest_cache/
.ruff_cache/
*.svg
*.png
*.html

58
README.md Normal file
View File

@ -0,0 +1,58 @@
# spice2wireviz
Convert LTspice SPICE netlists to WireViz wiring diagrams.
## What it does
`spice2wireviz` reads SPICE netlist files (`.net`, `.cir`, `.sp`) and generates [WireViz](https://github.com/wireviz/WireViz) YAML that documents the physical wiring: connectors, test points, and inter-module cables.
Two operating modes:
- **Single module** — External interface of one subcircuit (its connectors, test points, port interface)
- **Inter-module** — How multiple subcircuits/boards connect to each other
## Install
```bash
uv tool install spice2wireviz
# or
pip install spice2wireviz
```
## Usage
```bash
# Inter-module wiring (auto-detected from top-level X instances)
spice2wireviz top_level.net -o wiring.yml --render
# Single module external interface
spice2wireviz design.net -s amplifier_board -o amp.yml
# Only connectors and test points, no ground
spice2wireviz design.net --include-prefixes J,TP --no-ground
# Inspect before converting
spice2wireviz design.net --list-subcircuits
spice2wireviz design.net --list-components
spice2wireviz design.net --dry-run
```
## Filtering
Cherry-pick what appears in the diagram:
```bash
--include-prefixes J,TP # Only these component types
--exclude-refs X3,J_DEBUG # Hide specific references
--include-nets "SIG_*" # Glob patterns for net names
--no-ground # Hide GND connections
--no-power # Hide VCC/VDD connections
```
## Development
```bash
uv sync --extra dev
uv run pytest
uv run ruff check src/ tests/
```

View File

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "spice2wireviz" name = "spice2wireviz"
version = "2026-02-13" version = "2026.2.13"
description = "Convert LTspice SPICE netlists to WireViz wiring diagrams" description = "Convert LTspice SPICE netlists to WireViz wiring diagrams"
authors = [{name = "Ryan Malloy", email = "ryan@supported.systems"}] authors = [{name = "Ryan Malloy", email = "ryan@supported.systems"}]
requires-python = ">=3.11" requires-python = ">=3.11"

View File

@ -1,3 +1,3 @@
"""spice2wireviz — Convert LTspice SPICE netlists to WireViz wiring diagrams.""" """spice2wireviz — Convert LTspice SPICE netlists to WireViz wiring diagrams."""
__version__ = "2026-02-13" __version__ = "2026.2.13"

View File

@ -68,10 +68,7 @@ def filter_component(comp: SpiceComponent, config: FilterConfig, netlist: Parsed
# Specific reference filtering # Specific reference filtering
if config.exclude_refs and comp.reference in config.exclude_refs: if config.exclude_refs and comp.reference in config.exclude_refs:
return False return False
if config.include_refs and comp.reference not in config.include_refs: return not (config.include_refs and comp.reference not in config.include_refs)
return False
return True
def filter_instance( def filter_instance(
@ -93,10 +90,10 @@ def filter_instance(
# Subcircuit name filtering # Subcircuit name filtering
if config.exclude_subcircuits and inst.subcircuit_name in config.exclude_subcircuits: if config.exclude_subcircuits and inst.subcircuit_name in config.exclude_subcircuits:
return False return False
if config.include_subcircuits and inst.subcircuit_name not in config.include_subcircuits: return not (
return False config.include_subcircuits
and inst.subcircuit_name not in config.include_subcircuits
return True )
def filter_net(net: str, config: FilterConfig, netlist: ParsedNetlist) -> bool: def filter_net(net: str, config: FilterConfig, netlist: ParsedNetlist) -> bool:

View File

@ -13,9 +13,14 @@ from __future__ import annotations
from collections import defaultdict from collections import defaultdict
from typing import Any from typing import Any
from ..emitter.yaml_emitter import assemble_wireviz_doc, build_cable, build_connection, build_connector 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 ..filter import FilterConfig, filter_component, filter_instance, filter_net
from ..parser.models import ParsedNetlist, SubcircuitInstance from ..parser.models import ParsedNetlist
def _net_wire_color(net: str, netlist: ParsedNetlist) -> str: def _net_wire_color(net: str, netlist: ParsedNetlist) -> str:
@ -67,7 +72,6 @@ def map_inter_module(
for inst in filtered_instances: for inst in filtered_instances:
conn_name = _make_connector_name(inst.reference) conn_name = _make_connector_name(inst.reference)
port_labels = list(inst.port_to_net.keys())
filtered_ports: list[str] = [] filtered_ports: list[str] = []
filtered_nets: list[str] = [] filtered_nets: list[str] = []
@ -87,7 +91,9 @@ def map_inter_module(
) )
# Register net endpoints # Register net endpoints
for pin_idx, (port, net) in enumerate(zip(filtered_ports, filtered_nets), 1): for pin_idx, (port, net) in enumerate(
zip(filtered_ports, filtered_nets, strict=True), 1
):
net_endpoints[net].append((conn_name, pin_idx, port)) net_endpoints[net].append((conn_name, pin_idx, port))
# --- Create connectors for top-level J*/TP*/P* --- # --- Create connectors for top-level J*/TP*/P* ---
@ -99,20 +105,34 @@ def map_inter_module(
for comp in filtered_components: for comp in filtered_components:
conn_name = _make_connector_name(comp.reference) 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 "" # Filter pins by net — only include pins whose net passes the filter
filtered_labels: list[str] = []
filtered_nodes: list[str] = []
for pin in comp.pins:
if filter_net(pin.net_name, config, netlist):
filtered_labels.append(pin.name)
filtered_nodes.append(pin.net_name)
if not filtered_labels and not comp.nodes:
continue
style = (
"simple"
if comp.prefix.upper() == "TP" and len(filtered_labels) == 1
else ""
)
connectors[conn_name] = build_connector( connectors[conn_name] = build_connector(
conn_name, conn_name,
pinlabels=pin_labels if pin_labels else None, pinlabels=filtered_labels if filtered_labels else None,
pincount=len(comp.nodes) if not pin_labels else None, pincount=len(filtered_nodes) if not filtered_labels else None,
connector_type=comp.value or comp.prefix, connector_type=comp.value or comp.prefix,
style=style, style=style,
notes=f"SPICE ref: {comp.reference}", notes=f"SPICE ref: {comp.reference}",
) )
for pin_idx, node in enumerate(comp.nodes, 1): for pin_idx, node in enumerate(filtered_nodes, 1):
if filter_net(node, config, netlist):
net_endpoints[node].append((conn_name, pin_idx, node)) net_endpoints[node].append((conn_name, pin_idx, node))
# --- Trace connections via shared nets --- # --- Trace connections via shared nets ---
@ -163,7 +183,6 @@ def map_inter_module(
else: else:
# Individual wires (one cable per wire) # Individual wires (one cable per wire)
for a_pin, b_pin, net in wires: for a_pin, b_pin, net in wires:
cable_counter_inner = cable_counter
cable_name = f"W{cable_counter}" cable_name = f"W{cable_counter}"
wire_color = _net_wire_color(net, netlist) wire_color = _net_wire_color(net, netlist)

View File

@ -14,7 +14,12 @@ from __future__ import annotations
from typing import Any from typing import Any
from ..emitter.yaml_emitter import assemble_wireviz_doc, build_cable, build_connection, build_connector from ..emitter.yaml_emitter import (
assemble_wireviz_doc,
build_cable,
build_connection,
build_connector,
)
from ..filter import FilterConfig, filter_component, filter_net from ..filter import FilterConfig, filter_component, filter_net
from ..parser.models import ParsedNetlist, SpiceComponent, SubcircuitDef from ..parser.models import ParsedNetlist, SpiceComponent, SubcircuitDef
@ -119,9 +124,11 @@ def map_single_module(
cable_wires = list(range(1, len(shared_nets) + 1)) cable_wires = list(range(1, len(shared_nets) + 1))
if header_pins and comp_pins and len(header_pins) == len(comp_pins): if header_pins and comp_pins and len(header_pins) == len(comp_pins):
connections.append( connections.append(build_connection(
build_connection(header_name, header_pins, cable_name, cable_wires, comp_name, comp_pins) header_name, header_pins,
) cable_name, cable_wires,
comp_name, comp_pins,
))
return assemble_wireviz_doc(connectors, cables, connections, metadata) return assemble_wireviz_doc(connectors, cables, connections, metadata)

View File

@ -12,13 +12,7 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from .models import ( from .models import ParsedNetlist, SpiceComponent, SubcircuitDef, SubcircuitInstance
ParsedNetlist,
SpiceComponent,
SpicePin,
SubcircuitDef,
SubcircuitInstance,
)
from .netlist import BOUNDARY_PREFIXES, _extract_prefix from .netlist import BOUNDARY_PREFIXES, _extract_prefix

View File

@ -7,12 +7,12 @@ components (J*, TP*, P*), subcircuit instances (X*), and net connectivity.
from __future__ import annotations from __future__ import annotations
from enum import Enum from enum import StrEnum
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
class PinDirection(str, Enum): class PinDirection(StrEnum):
UNKNOWN = "unknown" UNKNOWN = "unknown"
INPUT = "input" INPUT = "input"
OUTPUT = "output" OUTPUT = "output"

View File

@ -110,8 +110,11 @@ def parse_netlist(source: str | Path) -> ParsedNetlist:
Returns: Returns:
ParsedNetlist with subcircuit defs, instances, and components. ParsedNetlist with subcircuit defs, instances, and components.
""" """
netlist_suffixes = {".net", ".cir", ".sp"}
if isinstance(source, Path) or ( if isinstance(source, Path) or (
isinstance(source, str) and not "\n" in source and Path(source).suffix in {".net", ".cir", ".sp"} isinstance(source, str)
and "\n" not in source
and Path(source).suffix in netlist_suffixes
): ):
path = Path(source) path = Path(source)
if path.exists(): if path.exists():

View File

@ -14,7 +14,7 @@ class TestCLIBasic:
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["--version"]) result = runner.invoke(main, ["--version"])
assert result.exit_code == 0 assert result.exit_code == 0
assert "2026-02-13" in result.output assert "2026.2.13" in result.output
def test_list_subcircuits(self): def test_list_subcircuits(self):
runner = CliRunner() runner = CliRunner()

View File

@ -1,6 +1,12 @@
"""Tests for the filter engine.""" """Tests for the filter engine."""
from spice2wireviz.filter import FilterConfig, apply_filters, filter_component, filter_instance, filter_net from spice2wireviz.filter import (
FilterConfig,
apply_filters,
filter_component,
filter_instance,
filter_net,
)
from spice2wireviz.parser.models import ( from spice2wireviz.parser.models import (
ParsedNetlist, ParsedNetlist,
SpiceComponent, SpiceComponent,

1116
uv.lock generated Normal file

File diff suppressed because it is too large Load Diff