Merge phase6: SVG plots, tuning, templates, integration tests

This commit is contained in:
Ryan Malloy 2026-02-11 12:53:26 -07:00
commit 0c545800f7
16 changed files with 2949 additions and 82 deletions

View File

@ -50,6 +50,9 @@ packages = ["src/mcp_ltspice"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests"]
asyncio_mode = "auto" asyncio_mode = "auto"
markers = [
"integration: tests requiring LTspice/Wine installation (deselect with '-m not integration')",
]
[tool.ruff] [tool.ruff]
line-length = 100 line-length = 100
@ -58,3 +61,6 @@ target-version = "py311"
[tool.ruff.lint] [tool.ruff.lint]
select = ["E", "F", "I", "N", "W", "UP"] select = ["E", "F", "I", "N", "W", "UP"]
ignore = ["E501"] ignore = ["E501"]
[tool.ruff.lint.per-file-ignores]
"tests/**" = ["N806"] # Allow EE-conventional uppercase vars (K, Q, H, Vpk, etc.)

View File

@ -581,40 +581,34 @@ def generate_common_emitter_amp(
qe = pin_position("npn", 2, 400, 288) # emitter qe = pin_position("npn", 2, 400, 288) # emitter
# RC from Vcc rail to collector. # RC from Vcc rail to collector.
# Want pinB at collector (464, 288). res at (448, 192): pinB=(464, 288). Good. # res at (448, 192): pinA=(464, 208)=vcc rail, pinB=(464, 288)=collector
rc_a = pin_position("res", 0, 448, 192) # (464, 208) = vcc rail rc_a = pin_position("res", 0, 448, 192) # (464, 208) = vcc rail
# RE from emitter to GND. # RE from emitter to GND.
# Want pinA at emitter (464, 384). res at (448, 368): pinA=(464, 384). # res at (448, 368): pinA=(464, 384)=emitter, pinB=(464, 464)=GND
re_b = pin_position("res", 1, 448, 368) # (464, 464) = GND re_b = pin_position("res", 1, 448, 368) # (464, 464) = GND
# RB1 from Vcc rail to base. Vertical, placed to the left of Q1. # RB1 from Vcc rail to base. res at (240, 240): pinA=(256, 256), pinB=(256, 336)
# Want pinB at base Y (336). res at (240, 240): pinA=(256, 256), pinB=(256, 336)
rb1_a = pin_position("res", 0, 240, 240) # (256, 256) rb1_a = pin_position("res", 0, 240, 240) # (256, 256)
rb1_b = pin_position("res", 1, 240, 240) # (256, 336) rb1_b = pin_position("res", 1, 240, 240) # (256, 336)
# RB2 from base to GND. # RB2 from base to GND. res at (240, 320): pinA=(256, 336), pinB=(256, 416)
# Want pinA at base Y (336). res at (240, 320): pinA=(256, 336), pinB=(256, 416)
rb2_b = pin_position("res", 1, 240, 320) # (256, 416) rb2_b = pin_position("res", 1, 240, 320) # (256, 416)
# CC_in (input coupling cap) horizontal, connecting input to base. # CC_in (input coupling cap) — VERTICAL (R0) above the base wire.
# cap R90: pinA=origin+(-16,0)→(origin_x, origin_y+16)... wait let me recalculate. # cap R0 at (384, 256): pinA=(400, 256)=input side, pinB=(400, 320)=base side.
# cap R90: pinA offset (16,0) → rotate90 → (0, 16), pinB offset (16,64) → (-64, 16) # Critically, neither pin sits on the horizontal RB1-to-base wire at y=336,
# At origin (368, 320): pinA = (368, 336), pinB = (304, 336) # so the coupling cap is NOT shorted by the bias wire.
# pinA at (368, 336) close to base (400, 336). pinB at (304, 336). ccin_a = pin_position("cap", 0, 384, 256) # (400, 256) = input
# Wire from pinA (368, 336) to base (400, 336). ccin_b = pin_position("cap", 1, 384, 256) # (400, 320) = base side
ccin_a = pin_position("cap", 0, 368, 320, 90) # (368, 336)
ccin_b = pin_position("cap", 1, 368, 320, 90) # (304, 336)
# CC_out (output coupling cap) to the right of collector. # CC_out (output coupling cap) horizontal R90 to the right of collector.
# cap R90 at origin (560, 272): pinA = (560, 288), pinB = (496, 288) # cap R90 at (560, 272): pinA=(560, 288)=output, pinB=(496, 288)=collector side
# pinB near collector (464, 288), pinA extends right to output
# Actually pinB = (560-64, 288) = (496, 288). Wire from collector (464,288) to (496,288).
ccout_a = pin_position("cap", 0, 560, 272, 90) # (560, 288) ccout_a = pin_position("cap", 0, 560, 272, 90) # (560, 288)
ccout_b = pin_position("cap", 1, 560, 272, 90) # (496, 288) ccout_b = pin_position("cap", 1, 560, 272, 90) # (496, 288)
# CE (emitter bypass cap) parallel to RE. # CE (emitter bypass cap) parallel to RE.
# Place to the right of RE. cap at (528, 384): pinA=(544, 384), pinB=(544, 448) # cap at (528, 384): pinA=(544, 384)=emitter, pinB=(544, 448)=GND
ce_a = pin_position("cap", 0, 528, 384) # (544, 384) ce_a = pin_position("cap", 0, 528, 384) # (544, 384)
ce_b = pin_position("cap", 1, 528, 384) # (544, 448) ce_b = pin_position("cap", 1, 528, 384) # (544, 448)
@ -627,7 +621,7 @@ def generate_common_emitter_amp(
vin_n = pin_position("voltage", 1, 80, 288) # (80, 384) vin_n = pin_position("voltage", 1, 80, 288) # (80, 384)
# === WIRING === # === WIRING ===
# Vcc rail at y=208 — route RIGHT from Vcc+ first (avoid crossing Vcc- at 80,192) # Vcc rail at y=208 — route RIGHT from Vcc+ (avoid crossing Vcc- at 80,192)
vcc_y = rc_a[1] # 208 vcc_y = rc_a[1] # 208
sch.add_wire(vcc_p[0], vcc_p[1], 160, vcc_p[1]) # (80,112)→(160,112) sch.add_wire(vcc_p[0], vcc_p[1], 160, vcc_p[1]) # (80,112)→(160,112)
sch.add_wire(160, vcc_p[1], 160, vcc_y) # (160,112)→(160,208) sch.add_wire(160, vcc_p[1], 160, vcc_y) # (160,112)→(160,208)
@ -635,22 +629,22 @@ def generate_common_emitter_amp(
sch.add_wire(rb1_a[0], rb1_a[1], rb1_a[0], vcc_y) # RB1 top up to rail sch.add_wire(rb1_a[0], rb1_a[1], rb1_a[0], vcc_y) # RB1 top up to rail
sch.add_wire(rb1_a[0], vcc_y, rc_a[0], vcc_y) # connect to rail sch.add_wire(rb1_a[0], vcc_y, rc_a[0], vcc_y) # connect to rail
# RB1 bottom to base, RB2 top at same junction # RB1 bottom → base (horizontal at y=336)
sch.add_wire(rb1_b[0], rb1_b[1], qb[0], qb[1]) # RB1 bottom → base sch.add_wire(rb1_b[0], rb1_b[1], qb[0], qb[1])
# CC_in to base # CC_in base side → base junction (short vertical drop)
sch.add_wire(ccin_a[0], ccin_a[1], qb[0], qb[1]) # CC_in right → base sch.add_wire(ccin_b[0], ccin_b[1], qb[0], qb[1]) # (400,320)→(400,336)
# Input: Vin+ to CC_in left # Vin+ → CC_in input side via manhattan L-route
sch.add_wire(vin_p[0], vin_p[1], ccin_b[0], ccin_b[1]) # Vin+ → CC_in left sch.add_wire(vin_p[0], vin_p[1], ccin_a[0], vin_p[1]) # (80,304)→(400,304)
sch.add_wire(ccin_a[0], vin_p[1], ccin_a[0], ccin_a[1]) # (400,304)→(400,256)
# Collector to CC_out # Collector → CC_out left pin
sch.add_wire(qc[0], qc[1], ccout_b[0], ccout_b[1]) # collector → CC_out left sch.add_wire(qc[0], qc[1], ccout_b[0], ccout_b[1])
# Emitter to CE (bypass cap in parallel with RE) # Emitter → CE top (bypass cap in parallel with RE)
sch.add_wire(qe[0], qe[1], ce_a[0], ce_a[1]) # emitter → CE top sch.add_wire(qe[0], qe[1], ce_a[0], ce_a[1])
# CE bottom to RE bottom (shared GND rail) # CE bottom → GND (separate ground flag, no diagonal wire to RE)
sch.add_wire(ce_b[0], ce_b[1], re_b[0], re_b[1]) # CE bot → RE bot
# === COMPONENTS === # === COMPONENTS ===
sch.add_component("npn", "Q1", bjt_model, 400, 288) sch.add_component("npn", "Q1", bjt_model, 400, 288)
@ -658,7 +652,7 @@ def generate_common_emitter_amp(
sch.add_component("res", "RE", re, 448, 368) sch.add_component("res", "RE", re, 448, 368)
sch.add_component("res", "RB1", rb1, 240, 240) sch.add_component("res", "RB1", rb1, 240, 240)
sch.add_component("res", "RB2", rb2, 240, 320) sch.add_component("res", "RB2", rb2, 240, 320)
sch.add_component("cap", "CC1", cc_in, 368, 320, rotation=90) sch.add_component("cap", "CC1", cc_in, 384, 256)
sch.add_component("cap", "CC2", cc_out, 560, 272, rotation=90) sch.add_component("cap", "CC2", cc_out, 560, 272, rotation=90)
sch.add_component("cap", "CE", ce, 528, 384) sch.add_component("cap", "CE", ce, 528, 384)
sch.add_component("voltage", "Vcc", vcc, 80, 96) sch.add_component("voltage", "Vcc", vcc, 80, 96)
@ -669,6 +663,7 @@ def generate_common_emitter_amp(
sch.add_ground(*vin_n) sch.add_ground(*vin_n)
sch.add_ground(*rb2_b) sch.add_ground(*rb2_b)
sch.add_ground(*re_b) sch.add_ground(*re_b)
sch.add_ground(*ce_b) # CE bottom to GND directly (no diagonal wire)
sch.add_net_label("out", *ccout_a) sch.add_net_label("out", *ccout_a)
sch.add_directive(".tran 5m", 80, 528) sch.add_directive(".tran 5m", 80, 528)
@ -1257,3 +1252,670 @@ def generate_h_bridge(
sch.add_directive(".tran 5m", 80, 592) sch.add_directive(".tran 5m", 80, 592)
return sch return sch
def generate_sallen_key_lowpass(
r1: str = "10k",
r2: str = "10k",
c1: str = "10n",
c2: str = "10n",
) -> AscSchematic:
"""Generate a Sallen-Key lowpass filter schematic (unity gain).
Topology::
V1 --[R1]-- n1 --[R2]-- n2 (In+)
| --> U1 --> out
[C1]--> out In- --> out (unity gain)
|
[C2]
|
GND
The C1 connects n1 to out (feedback). C2 connects n2 to GND.
f_c = 1 / (2*pi*sqrt(R1*R2*C1*C2)). Supply: +/-15V.
Args:
r1: First series resistor
r2: Second series resistor
c1: Feedback capacitor (n1 to out)
c2: Shunt capacitor (n2 to GND)
"""
sch = AscSchematic(sheet_w=1200, sheet_h=880)
oa_sym = "OpAmps/UniversalOpamp2"
# Op-amp U1 at (576, 336)
inp = pin_position(oa_sym, 0, 576, 336) # In+ = (544, 352)
inn = pin_position(oa_sym, 1, 576, 336) # In- = (544, 320)
vp = pin_position(oa_sym, 2, 576, 336) # V+ = (576, 304)
vn = pin_position(oa_sym, 3, 576, 336) # V- = (576, 368)
out = pin_position(oa_sym, 4, 576, 336) # OUT = (608, 336)
# V1 at (80, 256): pin+=(80,272), pin-=(80,352)
v1p = pin_position("voltage", 0, 80, 256)
v1n = pin_position("voltage", 1, 80, 256)
# R1 horizontal (R90) at origin (256, 256): pinA=(240,272), pinB=(160,272)
r1_a = pin_position("res", 0, 256, 256, 90) # (240, 272) = n1 side
r1_b = pin_position("res", 1, 256, 256, 90) # (160, 272) = input side
# R2 horizontal (R90) at origin (416, 256): pinA=(400,272), pinB=(320,272)
r2_a = pin_position("res", 0, 416, 256, 90) # (400, 272) = n2 side
r2_b = pin_position("res", 1, 416, 256, 90) # (320, 272) = n1 side
# C1 vertical: connects n1 (junction R1_a/R2_b at x=272-ish) to out
# We route n1 down to C1, then C1 bottom goes right to out.
# C1 at (304, 336): pinA=(320, 336), pinB=(320, 400)
# Actually let's place C1 horizontal (R90) above the feedback path.
# Better: C1 as a horizontal cap (R90) from n1 to out.
# cap R90: pinA offset (16,0)-> (0,16), pinB offset (16,64)->(-64,16)
# At origin (560, 192): pinA=(560,208), pinB=(496,208)
c1_a = pin_position("cap", 0, 560, 192, 90) # (560, 208) = out side
c1_b = pin_position("cap", 1, 560, 192, 90) # (496, 208) = n1 side
# C2 vertical at (480, 352): pinA=(496,352), pinB=(496,416)
c2_a = pin_position("cap", 0, 480, 352) # (496, 352)
c2_b = pin_position("cap", 1, 480, 352) # (496, 416)
# Supply: Vpos at (752, 176), Vneg at (752, 416)
vpos_p = pin_position("voltage", 0, 752, 176) # (752, 192)
vpos_n = pin_position("voltage", 1, 752, 176) # (752, 272)
vneg_p = pin_position("voltage", 0, 752, 416) # (752, 432)
vneg_n = pin_position("voltage", 1, 752, 416) # (752, 512)
# === WIRING ===
# V1+ to R1 pinB
sch.add_wire(*v1p, *r1_b)
# R1 pinA to R2 pinB (n1 junction at roughly x=272..320)
# n1 junction: R1_a at (240,272), R2_b at (320,272) -- wire between
sch.add_wire(*r1_a, *r2_b)
# R2 pinA to In+ : (400,272) down to (400,352) then right to (544,352)
sch.add_wire(r2_a[0], r2_a[1], r2_a[0], inp[1])
sch.add_wire(r2_a[0], inp[1], inp[0], inp[1])
# n2 junction to C2: from R2_a/In+ junction at (400,352) left to C2 pinA (496,352)
# Actually, n2 is at the In+ side. C2 from n2 to GND.
# n2 is where R2 meets In+. Let's tap off at (496,352) for C2.
sch.add_wire(r2_a[0], inp[1], c2_a[0], c2_a[1])
# C1 feedback: n1 to out via C1
# n1 is at the R1_a / R2_b junction. Wire from n1 up to C1 pinB.
# n1 at y=272, C1 pinB at (496, 208)
# Route from (240,272) up to (240,208) then right to (496,208)
sch.add_wire(r1_a[0], r1_a[1], r1_a[0], c1_b[1])
sch.add_wire(r1_a[0], c1_b[1], c1_b[0], c1_b[1])
# C1 pinA to out: (560,208) down to out (608,336)
sch.add_wire(c1_a[0], c1_a[1], out[0], c1_a[1])
sch.add_wire(out[0], c1_a[1], out[0], out[1])
# Unity gain feedback: out to In-
# (608,336) up to (608,320) ... In- is at (544,320)
# Route: out (608,336) left to (544,336) up... no.
# Actually wire from In- (544,320) right and down to out (608,336).
# Route: (544,320) right to (620,320), down to (620,336), left to (608,336)
# Simpler: go below. (608,336) to (608,400), left to (544,400), up to (544,320)
sch.add_wire(out[0], out[1], out[0], 400)
sch.add_wire(out[0], 400, inn[0], 400)
sch.add_wire(inn[0], 400, inn[0], inn[1])
# Supply wiring
sch.add_wire(vp[0], vp[1], vp[0], vpos_p[1])
sch.add_wire(vp[0], vpos_p[1], vpos_p[0], vpos_p[1])
sch.add_wire(vn[0], vn[1], vn[0], vneg_n[1])
sch.add_wire(vn[0], vneg_n[1], vneg_n[0], vneg_n[1])
# === COMPONENTS ===
sch.add_component("voltage", "V1", "AC 1", 80, 256)
sch.add_component(oa_sym, "U1", "", 576, 336)
sch.add_component("res", "R1", r1, 256, 256, rotation=90)
sch.add_component("res", "R2", r2, 416, 256, rotation=90)
sch.add_component("cap", "C1", c1, 560, 192, rotation=90)
sch.add_component("cap", "C2", c2, 480, 352)
sch.add_component("voltage", "Vpos", "15", 752, 176)
sch.add_component("voltage", "Vneg", "15", 752, 416)
# === FLAGS ===
sch.add_ground(*v1n)
sch.add_ground(*c2_b)
sch.add_ground(*vpos_n)
sch.add_ground(*vneg_p)
sch.add_net_label("out", *out)
sch.add_net_label("n1", r1_a[0], r1_a[1])
sch.add_net_label("n2", r2_a[0], r2_a[1])
sch.add_directive(".ac dec 100 1 1meg", 80, 560)
return sch
def generate_boost_converter(
ind: str = "10u",
c_out: str = "100u",
r_load: str = "50",
v_in: str = "5",
duty_cycle: float = 0.5,
freq: str = "100k",
) -> AscSchematic:
"""Generate a boost (step-up) converter schematic.
Topology::
Vin --[L1]--> sw_node --[D1]--> out
| |
[MOSFET] [Cout] [Rload]
D=sw | |
G=gate GND GND
S=GND
Gate driven by PULSE at switching frequency and duty cycle.
Vout_ideal = Vin / (1 - duty_cycle).
Args:
ind: Inductor value
c_out: Output capacitor
r_load: Load resistor
v_in: Input voltage
duty_cycle: Switching duty cycle (0.0-1.0)
freq: Switching frequency
"""
sch = AscSchematic(sheet_w=1040, sheet_h=680)
# Compute PULSE timing
freq_hz = _parse_spice_value(freq)
period = 1.0 / freq_hz
t_on = period * duty_cycle
t_rise = period * 0.01
t_fall = t_rise
pulse_val = (
f"PULSE(0 {v_in} 0 {t_rise:.4g} {t_fall:.4g} {t_on:.4g} {period:.4g})"
)
mosfet_model = "IRF540N"
diode_model = "1N5819"
# Vin at (80, 48): pin+=(80,64), pin-=(80,144)
vin_p = pin_position("voltage", 0, 80, 48)
vin_n = pin_position("voltage", 1, 80, 48)
# Vgate at (80, 256): pin+=(80,272), pin-=(80,352)
vg_p = pin_position("voltage", 0, 80, 256)
vg_n = pin_position("voltage", 1, 80, 256)
# L1 horizontal (R90) at (192, 48):
# ind R90: pinA offset (16,16)->(-16,16), pinB offset (16,96)->(-96,16)
# At (192, 48): pinA = (176, 64), pinB = (96, 64)
# Actually re-derive: R90 transform: (px,py) -> (-py, px)
# pinA raw = (16,16) -> R90 -> (-16, 16). origin(192,48) + (-16,16) = (176, 64)
# pinB raw = (16,96) -> R90 -> (-96, 16). origin(192,48) + (-96,16) = (96, 64)
# So pinB is on the Vin side, pinA on the sw side. Good.
l1_a = pin_position("ind", 0, 192, 48, 90) # (176, 64) = sw side
l1_b = pin_position("ind", 1, 192, 48, 90) # (96, 64) = vin side
# NMOS at (224, 112): D=(272,112)=sw, G=(224,192), S=(272,208)=GND
# Actually let's position so drain lines up with L1 pinA.
# L1_a = (176, 64). NMOS drain should be at (176, something).
# nmos D offset = (48, 0). So origin = (176-48, y) = (128, y).
# Want drain at y ~ 112 range. Put origin at (128, 112): D=(176,112).
# Then wire from L1_a (176,64) down to D (176,112).
md = pin_position("nmos", 0, 128, 112) # drain (176, 112)
mg = pin_position("nmos", 1, 128, 112) # gate (128, 192)
ms = pin_position("nmos", 2, 128, 112) # source (176, 208)
# Diode from sw_node to output.
# sw_node is at L1_a (176,64). Diode horizontal.
# diode R90: anode offset (16,0)->(0,16), cathode offset (16,64)->(-64,16)
# At origin (320, 48): anode=(320,64), cathode=(256,64)
# We want anode at sw side, cathode at out side. But R90 has cathode to left.
# Use R270 instead: anode (16,0)->R270->(0,-16), cathode (16,64)->R270->(64,-16)
# At origin (240, 80): anode=(240,64), cathode=(304,64)
# Wire from sw (176,64) right to anode (240,64). Cathode (304,64) = output side.
d_anode = pin_position("diode", 0, 240, 80, 270) # (240, 64)
d_cathode = pin_position("diode", 1, 240, 80, 270) # (304, 64)
# Wire from L1_a (176,64) to sw junction, then to diode anode (240,64)
# And from diode cathode (304,64) to out
# Cout at (384, 64): pinA=(400,64), pinB=(400,128)
cout_a = pin_position("cap", 0, 384, 64) # (400, 64)
cout_b = pin_position("cap", 1, 384, 64) # (400, 128)
# Rload at (464, 48): pinA=(480,64), pinB=(480,144)
rl_a = pin_position("res", 0, 464, 48) # (480, 64)
rl_b = pin_position("res", 1, 464, 48) # (480, 144)
# === WIRING ===
# Vin+ to L1 pinB
sch.add_wire(*vin_p, *l1_b)
# L1 pinA (sw) to MOSFET drain
sch.add_wire(l1_a[0], l1_a[1], md[0], md[1])
# L1 pinA (sw) to diode anode
sch.add_wire(l1_a[0], l1_a[1], d_anode[0], d_anode[1])
# Diode cathode to Cout and Rload (output rail)
sch.add_wire(d_cathode[0], d_cathode[1], cout_a[0], cout_a[1])
sch.add_wire(cout_a[0], cout_a[1], rl_a[0], rl_a[1])
# Gate drive: Vgate+ to MOSFET gate
sch.add_wire(vg_p[0], vg_p[1], mg[0], vg_p[1])
sch.add_wire(mg[0], vg_p[1], mg[0], mg[1])
# === COMPONENTS ===
sch.add_component("voltage", "Vin", v_in, 80, 48)
sch.add_component("voltage", "Vgate", pulse_val, 80, 256)
sch.add_component("ind", "L1", ind, 192, 48, rotation=90)
sch.add_component("nmos", "M1", mosfet_model, 128, 112)
sch.add_component("diode", "D1", diode_model, 240, 80, rotation=270)
sch.add_component("cap", "Cout", c_out, 384, 64)
sch.add_component("res", "Rload", r_load, 464, 48)
# === FLAGS ===
sch.add_ground(*vin_n)
sch.add_ground(*vg_n)
sch.add_ground(*ms)
sch.add_ground(*cout_b)
sch.add_ground(*rl_b)
sch.add_net_label("sw", l1_a[0], l1_a[1])
sch.add_net_label("out", d_cathode[0], d_cathode[1])
sch.add_directive(f".tran {period * 200:.4g}", 80, 420)
return sch
def generate_instrumentation_amp(
r1: str = "10k",
r2: str = "10k",
r3: str = "10k",
r_gain: str = "10k",
) -> AscSchematic:
"""Generate a 3-opamp instrumentation amplifier schematic.
Stage 1 (input buffers with gain)::
Vin+ --> In+(X1) --> out1
Vin- --> In+(X2) --> out2
R1 from out1 to In-(X1), R1b from out2 to In-(X2)
Rgain between In-(X1) and In-(X2)
Stage 2 (difference amp)::
out1 --[R2]--> In-(X3) --[R3]--> Vout
out2 --[R2b]--> In+(X3)
In+(X3) --[R3b]--> GND
Gain = (1 + 2*R1/Rgain) * (R3/R2). Supply: +/-15V.
Args:
r1: Stage 1 feedback resistor
r2: Stage 2 input resistor
r3: Stage 2 feedback resistor
r_gain: Gain-setting resistor between X1 and X2 inverting inputs
"""
sch = AscSchematic(sheet_w=1600, sheet_h=1040)
oa_sym = "OpAmps/UniversalOpamp2"
# === STAGE 1 ===
# X1 at (480, 208)
x1_inp = pin_position(oa_sym, 0, 480, 208) # In+ = (448, 224)
x1_inn = pin_position(oa_sym, 1, 480, 208) # In- = (448, 192)
x1_vp = pin_position(oa_sym, 2, 480, 208) # V+ = (480, 176)
x1_vn = pin_position(oa_sym, 3, 480, 208) # V- = (480, 240)
x1_out = pin_position(oa_sym, 4, 480, 208) # OUT = (512, 208)
# X2 at (480, 480)
x2_inp = pin_position(oa_sym, 0, 480, 480) # In+ = (448, 496)
x2_inn = pin_position(oa_sym, 1, 480, 480) # In- = (448, 464)
x2_vp = pin_position(oa_sym, 2, 480, 480) # V+ = (480, 448)
x2_vn = pin_position(oa_sym, 3, 480, 480) # V- = (480, 512)
x2_out = pin_position(oa_sym, 4, 480, 480) # OUT = (512, 480)
# R1: feedback from X1 out to X1 In- (horizontal R90)
# Place at (576, 96): pinA=(560,112), pinB=(480,112)
# We want to connect out1 (512,208) up to (512,112) left to (560,112)=R1_a
# and R1_b (480,112) left to (448,112) down to In- (448,192)
r1_a = pin_position("res", 0, 576, 96, 90) # (560, 112)
r1_b = pin_position("res", 1, 576, 96, 90) # (480, 112)
# R1b: feedback from X2 out to X2 In- (horizontal R90)
# Place at (576, 544): pinA=(560,560), pinB=(480,560)
r1b_a = pin_position("res", 0, 576, 544, 90) # (560, 560)
r1b_b = pin_position("res", 1, 576, 544, 90) # (480, 560)
# Rgain: vertical between X1 In- junction and X2 In- junction
# X1 In- at y=192 area, X2 In- at y=464 area
# We'll connect to the R1/R1b feedback junctions.
# Place Rgain at (400, 208): pinA=(416,224), pinB=(416,304)
# Actually we need it to span from node_a (X1 In- junction) to node_b (X2 In- junction).
# The junctions are at the R1_b / R1b_b x positions.
# Let's use net labels for cleanliness: "node_a" at R1_b junction, "node_b" at R1b_b junction.
# Rgain vertical res at (336, 256): pinA=(352, 272), pinB=(352, 352)
rg_a = pin_position("res", 0, 336, 256) # (352, 272) = node_a side
rg_b = pin_position("res", 1, 336, 256) # (352, 352) = node_b side
# === STAGE 2 ===
# X3 at (880, 352)
x3_inp = pin_position(oa_sym, 0, 880, 352) # In+ = (848, 368)
x3_inn = pin_position(oa_sym, 1, 880, 352) # In- = (848, 336)
x3_vp = pin_position(oa_sym, 2, 880, 352) # V+ = (880, 320)
x3_vn = pin_position(oa_sym, 3, 880, 352) # V- = (880, 384)
x3_out = pin_position(oa_sym, 4, 880, 352) # OUT = (912, 352)
# R2: out1 to X3 In- (horizontal R90)
# Place at (768, 320): pinA=(752,336), pinB=(672,336)
r2_a = pin_position("res", 0, 768, 320, 90) # (752, 336) near X3 In-
r2_b = pin_position("res", 1, 768, 320, 90) # (672, 336) from out1
# R3: X3 In- to Vout (horizontal R90, above X3 for feedback)
# Place at (960, 240): pinA=(944, 256), pinB=(864, 256)
r3_a = pin_position("res", 0, 960, 240, 90) # (944, 256) near Vout
r3_b = pin_position("res", 1, 960, 240, 90) # (864, 256) near In-
# R2b: out2 to X3 In+ (horizontal R90)
# Place at (768, 352): pinA=(752,368), pinB=(672,368)
r2b_a = pin_position("res", 0, 768, 352, 90) # (752, 368) near X3 In+
r2b_b = pin_position("res", 1, 768, 352, 90) # (672, 368) from out2
# R3b: X3 In+ to GND (vertical)
# Place at (832, 416): pinA=(848,432), pinB=(848,512)
r3b_a = pin_position("res", 0, 832, 416) # (848, 432)
r3b_b = pin_position("res", 1, 832, 416) # (848, 512)
# Sources
# V1 at (80, 160): pin+=(80,176), pin-=(80,256)
v1p = pin_position("voltage", 0, 80, 160)
v1n = pin_position("voltage", 1, 80, 160)
# V2 at (80, 432): pin+=(80,448), pin-=(80,528)
v2p = pin_position("voltage", 0, 80, 432)
v2n = pin_position("voltage", 1, 80, 432)
# Supply: Vpos at (1056, 176), Vneg at (1056, 480)
vpos_p = pin_position("voltage", 0, 1056, 176)
vpos_n = pin_position("voltage", 1, 1056, 176)
vneg_p = pin_position("voltage", 0, 1056, 480)
vneg_n = pin_position("voltage", 1, 1056, 480)
# === WIRING ===
# V1+ to X1 In+
sch.add_wire(v1p[0], v1p[1], v1p[0], x1_inp[1])
sch.add_wire(v1p[0], x1_inp[1], x1_inp[0], x1_inp[1])
# V2+ to X2 In+
sch.add_wire(v2p[0], v2p[1], v2p[0], x2_inp[1])
sch.add_wire(v2p[0], x2_inp[1], x2_inp[0], x2_inp[1])
# R1 feedback: X1 out (512,208) up to (512,112), right to R1_a (560,112)
sch.add_wire(x1_out[0], x1_out[1], x1_out[0], r1_a[1])
sch.add_wire(x1_out[0], r1_a[1], r1_a[0], r1_a[1])
# R1_b (480,112) down to X1 In- (448,192): (480,112) left to (448,112), down to (448,192)
sch.add_wire(r1_b[0], r1_b[1], x1_inn[0], r1_b[1])
sch.add_wire(x1_inn[0], r1_b[1], x1_inn[0], x1_inn[1])
# R1b feedback: X2 out (512,480) down to (512,560), right to R1b_a (560,560)
sch.add_wire(x2_out[0], x2_out[1], x2_out[0], r1b_a[1])
sch.add_wire(x2_out[0], r1b_a[1], r1b_a[0], r1b_a[1])
# R1b_b (480,560) down to X2 In- (448,464): (480,560) left to (448,560), up to (448,464)
sch.add_wire(r1b_b[0], r1b_b[1], x2_inn[0], r1b_b[1])
sch.add_wire(x2_inn[0], r1b_b[1], x2_inn[0], x2_inn[1])
# Rgain: node_a (junction at X1 In-) to Rgain pinA, node_b (X2 In-) to Rgain pinB
# Route from X1 In- junction (448,192) left to (352,192), down to Rg_a (352,272)
sch.add_wire(x1_inn[0], x1_inn[1], rg_a[0], x1_inn[1])
sch.add_wire(rg_a[0], x1_inn[1], rg_a[0], rg_a[1])
# Route from X2 In- junction (448,464) left to (352,464), up to Rg_b (352,352)
sch.add_wire(x2_inn[0], x2_inn[1], rg_b[0], x2_inn[1])
sch.add_wire(rg_b[0], x2_inn[1], rg_b[0], rg_b[1])
# Stage 2 wiring
# out1 (512,208) to R2_b (672,336): route (512,208) right to (672,208), down to (672,336)
sch.add_wire(x1_out[0], x1_out[1], r2_b[0], x1_out[1])
sch.add_wire(r2_b[0], x1_out[1], r2_b[0], r2_b[1])
# R2_a (752,336) to X3 In- (848,336)
sch.add_wire(r2_a[0], r2_a[1], x3_inn[0], x3_inn[1])
# out2 (512,480) to R2b_b (672,368): route (512,480) right to (672,480), up to (672,368)
sch.add_wire(x2_out[0], x2_out[1], r2b_b[0], x2_out[1])
sch.add_wire(r2b_b[0], x2_out[1], r2b_b[0], r2b_b[1])
# R2b_a (752,368) to X3 In+ (848,368)
sch.add_wire(r2b_a[0], r2b_a[1], x3_inp[0], x3_inp[1])
# R3 feedback: X3 out (912,352) up to (912,256), left to R3_a (944,256)
sch.add_wire(x3_out[0], x3_out[1], x3_out[0], r3_a[1])
sch.add_wire(x3_out[0], r3_a[1], r3_a[0], r3_a[1])
# R3_b (864,256) down to X3 In- junction: (864,256) left to (848,256), down to (848,336)
sch.add_wire(r3_b[0], r3_b[1], x3_inn[0], r3_b[1])
sch.add_wire(x3_inn[0], r3_b[1], x3_inn[0], x3_inn[1])
# R3b: X3 In+ to GND via R3b
sch.add_wire(x3_inp[0], x3_inp[1], r3b_a[0], r3b_a[1])
# Supply wiring (use net labels for cleanliness with 3 opamps)
# Just wire X1, X2, X3 supply pins to net labels
# === COMPONENTS ===
sch.add_component("voltage", "V1", "AC 1", 80, 160)
sch.add_component("voltage", "V2", "0", 80, 432)
sch.add_component(oa_sym, "X1", "", 480, 208)
sch.add_component(oa_sym, "X2", "", 480, 480)
sch.add_component(oa_sym, "X3", "", 880, 352)
sch.add_component("res", "R1", r1, 576, 96, rotation=90)
sch.add_component("res", "R1b", r1, 576, 544, rotation=90)
sch.add_component("res", "Rgain", r_gain, 336, 256)
sch.add_component("res", "R2", r2, 768, 320, rotation=90)
sch.add_component("res", "R3", r3, 960, 240, rotation=90)
sch.add_component("res", "R2b", r2, 768, 352, rotation=90)
sch.add_component("res", "R3b", r3, 832, 416)
sch.add_component("voltage", "Vpos", "15", 1056, 176)
sch.add_component("voltage", "Vneg", "15", 1056, 480)
# === FLAGS ===
sch.add_ground(*v1n)
sch.add_ground(*v2n)
sch.add_ground(*r3b_b)
sch.add_ground(*vpos_n)
sch.add_ground(*vneg_p)
# Supply net labels for all three opamps
sch.add_net_label("vdd", *x1_vp)
sch.add_net_label("vdd", *x2_vp)
sch.add_net_label("vdd", *x3_vp)
sch.add_net_label("vdd", *vpos_p)
sch.add_net_label("vss", *x1_vn)
sch.add_net_label("vss", *x2_vn)
sch.add_net_label("vss", *x3_vn)
sch.add_net_label("vss", *vneg_n)
sch.add_net_label("vout", *x3_out)
sch.add_directive(".ac dec 100 1 1meg", 80, 640)
return sch
def generate_current_mirror(
r_ref: str = "10k",
r_load: str = "1k",
vcc: str = "12",
) -> AscSchematic:
"""Generate a BJT current mirror schematic.
Topology::
Vcc --[Rref]--> collector_Q1 = base_Q1 = base_Q2
emitter_Q1 = GND
Vcc --[Rload]--> collector_Q2
emitter_Q2 = GND
Q1 is diode-connected (collector tied to base).
I_ref ~ (Vcc - Vbe) / Rref, I_load ~ I_ref.
Args:
r_ref: Reference resistor
r_load: Load resistor
vcc: Supply voltage
"""
sch = AscSchematic(sheet_w=880, sheet_h=680)
bjt_model = "2N2222"
# Q1 (NPN, diode-connected) at (256, 288): C=(320,288), B=(256,336), E=(320,384)
q1c = pin_position("npn", 0, 256, 288) # collector (320, 288)
q1b = pin_position("npn", 1, 256, 288) # base (256, 336)
q1e = pin_position("npn", 2, 256, 288) # emitter (320, 384)
# Q2 (NPN) at (448, 288): C=(512,288), B=(448,336), E=(512,384)
q2c = pin_position("npn", 0, 448, 288) # collector (512, 288)
q2b = pin_position("npn", 1, 448, 288) # base (448, 336)
q2e = pin_position("npn", 2, 448, 288) # emitter (512, 384)
# Rref from Vcc to Q1 collector. res at (304, 192): pinA=(320,208)=vcc, pinB=(320,288)=Q1c
rref_a = pin_position("res", 0, 304, 192) # (320, 208)
# Rload from Vcc to Q2 collector. res at (496, 192): pinA=(512,208)=vcc, pinB=(512,288)=Q2c
rload_a = pin_position("res", 0, 496, 192) # (512, 208)
# Vcc source at (80, 96): pin+=(80,112), pin-=(80,192)
vcc_p = pin_position("voltage", 0, 80, 96)
vcc_n = pin_position("voltage", 1, 80, 96)
# Vcc rail at y=208
vcc_y = rref_a[1] # 208
# === WIRING ===
# Vcc rail
sch.add_wire(vcc_p[0], vcc_p[1], 160, vcc_p[1]) # (80,112)->(160,112)
sch.add_wire(160, vcc_p[1], 160, vcc_y) # (160,112)->(160,208)
sch.add_wire(160, vcc_y, rload_a[0], vcc_y) # (160,208)->(512,208) spans both
# Diode connection: Q1 collector to Q1 base
# Q1c = (320,288), Q1b = (256,336)
# Route: (320,288) left to (256,288) down to (256,336)
sch.add_wire(q1c[0], q1c[1], q1b[0], q1c[1])
sch.add_wire(q1b[0], q1c[1], q1b[0], q1b[1])
# Base connection: Q1 base to Q2 base
sch.add_wire(q1b[0], q1b[1], q2b[0], q2b[1])
# === COMPONENTS ===
sch.add_component("npn", "Q1", bjt_model, 256, 288)
sch.add_component("npn", "Q2", bjt_model, 448, 288)
sch.add_component("res", "Rref", r_ref, 304, 192)
sch.add_component("res", "Rload", r_load, 496, 192)
sch.add_component("voltage", "Vcc", vcc, 80, 96)
# === FLAGS ===
sch.add_ground(*vcc_n)
sch.add_ground(*q1e)
sch.add_ground(*q2e)
sch.add_net_label("mirror", *q1b)
sch.add_net_label("out", *q2c)
sch.add_directive(".op", 80, 448)
sch.add_directive(".tran 1m", 80, 480)
return sch
def generate_transimpedance_amp(
rf: str = "100k",
cf: str = "1p",
i_source: str = "1u",
) -> AscSchematic:
"""Generate a transimpedance amplifier (TIA) schematic.
Topology::
I1 (current src, AC) --> In- (inv)
--> U1 --> out
GND --> In+ (noninv)
Rf from In- to out (feedback)
Cf from In- to out (parallel with Rf, for stability)
Vout = -I_in * Rf (at low frequencies).
Bandwidth limited by Cf. Supply: +/-15V.
Args:
rf: Feedback resistor
cf: Feedback capacitor (for stability)
i_source: AC current source magnitude
"""
sch = AscSchematic(sheet_w=1200, sheet_h=880)
oa_sym = "OpAmps/UniversalOpamp2"
# Op-amp U1 at (512, 336)
inp = pin_position(oa_sym, 0, 512, 336) # In+ = (480, 352)
inn = pin_position(oa_sym, 1, 512, 336) # In- = (480, 320)
vp = pin_position(oa_sym, 2, 512, 336) # V+ = (512, 304)
vn = pin_position(oa_sym, 3, 512, 336) # V- = (512, 368)
out = pin_position(oa_sym, 4, 512, 336) # OUT = (544, 336)
# Current source I1 at (320, 224) (vertical, like voltage source)
# Using "voltage" pin positions since current source shares the same symbol geometry
# I1 at origin (320, 224): pin+ = (320, 240), pin- = (320, 320)
# Current flows from pin+ to pin- inside the source (conventional).
# We want current into In- node: connect pin+ to In- node, pin- to GND.
i1p = pin_position("voltage", 0, 320, 224) # (320, 240)
i1n = pin_position("voltage", 1, 320, 224) # (320, 320)
# Rf horizontal (R90) above op-amp for feedback
# At (560, 208): pinA=(544,224), pinB=(464,224)
rf_a = pin_position("res", 0, 560, 208, 90) # (544, 224) near out
rf_b = pin_position("res", 1, 560, 208, 90) # (464, 224) near In-
# Cf horizontal (R90) above Rf for parallel feedback
# At (560, 160): pinA=(544,176), pinB=(464,176)
# Actually cap R90: pinA offset (16,0)->(0,16), pinB offset (16,64)->(-64,16)
# At (560, 160): pinA=(560,176), pinB=(496,176)
cf_a = pin_position("cap", 0, 560, 160, 90) # (560, 176) near out
cf_b = pin_position("cap", 1, 560, 160, 90) # (496, 176) near In-
# Supply: Vpos at (688, 176), Vneg at (688, 416)
vpos_p = pin_position("voltage", 0, 688, 176)
vpos_n = pin_position("voltage", 1, 688, 176)
vneg_p = pin_position("voltage", 0, 688, 416)
vneg_n = pin_position("voltage", 1, 688, 416)
# === WIRING ===
# I1 pin+ (320,240) to In- node (480,320)
# Route: (320,240) right to (480,240), down to (480,320)
sch.add_wire(i1p[0], i1p[1], inn[0], i1p[1])
sch.add_wire(inn[0], i1p[1], inn[0], inn[1])
# Rf feedback: out up to Rf pinA, Rf pinB left and down to In- junction
sch.add_wire(out[0], out[1], rf_a[0], rf_a[1]) # (544,336)->(544,224)
sch.add_wire(rf_b[0], rf_b[1], rf_b[0], inn[1]) # (464,224)->(464,320)
sch.add_wire(rf_b[0], inn[1], inn[0], inn[1]) # (464,320)->(480,320)
# Cf feedback: parallel path
# Connect Cf pinA to Rf pinA column, Cf pinB to Rf pinB column
sch.add_wire(cf_a[0], cf_a[1], rf_a[0], cf_a[1]) # (560,176)->(544,176)
sch.add_wire(rf_a[0], cf_a[1], rf_a[0], rf_a[1]) # (544,176)->(544,224) vertical
sch.add_wire(cf_b[0], cf_b[1], rf_b[0], cf_b[1]) # (496,176)->(464,176)
sch.add_wire(rf_b[0], cf_b[1], rf_b[0], rf_b[1]) # (464,176)->(464,224) vertical
# Supply wiring
sch.add_wire(vp[0], vp[1], vp[0], vpos_p[1])
sch.add_wire(vp[0], vpos_p[1], vpos_p[0], vpos_p[1])
sch.add_wire(vn[0], vn[1], vn[0], vneg_n[1])
sch.add_wire(vn[0], vneg_n[1], vneg_n[0], vneg_n[1])
# === COMPONENTS ===
sch.add_component("voltage", "I1", f"AC {i_source}", 320, 224)
sch.add_component(oa_sym, "U1", "", 512, 336)
sch.add_component("res", "Rf", rf, 560, 208, rotation=90)
sch.add_component("cap", "Cf", cf, 560, 160, rotation=90)
sch.add_component("voltage", "Vpos", "15", 688, 176)
sch.add_component("voltage", "Vneg", "15", 688, 416)
# === FLAGS ===
sch.add_ground(*i1n)
sch.add_ground(*inp) # In+ to GND
sch.add_ground(*vpos_n)
sch.add_ground(*vneg_p)
sch.add_net_label("out", *out)
sch.add_directive(".ac dec 100 1 1meg", 80, 560)
return sch

View File

@ -638,6 +638,209 @@ def h_bridge(
) )
def sallen_key_lowpass(
r1: str = "10k",
r2: str = "10k",
c1: str = "10n",
c2: str = "10n",
opamp_model: str = "LT1001",
) -> Netlist:
"""Create a Sallen-Key lowpass filter (unity gain).
Topology:
in --[R1]--> n1 --[R2]--> n2
n2 --> opamp In+ (non-inverting)
opamp out --> In- (unity gain feedback)
C1 from n1 to out (feedback element)
C2 from n2 to GND
f_c = 1 / (2*pi*sqrt(R1*R2*C1*C2))
Supply: +/-15V
"""
return (
Netlist("Sallen-Key Lowpass Filter")
.add_comment(f"f_c = 1/(2*pi*sqrt({r1}*{r2}*{c1}*{c2}))")
.add_lib(opamp_model)
.add_voltage_source("V1", "in", "0", ac="1")
.add_voltage_source("Vpos", "vdd", "0", dc="15")
.add_voltage_source("Vneg", "0", "vss", dc="15")
.add_resistor("R1", "in", "n1", r1)
.add_resistor("R2", "n1", "n2", r2)
.add_capacitor("C1", "n1", "out", c1)
.add_capacitor("C2", "n2", "0", c2)
.add_opamp("X1", "n2", "out", "out", "vdd", "vss", opamp_model)
.add_directive(".ac dec 100 1 1meg")
)
def boost_converter(
ind: str = "10u",
c_out: str = "100u",
r_load: str = "50",
v_in: str = "5",
duty_cycle: float = 0.5,
freq: str = "100k",
mosfet_model: str = "IRF540N",
diode_model: str = "1N5819",
) -> Netlist:
"""Create a boost (step-up) converter.
Topology:
Vin --[L1]--> sw_node --[D1 (anode->cathode)]--> out
| |
[MOSFET] [Cout] [Rload]
drain=sw | |
gate=gate GND GND
source=GND
Vout_ideal = Vin / (1 - duty_cycle)
Gate driven by PULSE source at switching frequency and duty cycle.
"""
freq_hz = _parse_spice_value(freq)
period = 1.0 / freq_hz
t_on = period * duty_cycle
t_rise = period * 0.01
t_fall = t_rise
return (
Netlist("Boost Converter")
.add_comment(f"Duty cycle = {duty_cycle:.0%}, Fsw = {freq}")
.add_lib(mosfet_model)
.add_lib(diode_model)
.add_voltage_source("Vin", "vin", "0", dc=v_in)
.add_voltage_source(
"Vgate",
"gate",
"0",
pulse=(
"0",
v_in,
"0",
f"{t_rise:.4g}",
f"{t_fall:.4g}",
f"{t_on:.4g}",
f"{period:.4g}",
),
)
.add_inductor("L1", "vin", "sw", ind)
.add_mosfet("M1", "sw", "gate", "0", "0", mosfet_model)
.add_diode("D1", "sw", "out", diode_model)
.add_capacitor("Cout", "out", "0", c_out)
.add_resistor("Rload", "out", "0", r_load)
.add_directive(f".tran {period * 200:.4g}")
)
def instrumentation_amplifier(
r1: str = "10k",
r2: str = "10k",
r3: str = "10k",
r_gain: str = "10k",
) -> Netlist:
"""Create a classic 3-opamp instrumentation amplifier.
Stage 1 (input buffers with gain):
X1: In+ = Vin+, In- = node_a, Out = out1
X2: In+ = Vin-, In- = node_b, Out = out2
R1 from out1 to node_a (feedback)
R1_match from out2 to node_b (feedback)
Rgain between node_a and node_b
Stage 2 (difference amplifier):
X3: In- receives out1 via R2, feedback via R3 to Vout
In+ receives out2 via R2_match, R3_match to GND
Gain = (1 + 2*R1/Rgain) * (R3/R2)
All opamps use LT1001, supply +/-15V.
"""
opamp_model = "LT1001"
return (
Netlist("Instrumentation Amplifier")
.add_comment(f"Gain = (1 + 2*{r1}/{r_gain}) * ({r3}/{r2})")
.add_lib(opamp_model)
.add_voltage_source("V1", "vinp", "0", ac="1")
.add_voltage_source("V2", "vinn", "0", dc="0")
.add_voltage_source("Vpos", "vdd", "0", dc="15")
.add_voltage_source("Vneg", "0", "vss", dc="15")
# Stage 1: input buffers
.add_opamp("X1", "vinp", "node_a", "out1", "vdd", "vss", opamp_model)
.add_opamp("X2", "vinn", "node_b", "out2", "vdd", "vss", opamp_model)
.add_resistor("R1", "out1", "node_a", r1)
.add_resistor("R1b", "out2", "node_b", r1)
.add_resistor("Rgain", "node_a", "node_b", r_gain)
# Stage 2: difference amplifier
.add_opamp("X3", "noninv3", "inv3", "vout", "vdd", "vss", opamp_model)
.add_resistor("R2", "out1", "inv3", r2)
.add_resistor("R3", "inv3", "vout", r3)
.add_resistor("R2b", "out2", "noninv3", r2)
.add_resistor("R3b", "noninv3", "0", r3)
.add_directive(".ac dec 100 1 1meg")
)
def current_mirror(
r_ref: str = "10k",
r_load: str = "1k",
vcc: str = "12",
bjt_model: str = "2N2222",
) -> Netlist:
"""Create a basic BJT current mirror.
Topology:
Vcc --[Rref]--> collector_Q1 = base_Q1 = base_Q2
emitter_Q1 = GND
Vcc --[Rload]--> collector_Q2
emitter_Q2 = GND
Q1 is diode-connected (collector tied to base).
I_ref = (Vcc - Vbe) / Rref, I_load ~ I_ref.
"""
return (
Netlist("Current Mirror")
.add_comment(f"I_ref ~ ({vcc} - 0.7) / {r_ref}")
.add_lib(bjt_model)
.add_voltage_source("Vcc", "vcc", "0", dc=vcc)
.add_resistor("Rref", "vcc", "mirror", r_ref)
.add_resistor("Rload", "vcc", "out", r_load)
.add_bjt("Q1", "mirror", "mirror", "0", bjt_model)
.add_bjt("Q2", "out", "mirror", "0", bjt_model)
.add_directive(".op")
.add_directive(".tran 1m")
)
def transimpedance_amplifier(
rf: str = "100k",
cf: str = "1p",
i_source: str = "1u",
) -> Netlist:
"""Create a transimpedance amplifier (TIA).
Topology:
I1 (current source, AC) --> In- (inverting)
In+ (non-inverting) --> GND
Rf from In- to out (feedback resistor)
Cf from In- to out (feedback cap, parallel with Rf for stability)
Vout = -I_in * Rf (at low frequencies)
Bandwidth limited by Cf.
Supply: +/-15V, opamp_model = LT1001
"""
opamp_model = "LT1001"
return (
Netlist("Transimpedance Amplifier")
.add_comment(f"Vout = -I_in * {rf}")
.add_lib(opamp_model)
.add_current_source("I1", "inv", "0", ac=i_source)
.add_voltage_source("Vpos", "vdd", "0", dc="15")
.add_voltage_source("Vneg", "0", "vss", dc="15")
.add_resistor("Rf", "inv", "out", rf)
.add_capacitor("Cf", "inv", "out", cf)
.add_opamp("X1", "0", "inv", "out", "vdd", "vss", opamp_model)
.add_directive(".ac dec 100 1 1meg")
)
def _parse_spice_value(value: str) -> float: def _parse_spice_value(value: str) -> float:
"""Convert a SPICE-style value string to a float. """Convert a SPICE-style value string to a float.

View File

@ -18,8 +18,12 @@ from pathlib import Path
import numpy as np import numpy as np
from fastmcp import FastMCP from fastmcp import FastMCP
from fastmcp.prompts import Message
from . import __version__ from . import __version__
from .asc_generator import (
generate_boost_converter as generate_boost_converter_asc,
)
from .asc_generator import ( from .asc_generator import (
generate_buck_converter as generate_buck_converter_asc, generate_buck_converter as generate_buck_converter_asc,
) )
@ -29,12 +33,18 @@ from .asc_generator import (
from .asc_generator import ( from .asc_generator import (
generate_common_emitter_amp as generate_ce_amp_asc, generate_common_emitter_amp as generate_ce_amp_asc,
) )
from .asc_generator import (
generate_current_mirror as generate_current_mirror_asc,
)
from .asc_generator import ( from .asc_generator import (
generate_differential_amp as generate_diff_amp_asc, generate_differential_amp as generate_diff_amp_asc,
) )
from .asc_generator import ( from .asc_generator import (
generate_h_bridge as generate_h_bridge_asc, generate_h_bridge as generate_h_bridge_asc,
) )
from .asc_generator import (
generate_instrumentation_amp as generate_inamp_asc,
)
from .asc_generator import ( from .asc_generator import (
generate_inverting_amp, generate_inverting_amp,
) )
@ -47,6 +57,12 @@ from .asc_generator import (
from .asc_generator import ( from .asc_generator import (
generate_rc_lowpass as generate_rc_lowpass_asc, generate_rc_lowpass as generate_rc_lowpass_asc,
) )
from .asc_generator import (
generate_sallen_key_lowpass as generate_sallen_key_asc,
)
from .asc_generator import (
generate_transimpedance_amp as generate_tia_asc,
)
from .asc_generator import ( from .asc_generator import (
generate_voltage_divider as generate_voltage_divider_asc, generate_voltage_divider as generate_voltage_divider_asc,
) )
@ -63,23 +79,24 @@ from .config import (
from .diff import diff_schematics as _diff_schematics from .diff import diff_schematics as _diff_schematics
from .drc import run_drc as _run_drc from .drc import run_drc as _run_drc
from .log_parser import parse_log from .log_parser import parse_log
from .models import ( from .models import search_models as _search_models
search_models as _search_models, from .models import search_subcircuits as _search_subcircuits
)
from .models import (
search_subcircuits as _search_subcircuits,
)
from .netlist import ( from .netlist import (
Netlist, Netlist,
boost_converter,
buck_converter, buck_converter,
colpitts_oscillator, colpitts_oscillator,
common_emitter_amplifier, common_emitter_amplifier,
current_mirror,
differential_amplifier, differential_amplifier,
h_bridge, h_bridge,
instrumentation_amplifier,
inverting_amplifier, inverting_amplifier,
ldo_regulator, ldo_regulator,
non_inverting_amplifier, non_inverting_amplifier,
rc_lowpass, rc_lowpass,
sallen_key_lowpass,
transimpedance_amplifier,
voltage_divider, voltage_divider,
) )
from .noise_analysis import ( from .noise_analysis import (
@ -98,6 +115,7 @@ from .raw_parser import parse_raw_file
from .runner import run_netlist, run_simulation from .runner import run_netlist, run_simulation
from .schematic import modify_component_value, parse_schematic from .schematic import modify_component_value, parse_schematic
from .stability import compute_stability_metrics from .stability import compute_stability_metrics
from .svg_plot import plot_bode, plot_spectrum, plot_timeseries
from .touchstone import parse_touchstone, s_param_to_db from .touchstone import parse_touchstone, s_param_to_db
from .waveform_expr import WaveformCalculator from .waveform_expr import WaveformCalculator
from .waveform_math import ( from .waveform_math import (
@ -1014,6 +1032,394 @@ async def optimize_circuit(
} }
# ============================================================================
# CIRCUIT TUNING TOOL
# ============================================================================
@mcp.tool()
async def tune_circuit(
template: str,
params: dict[str, str] | None = None,
targets: dict[str, str] | None = None,
signal: str = "V(out)",
) -> dict:
"""Measure circuit performance and suggest parameter adjustments.
Single-shot workflow: generates a circuit from a template, simulates it,
measures key metrics, compares against targets, and suggests what to change.
Call this tool repeatedly with adjusted params until targets are met.
Args:
template: Template name (from list_templates). Works with both
netlist templates (_TEMPLATES) and schematic templates (_ASC_TEMPLATES).
params: Component value overrides (e.g., {"r": "2.2k", "c": "47n"}).
Use list_templates to see available parameters.
targets: Performance targets to check against. Each key is a metric name,
value is a comparison string like ">5000" or "<0.1" or "~1000".
Supported metrics: bandwidth_hz, gain_db, rms, peak_to_peak,
settling_time_s, dc_value, fundamental_freq_hz.
Example: {"bandwidth_hz": ">5000", "gain_db": ">20"}
signal: Signal name to measure (default: "V(out)")
Returns:
Dict with metrics, target comparison, and tuning suggestions.
"""
# --- 1. Look up template ---
tmpl = _TEMPLATES.get(template)
is_netlist = tmpl is not None
if tmpl is None:
tmpl = _ASC_TEMPLATES.get(template)
if tmpl is None:
names = sorted(set(list(_TEMPLATES.keys()) + list(_ASC_TEMPLATES.keys())))
return {"error": f"Unknown template '{template}'. Available: {', '.join(names)}"}
# --- 2. Build effective params ---
effective_params: dict[str, str] = dict(tmpl["params"])
if params:
for k, v in params.items():
if k not in tmpl["params"]:
return {
"error": f"Unknown param '{k}' for {template}",
"valid_params": list(tmpl["params"].keys()),
}
effective_params[k] = v
# --- 3. Generate and simulate ---
import tempfile
from pathlib import Path
try:
# Build kwargs (handle duty_cycle float conversion)
call_kwargs: dict = {}
for k, v in effective_params.items():
if k == "duty_cycle":
call_kwargs[k] = float(v)
else:
call_kwargs[k] = v
if is_netlist:
nl = tmpl["func"](**call_kwargs)
out_path = Path(tempfile.gettempdir()) / f"tune_{template}.cir"
nl.save(out_path)
result = await run_netlist(out_path)
else:
sch = tmpl["func"](**call_kwargs)
out_path = Path(tempfile.gettempdir()) / f"tune_{template}.asc"
sch.save(out_path)
result = await run_simulation(out_path)
if not result.success:
return {
"error": "Simulation failed",
"detail": result.error or result.stderr,
"params_used": effective_params,
}
except Exception as e:
return {"error": f"Generation/simulation failed: {e}", "params_used": effective_params}
# --- 4. Extract waveform and compute metrics ---
raw = result.raw_data
if raw is None:
return {"error": "No raw data from simulation", "params_used": effective_params}
# Find the signal variable
sig_idx = None
time_idx = None
freq_idx = None
for var in raw.variables:
if var.name.lower() == signal.lower():
sig_idx = var.index
if var.name.lower() == "time":
time_idx = var.index
if var.name.lower() == "frequency":
freq_idx = var.index
if sig_idx is None:
available = [v.name for v in raw.variables]
return {
"error": f"Signal '{signal}' not found",
"available_signals": available,
"params_used": effective_params,
}
sig_data = raw.data[sig_idx]
metrics: dict[str, float] = {}
is_ac = freq_idx is not None
if is_ac:
# Frequency-domain metrics
freq = np.abs(raw.data[freq_idx]).real
mag_complex = sig_data
mag_db = 20.0 * np.log10(np.maximum(np.abs(mag_complex), 1e-30))
# Bandwidth
try:
bw_result = compute_bandwidth(freq, mag_db)
metrics["bandwidth_hz"] = bw_result["bandwidth_hz"]
if bw_result.get("f_low"):
metrics["f_low_hz"] = bw_result["f_low"]
if bw_result.get("f_high"):
metrics["f_high_hz"] = bw_result["f_high"]
except Exception:
pass
# DC gain (magnitude at lowest frequency)
metrics["gain_db"] = float(mag_db[0])
metrics["dc_value"] = float(np.abs(mag_complex[0]))
else:
# Time-domain metrics
time_data = raw.data[time_idx] if time_idx is not None else None
sig_real = np.real(sig_data)
# RMS
metrics["rms"] = float(compute_rms(sig_real))
# Peak-to-peak
pp = compute_peak_to_peak(sig_real)
metrics["peak_to_peak"] = pp["peak_to_peak"]
metrics["dc_value"] = pp["mean"]
# Settling time (if signal looks like a step response)
if time_data is not None:
time_real = np.real(time_data)
try:
settle = compute_settling_time(time_real, sig_real)
if settle["settled"]:
metrics["settling_time_s"] = settle["settling_time"]
except Exception:
pass
# FFT for fundamental frequency
try:
fft = compute_fft(time_real, sig_real)
if fft["fundamental_freq"] > 0:
metrics["fundamental_freq_hz"] = fft["fundamental_freq"]
except Exception:
pass
# --- 5. Compare against targets ---
targets_met = True
target_results: dict[str, dict] = {}
if targets:
for metric_name, target_str in targets.items():
if metric_name not in metrics:
target_results[metric_name] = {
"status": "unmeasurable",
"reason": f"Metric '{metric_name}' not available from this simulation",
}
targets_met = False
continue
actual = metrics[metric_name]
met = False
target_val = 0.0
if target_str.startswith(">"):
target_val = float(target_str[1:])
met = actual > target_val
elif target_str.startswith("<"):
target_val = float(target_str[1:])
met = actual < target_val
elif target_str.startswith("~"):
target_val = float(target_str[1:])
tolerance = target_val * 0.1 # 10% tolerance
met = abs(actual - target_val) <= tolerance
else:
target_val = float(target_str)
met = abs(actual - target_val) <= target_val * 0.1
target_results[metric_name] = {
"target": target_str,
"actual": actual,
"met": met,
}
if not met:
targets_met = False
# --- 6. Generate suggestions ---
suggestions: list[str] = []
if targets and not targets_met:
for metric_name, result_info in target_results.items():
if isinstance(result_info, dict) and not result_info.get("met", True):
actual = result_info.get("actual")
target_str_val = result_info.get("target", "")
if actual is None:
continue
if metric_name == "bandwidth_hz":
if str(target_str_val).startswith(">") and actual < float(
str(target_str_val)[1:]
):
suggestions.append(
"To increase bandwidth: decrease R or C values "
"(f_c = 1/(2*pi*R*C) for RC filters)"
)
elif str(target_str_val).startswith("<"):
suggestions.append(
"To decrease bandwidth: increase R or C values"
)
elif metric_name == "gain_db":
if str(target_str_val).startswith(">") and actual < float(
str(target_str_val)[1:]
):
suggestions.append(
"To increase gain: increase Rf/Rin ratio for op-amp circuits, "
"or increase Rc/Re ratio for CE amplifiers"
)
elif str(target_str_val).startswith("<"):
suggestions.append("To decrease gain: decrease Rf/Rin ratio")
elif metric_name == "peak_to_peak":
if str(target_str_val).startswith("<"):
suggestions.append(
"To reduce peak-to-peak (ripple): increase filter capacitance "
"or inductance, or increase switching frequency"
)
elif metric_name == "settling_time_s":
if str(target_str_val).startswith("<"):
suggestions.append(
"To reduce settling time: increase bandwidth (decrease R*C), "
"or add damping to reduce ringing"
)
elif metric_name == "rms":
suggestions.append(
f"RMS is {actual:.4g}, target was {target_str_val}. "
"Adjust source amplitude or gain."
)
if not suggestions and not targets_met:
suggestions.append(
"Adjust component values toward the target. Use smaller steps for fine-tuning."
)
return {
"template": template,
"params_used": effective_params,
"metrics": metrics,
"targets": target_results if targets else {},
"targets_met": targets_met,
"suggestions": suggestions,
"signal": signal,
"analysis_type": "ac" if is_ac else "transient",
}
# ============================================================================
# WAVEFORM PLOTTING TOOL
# ============================================================================
@mcp.tool()
async def plot_waveform(
raw_file: str,
signal: str = "V(out)",
plot_type: str = "auto",
output_path: str | None = None,
) -> dict:
"""Generate an SVG plot from simulation results.
Parses a .raw file and creates a publication-quality SVG waveform plot.
Supports time-domain, Bode (frequency response), and FFT spectrum plots.
Args:
raw_file: Path to the LTspice .raw binary file
signal: Signal name to plot (e.g. "V(out)", "I(R1)")
plot_type: "auto" (detect from data), "time", "bode", or "spectrum"
output_path: Where to save SVG file (None = auto in /tmp)
"""
raw_path = Path(raw_file)
if not raw_path.exists():
return {"error": f"Raw file not found: {raw_file}"}
try:
raw_data = parse_raw_file(str(raw_path))
except Exception as e:
return {"error": f"Failed to parse raw file: {e}"}
# Find the requested signal
sig_names = [v.name for v in raw_data.variables]
sig_lower = {s.lower(): s for s in sig_names}
actual_name = sig_lower.get(signal.lower())
if actual_name is None:
return {
"error": f"Signal '{signal}' not found",
"available_signals": sig_names,
}
sig_idx = next(i for i, v in enumerate(raw_data.variables) if v.name == actual_name)
values = raw_data.data[:, sig_idx]
# Get x-axis data (time or frequency)
x_var = raw_data.variables[0]
x_data = raw_data.data[:, 0]
is_freq = x_var.name.lower() == "frequency"
# Handle complex data (AC analysis produces complex values)
if np.iscomplexobj(values):
mag_db = 20.0 * np.log10(np.maximum(np.abs(values), 1e-30))
phase_deg = np.degrees(np.angle(values))
is_complex = True
else:
is_complex = False
mag_db = None
phase_deg = None
# Determine plot type
if plot_type == "auto":
if is_freq and is_complex:
plot_type = "bode"
elif is_freq:
plot_type = "spectrum"
else:
plot_type = "time"
# Generate SVG
if plot_type == "bode":
if mag_db is None:
mag_db = np.real(values)
phase_deg = None
freq = np.real(x_data)
svg = plot_bode(
freq=freq, mag_db=mag_db, phase_deg=phase_deg,
title=f"Bode Plot — {actual_name}",
)
elif plot_type == "spectrum":
freq = np.real(x_data)
if mag_db is None:
mag_db = 20.0 * np.log10(np.maximum(np.abs(np.real(values)), 1e-30))
svg = plot_spectrum(
freq=freq, mag_db=mag_db,
title=f"Spectrum — {actual_name}",
)
else: # time
svg = plot_timeseries(
time=np.real(x_data), values=np.real(values),
title=f"Time Domain — {actual_name}",
ylabel=actual_name,
)
# Save SVG
if output_path is None:
out = Path(tempfile.mktemp(suffix=".svg", prefix="ltspice_plot_"))
else:
out = Path(output_path)
out.write_text(svg)
return {
"svg_path": str(out),
"plot_type": plot_type,
"signal": actual_name,
"points": len(x_data),
}
# ============================================================================ # ============================================================================
# BATCH SIMULATION TOOLS # BATCH SIMULATION TOOLS
# ============================================================================ # ============================================================================
@ -1194,6 +1600,34 @@ _ASC_TEMPLATES: dict[str, dict] = {
"description": "H-bridge motor driver with 4 NMOS transistors", "description": "H-bridge motor driver with 4 NMOS transistors",
"params": {"v_supply": "12", "r_load": "10", "mosfet_model": "IRF540N"}, "params": {"v_supply": "12", "r_load": "10", "mosfet_model": "IRF540N"},
}, },
"sallen_key_lowpass": {
"func": generate_sallen_key_asc,
"description": "Sallen-Key lowpass filter (unity gain, 2nd order)",
"params": {"r1": "10k", "r2": "10k", "c1": "10n", "c2": "10n"},
},
"boost_converter": {
"func": generate_boost_converter_asc,
"description": "Boost (step-up) converter with NMOS switch",
"params": {
"ind": "10u", "c_out": "100u", "r_load": "50",
"v_in": "5", "duty_cycle": "0.5", "freq": "100k",
},
},
"instrumentation_amp": {
"func": generate_inamp_asc,
"description": "3-opamp instrumentation amplifier",
"params": {"r1": "10k", "r2": "10k", "r3": "10k", "r_gain": "10k"},
},
"current_mirror": {
"func": generate_current_mirror_asc,
"description": "BJT current mirror with reference and load",
"params": {"r_ref": "10k", "r_load": "1k", "vcc": "12"},
},
"transimpedance_amp": {
"func": generate_tia_asc,
"description": "Transimpedance amplifier (current to voltage)",
"params": {"rf": "100k", "cf": "1p", "i_source": "1u"},
},
} }
@ -1211,7 +1645,9 @@ def generate_schematic(
Available templates (use list_templates for full details): Available templates (use list_templates for full details):
- rc_lowpass, voltage_divider, inverting_amp, non_inverting_amp, - rc_lowpass, voltage_divider, inverting_amp, non_inverting_amp,
common_emitter_amp, colpitts_oscillator, differential_amp, common_emitter_amp, colpitts_oscillator, differential_amp,
buck_converter, ldo_regulator, h_bridge buck_converter, ldo_regulator, h_bridge,
sallen_key_lowpass, boost_converter, instrumentation_amp,
current_mirror, transimpedance_amp
Args: Args:
template: Template name (see list above) template: Template name (see list above)
@ -1565,6 +2001,60 @@ _TEMPLATES: dict[str, dict] = {
"mosfet_model": "IRF540N", "mosfet_model": "IRF540N",
}, },
}, },
"sallen_key_lowpass": {
"func": sallen_key_lowpass,
"description": "Sallen-Key lowpass filter (unity gain, 2nd order)",
"params": {
"r1": "10k",
"r2": "10k",
"c1": "10n",
"c2": "10n",
"opamp_model": "LT1001",
},
},
"boost_converter": {
"func": boost_converter,
"description": "Step-up DC-DC converter with MOSFET switch",
"params": {
"ind": "10u",
"c_out": "100u",
"r_load": "50",
"v_in": "5",
"duty_cycle": "0.5",
"freq": "100k",
"mosfet_model": "IRF540N",
"diode_model": "1N5819",
},
},
"instrumentation_amplifier": {
"func": instrumentation_amplifier,
"description": "3-opamp instrumentation amp: gain = 1 + 2*R1/R_gain",
"params": {
"r1": "10k",
"r2": "10k",
"r3": "10k",
"r_gain": "10k",
},
},
"current_mirror": {
"func": current_mirror,
"description": "BJT current mirror with reference resistor",
"params": {
"r_ref": "10k",
"r_load": "1k",
"vcc": "12",
"bjt_model": "2N2222",
},
},
"transimpedance_amplifier": {
"func": transimpedance_amplifier,
"description": "TIA: converts input current to output voltage (Vout = -Iph * Rf)",
"params": {
"rf": "100k",
"cf": "1p",
"i_source": "1u",
},
},
} }
@ -1587,6 +2077,11 @@ def create_from_template(
- ldo_regulator: params {opamp_model, r1, r2, pass_transistor, v_in, v_ref} - ldo_regulator: params {opamp_model, r1, r2, pass_transistor, v_in, v_ref}
- colpitts_oscillator: params {ind, c1, c2, rb, rc, re, vcc, bjt_model} - colpitts_oscillator: params {ind, c1, c2, rb, rc, re, vcc, bjt_model}
- h_bridge: params {v_supply, r_load, mosfet_model} - h_bridge: params {v_supply, r_load, mosfet_model}
- sallen_key_lowpass: params {r1, r2, c1, c2, opamp_model}
- boost_converter: params {ind, c_out, r_load, v_in, duty_cycle, freq, mosfet_model, diode_model}
- instrumentation_amplifier: params {r1, r2, r3, r_gain}
- current_mirror: params {r_ref, r_load, vcc, bjt_model}
- transimpedance_amplifier: params {rf, cf, i_source}
All parameter values are optional -- defaults are used if omitted. All parameter values are optional -- defaults are used if omitted.
@ -1899,6 +2394,49 @@ def resource_status() -> str:
return json.dumps(check_installation(), indent=2) return json.dumps(check_installation(), indent=2)
@mcp.resource("ltspice://templates")
def resource_templates() -> str:
"""All available circuit templates (netlist and schematic) with parameters."""
templates = {
"netlist_templates": [
{"name": name, "description": info["description"], "params": info["params"]}
for name, info in _TEMPLATES.items()
],
"schematic_templates": [
{"name": name, "description": info["description"], "params": info["params"]}
for name, info in _ASC_TEMPLATES.items()
],
}
return json.dumps(templates, indent=2)
@mcp.resource("ltspice://template/{name}")
def resource_template_detail(name: str) -> str:
"""Detailed information about a specific circuit template."""
# Check both registries
netlist_info = _TEMPLATES.get(name)
asc_info = _ASC_TEMPLATES.get(name)
if not netlist_info and not asc_info:
return json.dumps({"error": f"Template '{name}' not found"})
result = {"name": name}
if netlist_info:
result["netlist"] = {
"description": netlist_info["description"],
"params": netlist_info["params"],
"type": "netlist (.cir)",
}
if asc_info:
result["schematic"] = {
"description": asc_info["description"],
"params": asc_info["params"],
"type": "schematic (.asc)",
}
return json.dumps(result, indent=2)
# ============================================================================ # ============================================================================
# PROMPTS # PROMPTS
# ============================================================================ # ============================================================================
@ -1909,7 +2447,7 @@ def design_filter(
filter_type: str = "lowpass", filter_type: str = "lowpass",
topology: str = "rc", topology: str = "rc",
cutoff_freq: str = "1kHz", cutoff_freq: str = "1kHz",
) -> str: ) -> list:
"""Guide through designing and simulating a filter circuit. """Guide through designing and simulating a filter circuit.
Args: Args:
@ -1917,7 +2455,7 @@ def design_filter(
topology: rc (1st order), rlc (2nd order), or sallen-key (active) topology: rc (1st order), rlc (2nd order), or sallen-key (active)
cutoff_freq: Target cutoff frequency with units cutoff_freq: Target cutoff frequency with units
""" """
return f"""Design a {filter_type} filter with these requirements: return [Message(role="user", content=f"""Design a {filter_type} filter with these requirements:
- Topology: {topology} - Topology: {topology}
- Cutoff frequency: {cutoff_freq} - Cutoff frequency: {cutoff_freq}
@ -1934,11 +2472,11 @@ Tips:
- For RC lowpass: f_c = 1/(2*pi*R*C) - For RC lowpass: f_c = 1/(2*pi*R*C)
- For 2nd order: Q controls peaking, Butterworth Q=0.707 - For 2nd order: Q controls peaking, Butterworth Q=0.707
- Use search_spice_models to find op-amp models for active filters - Use search_spice_models to find op-amp models for active filters
""" """)]
@mcp.prompt() @mcp.prompt()
def analyze_power_supply(schematic_path: str = "") -> str: def analyze_power_supply(schematic_path: str = "") -> list:
"""Guide through analyzing a power supply circuit. """Guide through analyzing a power supply circuit.
Args: Args:
@ -1950,7 +2488,7 @@ def analyze_power_supply(schematic_path: str = "") -> str:
else "First, identify or create the power supply schematic." else "First, identify or create the power supply schematic."
) )
return f"""Analyze a power supply circuit for key performance metrics. return [Message(role="user", content=f"""Analyze a power supply circuit for key performance metrics.
{path_instruction} {path_instruction}
@ -1969,11 +2507,11 @@ Key metrics to extract:
- Ripple voltage (peak-to-peak on output) - Ripple voltage (peak-to-peak on output)
- Load transient response (settling time after step) - Load transient response (settling time after step)
- Efficiency (input power vs output power) - Efficiency (input power vs output power)
""" """)]
@mcp.prompt() @mcp.prompt()
def debug_circuit(schematic_path: str = "") -> str: def debug_circuit(schematic_path: str = "") -> list:
"""Guide through debugging a circuit that isn't working. """Guide through debugging a circuit that isn't working.
Args: Args:
@ -1985,7 +2523,7 @@ def debug_circuit(schematic_path: str = "") -> str:
else "First, identify the schematic file." else "First, identify the schematic file."
) )
return f"""Systematic approach to debugging a circuit. return [Message(role="user", content=f"""Systematic approach to debugging a circuit.
{path_instruction} {path_instruction}
@ -2012,21 +2550,21 @@ Common issues:
- Missing bias voltages or ground - Missing bias voltages or ground
- Component values off by orders of magnitude - Component values off by orders of magnitude
- Wrong model (check with search_spice_models) - Wrong model (check with search_spice_models)
""" """)]
@mcp.prompt() @mcp.prompt()
def optimize_design( def optimize_design(
circuit_type: str = "filter", circuit_type: str = "filter",
target_spec: str = "1kHz bandwidth", target_spec: str = "1kHz bandwidth",
) -> str: ) -> list:
"""Guide through optimizing a circuit to meet target specifications. """Guide through optimizing a circuit to meet target specifications.
Args: Args:
circuit_type: Type of circuit (filter, amplifier, regulator, oscillator) circuit_type: Type of circuit (filter, amplifier, regulator, oscillator)
target_spec: Target specification to achieve target_spec: Target specification to achieve
""" """
return f"""Optimize a {circuit_type} circuit to achieve: {target_spec} return [Message(role="user", content=f"""Optimize a {circuit_type} circuit to achieve: {target_spec}
Workflow: Workflow:
1. Start with a template: use list_templates to see available circuits 1. Start with a template: use list_templates to see available circuits
@ -2045,21 +2583,21 @@ Tips:
- For filters: target bandwidth_hz metric - For filters: target bandwidth_hz metric
- For amplifiers: target gain_db and phase_margin_deg - For amplifiers: target gain_db and phase_margin_deg
- For regulators: target settling_time and peak_to_peak (ripple) - For regulators: target settling_time and peak_to_peak (ripple)
""" """)]
@mcp.prompt() @mcp.prompt()
def monte_carlo_analysis( def monte_carlo_analysis(
circuit_description: str = "RC filter", circuit_description: str = "RC filter",
n_runs: str = "100", n_runs: str = "100",
) -> str: ) -> list:
"""Guide through Monte Carlo tolerance analysis. """Guide through Monte Carlo tolerance analysis.
Args: Args:
circuit_description: What circuit to analyze circuit_description: What circuit to analyze
n_runs: Number of Monte Carlo iterations n_runs: Number of Monte Carlo iterations
""" """
return f"""Run Monte Carlo tolerance analysis on: {circuit_description} return [Message(role="user", content=f"""Run Monte Carlo tolerance analysis on: {circuit_description}
Number of runs: {n_runs} Number of runs: {n_runs}
Workflow: Workflow:
@ -2087,19 +2625,19 @@ Tips:
- Ceramic capacitors: 10-20% - Ceramic capacitors: 10-20%
- Electrolytic capacitors: 20% - Electrolytic capacitors: 20%
- Inductors: 10-20% - Inductors: 10-20%
""" """)]
@mcp.prompt() @mcp.prompt()
def circuit_from_scratch( def circuit_from_scratch(
description: str = "audio amplifier", description: str = "audio amplifier",
) -> str: ) -> list:
"""Guide through creating a complete circuit from scratch. """Guide through creating a complete circuit from scratch.
Args: Args:
description: What circuit to build description: What circuit to build
""" """
return f"""Build a complete circuit from scratch: {description} return [Message(role="user", content=f"""Build a complete circuit from scratch: {description}
Approach 1 - Use a template (recommended for common circuits): Approach 1 - Use a template (recommended for common circuits):
1. Use list_templates to see available circuit templates 1. Use list_templates to see available circuit templates
@ -2129,7 +2667,81 @@ Verification workflow:
4. Run .ac analysis for frequency response 4. Run .ac analysis for frequency response
5. Run .tran analysis for time-domain behavior 5. Run .tran analysis for time-domain behavior
6. Use diff_schematics to compare design iterations 6. Use diff_schematics to compare design iterations
""")]
@mcp.prompt()
def troubleshoot_simulation(
error_description: str = "",
schematic_path: str = "",
) -> list:
"""Systematic checklist for diagnosing simulation failures.
Args:
error_description: What went wrong (error message, unexpected results, etc.)
schematic_path: Path to the problematic schematic or netlist
""" """
path_note = (
f"Schematic/netlist: {schematic_path}"
if schematic_path
else "First, identify the schematic or netlist file."
)
error_note = (
f"Reported issue: {error_description}"
if error_description
else "No specific error described -- run full diagnostic."
)
return [Message(role="user", content=f"""Troubleshoot a simulation that isn't working correctly.
{path_note}
{error_note}
Diagnostic checklist (work through in order):
1. **Design Rule Check**
- Run run_drc on the schematic
- Fix any: missing ground, floating nodes, duplicate names, missing sim directive
2. **Installation & Setup**
- Run check_installation to verify Wine + LTspice
- Check that required .lib files exist
3. **Model Availability**
- Use search_spice_models to verify all transistor/diode models exist
- Use search_spice_subcircuits to verify op-amp models
- Common issue: model name in schematic doesn't match library
4. **Simulation Directive**
- Use read_schematic to check the directive
- Verify analysis type matches what you want (.tran, .ac, .dc, .op, .tf)
- For .tran: is the stop time long enough?
- For .ac: are start/stop frequencies reasonable?
5. **Node Connections**
- Use read_schematic to list all components and nets
- Check for disconnected nodes (components not wired)
- Verify ground connections on all return paths
6. **Run & Inspect**
- Simulate the circuit
- Check the log file for convergence warnings
- Use get_waveform to inspect node voltages
- Compare expected vs actual at each circuit stage
7. **Simplify & Isolate**
- Use edit_component to replace active devices with ideal ones
- Remove non-essential subcircuits
- Test each stage independently
- Add .ic directives if oscillators won't start
Common failure modes:
- Convergence failure: reduce timestep, add initial conditions
- All zeros: check ground connections and source polarity
- Unexpected clipping: check supply voltages and headroom
- Oscillation in DC circuit: add small capacitors on feedback
- Model not found: verify .lib/.include paths
""")]
# ============================================================================ # ============================================================================

