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,2510 @@
|
|
|
1
|
+
"""Analysis Tools Page.
|
|
2
|
+
|
|
3
|
+
Statistical analysis, curve fitting, optimization, and linear algebra
|
|
4
|
+
operations on loaded datasets, using functions from eagleplot.math.
|
|
5
|
+
|
|
6
|
+
Tabs
|
|
7
|
+
----
|
|
8
|
+
- Statistics: calculate_statistics()
|
|
9
|
+
- Distribution: check_normality(), fit_distribution()
|
|
10
|
+
- Curve Fitting: fit_linear(), fit_polynomial(), fit_exponential(), fit_custom()
|
|
11
|
+
- Optimization: minimize_function(), find_roots()
|
|
12
|
+
- Linear Algebra: compute_eigenvalues(), solve_linear_system(), matrix_decomposition()
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import traceback
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
import streamlit as st
|
|
20
|
+
import numpy as np
|
|
21
|
+
import pandas as pd
|
|
22
|
+
import matplotlib.pyplot as plt
|
|
23
|
+
import plotly.graph_objects as go
|
|
24
|
+
from eagleplot.peaks import (
|
|
25
|
+
find_peaks as pk_find_peaks,
|
|
26
|
+
integrate_peaks,
|
|
27
|
+
compute_fwhm,
|
|
28
|
+
fit_multipeak,
|
|
29
|
+
)
|
|
30
|
+
from eagleplot.signal import (
|
|
31
|
+
smooth_moving_average,
|
|
32
|
+
smooth_savitzky_golay,
|
|
33
|
+
smooth_gaussian,
|
|
34
|
+
filter_lowpass,
|
|
35
|
+
filter_highpass,
|
|
36
|
+
filter_bandpass,
|
|
37
|
+
filter_bandstop,
|
|
38
|
+
fft as sig_fft,
|
|
39
|
+
derivative as sig_derivative,
|
|
40
|
+
baseline_polynomial,
|
|
41
|
+
baseline_rolling_ball,
|
|
42
|
+
baseline_als,
|
|
43
|
+
interpolate as sig_interpolate,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
|
47
|
+
|
|
48
|
+
from eagleplot.math import (
|
|
49
|
+
calculate_statistics,
|
|
50
|
+
check_normality,
|
|
51
|
+
fit_distribution,
|
|
52
|
+
fit_linear,
|
|
53
|
+
fit_polynomial,
|
|
54
|
+
fit_exponential,
|
|
55
|
+
fit_custom,
|
|
56
|
+
minimize_function,
|
|
57
|
+
find_roots,
|
|
58
|
+
compute_eigenvalues,
|
|
59
|
+
solve_linear_system,
|
|
60
|
+
matrix_decomposition,
|
|
61
|
+
ttest_one_sample,
|
|
62
|
+
ttest_two_sample,
|
|
63
|
+
ttest_paired,
|
|
64
|
+
mannwhitney_u,
|
|
65
|
+
wilcoxon_signed_rank,
|
|
66
|
+
kruskal_wallis,
|
|
67
|
+
anova_oneway,
|
|
68
|
+
anova_twoway,
|
|
69
|
+
tukey_hsd,
|
|
70
|
+
bonferroni_correction,
|
|
71
|
+
pearson_correlation,
|
|
72
|
+
spearman_correlation,
|
|
73
|
+
chi_square_independence,
|
|
74
|
+
)
|
|
75
|
+
from eagleplot.utils import (
|
|
76
|
+
initialize_session_state,
|
|
77
|
+
get_dataset,
|
|
78
|
+
add_analysis_result,
|
|
79
|
+
get_session_summary,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
initialize_session_state()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _numeric_cols(data) -> list:
|
|
89
|
+
if isinstance(data, pd.DataFrame):
|
|
90
|
+
return data.select_dtypes(include=[np.number]).columns.tolist()
|
|
91
|
+
return []
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _to_1d(data, col_ref=None) -> np.ndarray:
|
|
95
|
+
"""Extract a 1-D float array from *data*.
|
|
96
|
+
|
|
97
|
+
Parameters
|
|
98
|
+
----------
|
|
99
|
+
data : DataFrame or ndarray
|
|
100
|
+
Source dataset.
|
|
101
|
+
col_ref : str, int, or None
|
|
102
|
+
Column name (DataFrame), integer column index (2-D ndarray), or
|
|
103
|
+
``None`` to return the whole array flattened.
|
|
104
|
+
"""
|
|
105
|
+
if isinstance(data, pd.DataFrame):
|
|
106
|
+
return data[col_ref].values.astype(float)
|
|
107
|
+
arr = np.asarray(data, dtype=float)
|
|
108
|
+
if col_ref is None:
|
|
109
|
+
return arr.flatten()
|
|
110
|
+
return arr[:, int(col_ref)]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _to_2d(data) -> np.ndarray:
|
|
114
|
+
"""Return *data* as a 2-D float array (uses all numeric columns for DataFrames)."""
|
|
115
|
+
if isinstance(data, pd.DataFrame):
|
|
116
|
+
cols = _numeric_cols(data)
|
|
117
|
+
return data[cols].values.astype(float)
|
|
118
|
+
return np.asarray(data, dtype=float)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _pick_col(data, label: str, key: str):
|
|
122
|
+
"""Render a column-picker widget.
|
|
123
|
+
|
|
124
|
+
Returns
|
|
125
|
+
-------
|
|
126
|
+
col_ref : str | int | None
|
|
127
|
+
Column name (DataFrame), integer column index (2-D ndarray), or
|
|
128
|
+
``None`` for a 1-D array (whole array is used).
|
|
129
|
+
valid : bool
|
|
130
|
+
False only when a DataFrame has no numeric columns.
|
|
131
|
+
"""
|
|
132
|
+
if isinstance(data, pd.DataFrame):
|
|
133
|
+
cols = _numeric_cols(data)
|
|
134
|
+
if not cols:
|
|
135
|
+
st.warning("No numeric columns in this dataset.")
|
|
136
|
+
return None, False
|
|
137
|
+
return st.selectbox(label, cols, key=key), True
|
|
138
|
+
arr = np.asarray(data)
|
|
139
|
+
if arr.ndim == 1:
|
|
140
|
+
st.caption(f"{label}: 1-D array — all values used.")
|
|
141
|
+
return None, True
|
|
142
|
+
idx = st.selectbox(
|
|
143
|
+
label,
|
|
144
|
+
range(arr.shape[1]),
|
|
145
|
+
format_func=lambda i: f"Column {i}",
|
|
146
|
+
key=key,
|
|
147
|
+
)
|
|
148
|
+
return idx, True
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _r2_color(r2: float) -> str:
|
|
152
|
+
"""Return a quality label for R²."""
|
|
153
|
+
if r2 >= 0.99:
|
|
154
|
+
return "(excellent)"
|
|
155
|
+
if r2 >= 0.90:
|
|
156
|
+
return "(good)"
|
|
157
|
+
return "(poor)"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _fit_plot(x, y, predict_fn, x_label: str, y_label: str, title: str):
|
|
161
|
+
"""Return a matplotlib Figure with data scatter + fitted curve."""
|
|
162
|
+
fig, ax = plt.subplots(figsize=(7, 4))
|
|
163
|
+
ax.scatter(x, y, s=20, alpha=0.7, color="#333333", label="Data")
|
|
164
|
+
x_fit = np.linspace(x.min(), x.max(), 400)
|
|
165
|
+
ax.plot(x_fit, predict_fn(x_fit), "r-", linewidth=2, label="Fit")
|
|
166
|
+
ax.set_xlabel(x_label)
|
|
167
|
+
ax.set_ylabel(y_label)
|
|
168
|
+
ax.set_title(title)
|
|
169
|
+
ax.legend()
|
|
170
|
+
fig.tight_layout()
|
|
171
|
+
return fig
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ── Page layout ───────────────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
st.title("Analysis Tools")
|
|
177
|
+
st.caption(
|
|
178
|
+
"Statistical analysis, curve fitting, optimization, and linear algebra on your loaded datasets."
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
# Guard: need at least one dataset
|
|
182
|
+
summary = get_session_summary()
|
|
183
|
+
if summary["num_datasets"] == 0:
|
|
184
|
+
st.warning("No datasets loaded. Go to **Data Upload** first.")
|
|
185
|
+
st.stop()
|
|
186
|
+
|
|
187
|
+
# ── Dataset selector ──────────────────────────────────────────────────────────
|
|
188
|
+
st.markdown("---")
|
|
189
|
+
st.markdown("## Dataset")
|
|
190
|
+
|
|
191
|
+
dataset_names = list(st.session_state.datasets.keys())
|
|
192
|
+
default_idx = (
|
|
193
|
+
dataset_names.index(summary["current_dataset"])
|
|
194
|
+
if summary["current_dataset"] in dataset_names
|
|
195
|
+
else 0
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
col1, col2 = st.columns([3, 1])
|
|
199
|
+
with col1:
|
|
200
|
+
dataset_name = st.selectbox(
|
|
201
|
+
"Select dataset",
|
|
202
|
+
dataset_names,
|
|
203
|
+
index=default_idx,
|
|
204
|
+
key="at_dataset",
|
|
205
|
+
)
|
|
206
|
+
with col2:
|
|
207
|
+
st.write("")
|
|
208
|
+
st.write("")
|
|
209
|
+
st.metric("Loaded", summary["num_datasets"])
|
|
210
|
+
|
|
211
|
+
if dataset_name:
|
|
212
|
+
st.session_state.current_dataset = dataset_name
|
|
213
|
+
|
|
214
|
+
data = get_dataset(dataset_name)
|
|
215
|
+
|
|
216
|
+
if isinstance(data, pd.DataFrame):
|
|
217
|
+
st.caption(f"DataFrame — {data.shape[0]} rows × {data.shape[1]} cols")
|
|
218
|
+
elif isinstance(data, np.ndarray):
|
|
219
|
+
st.caption(f"NumPy array — shape {data.shape}, dtype {data.dtype}")
|
|
220
|
+
|
|
221
|
+
# ── Tabs ──────────────────────────────────────────────────────────────────────
|
|
222
|
+
st.markdown("---")
|
|
223
|
+
tab_stats, tab_dist, tab_fit, tab_opt, tab_linalg, tab_smooth, tab_peaks, tab_tests = st.tabs(
|
|
224
|
+
[
|
|
225
|
+
"Statistics",
|
|
226
|
+
"Distribution",
|
|
227
|
+
"Curve Fitting",
|
|
228
|
+
"Optimization",
|
|
229
|
+
"Linear Algebra",
|
|
230
|
+
"Signal Processing",
|
|
231
|
+
"Peak Fitting",
|
|
232
|
+
"Statistical Tests",
|
|
233
|
+
]
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
237
|
+
# Tab 1 — Statistics
|
|
238
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
239
|
+
with tab_stats:
|
|
240
|
+
st.markdown("### Descriptive Statistics")
|
|
241
|
+
|
|
242
|
+
col_ref, valid = _pick_col(data, "Select column / variable", key="stats_col")
|
|
243
|
+
|
|
244
|
+
if valid:
|
|
245
|
+
if st.button("Calculate Statistics", type="primary", key="stats_run"):
|
|
246
|
+
try:
|
|
247
|
+
arr = _to_1d(data, col_ref)
|
|
248
|
+
s = calculate_statistics(arr)
|
|
249
|
+
|
|
250
|
+
st.markdown("#### Results")
|
|
251
|
+
c1, c2, c3, c4 = st.columns(4)
|
|
252
|
+
c1.metric("Mean", f"{s['mean']:.6g}")
|
|
253
|
+
c2.metric("Median", f"{s['median']:.6g}")
|
|
254
|
+
c3.metric("Std Dev", f"{s['std']:.6g}")
|
|
255
|
+
c4.metric("Variance", f"{s['var']:.6g}")
|
|
256
|
+
|
|
257
|
+
c1, c2, c3, c4 = st.columns(4)
|
|
258
|
+
c1.metric("Min", f"{s['min']:.6g}")
|
|
259
|
+
c2.metric("Max", f"{s['max']:.6g}")
|
|
260
|
+
c3.metric("Range", f"{s['range']:.6g}")
|
|
261
|
+
c4.metric("IQR", f"{s['iqr']:.6g}")
|
|
262
|
+
|
|
263
|
+
c1, c2 = st.columns(2)
|
|
264
|
+
c1.metric("Q1 (25%)", f"{s['q1']:.6g}")
|
|
265
|
+
c2.metric("Q3 (75%)", f"{s['q3']:.6g}")
|
|
266
|
+
|
|
267
|
+
add_analysis_result(
|
|
268
|
+
{
|
|
269
|
+
"type": "statistics",
|
|
270
|
+
"dataset": dataset_name,
|
|
271
|
+
"column": str(col_ref),
|
|
272
|
+
"results": {k: float(v) for k, v in s.items()},
|
|
273
|
+
}
|
|
274
|
+
)
|
|
275
|
+
st.success("Results saved to analysis history.")
|
|
276
|
+
|
|
277
|
+
except Exception as exc:
|
|
278
|
+
st.error(f"Error: {exc}")
|
|
279
|
+
with st.expander("Details"):
|
|
280
|
+
st.code(traceback.format_exc())
|
|
281
|
+
|
|
282
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
283
|
+
# Tab 2 — Distribution
|
|
284
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
285
|
+
with tab_dist:
|
|
286
|
+
st.markdown("### Distribution Analysis")
|
|
287
|
+
|
|
288
|
+
col_ref_d, valid_d = _pick_col(data, "Select column / variable", key="dist_col")
|
|
289
|
+
|
|
290
|
+
if valid_d:
|
|
291
|
+
# ── Normality test ────────────────────────────────────────────────────
|
|
292
|
+
with st.expander("Shapiro-Wilk Normality Test", expanded=True):
|
|
293
|
+
if st.button("Test Normality", key="norm_run"):
|
|
294
|
+
try:
|
|
295
|
+
arr = _to_1d(data, col_ref_d)
|
|
296
|
+
result = check_normality(arr)
|
|
297
|
+
|
|
298
|
+
c1, c2 = st.columns(2)
|
|
299
|
+
c1.metric("W statistic", f"{result['statistic']:.6f}")
|
|
300
|
+
c2.metric("p-value", f"{result['p_value']:.6f}")
|
|
301
|
+
|
|
302
|
+
if result["is_normal"]:
|
|
303
|
+
st.success("Data appears **normally distributed** (p > 0.05).")
|
|
304
|
+
else:
|
|
305
|
+
st.warning("Data does **not** appear normally distributed (p ≤ 0.05).")
|
|
306
|
+
|
|
307
|
+
add_analysis_result(
|
|
308
|
+
{
|
|
309
|
+
"type": "normality_test",
|
|
310
|
+
"dataset": dataset_name,
|
|
311
|
+
"column": str(col_ref_d),
|
|
312
|
+
"results": result,
|
|
313
|
+
}
|
|
314
|
+
)
|
|
315
|
+
st.success("Results saved to analysis history.")
|
|
316
|
+
|
|
317
|
+
except Exception as exc:
|
|
318
|
+
st.error(f"Error: {exc}")
|
|
319
|
+
with st.expander("Details"):
|
|
320
|
+
st.code(traceback.format_exc())
|
|
321
|
+
|
|
322
|
+
# ── Distribution fitting ──────────────────────────────────────────────
|
|
323
|
+
with st.expander("Fit Statistical Distribution"):
|
|
324
|
+
_DIST_LABELS = {
|
|
325
|
+
"norm": "Normal (Gaussian)",
|
|
326
|
+
"expon": "Exponential",
|
|
327
|
+
"gamma": "Gamma",
|
|
328
|
+
"lognorm": "Log-Normal",
|
|
329
|
+
"beta": "Beta",
|
|
330
|
+
}
|
|
331
|
+
dist_choice = st.selectbox(
|
|
332
|
+
"Distribution",
|
|
333
|
+
list(_DIST_LABELS.keys()),
|
|
334
|
+
format_func=lambda d: _DIST_LABELS[d],
|
|
335
|
+
key="dist_choice",
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
if st.button("Fit Distribution", key="dist_run"):
|
|
339
|
+
try:
|
|
340
|
+
arr = _to_1d(data, col_ref_d)
|
|
341
|
+
result = fit_distribution(arr, distribution=dist_choice)
|
|
342
|
+
|
|
343
|
+
params_fmt = ", ".join(f"{p:.4g}" for p in result["params"])
|
|
344
|
+
st.markdown(
|
|
345
|
+
f"**Fitted {_DIST_LABELS[dist_choice]} parameters:** `({params_fmt})`"
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
c1, c2 = st.columns(2)
|
|
349
|
+
c1.metric("KS statistic", f"{result['ks_statistic']:.6f}")
|
|
350
|
+
c2.metric("KS p-value", f"{result['ks_pvalue']:.6f}")
|
|
351
|
+
|
|
352
|
+
if result["ks_pvalue"] > 0.05:
|
|
353
|
+
st.success(
|
|
354
|
+
f"Data is consistent with a "
|
|
355
|
+
f"{_DIST_LABELS[dist_choice]} distribution (KS p > 0.05)."
|
|
356
|
+
)
|
|
357
|
+
else:
|
|
358
|
+
st.warning(
|
|
359
|
+
f"Data is NOT well-described by "
|
|
360
|
+
f"{_DIST_LABELS[dist_choice]} distribution (KS p ≤ 0.05)."
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
add_analysis_result(
|
|
364
|
+
{
|
|
365
|
+
"type": "distribution_fit",
|
|
366
|
+
"dataset": dataset_name,
|
|
367
|
+
"column": str(col_ref_d),
|
|
368
|
+
"distribution": dist_choice,
|
|
369
|
+
"results": {
|
|
370
|
+
"params": [float(p) for p in result["params"]],
|
|
371
|
+
"ks_statistic": result["ks_statistic"],
|
|
372
|
+
"ks_pvalue": result["ks_pvalue"],
|
|
373
|
+
},
|
|
374
|
+
}
|
|
375
|
+
)
|
|
376
|
+
st.success("Results saved to analysis history.")
|
|
377
|
+
|
|
378
|
+
except Exception as exc:
|
|
379
|
+
st.error(f"Error: {exc}")
|
|
380
|
+
with st.expander("Details"):
|
|
381
|
+
st.code(traceback.format_exc())
|
|
382
|
+
|
|
383
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
384
|
+
# Tab 3 — Curve Fitting
|
|
385
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
386
|
+
with tab_fit:
|
|
387
|
+
st.markdown("### Curve Fitting")
|
|
388
|
+
|
|
389
|
+
# Check that dataset has at least 2 numeric columns / 2 array columns
|
|
390
|
+
_is_multidim = (isinstance(data, pd.DataFrame) and len(_numeric_cols(data)) >= 2) or (
|
|
391
|
+
isinstance(data, np.ndarray) and data.ndim == 2 and data.shape[1] >= 2
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
if not _is_multidim:
|
|
395
|
+
st.warning(
|
|
396
|
+
"Curve fitting requires at least 2 variables (X and Y). "
|
|
397
|
+
"Load a DataFrame with at least 2 numeric columns, "
|
|
398
|
+
"or a 2-D NumPy array with at least 2 columns."
|
|
399
|
+
)
|
|
400
|
+
else:
|
|
401
|
+
cfit1, cfit2 = st.columns(2)
|
|
402
|
+
with cfit1:
|
|
403
|
+
x_col_ref, valid_x = _pick_col(data, "X variable", key="fit_xcol")
|
|
404
|
+
with cfit2:
|
|
405
|
+
y_col_ref, valid_y = _pick_col(data, "Y variable", key="fit_ycol")
|
|
406
|
+
|
|
407
|
+
if valid_x and valid_y:
|
|
408
|
+
fit_type = st.radio(
|
|
409
|
+
"Fit type",
|
|
410
|
+
["Linear", "Polynomial", "Exponential", "Custom"],
|
|
411
|
+
horizontal=True,
|
|
412
|
+
key="fit_type",
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
poly_degree = None
|
|
416
|
+
custom_expr = custom_params_str = custom_p0_str = None
|
|
417
|
+
|
|
418
|
+
if fit_type == "Polynomial":
|
|
419
|
+
poly_degree = st.number_input(
|
|
420
|
+
"Degree",
|
|
421
|
+
min_value=1,
|
|
422
|
+
max_value=10,
|
|
423
|
+
value=2,
|
|
424
|
+
step=1,
|
|
425
|
+
key="fit_degree",
|
|
426
|
+
)
|
|
427
|
+
elif fit_type == "Custom":
|
|
428
|
+
st.markdown(
|
|
429
|
+
"Define your function using `x` (array) and named parameters. "
|
|
430
|
+
"`np` is available."
|
|
431
|
+
)
|
|
432
|
+
custom_expr = st.text_input(
|
|
433
|
+
"Expression (e.g. `a * np.exp(-b * x) + c`)",
|
|
434
|
+
value="a * x**2 + b * x + c",
|
|
435
|
+
key="fit_expr",
|
|
436
|
+
)
|
|
437
|
+
custom_params_str = st.text_input(
|
|
438
|
+
"Parameter names (comma-separated)",
|
|
439
|
+
value="a, b, c",
|
|
440
|
+
key="fit_params",
|
|
441
|
+
)
|
|
442
|
+
custom_p0_str = st.text_input(
|
|
443
|
+
"Initial guesses (comma-separated)",
|
|
444
|
+
value="1.0, 1.0, 0.0",
|
|
445
|
+
key="fit_p0",
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
show_fit_plot = st.checkbox("Show fitted curve plot", value=True, key="fit_showplot")
|
|
449
|
+
|
|
450
|
+
if st.button("Run Fit", type="primary", key="fit_run"):
|
|
451
|
+
try:
|
|
452
|
+
x = _to_1d(data, x_col_ref)
|
|
453
|
+
y = _to_1d(data, y_col_ref)
|
|
454
|
+
x_label = str(x_col_ref) if x_col_ref is not None else "x"
|
|
455
|
+
y_label = str(y_col_ref) if y_col_ref is not None else "y"
|
|
456
|
+
result = None
|
|
457
|
+
equation = ""
|
|
458
|
+
|
|
459
|
+
# ── Linear ───────────────────────────────────────────────
|
|
460
|
+
if fit_type == "Linear":
|
|
461
|
+
result = fit_linear(x, y)
|
|
462
|
+
slope, intercept = result["slope"], result["intercept"]
|
|
463
|
+
sign = "+" if intercept >= 0 else "-"
|
|
464
|
+
equation = f"y = {slope:.6g} x {sign} {abs(intercept):.6g}"
|
|
465
|
+
|
|
466
|
+
st.markdown("#### Results")
|
|
467
|
+
c1, c2 = st.columns(2)
|
|
468
|
+
c1.metric("Slope (m)", f"{slope:.6g}")
|
|
469
|
+
c2.metric("Intercept (b)", f"{intercept:.6g}")
|
|
470
|
+
c1, c2, c3 = st.columns(3)
|
|
471
|
+
c1.metric(
|
|
472
|
+
f"R² {_r2_color(result['r_squared'])}",
|
|
473
|
+
f"{result['r_squared']:.6f}",
|
|
474
|
+
)
|
|
475
|
+
c2.metric("p-value", f"{result['p_value']:.4g}")
|
|
476
|
+
c3.metric("Std Error", f"{result['std_err']:.6g}")
|
|
477
|
+
st.info(f"**Equation:** `{equation}`")
|
|
478
|
+
|
|
479
|
+
if show_fit_plot:
|
|
480
|
+
fig = _fit_plot(
|
|
481
|
+
x,
|
|
482
|
+
y,
|
|
483
|
+
lambda xn: result["slope"] * xn + result["intercept"],
|
|
484
|
+
x_label,
|
|
485
|
+
y_label,
|
|
486
|
+
f"Linear Fit: {equation}",
|
|
487
|
+
)
|
|
488
|
+
st.pyplot(fig)
|
|
489
|
+
plt.close(fig)
|
|
490
|
+
|
|
491
|
+
add_analysis_result(
|
|
492
|
+
{
|
|
493
|
+
"type": "fit_linear",
|
|
494
|
+
"dataset": dataset_name,
|
|
495
|
+
"x_col": str(x_col_ref),
|
|
496
|
+
"y_col": str(y_col_ref),
|
|
497
|
+
"equation": equation,
|
|
498
|
+
"results": {
|
|
499
|
+
k: float(v) for k, v in result.items() if k != "predict"
|
|
500
|
+
},
|
|
501
|
+
}
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
# ── Polynomial ───────────────────────────────────────────
|
|
505
|
+
elif fit_type == "Polynomial":
|
|
506
|
+
degree = int(poly_degree)
|
|
507
|
+
result = fit_polynomial(x, y, degree)
|
|
508
|
+
coeffs = result["coefficients"]
|
|
509
|
+
|
|
510
|
+
terms = []
|
|
511
|
+
for i, c in enumerate(coeffs):
|
|
512
|
+
power = degree - i
|
|
513
|
+
if abs(c) < 1e-15:
|
|
514
|
+
continue
|
|
515
|
+
if power == 0:
|
|
516
|
+
terms.append(f"{c:.4g}")
|
|
517
|
+
elif power == 1:
|
|
518
|
+
terms.append(f"{c:.4g}x")
|
|
519
|
+
else:
|
|
520
|
+
terms.append(f"{c:.4g}x^{power}")
|
|
521
|
+
equation = "y = " + (" + ".join(terms) if terms else "0")
|
|
522
|
+
equation = equation.replace("+ -", "- ")
|
|
523
|
+
|
|
524
|
+
st.markdown("#### Results")
|
|
525
|
+
st.metric(
|
|
526
|
+
f"R² {_r2_color(result['r_squared'])}",
|
|
527
|
+
f"{result['r_squared']:.6f}",
|
|
528
|
+
)
|
|
529
|
+
st.info(f"**Equation:** `{equation}`")
|
|
530
|
+
st.markdown("**Coefficients** (highest degree first):")
|
|
531
|
+
coeff_df = pd.DataFrame(
|
|
532
|
+
{
|
|
533
|
+
"Power": [degree - i for i in range(degree + 1)],
|
|
534
|
+
"Coefficient": list(coeffs),
|
|
535
|
+
}
|
|
536
|
+
)
|
|
537
|
+
st.dataframe(coeff_df, width="stretch")
|
|
538
|
+
|
|
539
|
+
if show_fit_plot:
|
|
540
|
+
fig = _fit_plot(
|
|
541
|
+
x,
|
|
542
|
+
y,
|
|
543
|
+
result["predict"],
|
|
544
|
+
x_label,
|
|
545
|
+
y_label,
|
|
546
|
+
f"Polynomial Fit (degree {degree})",
|
|
547
|
+
)
|
|
548
|
+
st.pyplot(fig)
|
|
549
|
+
plt.close(fig)
|
|
550
|
+
|
|
551
|
+
add_analysis_result(
|
|
552
|
+
{
|
|
553
|
+
"type": "fit_polynomial",
|
|
554
|
+
"dataset": dataset_name,
|
|
555
|
+
"x_col": str(x_col_ref),
|
|
556
|
+
"y_col": str(y_col_ref),
|
|
557
|
+
"degree": degree,
|
|
558
|
+
"equation": equation,
|
|
559
|
+
"results": {
|
|
560
|
+
"coefficients": [float(c) for c in coeffs],
|
|
561
|
+
"r_squared": result["r_squared"],
|
|
562
|
+
"residuals": result["residuals"],
|
|
563
|
+
},
|
|
564
|
+
}
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
# ── Exponential ──────────────────────────────────────────
|
|
568
|
+
elif fit_type == "Exponential":
|
|
569
|
+
result = fit_exponential(x, y)
|
|
570
|
+
a, b, c_off = result["a"], result["b"], result["c"]
|
|
571
|
+
equation = f"y = {a:.4g} * exp({b:.4g} * x)" + (
|
|
572
|
+
f" + {c_off:.4g}" if c_off >= 0 else f" - {abs(c_off):.4g}"
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
st.markdown("#### Results")
|
|
576
|
+
c1, c2, c3 = st.columns(3)
|
|
577
|
+
c1.metric("a (amplitude)", f"{a:.6g}")
|
|
578
|
+
c2.metric("b (rate)", f"{b:.6g}")
|
|
579
|
+
c3.metric("c (offset)", f"{c_off:.6g}")
|
|
580
|
+
st.metric(
|
|
581
|
+
f"R² {_r2_color(result['r_squared'])}",
|
|
582
|
+
f"{result['r_squared']:.6f}",
|
|
583
|
+
)
|
|
584
|
+
st.info(f"**Equation:** `{equation}`")
|
|
585
|
+
|
|
586
|
+
if show_fit_plot:
|
|
587
|
+
fig = _fit_plot(
|
|
588
|
+
x,
|
|
589
|
+
y,
|
|
590
|
+
result["predict"],
|
|
591
|
+
x_label,
|
|
592
|
+
y_label,
|
|
593
|
+
"Exponential Fit",
|
|
594
|
+
)
|
|
595
|
+
st.pyplot(fig)
|
|
596
|
+
plt.close(fig)
|
|
597
|
+
|
|
598
|
+
add_analysis_result(
|
|
599
|
+
{
|
|
600
|
+
"type": "fit_exponential",
|
|
601
|
+
"dataset": dataset_name,
|
|
602
|
+
"x_col": str(x_col_ref),
|
|
603
|
+
"y_col": str(y_col_ref),
|
|
604
|
+
"equation": equation,
|
|
605
|
+
"results": {
|
|
606
|
+
k: float(v) for k, v in result.items() if k != "predict"
|
|
607
|
+
},
|
|
608
|
+
}
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
# ── Custom ───────────────────────────────────────────────
|
|
612
|
+
elif fit_type == "Custom":
|
|
613
|
+
param_names = [p.strip() for p in custom_params_str.split(",")]
|
|
614
|
+
p0_values = [float(v.strip()) for v in custom_p0_str.split(",")]
|
|
615
|
+
func_str = f"lambda x, {', '.join(param_names)}: {custom_expr}"
|
|
616
|
+
_ns = {"np": np, "numpy": np}
|
|
617
|
+
custom_func = eval(func_str, _ns) # noqa: S307
|
|
618
|
+
result = fit_custom(x, y, custom_func, p0=p0_values)
|
|
619
|
+
|
|
620
|
+
params_fmt = ", ".join(
|
|
621
|
+
f"{n}={v:.4g}" for n, v in zip(param_names, result["parameters"])
|
|
622
|
+
)
|
|
623
|
+
equation = f"f(x) = {custom_expr} [{params_fmt}]"
|
|
624
|
+
|
|
625
|
+
st.markdown("#### Results")
|
|
626
|
+
st.metric(
|
|
627
|
+
f"R² {_r2_color(result['r_squared'])}",
|
|
628
|
+
f"{result['r_squared']:.6f}",
|
|
629
|
+
)
|
|
630
|
+
st.info(f"**Fitted parameters:** `{params_fmt}`")
|
|
631
|
+
|
|
632
|
+
param_df = pd.DataFrame(
|
|
633
|
+
{
|
|
634
|
+
"Parameter": param_names,
|
|
635
|
+
"Value": list(result["parameters"]),
|
|
636
|
+
"Std Error": list(result["std_errors"]),
|
|
637
|
+
}
|
|
638
|
+
)
|
|
639
|
+
st.dataframe(param_df, width="stretch")
|
|
640
|
+
|
|
641
|
+
if show_fit_plot:
|
|
642
|
+
fig = _fit_plot(
|
|
643
|
+
x,
|
|
644
|
+
y,
|
|
645
|
+
result["predict"],
|
|
646
|
+
x_label,
|
|
647
|
+
y_label,
|
|
648
|
+
"Custom Fit",
|
|
649
|
+
)
|
|
650
|
+
st.pyplot(fig)
|
|
651
|
+
plt.close(fig)
|
|
652
|
+
|
|
653
|
+
add_analysis_result(
|
|
654
|
+
{
|
|
655
|
+
"type": "fit_custom",
|
|
656
|
+
"dataset": dataset_name,
|
|
657
|
+
"x_col": str(x_col_ref),
|
|
658
|
+
"y_col": str(y_col_ref),
|
|
659
|
+
"expression": custom_expr,
|
|
660
|
+
"results": {
|
|
661
|
+
"parameters": [float(p) for p in result["parameters"]],
|
|
662
|
+
"std_errors": [float(e) for e in result["std_errors"]],
|
|
663
|
+
"r_squared": result["r_squared"],
|
|
664
|
+
},
|
|
665
|
+
}
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
if result is not None:
|
|
669
|
+
st.success("Results saved to analysis history.")
|
|
670
|
+
|
|
671
|
+
except Exception as exc:
|
|
672
|
+
st.error(f"Error: {exc}")
|
|
673
|
+
with st.expander("Details"):
|
|
674
|
+
st.code(traceback.format_exc())
|
|
675
|
+
|
|
676
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
677
|
+
# Tab 4 — Optimization
|
|
678
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
679
|
+
with tab_opt:
|
|
680
|
+
st.markdown("### Optimization")
|
|
681
|
+
st.info(
|
|
682
|
+
"Enter Python expressions. Use `x` (array for minimization, scalar for "
|
|
683
|
+
"root finding) and `np` (numpy). No imports needed."
|
|
684
|
+
)
|
|
685
|
+
|
|
686
|
+
# ── Function minimization ─────────────────────────────────────────────────
|
|
687
|
+
with st.expander("Function Minimization", expanded=True):
|
|
688
|
+
st.markdown("Find **x** that minimizes scalar `f(x)` where `x` is a NumPy array.")
|
|
689
|
+
min_expr = st.text_area(
|
|
690
|
+
"Objective function `f(x)`",
|
|
691
|
+
value="(x[0] - 2)**2 + (x[1] + 1)**2",
|
|
692
|
+
height=80,
|
|
693
|
+
key="min_expr",
|
|
694
|
+
)
|
|
695
|
+
min_x0 = st.text_input(
|
|
696
|
+
"Initial guess x₀ (comma-separated)",
|
|
697
|
+
value="0.0, 0.0",
|
|
698
|
+
key="min_x0",
|
|
699
|
+
)
|
|
700
|
+
min_method = st.selectbox(
|
|
701
|
+
"Method",
|
|
702
|
+
["Nelder-Mead", "BFGS", "L-BFGS-B", "Powell"],
|
|
703
|
+
key="min_method",
|
|
704
|
+
)
|
|
705
|
+
|
|
706
|
+
if st.button("Minimize", key="min_run"):
|
|
707
|
+
try:
|
|
708
|
+
_ns = {"np": np, "numpy": np}
|
|
709
|
+
obj_func = eval(f"lambda x: {min_expr}", _ns) # noqa: S307
|
|
710
|
+
x0 = np.array([float(v.strip()) for v in min_x0.split(",")])
|
|
711
|
+
result = minimize_function(obj_func, x0, method=min_method)
|
|
712
|
+
|
|
713
|
+
st.markdown("#### Results")
|
|
714
|
+
c1, c2, c3 = st.columns(3)
|
|
715
|
+
c1.metric("f(x*)", f"{result['fun']:.6g}")
|
|
716
|
+
c2.metric("Success", "✅" if result["success"] else "❌")
|
|
717
|
+
c3.metric("Evaluations", str(result["nfev"]))
|
|
718
|
+
st.write("**Optimal x:**", result["x"])
|
|
719
|
+
st.caption(f"Message: {result['message']}")
|
|
720
|
+
|
|
721
|
+
add_analysis_result(
|
|
722
|
+
{
|
|
723
|
+
"type": "minimize_function",
|
|
724
|
+
"dataset": dataset_name,
|
|
725
|
+
"expression": min_expr,
|
|
726
|
+
"method": min_method,
|
|
727
|
+
"results": {
|
|
728
|
+
"x": [float(v) for v in result["x"]],
|
|
729
|
+
"fun": result["fun"],
|
|
730
|
+
"success": result["success"],
|
|
731
|
+
"nfev": result["nfev"],
|
|
732
|
+
},
|
|
733
|
+
}
|
|
734
|
+
)
|
|
735
|
+
st.success("Results saved to analysis history.")
|
|
736
|
+
|
|
737
|
+
except Exception as exc:
|
|
738
|
+
st.error(f"Error: {exc}")
|
|
739
|
+
with st.expander("Details"):
|
|
740
|
+
st.code(traceback.format_exc())
|
|
741
|
+
|
|
742
|
+
# ── Root finding ──────────────────────────────────────────────────────────
|
|
743
|
+
with st.expander("Root Finding (Brent's Method)"):
|
|
744
|
+
st.markdown("Find scalar `x` such that `f(x) = 0`.")
|
|
745
|
+
root_expr = st.text_area(
|
|
746
|
+
"Function `f(x)`",
|
|
747
|
+
value="x**3 - x - 2",
|
|
748
|
+
height=80,
|
|
749
|
+
key="root_expr",
|
|
750
|
+
)
|
|
751
|
+
rc1, rc2 = st.columns(2)
|
|
752
|
+
with rc1:
|
|
753
|
+
root_a = st.number_input("Bracket lower bound (a)", value=-10.0, key="root_a")
|
|
754
|
+
with rc2:
|
|
755
|
+
root_b = st.number_input("Bracket upper bound (b)", value=10.0, key="root_b")
|
|
756
|
+
|
|
757
|
+
if st.button("Find Root", key="root_run"):
|
|
758
|
+
try:
|
|
759
|
+
_ns = {"np": np, "numpy": np}
|
|
760
|
+
root_func = eval(f"lambda x: {root_expr}", _ns) # noqa: S307
|
|
761
|
+
result = find_roots(root_func, bracket=(root_a, root_b))
|
|
762
|
+
|
|
763
|
+
st.markdown("#### Results")
|
|
764
|
+
c1, c2, c3 = st.columns(3)
|
|
765
|
+
c1.metric("Root", f"{result['root']:.8g}")
|
|
766
|
+
c2.metric("Converged", "✅" if result["converged"] else "❌")
|
|
767
|
+
c3.metric("Iterations", str(result["iterations"]))
|
|
768
|
+
st.caption(f"f(root) evaluated using {int(result['function_value'])} calls.")
|
|
769
|
+
|
|
770
|
+
add_analysis_result(
|
|
771
|
+
{
|
|
772
|
+
"type": "find_roots",
|
|
773
|
+
"dataset": dataset_name,
|
|
774
|
+
"expression": root_expr,
|
|
775
|
+
"results": {
|
|
776
|
+
"root": result["root"],
|
|
777
|
+
"converged": result["converged"],
|
|
778
|
+
"iterations": result["iterations"],
|
|
779
|
+
},
|
|
780
|
+
}
|
|
781
|
+
)
|
|
782
|
+
st.success("Results saved to analysis history.")
|
|
783
|
+
|
|
784
|
+
except Exception as exc:
|
|
785
|
+
st.error(f"Error: {exc}")
|
|
786
|
+
with st.expander("Details"):
|
|
787
|
+
st.code(traceback.format_exc())
|
|
788
|
+
|
|
789
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
790
|
+
# Tab 5 — Linear Algebra
|
|
791
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
792
|
+
with tab_linalg:
|
|
793
|
+
st.markdown("### Linear Algebra")
|
|
794
|
+
st.info(
|
|
795
|
+
"Operations use the **full dataset** as a 2-D matrix. "
|
|
796
|
+
"For a DataFrame, all numeric columns are used."
|
|
797
|
+
)
|
|
798
|
+
|
|
799
|
+
try:
|
|
800
|
+
_mat = _to_2d(data)
|
|
801
|
+
_nrows, _ncols_m = _mat.shape
|
|
802
|
+
st.caption(f"Matrix shape: {_nrows} × {_ncols_m}")
|
|
803
|
+
_mat_ok = True
|
|
804
|
+
except Exception as _mat_exc:
|
|
805
|
+
st.error(f"Cannot convert dataset to a 2-D numeric matrix: {_mat_exc}")
|
|
806
|
+
_mat_ok = False
|
|
807
|
+
|
|
808
|
+
if _mat_ok:
|
|
809
|
+
# ── Eigenvalues ───────────────────────────────────────────────────────
|
|
810
|
+
with st.expander("Eigenvalues & Eigenvectors", expanded=True):
|
|
811
|
+
show_evecs = st.checkbox("Include eigenvectors", value=False, key="eig_vecs")
|
|
812
|
+
|
|
813
|
+
if st.button("Compute Eigenvalues", key="eig_run"):
|
|
814
|
+
try:
|
|
815
|
+
if _nrows != _ncols_m:
|
|
816
|
+
st.error(f"Matrix must be square — got {_nrows} × {_ncols_m}.")
|
|
817
|
+
else:
|
|
818
|
+
result = compute_eigenvalues(_mat, eigenvectors=show_evecs)
|
|
819
|
+
evals = result["eigenvalues"]
|
|
820
|
+
|
|
821
|
+
st.markdown("#### Eigenvalues")
|
|
822
|
+
edf = pd.DataFrame(
|
|
823
|
+
{
|
|
824
|
+
"Index": list(range(len(evals))),
|
|
825
|
+
"Real Part": evals.real,
|
|
826
|
+
"Imaginary Part": evals.imag,
|
|
827
|
+
"Magnitude": np.abs(evals),
|
|
828
|
+
}
|
|
829
|
+
)
|
|
830
|
+
st.dataframe(edf, width="stretch")
|
|
831
|
+
|
|
832
|
+
if show_evecs and "eigenvectors" in result:
|
|
833
|
+
st.markdown("#### Eigenvectors (columns)")
|
|
834
|
+
st.dataframe(
|
|
835
|
+
pd.DataFrame(
|
|
836
|
+
result["eigenvectors"].real,
|
|
837
|
+
columns=[f"v{i}" for i in range(_ncols_m)],
|
|
838
|
+
),
|
|
839
|
+
width="stretch",
|
|
840
|
+
)
|
|
841
|
+
|
|
842
|
+
add_analysis_result(
|
|
843
|
+
{
|
|
844
|
+
"type": "eigenvalues",
|
|
845
|
+
"dataset": dataset_name,
|
|
846
|
+
"results": {
|
|
847
|
+
"eigenvalues_real": [float(v) for v in evals.real],
|
|
848
|
+
"eigenvalues_imag": [float(v) for v in evals.imag],
|
|
849
|
+
},
|
|
850
|
+
}
|
|
851
|
+
)
|
|
852
|
+
st.success("Results saved to analysis history.")
|
|
853
|
+
|
|
854
|
+
except Exception as exc:
|
|
855
|
+
st.error(f"Error: {exc}")
|
|
856
|
+
with st.expander("Details"):
|
|
857
|
+
st.code(traceback.format_exc())
|
|
858
|
+
|
|
859
|
+
# ── Solve Ax = b ──────────────────────────────────────────────────────
|
|
860
|
+
with st.expander("Solve Linear System Ax = b"):
|
|
861
|
+
st.markdown("The dataset matrix is **A**. Enter **b** as a comma-separated vector.")
|
|
862
|
+
b_str = st.text_input(
|
|
863
|
+
"Right-hand side vector b",
|
|
864
|
+
placeholder=f"{', '.join(['1.0'] * min(_nrows, 5))}{' ...' if _nrows > 5 else ''}",
|
|
865
|
+
key="linalg_b",
|
|
866
|
+
)
|
|
867
|
+
|
|
868
|
+
if st.button("Solve", key="linalg_solve"):
|
|
869
|
+
try:
|
|
870
|
+
if _nrows != _ncols_m:
|
|
871
|
+
st.error(f"A must be square — got {_nrows} × {_ncols_m}.")
|
|
872
|
+
elif not b_str.strip():
|
|
873
|
+
st.warning("Enter the b vector first.")
|
|
874
|
+
else:
|
|
875
|
+
b_vec = np.array([float(v.strip()) for v in b_str.split(",")])
|
|
876
|
+
if len(b_vec) != _nrows:
|
|
877
|
+
st.error(f"b must have {_nrows} element(s), got {len(b_vec)}.")
|
|
878
|
+
else:
|
|
879
|
+
result = solve_linear_system(_mat, b_vec)
|
|
880
|
+
|
|
881
|
+
st.markdown("#### Solution vector x")
|
|
882
|
+
sol_df = pd.DataFrame(
|
|
883
|
+
{"Component": range(len(result["x"])), "x": result["x"]}
|
|
884
|
+
)
|
|
885
|
+
st.dataframe(sol_df, width="stretch")
|
|
886
|
+
|
|
887
|
+
c1, c2 = st.columns(2)
|
|
888
|
+
c1.metric("Residual ‖Ax−b‖", f"{result['residual']:.4e}")
|
|
889
|
+
c2.metric("Condition number", f"{result['condition_number']:.4g}")
|
|
890
|
+
|
|
891
|
+
if result["condition_number"] > 1e10:
|
|
892
|
+
st.warning(
|
|
893
|
+
"High condition number — matrix is nearly singular; "
|
|
894
|
+
"solution may be inaccurate."
|
|
895
|
+
)
|
|
896
|
+
|
|
897
|
+
add_analysis_result(
|
|
898
|
+
{
|
|
899
|
+
"type": "solve_linear_system",
|
|
900
|
+
"dataset": dataset_name,
|
|
901
|
+
"results": {
|
|
902
|
+
"x": [float(v) for v in result["x"]],
|
|
903
|
+
"residual": result["residual"],
|
|
904
|
+
"condition_number": result["condition_number"],
|
|
905
|
+
},
|
|
906
|
+
}
|
|
907
|
+
)
|
|
908
|
+
st.success("Results saved to analysis history.")
|
|
909
|
+
|
|
910
|
+
except Exception as exc:
|
|
911
|
+
st.error(f"Error: {exc}")
|
|
912
|
+
with st.expander("Details"):
|
|
913
|
+
st.code(traceback.format_exc())
|
|
914
|
+
|
|
915
|
+
# ── Matrix decomposition ──────────────────────────────────────────────
|
|
916
|
+
with st.expander("Matrix Decomposition"):
|
|
917
|
+
_DECOMP_LABELS = {
|
|
918
|
+
"svd": "SVD — Singular Value Decomposition",
|
|
919
|
+
"qr": "QR Decomposition",
|
|
920
|
+
"lu": "LU Decomposition",
|
|
921
|
+
"cholesky": "Cholesky (positive-definite matrices only)",
|
|
922
|
+
}
|
|
923
|
+
decomp_method = st.selectbox(
|
|
924
|
+
"Method",
|
|
925
|
+
list(_DECOMP_LABELS.keys()),
|
|
926
|
+
format_func=lambda m: _DECOMP_LABELS[m],
|
|
927
|
+
key="decomp_method",
|
|
928
|
+
)
|
|
929
|
+
|
|
930
|
+
if st.button("Decompose", key="decomp_run"):
|
|
931
|
+
try:
|
|
932
|
+
result = matrix_decomposition(_mat, method=decomp_method)
|
|
933
|
+
|
|
934
|
+
st.markdown(f"#### {decomp_method.upper()} Result")
|
|
935
|
+
for key_name, arr_val in result.items():
|
|
936
|
+
if isinstance(arr_val, np.ndarray) and arr_val.ndim == 2:
|
|
937
|
+
st.markdown(
|
|
938
|
+
f"**{key_name}** — shape {arr_val.shape[0]} × {arr_val.shape[1]}:"
|
|
939
|
+
)
|
|
940
|
+
st.dataframe(
|
|
941
|
+
pd.DataFrame(arr_val.real),
|
|
942
|
+
width="stretch",
|
|
943
|
+
)
|
|
944
|
+
elif isinstance(arr_val, np.ndarray) and arr_val.ndim == 1:
|
|
945
|
+
st.markdown(f"**{key_name}** (singular values / diagonal):")
|
|
946
|
+
st.write(arr_val.real)
|
|
947
|
+
|
|
948
|
+
add_analysis_result(
|
|
949
|
+
{
|
|
950
|
+
"type": "matrix_decomposition",
|
|
951
|
+
"dataset": dataset_name,
|
|
952
|
+
"method": decomp_method,
|
|
953
|
+
"results": {
|
|
954
|
+
k: [float(v) for v in vv.real.flatten()]
|
|
955
|
+
for k, vv in result.items()
|
|
956
|
+
if isinstance(vv, np.ndarray)
|
|
957
|
+
},
|
|
958
|
+
}
|
|
959
|
+
)
|
|
960
|
+
st.success("Results saved to analysis history.")
|
|
961
|
+
|
|
962
|
+
except Exception as exc:
|
|
963
|
+
st.error(f"Error: {exc}")
|
|
964
|
+
with st.expander("Details"):
|
|
965
|
+
st.code(traceback.format_exc())
|
|
966
|
+
|
|
967
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
968
|
+
# Tab 6 — Signal Processing
|
|
969
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
970
|
+
with tab_smooth:
|
|
971
|
+
st.markdown("### Signal Processing")
|
|
972
|
+
st.markdown(
|
|
973
|
+
"Apply smoothing, filtering, FFT, derivatives, baseline correction, "
|
|
974
|
+
"and interpolation to a 1-D signal column."
|
|
975
|
+
)
|
|
976
|
+
|
|
977
|
+
col_ref_sp, valid_sp = _pick_col(data, "Signal column (Y)", key="sp_ycol")
|
|
978
|
+
|
|
979
|
+
# Optional X column (needed by Baseline — Polynomial and Interpolate)
|
|
980
|
+
x_col_sp = None
|
|
981
|
+
if isinstance(data, pd.DataFrame) and len(_numeric_cols(data)) >= 2:
|
|
982
|
+
with st.expander("X-axis column (optional — defaults to index)", expanded=False):
|
|
983
|
+
x_col_sp, _ = _pick_col(data, "X column", key="sp_xcol")
|
|
984
|
+
|
|
985
|
+
if valid_sp:
|
|
986
|
+
(
|
|
987
|
+
sub_smooth,
|
|
988
|
+
sub_filter,
|
|
989
|
+
sub_fft,
|
|
990
|
+
sub_deriv,
|
|
991
|
+
sub_baseline,
|
|
992
|
+
sub_interp,
|
|
993
|
+
) = st.tabs(["Smooth", "Filter", "FFT", "Derivative", "Baseline", "Interpolate"])
|
|
994
|
+
|
|
995
|
+
# ── Smooth ───────────────────────────────────────────────────────────
|
|
996
|
+
with sub_smooth:
|
|
997
|
+
st.markdown("#### Smoothing")
|
|
998
|
+
sp_smooth_method = st.radio(
|
|
999
|
+
"Method",
|
|
1000
|
+
["Moving Average", "Savitzky-Golay", "Gaussian"],
|
|
1001
|
+
horizontal=True,
|
|
1002
|
+
key="sp_smooth_method",
|
|
1003
|
+
)
|
|
1004
|
+
|
|
1005
|
+
sp_ma_window = 11
|
|
1006
|
+
sp_sg_window = 11
|
|
1007
|
+
sp_sg_poly = 3
|
|
1008
|
+
sp_gauss_sigma = 2.0
|
|
1009
|
+
|
|
1010
|
+
if sp_smooth_method == "Moving Average":
|
|
1011
|
+
sp_ma_window = st.slider("Window size (points)", 3, 101, 11, 2, key="sp_ma_window")
|
|
1012
|
+
elif sp_smooth_method == "Savitzky-Golay":
|
|
1013
|
+
c1, c2 = st.columns(2)
|
|
1014
|
+
with c1:
|
|
1015
|
+
sp_sg_window = st.slider(
|
|
1016
|
+
"Window length (odd)", 5, 101, 11, 2, key="sp_sg_window"
|
|
1017
|
+
)
|
|
1018
|
+
if sp_sg_window % 2 == 0:
|
|
1019
|
+
sp_sg_window += 1
|
|
1020
|
+
with c2:
|
|
1021
|
+
sp_sg_poly = int(
|
|
1022
|
+
st.number_input(
|
|
1023
|
+
"Polynomial order",
|
|
1024
|
+
min_value=1,
|
|
1025
|
+
max_value=min(sp_sg_window - 1, 6),
|
|
1026
|
+
value=min(3, sp_sg_window - 1),
|
|
1027
|
+
key="sp_sg_poly",
|
|
1028
|
+
)
|
|
1029
|
+
)
|
|
1030
|
+
else:
|
|
1031
|
+
sp_gauss_sigma = st.number_input(
|
|
1032
|
+
"Sigma (std deviation, index units)",
|
|
1033
|
+
min_value=0.1,
|
|
1034
|
+
value=2.0,
|
|
1035
|
+
step=0.5,
|
|
1036
|
+
key="sp_gauss_sigma",
|
|
1037
|
+
)
|
|
1038
|
+
|
|
1039
|
+
show_raw_sp = st.checkbox("Show original signal", value=True, key="sp_smooth_show_raw")
|
|
1040
|
+
|
|
1041
|
+
if st.button("Apply Smoothing", type="primary", key="sp_smooth_run"):
|
|
1042
|
+
try:
|
|
1043
|
+
y_sp = _to_1d(data, col_ref_sp)
|
|
1044
|
+
x_sp = _to_1d(data, x_col_sp) if x_col_sp is not None else np.arange(len(y_sp))
|
|
1045
|
+
|
|
1046
|
+
if sp_smooth_method == "Moving Average":
|
|
1047
|
+
y_out = smooth_moving_average(y_sp, int(sp_ma_window))
|
|
1048
|
+
label = f"Moving Average (window={sp_ma_window})"
|
|
1049
|
+
elif sp_smooth_method == "Savitzky-Golay":
|
|
1050
|
+
y_out = smooth_savitzky_golay(y_sp, int(sp_sg_window), int(sp_sg_poly))
|
|
1051
|
+
label = f"Savitzky-Golay (window={sp_sg_window}, order={sp_sg_poly})"
|
|
1052
|
+
else:
|
|
1053
|
+
y_out = smooth_gaussian(y_sp, float(sp_gauss_sigma))
|
|
1054
|
+
label = f"Gaussian (σ={sp_gauss_sigma})"
|
|
1055
|
+
|
|
1056
|
+
fig, ax = plt.subplots(figsize=(8, 4))
|
|
1057
|
+
if show_raw_sp:
|
|
1058
|
+
ax.plot(x_sp, y_sp, alpha=0.4, lw=1, color="#888888", label="Original")
|
|
1059
|
+
ax.plot(x_sp, y_out, lw=2, color="#d62728", label=label)
|
|
1060
|
+
col_name = str(col_ref_sp) if col_ref_sp is not None else "Value"
|
|
1061
|
+
ax.set_xlabel(str(x_col_sp) if x_col_sp is not None else "Index")
|
|
1062
|
+
ax.set_ylabel(col_name)
|
|
1063
|
+
ax.set_title("Smoothed Signal")
|
|
1064
|
+
ax.legend()
|
|
1065
|
+
ax.grid(True, alpha=0.3)
|
|
1066
|
+
fig.tight_layout()
|
|
1067
|
+
st.pyplot(fig)
|
|
1068
|
+
plt.close(fig)
|
|
1069
|
+
|
|
1070
|
+
with st.expander("Smoothed data table"):
|
|
1071
|
+
st.dataframe(
|
|
1072
|
+
pd.DataFrame({"X": x_sp, "Original": y_sp, "Smoothed": y_out}),
|
|
1073
|
+
width="stretch",
|
|
1074
|
+
)
|
|
1075
|
+
|
|
1076
|
+
add_analysis_result(
|
|
1077
|
+
{
|
|
1078
|
+
"type": "smoothing",
|
|
1079
|
+
"dataset": dataset_name,
|
|
1080
|
+
"column": str(col_ref_sp),
|
|
1081
|
+
"method": label,
|
|
1082
|
+
"results": {"smoothed": [float(v) for v in y_out]},
|
|
1083
|
+
}
|
|
1084
|
+
)
|
|
1085
|
+
st.success("Results saved to analysis history.")
|
|
1086
|
+
|
|
1087
|
+
except Exception as exc:
|
|
1088
|
+
st.error(f"Error: {exc}")
|
|
1089
|
+
with st.expander("Details"):
|
|
1090
|
+
st.code(traceback.format_exc())
|
|
1091
|
+
|
|
1092
|
+
# ── Filter ───────────────────────────────────────────────────────────
|
|
1093
|
+
with sub_filter:
|
|
1094
|
+
st.markdown("#### Butterworth Filter")
|
|
1095
|
+
st.caption("Zero-phase IIR filter (sosfiltfilt) — no phase distortion.")
|
|
1096
|
+
|
|
1097
|
+
sp_flt_type = st.selectbox(
|
|
1098
|
+
"Filter type",
|
|
1099
|
+
["Low-pass", "High-pass", "Band-pass", "Band-stop"],
|
|
1100
|
+
key="sp_flt_type",
|
|
1101
|
+
)
|
|
1102
|
+
sp_flt_order = st.slider("Filter order", 1, 10, 4, key="sp_flt_order")
|
|
1103
|
+
|
|
1104
|
+
c1, c2 = st.columns(2)
|
|
1105
|
+
with c1:
|
|
1106
|
+
sp_flt_fs = st.number_input(
|
|
1107
|
+
"Sampling frequency (fs)",
|
|
1108
|
+
min_value=0.001,
|
|
1109
|
+
value=1.0,
|
|
1110
|
+
step=0.1,
|
|
1111
|
+
format="%.4g",
|
|
1112
|
+
key="sp_flt_fs",
|
|
1113
|
+
)
|
|
1114
|
+
|
|
1115
|
+
# Defaults (overwritten by widgets below)
|
|
1116
|
+
sp_flt_cutoff = 0.3
|
|
1117
|
+
sp_flt_low = 0.1
|
|
1118
|
+
sp_flt_high = 0.4
|
|
1119
|
+
|
|
1120
|
+
if sp_flt_type in ("Band-pass", "Band-stop"):
|
|
1121
|
+
col_a, col_b = st.columns(2)
|
|
1122
|
+
with col_a:
|
|
1123
|
+
sp_flt_low = st.number_input(
|
|
1124
|
+
"Low cut-off (Hz)",
|
|
1125
|
+
min_value=1e-6,
|
|
1126
|
+
value=0.1,
|
|
1127
|
+
step=0.01,
|
|
1128
|
+
format="%.4g",
|
|
1129
|
+
key="sp_flt_low",
|
|
1130
|
+
)
|
|
1131
|
+
with col_b:
|
|
1132
|
+
sp_flt_high = st.number_input(
|
|
1133
|
+
"High cut-off (Hz)",
|
|
1134
|
+
min_value=1e-6,
|
|
1135
|
+
value=0.4,
|
|
1136
|
+
step=0.01,
|
|
1137
|
+
format="%.4g",
|
|
1138
|
+
key="sp_flt_high",
|
|
1139
|
+
)
|
|
1140
|
+
else:
|
|
1141
|
+
with c2:
|
|
1142
|
+
sp_flt_cutoff = st.number_input(
|
|
1143
|
+
"Cut-off frequency (Hz)",
|
|
1144
|
+
min_value=1e-6,
|
|
1145
|
+
value=0.3,
|
|
1146
|
+
step=0.01,
|
|
1147
|
+
format="%.4g",
|
|
1148
|
+
key="sp_flt_cutoff",
|
|
1149
|
+
)
|
|
1150
|
+
|
|
1151
|
+
show_raw_flt = st.checkbox("Show original signal", value=True, key="sp_flt_show_raw")
|
|
1152
|
+
|
|
1153
|
+
if st.button("Apply Filter", type="primary", key="sp_flt_run"):
|
|
1154
|
+
try:
|
|
1155
|
+
y_sp = _to_1d(data, col_ref_sp)
|
|
1156
|
+
x_sp = _to_1d(data, x_col_sp) if x_col_sp is not None else np.arange(len(y_sp))
|
|
1157
|
+
|
|
1158
|
+
if sp_flt_type == "Low-pass":
|
|
1159
|
+
y_out = filter_lowpass(
|
|
1160
|
+
y_sp, float(sp_flt_cutoff), float(sp_flt_fs), int(sp_flt_order)
|
|
1161
|
+
)
|
|
1162
|
+
label = f"Low-pass (fc={sp_flt_cutoff} Hz, order={sp_flt_order})"
|
|
1163
|
+
elif sp_flt_type == "High-pass":
|
|
1164
|
+
y_out = filter_highpass(
|
|
1165
|
+
y_sp, float(sp_flt_cutoff), float(sp_flt_fs), int(sp_flt_order)
|
|
1166
|
+
)
|
|
1167
|
+
label = f"High-pass (fc={sp_flt_cutoff} Hz, order={sp_flt_order})"
|
|
1168
|
+
elif sp_flt_type == "Band-pass":
|
|
1169
|
+
y_out = filter_bandpass(
|
|
1170
|
+
y_sp,
|
|
1171
|
+
float(sp_flt_low),
|
|
1172
|
+
float(sp_flt_high),
|
|
1173
|
+
float(sp_flt_fs),
|
|
1174
|
+
int(sp_flt_order),
|
|
1175
|
+
)
|
|
1176
|
+
label = f"Band-pass ({sp_flt_low}–{sp_flt_high} Hz, order={sp_flt_order})"
|
|
1177
|
+
else:
|
|
1178
|
+
y_out = filter_bandstop(
|
|
1179
|
+
y_sp,
|
|
1180
|
+
float(sp_flt_low),
|
|
1181
|
+
float(sp_flt_high),
|
|
1182
|
+
float(sp_flt_fs),
|
|
1183
|
+
int(sp_flt_order),
|
|
1184
|
+
)
|
|
1185
|
+
label = f"Band-stop ({sp_flt_low}–{sp_flt_high} Hz, order={sp_flt_order})"
|
|
1186
|
+
|
|
1187
|
+
fig, ax = plt.subplots(figsize=(8, 4))
|
|
1188
|
+
if show_raw_flt:
|
|
1189
|
+
ax.plot(x_sp, y_sp, alpha=0.4, lw=1, color="#888888", label="Original")
|
|
1190
|
+
ax.plot(x_sp, y_out, lw=2, color="#1f77b4", label=label)
|
|
1191
|
+
col_name = str(col_ref_sp) if col_ref_sp is not None else "Value"
|
|
1192
|
+
ax.set_xlabel(str(x_col_sp) if x_col_sp is not None else "Index")
|
|
1193
|
+
ax.set_ylabel(col_name)
|
|
1194
|
+
ax.set_title("Filtered Signal")
|
|
1195
|
+
ax.legend()
|
|
1196
|
+
ax.grid(True, alpha=0.3)
|
|
1197
|
+
fig.tight_layout()
|
|
1198
|
+
st.pyplot(fig)
|
|
1199
|
+
plt.close(fig)
|
|
1200
|
+
|
|
1201
|
+
add_analysis_result(
|
|
1202
|
+
{
|
|
1203
|
+
"type": "filter",
|
|
1204
|
+
"dataset": dataset_name,
|
|
1205
|
+
"column": str(col_ref_sp),
|
|
1206
|
+
"method": label,
|
|
1207
|
+
"results": {"filtered": [float(v) for v in y_out]},
|
|
1208
|
+
}
|
|
1209
|
+
)
|
|
1210
|
+
st.success("Results saved to analysis history.")
|
|
1211
|
+
|
|
1212
|
+
except Exception as exc:
|
|
1213
|
+
st.error(f"Error: {exc}")
|
|
1214
|
+
with st.expander("Details"):
|
|
1215
|
+
st.code(traceback.format_exc())
|
|
1216
|
+
|
|
1217
|
+
# ── FFT ──────────────────────────────────────────────────────────────
|
|
1218
|
+
with sub_fft:
|
|
1219
|
+
st.markdown("#### FFT / Power Spectrum")
|
|
1220
|
+
|
|
1221
|
+
c1, c2 = st.columns(2)
|
|
1222
|
+
with c1:
|
|
1223
|
+
sp_fft_dt = st.number_input(
|
|
1224
|
+
"Sample spacing (dt = 1/fs)",
|
|
1225
|
+
min_value=1e-12,
|
|
1226
|
+
value=1.0,
|
|
1227
|
+
step=0.1,
|
|
1228
|
+
format="%.6g",
|
|
1229
|
+
key="sp_fft_dt",
|
|
1230
|
+
help="Set to 1.0 for index-domain data.",
|
|
1231
|
+
)
|
|
1232
|
+
with c2:
|
|
1233
|
+
sp_fft_plot = st.selectbox(
|
|
1234
|
+
"Display",
|
|
1235
|
+
["Amplitude spectrum", "Power spectrum"],
|
|
1236
|
+
key="sp_fft_plot",
|
|
1237
|
+
)
|
|
1238
|
+
|
|
1239
|
+
if st.button("Compute FFT", type="primary", key="sp_fft_run"):
|
|
1240
|
+
try:
|
|
1241
|
+
y_sp = _to_1d(data, col_ref_sp)
|
|
1242
|
+
result_fft = sig_fft(y_sp, dt=float(sp_fft_dt))
|
|
1243
|
+
|
|
1244
|
+
freqs_fft = result_fft["frequencies"]
|
|
1245
|
+
if "Power" in sp_fft_plot:
|
|
1246
|
+
yplot = result_fft["power"]
|
|
1247
|
+
ylabel_fft = "Power"
|
|
1248
|
+
else:
|
|
1249
|
+
yplot = result_fft["amplitudes"]
|
|
1250
|
+
ylabel_fft = "Amplitude"
|
|
1251
|
+
|
|
1252
|
+
fig, ax = plt.subplots(figsize=(8, 4))
|
|
1253
|
+
ax.plot(freqs_fft, yplot, lw=1.5, color="#2ca02c")
|
|
1254
|
+
ax.set_xlabel("Frequency")
|
|
1255
|
+
ax.set_ylabel(ylabel_fft)
|
|
1256
|
+
ax.set_title(f"{ylabel_fft} Spectrum")
|
|
1257
|
+
ax.grid(True, alpha=0.3)
|
|
1258
|
+
fig.tight_layout()
|
|
1259
|
+
st.pyplot(fig)
|
|
1260
|
+
plt.close(fig)
|
|
1261
|
+
|
|
1262
|
+
# Dominant frequency (skip DC at index 0)
|
|
1263
|
+
dom_idx = int(np.argmax(result_fft["amplitudes"][1:]) + 1)
|
|
1264
|
+
dom_freq = float(freqs_fft[dom_idx])
|
|
1265
|
+
c1, c2, c3 = st.columns(3)
|
|
1266
|
+
c1.metric("N points", result_fft["n"])
|
|
1267
|
+
c2.metric("Dominant frequency", f"{dom_freq:.4g}")
|
|
1268
|
+
c3.metric(
|
|
1269
|
+
"Period",
|
|
1270
|
+
f"{1.0 / dom_freq:.4g}" if dom_freq != 0 else "—",
|
|
1271
|
+
)
|
|
1272
|
+
|
|
1273
|
+
with st.expander("Top-10 frequency components"):
|
|
1274
|
+
top_idx = np.argsort(result_fft["amplitudes"])[::-1][:10]
|
|
1275
|
+
st.dataframe(
|
|
1276
|
+
pd.DataFrame(
|
|
1277
|
+
{
|
|
1278
|
+
"Frequency": freqs_fft[top_idx],
|
|
1279
|
+
"Amplitude": result_fft["amplitudes"][top_idx],
|
|
1280
|
+
"Power": result_fft["power"][top_idx],
|
|
1281
|
+
"Phase (rad)": result_fft["phases"][top_idx],
|
|
1282
|
+
}
|
|
1283
|
+
),
|
|
1284
|
+
width="stretch",
|
|
1285
|
+
)
|
|
1286
|
+
|
|
1287
|
+
add_analysis_result(
|
|
1288
|
+
{
|
|
1289
|
+
"type": "fft",
|
|
1290
|
+
"dataset": dataset_name,
|
|
1291
|
+
"column": str(col_ref_sp),
|
|
1292
|
+
"dt": float(sp_fft_dt),
|
|
1293
|
+
"results": {
|
|
1294
|
+
"dominant_frequency": dom_freq,
|
|
1295
|
+
"n": result_fft["n"],
|
|
1296
|
+
},
|
|
1297
|
+
}
|
|
1298
|
+
)
|
|
1299
|
+
st.success("Results saved to analysis history.")
|
|
1300
|
+
|
|
1301
|
+
except Exception as exc:
|
|
1302
|
+
st.error(f"Error: {exc}")
|
|
1303
|
+
with st.expander("Details"):
|
|
1304
|
+
st.code(traceback.format_exc())
|
|
1305
|
+
|
|
1306
|
+
# ── Derivative ───────────────────────────────────────────────────────
|
|
1307
|
+
with sub_deriv:
|
|
1308
|
+
st.markdown("#### Numerical Derivative")
|
|
1309
|
+
st.caption("Central-difference approximation via numpy.gradient.")
|
|
1310
|
+
|
|
1311
|
+
sp_deriv_order = st.radio(
|
|
1312
|
+
"Derivative order", [1, 2], horizontal=True, key="sp_deriv_order"
|
|
1313
|
+
)
|
|
1314
|
+
show_raw_drv = st.checkbox("Show original signal", value=True, key="sp_deriv_show_raw")
|
|
1315
|
+
|
|
1316
|
+
if st.button("Compute Derivative", type="primary", key="sp_deriv_run"):
|
|
1317
|
+
try:
|
|
1318
|
+
y_sp = _to_1d(data, col_ref_sp)
|
|
1319
|
+
x_arr = _to_1d(data, x_col_sp) if x_col_sp is not None else None
|
|
1320
|
+
x_axis = x_arr if x_arr is not None else np.arange(len(y_sp))
|
|
1321
|
+
|
|
1322
|
+
dy = sig_derivative(y_sp, x=x_arr, order=int(sp_deriv_order))
|
|
1323
|
+
ord_label = "1st" if sp_deriv_order == 1 else "2nd"
|
|
1324
|
+
label = f"{ord_label} derivative"
|
|
1325
|
+
|
|
1326
|
+
if show_raw_drv:
|
|
1327
|
+
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(8, 5), sharex=True)
|
|
1328
|
+
col_name = str(col_ref_sp) if col_ref_sp is not None else "Signal"
|
|
1329
|
+
ax1.plot(x_axis, y_sp, lw=1.5, color="#888888")
|
|
1330
|
+
ax1.set_ylabel(col_name)
|
|
1331
|
+
ax1.set_title("Original")
|
|
1332
|
+
ax1.grid(True, alpha=0.3)
|
|
1333
|
+
ax2.plot(x_axis, dy, lw=1.5, color="#9467bd")
|
|
1334
|
+
ax2.set_xlabel(str(x_col_sp) if x_col_sp is not None else "Index")
|
|
1335
|
+
drv_sym = "dy/dx" if sp_deriv_order == 1 else "d²y/dx²"
|
|
1336
|
+
ax2.set_ylabel(drv_sym)
|
|
1337
|
+
ax2.set_title(label.capitalize())
|
|
1338
|
+
ax2.grid(True, alpha=0.3)
|
|
1339
|
+
else:
|
|
1340
|
+
fig, ax2 = plt.subplots(figsize=(8, 4))
|
|
1341
|
+
ax2.plot(x_axis, dy, lw=1.5, color="#9467bd", label=label)
|
|
1342
|
+
ax2.set_xlabel(str(x_col_sp) if x_col_sp is not None else "Index")
|
|
1343
|
+
drv_sym = "dy/dx" if sp_deriv_order == 1 else "d²y/dx²"
|
|
1344
|
+
ax2.set_ylabel(drv_sym)
|
|
1345
|
+
ax2.set_title(label.capitalize())
|
|
1346
|
+
ax2.legend()
|
|
1347
|
+
ax2.grid(True, alpha=0.3)
|
|
1348
|
+
|
|
1349
|
+
fig.tight_layout()
|
|
1350
|
+
st.pyplot(fig)
|
|
1351
|
+
plt.close(fig)
|
|
1352
|
+
|
|
1353
|
+
add_analysis_result(
|
|
1354
|
+
{
|
|
1355
|
+
"type": "derivative",
|
|
1356
|
+
"dataset": dataset_name,
|
|
1357
|
+
"column": str(col_ref_sp),
|
|
1358
|
+
"order": int(sp_deriv_order),
|
|
1359
|
+
"results": {"derivative": [float(v) for v in dy]},
|
|
1360
|
+
}
|
|
1361
|
+
)
|
|
1362
|
+
st.success("Results saved to analysis history.")
|
|
1363
|
+
|
|
1364
|
+
except Exception as exc:
|
|
1365
|
+
st.error(f"Error: {exc}")
|
|
1366
|
+
with st.expander("Details"):
|
|
1367
|
+
st.code(traceback.format_exc())
|
|
1368
|
+
|
|
1369
|
+
# ── Baseline ─────────────────────────────────────────────────────────
|
|
1370
|
+
with sub_baseline:
|
|
1371
|
+
st.markdown("#### Baseline Correction")
|
|
1372
|
+
st.markdown("Estimate and subtract a background baseline from the signal.")
|
|
1373
|
+
|
|
1374
|
+
sp_bl_method = st.radio(
|
|
1375
|
+
"Method",
|
|
1376
|
+
["Polynomial", "Rolling Ball", "ALS (Asymmetric Least Squares)"],
|
|
1377
|
+
key="sp_bl_method",
|
|
1378
|
+
)
|
|
1379
|
+
|
|
1380
|
+
sp_bl_degree = 3
|
|
1381
|
+
sp_bl_radius = 50
|
|
1382
|
+
sp_bl_lam = 1e5
|
|
1383
|
+
sp_bl_p = 0.01
|
|
1384
|
+
|
|
1385
|
+
if sp_bl_method == "Polynomial":
|
|
1386
|
+
sp_bl_degree = st.slider("Polynomial degree", 1, 10, 3, key="sp_bl_poly_deg")
|
|
1387
|
+
if x_col_sp is None:
|
|
1388
|
+
st.info("Tip: select an X column above for physical x-axis fitting.")
|
|
1389
|
+
elif sp_bl_method == "Rolling Ball":
|
|
1390
|
+
sp_bl_radius = st.slider("Ball radius (samples)", 5, 500, 50, key="sp_bl_rb_radius")
|
|
1391
|
+
else:
|
|
1392
|
+
c1, c2 = st.columns(2)
|
|
1393
|
+
with c1:
|
|
1394
|
+
sp_bl_lam = st.number_input(
|
|
1395
|
+
"Smoothness λ",
|
|
1396
|
+
min_value=1.0,
|
|
1397
|
+
value=1e5,
|
|
1398
|
+
step=1e4,
|
|
1399
|
+
format="%.2e",
|
|
1400
|
+
key="sp_bl_lam",
|
|
1401
|
+
)
|
|
1402
|
+
with c2:
|
|
1403
|
+
sp_bl_p = st.number_input(
|
|
1404
|
+
"Asymmetry p",
|
|
1405
|
+
min_value=0.001,
|
|
1406
|
+
max_value=0.5,
|
|
1407
|
+
value=0.01,
|
|
1408
|
+
step=0.005,
|
|
1409
|
+
format="%.4f",
|
|
1410
|
+
key="sp_bl_p",
|
|
1411
|
+
)
|
|
1412
|
+
|
|
1413
|
+
show_raw_bl = st.checkbox("Show original signal", value=True, key="sp_bl_show_raw")
|
|
1414
|
+
|
|
1415
|
+
if st.button("Correct Baseline", type="primary", key="sp_bl_run"):
|
|
1416
|
+
try:
|
|
1417
|
+
y_sp = _to_1d(data, col_ref_sp)
|
|
1418
|
+
x_sp = _to_1d(data, x_col_sp) if x_col_sp is not None else np.arange(len(y_sp))
|
|
1419
|
+
|
|
1420
|
+
if sp_bl_method == "Polynomial":
|
|
1421
|
+
bl_vals, y_corr = baseline_polynomial(y_sp, x_sp, int(sp_bl_degree))
|
|
1422
|
+
label = f"Polynomial baseline (degree={sp_bl_degree})"
|
|
1423
|
+
elif sp_bl_method == "Rolling Ball":
|
|
1424
|
+
bl_vals, y_corr = baseline_rolling_ball(y_sp, int(sp_bl_radius))
|
|
1425
|
+
label = f"Rolling ball (radius={sp_bl_radius})"
|
|
1426
|
+
else:
|
|
1427
|
+
bl_vals, y_corr = baseline_als(y_sp, lam=float(sp_bl_lam), p=float(sp_bl_p))
|
|
1428
|
+
label = f"ALS (λ={sp_bl_lam:.0e}, p={sp_bl_p})"
|
|
1429
|
+
|
|
1430
|
+
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(8, 6), sharex=True)
|
|
1431
|
+
if show_raw_bl:
|
|
1432
|
+
ax1.plot(x_sp, y_sp, lw=1, color="#888888", alpha=0.7, label="Original")
|
|
1433
|
+
ax1.plot(
|
|
1434
|
+
x_sp,
|
|
1435
|
+
bl_vals,
|
|
1436
|
+
lw=2,
|
|
1437
|
+
color="#ff7f0e",
|
|
1438
|
+
ls="--",
|
|
1439
|
+
label="Baseline",
|
|
1440
|
+
)
|
|
1441
|
+
ax1.plot(x_sp, y_corr, lw=1.5, color="#1f77b4", label="Corrected")
|
|
1442
|
+
col_name = str(col_ref_sp) if col_ref_sp is not None else "Signal"
|
|
1443
|
+
ax1.set_ylabel(col_name)
|
|
1444
|
+
ax1.set_title(label)
|
|
1445
|
+
ax1.legend()
|
|
1446
|
+
ax1.grid(True, alpha=0.3)
|
|
1447
|
+
|
|
1448
|
+
ax2.plot(x_sp, y_corr, lw=1.5, color="#1f77b4")
|
|
1449
|
+
ax2.axhline(0, color="red", lw=0.8, ls="--")
|
|
1450
|
+
ax2.set_xlabel(str(x_col_sp) if x_col_sp is not None else "Index")
|
|
1451
|
+
ax2.set_ylabel("Corrected signal")
|
|
1452
|
+
ax2.grid(True, alpha=0.3)
|
|
1453
|
+
|
|
1454
|
+
fig.tight_layout()
|
|
1455
|
+
st.pyplot(fig)
|
|
1456
|
+
plt.close(fig)
|
|
1457
|
+
|
|
1458
|
+
add_analysis_result(
|
|
1459
|
+
{
|
|
1460
|
+
"type": "baseline_correction",
|
|
1461
|
+
"dataset": dataset_name,
|
|
1462
|
+
"column": str(col_ref_sp),
|
|
1463
|
+
"method": label,
|
|
1464
|
+
"results": {"corrected": [float(v) for v in y_corr]},
|
|
1465
|
+
}
|
|
1466
|
+
)
|
|
1467
|
+
st.success("Results saved to analysis history.")
|
|
1468
|
+
|
|
1469
|
+
except Exception as exc:
|
|
1470
|
+
st.error(f"Error: {exc}")
|
|
1471
|
+
with st.expander("Details"):
|
|
1472
|
+
st.code(traceback.format_exc())
|
|
1473
|
+
|
|
1474
|
+
# ── Interpolate ──────────────────────────────────────────────────────
|
|
1475
|
+
with sub_interp:
|
|
1476
|
+
st.markdown("#### Interpolation")
|
|
1477
|
+
st.markdown("Resample the signal to a new, uniform x-grid.")
|
|
1478
|
+
|
|
1479
|
+
if x_col_sp is None:
|
|
1480
|
+
st.info(
|
|
1481
|
+
"No X column selected above. Select one in the "
|
|
1482
|
+
"'X-axis column' expander to interpolate on a physical grid. "
|
|
1483
|
+
"Index coordinates are used when no X column is set."
|
|
1484
|
+
)
|
|
1485
|
+
|
|
1486
|
+
sp_interp_method = st.selectbox(
|
|
1487
|
+
"Method",
|
|
1488
|
+
["cubic", "linear", "quadratic", "nearest"],
|
|
1489
|
+
key="sp_interp_method",
|
|
1490
|
+
)
|
|
1491
|
+
c1, c2, c3 = st.columns(3)
|
|
1492
|
+
with c1:
|
|
1493
|
+
sp_interp_n = int(
|
|
1494
|
+
st.number_input(
|
|
1495
|
+
"Output points",
|
|
1496
|
+
min_value=10,
|
|
1497
|
+
value=500,
|
|
1498
|
+
step=10,
|
|
1499
|
+
key="sp_interp_n",
|
|
1500
|
+
)
|
|
1501
|
+
)
|
|
1502
|
+
sp_use_data_bounds = st.checkbox(
|
|
1503
|
+
"Use data x-range as bounds", value=True, key="sp_interp_auto_bounds"
|
|
1504
|
+
)
|
|
1505
|
+
if not sp_use_data_bounds:
|
|
1506
|
+
with c2:
|
|
1507
|
+
sp_interp_start = st.number_input("X start", value=0.0, key="sp_interp_start")
|
|
1508
|
+
with c3:
|
|
1509
|
+
sp_interp_end = st.number_input("X end", value=1.0, key="sp_interp_end")
|
|
1510
|
+
|
|
1511
|
+
if st.button("Interpolate", type="primary", key="sp_interp_run"):
|
|
1512
|
+
try:
|
|
1513
|
+
y_sp = _to_1d(data, col_ref_sp)
|
|
1514
|
+
x_sp = (
|
|
1515
|
+
_to_1d(data, x_col_sp)
|
|
1516
|
+
if x_col_sp is not None
|
|
1517
|
+
else np.arange(len(y_sp), dtype=float)
|
|
1518
|
+
)
|
|
1519
|
+
|
|
1520
|
+
if sp_use_data_bounds:
|
|
1521
|
+
x_start = float(x_sp.min())
|
|
1522
|
+
x_stop = float(x_sp.max())
|
|
1523
|
+
else:
|
|
1524
|
+
x_start = float(sp_interp_start)
|
|
1525
|
+
x_stop = float(sp_interp_end)
|
|
1526
|
+
|
|
1527
|
+
x_new = np.linspace(x_start, x_stop, sp_interp_n)
|
|
1528
|
+
y_new = sig_interpolate(x_sp, y_sp, x_new, method=sp_interp_method)
|
|
1529
|
+
|
|
1530
|
+
fig, ax = plt.subplots(figsize=(8, 4))
|
|
1531
|
+
ax.scatter(
|
|
1532
|
+
x_sp,
|
|
1533
|
+
y_sp,
|
|
1534
|
+
s=12,
|
|
1535
|
+
alpha=0.5,
|
|
1536
|
+
color="#333333",
|
|
1537
|
+
label=f"Original ({len(x_sp)} pts)",
|
|
1538
|
+
zorder=3,
|
|
1539
|
+
)
|
|
1540
|
+
ax.plot(
|
|
1541
|
+
x_new,
|
|
1542
|
+
y_new,
|
|
1543
|
+
lw=1.5,
|
|
1544
|
+
color="#2ca02c",
|
|
1545
|
+
label=f"{sp_interp_method} ({sp_interp_n} pts)",
|
|
1546
|
+
)
|
|
1547
|
+
col_name = str(col_ref_sp) if col_ref_sp is not None else "Signal"
|
|
1548
|
+
ax.set_xlabel(str(x_col_sp) if x_col_sp is not None else "Index")
|
|
1549
|
+
ax.set_ylabel(col_name)
|
|
1550
|
+
ax.set_title("Interpolated Signal")
|
|
1551
|
+
ax.legend()
|
|
1552
|
+
ax.grid(True, alpha=0.3)
|
|
1553
|
+
fig.tight_layout()
|
|
1554
|
+
st.pyplot(fig)
|
|
1555
|
+
plt.close(fig)
|
|
1556
|
+
|
|
1557
|
+
with st.expander("Interpolated data (first 100 rows)"):
|
|
1558
|
+
st.dataframe(
|
|
1559
|
+
pd.DataFrame(
|
|
1560
|
+
{
|
|
1561
|
+
"X_new": x_new[:100],
|
|
1562
|
+
"Y_interp": y_new[:100],
|
|
1563
|
+
}
|
|
1564
|
+
),
|
|
1565
|
+
width="stretch",
|
|
1566
|
+
)
|
|
1567
|
+
|
|
1568
|
+
add_analysis_result(
|
|
1569
|
+
{
|
|
1570
|
+
"type": "interpolation",
|
|
1571
|
+
"dataset": dataset_name,
|
|
1572
|
+
"column": str(col_ref_sp),
|
|
1573
|
+
"method": sp_interp_method,
|
|
1574
|
+
"results": {
|
|
1575
|
+
"n_input": int(len(x_sp)),
|
|
1576
|
+
"n_output": sp_interp_n,
|
|
1577
|
+
"x_range": [x_start, x_stop],
|
|
1578
|
+
},
|
|
1579
|
+
}
|
|
1580
|
+
)
|
|
1581
|
+
st.success("Results saved to analysis history.")
|
|
1582
|
+
|
|
1583
|
+
except Exception as exc:
|
|
1584
|
+
st.error(f"Error: {exc}")
|
|
1585
|
+
with st.expander("Details"):
|
|
1586
|
+
st.code(traceback.format_exc())
|
|
1587
|
+
|
|
1588
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
1589
|
+
# Tab 7 — Peak Analysis
|
|
1590
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
1591
|
+
with tab_peaks:
|
|
1592
|
+
st.markdown("### Peak Analysis")
|
|
1593
|
+
st.markdown(
|
|
1594
|
+
"Detect peaks, compute FWHM and area, and fit multi-peak models "
|
|
1595
|
+
"to spectral or chromatographic data."
|
|
1596
|
+
)
|
|
1597
|
+
|
|
1598
|
+
_is_multidim_pk = (isinstance(data, pd.DataFrame) and len(_numeric_cols(data)) >= 2) or (
|
|
1599
|
+
isinstance(data, np.ndarray) and data.ndim == 2 and data.shape[1] >= 2
|
|
1600
|
+
)
|
|
1601
|
+
|
|
1602
|
+
if not _is_multidim_pk:
|
|
1603
|
+
st.warning(
|
|
1604
|
+
"Peak analysis requires X and Y columns. "
|
|
1605
|
+
"Load a DataFrame with ≥ 2 numeric columns or a 2-D NumPy array."
|
|
1606
|
+
)
|
|
1607
|
+
else:
|
|
1608
|
+
# Shared column selectors
|
|
1609
|
+
pk1, pk2 = st.columns(2)
|
|
1610
|
+
with pk1:
|
|
1611
|
+
x_col_pk, valid_xpk = _pick_col(
|
|
1612
|
+
data,
|
|
1613
|
+
"X column (wavenumber / wavelength / etc.)",
|
|
1614
|
+
key="pk_xcol",
|
|
1615
|
+
)
|
|
1616
|
+
with pk2:
|
|
1617
|
+
y_col_pk, valid_ypk = _pick_col(
|
|
1618
|
+
data,
|
|
1619
|
+
"Y column (intensity / absorbance / etc.)",
|
|
1620
|
+
key="pk_ycol",
|
|
1621
|
+
)
|
|
1622
|
+
|
|
1623
|
+
if valid_xpk and valid_ypk:
|
|
1624
|
+
sub_find, sub_fit = st.tabs(["Find & Measure", "Fit Peaks"])
|
|
1625
|
+
|
|
1626
|
+
# ── Find & Measure ────────────────────────────────────────────────
|
|
1627
|
+
with sub_find:
|
|
1628
|
+
st.markdown("#### Auto Peak Detection")
|
|
1629
|
+
st.caption(
|
|
1630
|
+
"Adjust the thresholds below, then click **Find Peaks**. "
|
|
1631
|
+
"Detected peaks are highlighted on an interactive plot."
|
|
1632
|
+
)
|
|
1633
|
+
|
|
1634
|
+
c1, c2 = st.columns(2)
|
|
1635
|
+
with c1:
|
|
1636
|
+
pk_min_height = st.number_input(
|
|
1637
|
+
"Min height (blank = any)",
|
|
1638
|
+
value=0.0,
|
|
1639
|
+
key="pk_height",
|
|
1640
|
+
help="Minimum absolute peak height.",
|
|
1641
|
+
)
|
|
1642
|
+
pk_prominence = st.number_input(
|
|
1643
|
+
"Min prominence",
|
|
1644
|
+
value=0.0,
|
|
1645
|
+
key="pk_prominence",
|
|
1646
|
+
help="How much a peak stands out from its neighbours.",
|
|
1647
|
+
)
|
|
1648
|
+
with c2:
|
|
1649
|
+
pk_distance = int(
|
|
1650
|
+
st.number_input(
|
|
1651
|
+
"Min distance (samples)",
|
|
1652
|
+
min_value=1,
|
|
1653
|
+
value=5,
|
|
1654
|
+
step=1,
|
|
1655
|
+
key="pk_distance",
|
|
1656
|
+
)
|
|
1657
|
+
)
|
|
1658
|
+
pk_width = st.number_input(
|
|
1659
|
+
"Min width (samples)",
|
|
1660
|
+
value=0.0,
|
|
1661
|
+
key="pk_width",
|
|
1662
|
+
help="Minimum peak width at half prominence.",
|
|
1663
|
+
)
|
|
1664
|
+
|
|
1665
|
+
if st.button("Find Peaks", type="primary", key="pk_find_run"):
|
|
1666
|
+
try:
|
|
1667
|
+
x_pk = _to_1d(data, x_col_pk)
|
|
1668
|
+
y_pk = _to_1d(data, y_col_pk)
|
|
1669
|
+
|
|
1670
|
+
result_pk = pk_find_peaks(
|
|
1671
|
+
y_pk,
|
|
1672
|
+
x=x_pk,
|
|
1673
|
+
height=float(pk_min_height) if pk_min_height > 0 else None,
|
|
1674
|
+
prominence=float(pk_prominence) if pk_prominence > 0 else None,
|
|
1675
|
+
distance=int(pk_distance),
|
|
1676
|
+
width=float(pk_width) if pk_width > 0 else None,
|
|
1677
|
+
)
|
|
1678
|
+
n_found = result_pk["n_peaks"]
|
|
1679
|
+
|
|
1680
|
+
if n_found == 0:
|
|
1681
|
+
st.warning("No peaks found — try lowering the thresholds.")
|
|
1682
|
+
else:
|
|
1683
|
+
st.success(f"Found **{n_found}** peak(s).")
|
|
1684
|
+
|
|
1685
|
+
# Compute FWHM and areas
|
|
1686
|
+
fwhm_result = compute_fwhm(y_pk, x_pk, result_pk)
|
|
1687
|
+
area_result = integrate_peaks(y_pk, x_pk, result_pk)
|
|
1688
|
+
|
|
1689
|
+
# ── Plotly interactive figure ─────────────────────
|
|
1690
|
+
fig_pk = go.Figure()
|
|
1691
|
+
fig_pk.add_trace(
|
|
1692
|
+
go.Scatter(
|
|
1693
|
+
x=x_pk,
|
|
1694
|
+
y=y_pk,
|
|
1695
|
+
mode="lines",
|
|
1696
|
+
name="Signal",
|
|
1697
|
+
line=dict(color="#1f77b4", width=1.5),
|
|
1698
|
+
)
|
|
1699
|
+
)
|
|
1700
|
+
fig_pk.add_trace(
|
|
1701
|
+
go.Scatter(
|
|
1702
|
+
x=result_pk["positions"],
|
|
1703
|
+
y=result_pk["heights"],
|
|
1704
|
+
mode="markers+text",
|
|
1705
|
+
name="Peaks",
|
|
1706
|
+
marker=dict(color="#d62728", size=10, symbol="triangle-down"),
|
|
1707
|
+
text=[f"#{i + 1}" for i in range(n_found)],
|
|
1708
|
+
textposition="top center",
|
|
1709
|
+
)
|
|
1710
|
+
)
|
|
1711
|
+
x_lbl_pk = str(x_col_pk) if x_col_pk is not None else "x"
|
|
1712
|
+
y_lbl_pk = str(y_col_pk) if y_col_pk is not None else "y"
|
|
1713
|
+
fig_pk.update_layout(
|
|
1714
|
+
xaxis_title=x_lbl_pk,
|
|
1715
|
+
yaxis_title=y_lbl_pk,
|
|
1716
|
+
title=f"Detected Peaks ({n_found})",
|
|
1717
|
+
height=400,
|
|
1718
|
+
legend=dict(orientation="h", y=-0.2),
|
|
1719
|
+
)
|
|
1720
|
+
st.plotly_chart(fig_pk, width="stretch")
|
|
1721
|
+
|
|
1722
|
+
# ── Peak report table ─────────────────────────────
|
|
1723
|
+
st.markdown("#### Peak Report")
|
|
1724
|
+
report_df = pd.DataFrame(
|
|
1725
|
+
{
|
|
1726
|
+
"Peak": range(1, n_found + 1),
|
|
1727
|
+
"Position": [f"{v:.4g}" for v in result_pk["positions"]],
|
|
1728
|
+
"Height": [f"{v:.4g}" for v in result_pk["heights"]],
|
|
1729
|
+
"Prominence": [f"{v:.4g}" for v in result_pk["prominences"]],
|
|
1730
|
+
"FWHM": [f"{v:.4g}" for v in fwhm_result["fwhm"]],
|
|
1731
|
+
"Area": [f"{v:.4g}" for v in area_result["areas"]],
|
|
1732
|
+
}
|
|
1733
|
+
)
|
|
1734
|
+
st.dataframe(report_df, width="stretch")
|
|
1735
|
+
|
|
1736
|
+
# CSV download
|
|
1737
|
+
csv_bytes = report_df.to_csv(index=False).encode()
|
|
1738
|
+
st.download_button(
|
|
1739
|
+
"Download peak report (CSV)",
|
|
1740
|
+
data=csv_bytes,
|
|
1741
|
+
file_name="peak_report.csv",
|
|
1742
|
+
mime="text/csv",
|
|
1743
|
+
key="pk_csv_dl",
|
|
1744
|
+
)
|
|
1745
|
+
|
|
1746
|
+
add_analysis_result(
|
|
1747
|
+
{
|
|
1748
|
+
"type": "peak_detection",
|
|
1749
|
+
"dataset": dataset_name,
|
|
1750
|
+
"x_col": str(x_col_pk),
|
|
1751
|
+
"y_col": str(y_col_pk),
|
|
1752
|
+
"results": {
|
|
1753
|
+
"n_peaks": n_found,
|
|
1754
|
+
"positions": [float(v) for v in result_pk["positions"]],
|
|
1755
|
+
"heights": [float(v) for v in result_pk["heights"]],
|
|
1756
|
+
"fwhm": [float(v) for v in fwhm_result["fwhm"]],
|
|
1757
|
+
"areas": [float(v) for v in area_result["areas"]],
|
|
1758
|
+
},
|
|
1759
|
+
}
|
|
1760
|
+
)
|
|
1761
|
+
st.success("Results saved to analysis history.")
|
|
1762
|
+
|
|
1763
|
+
except Exception as exc:
|
|
1764
|
+
st.error(f"Error: {exc}")
|
|
1765
|
+
with st.expander("Details"):
|
|
1766
|
+
st.code(traceback.format_exc())
|
|
1767
|
+
|
|
1768
|
+
# ── Fit Peaks ─────────────────────────────────────────────────────
|
|
1769
|
+
with sub_fit:
|
|
1770
|
+
st.markdown("#### Multi-Peak Model Fitting")
|
|
1771
|
+
|
|
1772
|
+
c1, c2 = st.columns(2)
|
|
1773
|
+
with c1:
|
|
1774
|
+
profile_pk = st.selectbox(
|
|
1775
|
+
"Peak profile",
|
|
1776
|
+
["Gaussian", "Lorentzian", "Pseudo-Voigt", "Voigt"],
|
|
1777
|
+
key="pk_profile",
|
|
1778
|
+
)
|
|
1779
|
+
with c2:
|
|
1780
|
+
bg_pk = st.selectbox(
|
|
1781
|
+
"Background",
|
|
1782
|
+
["linear", "constant", "none"],
|
|
1783
|
+
key="pk_bg",
|
|
1784
|
+
)
|
|
1785
|
+
|
|
1786
|
+
n_peaks_pk = int(
|
|
1787
|
+
st.number_input(
|
|
1788
|
+
"Number of peaks",
|
|
1789
|
+
min_value=1,
|
|
1790
|
+
max_value=8,
|
|
1791
|
+
value=1,
|
|
1792
|
+
step=1,
|
|
1793
|
+
key="pk_n",
|
|
1794
|
+
)
|
|
1795
|
+
)
|
|
1796
|
+
|
|
1797
|
+
# Pre-read for default guesses
|
|
1798
|
+
x_pk_raw = _to_1d(data, x_col_pk)
|
|
1799
|
+
y_pk_raw = _to_1d(data, y_col_pk)
|
|
1800
|
+
x_range_pk = float(x_pk_raw.max() - x_pk_raw.min())
|
|
1801
|
+
default_centers = [
|
|
1802
|
+
float(x_pk_raw.min() + (i + 1) * x_range_pk / (n_peaks_pk + 1))
|
|
1803
|
+
for i in range(n_peaks_pk)
|
|
1804
|
+
]
|
|
1805
|
+
|
|
1806
|
+
st.markdown("**Initial guesses** — one row per peak:")
|
|
1807
|
+
guess_rows = []
|
|
1808
|
+
for i in range(n_peaks_pk):
|
|
1809
|
+
c1, c2, c3 = st.columns(3)
|
|
1810
|
+
with c1:
|
|
1811
|
+
ctr = st.number_input(
|
|
1812
|
+
f"Peak {i + 1} — center",
|
|
1813
|
+
value=default_centers[i],
|
|
1814
|
+
key=f"pk_ctr_{i}",
|
|
1815
|
+
)
|
|
1816
|
+
with c2:
|
|
1817
|
+
amp = st.number_input(
|
|
1818
|
+
f"Peak {i + 1} — amplitude",
|
|
1819
|
+
value=float(y_pk_raw.max()),
|
|
1820
|
+
key=f"pk_amp_{i}",
|
|
1821
|
+
)
|
|
1822
|
+
with c3:
|
|
1823
|
+
wid = st.number_input(
|
|
1824
|
+
f"Peak {i + 1} — width (σ / Γ)",
|
|
1825
|
+
value=float(x_range_pk / max(n_peaks_pk * 4, 1)),
|
|
1826
|
+
min_value=1e-9,
|
|
1827
|
+
key=f"pk_wid_{i}",
|
|
1828
|
+
)
|
|
1829
|
+
guess_rows.append((float(ctr), float(amp), float(wid)))
|
|
1830
|
+
|
|
1831
|
+
if st.button("Fit Peaks", type="primary", key="pk_fit_run"):
|
|
1832
|
+
try:
|
|
1833
|
+
x_pk = x_pk_raw.copy()
|
|
1834
|
+
y_pk = y_pk_raw.copy()
|
|
1835
|
+
model_name = profile_pk.lower().replace("-", "_")
|
|
1836
|
+
|
|
1837
|
+
fit_result = fit_multipeak(
|
|
1838
|
+
y_pk,
|
|
1839
|
+
x_pk,
|
|
1840
|
+
n_peaks=n_peaks_pk,
|
|
1841
|
+
model=model_name,
|
|
1842
|
+
background=bg_pk,
|
|
1843
|
+
initial_guesses=guess_rows,
|
|
1844
|
+
)
|
|
1845
|
+
|
|
1846
|
+
r2_pk = fit_result["r_squared"]
|
|
1847
|
+
x_lbl_pk = str(x_col_pk) if x_col_pk is not None else "x"
|
|
1848
|
+
y_lbl_pk = str(y_col_pk) if y_col_pk is not None else "y"
|
|
1849
|
+
|
|
1850
|
+
# ── Plot ──────────────────────────────────────────────
|
|
1851
|
+
fig, (ax_fit, ax_res) = plt.subplots(
|
|
1852
|
+
2,
|
|
1853
|
+
1,
|
|
1854
|
+
figsize=(8, 6),
|
|
1855
|
+
gridspec_kw={"height_ratios": [3, 1]},
|
|
1856
|
+
sharex=True,
|
|
1857
|
+
)
|
|
1858
|
+
ax_fit.scatter(x_pk, y_pk, s=10, alpha=0.5, color="#333333", label="Data")
|
|
1859
|
+
ax_fit.plot(
|
|
1860
|
+
x_pk,
|
|
1861
|
+
fit_result["fitted_y"],
|
|
1862
|
+
"r-",
|
|
1863
|
+
linewidth=2,
|
|
1864
|
+
label=f"{profile_pk} fit",
|
|
1865
|
+
)
|
|
1866
|
+
if n_peaks_pk > 1:
|
|
1867
|
+
x_dense = np.linspace(float(x_pk.min()), float(x_pk.max()), 600)
|
|
1868
|
+
for k, y_ind in enumerate(fit_result["individual_y"]):
|
|
1869
|
+
ax_fit.plot(x_pk, y_ind, "--", alpha=0.6, label=f"Peak {k + 1}")
|
|
1870
|
+
ax_fit.set_ylabel(y_lbl_pk)
|
|
1871
|
+
ax_fit.set_title(f"{profile_pk} Fit | R² = {r2_pk:.5f}")
|
|
1872
|
+
ax_fit.legend()
|
|
1873
|
+
ax_fit.grid(True, alpha=0.3)
|
|
1874
|
+
|
|
1875
|
+
ax_res.scatter(
|
|
1876
|
+
x_pk,
|
|
1877
|
+
fit_result["residuals"],
|
|
1878
|
+
s=8,
|
|
1879
|
+
alpha=0.5,
|
|
1880
|
+
color="#555555",
|
|
1881
|
+
)
|
|
1882
|
+
ax_res.axhline(0, color="red", linewidth=1)
|
|
1883
|
+
ax_res.set_xlabel(x_lbl_pk)
|
|
1884
|
+
ax_res.set_ylabel("Residuals")
|
|
1885
|
+
ax_res.grid(True, alpha=0.3)
|
|
1886
|
+
|
|
1887
|
+
fig.tight_layout()
|
|
1888
|
+
st.pyplot(fig)
|
|
1889
|
+
plt.close(fig)
|
|
1890
|
+
|
|
1891
|
+
# ── Parameter table ───────────────────────────────────
|
|
1892
|
+
st.markdown("#### Fitted Parameters")
|
|
1893
|
+
st.metric(f"R² {_r2_color(r2_pk)}", f"{r2_pk:.6f}")
|
|
1894
|
+
st.dataframe(
|
|
1895
|
+
pd.DataFrame(
|
|
1896
|
+
{
|
|
1897
|
+
"Parameter": fit_result["param_names"],
|
|
1898
|
+
"Value": [f"{v:.6g}" for v in fit_result["params"]],
|
|
1899
|
+
"Std Error": [f"{e:.4g}" for e in fit_result["std_errors"]],
|
|
1900
|
+
}
|
|
1901
|
+
),
|
|
1902
|
+
width="stretch",
|
|
1903
|
+
)
|
|
1904
|
+
|
|
1905
|
+
# ── Peak summary ──────────────────────────────────────
|
|
1906
|
+
st.markdown("#### Peak Summary")
|
|
1907
|
+
st.dataframe(
|
|
1908
|
+
pd.DataFrame(
|
|
1909
|
+
[
|
|
1910
|
+
{
|
|
1911
|
+
"Peak": k + 1,
|
|
1912
|
+
"Center": f"{s['center']:.4g}",
|
|
1913
|
+
"Amplitude": f"{s['amplitude']:.4g}",
|
|
1914
|
+
"Width": f"{s['width']:.4g}",
|
|
1915
|
+
"FWHM": f"{s['fwhm']:.4g}",
|
|
1916
|
+
}
|
|
1917
|
+
for k, s in enumerate(fit_result["peak_summaries"])
|
|
1918
|
+
]
|
|
1919
|
+
),
|
|
1920
|
+
width="stretch",
|
|
1921
|
+
)
|
|
1922
|
+
|
|
1923
|
+
add_analysis_result(
|
|
1924
|
+
{
|
|
1925
|
+
"type": "peak_fitting",
|
|
1926
|
+
"dataset": dataset_name,
|
|
1927
|
+
"x_col": str(x_col_pk),
|
|
1928
|
+
"y_col": str(y_col_pk),
|
|
1929
|
+
"profile": profile_pk,
|
|
1930
|
+
"n_peaks": n_peaks_pk,
|
|
1931
|
+
"results": {
|
|
1932
|
+
"parameters": [float(v) for v in fit_result["params"]],
|
|
1933
|
+
"std_errors": [float(e) for e in fit_result["std_errors"]],
|
|
1934
|
+
"r_squared": r2_pk,
|
|
1935
|
+
},
|
|
1936
|
+
}
|
|
1937
|
+
)
|
|
1938
|
+
st.success("Results saved to analysis history.")
|
|
1939
|
+
|
|
1940
|
+
except Exception as exc:
|
|
1941
|
+
st.error(f"Error: {exc}")
|
|
1942
|
+
with st.expander("Details"):
|
|
1943
|
+
st.code(traceback.format_exc())
|
|
1944
|
+
|
|
1945
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
1946
|
+
# Tab 8 — Statistical Tests
|
|
1947
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
1948
|
+
with tab_tests:
|
|
1949
|
+
st.markdown("### Statistical Tests")
|
|
1950
|
+
st.markdown(
|
|
1951
|
+
"Hypothesis tests, correlation analysis, and multiple-comparison "
|
|
1952
|
+
"corrections. Select a test, choose columns, and inspect the result "
|
|
1953
|
+
"table."
|
|
1954
|
+
)
|
|
1955
|
+
|
|
1956
|
+
_TEST_CHOICES = [
|
|
1957
|
+
"One-Sample t-Test",
|
|
1958
|
+
"Two-Sample t-Test (Student / Welch)",
|
|
1959
|
+
"Paired t-Test",
|
|
1960
|
+
"Mann-Whitney U (nonparametric 2-sample)",
|
|
1961
|
+
"Wilcoxon Signed-Rank (nonparametric paired)",
|
|
1962
|
+
"One-Way ANOVA",
|
|
1963
|
+
"Two-Way ANOVA",
|
|
1964
|
+
"Kruskal-Wallis (nonparametric ANOVA)",
|
|
1965
|
+
"Tukey HSD Post-Hoc",
|
|
1966
|
+
"Bonferroni Correction",
|
|
1967
|
+
"Pearson Correlation",
|
|
1968
|
+
"Spearman Correlation",
|
|
1969
|
+
"Chi-Square Independence",
|
|
1970
|
+
]
|
|
1971
|
+
|
|
1972
|
+
test_choice = st.selectbox("Select statistical test", _TEST_CHOICES, key="sttest_choice")
|
|
1973
|
+
alpha_st = st.number_input(
|
|
1974
|
+
"Significance level α",
|
|
1975
|
+
min_value=0.001,
|
|
1976
|
+
max_value=0.20,
|
|
1977
|
+
value=0.05,
|
|
1978
|
+
step=0.01,
|
|
1979
|
+
format="%.3f",
|
|
1980
|
+
key="sttest_alpha",
|
|
1981
|
+
)
|
|
1982
|
+
|
|
1983
|
+
st.markdown("---")
|
|
1984
|
+
|
|
1985
|
+
# ── helpers ───────────────────────────────────────────────────────────────
|
|
1986
|
+
|
|
1987
|
+
def _result_table(result: dict) -> None:
|
|
1988
|
+
"""Render a test result dict as a two-column Streamlit table."""
|
|
1989
|
+
rows = []
|
|
1990
|
+
for k, v in result.items():
|
|
1991
|
+
if isinstance(v, list):
|
|
1992
|
+
display = ", ".join(str(x) for x in v)
|
|
1993
|
+
elif isinstance(v, float):
|
|
1994
|
+
display = f"{v:.6g}"
|
|
1995
|
+
else:
|
|
1996
|
+
display = str(v)
|
|
1997
|
+
rows.append({"Parameter": k, "Value": display})
|
|
1998
|
+
import pandas as _pd
|
|
1999
|
+
|
|
2000
|
+
st.dataframe(_pd.DataFrame(rows), width="stretch", hide_index=True)
|
|
2001
|
+
|
|
2002
|
+
# Effect size display (Cohen's d or similar)
|
|
2003
|
+
if "effect_size" in result:
|
|
2004
|
+
d = result["effect_size"]
|
|
2005
|
+
label = result.get("effect_size_label", "Effect size")
|
|
2006
|
+
abs_d = abs(d)
|
|
2007
|
+
if abs_d < 0.2:
|
|
2008
|
+
magnitude = "Negligible"
|
|
2009
|
+
color = "gray"
|
|
2010
|
+
elif abs_d < 0.5:
|
|
2011
|
+
magnitude = "Small"
|
|
2012
|
+
color = "blue"
|
|
2013
|
+
elif abs_d < 0.8:
|
|
2014
|
+
magnitude = "Medium"
|
|
2015
|
+
color = "orange"
|
|
2016
|
+
else:
|
|
2017
|
+
magnitude = "Large"
|
|
2018
|
+
color = "red"
|
|
2019
|
+
st.markdown(
|
|
2020
|
+
f"**{label}:** `{d:.4f}` — "
|
|
2021
|
+
f":{color}[**{magnitude}**] "
|
|
2022
|
+
f"(|d| < 0.2 negligible · 0.2–0.5 small · 0.5–0.8 medium · ≥ 0.8 large)"
|
|
2023
|
+
)
|
|
2024
|
+
|
|
2025
|
+
def _verdict(result: dict) -> None:
|
|
2026
|
+
"""Show a coloured reject / fail-to-reject banner."""
|
|
2027
|
+
if result.get("reject_null"):
|
|
2028
|
+
st.success(f"**Reject H₀** (p = {result['p_value']:.4g} < α = {result['alpha']:.3g})")
|
|
2029
|
+
else:
|
|
2030
|
+
st.info(
|
|
2031
|
+
f"**Fail to reject H₀** (p = {result['p_value']:.4g} ≥ α = {result['alpha']:.3g})"
|
|
2032
|
+
)
|
|
2033
|
+
|
|
2034
|
+
# ── One-sample t-test ─────────────────────────────────────────────────────
|
|
2035
|
+
if test_choice == "One-Sample t-Test":
|
|
2036
|
+
st.markdown("**H₀:** sample mean = μ₀")
|
|
2037
|
+
col_ref_t1, valid_t1 = _pick_col(data, "Select column", key="t1_col")
|
|
2038
|
+
popmean_t1 = st.number_input("Hypothesized mean (μ₀)", value=0.0, key="t1_mu")
|
|
2039
|
+
if valid_t1 and st.button("Run Test", type="primary", key="t1_run"):
|
|
2040
|
+
try:
|
|
2041
|
+
arr = _to_1d(data, col_ref_t1)
|
|
2042
|
+
result = ttest_one_sample(arr, popmean_t1, alpha=alpha_st)
|
|
2043
|
+
_verdict(result)
|
|
2044
|
+
_result_table(result)
|
|
2045
|
+
add_analysis_result(
|
|
2046
|
+
{"type": "ttest_one_sample", "dataset": dataset_name, "results": result}
|
|
2047
|
+
)
|
|
2048
|
+
except Exception as exc:
|
|
2049
|
+
st.error(f"Error: {exc}")
|
|
2050
|
+
with st.expander("Details"):
|
|
2051
|
+
st.code(traceback.format_exc())
|
|
2052
|
+
|
|
2053
|
+
# ── Two-sample t-test ─────────────────────────────────────────────────────
|
|
2054
|
+
elif test_choice == "Two-Sample t-Test (Student / Welch)":
|
|
2055
|
+
st.markdown("**H₀:** mean(A) = mean(B)")
|
|
2056
|
+
c1, c2 = st.columns(2)
|
|
2057
|
+
with c1:
|
|
2058
|
+
col_a, valid_a = _pick_col(data, "Group A column", key="t2_col_a")
|
|
2059
|
+
with c2:
|
|
2060
|
+
col_b, valid_b = _pick_col(data, "Group B column", key="t2_col_b")
|
|
2061
|
+
equal_var_t2 = st.checkbox(
|
|
2062
|
+
"Assume equal variances (Student; uncheck for Welch)",
|
|
2063
|
+
value=True,
|
|
2064
|
+
key="t2_eq_var",
|
|
2065
|
+
)
|
|
2066
|
+
if valid_a and valid_b and st.button("Run Test", type="primary", key="t2_run"):
|
|
2067
|
+
try:
|
|
2068
|
+
a = _to_1d(data, col_a)
|
|
2069
|
+
b = _to_1d(data, col_b)
|
|
2070
|
+
result = ttest_two_sample(a, b, equal_var=equal_var_t2, alpha=alpha_st)
|
|
2071
|
+
_verdict(result)
|
|
2072
|
+
_result_table(result)
|
|
2073
|
+
add_analysis_result(
|
|
2074
|
+
{"type": "ttest_two_sample", "dataset": dataset_name, "results": result}
|
|
2075
|
+
)
|
|
2076
|
+
except Exception as exc:
|
|
2077
|
+
st.error(f"Error: {exc}")
|
|
2078
|
+
with st.expander("Details"):
|
|
2079
|
+
st.code(traceback.format_exc())
|
|
2080
|
+
|
|
2081
|
+
# ── Paired t-test ─────────────────────────────────────────────────────────
|
|
2082
|
+
elif test_choice == "Paired t-Test":
|
|
2083
|
+
st.markdown("**H₀:** mean(A − B) = 0")
|
|
2084
|
+
c1, c2 = st.columns(2)
|
|
2085
|
+
with c1:
|
|
2086
|
+
col_a_pt, valid_a_pt = _pick_col(data, "Before / Group A", key="pt_col_a")
|
|
2087
|
+
with c2:
|
|
2088
|
+
col_b_pt, valid_b_pt = _pick_col(data, "After / Group B", key="pt_col_b")
|
|
2089
|
+
if valid_a_pt and valid_b_pt and st.button("Run Test", type="primary", key="pt_run"):
|
|
2090
|
+
try:
|
|
2091
|
+
a = _to_1d(data, col_a_pt)
|
|
2092
|
+
b = _to_1d(data, col_b_pt)
|
|
2093
|
+
result = ttest_paired(a, b, alpha=alpha_st)
|
|
2094
|
+
_verdict(result)
|
|
2095
|
+
_result_table(result)
|
|
2096
|
+
add_analysis_result(
|
|
2097
|
+
{"type": "ttest_paired", "dataset": dataset_name, "results": result}
|
|
2098
|
+
)
|
|
2099
|
+
except Exception as exc:
|
|
2100
|
+
st.error(f"Error: {exc}")
|
|
2101
|
+
with st.expander("Details"):
|
|
2102
|
+
st.code(traceback.format_exc())
|
|
2103
|
+
|
|
2104
|
+
# ── Mann-Whitney U ─────────────────────────────────────────────────────────
|
|
2105
|
+
elif test_choice == "Mann-Whitney U (nonparametric 2-sample)":
|
|
2106
|
+
st.markdown("**H₀:** distributions of A and B are equal")
|
|
2107
|
+
c1, c2 = st.columns(2)
|
|
2108
|
+
with c1:
|
|
2109
|
+
col_a_mw, valid_a_mw = _pick_col(data, "Group A column", key="mw_col_a")
|
|
2110
|
+
with c2:
|
|
2111
|
+
col_b_mw, valid_b_mw = _pick_col(data, "Group B column", key="mw_col_b")
|
|
2112
|
+
if valid_a_mw and valid_b_mw and st.button("Run Test", type="primary", key="mw_run"):
|
|
2113
|
+
try:
|
|
2114
|
+
a = _to_1d(data, col_a_mw)
|
|
2115
|
+
b = _to_1d(data, col_b_mw)
|
|
2116
|
+
result = mannwhitney_u(a, b, alpha=alpha_st)
|
|
2117
|
+
_verdict(result)
|
|
2118
|
+
_result_table(result)
|
|
2119
|
+
add_analysis_result(
|
|
2120
|
+
{"type": "mannwhitney_u", "dataset": dataset_name, "results": result}
|
|
2121
|
+
)
|
|
2122
|
+
except Exception as exc:
|
|
2123
|
+
st.error(f"Error: {exc}")
|
|
2124
|
+
with st.expander("Details"):
|
|
2125
|
+
st.code(traceback.format_exc())
|
|
2126
|
+
|
|
2127
|
+
# ── Wilcoxon signed-rank ──────────────────────────────────────────────────
|
|
2128
|
+
elif test_choice == "Wilcoxon Signed-Rank (nonparametric paired)":
|
|
2129
|
+
st.markdown("**H₀:** median difference = 0")
|
|
2130
|
+
c1, c2 = st.columns(2)
|
|
2131
|
+
with c1:
|
|
2132
|
+
col_a_wx, valid_a_wx = _pick_col(data, "Before / Group A", key="wx_col_a")
|
|
2133
|
+
with c2:
|
|
2134
|
+
col_b_wx, valid_b_wx = _pick_col(data, "After / Group B", key="wx_col_b")
|
|
2135
|
+
if valid_a_wx and valid_b_wx and st.button("Run Test", type="primary", key="wx_run"):
|
|
2136
|
+
try:
|
|
2137
|
+
a = _to_1d(data, col_a_wx)
|
|
2138
|
+
b = _to_1d(data, col_b_wx)
|
|
2139
|
+
result = wilcoxon_signed_rank(a, b, alpha=alpha_st)
|
|
2140
|
+
_verdict(result)
|
|
2141
|
+
_result_table(result)
|
|
2142
|
+
add_analysis_result(
|
|
2143
|
+
{"type": "wilcoxon", "dataset": dataset_name, "results": result}
|
|
2144
|
+
)
|
|
2145
|
+
except Exception as exc:
|
|
2146
|
+
st.error(f"Error: {exc}")
|
|
2147
|
+
with st.expander("Details"):
|
|
2148
|
+
st.code(traceback.format_exc())
|
|
2149
|
+
|
|
2150
|
+
# ── One-way ANOVA ─────────────────────────────────────────────────────────
|
|
2151
|
+
elif test_choice == "One-Way ANOVA":
|
|
2152
|
+
st.markdown("**H₀:** all group means are equal")
|
|
2153
|
+
st.caption("Select 2–6 group columns from the loaded dataset.")
|
|
2154
|
+
if isinstance(data, pd.DataFrame):
|
|
2155
|
+
cols_all = _numeric_cols(data)
|
|
2156
|
+
group_cols = st.multiselect(
|
|
2157
|
+
"Group columns (select ≥ 2)",
|
|
2158
|
+
cols_all,
|
|
2159
|
+
key="anova1_cols",
|
|
2160
|
+
)
|
|
2161
|
+
else:
|
|
2162
|
+
arr_all = np.asarray(data)
|
|
2163
|
+
if arr_all.ndim < 2:
|
|
2164
|
+
st.warning("ANOVA requires a 2-D dataset with at least 2 columns.")
|
|
2165
|
+
group_cols = []
|
|
2166
|
+
else:
|
|
2167
|
+
n_avail = arr_all.shape[1]
|
|
2168
|
+
group_idx = st.multiselect(
|
|
2169
|
+
"Group columns (select ≥ 2)",
|
|
2170
|
+
list(range(n_avail)),
|
|
2171
|
+
format_func=lambda i: f"Column {i}",
|
|
2172
|
+
key="anova1_cols_np",
|
|
2173
|
+
)
|
|
2174
|
+
group_cols = group_idx # reuse variable
|
|
2175
|
+
|
|
2176
|
+
if len(group_cols) >= 2 and st.button("Run Test", type="primary", key="anova1_run"):
|
|
2177
|
+
try:
|
|
2178
|
+
if isinstance(data, pd.DataFrame):
|
|
2179
|
+
arrays = [data[c].dropna().values.astype(float) for c in group_cols]
|
|
2180
|
+
else:
|
|
2181
|
+
arrays = [np.asarray(data)[:, i].astype(float) for i in group_cols]
|
|
2182
|
+
result = anova_oneway(*arrays, alpha=alpha_st)
|
|
2183
|
+
_verdict(result)
|
|
2184
|
+
_result_table(result)
|
|
2185
|
+
add_analysis_result(
|
|
2186
|
+
{
|
|
2187
|
+
"type": "anova_oneway",
|
|
2188
|
+
"dataset": dataset_name,
|
|
2189
|
+
"groups": [str(c) for c in group_cols],
|
|
2190
|
+
"results": result,
|
|
2191
|
+
}
|
|
2192
|
+
)
|
|
2193
|
+
except Exception as exc:
|
|
2194
|
+
st.error(f"Error: {exc}")
|
|
2195
|
+
with st.expander("Details"):
|
|
2196
|
+
st.code(traceback.format_exc())
|
|
2197
|
+
elif len(group_cols) < 2:
|
|
2198
|
+
st.info("Select at least 2 group columns to run the test.")
|
|
2199
|
+
|
|
2200
|
+
# ── Two-Way ANOVA ─────────────────────────────────────────────────────────
|
|
2201
|
+
elif test_choice == "Two-Way ANOVA":
|
|
2202
|
+
st.markdown("**H₀:** all factor main effects and interaction are zero")
|
|
2203
|
+
st.caption(
|
|
2204
|
+
"Requires a DataFrame with one continuous response column and two "
|
|
2205
|
+
"categorical factor columns. Requires **statsmodels**."
|
|
2206
|
+
)
|
|
2207
|
+
try:
|
|
2208
|
+
import statsmodels # noqa: F401
|
|
2209
|
+
|
|
2210
|
+
_has_sm = True
|
|
2211
|
+
except ImportError:
|
|
2212
|
+
_has_sm = False
|
|
2213
|
+
|
|
2214
|
+
if not _has_sm:
|
|
2215
|
+
st.warning(
|
|
2216
|
+
"Two-way ANOVA requires statsmodels. Install it with: `pip install statsmodels`"
|
|
2217
|
+
)
|
|
2218
|
+
elif not isinstance(data, pd.DataFrame):
|
|
2219
|
+
st.warning("Two-way ANOVA requires a tabular (DataFrame) dataset.")
|
|
2220
|
+
else:
|
|
2221
|
+
all_cols = list(data.columns)
|
|
2222
|
+
num_cols = _numeric_cols(data)
|
|
2223
|
+
col_r, col_fa, col_fb = st.columns(3)
|
|
2224
|
+
with col_r:
|
|
2225
|
+
response_col = st.selectbox("Response (numeric)", num_cols, key="anova2_resp")
|
|
2226
|
+
with col_fa:
|
|
2227
|
+
fa_col = st.selectbox("Factor A", all_cols, key="anova2_fa")
|
|
2228
|
+
with col_fb:
|
|
2229
|
+
remaining_fb = [c for c in all_cols if c != fa_col]
|
|
2230
|
+
fb_col = st.selectbox("Factor B", remaining_fb, key="anova2_fb")
|
|
2231
|
+
include_int = st.checkbox("Include A×B interaction term", value=True, key="anova2_int")
|
|
2232
|
+
if st.button("Run Two-Way ANOVA", type="primary", key="anova2_run"):
|
|
2233
|
+
try:
|
|
2234
|
+
result = anova_twoway(
|
|
2235
|
+
data,
|
|
2236
|
+
response_col,
|
|
2237
|
+
fa_col,
|
|
2238
|
+
fb_col,
|
|
2239
|
+
include_interaction=include_int,
|
|
2240
|
+
)
|
|
2241
|
+
table = result["table"]
|
|
2242
|
+
st.markdown("#### ANOVA Table")
|
|
2243
|
+
st.dataframe(table, width="stretch")
|
|
2244
|
+
st.markdown("#### Significance Summary (α = 0.05)")
|
|
2245
|
+
|
|
2246
|
+
def _sig_badge(sig):
|
|
2247
|
+
return "**Significant** (p < 0.05)" if sig else "Not significant (p ≥ 0.05)"
|
|
2248
|
+
|
|
2249
|
+
st.markdown(
|
|
2250
|
+
f"- **Factor A ({fa_col}):** {_sig_badge(result['significant_a'])} "
|
|
2251
|
+
f"(p = {result['p_value_a']:.4g})"
|
|
2252
|
+
)
|
|
2253
|
+
st.markdown(
|
|
2254
|
+
f"- **Factor B ({fb_col}):** {_sig_badge(result['significant_b'])} "
|
|
2255
|
+
f"(p = {result['p_value_b']:.4g})"
|
|
2256
|
+
)
|
|
2257
|
+
if result["significant_interaction"] is not None:
|
|
2258
|
+
st.markdown(
|
|
2259
|
+
f"- **Interaction ({fa_col}×{fb_col}):** "
|
|
2260
|
+
f"{_sig_badge(result['significant_interaction'])} "
|
|
2261
|
+
f"(p = {result['p_value_interaction']:.4g})"
|
|
2262
|
+
)
|
|
2263
|
+
add_analysis_result(
|
|
2264
|
+
{
|
|
2265
|
+
"type": "anova_twoway",
|
|
2266
|
+
"dataset": dataset_name,
|
|
2267
|
+
"response": response_col,
|
|
2268
|
+
"factor_a": fa_col,
|
|
2269
|
+
"factor_b": fb_col,
|
|
2270
|
+
"interaction_included": include_int,
|
|
2271
|
+
}
|
|
2272
|
+
)
|
|
2273
|
+
except Exception as exc:
|
|
2274
|
+
st.error(f"Error: {exc}")
|
|
2275
|
+
with st.expander("Details"):
|
|
2276
|
+
st.code(traceback.format_exc())
|
|
2277
|
+
|
|
2278
|
+
# ── Kruskal-Wallis ────────────────────────────────────────────────────────
|
|
2279
|
+
elif test_choice == "Kruskal-Wallis (nonparametric ANOVA)":
|
|
2280
|
+
st.markdown("**H₀:** all group distributions are equal")
|
|
2281
|
+
if isinstance(data, pd.DataFrame):
|
|
2282
|
+
cols_all = _numeric_cols(data)
|
|
2283
|
+
group_cols_kw = st.multiselect("Group columns (select ≥ 2)", cols_all, key="kw_cols")
|
|
2284
|
+
else:
|
|
2285
|
+
arr_all = np.asarray(data)
|
|
2286
|
+
if arr_all.ndim < 2:
|
|
2287
|
+
st.warning("Kruskal-Wallis requires a 2-D dataset.")
|
|
2288
|
+
group_cols_kw = []
|
|
2289
|
+
else:
|
|
2290
|
+
group_cols_kw = st.multiselect(
|
|
2291
|
+
"Group columns (select ≥ 2)",
|
|
2292
|
+
list(range(arr_all.shape[1])),
|
|
2293
|
+
format_func=lambda i: f"Column {i}",
|
|
2294
|
+
key="kw_cols_np",
|
|
2295
|
+
)
|
|
2296
|
+
|
|
2297
|
+
if len(group_cols_kw) >= 2 and st.button("Run Test", type="primary", key="kw_run"):
|
|
2298
|
+
try:
|
|
2299
|
+
if isinstance(data, pd.DataFrame):
|
|
2300
|
+
arrays = [data[c].dropna().values.astype(float) for c in group_cols_kw]
|
|
2301
|
+
else:
|
|
2302
|
+
arrays = [np.asarray(data)[:, i].astype(float) for i in group_cols_kw]
|
|
2303
|
+
result = kruskal_wallis(*arrays, alpha=alpha_st)
|
|
2304
|
+
_verdict(result)
|
|
2305
|
+
_result_table(result)
|
|
2306
|
+
add_analysis_result(
|
|
2307
|
+
{
|
|
2308
|
+
"type": "kruskal_wallis",
|
|
2309
|
+
"dataset": dataset_name,
|
|
2310
|
+
"groups": [str(c) for c in group_cols_kw],
|
|
2311
|
+
"results": result,
|
|
2312
|
+
}
|
|
2313
|
+
)
|
|
2314
|
+
except Exception as exc:
|
|
2315
|
+
st.error(f"Error: {exc}")
|
|
2316
|
+
with st.expander("Details"):
|
|
2317
|
+
st.code(traceback.format_exc())
|
|
2318
|
+
elif len(group_cols_kw) < 2:
|
|
2319
|
+
st.info("Select at least 2 group columns to run the test.")
|
|
2320
|
+
|
|
2321
|
+
# ── Tukey HSD ─────────────────────────────────────────────────────────────
|
|
2322
|
+
elif test_choice == "Tukey HSD Post-Hoc":
|
|
2323
|
+
st.markdown(
|
|
2324
|
+
"Pairwise post-hoc comparisons after a significant ANOVA. Requires scipy ≥ 1.8."
|
|
2325
|
+
)
|
|
2326
|
+
if isinstance(data, pd.DataFrame):
|
|
2327
|
+
cols_all = _numeric_cols(data)
|
|
2328
|
+
group_cols_thsd = st.multiselect(
|
|
2329
|
+
"Group columns (select ≥ 2)", cols_all, key="thsd_cols"
|
|
2330
|
+
)
|
|
2331
|
+
else:
|
|
2332
|
+
arr_all = np.asarray(data)
|
|
2333
|
+
if arr_all.ndim < 2:
|
|
2334
|
+
st.warning("Tukey HSD requires a 2-D dataset.")
|
|
2335
|
+
group_cols_thsd = []
|
|
2336
|
+
else:
|
|
2337
|
+
group_cols_thsd = st.multiselect(
|
|
2338
|
+
"Group columns (select ≥ 2)",
|
|
2339
|
+
list(range(arr_all.shape[1])),
|
|
2340
|
+
format_func=lambda i: f"Column {i}",
|
|
2341
|
+
key="thsd_cols_np",
|
|
2342
|
+
)
|
|
2343
|
+
|
|
2344
|
+
if len(group_cols_thsd) >= 2 and st.button("Run Test", type="primary", key="thsd_run"):
|
|
2345
|
+
try:
|
|
2346
|
+
if isinstance(data, pd.DataFrame):
|
|
2347
|
+
arrays = [data[c].dropna().values.astype(float) for c in group_cols_thsd]
|
|
2348
|
+
else:
|
|
2349
|
+
arrays = [np.asarray(data)[:, i].astype(float) for i in group_cols_thsd]
|
|
2350
|
+
result = tukey_hsd(*arrays, alpha=alpha_st)
|
|
2351
|
+
st.markdown(
|
|
2352
|
+
f"**{len(result['comparisons'])} pairwise comparisons** (α = {alpha_st})"
|
|
2353
|
+
)
|
|
2354
|
+
import pandas as _pd
|
|
2355
|
+
|
|
2356
|
+
rows = []
|
|
2357
|
+
for cmp in result["comparisons"]:
|
|
2358
|
+
gi = (
|
|
2359
|
+
group_cols_thsd[cmp["group_i"]]
|
|
2360
|
+
if isinstance(data, pd.DataFrame)
|
|
2361
|
+
else f"Col {cmp['group_i']}"
|
|
2362
|
+
)
|
|
2363
|
+
gj = (
|
|
2364
|
+
group_cols_thsd[cmp["group_j"]]
|
|
2365
|
+
if isinstance(data, pd.DataFrame)
|
|
2366
|
+
else f"Col {cmp['group_j']}"
|
|
2367
|
+
)
|
|
2368
|
+
rows.append(
|
|
2369
|
+
{
|
|
2370
|
+
"Group i": gi,
|
|
2371
|
+
"Group j": gj,
|
|
2372
|
+
"Mean diff": f"{cmp['mean_diff']:.4g}",
|
|
2373
|
+
"p-value": f"{cmp['p_value']:.4g}",
|
|
2374
|
+
"Significant": "Yes" if cmp["significant"] else "No",
|
|
2375
|
+
}
|
|
2376
|
+
)
|
|
2377
|
+
st.dataframe(_pd.DataFrame(rows), width="stretch", hide_index=True)
|
|
2378
|
+
add_analysis_result(
|
|
2379
|
+
{
|
|
2380
|
+
"type": "tukey_hsd",
|
|
2381
|
+
"dataset": dataset_name,
|
|
2382
|
+
"groups": [str(c) for c in group_cols_thsd],
|
|
2383
|
+
"results": result,
|
|
2384
|
+
}
|
|
2385
|
+
)
|
|
2386
|
+
except Exception as exc:
|
|
2387
|
+
st.error(f"Error: {exc}")
|
|
2388
|
+
with st.expander("Details"):
|
|
2389
|
+
st.code(traceback.format_exc())
|
|
2390
|
+
elif len(group_cols_thsd) < 2:
|
|
2391
|
+
st.info("Select at least 2 group columns to run the test.")
|
|
2392
|
+
|
|
2393
|
+
# ── Bonferroni ────────────────────────────────────────────────────────────
|
|
2394
|
+
elif test_choice == "Bonferroni Correction":
|
|
2395
|
+
st.markdown("Enter raw p-values (comma-separated or one per line) from multiple tests.")
|
|
2396
|
+
p_raw_input = st.text_area(
|
|
2397
|
+
"Raw p-values",
|
|
2398
|
+
value="0.03, 0.12, 0.001, 0.045",
|
|
2399
|
+
key="bonf_pvals",
|
|
2400
|
+
)
|
|
2401
|
+
if st.button("Apply Correction", type="primary", key="bonf_run"):
|
|
2402
|
+
try:
|
|
2403
|
+
import re
|
|
2404
|
+
|
|
2405
|
+
p_raw = [float(v) for v in re.split(r"[,\s]+", p_raw_input.strip()) if v]
|
|
2406
|
+
result = bonferroni_correction(np.array(p_raw), alpha=alpha_st)
|
|
2407
|
+
st.metric("Adjusted α", f"{result['adjusted_alpha']:.4g}")
|
|
2408
|
+
import pandas as _pd
|
|
2409
|
+
|
|
2410
|
+
rows = [
|
|
2411
|
+
{
|
|
2412
|
+
"Test #": i + 1,
|
|
2413
|
+
"Raw p": f"{p:.4g}",
|
|
2414
|
+
"Reject H₀": "Yes" if r else "No",
|
|
2415
|
+
}
|
|
2416
|
+
for i, (p, r) in enumerate(zip(result["corrected_p_values"], result["reject"]))
|
|
2417
|
+
]
|
|
2418
|
+
st.dataframe(_pd.DataFrame(rows), width="stretch", hide_index=True)
|
|
2419
|
+
add_analysis_result(
|
|
2420
|
+
{"type": "bonferroni", "dataset": dataset_name, "results": result}
|
|
2421
|
+
)
|
|
2422
|
+
except Exception as exc:
|
|
2423
|
+
st.error(f"Error: {exc}")
|
|
2424
|
+
with st.expander("Details"):
|
|
2425
|
+
st.code(traceback.format_exc())
|
|
2426
|
+
|
|
2427
|
+
# ── Pearson correlation ────────────────────────────────────────────────────
|
|
2428
|
+
elif test_choice == "Pearson Correlation":
|
|
2429
|
+
st.markdown("**H₀:** ρ = 0 (no linear correlation)")
|
|
2430
|
+
c1, c2 = st.columns(2)
|
|
2431
|
+
with c1:
|
|
2432
|
+
col_x_pc, valid_x_pc = _pick_col(data, "X column", key="pc_col_x")
|
|
2433
|
+
with c2:
|
|
2434
|
+
col_y_pc, valid_y_pc = _pick_col(data, "Y column", key="pc_col_y")
|
|
2435
|
+
if valid_x_pc and valid_y_pc and st.button("Run Test", type="primary", key="pc_run"):
|
|
2436
|
+
try:
|
|
2437
|
+
x = _to_1d(data, col_x_pc)
|
|
2438
|
+
y = _to_1d(data, col_y_pc)
|
|
2439
|
+
result = pearson_correlation(x, y, alpha=alpha_st)
|
|
2440
|
+
_verdict(result)
|
|
2441
|
+
_result_table(result)
|
|
2442
|
+
add_analysis_result(
|
|
2443
|
+
{"type": "pearson_correlation", "dataset": dataset_name, "results": result}
|
|
2444
|
+
)
|
|
2445
|
+
except Exception as exc:
|
|
2446
|
+
st.error(f"Error: {exc}")
|
|
2447
|
+
with st.expander("Details"):
|
|
2448
|
+
st.code(traceback.format_exc())
|
|
2449
|
+
|
|
2450
|
+
# ── Spearman correlation ───────────────────────────────────────────────────
|
|
2451
|
+
elif test_choice == "Spearman Correlation":
|
|
2452
|
+
st.markdown("**H₀:** ρₛ = 0 (no monotonic correlation)")
|
|
2453
|
+
c1, c2 = st.columns(2)
|
|
2454
|
+
with c1:
|
|
2455
|
+
col_x_sc, valid_x_sc = _pick_col(data, "X column", key="sc_col_x")
|
|
2456
|
+
with c2:
|
|
2457
|
+
col_y_sc, valid_y_sc = _pick_col(data, "Y column", key="sc_col_y")
|
|
2458
|
+
if valid_x_sc and valid_y_sc and st.button("Run Test", type="primary", key="sc_run"):
|
|
2459
|
+
try:
|
|
2460
|
+
x = _to_1d(data, col_x_sc)
|
|
2461
|
+
y = _to_1d(data, col_y_sc)
|
|
2462
|
+
result = spearman_correlation(x, y, alpha=alpha_st)
|
|
2463
|
+
_verdict(result)
|
|
2464
|
+
_result_table(result)
|
|
2465
|
+
add_analysis_result(
|
|
2466
|
+
{"type": "spearman_correlation", "dataset": dataset_name, "results": result}
|
|
2467
|
+
)
|
|
2468
|
+
except Exception as exc:
|
|
2469
|
+
st.error(f"Error: {exc}")
|
|
2470
|
+
with st.expander("Details"):
|
|
2471
|
+
st.code(traceback.format_exc())
|
|
2472
|
+
|
|
2473
|
+
# ── Chi-square independence ────────────────────────────────────────────────
|
|
2474
|
+
elif test_choice == "Chi-Square Independence":
|
|
2475
|
+
st.markdown(
|
|
2476
|
+
"**H₀:** the two categorical variables are independent. "
|
|
2477
|
+
"Enter the contingency table as rows separated by newlines and "
|
|
2478
|
+
"values separated by commas."
|
|
2479
|
+
)
|
|
2480
|
+
ct_input = st.text_area(
|
|
2481
|
+
"Contingency table (rows, comma-separated)",
|
|
2482
|
+
value="10, 20, 30\n6, 9, 17",
|
|
2483
|
+
key="chi2_table",
|
|
2484
|
+
)
|
|
2485
|
+
if st.button("Run Test", type="primary", key="chi2_run"):
|
|
2486
|
+
try:
|
|
2487
|
+
rows_ct = [
|
|
2488
|
+
[float(v) for v in row.split(",")]
|
|
2489
|
+
for row in ct_input.strip().splitlines()
|
|
2490
|
+
if row.strip()
|
|
2491
|
+
]
|
|
2492
|
+
ct_array = np.array(rows_ct)
|
|
2493
|
+
result = chi_square_independence(ct_array, alpha=alpha_st)
|
|
2494
|
+
_verdict(result)
|
|
2495
|
+
_result_table({k: v for k, v in result.items() if k != "expected"})
|
|
2496
|
+
with st.expander("Expected frequencies"):
|
|
2497
|
+
import pandas as _pd
|
|
2498
|
+
|
|
2499
|
+
st.dataframe(
|
|
2500
|
+
_pd.DataFrame(result["expected"]),
|
|
2501
|
+
width="stretch",
|
|
2502
|
+
hide_index=True,
|
|
2503
|
+
)
|
|
2504
|
+
add_analysis_result(
|
|
2505
|
+
{"type": "chi_square", "dataset": dataset_name, "results": result}
|
|
2506
|
+
)
|
|
2507
|
+
except Exception as exc:
|
|
2508
|
+
st.error(f"Error: {exc}")
|
|
2509
|
+
with st.expander("Details"):
|
|
2510
|
+
st.code(traceback.format_exc())
|