eagleplot 3.0.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 (73) hide show
  1. eagleplot/Home.py +362 -0
  2. eagleplot/__init__.py +19 -0
  3. eagleplot/annotations.py +215 -0
  4. eagleplot/assets/logo.ico +0 -0
  5. eagleplot/assets/logo.png +0 -0
  6. eagleplot/assets/nccu-horiz-logo.png +0 -0
  7. eagleplot/assets/nccu-wings.png +0 -0
  8. eagleplot/batch.py +446 -0
  9. eagleplot/cli.py +719 -0
  10. eagleplot/data_tools.py +849 -0
  11. eagleplot/example-data/Artificial/bimodal_distribution.csv +501 -0
  12. eagleplot/example-data/Artificial/correlation_matrix.npy +0 -0
  13. eagleplot/example-data/Artificial/gaussian_surface.npy +0 -0
  14. eagleplot/example-data/Artificial/grouped_categorical.csv +241 -0
  15. eagleplot/example-data/Artificial/ir_spectrum.csv +1802 -0
  16. eagleplot/example-data/Artificial/molecular_properties.csv +121 -0
  17. eagleplot/example-data/Artificial/normal_distribution.csv +401 -0
  18. eagleplot/example-data/Artificial/reaction_kinetics.csv +121 -0
  19. eagleplot/example-data/Artificial/scatter_correlation.csv +201 -0
  20. eagleplot/example-data/Artificial/sine_cosine_waves.csv +301 -0
  21. eagleplot/gallery/box_plot.png +0 -0
  22. eagleplot/gallery/contour_plot.png +0 -0
  23. eagleplot/gallery/distribution_plot.png +0 -0
  24. eagleplot/gallery/heatmap.png +0 -0
  25. eagleplot/gallery/histogram.png +0 -0
  26. eagleplot/gallery/interactive_3d_surface.png +0 -0
  27. eagleplot/gallery/interactive_heatmap.png +0 -0
  28. eagleplot/gallery/interactive_histogram.png +0 -0
  29. eagleplot/gallery/interactive_line.png +0 -0
  30. eagleplot/gallery/interactive_scatter.png +0 -0
  31. eagleplot/gallery/line_plot.png +0 -0
  32. eagleplot/gallery/manifest.json +196 -0
  33. eagleplot/gallery/regression_plot.png +0 -0
  34. eagleplot/gallery/scatter_plot.png +0 -0
  35. eagleplot/io.py +1008 -0
  36. eagleplot/math.py +1210 -0
  37. eagleplot/molecular/__init__.py +21 -0
  38. eagleplot/molecular/atom_data.py +287 -0
  39. eagleplot/molecular/parsers.py +747 -0
  40. eagleplot/nist.py +241 -0
  41. eagleplot/pages/10_Molecular_Viz.py +225 -0
  42. eagleplot/pages/11_Batch_Analysis.py +325 -0
  43. eagleplot/pages/12_Help.py +199 -0
  44. eagleplot/pages/13_Settings.py +388 -0
  45. eagleplot/pages/1_Data_Upload.py +521 -0
  46. eagleplot/pages/2_Quick_Plot.py +1693 -0
  47. eagleplot/pages/3_Analysis_Tools.py +2510 -0
  48. eagleplot/pages/4_Multi_Plot_Dashboard.py +791 -0
  49. eagleplot/pages/5_Advanced_Plotting.py +691 -0
  50. eagleplot/pages/6_Export_Results.py +442 -0
  51. eagleplot/pages/7_Gallery.py +201 -0
  52. eagleplot/pages/8_Data_Tools.py +434 -0
  53. eagleplot/pages/9_Spectroscopy.py +821 -0
  54. eagleplot/pages/__init__.py +8 -0
  55. eagleplot/peaks.py +513 -0
  56. eagleplot/plotting.py +2630 -0
  57. eagleplot/plugin_loader.py +208 -0
  58. eagleplot/report.py +448 -0
  59. eagleplot/signal.py +494 -0
  60. eagleplot/spectroscopy.py +830 -0
  61. eagleplot/utils/__init__.py +76 -0
  62. eagleplot/utils/access.py +77 -0
  63. eagleplot/utils/data_preview.py +352 -0
  64. eagleplot/utils/plot_config.py +2721 -0
  65. eagleplot/utils/session_state.py +535 -0
  66. eagleplot/utils/theming.py +312 -0
  67. eagleplot/utils/user_settings.py +294 -0
  68. eagleplot-3.0.0.dist-info/METADATA +304 -0
  69. eagleplot-3.0.0.dist-info/RECORD +73 -0
  70. eagleplot-3.0.0.dist-info/WHEEL +5 -0
  71. eagleplot-3.0.0.dist-info/entry_points.txt +2 -0
  72. eagleplot-3.0.0.dist-info/licenses/LICENSE +21 -0
  73. eagleplot-3.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,830 @@
