1+ # -------------------------------------------------------------------------------
2+ # Name: IV Sweep GUI for Keithley 2400/2182
3+ # Purpose: Provide a professional GUI for performing IV sweeps using a
4+ # Keithley 2400 as a current source and a Keithley 2182
5+ # as a nanovoltmeter.
6+ # Author: Prathamesh Deshmukh (GUI by Gemini)
7+ # Created: 04/10/2025
8+ # Version: 1.0
9+ # -------------------------------------------------------------------------------
10+
11+ # --- GUI and Plotting Packages ---
12+ import tkinter as tk
13+ from tkinter import ttk , filedialog , messagebox , scrolledtext
14+ import numpy as np
15+ import os
16+ import time
17+ import traceback
18+ import csv
19+ from datetime import datetime
20+ from matplotlib .figure import Figure
21+ from matplotlib .backends .backend_tkagg import FigureCanvasTkAgg
22+ import matplotlib as mpl
23+
24+ # --- Instrument Control Packages ---
25+ try :
26+ import pyvisa
27+ from pymeasure .instruments .keithley import Keithley2400
28+ PYMEASURE_AVAILABLE = True
29+ except ImportError :
30+ pyvisa , Keithley2400 = None , None
31+ PYMEASURE_AVAILABLE = False
32+
33+ # -------------------------------------------------------------------------------
34+ # --- BACKEND INSTRUMENT CONTROL ---
35+ # -------------------------------------------------------------------------------
36+ class IV_Backend :
37+ """ Manages communication with the Keithley 2400 and 2182. """
38+ def __init__ (self ):
39+ self .k2400 , self .k2182 = None , None
40+ if pyvisa :
41+ try : self .rm = pyvisa .ResourceManager ()
42+ except Exception as e : print (f"Could not initialize VISA: { e } " ); self .rm = None
43+
44+ def connect (self , k2400_visa , k2182_visa ):
45+ if not self .rm : raise ConnectionError ("PyVISA is not available." )
46+ if not PYMEASURE_AVAILABLE : raise ImportError ("Pymeasure is not available." )
47+ self .k2400 = Keithley2400 (k2400_visa )
48+ print (f" K2400 Connected: { self .k2400 .id } " )
49+ self .k2182 = self .rm .open_resource (k2182_visa )
50+ print (f" K2182 Connected: { self .k2182 .query ('*IDN?' ).strip ()} " )
51+
52+ def configure_instruments (self , compliance_v , current_range_a ):
53+ # Keithley 2400 setup
54+ self .k2400 .reset ()
55+ self .k2400 .apply_current ()
56+ self .k2400 .source_current_range = current_range_a
57+ self .k2400 .compliance_voltage = compliance_v
58+ self .k2400 .source_current = 0
59+ self .k2400 .enable_source ()
60+
61+ # Keithley 2182 setup
62+ self .k2182 .write ("*rst; status:preset; *cls" )
63+ time .sleep (1 )
64+
65+ def measure_voltage_at_current (self , current_a , delay_s ):
66+ self .k2400 .ramp_to_current (current_a , steps = 10 , pause = 0.05 )
67+ time .sleep (delay_s )
68+
69+ # K2182 measurement sequence
70+ self .k2182 .write ("status:measurement:enable 512; *sre 1" )
71+ self .k2182 .write ("sample:count 2" )
72+ self .k2182 .write ("trigger:source bus" )
73+ self .k2182 .write ("trigger:delay 0.1" )
74+ self .k2182 .write ("trace:points 2" )
75+ self .k2182 .write ("trace:feed sense1; feed:control next" )
76+ self .k2182 .write ("initiate" )
77+ self .k2182 .assert_trigger ()
78+ self .k2182 .wait_for_srq (timeout = 10 )
79+ voltages = self .k2182 .query_ascii_values ("trace:data?" )
80+ self .k2182 .query ("status:measurement?" )
81+ self .k2182 .write ("trace:clear; feed:control next" )
82+
83+ return sum (voltages ) / len (voltages ) if voltages else float ('nan' )
84+
85+ def shutdown (self ):
86+ if self .k2400 :
87+ try : self .k2400 .shutdown ()
88+ except : pass
89+ if self .k2182 :
90+ try : self .k2182 .write ("*rst" ); self .k2182 .close ()
91+ except : pass
92+ print (" Instruments shut down and disconnected." )
93+
94+ # -------------------------------------------------------------------------------
95+ # --- FRONT END (GUI) ---
96+ # -------------------------------------------------------------------------------
97+ class IV_GUI :
98+ PROGRAM_VERSION = "1.0"
99+ CLR_BG = '#2B3D4F' ; CLR_HEADER = '#3A506B' ; CLR_FG = '#EDF2F4'
100+ CLR_FRAME_BG = '#3A506B' ; CLR_INPUT_BG = '#4C566A'
101+ CLR_ACCENT_GREEN , CLR_ACCENT_RED , CLR_ACCENT_BLUE = '#A7C957' , '#E74C3C' , '#8D99AE'
102+ CLR_ACCENT_GOLD = '#FFC107' ; CLR_CONSOLE_BG = '#1E2B38'
103+ FONT_BASE = ('Segoe UI' , 11 ); FONT_TITLE = ('Segoe UI' , 13 , 'bold' )
104+
105+ def __init__ (self , root ):
106+ self .root = root
107+ self .root .title ("I-V Sweep (K2400 + K2182)" )
108+ self .root .geometry ("1600x950" )
109+ self .root .minsize (1400 , 800 )
110+ self .root .configure (bg = self .CLR_BG )
111+ self .is_running = False
112+ self .backend = IV_Backend ()
113+ self .data_storage = {'current' : [], 'voltage' : []}
114+ self .setup_styles ()
115+ self .create_widgets ()
116+ self .root .protocol ("WM_DELETE_WINDOW" , self ._on_closing )
117+
118+ def setup_styles (self ):
119+ style = ttk .Style (self .root ); style .theme_use ('clam' )
120+ style .configure ('.' , background = self .CLR_BG , foreground = self .CLR_FG , font = self .FONT_BASE )
121+ style .configure ('TFrame' , background = self .CLR_BG ); style .configure ('TPanedWindow' , background = self .CLR_BG )
122+ style .configure ('TLabel' , background = self .CLR_FRAME_BG , foreground = self .CLR_FG )
123+ style .configure ('Header.TLabel' , background = self .CLR_HEADER )
124+ style .configure ('TEntry' , fieldbackground = self .CLR_INPUT_BG , foreground = self .CLR_FG , insertcolor = self .CLR_FG )
125+ style .configure ('TButton' , font = self .FONT_BASE , padding = (10 , 9 ), foreground = self .CLR_ACCENT_GOLD , background = self .CLR_HEADER )
126+ style .map ('TButton' , background = [('active' , self .CLR_ACCENT_GOLD ), ('hover' , self .CLR_ACCENT_GOLD )], foreground = [('active' , self .CLR_BG ), ('hover' , self .CLR_BG )])
127+ style .configure ('Start.TButton' , background = self .CLR_ACCENT_GREEN , foreground = self .CLR_BG )
128+ style .map ('Start.TButton' , background = [('active' , '#8AB845' ), ('hover' , '#8AB845' )])
129+ style .configure ('Stop.TButton' , background = self .CLR_ACCENT_RED , foreground = self .CLR_FG )
130+ style .map ('Stop.TButton' , background = [('active' , '#D63C2A' ), ('hover' , '#D63C2A' )])
131+ style .configure ('TLabelframe' , background = self .CLR_FRAME_BG , bordercolor = self .CLR_ACCENT_BLUE )
132+ style .configure ('TLabelframe.Label' , background = self .CLR_FRAME_BG , foreground = self .CLR_FG , font = self .FONT_TITLE )
133+ mpl .rcParams .update ({'font.family' : 'Segoe UI' , 'font.size' : 11 , 'axes.titlesize' : 15 , 'axes.labelsize' : 13 })
134+
135+ def create_widgets (self ):
136+ header = tk .Frame (self .root , bg = self .CLR_HEADER ); header .pack (side = 'top' , fill = 'x' )
137+ ttk .Label (header , text = f"I-V Sweep (K2400 + K2182) v{ self .PROGRAM_VERSION } " , style = 'Header.TLabel' , font = self .FONT_TITLE ).pack (side = 'left' , padx = 20 , pady = 10 )
138+ main_pane = ttk .PanedWindow (self .root , orient = 'horizontal' ); main_pane .pack (fill = 'both' , expand = True , padx = 10 , pady = 10 )
139+ left_panel = self ._create_left_panel (main_pane ); main_pane .add (left_panel , weight = 2 )
140+ right_panel = self ._create_right_panel (main_pane ); main_pane .add (right_panel , weight = 3 )
141+
142+ def _create_left_panel (self , parent ):
143+ panel = ttk .Frame (parent , padding = 5 ); panel .grid_columnconfigure (0 , weight = 1 ); panel .grid_rowconfigure (2 , weight = 1 )
144+ self ._create_params_panel (panel , 0 ); self ._create_control_panel (panel , 1 ); self ._create_console_panel (panel , 2 )
145+ return panel
146+
147+ def _create_right_panel (self , parent ):
148+ panel = ttk .Frame (parent , padding = 5 )
149+ container = ttk .LabelFrame (panel , text = 'Live I-V Curve' ); container .pack (fill = 'both' , expand = True )
150+ self .figure = Figure (dpi = 100 , facecolor = 'white' )
151+ self .ax_main = self .figure .add_subplot (111 )
152+ self .line_main , = self .ax_main .plot ([], [], color = self .CLR_ACCENT_RED , marker = 'o' , markersize = 4 , linestyle = '-' )
153+ self .ax_main .set_title ("Waiting for experiment..." , fontweight = 'bold' ); self .ax_main .set_xlabel ("Voltage (V)" ); self .ax_main .set_ylabel ("Current (A)" )
154+ self .ax_main .grid (True , linestyle = '--' , alpha = 0.6 ); self .figure .tight_layout ()
155+ self .canvas = FigureCanvasTkAgg (self .figure , container ); self .canvas .get_tk_widget ().pack (fill = 'both' , expand = True , padx = 5 , pady = 5 )
156+ return panel
157+
158+ def _create_params_panel (self , parent , grid_row ):
159+ container = ttk .LabelFrame (parent , text = 'Sweep Parameters' ); container .grid (row = grid_row , column = 0 , sticky = 'new' , pady = 5 )
160+ container .grid_columnconfigure (1 , weight = 1 ); self .entries = {}
161+ self ._create_entry (container , "Start Current (mA)" , "-1" , 0 )
162+ self ._create_entry (container , "Stop Current (mA)" , "1" , 1 )
163+ self ._create_entry (container , "Step Current (mA)" , "0.1" , 2 )
164+ self ._create_entry (container , "Compliance (V)" , "10" , 3 )
165+ self ._create_entry (container , "Dwell Time (s)" , "0.5" , 4 )
166+ self .k2400_cb = self ._create_combobox (container , "Keithley 2400 VISA" , 5 )
167+ self .k2182_cb = self ._create_combobox (container , "Keithley 2182 VISA" , 6 )
168+
169+ def _create_control_panel (self , parent , grid_row ):
170+ frame = ttk .LabelFrame (parent , text = 'Experiment Control' ); frame .grid (row = grid_row , column = 0 , sticky = 'new' , pady = 5 )
171+ frame .grid_columnconfigure (0 , weight = 1 )
172+ self ._create_entry (frame , "Sample Name" , "Sample_IV" , 0 )
173+ self ._create_entry (frame , "Save Location" , "" , 1 , browse = True )
174+ button_frame = ttk .Frame (frame ); button_frame .grid (row = 2 , column = 0 , columnspan = 4 , sticky = 'ew' , pady = 5 )
175+ button_frame .grid_columnconfigure ((0 ,1 ,2 ), weight = 1 )
176+ self .start_button = ttk .Button (button_frame , text = "Start" , style = 'Start.TButton' , command = self .start_experiment )
177+ self .start_button .grid (row = 0 , column = 0 , sticky = 'ew' , padx = 5 )
178+ self .stop_button = ttk .Button (button_frame , text = "Stop" , style = 'Stop.TButton' , state = 'disabled' , command = self .stop_experiment )
179+ self .stop_button .grid (row = 0 , column = 1 , sticky = 'ew' , padx = 5 )
180+ ttk .Button (button_frame , text = "Scan" , command = self ._scan_for_visa ).grid (row = 0 , column = 2 , sticky = 'ew' , padx = 5 )
181+
182+ def _create_console_panel (self , parent , grid_row ):
183+ frame = ttk .LabelFrame (parent , text = 'Console' ); frame .grid (row = grid_row , column = 0 , sticky = 'nsew' , pady = 5 )
184+ self .console = scrolledtext .ScrolledText (frame , state = 'disabled' , bg = self .CLR_CONSOLE_BG , fg = self .CLR_FG , font = ('Consolas' , 9 ), wrap = 'word' , borderwidth = 0 )
185+ self .console .pack (fill = 'both' , expand = True , padx = 5 , pady = 5 )
186+
187+ def log (self , message ):
188+ ts = datetime .now ().strftime ("%H:%M:%S" ); log_msg = f"[{ ts } ] { message } \n "
189+ self .console .config (state = 'normal' ); self .console .insert ('end' , log_msg ); self .console .see ('end' ); self .console .config (state = 'disabled' )
190+
191+ def start_experiment (self ):
192+ try :
193+ self .params = self ._validate_and_get_params ()
194+ self .log ("Connecting to instruments..." ); self .backend .connect (self .params ['k2400_visa' ], self .params ['k2182_visa' ])
195+ self .backend .configure_instruments (self .params ['compliance_v' ], self .params ['stop_i' ])
196+ self .log ("All instruments connected and configured." )
197+
198+ start_i , stop_i , step_i = self .params ['start_i' ], self .params ['stop_i' ], self .params ['step_i' ]
199+ self .current_points = np .arange (start_i , stop_i + step_i / 2 , step_i )
200+ if not len (self .current_points ): raise ValueError ("Current sweep results in zero points." )
201+
202+ ts = datetime .now ().strftime ("%Y%m%d_%H%M%S" ); filename = f"{ self .params ['name' ]} _{ ts } _IV.csv"
203+ self .data_filepath = os .path .join (self .params ['save_path' ], filename )
204+ with open (self .data_filepath , 'w' , newline = '' ) as f :
205+ writer = csv .writer (f ); writer .writerow (["Current (A)" , "Voltage (V)" ])
206+
207+ self .current_step_index = 0 ; self .set_ui_state (running = True )
208+ for key in self .data_storage : self .data_storage [key ].clear ()
209+ self .line_main .set_data ([], []); self .ax_main .set_title (f"I-V Curve: { self .params ['name' ]} " ); self .canvas .draw ()
210+ self .log (f"Starting sweep: { len (self .current_points )} current points." )
211+ self .root .after (100 , self ._experiment_loop )
212+ except Exception as e :
213+ self .log (f"ERROR: { traceback .format_exc ()} " ); messagebox .showerror ("Start Failed" , f"{ e } " ); self .backend .shutdown ()
214+
215+ def stop_experiment (self , reason = "" ):
216+ if not self .is_running : return
217+ self .log (f"Stopping... { reason } " if reason else "Stopping by user request." )
218+ self .is_running = False ; self .backend .shutdown (); self .set_ui_state (running = False )
219+ self .ax_main .set_title ("Experiment stopped." ); self .canvas .draw ()
220+ if reason : messagebox .showinfo ("Experiment Finished" , f"Reason: { reason } " )
221+
222+ def _experiment_loop (self ):
223+ if not self .is_running : return
224+ try :
225+ current_setpoint = self .current_points [self .current_step_index ]
226+ self .log (f"--- Setting current to { current_setpoint :.3e} A ({ self .current_step_index + 1 } /{ len (self .current_points )} ) ---" )
227+ self .ax_main .set_title (f"Measuring at { current_setpoint :.3e} A..." ); self .canvas .draw ()
228+
229+ voltage = self .backend .measure_voltage_at_current (current_setpoint , self .params ['delay_s' ])
230+ self .log (f" Read: V = { voltage :.6e} V" )
231+
232+ self .data_storage ['current' ].append (current_setpoint ); self .data_storage ['voltage' ].append (voltage )
233+ with open (self .data_filepath , 'a' , newline = '' ) as f : csv .writer (f ).writerow ([f"{ current_setpoint :.6e} " , f"{ voltage :.6e} " ])
234+ self .line_main .set_data (self .data_storage ['voltage' ], self .data_storage ['current' ])
235+ self .ax_main .relim (); self .ax_main .autoscale_view (); self .figure .tight_layout (); self .canvas .draw ()
236+
237+ self .current_step_index += 1
238+ if self .current_step_index >= len (self .current_points ):
239+ self .stop_experiment ("All points measured." )
240+ else :
241+ self .root .after (100 , self ._experiment_loop )
242+ except Exception as e :
243+ self .log (f"CRITICAL ERROR: { traceback .format_exc ()} " ); messagebox .showerror ("Runtime Error" , f"{ e } " ); self .stop_experiment ("Runtime Error" )
244+
245+ def _validate_and_get_params (self ):
246+ try :
247+ params = {
248+ 'name' : self .entries ["Sample Name" ].get (), 'save_path' : self .entries ["Save Location" ].get (),
249+ 'start_i' : float (self .entries ["Start Current (mA)" ].get ()) * 1e-3 ,
250+ 'stop_i' : float (self .entries ["Stop Current (mA)" ].get ()) * 1e-3 ,
251+ 'step_i' : float (self .entries ["Step Current (mA)" ].get ()) * 1e-3 ,
252+ 'compliance_v' : float (self .entries ["Compliance (V)" ].get ()),
253+ 'delay_s' : float (self .entries ["Dwell Time (s)" ].get ()),
254+ 'k2400_visa' : self .k2400_cb .get (), 'k2182_visa' : self .k2182_cb .get ()
255+ }
256+ if not all (params .values ()): raise ValueError ("All fields must be filled." )
257+ if params ['step_i' ] == 0 : raise ValueError ("Step Current cannot be zero." )
258+ return params
259+ except Exception as e : raise ValueError (f"Invalid parameter input: { e } " )
260+
261+ def set_ui_state (self , running : bool ):
262+ self .is_running = running
263+ state = 'disabled' if running else 'normal'
264+ self .start_button .config (state = state )
265+ for w in self .entries .values (): w .config (state = state )
266+ for cb in [self .k2400_cb , self .k2182_cb ]: cb .config (state = state if state == 'normal' else 'readonly' )
267+ self .stop_button .config (state = 'normal' if running else 'disabled' )
268+
269+ def _scan_for_visa (self ):
270+ if self .backend .rm is None : self .log ("ERROR: PyVISA library missing." ); return
271+ self .log ("Scanning for VISA instruments..." ); resources = self .backend .rm .list_resources ()
272+ if resources :
273+ self .log (f"Found: { resources } " ); self .k2400_cb ['values' ] = resources ; self .k2182_cb ['values' ] = resources
274+ for r in resources :
275+ if '2400' in r or 'GPIB::4' in r : self .k2400_cb .set (r )
276+ if '2182' in r or 'GPIB::7' in r : self .k2182_cb .set (r )
277+ else : self .log ("No VISA instruments found." )
278+
279+ def _browse_file_location (self ):
280+ path = filedialog .askdirectory ()
281+ if path :
282+ self .entries ["Save Location" ].config (state = 'normal' ); self .entries ["Save Location" ].delete (0 , 'end' )
283+ self .entries ["Save Location" ].insert (0 , path ); self .entries ["Save Location" ].config (state = 'disabled' )
284+
285+ def _create_entry (self , parent , label_text , default_value , row , browse = False ):
286+ ttk .Label (parent , text = f"{ label_text } :" ).grid (row = row , column = 0 , sticky = 'w' , padx = 10 , pady = 3 )
287+ entry = ttk .Entry (parent , font = self .FONT_BASE )
288+ entry .grid (row = row , column = 1 , sticky = 'ew' , padx = 10 , pady = 3 , columnspan = 2 if browse else 1 )
289+ entry .insert (0 , default_value ); self .entries [label_text ] = entry
290+ if browse :
291+ btn = ttk .Button (parent , text = "..." , width = 3 , command = self ._browse_file_location )
292+ btn .grid (row = row , column = 3 , sticky = 'e' , padx = (0 ,10 ))
293+ entry .config (state = 'disabled' )
294+
295+ def _create_combobox (self , parent , label_text , row ):
296+ ttk .Label (parent , text = f"{ label_text } :" ).grid (row = row , column = 0 , sticky = 'w' , padx = 10 , pady = 3 )
297+ cb = ttk .Combobox (parent , font = self .FONT_BASE , state = 'readonly' )
298+ cb .grid (row = row , column = 1 , sticky = 'ew' , padx = 10 , pady = 3 , columnspan = 3 )
299+ return cb
300+
301+ def _on_closing (self ):
302+ if self .is_running and messagebox .askyesno ("Exit" , "Experiment is running. Stop and exit?" ):
303+ self .stop_experiment ("Application closed by user." ); self .root .destroy ()
304+ elif not self .is_running : self .root .destroy ()
305+
306+ if __name__ == '__main__' :
307+ if not PYMEASURE_AVAILABLE :
308+ messagebox .showerror ("Dependency Error" , "Pymeasure or PyVISA is not installed. Please run 'pip install pymeasure'." )
309+ else :
310+ root = tk .Tk (); app = IV_GUI (root ); root .mainloop ()
0 commit comments