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
eagleplot/batch.py ADDED
@@ -0,0 +1,446 @@
1
+ """Batch processing utilities for EaglePlot.
2
+
3
+ Provides helpers for scanning directories, loading multiple files in bulk,
4
+ and running common analyses (statistics, curve fitting, peak detection) across
5
+ entire datasets in one call.
6
+
7
+ No Streamlit dependency — safe to import from CLI or notebooks.
8
+
9
+ Public API
10
+ ----------
11
+ scan_directory(folder_path, extensions=None, pattern=None) -> list[Path]
12
+ Return sorted paths matching the given criteria.
13
+ batch_load_files(file_paths, on_error='skip') -> dict
14
+ Load many files; returns datasets, errors, and metadata.
15
+ batch_statistics(datasets, columns=None) -> pd.DataFrame
16
+ Descriptive statistics across all DataFrame datasets.
17
+ batch_curve_fit(datasets, x_col, y_col, fit_type='linear', degree=2) -> pd.DataFrame
18
+ Curve fit one x/y column pair across all DataFrame datasets.
19
+ batch_peak_analysis(datasets, y_col, x_col=None, ...) -> pd.DataFrame
20
+ Find peaks in y_col across all DataFrame datasets.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import fnmatch
26
+ from pathlib import Path
27
+ from typing import Any
28
+
29
+ import numpy as np
30
+ import pandas as pd
31
+
32
+ from eagleplot.io import load_data
33
+ from eagleplot.math import (
34
+ calculate_statistics,
35
+ fit_exponential,
36
+ fit_linear,
37
+ fit_polynomial,
38
+ )
39
+ from eagleplot.peaks import compute_fwhm, find_peaks
40
+
41
+ # ── Constants ─────────────────────────────────────────────────────────────────
42
+
43
+ _SUPPORTED_EXTENSIONS: set[str] = {
44
+ ".pkl",
45
+ ".npy",
46
+ ".npz",
47
+ ".csv",
48
+ ".xlsx",
49
+ ".xls",
50
+ ".tsv",
51
+ ".json",
52
+ ".parquet",
53
+ ".jdx",
54
+ ".dx",
55
+ ".h5",
56
+ ".hdf5",
57
+ ".nc",
58
+ ".cdf",
59
+ ".spc",
60
+ ".asc",
61
+ }
62
+
63
+ # ── Helpers ───────────────────────────────────────────────────────────────────
64
+
65
+
66
+ def _normalise_extensions(extensions: list[str] | None) -> set[str] | None:
67
+ """Return a set of lower-case dot-prefixed extensions, or None for 'all'."""
68
+ if extensions is None:
69
+ return None
70
+ normalised = set()
71
+ for ext in extensions:
72
+ ext = ext.strip().lower()
73
+ if not ext.startswith("."):
74
+ ext = "." + ext
75
+ normalised.add(ext)
76
+ return normalised
77
+
78
+
79
+ def _file_metadata(path: Path, data: Any) -> dict:
80
+ """Return a metadata dict for a single loaded file."""
81
+ meta: dict[str, Any] = {
82
+ "source": str(path),
83
+ "size_bytes": path.stat().st_size if path.exists() else 0,
84
+ "suffix": path.suffix.lower(),
85
+ }
86
+ if isinstance(data, pd.DataFrame):
87
+ meta["shape"] = data.shape
88
+ meta["columns"] = list(data.columns)
89
+ elif isinstance(data, np.ndarray):
90
+ meta["shape"] = data.shape
91
+ meta["dtype"] = str(data.dtype)
92
+ return meta
93
+
94
+
95
+ # ── Public API ────────────────────────────────────────────────────────────────
96
+
97
+
98
+ def scan_directory(
99
+ folder_path: str | Path,
100
+ extensions: list[str] | None = None,
101
+ pattern: str | None = None,
102
+ ) -> list[Path]:
103
+ """Scan a directory and return sorted matching file paths.
104
+
105
+ Args:
106
+ folder_path: Path to the directory to scan.
107
+ extensions: List of file extensions to include, e.g. ``['csv', 'xlsx']``.
108
+ Leading dots are optional. Pass ``None`` (default) to accept all
109
+ EaglePlot-supported formats.
110
+ pattern: Optional ``fnmatch``-style filename filter, e.g.
111
+ ``'sample_*.csv'``. Applied after the extension filter.
112
+
113
+ Returns:
114
+ Sorted list of ``Path`` objects for every matching file.
115
+
116
+ Raises:
117
+ NotADirectoryError: If ``folder_path`` is not a directory.
118
+ """
119
+ folder = Path(folder_path)
120
+ if not folder.is_dir():
121
+ raise NotADirectoryError(f"Not a directory: {folder}")
122
+
123
+ allowed_exts = _normalise_extensions(extensions)
124
+ if allowed_exts is None:
125
+ allowed_exts = _SUPPORTED_EXTENSIONS
126
+
127
+ results: list[Path] = []
128
+ for p in folder.iterdir():
129
+ if not p.is_file():
130
+ continue
131
+ if p.suffix.lower() not in allowed_exts:
132
+ continue
133
+ if pattern is not None and not fnmatch.fnmatch(p.name, pattern):
134
+ continue
135
+ results.append(p)
136
+
137
+ return sorted(results)
138
+
139
+
140
+ def batch_load_files(
141
+ file_paths: list[str | Path],
142
+ on_error: str = "skip",
143
+ ) -> dict:
144
+ """Load multiple files and return a structured result dict.
145
+
146
+ Args:
147
+ file_paths: Iterable of file paths to load.
148
+ on_error: What to do when a file fails to load.
149
+ ``'skip'`` silently records the error and continues;
150
+ ``'raise'`` re-raises the exception immediately.
151
+
152
+ Returns:
153
+ A dict with three keys:
154
+
155
+ - ``'datasets'``: ``{name: data}`` — successfully loaded objects.
156
+ - ``'errors'``: ``{name: error_message}`` — files that failed.
157
+ - ``'metadata'``: ``{name: {...}}`` — per-file metadata (source path,
158
+ size_bytes, suffix; plus shape/columns for DataFrames and
159
+ shape/dtype for ndarrays).
160
+
161
+ The *name* key is the bare filename (e.g. ``'data.csv'``).
162
+ """
163
+ if on_error not in ("skip", "raise"):
164
+ raise ValueError(f"on_error must be 'skip' or 'raise', got {on_error!r}")
165
+
166
+ datasets: dict[str, Any] = {}
167
+ errors: dict[str, str] = {}
168
+ metadata: dict[str, dict] = {}
169
+
170
+ for raw_path in file_paths:
171
+ path = Path(raw_path)
172
+ name = path.name
173
+ try:
174
+ data = load_data(str(path))
175
+ datasets[name] = data
176
+ metadata[name] = _file_metadata(path, data)
177
+ except Exception as exc: # noqa: BLE001
178
+ if on_error == "raise":
179
+ raise
180
+ errors[name] = str(exc)
181
+
182
+ return {"datasets": datasets, "errors": errors, "metadata": metadata}
183
+
184
+
185
+ def batch_statistics(
186
+ datasets: dict[str, Any],
187
+ columns: list[str] | None = None,
188
+ ) -> pd.DataFrame:
189
+ """Compute descriptive statistics across multiple datasets.
190
+
191
+ Only ``pd.DataFrame`` datasets are processed; others are silently skipped.
192
+
193
+ Args:
194
+ datasets: Mapping of dataset name to loaded data object.
195
+ columns: Column names to analyse. Pass ``None`` (default) to use all
196
+ numeric columns in each DataFrame.
197
+
198
+ Returns:
199
+ A ``pd.DataFrame`` with one row per (dataset, column) combination and
200
+ columns: ``dataset, column, n, mean, median, std, min, max, q1, q3,
201
+ iqr, range``. Returns an empty DataFrame with those columns if there
202
+ is nothing to compute.
203
+ """
204
+ _COLS = [
205
+ "dataset",
206
+ "column",
207
+ "n",
208
+ "mean",
209
+ "median",
210
+ "std",
211
+ "min",
212
+ "max",
213
+ "q1",
214
+ "q3",
215
+ "iqr",
216
+ "range",
217
+ ]
218
+ rows: list[dict] = []
219
+
220
+ for name, data in datasets.items():
221
+ if not isinstance(data, pd.DataFrame):
222
+ continue
223
+
224
+ df: pd.DataFrame = data
225
+ if columns is not None:
226
+ target_cols = [c for c in columns if c in df.columns]
227
+ else:
228
+ target_cols = list(df.select_dtypes(include="number").columns)
229
+
230
+ for col in target_cols:
231
+ series = df[col].dropna()
232
+ if series.empty:
233
+ continue
234
+ arr = series.to_numpy(dtype=float)
235
+ stats = calculate_statistics(arr)
236
+ rows.append(
237
+ {
238
+ "dataset": name,
239
+ "column": col,
240
+ "n": int(len(arr)),
241
+ "mean": stats["mean"],
242
+ "median": stats["median"],
243
+ "std": stats["std"],
244
+ "min": stats["min"],
245
+ "max": stats["max"],
246
+ "q1": stats["q1"],
247
+ "q3": stats["q3"],
248
+ "iqr": stats["iqr"],
249
+ "range": stats["range"],
250
+ }
251
+ )
252
+
253
+ if not rows:
254
+ return pd.DataFrame(columns=_COLS)
255
+ return pd.DataFrame(rows, columns=_COLS)
256
+
257
+
258
+ def batch_curve_fit(
259
+ datasets: dict[str, Any],
260
+ x_col: str,
261
+ y_col: str,
262
+ fit_type: str = "linear",
263
+ degree: int = 2,
264
+ ) -> pd.DataFrame:
265
+ """Fit a curve to x_col vs y_col across multiple datasets.
266
+
267
+ Only ``pd.DataFrame`` datasets that contain both ``x_col`` and ``y_col``
268
+ are processed; others are silently skipped.
269
+
270
+ Args:
271
+ datasets: Mapping of dataset name to loaded data object.
272
+ x_col: Name of the independent-variable column.
273
+ y_col: Name of the dependent-variable column.
274
+ fit_type: One of ``'linear'``, ``'polynomial'``, or
275
+ ``'exponential'``.
276
+ degree: Polynomial degree; only used when ``fit_type='polynomial'``.
277
+
278
+ Returns:
279
+ A ``pd.DataFrame`` with one row per dataset. Columns depend on
280
+ ``fit_type``:
281
+
282
+ - *linear*: ``dataset, slope, intercept, r_squared, p_value, error``
283
+ - *polynomial*: ``dataset, degree, r_squared, coeff_0 … coeff_N, error``
284
+ - *exponential*: ``dataset, a, b, c, r_squared, error``
285
+
286
+ The ``error`` column is ``None`` on success, or an error message string
287
+ on failure. Returns an empty ``pd.DataFrame`` if nothing could be
288
+ computed.
289
+ """
290
+ rows: list[dict] = []
291
+
292
+ for name, data in datasets.items():
293
+ if not isinstance(data, pd.DataFrame):
294
+ continue
295
+ df: pd.DataFrame = data
296
+ if x_col not in df.columns or y_col not in df.columns:
297
+ continue
298
+
299
+ sub = df[[x_col, y_col]].dropna()
300
+ x = sub[x_col].to_numpy(dtype=float)
301
+ y = sub[y_col].to_numpy(dtype=float)
302
+
303
+ row: dict[str, Any] = {"dataset": name, "error": None}
304
+
305
+ try:
306
+ if fit_type == "linear":
307
+ result = fit_linear(x, y)
308
+ row.update(
309
+ {
310
+ "slope": result["slope"],
311
+ "intercept": result["intercept"],
312
+ "r_squared": result["r_squared"],
313
+ "p_value": result["p_value"],
314
+ }
315
+ )
316
+ elif fit_type == "polynomial":
317
+ result = fit_polynomial(x, y, degree)
318
+ row["degree"] = degree
319
+ row["r_squared"] = result["r_squared"]
320
+ for i, coeff in enumerate(result["coefficients"]): # type: ignore[var-annotated,arg-type]
321
+ row[f"coeff_{i}"] = coeff
322
+ elif fit_type == "exponential":
323
+ result = fit_exponential(x, y)
324
+ row.update(
325
+ {
326
+ "a": result["a"],
327
+ "b": result["b"],
328
+ "c": result["c"],
329
+ "r_squared": result["r_squared"],
330
+ }
331
+ )
332
+ else:
333
+ row["error"] = f"Unknown fit_type: {fit_type!r}"
334
+ except Exception as exc: # noqa: BLE001
335
+ row["error"] = str(exc)
336
+
337
+ rows.append(row)
338
+
339
+ if not rows:
340
+ return pd.DataFrame()
341
+ return pd.DataFrame(rows)
342
+
343
+
344
+ def batch_peak_analysis(
345
+ datasets: dict[str, Any],
346
+ y_col: str,
347
+ x_col: str | None = None,
348
+ height: float | None = None,
349
+ prominence: float | None = None,
350
+ distance: int | None = None,
351
+ ) -> pd.DataFrame:
352
+ """Find peaks in y_col across multiple datasets.
353
+
354
+ Only ``pd.DataFrame`` datasets that contain ``y_col`` are processed;
355
+ others are silently skipped. If ``x_col`` is not given, integer row
356
+ indices are used as the x axis.
357
+
358
+ Args:
359
+ datasets: Mapping of dataset name to loaded data object.
360
+ y_col: Name of the column containing the signal.
361
+ x_col: Name of the x-axis column. Defaults to integer indices.
362
+ height: Minimum absolute peak height passed to ``find_peaks``.
363
+ prominence: Minimum peak prominence passed to ``find_peaks``.
364
+ distance: Minimum sample distance between peaks passed to
365
+ ``find_peaks``.
366
+
367
+ Returns:
368
+ A ``pd.DataFrame`` with columns: ``dataset, n_peaks,
369
+ peak_positions, mean_height, max_height, mean_fwhm, error``.
370
+
371
+ - ``peak_positions``: comma-separated string of x values.
372
+ - ``mean_fwhm``: ``NaN`` if FWHM could not be computed.
373
+ - ``error``: ``None`` on success, or an error message string.
374
+
375
+ Returns an empty ``pd.DataFrame`` with those columns if nothing could
376
+ be computed.
377
+ """
378
+ _COLS = [
379
+ "dataset",
380
+ "n_peaks",
381
+ "peak_positions",
382
+ "mean_height",
383
+ "max_height",
384
+ "mean_fwhm",
385
+ "error",
386
+ ]
387
+ rows: list[dict] = []
388
+
389
+ for name, data in datasets.items():
390
+ if not isinstance(data, pd.DataFrame):
391
+ continue
392
+ df: pd.DataFrame = data
393
+ if y_col not in df.columns:
394
+ continue
395
+
396
+ row: dict[str, Any] = {
397
+ "dataset": name,
398
+ "n_peaks": 0,
399
+ "peak_positions": "",
400
+ "mean_height": float("nan"),
401
+ "max_height": float("nan"),
402
+ "mean_fwhm": float("nan"),
403
+ "error": None,
404
+ }
405
+
406
+ try:
407
+ y = df[y_col].to_numpy(dtype=float)
408
+ if x_col is not None and x_col in df.columns:
409
+ x = df[x_col].to_numpy(dtype=float)
410
+ else:
411
+ x = np.arange(len(y), dtype=float)
412
+
413
+ peaks_dict = find_peaks(
414
+ y,
415
+ x=x,
416
+ height=height,
417
+ prominence=prominence,
418
+ distance=distance,
419
+ )
420
+
421
+ n = peaks_dict["n_peaks"]
422
+ row["n_peaks"] = n
423
+
424
+ if n > 0:
425
+ positions = peaks_dict["positions"]
426
+ heights = peaks_dict["heights"]
427
+ row["peak_positions"] = ", ".join(f"{p:.6g}" for p in positions)
428
+ row["mean_height"] = float(np.mean(heights))
429
+ row["max_height"] = float(np.max(heights))
430
+
431
+ try:
432
+ fwhm_dict = compute_fwhm(y, x, peaks_dict)
433
+ fwhm_vals = fwhm_dict["fwhm"]
434
+ if len(fwhm_vals) > 0:
435
+ row["mean_fwhm"] = float(np.mean(fwhm_vals))
436
+ except Exception: # noqa: BLE001
437
+ pass # mean_fwhm stays NaN
438
+
439
+ except Exception as exc: # noqa: BLE001
440
+ row["error"] = str(exc)
441
+
442
+ rows.append(row)
443
+
444
+ if not rows:
445
+ return pd.DataFrame(columns=_COLS)
446
+ return pd.DataFrame(rows, columns=_COLS)