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.
- eagleplot/Home.py +362 -0
- eagleplot/__init__.py +19 -0
- eagleplot/annotations.py +215 -0
- eagleplot/assets/logo.ico +0 -0
- eagleplot/assets/logo.png +0 -0
- eagleplot/assets/nccu-horiz-logo.png +0 -0
- eagleplot/assets/nccu-wings.png +0 -0
- eagleplot/batch.py +446 -0
- eagleplot/cli.py +719 -0
- eagleplot/data_tools.py +849 -0
- eagleplot/example-data/Artificial/bimodal_distribution.csv +501 -0
- eagleplot/example-data/Artificial/correlation_matrix.npy +0 -0
- eagleplot/example-data/Artificial/gaussian_surface.npy +0 -0
- eagleplot/example-data/Artificial/grouped_categorical.csv +241 -0
- eagleplot/example-data/Artificial/ir_spectrum.csv +1802 -0
- eagleplot/example-data/Artificial/molecular_properties.csv +121 -0
- eagleplot/example-data/Artificial/normal_distribution.csv +401 -0
- eagleplot/example-data/Artificial/reaction_kinetics.csv +121 -0
- eagleplot/example-data/Artificial/scatter_correlation.csv +201 -0
- eagleplot/example-data/Artificial/sine_cosine_waves.csv +301 -0
- eagleplot/gallery/box_plot.png +0 -0
- eagleplot/gallery/contour_plot.png +0 -0
- eagleplot/gallery/distribution_plot.png +0 -0
- eagleplot/gallery/heatmap.png +0 -0
- eagleplot/gallery/histogram.png +0 -0
- eagleplot/gallery/interactive_3d_surface.png +0 -0
- eagleplot/gallery/interactive_heatmap.png +0 -0
- eagleplot/gallery/interactive_histogram.png +0 -0
- eagleplot/gallery/interactive_line.png +0 -0
- eagleplot/gallery/interactive_scatter.png +0 -0
- eagleplot/gallery/line_plot.png +0 -0
- eagleplot/gallery/manifest.json +196 -0
- eagleplot/gallery/regression_plot.png +0 -0
- eagleplot/gallery/scatter_plot.png +0 -0
- eagleplot/io.py +1008 -0
- eagleplot/math.py +1210 -0
- eagleplot/molecular/__init__.py +21 -0
- eagleplot/molecular/atom_data.py +287 -0
- eagleplot/molecular/parsers.py +747 -0
- eagleplot/nist.py +241 -0
- eagleplot/pages/10_Molecular_Viz.py +225 -0
- eagleplot/pages/11_Batch_Analysis.py +325 -0
- eagleplot/pages/12_Help.py +199 -0
- eagleplot/pages/13_Settings.py +388 -0
- eagleplot/pages/1_Data_Upload.py +521 -0
- eagleplot/pages/2_Quick_Plot.py +1693 -0
- eagleplot/pages/3_Analysis_Tools.py +2510 -0
- eagleplot/pages/4_Multi_Plot_Dashboard.py +791 -0
- eagleplot/pages/5_Advanced_Plotting.py +691 -0
- eagleplot/pages/6_Export_Results.py +442 -0
- eagleplot/pages/7_Gallery.py +201 -0
- eagleplot/pages/8_Data_Tools.py +434 -0
- eagleplot/pages/9_Spectroscopy.py +821 -0
- eagleplot/pages/__init__.py +8 -0
- eagleplot/peaks.py +513 -0
- eagleplot/plotting.py +2630 -0
- eagleplot/plugin_loader.py +208 -0
- eagleplot/report.py +448 -0
- eagleplot/signal.py +494 -0
- eagleplot/spectroscopy.py +830 -0
- eagleplot/utils/__init__.py +76 -0
- eagleplot/utils/access.py +77 -0
- eagleplot/utils/data_preview.py +352 -0
- eagleplot/utils/plot_config.py +2721 -0
- eagleplot/utils/session_state.py +535 -0
- eagleplot/utils/theming.py +312 -0
- eagleplot/utils/user_settings.py +294 -0
- eagleplot-3.0.0.dist-info/METADATA +304 -0
- eagleplot-3.0.0.dist-info/RECORD +73 -0
- eagleplot-3.0.0.dist-info/WHEEL +5 -0
- eagleplot-3.0.0.dist-info/entry_points.txt +2 -0
- eagleplot-3.0.0.dist-info/licenses/LICENSE +21 -0
- 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
|
+
}
|