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.
- quantized/__init__.py +11 -0
- quantized/__main__.py +14 -0
- quantized/api.py +220 -0
- quantized/app.py +206 -0
- quantized/calc/__init__.py +8 -0
- quantized/calc/_clipfit.py +76 -0
- quantized/calc/_natural_neighbor.py +219 -0
- quantized/calc/aggregate.py +159 -0
- quantized/calc/backgrounds.py +353 -0
- quantized/calc/baseline.py +349 -0
- quantized/calc/batch_fit.py +148 -0
- quantized/calc/constants.py +27 -0
- quantized/calc/corrections.py +192 -0
- quantized/calc/crystallography.py +400 -0
- quantized/calc/diffusion.py +120 -0
- quantized/calc/electrical.py +248 -0
- quantized/calc/electrochemistry.py +176 -0
- quantized/calc/element_data.json +1 -0
- quantized/calc/element_data.py +59 -0
- quantized/calc/errors.py +246 -0
- quantized/calc/figure.py +417 -0
- quantized/calc/figure_break.py +152 -0
- quantized/calc/figure_categorical.py +156 -0
- quantized/calc/figure_corner.py +229 -0
- quantized/calc/figure_facets.py +127 -0
- quantized/calc/figure_field.py +137 -0
- quantized/calc/figure_hitmap.py +116 -0
- quantized/calc/figure_labels.py +62 -0
- quantized/calc/figure_map.py +287 -0
- quantized/calc/figure_overrides.py +159 -0
- quantized/calc/figure_page.py +266 -0
- quantized/calc/figure_scale.py +125 -0
- quantized/calc/figure_statplots.py +167 -0
- quantized/calc/figure_styles.py +131 -0
- quantized/calc/figure_ternary.py +239 -0
- quantized/calc/figure_ticks.py +217 -0
- quantized/calc/fit_autoguess.py +156 -0
- quantized/calc/fit_bootstrap.py +163 -0
- quantized/calc/fit_bumps.py +258 -0
- quantized/calc/fit_constraints.py +114 -0
- quantized/calc/fit_equation.py +264 -0
- quantized/calc/fit_findxy.py +80 -0
- quantized/calc/fit_models.py +195 -0
- quantized/calc/fit_models_special.py +189 -0
- quantized/calc/fit_odr.py +99 -0
- quantized/calc/fit_scan.py +243 -0
- quantized/calc/fit_stats.py +215 -0
- quantized/calc/fitting.py +199 -0
- quantized/calc/formula.py +90 -0
- quantized/calc/global_curve_fit.py +305 -0
- quantized/calc/global_fit.py +181 -0
- quantized/calc/interp2d.py +260 -0
- quantized/calc/linecut.py +269 -0
- quantized/calc/magnetic.py +414 -0
- quantized/calc/magnetometry.py +464 -0
- quantized/calc/map.py +228 -0
- quantized/calc/mcmc.py +177 -0
- quantized/calc/optics.py +228 -0
- quantized/calc/pawley.py +251 -0
- quantized/calc/peak_batch.py +128 -0
- quantized/calc/peak_fit.py +259 -0
- quantized/calc/peak_integrate.py +104 -0
- quantized/calc/peak_multifit.py +260 -0
- quantized/calc/peak_track.py +134 -0
- quantized/calc/peaks.py +298 -0
- quantized/calc/peakshapes.py +85 -0
- quantized/calc/plotting.py +147 -0
- quantized/calc/processing.py +232 -0
- quantized/calc/qspace.py +48 -0
- quantized/calc/reductions.py +155 -0
- quantized/calc/reductions_fft.py +383 -0
- quantized/calc/refl_sld_presets.json +1 -0
- quantized/calc/reflectivity.py +80 -0
- quantized/calc/registry.py +303 -0
- quantized/calc/relaxation.py +119 -0
- quantized/calc/report.py +253 -0
- quantized/calc/report_emit.py +227 -0
- quantized/calc/resample.py +142 -0
- quantized/calc/rsm.py +91 -0
- quantized/calc/rsm_analyze.py +245 -0
- quantized/calc/semiconductor.py +488 -0
- quantized/calc/sld.py +131 -0
- quantized/calc/sld_formula.py +138 -0
- quantized/calc/spectral.py +357 -0
- quantized/calc/statplots.py +214 -0
- quantized/calc/stats.py +399 -0
- quantized/calc/stats_anova2.py +202 -0
- quantized/calc/stats_anova_ext.py +338 -0
- quantized/calc/stats_dist.py +196 -0
- quantized/calc/stats_glm.py +245 -0
- quantized/calc/stats_multivar.py +289 -0
- quantized/calc/stats_roc.py +157 -0
- quantized/calc/stats_survival.py +261 -0
- quantized/calc/stats_tests.py +380 -0
- quantized/calc/substrates.py +181 -0
- quantized/calc/superconductor.py +359 -0
- quantized/calc/surface_fit.py +290 -0
- quantized/calc/surface_models.py +156 -0
- quantized/calc/thermal.py +119 -0
- quantized/calc/thin_film.py +425 -0
- quantized/calc/unit_convert.py +259 -0
- quantized/calc/units.py +80 -0
- quantized/calc/vacuum.py +290 -0
- quantized/calc/xray.py +169 -0
- quantized/cli.py +214 -0
- quantized/datastruct.py +153 -0
- quantized/io/__init__.py +11 -0
- quantized/io/_hdf5_layout.py +308 -0
- quantized/io/_jcamp_asdf.py +135 -0
- quantized/io/_xrdml_scan.py +291 -0
- quantized/io/base.py +82 -0
- quantized/io/bruker_brml.py +177 -0
- quantized/io/bruker_raw.py +158 -0
- quantized/io/cif.py +266 -0
- quantized/io/consolidated.py +122 -0
- quantized/io/delimited.py +222 -0
- quantized/io/excel.py +135 -0
- quantized/io/hdf5.py +192 -0
- quantized/io/import_filters.py +178 -0
- quantized/io/import_preview.py +262 -0
- quantized/io/jcamp.py +179 -0
- quantized/io/lakeshore.py +163 -0
- quantized/io/ncnr.py +278 -0
- quantized/io/netcdf.py +195 -0
- quantized/io/opus.py +231 -0
- quantized/io/origin.py +346 -0
- quantized/io/origin_com.py +194 -0
- quantized/io/origin_project/__init__.py +221 -0
- quantized/io/origin_project/annotation_marks.py +288 -0
- quantized/io/origin_project/container.py +262 -0
- quantized/io/origin_project/curve_style_color.py +359 -0
- quantized/io/origin_project/figure_geometry.py +108 -0
- quantized/io/origin_project/figure_layers.py +333 -0
- quantized/io/origin_project/figure_text.py +258 -0
- quantized/io/origin_project/figures.py +210 -0
- quantized/io/origin_project/figures_opju.py +440 -0
- quantized/io/origin_project/notes.py +302 -0
- quantized/io/origin_project/opj.py +459 -0
- quantized/io/origin_project/opj_curves.py +297 -0
- quantized/io/origin_project/opj_shapes.py +148 -0
- quantized/io/origin_project/opju.py +146 -0
- quantized/io/origin_project/opju_axis_real_form.py +418 -0
- quantized/io/origin_project/opju_axis_specimen_form.py +167 -0
- quantized/io/origin_project/opju_codec.py +370 -0
- quantized/io/origin_project/opju_curves.py +497 -0
- quantized/io/origin_project/opju_curves_allcols.py +258 -0
- quantized/io/origin_project/opju_figure_curves.py +302 -0
- quantized/io/origin_project/opju_figure_text.py +245 -0
- quantized/io/origin_project/opju_reports.py +129 -0
- quantized/io/origin_project/origin_richtext.py +145 -0
- quantized/io/origin_project/preview.py +132 -0
- quantized/io/origin_project/templates.py +314 -0
- quantized/io/origin_project/tree.py +379 -0
- quantized/io/origin_project/tree_opju.py +228 -0
- quantized/io/origin_project/windows.py +238 -0
- quantized/io/origin_project/windows_opju.py +393 -0
- quantized/io/origin_project/writer.py +156 -0
- quantized/io/origin_project/writer_blocks.py +282 -0
- quantized/io/qd.py +380 -0
- quantized/io/refl1d.py +132 -0
- quantized/io/registry.py +210 -0
- quantized/io/report_export.py +347 -0
- quantized/io/rigaku.py +100 -0
- quantized/io/sims.py +398 -0
- quantized/io/spc.py +311 -0
- quantized/io/xrd_csv.py +308 -0
- quantized/io/xrdml.py +394 -0
- quantized/jobs.py +173 -0
- quantized/plugins/__init__.py +50 -0
- quantized/plugins/contract.py +111 -0
- quantized/plugins/loader.py +394 -0
- quantized/plugins/steps.py +90 -0
- quantized/routes/__init__.py +7 -0
- quantized/routes/_bookcache.py +62 -0
- quantized/routes/_export_common.py +27 -0
- quantized/routes/_payload.py +58 -0
- quantized/routes/_uploadcache.py +59 -0
- quantized/routes/aggregate.py +46 -0
- quantized/routes/baseline.py +210 -0
- quantized/routes/books.py +117 -0
- quantized/routes/calc.py +56 -0
- quantized/routes/corrections.py +78 -0
- quantized/routes/crystallography.py +80 -0
- quantized/routes/diffusion.py +58 -0
- quantized/routes/electrical.py +101 -0
- quantized/routes/electrochemistry.py +83 -0
- quantized/routes/export.py +280 -0
- quantized/routes/export_facets.py +83 -0
- quantized/routes/export_figures.py +471 -0
- quantized/routes/export_page.py +125 -0
- quantized/routes/fitting.py +379 -0
- quantized/routes/fitting_bumps.py +97 -0
- quantized/routes/import_template.py +97 -0
- quantized/routes/import_wizard.py +150 -0
- quantized/routes/jobs_api.py +59 -0
- quantized/routes/magnetic.py +135 -0
- quantized/routes/magnetometry.py +133 -0
- quantized/routes/optics.py +98 -0
- quantized/routes/parsers.py +281 -0
- quantized/routes/peaks.py +184 -0
- quantized/routes/plot.py +103 -0
- quantized/routes/reductions.py +121 -0
- quantized/routes/reference.py +58 -0
- quantized/routes/reflectivity.py +91 -0
- quantized/routes/report_export.py +119 -0
- quantized/routes/rsm.py +136 -0
- quantized/routes/samples.py +32 -0
- quantized/routes/semiconductor.py +207 -0
- quantized/routes/sld.py +42 -0
- quantized/routes/spectral.py +54 -0
- quantized/routes/statplots.py +99 -0
- quantized/routes/stats.py +418 -0
- quantized/routes/stats_design.py +321 -0
- quantized/routes/substrates.py +46 -0
- quantized/routes/superconductor.py +139 -0
- quantized/routes/thermal.py +57 -0
- quantized/routes/thin_film.py +153 -0
- quantized/routes/vacuum.py +113 -0
- quantized/routes/xray.py +32 -0
- quantized/samples/demo_vsm.csv +42 -0
- quantized/server_launch.py +251 -0
- quantized/web/assets/JetBrainsMono-Bold-CUogYd9I.woff2 +0 -0
- quantized/web/assets/JetBrainsMono-Regular-CA-Os4ii.woff2 +0 -0
- quantized/web/assets/index-BHmmCL-x.js +27 -0
- quantized/web/assets/index-BiZzN7J6.css +1 -0
- quantized/web/index.html +13 -0
- quantized/web/loading.html +69 -0
- quantized_lab-0.8.0.dist-info/METADATA +122 -0
- quantized_lab-0.8.0.dist-info/RECORD +233 -0
- quantized_lab-0.8.0.dist-info/WHEEL +4 -0
- quantized_lab-0.8.0.dist-info/entry_points.txt +4 -0
- quantized_lab-0.8.0.dist-info/licenses/LICENSE +201 -0
- 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
|
+
}
|