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,535 @@
1
+ """Session State Management for Streamlit GUI.
2
+
3
+ This module provides centralized session state management to persist data,
4
+ plots, and analysis results across page navigation in the Streamlit multi-page app.
5
+
6
+ Functions
7
+ ---------
8
+ initialize_session_state()
9
+ Initialize all session state variables with defaults
10
+ add_dataset(name, data, metadata=None)
11
+ Add a dataset to session state
12
+ get_current_dataset()
13
+ Retrieve the currently selected dataset
14
+ get_dataset(name)
15
+ Retrieve a specific dataset by name
16
+ delete_dataset(name)
17
+ Remove a dataset from session state
18
+ add_plot_to_history(plot_data)
19
+ Add a generated plot to history
20
+ clear_plot_history()
21
+ Remove all plots from history
22
+ add_analysis_result(result_data)
23
+ Add analysis result to session state
24
+ save_session_to_file(filepath)
25
+ Persist session state to JSON file
26
+ load_session_from_file(filepath)
27
+ Restore session state from JSON file
28
+ clear_session()
29
+ Reset all session state to defaults
30
+
31
+ Examples
32
+ --------
33
+ >>> import streamlit as st
34
+ >>> from eagleplot.utils.session_state import initialize_session_state, add_dataset
35
+ >>> initialize_session_state()
36
+ >>> data = np.array([1, 2, 3, 4, 5])
37
+ >>> add_dataset('test_data.npy', data)
38
+ >>> dataset = get_current_dataset()
39
+ """
40
+
41
+ import io
42
+ import streamlit as st
43
+ import json
44
+ import base64
45
+ from datetime import datetime
46
+ from pathlib import Path
47
+ from typing import Any, Dict, List, Optional
48
+ import numpy as np
49
+ import pandas as pd
50
+
51
+
52
+ def initialize_session_state():
53
+ """Initialize all session state variables with default values.
54
+
55
+ This should be called once at the start of the app (in gui.py).
56
+ Creates the following state variables:
57
+ - datasets: Dict[str, Any] - Loaded datasets keyed by filename
58
+ - current_dataset: str | None - Name of currently selected dataset
59
+ - plot_history: List[Dict] - History of generated plots
60
+ - analysis_results: List[Dict] - Results from mathematical operations
61
+ - plot_config: Dict - Current plot configuration settings
62
+ - export_queue: List[Dict] - Items queued for export
63
+ """
64
+ if "datasets" not in st.session_state:
65
+ st.session_state.datasets = {}
66
+
67
+ if "dataset_metadata" not in st.session_state:
68
+ st.session_state.dataset_metadata = {}
69
+
70
+ if "current_dataset" not in st.session_state:
71
+ st.session_state.current_dataset = None
72
+
73
+ if "plot_history" not in st.session_state:
74
+ st.session_state.plot_history = []
75
+
76
+ if "analysis_results" not in st.session_state:
77
+ st.session_state.analysis_results = []
78
+
79
+ if "plot_config" not in st.session_state:
80
+ st.session_state.plot_config = {
81
+ "default_figsize": (8, 6),
82
+ "default_dpi": 100,
83
+ "default_style": "default",
84
+ }
85
+
86
+ if "export_queue" not in st.session_state:
87
+ st.session_state.export_queue = []
88
+
89
+ if "mol_vib_data" not in st.session_state:
90
+ st.session_state.mol_vib_data = None
91
+
92
+
93
+ def add_dataset(name: str, data: Any, metadata: Optional[Dict] = None):
94
+ """Add a dataset to session state.
95
+
96
+ Parameters
97
+ ----------
98
+ name : str
99
+ Dataset name (usually filename)
100
+ data : Any
101
+ Dataset (DataFrame, ndarray, dict, etc.)
102
+ metadata : dict, optional
103
+ Additional metadata (file size, upload time, etc.)
104
+ """
105
+ st.session_state.datasets[name] = data
106
+
107
+ # Store metadata
108
+ if metadata is None:
109
+ metadata = {}
110
+
111
+ metadata["added_time"] = datetime.now().isoformat()
112
+ metadata["data_type"] = type(data).__name__
113
+
114
+ # Add shape/size info
115
+ if isinstance(data, pd.DataFrame):
116
+ metadata["shape"] = data.shape
117
+ metadata["columns"] = list(data.columns)
118
+ metadata["dtypes"] = {col: str(dtype) for col, dtype in data.dtypes.items()}
119
+ elif isinstance(data, np.ndarray):
120
+ metadata["shape"] = data.shape
121
+ metadata["dtype"] = str(data.dtype)
122
+ elif isinstance(data, (list, tuple)):
123
+ metadata["length"] = len(data)
124
+
125
+ st.session_state.dataset_metadata[name] = metadata
126
+
127
+ # Set as current dataset if it's the first one
128
+ if st.session_state.current_dataset is None:
129
+ st.session_state.current_dataset = name
130
+
131
+
132
+ def get_current_dataset() -> Optional[Any]:
133
+ """Retrieve the currently selected dataset.
134
+
135
+ Returns
136
+ -------
137
+ data : Any or None
138
+ Current dataset, or None if no dataset is selected
139
+ """
140
+ if st.session_state.current_dataset is None:
141
+ return None
142
+
143
+ return st.session_state.datasets.get(st.session_state.current_dataset)
144
+
145
+
146
+ def get_dataset(name: str) -> Optional[Any]:
147
+ """Retrieve a specific dataset by name.
148
+
149
+ Parameters
150
+ ----------
151
+ name : str
152
+ Dataset name
153
+
154
+ Returns
155
+ -------
156
+ data : Any or None
157
+ Dataset if found, None otherwise
158
+ """
159
+ return st.session_state.datasets.get(name)
160
+
161
+
162
+ def delete_dataset(name: str) -> bool:
163
+ """Remove a dataset from session state.
164
+
165
+ Parameters
166
+ ----------
167
+ name : str
168
+ Dataset name to delete
169
+
170
+ Returns
171
+ -------
172
+ success : bool
173
+ True if dataset was deleted, False if not found
174
+ """
175
+ if name in st.session_state.datasets:
176
+ del st.session_state.datasets[name]
177
+ del st.session_state.dataset_metadata[name]
178
+
179
+ # Update current dataset if we deleted it
180
+ if st.session_state.current_dataset == name:
181
+ if st.session_state.datasets:
182
+ st.session_state.current_dataset = list(st.session_state.datasets.keys())[0]
183
+ else:
184
+ st.session_state.current_dataset = None
185
+
186
+ return True
187
+
188
+ return False
189
+
190
+
191
+ _MAX_PLOT_HISTORY = 50
192
+
193
+
194
+ def add_plot_to_history(plot_data: Dict):
195
+ """Add a generated plot to history (capped at ``_MAX_PLOT_HISTORY`` entries).
196
+
197
+ Parameters
198
+ ----------
199
+ plot_data : dict
200
+ Plot information with keys:
201
+ - type: Plot type (e.g., 'histogram', 'scatter')
202
+ - dataset: Dataset name used
203
+ - config: Plot configuration dict
204
+ - timestamp: When plot was created
205
+ - figure: Matplotlib/Plotly figure object (optional)
206
+
207
+ Notes
208
+ -----
209
+ Figure objects are stripped from older entries once the history exceeds
210
+ five entries to prevent unbounded memory growth.
211
+ """
212
+ if "timestamp" not in plot_data:
213
+ plot_data["timestamp"] = datetime.now().isoformat()
214
+
215
+ st.session_state.plot_history.append(plot_data)
216
+
217
+ # Drop figure objects from all but the 5 most recent entries.
218
+ history = st.session_state.plot_history
219
+ for entry in history[:-5]:
220
+ entry.pop("figure", None)
221
+
222
+ # Rolling cap — evict oldest entries when limit is exceeded.
223
+ if len(history) > _MAX_PLOT_HISTORY:
224
+ st.session_state.plot_history = history[-_MAX_PLOT_HISTORY:]
225
+
226
+
227
+ def clear_plot_history():
228
+ """Remove all plots from history."""
229
+ st.session_state.plot_history = []
230
+
231
+
232
+ def add_analysis_result(result_data: Dict):
233
+ """Add analysis result to session state.
234
+
235
+ Parameters
236
+ ----------
237
+ result_data : dict
238
+ Analysis result with keys:
239
+ - type: Analysis type (e.g., 'statistics', 'curve_fit')
240
+ - dataset: Dataset name used
241
+ - results: Analysis output dict
242
+ - timestamp: When analysis was performed
243
+ """
244
+ if "timestamp" not in result_data:
245
+ result_data["timestamp"] = datetime.now().isoformat()
246
+
247
+ st.session_state.analysis_results.append(result_data)
248
+
249
+
250
+ def _serialize_data(data: Any) -> Any:
251
+ """Serialize data for JSON storage.
252
+
253
+ Only types with a safe, self-describing encoding are serialized:
254
+ DataFrames (via ``to_json``), numeric/boolean ndarrays (via an explicit
255
+ dtype + shape + raw-buffer triple), JSON scalars, and lists/dicts of the
256
+ above. Anything else is replaced by an ``unsupported`` placeholder rather
257
+ than being pickled — see Notes.
258
+
259
+ Parameters
260
+ ----------
261
+ data : Any
262
+ Data to serialize
263
+
264
+ Returns
265
+ -------
266
+ serialized : Any
267
+ Serialized representation — a dict for tagged types, otherwise the
268
+ value itself.
269
+
270
+ Notes
271
+ -----
272
+ Session files are written and re-read through the GUI's Export page, which
273
+ accepts uploads. Any ``pickle``-based encoding would therefore make
274
+ "open a session file" equivalent to "execute arbitrary code", so pickle is
275
+ deliberately not used here. Object-dtype arrays are also refused, since
276
+ their contents cannot be represented without pickling.
277
+ """
278
+ if isinstance(data, pd.DataFrame):
279
+ return {"__type__": "DataFrame", "__data__": data.to_json(orient="split")}
280
+ elif isinstance(data, np.ndarray):
281
+ if data.dtype.hasobject:
282
+ return {
283
+ "__type__": "unsupported",
284
+ "__class__": f"numpy.ndarray[{data.dtype}]",
285
+ "__reason__": "object-dtype arrays cannot be serialized safely",
286
+ }
287
+ return {
288
+ "__type__": "ndarray",
289
+ "__dtype__": data.dtype.str,
290
+ "__shape__": list(data.shape),
291
+ "__data__": base64.b64encode(np.ascontiguousarray(data).tobytes()).decode("utf-8"),
292
+ }
293
+ elif isinstance(data, (int, float, str, bool, type(None))):
294
+ return data
295
+ elif isinstance(data, (list, tuple)):
296
+ return [_serialize_data(item) for item in data]
297
+ elif isinstance(data, dict):
298
+ return {key: _serialize_data(value) for key, value in data.items()}
299
+ else:
300
+ return {
301
+ "__type__": "unsupported",
302
+ "__class__": type(data).__name__,
303
+ "__reason__": "no safe JSON encoding for this type",
304
+ }
305
+
306
+
307
+ def _decode_ndarray(data: Dict) -> np.ndarray:
308
+ """Rebuild an ndarray from a serialized dtype/shape/buffer triple.
309
+
310
+ Parameters
311
+ ----------
312
+ data : dict
313
+ Mapping with ``__dtype__``, ``__shape__``, and ``__data__`` keys as
314
+ written by :func:`_serialize_data`.
315
+
316
+ Returns
317
+ -------
318
+ numpy.ndarray
319
+ A writable array with the recorded dtype and shape.
320
+
321
+ Raises
322
+ ------
323
+ ValueError
324
+ If the dtype is absent, is an object dtype, or if the decoded buffer
325
+ length does not match ``dtype.itemsize * prod(shape)``.
326
+ """
327
+ dtype_str = data.get("__dtype__")
328
+ if not dtype_str:
329
+ raise ValueError("ndarray entry is missing '__dtype__'")
330
+
331
+ dtype = np.dtype(dtype_str)
332
+ if dtype.hasobject:
333
+ raise ValueError(f"refusing to decode object dtype {dtype!r}")
334
+
335
+ shape = tuple(int(n) for n in data.get("__shape__", ()))
336
+ if any(n < 0 for n in shape):
337
+ raise ValueError(f"invalid shape {shape}")
338
+
339
+ buf = base64.b64decode(data["__data__"])
340
+ # np.frombuffer validates that the buffer is a whole number of items, but
341
+ # not that it matches `shape`; check explicitly so a truncated or padded
342
+ # file fails here rather than producing a silently wrong array.
343
+ expected = dtype.itemsize * int(np.prod(shape, dtype=np.int64)) if shape else dtype.itemsize
344
+ if len(buf) != expected:
345
+ raise ValueError(
346
+ f"ndarray buffer is {len(buf)} bytes; expected {expected} "
347
+ f"for dtype {dtype.str} and shape {shape}"
348
+ )
349
+
350
+ # frombuffer returns a read-only view onto `buf`; copy so callers can write.
351
+ return np.frombuffer(buf, dtype=dtype).reshape(shape).copy()
352
+
353
+
354
+ def _deserialize_data(data: Any, skipped: Optional[List[str]] = None) -> Any:
355
+ """Deserialize data from JSON storage.
356
+
357
+ Parameters
358
+ ----------
359
+ data : Any
360
+ Serialized data
361
+ skipped : list of str, optional
362
+ If given, a human-readable note is appended for every entry that could
363
+ not be restored. Callers use this to tell the user what was dropped.
364
+
365
+ Returns
366
+ -------
367
+ deserialized : Any
368
+ Deserialized data, or ``None`` for entries that could not be restored.
369
+
370
+ Notes
371
+ -----
372
+ Entries written by Plottle 2.0.1 and earlier used a pickle-based encoding
373
+ (``__type__`` of ``"pickled"``, and ``"ndarray"`` without a ``__dtype__``).
374
+ Those are refused rather than unpickled — see :func:`_serialize_data`.
375
+ """
376
+ if isinstance(data, dict):
377
+ kind = data.get("__type__")
378
+ if kind is None:
379
+ return {key: _deserialize_data(value, skipped) for key, value in data.items()}
380
+
381
+ if kind == "DataFrame":
382
+ return pd.read_json(io.StringIO(data["__data__"]), orient="split")
383
+
384
+ if kind == "ndarray":
385
+ if "__dtype__" not in data:
386
+ if skipped is not None:
387
+ skipped.append(
388
+ "an array saved by Plottle 2.0.1 or earlier "
389
+ "(pickle-encoded; refused for safety)"
390
+ )
391
+ return None
392
+ try:
393
+ return _decode_ndarray(data)
394
+ except (ValueError, TypeError) as exc:
395
+ if skipped is not None:
396
+ skipped.append(f"a corrupt array entry ({exc})")
397
+ return None
398
+
399
+ if kind == "pickled":
400
+ if skipped is not None:
401
+ skipped.append(
402
+ f"a pickled {data.get('__class__', 'object')} saved by Plottle 2.0.1 "
403
+ "or earlier (refused for safety)"
404
+ )
405
+ return None
406
+
407
+ if kind == "unsupported":
408
+ if skipped is not None:
409
+ skipped.append(
410
+ f"{data.get('__class__', 'an object')} "
411
+ f"({data.get('__reason__', 'unsupported type')})"
412
+ )
413
+ return None
414
+
415
+ if skipped is not None:
416
+ skipped.append(f"an entry of unrecognized type {kind!r}")
417
+ return None
418
+
419
+ elif isinstance(data, list):
420
+ return [_deserialize_data(item, skipped) for item in data]
421
+ else:
422
+ return data
423
+
424
+
425
+ def save_session_to_file(filepath: str):
426
+ """Persist session state to JSON file.
427
+
428
+ Parameters
429
+ ----------
430
+ filepath : str
431
+ Path to save session file
432
+
433
+ Notes
434
+ -----
435
+ This saves datasets, plot history, and analysis results.
436
+ Matplotlib figure objects are NOT saved (too large).
437
+ """
438
+ session_data = {
439
+ "version": "1.0",
440
+ "saved_at": datetime.now().isoformat(),
441
+ "datasets": {},
442
+ "dataset_metadata": st.session_state.dataset_metadata,
443
+ "current_dataset": st.session_state.current_dataset,
444
+ "plot_history": [
445
+ {k: v for k, v in plot.items() if k != "figure"}
446
+ for plot in st.session_state.plot_history
447
+ ],
448
+ "analysis_results": st.session_state.analysis_results,
449
+ "plot_config": st.session_state.plot_config,
450
+ }
451
+
452
+ # Serialize datasets
453
+ for name, data in st.session_state.datasets.items():
454
+ session_data["datasets"][name] = _serialize_data(data)
455
+
456
+ filepath = Path(filepath)
457
+ with open(filepath, "w") as f:
458
+ json.dump(session_data, f, indent=2)
459
+
460
+
461
+ def load_session_from_file(filepath: str) -> List[str]:
462
+ """Restore session state from JSON file.
463
+
464
+ Parameters
465
+ ----------
466
+ filepath : str
467
+ Path to session file
468
+
469
+ Returns
470
+ -------
471
+ skipped : list of str
472
+ One human-readable note per dataset that could not be restored — for
473
+ example entries saved by Plottle 2.0.1 or earlier, which used a
474
+ pickle-based encoding that is refused for safety. Empty when the whole
475
+ session was restored. Callers should surface this to the user.
476
+ """
477
+ filepath = Path(filepath)
478
+ with open(filepath, "r") as f:
479
+ session_data = json.load(f)
480
+
481
+ # Deserialize datasets
482
+ skipped: List[str] = []
483
+ st.session_state.datasets = {}
484
+ for name, data in session_data["datasets"].items():
485
+ notes: List[str] = []
486
+ restored = _deserialize_data(data, notes)
487
+ if restored is None and notes:
488
+ skipped.extend(f"{name}: {note}" for note in notes)
489
+ continue
490
+ skipped.extend(f"{name}: {note}" for note in notes)
491
+ st.session_state.datasets[name] = restored
492
+
493
+ st.session_state.dataset_metadata = session_data["dataset_metadata"]
494
+ st.session_state.current_dataset = session_data["current_dataset"]
495
+ st.session_state.plot_history = session_data["plot_history"]
496
+ st.session_state.analysis_results = session_data["analysis_results"]
497
+ st.session_state.plot_config = session_data["plot_config"]
498
+
499
+ # A dataset that was dropped must not stay selected.
500
+ if st.session_state.current_dataset not in st.session_state.datasets:
501
+ st.session_state.current_dataset = next(iter(st.session_state.datasets), None)
502
+
503
+ return skipped
504
+
505
+
506
+ def clear_session():
507
+ """Reset all session state to defaults."""
508
+ st.session_state.datasets = {}
509
+ st.session_state.dataset_metadata = {}
510
+ st.session_state.current_dataset = None
511
+ st.session_state.plot_history = []
512
+ st.session_state.analysis_results = []
513
+ st.session_state.plot_config = {
514
+ "default_figsize": (8, 6),
515
+ "default_dpi": 100,
516
+ "default_style": "default",
517
+ }
518
+ st.session_state.export_queue = []
519
+
520
+
521
+ def get_session_summary() -> Dict:
522
+ """Get a summary of current session state.
523
+
524
+ Returns
525
+ -------
526
+ summary : dict
527
+ Summary with counts and current selections
528
+ """
529
+ return {
530
+ "num_datasets": len(st.session_state.datasets),
531
+ "current_dataset": st.session_state.current_dataset,
532
+ "num_plots": len(st.session_state.plot_history),
533
+ "num_analyses": len(st.session_state.analysis_results),
534
+ "dataset_names": list(st.session_state.datasets.keys()),
535
+ }