SMU IV sweep simulation: rehearse compliance, sweeps and the buffer before bench time
By Alex Hernandez · · 14 min read


To rehearse an SMU IV sweep before the instrument arrives, wire the simulated SMU to a resistor and run the sweep script against it. The simulation forces voltage or current, clamps at compliance on both signs, runs linear, log and list sweeps from the load line, and fills a buffer the script reads back.
EdgeSim is the open-source bench simulator from Galois Labs: simulated instruments wired into simulated benches that PyVISA scripts, pytest, galois-edge and AI agents can't tell from real hardware. This guide drives its source-measure unit, galois_sim-smu-1, from PyVISA. Every snippet and output ran against EdgeSim 0.2.0, on the shipped bench smu.bench.yaml with one address line added. EdgeSim's open-source release, with the edgesim package and its example benches, is coming soon. Behavior claims come from the profile, its behavior plugin and their tests; the Keithley fact comes from the 2450 reference manual (Rev. D). The simulated SMU is one reference instrument that speaks a common SCPI source-measure convention, not one model's dialect; Keithley SMU automation with Python covers a real 2450 and 2400.
What does an SMU simulation need to model?
An SMU is a source with a limit and a meter on the same terminals. A real SMU clamps the source to keep it within the set limits, as the 2450 reference manual cited above describes. Into 1 kΩ, 0.8 V reads 0.8 mA until the limit drops below that. Past it the reading sits at the limit and the load voltage falls to limit times resistance. A script that never asks which regime it is in records a plausible number either way.
The simulated SMU models the parts a sweep script touches:
| SMU behavior | In the simulated SMU |
|---|---|
| Force voltage or current | :SOURce:FUNCtion, :SOURce:VOLTage, :SOURce:CURRent, up to 210 V and 1.05 A |
| Compliance in all four quadrants | The limit on the quantity that is not forced: :SENSe:CURRent:PROTection and :SENSe:VOLTage:PROTection, clamping both signs |
| Which loop is in control | :OUTPut:REGulation? answers CV, CC or OFF; TRIPped? queries; STAT 8; condition register bit 14 |
| Integration time | NPLC from 0.01 to 10, auto-zero, 50 or 60 Hz line frequency |
| Sweeps | Linear, log and list, voltage or current, up to 2500 points |
| Measurement buffer | :READ?, :MEASure?, :FETCh?, :INITiate, :TRACe:POINts:ACTual?, :TRACe:CLEar |
| Terminal envelope | 1.05 A up to 21 V and 105 mA above it, about 22 W; a fixed measurement range holds 105 % of its value |
| Faults on a schedule | World.inject: timeouts, garbled replies, a dead link |
A command spelling that a real unit gives differently, such as the 2450's SOUR:VOLT:ILIM for the current limit, is a profile matter, so the logic of a sweep carries over and the spellings are the one thing to map. The EdgeSim announcement places the SMU among the other instrument classes.
How do I wire the simulated SMU to a resistor?
A bench is a topology file, the shape the Galois cloud editor draws, plus ext.sim bags that name each profile, address and DUT model. The shipped SMU bench wires the SMU's HI terminal to a 1 kΩ resistor and puts a DMM on the same net. It starts as a 5 V source with a 10 mA limit and the output on, with a nine-point 0 to 8 V linear sweep configured. The one edit gives the SMU the address a real bench would use:
- id: inst-smu-sim1
kind: instrument
label: smu
position: {x: -320, y: 45}
instrumentId: sim-smu-1
instrumentModel: SIM-SMU-1
functionRole: smu
ext:
sim:
profile: galois_sim-smu-1
address: "TCPIP0::192.168.1.60::5025::SOCKET" # the one added line
state:
source.voltage.level: 5.0
compliance.current.limit: 0.01
output.enabled: true
sweep.mode: LINear
sweep.start: 0.0
sweep.stop: 8.0
sweep.points: 9
source.delay: 0.001edgesim validate bench/smu.bench.yaml prints nothing and exits 0. A wrong port name, profile key or DUT parameter fails there, before a test runs. The terminal sees the resistor and the DMM's 10 MΩ input in parallel, so the load is 999.9 Ω and every resistance column below reads 999.9.
How do I force voltage or current and see compliance?
The helper module opens the SMU, parses the buffer and guards the output. table turns reply text into an array with one row per reading and five columns, VOLT,CURR,RES,TIME,STAT:
"""IV sweeps on a source-measure unit: the instrument's own sweep, and a stepped one for loads it refuses."""
from contextlib import contextmanager
import numpy as np
import pyvisa
ADDRESS = "TCPIP0::192.168.1.60::5025::SOCKET"
COMPLIANCE = 8 # STAT bit 3: the reading was taken with a limit in control
def open_smu(rm: pyvisa.ResourceManager, address: str = ADDRESS):
"""Open the SMU and start from output off."""
smu = rm.open_resource(address, read_termination="\n", write_termination="\n")
smu.write(":OUTPut:STATe OFF")
return smu
class SmuRefused(RuntimeError):
"""The SMU answered a query with nothing and queued an error."""
def table(text: str) -> np.ndarray:
"""Buffer text as an (N, 5) array: VOLT, CURR, RES, TIME, STAT."""
return np.array(text.split(","), dtype=float).reshape(-1, 5)
def read_buffer(smu, query: str = ":READ?") -> np.ndarray:
text = smu.query(query)
if not text: # a refusal answers nothing; the reason is in the error queue
raise SmuRefused(smu.query(":SYSTem:ERRor?"))
return table(text)
@contextmanager
def output_on(smu):
"""Output on for the block and off on every exit the link survives."""
smu.write(":OUTPut:STATe ON")
try:
yield
finally:
smu.write(":OUTPut:STATe OFF")
def start(smu, ilimit: float) -> None:
"""Reset, force voltage, set the current limit: the limit goes in before the output goes on."""
smu.write("*RST") # the limit is back to 105 uA and the output is off
smu.write(":SOURce:FUNCtion VOLTage")
smu.write(f":SENSe:CURRent:PROTection {ilimit}")
def sweep(smu, config: str, ilimit: float) -> np.ndarray:
"""The instrument's own sweep. `config` is one :SOURce:SWEep line; the buffer comes back as a table."""
start(smu, ilimit)
smu.write(config)
with output_on(smu):
return read_buffer(smu)
def stepped_sweep(smu, levels, ilimit: float) -> np.ndarray:
"""One level and one reading per point: any load, at the price of a round trip per point."""
start(smu, ilimit)
rows = np.empty((len(levels), 5))
with output_on(smu):
for k, level in enumerate(levels):
smu.write(f":SOURce:VOLTage {level:.6g}")
rows[k] = read_buffer(smu, ":MEASure?")[0]
return rowsThe first script starts from the bench's 5 V and 10 mA, lowers the limit to 4 mA, pushes the setpoint to both signs, then forces current into the same resistor under a voltage limit:
import pyvisa
from bench.smu_iv import open_smu, table
rm = pyvisa.ResourceManager("bench/smu.bench.yaml@edgesim")
smu = open_smu(rm, "TCPIP0::192.168.1.60::5025::SOCKET")
dmm = rm.open_resource("TCPIP0::192.168.1.61::5025::SOCKET", read_termination="\n", write_termination="\n")
smu.write(":OUTPut:STATe ON") # the bench starts at 5 V with a 10 mA limit
def show(label: str) -> None:
volt, curr, _res, _time, stat = table(smu.query(":MEASure?"))[0]
loop = smu.query(":OUTPut:REGulation?")
print(f"{label:30s} {volt:8.4f} V {curr * 1e3:8.4f} mA {loop} STAT {stat:.0f}")
print(smu.query("*IDN?"))
show("5 V, 10 mA limit")
smu.write(":SENSe:CURRent:PROTection 0.004")
show("limit lowered to 4 mA")
print(" tripped:", smu.query(":SENSe:CURRent:PROTection:TRIPped?"), " condition:", smu.query(":STATus:MEASurement:CONDition?"),
" DMM on the net:", f"{float(dmm.query('MEAS:VOLT:DC?')):.4f} V")
smu.write(":SOURce:VOLTage 8")
show("setpoint raised to 8 V")
smu.write(":SOURce:VOLTage -8")
show("setpoint -8 V")
smu.write(":SOURce:FUNCtion CURRent")
smu.write(":SOURce:CURRent 0.002")
smu.write(":SENSe:VOLTage:PROTection 1.5")
show("force 2 mA, 1.5 V limit")
print(" tripped:", smu.query(":SENSe:VOLTage:PROTection:TRIPped?"), " condition:", smu.query(":STATus:MEASurement:CONDition?"))
smu.write(":SOURce:CURRent -0.002")
show("force -2 mA")
smu.write(":OUTPut:STATe OFF")
show("output off")
print("errors:", smu.query(":SYSTem:ERRor?"))GALOIS,SIM-SMU-1,SIM00001,edgesim-1
5 V, 10 mA limit 5.0000 V 5.0005 mA CV STAT 0
limit lowered to 4 mA 3.9996 V 4.0000 mA CC STAT 8
tripped: 1 condition: 16384 DMM on the net: 3.9996 V
setpoint raised to 8 V 3.9996 V 4.0000 mA CC STAT 8
setpoint -8 V -3.9996 V -4.0000 mA CC STAT 8
force 2 mA, 1.5 V limit 1.5000 V 1.5002 mA CV STAT 8
tripped: 1 condition: 16384
force -2 mA -1.5000 V -1.5002 mA CV STAT 8
output off 0.0000 V 0.0000 mA OFF STAT 0
errors: 0,"No error"Lowering the current limit to 4 mA under a 5 V setpoint into 1 kΩ steps the graph's trace down onto the limit line and turns LOOP to CC with the I limit flag lit at 4.000 V and 4.000 mA, raising the setpoint to 8 V leaves the line flat, and the condition register reads 16384.
The GRAPH box plots the measured current against virtual time while the source is fixed, with the limit as a dashed line, so the step to 4.000 mA scrolls left across its ten-second window as the clock runs.
- Force voltage, limit current. At 5 V into 999.9 Ω the current is 5.0005 mA, below the 10 mA limit, so the voltage loop holds (
CV). A 4 mA limit is below what 5 V wants, so the current loop takes over (CC), the terminal falls to 4 mA × 999.9 Ω = 3.9996 V, and the DMM on the net agrees. Raising the setpoint to 8 V changes nothing at the terminal. - Force current, limit voltage. The roles swap. 2 mA into 999.9 Ω wants 2.0 V, the limit is 1.5 V, so the voltage loop takes over: 1.5000 V and 1.5002 mA, with
:SENSe:VOLTage:PROTection:TRIPped?answering 1. - Both signs. −8 V clamps at −4 mA and −2 mA clamps at −1.5 V, with the same flags.
- Three places say it. The regulation query answers
CVorCC, the reading's STAT element carries 8, and the condition register carries bit 14 (16384). The error queue stays empty: compliance is a state, not an error.
A resistor lives in quadrants 1 and 3. The clamp also holds where the load pushes current into the SMU, quadrants 2 and 4: EdgeSim's tests wire a 12 V supply behind a 1 kΩ resistor, and an SMU forcing 0 V with a 1 mA limit reads 11.000 V and −1.000 mA in compliance. That wiring needs a source inside the load; the shipped DUT models are passive.
What does NPLC change?
NPLC is the integration time in power-line cycles, one setting shared by the voltage, current and resistance functions. The simulated SMU accepts 0.01 to 10 and answers -222 beyond. The setting lands in each reading's TIME element:
import pyvisa
from bench.smu_iv import open_smu, start, table
rm = pyvisa.ResourceManager("bench/smu.bench.yaml@edgesim")
smu = open_smu(rm)
start(smu, 10e-3)
smu.write(":SOURce:VOLTage 1")
smu.write(":OUTPut:STATe ON")
for line_hz in (60, 50):
smu.write(f":SYSTem:LFRequency {line_hz}")
for nplc in (0.01, 1, 10):
smu.write(f":SENSe:CURRent:NPLCycles {nplc}")
volt, curr, _res, elapsed, _stat = table(smu.query(":MEASure?"))[0]
print(f"{line_hz} Hz NPLC {nplc:5} I = {curr * 1e3:.4f} mA TIME = {elapsed * 1e3:8.3f} ms")
print("shared across functions:", smu.query(":SENSe:VOLTage:NPLCycles?"), smu.query(":SENSe:RESistance:NPLCycles?"))
smu.write(":SENSe:CURRent:NPLCycles 11")
print("NPLC 11:", smu.query(":SYSTem:ERRor?"))
smu.write(":OUTPut:STATe OFF")60 Hz NPLC 0.01 I = 1.0001 mA TIME = 2.905 ms
60 Hz NPLC 1 I = 1.0001 mA TIME = 52.405 ms
60 Hz NPLC 10 I = 1.0001 mA TIME = 502.405 ms
50 Hz NPLC 0.01 I = 1.0001 mA TIME = 3.005 ms
50 Hz NPLC 1 I = 1.0001 mA TIME = 62.405 ms
50 Hz NPLC 10 I = 1.0001 mA TIME = 602.405 ms
shared across functions: +1.000000E+01 +1.000000E+01
NPLC 11: -222,"Data out of range"TIME is modelled instrument time, 50 µs + delay + phases × (NPLC / line frequency + 185 µs) + firmware. With auto-zero on, a reading has three phases and 1.8 ms of firmware for a voltage source. At 60 Hz and NPLC 1 that is 0.05 ms + 3 × (16.667 ms + 0.185 ms) + 1.8 ms = 52.405 ms, the second line above. With auto-zero off it is one phase and 0.3 ms. The current reads 1.0001 mA at every setting: here NPLC changes the TIME column and nothing else, so noise-versus-speed choices still need the real unit. The virtual clock does not move for a reading either; TIME is carried in the reading and nowhere else.
How do linear, log and list sweeps fill the buffer?
Three configure commands each set the sweep mode and its levels, and :READ? runs the sweep and returns the buffer. The levels come from a load line: a plugin cannot ask the World to re-solve at every level, so the SMU solves three or four operating points of the real net inside one command, fits the line through them, and computes each row from the line with the compliance clamp applied on both signs. That is exact for a linear load: a resistor, a resistor in series with another instrument's source, or a constant-current load inside its regulation range.
| Command | Meaning |
|---|---|
:SOURce:SWEep:VOLTage:LINear start,stop,points,delay | Evenly spaced levels; the current variant is :SOURce:SWEep:CURRent:LINear |
:SOURce:SWEep:VOLTage:LOGarithmic start,stop,points,delay | Geometric levels; start and stop non-zero and of one sign |
:SOURce:SWEep:VOLTage:LIST "v1,v2,..." | Any order and sign; a quoted string, and the unquoted form answers -108 |
:READ? | Runs one reading, or the configured sweep, and returns the buffer rows |
:FETCh? | Returns the last buffer without measuring; -230 when it is empty |
:MEASure? | One reading with the configured source function, in any sweep mode |
:INITiate, :TRACe:POINts:ACTual?, :TRACe:CLEar | Fill without a reply, count the rows, empty the buffer |
Every :MEASure:<function>? also answers all five elements, in the order VOLT,CURR,RES,TIME,STAT, whichever function was asked. This script runs a five-point sweep and prints the rows as the SMU sent them, then summarizes one of each kind:
import numpy as np
import pyvisa
from bench.smu_iv import COMPLIANCE, open_smu, sweep
rm = pyvisa.ResourceManager("bench/smu.bench.yaml@edgesim")
smu = open_smu(rm)
# Five points, read as the SMU sends them: five elements per point, one point per line here.
rows = sweep(smu, ":SOURce:SWEep:VOLTage:LINear 0,0.8,5,0", ilimit=450e-6)
text = smu.query(":FETCh?")
print("FETCh? text, first point:", text.split(",")[:5])
print("points in the buffer:", smu.query(":TRACe:POINts:ACTual?"))
print("VOLT CURR RES TIME STAT")
for row in rows:
print(" ".join(f"{x:+.5E}" for x in row))
for name, config, ilimit in (
("linear 0 to 0.8 V, 41 points, 450 uA", ":SOURce:SWEep:VOLTage:LINear 0,0.8,41,0", 450e-6),
("log 0.01 to 10 V, 7 points, 4 mA", ":SOURce:SWEep:VOLTage:LOGarithmic 0.01,10,7,0", 4e-3),
('list "-8,-2,0,2,8", 4 mA', ':SOURce:SWEep:VOLTage:LIST "-8,-2,0,2,8"', 4e-3),
):
rows = sweep(smu, config, ilimit)
clamped = np.flatnonzero(rows[:, 4] == COMPLIANCE)
print(f"\n{name}: {len(rows)} points, {len(clamped)} in compliance" + (f", first at point {clamped[0]}" if len(clamped) else ""))
for k in sorted({0, 1, len(rows) // 2, clamped[0] if len(clamped) else 0, len(rows) - 1}) if len(rows) > 7 else range(len(rows)):
v, i, _r, _t, s = rows[k]
print(f" {k:2d} {v:+9.4f} V {i * 1e3:+8.4f} mA STAT {s:.0f}")
print("\nerrors:", smu.query(":SYSTem:ERRor?"))
smu.write(":TRACe:CLEAR")
print("after :TRACe:CLEar, points:", smu.query(":TRACe:POINts:ACTual?"))FETCh? text, first point: ['+0.000000E+00', '+0.000000E+00', '+9.910000E+37', '+5.240500E-02', '+0.000000E+00']
points in the buffer: 5
VOLT CURR RES TIME STAT
+0.00000E+00 +0.00000E+00 +9.91000E+37 +5.24050E-02 +0.00000E+00
+2.00000E-01 +2.00020E-04 +9.99900E+02 +1.04810E-01 +0.00000E+00
+4.00000E-01 +4.00040E-04 +9.99900E+02 +1.57215E-01 +0.00000E+00
+4.49955E-01 +4.50000E-04 +9.99900E+02 +2.09620E-01 +8.00000E+00
+4.49955E-01 +4.50000E-04 +9.99900E+02 +2.62025E-01 +8.00000E+00
linear 0 to 0.8 V, 41 points, 450 uA: 41 points, 18 in compliance, first at point 23
0 +0.0000 V +0.0000 mA STAT 0
1 +0.0200 V +0.0200 mA STAT 0
20 +0.4000 V +0.4000 mA STAT 0
23 +0.4500 V +0.4500 mA STAT 8
40 +0.4500 V +0.4500 mA STAT 8
log 0.01 to 10 V, 7 points, 4 mA: 7 points, 1 in compliance, first at point 6
0 +0.0100 V +0.0100 mA STAT 0
1 +0.0316 V +0.0316 mA STAT 0
2 +0.1000 V +0.1000 mA STAT 0
3 +0.3162 V +0.3163 mA STAT 0
4 +1.0000 V +1.0001 mA STAT 0
5 +3.1623 V +3.1626 mA STAT 0
6 +3.9996 V +4.0000 mA STAT 8
list "-8,-2,0,2,8", 4 mA: 5 points, 2 in compliance, first at point 0
0 -3.9996 V -4.0000 mA STAT 8
1 -2.0000 V -2.0002 mA STAT 0
2 +0.0000 V +0.0000 mA STAT 0
3 +2.0000 V +2.0002 mA STAT 0
4 +3.9996 V +4.0000 mA STAT 8
errors: 0,"No error"
after :TRACe:CLEar, points: 0The console's GRAPH box draws the same buffer as an I-V curve. With the 450 µA limit and the 41-point sweep configured, :READ? replaces the strip with a line that rises 1 mA per volt and ends on the dashed limit at 450 µA and 0.450 V, where the 18 clamped rows share one point:
With a 450 µA limit and a 41-point sweep configured, :READ? turns the graph from current against time into an I-V curve that rises at 1 mA per volt and ends on the dashed limit line at 450 µA and 0.450 V.
What happens when the load is not a line?
A sweep into a load that is not one line over the sweep is refused. The second bench wires the SMU's HI terminal to an electronic load in constant-current mode. The load sinks its 2 mA setting at any input above about 0.1 mV, where its 0.0367 Ω minimum operating resistance stops limiting it, and nothing at 0 V, so a 0 to 8 V sweep starts on the knee of its regulation:
# SMU HI -> electronic load IN. The load is constant-current: it sinks 2 mA at any input above about 0.1 mV.
version: 1
ext:
sim: {name: smu-eload, seed: 5, clock: {mode: stepped}}
nodes:
- id: inst-smu
kind: instrument
label: smu
position: {x: 0, y: 0}
instrumentId: sim-smu-1
instrumentModel: SIM-SMU-1
functionRole: smu
ext: {sim: {profile: galois_sim-smu-1, address: "TCPIP0::192.168.1.60::5025::SOCKET"}}
- id: inst-eload
kind: instrument
label: eload
position: {x: 300, y: 0}
instrumentId: sim-eload-1
instrumentModel: SIM-ELOAD-1
functionRole: eload
ext:
sim:
profile: galois_sim-eload-1
state: {function.mode: CC, source.current.level: 0.002, input.enabled: true}
edges:
- id: e-smu-eload
source: inst-smu
target: inst-eload
ext: {sim: {sourcePort: HI, targetPort: IN}}edgesim run reads <instrument_id> <SCPI> lines and prints each reply, with errors on stderr:
printf 'sim-smu-1 :SOURce:FUNCtion VOLTage\nsim-smu-1 :SENSe:CURRent:PROTection 0.01\nsim-smu-1 :SOURce:SWEep:VOLTage:LINear 0,8,9,0\nsim-smu-1 :OUTPut:STATe ON\nsim-smu-1 :READ?\nsim-smu-1 :SYSTem:ERRor?\nsim-smu-1 :TRACe:POINts:ACTual?\n' \
| edgesim run bench/smu_eload.bench.yaml 2>&1edgesim run: line 5: sim-smu-1: error: -221,"Settings conflict; the load is not one line over the sweep (a nonlinear load, or a protection tripped)"
sim-smu-1:
sim-smu-1: -221,"Settings conflict; the load is not one line over the sweep (a nonlinear load, or a protection tripped)"
sim-smu-1: 0The refusal is -221, the settings-conflict code; refusals never use -241, which hosts read as a disconnect. The :READ? reply is empty and the buffer holds 0 points, and EdgeSim's tests pin that a refused sweep never leaves an earlier sweep's rows to read back. Refusing keeps a wrong curve out of a test: a table drawn across a knee would look like data. A 1 to 8 V sweep into the same load stays above the knee, is accepted, and reads about 2 mA at all eight points.
A single level is still solved against any net, so a stepped sweep, one source command and one :MEASure? per point, works for every load. It costs a round trip per point, and it is what stepped_sweep in smu_iv.py does.
How do I test the sweeps and the output-off guarantee in pytest?
Two fixtures open the bench named by the test class or the default, and the SMU:
import pyvisa
import pytest
from bench.smu_iv import open_smu
@pytest.fixture
def rm(request):
bench = getattr(request.cls, "BENCH", "bench/smu.bench.yaml")
manager = pyvisa.ResourceManager(f"{bench}@edgesim")
yield manager
manager.close()
@pytest.fixture
def smu(rm):
return open_smu(rm)The tests assert what the sections above printed. The last one cuts the link after the fifth reading of a stepped sweep, using a snapshot of the SMU taken as the link goes down:
import numpy as np
import pytest
from edgesim.api import Fault
from pyvisa.constants import StatusCode
from pyvisa.errors import VisaIOError
from bench.smu_iv import COMPLIANCE, SmuRefused, open_smu, stepped_sweep, sweep, table
R = 999.9 # 1 kohm in parallel with the DMM's 10 Mohm input
def test_linear_sweep_follows_ohms_law_until_the_limit(smu):
rows = sweep(smu, ":SOURce:SWEep:VOLTage:LINear 0,0.8,41,0", ilimit=450e-6)
volt, curr, stat = rows[:, 0], rows[:, 1], rows[:, 4]
clamped = stat == COMPLIANCE
assert clamped.sum() == 18 and clamped.argmax() == 23
assert np.allclose(curr[~clamped], volt[~clamped] / R)
assert np.allclose(curr[clamped], 450e-6)
def test_compliance_holds_on_both_signs(smu):
rows = sweep(smu, ':SOURce:SWEep:VOLTage:LIST "-8,-2,0,2,8"', ilimit=4e-3)
assert list(rows[:, 4]) == [COMPLIANCE, 0, 0, 0, COMPLIANCE]
assert rows[0, 1] == pytest.approx(-4e-3) and rows[-1, 1] == pytest.approx(4e-3)
def test_log_sweep_levels_are_geometric(smu):
rows = sweep(smu, ":SOURce:SWEep:VOLTage:LOGarithmic 0.01,10,7,0", ilimit=10e-3)
assert np.allclose(rows[1:, 0] / rows[:-1, 0], 10 ** 0.5, rtol=1e-3)
def test_the_buffer_survives_until_it_is_cleared(smu):
first = sweep(smu, ":SOURce:SWEep:VOLTage:LINear 0,0.8,5,0", ilimit=10e-3)
assert int(smu.query(":TRACe:POINts:ACTual?")) == 5
assert np.array_equal(table(smu.query(":FETCh?")), first)
smu.write(":TRACe:CLEar")
assert int(smu.query(":TRACe:POINts:ACTual?")) == 0
class TestNonlinearLoad:
BENCH = "bench/smu_eload.bench.yaml"
def test_the_instruments_sweep_is_refused_and_the_stepped_one_is_not(self, smu):
with pytest.raises(SmuRefused, match="-221"):
sweep(smu, ":SOURce:SWEep:VOLTage:LINear 0,8,9,0", ilimit=10e-3)
assert int(smu.query(":TRACe:POINts:ACTual?")) == 0
rows = stepped_sweep(smu, np.linspace(0, 8, 9), ilimit=10e-3)
assert rows.shape == (9, 5) and (rows[:, 4] == 0).all()
def test_a_dropped_link_leaves_the_output_on_until_a_new_session(rm, smu):
world = smu.visalib.world
calls, snapshot = 0, None
class Link:
"""The SMU resource, with the link cut after the fifth reading."""
def __getattr__(self, name):
return getattr(smu, name)
def query(self, message):
nonlocal calls, snapshot
reply = smu.query(message)
calls += message == ":MEASure?"
if calls == 5:
snapshot = world.snapshot() # the SMU exactly as the link goes down
world.inject("sim-smu-1", Fault("disconnect"))
return reply
with pytest.raises(VisaIOError) as raised:
stepped_sweep(Link(), np.linspace(0, 0.8, 41), ilimit=1e-3)
assert raised.value.error_code == StatusCode.error_connection_lost
world.restore(snapshot) # the link is back; nothing changed while it was down
assert world.state("sim-smu-1", "output")["output.enabled"] # OUTPut:STATe OFF never reached the SMU
open_smu(rm) # a new session starts with output off
assert not world.state("sim-smu-1", "output")["output.enabled"]tests/test_smu_iv.py::test_linear_sweep_follows_ohms_law_until_the_limit PASSED [ 16%]
tests/test_smu_iv.py::test_compliance_holds_on_both_signs PASSED [ 33%]
tests/test_smu_iv.py::test_log_sweep_levels_are_geometric PASSED [ 50%]
tests/test_smu_iv.py::test_the_buffer_survives_until_it_is_cleared PASSED [ 66%]
tests/test_smu_iv.py::TestNonlinearLoad::test_the_instruments_sweep_is_refused_and_the_stepped_one_is_not PASSED [ 83%]
tests/test_smu_iv.py::test_a_dropped_link_leaves_the_output_on_until_a_new_session PASSED [100%]
============================== 6 passed in 1.10s ===============================Read the last test as the answer to "guaranteed output-off". The sweep raises VI_ERROR_CONN_LOST, and so does the :OUTPut:STATe OFF in finally, because the guard cannot send over the link that failed. The output is still on, mid-sweep, until a new session sends its first line. world.restore stands in for the link coming back with the SMU as the fault found it.
The finally block covers every failure the link survives, and no code on the PC covers one it does not. Three habits close the gap. Open every session with an output-off command, as open_smu does. Choose the limit for the unattended state: a low current limit and a low voltage limit bound what a stuck-on output can do to a fragile device. Ask the instrument. What an SMU does by itself when its link dies is in its manual and on its bench; the simulated SMU holds the output where it was because its profile declares no link-loss behavior. A profile's cleanup_commands run when the galois-edge daemon stops cleanly and disconnects, and cannot reach an SMU behind a dead link either.
What does the simulation not prove?
- Loads that are not one line. The instrument-side sweep is refused for them, as shown above, and the stepped loop is the way through. The shipped DUT models are passive.
- Settling, noise and accuracy. The clock is virtual and the SMU's readings are exact: NPLC moves the TIME element and not the current. Source delay, NPLC and auto-zero trade-offs show up on the real unit, where a sweep needs a delay and
*OPC?. - One model's dialect. The simulated SMU uses short forms such as
:SOURce:VOLTageand:SENSe:CURRent:PROTection. The optional-node spellings (:SOURce:VOLTage:LEVel[:IMMediate],:INITiate:IMMediate,:MEASure:VOLTage:DC?,:READ:<function>?) answer -113, so a script that uses them needs the short form or a profile for its own unit. - Errors inside the profile. The simulation answers what the profile says, so a wrong template, range or unit passes the rehearsal.
- The error format.
SYST:ERR?answers0,"No error"; read the code before the first comma.
How do I run this in Galois with Évariste?
Galois is agent-driven test engineering for hardware teams: agents generate tests and instrument drivers, run them on real benches through the open-source galois-edge daemon, and turn the results into reports and a shared engineering record.
EdgeSim plugs into galois-edge as an instrument backend. With SIM_MODE and SIM_BENCH set to the bench file above, the edge lists the simulated SMU beside real instruments, and SIM_MARK_INSTRUMENTS flags it is_simulated. Évariste, the agent in the Galois platform, then works the virtual bench with the tools it uses on a real one: list_instruments, send_command, create_sequence, start_test_run and get_run_results. The Galois library ships 573 instrument profiles across 135 manufacturers, and each is a driver, a simulation spec and a navigable map for an agent; the SMU's profile is the same kind of file.
Open Évariste from the app sidebar (Ctrl+Shift+E) beside the project and state the sweep with its limits:
Create an IV sweep sequence for the source-measure unit: force voltage, 0 V to 0.8 V in 41 points with a 1 mA current limit, read the buffer, and end with the output off. Then rehearse it on the simulated SMU with a 450 µA limit and list the rows that are in compliance.
Évariste drafts a sequence of named profile commands, the aliases the profile gives its leaves:
name: "IV, 0 to 0.8 V, 41 points, 1 mA limit"
steps:
- name: "Source function: voltage"
type: action
config:
instrument_id: "sim-smu-1"
command_name: "set_source_function"
parameters: { function: "VOLTage" }
- name: "Current limit 1 mA"
type: action
config:
instrument_id: "sim-smu-1"
command_name: "set_current_limit"
parameters: { current: "0.001" }
- name: "Linear sweep, 41 points"
type: action
config:
instrument_id: "sim-smu-1"
command_name: "config_linear_sweep"
parameters: { start: "0", stop: "0.8", points: "41", delay: "0" }
- name: "Output on"
type: action
config:
instrument_id: "sim-smu-1"
command_name: "output_enable"
parameters: { state: "ON" }
- name: "Read the buffer"
type: measure
config:
instrument_id: "sim-smu-1"
command_name: "read"
- name: "Output off"
type: action
config:
instrument_id: "sim-smu-1"
command_name: "output_enable"
parameters: { state: "OFF" }Review and approve. The draft lands as a draft, and a draft does not run until an engineer approves it. Apply the SMU checklist: the limit step comes before the output-on step; the last step turns the output off; the sense and force wiring match the fixture; the 41 levels run from 0 V to 0.8 V. Ask for changes in conversation or in the sequence builder, and every edit is a new version with a diff. How to review an AI-generated test plan lists more.
Rehearse on the virtual bench. Start the run against the simulated edge. galois-edge executes it with the calls it uses on a real bench, and every record is traced with provenance sim. With the limit step changed to 0.00045, a new version with a diff, the buffer rows should carry STAT 8 from the 0.46 V point, 18 rows in all; ask Évariste which rows are in compliance and it answers from the results. A run stopped partway skips the final output-off step, so rehearse the teardown too. The fault tools sim_inject_fault, sim_advance and sim_reset are registered when SIM_CONTROL_TOOLS is true, so an agent cannot inject a fault unless the operator opts in. The sequence rehearsal gate is in build: every sequence will dry-run on a virtual bench built from the project topology before approval, so the reviewer sees the compliance trip and the teardown without anyone starting a run.
Run on the real unit. Approve the version, bind the real SMU's profile, and run the sequence. Where that profile carries the smu capability tags, find_capability finds each command by id, so the sequence is retargeted without reading the unit's command tree. Ask for one report that overlays the rehearsal curve and the measured curve, labeled by provenance, then "Generate a test report from the last run" for the PDF or HTML.
Your job is the device's limits, the review, the approval, the force and sense wiring, and checking that the output is off before anyone touches the leads. You no longer write the sweep function, the guard, the fault harness or the report code.
| Step | Code path (this guide) | Galois with Évariste |
|---|---|---|
| Wire the load | smu.bench.yaml, edgesim validate | The project topology is the bench |
| Force and limit | run_force.py, STAT and regulation per reading | set_source_function and set_current_limit steps |
| Sweep | sweep(smu, config, ilimit) with linear, log or list | config_linear_sweep and read steps, plain-English objective |
| Compliance | Rows with STAT 8 | Rows in compliance listed in the results |
| Refused load | SmuRefused, stepped_sweep | The -221 shows in the step result; Évariste drafts a stepped sequence |
| Output-off | output_on, new session first | Final step; you confirm it on the instrument |
| Faults | Fault("disconnect"), world.restore | Tools behind SIM_CONTROL_TOOLS |
| Approval | Code review | Draft reviewed, versioned, approved |
| Real unit | ResourceManager() without @edgesim, the unit's spellings | Bind the profile; same sequence |
| Report | Arrays and prints | Generated report, labeled by provenance |
When is plain code enough?
If one engineer owns one SMU and the sweep is a short script, the files in this guide are enough. A resistor in the fixture on the real SMU is the cheapest check of leads and limits; the simulation makes sure the first real run is not the first run of the code.
Moving the script to the bench changes the resource manager and any command the unit spells differently, shown here as an illustration because it needs the instrument:
rm = pyvisa.ResourceManager() # NI-VISA or pyvisa-py; the unit's own resource string and command spellings
smu = open_smu(rm, "TCPIP0::192.168.1.60::5025::SOCKET")Keithley SMU automation with Python walks the real command set of a 2450 and a 2400, including the instrument's own sweep and trace buffer. For labs where SMU time is booked by the hour, lab automation for university research labs covers the wider setup, post-silicon validation bench automation covers voltage and temperature corners, and Can an LLM safely drive lab instruments? covers the layers behind an agent that sources voltage. The Galois and PyVISA comparison shows where code ends and the platform begins.
Next in this series, spectrum analyzer simulation tests marker and RBW code against a simulated analyzer before the instrument is free.
Frequently asked questions
- How do I test an SMU IV sweep script without the instrument?
- Run it against a simulated SMU. Wire the simulated SMU to a resistor in a bench file, open PyVISA with bench.yaml@edgesim, and send the same force, limit, sweep and read commands. The simulation answers from its profile: it clamps at compliance, fills a buffer of VOLT,CURR,RES,TIME,STAT rows and queues SCPI errors, so the checks a script needs run on a laptop.
- Why does my SMU IV curve flatten into a straight line?
- The source reached its current limit. After *RST the simulated SMU holds a 105 µA limit, so a script that forgets to set one clamps the current as soon as V divided by R exceeds 105 µA. Into 1 kΩ a 41-point sweep from 0 to 0.8 V clamps from point 6 on, 35 points in all, with no error raised. The STAT element, 8, and the TRIPped query are the evidence.
- Does try/finally turn the SMU output off if the connection drops?
- No. The finally block sends the output-off command over the same link that failed, so that send fails too and the output stays at its last level. Start every session with an output-off command from a new connection, and keep the limit low enough that an unattended output is survivable.
- Why does my sweep return -221 into an electronic load?
- A sweep is computed from the load line, and a load that is not one line over the sweep, such as an electronic load across the knee of its regulation, is refused with -221 and no buffer rows. A single level is still solved, so a stepped loop of source and measure commands works for any load.
- How do I make a simulated SMU hit compliance on purpose?
- Set the current limit below V divided by R at the top of the sweep. With a 450 µA limit into 1 kΩ the source clamps near 0.45 V, so on a 0.02 V grid the first clamped point is 0.46 V, point 23, and 18 of 41 rows carry STAT 8. Count the rows with STAT 8, or read the regulation query after each point.
Bring Galois to your bench.
The daemon is Apache-2.0, free forever. Enterprise runs in your cloud or on-prem.