quantized-lab 0.8.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 (233) hide show
  1. quantized/__init__.py +11 -0
  2. quantized/__main__.py +14 -0
  3. quantized/api.py +220 -0
  4. quantized/app.py +206 -0
  5. quantized/calc/__init__.py +8 -0
  6. quantized/calc/_clipfit.py +76 -0
  7. quantized/calc/_natural_neighbor.py +219 -0
  8. quantized/calc/aggregate.py +159 -0
  9. quantized/calc/backgrounds.py +353 -0
  10. quantized/calc/baseline.py +349 -0
  11. quantized/calc/batch_fit.py +148 -0
  12. quantized/calc/constants.py +27 -0
  13. quantized/calc/corrections.py +192 -0
  14. quantized/calc/crystallography.py +400 -0
  15. quantized/calc/diffusion.py +120 -0
  16. quantized/calc/electrical.py +248 -0
  17. quantized/calc/electrochemistry.py +176 -0
  18. quantized/calc/element_data.json +1 -0
  19. quantized/calc/element_data.py +59 -0
  20. quantized/calc/errors.py +246 -0
  21. quantized/calc/figure.py +417 -0
  22. quantized/calc/figure_break.py +152 -0
  23. quantized/calc/figure_categorical.py +156 -0
  24. quantized/calc/figure_corner.py +229 -0
  25. quantized/calc/figure_facets.py +127 -0
  26. quantized/calc/figure_field.py +137 -0
  27. quantized/calc/figure_hitmap.py +116 -0
  28. quantized/calc/figure_labels.py +62 -0
  29. quantized/calc/figure_map.py +287 -0
  30. quantized/calc/figure_overrides.py +159 -0
  31. quantized/calc/figure_page.py +266 -0
  32. quantized/calc/figure_scale.py +125 -0
  33. quantized/calc/figure_statplots.py +167 -0
  34. quantized/calc/figure_styles.py +131 -0
  35. quantized/calc/figure_ternary.py +239 -0
  36. quantized/calc/figure_ticks.py +217 -0
  37. quantized/calc/fit_autoguess.py +156 -0
  38. quantized/calc/fit_bootstrap.py +163 -0
  39. quantized/calc/fit_bumps.py +258 -0
  40. quantized/calc/fit_constraints.py +114 -0
  41. quantized/calc/fit_equation.py +264 -0
  42. quantized/calc/fit_findxy.py +80 -0
  43. quantized/calc/fit_models.py +195 -0
  44. quantized/calc/fit_models_special.py +189 -0
  45. quantized/calc/fit_odr.py +99 -0
  46. quantized/calc/fit_scan.py +243 -0
  47. quantized/calc/fit_stats.py +215 -0
  48. quantized/calc/fitting.py +199 -0
  49. quantized/calc/formula.py +90 -0
  50. quantized/calc/global_curve_fit.py +305 -0
  51. quantized/calc/global_fit.py +181 -0
  52. quantized/calc/interp2d.py +260 -0
  53. quantized/calc/linecut.py +269 -0
  54. quantized/calc/magnetic.py +414 -0
  55. quantized/calc/magnetometry.py +464 -0
  56. quantized/calc/map.py +228 -0
  57. quantized/calc/mcmc.py +177 -0
  58. quantized/calc/optics.py +228 -0
  59. quantized/calc/pawley.py +251 -0
  60. quantized/calc/peak_batch.py +128 -0
  61. quantized/calc/peak_fit.py +259 -0
  62. quantized/calc/peak_integrate.py +104 -0
  63. quantized/calc/peak_multifit.py +260 -0
  64. quantized/calc/peak_track.py +134 -0
  65. quantized/calc/peaks.py +298 -0
  66. quantized/calc/peakshapes.py +85 -0
  67. quantized/calc/plotting.py +147 -0
  68. quantized/calc/processing.py +232 -0
  69. quantized/calc/qspace.py +48 -0
  70. quantized/calc/reductions.py +155 -0
  71. quantized/calc/reductions_fft.py +383 -0
  72. quantized/calc/refl_sld_presets.json +1 -0
  73. quantized/calc/reflectivity.py +80 -0
  74. quantized/calc/registry.py +303 -0
  75. quantized/calc/relaxation.py +119 -0
  76. quantized/calc/report.py +253 -0
  77. quantized/calc/report_emit.py +227 -0
  78. quantized/calc/resample.py +142 -0
  79. quantized/calc/rsm.py +91 -0
  80. quantized/calc/rsm_analyze.py +245 -0
  81. quantized/calc/semiconductor.py +488 -0
  82. quantized/calc/sld.py +131 -0
  83. quantized/calc/sld_formula.py +138 -0
  84. quantized/calc/spectral.py +357 -0
  85. quantized/calc/statplots.py +214 -0
  86. quantized/calc/stats.py +399 -0
  87. quantized/calc/stats_anova2.py +202 -0
  88. quantized/calc/stats_anova_ext.py +338 -0
  89. quantized/calc/stats_dist.py +196 -0
  90. quantized/calc/stats_glm.py +245 -0
  91. quantized/calc/stats_multivar.py +289 -0
  92. quantized/calc/stats_roc.py +157 -0
  93. quantized/calc/stats_survival.py +261 -0
  94. quantized/calc/stats_tests.py +380 -0
  95. quantized/calc/substrates.py +181 -0
  96. quantized/calc/superconductor.py +359 -0
  97. quantized/calc/surface_fit.py +290 -0
  98. quantized/calc/surface_models.py +156 -0
  99. quantized/calc/thermal.py +119 -0
  100. quantized/calc/thin_film.py +425 -0
  101. quantized/calc/unit_convert.py +259 -0
  102. quantized/calc/units.py +80 -0
  103. quantized/calc/vacuum.py +290 -0
  104. quantized/calc/xray.py +169 -0
  105. quantized/cli.py +214 -0
  106. quantized/datastruct.py +153 -0
  107. quantized/io/__init__.py +11 -0
  108. quantized/io/_hdf5_layout.py +308 -0
  109. quantized/io/_jcamp_asdf.py +135 -0
  110. quantized/io/_xrdml_scan.py +291 -0
  111. quantized/io/base.py +82 -0
  112. quantized/io/bruker_brml.py +177 -0
  113. quantized/io/bruker_raw.py +158 -0
  114. quantized/io/cif.py +266 -0
  115. quantized/io/consolidated.py +122 -0
  116. quantized/io/delimited.py +222 -0
  117. quantized/io/excel.py +135 -0
  118. quantized/io/hdf5.py +192 -0
  119. quantized/io/import_filters.py +178 -0
  120. quantized/io/import_preview.py +262 -0
  121. quantized/io/jcamp.py +179 -0
  122. quantized/io/lakeshore.py +163 -0
  123. quantized/io/ncnr.py +278 -0
  124. quantized/io/netcdf.py +195 -0
  125. quantized/io/opus.py +231 -0
  126. quantized/io/origin.py +346 -0
  127. quantized/io/origin_com.py +194 -0
  128. quantized/io/origin_project/__init__.py +221 -0
  129. quantized/io/origin_project/annotation_marks.py +288 -0
  130. quantized/io/origin_project/container.py +262 -0
  131. quantized/io/origin_project/curve_style_color.py +359 -0
  132. quantized/io/origin_project/figure_geometry.py +108 -0
  133. quantized/io/origin_project/figure_layers.py +333 -0
  134. quantized/io/origin_project/figure_text.py +258 -0
  135. quantized/io/origin_project/figures.py +210 -0
  136. quantized/io/origin_project/figures_opju.py +440 -0
  137. quantized/io/origin_project/notes.py +302 -0
  138. quantized/io/origin_project/opj.py +459 -0
  139. quantized/io/origin_project/opj_curves.py +297 -0
  140. quantized/io/origin_project/opj_shapes.py +148 -0
  141. quantized/io/origin_project/opju.py +146 -0
  142. quantized/io/origin_project/opju_axis_real_form.py +418 -0
  143. quantized/io/origin_project/opju_axis_specimen_form.py +167 -0
  144. quantized/io/origin_project/opju_codec.py +370 -0
  145. quantized/io/origin_project/opju_curves.py +497 -0
  146. quantized/io/origin_project/opju_curves_allcols.py +258 -0
  147. quantized/io/origin_project/opju_figure_curves.py +302 -0
  148. quantized/io/origin_project/opju_figure_text.py +245 -0
  149. quantized/io/origin_project/opju_reports.py +129 -0
  150. quantized/io/origin_project/origin_richtext.py +145 -0
  151. quantized/io/origin_project/preview.py +132 -0
  152. quantized/io/origin_project/templates.py +314 -0
  153. quantized/io/origin_project/tree.py +379 -0
  154. quantized/io/origin_project/tree_opju.py +228 -0
  155. quantized/io/origin_project/windows.py +238 -0
  156. quantized/io/origin_project/windows_opju.py +393 -0
  157. quantized/io/origin_project/writer.py +156 -0
  158. quantized/io/origin_project/writer_blocks.py +282 -0
  159. quantized/io/qd.py +380 -0
  160. quantized/io/refl1d.py +132 -0
  161. quantized/io/registry.py +210 -0
  162. quantized/io/report_export.py +347 -0
  163. quantized/io/rigaku.py +100 -0
  164. quantized/io/sims.py +398 -0
  165. quantized/io/spc.py +311 -0
  166. quantized/io/xrd_csv.py +308 -0
  167. quantized/io/xrdml.py +394 -0
  168. quantized/jobs.py +173 -0
  169. quantized/plugins/__init__.py +50 -0
  170. quantized/plugins/contract.py +111 -0
  171. quantized/plugins/loader.py +394 -0
  172. quantized/plugins/steps.py +90 -0
  173. quantized/routes/__init__.py +7 -0
  174. quantized/routes/_bookcache.py +62 -0
  175. quantized/routes/_export_common.py +27 -0
  176. quantized/routes/_payload.py +58 -0
  177. quantized/routes/_uploadcache.py +59 -0
  178. quantized/routes/aggregate.py +46 -0
  179. quantized/routes/baseline.py +210 -0
  180. quantized/routes/books.py +117 -0
  181. quantized/routes/calc.py +56 -0
  182. quantized/routes/corrections.py +78 -0
  183. quantized/routes/crystallography.py +80 -0
  184. quantized/routes/diffusion.py +58 -0
  185. quantized/routes/electrical.py +101 -0
  186. quantized/routes/electrochemistry.py +83 -0
  187. quantized/routes/export.py +280 -0
  188. quantized/routes/export_facets.py +83 -0
  189. quantized/routes/export_figures.py +471 -0
  190. quantized/routes/export_page.py +125 -0
  191. quantized/routes/fitting.py +379 -0
  192. quantized/routes/fitting_bumps.py +97 -0
  193. quantized/routes/import_template.py +97 -0
  194. quantized/routes/import_wizard.py +150 -0
  195. quantized/routes/jobs_api.py +59 -0
  196. quantized/routes/magnetic.py +135 -0
  197. quantized/routes/magnetometry.py +133 -0
  198. quantized/routes/optics.py +98 -0
  199. quantized/routes/parsers.py +281 -0
  200. quantized/routes/peaks.py +184 -0
  201. quantized/routes/plot.py +103 -0
  202. quantized/routes/reductions.py +121 -0
  203. quantized/routes/reference.py +58 -0
  204. quantized/routes/reflectivity.py +91 -0
  205. quantized/routes/report_export.py +119 -0
  206. quantized/routes/rsm.py +136 -0
  207. quantized/routes/samples.py +32 -0
  208. quantized/routes/semiconductor.py +207 -0
  209. quantized/routes/sld.py +42 -0
  210. quantized/routes/spectral.py +54 -0
  211. quantized/routes/statplots.py +99 -0
  212. quantized/routes/stats.py +418 -0
  213. quantized/routes/stats_design.py +321 -0
  214. quantized/routes/substrates.py +46 -0
  215. quantized/routes/superconductor.py +139 -0
  216. quantized/routes/thermal.py +57 -0
  217. quantized/routes/thin_film.py +153 -0
  218. quantized/routes/vacuum.py +113 -0
  219. quantized/routes/xray.py +32 -0
  220. quantized/samples/demo_vsm.csv +42 -0
  221. quantized/server_launch.py +251 -0
  222. quantized/web/assets/JetBrainsMono-Bold-CUogYd9I.woff2 +0 -0
  223. quantized/web/assets/JetBrainsMono-Regular-CA-Os4ii.woff2 +0 -0
  224. quantized/web/assets/index-BHmmCL-x.js +27 -0
  225. quantized/web/assets/index-BiZzN7J6.css +1 -0
  226. quantized/web/index.html +13 -0
  227. quantized/web/loading.html +69 -0
  228. quantized_lab-0.8.0.dist-info/METADATA +122 -0
  229. quantized_lab-0.8.0.dist-info/RECORD +233 -0
  230. quantized_lab-0.8.0.dist-info/WHEEL +4 -0
  231. quantized_lab-0.8.0.dist-info/entry_points.txt +4 -0
  232. quantized_lab-0.8.0.dist-info/licenses/LICENSE +201 -0
  233. quantized_lab-0.8.0.dist-info/licenses/NOTICE +11 -0
