"""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