Merge phase6: SVG plots, tuning, templates, integration tests
This commit is contained in:
commit
0c545800f7
@ -50,6 +50,9 @@ packages = ["src/mcp_ltspice"]
|
|||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
asyncio_mode = "auto"
|
asyncio_mode = "auto"
|
||||||
|
markers = [
|
||||||
|
"integration: tests requiring LTspice/Wine installation (deselect with '-m not integration')",
|
||||||
|
]
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
line-length = 100
|
line-length = 100
|
||||||
@ -58,3 +61,6 @@ target-version = "py311"
|
|||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
select = ["E", "F", "I", "N", "W", "UP"]
|
select = ["E", "F", "I", "N", "W", "UP"]
|
||||||
ignore = ["E501"]
|
ignore = ["E501"]
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
"tests/**" = ["N806"] # Allow EE-conventional uppercase vars (K, Q, H, Vpk, etc.)
|
||||||
|
|||||||
@ -581,40 +581,34 @@ def generate_common_emitter_amp(
|
|||||||
qe = pin_position("npn", 2, 400, 288) # emitter
|
qe = pin_position("npn", 2, 400, 288) # emitter
|
||||||
|
|
||||||
# RC from Vcc rail to collector.
|
# RC from Vcc rail to collector.
|
||||||
# Want pinB at collector (464, 288). res at (448, 192): pinB=(464, 288). Good.
|
# res at (448, 192): pinA=(464, 208)=vcc rail, pinB=(464, 288)=collector
|
||||||
rc_a = pin_position("res", 0, 448, 192) # (464, 208) = vcc rail
|
rc_a = pin_position("res", 0, 448, 192) # (464, 208) = vcc rail
|
||||||
|
|
||||||
# RE from emitter to GND.
|
# RE from emitter to GND.
|
||||||
# Want pinA at emitter (464, 384). res at (448, 368): pinA=(464, 384).
|
# res at (448, 368): pinA=(464, 384)=emitter, pinB=(464, 464)=GND
|
||||||
re_b = pin_position("res", 1, 448, 368) # (464, 464) = GND
|
re_b = pin_position("res", 1, 448, 368) # (464, 464) = GND
|
||||||
|
|
||||||
# RB1 from Vcc rail to base. Vertical, placed to the left of Q1.
|
# RB1 from Vcc rail to base. res at (240, 240): pinA=(256, 256), pinB=(256, 336)
|
||||||
# Want pinB at base Y (336). res at (240, 240): pinA=(256, 256), pinB=(256, 336)
|
|
||||||
rb1_a = pin_position("res", 0, 240, 240) # (256, 256)
|
rb1_a = pin_position("res", 0, 240, 240) # (256, 256)
|
||||||
rb1_b = pin_position("res", 1, 240, 240) # (256, 336)
|
rb1_b = pin_position("res", 1, 240, 240) # (256, 336)
|
||||||
|
|
||||||
# RB2 from base to GND.
|
# RB2 from base to GND. res at (240, 320): pinA=(256, 336), pinB=(256, 416)
|
||||||
# Want pinA at base Y (336). res at (240, 320): pinA=(256, 336), pinB=(256, 416)
|
|
||||||
rb2_b = pin_position("res", 1, 240, 320) # (256, 416)
|
rb2_b = pin_position("res", 1, 240, 320) # (256, 416)
|
||||||
|
|
||||||
# CC_in (input coupling cap) horizontal, connecting input to base.
|
# CC_in (input coupling cap) — VERTICAL (R0) above the base wire.
|
||||||
# cap R90: pinA=origin+(-16,0)→(origin_x, origin_y+16)... wait let me recalculate.
|
# cap R0 at (384, 256): pinA=(400, 256)=input side, pinB=(400, 320)=base side.
|
||||||
# cap R90: pinA offset (16,0) → rotate90 → (0, 16), pinB offset (16,64) → (-64, 16)
|
# Critically, neither pin sits on the horizontal RB1-to-base wire at y=336,
|
||||||
# At origin (368, 320): pinA = (368, 336), pinB = (304, 336)
|
# so the coupling cap is NOT shorted by the bias wire.
|
||||||
# pinA at (368, 336) close to base (400, 336). pinB at (304, 336).
|
ccin_a = pin_position("cap", 0, 384, 256) # (400, 256) = input
|
||||||
# Wire from pinA (368, 336) to base (400, 336).
|
ccin_b = pin_position("cap", 1, 384, 256) # (400, 320) = base side
|
||||||
ccin_a = pin_position("cap", 0, 368, 320, 90) # (368, 336)
|
|
||||||
ccin_b = pin_position("cap", 1, 368, 320, 90) # (304, 336)
|
|
||||||
|
|
||||||
# CC_out (output coupling cap) to the right of collector.
|
# CC_out (output coupling cap) horizontal R90 to the right of collector.
|
||||||
# cap R90 at origin (560, 272): pinA = (560, 288), pinB = (496, 288)
|
# cap R90 at (560, 272): pinA=(560, 288)=output, pinB=(496, 288)=collector side
|
||||||
# pinB near collector (464, 288), pinA extends right to output
|
|
||||||
# Actually pinB = (560-64, 288) = (496, 288). Wire from collector (464,288) to (496,288).
|
|
||||||
ccout_a = pin_position("cap", 0, 560, 272, 90) # (560, 288)
|
ccout_a = pin_position("cap", 0, 560, 272, 90) # (560, 288)
|
||||||
ccout_b = pin_position("cap", 1, 560, 272, 90) # (496, 288)
|
ccout_b = pin_position("cap", 1, 560, 272, 90) # (496, 288)
|
||||||
|
|
||||||
# CE (emitter bypass cap) parallel to RE.
|
# CE (emitter bypass cap) parallel to RE.
|
||||||
# Place to the right of RE. cap at (528, 384): pinA=(544, 384), pinB=(544, 448)
|
# cap at (528, 384): pinA=(544, 384)=emitter, pinB=(544, 448)=GND
|
||||||
ce_a = pin_position("cap", 0, 528, 384) # (544, 384)
|
ce_a = pin_position("cap", 0, 528, 384) # (544, 384)
|
||||||
ce_b = pin_position("cap", 1, 528, 384) # (544, 448)
|
ce_b = pin_position("cap", 1, 528, 384) # (544, 448)
|
||||||
|
|
||||||
@ -627,7 +621,7 @@ def generate_common_emitter_amp(
|
|||||||
vin_n = pin_position("voltage", 1, 80, 288) # (80, 384)
|
vin_n = pin_position("voltage", 1, 80, 288) # (80, 384)
|
||||||
|
|
||||||
# === WIRING ===
|
# === WIRING ===
|
||||||
# Vcc rail at y=208 — route RIGHT from Vcc+ first (avoid crossing Vcc- at 80,192)
|
# Vcc rail at y=208 — route RIGHT from Vcc+ (avoid crossing Vcc- at 80,192)
|
||||||
vcc_y = rc_a[1] # 208
|
vcc_y = rc_a[1] # 208
|
||||||
sch.add_wire(vcc_p[0], vcc_p[1], 160, vcc_p[1]) # (80,112)→(160,112)
|
sch.add_wire(vcc_p[0], vcc_p[1], 160, vcc_p[1]) # (80,112)→(160,112)
|
||||||
sch.add_wire(160, vcc_p[1], 160, vcc_y) # (160,112)→(160,208)
|
sch.add_wire(160, vcc_p[1], 160, vcc_y) # (160,112)→(160,208)
|
||||||
@ -635,22 +629,22 @@ def generate_common_emitter_amp(
|
|||||||
sch.add_wire(rb1_a[0], rb1_a[1], rb1_a[0], vcc_y) # RB1 top up to rail
|
sch.add_wire(rb1_a[0], rb1_a[1], rb1_a[0], vcc_y) # RB1 top up to rail
|
||||||
sch.add_wire(rb1_a[0], vcc_y, rc_a[0], vcc_y) # connect to rail
|
sch.add_wire(rb1_a[0], vcc_y, rc_a[0], vcc_y) # connect to rail
|
||||||
|
|
||||||
# RB1 bottom to base, RB2 top at same junction
|
# RB1 bottom → base (horizontal at y=336)
|
||||||
sch.add_wire(rb1_b[0], rb1_b[1], qb[0], qb[1]) # RB1 bottom → base
|
sch.add_wire(rb1_b[0], rb1_b[1], qb[0], qb[1])
|
||||||
|
|
||||||
# CC_in to base
|
# CC_in base side → base junction (short vertical drop)
|
||||||
sch.add_wire(ccin_a[0], ccin_a[1], qb[0], qb[1]) # CC_in right → base
|
sch.add_wire(ccin_b[0], ccin_b[1], qb[0], qb[1]) # (400,320)→(400,336)
|
||||||
|
|
||||||
# Input: Vin+ to CC_in left
|
# Vin+ → CC_in input side via manhattan L-route
|
||||||
sch.add_wire(vin_p[0], vin_p[1], ccin_b[0], ccin_b[1]) # Vin+ → CC_in left
|
sch.add_wire(vin_p[0], vin_p[1], ccin_a[0], vin_p[1]) # (80,304)→(400,304)
|
||||||
|
sch.add_wire(ccin_a[0], vin_p[1], ccin_a[0], ccin_a[1]) # (400,304)→(400,256)
|
||||||
|
|
||||||
# Collector to CC_out
|
# Collector → CC_out left pin
|
||||||
sch.add_wire(qc[0], qc[1], ccout_b[0], ccout_b[1]) # collector → CC_out left
|
sch.add_wire(qc[0], qc[1], ccout_b[0], ccout_b[1])
|
||||||
|
|
||||||
# Emitter to CE (bypass cap in parallel with RE)
|
# Emitter → CE top (bypass cap in parallel with RE)
|
||||||
sch.add_wire(qe[0], qe[1], ce_a[0], ce_a[1]) # emitter → CE top
|
sch.add_wire(qe[0], qe[1], ce_a[0], ce_a[1])
|
||||||
# CE bottom to RE bottom (shared GND rail)
|
# CE bottom → GND (separate ground flag, no diagonal wire to RE)
|
||||||
sch.add_wire(ce_b[0], ce_b[1], re_b[0], re_b[1]) # CE bot → RE bot
|
|
||||||
|
|
||||||
# === COMPONENTS ===
|
# === COMPONENTS ===
|
||||||
sch.add_component("npn", "Q1", bjt_model, 400, 288)
|
sch.add_component("npn", "Q1", bjt_model, 400, 288)
|
||||||
@ -658,7 +652,7 @@ def generate_common_emitter_amp(
|
|||||||
sch.add_component("res", "RE", re, 448, 368)
|
sch.add_component("res", "RE", re, 448, 368)
|
||||||
sch.add_component("res", "RB1", rb1, 240, 240)
|
sch.add_component("res", "RB1", rb1, 240, 240)
|
||||||
sch.add_component("res", "RB2", rb2, 240, 320)
|
sch.add_component("res", "RB2", rb2, 240, 320)
|
||||||
sch.add_component("cap", "CC1", cc_in, 368, 320, rotation=90)
|
sch.add_component("cap", "CC1", cc_in, 384, 256)
|
||||||
sch.add_component("cap", "CC2", cc_out, 560, 272, rotation=90)
|
sch.add_component("cap", "CC2", cc_out, 560, 272, rotation=90)
|
||||||
sch.add_component("cap", "CE", ce, 528, 384)
|
sch.add_component("cap", "CE", ce, 528, 384)
|
||||||
sch.add_component("voltage", "Vcc", vcc, 80, 96)
|
sch.add_component("voltage", "Vcc", vcc, 80, 96)
|
||||||
@ -669,6 +663,7 @@ def generate_common_emitter_amp(
|
|||||||
sch.add_ground(*vin_n)
|
sch.add_ground(*vin_n)
|
||||||
sch.add_ground(*rb2_b)
|
sch.add_ground(*rb2_b)
|
||||||
sch.add_ground(*re_b)
|
sch.add_ground(*re_b)
|
||||||
|
sch.add_ground(*ce_b) # CE bottom to GND directly (no diagonal wire)
|
||||||
sch.add_net_label("out", *ccout_a)
|
sch.add_net_label("out", *ccout_a)
|
||||||
|
|
||||||
sch.add_directive(".tran 5m", 80, 528)
|
sch.add_directive(".tran 5m", 80, 528)
|
||||||
@ -1257,3 +1252,670 @@ def generate_h_bridge(
|
|||||||
sch.add_directive(".tran 5m", 80, 592)
|
sch.add_directive(".tran 5m", 80, 592)
|
||||||
|
|
||||||
return sch
|
return sch
|
||||||
|
|
||||||
|
|
||||||
|
def generate_sallen_key_lowpass(
|
||||||
|
r1: str = "10k",
|
||||||
|
r2: str = "10k",
|
||||||
|
c1: str = "10n",
|
||||||
|
c2: str = "10n",
|
||||||
|
) -> AscSchematic:
|
||||||
|
"""Generate a Sallen-Key lowpass filter schematic (unity gain).
|
||||||
|
|
||||||
|
Topology::
|
||||||
|
|
||||||
|
V1 --[R1]-- n1 --[R2]-- n2 (In+)
|
||||||
|
| --> U1 --> out
|
||||||
|
[C1]--> out In- --> out (unity gain)
|
||||||
|
|
|
||||||
|
[C2]
|
||||||
|
|
|
||||||
|
GND
|
||||||
|
|
||||||
|
The C1 connects n1 to out (feedback). C2 connects n2 to GND.
|
||||||
|
f_c = 1 / (2*pi*sqrt(R1*R2*C1*C2)). Supply: +/-15V.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
r1: First series resistor
|
||||||
|
r2: Second series resistor
|
||||||
|
c1: Feedback capacitor (n1 to out)
|
||||||
|
c2: Shunt capacitor (n2 to GND)
|
||||||
|
"""
|
||||||
|
sch = AscSchematic(sheet_w=1200, sheet_h=880)
|
||||||
|
oa_sym = "OpAmps/UniversalOpamp2"
|
||||||
|
|
||||||
|
# Op-amp U1 at (576, 336)
|
||||||
|
inp = pin_position(oa_sym, 0, 576, 336) # In+ = (544, 352)
|
||||||
|
inn = pin_position(oa_sym, 1, 576, 336) # In- = (544, 320)
|
||||||
|
vp = pin_position(oa_sym, 2, 576, 336) # V+ = (576, 304)
|
||||||
|
vn = pin_position(oa_sym, 3, 576, 336) # V- = (576, 368)
|
||||||
|
out = pin_position(oa_sym, 4, 576, 336) # OUT = (608, 336)
|
||||||
|
|
||||||
|
# V1 at (80, 256): pin+=(80,272), pin-=(80,352)
|
||||||
|
v1p = pin_position("voltage", 0, 80, 256)
|
||||||
|
v1n = pin_position("voltage", 1, 80, 256)
|
||||||
|
|
||||||
|
# R1 horizontal (R90) at origin (256, 256): pinA=(240,272), pinB=(160,272)
|
||||||
|
r1_a = pin_position("res", 0, 256, 256, 90) # (240, 272) = n1 side
|
||||||
|
r1_b = pin_position("res", 1, 256, 256, 90) # (160, 272) = input side
|
||||||
|
|
||||||
|
# R2 horizontal (R90) at origin (416, 256): pinA=(400,272), pinB=(320,272)
|
||||||
|
r2_a = pin_position("res", 0, 416, 256, 90) # (400, 272) = n2 side
|
||||||
|
r2_b = pin_position("res", 1, 416, 256, 90) # (320, 272) = n1 side
|
||||||
|
|
||||||
|
# C1 vertical: connects n1 (junction R1_a/R2_b at x=272-ish) to out
|
||||||
|
# We route n1 down to C1, then C1 bottom goes right to out.
|
||||||
|
# C1 at (304, 336): pinA=(320, 336), pinB=(320, 400)
|
||||||
|
# Actually let's place C1 horizontal (R90) above the feedback path.
|
||||||
|
# Better: C1 as a horizontal cap (R90) from n1 to out.
|
||||||
|
# cap R90: pinA offset (16,0)-> (0,16), pinB offset (16,64)->(-64,16)
|
||||||
|
# At origin (560, 192): pinA=(560,208), pinB=(496,208)
|
||||||
|
c1_a = pin_position("cap", 0, 560, 192, 90) # (560, 208) = out side
|
||||||
|
c1_b = pin_position("cap", 1, 560, 192, 90) # (496, 208) = n1 side
|
||||||
|
|
||||||
|
# C2 vertical at (480, 352): pinA=(496,352), pinB=(496,416)
|
||||||
|
c2_a = pin_position("cap", 0, 480, 352) # (496, 352)
|
||||||
|
c2_b = pin_position("cap", 1, 480, 352) # (496, 416)
|
||||||
|
|
||||||
|
# Supply: Vpos at (752, 176), Vneg at (752, 416)
|
||||||
|
vpos_p = pin_position("voltage", 0, 752, 176) # (752, 192)
|
||||||
|
vpos_n = pin_position("voltage", 1, 752, 176) # (752, 272)
|
||||||
|
vneg_p = pin_position("voltage", 0, 752, 416) # (752, 432)
|
||||||
|
vneg_n = pin_position("voltage", 1, 752, 416) # (752, 512)
|
||||||
|
|
||||||
|
# === WIRING ===
|
||||||
|
# V1+ to R1 pinB
|
||||||
|
sch.add_wire(*v1p, *r1_b)
|
||||||
|
|
||||||
|
# R1 pinA to R2 pinB (n1 junction at roughly x=272..320)
|
||||||
|
# n1 junction: R1_a at (240,272), R2_b at (320,272) -- wire between
|
||||||
|
sch.add_wire(*r1_a, *r2_b)
|
||||||
|
|
||||||
|
# R2 pinA to In+ : (400,272) down to (400,352) then right to (544,352)
|
||||||
|
sch.add_wire(r2_a[0], r2_a[1], r2_a[0], inp[1])
|
||||||
|
sch.add_wire(r2_a[0], inp[1], inp[0], inp[1])
|
||||||
|
|
||||||
|
# n2 junction to C2: from R2_a/In+ junction at (400,352) left to C2 pinA (496,352)
|
||||||
|
# Actually, n2 is at the In+ side. C2 from n2 to GND.
|
||||||
|
# n2 is where R2 meets In+. Let's tap off at (496,352) for C2.
|
||||||
|
sch.add_wire(r2_a[0], inp[1], c2_a[0], c2_a[1])
|
||||||
|
|
||||||
|
# C1 feedback: n1 to out via C1
|
||||||
|
# n1 is at the R1_a / R2_b junction. Wire from n1 up to C1 pinB.
|
||||||
|
# n1 at y=272, C1 pinB at (496, 208)
|
||||||
|
# Route from (240,272) up to (240,208) then right to (496,208)
|
||||||
|
sch.add_wire(r1_a[0], r1_a[1], r1_a[0], c1_b[1])
|
||||||
|
sch.add_wire(r1_a[0], c1_b[1], c1_b[0], c1_b[1])
|
||||||
|
|
||||||
|
# C1 pinA to out: (560,208) down to out (608,336)
|
||||||
|
sch.add_wire(c1_a[0], c1_a[1], out[0], c1_a[1])
|
||||||
|
sch.add_wire(out[0], c1_a[1], out[0], out[1])
|
||||||
|
|
||||||
|
# Unity gain feedback: out to In-
|
||||||
|
# (608,336) up to (608,320) ... In- is at (544,320)
|
||||||
|
# Route: out (608,336) left to (544,336) up... no.
|
||||||
|
# Actually wire from In- (544,320) right and down to out (608,336).
|
||||||
|
# Route: (544,320) right to (620,320), down to (620,336), left to (608,336)
|
||||||
|
# Simpler: go below. (608,336) to (608,400), left to (544,400), up to (544,320)
|
||||||
|
sch.add_wire(out[0], out[1], out[0], 400)
|
||||||
|
sch.add_wire(out[0], 400, inn[0], 400)
|
||||||
|
sch.add_wire(inn[0], 400, inn[0], inn[1])
|
||||||
|
|
||||||
|
# Supply wiring
|
||||||
|
sch.add_wire(vp[0], vp[1], vp[0], vpos_p[1])
|
||||||
|
sch.add_wire(vp[0], vpos_p[1], vpos_p[0], vpos_p[1])
|
||||||
|
sch.add_wire(vn[0], vn[1], vn[0], vneg_n[1])
|
||||||
|
sch.add_wire(vn[0], vneg_n[1], vneg_n[0], vneg_n[1])
|
||||||
|
|
||||||
|
# === COMPONENTS ===
|
||||||
|
sch.add_component("voltage", "V1", "AC 1", 80, 256)
|
||||||
|
sch.add_component(oa_sym, "U1", "", 576, 336)
|
||||||
|
sch.add_component("res", "R1", r1, 256, 256, rotation=90)
|
||||||
|
sch.add_component("res", "R2", r2, 416, 256, rotation=90)
|
||||||
|
sch.add_component("cap", "C1", c1, 560, 192, rotation=90)
|
||||||
|
sch.add_component("cap", "C2", c2, 480, 352)
|
||||||
|
sch.add_component("voltage", "Vpos", "15", 752, 176)
|
||||||
|
sch.add_component("voltage", "Vneg", "15", 752, 416)
|
||||||
|
|
||||||
|
# === FLAGS ===
|
||||||
|
sch.add_ground(*v1n)
|
||||||
|
sch.add_ground(*c2_b)
|
||||||
|
sch.add_ground(*vpos_n)
|
||||||
|
sch.add_ground(*vneg_p)
|
||||||
|
sch.add_net_label("out", *out)
|
||||||
|
sch.add_net_label("n1", r1_a[0], r1_a[1])
|
||||||
|
sch.add_net_label("n2", r2_a[0], r2_a[1])
|
||||||
|
|
||||||
|
sch.add_directive(".ac dec 100 1 1meg", 80, 560)
|
||||||
|
|
||||||
|
return sch
|
||||||
|
|
||||||
|
|
||||||
|
def generate_boost_converter(
|
||||||
|
ind: str = "10u",
|
||||||
|
c_out: str = "100u",
|
||||||
|
r_load: str = "50",
|
||||||
|
v_in: str = "5",
|
||||||
|
duty_cycle: float = 0.5,
|
||||||
|
freq: str = "100k",
|
||||||
|
) -> AscSchematic:
|
||||||
|
"""Generate a boost (step-up) converter schematic.
|
||||||
|
|
||||||
|
Topology::
|
||||||
|
|
||||||
|
Vin --[L1]--> sw_node --[D1]--> out
|
||||||
|
| |
|
||||||
|
[MOSFET] [Cout] [Rload]
|
||||||
|
D=sw | |
|
||||||
|
G=gate GND GND
|
||||||
|
S=GND
|
||||||
|
|
||||||
|
Gate driven by PULSE at switching frequency and duty cycle.
|
||||||
|
Vout_ideal = Vin / (1 - duty_cycle).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ind: Inductor value
|
||||||
|
c_out: Output capacitor
|
||||||
|
r_load: Load resistor
|
||||||
|
v_in: Input voltage
|
||||||
|
duty_cycle: Switching duty cycle (0.0-1.0)
|
||||||
|
freq: Switching frequency
|
||||||
|
"""
|
||||||
|
sch = AscSchematic(sheet_w=1040, sheet_h=680)
|
||||||
|
|
||||||
|
# Compute PULSE timing
|
||||||
|
freq_hz = _parse_spice_value(freq)
|
||||||
|
period = 1.0 / freq_hz
|
||||||
|
t_on = period * duty_cycle
|
||||||
|
t_rise = period * 0.01
|
||||||
|
t_fall = t_rise
|
||||||
|
pulse_val = (
|
||||||
|
f"PULSE(0 {v_in} 0 {t_rise:.4g} {t_fall:.4g} {t_on:.4g} {period:.4g})"
|
||||||
|
)
|
||||||
|
|
||||||
|
mosfet_model = "IRF540N"
|
||||||
|
diode_model = "1N5819"
|
||||||
|
|
||||||
|
# Vin at (80, 48): pin+=(80,64), pin-=(80,144)
|
||||||
|
vin_p = pin_position("voltage", 0, 80, 48)
|
||||||
|
vin_n = pin_position("voltage", 1, 80, 48)
|
||||||
|
|
||||||
|
# Vgate at (80, 256): pin+=(80,272), pin-=(80,352)
|
||||||
|
vg_p = pin_position("voltage", 0, 80, 256)
|
||||||
|
vg_n = pin_position("voltage", 1, 80, 256)
|
||||||
|
|
||||||
|
# L1 horizontal (R90) at (192, 48):
|
||||||
|
# ind R90: pinA offset (16,16)->(-16,16), pinB offset (16,96)->(-96,16)
|
||||||
|
# At (192, 48): pinA = (176, 64), pinB = (96, 64)
|
||||||
|
# Actually re-derive: R90 transform: (px,py) -> (-py, px)
|
||||||
|
# pinA raw = (16,16) -> R90 -> (-16, 16). origin(192,48) + (-16,16) = (176, 64)
|
||||||
|
# pinB raw = (16,96) -> R90 -> (-96, 16). origin(192,48) + (-96,16) = (96, 64)
|
||||||
|
# So pinB is on the Vin side, pinA on the sw side. Good.
|
||||||
|
l1_a = pin_position("ind", 0, 192, 48, 90) # (176, 64) = sw side
|
||||||
|
l1_b = pin_position("ind", 1, 192, 48, 90) # (96, 64) = vin side
|
||||||
|
|
||||||
|
# NMOS at (224, 112): D=(272,112)=sw, G=(224,192), S=(272,208)=GND
|
||||||
|
# Actually let's position so drain lines up with L1 pinA.
|
||||||
|
# L1_a = (176, 64). NMOS drain should be at (176, something).
|
||||||
|
# nmos D offset = (48, 0). So origin = (176-48, y) = (128, y).
|
||||||
|
# Want drain at y ~ 112 range. Put origin at (128, 112): D=(176,112).
|
||||||
|
# Then wire from L1_a (176,64) down to D (176,112).
|
||||||
|
md = pin_position("nmos", 0, 128, 112) # drain (176, 112)
|
||||||
|
mg = pin_position("nmos", 1, 128, 112) # gate (128, 192)
|
||||||
|
ms = pin_position("nmos", 2, 128, 112) # source (176, 208)
|
||||||
|
|
||||||
|
# Diode from sw_node to output.
|
||||||
|
# sw_node is at L1_a (176,64). Diode horizontal.
|
||||||
|
# diode R90: anode offset (16,0)->(0,16), cathode offset (16,64)->(-64,16)
|
||||||
|
# At origin (320, 48): anode=(320,64), cathode=(256,64)
|
||||||
|
# We want anode at sw side, cathode at out side. But R90 has cathode to left.
|
||||||
|
# Use R270 instead: anode (16,0)->R270->(0,-16), cathode (16,64)->R270->(64,-16)
|
||||||
|
# At origin (240, 80): anode=(240,64), cathode=(304,64)
|
||||||
|
# Wire from sw (176,64) right to anode (240,64). Cathode (304,64) = output side.
|
||||||
|
d_anode = pin_position("diode", 0, 240, 80, 270) # (240, 64)
|
||||||
|
d_cathode = pin_position("diode", 1, 240, 80, 270) # (304, 64)
|
||||||
|
|
||||||
|
# Wire from L1_a (176,64) to sw junction, then to diode anode (240,64)
|
||||||
|
# And from diode cathode (304,64) to out
|
||||||
|
|
||||||
|
# Cout at (384, 64): pinA=(400,64), pinB=(400,128)
|
||||||
|
cout_a = pin_position("cap", 0, 384, 64) # (400, 64)
|
||||||
|
cout_b = pin_position("cap", 1, 384, 64) # (400, 128)
|
||||||
|
|
||||||
|
# Rload at (464, 48): pinA=(480,64), pinB=(480,144)
|
||||||
|
rl_a = pin_position("res", 0, 464, 48) # (480, 64)
|
||||||
|
rl_b = pin_position("res", 1, 464, 48) # (480, 144)
|
||||||
|
|
||||||
|
# === WIRING ===
|
||||||
|
# Vin+ to L1 pinB
|
||||||
|
sch.add_wire(*vin_p, *l1_b)
|
||||||
|
|
||||||
|
# L1 pinA (sw) to MOSFET drain
|
||||||
|
sch.add_wire(l1_a[0], l1_a[1], md[0], md[1])
|
||||||
|
|
||||||
|
# L1 pinA (sw) to diode anode
|
||||||
|
sch.add_wire(l1_a[0], l1_a[1], d_anode[0], d_anode[1])
|
||||||
|
|
||||||
|
# Diode cathode to Cout and Rload (output rail)
|
||||||
|
sch.add_wire(d_cathode[0], d_cathode[1], cout_a[0], cout_a[1])
|
||||||
|
sch.add_wire(cout_a[0], cout_a[1], rl_a[0], rl_a[1])
|
||||||
|
|
||||||
|
# Gate drive: Vgate+ to MOSFET gate
|
||||||
|
sch.add_wire(vg_p[0], vg_p[1], mg[0], vg_p[1])
|
||||||
|
sch.add_wire(mg[0], vg_p[1], mg[0], mg[1])
|
||||||
|
|
||||||
|
# === COMPONENTS ===
|
||||||
|
sch.add_component("voltage", "Vin", v_in, 80, 48)
|
||||||
|
sch.add_component("voltage", "Vgate", pulse_val, 80, 256)
|
||||||
|
sch.add_component("ind", "L1", ind, 192, 48, rotation=90)
|
||||||
|
sch.add_component("nmos", "M1", mosfet_model, 128, 112)
|
||||||
|
sch.add_component("diode", "D1", diode_model, 240, 80, rotation=270)
|
||||||
|
sch.add_component("cap", "Cout", c_out, 384, 64)
|
||||||
|
sch.add_component("res", "Rload", r_load, 464, 48)
|
||||||
|
|
||||||
|
# === FLAGS ===
|
||||||
|
sch.add_ground(*vin_n)
|
||||||
|
sch.add_ground(*vg_n)
|
||||||
|
sch.add_ground(*ms)
|
||||||
|
sch.add_ground(*cout_b)
|
||||||
|
sch.add_ground(*rl_b)
|
||||||
|
sch.add_net_label("sw", l1_a[0], l1_a[1])
|
||||||
|
sch.add_net_label("out", d_cathode[0], d_cathode[1])
|
||||||
|
|
||||||
|
sch.add_directive(f".tran {period * 200:.4g}", 80, 420)
|
||||||
|
|
||||||
|
return sch
|
||||||
|
|
||||||
|
|
||||||
|
def generate_instrumentation_amp(
|
||||||
|
r1: str = "10k",
|
||||||
|
r2: str = "10k",
|
||||||
|
r3: str = "10k",
|
||||||
|
r_gain: str = "10k",
|
||||||
|
) -> AscSchematic:
|
||||||
|
"""Generate a 3-opamp instrumentation amplifier schematic.
|
||||||
|
|
||||||
|
Stage 1 (input buffers with gain)::
|
||||||
|
|
||||||
|
Vin+ --> In+(X1) --> out1
|
||||||
|
Vin- --> In+(X2) --> out2
|
||||||
|
R1 from out1 to In-(X1), R1b from out2 to In-(X2)
|
||||||
|
Rgain between In-(X1) and In-(X2)
|
||||||
|
|
||||||
|
Stage 2 (difference amp)::
|
||||||
|
|
||||||
|
out1 --[R2]--> In-(X3) --[R3]--> Vout
|
||||||
|
out2 --[R2b]--> In+(X3)
|
||||||
|
In+(X3) --[R3b]--> GND
|
||||||
|
|
||||||
|
Gain = (1 + 2*R1/Rgain) * (R3/R2). Supply: +/-15V.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
r1: Stage 1 feedback resistor
|
||||||
|
r2: Stage 2 input resistor
|
||||||
|
r3: Stage 2 feedback resistor
|
||||||
|
r_gain: Gain-setting resistor between X1 and X2 inverting inputs
|
||||||
|
"""
|
||||||
|
sch = AscSchematic(sheet_w=1600, sheet_h=1040)
|
||||||
|
oa_sym = "OpAmps/UniversalOpamp2"
|
||||||
|
|
||||||
|
# === STAGE 1 ===
|
||||||
|
# X1 at (480, 208)
|
||||||
|
x1_inp = pin_position(oa_sym, 0, 480, 208) # In+ = (448, 224)
|
||||||
|
x1_inn = pin_position(oa_sym, 1, 480, 208) # In- = (448, 192)
|
||||||
|
x1_vp = pin_position(oa_sym, 2, 480, 208) # V+ = (480, 176)
|
||||||
|
x1_vn = pin_position(oa_sym, 3, 480, 208) # V- = (480, 240)
|
||||||
|
x1_out = pin_position(oa_sym, 4, 480, 208) # OUT = (512, 208)
|
||||||
|
|
||||||
|
# X2 at (480, 480)
|
||||||
|
x2_inp = pin_position(oa_sym, 0, 480, 480) # In+ = (448, 496)
|
||||||
|
x2_inn = pin_position(oa_sym, 1, 480, 480) # In- = (448, 464)
|
||||||
|
x2_vp = pin_position(oa_sym, 2, 480, 480) # V+ = (480, 448)
|
||||||
|
x2_vn = pin_position(oa_sym, 3, 480, 480) # V- = (480, 512)
|
||||||
|
x2_out = pin_position(oa_sym, 4, 480, 480) # OUT = (512, 480)
|
||||||
|
|
||||||
|
# R1: feedback from X1 out to X1 In- (horizontal R90)
|
||||||
|
# Place at (576, 96): pinA=(560,112), pinB=(480,112)
|
||||||
|
# We want to connect out1 (512,208) up to (512,112) left to (560,112)=R1_a
|
||||||
|
# and R1_b (480,112) left to (448,112) down to In- (448,192)
|
||||||
|
r1_a = pin_position("res", 0, 576, 96, 90) # (560, 112)
|
||||||
|
r1_b = pin_position("res", 1, 576, 96, 90) # (480, 112)
|
||||||
|
|
||||||
|
# R1b: feedback from X2 out to X2 In- (horizontal R90)
|
||||||
|
# Place at (576, 544): pinA=(560,560), pinB=(480,560)
|
||||||
|
r1b_a = pin_position("res", 0, 576, 544, 90) # (560, 560)
|
||||||
|
r1b_b = pin_position("res", 1, 576, 544, 90) # (480, 560)
|
||||||
|
|
||||||
|
# Rgain: vertical between X1 In- junction and X2 In- junction
|
||||||
|
# X1 In- at y=192 area, X2 In- at y=464 area
|
||||||
|
# We'll connect to the R1/R1b feedback junctions.
|
||||||
|
# Place Rgain at (400, 208): pinA=(416,224), pinB=(416,304)
|
||||||
|
# Actually we need it to span from node_a (X1 In- junction) to node_b (X2 In- junction).
|
||||||
|
# The junctions are at the R1_b / R1b_b x positions.
|
||||||
|
# Let's use net labels for cleanliness: "node_a" at R1_b junction, "node_b" at R1b_b junction.
|
||||||
|
# Rgain vertical res at (336, 256): pinA=(352, 272), pinB=(352, 352)
|
||||||
|
rg_a = pin_position("res", 0, 336, 256) # (352, 272) = node_a side
|
||||||
|
rg_b = pin_position("res", 1, 336, 256) # (352, 352) = node_b side
|
||||||
|
|
||||||
|
# === STAGE 2 ===
|
||||||
|
# X3 at (880, 352)
|
||||||
|
x3_inp = pin_position(oa_sym, 0, 880, 352) # In+ = (848, 368)
|
||||||
|
x3_inn = pin_position(oa_sym, 1, 880, 352) # In- = (848, 336)
|
||||||
|
x3_vp = pin_position(oa_sym, 2, 880, 352) # V+ = (880, 320)
|
||||||
|
x3_vn = pin_position(oa_sym, 3, 880, 352) # V- = (880, 384)
|
||||||
|
x3_out = pin_position(oa_sym, 4, 880, 352) # OUT = (912, 352)
|
||||||
|
|
||||||
|
# R2: out1 to X3 In- (horizontal R90)
|
||||||
|
# Place at (768, 320): pinA=(752,336), pinB=(672,336)
|
||||||
|
r2_a = pin_position("res", 0, 768, 320, 90) # (752, 336) near X3 In-
|
||||||
|
r2_b = pin_position("res", 1, 768, 320, 90) # (672, 336) from out1
|
||||||
|
|
||||||
|
# R3: X3 In- to Vout (horizontal R90, above X3 for feedback)
|
||||||
|
# Place at (960, 240): pinA=(944, 256), pinB=(864, 256)
|
||||||
|
r3_a = pin_position("res", 0, 960, 240, 90) # (944, 256) near Vout
|
||||||
|
r3_b = pin_position("res", 1, 960, 240, 90) # (864, 256) near In-
|
||||||
|
|
||||||
|
# R2b: out2 to X3 In+ (horizontal R90)
|
||||||
|
# Place at (768, 352): pinA=(752,368), pinB=(672,368)
|
||||||
|
r2b_a = pin_position("res", 0, 768, 352, 90) # (752, 368) near X3 In+
|
||||||
|
r2b_b = pin_position("res", 1, 768, 352, 90) # (672, 368) from out2
|
||||||
|
|
||||||
|
# R3b: X3 In+ to GND (vertical)
|
||||||
|
# Place at (832, 416): pinA=(848,432), pinB=(848,512)
|
||||||
|
r3b_a = pin_position("res", 0, 832, 416) # (848, 432)
|
||||||
|
r3b_b = pin_position("res", 1, 832, 416) # (848, 512)
|
||||||
|
|
||||||
|
# Sources
|
||||||
|
# V1 at (80, 160): pin+=(80,176), pin-=(80,256)
|
||||||
|
v1p = pin_position("voltage", 0, 80, 160)
|
||||||
|
v1n = pin_position("voltage", 1, 80, 160)
|
||||||
|
|
||||||
|
# V2 at (80, 432): pin+=(80,448), pin-=(80,528)
|
||||||
|
v2p = pin_position("voltage", 0, 80, 432)
|
||||||
|
v2n = pin_position("voltage", 1, 80, 432)
|
||||||
|
|
||||||
|
# Supply: Vpos at (1056, 176), Vneg at (1056, 480)
|
||||||
|
vpos_p = pin_position("voltage", 0, 1056, 176)
|
||||||
|
vpos_n = pin_position("voltage", 1, 1056, 176)
|
||||||
|
vneg_p = pin_position("voltage", 0, 1056, 480)
|
||||||
|
vneg_n = pin_position("voltage", 1, 1056, 480)
|
||||||
|
|
||||||
|
# === WIRING ===
|
||||||
|
# V1+ to X1 In+
|
||||||
|
sch.add_wire(v1p[0], v1p[1], v1p[0], x1_inp[1])
|
||||||
|
sch.add_wire(v1p[0], x1_inp[1], x1_inp[0], x1_inp[1])
|
||||||
|
|
||||||
|
# V2+ to X2 In+
|
||||||
|
sch.add_wire(v2p[0], v2p[1], v2p[0], x2_inp[1])
|
||||||
|
sch.add_wire(v2p[0], x2_inp[1], x2_inp[0], x2_inp[1])
|
||||||
|
|
||||||
|
# R1 feedback: X1 out (512,208) up to (512,112), right to R1_a (560,112)
|
||||||
|
sch.add_wire(x1_out[0], x1_out[1], x1_out[0], r1_a[1])
|
||||||
|
sch.add_wire(x1_out[0], r1_a[1], r1_a[0], r1_a[1])
|
||||||
|
# R1_b (480,112) down to X1 In- (448,192): (480,112) left to (448,112), down to (448,192)
|
||||||
|
sch.add_wire(r1_b[0], r1_b[1], x1_inn[0], r1_b[1])
|
||||||
|
sch.add_wire(x1_inn[0], r1_b[1], x1_inn[0], x1_inn[1])
|
||||||
|
|
||||||
|
# R1b feedback: X2 out (512,480) down to (512,560), right to R1b_a (560,560)
|
||||||
|
sch.add_wire(x2_out[0], x2_out[1], x2_out[0], r1b_a[1])
|
||||||
|
sch.add_wire(x2_out[0], r1b_a[1], r1b_a[0], r1b_a[1])
|
||||||
|
# R1b_b (480,560) down to X2 In- (448,464): (480,560) left to (448,560), up to (448,464)
|
||||||
|
sch.add_wire(r1b_b[0], r1b_b[1], x2_inn[0], r1b_b[1])
|
||||||
|
sch.add_wire(x2_inn[0], r1b_b[1], x2_inn[0], x2_inn[1])
|
||||||
|
|
||||||
|
# Rgain: node_a (junction at X1 In-) to Rgain pinA, node_b (X2 In-) to Rgain pinB
|
||||||
|
# Route from X1 In- junction (448,192) left to (352,192), down to Rg_a (352,272)
|
||||||
|
sch.add_wire(x1_inn[0], x1_inn[1], rg_a[0], x1_inn[1])
|
||||||
|
sch.add_wire(rg_a[0], x1_inn[1], rg_a[0], rg_a[1])
|
||||||
|
# Route from X2 In- junction (448,464) left to (352,464), up to Rg_b (352,352)
|
||||||
|
sch.add_wire(x2_inn[0], x2_inn[1], rg_b[0], x2_inn[1])
|
||||||
|
sch.add_wire(rg_b[0], x2_inn[1], rg_b[0], rg_b[1])
|
||||||
|
|
||||||
|
# Stage 2 wiring
|
||||||
|
# out1 (512,208) to R2_b (672,336): route (512,208) right to (672,208), down to (672,336)
|
||||||
|
sch.add_wire(x1_out[0], x1_out[1], r2_b[0], x1_out[1])
|
||||||
|
sch.add_wire(r2_b[0], x1_out[1], r2_b[0], r2_b[1])
|
||||||
|
# R2_a (752,336) to X3 In- (848,336)
|
||||||
|
sch.add_wire(r2_a[0], r2_a[1], x3_inn[0], x3_inn[1])
|
||||||
|
|
||||||
|
# out2 (512,480) to R2b_b (672,368): route (512,480) right to (672,480), up to (672,368)
|
||||||
|
sch.add_wire(x2_out[0], x2_out[1], r2b_b[0], x2_out[1])
|
||||||
|
sch.add_wire(r2b_b[0], x2_out[1], r2b_b[0], r2b_b[1])
|
||||||
|
# R2b_a (752,368) to X3 In+ (848,368)
|
||||||
|
sch.add_wire(r2b_a[0], r2b_a[1], x3_inp[0], x3_inp[1])
|
||||||
|
|
||||||
|
# R3 feedback: X3 out (912,352) up to (912,256), left to R3_a (944,256)
|
||||||
|
sch.add_wire(x3_out[0], x3_out[1], x3_out[0], r3_a[1])
|
||||||
|
sch.add_wire(x3_out[0], r3_a[1], r3_a[0], r3_a[1])
|
||||||
|
# R3_b (864,256) down to X3 In- junction: (864,256) left to (848,256), down to (848,336)
|
||||||
|
sch.add_wire(r3_b[0], r3_b[1], x3_inn[0], r3_b[1])
|
||||||
|
sch.add_wire(x3_inn[0], r3_b[1], x3_inn[0], x3_inn[1])
|
||||||
|
|
||||||
|
# R3b: X3 In+ to GND via R3b
|
||||||
|
sch.add_wire(x3_inp[0], x3_inp[1], r3b_a[0], r3b_a[1])
|
||||||
|
|
||||||
|
# Supply wiring (use net labels for cleanliness with 3 opamps)
|
||||||
|
# Just wire X1, X2, X3 supply pins to net labels
|
||||||
|
|
||||||
|
# === COMPONENTS ===
|
||||||
|
sch.add_component("voltage", "V1", "AC 1", 80, 160)
|
||||||
|
sch.add_component("voltage", "V2", "0", 80, 432)
|
||||||
|
sch.add_component(oa_sym, "X1", "", 480, 208)
|
||||||
|
sch.add_component(oa_sym, "X2", "", 480, 480)
|
||||||
|
sch.add_component(oa_sym, "X3", "", 880, 352)
|
||||||
|
sch.add_component("res", "R1", r1, 576, 96, rotation=90)
|
||||||
|
sch.add_component("res", "R1b", r1, 576, 544, rotation=90)
|
||||||
|
sch.add_component("res", "Rgain", r_gain, 336, 256)
|
||||||
|
sch.add_component("res", "R2", r2, 768, 320, rotation=90)
|
||||||
|
sch.add_component("res", "R3", r3, 960, 240, rotation=90)
|
||||||
|
sch.add_component("res", "R2b", r2, 768, 352, rotation=90)
|
||||||
|
sch.add_component("res", "R3b", r3, 832, 416)
|
||||||
|
sch.add_component("voltage", "Vpos", "15", 1056, 176)
|
||||||
|
sch.add_component("voltage", "Vneg", "15", 1056, 480)
|
||||||
|
|
||||||
|
# === FLAGS ===
|
||||||
|
sch.add_ground(*v1n)
|
||||||
|
sch.add_ground(*v2n)
|
||||||
|
sch.add_ground(*r3b_b)
|
||||||
|
sch.add_ground(*vpos_n)
|
||||||
|
sch.add_ground(*vneg_p)
|
||||||
|
# Supply net labels for all three opamps
|
||||||
|
sch.add_net_label("vdd", *x1_vp)
|
||||||
|
sch.add_net_label("vdd", *x2_vp)
|
||||||
|
sch.add_net_label("vdd", *x3_vp)
|
||||||
|
sch.add_net_label("vdd", *vpos_p)
|
||||||
|
sch.add_net_label("vss", *x1_vn)
|
||||||
|
sch.add_net_label("vss", *x2_vn)
|
||||||
|
sch.add_net_label("vss", *x3_vn)
|
||||||
|
sch.add_net_label("vss", *vneg_n)
|
||||||
|
sch.add_net_label("vout", *x3_out)
|
||||||
|
|
||||||
|
sch.add_directive(".ac dec 100 1 1meg", 80, 640)
|
||||||
|
|
||||||
|
return sch
|
||||||
|
|
||||||
|
|
||||||
|
def generate_current_mirror(
|
||||||
|
r_ref: str = "10k",
|
||||||
|
r_load: str = "1k",
|
||||||
|
vcc: str = "12",
|
||||||
|
) -> AscSchematic:
|
||||||
|
"""Generate a BJT current mirror schematic.
|
||||||
|
|
||||||
|
Topology::
|
||||||
|
|
||||||
|
Vcc --[Rref]--> collector_Q1 = base_Q1 = base_Q2
|
||||||
|
emitter_Q1 = GND
|
||||||
|
Vcc --[Rload]--> collector_Q2
|
||||||
|
emitter_Q2 = GND
|
||||||
|
|
||||||
|
Q1 is diode-connected (collector tied to base).
|
||||||
|
I_ref ~ (Vcc - Vbe) / Rref, I_load ~ I_ref.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
r_ref: Reference resistor
|
||||||
|
r_load: Load resistor
|
||||||
|
vcc: Supply voltage
|
||||||
|
"""
|
||||||
|
sch = AscSchematic(sheet_w=880, sheet_h=680)
|
||||||
|
bjt_model = "2N2222"
|
||||||
|
|
||||||
|
# Q1 (NPN, diode-connected) at (256, 288): C=(320,288), B=(256,336), E=(320,384)
|
||||||
|
q1c = pin_position("npn", 0, 256, 288) # collector (320, 288)
|
||||||
|
q1b = pin_position("npn", 1, 256, 288) # base (256, 336)
|
||||||
|
q1e = pin_position("npn", 2, 256, 288) # emitter (320, 384)
|
||||||
|
|
||||||
|
# Q2 (NPN) at (448, 288): C=(512,288), B=(448,336), E=(512,384)
|
||||||
|
q2c = pin_position("npn", 0, 448, 288) # collector (512, 288)
|
||||||
|
q2b = pin_position("npn", 1, 448, 288) # base (448, 336)
|
||||||
|
q2e = pin_position("npn", 2, 448, 288) # emitter (512, 384)
|
||||||
|
|
||||||
|
# Rref from Vcc to Q1 collector. res at (304, 192): pinA=(320,208)=vcc, pinB=(320,288)=Q1c
|
||||||
|
rref_a = pin_position("res", 0, 304, 192) # (320, 208)
|
||||||
|
|
||||||
|
# Rload from Vcc to Q2 collector. res at (496, 192): pinA=(512,208)=vcc, pinB=(512,288)=Q2c
|
||||||
|
rload_a = pin_position("res", 0, 496, 192) # (512, 208)
|
||||||
|
|
||||||
|
# Vcc source at (80, 96): pin+=(80,112), pin-=(80,192)
|
||||||
|
vcc_p = pin_position("voltage", 0, 80, 96)
|
||||||
|
vcc_n = pin_position("voltage", 1, 80, 96)
|
||||||
|
|
||||||
|
# Vcc rail at y=208
|
||||||
|
vcc_y = rref_a[1] # 208
|
||||||
|
|
||||||
|
# === WIRING ===
|
||||||
|
# Vcc rail
|
||||||
|
sch.add_wire(vcc_p[0], vcc_p[1], 160, vcc_p[1]) # (80,112)->(160,112)
|
||||||
|
sch.add_wire(160, vcc_p[1], 160, vcc_y) # (160,112)->(160,208)
|
||||||
|
sch.add_wire(160, vcc_y, rload_a[0], vcc_y) # (160,208)->(512,208) spans both
|
||||||
|
|
||||||
|
# Diode connection: Q1 collector to Q1 base
|
||||||
|
# Q1c = (320,288), Q1b = (256,336)
|
||||||
|
# Route: (320,288) left to (256,288) down to (256,336)
|
||||||
|
sch.add_wire(q1c[0], q1c[1], q1b[0], q1c[1])
|
||||||
|
sch.add_wire(q1b[0], q1c[1], q1b[0], q1b[1])
|
||||||
|
|
||||||
|
# Base connection: Q1 base to Q2 base
|
||||||
|
sch.add_wire(q1b[0], q1b[1], q2b[0], q2b[1])
|
||||||
|
|
||||||
|
# === COMPONENTS ===
|
||||||
|
sch.add_component("npn", "Q1", bjt_model, 256, 288)
|
||||||
|
sch.add_component("npn", "Q2", bjt_model, 448, 288)
|
||||||
|
sch.add_component("res", "Rref", r_ref, 304, 192)
|
||||||
|
sch.add_component("res", "Rload", r_load, 496, 192)
|
||||||
|
sch.add_component("voltage", "Vcc", vcc, 80, 96)
|
||||||
|
|
||||||
|
# === FLAGS ===
|
||||||
|
sch.add_ground(*vcc_n)
|
||||||
|
sch.add_ground(*q1e)
|
||||||
|
sch.add_ground(*q2e)
|
||||||
|
sch.add_net_label("mirror", *q1b)
|
||||||
|
sch.add_net_label("out", *q2c)
|
||||||
|
|
||||||
|
sch.add_directive(".op", 80, 448)
|
||||||
|
sch.add_directive(".tran 1m", 80, 480)
|
||||||
|
|
||||||
|
return sch
|
||||||
|
|
||||||
|
|
||||||
|
def generate_transimpedance_amp(
|
||||||
|
rf: str = "100k",
|
||||||
|
cf: str = "1p",
|
||||||
|
i_source: str = "1u",
|
||||||
|
) -> AscSchematic:
|
||||||
|
"""Generate a transimpedance amplifier (TIA) schematic.
|
||||||
|
|
||||||
|
Topology::
|
||||||
|
|
||||||
|
I1 (current src, AC) --> In- (inv)
|
||||||
|
--> U1 --> out
|
||||||
|
GND --> In+ (noninv)
|
||||||
|
Rf from In- to out (feedback)
|
||||||
|
Cf from In- to out (parallel with Rf, for stability)
|
||||||
|
|
||||||
|
Vout = -I_in * Rf (at low frequencies).
|
||||||
|
Bandwidth limited by Cf. Supply: +/-15V.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
rf: Feedback resistor
|
||||||
|
cf: Feedback capacitor (for stability)
|
||||||
|
i_source: AC current source magnitude
|
||||||
|
"""
|
||||||
|
sch = AscSchematic(sheet_w=1200, sheet_h=880)
|
||||||
|
oa_sym = "OpAmps/UniversalOpamp2"
|
||||||
|
|
||||||
|
# Op-amp U1 at (512, 336)
|
||||||
|
inp = pin_position(oa_sym, 0, 512, 336) # In+ = (480, 352)
|
||||||
|
inn = pin_position(oa_sym, 1, 512, 336) # In- = (480, 320)
|
||||||
|
vp = pin_position(oa_sym, 2, 512, 336) # V+ = (512, 304)
|
||||||
|
vn = pin_position(oa_sym, 3, 512, 336) # V- = (512, 368)
|
||||||
|
out = pin_position(oa_sym, 4, 512, 336) # OUT = (544, 336)
|
||||||
|
|
||||||
|
# Current source I1 at (320, 224) (vertical, like voltage source)
|
||||||
|
# Using "voltage" pin positions since current source shares the same symbol geometry
|
||||||
|
# I1 at origin (320, 224): pin+ = (320, 240), pin- = (320, 320)
|
||||||
|
# Current flows from pin+ to pin- inside the source (conventional).
|
||||||
|
# We want current into In- node: connect pin+ to In- node, pin- to GND.
|
||||||
|
i1p = pin_position("voltage", 0, 320, 224) # (320, 240)
|
||||||
|
i1n = pin_position("voltage", 1, 320, 224) # (320, 320)
|
||||||
|
|
||||||
|
# Rf horizontal (R90) above op-amp for feedback
|
||||||
|
# At (560, 208): pinA=(544,224), pinB=(464,224)
|
||||||
|
rf_a = pin_position("res", 0, 560, 208, 90) # (544, 224) near out
|
||||||
|
rf_b = pin_position("res", 1, 560, 208, 90) # (464, 224) near In-
|
||||||
|
|
||||||
|
# Cf horizontal (R90) above Rf for parallel feedback
|
||||||
|
# At (560, 160): pinA=(544,176), pinB=(464,176)
|
||||||
|
# Actually cap R90: pinA offset (16,0)->(0,16), pinB offset (16,64)->(-64,16)
|
||||||
|
# At (560, 160): pinA=(560,176), pinB=(496,176)
|
||||||
|
cf_a = pin_position("cap", 0, 560, 160, 90) # (560, 176) near out
|
||||||
|
cf_b = pin_position("cap", 1, 560, 160, 90) # (496, 176) near In-
|
||||||
|
|
||||||
|
# Supply: Vpos at (688, 176), Vneg at (688, 416)
|
||||||
|
vpos_p = pin_position("voltage", 0, 688, 176)
|
||||||
|
vpos_n = pin_position("voltage", 1, 688, 176)
|
||||||
|
vneg_p = pin_position("voltage", 0, 688, 416)
|
||||||
|
vneg_n = pin_position("voltage", 1, 688, 416)
|
||||||
|
|
||||||
|
# === WIRING ===
|
||||||
|
# I1 pin+ (320,240) to In- node (480,320)
|
||||||
|
# Route: (320,240) right to (480,240), down to (480,320)
|
||||||
|
sch.add_wire(i1p[0], i1p[1], inn[0], i1p[1])
|
||||||
|
sch.add_wire(inn[0], i1p[1], inn[0], inn[1])
|
||||||
|
|
||||||
|
# Rf feedback: out up to Rf pinA, Rf pinB left and down to In- junction
|
||||||
|
sch.add_wire(out[0], out[1], rf_a[0], rf_a[1]) # (544,336)->(544,224)
|
||||||
|
sch.add_wire(rf_b[0], rf_b[1], rf_b[0], inn[1]) # (464,224)->(464,320)
|
||||||
|
sch.add_wire(rf_b[0], inn[1], inn[0], inn[1]) # (464,320)->(480,320)
|
||||||
|
|
||||||
|
# Cf feedback: parallel path
|
||||||
|
# Connect Cf pinA to Rf pinA column, Cf pinB to Rf pinB column
|
||||||
|
sch.add_wire(cf_a[0], cf_a[1], rf_a[0], cf_a[1]) # (560,176)->(544,176)
|
||||||
|
sch.add_wire(rf_a[0], cf_a[1], rf_a[0], rf_a[1]) # (544,176)->(544,224) vertical
|
||||||
|
sch.add_wire(cf_b[0], cf_b[1], rf_b[0], cf_b[1]) # (496,176)->(464,176)
|
||||||
|
sch.add_wire(rf_b[0], cf_b[1], rf_b[0], rf_b[1]) # (464,176)->(464,224) vertical
|
||||||
|
|
||||||
|
# Supply wiring
|
||||||
|
sch.add_wire(vp[0], vp[1], vp[0], vpos_p[1])
|
||||||
|
sch.add_wire(vp[0], vpos_p[1], vpos_p[0], vpos_p[1])
|
||||||
|
sch.add_wire(vn[0], vn[1], vn[0], vneg_n[1])
|
||||||
|
sch.add_wire(vn[0], vneg_n[1], vneg_n[0], vneg_n[1])
|
||||||
|
|
||||||
|
# === COMPONENTS ===
|
||||||
|
sch.add_component("voltage", "I1", f"AC {i_source}", 320, 224)
|
||||||
|
sch.add_component(oa_sym, "U1", "", 512, 336)
|
||||||
|
sch.add_component("res", "Rf", rf, 560, 208, rotation=90)
|
||||||
|
sch.add_component("cap", "Cf", cf, 560, 160, rotation=90)
|
||||||
|
sch.add_component("voltage", "Vpos", "15", 688, 176)
|
||||||
|
sch.add_component("voltage", "Vneg", "15", 688, 416)
|
||||||
|
|
||||||
|
# === FLAGS ===
|
||||||
|
sch.add_ground(*i1n)
|
||||||
|
sch.add_ground(*inp) # In+ to GND
|
||||||
|
sch.add_ground(*vpos_n)
|
||||||
|
sch.add_ground(*vneg_p)
|
||||||
|
sch.add_net_label("out", *out)
|
||||||
|
|
||||||
|
sch.add_directive(".ac dec 100 1 1meg", 80, 560)
|
||||||
|
|
||||||
|
return sch
|
||||||
|
|||||||
@ -638,6 +638,209 @@ def h_bridge(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sallen_key_lowpass(
|
||||||
|
r1: str = "10k",
|
||||||
|
r2: str = "10k",
|
||||||
|
c1: str = "10n",
|
||||||
|
c2: str = "10n",
|
||||||
|
opamp_model: str = "LT1001",
|
||||||
|
) -> Netlist:
|
||||||
|
"""Create a Sallen-Key lowpass filter (unity gain).
|
||||||
|
|
||||||
|
Topology:
|
||||||
|
in --[R1]--> n1 --[R2]--> n2
|
||||||
|
n2 --> opamp In+ (non-inverting)
|
||||||
|
opamp out --> In- (unity gain feedback)
|
||||||
|
C1 from n1 to out (feedback element)
|
||||||
|
C2 from n2 to GND
|
||||||
|
|
||||||
|
f_c = 1 / (2*pi*sqrt(R1*R2*C1*C2))
|
||||||
|
Supply: +/-15V
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
Netlist("Sallen-Key Lowpass Filter")
|
||||||
|
.add_comment(f"f_c = 1/(2*pi*sqrt({r1}*{r2}*{c1}*{c2}))")
|
||||||
|
.add_lib(opamp_model)
|
||||||
|
.add_voltage_source("V1", "in", "0", ac="1")
|
||||||
|
.add_voltage_source("Vpos", "vdd", "0", dc="15")
|
||||||
|
.add_voltage_source("Vneg", "0", "vss", dc="15")
|
||||||
|
.add_resistor("R1", "in", "n1", r1)
|
||||||
|
.add_resistor("R2", "n1", "n2", r2)
|
||||||
|
.add_capacitor("C1", "n1", "out", c1)
|
||||||
|
.add_capacitor("C2", "n2", "0", c2)
|
||||||
|
.add_opamp("X1", "n2", "out", "out", "vdd", "vss", opamp_model)
|
||||||
|
.add_directive(".ac dec 100 1 1meg")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def boost_converter(
|
||||||
|
ind: str = "10u",
|
||||||
|
c_out: str = "100u",
|
||||||
|
r_load: str = "50",
|
||||||
|
v_in: str = "5",
|
||||||
|
duty_cycle: float = 0.5,
|
||||||
|
freq: str = "100k",
|
||||||
|
mosfet_model: str = "IRF540N",
|
||||||
|
diode_model: str = "1N5819",
|
||||||
|
) -> Netlist:
|
||||||
|
"""Create a boost (step-up) converter.
|
||||||
|
|
||||||
|
Topology:
|
||||||
|
Vin --[L1]--> sw_node --[D1 (anode->cathode)]--> out
|
||||||
|
| |
|
||||||
|
[MOSFET] [Cout] [Rload]
|
||||||
|
drain=sw | |
|
||||||
|
gate=gate GND GND
|
||||||
|
source=GND
|
||||||
|
|
||||||
|
Vout_ideal = Vin / (1 - duty_cycle)
|
||||||
|
Gate driven by PULSE source at switching frequency and duty cycle.
|
||||||
|
"""
|
||||||
|
freq_hz = _parse_spice_value(freq)
|
||||||
|
period = 1.0 / freq_hz
|
||||||
|
t_on = period * duty_cycle
|
||||||
|
t_rise = period * 0.01
|
||||||
|
t_fall = t_rise
|
||||||
|
|
||||||
|
return (
|
||||||
|
Netlist("Boost Converter")
|
||||||
|
.add_comment(f"Duty cycle = {duty_cycle:.0%}, Fsw = {freq}")
|
||||||
|
.add_lib(mosfet_model)
|
||||||
|
.add_lib(diode_model)
|
||||||
|
.add_voltage_source("Vin", "vin", "0", dc=v_in)
|
||||||
|
.add_voltage_source(
|
||||||
|
"Vgate",
|
||||||
|
"gate",
|
||||||
|
"0",
|
||||||
|
pulse=(
|
||||||
|
"0",
|
||||||
|
v_in,
|
||||||
|
"0",
|
||||||
|
f"{t_rise:.4g}",
|
||||||
|
f"{t_fall:.4g}",
|
||||||
|
f"{t_on:.4g}",
|
||||||
|
f"{period:.4g}",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.add_inductor("L1", "vin", "sw", ind)
|
||||||
|
.add_mosfet("M1", "sw", "gate", "0", "0", mosfet_model)
|
||||||
|
.add_diode("D1", "sw", "out", diode_model)
|
||||||
|
.add_capacitor("Cout", "out", "0", c_out)
|
||||||
|
.add_resistor("Rload", "out", "0", r_load)
|
||||||
|
.add_directive(f".tran {period * 200:.4g}")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def instrumentation_amplifier(
|
||||||
|
r1: str = "10k",
|
||||||
|
r2: str = "10k",
|
||||||
|
r3: str = "10k",
|
||||||
|
r_gain: str = "10k",
|
||||||
|
) -> Netlist:
|
||||||
|
"""Create a classic 3-opamp instrumentation amplifier.
|
||||||
|
|
||||||
|
Stage 1 (input buffers with gain):
|
||||||
|
X1: In+ = Vin+, In- = node_a, Out = out1
|
||||||
|
X2: In+ = Vin-, In- = node_b, Out = out2
|
||||||
|
R1 from out1 to node_a (feedback)
|
||||||
|
R1_match from out2 to node_b (feedback)
|
||||||
|
Rgain between node_a and node_b
|
||||||
|
|
||||||
|
Stage 2 (difference amplifier):
|
||||||
|
X3: In- receives out1 via R2, feedback via R3 to Vout
|
||||||
|
In+ receives out2 via R2_match, R3_match to GND
|
||||||
|
|
||||||
|
Gain = (1 + 2*R1/Rgain) * (R3/R2)
|
||||||
|
All opamps use LT1001, supply +/-15V.
|
||||||
|
"""
|
||||||
|
opamp_model = "LT1001"
|
||||||
|
return (
|
||||||
|
Netlist("Instrumentation Amplifier")
|
||||||
|
.add_comment(f"Gain = (1 + 2*{r1}/{r_gain}) * ({r3}/{r2})")
|
||||||
|
.add_lib(opamp_model)
|
||||||
|
.add_voltage_source("V1", "vinp", "0", ac="1")
|
||||||
|
.add_voltage_source("V2", "vinn", "0", dc="0")
|
||||||
|
.add_voltage_source("Vpos", "vdd", "0", dc="15")
|
||||||
|
.add_voltage_source("Vneg", "0", "vss", dc="15")
|
||||||
|
# Stage 1: input buffers
|
||||||
|
.add_opamp("X1", "vinp", "node_a", "out1", "vdd", "vss", opamp_model)
|
||||||
|
.add_opamp("X2", "vinn", "node_b", "out2", "vdd", "vss", opamp_model)
|
||||||
|
.add_resistor("R1", "out1", "node_a", r1)
|
||||||
|
.add_resistor("R1b", "out2", "node_b", r1)
|
||||||
|
.add_resistor("Rgain", "node_a", "node_b", r_gain)
|
||||||
|
# Stage 2: difference amplifier
|
||||||
|
.add_opamp("X3", "noninv3", "inv3", "vout", "vdd", "vss", opamp_model)
|
||||||
|
.add_resistor("R2", "out1", "inv3", r2)
|
||||||
|
.add_resistor("R3", "inv3", "vout", r3)
|
||||||
|
.add_resistor("R2b", "out2", "noninv3", r2)
|
||||||
|
.add_resistor("R3b", "noninv3", "0", r3)
|
||||||
|
.add_directive(".ac dec 100 1 1meg")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def current_mirror(
|
||||||
|
r_ref: str = "10k",
|
||||||
|
r_load: str = "1k",
|
||||||
|
vcc: str = "12",
|
||||||
|
bjt_model: str = "2N2222",
|
||||||
|
) -> Netlist:
|
||||||
|
"""Create a basic BJT current mirror.
|
||||||
|
|
||||||
|
Topology:
|
||||||
|
Vcc --[Rref]--> collector_Q1 = base_Q1 = base_Q2
|
||||||
|
emitter_Q1 = GND
|
||||||
|
Vcc --[Rload]--> collector_Q2
|
||||||
|
emitter_Q2 = GND
|
||||||
|
|
||||||
|
Q1 is diode-connected (collector tied to base).
|
||||||
|
I_ref = (Vcc - Vbe) / Rref, I_load ~ I_ref.
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
Netlist("Current Mirror")
|
||||||
|
.add_comment(f"I_ref ~ ({vcc} - 0.7) / {r_ref}")
|
||||||
|
.add_lib(bjt_model)
|
||||||
|
.add_voltage_source("Vcc", "vcc", "0", dc=vcc)
|
||||||
|
.add_resistor("Rref", "vcc", "mirror", r_ref)
|
||||||
|
.add_resistor("Rload", "vcc", "out", r_load)
|
||||||
|
.add_bjt("Q1", "mirror", "mirror", "0", bjt_model)
|
||||||
|
.add_bjt("Q2", "out", "mirror", "0", bjt_model)
|
||||||
|
.add_directive(".op")
|
||||||
|
.add_directive(".tran 1m")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def transimpedance_amplifier(
|
||||||
|
rf: str = "100k",
|
||||||
|
cf: str = "1p",
|
||||||
|
i_source: str = "1u",
|
||||||
|
) -> Netlist:
|
||||||
|
"""Create a transimpedance amplifier (TIA).
|
||||||
|
|
||||||
|
Topology:
|
||||||
|
I1 (current source, AC) --> In- (inverting)
|
||||||
|
In+ (non-inverting) --> GND
|
||||||
|
Rf from In- to out (feedback resistor)
|
||||||
|
Cf from In- to out (feedback cap, parallel with Rf for stability)
|
||||||
|
|
||||||
|
Vout = -I_in * Rf (at low frequencies)
|
||||||
|
Bandwidth limited by Cf.
|
||||||
|
Supply: +/-15V, opamp_model = LT1001
|
||||||
|
"""
|
||||||
|
opamp_model = "LT1001"
|
||||||
|
return (
|
||||||
|
Netlist("Transimpedance Amplifier")
|
||||||
|
.add_comment(f"Vout = -I_in * {rf}")
|
||||||
|
.add_lib(opamp_model)
|
||||||
|
.add_current_source("I1", "inv", "0", ac=i_source)
|
||||||
|
.add_voltage_source("Vpos", "vdd", "0", dc="15")
|
||||||
|
.add_voltage_source("Vneg", "0", "vss", dc="15")
|
||||||
|
.add_resistor("Rf", "inv", "out", rf)
|
||||||
|
.add_capacitor("Cf", "inv", "out", cf)
|
||||||
|
.add_opamp("X1", "0", "inv", "out", "vdd", "vss", opamp_model)
|
||||||
|
.add_directive(".ac dec 100 1 1meg")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _parse_spice_value(value: str) -> float:
|
def _parse_spice_value(value: str) -> float:
|
||||||
"""Convert a SPICE-style value string to a float.
|
"""Convert a SPICE-style value string to a float.
|
||||||
|
|
||||||
|
|||||||
@ -18,8 +18,12 @@ from pathlib import Path
|
|||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from fastmcp import FastMCP
|
from fastmcp import FastMCP
|
||||||
|
from fastmcp.prompts import Message
|
||||||
|
|
||||||
from . import __version__
|
from . import __version__
|
||||||
|
from .asc_generator import (
|
||||||
|
generate_boost_converter as generate_boost_converter_asc,
|
||||||
|
)
|
||||||
from .asc_generator import (
|
from .asc_generator import (
|
||||||
generate_buck_converter as generate_buck_converter_asc,
|
generate_buck_converter as generate_buck_converter_asc,
|
||||||
)
|
)
|
||||||
@ -29,12 +33,18 @@ from .asc_generator import (
|
|||||||
from .asc_generator import (
|
from .asc_generator import (
|
||||||
generate_common_emitter_amp as generate_ce_amp_asc,
|
generate_common_emitter_amp as generate_ce_amp_asc,
|
||||||
)
|
)
|
||||||
|
from .asc_generator import (
|
||||||
|
generate_current_mirror as generate_current_mirror_asc,
|
||||||
|
)
|
||||||
from .asc_generator import (
|
from .asc_generator import (
|
||||||
generate_differential_amp as generate_diff_amp_asc,
|
generate_differential_amp as generate_diff_amp_asc,
|
||||||
)
|
)
|
||||||
from .asc_generator import (
|
from .asc_generator import (
|
||||||
generate_h_bridge as generate_h_bridge_asc,
|
generate_h_bridge as generate_h_bridge_asc,
|
||||||
)
|
)
|
||||||
|
from .asc_generator import (
|
||||||
|
generate_instrumentation_amp as generate_inamp_asc,
|
||||||
|
)
|
||||||
from .asc_generator import (
|
from .asc_generator import (
|
||||||
generate_inverting_amp,
|
generate_inverting_amp,
|
||||||
)
|
)
|
||||||
@ -47,6 +57,12 @@ from .asc_generator import (
|
|||||||
from .asc_generator import (
|
from .asc_generator import (
|
||||||
generate_rc_lowpass as generate_rc_lowpass_asc,
|
generate_rc_lowpass as generate_rc_lowpass_asc,
|
||||||
)
|
)
|
||||||
|
from .asc_generator import (
|
||||||
|
generate_sallen_key_lowpass as generate_sallen_key_asc,
|
||||||
|
)
|
||||||
|
from .asc_generator import (
|
||||||
|
generate_transimpedance_amp as generate_tia_asc,
|
||||||
|
)
|
||||||
from .asc_generator import (
|
from .asc_generator import (
|
||||||
generate_voltage_divider as generate_voltage_divider_asc,
|
generate_voltage_divider as generate_voltage_divider_asc,
|
||||||
)
|
)
|
||||||
@ -63,23 +79,24 @@ from .config import (
|
|||||||
from .diff import diff_schematics as _diff_schematics
|
from .diff import diff_schematics as _diff_schematics
|
||||||
from .drc import run_drc as _run_drc
|
from .drc import run_drc as _run_drc
|
||||||
from .log_parser import parse_log
|
from .log_parser import parse_log
|
||||||
from .models import (
|
from .models import search_models as _search_models
|
||||||
search_models as _search_models,
|
from .models import search_subcircuits as _search_subcircuits
|
||||||
)
|
|
||||||
from .models import (
|
|
||||||
search_subcircuits as _search_subcircuits,
|
|
||||||
)
|
|
||||||
from .netlist import (
|
from .netlist import (
|
||||||
Netlist,
|
Netlist,
|
||||||
|
boost_converter,
|
||||||
buck_converter,
|
buck_converter,
|
||||||
colpitts_oscillator,
|
colpitts_oscillator,
|
||||||
common_emitter_amplifier,
|
common_emitter_amplifier,
|
||||||
|
current_mirror,
|
||||||
differential_amplifier,
|
differential_amplifier,
|
||||||
h_bridge,
|
h_bridge,
|
||||||
|
instrumentation_amplifier,
|
||||||
inverting_amplifier,
|
inverting_amplifier,
|
||||||
ldo_regulator,
|
ldo_regulator,
|
||||||
non_inverting_amplifier,
|
non_inverting_amplifier,
|
||||||
rc_lowpass,
|
rc_lowpass,
|
||||||
|
sallen_key_lowpass,
|
||||||
|
transimpedance_amplifier,
|
||||||
voltage_divider,
|
voltage_divider,
|
||||||
)
|
)
|
||||||
from .noise_analysis import (
|
from .noise_analysis import (
|
||||||
@ -98,6 +115,7 @@ from .raw_parser import parse_raw_file
|
|||||||
from .runner import run_netlist, run_simulation
|
from .runner import run_netlist, run_simulation
|
||||||
from .schematic import modify_component_value, parse_schematic
|
from .schematic import modify_component_value, parse_schematic
|
||||||
from .stability import compute_stability_metrics
|
from .stability import compute_stability_metrics
|
||||||
|
from .svg_plot import plot_bode, plot_spectrum, plot_timeseries
|
||||||
from .touchstone import parse_touchstone, s_param_to_db
|
from .touchstone import parse_touchstone, s_param_to_db
|
||||||
from .waveform_expr import WaveformCalculator
|
from .waveform_expr import WaveformCalculator
|
||||||
from .waveform_math import (
|
from .waveform_math import (
|
||||||
@ -1014,6 +1032,394 @@ async def optimize_circuit(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# CIRCUIT TUNING TOOL
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def tune_circuit(
|
||||||
|
template: str,
|
||||||
|
params: dict[str, str] | None = None,
|
||||||
|
targets: dict[str, str] | None = None,
|
||||||
|
signal: str = "V(out)",
|
||||||
|
) -> dict:
|
||||||
|
"""Measure circuit performance and suggest parameter adjustments.
|
||||||
|
|
||||||
|
Single-shot workflow: generates a circuit from a template, simulates it,
|
||||||
|
measures key metrics, compares against targets, and suggests what to change.
|
||||||
|
Call this tool repeatedly with adjusted params until targets are met.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
template: Template name (from list_templates). Works with both
|
||||||
|
netlist templates (_TEMPLATES) and schematic templates (_ASC_TEMPLATES).
|
||||||
|
params: Component value overrides (e.g., {"r": "2.2k", "c": "47n"}).
|
||||||
|
Use list_templates to see available parameters.
|
||||||
|
targets: Performance targets to check against. Each key is a metric name,
|
||||||
|
value is a comparison string like ">5000" or "<0.1" or "~1000".
|
||||||
|
Supported metrics: bandwidth_hz, gain_db, rms, peak_to_peak,
|
||||||
|
settling_time_s, dc_value, fundamental_freq_hz.
|
||||||
|
Example: {"bandwidth_hz": ">5000", "gain_db": ">20"}
|
||||||
|
signal: Signal name to measure (default: "V(out)")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with metrics, target comparison, and tuning suggestions.
|
||||||
|
"""
|
||||||
|
# --- 1. Look up template ---
|
||||||
|
tmpl = _TEMPLATES.get(template)
|
||||||
|
is_netlist = tmpl is not None
|
||||||
|
if tmpl is None:
|
||||||
|
tmpl = _ASC_TEMPLATES.get(template)
|
||||||
|
if tmpl is None:
|
||||||
|
names = sorted(set(list(_TEMPLATES.keys()) + list(_ASC_TEMPLATES.keys())))
|
||||||
|
return {"error": f"Unknown template '{template}'. Available: {', '.join(names)}"}
|
||||||
|
|
||||||
|
# --- 2. Build effective params ---
|
||||||
|
effective_params: dict[str, str] = dict(tmpl["params"])
|
||||||
|
if params:
|
||||||
|
for k, v in params.items():
|
||||||
|
if k not in tmpl["params"]:
|
||||||
|
return {
|
||||||
|
"error": f"Unknown param '{k}' for {template}",
|
||||||
|
"valid_params": list(tmpl["params"].keys()),
|
||||||
|
}
|
||||||
|
effective_params[k] = v
|
||||||
|
|
||||||
|
# --- 3. Generate and simulate ---
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Build kwargs (handle duty_cycle float conversion)
|
||||||
|
call_kwargs: dict = {}
|
||||||
|
for k, v in effective_params.items():
|
||||||
|
if k == "duty_cycle":
|
||||||
|
call_kwargs[k] = float(v)
|
||||||
|
else:
|
||||||
|
call_kwargs[k] = v
|
||||||
|
|
||||||
|
if is_netlist:
|
||||||
|
nl = tmpl["func"](**call_kwargs)
|
||||||
|
out_path = Path(tempfile.gettempdir()) / f"tune_{template}.cir"
|
||||||
|
nl.save(out_path)
|
||||||
|
result = await run_netlist(out_path)
|
||||||
|
else:
|
||||||
|
sch = tmpl["func"](**call_kwargs)
|
||||||
|
out_path = Path(tempfile.gettempdir()) / f"tune_{template}.asc"
|
||||||
|
sch.save(out_path)
|
||||||
|
result = await run_simulation(out_path)
|
||||||
|
|
||||||
|
if not result.success:
|
||||||
|
return {
|
||||||
|
"error": "Simulation failed",
|
||||||
|
"detail": result.error or result.stderr,
|
||||||
|
"params_used": effective_params,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": f"Generation/simulation failed: {e}", "params_used": effective_params}
|
||||||
|
|
||||||
|
# --- 4. Extract waveform and compute metrics ---
|
||||||
|
raw = result.raw_data
|
||||||
|
if raw is None:
|
||||||
|
return {"error": "No raw data from simulation", "params_used": effective_params}
|
||||||
|
|
||||||
|
# Find the signal variable
|
||||||
|
sig_idx = None
|
||||||
|
time_idx = None
|
||||||
|
freq_idx = None
|
||||||
|
for var in raw.variables:
|
||||||
|
if var.name.lower() == signal.lower():
|
||||||
|
sig_idx = var.index
|
||||||
|
if var.name.lower() == "time":
|
||||||
|
time_idx = var.index
|
||||||
|
if var.name.lower() == "frequency":
|
||||||
|
freq_idx = var.index
|
||||||
|
|
||||||
|
if sig_idx is None:
|
||||||
|
available = [v.name for v in raw.variables]
|
||||||
|
return {
|
||||||
|
"error": f"Signal '{signal}' not found",
|
||||||
|
"available_signals": available,
|
||||||
|
"params_used": effective_params,
|
||||||
|
}
|
||||||
|
|
||||||
|
sig_data = raw.data[sig_idx]
|
||||||
|
metrics: dict[str, float] = {}
|
||||||
|
|
||||||
|
is_ac = freq_idx is not None
|
||||||
|
|
||||||
|
if is_ac:
|
||||||
|
# Frequency-domain metrics
|
||||||
|
freq = np.abs(raw.data[freq_idx]).real
|
||||||
|
mag_complex = sig_data
|
||||||
|
mag_db = 20.0 * np.log10(np.maximum(np.abs(mag_complex), 1e-30))
|
||||||
|
|
||||||
|
# Bandwidth
|
||||||
|
try:
|
||||||
|
bw_result = compute_bandwidth(freq, mag_db)
|
||||||
|
metrics["bandwidth_hz"] = bw_result["bandwidth_hz"]
|
||||||
|
if bw_result.get("f_low"):
|
||||||
|
metrics["f_low_hz"] = bw_result["f_low"]
|
||||||
|
if bw_result.get("f_high"):
|
||||||
|
metrics["f_high_hz"] = bw_result["f_high"]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# DC gain (magnitude at lowest frequency)
|
||||||
|
metrics["gain_db"] = float(mag_db[0])
|
||||||
|
metrics["dc_value"] = float(np.abs(mag_complex[0]))
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Time-domain metrics
|
||||||
|
time_data = raw.data[time_idx] if time_idx is not None else None
|
||||||
|
sig_real = np.real(sig_data)
|
||||||
|
|
||||||
|
# RMS
|
||||||
|
metrics["rms"] = float(compute_rms(sig_real))
|
||||||
|
|
||||||
|
# Peak-to-peak
|
||||||
|
pp = compute_peak_to_peak(sig_real)
|
||||||
|
metrics["peak_to_peak"] = pp["peak_to_peak"]
|
||||||
|
metrics["dc_value"] = pp["mean"]
|
||||||
|
|
||||||
|
# Settling time (if signal looks like a step response)
|
||||||
|
if time_data is not None:
|
||||||
|
time_real = np.real(time_data)
|
||||||
|
try:
|
||||||
|
settle = compute_settling_time(time_real, sig_real)
|
||||||
|
if settle["settled"]:
|
||||||
|
metrics["settling_time_s"] = settle["settling_time"]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# FFT for fundamental frequency
|
||||||
|
try:
|
||||||
|
fft = compute_fft(time_real, sig_real)
|
||||||
|
if fft["fundamental_freq"] > 0:
|
||||||
|
metrics["fundamental_freq_hz"] = fft["fundamental_freq"]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# --- 5. Compare against targets ---
|
||||||
|
targets_met = True
|
||||||
|
target_results: dict[str, dict] = {}
|
||||||
|
if targets:
|
||||||
|
for metric_name, target_str in targets.items():
|
||||||
|
if metric_name not in metrics:
|
||||||
|
target_results[metric_name] = {
|
||||||
|
"status": "unmeasurable",
|
||||||
|
"reason": f"Metric '{metric_name}' not available from this simulation",
|
||||||
|
}
|
||||||
|
targets_met = False
|
||||||
|
continue
|
||||||
|
|
||||||
|
actual = metrics[metric_name]
|
||||||
|
met = False
|
||||||
|
target_val = 0.0
|
||||||
|
|
||||||
|
if target_str.startswith(">"):
|
||||||
|
target_val = float(target_str[1:])
|
||||||
|
met = actual > target_val
|
||||||
|
elif target_str.startswith("<"):
|
||||||
|
target_val = float(target_str[1:])
|
||||||
|
met = actual < target_val
|
||||||
|
elif target_str.startswith("~"):
|
||||||
|
target_val = float(target_str[1:])
|
||||||
|
tolerance = target_val * 0.1 # 10% tolerance
|
||||||
|
met = abs(actual - target_val) <= tolerance
|
||||||
|
else:
|
||||||
|
target_val = float(target_str)
|
||||||
|
met = abs(actual - target_val) <= target_val * 0.1
|
||||||
|
|
||||||
|
target_results[metric_name] = {
|
||||||
|
"target": target_str,
|
||||||
|
"actual": actual,
|
||||||
|
"met": met,
|
||||||
|
}
|
||||||
|
if not met:
|
||||||
|
targets_met = False
|
||||||
|
|
||||||
|
# --- 6. Generate suggestions ---
|
||||||
|
suggestions: list[str] = []
|
||||||
|
if targets and not targets_met:
|
||||||
|
for metric_name, result_info in target_results.items():
|
||||||
|
if isinstance(result_info, dict) and not result_info.get("met", True):
|
||||||
|
actual = result_info.get("actual")
|
||||||
|
target_str_val = result_info.get("target", "")
|
||||||
|
if actual is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if metric_name == "bandwidth_hz":
|
||||||
|
if str(target_str_val).startswith(">") and actual < float(
|
||||||
|
str(target_str_val)[1:]
|
||||||
|
):
|
||||||
|
suggestions.append(
|
||||||
|
"To increase bandwidth: decrease R or C values "
|
||||||
|
"(f_c = 1/(2*pi*R*C) for RC filters)"
|
||||||
|
)
|
||||||
|
elif str(target_str_val).startswith("<"):
|
||||||
|
suggestions.append(
|
||||||
|
"To decrease bandwidth: increase R or C values"
|
||||||
|
)
|
||||||
|
|
||||||
|
elif metric_name == "gain_db":
|
||||||
|
if str(target_str_val).startswith(">") and actual < float(
|
||||||
|
str(target_str_val)[1:]
|
||||||
|
):
|
||||||
|
suggestions.append(
|
||||||
|
"To increase gain: increase Rf/Rin ratio for op-amp circuits, "
|
||||||
|
"or increase Rc/Re ratio for CE amplifiers"
|
||||||
|
)
|
||||||
|
elif str(target_str_val).startswith("<"):
|
||||||
|
suggestions.append("To decrease gain: decrease Rf/Rin ratio")
|
||||||
|
|
||||||
|
elif metric_name == "peak_to_peak":
|
||||||
|
if str(target_str_val).startswith("<"):
|
||||||
|
suggestions.append(
|
||||||
|
"To reduce peak-to-peak (ripple): increase filter capacitance "
|
||||||
|
"or inductance, or increase switching frequency"
|
||||||
|
)
|
||||||
|
|
||||||
|
elif metric_name == "settling_time_s":
|
||||||
|
if str(target_str_val).startswith("<"):
|
||||||
|
suggestions.append(
|
||||||
|
"To reduce settling time: increase bandwidth (decrease R*C), "
|
||||||
|
"or add damping to reduce ringing"
|
||||||
|
)
|
||||||
|
|
||||||
|
elif metric_name == "rms":
|
||||||
|
suggestions.append(
|
||||||
|
f"RMS is {actual:.4g}, target was {target_str_val}. "
|
||||||
|
"Adjust source amplitude or gain."
|
||||||
|
)
|
||||||
|
|
||||||
|
if not suggestions and not targets_met:
|
||||||
|
suggestions.append(
|
||||||
|
"Adjust component values toward the target. Use smaller steps for fine-tuning."
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"template": template,
|
||||||
|
"params_used": effective_params,
|
||||||
|
"metrics": metrics,
|
||||||
|
"targets": target_results if targets else {},
|
||||||
|
"targets_met": targets_met,
|
||||||
|
"suggestions": suggestions,
|
||||||
|
"signal": signal,
|
||||||
|
"analysis_type": "ac" if is_ac else "transient",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# WAVEFORM PLOTTING TOOL
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def plot_waveform(
|
||||||
|
raw_file: str,
|
||||||
|
signal: str = "V(out)",
|
||||||
|
plot_type: str = "auto",
|
||||||
|
output_path: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Generate an SVG plot from simulation results.
|
||||||
|
|
||||||
|
Parses a .raw file and creates a publication-quality SVG waveform plot.
|
||||||
|
Supports time-domain, Bode (frequency response), and FFT spectrum plots.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
raw_file: Path to the LTspice .raw binary file
|
||||||
|
signal: Signal name to plot (e.g. "V(out)", "I(R1)")
|
||||||
|
plot_type: "auto" (detect from data), "time", "bode", or "spectrum"
|
||||||
|
output_path: Where to save SVG file (None = auto in /tmp)
|
||||||
|
"""
|
||||||
|
raw_path = Path(raw_file)
|
||||||
|
if not raw_path.exists():
|
||||||
|
return {"error": f"Raw file not found: {raw_file}"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw_data = parse_raw_file(str(raw_path))
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": f"Failed to parse raw file: {e}"}
|
||||||
|
|
||||||
|
# Find the requested signal
|
||||||
|
sig_names = [v.name for v in raw_data.variables]
|
||||||
|
sig_lower = {s.lower(): s for s in sig_names}
|
||||||
|
actual_name = sig_lower.get(signal.lower())
|
||||||
|
if actual_name is None:
|
||||||
|
return {
|
||||||
|
"error": f"Signal '{signal}' not found",
|
||||||
|
"available_signals": sig_names,
|
||||||
|
}
|
||||||
|
|
||||||
|
sig_idx = next(i for i, v in enumerate(raw_data.variables) if v.name == actual_name)
|
||||||
|
values = raw_data.data[:, sig_idx]
|
||||||
|
|
||||||
|
# Get x-axis data (time or frequency)
|
||||||
|
x_var = raw_data.variables[0]
|
||||||
|
x_data = raw_data.data[:, 0]
|
||||||
|
is_freq = x_var.name.lower() == "frequency"
|
||||||
|
|
||||||
|
# Handle complex data (AC analysis produces complex values)
|
||||||
|
if np.iscomplexobj(values):
|
||||||
|
mag_db = 20.0 * np.log10(np.maximum(np.abs(values), 1e-30))
|
||||||
|
phase_deg = np.degrees(np.angle(values))
|
||||||
|
is_complex = True
|
||||||
|
else:
|
||||||
|
is_complex = False
|
||||||
|
mag_db = None
|
||||||
|
phase_deg = None
|
||||||
|
|
||||||
|
# Determine plot type
|
||||||
|
if plot_type == "auto":
|
||||||
|
if is_freq and is_complex:
|
||||||
|
plot_type = "bode"
|
||||||
|
elif is_freq:
|
||||||
|
plot_type = "spectrum"
|
||||||
|
else:
|
||||||
|
plot_type = "time"
|
||||||
|
|
||||||
|
# Generate SVG
|
||||||
|
if plot_type == "bode":
|
||||||
|
if mag_db is None:
|
||||||
|
mag_db = np.real(values)
|
||||||
|
phase_deg = None
|
||||||
|
freq = np.real(x_data)
|
||||||
|
svg = plot_bode(
|
||||||
|
freq=freq, mag_db=mag_db, phase_deg=phase_deg,
|
||||||
|
title=f"Bode Plot — {actual_name}",
|
||||||
|
)
|
||||||
|
elif plot_type == "spectrum":
|
||||||
|
freq = np.real(x_data)
|
||||||
|
if mag_db is None:
|
||||||
|
mag_db = 20.0 * np.log10(np.maximum(np.abs(np.real(values)), 1e-30))
|
||||||
|
svg = plot_spectrum(
|
||||||
|
freq=freq, mag_db=mag_db,
|
||||||
|
title=f"Spectrum — {actual_name}",
|
||||||
|
)
|
||||||
|
else: # time
|
||||||
|
svg = plot_timeseries(
|
||||||
|
time=np.real(x_data), values=np.real(values),
|
||||||
|
title=f"Time Domain — {actual_name}",
|
||||||
|
ylabel=actual_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save SVG
|
||||||
|
if output_path is None:
|
||||||
|
out = Path(tempfile.mktemp(suffix=".svg", prefix="ltspice_plot_"))
|
||||||
|
else:
|
||||||
|
out = Path(output_path)
|
||||||
|
out.write_text(svg)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"svg_path": str(out),
|
||||||
|
"plot_type": plot_type,
|
||||||
|
"signal": actual_name,
|
||||||
|
"points": len(x_data),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# BATCH SIMULATION TOOLS
|
# BATCH SIMULATION TOOLS
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@ -1194,6 +1600,34 @@ _ASC_TEMPLATES: dict[str, dict] = {
|
|||||||
"description": "H-bridge motor driver with 4 NMOS transistors",
|
"description": "H-bridge motor driver with 4 NMOS transistors",
|
||||||
"params": {"v_supply": "12", "r_load": "10", "mosfet_model": "IRF540N"},
|
"params": {"v_supply": "12", "r_load": "10", "mosfet_model": "IRF540N"},
|
||||||
},
|
},
|
||||||
|
"sallen_key_lowpass": {
|
||||||
|
"func": generate_sallen_key_asc,
|
||||||
|
"description": "Sallen-Key lowpass filter (unity gain, 2nd order)",
|
||||||
|
"params": {"r1": "10k", "r2": "10k", "c1": "10n", "c2": "10n"},
|
||||||
|
},
|
||||||
|
"boost_converter": {
|
||||||
|
"func": generate_boost_converter_asc,
|
||||||
|
"description": "Boost (step-up) converter with NMOS switch",
|
||||||
|
"params": {
|
||||||
|
"ind": "10u", "c_out": "100u", "r_load": "50",
|
||||||
|
"v_in": "5", "duty_cycle": "0.5", "freq": "100k",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"instrumentation_amp": {
|
||||||
|
"func": generate_inamp_asc,
|
||||||
|
"description": "3-opamp instrumentation amplifier",
|
||||||
|
"params": {"r1": "10k", "r2": "10k", "r3": "10k", "r_gain": "10k"},
|
||||||
|
},
|
||||||
|
"current_mirror": {
|
||||||
|
"func": generate_current_mirror_asc,
|
||||||
|
"description": "BJT current mirror with reference and load",
|
||||||
|
"params": {"r_ref": "10k", "r_load": "1k", "vcc": "12"},
|
||||||
|
},
|
||||||
|
"transimpedance_amp": {
|
||||||
|
"func": generate_tia_asc,
|
||||||
|
"description": "Transimpedance amplifier (current to voltage)",
|
||||||
|
"params": {"rf": "100k", "cf": "1p", "i_source": "1u"},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -1211,7 +1645,9 @@ def generate_schematic(
|
|||||||
Available templates (use list_templates for full details):
|
Available templates (use list_templates for full details):
|
||||||
- rc_lowpass, voltage_divider, inverting_amp, non_inverting_amp,
|
- rc_lowpass, voltage_divider, inverting_amp, non_inverting_amp,
|
||||||
common_emitter_amp, colpitts_oscillator, differential_amp,
|
common_emitter_amp, colpitts_oscillator, differential_amp,
|
||||||
buck_converter, ldo_regulator, h_bridge
|
buck_converter, ldo_regulator, h_bridge,
|
||||||
|
sallen_key_lowpass, boost_converter, instrumentation_amp,
|
||||||
|
current_mirror, transimpedance_amp
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
template: Template name (see list above)
|
template: Template name (see list above)
|
||||||
@ -1565,6 +2001,60 @@ _TEMPLATES: dict[str, dict] = {
|
|||||||
"mosfet_model": "IRF540N",
|
"mosfet_model": "IRF540N",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"sallen_key_lowpass": {
|
||||||
|
"func": sallen_key_lowpass,
|
||||||
|
"description": "Sallen-Key lowpass filter (unity gain, 2nd order)",
|
||||||
|
"params": {
|
||||||
|
"r1": "10k",
|
||||||
|
"r2": "10k",
|
||||||
|
"c1": "10n",
|
||||||
|
"c2": "10n",
|
||||||
|
"opamp_model": "LT1001",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"boost_converter": {
|
||||||
|
"func": boost_converter,
|
||||||
|
"description": "Step-up DC-DC converter with MOSFET switch",
|
||||||
|
"params": {
|
||||||
|
"ind": "10u",
|
||||||
|
"c_out": "100u",
|
||||||
|
"r_load": "50",
|
||||||
|
"v_in": "5",
|
||||||
|
"duty_cycle": "0.5",
|
||||||
|
"freq": "100k",
|
||||||
|
"mosfet_model": "IRF540N",
|
||||||
|
"diode_model": "1N5819",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"instrumentation_amplifier": {
|
||||||
|
"func": instrumentation_amplifier,
|
||||||
|
"description": "3-opamp instrumentation amp: gain = 1 + 2*R1/R_gain",
|
||||||
|
"params": {
|
||||||
|
"r1": "10k",
|
||||||
|
"r2": "10k",
|
||||||
|
"r3": "10k",
|
||||||
|
"r_gain": "10k",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"current_mirror": {
|
||||||
|
"func": current_mirror,
|
||||||
|
"description": "BJT current mirror with reference resistor",
|
||||||
|
"params": {
|
||||||
|
"r_ref": "10k",
|
||||||
|
"r_load": "1k",
|
||||||
|
"vcc": "12",
|
||||||
|
"bjt_model": "2N2222",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"transimpedance_amplifier": {
|
||||||
|
"func": transimpedance_amplifier,
|
||||||
|
"description": "TIA: converts input current to output voltage (Vout = -Iph * Rf)",
|
||||||
|
"params": {
|
||||||
|
"rf": "100k",
|
||||||
|
"cf": "1p",
|
||||||
|
"i_source": "1u",
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -1587,6 +2077,11 @@ def create_from_template(
|
|||||||
- ldo_regulator: params {opamp_model, r1, r2, pass_transistor, v_in, v_ref}
|
- ldo_regulator: params {opamp_model, r1, r2, pass_transistor, v_in, v_ref}
|
||||||
- colpitts_oscillator: params {ind, c1, c2, rb, rc, re, vcc, bjt_model}
|
- colpitts_oscillator: params {ind, c1, c2, rb, rc, re, vcc, bjt_model}
|
||||||
- h_bridge: params {v_supply, r_load, mosfet_model}
|
- h_bridge: params {v_supply, r_load, mosfet_model}
|
||||||
|
- sallen_key_lowpass: params {r1, r2, c1, c2, opamp_model}
|
||||||
|
- boost_converter: params {ind, c_out, r_load, v_in, duty_cycle, freq, mosfet_model, diode_model}
|
||||||
|
- instrumentation_amplifier: params {r1, r2, r3, r_gain}
|
||||||
|
- current_mirror: params {r_ref, r_load, vcc, bjt_model}
|
||||||
|
- transimpedance_amplifier: params {rf, cf, i_source}
|
||||||
|
|
||||||
All parameter values are optional -- defaults are used if omitted.
|
All parameter values are optional -- defaults are used if omitted.
|
||||||
|
|
||||||
@ -1899,6 +2394,49 @@ def resource_status() -> str:
|
|||||||
return json.dumps(check_installation(), indent=2)
|
return json.dumps(check_installation(), indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.resource("ltspice://templates")
|
||||||
|
def resource_templates() -> str:
|
||||||
|
"""All available circuit templates (netlist and schematic) with parameters."""
|
||||||
|
templates = {
|
||||||
|
"netlist_templates": [
|
||||||
|
{"name": name, "description": info["description"], "params": info["params"]}
|
||||||
|
for name, info in _TEMPLATES.items()
|
||||||
|
],
|
||||||
|
"schematic_templates": [
|
||||||
|
{"name": name, "description": info["description"], "params": info["params"]}
|
||||||
|
for name, info in _ASC_TEMPLATES.items()
|
||||||
|
],
|
||||||
|
}
|
||||||
|
return json.dumps(templates, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.resource("ltspice://template/{name}")
|
||||||
|
def resource_template_detail(name: str) -> str:
|
||||||
|
"""Detailed information about a specific circuit template."""
|
||||||
|
# Check both registries
|
||||||
|
netlist_info = _TEMPLATES.get(name)
|
||||||
|
asc_info = _ASC_TEMPLATES.get(name)
|
||||||
|
|
||||||
|
if not netlist_info and not asc_info:
|
||||||
|
return json.dumps({"error": f"Template '{name}' not found"})
|
||||||
|
|
||||||
|
result = {"name": name}
|
||||||
|
if netlist_info:
|
||||||
|
result["netlist"] = {
|
||||||
|
"description": netlist_info["description"],
|
||||||
|
"params": netlist_info["params"],
|
||||||
|
"type": "netlist (.cir)",
|
||||||
|
}
|
||||||
|
if asc_info:
|
||||||
|
result["schematic"] = {
|
||||||
|
"description": asc_info["description"],
|
||||||
|
"params": asc_info["params"],
|
||||||
|
"type": "schematic (.asc)",
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.dumps(result, indent=2)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# PROMPTS
|
# PROMPTS
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@ -1909,7 +2447,7 @@ def design_filter(
|
|||||||
filter_type: str = "lowpass",
|
filter_type: str = "lowpass",
|
||||||
topology: str = "rc",
|
topology: str = "rc",
|
||||||
cutoff_freq: str = "1kHz",
|
cutoff_freq: str = "1kHz",
|
||||||
) -> str:
|
) -> list:
|
||||||
"""Guide through designing and simulating a filter circuit.
|
"""Guide through designing and simulating a filter circuit.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@ -1917,7 +2455,7 @@ def design_filter(
|
|||||||
topology: rc (1st order), rlc (2nd order), or sallen-key (active)
|
topology: rc (1st order), rlc (2nd order), or sallen-key (active)
|
||||||
cutoff_freq: Target cutoff frequency with units
|
cutoff_freq: Target cutoff frequency with units
|
||||||
"""
|
"""
|
||||||
return f"""Design a {filter_type} filter with these requirements:
|
return [Message(role="user", content=f"""Design a {filter_type} filter with these requirements:
|
||||||
- Topology: {topology}
|
- Topology: {topology}
|
||||||
- Cutoff frequency: {cutoff_freq}
|
- Cutoff frequency: {cutoff_freq}
|
||||||
|
|
||||||
@ -1934,11 +2472,11 @@ Tips:
|
|||||||
- For RC lowpass: f_c = 1/(2*pi*R*C)
|
- For RC lowpass: f_c = 1/(2*pi*R*C)
|
||||||
- For 2nd order: Q controls peaking, Butterworth Q=0.707
|
- For 2nd order: Q controls peaking, Butterworth Q=0.707
|
||||||
- Use search_spice_models to find op-amp models for active filters
|
- Use search_spice_models to find op-amp models for active filters
|
||||||
"""
|
""")]
|
||||||
|
|
||||||
|
|
||||||
@mcp.prompt()
|
@mcp.prompt()
|
||||||
def analyze_power_supply(schematic_path: str = "") -> str:
|
def analyze_power_supply(schematic_path: str = "") -> list:
|
||||||
"""Guide through analyzing a power supply circuit.
|
"""Guide through analyzing a power supply circuit.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@ -1950,7 +2488,7 @@ def analyze_power_supply(schematic_path: str = "") -> str:
|
|||||||
else "First, identify or create the power supply schematic."
|
else "First, identify or create the power supply schematic."
|
||||||
)
|
)
|
||||||
|
|
||||||
return f"""Analyze a power supply circuit for key performance metrics.
|
return [Message(role="user", content=f"""Analyze a power supply circuit for key performance metrics.
|
||||||
|
|
||||||
{path_instruction}
|
{path_instruction}
|
||||||
|
|
||||||
@ -1969,11 +2507,11 @@ Key metrics to extract:
|
|||||||
- Ripple voltage (peak-to-peak on output)
|
- Ripple voltage (peak-to-peak on output)
|
||||||
- Load transient response (settling time after step)
|
- Load transient response (settling time after step)
|
||||||
- Efficiency (input power vs output power)
|
- Efficiency (input power vs output power)
|
||||||
"""
|
""")]
|
||||||
|
|
||||||
|
|
||||||
@mcp.prompt()
|
@mcp.prompt()
|
||||||
def debug_circuit(schematic_path: str = "") -> str:
|
def debug_circuit(schematic_path: str = "") -> list:
|
||||||
"""Guide through debugging a circuit that isn't working.
|
"""Guide through debugging a circuit that isn't working.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@ -1985,7 +2523,7 @@ def debug_circuit(schematic_path: str = "") -> str:
|
|||||||
else "First, identify the schematic file."
|
else "First, identify the schematic file."
|
||||||
)
|
)
|
||||||
|
|
||||||
return f"""Systematic approach to debugging a circuit.
|
return [Message(role="user", content=f"""Systematic approach to debugging a circuit.
|
||||||
|
|
||||||
{path_instruction}
|
{path_instruction}
|
||||||
|
|
||||||
@ -2012,21 +2550,21 @@ Common issues:
|
|||||||
- Missing bias voltages or ground
|
- Missing bias voltages or ground
|
||||||
- Component values off by orders of magnitude
|
- Component values off by orders of magnitude
|
||||||
- Wrong model (check with search_spice_models)
|
- Wrong model (check with search_spice_models)
|
||||||
"""
|
""")]
|
||||||
|
|
||||||
|
|
||||||
@mcp.prompt()
|
@mcp.prompt()
|
||||||
def optimize_design(
|
def optimize_design(
|
||||||
circuit_type: str = "filter",
|
circuit_type: str = "filter",
|
||||||
target_spec: str = "1kHz bandwidth",
|
target_spec: str = "1kHz bandwidth",
|
||||||
) -> str:
|
) -> list:
|
||||||
"""Guide through optimizing a circuit to meet target specifications.
|
"""Guide through optimizing a circuit to meet target specifications.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
circuit_type: Type of circuit (filter, amplifier, regulator, oscillator)
|
circuit_type: Type of circuit (filter, amplifier, regulator, oscillator)
|
||||||
target_spec: Target specification to achieve
|
target_spec: Target specification to achieve
|
||||||
"""
|
"""
|
||||||
return f"""Optimize a {circuit_type} circuit to achieve: {target_spec}
|
return [Message(role="user", content=f"""Optimize a {circuit_type} circuit to achieve: {target_spec}
|
||||||
|
|
||||||
Workflow:
|
Workflow:
|
||||||
1. Start with a template: use list_templates to see available circuits
|
1. Start with a template: use list_templates to see available circuits
|
||||||
@ -2045,21 +2583,21 @@ Tips:
|
|||||||
- For filters: target bandwidth_hz metric
|
- For filters: target bandwidth_hz metric
|
||||||
- For amplifiers: target gain_db and phase_margin_deg
|
- For amplifiers: target gain_db and phase_margin_deg
|
||||||
- For regulators: target settling_time and peak_to_peak (ripple)
|
- For regulators: target settling_time and peak_to_peak (ripple)
|
||||||
"""
|
""")]
|
||||||
|
|
||||||
|
|
||||||
@mcp.prompt()
|
@mcp.prompt()
|
||||||
def monte_carlo_analysis(
|
def monte_carlo_analysis(
|
||||||
circuit_description: str = "RC filter",
|
circuit_description: str = "RC filter",
|
||||||
n_runs: str = "100",
|
n_runs: str = "100",
|
||||||
) -> str:
|
) -> list:
|
||||||
"""Guide through Monte Carlo tolerance analysis.
|
"""Guide through Monte Carlo tolerance analysis.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
circuit_description: What circuit to analyze
|
circuit_description: What circuit to analyze
|
||||||
n_runs: Number of Monte Carlo iterations
|
n_runs: Number of Monte Carlo iterations
|
||||||
"""
|
"""
|
||||||
return f"""Run Monte Carlo tolerance analysis on: {circuit_description}
|
return [Message(role="user", content=f"""Run Monte Carlo tolerance analysis on: {circuit_description}
|
||||||
Number of runs: {n_runs}
|
Number of runs: {n_runs}
|
||||||
|
|
||||||
Workflow:
|
Workflow:
|
||||||
@ -2087,19 +2625,19 @@ Tips:
|
|||||||
- Ceramic capacitors: 10-20%
|
- Ceramic capacitors: 10-20%
|
||||||
- Electrolytic capacitors: 20%
|
- Electrolytic capacitors: 20%
|
||||||
- Inductors: 10-20%
|
- Inductors: 10-20%
|
||||||
"""
|
""")]
|
||||||
|
|
||||||
|
|
||||||
@mcp.prompt()
|
@mcp.prompt()
|
||||||
def circuit_from_scratch(
|
def circuit_from_scratch(
|
||||||
description: str = "audio amplifier",
|
description: str = "audio amplifier",
|
||||||
) -> str:
|
) -> list:
|
||||||
"""Guide through creating a complete circuit from scratch.
|
"""Guide through creating a complete circuit from scratch.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
description: What circuit to build
|
description: What circuit to build
|
||||||
"""
|
"""
|
||||||
return f"""Build a complete circuit from scratch: {description}
|
return [Message(role="user", content=f"""Build a complete circuit from scratch: {description}
|
||||||
|
|
||||||
Approach 1 - Use a template (recommended for common circuits):
|
Approach 1 - Use a template (recommended for common circuits):
|
||||||
1. Use list_templates to see available circuit templates
|
1. Use list_templates to see available circuit templates
|
||||||
@ -2129,7 +2667,81 @@ Verification workflow:
|
|||||||
4. Run .ac analysis for frequency response
|
4. Run .ac analysis for frequency response
|
||||||
5. Run .tran analysis for time-domain behavior
|
5. Run .tran analysis for time-domain behavior
|
||||||
6. Use diff_schematics to compare design iterations
|
6. Use diff_schematics to compare design iterations
|
||||||
|
""")]
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.prompt()
|
||||||
|
def troubleshoot_simulation(
|
||||||
|
error_description: str = "",
|
||||||
|
schematic_path: str = "",
|
||||||
|
) -> list:
|
||||||
|
"""Systematic checklist for diagnosing simulation failures.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
error_description: What went wrong (error message, unexpected results, etc.)
|
||||||
|
schematic_path: Path to the problematic schematic or netlist
|
||||||
"""
|
"""
|
||||||
|
path_note = (
|
||||||
|
f"Schematic/netlist: {schematic_path}"
|
||||||
|
if schematic_path
|
||||||
|
else "First, identify the schematic or netlist file."
|
||||||
|
)
|
||||||
|
error_note = (
|
||||||
|
f"Reported issue: {error_description}"
|
||||||
|
if error_description
|
||||||
|
else "No specific error described -- run full diagnostic."
|
||||||
|
)
|
||||||
|
|
||||||
|
return [Message(role="user", content=f"""Troubleshoot a simulation that isn't working correctly.
|
||||||
|
|
||||||
|
{path_note}
|
||||||
|
{error_note}
|
||||||
|
|
||||||
|
Diagnostic checklist (work through in order):
|
||||||
|
|
||||||
|
1. **Design Rule Check**
|
||||||
|
- Run run_drc on the schematic
|
||||||
|
- Fix any: missing ground, floating nodes, duplicate names, missing sim directive
|
||||||
|
|
||||||
|
2. **Installation & Setup**
|
||||||
|
- Run check_installation to verify Wine + LTspice
|
||||||
|
- Check that required .lib files exist
|
||||||
|
|
||||||
|
3. **Model Availability**
|
||||||
|
- Use search_spice_models to verify all transistor/diode models exist
|
||||||
|
- Use search_spice_subcircuits to verify op-amp models
|
||||||
|
- Common issue: model name in schematic doesn't match library
|
||||||
|
|
||||||
|
4. **Simulation Directive**
|
||||||
|
- Use read_schematic to check the directive
|
||||||
|
- Verify analysis type matches what you want (.tran, .ac, .dc, .op, .tf)
|
||||||
|
- For .tran: is the stop time long enough?
|
||||||
|
- For .ac: are start/stop frequencies reasonable?
|
||||||
|
|
||||||
|
5. **Node Connections**
|
||||||
|
- Use read_schematic to list all components and nets
|
||||||
|
- Check for disconnected nodes (components not wired)
|
||||||
|
- Verify ground connections on all return paths
|
||||||
|
|
||||||
|
6. **Run & Inspect**
|
||||||
|
- Simulate the circuit
|
||||||
|
- Check the log file for convergence warnings
|
||||||
|
- Use get_waveform to inspect node voltages
|
||||||
|
- Compare expected vs actual at each circuit stage
|
||||||
|
|
||||||
|
7. **Simplify & Isolate**
|
||||||
|
- Use edit_component to replace active devices with ideal ones
|
||||||
|
- Remove non-essential subcircuits
|
||||||
|
- Test each stage independently
|
||||||
|
- Add .ic directives if oscillators won't start
|
||||||
|
|
||||||
|
Common failure modes:
|
||||||
|
- Convergence failure: reduce timestep, add initial conditions
|
||||||
|
- All zeros: check ground connections and source polarity
|
||||||
|
- Unexpected clipping: check supply voltages and headroom
|
||||||
|
- Oscillation in DC circuit: add small capacitors on feedback
|
||||||
|
- Model not found: verify .lib/.include paths
|
||||||
|
""")]
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
533
src/mcp_ltspice/svg_plot.py
Normal file
533
src/mcp_ltspice/svg_plot.py
Normal file
@ -0,0 +1,533 @@
|
|||||||
|
"""Pure SVG waveform plot generation -- no matplotlib dependency.
|
||||||
|
|
||||||
|
Generates complete <svg> XML strings for time-domain, Bode, and spectrum plots
|
||||||
|
suitable for embedding in HTML or saving as standalone .svg files.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from html import escape as _html_escape
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_ENG_PREFIXES = [
|
||||||
|
(1e18, "E"),
|
||||||
|
(1e15, "P"),
|
||||||
|
(1e12, "T"),
|
||||||
|
(1e9, "G"),
|
||||||
|
(1e6, "M"),
|
||||||
|
(1e3, "k"),
|
||||||
|
(1e0, ""),
|
||||||
|
(1e-3, "m"),
|
||||||
|
(1e-6, "\u00b5"), # micro sign
|
||||||
|
(1e-9, "n"),
|
||||||
|
(1e-12, "p"),
|
||||||
|
(1e-15, "f"),
|
||||||
|
(1e-18, "a"),
|
||||||
|
]
|
||||||
|
|
||||||
|
_FREQ_PREFIXES = [
|
||||||
|
(1e9, "G"),
|
||||||
|
(1e6, "M"),
|
||||||
|
(1e3, "k"),
|
||||||
|
(1e0, ""),
|
||||||
|
(1e-3, "m"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _svg_escape(text: str) -> str:
|
||||||
|
"""Escape special characters for embedding inside SVG XML."""
|
||||||
|
return _html_escape(str(text), quote=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_eng(value: float, unit: str = "") -> str:
|
||||||
|
"""Format *value* with an engineering prefix and optional *unit*."""
|
||||||
|
if value == 0:
|
||||||
|
return f"0{unit}"
|
||||||
|
abs_val = abs(value)
|
||||||
|
for threshold, prefix in _ENG_PREFIXES:
|
||||||
|
if abs_val >= threshold * 0.9999:
|
||||||
|
scaled = value / threshold
|
||||||
|
# Trim trailing zeros but keep at least one digit
|
||||||
|
txt = f"{scaled:.3g}"
|
||||||
|
return f"{txt}{prefix}{unit}"
|
||||||
|
# Extremely small -- fall back to scientific
|
||||||
|
return f"{value:.2e}{unit}"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_freq(hz: float) -> str:
|
||||||
|
"""Format a frequency value for axis labels (1, 10, 1k, 1M, etc.)."""
|
||||||
|
if hz == 0:
|
||||||
|
return "0"
|
||||||
|
abs_hz = abs(hz)
|
||||||
|
for threshold, prefix in _FREQ_PREFIXES:
|
||||||
|
if abs_hz >= threshold * 0.9999:
|
||||||
|
scaled = hz / threshold
|
||||||
|
txt = f"{scaled:.4g}"
|
||||||
|
# Strip unnecessary trailing zeros after decimal
|
||||||
|
if "." in txt:
|
||||||
|
txt = txt.rstrip("0").rstrip(".")
|
||||||
|
return f"{txt}{prefix}"
|
||||||
|
return f"{hz:.2e}"
|
||||||
|
|
||||||
|
|
||||||
|
def _nice_ticks(vmin: float, vmax: float, n_ticks: int = 5) -> list[float]:
|
||||||
|
"""Compute human-friendly tick values spanning [vmin, vmax].
|
||||||
|
|
||||||
|
Returns a list of round numbers that cover the data range.
|
||||||
|
"""
|
||||||
|
if vmin == vmax:
|
||||||
|
return [vmin]
|
||||||
|
if not math.isfinite(vmin) or not math.isfinite(vmax):
|
||||||
|
return [0.0]
|
||||||
|
|
||||||
|
raw_step = (vmax - vmin) / max(n_ticks - 1, 1)
|
||||||
|
if raw_step == 0:
|
||||||
|
return [vmin]
|
||||||
|
|
||||||
|
magnitude = 10 ** math.floor(math.log10(abs(raw_step)))
|
||||||
|
residual = raw_step / magnitude
|
||||||
|
# Snap to a "nice" step: 1, 2, 2.5, 5, 10
|
||||||
|
if residual <= 1.0:
|
||||||
|
nice_step = magnitude
|
||||||
|
elif residual <= 2.0:
|
||||||
|
nice_step = 2 * magnitude
|
||||||
|
elif residual <= 2.5:
|
||||||
|
nice_step = 2.5 * magnitude
|
||||||
|
elif residual <= 5.0:
|
||||||
|
nice_step = 5 * magnitude
|
||||||
|
else:
|
||||||
|
nice_step = 10 * magnitude
|
||||||
|
|
||||||
|
tick_min = math.floor(vmin / nice_step) * nice_step
|
||||||
|
tick_max = math.ceil(vmax / nice_step) * nice_step
|
||||||
|
ticks: list[float] = []
|
||||||
|
t = tick_min
|
||||||
|
while t <= tick_max + nice_step * 0.001:
|
||||||
|
ticks.append(round(t, 12))
|
||||||
|
t += nice_step
|
||||||
|
return ticks
|
||||||
|
|
||||||
|
|
||||||
|
def _log_ticks(vmin: float, vmax: float) -> list[float]:
|
||||||
|
"""Generate tick values at powers of 10 spanning [vmin, vmax] (linear values)."""
|
||||||
|
if vmin <= 0:
|
||||||
|
vmin = 1.0
|
||||||
|
if vmax <= vmin:
|
||||||
|
vmax = vmin * 10
|
||||||
|
low = math.floor(math.log10(vmin))
|
||||||
|
high = math.ceil(math.log10(vmax))
|
||||||
|
ticks = [10.0**i for i in range(low, high + 1)]
|
||||||
|
# Filter to range
|
||||||
|
return [t for t in ticks if vmin * 0.9999 <= t <= vmax * 1.0001]
|
||||||
|
|
||||||
|
|
||||||
|
def _data_extent(arr: np.ndarray, pad_frac: float = 0.05) -> tuple[float, float]:
|
||||||
|
"""Return (min, max) of *arr* with *pad_frac* padding on each side."""
|
||||||
|
if len(arr) == 0:
|
||||||
|
return (0.0, 1.0)
|
||||||
|
lo, hi = float(np.nanmin(arr)), float(np.nanmax(arr))
|
||||||
|
if lo == hi:
|
||||||
|
lo -= 1.0
|
||||||
|
hi += 1.0
|
||||||
|
span = hi - lo
|
||||||
|
return (lo - span * pad_frac, hi + span * pad_frac)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Core SVG building blocks
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_FONT = "system-ui, -apple-system, sans-serif"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_path_d(
|
||||||
|
xs: np.ndarray,
|
||||||
|
ys: np.ndarray,
|
||||||
|
x_min: float,
|
||||||
|
x_max: float,
|
||||||
|
y_min: float,
|
||||||
|
y_max: float,
|
||||||
|
plot_x: float,
|
||||||
|
plot_y: float,
|
||||||
|
plot_w: float,
|
||||||
|
plot_h: float,
|
||||||
|
log_x: bool = False,
|
||||||
|
) -> str:
|
||||||
|
"""Convert data arrays into an SVG path *d* attribute string."""
|
||||||
|
if len(xs) == 0:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Map data -> pixel coords
|
||||||
|
if log_x:
|
||||||
|
safe_xs = np.clip(xs, max(x_min, 1e-30), None)
|
||||||
|
lx = np.log10(safe_xs)
|
||||||
|
lx_min = math.log10(max(x_min, 1e-30))
|
||||||
|
lx_max = math.log10(max(x_max, 1e-30))
|
||||||
|
denom_x = lx_max - lx_min if lx_max != lx_min else 1.0
|
||||||
|
px = plot_x + (lx - lx_min) / denom_x * plot_w
|
||||||
|
else:
|
||||||
|
denom_x = x_max - x_min if x_max != x_min else 1.0
|
||||||
|
px = plot_x + (xs - x_min) / denom_x * plot_w
|
||||||
|
|
||||||
|
denom_y = y_max - y_min if y_max != y_min else 1.0
|
||||||
|
# Y axis is inverted in SVG (top = 0)
|
||||||
|
py = plot_y + plot_h - (ys - y_min) / denom_y * plot_h
|
||||||
|
|
||||||
|
parts = [f"M{px[0]:.2f},{py[0]:.2f}"]
|
||||||
|
for i in range(1, len(px)):
|
||||||
|
parts.append(f"L{px[i]:.2f},{py[i]:.2f}")
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_subplot(
|
||||||
|
*,
|
||||||
|
xs: np.ndarray,
|
||||||
|
ys: np.ndarray,
|
||||||
|
x_min: float,
|
||||||
|
x_max: float,
|
||||||
|
y_min: float,
|
||||||
|
y_max: float,
|
||||||
|
plot_x: float,
|
||||||
|
plot_y: float,
|
||||||
|
plot_w: float,
|
||||||
|
plot_h: float,
|
||||||
|
log_x: bool,
|
||||||
|
xlabel: str,
|
||||||
|
ylabel: str,
|
||||||
|
title: str | None,
|
||||||
|
stroke: str,
|
||||||
|
x_ticks: list[float] | None = None,
|
||||||
|
y_ticks: list[float] | None = None,
|
||||||
|
show_x_labels: bool = True,
|
||||||
|
) -> str:
|
||||||
|
"""Render a single subplot region as a block of SVG elements."""
|
||||||
|
lines: list[str] = []
|
||||||
|
|
||||||
|
# Compute ticks
|
||||||
|
if y_ticks is None:
|
||||||
|
y_ticks = _nice_ticks(y_min, y_max, n_ticks=6)
|
||||||
|
|
||||||
|
if x_ticks is None:
|
||||||
|
if log_x:
|
||||||
|
x_ticks = _log_ticks(x_min, x_max)
|
||||||
|
else:
|
||||||
|
x_ticks = _nice_ticks(x_min, x_max, n_ticks=6)
|
||||||
|
|
||||||
|
# Background
|
||||||
|
lines.append(
|
||||||
|
f'<rect x="{plot_x}" y="{plot_y}" width="{plot_w}" height="{plot_h}" '
|
||||||
|
f'fill="white" stroke="#ccc" stroke-width="1"/>'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Grid + Y tick labels
|
||||||
|
denom_y = y_max - y_min if y_max != y_min else 1.0
|
||||||
|
for tv in y_ticks:
|
||||||
|
if tv < y_min or tv > y_max:
|
||||||
|
continue
|
||||||
|
py = plot_y + plot_h - (tv - y_min) / denom_y * plot_h
|
||||||
|
lines.append(
|
||||||
|
f'<line x1="{plot_x}" y1="{py:.1f}" x2="{plot_x + plot_w}" y2="{py:.1f}" '
|
||||||
|
f'stroke="#ddd" stroke-width="0.5" stroke-dasharray="4,3"/>'
|
||||||
|
)
|
||||||
|
label = _format_eng(tv)
|
||||||
|
lines.append(
|
||||||
|
f'<text x="{plot_x - 8}" y="{py:.1f}" text-anchor="end" '
|
||||||
|
f'dominant-baseline="middle" font-size="11" font-family="{_FONT}" '
|
||||||
|
f'fill="#444">{_svg_escape(label)}</text>'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Grid + X tick labels
|
||||||
|
if log_x:
|
||||||
|
lx_min = math.log10(max(x_min, 1e-30))
|
||||||
|
lx_max = math.log10(max(x_max, 1e-30))
|
||||||
|
denom_x = lx_max - lx_min if lx_max != lx_min else 1.0
|
||||||
|
else:
|
||||||
|
denom_x = x_max - x_min if x_max != x_min else 1.0
|
||||||
|
|
||||||
|
for tv in x_ticks:
|
||||||
|
if log_x:
|
||||||
|
if tv <= 0:
|
||||||
|
continue
|
||||||
|
frac = (math.log10(tv) - lx_min) / denom_x
|
||||||
|
else:
|
||||||
|
frac = (tv - x_min) / denom_x
|
||||||
|
if frac < -0.001 or frac > 1.001:
|
||||||
|
continue
|
||||||
|
px = plot_x + frac * plot_w
|
||||||
|
lines.append(
|
||||||
|
f'<line x1="{px:.1f}" y1="{plot_y}" x2="{px:.1f}" y2="{plot_y + plot_h}" '
|
||||||
|
f'stroke="#ddd" stroke-width="0.5" stroke-dasharray="4,3"/>'
|
||||||
|
)
|
||||||
|
if show_x_labels:
|
||||||
|
if log_x:
|
||||||
|
label = _format_freq(tv)
|
||||||
|
else:
|
||||||
|
label = _format_eng(tv)
|
||||||
|
lines.append(
|
||||||
|
f'<text x="{px:.1f}" y="{plot_y + plot_h + 16}" text-anchor="middle" '
|
||||||
|
f'font-size="11" font-family="{_FONT}" fill="#444">'
|
||||||
|
f'{_svg_escape(label)}</text>'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Data path
|
||||||
|
d = _build_path_d(xs, ys, x_min, x_max, y_min, y_max, plot_x, plot_y, plot_w, plot_h, log_x)
|
||||||
|
if d:
|
||||||
|
lines.append(
|
||||||
|
f'<path d="{d}" fill="none" stroke="{stroke}" stroke-width="1.5" '
|
||||||
|
f'stroke-linejoin="round" stroke-linecap="round"/>'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Title
|
||||||
|
if title:
|
||||||
|
lines.append(
|
||||||
|
f'<text x="{plot_x + plot_w / 2}" y="{plot_y - 12}" text-anchor="middle" '
|
||||||
|
f'font-size="14" font-weight="600" font-family="{_FONT}" fill="#111">'
|
||||||
|
f'{_svg_escape(title)}</text>'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Axis labels
|
||||||
|
if ylabel:
|
||||||
|
mid_y = plot_y + plot_h / 2
|
||||||
|
lines.append(
|
||||||
|
f'<text x="{plot_x - 55}" y="{mid_y}" text-anchor="middle" '
|
||||||
|
f'font-size="12" font-family="{_FONT}" fill="#333" '
|
||||||
|
f'transform="rotate(-90, {plot_x - 55}, {mid_y})">'
|
||||||
|
f'{_svg_escape(ylabel)}</text>'
|
||||||
|
)
|
||||||
|
|
||||||
|
if xlabel and show_x_labels:
|
||||||
|
lines.append(
|
||||||
|
f'<text x="{plot_x + plot_w / 2}" y="{plot_y + plot_h + 42}" '
|
||||||
|
f'text-anchor="middle" font-size="12" font-family="{_FONT}" fill="#333">'
|
||||||
|
f'{_svg_escape(xlabel)}</text>'
|
||||||
|
)
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _wrap_svg(inner: str, width: int, height: int) -> str:
|
||||||
|
"""Wrap inner SVG elements in a root <svg> tag with white background."""
|
||||||
|
return (
|
||||||
|
f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" '
|
||||||
|
f'viewBox="0 0 {width} {height}">\n'
|
||||||
|
f'<rect width="{width}" height="{height}" fill="white"/>\n'
|
||||||
|
f'{inner}\n'
|
||||||
|
f'</svg>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def plot_timeseries(
|
||||||
|
time: list[float] | np.ndarray,
|
||||||
|
values: list[float] | np.ndarray,
|
||||||
|
title: str = "Time Domain",
|
||||||
|
ylabel: str = "Voltage (V)",
|
||||||
|
width: int = 800,
|
||||||
|
height: int = 400,
|
||||||
|
) -> str:
|
||||||
|
"""Plot a time-domain signal. Linear X axis (time), linear Y axis.
|
||||||
|
|
||||||
|
Returns a complete ``<svg>`` XML string.
|
||||||
|
"""
|
||||||
|
t = np.asarray(time, dtype=float).ravel()
|
||||||
|
v = np.asarray(values, dtype=float).ravel()
|
||||||
|
n = min(len(t), len(v))
|
||||||
|
t, v = t[:n], v[:n]
|
||||||
|
|
||||||
|
margin_l, margin_r, margin_t, margin_b = 80, 20, 40, 60
|
||||||
|
plot_x = float(margin_l)
|
||||||
|
plot_y = float(margin_t)
|
||||||
|
plot_w = float(width - margin_l - margin_r)
|
||||||
|
plot_h = float(height - margin_t - margin_b)
|
||||||
|
|
||||||
|
x_min, x_max = _data_extent(t)
|
||||||
|
y_min, y_max = _data_extent(v)
|
||||||
|
|
||||||
|
inner = _render_subplot(
|
||||||
|
xs=t,
|
||||||
|
ys=v,
|
||||||
|
x_min=x_min,
|
||||||
|
x_max=x_max,
|
||||||
|
y_min=y_min,
|
||||||
|
y_max=y_max,
|
||||||
|
plot_x=plot_x,
|
||||||
|
plot_y=plot_y,
|
||||||
|
plot_w=plot_w,
|
||||||
|
plot_h=plot_h,
|
||||||
|
log_x=False,
|
||||||
|
xlabel="Time (s)",
|
||||||
|
ylabel=ylabel,
|
||||||
|
title=title,
|
||||||
|
stroke="#2563eb",
|
||||||
|
)
|
||||||
|
return _wrap_svg(inner, width, height)
|
||||||
|
|
||||||
|
|
||||||
|
def plot_bode(
|
||||||
|
freq: list[float] | np.ndarray,
|
||||||
|
mag_db: list[float] | np.ndarray,
|
||||||
|
phase_deg: list[float] | np.ndarray | None = None,
|
||||||
|
title: str = "Bode Plot",
|
||||||
|
width: int = 800,
|
||||||
|
height: int = 500,
|
||||||
|
) -> str:
|
||||||
|
"""Plot frequency response (Bode plot). Log10 X, linear Y (dB).
|
||||||
|
|
||||||
|
If *phase_deg* is provided, the SVG contains two stacked subplots:
|
||||||
|
magnitude on top, phase on the bottom.
|
||||||
|
|
||||||
|
Returns a complete ``<svg>`` XML string.
|
||||||
|
"""
|
||||||
|
f = np.asarray(freq, dtype=float).ravel()
|
||||||
|
m = np.asarray(mag_db, dtype=float).ravel()
|
||||||
|
n = min(len(f), len(m))
|
||||||
|
f, m = f[:n], m[:n]
|
||||||
|
|
||||||
|
has_phase = phase_deg is not None
|
||||||
|
if has_phase:
|
||||||
|
p = np.asarray(phase_deg, dtype=float).ravel()
|
||||||
|
n = min(len(f), len(p))
|
||||||
|
f, m, p = f[:n], m[:n], p[:n]
|
||||||
|
|
||||||
|
margin_l, margin_r, margin_t, margin_b = 80, 20, 40, 60
|
||||||
|
plot_x = float(margin_l)
|
||||||
|
plot_w = float(width - margin_l - margin_r)
|
||||||
|
|
||||||
|
# Frequency range (shared)
|
||||||
|
f_min, f_max = _data_extent(f[f > 0] if np.any(f > 0) else f, pad_frac=0.0)
|
||||||
|
if f_min <= 0:
|
||||||
|
f_min = 1.0
|
||||||
|
freq_ticks = _log_ticks(f_min, f_max)
|
||||||
|
|
||||||
|
if has_phase:
|
||||||
|
# Split into two subplots with a gap
|
||||||
|
gap = 30
|
||||||
|
available_h = height - margin_t - margin_b - gap
|
||||||
|
mag_h = available_h * 0.55
|
||||||
|
phase_h = available_h * 0.45
|
||||||
|
mag_y = float(margin_t)
|
||||||
|
phase_y = float(margin_t + mag_h + gap)
|
||||||
|
|
||||||
|
m_min, m_max = _data_extent(m)
|
||||||
|
p_min, p_max = _data_extent(p)
|
||||||
|
|
||||||
|
mag_svg = _render_subplot(
|
||||||
|
xs=f,
|
||||||
|
ys=m,
|
||||||
|
x_min=f_min,
|
||||||
|
x_max=f_max,
|
||||||
|
y_min=m_min,
|
||||||
|
y_max=m_max,
|
||||||
|
plot_x=plot_x,
|
||||||
|
plot_y=mag_y,
|
||||||
|
plot_w=plot_w,
|
||||||
|
plot_h=mag_h,
|
||||||
|
log_x=True,
|
||||||
|
xlabel="",
|
||||||
|
ylabel="Magnitude (dB)",
|
||||||
|
title=title,
|
||||||
|
stroke="#2563eb",
|
||||||
|
x_ticks=freq_ticks,
|
||||||
|
show_x_labels=False,
|
||||||
|
)
|
||||||
|
phase_svg = _render_subplot(
|
||||||
|
xs=f,
|
||||||
|
ys=p,
|
||||||
|
x_min=f_min,
|
||||||
|
x_max=f_max,
|
||||||
|
y_min=p_min,
|
||||||
|
y_max=p_max,
|
||||||
|
plot_x=plot_x,
|
||||||
|
plot_y=phase_y,
|
||||||
|
plot_w=plot_w,
|
||||||
|
plot_h=phase_h,
|
||||||
|
log_x=True,
|
||||||
|
xlabel="Frequency (Hz)",
|
||||||
|
ylabel="Phase (deg)",
|
||||||
|
title=None,
|
||||||
|
stroke="#dc2626",
|
||||||
|
x_ticks=freq_ticks,
|
||||||
|
)
|
||||||
|
return _wrap_svg(mag_svg + "\n" + phase_svg, width, height)
|
||||||
|
|
||||||
|
else:
|
||||||
|
plot_y = float(margin_t)
|
||||||
|
plot_h = float(height - margin_t - margin_b)
|
||||||
|
m_min, m_max = _data_extent(m)
|
||||||
|
|
||||||
|
inner = _render_subplot(
|
||||||
|
xs=f,
|
||||||
|
ys=m,
|
||||||
|
x_min=f_min,
|
||||||
|
x_max=f_max,
|
||||||
|
y_min=m_min,
|
||||||
|
y_max=m_max,
|
||||||
|
plot_x=plot_x,
|
||||||
|
plot_y=plot_y,
|
||||||
|
plot_w=plot_w,
|
||||||
|
plot_h=plot_h,
|
||||||
|
log_x=True,
|
||||||
|
xlabel="Frequency (Hz)",
|
||||||
|
ylabel="Magnitude (dB)",
|
||||||
|
title=title,
|
||||||
|
stroke="#2563eb",
|
||||||
|
x_ticks=freq_ticks,
|
||||||
|
)
|
||||||
|
return _wrap_svg(inner, width, height)
|
||||||
|
|
||||||
|
|
||||||
|
def plot_spectrum(
|
||||||
|
freq: list[float] | np.ndarray,
|
||||||
|
mag_db: list[float] | np.ndarray,
|
||||||
|
title: str = "FFT Spectrum",
|
||||||
|
width: int = 800,
|
||||||
|
height: int = 400,
|
||||||
|
) -> str:
|
||||||
|
"""Plot an FFT spectrum. Log10 X axis, linear Y axis (dB).
|
||||||
|
|
||||||
|
Returns a complete ``<svg>`` XML string.
|
||||||
|
"""
|
||||||
|
f = np.asarray(freq, dtype=float).ravel()
|
||||||
|
m = np.asarray(mag_db, dtype=float).ravel()
|
||||||
|
n = min(len(f), len(m))
|
||||||
|
f, m = f[:n], m[:n]
|
||||||
|
|
||||||
|
margin_l, margin_r, margin_t, margin_b = 80, 20, 40, 60
|
||||||
|
plot_x = float(margin_l)
|
||||||
|
plot_y = float(margin_t)
|
||||||
|
plot_w = float(width - margin_l - margin_r)
|
||||||
|
plot_h = float(height - margin_t - margin_b)
|
||||||
|
|
||||||
|
f_min, f_max = _data_extent(f[f > 0] if np.any(f > 0) else f, pad_frac=0.0)
|
||||||
|
if f_min <= 0:
|
||||||
|
f_min = 1.0
|
||||||
|
m_min, m_max = _data_extent(m)
|
||||||
|
|
||||||
|
inner = _render_subplot(
|
||||||
|
xs=f,
|
||||||
|
ys=m,
|
||||||
|
x_min=f_min,
|
||||||
|
x_max=f_max,
|
||||||
|
y_min=m_min,
|
||||||
|
y_max=m_max,
|
||||||
|
plot_x=plot_x,
|
||||||
|
plot_y=plot_y,
|
||||||
|
plot_w=plot_w,
|
||||||
|
plot_h=plot_h,
|
||||||
|
log_x=True,
|
||||||
|
xlabel="Frequency (Hz)",
|
||||||
|
ylabel="Magnitude (dB)",
|
||||||
|
title=title,
|
||||||
|
stroke="#2563eb",
|
||||||
|
)
|
||||||
|
return _wrap_svg(inner, width, height)
|
||||||
@ -3,7 +3,6 @@
|
|||||||
All fixtures produce synthetic data -- no LTspice or Wine required.
|
All fixtures produce synthetic data -- no LTspice or Wine required.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@ -12,7 +11,6 @@ import pytest
|
|||||||
from mcp_ltspice.raw_parser import RawFile, Variable
|
from mcp_ltspice.raw_parser import RawFile, Variable
|
||||||
from mcp_ltspice.schematic import Component, Flag, Schematic, Text, Wire
|
from mcp_ltspice.schematic import Component, Flag, Schematic, Text, Wire
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Time-domain fixtures
|
# Time-domain fixtures
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@ -326,3 +324,13 @@ def schematic_duplicate_names() -> Schematic:
|
|||||||
]
|
]
|
||||||
sch.texts = [Text(80, 296, ".tran 10m", type="spice")]
|
sch.texts = [Text(80, 296, ".tran 10m", type="spice")]
|
||||||
return sch
|
return sch
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def ltspice_available():
|
||||||
|
"""Skip test if LTspice is not available."""
|
||||||
|
from mcp_ltspice.config import validate_installation
|
||||||
|
ok, msg = validate_installation()
|
||||||
|
if not ok:
|
||||||
|
pytest.skip(f"LTspice not available: {msg}")
|
||||||
|
return True
|
||||||
|
|||||||
@ -3,9 +3,8 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from mcp_ltspice.asc_generator import (
|
from mcp_ltspice.asc_generator import (
|
||||||
AscSchematic,
|
|
||||||
GRID,
|
|
||||||
_PIN_OFFSETS,
|
_PIN_OFFSETS,
|
||||||
|
AscSchematic,
|
||||||
_rotate,
|
_rotate,
|
||||||
generate_inverting_amp,
|
generate_inverting_amp,
|
||||||
generate_rc_lowpass,
|
generate_rc_lowpass,
|
||||||
|
|||||||
@ -1,12 +1,9 @@
|
|||||||
"""Tests for diff module: schematic comparison."""
|
"""Tests for diff module: schematic comparison."""
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from mcp_ltspice.diff import (
|
from mcp_ltspice.diff import (
|
||||||
ComponentChange,
|
ComponentChange,
|
||||||
DirectiveChange,
|
|
||||||
SchematicDiff,
|
SchematicDiff,
|
||||||
_diff_components,
|
_diff_components,
|
||||||
_diff_directives,
|
_diff_directives,
|
||||||
|
|||||||
@ -1,9 +1,6 @@
|
|||||||
"""Tests for drc module: design rule checks on schematic objects."""
|
"""Tests for drc module: design rule checks on schematic objects."""
|
||||||
|
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from mcp_ltspice.drc import (
|
from mcp_ltspice.drc import (
|
||||||
DRCResult,
|
DRCResult,
|
||||||
@ -13,7 +10,7 @@ from mcp_ltspice.drc import (
|
|||||||
_check_ground,
|
_check_ground,
|
||||||
_check_simulation_directive,
|
_check_simulation_directive,
|
||||||
)
|
)
|
||||||
from mcp_ltspice.schematic import Component, Flag, Schematic, Text, Wire, write_schematic
|
from mcp_ltspice.schematic import Schematic, write_schematic
|
||||||
|
|
||||||
|
|
||||||
def _run_single_check(check_fn, schematic: Schematic) -> DRCResult:
|
def _run_single_check(check_fn, schematic: Schematic) -> DRCResult:
|
||||||
|
|||||||
209
tests/test_integration.py
Normal file
209
tests/test_integration.py
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
"""Integration tests that run actual LTspice simulations.
|
||||||
|
|
||||||
|
These tests require LTspice and Wine to be installed. Skip with:
|
||||||
|
pytest -m 'not integration'
|
||||||
|
"""
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from mcp_ltspice.asc_generator import (
|
||||||
|
generate_colpitts_oscillator as generate_colpitts_asc,
|
||||||
|
)
|
||||||
|
from mcp_ltspice.asc_generator import (
|
||||||
|
generate_common_emitter_amp as generate_ce_amp_asc,
|
||||||
|
)
|
||||||
|
from mcp_ltspice.asc_generator import (
|
||||||
|
generate_non_inverting_amp as generate_noninv_amp_asc,
|
||||||
|
)
|
||||||
|
from mcp_ltspice.asc_generator import (
|
||||||
|
generate_rc_lowpass as generate_rc_lowpass_asc,
|
||||||
|
)
|
||||||
|
from mcp_ltspice.runner import run_simulation
|
||||||
|
from mcp_ltspice.waveform_math import compute_bandwidth, compute_rms
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestRCLowpass:
|
||||||
|
"""End-to-end test: RC lowpass filter -> simulate -> verify -3dB point."""
|
||||||
|
|
||||||
|
async def test_rc_lowpass_bandwidth(self, ltspice_available):
|
||||||
|
"""Generate RC lowpass (R=1k, C=100n), simulate, verify fc ~ 1.6 kHz."""
|
||||||
|
# Generate schematic
|
||||||
|
sch = generate_rc_lowpass_asc(r="1k", c="100n")
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
asc_path = Path(tmpdir) / "rc_lowpass.asc"
|
||||||
|
sch.save(asc_path)
|
||||||
|
|
||||||
|
# Simulate
|
||||||
|
result = await run_simulation(asc_path)
|
||||||
|
assert result.success, f"Simulation failed: {result.error or result.stderr}"
|
||||||
|
assert result.raw_data is not None, "No raw data produced"
|
||||||
|
|
||||||
|
# Find frequency and V(out) variables
|
||||||
|
raw = result.raw_data
|
||||||
|
freq_idx = None
|
||||||
|
vout_idx = None
|
||||||
|
for var in raw.variables:
|
||||||
|
if var.name.lower() == "frequency":
|
||||||
|
freq_idx = var.index
|
||||||
|
elif var.name.lower() == "v(out)":
|
||||||
|
vout_idx = var.index
|
||||||
|
|
||||||
|
assert freq_idx is not None, (
|
||||||
|
f"No frequency variable. Variables: {[v.name for v in raw.variables]}"
|
||||||
|
)
|
||||||
|
assert vout_idx is not None, (
|
||||||
|
f"No V(out) variable. Variables: {[v.name for v in raw.variables]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract data
|
||||||
|
freq = np.abs(raw.data[freq_idx])
|
||||||
|
mag_complex = raw.data[vout_idx]
|
||||||
|
mag_db = 20.0 * np.log10(np.maximum(np.abs(mag_complex), 1e-30))
|
||||||
|
|
||||||
|
# Compute bandwidth
|
||||||
|
bw = compute_bandwidth(freq, mag_db)
|
||||||
|
|
||||||
|
# Expected: fc = 1/(2*pi*1000*100e-9) ~ 1591 Hz
|
||||||
|
# Allow 20% tolerance for simulation differences
|
||||||
|
expected_fc = 1.0 / (2 * np.pi * 1000 * 100e-9)
|
||||||
|
assert bw["bandwidth_hz"] is not None, "Could not compute bandwidth"
|
||||||
|
assert abs(bw["bandwidth_hz"] - expected_fc) / expected_fc < 0.2, (
|
||||||
|
f"Bandwidth {bw['bandwidth_hz']:.0f} Hz too far from expected {expected_fc:.0f} Hz"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestNonInvertingAmp:
|
||||||
|
"""End-to-end test: non-inverting amp -> simulate -> verify gain."""
|
||||||
|
|
||||||
|
async def test_noninv_amp_gain(self, ltspice_available):
|
||||||
|
"""Generate non-inverting amp (Rf=100k, Rin=10k), verify gain ~ 11 (20.8 dB)."""
|
||||||
|
sch = generate_noninv_amp_asc(rin="10k", rf="100k")
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
asc_path = Path(tmpdir) / "noninv_amp.asc"
|
||||||
|
sch.save(asc_path)
|
||||||
|
|
||||||
|
result = await run_simulation(asc_path)
|
||||||
|
assert result.success, f"Simulation failed: {result.error or result.stderr}"
|
||||||
|
assert result.raw_data is not None
|
||||||
|
|
||||||
|
raw = result.raw_data
|
||||||
|
freq_idx = None
|
||||||
|
vout_idx = None
|
||||||
|
for var in raw.variables:
|
||||||
|
if var.name.lower() == "frequency":
|
||||||
|
freq_idx = var.index
|
||||||
|
elif var.name.lower() == "v(out)":
|
||||||
|
vout_idx = var.index
|
||||||
|
|
||||||
|
assert freq_idx is not None
|
||||||
|
assert vout_idx is not None
|
||||||
|
|
||||||
|
_freq = np.abs(raw.data[freq_idx]) # noqa: F841 — kept for debug
|
||||||
|
mag = np.abs(raw.data[vout_idx])
|
||||||
|
mag_db = 20.0 * np.log10(np.maximum(mag, 1e-30))
|
||||||
|
|
||||||
|
# At low frequency, gain should be 1 + 100k/10k = 11 = 20.83 dB
|
||||||
|
# Use first few points (low frequency)
|
||||||
|
low_freq_gain_db = float(np.mean(mag_db[:5]))
|
||||||
|
expected_gain_db = 20 * np.log10(11) # 20.83 dB
|
||||||
|
|
||||||
|
assert abs(low_freq_gain_db - expected_gain_db) < 2.0, (
|
||||||
|
f"Low-freq gain {low_freq_gain_db:.1f} dB, expected ~{expected_gain_db:.1f} dB"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestCommonEmitterAmp:
|
||||||
|
"""End-to-end test: CE amplifier -> simulate transient -> verify output exists."""
|
||||||
|
|
||||||
|
async def test_ce_amp_output(self, ltspice_available):
|
||||||
|
"""Generate CE amp, simulate transient, verify output has AC content."""
|
||||||
|
sch = generate_ce_amp_asc()
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
asc_path = Path(tmpdir) / "ce_amp.asc"
|
||||||
|
sch.save(asc_path)
|
||||||
|
|
||||||
|
result = await run_simulation(asc_path)
|
||||||
|
assert result.success, f"Simulation failed: {result.error or result.stderr}"
|
||||||
|
assert result.raw_data is not None
|
||||||
|
|
||||||
|
raw = result.raw_data
|
||||||
|
time_idx = None
|
||||||
|
vout_idx = None
|
||||||
|
for var in raw.variables:
|
||||||
|
if var.name.lower() == "time":
|
||||||
|
time_idx = var.index
|
||||||
|
elif var.name.lower() == "v(out)":
|
||||||
|
vout_idx = var.index
|
||||||
|
|
||||||
|
assert time_idx is not None
|
||||||
|
assert vout_idx is not None
|
||||||
|
|
||||||
|
sig = np.real(raw.data[vout_idx])
|
||||||
|
|
||||||
|
# Output should not be DC-only -- check peak-to-peak > threshold
|
||||||
|
pp = float(np.max(sig) - np.min(sig))
|
||||||
|
assert pp > 0.01, (
|
||||||
|
f"Output appears DC-only (peak-to-peak={pp:.4f}V). "
|
||||||
|
"Expected amplified AC signal."
|
||||||
|
)
|
||||||
|
|
||||||
|
# RMS should be non-trivial
|
||||||
|
rms = float(compute_rms(sig))
|
||||||
|
assert rms > 0.01, f"Output RMS too low: {rms:.4f}V"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestColpittsOscillator:
|
||||||
|
"""End-to-end test: Colpitts oscillator -> simulate -> verify oscillation."""
|
||||||
|
|
||||||
|
async def test_colpitts_oscillation(self, ltspice_available):
|
||||||
|
"""Generate Colpitts oscillator, verify oscillation near expected frequency."""
|
||||||
|
sch = generate_colpitts_asc()
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
asc_path = Path(tmpdir) / "colpitts.asc"
|
||||||
|
sch.save(asc_path)
|
||||||
|
|
||||||
|
result = await run_simulation(asc_path)
|
||||||
|
assert result.success, f"Simulation failed: {result.error or result.stderr}"
|
||||||
|
assert result.raw_data is not None
|
||||||
|
|
||||||
|
raw = result.raw_data
|
||||||
|
time_idx = None
|
||||||
|
vcol_idx = None
|
||||||
|
for var in raw.variables:
|
||||||
|
if var.name.lower() == "time":
|
||||||
|
time_idx = var.index
|
||||||
|
elif var.name.lower() == "v(out)":
|
||||||
|
vcol_idx = var.index
|
||||||
|
elif "collector" in var.name.lower() and vcol_idx is None:
|
||||||
|
vcol_idx = var.index
|
||||||
|
|
||||||
|
assert time_idx is not None
|
||||||
|
|
||||||
|
assert vcol_idx is not None, (
|
||||||
|
f"No V(out) or collector signal. Variables: {[v.name for v in raw.variables]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
sig = np.real(raw.data[vcol_idx])
|
||||||
|
|
||||||
|
# Oscillator output should have significant AC content
|
||||||
|
pp = float(np.max(sig) - np.min(sig))
|
||||||
|
assert pp > 0.1, (
|
||||||
|
f"Output peak-to-peak {pp:.3f}V too small -- oscillator may not have started"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Expected frequency: f = 1/(2*pi*sqrt(L*C1*C2/(C1+C2)))
|
||||||
|
# With L=1u, C1=C2=100p: Cseries = 50p
|
||||||
|
# f = 1/(2*pi*sqrt(1e-6 * 50e-12)) ~ 22.5 MHz
|
||||||
|
# This is quite high, but we just verify oscillation exists
|
||||||
447
tests/test_new_templates.py
Normal file
447
tests/test_new_templates.py
Normal file
@ -0,0 +1,447 @@
|
|||||||
|
"""Tests for the 5 new circuit templates (netlist + asc generator)."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from mcp_ltspice.asc_generator import (
|
||||||
|
AscSchematic,
|
||||||
|
generate_boost_converter,
|
||||||
|
generate_current_mirror,
|
||||||
|
generate_instrumentation_amp,
|
||||||
|
generate_sallen_key_lowpass,
|
||||||
|
generate_transimpedance_amp,
|
||||||
|
)
|
||||||
|
from mcp_ltspice.netlist import (
|
||||||
|
Netlist,
|
||||||
|
boost_converter,
|
||||||
|
current_mirror,
|
||||||
|
instrumentation_amplifier,
|
||||||
|
sallen_key_lowpass,
|
||||||
|
transimpedance_amplifier,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Netlist template tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestSallenKeyLowpassNetlist:
|
||||||
|
def test_returns_netlist(self):
|
||||||
|
n = sallen_key_lowpass()
|
||||||
|
assert isinstance(n, Netlist)
|
||||||
|
|
||||||
|
def test_component_count(self):
|
||||||
|
n = sallen_key_lowpass()
|
||||||
|
# V1, Vpos, Vneg, R1, R2, C1, C2, X1 = 8 components
|
||||||
|
assert len(n.components) == 8
|
||||||
|
|
||||||
|
def test_render_contains_key_components(self):
|
||||||
|
text = sallen_key_lowpass().render()
|
||||||
|
assert "R1" in text
|
||||||
|
assert "R2" in text
|
||||||
|
assert "C1" in text
|
||||||
|
assert "C2" in text
|
||||||
|
assert "X1" in text
|
||||||
|
assert "LT1001" in text
|
||||||
|
assert ".ac" in text
|
||||||
|
|
||||||
|
def test_custom_params(self):
|
||||||
|
n = sallen_key_lowpass(r1="4.7k", r2="4.7k", c1="22n", c2="22n")
|
||||||
|
text = n.render()
|
||||||
|
assert "4.7k" in text
|
||||||
|
assert "22n" in text
|
||||||
|
|
||||||
|
def test_has_backanno_and_end(self):
|
||||||
|
text = sallen_key_lowpass().render()
|
||||||
|
assert ".backanno" in text
|
||||||
|
assert ".end" in text
|
||||||
|
|
||||||
|
|
||||||
|
class TestBoostConverterNetlist:
|
||||||
|
def test_returns_netlist(self):
|
||||||
|
n = boost_converter()
|
||||||
|
assert isinstance(n, Netlist)
|
||||||
|
|
||||||
|
def test_component_count(self):
|
||||||
|
n = boost_converter()
|
||||||
|
# Vin, Vgate, L1, M1, D1, Cout, Rload = 7 components
|
||||||
|
assert len(n.components) == 7
|
||||||
|
|
||||||
|
def test_render_contains_key_components(self):
|
||||||
|
text = boost_converter().render()
|
||||||
|
assert "L1" in text
|
||||||
|
assert "M1" in text
|
||||||
|
assert "D1" in text
|
||||||
|
assert "Cout" in text
|
||||||
|
assert "Rload" in text
|
||||||
|
assert "PULSE(" in text
|
||||||
|
assert ".tran" in text
|
||||||
|
|
||||||
|
def test_custom_params(self):
|
||||||
|
n = boost_converter(ind="22u", r_load="100", v_in="3.3", duty_cycle=0.6)
|
||||||
|
text = n.render()
|
||||||
|
assert "22u" in text
|
||||||
|
assert "100" in text
|
||||||
|
assert "3.3" in text
|
||||||
|
|
||||||
|
def test_has_backanno_and_end(self):
|
||||||
|
text = boost_converter().render()
|
||||||
|
assert ".backanno" in text
|
||||||
|
assert ".end" in text
|
||||||
|
|
||||||
|
|
||||||
|
class TestInstrumentationAmplifierNetlist:
|
||||||
|
def test_returns_netlist(self):
|
||||||
|
n = instrumentation_amplifier()
|
||||||
|
assert isinstance(n, Netlist)
|
||||||
|
|
||||||
|
def test_component_count(self):
|
||||||
|
n = instrumentation_amplifier()
|
||||||
|
# V1, V2, Vpos, Vneg, X1, X2, R1, R1b, Rgain, X3, R2, R3, R2b, R3b = 14
|
||||||
|
assert len(n.components) == 14
|
||||||
|
|
||||||
|
def test_render_contains_key_components(self):
|
||||||
|
text = instrumentation_amplifier().render()
|
||||||
|
assert "X1" in text
|
||||||
|
assert "X2" in text
|
||||||
|
assert "X3" in text
|
||||||
|
assert "Rgain" in text
|
||||||
|
assert "LT1001" in text
|
||||||
|
assert ".ac" in text
|
||||||
|
|
||||||
|
def test_custom_params(self):
|
||||||
|
n = instrumentation_amplifier(r1="20k", r_gain="1k")
|
||||||
|
text = n.render()
|
||||||
|
assert "20k" in text
|
||||||
|
assert "1k" in text
|
||||||
|
|
||||||
|
def test_has_backanno_and_end(self):
|
||||||
|
text = instrumentation_amplifier().render()
|
||||||
|
assert ".backanno" in text
|
||||||
|
assert ".end" in text
|
||||||
|
|
||||||
|
|
||||||
|
class TestCurrentMirrorNetlist:
|
||||||
|
def test_returns_netlist(self):
|
||||||
|
n = current_mirror()
|
||||||
|
assert isinstance(n, Netlist)
|
||||||
|
|
||||||
|
def test_component_count(self):
|
||||||
|
n = current_mirror()
|
||||||
|
# Vcc, Rref, Rload, Q1, Q2 = 5 components
|
||||||
|
assert len(n.components) == 5
|
||||||
|
|
||||||
|
def test_render_contains_key_components(self):
|
||||||
|
text = current_mirror().render()
|
||||||
|
assert "Q1" in text
|
||||||
|
assert "Q2" in text
|
||||||
|
assert "Rref" in text
|
||||||
|
assert "Rload" in text
|
||||||
|
assert "2N2222" in text
|
||||||
|
assert ".op" in text
|
||||||
|
assert ".tran" in text
|
||||||
|
|
||||||
|
def test_custom_params(self):
|
||||||
|
n = current_mirror(r_ref="4.7k", r_load="2.2k", vcc="5")
|
||||||
|
text = n.render()
|
||||||
|
assert "4.7k" in text
|
||||||
|
assert "2.2k" in text
|
||||||
|
assert "5" in text
|
||||||
|
|
||||||
|
def test_has_backanno_and_end(self):
|
||||||
|
text = current_mirror().render()
|
||||||
|
assert ".backanno" in text
|
||||||
|
assert ".end" in text
|
||||||
|
|
||||||
|
|
||||||
|
class TestTransimpedanceAmplifierNetlist:
|
||||||
|
def test_returns_netlist(self):
|
||||||
|
n = transimpedance_amplifier()
|
||||||
|
assert isinstance(n, Netlist)
|
||||||
|
|
||||||
|
def test_component_count(self):
|
||||||
|
n = transimpedance_amplifier()
|
||||||
|
# I1, Vpos, Vneg, Rf, Cf, X1 = 6 components
|
||||||
|
assert len(n.components) == 6
|
||||||
|
|
||||||
|
def test_render_contains_key_components(self):
|
||||||
|
text = transimpedance_amplifier().render()
|
||||||
|
assert "I1" in text
|
||||||
|
assert "Rf" in text
|
||||||
|
assert "Cf" in text
|
||||||
|
assert "X1" in text
|
||||||
|
assert "LT1001" in text
|
||||||
|
assert ".ac" in text
|
||||||
|
|
||||||
|
def test_custom_params(self):
|
||||||
|
n = transimpedance_amplifier(rf="1Meg", cf="0.5p", i_source="10u")
|
||||||
|
text = n.render()
|
||||||
|
assert "1Meg" in text
|
||||||
|
assert "0.5p" in text
|
||||||
|
assert "10u" in text
|
||||||
|
|
||||||
|
def test_has_backanno_and_end(self):
|
||||||
|
text = transimpedance_amplifier().render()
|
||||||
|
assert ".backanno" in text
|
||||||
|
assert ".end" in text
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ASC generator template tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestSallenKeyLowpassAsc:
|
||||||
|
def test_returns_schematic(self):
|
||||||
|
sch = generate_sallen_key_lowpass()
|
||||||
|
assert isinstance(sch, AscSchematic)
|
||||||
|
|
||||||
|
def test_render_valid(self):
|
||||||
|
text = generate_sallen_key_lowpass().render()
|
||||||
|
assert text.startswith("Version 4\n")
|
||||||
|
assert "SHEET" in text
|
||||||
|
assert len(text) > 100
|
||||||
|
|
||||||
|
def test_contains_expected_symbols(self):
|
||||||
|
text = generate_sallen_key_lowpass().render()
|
||||||
|
assert "SYMBOL res" in text
|
||||||
|
assert "SYMBOL cap" in text
|
||||||
|
assert "SYMBOL OpAmps/UniversalOpamp2" in text
|
||||||
|
assert "SYMBOL voltage" in text
|
||||||
|
|
||||||
|
def test_custom_params(self):
|
||||||
|
text = generate_sallen_key_lowpass(r1="4.7k", c1="22n").render()
|
||||||
|
assert "4.7k" in text
|
||||||
|
assert "22n" in text
|
||||||
|
|
||||||
|
def test_has_simulation_directive(self):
|
||||||
|
text = generate_sallen_key_lowpass().render()
|
||||||
|
assert ".ac" in text
|
||||||
|
|
||||||
|
|
||||||
|
class TestBoostConverterAsc:
|
||||||
|
def test_returns_schematic(self):
|
||||||
|
sch = generate_boost_converter()
|
||||||
|
assert isinstance(sch, AscSchematic)
|
||||||
|
|
||||||
|
def test_render_valid(self):
|
||||||
|
text = generate_boost_converter().render()
|
||||||
|
assert text.startswith("Version 4\n")
|
||||||
|
assert "SHEET" in text
|
||||||
|
assert len(text) > 100
|
||||||
|
|
||||||
|
def test_contains_expected_symbols(self):
|
||||||
|
text = generate_boost_converter().render()
|
||||||
|
assert "SYMBOL ind" in text
|
||||||
|
assert "SYMBOL nmos" in text
|
||||||
|
assert "SYMBOL diode" in text
|
||||||
|
assert "SYMBOL cap" in text
|
||||||
|
assert "SYMBOL res" in text
|
||||||
|
|
||||||
|
def test_custom_params(self):
|
||||||
|
text = generate_boost_converter(ind="22u", r_load="100").render()
|
||||||
|
assert "22u" in text
|
||||||
|
assert "100" in text
|
||||||
|
|
||||||
|
def test_has_simulation_directive(self):
|
||||||
|
text = generate_boost_converter().render()
|
||||||
|
assert ".tran" in text
|
||||||
|
|
||||||
|
|
||||||
|
class TestInstrumentationAmpAsc:
|
||||||
|
def test_returns_schematic(self):
|
||||||
|
sch = generate_instrumentation_amp()
|
||||||
|
assert isinstance(sch, AscSchematic)
|
||||||
|
|
||||||
|
def test_render_valid(self):
|
||||||
|
text = generate_instrumentation_amp().render()
|
||||||
|
assert text.startswith("Version 4\n")
|
||||||
|
assert "SHEET" in text
|
||||||
|
assert len(text) > 100
|
||||||
|
|
||||||
|
def test_contains_expected_symbols(self):
|
||||||
|
text = generate_instrumentation_amp().render()
|
||||||
|
# Should have 3 opamps
|
||||||
|
assert text.count("SYMBOL OpAmps/UniversalOpamp2") == 3
|
||||||
|
# Should have multiple resistors
|
||||||
|
assert text.count("SYMBOL res") >= 7
|
||||||
|
|
||||||
|
def test_custom_params(self):
|
||||||
|
text = generate_instrumentation_amp(r1="20k", r_gain="1k").render()
|
||||||
|
assert "20k" in text
|
||||||
|
assert "1k" in text
|
||||||
|
|
||||||
|
def test_has_simulation_directive(self):
|
||||||
|
text = generate_instrumentation_amp().render()
|
||||||
|
assert ".ac" in text
|
||||||
|
|
||||||
|
|
||||||
|
class TestCurrentMirrorAsc:
|
||||||
|
def test_returns_schematic(self):
|
||||||
|
sch = generate_current_mirror()
|
||||||
|
assert isinstance(sch, AscSchematic)
|
||||||
|
|
||||||
|
def test_render_valid(self):
|
||||||
|
text = generate_current_mirror().render()
|
||||||
|
assert text.startswith("Version 4\n")
|
||||||
|
assert "SHEET" in text
|
||||||
|
assert len(text) > 100
|
||||||
|
|
||||||
|
def test_contains_expected_symbols(self):
|
||||||
|
text = generate_current_mirror().render()
|
||||||
|
# Should have 2 NPN transistors
|
||||||
|
assert text.count("SYMBOL npn") == 2
|
||||||
|
assert "SYMBOL res" in text
|
||||||
|
assert "SYMBOL voltage" in text
|
||||||
|
|
||||||
|
def test_custom_params(self):
|
||||||
|
text = generate_current_mirror(r_ref="4.7k", r_load="2.2k").render()
|
||||||
|
assert "4.7k" in text
|
||||||
|
assert "2.2k" in text
|
||||||
|
|
||||||
|
def test_has_simulation_directive(self):
|
||||||
|
text = generate_current_mirror().render()
|
||||||
|
assert ".op" in text
|
||||||
|
assert ".tran" in text
|
||||||
|
|
||||||
|
|
||||||
|
class TestTransimpedanceAmpAsc:
|
||||||
|
def test_returns_schematic(self):
|
||||||
|
sch = generate_transimpedance_amp()
|
||||||
|
assert isinstance(sch, AscSchematic)
|
||||||
|
|
||||||
|
def test_render_valid(self):
|
||||||
|
text = generate_transimpedance_amp().render()
|
||||||
|
assert text.startswith("Version 4\n")
|
||||||
|
assert "SHEET" in text
|
||||||
|
assert len(text) > 100
|
||||||
|
|
||||||
|
def test_contains_expected_symbols(self):
|
||||||
|
text = generate_transimpedance_amp().render()
|
||||||
|
assert "SYMBOL OpAmps/UniversalOpamp2" in text
|
||||||
|
assert "SYMBOL res" in text
|
||||||
|
assert "SYMBOL cap" in text
|
||||||
|
|
||||||
|
def test_custom_params(self):
|
||||||
|
text = generate_transimpedance_amp(rf="1Meg", cf="0.5p").render()
|
||||||
|
assert "1Meg" in text
|
||||||
|
assert "0.5p" in text
|
||||||
|
|
||||||
|
def test_has_simulation_directive(self):
|
||||||
|
text = generate_transimpedance_amp().render()
|
||||||
|
assert ".ac" in text
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Parametrized cross-cutting tests for all 5 new netlist templates
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestNewNetlistTemplatesCommon:
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"factory",
|
||||||
|
[
|
||||||
|
sallen_key_lowpass,
|
||||||
|
boost_converter,
|
||||||
|
instrumentation_amplifier,
|
||||||
|
current_mirror,
|
||||||
|
transimpedance_amplifier,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_returns_netlist(self, factory):
|
||||||
|
assert isinstance(factory(), Netlist)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"factory",
|
||||||
|
[
|
||||||
|
sallen_key_lowpass,
|
||||||
|
boost_converter,
|
||||||
|
instrumentation_amplifier,
|
||||||
|
current_mirror,
|
||||||
|
transimpedance_amplifier,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_has_backanno_and_end(self, factory):
|
||||||
|
text = factory().render()
|
||||||
|
assert ".backanno" in text
|
||||||
|
assert ".end" in text
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"factory",
|
||||||
|
[
|
||||||
|
sallen_key_lowpass,
|
||||||
|
boost_converter,
|
||||||
|
instrumentation_amplifier,
|
||||||
|
current_mirror,
|
||||||
|
transimpedance_amplifier,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_has_components(self, factory):
|
||||||
|
n = factory()
|
||||||
|
assert len(n.components) > 0
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"factory",
|
||||||
|
[
|
||||||
|
sallen_key_lowpass,
|
||||||
|
boost_converter,
|
||||||
|
instrumentation_amplifier,
|
||||||
|
current_mirror,
|
||||||
|
transimpedance_amplifier,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_has_sim_directive(self, factory):
|
||||||
|
text = factory().render()
|
||||||
|
sim_types = [".tran", ".ac", ".dc", ".op", ".noise", ".tf"]
|
||||||
|
assert any(sim in text.lower() for sim in sim_types), (
|
||||||
|
f"No simulation directive found in {factory.__name__}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Parametrized cross-cutting tests for all 5 new ASC templates
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestNewAscTemplatesCommon:
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"factory",
|
||||||
|
[
|
||||||
|
generate_sallen_key_lowpass,
|
||||||
|
generate_boost_converter,
|
||||||
|
generate_instrumentation_amp,
|
||||||
|
generate_current_mirror,
|
||||||
|
generate_transimpedance_amp,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_returns_schematic(self, factory):
|
||||||
|
assert isinstance(factory(), AscSchematic)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"factory",
|
||||||
|
[
|
||||||
|
generate_sallen_key_lowpass,
|
||||||
|
generate_boost_converter,
|
||||||
|
generate_instrumentation_amp,
|
||||||
|
generate_current_mirror,
|
||||||
|
generate_transimpedance_amp,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_render_nonempty(self, factory):
|
||||||
|
text = factory().render()
|
||||||
|
assert len(text) > 50
|
||||||
|
assert "SYMBOL" in text
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"factory",
|
||||||
|
[
|
||||||
|
generate_sallen_key_lowpass,
|
||||||
|
generate_boost_converter,
|
||||||
|
generate_instrumentation_amp,
|
||||||
|
generate_current_mirror,
|
||||||
|
generate_transimpedance_amp,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_has_version_and_sheet(self, factory):
|
||||||
|
text = factory().render()
|
||||||
|
assert "Version 4" in text
|
||||||
|
assert "SHEET" in text
|
||||||
@ -3,7 +3,7 @@
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from mcp_ltspice.raw_parser import RawFile, Variable, _detect_run_boundaries
|
from mcp_ltspice.raw_parser import _detect_run_boundaries
|
||||||
|
|
||||||
|
|
||||||
class TestDetectRunBoundaries:
|
class TestDetectRunBoundaries:
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
"""Tests for stability module: gain margin, phase margin from loop gain data."""
|
"""Tests for stability module: gain margin, phase margin from loop gain data."""
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pytest
|
|
||||||
|
|
||||||
from mcp_ltspice.stability import (
|
from mcp_ltspice.stability import (
|
||||||
compute_gain_margin,
|
compute_gain_margin,
|
||||||
|
|||||||
199
tests/test_svg_plot.py
Normal file
199
tests/test_svg_plot.py
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
"""Tests for the pure-SVG waveform plot generation module."""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from mcp_ltspice.svg_plot import (
|
||||||
|
_format_freq,
|
||||||
|
_nice_ticks,
|
||||||
|
plot_bode,
|
||||||
|
plot_spectrum,
|
||||||
|
plot_timeseries,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def sine_wave():
|
||||||
|
"""A 1 kHz sine wave sampled at 100 kHz for 10 ms."""
|
||||||
|
t = np.linspace(0, 0.01, 1000, endpoint=False)
|
||||||
|
v = np.sin(2 * np.pi * 1000 * t)
|
||||||
|
return t, v
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def bode_data():
|
||||||
|
"""Simple first-order lowpass Bode response (fc = 1 kHz)."""
|
||||||
|
freq = np.logspace(1, 6, 500)
|
||||||
|
fc = 1e3
|
||||||
|
mag_db = -10 * np.log10(1 + (freq / fc) ** 2)
|
||||||
|
phase_deg = -np.degrees(np.arctan(freq / fc))
|
||||||
|
return freq, mag_db, phase_deg
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def spectrum_data():
|
||||||
|
"""Synthetic FFT spectrum with a peak at 1 kHz."""
|
||||||
|
freq = np.logspace(1, 5, 300)
|
||||||
|
mag_db = -60 * np.ones_like(freq)
|
||||||
|
peak_idx = np.argmin(np.abs(freq - 1e3))
|
||||||
|
mag_db[peak_idx] = 0.0
|
||||||
|
return freq, mag_db
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Timeseries
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestPlotTimeseries:
|
||||||
|
def test_basic(self, sine_wave):
|
||||||
|
"""A simple sine wave produces a valid SVG with expected elements."""
|
||||||
|
t, v = sine_wave
|
||||||
|
svg = plot_timeseries(t, v)
|
||||||
|
assert svg.startswith("<svg")
|
||||||
|
assert "<path" in svg
|
||||||
|
assert "Time Domain" in svg
|
||||||
|
|
||||||
|
def test_empty_arrays(self):
|
||||||
|
"""Empty input should not crash and should return a valid SVG."""
|
||||||
|
svg = plot_timeseries([], [])
|
||||||
|
assert svg.startswith("<svg")
|
||||||
|
assert "</svg>" in svg
|
||||||
|
|
||||||
|
def test_custom_title_and_labels(self, sine_wave):
|
||||||
|
"""Custom title and ylabel should appear in the SVG output."""
|
||||||
|
t, v = sine_wave
|
||||||
|
svg = plot_timeseries(t, v, title="My Signal", ylabel="Current (A)")
|
||||||
|
assert "My Signal" in svg
|
||||||
|
assert "Current (A)" in svg
|
||||||
|
|
||||||
|
def test_svg_dimensions(self, sine_wave):
|
||||||
|
"""The width/height attributes should match the requested size."""
|
||||||
|
t, v = sine_wave
|
||||||
|
svg = plot_timeseries(t, v, width=1024, height=768)
|
||||||
|
assert 'width="1024"' in svg
|
||||||
|
assert 'height="768"' in svg
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Bode
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestPlotBode:
|
||||||
|
def test_magnitude_only(self, bode_data):
|
||||||
|
"""Bode plot without phase produces a valid SVG with one trace."""
|
||||||
|
freq, mag_db, _ = bode_data
|
||||||
|
svg = plot_bode(freq, mag_db)
|
||||||
|
assert svg.startswith("<svg")
|
||||||
|
assert "<path" in svg
|
||||||
|
assert "Bode Plot" in svg
|
||||||
|
|
||||||
|
def test_with_phase(self, bode_data):
|
||||||
|
"""Bode plot with phase should contain two <path> elements (mag + phase)."""
|
||||||
|
freq, mag_db, phase_deg = bode_data
|
||||||
|
svg = plot_bode(freq, mag_db, phase_deg)
|
||||||
|
assert svg.startswith("<svg")
|
||||||
|
# Two traces -- magnitude and phase
|
||||||
|
assert svg.count("<path") >= 2
|
||||||
|
# Phase subplot label
|
||||||
|
assert "Phase (deg)" in svg
|
||||||
|
|
||||||
|
def test_log_axis_ticks(self, bode_data):
|
||||||
|
"""Log frequency axis should contain tick labels at powers of 10."""
|
||||||
|
freq, mag_db, _ = bode_data
|
||||||
|
svg = plot_bode(freq, mag_db)
|
||||||
|
# Expect at least some frequency labels like "100", "1k", "10k", "100k"
|
||||||
|
found = sum(1 for lbl in ("100", "1k", "10k", "100k") if lbl in svg)
|
||||||
|
assert found >= 2, f"Expected log tick labels in SVG; found {found}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Spectrum
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestPlotSpectrum:
|
||||||
|
def test_basic(self, spectrum_data):
|
||||||
|
"""A simple spectrum produces a valid SVG."""
|
||||||
|
freq, mag_db = spectrum_data
|
||||||
|
svg = plot_spectrum(freq, mag_db)
|
||||||
|
assert svg.startswith("<svg")
|
||||||
|
assert "<path" in svg
|
||||||
|
assert "FFT Spectrum" in svg
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestNiceTicks:
|
||||||
|
def test_simple_range(self):
|
||||||
|
ticks = _nice_ticks(0, 10, n_ticks=5)
|
||||||
|
assert len(ticks) >= 3
|
||||||
|
assert ticks[0] <= 0
|
||||||
|
assert ticks[-1] >= 10
|
||||||
|
|
||||||
|
def test_equal_values(self):
|
||||||
|
"""When vmin == vmax, return a single-element list."""
|
||||||
|
ticks = _nice_ticks(5, 5)
|
||||||
|
assert ticks == [5]
|
||||||
|
|
||||||
|
def test_negative_range(self):
|
||||||
|
ticks = _nice_ticks(-100, -20, n_ticks=5)
|
||||||
|
assert ticks[0] <= -100
|
||||||
|
assert ticks[-1] >= -20
|
||||||
|
|
||||||
|
def test_small_range(self):
|
||||||
|
ticks = _nice_ticks(0.001, 0.005, n_ticks=5)
|
||||||
|
assert all(0 <= t <= 0.01 for t in ticks)
|
||||||
|
|
||||||
|
|
||||||
|
class TestFormatFreq:
|
||||||
|
def test_hz(self):
|
||||||
|
assert _format_freq(1) == "1"
|
||||||
|
assert _format_freq(10) == "10"
|
||||||
|
assert _format_freq(100) == "100"
|
||||||
|
|
||||||
|
def test_khz(self):
|
||||||
|
assert _format_freq(1000) == "1k"
|
||||||
|
assert _format_freq(10000) == "10k"
|
||||||
|
assert _format_freq(100000) == "100k"
|
||||||
|
|
||||||
|
def test_mhz(self):
|
||||||
|
assert _format_freq(1e6) == "1M"
|
||||||
|
assert _format_freq(10e6) == "10M"
|
||||||
|
|
||||||
|
def test_ghz(self):
|
||||||
|
assert _format_freq(1e9) == "1G"
|
||||||
|
|
||||||
|
def test_zero(self):
|
||||||
|
assert _format_freq(0) == "0"
|
||||||
|
|
||||||
|
|
||||||
|
class TestSvgDimensions:
|
||||||
|
def test_timeseries_dimensions(self):
|
||||||
|
t = np.linspace(0, 1, 100)
|
||||||
|
v = np.sin(t)
|
||||||
|
svg = plot_timeseries(t, v, width=640, height=480)
|
||||||
|
assert 'width="640"' in svg
|
||||||
|
assert 'height="480"' in svg
|
||||||
|
|
||||||
|
def test_bode_dimensions(self):
|
||||||
|
freq = np.logspace(1, 5, 50)
|
||||||
|
mag = np.zeros(50)
|
||||||
|
svg = plot_bode(freq, mag, width=900, height=600)
|
||||||
|
assert 'width="900"' in svg
|
||||||
|
assert 'height="600"' in svg
|
||||||
|
|
||||||
|
def test_spectrum_dimensions(self):
|
||||||
|
freq = np.logspace(1, 5, 50)
|
||||||
|
mag = np.zeros(50)
|
||||||
|
svg = plot_spectrum(freq, mag, width=1000, height=500)
|
||||||
|
assert 'width="1000"' in svg
|
||||||
|
assert 'height="500"' in svg
|
||||||
@ -1,13 +1,11 @@
|
|||||||
"""Tests for touchstone module: format conversion, parsing, S-parameter extraction."""
|
"""Tests for touchstone module: format conversion, parsing, S-parameter extraction."""
|
||||||
|
|
||||||
import re
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from mcp_ltspice.touchstone import (
|
from mcp_ltspice.touchstone import (
|
||||||
TouchstoneData,
|
|
||||||
_detect_ports,
|
_detect_ports,
|
||||||
_to_complex,
|
_to_complex,
|
||||||
get_s_parameter,
|
get_s_parameter,
|
||||||
|
|||||||
@ -5,13 +5,11 @@ import pytest
|
|||||||
|
|
||||||
from mcp_ltspice.waveform_expr import (
|
from mcp_ltspice.waveform_expr import (
|
||||||
WaveformCalculator,
|
WaveformCalculator,
|
||||||
_Token,
|
|
||||||
_tokenize,
|
_tokenize,
|
||||||
_TokenType,
|
_TokenType,
|
||||||
evaluate_expression,
|
evaluate_expression,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Tokenizer tests
|
# Tokenizer tests
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user