pyplotutil 2.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.
pyplotutil/datautil.py ADDED
@@ -0,0 +1,1295 @@
1
+ """Data Handling and Manipulation Module.
2
+
3
+ This module provides classes for managing and manipulating tabular data, with functionalities to
4
+ load data from various sources, group data structure by specified tags, and access columns or rows
5
+ with intuitive syntax. The primary classes, `Data`, `TaggedData`, and `Dataset`, facilitate working with
6
+ tabular data in polars DataFrame while allowing access to specific features like data grouping,
7
+ dynamic attribute setting, and easy retrieval of parameter values.
8
+
9
+ Classes
10
+ -------
11
+ BaseData : Abstract base class providing the core attributes and methods for data handling.
12
+ Defines basic properties for data path and DataFrame storage.
13
+
14
+ Data : Extends BaseData to represent a single tabular data.
15
+ Provides methods to access columns and retrieve specific parameters.
16
+
17
+ TaggedData : Extends BaseData to handle grouped data based on a specified tag column.
18
+ Allows grouping data by a tag and accessing each group as a separate `Data` object.
19
+
20
+ Dataset : Collection of multiple Data objects with analysis capabilities.
21
+ Manages multiple data files and provides methods for cross-data analysis and comparison.
22
+
23
+ Examples
24
+ --------
25
+ Basic usage:
26
+ >>> data = Data("data.csv")
27
+ >>> print(data.param("column_name"))
28
+
29
+ Tagged data usage:
30
+ >>> tagged_data = TaggedData("data.csv", tag="tag")
31
+ >>> data = tagged_data.get("specific_tag")
32
+ >>> print(group.param("column_name"))
33
+
34
+ Dataset usage:
35
+ >>> dataset = Dataset(["data1.csv", "data2.csv"])
36
+ >>> t, y = dataset.get_timeseries("value_column")
37
+
38
+ This module is designed to streamline operations with tabular data in data analysis, data plotting,
39
+ and other applications requiring structured data handling.
40
+
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import warnings
46
+ from collections import OrderedDict
47
+ from collections.abc import Callable, Iterable, Mapping, Sequence
48
+ from functools import cached_property, partial
49
+ from io import StringIO
50
+ from pathlib import Path
51
+ from typing import TYPE_CHECKING, TextIO, overload
52
+
53
+ import numpy as np
54
+ import polars as pl
55
+ from polars.exceptions import ColumnNotFoundError
56
+
57
+ from pyplotutil._typing import (
58
+ FilePath,
59
+ MultiColSelector,
60
+ MultiIndexSelector,
61
+ NoDefault,
62
+ SingleColSelector,
63
+ SingleIndexSelector,
64
+ Unknown,
65
+ no_default,
66
+ )
67
+
68
+ if TYPE_CHECKING:
69
+ from collections.abc import ItemsView, Iterator, KeysView
70
+
71
+ from pyplotutil._typing import DataSourceType
72
+
73
+
74
+ _ERR_MSG_NOT_LOADED_FROM_FILE = "Data object may not be loaded from a file."
75
+ _ERR_MSG_PATH_NOT_EXIST = "Source path does not exist"
76
+ _WARN_MSG_CLONE_NOT_LOADED_FROM_FILE = "clone: Source Data object may not be loaded from a file."
77
+ _WARN_MSG_NO_FILES_FOUND = "No files found with the following glob pattern"
78
+
79
+
80
+ class BaseData:
81
+ """Base class for data handling and manipulation.
82
+
83
+ This class provides functionalities for setting and retrieving the path and the main DataFrame
84
+ associated with the data.
85
+
86
+ """
87
+
88
+ _datapath: Path
89
+ _dataframe: pl.DataFrame
90
+
91
+ def __init__(
92
+ self,
93
+ data_source: DataSourceType,
94
+ *,
95
+ separator: str,
96
+ has_header: bool,
97
+ columns: Sequence[int] | Sequence[str] | None,
98
+ names: Sequence[str] | None,
99
+ n_rows: int | None,
100
+ comment: str | None,
101
+ ) -> None:
102
+ """Initialize the BaseData object with the provided data source.
103
+
104
+ Parameters
105
+ ----------
106
+ data_source : str | Path | StringIO | pl.DataFrame | pl.Series
107
+ The data source.
108
+ separator : str
109
+ Single byte character to use as separator in the source, by default ",".
110
+ has_header : bool, optional
111
+ Indicate if the first row of the dataset is a header or not, by default True.
112
+ columns : Sequence[int], Sequence[str] or range, optional
113
+ Columns to read from the data source.
114
+ names : Sequence[str], optional
115
+ Rename columns right after parsing the source.
116
+ n_rows : int, optional
117
+ Number of rows to read.
118
+ comment : str, optional
119
+ Character to indicate comments in the data source.
120
+
121
+ Raises
122
+ ------
123
+ TypeError
124
+ If the data type is unsupported.
125
+
126
+ """
127
+ if isinstance(data_source, pl.DataFrame):
128
+ self._set_dataframe(data_source)
129
+ elif isinstance(data_source, pl.Series):
130
+ self._set_dataframe(data_source.to_frame())
131
+ elif isinstance(data_source, StringIO | FilePath):
132
+ self._set_dataframe(
133
+ self.read_csv(
134
+ data_source,
135
+ separator=separator,
136
+ has_header=has_header,
137
+ columns=columns,
138
+ names=names,
139
+ n_rows=n_rows,
140
+ comment=comment,
141
+ ),
142
+ )
143
+ if isinstance(data_source, FilePath):
144
+ self.set_datapath(data_source)
145
+ else:
146
+ msg = f"Unsupported data source type: {type(data_source)}"
147
+ raise TypeError(msg)
148
+
149
+ @staticmethod
150
+ def read_commented_column_names(
151
+ file_or_buffer: FilePath | StringIO,
152
+ *,
153
+ separator: str,
154
+ comment: str,
155
+ ) -> list[str] | None:
156
+ """Return a list of column names extracted from commented lines in the file.
157
+
158
+ Parameters
159
+ ----------
160
+ file_or_buffer : str | Path | StringIO
161
+ The file or buffer containing the data.
162
+ separator : str
163
+ Single byte character to use as separator in the file.
164
+ comment : str
165
+ Character indicating commented lines.
166
+
167
+ Returns
168
+ -------
169
+ list of str or None
170
+ List of column names if found; otherwise, None.
171
+
172
+ """
173
+
174
+ def last_commented_header(buffer: TextIO, comment: str) -> str:
175
+ header = ""
176
+ for line in buffer:
177
+ if line.startswith(comment):
178
+ header = line
179
+ else:
180
+ break
181
+ return header
182
+
183
+ if isinstance(file_or_buffer, FilePath):
184
+ with Path(file_or_buffer).open() as f:
185
+ header = last_commented_header(f, comment)
186
+ else:
187
+ header = last_commented_header(file_or_buffer, comment)
188
+ if len(header) > 0:
189
+ n = len(comment)
190
+ names = header[n:].strip().split(separator)
191
+ if len(names) == 1 and names[0] == "":
192
+ names = []
193
+ return names
194
+ return None
195
+
196
+ @staticmethod
197
+ def read_csv(
198
+ file_or_buffer: FilePath | StringIO,
199
+ *,
200
+ separator: str,
201
+ has_header: bool,
202
+ columns: Sequence[int] | Sequence[str] | None,
203
+ names: Sequence[str] | None,
204
+ n_rows: int | None,
205
+ comment: str | None,
206
+ ) -> pl.DataFrame:
207
+ """Return a polars DataFrame loaded from a file or string buffer.
208
+
209
+ Parameters
210
+ ----------
211
+ file_or_buffer : str | Path | StringIO
212
+ The file or buffer to read from.
213
+ separator : str
214
+ Single byte character to use as separator in the source.
215
+ has_header : bool
216
+ Indicate if the first row of the dataset is a header or not.
217
+ columns : Sequence[int], Sequence[str] or range
218
+ Columns to read from the data source.
219
+ names : Sequence[str]
220
+ Rename columns right after parsing the source.
221
+ n_rows : int
222
+ Number of rows to read.
223
+ comment : str
224
+ Character to indicate comments in the data source.
225
+
226
+ Returns
227
+ -------
228
+ pl.DataFrame
229
+ The loaded DataFrame.
230
+
231
+ """
232
+ if comment is not None and names is None:
233
+ names = BaseData.read_commented_column_names(file_or_buffer, separator=separator, comment=comment)
234
+ if names is not None:
235
+ has_header = False
236
+ if isinstance(file_or_buffer, StringIO):
237
+ file_or_buffer.seek(0)
238
+ return pl.read_csv(
239
+ file_or_buffer,
240
+ has_header=has_header,
241
+ columns=columns,
242
+ new_columns=names,
243
+ separator=separator,
244
+ comment_prefix=comment,
245
+ n_rows=n_rows,
246
+ rechunk=True,
247
+ )
248
+
249
+ def _set_dataframe(self, dataframe: pl.DataFrame) -> None:
250
+ """Set the DataFrame associated with the data object.
251
+
252
+ Parameters
253
+ ----------
254
+ dataframe : pl.DataFrame
255
+ The DataFrame to associate with the data.
256
+
257
+ """
258
+ self._dataframe = dataframe
259
+
260
+ @property
261
+ def dataframe(self) -> pl.DataFrame:
262
+ """Retrieve the raw DataFrame associated with the data.
263
+
264
+ Returns
265
+ -------
266
+ pl.DataFrame
267
+ The DataFrame associated with the data.
268
+
269
+ """
270
+ return self._dataframe
271
+
272
+ @property
273
+ def df(self) -> pl.DataFrame:
274
+ """Alias for `dataframe` attribute.
275
+
276
+ Returns
277
+ -------
278
+ pl.DataFrame
279
+ The DataFrame associated with the data.
280
+
281
+ """
282
+ return self.dataframe
283
+
284
+ def is_loaded_from_file(self) -> bool:
285
+ """Check if the Data object was loaded from a file.
286
+
287
+ Returns
288
+ -------
289
+ bool
290
+ True if loaded from a file; otherwise, False.
291
+
292
+ """
293
+ try:
294
+ _ = self._datapath
295
+ except AttributeError:
296
+ return False
297
+ return True
298
+
299
+ def set_datapath(self, datapath: str | Path) -> None:
300
+ """Set the path to the data file.
301
+
302
+ Parameters
303
+ ----------
304
+ datapath : str or Path
305
+ Path to the data file.
306
+
307
+ """
308
+ self._datapath = Path(datapath)
309
+
310
+ @property
311
+ def datapath(self) -> Path:
312
+ """Retrieve the path to the data file.
313
+
314
+ Returns
315
+ -------
316
+ Path
317
+ Path to the data file.
318
+
319
+ """
320
+ try:
321
+ return self._datapath
322
+ except AttributeError as e:
323
+ msg = _ERR_MSG_NOT_LOADED_FROM_FILE
324
+ raise AttributeError(msg) from e
325
+
326
+ @property
327
+ def datadir(self) -> Path:
328
+ """Retrieve the directory of the data file.
329
+
330
+ Returns
331
+ -------
332
+ Path
333
+ Directory of the data file.
334
+
335
+ """
336
+ return self.datapath.parent
337
+
338
+ def __str__(self) -> str:
339
+ """Return a string of the associated DataFrame.
340
+
341
+ Returns
342
+ -------
343
+ str
344
+ String representation of the associated DataFrame object.
345
+
346
+ """
347
+ return str(self.dataframe)
348
+
349
+ def __repr__(self) -> str:
350
+ """Return a string representation of the object.
351
+
352
+ Returns
353
+ -------
354
+ str
355
+ String representation of the object.
356
+
357
+ """
358
+ if self.is_loaded_from_file():
359
+ return f"{self.__class__.__name__}({self.datapath})"
360
+ return f"{self.__class__.__name__}({self.dataframe})"
361
+
362
+
363
+ class Data(BaseData):
364
+ """A class representing tabular data loaded from a source.
365
+
366
+ This class provides methods for easy data access and specific data parameter extraction. When
367
+ the source data contains column names, this class provides attributes for access the data column
368
+ with its name.
369
+
370
+ Attributes
371
+ ----------
372
+ dataframe : pl.DataFrame
373
+ The DataFrame containing the tabular data.
374
+ datapath : Path
375
+ The path to the data file.
376
+ datadir : Path
377
+ The directory of the data file
378
+
379
+ Examples
380
+ --------
381
+ Initialization of the data object and access for column data with its name.
382
+
383
+ >>> import polars as pl
384
+ >>> data = Data(pl.DataFrame({"a": [1, 2, 3], "b": [0.1, 0.2, 0.3], "c": [5, 5, 5]}))
385
+ >>> data.a
386
+ 0 1
387
+ 1 2
388
+ 2 3
389
+ Name: a, dtype: int64
390
+ >>> data.param("c")
391
+ np.int64(5)
392
+
393
+ """
394
+
395
+ def __init__(
396
+ self,
397
+ data_source: DataSourceType,
398
+ *,
399
+ separator: str = ",",
400
+ has_header: bool = True,
401
+ columns: Sequence[int] | Sequence[str] | None = None,
402
+ names: Sequence[str] | None = None,
403
+ n_rows: int | None = None,
404
+ comment: str | None = None,
405
+ ) -> None:
406
+ """Initialize the BaseData object with the provided data source.
407
+
408
+ Parameters
409
+ ----------
410
+ data_source : str | Path | StringIO | pl.DataFrame | pl.Series
411
+ The data source.
412
+ separator : str
413
+ Single byte character to use as separator in the source, by default ",".
414
+ has_header : bool, optional
415
+ Indicate if the first row of the dataset is a header or not, by default True.
416
+ columns : Sequence[int], Sequence[str] or range, optional
417
+ Columns to read from the data source.
418
+ names : Sequence[str], optional
419
+ Rename columns right after parsing the source.
420
+ n_rows : int, optional
421
+ Number of rows to read.
422
+ comment : str, optional
423
+ Character to indicate comments in the data source.
424
+
425
+ Raises
426
+ ------
427
+ TypeError
428
+ If the data type is unsupported.
429
+
430
+ """
431
+ super().__init__(
432
+ data_source,
433
+ separator=separator,
434
+ has_header=has_header,
435
+ columns=columns,
436
+ names=names,
437
+ n_rows=n_rows,
438
+ comment=comment,
439
+ )
440
+
441
+ @overload
442
+ def __getitem__(self, key: tuple[SingleIndexSelector, SingleColSelector]) -> Unknown: ...
443
+
444
+ @overload
445
+ def __getitem__( # type: ignore[overload-overlap]
446
+ self,
447
+ key: str | tuple[MultiIndexSelector, SingleColSelector],
448
+ ) -> pl.Series: ...
449
+
450
+ @overload
451
+ def __getitem__(
452
+ self,
453
+ key: (
454
+ SingleIndexSelector
455
+ | MultiIndexSelector
456
+ | MultiColSelector
457
+ | tuple[SingleIndexSelector, MultiColSelector]
458
+ | tuple[MultiIndexSelector, MultiColSelector]
459
+ ),
460
+ ) -> pl.DataFrame: ...
461
+
462
+ def __getitem__(
463
+ self,
464
+ key: (
465
+ SingleIndexSelector
466
+ | SingleColSelector
467
+ | MultiColSelector
468
+ | MultiIndexSelector
469
+ | tuple[SingleIndexSelector, SingleColSelector]
470
+ | tuple[SingleIndexSelector, MultiColSelector]
471
+ | tuple[MultiIndexSelector, SingleColSelector]
472
+ | tuple[MultiIndexSelector, MultiColSelector]
473
+ ),
474
+ ) -> pl.DataFrame | pl.Series | Unknown:
475
+ """Access a specific column(s).
476
+
477
+ Parameters
478
+ ----------
479
+ key : str or int or Sequence of str or int
480
+ Column name or column index.
481
+
482
+ Returns
483
+ -------
484
+ pl.Series or pl.DataFrame
485
+ Series or frame of the specified column(s).
486
+
487
+ """
488
+ return self.dataframe.__getitem__(key)
489
+
490
+ def __len__(self) -> int:
491
+ """Return the number of rows in the `Data` object.
492
+
493
+ Returns
494
+ -------
495
+ int
496
+ Number of rows in the `Data` object.
497
+
498
+ """
499
+ return len(self.dataframe)
500
+
501
+ def __getattr__(self, name: str) -> Unknown:
502
+ """Access DataFrame attributes not explicitly defined in Data.
503
+
504
+ Parameters
505
+ ----------
506
+ name : str
507
+ Attribute name.
508
+
509
+ Returns
510
+ -------
511
+ Unknown
512
+ The attribute from the DataFrame.
513
+
514
+ """
515
+ if name in ("datapath", "datadir"):
516
+ return self.__getattribute__(name)
517
+ if name in self.dataframe.columns:
518
+ return self.dataframe.get_column(name)
519
+ return getattr(self.dataframe, name)
520
+
521
+ def __iter__(self) -> Iterator[np.ndarray]:
522
+ """Return an iterator over the Data objects.
523
+
524
+ Returns
525
+ -------
526
+ Iterator[np.ndarray]
527
+ An iterator over the Data objects.
528
+
529
+ """
530
+ return iter(self.dataframe.to_numpy())
531
+
532
+ def split_by_row(self, row_index: int) -> tuple[Data, Data]:
533
+ """Split the Data object into two parts at a specified row index.
534
+
535
+ Parameters
536
+ ----------
537
+ row_index : int
538
+ The index at which to split the data object. Rows from the start up to
539
+ `row_index` will go to the first split, and rows from `row_index` to
540
+ the end will go to the second split.
541
+
542
+ Returns
543
+ -------
544
+ tuple[Data, Data]
545
+ A tuple containing two Data objects. The first contains rows from the
546
+ start to `row_index`, and the second contains rows from `row_index`
547
+ to the end.
548
+
549
+ """
550
+ df1 = self.dataframe[:row_index]
551
+ df2 = self.dataframe[row_index:]
552
+ return Data(df1), Data(df2)
553
+
554
+ @overload
555
+ def param(self, key: int | str) -> float: ...
556
+
557
+ @overload
558
+ def param(self, key: Sequence) -> tuple[float, ...]: ...
559
+
560
+ def param(self, key):
561
+ """Retrieve specific parameter(s) for column(s) as float.
562
+
563
+ Parameters
564
+ ----------
565
+ key : int or str or Sequence of int or str
566
+ The column(s) for which to retrieve the parameter.
567
+
568
+ Returns
569
+ -------
570
+ float or tuple[float, ...]
571
+ Retrieved parameter value(s).
572
+
573
+ """
574
+ subset = self.dataframe[:, key]
575
+ if isinstance(subset, pl.Series):
576
+ return subset.cast(pl.Float64)[0]
577
+ return subset.cast(pl.Float64).row(0)
578
+
579
+ def _copy_datapath_if_loaded_from_file(self, dest_data: Data) -> Data:
580
+ """Copy datapath from source to destination Data object if source was loaded from file.
581
+
582
+ Parameters
583
+ ----------
584
+ dest_data : Data
585
+ Destination Data object to copy datapath to
586
+
587
+ Returns
588
+ -------
589
+ Data
590
+ Data object with copied datapath if applicable
591
+
592
+ Warns
593
+ -----
594
+ UserWarning
595
+ If source data was not loaded from file
596
+
597
+ """
598
+ try:
599
+ dest_data.set_datapath(self.datapath)
600
+ except AttributeError as e:
601
+ if _ERR_MSG_NOT_LOADED_FROM_FILE in str(e):
602
+ msg = _WARN_MSG_CLONE_NOT_LOADED_FROM_FILE
603
+ warnings.warn(msg, UserWarning, stacklevel=2)
604
+ else:
605
+ raise AttributeError(str(e)) from e
606
+ return dest_data
607
+
608
+ def clone(
609
+ self,
610
+ *,
611
+ rename_mapping: dict[str, str] | Sequence[str] | None = None,
612
+ keep_datapath: bool = False,
613
+ ) -> Data:
614
+ """Create a clone of the current Data object.
615
+
616
+ Parameters
617
+ ----------
618
+ rename_mapping : dict[str, str] | Sequence[str] | None, optional
619
+ Mapping to rename columns in the cloned data. If sequence, maps existing column names to
620
+ new names in order.
621
+ keep_datapath : bool, optional
622
+ Whether to preserve the datapath in the cloned object, by default False.
623
+
624
+ Returns
625
+ -------
626
+ Data
627
+ A new Data object with cloned content.
628
+
629
+ """
630
+ if isinstance(rename_mapping, Mapping):
631
+ cloned_df = self.df.rename(rename_mapping)
632
+ elif isinstance(rename_mapping, Sequence):
633
+ cloned_df = self.df.rename(dict(zip(self.df.columns, rename_mapping, strict=True)))
634
+ else:
635
+ cloned_df = self.df.clone()
636
+ cloned_data = Data(cloned_df)
637
+ if keep_datapath:
638
+ cloned_data = self._copy_datapath_if_loaded_from_file(cloned_data)
639
+ return cloned_data
640
+
641
+ def subset(
642
+ self,
643
+ key: Sequence[str],
644
+ *,
645
+ start: int | None = None,
646
+ end: int | None = None,
647
+ rename_mapping: dict[str, str] | Sequence[str] | None = None,
648
+ keep_datapath: bool = False,
649
+ ) -> Data:
650
+ """Create a subset of the current Data object.
651
+
652
+ Parameters
653
+ ----------
654
+ key : Sequence[str]
655
+ Column names to include in subset
656
+ start : int | None, optional
657
+ Start index for row selection, by default None.
658
+ end : int | None, optional
659
+ End index for row selection, by default None.
660
+ rename_mapping : dict[str, str] | Sequence[str] | None, optional
661
+ Mapping to rename columns in subset. If sequence, maps existing column names to new
662
+ names in order.
663
+ keep_datapath : bool, optional
664
+ Whether to preserve the datapath in subset, by default False.
665
+
666
+ Returns
667
+ -------
668
+ Data
669
+ A new Data object containing the specified subset.
670
+
671
+ """
672
+ subset_df = self[start:end, key]
673
+ if isinstance(rename_mapping, Mapping):
674
+ subset_df = subset_df.rename(rename_mapping)
675
+ elif isinstance(rename_mapping, Sequence):
676
+ subset_df = subset_df.rename(dict(zip(subset_df.columns, rename_mapping, strict=True)))
677
+ subset_data = Data(subset_df)
678
+ if keep_datapath:
679
+ subset_data = self._copy_datapath_if_loaded_from_file(subset_data)
680
+ return subset_data
681
+
682
+
683
+ class TaggedData(BaseData):
684
+ """A class for handling data grouped by a specified tag.
685
+
686
+ This class provides methods to load and access data grouped by a tag, enabling
687
+ easy access to each group's data as individual `Data` objects.
688
+
689
+ Attributes
690
+ ----------
691
+ dataframe : pl.DataFrame
692
+ The DataFrame containing the tabular data with tags.
693
+ datadict : dict of str to Data
694
+ A dictionary mapping each tag value to a corresponding `Data` object.
695
+
696
+ """
697
+
698
+ _datadict: dict[str, Data]
699
+ _tag_column_name: str
700
+
701
+ def __init__(
702
+ self,
703
+ data_source: DataSourceType,
704
+ *,
705
+ separator: str = ",",
706
+ has_header: bool = True,
707
+ columns: Sequence[int] | Sequence[str] | None = None,
708
+ names: Sequence[str] | None = None,
709
+ n_rows: int | None = None,
710
+ comment: str | None = None,
711
+ tag_column: str = "tag",
712
+ ) -> None:
713
+ """Initialize the BaseData object with the provided data source.
714
+
715
+ Parameters
716
+ ----------
717
+ data_source : str | Path | StringIO | pl.DataFrame | pl.Series
718
+ The data source.
719
+ separator : str
720
+ Single byte character to use as separator in the source, by default ",".
721
+ has_header : bool, optional
722
+ Indicate if the first row of the dataset is a header or not, by default True.
723
+ columns : Sequence[int], Sequence[str] or range, optional
724
+ Columns to read from the data source.
725
+ names : Sequence[str], optional
726
+ Rename columns right after parsing the source.
727
+ n_rows : int, optional
728
+ Number of rows to read.
729
+ comment : str, optional
730
+ Character to indicate comments in the data source.
731
+ tag_column : str, optional
732
+ Column name used to tag and group data.
733
+
734
+ Raises
735
+ ------
736
+ TypeError
737
+ If the data type is unsupported.
738
+
739
+ """
740
+ super().__init__(
741
+ data_source,
742
+ separator=separator,
743
+ has_header=has_header,
744
+ columns=columns,
745
+ names=names,
746
+ n_rows=n_rows,
747
+ comment=comment,
748
+ )
749
+ self._tag_column_name = tag_column
750
+ self._make_groups(self._tag_column_name)
751
+
752
+ def __iter__(self) -> Iterator[tuple[str, Data]]:
753
+ """Return an iterator over the grouped Data objects.
754
+
755
+ Each group is represented by a tuple of (tag, Data).
756
+
757
+ Returns
758
+ -------
759
+ Iterator[tuple[str, Data]]
760
+ An iterator over the grouped Data objects.
761
+
762
+ """
763
+ return iter(self.datadict.items())
764
+
765
+ def __len__(self) -> int:
766
+ """Return the number of Data objects divided by the tag.
767
+
768
+ Returns
769
+ -------
770
+ int
771
+ Number of Data objects divided by the tag.
772
+
773
+ """
774
+ return len(self._datadict)
775
+
776
+ def _make_groups(self, tag_column_name: str) -> None:
777
+ """Group data by specified tag column and store in internal dictionary.
778
+
779
+ Parameters
780
+ ----------
781
+ tag_column_name : str
782
+ Name of column to use for grouping
783
+
784
+ """
785
+ try:
786
+ self._datadict = {
787
+ str(name[0]): Data(group.drop(tag_column_name))
788
+ for name, group in self.dataframe.group_by(tag_column_name)
789
+ }
790
+ except ColumnNotFoundError:
791
+ self._datadict = {"unknown": Data(self.dataframe)}
792
+
793
+ @property
794
+ def datadict(self) -> dict[str, Data]:
795
+ """Retrieve the dictionary of grouped Data objects.
796
+
797
+ Returns
798
+ -------
799
+ dict of str, Data
800
+ Dictionary of grouped Data objects.
801
+
802
+ """
803
+ return self._datadict
804
+
805
+ def tags(self) -> KeysView[str]:
806
+ """Return the tags associated with the data groups.
807
+
808
+ Returns
809
+ -------
810
+ KeysView of str
811
+ Dictionary keys of tags.
812
+
813
+ """
814
+ return self.datadict.keys()
815
+
816
+ def items(self) -> ItemsView[str, Data]:
817
+ """Retrieve the items (tag and Data object) of the grouped data.
818
+
819
+ Returns
820
+ -------
821
+ ItemsView of tuple of (str, Data)
822
+ Dictionary items of tag-Data object pairs.
823
+
824
+ """
825
+ return self.datadict.items()
826
+
827
+ @cached_property
828
+ def first_tag(self) -> str:
829
+ """Return the first tag stored in the TaggedData object.
830
+
831
+ Returns
832
+ -------
833
+ str
834
+ The first tag after tags in the object are sorted alphabetically.
835
+
836
+ Raises
837
+ ------
838
+ RuntimeError
839
+ If no tagged data is stored.
840
+
841
+ """
842
+ try:
843
+ tag = sorted(self.tags())[0]
844
+ except IndexError as e:
845
+ msg = "No tagged data is stored."
846
+ raise RuntimeError(msg) from e
847
+ return tag
848
+
849
+ @overload
850
+ def get(self, tag: str) -> Data: ...
851
+
852
+ @overload
853
+ def get(self, tag: str, default: Data | NoDefault) -> Data: ...
854
+
855
+ @overload
856
+ def get(self, tag: str, default: None) -> Data | None: ...
857
+
858
+ def get(self, tag, default=no_default):
859
+ """Retrieve the Data object associated with the specified tag.
860
+
861
+ Parameters
862
+ ----------
863
+ tag : str
864
+ Tag of the data group to retrieve.
865
+
866
+ default : Data or None, optional
867
+ The default data object when the specified tag is not found.
868
+
869
+ Returns
870
+ -------
871
+ Data
872
+ Data object corresponding to the tag.
873
+
874
+ Raises
875
+ ------
876
+ KeyError
877
+ If the specified tag value does not exist and no default value is given.
878
+
879
+ """
880
+ if default is no_default:
881
+ return self.datadict[tag]
882
+ if default is None:
883
+ # note: this return statement seems nonsense, but without it type checker produces an
884
+ # error, somehow.
885
+ return self.datadict.get(tag, None)
886
+ return self.datadict.get(tag, default)
887
+
888
+ @overload
889
+ def param(self, key: int | str, tag: str | None = None) -> float: ...
890
+
891
+ @overload
892
+ def param(self, key: Sequence, tag: str | None = None) -> tuple[float, ...]: ...
893
+
894
+ def param(self, key, tag=None):
895
+ """Retrieve specific parameter(s) for column(s) from a tagged Data object.
896
+
897
+ Parameters
898
+ ----------
899
+ key : int or str or Sequence of int or str
900
+ The column(s) for which to retrieve the parameter.
901
+ tag : str or None, optional
902
+ Tag of the data group to retrieve, by default None. If None is given, the first tag is
903
+ used.
904
+
905
+ Returns
906
+ -------
907
+ float or tuple[float, ...]
908
+ Retrieved parameter value(s).
909
+
910
+ """
911
+ if tag is None:
912
+ tag = self.first_tag
913
+ return self.get(tag).param(key)
914
+
915
+ def __str__(self) -> str:
916
+ """Return a string of the grouped mapping of tag to Data.
917
+
918
+ Returns
919
+ -------
920
+ str
921
+ String representation of the grouped mapping of tag to Data.
922
+
923
+ """
924
+ return str(self.datadict)
925
+
926
+ def __repr__(self) -> str:
927
+ """Return a string representation of the object.
928
+
929
+ Returns
930
+ -------
931
+ str
932
+ String representation of the object.
933
+
934
+ """
935
+ if self.is_loaded_from_file():
936
+ return f"{self.__class__.__name__}({self.datapath}, tag={self._tag_column_name})"
937
+ return f"{self.__class__.__name__}({self.dataframe}, tag={self._tag_column_name})"
938
+
939
+
940
+ class Dataset:
941
+ """A class for managing multiple Data objects as a collection.
942
+
943
+ This class provides functionality to load and handle multiple Data objects from files
944
+ or directories, with methods to access and analyze data across the collection.
945
+
946
+ Attributes
947
+ ----------
948
+ dataset : list[Data]
949
+ List of Data objects in the collection
950
+ datapaths : list[Path]
951
+ List of paths to data files
952
+ datadirs : list[Path]
953
+ List of unique directory paths containing data files
954
+ min_n_rows : int
955
+ Minimum number of rows across all Data objects
956
+
957
+ """
958
+
959
+ _dataset: list[Data]
960
+
961
+ def __init__(
962
+ self,
963
+ source_paths: FilePath | Iterable[FilePath],
964
+ *,
965
+ separator: str = ",",
966
+ has_header: bool = True,
967
+ columns: Sequence[int] | Sequence[str] | None = None,
968
+ names: Sequence[str] | None = None,
969
+ n_rows: int | None = None,
970
+ comment: str | None = None,
971
+ glob_pattern: str = "**/*.csv",
972
+ n_pickup_per_directory: int | None = None,
973
+ ) -> None:
974
+ """Initialize Dataset with data from specified sources.
975
+
976
+ Parameters
977
+ ----------
978
+ source_paths : FilePath | Iterable[FilePath]
979
+ Path(s) to data files or directories.
980
+ separator : str, optional
981
+ Single byte character to use as separator in the file, by default ",".
982
+ has_header : bool, optional
983
+ Whether data files have headers, by default True.
984
+ columns : Sequence[int] | Sequence[str] | None, optional
985
+ Columns to read from files.
986
+ names : Sequence[str] | None, optional
987
+ Names to assign to columns.
988
+ n_rows : int | None, optional
989
+ Number of rows to read.
990
+ comment : str | None, optional
991
+ Character to indicate comments in data files.
992
+ glob_pattern : str, optional
993
+ Pattern for finding files in directories, by default "**/*.csv".
994
+ n_pickup_per_directory : int | None, optional
995
+ Maximum number of files to load per directory.
996
+
997
+ """
998
+ self._dataset = Dataset.load_dataset(
999
+ source_paths,
1000
+ separator=separator,
1001
+ has_header=has_header,
1002
+ columns=columns,
1003
+ names=names,
1004
+ n_rows=n_rows,
1005
+ comment=comment,
1006
+ glob_pattern=glob_pattern,
1007
+ n_pickup_per_directory=n_pickup_per_directory,
1008
+ )
1009
+
1010
+ def __len__(self) -> int:
1011
+ """Return the number of Data objects in the dataset.
1012
+
1013
+ Returns
1014
+ -------
1015
+ int
1016
+ Number of Data objects.
1017
+ """
1018
+ return len(self._dataset)
1019
+
1020
+ def __str__(self) -> str:
1021
+ """Return a string of the grouped mapping of tag to Data.
1022
+
1023
+ Returns
1024
+ -------
1025
+ str
1026
+ String representation of the grouped mapping of tag to Data.
1027
+
1028
+ """
1029
+ return str(self.dataset)
1030
+
1031
+ def __repr__(self) -> str:
1032
+ """Return a string representation of the object.
1033
+
1034
+ Returns
1035
+ -------
1036
+ str
1037
+ String representation of the object.
1038
+
1039
+ """
1040
+ return f"{self.__class__.__name__}({self.datadirs})"
1041
+
1042
+ def __iter__(self) -> Iterator[Data]:
1043
+ """Return an iterator over the Data objects.
1044
+
1045
+ Returns
1046
+ -------
1047
+ Iterator[Data]
1048
+ Iterator yielding Data objects.
1049
+ """
1050
+ return iter(self._dataset)
1051
+
1052
+ @overload
1053
+ def __getitem__(self, index: int) -> Data: ...
1054
+
1055
+ @overload
1056
+ def __getitem__(self, index: slice) -> list[Data]: ...
1057
+
1058
+ def __getitem__(self, index):
1059
+ """Access Data object(s) by index.
1060
+
1061
+ Parameters
1062
+ ----------
1063
+ index : int or slice
1064
+ Index or slice to access Data objects.
1065
+
1066
+ Returns
1067
+ -------
1068
+ Data or list[Data]
1069
+ Single Data object if index is int, list of Data objects if slice.
1070
+ """
1071
+ return self._dataset[index]
1072
+
1073
+ @property
1074
+ def datapaths(self) -> list[Path]:
1075
+ """Get paths of all data files in the dataset.
1076
+
1077
+ Returns
1078
+ -------
1079
+ list[Path]
1080
+ List of paths to data files.
1081
+ """
1082
+ return [data.datapath for data in self]
1083
+
1084
+ @property
1085
+ def datadirs(self) -> list[Path]:
1086
+ """Get unique directory paths containing data files.
1087
+
1088
+ Returns
1089
+ -------
1090
+ list[Path]
1091
+ List of unique directory paths.
1092
+ """
1093
+ return list(OrderedDict.fromkeys(x.parent for x in self.datapaths))
1094
+
1095
+ @property
1096
+ def dataset(self) -> list[Data]:
1097
+ """Get the list of Data objects.
1098
+
1099
+ Returns
1100
+ -------
1101
+ list[Data]
1102
+ List of Data objects in the dataset.
1103
+ """
1104
+ return self._dataset
1105
+
1106
+ @staticmethod
1107
+ def load_dataset(
1108
+ source_paths: FilePath | Iterable[FilePath],
1109
+ *,
1110
+ separator: str = ",",
1111
+ has_header: bool = True,
1112
+ columns: Sequence[int] | Sequence[str] | None = None,
1113
+ names: Sequence[str] | None = None,
1114
+ n_rows: int | None = None,
1115
+ comment: str | None = None,
1116
+ glob_pattern: str = "**/*.csv",
1117
+ n_pickup_per_directory: int | None = None,
1118
+ ) -> list[Data]:
1119
+ """Load multiple Data objects from specified sources.
1120
+
1121
+ Parameters
1122
+ ----------
1123
+ source_paths : FilePath | Iterable[FilePath]
1124
+ Path(s) to data files or directories.
1125
+ separator : str, optional
1126
+ Single byte character to use as separator in the file, by default ",".
1127
+ has_header : bool, optional
1128
+ Whether data files have headers, by default True.
1129
+ columns : Sequence[int] | Sequence[str] | None, optional
1130
+ Columns to read from files.
1131
+ names : Sequence[str] | None, optional
1132
+ Names to assign to columns.
1133
+ n_rows : int | None, optional
1134
+ Number of rows to read.
1135
+ comment : str | None, optional
1136
+ Character to indicate comments in data files.
1137
+ glob_pattern : str, optional
1138
+ Pattern for finding files in directories, by default "**/*.csv".
1139
+ n_pickup_per_directory : int | None, optional
1140
+ Maximum number of files to load per directory.
1141
+
1142
+ Returns
1143
+ -------
1144
+ list[Data]
1145
+ List of loaded Data objects.
1146
+
1147
+ Raises
1148
+ ------
1149
+ ValueError
1150
+ If source path does not exist.
1151
+
1152
+ """
1153
+ if isinstance(source_paths, FilePath):
1154
+ source_paths = [source_paths]
1155
+
1156
+ data_loader: Callable[[Path], Data] = partial(
1157
+ Data,
1158
+ separator=separator,
1159
+ has_header=has_header,
1160
+ columns=columns,
1161
+ names=names,
1162
+ n_rows=n_rows,
1163
+ comment=comment,
1164
+ )
1165
+
1166
+ dataset: list[Data] = []
1167
+ for source_path in source_paths:
1168
+ path = Path(source_path)
1169
+ if not path.exists():
1170
+ msg = f"{_ERR_MSG_PATH_NOT_EXIST}: {path!s}"
1171
+ raise ValueError(msg)
1172
+ if path.is_dir():
1173
+ found_files = sorted(path.glob(glob_pattern))
1174
+ if n_pickup_per_directory is not None:
1175
+ found_files = found_files[:n_pickup_per_directory]
1176
+ if len(found_files) == 0:
1177
+ msg = f"{_WARN_MSG_NO_FILES_FOUND}: {path!s}, {glob_pattern}"
1178
+ warnings.warn(msg, UserWarning, stacklevel=2)
1179
+ dataset.extend(data_loader(x) for x in found_files)
1180
+ else:
1181
+ dataset.append(data_loader(path))
1182
+ return dataset
1183
+
1184
+ @cached_property
1185
+ def min_n_rows(self) -> int:
1186
+ """Get the minimum number of rows across all Data objects.
1187
+
1188
+ Returns
1189
+ -------
1190
+ int
1191
+ Minimum number of rows.
1192
+ """
1193
+ return min(map(len, self._dataset))
1194
+
1195
+ def get_columns(self, name: str, *, aligned: bool = True) -> list[pl.Series]:
1196
+ """Get columns with specified name from all Data objects.
1197
+
1198
+ Parameters
1199
+ ----------
1200
+ name : str
1201
+ Column name to retrieve.
1202
+ aligned : bool, optional
1203
+ Whether to align columns to minimum length, by default True.
1204
+
1205
+ Returns
1206
+ -------
1207
+ list[pl.Series]
1208
+ List of columns from each Data object.
1209
+
1210
+ """
1211
+ if not aligned:
1212
+ return [data.get_column(name) for data in self]
1213
+ n = self.min_n_rows
1214
+ return [data.get_column(name)[:n] for data in self]
1215
+
1216
+ def get_columns_as_array(self, name: str) -> np.ndarray:
1217
+ """Get columns as a numpy array with aligned lengths.
1218
+
1219
+ Parameters
1220
+ ----------
1221
+ name : str
1222
+ Column name to retrieve.
1223
+
1224
+ Returns
1225
+ -------
1226
+ np.ndarray
1227
+ Array containing aligned columns from all Data objects.
1228
+
1229
+ """
1230
+ return np.asarray(self.get_columns(name, aligned=True))
1231
+
1232
+ def get_axis_data(
1233
+ self,
1234
+ x_axis_name: str,
1235
+ y_data_name: str,
1236
+ *,
1237
+ x_axis_index: int = 0,
1238
+ ) -> tuple[np.ndarray, np.ndarray]:
1239
+ """Get x-axis and y-axis data for plotting.
1240
+
1241
+ Parameters
1242
+ ----------
1243
+ x_axis_name : str
1244
+ Column name for x-axis.
1245
+ y_data_name : str
1246
+ Column name for y-axis.
1247
+ x_axis_index : int, optional
1248
+ Index of Data object to use for x-axis, by default 0.
1249
+
1250
+ Returns
1251
+ -------
1252
+ tuple[np.ndarray, np.ndarray]
1253
+ X-axis values and corresponding Y-axis values.
1254
+
1255
+ """
1256
+ y = self.get_columns_as_array(y_data_name)
1257
+ n = self.min_n_rows
1258
+ x = self._dataset[x_axis_index].get_column(x_axis_name).to_numpy()[:n]
1259
+ return x, y
1260
+
1261
+ def get_timeseries(
1262
+ self,
1263
+ name: str,
1264
+ *,
1265
+ t_shift: float = 0.0,
1266
+ t_axis_name: str = "t",
1267
+ t_axis_index: int = 0,
1268
+ ) -> tuple[np.ndarray, np.ndarray]:
1269
+ """Get time series data with optional time shift.
1270
+
1271
+ Parameters
1272
+ ----------
1273
+ name : str
1274
+ Name of data column.
1275
+ t_shift : float, optional
1276
+ Time shift to apply, by default 0.0.
1277
+ t_axis_name : str, optional
1278
+ Name of time axis column, by default "t".
1279
+ t_axis_index : int, optional
1280
+ Index of Data object to use for time axis, by default 0.
1281
+
1282
+ Returns
1283
+ -------
1284
+ tuple[np.ndarray, np.ndarray]
1285
+ Time values and corresponding data values.
1286
+
1287
+ """
1288
+ t, y = self.get_axis_data(t_axis_name, name, x_axis_index=t_axis_index)
1289
+ t = t - t_shift
1290
+ return t, y
1291
+
1292
+
1293
+ # Local Variables:
1294
+ # jinx-local-words: "Iterable Runtime StringIO csv datadict datadir datadirs dataframe datapath datapaths dataset dest dtype ndarray noqa np numpy param polars str timeseries" # noqa: E501
1295
+ # End: