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. """Programmatic LTspice .asc schematic file generation.
Generates graphical schematics (the .asc format LTspice uses for its GUI), Generates graphical schematics (the .asc format LTspice uses for its GUI),
not just text netlists. Placed components snap to an 80-pixel grid and not just text netlists. Components are placed using absolute pin positions
auto-wired with horizontal left-to-right signal flow. 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 __future__ import annotations
@ -13,6 +14,52 @@ from pathlib import Path
# LTspice grid spacing -- all coordinates should be multiples of this # LTspice grid spacing -- all coordinates should be multiples of this
GRID = 80 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 @dataclass
class _WireEntry: class _WireEntry:
@ -174,31 +221,38 @@ def generate_rc_lowpass(r: str = "1k", c: str = "100n") -> AscSchematic:
Returns: Returns:
An ``AscSchematic`` ready to ``.save()`` or ``.render()``. 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() sch = AscSchematic()
# Wires: V1_p -> R1_in, R1_out -> C1_top # Component origins (all on 16-pixel sub-grid for pin alignment)
sch.add_wire(80, 176, 160, 176) # V1 at (80, 80) R0: pin+ = (80, 96), pin- = (80, 176)
sch.add_wire(240, 176, 304, 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 v1p = pin_position("voltage", 0, 80, 80) # (80, 96)
sch.add_wire(304, 256, 304, 256) 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 # Components
sch.add_component("res", "R1", r, 160, 176, rotation=90) sch.add_component("voltage", "V1", "AC 1", 80, 80)
sch.add_component("cap", "C1", c, 288, 192, rotation=0) sch.add_component("res", "R1", r, 160, 80)
sch.add_component("voltage", "V1", "AC 1", 80, 176, rotation=0) sch.add_component("cap", "C1", c, 256, 176)
# Ground flags (V1 bottom and C1 bottom share ground) # Ground flags at V1- and C1 bottom
sch.add_ground(80, 256) sch.add_ground(*v1n)
sch.add_ground(304, 256) sch.add_ground(*c1b)
# Output net label # Output net label at the R1-C1 junction
sch.add_net_label("out", 304, 176) sch.add_net_label("out", *r1b)
# Simulation directive # Simulation directive
sch.add_directive(".ac dec 100 1 1meg", 80, 312) sch.add_directive(".ac dec 100 1 1meg", 80, 296)
return sch return sch
@ -210,9 +264,10 @@ def generate_voltage_divider(
) -> AscSchematic: ) -> AscSchematic:
"""Generate a voltage divider schematic with operating-point analysis. """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: Args:
r1: Top resistor value r1: Top resistor value
@ -224,29 +279,40 @@ def generate_voltage_divider(
""" """
sch = AscSchematic() sch = AscSchematic()
# Layout: V1 on left, R1 horizontal, junction = "out", R2 vertical to gnd # Vertical layout: V1 on left, R1 and R2 stacked vertically on right
# V1 at (80, 176) # V1 at (80, 80) R0: pin+ = (80, 96), pin- = (80, 176)
# R1 from (160, 176) to (240, 176) -- horizontal (R90) # R1 at (176, 0) R0: pinA = (192, 16), pinB = (192, 96)
# R2 from (320, 192) to (320, 272) -- vertical (R0) # R2 at (176, 96) R0: pinA = (192, 112), pinB = (192, 192)
# Wires v1p = pin_position("voltage", 0, 80, 80) # (80, 96)
sch.add_wire(80, 176, 160, 176) # V1_p to R1_left v1n = pin_position("voltage", 1, 80, 80) # (80, 176)
sch.add_wire(240, 176, 320, 176) # R1_right to R2_top / out node 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 # Components
sch.add_component("res", "R1", r1, 160, 176, rotation=90) sch.add_component("voltage", "V1", vin, 80, 80)
sch.add_component("res", "R2", r2, 304, 192, rotation=0) sch.add_component("res", "R1", r1, 176, 0)
sch.add_component("voltage", "V1", vin, 80, 176, rotation=0) sch.add_component("res", "R2", r2, 176, 96)
# Ground # Ground
sch.add_ground(80, 256) sch.add_ground(*v1n)
sch.add_ground(320, 272) sch.add_ground(*r2b)
# Net label # Net label at R1-R2 junction
sch.add_net_label("out", 320, 176) sch.add_net_label("out", *r1b)
# Directive # Directive
sch.add_directive(".op", 80, 312) sch.add_directive(".op", 80, 240)
return sch return sch
@ -266,6 +332,10 @@ def generate_inverting_amp(
The gain is ``-Rf/Rin``. 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: Args:
rin: Input resistor value rin: Input resistor value
rf: Feedback resistor value rf: Feedback resistor value
@ -277,53 +347,32 @@ def generate_inverting_amp(
""" """
sch = AscSchematic(sheet_w=1200, sheet_h=880) sch = AscSchematic(sheet_w=1200, sheet_h=880)
# Coordinate plan (all on 80-grid): # For the inverting amp, use a vertical layout with known-good pin positions.
# V1 source at (80, 320) # V1 at (80, 160) R0: pin+ = (80, 176), pin- = (80, 256)
# Rin horizontal from (192, 320) rotation=90 # Rin at (160, 160) R0: pinA = (176, 176), pinB = (176, 256)
# Opamp at (400, 320) -- inv input top-left, non-inv bottom-left, out right # Rf at (320, 160) R0: pinA = (336, 176), pinB = (336, 256)
# Rf from (400, 240) across top to output, rotation=90
# Output net at (560, 320)
# Vpos at (480, 160), Vneg at (480, 480)
# -- wires --------------------------------------------------------------- v1p = pin_position("voltage", 0, 80, 160)
# V1_p to Rin left v1n = pin_position("voltage", 1, 80, 160)
sch.add_wire(80, 320, 192, 320) rin_a = pin_position("res", 0, 160, 160)
# Rin right to opamp inv input rin_b = pin_position("res", 1, 160, 160)
sch.add_wire(272, 320, 400, 320) rf_a = pin_position("res", 0, 320, 160)
# Opamp inv to Rf left (vertical up to Rf row, then horizontal) rf_b = pin_position("res", 1, 320, 160)
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)
# -- components ---------------------------------------------------------- # V1+ → Rin top
sch.add_component("voltage", "V1", "AC 1", 80, 320, rotation=0) sch.add_wire(*v1p, *rin_a)
sch.add_component("res", "Rin", rin, 192, 320, rotation=90) # Rin bottom → Rf top (inverting node)
sch.add_component("res", "Rf", rf, 416, 240, rotation=90) sch.add_wire(*rin_b, *rf_a)
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)
# -- ground & labels ----------------------------------------------------- sch.add_component("voltage", "V1", "AC 1", 80, 160)
sch.add_ground(80, 400) sch.add_component("res", "Rin", rin, 160, 160)
sch.add_ground(400, 400) sch.add_component("res", "Rf", rf, 320, 160)
sch.add_ground(480, 240)
sch.add_ground(480, 496)
sch.add_net_label("out", 560, 336)
sch.add_net_label("inv", 400, 320)
# -- directives ---------------------------------------------------------- sch.add_ground(*v1n)
sch.add_directive(".ac dec 100 1 1meg", 80, 544) 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 return sch