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:
parent
e20a956f51
commit
eb3ad60bd1
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal 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
58
README.md
Normal 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/
|
||||||
|
```
|
||||||
@ -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"
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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():
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user