-
Notifications
You must be signed in to change notification settings - Fork 0
/
scandata.py
292 lines (239 loc) · 9.73 KB
/
scandata.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
from ast import literal_eval
from dataclasses import dataclass, field
from pathlib import Path
import os
from matplotlib import pyplot as plt
import numpy as np
import pandas as pd
from scipy.signal import savgol_filter, find_peaks
DATA_DIR = Path(r'C:\Users\QT3\Documents\EDUAFM')
# VOLTS_PER_NM_CF = 0.6363846153846155 / 114
VOLTS_PER_NM_CF = None
# VOLTS_PER_NM_CH = 0.10349999999999998 / 114
VOLTS_PER_NM_CH = None
@dataclass
class ScanData:
"""
This is a class for image analysis of an afm scan.
Attributes:
name: name of the scan
width: width of the scan in microns
res: resolution of the scan in pixels. 250 means 250 x 250 px
speed: speed of the scan in pixels per second
mode: mode of the scan. either constant force or constant height
straingauge: strain gauge on or off
pid: pid values of the scan. (p, i, d)
lateral: lateral force on or off
backward: forward or backward
data: scan data
data_slice: one horizontal sweep of the scan
cf_calibrate: calibration factor in constant force mode
ch_calibrate: calibration factor in constant height mode
"""
name: str = 'Sample'
width: float = 20.
res: int = 250
speed: int = 100
mode: str = 'ConstantForce'
straingauge: bool = False
pid: tuple = (0.1, 0.1, 0.1)
lateral: bool = False
backward: bool = False
data: np.ndarray = field(default_factory=list)
data_slice: np.ndarray = field(default_factory=list)
cf_calibrate: float = VOLTS_PER_NM_CF
ch_calibrate: float = VOLTS_PER_NM_CH
def create_from_filepath(self, file_path):
"""
This method takes an AFM data filepath and modifies the instance of ScanData to store parameter data.
:param str file_path: file path of the afm scan
:return: self
"""
# Split file name into string list of parameters
file_name = os.path.basename(file_path)
file_info = str(file_name).split('.csv')[0].split('_')
for infostring in file_info:
for key in self.__dict__:
if key.lower() in infostring.lower():
# case for boolean attributes
if key.lower() == infostring.lower():
val = not self.__dict__[key]
else:
val = infostring.lower().replace(key.lower(), '')
# case for tuple
if isinstance(self.__dict__[key], tuple):
val = literal_eval(val)
else:
val = type(self.__dict__[key])(val)
self.__dict__[key] = val
with open(file_path, 'r') as f:
df = pd.read_csv(f, delimiter=';', header=None)
self.data = df.to_numpy()[:, :-1].astype(float)
return self
def plot_afm_image(self):
"""
This method plots the afm scan data array as a color plot.
:return: self
"""
fig, ax = plt.subplots(1, 1)
cax = ax.imshow(self.data, extent=[0, self.width, self.width, 0])
fig.colorbar(cax)
ax.set_title('AFM Scan Voltage Image of' + self.name)
ax.set_xlabel('x [microns]')
ax.set_ylabel('y [microns]')
# plt.show()
return self
def get_edge(self, y_coord=None):
"""
This method picks out a single horizontal sweep of the scan and assigns it to the data_slice attribute.
:param float y_coord: y coordinate of the sweep of interest. default is the midpoint of the scan.
:return: self
"""
ppm = self.res / self.width
if y_coord is None:
y_coord = self.width / 2
slice_pos = int(y_coord * ppm)
self.data_slice = self.data[slice_pos, :]
return self
def plot_edge(self):
"""
This method plots the sweep of the scan selected in the get_edge() method.
:return: self
"""
fig, ax = plt.subplots(1, 1)
ax.plot(np.linspace(0, self.width, self.res), self.data_slice)
ax.set_title('Horizontal Slice of ' + self.name)
ax.set_xlabel('x [microns]')
ax.set_ylabel('height [nm]')
return self
def volt_to_height(self):
"""
This method converts voltage to height.
:return: self
"""
if self.mode.lower() == 'constantforce':
volts_per_nm = self.cf_calibrate
else:
volts_per_nm = self.ch_calibrate
set_zero = abs(self.data_slice - np.max(self.data_slice))
self.data_slice = np.divide(set_zero, volts_per_nm)
return self
def tilt_correct(self, linear_coords=None):
"""
This method corrects for a linear tilt in the scan data.
:param tuple linear_coords: start and end coordinates of the linear region of a sweep.
:return: self
"""
ppm = self.res / self.width
if linear_coords is None:
start, end = 0, self.res
else:
start, end = int(linear_coords[0]*ppm), int(linear_coords[1]*ppm)
y = self.data_slice[start:end]
x = np.linspace(0, self.width, len(y))
A = np.vstack([x, np.ones(len(x))]).T
# Calculate the linear fit (m: slope, c: intercept)
m, c = np.linalg.lstsq(A, y, rcond=None)[0]
linear_fit = np.interp(np.linspace(0, self.width, self.res), x, m * x + c)
subtracted_data = self.data_slice - linear_fit
self.data_slice = subtracted_data - subtracted_data.min()
return self
def denoise(self):
"""
This method reduces noise in a sweep of the scan.
:return: self
"""
smooth = savgol_filter(self.data_slice, 8, 1)
return smooth
def find_features(self):
"""
This method finds maxima and minima in a sweep of the scan and prints them as lists.
:return: self
"""
ppm = self.res / self.width
peaks, _ = find_peaks(self.denoise())
valleys, _ = find_peaks(-self.denoise())
print('Maxima: ' + str(peaks / ppm), 'Minima: ' + str(valleys / ppm), sep='\n')
return self
def get_step_width(self, step_coords):
"""
This method calculates the width of a step in a sweep of the scan.
:param tuple step_coords: x-coordinates of the top and bottom of the step.
:return: step_width
:rtype: float
"""
ppm = self.res / self.width
# index of top and bottom of step
top, bottom = int(step_coords[0]*ppm), int(step_coords[1]*ppm)
top_val = self.data_slice[top]
bottom_val = self.data_slice[bottom]
# nintey and ten percent values
ninety = (top_val - bottom_val) * 0.9
ten = (top_val - bottom_val) * 0.1
# closest index of 90-10 values
pos_ninety = np.argmin(abs(self.data_slice[min(top, bottom):max(top, bottom)] - ninety))
pos_ten = np.argmin(abs(self.data_slice[min(top, bottom):max(top, bottom)] - ten))
step_width = abs(pos_ninety - pos_ten) / ppm
return step_width
def get_noise(self, flat_coords):
"""
This method calculates the rms deviation of a sweep of the scan.
:param tuple flat_coords: start and end coordinates of the flat region in a sweep.
:return : rms of the sweep
:rtype: float
"""
ppm = self.res / self.width
# index of start and end of flat region
start, end = int(flat_coords[0]*ppm), int(flat_coords[1]*ppm)
flat_region = self.data_slice[start:end]
rms = np.std(flat_region)
return rms
def get_scan_data_from_directory(folder_name, includes=None, excludes=None):
"""
This function takes a folder name and creates a ScanData instance for every allowed file the folder.
:param str folder_name: name of the folder
:param str includes: keywords in the file name to include
:param str excludes: keywords in the file name to exclude
:return: list of ScanData instances
:rtype: list
"""
folder_path = os.path.join(DATA_DIR, folder_name)
filepaths = []
for file_name in os.listdir(folder_path):
if file_name.endswith('.csv'):
file_path = os.path.join(folder_path, file_name)
if (excludes is None and includes is None
or excludes is not None and all(exclude not in file_name for exclude in excludes)
or includes is not None and all(include in file_name for include in includes)
or excludes is not None and includes is not None
and all(exclude not in file_name for exclude in excludes)
and all(include in file_name for include in includes)):
filepaths.append(file_path)
scan_data = []
for path in filepaths:
scan = ScanData()
scan_data.append(scan.create_from_filepath(path))
return scan_data
def get_step_volts_for_calibration(data):
"""
This function takes a list of arrays and calculates the average peak to peak voltage for height calibration.
:param list data: horizontal slices of the scan data
:return: mean peak to peak voltage of the step
:rtype: float
"""
step_volts = []
for scan in data:
step_volts.append(np.ptp(scan))
return np.mean(step_volts)
if __name__ == '__main__':
myhair = get_scan_data_from_directory('FunScans')
fig, ax = plt.subplots(1, 1)
coords = [7.5, 7.5, 4., 4.]
for scan, coord in zip(myhair, coords):
scan.plot_afm_image().get_edge(y_coord=coord).volt_to_height().tilt_correct()
ax.plot(np.linspace(0, scan.width, scan.res), scan.data_slice)
ax.set_title('Slice of My Hair')
ax.set_xlabel('x [microns]')
ax.set_ylabel('height [nm]')
ax.grid(True)
plt.show()