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).
379 lines
12 KiB
Python
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
|