-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathSpecEChemProgram.py
More file actions
1604 lines (1442 loc) · 81.9 KB
/
Copy pathSpecEChemProgram.py
File metadata and controls
1604 lines (1442 loc) · 81.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# Graphical program for collecting electrochemical and spectral data simultaneously using a Gamry potentiostat and OceanOptics spectrometer
# Originally written by Carter Pryor (carter_pryor@outlook.com) for Graham group at UKY
# Last modified 2026-06-19
# Recommended sample period: >=0.1 s according to Gamry docs
# Run command:
# & 'C:\Program Files (x86)\Gamry Instruments\Python\Python37-32\python.exe' .\SpecEChemProgram.py
# Lightbulb icon source: <a href="https://www.flaticon.com/free-icons/idea" title="idea icons">Idea icons created by Good Ware - Flaticon</a>
'''
TODO:
- Update email sending to include whether or not expt finished completely
- Warning if user pstat settings are over max number of data points
- Break up code into multiple files to increase readability
- Fix bug preventing running too long experiments (sequence wizard type thing)
- Test everything flushes every 30 s correctly
- Consider additionally snapping the data collection to the start of each cycle to improve stability
- Allow user to choose the maximum current before automatic stop
- Make an option (either another program or an option in this one) for chronocoulometry and perhaps for open circuit measurements
'''
# Import Python's built-in functionality for things like timing, etc.
import datetime
import time
# And for multithreading
import threading
# and for logging
import logging
import pathlib
import math
# Import Python's built-in GUI library
import tkinter as tk
import tkinter.messagebox as mbox
import tkinter.ttk
import tkinter.simpledialog as simpledialog
import tkinter.filedialog as filedialog
# Import numpy, for math
import numpy as np
# and matplotlib, for plotting
import matplotlib.figure as figure
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
# libraries for sending notification emails when complete
import smtplib
import ssl
from email import encoders
from email.mime.base import MIMEBase
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
# create zip file to send data
import zipfile
# Import Seabreeze, the library which controls an OceanOptics spectrometer
import seabreeze as sb
import seabreeze.spectrometers
# Gamry's toolkitpy library for controlling the Gamry pstat directly from Python
import toolkitpy as tkp
# initialize logger
logger = logging.getLogger(__name__)
def perf_sleep_until(final_time: int):
while True:
now = time.perf_counter_ns()
dt = final_time - now
# if we are at time or past it, stop sleeping
if (dt <= 0):
break
# if we are more than 200 ms away from time, sleep for 80% of remaining time
elif (dt > 0.200*(1E9)):
time.sleep(dt * 0.8 * (1E-9))
# if we are more than 50 ms away from time, sleep for 15 ms
elif (dt > 0.050*(1E9)):
time.sleep(0.015)
# otherwise, just continue the loop
else:
# yield to other processes; this can reduce CPU jitter and make this more accurate
time.sleep(0)
# A window class, which holds all the information for the our active experiment
class MyWindow:
# Constructor, called when we make a new object of this class
# Sets up the GUI and the functions that will be called when we click on parts of it
def __init__(self):
# Initialize Toolkitpy for this file
tkp.toolkitpy_init("Lighthouse_SpecEChemProgram.py")
# Set up the window
self.root = tk.Tk()
self.root.geometry("1000x600")
self.root.title("Lighthouse")
# attach the function that runs before exiting, confirming we want to quit
self.root.protocol("WM_DELETE_WINDOW", self.confirm_quit)
# for now, make window not resizeable until I change the plot updates to be in main thread
self.root.resizable(False, False)
# Then, add all the pieces to the GUI
### Top level GUI code
# Menu bar
self.menubar = tk.Menu(self.root)
# File submenu
self.menu_file = tk.Menu(self.menubar, tearoff=0)
self.menu_file.add_command(label="Choose output folder", command=self.choose_out_dir)
# Edit submenu
self.menu_edit = tk.Menu(self.menubar, tearoff=0)
self.menu_edit.add_command(label="Experiment Title", command=self.edit_exp_name)
self.menu_edit.add_command(label="Operator", command=self.edit_operator)
self.menu_edit.add_command(label="Description", command=self.edit_description)
self.menu_edit.add_command(label="Notification emails", command=self.edit_emails)
# Add the menus to the menubar
self.menubar.add_cascade(label="File", menu = self.menu_file)
self.menubar.add_cascade(label="Edit", menu = self.menu_edit)
# Set the menu of the window to be the menu we made
self.root.config(menu = self.menubar)
# Filename label
self.lbl_filename = tk.Label(self.root, text="Save to: default")
self.lbl_filename.place(x=20, y=10)
# actively running label
self.lbl_running = tk.Label(self.root, text="not running", fg="red")
self.lbl_running.place(x=850, y=560)
# Checkbox to include or not include potentiostat in the current measurement
self.use_pstat = tk.BooleanVar()
self.chk_use_pstat = tk.Checkbutton(self.root, text="Use Potentiostat", variable=self.use_pstat, onvalue=True, offvalue=False)
self.chk_use_pstat.select()
self.chk_use_pstat.place(x=20, y=540)
# Checkbox to include or not include spectrometer in current measurement
self.use_spec = tk.BooleanVar()
self.chk_use_spec = tk.Checkbutton(self.root, text="Use Spectrometer", variable=self.use_spec, onvalue=True, offvalue=False)
self.chk_use_spec.select()
self.chk_use_spec.place(x=20+20+470, y=540)
# Button to actually start the measurement
self.btn_start = tk.Button(self.root, text="Start!", command=self.start_measurement)
self.btn_start.place(x=750, y=560)
# Button to abort measurement
self.btn_abort = tk.Button(self.root, text="Abort", command=self.abort_measurement)
self.btn_abort.place(x=800, y=560)
# Default experiment name & operator
self.experiment_name = "Experiment"
self.operator = "Graham Lab"
self.description = ""
self.emails = ""
# Default save directory = current directory:
self.save_dir = "."
# Bool for if the experiment is actively running
self.running = False
### End top level GUI code
### PStat related init code
# The frame for potentiostat-related settings
self.frame_pstat = tk.Frame(self.root, width=470, height=500, bd=2, relief="sunken")
self.frame_pstat.place(x=20, y=40)
# if pstat is connected label
self.lbl_pstat = tk.Label(self.frame_pstat, text="Potentiostat:")
self.lbl_pstat.place(x=1, y=1)
self.lbl_pstat_connected = tk.Label(self.frame_pstat, text="Not connected", fg="red")
self.lbl_pstat_connected.place(x=70, y=1)
# pstat model label
self.lbl_pstat_model = tk.Label(self.frame_pstat, text="Model: ---")
self.lbl_pstat_model.place(x=1, y=20)
# cell off/on label
self.lbl_cell_state = tk.Label(self.frame_pstat, text="Cell Off", fg="red")
self.lbl_cell_state.place(x=1, y=40)
# button to connect PStat
self.btn_connect_pstat = tk.Button(self.frame_pstat, text="Connect Potentiostat", command=self.connect_pstat)
self.btn_connect_pstat.place(x=250, y=20)
# pstat potential reading label
self.lbl_pstat_potential = tk.Label(self.frame_pstat, text="E: --- V", font=("Arial", 11))
self.lbl_pstat_potential.place(x=150, y=80)
# pstat current reading label
self.lbl_pstat_current = tk.Label(self.frame_pstat, text="i: --- A", font=("Arial", 11))
self.lbl_pstat_current.place(x=250, y=80)
# All this GUI stuff is for choosing settings for a given experiment
# First vertex potential: where we start the scan
# Label
self.lbl_vertex_pot1 = tk.Label(self.frame_pstat, text="Vertex Potential 1 (V):")
self.lbl_vertex_pot1.place(x=20, y=120)
# Text box
self.vertex_pot1_text = tk.StringVar()
self.entry_vertex_pot1 = tk.Entry(self.frame_pstat, textvariable=self.vertex_pot1_text)
self.entry_vertex_pot1.place(x=145, y=122, width=35)
# Vertex potential 2: where we turn around in the cycles
# Label
self.lbl_vertex_pot2 = tk.Label(self.frame_pstat, text="Vertex Potential 2 (V):")
self.lbl_vertex_pot2.place(x=20, y=140)
# Text box
self.vertex_pot2_text = tk.StringVar()
self.entry_vertex_pot2 = tk.Entry(self.frame_pstat, textvariable=self.vertex_pot2_text)
self.entry_vertex_pot2.place(x=145, y=142, width=35)
# Scan rate
# Label
self.lbl_scan_rate = tk.Label(self.frame_pstat, text="Scan Rate (V/s):")
self.lbl_scan_rate.place(x=20, y=160)
# Text box
self.scan_rate_text = tk.StringVar()
self.entry_scan_rate = tk.Entry(self.frame_pstat, textvariable=self.scan_rate_text)
self.entry_scan_rate.place(x=145, y=162, width=35)
# Step size
# Label
self.lbl_step_size = tk.Label(self.frame_pstat, text="Sample Time (s):")
self.lbl_step_size.place(x=200, y=120)
# Text box
self.step_size_text = tk.StringVar()
self.entry_step_size = tk.Entry(self.frame_pstat, textvariable=self.step_size_text)
self.entry_step_size.place(x=295, y=122, width=35)
# Number of cycles
# Label
self.lbl_num_cycles = tk.Label(self.frame_pstat, text="# Cycles:")
self.lbl_num_cycles.place(x=200, y=140)
# Text box
self.num_cycles_text = tk.StringVar()
self.entry_num_cycles = tk.Entry(self.frame_pstat, textvariable=self.num_cycles_text)
self.entry_num_cycles.place(x=295, y=142, width=35)
self.figure_cv = figure.Figure((4.55, 3), dpi=100)
self.axes_cv = self.figure_cv.subplots()
self.canv_cv = FigureCanvasTkAgg(self.figure_cv, self.frame_pstat)
self.canv_cv.get_tk_widget().place(x=6, y=190)
# Variables that keep the program from doing things it shouldn't when it shouldn't
self.has_potentiostat = False
self.should_draw_pstat = False
# Potentiostat handle
self.potentiostat = None
### End PStat related init code
### Spectrometer-related init code
# The frame for spectrometer-related settings
self.frame_spec = tk.Frame(self.root, width=470, height=500, bd=2, relief="sunken")
self.frame_spec.place(x=20+20+470, y=40)
# if spectrometer is connected label
self.lbl_spectrometer = tk.Label(self.frame_spec, text="Spectrometer:")
self.lbl_spectrometer.place(x=1, y=1)
self.lbl_spec_connected = tk.Label(self.frame_spec, text="Not connected", fg="red")
self.lbl_spec_connected.place(x=80, y=1)
# spectrometer model label
self.lbl_spec_model = tk.Label(self.frame_spec, text="Model: ---")
self.lbl_spec_model.place(x=1, y=20)
# Connect button
self.btn_connect_spec = tk.Button(self.frame_spec, text="Connect Spectrometer", command=self.connect_spectrometer)
self.btn_connect_spec.place(x=270, y=10)
# integration time label
self.lbl_integ_time = tk.Label(self.frame_spec, text="Integration Time:", font=("Arial", 11))
self.lbl_integ_time.place(x=1, y=50)
# textbox for integration time entry
self.integ_time_txt = tk.StringVar()
self.entry_integ_time = tk.Entry(self.frame_spec, textvariable=self.integ_time_txt)
self.entry_integ_time.place(x=121, y=53, width=35)
# ms label
self.lbl_ms = tk.Label(self.frame_spec, text="ms")
self.lbl_ms.place(x=156, y=50)
# Set integration time from entry label
self.btn_integ_time = tk.Button(self.frame_spec, text="Set", command=self.set_integ_time)
self.btn_integ_time.place(x=180, y=50)
# electric dark correction checkbox
self.enable_dark_correction = tk.BooleanVar()
self.chk_dark = tk.Checkbutton(self.frame_spec, text="Electric Dark Correction", variable=self.enable_dark_correction, onvalue=True, offvalue=False)
self.chk_dark.select() # check the box
self.chk_dark.place(x=1, y=80)
# nonlinearity correction checkbox
self.enable_nonlinearity_correction = tk.BooleanVar()
self.chk_nonlin = tk.Checkbutton(self.frame_spec, text="Nonlinearity Correction", variable=self.enable_nonlinearity_correction, onvalue=True, offvalue=False)
self.chk_nonlin.select()
self.chk_nonlin.place(x=150, y=80)
# Store reference spectrum button
self.ref_spec_photo = tk.PhotoImage(file="lightbulb-on-ico.png")
self.btn_store_ref_spec = tk.Button(self.frame_spec, image=self.ref_spec_photo, command=self.store_reference_spectrum)
self.btn_store_ref_spec.place(x=139, y=115)
# Store dark spectrum button
self.dark_spec_photo = tk.PhotoImage(file="lightbulb-off-ico.png")
self.btn_store_dark_spec = tk.Button(self.frame_spec, image=self.dark_spec_photo, command=self.store_dark_spectrum)
self.btn_store_dark_spec.place(x=299, y=115)
# Canvas to draw currently measured spectrum on
self.figure_spectrum = figure.Figure((4.55, 3), dpi=100)
self.axes_spectrum = self.figure_spectrum.add_subplot()
self.canv_spectrum = FigureCanvasTkAgg(self.figure_spectrum, self.frame_spec)
# right-click context menu for the spectrum
self.menu_canv_spectrum = tk.Menu(self.canv_spectrum.get_tk_widget(), tearoff=0)
self.menu_canv_spectrum.add_command(label="Set y-min", command=self.canv_spectrum_set_ymin)
self.menu_canv_spectrum.add_command(label="Set y-max", command=self.canv_spectrum_set_ymax)
self.canv_spectrum.get_tk_widget().bind("<Button-3>", self.canv_spectrum_popup)
self.canv_spectrum.get_tk_widget().place(x=6, y=160)
# variable that stores if we should be repeatedly looping drawing the spectrum
self.should_draw_spec = False
self.should_reset_spec_limits = False # flag that indicates that spectrometer plot limits need to be reset
# variables storing the limits for each plotting mode besides
self.spec_plot_ylims_sub = (0, 180000)
self.spec_plot_ylims_t = (-0.1, 1.1)
self.spec_plot_ylims_abs = (-0.1, 2)
# Label and box to input how often we collect spectra
self.lbl_collect = tk.Label(self.frame_spec, text="Collect every:")
self.lbl_collect.place(x=70, y=470)
self.spec_freq_txt = tk.StringVar()
self.entry_collect_frequency = tk.Entry(self.frame_spec, textvariable=self.spec_freq_txt)
self.entry_collect_frequency.place(x=147, y=472, width=30)
# Unit selection list box
self.spec_freq_units = tk.StringVar() # Variable to capture input from the frequency combo box
self.combo_spec_freq_units = tk.ttk.Combobox(self.frame_spec, width=4, textvariable=self.spec_freq_units)
self.combo_spec_freq_units["values"] = ("ms", "s", "min", "hr") # Possible options to select from
self.combo_spec_freq_units.current(0) # make ms the default unit
self.combo_spec_freq_units.state(["readonly"]) # User can only pick from pre-defined options
self.combo_spec_freq_units.place(x=183, y=471)
# How should intensities be saved?
# Each file will always include the saved reference and dark spectra, so you can always go back and forth, but
# this setting controls what it defaults to
self.lbl_as = tk.Label(self.frame_spec, text="as")
self.lbl_as.place(x=230, y=470)
self.spec_intensity_type = tk.StringVar()
self.combo_spec_intensity_type = tk.ttk.Combobox(self.frame_spec, width=10, textvariable=self.spec_intensity_type)
self.combo_spec_intensity_type["values"] = ("Raw Int.", "Raw Int. - Ref", "%T or %R", "Abs")
# Callback to trigger redraws if the box is selected
self.combo_spec_intensity_type.bind("<<ComboboxSelected>>", self.combo_spec_intensity_changed)
# Raw intensity = raw spectrometer data
# Raw Int. - Ref = Raw spectrometer data minus the saved reference spectrum
# %T or %R = (Raw spectrum - dark spectrum) / (Ref. Spectrum - dark spectrum)
# Abs = -log ([Raw spectrum - dark spectrum]/ [Ref. spectrum - dark spectrum])
self.combo_spec_intensity_type.current(0)
self.combo_spec_intensity_type.state(["readonly"])
self.combo_spec_intensity_type.place(x=248, y=471)
# Collect now button
self.btn_collect_spec = tk.Button(self.frame_spec, text="Collect now", command=self.collect_spec_now)
self.btn_collect_spec.place(x=350, y=470)
self.has_spectrometer = False
self.spectrometer = None
self.reference_spec = None
self.dark_spec = None
# a lock on accessing the spectrometer between threads
self.spectrometer_lock = threading.Lock()
### End spectrometer related initialization code
# function to confirm if the user wants to quit while an experiment is running
def confirm_quit(self):
# if experiment is running
if self.running == True:
# ask if the user wants to quit
response = mbox.askyesno("Confirm Quit", "An experiment is currently running. Are you sure you want to close the software?")
# If they say yes, close
if response == True:
# Abort measurement
self.abort_measurement()
# Wait for measurement thread to finish clean-up
self.thread_measurement.join()
# end all the other threads if necessary
if (hasattr(self, "thread_draw_pstat")):
self.should_draw_pstat = False
self.thread_draw_pstat.join()
# then close window
self.root.destroy()
# otherwise, do nothing
# if experiment is not running, just close
else:
# stop running all threads then let them join
if (hasattr(self, "thread_draw_pstat")):
self.should_draw_pstat = False
self.thread_draw_pstat.join()
self.root.destroy()
# update all labels
def gui_update(self):
logger.debug("GUI Update loop top")
# expt running & cycle label
if (self.running):
# if we don't have a first pstat point yet, we're still at cycle 0
if (hasattr(self, "most_recent_pstat_pt")):
now_cycle = self.most_recent_pstat_pt[8]
else:
now_cycle = 0
# using current_expt_max_cycles shouldn't be a race condition as long as you only set
# running = True AFTER setting current_expt_max_cycles for each new expt run
logger.debug(f"Updating running label to cycle: {now_cycle}")
self.lbl_running.configure(text=f"Running: Cycle {now_cycle}/{self.current_expt_max_cycles}", fg="green")
else:
logger.debug("Updating running label to not running")
self.lbl_running.configure(text="not running", fg="red")
# Updates that only happen if spectrometer is connected (empty right now)
# Updates that only happen if pstat is connected
if (self.has_potentiostat == True):
# cell on/off label
logger.debug("Updating cell state label")
if (self.potentiostat.cell() == tkp.CELLSTATE.CELL_ON):
self.lbl_cell_state.configure(text="Cell On", fg="green")
else:
self.lbl_cell_state.configure(text="Cell Off", fg="red")
# voltage/current labels
if (self.running):
logger.debug("Getting E/i from experiment most recent pt")
# calling "measure" directly may autorange the pstat which we don't want during a measurement
# so while we are running, we just use the most recent pstat data point to update labels
potential = self.most_recent_pstat_pt[2]
current = self.most_recent_pstat_pt[4]
else:
logger.debug("Getting E/i from measure_x() func")
potential = self.potentiostat.measure_v()
current = self.potentiostat.measure_i()
logger.debug("Updating pstat potential & current labels")
# update labels
self.lbl_pstat_potential.configure(text=f"E: {potential:.3f}V")
self.lbl_pstat_current.configure(text=f"i: {current:.3E} A") # the .3E = 3 decimal places, in scientific notation
logger.debug("GUI Update bottom")
# loop GUI update
logger.debug("GUI Update loop bottom")
self.root.after(500, self.gui_update)
# Open a dialogue box to change the experiment name
def edit_exp_name(self):
self.experiment_name = simpledialog.askstring("Edit Experiment Title", "Enter the experiment title (letters, numbers, spaces, and _ OK): ", initialvalue=self.experiment_name)
# Open dialogue box to change the operator name
def edit_operator(self):
self.operator = simpledialog.askstring("Edit Operator Name", "Enter Operator Name: ", initialvalue=self.operator)
def edit_description(self):
self.description = simpledialog.askstring("Edit Description", "Enter experiment description: ", initialvalue=self.description)
def edit_emails(self):
self.emails = simpledialog.askstring("Edit Emails", "Enter email(s) to notify when experiment finishes, separated by comma", initialvalue=self.emails)
# Choose the directory where the files will be outputted (filename is generated based on experiment name)
def choose_out_dir(self):
self.save_dir = filedialog.askdirectory(initialdir=self.save_dir)
self.lbl_filename.configure(text=f"Save to: {self.save_dir}")
def combo_spec_intensity_changed(self, event):
# trigger a reset of the spec limits
if (self.has_spectrometer == True):
self.should_reset_spec_limits = True
def canv_spectrum_popup(self, event):
try:
self.menu_canv_spectrum.tk_popup(event.x_root, event.y_root)
finally:
self.menu_canv_spectrum.grab_release()
def canv_spectrum_set_ymin(self):
# do nothing if we aren't plotting
if (self.has_spectrometer == False):
return
# ask new ymin
intensity_type = self.spec_intensity_type.get()
if (intensity_type == "Raw Int."):
# we always use the same limits for this type, so do nothing
return
elif (intensity_type == "Raw Int. - Ref"):
old_min, old_max = self.spec_plot_ylims_sub
new_ymin = simpledialog.askfloat("Spectrometer Plot y-min", "Enter the new y-min", initialvalue=old_min)
if (new_ymin is not None):
self.spec_plot_ylims_sub = (new_ymin, old_max)
elif (intensity_type == "%T or %R"):
old_min, old_max = self.spec_plot_ylims_t
new_ymin = simpledialog.askfloat("Spectrometer Plot y-min", "Enter the new y-min", initialvalue=old_min)
if (new_ymin is not None):
self.spec_plot_ylims_t = (new_ymin, old_max)
elif (intensity_type == "Abs"):
old_min, old_max = self.spec_plot_ylims_abs
new_ymin = simpledialog.askfloat("Spectrometer Plot y-min", "Enter the new y-min", initialvalue=old_min)
if (new_ymin is not None):
self.spec_plot_ylims_abs = (new_ymin, old_max)
# flag spec plot limits for an update
self.should_reset_spec_limits = True
def canv_spectrum_set_ymax(self):
# do nothing if we aren't plotting
if (self.has_spectrometer == False):
return
# ask new ymin
intensity_type = self.spec_intensity_type.get()
if (intensity_type == "Raw Int."):
# we always use the same limits for this type, so do nothing
return
elif (intensity_type == "Raw Int. - Ref"):
old_min, old_max = self.spec_plot_ylims_sub
new_ymax = simpledialog.askfloat("Spectrometer Plot y-max", "Enter the new y-max", initialvalue=old_max)
if (new_ymax is not None):
self.spec_plot_ylims_sub = (old_min, new_ymax)
elif (intensity_type == "%T or %R"):
old_min, old_max = self.spec_plot_ylims_t
new_ymax = simpledialog.askfloat("Spectrometer Plot y-max", "Enter the new y-max", initialvalue=old_max)
if (new_ymax is not None):
self.spec_plot_ylims_t = (old_min, new_ymax)
elif (intensity_type == "Abs"):
old_min, old_max = self.spec_plot_ylims_abs
new_ymax = simpledialog.askfloat("Spectrometer Plot y-max", "Enter the new y-max", initialvalue=old_max)
if (new_ymax is not None):
self.spec_plot_ylims_abs = (old_min, new_ymax)
# flag spec plot limits for an update
self.should_reset_spec_limits = True
# Attempt to access the Gamry Potentiostat
def connect_pstat(self):
# Get list of connected Gamry devices
logger.info("Attempting to connect to pstat...")
device_list = tkp.enum_sections()
if (len(device_list) == 0): # List is empty, no attatched pstat
logger.warning("No Gamry Potentiostat found")
mbox.showwarning("Warning", "No connected Gamry Potentiostats found!")
else:
# We have a pstat, try to connect to first one
logger.info("At least on PStat available, trying to connect...")
self.potentiostat = tkp.Pstat(device_list[0])
# Open connection to the Pstat
self.potentiostat.open()
model = self.potentiostat.model_no()
print(f"Serial No.: {self.potentiostat.serial_no()}")
logger.info(f"Connected pstat: Serial No. {self.potentiostat.serial_no()}")
self.has_potentiostat = True
# TODO: See about changing current convention (which direction is positive?)
self.lbl_pstat_connected.configure(text="connected", fg="green")
self.lbl_pstat_model.configure(text=f"Model: {model}")
# Begin updating the labels showing voltage and current
self.should_update_pstat_labels = True
# Legacy code - TBR
# self.thread_pstat_labels = threading.Thread(target=self.update_pstat_readinglabels)
# self.thread_pstat_labels.start()
''' Legacy Code - TBR
def update_pstat_readinglabels(self):
# Loop
while self.should_update_pstat_labels:
# only run if potentiostat is connected
if (self.has_potentiostat):
# if an experiment is actively running, just get the most recent value from that.
# The measure commands can force the potentiostat to autorange the current, which we
# don't want during an experiment more than necessary
if (self.running):
potential = self.most_recent_pstat_pt[2]
current = self.most_recent_pstat_pt[4]
else:
potential = self.potentiostat.measure_v()
current = self.potentiostat.measure_i()
self.lbl_pstat_potential.configure(text=f"E: {potential:.3f}V")
self.lbl_pstat_current.configure(text=f"i: {current:.3e} A")
else:
break # stop execution of this thread if pstat no longer connected
time.sleep(0.5) # sleep for half a second
'''
# Attempt to connect to an OceanOptics spectrometer via USB
def connect_spectrometer(self):
logger.info("Attempting to connect to OceanOptics Spectrometer...")
try:
logger.info("At least one spectrometer available, trying to connect...")
# Try to grab the first spectrometer available
self.spectrometer = sb.spectrometers.Spectrometer.from_first_available()
logger.info("Spectrometer connected successfully")
# If we get one, update the text to show we are connected and what the spectrometer model label is
self.lbl_spec_connected.configure(text="Connected", fg="green")
model = self.spectrometer.model
logger.info(f"Spectrometer model: {model}")
self.lbl_spec_model.configure(text=f"Model: {model}")
self.has_spectrometer = True
# Also update the integration time to be the minimum the instrument supports
limits = self.spectrometer.integration_time_micros_limits
# Update the instrument
self.spectrometer.integration_time_micros(limits[0])
self.integration_time_micros = limits[0]
# Update the text box
min_ms = limits[0] / 1000 # Convert from micros to ms
self.entry_integ_time.delete(0, tk.END)
self.entry_integ_time.insert(0, f"{min_ms}")
# The wavelengths attached to each pixel don't change. So save them at the start to save some processing time
self.wavelengths = self.spectrometer.wavelengths()
self.has_reference_spec = False
self.dark_spec = np.zeros((len(self.wavelengths)))
# Finally, start drawing the current spectrum the instrument records on the canvas on repeat
self.draw_first_spec()
except sb.spectrometers.SeaBreezeError as e:
logger.warning("No available OceanOptics Spectrometer found")
logger.warning(e)
# If we can't get a spectrometer
self.spectrometer = None
mbox.showwarning("Failed to connect to OceanOptics Spectrometer", e)
# draw the first spectrum, that will later be modified by repeat calls to draw_spec
def draw_first_spec(self):
logger.info("Starting drawing first spectrum")
now_intensities = self.spectrometer.intensities(self.enable_dark_correction, self.enable_nonlinearity_correction)
# first spectrum is always just raw intensity
self.axes_spectrum.clear()
# disable autoscale
self.axes_spectrum.autoscale(enable=False)
# plot the raw intensities
logger.debug("draw_first_spec: calling plot()")
self.line2d_spec, = self.axes_spectrum.plot(self.wavelengths, now_intensities, color="blue")
# plot a "max intensity" line at 170,000 intensity
self.line2d_spec_int_max, = self.axes_spectrum.plot([0, 3000], [170000, 170000], "--", color="red")
# set plot labels, etc.
self.axes_spectrum.set_xlabel("Wavelength (nm)")
self.axes_spectrum.set_ylabel("Intensity (a.u.)")
self.axes_spectrum.set_xlim(self.wavelengths[0], self.wavelengths[-1])
self.axes_spectrum.set_ylim(0, 180000)
self.axes_spectrum.grid()
# finally execute the draw call
logger.debug("draw_first_spec: calling draw()")
self.canv_spectrum.draw()
# begin the looping draw
self.root.after(1000, self.draw_spec)
# Draw command, to draw the most recent spectrum
def draw_spec(self):
logger.debug("draw_spec: Beginning of call")
if (self.has_spectrometer):
# block access to spectrometer til intensities call returns
with self.spectrometer_lock:
now_intensities = self.spectrometer.intensities(self.enable_dark_correction, self.enable_nonlinearity_correction)
intensity_type = self.spec_intensity_type.get()
if (intensity_type == "Raw Int."):
logger.debug("Updating y-data for raw intensity")
# update line data
self.line2d_spec.set_ydata(now_intensities)
# set max int. line visible if not visible
self.line2d_spec_int_max.set_visible(True)
if (self.should_reset_spec_limits == True):
# set y-scale
self.axes_spectrum.set_ylim(0, 180000)
self.should_reset_spec_limits = False
elif (intensity_type == "Raw Int. - Ref" and self.has_reference_spec):
logger.debug("Updating y-data for raw - ref")
# update line data
calc_intensities = now_intensities - self.reference_spec
self.line2d_spec.set_ydata(calc_intensities)
self.line2d_spec_int_max.set_visible(False)
if (self.should_reset_spec_limits == True):
# set y-scale
self.axes_spectrum.set_ylim(self.spec_plot_ylims_sub)
self.should_reset_spec_limits = False
elif (intensity_type == "%T or %R" and self.has_reference_spec):
logger.debug("Updating y-data for %T/%R")
calc_intensities = (now_intensities - self.dark_spec) / (self.reference_spec - self.dark_spec)
self.line2d_spec.set_ydata(calc_intensities)
self.line2d_spec_int_max.set_visible(False)
if (self.should_reset_spec_limits == True):
# set y-scale
self.axes_spectrum.set_ylim(self.spec_plot_ylims_t)
self.should_reset_spec_limits = False
elif (intensity_type == "Abs" and self.has_reference_spec):
logger.debug("Updating y-data for Abs")
calc_T = (now_intensities - self.dark_spec) / (self.reference_spec - self.dark_spec)
calc_intensities = np.log10(calc_T)
self.line2d_spec.set_ydata(calc_intensities)
self.line2d_spec_int_max.set_visible(False)
if (self.should_reset_spec_limits == True):
# set y-scale
self.axes_spectrum.set_ylim(self.spec_plot_ylims_abs)
self.should_reset_spec_limits = False
logger.debug("draw_spec: calling draw_idle()")
self.canv_spectrum.draw_idle()
logger.debug("draw_spec: calling flush_events()")
self.canv_spectrum.flush_events()
# loop the draw call
logger.debug("draw_spec: End of call")
self.root.after(1000, self.draw_spec)
''' # Legacy code, TBR
def draw_spec(self):
while (self.should_draw_spec):
if (self.has_spectrometer == False):
continue
# get the latest intensities
now_intensities = self.spectrometer.intensities(self.enable_dark_correction, self.enable_nonlinearity_correction)
# figure out what kind of spectrum we are drawing
intensity_type = self.spec_intensity_type.get()
if (intensity_type == "Raw Int."):
# clear the figure's axes
self.axes_spectrum.clear()
# plot the raw intensities
self.axes_spectrum.plot(self.wavelengths, now_intensities, color="blue")
# plot a "max intensity" line at 170,000 intensity
self.axes_spectrum.plot([0, 3000], [170000, 170000], "--", color="red")
# set plot labels, etc.
self.axes_spectrum.set_xlabel("Wavelength (nm)")
self.axes_spectrum.set_ylabel("Raw Intensity (a.u.)")
self.axes_spectrum.set_xlim(self.wavelengths[0], self.wavelengths[-1])
self.axes_spectrum.set_ylim(0, 180000)
self.axes_spectrum.grid()
# finally execute the draw call
self.canv_spectrum.draw()
elif (intensity_type == "Raw Int. - Ref" and self.has_reference_spec):
calc_intensities = now_intensities - self.reference_spec
self.axes_spectrum.clear()
self.axes_spectrum.plot(self.wavelengths, calc_intensities, color="blue")
self.axes_spectrum.set_xlabel("Wavelength (nm)")
self.axes_spectrum.set_ylabel("Subtracted Intensity")
self.axes_spectrum.set_xlim(self.wavelengths[0], self.wavelengths[-1])
self.axes_spectrum.grid()
self.canv_spectrum.draw()
elif (intensity_type == "%T or %R" and self.has_reference_spec):
calc_intensities = (now_intensities - self.dark_spec) / (self.reference_spec - self.dark_spec)
self.axes_spectrum.clear()
self.axes_spectrum.plot(self.wavelengths, calc_intensities, color="blue")
self.axes_spectrum.set_xlabel("Wavelength (nm)")
self.axes_spectrum.set_ylabel("Fractional T or R")
self.axes_spectrum.set_xlim(self.wavelengths[0], self.wavelengths[-1])
self.axes_spectrum.grid()
self.canv_spectrum.draw()
elif (intensity_type == "Abs" and self.has_reference_spec):
fraction = (now_intensities - self.dark_spec) / (self.reference_spec - self.dark_spec)
calc_abs = -1 * np.log10(fraction)
self.axes_spectrum.clear()
self.axes_spectrum.plot(self.wavelengths, calc_abs)
self.axes_spectrum.set_xlabel("Wavelength (nm)")
self.axes_spectrum.set_ylabel("Absorbance")
self.axes_spectrum.set_xlim(self.wavelengths[0], self.wavelengths[-1])
self.axes_spectrum.grid()
self.canv_spectrum.draw()
# sleep until next run
time.sleep(self.spec_draw_time)
'''
# Sets integration time based off the value user has in the box for it
def set_integ_time(self):
# only need to do anything if spectrometer is connected
if (self.has_spectrometer and self.running == False):
# Try to convert the text to a float, if it doesn't work let the user know
try:
integ_time_micros = 1000 * float(self.integ_time_txt.get())
# Double check that integ time falls within limits
limits = self.spectrometer.integration_time_micros_limits
if (integ_time_micros > limits[0]) and (integ_time_micros < limits[1]):
with self.spectrometer_lock:
self.spectrometer.integration_time_micros(integ_time_micros)
self.integration_time_micros = integ_time_micros
else:
mbox.showwarning("Could not set integ. time", "Value is outside hardcoded spectrometer limits")
except ValueError:
mbox.showwarning("Could not set integ. time", "Value does not appear to be a string")
# Stores reference spectrum based on current spectrometer input
def store_reference_spectrum(self):
if (self.has_spectrometer and self.running == False):
with self.spectrometer_lock:
self.reference_spec = self.spectrometer.intensities(self.enable_dark_correction, self.enable_nonlinearity_correction)
self.has_reference_spec = True
logger.info("Stored reference spectrum successfully")
print("Stored reference spectrum successfully")
# Stores dark spectrum based on current spectrometer input
# Dark spectrum is subtracted from what we actually measure
def store_dark_spectrum(self):
if (self.has_spectrometer and self.running == False):
with self.spectrometer_lock:
self.dark_spec = self.spectrometer.intensities(self.enable_dark_correction, self.enable_nonlinearity_correction)
logger.info("Stored dark spectrum successfully")
print("Stored dark spectrum successfully")
# If a spectrometer is attached, collect a spectrum and save right away
def collect_spec_now(self):
logger.info("Trying to collect spectrum now...")
if (self.has_spectrometer == False):
logger.warning("No spectrometer connected to collect from!")
return
# Make sure we have what we need to calc. requested intensity type
if (self.spec_intensity_type.get() != "Raw Int." and self.has_reference_spec == False):
logger.warning("No reference spectrum saved, but is needed to calculate intensity type")
mbox.showwarning("Error saving file", "Chosen intensity type requires a reference spectrum")
return
# Get the current intensities
with self.spectrometer_lock:
now_intensities = self.spectrometer.intensities(self.enable_dark_correction, self.enable_nonlinearity_correction)
# Get the current time
now = datetime.datetime.now()
# Convert the intensity type from the drop down menu to one more filename friendly
filename_intensity_type = {"Raw Int.": "Raw_Intensity",
"Raw. Int. - Ref": "Reference_Sub_Intensity",
"%T or %R": "Transmittance",
"Abs": "Abs"}
intensity_type = self.spec_intensity_type.get()
intensity_type_text = filename_intensity_type[intensity_type]
# Assemble the filename
filename = f"{intensity_type_text}_{now.isoformat()}.csv"
# Replace colons in the filename (windows doesn't like them)
filename = filename.replace(":", "-")
# add the folder name
filename = f"{self.save_dir}/{filename}"
# Try and save the file
logger.info("Trying to open file to write current spectrum to")
with open(filename, "w") as outfile:
# First write the header
outfile.write("Ocean Optics spectrometer spectrum generated by Lighthouse\n")
outfile.write(f"Spectrometer model: {self.spectrometer.model}\n")
outfile.write(f"Integration time (ms): {self.integration_time_micros/1000:.0f}\n")
outfile.write(f"Electric dark correction enabled: {self.enable_dark_correction.get()}\n")
outfile.write(f"Nonlinearity correction enabled: {self.enable_nonlinearity_correction.get()}\n")
outfile.write(f"Number of data points: {len(self.wavelengths):d}\n\n")
outfile.write(">>>Begin Data<<<\n")
outfile.write(f"Wavelength_nm,{self.spec_intensity_type.get()},")
if (self.has_reference_spec):
outfile.write("Reference Int.,")
outfile.write("Dark Int.\n")
# Then write out all the rows sequentially
for i in range(0, len(self.wavelengths)):
# Write the wavelength first
outfile.write(f"{self.wavelengths[i]},")
# Then, calculate the desired intensity type, and write that to the file
if self.spec_intensity_type.get() == "Raw Int.":
outfile.write(f"{now_intensities[i]},")
elif self.spec_intensity_type.get() == "Raw Int. - Ref":
out_intensity = now_intensities[i] - self.reference_spec[i]
outfile.write(f"{out_intensity},")
elif self.spec_intensity_type.get() == "%T or %R":
out_intensity = (now_intensities[i] - self.dark_spec[i]) / (self.reference_spec[i] - self.dark_spec[i])
outfile.write(f"{out_intensity},")
elif self.spec_intensity_type.get() == "Abs":
transmittance = (now_intensities[i] - self.dark_spec[i]) / (self.reference_spec[i] - self.dark_spec[i])
out_intensity = -1*np.log10(transmittance)
outfile.write(f"{out_intensity},")
# Then write the reference spectrum, if there is one
if (self.has_reference_spec):
outfile.write(f"{self.reference_spec[i]},")
# Finally write the dark spectrum and end the line
outfile.write(f"{self.dark_spec[i]}\n")
logger.info("Successfully wrote file")
# Function to plot the pstat data from currently running experiment
''' LEGACY CODE TBR
def plot_pstat_curve(self):
# pause very briefly to allow for initial data collection
logger.info("Starting pstat curve plotting")
time.sleep(0.3)
while (self.should_draw_pstat):
logger.debug("plot_pstat_curve: Top of pstat draw loop")
now_pstat_pt = self.acq_curve.last_data_point()
num_pts = self.acq_curve.count()
# if there aren't enough points, skip plotting for now
if (num_pts < 2 or now_pstat_pt is None):
logger.debug("Not enough to pts to plot")
continue
# get all the currently acquired data
logger.debug("plot_pstat_curve: calling acq_data()")
data = self.acq_curve.acq_data()
# get the elapsed time (to determine when to plot)
elapsed_time = now_pstat_pt[1]
# get columns 2 and 4 from the data table, which correspond to potentials in V and current in A respectively
# try block in here temporarily to see if it gets around this thread crashing on start
try:
potentials = data["vf"]
currents = data["im"]
cycles = data["cycle"]
except IndexError:
print("Index error accessing CV data for plotting")
continue
# down sample as needed
if (num_pts < 10000):
# full # of points if less than 10k
logger.debug("plot_pstat_curve: Plotting all points in pstat curve")
plot_potentials = potentials
plot_currents = currents
plot_cycles = cycles
elif (num_pts < 100000):
# every 5th point if btwn 10k-100k pts
logger.debug("plot_pstat_curve: Plotting every 5th point in pstat curve")
plot_potentials = potentials[::5]
plot_currents = currents[::5]
plot_cycles = cycles[::5]
else:
# if we have > 100,000 pts, only plot every 10th point to save on memory
logger.debug("plot_pstat_curve: Plotting every 10th point in pstat curve")
plot_potentials = potentials[::10]
plot_currents = currents[::10]
plot_cycles = cycles[::10]
# clear the old plot
logger.debug("plot_pstat_curve: calling clear()")
self.axes_cv.clear()
# find the first position of the current cycle
now_cycle = now_pstat_pt[8]
now_cycle_index = np.argmax(plot_cycles == now_cycle) # this returns 0 if the first index is 0 or 0 on a fail to find
if (now_cycle_index == 0):
# in the "0 index case" - we have either only the first cycle,
# or some weird error where we couldn't find the current cycle in our data here
# regardless, just plot everything as-is
logger.debug("plot_pstat_curve: calling plot() for all cycles in blue")
self.axes_cv.plot(plot_potentials, plot_currents, color="blue")
else: # if we have > 1 cycle & we can find an index, sketch current cycle in different color
# plot everything up to where the current cycle starts in blue
logger.debug("plot_pstat_curve: calling plot() for previous cycles in blue")
self.axes_cv.plot(plot_potentials[:now_cycle_index], plot_currents[:now_cycle_index], color="blue")
# plot everything after in red
logger.debug("plot_pstat_curve: calling plot() for current cycle in red")
self.axes_cv.plot(plot_potentials[now_cycle_index:], plot_currents[now_cycle_index:], color="red")
# label axes
self.axes_cv.set_xlabel("WE Potential (V)")
self.axes_cv.set_ylabel("Current (A)")
# grid
self.axes_cv.grid()
# draw the updates
logger.debug("plot_pstat_curve: calling draw()")
self.canv_cv.draw()
logger.debug("Bottom of pstat draw loop")
# if the time is past the first minute, plot only every 30 s
if (elapsed_time > 30):
time.sleep(30)
else:
time.sleep(3)
'''
def plot_first_pstat_curve(self):
logger.info("Starting pstat curve plotting")
# Pause to allow for initial data collection
time.sleep(0.5)
logger.debug("plot_first_pstat_curve: getting last data pt")
now_pstat_pt = self.acq_curve.last_data_point()
num_pts = self.acq_curve.count()
# plot dummy data if we don't have enough points yet
if (num_pts < 2 or now_pstat_pt is None):
logger.debug("plot_first_pstat_curve: not enough points to plot, plotting dummy data")
self.line2d_cv = self.axes_cv.plot([0], [0])
self.line2d_cv_currentcycle = self.axes_cv.plot([-10], [0], color="red")
else:
logger.debug("plot_first_pstat_curve: plotting first points")
data = self.acq_curve.acq_data()
potentials = data["vf"]
currents = data["im"]
# plot & create line2d object to update later
self.line2d_cv = self.axes_cv.plot(potentials, currents, color="blue")
self.line2d_cv_currentcycle = self.axes_cv.plot([-10], [0], color="red")
logger.debug("plot_first_pstat_curve: Setting x-limits")
# fix x-bounds to the known potential window
self.axes_cv.autoscale(enable=False, axis="both")
if (self.current_expt_v1 < self.current_expt_v2):
self.axes_cv.set_xlim(self.current_expt_v1, self.current_expt_v2)
else:
self.axes_cv.set_xlim(self.current_expt_v2, self.current_expt_v1)
# label axes
self.axes_cv.set_xlabel("WE Potential (V)")
self.axes_cv.set_ylabel("Current (A)")
# grid
self.axes_cv.grid()
# draw the updates
logger.debug("plot_first_pstat_curve: beginning draw call")
self.canv_cv.draw_idle()
logger.debug("plot_first_pstat_curve: flush events call")
self.canv_cv.flush_events()
# queue up the next draw
logger.debug("plot_first_pstat_curve: calling root.after()")
self.root.after(1000, self.plot_pstat_curve)