533
src/mcp_ltspice/svg_plot.py Normal file
View File

@ -0,0 +1,533 @@
"""Pure SVG waveform plot generation -- no matplotlib dependency.
Generates complete <svg> XML strings for time-domain, Bode, and spectrum plots
suitable for embedding in HTML or saving as standalone .svg files.
"""
from __future__ import annotations
import math
from html import escape as _html_escape
import numpy as np
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_ENG_PREFIXES = [
(1e18, "E"),
(1e15, "P"),
(1e12, "T"),
(1e9, "G"),
(1e6, "M"),
(1e3, "k"),
(1e0, ""),
(1e-3, "m"),
(1e-6, "\u00b5"), # micro sign
(1e-9, "n"),
(1e-12, "p"),
(1e-15, "f"),
(1e-18, "a"),
]
_FREQ_PREFIXES = [
(1e9, "G"),
(1e6, "M"),
(1e3, "k"),
(1e0, ""),
(1e-3, "m"),
]
def _svg_escape(text: str) -> str:
"""Escape special characters for embedding inside SVG XML."""
return _html_escape(str(text), quote=True)
def _format_eng(value: float, unit: str = "") -> str:
"""Format *value* with an engineering prefix and optional *unit*."""
if value == 0:
return f"0{unit}"
abs_val = abs(value)
for threshold, prefix in _ENG_PREFIXES:
if abs_val >= threshold * 0.9999:
scaled = value / threshold
# Trim trailing zeros but keep at least one digit
txt = f"{scaled:.3g}"
return f"{txt}{prefix}{unit}"
# Extremely small -- fall back to scientific
return f"{value:.2e}{unit}"
def _format_freq(hz: float) -> str:
"""Format a frequency value for axis labels (1, 10, 1k, 1M, etc.)."""
if hz == 0:
return "0"
abs_hz = abs(hz)
for threshold, prefix in _FREQ_PREFIXES:
if abs_hz >= threshold * 0.9999:
scaled = hz / threshold
txt = f"{scaled:.4g}"
# Strip unnecessary trailing zeros after decimal
if "." in txt:
txt = txt.rstrip("0").rstrip(".")
return f"{txt}{prefix}"
return f"{hz:.2e}"
def _nice_ticks(vmin: float, vmax: float, n_ticks: int = 5) -> list[float]:
"""Compute human-friendly tick values spanning [vmin, vmax].
Returns a list of round numbers that cover the data range.
"""
if vmin == vmax:
return [vmin]
if not math.isfinite(vmin) or not math.isfinite(vmax):
return [0.0]
raw_step = (vmax - vmin) / max(n_ticks - 1, 1)
if raw_step == 0:
return [vmin]
magnitude = 10 ** math.floor(math.log10(abs(raw_step)))
residual = raw_step / magnitude
# Snap to a "nice" step: 1, 2, 2.5, 5, 10
if residual <= 1.0:
nice_step = magnitude
elif residual <= 2.0:
nice_step = 2 * magnitude
elif residual <= 2.5:
nice_step = 2.5 * magnitude
elif residual <= 5.0:
nice_step = 5 * magnitude
else:
nice_step = 10 * magnitude
tick_min = math.floor(vmin / nice_step) * nice_step
tick_max = math.ceil(vmax / nice_step) * nice_step
ticks: list[float] = []
t = tick_min
while t <= tick_max + nice_step * 0.001:
ticks.append(round(t, 12))
t += nice_step
return ticks
def _log_ticks(vmin: float, vmax: float) -> list[float]:
"""Generate tick values at powers of 10 spanning [vmin, vmax] (linear values)."""
if vmin <= 0:
vmin = 1.0
if vmax <= vmin:
vmax = vmin * 10
low = math.floor(math.log10(vmin))
high = math.ceil(math.log10(vmax))
ticks = [10.0**i for i in range(low, high + 1)]
# Filter to range
return [t for t in ticks if vmin * 0.9999 <= t <= vmax * 1.0001]
def _data_extent(arr: np.ndarray, pad_frac: float = 0.05) -> tuple[float, float]:
"""Return (min, max) of *arr* with *pad_frac* padding on each side."""
if len(arr) == 0:
return (0.0, 1.0)
lo, hi = float(np.nanmin(arr)), float(np.nanmax(arr))
if lo == hi:
lo -= 1.0
hi += 1.0
span = hi - lo
return (lo - span * pad_frac, hi + span * pad_frac)
# ---------------------------------------------------------------------------
# Core SVG building blocks
# ---------------------------------------------------------------------------
_FONT = "system-ui, -apple-system, sans-serif"
def _build_path_d(
xs: np.ndarray,
ys: np.ndarray,
x_min: float,
x_max: float,
y_min: float,
y_max: float,
plot_x: float,
plot_y: float,
plot_w: float,
plot_h: float,
log_x: bool = False,
) -> str:
"""Convert data arrays into an SVG path *d* attribute string."""
if len(xs) == 0:
return ""
# Map data -> pixel coords
if log_x:
safe_xs = np.clip(xs, max(x_min, 1e-30), None)
lx = np.log10(safe_xs)
lx_min = math.log10(max(x_min, 1e-30))
lx_max = math.log10(max(x_max, 1e-30))
denom_x = lx_max - lx_min if lx_max != lx_min else 1.0
px = plot_x + (lx - lx_min) / denom_x * plot_w
else:
denom_x = x_max - x_min if x_max != x_min else 1.0
px = plot_x + (xs - x_min) / denom_x * plot_w
denom_y = y_max - y_min if y_max != y_min else 1.0
# Y axis is inverted in SVG (top = 0)
py = plot_y + plot_h - (ys - y_min) / denom_y * plot_h
parts = [f"M{px[0]:.2f},{py[0]:.2f}"]
for i in range(1, len(px)):
parts.append(f"L{px[i]:.2f},{py[i]:.2f}")
return "".join(parts)
def _render_subplot(
*,
xs: np.ndarray,
ys: np.ndarray,
x_min: float,
x_max: float,
y_min: float,
y_max: float,
plot_x: float,
plot_y: float,
plot_w: float,
plot_h: float,
log_x: bool,
xlabel: str,
ylabel: str,
title: str | None,
stroke: str,
x_ticks: list[float] | None = None,
y_ticks: list[float] | None = None,
show_x_labels: bool = True,
) -> str:
"""Render a single subplot region as a block of SVG elements."""
lines: list[str] = []
# Compute ticks
if y_ticks is None:
y_ticks = _nice_ticks(y_min, y_max, n_ticks=6)
if x_ticks is None:
if log_x:
x_ticks = _log_ticks(x_min, x_max)
else:
x_ticks = _nice_ticks(x_min, x_max, n_ticks=6)
# Background
lines.append(
f'<rect x="{plot_x}" y="{plot_y}" width="{plot_w}" height="{plot_h}" '
f'fill="white" stroke="#ccc" stroke-width="1"/>'
)
# Grid + Y tick labels
denom_y = y_max - y_min if y_max != y_min else 1.0
for tv in y_ticks:
if tv < y_min or tv > y_max:
continue
py = plot_y + plot_h - (tv - y_min) / denom_y * plot_h
lines.append(
f'<line x1="{plot_x}" y1="{py:.1f}" x2="{plot_x + plot_w}" y2="{py:.1f}" '
f'stroke="#ddd" stroke-width="0.5" stroke-dasharray="4,3"/>'
)
label = _format_eng(tv)
lines.append(
f'<text x="{plot_x - 8}" y="{py:.1f}" text-anchor="end" '
f'dominant-baseline="middle" font-size="11" font-family="{_FONT}" '
f'fill="#444">{_svg_escape(label)}</text>'
)
# Grid + X tick labels
if log_x:
lx_min = math.log10(max(x_min, 1e-30))
lx_max = math.log10(max(x_max, 1e-30))
denom_x = lx_max - lx_min if lx_max != lx_min else 1.0
else:
denom_x = x_max - x_min if x_max != x_min else 1.0
for tv in x_ticks:
if log_x:
if tv <= 0:
continue
frac = (math.log10(tv) - lx_min) / denom_x
else:
frac = (tv - x_min) / denom_x
if frac < -0.001 or frac > 1.001:
continue
px = plot_x + frac * plot_w
lines.append(
f'<line x1="{px:.1f}" y1="{plot_y}" x2="{px:.1f}" y2="{plot_y + plot_h}" '
f'stroke="#ddd" stroke-width="0.5" stroke-dasharray="4,3"/>'
)
if show_x_labels:
if log_x:
label = _format_freq(tv)
else:
label = _format_eng(tv)
lines.append(
f'<text x="{px:.1f}" y="{plot_y + plot_h + 16}" text-anchor="middle" '
f'font-size="11" font-family="{_FONT}" fill="#444">'
f'{_svg_escape(label)}</text>'
)
# Data path
d = _build_path_d(xs, ys, x_min, x_max, y_min, y_max, plot_x, plot_y, plot_w, plot_h, log_x)
if d:
lines.append(
f'<path d="{d}" fill="none" stroke="{stroke}" stroke-width="1.5" '
f'stroke-linejoin="round" stroke-linecap="round"/>'
)
# Title
if title:
lines.append(
f'<text x="{plot_x + plot_w / 2}" y="{plot_y - 12}" text-anchor="middle" '
f'font-size="14" font-weight="600" font-family="{_FONT}" fill="#111">'
f'{_svg_escape(title)}</text>'
)
# Axis labels
if ylabel:
mid_y = plot_y + plot_h / 2
lines.append(
f'<text x="{plot_x - 55}" y="{mid_y}" text-anchor="middle" '
f'font-size="12" font-family="{_FONT}" fill="#333" '
f'transform="rotate(-90, {plot_x - 55}, {mid_y})">'
f'{_svg_escape(ylabel)}</text>'
)
if xlabel and show_x_labels:
lines.append(
f'<text x="{plot_x + plot_w / 2}" y="{plot_y + plot_h + 42}" '
f'text-anchor="middle" font-size="12" font-family="{_FONT}" fill="#333">'
f'{_svg_escape(xlabel)}</text>'
)
return "\n".join(lines)
def _wrap_svg(inner: str, width: int, height: int) -> str:
"""Wrap inner SVG elements in a root <svg> tag with white background."""
return (
f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" '
f'viewBox="0 0 {width} {height}">\n'
f'<rect width="{width}" height="{height}" fill="white"/>\n'
f'{inner}\n'
f'</svg>'
)
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def plot_timeseries(
time: list[float] | np.ndarray,
values: list[float] | np.ndarray,
title: str = "Time Domain",
ylabel: str = "Voltage (V)",
width: int = 800,
height: int = 400,
) -> str:
"""Plot a time-domain signal. Linear X axis (time), linear Y axis.
Returns a complete ``<svg>`` XML string.
"""
t = np.asarray(time, dtype=float).ravel()
v = np.asarray(values, dtype=float).ravel()
n = min(len(t), len(v))
t, v = t[:n], v[:n]
margin_l, margin_r, margin_t, margin_b = 80, 20, 40, 60
plot_x = float(margin_l)
plot_y = float(margin_t)
plot_w = float(width - margin_l - margin_r)
plot_h = float(height - margin_t - margin_b)
x_min, x_max = _data_extent(t)
y_min, y_max = _data_extent(v)
inner = _render_subplot(
xs=t,
ys=v,
x_min=x_min,
x_max=x_max,
y_min=y_min,
y_max=y_max,
plot_x=plot_x,
plot_y=plot_y,
plot_w=plot_w,
plot_h=plot_h,
log_x=False,
xlabel="Time (s)",
ylabel=ylabel,
title=title,
stroke="#2563eb",
)
return _wrap_svg(inner, width, height)
def plot_bode(
freq: list[float] | np.ndarray,
mag_db: list[float] | np.ndarray,
phase_deg: list[float] | np.ndarray | None = None,
title: str = "Bode Plot",
width: int = 800,
height: int = 500,
) -> str:
"""Plot frequency response (Bode plot). Log10 X, linear Y (dB).
If *phase_deg* is provided, the SVG contains two stacked subplots:
magnitude on top, phase on the bottom.
Returns a complete ``<svg>`` XML string.
"""
f = np.asarray(freq, dtype=float).ravel()
m = np.asarray(mag_db, dtype=float).ravel()
n = min(len(f), len(m))
f, m = f[:n], m[:n]
has_phase = phase_deg is not None
if has_phase:
p = np.asarray(phase_deg, dtype=float).ravel()
n = min(len(f), len(p))
f, m, p = f[:n], m[:n], p[:n]
margin_l, margin_r, margin_t, margin_b = 80, 20, 40, 60
plot_x = float(margin_l)
plot_w = float(width - margin_l - margin_r)
# Frequency range (shared)
f_min, f_max = _data_extent(f[f > 0] if np.any(f > 0) else f, pad_frac=0.0)
if f_min <= 0:
f_min = 1.0
freq_ticks = _log_ticks(f_min, f_max)
if has_phase:
# Split into two subplots with a gap
gap = 30
available_h = height - margin_t - margin_b - gap
mag_h = available_h * 0.55
phase_h = available_h * 0.45
mag_y = float(margin_t)
phase_y = float(margin_t + mag_h + gap)
m_min, m_max = _data_extent(m)
p_min, p_max = _data_extent(p)
mag_svg = _render_subplot(
xs=f,
ys=m,
x_min=f_min,
x_max=f_max,
y_min=m_min,
y_max=m_max,
plot_x=plot_x,
plot_y=mag_y,
plot_w=plot_w,
plot_h=mag_h,
log_x=True,
xlabel="",
ylabel="Magnitude (dB)",
title=title,
stroke="#2563eb",
x_ticks=freq_ticks,
show_x_labels=False,
)
phase_svg = _render_subplot(
xs=f,
ys=p,
x_min=f_min,
x_max=f_max,
y_min=p_min,
y_max=p_max,
plot_x=plot_x,
plot_y=phase_y,
plot_w=plot_w,
plot_h=phase_h,
log_x=True,
xlabel="Frequency (Hz)",
ylabel="Phase (deg)",
title=None,
stroke="#dc2626",
x_ticks=freq_ticks,
)
return _wrap_svg(mag_svg + "\n" + phase_svg, width, height)
else:
plot_y = float(margin_t)
plot_h = float(height - margin_t - margin_b)
m_min, m_max = _data_extent(m)
inner = _render_subplot(
xs=f,
ys=m,
x_min=f_min,
x_max=f_max,
y_min=m_min,
y_max=m_max,
plot_x=plot_x,
plot_y=plot_y,
plot_w=plot_w,
plot_h=plot_h,
log_x=True,
xlabel="Frequency (Hz)",
ylabel="Magnitude (dB)",
title=title,
stroke="#2563eb",
x_ticks=freq_ticks,
)
return _wrap_svg(inner, width, height)
def plot_spectrum(
freq: list[float] | np.ndarray,
mag_db: list[float] | np.ndarray,
title: str = "FFT Spectrum",
width: int = 800,
height: int = 400,
) -> str:
"""Plot an FFT spectrum. Log10 X axis, linear Y axis (dB).
Returns a complete ``<svg>`` XML string.
"""
f = np.asarray(freq, dtype=float).ravel()
m = np.asarray(mag_db, dtype=float).ravel()
n = min(len(f), len(m))
f, m = f[:n], m[:n]
margin_l, margin_r, margin_t, margin_b = 80, 20, 40, 60
plot_x = float(margin_l)
plot_y = float(margin_t)
plot_w = float(width - margin_l - margin_r)
plot_h = float(height - margin_t - margin_b)
f_min, f_max = _data_extent(f[f > 0] if np.any(f > 0) else f, pad_frac=0.0)
if f_min <= 0:
f_min = 1.0
m_min, m_max = _data_extent(m)
inner = _render_subplot(
xs=f,
ys=m,
x_min=f_min,
x_max=f_max,
y_min=m_min,
y_max=m_max,
plot_x=plot_x,
plot_y=plot_y,
plot_w=plot_w,
plot_h=plot_h,
log_x=True,
xlabel="Frequency (Hz)",
ylabel="Magnitude (dB)",
title=title,
stroke="#2563eb",
)
return _wrap_svg(inner, width, height)

