├── betsi
├── __init__.py
├── .idea
│ ├── .gitignore
│ ├── misc.xml
│ ├── vcs.xml
│ ├── inspectionProfiles
│ │ └── profiles_settings.xml
│ ├── modules.xml
│ ├── betsiv8.iml
│ ├── markdown-navigator.xml
│ └── markdown-navigator-enh.xml
├── __pycache__
│ ├── lib.cpython-37.pyc
│ ├── utils.cpython-37.pyc
│ └── plotting.cpython-37.pyc
├── __main__.py
├── data
│ ├── MCM-41.csv
│ ├── PCN-777.csv
│ ├── NU-1000.csv
│ ├── TPB-DMTP-COF.csv
│ ├── NU-1102.csv
│ ├── ZIF-8.csv
│ ├── Mg-MOF-74.csv
│ ├── Al fumarate.csv
│ ├── Zeolite-13X.csv
│ ├── MIL-101.csv
│ ├── UiO-66-NH2.csv
│ ├── EXP_ZIF-8powder.csv
│ ├── HKUST-1.csv
│ ├── DMOF-1.csv
│ ├── MOF-5.csv
│ ├── NU-1105.csv
│ ├── MIL-100.csv
│ ├── NU-1104.csv
│ └── UiO-66.csv
├── utils.py
├── lib.py
├── plotting.py
└── gui.py
├── MANIFEST.in
├── .idea
├── .gitignore
├── misc.xml
├── vcs.xml
├── inspectionProfiles
│ └── profiles_settings.xml
├── betsi-gui.iml
├── modules.xml
├── betsi.iml
├── markdown-navigator.xml
└── markdown-navigator-enh.xml
├── BETSI-v2.0.pdf
├── cli.py
├── docs
└── images
│ ├── step-1.png
│ ├── step-2.png
│ ├── step-3.png
│ ├── step-4.png
│ ├── step-5.png
│ ├── step-6.png
│ ├── step-7.png
│ ├── step-8.png
│ ├── step-9.png
│ ├── a2ml_logo.png
│ ├── step-10.png
│ ├── step-11.png
│ ├── step-12.png
│ ├── step-13.png
│ ├── step-14.png
│ ├── BETSI-logo.jpeg
│ ├── betsi_logo.PNG
│ └── executables-banner.PNG
├── requirements.txt
├── executables
├── BETSI_v2_0_linux.zip
├── BETSI_v2_0_mac.zip
├── BETSI_v2_0_windows.exe
├── BETSI_v2_0_linux-faster_startup.zip
├── BETSI_v2_0_mac_faster_startup.zip
└── BETSI_v2_0_windows_faster_startup.zip
├── .travis.yml
├── .gitattributes
├── LICENSE.txt
├── setup.py
├── cli.spec
└── README.md
/betsi/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | graft betsi
--------------------------------------------------------------------------------
/.idea/.gitignore:
--------------------------------------------------------------------------------
1 |
2 | # Default ignored files
3 | /workspace.xml
--------------------------------------------------------------------------------
/betsi/.idea/.gitignore:
--------------------------------------------------------------------------------
1 | # Default ignored files
2 | /workspace.xml
--------------------------------------------------------------------------------
/BETSI-v2.0.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fairen-group/betsi-gui/HEAD/BETSI-v2.0.pdf
--------------------------------------------------------------------------------
/cli.py:
--------------------------------------------------------------------------------
1 | from betsi.gui import runbetsi
2 |
3 | runbetsi() # pylint: disable=no-value-for-parameter
--------------------------------------------------------------------------------
/docs/images/step-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fairen-group/betsi-gui/HEAD/docs/images/step-1.png
--------------------------------------------------------------------------------
/docs/images/step-2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fairen-group/betsi-gui/HEAD/docs/images/step-2.png
--------------------------------------------------------------------------------
/docs/images/step-3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fairen-group/betsi-gui/HEAD/docs/images/step-3.png
--------------------------------------------------------------------------------
/docs/images/step-4.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fairen-group/betsi-gui/HEAD/docs/images/step-4.png
--------------------------------------------------------------------------------
/docs/images/step-5.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fairen-group/betsi-gui/HEAD/docs/images/step-5.png
--------------------------------------------------------------------------------
/docs/images/step-6.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fairen-group/betsi-gui/HEAD/docs/images/step-6.png
--------------------------------------------------------------------------------
/docs/images/step-7.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fairen-group/betsi-gui/HEAD/docs/images/step-7.png
--------------------------------------------------------------------------------
/docs/images/step-8.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fairen-group/betsi-gui/HEAD/docs/images/step-8.png
--------------------------------------------------------------------------------
/docs/images/step-9.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fairen-group/betsi-gui/HEAD/docs/images/step-9.png
--------------------------------------------------------------------------------
/docs/images/a2ml_logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fairen-group/betsi-gui/HEAD/docs/images/a2ml_logo.png
--------------------------------------------------------------------------------
/docs/images/step-10.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fairen-group/betsi-gui/HEAD/docs/images/step-10.png
--------------------------------------------------------------------------------
/docs/images/step-11.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fairen-group/betsi-gui/HEAD/docs/images/step-11.png
--------------------------------------------------------------------------------
/docs/images/step-12.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fairen-group/betsi-gui/HEAD/docs/images/step-12.png
--------------------------------------------------------------------------------
/docs/images/step-13.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fairen-group/betsi-gui/HEAD/docs/images/step-13.png
--------------------------------------------------------------------------------
/docs/images/step-14.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fairen-group/betsi-gui/HEAD/docs/images/step-14.png
--------------------------------------------------------------------------------
/docs/images/BETSI-logo.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fairen-group/betsi-gui/HEAD/docs/images/BETSI-logo.jpeg
--------------------------------------------------------------------------------
/docs/images/betsi_logo.PNG:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fairen-group/betsi-gui/HEAD/docs/images/betsi_logo.PNG
--------------------------------------------------------------------------------
/docs/images/executables-banner.PNG:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fairen-group/betsi-gui/HEAD/docs/images/executables-banner.PNG
--------------------------------------------------------------------------------
/betsi/__pycache__/lib.cpython-37.pyc:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fairen-group/betsi-gui/HEAD/betsi/__pycache__/lib.cpython-37.pyc
--------------------------------------------------------------------------------
/betsi/__pycache__/utils.cpython-37.pyc:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fairen-group/betsi-gui/HEAD/betsi/__pycache__/utils.cpython-37.pyc
--------------------------------------------------------------------------------
/betsi/__pycache__/plotting.cpython-37.pyc:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fairen-group/betsi-gui/HEAD/betsi/__pycache__/plotting.cpython-37.pyc
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | numpy==1.19.3
2 | scipy==1.5.4
3 | matplotlib==3.2.2
4 | PyQt5==5.9.2
5 | pandas==1.1.5
6 | seaborn==0.11.0
7 | statsmodels==0.12.1
8 | xlrd==2.0.1
9 |
--------------------------------------------------------------------------------
/executables/BETSI_v2_0_linux.zip:
--------------------------------------------------------------------------------
1 | version https://git-lfs.github.com/spec/v1
2 | oid sha256:9fe6a2109a0c40e5fef4be93cf4fefb0f510a77fa996e09f3f8470581c0553d4
3 | size 117570162
4 |
--------------------------------------------------------------------------------
/executables/BETSI_v2_0_mac.zip:
--------------------------------------------------------------------------------
1 | version https://git-lfs.github.com/spec/v1
2 | oid sha256:2eff4fd62f6050c8bb5b9744d2cb9cb68f2d9c479337dc6c2b6dec047f2fde67
3 | size 117573850
4 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: python
2 | python:
3 | - "3.6"
4 | install:
5 | - easy_install distribute
6 | - pip install -r requirements.txt
7 | script: cd betsi; python3 gui.py
8 |
--------------------------------------------------------------------------------
/executables/BETSI_v2_0_windows.exe:
--------------------------------------------------------------------------------
1 | version https://git-lfs.github.com/spec/v1
2 | oid sha256:d0d973d7b1461fe02cfd1e82bafa5d7e634076dfd09dcb86378551e48cbb5a47
3 | size 183182879
4 |
--------------------------------------------------------------------------------
/betsi/__main__.py:
--------------------------------------------------------------------------------
1 | from betsi.gui import runbetsi
2 |
3 | def main():
4 | runbetsi() # pylint: disable=no-value-for-parameter
5 |
6 | if __name__ == "__main__":
7 | main()
--------------------------------------------------------------------------------
/executables/BETSI_v2_0_linux-faster_startup.zip:
--------------------------------------------------------------------------------
1 | version https://git-lfs.github.com/spec/v1
2 | oid sha256:8dc2a7c7dc1478c65480a4a6485c8bf4e79c8bc53195d981337b0bdc47d39203
3 | size 119517011
4 |
--------------------------------------------------------------------------------
/executables/BETSI_v2_0_mac_faster_startup.zip:
--------------------------------------------------------------------------------
1 | version https://git-lfs.github.com/spec/v1
2 | oid sha256:a134968d4c6d701f1e71996807c0a0490a99e134b9016ef1048dc937e3bf68dc
3 | size 239720524
4 |
--------------------------------------------------------------------------------
/executables/BETSI_v2_0_windows_faster_startup.zip:
--------------------------------------------------------------------------------
1 | version https://git-lfs.github.com/spec/v1
2 | oid sha256:7845386f43b94ac94d55ff6daf8280db4d83386880f15507c64c5cc970603899
3 | size 171295906
4 |
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
15 |
16 |
50 |
51 | Next, create a new environment by clicking ```Create``` on the bottom left corner. You can give your environment and arbitrary name (we have called ours ```betsi```) and select as a package ```Python 3.7```.
52 |
53 | #
54 |
55 | If you have successfully created a new environment, it should appear under the base environment. Next, click the :arrow_forward: button in the newly created environment and select ```Open Terminal```
56 |
57 | #
58 |
59 | This will prompt a command terminal in the new environment.
60 |
61 | #
62 |
63 | ### :star: Option 1: via ```pypi```
64 |
65 | Next, type in the command: ```pip install -i https://test.pypi.org/simple/ betsi-gui```
66 |
67 |
68 |
69 | ### :point_right: Option 2: using the source code
70 |
71 | Navigate to the location where you have stored the source code and enter the directory ```betsi-gui```. Once in the directory, run the command ```python setup.py install```, followed by the command ```pip install .```
72 |
73 | To read more about using the command line please visit: [windows](https://www.computerhope.com/issues/chusedos.htm), [linux](https://ubuntu.com/tutorials/command-line-for-beginners#1-overview), [macOS](https://blog.teamtreehouse.com/introduction-to-the-mac-os-x-command-line).
74 |
75 | #
76 | :heavy_check_mark: This will install BETSI in the newly created environment and download all the relevant python packages from our test server.
77 |
78 | ## Instructions of use
79 |
80 | *Estimated run time*: ***5 minutes***
81 |
82 | Next, to run BETSI, type in the command: ```python -m betsi```
83 |
84 | #
85 |
86 | Run the command, which will prompt the BETSI GUI. This step may take some time. The BETSI GUI will appear with its default settings as laid out in the Rouquerol criteria. Run an isotherm in the GUI by dragging a correct ```.csv``` file into the empty space on the right. Test isotherms can be found in the repository. Note that isotherms will only run successfully in BETSI if they are in the same format as the exemplary isotherms, further information can be found in section [Test Dataset](#test-dataset) below.
87 |
88 | #
89 |
90 | The code will run automatically and two windows appear. For a full explanation of all figures, please refer to the Supplementary Information of the manuscript, Section S5.
91 |
92 | #
93 |
94 | Further, you can interact with the GUI by manually selecting other Rouquerol-permitted BET areas. In the ```Filtered BET areas``` plot, click on one of the other points. All plots will automatically update to the new selected linear region/BET area. The ```active``` plot is always shown in yellow.
95 |
96 | #
97 |
98 | To output BETSI data, select an output directory and click ```Export Results``` in the GUI.
99 |
100 | #
101 |
102 | The specified directory will contain ```.pdf``` prints of the two active plots (BETSI analysis and regression diagnostics), a ```.json``` file specifying the filter criteria, a ```.txt``` file featuring a small summary, and a folder containing all matrices that the program uses.
103 |
104 | #
105 |
106 | Analyse a new isotherm in BETSI by clearing the current plot either via ```Tools-> Clear```, or by pressing the hotkey combination ```CMD/CTRL+C```.
107 |
108 | #
109 |
110 |
111 | ## Test Dataset
112 |
113 | A test dataset of isotherms is supplied on this repository. To run the isotherms in BETSI, download the dataset and drag isotherms into the BETSI GUI as described above. If you would like to try BETSI with your own dataset, you will need to convert it first into the same format as the test isotherms: It must be a 2-column ```.csv``` file with the relative pressure in the first column and the adsorbed quantity in the second. The first row will not be read as this usually contains the header. You must use an adsorption isotherm only, a desorption swing, or discontinuity in the adsorption from pressure equilibration issues will result in an error, with the PChip interpolation method.
114 |
115 | ## License
116 |
117 | BETSI is distributed under the MIT open source license (see [`LICENSE.txt`](LICENSE.txt)).
118 |
119 | ## Acknowledgements
120 |
121 | Main Developers: James Rampersad, Johannes W. M. Osterrieth, and Nakul Rampal
122 |
123 | This work is supported by:
124 | * [Cambridge International Scholarship](https://www.cambridgetrust.org/) funded by the Cambridge Commonwealth, European & International Trust;
125 | * [Trinity-Henry Barlow Scholarship](https://www.trin.cam.ac.uk/) (Honorary) funded by Trinity College, Cambridge.
126 |
127 |
--------------------------------------------------------------------------------
/betsi/utils.py:
--------------------------------------------------------------------------------
1 | """
2 |
3 | """
4 | from pathlib import Path
5 |
6 | import pandas as pd
7 | import numpy as np
8 | from scipy.interpolate import splrep, PchipInterpolator, pchip_interpolate
9 |
10 |
11 | def get_data(input_file):
12 | """Read pressure and adsorbate uptake data from file. Asserts pressures in units of bar.
13 |
14 | Args:
15 | input_file: Path the path to the input file
16 |
17 | Returns:
18 | Pressure and Quantity adsorbed.
19 | """
20 | input_file = Path(input_file)
21 |
22 | if str(input_file).find('.txt') != -1 or str(input_file).find('.aif') != -1 or str(input_file).find('.csv') != -1:
23 | pressure, q_adsorbed = get_data_for_txt_aif_csv_two_columns(str(input_file))
24 | elif str(input_file).find('.XLS') != -1:
25 | pressure, q_adsorbed = get_data_from_micromeritics_xls(str(input_file))
26 | else:
27 | pressure = np.array([])
28 | q_adsorbed = np.array([])
29 |
30 | if len(q_adsorbed) > 0:
31 | start_index = np.argmax(q_adsorbed > 0)
32 | pressure = pressure[start_index:]
33 | q_adsorbed = q_adsorbed[start_index:]
34 |
35 | # else:
36 | # try:
37 | # data = np.loadtxt(str(input_file), skiprows=0, delimiter=',')
38 | # pressure = data[:, 0]
39 | # q_adsorbed = data[:, 1]
40 | # except ValueError:
41 | # data = np.loadtxt(str(input_file), skiprows=1, delimiter=',')
42 | # pressure = data[:, 0]
43 | # q_adsorbed = data[:, 1]
44 |
45 |
46 | comments_to_data = {'has_negative_pressure_points': False,\
47 | 'monotonically_increasing_pressure': True,\
48 | 'rel_pressure_between_0_and_1': True}
49 | ## removes negetive relative pressure points if any
50 | negative_pressure_indexes = np.where(pressure < 0)[0]
51 | if len(negative_pressure_indexes) > 0:
52 | comments_to_data['has_negative_pressure_points'] = True
53 | pressure = np.delete(pressure, negative_pressure_indexes)
54 | q_adsorbed = np.delete(q_adsorbed, negative_pressure_indexes)
55 |
56 | ## checks if relative pressure points are monotonically increasing, if not,
57 | ## removes problematic points
58 | if not (pressure == np.sort(pressure)).all():
59 | comments_to_data['monotonically_increasing_pressure'] = False
60 | temp_index = 0
61 | temp_pressure = pressure
62 | temp_q_adsorbed = q_adsorbed
63 | while temp_index < len(temp_pressure)-1:
64 | if temp_pressure[temp_index+1] <= temp_pressure[temp_index]:
65 | temp_pressure = np.delete(temp_pressure, [temp_index+1])
66 | temp_q_adsorbed = np.delete(temp_q_adsorbed, [temp_index+1])
67 | else:
68 | temp_index += 1
69 | pressure = temp_pressure
70 | q_adsorbed = temp_q_adsorbed
71 |
72 | ## checks if relative pressure points lie between 0 and 1 (bar)
73 | if not (pressure < 1.1).all():
74 | comments_to_data['rel_pressure_between_0_and_1'] = False
75 | pressure_above_one_indexes = np.where(pressure > 1.1)[0]
76 | pressure = np.delete(pressure, pressure_above_one_indexes)
77 | q_adsorbed = np.delete(q_adsorbed, pressure_above_one_indexes)
78 |
79 | comments_to_data['interpolated_points_added'] = False
80 |
81 | ## assert (pressure < 1.1).all(), "Relative pressure must lie between 0 and 1 bar."
82 | return pressure, q_adsorbed, comments_to_data
83 |
84 |
85 | def get_fitted_spline(pressure, q_adsorbed):
86 | """ Fits a cubic spline to the isotherm.
87 |
88 | Args:
89 | pressure: Array of relative pressure values.
90 | q_adsorbed: Array of adsorbate uptake values.
91 |
92 | Returns:
93 | tck tuple of spline parameters.
94 | """
95 | return splrep(pressure, q_adsorbed, s=50, k=3, quiet=True)
96 |
97 | def get_pchip_interpolation(pressure,q_adsorbed):
98 | """ Fits isotherm with shape preserving pchip interpolation
99 |
100 | Args:
101 | pressure: Array of relative pressure values
102 | q_adsorbed: Array of adsorbate uptake values
103 |
104 | Returns:
105 | Pchip parameters
106 | """
107 | return PchipInterpolator(pressure,q_adsorbed, axis =0, extrapolate=None)
108 |
109 | def isotherm_pchip_reconstruction(pressure, q_adsorbed, num_of_interpolated_points):
110 | """ Fits isotherm with a pchip interpolation. Can use this to
111 | calculate BET area for difficult isotherms
112 |
113 | Args: pressure: Array of relative pressure values
114 | q_adsorbed: Array of adsorbate uptake values
115 |
116 | Returns: Array of interpolated adsorbate uptake values
117 |
118 | """
119 | ## x_range = np.linspace(pressure[0], pressure[len(pressure) -1 ], 500)
120 | ## y_pchip = pchip_interpolate(pressure,q_adsorbed,x_range, der=0, axis=0)
121 |
122 | ## pressure = x_range
123 | ## q_adsorbed = y_pchip
124 |
125 |
126 | ## Add new interpolated points using pchip interpolation while having the original data points in the list as well
127 | x_range = np.linspace(np.log10(pressure[0]), np.log10(pressure[len(pressure) -1 ]), num_of_interpolated_points)
128 | delta_x = abs(x_range[1] - x_range[0])/2
129 | for p in np.log10(pressure[1:-1]):
130 | to_be_deleted_indexes = []
131 | index = np.searchsorted(x_range,p)
132 | if 0 <= index < len(x_range) and abs(p - x_range[index]) < delta_x:
133 | to_be_deleted_indexes.append(index)
134 | if 0 <= (index+1) < len(x_range) and abs(p - x_range[index+1]) < delta_x:
135 | to_be_deleted_indexes.append(index+1)
136 | if 0 <= (index-1) < len(x_range) and abs(p - x_range[index-1]) < delta_x:
137 | to_be_deleted_indexes.append(index-1)
138 | x_range = np.delete(x_range, to_be_deleted_indexes)
139 | x_range = np.append(x_range[1:-1], np.log10(pressure))
140 | x_range.sort()
141 | x_range = np.power(10, x_range)
142 |
143 | y_pchip = pchip_interpolate(pressure,q_adsorbed,x_range, der=0, axis=0)
144 |
145 | pressure_new = x_range
146 | q_adsorbed_new = y_pchip
147 |
148 | return pressure_new, q_adsorbed_new
149 |
150 | def get_data_for_txt_aif_csv_two_columns(input_file):
151 | """ Read pressure and adsorbate uptake data from file if the file extension is *.txt or *.aif.
152 | this function will be called in the get_data() function, and should not be called alone anywhere in the code
153 | as it might cause some errors.
154 |
155 | Args:
156 | input_file: String of the path to the input file
157 |
158 | Returns:
159 | Pressure and Quantity adsorbed.
160 | """
161 |
162 | pressure = []
163 | q_adsorbed = []
164 |
165 | with open(input_file, 'r') as f:
166 | input_file_lines = f.readlines()
167 |
168 | if len(input_file_lines) == 0:
169 | pressure = np.array(pressure)
170 | q_adsorbed = np.array(q_adsorbed)
171 | return pressure, q_adsorbed
172 |
173 | index_start = 0
174 | index_stop = len(input_file_lines) - 1
175 | index_iter = 0
176 | first_loop_index = 0
177 |
178 | if input_file.find(".aif") != -1:
179 | for line in input_file_lines:
180 | if line.find("loop_") != -1 and first_loop_index == 0:
181 | first_loop_index = index_iter;
182 | if line.find("_adsorp_pressure") != -1:
183 | _adsorp_pressure_index = index_iter;
184 | if line.find("_adsorp_p0") != -1:
185 | _adsorp_p0_index = index_iter;
186 | if line.find("_adsorp_amount") != -1:
187 | index_start = index_iter
188 | if index_start > 0 and (line.find("loop_") != -1 or index_iter + 1 == len(input_file_lines)):
189 | index_stop = index_iter
190 | break
191 | index_iter += 1
192 | for index in range(index_start, index_stop + 1):
193 | val = []
194 | for t in input_file_lines[index].split():
195 | try:
196 | val.append(float(t))
197 | except ValueError:
198 | pass
199 | if len(val) >= 3:
200 | pressure.append(val[_adsorp_pressure_index - first_loop_index - 1]/val[_adsorp_p0_index - first_loop_index - 1])
201 | q_adsorbed.append(val[index_start - first_loop_index - 1])
202 | else:
203 | delimiter = " "
204 | if input_file_lines[round(len(input_file_lines)/2)].find(",") != -1:
205 | delimiter = ","
206 | elif input_file_lines[round(len(input_file_lines)/2)].find(" ") != -1:
207 | delimiter = " "
208 | elif input_file_lines[round(len(input_file_lines)/2)].find("\t") != -1:
209 | delimiter = "\t"
210 | for line in input_file_lines:
211 | val = []
212 | for t in line.split(delimiter):
213 | try:
214 | val.append(float(t))
215 | except ValueError:
216 | pass
217 | if len(val) >= 2:
218 | pressure.append(val[0])
219 | q_adsorbed.append(val[1])
220 | if len(pressure) == 0 or len(q_adsorbed) == 0:
221 | print("You must provide a valid input file!")
222 |
223 | pressure = np.array(pressure)
224 | q_adsorbed = np.array(q_adsorbed)
225 |
226 | return pressure, q_adsorbed
227 |
228 | def get_data_from_micromeritics_xls(input_file):
229 | """ Read pressure and adsorbate uptake data from Micromeritics output files with *.XLS extension
230 | this function will be called in the get_data() function, and should not be called alone anywhere in the code
231 | as it might cause some errors.
232 |
233 | Args:
234 | input_file: String of the path to the input file
235 |
236 | Returns:
237 | Pressure and Quantity adsorbed.
238 | """
239 |
240 | # pressure = []
241 | # q_adsorbed = []
242 |
243 | excel_file = pd.ExcelFile(input_file)
244 |
245 | if len(excel_file.sheet_names) == 1:
246 | excel_df = pd.read_excel(input_file, sheet_name=0)
247 | column_num = 0
248 | for column in excel_df.columns:
249 | if len(excel_df.loc[excel_df[column]=='Isotherm Linear Plot']) > 0:
250 | keyword_column_name = column
251 | keyword_index = excel_df.loc[excel_df[column]=='Isotherm Linear Plot'].index[0]
252 |
253 | adsorption_data_start_index = 0
254 | for row_num in range(keyword_index+1, len(excel_df.index)):
255 | if excel_df[keyword_column_name][row_num] == "Relative Pressure (p/p°)":
256 | adsorption_data_start_index = row_num + 1
257 |
258 | nan_indexes_in_column = np.array(excel_df.loc[excel_df[column].isnull()].index)
259 | adsorption_data_stop_index = nan_indexes_in_column[np.argmax(nan_indexes_in_column > adsorption_data_start_index)] - 1
260 |
261 | if adsorption_data_stop_index < adsorption_data_start_index:
262 | adsorption_data_stop_index = len(excel_df.index) - 1
263 |
264 | if adsorption_data_start_index > 0:
265 | pressure = np.array(excel_df[keyword_column_name][adsorption_data_start_index:adsorption_data_stop_index+1])
266 | q_adsorbed = np.array(excel_df[excel_df.columns[column_num+1]][adsorption_data_start_index:adsorption_data_stop_index+1])
267 | else:
268 | pressure = np.array([])
269 | q_adsorbed = np.array([])
270 |
271 | break
272 | column_num += 1
273 |
274 | elif len(excel_file.sheet_names) == 0:
275 | pressure = np.array([])
276 | q_adsorbed = np.array([])
277 | else:
278 | try:
279 | excel_df = pd.read_excel(input_file, sheet_name="Isotherm Linear Plot")
280 | column_num = 0
281 | for column in excel_df.columns:
282 | if len(excel_df.loc[excel_df[column]=='Isotherm Linear Plot']) > 0:
283 | keyword_column_name = column
284 | keyword_index = excel_df.loc[excel_df[column]=='Isotherm Linear Plot'].index[0]
285 |
286 | adsorption_data_start_index = 0
287 | for row_num in range(keyword_index+1, len(excel_df.index)):
288 | if excel_df[keyword_column_name][row_num] == "Relative Pressure (p/p°)":
289 | adsorption_data_start_index = row_num + 1
290 |
291 | nan_indexes_in_column = np.array(excel_df.loc[excel_df[column].isnull()].index)
292 | adsorption_data_stop_index = nan_indexes_in_column[np.argmax(nan_indexes_in_column > adsorption_data_start_index)] - 1
293 |
294 | if adsorption_data_stop_index < adsorption_data_start_index:
295 | adsorption_data_stop_index = len(excel_df.index) - 1
296 |
297 | if adsorption_data_start_index > 0:
298 | pressure = np.array(excel_df[keyword_column_name][adsorption_data_start_index:adsorption_data_stop_index+1])
299 | q_adsorbed = np.array(excel_df[excel_df.columns[column_num+1]][adsorption_data_start_index:adsorption_data_stop_index+1])
300 | else:
301 | pressure = np.array([])
302 | q_adsorbed = np.array([])
303 |
304 | break
305 | column_num += 1
306 |
307 | except ValueError:
308 | pressure = np.array([])
309 | q_adsorbed = np.array([])
310 |
311 | pressure = np.float64(pressure)
312 | q_adsorbed = np.float64(q_adsorbed)
313 |
314 | return pressure, q_adsorbed
--------------------------------------------------------------------------------
/betsi/lib.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | """
3 | Functions for computing the BET areas.
4 | Created on Thu Mar 21 16:24:04 2019
5 |
6 | @author: jwmo2, ls604, jrampersad
7 | """
8 | import os
9 | from pathlib import Path
10 |
11 | import matplotlib
12 | import numpy as np
13 | from matplotlib import pyplot as plt
14 | from scipy.interpolate import splev
15 | from scipy.optimize import minimize
16 |
17 | from betsi.plotting import create_matrix_plot,regression_diagnostics_plots
18 | from betsi.utils import *
19 | from pprint import pprint
20 | from scipy.interpolate import splrep, pchip_interpolate
21 | matplotlib.use('Qt5Agg')
22 |
23 | ##NITROGEN_RADIUS = 1.62E-19
24 | ##NITROGEN_MOL_VOL = 44.64117195
25 | AVOGADRO_N = 6.02E+23
26 | ## in units of m2
27 | cross_sectional_area = {"N2": 1.62E-19, "Ar": 1.66E-19, "Kr": 2.02E-19, "Xe": 2.32E-19, "CO2": 1.95E-19, "Custom": None}
28 | ## in units of mmol/litre or mol/m3
29 | mol_vol = {"N2": 44.64117195, "Ar": 44.642857, "Kr": 44.642857, "Xe": 44.642857, "CO2": 44.642857, "Custom": None}
30 |
31 | class BETResult:
32 | """
33 | Structure to hold the unfiltered results obtained from running initial BET analysis on
34 | an isotherm.
35 | """
36 |
37 | def __init__(self, pressure, q_adsorbed):
38 |
39 | def zero_matrix(dim):
40 | # Helper function to create a square matrix of dimension dim.
41 | return np.zeros([dim, dim])
42 |
43 | # Pressure & Q_Adsorbed
44 | self.pressure = pressure
45 | self.q_adsorbed = q_adsorbed
46 |
47 | # Apply linearised BET equation
48 | self.linear_y = (pressure / (q_adsorbed * (1. - pressure)))
49 | self.rouq_y = q_adsorbed * (1. - pressure)
50 |
51 | # Fit a line to isotherm.
52 | self.x_range = np.linspace(pressure[0], pressure[len(pressure) -1 ], 10000)
53 | self.fitted_spline = get_fitted_spline(pressure, q_adsorbed)
54 |
55 | # Number of points in each segment:
56 | num_points = len(pressure)
57 | self.point_count = zero_matrix(num_points)
58 |
59 | # Gradients, Intercept and Residual of Linearised BET:
60 | self.fit_grad = zero_matrix(num_points)
61 | self.fit_intercept = zero_matrix(num_points)
62 | self.fit_rsquared = zero_matrix(num_points)
63 |
64 | # 'C' constants and Monolayer loadings:
65 | self.c = zero_matrix(num_points)
66 | self.nm = zero_matrix(num_points)
67 |
68 | # Pressure at the Monolayer loading, error and percentage error:
69 | self.calc_pressure = zero_matrix(num_points)
70 | self.error = zero_matrix(num_points) + 300
71 | self.pc_error = zero_matrix(num_points) + 300
72 |
73 | # Identify the Isotherm Knee
74 | self.knee_index = np.argmax(np.diff(self.rouq_y) < 0)
75 |
76 | # Binary rouquerol `pass` matrices:
77 | self.rouq1 = zero_matrix(num_points)
78 | self.rouq2 = zero_matrix(num_points)
79 | self.rouq3 = zero_matrix(num_points)
80 | self.rouq4 = zero_matrix(num_points)
81 | self.rouq5 = zero_matrix(num_points)
82 |
83 | #Corresponding pressure matrix
84 | self.corresponding_pressure = zero_matrix(num_points)
85 | self.corresponding_pressure_pchip = zero_matrix(num_points)
86 |
87 | # Fill all the matrices
88 | self._compute_betsi_data(pressure, q_adsorbed)
89 |
90 | def _compute_betsi_data(self, pressure, q_adsorbed):
91 | """ Computes Monolayer loadings and applies the rouquerel criteria individually to produce a
92 | dictionary of matrices that can be used in downstream analysis and plotting.
93 |
94 | Args:
95 | pressure: Array of relative pressure values.
96 | q_adsorbed: Array of Nitrogen uptake values.
97 |
98 | Returns:
99 | A Betsi Result Object
100 | """
101 |
102 | # Fit a line to isotherm.
103 | fitted_spline = get_fitted_spline(pressure, q_adsorbed)
104 | fitted_pchip = get_pchip_interpolation(pressure,q_adsorbed)
105 |
106 | def distance_to_interpolation(_s, _mono):
107 | # Calculate distance of monolayer loading to fitted spline.
108 | return (splev(_s, fitted_spline, der=0, ext=0) - _mono) ** 2
109 |
110 | def distance_to_pchip(_s,_mono):
111 | # Create a BET results object:
112 |
113 | return (fitted_pchip.__call__(_s,nu=0,extrapolate=None) - _mono) ** 2
114 |
115 |
116 | num_points = len(pressure)
117 |
118 | for i in range(num_points):
119 | for j in range(i + 1, num_points + 1):
120 |
121 | # Set the number of points
122 | self.point_count[i, j - 1] = j - i
123 |
124 | # Fit a straight line to points i:j of the linearised equation. Compute C and Nm.
125 | x = np.concatenate(
126 | [np.ones([num_points, 1]), pressure[:, None]], axis=1)
127 | params, residuals, _, _ = np.linalg.lstsq(
128 | x[i:j, :], self.linear_y[i:j], rcond=None)
129 | if residuals:
130 | r2 = 1. - residuals / \
131 | (self.linear_y[i:j].size * self.linear_y[i:j].var())
132 | self.fit_rsquared[i, j - 1] = r2
133 |
134 | # Set the linearised BET parameters from the fit.
135 | self.fit_intercept[i, j - 1] = params[0]
136 | self.fit_grad[i, j - 1] = params[1]
137 | self.c[i, j - 1] = self.fit_grad[i, j - 1] / \
138 | self.fit_intercept[i, j - 1] + 1.
139 | self.nm[i, j - 1] = 1. / \
140 | (self.fit_grad[i, j - 1] + self.fit_intercept[i, j - 1])
141 |
142 | for i in range(num_points):
143 | for j in range(i + 1, num_points):
144 |
145 | # ROUQUEROL CRITERIA 1. vol_adsorbed * (1. - pressure) increases monotonically.
146 | deltas = np.diff(self.rouq_y[i:(j + 1)])
147 | if not (deltas < 0).any():
148 | self.rouq1[i, j] = 1
149 | deltas_2 = np.diff(self.linear_y[i:(j+1)])
150 | if (deltas_2 < 0).any():
151 | self.rouq1[i,j]=0
152 |
153 | # ROUQUEROL CRITERIA 2. Resulting C value must be positive.
154 | if self.c[i, j] <= 0:
155 | continue
156 | self.rouq2[i, j] = 1
157 |
158 | # ROUQUEROL CRITERIA 3. Pressure corresponding to Nm should lie in linear range
159 | self.calc_pressure[i, j] = 1. / (np.sqrt(self.c[i, j]) + 1)
160 | opt_res = minimize(fun=distance_to_interpolation,
161 | x0=self.calc_pressure[i, j],
162 | args=(self.nm[i, j]))
163 | self.corresponding_pressure[i,j] = opt_res.x
164 | opt_res_pchip = minimize(fun=distance_to_pchip,x0=self.calc_pressure[i, j], args=(self.nm[i, j]))
165 | self.corresponding_pressure_pchip[i,j] = opt_res_pchip.x
166 |
167 |
168 | if not pressure[i] < self.corresponding_pressure_pchip[i,j] < pressure[j]:
169 | continue
170 | self.rouq3[i, j] = 1
171 |
172 | # ROUQUEROL CRITERIA 4. Relative Pressure should be *close* to P from BET Theory.
173 | self.error[i, j] = abs(
174 | self.corresponding_pressure_pchip[i,j] - self.calc_pressure[i, j])
175 | self.pc_error[i, j] = (
176 | self.error[i, j] / self.corresponding_pressure_pchip[i,j]) * 100.
177 |
178 | ### ROUQUEROL CRITERIA 5. Linear region must end at the knee
179 | ##if j == self.knee_index:
180 | ## self.rouq5[i, j] = 1
181 | self.rouq5[i, j] = 1
182 |
183 | class BETFilterAppliedResults:
184 | """
185 | Structure obtained from applying a set of custom filters to a BETResult.
186 |
187 | After initialisation with an initialised BETResult object and set of desired features, the
188 | BETFilterAppliedResults object contains all data required to produce any plot.
189 | """
190 |
191 | def __init__(self, bet_result, **kwargs):
192 |
193 | # Transfer all the properties from the original BET calculation
194 | self.__dict__.update(bet_result.__dict__)
195 | self.filter_params = kwargs
196 |
197 | # Apply the selected filters in turn
198 | filter_mask = np.ones_like(bet_result.c)
199 |
200 | if kwargs.get('use_rouq1', True):
201 | filter_mask = filter_mask * bet_result.rouq1
202 |
203 | if kwargs.get('use_rouq2', True):
204 | filter_mask = filter_mask * bet_result.rouq2
205 |
206 | if kwargs.get('use_rouq3', True):
207 | filter_mask = filter_mask * bet_result.rouq3
208 |
209 | if kwargs.get('use_rouq4', True):
210 | max_perc_error = kwargs.get('max_perc_error', 20)
211 | filter_mask = filter_mask * (bet_result.pc_error < max_perc_error)
212 |
213 | ##if kwargs.get('use_rouq5', False):
214 | ## filter_mask = filter_mask * bet_result.rouq5
215 | if kwargs.get('use_rouq5', True):
216 | filter_mask = filter_mask * bet_result.rouq5
217 |
218 | self.use_rouq5 = kwargs.get('use_rouq5', True)
219 | adsorbate = kwargs.get('adsorbate', "N2")
220 |
221 | if adsorbate == "Custom":
222 | cross_sectional_area[adsorbate] = 1.0E-18 * kwargs.get('cross_sectional_area')
223 | mol_vol[adsorbate] = kwargs.get('molar_volume')
224 |
225 | # Filter results that have less than the minimum points
226 | min_points = kwargs.get('min_num_pts', 10)
227 | filter_mask = filter_mask * (bet_result.point_count >= min_points)
228 |
229 | # Block out results that have less than the minimum R2
230 | min_r2 = kwargs.get('min_r2', 0.9)
231 | filter_mask = filter_mask * (bet_result.fit_rsquared > min_r2)
232 |
233 | ## assert np.sum(filter_mask) != 0, "NO valid areas found"
234 | self.has_valid_areas = False
235 | self.original_pressure_data = bet_result.original_pressure_data
236 | self.original_q_adsorbed_data = bet_result.original_q_adsorbed_data
237 | self.comments_to_data = bet_result.comments_to_data
238 | self.adsorbate = adsorbate
239 |
240 | if np.sum(filter_mask) != 0:
241 | self.has_valid_areas = True
242 |
243 | # Compute valid BET areas
244 | ##self.bet_areas = NITROGEN_RADIUS * AVOGADRO_N * \
245 | ## NITROGEN_MOL_VOL * bet_result.nm * 0.000001
246 | self.bet_areas = cross_sectional_area[adsorbate] * AVOGADRO_N * \
247 | mol_vol[adsorbate] * bet_result.nm * 0.000001
248 | self.bet_areas_filtered = self.bet_areas * filter_mask
249 | self.valid_indices = np.where(self.bet_areas_filtered > 0)
250 |
251 | # Define isotherm knee as ending on highest P
252 | self.list = np.where(self.valid_indices[1] == np.amax(self.valid_indices[1]))
253 | self.lower = (self.valid_indices[0])[self.list]
254 | self.upper = (self.valid_indices[1])[self.list]
255 | self.valid_knee_indices = (self.lower, self.upper)
256 | self.knee_only_bet_areas_filtered = self.bet_areas * bet_result.rouq5
257 |
258 |
259 | # Define the valid cases
260 | self.num_valid = len(self.valid_indices[0])
261 | self.valid_bet_areas = self.bet_areas[self.valid_indices]
262 | self.valid_pc_errors = bet_result.pc_error[self.valid_indices]
263 | self.valid_knee_bet_areas = self.bet_areas[self.valid_knee_indices]
264 | self.valid_knee_pc_errors = bet_result.pc_error[self.valid_knee_indices]
265 | self.valid_calc_pressures = bet_result.calc_pressure[self.valid_indices]
266 | self.valid_nm = bet_result.nm[self.valid_indices]
267 |
268 |
269 | ## was needed in the plotting.py file
270 | self.pc_errors = bet_result.pc_error
271 |
272 | # Find min error and corresponding indices
273 | knee_only_filter = np.zeros([len(bet_result.pressure),len(bet_result.pressure)])
274 | knee_only_filter[self.valid_knee_indices] = 1
275 | knee_filter = filter_mask * knee_only_filter
276 | filtered_pcerrors = bet_result.pc_error + 1000.0 * (1 - filter_mask)
277 | knee_filtered_pcerrors = bet_result.pc_error + \
278 | 1000.0 * (1 - knee_filter)
279 |
280 | if self.use_rouq5:
281 | min_i, min_j = np.unravel_index(np.argmin(knee_filtered_pcerrors), filtered_pcerrors.shape)
282 | else:
283 | min_i, min_j = np.unravel_index(np.argmin(filtered_pcerrors), filtered_pcerrors.shape)
284 | self.min_i = min_i
285 | self.min_j = min_j
286 |
287 | self.compute_BET_curve()
288 |
289 | self.std_area = np.std(self.bet_areas[self.valid_indices])
290 |
291 | def compute_BET_curve(self):
292 | """Function for computing BET curve. This is separated to a different function to allow custom min_i min_j"""
293 |
294 | # Compute BET curve at min point
295 | numerator = (self.nm[self.min_i, self.min_j] *
296 | self.c[self.min_i, self.min_j] * self.x_range)
297 | denominator = (1. - self.x_range) + (1. - self.x_range)\
298 | * (self.c[self.min_i, self.min_j] - 1.) * self.x_range
299 |
300 | with np.errstate(divide='ignore'):
301 | self.bet_curve = numerator / denominator
302 |
303 | self.min_area = self.bet_areas[self.min_i, self.min_j]
304 |
305 | def find_nearest_idx(self, coords):
306 | """Finds min_i and min_j for the given monlayer error coordinates
307 | """
308 | # find the index bet areas
309 | betarea_idx = np.abs(self.valid_bet_areas - coords[0]).argmin()
310 | pcerror_idx = np.abs(self.valid_pc_errors - coords[1]).argmin()
311 |
312 | # get the indices
313 | min_i = self.valid_indices[0][betarea_idx] #+ 1
314 | min_j = self.valid_indices[1][pcerror_idx] #+ 1
315 |
316 | self.min_i = min_i
317 | self.min_j = min_j
318 |
319 | self.compute_BET_curve()
320 |
321 | def export(self, filepath):
322 | """ Write all relevant information to the directory at filepath.
323 |
324 | """
325 | filepath = Path(filepath)
326 |
327 | # Write out the filter settings used to get these results.
328 | with (filepath / 'filter_summary.json').open('w') as fp:
329 | pprint(self.filter_params, fp)
330 |
331 | # Write out the key results.
332 | with (filepath / 'results.txt').open('w') as fp:
333 | print(f"Best area has: ", file=fp)
334 | print(f"Area: {self.min_area} ", file=fp)
335 | print(
336 | f"Total points: {self.point_count[self.min_i, self.min_j]} ", file=fp)
337 | print(
338 | f"R-Squared: {self.fit_rsquared[self.min_i, self.min_j]} ", file=fp)
339 | print(
340 | f"Linear Gradient: {self.fit_grad[self.min_i, self.min_j]} ", file=fp)
341 | print(
342 | f"Intercept: {self.fit_intercept[self.min_i, self.min_j]} ", file=fp)
343 | print(
344 | f"C: {self.c[self.min_i, self.min_j]} ", file=fp)
345 | print(
346 | f"Monolayer Loading: {self.nm[self.min_i, self.min_j]} ", file=fp)
347 | print(
348 | f"Calculated Pressure: {self.calc_pressure[self.min_i, self.min_j]} ", file=fp)
349 | print(
350 | f"Read pressure: {self.corresponding_pressure_pchip[self.min_i,self.min_j]} ", file =fp)
351 | print(
352 | f"Error: {self.error[self.min_i, self.min_j]} ", file=fp)
353 |
354 | # Write out a set of csv files
355 | matrices_f = filepath / 'matrices'
356 | matrices_f.mkdir(exist_ok=True)
357 | np.savetxt(str(matrices_f / 'point_counts.csv'),
358 | self.point_count, delimiter=',', fmt='%i')
359 | np.savetxt(str(matrices_f / 'rouq1.csv'),
360 | self.rouq1, delimiter=',', fmt='%i')
361 | np.savetxt(str(matrices_f / 'rouq2.csv'),
362 | self.rouq2, delimiter=',', fmt='%i')
363 | np.savetxt(str(matrices_f / 'rouq3.csv'),
364 | self.rouq3, delimiter=',', fmt='%i')
365 | np.savetxt(str(matrices_f / 'rouq4.csv'),
366 | self.rouq4, delimiter=',', fmt='%i')
367 | np.savetxt(str(matrices_f / 'rouq5.csv'),
368 | self.rouq5, delimiter=',', fmt='%i')
369 | np.savetxt(str(matrices_f / 'bet_areas_filtered.csv'),
370 | self.bet_areas_filtered, delimiter=',', fmt='%1.3f')
371 | np.savetxt(str(matrices_f / 'bet_areas.csv'),
372 | self.bet_areas, delimiter=',', fmt='%1.3f')
373 | np.savetxt(str(matrices_f / 'fit_rsquared.csv'),
374 | self.fit_rsquared, delimiter=',', fmt='%1.3f')
375 | np.savetxt(str(matrices_f / 'fit_grad.csv'),
376 | self.fit_grad, delimiter=',', fmt='%1.3f')
377 | np.savetxt(str(matrices_f / 'fit_intercept.csv'),
378 | self.fit_intercept, delimiter=',', fmt='%1.3f')
379 | np.savetxt(str(matrices_f / 'c_value.csv'),
380 | self.c, delimiter=',', fmt='%1.3f')
381 | np.savetxt(str(matrices_f / 'nm.csv'),
382 | self.nm, delimiter=',', fmt='%1.3f')
383 | np.savetxt(str(matrices_f / 'calc_pressure.csv'),
384 | self.calc_pressure, delimiter=',', fmt='%1.3f')
385 | np.savetxt(str(matrices_f / 'error.csv'),
386 | self.error, delimiter=',', fmt='%1.3f')
387 | np.savetxt(str(matrices_f / 'pc_error.csv'),
388 | self.pc_error, delimiter=',', fmt='%1.3f')
389 |
390 |
391 | def analyse_file(input_file, output_dir=None, **kwargs):
392 | """ Entry point for performing BET analysis on a single named csv file.
393 | If the output directory does not exist, one is created automatically."""
394 |
395 | if output_dir is None:
396 | output_dir = Path(os.getcwd() + '/bet_output')
397 |
398 | if isinstance(input_file, str):
399 | input_file = Path(input_file)
400 |
401 | if isinstance(output_dir, str):
402 | output_dir = Path(output_dir)
403 |
404 | output_subdir = output_dir / input_file.name
405 | output_subdir.mkdir(exist_ok=True, parents=True)
406 |
407 | # Compute unfiltered results
408 | pressure, q_adsorbed, comments_to_data = get_data(input_file=input_file)
409 | betsi_unfiltered = BETResult(pressure, q_adsorbed)
410 |
411 | # Apply custom filters:
412 | betsi_filtered = BETFilterAppliedResults(betsi_unfiltered, **kwargs)
413 |
414 | # Export the results
415 | betsi_filtered.export(output_subdir)
416 |
417 | # Create and save a PDF plot
418 | fig = create_matrix_plot(betsi_filtered, name=input_file.stem)
419 |
420 | #fig.tight_layout(pad=0.3, rect=[0, 0, 1, 0.95])
421 | fig.savefig(
422 | str(output_subdir / f'{input_file.stem}_combined_plot.pdf'), bbox_inches='tight')
423 | #plt.tight_layout()
424 | plt.show()
425 |
426 | # Create and show Diagnostics plot
427 | fig_2 = regression_diagnostics_plots(betsi_filtered,name=input_file.stem)
428 | fig_2.tight_layout(pad=.3, rect=[0,0,1,.95])
429 | plt.show()
430 |
431 |
432 | if __name__ == "__main__":
433 | analyse_file(
434 | Path(r"/Users/johannesosterrieth/Desktop/q_nu1105.csv"))
435 |
--------------------------------------------------------------------------------
/betsi/plotting.py:
--------------------------------------------------------------------------------
1 | """
2 | Defines a series of plotting functions that can be produced directly from a filtered BET results
3 | object.
4 |
5 | The plots can all be made individually or as part of the larger 2x3 plot matrix.
6 | """
7 | from matplotlib import pyplot as plt
8 | from matplotlib.ticker import FormatStrFormatter
9 | from scipy.interpolate import splev, pchip_interpolate
10 | import numpy as np
11 | import pandas as pd
12 | import seaborn as sns
13 | import statsmodels.api as sm
14 | from statsmodels.graphics.gofplots import ProbPlot
15 | import matplotlib.gridspec as gridspec
16 | import matplotlib.font_manager
17 | import matplotlib as mpl
18 |
19 |
20 | mpl.rc('font', family='Arial',size=9)
21 |
22 | def regression_diagnostics_plots(bet_filtered, name, fig_2=None):
23 | """ Creates 4 regression diagnostics plots in 2 x 2 matrix
24 | Args:
25 | fit: Matplotlib Figure
26 | bet_filtered : A BETFilterAppliedResults object
27 | name: A string, name to give as a title.
28 |
29 | Returns:
30 | Fig, the updated matplotlib figure
31 |
32 | """
33 | # Obtaining Regression Diagnostics
34 |
35 | # Gather data and put in DF
36 | min_i = bet_filtered.min_i
37 | min_j = bet_filtered.min_j + 1
38 | p = bet_filtered.pressure
39 | lin_q = bet_filtered.linear_y
40 | P = pd.DataFrame(p)
41 | LIN_Q = pd.DataFrame(lin_q)
42 | dataframe = pd.concat([P, LIN_Q], axis=1)
43 |
44 | # Helper functions
45 |
46 | num_points = len(p)
47 |
48 | def graph(formula, x_range, label=None, ax=None):
49 | """Helper function for plotting cook Distance lines
50 | """
51 | x = x_range
52 | y = formula(x)
53 | if ax is None:
54 | plt.plot(x, y, label=label, lw=1, ls='--', color='black', alpha = 0.75)
55 | else:
56 | ax.plot(x, y, label=label, lw=1, ls='--', color='black', alpha = 0.75)
57 |
58 | # OLS regression
59 | x = sm.add_constant(p)
60 | model = sm.OLS(lin_q[min_i:min_j], x[min_i:min_j])
61 | fit = model.fit()
62 | fit_values = fit.fittedvalues
63 | fit_resid = fit.resid
64 | fit_stud_resid = fit.get_influence().resid_studentized_internal
65 | fit_stud_resid_abs_sqrt = np.sqrt(np.abs(fit_stud_resid))
66 | fit_abs_resid = np.abs(fit_resid)
67 | fit_leverage = fit.get_influence().hat_matrix_diag
68 | fit_CD = fit.get_influence().cooks_distance[0]
69 |
70 | # Make new figure
71 | if fig_2 is None:
72 | fig_2 = plt.figure(constrained_layout=False, figsize=(6.29921, 9.52756))
73 | mpl.rc('font', family='Arial',size=9)
74 | fig_2.suptitle(f"BETSI Regression Diagnostics for {name}, (Adsorbate: {bet_filtered.adsorbate})\n")
75 |
76 | # "Residual vs fitted" plot
77 | resid_vs_fit = fig_2.add_subplot(2, 2, 1)
78 | sns.residplot(fit_values, fit_resid, data=dataframe,
79 | lowess=True,
80 | scatter_kws={'alpha': .5, 'color': 'red'},
81 | line_kws={'color': 'black', 'lw': 1, 'alpha': 0.75},
82 | ax=resid_vs_fit)
83 | resid_vs_fit.axes.set
84 | resid_vs_fit.axes.set_title('Residuals vs Fitted',fontsize=11)
85 | resid_vs_fit.axes.set_xlabel('Fitted Values')
86 | resid_vs_fit.locator_params(axis='x', nbins=4)
87 | resid_vs_fit.axes.set_ylabel('Residuals')
88 | resid_vs_fit.tick_params(axis='both', which='major', labelsize=9)
89 | resid_vs_fit.tick_params(axis='both', which='minor', labelsize=9)
90 |
91 | dfit_values = (max(fit_values) - min(fit_values)) * 1
92 | resid_vs_fit.axes.set_xlim(min(fit_values) - dfit_values, max(fit_values) + dfit_values)
93 | dfit_resid = (max(fit_resid) - min(fit_resid)) * 1
94 | resid_vs_fit.axes.set_ylim(min(fit_resid) - dfit_resid, max(fit_resid) + dfit_resid)
95 |
96 | # "Normal Q-Q" plot
97 | QQ = ProbPlot(fit_stud_resid)
98 |
99 | qq_plot = QQ.qqplot(line='45',markerfacecolor='red',markeredgecolor='red',color='black', alpha=.3, lw=.5, ax=fig_2.add_subplot(2, 2, 2))
100 | qq_plot.axes[1].set_title('Normal Q-Q')
101 | qq_plot.axes[1].set_xlabel('Theoretical Quantiles')
102 | qq_plot.axes[1].set_ylabel('Studentized Residuals')
103 | qq_plot.axes[1].tick_params(axis='both', which='major')
104 | qq_plot.axes[1].tick_params(axis='both', which='minor')
105 |
106 | abs_norm_resid = np.flip(np.argsort(np.abs(fit_stud_resid)), 0)
107 | abs_norm_resid_top_3 = abs_norm_resid[:3]
108 | for r, i in enumerate(abs_norm_resid_top_3):
109 | # Add annotations
110 | qq_plot.axes[0].annotate(i, xy=(np.flip(QQ.theoretical_quantiles, 0)[r], fit_stud_resid[i]), size = 9)
111 |
112 | # "Scale-location" plot
113 | scale_loc = fig_2.add_subplot(2, 2, 3)
114 | scale_loc.scatter(fit_values, fit_stud_resid_abs_sqrt, alpha=.5, c = 'red')
115 | sns.regplot(fit_values, fit_stud_resid_abs_sqrt, scatter=False, ci=False, lowess=True, line_kws={'color': 'black', 'lw': 1, 'alpha': .75}, ax=scale_loc)
116 | scale_loc.set_title('Scale-Location')
117 | scale_loc.set_xlabel('Fitted Values')
118 | scale_loc.set_ylabel('$\mathregular{\sqrt{|Studentized\ Residuals|}}$')
119 | scale_loc.tick_params(axis='both', which='major', labelsize=9)
120 | scale_loc.tick_params(axis='both', which='minor', labelsize=9)
121 |
122 | abs_sq_norm_resid = np.flip(np.argsort(fit_stud_resid_abs_sqrt), 0)
123 | abs_sq_norm_resid_top_3 = abs_sq_norm_resid[:3]
124 | for i in abs_norm_resid_top_3:
125 | # Add annotations
126 | scale_loc.axes.annotate(i, xy=(fit_values[i], fit_stud_resid_abs_sqrt[i]), size=11)
127 |
128 | scale_loc.axes.set_xlim(min(fit_values) - .2 * max(fit_values), max(fit_values) + .2 * max(fit_values))
129 | scale_loc.locator_params(axis='x', nbins=4)
130 |
131 | # "Residuals vs leverage" plot
132 | res_vs_lev = fig_2.add_subplot(2, 2, 4)
133 | res_vs_lev.scatter(fit_leverage, fit_stud_resid, alpha=.5, color = 'red')
134 | sns.regplot(fit_leverage, fit_stud_resid, scatter=False, ci=False, lowess=True, line_kws={'color': 'black', 'lw': 1, 'alpha': .75}, ax=res_vs_lev)
135 | res_vs_lev.axes.set_title('Residuals vs Leverage')
136 | res_vs_lev.axes.set_xlabel('Leverage')
137 | res_vs_lev.axes.set_ylabel('Studentized Residuals')
138 | res_vs_lev.tick_params(axis='both', which='major')
139 | res_vs_lev.tick_params(axis='both', which='minor')
140 |
141 | leverage_top_3 = np.flip(np.argsort(fit_CD), 0)[:3]
142 | for i in leverage_top_3:
143 | # Add annotations
144 | res_vs_lev.axes.annotate(i, xy=(fit_leverage[i], fit_stud_resid[i]), size=9)
145 |
146 | p_3 = p[min_i:min_j]
147 | p_2 = len(fit.params) # number of model parameters
148 | graph(lambda p_3: np.sqrt((.5 * p_2 * (1 - p_3)) / p_3), np.linspace(.001, max(fit_leverage), 50), 'Cook\'s Distance', ax=res_vs_lev) # .5 line
149 | graph(lambda p_3: -1 * np.sqrt((.5 * p_2 * (1 - p_3)) / p_3), np.linspace(.001, max(fit_leverage), 50), ax=res_vs_lev)
150 | graph(lambda p_3: np.sqrt((1 * p_2 * (1 - p_3)) / p_3), np.linspace(.001, max(fit_leverage), 50), ax=res_vs_lev) # 1 line
151 | graph(lambda p_3: -1 * np.sqrt((1 * p_2 * (1 - p_3)) / p_3), np.linspace(.001, max(fit_leverage), 50), ax=res_vs_lev) # 1 line
152 |
153 | res_vs_lev.legend(prop={'size': 9})
154 |
155 | plt.subplots_adjust(bottom=0.07, top=0.91, hspace=.255, wspace=0.315, left=0.12, right=0.92)
156 |
157 | return fig_2
158 |
159 |
160 | def create_matrix_plot(bet_filtered, rouq3, rouq4, name, fig=None):
161 | """ Creates all 6 of the key plots in a 2x3 matrix
162 |
163 | Args:
164 | fig: Matplotlib Figure
165 | bet_filtered: A BETFilterAppliedResults object.
166 | name: A string, name to give as a title.
167 |
168 | Returns:
169 | Fig, the updated matplotlib figure
170 |
171 | """
172 | # Make Isotherm Plot
173 | if fig is None:
174 | fig = plt.figure(figsize=(6.29921, 9.52756))
175 |
176 | fig.set_size_inches(6.29921, 9.52756)
177 | fig.suptitle(f"BETSI Analysis for {name}, (Adsorbate: {bet_filtered.adsorbate})\n", fontname="Arial", fontsize = '11')
178 | fig.subplots_adjust(hspace=1.0, top=0.91, bottom=0.07, left=0.052, right=0.865, wspace=0.315)
179 |
180 | gs = gridspec.GridSpec(9, 2, figure=fig)
181 |
182 | # Plot "Adsorption isotherm"
183 | ax = fig.add_subplot(gs[:3,0])
184 | plot_isotherm(bet_filtered, ax)
185 |
186 | # Plot "Roquerol representation"
187 | ax = fig.add_subplot(gs[:3,1])
188 | plot_roquerol_representation(bet_filtered, ax)
189 |
190 | # Plot "Linear range"
191 | ax = fig.add_subplot(gs[3:6,0])
192 | plot_linear_y(bet_filtered, ax)
193 |
194 | # Plot "Filtered BET Areas"
195 | if not rouq3 and not rouq4:
196 |
197 | x_coords = bet_filtered.valid_bet_areas
198 | y_coords = bet_filtered.valid_pc_errors
199 | pc_error_higher_than_290_indexes = np.where(y_coords > 290)[0]
200 | y_coords_lower_than_290 = np.delete(y_coords, pc_error_higher_than_290_indexes)
201 | if not (len(y_coords_lower_than_290) == 0 or len(pc_error_higher_than_290_indexes) == 0):
202 | ax = fig.add_subplot(gs[3:4, 1:])
203 | ax2 = fig.add_subplot(gs[4:6, 1:])
204 | # Plots a figure with a break in the y-axis
205 | plot_area_error_1(bet_filtered, ax, ax2)
206 | else:
207 | ax = fig.add_subplot(gs[3:6,1])
208 | # Plots a figure with NO break in the y-axis
209 | plot_area_error_2(bet_filtered, ax)
210 | else:
211 | ax = fig.add_subplot(gs[3:6,1])
212 | # Plots a figure with NO break in the y-axis
213 | plot_area_error_2(bet_filtered, ax)
214 |
215 | # Plot "Filtered monolayer-loadings"
216 | ax = fig.add_subplot(gs[6:9,0])
217 | plot_monolayer_loadings(bet_filtered, ax)
218 |
219 | # Plot "Distribution of Filtered BET Areas"
220 | ax = fig.add_subplot(gs[6:9,1])
221 | plot_box_and_whisker(bet_filtered, ax)
222 |
223 | plt.tight_layout()
224 | return fig
225 |
226 |
227 | def plot_isotherm(bet_filtered, ax=None):
228 | """ Plot the Isotherm alongside the selected linear region, spline interpolation, point corresponding to lowest error and fit from BET theory.
229 |
230 | Args:
231 | bet_filtered: A BETFilterAppliedResults object.
232 | ax: Optional matplotlib axis object. If none is provided, an axis for a single subplot is made.
233 |
234 | """
235 | if ax is None:
236 | # When this is not part of a larger plot
237 | fig = plt.figure()
238 | ax = fig.add_subplot(1, 1, 1)
239 |
240 | # Set axis details
241 | mpl.rc('font', family='Arial',size=9)
242 | ax.set_title(f"Adsorption Isotherm", fontname="Arial", fontsize = '11')
243 | ax.set_xlabel(r'$\mathregular{P/P_0}$')
244 | ax.set_ylabel(r'$\mathregular{N_2 uptake}$ (STP) $\mathregular{cm^3 g^{-1}}$')
245 | ax.set_xlim([-0.1, 1.1])
246 | ax.set_ylim([0.0, max(bet_filtered.q_adsorbed)])
247 | ax.tick_params(axis='both', which='major', labelsize=9)
248 | ax.tick_params(axis='both', which='minor', labelsize=9)
249 |
250 | # Get min_i, min_j from the filtered result
251 | min_i = bet_filtered.min_i
252 | min_j = bet_filtered.min_j
253 |
254 | # Plot the isotherm itself
255 | ax.scatter(bet_filtered.pressure[:min_i], bet_filtered.q_adsorbed[:min_i], color='black', edgecolors='black', label='Adsorption Isotherm', alpha=0.50)
256 | ax.scatter(bet_filtered.pressure[min_j+1:], bet_filtered.q_adsorbed[min_j+1:], color='black', edgecolors='black',alpha = 0.50)
257 |
258 | # Plot the part corresponding to the selected linear region
259 | ax.scatter(bet_filtered.pressure[min_i:min_j + 1], bet_filtered.q_adsorbed[min_i:min_j + 1], marker='s',color='red', edgecolors='red', label='Linear Range', alpha=0.5)
260 |
261 | # plot pchip interpolation
262 | ax.plot(bet_filtered.x_range, pchip_interpolate(bet_filtered.pressure, bet_filtered.q_adsorbed, bet_filtered.x_range), color='black', alpha=.75, label='Pchip Interpolation')
263 | # plot corresponding pressure
264 | ax.scatter(bet_filtered.corresponding_pressure_pchip[min_i, min_j], bet_filtered.nm[min_i, min_j], marker='^', color='blue', edgecolor='blue', label='$\mathregular{N_m}$ Read', alpha=0.50)
265 |
266 | # Plot selected Monolayer loading (single point)
267 | ax.scatter(bet_filtered.calc_pressure[min_i, min_j], bet_filtered.nm[min_i, min_j], marker='v', color='green', edgecolors='green', edgecolor='green', label='$\mathregular{N_m}$ BET')
268 |
269 | # Plot the BET curve derived from BET theory
270 | ax.plot(bet_filtered.x_range, bet_filtered.bet_curve, c='g', label='BET Fit', alpha=.5)
271 |
272 | # Add a legend
273 | ax.autoscale(False)
274 | ax.legend(prop={'size': 9})
275 |
276 |
277 | def plot_roquerol_representation(bet_filtered, ax=None):
278 | """ Plot the Roquerol representation with points corresponding to those in the selected linear region highlighted.
279 |
280 | Args:
281 | bet_filtered: A BETFilterAppliedResults object.
282 | ax: Optional matplotlib axis object. If none is provided, an axis for a single subplot is made.
283 |
284 | """
285 |
286 | if ax is None:
287 | # When this is not part of a larger plot
288 | fig = plt.figure()
289 | ax = fig.add_subplot(1, 1, 1)
290 |
291 | # Set axis details
292 | mpl.rc('font', family='Arial',size=9)
293 | ax.set_title(f"Rouquerol Representation", fontname="Arial", fontsize = '11')
294 | ax.set_xlabel(r'$\mathregular{P/P_0}$')
295 | ax.set_ylabel(r'$\mathregular{N(1-P/P_0)}$', fontname="Arial")
296 | ax.tick_params(axis='both', which='major', labelsize=9)
297 | ax.tick_params(axis='both', which='minor', labelsize=9)
298 |
299 | # Get min_i, min_j from the filtered result
300 | min_i = bet_filtered.min_i
301 | min_j = bet_filtered.min_j
302 |
303 | # Plot the main Roquerol representation scatter
304 | ax.scatter(bet_filtered.pressure[:min_i], bet_filtered.rouq_y[:min_i], edgecolors='black', color='black', label=r'$\mathregular{N(1-P/P_0)}$', alpha=0.5)
305 | ax.scatter(bet_filtered.pressure[min_j+1:], bet_filtered.rouq_y[min_j+1:], edgecolors='black', color='black', alpha=0.5)
306 |
307 | # Plot the part corresponding to the linear region
308 | ax.scatter(bet_filtered.pressure[min_i:min_j + 1], bet_filtered.rouq_y[min_i:min_j + 1], marker='s', color='red', edgecolors='none', label='Linear Range', alpha=0.50)
309 |
310 | # Add a legend
311 | ax.legend(prop={'size': 9})
312 |
313 |
314 | def plot_linear_y(bet_filtered, ax=None):
315 | """ Plot the selected linear region of the linearised BET equation and print the formula of the
316 | straight line alongside it.
317 |
318 | Args:
319 | bet_filtered: A BETFilterAppliedResults object.
320 | ax: Optional matplotlib axis object. If none is provided, an axis for a single subplot is made.
321 |
322 | """
323 | if ax is None:
324 | # When this is not part of a larger plot:
325 | fig = plt.figure()
326 | ax = fig.add_subplot(1, 1, 1)
327 |
328 | # Set axis details
329 | mpl.rc('font', family='Arial',size=9)
330 | ax.set_title(f"Linear Range", fontname="Arial",fontsize='11')
331 | ax.set_xlabel(r'$\mathregular{P/P_0}$')
332 | ax.set_ylabel(r'$\mathregular{P/N(P_0 - P)}$', fontname="Arial", fontsize = '9')
333 | ax.tick_params(axis='both', which='major', labelsize=9)
334 | ax.tick_params(axis='both', which='minor', labelsize=9)
335 |
336 | # Get min_i, min_j from the filtered result
337 | min_i = bet_filtered.min_i
338 | min_j = bet_filtered.min_j
339 |
340 | ### Plot the points in the selected linear region
341 | ##ax.scatter(bet_filtered.pressure[min_i:min_j + 1], bet_filtered.linear_y[min_i:min_j + 1], marker='s', color='r', edgecolors='red', label='Interpolated Points', alpha = 0.50)
342 |
343 | ## Detect data points that were in the original adsorption data
344 | if bet_filtered.comments_to_data['interpolated_points_added']:
345 | # Plot the points in the selected linear region
346 | ax.scatter(bet_filtered.pressure[min_i:min_j + 1], bet_filtered.linear_y[min_i:min_j + 1], marker='s', color='green', edgecolors='green', label='Interpolated Points', alpha = 0.50)
347 |
348 | ###original_pressure_points_used = [x for x in bet_filtered.pressure[min_i:min_j + 1] if x in bet_filtered.original_pressure_data]
349 | original_pressure_points_used = [x for x in bet_filtered.original_pressure_data if bet_filtered.pressure[min_i] <= x <= bet_filtered.pressure[min_j]]
350 | original_points_indexes = []
351 | for x in original_pressure_points_used:
352 | ##original_points_indexes.append(np.where(bet_filtered.pressure[min_i:min_j + 1] == x)[0][0])
353 | original_points_indexes.append(np.where(abs((bet_filtered.pressure[min_i:min_j + 1]-x)/x) * 100 < 0.01)[0][0])
354 | original_linear_y_points_used = bet_filtered.linear_y[min_i:min_j + 1][original_points_indexes]
355 | if len(original_pressure_points_used) != 0:
356 | ax.scatter(original_pressure_points_used, original_linear_y_points_used, marker='s', color='red', edgecolors='red', label='Original Points', alpha = 0.50)
357 | # Add a legend
358 | ax.autoscale(False)
359 | ax.legend(loc=4, prop={'size': 9})
360 | else:
361 | # Plot the points in the selected linear region
362 | ax.scatter(bet_filtered.pressure[min_i:min_j + 1], bet_filtered.linear_y[min_i:min_j + 1], marker='s', color='r', edgecolors='red', label='Interpolated Points', alpha = 0.50)
363 |
364 |
365 |
366 | # Plot the straight line obtained from the linear regression
367 | largest_valid_x = max(bet_filtered.pressure[min_i:min_j + 1])
368 | intercept_at_opt = bet_filtered.fit_intercept[min_i, min_j]
369 | grad_at_opt = bet_filtered.fit_grad[min_i, min_j]
370 | highest_valid_pressure = max(bet_filtered.pressure[min_i:min_j + 1])
371 | end_y = grad_at_opt * highest_valid_pressure + intercept_at_opt
372 |
373 | ax.plot([0, largest_valid_x], [intercept_at_opt, end_y], color='black',alpha=.75)
374 |
375 | # Set the plot limits
376 | smallest_y = 0.1 * min(bet_filtered.linear_y[min_i:min_j + 1])
377 | biggest_y = 1.1 * max(bet_filtered.linear_y[min_i:min_j + 1])
378 | smallest_x = 0.1 * min(bet_filtered.pressure[min_i:min_j + 1])
379 | biggest_x = 1.1 * max(bet_filtered.pressure[min_i:min_j + 1])
380 |
381 | ax.set_ylim(smallest_y, biggest_y)
382 | ax.set_xlim(smallest_x, biggest_x)
383 |
384 | # Print the equation of the straight line.
385 | ax.yaxis.set_major_formatter(FormatStrFormatter('%.1E'))
386 | ##y_eqn = r"y = {0:.8f}$x$ + {1:.8f}".format(bet_filtered.fit_grad[min_i, min_j], bet_filtered.fit_intercept[min_i, min_j])
387 | y_eqn = r"y = {0:0.4e}$x$ + {1:0.4e}".format(bet_filtered.fit_grad[min_i, min_j], bet_filtered.fit_intercept[min_i, min_j])
388 | r_eqn = r"$R^2$ = {0:.8f}".format(bet_filtered.fit_rsquared[min_i, min_j])
389 | ax.text(0.05, 0.9, y_eqn, {'color': 'black', 'fontsize': 9}, transform=ax.transAxes)
390 | ax.text(0.05, 0.825, r_eqn, {'color': 'black', 'fontsize': 9}, transform=ax.transAxes)
391 |
392 |
393 | def plot_area_error_1(bet_filtered, ax=None, ax2=None):
394 | """ Plot the distribution of valid BET areas, highlight those ending on the `knee` and print
395 | start and end indices.
396 |
397 | Args:
398 | bet_filtered: A BETFilterAppliedResults object.
399 | ax: Optional matplotlib axis object. If none is provided, an axis for a single subplot is made.
400 |
401 | """
402 |
403 | min_i = bet_filtered.min_i
404 | min_j = bet_filtered.min_j
405 |
406 | if ax is None:
407 | # When this is not part of a larger plot
408 | fig = plt.figure()
409 | ax = fig.add_subplot(1, 1, 1)
410 |
411 | # Set axis details
412 | mpl.rc('font', family='Arial',size=9)
413 | ax.set_title('Filtered BET Areas ', fontname="Arial")
414 | ax2.set_xlabel(r'BET Area $\mathregular{m^2 g^{-1}}$', fontname="Arial", fontsize = '9')
415 | ax2.set_ylabel(r'Percentage Error %', fontname="Arial", fontsize = '9')
416 | ax2.yaxis.set_label_coords(-0.1, 0.78)
417 | ax.tick_params(axis='both', which='major', labelsize=9)
418 | ax.tick_params(axis='both', which='minor', labelsize=9)
419 | ax2.tick_params(axis='both', which='major', labelsize=9)
420 | ax2.tick_params(axis='both', which='minor', labelsize=9)
421 |
422 | x_coords = bet_filtered.valid_bet_areas
423 | y_coords = bet_filtered.valid_pc_errors
424 |
425 | pc_error_higher_than_290_indexes = np.where(y_coords > 290)[0]
426 | y_coords_lower_than_290 = np.delete(y_coords, pc_error_higher_than_290_indexes)
427 |
428 | ##x_coords_nonvalid = np.array([x for x in x_coords if x not in bet_filtered.valid_knee_bet_areas])
429 | ##y_coords_nonvalid = np.array([y for y in y_coords if y not in bet_filtered.valid_knee_pc_errors])
430 |
431 | ## Find indices that are in valid_indices but not in valid_knee_indices
432 | temp_x = []
433 | temp_y = []
434 | for i in range(len(bet_filtered.valid_indices[0])):
435 | a = [bet_filtered.valid_indices[0][i], bet_filtered.valid_indices[1][i]]
436 | temp_checker = 0
437 | for j in range(len(bet_filtered.valid_knee_indices[0])):
438 | b = [bet_filtered.valid_knee_indices[0][j], bet_filtered.valid_knee_indices[1][j]]
439 | if a==b:
440 | temp_checker = 1
441 | if temp_checker == 0:
442 | temp_x.append(a[0])
443 | temp_y.append(a[1])
444 | bet_filtered.valid_indeces_but_not_knee = np.array([temp_x,temp_y])
445 | bet_filtered.valid_indeces_but_not_knee = tuple(bet_filtered.valid_indeces_but_not_knee)
446 |
447 | x_coords_nonvalid = bet_filtered.bet_areas[bet_filtered.valid_indeces_but_not_knee]
448 | y_coords_nonvalid = bet_filtered.pc_error[bet_filtered.valid_indeces_but_not_knee]
449 |
450 |
451 | # Scatter plot of the Error across valid areas
452 |
453 | ax.scatter(x_coords_nonvalid, y_coords_nonvalid, color='red', edgecolors='red', picker=5, alpha =0.5)
454 | ax.scatter(bet_filtered.valid_knee_bet_areas, bet_filtered.valid_knee_pc_errors, color='b', edgecolors='b', marker='s', picker=5, alpha=0.5)
455 | ax.scatter(bet_filtered.bet_areas[min_i, min_j], bet_filtered.pc_error[min_i, min_j], marker='s', color='yellow', edgecolors='yellow')
456 |
457 | ax2.scatter(x_coords_nonvalid, y_coords_nonvalid, color='r', edgecolors='r', picker=5, alpha = 0.5)
458 | ax2.scatter(bet_filtered.valid_knee_bet_areas, bet_filtered.valid_knee_pc_errors, color='b', edgecolors='b', marker='s', picker=5, alpha=.5)
459 | ax2.scatter(bet_filtered.bet_areas[min_i, min_j], bet_filtered.pc_error[min_i, min_j], marker='s', color='orange', edgecolors='orange')
460 |
461 | # Axis settings
462 | ax.set_ylim(290, 310)
463 | ## define the y-axis upper limit of the lower figure (ax2)
464 | ax2.set_ylim(top=max(y_coords_lower_than_290)*1.1)
465 |
466 | ax.spines['bottom'].set_visible(False)
467 | ax2.spines['top'].set_visible(False)
468 | ax.xaxis.set_visible(False)
469 | ax.tick_params(labeltop='off')
470 | ax2.tick_params(labeltop='off')
471 | ax2.xaxis.tick_bottom()
472 |
473 | d = .015 # how big to make the diagonal lines in axes coordinates
474 | # arguments to pass plot, just so we don't keep repeating them
475 | kwargs = dict(transform=ax.transAxes, color='k', clip_on=False)
476 | ax.plot((-d, +d), (-d, +d), **kwargs) # top-left diagonal
477 | ax.plot((1 - d, 1 + d), (-d, +d), **kwargs) # top-right diagonal
478 |
479 | kwargs.update(transform=ax2.transAxes) # switch to the bottom axes
480 | ax2.plot((-d, +d), (1 - d, 1 + d), **kwargs) # bottom-left diagonal
481 | ax2.plot((1 - d, 1 + d), (1 - d, 1 + d), **kwargs) # bottom-right diagonal
482 |
483 | # Add text annotation to each error point
484 | for i, type in enumerate(x_coords):
485 | index = f"({bet_filtered.valid_indices[0][i] + 1}," \
486 | f" {bet_filtered.valid_indices[1][i] + 1})"
487 | plt.text(x_coords[i], y_coords[i], index, fontsize=7, clip_on=True)
488 |
489 |
490 | def plot_area_error_2(bet_filtered, ax=None):
491 | """ Plot the distribution of valid BET areas, highlight those ending on the `knee` and print start and end indices.
492 |
493 | Args:
494 | bet_filtered: A BETFilterAppliedResults object.
495 | ax: Optional matplotlib axis object. If none is provided, an axis for a single subplot is made.
496 |
497 | """
498 | min_i = bet_filtered.min_i
499 | min_j = bet_filtered.min_j
500 |
501 | if ax is None:
502 | # When this is not part of a larger plot
503 | fig = plt.figure()
504 | ax = fig.add_subplot(1, 1, 1)
505 |
506 | # Set axis details
507 | ax.set_title('Filtered BET Areas ', fontname="Arial")
508 | ax.set_xlabel(r'BET Area $\mathregular{m^2 g^{-1}}$', fontname="Arial", fontsize = '9')
509 | ax.set_ylabel(r'Percentage Error %', fontname="Arial", fontsize = '9')
510 | ax.tick_params(axis='both', which='major', labelsize=9)
511 | ax.tick_params(axis='both', which='major', labelsize=9)
512 |
513 | x_coords = bet_filtered.valid_bet_areas
514 | y_coords = bet_filtered.valid_pc_errors
515 | ##x_coords_nonvalid = np.array([x for x in x_coords if x not in bet_filtered.valid_knee_bet_areas])
516 | ##y_coords_nonvalid = np.array([y for y in y_coords if y not in bet_filtered.valid_knee_pc_errors])
517 |
518 | ## Find indices that are in valid_indices but not in valid_knee_indices
519 | temp_x = []
520 | temp_y = []
521 | for i in range(len(bet_filtered.valid_indices[0])):
522 | a = [bet_filtered.valid_indices[0][i], bet_filtered.valid_indices[1][i]]
523 | temp_checker = 0
524 | for j in range(len(bet_filtered.valid_knee_indices[0])):
525 | b = [bet_filtered.valid_knee_indices[0][j], bet_filtered.valid_knee_indices[1][j]]
526 | if a==b:
527 | temp_checker = 1
528 | if temp_checker == 0:
529 | temp_x.append(a[0])
530 | temp_y.append(a[1])
531 | bet_filtered.valid_indeces_but_not_knee = np.array([temp_x,temp_y])
532 | bet_filtered.valid_indeces_but_not_knee = tuple(bet_filtered.valid_indeces_but_not_knee)
533 | if len(bet_filtered.valid_indeces_but_not_knee[0]) > 0:
534 | x_coords_nonvalid = bet_filtered.bet_areas[bet_filtered.valid_indeces_but_not_knee]
535 | y_coords_nonvalid = bet_filtered.pc_error[bet_filtered.valid_indeces_but_not_knee]
536 | else:
537 | x_coords_nonvalid = np.empty(0)
538 | y_coords_nonvalid = np.empty(0)
539 |
540 |
541 | # Scatter plot of the Error across valid areas
542 | ax.scatter(x_coords_nonvalid, y_coords_nonvalid, color='red', edgecolors='red', picker=5, alpha=0.5)
543 | ax.scatter(bet_filtered.valid_knee_bet_areas, bet_filtered.valid_knee_pc_errors, color='b', edgecolors='b', marker='s', picker=5, alpha=0.50)
544 | ax.scatter(bet_filtered.bet_areas[min_i, min_j], bet_filtered.pc_error[min_i, min_j], marker='s', color='yellow', edgecolors='yellow')
545 |
546 | # Add text annotation to each error point.
547 | for i, type in enumerate(x_coords):
548 | index = f"({bet_filtered.valid_indices[0][i] + 1}," \
549 | f" {bet_filtered.valid_indices[1][i] + 1})"
550 | plt.text(x_coords[i], y_coords[i], index, fontsize=7, clip_on=True)
551 |
552 | # Set the Y-limit on the errors
553 | ax.set_ylim(0, max(y_coords) * 1.1)
554 |
555 | def plot_monolayer_loadings(bet_filtered, ax=None):
556 | """ Plot the distribution of monolayer loadings alonside the Isotherm and fitted spline.
557 |
558 | Args:
559 | bet_filtered: A BETFilterAppliedResults object.
560 | ax: Optional matplotlib axis object. If none is provided, an axis for a single subplot is made.
561 |
562 | """
563 |
564 | if ax is None:
565 | # When this is not part of a larger plot
566 | fig = plt.figure()
567 | ax = fig.add_subplot(1, 1, 1)
568 |
569 | # Set axis details
570 | mpl.rc('font', family='Arial',size=9)
571 | ax.set_title("Filtered Monolayer-Loadings", fontname="Arial")
572 | ax.set_xlabel(r'$\mathregular{P/P_0}$', fontname="Arial", fontsize = '9')
573 | ax.set_ylabel(r'$\mathregular{N_2 uptake}$ (STP) $\mathregular{cm^3 g^{-1}}$')
574 | ax.tick_params(axis='both', which='major', labelsize=9)
575 | ax.tick_params(axis='both', which='minor', labelsize=9)
576 |
577 | # Get min_i, min_j from the filtered result
578 | min_i = bet_filtered.min_i
579 | min_j = bet_filtered.min_j
580 |
581 | # Plot the Isotherm itself
582 | ax.scatter(bet_filtered.pressure[:min_i], bet_filtered.q_adsorbed[:min_i], color='black', edgecolors='black', alpha=0.5)
583 | ax.scatter(bet_filtered.pressure[min_j+1:], bet_filtered.q_adsorbed[min_j+1:], color='black', edgecolors='black', label='Adsorption Isotherm', alpha=0.5)
584 | # Plot the fitted spline
585 | ax.plot(bet_filtered.x_range, pchip_interpolate(bet_filtered.pressure, bet_filtered.q_adsorbed, bet_filtered.x_range), color='black',alpha=.5, label='Pchip Interpolation')
586 |
587 | # Plot the valid monolayer loadings.
588 | ax.scatter(bet_filtered.valid_calc_pressures, bet_filtered.valid_nm, marker='^', color='blue', edgecolors='blue', label='$\mathregular{N_m}$ valid', alpha=0.5)
589 |
590 | # Plot the valid optimum linear range
591 | ax.scatter(bet_filtered.pressure[min_i:min_j + 1], bet_filtered.q_adsorbed[min_i:min_j + 1], marker='s', color='red', edgecolors='red', label='Linear Range', alpha=0.5)
592 |
593 | # Plot the single optimum Monolayer loading
594 | ax.scatter(bet_filtered.calc_pressure[min_i, min_j], bet_filtered.nm[min_i, min_j], marker='s', color='orange', edgecolors='orange', label='N$_m$ BET')
595 |
596 | # Plot the Fit obtained from the BET equation
597 | ax.plot(bet_filtered.x_range, bet_filtered.bet_curve, c='g',alpha=.5, label='BET Fit')
598 |
599 | # Set the Xlimits and add a legend
600 | ax.set_xlim([-0.001, max(bet_filtered.valid_calc_pressures)])
601 | ax.set_ylim([0.0, max(bet_filtered.q_adsorbed)])
602 |
603 | # Add a legend
604 | ax.autoscale(False)
605 | ax.legend(loc=4, prop={'size': 9})
606 |
607 |
608 | def plot_box_and_whisker(bet_filtered, ax=None):
609 | """ Plot a box and whisker plot for the valid BET areas.
610 |
611 | Args:
612 | bet_filtered: A BETFilterAppliedResults object.
613 | ax: Optional matplotlib axis object. If none is provided, an axis for a single subplot is made.
614 |
615 | """
616 | if ax is None:
617 | # When this is not part of a larger plot:
618 | fig = plt.figure()
619 | ax = fig.add_subplot(1, 1, 1)
620 |
621 | # Set axis details.
622 | mpl.rc('font', family='Arial',size=9)
623 | ax.set_title('Distribution of Filtered BET Areas', fontname="Arial")
624 | ax.set_ylabel(r'BET Area $\mathregular{m^2 g^{-1}}$', fontname="Arial", fontsize = '9')
625 | ax.tick_params(axis='both', which='major', labelsize=9)
626 | ax.tick_params(axis='both', which='minor', labelsize=9)
627 |
628 | min_i = bet_filtered.min_i
629 | min_j = bet_filtered.min_j
630 |
631 | y_min = [bet_filtered.bet_areas[min_i, min_j]]
632 | x_min = np.random.normal(1, 0.04, 1)
633 |
634 | y = list(set(bet_filtered.valid_knee_bet_areas) - set(y_min))
635 | x = np.random.normal(1, 0.04, size=len(y))
636 |
637 | y_2 = list(set(bet_filtered.valid_bet_areas) - set(y) - set(y_min))
638 | x_2 = np.random.normal(1, 0.04, size=len(y_2))
639 |
640 | # Plot the filtered BET areas
641 | ax.scatter(x_2, y_2, alpha=0.5, color='red', edgecolor='red')
642 | ax.scatter(x, y, color='blue', edgecolor='blue', alpha=0.5)
643 | ax.scatter(x_min, y_min, marker='s', color='orange', edgecolor='orange')
644 |
645 | if len(x)==0:
646 | ax.set_xlim([.75,1.25])
647 | dy = y_min[0]*0.25
648 | ax.set_ylim(y_min[0] - dy, y_min[0] + dy)
649 | else:
650 | ax.set_xlim([.75, 1.25])
651 | dy = (max(bet_filtered.valid_bet_areas)-min(bet_filtered.valid_bet_areas)) * 1
652 | ax.set_ylim(min(bet_filtered.valid_bet_areas) - dy, max(bet_filtered.valid_bet_areas) + dy)
653 | # if len(y_2)==0:
654 | # dy = (max(y)-min(y)) * 1
655 | # ax.set_ylim(min(y) - dy, max(y) + dy)
656 | # else:
657 | # dy = (max(y_2) - min(y_2)) * 1
658 | # ax.set_ylim(min(y_2) - dy, max(y_2) + dy)
659 |
660 | # Make the boxplot of valid areas
661 | medianprops = dict(linestyle='--', linewidth=1, color='black', alpha=0.35) # median line properties
662 | ax.boxplot(bet_filtered.valid_bet_areas, showfliers=False, medianprops=medianprops)
663 | ax.set_xticks([])
664 |
665 | # Write BET area
666 | called_BET_area = """BET Area = {0:0.0f} $m^2/g$""".format(np.around((bet_filtered.bet_areas[min_i, min_j])), decimals=0, out=None)
667 | ax.text(0.05, 0.90, called_BET_area, {'color': 'black', 'fontsize': 9}, transform=ax.transAxes)
668 |
669 | points_range = f"Selected Points Range: ({min_i+1},{min_j+1})"
670 | ax.text(0.05, 0.80, points_range, {'color': 'black', 'fontsize': 9}, transform=ax.transAxes)
671 |
--------------------------------------------------------------------------------
/betsi/gui.py:
--------------------------------------------------------------------------------
1 | # basics
2 | import os
3 |
4 | from PyQt5 import QtCore
5 | from PyQt5 import QtGui
6 | # qt gui framework
7 | from PyQt5.QtWidgets import *
8 | # matplotlib for plotting
9 | import matplotlib.pyplot as plt
10 | from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
11 | from matplotlib.figure import Figure
12 | import logging
13 | import sys
14 |
15 | import traceback
16 |
17 | # core lib
18 | plt.ion()
19 | from betsi.lib import *
20 | from betsi.plotting import *
21 |
22 |
23 | class BETSI_gui(QMainWindow):
24 | """Defines the main window of the BETSI gui and adds all the toolbars.
25 | The main widget is the BETSI_widget"""
26 |
27 | def __init__(self):
28 | super().__init__()
29 |
30 | self.title = 'BETSI'
31 | self.setWindowTitle(self.title)
32 |
33 | # define the menu bar
34 | menubar = self.menuBar()
35 | fileMenu = menubar.addMenu('&File')
36 | toolsMenu = menubar.addMenu('&Tools')
37 |
38 | # option for importing a single file.
39 | importfile_menu = fileMenu.addAction('&Import File')
40 | importfile_menu.setShortcut("Ctrl+I")
41 | importfile_menu.triggered.connect(self.import_file)
42 |
43 | # option for changing the root output directory.
44 | outputdir_menu = fileMenu.addAction('&Set Output Directory')
45 | outputdir_menu.setShortcut("Ctrl+O")
46 | outputdir_menu.triggered.connect(self.set_output_dir)
47 |
48 | # option for running on a chosen directory.
49 | directoryrun_menu = fileMenu.addAction('&Analyse Directory')
50 | directoryrun_menu.setShortcut("Ctrl+D")
51 | directoryrun_menu.triggered.connect(self.analyse_dir)
52 |
53 | # option for clearing the memory and resetting to defaults
54 | clear_menu = toolsMenu.addAction('&Clear')
55 | clear_menu.setShortcut("Ctrl+C")
56 | clear_menu.triggered.connect(self.clear)
57 |
58 | # option for replotting
59 | replot_menu = toolsMenu.addAction('&Replot')
60 | replot_menu.triggered.connect(self.replot_betsi)
61 |
62 | self.betsimagic_menu_action = QAction(
63 | 'BETSI Magic', self, checkable=True)
64 | self.betsimagic_menu_action.setStatusTip(
65 | "Resets the settings to default values and performs BETSI calculation")
66 | self.betsimagic_menu_action.setChecked(False)
67 | self.betsimagic_menu_action.triggered.connect(self.trigger_betsimagic)
68 |
69 | betsimagic_menu = toolsMenu.addAction(self.betsimagic_menu_action)
70 |
71 | # define the widget
72 | self.betsi_widget = BETSI_widget(self)
73 | self.setCentralWidget(self.betsi_widget)
74 |
75 | # enable drag and drop
76 | self.setAcceptDrops(True)
77 |
78 | def set_output_dir(self):
79 | self.betsi_widget.set_output_dir()
80 |
81 | def analyse_dir(self):
82 | dir_path = QFileDialog.getExistingDirectory(
83 | self, 'Select Directory', os.getcwd())
84 | print(f'Imported directory: {Path(dir_path).name}')
85 | self.betsi_widget.analyse_directory(dir_path)
86 |
87 |
88 | def import_file(self):
89 | ## file_path = QFileDialog.getOpenFileName(
90 | ## self, 'Select File', os.getcwd(), '*.csv')[0]
91 | file_path = QFileDialog.getOpenFileName(
92 | self, 'Select File', os.getcwd(), "*.csv *.aif *.txt *.XLS")[0]
93 | self.betsi_widget.target_filepath = file_path
94 | self.betsi_widget.populate_table(csv_path=file_path)
95 | print(f'Imported file: {Path(file_path).name}')
96 |
97 | def dragEnterEvent(self, e):
98 | data = e.mimeData()
99 | urls = data.urls()
100 | drag_type = [u.scheme() for u in urls]
101 | paths = [u.toLocalFile() for u in urls]
102 | extensions = [os.path.splitext(p)[-1] for p in paths]
103 | # accept files only for now.
104 |
105 | ##if all(dt == 'file' for dt in drag_type) and all(ext == '.csv' for ext in extensions):
106 | if all(dt == 'file' for dt in drag_type) and all(ext in ['.csv', '.aif', '.txt', '.XLS'] for ext in extensions):
107 | e.accept()
108 | elif len(drag_type) == 1 and os.path.isdir(paths[0]):
109 | e.ignore()
110 | else:
111 | e.ignore()
112 |
113 | def dropEvent(self, e):
114 | data = e.mimeData()
115 | urls = data.urls()
116 | paths = [u.toLocalFile() for u in urls]
117 |
118 | # Single path to csv file
119 |
120 | ##if len(paths) == 1 and Path(paths[0]).suffix == '.csv':
121 | if len(paths) == 1 and Path(paths[0]).suffix in ['.csv', '.aif', '.txt', '.XLS']:
122 | self.betsi_widget.target_filepath = paths[0]
123 | self.betsi_widget.populate_table(csv_path=paths[0])
124 | print(f'Imported file: {Path(paths[0]).name}')
125 |
126 | self.betsi_widget.bet_object = None
127 | self.betsi_widget.bet_filter_result = None
128 |
129 | self.betsi_widget.run_calculation()
130 |
131 | def clear(self):
132 | self.betsi_widget.clear()
133 |
134 | def replot_betsi(self):
135 | self.betsi_widget.run_calculation()
136 |
137 | def trigger_betsimagic(self, state):
138 | if state:
139 | self.betsi_widget.set_defaults()
140 | self.betsi_widget.set_editable(not state)
141 |
142 | def closeEvent(self, evt):
143 | print('Closing')
144 | try:
145 | plt.close(self.bet_widget.current_fig)
146 | plt.close(self.bet_widget.current_fig_2)
147 | except:
148 | pass
149 |
150 |
151 | class BETSI_widget(QWidget):
152 | """Widget containing all the options for running the BETSI analysis and display of the data.
153 |
154 | Args:
155 | parent: QMainWindow the widget belongs to
156 |
157 | """
158 |
159 | def __init__(self, parent=None):
160 | super().__init__(parent)
161 |
162 | # basic properties
163 | self.output_dir = os.getcwd()
164 | self.current_fig = None
165 | self.current_fig_2 = None
166 |
167 | self.target_filepath = None
168 | self.bet_object = None
169 | self.bet_filter_result = None
170 |
171 | # Create label boxes to display info
172 | self.current_output_label = QLabel()
173 | self.current_targetpath_label = QLabel()
174 |
175 | self.current_output_label.setText(
176 | f"Output Directory: {self.output_dir}")
177 | self.current_targetpath_label.setText(
178 | f"Loaded File: {self.target_filepath}")
179 | self.current_output_label.setFont(QtGui.QFont("Times", 10))
180 | self.current_targetpath_label.setFont(QtGui.QFont("Times", 10))
181 |
182 | self.current_output_label.setWordWrap(True)
183 | self.current_output_label.setAlignment(QtCore.Qt.AlignLeft)
184 | #self.current_output_label.setStyleSheet("background-color: lightgreen")
185 | self.current_targetpath_label.setWordWrap(True)
186 | self.current_targetpath_label.setAlignment(QtCore.Qt.AlignLeft)
187 | #self.current_targetpath_label.setStyleSheet("background-color: lightblue")
188 |
189 | # add a group box containing controls
190 | self.criteria_box = QGroupBox("BET area selection criteria")
191 | self.criteria_box.setMaximumWidth(700)
192 | self.criteria_box.setMaximumHeight(1000)
193 | self.criteria_box.setMinimumWidth(500)
194 | #self.criteria_box.setMinimumHeight(650)
195 | self.min_points_label = QLabel(self.criteria_box)
196 | self.min_points_label.setText('Minimum number of points in the linear region: [3,10]')
197 | self.min_points_edit = QLineEdit()
198 | self.min_points_edit.setMaximumWidth(75)
199 | self.min_points_slider = QSlider(QtCore.Qt.Horizontal)
200 | self.minr2_label = QLabel('Minimum R2: [0.8,0.999]')
201 | self.minr2_edit = QLineEdit()
202 | self.minr2_edit.setMaximumWidth(75)
203 | self.minr2_slider = QSlider(QtCore.Qt.Horizontal)
204 | self.rouq1_tick = QCheckBox("Rouquerol criterion 1: Monotonic")
205 | self.rouq2_tick = QCheckBox("Rouquerol criterion 2: Positive C")
206 | self.rouq3_tick = QCheckBox("Rouquerol criterion 3: Pressure in linear range")
207 | self.rouq4_tick = QCheckBox("Rouquerol criterion 4: Error in %, [5,75]")
208 | self.rouq5_tick = QCheckBox("BETSI criterion: End at the knee")
209 | self.rouq4_edit = QLineEdit()
210 | self.rouq4_edit.setMaximumWidth(75)
211 | self.rouq4_slider = QSlider(QtCore.Qt.Horizontal)
212 |
213 | self.adsorbate_label = QLabel('Adsorbate:')
214 | self.adsorbate_combo_box = QComboBox()
215 | self.adsorbate_combo_box.addItems(["N2", "Ar", "Kr", "Xe", "Custom"])
216 | self.adsorbate_cross_section_label = QLabel('Cross sectional area (nm2):')
217 | self.adsorbate_cross_section_edit = QLineEdit()
218 | self.adsorbate_cross_section_edit.setMaximumWidth(75)
219 | self.adsorbate_molar_volume_label = QLabel('Molar volume (mol/m3):')
220 | self.adsorbate_molar_volume_edit = QLineEdit()
221 | self.adsorbate_molar_volume_edit.setMaximumWidth(75)
222 | self.note_label = QLabel(
223 | """Hints:
224 | - For convenience, it is best to first set your desired criteria before importing the input file.
225 | - Drag and drop the input file into the BETSI window.
226 | - Valid input file formats:
227 | # Adsorption Information File: *.aif
228 | # Two-column data files: *.csv, *.txt
229 | # Micromeritics: *.XLS
230 | - Valid value range for parameters are given in brackets "[ ]"
231 | - Make sure to read the warnings that may pop up after BET calculation.
232 | - After the first run, by modifiying any of the parameters above, the calculations will rerun automatically.
233 | - Regarding the minimum number of points, Rouquerol suggested 10 points, but you can lower the number if the data has insufficient number of points.
234 | - Units: "Relative pressure" is dimensionless and "Quantity adsorbed" is in (cm\u00B3 STP/g).
235 | - When the calculation is done, by clicking on any points on the "Filtered BET Areas" plot, all the plots will change to the corresponding selected points range.
236 | - If the calculation takes longer than expected, bear with it. In case you see "Not responding", it means it is still running, otherwise the software would crash.
237 | """)
238 | self.note_label.setStyleSheet("background-color: lightblue")
239 | self.note_label.setMargin(10)
240 | #self.note_label.setIndent(10)
241 | self.note_label.setWordWrap(True)
242 | self.note_label.setAlignment(QtCore.Qt.AlignLeft)
243 |
244 |
245 | self.export_button = QPushButton('Export Results')
246 | self.export_button.pressed.connect(self.export)
247 |
248 | # any change in states updates the display.
249 | self.rouq1_tick.stateChanged.connect(self.maybe_run_calculation)
250 | self.rouq2_tick.stateChanged.connect(self.maybe_run_calculation)
251 | self.rouq3_tick.stateChanged.connect(self.maybe_run_calculation)
252 | self.rouq4_tick.stateChanged.connect(self.maybe_run_calculation)
253 | self.rouq5_tick.stateChanged.connect(self.maybe_run_calculation)
254 |
255 | # define widget parameters
256 | self.rouq4_slider.setRange(5, 75)
257 | self.minr2_slider.setRange(800, 999)
258 | self.min_points_slider.setRange(3, 10)
259 |
260 | # set the defaults
261 | self.set_defaults()
262 |
263 | # connect the actions
264 | self.minr2_edit.editingFinished.connect(self.minr2_edit_changed)
265 | ##self.minr2_edit.returnPressed.connect(self.minr2_edit_changed)
266 | self.rouq4_edit.editingFinished.connect(self.rouq4_edit_changed)
267 | ##self.rouq4_edit.returnPressed.connect(self.rouq4_edit_changed)
268 | self.min_points_edit.editingFinished.connect(
269 | self.min_points_edit_changed)
270 | ##self.min_points_edit.returnPressed.connect(
271 | ## self.min_points_edit_changed)
272 | ##self.rouq4_slider.valueChanged.connect(self.rouq4_slider_changed)
273 | ##self.minr2_slider.valueChanged.connect(self.minr2_slider_changed)
274 | ##self.min_points_slider.valueChanged.connect(
275 | ## self.min_points_slider_changed)
276 |
277 |
278 | self.adsorbate_combo_box.activated.connect(self.adsorbate_combo_box_changed)
279 | ##self.adsorbate_cross_section_edit.returnPressed.connect(self.adsorbate_related_edit_changed)
280 | self.adsorbate_cross_section_edit.editingFinished.connect(self.adsorbate_related_edit_changed)
281 |
282 | self.adsorbate_molar_volume_edit.editingFinished.connect(self.adsorbate_related_edit_changed)
283 | ##self.adsorbate_molar_volume_edit.returnPressed.connect(self.adsorbate_related_edit_changed)
284 |
285 |
286 | ##self.minr2_edit.editingFinished.connect(self.line_edit_changed)
287 | ##self.rouq4_edit.editingFinished.connect(self.line_edit_changed)
288 | ##self.min_points_edit.editingFinished.connect(
289 | ## self.line_edit_changed)
290 | ##self.adsorbate_cross_section_edit.editingFinished.connect(self.line_edit_changed)
291 | ##self.adsorbate_molar_volume_edit.editingFinished.connect(self.line_edit_changed)
292 |
293 |
294 | # add the table for results
295 | self.results_table = QTableWidget(self)
296 | self.results_table.setFixedWidth(520)
297 | self.clean_table()
298 |
299 | # create layout
300 | criteria_layout = QGridLayout()
301 | criteria_layout.addWidget(
302 | self.min_points_label, criteria_layout.rowCount(), 1, 1, 1)
303 | criteria_layout.addWidget(
304 | self.min_points_edit, criteria_layout.rowCount() - 1, 2)
305 | ##criteria_layout.addWidget(
306 | ## self.min_points_slider, criteria_layout.rowCount(), 1, 1, 2)
307 | criteria_layout.addWidget(
308 | self.minr2_label, criteria_layout.rowCount(), 1)
309 | criteria_layout.addWidget(
310 | self.minr2_edit, criteria_layout.rowCount() - 1, 2)
311 | ##criteria_layout.addWidget(
312 | ## self.minr2_slider, criteria_layout.rowCount(), 1, 1, 2)
313 | criteria_layout.addWidget(
314 | self.rouq1_tick, criteria_layout.rowCount(), 1)
315 | criteria_layout.addWidget(
316 | self.rouq2_tick, criteria_layout.rowCount(), 1)
317 | criteria_layout.addWidget(
318 | self.rouq3_tick, criteria_layout.rowCount(), 1)
319 | criteria_layout.addWidget(
320 | self.rouq4_tick, criteria_layout.rowCount(), 1)
321 | criteria_layout.addWidget(
322 | self.rouq4_edit, criteria_layout.rowCount() - 1, 2)
323 | ##criteria_layout.addWidget(
324 | ## self.rouq4_slider, criteria_layout.rowCount(), 1, 1, 2)
325 | criteria_layout.addWidget(
326 | self.rouq5_tick, criteria_layout.rowCount(), 1)
327 |
328 | criteria_layout.addWidget(
329 | self.adsorbate_label, criteria_layout.rowCount(), 1)
330 | criteria_layout.addWidget(
331 | self.adsorbate_combo_box, criteria_layout.rowCount() - 1, 2)
332 | criteria_layout.addWidget(
333 | self.adsorbate_cross_section_label, criteria_layout.rowCount(), 1)
334 | criteria_layout.addWidget(
335 | self.adsorbate_cross_section_edit, criteria_layout.rowCount() - 1, 2)
336 | criteria_layout.addWidget(
337 | self.adsorbate_molar_volume_label, criteria_layout.rowCount(), 1)
338 | criteria_layout.addWidget(
339 | self.adsorbate_molar_volume_edit, criteria_layout.rowCount() - 1, 2)
340 |
341 | criteria_layout.addWidget(
342 | self.note_label, criteria_layout.rowCount(), 1, 1, 2)
343 |
344 | criteria_layout.addWidget(
345 | self.export_button, criteria_layout.rowCount(), 1, 1, 2)
346 |
347 | # criteria_layout.addWidget(
348 | # self.current_output_label, criteria_layout.rowCount(), 1, 1, 1)
349 | # criteria_layout.addWidget(
350 | # self.current_targetpath_label, criteria_layout.rowCount(), 1, 1, 1)
351 | criteria_layout.addWidget(
352 | self.current_output_label, criteria_layout.rowCount(), 1, 1, 2)
353 | criteria_layout.addWidget(
354 | self.current_targetpath_label, criteria_layout.rowCount(), 1, 1, 2)
355 |
356 |
357 |
358 | criteria_layout.addItem(QSpacerItem(
359 | 20, 40, QSizePolicy.Minimum, QSizePolicy.Expanding), criteria_layout.rowCount() + 1, 1)
360 | self.criteria_box.setLayout(criteria_layout)
361 |
362 | main_layout = QGridLayout()
363 | main_layout.addWidget(self.criteria_box, 1, 1)
364 | main_layout.addWidget(self.results_table, 1, 2)
365 |
366 | self.setLayout(main_layout)
367 |
368 | def set_output_dir(self):
369 | """Defines the output directory of the BETSI analysis"""
370 | dir_path = QFileDialog.getExistingDirectory(
371 | self, 'Select Output Directory', os.getcwd())
372 | print(f"Output directory set to {dir_path}")
373 | self.output_dir = dir_path
374 | self.current_output_label.setText(
375 | f"Output Directory: {self.output_dir}")
376 |
377 | def maybe_run_calculation(self):
378 | self.check_rouq_compatibility()
379 |
380 | self.check_adsorbate_compatibility()
381 | ##if self.target_filepath is not None:
382 | if self.target_filepath is not None:
383 | if self.adsorbate_combo_box.currentText() != "Custom":
384 | self.run_calculation()
385 | elif float(self.adsorbate_cross_section_edit.text()) > 0 and float(self.adsorbate_molar_volume_edit.text()) > 0:
386 | self.run_calculation()
387 |
388 | def plot_bet(self):
389 | # if plt.get_fignums() != [1, 2]:
390 | # plt.close('all')
391 | plt.close('all')
392 | if self.current_fig is not None:
393 | self.current_fig.clear()
394 | self.current_fig_2.clear()
395 | try:
396 | # check if the figure has been closed, if it doesn't reset it to none and replot
397 | if self.current_fig is not None and not plt.fignum_exists(self.current_fig.number):
398 | self.current_fig = None
399 | self.current_fig_2 = None
400 | fig = create_matrix_plot(self.bet_filter_result, self.rouq3_tick.isChecked(), self.rouq4_tick.isChecked(), name=Path(
401 | self.target_filepath).stem, fig=self.current_fig)
402 | fig_2 = regression_diagnostics_plots(self.bet_filter_result, name=Path(
403 | self.target_filepath).stem, fig_2=self.current_fig_2)
404 | # connect the picker event to the figure
405 | if self.current_fig is None:
406 | fig.canvas.mpl_connect('pick_event', self.point_picker)
407 | self.current_fig = fig
408 | self.current_fig.tight_layout(pad=0.3, rect=[0, 0, 1, 0.95])
409 | self.current_fig_2 = fig_2
410 | plt.figure(num=1)
411 | plt.draw()
412 | plt.figure(num=2).canvas.manager.window.move(500,0)
413 | plt.draw()
414 | except TypeError:
415 | pass
416 |
417 | def run_calculation(self):
418 | """ Applies the currently specified filters to the currently specified target csv file. """
419 |
420 | ## assert self.target_filepath, "You must provide a csv file before calling run."
421 | if not self.target_filepath:
422 | warnings = "You must provide an input file (e.g. *.csv, *.txt, *.aif, *.XLS) before calling run. Press \"Clear\" and try again. Please refer to the \"Hints\" box for a quick guide."
423 | information = ""
424 | self.show_dialog(warnings, information)
425 | return
426 |
427 | use_rouq1 = self.rouq1_tick.isChecked()
428 | use_rouq2 = self.rouq2_tick.isChecked()
429 | use_rouq3 = self.rouq3_tick.isChecked()
430 | use_rouq4 = self.rouq4_tick.isChecked()
431 | use_rouq5 = self.rouq5_tick.isChecked()
432 | min_num_pts = int(self.min_points_edit.text())
433 | min_r2 = float(self.minr2_edit.text())
434 | max_perc_error = float(self.rouq4_edit.text())
435 | adsorbate = self.adsorbate_combo_box.currentText()
436 | cross_sectional_area = float(self.adsorbate_cross_section_edit.text())
437 | molar_volume = float(self.adsorbate_molar_volume_edit.text())
438 |
439 |
440 | # Retrieve the BETSI results object if non-existent
441 | if self.bet_object is None:
442 | pressure, q_adsorbed, comments_to_data = get_data(input_file=self.target_filepath)
443 | if len(pressure) == 0 or len(q_adsorbed) == 0:
444 | warnings = "You must provide a valid input file. BETSI cannot read it. Press \"Clear\" and try again with a valid one."
445 | information = ""
446 | self.show_dialog(warnings, information)
447 | return
448 | self.bet_object = BETResult(pressure, q_adsorbed)
449 | self.bet_object.comments_to_data = comments_to_data
450 | self.bet_object.original_pressure_data = pressure
451 | self.bet_object.original_q_adsorbed_data = q_adsorbed
452 |
453 |
454 | # Apply the currently selected filters.
455 | self.bet_filter_result = BETFilterAppliedResults(self.bet_object,
456 | min_num_pts=min_num_pts,
457 | min_r2=min_r2,
458 | use_rouq1=use_rouq1,
459 | use_rouq2=use_rouq2,
460 | use_rouq3=use_rouq3,
461 | use_rouq4=use_rouq4,
462 | use_rouq5=use_rouq5,
463 | max_perc_error=max_perc_error,
464 | adsorbate=adsorbate,
465 | cross_sectional_area=cross_sectional_area,
466 | molar_volume=molar_volume)
467 |
468 | ## Adds interpolated points to adsorption data if no valid area was found by the original data
469 | if not self.bet_filter_result.has_valid_areas:
470 | iter_num = 0
471 | self.bet_object.comments_to_data['interpolated_points_added'] = True
472 | while (not self.bet_filter_result.has_valid_areas) and (iter_num < 20):
473 | print('Adding extra interpolated points to the data')
474 |
475 | ##pressure = self.bet_object.pressure
476 | ##q_adsorbed = self.bet_object.q_adsorbed
477 | comments_to_data = self.bet_object.comments_to_data
478 | interpolated_points_added = self.bet_object.comments_to_data['interpolated_points_added']
479 | original_pressure_data = self.bet_object.original_pressure_data
480 | original_q_adsorbed_data = self.bet_object.original_q_adsorbed_data
481 |
482 | self.bet_object = None
483 | self.bet_filter_result = None
484 | pressure_added_points, q_adsorbed_added_points = isotherm_pchip_reconstruction(original_pressure_data, original_q_adsorbed_data, (iter_num+1)*round(len(original_pressure_data)/1.5))
485 | self.bet_object = BETResult(pressure_added_points, q_adsorbed_added_points)
486 | self.bet_object.comments_to_data = comments_to_data
487 | self.bet_object.comments_to_data['interpolated_points_added'] = interpolated_points_added
488 | self.bet_object.original_pressure_data = original_pressure_data
489 | self.bet_object.original_q_adsorbed_data = original_q_adsorbed_data
490 |
491 | # Apply the currently selected filters.
492 | self.bet_filter_result = BETFilterAppliedResults(self.bet_object,
493 | min_num_pts=min_num_pts,
494 | min_r2=min_r2,
495 | use_rouq1=use_rouq1,
496 | use_rouq2=use_rouq2,
497 | use_rouq3=use_rouq3,
498 | use_rouq4=use_rouq4,
499 | use_rouq5=use_rouq5,
500 | max_perc_error=max_perc_error,
501 | adsorbate=adsorbate,
502 | cross_sectional_area=cross_sectional_area,
503 | molar_volume=molar_volume)
504 | iter_num += 1
505 |
506 | ## Warnings for pop-up message box
507 | warnings = ""
508 | information = ""
509 | if self.bet_object.comments_to_data['has_negative_pressure_points']:
510 | warnings += "- Imported adsorption data has negative pressure point(s)!\n"
511 | information += "- Negative pressure point(s) have been removed from the data.\n"
512 | if not self.bet_object.comments_to_data['monotonically_increasing_pressure']:
513 | warnings += "- The pressure points are not monotonically increasing!\n"
514 | information += "- Non-monotonically increasing pressure point(s) have been removed from the data.\n"
515 | if not self.bet_object.comments_to_data['rel_pressure_between_0_and_1']:
516 | warnings += "- The relative pressure values must lie between 0 and 1!\n"
517 | information += "- Relative pressure point(s) above 1.0 have been removed from the data.\n"
518 | if self.bet_object.comments_to_data['interpolated_points_added']:
519 | warnings += "- No valid areas found with the chosen minimum number of points! So, interpolated points are added to the data!\n"
520 |
521 | if self.bet_filter_result.has_valid_areas:
522 | self.plot_bet()
523 | if warnings != "":
524 | warnings = "Consider the following warning(s):\n" + warnings
525 | if information != "":
526 | information = "Note(s):\n" + information
527 | self.show_dialog(warnings, information)
528 | else:
529 | if warnings == "":
530 | warnings = "No valid areas found! Try again with a new set of data and/or change your criteria"
531 | self.show_dialog(warnings, information)
532 | else:
533 | warnings_1 = "No valid areas found! Try again with a new set of data and/or change your criteria.\n"
534 | warnings_2 = "Consider the following warning(s):\n"
535 | warnings = warnings_1 + warnings_2 + warnings
536 | information = "Note(s):\n" + information
537 | self.show_dialog(warnings, information)
538 |
539 |
540 | def show_dialog(self, warnings, information):
541 | dialog = QMessageBox()
542 | dialog.setText(warnings)
543 | dialog.setWindowTitle('Warnings')
544 | if warnings.find("No valid areas found!") != -1:
545 | dialog.setIcon(QMessageBox().Critical)
546 | dialog.setStandardButtons(QMessageBox.Ok)
547 | dialog.addButton("Clear", QMessageBox.AcceptRole)
548 | if information != "":
549 | information += "\n\nPress \"Clear\" if you want to clear input data, reset to default values and start over with a new set of data.\n"
550 | else:
551 | information = "Press \"Clear\" if you want to clear input data, reset to default values and start over with a new set of data.\n"
552 | elif warnings.find("You must provide a") != -1:
553 | dialog.setIcon(QMessageBox().Critical)
554 | dialog.addButton("Clear", QMessageBox.AcceptRole)
555 | else:
556 | dialog.setIcon(QMessageBox().Warning)
557 | dialog.setInformativeText(information)
558 |
559 | dialog.buttonClicked.connect(self.dialog_clicked)
560 | dialog.exec_()
561 |
562 | def dialog_clicked(self, dialog_button):
563 | if dialog_button.text() == "Clear":
564 | self.clear()
565 |
566 | def point_picker(self, event):
567 | line = event.artist
568 | picked_coords = line.get_offsets()[event.ind][0]
569 | # redefine min_i and min_j
570 | self.bet_filter_result.find_nearest_idx(picked_coords)
571 | # replot based on the new min_i and min_j
572 | self.plot_bet()
573 |
574 | def export(self):
575 | """ Print out the plot, filter config and results to the output directory. """
576 | if self.bet_filter_result is not None:
577 |
578 | # Create a local sub-directory for export.
579 | target_path = Path(self.target_filepath)
580 | output_subdir = Path(self.output_dir) / target_path.name
581 | output_subdir.mkdir(exist_ok=True)
582 |
583 | self.bet_filter_result.export(output_subdir)
584 |
585 | self.current_fig.savefig(str(output_subdir / f'{target_path.stem}_plot.pdf'), bbox_inches='tight')
586 | plt.show()
587 | self.current_fig_2.savefig(str(output_subdir / f'{target_path.stem}_RD_plots.pdf'))
588 | plt.show()
589 |
590 | def analyse_directory(self, dir_path):
591 | """ Run BETSI on all csv files within dir_path. Use current filter config."""
592 | use_rouq1 = self.rouq1_tick.isChecked()
593 | use_rouq2 = self.rouq2_tick.isChecked()
594 | use_rouq3 = self.rouq3_tick.isChecked()
595 | use_rouq4 = self.rouq4_tick.isChecked()
596 | use_rouq5 = self.rouq5_tick.isChecked()
597 | min_num_points = int(self.min_points_edit.text())
598 | min_r2 = float(self.minr2_edit.text())
599 | max_perc_error = float(self.rouq4_edit.text())
600 |
601 | adsorbate = self.adsorbate_combo_box.currentText()
602 | cross_sectional_area = float(self.adsorbate_cross_section_edit.text())
603 | molar_volume = float(self.adsorbate_molar_volume_edit.text())
604 |
605 |
606 | ##csv_paths = Path(dir_path).glob('*.csv')
607 | csv_paths = Path(dir_path).glob('*.csv')
608 | aif_paths = Path(dir_path).glob('*.aif')
609 | txt_paths = Path(dir_path).glob('*.txt')
610 | XLS_paths = Path(dir_path).glob('*.XLS')
611 | input_file_paths = (*csv_paths, *aif_paths, *txt_paths, *XLS_paths)
612 |
613 | ##for file_path in csv_paths:
614 | for file_path in input_file_paths:
615 | # Update the table with current file
616 | self.populate_table(csv_path=str(file_path))
617 |
618 | # Run the analysis
619 | analyse_file(input_file=str(file_path),
620 | output_dir=self.output_dir,
621 | min_num_pts=min_num_points,
622 | min_r2=min_r2,
623 | use_rouq1=use_rouq1,
624 | use_rouq2=use_rouq2,
625 | use_rouq3=use_rouq3,
626 | use_rouq4=use_rouq4,
627 | use_rouq5=use_rouq5,
628 | max_perc_error=max_perc_error,
629 | adsorbate=adsorbate,
630 | cross_sectional_area=cross_sectional_area,
631 | molar_volume=molar_volume)
632 |
633 | def set_defaults(self):
634 | """Sets the widget to default state
635 | """
636 | # set default values for the tick marks
637 | self.rouq1_tick.setCheckState(True)
638 | self.rouq2_tick.setCheckState(True)
639 | self.rouq3_tick.setCheckState(True)
640 | self.rouq4_tick.setCheckState(True)
641 | self.rouq5_tick.setCheckState(True)
642 |
643 | # the ticks can only be on or off - Not sure why I need to do this every time, but doesn't matter
644 | self.rouq1_tick.setTristate(False)
645 | self.rouq2_tick.setTristate(False)
646 | self.rouq3_tick.setTristate(False)
647 | self.rouq4_tick.setTristate(False)
648 | self.rouq5_tick.setTristate(False)
649 | # set defaults for text fields
650 | self.minr2_edit.setText('0.995')
651 | self.rouq4_edit.setText('20')
652 | self.min_points_edit.setText('10')
653 |
654 |
655 | ## set defaults for adsorbate related parameters
656 | self.adsorbate_combo_box.setCurrentIndex(0)
657 | #self.adsorbate_cross_section_edit.setText('0.0')
658 | #self.adsorbate_molar_volume_edit.setText('0.0')
659 | self.adsorbate_cross_section_edit.setText(str(cross_sectional_area[self.adsorbate_combo_box.currentText()] * 1.0E18))
660 | self.adsorbate_molar_volume_edit.setText(str(mol_vol[self.adsorbate_combo_box.currentText()]))
661 |
662 |
663 | self.line_edit_values_before = {"minr2_edit": self.minr2_edit.text(), \
664 | "rouq4_edit": self.rouq4_edit.text(), \
665 | "min_points_edit": self.min_points_edit.text(), \
666 | "adsorbate_cross_section_edit": self.adsorbate_cross_section_edit.text(), \
667 | "adsorbate_molar_volume_edit": self.adsorbate_molar_volume_edit.text()}
668 |
669 |
670 | # trigger the corresponding sliders
671 | self.rouq4_edit_changed()
672 | self.minr2_edit_changed()
673 | self.min_points_edit_changed()
674 | # check the compatibility
675 | self.check_rouq_compatibility()
676 |
677 | ## check the compatibility for adsorbate
678 | self.check_adsorbate_compatibility()
679 |
680 | # if there is a figure, replot
681 | self.plot_bet()
682 |
683 | def clear(self):
684 | """Closes all plots and removes all data from memory"""
685 | # remove the bet filter result from memory
686 | self.bet_filter_result = None
687 | self.bet_object = None
688 | # clear the table
689 | self.clean_table()
690 | # close the plot
691 | if self.current_fig is not None:
692 | plt.close(fig=self.current_fig)
693 | plt.close(fig=self.current_fig_2)
694 |
695 |
696 | ##reset the parameters to defaults
697 | self.target_filepath = None
698 | self.current_targetpath_label.setText(
699 | f"Loaded File: {self.target_filepath}")
700 | self.set_defaults()
701 |
702 | def set_editable(self, state):
703 | if state:
704 | self.rouq1_tick.setEnabled(True)
705 | self.rouq2_tick.setEnabled(True)
706 | self.rouq3_tick.setEnabled(True)
707 | self.rouq4_tick.setEnabled(True)
708 | self.rouq5_tick.setEnabled(True)
709 | self.minr2_edit.setEnabled(True)
710 | self.rouq4_edit.setEnabled(True)
711 | self.min_points_edit.setEnabled(True)
712 | self.rouq4_slider.setEnabled(True)
713 | self.minr2_slider.setEnabled(True)
714 | self.min_points_slider.setEnabled(True)
715 | self.adsorbate_combo_box.setEnabled(True)
716 | self.adsorbate_cross_section_edit.setEnabled(True)
717 | self.adsorbate_molar_volume_edit.setEnabled(True)
718 | else:
719 | self.rouq1_tick.setEnabled(False)
720 | self.rouq2_tick.setEnabled(False)
721 | self.rouq3_tick.setEnabled(False)
722 | self.rouq4_tick.setEnabled(False)
723 | self.rouq5_tick.setEnabled(False)
724 | self.minr2_edit.setEnabled(False)
725 | self.rouq4_edit.setEnabled(False)
726 | self.min_points_edit.setEnabled(False)
727 | self.rouq4_slider.setEnabled(False)
728 | self.minr2_slider.setEnabled(False)
729 | self.min_points_slider.setEnabled(False)
730 | self.adsorbate_combo_box.setEnabled(False)
731 | self.adsorbate_cross_section_edit.setEnabled(False)
732 | self.adsorbate_molar_volume_edit.setEnabled(False)
733 |
734 | def check_rouq_compatibility(self):
735 | use_rouq1 = self.rouq1_tick.isChecked()
736 | use_rouq2 = self.rouq2_tick.isChecked()
737 | use_rouq3 = self.rouq3_tick.isChecked()
738 | use_rouq4 = self.rouq4_tick.isChecked()
739 | use_rouq5 = self.rouq5_tick.isChecked()
740 | if not (use_rouq3 or use_rouq4):
741 | self.rouq2_tick.setEnabled(True)
742 | else:
743 | self.rouq2_tick.setChecked(True)
744 | self.rouq2_tick.setEnabled(False)
745 |
746 | def check_adsorbate_compatibility(self):
747 | if self.adsorbate_combo_box.currentText() != "Custom":
748 | self.adsorbate_cross_section_edit.setEnabled(False)
749 | self.adsorbate_molar_volume_edit.setEnabled(False)
750 | self.adsorbate_cross_section_edit.setText("{0:0.3f}".format(cross_sectional_area[self.adsorbate_combo_box.currentText()] * 1.0E18))
751 | self.adsorbate_molar_volume_edit.setText("{0:0.3f}".format(mol_vol[self.adsorbate_combo_box.currentText()]))
752 | #self.adsorbate_cross_section_edit.setText("0.0")
753 | #self.adsorbate_molar_volume_edit.setText("0.0")
754 | else:
755 | self.adsorbate_cross_section_edit.setEnabled(True)
756 | self.adsorbate_molar_volume_edit.setEnabled(True)
757 |
758 | def populate_table(self, csv_path):
759 | self.results_table.setColumnCount(2)
760 | self.results_table.setRowCount(1)
761 | self.results_table.setHorizontalHeaderLabels(
762 | ['Relative pressure (p/p\u2080)', 'Quantity adsorbed (cm\u00B3/g)'])
763 | self.results_table.setColumnWidth(0, 250)
764 | self.results_table.setColumnWidth(1, 250)
765 |
766 | # Change the box title
767 | self.current_targetpath_label.setText(
768 | f"Loaded File: {self.target_filepath}")
769 |
770 | if csv_path is not None and Path(csv_path).exists():
771 | pressure, q_adsorbed, comments_to_data = get_data(input_file=csv_path)
772 | self.results_table.setRowCount(len(pressure))
773 | for i in range(len(pressure)):
774 | self.results_table.setItem(
775 | i, 0, QTableWidgetItem(str(pressure[i])))
776 | self.results_table.setItem(
777 | i, 1, QTableWidgetItem(str(q_adsorbed[i])))
778 |
779 | def clean_table(self):
780 | """Cleans the table"""
781 | self.results_table.setColumnCount(2)
782 | self.results_table.setRowCount(0)
783 | self.results_table.setHorizontalHeaderLabels(
784 | ['Relative pressure (p/p\u2080)', 'Quantity adsorbed (cm\u00B3 STP/g)'])
785 | self.results_table.setColumnWidth(0, 250)
786 | self.results_table.setColumnWidth(1, 250)
787 |
788 |
789 | def minr2_edit_changed(self):
790 | value = self.minr2_edit.text()
791 | mn = self.minr2_slider.minimum()
792 | mx = self.minr2_slider.maximum()
793 | try:
794 | value = int(round(float(value) * 1000))
795 | if value < mn:
796 | value = mn
797 | self.minr2_edit.setText(str(value / 1000))
798 | elif value > mx:
799 | value = mx
800 | self.minr2_edit.setText(str(value / 1000))
801 | self.minr2_slider.setValue(value)
802 | except (ValueError, TypeError):
803 | self.minr2_edit.setText('0.995')
804 | if self.line_edit_values_before["minr2_edit"] != self.minr2_edit.text():
805 | self.line_edit_values_before["minr2_edit"] = self.minr2_edit.text()
806 | self.maybe_run_calculation()
807 |
808 | def rouq4_edit_changed(self):
809 | value = self.rouq4_edit.text()
810 | mn = self.rouq4_slider.minimum()
811 | mx = self.rouq4_slider.maximum()
812 | try:
813 | value = int(float(value))
814 | if value < mn:
815 | value = mn
816 | self.rouq4_edit.setText(str(value))
817 | if value > mx:
818 | value = mx
819 | self.rouq4_edit.setText(str(value))
820 | self.rouq4_slider.setValue(value)
821 | except (ValueError, TypeError):
822 | self.rouq4_edit.setText('20')
823 | if self.line_edit_values_before["rouq4_edit"] != self.rouq4_edit.text():
824 | self.line_edit_values_before["rouq4_edit"] = self.rouq4_edit.text()
825 | self.maybe_run_calculation()
826 |
827 | def min_points_edit_changed(self):
828 | value = self.min_points_edit.text()
829 | mn = self.min_points_slider.minimum()
830 | mx = self.min_points_slider.maximum()
831 | try:
832 | value = int(round(float(value)))
833 | if value < mn:
834 | value = mn
835 | self.min_points_edit.setText(str(value))
836 | if value > mx:
837 | value = mx
838 | self.min_points_edit.setText(str(value))
839 | self.min_points_slider.setValue(value)
840 | except (ValueError, TypeError):
841 | self.min_points_edit.setText('10')
842 | if self.line_edit_values_before["min_points_edit"] != self.min_points_edit.text():
843 | self.line_edit_values_before["min_points_edit"] = self.min_points_edit.text()
844 | self.maybe_run_calculation()
845 |
846 | def rouq4_slider_changed(self):
847 | value = self.rouq4_slider.value()
848 | self.rouq4_edit.setText(str(value))
849 | self.maybe_run_calculation()
850 |
851 | def minr2_slider_changed(self):
852 | value = self.minr2_slider.value()
853 | self.minr2_edit.setText(str(value / 1000))
854 | self.maybe_run_calculation()
855 |
856 | def min_points_slider_changed(self):
857 | value = self.min_points_slider.value()
858 | self.min_points_edit.setText(str(value))
859 | self.maybe_run_calculation()
860 |
861 | def adsorbate_related_edit_changed(self):
862 | if not self.is_float(self.adsorbate_cross_section_edit.text()):
863 | self.adsorbate_cross_section_edit.setText("0.0")
864 | if not self.is_float(self.adsorbate_molar_volume_edit.text()):
865 | self.adsorbate_molar_volume_edit.setText("0.0")
866 | if self.line_edit_values_before["adsorbate_cross_section_edit"] != self.adsorbate_cross_section_edit.text() or \
867 | self.line_edit_values_before["adsorbate_molar_volume_edit"] != self.adsorbate_molar_volume_edit.text():
868 | self.line_edit_values_before["adsorbate_cross_section_edit"] = self.adsorbate_cross_section_edit.text()
869 | self.line_edit_values_before["adsorbate_molar_volume_edit"] = self.adsorbate_molar_volume_edit.text()
870 | self.maybe_run_calculation()
871 |
872 | def adsorbate_combo_box_changed(self):
873 | if self.adsorbate_combo_box.currentText() == "Custom":
874 | self.adsorbate_cross_section_edit.setText("0.0")
875 | self.adsorbate_molar_volume_edit.setText("0.0")
876 | self.maybe_run_calculation()
877 |
878 | def line_edit_changed(self):
879 | # modify_check = []
880 | # modify_check.append(self.minr2_edit.isModified())
881 | # modify_check.append(self.rouq4_edit.isModified())
882 | # modify_check.append(self.min_points_edit.isModified())
883 | # modify_check.append(self.adsorbate_cross_section_edit.isModified())
884 | # modify_check.append(self.adsorbate_molar_volume_edit.isModified())
885 | current_line_edit_values = self.get_line_edit_current_values()
886 | if self.line_edit_values_before != current_line_edit_values:
887 | self.maybe_run_calculation()
888 | self.line_edit_values_before = current_line_edit_values
889 |
890 | def get_line_edit_current_values(self):
891 | current_line_edit_values = []
892 | current_line_edit_values.append(self.minr2_edit.text())
893 | current_line_edit_values.append(self.rouq4_edit.text())
894 | current_line_edit_values.append(self.min_points_edit.text())
895 | current_line_edit_values.append(self.adsorbate_cross_section_edit.text())
896 | current_line_edit_values.append(self.adsorbate_molar_volume_edit.text())
897 | return current_line_edit_values
898 |
899 | def is_float(self, string):
900 | try:
901 | float(string)
902 | return True
903 | except ValueError:
904 | return False
905 |
906 | def __del__(self):
907 | try:
908 | plt.close(self.current_fig_2)
909 | plt.close(self.current_fig)
910 | except:
911 | pass
912 |
913 | class OutputCanvas(FigureCanvas):
914 | def __init__(self, parent, dpi=100):
915 | self.fig = Figure(dpi=dpi)
916 | self.fig.subplots_adjust(left=0.05, right=0.95)
917 | FigureCanvas.__init__(self, self.fig)
918 | self.setParent(parent)
919 | FigureCanvas.setSizePolicy(self,
920 | QSizePolicy.Expanding,
921 | QSizePolicy.Expanding)
922 | FigureCanvas.updateGeometry(self)
923 |
924 | class OutputCanvas_2(FigureCanvas):
925 | def __init__(self,parent,dpi=100):
926 | self.fig_2 = Figure(dpi=dpi)
927 | self.fig_2.subplots_adjust(left=.05, right=.95)
928 | FigureCanvas.__init__(self,self.fig_2)
929 | self.setParent(parent)
930 | FigureCanvas.setSizePolicy(self,
931 | QSizePolicy.Expanding,
932 | QSizePolicy.Expanding)
933 | FigureCanvas.updateGeometry(self)
934 |
935 | class OutLog(logging.Handler):
936 | def __init__(self, out_widget):
937 | """(edit, out=None, color=None) -> can write stdout, stderr to a
938 | QTextEdit.
939 | """
940 | logging.Handler.__init__(self)
941 | self.out_widget = out_widget
942 |
943 | def emit(self, message):
944 | self.write(message.getMessage() + '\n')
945 |
946 | def write(self, m):
947 | self.out_widget.moveCursor(QtGui.QTextCursor.End)
948 | self.out_widget.insertPlainText(m)
949 |
950 |
951 | def runbetsi():
952 | QApplication.setAttribute(QtCore.Qt.AA_EnableHighDpiScaling, True)
953 | app = QApplication(sys.argv)
954 | ex = BETSI_gui()
955 | ex.show()
956 | sys.exit(app.exec_())
957 |
958 | runbetsi()
--------------------------------------------------------------------------------