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,821 @@
1
+ """Spectroscopy Analysis page.
2
+
3
+ Four-tab spectroscopy analysis page: IR/Raman, NMR, UV-Vis, and Mass Spec.
4
+ All operations are non-destructive and save results as new named datasets.
5
+ """
6
+
7
+ from pathlib import Path
8
+ import sys
9
+
10
+ import matplotlib.pyplot as plt
11
+ import pandas as pd
12
+ import streamlit as st
13
+
14
+ sys.path.insert(0, str(Path(__file__).parent.parent.parent))
15
+
16
+ from eagleplot.utils.session_state import initialize_session_state, add_dataset
17
+ from eagleplot.spectroscopy import (
18
+ absorbance_to_transmittance,
19
+ apply_line_broadening,
20
+ assign_bands,
21
+ atr_correction,
22
+ beer_lambert,
23
+ calibrate_ppm_axis,
24
+ centroid_spectrum,
25
+ find_mz_peaks,
26
+ integrate_nmr_regions,
27
+ molar_absorptivity_series,
28
+ nmr_fft,
29
+ pick_nmr_peaks,
30
+ remove_cosmic_rays,
31
+ spectral_overlap_integral,
32
+ spectral_subtraction,
33
+ transmittance_to_absorbance,
34
+ zero_fill,
35
+ )
36
+
37
+ initialize_session_state()
38
+
39
+ st.title("Spectroscopy")
40
+ st.caption("Spectral processing and analysis for IR/Raman, NMR, UV-Vis, and Mass Spectrometry.")
41
+
42
+ # ── Dataset selector ───────────────────────────────────────────────────────────
43
+ dataset_names = list(st.session_state.datasets.keys())
44
+ if not dataset_names:
45
+ st.warning("No dataset loaded. Please upload data on the **Data Upload** page first.")
46
+ st.stop()
47
+
48
+ selected_name = st.selectbox("Source dataset", dataset_names, key="spec_source_ds")
49
+ data = st.session_state.datasets[selected_name]
50
+
51
+ if not isinstance(data, pd.DataFrame):
52
+ st.error(
53
+ "Spectroscopy Analysis requires a tabular (DataFrame) dataset. "
54
+ "Please load a CSV or Excel file."
55
+ )
56
+ st.stop()
57
+
58
+ numeric_cols = data.select_dtypes(include="number").columns.tolist()
59
+ if not numeric_cols:
60
+ st.warning("No numeric columns found in the current dataset.")
61
+ st.stop()
62
+
63
+ st.caption(f"Shape: {data.shape[0]} rows × {data.shape[1]} columns")
64
+
65
+
66
+ def _save(result_df: pd.DataFrame, suffix: str) -> None:
67
+ base = selected_name.rsplit(".", 1)[0]
68
+ new_name = f"{base}_{suffix}"
69
+ add_dataset(new_name, result_df)
70
+ st.success(f"Saved as **{new_name}**")
71
+ st.dataframe(result_df.head(20), width="stretch")
72
+
73
+
74
+ # ── Tabs ────────────────────────────────────────────────────────────────────
75
+ tab_ir, tab_nmr, tab_uv, tab_ms = st.tabs(["IR / Raman", "NMR", "UV-Vis", "Mass Spec"])
76
+
77
+ # ─────────────────────────────────────────────────────────────────────────────
78
+ # Tab 1 — IR / Raman
79
+ # ─────────────────────────────────────────────────────────────────────────────
80
+ with tab_ir:
81
+ st.header("IR / Raman Analysis")
82
+
83
+ col1, col2 = st.columns(2)
84
+ with col1:
85
+ wn_col = st.selectbox("Wavenumber column (cm⁻¹)", numeric_cols, key="ir_wn")
86
+ with col2:
87
+ ab_col_ir = st.selectbox(
88
+ "Absorbance / Intensity column",
89
+ numeric_cols,
90
+ index=min(1, len(numeric_cols) - 1),
91
+ key="ir_ab",
92
+ )
93
+
94
+ ir_op = st.radio(
95
+ "Operation",
96
+ [
97
+ "Absorbance ↔ Transmittance",
98
+ "ATR Correction",
99
+ "Spectral Subtraction",
100
+ "Remove Cosmic Rays",
101
+ "Band Assignment",
102
+ ],
103
+ horizontal=True,
104
+ key="ir_op",
105
+ )
106
+
107
+ wn_arr = data[wn_col].dropna().to_numpy(dtype=float)
108
+ ab_arr = data[ab_col_ir].dropna().to_numpy(dtype=float)
109
+ n_ir = min(len(wn_arr), len(ab_arr))
110
+ wn_arr, ab_arr = wn_arr[:n_ir], ab_arr[:n_ir]
111
+
112
+ # ── Absorbance ↔ Transmittance ─────────────────────────────────────────
113
+ if ir_op == "Absorbance ↔ Transmittance":
114
+ direction = st.radio(
115
+ "Direction",
116
+ ["Absorbance → Transmittance (%)", "Transmittance (%) → Absorbance"],
117
+ horizontal=True,
118
+ key="ir_conv_dir",
119
+ )
120
+ if st.button("Convert", key="ir_conv_btn"):
121
+ try:
122
+ if direction == "Absorbance → Transmittance (%)":
123
+ result_arr = absorbance_to_transmittance(ab_arr)
124
+ result_label = "Transmittance (%)"
125
+ else:
126
+ result_arr = transmittance_to_absorbance(ab_arr)
127
+ result_label = "Absorbance"
128
+ fig, ax = plt.subplots(figsize=(8, 4))
129
+ ax.plot(wn_arr, result_arr)
130
+ ax.set_xlabel(wn_col)
131
+ ax.set_ylabel(result_label)
132
+ ax.set_title("Converted Spectrum")
133
+ st.pyplot(fig, width="stretch")
134
+ plt.close(fig)
135
+ out_df = pd.DataFrame({wn_col: wn_arr, result_label: result_arr})
136
+ _save(out_df, f"conv_{ab_col_ir}")
137
+ except Exception as exc:
138
+ st.error(str(exc))
139
+
140
+ # ── ATR Correction ─────────────────────────────────────────────────────
141
+ elif ir_op == "ATR Correction":
142
+ col1, col2, col3 = st.columns(3)
143
+ with col1:
144
+ n_atr = st.number_input(
145
+ "Crystal refractive index (n_atr)",
146
+ value=1.5,
147
+ min_value=1.0,
148
+ step=0.1,
149
+ key="ir_natr",
150
+ )
151
+ with col2:
152
+ angle_deg = st.number_input(
153
+ "Angle of incidence (°)",
154
+ value=45.0,
155
+ min_value=1.0,
156
+ max_value=89.0,
157
+ step=1.0,
158
+ key="ir_angle",
159
+ )
160
+ with col3:
161
+ n_sample = st.number_input(
162
+ "Sample refractive index",
163
+ value=1.0,
164
+ min_value=0.1,
165
+ step=0.1,
166
+ key="ir_nsamp",
167
+ )
168
+ if st.button("Apply ATR Correction", key="ir_atr_btn"):
169
+ try:
170
+ result_arr = atr_correction(
171
+ wn_arr,
172
+ ab_arr,
173
+ n_atr=float(n_atr),
174
+ angle_deg=float(angle_deg),
175
+ n_sample=float(n_sample),
176
+ )
177
+ fig, axes = plt.subplots(1, 2, figsize=(12, 4))
178
+ axes[0].plot(wn_arr, ab_arr)
179
+ axes[0].set_title("Original")
180
+ axes[0].set_xlabel(wn_col)
181
+ axes[1].plot(wn_arr, result_arr, color="darkorange")
182
+ axes[1].set_title("ATR Corrected")
183
+ axes[1].set_xlabel(wn_col)
184
+ st.pyplot(fig, width="stretch")
185
+ plt.close(fig)
186
+ out_df = pd.DataFrame({wn_col: wn_arr, f"{ab_col_ir}_atr": result_arr})
187
+ _save(out_df, f"atr_{ab_col_ir}")
188
+ except Exception as exc:
189
+ st.error(str(exc))
190
+
191
+ # ── Spectral Subtraction ───────────────────────────────────────────────
192
+ elif ir_op == "Spectral Subtraction":
193
+ col1, col2 = st.columns(2)
194
+ with col1:
195
+ ref_col = st.selectbox("Reference column", numeric_cols, key="ir_ref")
196
+ with col2:
197
+ scale = st.number_input("Scale factor", value=1.0, step=0.05, key="ir_scale")
198
+ if st.button("Subtract", key="ir_sub_btn"):
199
+ try:
200
+ ref_arr = data[ref_col].dropna().to_numpy(dtype=float)[:n_ir]
201
+ result_arr = spectral_subtraction(wn_arr, ab_arr, ref_arr, scale=float(scale))
202
+ fig, ax = plt.subplots(figsize=(8, 4))
203
+ ax.plot(wn_arr, result_arr)
204
+ ax.set_xlabel(wn_col)
205
+ ax.set_ylabel("Difference")
206
+ ax.set_title(f"{ab_col_ir} − {scale:.2f}×{ref_col}")
207
+ st.pyplot(fig, width="stretch")
208
+ plt.close(fig)
209
+ out_df = pd.DataFrame({wn_col: wn_arr, "difference": result_arr})
210
+ _save(out_df, f"sub_{ab_col_ir}")
211
+ except Exception as exc:
212
+ st.error(str(exc))
213
+
214
+ # ── Remove Cosmic Rays ─────────────────────────────────────────────────
215
+ elif ir_op == "Remove Cosmic Rays":
216
+ col1, col2 = st.columns(2)
217
+ with col1:
218
+ thresh_sigma = st.number_input(
219
+ "Threshold (σ)", value=5.0, min_value=1.0, step=0.5, key="ir_cr_thresh"
220
+ )
221
+ with col2:
222
+ cr_window = st.number_input(
223
+ "Window half-width", value=5, min_value=1, step=1, key="ir_cr_window"
224
+ )
225
+ if st.button("Remove Cosmic Rays", key="ir_cr_btn"):
226
+ result_arr = remove_cosmic_rays(
227
+ ab_arr, threshold_sigma=float(thresh_sigma), window=int(cr_window)
228
+ )
229
+ fig, ax = plt.subplots(figsize=(8, 4))
230
+ ax.plot(wn_arr, ab_arr, alpha=0.5, label="Original")
231
+ ax.plot(wn_arr, result_arr, label="Cleaned")
232
+ ax.set_xlabel(wn_col)
233
+ ax.legend()
234
+ st.pyplot(fig, width="stretch")
235
+ plt.close(fig)
236
+ out_df = pd.DataFrame({wn_col: wn_arr, f"{ab_col_ir}_cleaned": result_arr})
237
+ _save(out_df, f"cr_{ab_col_ir}")
238
+
239
+ # ── Band Assignment ────────────────────────────────────────────────────
240
+ elif ir_op == "Band Assignment":
241
+ threshold_ab = st.number_input(
242
+ "Min absorbance for peaks",
243
+ value=0.05,
244
+ min_value=0.0,
245
+ step=0.01,
246
+ key="ir_ba_thresh",
247
+ )
248
+ if st.button("Assign Bands", key="ir_ba_btn"):
249
+ from scipy.signal import find_peaks as _sp_peaks
250
+
251
+ peak_idx, _ = _sp_peaks(ab_arr, height=float(threshold_ab))
252
+ if len(peak_idx) == 0:
253
+ st.info("No bands found above threshold.")
254
+ else:
255
+ rows = []
256
+ for idx in peak_idx:
257
+ peak_wn = float(wn_arr[idx])
258
+ groups = assign_bands(peak_wn)
259
+ rows.append(
260
+ {
261
+ "Wavenumber (cm⁻¹)": f"{peak_wn:.1f}",
262
+ "Absorbance": f"{ab_arr[idx]:.4f}",
263
+ "Possible Assignments": (", ".join(groups) if groups else "—"),
264
+ }
265
+ )
266
+ st.dataframe(pd.DataFrame(rows), width="stretch")
267
+
268
+ # ── NIST WebBook Lookup ────────────────────────────────────────────────────
269
+ with st.expander("🔍 NIST WebBook Lookup"):
270
+ st.markdown(
271
+ "Download an IR spectrum directly from the "
272
+ "[NIST Chemistry WebBook](https://webbook.nist.gov) by CAS number."
273
+ )
274
+ col_nist1, col_nist2 = st.columns([3, 1])
275
+ with col_nist1:
276
+ nist_cas = st.text_input(
277
+ "CAS Registry Number",
278
+ value="64-17-5",
279
+ key="nist_cas",
280
+ help="Example: 64-17-5 (ethanol), 67-63-0 (isopropanol)",
281
+ )
282
+ with col_nist2:
283
+ nist_idx = st.number_input(
284
+ "Spectrum index",
285
+ min_value=0,
286
+ max_value=10,
287
+ value=0,
288
+ step=1,
289
+ key="nist_idx",
290
+ help="0 = first available IR spectrum",
291
+ )
292
+ from eagleplot.nist import fetch_ir_spectrum as _nist_fetch, get_compound_url as _nist_url
293
+
294
+ nist_page_url = _nist_url(nist_cas) if nist_cas.strip() else ""
295
+ if nist_page_url:
296
+ st.markdown(f"NIST page: [{nist_cas}]({nist_page_url})")
297
+ if st.button("Fetch IR spectrum from NIST", key="nist_fetch_btn", type="primary"):
298
+ if not nist_cas.strip():
299
+ st.warning("Please enter a CAS Registry Number.")
300
+ else:
301
+ try:
302
+ nist_df = _nist_fetch(nist_cas.strip(), index=int(nist_idx))
303
+ title = nist_df.attrs.get("TITLE", nist_cas.strip())
304
+ ds_name = (
305
+ f"NIST_{nist_cas.strip().replace('-', '')}_{title[:20].replace(' ', '_')}"
306
+ )
307
+ add_dataset(ds_name, nist_df)
308
+ st.success(f"Spectrum fetched and saved as **{ds_name}**")
309
+ st.dataframe(nist_df.head(20), width="stretch")
310
+ fig_nist, ax_nist = plt.subplots(figsize=(8, 4))
311
+ ax_nist.plot(nist_df["x"], nist_df["y"], color="steelblue", linewidth=1.2)
312
+ ax_nist.set_xlabel("Wavenumber (cm⁻¹)")
313
+ ax_nist.set_ylabel("Intensity")
314
+ ax_nist.set_title(f"IR Spectrum: {title}")
315
+ ax_nist.invert_xaxis()
316
+ st.pyplot(fig_nist, use_container_width=False)
317
+ plt.close(fig_nist)
318
+ except ImportError as e:
319
+ st.warning(str(e))
320
+ except RuntimeError as e:
321
+ st.error(str(e))
322
+ except Exception as e:
323
+ st.error(f"Unexpected error: {e}")
324
+
325
+ # ─────────────────────────────────────────────────────────────────────────────
326
+ # Tab 2 — NMR
327
+ # ─────────────────────────────────────────────────────────────────────────────
328
+ with tab_nmr:
329
+ st.header("NMR Processing")
330
+
331
+ col1, col2 = st.columns(2)
332
+ with col1:
333
+ fid_col = st.selectbox("FID / Spectrum column", numeric_cols, key="nmr_fid")
334
+ with col2:
335
+ nmr_op = st.selectbox(
336
+ "Operation",
337
+ [
338
+ "Line Broadening + FFT",
339
+ "Peak Picking",
340
+ "Region Integration",
341
+ "Calibrate Hz → ppm",
342
+ ],
343
+ key="nmr_op",
344
+ )
345
+
346
+ fid_arr = data[fid_col].dropna().to_numpy(dtype=float)
347
+
348
+ # ── Line Broadening + FFT ──────────────────────────────────────────────
349
+ if nmr_op == "Line Broadening + FFT":
350
+ col1, col2, col3 = st.columns(3)
351
+ with col1:
352
+ dt = st.number_input(
353
+ "Dwell time (s)",
354
+ value=0.0001,
355
+ format="%.6f",
356
+ step=0.0001,
357
+ key="nmr_dt",
358
+ )
359
+ with col2:
360
+ lb = st.number_input(
361
+ "Line broadening (Hz)",
362
+ value=0.5,
363
+ min_value=0.0,
364
+ step=0.1,
365
+ key="nmr_lb",
366
+ )
367
+ with col3:
368
+ n_zf = st.number_input(
369
+ "Zero-fill to (pts; 0=none)",
370
+ value=0,
371
+ min_value=0,
372
+ step=1024,
373
+ key="nmr_zf",
374
+ )
375
+ lb_mode = st.radio(
376
+ "Apodisation", ["lorentzian", "gaussian"], horizontal=True, key="nmr_lb_mode"
377
+ )
378
+ if st.button("Process FID → Spectrum", key="nmr_fft_btn"):
379
+ try:
380
+ proc = apply_line_broadening(fid_arr, dt=float(dt), lb=float(lb), mode=lb_mode)
381
+ if int(n_zf) > len(proc):
382
+ proc = zero_fill(proc, int(n_zf))
383
+ result = nmr_fft(proc, dt=float(dt))
384
+ spectrum = result["spectrum"]
385
+ freq_hz = result["frequencies_hz"]
386
+ fig, ax = plt.subplots(figsize=(9, 4))
387
+ ax.plot(freq_hz, spectrum)
388
+ ax.set_xlabel("Frequency (Hz)")
389
+ ax.set_ylabel("Intensity")
390
+ ax.set_title("NMR Spectrum (FFT)")
391
+ st.pyplot(fig, width="stretch")
392
+ plt.close(fig)
393
+ out_df = pd.DataFrame({"frequency_hz": freq_hz, "spectrum": spectrum})
394
+ _save(out_df, f"fft_{fid_col}")
395
+ except Exception as exc:
396
+ st.error(str(exc))
397
+
398
+ # ── Peak Picking ───────────────────────────────────────────────────────
399
+ elif nmr_op == "Peak Picking":
400
+ ppm_col_nmr = st.selectbox("Chemical shift (ppm) column", numeric_cols, key="nmr_pk_ppm")
401
+ col1, col2 = st.columns(2)
402
+ with col1:
403
+ thresh_frac = st.number_input(
404
+ "Threshold (fraction of max)",
405
+ value=0.01,
406
+ min_value=0.001,
407
+ step=0.005,
408
+ key="nmr_pk_thresh",
409
+ )
410
+ with col2:
411
+ min_sep_nmr = st.number_input(
412
+ "Min separation (ppm)",
413
+ value=0.05,
414
+ min_value=0.001,
415
+ step=0.01,
416
+ key="nmr_pk_sep",
417
+ )
418
+ if st.button("Pick Peaks", key="nmr_pk_btn"):
419
+ ppm_arr = data[ppm_col_nmr].dropna().to_numpy(dtype=float)
420
+ n_use = min(len(ppm_arr), len(fid_arr))
421
+ result = pick_nmr_peaks(
422
+ ppm_arr[:n_use],
423
+ fid_arr[:n_use],
424
+ threshold_fraction=float(thresh_frac),
425
+ min_separation_ppm=float(min_sep_nmr),
426
+ )
427
+ fig, ax = plt.subplots(figsize=(9, 4))
428
+ ax.plot(ppm_arr[:n_use], fid_arr[:n_use])
429
+ if result["n_peaks"] > 0:
430
+ ax.scatter(
431
+ result["ppm_positions"],
432
+ result["intensities"],
433
+ color="red",
434
+ zorder=5,
435
+ )
436
+ ax.invert_xaxis()
437
+ ax.set_xlabel("Chemical Shift (ppm)")
438
+ ax.set_ylabel("Intensity")
439
+ ax.set_title(f"NMR Peaks ({result['n_peaks']} found)")
440
+ st.pyplot(fig, width="stretch")
441
+ plt.close(fig)
442
+ if result["n_peaks"] > 0:
443
+ pk_df = pd.DataFrame(
444
+ {
445
+ "ppm": result["ppm_positions"],
446
+ "intensity": result["intensities"],
447
+ }
448
+ )
449
+ st.dataframe(pk_df, width="stretch")
450
+
451
+ # ── Region Integration ─────────────────────────────────────────────────
452
+ elif nmr_op == "Region Integration":
453
+ ppm_col_int = st.selectbox("Chemical shift (ppm) column", numeric_cols, key="nmr_int_ppm")
454
+ st.markdown("Enter integration regions — one per line as `ppm_low, ppm_high`:")
455
+ regions_text = st.text_area(
456
+ "Regions", "0.0, 2.0\n2.0, 4.0\n7.0, 8.0", key="nmr_int_regions"
457
+ )
458
+ if st.button("Integrate Regions", key="nmr_int_btn"):
459
+ try:
460
+ regions = []
461
+ for line in regions_text.strip().split("\n"):
462
+ parts = line.split(",")
463
+ if len(parts) == 2:
464
+ regions.append((float(parts[0]), float(parts[1])))
465
+ if not regions:
466
+ st.warning("No valid regions entered.")
467
+ else:
468
+ ppm_arr = data[ppm_col_int].dropna().to_numpy(dtype=float)
469
+ n_use = min(len(ppm_arr), len(fid_arr))
470
+ result = integrate_nmr_regions(ppm_arr[:n_use], fid_arr[:n_use], regions)
471
+ int_df = pd.DataFrame(
472
+ {
473
+ "Region": [f"{r[0]:.2f}–{r[1]:.2f} ppm" for r in result["regions"]],
474
+ "Integral": result["integrals"],
475
+ "Ratio": result["normalized_ratios"],
476
+ }
477
+ )
478
+ st.dataframe(int_df, width="stretch")
479
+ except Exception as exc:
480
+ st.error(str(exc))
481
+
482
+ # ── Calibrate Hz → ppm ─────────────────────────────────────────────────
483
+ elif nmr_op == "Calibrate Hz → ppm":
484
+ col1, col2 = st.columns(2)
485
+ with col1:
486
+ spec_freq = st.number_input(
487
+ "Spectrometer frequency (MHz)",
488
+ value=400.0,
489
+ step=1.0,
490
+ key="nmr_cal_freq",
491
+ )
492
+ with col2:
493
+ ref_hz = st.number_input(
494
+ "Reference offset (Hz)", value=0.0, step=0.1, key="nmr_cal_ref"
495
+ )
496
+ if st.button("Convert Hz → ppm", key="nmr_cal_btn"):
497
+ ppm_out = calibrate_ppm_axis(
498
+ fid_arr,
499
+ spectrometer_freq_mhz=float(spec_freq),
500
+ reference_hz=float(ref_hz),
501
+ )
502
+ out_df = pd.DataFrame({"ppm": ppm_out, "intensity": fid_arr})
503
+ _save(out_df, f"ppm_{fid_col}")
504
+
505
+ # ─────────────────────────────────────────────────────────────────────────────
506
+ # Tab 3 — UV-Vis
507
+ # ─────────────────────────────────────────────────────────────────────────────
508
+ with tab_uv:
509
+ st.header("UV-Vis Analysis")
510
+
511
+ uv_op = st.radio(
512
+ "Operation",
513
+ ["Beer-Lambert Calculator", "Calibration Series", "Spectral Overlap (FRET)"],
514
+ horizontal=True,
515
+ key="uv_op",
516
+ )
517
+
518
+ # ── Beer-Lambert ───────────────────────────────────────────────────────
519
+ if uv_op == "Beer-Lambert Calculator":
520
+ st.markdown("Solve **A = ε × c × l** for the unknown quantity.")
521
+ col1, col2, col3 = st.columns(3)
522
+ with col1:
523
+ ab_val = st.number_input(
524
+ "Absorbance (A)", value=0.5, min_value=0.0, step=0.01, key="uv_bl_ab"
525
+ )
526
+ with col2:
527
+ solve_for = st.radio(
528
+ "Solve for",
529
+ ["Concentration (c)", "Molar absorptivity (ε)"],
530
+ key="uv_bl_solve",
531
+ )
532
+ with col3:
533
+ path_l = st.number_input(
534
+ "Path length (cm)",
535
+ value=1.0,
536
+ min_value=0.001,
537
+ step=0.1,
538
+ key="uv_bl_path",
539
+ )
540
+ if solve_for == "Concentration (c)":
541
+ eps_val = st.number_input(
542
+ "ε (L·mol⁻¹·cm⁻¹)",
543
+ value=1000.0,
544
+ min_value=0.001,
545
+ step=100.0,
546
+ key="uv_bl_eps",
547
+ )
548
+ if st.button("Calculate", key="uv_bl_btn"):
549
+ try:
550
+ result = beer_lambert(
551
+ absorbance=float(ab_val),
552
+ epsilon=float(eps_val),
553
+ concentration=None,
554
+ path_length=float(path_l),
555
+ )
556
+ st.success(f"**Concentration = {result['concentration']:.6g} mol/L**")
557
+ except Exception as exc:
558
+ st.error(str(exc))
559
+ else:
560
+ conc_val = st.number_input(
561
+ "Concentration (mol/L)",
562
+ value=0.001,
563
+ min_value=1e-10,
564
+ format="%.6f",
565
+ step=0.0001,
566
+ key="uv_bl_conc",
567
+ )
568
+ if st.button("Calculate", key="uv_bl_btn2"):
569
+ try:
570
+ result = beer_lambert(
571
+ absorbance=float(ab_val),
572
+ epsilon=None,
573
+ concentration=float(conc_val),
574
+ path_length=float(path_l),
575
+ )
576
+ st.success(f"**ε = {result['epsilon']:.4g} L·mol⁻¹·cm⁻¹**")
577
+ except Exception as exc:
578
+ st.error(str(exc))
579
+
580
+ # ── Calibration Series ─────────────────────────────────────────────────
581
+ elif uv_op == "Calibration Series":
582
+ col1, col2, col3 = st.columns(3)
583
+ with col1:
584
+ conc_col_uv = st.selectbox("Concentration column", numeric_cols, key="uv_cs_conc")
585
+ with col2:
586
+ abs_col_uv = st.selectbox(
587
+ "Absorbance column",
588
+ numeric_cols,
589
+ index=min(1, len(numeric_cols) - 1),
590
+ key="uv_cs_abs",
591
+ )
592
+ with col3:
593
+ path_l2 = st.number_input(
594
+ "Path length (cm)",
595
+ value=1.0,
596
+ min_value=0.001,
597
+ step=0.1,
598
+ key="uv_cs_path",
599
+ )
600
+ if st.button("Fit Calibration", key="uv_cs_btn"):
601
+ try:
602
+ c_arr = data[conc_col_uv].dropna().to_numpy(dtype=float)
603
+ a_arr = data[abs_col_uv].dropna().to_numpy(dtype=float)
604
+ n_use = min(len(c_arr), len(a_arr))
605
+ result = molar_absorptivity_series(
606
+ c_arr[:n_use], a_arr[:n_use], path_length=float(path_l2)
607
+ )
608
+ fig, ax = plt.subplots(figsize=(7, 5))
609
+ ax.scatter(c_arr[:n_use], a_arr[:n_use], label="Data", zorder=5)
610
+ ax.plot(
611
+ c_arr[:n_use],
612
+ result["fitted_absorbances"],
613
+ color="red",
614
+ label="Fit",
615
+ )
616
+ ax.set_xlabel("Concentration (mol/L)")
617
+ ax.set_ylabel("Absorbance")
618
+ r2 = result["r_squared"]
619
+ eps = result["epsilon"]
620
+ ax.set_title(f"ε = {eps:.2f} L·mol⁻¹·cm⁻¹ | R² = {r2:.4f}")
621
+ ax.legend()
622
+ st.pyplot(fig, width="stretch")
623
+ plt.close(fig)
624
+ lin_str = "✅ Linear" if result["linearity_ok"] else "⚠️ Non-linear"
625
+ st.info(f"**ε = {eps:.4g} L·mol⁻¹·cm⁻¹** | **R² = {r2:.5f}** | {lin_str}")
626
+ except Exception as exc:
627
+ st.error(str(exc))
628
+
629
+ # ── Spectral Overlap (FRET) ────────────────────────────────────────────
630
+ elif uv_op == "Spectral Overlap (FRET)":
631
+ st.markdown("Calculate the spectral overlap integral **J** for FRET efficiency.")
632
+ col1, col2 = st.columns(2)
633
+ with col1:
634
+ st.markdown("**Donor emission**")
635
+ wl_d_col = st.selectbox("Wavelength (nm)", numeric_cols, key="uv_fret_wld")
636
+ em_d_col = st.selectbox(
637
+ "Emission intensity",
638
+ numeric_cols,
639
+ index=min(1, len(numeric_cols) - 1),
640
+ key="uv_fret_emd",
641
+ )
642
+ with col2:
643
+ st.markdown("**Acceptor absorption**")
644
+ wl_a_col = st.selectbox(
645
+ "Wavelength (nm)",
646
+ numeric_cols,
647
+ index=min(2, len(numeric_cols) - 1),
648
+ key="uv_fret_wla",
649
+ )
650
+ abs_a_col = st.selectbox(
651
+ "ε (L·mol⁻¹·cm⁻¹)",
652
+ numeric_cols,
653
+ index=min(3, len(numeric_cols) - 1),
654
+ key="uv_fret_absa",
655
+ )
656
+ if st.button("Calculate J", key="uv_fret_btn"):
657
+ try:
658
+ wd = data[wl_d_col].dropna().to_numpy(dtype=float)
659
+ ed = data[em_d_col].dropna().to_numpy(dtype=float)
660
+ wa = data[wl_a_col].dropna().to_numpy(dtype=float)
661
+ aa = data[abs_a_col].dropna().to_numpy(dtype=float)
662
+ n_d = min(len(wd), len(ed))
663
+ n_a = min(len(wa), len(aa))
664
+ result = spectral_overlap_integral(wd[:n_d], ed[:n_d], wa[:n_a], aa[:n_a])
665
+ st.success(f"**J = {result['J']:.4e} M⁻¹·cm⁻¹·nm⁴**")
666
+ if len(result["wavelengths_common"]) > 0:
667
+ fig, ax = plt.subplots(figsize=(8, 4))
668
+ ax2 = ax.twinx()
669
+ ax.plot(
670
+ result["wavelengths_common"],
671
+ result["donor_norm"],
672
+ color="green",
673
+ label="Donor (norm.)",
674
+ )
675
+ ax2.plot(
676
+ result["wavelengths_common"],
677
+ result["acceptor_interp"],
678
+ color="red",
679
+ label="Acceptor ε",
680
+ )
681
+ ax.set_xlabel("Wavelength (nm)")
682
+ ax.set_ylabel("Donor emission (norm.)")
683
+ ax2.set_ylabel("ε (L·mol⁻¹·cm⁻¹)")
684
+ ax.legend(loc="upper left")
685
+ ax2.legend(loc="upper right")
686
+ st.pyplot(fig, width="stretch")
687
+ plt.close(fig)
688
+ except Exception as exc:
689
+ st.error(str(exc))
690
+
691
+ # ─────────────────────────────────────────────────────────────────────────────
692
+ # Tab 4 — Mass Spec
693
+ # ─────────────────────────────────────────────────────────────────────────────
694
+ with tab_ms:
695
+ st.header("Mass Spectrometry")
696
+
697
+ col1, col2 = st.columns(2)
698
+ with col1:
699
+ mz_col = st.selectbox("m/z column", numeric_cols, key="ms_mz")
700
+ with col2:
701
+ int_col_ms = st.selectbox(
702
+ "Intensity column",
703
+ numeric_cols,
704
+ index=min(1, len(numeric_cols) - 1),
705
+ key="ms_int",
706
+ )
707
+
708
+ mz_arr = data[mz_col].dropna().to_numpy(dtype=float)
709
+ int_arr_ms = data[int_col_ms].dropna().to_numpy(dtype=float)
710
+ n_ms = min(len(mz_arr), len(int_arr_ms))
711
+
712
+ ms_op = st.radio(
713
+ "Operation",
714
+ ["Find Peaks", "Centroid Spectrum"],
715
+ horizontal=True,
716
+ key="ms_op",
717
+ )
718
+
719
+ # ── Find Peaks ─────────────────────────────────────────────────────────
720
+ if ms_op == "Find Peaks":
721
+ col1, col2 = st.columns(2)
722
+ with col1:
723
+ min_int_frac = st.number_input(
724
+ "Min intensity (fraction of base)",
725
+ value=0.01,
726
+ min_value=0.001,
727
+ step=0.005,
728
+ key="ms_pk_thresh",
729
+ )
730
+ with col2:
731
+ min_sep_ms = st.number_input(
732
+ "Min m/z separation",
733
+ value=0.3,
734
+ min_value=0.01,
735
+ step=0.1,
736
+ key="ms_pk_sep",
737
+ )
738
+ if st.button("Find Peaks", key="ms_pk_btn"):
739
+ result = find_mz_peaks(
740
+ mz_arr[:n_ms],
741
+ int_arr_ms[:n_ms],
742
+ min_intensity_fraction=float(min_int_frac),
743
+ min_separation=float(min_sep_ms),
744
+ )
745
+ import plotly.graph_objects as go
746
+
747
+ fig = go.Figure()
748
+ for mv, iv in zip(mz_arr[:n_ms], int_arr_ms[:n_ms]):
749
+ fig.add_trace(
750
+ go.Scatter(
751
+ x=[mv, mv],
752
+ y=[0, iv],
753
+ mode="lines",
754
+ line=dict(color="steelblue", width=1),
755
+ showlegend=False,
756
+ hoverinfo="skip",
757
+ )
758
+ )
759
+ if result["n_peaks"] > 0:
760
+ fig.add_trace(
761
+ go.Scatter(
762
+ x=result["mz_positions"],
763
+ y=result["intensities"],
764
+ mode="markers+text",
765
+ text=[f"{m:.2f}" for m in result["mz_positions"]],
766
+ textposition="top center",
767
+ marker=dict(color="red", size=8),
768
+ name="Peaks",
769
+ )
770
+ )
771
+ fig.update_layout(xaxis_title="m/z", yaxis_title="Intensity", height=400)
772
+ st.plotly_chart(fig, width="stretch")
773
+
774
+ if result["n_peaks"] > 0:
775
+ st.markdown(
776
+ f"**Base peak:** m/z = {result['base_peak_mz']:.4f} "
777
+ f"(intensity = {result['base_peak_intensity']:.1f})"
778
+ )
779
+ pk_df = pd.DataFrame(
780
+ {
781
+ "m/z": result["mz_positions"],
782
+ "Intensity": result["intensities"],
783
+ "Relative Intensity (%)": result["relative_intensities"],
784
+ }
785
+ )
786
+ st.dataframe(pk_df, width="stretch")
787
+ else:
788
+ st.info("No peaks found above threshold.")
789
+
790
+ # ── Centroid Spectrum ──────────────────────────────────────────────────
791
+ elif ms_op == "Centroid Spectrum":
792
+ width_da = st.number_input(
793
+ "m/z grouping window (Da)",
794
+ value=0.05,
795
+ min_value=0.001,
796
+ step=0.01,
797
+ key="ms_cent_width",
798
+ )
799
+ if st.button("Centroid", key="ms_cent_btn"):
800
+ result = centroid_spectrum(mz_arr[:n_ms], int_arr_ms[:n_ms], width=float(width_da))
801
+ import plotly.graph_objects as go
802
+
803
+ fig = go.Figure(
804
+ go.Bar(
805
+ x=result["mz_centroids"],
806
+ y=result["intensities"],
807
+ name="Centroids",
808
+ marker_color="steelblue",
809
+ )
810
+ )
811
+ fig.update_layout(xaxis_title="m/z", yaxis_title="Intensity", height=400)
812
+ st.plotly_chart(fig, width="stretch")
813
+ st.info(f"{result['n_peaks']} centroided peaks")
814
+ if result["n_peaks"] > 0:
815
+ out_df = pd.DataFrame(
816
+ {
817
+ "mz": result["mz_centroids"],
818
+ "intensity": result["intensities"],
819
+ }
820
+ )
821
+ _save(out_df, f"centroid_{int_col_ms}")