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,138 @@
1
+ """Scattering-length density from a chemical formula (neutron + X-ray).
2
+
3
+ Computes the neutron and X-ray scattering-length densities (SLD) of a
4
+ compound from its chemical formula, mass density, and probe wavelength —
5
+ including the **imaginary (absorption)** parts and the wavelength
6
+ dependence (neutron absorption scales as 1/v ∝ λ; X-ray f′/f″ are
7
+ energy-dependent).
8
+
9
+ The atomic data and formulas come from ``periodictable`` (Sears neutron
10
+ tables + Henke/CXRO X-ray f1/f2 tables), which is the same engine behind
11
+ the **NIST NCNR online SLD calculator** and ``refl1d``. Parity with the
12
+ NCNR calculator is asserted in ``tests/test_sld_formula.py``.
13
+
14
+ Pure library: ndarray/scalars in → ``dict`` out. No fastapi/pydantic.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import math
20
+ from typing import Any
21
+
22
+ import periodictable as pt
23
+ from periodictable import formula as _formula
24
+
25
+ #: Thermal-neutron wavelength (2200 m/s) — the NCNR calculator default (Å).
26
+ NEUTRON_WAVELENGTH = 1.798
27
+ #: Cu Kα — the conventional laboratory X-ray wavelength (Å).
28
+ XRAY_WAVELENGTH = 1.5418
29
+
30
+
31
+ def _critical_q(sld_real_1e6: float) -> float:
32
+ """Critical wavevector Qc (1/Å) for total external reflection.
33
+
34
+ Qc = 4·√(π·SLD) with SLD the *real* coherent SLD (1/Ų). Below Qc a
35
+ beam totally externally reflects; it is the headline number for a
36
+ reflectometry substrate. Returns 0 for a non-positive (over-/under-
37
+ dense relative to vacuum) SLD, where no critical edge exists.
38
+ """
39
+ sld = sld_real_1e6 * 1e-6
40
+ if sld <= 0:
41
+ return 0.0
42
+ return 4.0 * math.sqrt(math.pi * sld)
43
+
44
+
45
+ def sld_from_formula(
46
+ compound: str,
47
+ density: float,
48
+ *,
49
+ neutron_wavelength: float = NEUTRON_WAVELENGTH,
50
+ xray_wavelength: float = XRAY_WAVELENGTH,
51
+ ) -> dict[str, Any]:
52
+ """Neutron + X-ray SLD of *compound* at mass *density* (g/cm³).
53
+
54
+ Parameters
55
+ ----------
56
+ compound:
57
+ Chemical formula, e.g. ``"SiO2"``, ``"D2O"``, ``"Fe2O3"``. Isotopes
58
+ use ``periodictable`` syntax (``"D2O"``, ``"Si[30]"``).
59
+ density:
60
+ Mass density in g/cm³ (required — there is no default for a
61
+ compound, matching the NCNR calculator).
62
+ neutron_wavelength, xray_wavelength:
63
+ Probe wavelengths in Å. The neutron value sets the absorption (1/v)
64
+ scaling; the X-ray value selects the f′/f″ anomalous corrections.
65
+
66
+ Returns
67
+ -------
68
+ dict with ``formula``, ``molar_mass`` (g/mol), ``number_density``
69
+ (formula units/cm³), and ``neutron`` / ``xray`` sub-dicts. Each carries
70
+ ``sld_real`` and ``sld_imag`` (10⁻⁶ Å⁻²; ``sld_imag`` is the absorption
71
+ term), a ``penetration`` 1/e depth (cm), and a critical ``qc`` (1/Å).
72
+ The neutron block additionally reports the ``incoherent`` SLD and the
73
+ coherent/absorption/incoherent macroscopic cross sections (1/cm).
74
+
75
+ Raises
76
+ ------
77
+ ValueError
78
+ Empty formula, non-positive density/wavelength, an unparseable
79
+ formula, or a formula whose elements lack neutron data.
80
+ """
81
+ if not compound.strip():
82
+ raise ValueError("formula is empty")
83
+ if not (density > 0):
84
+ raise ValueError("density must be positive")
85
+ if not (neutron_wavelength > 0 and xray_wavelength > 0):
86
+ raise ValueError("wavelength must be positive")
87
+
88
+ try:
89
+ mol = _formula(compound, density=density)
90
+ except Exception as exc:
91
+ # periodictable's formula parser raises pyparsing.ParseException (NOT a
92
+ # ValueError/KeyError) on malformed text; treat any parse/lookup failure
93
+ # as a bad formula so the route returns 422, never a 500.
94
+ raise ValueError(f"could not parse formula {compound!r}: {exc}") from exc
95
+
96
+ # neutron_scattering → ((real, -imag, incoh) SLD [1e-6/Ų],
97
+ # (coh, abs, incoh) xs [1/cm], penetration [cm])
98
+ # or (None, None, None) when a component has no neutron data.
99
+ sld, xs, pen = pt.neutron_scattering(mol, wavelength=neutron_wavelength)
100
+ if sld is None:
101
+ raise ValueError(f"no neutron scattering data for {compound!r}")
102
+ n_re, n_im, n_inc = (float(v) for v in sld)
103
+ xs_coh, xs_abs, xs_inc = (float(v) for v in xs)
104
+ n_pen = float(pen)
105
+
106
+ # xray_sld → (real, imag) SLD [1e-6/Ų]; imag is the absorption term.
107
+ x_re_v, x_im_v = pt.xray_sld(mol, wavelength=xray_wavelength)
108
+ x_re, x_im = float(x_re_v), float(x_im_v)
109
+ # 1/e *intensity* penetration depth: μ = 2·λ·Im(SLD) [1/Å] (λ Å, SLD 1/Ų);
110
+ # depth = 1/μ, converted Å → cm (×1e-8).
111
+ x_mu = 2.0 * xray_wavelength * (x_im * 1e-6)
112
+ x_pen = (1.0 / x_mu) * 1e-8 if x_mu > 0 else math.inf
113
+
114
+ number_density = density / mol.mass * pt.constants.avogadro_number
115
+
116
+ return {
117
+ "formula": str(mol),
118
+ "molar_mass": float(mol.mass),
119
+ "number_density": float(number_density),
120
+ "neutron": {
121
+ "wavelength": neutron_wavelength,
122
+ "sld_real": n_re,
123
+ "sld_imag": n_im,
124
+ "incoherent": n_inc,
125
+ "xs_coherent": xs_coh,
126
+ "xs_absorption": xs_abs,
127
+ "xs_incoherent": xs_inc,
128
+ "penetration": n_pen,
129
+ "qc": _critical_q(n_re),
130
+ },
131
+ "xray": {
132
+ "wavelength": xray_wavelength,
133
+ "sld_real": x_re,
134
+ "sld_imag": x_im,
135
+ "penetration": x_pen,
136
+ "qc": _critical_q(x_re),
137
+ },
138
+ }
@@ -0,0 +1,357 @@
1
+ """FFT spectral analysis + frequency-domain filtering. Ports of MATLAB +utilities.
2
+
3
+ Pure calc layer. ``fft_spectral`` computes PSD / magnitude / phase / complex
4
+ spectra (one- or two-sided, optional Welch averaging); ``fft_filter`` applies a
5
+ Butterworth-shaped frequency-domain filter. Windows, ``nextpow2`` nfft sizing,
6
+ the frequency axis and fftshift conventions all match MATLAB exactly.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+ from typing import Any
13
+
14
+ import numpy as np
15
+ from numpy.typing import ArrayLike, NDArray
16
+
17
+ __all__ = ["cross_correlation", "fft_filter", "fft_spectral"]
18
+
19
+ _EPS = float(np.finfo(float).eps)
20
+ _TWO_PI = 2.0 * math.pi
21
+
22
+
23
+ def _nextpow2(n: int) -> int:
24
+ return int(math.ceil(math.log2(n)))
25
+
26
+
27
+ def _matlab_round(x: float) -> int:
28
+ """Round half away from zero (MATLAB ``round``), not banker's rounding."""
29
+ return int(math.copysign(math.floor(abs(x) + 0.5), x))
30
+
31
+
32
+ def _bessel_i0(x: NDArray[np.float64]) -> NDArray[np.float64]:
33
+ """Modified Bessel I0 via 25-term series (matches MATLAB helper exactly)."""
34
+ y = np.ones_like(x)
35
+ term = np.ones_like(x)
36
+ for k in range(1, 26):
37
+ term = term * (x / (2 * k)) ** 2
38
+ y = y + term
39
+ return y
40
+
41
+
42
+ def _make_window(name: str, n: int, kaiser_beta: float) -> NDArray[np.float64]:
43
+ idx = np.arange(n, dtype=float)
44
+ if name == "none":
45
+ return np.ones(n)
46
+ if name == "hanning":
47
+ return 0.5 * (1 - np.cos(_TWO_PI * idx / (n - 1)))
48
+ if name == "hamming":
49
+ return 0.54 - 0.46 * np.cos(_TWO_PI * idx / (n - 1))
50
+ if name == "blackman":
51
+ return (
52
+ 0.42
53
+ - 0.5 * np.cos(_TWO_PI * idx / (n - 1))
54
+ + 0.08 * np.cos(2 * _TWO_PI * idx / (n - 1))
55
+ )
56
+ if name == "flattop":
57
+ a = (0.21557895, 0.41663158, 0.277263158, 0.083578947, 0.006947368)
58
+ return np.asarray(
59
+ a[0]
60
+ - a[1] * np.cos(_TWO_PI * idx / (n - 1))
61
+ + a[2] * np.cos(2 * _TWO_PI * idx / (n - 1))
62
+ - a[3] * np.cos(3 * _TWO_PI * idx / (n - 1))
63
+ + a[4] * np.cos(4 * _TWO_PI * idx / (n - 1)),
64
+ dtype=float,
65
+ )
66
+ if name == "kaiser":
67
+ alpha = (n - 1) / 2.0
68
+ arg = kaiser_beta * np.sqrt(1 - ((idx - alpha) / alpha) ** 2)
69
+ return np.asarray(_bessel_i0(arg) / _bessel_i0(np.asarray(kaiser_beta)), dtype=float)
70
+ raise ValueError(f"unknown window {name!r}")
71
+
72
+
73
+ def _detrend(y: NDArray[np.float64], x: NDArray[np.float64], mode: str) -> NDArray[np.float64]:
74
+ if mode == "mean":
75
+ return np.asarray(y - np.mean(y), dtype=float)
76
+ if mode == "linear":
77
+ coeffs = np.polyfit(x, y, 1)
78
+ return np.asarray(y - np.polyval(coeffs, x), dtype=float)
79
+ return y
80
+
81
+
82
+ def _two_sided_freq(nfft: int, df: float) -> NDArray[np.float64]:
83
+ """MATLAB ``(-floor(nfft/2):ceil(nfft/2)-1)*df`` == fftshift bin order."""
84
+ return np.asarray((np.arange(nfft) - (nfft // 2)) * df, dtype=float)
85
+
86
+
87
+ def cross_correlation(
88
+ x: ArrayLike, y: ArrayLike, *, normalize: str = "coeff"
89
+ ) -> dict[str, Any]:
90
+ """FFT-based cross-correlation of two equal-length signals. Port of crossCorrelation.
91
+
92
+ Returns ``lags`` (-(N-1)..N-1), the cross-correlation ``xcorr``, and the peak
93
+ lag/value (by largest magnitude). ``normalize='coeff'`` divides by
94
+ ``sqrt(sum(x^2) * sum(y^2))`` so an autocorrelation peaks at 1.
95
+ """
96
+ xv = np.asarray(x, dtype=float).ravel()
97
+ yv = np.asarray(y, dtype=float).ravel()
98
+ if xv.size != yv.size:
99
+ raise ValueError(f"signals must have equal length (got {xv.size} and {yv.size})")
100
+ n = xv.size
101
+ if n < 2:
102
+ raise ValueError("need at least 2 data points")
103
+
104
+ nfft = 2 ** _nextpow2(2 * n - 1)
105
+ rxy = np.real(np.fft.ifft(np.conj(np.fft.fft(xv, nfft)) * np.fft.fft(yv, nfft)))
106
+ xcorr = np.concatenate([rxy[nfft - n + 1 : nfft], rxy[:n]])
107
+ lags = np.arange(-(n - 1), n)
108
+ if normalize == "coeff":
109
+ denom = math.sqrt(float(np.sum(xv**2)) * float(np.sum(yv**2)))
110
+ if denom > 0:
111
+ xcorr = xcorr / denom
112
+ i_peak = int(np.argmax(np.abs(xcorr)))
113
+ return {
114
+ "lags": lags,
115
+ "xcorr": xcorr,
116
+ "peakLag": int(lags[i_peak]),
117
+ "peakValue": float(xcorr[i_peak]),
118
+ }
119
+
120
+
121
+ def fft_spectral(
122
+ x: ArrayLike,
123
+ y: ArrayLike,
124
+ *,
125
+ window: str = "hanning",
126
+ kaiser_beta: float = 5.0,
127
+ output_type: str = "psd",
128
+ sided: str = "one",
129
+ detrend: str = "mean",
130
+ zero_pad: int = 0,
131
+ overlap: float = 0.5,
132
+ segment_len: int = 0,
133
+ ) -> dict[str, Any]:
134
+ """Single-record or Welch-averaged FFT spectrum. Port of utilities.fftSpectral.
135
+
136
+ ``output_type`` selects ``psd`` | ``magnitude`` | ``phase`` (degrees) |
137
+ ``complex``. With ``segment_len > 0`` a Welch PSD is returned (segments of
138
+ ``segment_len`` with fractional ``overlap``). One-sided spectra fold interior
139
+ bins (x2); the sampling rate is inferred from the mean x-spacing.
140
+ """
141
+ xv = np.asarray(x, dtype=float).ravel()
142
+ yv = np.asarray(y, dtype=float).ravel()
143
+ n = xv.size
144
+ if n < 4:
145
+ raise ValueError("need at least 4 data points")
146
+ fs = 1.0 / abs(float(np.mean(np.diff(xv))))
147
+
148
+ if segment_len > 0:
149
+ return _welch_psd(
150
+ yv, fs, window, kaiser_beta, sided, detrend, zero_pad, overlap, segment_len
151
+ )
152
+
153
+ yd = _detrend(yv, xv, detrend)
154
+ win = _make_window(window, n, kaiser_beta)
155
+ y_win = yd * win
156
+ nfft = max(zero_pad, n) if zero_pad > 0 else 2 ** _nextpow2(n)
157
+ spectrum = np.fft.fft(y_win, nfft)
158
+ df = fs / nfft
159
+ s1 = float(win.sum())
160
+ s2 = float((win**2).sum())
161
+
162
+ out: dict[str, Any] = {}
163
+ if sided == "one":
164
+ n_half = nfft // 2 + 1
165
+ freq = np.arange(n_half) * df
166
+ yh = spectrum[:n_half]
167
+ if output_type == "psd":
168
+ val = (np.abs(yh) ** 2) / (fs * s2)
169
+ val[1:-1] *= 2
170
+ out["psd"] = val
171
+ elif output_type == "magnitude":
172
+ mag = np.abs(yh) / s1
173
+ mag[1:-1] *= 2
174
+ out["magnitude"] = mag
175
+ elif output_type == "phase":
176
+ out["phase"] = np.degrees(np.angle(yh))
177
+ else: # complex
178
+ out["spectrum"] = yh
179
+ out["freq"] = freq
180
+ else:
181
+ freq = _two_sided_freq(nfft, df)
182
+ y_shift = np.fft.fftshift(spectrum)
183
+ if output_type == "psd":
184
+ out["psd"] = (np.abs(y_shift) ** 2) / (fs * s2)
185
+ elif output_type == "magnitude":
186
+ out["magnitude"] = np.abs(y_shift) / s1
187
+ elif output_type == "phase":
188
+ out["phase"] = np.degrees(np.angle(y_shift))
189
+ else: # complex
190
+ out["spectrum"] = y_shift
191
+ out["freq"] = freq
192
+
193
+ out.update(window=win, df=df, nfft=nfft, fs=fs, windowName=window)
194
+ return out
195
+
196
+
197
+ def _welch_psd(
198
+ y: NDArray[np.float64],
199
+ fs: float,
200
+ window: str,
201
+ kaiser_beta: float,
202
+ sided: str,
203
+ detrend: str,
204
+ zero_pad: int,
205
+ overlap: float,
206
+ segment_len: int,
207
+ ) -> dict[str, Any]:
208
+ n = y.size
209
+ seg_len = min(segment_len, n)
210
+ if seg_len < 4:
211
+ raise ValueError("segment_len must be >= 4")
212
+ step = seg_len - _matlab_round(seg_len * overlap)
213
+ # overlap >= 1 (or any value making the hop <= 0) would make the segment loop
214
+ # below never advance -> infinite loop. Clamp to a 1-sample hop (max overlap).
215
+ step = max(step, 1)
216
+ nfft = max(zero_pad, seg_len) if zero_pad > 0 else 2 ** _nextpow2(seg_len)
217
+ df = fs / nfft
218
+ win = _make_window(window, seg_len, kaiser_beta)
219
+ s2 = float((win**2).sum())
220
+ n_half = nfft // 2 + 1
221
+
222
+ n_segs = 0
223
+ idx = 0
224
+ while idx + seg_len <= n:
225
+ n_segs += 1
226
+ idx += step
227
+ n_segs = max(n_segs, 1)
228
+
229
+ accum = np.zeros(n_half if sided == "one" else nfft)
230
+ idx = 0
231
+ n_actual = 0
232
+ for _ in range(n_segs):
233
+ if idx + seg_len > n:
234
+ break
235
+ seg = y[idx : idx + seg_len]
236
+ seg = _detrend(seg, np.arange(seg_len, dtype=float), detrend)
237
+ spectrum = np.fft.fft(seg * win, nfft)
238
+ if sided == "one":
239
+ yh = spectrum[:n_half]
240
+ p_seg = (np.abs(yh) ** 2) / (fs * s2)
241
+ p_seg[1:-1] *= 2
242
+ accum = accum + p_seg
243
+ else:
244
+ accum = accum + (np.abs(spectrum) ** 2) / (fs * s2)
245
+ n_actual += 1
246
+ idx += step
247
+ accum = accum / max(n_actual, 1)
248
+
249
+ if sided == "one":
250
+ freq = np.arange(n_half) * df
251
+ else:
252
+ freq = _two_sided_freq(nfft, df)
253
+ accum = np.fft.fftshift(accum)
254
+ return {
255
+ "freq": freq,
256
+ "psd": accum,
257
+ "window": win,
258
+ "df": df,
259
+ "nfft": nfft,
260
+ "fs": fs,
261
+ "windowName": window,
262
+ }
263
+
264
+
265
+ def fft_filter(
266
+ x: ArrayLike,
267
+ y: ArrayLike,
268
+ *,
269
+ filter_type: str = "lowpass",
270
+ cutoff: float | ArrayLike | None = None,
271
+ bandwidth: float | None = None,
272
+ order: int = 4,
273
+ window: str = "none",
274
+ detrend: bool = True,
275
+ ) -> dict[str, Any]:
276
+ """Frequency-domain Butterworth-shaped filter. Port of utilities.fftFilter.
277
+
278
+ ``filter_type`` is ``lowpass`` | ``highpass`` | ``bandpass`` | ``notch``.
279
+ ``cutoff`` is a scalar (lowpass/highpass/notch center) or ``[low, high]``
280
+ (bandpass); it defaults to Nyquist/4. Returns the filtered signal plus the
281
+ transfer function and one-/two-sided power spectra.
282
+ """
283
+ xv = np.asarray(x, dtype=float).ravel()
284
+ yv = np.asarray(y, dtype=float).ravel()
285
+ n = xv.size
286
+ if n < 4:
287
+ raise ValueError("need at least 4 data points")
288
+ fs = 1.0 / abs(float(np.mean(np.diff(xv))))
289
+ f_nyq = fs / 2.0
290
+
291
+ if cutoff is None:
292
+ cut = np.array([f_nyq / 4.0])
293
+ else:
294
+ cut = np.atleast_1d(np.asarray(cutoff, dtype=float))
295
+
296
+ if filter_type == "bandpass":
297
+ if cut.size != 2:
298
+ raise ValueError("bandpass requires cutoff = [low, high]")
299
+ f_low, f_high = float(cut[0]), float(cut[1])
300
+ elif filter_type == "notch":
301
+ f_center = float(cut[0])
302
+ bw = f_center / 10.0 if bandwidth is None else float(bandwidth)
303
+ f_low, f_high = f_center - bw / 2.0, f_center + bw / 2.0
304
+ elif filter_type == "lowpass":
305
+ f_low, f_high = 0.0, float(cut[0])
306
+ elif filter_type == "highpass":
307
+ f_low, f_high = float(cut[0]), f_nyq
308
+ else:
309
+ raise ValueError(f"unknown filter_type {filter_type!r}")
310
+
311
+ if detrend:
312
+ trend = np.polyval(np.polyfit(xv, yv, 1), xv)
313
+ else:
314
+ trend = np.zeros(n)
315
+ yd = yv - trend
316
+
317
+ win = _make_window(window, n, 5.0) if window != "none" else np.ones(n)
318
+ spectrum = np.fft.fft(yd * win)
319
+ freq = np.arange(n) * fs / n
320
+ freq = np.where(freq > f_nyq, freq - fs, freq)
321
+ abs_freq = np.abs(freq)
322
+
323
+ two_ord = 2 * order
324
+ if filter_type == "lowpass":
325
+ transfer = 1.0 / (1.0 + (abs_freq / max(f_high, _EPS)) ** two_ord)
326
+ elif filter_type == "highpass":
327
+ transfer = 1.0 / (1.0 + (max(f_low, _EPS) / np.maximum(abs_freq, _EPS)) ** two_ord)
328
+ transfer[abs_freq == 0] = 0.0
329
+ else: # bandpass / notch share the band shape
330
+ h_lp = 1.0 / (1.0 + (abs_freq / max(f_high, _EPS)) ** two_ord)
331
+ h_hp = 1.0 / (1.0 + (max(f_low, _EPS) / np.maximum(abs_freq, _EPS)) ** two_ord)
332
+ h_hp[abs_freq == 0] = 0.0
333
+ h_bp = h_lp * h_hp
334
+ transfer = h_bp if filter_type == "bandpass" else 1.0 - h_bp
335
+
336
+ y_filt_freq = spectrum * transfer
337
+ y_filt = np.real(np.fft.ifft(y_filt_freq))
338
+ if window != "none":
339
+ y_filt = y_filt / np.maximum(win, 0.01)
340
+ y_filt = y_filt + trend
341
+
342
+ power_orig = np.abs(spectrum) ** 2 / n
343
+ power_filt = np.abs(y_filt_freq) ** 2 / n
344
+ n_half = n // 2 + 1
345
+ freq_pos = np.arange(n_half) * fs / n
346
+ power_pos = 2.0 * power_orig[:n_half] / n
347
+ power_pos[0] = power_pos[0] / 2.0 # DC not doubled
348
+
349
+ return {
350
+ "yFiltered": y_filt,
351
+ "freq": freq,
352
+ "power": power_orig,
353
+ "powerFilt": power_filt,
354
+ "transfer": transfer,
355
+ "freqPos": freq_pos,
356
+ "powerPos": power_pos,
357
+ }