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,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
+ }
@@ -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