diff --git a/.gitignore b/.gitignore
index 7348e2b..4b332d3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,9 @@
-*.wpr
*.pyc
+*.dat
+.vscode/
+*.wpr
+/measdata_*
+*.log
+log.txt
+20112117.xml
+config*.yml
diff --git a/Arduino.py b/Arduino.py
deleted file mode 100644
index c10d9ad..0000000
--- a/Arduino.py
+++ /dev/null
@@ -1,120 +0,0 @@
-import serial
-import random
-import re
-
-global ser_py, debug_arduino, test_arduino
-
-
-def my_split(s):
- return filter(None, re.split(r'(\d+)', s))
-
-
-###########################################################################
-def Init_Arduino(i, com, rate, bits, stop, parity, dbg, test):
-###########################################################################
-
- global ser_py, debug_arduino, test_arduino
-
- debug_arduino = dbg
- test_arduino = test
- if i == 0:
- ser_py = list(range(0))
- portName = 'COM'+str(com)
- try:
- serial.Serial(port=portName)
- except serial.SerialException:
- print ('Port ' + portName + ' not present')
- if not test_arduino:
- quit()
-
- if not test_arduino:
- ser_py_ = serial.Serial(
- port = portName,
- baudrate = int(rate),
- parity = parity,
- stopbits = int(stop),
- bytesize = int(bits),
- timeout = 2.0)
-
- ser_py.append(ser_py_)
-
-
-
-###########################################################################
-def my_Read_Arduino(i, end_id):
-###########################################################################
-
- end_of_read = False
- st = ''
- while not end_of_read:
- char = ser_py[i].read(1).decode()
- st = st + char
- st = st.replace('\r', '\\r')
- st = st.replace('\n', '\\n')
- if end_id in st :
- end_of_read = True
- return (st)
-
-###########################################################################
-def Read_Arduino(i, cmd_list, read_id_list, end_id_list, position_list, separator_list):
-###########################################################################
-
- result = list(range(0))
- for j in range(len(cmd_list)):
- if not test_arduino:
- if debug_arduino:
- print ('Sending to ' + ser_py[i].port + ': ' + cmd_list[j])
- ser_py[i].write((cmd_list[j]+'\r').encode())
- #_temp = ser_py[i].readline().decode()
- #_temp = _temp.rstrip()
- _temp = my_Read_Arduino(i, end_id_list[j])
- else:
- if cmd_list[j] == 'g':
- _temp = 'g=41.19\\r\\n'
- elif cmd_list[j] =='c':
- _temp = 'c=24.50'
- elif cmd_list[j] == 'h':
- _temp = 'h=1'
- elif cmd_list[j] =='IN_PV_1':
- _temp = '0.0 1'
- elif cmd_list[j] =='GN_PV_2':
- _temp = '25.0 2'
- elif cmd_list[j] =='HN_X':
- _temp = '14.5XSTART=23.4;99.99;15;XEND77.7;'
- else:
- _temp = ''
-
- if debug_arduino:
- print ('reading from COM port: ', _temp)
-
- #_temp = _temp.replace('\r', '\\r')
- #_temp = _temp.replace('\n', '\\n')
-
- if read_id_list[j] in _temp:
- pos = _temp.find(read_id_list[j])
- pos2 = pos + len(read_id_list[j])
- temp = _temp[pos2:]
- if end_id_list[j] in temp:
- pos = temp.find(end_id_list[j])
- temp = temp[:pos]
- if position_list[j] != '':
- temp = temp.split(separator_list[j])[int(position_list[j])-1]
- else:
- z = 0
- nb_found = False
- temp_ = temp
- while not nb_found:
- try:
- temp_ = temp[:len(temp)-z]
- nb = float(temp_)
- nb_found = True
- temp = temp_
- except ValueError:
- z += 1
- continue
-
- if debug_arduino:
- print ('filtered from COM port: ', temp)
-
- result.append(float(temp))
- return (result)
diff --git a/DAQ_6510.py b/DAQ_6510.py
deleted file mode 100644
index d0615e5..0000000
--- a/DAQ_6510.py
+++ /dev/null
@@ -1,736 +0,0 @@
-import serial
-import pyvisa
-import numpy as np
-import random
-
-global ser_daq, debug_daq, test_daq, reading_str, nb_reading_values, ch_list, sensor_list, type_list, \
- daq_sensor_types, nplc
-global ch_list_temp, ch_list_TC_K, ch_list_TC_J, ch_list_ohm, ch_list_PT_100, \
- ch_list_PT_1000, ch_list_DCV_Volt, ch_list_DCV_100mV
-
-
-###########################################################################
-def init_daq(com, rate, bits, stop, parity, dbg, test, time):
-###########################################################################
-### Init the interface
-
- global ser_daq, debug_daq, test_daq, time_daq, nplc, interface
-
- debug_daq = dbg
- test_daq = test
- time_daq = time
-
- if com != 'LAN':
- interface = 'COM'
- portName = 'COM'+str(com)
- try:
- serial.Serial(port=portName)
- except serial.SerialException:
- print ('Port ' + portName + ' not present')
- if not test_daq:
- quit()
-
- if not test_daq:
- ser_daq = serial.Serial(
- port = portName,
- baudrate = int(rate),
- stopbits = int(stop),
- bytesize = int(bits),
- parity = parity,
- timeout = 2.0) #2.0
- else:
- interface = 'LAN'
- rm = pyvisa.ResourceManager()
- ser_daq = rm.open_resource('daq')
-
-
-
-###########################################################################
-def get_daq():
-###########################################################################
-### get the measurements from the active channels
-
- cm_list = list(range(0))
- cm_list.append('TRAC:CLE\n')
- cm_list.append('TRAC:POIN 100\n')
- cm_list.append('ROUT:SCAN:CRE ' + reading_str + '\n')
- cm_list.append('ROUT:SCAN:COUN:SCAN 1\n')
- cm_list.append('INIT\n')
- cm_list.append('*WAI\n')
-
-
- readings = '1,' + str(nb_reading_values) + ','
-
- if not time_daq:
- # reading returns a comma separated string: ch,value,ch,value,...
- cm_list.append('TRAC:DATA? '+ readings + '"defbuffer1", CHAN, READ\n')
- else:
- # reading returns a comma separated string: ch,tst,value,ch,tst,value,...
- cm_list.append('TRAC:DATA? '+ readings + '"defbuffer1", CHAN, REL, READ\n')
-
-
- if not test_daq:
- for i in range(len(cm_list)):
- if debug_daq:
- print ('writing to COM port: ', cm_list[i])
- if interface == 'LAN':
- ser_daq.write(cm_list[i])
- else:
- ser_daq.write(cm_list[i].encode())
- if interface == 'LAN':
- instrument = ser_daq.read()
- else:
- instrument = ser_daq.readline().decode()
-
- if debug_daq:
- print ('reading from COM : ' + str(instrument))
- else:
- instrument = ''
- for i in range(nb_reading_values):
- val = random.uniform(20,22)
- instrument = instrument + ch_list[i] + ',' + str(val) + ','
- return (instrument[:-1])
-
-
-###########################################################################
-def config_daq(ch, alias, s, t, types, nplc, PT1000mode, lsync, ocom, azer, adel):
-###########################################################################
-### Configure the measurement channels
-### ch . channel list
-### s : sensor list
-### t : type of each sensor or each range
-### types : sensor types
-### nplc : nplc for each channel
- global reading_str, nb_reading_values, ch_list, alias_list, sensor_list, type_list, \
- daq_sensor_types, daq_nplc_list, PT_1000_mode, daq_lsync, daq_ocom, daq_azer, daq_adel
- global ch_list_temp, ch_list_TC_K, ch_list_TC_J, ch_list_ohm, ch_list_PT_100, \
- ch_list_PT_1000, ch_list_DCV_Volt, ch_list_DCV_100mV, ch_list_ACV
-
- ch_nb_TC = list(range(0))
- ch_nb_PT_100 = list(range(0))
- ch_nb_PT_1000 = list(range(0))
- ch_nb_DCV_Volt = list(range(0))
- ch_nb_DCV_100mV = list(range(0))
- ch_nb_ACV = list(range(0))
- ch_nb_Rogowski = list(range(0))
- nplc_TE = list(range(0))
- nplc_PT_100 = list(range(0))
- nplc_PT_1000 = list(range(0))
- nplc_DCV_Volt = list(range(0))
- nplc_DCV_100mV = list(range(0))
- nplc_Rogowski = list(range(0))
- PT_1000_mode = PT1000mode
- ch_list = ch
- alias_list = alias
- sensor_list = s
- type_list = t
- daq_sensor_types = types
- daq_nplc_list = nplc
- if lsync:
- daq_lsync = 'ON, '
- else:
- daq_lsync = 'OFF, '
- if ocom:
- daq_ocom = 'ON, '
- else:
- daq_ocom = 'OFF, '
- if azer:
- daq_azer = 'ON, '
- else:
- daq_azer = 'OFF, '
- if adel:
- daq_adel = 'ON, '
- else:
- daq_adel = 'OFF, '
-
-
- #this will be the channel string for the measurements
- reading_str = '(@'
- for i in range(len(ch)):
- reading_str = reading_str + ch[i] + ','
- reading_str = reading_str[:-1] + ')'
- print ('reading string : ', reading_str)
- nb_reading_values = len(ch)
- print ('number of DAQ readings = ', nb_reading_values)
-
- ch_list_temp = '(@'
- ch_list_TC_K = '(@'
- ch_list_TC_J = '(@'
- ch_list_ohm = '(@'
- ch_list_PT_100 = '(@'
- ch_list_PT_1000 = '(@'
- ch_list_DCV_100mV = '(@'
- ch_list_DCV_Volt = '(@'
- ch_list_ACV = '(@'
- ch_list_Rogowski = '(@'
- range_list_DCV = list(range(0))
- range_list_Rogowski = list(range(0))
- range_list_ACV = list(range(0))
- nb_K = 0
- nb_J = 0
- nb_PT100 = 0
- nb_PT1000 = 0
- nb_DCV_100mV = 0
- nb_DCV_Volt = 0
- nb_ACV = 0
- nb_Rogowski = 0
-
- # generate the Keithley channels and range lists
- for i in range(len(ch)):
- if s[i] == 'TE':
- ch_list_temp = ch_list_temp + ch[i] + ','
- ch_nb_TC.append(ch[i])
- nplc_TE.append(daq_nplc_list[i])
- if t[i] == 'K':
- nb_K += 1
- ch_list_TC_K = ch_list_TC_K + ch[i] + ','
- if t[i] == 'J':
- nb_J += 1
- ch_list_TC_J = ch_list_TC_J + ch[i] + ','
- if s[i] == 'PT':
- ch_list_ohm = ch_list_ohm + ch[i] + ','
- if t[i] == '100':
- nb_PT100 += 1
- ch_list_PT_100 = ch_list_PT_100 + ch[i] + ','
- ch_nb_PT_100.append(ch[i])
- nplc_PT_100.append(daq_nplc_list[i])
- if t[i] == '1000':
- nb_PT1000 += 1
- ch_list_PT_1000 = ch_list_PT_1000 + ch[i] + ','
- ch_nb_PT_1000.append(ch[i])
- nplc_PT_1000.append(daq_nplc_list[i])
- if s[i] == 'DCV-100mV':
- ch_list_DCV_100mV = ch_list_DCV_100mV + ch[i] + ','
- ch_nb_DCV_100mV.append(ch[i])
- nplc_DCV_100mV.append(daq_nplc_list[i])
- nb_DCV_100mV += 1
- if s[i] == 'DCV':
- ch_list_DCV_Volt = ch_list_DCV_Volt + ch[i] + ','
- ch_nb_DCV_Volt.append(ch[i])
- nplc_DCV_Volt.append(daq_nplc_list[i])
- range_list_DCV.append(t[i])
- nb_DCV_Volt += 1
- if s[i] == 'ACV':
- ch_list_ACV = ch_list_ACV + ch[i] + ','
- ch_nb_ACV.append(ch[i])
- range_list_ACV.append(t[i])
- nb_ACV += 1
- if s[i] == 'Rogowski':
- ch_list_Rogowski = ch_list_Rogowski + ch[i] + ','
- ch_nb_Rogowski.append(ch[i])
- nplc_Rogowski.append(daq_nplc_list[i])
- range_list_Rogowski.append(t[i])
- nb_Rogowski += 1
-
-
- ch_list_temp = ch_list_temp[:-1] + ')'
- ch_list_TC_K = ch_list_TC_K[:-1] + ')'
- ch_list_TC_J = ch_list_TC_J[:-1] + ')'
- ch_list_ohm = ch_list_ohm[:-1] + ')'
- ch_list_PT_100 = ch_list_PT_100[:-1] + ')'
- ch_list_PT_1000 = ch_list_PT_1000[:-1] + ')'
- ch_list_DCV_100mV = ch_list_DCV_100mV[:-1] + ')'
- ch_list_DCV_Volt = ch_list_DCV_Volt[:-1] + ')'
- ch_list_ACV = ch_list_ACV[:-1] + ')'
- ch_list_Rogowski = ch_list_Rogowski[:-1] + ')'
- print ('Keithley NPLC(s): ', daq_nplc_list)
- print ('Keithley sensor list: ', sensor_list)
- print ('Keithley range list: ', type_list)
- print ('Keithley TE channel list: ', ch_list_temp)
- print ('Keithley TE - K channel list: ', ch_list_TC_K)
- print ('Keithley TE - J channel list: ', ch_list_TC_J)
- print ('Keithley PT channel list: ', ch_list_ohm)
- print ('Keithley PT-100 channel list: ', ch_list_PT_100)
- print ('Keithley PT-1000 channel list: ', ch_list_PT_1000)
- print ('Keithley DCV-100mV channel list: ', ch_list_DCV_100mV)
- print ('Keithley DCV-Volt channel list: ', ch_list_DCV_Volt)
- print ('Keithley Rogowski channel list: ', ch_list_Rogowski)
- print ('Keithley ACV channel list: ', ch_list_ACV)
-
- cm_list = list(range(0))
- cm_list.append(':SYSTEM:CLEAR\n')
- cm_list.append('FORM:DATA ASCII\n')
-
- if nb_K !=0 or nb_J != 0:
- cm_list.append('FUNC "TEMP", ' + ch_list_temp + '\n')
- cm_list.append('TEMP:TRAN TC, ' + ch_list_temp + '\n')
- if nb_K != 0:
- cm_list.append('TEMP:TC:TYPE K, ' + ch_list_TC_K + '\n')
- if nb_J != 0:
- cm_list.append('TEMP:TC:TYPE J, ' + ch_list_TC_J + '\n')
- cm_list.append('TEMP:UNIT CELS, ' + ch_list_temp + '\n')
- # cm_list.append('TEMP:TC:RJUN:RSEL INT, ' + ch_list_temp + '\n')
- cm_list.append('TEMP:TC:RJUN:RSEL SIM, ' + ch_list_temp + '\n')
- cm_list.append('TEMP:TC:RJUN:SIM 0, ' + ch_list_temp + '\n')
-
- cm_list.append('TEMP:AVER OFF, ' + ch_list_temp + '\n')
- cm_list.append('TEMP:LINE:SYNC ' + daq_lsync + ch_list_temp + '\n')
- cm_list.append('TEMP:OCOM ' + daq_ocom + ch_list_temp + '\n')
- cm_list.append('TEMP:AZER ' + daq_azer + ch_list_temp + '\n')
- #if not azer:
- #cm_list.append('AZER:ONCE' + '\n')
- cm_list.append('TEMP:DEL:AUTO ' + daq_adel + ch_list_temp + '\n')
-
- for i in range(nb_K+nb_J):
- str_nplc = str(nplc_TE[i])
- c = '(@' + ch_nb_TC[i] + ')\n'
- cm_list.append('TEMP:NPLC ' + str_nplc + ' , ' + c)
-
- if nb_PT100 != 0:
- ## note: PT-100 can be read out as temperature sensor
- cm_list.append('FUNC "TEMP", ' + ch_list_PT_100 + '\n')
- cm_list.append('TEMP:TRAN FRTD, '+ ch_list_PT_100 + '\n')
- cm_list.append('TEMP:RTD:FOUR PT100, ' + ch_list_PT_100 + '\n')
- cm_list.append('TEMP:LINE:SYNC ' + daq_lsync + ch_list_PT_100 + '\n')
- cm_list.append('TEMP:OCOM ' + daq_ocom + ch_list_PT_100 + '\n')
- cm_list.append('TEMP:AZER ' + daq_azer + ch_list_PT_100 + '\n')
- #if not azer:
- #cm_list.append('AZER:ONCE' + '\n')
- cm_list.append('TEMP:DEL:AUTO ' + daq_adel + ch_list_PT_100 + '\n')
- for i in range(nb_PT100):
- str_nplc = str(nplc_PT_100[i])
- c = '(@' + ch_nb_PT_100[i] + ')\n'
- cm_list.append('TEMP:NPLC ' + str_nplc + ' , ' + c)
-
- if nb_PT1000 != 0:
- # PT-1000 as 4 wire resistance
- if PT_1000_mode == 'R' or PT_1000_mode == 'R+T':
- cm_list.append('FUNC "FRES", ' + ch_list_PT_1000 + '\n')
- cm_list.append('FRES:RANG 10e3, ' + ch_list_PT_1000 + '\n')
- cm_list.append('FRES:LINE:SYNC ' + daq_lsync + ch_list_PT_1000 + '\n')
- cm_list.append('FRES:OCOM ' + daq_ocom + ch_list_PT_1000 + '\n')
- cm_list.append('FRES:AZER ' + daq_azer + ch_list_PT_1000 + '\n')
- #if not azer:
- #cm_list.append('AZER:ONCE' + '\n')
- cm_list.append('FRES:DEL:AUTO ' + daq_adel + ch_list_PT_1000 + '\n')
- cm_list.append('FRES:AVER OFF, ' + ch_list_PT_1000 + '\n')
- for i in range(nb_PT1000):
- str_nplc = str(nplc_PT_1000[i])
- c = '(@' + ch_nb_PT_1000[i] + ')\n'
- cm_list.append('FRES:NPLC ' + str_nplc + ' , ' + c)
- #PT1000 calculated inside DAQ
- if PT_1000_mode == 'T':
- cm_list.append('FUNC "TEMP", ' + ch_list_PT_1000 + '\n')
- cm_list.append('TEMP:TRAN FRTD, '+ ch_list_PT_1000 + '\n')
- cm_list.append('TEMP:RTD:FOUR USER, ' + ch_list_PT_1000 + '\n')
-
- cm_list.append('TEMP:RTD:ALPH 0.00385055, ' + ch_list_PT_1000 + '\n')
- cm_list.append('TEMP:RTD:BETA 0.10863, ' + ch_list_PT_1000 + '\n')
- cm_list.append('TEMP:RTD:DELT 1.4999, ' + ch_list_PT_1000 + '\n')
-
- # neu ??
- #cm_list.append('TEMP:RTD:ALPH 0.0039022, ' + ch_list_PT_1000 + '\n')
- #cm_list.append('TEMP:RTD:BETA 0.0, ' + ch_list_PT_1000 + '\n')
- #cm_list.append('TEMP:RTD:DELT 0.148659, ' + ch_list_PT_1000 + '\n')
-
- cm_list.append('TEMP:RTD:ZERO 1000, ' + ch_list_PT_1000 + '\n')
- cm_list.append('TEMP:LINE:SYNC ' + daq_lsync + ch_list_PT_1000 + '\n')
- cm_list.append('TEMP:OCOM ' + daq_ocom + ch_list_PT_1000 + '\n')
- cm_list.append('TEMP:AZER ' + daq_azer + ch_list_PT_1000 + '\n')
- #if not azer:
- #cm_list.append('AZER:ONCE' + '\n')
- cm_list.append('TEMP:DEL:AUTO ' + daq_adel + ch_list_PT_1000 + '\n')
- cm_list.append('TEMP:AVER OFF, ' + ch_list_PT_1000 + '\n')
- for i in range(nb_PT1000):
- str_nplc = str(nplc_PT_1000[i])
- c = '(@' + ch_nb_PT_1000[i] + ')\n'
- cm_list.append('TEMP:NPLC ' + str_nplc + ' , ' + c)
-
-
- if nb_DCV_100mV != 0:
- cm_list.append('FUNC "VOLT:DC", ' + ch_list_DCV_100mV + '\n')
- cm_list.append('VOLT:RANG 100e-3, ' + ch_list_DCV_100mV + '\n')
- cm_list.append('VOLT:LINE:SYNC ' + daq_lsync + ch_list_DCV_100mV + '\n')
- cm_list.append('VOLT:AZER ' + daq_azer + ch_list_DCV_100mV + '\n')
- #if not azer:
- #cm_list.append('AZER:ONCE' + '\n')
- cm_list.append('VOLT:AVER OFF, ' + ch_list_DCV_100mV + '\n')
- cm_list.append('VOLT:DEL:AUTO ' + daq_adel + ch_list_DCV_100mV + '\n')
- for i in range(nb_DCV_100mV):
- str_nplc = str(nplc_DCV_100mV[i])
- c = '(@' + ch_nb_DCV_100mV[i] + ')\n'
- cm_list.append('VOLT:NPLC ' + str_nplc + ', ' + c)
-
-
- if nb_DCV_Volt != 0:
- cm_list.append('FUNC "VOLT:DC", ' + ch_list_DCV_Volt + '\n')
- cm_list.append('VOLT:LINE:SYNC ' + daq_lsync + ch_list_DCV_Volt + '\n')
- cm_list.append('VOLT:AZER ' + daq_azer + ch_list_DCV_Volt + '\n')
- #if not azer:
- #cm_list.append('AZER:ONCE' + '\n')
- cm_list.append('VOLT:AVER OFF, ' + ch_list_DCV_Volt + '\n')
- cm_list.append('VOLT:DEL:AUTO ' + daq_adel + ch_list_DCV_Volt + '\n')
- for i in range(nb_DCV_Volt):
- str_nplc = str(nplc_DCV_Volt[i])
- c = '(@' + ch_nb_DCV_Volt[i] + ')\n'
- cm_list.append('VOLT:NPLC ' + str_nplc + ', ' + c)
- l = len(range_list_DCV[i])
- if range_list_DCV[i][l-1:] == 'V':
- r = range_list_DCV[i][:-1]
- cm_list.append('VOLT:RANG ' + r + ', ' + c)
- else:
- #Auto
- cm_list.append('VOLT:RANG:AUTO ON, ' + c)
-
- if nb_ACV != 0:
- cm_list.append('FUNC "VOLT:AC", ' + ch_list_ACV + '\n')
- cm_list.append('VOLT:AC:AVER OFF, ' + ch_list_ACV + '\n')
- cm_list.append('VOLT:AC:DEL:AUTO ' + daq_adel + ch_list_ACV + '\n')
-
- for i in range(nb_ACV):
- c = '(@' + ch_nb_ACV[i] + ')\n'
- l = len(range_list_ACV[i])
- if range_list_ACV[i][l-1:] == 'V':
- r = range_list_ACV[i][:-1]
- cm_list.append('VOLT:AC:RANG ' + r + ', ' + c)
- else:
- #Auto
- cm_list.append('VOLT:AC:RANG:AUTO ON, ' + c)
- #cm_list.append('VOLT:AC:RANG:AUTO ON, ' + ch_list_ACV + '\n')
- #cm_list.append('VOLT:AC:RANG 10, ' + ch_list_ACV + '\n')
- # only signals with frequency greater than the detector bandwidth are measured
- # detectors bandwith: 3, 30 or 300 Hz, default = 3
- cm_list.append('VOLT:AC:DET:BAND 300, ' + ch_list_ACV + '\n')
-
- if nb_Rogowski != 0:
- cm_list.append('FUNC "VOLT:DC", ' + ch_list_Rogowski + '\n')
- cm_list.append('VOLT:LINE:SYNC ' + daq_lsync + ch_list_Rogowski + '\n')
- cm_list.append('VOLT:AZER ' + daq_azer + ch_list_Rogowski + '\n')
- #if not azer:
- #cm_list.append('AZER:ONCE' + '\n')
- cm_list.append('VOLT:DEL:AUTO ' + daq_adel + ch_list_Rogowski + '\n')
- cm_list.append('VOLT:AVER OFF, ' + ch_list_Rogowski + '\n')
- for i in range(nb_Rogowski):
- str_nplc = str(nplc_Rogowski[i])
- c = '(@' + ch_nb_Rogowski[i] + ')\n'
- cm_list.append('VOLT:NPLC ' + str_nplc + ', ' + c)
- l = len(range_list_Rogowski[i])
- if range_list_Rogowski[i][l-1:] == 'V':
- r = range_list_Rogowski[i][:-1]
- cm_list.append('VOLT:RANG ' + r + ', ' + c)
- else:
- #Auto
- r = range_list_Rogowski[i]
- cm_list.append('VOLT:RANG:AUTO ON, ' + ch_list_Rogowski + '\n')
-
- cm_list.append('DISP:CLE\n')
- cm_list.append('DISP:LIGH:STAT ON50\n')
- #cm_list.append('DISP:SCR HOME_LARG\n')
- #cm_list.append('DISP:SCR PROC\n')
- cm_list.append('DISP:USER1:TEXT "ready to start ..."\n')
- #cm_list.append('DISP:BUFF:ACT "defbuffer1"\n')
- #cm_list.append('ROUTE:CHAN:CLOSE (@101)\n')
- #cm_list.append('DISP:WATC:CHAN (@101)\n')
-
- if not test_daq:
- for i in range(len(cm_list)):
- if debug_daq:
- print ('writing to COM port: ', cm_list[i])
- if interface == 'LAN':
- ser_daq.write(cm_list[i])
- else:
- ser_daq.write(cm_list[i].encode())
-
-
-###########################################################################
-def Write_LSYNC(u, state):
-###########################################################################
- if state == True:
- onoff = 'ON, '
- else:
- onoff = 'OFF, '
- if daq_sensor_types[u] == 'TE':
- cmd = 'TEMP:LINE:SYNC '+ onoff + ch_list_temp + '\n'
- if daq_sensor_types[u] == 'PT-100':
- cmd = 'TEMP:LINE:SYNC ' + onoff + ch_list_PT_100 + '\n'
- if daq_sensor_types[u] == 'PT-1000':
- if PT_1000_mode == 'R' or PT_1000_mode == 'R+T':
- cmd = 'FRES:LINE:SYNC ' + onoff + ch_list_PT_1000 + '\n'
- if PT_1000_mode == 'T':
- cmd = 'TEMP:LINE:SYNC ' + onoff + ch_list_PT_1000 + '\n'
- if daq_sensor_types[u] == 'DCV-100mV':
- cmd = 'VOLT:LINE:SYNC ' + onoff + ch_list_DCV_100mV + '\n'
- if daq_sensor_types[u] == 'DCV':
- cmd = 'VOLT:LINE:SYNC ' + onoff + ch_list_DCV_Volt + '\n'
- if daq_sensor_types[u] == 'Rogowski':
- cmd = 'VOLT:LINE:SYNC ' + onoff + ch_list_Rogowski + '\n'
- if not test_daq:
- if debug_daq:
- print ('Sending to COM: ' + cmd)
- if interface == 'LAN':
- ser_daq.write(cmd)
- else:
- ser_daq.write(cmd.encode())
- print ('LSYNC ' + daq_sensor_types[u] + ' : ', state)
-
-###########################################################################
-def Write_OCOM(u, state):
-###########################################################################
- if state == True:
- onoff = 'ON, '
- else:
- onoff = 'OFF, '
- if daq_sensor_types[u] == 'TE':
- cmd = 'TEMP:OCOM '+ onoff + ch_list_temp + '\n'
- if daq_sensor_types[u] == 'PT-100':
- cmd = 'TEMP:OCOM ' + onoff + ch_list_PT_100 + '\n'
- if daq_sensor_types[u] == 'PT-1000':
- if PT_1000_mode == 'R' or PT_1000_mode == 'R+T':
- cmd = 'FRES:OCOM ' + onoff + ch_list_PT_1000 + '\n'
- if PT_1000_mode == 'T':
- cmd = 'TEMP:OCOM ' + onoff + ch_list_PT_1000 + '\n'
- if not test_daq:
- if debug_daq:
- print ('Sending to COM: ' + cmd)
- if interface == 'LAN':
- ser_daq.write(cmd)
- else:
- ser_daq.write(cmd.encode())
- print ('OCOM ' + daq_sensor_types[u] + ' : ', state)
-
-
-###########################################################################
-def Write_NPLC(u, val):
-###########################################################################
- cm_list = list(range(0))
- val_str = str(val)
- if sensor_list[u] == 'TE':
- cm_list.append('TEMP:NPLC ' + val_str + ', (@' + ch_list[u] + ')\n')
- if sensor_list[u] == 'PT' and type_list[u] == '100':
- cm_list.append('TEMP:NPLC ' + val_str + ', (@' + ch_list[u] + ')\n')
- if sensor_list[u] == 'PT' and type_list[u] == '1000':
- if PT_1000_mode == 'R' or PT_1000_mode == 'R+T':
- cm_list.append('FRES:NPLC ' + val_str + ', (@' + ch_list[u] + ')\n')
- if PT_1000_mode == 'T':
- cm_list.append('TEMP:NPLC ' + val_str + ', (@' + ch_list[u] + ')\n')
- if sensor_list[u] == 'DCV-100mV':
- cm_list.append('VOLT:NPLC ' + val_str + ', (@' + ch_list[u] + ')\n')
- if sensor_list[u] == 'DCV':
- cm_list.append('VOLT:NPLC ' + val_str + ', (@' + ch_list[u] + ')\n')
- if sensor_list[u] == 'Rogowski':
- cm_list.append('VOLT:NPLC ' + val_str + ', (@' + ch_list[u] + ')\n')
- if not test_daq:
- for i in range(len(cm_list)):
- if debug_daq:
- print ('Sending to COM: ' + cm_list[i])
- if interface == 'LAN':
- ser_daq.write(cm_list[i])
- else:
- ser_daq.write(cm_list[i].encode())
- print ('NPLC ' + ch_list[u] + ' (' + alias_list[u] + ') : ' + val_str)
-
-
-###########################################################################
-def Write_Filter_Count(u, val):
-###########################################################################
- cm_list = list(range(0))
- val_str = str(val)
- if daq_sensor_types[u] == 'TE':
- cm_list.append('TEMP:AVER:COUNT ' + val_str + ', ' + ch_list_temp + '\n')
- if daq_sensor_types[u] == 'PT-100':
- cm_list.append('TEMP:AVER:COUNT ' + val_str + ch_list_PT_100 + '\n')
- if daq_sensor_types[u] == 'PT-1000':
- if PT_1000_mode == 'R' or PT_1000_mode == 'R+T':
- cm_list.append('FRES:AVER:COUNT ' + val_str + ch_list_PT_1000 + '\n')
- if PT_1000_mode == 'T':
- cm_list.append('TEMP:AVER:COUNT ' + val_str + ch_list_PT_1000 + '\n')
- if daq_sensor_types[u] == 'DCV-100mV':
- cm_list.append('VOLT:AVER:COUNT ' + val_str + ch_list_DCV_100mV + '\n')
- if daq_sensor_types[u] == 'DCV':
- cm_list.append('VOLT:AVER:COUNT ' + val_str + ch_list_DCV_Volt + '\n')
- if daq_sensor_types[u] == 'Rogowski':
- cm_list.append('VOLT:AVER:COUNT ' + val_str + ch_list_Rogowski + '\n')
- if not test_daq:
- for j in range(len(cm_list)):
- if debug_daq:
- print ('Sending to COM: ' + cm_list[j])
- if interface == 'LAN':
- ser_daq.write(cm_list[j])
- else:
- ser_daq.write(cm_list[j].encode())
- print ('Filter count(s) ' + daq_sensor_types[u] + ' : ' + val_str)
-
-###########################################################################
-def Write_Filter_State(u, state):
-###########################################################################
- cm_list = list(range(0))
- if daq_sensor_types[u] == 'TE':
- if state == 'OFF':
- cm_list.append('TEMP:AVER OFF, ' + ch_list_temp + '\n')
- elif state == 'repeat':
- cm_list.append('TEMP:AVER ON, ' + ch_list_temp + '\n')
- cm_list.append('TEMP:AVER:TCON REP, ' + ch_list_temp + '\n')
- elif state == 'moving':
- cm_list.append('TEMP:AVER ON, ' + ch_list_temp + '\n')
- cm_list.append('TEMP:AVER:TCON MOV, ' + ch_list_temp + '\n')
- if daq_sensor_types[u] == 'PT-100':
- if state == 'OFF':
- cm_list.append('TEMP:AVER OFF, ' + ch_list_PT_100 + '\n')
- elif state == 'repeat':
- cm_list.append('TEMP:AVER ON, ' + ch_list_PT_100 + '\n')
- cm_list.append('TEMP:AVER:TCON REP, ' + ch_list_PT_100 + '\n')
- elif state == 'moving':
- cm_list.append('TEMP:AVER ON, ' + ch_list_PT_100 + '\n')
- cm_list.append('TEMP:AVER:TCON MOV, ' + ch_list_PT_100 + '\n')
- if daq_sensor_types[u] == 'PT-1000':
- if PT_1000_mode == 'R' or PT_1000_mode == 'R+T':
- if state == 'OFF':
- cm_list.append('FRES:AVER OFF, ' + ch_list_PT_1000 + '\n')
- elif state == 'repeat':
- cm_list.append('FRES:AVER ON, ' + ch_list_PT_1000 + '\n')
- cm_list.append('FRES:AVER:TCON REP, ' + ch_list_PT_1000 + '\n')
- elif state == 'moving':
- cm_list.append('FRES:AVER ON, ' + ch_list_PT_1000 + '\n')
- cm_list.append('FRES:AVER:TCON MOV, ' + ch_list_PT_1000 + '\n')
- if PT_1000_mode == 'T':
- if state == 'OFF':
- cm_list.append('TEMP:AVER OFF, ' + ch_list_PT_1000 + '\n')
- elif state == 'repeat':
- cm_list.append('TEMP:AVER ON, ' + ch_list_PT_1000 + '\n')
- cm_list.append('TEMP:AVER:TCON REP, ' + ch_list_PT_1000 + '\n')
- elif state == 'moving':
- cm_list.append('TEMP:AVER ON, ' + ch_list_PT_1000 + '\n')
- cm_list.append('TEMP:AVER:TCON MOV, ' + ch_list_PT_1000 + '\n')
- if daq_sensor_types[u] == 'DCV-100mV':
- if state == 'OFF':
- cm_list.append('VOLT:AVER OFF, ' + ch_list_DCV_100mV + '\n')
- elif state == 'repeat':
- cm_list.append('VOLT:AVER ON, ' + ch_list_DCV_100mV + '\n')
- cm_list.append('VOLT:AVER:TCON REP, ' + ch_list_DCV_100mV + '\n')
- elif state == 'moving':
- cm_list.append('VOLT:AVER ON, ' + ch_list_DCV_100mV + '\n')
- cm_list.append('VOLT:AVER:TCON MOV, ' + ch_list_DCV_100mV + '\n')
- if daq_sensor_types[u] == 'DCV-Volt':
- if state == 'OFF':
- cm_list.append('VOLT:AVER OFF, ' + ch_list_DCV_Volt + '\n')
- elif state == 'repeat':
- cm_list.append('VOLT:AVER ON, ' + ch_list_DCV_Volt + '\n')
- cm_list.append('VOLT:AVER:TCON REP, ' + ch_list_DCV_Volt + '\n')
- elif state == 'moving':
- cm_list.append('VOLT:AVER ON, ' + ch_list_DCV_Volt + '\n')
- cm_list.append('VOLT:AVER:TCON MOV, ' + ch_list_DCV_Volt + '\n')
- if daq_sensor_types[u] == 'Rogowski':
- if state == 'OFF':
- cm_list.append('VOLT:AVER OFF, ' + ch_list_Rogowski + '\n')
- elif state == 'repeat':
- cm_list.append('VOLT:AVER ON, ' + ch_list_Rogowski + '\n')
- cm_list.append('VOLT:AVER:TCON REP, ' + ch_list_Rogowski + '\n')
- elif state == 'moving':
- cm_list.append('VOLT:AVER ON, ' + ch_list_Rogowski + '\n')
- cm_list.append('VOLT:AVER:TCON MOV, ' + ch_list_Rogowski + '\n')
-
- if not test_daq:
- for j in range(len(cm_list)):
- if debug_daq:
- print ('Sending to COM: ' + cm_list[j])
- if interface == 'LAN':
- ser_daq.write(cm_list[j])
- else:
- ser_daq.write(cm_list[j].encode())
- print ('Filter state ' + daq_sensor_types[u] + ' : ' + state)
-
-###########################################################################
-def Write_AZER(u, state):
-###########################################################################
- if state == True:
- onoff = 'ON, '
- else:
- onoff = 'OFF, '
- if daq_sensor_types[u] == 'TE':
- cmd = 'TEMP:AZER '+ onoff + ch_list_temp + '\n'
- if daq_sensor_types[u] == 'PT-100':
- cmd = 'TEMP:AZER ' + onoff + ch_list_PT_100 + '\n'
- if daq_sensor_types[u] == 'PT-1000':
- if PT_1000_mode == 'R' or PT_1000_mode == 'R+T':
- cmd = 'FRES:AZER ' + onoff + ch_list_PT_1000 + '\n'
- if PT_1000_mode == 'T':
- cmd = 'TEMP:AZER ' + onoff + ch_list_PT_1000 + '\n'
- if daq_sensor_types[u] == 'DCV-100mV':
- cmd = 'VOLT:AZER ' + onoff + ch_list_DCV_100mV + '\n'
- if daq_sensor_types[u] == 'DCV':
- cmd = 'VOLT:AZER ' + onoff + ch_list_DCV_Volt+ '\n'
- if daq_sensor_types[u] == 'Rogowski':
- cmd = 'VOLT:AZER ' + onoff + ch_list_Rogowski+ '\n'
- if not test_daq:
- if debug_daq:
- print ('Sending to COM: ' + cmd)
- if interface == 'LAN':
- ser_daq.write(cmd)
- else:
- ser_daq.write(cmd.encode())
- print ('AZER ' + daq_sensor_types[u] + ' : ', state)
-
-
-
-def reset_daq():
- print ('Reset DAQ 6510')
- cm_list = list(range(0))
- cm_list.append('*RST\n')
- cm_list.append('DISP:USER1:TEXT "ready to start ..."\n')
- if not test_daq:
- for i in range(len(cm_list)):
- if debug_daq:
- print ('writing to COM : ' + cm_list[i])
- if interface == 'LAN':
- ser_daq.write(cm_list[i])
- else:
- ser_daq.write(cm_list[i].encode())
-
-def message_daq_display():
- cm_list = list(range(0))
- cm_list.append('DISP:USER1:TEXT "sampling ..."\n')
- if not test_daq:
- for i in range(len(cm_list)):
- if debug_daq:
- print ('writing to COM : ' + cm_list[i])
- if interface == 'LAN':
- ser_daq.write(cm_list[i])
- else:
- ser_daq.write(cm_list[i].encode())
-
-def idn_daq():
- cmd = '*IDN?\n'
- if not test_daq:
- if debug_daq:
- print ('writing to COM : ' + cmd)
- if interface == 'LAN':
- ser_daq.write(cmd)
- instrument = ser_daq.read()
- else:
- ser_daq.write(cmd.encode())
- instrument = ser_daq.readline().decode()
- if debug_daq:
- print ('reading from COM : ' , instrument)
- print ('IDN: ', instrument)
-
-def idn_card_1():
- cmd = 'SYST:CARD1:IDN?\n'
- if not test_daq:
- if debug_daq:
- print ('writing to COM : ' + cmd)
- if interface == 'LAN':
- ser_daq.write(cmd)
- instrument = ser_daq.read()
- else:
- ser_daq.write(cmd.encode())
- instrument = ser_daq.readline().decode()
- if debug_daq:
- print ('reading from COM : ', instrument)
- print ('Card-01: ', instrument)
-
-def idn_card_2():
- cmd = 'SYST:CARD2:IDN?\n'
- if not test_daq:
- if debug_daq:
- print ('writing to COM : ' + cmd)
- if interface == 'LAN':
- ser_daq.write(cmd)
- instrument = ser_daq.read()
- else:
- ser_daq.write(cmd.encode())
- instrument = ser_daq.readline().decode()
- if debug_daq:
- print ('reading from COM : ', instrument)
- print ('Card-02: ', instrument)
-
-
diff --git a/Pyrometer.py b/Pyrometer.py
deleted file mode 100644
index b96514c..0000000
--- a/Pyrometer.py
+++ /dev/null
@@ -1,163 +0,0 @@
-import serial
-import random
-import numpy
-
-global ser_py, debug_pyro, test_pyro
-
-
-
-###########################################################################
-def Init_Pyro(i, com, rate, bits, stop, parity, dbg, test):
-###########################################################################
-
- global ser_py, debug_pyro, test_pyro
-
- debug_pyro = dbg
- test_pyro = test
- if i == 0:
- ser_py = list(range(0))
- portName = 'COM'+str(com)
- try:
- serial.Serial(port=portName)
- except serial.SerialException:
- print ('Port ' + portName + ' not present')
- if not test_pyro:
- quit()
-
- if not test_pyro:
- ser_py_ = serial.Serial(
- port = portName,
- baudrate = int(rate),
- parity = parity,
- stopbits = int(stop),
- bytesize = int(bits),
- timeout = 0.1)
-
- ser_py.append(ser_py_)
-
-
-
-###########################################################################
-def Config_Pyro(i, em, tr):
-###########################################################################
-
- if not test_pyro:
- print ('Pyrometer ', i+1, ' : ', Get_ID(i))
- Write_Pilot(i, False)
- Write_Pyro_Para(i, 'e', str(em))
- Write_Pyro_Para(i, 't', str(tr))
-
-###########################################################################
-def Get_Focus(i):
-###########################################################################
-
- p = '00df\r'
- ser_py[i].write(p.encode())
- pyro_focus = ser_py[i].readline().decode()
- return (pyro_focus)
-
-###########################################################################
-def Get_ID(i):
-###########################################################################
-
- p = '00na\r'
- ser_py[i].write(p.encode())
- pyro_id = ser_py[i].readline().decode()
- return (pyro_id)
-
-
-###########################################################################
-def Get_OK(i):
-###########################################################################
-
- answer = ser_py[i].readline().decode()
- print ('Pyrometer ', str(i+1), ' = ', answer)
-
-
-###########################################################################
-def Write_Pyro_Para(i, para, str_val):
-###########################################################################
-### e = emission, t = transmission
-
- if para == 'e':
- val = '%05.1f' % float(str_val)
- str_val = str(val).replace('.', '')
- p = '00em' + str_val + '\r'
- if para == 't':
- val = '%05.1f' % float(str_val)
- str_val = str(val).replace('.', '')
- p = '00et' + str_val + '\r'
- if para == 't90':
- p = '00ez' + str_val + '\r'
- if not test_pyro:
- if debug_pyro:
- print ('Sending to ' + ser_py[i].port +': ', p.encode())
- ser_py[i].write(p.encode())
- Get_OK(i)
- answer = Get_Pyro_Para(i, para)
- if para == 'e':
- print ('Pyrometer ', str(i+1), ' emission = ', answer)
- if para == 't':
- print ('Pyrometer ', str(i+1), ' transmission = ', answer)
- if para == 't90':
- print ('Pyrometer ', str(i+1), ' t90 = ', answer)
- else:
- print ('Pyro ' +str(i+1) + ' parameter: ', p)
-
-
-###########################################################################
-def Get_Pyro_Para(i, para):
-###########################################################################
-### e = emission, t = transmission
-
- if para == 'e':
- p = '00em\r'
- if para == 't':
- p = '00et\r'
- if para == 't90':
- p = '00ez\r'
- if not test_pyro:
- if debug_pyro:
- print ('Sending to ' + ser_py[i].port +': ', p.encode())
- ser_py[i].write(p.encode())
- answer = ser_py[i].readline().decode()
- return (answer)
- else:
- print ('Pyro ' +str(i+1) + ' parameter: ', p)
-
-
-###########################################################################
-def Read_Pyro(i):
-###########################################################################
-
- p = '00ms\r'
- if not test_pyro:
- if debug_pyro:
- print ('Sending to ' + ser_py[i].port + ': ', p.encode())
- ser_py[i].write(p.encode())
- temp = ser_py[i].readline().decode()
- temp = temp[:-1]
- l = len(temp)
- temp = temp[:l-1] + '.' + temp[l-1:]
- if debug_pyro:
- print ('Reading from ' + ser_py[i].port + ': ', float(temp))
- else:
- temp = random.uniform(20,22)
- return (float(temp))
-
-
-
-###########################################################################
-def Write_Pilot(i, state):
-###########################################################################
-
- print ('Pilot-'+str(i+1)+' : ' +str(state))
- if not test_pyro:
- if state:
- p = '00la1\r'
- else:
- p = '00la0\r'
- if debug_pyro:
- print ('Sending to ' + ser_py[i].port +': ', p.encode())
- ser_py[i].write(p.encode())
- Get_OK(i)
\ No newline at end of file
diff --git a/Pyrometer_Array.py b/Pyrometer_Array.py
deleted file mode 100644
index 9d51e84..0000000
--- a/Pyrometer_Array.py
+++ /dev/null
@@ -1,134 +0,0 @@
-import serial
-import random
-import numpy
-
-global ser_py_array, debug_pyro, test_pyro
-
-
-
-###########################################################################
-def Init_Pyro_Array(com, rate, bits, stop, parity, dbg, test):
-###########################################################################
-
- global ser_py_array, debug_pyro, test_pyro
-
- debug_pyro = dbg
- test_pyro = test
- portName = 'COM'+str(com)
- try:
- serial.Serial(port=portName)
- except serial.SerialException:
- print ('Port ' + portName + ' not present')
- if not test_pyro:
- quit()
-
- if not test_pyro:
- ser_py_array = serial.Serial(
- port = portName,
- baudrate = int(rate),
- parity = parity,
- stopbits = int(stop),
- bytesize = int(bits),
- timeout = 0.1)
-
-
-
-###########################################################################
-def Config_Pyro_Array(i, em):
-###########################################################################
-# i is the head number, starts with 0
-
- if not test_pyro:
- print ('Pyrometer array head', i+1, ' : ', Get_head_ID(i))
- Write_Pyro_Array_Para(i, 'e', str(em))
-
-###########################################################################
-def Get_nb_of_head():
-###########################################################################
-
- p = '00oc\r'
-
-###########################################################################
-def Get_head_ID(i):
-###########################################################################
-# i is the head number, starts with 0
-
- p = '00A' + str(i+1) + 'sn\r'
- ser_py_array.write(p.encode())
- pyro_head_id = ser_py_array.readline().decode()
- return (pyro_head_id)
-
-###########################################################################
-def Get_OK(i):
-###########################################################################
-
- answer = ser_py_array.readline().decode()
- print ('Pyrometer array head', str(i+1), ' = ', answer)
-
-
-###########################################################################
-def Write_Pyro_Array_Para(i, para, str_val):
-###########################################################################
-### e = emission
-### i is the head number, starts with 0
-
- if para == 'e':
- val = '%05.1f' % float(str_val)
- str_val = str(val).replace('.', '')
- p = '00A' + str(i+1) + 'em' + str_val + '\r'
- if para == 't90':
- p = '00A' + str(i+1) + 'ez' + str_val + '\r'
- if not test_pyro:
- if debug_pyro:
- print ('Sending to head ' + str(i+1) +': ', p.encode())
- ser_py_array.write(p.encode())
- Get_OK(i)
- answer = Get_Pyro_Array_Para(i, para)
- if para == 'e':
- print ('Pyrometer array head ', str(i+1), ' emission = ', answer)
- if para == 't90':
- print ('Pyrometer array head ', str(i+1), ' t90 = ', answer)
- else:
- print ('Pyrometer array head ' +str(i+1) + ' parameter: ', p)
-
-
-###########################################################################
-def Get_Pyro_Array_Para(i, para):
-###########################################################################
-### e = emission, t = transmission
-
- if para == 'e':
- p = '00A' + str(i+1) + 'em\r'
- if para == 't90':
- p = '00A' + str(i+1) + 'ez\r'
- if not test_pyro:
- if debug_pyro:
- print ('Sending to pyrometer head ' + str(i+1) + ': ', p.encode())
- ser_py_array.write(p.encode())
- answer = ser_py_array.readline().decode()
- return (answer)
- else:
- print ('Pyrometer array head ' + str(i+1) + ' parameter: ', p)
-
-
-###########################################################################
-def Read_Pyro_Array(i):
-###########################################################################
-# i is the head number, starts with 0
-
- p = '00A' + str(i+1) + 'ms\r'
- if not test_pyro:
- if debug_pyro:
- print ('Sending to head ', + str(i+1) + ': ', p.encode())
- ser_py_array.write(p.encode())
- temp = ser_py_array.readline().decode()
- temp = temp[:-1]
- l = len(temp)
- temp = temp[:l-1] + '.' + temp[l-1:]
- if debug_pyro:
- print ('Reading from head ' + str(i+1) + ': ', float(temp))
- else:
- temp = random.uniform(20,22)
- return (float(temp))
-
-
diff --git a/README.md b/README.md
index 261b19b..83dff76 100644
--- a/README.md
+++ b/README.md
@@ -1,43 +1,176 @@
# multilog
+[![DOI](https://zenodo.org/badge/419782987.svg)](https://zenodo.org/badge/latestdoi/419782987)
-Masurement data recording and visualization using various devices.
+Measurement data recording and visualization using various devices, e.g., multimeters, pyrometers, optical or infrared cameras.
-The project is developed and maintained by the [**Model experiments group**](https://www.ikz-berlin.de/en/research/materials-science/section-fundamental-description#c486) at the Leibniz Institute for Crystal Growth (IKZ).
+![multilog 2](./multilog.png)
+
+The project is developed and maintained by the [**Model experiments group**](https://www.ikz-berlin.de/en/research/materials-science/section-fundamental-description#c486) at the Leibniz-Institute for Crystal Growth (IKZ).
### Referencing
-If you use this code in your research, please cite our article (available with open access):
+
+If you use this code in your research, please cite our open-access article:
> A. Enders-Seidlitz, J. Pal, and K. Dadzis, Model experiments for Czochralski crystal growth processes using inductive and resistive heating *IOP Conference Series: Materials Science and Engineering*, 1223 (2022) 012003. https://doi.org/10.1088/1757-899X/1223/1/012003.
-## Programs
-Python 3 is used.
+## Supported devices
+
+Currently, the following devices are supported:
+
+- Keithley DAQ6510 multimeter (RS232)
+- Lumasense pyrometers (RS485):
+ - IGA-6-23
+ - IGAR-6
+ - Series-600
+- Basler optical cameras (Ethernet)
+- Optris IP-640 IR camera (USB)
+- Eurotherm controller (RS232)
+- IFM flowmeter (Ethernet)
+
+Additional devices may be included in a flexible manner.
+
+## Usage
+
+multilog is configured using the file *config.yml* in the main directory. A template [*config_template.yml*](./config_template.yml), including all supported measurement devices, is provided in this repository; please create a copy of this file and adjust it to your needs. Further details are given below.
+
+To run multilog execute the python file [*multilog.py*](./multilog.py):
+
+```shell
+python3 ./multilog.py
+```
+
+If everything is configured correctly, the GUI window opens up. Sampling is started immediately for verification purposes, but the measurements are not recorded yet. Once the *Start* button is clicked, the directory "measdata_*date*_#*XX*" is created and samplings are saved to this directory in csv format. A separate file (or folder for images) is created for each measurement device.
+
+multilog is built for continuous sampling. In case of problems, check the log file for errors and warnings!
+
+## Configuration
+
+multilog is configured using the file *config.yml* in the main directory. A template [*config_template.yml*](./config_template.yml) including all supported measurement devices is provided in this repository; please create a copy of this file and adjust it to your needs.
+
+### Main settings
+
+In the *settings* section of the config-file the sampling time steps are defined (in ms):
+
+- dt-main: main time step for sampling once the recording is started. Used for all devices except cameras.
+- dt-camera: time step for sampling of cameras.
+- dt-camera-update: time step for updating camera view. This value should be lower than dt-camera (to get a smooth view) but not lower than exposure + processing time.
+- dt-init: time step used for sampling before recording is started.
+
+### Logging
+
+The logging is configured in the *logging* section of the config-file. The parameters defined are passed directly to the [basicConfig-function](https://docs.python.org/3/library/logging.html#logging.basicConfig) of Python's logging module.
+
+### Devices
+
+The *devices* section is the heart of multilog's configuration and contains the settings for the measurement devices. You can add any number of supported devices here. Just give them an individual name. A separate tab will be created in the GUI for each device. The device type is defined by the name as given in *config-template.yml*, e.g. "DAQ-6510", "IFM-flowmeter", or "Optris-IP-640", and must always be contained in this name; extensions are possible (e.g., "DAQ-6510 - temperatures").
+
+#### DAQ-6510 multimeter
+
+For the Keithley DAQ6510 multimeter, the following main settings are available:
+
+- serial-interface: configuration for [pyserial](https://pyserial.readthedocs.io/en/latest/pyserial_api.html#serial.Serial)
+- settings: currently, some channel-specific settings are defined globally. This will be changed in the future.
+- channels: flexible configuration of the device's channels for measurement of temperatures with thermocouples / Pt100 / Pt1000 and ac / dc voltages. Conversion of voltages into different units is possible (see "rogowski" in config_template.yml).
+
+#### IFM-flowmeter
+
+The main configurations (IP, ports) should be self-explaining. If the section "flow-balance" is included in the settings, in- and outflows are balanced to check for leakage. This is connected to a discord-bot for automatized notification; the bot configuration is hard-coded in [*discord_bot.py*](./multilog/discord_bot.py).
+
+#### Eurotherm controller
+
+Temperature measurement and control operation points are logged. Configuration of:
-Script | Related Device
---------------------|------------------------
-Pyrometer.py | Impac IGA 6/23 and IGAR 6 Adv.
-Pyrometer_Array.py | Impac Series 600
-DAQ_6510.py | Multimeter
-Arduino.py | Not yet fully implemented
+- serial-interface: configuration for [pyserial](https://pyserial.readthedocs.io/en/latest/pyserial_api.html#serial.Serial)
-__Other files__
-1. sample.py
- * The main script to start
-2. config.ini
- * configuration file for the used devices
+#### Lumasense IGA-6-23 / IGAR-6-adv / Series-600 pyrometer
-__Further modules to be integrated here__
+The configuration of the Lumasense pyrometers includes:
-[IR Camera](https://github.com/nemocrys/IRCamera)
+- serial-interface: configuration for [pyserial](https://pyserial.readthedocs.io/en/latest/pyserial_api.html#serial.Serial)
+- device-id: RS485 device id (default: '00')
+- transmissivity
+- emissivity
+- t90
-## Operation
+#### Basler optical camera
-Start the main sample.py in a command window:
-python sample.py
+The camera is connected using ethernet. Configuration of:
-The flag --h shows some command line parameters
+- device number (default: 0)
+- exposure time
+- framerate
+- timeout
+
+#### Optris-IP-640 IR camera
+
+Configuration according to settings in [FiloCara/pyOptris](https://github.com/FiloCara/pyOptris/blob/dev/setup.py), including:
+
+- measurement-range
+- framerate
+- emissivity
+- transmissivity
+
+## Program structure
+
+multilog follows the [Model-view-vontroller](https://de.wikipedia.org/wiki/Model_View_Controller) pattern. For each measurement device two classes are defined: a pyQT-QWidget "view" class for visualization in [*view.py*](./multilog/view.py) and a "model" class in [*devices.py*](./multilog/devices.py). The "controller", including program construction and main sampling loop, is defined in [*main.py*](./multilog/main.py).
+
+To add a new device, the following changes are required:
+
+- create a device-class implementing the device configuration, sampling, and saving in [*devices.py*](./multilog/devices.py)
+- create a widget-class implementing the GUI in [*view.py*](./multilog/view.py)
+- add the configuration in the *devices* section in [*config_template.yml*](./config_template.yml)
+- add the new device to the "setup devices & tabs" section (search for "# add new devices here!") in Controller.\_\_init\_\_(...) in [*main.py*](./multilog/main.py)
+
+## Dependencies
+
+multilog runs with python >= 3.7 on both Linux and Windows (Mac not tested). The main dependencies are the following python packages:
+
+- matplotlib
+- numpy
+- PyQT5
+- pyqtgraph
+- pyserial
+- PyYAML
+
+Depending on the applied devices multilog needs various additional python packages. A missing device-specific dependency leads to a warning. Always check the log if something is not working as expected!
+
+#### IFM-flowmeter
+
+- requests
+
+For the discord bot there are the following additional dependencies:
+
+- dotenv
+- discord
+
+#### Basler optical camera
+
+- pypylon
+- Pillow
+
+#### Optris-IP-640 IR camera
+
+- mpl_toolkits
+- pyoptris and dependencies installed according to https://github.com/nemocrys/pyOptris/blob/dev/README.md
+
+## NOMAD support
+
+NOMAD support and the option to uploade measurement data to [NOMAD](https://nomad-lab.eu/) is under implementation. Currently, various yaml-files containing a machine-readable description of the measurement data are generated.
+
+## License
+
+This code is available under a GPL v3 License. Parts are copied from [FiloCara/pyOptris](https://github.com/FiloCara/pyOptris/blob/dev/setup.py) and available under MIT License.
+
+## Support
+
+In case of questions please [open an issue](https://github.com/nemocrys/multilog/issues/new)!
## Acknowledgements
[This project](https://www.researchgate.net/project/NEMOCRYS-Next-Generation-Multiphysical-Models-for-Crystal-Growth-Processes) has received funding from the European Research Council (ERC) under the European Union's Horizon 2020 research and innovation programme (grant agreement No 851768).
+
+## Contribution
+
+Any help to improve this code is very welcome!
diff --git a/config.ini b/config.ini
deleted file mode 100644
index 09c098d..0000000
--- a/config.ini
+++ /dev/null
@@ -1,97 +0,0 @@
-[Instruments]
-# DAQ-parameter: on/off, COM-port, datarate, #bits, #stop, parity
-# DAQ -parameter: COM=LAN optionally
-# Pyro: number of pyrometer
-# Pyro-Array-parameter: on/off, COM-port, datarate, #bits, #stop, parity
-# Arduino: number of arduinos
-DAQ-6510: on, 1, 115200, 8, 1, N
-Pyro: 2
-Pyro-Array: off, 4, 115200, 8, 1, E
-Arduino: 0
-[Overflow]
-DAQ-6510: 2000
-Pyro: 1000
-Arduino: 999
-[PT-1000]
-# options: R, T, R+T
-# T : calculated intrinsic inside instrument
-# R, R+T : sampled R and calculated to T by function
-Save: T
-[Card-01]
-# up to 20 channels
-# remark: for PT100 or PT1000 in 4 wire mode the actual channel
-# is mapped to channel +10 (i.e.: Ch01->Ch11)
-# Ch-xx: alias, sensor, type(or range), nplc, factor, offset
-Ch-01: TE_1_K_left, TE, K, 0.5, 1.0, 0
-Ch-02: TE_2_K_left, TE, K, 0.5, 1.0, 0
-Ch-03: TE_3_insu_right_out, TE, K, 0.5, 1.0, 0
-#Ch-04: TE_4_insu_front_out, TE, K, 0.5, 1.0, 0
-#Ch-05: TE_5_insu_right_in, TE, K, 0.5, 1.0, 0
-#Ch-06: TE_Q_b, TE, K, 0.5, 1.0, 0
-#Ch-07: TE_Q_f, TE, K, 0.5, 1.0, 0
-#Ch-19: TE_6_air_outside, TE, J, 0.5, 1.0, 0
-Ch-20: TE_7_J_left, TE, J, 0.5, 1.0, 0
-[Card-02]
-# up to 20 channels
-# remark: for PT100 or PT1000 in 4 wire mode the actual channel
-# is mapped to channel +10 (i.e.: Ch01->Ch11)
-# Ch-xx: alias, sensor, type(or range), nplc, factor, offset
-# Rogowski: 50 A/V - older one
-# Rogowski: 30/300/3000 A : factor = 10/100/1000
-Ch-01: Rogowski, Rogowski, 10V, 0.5, 100, 0
-#Ch-02: Rogowski, Rogowski, 10V, 0.5, 100, 0
-Ch-06: PT-100_1_rear, PT, 100, 0.5, 1.0, 0
-#Ch-07: PT-100_2, PT, 100, 0.5, 1.0, 0
-#Ch-08: PT-1000_1_air_above, PT, 1000, 0.5, 1.0, 0
-#Ch-09: PT-1000_2, PT, 1000, 0.5, 1.0, 0
-#Ch-11: LEM [A], DCV, 10V, 0.5, 40, 0
-#Ch-12: Flux_bottom, DCV, 100mV, 0.5, 1.0, 0
-#Ch-13: Flux_front, DCV, 100mV, 0.5, 1.0, 0
-
-
-[Card-05]
-# virtually mapped pyrometer(s)
-# Ch-xx: alias, COM, Transmission, Emission, Datarate, bits, stopbit, parity, t90, factor, offset, (t90 list)
-# t90: (0.002, 0.01, 0.05, 0.25, 1.0, 3.0, 10.0) (IGA 320/23 - Adrian)
-# t90: (0.0005, 0.001, 0.003, 0.005, 0.01, 0.05, 0.25, 1.0, 3.0, 10.0) (IGA 6-23 Adv)
-# t90: (0.0005, 0.01, 0.05, 0.25, 1.0, 3.0, 10.0) (IGAR 6 Q)
-Ch-01: Pyro-1-Quo, 2, 100, 100, 115200, 8, 1, E, 2, 1.0, 0, (0.0005, 0.01, 0.05, 0.25, 1.0, 3.0, 10.0)
-Ch-02: Pyro-2-Laser, 3, 53, 83, 115200, 8, 1, E, 2, 1.0, 0, (0.0005, 0.001, 0.003, 0.005, 0.01, 0.05, 0.25, 1.0, 3.0, 10.0)
-#Ch-03: Pyro-3-Visier-front, 6, 100, 100, 19200, 8, 1, E, 3, 1.0, 0, (0.0005, 0.001, 0.003, 0.005, 0.01, 0.05, 0.25, 1.0, 3.0, 10.0)
-
-[Card-20]
-# virtually mapped pyrometer array
-# no transmission
-# Ch-xx: alias, Emission, t90, factor, offset, (t90 list)
-# t90: (0.18, 0.5, 1.0, 2.0, 5.0, 10.0, 30.0) (Series 600 Array)
-Ch-01: Pyro_h1, 95, 1, 1.0, 0, (0.18, 0.5, 1.0, 2.0, 5.0, 10.0, 30.0)
-#Ch-02: Pyro_h2, 100, 1, 1.0, 0, (0.18, 0.5, 1.0, 2.0, 5.0, 10.0, 30.0)
-#Ch-03: Pyro_h3, 100, 2, 1.0, 0, (0.18, 0.5, 1.0, 2.0, 5.0, 10.0, 30.0)
-#Ch-04: Pyro_h4, 100, 1, 1.0, 0, (0.18, 0.5, 1.0, 2.0, 5.0, 10.0, 30.0)
-#Ch-05: Pyro_h5, 90, 2, 1.0, 0, (0.18, 0.5, 1.0, 2.0, 5.0, 10.0, 30.0)
-#Ch-06: Pyro_h6, 100, 2, 1.0, 0, (0.18, 0.5, 1.0, 2.0, 5.0, 10.0, 30.0)
-#Ch-07: Pyro_h7, 90, 2, 1.0, 0, (0.18, 0.5, 1.0, 2.0, 5.0, 10.0, 30.0)
-#Ch-08: Pyro_h8, 100, 2, 1.0, 0, (0.18, 0.5, 1.0, 2.0, 5.0, 10.0, 30.0)
-
-
-[Card-10]
-# virtually mapped arduino
-# COM: COM, Datarate, bits, stopbit, parity
-# Ch-xx: alias, send-command, start-id, end-id, position, separator if position>0, , factor, offset
-# remark: heating status channel (if present) at last position
-COM: 10, 19200, 8, 1, N
-Ch-01: Temp-Type-K-plate, g, g=, \r\n, , "", 1.0, 0
-Ch-02: Temp-IKA_2, GN_PV_2, , \r\n, 1, " ", 1.0, 0
-Ch-03: Temp-Test, HN_X, XSTART=, XEND, 2, ";", 1.0, 0
-Ch-04: Temp-IKA_1, IN_PV_1, , \r\n, 1, " ", 1.0, 0
-Ch-05: Temp-Type-K-air, c, c=, \r\n, , "", 1.0, 0
-Ch-06: Heating status [0/1], h, h=, \r\n, , "", 1.0, 0
-[Card-11]
-# virtually mapped arduino
-# COM: COM, Datarate, bits, stopbit, parity
-# Ch-xx: alias, send-command, start-id, end-id, position, separator if position>0, , factor, offset
-# remark: heating status channel (if present) at last position
-COM: 17, 19200, 8, 1, N
-Ch-01: Temp-Type-K-plate, g, g=, \r\n, , "", 1.0, 0
-
-
diff --git a/multilog.png b/multilog.png
new file mode 100644
index 0000000..395b480
Binary files /dev/null and b/multilog.png differ
diff --git a/multilog.py b/multilog.py
new file mode 100644
index 0000000..590429b
--- /dev/null
+++ b/multilog.py
@@ -0,0 +1,6 @@
+"""Execute this to start multilog!"""
+
+from multilog.main import main
+
+if __name__ == "__main__":
+ main()
diff --git a/multilog/__init__.py b/multilog/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/multilog/devices.py b/multilog/devices.py
new file mode 100644
index 0000000..118639a
--- /dev/null
+++ b/multilog/devices.py
@@ -0,0 +1,1478 @@
+"""This module contains a class for each device implementing
+device configuration, communication and saving of measurement data.
+
+Each device must implement the following functions:
+- init_output(self, directory: str) -> None
+- sample(self) -> Any
+- save_measurement(self, time_abs: float, time_rel: datetime, sampling: Any) -> None
+"""
+
+from copy import deepcopy
+import datetime
+import logging
+import matplotlib.pyplot as plt
+import multiprocessing
+import numpy as np
+import os
+from serial import Serial, SerialException
+import subprocess
+import traceback
+import yaml
+
+logger = logging.getLogger(__name__)
+
+from .discord_bot import send_message
+
+# device-specific imports
+# required by IfmFlowmeter
+try:
+ import requests
+except Exception as e:
+ logger.warning("Could not import requests.", exc_info=True)
+# required by BaslerCamera
+try:
+ from pypylon import pylon
+except Exception as e:
+ logger.warning("Could not import pypylon.", exc_info=True)
+# required by BaslerCamera
+try:
+ from PIL import Image
+except Exception as e:
+ logger.warning("Could not import PIL.", exc_info=True)
+# required by OptrisIP640
+try:
+ from mpl_toolkits.axes_grid1 import make_axes_locatable
+except Exception as e:
+ logger.warning("Could not import mpl_toolkits.", exc_info=True)
+# required by OptrisIP640
+try:
+ from .pyOptris import direct_binding as optris
+except Exception as e:
+ logger.warning(f"Could not import pyOtris", exc_info=True)
+
+
+class SerialMock:
+ """This class is used to mock a serial interface for debugging purposes."""
+
+ def write(self, _):
+ pass
+
+ def readline(self):
+ return "".encode()
+
+
+class Daq6510:
+ """Keythley multimeter DAQ6510. Implementation bases on v1 of
+ multilog and shall be refactored in future."""
+
+ def __init__(self, config, name="Daq6510"):
+ """Setup serial interface, configure device and prepare sampling.
+
+ Args:
+ config (dict): device configuration (as defined in
+ config.yml in the devices-section).
+ name (str, optional): Device name.
+ """
+ logger.info(f"Initializing Daq6510 device '{name}'")
+ self.config = config
+ self.name = name
+ try:
+ self.serial = Serial(**config["serial-interface"])
+ except SerialException as e:
+ logger.exception(f"Connection to {self.name} not possible.")
+ self.serial = SerialMock()
+ self.reset()
+ # bring the data from config into multilog v1 compatible structure
+ self.nb_reading_values = len(config["channels"])
+ self.reading_str = "(@"
+
+ self.ch_list_tc = []
+ self.ch_list_pt100 = []
+ self.ch_list_pt1000 = []
+ self.ch_list_dcv = []
+ self.ch_list_acv = []
+
+ self.ch_str_tc = "(@"
+ self.ch_str_tc_k = "(@"
+ self.ch_str_tc_j = "(@"
+ self.ch_str_pt_100 = "(@"
+ self.ch_str_pt_1000 = "(@"
+ self.ch_str_dcv = "(@"
+ self.ch_str_acv = "(@"
+
+ self.nb_tc_k = 0
+ self.nb_tc_j = 0
+ self.nb_pt100 = 0
+ self.nb_pt1000 = 0
+ self.nb_dcv = 0
+ self.nb_acv = 0
+
+ for channel in config["channels"]:
+ self.reading_str += f"{channel},"
+ sensor_type = config["channels"][channel]["type"].lower()
+ if sensor_type == "temperature":
+ subtype = config["channels"][channel]["sensor-id"].split("_")[0].lower()
+ if subtype == "te": # thermo couple
+ self.ch_list_tc.append(channel)
+ self.ch_str_tc += f"{channel},"
+ tc_type = (
+ config["channels"][channel]["sensor-id"].split("_")[-1].lower()
+ )
+ if tc_type == "k":
+ self.nb_tc_k += 1
+ self.ch_str_tc_k += f"{channel},"
+ if tc_type == "j":
+ self.nb_tc_j += 1
+ self.ch_str_tc_j += f"{channel},"
+ if subtype == "pt-100":
+ self.ch_list_pt100.append(channel)
+ self.nb_pt100 += 1
+ self.ch_str_pt_100 += f"{channel},"
+ if subtype == "pt-1000":
+ self.ch_list_pt1000.append(channel)
+ self.nb_pt1000 += 1
+ self.ch_str_pt_1000 += f"{channel},"
+ elif sensor_type == "dcv":
+ self.ch_list_dcv.append(channel)
+ self.nb_dcv += 1
+ self.ch_str_dcv += f"{channel},"
+ elif sensor_type == "acv":
+ self.ch_list_acv.append(channel)
+ self.nb_acv += 1
+ self.ch_str_acv += f"{channel},"
+ else:
+ raise ValueError(
+ f"Unknown sensor type {sensor_type} at channel {channel}."
+ )
+
+ self.reading_str = self.reading_str[:-1] + ")"
+ self.ch_str_tc = self.ch_str_tc[:-1] + ")"
+ self.ch_str_tc_k = self.ch_str_tc_k[:-1] + ")"
+ self.ch_str_tc_j = self.ch_str_tc_j[:-1] + ")"
+ self.ch_str_pt_100 = self.ch_str_pt_100[:-1] + ")"
+ self.ch_str_pt_1000 = self.ch_str_pt_1000[:-1] + ")"
+ self.ch_str_dcv = self.ch_str_dcv[:-1] + ")"
+ self.ch_str_acv = self.ch_str_acv[:-1] + ")"
+
+ if config["settings"]["lsync"]:
+ lsync = "ON"
+ else:
+ lsync = "OFF"
+ if config["settings"]["ocom"]:
+ ocom = "ON"
+ else:
+ ocom = "OFF"
+ if config["settings"]["azer"]:
+ azer = "ON"
+ else:
+ azer = "OFF"
+ if config["settings"]["adel"]:
+ adel = "ON"
+ else:
+ adel = "OFF"
+
+ cmds = [
+ ":SYSTEM:CLEAR\n",
+ "FORM:DATA ASCII\n",
+ ]
+ if self.nb_tc_k + self.nb_tc_j > 0: # if there are thermo couples
+ cmds.append(f'FUNC "TEMP", {self.ch_str_tc}\n')
+ cmds.append(f"TEMP:TRAN TC, {self.ch_str_tc}\n")
+ if self.nb_tc_k > 0:
+ cmds.append(f"TEMP:TC:TYPE K, {self.ch_str_tc_k}\n")
+ if self.nb_tc_j > 0:
+ cmds.append(f"TEMP:TC:TYPE J, {self.ch_str_tc_j}\n")
+ cmds.append(f"TEMP:UNIT CELS, {self.ch_str_tc}\n")
+ if config["settings"]["internal-cold-junction"]:
+ cmds.append(f"TEMP:TC:RJUN:RSEL INT, {self.ch_str_tc}\n")
+ else:
+ cmds.append(f"TEMP:TC:RJUN:RSEL SIM, {self.ch_str_tc}\n")
+ cmds.append(f"TEMP:TC:RJUN:SIM 0, {self.ch_str_tc}\n")
+ cmds.append(f"TEMP:AVER OFF, {self.ch_str_tc}\n")
+ cmds.append(f"TEMP:LINE:SYNC {lsync}, {self.ch_str_tc}\n")
+ cmds.append(f"TEMP:OCOM {ocom}, {self.ch_str_tc}\n")
+ cmds.append(f"TEMP:AZER {azer}, {self.ch_str_tc}\n")
+ cmds.append(f"TEMP:DEL:AUTO {adel}, {self.ch_str_tc}\n")
+ for channel in self.ch_list_tc:
+ cmds.append(f'TEMP:NPLC {config["settings"]["nplc"]}, (@{channel})\n')
+ if self.nb_pt100 > 0:
+ cmds.append(f'FUNC "TEMP", {self.ch_str_pt_100}\n')
+ cmds.append(f"TEMP:TRAN FRTD, {self.ch_str_pt_100}\n")
+ cmds.append(f"TEMP:RTD:FOUR PT100, {self.ch_str_pt_100}\n")
+ cmds.append(f"TEMP:LINE:SYNC {lsync}, {self.ch_str_pt_100}\n")
+ cmds.append(f"TEMP:OCOM {ocom}, {self.ch_str_pt_100}\n")
+ cmds.append(f"TEMP:AZER {azer}, {self.ch_str_pt_100}\n")
+ cmds.append(f"TEMP:DEL:AUTO {adel}, {self.ch_str_pt_100}\n")
+ cmds.append(f"TEMP:AVER OFF, {self.ch_str_pt_100}\n")
+ for channel in self.ch_list_pt100:
+ cmds.append(f'TEMP:NPLC {config["settings"]["nplc"]}, (@{channel})\n')
+ if self.nb_pt1000 > 0: # PT1000 temperature calculated inside DAQ
+ cmds.append(f'FUNC "TEMP", {self.ch_str_pt_1000}\n')
+ cmds.append(f"TEMP:TRAN FRTD, {self.ch_str_pt_1000}\n")
+ cmds.append(f"TEMP:RTD:FOUR USER, {self.ch_str_pt_1000}\n")
+ cmds.append(f"TEMP:RTD:ALPH 0.00385055, {self.ch_str_pt_1000}\n")
+ cmds.append(f"TEMP:RTD:BETA 0.10863, {self.ch_str_pt_1000}\n")
+ cmds.append(f"TEMP:RTD:DELT 1.4999, {self.ch_str_pt_1000}\n")
+ cmds.append(f"TEMP:RTD:ZERO 1000, {self.ch_str_pt_1000}\n")
+ cmds.append(f"TEMP:LINE:SYNC {lsync}, {self.ch_str_pt_1000}\n")
+ cmds.append(f"TEMP:OCOM {ocom}, {self.ch_str_pt_1000}\n")
+ cmds.append(f"TEMP:AZER {azer}, {self.ch_str_pt_1000}\n")
+ cmds.append(f"TEMP:DEL:AUTO {adel}, {self.ch_str_pt_1000}\n")
+ cmds.append(f"TEMP:AVER OFF, {self.ch_str_pt_1000}\n")
+ for channel in self.ch_list_pt1000:
+ cmds.append(f'TEMP:NPLC {config["settings"]["nplc"]}, (@{channel})\n')
+ if self.nb_dcv > 0:
+ cmds.append(f'FUNC "VOLT:DC", {self.ch_str_dcv}\n')
+ cmds.append(f"VOLT:LINE:SYNC {lsync}, {self.ch_str_dcv}\n")
+ cmds.append(f"VOLT:AZER {azer}, {self.ch_str_dcv}\n")
+ cmds.append(f"VOLT:DEL:AUTO {adel}, {self.ch_str_dcv}\n")
+ cmds.append(f"VOLT:AVER OFF, {self.ch_str_dcv}\n")
+ for channel in self.ch_list_dcv:
+ cmds.append(f'VOLT:NPLC {config["settings"]["nplc"]}, (@{channel})\n')
+ if "range" in config["channels"][channel]:
+ cmds.append(
+ f'VOLT:RANG {config["channels"][channel]["range"]}, (@{channel})\n'
+ )
+ else:
+ cmds.append(f"VOLT:RANG:AUTO ON, (@{channel})\n")
+ if self.nb_acv > 0:
+ cmds.append(f'FUNC "VOLT:AC", {self.ch_str_acv}\n')
+ cmds.append(f"VOLT:AC:AVER OFF, {self.ch_str_acv}\n")
+ cmds.append(f"VOLT:AC:DEL:AUTO {adel}, {self.ch_str_acv}\n")
+ for channel in self.ch_list_acv:
+ if "range" in config["channels"][channel]:
+ cmds.append(
+ f'VOLT:AC:RANG {config["channels"][channel]["range"]}, (@{channel})\n'
+ )
+ else:
+ cmds.append(f"VOLT:AC:RANG:AUTO ON, (@{channel})\n")
+ # only signals with frequency greater than the detector bandwidth are measured
+ # detectors bandwith: 3, 30 or 300 Hz, default = 3
+ cmds.append(f"VOLT:AC:DET:BAND 300, {self.ch_str_acv}\n")
+ cmds.append("DISP:CLE\n")
+ cmds.append("DISP:LIGH:STAT ON50\n")
+ cmds.append('DISP:USER1:TEXT "ready to start ..."\n')
+
+ for cmd in cmds:
+ self.serial.write(cmd.encode())
+
+ # container for measurement data, allocation of channel_id and name
+ self.meas_data = {}
+ self.channel_id_names = {}
+ for channel in config["channels"]:
+ if "position" in config["channels"][channel]:
+ name = f'{config["channels"][channel]["sensor-id"]} {config["channels"][channel]["position"]}'
+ else:
+ name = f'{config["channels"][channel]["sensor-id"]}'
+ name = name.replace(",", "")
+ self.meas_data.update({name: []})
+ self.channel_id_names.update({channel: name})
+
+ # unit conversion (for dcv and acv channels)
+ self.conversion_factor = {}
+ self.unit = {}
+ for channel in config["channels"]:
+ type = config["channels"][channel]["type"].lower()
+ name = self.channel_id_names[channel]
+ if type == "temperature":
+ self.unit.update({name: "°C"})
+ self.conversion_factor.update({name: 1})
+ else: # acv, dcv
+ if "unit" in config["channels"][channel]:
+ self.unit.update({name: config["channels"][channel]["unit"]})
+ else:
+ self.unit.update({name: "V"})
+ if "factor" in config["channels"][channel]:
+ self.conversion_factor.update(
+ {name: config["channels"][channel]["factor"]}
+ )
+ else:
+ self.conversion_factor.update({name: 1})
+
+ def init_output(self, directory="./"):
+ """Initialize the csv output file.
+
+ Args:
+ directory (str, optional): Output directory. Defaults to "./".
+ """
+ self.filename = f"{directory}/{self.name}.csv"
+ units = "# datetime,s,"
+ header = "time_abs,time_rel,"
+ for sensor in self.meas_data:
+ units += f"{self.unit[sensor].replace('°', 'DEG ')},"
+ header += f"{sensor},"
+ units += "\n"
+ header += "\n"
+ with open(self.filename, "w", encoding="utf-8") as f:
+ f.write(units)
+ f.write(header)
+ self.write_nomad_files(directory)
+
+ def write_nomad_files(self, directory="./"):
+ """Write .archive.yaml file based on device configuration.
+
+ Args:
+ directory (str, optional): Output directory. Defaults to "./".
+ """
+ with open("./multilog/nomad/archive_template.yml") as f:
+ nomad_template = yaml.safe_load(f)
+ definitions = nomad_template.pop("definitions")
+ data = nomad_template.pop("data")
+ sensor_schema_template = nomad_template.pop("sensor_schema_template")
+
+ data.update(
+ {
+ "data_file": self.filename.split("/")[-1],
+ }
+ )
+ for channel in self.channel_id_names:
+ sensor_name = self.channel_id_names[channel]
+ sensor_name_nomad = sensor_name.replace(" ", "_").replace("-", "_")
+ data.update(
+ {
+ sensor_name_nomad: {
+ # "model": "your_field_here",
+ "name": sensor_name_nomad,
+ "sensor_id": sensor_name.split(" ")[0],
+ "attached_to": " ".join(sensor_name.split(" ")[1:]),
+ "measured_property": self.config["channels"][channel]["type"],
+ "type": sensor_name.split("_")[0].split(" ")[0],
+ # "notes": "TE_1_K air 155 mm over crucible",
+ # "unit": self.unit[sensor_name], # TODO
+ # "channel": channel, # TODO
+ "value_timestamp_rel": "#/data/value_timestamp_rel",
+ "value_timestamp_abs": "#/data/value_timestamp_abs",
+ }
+ }
+ )
+ sensor_schema = deepcopy(sensor_schema_template)
+ sensor_schema["section"]["quantities"]["value_log"]["m_annotations"][
+ "tabular"
+ ]["name"] = sensor_name
+ definitions["sections"]["Sensors_list"]["sub_sections"].update(
+ {sensor_name_nomad: sensor_schema}
+ )
+ definitions["sections"]["Sensors_list"]["m_annotations"]["plot"].append(
+ {
+ "label": f"{sensor_name} over time",
+ "x": "value_timestamp_rel",
+ "y": [f"{sensor_name_nomad}/value_log"],
+ }
+ )
+ nomad_dict = {
+ "definitions": definitions,
+ "data": data,
+ }
+ with open(f"{directory}/{self.name}.archive.yaml", "w", encoding="utf-8") as f:
+ yaml.safe_dump(nomad_dict, f, sort_keys=False)
+
+ def save_measurement(self, time_abs, time_rel, sampling):
+ """Write measurement data to file.
+
+ Args:
+ time_abs (datetime): measurement timestamp.
+ time_rel (float): relative time of measurement.
+ sampling (dict): sampling data, as returned from sample()
+ """
+ timediff = (
+ datetime.datetime.now(datetime.timezone.utc).astimezone() - time_abs
+ ).total_seconds()
+ if timediff > 1:
+ logger.warning(
+ f"{self.name} save_measurement: time difference between event and saving of {timediff} seconds for samplint timestep {time_abs.isoformat(timespec='milliseconds').replace('T', ' ')} - {time_rel}"
+ )
+ line = f"{time_abs.isoformat(timespec='milliseconds').replace('T', ' ')},{time_rel},"
+ for sensor in self.meas_data:
+ self.meas_data[sensor].append(sampling[sensor])
+ line += f"{sampling[sensor]},"
+ line += "\n"
+ with open(self.filename, "a") as f:
+ f.write(line)
+
+ @property
+ def device_id(self):
+ """Get the device ID."""
+ cmd = "*IDN?\n"
+ self.serial.write(cmd.encode())
+ return self.serial.readline().decode().strip()
+
+ @property
+ def card1_id(self):
+ """Get the ID of Card #1."""
+ cmd = "SYST:CARD1:IDN?\n"
+ self.serial.write(cmd.encode())
+ return self.serial.readline().decode().strip()
+
+ @property
+ def card2_id(self):
+ """Get the ID of Card #2."""
+ cmd = "SYST:CARD2:IDN?\n"
+ self.serial.write(cmd.encode())
+ return self.serial.readline().decode().strip()
+
+ def set_display_message(self, message="hello world"):
+ """Set a message on the display."""
+ cmd = f'DISP:USER1:TEXT "{message}"\n'
+ self.serial.write(cmd.encode())
+
+ def reset(self):
+ """Reset device to factory default."""
+ logger.info(f"{self.name} - resetting device")
+ cmd = "*RST\n"
+ self.serial.write(cmd.encode())
+
+ def read(self):
+ """Read out all channels.
+
+ Returns:
+ str: measurement data
+ """
+ cmds = [
+ "TRAC:CLE\n",
+ "TRAC:POIN 100\n",
+ f"ROUT:SCAN:CRE {self.reading_str}\n",
+ "ROUT:SCAN:COUN:SCAN 1\n",
+ "INIT\n",
+ "*WAI\n",
+ f'TRAC:DATA? 1,{self.nb_reading_values},"defbuffer1", CHAN, READ\n',
+ ]
+ for cmd in cmds:
+ self.serial.write(cmd.encode())
+ return self.serial.readline().decode().strip()
+
+ def sample(self):
+ """Read sampling form device and convert values to specified format.
+
+ Returns:
+ dict: {sensor name: measurement value}
+ """
+ data = self.read().split(",") # = ['channel', 'value', 'channel', 'value', ...]
+
+ if len(data) != 2 * self.nb_reading_values: # there is an error in the sampling
+ logging.error(
+ f"Sampling of Daq6510 '{self.name}' failed. Expected {2* self.nb_reading_values} values but got {data}"
+ )
+ return {v: np.nan for _, v in self.channel_id_names.items()}
+ sampling = {}
+ for i in range(int(len(data) / 2)):
+ channel = int(data[2 * i])
+ sensor_name = self.channel_id_names[channel]
+ measurement_value = (
+ float(data[2 * i + 1]) * self.conversion_factor[sensor_name]
+ )
+ sampling.update({sensor_name: measurement_value})
+ return sampling
+
+
+class IfmFlowmeter:
+ def __init__(self, config, name="IfmFlowmeter"):
+ """Prepare sampling.
+
+ Args:
+ config (dict): device configuration (as defined in
+ config.yml in the devices-section).
+ name (str, optional): device name.
+ """
+ logger.info(f"Initializing IfmFlowmeter device '{name}'")
+ self.config = config
+ self.name = name
+ self.ip = config["IP"]
+ self.ports = config["ports"]
+ self.meas_data = {"Temperature": {}, "Flow": {}}
+ for port_id in self.ports:
+ name = self.ports[port_id]["name"]
+ self.meas_data["Temperature"].update({name: []})
+ self.meas_data["Flow"].update({name: []})
+ self.last_sampling = {"Temperature": {}, "Flow": {}}
+ if "flow-balance" in config:
+ self.inflow_sensors = config["flow-balance"]["inflow"]
+ self.outflow_sensors = config["flow-balance"]["outflow"]
+ self.tolerance = config["flow-balance"]["tolerance"]
+ for sensor in self.inflow_sensors + self.outflow_sensors:
+ self.last_sampling["Flow"].update({sensor: 0})
+
+ def sample(self):
+ """Read sampling form device and convert values to readable format.
+
+ Returns:
+ dict: {sensor name: measurement value}
+ """
+ sampling = {"Temperature": {}, "Flow": {}}
+ for port in self.ports:
+ try:
+ name = self.ports[port]["name"]
+ sensor_type = self.ports[port]["type"]
+ r = requests.get(
+ f"http://{self.ip}/iolinkmaster/port[{port}]/iolinkdevice/pdin/getdata"
+ )
+ data = r.json()
+ data_hex = data["data"]["value"]
+ l = len(data_hex)
+ if sensor_type == "SM-8020":
+ data_hex_t = data_hex[l - 8 : l - 4]
+ data_hex_f = data_hex[l - 16 : l - 12]
+ data_dec_t = 0.01 * int(data_hex_t, 16)
+ data_dec_f = 0.0166667 * int(data_hex_f, 16)
+ elif sensor_type == "SV-4200":
+ data_hex_t = data_hex[l - 4 :]
+ data_hex_f = data_hex[l - 8 : l - 4]
+ data_dec_t = 0.1 * int(data_hex_t, 16) / 4
+ data_dec_f = 0.1 * int(data_hex_f, 16)
+ elif sensor_type == "SBG-233":
+ data_hex_t = data_hex[l - 4 :]
+ data_hex_f = data_hex[l - 8 : l - 4]
+ data_dec_t = 1.0 * int(data_hex_t, 16) / 4
+ data_dec_f = 0.1 * int(data_hex_f, 16)
+ except Exception as e:
+ logger.exception(f"Could not sample IfmFlowmeter port '{name}'.")
+ data_dec_t = np.nan
+ data_dec_f = np.nan
+ sampling["Temperature"].update({name: data_dec_t})
+ sampling["Flow"].update({name: data_dec_f})
+ self.last_sampling = deepcopy(sampling)
+ return sampling
+
+ def save_measurement(self, time_abs, time_rel, sampling):
+ """Write measurement data to file.
+
+ Args:
+ time_abs (datetime): measurement timestamp.
+ time_rel (float): relative time of measurement.
+ sampling (dict): sampling data, as returned from sample()
+ """
+ timediff = (
+ datetime.datetime.now(datetime.timezone.utc).astimezone() - time_abs
+ ).total_seconds()
+ if timediff > 1:
+ logger.warning(
+ f"{self.name} save_measurement: time difference between event and saving of {timediff} seconds for samplint timestep {time_abs.isoformat(timespec='milliseconds').replace('T', ' ')} - {time_rel}"
+ )
+ line = f"{time_abs.isoformat(timespec='milliseconds').replace('T', ' ')},{time_rel},"
+ for sensor in self.meas_data["Flow"]:
+ self.meas_data["Flow"][sensor].append(sampling["Flow"][sensor])
+ line += f"{sampling['Flow'][sensor]},"
+ for sensor in self.meas_data["Temperature"]:
+ self.meas_data["Temperature"][sensor].append(
+ sampling["Temperature"][sensor]
+ )
+ line += f"{sampling['Temperature'][sensor]},"
+ line += "\n"
+ with open(self.filename, "a", encoding="utf-8") as f:
+ f.write(line)
+
+ def init_output(self, directory="./"):
+ """Initialize the csv output file.
+
+ Args:
+ directory (str, optional): Output directory. Defaults to "./".
+ """
+ self.filename = f"{directory}/{self.name}.csv"
+ units = "# datetime,s,"
+ header = "time_abs,time_rel,"
+ for sensor in self.meas_data["Flow"]:
+ header += f"{sensor}-flow,"
+ units += "l/min,"
+ for sensor in self.meas_data["Temperature"]:
+ header += f"{sensor}-temperature,"
+ units += "DEG C,"
+ units += "\n"
+ header += "\n"
+ with open(self.filename, "w", encoding="utf-8") as f:
+ f.write(units)
+ f.write(header)
+ self.write_nomad_files(directory)
+
+ def write_nomad_files(self, directory="./"):
+ """Write .archive.yaml file based on device configuration.
+
+ Args:
+ directory (str, optional): Output directory. Defaults to "./".
+ """
+ with open("./multilog/nomad/archive_template.yml") as f:
+ nomad_template = yaml.safe_load(f)
+ definitions = nomad_template.pop("definitions")
+ data = nomad_template.pop("data")
+ sensor_schema_template = nomad_template.pop("sensor_schema_template")
+ data.update(
+ {
+ "data_file": self.filename.split("/")[-1],
+ }
+ )
+ for port in self.ports:
+ sensor_name = self.ports[port]["name"]
+ sensor_type = self.ports[port]["type"]
+ for property in ["flow", "temperature"]:
+ sensor_name_nomad = (
+ f'{sensor_name.replace(" ", "_").replace("-", "_")}_{property}'
+ )
+ data.update(
+ {
+ sensor_name_nomad: {
+ # "model": "your_field_here",
+ "name": sensor_name_nomad,
+ # "sensor_id": sensor_name.split(" ")[0],
+ "attached_to": sensor_name,
+ "measured_property": property,
+ "type": sensor_type,
+ # "notes": "TE_1_K air 155 mm over crucible",
+ # "unit": self.unit[sensor_name], # TODO
+ # "channel": channel, # TODO
+ "value_timestamp_rel": "#/data/value_timestamp_rel",
+ "value_timestamp_abs": "#/data/value_timestamp_abs",
+ }
+ }
+ )
+ sensor_schema = deepcopy(sensor_schema_template)
+ sensor_schema["section"]["quantities"]["value_log"]["m_annotations"][
+ "tabular"
+ ]["name"] = f"{sensor_name}-{property}"
+ definitions["sections"]["Sensors_list"]["sub_sections"].update(
+ {sensor_name_nomad: sensor_schema}
+ )
+ definitions["sections"]["Sensors_list"]["m_annotations"]["plot"].append(
+ {
+ "label": f"{sensor_name_nomad} over time",
+ "x": "value_timestamp_rel",
+ "y": [f"{sensor_name_nomad}/value_log"],
+ }
+ )
+ nomad_dict = {
+ "definitions": definitions,
+ "data": data,
+ }
+ with open(f"{directory}/{self.name}.archive.yaml", "w", encoding="utf-8") as f:
+ yaml.safe_dump(nomad_dict, f, sort_keys=False)
+
+ def check_leakage(self):
+ """Evaluate flow balance as specified in config to check for
+ leakage. If leakage is detected a discord message is sent."""
+ inflow = 0
+ for sensor in self.inflow_sensors:
+ inflow += self.last_sampling["Flow"][sensor]
+ outflow = 0
+ for sensor in self.outflow_sensors:
+ outflow += self.last_sampling["Flow"][sensor]
+ loss = inflow - outflow
+ if abs(loss) > self.tolerance:
+ logger.warning(
+ f"Detected possible cooling water leakage, difference of {loss} l/min"
+ )
+ send_message(
+ f"There may be a cooling water leakage.\nThe difference between measured in- and outflow is {loss} l/min."
+ )
+
+
+class Eurotherm:
+ def __init__(self, config, name="Eurotherm"):
+ """Prepare sampling.
+
+ Args:
+ config (dict): device configuration (as defined in
+ config.yml in the devices-section).
+ name (str, optional): device name.
+ """
+ logger.info(f"Initializing Eurotherm device '{name}'")
+ self.config = config
+ self.name = name
+ try:
+ self.serial = Serial(**config["serial-interface"])
+ except SerialException as e:
+ logger.exception(f"Connection to {self.name} not possible.")
+ self.serial = SerialMock()
+
+ self.read_temperature = "\x040000PV\x05"
+ self.read_op = "\x040000OP\x05"
+
+ self.meas_data = {"Temperature": [], "Operating point": []}
+
+ def sample(self):
+ """Read sampling form device.
+
+ Returns:
+ dict: {sensor name: measurement value}
+ """
+ try:
+ self.serial.write(self.read_temperature.encode())
+ temperature = float(self.serial.readline().decode()[3:-2])
+ self.serial.write(self.read_op.encode())
+ op = float(self.serial.readline().decode()[3:-2])
+ except Exception as e:
+ logger.exception(f"Could not sample Eurotherm.")
+ temperature = np.nan
+ op = np.nan
+ return {"Temperature": temperature, "Operating point": op}
+
+ def save_measurement(self, time_abs, time_rel, sampling):
+ """Write measurement data to file.
+
+ Args:
+ time_abs (datetime): measurement timestamp.
+ time_rel (float): relative time of measurement.
+ sampling (dict): sampling data, as returned from sample()
+ """
+ timediff = (
+ datetime.datetime.now(datetime.timezone.utc).astimezone() - time_abs
+ ).total_seconds()
+ if timediff > 1:
+ logger.warning(
+ f"{self.name} save_measurement: time difference between event and saving of {timediff} seconds for samplint timestep {time_abs.isoformat(timespec='milliseconds').replace('T', ' ')} - {time_rel}"
+ )
+ self.meas_data["Temperature"].append(sampling["Temperature"])
+ self.meas_data["Operating point"].append(sampling["Operating point"])
+ line = f"{time_abs.isoformat(timespec='milliseconds').replace('T', ' ')},{time_rel},{sampling['Temperature']},{sampling['Operating point']},\n"
+ with open(self.filename, "a", encoding="utf-8") as f:
+ f.write(line)
+
+ def init_output(self, directory="./"):
+ """Initialize the csv output file.
+
+ Args:
+ directory (str, optional): Output directory. Defaults to "./".
+ """
+ self.filename = f"{directory}/{self.name}.csv"
+ units = "# datetime,s,DEG C,-,\n"
+ header = "time_abs,time_rel,Temperature,Operating point,\n"
+ with open(self.filename, "w", encoding="utf-8") as f:
+ f.write(units)
+ f.write(header)
+ self.write_nomad_files(directory)
+
+ def write_nomad_files(self, directory="./"):
+ """Write .archive.yaml file based on device configuration.
+
+ Args:
+ directory (str, optional): Output directory. Defaults to "./".
+ """
+ with open("./multilog/nomad/archive_template.yml") as f:
+ nomad_template = yaml.safe_load(f)
+ definitions = nomad_template.pop("definitions")
+ data = nomad_template.pop("data")
+ sensor_schema_template = nomad_template.pop("sensor_schema_template")
+ data.update(
+ {
+ "data_file": self.filename.split("/")[-1],
+ }
+ )
+ for sensor_name in self.meas_data:
+ sensor_name_nomad = sensor_name.replace(" ", "_").replace("-", "_")
+ data.update(
+ {
+ sensor_name_nomad: {
+ # "model": "your_field_here",
+ "name": sensor_name_nomad,
+ # "sensor_id": sensor_name.split(" ")[0],
+ # "attached_to": sensor_name, # TODO this information is important!
+ # "measured_property": ,
+ # "type": sensor_type,
+ # "notes": "TE_1_K air 155 mm over crucible",
+ # "unit": self.unit[sensor_name], # TODO
+ "value_timestamp_rel": "#/data/value_timestamp_rel",
+ "value_timestamp_abs": "#/data/value_timestamp_abs",
+ }
+ }
+ )
+ sensor_schema = deepcopy(sensor_schema_template)
+ sensor_schema["section"]["quantities"]["value_log"]["m_annotations"][
+ "tabular"
+ ]["name"] = sensor_name
+ definitions["sections"]["Sensors_list"]["sub_sections"].update(
+ {sensor_name_nomad: sensor_schema}
+ )
+ definitions["sections"]["Sensors_list"]["m_annotations"]["plot"].append(
+ {
+ "label": f"{sensor_name_nomad} over time",
+ "x": "value_timestamp_rel",
+ "y": [f"{sensor_name_nomad}/value_log"],
+ }
+ )
+ nomad_dict = {
+ "definitions": definitions,
+ "data": data,
+ }
+ with open(f"{directory}/{self.name}.archive.yaml", "w", encoding="utf-8") as f:
+ yaml.safe_dump(nomad_dict, f, sort_keys=False)
+
+
+class OptrisIP640:
+ """Optris Ip640 IR Camera."""
+
+ def __init__(self, config, name="OptrisIP640", xml_dir="./"):
+ """Initialize communication and configure device.
+
+ Args:
+ config (dict): device configuration (as defined in
+ config.yml in the devices-section).
+ name (str, optional): Device name.
+ xml_dir(str, optional): Directory for xml-file with device
+ configuration. Defaults to "./".
+ """
+ logger.info(f"Initializing OptrisIP640 device '{name}'")
+ self.name = name
+ self.emissivity = config["emissivity"]
+ logger.info(f"{self.name} - emissivity {self.emissivity}")
+ self.transmissivity = config["transmissivity"]
+ logger.info(f"{self.name} - transmissivity {self.transmissivity}")
+ self.t_ambient = config["T-ambient"]
+ logger.info(f"{self.name} - T-ambient {self.t_ambient}")
+ xml = f"""
+
+{config['serial-number']}
+0
+/usr/share/libirimager
+/usr/share/libirimager/cali
+/root/.irimager/Cali
+
+33
+
+ {config['measurement-range'][0]}
+ {config['measurement-range'][1]}
+
+
+{config['framerate']}
+0
+
+ 1
+ 15.0
+ 0.0
+
+0
+40.0
+50.0
+{config['extended-T-range']}
+5
+0
+
+"""
+ self.xml_file = f"{xml_dir}/{config['serial-number']}.xml"
+ with open(self.xml_file, "w", encoding="utf-8") as f:
+ f.write(xml)
+ try:
+ optris.usb_init(
+ self.xml_file
+ ) # This often fails on the first attempt, therefore just repeat in case of an error.
+ except Exception as e:
+ print(
+ f"Couldn't setup OptrisIP64.\n{traceback.format_exc()}\nTrying again..."
+ )
+ logging.error(traceback.format_exc())
+ optris.usb_init(self.xml_file)
+ optris.set_radiation_parameters(
+ self.emissivity, self.transmissivity, self.t_ambient
+ )
+ self.w, self.h = optris.get_thermal_image_size()
+ self.meas_data = []
+ self.image_counter = 1
+
+ def sample(self):
+ """Read image form device.
+
+ Returns:
+ numpy.array: IR image (2D temperature filed)
+ """
+ raw_image = optris.get_thermal_image(self.w, self.h)
+ thermal_image = (raw_image - 1000.0) / 10.0 # convert to temperature
+ return thermal_image
+
+ @staticmethod
+ def plot_to_file(sampling, filename):
+ """Create a plot of the temperature distribution. This function
+ has to be called from a subprocess because matplotlib is not
+ threadsave.
+
+ Args:
+ sampling (numpy array): IR image as returned from sample()
+ filename (str): filepath of plot
+ """
+ fig, ax = plt.subplots()
+ ax.axis("off")
+ line = ax.imshow(sampling, cmap="turbo", aspect="equal")
+ divider = make_axes_locatable(ax)
+ cax = divider.append_axes("right", size="5%", pad=0.05)
+ fig.colorbar(line, cax=cax)
+ fig.tight_layout()
+ fig.savefig(filename)
+ plt.close(fig)
+
+ def init_output(self, directory="./"):
+ """Initialize the output subdirectory and csv file..
+
+ Args:
+ directory (str, optional): Output directory. Defaults to "./".
+ """
+ self.directory = f"{directory}/{self.name}"
+ os.makedirs(self.directory)
+ with open(f"{self.directory}/_images.csv", "w", encoding="utf-8") as f:
+ f.write("# datetime,s,filename,\n")
+ f.write("time_abs,time_rel,img-name,\n")
+
+ def save_measurement(self, time_abs, time_rel, sampling):
+ """Write measurement data to files:
+ - numpy array with temperature distribution
+ - png file with 2D IR image
+ - csv with metadata
+
+ Args:
+ time_abs (datetime): measurement timestamp.
+ time_rel (float): relative time of measurement.
+ sampling (numpy.array): sampling data, as returned from sample()
+ """
+ timediff = (
+ datetime.datetime.now(datetime.timezone.utc).astimezone() - time_abs
+ ).total_seconds()
+ if timediff > 1:
+ logger.warning(
+ f"{self.name} save_measurement: time difference between event and saving of {timediff} seconds for samplint timestep {time_abs.isoformat(timespec='milliseconds').replace('T', ' ')} - {time_rel}"
+ )
+ self.meas_data = sampling
+ img_name = f"img_{self.image_counter:06}"
+ np.savetxt(f"{self.directory}/{img_name}.csv", sampling, "%.2f")
+ # plot in separate process because matplotlib is not threadsave
+ multiprocessing.Process(
+ target=self.plot_to_file,
+ args=(sampling, f"{self.directory}/{img_name}.png"),
+ ).start()
+ # self.plot_to_file(sampling, f"{self.directory}/{img_name}.png")
+ with open(f"{self.directory}/_images.csv", "a", encoding="utf-8") as f:
+ f.write(
+ f"{time_abs.isoformat(timespec='milliseconds').replace('T', ' ')},{time_rel},{img_name},\n"
+ )
+ self.image_counter += 1
+
+ def __del__(self):
+ """Terminate IR camera communictation and remove xml."""
+ optris.terminate()
+ os.remove(self.xml_file)
+
+
+class PyrometerLumasense:
+ """Lumasense pyrometer, e.g. IGA-6-23 or IGAR-6-adv."""
+
+ def __init__(self, config, name="PyrometerLumasense"):
+ """Setup serial interface, configure device.
+
+ Args:
+ config (dict): device configuration (as defined in
+ config.yml in the devices-section).
+ name (str, optional): Device name.
+ """
+ logger.info(f"Initializing PyrometerLumasense device '{name}'")
+ self.device_id = config["device-id"]
+ self.name = name
+ try:
+ self.serial = Serial(**config["serial-interface"])
+ except SerialException as e:
+ logger.exception(f"Connection to {self.name} not possible.")
+ self.serial = SerialMock()
+ self.set_emissivity(config["emissivity"])
+ self.set_transmissivity(config["transmissivity"])
+ self.t90_dict = config["t90-dict"]
+ self.set_t90(config["t90"])
+ self.meas_data = []
+
+ def _get_ok(self):
+ """Check if command was accepted."""
+ assert self.serial.readline().decode().strip() == "ok"
+
+ def _get_float(self):
+ """Read floatingpoint value."""
+ string_val = self.serial.readline().decode().strip()
+ return float(f"{string_val[:-1]}.{string_val[-1:]}")
+
+ @property
+ def focus(self):
+ """Get focuspoint."""
+ cmd = f"{self.device_id}df\r"
+ self.serial.write(cmd.encode())
+ return self.serial.readline().decode().strip()
+
+ @property
+ def intrument_id(self):
+ """Get the instrument id."""
+ cmd = f"{self.device_id}na\r"
+ self.serial.write(cmd.encode())
+ return self.serial.readline().decode().strip()
+
+ @property
+ def emissivity(self):
+ """Read the current emissivity."""
+ cmd = f"{self.device_id}em\r"
+ self.serial.write(cmd.encode())
+ return self._get_float()
+
+ @property
+ def transmissivity(self):
+ """Read the current transmissivity."""
+ cmd = f"{self.device_id}et\r"
+ self.serial.write(cmd.encode())
+ return self._get_float()
+
+ @property
+ def t90(self):
+ """Reat the current t90 value."""
+ cmd = f"{self.device_id}ez\r"
+ self.serial.write(cmd.encode())
+ idx = int(self.serial.readline().decode().strip())
+ t90_dict_inverted = {v: k for k, v in self.t90_dict.items()}
+ return t90_dict_inverted[idx]
+
+ def set_emissivity(self, emissivity):
+ """Set emissivity and check if it was accepted."""
+ logger.info(f"{self.name} - setting emissivity {emissivity}")
+ cmd = f"{self.device_id}em{emissivity*100:05.1f}\r".replace(".", "")
+ self.serial.write(cmd.encode())
+ self._get_ok()
+ assert self.emissivity == emissivity * 100
+
+ def set_transmissivity(self, transmissivity):
+ """Set transmissivity and check if it was accepted."""
+ logger.info(f"{self.name} - setting transmissivity {transmissivity}")
+ cmd = f"{self.device_id}et{transmissivity*100:05.1f}\r".replace(".", "")
+ self.serial.write(cmd.encode())
+ self._get_ok()
+ assert self.transmissivity == transmissivity * 100
+
+ def set_t90(self, t90):
+ """Set t90 and check if it was accepted."""
+ logger.info(f"{self.name} - setting t90 {t90}")
+ cmd = f"{self.device_id}ez{self.t90_dict[t90]}\r"
+ self.serial.write(cmd.encode())
+ self._get_ok()
+ assert self.t90 == t90
+
+ def sample(self):
+ """Read temperature form device.
+
+ Returns:
+ float: temperature reading.
+ """
+ try:
+ cmd = f"{self.device_id}ms\r"
+ self.serial.write(cmd.encode())
+ val = self._get_float()
+ except Exception as e:
+ logger.exception(f"Could not sample PyrometerLumasense.")
+ val = np.nan
+ return val
+
+ def init_output(self, directory="./"):
+ """Initialize the csv output file.
+
+ Args:
+ directory (str, optional): Output directory. Defaults to "./".
+ """
+ self.filename = f"{directory}/{self.name}.csv"
+ units = "# datetime,s,DEG C,\n"
+ header = "time_abs,time_rel,Temperature,\n"
+ with open(self.filename, "w", encoding="utf-8") as f:
+ f.write(units)
+ f.write(header)
+
+ def save_measurement(self, time_abs, time_rel, sampling):
+ """Write measurement data to file.
+
+ Args:
+ time_abs (datetime): measurement timestamp.
+ time_rel (float): relative time of measurement.
+ sampling (float): temperature, as returned from sample()
+ """
+ timediff = (
+ datetime.datetime.now(datetime.timezone.utc).astimezone() - time_abs
+ ).total_seconds()
+ if timediff > 1:
+ logger.warning(
+ f"{self.name} save_measurement: time difference between event and saving of {timediff} seconds for samplint timestep {time_abs.isoformat(timespec='milliseconds').replace('T', ' ')} - {time_rel}"
+ )
+ self.meas_data.append(sampling)
+ line = f"{time_abs.isoformat(timespec='milliseconds').replace('T', ' ')},{time_rel},{sampling},\n"
+ with open(self.filename, "a", encoding="utf-8") as f:
+ f.write(line)
+
+
+class PyrometerArrayLumasense:
+ """Lumasense pyrometer, e.g. Series 600."""
+
+ def __init__(self, config, name="PyrometerArrayLumasense"):
+ """Setup serial interface, configure device.
+
+ Args:
+ config (dict): device configuration (as defined in
+ config.yml in the devices-section).
+ name (str, optional): Device name.
+ """
+ logger.info(f"Initializing PyrometerArrayLumasense device '{name}'")
+ self.device_id = config["device-id"]
+ self.name = name
+ try:
+ self.serial = Serial(**config["serial-interface"])
+ except SerialException as e:
+ logger.exception(f"Connection to {self.name} not possible.")
+ self.serial = SerialMock()
+ self.t90_dict = config["t90-dict"]
+ self.meas_data = {}
+ self.head_numbering = {}
+ self.sensors = []
+ self.emissivities = {}
+ self.t90s = {}
+ for sensor in config["sensors"]:
+ self.sensors.append(sensor)
+ head_number = config["sensors"][sensor]["head-number"]
+ self.head_numbering.update({sensor: head_number})
+ self.meas_data.update({sensor: []})
+ self.emissivities.update({sensor: config["sensors"][sensor]["emissivity"]})
+ self.t90s.update({sensor: config["sensors"][sensor]["t90"]})
+ self.set_emissivity(head_number, config["sensors"][sensor]["emissivity"])
+ self.set_emissivity(head_number, config["sensors"][sensor]["t90"])
+
+ def _get_ok(self):
+ """Check if command was accepted."""
+ assert self.serial.readline().decode().strip() == "ok"
+
+ def _get_float(self):
+ """Read floatingpoint value."""
+ string_val = self.serial.readline().decode().strip()
+ return float(f"{string_val[:-1]}.{string_val[-1:]}")
+
+ def get_heat_id(self, head_number):
+ """Get the id of a certain head."""
+ cmd = f"{self.device_id}A{head_number}sn\r"
+ self.serial.write(cmd.encode())
+ return self.serial.readline().decode().strip()
+
+ def set_emissivity(self, head_number, emissivity):
+ """Set emissivity for a certain head."""
+ logger.info(
+ f"{self.name} - setting emissivity {emissivity} for heat {head_number}"
+ )
+ cmd = f"{self.device_id}A{head_number}em{emissivity*100:05.1f}\r".replace(
+ ".", ""
+ )
+ self.serial.write(cmd.encode())
+ self._get_ok()
+
+ def set_t90(self, head_number, t90):
+ """Set t90 for a certain head."""
+ logger.info(f"{self.name} - setting t90 {t90} for heat {head_number}")
+ cmd = f"{self.device_id}A{head_number}ez{self.t90_dict[t90]}\r"
+ self.serial.write(cmd.encode())
+ self._get_ok()
+
+ def read_sensor(self, head_number):
+ """Read temperature of a certain head."""
+ cmd = f"{self.device_id}A{head_number}ms\r"
+ self.serial.write(cmd.encode())
+ return self._get_float()
+
+ def sample(self):
+ """Read temperature form all heads.
+
+ Returns:
+ dict: {head name: temperature}.
+ """
+ sampling = {}
+ for sensor in self.head_numbering:
+ try:
+ sampling.update({sensor: self.read_sensor(self.head_numbering[sensor])})
+ except Exception as e:
+ logger.exception(
+ f"Could not sample PyrometerArrayLumasense heat '{sensor}'."
+ )
+ sampling.update({sensor: np.nan})
+ return sampling
+
+ def init_otput(self, directory="./"):
+ """Initialize the csv output file.
+
+ Args:
+ directory (str, optional): Output directory. Defaults to "./".
+ """
+ self.filename = f"{directory}/{self.name}.csv"
+ header = "time_abs,time_rel,"
+ units = "# datetime,s,"
+ for sensor in self.meas_data:
+ header += f"{sensor},"
+ units += "DEG C,"
+ header += "\n"
+ units += "\n"
+ with open(self.filename, "w", encoding="utf-8") as f:
+ f.write(units)
+ f.write(header)
+
+ def save_measurement(self, time_abs, time_rel, sampling):
+ """Write measurement data to file.
+
+ Args:
+ time_abs (datetime): measurement timestamp.
+ time_rel (float): relative time of measurement.
+ sampling (dict): measurement data, as returned from sample()
+ """
+ timediff = (
+ datetime.datetime.now(datetime.timezone.utc).astimezone() - time_abs
+ ).total_seconds()
+ if timediff > 1:
+ logger.warning(
+ f"{self.name} save_measurement: time difference between event and saving of {timediff} seconds for samplint timestep {time_abs.isoformat(timespec='milliseconds').replace('T', ' ')} - {time_rel}"
+ )
+ line = f"{time_abs.isoformat(timespec='milliseconds').replace('T', ' ')},{time_rel},"
+ for sensor in sampling:
+ self.meas_data[sensor].append(sampling[sensor])
+ line += f"{sampling[sensor]},"
+ line += "\n"
+ with open(self.filename, "a", encoding="utf-8") as f:
+ f.write(line)
+
+
+class BaslerCamera:
+ """Basler optical camera."""
+
+ def __init__(self, config, name="BaslerCamera"):
+ """Setup pypylon, configure device.
+
+ Args:
+ config (dict): device configuration (as defined in
+ config.yml in the devices-section).
+ name (str, optional): Device name.
+ """
+ logger.info(f"Initializing BaslerCamera device '{name}'")
+ self.name = name
+ self._timeout = config["timeout"]
+ device_number = config["device-number"]
+ tl_factory = pylon.TlFactory.GetInstance()
+ self.device_name = tl_factory.EnumerateDevices()[
+ device_number
+ ].GetFriendlyName()
+ self._device = pylon.InstantCamera()
+ self._device.Attach(
+ tl_factory.CreateDevice(tl_factory.EnumerateDevices()[device_number])
+ )
+ self._converter = pylon.ImageFormatConverter()
+ self._converter.OutputPixelFormat = pylon.PixelType_BGR8packed
+ self._converter.OutputBitAlignment = pylon.OutputBitAlignment_MsbAligned
+ self._name = tl_factory.EnumerateDevices()[device_number].GetFriendlyName()
+ self._model_number = self._name.split(" ")[1]
+ self._device_class = tl_factory.EnumerateDevices()[
+ device_number
+ ].GetDeviceClass()
+ self._set_exposure_time(config["exposure-time"])
+ self.set_frame_rate(config["frame-rate"])
+ self._device.Open()
+ self._device.StartGrabbing(pylon.GrabStrategy_LatestImageOnly)
+ # self._device.StartGrabbing(pylon.GrabStrategy_UpcomingImage)
+ self.meas_data = []
+ self.image_counter = 1
+
+ def _set_exposure_time(self, exposure_time):
+ """Set exposure time."""
+ logger.info(f"{self.name} - setting exposure time {exposure_time}")
+ self._device.Open()
+ if self._device_class == "BaslerGigE":
+ assert (
+ exposure_time <= self._device.ExposureTimeAbs.GetMax()
+ and exposure_time >= self._device.ExposureTimeAbs.GetMin()
+ )
+ self._device.ExposureTimeAbs.SetValue(exposure_time)
+ elif self._device_class == "BaslerUsb":
+ assert (
+ exposure_time <= self._device.ExposureTime.GetMax()
+ and exposure_time >= self._device.ExposureTime.GetMin()
+ )
+ self._device.ExposureTime.SetValue(exposure_time)
+ else:
+ raise ValueError(f"Device class {self._device_class} is not supported!")
+ self._device.Close()
+
+ def sample(self):
+ """Read latest image from device.
+
+ Returns:
+ numpy.array: image.
+ """
+ grab = self._device.RetrieveResult(self._timeout, pylon.TimeoutHandling_Return)
+ if grab and grab.GrabSucceeded():
+ image = self._converter.Convert(grab).GetArray()
+ else:
+ raise RuntimeError("Image grabbing failed.")
+ grab.Release()
+ return image
+
+ def init_output(self, directory="./"):
+ """Initialize the output subdirectory and csv file..
+
+ Args:
+ directory (str, optional): Output directory. Defaults to "./".
+ """
+ self.directory = f"{directory}/{self.name}"
+ os.makedirs(self.directory)
+ with open(f"{self.directory}/_images.csv", "w", encoding="utf-8") as f:
+ f.write("# datetime,s,filename,\n")
+ f.write("time_abs,time_rel,img-name,\n")
+ with open(f"{self.directory}/device.txt", "w", encoding="utf-8") as f:
+ f.write(self.device_name)
+
+ def save_measurement(self, time_abs, time_rel, sampling):
+ """Write measurement data to files:
+ - jpg file with image
+ - csv with metadata
+
+ Args:
+ time_abs (datetime): measurement timestamp.
+ time_rel (float): relative time of measurement.
+ sampling (numpy.array): image as returned from sample()
+ """
+ timediff = (
+ datetime.datetime.now(datetime.timezone.utc).astimezone() - time_abs
+ ).total_seconds()
+ if timediff > 1:
+ logger.warning(
+ f"{self.name} save_measurement: time difference between event and saving of {timediff} seconds for samplint timestep {time_abs.isoformat(timespec='milliseconds').replace('T', ' ')} - {time_rel}"
+ )
+ self.meas_data = sampling
+ img_name = f"img_{self.image_counter:06}.jpg"
+ Image.fromarray(sampling).convert("RGB").save(f"{self.directory}/{img_name}")
+ with open(f"{self.directory}/_images.csv", "a", encoding="utf-8") as f:
+ f.write(
+ f"{time_abs.isoformat(timespec='milliseconds').replace('T', ' ')},{time_rel},{img_name},\n"
+ )
+ self.image_counter += 1
+
+ def set_frame_rate(self, frame_rate):
+ """Set frame rate for continous sampling. The latest frame is
+ then grabbed by the sample function."""
+ self._device.Open()
+ self._device.AcquisitionFrameRateEnable.SetValue(True)
+ if self._device_class == "BaslerGigE":
+ assert (
+ frame_rate <= self._device.AcquisitionFrameRateAbs.GetMax()
+ and frame_rate >= self._device.AcquisitionFrameRateAbs.GetMin()
+ )
+ self._device.AcquisitionFrameRateAbs.SetValue(frame_rate)
+ elif self._device_class == "BaslerUsb":
+ assert (
+ frame_rate <= self._device.AcquisitionFrameRate.GetMax()
+ and frame_rate >= self._device.AcquisitionFrameRate.GetMin()
+ )
+ self._device.AcquisitionFrameRate.SetValue(frame_rate)
+ else:
+ raise ValueError(f"Device class {self._device_class} is not supported!")
+ self._device.Close()
+
+ def __del__(self):
+ """Stopp sampling, reset device."""
+ self._device.StopGrabbing()
+ self._device.Close()
+
+
+class ProcessConditionLogger:
+ """Virtual device for logging of process contidions. Instead of
+ sampling a physical devices the sampling are read from the user
+ input fields in the GUI."""
+
+ def __init__(self, config, name="ProcessConditionLogger"):
+ """Prepare sampling.
+
+ Args:
+ config (dict): device configuration (as defined in
+ config.yml in the devices-section).
+ name (str, optional): device name.
+ """
+ self.config = config
+ self.name = name
+ self.meas_data = {}
+ self.condition_units = {}
+ for condition in config:
+ default = ""
+ if "default" in config[condition]:
+ default = config[condition]["default"]
+ self.meas_data.update({condition: default})
+ if "unit" in config[condition]:
+ self.condition_units.update({condition: config[condition]["unit"]})
+ else:
+ self.condition_units.update({condition: ""})
+ self.last_meas_data = deepcopy(self.meas_data)
+
+ def init_output(self, directory="./"):
+ """Initialize the csv output file.
+
+ Args:
+ directory (str, optional): Output directory. Defaults to "./".
+ """
+ self.filename = f"{directory}/{self.name}.csv"
+ header = "time_abs,time_rel,"
+ units = "# datetime,s,"
+ for condition in self.meas_data:
+ header += f"{condition},"
+ units += f"{self.condition_units[condition]},"
+ header += "\n"
+ units += "\n"
+ with open(self.filename, "w", encoding="utf-8") as f:
+ f.write(units)
+ f.write(header)
+ self.protocol_filename = f"{directory}/protocol_{self.name}.md"
+ with open(self.protocol_filename, "w", encoding="utf-8") as f:
+ f.write("# Multilog protocol\n\n")
+ multilog_version = (
+ subprocess.check_output(
+ ["git", "describe", "--tags", "--dirty", "--always"]
+ )
+ .strip()
+ .decode("utf-8")
+ )
+ f.write(f"This is multilog version {multilog_version}.\n")
+ f.write(
+ f"Logging stated at {datetime.datetime.now():%d.%m.%Y, %H:%M:%S}.\n\n"
+ )
+ f.write("## Initial process conditions\n\n")
+ for condition in self.meas_data:
+ f.write(
+ f"- {condition}: {self.meas_data[condition]} {self.condition_units[condition]}\n"
+ )
+ f.write("\n## Process condition log\n\n")
+
+ def sample(self):
+ """This function just exists to fit into the sampling structure
+ of multilog. Data is directly updated upon changes in the GUI.
+
+ Returns:
+ dict: {process condition: user input}
+ """
+ # meas_data is updated by the widget if changes are made. No real sampling required.
+ return self.meas_data
+
+ def save_measurement(self, time_abs, time_rel, sampling):
+ """Write sampling data to file. This function creates to files:
+ - A csv file with all values for each timestep (follwoing the
+ standard sampling procedure)
+ - A readme.md file with the initial values and timestamp + value
+ for each change in the process conditons (similar as people
+ write it to their labbook)
+
+ Args:
+ time_abs (datetime): measurement timestamp.
+ time_rel (float): relative time of measurement.
+ sampling (dict): sampling data, as returned from sample()
+ """
+ timediff = (
+ datetime.datetime.now(datetime.timezone.utc).astimezone() - time_abs
+ ).total_seconds()
+ if timediff > 1:
+ logger.warning(
+ f"{self.name} save_measurement: time difference between event and saving of {timediff} seconds for samplint timestep {time_abs.isoformat(timespec='milliseconds').replace('T', ' ')} - {time_rel}"
+ )
+ line = f"{time_abs.isoformat(timespec='milliseconds').replace('T', ' ')},{time_rel},"
+ for condition in sampling:
+ line += f"{sampling[condition]},"
+ line += "\n"
+ with open(self.filename, "a", encoding="utf-8") as f:
+ f.write(line)
+
+ if self.meas_data != self.last_meas_data and time_rel > 1:
+ with open(self.protocol_filename, "a", encoding="utf-8") as f:
+ for condition in self.meas_data:
+ if self.meas_data[condition] != self.last_meas_data[condition]:
+ f.write(
+ f"- {time_abs.strftime('%d.%m.%Y, %H:%M:%S')}, {time_rel:.1f} s, {condition}: {self.meas_data[condition]} {self.condition_units[condition]}\n"
+ )
+ self.last_meas_data = deepcopy(self.meas_data)
diff --git a/multilog/discord_bot.py b/multilog/discord_bot.py
new file mode 100644
index 0000000..56bd40e
--- /dev/null
+++ b/multilog/discord_bot.py
@@ -0,0 +1,38 @@
+"""Bot for sending discord messages on a pre-configured computer.
+Refer to the discord docs for additional information."""
+import asyncio
+import os
+from os.path import expanduser
+import logging
+
+logger = logging.getLogger(__name__)
+try:
+ from dotenv import load_dotenv
+except Exception as e:
+ logger.warning("Could not import dotenv.", exc_info=True)
+try:
+ from discord.ext import commands
+except Exception as e:
+ logger.warning("Could not import discord.", exc_info=True)
+
+
+logger = logging.getLogger(__name__)
+
+
+def send_message(msg):
+ logger.info(f"Sending discord message '{msg}'")
+ asyncio.set_event_loop(asyncio.new_event_loop())
+ load_dotenv(expanduser("~") + "/discord.env")
+ DISCORD_TOKEN = os.getenv("DISCORD_TOKEN")
+ DISCORD_CHANNEL = int(os.getenv("DISCORD_CHANNEL"))
+
+ bot = commands.Bot(command_prefix="cmd!", description="I am NemoOneBot")
+
+ @bot.event
+ async def on_ready():
+ channel = bot.get_channel(DISCORD_CHANNEL)
+ await channel.send(msg)
+ await asyncio.sleep(0.5)
+ await bot.close()
+
+ bot.run(DISCORD_TOKEN) # blocking call!
diff --git a/Exit-icon.png b/multilog/icons/Exit-icon.png
similarity index 100%
rename from Exit-icon.png
rename to multilog/icons/Exit-icon.png
diff --git a/Pause-icon.png b/multilog/icons/Pause-icon.png
similarity index 100%
rename from Pause-icon.png
rename to multilog/icons/Pause-icon.png
diff --git a/Start-icon.png b/multilog/icons/Start-icon.png
similarity index 100%
rename from Start-icon.png
rename to multilog/icons/Start-icon.png
diff --git a/multilog/icons/nemocrys.png b/multilog/icons/nemocrys.png
new file mode 100644
index 0000000..3dc3474
Binary files /dev/null and b/multilog/icons/nemocrys.png differ
diff --git a/multilog/main.py b/multilog/main.py
new file mode 100644
index 0000000..8a85924
--- /dev/null
+++ b/multilog/main.py
@@ -0,0 +1,361 @@
+import shutil
+from PyQt5.QtWidgets import QApplication
+from PyQt5.QtCore import QTimer, QThread, QObject, pyqtSignal
+import numpy as np
+import datetime
+import yaml
+import sys
+import os
+import subprocess
+import platform
+import logging
+import time
+
+
+logger = logging.getLogger(__name__)
+
+
+# This metaclass is required because the pyqtSignal 'signal' must be a class varaible
+# see https://stackoverflow.com/questions/50294652/is-it-possible-to-create-pyqtsignals-on-instances-at-runtime-without-using-class
+class SignalMetaclass(type(QObject)):
+ """Metaclass used to create new signals on the fly."""
+
+ def __new__(cls, name, bases, dct):
+ """Create new class including a pyqtSignal."""
+ dct["signal"] = pyqtSignal(dict)
+ return super().__new__(cls, name, bases, dct)
+
+
+class Sampler(QObject, metaclass=SignalMetaclass):
+ """This class is used to sample the devices from separate threads."""
+
+ def __init__(self, devices):
+ """Create sampler object
+
+ Args:
+ devices (dict): devices to be sampled.
+ """
+ super().__init__()
+ self.devices = devices
+
+ def update(self):
+ """Sampling during initialization. Data is not saved."""
+ sampling = {}
+ for device in self.devices:
+ try:
+ logger.debug(f"Sampler: updating {device}")
+ sampling.update({device: self.devices[device].sample()})
+ logger.debug(f"Sampler: updated {device}")
+ except Exception as e:
+ logger.exception(f"Error in sampling of {device}")
+ self.signal.emit(sampling) # update graphics
+
+ def sample(self, time):
+ """Sampling during recording. Data is visualized and saved.
+
+ Args:
+ time (datetime): Global timestamp of sampling step.
+ """
+ time_abs = time["time_abs"]
+ time_rel = time["time_rel"]
+ meas_data = {}
+ for device in self.devices:
+ try:
+ logger.debug(
+ f"Sampler: sampling {device}, timestep {time_abs.isoformat(timespec='milliseconds')} - {time_rel}"
+ )
+ sampling = self.devices[device].sample()
+ self.devices[device].save_measurement(time_abs, time_rel, sampling)
+ meas_data.update({device: self.devices[device].meas_data})
+ logger.debug(f"Sampler: sampled {device}")
+ except Exception as e:
+ logger.exception(f"Error in sampling of {device}")
+ self.signal.emit(meas_data) # update graphics
+
+
+class Controller(QObject):
+ """Main class controlling multilog's sampling and data visualization."""
+
+ # signals to communicate with threads
+ signal_update_main = pyqtSignal() # sample and update view
+ signal_sample_main = pyqtSignal(dict) # sample, update view and save
+ signal_update_camera = pyqtSignal() # sample and update view
+ signal_sample_camera = pyqtSignal(dict) # sample, update view and save
+
+ def __init__(self) -> None:
+ """Initialize and run multilog."""
+ super().__init__()
+
+ # load configuration, setup logging
+ with open("./config.yml", encoding="utf-8") as f:
+ self.config = yaml.safe_load(f)
+ logging.basicConfig(**self.config["logging"])
+ logging.info("initializing multilog")
+ logging.info(f"configuration: {self.config}")
+
+ # do that after logging has been configured to log possible errors
+ from .devices import (
+ BaslerCamera,
+ Daq6510,
+ IfmFlowmeter,
+ Eurotherm,
+ OptrisIP640,
+ ProcessConditionLogger,
+ PyrometerArrayLumasense,
+ PyrometerLumasense,
+ )
+ from .view import (
+ BaslerCameraWidget,
+ IfmFlowmeterWidget,
+ MainWindow,
+ Daq6510Widget,
+ EurothermWidget,
+ OptrisIP640Widget,
+ ProcessConditionLoggerWidget,
+ PyrometerLumasenseWidget,
+ PyrometerArrayLumasenseWidget,
+ )
+
+ self.sampling_started = False # this will to be true once "start" was clicked
+
+ # setup timers that emit the signals for sampling
+ # main sampling loop
+ self.timer_measurement_main = QTimer()
+ self.timer_measurement_main.setInterval(self.config["settings"]["dt-main"])
+ self.timer_measurement_main.timeout.connect(self.sample_main)
+ # camera sampling loop
+ self.timer_measurement_camera = QTimer()
+ self.timer_measurement_camera.setInterval(self.config["settings"]["dt-camera"])
+ self.timer_measurement_camera.timeout.connect(self.sample_camera)
+ # main sampling loop after startup (without saving data)
+ self.timer_update_main = QTimer()
+ self.timer_update_main.setInterval(self.config["settings"]["dt-init"])
+ self.timer_update_main.timeout.connect(self.update_main)
+ # camera frame update loop
+ self.timer_update_camera = QTimer()
+ self.timer_update_camera.setInterval(
+ self.config["settings"]["dt-camera-update"]
+ )
+ self.timer_update_camera.timeout.connect(self.update_camera)
+
+ # time information is stored globally
+ # TODO this may be the reason for the race condition in IFM-flowmeter sampling
+ self.start_time = None
+ self.abs_time = []
+ self.rel_time = []
+
+ # setup main window
+ app = QApplication(sys.argv)
+ self.main_window = MainWindow(self.start, self.exit)
+ if app.desktop().screenGeometry().width() == 1280:
+ self.main_window.resize(1180, 900)
+ self.main_window.move(10, 10)
+
+ # setup devices & tabs
+ self.devices = {}
+ self.tabs = {}
+ self.cameras = []
+ for device_name in self.config["devices"]:
+ if "DAQ-6510" in device_name:
+ device = Daq6510(self.config["devices"][device_name], device_name)
+ widget = Daq6510Widget(device)
+ elif "IFM-flowmeter" in device_name:
+ device = IfmFlowmeter(self.config["devices"][device_name], device_name)
+ widget = IfmFlowmeterWidget(device)
+ elif "Eurotherm" in device_name:
+ device = Eurotherm(self.config["devices"][device_name], device_name)
+ widget = EurothermWidget(device)
+ elif "Optris-IP-640" in device_name:
+ device = OptrisIP640(self.config["devices"][device_name], device_name)
+ widget = OptrisIP640Widget(device)
+ self.cameras.append(device_name)
+ elif "IGA-6-23" in device_name or "IGAR-6-adv" in device_name:
+ device = PyrometerLumasense(
+ self.config["devices"][device_name], device_name
+ )
+ widget = PyrometerLumasenseWidget(device)
+ elif "Series-600" in device_name:
+ logger.warning("Series-600 devices haven't been tested yet.")
+ device = PyrometerArrayLumasense(
+ self.config["devices"][device_name], device_name
+ )
+ widget = PyrometerArrayLumasenseWidget(device)
+ elif "Basler" in device_name:
+ device = BaslerCamera(self.config["devices"][device_name], device_name)
+ widget = BaslerCameraWidget(device)
+ self.cameras.append(device_name)
+ elif "Process-Condition-Logger" in device_name:
+ device = ProcessConditionLogger(
+ self.config["devices"][device_name], device_name
+ )
+ widget = ProcessConditionLoggerWidget(device)
+ #######################
+ # add new devices here!
+ #######################
+ else:
+ raise ValueError(f"unknown device {device_name} in config file.")
+
+ self.devices.update({device_name: device})
+ self.main_window.add_tab(widget, device_name)
+ self.tabs.update({device_name: widget})
+
+ # setup threads
+ self.samplers = []
+ self.threads = []
+ for device in self.devices:
+ thread = QThread()
+ sampler = Sampler({device: self.devices[device]})
+ sampler.moveToThread(thread)
+ sampler.signal.connect(self.update_view)
+ if device in self.cameras:
+ self.signal_update_camera.connect(sampler.update)
+ self.signal_sample_camera.connect(sampler.sample)
+ else:
+ self.signal_update_main.connect(sampler.update)
+ self.signal_sample_main.connect(sampler.sample)
+ self.samplers.append(sampler)
+ self.threads.append(thread)
+
+ # run
+ for thread in self.threads:
+ thread.start()
+ self.timer_update_main.start()
+ self.timer_update_camera.start()
+ self.main_window.show()
+ sys.exit(app.exec())
+
+ def update_view(self, device_sampling):
+ """Update the view for selected devices. This is called by the
+ Sampler class's update function (using a signal).
+
+ Args:
+ device_sampling (dict): {device-name: sampling}
+ """
+ try:
+ for device in device_sampling:
+ logger.debug(f"updating view {device}")
+ if not self.sampling_started:
+ self.tabs[device].set_initialization_data(device_sampling[device])
+ else:
+ self.tabs[device].set_measurement_data(
+ self.rel_time, device_sampling[device]
+ )
+ logger.debug(f"updated view {device}")
+ except Exception as e:
+ logger.exception(f"Error in updating view of {device}")
+
+ def start(self):
+ """This is executed when the start button is clicked."""
+ logger.info("Stop updating.")
+ self.timer_update_main.stop()
+ time.sleep(1) # to finish running update jobs (running in separate threads)
+ logger.info("Start sampling.")
+ self.init_output_files()
+ self.start_time = datetime.datetime.now(datetime.timezone.utc).astimezone()
+ self.main_window.set_start_time(self.start_time.strftime("%d.%m.%Y, %H:%M:%S"))
+ self.sampling_started = True
+ self.timer_measurement_main.start()
+ self.sample_main()
+ self.timer_measurement_camera.start()
+ self.sample_camera()
+
+ def exit(self):
+ """This is executed when the exit button is clicked."""
+ logger.info("Stopping sampling")
+ self.timer_update_camera.stop()
+ self.timer_measurement_main.stop()
+ self.timer_measurement_camera.stop()
+ time.sleep(1) # to finish last sampling jobs (running in separate threads)
+ for thread in self.threads:
+ thread.quit()
+ logger.info("Stopped sampling")
+ exit()
+
+ def init_output_files(self):
+ """Create directory for sampling and initialize output files."""
+ logger.info("Setting up output files.")
+ date = datetime.datetime.now().strftime("%Y-%m-%d")
+ for i in range(100):
+ if i == 99:
+ raise ValueError("Too high directory count.")
+ self.directory = f"./measdata_{date}_#{i+1:02}"
+ if not os.path.exists(self.directory):
+ os.makedirs(self.directory)
+ break
+ for device in self.devices:
+ self.devices[device].init_output(self.directory)
+ self.write_metadata()
+ shutil.copy(
+ "./multilog/nomad/base_classes.schema.archive.yaml",
+ f"{self.directory}/base_classes.schema.archive.yaml",
+ )
+ self.main_window.set_output_directory(self.directory)
+
+ def write_metadata(self):
+ """Write a csv file with information about multilog version,
+ python version and operating system.
+ """
+ multilog_version = (
+ subprocess.check_output(
+ ["git", "describe", "--tags", "--dirty", "--always"]
+ )
+ .strip()
+ .decode("utf-8")
+ )
+ metadata = f"multilog version,python version,system information,\n"
+ metadata += f"{multilog_version},{platform.python_version()},{str(platform.uname()).replace(',',';')},\n"
+ with open(f"{self.directory}/config.yml", "w", encoding="utf-8") as f:
+ yaml.dump(self.config, f)
+ with open(f"{self.directory}/metadata.csv", "w", encoding="utf-8") as f:
+ f.write(metadata)
+
+ def update_main(self):
+ """Function that triggers sampling after startup (without saving).
+ This function is called by a timer and leads to a call of the
+ update function of the Sampler objects (running in their
+ respective threads)."""
+ logger.info("update main")
+ self.main_window.set_current_time(datetime.datetime.now().strftime("%H:%M:%S"))
+ self.signal_update_main.emit()
+ if "IFM-flowmeter" in self.devices:
+ flowmeter = self.devices["IFM-flowmeter"]
+ flowmeter.check_leakage()
+
+ def update_camera(self):
+ """Function that triggers graphics update for cameras (without saving).
+ This function is called by a timer and leads to a call of the
+ update function of the Sampler objects (running in their
+ respective threads)."""
+ logger.info("update camera")
+ self.signal_update_camera.emit()
+
+ def sample_main(self):
+ """Function that triggers sampling & saving of data.
+ This function is called by a timer and leads to a call of the
+ sample function of the Sampler objects (running in their
+ respective threads)."""
+ logger.info("sample main")
+ time_abs = datetime.datetime.now(datetime.timezone.utc).astimezone()
+ time_rel = round((time_abs - self.start_time).total_seconds(), 3)
+ self.abs_time.append(time_abs)
+ self.rel_time.append(time_rel)
+ self.main_window.set_current_time(f"{time_abs:%H:%M:%S}")
+ self.signal_sample_main.emit({"time_abs": time_abs, "time_rel": time_rel})
+ if "IFM-flowmeter" in self.devices:
+ flowmeter = self.devices["IFM-flowmeter"]
+ flowmeter.check_leakage()
+
+ def sample_camera(self):
+ """Function that triggers sampling & saving of data for cameras.
+ This function is called by a timer and leads to a call of the
+ sample function of the Sampler objects (running in their
+ respective threads)."""
+ logger.info("sample camera")
+ time_abs = datetime.datetime.now(datetime.timezone.utc).astimezone()
+ time_rel = round((time_abs - self.start_time).total_seconds(), 3)
+ self.signal_sample_camera.emit({"time_abs": time_abs, "time_rel": time_rel})
+
+
+def main():
+ """Function to execute multilog."""
+ ctrl = Controller()
diff --git a/multilog/nomad/archive_template.yml b/multilog/nomad/archive_template.yml
new file mode 100644
index 0000000..0a74ff2
--- /dev/null
+++ b/multilog/nomad/archive_template.yml
@@ -0,0 +1,97 @@
+definitions:
+ name: 'Sensors'
+ sections:
+ Sensors_list:
+ base_sections:
+ - nomad.parsing.tabular.TableData
+ - nomad.datamodel.data.EntryData
+ m_annotations:
+ plot: []
+ # - label: My test plot label 1
+ # x: value_timestamp_rel
+ # y:
+ # - temperature_sensor_1/value_log
+ # - pyrosensor_2/value_log
+ quantities:
+ data_file:
+ type: str
+ description: |
+ A reference to an uploaded .csv
+ m_annotations:
+ tabular_parser:
+ sep: ','
+ comment: '#'
+ browser:
+ adaptor: RawFileAdaptor
+ eln:
+ component: FileEditQuantity
+ value_timestamp_rel:
+ type: np.float64
+ shape: ['*']
+ m_annotations:
+ tabular:
+ name: time_rel
+ description: Relative time
+ value_timestamp_abs:
+ type: Datetime
+ shape: ['*']
+ m_annotations:
+ tabular:
+ name: time_abs
+ description: |
+ Timestamp for when the values provided in the value field were registered.
+ Individual readings can be stored with their timestamps under value_log.
+ This is to timestamp the nominal setpoint or
+ average reading values listed above in the value field.
+ sub_sections:
+ {}
+data:
+ m_def: Sensors_list
+ data_file: test.csv
+
+sensor_schema_template:
+ section:
+ m_annotations:
+ plot:
+ - label: My test plot label 1
+ x: value_timestamp_rel
+ y: value_log
+ base_section: ../upload/raw/base_classes.schema.archive.yaml#Sample
+ quantities:
+ value_log: # The one we actually measurement
+ type: np.float64 # TODO try if that can be removed, it's already present in parent class
+ shape: ['*']
+ m_annotations:
+ tabular:
+ name: TE_1_K air 155 mm over crucible
+ description: Time history of sensor readings. May differ from setpoint
+ value_timestamp_rel:
+ type:
+ type_kind: quantity_reference
+ type_data: '#/definitions/section_definitions/0/quantities/1'
+ value_timestamp_abs:
+ type:
+ type_kind: quantity_reference
+ type_data: '#/definitions/section_definitions/0/quantities/2'
+ # emission:
+ # type: int
+ # m_annotations:
+ # eln:
+ # component: NumberEditQuantity
+ # description: "Emission percentage value set in pyrometer"
+ # transmission:
+ # type: int
+ # m_annotations:
+ # eln:
+ # component: NumberEditQuantity
+ # description: "Transmission percentage value set in pyrometer"
+ # t90:
+ # type: np.float64
+ # description: "FILL THE DESCRIPTION"
+ # unit: second
+ # m_annotations:
+ # eln:
+ # component: NumberEditQuantity
+ # defaultDisplayUnit: second
+ # TODO keep listing all the quantities here
+
diff --git a/multilog/nomad/base_classes.schema.archive.yaml b/multilog/nomad/base_classes.schema.archive.yaml
new file mode 100644
index 0000000..6e78394
--- /dev/null
+++ b/multilog/nomad/base_classes.schema.archive.yaml
@@ -0,0 +1,730 @@
+definitions:
+ name: 'Multiple Base Class'
+ sections: # Schemes consist of section definitions
+ Entity:
+ m_annotations:
+ eln:
+ dict()
+ base_sections:
+ - nomad.datamodel.data.EntryData
+ sub_sections:
+ users:
+ section:
+ quantities:
+ responsible_person:
+ type: Author
+ shape: ['*']
+ m_annotations:
+ eln:
+ component: AuthorEditQuantity
+ history:
+ section:
+ m_annotations:
+ eln:
+ quantities:
+ activities:
+ type: Activity
+ shape: ['*']
+ m_annotations:
+ eln:
+ component: ReferenceEditQuantity
+ Experiment:
+ base_section: '#/Entity'
+ quantities:
+ experiment_goal:
+ type: str
+ description: indicate here the goal aimed with this experiment
+ m_annotations:
+ eln:
+ component: StringEditQuantity
+ Material:
+ base_section: '#/Entity'
+ quantities:
+ test:
+ type: str
+ m_annotations:
+ eln:
+ component: StringEditQuantity
+ Substrate:
+ base_section: '#/Material'
+ sub_sections:
+ SampleID:
+ section:
+ base_sections:
+ - 'nomad.datamodel.metainfo.eln.SampleID'
+ - 'nomad.datamodel.data.EntryData'
+ m_annotations:
+ template:
+ eln:
+ # hide: ['children', 'parents']
+ # hide: ['children', 'parents', institute]
+ hide: []
+ composition:
+ repeats: true
+ #m_annotations:
+ # eln:
+ section: '#/EntityAndAmount'
+ quantities:
+ comment:
+ type: str
+ m_annotations:
+ eln:
+ component: StringEditQuantity
+ supplier:
+ type: str
+ description: Sample preparation including orientating, polishing, cutting done by this company
+ m_annotations:
+ eln:
+ component: StringEditQuantity
+ orientation:
+ type: str
+ description: crystallographic orientation of the substrate in [hkl]
+ m_annotations:
+ eln:
+ component: StringEditQuantity
+ off_cut:
+ type: np.float64
+ unit: degrees
+ description: Off-cut angle to the substrates surface
+ m_annotations:
+ eln:
+ component: NumberEditQuantity
+ doping_level:
+ type: np.float64
+ #unit: wt %
+ description: Chemical doping level of electrically conductive substrates
+ m_annotations:
+ eln:
+ component: NumberEditQuantity
+ doping_species:
+ type: str
+ description: Doping species to obtain electrical conductivity in the substrates
+ m_annotations:
+ eln:
+ component: StringEditQuantity
+ charge:
+ type: str
+ description: Substrate charge ID given by fabrication company. Detailed information can be obtained from the company by requesting this charge ID
+ m_annotations:
+ eln:
+ component: StringEditQuantity
+ size:
+ type: str
+ description: Substrate dimensions
+ m_annotations:
+ eln:
+ component: StringEditQuantity
+ prepared:
+ type: str
+ description: Is the sample annealed, cleaned and etched for smooth stepped surface?
+ m_annotations:
+ eln:
+ component: StringEditQuantity
+ recycled:
+ type: str
+ description: Was the substrate deposited already and is recycled by polishing?
+ m_annotations:
+ eln:
+ component: StringEditQuantity
+ Sample:
+ base_section: '#/Material'
+ sub_sections:
+ SampleID:
+ section:
+ base_sections:
+ - 'nomad.datamodel.metainfo.eln.SampleID'
+ - 'nomad.datamodel.data.EntryData'
+ m_annotations:
+ template:
+ eln:
+ # hide: ['children', 'parents']
+ # hide: ['children', 'parents', institute]
+ hide: []
+ geometry:
+ section:
+ m_annotations:
+ eln:
+ dict()
+ sub_sections:
+ parallelepiped:
+ section:
+ quantities:
+ height:
+ type: np.float64
+ unit: nanometer
+ description: docs
+ m_annotations:
+ eln:
+ component: NumberEditQuantity
+ defaultDisplayUnit: nanometer
+ width:
+ type: np.float64
+ unit: millimeter
+ description: docs
+ m_annotations:
+ eln:
+ component: NumberEditQuantity
+ defaultDisplayUnit: millimeter
+ length:
+ type: np.float64
+ unit: millimeter
+ description: docs
+ m_annotations:
+ eln:
+ component: NumberEditQuantity
+ defaultDisplayUnit: millimeter
+ surface_area:
+ type: np.float64
+ unit: millimeter ** 2
+ description: docs
+ m_annotations:
+ eln:
+ component: NumberEditQuantity
+ defaultDisplayUnit: millimeter ** 2
+ volume:
+ type: np.float64
+ unit: millimeter ** 3
+ description: docs
+ m_annotations:
+ eln:
+ component: NumberEditQuantity
+ defaultDisplayUnit: millimeter ** 3
+ cylinder:
+ section:
+ quantities:
+ height:
+ type: np.float64
+ unit: nanometer
+ description: docs
+ m_annotations:
+ eln:
+ component: NumberEditQuantity
+ defaultDisplayUnit: nanometer
+ radius:
+ type: np.float64
+ unit: millimeter
+ description: docs
+ m_annotations:
+ eln:
+ component: NumberEditQuantity
+ defaultDisplayUnit: millimeter
+ lower_cap_radius:
+ type: np.float64
+ unit: millimeter
+ description: docs
+ m_annotations:
+ eln:
+ component: NumberEditQuantity
+ defaultDisplayUnit: millimeter
+ upper_cap_radius:
+ type: np.float64
+ unit: millimeter
+ description: docs
+ m_annotations:
+ eln:
+ component: NumberEditQuantity
+ defaultDisplayUnit: millimeter
+ cap_surface_area:
+ type: np.float64
+ unit: millimeter ** 2
+ description: docs
+ m_annotations:
+ eln:
+ component: NumberEditQuantity
+ defaultDisplayUnit: millimeter ** 2
+ lateral_surface_area:
+ type: np.float64
+ unit: millimeter ** 2
+ description: docs
+ m_annotations:
+ eln:
+ component: NumberEditQuantity
+ defaultDisplayUnit: millimeter ** 2
+ volume:
+ type: np.float64
+ unit: millimeter ** 3
+ description: docs
+ m_annotations:
+ eln:
+ component: NumberEditQuantity
+ defaultDisplayUnit: millimeter ** 3
+ quantities:
+ # data_source:
+ # type:
+ # type_kind: Enum
+ # type_data:
+ # - experimental
+ # - simulation
+ # - declared_by_vendor
+ # - nominal
+ # m_annotations:
+ # eln:
+ # component: RadioEnumEditQuantity
+ # synthesis_method:
+ # type:
+ # type_kind: Enum
+ # type_data:
+ # - MOVPE
+ # - what not
+ # m_annotations:
+ # eln:
+ # component: RadioEnumEditQuantity
+ comment:
+ type: str
+ m_annotations:
+ eln:
+ component: StringEditQuantity
+ iupac_name:
+ type: str
+ m_annotations:
+ eln:
+ component: StringEditQuantity
+ empirical_formula:
+ type: str
+ m_annotations:
+ eln:
+ component: StringEditQuantity
+ state_or_phase:
+ type:
+ type_kind: Enum
+ type_data:
+ - cristalline solid
+ - microcristalline solid
+ - powder
+ m_annotations:
+ eln:
+ component: RadioEnumEditQuantity
+ preparation_date:
+ type: str
+ description: creation date
+ m_annotations:
+ eln:
+ component: StringEditQuantity
+ components:
+ type: '#/Material'
+ shape: ['*']
+ m_annotations:
+ eln:
+ component: ReferenceEditQuantity
+ Activity:
+ base_section: "nomad.datamodel.data.EntryData"
+ quantities:
+ start_time:
+ description: |
+ The starting date and time of the activity.
+ type: Datetime
+ m_annotations:
+ eln:
+ component: DateTimeEditQuantity
+ end_time:
+ description: |
+ The ending date and time of the activity.
+ type: Datetime
+ m_annotations:
+ eln:
+ component: DateTimeEditQuantity
+ input_materials:
+ type: '#/Material'
+ shape: ['*']
+ m_annotations:
+ eln:
+ component: ReferenceEditQuantity
+ output_materials:
+ type: '#/Material'
+ shape: ['*']
+ m_annotations:
+ eln:
+ component: ReferenceEditQuantity
+ activity_category:
+ type:
+ type_kind: Enum
+ type_data:
+ - crystal growth synthesis
+ - sample preparation synthesis
+ - epitaxial growth synthesis
+ - sol-gel synthesis
+ - surface coating synthesis
+ - measurement experiment
+ description: |
+ A phenomenon by which change takes place in a system.
+ In physiological systems, a process may be
+ chemical, physical or both.
+ [IUPAC Gold Book](https://goldbook.iupac.org/terms/view/P04858)
+ m_annotations:
+ eln:
+ component: EnumEditQuantity
+ activity_method:
+ type:
+ type_kind: Enum
+ type_data:
+ - ALL THE TECHNIQUES HERE
+ - Melt Czochralski
+ description: |
+ a method is a series of steps for performing a function or accomplishing a result.
+ Or
+ any systematic way of obtaining information about a scientific nature or to obtain a desired material or product
+ m_annotations:
+ eln:
+ component: EnumEditQuantity
+ activity_identifier:
+ type: str
+ m_annotations:
+ # tabular:
+ # name: Overview/Experiment Identifier
+ eln:
+ component: StringEditQuantity
+ location:
+ type: str
+ m_annotations:
+ # tabular:
+ # name: Overview/Experiment Location
+ eln:
+ component: StringEditQuantity
+ sub_sections:
+ users:
+ section:
+ quantities:
+ responsible_person:
+ type: Author
+ shape: ['*']
+ m_annotations:
+ eln:
+ component: AuthorEditQuantity
+ operator:
+ type: Author
+ shape: ['*']
+ m_annotations:
+ eln:
+ component: AuthorEditQuantity
+ Measurement:
+ base_section: '#/Activity'
+ Procedure_step:
+ more:
+ label_quantity: 'step_name'
+ quantities:
+ step_name:
+ type: str
+ description: what this step consists of
+ m_annotations:
+ eln:
+ component: StringEditQuantity
+ # step_type:
+ # type:
+ # type_kind: Enum
+ # type_data:
+ # - Pre-process
+ # - Process
+ # - Post-process
+ # - Measurement
+ # - Storage
+ # m_annotations:
+ # eln:
+ # component: EnumEditQuantity
+ step_number:
+ type: int
+ description: sequential number of the step on going
+ m_annotations:
+ eln:
+ component: NumberEditQuantity
+ step_comment:
+ type: str
+ description: more verbose description of the step
+ m_annotations:
+ eln:
+ component: StringEditQuantity
+ step_duration:
+ type: np.float64
+ unit: minute
+ description: Past time since process start
+ m_annotations:
+ eln:
+ component: NumberEditQuantity
+ defaultDisplayUnit: minute
+ elapsed_time:
+ type: np.float64
+ unit: minute
+ description: Duration of each step
+ m_annotations:
+ eln:
+ component: NumberEditQuantity
+ defaultDisplayUnit: minute
+ Process:
+ base_section: '#/Activity'
+ quantities:
+ comment:
+ type: str
+ description: my descr
+ m_annotations:
+ eln:
+ component: StringEditQuantity
+ sub_sections:
+ procedure:
+ repeats: true
+ section: '#/Procedure_step'
+ Substance:
+ base_section: '#/Material'
+ more:
+ label_quantity: iupac_name
+ sub_sections:
+ SampleID:
+ section:
+ base_sections:
+ - 'nomad.datamodel.metainfo.eln.SampleID'
+ - 'nomad.datamodel.data.EntryData'
+ m_annotations:
+ template:
+ eln:
+ # hide: ['children', 'parents']
+ # hide: ['children', 'parents', institute]
+ hide: []
+ quantities:
+ comment:
+ type: str
+ m_annotations:
+ eln:
+ component: StringEditQuantity
+ empirical_formula:
+ type: str
+ description: chemical formula
+ m_annotations:
+ eln:
+ component: StringEditQuantity
+ iupac_name:
+ type: str
+ description: the IUPAC nomenclature of the chemical
+ m_annotations:
+ eln:
+ component: StringEditQuantity
+ state_or_phase:
+ type: str
+ description: Phase of the chemical in the bottle
+ m_annotations:
+ eln:
+ component: StringEditQuantity
+ supplier:
+ type: str
+ description: Fabricating company
+ m_annotations:
+ eln:
+ component: StringEditQuantity
+ purity:
+ type:
+ type_kind: Enum
+ type_data:
+ - Puratronic 99.995%
+ - Puratronic 99.999%
+ - REacton 99.995%
+ - REacton 99.999%
+ - ACS grade
+ - Reagent grade
+ - USP grade
+ - NF grade
+ - BP grade
+ - JP grade
+ - Laboratory grade
+ - Purified grade
+ - Technical grade
+ description: Purity of the Chemical. [Wikipedia](https://en.wikipedia.org/wiki/Chemical_purity)
+ m_annotations:
+ eln:
+ component: EnumEditQuantity
+ buying_date:
+ type: Datetime
+ description: Date of the Invoice Mail
+ m_annotations:
+ eln:
+ component: DateTimeEditQuantity
+ opening_date:
+ type: Datetime
+ description: Date of Opening the Chemical bottle in the Glove box
+ m_annotations:
+ eln:
+ component: DateTimeEditQuantity
+ batch_number:
+ type: str
+ description: batch number of chemical
+ m_annotations:
+ eln:
+ component: StringEditQuantity
+ cas_number:
+ type: str
+ description: CAS number
+ m_annotations:
+ eln:
+ component: StringEditQuantity
+ sku_number:
+ type: str
+ description: sku number
+ m_annotations:
+ eln:
+ component: StringEditQuantity
+ smiles:
+ type: str
+ description: smiles string indentifier
+ m_annotations:
+ eln:
+ component: StringEditQuantity
+ inchi:
+ type: str
+ description: inchi string indentifier
+ m_annotations:
+ eln:
+ component: StringEditQuantity
+ documentation:
+ type: str
+ description: pdf files containing certificate and other documentation
+ m_annotations:
+ browser:
+ adaptor: RawFileAdaptor # Allows to navigate to files in the data browser
+ eln:
+ component: FileEditQuantity
+ EntityAndAmount:
+ base_section: '#/Material'
+ quantities:
+ component:
+ type: Material
+ m_annotations:
+ eln:
+ component: ReferenceEditQuantity
+ mass:
+ type: np.float64
+ unit: mg
+ description: |
+ Mass of the powder precursor weighted out in the glove box
+ m_annotations:
+ eln:
+ component: NumberEditQuantity
+ defaultDisplayUnit: mg
+ amount:
+ type: np.float64
+ unit: mmol
+ description: |
+ Amount of substance of precursor powder weighted out
+ m_annotations:
+ eln:
+ component: NumberEditQuantity
+ defaultDisplayUnit: mmol
+ volume_solvent:
+ type: np.float64
+ unit: ml
+ description: |
+ Volume of solvent used to solve the powder precursor
+ m_annotations:
+ eln:
+ component: NumberEditQuantity
+ defaultDisplayUnit: ml
+ mass_concentration:
+ type: np.float64
+ unit: g/L
+ description: |
+ Mass concentration of the prepared precursor-solvent solution
+ m_annotations:
+ eln:
+ component: NumberEditQuantity
+ defaultDisplayUnit: g/L
+ molar_concentration:
+ type: np.float64
+ unit: mmol/L
+ description: |
+ Amount of substance concentration of the prepared precursor-solvent solution
+ m_annotations:
+ eln:
+ component: NumberEditQuantity
+ defaultDisplayUnit: mmol/L
+ flow:
+ type: np.float64
+ unit: mL/minute
+ description: |
+ Velocity of the precursor solution flow adjusted by peristaltic pumps
+ m_annotations:
+ eln:
+ component: NumberEditQuantity
+ defaultDisplayUnit: mL/minute
+ Sensor:
+ quantities:
+ model:
+ type: str
+ m_annotations:
+ eln:
+ component: StringEditQuantity
+ name:
+ type: str
+ m_annotations:
+ eln:
+ component: StringEditQuantity
+ description: "name for the sensor"
+ sensor_id:
+ type: str
+ m_annotations:
+ eln:
+ component: StringEditQuantity
+ description: "ID of the applied sensor"
+ attached_to:
+ type: str
+ m_annotations:
+ eln:
+ component: StringEditQuantity
+ description: your port or channel where sensor is attached
+ measured_property:
+ type:
+ type_kind: Enum
+ type_data:
+ - temperature
+ - pH
+ - magnetic_field
+ - electric_field
+ - conductivity
+ - resistance
+ - voltage
+ - pressure
+ - flow
+ - stress
+ - strain
+ - shear
+ - surface_pressure
+ description: "name for measured signal"
+ m_annotations:
+ eln:
+ component: EnumEditQuantity
+ type:
+ type: str
+ m_annotations:
+ eln:
+ component: StringEditQuantity
+ description: |
+ The type of hardware used for the measurement.
+ Examples (suggestions but not restrictions):
+ Temperature: J | K | T | E | R | S | Pt100 | Rh/Fe
+ pH: Hg/Hg2Cl2 | Ag/AgCl | ISFET
+ Ion selective electrode: specify species; e.g. Ca2+
+ Magnetic field: Hall
+ Surface pressure: wilhelmy plate
+ notes:
+ type: str
+ m_annotations:
+ eln:
+ component: StringEditQuantity
+ description: "Notes or comments for the sensor"
+ value_set:
+ type: np.float64
+ shape: ['*']
+ description: |
+ For each point in the scan space, either the nominal
+ setpoint of an independently scanned controller
+ or a representative average value of a measurement sensor is registered.
+ value_log:
+ type: np.float64
+ shape: ['*']
+ description: Time history of sensor readings. May differ from setpoint
+ value_timestamp_rel:
+ type: np.float64
+ shape: ['*']
+ description: Relative time in measurement series.
+ value_timestamp_abs:
+ type: Datetime
+ shape: ['*']
+ description: |
+ Timestamp for when the values provided in the value field were registered.
+ Individual readings can be stored with their timestamps under value_log.
+ This is to timestamp the nominal setpoint or
+ average reading values listed above in the value field.
diff --git a/multilog/pyOptris/__init__.py b/multilog/pyOptris/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/multilog/pyOptris/direct_binding.py b/multilog/pyOptris/direct_binding.py
new file mode 100644
index 0000000..4dff13d
--- /dev/null
+++ b/multilog/pyOptris/direct_binding.py
@@ -0,0 +1,339 @@
+import sys
+import ctypes
+from typing import Tuple
+from enum import Enum
+import numpy as np
+
+DEFAULT_WIN_PATH = "..//..//irDirectSDK//sdk//x64//libirimager.dll"
+DEFAULT_LINUX_PATH = "/usr/lib/libirdirectsdk.so"
+lib = None
+
+# Function to load the DLL accordingly to the OS
+def load_DLL(dll_path: str):
+ global lib
+ if sys.platform == "linux":
+ path = dll_path if dll_path is not None else DEFAULT_LINUX_PATH
+ lib = ctypes.CDLL(DEFAULT_LINUX_PATH)
+
+ elif sys.platform == "win32":
+ path = dll_path if dll_path is not None else DEFAULT_WIN_PATH
+ lib = ctypes.CDLL(path)
+
+
+# Load DLL
+load_DLL(None)
+
+#
+# @brief Initializes an IRImager instance connected to this computer via USB
+# @param[in] xml_config path to xml config
+# @param[in] formats_def path to Formats.def file. Set zero for standard value.
+# @param[in] log_file path to log file. Set zero for standard value.
+# @return 0 on success, -1 on error
+#
+# __IRDIRECTSDK_API__ int evo_irimager_usb_init(const char* xml_config, const char* formats_def, const char* log_file);
+#
+def usb_init(xml_config: str, formats_def: str = None, log_file: str = None) -> int:
+ return lib.evo_irimager_usb_init(
+ xml_config.encode(),
+ None if formats_def is None else formats_def.encode(),
+ None if log_file is None else log_file.encode(),
+ )
+
+
+#
+# @brief Initializes the TCP connection to the daemon process (non-blocking)
+# @param[in] IP address of the machine where the daemon process is running ("localhost" can be resolved)
+# @param port Port of daemon, default 1337
+# @return error code: 0 on success, -1 on host not found (wrong IP, daemon not running), -2 on fatal error
+#
+# __IRDIRECTSDK_API__ int evo_irimager_tcp_init(const char* ip, int port);
+#
+def tcp_init(ip: str, port: int) -> int:
+ return lib.evo_irimager_tcp_init(ip.encode(), port)
+
+
+#
+# @brief Disconnects the camera, either connected via USB or TCP
+# @return 0 on success, -1 on error
+#
+# __IRDIRECTSDK_API__ int evo_irimager_terminate();
+#
+def terminate() -> int:
+ return lib.evo_irimager_terminate(None)
+
+
+#
+# @brief Accessor to image width and height
+# @param[out] w width
+# @param[out] h height
+# @return 0 on success, -1 on error
+#
+# __IRDIRECTSDK_API__ int evo_irimager_get_thermal_image_size(int* w, int* h);
+#
+def get_thermal_image_size() -> Tuple[int, int]:
+ width = ctypes.c_int()
+ height = ctypes.c_int()
+ _ = lib.evo_irimager_get_thermal_image_size(
+ ctypes.byref(width), ctypes.byref(height)
+ )
+ return width.value, height.value
+
+
+#
+# @brief Accessor to width and height of false color coded palette image
+# @param[out] w width
+# @param[out] h height
+# @return 0 on success, -1 on error
+#
+# __IRDIRECTSDK_API__ int evo_irimager_get_palette_image_size(int* w, int* h);
+#
+def get_palette_image_size() -> Tuple[int, int]:
+ width = ctypes.c_int()
+ height = ctypes.c_int()
+ _ = lib.evo_irimager_get_palette_image_size(
+ ctypes.byref(width), ctypes.byref(height)
+ )
+ return width.value, height.value
+
+
+#
+# @brief Accessor to thermal image by reference
+# Conversion to temperature values are to be performed as follows:
+# t = ((double)data[x] - 1000.0) / 10.0;
+# @param[in] w image width
+# @param[in] h image height
+# @param[out] data pointer to unsigned short array allocate by the user (size of w * h)
+# @return error code: 0 on success, -1 on error, -2 on fatal error (only TCP connection)
+#
+# __IRDIRECTSDK_API__ int evo_irimager_get_thermal_image(int* w, int* h, unsigned short* data);
+#
+def get_thermal_image(width: int, height: int) -> np.ndarray:
+ w = ctypes.byref(ctypes.c_int(width))
+ h = ctypes.byref(ctypes.c_int(height))
+ thermalData = np.empty((height, width), dtype=np.uint16)
+ thermalDataPointer = thermalData.ctypes.data_as(ctypes.POINTER(ctypes.c_ushort))
+ _ = lib.evo_irimager_get_thermal_image(w, h, thermalDataPointer)
+ return thermalData
+
+
+#
+# @brief Accessor to an RGB palette image by reference
+# data format: unsigned char array (size 3 * w * h) r,g,b
+# @param[in] w image width
+# @param[in] h image height
+# @param[out] data pointer to unsigned char array allocate by the user (size of 3 * w * h)
+# @return error code: 0 on success, -1 on error, -2 on fatal error (only TCP connection)
+#
+# __IRDIRECTSDK_API__ int evo_irimager_get_palette_image(int* w, int* h, unsigned char* data);
+#
+def get_palette_image(width: int, height: int) -> np.ndarray:
+ w = ctypes.byref(ctypes.c_int(width))
+ h = ctypes.byref(ctypes.c_int(height))
+ paletteData = np.empty((height, width, 3), dtype=np.uint8)
+ paletteDataPointer = paletteData.ctypes.data_as(ctypes.POINTER(ctypes.c_ubyte))
+ retVal = -1
+ while retVal != 0:
+ retVal = lib.evo_irimager_get_palette_image(w, h, paletteDataPointer)
+ return paletteData
+
+
+#
+# @brief Accessor to an RGB palette image and a thermal image by reference
+# @param[in] w_t width of thermal image
+# @param[in] h_t height of thermal image
+# @param[out] data_t data pointer to unsigned short array allocate by the user (size of w * h)
+# @param[in] w_p width of palette image (can differ from thermal image width due to striding)
+# @param[in] h_p height of palette image (can differ from thermal image height due to striding)
+# @param[out] data_p data pointer to unsigned char array allocate by the user (size of 3 * w * h)
+# @return error code: 0 on success, -1 on error, -2 on fatal error (only TCP connection)
+#
+# __IRDIRECTSDK_API__ int evo_irimager_get_thermal_palette_image(int w_t, int h_t, unsigned short* data_t, int w_p, int h_p, unsigned char* data_p );
+#
+def get_thermal_palette_image(width: int, height: int) -> Tuple[int, int]:
+ w = ctypes.byref(ctypes.c_int(width))
+ h = ctypes.byref(ctypes.c_int(height))
+ thermalData = np.empty((height, width), dtype=np.uint16)
+ paletteData = np.empty((height, width, 3), dtype=np.uint8)
+ thermalDataPointer = thermalData.ctypes.data_as(ctypes.POINTER(ctypes.c_ushort))
+ paletteDataPointer = paletteData.ctypes.data_as(ctypes.POINTER(ctypes.c_ubyte))
+ _ = lib.evo_irimager_get_thermal_palette_image(
+ w, h, thermalDataPointer, w, h, paletteDataPointer
+ )
+ return (thermalData, paletteData)
+
+
+#
+# @brief sets palette format to daemon.
+# Defined in IRImager Direct-SDK, see
+# enum EnumOptrisColoringPalette{eAlarmBlue = 1,
+# eAlarmBlueHi = 2,
+# eGrayBW = 3,
+# eGrayWB = 4,
+# eAlarmGreen = 5,
+# eIron = 6,
+# eIronHi = 7,
+# eMedical = 8,
+# eRainbow = 9,
+# eRainbowHi = 10,
+# eAlarmRed = 11 };
+#
+# @param id palette id
+# @return error code: 0 on success, -1 on error, -2 on fatal error (only TCP connection)
+#
+# __IRDIRECTSDK_API__ int evo_irimager_set_palette(int id);
+#
+class ColouringPalette(Enum):
+ ALARM_BLUE = 1
+ ALARM_BLUE_HI = 2
+ GRAY_BW = 3
+ GRAY_WB = 4
+ ALARM_GREEN = 5
+ IRON = 6
+ IRON_HI = 7
+ MEDICAL = 8
+ RAINBOW = 9
+ RAINBOW_HI = 10
+ ALARM_RED = 11
+
+
+def set_palette(colouringPalette: ColouringPalette) -> int:
+ return lib.evo_irimager_set_palette(colouringPalette)
+
+
+#
+# @brief sets palette scaling method
+# Defined in IRImager Direct-SDK, see
+# enum EnumOptrisPaletteScalingMethod{eManual = 1,
+# eMinMax = 2,
+# eSigma1 = 3,
+# eSigma3 = 4 };
+# @param scale scaling method id
+# @return error code: 0 on success, -1 on error, -2 on fatal error (only TCP connection)
+#
+# __IRDIRECTSDK_API__ int evo_irimager_set_palette_scale(int scale);
+#
+class PaletteScalingMethod(Enum):
+ MANUAL = 1
+ MIN_MAX = 2
+ SIGMA1 = 3
+ SIGMA3 = 4
+
+
+def set_palette_scale(paletteScalingMethod: PaletteScalingMethod) -> int:
+ return lib.evo_irimager_set_palette_scale(paletteScalingMethod)
+
+
+#
+# @brief sets shutter flag control mode
+# @param mode 0 means manual control, 1 means automode
+# @return error code: 0 on success, -1 on error, -2 on fatal error (only TCP connection)
+#
+# __IRDIRECTSDK_API__ int evo_irimager_set_shutter_mode(int mode);
+#
+class ShutterMode(Enum):
+ MANUAL = 0
+ AUTO = 1
+
+
+def set_shutter_mode(shutterMode: ShutterMode) -> int:
+ return lib.evo_irimager_set_shutter_mode(shutterMode)
+
+
+#
+# @brief forces a shutter flag cycle
+# @return error code: 0 on success, -1 on error, -2 on fatal error (only TCP connection)
+#
+# __IRDIRECTSDK_API__ int evo_irimager_trigger_shutter_flag();
+#
+def trigger_shutter_flag() -> int:
+ return lib.evo_irimager_trigger_shutter_flag()
+
+
+#
+# @brief sets the minimum and maximum remperature range to the camera (also configurable in xml-config)
+# @return error code: 0 on success, -1 on error, -2 on fatal error (only TCP connection)
+#
+# __IRDIRECTSDK_API__ int evo_irimager_set_temperature_range(int t_min, int t_max);
+#
+def set_temperature_range(min: int, max: int) -> int:
+ return lib.evo_irimager_set_temperature_range(min, max)
+
+
+#
+# @brief sets radiation properties, i.e. emissivity and transmissivity parameters (not implemented for TCP connection, usb mode only)
+# @param[in] emissivity emissivity of observed object [0;1]
+# @param[in] transmissivity transmissivity of observed object [0;1]
+# @param[in] tAmbient ambient temperature, setting invalid values (below -273,15 degrees) forces the library to take its own measurement values.
+# @return error code: 0 on success, -1 on error, -2 on fatal error (only TCP connection)
+#
+# __IRDIRECTSDK_API__ int evo_irimager_set_radiation_parameters(float emissivity, float transmissivity, float tAmbient);
+#
+def set_radiation_parameters(
+ emissivity: float, transmissivity: float, ambientTemperature: float
+) -> int:
+ return lib.evo_irimager_set_radiation_parameters(
+ ctypes.c_float(emissivity),
+ ctypes.c_float(transmissivity),
+ ctypes.c_float(ambientTemperature),
+ )
+
+
+#
+# @brief
+# @return error code: 0 on success, -1 on error
+#
+# __IRDIRECTSDK_API__ int evo_irimager_to_palette_save_png(unsigned short* thermal_data, int w, int h, const char* path, int palette, int palette_scale);
+#
+
+#
+# @brief Set the position of the focusmotor
+# @param[in] pos fucos motor position in %
+# @return error code: 0 on success, -1 on error or if no focusmotor is available
+#
+# __IRDIRECTSDK_API__ int evo_irimager_set_focusmotor_pos(float pos);
+#
+def set_focus_motor_position(position: float) -> int:
+ return lib.evo_irimager_set_focusmotor_pos(position)
+
+
+#
+# @brief Get the position of the focusmotor
+# @param[out] posOut Data pointer to float for current fucos motor position in % (< 0 if no focusmotor available)
+# @return error code: 0 on success, -1 on error
+#
+# __IRDIRECTSDK_API__ int evo_irimager_get_focusmotor_pos(float *posOut);
+#
+def get_focus_motor_position() -> float:
+ position = ctypes.c_float()
+ _ = lib.evo_irimager_get_focusmotor_pos(ctypes.byref(position))
+ return position.value
+
+
+#
+# Launch TCP daemon
+# @return error code: 0 on success, -1 on error, -2 on fatal error (only TCP connection)
+#
+# __IRDIRECTSDK_API__ int evo_irimager_daemon_launch();
+#
+def daemon_launch() -> int:
+ return lib.evo_irimager_daemon_launch(None)
+
+
+#
+# Check whether daemon is already running
+# @return error code: 0 daemon is already active, -1 daemon is not started yet
+#
+# __IRDIRECTSDK_API__ int evo_irimager_daemon_is_running();
+#
+def daemon_is_running() -> int:
+ return lib.evo_irimager_daemon_is_running(None)
+
+
+#
+# Kill TCP daemon
+# @return error code: 0 on success, -1 on error, -2 on fatal error (only TCP connection)
+#
+# __IRDIRECTSDK_API__ int evo_irimager_daemon_kill();
+#
+def daemon_kill() -> int:
+ return lib.evo_irimager_daemon_kill(None)
diff --git a/multilog/pyOptris/readme.md b/multilog/pyOptris/readme.md
new file mode 100644
index 0000000..4a11924
--- /dev/null
+++ b/multilog/pyOptris/readme.md
@@ -0,0 +1,25 @@
+This code is copied from https://github.com/FiloCara/pyOptris/tree/dev/pyOptris
+
+There was no License file / text included but in the setup.py it is stated that this code is published under MIT License:
+
+MIT License
+
+Copyright (c) [year] [fullname]
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/multilog/view.py b/multilog/view.py
new file mode 100644
index 0000000..31d1a3c
--- /dev/null
+++ b/multilog/view.py
@@ -0,0 +1,974 @@
+"""This module contains the main GUI window and a class for each device
+implementing respective the tab.
+
+Each device-widget must implement the following functions:
+- def set_initialization_data(self, sampling: Any) -> None
+- set_measurement_data(self, rel_time: list, meas_data: Any) -> None
+"""
+
+
+from functools import partial
+import logging
+from matplotlib.colors import cnames
+import numpy as np
+from PyQt5.QtCore import Qt
+from PyQt5.QtGui import QIcon, QFont
+from PyQt5.QtWidgets import (
+ QMainWindow,
+ QWidget,
+ QHBoxLayout,
+ QVBoxLayout,
+ QGridLayout,
+ QComboBox,
+ QFrame,
+ QLineEdit,
+ QPushButton,
+ QSplitter,
+ QTabWidget,
+ QScrollArea,
+ QLabel,
+ QCheckBox,
+ QGroupBox,
+)
+import pyqtgraph as pg
+
+from .devices import (
+ BaslerCamera,
+ Daq6510,
+ IfmFlowmeter,
+ Eurotherm,
+ OptrisIP640,
+ ProcessConditionLogger,
+ PyrometerArrayLumasense,
+ PyrometerLumasense,
+)
+
+
+logger = logging.getLogger(__name__)
+COLORS = [
+ "red",
+ "green",
+ "cyan",
+ "magenta",
+ "blue",
+ "orange",
+ "darkmagenta",
+ "yellow",
+ "turquoise",
+ "purple",
+ "brown",
+ "tomato",
+ "lime",
+ "olive",
+ "navy",
+ "darkmagenta",
+ "beige",
+ "peru",
+ "grey",
+ "white",
+]
+
+
+class LineEdit(QLineEdit):
+ """Modified of QLineEdit: red color if modified and not saved."""
+
+ def __init__(self, parent=None):
+ super(LineEdit, self).__init__(parent)
+
+ def focusInEvent(self, e):
+ super(LineEdit, self).focusInEvent(e)
+ self.setStyleSheet("color: red")
+ self.selectAll()
+
+ def mousePressEvent(self, e):
+ self.setStyleSheet("color: red")
+ self.selectAll()
+
+
+class MainWindow(QMainWindow):
+ """multilog's main window."""
+
+ def __init__(self, start_function, exit_function, parent=None):
+ """Initialize main window.
+
+ Args:
+ start_function (func): function to-be-connected to start button
+ exit_function (func): function to-be-connected to exit button
+ """
+ super().__init__(parent)
+ self.start_function = start_function
+ self.exit_function = exit_function
+
+ # Main window
+ self.setWindowTitle("multilog 2")
+ self.setWindowIcon(QIcon("./multilog/icons/nemocrys.png"))
+ self.resize(1400, 900)
+ self.move(300, 10)
+ self.central_widget = QWidget()
+ self.setCentralWidget(self.central_widget)
+ self.main_layout = QVBoxLayout()
+ self.central_widget.setLayout(self.main_layout)
+ splitter_style_sheet = (
+ "QSplitter::handle{background: LightGrey; width: 5px; height: 5px;}"
+ )
+ self.splitter_display = QSplitter(Qt.Vertical, frameShape=QFrame.StyledPanel)
+ self.splitter_display.setChildrenCollapsible(False)
+ self.splitter_display.setStyleSheet(splitter_style_sheet)
+ self.main_layout.addWidget(self.splitter_display)
+
+ # Tabs with instruments
+ self.tab_widget = QTabWidget()
+ self.tab_widget.setStyleSheet("QTabBar {font-size: 14pt; color: blue;}")
+ scroll_area = QScrollArea()
+ scroll_area.setWidget(self.tab_widget)
+ scroll_area.setWidgetResizable(True)
+ scroll_area.setFixedHeight(1000)
+ self.splitter_display.addWidget(self.tab_widget)
+
+ # Buttons and labels with main information
+ self.button_widget = QWidget()
+ self.button_layout = QHBoxLayout()
+ self.button_widget.setLayout(self.button_layout)
+ self._setup_buttons(self.button_layout)
+ self.splitter_display.addWidget(self.button_widget)
+ self.lbl_current_time_txt = QLabel("Current time: ")
+ self.lbl_current_time_txt.setFont(QFont("Times", 12))
+ self.lbl_current_time_val = QLabel("XX:XX:XX")
+ self.lbl_current_time_val.setFont(QFont("Times", 12, QFont.Bold))
+ self.lbl_current_time_val.setStyleSheet(f"color: blue")
+ self.button_layout.addWidget(self.lbl_current_time_txt)
+ self.button_layout.addWidget(self.lbl_current_time_val)
+ self.lbl_start_time_txt = QLabel("Start time: ")
+ self.lbl_start_time_txt.setFont(QFont("Times", 12))
+ self.lbl_start_time_val = QLabel("XX.XX.XXXX XX:XX:XX")
+ self.lbl_start_time_val.setFont(QFont("Times", 12, QFont.Bold))
+ self.lbl_start_time_val.setStyleSheet(f"color: blue")
+ self.button_layout.addWidget(self.lbl_start_time_txt)
+ self.button_layout.addWidget(self.lbl_start_time_val)
+ self.lbl_output_dir_txt = QLabel("Output directory: ")
+ self.lbl_output_dir_txt.setFont(QFont("Times", 12))
+ self.lbl_output_dir_val = QLabel("./measdata_XXXX-XX-XX_#XX")
+ self.lbl_output_dir_val.setFont(QFont("Times", 12, QFont.Bold))
+ self.lbl_output_dir_val.setStyleSheet(f"color: blue")
+ self.button_layout.addWidget(self.lbl_output_dir_txt)
+ self.button_layout.addWidget(self.lbl_output_dir_val)
+
+ def add_tab(self, tab_widget, tab_name):
+ """Add device tab to the main layout.
+
+ Args:
+ tab_widget (QWidget): widget to-be added
+ tab_name (str): name of the tab
+ """
+ self.tab_widget.addTab(tab_widget, tab_name)
+
+ def _setup_buttons(self, button_layout):
+ self.btn_start = QPushButton()
+ self.btn_start.setText("Start")
+ self.btn_start.setMaximumWidth(300)
+ self.btn_start.setIcon(QIcon("./multilog/icons/Start-icon.png"))
+ self.btn_start.setFont(QFont("Times", 16, QFont.Bold))
+ self.btn_start.setStyleSheet("color: red")
+ self.btn_start.setEnabled(True)
+
+ self.btn_exit = QPushButton()
+ self.btn_exit.setText("Exit")
+ self.btn_exit.setMaximumWidth(380)
+ self.btn_exit.setIcon(QIcon("./multilog/icons/Exit-icon.png"))
+ self.btn_exit.setFont(QFont("Times", 16, QFont.Bold))
+ self.btn_exit.setStyleSheet("color: red")
+ self.btn_exit.setEnabled(True)
+
+ button_layout.addWidget(self.btn_start)
+ button_layout.addWidget(self.btn_exit)
+ button_layout.setSpacing(20)
+
+ self.btn_start.clicked.connect(self.btn_start_click)
+ self.btn_exit.clicked.connect(self.btn_exit_click)
+
+ def btn_start_click(self):
+ self.btn_exit.setEnabled(True)
+ self.btn_start.setEnabled(False)
+ self.lbl_current_time_val.setStyleSheet(f"color: red")
+ self.start_function()
+
+ def btn_exit_click(self):
+ self.exit_function()
+
+ def set_current_time(self, current_time):
+ self.lbl_current_time_val.setText(f"{current_time}")
+
+ def set_output_directory(self, output_directory):
+ self.lbl_output_dir_val.setText(f"{output_directory}")
+
+ def set_start_time(self, start_time):
+ self.lbl_start_time_val.setText(f"{start_time}")
+
+
+class ImageWidget(QSplitter):
+ """Base class for devices displaying an image."""
+
+ def __init__(self, parent=None):
+ super().__init__(Qt.Horizontal, frameShape=QFrame.StyledPanel, parent=parent)
+ self.setChildrenCollapsible(True)
+ self.setStyleSheet(
+ "QSplitter::handle{background: LightGrey; width: 5px; height: 5px;}"
+ )
+
+ self.graphics_widget = QWidget()
+ self.graphics_layout = QVBoxLayout()
+ self.graphics_widget.setLayout(self.graphics_layout)
+ self.addWidget(self.graphics_widget)
+
+ self.parameter_widget = QWidget()
+ self.parameter_layout = QGridLayout()
+ self.parameter_widget.setLayout(self.parameter_layout)
+ self.addWidget(self.parameter_widget)
+
+ self.image_view = pg.ImageView()
+ self.graphics_layout.addWidget(self.image_view)
+
+ def set_image(self, data):
+ """Set an image to be displayed.
+
+ Args:
+ data (numpy.array): image
+ """
+ self.image_view.setImage(data)
+
+ def set_cmap(self, cmap_name="turbo"):
+ """Set color map for the image (if data is 2D heatmap)"""
+ self.cmap = pg.colormap.getFromMatplotlib(cmap_name)
+ self.image_view.setColorMap(self.cmap)
+
+
+class PlotWidget(QSplitter):
+ """Base class for devices displaying a 2D plot."""
+
+ def __init__(self, sensors, parameter="Temperature", unit="°C", parent=None):
+ """Setup plot widget tab.
+
+ Args:
+ sensors (list): list of sensors
+ parameter (str, optional): name of visualized parameter.
+ Defaults to "Temperature".
+ unit (str, optional): unit of visualized parameter.
+ Defaults to "°C".
+ """
+ super().__init__(Qt.Horizontal, frameShape=QFrame.StyledPanel, parent=parent)
+ self.unit = unit
+
+ # setup main layout
+ self.setChildrenCollapsible(True)
+ self.setStyleSheet(
+ "QSplitter::handle{background: LightGrey; width: 5px; height: 5px;}"
+ )
+
+ self.graphics_widget = QWidget()
+ self.graphics_layout = QVBoxLayout()
+ self.graphics_widget.setLayout(self.graphics_layout)
+ self.addWidget(self.graphics_widget)
+
+ self.parameter_widget = QWidget()
+ self.parameter_layout = QGridLayout()
+ self.parameter_widget.setLayout(self.parameter_layout)
+ self.addWidget(self.parameter_widget)
+
+ self.graphics_widget = pg.GraphicsLayoutWidget()
+ self.graphics_layout.addWidget(self.graphics_widget)
+
+ # setup plot
+ self.plot = self.graphics_widget.addPlot()
+ self.plot.showGrid(x=True, y=True)
+ self.plot.setLabel("left", f"{parameter} [{unit}]")
+ self.plot.setLabel("bottom", "time [s]")
+ # self.plot.getAxis('top').setTicks([x2_ticks,[]]) # TODO set that!
+ self.plot.enableAutoRange(axis="x")
+ self.plot.enableAutoRange(axis="y")
+ self.pens = []
+ for color in COLORS:
+ self.pens.append(pg.mkPen(color=cnames[color]))
+ self.lines = {}
+ for i in range(len(sensors)):
+ line = self.plot.plot([], [], pen=self.pens[i])
+ self.lines.update({sensors[i]: line})
+
+ self.padding = 0.0
+ self.x_min = 0
+ self.x_max = 60
+ self.y_min = 0
+ self.y_max = 1
+
+ # setup controls for figure scaling
+ self.group_box_plot = QGroupBox("Plot confiuration")
+ # self.group_box_plot.setObjectName('Group')
+ # self.group_box_plot.setStyleSheet(
+ # 'QGroupBox#Group{border: 1px solid black; color: black; \
+ # font-size: 16px; subcontrol-position: top left; font-weight: bold;\
+ # subcontrol-origin: margin; padding: 10px}'
+ # )
+ self.group_box_plot_layout = QGridLayout()
+ self.group_box_plot.setLayout(self.group_box_plot_layout)
+ self.parameter_layout.addWidget(self.group_box_plot)
+ self.parameter_layout.setAlignment(self.group_box_plot, Qt.AlignTop)
+
+ self.lbl_x_edit = QLabel("Time [s] : ")
+ self.lbl_x_edit.setFont(QFont("Times", 12))
+ self.lbl_x_edit.setAlignment(Qt.AlignRight)
+ self.edit_x_min = LineEdit()
+ self.edit_x_min.setFixedWidth(90)
+ self.edit_x_min.setFont(QFont("Times", 14, QFont.Bold))
+ self.edit_x_min.setText(str(self.x_min))
+ self.edit_x_min.setEnabled(False)
+ self.edit_x_max = LineEdit()
+ self.edit_x_max.setFixedWidth(90)
+ self.edit_x_max.setFont(QFont("Times", 14, QFont.Bold))
+ self.edit_x_max.setText(str(self.x_max))
+ self.edit_x_max.setEnabled(False)
+ self.cb_autoscale_x = QCheckBox("Autoscale X")
+ self.cb_autoscale_x.setChecked(True)
+ self.cb_autoscale_x.setFont(QFont("Times", 12))
+ self.cb_autoscale_x.setEnabled(True)
+
+ self.lbl_y_edit = QLabel(f"{parameter} [{unit}] : ")
+ self.lbl_y_edit.setFont(QFont("Times", 12))
+ self.lbl_y_edit.setAlignment(Qt.AlignRight)
+ self.edit_y_min = LineEdit()
+ self.edit_y_min.setFixedWidth(90)
+ self.edit_y_min.setFont(QFont("Times", 14, QFont.Bold))
+ self.edit_y_min.setText(str(self.y_min))
+ self.edit_y_min.setEnabled(False)
+ self.edit_y_max = LineEdit()
+ self.edit_y_max.setFixedWidth(90)
+ self.edit_y_max.setFont(QFont("Times", 14, QFont.Bold))
+ self.edit_y_max.setText(str(self.y_max))
+ self.edit_y_max.setEnabled(False)
+ self.cb_autoscale_y = QCheckBox("Autoscale y")
+ self.cb_autoscale_y.setChecked(True)
+ self.cb_autoscale_y.setFont(QFont("Times", 12))
+ self.cb_autoscale_y.setEnabled(True)
+
+ self.group_box_plot_layout.addWidget(self.lbl_x_edit, 0, 0, 1, 1)
+ self.group_box_plot_layout.setAlignment(self.lbl_x_edit, Qt.AlignBottom)
+ self.group_box_plot_layout.addWidget(self.edit_x_min, 0, 1, 1, 1)
+ self.group_box_plot_layout.setAlignment(self.edit_x_min, Qt.AlignBottom)
+ self.group_box_plot_layout.addWidget(self.edit_x_max, 0, 2, 1, 1)
+ self.group_box_plot_layout.setAlignment(self.edit_x_max, Qt.AlignBottom)
+ self.group_box_plot_layout.addWidget(self.cb_autoscale_x, 0, 3, 1, 3)
+ self.group_box_plot_layout.setAlignment(self.cb_autoscale_x, Qt.AlignBottom)
+ self.group_box_plot_layout.addWidget(self.lbl_y_edit, 1, 0, 1, 1)
+ self.group_box_plot_layout.setAlignment(self.lbl_y_edit, Qt.AlignBottom)
+ self.group_box_plot_layout.addWidget(self.edit_y_min, 1, 1, 1, 1)
+ self.group_box_plot_layout.addWidget(self.edit_y_max, 1, 2, 1, 1)
+ self.group_box_plot_layout.addWidget(self.cb_autoscale_y, 1, 3, 1, 3)
+
+ self.cb_autoscale_x.clicked.connect(self.update_autoscale_x)
+ self.cb_autoscale_y.clicked.connect(self.update_autoscale_y)
+ self.edit_x_min.editingFinished.connect(self.edit_x_min_changed)
+ self.edit_x_max.editingFinished.connect(self.edit_x_max_changed)
+ self.edit_y_min.editingFinished.connect(self.edit_y_min_changed)
+ self.edit_y_max.editingFinished.connect(self.edit_y_max_changed)
+
+ # setup labels for sensors
+ self.group_box_sensors = QGroupBox("Sensors")
+ self.group_box_sensors_layout = QGridLayout()
+ self.group_box_sensors.setLayout(self.group_box_sensors_layout)
+ self.parameter_layout.addWidget(self.group_box_sensors)
+ self.parameter_layout.setAlignment(self.group_box_sensors, Qt.AlignTop)
+
+ self.sensor_name_labels = {}
+ self.sensor_value_labels = {}
+ for i in range(len(sensors)):
+ lbl_name = QLabel()
+ lbl_name.setText(f"{sensors[i]}:")
+ lbl_name.setFont(QFont("Times", 12, QFont.Bold))
+ lbl_name.setStyleSheet(f"color: {COLORS[i]}")
+ self.group_box_sensors_layout.addWidget(lbl_name, i, 0, 1, 1)
+ self.sensor_name_labels.update({sensors[i]: lbl_name})
+ lbl_value = QLabel()
+ if self.unit == "-":
+ lbl_value.setText(f"XXX.XXX")
+ else:
+ lbl_value.setText(f"XXX.XXX {self.unit}")
+ lbl_value.setFont(QFont("Times", 12, QFont.Bold))
+ lbl_value.setStyleSheet(f"color: {COLORS[i]}")
+ self.group_box_sensors_layout.addWidget(lbl_value, i, 1, 1, 1)
+ self.sensor_value_labels.update({sensors[i]: lbl_value})
+
+ def update_autoscale_x(self):
+ if self.cb_autoscale_x.isChecked():
+ self.edit_x_min.setEnabled(False)
+ self.edit_x_max.setEnabled(False)
+ self.plot.enableAutoRange(axis="x")
+ else:
+ self.edit_x_min.setEnabled(True)
+ self.edit_x_max.setEnabled(True)
+ self.plot.disableAutoRange(axis="x")
+
+ def update_autoscale_y(self):
+ if self.cb_autoscale_y.isChecked():
+ self.edit_y_min.setEnabled(False)
+ self.edit_y_max.setEnabled(False)
+ self.plot.enableAutoRange(axis="y")
+ else:
+ self.edit_y_min.setEnabled(True)
+ self.edit_y_max.setEnabled(True)
+ self.plot.disableAutoRange(axis="y")
+
+ def edit_x_min_changed(self):
+ self.x_min = float(self.edit_x_min.text().replace(",", "."))
+ self.edit_x_min.setText(str(self.x_min))
+ self.edit_x_min.setStyleSheet("color: black")
+ self.edit_x_min.clearFocus()
+ self.plot.setXRange(self.x_min, self.x_max, padding=self.padding)
+ self.calc_x2_ticks()
+
+ def edit_x_max_changed(self):
+ self.x_max = float(self.edit_x_max.text().replace(",", "."))
+ self.edit_x_max.setText(str(self.x_max))
+ self.edit_x_max.setStyleSheet("color: black")
+ self.edit_x_max.clearFocus()
+ self.plot.setXRange(self.x_min, self.x_max, padding=self.padding)
+ self.calc_x2_ticks()
+
+ def edit_y_min_changed(self):
+ self.y_min = float(self.edit_y_min.text().replace(",", "."))
+ self.edit_y_min.setText(str(self.y_min))
+ self.edit_y_min.setStyleSheet("color: black")
+ self.edit_y_min.clearFocus()
+ self.plot.setYRange(self.y_min, self.y_max, padding=self.padding)
+
+ def edit_y_max_changed(self):
+ self.y_max = float(self.edit_y_max.text().replace(",", "."))
+ self.edit_y_max.setText(str(self.y_max))
+ self.edit_y_max.setStyleSheet("color: black")
+ self.edit_y_max.clearFocus()
+ self.plot.setYRange(self.y_min, self.y_max, padding=self.padding)
+
+ def calc_x2_ticks(self): # TODO
+ """Not implemented. Intended to be used for a datetime axis."""
+ # # calculate the datetime axis at the top x axis
+ # delta_t = int(self.x_max[u] - self.x_min[u])
+ # x2_min = time_start + datetime.timedelta(seconds=self.x_min[u])
+ # x2_max = (x2_min + datetime.timedelta(seconds=delta_t)).strftime('%H:%M:%S')
+ # x2_list = []
+ # x2_ticks = []
+ # for i in range(x2_Nb_ticks):
+ # x2_list.append((x2_min + datetime.timedelta(seconds=i*delta_t/(x2_Nb_ticks-1))).strftime('%H:%M:%S'))
+ # x2_ticks.append([self.x_min[u]+i*delta_t/(x2_Nb_ticks-1), x2_list[i]])
+ # self.ax_X_2[u].setTicks([x2_ticks,[]])
+ pass
+
+ def set_data(self, sensor, x, y):
+ """Set data for selected sensor in plot.
+
+ Args:
+ sensor (str): name of the sensor
+ x (list): x values
+ y (list): y values
+ """
+ # PyQtGraph workaround for NaN from instrument
+ x = np.array(x)
+ y = np.array(y)
+ con = np.isfinite(y)
+ if len(y) >= 2 and y[-2:-1] != np.nan:
+ y_ok = y[-2:-1]
+ y[~con] = y_ok
+ self.lines[sensor].setData(x, y, connect=np.logical_and(con, np.roll(con, -1)))
+ if self.unit == "-":
+ self.sensor_value_labels[sensor].setText(f"{y[-1]:.3f}")
+ else:
+ self.sensor_value_labels[sensor].setText(f"{y[-1]:.3f} {self.unit}")
+
+ def set_label(self, sensor, val):
+ """Set the label with current measurement value
+
+ Args:
+ sensor (str): name of the sensor
+ val (str/float): measurement value
+ """
+ if self.unit == "-":
+ self.sensor_value_labels[sensor].setText(f"{val:.3f}")
+ else:
+ self.sensor_value_labels[sensor].setText(f"{val:.3f} {self.unit}")
+
+
+class Daq6510Widget(QWidget):
+ def __init__(self, daq: Daq6510, parent=None):
+ """GUI widget of Kethley DAQ6510 multimeter.
+
+ Args:
+ daq (Daq6510): Daq6510 device including configuration
+ information.
+ """
+ logger.info(f"Setting up Daq6510Widget for device {daq.name}")
+ super().__init__(parent)
+ self.layout = QGridLayout()
+ self.setLayout(self.layout)
+ self.tab_widget = QTabWidget()
+ self.layout.addWidget(self.tab_widget)
+ self.tab_widget.setStyleSheet("QTabBar {font-size: 14pt; color: blue;}")
+
+ # create dicts with information required for visualization
+ self.tabs_sensors = {} # tab name : sensor name
+ self.sensors_tabs = {} # sensor name : tab name
+ self.tabs_units = {} # tab name : unit name
+ for channel in daq.config["channels"]:
+ sensor_type = daq.config["channels"][channel]["type"].lower()
+ sensor_name = daq.channel_id_names[channel]
+ if sensor_type == "temperature":
+ unit = "°C"
+ else:
+ if "unit" in daq.config["channels"][channel]:
+ unit = daq.config["channels"][channel]["unit"]
+ else:
+ unit = "V"
+ if "tab-name" in daq.config["channels"][channel]:
+ tab_name = daq.config["channels"][channel]["tab-name"]
+ if not tab_name in self.tabs_sensors:
+ self.tabs_sensors.update({tab_name: [sensor_name]})
+ self.tabs_units.update({tab_name: unit})
+ else:
+ self.tabs_sensors[tab_name].append(sensor_name)
+ if unit != self.tabs_units[tab_name]:
+ raise ValueError(f"Different units given for tab {tab_name}.")
+ else:
+ if sensor_type == "temperature":
+ tab_name = "Temperature"
+ if not tab_name in self.tabs_sensors:
+ self.tabs_sensors.update({tab_name: [sensor_name]})
+ self.tabs_units.update({tab_name: unit})
+ else:
+ self.tabs_sensors[tab_name].append(sensor_name)
+ elif sensor_type == "dcv":
+ tab_name = "DCV"
+ if not tab_name in self.tabs_sensors:
+ self.tabs_sensors.update({tab_name: [sensor_name]})
+ self.tabs_units.update({tab_name: unit})
+ else:
+ self.tabs_sensors[tab_name].append(sensor_name)
+ if unit != self.tabs_units[tab_name]:
+ raise ValueError(
+ f"Different units given for tab {tab_name}."
+ )
+ elif sensor_type == "acv":
+ tab_name = "ACV"
+ if not tab_name in self.tabs_sensors:
+ self.tabs_sensors.update({tab_name: [sensor_name]})
+ self.tabs_units.update({tab_name: unit})
+ else:
+ self.tabs_sensors[tab_name].append(sensor_name)
+ if unit != self.tabs_units[tab_name]:
+ raise ValueError(
+ f"Different units given for tab {tab_name}."
+ )
+ self.sensors_tabs.update({sensor_name: tab_name})
+
+ # create widgets for each tab
+ self.plot_widgets = {}
+ for tab_name in self.tabs_sensors:
+ plot_widget = PlotWidget(
+ self.tabs_sensors[tab_name], tab_name, self.tabs_units[tab_name]
+ )
+ self.tab_widget.addTab(plot_widget, tab_name)
+ self.plot_widgets.update({tab_name: plot_widget})
+
+ def set_measurement_data(self, rel_time, meas_data):
+ """Update plot and labels with measurement data (used after
+ recording was started).
+
+ Args:
+ rel_time (list): relative time of measurement data.
+ meas_data (dict): {sensor name: measurement time series}
+ """
+ for sensor in meas_data:
+ self.plot_widgets[self.sensors_tabs[sensor]].set_data(
+ sensor, rel_time, meas_data[sensor]
+ )
+
+ def set_initialization_data(self, sampling):
+ """Update labels with sampling data (used before recording is
+ started).
+
+ Args:
+ sampling (dict): {sensor name: value}
+ """
+ for sensor in sampling:
+ self.plot_widgets[self.sensors_tabs[sensor]].set_label(
+ sensor, sampling[sensor]
+ )
+
+
+class IfmFlowmeterWidget(QWidget):
+ def __init__(self, flowmeter: IfmFlowmeter, parent=None):
+ """GUI widget of IFM flowmeter.
+
+ Args:
+ flowmeter (IfmFlowmeter): IfmFlowmeter device including
+ configuration information.
+ """
+ logger.info(f"Setting up IfmFlowmeterWidget for device {flowmeter.name}")
+ super().__init__(parent)
+ self.layout = QGridLayout()
+ self.setLayout(self.layout)
+ self.tab_widget = QTabWidget()
+ self.layout.addWidget(self.tab_widget)
+ self.tab_widget.setStyleSheet("QTabBar {font-size: 14pt; color: blue;}")
+
+ # create dicts with information required for visualization
+ self.sensors = []
+ for port in flowmeter.ports:
+ self.sensors.append(flowmeter.ports[port]["name"])
+
+ self.flow_widget = PlotWidget(self.sensors, "Flow", "l/min")
+ self.tab_widget.addTab(self.flow_widget, "Flow")
+
+ self.temperature_widget = PlotWidget(self.sensors, "Temperature", "°C")
+ self.tab_widget.addTab(self.temperature_widget, "Temperature")
+
+ def set_initialization_data(self, sampling):
+ """Update labels with sampling data (used before recording is
+ started).
+
+ Args:
+ sampling (dict): {
+ "Flow": {sensor name: value},
+ "Temperature": {sensor name: value},
+ }
+ """
+ for sensor in self.sensors:
+ self.temperature_widget.set_label(sensor, sampling["Temperature"][sensor])
+ self.flow_widget.set_label(sensor, sampling["Flow"][sensor])
+
+ def set_measurement_data(self, rel_time, meas_data):
+ """Update plot and labels with measurement data (used after
+ recording was started).
+
+ Args:
+ rel_time (list): relative time of measurement data.
+ meas_data (dict): {
+ "Flow": {sensor name: measurement time series},
+ "Temperature": {sensor name: measurement time series},
+ }
+ """
+ for sensor in self.sensors:
+ self.temperature_widget.set_data(
+ sensor, rel_time, meas_data["Temperature"][sensor]
+ )
+ self.flow_widget.set_data(sensor, rel_time, meas_data["Flow"][sensor])
+
+
+class EurothermWidget(QWidget):
+ def __init__(self, eurotherm: Eurotherm, parent=None):
+ """GUI widget of Eurotherm controller.
+
+ Args:
+ flowmeter (Eurotherm): Eurotherm device including
+ configuration information.
+ """
+ logger.info(f"Setting up EurothermWidget for device {eurotherm.name}")
+ super().__init__(parent)
+ self.layout = QGridLayout()
+ self.setLayout(self.layout)
+ self.tab_widget = QTabWidget()
+ self.layout.addWidget(self.tab_widget)
+ self.tab_widget.setStyleSheet("QTabBar {font-size: 14pt; color: blue;}")
+
+ self.temperature_widget = PlotWidget(["Temperature"], "Temperature", "°C")
+ self.tab_widget.addTab(self.temperature_widget, "Temperature")
+
+ self.op_widget = PlotWidget(["Operating point"], "Operating point", "-")
+ self.tab_widget.addTab(self.op_widget, "Operating point")
+
+ def set_initialization_data(self, sampling):
+ """Update labels with sampling data (used before recording is
+ started).
+
+ Args:
+ sampling (dict): {sampling name: value}
+ """
+ self.temperature_widget.set_label("Temperature", sampling["Temperature"])
+ self.op_widget.set_label("Operating point", sampling["Operating point"])
+
+ def set_measurement_data(self, rel_time, meas_data):
+ """Update plot and labels with measurement data (used after
+ recording was started).
+
+ Args:
+ rel_time (list): relative time of measurement data.
+ meas_data (dict): {sampling name: measurement time series}
+ """
+ self.temperature_widget.set_data(
+ "Temperature", rel_time, meas_data["Temperature"]
+ )
+ self.op_widget.set_data(
+ "Operating point", rel_time, meas_data["Operating point"]
+ )
+
+
+class OptrisIP640Widget(ImageWidget):
+ def __init__(self, optris_ip_640: OptrisIP640, parent=None):
+ """GUI widget of Optirs Ip640 IR camera.
+
+ Args:
+ optris_ip_640 (OptrisIP640): OptrisIP640 device including
+ configuration information.
+ """
+ logger.info(f"Setting up OptrisIP640Widget for device {optris_ip_640.name}")
+ super().__init__(parent)
+
+ def set_initialization_data(self, sampling):
+ """Update image with sampling data (used before recording is
+ started) using a grayscale colormap.
+
+ Args:
+ sampling (np.array): IR image.
+ """
+ self.set_image(sampling.T)
+
+ def set_measurement_data(self, rel_time, meas_data):
+ """Update plot and labels with measurement data (used after
+ recording was started) using turbo colormap.
+
+ Args:
+ rel_time (list): relative time of measurement data. Unused.
+ meas_data (np.array): IR image.
+ """
+ self.set_cmap("turbo") # TODO only do that once
+ self.set_image(meas_data.T)
+
+
+class BaslerCameraWidget(ImageWidget):
+ def __init__(self, basler_camera: BaslerCamera, parent=None):
+ """GUI widget of Basler optical camera.
+
+ Args:
+ basler_camera (BaslerCamera): BaslerCamera device including
+ configuration information.
+ """
+ logger.info(f"Setting up BaslerCameraWidget for device {basler_camera.name}")
+ super().__init__(parent)
+
+ def set_initialization_data(self, sampling):
+ """Update image with sampling data (used before recording is
+ started).
+
+ Args:
+ sampling (np.array): image.
+ """
+ self.set_image(np.swapaxes(sampling, 0, 1))
+
+ def set_measurement_data(self, rel_time, meas_data):
+ """Update plot and labels with measurement data (used after
+ recording was started).
+
+ Args:
+ rel_time (list): relative time of measurement data. Unused.
+ meas_data (np.array): image.
+ """
+ self.set_image(np.swapaxes(meas_data, 0, 1))
+
+
+class PyrometerLumasenseWidget(PlotWidget):
+ def __init__(self, pyrometer: PyrometerLumasense, parent=None):
+ """GUI widget of Lumasense pyrometer.
+
+ Args:
+ pyrometer (PyrometerLumasense): PyrometerLumasense device
+ including configuration information.
+ """
+ logger.info(f"Setting up PyrometerLumasense widget for device {pyrometer.name}")
+ self.sensor_name = pyrometer.name
+ super().__init__(
+ [self.sensor_name], parameter="Temperature", unit="°C", parent=parent
+ )
+
+ # Group box with emissivity, transmissivity, ...
+ self.group_box_parameter = QGroupBox("Pyrometer configuration")
+ self.group_box_parameter_layout = QVBoxLayout()
+ self.group_box_parameter.setLayout(self.group_box_parameter_layout)
+ self.parameter_layout.addWidget(self.group_box_parameter)
+ self.parameter_layout.setAlignment(self.group_box_parameter, Qt.AlignTop)
+
+ self.lbl_emissivity = QLabel(f"Emissivity:\t{pyrometer.emissivity}%")
+ self.lbl_emissivity.setFont(QFont("Times", 12))
+ self.group_box_parameter_layout.addWidget(self.lbl_emissivity)
+ self.lbl_transmissivity = QLabel(
+ f"Transmissivity:\t{pyrometer.transmissivity}%"
+ )
+ self.lbl_transmissivity.setFont(QFont("Times", 12))
+ self.group_box_parameter_layout.addWidget(self.lbl_transmissivity)
+ self.lbl_t90 = QLabel(f"t90:\t\t{pyrometer.t90} s")
+ self.lbl_t90.setFont(QFont("Times", 12))
+ self.group_box_parameter_layout.addWidget(self.lbl_t90)
+
+ def set_initialization_data(self, sampling):
+ """Update label with sampling data (used before recording is
+ started).
+
+ Args:
+ sampling (float): temperature value
+ """
+ self.set_label(self.sensor_name, sampling)
+
+ def set_measurement_data(self, rel_time, meas_data):
+ """Update plot and labels with measurement data (used after
+ recording was started).
+
+ Args:
+ rel_time (list): relative time of measurement data.
+ meas_data (list): measurement time series
+ """
+ self.set_data(self.sensor_name, rel_time, meas_data)
+
+
+class PyrometerArrayLumasenseWidget(PlotWidget):
+ def __init__(self, pyrometer_array: PyrometerArrayLumasense, parent=None):
+ """GUI widget of Lumasense pyrometer array.
+
+ Args:
+ pyrometer_array (PyrometerArrayLumasense):
+ PyrometerArrayLumasense device including configuration
+ information.
+ """
+ logger.info(
+ f"Setting up PyrometerArrayLumasense widget for device {pyrometer_array.name}"
+ )
+ super().__init__(
+ pyrometer_array.sensors, parameter="Temperature", unit="°C", parent=parent
+ )
+
+ # Group box with emissivity, transmissivity, ...
+ self.group_box_parameter = QGroupBox("Pyrometer configuration")
+ self.group_box_parameter_layout = QVBoxLayout()
+ self.group_box_parameter.setLayout(self.group_box_parameter_layout)
+ self.parameter_layout.addWidget(self.group_box_parameter)
+ self.parameter_layout.setAlignment(self.group_box_parameter, Qt.AlignTop)
+
+ for sensor in pyrometer_array.sensors:
+ lbl_emissivity = QLabel(
+ f"{sensor} emissivity:\t{pyrometer_array.emissivities[sensor]*100}%"
+ )
+ lbl_emissivity.setFont(QFont("Times", 12))
+ self.group_box_parameter_layout.addWidget(lbl_emissivity)
+ lbl_t90 = QLabel(f"{sensor} t90:\t\t{pyrometer_array.t90s[sensor]} s")
+ lbl_t90.setFont(QFont("Times", 12))
+ self.group_box_parameter_layout.addWidget(lbl_t90)
+
+ def set_initialization_data(self, sampling):
+ """Update labels with sampling data (used before recording is
+ started).
+
+ Args:
+ sampling (dict): {head name: temperature}
+ """
+ for sensor in sampling:
+ self.set_label(sensor, sampling[sensor])
+
+ def set_measurement_data(self, rel_time, meas_data):
+ """Update plot and labels with measurement data (used after
+ recording was started).
+
+ Args:
+ rel_time (list): relative time of measurement data.
+ meas_data (dict): {heat name: measurement time series}
+ """
+ for sensor in meas_data:
+ self.set_data(sensor, rel_time, meas_data)
+
+
+class ProcessConditionLoggerWidget(QWidget):
+ def __init__(self, process_logger: ProcessConditionLogger, parent=None):
+ """GUI widget for logging of process conditions.
+
+ Args:
+ process_logger (ProcessConditionLogger):
+ ProcessConditionLogger device including configuration
+ information.
+ """
+ logger.info(
+ f"Setting up ProcessConditionLoggerWidget for device {process_logger.name}"
+ )
+ super().__init__(parent)
+ self.process_logger = process_logger
+ self.layout = QGridLayout()
+ self.setLayout(self.layout)
+
+ # create labels and input fields according to device configuration (read from config.yml)
+ self.input_boxes = {}
+ row = 0
+ for condition in process_logger.config:
+ if "label" in process_logger.config[condition]:
+ label_name = QLabel(f"{process_logger.config[condition]['label']}:")
+ else:
+ label_name = QLabel(f"{condition}:")
+ label_name.setFont(QFont("Times", 12))
+ self.layout.addWidget(label_name, row, 0)
+
+ default_value = ""
+ if "default" in process_logger.config[condition]:
+ default_value = str(
+ process_logger.config[condition]["default"]
+ ).replace(",", ".")
+ if "values" in process_logger.config[condition]:
+ input_box = QComboBox()
+ input_box.addItems(process_logger.config[condition]["values"])
+ input_box.setCurrentText(default_value)
+ input_box.currentIndexChanged.connect(
+ partial(self.update_combo_condition, condition)
+ )
+ else:
+ input_box = LineEdit()
+ input_box.setText(default_value)
+ input_box.returnPressed.connect(
+ partial(self.update_text_condition, condition)
+ )
+ input_box.setFixedWidth(320)
+ input_box.setFont(QFont("Times", 12))
+ self.layout.addWidget(input_box, row, 1)
+ self.input_boxes.update({condition: input_box})
+
+ label_unit = QLabel(process_logger.condition_units[condition])
+ label_unit.setFont(QFont("Times", 12))
+ self.layout.addWidget(label_unit, row, 2)
+
+ row += 1
+
+ def update_text_condition(self, condition_name):
+ """This is called if the text of an input filed was changed by
+ the user. The data in the ProcessConditionLogger is updated.
+
+ Args:
+ condition_name (str): name of the changed process
+ """
+ box = self.input_boxes[condition_name]
+ box.setStyleSheet("color: black")
+ box.clearFocus()
+ text = box.text().replace(",", ".")
+ box.setText(text)
+ self.process_logger.meas_data.update({condition_name: text})
+
+ def update_combo_condition(self, condition_name):
+ """This is called if the index of an input box was changed by
+ the user. The data in the ProcessConditionLogger is updated.
+
+ Args:
+ condition_name (str): name of the changed process
+ """
+ box = self.input_boxes[condition_name]
+ box.clearFocus()
+ self.process_logger.meas_data.update(
+ {condition_name: box.itemText(box.currentIndex())}
+ )
+
+ def set_initialization_data(self, sampling):
+ """This function exists to conform with the main sampling loop.
+ It's empty because no data needs to be visualized.
+ """
+ pass
+
+ def set_measurement_data(self, rel_time, meas_data):
+ """This function exists to conform with the main sampling loop.
+ It's empty because no data needs to be visualized.
+ """
+ pass
diff --git a/postprocessing/evaluate-daq6510.ipynb b/postprocessing/evaluate-daq6510.ipynb
new file mode 100644
index 0000000..d7250bc
--- /dev/null
+++ b/postprocessing/evaluate-daq6510.ipynb
@@ -0,0 +1,225 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "# Evaluation of multilog measurement\n",
+ "\n",
+ "Growth experiment: Sn in aluminum crucible, September 8th, 2022\n",
+ "\n",
+ "## Process information\n",
+ "\n",
+ "- 11:29:11 - heating up\n",
+ "- 13:01:45 - seeding\n",
+ "- 13:08:15 - growing\n",
+ "- 21:01:43 - cool down\n",
+ "- 21:18:15 - end"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Read data and generate overview plots"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import pandas as pd\n",
+ "import matplotlib.pyplot as plt\n",
+ "\n",
+ "df = pd.read_csv(\"./DAQ-6510.csv\", comment=\"#\")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ ""
+ ]
+ },
+ "execution_count": 3,
+ "metadata": {},
+ "output_type": "execute_result"
+ },
+ {
+ "data": {
+ "image/png": "iVBORw0KGgoAAAANSUhEUgAAAYUAAAEGCAYAAACKB4k+AAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjMuNCwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy8QVMy6AAAACXBIWXMAAAsTAAALEwEAmpwYAAAu6ElEQVR4nO3dd5xU1f3/8ddnC733LohYQBR0QQF7wxbRRBNiYokt+YYkmmJEExNLVEzxa75G448kRkxCsJeosaHYUHFBpAlSpUrvZWF3P78/7t1htg+wM3dm5/18PPYxM2fuvfM5s7v3c+85555r7o6IiAhATtQBiIhI+lBSEBGRGCUFERGJUVIQEZEYJQUREYnJizqAA9GuXTvv2bNn1GGIiGSUqVOnrnP39lW9l9FJoWfPnhQWFkYdhohIRjGzL6p7T81HIiISo6QgIiIxSgoiIhKjpCAiIjFKCiIiEqOkICIiMUoKIiISo6QgIvXGa7O/ZMuuPbHXb81bw4pNOyOMKPMoKYhIvfDF+u1c94+p/OTx6bGy7/z9Y4b/7zvRBZWBlBREpF7YuacEgGUbyp8ZbCsqjiKcjKWkICIZ7dK/fEjP0S9FHUa9oaQgIhlt8sL1UYdQryQ1KZjZEjObaWbTzawwLGtjZq+b2fzwsXXc8jeb2QIzm2dmw5MZm4jUT47j7ny5eVfUoWSkVJwpnOruA9y9IHw9Gpjo7n2AieFrzKwvMBLoB5wNPGRmuSmIT0TqAcNiz8e+s4jj75kYYTSZK4rmoxHAuPD5OODCuPIJ7l7k7ouBBcDg1IcnIpns89XbuOe/c6MOI2MlOyk48JqZTTWz68Kyju6+CiB87BCWdwWWxa27PCwrx8yuM7NCMytcu3ZtEkMXEck+yb7JzjB3X2lmHYDXzaym9G1VlHmlAvexwFiAgoKCSu+LiMj+S+qZgruvDB/XAM8SNAetNrPOAOHjmnDx5UD3uNW7ASuTGZ+I1B9W1WGl7LOkJQUza2pmzcueA2cBs4AXgCvCxa4Ang+fvwCMNLOGZtYL6ANMSVZ8IiJSWTKbjzoCz1qQvvOA8e7+ipl9DDxhZlcDS4FLANx9tpk9AcwBioFR7l6SxPhERKSCpCUFd18EHF1F+Xrg9GrWuQu4K1kxiYhIzXRFs4jUC+pSqBtKCiIiEqOkICIiMUoKIiISo6QgIvWCrlOoG0oKIlIvuOY3qBNKCiIiEqOkICIiMUoKIlIvqE+hbigpiIhIjJKCiIjEKCmIiEiMkoKI1BPqVKgLSgoiIhKjpCAiIjFKCiIiEqOkICL1gq5TqBtKCiIiEqOkICIiMUoKIiISo6QgIvWCuhTqhpKCiIjEKCmIiEiMkoKIiMQoKYhI2istdXbtKYk6jKyQF3UA6cDdKfxiIwe1aULThnlcPe5jTju8A/27tmLH7mIG9WpDi0b5seV37SlhyfrtNG+Uz+K122ndNJ9WTRqwbVcxjtO7fTNyzNi6aw8tGuVjBqUOuTlW7jNfnb2awb3a0KZpA7YVFfPJ0o18uXkXFw3sSl5u9fl6d3EpuTlWbnsi9dGrs7/ku/+YyqXH9WD8R0u5+6L+NG2Yy9De7fj7+4tp07RB1CHWO0oKwEszV/GD8Z+UK/tw0YaUfPbR3Vvx/KhhHHXbq5SGNx5ftmEHPznrsCqX37B9N8fc+ToAS8acl5IYJXm+3LyLUne6tGocdShp6cG3FgAw/qOlANzy7EwADm7XlEXrtkcWV32mpAA0bRjd19CrbROAWEIAWFjDH/vKTTuTHZKkwKYdu7l+wnTe/nwtoAS/r6pKCKZ5LuqEkgKQFzbDjL/mOA7r1Jxjf/MGAC/8YBj9u7Zk554SGufnUhQ22+Tn5vC7V+fy4FsLAZh265m1nsa6e+yP1t0pLnX6/OK/9G7fDID8XGNPide0iSDW3GAbajrKbD97ckYsIYikk6R3NJtZrpl9YmYvhq/bmNnrZjY/fGwdt+zNZrbAzOaZ2fBkx1ZRg7ycSkcbZkaTBnmYGY3yc8mv0NZ/6XE9EmrXjN+umZEbvi7xIBHEnylQe27APYGFJG1t3LE76hBEqpSK0UfXA5/FvR4NTHT3PsDE8DVm1hcYCfQDzgYeMrPcFMQXiZzwSH/Tjj1AhZ18AicBSgmZ6ZOlG1m4dlvUYWQMHfukXlKTgpl1A84D/hpXPAIYFz4fB1wYVz7B3YvcfTGwABiczPjSwaOTlwD7vpPXP0tmuuihyZz+h7c1JUMS6DutG8k+U7gf+DlQGlfW0d1XAYSPHcLyrsCyuOWWh2XlmNl1ZlZoZoVr19afNtl9PFGQDFf4xcaoQ8gI6jtOvaQlBTM7H1jj7lMTXaWKskrHw+4+1t0L3L2gffv2BxRjutJJgEhAZ8Spl8zRR8OAC8zsXKAR0MLM/gmsNrPO7r7KzDoDa8LllwPd49bvBqxMYnwiaSN+dJpIlJJ2puDuN7t7N3fvSdCB/Ka7fxt4AbgiXOwK4Pnw+QvASDNraGa9gD7AlGTFVz7WVHxKZcf0aAUEl/DH064h+zxZuDzqEDKecmrdiOI6hTHAE2Z2NbAUuATA3Web2RPAHKAYGOXuKZ3sJNV/VNOWbgLgPzN0QpTt5q3eGnUIIkCKkoK7TwImhc/XA6dXs9xdwF2piCmdzFqxOeFl1cYqIsmkWVLTwBfrd0QdgkRMyV7ShZJCGnhtzupyr3fsLmHNll0RRSNRcI05O2Cm3rg6sV9JwcwG1XUgstebc9cw+O6JtS63dP0O+v3qFRZrtkgRqSMJJwUz62tmd5jZfODPSYwpa9zz1f4HtP5z01ewfXcJT0/VyJVMp+ajA6ezrbpRY1Iws4PMbLSZfQr8A/g+cKa7F6Qkunpu5KDutS8kIpJC1SYFM5sMvAzkAxe7+7HAVndfkqLY6r26ulhJ47NF1KdQV2o6U1gLNAc6AmXzSdTL87P4SunPSkSyWbVJwd1HAP2BacDtZrYYaG1m9Xjm0sxMCWqPzny6P4akixovXnP3zcAjwCNm1gH4BnC/mXV3dzWIRywzU5hURSlB0kXCo4/cfY27P+DuQ4ETkhhTVunWWjdsF6kL6lurG/t1nYK7f1HXgWSrob3bRh2CpAG1Hkm60BXNEbvs+J77tLx2HvWTxthLulBSiNhhnZpHHYJI2lKyTL1aZ0k1s/bAtUDP+OXd/arkhZU9GuQpL4vOAOvCll17og6hXkhk6uzngXeBN4CU3t9AEqOjqcyn32DVqrsgzaxyIlVirRuJJIUm7n5T0iOJUKaOEddoC6nv9uVvPEP/jdNOIm0XL4b3Wa73Kv4Bpuqy+fHXHJeSzxERqU0iSeF6gsSw08y2mNlWM9uS7MCyydBD2h3Q+jpCynz6He4bnSQnT61Jwd2bu3uOuzd29xbh6xapCE4So2ak+kBZ4UDta9/a/NVbmbNy/49vl2/cwfai4tjr3cWlfLJ0435vL13UNEvq4eHjMVX9pC7E7HDCAZ4tiNRHdX288/z0FYz/aCm79pRw5v++w7n/9y4Q9Cs+/nFQXpG78+BbC2J3Q5y1YjNTv9jACfe+Rb9fv8rz01cA8JuX5nDRQ5NZuHZbjTG4e7l+THdnwZqa10mlmjqafwJcB/yhivccOC0pEWWpQzo0470F6/ZpHTU51B/6XVZt/fbdVZaXVvF9JfIdXj9hOgAzV2zau61S5825a7jp6Zk89sEXjBzcg8uOP4jdxaXc/fJnvD5nNSs27eSdz9fy+HeHcP4D71Xa5hlHdGTmis0AbNoRxLxq807aN2tIXm75Y+9R46fx8swvWTLmPFZs2snkBeu48akZnHBIO/6ZBv2L1SYFd78ufDw1deFkjrruhL7l3CN4dPKStIhFUk9JoWrLN+5MeNmlG3ZU+96dL87hZ2cdFnsd32x08cOT6da6CQCzV27h1udm8dwnK5j6RfmmoI8Wb2BWuOOvqN+vX2Vgj1YAfO3PH3BYx+bMW72Vq0/oRc+2TbjomG7k5xqT5q3l5ZlfAvDQpAX89pV5dG0VzH/23oJ1PPHxMjq0aMgph3VIuN51LZEhqZICiV7EVlW7qa5TkDJTFm/gsI7NadkkHwiOWk+89y0evWoQxx7UpsZ13Z3b/zOHbwzqzhGd93Ybzli+iS/W7+ArR3dJWtxvzl3N4Z1a0KXV/k8Q+cN/f1Lte397bzF/e29x7PWny/fu3Kct3cS0pZvKLV8xIZSpeJYQb/POvRfPzVu9Nfa5ALc+P7vS8r99ZR4AKzbtTXw/f3oGAEvGnFft5ySbLqcVSQMfLFpf6zKlpc6789fi7vzhtXnc+eIciopLKAnbUoqKS/j6//uA7zw6JbZO4ZKNbC0q5qG3FrJgzTZ27C5mzsot/OWdRZW2v3ZbEY9OXsJlf/uoXPkFf3q/0g532YYdsWaSeIVLNlBUXMLmnXtYGe7sthUVc9b/vl3uKHvClKWM/2gpELTzX/VoIRf86T1mLN8Ua28vqaqNKI0tWru9zrb17vy1dbatfaUzBSrceS0DW2LUfJRe3J3iUic/N/FjrqUbdvDAxPms21bE7SOOrLS9ax8rpNThzblr+N3FR/HAmwuA4Ej05EPbM+6qwbGd6LSlm3h++gpGDOga28aeUueM+97mxD7teHd+0Hd118uf8edvHcOQ3m3ZvruEwiUbAFi3bTeD7nqDcd8ZTN8ue88Ynp++gvOP6sIvn5vFv6cspWFeDu+PPo3xHy1l2CHt2F1cyjf/8iF9O7dgzqqgeWbJmPN44M35fL56G+c/8B6PXTWYE/u0Y/QzM4Hg6PreV+bGPveCP73Pt4/vQbfWTRjz37n79L3XJ5f9bQrv/vxUurdpkvLPTmTuo4nufnptZfVBNu5ad+wupu+vXuWPIweU24lEbcP23TTIy6FZw8p/olt37aFJgzxyc5L3G1u8bjvbi4o5smvLWNm2omI+WbqRE/u0r2FN+OVzs/jXR0srNQEsXV99mzfAH17/HCCWFJau30Hrpvms3LSLNz5bE1vuxqdmlFvv7c/X0vdXr7Bj996RM9dPmB7rVAV45/PgyLMsIZT5n39NqzKWtVuLOPf/3i13kHT9hOms3rKLf08JjvCLiksp+M0bANwXxg7EEgLAL56dyb/CMwKAyx+ZwtHd9n6nZQkh3j8/XFqpLBtt2L47vZKCmTUCmgDtzKw1e/eZLYDkNS5Kwuri5PrLzcEwu/te/zyypODuPPL+Ei4+thstGwdt4cfc+TpQvm118849PPz2Qv48aSHd2zTmL5cX0LRBXqV/nPfmr6NBXg6De+1tQ39/wTpaNs7nx49P5/6RA+jXpSXVufaxQl6fsxqAj245nSYNcmneKJ+bnp7BSzNWcd5Rnfn9xUdz4m/f5Mbhh/GNQT2A4J943OQlsZ2gu2NmsQRTU3t0vJ6jX0pouXjxCaEuVewAv/vlfTt6j08IZeLb86V6r8z+kqO7t0r559Z0pvBd4AaCBDCVvUlhC/BgcsPKTpN+dgqn/H5SwstvCIfr7S4prfTetqJimjXMY+byzRSXljKwR+sqt2HVtJf99IlPmbxwHZNHn0ZxqVNS6jTKz601pn9PWcrph3dg8N0TufS4Htx9UX/ufHEOe0pKuaNCs0hRcQk5Zrw2ezV3vjiH2Ss2c983BvBx2IxRZtHabUxfton3F6zn6WnLAVi2YSdn3x+MMX/rZ6ewp6SUnm2bcutzs3i8cNnede8+lxufmhFbD+DGJ2fw8vUnsmbLLn736jxuOPNQfvfKXJ6bvpJnvz80lhAAjrt7YqU6vjRjFS/NWAXATU/P5KRD29O5ZeNYIivz48en89z0lbV+ZyJVGTd5CTedfXjKP7emIal/BP5oZj909wdSGFPW6tmu6T4tXzaEdew7i+jeujGXDenJab+fRIvG+UxftqncskvGnMeThcvYXlTMyME9aJiXw9n3vxu70MZ97xHq7NuHx3aivW5+ObaNQT1b8+h3BnPvK3MZ2KMVJ/VpT9tmDSktdbYWFVNa6tz8zEx6tg2O3Md/tJT/Obl3bATGSX3aM2XJBsZW0ckJ8MwnK9i+u5hXZ+/dKSdy1HxqmEi7tGzEyvDMp8zBt7xcafk5q7aU2+6TU/cmjIsemlzr51U05J43qyxXQpADcVS36s9mk6nWPgV3f8DMhlL5fgqP1bRe2Pz0DtAwXO8pd/+1mbUBHg+3twT4urtvDNe5GbiaYIruH7n7q/tepdRKdj/E+m1FfLxkI1t27aFFo3xaNKr6V3br87OrHPZWZtiYN2ND3277z5xK78eP8e7366q/9o+XbIy999gH1d+RdUlc2/mJv30r9vyaxwqrXadMfELYVxUTgkgm6xFBfwIk1tH8D6A3MJ2991NwoMakABQBp7n7NjPLB94zs/8CXwUmuvsYMxsNjAZuMrO+wEigH0GT1Rtmdqi7Z9U9HK4/vQ9/nDgfCNqkjw078g5U/FhokWwzuFcbtu0qLtcJXlHHFg1ZvaWo0vNUys819pQEHTkn1DKgIVkSGTNXAAxz9++7+w/Dnx/VtpIHyib0yA9/HBgBjAvLxwEXhs9HABPcvcjdFwMLgMGJV6V+iB9tc8Z9b0cYiUj9MPfOs/n3tcfznWE9q3x/xm1nsWTMeXx0yxmxsrLnLRrlceGAYFzNef07M7xfR+7/xoBy63dq0Yg5dwznxuGHUZXbvtIXgHOO7MRvLizfr1ZxnW6tmzD2smMByI1ofHwi1ynMAjoBq/Z142aWS9BJfQjwoLt/ZGYd3X0VgLuvMrOy67m7Ah/Grb48LKu4zesI5mSiR48e+xpS1dLoGpkWjff+ShbW4cUwIvXNorvP5R8ffsGvX5jNHSP68a8Pl8auJC5zxhEdYgMkjuvVFoA7R/TjsiE9Y/1KLRrlx5afduuZ7AkHbnz+m3PIMdiwYzeFX2zkp2cdysHtmwFww+PTAZj405PpHZaNOvUQRp16SLn+qme/P5SBPVpz5bBesbKx7yxi6YYdvPWzU+jVrimPvLc4NsfT90/pHetbjGqmgkTOFNoBc8zsVTN7oewnkY27e4m7DwC6AYPN7MgaFq8qLVb6Vtx9rLsXuHtB+/Z1e3pV3UicVGrcQNcTZquy4bgVVXWtRl069qDWPPzt1Ex83LVV41qncOjSslHs+dw7z6Zt0wZcPuSgcsu8+MMTyMkxrhjakyVjzuPyIT15btSwStuKH5bco20Tlow5j8uG9ATg/112LP+8uvwEdG2aNqBji+DzG+TlkJebQ4fmjXjvptNiCQH2/k56x5WVmTz6NM7r35n/Xn9ilaP+vn9KbyBoogL448iBAIy7ajCXFHSP/HqpRP7abjvQD3H3TWY2CTgbWG1mncOzhM5A2VU5y4Hucat1A7Ju+Eb8cEipnwb1bM3HS/bOrfPIlQWc1Kc9ebk5vDFnNYd1as5b89bwq3DgwH+vP5HxU5YybvKSGq9HuOz4gzju4Db88Y35zI+binn0OYdTcFBrLn74Aw7v1JwLB3aNXS3cq11TnvreEMyMN35yMmfc9zb/c0pv/jxpIQCHd2rO3C/LH30/cmUBVz1ayCmHtWfsZQW8O38tV48LBhHcen5f7nyx8kCG7550MIN6tqFf1/K3Ypl9+3AWr9vO+Q+8x+mHdyA/N4f/++ZADv3lfwFolJ/L1FvPDL6HWV9y0cCu3HLuEVXWv1F+5WPcmq72H96vU7Xv1ebtG09hU9xcR/G6tGrMg9+qPsmOHNyDkYP3tnKc0KddpHMdVZTI6KO3zewgoI+7v2FmTYBaB6ybWXtgT5gQGgNnAPcCLwBXAGPCx+fDVV4AxpvZfQQdzX2AKZU2XM8dyFHCzecczj3VTA3QsnF+uQm7qnLv1/pTUgoHtW3Ct/4azH/TvU1jlm3Y20n91PeG0LFFI1o0zufNuau59bnZbCsqZsotpzM4HNP/7PeHsr2ohG9XmEMnmcZfcxyX/rXy50247nhGjv2wijWqdlDbJizfuJNHrhzEFY8k/ufXo02TSrN0Du3dlvlrtnFE5xaxK4qXjDkPd+d3r86jcMlG/nZlAc3jmi/O6NsRgMuH9OTy8IgW4KazD2fWis28O38d464aTLfWjZn2xUa27irmjhfn0CAvhzvD9urh/TpRUuqs3VpEcanTq11Tlm8MYuvTsTnfO7k33zu5d6U6HNKhGR/efDodmjfkooFd6dGmCY3yc2PNIe/ceCpNGuZSGl7RdtnxB9EgL4fTj+jIvV/rT6eWjTn50Pb07dyCFo3zOKhtUz5YuJ5rHyvk8M7NY3WL17RhHkd2bVlpp3j5kIMqjXD7+BdnkKixlx3LjU/N4NyjOie8zr5o26whbZs1TMq2y0Q1c24io4+uJWjDb0MwCqkr8DBQ2zQXnYFxYb9CDvCEu79oZh8AT5jZ1cBS4BIAd59tZk8Ac4BiYFS2jTwC6NelBS98WvsJ0vhrjqNhfi5f+/PecfXXnXRwuaRwVt+OvDZnNWf27chfLi/g7+8v5vb/zOH8ozrz4ozKXURlV+ZC0Lb64aL1nNu/M2ff/w5zv9zKwrvPLTe1xEUDu3HRwG6x1++PPo3XZn9Z7YVyiTiqW0tmVHPF68XHduOp8JqCvp1bsHNPCQ3zcrjroiM59qA2LLr7XG59fhY7dpfw7CfBjU+OP7gtT35vCJc8/AEA3xzcg3u+2r9cu2+Xlo3Co/O1XHB0F356VuUOwz+OHMAnSzfFrg0Zf81xDD2kHW/MWU1+Xg4nH9qenzwxnWemrYitM/7a42PP73ppDuu2Be3GZsbP9+OipBZh81J+rtG7fbNY08XFBd3IiWv6zM/NIT+Xcld6d2vdhAnXHU//rjWPfe8UNt0c2rF5pfd6tN27vYo78fi/nSG928aen9m3I6/ecBKHdqzczFKTO0YcWelix0QN6tmas/p14qwDOBOIUtSt2Ik0H40iGAX0EYC7z4/rHK6Wu88ABlZRvp5qEoq73wXclUBM9dYVQ3uW27HHT2AGcHa/ThT0bM3QQ9qVm9530d3nxvpEOjRvyCNXDio3bw8Qm6Cta6vGnN2vE62b5nPPV4/itN9PYtG68p3abZo24Nz+wVHW498dwuotu2qda6hrq8Z8J65DrXF+Lt3bNObz1UFTxnOjhnHZ3z5i667icuv96dKBnHNkZybNW8Nph3eIXTBXcFBrNu/cE2sKGfPV/gw7pC0/fvxT+nZpwe8vObrcdnJyjLsu6s/qLbt49pMV3DGiHwCDeraJnSk1rDBF+eJ7gu9t/uqtvDVvbazO8cp2gCMGdOWX5x3Byk27YjvI+KPfrx3TrVxSiPeL8/rW+N0l4u4L+3NU15YMObhtufL4jtKaHF9hvUTNun34AZ3BHtapcoJ562en0KRB7VfI7wsz4+UfnUj3Nvs//XY6iWr8SyJJocjdd5ftcMwsj7Qar1O/VJxK4i+XF9QwvUTwa+jWujE54Q57/LXH0bt9s1hnWbyvF3Rn5aadjDr1EJrGdV4+94NhbN5RfdNSy8b51XaC1mTOHcOBYKqINVuLGNC9FYW/PIOC37zB907uzcOTFvLy9SfGjmhPPyLYwc66fThzV22hb5cW7NxdwoSPl9GheXAHq4sGdqND80YcU8PZSMcWjSodyd59UX9GjZ9G57hOzCEHt40l0j4dm1da59bz+9Kmafl65+XmlDtijjfskKBt+IOF61lQyy0Z90fLJvl8t4pmn2RLRkd3r328ej9R8bO6Zq5oTxUS+W2/bWa3AI3N7Ezg+8B/khuWlKlpvqGyNsf4BDC0d/X3em6Ql1Nls0VwpfS+7/RrU7bDffvGUykuDYb5NczLZeZtQbIYdeohVa7XrGEeBT2DUSNNGuRVWm7YftzP+tz+nRh72bGxxPPJrWfSpGHNR6pXn9CrxverM6R323JNKCL7wyPqVEhkSOpNwFpgJsEkeS8Dv0xmUFK/NA5nGY2SmXFWv06xJrDWTRvQMK9umy9E6kJa9ymYWQ4ww92PBP6SmpBSL/4ikUy6YY3a8ESkrtV4puDupcCnZlZHlw6nt8xJB+VlatwiUlnU/8+J9Cl0Bmab2RQgNkTF3S9IWlRpIurTuNpENY5ZRJIvba9TAG5PehSyX8o6otI9eYlI4qKebieRPoUHwz4FSVOZ1A8iIulNfQpp6M4LE8vBaj0Sqb/SeZbUsj6Fifs6S6rsn7L522tzWDgVwVX7OZ5eRNJP1Of96lPIYK2bNkir2RVFpO6kbUezu+v2XyIiKRL1wJFEZkndyt7m6wYEt9Xc7u71YZIRQEM7RUTKJHKmUG6KQzO7kHp67+SoM7SISJmoDlYT6Wgux92fA06r+1CkTNTjlEUkOlEPMU+k+eircS9zgAI0GlJnFSJSLyUy+ugrcc+LgSXAiKREIyIiQBrfZMfdv5OKQEREJPpWiFr7FMxsnJm1invd2sweSWpUIiISiUQ6mo9y901lL9x9I1Xce1lEROpOOt95LcfMYjfENbM2JNYXISIiGSaRnfsfgMlm9hRB38fXgbuSGlWKlUvIGlUkIlkskY7mx8yskODaBAO+6u5zkh5ZBKIeHywiUiZtRx8BhEmgXiaCdKTUJJK90n70kYiIZA8lhTQW9RGDiGSfGpOCmeWa2RupCkZERELpOCGeu5cAO8ysZYrikTg6URDJPlFPiJlIR/MuYKaZvQ5sLyt09x8lLSoREYlEIknhpfBnn5hZd+AxoBNQCox19z+GF789DvQkmFzv6+FV0pjZzcDVQAnwI3d/dV8/d198umwT89dso1Xj/GR+jIjIPvOI2o8SuU5hnJk1Bnq4+7x92HYx8FN3n2ZmzYGp4dnGlcBEdx9jZqOB0cBNZtYXGAn0A7oAb5jZoWETVlKMePB9AP5yecF+byOZZ3pRn0aKSOpF/V+fyIR4XwGmA6+ErweY2Qu1refuq9x9Wvh8K/AZ0JVg2u1x4WLjgAvD5yOACe5e5O6LgQWk6A5vJaVBRs7RWCwRyXKJ7AZvI9g5bwJw9+lAr335EDPrSTCJ3kdAR3dfFW5rFdAhXKwrsCxuteVhWcVtXWdmhWZWuHbt2n0Jo1ql4TwXuTlR5+iAThBEJJ1vx1ns7psrlCUcrpk1A54GbnD3LTUtWkVZpc9x97HuXuDuBe3bt080jBrFkoL2xiISsah3Q4kkhVlmdimQa2Z9zOwBYHIiGzezfIKE8C93fyYsXm1mncP3OwNrwvLlQPe41bsBKxP5nANV1nykNnwRyXaJJIUfEnT+FgHjgc3A9bWtZMEe9m/AZ+5+X9xbLwBXhM+vAJ6PKx9pZg3NrBfQB5iSSCUOVLo1H5VJr2hEJJXSeUK889z9F8AvygrM7BLgyVrWGwZcRnCNw/Sw7BZgDPCEmV0NLAUuAXD32Wb2BMHEe8XAqGSOPIpXUho8qvlIRKIW9WzNiSSFm6mcAKoqK8fd36P6g93Tq1nnLiK4V8PmnXuA9Bt9pBwlIqlWbVIws3OAc4GuZvZ/cW+1IDiSrzfufDGYFTzdmo9EJHtFNfqopjOFlUAhcAEwNa58K/DjZAYVlQa5OWl1dB71aaSIpF7U+6Bqk4K7fwp8ambj3X1PCmNKmZGDujPh4+DSiD4dmtG2WUO27EqfqkZ1mbuIZK9E+hR6mtk9QF+gUVmhux+ctKhSqGOLhnx0yxlRh1GOzhBEJCqJdK3+HfgzQT/CqQST3P0jmUFJQMlBJHtF1VKQSFJo7O4TAXP3L9z9NuC05IYlIpKdoj4UTOh+CmaWA8w3sx8AK9g7X5EkU9R/HSKSdRI5U7gBaAL8CDgW+DZ7r0gWEZEkSMchqQC4+8cAZubu/p3kh5QZdBAvIkmR7hPimdkQM5tDcD8EzOxoM3so6ZFJ1H8bIpKFEmk+uh8YDqyH2PULJyUxJhGRrBfVVUoJzfbj7ssqFKVkorpki6rNTkSkOlEPRU9k9NEyMxsKuJk1IOhw/iy5YaVO1L8AEZF0ksiZwveAUQS3xlwODAhfS5JFPQeKiEQooqaMREYfrQO+lYJYRESyXtQHgzVNnf0ANfR1uPuPkhKRaCI8EYlMTWcKhXHPbwd+neRYpAL1d4hkr7S7Hae7jyt7bmY3xL+W5NKoKJHsFfWhYKI3oMya3VTUv5B4Ubctikj2SbO7EgtkUQYWkWql3dxHZraVvfunJma2pewtwN29RbKDExHJNhZxE0FNfQrNUxlIFGob5RPV70atRiISlaxvPkrHdns1H4lIVLI+KaSzNMxXIpIiHlGngpJCGorqj0FEohf1waCSQhqLusNJRLKPkoKISBpK6/spZKuopplQ45FI9oq6gUBJIY2p8UhEUi1pScHMHjGzNWY2K66sjZm9bmbzw8fWce/dbGYLzGyemQ1PVlyZoEFu8GsZ3KtNxJGISFSiGm+SzDOFR4GzK5SNBia6ex9gYvgaM+sLjAT6hes8ZGa5SYwtrTXKz+W1H5/Eny49JupQRCTFop4dOWlJwd3fATZUKB4BlM22Og64MK58grsXuftiYAEwOFmx7Y0x2Z+w/w7t2JzGDbI2L4pIRFLdp9DR3VcBhI8dwvKuwLK45ZaHZZWY2XVmVmhmhWvXrj3ggGrKyYl0+ESd1UWkfsr20UdV7Vmr/E7cfay7F7h7Qfv27ZMclohIimXZ6KPVZtYZIHxcE5YvB7rHLdcNWJni2EREsl6qk8ILwBXh8yuA5+PKR5pZQzPrBfQBpqQ4NhGRtBHVdDc13aP5gJjZv4FTgHZmtpzgHs9jgCfM7GpgKXAJgLvPNrMngDlAMTDK3UuSFVstcUfxsSIiQPQXryUtKbj7N6t56/Rqlr8LuCtZ8YiISO3SpaM5LUWdsUVEUk1JQUQkjUR9LKqksL90GiEi9VBWJ4U0vqBZRCQSWZ0UQKONRCQ91ccJ8TKeprAQkVSL+kBVSUFERGKUFERE0pBH1OuppCAikkaibrRWUhARkRglBRGRNKTRR2lIo1VFJNWi3u9kdVJI59txiohEIauTgohIusr223GKiAjRXzSrpCAiIjFKChVYNc9FRFJJo49ERESjj0REJH0oKYiIpCHNfSQiIpFTUhARkZisTgq1nZ5F3eEjIpJqWZ0UQDt+EUlPGpIqIiKRH6gqKYiISIySgoiIxCgp1EgdDiKSWpoQT0RE0kbaJQUzO9vM5pnZAjMbHXU8IiJR8IiGH6VVUjCzXOBB4BygL/BNM+sbbVQiIqkT9eijvGg/vpLBwAJ3XwRgZhOAEcCcuvyQouISHpv8Bc9MW7Hf2ygtDbL4rj0ldRWWiEjM71/7nN+/9nmV7+XmGC/96AQO79Sizj83rc4UgK7AsrjXy8OyOjV75RbuevmzKt/LzdmbphvkVv/1vLdgHQCFX2ys2+BEJKvlJHCqUFLqnH3/u8n5/KRsdf9V9W2Ua1gzs+vMrNDMCteuXbtfH3Jox+ZcelwPAG44o0+59xrl5/KVo7tw0qHt6dG2SbXbGPO1/gD885rj9isGEZGq5OYYIwZ0qXW5f16dnH2PRdWZURUzGwLc5u7Dw9c3A7j7PVUtX1BQ4IWFhSmMUEQk85nZVHcvqOq9dDtT+BjoY2a9zKwBMBJ4IeKYRESyRlp1NLt7sZn9AHgVyAUecffZEYclIpI10iopALj7y8DLUcchIpKN0q35SEREIqSkICIiMUoKIiISo6QgIiIxSgoiIhKTVhev7SszWwt8cQCbaAesq6Nwoqa6pK/6VJ/6VBeoX/XZl7oc5O7tq3ojo5PCgTKzwuqu6ss0qkv6qk/1qU91gfpVn7qqi5qPREQkRklBRERisj0pjI06gDqkuqSv+lSf+lQXqF/1qZO6ZHWfgoiIlJftZwoiIhJHSUFERGKyMimY2dlmNs/MFpjZ6KjjSYSZPWJma8xsVlxZGzN73czmh4+t4967OazfPDMbHk3UVTOz7mb2lpl9Zmazzez6sDzj6mNmjcxsipl9Gtbl9rA84+pSxsxyzewTM3sxfJ3JdVliZjPNbLqZFYZlGVkfM2tlZk+Z2dzwf2dIUuri7ln1Q3CfhoXAwUAD4FOgb9RxJRD3ScAxwKy4st8Co8Pno4F7w+d9w3o1BHqF9c2Nug5xcXcGjgmfNwc+D2POuPoQ3EK2Wfg8H/gIOD4T6xJXp58A44EXM/nvLIxxCdCuQllG1gcYB1wTPm8AtEpGXbLxTGEwsMDdF7n7bmACMCLimGrl7u8AGyoUjyD4QyF8vDCufIK7F7n7YmABQb3Tgruvcvdp4fOtwGdAVzKwPh7YFr7MD3+cDKwLgJl1A84D/hpXnJF1qUHG1cfMWhAcGP4NwN13u/smklCXbEwKXYFlca+Xh2WZqKO7r4JgRwt0CMszpo5m1hMYSHCEnZH1CZtbpgNrgNfdPWPrAtwP/BwojSvL1LpAkKBfM7OpZnZdWJaJ9TkYWAv8PWza+6uZNSUJdcnGpGBVlNW3cbkZUUczawY8Ddzg7ltqWrSKsrSpj7uXuPsAoBsw2MyOrGHxtK2LmZ0PrHH3qYmuUkVZWtQlzjB3PwY4BxhlZifVsGw61yePoPn4z+4+ENhO0FxUnf2uSzYmheVA97jX3YCVEcVyoFabWWeA8HFNWJ72dTSzfIKE8C93fyYsztj6AISn85OAs8nMugwDLjCzJQTNqqeZ2T/JzLoA4O4rw8c1wLMETSiZWJ/lwPLwLBTgKYIkUed1ycak8DHQx8x6mVkDYCTwQsQx7a8XgCvC51cAz8eVjzSzhmbWC+gDTIkgviqZmRG0jX7m7vfFvZVx9TGz9mbWKnzeGDgDmEsG1sXdb3b3bu7ek+D/4k13/zYZWBcAM2tqZs3LngNnAbPIwPq4+5fAMjM7LCw6HZhDMuoSdY96RL345xKMeFkI/CLqeBKM+d/AKmAPwVHA1UBbYCIwP3xsE7f8L8L6zQPOiTr+CnU5geBUdgYwPfw5NxPrAxwFfBLWZRbwq7A84+pSoV6nsHf0UUbWhaAd/tPwZ3bZ/3oG12cAUBj+rT0HtE5GXTTNhYiIxGRj85GIiFRDSUFERGKUFEREJEZJQUREYpQUREQkRklB0pKZlYQzW84ys/+UXQtQw/K3mdnPalnmQjPrG/f6DjM7ow5ivdLMusS9/mv859QVM5tc19tM5nYlMykpSLra6e4D3P1IgokAR9XBNi8kmD0SAHf/lbu/UQfbvRKIJQV3v8bd59TBdstx96F1vc1kblcyk5KCZIIPCCfzMrPeZvZKOMHZu2Z2eMWFzexaM/vYgnscPG1mTcxsKHAB8LvwDKS3mT1qZheb2Tlm9kTc+qeY2X/C52eZ2QdmNs3Mngzna4r/rIuBAuBf4XYbm9kkMysI399mZveG8b5hZoPD9xeZ2QXhMrlm9rsw5hlm9t2qvgQz2xYX3yTbO7f+v8KrxCsuP8nM/tfM3rFg/v1BZvaMBXPv/2Z/tyv1m5KCpDUzyyW4pL9sKpKxwA/d/VjgZ8BDVaz2jLsPcvejCablvtrdJ4fbuDE8A1kYt/zrwPHhVAgA3wAeN7N2wC+BMzyYVK2Q4F4DMe7+VFj+rXC7OyvE0hSYFMa7FfgNcCZwEXBHuMzVwGZ3HwQMAq4NpyaoyUDgBoIzn4MJ5i2qym53Pwl4mGAKhFHAkcCVZtb2ALYr9VRe1AGIVKOxBdNR9wSmAq+HR+lDgSfjDmAbVrHukeGRcCugGfBqTR/k7sVm9grwFTN7iuB+Aj8HTibYOb4ffl4DgrOWfbEbeCV8PhMocvc9ZjYzrBsEc/IcFZ51ALQkmKtmcQ3bneLuywHivqf3qliuLJnOBGZ7OM2ymS0imDBt/X5uV+opJQVJVzvdfYCZtQReJDjCfRTY5ME01TV5FLjQ3T81sysJ5vGpzePhZ2wAPnb3rWHTyevu/s39qkFgj++dS6YUKAJw91IzK/v/M4KznxqTVwVFcc9LqP5/uWy5UsqvU1rNOoluV+opNR9JWnP3zcCPCJqKdgKLzewSCGZbNbOjq1itObDKgum5vxVXvjV8ryqTCKYivpYgQQB8CAwzs0PCz2tiZodWsW5N203Eq8D/hPFiZofGNWWJpJSSgqQ9d/+EYKbLkQQ7+avNrGzmy6pupXorwZ3cXieYxrrMBOBGC+5c1bvCZ5QQnJGcEz7i7msJRhb928xmECSJSh3bBGcmD5d1NO9HFf9KMA3yNDObBfw/dIQuEdEsqSIiEqMzBRERiVFSEBGRGCUFERGJUVIQEZEYJQUREYlRUhARkRglBRERifn/8tJ4hCS1GPgAAAAASUVORK5CYII=",
+ "text/plain": [
+ "