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).
This commit is contained in:
Ryan Malloy 2026-02-10 23:15:48 -07:00
parent ba649d2a6e
commit d2d33fff57

View File

@ -1,8 +1,9 @@
"""Programmatic LTspice .asc schematic file generation.
Generates graphical schematics (the .asc format LTspice uses for its GUI),
not just text netlists. Placed components snap to an 80-pixel grid and
auto-wired with horizontal left-to-right signal flow.
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
@ -13,6 +14,52 @@ 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:
@ -174,31 +221,38 @@ def generate_rc_lowpass(r: str = "1k", c: str = "100n") -> AscSchematic:
Returns:
An ``AscSchematic`` ready to ``.save()`` or ``.render()``.
"""
# Grid positions (all multiples of GRID=80)
# V1 at x=80, R1 from 160..240, C1 at 304, out label at 304
sch = AscSchematic()
# Wires: V1_p -> R1_in, R1_out -> C1_top
sch.add_wire(80, 176, 160, 176)
sch.add_wire(240, 176, 304, 176)
# 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)
# Vertical wire for cap bottom to ground
sch.add_wire(304, 256, 304, 256)
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("res", "R1", r, 160, 176, rotation=90)
sch.add_component("cap", "C1", c, 288, 192, rotation=0)
sch.add_component("voltage", "V1", "AC 1", 80, 176, rotation=0)
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 (V1 bottom and C1 bottom share ground)
sch.add_ground(80, 256)
sch.add_ground(304, 256)
# Ground flags at V1- and C1 bottom
sch.add_ground(*v1n)
sch.add_ground(*c1b)
# Output net label
sch.add_net_label("out", 304, 176)
# 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, 312)
sch.add_directive(".ac dec 100 1 1meg", 80, 296)
return sch
@ -210,9 +264,10 @@ def generate_voltage_divider(
) -> AscSchematic:
"""Generate a voltage divider schematic with operating-point analysis.
Topology::
Topology (vertical)::
V1 --[R1]-- out --[R2]-- GND
V1+ --[R1]-- out --[R2]-- GND
V1- = GND
Args:
r1: Top resistor value
@ -224,29 +279,40 @@ def generate_voltage_divider(
"""
sch = AscSchematic()
# Layout: V1 on left, R1 horizontal, junction = "out", R2 vertical to gnd
# V1 at (80, 176)
# R1 from (160, 176) to (240, 176) -- horizontal (R90)
# R2 from (320, 192) to (320, 272) -- vertical (R0)
# 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)
# Wires
sch.add_wire(80, 176, 160, 176) # V1_p to R1_left
sch.add_wire(240, 176, 320, 176) # R1_right to R2_top / out node
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("res", "R1", r1, 160, 176, rotation=90)
sch.add_component("res", "R2", r2, 304, 192, rotation=0)
sch.add_component("voltage", "V1", vin, 80, 176, rotation=0)
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(80, 256)
sch.add_ground(320, 272)
sch.add_ground(*v1n)
sch.add_ground(*r2b)
# Net label
sch.add_net_label("out", 320, 176)
# Net label at R1-R2 junction
sch.add_net_label("out", *r1b)
# Directive
sch.add_directive(".op", 80, 312)
sch.add_directive(".op", 80, 240)
return sch
@ -266,6 +332,10 @@ def generate_inverting_amp(
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
@ -277,53 +347,32 @@ def generate_inverting_amp(
"""
sch = AscSchematic(sheet_w=1200, sheet_h=880)
# Coordinate plan (all on 80-grid):
# V1 source at (80, 320)
# Rin horizontal from (192, 320) rotation=90
# Opamp at (400, 320) -- inv input top-left, non-inv bottom-left, out right
# Rf from (400, 240) across top to output, rotation=90
# Output net at (560, 320)
# Vpos at (480, 160), Vneg at (480, 480)
# 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)
# -- wires ---------------------------------------------------------------
# V1_p to Rin left
sch.add_wire(80, 320, 192, 320)
# Rin right to opamp inv input
sch.add_wire(272, 320, 400, 320)
# Opamp inv to Rf left (vertical up to Rf row, then horizontal)
sch.add_wire(400, 320, 400, 240)
sch.add_wire(400, 240, 416, 240)
# Rf right to output
sch.add_wire(496, 240, 560, 240)
# Output down to opamp output level
sch.add_wire(560, 240, 560, 336)
# Opamp output to out node
sch.add_wire(496, 336, 560, 336)
# Opamp non-inv to ground
sch.add_wire(400, 352, 400, 400)
# Supply wires
sch.add_wire(448, 288, 448, 240)
sch.add_wire(448, 240, 480, 240)
sch.add_wire(448, 368, 448, 416)
sch.add_wire(448, 416, 480, 416)
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)
# -- components ----------------------------------------------------------
sch.add_component("voltage", "V1", "AC 1", 80, 320, rotation=0)
sch.add_component("res", "Rin", rin, 192, 320, rotation=90)
sch.add_component("res", "Rf", rf, 416, 240, rotation=90)
sch.add_component(f"Opamps\\\\{opamp_model}", "U1", opamp_model, 400, 304, rotation=0)
sch.add_component("voltage", "Vpos", "15", 480, 160, rotation=0)
sch.add_component("voltage", "Vneg", "15", 480, 416, rotation=0)
# V1+ → Rin top
sch.add_wire(*v1p, *rin_a)
# Rin bottom → Rf top (inverting node)
sch.add_wire(*rin_b, *rf_a)
# -- ground & labels -----------------------------------------------------
sch.add_ground(80, 400)
sch.add_ground(400, 400)
sch.add_ground(480, 240)
sch.add_ground(480, 496)
sch.add_net_label("out", 560, 336)
sch.add_net_label("inv", 400, 320)
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)
# -- directives ----------------------------------------------------------
sch.add_directive(".ac dec 100 1 1meg", 80, 544)
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