@@ -0,0 +1,383 @@
1
+ """FFT-based thickness reductions (PORT_PLAN #19) — see reductions.py.
2
+
3
+ Split from calc/reductions.py to respect the 500-line ceiling. Both public
4
+ functions re-export through ``quantized.calc.reductions``.
5
+
6
+ - ``fft_thickness`` — ``+bosonPlotter/peakTools.m`` ``fftThickness/doFFT``
7
+ - ``reflectivity_fft`` — ``peakTools.m`` ``reflectivityFFT/doReflFFT``
8
+ (including the superlattice harmonic analysis)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import math
14
+ from typing import Any
15
+
16
+ import numpy as np
17
+ from numpy.typing import NDArray
18
+ from scipy.interpolate import PchipInterpolator
19
+
20
+ __all__ = ["fft_thickness", "reflectivity_fft"]
21
+
22
+ _FloatArray = NDArray[np.float64]
23
+
24
+
25
+ def _window(name: str, n: int) -> _FloatArray:
26
+ """MATLAB peakTools window formulas (symmetric, N-1 denominator)."""
27
+ k = np.arange(n, dtype=float)
28
+ if name == "hann":
29
+ return np.asarray(0.5 * (1.0 - np.cos(2.0 * math.pi * k / (n - 1))), dtype=float)
30
+ if name == "blackman":
31
+ return np.asarray(
32
+ 0.42
33
+ - 0.5 * np.cos(2.0 * math.pi * k / (n - 1))
34
+ + 0.08 * np.cos(4.0 * math.pi * k / (n - 1)),
35
+ dtype=float,
36
+ )
37
+ if name == "none":
38
+ return np.ones(n, dtype=float)
39
+ raise ValueError(f"unknown window {name!r} (expected 'hann', 'blackman' or 'none')")
40
+
41
+
42
+ def _next_pow2_len(n: int) -> int:
43
+ """MATLAB ``2^nextpow2(4*N)`` — smallest power of two >= 4*N."""
44
+ return 1 << (4 * n - 1).bit_length()
45
+
46
+
47
+ def _uniform_q_fft(
48
+ q: _FloatArray, signal: _FloatArray, window: str
49
+ ) -> tuple[_FloatArray, _FloatArray, _FloatArray]:
50
+ """Shared tail of both FFT reductions: pchip onto a uniform Q grid is done
51
+ by the callers (they differ in detrending); this applies the window and
52
+ the 4x zero-padded FFT, returning (F_half, thickness_nm_axis, w) —
53
+ thickness axis in nm from ``2*pi*k / (N_fft*dQ)`` Angstroms."""
54
+ n = signal.size
55
+ w = _window(window, n)
56
+ windowed = signal * w
57
+ n_fft = _next_pow2_len(n)
58
+ f = np.asarray(np.abs(np.fft.fft(windowed, n_fft))[: n_fft // 2], dtype=float)
59
+ dq = float(q[1] - q[0])
60
+ thickness_a = 2.0 * math.pi * np.arange(n_fft // 2, dtype=float) / (n_fft * dq)
61
+ return f, np.asarray(thickness_a / 10.0, dtype=float), w
62
+
63
+
64
+ def fft_thickness(
65
+ two_theta_deg: Any,
66
+ intensity: Any,
67
+ wavelength_a: float,
68
+ *,
69
+ two_theta_min: float | None = None,
70
+ two_theta_max: float | None = None,
71
+ window: str = "hann",
72
+ max_thickness_nm: float = 200.0,
73
+ ) -> dict[str, Any]:
74
+ """Film thickness from Laue-fringe periodicity via FFT (XRD).
75
+
76
+ 2-theta -> Q, pchip-resample to a uniform Q grid, mean-subtract, window,
77
+ 4x zero-padded FFT; the strongest peak in bins 4..(t <= max) is the
78
+ thickness, with the FFT-peak FWHM/2 as the uncertainty.
79
+ """
80
+ if wavelength_a <= 0 or not math.isfinite(wavelength_a):
81
+ raise ValueError("wavelength_a must be positive and finite")
82
+ x_all = np.asarray(two_theta_deg, dtype=float).ravel()
83
+ y_all = np.asarray(intensity, dtype=float).ravel()
84
+ if x_all.size != y_all.size:
85
+ raise ValueError("two_theta and intensity must have the same length")
86
+
87
+ lo = float(x_all.min()) if two_theta_min is None else float(two_theta_min)
88
+ hi = float(x_all.max()) if two_theta_max is None else float(two_theta_max)
89
+ if lo >= hi:
90
+ raise ValueError("two_theta_min must be less than two_theta_max")
91
+ mask = (x_all >= lo) & (x_all <= hi)
92
+ if int(mask.sum()) < 10:
93
+ raise ValueError("too few data points in selected range (need >= 10)")
94
+ tt_sel = x_all[mask]
95
+ i_sel = y_all[mask]
96
+
97
+ q = np.asarray(
98
+ (4.0 * math.pi / wavelength_a) * np.sin(tt_sel / 2.0 * math.pi / 180.0), dtype=float
99
+ )
100
+ order = np.argsort(q, kind="stable") # interp1 sorts sample points internally
101
+ q_sorted = q[order]
102
+ i_sorted = i_sel[order]
103
+
104
+ n_pts = q_sorted.size
105
+ q_uniform = np.linspace(float(q_sorted[0]), float(q_sorted[-1]), n_pts)
106
+ i_uniform = np.asarray(PchipInterpolator(q_sorted, i_sorted)(q_uniform), dtype=float)
107
+ i_uniform = np.asarray(i_uniform - np.mean(i_uniform), dtype=float)
108
+
109
+ f, thickness_nm, _ = _uniform_q_fft(q_uniform, i_uniform, window)
110
+
111
+ # MATLAB searches F(4:searchMax) (1-based) — skip the DC/low bins.
112
+ search_min0 = 3 # 0-based index of MATLAB bin 4
113
+ below = np.nonzero(thickness_nm <= max_thickness_nm)[0]
114
+ search_max = int(below[-1]) + 1 if below.size else 0 # exclusive, = MATLAB count
115
+ if search_max < search_min0 + 2:
116
+ search_max = f.size
117
+ seg = f[search_min0:search_max]
118
+ peak_rel = int(np.argmax(seg))
119
+ peak_val = float(seg[peak_rel])
120
+ peak_idx = peak_rel + search_min0
121
+ t_nm = float(thickness_nm[peak_idx])
122
+
123
+ # Uncertainty from the FFT-peak FWHM (searched over the FULL half-spectrum
124
+ # like MATLAB, not just the restricted range).
125
+ half_max = peak_val / 2.0
126
+ left_below = np.nonzero(f[: peak_idx + 1] < half_max)[0]
127
+ right_below = np.nonzero(f[peak_idx:] < half_max)[0]
128
+ if left_below.size and right_below.size:
129
+ left_idx = int(left_below[-1])
130
+ right_idx = peak_idx + int(right_below[0])
131
+ fwhm_bins = right_idx - left_idx
132
+ hw = math.ceil(fwhm_bins / 2)
133
+ dt_nm = float(
134
+ thickness_nm[min(peak_idx + hw, thickness_nm.size - 1)]
135
+ - thickness_nm[max(peak_idx - hw, 0)]
136
+ )
137
+ uncertainty_nm = dt_nm / 2.0
138
+ else:
139
+ uncertainty_nm = float("nan")
140
+
141
+ return {
142
+ "thickness_nm": t_nm,
143
+ "uncertainty_nm": uncertainty_nm,
144
+ "wavelength_a": wavelength_a,
145
+ "two_theta_range": [lo, hi],
146
+ "fft_magnitude": f[:search_max].tolist(),
147
+ "thickness_axis": thickness_nm[:search_max].tolist(),
148
+ "n_points": int(mask.sum()),
149
+ }
150
+
151
+
152
+ _PREPROCESS_MODES = ("logR", "logRQ4", "R", "RQ4")
153
+
154
+
155
+ def reflectivity_fft(
156
+ x: Any,
157
+ reflectivity: Any,
158
+ *,
159
+ is_neutron: bool = False,
160
+ wavelength_a: float | None = None,
161
+ x_min: float | None = None,
162
+ x_max: float | None = None,
163
+ window: str = "hann",
164
+ preprocess: str = "logR",
165
+ max_thickness_nm: float = 500.0,
166
+ peak_prominence_threshold: float = 0.05,
167
+ ) -> dict[str, Any]:
168
+ """Film thickness(es) from Kiessig-fringe periodicity via FFT.
169
+
170
+ Supports neutron NR (``x`` already Q in 1/Angstrom) and XRR (``x`` in
171
+ degrees 2-theta, converted with ``wavelength_a``). Preprocess modes
172
+ ``logR`` / ``logRQ4`` / ``R`` / ``RQ4`` (Q^4 = Fresnel correction),
173
+ linear detrend, window, 4x zero-padded FFT, multi-peak detection with a
174
+ prominence filter, and the MATLAB superlattice heuristics (harmonic
175
+ scoring at 8% tolerance, satellite counting, suppressed-order sublayer
176
+ split).
177
+ """
178
+ if preprocess not in _PREPROCESS_MODES:
179
+ raise ValueError(f"preprocess must be one of {_PREPROCESS_MODES} (got {preprocess!r})")
180
+ if not 0.0 < peak_prominence_threshold <= 1.0:
181
+ raise ValueError("peak_prominence_threshold must be in (0, 1]")
182
+ x_all = np.asarray(x, dtype=float).ravel()
183
+ r_all = np.asarray(reflectivity, dtype=float).ravel()
184
+ if x_all.size != r_all.size:
185
+ raise ValueError("x and reflectivity must have the same length")
186
+
187
+ lo = float(x_all.min()) if x_min is None else float(x_min)
188
+ hi = float(x_all.max()) if x_max is None else float(x_max)
189
+ if lo >= hi:
190
+ raise ValueError("x_min must be less than x_max")
191
+ mask = (x_all >= lo) & (x_all <= hi)
192
+ if int(mask.sum()) < 10:
193
+ raise ValueError("too few data points in selected range (need >= 10)")
194
+ x_sel = x_all[mask]
195
+ r_sel = r_all[mask]
196
+
197
+ if is_neutron:
198
+ q = np.asarray(x_sel, dtype=float)
199
+ else:
200
+ if wavelength_a is None or not math.isfinite(wavelength_a) or wavelength_a <= 0:
201
+ raise ValueError("wavelength_a is required (positive, finite) for XRR mode")
202
+ q = np.asarray(
203
+ (4.0 * math.pi / wavelength_a) * np.sin(x_sel / 2.0 * math.pi / 180.0), dtype=float
204
+ )
205
+
206
+ use_q4 = "Q4" in preprocess
207
+ use_log = preprocess.startswith("log")
208
+ r_proc = np.asarray(r_sel, dtype=float)
209
+ if use_q4:
210
+ q_safe = np.asarray(np.maximum(q, 1e-6), dtype=float)
211
+ r_proc = np.asarray(r_proc * q_safe**4, dtype=float)
212
+ if use_log:
213
+ r_proc = np.asarray(np.log10(np.maximum(r_proc, 1e-30)), dtype=float)
214
+
215
+ order = np.argsort(q, kind="stable")
216
+ q_sorted = q[order]
217
+ r_sorted = r_proc[order]
218
+ n_pts = q_sorted.size
219
+ q_uniform = np.linspace(float(q_sorted[0]), float(q_sorted[-1]), n_pts)
220
+ r_uniform = np.asarray(PchipInterpolator(q_sorted, r_sorted)(q_uniform), dtype=float)
221
+
222
+ trend = np.polyfit(q_uniform, r_uniform, 1)
223
+ r_uniform = np.asarray(r_uniform - np.polyval(trend, q_uniform), dtype=float)
224
+
225
+ f, thickness_nm, _ = _uniform_q_fft(q_uniform, r_uniform, window)
226
+
227
+ search_min0 = 3 # MATLAB bin 4, 0-based
228
+ below = np.nonzero(thickness_nm <= max_thickness_nm)[0]
229
+ search_max = int(below[-1]) + 1 if below.size else 0
230
+ if search_max < search_min0 + 2:
231
+ search_max = f.size
232
+ f_search = np.asarray(f[search_min0:search_max], dtype=float)
233
+ t_search = np.asarray(thickness_nm[search_min0:search_max], dtype=float)
234
+
235
+ # Local maxima (strict on both sides), MATLAB fallback to the global max.
236
+ n_s = f_search.size
237
+ interior = np.arange(1, n_s - 1)
238
+ is_max = (f_search[interior] > f_search[interior - 1]) & (
239
+ f_search[interior] > f_search[interior + 1]
240
+ )
241
+ max_idx = interior[is_max]
242
+ if max_idx.size == 0:
243
+ max_idx = np.asarray([int(np.argmax(f_search))])
244
+
245
+ pk_amps = f_search[max_idx]
246
+ pk_thick = t_search[max_idx]
247
+
248
+ # Prominence: height above the higher of the two flanking minima.
249
+ prominences = np.empty(pk_amps.size, dtype=float)
250
+ for j, idx in enumerate(max_idx):
251
+ left_min = float(np.min(f_search[: idx + 1]))
252
+ right_min = float(np.min(f_search[idx:]))
253
+ prominences[j] = pk_amps[j] - max(left_min, right_min)
254
+ prom_thresh = peak_prominence_threshold * float(np.max(prominences))
255
+ keep = prominences > prom_thresh
256
+ pk_amps = pk_amps[keep]
257
+ pk_thick = pk_thick[keep]
258
+
259
+ sort_ord = np.argsort(-pk_amps, kind="stable")
260
+ pk_amps = pk_amps[sort_ord]
261
+ pk_thick = pk_thick[sort_ord]
262
+ if pk_amps.size > 20:
263
+ pk_amps = pk_amps[:20]
264
+ pk_thick = pk_thick[:20]
265
+
266
+ superlattice, labels = _superlattice_analysis(pk_thick)
267
+
268
+ result: dict[str, Any] = {
269
+ "thicknesses_nm": pk_thick.tolist(),
270
+ "amplitudes": pk_amps.tolist(),
271
+ "harmonic_labels": labels,
272
+ "q_range": [float(q.min()), float(q.max())],
273
+ "preprocess": preprocess,
274
+ "fft_magnitude": f_search.tolist(),
275
+ "thickness_axis": t_search.tolist(),
276
+ "is_neutron": is_neutron,
277
+ "superlattice": superlattice,
278
+ }
279
+ if not is_neutron:
280
+ result["wavelength_a"] = wavelength_a
281
+ return result
282
+
283
+
284
+ _HARM_TOL = 0.08 # MATLAB harmTol: 8% relative tolerance for harmonic matching
285
+
286
+
287
+ def _ml_round(x: float) -> int:
288
+ """MATLAB round(): half away from zero (Python round() is half-to-even)."""
289
+ return int(math.floor(x + 0.5)) if x >= 0 else int(math.ceil(x - 0.5))
290
+
291
+
292
+ def _superlattice_analysis(
293
+ pk_thick: _FloatArray,
294
+ ) -> tuple[dict[str, Any], list[str]]:
295
+ """Port of the doReflFFT superlattice block: try the 5 smallest peak
296
+ thicknesses as bilayer-period candidates, score how many peaks sit on
297
+ integer harmonics, and accept at >= 3 matches."""
298
+ n_pk = pk_thick.size
299
+ labels = [""] * n_pk
300
+
301
+ detected = False
302
+ lambda_nm = float("nan")
303
+ total_nm = float("nan")
304
+ n_repeats: float = float("nan")
305
+ sub_a_nm = float("nan")
306
+ sub_b_nm = float("nan")
307
+ suppressed: list[int] = []
308
+
309
+ if n_pk >= 2:
310
+ t_asc = np.sort(pk_thick)
311
+ best_score = 0
312
+ best_lambda = float("nan")
313
+ for cand in t_asc[: min(5, n_pk)]:
314
+ score = 0
315
+ for t in pk_thick:
316
+ ratio = t / cand
317
+ nr = _ml_round(ratio)
318
+ if nr >= 1 and abs(ratio - nr) / nr < _HARM_TOL:
319
+ score += 1
320
+ if score > best_score:
321
+ best_score = score
322
+ best_lambda = float(cand)
323
+
324
+ if best_score >= 3:
325
+ detected = True
326
+ lambda_nm = best_lambda
327
+
328
+ n_max = 1
329
+ for t in pk_thick:
330
+ ratio = t / lambda_nm
331
+ nr = _ml_round(ratio)
332
+ if nr >= 1 and abs(ratio - nr) / nr < _HARM_TOL and nr > n_max:
333
+ n_max = nr
334
+
335
+ # Satellites between Lambda and 2*Lambda imply extra repeats.
336
+ n_sub = 0
337
+ for t in pk_thick:
338
+ if 1.15 * lambda_nm < t < 1.85 * lambda_nm:
339
+ ratio = t / lambda_nm
340
+ nr = _ml_round(ratio)
341
+ if not (nr == 2 and abs(ratio - 2) / 2 < _HARM_TOL):
342
+ n_sub += 1
343
+
344
+ n_repeats = n_sub + 2 if n_sub > 0 else n_max
345
+ total_nm = n_repeats * lambda_nm
346
+
347
+ for order in range(2, min(6, max(n_max, 3)) + 1):
348
+ expected = order * lambda_nm
349
+ if not any(abs(t - expected) / expected < _HARM_TOL for t in pk_thick):
350
+ suppressed.append(order)
351
+
352
+ if suppressed:
353
+ sub_a_nm = lambda_nm / suppressed[0]
354
+ sub_b_nm = lambda_nm - sub_a_nm
355
+
356
+ bilayer_assigned = False
357
+ for j, t in enumerate(pk_thick):
358
+ ratio = t / lambda_nm
359
+ nr = _ml_round(ratio)
360
+ is_harm = nr >= 1 and abs(ratio - nr) / nr < _HARM_TOL
361
+ if is_harm and nr == 1 and not bilayer_assigned:
362
+ labels[j] = "Bilayer Λ"
363
+ bilayer_assigned = True
364
+ elif is_harm and nr >= 2:
365
+ labels[j] = f"SL order {nr}"
366
+ elif 1.15 * lambda_nm < t < 1.85 * lambda_nm:
367
+ nr2 = _ml_round(t / lambda_nm)
368
+ if not (nr2 == 2 and abs(t / lambda_nm - 2) / 2 < _HARM_TOL):
369
+ labels[j] = "Satellite"
370
+ # MATLAB leaves the order-2-within-tol case unlabeled here
371
+ else:
372
+ labels[j] = "Independent"
373
+
374
+ superlattice = {
375
+ "detected": detected,
376
+ "bilayer_period_nm": lambda_nm,
377
+ "total_thickness_nm": total_nm,
378
+ "n_repeats": n_repeats,
379
+ "sublayer_a_nm": sub_a_nm,
380
+ "sublayer_b_nm": sub_b_nm,
381
+ "suppressed_orders": suppressed,
382
+ }
383
+ return superlattice, labels
@@ -0,0 +1 @@
1
+ [{"name":"Silicon","formula":"Si","sldX":2.007E-5,"sldN":2.073E-6,"sldImag":0,"density":2.33},{"name":"Silicon Oxide","formula":"SiO2","sldX":1.888E-5,"sldN":3.47E-6,"sldImag":0,"density":2.2},{"name":"Sapphire","formula":"Al2O3","sldX":2.451E-5,"sldN":5.726E-6,"sldImag":0,"density":3.97},{"name":"Glass (borosilicate)","formula":"BK7","sldX":1.693E-5,"sldN":3.96E-6,"sldImag":0,"density":2.51},{"name":"Quartz","formula":"SiO2","sldX":1.888E-5,"sldN":4.18E-6,"sldImag":0,"density":2.65},{"name":"Gold","formula":"Au","sldX":0.0001245,"sldN":4.46E-6,"sldImag":4.42E-7,"density":19.32},{"name":"Silver","formula":"Ag","sldX":7.44E-5,"sldN":3.47E-6,"sldImag":0,"density":10.5},{"name":"Platinum","formula":"Pt","sldX":0.0001155,"sldN":6.35E-6,"sldImag":2.28E-7,"density":21.45},{"name":"Copper","formula":"Cu","sldX":6.43E-5,"sldN":6.53E-6,"sldImag":8.7E-8,"density":8.96},{"name":"Aluminum","formula":"Al","sldX":2.2E-5,"sldN":2.078E-6,"sldImag":0,"density":2.7},{"name":"Titanium","formula":"Ti","sldX":3.08E-5,"sldN":-1.95E-6,"sldImag":0,"density":4.51},{"name":"Chromium","formula":"Cr","sldX":5.48E-5,"sldN":3.027E-6,"sldImag":0,"density":7.19},{"name":"Tantalum","formula":"Ta","sldX":8.38E-5,"sldN":3.83E-6,"sldImag":0,"density":16.69},{"name":"Palladium","formula":"Pd","sldX":8.02E-5,"sldN":4.01E-6,"sldImag":0,"density":12.02},{"name":"Iron","formula":"Fe","sldX":5.94E-5,"sldN":8.024E-6,"sldImag":0,"density":7.87},{"name":"Cobalt","formula":"Co","sldX":5.89E-5,"sldN":2.261E-6,"sldImag":0,"density":8.9},{"name":"Nickel","formula":"Ni","sldX":6.4E-5,"sldN":9.408E-6,"sldImag":0,"density":8.91},{"name":"Permalloy","formula":"Ni80Fe20","sldX":6.31E-5,"sldN":9.12E-6,"sldImag":0,"density":8.72},{"name":"Magnetite","formula":"Fe3O4","sldX":4.23E-5,"sldN":6.95E-6,"sldImag":0,"density":5.17},{"name":"Alumina","formula":"Al2O3","sldX":2.45E-5,"sldN":5.726E-6,"sldImag":0,"density":3.97},{"name":"Titanium Oxide","formula":"TiO2","sldX":3.1E-5,"sldN":2.632E-6,"sldImag":0,"density":4.23},{"name":"Hafnium Oxide","formula":"HfO2","sldX":4.7E-5,"sldN":5.16E-6,"sldImag":0,"density":9.68},{"name":"Air / Vacuum","formula":"","sldX":0,"sldN":0,"sldImag":0,"density":0},{"name":"Water (H2O)","formula":"H2O","sldX":9.43E-6,"sldN":-5.6E-7,"sldImag":0,"density":1},{"name":"Heavy Water (D2O)","formula":"D2O","sldX":9.43E-6,"sldN":6.335E-6,"sldImag":0,"density":1.11},{"name":"Polystyrene","formula":"PS","sldX":9.6E-6,"sldN":1.412E-6,"sldImag":0,"density":1.05},{"name":"PMMA","formula":"PMMA","sldX":1.093E-5,"sldN":1.065E-6,"sldImag":0,"density":1.18},{"name":"Polyethylene","formula":"PE","sldX":8.61E-6,"sldN":-2.8E-7,"sldImag":0,"density":0.92},{"name":"Silicon Nitride","formula":"Si3N4","sldX":2.215E-5,"sldN":3.27E-6,"sldImag":0,"density":3.17},{"name":"Titanium Nitride","formula":"TiN","sldX":3.32E-5,"sldN":-4.8E-7,"sldImag":0,"density":5.22}]
@@ -0,0 +1,80 @@
1
+ """Specular reflectivity (Parratt recursion). Port of fitting.parrattRefl.
2
+
3
+ Pure calc layer. Computes X-ray/neutron specular reflectivity R(Q) from a layer
4
+ stack via the Parratt recursion, with optional Névot-Croce roughness and Gaussian
5
+ resolution smearing. Internally complex (Fresnel); the returned R = |r|^2 is real.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import numpy as np
11
+ from numpy.typing import ArrayLike, NDArray
12
+
13
+ __all__ = ["parratt_refl"]
14
+
15
+
16
+ def parratt_refl(
17
+ q: ArrayLike,
18
+ layers: ArrayLike,
19
+ *,
20
+ roughness: bool = True,
21
+ scale: float = 1.0,
22
+ background: float = 0.0,
23
+ resolution: ArrayLike | None = None,
24
+ ) -> NDArray[np.float64]:
25
+ """Specular reflectivity R(Q) from a layer stack. Port of parrattRefl.
26
+
27
+ ``layers`` is (M, 4): [thickness Å, SLD_real Å⁻², SLD_imag Å⁻², roughness Å],
28
+ layer 0 = incident medium, last = substrate. ``resolution`` is None, a scalar
29
+ dQ/Q, or a per-point σ_Q vector (Gaussian-smeared over 21 samples, ±3σ).
30
+ """
31
+ lay = np.asarray(layers, dtype=float)
32
+ n_layers = lay.shape[0]
33
+ if n_layers < 2:
34
+ raise ValueError("need at least 2 layers (incident medium + substrate)")
35
+ q2 = np.asarray(q, dtype=float).ravel()
36
+ n = q2.size
37
+
38
+ if resolution is not None and np.any(np.asarray(resolution, dtype=float) > 0):
39
+ res = np.asarray(resolution, dtype=float)
40
+ if res.size == 1:
41
+ dq = q2 * float(res)
42
+ elif res.size == n:
43
+ dq = res.ravel()
44
+ else:
45
+ raise ValueError("resolution must be empty, a scalar dQ/Q, or an N-vector σ_Q")
46
+ n_over, n_sigma = 21, 3
47
+ offsets = np.linspace(-n_sigma, n_sigma, n_over)
48
+ zero_res = dq <= 0
49
+ dq_safe = dq.copy()
50
+ dq_safe[zero_res] = 1.0
51
+ q_full = np.maximum(q2[:, None] + dq_safe[:, None] * offsets[None, :], 1e-6)
52
+ r_flat = parratt_refl(
53
+ q_full.ravel(), lay, roughness=roughness, scale=scale, background=background
54
+ )
55
+ r_mat = r_flat.reshape(n, n_over)
56
+ w = np.exp(-0.5 * ((q_full - q2[:, None]) / dq_safe[:, None]) ** 2)
57
+ r_out = np.sum(w * r_mat, axis=1) / np.sum(w, axis=1)
58
+ if np.any(zero_res):
59
+ r_out[zero_res] = r_mat[zero_res, n_over // 2]
60
+ return np.asarray(r_out, dtype=float)
61
+
62
+ d = lay[:, 0]
63
+ sigma = lay[:, 3]
64
+ sld = lay[:, 1] + 1j * lay[:, 2]
65
+ kz = np.sqrt((q2[:, None] / 2) ** 2 - 4 * np.pi * sld[None, :]) # (N, M) complex
66
+ r = np.zeros(n, dtype=complex)
67
+ for j in range(n_layers - 1, 0, -1):
68
+ kz_above = kz[:, j - 1]
69
+ kz_below = kz[:, j]
70
+ fj = (kz_above - kz_below) / (kz_above + kz_below)
71
+ if roughness and sigma[j] > 0:
72
+ fj = fj * np.exp(-2 * kz_above * kz_below * sigma[j] ** 2)
73
+ if j < n_layers - 1 and d[j] > 0:
74
+ phase = np.exp(2j * kz_below * d[j])
75
+ else:
76
+ phase = np.ones(n, dtype=complex)
77
+ r = (fj + r * phase) / (1 + fj * r * phase)
78
+
79
+ refl = scale * np.abs(r) ** 2 + background
80
+ return np.asarray(np.maximum(refl, 0.0), dtype=float)