gratinglab 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gratinglab/__init__.py +35 -0
- gratinglab/checks.py +263 -0
- gratinglab/compare.py +233 -0
- gratinglab/convergence.py +455 -0
- gratinglab/geometry.py +483 -0
- gratinglab/gui/__init__.py +0 -0
- gratinglab/gui/app.py +80 -0
- gratinglab/gui/diagram.py +699 -0
- gratinglab/gui/diagram3d.py +609 -0
- gratinglab/gui/docs.py +189 -0
- gratinglab/gui/icon.py +101 -0
- gratinglab/gui/integral_options.py +219 -0
- gratinglab/gui/mathtext.py +209 -0
- gratinglab/gui/orders.py +152 -0
- gratinglab/gui/provenance.py +273 -0
- gratinglab/gui/qt/__init__.py +20 -0
- gratinglab/gui/qt/geometry_panel.py +239 -0
- gratinglab/gui/qt/geometry_tab.py +387 -0
- gratinglab/gui/qt/integral_tab.py +447 -0
- gratinglab/gui/qt/main_window.py +514 -0
- gratinglab/gui/qt/profile_plot_panel.py +67 -0
- gratinglab/gui/qt/scalar_tab.py +498 -0
- gratinglab/gui/qt/scene3d_panel.py +347 -0
- gratinglab/gui/qt/setup_tab.py +215 -0
- gratinglab/gui/qt/sizing.py +25 -0
- gratinglab/gui/qt/theory_viewer.py +89 -0
- gratinglab/gui/qt/worker.py +203 -0
- gratinglab/gui/richtext.py +463 -0
- gratinglab/gui/scalar_options.py +139 -0
- gratinglab/gui/state.py +379 -0
- gratinglab/illumination.py +212 -0
- gratinglab/io/__init__.py +0 -0
- gratinglab/io/efficiency_table.py +382 -0
- gratinglab/io/ggp.py +248 -0
- gratinglab/materials/__init__.py +218 -0
- gratinglab/materials/data/SOURCES.md +53 -0
- gratinglab/materials/fresnel.py +282 -0
- gratinglab/materials/optical.py +319 -0
- gratinglab/materials/refractiveindex.py +306 -0
- gratinglab/metrology/__init__.py +121 -0
- gratinglab/metrology/analyzer.py +875 -0
- gratinglab/metrology/boundary/__init__.py +27 -0
- gratinglab/metrology/boundary/average.py +143 -0
- gratinglab/metrology/boundary/pipeline.py +211 -0
- gratinglab/metrology/cli.py +52 -0
- gratinglab/metrology/config.py +170 -0
- gratinglab/metrology/core/__init__.py +23 -0
- gratinglab/metrology/core/analysis.py +315 -0
- gratinglab/metrology/core/image_flatten.py +131 -0
- gratinglab/metrology/core/processing.py +344 -0
- gratinglab/metrology/core/tip.py +233 -0
- gratinglab/metrology/data/__init__.py +16 -0
- gratinglab/metrology/data/aggregation.py +193 -0
- gratinglab/metrology/gui/__init__.py +56 -0
- gratinglab/metrology/gui/icon.py +136 -0
- gratinglab/metrology/gui/qt/__init__.py +19 -0
- gratinglab/metrology/gui/qt/app.py +28 -0
- gratinglab/metrology/gui/qt/boundary_view.py +324 -0
- gratinglab/metrology/gui/qt/canvas.py +151 -0
- gratinglab/metrology/gui/qt/efficiency_dialog.py +273 -0
- gratinglab/metrology/gui/qt/import_view.py +380 -0
- gratinglab/metrology/gui/qt/main_window.py +398 -0
- gratinglab/metrology/gui/qt/wiki_view.py +99 -0
- gratinglab/metrology/gui/qt/worker.py +59 -0
- gratinglab/metrology/gui/state.py +168 -0
- gratinglab/metrology/io/__init__.py +8 -0
- gratinglab/metrology/io/file_io.py +253 -0
- gratinglab/metrology/io/metrics.py +38 -0
- gratinglab/metrology/io/spm.py +210 -0
- gratinglab/metrology/settings.py +216 -0
- gratinglab/metrology/stats/__init__.py +16 -0
- gratinglab/metrology/stats/analysis.py +329 -0
- gratinglab/metrology/stats/icc.py +128 -0
- gratinglab/metrology/visualization/__init__.py +16 -0
- gratinglab/metrology/visualization/diagnostics.py +111 -0
- gratinglab/metrology/visualization/profiles.py +234 -0
- gratinglab/metrology/visualization/statistics.py +412 -0
- gratinglab/metrology/wiki/__init__.py +91 -0
- gratinglab/metrology/wiki/facet-fitting.md +95 -0
- gratinglab/metrology/wiki/file-formats.md +116 -0
- gratinglab/metrology/wiki/flattening.md +105 -0
- gratinglab/metrology/wiki/icc-correction.md +111 -0
- gratinglab/metrology/wiki/overview.md +51 -0
- gratinglab/metrology/wiki/reading-outputs.md +154 -0
- gratinglab/metrology/wiki/row-groups.md +75 -0
- gratinglab/metrology/wiki/tip-correction.md +101 -0
- gratinglab/metrology/workflows.py +383 -0
- gratinglab/problem.py +79 -0
- gratinglab/profiles.py +531 -0
- gratinglab/resolution.py +198 -0
- gratinglab/result.py +268 -0
- gratinglab/solvers/__init__.py +34 -0
- gratinglab/solvers/base.py +207 -0
- gratinglab/solvers/integral/__init__.py +406 -0
- gratinglab/solvers/integral/_boundary.py +50 -0
- gratinglab/solvers/integral/_core.py +126 -0
- gratinglab/solvers/integral/_finite.py +396 -0
- gratinglab/solvers/integral/_greens.py +234 -0
- gratinglab/solvers/integral/_kernels.py +308 -0
- gratinglab/solvers/integral/_nystrom.py +474 -0
- gratinglab/solvers/scalar.py +1144 -0
- gratinglab-0.1.0.dist-info/METADATA +319 -0
- gratinglab-0.1.0.dist-info/RECORD +107 -0
- gratinglab-0.1.0.dist-info/WHEEL +5 -0
- gratinglab-0.1.0.dist-info/entry_points.txt +4 -0
- gratinglab-0.1.0.dist-info/licenses/LICENSE +28 -0
- gratinglab-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core data processing functions for AFM analysis
|
|
3
|
+
"""
|
|
4
|
+
import numpy as np
|
|
5
|
+
from scipy.signal import find_peaks
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def load_afm_data(filename, default_scan_size=2.0, settings=None):
|
|
9
|
+
"""
|
|
10
|
+
Load AFM data, whatever format it arrived in.
|
|
11
|
+
|
|
12
|
+
Dispatches on content rather than on the caller knowing: raw Nanoscope files
|
|
13
|
+
(.spm, and the extensionless companions Nanoscope also writes) are read
|
|
14
|
+
directly; everything else takes the text path used for Gwyddion exports.
|
|
15
|
+
|
|
16
|
+
Dispatching here rather than at each call site is deliberate - there are six
|
|
17
|
+
callers, and none of them should have to care.
|
|
18
|
+
|
|
19
|
+
Parameters:
|
|
20
|
+
settings: optional AnalysisSettings, consulted for which .spm channel and
|
|
21
|
+
scan direction to read. Ignored for text files.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
data: 2D array of height data, in metres
|
|
25
|
+
scan_x_size: scan width in microns
|
|
26
|
+
"""
|
|
27
|
+
from .. import io as _io # noqa: F401 - keeps the package import explicit
|
|
28
|
+
from ..io.spm import (DEFAULT_CHANNEL, DEFAULT_DIRECTION, is_nanoscope_file,
|
|
29
|
+
read_spm)
|
|
30
|
+
|
|
31
|
+
if is_nanoscope_file(filename):
|
|
32
|
+
channel = getattr(settings, 'spm_channel', None) or DEFAULT_CHANNEL
|
|
33
|
+
direction = getattr(settings, 'spm_direction', None) or DEFAULT_DIRECTION
|
|
34
|
+
data, scan_x_size = read_spm(filename, channel=channel,
|
|
35
|
+
direction=direction)
|
|
36
|
+
print(f" Nanoscope file: {channel} / {direction}, "
|
|
37
|
+
f"{data.shape[0]}x{data.shape[1]} px, {scan_x_size:g} µm")
|
|
38
|
+
return data, scan_x_size
|
|
39
|
+
|
|
40
|
+
return _load_text(filename, default_scan_size)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _load_text(filename, default_scan_size=2.0):
|
|
44
|
+
"""
|
|
45
|
+
Load a text export (Gwyddion and similar), reading the scan size from the
|
|
46
|
+
header comments when they carry it.
|
|
47
|
+
|
|
48
|
+
Unchanged behaviour - this is the original load_afm_data body, moved so the
|
|
49
|
+
dispatcher above stays readable. The stored baselines depend on it.
|
|
50
|
+
"""
|
|
51
|
+
import re
|
|
52
|
+
|
|
53
|
+
scan_x_size = default_scan_size
|
|
54
|
+
|
|
55
|
+
# Try to read scan size from header
|
|
56
|
+
try:
|
|
57
|
+
with open(filename, 'r', encoding="utf-8", errors="replace") as f:
|
|
58
|
+
# Read first 10 lines to look for scan size info
|
|
59
|
+
header_lines = [f.readline() for _ in range(10)]
|
|
60
|
+
|
|
61
|
+
for line in header_lines:
|
|
62
|
+
# Common AFM file formats include scan size info
|
|
63
|
+
# Look for patterns like "Width: 2.000 µm"
|
|
64
|
+
if 'width' in line.lower():
|
|
65
|
+
match = re.search(r'(\d+\.?\d*)\s*(um|µm|micron)', line, re.IGNORECASE)
|
|
66
|
+
if match:
|
|
67
|
+
scan_x_size = float(match.group(1))
|
|
68
|
+
print(f" Detected scan width from header: {scan_x_size} µm")
|
|
69
|
+
break
|
|
70
|
+
|
|
71
|
+
match = re.search(r'(\d+\.?\d*)\s*nm', line, re.IGNORECASE)
|
|
72
|
+
if match:
|
|
73
|
+
scan_x_size = float(match.group(1)) / 1000 # Convert nm to µm
|
|
74
|
+
print(f" Detected scan width from header: {scan_x_size} µm ({match.group(1)} nm)")
|
|
75
|
+
break
|
|
76
|
+
|
|
77
|
+
elif 'scan' in line.lower() and 'size' in line.lower():
|
|
78
|
+
# Extract number
|
|
79
|
+
match = re.search(r'(\d+\.?\d*)\s*(um|µm|micron)', line, re.IGNORECASE)
|
|
80
|
+
if match:
|
|
81
|
+
scan_x_size = float(match.group(1))
|
|
82
|
+
print(f" Detected scan size from header: {scan_x_size} µm")
|
|
83
|
+
break
|
|
84
|
+
except Exception:
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
# Load the data
|
|
88
|
+
try:
|
|
89
|
+
# Another program's file: only the numbers are parsed, so an
|
|
90
|
+
# undecodable byte in the skipped header must not stop the load.
|
|
91
|
+
with open(filename, encoding="utf-8", errors="replace") as handle:
|
|
92
|
+
data = np.genfromtxt(handle, skip_header=4, skip_footer=0)
|
|
93
|
+
except Exception as e:
|
|
94
|
+
raise Exception(f"Error loading {filename}: {e}") from e
|
|
95
|
+
|
|
96
|
+
if scan_x_size == default_scan_size:
|
|
97
|
+
print(f" Using default scan size: {scan_x_size} µm")
|
|
98
|
+
|
|
99
|
+
return data, scan_x_size
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def raw_data(data, scan_x_size):
|
|
103
|
+
"""Extract displacement and height profile from 2D AFM data (averages all rows)"""
|
|
104
|
+
disp_um = scan_x_size * np.arange(0, len(data[0])) / (len(data[0]) - 1)
|
|
105
|
+
groov_nm = (10**9) * (np.mean(data, axis=0) - np.min(np.mean(data, axis=0)))
|
|
106
|
+
return disp_um, groov_nm
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def raw_data_multi_group(data, scan_x_size, n_groups=10):
|
|
110
|
+
"""
|
|
111
|
+
Extract multiple height profiles from different row groups in 2D AFM data
|
|
112
|
+
|
|
113
|
+
This function divides the AFM image into n_groups horizontal bands and extracts
|
|
114
|
+
an averaged profile from each band. This allows for many more measurements per
|
|
115
|
+
image and enables assessment of within-image variation.
|
|
116
|
+
|
|
117
|
+
Parameters:
|
|
118
|
+
data: 2D array of AFM height data (rows × columns)
|
|
119
|
+
scan_x_size: scan width in microns
|
|
120
|
+
n_groups: number of row groups to create (default: 10)
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
disp_um: 1D array of x-positions in microns (same for all groups)
|
|
124
|
+
profiles_nm: list of n_groups 1D arrays, each containing a height profile in nm
|
|
125
|
+
group_info: dict with metadata about the grouping
|
|
126
|
+
"""
|
|
127
|
+
n_rows = data.shape[0]
|
|
128
|
+
n_cols = data.shape[1]
|
|
129
|
+
|
|
130
|
+
# Calculate x-displacement (same for all profiles)
|
|
131
|
+
disp_um = scan_x_size * np.arange(0, n_cols) / (n_cols - 1)
|
|
132
|
+
|
|
133
|
+
# Determine row ranges for each group
|
|
134
|
+
rows_per_group = n_rows // n_groups
|
|
135
|
+
|
|
136
|
+
if rows_per_group < 3:
|
|
137
|
+
print(f"Warning: Only {rows_per_group} rows per group with n_groups={n_groups}")
|
|
138
|
+
print(f" Reducing to n_groups={n_rows // 3} to ensure at least 3 rows per group")
|
|
139
|
+
n_groups = max(1, n_rows // 3)
|
|
140
|
+
rows_per_group = n_rows // n_groups
|
|
141
|
+
|
|
142
|
+
profiles_nm = []
|
|
143
|
+
group_ranges = []
|
|
144
|
+
|
|
145
|
+
for i in range(n_groups):
|
|
146
|
+
# Calculate row range for this group
|
|
147
|
+
start_row = i * rows_per_group
|
|
148
|
+
|
|
149
|
+
# For the last group, include any remaining rows
|
|
150
|
+
if i == n_groups - 1:
|
|
151
|
+
end_row = n_rows
|
|
152
|
+
else:
|
|
153
|
+
end_row = (i + 1) * rows_per_group
|
|
154
|
+
|
|
155
|
+
# Extract and average rows in this group
|
|
156
|
+
group_data = data[start_row:end_row, :]
|
|
157
|
+
profile_avg = np.mean(group_data, axis=0)
|
|
158
|
+
|
|
159
|
+
# Convert to nm and subtract minimum
|
|
160
|
+
profile_nm = (10**9) * (profile_avg - np.min(profile_avg))
|
|
161
|
+
|
|
162
|
+
profiles_nm.append(profile_nm)
|
|
163
|
+
group_ranges.append((start_row, end_row))
|
|
164
|
+
|
|
165
|
+
# Package metadata
|
|
166
|
+
group_info = {
|
|
167
|
+
'n_groups': n_groups,
|
|
168
|
+
'n_rows': n_rows,
|
|
169
|
+
'rows_per_group': rows_per_group,
|
|
170
|
+
'group_ranges': group_ranges
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
print(f" Extracted {n_groups} row-group profiles")
|
|
174
|
+
print(f" Rows per group: {rows_per_group}")
|
|
175
|
+
print(f" Total rows used: {n_groups * rows_per_group} of {n_rows}")
|
|
176
|
+
|
|
177
|
+
return disp_um, profiles_nm, group_info
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def flatten_profile(x, y, method='linear', poly_order=2, exclude_edges=0.0,
|
|
181
|
+
feature='both', period_nm=None):
|
|
182
|
+
"""
|
|
183
|
+
Remove background tilt/curvature from AFM profile
|
|
184
|
+
|
|
185
|
+
Parameters:
|
|
186
|
+
method: 'linear', 'polynomial', 'groove_peaks', or 'level_grooves'
|
|
187
|
+
poly_order: polynomial order (for 'polynomial' and 'level_grooves' methods)
|
|
188
|
+
exclude_edges: fraction of data to exclude from edges (0.0 to 0.5)
|
|
189
|
+
feature: for 'level_grooves': 'peaks', 'troughs', or 'both'
|
|
190
|
+
period_nm: estimated period for finding grooves (for 'level_grooves')
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
y_flat: flattened profile
|
|
194
|
+
background: the removed background
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
if method == 'linear':
|
|
198
|
+
poly_coeffs = np.polyfit(x, y, 1)
|
|
199
|
+
background = np.polyval(poly_coeffs, x)
|
|
200
|
+
|
|
201
|
+
elif method == 'polynomial':
|
|
202
|
+
if exclude_edges > 0:
|
|
203
|
+
n_exclude = int(len(x) * exclude_edges)
|
|
204
|
+
x_fit = x[n_exclude:-n_exclude]
|
|
205
|
+
y_fit = y[n_exclude:-n_exclude]
|
|
206
|
+
poly_coeffs = np.polyfit(x_fit, y_fit, poly_order)
|
|
207
|
+
else:
|
|
208
|
+
poly_coeffs = np.polyfit(x, y, poly_order)
|
|
209
|
+
|
|
210
|
+
background = np.polyval(poly_coeffs, x)
|
|
211
|
+
|
|
212
|
+
elif method == 'groove_peaks':
|
|
213
|
+
peaks, _ = find_peaks(y, distance=len(y)//20)
|
|
214
|
+
|
|
215
|
+
if len(peaks) < 3:
|
|
216
|
+
print("Warning: Not enough peaks found for groove_peaks method, using polynomial")
|
|
217
|
+
poly_coeffs = np.polyfit(x, y, poly_order)
|
|
218
|
+
background = np.polyval(poly_coeffs, x)
|
|
219
|
+
else:
|
|
220
|
+
poly_coeffs = np.polyfit(x[peaks], y[peaks], poly_order)
|
|
221
|
+
background = np.polyval(poly_coeffs, x)
|
|
222
|
+
|
|
223
|
+
elif method == 'level_grooves':
|
|
224
|
+
if period_nm is None:
|
|
225
|
+
raise ValueError("period_nm must be provided for level_grooves method")
|
|
226
|
+
|
|
227
|
+
feature_x = []
|
|
228
|
+
feature_y = []
|
|
229
|
+
|
|
230
|
+
if feature in ['peaks', 'both']:
|
|
231
|
+
dx_nm = (x[1] - x[0]) * 1000
|
|
232
|
+
min_distance = int(0.5 * period_nm / dx_nm) # Changed from 0.3 to 0.5 for more reliable detection
|
|
233
|
+
# Use a lower prominence to catch more peaks
|
|
234
|
+
height_range = np.max(y) - np.min(y)
|
|
235
|
+
min_prominence = 0.2 * height_range # Require peaks to be at least 20% of height range
|
|
236
|
+
peaks, _ = find_peaks(y, distance=min_distance, prominence=min_prominence)
|
|
237
|
+
if len(peaks) > 0:
|
|
238
|
+
feature_x.extend(x[peaks])
|
|
239
|
+
feature_y.extend(y[peaks])
|
|
240
|
+
|
|
241
|
+
if feature in ['troughs', 'both']:
|
|
242
|
+
y_inv = -y
|
|
243
|
+
dx_nm = (x[1] - x[0]) * 1000
|
|
244
|
+
min_distance = int(0.5 * period_nm / dx_nm) # Changed from 0.3 to 0.5
|
|
245
|
+
height_range = np.max(y) - np.min(y)
|
|
246
|
+
min_prominence = 0.2 * height_range
|
|
247
|
+
troughs, _ = find_peaks(y_inv, distance=min_distance, prominence=min_prominence)
|
|
248
|
+
if len(troughs) > 0:
|
|
249
|
+
feature_x.extend(x[troughs])
|
|
250
|
+
feature_y.extend(y[troughs])
|
|
251
|
+
|
|
252
|
+
if len(feature_x) < 2:
|
|
253
|
+
print(" Warning: Not enough features found for level_grooves, using linear")
|
|
254
|
+
poly_coeffs = np.polyfit(x, y, 1)
|
|
255
|
+
background = np.polyval(poly_coeffs, x)
|
|
256
|
+
else:
|
|
257
|
+
feature_x = np.array(feature_x)
|
|
258
|
+
feature_y = np.array(feature_y)
|
|
259
|
+
|
|
260
|
+
if exclude_edges > 0:
|
|
261
|
+
x_min = x[0] + exclude_edges * (x[-1] - x[0])
|
|
262
|
+
x_max = x[-1] - exclude_edges * (x[-1] - x[0])
|
|
263
|
+
mask = (feature_x >= x_min) & (feature_x <= x_max)
|
|
264
|
+
feature_x = feature_x[mask]
|
|
265
|
+
feature_y = feature_y[mask]
|
|
266
|
+
|
|
267
|
+
if len(feature_x) < 2:
|
|
268
|
+
print(" Warning: Not enough features after edge exclusion, using all")
|
|
269
|
+
|
|
270
|
+
poly_coeffs = np.polyfit(feature_x, feature_y, poly_order)
|
|
271
|
+
background = np.polyval(poly_coeffs, x)
|
|
272
|
+
else:
|
|
273
|
+
raise ValueError(f"Unknown flattening method: {method}")
|
|
274
|
+
|
|
275
|
+
y_flat = y - background
|
|
276
|
+
|
|
277
|
+
return y_flat, background
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def find_groove_positions(x, y, period_nm, prominence_factor=0.3, distance_factor=0.7,
|
|
281
|
+
edge_exclusion=0.0, return_n_edge_rejected=False):
|
|
282
|
+
"""
|
|
283
|
+
Find positions of groove centers (minima)
|
|
284
|
+
|
|
285
|
+
Parameters:
|
|
286
|
+
edge_exclusion: reject centers lying within this many periods of either
|
|
287
|
+
end of the profile. A groove that close to the edge has no complete
|
|
288
|
+
facet in the data - the scan starts or stops part-way through it -
|
|
289
|
+
so any angle fitted to it is meaningless. 0.0 disables the check.
|
|
290
|
+
return_n_edge_rejected: also return how many centers were rejected
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
peaks, or (peaks, n_rejected) if return_n_edge_rejected
|
|
294
|
+
"""
|
|
295
|
+
y_inv = -y # Invert so grooves become peaks
|
|
296
|
+
dx_nm = (x[1] - x[0]) * 1000
|
|
297
|
+
period_idx = int(period_nm / dx_nm)
|
|
298
|
+
height_range = np.max(y) - np.min(y)
|
|
299
|
+
min_prominence = prominence_factor * height_range
|
|
300
|
+
|
|
301
|
+
peaks, properties = find_peaks(y_inv,
|
|
302
|
+
distance=int(distance_factor * period_idx),
|
|
303
|
+
prominence=min_prominence)
|
|
304
|
+
|
|
305
|
+
n_rejected = 0
|
|
306
|
+
if edge_exclusion > 0 and len(peaks) > 0:
|
|
307
|
+
margin_idx = edge_exclusion * period_idx
|
|
308
|
+
last = len(x) - 1
|
|
309
|
+
keep = (peaks >= margin_idx) & (peaks <= last - margin_idx)
|
|
310
|
+
n_rejected = int((~keep).sum())
|
|
311
|
+
peaks = peaks[keep]
|
|
312
|
+
|
|
313
|
+
if return_n_edge_rejected:
|
|
314
|
+
return peaks, n_rejected
|
|
315
|
+
return peaks
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def extract_single_groove(x, y, center_idx, period_nm, margin=0.1, allow_asymmetric=True):
|
|
319
|
+
"""
|
|
320
|
+
Extract a single groove around center_idx
|
|
321
|
+
|
|
322
|
+
Parameters:
|
|
323
|
+
allow_asymmetric: If True, allows asymmetric extraction near edges
|
|
324
|
+
"""
|
|
325
|
+
dx_nm = (x[1] - x[0]) * 1000
|
|
326
|
+
half_width = int(round((period_nm / 2) * (1 + margin) / dx_nm))
|
|
327
|
+
|
|
328
|
+
if allow_asymmetric:
|
|
329
|
+
# Allow asymmetric extraction for edge grooves
|
|
330
|
+
left_extent = min(half_width, center_idx)
|
|
331
|
+
right_extent = min(half_width, len(x) - 1 - center_idx)
|
|
332
|
+
|
|
333
|
+
start = center_idx - left_extent
|
|
334
|
+
end = center_idx + right_extent + 1
|
|
335
|
+
else:
|
|
336
|
+
# Original symmetric extraction
|
|
337
|
+
L = min(half_width, center_idx, len(x) - 1 - center_idx)
|
|
338
|
+
start = center_idx - L
|
|
339
|
+
end = center_idx + L + 1
|
|
340
|
+
|
|
341
|
+
x_groove = x[start:end]
|
|
342
|
+
y_groove = y[start:end]
|
|
343
|
+
|
|
344
|
+
return x_groove, y_groove
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tip convolution: simulate it, and undo what can be undone.
|
|
3
|
+
|
|
4
|
+
An AFM image is not the surface. It is the surface **dilated** by the tip
|
|
5
|
+
shape -- at every pixel the tip descends until it touches, and the recorded
|
|
6
|
+
height is where the *apex* stopped, which is above the true surface anywhere
|
|
7
|
+
the contact happened on the tip's flank instead of its apex. Villarrubia
|
|
8
|
+
(1997, J. Res. NIST 102, 425) gives the exact morphological statement and the
|
|
9
|
+
two operations this module implements:
|
|
10
|
+
|
|
11
|
+
- ``dilate`` -- image formation. What a tip of the given shape would report
|
|
12
|
+
for a known surface. Exact, and used to *make* test data: dilating the
|
|
13
|
+
synthetic scan produces an image whose true surface is known, which no real
|
|
14
|
+
scan allows.
|
|
15
|
+
- ``erode`` -- reconstruction. The **least upper bound** on the true surface
|
|
16
|
+
consistent with the image and the tip: everywhere the apex made contact the
|
|
17
|
+
bound *is* the surface, and everywhere else the tip physically could not
|
|
18
|
+
reach, so no algorithm can recover what is there. The certainty map marks
|
|
19
|
+
the difference, which is the entire point: a reconstruction without one
|
|
20
|
+
manufactures exactly the false confidence it exists to remove.
|
|
21
|
+
|
|
22
|
+
The tip is a cone with a spherical apex cap -- ``radius_nm`` and
|
|
23
|
+
``half_angle_deg`` (measured from the tip axis, so the flank rises at
|
|
24
|
+
``90 - half_angle`` degrees from the surface). The consequence worth knowing
|
|
25
|
+
before trusting any corrected groove: a facet steeper than the flank is
|
|
26
|
+
touched only at its top corner and is **unrecoverable**, and a facet close to
|
|
27
|
+
the flank angle is the marginal case -- an 18 deg tip has a 72 deg flank,
|
|
28
|
+
almost parallel to a 70.5 deg anti-blaze facet. That near-parallelism, not
|
|
29
|
+
the nanometre apex, is what rounds the troughs of blazed gratings.
|
|
30
|
+
|
|
31
|
+
**One dimension, deliberately.** The operations run along the fast-scan axis
|
|
32
|
+
(rows), not over the full 2-D image. For a grating the surface is invariant
|
|
33
|
+
along the grooves, and dilating an invariant surface with a cone reduces
|
|
34
|
+
exactly to dilating each cross-section with the cone's cross-section -- the
|
|
35
|
+
groove-parallel extent of the tip touches nothing the transverse extent does
|
|
36
|
+
not. What a 2-D pass would add is sensitivity to along-groove features, and
|
|
37
|
+
it would need the slow-axis pixel pitch, which the text-export format does
|
|
38
|
+
not record.
|
|
39
|
+
|
|
40
|
+
All heights are in metres (the unit the loaders promise), tip parameters in
|
|
41
|
+
nm, and the pixel pitch comes from the scan width exactly as
|
|
42
|
+
``core.processing.raw_data`` computes it.
|
|
43
|
+
"""
|
|
44
|
+
from dataclasses import dataclass
|
|
45
|
+
|
|
46
|
+
import numpy as np
|
|
47
|
+
|
|
48
|
+
#: Values ``AnalysisSettings.tip_correction`` may take. 'none' is the default:
|
|
49
|
+
#: the correction changes measured numbers and must be asked for, not applied
|
|
50
|
+
#: silently.
|
|
51
|
+
VALID_TIP_CORRECTIONS = ('none', 'erosion')
|
|
52
|
+
|
|
53
|
+
#: Height differences below this (metres) are numerically meaningless: 1e-12 m
|
|
54
|
+
#: is a thousandth of a nanometre, far under any AFM noise floor. Used to call
|
|
55
|
+
#: two candidate contacts a tie, which marks the pixel uncertain.
|
|
56
|
+
_TIE_TOL_M = 1e-12
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def tip_cross_section(dx_nm, radius_nm, half_angle_deg, max_height_nm):
|
|
60
|
+
"""The tip's transverse profile, sampled at the pixel pitch.
|
|
61
|
+
|
|
62
|
+
Returns heights in nm above the apex at lateral offsets
|
|
63
|
+
``k * dx_nm`` for ``k in [-K, K]`` (odd length, apex at the centre,
|
|
64
|
+
``t[K] == 0``). ``K`` is chosen so the profile just exceeds
|
|
65
|
+
``max_height_nm`` -- parts of the tip higher than the tallest feature can
|
|
66
|
+
never make contact and would only widen every window.
|
|
67
|
+
|
|
68
|
+
Sphere of radius ``R`` capping a cone of half-angle ``theta``: the two
|
|
69
|
+
meet where the sphere's tangent matches the cone flank, at lateral offset
|
|
70
|
+
``R cos(theta)`` and height ``R (1 - sin(theta))``.
|
|
71
|
+
"""
|
|
72
|
+
if radius_nm <= 0:
|
|
73
|
+
raise ValueError(f"tip radius must be positive, got {radius_nm} nm")
|
|
74
|
+
if not 0.0 < half_angle_deg < 90.0:
|
|
75
|
+
raise ValueError(
|
|
76
|
+
f"tip half angle must lie in (0, 90) degrees, got {half_angle_deg}")
|
|
77
|
+
|
|
78
|
+
theta = np.radians(half_angle_deg)
|
|
79
|
+
r_tangent = radius_nm * np.cos(theta)
|
|
80
|
+
t_tangent = radius_nm * (1.0 - np.sin(theta))
|
|
81
|
+
|
|
82
|
+
# Lateral extent at which the tip profile reaches max_height_nm.
|
|
83
|
+
if max_height_nm <= t_tangent:
|
|
84
|
+
r_max = float(np.sqrt(max(radius_nm**2 - (radius_nm - max_height_nm)**2,
|
|
85
|
+
0.0)))
|
|
86
|
+
else:
|
|
87
|
+
r_max = float(r_tangent + (max_height_nm - t_tangent) * np.tan(theta))
|
|
88
|
+
|
|
89
|
+
# At least the nearest neighbours, so a sub-pixel tip degrades to a
|
|
90
|
+
# near-no-op rather than an empty window.
|
|
91
|
+
k = max(int(np.ceil(r_max / dx_nm)), 1)
|
|
92
|
+
r = np.abs(np.arange(-k, k + 1)) * dx_nm
|
|
93
|
+
|
|
94
|
+
sphere = radius_nm - np.sqrt(np.maximum(radius_nm**2 - r**2, 0.0))
|
|
95
|
+
cone = t_tangent + (r - r_tangent) / np.tan(theta)
|
|
96
|
+
return np.where(r <= r_tangent, sphere, cone)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _pixel_pitch_nm(data, scan_x_size):
|
|
100
|
+
"""nm per column, matching ``raw_data``'s displacement axis exactly."""
|
|
101
|
+
return scan_x_size * 1000.0 / (data.shape[1] - 1)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _shifted(data, k, fill):
|
|
105
|
+
"""``out[:, j] = data[:, j + k]``, with ``fill`` where that runs off."""
|
|
106
|
+
out = np.full_like(data, fill)
|
|
107
|
+
if k == 0:
|
|
108
|
+
return data.copy()
|
|
109
|
+
if k > 0:
|
|
110
|
+
out[:, :-k] = data[:, k:]
|
|
111
|
+
else:
|
|
112
|
+
out[:, -k:] = data[:, :k]
|
|
113
|
+
return out
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def dilate(data, scan_x_size, *, radius_nm, half_angle_deg):
|
|
117
|
+
"""Image formation: what this tip reports for the surface ``data``.
|
|
118
|
+
|
|
119
|
+
``img(x) = max_u [ surface(x + u) - tip(u) ]`` -- the apex height when the
|
|
120
|
+
tip, centred at ``x``, rests on whichever point it touches first. Always
|
|
121
|
+
``>= data`` pointwise: a tip can hide a trough but never dig one.
|
|
122
|
+
|
|
123
|
+
Exists for synthesis and testing; the pipeline itself only erodes.
|
|
124
|
+
"""
|
|
125
|
+
dx_nm = _pixel_pitch_nm(data, scan_x_size)
|
|
126
|
+
relief_nm = float(data.max() - data.min()) * 1e9
|
|
127
|
+
tip_m = tip_cross_section(dx_nm, radius_nm, half_angle_deg, relief_nm) * 1e-9
|
|
128
|
+
k_max = len(tip_m) // 2
|
|
129
|
+
|
|
130
|
+
img = np.full_like(data, -np.inf)
|
|
131
|
+
for k in range(-k_max, k_max + 1):
|
|
132
|
+
np.maximum(img, _shifted(data, k, -np.inf) - tip_m[k + k_max], out=img)
|
|
133
|
+
return img
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass(frozen=True)
|
|
137
|
+
class TipCorrection:
|
|
138
|
+
"""An eroded image, and how much of it is actually the surface.
|
|
139
|
+
|
|
140
|
+
``data`` is the least upper bound on the true surface: equal to it at
|
|
141
|
+
every certain pixel, above it (by an unknowable amount) elsewhere. The
|
|
142
|
+
parameters are carried so downstream records can state what was assumed
|
|
143
|
+
rather than that something was.
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
data: np.ndarray #: corrected heights, metres, same shape as input
|
|
147
|
+
certain: np.ndarray #: bool; True where the apex provably made contact
|
|
148
|
+
radius_nm: float
|
|
149
|
+
half_angle_deg: float
|
|
150
|
+
footprint_px: int #: window width used; 3 means the tip is sub-pixel
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def certain_fraction(self) -> float:
|
|
154
|
+
"""Fraction of pixels where the correction recovered the surface."""
|
|
155
|
+
return float(np.mean(self.certain))
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def summary(self) -> str:
|
|
159
|
+
"""One line for a log or a metrics sidecar."""
|
|
160
|
+
note = (" -- tip is sub-pixel at this pitch, correction is a near-no-op"
|
|
161
|
+
if self.footprint_px <= 3 else "")
|
|
162
|
+
return (f"erosion, R = {self.radius_nm:g} nm, "
|
|
163
|
+
f"half angle = {self.half_angle_deg:g} deg: "
|
|
164
|
+
f"{100.0 * self.certain_fraction:.1f}% of pixels certain, "
|
|
165
|
+
f"the rest upper bounds{note}")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def erode(data, scan_x_size, *, radius_nm, half_angle_deg) -> TipCorrection:
|
|
169
|
+
"""Reconstruct the least upper bound on the surface behind an image.
|
|
170
|
+
|
|
171
|
+
``rec(x) = min_u [ img(x + u) + tip(u) ]`` -- the lowest the surface can
|
|
172
|
+
be at ``x`` given that the tip, wherever it stood, stopped where the image
|
|
173
|
+
says. Sits between the truth and the image: ``surface <= rec <= img``.
|
|
174
|
+
|
|
175
|
+
The certainty map re-runs the contact argument: re-dilating ``rec``
|
|
176
|
+
reproduces the image exactly, and for each image pixel the surface point
|
|
177
|
+
achieving that maximum is where the tip touched. A pixel is certain when
|
|
178
|
+
it is such a contact point for some image pixel **and** the contact is
|
|
179
|
+
unique -- a tie means the contact cannot be localised (the flank lying
|
|
180
|
+
flush along a facet, a wedge gripped at both walls), and every tied point
|
|
181
|
+
stays an upper bound. Window-truncated pixels at the scan edges are never
|
|
182
|
+
marked certain.
|
|
183
|
+
"""
|
|
184
|
+
dx_nm = _pixel_pitch_nm(data, scan_x_size)
|
|
185
|
+
relief_nm = float(data.max() - data.min()) * 1e9
|
|
186
|
+
tip_m = tip_cross_section(dx_nm, radius_nm, half_angle_deg, relief_nm) * 1e-9
|
|
187
|
+
k_max = len(tip_m) // 2
|
|
188
|
+
|
|
189
|
+
rec = np.full_like(data, np.inf)
|
|
190
|
+
for k in range(-k_max, k_max + 1):
|
|
191
|
+
np.minimum(rec, _shifted(data, k, np.inf) + tip_m[k + k_max], out=rec)
|
|
192
|
+
|
|
193
|
+
# Contact scan: for each image pixel, the best (highest) candidate
|
|
194
|
+
# rec(x+u) - tip(u) is the redilated image; its argmax u is the contact.
|
|
195
|
+
best = np.full_like(data, -np.inf)
|
|
196
|
+
second = np.full_like(data, -np.inf)
|
|
197
|
+
best_k = np.zeros(data.shape, dtype=np.int64)
|
|
198
|
+
for k in range(-k_max, k_max + 1):
|
|
199
|
+
candidate = _shifted(rec, k, -np.inf) - tip_m[k + k_max]
|
|
200
|
+
improves = candidate > best
|
|
201
|
+
second = np.where(improves, best, np.maximum(second, candidate))
|
|
202
|
+
best = np.where(improves, candidate, best)
|
|
203
|
+
best_k = np.where(improves, k, best_k)
|
|
204
|
+
|
|
205
|
+
unique = np.isfinite(best) & (best - second > _TIE_TOL_M)
|
|
206
|
+
certain = np.zeros(data.shape, dtype=bool)
|
|
207
|
+
rows, cols = np.nonzero(unique)
|
|
208
|
+
certain[rows, cols + best_k[rows, cols]] = True
|
|
209
|
+
|
|
210
|
+
return TipCorrection(
|
|
211
|
+
data=rec,
|
|
212
|
+
certain=certain,
|
|
213
|
+
radius_nm=radius_nm,
|
|
214
|
+
half_angle_deg=half_angle_deg,
|
|
215
|
+
footprint_px=2 * k_max + 1,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def apply_tip_correction(data, scan_x_size, settings):
|
|
220
|
+
"""The pipeline entry point: dispatch on ``settings.tip_correction``.
|
|
221
|
+
|
|
222
|
+
Returns ``(data, correction)`` -- the array to analyse and the
|
|
223
|
+
:class:`TipCorrection` behind it, or ``(data, None)`` untouched when the
|
|
224
|
+
setting is ``'none'``. Callers record ``correction.summary`` wherever
|
|
225
|
+
their output lands, because a corrected depth and an uncorrected one are
|
|
226
|
+
different measurements and must not be filed under the same description.
|
|
227
|
+
"""
|
|
228
|
+
if settings.tip_correction == 'none':
|
|
229
|
+
return data, None
|
|
230
|
+
correction = erode(data, scan_x_size,
|
|
231
|
+
radius_nm=settings.tip_radius_nm,
|
|
232
|
+
half_angle_deg=settings.tip_half_angle_deg)
|
|
233
|
+
return correction.data, correction
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Data management and aggregation
|
|
3
|
+
Combining scans and grouping by temperature
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .aggregation import (
|
|
7
|
+
group_by_temperature,
|
|
8
|
+
extract_temperatures_for_output,
|
|
9
|
+
combine_scans
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
'group_by_temperature',
|
|
14
|
+
'extract_temperatures_for_output',
|
|
15
|
+
'combine_scans'
|
|
16
|
+
]
|