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/io.py ADDED
@@ -0,0 +1,1008 @@
1
+ """Input/Output Module.
2
+
3
+ This module provides utilities for reading and writing scientific data
4
+ in various formats commonly used in computational chemistry.
5
+
6
+ Supported Formats
7
+ -----------------
8
+ - Pickle (.pkl): Python object serialization
9
+ - NumPy arrays (.npy, .npz): Numerical array data
10
+ - Pandas DataFrames (.csv, .xlsx, .tsv, .json, .parquet): Tabular data
11
+ - JCAMP-DX (.jdx, .dx): Spectroscopy data (IR, Raman, NMR, MS) — requires ``jcamp``
12
+ - HDF5 (.h5, .hdf5): Hierarchical data arrays — requires ``h5py``
13
+ - NetCDF (.nc, .cdf): Array/climate data — requires ``xarray``
14
+ - SPC (.spc): Thermo Fisher spectroscopy — requires ``spc``
15
+ - ASC (.asc): Generic instrument text export (no extra dependency)
16
+
17
+ Examples
18
+ --------
19
+ >>> from eagleplot.io import load_data, save_data
20
+ >>> data = load_data('experiment.pkl')
21
+ >>> save_data(processed_data, 'results.pkl')
22
+ >>> spectrum = load_data('spectrum.jdx') # returns pd.DataFrame
23
+ >>> arrays = load_data('simulation.h5') # returns dict of np.ndarray
24
+ """
25
+
26
+ import pickle
27
+ import numpy as np
28
+ import pandas as pd
29
+ from pathlib import Path
30
+ from typing import Any, Union
31
+
32
+ # ============================================================================
33
+ # Pickle Operations
34
+ # ============================================================================
35
+
36
+
37
+ def load_pickle(filepath: Union[str, Path]) -> Any:
38
+ """Load data from a pickle file.
39
+
40
+ Parameters
41
+ ----------
42
+ filepath : str or Path
43
+ Path to the pickle file (.pkl)
44
+
45
+ Returns
46
+ -------
47
+ data : Any
48
+ The deserialized Python object
49
+
50
+ Raises
51
+ ------
52
+ FileNotFoundError
53
+ If the file does not exist
54
+ pickle.UnpicklingError
55
+ If the file is corrupted or not a valid pickle file
56
+
57
+ Examples
58
+ --------
59
+ >>> data = load_pickle('experiment.pkl')
60
+ >>> print(type(data))
61
+ <class 'dict'>
62
+ """
63
+ filepath = Path(filepath)
64
+
65
+ if not filepath.exists():
66
+ raise FileNotFoundError(f"File not found: {filepath}")
67
+
68
+ try:
69
+ with open(filepath, "rb") as f:
70
+ data = pickle.load(f)
71
+ return data
72
+ except pickle.UnpicklingError as e:
73
+ raise pickle.UnpicklingError(
74
+ f"Failed to unpickle file {filepath}. "
75
+ f"The file may be corrupted or not a valid pickle file."
76
+ ) from e
77
+
78
+
79
+ def save_pickle(data: Any, filepath: Union[str, Path]) -> None:
80
+ """Save data to a pickle file.
81
+
82
+ Parameters
83
+ ----------
84
+ data : Any
85
+ Python object to serialize
86
+ filepath : str or Path
87
+ Output file path (.pkl)
88
+
89
+ Raises
90
+ ------
91
+ PermissionError
92
+ If there are insufficient permissions to write to the file
93
+ pickle.PicklingError
94
+ If the object cannot be pickled
95
+
96
+ Examples
97
+ --------
98
+ >>> data = {'temperature': [300, 310, 320], 'pressure': [1.0, 1.1, 1.2]}
99
+ >>> save_pickle(data, 'results.pkl')
100
+ """
101
+ filepath = Path(filepath)
102
+
103
+ # Create parent directories if they don't exist
104
+ filepath.parent.mkdir(parents=True, exist_ok=True)
105
+
106
+ try:
107
+ with open(filepath, "wb") as f:
108
+ pickle.dump(data, f)
109
+ except pickle.PicklingError as e:
110
+ raise pickle.PicklingError(
111
+ f"Failed to pickle object of type {type(data).__name__}. "
112
+ f"Ensure the object is serializable."
113
+ ) from e
114
+
115
+
116
+ # ============================================================================
117
+ # NumPy Array Operations
118
+ # ============================================================================
119
+
120
+
121
+ def load_numpy(filepath: Union[str, Path]) -> Union[np.ndarray, dict]:
122
+ """Load NumPy array(s) from file.
123
+
124
+ Supports both .npy (single array) and .npz (multiple arrays) formats.
125
+
126
+ Parameters
127
+ ----------
128
+ filepath : str or Path
129
+ Path to NumPy file (.npy or .npz)
130
+
131
+ Returns
132
+ -------
133
+ data : np.ndarray or dict
134
+ For .npy files: returns a single NumPy array
135
+ For .npz files: returns a dict-like object with array names as keys
136
+
137
+ Raises
138
+ ------
139
+ FileNotFoundError
140
+ If the file does not exist
141
+ ValueError
142
+ If the file format is not supported
143
+
144
+ Examples
145
+ --------
146
+ >>> array = load_numpy('data.npy')
147
+ >>> print(array.shape)
148
+ (100, 3)
149
+
150
+ >>> arrays = load_numpy('multiple.npz')
151
+ >>> print(arrays['x'].shape)
152
+ (100,)
153
+ """
154
+ filepath = Path(filepath)
155
+
156
+ if not filepath.exists():
157
+ raise FileNotFoundError(f"File not found: {filepath}")
158
+
159
+ suffix = filepath.suffix.lower()
160
+
161
+ if suffix == ".npy":
162
+ return np.load(filepath)
163
+ elif suffix == ".npz":
164
+ return np.load(filepath)
165
+ else:
166
+ raise ValueError(f"Unsupported file format: {suffix}. Expected .npy or .npz")
167
+
168
+
169
+ def save_numpy(
170
+ data: Union[np.ndarray, dict], filepath: Union[str, Path], compressed: bool = False
171
+ ) -> None:
172
+ """Save NumPy array(s) to file.
173
+
174
+ Parameters
175
+ ----------
176
+ data : np.ndarray or dict
177
+ For .npy: single NumPy array
178
+ For .npz: dict of arrays with names as keys
179
+ filepath : str or Path
180
+ Output file path (.npy or .npz)
181
+ compressed : bool, default False
182
+ If True and saving .npz, use compression
183
+
184
+ Raises
185
+ ------
186
+ ValueError
187
+ If data type doesn't match file format
188
+ TypeError
189
+ If data is not a NumPy array or dict of arrays
190
+
191
+ Examples
192
+ --------
193
+ >>> array = np.random.rand(100, 3)
194
+ >>> save_numpy(array, 'data.npy')
195
+
196
+ >>> arrays = {'x': np.arange(10), 'y': np.arange(10)**2}
197
+ >>> save_numpy(arrays, 'multiple.npz')
198
+ """
199
+ filepath = Path(filepath)
200
+ filepath.parent.mkdir(parents=True, exist_ok=True)
201
+
202
+ suffix = filepath.suffix.lower()
203
+
204
+ if suffix == ".npy":
205
+ if not isinstance(data, np.ndarray):
206
+ raise TypeError(
207
+ f"For .npy format, data must be a NumPy array, got {type(data).__name__}"
208
+ )
209
+ np.save(filepath, data)
210
+
211
+ elif suffix == ".npz":
212
+ if isinstance(data, dict):
213
+ if compressed:
214
+ np.savez_compressed(filepath, **data)
215
+ else:
216
+ np.savez(filepath, **data)
217
+ elif isinstance(data, np.ndarray):
218
+ # Save single array with default name
219
+ if compressed:
220
+ np.savez_compressed(filepath, arr_0=data)
221
+ else:
222
+ np.savez(filepath, arr_0=data)
223
+ else:
224
+ raise TypeError(
225
+ f"For .npz format, data must be a NumPy array or dict of arrays, "
226
+ f"got {type(data).__name__}"
227
+ )
228
+
229
+ else:
230
+ raise ValueError(f"Unsupported file format: {suffix}. Expected .npy or .npz")
231
+
232
+
233
+ # ============================================================================
234
+ # Pandas DataFrame Operations
235
+ # ============================================================================
236
+
237
+
238
+ def load_dataframe(filepath: Union[str, Path], **kwargs) -> pd.DataFrame:
239
+ """Load tabular data into a Pandas DataFrame.
240
+
241
+ Automatically detects format from file extension.
242
+ Supports CSV, Excel, and other Pandas-compatible formats.
243
+
244
+ Parameters
245
+ ----------
246
+ filepath : str or Path
247
+ Path to data file (.csv, .xlsx, .xls, etc.)
248
+ kwargs : dict
249
+ Additional keyword arguments passed to pandas read function
250
+
251
+ Returns
252
+ -------
253
+ df : pd.DataFrame
254
+ Loaded DataFrame
255
+
256
+ Raises
257
+ ------
258
+ FileNotFoundError
259
+ If the file does not exist
260
+ ValueError
261
+ If the file format is not supported
262
+
263
+ Examples
264
+ --------
265
+ >>> df = load_dataframe('data.csv')
266
+ >>> print(df.head())
267
+
268
+ >>> df = load_dataframe('data.xlsx', sheet_name='Sheet1')
269
+ """
270
+ filepath = Path(filepath)
271
+
272
+ if not filepath.exists():
273
+ raise FileNotFoundError(f"File not found: {filepath}")
274
+
275
+ suffix = filepath.suffix.lower()
276
+
277
+ if suffix == ".csv":
278
+ return pd.read_csv(filepath, **kwargs)
279
+ elif suffix in [".xlsx", ".xls"]:
280
+ return pd.read_excel(filepath, **kwargs)
281
+ elif suffix == ".tsv":
282
+ return pd.read_csv(filepath, sep="\t", **kwargs)
283
+ elif suffix == ".json":
284
+ return pd.read_json(filepath, **kwargs)
285
+ elif suffix == ".parquet":
286
+ return pd.read_parquet(filepath, **kwargs)
287
+ else:
288
+ raise ValueError(
289
+ f"Unsupported file format: {suffix}. "
290
+ f"Supported formats: .csv, .xlsx, .xls, .tsv, .json, .parquet"
291
+ )
292
+
293
+
294
+ def save_dataframe(
295
+ df: pd.DataFrame, filepath: Union[str, Path], index: bool = False, **kwargs
296
+ ) -> None:
297
+ """Save Pandas DataFrame to file.
298
+
299
+ Automatically selects format based on file extension.
300
+
301
+ Parameters
302
+ ----------
303
+ df : pd.DataFrame
304
+ DataFrame to save
305
+ filepath : str or Path
306
+ Output file path
307
+ index : bool, default False
308
+ Whether to include the index in the output
309
+ kwargs : dict
310
+ Additional keyword arguments passed to pandas write function
311
+
312
+ Raises
313
+ ------
314
+ ValueError
315
+ If the file format is not supported
316
+ TypeError
317
+ If df is not a Pandas DataFrame
318
+
319
+ Examples
320
+ --------
321
+ >>> df = pd.DataFrame({'x': [1, 2, 3], 'y': [4, 5, 6]})
322
+ >>> save_dataframe(df, 'output.csv')
323
+
324
+ >>> save_dataframe(df, 'output.xlsx', sheet_name='Results')
325
+ """
326
+ if not isinstance(df, pd.DataFrame):
327
+ raise TypeError(f"Expected pandas DataFrame, got {type(df).__name__}")
328
+
329
+ filepath = Path(filepath)
330
+ filepath.parent.mkdir(parents=True, exist_ok=True)
331
+
332
+ suffix = filepath.suffix.lower()
333
+
334
+ if suffix == ".csv":
335
+ df.to_csv(filepath, index=index, **kwargs)
336
+ elif suffix in [".xlsx", ".xls"]:
337
+ df.to_excel(filepath, index=index, **kwargs)
338
+ elif suffix == ".tsv":
339
+ df.to_csv(filepath, sep="\t", index=index, **kwargs)
340
+ elif suffix == ".json":
341
+ df.to_json(filepath, **kwargs)
342
+ elif suffix == ".parquet":
343
+ df.to_parquet(filepath, index=index, **kwargs)
344
+ else:
345
+ raise ValueError(
346
+ f"Unsupported file format: {suffix}. "
347
+ f"Supported formats: .csv, .xlsx, .xls, .tsv, .json, .parquet"
348
+ )
349
+
350
+
351
+ # ============================================================================
352
+ # Instrument File Formats (M17)
353
+ # ============================================================================
354
+
355
+
356
+ def load_jcamp(filepath: Union[str, Path]) -> pd.DataFrame:
357
+ """Load a JCAMP-DX spectroscopy file (.jdx or .dx).
358
+
359
+ Requires the ``jcamp`` package: ``pip install jcamp``
360
+
361
+ Returns a :class:`pandas.DataFrame` with columns ``x`` and ``y``.
362
+ Spectral metadata (title, data type, units) is stored in ``df.attrs``.
363
+
364
+ Parameters
365
+ ----------
366
+ filepath : str or Path
367
+ Path to the JCAMP-DX file (.jdx or .dx)
368
+
369
+ Returns
370
+ -------
371
+ df : pd.DataFrame
372
+ Two-column DataFrame: ``x`` (frequency/wavenumber axis) and
373
+ ``y`` (intensity/absorbance/transmittance). Metadata available
374
+ via ``df.attrs``.
375
+
376
+ Raises
377
+ ------
378
+ ImportError
379
+ If the ``jcamp`` package is not installed
380
+ FileNotFoundError
381
+ If the file does not exist
382
+
383
+ Examples
384
+ --------
385
+ >>> df = load_jcamp('spectrum.jdx')
386
+ >>> print(df.columns.tolist())
387
+ ['x', 'y']
388
+ >>> print(df.attrs.get('xunits'))
389
+ 1/CM
390
+ """
391
+ try:
392
+ from jcamp import JCAMP_reader as _jcamp_read
393
+ except ImportError:
394
+ try:
395
+ from jcamp import jcamp_read as _jcamp_read # type: ignore[no-redef]
396
+ except ImportError:
397
+ raise ImportError(
398
+ "The `jcamp` package is required to load JCAMP-DX files. "
399
+ "Install it with: pip install jcamp"
400
+ )
401
+
402
+ filepath = Path(filepath)
403
+ if not filepath.exists():
404
+ raise FileNotFoundError(f"File not found: {filepath}")
405
+
406
+ data = _jcamp_read(str(filepath))
407
+
408
+ x = np.asarray(data.get("x", []), dtype=float)
409
+ y = np.asarray(data.get("y", []), dtype=float)
410
+
411
+ df = pd.DataFrame({"x": x, "y": y})
412
+
413
+ for key in ("title", "data type", "xunits", "yunits", "origin", "owner"):
414
+ if key in data:
415
+ df.attrs[key] = data[key]
416
+
417
+ return df
418
+
419
+
420
+ def load_hdf5(
421
+ filepath: Union[str, Path], dataset: Union[str, None] = None
422
+ ) -> Union[dict, np.ndarray]:
423
+ """Load an HDF5 file (.h5 or .hdf5).
424
+
425
+ Requires the ``h5py`` package: ``pip install h5py``
426
+
427
+ By default returns a flat ``dict`` mapping every dataset path to a
428
+ :class:`numpy.ndarray`. If *dataset* is given, returns just that
429
+ single array.
430
+
431
+ Parameters
432
+ ----------
433
+ filepath : str or Path
434
+ Path to the HDF5 file
435
+ dataset : str, optional
436
+ Slash-separated path to a specific dataset inside the file
437
+ (e.g. ``"group/signal"``). When omitted all datasets are
438
+ returned.
439
+
440
+ Returns
441
+ -------
442
+ data : dict[str, np.ndarray] or np.ndarray
443
+ All datasets as a flat dict, or the requested single array.
444
+
445
+ Raises
446
+ ------
447
+ ImportError
448
+ If ``h5py`` is not installed
449
+ FileNotFoundError
450
+ If the file does not exist
451
+ KeyError
452
+ If the requested *dataset* path does not exist in the file
453
+
454
+ Examples
455
+ --------
456
+ >>> arrays = load_hdf5('simulation.h5')
457
+ >>> print(list(arrays.keys()))
458
+ ['time', 'temperature', 'pressure']
459
+
460
+ >>> arr = load_hdf5('simulation.h5', dataset='temperature')
461
+ >>> print(arr.shape)
462
+ (1000,)
463
+ """
464
+ try:
465
+ import h5py
466
+ except ImportError:
467
+ raise ImportError(
468
+ "The `h5py` package is required to load HDF5 files. Install it with: pip install h5py"
469
+ )
470
+
471
+ filepath = Path(filepath)
472
+ if not filepath.exists():
473
+ raise FileNotFoundError(f"File not found: {filepath}")
474
+
475
+ def _flatten(group: "h5py.Group", prefix: str = "") -> dict:
476
+ result: dict = {}
477
+ for key, item in group.items():
478
+ full = f"{prefix}/{key}" if prefix else key
479
+ if isinstance(item, h5py.Dataset):
480
+ result[full] = np.array(item)
481
+ elif isinstance(item, h5py.Group):
482
+ result.update(_flatten(item, full))
483
+ return result
484
+
485
+ with h5py.File(filepath, "r") as f:
486
+ if dataset is not None:
487
+ if dataset not in f:
488
+ raise KeyError(f"Dataset '{dataset}' not found in {filepath}")
489
+ return np.array(f[dataset])
490
+ return _flatten(f)
491
+
492
+
493
+ def load_netcdf(filepath: Union[str, Path]) -> dict:
494
+ """Load a NetCDF file (.nc or .cdf).
495
+
496
+ Requires the ``xarray`` package: ``pip install xarray netcdf4``
497
+
498
+ Returns a ``dict`` mapping variable names to :class:`numpy.ndarray`
499
+ values. Coordinate arrays are included alongside data variables.
500
+
501
+ Parameters
502
+ ----------
503
+ filepath : str or Path
504
+ Path to the NetCDF file
505
+
506
+ Returns
507
+ -------
508
+ data : dict[str, np.ndarray]
509
+ All data variables and coordinates as numpy arrays.
510
+
511
+ Raises
512
+ ------
513
+ ImportError
514
+ If ``xarray`` (or its NetCDF engine) is not installed
515
+ FileNotFoundError
516
+ If the file does not exist
517
+
518
+ Examples
519
+ --------
520
+ >>> data = load_netcdf('climate.nc')
521
+ >>> print(list(data.keys()))
522
+ ['time', 'lat', 'lon', 'temperature']
523
+ """
524
+ try:
525
+ import xarray as xr
526
+ except ImportError:
527
+ raise ImportError(
528
+ "The `xarray` package is required to load NetCDF files. "
529
+ "Install it with: pip install xarray netcdf4"
530
+ )
531
+
532
+ filepath = Path(filepath)
533
+ if not filepath.exists():
534
+ raise FileNotFoundError(f"File not found: {filepath}")
535
+
536
+ ds = xr.open_dataset(str(filepath))
537
+ result: dict = {}
538
+ for name, var in ds.data_vars.items():
539
+ result[str(name)] = np.array(var.values)
540
+ for name, coord in ds.coords.items():
541
+ if str(name) not in result:
542
+ result[str(name)] = np.array(coord.values)
543
+ ds.close()
544
+ return result
545
+
546
+
547
+ def load_spc(filepath: Union[str, Path]) -> pd.DataFrame:
548
+ """Load a Thermo Fisher SPC spectroscopy file (.spc).
549
+
550
+ Requires the ``spc`` package: ``pip install spc``
551
+
552
+ Returns a :class:`pandas.DataFrame` with a ``wavenumber`` column
553
+ and one ``intensity`` column per trace (``intensity_0``,
554
+ ``intensity_1``, … for multi-trace files).
555
+
556
+ Parameters
557
+ ----------
558
+ filepath : str or Path
559
+ Path to the SPC file
560
+
561
+ Returns
562
+ -------
563
+ df : pd.DataFrame
564
+ Spectral data. Single-trace files have columns
565
+ ``['wavenumber', 'intensity']``; multi-trace files add a
566
+ numeric suffix to each intensity column.
567
+
568
+ Raises
569
+ ------
570
+ ImportError
571
+ If the ``spc`` package is not installed
572
+ FileNotFoundError
573
+ If the file does not exist
574
+
575
+ Examples
576
+ --------
577
+ >>> df = load_spc('raman.spc')
578
+ >>> print(df.columns.tolist())
579
+ ['wavenumber', 'intensity']
580
+ """
581
+ try:
582
+ import spc # type: ignore[import-untyped]
583
+ except ImportError:
584
+ raise ImportError(
585
+ "The `spc` package is required to load SPC files. Install it with: pip install spc"
586
+ )
587
+
588
+ filepath = Path(filepath)
589
+ if not filepath.exists():
590
+ raise FileNotFoundError(f"File not found: {filepath}")
591
+
592
+ f = spc.File(str(filepath))
593
+
594
+ # Shared x-axis (may live on the file or on each sub-file)
595
+ shared_x = np.asarray(f.x) if hasattr(f, "x") and f.x is not None else None
596
+
597
+ columns: dict = {}
598
+ for i, sub in enumerate(f.sub):
599
+ x_data = np.asarray(sub.x) if (hasattr(sub, "x") and sub.x is not None) else shared_x
600
+ y_data = np.asarray(sub.y)
601
+
602
+ if i == 0 and x_data is not None:
603
+ columns["wavenumber"] = x_data
604
+
605
+ suffix = f"_{i}" if len(f.sub) > 1 else ""
606
+ columns[f"intensity{suffix}"] = y_data
607
+
608
+ return pd.DataFrame(columns)
609
+
610
+
611
+ def load_asc(filepath: Union[str, Path]) -> pd.DataFrame:
612
+ """Load a generic instrument ASC / plain-text export file (.asc).
613
+
614
+ Handles whitespace-, comma-, and tab-delimited files. Lines
615
+ starting with ``#`` are treated as comments. If the first
616
+ non-comment line contains only numeric tokens the file is assumed
617
+ to have no header row and columns are named ``col_0``, ``col_1``,
618
+ etc. Otherwise the first line is used as a header.
619
+
620
+ No extra package is required beyond Pandas.
621
+
622
+ Parameters
623
+ ----------
624
+ filepath : str or Path
625
+ Path to the ASC or plain-text file
626
+
627
+ Returns
628
+ -------
629
+ df : pd.DataFrame
630
+ Parsed tabular data.
631
+
632
+ Raises
633
+ ------
634
+ FileNotFoundError
635
+ If the file does not exist
636
+ ValueError
637
+ If the file cannot be parsed as delimited text
638
+
639
+ Examples
640
+ --------
641
+ >>> df = load_asc('spectrum.asc')
642
+ >>> print(df.shape)
643
+ (512, 2)
644
+ """
645
+ filepath = Path(filepath)
646
+ if not filepath.exists():
647
+ raise FileNotFoundError(f"File not found: {filepath}")
648
+
649
+ # Detect whether the first non-comment line is a header
650
+ first_data_line = ""
651
+ with open(filepath, encoding="utf-8", errors="replace") as fh:
652
+ for line in fh:
653
+ stripped = line.strip()
654
+ if stripped and not stripped.startswith("#"):
655
+ first_data_line = stripped
656
+ break
657
+
658
+ parts = first_data_line.replace(",", " ").replace("\t", " ").split()
659
+ try:
660
+ [float(p) for p in parts if p]
661
+ header_row: Union[int, None] = None # all numeric → no header
662
+ except ValueError:
663
+ header_row = 0 # first line is a header
664
+
665
+ try:
666
+ df = pd.read_csv(
667
+ filepath,
668
+ sep=None,
669
+ comment="#",
670
+ header=header_row,
671
+ engine="python",
672
+ skip_blank_lines=True,
673
+ )
674
+ except Exception as exc:
675
+ raise ValueError(f"Could not parse {filepath} as delimited text: {exc}") from exc
676
+
677
+ if header_row is None:
678
+ df.columns = [f"col_{i}" for i in range(df.shape[1])]
679
+
680
+ return df
681
+
682
+
683
+ def load_mzml(filepath: Union[str, Path]) -> "pd.DataFrame":
684
+ """Load a mass spectrometry mzML or mzXML file.
685
+
686
+ Requires the ``pymzml`` package (``pip install pymzml``).
687
+ Returns a summary DataFrame with one row per scan.
688
+
689
+ Parameters
690
+ ----------
691
+ filepath : str
692
+ Path to a ``.mzML`` or ``.mzXML`` file.
693
+
694
+ Returns
695
+ -------
696
+ pd.DataFrame
697
+ Columns: ``scan``, ``rt`` (s), ``ms_level``, ``tic``,
698
+ ``base_peak_mz``, ``base_peak_intensity``.
699
+ Raw (mz, intensity) arrays are available via
700
+ ``df.attrs["spectra"]`` — a list of dicts with keys
701
+ ``scan``, ``mz``, ``intensity``.
702
+ """
703
+ try:
704
+ import pymzml
705
+ except ImportError as exc:
706
+ raise ImportError(
707
+ "pymzml is required for mzML/mzXML files. Install it with: pip install pymzml"
708
+ ) from exc
709
+
710
+ rows = []
711
+ spectra = []
712
+ run = pymzml.run.Reader(str(filepath))
713
+ for scan_idx, spectrum in enumerate(run, start=1):
714
+ mz_arr = np.asarray(spectrum.mz, dtype=float)
715
+ int_arr = np.asarray(spectrum.i, dtype=float)
716
+ if hasattr(spectrum, "scan_time_in_minutes"):
717
+ rt = float(spectrum.scan_time_in_minutes() * 60.0)
718
+ else:
719
+ rt = float(spectrum.get("MS:1000016", 0.0))
720
+ ms_level = int(spectrum.ms_level) if spectrum.ms_level else 1
721
+ tic = float(int_arr.sum()) if len(int_arr) else 0.0
722
+ if len(int_arr) > 0:
723
+ bp_idx = int(np.argmax(int_arr))
724
+ bp_mz = float(mz_arr[bp_idx])
725
+ bp_int = float(int_arr[bp_idx])
726
+ else:
727
+ bp_mz, bp_int = float("nan"), 0.0
728
+ rows.append(
729
+ {
730
+ "scan": scan_idx,
731
+ "rt": rt,
732
+ "ms_level": ms_level,
733
+ "tic": tic,
734
+ "base_peak_mz": bp_mz,
735
+ "base_peak_intensity": bp_int,
736
+ }
737
+ )
738
+ spectra.append({"scan": scan_idx, "mz": mz_arr, "intensity": int_arr})
739
+
740
+ df = pd.DataFrame(rows)
741
+ df.attrs["spectra"] = spectra
742
+ df.attrs["source_file"] = str(filepath)
743
+ return df
744
+
745
+
746
+ # ============================================================================
747
+ # Universal Data Loader/Saver
748
+ # ============================================================================
749
+
750
+
751
+ def load_data(filepath: Union[str, Path], **kwargs) -> Any:
752
+ """Universal data loader with automatic format detection.
753
+
754
+ Automatically detects the file format from extension and uses
755
+ the appropriate loading function.
756
+
757
+ Supported formats:
758
+ - .pkl: Pickle files
759
+ - .npy, .npz: NumPy arrays
760
+ - .csv, .xlsx, .xls, .tsv, .json, .parquet: Tabular data (Pandas)
761
+ - .jdx, .dx: JCAMP-DX spectroscopy (requires ``jcamp``)
762
+ - .h5, .hdf5: HDF5 arrays (requires ``h5py``)
763
+ - .nc, .cdf: NetCDF (requires ``xarray``)
764
+ - .spc: Thermo Fisher SPC (requires ``spc``)
765
+ - .asc: Generic instrument text export
766
+
767
+ Parameters
768
+ ----------
769
+ filepath : str or Path
770
+ Path to data file
771
+ kwargs : dict
772
+ Additional arguments passed to format-specific loader
773
+
774
+ Returns
775
+ -------
776
+ data : Any
777
+ Loaded data in appropriate format
778
+
779
+ Raises
780
+ ------
781
+ FileNotFoundError
782
+ If the file does not exist
783
+ ValueError
784
+ If the file format is not supported
785
+
786
+ Examples
787
+ --------
788
+ >>> data = load_data('experiment.pkl')
789
+ >>> array = load_data('data.npy')
790
+ >>> df = load_data('results.csv')
791
+ >>> df = load_data('spectrum.jdx')
792
+ >>> arrays = load_data('simulation.h5')
793
+ """
794
+ filepath = Path(filepath)
795
+
796
+ if not filepath.exists():
797
+ raise FileNotFoundError(f"File not found: {filepath}")
798
+
799
+ suffix = filepath.suffix.lower()
800
+
801
+ # Pickle files
802
+ if suffix == ".pkl":
803
+ return load_pickle(filepath)
804
+
805
+ # NumPy files
806
+ elif suffix in [".npy", ".npz"]:
807
+ return load_numpy(filepath)
808
+
809
+ # Tabular data files
810
+ elif suffix in [".csv", ".xlsx", ".xls", ".tsv", ".json", ".parquet"]:
811
+ return load_dataframe(filepath, **kwargs)
812
+
813
+ # JCAMP-DX spectroscopy
814
+ elif suffix in [".jdx", ".dx"]:
815
+ return load_jcamp(filepath)
816
+
817
+ # HDF5
818
+ elif suffix in [".h5", ".hdf5"]:
819
+ return load_hdf5(filepath, **kwargs)
820
+
821
+ # NetCDF
822
+ elif suffix in [".nc", ".cdf"]:
823
+ return load_netcdf(filepath)
824
+
825
+ # Thermo SPC
826
+ elif suffix == ".spc":
827
+ return load_spc(filepath)
828
+
829
+ # Generic instrument text
830
+ elif suffix == ".asc":
831
+ return load_asc(filepath)
832
+
833
+ # mzML / mzXML mass spectrometry
834
+ elif suffix in [".mzml", ".mzxml"]:
835
+ return load_mzml(filepath)
836
+
837
+ else:
838
+ raise ValueError(
839
+ f"Unsupported file format: {suffix}. "
840
+ f"Supported formats: .pkl, .npy, .npz, .csv, .xlsx, .xls, "
841
+ f".tsv, .json, .parquet, .jdx, .dx, .h5, .hdf5, .nc, .cdf, "
842
+ f".spc, .asc, .mzml, .mzxml"
843
+ )
844
+
845
+
846
+ def save_data(data: Any, filepath: Union[str, Path], **kwargs) -> None:
847
+ """Universal data saver with automatic format detection.
848
+
849
+ Automatically detects the target format from file extension and uses
850
+ the appropriate saving function.
851
+
852
+ Parameters
853
+ ----------
854
+ data : Any
855
+ Data to save (Python object, NumPy array, or Pandas DataFrame)
856
+ filepath : str or Path
857
+ Output file path
858
+ kwargs : dict
859
+ Additional arguments passed to format-specific saver
860
+
861
+ Raises
862
+ ------
863
+ ValueError
864
+ If the file format is not supported or data type incompatible
865
+
866
+ Examples
867
+ --------
868
+ >>> save_data({'temp': 300, 'pressure': 1.0}, 'params.pkl')
869
+ >>> save_data(np.array([1, 2, 3]), 'data.npy')
870
+ >>> save_data(df, 'results.csv')
871
+ """
872
+ filepath = Path(filepath)
873
+ suffix = filepath.suffix.lower()
874
+
875
+ # Pickle files - accept any object
876
+ if suffix == ".pkl":
877
+ save_pickle(data, filepath)
878
+
879
+ # NumPy files - require NumPy array or dict of arrays
880
+ elif suffix in [".npy", ".npz"]:
881
+ if isinstance(data, (np.ndarray, dict)):
882
+ save_numpy(data, filepath, **kwargs)
883
+ else:
884
+ raise TypeError(
885
+ f"For {suffix} format, data must be NumPy array or dict of arrays, "
886
+ f"got {type(data).__name__}"
887
+ )
888
+
889
+ # Tabular data - require Pandas DataFrame
890
+ elif suffix in [".csv", ".xlsx", ".xls", ".tsv", ".json", ".parquet"]:
891
+ if isinstance(data, pd.DataFrame):
892
+ save_dataframe(data, filepath, **kwargs)
893
+ else:
894
+ raise TypeError(
895
+ f"For {suffix} format, data must be pandas DataFrame, got {type(data).__name__}"
896
+ )
897
+
898
+ else:
899
+ raise ValueError(
900
+ f"Unsupported file format: {suffix}. "
901
+ f"Supported formats: .pkl, .npy, .npz, .csv, .xlsx, .xls, "
902
+ f".tsv, .json, .parquet"
903
+ )
904
+
905
+
906
+ # ============================================================================
907
+ # Large Dataset Utilities
908
+ # ============================================================================
909
+
910
+
911
+ def downsample_for_preview(
912
+ data: pd.DataFrame,
913
+ max_rows: int = 10_000,
914
+ method: str = "systematic",
915
+ ) -> pd.DataFrame:
916
+ """Downsample a DataFrame to at most *max_rows* rows for GUI performance.
917
+
918
+ The original DataFrame is returned unchanged when it already fits within
919
+ the limit, so callers can use the result unconditionally.
920
+
921
+ Parameters
922
+ ----------
923
+ data : pd.DataFrame
924
+ Input DataFrame.
925
+ max_rows : int
926
+ Row threshold above which downsampling is applied. Default 10 000.
927
+ method : {'systematic', 'random'}
928
+ ``'systematic'`` selects every N-th row (preserves data shape).
929
+ ``'random'`` draws a random sample (uniform coverage).
930
+
931
+ Returns
932
+ -------
933
+ pd.DataFrame
934
+ Downsampled (or original) DataFrame.
935
+
936
+ Examples
937
+ --------
938
+ >>> import pandas as pd, numpy as np
939
+ >>> large = pd.DataFrame({'x': np.arange(50_000)})
940
+ >>> small = downsample_for_preview(large, max_rows=1000)
941
+ >>> len(small) <= 1000
942
+ True
943
+ """
944
+ if not isinstance(data, pd.DataFrame) or len(data) <= max_rows:
945
+ return data
946
+
947
+ if method == "random":
948
+ return data.sample(n=max_rows, random_state=42).reset_index(drop=True)
949
+
950
+ # systematic: every step-th row
951
+ step = max(1, len(data) // max_rows)
952
+ return data.iloc[::step].reset_index(drop=True)
953
+
954
+
955
+ def load_large_csv(
956
+ filepath: Union[str, Path],
957
+ max_rows: int = 500_000,
958
+ chunksize: int = 50_000,
959
+ **kwargs,
960
+ ) -> pd.DataFrame:
961
+ """Load a large CSV in chunks, stopping once *max_rows* rows are read.
962
+
963
+ Useful for very large files where ``pd.read_csv`` would exhaust memory.
964
+ For files where all rows are needed, use :func:`load_data` directly.
965
+
966
+ Parameters
967
+ ----------
968
+ filepath : str or Path
969
+ Path to the CSV file.
970
+ max_rows : int
971
+ Maximum rows to load. Default 500 000.
972
+ chunksize : int
973
+ Rows per chunk. Default 50 000.
974
+ kwargs
975
+ Extra keyword arguments forwarded to :func:`pandas.read_csv`.
976
+
977
+ Returns
978
+ -------
979
+ pd.DataFrame
980
+ Concatenated DataFrame of at most *max_rows* rows.
981
+
982
+ Examples
983
+ --------
984
+ >>> df = load_large_csv('huge.csv', max_rows=100_000)
985
+ >>> len(df) <= 100_000
986
+ True
987
+ """
988
+ filepath = Path(filepath)
989
+ if not filepath.exists():
990
+ raise FileNotFoundError(f"File not found: {filepath}")
991
+
992
+ chunks = []
993
+ rows_read = 0
994
+ reader = pd.read_csv(filepath, chunksize=chunksize, **kwargs)
995
+ for chunk in reader:
996
+ remaining = max_rows - rows_read
997
+ if remaining <= 0:
998
+ break
999
+ if len(chunk) > remaining:
1000
+ chunk = chunk.iloc[:remaining]
1001
+ chunks.append(chunk)
1002
+ rows_read += len(chunk)
1003
+ if rows_read >= max_rows:
1004
+ break
1005
+
1006
+ if not chunks:
1007
+ return pd.DataFrame()
1008
+ return pd.concat(chunks, ignore_index=True)