1
+ """Spectroscopy Analysis Module.
2
+
3
+ Conversion, correction, and analysis utilities for IR/Raman, UV-Vis, NMR,
4
+ and mass spectrometry data.
5
+
6
+ All functions accept and return NumPy arrays or plain dicts. No Streamlit
7
+ imports are present in this module.
8
+
9
+ Functions
10
+ ---------
11
+ IR / Raman:
12
+ absorbance_to_transmittance, transmittance_to_absorbance,
13
+ atr_correction, spectral_subtraction, remove_cosmic_rays,
14
+ assign_bands
15
+
16
+ UV-Vis:
17
+ beer_lambert, molar_absorptivity_series, spectral_overlap_integral
18
+
19
+ NMR:
20
+ calibrate_ppm_axis, apply_line_broadening, zero_fill, nmr_fft,
21
+ pick_nmr_peaks, integrate_nmr_regions
22
+
23
+ Mass Spec:
24
+ find_mz_peaks, centroid_spectrum
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import numpy as np
30
+ from scipy import stats as _stats
31
+ from scipy.fft import fft as _scipy_fft
32
+ from scipy.fft import fftfreq as _fftfreq
33
+ from scipy.interpolate import interp1d
34
+ from scipy.signal import find_peaks as _find_peaks
35
+
36
+ # ─── IR / Raman ───────────────────────────────────────────────────────────────
37
+
38
+
39
+ def absorbance_to_transmittance(absorbance: np.ndarray) -> np.ndarray:
40
+ """Convert absorbance A to percent transmittance T(%) = 100 × 10^(-A).
41
+
42
+ Parameters
43
+ ----------
44
+ absorbance:
45
+ Array of absorbance values.
46
+
47
+ Returns
48
+ -------
49
+ np.ndarray
50
+ Percent transmittance array.
51
+ """
52
+ absorbance = np.asarray(absorbance, dtype=float)
53
+ return 100.0 * np.power(10.0, -absorbance)
54
+
55
+
56
+ def transmittance_to_absorbance(transmittance: np.ndarray) -> np.ndarray:
57
+ """Convert percent transmittance T(%) to absorbance A = 2 - log10(T).
58
+
59
+ Clips input to a minimum of 1e-10 to avoid log(0).
60
+
61
+ Parameters
62
+ ----------
63
+ transmittance:
64
+ Array of percent transmittance values (0–100 range expected).
65
+
66
+ Returns
67
+ -------
68
+ np.ndarray
69
+ Absorbance array.
70
+
71
+ Raises
72
+ ------
73
+ ValueError
74
+ If any input value is <= 0 before clipping check.
75
+ """
76
+ transmittance = np.asarray(transmittance, dtype=float)
77
+ if np.any(transmittance <= 0.0):
78
+ raise ValueError("transmittance values must be > 0; got values <= 0.")
79
+ t_clipped = np.clip(transmittance, 1e-10, None)
80
+ return 2.0 - np.log10(t_clipped)
81
+
82
+
83
+ def atr_correction(
84
+ wavenumbers: np.ndarray,
85
+ absorbance: np.ndarray,
86
+ n_atr: float = 1.5,
87
+ angle_deg: float = 45.0,
88
+ n_sample: float = 1.0,
89
+ ) -> np.ndarray:
90
+ """ATR depth-of-penetration correction.
91
+
92
+ Divides each absorbance value by the effective dp(ν) (depth of
93
+ penetration) to approximate a transmission spectrum::
94
+
95
+ dp(ν) = λ / (2π × n_atr × √(sin²(θ) - (n_sample/n_atr)²))
96
+ λ = 10000 / ν (µm, since ν is in cm⁻¹)
97
+
98
+ Parameters
99
+ ----------
100
+ wavenumbers:
101
+ Wavenumber axis in cm⁻¹.
102
+ absorbance:
103
+ Absorbance values matching the wavenumber axis.
104
+ n_atr:
105
+ Refractive index of the ATR crystal (default 1.5, ZnSe).
106
+ angle_deg:
107
+ Angle of incidence in degrees (default 45°).
108
+ n_sample:
109
+ Refractive index of the sample (default 1.0).
110
+
111
+ Returns
112
+ -------
113
+ np.ndarray
114
+ Corrected absorbance array.
115
+
116
+ Raises
117
+ ------
118
+ ValueError
119
+ If sin(angle_deg) <= n_sample / n_atr (below or at critical angle).
120
+ """
121
+ wavenumbers = np.asarray(wavenumbers, dtype=float)
122
+ absorbance = np.asarray(absorbance, dtype=float)
123
+
124
+ angle_rad = np.deg2rad(angle_deg)
125
+ sin_theta = np.sin(angle_rad)
126
+ ratio = n_sample / n_atr
127
+
128
+ if sin_theta <= ratio:
129
+ raise ValueError(
130
+ f"Angle {angle_deg}° is at or below the critical angle for total "
131
+ f"internal reflection (sin(θ)={sin_theta:.4f} <= n_sample/n_atr="
132
+ f"{ratio:.4f}). Increase the angle or change refractive indices."
133
+ )
134
+
135
+ sin2_theta = sin_theta**2
136
+ ratio_sq = ratio**2
137
+ lambda_um = 10000.0 / wavenumbers # cm⁻¹ → µm
138
+ dp = lambda_um / (2.0 * np.pi * n_atr * np.sqrt(sin2_theta - ratio_sq))
139
+ corrected = np.where(dp > 0, absorbance / dp, absorbance)
140
+ return corrected
141
+
142
+
143
+ def spectral_subtraction(
144
+ wavenumbers: np.ndarray,
145
+ spectrum_a: np.ndarray,
146
+ spectrum_b: np.ndarray,
147
+ scale: float = 1.0,
148
+ ) -> np.ndarray:
149
+ """Subtract scale*B from A.
150
+
151
+ Parameters
152
+ ----------
153
+ wavenumbers:
154
+ Wavenumber axis shared by both spectra.
155
+ spectrum_a:
156
+ Primary spectrum.
157
+ spectrum_b:
158
+ Spectrum to subtract (after scaling).
159
+ scale:
160
+ Scaling factor applied to spectrum_b before subtraction.
161
+
162
+ Returns
163
+ -------
164
+ np.ndarray
165
+ Difference spectrum (same length as spectrum_a).
166
+
167
+ Raises
168
+ ------
169
+ ValueError
170
+ If array lengths do not match wavenumbers.
171
+ """
172
+ wavenumbers = np.asarray(wavenumbers, dtype=float)
173
+ spectrum_a = np.asarray(spectrum_a, dtype=float)
174
+ spectrum_b = np.asarray(spectrum_b, dtype=float)
175
+
176
+ if len(spectrum_a) != len(wavenumbers) or len(spectrum_b) != len(wavenumbers):
177
+ raise ValueError(
178
+ "spectrum_a and spectrum_b must both have the same length as wavenumbers. "
179
+ f"Got wavenumbers={len(wavenumbers)}, A={len(spectrum_a)}, B={len(spectrum_b)}."
180
+ )
181
+ return spectrum_a - scale * spectrum_b
182
+
183
+
184
+ def remove_cosmic_rays(
185
+ y: np.ndarray,
186
+ threshold_sigma: float = 5.0,
187
+ window: int = 5,
188
+ ) -> np.ndarray:
189
+ """Remove cosmic ray spikes from Raman spectra using local z-score detection.
190
+
191
+ For each point, the local median and standard deviation are computed from
192
+ the `window` neighboring points on each side (excluding the point itself).
193
+ If |y[i] - local_median| > threshold_sigma * local_std, y[i] is replaced
194
+ with the local median.
195
+
196
+ Parameters
197
+ ----------
198
+ y:
199
+ Input Raman spectrum.
200
+ threshold_sigma:
201
+ Number of local standard deviations above which a point is flagged.
202
+ window:
203
+ Number of neighboring points on each side to use for local statistics.
204
+
205
+ Returns
206
+ -------
207
+ np.ndarray
208
+ Cleaned copy of y (same shape, original not mutated).
209
+ """
210
+ y = np.asarray(y, dtype=float)
211
+ result = y.copy()
212
+ n = len(y)
213
+
214
+ for i in range(n):
215
+ lo = max(0, i - window)
216
+ hi = min(n, i + window + 1)
217
+ neighbors = np.concatenate([y[lo:i], y[i + 1 : hi]])
218
+ if len(neighbors) == 0:
219
+ continue
220
+ local_median = np.median(neighbors)
221
+ local_std = np.std(neighbors)
222
+ if local_std == 0.0:
223
+ continue
224
+ if abs(y[i] - local_median) > threshold_sigma * local_std:
225
+ result[i] = local_median
226
+
227
+ return result
228
+
229
+
230
+ # ─── Functional Group Lookup ──────────────────────────────────────────────────
231
+
232
+ FUNCTIONAL_GROUPS: dict = {
233
+ "O-H stretch (free)": (3580.0, 3650.0),
234
+ "O-H stretch (H-bonded)": (3200.0, 3580.0),
235
+ "N-H stretch (primary amine)": (3300.0, 3500.0),
236
+ "N-H stretch (secondary/amide)": (3100.0, 3350.0),
237
+ "C-H stretch (sp3)": (2850.0, 3000.0),
238
+ "C-H stretch (sp2/aromatic)": (3000.0, 3100.0),
239
+ "C-H stretch (alkyne)": (3250.0, 3350.0),
240
+ "C=O stretch (ester)": (1730.0, 1750.0),
241
+ "C=O stretch (aldehyde)": (1710.0, 1740.0),
242
+ "C=O stretch (ketone)": (1700.0, 1725.0),
243
+ "C=O stretch (conjugated/amide)": (1630.0, 1700.0),
244
+ "C=C stretch (alkene)": (1620.0, 1680.0),
245
+ "Aromatic C=C stretch": (1500.0, 1600.0),
246
+ "C≡C stretch (alkyne)": (2100.0, 2260.0),
247
+ "C≡N stretch (nitrile)": (2200.0, 2260.0),
248
+ "N-O asymm stretch (nitro)": (1540.0, 1570.0),
249
+ "N-O symm stretch (nitro)": (1350.0, 1380.0),
250
+ "C-O-C stretch (ether/ester)": (1050.0, 1260.0),
251
+ "C-O stretch (alcohol)": (1000.0, 1200.0),
252
+ "Aromatic C-H oop bend": (700.0, 900.0),
253
+ "C-Cl stretch": (600.0, 800.0),
254
+ "C-Br stretch": (500.0, 680.0),
255
+ }
256
+
257
+
258
+ def assign_bands(wavenumber: float) -> list[str]:
259
+ """Return list of functional group names whose range includes wavenumber.
260
+
261
+ Parameters
262
+ ----------
263
+ wavenumber:
264
+ A single wavenumber value in cm⁻¹.
265
+
266
+ Returns
267
+ -------
268
+ list[str]
269
+ Names of matching functional groups; empty list if no match.
270
+ """
271
+ matches: list[str] = []
272
+ for name, (low, high) in FUNCTIONAL_GROUPS.items():
273
+ if low <= wavenumber <= high:
274
+ matches.append(name)
275
+ return matches
276
+
277
+
278
+ # ─── UV-Vis ───────────────────────────────────────────────────────────────────
279
+
280
+
281
+ def beer_lambert(
282
+ absorbance: float,
283
+ epsilon: float | None = None,
284
+ concentration: float | None = None,
285
+ path_length: float = 1.0,
286
+ ) -> dict:
287
+ """Beer-Lambert calculator: A = ε × c × l.
288
+
289
+ Exactly one of epsilon or concentration must be None (the unknown to
290
+ solve for).
291
+
292
+ Parameters
293
+ ----------
294
+ absorbance:
295
+ Measured absorbance (dimensionless).
296
+ epsilon:
297
+ Molar absorptivity (L mol⁻¹ cm⁻¹). Pass None to solve for ε.
298
+ concentration:
299
+ Concentration (mol L⁻¹). Pass None to solve for c.
300
+ path_length:
301
+ Optical path length in cm (default 1.0).
302
+
303
+ Returns
304
+ -------
305
+ dict
306
+ Keys: absorbance, epsilon, concentration, path_length, solved_for.
307
+
308
+ Raises
309
+ ------
310
+ ValueError
311
+ If both or neither of epsilon/concentration are None, or if known
312
+ values are <= 0.
313
+ """
314
+ if epsilon is None and concentration is None:
315
+ raise ValueError("Exactly one of epsilon or concentration must be None. Both are None.")
316
+ if epsilon is not None and concentration is not None:
317
+ raise ValueError("Exactly one of epsilon or concentration must be None. Neither is None.")
318
+ if path_length <= 0.0:
319
+ raise ValueError(f"path_length must be > 0; got {path_length}.")
320
+
321
+ if epsilon is None:
322
+ # solve for epsilon
323
+ if concentration <= 0.0:
324
+ raise ValueError(f"concentration must be > 0; got {concentration}.")
325
+ solved_epsilon = absorbance / (concentration * path_length)
326
+ solved_concentration = concentration
327
+ solved_for = "epsilon"
328
+ else:
329
+ # solve for concentration
330
+ if epsilon <= 0.0:
331
+ raise ValueError(f"epsilon must be > 0; got {epsilon}.")
332
+ solved_epsilon = epsilon
333
+ solved_concentration = absorbance / (epsilon * path_length)
334
+ solved_for = "concentration"
335
+
336
+ return {
337
+ "absorbance": absorbance,
338
+ "epsilon": solved_epsilon,
339
+ "concentration": solved_concentration,
340
+ "path_length": path_length,
341
+ "solved_for": solved_for,
342
+ }
343
+
344
+
345
+ def molar_absorptivity_series(
346
+ concentrations: np.ndarray,
347
+ absorbances: np.ndarray,
348
+ path_length: float = 1.0,
349
+ ) -> dict:
350
+ """Determine molar absorptivity from a Beer-Lambert calibration series.
351
+
352
+ Performs linear regression of absorbances vs concentrations × path_length.
353
+
354
+ Parameters
355
+ ----------
356
+ concentrations:
357
+ Array of concentration values (mol L⁻¹).
358
+ absorbances:
359
+ Corresponding absorbance measurements.
360
+ path_length:
361
+ Optical path length in cm (default 1.0).
362
+
363
+ Returns
364
+ -------
365
+ dict
366
+ Keys: epsilon, r_squared, slope, intercept, fitted_absorbances,
367
+ residuals, linearity_ok (bool: r_squared >= 0.999).
368
+
369
+ Raises
370
+ ------
371
+ ValueError
372
+ If arrays have different lengths or fewer than 2 points.
373
+ """
374
+ concentrations = np.asarray(concentrations, dtype=float)
375
+ absorbances = np.asarray(absorbances, dtype=float)
376
+
377
+ if len(concentrations) != len(absorbances):
378
+ raise ValueError(
379
+ f"concentrations and absorbances must have the same length; "
380
+ f"got {len(concentrations)} and {len(absorbances)}."
381
+ )
382
+ if len(concentrations) < 2:
383
+ raise ValueError("At least 2 data points are required for molar absorptivity series.")
384
+
385
+ x = concentrations * path_length
386
+ result = _stats.linregress(x, absorbances)
387
+ slope = float(result.slope)
388
+ intercept = float(result.intercept)
389
+ r_squared = float(result.rvalue**2)
390
+ fitted = slope * x + intercept
391
+ residuals = absorbances - fitted
392
+
393
+ return {
394
+ "epsilon": slope,
395
+ "r_squared": r_squared,
396
+ "slope": slope,
397
+ "intercept": intercept,
398
+ "fitted_absorbances": fitted,
399
+ "residuals": residuals,
400
+ "linearity_ok": r_squared >= 0.999,
401
+ }
402
+
403
+
404
+ def spectral_overlap_integral(
405
+ wavelengths_d: np.ndarray,
406
+ emission_d: np.ndarray,
407
+ wavelengths_a: np.ndarray,
408
+ absorption_a: np.ndarray,
409
+ ) -> dict:
410
+ """Compute spectral overlap integral J for FRET.
411
+
412
+ J = ∫ F_D(λ) × ε_A(λ) × λ⁴ dλ
413
+
414
+ Both spectra are interpolated onto a common wavelength grid (500 points
415
+ over the overlapping wavelength range). Donor emission is normalized
416
+ (∫F_D dλ = 1) before computing J.
417
+
418
+ Parameters
419
+ ----------
420
+ wavelengths_d:
421
+ Wavelength axis for donor emission spectrum (nm).
422
+ emission_d:
423
+ Donor fluorescence emission intensity.
424
+ wavelengths_a:
425
+ Wavelength axis for acceptor absorption spectrum (nm).
426
+ absorption_a:
427
+ Acceptor molar absorptivity (L mol⁻¹ cm⁻¹).
428
+
429
+ Returns
430
+ -------
431
+ dict
432
+ Keys: J, wavelengths_common, integrand, donor_norm, acceptor_interp.
433
+ Returns J=0.0 if spectra do not overlap.
434
+ """
435
+ wavelengths_d = np.asarray(wavelengths_d, dtype=float)
436
+ emission_d = np.asarray(emission_d, dtype=float)
437
+ wavelengths_a = np.asarray(wavelengths_a, dtype=float)
438
+ absorption_a = np.asarray(absorption_a, dtype=float)
439
+
440
+ overlap_lo = max(wavelengths_d.min(), wavelengths_a.min())
441
+ overlap_hi = min(wavelengths_d.max(), wavelengths_a.max())
442
+
443
+ if overlap_lo >= overlap_hi:
444
+ empty = np.array([])
445
+ return {
446
+ "J": 0.0,
447
+ "wavelengths_common": empty,
448
+ "integrand": empty,
449
+ "donor_norm": empty,
450
+ "acceptor_interp": empty,
451
+ }
452
+
453
+ wl_common = np.linspace(overlap_lo, overlap_hi, 500)
454
+
455
+ interp_d = interp1d(wavelengths_d, emission_d, bounds_error=False, fill_value=0.0)
456
+ interp_a = interp1d(wavelengths_a, absorption_a, bounds_error=False, fill_value=0.0)
457
+
458
+ donor_raw = interp_d(wl_common)
459
+ donor_raw = np.clip(donor_raw, 0.0, None)
460
+ acceptor = interp_a(wl_common)
461
+ acceptor = np.clip(acceptor, 0.0, None)
462
+
463
+ norm_factor = np.trapezoid(donor_raw, wl_common)
464
+ if norm_factor > 0.0:
465
+ donor_norm = donor_raw / norm_factor
466
+ else:
467
+ donor_norm = donor_raw
468
+
469
+ integrand = donor_norm * acceptor * wl_common**4
470
+ J = float(np.trapezoid(integrand, wl_common))
471
+
472
+ return {
473
+ "J": J,
474
+ "wavelengths_common": wl_common,
475
+ "integrand": integrand,
476
+ "donor_norm": donor_norm,
477
+ "acceptor_interp": acceptor,
478
+ }
479
+
480
+
481
+ # ─── NMR ──────────────────────────────────────────────────────────────────────
482
+
483
+
484
+ def calibrate_ppm_axis(
485
+ hz_array: np.ndarray,
486
+ spectrometer_freq_mhz: float,
487
+ reference_hz: float = 0.0,
488
+ ) -> np.ndarray:
489
+ """Convert Hz axis to ppm: ppm = (hz - reference_hz) / spectrometer_freq_mhz.
490
+
491
+ Parameters
492
+ ----------
493
+ hz_array:
494
+ Frequency axis in Hz.
495
+ spectrometer_freq_mhz:
496
+ Spectrometer operating frequency in MHz.
497
+ reference_hz:
498
+ Reference frequency offset in Hz (default 0.0, e.g., TMS).
499
+
500
+ Returns
501
+ -------
502
+ np.ndarray
503
+ Chemical shift axis in ppm (same shape as hz_array).
504
+ """
505
+ hz_array = np.asarray(hz_array, dtype=float)
506
+ return (hz_array - reference_hz) / spectrometer_freq_mhz
507
+
508
+
509
+ def apply_line_broadening(
510
+ fid: np.ndarray,
511
+ dt: float,
512
+ lb: float,
513
+ mode: str = "lorentzian",
514
+ ) -> np.ndarray:
515
+ """Apply line broadening to an NMR FID.
516
+
517
+ Parameters
518
+ ----------
519
+ fid:
520
+ Free induction decay array (real or complex).
521
+ dt:
522
+ Dwell time — seconds per point.
523
+ lb:
524
+ Line broadening factor in Hz.
525
+ mode:
526
+ ``'lorentzian'``: multiply by exp(-π × lb × t).
527
+ ``'gaussian'``: multiply by exp(-0.5 × (π × lb × t)²).
528
+
529
+ Returns
530
+ -------
531
+ np.ndarray
532
+ Broadened FID (same shape as input).
533
+
534
+ Raises
535
+ ------
536
+ ValueError
537
+ If mode is not ``'lorentzian'`` or ``'gaussian'``.
538
+ """
539
+ fid = np.asarray(fid, dtype=complex if np.iscomplexobj(fid) else float)
540
+ t = np.arange(len(fid), dtype=float) * dt
541
+
542
+ if mode == "lorentzian":
543
+ window = np.exp(-np.pi * lb * t)
544
+ elif mode == "gaussian":
545
+ window = np.exp(-0.5 * (np.pi * lb * t) ** 2)
546
+ else:
547
+ raise ValueError(f"mode must be 'lorentzian' or 'gaussian'; got '{mode}'.")
548
+
549
+ return fid * window
550
+
551
+
552
+ def zero_fill(fid: np.ndarray, n_points: int) -> np.ndarray:
553
+ """Zero-fill FID to n_points total.
554
+
555
+ Parameters
556
+ ----------
557
+ fid:
558
+ Input FID array.
559
+ n_points:
560
+ Target total number of points (must be >= len(fid)).
561
+
562
+ Returns
563
+ -------
564
+ np.ndarray
565
+ Zero-padded array of length n_points.
566
+
567
+ Raises
568
+ ------
569
+ ValueError
570
+ If n_points < len(fid).
571
+ """
572
+ fid = np.asarray(fid, dtype=complex if np.iscomplexobj(fid) else float)
573
+ if n_points < len(fid):
574
+ raise ValueError(f"n_points ({n_points}) must be >= len(fid) ({len(fid)}).")
575
+ pad_width = n_points - len(fid)
576
+ return np.pad(fid, (0, pad_width), mode="constant", constant_values=0)
577
+
578
+
579
+ def nmr_fft(fid: np.ndarray, dt: float) -> dict:
580
+ """FFT a real or complex FID.
581
+
582
+ Uses ``scipy.fft.fft``. The real part of the transform is returned as the
583
+ spectrum.
584
+
585
+ Parameters
586
+ ----------
587
+ fid:
588
+ Input FID (real or complex).
589
+ dt:
590
+ Dwell time in seconds per point.
591
+
592
+ Returns
593
+ -------
594
+ dict
595
+ Keys: spectrum (real part, ndarray), frequencies_hz (ndarray),
596
+ n_points (int).
597
+ """
598
+ fid = np.asarray(fid, dtype=complex if np.iscomplexobj(fid) else float)
599
+ n = len(fid)
600
+ spectrum_complex = _scipy_fft(fid)
601
+ spectrum_real = np.real(spectrum_complex)
602
+ frequencies_hz = _fftfreq(n, d=dt)
603
+ return {
604
+ "spectrum": spectrum_real,
605
+ "frequencies_hz": frequencies_hz,
606
+ "n_points": n,
607
+ }
608
+
609
+
610
+ def pick_nmr_peaks(
611
+ ppm: np.ndarray,
612
+ spectrum: np.ndarray,
613
+ threshold_fraction: float = 0.01,
614
+ min_separation_ppm: float = 0.05,
615
+ ) -> dict:
616
+ """Peak picking on a 1D NMR spectrum.
617
+
618
+ Uses ``scipy.signal.find_peaks``. Works whether ppm is increasing or
619
+ decreasing.
620
+
621
+ Parameters
622
+ ----------
623
+ ppm:
624
+ Chemical shift axis in ppm.
625
+ spectrum:
626
+ Spectral intensity array.
627
+ threshold_fraction:
628
+ Minimum peak height as a fraction of the tallest peak.
629
+ min_separation_ppm:
630
+ Minimum separation between adjacent peaks in ppm units.
631
+
632
+ Returns
633
+ -------
634
+ dict
635
+ Keys: ppm_positions, intensities, n_peaks.
636
+ """
637
+ ppm = np.asarray(ppm, dtype=float)
638
+ spectrum = np.asarray(spectrum, dtype=float)
639
+
640
+ max_intensity = spectrum.max() if spectrum.size > 0 else 0.0
641
+ height = threshold_fraction * max_intensity
642
+
643
+ ppm_step = float(np.abs(np.mean(np.diff(ppm)))) if len(ppm) > 1 else 1.0
644
+ min_distance = max(1, int(np.round(min_separation_ppm / ppm_step)))
645
+
646
+ peaks, _ = _find_peaks(spectrum, height=height, distance=min_distance)
647
+
648
+ return {
649
+ "ppm_positions": ppm[peaks],
650
+ "intensities": spectrum[peaks],
651
+ "n_peaks": len(peaks),
652
+ }
653
+
654
+
655
+ def integrate_nmr_regions(
656
+ ppm: np.ndarray,
657
+ spectrum: np.ndarray,
658
+ regions: list,
659
+ ) -> dict:
660
+ """Integrate spectrum over a list of (ppm_low, ppm_high) regions.
661
+
662
+ Parameters
663
+ ----------
664
+ ppm:
665
+ Chemical shift axis in ppm (may be ascending or descending).
666
+ spectrum:
667
+ Spectral intensity array.
668
+ regions:
669
+ List of (ppm_low, ppm_high) tuples defining integration windows.
670
+
671
+ Returns
672
+ -------
673
+ dict
674
+ Keys: regions, integrals, normalized_ratios.
675
+ Ratios are normalized so the smallest non-zero integral equals 1.0.
676
+ If all integrals are zero, ratios is a list of zeros.
677
+ """
678
+ ppm = np.asarray(ppm, dtype=float)
679
+ spectrum = np.asarray(spectrum, dtype=float)
680
+
681
+ integrals: list[float] = []
682
+ for low, high in regions:
683
+ # handle ascending and descending ppm axes
684
+ mask = (ppm >= min(low, high)) & (ppm <= max(low, high))
685
+ if mask.sum() < 2:
686
+ integrals.append(0.0)
687
+ else:
688
+ integrals.append(float(np.trapezoid(spectrum[mask], ppm[mask])))
689
+
690
+ integrals_arr = np.array(integrals)
691
+ nonzero = integrals_arr[integrals_arr != 0.0]
692
+ if nonzero.size > 0:
693
+ min_val = float(np.abs(nonzero).min())
694
+ normalized = (integrals_arr / min_val).tolist()
695
+ else:
696
+ normalized = [0.0] * len(integrals)
697
+
698
+ return {
699
+ "regions": list(regions),
700
+ "integrals": integrals,
701
+ "normalized_ratios": normalized,
702
+ }
703
+
704
+
705
+ # ─── Mass Spec ────────────────────────────────────────────────────────────────
706
+
707
+
708
+ def find_mz_peaks(
709
+ mz: np.ndarray,
710
+ intensity: np.ndarray,
711
+ min_intensity_fraction: float = 0.01,
712
+ min_separation: float = 0.3,
713
+ ) -> dict:
714
+ """Detect peaks in a mass spectrum.
715
+
716
+ Parameters
717
+ ----------
718
+ mz:
719
+ m/z axis array.
720
+ intensity:
721
+ Intensity array (same length as mz).
722
+ min_intensity_fraction:
723
+ Minimum peak height as a fraction of the base peak intensity.
724
+ min_separation:
725
+ Minimum m/z separation between adjacent peaks.
726
+
727
+ Returns
728
+ -------
729
+ dict
730
+ Keys: mz_positions, intensities, relative_intensities (% of base
731
+ peak), base_peak_mz, base_peak_intensity, n_peaks.
732
+ Returns empty arrays and n_peaks=0 if intensity is all zero.
733
+ """
734
+ mz = np.asarray(mz, dtype=float)
735
+ intensity = np.asarray(intensity, dtype=float)
736
+
737
+ empty_result = {
738
+ "mz_positions": np.array([]),
739
+ "intensities": np.array([]),
740
+ "relative_intensities": np.array([]),
741
+ "base_peak_mz": np.nan,
742
+ "base_peak_intensity": 0.0,
743
+ "n_peaks": 0,
744
+ }
745
+
746
+ if intensity.max() == 0.0:
747
+ return empty_result
748
+
749
+ base_intensity = float(intensity.max())
750
+ height_threshold = min_intensity_fraction * base_intensity
751
+
752
+ mz_step = float(np.abs(np.mean(np.diff(mz)))) if len(mz) > 1 else 1.0
753
+ min_distance = max(1, int(np.round(min_separation / mz_step)))
754
+
755
+ peaks, _ = _find_peaks(intensity, height=height_threshold, distance=min_distance)
756
+
757
+ if len(peaks) == 0:
758
+ return empty_result
759
+
760
+ mz_pos = mz[peaks]
761
+ int_pos = intensity[peaks]
762
+ rel_int = (int_pos / base_intensity) * 100.0
763
+ base_idx = int(np.argmax(int_pos))
764
+
765
+ return {
766
+ "mz_positions": mz_pos,
767
+ "intensities": int_pos,
768
+ "relative_intensities": rel_int,
769
+ "base_peak_mz": float(mz_pos[base_idx]),
770
+ "base_peak_intensity": float(int_pos[base_idx]),
771
+ "n_peaks": len(peaks),
772
+ }
773
+
774
+
775
+ def centroid_spectrum(
776
+ mz: np.ndarray,
777
+ intensity: np.ndarray,
778
+ width: float = 0.05,
779
+ ) -> dict:
780
+ """Centroid a profile-mode mass spectrum.
781
+
782
+ Groups contiguous points where intensity > 0 and computes the
783
+ intensity-weighted centroid m/z for each group.
784
+
785
+ Parameters
786
+ ----------
787
+ mz:
788
+ m/z axis array.
789
+ intensity:
790
+ Intensity array (same length as mz).
791
+ width:
792
+ Minimum m/z gap between groups (not currently used for grouping, but
793
+ reserved for future use). Grouping is based on contiguous nonzero runs.
794
+
795
+ Returns
796
+ -------
797
+ dict
798
+ Keys: mz_centroids, intensities, n_peaks.
799
+ """
800
+ mz = np.asarray(mz, dtype=float)
801
+ intensity = np.asarray(intensity, dtype=float)
802
+
803
+ above = intensity > 0.0
804
+ centroids: list[float] = []
805
+ peak_intensities: list[float] = []
806
+
807
+ i = 0
808
+ n = len(intensity)
809
+ while i < n:
810
+ if above[i]:
811
+ j = i
812
+ while j < n and above[j]:
813
+ j += 1
814
+ # group is [i, j)
815
+ seg_mz = mz[i:j]
816
+ seg_int = intensity[i:j]
817
+ total = seg_int.sum()
818
+ if total > 0.0:
819
+ centroid = float(np.sum(seg_mz * seg_int) / total)
820
+ centroids.append(centroid)
821
+ peak_intensities.append(float(total))
822
+ i = j
823
+ else:
824
+ i += 1
825
+
826
+ return {
827
+ "mz_centroids": np.array(centroids),
828
+ "intensities": np.array(peak_intensities),
829
+ "n_peaks": len(centroids),
830
+ }