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,260 @@
|
|
|
1
|
+
"""Simultaneous multi-peak + polynomial-background fit. Port of the
|
|
2
|
+
``+bosonPlotter/peakAnalysis`` global fit (``onFitSimultaneous`` +
|
|
3
|
+
``buildCompositeModel``/``compositeEval`` + ``computeArea``) and the exposed
|
|
4
|
+
``+bosonPlotter/buildLinkedPacker``.
|
|
5
|
+
|
|
6
|
+
Pure calc layer. Fits N peaks and a degree-``bg_degree`` polynomial background in
|
|
7
|
+
one optimisation, optionally sharing FWHM (and η) across peaks and/or penalising
|
|
8
|
+
center drift. The composite model approximates Split-Pearson-VII as a symmetric
|
|
9
|
+
Pearson VII (m=1.5) and TCH-pV as an η=0.5 pseudo-Voigt — matching the MATLAB
|
|
10
|
+
global fit (the per-peak fit in ``peak_fit`` uses the full forms).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import math
|
|
16
|
+
from collections.abc import Callable, Sequence
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
from numpy.typing import ArrayLike, NDArray
|
|
21
|
+
from scipy.optimize import minimize
|
|
22
|
+
|
|
23
|
+
__all__ = ["build_linked_packer", "compute_peak_area", "fit_multi_peak"]
|
|
24
|
+
|
|
25
|
+
_LN2 = math.log(2.0)
|
|
26
|
+
_EPS = float(np.finfo(float).eps)
|
|
27
|
+
_A_L = math.pi / 2.0
|
|
28
|
+
_A_G = math.sqrt(math.pi) / (2.0 * math.sqrt(_LN2))
|
|
29
|
+
|
|
30
|
+
ExpandFn = Callable[[NDArray[np.float64]], NDArray[np.float64]]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _composite_eval(
|
|
34
|
+
p: NDArray[np.float64], x: NDArray[np.float64], n_peaks: int, n_per_peak: int,
|
|
35
|
+
n_bg: int, model: str,
|
|
36
|
+
) -> NDArray[np.float64]:
|
|
37
|
+
"""Sum of ``n_peaks`` peaks + a polynomial background. Port of compositeEval."""
|
|
38
|
+
bg_coeffs = p[len(p) - n_bg :] # [c0, c1, ..., cn]
|
|
39
|
+
y = np.polyval(bg_coeffs[::-1], x)
|
|
40
|
+
for k in range(n_peaks):
|
|
41
|
+
base = k * n_per_peak
|
|
42
|
+
height, x0 = p[base], p[base + 1]
|
|
43
|
+
fw = p[base + 2] if p[base + 2] != 0 else _EPS
|
|
44
|
+
u = (x - x0) / fw
|
|
45
|
+
if model == "Gaussian":
|
|
46
|
+
y = y + height * np.exp(-4.0 * _LN2 * u**2)
|
|
47
|
+
elif model == "Pseudo-Voigt":
|
|
48
|
+
eta = max(0.0, min(1.0, float(p[base + 3])))
|
|
49
|
+
y = y + eta * (height / (1.0 + 4.0 * u**2)) + (1.0 - eta) * (
|
|
50
|
+
height * np.exp(-4.0 * _LN2 * u**2)
|
|
51
|
+
)
|
|
52
|
+
elif model == "Split Pearson VII":
|
|
53
|
+
m = 1.5
|
|
54
|
+
y = y + height * (1.0 + 4.0 * (2.0 ** (1.0 / m) - 1.0) * u**2) ** (-m)
|
|
55
|
+
elif model == "TCH-pV":
|
|
56
|
+
y = y + 0.5 * (height / (1.0 + 4.0 * u**2)) + 0.5 * (
|
|
57
|
+
height * np.exp(-4.0 * _LN2 * u**2)
|
|
58
|
+
)
|
|
59
|
+
else: # Lorentzian
|
|
60
|
+
y = y + height / (1.0 + 4.0 * u**2)
|
|
61
|
+
return np.asarray(y, dtype=float)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def compute_peak_area(model: str, height: float, fwhm: float, eta: float) -> float:
|
|
65
|
+
"""Integrated peak area from fitted params. Port of computeArea (SPVII/TCH use
|
|
66
|
+
the Lorentzian form, matching MATLAB's ``otherwise`` branch)."""
|
|
67
|
+
if model == "Gaussian":
|
|
68
|
+
return height * fwhm * math.sqrt(math.pi / _LN2) / 2.0
|
|
69
|
+
if model == "Pseudo-Voigt":
|
|
70
|
+
e = 0.5 if math.isnan(eta) else eta
|
|
71
|
+
return height * fwhm * (e * _A_L + (1.0 - e) * _A_G)
|
|
72
|
+
return height * fwhm * math.pi / 2.0
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _build_composite_model(
|
|
76
|
+
x: NDArray[np.float64], y: NDArray[np.float64], peaks: list[dict[str, Any]],
|
|
77
|
+
model: str, bg_degree: int,
|
|
78
|
+
) -> tuple[NDArray[np.float64], int, list[int], NDArray[np.float64]]:
|
|
79
|
+
"""Initial super-parameter vector + layout. Port of buildCompositeModel."""
|
|
80
|
+
n_peaks = len(peaks)
|
|
81
|
+
x_span = float(np.max(x) - np.min(x))
|
|
82
|
+
is_pv = model == "Pseudo-Voigt"
|
|
83
|
+
n_per_peak = 4 if is_pv else 3
|
|
84
|
+
n_bg = bg_degree + 1
|
|
85
|
+
p0 = np.zeros(n_peaks * n_per_peak + n_bg)
|
|
86
|
+
center_indices: list[int] = []
|
|
87
|
+
seed_centers = np.zeros(n_peaks)
|
|
88
|
+
y_max = float(np.max(y))
|
|
89
|
+
for k in range(n_peaks):
|
|
90
|
+
pk = peaks[k]
|
|
91
|
+
base = k * n_per_peak
|
|
92
|
+
p0[base] = max(float(pk["height"]), y_max * 0.01)
|
|
93
|
+
p0[base + 1] = float(pk["center"])
|
|
94
|
+
p0[base + 2] = max(float(pk["fwhm"]), x_span * 0.005)
|
|
95
|
+
if is_pv:
|
|
96
|
+
eta0 = 0.5
|
|
97
|
+
pk_eta = pk.get("eta")
|
|
98
|
+
if pk_eta is not None and not (isinstance(pk_eta, float) and math.isnan(pk_eta)):
|
|
99
|
+
eta0 = float(pk_eta)
|
|
100
|
+
p0[base + 3] = eta0
|
|
101
|
+
center_indices.append(base + 1)
|
|
102
|
+
seed_centers[k] = float(pk["center"])
|
|
103
|
+
p0[len(p0) - n_bg] = float(np.min(y)) # c0 (intercept); c1.. default 0
|
|
104
|
+
return p0, n_per_peak, center_indices, seed_centers
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def build_linked_packer(
|
|
108
|
+
p0: ArrayLike, n_peaks: int, n_per_peak: int, link_mode: str, center_indices: Sequence[int]
|
|
109
|
+
) -> tuple[NDArray[np.float64], ExpandFn, list[int]]:
|
|
110
|
+
"""Reduce/expand machinery for shared peak parameters. Port of buildLinkedPacker.
|
|
111
|
+
|
|
112
|
+
Modes: ``None`` (identity), ``Shared FWHM`` (peak 0's FWHM is the master),
|
|
113
|
+
``Shared FWHM + eta`` (FWHM and, for pseudo-Voigt, η are masters). Returns
|
|
114
|
+
``(p_free0, expand_fn, free_center_idx)``; indices are 0-based.
|
|
115
|
+
"""
|
|
116
|
+
p0a = np.asarray(p0, dtype=float)
|
|
117
|
+
m = p0a.size
|
|
118
|
+
if link_mode == "None" or n_peaks < 2:
|
|
119
|
+
return p0a.copy(), (lambda p: p), list(center_indices)
|
|
120
|
+
|
|
121
|
+
link_eta = link_mode == "Shared FWHM + eta" and n_per_peak == 4
|
|
122
|
+
drop: set[int] = set()
|
|
123
|
+
for k in range(1, n_peaks):
|
|
124
|
+
base = k * n_per_peak
|
|
125
|
+
drop.add(base + 2) # slave FWHM
|
|
126
|
+
if link_eta:
|
|
127
|
+
drop.add(base + 3) # slave eta
|
|
128
|
+
keep_idx = [i for i in range(m) if i not in drop]
|
|
129
|
+
p_free0 = p0a[keep_idx]
|
|
130
|
+
|
|
131
|
+
master_fwhm_free = keep_idx.index(2) # peak 0 FWHM (0-based)
|
|
132
|
+
master_eta_free = keep_idx.index(3) if (link_eta and 3 in keep_idx) else -1
|
|
133
|
+
|
|
134
|
+
src_map = [0] * m
|
|
135
|
+
for pos, i in enumerate(keep_idx):
|
|
136
|
+
src_map[i] = pos + 1 # 1-based positive
|
|
137
|
+
for k in range(1, n_peaks):
|
|
138
|
+
base = k * n_per_peak
|
|
139
|
+
src_map[base + 2] = -1 # slave FWHM
|
|
140
|
+
if link_eta:
|
|
141
|
+
src_map[base + 3] = -2 # slave eta
|
|
142
|
+
|
|
143
|
+
def expand(p_free: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
144
|
+
p_full = np.zeros(m)
|
|
145
|
+
for i in range(m):
|
|
146
|
+
s = src_map[i]
|
|
147
|
+
if s > 0:
|
|
148
|
+
p_full[i] = p_free[s - 1]
|
|
149
|
+
elif s == -1:
|
|
150
|
+
p_full[i] = p_free[master_fwhm_free]
|
|
151
|
+
elif s == -2:
|
|
152
|
+
p_full[i] = p_free[master_eta_free]
|
|
153
|
+
return p_full
|
|
154
|
+
|
|
155
|
+
free_center_idx = [keep_idx.index(ci) for ci in center_indices]
|
|
156
|
+
return p_free0, expand, free_center_idx
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def fit_multi_peak(
|
|
160
|
+
x: ArrayLike,
|
|
161
|
+
y: ArrayLike,
|
|
162
|
+
peaks: list[dict[str, Any]],
|
|
163
|
+
*,
|
|
164
|
+
model: str = "Lorentzian",
|
|
165
|
+
bg_degree: int = 1,
|
|
166
|
+
constrain: bool = False,
|
|
167
|
+
link_mode: str = "None",
|
|
168
|
+
max_fev: int | None = None,
|
|
169
|
+
) -> dict[str, Any]:
|
|
170
|
+
"""Fit ``peaks`` + a polynomial background simultaneously. Port of
|
|
171
|
+
``peakAnalysis.onFitSimultaneous``.
|
|
172
|
+
|
|
173
|
+
``model`` ∈ {Lorentzian, Gaussian, Pseudo-Voigt, Split Pearson VII, TCH-pV}.
|
|
174
|
+
``bg_degree`` is the background polynomial degree. ``constrain=True`` adds a
|
|
175
|
+
soft center-drift penalty; ``link_mode`` shares FWHM/η across peaks. Returns a
|
|
176
|
+
dict with fitted ``peaks`` (center/fwhm/height/bg/eta/area/status), ``bgCoeffs``,
|
|
177
|
+
``R2``, ``rmse``, ``params``, ``nPeaks``, ``model``.
|
|
178
|
+
|
|
179
|
+
Faithful-port note: MATLAB's ``onFitSimultaneous`` sets ``MaxIter`` to 30000
|
|
180
|
+
but leaves ``MaxFunEvals`` at fminsearch's default ``200·nFree`` — so the GUI
|
|
181
|
+
fit is *function-evaluation-limited*, often stopping before full convergence.
|
|
182
|
+
We replicate that budget by default (``max_fev=None`` → ``200·nFree``) so the
|
|
183
|
+
Python result matches the MATLAB GUI bit-for-bit (scipy and MATLAB share the
|
|
184
|
+
Lagarias simplex). Pass an explicit ``max_fev`` to let the fit converge further
|
|
185
|
+
(diverges from MATLAB parity — use only when you want the best fit, not parity).
|
|
186
|
+
"""
|
|
187
|
+
xv = np.asarray(x, dtype=float).ravel()
|
|
188
|
+
yv = np.asarray(y, dtype=float).ravel()
|
|
189
|
+
n_peaks = len(peaks)
|
|
190
|
+
if n_peaks < 1:
|
|
191
|
+
raise ValueError("need at least one peak to fit")
|
|
192
|
+
x_span = float(np.max(xv) - np.min(xv))
|
|
193
|
+
is_pv = model == "Pseudo-Voigt"
|
|
194
|
+
n_bg = bg_degree + 1
|
|
195
|
+
|
|
196
|
+
p0, n_per_peak, center_indices, seed_centers = _build_composite_model(
|
|
197
|
+
xv, yv, peaks, model, bg_degree
|
|
198
|
+
)
|
|
199
|
+
p_free0, expand, free_center_idx = build_linked_packer(
|
|
200
|
+
p0, n_peaks, n_per_peak, link_mode, center_indices
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
def model_eval(pf: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
204
|
+
return _composite_eval(expand(pf), xv, n_peaks, n_per_peak, n_bg, model)
|
|
205
|
+
|
|
206
|
+
if constrain and n_peaks > 1:
|
|
207
|
+
center_bnd = np.array(
|
|
208
|
+
[max(3.0 * abs(p0[k * n_per_peak + 2]), x_span * 0.02) for k in range(n_peaks)]
|
|
209
|
+
)
|
|
210
|
+
penalty_wt = float(np.sum((yv - np.mean(yv)) ** 2)) * 10.0
|
|
211
|
+
fci = np.asarray(free_center_idx, dtype=int)
|
|
212
|
+
|
|
213
|
+
def objective(pf: NDArray[np.float64]) -> float:
|
|
214
|
+
resid = model_eval(pf) - yv
|
|
215
|
+
pen = np.sum(np.maximum(0.0, ((pf[fci] - seed_centers) / center_bnd) ** 2 - 1.0))
|
|
216
|
+
return float(np.sum(resid**2) + penalty_wt * pen)
|
|
217
|
+
else:
|
|
218
|
+
def objective(pf: NDArray[np.float64]) -> float:
|
|
219
|
+
resid = model_eval(pf) - yv
|
|
220
|
+
return float(np.sum(resid**2))
|
|
221
|
+
|
|
222
|
+
n_free = int(np.asarray(p_free0).size)
|
|
223
|
+
fev = 200 * n_free if max_fev is None else max_fev
|
|
224
|
+
res = minimize(
|
|
225
|
+
objective, p_free0, method="Nelder-Mead",
|
|
226
|
+
options={"maxiter": 30000, "maxfev": fev, "xatol": 1e-10, "fatol": 1e-14},
|
|
227
|
+
)
|
|
228
|
+
p_fit = expand(np.asarray(res.x, dtype=float))
|
|
229
|
+
|
|
230
|
+
bg_coeffs = p_fit[len(p_fit) - n_bg :]
|
|
231
|
+
fitted: list[dict[str, Any]] = []
|
|
232
|
+
for k in range(n_peaks):
|
|
233
|
+
base = k * n_per_peak
|
|
234
|
+
height = float(p_fit[base])
|
|
235
|
+
x0 = float(p_fit[base + 1])
|
|
236
|
+
fw = abs(float(p_fit[base + 2]))
|
|
237
|
+
eta = max(0.0, min(1.0, float(p_fit[base + 3]))) if is_pv else float("nan")
|
|
238
|
+
fitted.append({
|
|
239
|
+
"center": x0,
|
|
240
|
+
"fwhm": fw,
|
|
241
|
+
"height": height,
|
|
242
|
+
"bg": float(np.polyval(bg_coeffs[::-1], x0)),
|
|
243
|
+
"eta": eta,
|
|
244
|
+
"area": compute_peak_area(model, height, fw, eta),
|
|
245
|
+
"status": "fitted(global)",
|
|
246
|
+
"model": model,
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
y_fit = _composite_eval(p_fit, xv, n_peaks, n_per_peak, n_bg, model)
|
|
250
|
+
ss_res = float(np.sum((yv - y_fit) ** 2))
|
|
251
|
+
ss_tot = float(np.sum((yv - np.mean(yv)) ** 2))
|
|
252
|
+
return {
|
|
253
|
+
"peaks": fitted,
|
|
254
|
+
"bgCoeffs": [float(c) for c in bg_coeffs],
|
|
255
|
+
"R2": 1.0 - ss_res / max(ss_tot, _EPS),
|
|
256
|
+
"rmse": math.sqrt(ss_res / xv.size),
|
|
257
|
+
"params": [float(v) for v in p_fit],
|
|
258
|
+
"nPeaks": n_peaks,
|
|
259
|
+
"model": model,
|
|
260
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Track a peak across a series of datasets. Port of fitting.trackPeak.
|
|
2
|
+
|
|
3
|
+
Pure calc layer. Starting from a seed x-position, fits the nearest peak in each
|
|
4
|
+
dataset within a search window; with ``follow=True`` the window recenters on each
|
|
5
|
+
fitted position so a drifting peak (e.g. a Bragg reflection shifting with
|
|
6
|
+
temperature) stays in view. Uses the bounded single-peak ``curve_fit`` (Gaussian
|
|
7
|
+
or Lorentzian) and keeps a fit only when its R² exceeds 0.5.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import math
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
from numpy.typing import NDArray
|
|
17
|
+
|
|
18
|
+
from quantized.datastruct import DataStruct
|
|
19
|
+
|
|
20
|
+
from .fitting import curve_fit
|
|
21
|
+
|
|
22
|
+
__all__ = ["track_peak"]
|
|
23
|
+
|
|
24
|
+
_FWHM_PER_SIGMA = 2.355 # MATLAB trackPeak uses the rounded constant, not 2*sqrt(2 ln 2)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _gaussian(x: NDArray[np.float64], p: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
28
|
+
return np.asarray(p[0] * np.exp(-((x - p[1]) ** 2) / (2.0 * p[2] ** 2)), dtype=float)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _lorentzian(x: NDArray[np.float64], p: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
32
|
+
return np.asarray(p[0] / (1.0 + ((x - p[1]) / p[2]) ** 2), dtype=float)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _extract_xy(
|
|
36
|
+
ds: DataStruct | tuple[Any, Any] | list[Any], channel: int
|
|
37
|
+
) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
|
|
38
|
+
"""Pull (x, y) from a DataStruct (time / values[:, channel]) or an (x, y) pair."""
|
|
39
|
+
if isinstance(ds, DataStruct):
|
|
40
|
+
x = np.asarray(ds.time, dtype=float).ravel()
|
|
41
|
+
values = np.asarray(ds.values, dtype=float)
|
|
42
|
+
ch = min(channel, values.shape[1] - 1) if values.ndim == 2 else 0
|
|
43
|
+
y = values[:, ch] if values.ndim == 2 else values.ravel()
|
|
44
|
+
return x, np.asarray(y, dtype=float).ravel()
|
|
45
|
+
if isinstance(ds, (tuple, list)) and len(ds) >= 2:
|
|
46
|
+
return (
|
|
47
|
+
np.asarray(ds[0], dtype=float).ravel(),
|
|
48
|
+
np.asarray(ds[1], dtype=float).ravel(),
|
|
49
|
+
)
|
|
50
|
+
return np.empty(0), np.empty(0)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def track_peak(
|
|
54
|
+
datasets: list[Any],
|
|
55
|
+
seed_position: float,
|
|
56
|
+
*,
|
|
57
|
+
channel: int = 0,
|
|
58
|
+
window: float = 0.0,
|
|
59
|
+
shape: str = "gaussian",
|
|
60
|
+
min_height: float = 0.0,
|
|
61
|
+
follow: bool = True,
|
|
62
|
+
) -> dict[str, Any]:
|
|
63
|
+
"""Track a peak across ``datasets`` from ``seed_position``. Port of trackPeak.
|
|
64
|
+
|
|
65
|
+
Each dataset is a ``DataStruct`` or an ``(x, y)`` pair. ``window`` is the
|
|
66
|
+
search half-width in x-units (0 → auto, 5% of the x-range). ``shape`` is
|
|
67
|
+
``"gaussian"`` or ``"lorentzian"``. Returns per-dataset ``center``/``height``/
|
|
68
|
+
``fwhm``/``area``/``R2`` (NaN where no acceptable peak was found), ``found``
|
|
69
|
+
flags, and ``nDatasets``. ``channel`` is 0-based (MATLAB's is 1-based).
|
|
70
|
+
"""
|
|
71
|
+
if shape not in ("gaussian", "lorentzian"):
|
|
72
|
+
raise ValueError(f'shape must be "gaussian" or "lorentzian", got "{shape}"')
|
|
73
|
+
n = len(datasets)
|
|
74
|
+
nan = float("nan")
|
|
75
|
+
center = [nan] * n
|
|
76
|
+
height = [nan] * n
|
|
77
|
+
fwhm = [nan] * n
|
|
78
|
+
area = [nan] * n
|
|
79
|
+
r2 = [nan] * n
|
|
80
|
+
found = [False] * n
|
|
81
|
+
|
|
82
|
+
current_pos = float(seed_position)
|
|
83
|
+
model = _gaussian if shape == "gaussian" else _lorentzian
|
|
84
|
+
|
|
85
|
+
for i in range(n):
|
|
86
|
+
x_data, y_data = _extract_xy(datasets[i], channel)
|
|
87
|
+
if x_data.size < 5:
|
|
88
|
+
continue
|
|
89
|
+
|
|
90
|
+
hw = window if window > 0 else 0.05 * (float(np.max(x_data)) - float(np.min(x_data)))
|
|
91
|
+
mask = (x_data >= current_pos - hw) & (x_data <= current_pos + hw)
|
|
92
|
+
x_seg, y_seg = x_data[mask], y_data[mask]
|
|
93
|
+
if x_seg.size < 5:
|
|
94
|
+
continue
|
|
95
|
+
|
|
96
|
+
peak_idx = int(np.argmax(y_seg))
|
|
97
|
+
peak_h, peak_x = float(y_seg[peak_idx]), float(x_seg[peak_idx])
|
|
98
|
+
if peak_h < min_height:
|
|
99
|
+
continue
|
|
100
|
+
|
|
101
|
+
p0 = [peak_h, peak_x, hw / 3.0]
|
|
102
|
+
lower = [0.0, current_pos - hw, 0.0]
|
|
103
|
+
upper = [math.inf, current_pos + hw, hw]
|
|
104
|
+
try:
|
|
105
|
+
r = curve_fit(x_seg, y_seg, model, p0, lower=lower, upper=upper, calc_errors=False)
|
|
106
|
+
except (ValueError, FloatingPointError, np.linalg.LinAlgError):
|
|
107
|
+
continue
|
|
108
|
+
|
|
109
|
+
if r["R2"] <= 0.5:
|
|
110
|
+
continue
|
|
111
|
+
params = r["params"]
|
|
112
|
+
center[i] = float(params[1])
|
|
113
|
+
height[i] = float(params[0])
|
|
114
|
+
w = abs(float(params[2]))
|
|
115
|
+
if shape == "gaussian":
|
|
116
|
+
fwhm[i] = _FWHM_PER_SIGMA * w
|
|
117
|
+
area[i] = float(params[0]) * w * math.sqrt(2.0 * math.pi)
|
|
118
|
+
else:
|
|
119
|
+
fwhm[i] = 2.0 * w
|
|
120
|
+
area[i] = float(params[0]) * math.pi * w
|
|
121
|
+
r2[i] = float(r["R2"])
|
|
122
|
+
found[i] = True
|
|
123
|
+
if follow:
|
|
124
|
+
current_pos = center[i]
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
"center": center,
|
|
128
|
+
"height": height,
|
|
129
|
+
"fwhm": fwhm,
|
|
130
|
+
"area": area,
|
|
131
|
+
"R2": r2,
|
|
132
|
+
"found": found,
|
|
133
|
+
"nDatasets": n,
|
|
134
|
+
}
|
quantized/calc/peaks.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""Robust peak detection. Port of utilities.findPeaksRobust.
|
|
2
|
+
|
|
3
|
+
Pure calc layer (no Signal Toolbox equivalent — hand-rolled to match MATLAB).
|
|
4
|
+
Detects local maxima on a background-subtracted residual, then filters by
|
|
5
|
+
prominence, background slope, width (FWHM + points above half-max), local SNR,
|
|
6
|
+
and a greedy minimum-separation rule. Returns ``(peaks, background)`` where
|
|
7
|
+
``peaks`` is a list of dicts (one per peak).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import math
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
from numpy.typing import ArrayLike, NDArray
|
|
17
|
+
|
|
18
|
+
from .baseline import estimate_background
|
|
19
|
+
from .processing import _matlab_gradient
|
|
20
|
+
|
|
21
|
+
__all__ = ["find_peaks_robust"]
|
|
22
|
+
|
|
23
|
+
_EPS = float(np.finfo(float).eps)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _matlab_round(x: float) -> int:
|
|
27
|
+
return int(math.copysign(math.floor(abs(x) + 0.5), x))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _estimate_noise(y: NDArray[np.float64]) -> float:
|
|
31
|
+
"""Robust noise estimate from first differences (MAD/sqrt(2)), floored."""
|
|
32
|
+
diffs = np.diff(y)
|
|
33
|
+
sigma = 1.4826 * float(np.median(np.abs(diffs - np.median(diffs)))) / math.sqrt(2.0)
|
|
34
|
+
return max(sigma, float(np.max(y)) * 1e-6)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _prominence_bruteforce(
|
|
38
|
+
residual: NDArray[np.float64], max_idx: NDArray[np.intp]
|
|
39
|
+
) -> NDArray[np.float64]:
|
|
40
|
+
"""Reference O(M·walk) prominence: walk out each side until a higher sample.
|
|
41
|
+
|
|
42
|
+
Kept as the parity reference and the fallback for non-finite residuals
|
|
43
|
+
(NaN/Inf comparison semantics differ from the fast path)."""
|
|
44
|
+
n = residual.size
|
|
45
|
+
prom = np.zeros(max_idx.size)
|
|
46
|
+
for k in range(max_idx.size):
|
|
47
|
+
idx = int(max_idx[k])
|
|
48
|
+
pk_height = residual[idx]
|
|
49
|
+
left_min = pk_height
|
|
50
|
+
for j in range(idx - 1, -1, -1):
|
|
51
|
+
if residual[j] < left_min:
|
|
52
|
+
left_min = residual[j]
|
|
53
|
+
if residual[j] > pk_height:
|
|
54
|
+
break
|
|
55
|
+
right_min = pk_height
|
|
56
|
+
for j in range(idx + 1, n):
|
|
57
|
+
if residual[j] < right_min:
|
|
58
|
+
right_min = residual[j]
|
|
59
|
+
if residual[j] > pk_height:
|
|
60
|
+
break
|
|
61
|
+
prom[k] = pk_height - max(left_min, right_min)
|
|
62
|
+
return prom
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _nearest_greater(a: NDArray[np.float64], *, forward: bool) -> NDArray[np.intp]:
|
|
66
|
+
"""Index of the nearest strictly-greater element to each position.
|
|
67
|
+
|
|
68
|
+
``forward=False`` scans left (previous-greater, sentinel -1); ``forward=True``
|
|
69
|
+
scans right (next-greater, sentinel n). O(n) via a monotonic stack."""
|
|
70
|
+
n = a.size
|
|
71
|
+
res = np.empty(n, dtype=np.intp)
|
|
72
|
+
rng = range(n) if not forward else range(n - 1, -1, -1)
|
|
73
|
+
sentinel = -1 if not forward else n
|
|
74
|
+
stack: list[int] = []
|
|
75
|
+
for i in rng:
|
|
76
|
+
ai = a[i]
|
|
77
|
+
while stack and a[stack[-1]] <= ai:
|
|
78
|
+
stack.pop()
|
|
79
|
+
res[i] = stack[-1] if stack else sentinel
|
|
80
|
+
stack.append(i)
|
|
81
|
+
return res
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class _RangeMin:
|
|
85
|
+
"""Sparse table for static inclusive range-minimum (O(n log n) build, O(1) query)."""
|
|
86
|
+
|
|
87
|
+
def __init__(self, a: NDArray[np.float64]) -> None:
|
|
88
|
+
n = a.size
|
|
89
|
+
self.table: list[NDArray[np.float64]] = [np.asarray(a, dtype=float)]
|
|
90
|
+
j = 1
|
|
91
|
+
while (1 << j) <= n:
|
|
92
|
+
prev = self.table[j - 1]
|
|
93
|
+
span = 1 << (j - 1)
|
|
94
|
+
width = n - (1 << j) + 1
|
|
95
|
+
self.table.append(np.minimum(prev[:width], prev[span : span + width]))
|
|
96
|
+
j += 1
|
|
97
|
+
|
|
98
|
+
def query(self, lo: int, hi: int) -> float:
|
|
99
|
+
"""Minimum over the inclusive range ``[lo, hi]``; ``+inf`` if empty (lo>hi)."""
|
|
100
|
+
if lo > hi:
|
|
101
|
+
return float("inf")
|
|
102
|
+
j = (hi - lo + 1).bit_length() - 1
|
|
103
|
+
return float(min(self.table[j][lo], self.table[j][hi - (1 << j) + 1]))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _compute_prominence(
|
|
107
|
+
residual: NDArray[np.float64], max_idx: NDArray[np.intp]
|
|
108
|
+
) -> NDArray[np.float64]:
|
|
109
|
+
"""Topographic prominence for each candidate maximum.
|
|
110
|
+
|
|
111
|
+
For a peak at ``idx`` the prominence is ``residual[idx] - max(left_min,
|
|
112
|
+
right_min)`` where each side-min is taken over the run from the nearest
|
|
113
|
+
strictly-greater sample up to the peak. Computing the nearest-greater indices
|
|
114
|
+
(monotonic stacks) and the run-minima (sparse table) is O(n log n) instead of
|
|
115
|
+
the O(n²) per-candidate walk that bites on large, noisy data with many local
|
|
116
|
+
maxima. Bit-for-bit identical to :func:`_prominence_bruteforce` on finite
|
|
117
|
+
data (verified); non-finite residuals fall back to the brute force."""
|
|
118
|
+
if not bool(np.isfinite(residual).all()):
|
|
119
|
+
return _prominence_bruteforce(residual, max_idx)
|
|
120
|
+
pge = _nearest_greater(residual, forward=False)
|
|
121
|
+
nge = _nearest_greater(residual, forward=True)
|
|
122
|
+
rmin = _RangeMin(residual)
|
|
123
|
+
prom = np.zeros(max_idx.size)
|
|
124
|
+
for k in range(max_idx.size):
|
|
125
|
+
idx = int(max_idx[k])
|
|
126
|
+
pk = float(residual[idx])
|
|
127
|
+
left_min = min(pk, rmin.query(int(pge[idx]) + 1, idx - 1))
|
|
128
|
+
right_min = min(pk, rmin.query(idx + 1, int(nge[idx]) - 1))
|
|
129
|
+
prom[k] = pk - max(left_min, right_min)
|
|
130
|
+
return prom
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _estimate_fwhm(
|
|
134
|
+
x: NDArray[np.float64], residual: NDArray[np.float64], idx: int, n: int
|
|
135
|
+
) -> tuple[float, int]:
|
|
136
|
+
"""FWHM via half-max crossings (linearly interpolated) + points above half."""
|
|
137
|
+
half_max = residual[idx] / 2.0
|
|
138
|
+
|
|
139
|
+
l_idx = 0
|
|
140
|
+
for j in range(idx - 1, -1, -1):
|
|
141
|
+
if residual[j] <= half_max:
|
|
142
|
+
l_idx = j
|
|
143
|
+
break
|
|
144
|
+
if 0 <= l_idx < idx:
|
|
145
|
+
r1, r2 = residual[l_idx], residual[min(l_idx + 1, n - 1)]
|
|
146
|
+
if abs(r2 - r1) > _EPS:
|
|
147
|
+
frac = (half_max - r1) / (r2 - r1)
|
|
148
|
+
x_left = x[l_idx] + frac * (x[min(l_idx + 1, n - 1)] - x[l_idx])
|
|
149
|
+
else:
|
|
150
|
+
x_left = x[l_idx]
|
|
151
|
+
else:
|
|
152
|
+
x_left = x[max(0, idx - 1)]
|
|
153
|
+
|
|
154
|
+
r_idx = n - 1
|
|
155
|
+
for j in range(idx + 1, n):
|
|
156
|
+
if residual[j] <= half_max:
|
|
157
|
+
r_idx = j
|
|
158
|
+
break
|
|
159
|
+
if idx < r_idx <= n - 1:
|
|
160
|
+
r1, r2 = residual[r_idx], residual[max(r_idx - 1, 0)]
|
|
161
|
+
if abs(r2 - r1) > _EPS:
|
|
162
|
+
frac = (half_max - r1) / (r2 - r1)
|
|
163
|
+
x_right = x[r_idx] + frac * (x[max(r_idx - 1, 0)] - x[r_idx])
|
|
164
|
+
else:
|
|
165
|
+
x_right = x[r_idx]
|
|
166
|
+
else:
|
|
167
|
+
x_right = x[min(n - 1, idx + 1)]
|
|
168
|
+
|
|
169
|
+
fw = abs(x_right - x_left)
|
|
170
|
+
if fw <= 0:
|
|
171
|
+
fw = abs(x[min(n - 1, idx + 1)] - x[max(0, idx - 1)])
|
|
172
|
+
n_above = int(np.sum(residual[max(0, l_idx) : min(n, r_idx + 1)] >= half_max))
|
|
173
|
+
return fw, n_above
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def find_peaks_robust(
|
|
177
|
+
x: ArrayLike,
|
|
178
|
+
y: ArrayLike,
|
|
179
|
+
*,
|
|
180
|
+
snr_threshold: float = 5.0,
|
|
181
|
+
min_separation: float = 0.0,
|
|
182
|
+
max_peaks: int = 50,
|
|
183
|
+
max_window_deg: float = 2.0,
|
|
184
|
+
min_width_deg: float = 0.01,
|
|
185
|
+
max_width_deg: float = 10.0,
|
|
186
|
+
min_prominence: float = 0.02,
|
|
187
|
+
sensitivity: str = "medium",
|
|
188
|
+
) -> tuple[list[dict[str, Any]], NDArray[np.float64]]:
|
|
189
|
+
"""Detect peaks robustly. Port of utilities.findPeaksRobust.
|
|
190
|
+
|
|
191
|
+
Returns ``(peaks, background)``. Each peak dict carries center/fwhm/height/
|
|
192
|
+
area/xRange/status/bg/model/eta/prominence/localSNR (area/eta are NaN and
|
|
193
|
+
xRange empty — filled later by an explicit fit). ``sensitivity`` (low/medium/
|
|
194
|
+
high) tightens or loosens the SNR and prominence thresholds.
|
|
195
|
+
"""
|
|
196
|
+
xv = np.asarray(x, dtype=float).ravel()
|
|
197
|
+
yv = np.asarray(y, dtype=float).ravel()
|
|
198
|
+
n = yv.size
|
|
199
|
+
if n < 5:
|
|
200
|
+
return [], yv.copy()
|
|
201
|
+
|
|
202
|
+
if sensitivity == "high":
|
|
203
|
+
snr_thr, min_prom = min(snr_threshold, 3.0), min(min_prominence, 0.005)
|
|
204
|
+
elif sensitivity == "low":
|
|
205
|
+
snr_thr, min_prom = max(snr_threshold, 8.0), max(min_prominence, 0.05)
|
|
206
|
+
else:
|
|
207
|
+
snr_thr, min_prom = snr_threshold, min_prominence
|
|
208
|
+
|
|
209
|
+
x_span = float(xv.max() - xv.min())
|
|
210
|
+
bg = estimate_background(xv, yv, max_window_deg=max(max_window_deg, x_span * 0.05))
|
|
211
|
+
residual = yv - bg
|
|
212
|
+
global_noise = _estimate_noise(yv)
|
|
213
|
+
local_noise = global_noise * np.ones(n)
|
|
214
|
+
|
|
215
|
+
is_max = np.zeros(n, dtype=bool)
|
|
216
|
+
is_max[1:-1] = (residual[1:-1] >= residual[:-2]) & (residual[1:-1] > residual[2:])
|
|
217
|
+
is_max &= residual >= snr_thr * local_noise
|
|
218
|
+
max_idx = np.flatnonzero(is_max)
|
|
219
|
+
if max_idx.size == 0:
|
|
220
|
+
return [], bg
|
|
221
|
+
|
|
222
|
+
prom = _compute_prominence(residual, max_idx)
|
|
223
|
+
abs_prom_thresh = max(min_prom * float(residual.max()), 4 * global_noise)
|
|
224
|
+
rel_prom_ratio = prom / np.maximum(residual[max_idx], _EPS)
|
|
225
|
+
keep = (prom >= abs_prom_thresh) | ((rel_prom_ratio >= 0.15) & (prom >= 4 * global_noise))
|
|
226
|
+
max_idx, prom = max_idx[keep], prom[keep]
|
|
227
|
+
if max_idx.size == 0:
|
|
228
|
+
return [], bg
|
|
229
|
+
|
|
230
|
+
bg_grad = np.abs(_matlab_gradient(bg, xv))
|
|
231
|
+
nbr_span = np.abs(xv[np.minimum(n - 1, max_idx + 1)] - xv[np.maximum(0, max_idx - 1)])
|
|
232
|
+
slope_span = bg_grad[max_idx] * nbr_span
|
|
233
|
+
keep_slope = slope_span <= residual[max_idx] * 0.3
|
|
234
|
+
max_idx, prom = max_idx[keep_slope], prom[keep_slope]
|
|
235
|
+
if max_idx.size == 0:
|
|
236
|
+
return [], bg
|
|
237
|
+
|
|
238
|
+
dx = float(np.median(np.diff(xv)))
|
|
239
|
+
min_width_pts = max(4, _matlab_round(min_width_deg / max(dx, _EPS)))
|
|
240
|
+
pk_fwhm = np.zeros(max_idx.size)
|
|
241
|
+
valid = np.ones(max_idx.size, dtype=bool)
|
|
242
|
+
for k in range(max_idx.size):
|
|
243
|
+
fw, n_above = _estimate_fwhm(xv, residual, int(max_idx[k]), n)
|
|
244
|
+
pk_fwhm[k] = fw
|
|
245
|
+
if fw < min_width_deg or fw > max_width_deg or n_above < min_width_pts:
|
|
246
|
+
valid[k] = False
|
|
247
|
+
max_idx, prom, pk_fwhm = max_idx[valid], prom[valid], pk_fwhm[valid]
|
|
248
|
+
if max_idx.size == 0:
|
|
249
|
+
return [], bg
|
|
250
|
+
|
|
251
|
+
pk_x = xv[max_idx]
|
|
252
|
+
pk_h = residual[max_idx]
|
|
253
|
+
pk_bg = bg[max_idx]
|
|
254
|
+
pk_snr = pk_h / np.maximum(local_noise[max_idx], _EPS)
|
|
255
|
+
min_sep = min_separation if min_separation > 0 else x_span * 0.005
|
|
256
|
+
|
|
257
|
+
# Greedy minimum-separation suppression, strongest peak first.
|
|
258
|
+
order = np.argsort(-pk_h, kind="stable")
|
|
259
|
+
cx, ch, cbg, cfw, cprom, csnr = (
|
|
260
|
+
pk_x[order], pk_h[order], pk_bg[order], pk_fwhm[order], prom[order], pk_snr[order],
|
|
261
|
+
)
|
|
262
|
+
keep2 = np.ones(cx.size, dtype=bool)
|
|
263
|
+
for ii in range(cx.size):
|
|
264
|
+
if not keep2[ii]:
|
|
265
|
+
continue
|
|
266
|
+
for jj in range(ii + 1, cx.size):
|
|
267
|
+
if keep2[jj] and abs(cx[ii] - cx[jj]) < min_sep:
|
|
268
|
+
keep2[jj] = False
|
|
269
|
+
cx, ch, cbg, cfw, cprom, csnr = (
|
|
270
|
+
cx[keep2], ch[keep2], cbg[keep2], cfw[keep2], cprom[keep2], csnr[keep2],
|
|
271
|
+
)
|
|
272
|
+
if cx.size > max_peaks:
|
|
273
|
+
sl = slice(0, max_peaks)
|
|
274
|
+
cx, ch, cbg, cfw, cprom, csnr = cx[sl], ch[sl], cbg[sl], cfw[sl], cprom[sl], csnr[sl]
|
|
275
|
+
|
|
276
|
+
reorder = np.argsort(cx, kind="stable")
|
|
277
|
+
cx, ch, cbg, cfw, cprom, csnr = (
|
|
278
|
+
cx[reorder], ch[reorder], cbg[reorder], cfw[reorder], cprom[reorder], csnr[reorder],
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
nan = float("nan")
|
|
282
|
+
peaks = [
|
|
283
|
+
{
|
|
284
|
+
"center": float(cx[k]),
|
|
285
|
+
"fwhm": float(cf),
|
|
286
|
+
"height": float(ch[k]),
|
|
287
|
+
"area": nan,
|
|
288
|
+
"xRange": [],
|
|
289
|
+
"status": "auto",
|
|
290
|
+
"bg": float(cbg[k]),
|
|
291
|
+
"model": "",
|
|
292
|
+
"eta": nan,
|
|
293
|
+
"prominence": float(cprom[k]),
|
|
294
|
+
"localSNR": float(csnr[k]),
|
|
295
|
+
}
|
|
296
|
+
for k, cf in enumerate(cfw)
|
|
297
|
+
]
|
|
298
|
+
return peaks, bg
|