eagleplot 3.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. eagleplot/Home.py +362 -0
  2. eagleplot/__init__.py +19 -0
  3. eagleplot/annotations.py +215 -0
  4. eagleplot/assets/logo.ico +0 -0
  5. eagleplot/assets/logo.png +0 -0
  6. eagleplot/assets/nccu-horiz-logo.png +0 -0
  7. eagleplot/assets/nccu-wings.png +0 -0
  8. eagleplot/batch.py +446 -0
  9. eagleplot/cli.py +719 -0
  10. eagleplot/data_tools.py +849 -0
  11. eagleplot/example-data/Artificial/bimodal_distribution.csv +501 -0
  12. eagleplot/example-data/Artificial/correlation_matrix.npy +0 -0
  13. eagleplot/example-data/Artificial/gaussian_surface.npy +0 -0
  14. eagleplot/example-data/Artificial/grouped_categorical.csv +241 -0
  15. eagleplot/example-data/Artificial/ir_spectrum.csv +1802 -0
  16. eagleplot/example-data/Artificial/molecular_properties.csv +121 -0
  17. eagleplot/example-data/Artificial/normal_distribution.csv +401 -0
  18. eagleplot/example-data/Artificial/reaction_kinetics.csv +121 -0
  19. eagleplot/example-data/Artificial/scatter_correlation.csv +201 -0
  20. eagleplot/example-data/Artificial/sine_cosine_waves.csv +301 -0
  21. eagleplot/gallery/box_plot.png +0 -0
  22. eagleplot/gallery/contour_plot.png +0 -0
  23. eagleplot/gallery/distribution_plot.png +0 -0
  24. eagleplot/gallery/heatmap.png +0 -0
  25. eagleplot/gallery/histogram.png +0 -0
  26. eagleplot/gallery/interactive_3d_surface.png +0 -0
  27. eagleplot/gallery/interactive_heatmap.png +0 -0
  28. eagleplot/gallery/interactive_histogram.png +0 -0
  29. eagleplot/gallery/interactive_line.png +0 -0
  30. eagleplot/gallery/interactive_scatter.png +0 -0
  31. eagleplot/gallery/line_plot.png +0 -0
  32. eagleplot/gallery/manifest.json +196 -0
  33. eagleplot/gallery/regression_plot.png +0 -0
  34. eagleplot/gallery/scatter_plot.png +0 -0
  35. eagleplot/io.py +1008 -0
  36. eagleplot/math.py +1210 -0
  37. eagleplot/molecular/__init__.py +21 -0
  38. eagleplot/molecular/atom_data.py +287 -0
  39. eagleplot/molecular/parsers.py +747 -0
  40. eagleplot/nist.py +241 -0
  41. eagleplot/pages/10_Molecular_Viz.py +225 -0
  42. eagleplot/pages/11_Batch_Analysis.py +325 -0
  43. eagleplot/pages/12_Help.py +199 -0
  44. eagleplot/pages/13_Settings.py +388 -0
  45. eagleplot/pages/1_Data_Upload.py +521 -0
  46. eagleplot/pages/2_Quick_Plot.py +1693 -0
  47. eagleplot/pages/3_Analysis_Tools.py +2510 -0
  48. eagleplot/pages/4_Multi_Plot_Dashboard.py +791 -0
  49. eagleplot/pages/5_Advanced_Plotting.py +691 -0
  50. eagleplot/pages/6_Export_Results.py +442 -0
  51. eagleplot/pages/7_Gallery.py +201 -0
  52. eagleplot/pages/8_Data_Tools.py +434 -0
  53. eagleplot/pages/9_Spectroscopy.py +821 -0
  54. eagleplot/pages/__init__.py +8 -0
  55. eagleplot/peaks.py +513 -0
  56. eagleplot/plotting.py +2630 -0
  57. eagleplot/plugin_loader.py +208 -0
  58. eagleplot/report.py +448 -0
  59. eagleplot/signal.py +494 -0
  60. eagleplot/spectroscopy.py +830 -0
  61. eagleplot/utils/__init__.py +76 -0
  62. eagleplot/utils/access.py +77 -0
  63. eagleplot/utils/data_preview.py +352 -0
  64. eagleplot/utils/plot_config.py +2721 -0
  65. eagleplot/utils/session_state.py +535 -0
  66. eagleplot/utils/theming.py +312 -0
  67. eagleplot/utils/user_settings.py +294 -0
  68. eagleplot-3.0.0.dist-info/METADATA +304 -0
  69. eagleplot-3.0.0.dist-info/RECORD +73 -0
  70. eagleplot-3.0.0.dist-info/WHEEL +5 -0
  71. eagleplot-3.0.0.dist-info/entry_points.txt +2 -0
  72. eagleplot-3.0.0.dist-info/licenses/LICENSE +21 -0
  73. eagleplot-3.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,521 @@