View File

@ -3,7 +3,6 @@
All fixtures produce synthetic data -- no LTspice or Wine required. All fixtures produce synthetic data -- no LTspice or Wine required.
""" """
import tempfile
from pathlib import Path from pathlib import Path
import numpy as np import numpy as np
@ -12,7 +11,6 @@ import pytest
from mcp_ltspice.raw_parser import RawFile, Variable from mcp_ltspice.raw_parser import RawFile, Variable
from mcp_ltspice.schematic import Component, Flag, Schematic, Text, Wire from mcp_ltspice.schematic import Component, Flag, Schematic, Text, Wire
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Time-domain fixtures # Time-domain fixtures
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -326,3 +324,13 @@ def schematic_duplicate_names() -> Schematic:
] ]
sch.texts = [Text(80, 296, ".tran 10m", type="spice")] sch.texts = [Text(80, 296, ".tran 10m", type="spice")]
return sch return sch
@pytest.fixture
def ltspice_available():
"""Skip test if LTspice is not available."""
from mcp_ltspice.config import validate_installation
ok, msg = validate_installation()
if not ok:
pytest.skip(f"LTspice not available: {msg}")
return True

View File

@ -3,9 +3,8 @@
import pytest import pytest
from mcp_ltspice.asc_generator import ( from mcp_ltspice.asc_generator import (
AscSchematic,
GRID,
_PIN_OFFSETS, _PIN_OFFSETS,
AscSchematic,
_rotate, _rotate,
generate_inverting_amp, generate_inverting_amp,
generate_rc_lowpass, generate_rc_lowpass,

View File

@ -1,12 +1,9 @@
"""Tests for diff module: schematic comparison.""" """Tests for diff module: schematic comparison."""
from pathlib import Path
import pytest
from mcp_ltspice.diff import ( from mcp_ltspice.diff import (
ComponentChange, ComponentChange,
DirectiveChange,
SchematicDiff, SchematicDiff,
_diff_components, _diff_components,
_diff_directives, _diff_directives,

View File

@ -1,9 +1,6 @@
"""Tests for drc module: design rule checks on schematic objects.""" """Tests for drc module: design rule checks on schematic objects."""
import tempfile
from pathlib import Path
import pytest
from mcp_ltspice.drc import ( from mcp_ltspice.drc import (
DRCResult, DRCResult,
@ -13,7 +10,7 @@ from mcp_ltspice.drc import (
_check_ground, _check_ground,
_check_simulation_directive, _check_simulation_directive,
) )
from mcp_ltspice.schematic import Component, Flag, Schematic, Text, Wire, write_schematic from mcp_ltspice.schematic import Schematic, write_schematic
def _run_single_check(check_fn, schematic: Schematic) -> DRCResult: def _run_single_check(check_fn, schematic: Schematic) -> DRCResult:

209
tests/test_integration.py Normal file
View File

@ -0,0 +1,209 @@
"""Integration tests that run actual LTspice simulations.
These tests require LTspice and Wine to be installed. Skip with:
pytest -m 'not integration'
"""
import tempfile
from pathlib import Path
import numpy as np
import pytest
from mcp_ltspice.asc_generator import (
generate_colpitts_oscillator as generate_colpitts_asc,
)
from mcp_ltspice.asc_generator import (
generate_common_emitter_amp as generate_ce_amp_asc,
)
from mcp_ltspice.asc_generator import (
generate_non_inverting_amp as generate_noninv_amp_asc,
)
from mcp_ltspice.asc_generator import (
generate_rc_lowpass as generate_rc_lowpass_asc,
)
from mcp_ltspice.runner import run_simulation
from mcp_ltspice.waveform_math import compute_bandwidth, compute_rms
@pytest.mark.integration
class TestRCLowpass:
"""End-to-end test: RC lowpass filter -> simulate -> verify -3dB point."""
async def test_rc_lowpass_bandwidth(self, ltspice_available):
"""Generate RC lowpass (R=1k, C=100n), simulate, verify fc ~ 1.6 kHz."""
# Generate schematic
sch = generate_rc_lowpass_asc(r="1k", c="100n")
with tempfile.TemporaryDirectory() as tmpdir:
asc_path = Path(tmpdir) / "rc_lowpass.asc"
sch.save(asc_path)
# Simulate
result = await run_simulation(asc_path)
assert result.success, f"Simulation failed: {result.error or result.stderr}"
assert result.raw_data is not None, "No raw data produced"
# Find frequency and V(out) variables
raw = result.raw_data
freq_idx = None
vout_idx = None
for var in raw.variables:
if var.name.lower() == "frequency":
freq_idx = var.index
elif var.name.lower() == "v(out)":
vout_idx = var.index
assert freq_idx is not None, (
f"No frequency variable. Variables: {[v.name for v in raw.variables]}"
)
assert vout_idx is not None, (
f"No V(out) variable. Variables: {[v.name for v in raw.variables]}"
)
# Extract data
freq = np.abs(raw.data[freq_idx])
mag_complex = raw.data[vout_idx]
mag_db = 20.0 * np.log10(np.maximum(np.abs(mag_complex), 1e-30))
# Compute bandwidth
bw = compute_bandwidth(freq, mag_db)
# Expected: fc = 1/(2*pi*1000*100e-9) ~ 1591 Hz
# Allow 20% tolerance for simulation differences
expected_fc = 1.0 / (2 * np.pi * 1000 * 100e-9)
assert bw["bandwidth_hz"] is not None, "Could not compute bandwidth"
assert abs(bw["bandwidth_hz"] - expected_fc) / expected_fc < 0.2, (
f"Bandwidth {bw['bandwidth_hz']:.0f} Hz too far from expected {expected_fc:.0f} Hz"
)
@pytest.mark.integration
class TestNonInvertingAmp:
"""End-to-end test: non-inverting amp -> simulate -> verify gain."""
async def test_noninv_amp_gain(self, ltspice_available):
"""Generate non-inverting amp (Rf=100k, Rin=10k), verify gain ~ 11 (20.8 dB)."""
sch = generate_noninv_amp_asc(rin="10k", rf="100k")
with tempfile.TemporaryDirectory() as tmpdir:
asc_path = Path(tmpdir) / "noninv_amp.asc"
sch.save(asc_path)
result = await run_simulation(asc_path)
assert result.success, f"Simulation failed: {result.error or result.stderr}"
assert result.raw_data is not None
raw = result.raw_data
freq_idx = None
vout_idx = None
for var in raw.variables:
if var.name.lower() == "frequency":
freq_idx = var.index
elif var.name.lower() == "v(out)":
vout_idx = var.index
assert freq_idx is not None
assert vout_idx is not None
_freq = np.abs(raw.data[freq_idx]) # noqa: F841 — kept for debug
mag = np.abs(raw.data[vout_idx])
mag_db = 20.0 * np.log10(np.maximum(mag, 1e-30))
# At low frequency, gain should be 1 + 100k/10k = 11 = 20.83 dB
# Use first few points (low frequency)
low_freq_gain_db = float(np.mean(mag_db[:5]))
expected_gain_db = 20 * np.log10(11) # 20.83 dB
assert abs(low_freq_gain_db - expected_gain_db) < 2.0, (
f"Low-freq gain {low_freq_gain_db:.1f} dB, expected ~{expected_gain_db:.1f} dB"
)
@pytest.mark.integration
class TestCommonEmitterAmp:
"""End-to-end test: CE amplifier -> simulate transient -> verify output exists."""
async def test_ce_amp_output(self, ltspice_available):
"""Generate CE amp, simulate transient, verify output has AC content."""
sch = generate_ce_amp_asc()
with tempfile.TemporaryDirectory() as tmpdir:
asc_path = Path(tmpdir) / "ce_amp.asc"
sch.save(asc_path)
result = await run_simulation(asc_path)
assert result.success, f"Simulation failed: {result.error or result.stderr}"
assert result.raw_data is not None
raw = result.raw_data
time_idx = None
vout_idx = None
for var in raw.variables:
if var.name.lower() == "time":
time_idx = var.index
elif var.name.lower() == "v(out)":
vout_idx = var.index
assert time_idx is not None
assert vout_idx is not None
sig = np.real(raw.data[vout_idx])
# Output should not be DC-only -- check peak-to-peak > threshold
pp = float(np.max(sig) - np.min(sig))
assert pp > 0.01, (
f"Output appears DC-only (peak-to-peak={pp:.4f}V). "
"Expected amplified AC signal."
)
# RMS should be non-trivial
rms = float(compute_rms(sig))
assert rms > 0.01, f"Output RMS too low: {rms:.4f}V"
@pytest.mark.integration
class TestColpittsOscillator:
"""End-to-end test: Colpitts oscillator -> simulate -> verify oscillation."""
async def test_colpitts_oscillation(self, ltspice_available):
"""Generate Colpitts oscillator, verify oscillation near expected frequency."""
sch = generate_colpitts_asc()
with tempfile.TemporaryDirectory() as tmpdir:
asc_path = Path(tmpdir) / "colpitts.asc"
sch.save(asc_path)
result = await run_simulation(asc_path)
assert result.success, f"Simulation failed: {result.error or result.stderr}"
assert result.raw_data is not None
raw = result.raw_data
time_idx = None
vcol_idx = None
for var in raw.variables:
if var.name.lower() == "time":
time_idx = var.index
elif var.name.lower() == "v(out)":
vcol_idx = var.index
elif "collector" in var.name.lower() and vcol_idx is None:
vcol_idx = var.index
assert time_idx is not None
assert vcol_idx is not None, (
f"No V(out) or collector signal. Variables: {[v.name for v in raw.variables]}"
)
sig = np.real(raw.data[vcol_idx])
# Oscillator output should have significant AC content
pp = float(np.max(sig) - np.min(sig))
assert pp > 0.1, (
f"Output peak-to-peak {pp:.3f}V too small -- oscillator may not have started"
)
# Expected frequency: f = 1/(2*pi*sqrt(L*C1*C2/(C1+C2)))
# With L=1u, C1=C2=100p: Cseries = 50p
# f = 1/(2*pi*sqrt(1e-6 * 50e-12)) ~ 22.5 MHz
# This is quite high, but we just verify oscillation exists

447
tests/test_new_templates.py Normal file
View File

@ -0,0 +1,447 @@
"""Tests for the 5 new circuit templates (netlist + asc generator)."""
import pytest
from mcp_ltspice.asc_generator import (
AscSchematic,
generate_boost_converter,
generate_current_mirror,
generate_instrumentation_amp,
generate_sallen_key_lowpass,
generate_transimpedance_amp,
)
from mcp_ltspice.netlist import (
Netlist,
boost_converter,
current_mirror,
instrumentation_amplifier,
sallen_key_lowpass,
transimpedance_amplifier,
)
# ---------------------------------------------------------------------------
# Netlist template tests
# ---------------------------------------------------------------------------
class TestSallenKeyLowpassNetlist:
def test_returns_netlist(self):
n = sallen_key_lowpass()
assert isinstance(n, Netlist)
def test_component_count(self):
n = sallen_key_lowpass()
# V1, Vpos, Vneg, R1, R2, C1, C2, X1 = 8 components
assert len(n.components) == 8
def test_render_contains_key_components(self):
text = sallen_key_lowpass().render()
assert "R1" in text
assert "R2" in text
assert "C1" in text
assert "C2" in text
assert "X1" in text
assert "LT1001" in text
assert ".ac" in text
def test_custom_params(self):
n = sallen_key_lowpass(r1="4.7k", r2="4.7k", c1="22n", c2="22n")
text = n.render()
assert "4.7k" in text
assert "22n" in text
def test_has_backanno_and_end(self):
text = sallen_key_lowpass().render()
assert ".backanno" in text
assert ".end" in text
class TestBoostConverterNetlist:
def test_returns_netlist(self):
n = boost_converter()
assert isinstance(n, Netlist)
def test_component_count(self):
n = boost_converter()
# Vin, Vgate, L1, M1, D1, Cout, Rload = 7 components
assert len(n.components) == 7
def test_render_contains_key_components(self):
text = boost_converter().render()
assert "L1" in text
assert "M1" in text
assert "D1" in text
assert "Cout" in text
assert "Rload" in text
assert "PULSE(" in text
assert ".tran" in text
def test_custom_params(self):
n = boost_converter(ind="22u", r_load="100", v_in="3.3", duty_cycle=0.6)
text = n.render()
assert "22u" in text
assert "100" in text
assert "3.3" in text
def test_has_backanno_and_end(self):
text = boost_converter().render()
assert ".backanno" in text
assert ".end" in text
class TestInstrumentationAmplifierNetlist:
def test_returns_netlist(self):
n = instrumentation_amplifier()
assert isinstance(n, Netlist)
def test_component_count(self):
n = instrumentation_amplifier()
# V1, V2, Vpos, Vneg, X1, X2, R1, R1b, Rgain, X3, R2, R3, R2b, R3b = 14
assert len(n.components) == 14
def test_render_contains_key_components(self):
text = instrumentation_amplifier().render()
assert "X1" in text
assert "X2" in text
assert "X3" in text
assert "Rgain" in text
assert "LT1001" in text
assert ".ac" in text
def test_custom_params(self):
n = instrumentation_amplifier(r1="20k", r_gain="1k")
text = n.render()
assert "20k" in text
assert "1k" in text
def test_has_backanno_and_end(self):
text = instrumentation_amplifier().render()
assert ".backanno" in text
assert ".end" in text
class TestCurrentMirrorNetlist:
def test_returns_netlist(self):
n = current_mirror()
assert isinstance(n, Netlist)
def test_component_count(self):
n = current_mirror()
# Vcc, Rref, Rload, Q1, Q2 = 5 components
assert len(n.components) == 5
def test_render_contains_key_components(self):
text = current_mirror().render()
assert "Q1" in text
assert "Q2" in text
assert "Rref" in text
assert "Rload" in text
assert "2N2222" in text
assert ".op" in text
assert ".tran" in text
def test_custom_params(self):
n = current_mirror(r_ref="4.7k", r_load="2.2k", vcc="5")
text = n.render()
assert "4.7k" in text
assert "2.2k" in text
assert "5" in text
def test_has_backanno_and_end(self):
text = current_mirror().render()
assert ".backanno" in text
assert ".end" in text
class TestTransimpedanceAmplifierNetlist:
def test_returns_netlist(self):
n = transimpedance_amplifier()
assert isinstance(n, Netlist)
def test_component_count(self):
n = transimpedance_amplifier()
# I1, Vpos, Vneg, Rf, Cf, X1 = 6 components
assert len(n.components) == 6
def test_render_contains_key_components(self):
text = transimpedance_amplifier().render()
assert "I1" in text
assert "Rf" in text
assert "Cf" in text
assert "X1" in text
assert "LT1001" in text
assert ".ac" in text
def test_custom_params(self):
n = transimpedance_amplifier(rf="1Meg", cf="0.5p", i_source="10u")
text = n.render()
assert "1Meg" in text
assert "0.5p" in text
assert "10u" in text
def test_has_backanno_and_end(self):
text = transimpedance_amplifier().render()
assert ".backanno" in text
assert ".end" in text
# ---------------------------------------------------------------------------
# ASC generator template tests
# ---------------------------------------------------------------------------
class TestSallenKeyLowpassAsc:
def test_returns_schematic(self):
sch = generate_sallen_key_lowpass()
assert isinstance(sch, AscSchematic)
def test_render_valid(self):
text = generate_sallen_key_lowpass().render()
assert text.startswith("Version 4\n")
assert "SHEET" in text
assert len(text) > 100
def test_contains_expected_symbols(self):
text = generate_sallen_key_lowpass().render()
assert "SYMBOL res" in text
assert "SYMBOL cap" in text
assert "SYMBOL OpAmps/UniversalOpamp2" in text
assert "SYMBOL voltage" in text
def test_custom_params(self):
text = generate_sallen_key_lowpass(r1="4.7k", c1="22n").render()
assert "4.7k" in text
assert "22n" in text
def test_has_simulation_directive(self):
text = generate_sallen_key_lowpass().render()
assert ".ac" in text
class TestBoostConverterAsc:
def test_returns_schematic(self):
sch = generate_boost_converter()
assert isinstance(sch, AscSchematic)
def test_render_valid(self):
text = generate_boost_converter().render()
assert text.startswith("Version 4\n")
assert "SHEET" in text
assert len(text) > 100
def test_contains_expected_symbols(self):
text = generate_boost_converter().render()
assert "SYMBOL ind" in text
assert "SYMBOL nmos" in text
assert "SYMBOL diode" in text
assert "SYMBOL cap" in text
assert "SYMBOL res" in text
def test_custom_params(self):
text = generate_boost_converter(ind="22u", r_load="100").render()
assert "22u" in text
assert "100" in text
def test_has_simulation_directive(self):
text = generate_boost_converter().render()
assert ".tran" in text
class TestInstrumentationAmpAsc:
def test_returns_schematic(self):
sch = generate_instrumentation_amp()
assert isinstance(sch, AscSchematic)
def test_render_valid(self):
text = generate_instrumentation_amp().render()
assert text.startswith("Version 4\n")
assert "SHEET" in text
assert len(text) > 100
def test_contains_expected_symbols(self):
text = generate_instrumentation_amp().render()
# Should have 3 opamps
assert text.count("SYMBOL OpAmps/UniversalOpamp2") == 3
# Should have multiple resistors
assert text.count("SYMBOL res") >= 7
def test_custom_params(self):
text = generate_instrumentation_amp(r1="20k", r_gain="1k").render()
assert "20k" in text
assert "1k" in text
def test_has_simulation_directive(self):
text = generate_instrumentation_amp().render()
assert ".ac" in text
class TestCurrentMirrorAsc:
def test_returns_schematic(self):
sch = generate_current_mirror()
assert isinstance(sch, AscSchematic)
def test_render_valid(self):
text = generate_current_mirror().render()
assert text.startswith("Version 4\n")
assert "SHEET" in text
assert len(text) > 100
def test_contains_expected_symbols(self):
text = generate_current_mirror().render()
# Should have 2 NPN transistors
assert text.count("SYMBOL npn") == 2
assert "SYMBOL res" in text
assert "SYMBOL voltage" in text
def test_custom_params(self):
text = generate_current_mirror(r_ref="4.7k", r_load="2.2k").render()
assert "4.7k" in text
assert "2.2k" in text
def test_has_simulation_directive(self):
text = generate_current_mirror().render()
assert ".op" in text
assert ".tran" in text
class TestTransimpedanceAmpAsc:
def test_returns_schematic(self):
sch = generate_transimpedance_amp()
assert isinstance(sch, AscSchematic)
def test_render_valid(self):
text = generate_transimpedance_amp().render()
assert text.startswith("Version 4\n")
assert "SHEET" in text
assert len(text) > 100
def test_contains_expected_symbols(self):
text = generate_transimpedance_amp().render()
assert "SYMBOL OpAmps/UniversalOpamp2" in text
assert "SYMBOL res" in text
assert "SYMBOL cap" in text
def test_custom_params(self):
text = generate_transimpedance_amp(rf="1Meg", cf="0.5p").render()
assert "1Meg" in text
assert "0.5p" in text
def test_has_simulation_directive(self):
text = generate_transimpedance_amp().render()
assert ".ac" in text
# ---------------------------------------------------------------------------
# Parametrized cross-cutting tests for all 5 new netlist templates
# ---------------------------------------------------------------------------
class TestNewNetlistTemplatesCommon:
@pytest.mark.parametrize(
"factory",
[
sallen_key_lowpass,
boost_converter,
instrumentation_amplifier,
current_mirror,
transimpedance_amplifier,
],
)
def test_returns_netlist(self, factory):
assert isinstance(factory(), Netlist)
@pytest.mark.parametrize(
"factory",
[
sallen_key_lowpass,
boost_converter,
instrumentation_amplifier,
current_mirror,
transimpedance_amplifier,
],
)
def test_has_backanno_and_end(self, factory):
text = factory().render()
assert ".backanno" in text
assert ".end" in text
@pytest.mark.parametrize(
"factory",
[
sallen_key_lowpass,
boost_converter,
instrumentation_amplifier,
current_mirror,
transimpedance_amplifier,
],
)
def test_has_components(self, factory):
n = factory()
assert len(n.components) > 0
@pytest.mark.parametrize(
"factory",
[
sallen_key_lowpass,
boost_converter,
instrumentation_amplifier,
current_mirror,
transimpedance_amplifier,
],
)
def test_has_sim_directive(self, factory):
text = factory().render()
sim_types = [".tran", ".ac", ".dc", ".op", ".noise", ".tf"]
assert any(sim in text.lower() for sim in sim_types), (
f"No simulation directive found in {factory.__name__}"
)
# ---------------------------------------------------------------------------
# Parametrized cross-cutting tests for all 5 new ASC templates
# ---------------------------------------------------------------------------
class TestNewAscTemplatesCommon:
@pytest.mark.parametrize(
"factory",
[
generate_sallen_key_lowpass,
generate_boost_converter,
generate_instrumentation_amp,
generate_current_mirror,
generate_transimpedance_amp,
],
)
def test_returns_schematic(self, factory):
assert isinstance(factory(), AscSchematic)
@pytest.mark.parametrize(
"factory",
[
generate_sallen_key_lowpass,
generate_boost_converter,
generate_instrumentation_amp,
generate_current_mirror,
generate_transimpedance_amp,
],
)
def test_render_nonempty(self, factory):
text = factory().render()
assert len(text) > 50
assert "SYMBOL" in text
@pytest.mark.parametrize(
"factory",
[
generate_sallen_key_lowpass,
generate_boost_converter,
generate_instrumentation_amp,
generate_current_mirror,
generate_transimpedance_amp,
],
)
def test_has_version_and_sheet(self, factory):
text = factory().render()
assert "Version 4" in text
assert "SHEET" in text

View File

@ -3,7 +3,7 @@
import numpy as np import numpy as np
import pytest import pytest
from mcp_ltspice.raw_parser import RawFile, Variable, _detect_run_boundaries from mcp_ltspice.raw_parser import _detect_run_boundaries
class TestDetectRunBoundaries: class TestDetectRunBoundaries:

View File

@ -1,7 +1,6 @@
"""Tests for stability module: gain margin, phase margin from loop gain data.""" """Tests for stability module: gain margin, phase margin from loop gain data."""
import numpy as np import numpy as np
import pytest
from mcp_ltspice.stability import ( from mcp_ltspice.stability import (
compute_gain_margin, compute_gain_margin,

199
tests/test_svg_plot.py Normal file
View File

@ -0,0 +1,199 @@
"""Tests for the pure-SVG waveform plot generation module."""
import numpy as np
import pytest
from mcp_ltspice.svg_plot import (
_format_freq,
_nice_ticks,
plot_bode,
plot_spectrum,
plot_timeseries,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture()
def sine_wave():
"""A 1 kHz sine wave sampled at 100 kHz for 10 ms."""
t = np.linspace(0, 0.01, 1000, endpoint=False)
v = np.sin(2 * np.pi * 1000 * t)
return t, v
@pytest.fixture()
def bode_data():
"""Simple first-order lowpass Bode response (fc = 1 kHz)."""
freq = np.logspace(1, 6, 500)
fc = 1e3
mag_db = -10 * np.log10(1 + (freq / fc) ** 2)
phase_deg = -np.degrees(np.arctan(freq / fc))
return freq, mag_db, phase_deg
@pytest.fixture()
def spectrum_data():
"""Synthetic FFT spectrum with a peak at 1 kHz."""
freq = np.logspace(1, 5, 300)
mag_db = -60 * np.ones_like(freq)
peak_idx = np.argmin(np.abs(freq - 1e3))
mag_db[peak_idx] = 0.0
return freq, mag_db
# ---------------------------------------------------------------------------
# Timeseries
# ---------------------------------------------------------------------------
class TestPlotTimeseries:
def test_basic(self, sine_wave):
"""A simple sine wave produces a valid SVG with expected elements."""
t, v = sine_wave
svg = plot_timeseries(t, v)
assert svg.startswith("<svg")
assert "<path" in svg
assert "Time Domain" in svg
def test_empty_arrays(self):
"""Empty input should not crash and should return a valid SVG."""
svg = plot_timeseries([], [])
assert svg.startswith("<svg")
assert "</svg>" in svg
def test_custom_title_and_labels(self, sine_wave):
"""Custom title and ylabel should appear in the SVG output."""
t, v = sine_wave
svg = plot_timeseries(t, v, title="My Signal", ylabel="Current (A)")
assert "My Signal" in svg
assert "Current (A)" in svg
def test_svg_dimensions(self, sine_wave):
"""The width/height attributes should match the requested size."""
t, v = sine_wave
svg = plot_timeseries(t, v, width=1024, height=768)
assert 'width="1024"' in svg
assert 'height="768"' in svg
# ---------------------------------------------------------------------------
# Bode
# ---------------------------------------------------------------------------
class TestPlotBode:
def test_magnitude_only(self, bode_data):
"""Bode plot without phase produces a valid SVG with one trace."""
freq, mag_db, _ = bode_data
svg = plot_bode(freq, mag_db)
assert svg.startswith("<svg")
assert "<path" in svg
assert "Bode Plot" in svg
def test_with_phase(self, bode_data):
"""Bode plot with phase should contain two <path> elements (mag + phase)."""
freq, mag_db, phase_deg = bode_data
svg = plot_bode(freq, mag_db, phase_deg)
assert svg.startswith("<svg")
# Two traces -- magnitude and phase
assert svg.count("<path") >= 2
# Phase subplot label
assert "Phase (deg)" in svg
def test_log_axis_ticks(self, bode_data):
"""Log frequency axis should contain tick labels at powers of 10."""
freq, mag_db, _ = bode_data
svg = plot_bode(freq, mag_db)
# Expect at least some frequency labels like "100", "1k", "10k", "100k"
found = sum(1 for lbl in ("100", "1k", "10k", "100k") if lbl in svg)
assert found >= 2, f"Expected log tick labels in SVG; found {found}"
# ---------------------------------------------------------------------------
# Spectrum
# ---------------------------------------------------------------------------
class TestPlotSpectrum:
def test_basic(self, spectrum_data):
"""A simple spectrum produces a valid SVG."""
freq, mag_db = spectrum_data
svg = plot_spectrum(freq, mag_db)
assert svg.startswith("<svg")
assert "<path" in svg
assert "FFT Spectrum" in svg
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
class TestNiceTicks:
def test_simple_range(self):
ticks = _nice_ticks(0, 10, n_ticks=5)
assert len(ticks) >= 3
assert ticks[0] <= 0
assert ticks[-1] >= 10
def test_equal_values(self):
"""When vmin == vmax, return a single-element list."""
ticks = _nice_ticks(5, 5)
assert ticks == [5]
def test_negative_range(self):
ticks = _nice_ticks(-100, -20, n_ticks=5)
assert ticks[0] <= -100
assert ticks[-1] >= -20
def test_small_range(self):
ticks = _nice_ticks(0.001, 0.005, n_ticks=5)
assert all(0 <= t <= 0.01 for t in ticks)
class TestFormatFreq:
def test_hz(self):
assert _format_freq(1) == "1"
assert _format_freq(10) == "10"
assert _format_freq(100) == "100"
def test_khz(self):
assert _format_freq(1000) == "1k"
assert _format_freq(10000) == "10k"
assert _format_freq(100000) == "100k"
def test_mhz(self):
assert _format_freq(1e6) == "1M"
assert _format_freq(10e6) == "10M"
def test_ghz(self):
assert _format_freq(1e9) == "1G"
def test_zero(self):
assert _format_freq(0) == "0"
class TestSvgDimensions:
def test_timeseries_dimensions(self):
t = np.linspace(0, 1, 100)
v = np.sin(t)
svg = plot_timeseries(t, v, width=640, height=480)
assert 'width="640"' in svg
assert 'height="480"' in svg
def test_bode_dimensions(self):
freq = np.logspace(1, 5, 50)
mag = np.zeros(50)
svg = plot_bode(freq, mag, width=900, height=600)
assert 'width="900"' in svg
assert 'height="600"' in svg
def test_spectrum_dimensions(self):
freq = np.logspace(1, 5, 50)
mag = np.zeros(50)
svg = plot_spectrum(freq, mag, width=1000, height=500)
assert 'width="1000"' in svg
assert 'height="500"' in svg

View File

@ -1,13 +1,11 @@
"""Tests for touchstone module: format conversion, parsing, S-parameter extraction.""" """Tests for touchstone module: format conversion, parsing, S-parameter extraction."""
import re
from pathlib import Path from pathlib import Path
import numpy as np import numpy as np
import pytest import pytest
from mcp_ltspice.touchstone import ( from mcp_ltspice.touchstone import (
TouchstoneData,
_detect_ports, _detect_ports,
_to_complex, _to_complex,
get_s_parameter, get_s_parameter,

View File

@ -5,13 +5,11 @@ import pytest
from mcp_ltspice.waveform_expr import ( from mcp_ltspice.waveform_expr import (
WaveformCalculator, WaveformCalculator,
_Token,
_tokenize, _tokenize,
_TokenType, _TokenType,
evaluate_expression, evaluate_expression,
) )
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Tokenizer tests # Tokenizer tests
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------