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.
Files changed (107) hide show
  1. gratinglab/__init__.py +35 -0
  2. gratinglab/checks.py +263 -0
  3. gratinglab/compare.py +233 -0
  4. gratinglab/convergence.py +455 -0
  5. gratinglab/geometry.py +483 -0
  6. gratinglab/gui/__init__.py +0 -0
  7. gratinglab/gui/app.py +80 -0
  8. gratinglab/gui/diagram.py +699 -0
  9. gratinglab/gui/diagram3d.py +609 -0
  10. gratinglab/gui/docs.py +189 -0
  11. gratinglab/gui/icon.py +101 -0
  12. gratinglab/gui/integral_options.py +219 -0
  13. gratinglab/gui/mathtext.py +209 -0
  14. gratinglab/gui/orders.py +152 -0
  15. gratinglab/gui/provenance.py +273 -0
  16. gratinglab/gui/qt/__init__.py +20 -0
  17. gratinglab/gui/qt/geometry_panel.py +239 -0
  18. gratinglab/gui/qt/geometry_tab.py +387 -0
  19. gratinglab/gui/qt/integral_tab.py +447 -0
  20. gratinglab/gui/qt/main_window.py +514 -0
  21. gratinglab/gui/qt/profile_plot_panel.py +67 -0
  22. gratinglab/gui/qt/scalar_tab.py +498 -0
  23. gratinglab/gui/qt/scene3d_panel.py +347 -0
  24. gratinglab/gui/qt/setup_tab.py +215 -0
  25. gratinglab/gui/qt/sizing.py +25 -0
  26. gratinglab/gui/qt/theory_viewer.py +89 -0
  27. gratinglab/gui/qt/worker.py +203 -0
  28. gratinglab/gui/richtext.py +463 -0
  29. gratinglab/gui/scalar_options.py +139 -0
  30. gratinglab/gui/state.py +379 -0
  31. gratinglab/illumination.py +212 -0
  32. gratinglab/io/__init__.py +0 -0
  33. gratinglab/io/efficiency_table.py +382 -0
  34. gratinglab/io/ggp.py +248 -0
  35. gratinglab/materials/__init__.py +218 -0
  36. gratinglab/materials/data/SOURCES.md +53 -0
  37. gratinglab/materials/fresnel.py +282 -0
  38. gratinglab/materials/optical.py +319 -0
  39. gratinglab/materials/refractiveindex.py +306 -0
  40. gratinglab/metrology/__init__.py +121 -0
  41. gratinglab/metrology/analyzer.py +875 -0
  42. gratinglab/metrology/boundary/__init__.py +27 -0
  43. gratinglab/metrology/boundary/average.py +143 -0
  44. gratinglab/metrology/boundary/pipeline.py +211 -0
  45. gratinglab/metrology/cli.py +52 -0
  46. gratinglab/metrology/config.py +170 -0
  47. gratinglab/metrology/core/__init__.py +23 -0
  48. gratinglab/metrology/core/analysis.py +315 -0
  49. gratinglab/metrology/core/image_flatten.py +131 -0
  50. gratinglab/metrology/core/processing.py +344 -0
  51. gratinglab/metrology/core/tip.py +233 -0
  52. gratinglab/metrology/data/__init__.py +16 -0
  53. gratinglab/metrology/data/aggregation.py +193 -0
  54. gratinglab/metrology/gui/__init__.py +56 -0
  55. gratinglab/metrology/gui/icon.py +136 -0
  56. gratinglab/metrology/gui/qt/__init__.py +19 -0
  57. gratinglab/metrology/gui/qt/app.py +28 -0
  58. gratinglab/metrology/gui/qt/boundary_view.py +324 -0
  59. gratinglab/metrology/gui/qt/canvas.py +151 -0
  60. gratinglab/metrology/gui/qt/efficiency_dialog.py +273 -0
  61. gratinglab/metrology/gui/qt/import_view.py +380 -0
  62. gratinglab/metrology/gui/qt/main_window.py +398 -0
  63. gratinglab/metrology/gui/qt/wiki_view.py +99 -0
  64. gratinglab/metrology/gui/qt/worker.py +59 -0
  65. gratinglab/metrology/gui/state.py +168 -0
  66. gratinglab/metrology/io/__init__.py +8 -0
  67. gratinglab/metrology/io/file_io.py +253 -0
  68. gratinglab/metrology/io/metrics.py +38 -0
  69. gratinglab/metrology/io/spm.py +210 -0
  70. gratinglab/metrology/settings.py +216 -0
  71. gratinglab/metrology/stats/__init__.py +16 -0
  72. gratinglab/metrology/stats/analysis.py +329 -0
  73. gratinglab/metrology/stats/icc.py +128 -0
  74. gratinglab/metrology/visualization/__init__.py +16 -0
  75. gratinglab/metrology/visualization/diagnostics.py +111 -0
  76. gratinglab/metrology/visualization/profiles.py +234 -0
  77. gratinglab/metrology/visualization/statistics.py +412 -0
  78. gratinglab/metrology/wiki/__init__.py +91 -0
  79. gratinglab/metrology/wiki/facet-fitting.md +95 -0
  80. gratinglab/metrology/wiki/file-formats.md +116 -0
  81. gratinglab/metrology/wiki/flattening.md +105 -0
  82. gratinglab/metrology/wiki/icc-correction.md +111 -0
  83. gratinglab/metrology/wiki/overview.md +51 -0
  84. gratinglab/metrology/wiki/reading-outputs.md +154 -0
  85. gratinglab/metrology/wiki/row-groups.md +75 -0
  86. gratinglab/metrology/wiki/tip-correction.md +101 -0
  87. gratinglab/metrology/workflows.py +383 -0
  88. gratinglab/problem.py +79 -0
  89. gratinglab/profiles.py +531 -0
  90. gratinglab/resolution.py +198 -0
  91. gratinglab/result.py +268 -0
  92. gratinglab/solvers/__init__.py +34 -0
  93. gratinglab/solvers/base.py +207 -0
  94. gratinglab/solvers/integral/__init__.py +406 -0
  95. gratinglab/solvers/integral/_boundary.py +50 -0
  96. gratinglab/solvers/integral/_core.py +126 -0
  97. gratinglab/solvers/integral/_finite.py +396 -0
  98. gratinglab/solvers/integral/_greens.py +234 -0
  99. gratinglab/solvers/integral/_kernels.py +308 -0
  100. gratinglab/solvers/integral/_nystrom.py +474 -0
  101. gratinglab/solvers/scalar.py +1144 -0
  102. gratinglab-0.1.0.dist-info/METADATA +319 -0
  103. gratinglab-0.1.0.dist-info/RECORD +107 -0
  104. gratinglab-0.1.0.dist-info/WHEEL +5 -0
  105. gratinglab-0.1.0.dist-info/entry_points.txt +4 -0
  106. gratinglab-0.1.0.dist-info/licenses/LICENSE +28 -0
  107. 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
+ ]