1
+ """Data Upload and Preview Page.
2
+
3
+ This page allows users to upload datasets in various formats, preview
4
+ the data, and view basic statistics. Uploaded datasets are stored in
5
+ session state and available throughout the app.
6
+
7
+ Supported Formats
8
+ -----------------
9
+ - NumPy arrays (.npy, .npz)
10
+ - CSV (.csv)
11
+ - Excel (.xlsx, .xls)
12
+ - TSV (.tsv)
13
+ - JSON (.json)
14
+ - Parquet (.parquet)
15
+ """
16
+
17
+ import sys
18
+ import streamlit as st
19
+ import tempfile
20
+ from pathlib import Path
21
+ import traceback
22
+ import numpy as np
23
+ import pandas as pd
24
+
25
+ _APP_ROOT = Path(__file__).resolve().parent.parent.parent
26
+ # Example datasets ship inside the package so this tab works after
27
+ # `pip install` -- see audit A-09.
28
+ _EXAMPLE_DIR = Path(__file__).resolve().parent.parent / "example-data" / "Artificial"
29
+
30
+ # Metadata for each built-in example file
31
+ _EXAMPLES = {
32
+ "normal_distribution.csv": {
33
+ "label": "Normal Distribution",
34
+ "desc": "400 simulated temperature measurements (Gaussian, μ=298 K).",
35
+ "use": "Histogram · Distribution · Interactive Histogram",
36
+ },
37
+ "bimodal_distribution.csv": {
38
+ "label": "Bimodal Distribution",
39
+ "desc": "500 absorbance values drawn from two overlapping Gaussians.",
40
+ "use": "Distribution · Overlaid Distributions",
41
+ },
42
+ "sine_cosine_waves.csv": {
43
+ "label": "Sine & Cosine Waves",
44
+ "desc": "300-point sin, cos, and damped-sin series over 0–4π.",
45
+ "use": "Line Plot · Interactive Line",
46
+ },
47
+ "scatter_correlation.csv": {
48
+ "label": "Scatter Correlation",
49
+ "desc": "200 (reaction time, yield, temperature) points with linear trend.",
50
+ "use": "Scatter · Regression · Interactive Scatter",
51
+ },
52
+ "grouped_categorical.csv": {
53
+ "label": "Grouped Categorical",
54
+ "desc": "Catalyst × solvent yield data (4 catalysts × 3 solvents, 20 obs each).",
55
+ "use": "Box / Violin / Swarm · Grouped Categorical",
56
+ },
57
+ "molecular_properties.csv": {
58
+ "label": "Molecular Properties",
59
+ "desc": "120 molecules with MW, logP, TPSA, HBD, HBA, RotBonds, pIC50.",
60
+ "use": "Correlation Heatmap · Scatter · Regression",
61
+ },
62
+ "ir_spectrum.csv": {
63
+ "label": "IR Spectrum",
64
+ "desc": "Simulated IR transmittance spectrum from 4000–400 cm⁻¹.",
65
+ "use": "Line Plot · Interactive Line",
66
+ },
67
+ "reaction_kinetics.csv": {
68
+ "label": "Reaction Kinetics",
69
+ "desc": "A→B→C consecutive-reaction concentration profiles over 60 min.",
70
+ "use": "Line Plot · Interactive Line",
71
+ },
72
+ "gaussian_surface.npy": {
73
+ "label": "Gaussian Surface (2D array)",
74
+ "desc": "60×60 double-Gaussian potential energy surface.",
75
+ "use": "Contour Plot · 3D Surface · Heatmap · Interactive Heatmap",
76
+ },
77
+ "correlation_matrix.npy": {
78
+ "label": "Correlation Matrix (2D array)",
79
+ "desc": "10×10 exact correlation matrix derived from random data.",
80
+ "use": "Heatmap · Interactive Heatmap",
81
+ },
82
+ }
83
+
84
+ sys.path.insert(0, str(Path(__file__).parent.parent.parent))
85
+
86
+ from eagleplot.io import load_data # noqa: E402
87
+ from eagleplot.math import calculate_statistics, check_normality # noqa: E402
88
+ from eagleplot.utils import ( # noqa: E402
89
+ initialize_session_state,
90
+ add_dataset,
91
+ get_dataset,
92
+ delete_dataset,
93
+ get_session_summary,
94
+ display_dataset_card,
95
+ display_data_preview,
96
+ )
97
+ from eagleplot.batch import scan_directory, batch_load_files # noqa: E402
98
+ from eagleplot.io import downsample_for_preview # noqa: E402
99
+ from eagleplot.utils.access import folder_loading_allowed_here # noqa: E402
100
+
101
+ _LARGE_FILE_BYTES = 50 * 1024 * 1024 # 50 MB — warn above this
102
+ _PREVIEW_MAX_ROWS = 10_000 # downsample preview above this
103
+
104
+
105
+ @st.cache_data(show_spinner=False)
106
+ def _load_example_cached(path: str):
107
+ """Load an example dataset from a fixed path, cached across reruns."""
108
+ return load_data(path)
109
+
110
+
111
+ # Initialize session state
112
+ initialize_session_state()
113
+
114
+ # Page title
115
+ st.title("Data Upload")
116
+ st.caption("Load datasets from file, examples, or a folder.")
117
+
118
+ tab1, tab2, tab3 = st.tabs(["Upload File", "Example Datasets", "Batch Import"])
119
+
120
+ with tab1:
121
+ # NOTE: `pkl` is deliberately absent. Unpickling an uploaded file executes
122
+ # code contained in it, and a user clicking "Browse files" has not consented
123
+ # to that. `eagleplot.io.load_pickle()` remains available in the Python API,
124
+ # where the caller has chosen to trust the file. See TDEC-011 / audit A-16.
125
+ uploaded_file = st.file_uploader(
126
+ "Choose a data file",
127
+ type=[
128
+ "npy",
129
+ "npz",
130
+ "csv",
131
+ "xlsx",
132
+ "xls",
133
+ "tsv",
134
+ "json",
135
+ "parquet",
136
+ "jdx",
137
+ "dx",
138
+ "h5",
139
+ "hdf5",
140
+ "nc",
141
+ "cdf",
142
+ "spc",
143
+ "asc",
144
+ "mzml",
145
+ "mzxml",
146
+ ],
147
+ help="Upload a data file in any supported format",
148
+ )
149
+ st.caption(
150
+ "Pickle (`.pkl`) files are not accepted here — loading one runs any code it "
151
+ "contains. Use `eagleplot.io.load_pickle()` from Python if you trust the file, "
152
+ "or re-save your data as CSV or Parquet."
153
+ )
154
+
155
+ if uploaded_file is not None:
156
+ try:
157
+ with st.spinner(f"Loading {uploaded_file.name}..."):
158
+ # Each upload gets its own temp directory, removed as soon as the
159
+ # file is parsed. A shared $TMP/eagleplot/ let two sessions uploading
160
+ # the same filename overwrite each other's data, and nothing was
161
+ # ever deleted. The browser-supplied name is reduced to its
162
+ # basename before it touches the filesystem. See audit A-14.
163
+ with tempfile.TemporaryDirectory(prefix="eagleplot-upload-") as temp_dir:
164
+ temp_path = Path(temp_dir) / Path(uploaded_file.name).name
165
+ temp_path.write_bytes(uploaded_file.getvalue())
166
+ data = load_data(str(temp_path))
167
+ # np.load() returns a lazy NpzFile that keeps the file open;
168
+ # read every array now, before the directory is removed.
169
+ if isinstance(data, np.lib.npyio.NpzFile):
170
+ with data:
171
+ data = {key: data[key] for key in data.files}
172
+
173
+ # Large-file warning: show a note and offer a downsampled preview
174
+ _is_large = isinstance(data, pd.DataFrame) and (
175
+ uploaded_file.size > _LARGE_FILE_BYTES or len(data) > _PREVIEW_MAX_ROWS
176
+ )
177
+ if _is_large:
178
+ st.warning(
179
+ f"Large dataset: {len(data):,} rows / "
180
+ f"{uploaded_file.size / 1_048_576:.1f} MB. "
181
+ "Full data stored; previews use a downsampled view."
182
+ )
183
+
184
+ # Add to session state
185
+ add_dataset(
186
+ uploaded_file.name,
187
+ data,
188
+ metadata={
189
+ "file_size": uploaded_file.size,
190
+ "file_type": uploaded_file.type,
191
+ "downsampled_preview": _is_large,
192
+ },
193
+ )
194
+
195
+ st.toast(f"Loaded '{uploaded_file.name}' successfully", icon="✅")
196
+ st.success(f"Successfully loaded: **{uploaded_file.name}**")
197
+
198
+ # Show preview (downsampled for large files)
199
+ st.markdown("### Preview")
200
+ _preview_data = (
201
+ downsample_for_preview(data, _PREVIEW_MAX_ROWS) if _is_large else data
202
+ )
203
+ display_data_preview(_preview_data, uploaded_file.name)
204
+
205
+ except Exception as e:
206
+ st.error(f"Error loading file: {str(e)}")
207
+ with st.expander("Show Error Details"):
208
+ st.code(traceback.format_exc())
209
+
210
+ with tab2:
211
+ st.markdown(
212
+ "Load a built-in artificial dataset to explore each plot type "
213
+ "immediately — no file upload required."
214
+ )
215
+
216
+ if not _EXAMPLE_DIR.exists():
217
+ st.warning(
218
+ f"Example data directory not found: `{_EXAMPLE_DIR}`. "
219
+ "Run `python generate_examples.py` from the repository root first."
220
+ )
221
+ else:
222
+ st.markdown(
223
+ """
224
+ <style>
225
+ /* Equal-height example dataset cards */
226
+ [data-testid="stHorizontalBlock"] {
227
+ align-items: stretch !important;
228
+ }
229
+ [data-testid="column"] {
230
+ display: flex !important;
231
+ flex-direction: column !important;
232
+ }
233
+ [data-testid="column"] > div {
234
+ flex: 1 !important;
235
+ display: flex !important;
236
+ flex-direction: column !important;
237
+ }
238
+ [data-testid="stVerticalBlockBorderWrapper"] {
239
+ flex: 1 !important;
240
+ display: flex !important;
241
+ flex-direction: column !important;
242
+ }
243
+ [data-testid="stVerticalBlockBorderWrapper"] > div {
244
+ flex: 1 !important;
245
+ }
246
+ </style>
247
+ """,
248
+ unsafe_allow_html=True,
249
+ )
250
+ _available = [
251
+ (fname, meta) for fname, meta in _EXAMPLES.items() if (_EXAMPLE_DIR / fname).exists()
252
+ ]
253
+ for i in range(0, len(_available), 2):
254
+ _row_cols = st.columns(2)
255
+ for j, (fname, meta) in enumerate(_available[i : i + 2]):
256
+ fpath = _EXAMPLE_DIR / fname
257
+ with _row_cols[j]:
258
+ with st.container(border=True):
259
+ st.markdown(f"**{meta['label']}**")
260
+ st.caption(meta["desc"])
261
+ st.caption(f"*Best for:* {meta['use']}")
262
+ btn_key = f"ex_load_{fname}"
263
+ if st.button("Load", key=btn_key, width="stretch"):
264
+ try:
265
+ ex_data = _load_example_cached(str(fpath))
266
+ add_dataset(
267
+ fname,
268
+ ex_data,
269
+ metadata={
270
+ "source": "example",
271
+ "file_type": fpath.suffix,
272
+ },
273
+ )
274
+ st.success(f"Loaded **{meta['label']}**")
275
+ st.rerun()
276
+ except Exception as _ex_err:
277
+ st.error(f"Error: {_ex_err}")
278
+
279
+ with tab3:
280
+ # Reading a typed folder path is the point when EaglePlot runs on your own
281
+ # machine, and a way to read the server's disk on a shared deployment.
282
+ # See audit A-15 and eagleplot/utils/access.py.
283
+ if not folder_loading_allowed_here():
284
+ st.info(
285
+ "Batch import from a folder is turned off on this server, because "
286
+ "the path would be read on the server, not your computer. Upload files "
287
+ "individually on the **Upload File** tab instead. To enable it, start "
288
+ "EaglePlot with the environment variable `EAGLEPLOT_ALLOW_FOLDER_LOAD=1`."
289
+ )
290
+ else:
291
+ st.markdown(
292
+ "Load all matching files from a local directory at once. "
293
+ "Each file becomes a separate dataset."
294
+ )
295
+ _batch_folder = st.text_input(
296
+ "Folder path",
297
+ placeholder="C:/Users/you/data",
298
+ key="batch_folder_path",
299
+ help="Absolute path to the directory containing your data files.",
300
+ )
301
+ # `pkl` is deliberately absent here too — see the note on the uploader above.
302
+ _all_exts = [
303
+ "csv",
304
+ "xlsx",
305
+ "xls",
306
+ "tsv",
307
+ "json",
308
+ "parquet",
309
+ "npy",
310
+ "npz",
311
+ "jdx",
312
+ "dx",
313
+ "h5",
314
+ "hdf5",
315
+ "nc",
316
+ "cdf",
317
+ "spc",
318
+ "asc",
319
+ ]
320
+ _batch_exts = st.multiselect(
321
+ "File extensions to include",
322
+ options=_all_exts,
323
+ default=["csv", "xlsx", "tsv"],
324
+ key="batch_extensions",
325
+ help="Leave blank to include all supported formats.",
326
+ )
327
+ _batch_pattern = st.text_input(
328
+ "Filename pattern (optional)",
329
+ placeholder="sample_*.csv",
330
+ key="batch_filename_pattern",
331
+ help="fnmatch-style pattern, e.g. 'sample_*.csv'. Leave blank to match all.",
332
+ )
333
+
334
+ _scan_col, _load_col = st.columns(2)
335
+ with _scan_col:
336
+ _do_scan = st.button("Scan Directory", key="batch_scan_btn", width="stretch")
337
+ with _load_col:
338
+ _do_load = st.button("Load All Files", key="batch_load_btn", width="stretch")
339
+
340
+ if _do_scan or _do_load:
341
+ if not _batch_folder:
342
+ st.warning("Please enter a folder path.")
343
+ else:
344
+ try:
345
+ _exts = _batch_exts if _batch_exts else None
346
+ _pat = _batch_pattern.strip() if _batch_pattern.strip() else None
347
+ _found = scan_directory(_batch_folder, extensions=_exts, pattern=_pat)
348
+
349
+ if not _found:
350
+ st.info("No matching files found in that directory.")
351
+ else:
352
+ # Always show metadata table
353
+ _meta_rows = []
354
+ for _fp in _found:
355
+ _size = _fp.stat().st_size
356
+ _meta_rows.append(
357
+ {
358
+ "Filename": _fp.name,
359
+ "Extension": _fp.suffix,
360
+ "Size (KB)": f"{_size / 1024:.1f}",
361
+ }
362
+ )
363
+ st.markdown(f"**{len(_found)} file(s) found:**")
364
+ st.dataframe(pd.DataFrame(_meta_rows), hide_index=True)
365
+
366
+ if _do_load:
367
+ with st.spinner(f"Loading {len(_found)} file(s)..."):
368
+ _result = batch_load_files(_found, on_error="skip")
369
+
370
+ _loaded = _result["datasets"]
371
+ _errors = _result["errors"]
372
+ _bmeta = _result["metadata"]
373
+
374
+ for _name, _data in _loaded.items():
375
+ add_dataset(
376
+ _name,
377
+ _data,
378
+ metadata={
379
+ "source": "batch_import",
380
+ "file_type": Path(_bmeta[_name]["source"]).suffix,
381
+ "size_bytes": _bmeta[_name]["size_bytes"],
382
+ },
383
+ )
384
+
385
+ if _loaded:
386
+ st.success(
387
+ f"Loaded {len(_loaded)} dataset(s): "
388
+ + ", ".join(f"**{n}**" for n in _loaded)
389
+ )
390
+ st.rerun()
391
+ if _errors:
392
+ with st.expander(f"{len(_errors)} file(s) failed to load"):
393
+ for _ename, _emsg in _errors.items():
394
+ st.error(f"`{_ename}`: {_emsg}")
395
+
396
+ except NotADirectoryError as _e:
397
+ st.error(f"Not a valid directory: {_e}")
398
+ except Exception as _e:
399
+ st.error(f"Batch import error: {_e}")
400
+
401
+
402
+ # Dataset management section
403
+ st.markdown("---")
404
+ st.markdown("## Loaded Datasets")
405
+
406
+ summary = get_session_summary()
407
+
408
+ if summary["num_datasets"] == 0:
409
+ st.info("No datasets loaded yet. Upload a file above to get started.")
410
+ else:
411
+ st.write(f"**{summary['num_datasets']} dataset(s) loaded**")
412
+
413
+ # Dataset selector with delete option
414
+ col1, col2 = st.columns([3, 1])
415
+
416
+ with col1:
417
+ selected_dataset = st.selectbox(
418
+ "Select dataset to view",
419
+ options=list(st.session_state.datasets.keys()),
420
+ index=(
421
+ list(st.session_state.datasets.keys()).index(summary["current_dataset"])
422
+ if summary["current_dataset"]
423
+ else 0
424
+ ),
425
+ key="dataset_selector",
426
+ )
427
+
428
+ with col2:
429
+ st.write("") # Spacing
430
+ st.write("") # Spacing
431
+ if st.button("Delete", key="delete_btn", help="Delete selected dataset"):
432
+ if delete_dataset(selected_dataset):
433
+ st.success(f"Deleted {selected_dataset}")
434
+ st.rerun()
435
+ else:
436
+ st.error("Failed to delete dataset")
437
+
438
+ # Update current dataset
439
+ if selected_dataset:
440
+ st.session_state.current_dataset = selected_dataset
441
+ data = get_dataset(selected_dataset)
442
+ metadata = st.session_state.dataset_metadata.get(selected_dataset, {})
443
+
444
+ # Display dataset card (main area)
445
+ st.markdown("---")
446
+ display_dataset_card(selected_dataset, data, metadata)
447
+
448
+ # ── Sidebar: Data Preview + Quick Statistics ──────────────────────
449
+ with st.sidebar:
450
+ st.markdown("## Data Preview")
451
+ display_data_preview(data, selected_dataset)
452
+
453
+ st.markdown("---")
454
+ st.markdown("## Quick Statistics")
455
+
456
+ if isinstance(data, pd.DataFrame):
457
+ numeric_cols = data.select_dtypes(include=[np.number]).columns.tolist()
458
+
459
+ if len(numeric_cols) > 0:
460
+ selected_col = st.selectbox(
461
+ "Select column for statistics",
462
+ options=numeric_cols,
463
+ key="stats_column",
464
+ )
465
+
466
+ if st.button("Calculate Statistics", key="calc_stats"):
467
+ stats = calculate_statistics(data[selected_col].values)
468
+ st.write("**Descriptive Statistics:**")
469
+ mc1, mc2, mc3 = st.columns(3)
470
+ mc1.metric("Mean", f"{stats['mean']:.4g}")
471
+ mc2.metric("Median", f"{stats['median']:.4g}")
472
+ mc3.metric("Std Dev", f"{stats['std']:.4g}")
473
+ mc1, mc2, mc3 = st.columns(3)
474
+ mc1.metric("Min", f"{stats['min']:.4g}")
475
+ mc2.metric("Max", f"{stats['max']:.4g}")
476
+ mc3.metric("Range", f"{stats['range']:.4g}")
477
+ st.write("**Quartiles:**")
478
+ mc1, mc2, mc3 = st.columns(3)
479
+ mc1.metric("Q1 (25%)", f"{stats['q1']:.4g}")
480
+ mc2.metric("Q3 (75%)", f"{stats['q3']:.4g}")
481
+ mc3.metric("IQR", f"{stats['iqr']:.4g}")
482
+
483
+ if st.button("Test Normality", key="test_normality"):
484
+ result = check_normality(data[selected_col].values)
485
+ st.write("**Shapiro-Wilk Normality Test:**")
486
+ st.write(f"Statistic: {result['statistic']:.6f}")
487
+ st.write(f"P-value: {result['p_value']:.6f}")
488
+ if result["is_normal"]:
489
+ st.success("Normally distributed (p > 0.05)")
490
+ else:
491
+ st.warning("Not normally distributed (p ≤ 0.05)")
492
+ else:
493
+ st.info("No numeric columns available for statistics")
494
+
495
+ elif isinstance(data, np.ndarray):
496
+ if np.issubdtype(data.dtype, np.number):
497
+ if st.button("Calculate Statistics", key="calc_stats"):
498
+ flat_data = data.flatten() if data.ndim > 1 else data
499
+ stats = calculate_statistics(flat_data)
500
+ st.write("**Descriptive Statistics:**")
501
+ mc1, mc2, mc3 = st.columns(3)
502
+ mc1.metric("Mean", f"{stats['mean']:.4g}")
503
+ mc2.metric("Median", f"{stats['median']:.4g}")
504
+ mc3.metric("Std Dev", f"{stats['std']:.4g}")
505
+ mc1, mc2, mc3 = st.columns(3)
506
+ mc1.metric("Min", f"{stats['min']:.4g}")
507
+ mc2.metric("Max", f"{stats['max']:.4g}")
508
+ mc3.metric("Range", f"{stats['range']:.4g}")
509
+
510
+ if st.button("Test Normality", key="test_normality"):
511
+ flat_data = data.flatten() if data.ndim > 1 else data
512
+ result = check_normality(flat_data)
513
+ st.write("**Shapiro-Wilk Normality Test:**")
514
+ st.write(f"Statistic: {result['statistic']:.6f}")
515
+ st.write(f"P-value: {result['p_value']:.6f}")
516
+ if result["is_normal"]:
517
+ st.success("Normally distributed (p > 0.05)")
518
+ else:
519
+ st.warning("Not normally distributed (p ≤ 0.05)")
520
+ else:
521
+ st.info("Array is not numeric - statistics not applicable")