From 9b418a06c56b8a991e2369b84bddc423e05e9b01 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Wed, 11 Feb 2026 05:13:50 -0700 Subject: [PATCH 1/2] Add SVG plotting, circuit tuning, 5 new templates, fix prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SVG waveform plots (svg_plot.py): pure-SVG timeseries, Bode, spectrum generation with plot_waveform MCP tool — no matplotlib dependency - Circuit tuning tool (tune_circuit): single-shot simulate → measure → compare targets → suggest adjustments workflow for iterative design - 5 new circuit templates: Sallen-Key lowpass, boost converter, instrumentation amplifier, current mirror, transimpedance amplifier (both netlist and .asc schematic generators, 15 total templates) - Fix all 6 prompts to return list[Message] per FastMCP 2.x spec - Add ltspice://templates and ltspice://template/{name} resources - Add troubleshoot_simulation prompt - Integration tests for RC lowpass and non-inverting amp (2/4 pass; CE amp and Colpitts oscillator have pre-existing schematic bugs) - 360 unit tests passing, ruff clean --- pyproject.toml | 6 + src/mcp_ltspice/asc_generator.py | 667 +++++++++++++++++++++++++++++++ src/mcp_ltspice/netlist.py | 203 ++++++++++ src/mcp_ltspice/server.py | 662 ++++++++++++++++++++++++++++-- src/mcp_ltspice/svg_plot.py | 533 ++++++++++++++++++++++++ tests/conftest.py | 12 +- tests/test_asc_generator.py | 3 +- tests/test_diff.py | 3 - tests/test_drc.py | 5 +- tests/test_integration.py | 218 ++++++++++ tests/test_new_templates.py | 447 +++++++++++++++++++++ tests/test_raw_parser.py | 2 +- tests/test_stability.py | 1 - tests/test_svg_plot.py | 199 +++++++++ tests/test_touchstone.py | 2 - tests/test_waveform_expr.py | 2 - 16 files changed, 2923 insertions(+), 42 deletions(-) create mode 100644 src/mcp_ltspice/svg_plot.py create mode 100644 tests/test_integration.py create mode 100644 tests/test_new_templates.py create mode 100644 tests/test_svg_plot.py diff --git a/pyproject.toml b/pyproject.toml index 5893759..d98ef52 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,9 @@ packages = ["src/mcp_ltspice"] [tool.pytest.ini_options] testpaths = ["tests"] asyncio_mode = "auto" +markers = [ + "integration: tests requiring LTspice/Wine installation (deselect with '-m not integration')", +] [tool.ruff] line-length = 100 @@ -58,3 +61,6 @@ target-version = "py311" [tool.ruff.lint] select = ["E", "F", "I", "N", "W", "UP"] ignore = ["E501"] + +[tool.ruff.lint.per-file-ignores] +"tests/**" = ["N806"] # Allow EE-conventional uppercase vars (K, Q, H, Vpk, etc.) diff --git a/src/mcp_ltspice/asc_generator.py b/src/mcp_ltspice/asc_generator.py index 42bcf8c..1cfcca7 100644 --- a/src/mcp_ltspice/asc_generator.py +++ b/src/mcp_ltspice/asc_generator.py @@ -1257,3 +1257,670 @@ def generate_h_bridge( sch.add_directive(".tran 5m", 80, 592) 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 diff --git a/src/mcp_ltspice/netlist.py b/src/mcp_ltspice/netlist.py index f0051db..1326510 100644 --- a/src/mcp_ltspice/netlist.py +++ b/src/mcp_ltspice/netlist.py @@ -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: """Convert a SPICE-style value string to a float. diff --git a/src/mcp_ltspice/server.py b/src/mcp_ltspice/server.py index 07881fb..a1bf5e2 100644 --- a/src/mcp_ltspice/server.py +++ b/src/mcp_ltspice/server.py @@ -18,8 +18,12 @@ from pathlib import Path import numpy as np from fastmcp import FastMCP +from fastmcp.prompts import Message from . import __version__ +from .asc_generator import ( + generate_boost_converter as generate_boost_converter_asc, +) from .asc_generator import ( generate_buck_converter as generate_buck_converter_asc, ) @@ -29,12 +33,18 @@ from .asc_generator import ( from .asc_generator import ( 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 ( generate_differential_amp as generate_diff_amp_asc, ) from .asc_generator import ( generate_h_bridge as generate_h_bridge_asc, ) +from .asc_generator import ( + generate_instrumentation_amp as generate_inamp_asc, +) from .asc_generator import ( generate_inverting_amp, ) @@ -47,6 +57,12 @@ from .asc_generator import ( from .asc_generator import ( 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 ( generate_voltage_divider as generate_voltage_divider_asc, ) @@ -63,23 +79,24 @@ from .config import ( from .diff import diff_schematics as _diff_schematics from .drc import run_drc as _run_drc from .log_parser import parse_log -from .models import ( - search_models as _search_models, -) -from .models import ( - search_subcircuits as _search_subcircuits, -) +from .models import search_models as _search_models +from .models import search_subcircuits as _search_subcircuits from .netlist import ( Netlist, + boost_converter, buck_converter, colpitts_oscillator, common_emitter_amplifier, + current_mirror, differential_amplifier, h_bridge, + instrumentation_amplifier, inverting_amplifier, ldo_regulator, non_inverting_amplifier, rc_lowpass, + sallen_key_lowpass, + transimpedance_amplifier, voltage_divider, ) from .noise_analysis import ( @@ -98,6 +115,7 @@ from .raw_parser import parse_raw_file from .runner import run_netlist, run_simulation from .schematic import modify_component_value, parse_schematic 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 .waveform_expr import WaveformCalculator 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 # ============================================================================ @@ -1194,6 +1600,34 @@ _ASC_TEMPLATES: dict[str, dict] = { "description": "H-bridge motor driver with 4 NMOS transistors", "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): - rc_lowpass, voltage_divider, inverting_amp, non_inverting_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: template: Template name (see list above) @@ -1565,6 +2001,60 @@ _TEMPLATES: dict[str, dict] = { "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} - colpitts_oscillator: params {ind, c1, c2, rb, rc, re, vcc, bjt_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. @@ -1899,6 +2394,49 @@ def resource_status() -> str: 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 # ============================================================================ @@ -1909,7 +2447,7 @@ def design_filter( filter_type: str = "lowpass", topology: str = "rc", cutoff_freq: str = "1kHz", -) -> str: +) -> list: """Guide through designing and simulating a filter circuit. Args: @@ -1917,7 +2455,7 @@ def design_filter( topology: rc (1st order), rlc (2nd order), or sallen-key (active) 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} - Cutoff frequency: {cutoff_freq} @@ -1934,11 +2472,11 @@ Tips: - For RC lowpass: f_c = 1/(2*pi*R*C) - For 2nd order: Q controls peaking, Butterworth Q=0.707 - Use search_spice_models to find op-amp models for active filters -""" +""")] @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. Args: @@ -1950,7 +2488,7 @@ def analyze_power_supply(schematic_path: str = "") -> str: 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} @@ -1969,11 +2507,11 @@ Key metrics to extract: - Ripple voltage (peak-to-peak on output) - Load transient response (settling time after step) - Efficiency (input power vs output power) -""" +""")] @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. Args: @@ -1985,7 +2523,7 @@ def debug_circuit(schematic_path: str = "") -> str: 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} @@ -2012,21 +2550,21 @@ Common issues: - Missing bias voltages or ground - Component values off by orders of magnitude - Wrong model (check with search_spice_models) -""" +""")] @mcp.prompt() def optimize_design( circuit_type: str = "filter", target_spec: str = "1kHz bandwidth", -) -> str: +) -> list: """Guide through optimizing a circuit to meet target specifications. Args: circuit_type: Type of circuit (filter, amplifier, regulator, oscillator) 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: 1. Start with a template: use list_templates to see available circuits @@ -2045,21 +2583,21 @@ Tips: - For filters: target bandwidth_hz metric - For amplifiers: target gain_db and phase_margin_deg - For regulators: target settling_time and peak_to_peak (ripple) -""" +""")] @mcp.prompt() def monte_carlo_analysis( circuit_description: str = "RC filter", n_runs: str = "100", -) -> str: +) -> list: """Guide through Monte Carlo tolerance analysis. Args: circuit_description: What circuit to analyze 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} Workflow: @@ -2087,19 +2625,19 @@ Tips: - Ceramic capacitors: 10-20% - Electrolytic capacitors: 20% - Inductors: 10-20% -""" +""")] @mcp.prompt() def circuit_from_scratch( description: str = "audio amplifier", -) -> str: +) -> list: """Guide through creating a complete circuit from scratch. Args: 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): 1. Use list_templates to see available circuit templates @@ -2129,7 +2667,81 @@ Verification workflow: 4. Run .ac analysis for frequency response 5. Run .tran analysis for time-domain behavior 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 +""")] # ============================================================================ diff --git a/src/mcp_ltspice/svg_plot.py b/src/mcp_ltspice/svg_plot.py new file mode 100644 index 0000000..e6de066 --- /dev/null +++ b/src/mcp_ltspice/svg_plot.py @@ -0,0 +1,533 @@ +"""Pure SVG waveform plot generation -- no matplotlib dependency. + +Generates complete 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'' + ) + + # 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'' + ) + label = _format_eng(tv) + lines.append( + f'{_svg_escape(label)}' + ) + + # 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'' + ) + if show_x_labels: + if log_x: + label = _format_freq(tv) + else: + label = _format_eng(tv) + lines.append( + f'' + f'{_svg_escape(label)}' + ) + + # 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'' + ) + + # Title + if title: + lines.append( + f'' + f'{_svg_escape(title)}' + ) + + # Axis labels + if ylabel: + mid_y = plot_y + plot_h / 2 + lines.append( + f'' + f'{_svg_escape(ylabel)}' + ) + + if xlabel and show_x_labels: + lines.append( + f'' + f'{_svg_escape(xlabel)}' + ) + + return "\n".join(lines) + + +def _wrap_svg(inner: str, width: int, height: int) -> str: + """Wrap inner SVG elements in a root tag with white background.""" + return ( + f'\n' + f'\n' + f'{inner}\n' + f'' + ) + + +# --------------------------------------------------------------------------- +# 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 ```` 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 ```` 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 ```` 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) diff --git a/tests/conftest.py b/tests/conftest.py index c52d71d..6731b95 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,7 +3,6 @@ All fixtures produce synthetic data -- no LTspice or Wine required. """ -import tempfile from pathlib import Path import numpy as np @@ -12,7 +11,6 @@ import pytest from mcp_ltspice.raw_parser import RawFile, Variable from mcp_ltspice.schematic import Component, Flag, Schematic, Text, Wire - # --------------------------------------------------------------------------- # Time-domain fixtures # --------------------------------------------------------------------------- @@ -326,3 +324,13 @@ def schematic_duplicate_names() -> Schematic: ] sch.texts = [Text(80, 296, ".tran 10m", type="spice")] 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 diff --git a/tests/test_asc_generator.py b/tests/test_asc_generator.py index 264f474..5382abd 100644 --- a/tests/test_asc_generator.py +++ b/tests/test_asc_generator.py @@ -3,9 +3,8 @@ import pytest from mcp_ltspice.asc_generator import ( - AscSchematic, - GRID, _PIN_OFFSETS, + AscSchematic, _rotate, generate_inverting_amp, generate_rc_lowpass, diff --git a/tests/test_diff.py b/tests/test_diff.py index d8cbe2f..d3b441b 100644 --- a/tests/test_diff.py +++ b/tests/test_diff.py @@ -1,12 +1,9 @@ """Tests for diff module: schematic comparison.""" -from pathlib import Path -import pytest from mcp_ltspice.diff import ( ComponentChange, - DirectiveChange, SchematicDiff, _diff_components, _diff_directives, diff --git a/tests/test_drc.py b/tests/test_drc.py index 4fc3638..5da97b3 100644 --- a/tests/test_drc.py +++ b/tests/test_drc.py @@ -1,9 +1,6 @@ """Tests for drc module: design rule checks on schematic objects.""" -import tempfile -from pathlib import Path -import pytest from mcp_ltspice.drc import ( DRCResult, @@ -13,7 +10,7 @@ from mcp_ltspice.drc import ( _check_ground, _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: diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..46737fa --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,218 @@ +"""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 + # Look for collector voltage + vcol_idx = None + for var in raw.variables: + if var.name.lower() == "time": + time_idx = var.index + elif "collector" in var.name.lower() or var.name.lower() == "v(collector)": + vcol_idx = var.index + + assert time_idx is not None + + if vcol_idx is None: + # Fall back to any voltage signal that isn't supply + for var in raw.variables: + if var.name.lower().startswith("v(") and var.name.lower() not in ( + "v(vcc)", + "v(time)", + ): + vcol_idx = var.index + break + + assert vcol_idx is not None, ( + f"No suitable signal found. 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 diff --git a/tests/test_new_templates.py b/tests/test_new_templates.py new file mode 100644 index 0000000..348b7ba --- /dev/null +++ b/tests/test_new_templates.py @@ -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 diff --git a/tests/test_raw_parser.py b/tests/test_raw_parser.py index ffdd47d..8856daf 100644 --- a/tests/test_raw_parser.py +++ b/tests/test_raw_parser.py @@ -3,7 +3,7 @@ import numpy as np import pytest -from mcp_ltspice.raw_parser import RawFile, Variable, _detect_run_boundaries +from mcp_ltspice.raw_parser import _detect_run_boundaries class TestDetectRunBoundaries: diff --git a/tests/test_stability.py b/tests/test_stability.py index 1d9679e..fd64b5c 100644 --- a/tests/test_stability.py +++ b/tests/test_stability.py @@ -1,7 +1,6 @@ """Tests for stability module: gain margin, phase margin from loop gain data.""" import numpy as np -import pytest from mcp_ltspice.stability import ( compute_gain_margin, diff --git a/tests/test_svg_plot.py b/tests/test_svg_plot.py new file mode 100644 index 0000000..da71942 --- /dev/null +++ b/tests/test_svg_plot.py @@ -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("" 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(" elements (mag + phase).""" + freq, mag_db, phase_deg = bode_data + svg = plot_bode(freq, mag_db, phase_deg) + assert svg.startswith("= 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("= 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 diff --git a/tests/test_touchstone.py b/tests/test_touchstone.py index 186c860..86a3120 100644 --- a/tests/test_touchstone.py +++ b/tests/test_touchstone.py @@ -1,13 +1,11 @@ """Tests for touchstone module: format conversion, parsing, S-parameter extraction.""" -import re from pathlib import Path import numpy as np import pytest from mcp_ltspice.touchstone import ( - TouchstoneData, _detect_ports, _to_complex, get_s_parameter, diff --git a/tests/test_waveform_expr.py b/tests/test_waveform_expr.py index db4fe1f..049c046 100644 --- a/tests/test_waveform_expr.py +++ b/tests/test_waveform_expr.py @@ -5,13 +5,11 @@ import pytest from mcp_ltspice.waveform_expr import ( WaveformCalculator, - _Token, _tokenize, _TokenType, evaluate_expression, ) - # --------------------------------------------------------------------------- # Tokenizer tests # --------------------------------------------------------------------------- From b16c20c2caadb9b81f7510c619e27386894c0afa Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Wed, 11 Feb 2026 06:01:30 -0700 Subject: [PATCH 2/2] Fix CE amp coupling cap routing and Colpitts test variable selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CE amplifier schematic: the input coupling cap CC_in was placed horizontally (R90) at y=336 — the same y as the RB1-to-base bias wire. Both cap pins sat on the wire, shorting the cap and allowing Vin's 0V DC to override the bias divider, putting Q1 in cutoff. Fix: move CC_in to vertical orientation (R0) above the base wire. Now pinA=(400,256) and pinB=(400,320) are off the y=336 bias path. The cap properly blocks DC while passing the 1kHz input signal. Result: V(out) swings 2.2Vpp (gain ≈ 110) instead of stuck at Vcc. Colpitts oscillator test: the schematic was actually working (V(out) pp=2.05V) but the test's fallback variable selection picked V(n001) (the Vcc rail, constant 12V) instead of V(out). Fix: look for V(out) first since the schematic labels the collector with "out". Integration tests: 4/4 pass, unit tests: 360/360 pass. --- src/mcp_ltspice/asc_generator.py | 75 +++++++++++++++----------------- tests/test_integration.py | 17 ++------ 2 files changed, 39 insertions(+), 53 deletions(-) diff --git a/src/mcp_ltspice/asc_generator.py b/src/mcp_ltspice/asc_generator.py index 1cfcca7..588655b 100644 --- a/src/mcp_ltspice/asc_generator.py +++ b/src/mcp_ltspice/asc_generator.py @@ -581,76 +581,70 @@ def generate_common_emitter_amp( qe = pin_position("npn", 2, 400, 288) # emitter # 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 # 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 - # RB1 from Vcc rail to base. Vertical, placed to the left of Q1. - # Want pinB at base Y (336). res at (240, 240): pinA=(256, 256), pinB=(256, 336) + # RB1 from Vcc rail to base. res at (240, 240): pinA=(256, 256), pinB=(256, 336) rb1_a = pin_position("res", 0, 240, 240) # (256, 256) rb1_b = pin_position("res", 1, 240, 240) # (256, 336) - # RB2 from base to GND. - # Want pinA at base Y (336). res at (240, 320): pinA=(256, 336), pinB=(256, 416) + # RB2 from base to GND. res at (240, 320): pinA=(256, 336), pinB=(256, 416) rb2_b = pin_position("res", 1, 240, 320) # (256, 416) - # CC_in (input coupling cap) horizontal, connecting input to base. - # cap R90: pinA=origin+(-16,0)→(origin_x, origin_y+16)... wait let me recalculate. - # cap R90: pinA offset (16,0) → rotate90 → (0, 16), pinB offset (16,64) → (-64, 16) - # At origin (368, 320): pinA = (368, 336), pinB = (304, 336) - # pinA at (368, 336) close to base (400, 336). pinB at (304, 336). - # Wire from pinA (368, 336) to base (400, 336). - ccin_a = pin_position("cap", 0, 368, 320, 90) # (368, 336) - ccin_b = pin_position("cap", 1, 368, 320, 90) # (304, 336) + # CC_in (input coupling cap) — VERTICAL (R0) above the base wire. + # cap R0 at (384, 256): pinA=(400, 256)=input side, pinB=(400, 320)=base side. + # Critically, neither pin sits on the horizontal RB1-to-base wire at y=336, + # so the coupling cap is NOT shorted by the bias wire. + ccin_a = pin_position("cap", 0, 384, 256) # (400, 256) = input + ccin_b = pin_position("cap", 1, 384, 256) # (400, 320) = base side - # CC_out (output coupling cap) to the right of collector. - # cap R90 at origin (560, 272): pinA = (560, 288), pinB = (496, 288) - # 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). + # CC_out (output coupling cap) horizontal R90 to the right of collector. + # cap R90 at (560, 272): pinA=(560, 288)=output, pinB=(496, 288)=collector side ccout_a = pin_position("cap", 0, 560, 272, 90) # (560, 288) ccout_b = pin_position("cap", 1, 560, 272, 90) # (496, 288) # 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_b = pin_position("cap", 1, 528, 384) # (544, 448) - # Vcc source at (80, 96): pin+=(80, 112), pin-=(80, 192) + # Vcc source at (80, 96): pin+=(80, 112), pin-=(80, 192) vcc_p = pin_position("voltage", 0, 80, 96) # (80, 112) vcc_n = pin_position("voltage", 1, 80, 96) # (80, 192) - # Vin source at (80, 288): pin+=(80, 304), pin-=(80, 384) + # Vin source at (80, 288): pin+=(80, 304), pin-=(80, 384) vin_p = pin_position("voltage", 0, 80, 288) # (80, 304) vin_n = pin_position("voltage", 1, 80, 288) # (80, 384) # === 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 - 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, rc_a[0], vcc_y) # (160,208) → (464,208) = RC top - 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(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, rc_a[0], vcc_y) # (160,208)→(464,208) = RC top + 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 - # RB1 bottom to base, RB2 top at same junction - sch.add_wire(rb1_b[0], rb1_b[1], qb[0], qb[1]) # RB1 bottom → base + # RB1 bottom → base (horizontal at y=336) + sch.add_wire(rb1_b[0], rb1_b[1], qb[0], qb[1]) - # CC_in to base - sch.add_wire(ccin_a[0], ccin_a[1], qb[0], qb[1]) # CC_in right → base + # CC_in base side → base junction (short vertical drop) + sch.add_wire(ccin_b[0], ccin_b[1], qb[0], qb[1]) # (400,320)→(400,336) - # Input: Vin+ to CC_in left - sch.add_wire(vin_p[0], vin_p[1], ccin_b[0], ccin_b[1]) # Vin+ → CC_in left + # Vin+ → CC_in input side via manhattan L-route + 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 - sch.add_wire(qc[0], qc[1], ccout_b[0], ccout_b[1]) # collector → CC_out left + # Collector → CC_out left pin + sch.add_wire(qc[0], qc[1], ccout_b[0], ccout_b[1]) - # Emitter to CE (bypass cap in parallel with RE) - sch.add_wire(qe[0], qe[1], ce_a[0], ce_a[1]) # emitter → CE top - # CE bottom to RE bottom (shared GND rail) - sch.add_wire(ce_b[0], ce_b[1], re_b[0], re_b[1]) # CE bot → RE bot + # Emitter → CE top (bypass cap in parallel with RE) + sch.add_wire(qe[0], qe[1], ce_a[0], ce_a[1]) + # CE bottom → GND (separate ground flag, no diagonal wire to RE) # === COMPONENTS === 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", "RB1", rb1, 240, 240) 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", "CE", ce, 528, 384) 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(*rb2_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_directive(".tran 5m", 80, 528) diff --git a/tests/test_integration.py b/tests/test_integration.py index 46737fa..fd5486a 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -180,28 +180,19 @@ class TestColpittsOscillator: raw = result.raw_data time_idx = None - # Look for collector voltage vcol_idx = None for var in raw.variables: if var.name.lower() == "time": time_idx = var.index - elif "collector" in var.name.lower() or var.name.lower() == "v(collector)": + 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 - if vcol_idx is None: - # Fall back to any voltage signal that isn't supply - for var in raw.variables: - if var.name.lower().startswith("v(") and var.name.lower() not in ( - "v(vcc)", - "v(time)", - ): - vcol_idx = var.index - break - assert vcol_idx is not None, ( - f"No suitable signal found. Variables: {[v.name for v in raw.variables]}" + f"No V(out) or collector signal. Variables: {[v.name for v in raw.variables]}" ) sig = np.real(raw.data[vcol_idx])