quantized-lab 0.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (233) hide show
  1. quantized/__init__.py +11 -0
  2. quantized/__main__.py +14 -0
  3. quantized/api.py +220 -0
  4. quantized/app.py +206 -0
  5. quantized/calc/__init__.py +8 -0
  6. quantized/calc/_clipfit.py +76 -0
  7. quantized/calc/_natural_neighbor.py +219 -0
  8. quantized/calc/aggregate.py +159 -0
  9. quantized/calc/backgrounds.py +353 -0
  10. quantized/calc/baseline.py +349 -0
  11. quantized/calc/batch_fit.py +148 -0
  12. quantized/calc/constants.py +27 -0
  13. quantized/calc/corrections.py +192 -0
  14. quantized/calc/crystallography.py +400 -0
  15. quantized/calc/diffusion.py +120 -0
  16. quantized/calc/electrical.py +248 -0
  17. quantized/calc/electrochemistry.py +176 -0
  18. quantized/calc/element_data.json +1 -0
  19. quantized/calc/element_data.py +59 -0
  20. quantized/calc/errors.py +246 -0
  21. quantized/calc/figure.py +417 -0
  22. quantized/calc/figure_break.py +152 -0
  23. quantized/calc/figure_categorical.py +156 -0
  24. quantized/calc/figure_corner.py +229 -0
  25. quantized/calc/figure_facets.py +127 -0
  26. quantized/calc/figure_field.py +137 -0
  27. quantized/calc/figure_hitmap.py +116 -0
  28. quantized/calc/figure_labels.py +62 -0
  29. quantized/calc/figure_map.py +287 -0
  30. quantized/calc/figure_overrides.py +159 -0
  31. quantized/calc/figure_page.py +266 -0
  32. quantized/calc/figure_scale.py +125 -0
  33. quantized/calc/figure_statplots.py +167 -0
  34. quantized/calc/figure_styles.py +131 -0
  35. quantized/calc/figure_ternary.py +239 -0
  36. quantized/calc/figure_ticks.py +217 -0
  37. quantized/calc/fit_autoguess.py +156 -0
  38. quantized/calc/fit_bootstrap.py +163 -0
  39. quantized/calc/fit_bumps.py +258 -0
  40. quantized/calc/fit_constraints.py +114 -0
  41. quantized/calc/fit_equation.py +264 -0
  42. quantized/calc/fit_findxy.py +80 -0
  43. quantized/calc/fit_models.py +195 -0
  44. quantized/calc/fit_models_special.py +189 -0
  45. quantized/calc/fit_odr.py +99 -0
  46. quantized/calc/fit_scan.py +243 -0
  47. quantized/calc/fit_stats.py +215 -0
  48. quantized/calc/fitting.py +199 -0
  49. quantized/calc/formula.py +90 -0
  50. quantized/calc/global_curve_fit.py +305 -0
  51. quantized/calc/global_fit.py +181 -0
  52. quantized/calc/interp2d.py +260 -0
  53. quantized/calc/linecut.py +269 -0
  54. quantized/calc/magnetic.py +414 -0
  55. quantized/calc/magnetometry.py +464 -0
  56. quantized/calc/map.py +228 -0
  57. quantized/calc/mcmc.py +177 -0
  58. quantized/calc/optics.py +228 -0
  59. quantized/calc/pawley.py +251 -0
  60. quantized/calc/peak_batch.py +128 -0
  61. quantized/calc/peak_fit.py +259 -0
  62. quantized/calc/peak_integrate.py +104 -0
  63. quantized/calc/peak_multifit.py +260 -0
  64. quantized/calc/peak_track.py +134 -0
  65. quantized/calc/peaks.py +298 -0
  66. quantized/calc/peakshapes.py +85 -0
  67. quantized/calc/plotting.py +147 -0
  68. quantized/calc/processing.py +232 -0
  69. quantized/calc/qspace.py +48 -0
  70. quantized/calc/reductions.py +155 -0
  71. quantized/calc/reductions_fft.py +383 -0
  72. quantized/calc/refl_sld_presets.json +1 -0
  73. quantized/calc/reflectivity.py +80 -0
  74. quantized/calc/registry.py +303 -0
  75. quantized/calc/relaxation.py +119 -0
  76. quantized/calc/report.py +253 -0
  77. quantized/calc/report_emit.py +227 -0
  78. quantized/calc/resample.py +142 -0
  79. quantized/calc/rsm.py +91 -0
  80. quantized/calc/rsm_analyze.py +245 -0
  81. quantized/calc/semiconductor.py +488 -0
  82. quantized/calc/sld.py +131 -0
  83. quantized/calc/sld_formula.py +138 -0
  84. quantized/calc/spectral.py +357 -0
  85. quantized/calc/statplots.py +214 -0
  86. quantized/calc/stats.py +399 -0
  87. quantized/calc/stats_anova2.py +202 -0
  88. quantized/calc/stats_anova_ext.py +338 -0
  89. quantized/calc/stats_dist.py +196 -0
  90. quantized/calc/stats_glm.py +245 -0
  91. quantized/calc/stats_multivar.py +289 -0
  92. quantized/calc/stats_roc.py +157 -0
  93. quantized/calc/stats_survival.py +261 -0
  94. quantized/calc/stats_tests.py +380 -0
  95. quantized/calc/substrates.py +181 -0
  96. quantized/calc/superconductor.py +359 -0
  97. quantized/calc/surface_fit.py +290 -0
  98. quantized/calc/surface_models.py +156 -0
  99. quantized/calc/thermal.py +119 -0
  100. quantized/calc/thin_film.py +425 -0
  101. quantized/calc/unit_convert.py +259 -0
  102. quantized/calc/units.py +80 -0
  103. quantized/calc/vacuum.py +290 -0
  104. quantized/calc/xray.py +169 -0
  105. quantized/cli.py +214 -0
  106. quantized/datastruct.py +153 -0
  107. quantized/io/__init__.py +11 -0
  108. quantized/io/_hdf5_layout.py +308 -0
  109. quantized/io/_jcamp_asdf.py +135 -0
  110. quantized/io/_xrdml_scan.py +291 -0
  111. quantized/io/base.py +82 -0
  112. quantized/io/bruker_brml.py +177 -0
  113. quantized/io/bruker_raw.py +158 -0
  114. quantized/io/cif.py +266 -0
  115. quantized/io/consolidated.py +122 -0
  116. quantized/io/delimited.py +222 -0
  117. quantized/io/excel.py +135 -0
  118. quantized/io/hdf5.py +192 -0
  119. quantized/io/import_filters.py +178 -0
  120. quantized/io/import_preview.py +262 -0
  121. quantized/io/jcamp.py +179 -0
  122. quantized/io/lakeshore.py +163 -0
  123. quantized/io/ncnr.py +278 -0
  124. quantized/io/netcdf.py +195 -0
  125. quantized/io/opus.py +231 -0
  126. quantized/io/origin.py +346 -0
  127. quantized/io/origin_com.py +194 -0
  128. quantized/io/origin_project/__init__.py +221 -0
  129. quantized/io/origin_project/annotation_marks.py +288 -0
  130. quantized/io/origin_project/container.py +262 -0
  131. quantized/io/origin_project/curve_style_color.py +359 -0
  132. quantized/io/origin_project/figure_geometry.py +108 -0
  133. quantized/io/origin_project/figure_layers.py +333 -0
  134. quantized/io/origin_project/figure_text.py +258 -0
  135. quantized/io/origin_project/figures.py +210 -0
  136. quantized/io/origin_project/figures_opju.py +440 -0
  137. quantized/io/origin_project/notes.py +302 -0
  138. quantized/io/origin_project/opj.py +459 -0
  139. quantized/io/origin_project/opj_curves.py +297 -0
  140. quantized/io/origin_project/opj_shapes.py +148 -0
  141. quantized/io/origin_project/opju.py +146 -0
  142. quantized/io/origin_project/opju_axis_real_form.py +418 -0
  143. quantized/io/origin_project/opju_axis_specimen_form.py +167 -0
  144. quantized/io/origin_project/opju_codec.py +370 -0
  145. quantized/io/origin_project/opju_curves.py +497 -0
  146. quantized/io/origin_project/opju_curves_allcols.py +258 -0
  147. quantized/io/origin_project/opju_figure_curves.py +302 -0
  148. quantized/io/origin_project/opju_figure_text.py +245 -0
  149. quantized/io/origin_project/opju_reports.py +129 -0
  150. quantized/io/origin_project/origin_richtext.py +145 -0
  151. quantized/io/origin_project/preview.py +132 -0
  152. quantized/io/origin_project/templates.py +314 -0
  153. quantized/io/origin_project/tree.py +379 -0
  154. quantized/io/origin_project/tree_opju.py +228 -0
  155. quantized/io/origin_project/windows.py +238 -0
  156. quantized/io/origin_project/windows_opju.py +393 -0
  157. quantized/io/origin_project/writer.py +156 -0
  158. quantized/io/origin_project/writer_blocks.py +282 -0
  159. quantized/io/qd.py +380 -0
  160. quantized/io/refl1d.py +132 -0
  161. quantized/io/registry.py +210 -0
  162. quantized/io/report_export.py +347 -0
  163. quantized/io/rigaku.py +100 -0
  164. quantized/io/sims.py +398 -0
  165. quantized/io/spc.py +311 -0
  166. quantized/io/xrd_csv.py +308 -0
  167. quantized/io/xrdml.py +394 -0
  168. quantized/jobs.py +173 -0
  169. quantized/plugins/__init__.py +50 -0
  170. quantized/plugins/contract.py +111 -0
  171. quantized/plugins/loader.py +394 -0
  172. quantized/plugins/steps.py +90 -0
  173. quantized/routes/__init__.py +7 -0
  174. quantized/routes/_bookcache.py +62 -0
  175. quantized/routes/_export_common.py +27 -0
  176. quantized/routes/_payload.py +58 -0
  177. quantized/routes/_uploadcache.py +59 -0
  178. quantized/routes/aggregate.py +46 -0
  179. quantized/routes/baseline.py +210 -0
  180. quantized/routes/books.py +117 -0
  181. quantized/routes/calc.py +56 -0
  182. quantized/routes/corrections.py +78 -0
  183. quantized/routes/crystallography.py +80 -0
  184. quantized/routes/diffusion.py +58 -0
  185. quantized/routes/electrical.py +101 -0
  186. quantized/routes/electrochemistry.py +83 -0
  187. quantized/routes/export.py +280 -0
  188. quantized/routes/export_facets.py +83 -0
  189. quantized/routes/export_figures.py +471 -0
  190. quantized/routes/export_page.py +125 -0
  191. quantized/routes/fitting.py +379 -0
  192. quantized/routes/fitting_bumps.py +97 -0
  193. quantized/routes/import_template.py +97 -0
  194. quantized/routes/import_wizard.py +150 -0
  195. quantized/routes/jobs_api.py +59 -0
  196. quantized/routes/magnetic.py +135 -0
  197. quantized/routes/magnetometry.py +133 -0
  198. quantized/routes/optics.py +98 -0
  199. quantized/routes/parsers.py +281 -0
  200. quantized/routes/peaks.py +184 -0
  201. quantized/routes/plot.py +103 -0
  202. quantized/routes/reductions.py +121 -0
  203. quantized/routes/reference.py +58 -0
  204. quantized/routes/reflectivity.py +91 -0
  205. quantized/routes/report_export.py +119 -0
  206. quantized/routes/rsm.py +136 -0
  207. quantized/routes/samples.py +32 -0
  208. quantized/routes/semiconductor.py +207 -0
  209. quantized/routes/sld.py +42 -0
  210. quantized/routes/spectral.py +54 -0
  211. quantized/routes/statplots.py +99 -0
  212. quantized/routes/stats.py +418 -0
  213. quantized/routes/stats_design.py +321 -0
  214. quantized/routes/substrates.py +46 -0
  215. quantized/routes/superconductor.py +139 -0
  216. quantized/routes/thermal.py +57 -0
  217. quantized/routes/thin_film.py +153 -0
  218. quantized/routes/vacuum.py +113 -0
  219. quantized/routes/xray.py +32 -0
  220. quantized/samples/demo_vsm.csv +42 -0
  221. quantized/server_launch.py +251 -0
  222. quantized/web/assets/JetBrainsMono-Bold-CUogYd9I.woff2 +0 -0
  223. quantized/web/assets/JetBrainsMono-Regular-CA-Os4ii.woff2 +0 -0
  224. quantized/web/assets/index-BHmmCL-x.js +27 -0
  225. quantized/web/assets/index-BiZzN7J6.css +1 -0
  226. quantized/web/index.html +13 -0
  227. quantized/web/loading.html +69 -0
  228. quantized_lab-0.8.0.dist-info/METADATA +122 -0
  229. quantized_lab-0.8.0.dist-info/RECORD +233 -0
  230. quantized_lab-0.8.0.dist-info/WHEEL +4 -0
  231. quantized_lab-0.8.0.dist-info/entry_points.txt +4 -0
  232. quantized_lab-0.8.0.dist-info/licenses/LICENSE +201 -0
  233. quantized_lab-0.8.0.dist-info/licenses/NOTICE +11 -0
@@ -0,0 +1,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}