gensor 0.0.1__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.
gensor/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ from .compensation import Compensator, compensate
2
+ from .dtypes import Dataset, Timeseries
3
+ from .getters import read_from_csv
4
+ from .preprocessing import OutlierDetection, Transform
5
+
6
+ __all__ = [
7
+ "Dataset",
8
+ "Timeseries",
9
+ "read_from_csv",
10
+ "OutlierDetection",
11
+ "Transform",
12
+ "Compensator",
13
+ "compensate",
14
+ ]
gensor/compensation.py ADDED
@@ -0,0 +1,110 @@
1
+ """Compensating the raw data from the absolute pressure transducer to the actual water
2
+ level using the barometric pressure data.
3
+
4
+ Because van Essen Instrument divers are non-vented pressure transducers, to obtain the
5
+ pressure resulting from the water column above the logger (i.e. the water level), the
6
+ barometric pressure must be subtracted from the raw pressure measurements. In the
7
+ first step the function aligns the two series to the same time step and then subtracts
8
+ the barometric pressure from the raw pressure measurements. For short time periods (when
9
+ for instance a slug test is performed) the barometric pressure can be provided as a
10
+ single float value.
11
+
12
+ Subsequently the function filters out all records where the absolute water column is
13
+ less than or equal to the cutoff value. This is because when the logger is out of the
14
+ water when the measurement is taken, the absolute water column is close to zero,
15
+ producing erroneous results and spikes in the plots. The cutoff value is set to 5 cm by
16
+ default, but can be adjusted using the cutoff_wc kwarg.
17
+
18
+ Functions:
19
+
20
+ compensate: Compensate raw sensor pressure measurement with barometric pressure.
21
+ """
22
+
23
+ from typing import Self
24
+
25
+ import pydantic as pyd
26
+
27
+ from .dtypes import Timeseries
28
+ from .exceptions import (
29
+ InvalidMeasurementTypeError,
30
+ MissingInputError,
31
+ )
32
+
33
+
34
+ class Compensator(pyd.BaseModel):
35
+ ts: Timeseries
36
+ barometric: Timeseries | float
37
+ drop_low_wc: bool = True
38
+
39
+ @pyd.field_validator("ts", "barometric", mode="before")
40
+ def validate_timeseries_type(cls, v):
41
+ if isinstance(v, Timeseries) and v.variable != "pressure":
42
+ raise InvalidMeasurementTypeError(v.location)
43
+ return v
44
+
45
+ @pyd.field_validator("ts")
46
+ def validate_sensor_information(cls, v: Timeseries):
47
+ if v.sensor is not None and not v.sensor_alt:
48
+ raise MissingInputError("sensor_alt")
49
+ return v
50
+
51
+ def compensate(self, **kwargs) -> Self | None:
52
+ """Compensate raw sensor pressure measurement with barometric pressure.
53
+
54
+ Parameters:
55
+ ts (Timeseries): Raw sensor timeseries
56
+ barometric (Timeseries or float): Barometric pressure timeseries or a single
57
+ float value. If a float value is provided, it is assumed to be in cmH2O.
58
+ drop_low_wc (bool): Whether to drop records where the absolute water column is
59
+ less than or equal to the cutoff value. Defaults to True.
60
+ inplace (bool): Whether to update the timeseries in place. Defaults to True.
61
+
62
+ Keyword Arguments:
63
+ alignment_period (str): The alignment period for the timeseries.
64
+ Default is 'H' (hourly).
65
+ threshold_wc (float): The threshold for the absolute water column.
66
+ Defaults to 0.5 m.
67
+
68
+ Returns:
69
+ Timeseries: A new Timeseries instance with the compensated data.
70
+ """
71
+
72
+ alignment_period = kwargs.get("alignment_period", "h")
73
+ threshold_wc = kwargs.get("threshold_wc", 0.5)
74
+ resample_params = {"freq": alignment_period, "agg_func": "mean"}
75
+
76
+ if isinstance(self.barometric, Timeseries):
77
+ if self.ts == self.barometric:
78
+ print("Skipping compensation: both timeseries are the same.")
79
+ return None
80
+ baro = self.barometric.resample(**resample_params).ts
81
+ else:
82
+ baro = self.barometric
83
+
84
+ resampled_ts = self.ts.resample(**resample_params)
85
+
86
+ # dividing by 100 to convert water column from cmH2O to mH2O
87
+ watercolumn_ts = resampled_ts.ts.sub(baro).divide(100).dropna()
88
+
89
+ if self.drop_low_wc:
90
+ watercolumn_ts_filtered = watercolumn_ts[
91
+ watercolumn_ts.abs() > threshold_wc
92
+ ]
93
+ print(
94
+ f"{len(watercolumn_ts) - len(watercolumn_ts_filtered)} records \
95
+ dropped due to low water column."
96
+ )
97
+ gwl = watercolumn_ts_filtered.add(float(resampled_ts.sensor_alt))
98
+ else:
99
+ gwl = watercolumn_ts.add(float(resampled_ts.sensor_alt))
100
+
101
+ compensated = resampled_ts.model_copy(
102
+ update={"ts": gwl, "unit": "m asl", "variable": "head"}
103
+ )
104
+
105
+ return compensated
106
+
107
+
108
+ def compensate(ts, barometric, drop_low_wc, **kwargs) -> Timeseries:
109
+ comp = Compensator(ts=ts, barometric=barometric, drop_low_wc=drop_low_wc)
110
+ return comp.compensate(**kwargs)
gensor/db/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .connection import DatabaseConnection
2
+
3
+ __all__ = ["DatabaseConnection"]
@@ -0,0 +1,49 @@
1
+ """Module for database connection."""
2
+
3
+ from pathlib import Path
4
+
5
+ import pydantic as pyd
6
+ from sqlalchemy import Engine, create_engine
7
+ from sqlalchemy.orm import Session, sessionmaker
8
+
9
+ from ..exceptions import DatabaseNotFound
10
+
11
+
12
+ class DatabaseConnection(pyd.BaseModel):
13
+ """Class for handling the database connection.
14
+ If no database exists at the specified path, it will be created.
15
+ If no database is specified, an in-memory database will be used.
16
+
17
+ The user should specify the database directory and name separately. If directory is not specified,
18
+ current directory and a default name are used. ."""
19
+
20
+ model_config = pyd.ConfigDict(
21
+ arbitrary_types_allowed=True, validate_assignment=True
22
+ )
23
+
24
+ in_memory: bool = False
25
+ db_directory: Path = Path.cwd()
26
+ db_name: str = "gensor.db"
27
+ engine: Engine | None = None
28
+ session: Session | None = None
29
+
30
+ def __post_init__(self) -> None:
31
+ self.connect()
32
+
33
+ def _verify_path(self) -> str:
34
+ if self.in_memory:
35
+ return "sqlite:///:memory:"
36
+ else:
37
+ if not self.db_directory.exists():
38
+ raise DatabaseNotFound()
39
+ else:
40
+ return f"sqlite:///{self.db_directory}/{self.db_name}"
41
+
42
+ def connect(self) -> Session:
43
+ sqlite_path = self._verify_path()
44
+
45
+ self.engine = create_engine(sqlite_path)
46
+ session = sessionmaker(bind=self.engine)
47
+ self.session = session()
48
+
49
+ return session()
gensor/dtypes.py ADDED
@@ -0,0 +1,429 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from typing import Any, Literal
5
+
6
+ import pandas as pd
7
+ import pandera as pa
8
+ import pydantic as pyd
9
+ from matplotlib import pyplot as plt
10
+
11
+ from .db import DatabaseConnection
12
+ from .exceptions import IndexOutOfRangeError, TimeseriesNotFound, TimeseriesUnequal
13
+ from .preprocessing import OutlierDetection, Transform
14
+
15
+ ts_schema = pa.SeriesSchema(
16
+ float,
17
+ index=pa.Index(pa.DateTime, coerce=True),
18
+ coerce=True,
19
+ )
20
+
21
+
22
+ class Timeseries(pyd.BaseModel):
23
+ """Timeseries from a sensor including measurement metadata.
24
+
25
+ This is class for any sensor timeseries. The basic required attributes are
26
+ just the ts, variable and unit. SensorInfo object is created from the
27
+ relevant kwargs if they are passed.
28
+
29
+ Timeseries represents a series of measurements of a single variable, from a
30
+ single sensor with unique timestamps.
31
+
32
+ TODO: Perhaps it would be cool to implement kind of a tracking of which
33
+ analyses were performed on the timeseries?
34
+
35
+ Attributes:
36
+ ts (pd.Series): The timeseries data.
37
+ variable (Literal['temperature', 'pressure', 'conductivity', 'flux']):
38
+ The type of the measurement.
39
+ unit (Literal['degC', 'mmH2O', 'mS/cm', 'm/s']): The unit of
40
+ the measurement.
41
+ sensor (SensorInfo): The serial number of the sensor.
42
+ analysis (Analysis): An object containing details of analysis done
43
+ on the timeseries.
44
+
45
+ Methods:
46
+ validate_ts: if the pd.Series is not exactly what is required, coerce.
47
+ """
48
+
49
+ model_config = pyd.ConfigDict(
50
+ arbitrary_types_allowed=True, validate_assignment=True
51
+ )
52
+
53
+ ts: pd.Series = pyd.Field(repr=False)
54
+ variable: Literal[
55
+ "temperature", "pressure", "conductivity", "flux", "head", "depth"
56
+ ]
57
+ unit: Literal["degC", "cmH2O", "mS/cm", "m/s", "m asl", "m"]
58
+ location: str | None = None
59
+ sensor: str | None = None
60
+ sensor_alt: float | None = None
61
+ outliers: pd.Series | None = pyd.Field(default=None, repr=False)
62
+ transformation: Any = pyd.Field(default=None, repr=False)
63
+
64
+ def __eq__(self, other: object) -> bool:
65
+ """Check equality based on location, sensor, and variable."""
66
+ if not isinstance(other, Timeseries):
67
+ return NotImplemented
68
+
69
+ return (
70
+ self.variable == other.variable
71
+ and self.unit == other.unit
72
+ and self.location == other.location
73
+ and self.sensor == other.sensor
74
+ )
75
+
76
+ def __getattr__(self, attr: Any) -> Any:
77
+ """Delegate attribute access to the underlying pandas Series if it exists."""
78
+
79
+ error_message = f"'{self.__class__.__name__}' object has no attribute '{attr}'"
80
+
81
+ if hasattr(self.ts, attr):
82
+ # Return a function to call on the `ts` if it's a method, otherwise return the attribute
83
+ ts_attr = getattr(self.ts, attr)
84
+ if callable(ts_attr):
85
+
86
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
87
+ result = ts_attr(*args, **kwargs)
88
+ # If the result is a Series, return a new Timeseries; otherwise, return the result
89
+ if isinstance(result, pd.Series):
90
+ return self.model_copy(update={"ts": result}, deep=True)
91
+ return result
92
+
93
+ return wrapper
94
+ else:
95
+ return ts_attr
96
+ raise AttributeError(error_message)
97
+
98
+ @pyd.field_validator("ts")
99
+ def validate_ts(cls, v: pd.Series) -> pd.Series:
100
+ return ts_schema.validate(v)
101
+
102
+ @pyd.field_validator("outliers")
103
+ def validate_outliers(cls, v: pd.Series) -> pd.Series:
104
+ if v is not None:
105
+ return ts_schema.validate(v)
106
+ return v
107
+
108
+ def concatenate(self, other: Timeseries) -> Timeseries:
109
+ """Concatenate two Timeseries objects if they are considered equal."""
110
+ if not isinstance(other, Timeseries):
111
+ return NotImplemented
112
+
113
+ if self == other:
114
+ combined_ts = pd.concat([self.ts, other.ts]).sort_index()
115
+ combined_ts = combined_ts[~combined_ts.index.duplicated(keep="first")]
116
+
117
+ return self.model_copy(update={"ts": combined_ts})
118
+ else:
119
+ raise TimeseriesUnequal()
120
+
121
+ def resample(
122
+ self, freq: str, agg_func: Callable = pd.Series.mean, **resample_kwargs: Any
123
+ ) -> Timeseries:
124
+ """Resample the timeseries to a new frequency with a specified
125
+ aggregation function.
126
+
127
+ Parameters:
128
+ freq (str): The new frequency for resampling the timeseries
129
+ (e.g., 'D' for daily, 'W' for weekly).
130
+ agg_func (Callable, optional): The aggregation function to apply
131
+ after resampling. Defaults to pd.Series.mean.
132
+ **resample_kwargs: Additional keyword arguments passed to the
133
+ pandas.Series.resample method.
134
+
135
+ Returns:
136
+ Updated deep copy of the Timeseries object with the
137
+ resampled timeseries data.
138
+ """
139
+ resampled_ts = self.ts.resample(freq, **resample_kwargs).apply(agg_func)
140
+
141
+ return self.model_copy(update={"ts": resampled_ts}, deep=True)
142
+
143
+ def transform(
144
+ self,
145
+ method: Literal[
146
+ "difference",
147
+ "log",
148
+ "square_root",
149
+ "box_cox",
150
+ "standard_scaler",
151
+ "minmax_scaler",
152
+ "robust_scaler",
153
+ "maxabs_scaler",
154
+ ],
155
+ **transformer_kwargs: Any,
156
+ ) -> Timeseries:
157
+ """Transforms the timeseries using the specified method.
158
+
159
+ Parameters:
160
+ method (str): The method to use for transformation ('minmax',
161
+ 'standard', 'robust').
162
+ transformer_kwargs: Additional keyword arguments passed to the
163
+ transformer definition. See gensor.preprocessing.
164
+
165
+ Returns:
166
+ Updated deep copy of the Timeseries object with the
167
+ transformed timeseries data.
168
+ """
169
+
170
+ data, transformation = Transform(
171
+ self.ts, method, **transformer_kwargs
172
+ ).get_transformation()
173
+
174
+ return self.model_copy(
175
+ update={"ts": data, "transformation": transformation}, deep=True
176
+ )
177
+
178
+ def detect_outliers(
179
+ self,
180
+ method: Literal["iqr", "zscore", "isolation_forest", "lof"],
181
+ remove: bool = True,
182
+ **kwargs: Any,
183
+ ) -> Timeseries:
184
+ """Detects outliers in the timeseries using the specified method.
185
+
186
+ Parameters:
187
+ method (Literal['iqr', 'zscore', 'isolation_forest', 'lof']): The
188
+ method to use for outlier detection.
189
+ **kwargs: Additional kewword arguments for OutlierDetection.
190
+
191
+ Returns:
192
+ Updated deep copy of the Timeseries object with outliers,
193
+ optionally removed from the original timeseries.
194
+ """
195
+ self.outliers = OutlierDetection(self.ts, method, **kwargs).outliers
196
+
197
+ if remove:
198
+ filtered_ts = self.ts.drop(self.outliers.index)
199
+ return self.model_copy(update={"ts": filtered_ts})
200
+
201
+ else:
202
+ return self
203
+
204
+ def to_sql(self, db: DatabaseConnection) -> str:
205
+ """Converts the timeseries to a list of dictionaries and uploads it to the database.
206
+
207
+ Normally the upload of the data with SQLAlchemy ORM would require creation of LoggerRecords instances,
208
+ but since the on_conflict_do_nothing clause is is used to avoid inserting duplicate rows, the
209
+ data has to be uploaded as a list of dictionaries.
210
+
211
+ Args:
212
+ db (DatabaseConnection): The database connection object (see gwlogger.db.connection).
213
+
214
+ Returns:
215
+ str: A message indicating the number of rows inserted into the database.
216
+ """
217
+ schema_name = f"{self.location}_{self.sensor}_{self.variable}_{self.unit}"
218
+ con = db.engine.connect()
219
+ self.ts.to_sql(name=schema_name, con=con, if_exists="append", index=False)
220
+
221
+ return f"{schema_name} table updated."
222
+
223
+ def plot(
224
+ self, include_outliers: bool = False, ax: Any = None, **plot_kwargs: Any
225
+ ) -> tuple:
226
+ """Plots the timeseries data.
227
+
228
+ Args:
229
+ include_outliers (bool): Whether to include outliers in the plot.
230
+ ax (matplotlib.axes.Axes, optional): Matplotlib axes object to plot on.
231
+ If None, a new figure and axes are created.
232
+ **plot_kwargs: Additional keyword arguments passed to plt.plot.
233
+
234
+ Returns:
235
+ (fig, ax): Matplotlib figure and axes to allow further customization.
236
+ """
237
+ # Create new figure and axes if not provided
238
+ if ax is None:
239
+ fig, ax = plt.subplots(figsize=(10, 5))
240
+ else:
241
+ fig = ax.get_figure()
242
+
243
+ ax.plot(
244
+ self.ts.index,
245
+ self.ts,
246
+ label=f"{self.variable} ({self.unit})",
247
+ **plot_kwargs,
248
+ )
249
+
250
+ if include_outliers and self.outliers is not None:
251
+ ax.scatter(
252
+ self.outliers.index, self.outliers, color="red", label="Outliers"
253
+ )
254
+
255
+ ax.set_xlabel("Time")
256
+ ax.set_ylabel(f"{self.variable} ({self.unit})")
257
+ ax.set_title(f"{self.variable.capitalize()} at {self.location}")
258
+
259
+ ax.legend()
260
+
261
+ return fig, ax
262
+
263
+
264
+ class Dataset(pyd.BaseModel):
265
+ """Class to store a collection of timeseries.
266
+
267
+ The Dataset class is used to store a collection of Timeseries objects. It
268
+ is meant to be created when the van Essen CSV file is parsed.
269
+
270
+ Attributes:
271
+ timeseries (list[Timeseries]): A list of Timeseries objects.
272
+
273
+ Methods:
274
+ __iter__: Returns timeseries when iterated over.
275
+ __len__: Gives the number of timeseries in the Dataset.
276
+ get_stations: List all unique locations in the dataset.
277
+ add: Appends a new series to the Dataset or merges series if
278
+ an equal one exists.
279
+ align: Aligns the timeseries to a common time axis.
280
+ plot: Plots the timeseries data.
281
+ """
282
+
283
+ timeseries: list[Timeseries | None] = pyd.Field(default_factory=list)
284
+
285
+ def __iter__(self) -> Any:
286
+ """Allows to iterate directly over the dataset."""
287
+ return iter(self.timeseries)
288
+
289
+ def __len__(self) -> int:
290
+ """Gives the number of timeseries in the Dataset."""
291
+ return len(self.timeseries)
292
+
293
+ def __repr__(self) -> str:
294
+ return f"Dataset({len(self)})"
295
+
296
+ def __getitem__(self, index: int) -> Timeseries:
297
+ """Retrieve a Timeseries object by its index in the dataset.
298
+
299
+ Parameters:
300
+ index (int): The index of the Timeseries to retrieve.
301
+
302
+ Returns:
303
+ Timeseries: The Timeseries object at the specified index.
304
+
305
+ Raises:
306
+ IndexError: If the index is out of range.
307
+ """
308
+ try:
309
+ return self.timeseries[index]
310
+ except IndexError:
311
+ raise IndexOutOfRangeError(index, len(self)) from None
312
+
313
+ def get_stations(self):
314
+ """List all unique locations in the dataset."""
315
+ return [ts.location for ts in self.timeseries if ts is not None]
316
+
317
+ def add(self, other: Timeseries):
318
+ """Appends a new series to the Dataset or merges series if an equal
319
+ one exists.
320
+
321
+ If a Timeseries with the same location, sensor, and variable already
322
+ exists, merge the new data into the existing Timeseries, dropping
323
+ duplicate timestamps.
324
+
325
+ Parameters:
326
+ other (Timeseries): The Timeseries object to add.
327
+ """
328
+ if isinstance(other, list):
329
+ for ts in other:
330
+ self._add_single_timeseries(ts)
331
+ else:
332
+ self._add_single_timeseries(other)
333
+
334
+ def _add_single_timeseries(self, ts: Timeseries):
335
+ """Adds a single Timeseries to the Dataset or merges if an equal one exists."""
336
+ for i, existing_ts in enumerate(self.timeseries):
337
+ if existing_ts == ts:
338
+ self.timeseries[i] = existing_ts.concatenate(ts)
339
+ return
340
+
341
+ self.timeseries.append(ts)
342
+
343
+ def filter(
344
+ self,
345
+ station: str | None = None,
346
+ sensor: str | None = None,
347
+ variable: str | None = None,
348
+ ) -> Timeseries | Dataset:
349
+ """Return a Timeseries or a new Dataset filtered by station, sensor,
350
+ and/or variable.
351
+
352
+ Parameters:
353
+ station (Optional[str]): The location of the station.
354
+ sensor (Optional[str]): The sensor identifier.
355
+ variable (Optional[str]): The variable being measured.
356
+
357
+ Returns:
358
+ Timeseries or Dataset: A single Timeseries if exactly one match is found,
359
+ or a new Dataset if multiple matches are found.
360
+ """
361
+ matching_timeseries = [
362
+ ts
363
+ for ts in self.timeseries
364
+ if (station is None or ts.location == station)
365
+ and (sensor is None or ts.sensor == sensor)
366
+ and (variable is None or ts.variable == variable)
367
+ ]
368
+
369
+ if not matching_timeseries:
370
+ raise TimeseriesNotFound()
371
+
372
+ if len(matching_timeseries) == 1:
373
+ return matching_timeseries[0]
374
+
375
+ return self.model_copy(update={"timeseries": matching_timeseries})
376
+
377
+ # def align(self,
378
+ # freq: str = 'h',
379
+ # inplace: bool = True):
380
+ # """Aligns the timeseries to a common time axis.
381
+
382
+ # Args:
383
+ # freq (str): The target frequency for resampling.
384
+ # inplace (bool): Whether to update the timeseries in place. Defaults to True.
385
+ # """
386
+
387
+ # index_sets = [set(serie._resample(freq).index)
388
+ # for serie in self.timeseries]
389
+
390
+ # # Find the intersection of all index sets to get the common dates
391
+ # common_dates = set.intersection(*index_sets)
392
+
393
+ # # Sort the common dates since set intersection will not preserve order
394
+ # common_dates = sorted(list(common_dates))
395
+
396
+ # aligned_series = []
397
+
398
+ # for serie in self.timeseries:
399
+ # serie.copy(deep=True)
400
+ # serie.timeseries = serie.timeseries.reindex(
401
+ # common_dates).dropna()
402
+
403
+ # aligned_series.append(serie)
404
+
405
+ # if inplace:
406
+ # self.timeseries = aligned_series
407
+ # return None
408
+ # else:
409
+ # aligned_series = Dataset(aligned_series)
410
+
411
+ # return aligned_series
412
+
413
+
414
+ # def plot(self, stations: list[str] | None = None):
415
+ # """Plots the timeseries data.
416
+
417
+ # Args:
418
+ # ts (Timeseries): The timeseries to plot.
419
+ # """
420
+ # plt.figure(figsize=(10, 5))
421
+
422
+ # for ts in self.timeseries:
423
+ # plt.plot(ts.timeseries.index, ts.timeseries,
424
+ # label=f'{ts.measurement_type} at {ts.station}')
425
+ # plt.xlabel('Time')
426
+ # plt.ylabel('Value')
427
+ # plt.title('Timeseries data')
428
+ # plt.legend()
429
+ # plt.show()
gensor/exceptions.py ADDED
@@ -0,0 +1,56 @@
1
+ class InvalidMeasurementTypeError(ValueError):
2
+ """Raised when a timeseries of a wrong measurement type is operated upon."""
3
+
4
+ def __init__(self, timeseries_name: str, expected_type: str = "pressure") -> None:
5
+ self.timeseries_name = timeseries_name
6
+ self.expected_type = expected_type
7
+ message = f"Timeseries '{self.timeseries_name}' must be of measurement type '{self.expected_type}'."
8
+ super().__init__(message)
9
+
10
+
11
+ class MissingInputError(ValueError):
12
+ """Raised when a required input is missing."""
13
+
14
+ def __init__(self, input_name: str, message: str | None = None) -> None:
15
+ self.input_name = input_name
16
+ if message is None:
17
+ message = f"Missing required input: '{self.input_name}'."
18
+ super().__init__(message)
19
+
20
+
21
+ class DatabaseNotFound(FileExistsError):
22
+ def __init__(self, *args: object, message: str | None = None) -> None:
23
+ message = "Database directory does not exist."
24
+ super().__init__(message, *args)
25
+
26
+
27
+ class TimeseriesUnequal(ValueError):
28
+ """Raised when Timeseries objects are compared and are unequal."""
29
+
30
+ def __init__(self, *args: object, message: str | None = None) -> None:
31
+ message = (
32
+ "Timeseries objects must have the same location, sensor, variable, and \
33
+ unit to be added together."
34
+ )
35
+ super().__init__(message, *args)
36
+
37
+
38
+ class IndexOutOfRangeError(IndexError):
39
+ """Custom exception raised when an index is out of range in the dataset."""
40
+
41
+ def __init__(self, index: int, dataset_size: int) -> None:
42
+ super().__init__(
43
+ f"Index {index} is out of range for the dataset with {dataset_size} timeseries."
44
+ )
45
+
46
+
47
+ class TimeseriesNotFound(ValueError):
48
+ def __init__(self, *args: object, message: str | None = None) -> None:
49
+ message = "No matching timeseries found for the given criteria."
50
+ super().__init__(message, *args)
51
+
52
+
53
+ class NoFilesToLoad(FileNotFoundError):
54
+ def __init__(self, *args: object, message: str | None = None) -> None:
55
+ message = "Directory contains no files or only contains other folders."
56
+ super().__init__(message, *args)