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,128 @@
|
|
|
1
|
+
"""Batch peak integration across a spectra series, with optional alignment.
|
|
2
|
+
|
|
3
|
+
ORIGIN_GAP_PLAN #35. Composes the shipped primitives — cross-correlation
|
|
4
|
+
alignment (``calc.spectral.cross_correlation``) and per-region trapezoid
|
|
5
|
+
integration (``calc.peak_integrate.integrate_peaks``) — over a stack of
|
|
6
|
+
spectra sharing one x-axis. Produces per-spectrum results plus area /
|
|
7
|
+
centroid / FWHM matrices (one row per spectrum, one column per region) so the
|
|
8
|
+
caller gets parameter-vs-spectrum trends for free. Failure is isolated
|
|
9
|
+
per spectrum: one bad trace yields a flagged row, never a dead batch (the
|
|
10
|
+
batch-run philosophy of #3).
|
|
11
|
+
|
|
12
|
+
Pure calc layer — ndarrays in, plain dict out.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
from numpy.typing import ArrayLike, NDArray
|
|
21
|
+
|
|
22
|
+
from quantized.calc.peak_integrate import integrate_peaks
|
|
23
|
+
from quantized.calc.spectral import cross_correlation
|
|
24
|
+
|
|
25
|
+
__all__ = ["batch_integrate_peaks"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _shift_samples(y: NDArray[np.float64], s: int) -> NDArray[np.float64]:
|
|
29
|
+
"""Shift ``y`` by ``s`` samples (``s>0`` delays / moves right); edge-filled.
|
|
30
|
+
|
|
31
|
+
Edge fill (not wrap-around) keeps a shifted feature from bleeding across the
|
|
32
|
+
trace ends into an integration region.
|
|
33
|
+
"""
|
|
34
|
+
n = y.size
|
|
35
|
+
if s == 0:
|
|
36
|
+
return y.copy()
|
|
37
|
+
out = np.empty_like(y)
|
|
38
|
+
if s > 0:
|
|
39
|
+
out[:s] = y[0]
|
|
40
|
+
out[s:] = y[: n - s]
|
|
41
|
+
else:
|
|
42
|
+
k = -s
|
|
43
|
+
out[n - k:] = y[-1]
|
|
44
|
+
out[: n - k] = y[k:]
|
|
45
|
+
return out
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def batch_integrate_peaks(
|
|
49
|
+
x: ArrayLike,
|
|
50
|
+
spectra: list[ArrayLike],
|
|
51
|
+
regions: list[tuple[float, float]],
|
|
52
|
+
*,
|
|
53
|
+
baseline: str = "linear",
|
|
54
|
+
align: bool = False,
|
|
55
|
+
reference: int = 0,
|
|
56
|
+
labels: list[str] | None = None,
|
|
57
|
+
) -> dict[str, Any]:
|
|
58
|
+
"""Integrate ``regions`` across every spectrum in ``spectra`` (shared ``x``).
|
|
59
|
+
|
|
60
|
+
With ``align=True`` each spectrum is cross-correlated against the
|
|
61
|
+
``reference`` spectrum and shifted by the integer sample lag so a common
|
|
62
|
+
feature lines up before integrating (regions are defined in the reference
|
|
63
|
+
frame). Returns per-spectrum results and area/centroid/FWHM matrices
|
|
64
|
+
``(n_spectra, n_regions)``; a spectrum that fails integration gets an
|
|
65
|
+
``error`` and an all-NaN row rather than aborting the batch.
|
|
66
|
+
"""
|
|
67
|
+
xv = np.asarray(x, dtype=float).ravel()
|
|
68
|
+
if len(spectra) == 0:
|
|
69
|
+
raise ValueError("batch_integrate_peaks needs at least one spectrum")
|
|
70
|
+
if not regions:
|
|
71
|
+
raise ValueError("batch_integrate_peaks needs at least one region")
|
|
72
|
+
if not 0 <= reference < len(spectra):
|
|
73
|
+
raise ValueError(f"reference index {reference} out of range")
|
|
74
|
+
if labels is not None and len(labels) != len(spectra):
|
|
75
|
+
raise ValueError("labels length must match the number of spectra")
|
|
76
|
+
|
|
77
|
+
ys = [np.asarray(s, dtype=float).ravel() for s in spectra]
|
|
78
|
+
for i, y in enumerate(ys):
|
|
79
|
+
if y.size != xv.size:
|
|
80
|
+
raise ValueError(f"spectrum {i} length ({y.size}) must equal x length ({xv.size})")
|
|
81
|
+
|
|
82
|
+
ref = ys[reference]
|
|
83
|
+
dx = float(np.median(np.diff(xv))) if xv.size > 1 else 0.0
|
|
84
|
+
n_reg = len(regions)
|
|
85
|
+
results: list[dict[str, Any]] = []
|
|
86
|
+
area_m, cen_m, fwhm_m = [], [], []
|
|
87
|
+
|
|
88
|
+
for i, y in enumerate(ys):
|
|
89
|
+
label = labels[i] if labels else f"spectrum {i + 1}"
|
|
90
|
+
row: dict[str, Any] = {"index": i, "label": label, "shift_samples": 0, "shift_x": 0.0}
|
|
91
|
+
try:
|
|
92
|
+
shift = 0
|
|
93
|
+
yi = y
|
|
94
|
+
if align and i != reference:
|
|
95
|
+
# cross_correlation(ref, y) peaks at the lag by which y trails
|
|
96
|
+
# ref; shift y back by that lag to align onto the reference.
|
|
97
|
+
shift = int(cross_correlation(ref, y)["peakLag"])
|
|
98
|
+
yi = _shift_samples(y, -shift)
|
|
99
|
+
row["shift_samples"], row["shift_x"] = shift, shift * dx
|
|
100
|
+
integ = integrate_peaks(xv, yi, regions, baseline=baseline)
|
|
101
|
+
row["ok"] = True
|
|
102
|
+
row["total_area"] = integ["total_area"]
|
|
103
|
+
row["peaks"] = integ["peaks"]
|
|
104
|
+
area_m.append([p["area"] for p in integ["peaks"]])
|
|
105
|
+
cen_m.append([p["centroid"] for p in integ["peaks"]])
|
|
106
|
+
fwhm_m.append([p["fwhm"] for p in integ["peaks"]])
|
|
107
|
+
except ValueError as exc:
|
|
108
|
+
row["ok"] = False
|
|
109
|
+
row["error"] = str(exc)
|
|
110
|
+
row["total_area"] = float("nan")
|
|
111
|
+
area_m.append([float("nan")] * n_reg)
|
|
112
|
+
cen_m.append([float("nan")] * n_reg)
|
|
113
|
+
fwhm_m.append([float("nan")] * n_reg)
|
|
114
|
+
results.append(row)
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
"regions": [[float(lo), float(hi)] for lo, hi in regions],
|
|
118
|
+
"n_spectra": len(ys),
|
|
119
|
+
"n_regions": n_reg,
|
|
120
|
+
"aligned": bool(align),
|
|
121
|
+
"reference": reference,
|
|
122
|
+
"baseline": baseline,
|
|
123
|
+
"results": results,
|
|
124
|
+
"area_matrix": area_m,
|
|
125
|
+
"centroid_matrix": cen_m,
|
|
126
|
+
"fwhm_matrix": fwhm_m,
|
|
127
|
+
"n_failed": sum(1 for r in results if not r["ok"]),
|
|
128
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""Single-peak fitting + peak de-duplication. Port of +bosonPlotter/+peak.
|
|
2
|
+
|
|
3
|
+
Pure calc layer (ndarray in → result dict out). ``fit_single_peak`` fits one
|
|
4
|
+
peak inside a user window to one of five line-shape models via Nelder-Mead
|
|
5
|
+
(scipy's ``minimize`` ↔ MATLAB ``fminsearch`` — shared simplex constants), then
|
|
6
|
+
derives FWHM / area / eta exactly as ``+bosonPlotter/+peak/fitSinglePeak.m``.
|
|
7
|
+
``deduplicate_peaks`` is the overlap-merge rule from ``deduplicatePeaks.m``.
|
|
8
|
+
|
|
9
|
+
The objective mirrors MATLAB's choice of evaluator per model: raw inline
|
|
10
|
+
formulas (no clamping) for Gaussian/Lorentzian/Pseudo-Voigt, and the validating
|
|
11
|
+
``split_pearson_vii`` / ``tch_pseudo_voigt`` for the asymmetric/TCH models — so a
|
|
12
|
+
simplex that probes an invalid region aborts the fit with ``fminsearch-error``
|
|
13
|
+
the same way MATLAB's ``utilities.splitPearsonVII`` ``error`` does.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import math
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
import numpy as np
|
|
22
|
+
from numpy.typing import ArrayLike, NDArray
|
|
23
|
+
from scipy.optimize import minimize
|
|
24
|
+
|
|
25
|
+
from .peakshapes import split_pearson_vii, tch_pseudo_voigt
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"MODELS",
|
|
29
|
+
"deduplicate_peaks",
|
|
30
|
+
"eval_multi_peak",
|
|
31
|
+
"eval_multi_peak_pv",
|
|
32
|
+
"fit_single_peak",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
_LN2 = math.log(2.0)
|
|
36
|
+
_A_L = math.pi / 2.0 # integrated-area constant, Lorentzian
|
|
37
|
+
_A_G = math.sqrt(math.pi) / (2.0 * math.sqrt(_LN2)) # ... Gaussian
|
|
38
|
+
|
|
39
|
+
MODELS = ("Lorentzian", "Gaussian", "Pseudo-Voigt", "Split Pearson VII", "TCH-pV")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _model_eval(model: str, p: NDArray[np.float64], x: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
43
|
+
"""Raw model evaluation used inside the objective (matches fitSinglePeak.m)."""
|
|
44
|
+
if model == "Gaussian":
|
|
45
|
+
return np.asarray(p[0] * np.exp(-4.0 * _LN2 * ((x - p[1]) / p[2]) ** 2) + p[3], dtype=float)
|
|
46
|
+
if model == "Pseudo-Voigt":
|
|
47
|
+
u = (x - p[1]) / p[2]
|
|
48
|
+
lor = 1.0 / (1.0 + 4.0 * u**2)
|
|
49
|
+
gau = np.exp(-4.0 * _LN2 * u**2)
|
|
50
|
+
return np.asarray(p[0] * (p[4] * lor + (1.0 - p[4]) * gau) + p[3], dtype=float)
|
|
51
|
+
if model == "Split Pearson VII":
|
|
52
|
+
return split_pearson_vii(x, p)
|
|
53
|
+
if model == "TCH-pV":
|
|
54
|
+
return tch_pseudo_voigt(x, p)
|
|
55
|
+
# Lorentzian (default)
|
|
56
|
+
return np.asarray(p[0] / (1.0 + 4.0 * ((x - p[1]) / p[2]) ** 2) + p[3], dtype=float)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _result(reason: str, model: str, window: list[float]) -> dict[str, Any]:
|
|
60
|
+
nan = float("nan")
|
|
61
|
+
return {
|
|
62
|
+
"success": False, "reason": reason, "center": nan, "fwhm": nan, "height": nan,
|
|
63
|
+
"bg": nan, "eta": nan, "area": nan, "params": [], "model": model, "window": window,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def fit_single_peak(
|
|
68
|
+
x: ArrayLike,
|
|
69
|
+
y: ArrayLike,
|
|
70
|
+
x_lo: float,
|
|
71
|
+
x_hi: float,
|
|
72
|
+
*,
|
|
73
|
+
seed_center: float,
|
|
74
|
+
seed_fwhm: float = float("nan"),
|
|
75
|
+
model: str = "Lorentzian",
|
|
76
|
+
snip_bg: ArrayLike | None = None,
|
|
77
|
+
) -> dict[str, Any]:
|
|
78
|
+
"""Fit one peak in ``[x_lo, x_hi]`` to ``model``. Port of fitSinglePeak.m.
|
|
79
|
+
|
|
80
|
+
``model`` ∈ ``MODELS``. ``seed_center``/``seed_fwhm`` seed the initial guess
|
|
81
|
+
(``seed_fwhm`` NaN → derived from the window). ``snip_bg`` (optional, aligned
|
|
82
|
+
with ``x``) is subtracted at finite positions before fitting. Returns a dict
|
|
83
|
+
with ``success``/``reason`` and, on success, ``center``/``fwhm``/``height``/
|
|
84
|
+
``bg``/``eta``/``area``/``params``. ``reason`` ∈ {``too-few-points``,
|
|
85
|
+
``window-too-narrow``, ``center-drift``, ``fwhm-too-wide``, ``fminsearch-error``}.
|
|
86
|
+
"""
|
|
87
|
+
xv = np.asarray(x, dtype=float).ravel()
|
|
88
|
+
yv = np.asarray(y, dtype=float).ravel()
|
|
89
|
+
window = [float(x_lo), float(x_hi)]
|
|
90
|
+
|
|
91
|
+
if xv.size < 5:
|
|
92
|
+
return _result("too-few-points", model, window)
|
|
93
|
+
x_span = float(np.max(xv) - np.min(xv))
|
|
94
|
+
|
|
95
|
+
y_work = yv.copy()
|
|
96
|
+
if snip_bg is not None:
|
|
97
|
+
bgv = np.asarray(snip_bg, dtype=float).ravel()
|
|
98
|
+
if bgv.size == yv.size:
|
|
99
|
+
ok = np.isfinite(bgv)
|
|
100
|
+
y_work[ok] = yv[ok] - bgv[ok]
|
|
101
|
+
|
|
102
|
+
in_win = (xv >= x_lo) & (xv <= x_hi)
|
|
103
|
+
if int(np.sum(in_win)) < 4:
|
|
104
|
+
return _result("window-too-narrow", model, window)
|
|
105
|
+
x_fit = xv[in_win]
|
|
106
|
+
y_fit = y_work[in_win]
|
|
107
|
+
|
|
108
|
+
# ── Initial guesses (interp1 'linear' with max(y_fit) as the extrap value) ─
|
|
109
|
+
bg0 = float(np.min(y_fit))
|
|
110
|
+
if x_fit[0] <= x_fit[-1]:
|
|
111
|
+
xi, yi = x_fit, y_fit
|
|
112
|
+
else: # honour decreasing-x active data
|
|
113
|
+
xi, yi = x_fit[::-1], y_fit[::-1]
|
|
114
|
+
if xi[0] <= seed_center <= xi[-1]:
|
|
115
|
+
h0 = float(np.interp(seed_center, xi, yi)) - bg0
|
|
116
|
+
else:
|
|
117
|
+
h0 = float(np.max(y_fit)) - bg0
|
|
118
|
+
if h0 <= 0:
|
|
119
|
+
h0 = float(np.max(y_fit)) - bg0
|
|
120
|
+
if math.isfinite(seed_fwhm) and seed_fwhm > 0:
|
|
121
|
+
fw0 = float(seed_fwhm)
|
|
122
|
+
else:
|
|
123
|
+
dx = (x_fit[-1] - x_fit[0]) / max(1, x_fit.size - 1)
|
|
124
|
+
fw0 = max((x_hi - x_lo) * 0.3, dx * 2.0)
|
|
125
|
+
|
|
126
|
+
is_pv = model == "Pseudo-Voigt"
|
|
127
|
+
is_spvii = model == "Split Pearson VII"
|
|
128
|
+
is_tch = model == "TCH-pV"
|
|
129
|
+
if is_spvii:
|
|
130
|
+
hw0 = fw0 / 2.0
|
|
131
|
+
p0 = np.array([h0, seed_center, hw0, hw0, 1.5, 1.5, bg0], dtype=float)
|
|
132
|
+
elif is_tch:
|
|
133
|
+
fw_seed = fw0 / math.sqrt(2.0)
|
|
134
|
+
p0 = np.array([h0, seed_center, fw_seed, fw_seed, bg0], dtype=float)
|
|
135
|
+
elif is_pv:
|
|
136
|
+
p0 = np.array([h0, seed_center, fw0, bg0, 0.5], dtype=float)
|
|
137
|
+
else:
|
|
138
|
+
p0 = np.array([h0, seed_center, fw0, bg0], dtype=float)
|
|
139
|
+
|
|
140
|
+
def objective(p: NDArray[np.float64]) -> float:
|
|
141
|
+
resid = _model_eval(model, p, x_fit) - y_fit
|
|
142
|
+
return float(np.sum(resid**2))
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
res = minimize(
|
|
146
|
+
objective, p0, method="Nelder-Mead",
|
|
147
|
+
options={"maxiter": 8000, "maxfev": 8000, "xatol": 1e-10, "fatol": 1e-14},
|
|
148
|
+
)
|
|
149
|
+
p_fit = np.asarray(res.x, dtype=float)
|
|
150
|
+
except Exception: # noqa: BLE001 — mirror MATLAB's blanket fminsearch try/catch
|
|
151
|
+
return _result("fminsearch-error", model, window)
|
|
152
|
+
|
|
153
|
+
if is_spvii:
|
|
154
|
+
fwhm_fit = abs(float(p_fit[2])) + abs(float(p_fit[3]))
|
|
155
|
+
eta_fit = float("nan")
|
|
156
|
+
bg_fit = float(p_fit[6])
|
|
157
|
+
elif is_tch:
|
|
158
|
+
f_g, f_l = abs(float(p_fit[2])), abs(float(p_fit[3]))
|
|
159
|
+
f5 = (
|
|
160
|
+
f_g**5 + 2.69269 * f_g**4 * f_l + 2.42843 * f_g**3 * f_l**2
|
|
161
|
+
+ 4.47163 * f_g**2 * f_l**3 + 0.07842 * f_g * f_l**4 + f_l**5
|
|
162
|
+
)
|
|
163
|
+
fwhm_fit = f5 ** (1.0 / 5.0)
|
|
164
|
+
if fwhm_fit > 0:
|
|
165
|
+
rr = f_l / fwhm_fit
|
|
166
|
+
eta_fit = max(0.0, min(1.0, 1.36603 * rr - 0.47719 * rr**2 + 0.11116 * rr**3))
|
|
167
|
+
else:
|
|
168
|
+
eta_fit = float("nan")
|
|
169
|
+
bg_fit = float(p_fit[4])
|
|
170
|
+
else:
|
|
171
|
+
fwhm_fit = abs(float(p_fit[2]))
|
|
172
|
+
eta_fit = max(0.0, min(1.0, float(p_fit[4]))) if is_pv else float("nan")
|
|
173
|
+
bg_fit = float(p_fit[3])
|
|
174
|
+
|
|
175
|
+
if p_fit[1] < x_lo or p_fit[1] > x_hi:
|
|
176
|
+
return _result("center-drift", model, window)
|
|
177
|
+
if not (fwhm_fit > 0 and fwhm_fit < x_span * 0.5):
|
|
178
|
+
return _result("fwhm-too-wide", model, window)
|
|
179
|
+
|
|
180
|
+
height = float(p_fit[0])
|
|
181
|
+
if model == "Gaussian":
|
|
182
|
+
area = height * fwhm_fit * math.sqrt(math.pi / _LN2) / 2.0
|
|
183
|
+
elif model in ("Pseudo-Voigt", "TCH-pV"):
|
|
184
|
+
area = height * fwhm_fit * (eta_fit * _A_L + (1.0 - eta_fit) * _A_G)
|
|
185
|
+
elif is_spvii:
|
|
186
|
+
x_dense = np.linspace(x_lo, x_hi, 500)
|
|
187
|
+
y_dense = split_pearson_vii(x_dense, p_fit) - float(p_fit[6])
|
|
188
|
+
area = float(np.trapezoid(y_dense, x_dense))
|
|
189
|
+
else: # Lorentzian
|
|
190
|
+
area = height * fwhm_fit * math.pi / 2.0
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
"success": True, "reason": "", "center": float(p_fit[1]), "fwhm": fwhm_fit,
|
|
194
|
+
"height": height, "bg": bg_fit, "eta": eta_fit, "area": area,
|
|
195
|
+
"params": [float(v) for v in p_fit], "model": model, "window": window,
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def eval_multi_peak(
|
|
200
|
+
p: ArrayLike, x: ArrayLike, n_peaks: int, *, gaussian: bool = False
|
|
201
|
+
) -> NDArray[np.float64]:
|
|
202
|
+
"""Sum of ``n_peaks`` Lorentzian (or Gaussian) peaks + linear background.
|
|
203
|
+
|
|
204
|
+
Port of evalMultiPeak.m. ``p`` layout: ``[H, x0, fw] * n_peaks`` then
|
|
205
|
+
``[slope, intercept]``. ``gaussian=True`` selects the Gaussian shape.
|
|
206
|
+
"""
|
|
207
|
+
pv = np.asarray(p, dtype=float).ravel()
|
|
208
|
+
xv = np.asarray(x, dtype=float)
|
|
209
|
+
y = pv[-2] * xv + pv[-1]
|
|
210
|
+
for k in range(n_peaks):
|
|
211
|
+
height, x0, fw = pv[k * 3], pv[k * 3 + 1], abs(pv[k * 3 + 2])
|
|
212
|
+
if gaussian:
|
|
213
|
+
y = y + height * np.exp(-4.0 * _LN2 * ((xv - x0) / fw) ** 2)
|
|
214
|
+
else:
|
|
215
|
+
y = y + height / (1.0 + 4.0 * ((xv - x0) / fw) ** 2)
|
|
216
|
+
return np.asarray(y, dtype=float)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def eval_multi_peak_pv(p: ArrayLike, x: ArrayLike, n_peaks: int) -> NDArray[np.float64]:
|
|
220
|
+
"""Sum of ``n_peaks`` pseudo-Voigt peaks + linear background.
|
|
221
|
+
|
|
222
|
+
Port of evalMultiPeakPV.m. ``p`` layout: ``[H, x0, fw, eta] * n_peaks`` then
|
|
223
|
+
``[slope, intercept]``; ``eta`` is clamped to [0, 1].
|
|
224
|
+
"""
|
|
225
|
+
pv = np.asarray(p, dtype=float).ravel()
|
|
226
|
+
xv = np.asarray(x, dtype=float)
|
|
227
|
+
y = pv[-2] * xv + pv[-1]
|
|
228
|
+
for k in range(n_peaks):
|
|
229
|
+
height, x0, fw = pv[k * 4], pv[k * 4 + 1], abs(pv[k * 4 + 2])
|
|
230
|
+
eta = max(0.0, min(1.0, float(pv[k * 4 + 3])))
|
|
231
|
+
u = (xv - x0) / fw
|
|
232
|
+
lor = height / (1.0 + 4.0 * u**2)
|
|
233
|
+
gau = height * np.exp(-4.0 * _LN2 * u**2)
|
|
234
|
+
y = y + eta * lor + (1.0 - eta) * gau
|
|
235
|
+
return np.asarray(y, dtype=float)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def deduplicate_peaks(peaks: list[dict[str, Any]], min_sep: float) -> list[dict[str, Any]]:
|
|
239
|
+
"""Drop peaks within ``min_sep`` of each other, keeping the taller (``auto``
|
|
240
|
+
beats ``manual`` at equal height). Port of deduplicatePeaks.m."""
|
|
241
|
+
n = len(peaks)
|
|
242
|
+
if n <= 1:
|
|
243
|
+
return list(peaks)
|
|
244
|
+
keep = [True] * n
|
|
245
|
+
for i in range(n):
|
|
246
|
+
if not keep[i]:
|
|
247
|
+
continue
|
|
248
|
+
for j in range(i + 1, n):
|
|
249
|
+
if not keep[j]:
|
|
250
|
+
continue
|
|
251
|
+
if abs(float(peaks[i]["center"]) - float(peaks[j]["center"])) < min_sep:
|
|
252
|
+
hi, hj = float(peaks[i]["height"]), float(peaks[j]["height"])
|
|
253
|
+
i_wins = hi > hj or (hi == hj and peaks[i].get("status") == "auto")
|
|
254
|
+
if i_wins:
|
|
255
|
+
keep[j] = False
|
|
256
|
+
else:
|
|
257
|
+
keep[i] = False
|
|
258
|
+
break
|
|
259
|
+
return [pk for pk, k in zip(peaks, keep, strict=True) if k]
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Integrate-only peak analysis: areas / centroid / FWHM without a fit.
|
|
2
|
+
|
|
3
|
+
ORIGIN_GAP_PLAN #32 backend — per-region trapezoidal integration over a
|
|
4
|
+
local shoulder-to-shoulder linear baseline (or none), plus the %-area
|
|
5
|
+
deconvolution table. Wizard page 5's "integrate instead of fit" path.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
from numpy.typing import ArrayLike, NDArray
|
|
14
|
+
|
|
15
|
+
__all__ = ["integrate_peaks"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _fwhm(xm: NDArray[np.float64], net: NDArray[np.float64], height: float) -> float:
|
|
19
|
+
"""Full width at half the (net) maximum via linear-interp crossings."""
|
|
20
|
+
if height <= 0:
|
|
21
|
+
return float("nan")
|
|
22
|
+
half = height / 2.0
|
|
23
|
+
above = net >= half
|
|
24
|
+
if not above.any():
|
|
25
|
+
return float("nan")
|
|
26
|
+
i = int(np.argmax(above))
|
|
27
|
+
j = int(len(above) - 1 - np.argmax(above[::-1]))
|
|
28
|
+
# left crossing between i-1 and i (exact when the edge point sits on half)
|
|
29
|
+
if i == 0:
|
|
30
|
+
left = float(xm[0])
|
|
31
|
+
else:
|
|
32
|
+
f = (half - net[i - 1]) / (net[i] - net[i - 1])
|
|
33
|
+
left = float(xm[i - 1] + f * (xm[i] - xm[i - 1]))
|
|
34
|
+
if j == len(net) - 1:
|
|
35
|
+
right = float(xm[-1])
|
|
36
|
+
else:
|
|
37
|
+
f = (half - net[j + 1]) / (net[j] - net[j + 1])
|
|
38
|
+
right = float(xm[j + 1] - f * (xm[j + 1] - xm[j]))
|
|
39
|
+
return right - left
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def integrate_peaks(
|
|
43
|
+
x: ArrayLike,
|
|
44
|
+
y: ArrayLike,
|
|
45
|
+
regions: list[tuple[float, float]],
|
|
46
|
+
*,
|
|
47
|
+
baseline: str = "linear",
|
|
48
|
+
) -> dict[str, Any]:
|
|
49
|
+
"""Integrate each x-region of a trace without fitting a model.
|
|
50
|
+
|
|
51
|
+
``baseline='linear'`` subtracts the straight line through the region's
|
|
52
|
+
endpoints (shoulder-to-shoulder — standard manual peak integration);
|
|
53
|
+
``'none'`` integrates the raw trace. Per region: net area (trapezoid),
|
|
54
|
+
intensity-weighted centroid, net height + its position, FWHM of the net
|
|
55
|
+
signal, and the percent of the summed area (the deconvolution table).
|
|
56
|
+
"""
|
|
57
|
+
if baseline not in ("linear", "none"):
|
|
58
|
+
raise ValueError(f'baseline must be "linear" or "none", got "{baseline}"')
|
|
59
|
+
if not regions:
|
|
60
|
+
raise ValueError("integrate_peaks needs at least one region")
|
|
61
|
+
xv = np.asarray(x, dtype=float).ravel()
|
|
62
|
+
yv = np.asarray(y, dtype=float).ravel()
|
|
63
|
+
if xv.size != yv.size:
|
|
64
|
+
raise ValueError("x and y must have the same length")
|
|
65
|
+
finite = np.isfinite(xv) & np.isfinite(yv)
|
|
66
|
+
xv, yv = xv[finite], yv[finite]
|
|
67
|
+
order = np.argsort(xv, kind="stable")
|
|
68
|
+
xv, yv = xv[order], yv[order]
|
|
69
|
+
|
|
70
|
+
peaks: list[dict[str, Any]] = []
|
|
71
|
+
for k, (r0, r1) in enumerate(regions):
|
|
72
|
+
lo, hi = (r0, r1) if r0 <= r1 else (r1, r0)
|
|
73
|
+
mask = (xv >= lo) & (xv <= hi)
|
|
74
|
+
if int(mask.sum()) < 3:
|
|
75
|
+
raise ValueError(f"region {k} [{lo:g}, {hi:g}] contains fewer than 3 points")
|
|
76
|
+
xm, ym = xv[mask], yv[mask]
|
|
77
|
+
if baseline == "linear":
|
|
78
|
+
slope = (ym[-1] - ym[0]) / (xm[-1] - xm[0]) if xm[-1] != xm[0] else 0.0
|
|
79
|
+
base = ym[0] + slope * (xm - xm[0])
|
|
80
|
+
else:
|
|
81
|
+
base = np.zeros_like(ym)
|
|
82
|
+
net = ym - base
|
|
83
|
+
area = float(np.trapezoid(net, xm))
|
|
84
|
+
weight = float(np.trapezoid(np.abs(net), xm))
|
|
85
|
+
if weight > 0:
|
|
86
|
+
centroid = float(np.trapezoid(xm * np.abs(net), xm) / weight)
|
|
87
|
+
else:
|
|
88
|
+
centroid = float("nan")
|
|
89
|
+
i_max = int(np.argmax(net))
|
|
90
|
+
height = float(net[i_max])
|
|
91
|
+
peaks.append({
|
|
92
|
+
"region": [lo, hi],
|
|
93
|
+
"area": area,
|
|
94
|
+
"centroid": centroid,
|
|
95
|
+
"height": height,
|
|
96
|
+
"position": float(xm[i_max]),
|
|
97
|
+
"fwhm": _fwhm(xm, net, height),
|
|
98
|
+
"n_points": int(mask.sum()),
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
total = sum(p["area"] for p in peaks)
|
|
102
|
+
for p in peaks:
|
|
103
|
+
p["area_pct"] = 100.0 * p["area"] / total if total != 0 else float("nan")
|
|
104
|
+
return {"peaks": peaks, "total_area": total, "baseline": baseline}
|