mcltspice/src/mcp_ltspice/asc_generator.py
Ryan Malloy d2d33fff57 Fix schematic generator pin positions using actual .asy data
The .asc schematic templates had wrong pin offsets, causing LTspice
to extract netlists with disconnected (NC_*) nodes and singular
matrix errors.

Fixed by reading pin positions from the .asy symbol files and applying
the correct CCW rotation transform: R90 maps (px, py) → (-py, px).

Pin offsets: voltage (+0,+16)/(+0,+96), res (+16,+16)/(+16,+96),
cap (+16,+0)/(+16,+64). Added pin_position() helper and _PIN_OFFSETS
table for reuse by all layout functions.

Verified end-to-end: generate_rc_lowpass → simulate → bandwidth gives
1587.8 Hz vs theoretical 1591.5 Hz (0.24% error).
2026-02-10 23:15:48 -07:00

379 lines
12 KiB
Python

"""Programmatic LTspice .asc schematic file generation.
Generates graphical schematics (the .asc format LTspice uses for its GUI),
not just text netlists. Components are placed using absolute pin positions
derived from the .asy symbol files and rotated with the standard CCW
transform: R90 applies (px, py) → (-py, px) relative to the component origin.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
# LTspice grid spacing -- all coordinates should be multiples of this
GRID = 80
# Pin positions (relative to component origin) from LTspice .asy files.
# R0 orientation. Key = symbol name, value = list of (px, py) per pin.
_PIN_OFFSETS: dict[str, list[tuple[int, int]]] = {
"voltage": [(0, 16), (0, 96)], # pin+ , pin-
"res": [(16, 16), (16, 96)], # pinA , pinB
"cap": [(16, 0), (16, 64)], # pinA , pinB
"ind": [(16, 16), (16, 96)], # pinA , pinB (same body as res)
}
def _rotate(px: int, py: int, rotation: int) -> tuple[int, int]:
"""Apply LTspice rotation to a relative pin offset.
LTspice rotations are counterclockwise: (px, py) → (-py, px) for R90.
"""
if rotation == 0:
return px, py
if rotation == 90:
return -py, px
if rotation == 180:
return -px, -py
if rotation == 270:
return py, -px
return px, py
def pin_position(
symbol: str, pin_index: int, cx: int, cy: int, rotation: int = 0
) -> tuple[int, int]:
"""Compute absolute pin position for a component.
Args:
symbol: Base symbol name (``"res"``, ``"cap"``, ``"voltage"``, ...)
pin_index: 0 for first pin, 1 for second pin, etc.
cx: Component origin X
cy: Component origin Y
rotation: 0, 90, 180, or 270
Returns:
Absolute (x, y) of the pin.
"""
offsets = _PIN_OFFSETS.get(symbol, [(0, 0), (0, 80)])
px, py = offsets[pin_index % len(offsets)]
rx, ry = _rotate(px, py, rotation)
return cx + rx, cy + ry
@dataclass
class _WireEntry:
x1: int
y1: int
x2: int
y2: int
@dataclass
class _SymbolEntry:
symbol: str
name: str
value: str
x: int
y: int
rotation: int # 0, 90, 180, 270
windows: list[str] = field(default_factory=list)
@dataclass
class _FlagEntry:
x: int
y: int
name: str
@dataclass
class _TextEntry:
x: int
y: int
content: str
class AscSchematic:
"""Builder for LTspice .asc schematic files.
All ``add_*`` methods return ``self`` for chaining::
sch = (AscSchematic()
.add_component("res", "R1", "1k", 160, 176, rotation=90)
.add_wire(80, 176, 160, 176)
.add_ground(80, 256))
"""
def __init__(self, sheet_w: int = 880, sheet_h: int = 680) -> None:
self._sheet_w = sheet_w
self._sheet_h = sheet_h
self._wires: list[_WireEntry] = []
self._symbols: list[_SymbolEntry] = []
self._flags: list[_FlagEntry] = []
self._texts: list[_TextEntry] = []
# -- builder methods -----------------------------------------------------
def add_component(
self,
symbol: str,
name: str,
value: str,
x: int,
y: int,
rotation: int = 0,
) -> AscSchematic:
"""Place a component symbol.
Args:
symbol: LTspice symbol name (``res``, ``cap``, ``voltage``, etc.)
name: Instance name (``R1``, ``C1``, ``V1``, ...)
value: Component value (``1k``, ``100n``, ``AC 1``, ...)
x: X coordinate (should be on 80-pixel grid)
y: Y coordinate
rotation: 0, 90, 180, or 270 degrees
"""
windows: list[str] = []
# For resistors and inductors placed at R90, shift the WINDOW lines
# so labels sit neatly above/below the body
if rotation == 90 and symbol in ("res", "ind", "ind2"):
windows = [
"WINDOW 0 0 56 VBottom 2",
"WINDOW 3 32 56 VTop 2",
]
self._symbols.append(_SymbolEntry(symbol, name, value, x, y, rotation, windows))
return self
def add_wire(self, x1: int, y1: int, x2: int, y2: int) -> AscSchematic:
"""Add a wire segment between two points."""
self._wires.append(_WireEntry(x1, y1, x2, y2))
return self
def add_ground(self, x: int, y: int) -> AscSchematic:
"""Place a ground flag (net name ``0``)."""
self._flags.append(_FlagEntry(x, y, "0"))
return self
def add_net_label(self, name: str, x: int, y: int) -> AscSchematic:
"""Place a named net label (e.g., ``out``, ``vdd``)."""
self._flags.append(_FlagEntry(x, y, name))
return self
def add_directive(self, text: str, x: int, y: int) -> AscSchematic:
"""Add a SPICE directive (rendered with ``!`` prefix)."""
self._texts.append(_TextEntry(x, y, text))
return self
# -- output --------------------------------------------------------------
def render(self) -> str:
"""Render the schematic to an ``.asc`` format string."""
lines: list[str] = []
lines.append("Version 4")
lines.append(f"SHEET 1 {self._sheet_w} {self._sheet_h}")
for w in self._wires:
lines.append(f"WIRE {w.x1} {w.y1} {w.x2} {w.y2}")
for f in self._flags:
lines.append(f"FLAG {f.x} {f.y} {f.name}")
for s in self._symbols:
lines.append(f"SYMBOL {s.symbol} {s.x} {s.y} R{s.rotation}")
for win in s.windows:
lines.append(win)
lines.append(f"SYMATTR InstName {s.name}")
lines.append(f"SYMATTR Value {s.value}")
for t in self._texts:
lines.append(f"TEXT {t.x} {t.y} Left 2 !{t.content}")
return "\n".join(lines) + "\n"
def save(self, path: Path | str) -> Path:
"""Write the schematic to an ``.asc`` file on disk."""
path = Path(path)
path.write_text(self.render(), encoding="utf-8")
return path
# ============================================================================
# Layout helper functions -- auto-placed, ready-to-simulate schematics
# ============================================================================
def generate_rc_lowpass(r: str = "1k", c: str = "100n") -> AscSchematic:
"""Generate an RC lowpass filter schematic with AC analysis.
Signal flow (left to right)::
V1 --[R1]-- out --+
|
[C1]
|
GND
Args:
r: Resistor value (e.g., ``"1k"``, ``"4.7k"``)
c: Capacitor value (e.g., ``"100n"``, ``"10p"``)
Returns:
An ``AscSchematic`` ready to ``.save()`` or ``.render()``.
"""
sch = AscSchematic()
# Component origins (all on 16-pixel sub-grid for pin alignment)
# V1 at (80, 80) R0: pin+ = (80, 96), pin- = (80, 176)
# R1 at (160, 80) R0: pinA = (176, 96), pinB = (176, 176)
# C1 at (256, 176) R0: pinA = (272, 176), pinB = (272, 240)
v1p = pin_position("voltage", 0, 80, 80) # (80, 96)
v1n = pin_position("voltage", 1, 80, 80) # (80, 176)
r1a = pin_position("res", 0, 160, 80) # (176, 96)
r1b = pin_position("res", 1, 160, 80) # (176, 176)
c1a = pin_position("cap", 0, 256, 176) # (272, 176)
c1b = pin_position("cap", 1, 256, 176) # (272, 240)
# Wires
sch.add_wire(*v1p, *r1a) # V1+ to R1 top
sch.add_wire(*r1b, *c1a) # R1 bottom to C1 top (junction = "out")
# Components
sch.add_component("voltage", "V1", "AC 1", 80, 80)
sch.add_component("res", "R1", r, 160, 80)
sch.add_component("cap", "C1", c, 256, 176)
# Ground flags at V1- and C1 bottom
sch.add_ground(*v1n)
sch.add_ground(*c1b)
# Output net label at the R1-C1 junction
sch.add_net_label("out", *r1b)
# Simulation directive
sch.add_directive(".ac dec 100 1 1meg", 80, 296)
return sch
def generate_voltage_divider(
r1: str = "10k",
r2: str = "10k",
vin: str = "5",
) -> AscSchematic:
"""Generate a voltage divider schematic with operating-point analysis.
Topology (vertical)::
V1+ --[R1]-- out --[R2]-- GND
V1- = GND
Args:
r1: Top resistor value
r2: Bottom resistor value
vin: DC input voltage
Returns:
An ``AscSchematic`` ready to ``.save()`` or ``.render()``.
"""
sch = AscSchematic()
# Vertical layout: V1 on left, R1 and R2 stacked vertically on right
# V1 at (80, 80) R0: pin+ = (80, 96), pin- = (80, 176)
# R1 at (176, 0) R0: pinA = (192, 16), pinB = (192, 96)
# R2 at (176, 96) R0: pinA = (192, 112), pinB = (192, 192)
v1p = pin_position("voltage", 0, 80, 80) # (80, 96)
v1n = pin_position("voltage", 1, 80, 80) # (80, 176)
r1a = pin_position("res", 0, 176, 0) # (192, 16)
r1b = pin_position("res", 1, 176, 0) # (192, 96)
r2a = pin_position("res", 0, 176, 96) # (192, 112)
r2b = pin_position("res", 1, 176, 96) # (192, 192)
# Wires: V1+ to R1 top, R1 bottom to R2 top (junction = "out")
sch.add_wire(*v1p, *r1a) # need to connect (80,96) to (192,16)
# Route: V1+ up then right then down to R1 top
sch.add_wire(80, 96, 80, 16)
sch.add_wire(80, 16, 192, 16)
# R1 bottom to R2 top
sch.add_wire(*r1b, *r2a)
# Components
sch.add_component("voltage", "V1", vin, 80, 80)
sch.add_component("res", "R1", r1, 176, 0)
sch.add_component("res", "R2", r2, 176, 96)
# Ground
sch.add_ground(*v1n)
sch.add_ground(*r2b)
# Net label at R1-R2 junction
sch.add_net_label("out", *r1b)
# Directive
sch.add_directive(".op", 80, 240)
return sch
def generate_inverting_amp(
rin: str = "10k",
rf: str = "100k",
opamp_model: str = "UniversalOpamp2",
) -> AscSchematic:
"""Generate an inverting op-amp amplifier schematic.
Topology::
V1 --[Rin]--> inv(-) --[Rf]--> out
non-inv(+) --> GND
Supply: Vpos=+15V, Vneg=-15V
The gain is ``-Rf/Rin``.
This template uses the netlist (.cir) builder approach for the
op-amp since op-amp symbols have complex multi-pin layouts. The
generated .asc is a placeholder showing the RC input network.
Args:
rin: Input resistor value
rf: Feedback resistor value
opamp_model: Op-amp symbol name (default ``UniversalOpamp2``,
the built-in ideal op-amp that needs no external model file)
Returns:
An ``AscSchematic`` ready to ``.save()`` or ``.render()``.
"""
sch = AscSchematic(sheet_w=1200, sheet_h=880)
# For the inverting amp, use a vertical layout with known-good pin positions.
# V1 at (80, 160) R0: pin+ = (80, 176), pin- = (80, 256)
# Rin at (160, 160) R0: pinA = (176, 176), pinB = (176, 256)
# Rf at (320, 160) R0: pinA = (336, 176), pinB = (336, 256)
v1p = pin_position("voltage", 0, 80, 160)
v1n = pin_position("voltage", 1, 80, 160)
rin_a = pin_position("res", 0, 160, 160)
rin_b = pin_position("res", 1, 160, 160)
rf_a = pin_position("res", 0, 320, 160)
rf_b = pin_position("res", 1, 320, 160)
# V1+ → Rin top
sch.add_wire(*v1p, *rin_a)
# Rin bottom → Rf top (inverting node)
sch.add_wire(*rin_b, *rf_a)
sch.add_component("voltage", "V1", "AC 1", 80, 160)
sch.add_component("res", "Rin", rin, 160, 160)
sch.add_component("res", "Rf", rf, 320, 160)
sch.add_ground(*v1n)
sch.add_ground(*rf_b)
sch.add_net_label("inv", *rin_b)
sch.add_net_label("out", *rf_a)
sch.add_directive(".ac dec 100 1 1meg", 80, 320)
return sch