labda 0.0.2__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 (46) hide show
  1. CHANGELOG.md +16 -0
  2. labda/__init__.py +1 -0
  3. labda/assets/__init__.py +2 -0
  4. labda/assets/counts_cut_points.py +35 -0
  5. labda/assets/transportation_cut_points.py +16 -0
  6. labda/expanders/__init__.py +8 -0
  7. labda/expanders/accelerometer.py +29 -0
  8. labda/expanders/extras.py +33 -0
  9. labda/expanders/spatial.py +242 -0
  10. labda/parsers/__init__.py +4 -0
  11. labda/parsers/actigraph.py +232 -0
  12. labda/parsers/bulk.py +31 -0
  13. labda/parsers/gadgetbridge.py +232 -0
  14. labda/parsers/garmin.py +89 -0
  15. labda/parsers/qstarz.py +366 -0
  16. labda/processing/__init__.py +1 -0
  17. labda/processing/accelerometer/__init__.py +2 -0
  18. labda/processing/accelerometer/activity_intensity.py +57 -0
  19. labda/processing/accelerometer/steps.py +24 -0
  20. labda/processing/accelerometer/wear.py +38 -0
  21. labda/processing/bouts.py +85 -0
  22. labda/processing/domains.py +32 -0
  23. labda/processing/manipulations.py +22 -0
  24. labda/processing/spatial/__init__.py +4 -0
  25. labda/processing/spatial/environment.py +50 -0
  26. labda/processing/spatial/manipulations.py +186 -0
  27. labda/processing/spatial/timeline.py +99 -0
  28. labda/processing/spatial/transportation.py +80 -0
  29. labda/processing/spatial/trips.py +551 -0
  30. labda/processing/wear_validity.py +69 -0
  31. labda/structure/__init__.py +6 -0
  32. labda/structure/collection.py +103 -0
  33. labda/structure/domains.py +29 -0
  34. labda/structure/linkage.py +28 -0
  35. labda/structure/merging.py +133 -0
  36. labda/structure/resampling.py +82 -0
  37. labda/structure/subject.py +294 -0
  38. labda/structure/validation/__init__.py +0 -0
  39. labda/structure/validation/domains.py +37 -0
  40. labda/structure/validation/linkage.py +28 -0
  41. labda/structure/validation/subject.py +323 -0
  42. labda/utils.py +240 -0
  43. labda/visualisation/__init__.py +0 -0
  44. labda-0.0.2.dist-info/METADATA +59 -0
  45. labda-0.0.2.dist-info/RECORD +46 -0
  46. labda-0.0.2.dist-info/WHEEL +4 -0
CHANGELOG.md ADDED
@@ -0,0 +1,16 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ### Added
11
+
12
+ - Parser: Qstarz
13
+ - Basic subject and collection manipulaton.
14
+ - Expanding data with timedelta, speed, acceleration, distance, direction.
15
+ - Getting sampling frequency, CRS and timezone automatically from dataset.
16
+ - Initial documentation with mkdocs and material theme.
labda/__init__.py ADDED
@@ -0,0 +1 @@
1
+ from .structure import Collection, Column, Subject, merge_collections, merge_subjects
@@ -0,0 +1,2 @@
1
+ from .counts_cut_points import CUT_POINTS as COUNTS_CUT_POINTS
2
+ from .transportation_cut_points import CUT_POINTS as TRANSPORTATION_CUT_POINTS
@@ -0,0 +1,35 @@
1
+ from ..structure.validation.subject import Column
2
+
3
+ CUT_POINTS = [
4
+ {
5
+ "id": 1,
6
+ "name": "Freedson Adult (1998)",
7
+ "reference": "https://journals.lww.com/acsm-msse/fulltext/1998/05000/calibration_of_the_computer_science_and.21.aspx",
8
+ "sampling_frequency": 60,
9
+ "category": "adult",
10
+ "placement": "hip",
11
+ "required_data": Column.VERTICAL_COUNTS,
12
+ "cut_points": [
13
+ {"name": "sedentary", "max": 99},
14
+ {"name": "light", "max": 1951},
15
+ {"name": "moderate", "max": 5724},
16
+ {"name": "vigorous", "max": 9498},
17
+ {"name": "very_vigorous", "max": float("inf")},
18
+ ],
19
+ },
20
+ {
21
+ "id": 2,
22
+ "name": "Freedson Adult VM3 (2011)",
23
+ "reference": "https://doi.org/10.1016/j.jsams.2011.04.003",
24
+ "sampling_frequency": 60,
25
+ "category": "adult",
26
+ "placement": "hip",
27
+ "required_data": Column.VM_COUNTS,
28
+ "cut_points": [
29
+ {"name": "light", "max": 2689},
30
+ {"name": "moderate", "max": 6166},
31
+ {"name": "vigorous", "max": 9642},
32
+ {"name": "very_vigorous", "max": float("inf")},
33
+ ],
34
+ },
35
+ ]
@@ -0,0 +1,16 @@
1
+ from ..structure.validation.subject import Column
2
+
3
+ CUT_POINTS = [
4
+ {
5
+ "id": 1,
6
+ "name": "Carlson PALMS Validation (2015)",
7
+ "reference": "https://doi.org/10.1249/MSS.0000000000000446",
8
+ "required_data": Column.SPEED,
9
+ "units": "km/h",
10
+ "cut_points": [
11
+ {"name": "walking/running", "max": 9},
12
+ {"name": "bicycle", "max": 35},
13
+ {"name": "vehicle", "max": float("inf")},
14
+ ],
15
+ },
16
+ ]
@@ -0,0 +1,8 @@
1
+ from .extras import add_timedelta
2
+ from .spatial import (
3
+ add_acceleration,
4
+ add_direction,
5
+ add_distance,
6
+ add_elevation,
7
+ add_speed,
8
+ )
@@ -0,0 +1,29 @@
1
+ import numpy as np
2
+ import pandas as pd
3
+
4
+ from ..structure.validation.subject import Column
5
+ from ..utils import columns_exists, columns_not_exists
6
+
7
+
8
+ def add_counts_vector_magnitude(
9
+ df: pd.DataFrame,
10
+ *,
11
+ name: str = Column.VM_COUNTS,
12
+ overwrite: bool = False,
13
+ ) -> pd.DataFrame:
14
+ counts_cols = [
15
+ Column.VERTICAL_COUNTS,
16
+ Column.HORIZONTAL_COUNTS,
17
+ Column.PERPENDICULAR_COUNTS,
18
+ ]
19
+
20
+ columns_exists(df, counts_cols) # type: ignore
21
+ columns_not_exists(df, [name], overwrite=overwrite)
22
+
23
+ df = df.copy()
24
+ df[name] = np.linalg.norm(
25
+ df[counts_cols].astype(float),
26
+ axis=1,
27
+ )
28
+
29
+ return df
@@ -0,0 +1,33 @@
1
+ import pandas as pd
2
+
3
+ from ..structure.validation.subject import Column
4
+ from ..utils import columns_not_exists
5
+
6
+
7
+ def add_timedelta(
8
+ df: pd.DataFrame,
9
+ *,
10
+ name: str = Column.TIMEDELTA,
11
+ overwrite: bool = False,
12
+ ) -> pd.DataFrame:
13
+ """
14
+ Adds a timedelta column to the DataFrame.
15
+
16
+ This function calculates the time difference between each row and the previous row and adds it as a new column to the DataFrame.
17
+
18
+ Args:
19
+ df (pd.DataFrame): The DataFrame to which to add the timedelta column.
20
+ name (str, optional): The name of the new timedelta column. Defaults to "timedelta".
21
+ overwrite (bool, optional): Whether to overwrite the existing timedelta column if it exists. Defaults to False.
22
+
23
+ Returns:
24
+ pd.DataFrame: The DataFrame with the added timedelta column.
25
+ """
26
+
27
+ columns_not_exists(df, [name], overwrite=overwrite)
28
+
29
+ df = df.copy()
30
+
31
+ df[name] = df.index.diff() # type: ignore
32
+
33
+ return df
@@ -0,0 +1,242 @@
1
+ import numpy as np
2
+ import pandas as pd
3
+
4
+ from ..structure.validation.subject import Column
5
+ from ..utils import (
6
+ columns_exists,
7
+ columns_not_exists,
8
+ convert_to_geodataframe,
9
+ get_crs,
10
+ get_crs_units,
11
+ get_unique_column_name,
12
+ )
13
+ from .extras import add_timedelta
14
+
15
+ __all__ = [
16
+ "add_speed",
17
+ "add_distance",
18
+ "add_acceleration",
19
+ "add_direction",
20
+ "add_elevation",
21
+ # "add_weather",
22
+ # "add_address",
23
+ ]
24
+
25
+
26
+ def add_speed(
27
+ df: pd.DataFrame,
28
+ *,
29
+ crs: str | None = None,
30
+ name: str = Column.SPEED,
31
+ overwrite: bool = False,
32
+ ) -> pd.DataFrame:
33
+ """
34
+ Adds a speed column to the DataFrame by calculating the speed based on the distance and time.
35
+ Units are based on the CRS. If CRS is not specified, the CRS will be guessed based on the coordinates (WGS84 by default).
36
+
37
+ Args:
38
+ df (pd.DataFrame): The input DataFrame.
39
+ crs (str | None, optional): The coordinate reference system. Defaults to None.
40
+ name (str, optional): The name of the speed dataframe.Column. Defaults to "speed".
41
+ overwrite (bool, optional): Whether to overwrite the existing speed column if it already exists.
42
+ Defaults to False.
43
+
44
+ Returns:
45
+ pd.DataFrame: The DataFrame with the speed column added.
46
+
47
+ Raises:
48
+ ValueError: If the speed column already exists in the DataFrame and overwrite is set to False.
49
+ """
50
+
51
+ columns_not_exists(df, [name], overwrite=overwrite)
52
+
53
+ df = df.copy()
54
+
55
+ timedelta_colname = get_unique_column_name(df)
56
+ df = add_timedelta(df, name=timedelta_colname)
57
+
58
+ distance_colname = get_unique_column_name(df)
59
+ df = add_distance(df, crs=crs, name=distance_colname)
60
+
61
+ df[name] = df[distance_colname] / df[timedelta_colname].dt.total_seconds()
62
+
63
+ # TODO: This is temporary fix for changing m/s to km/h for now.
64
+ if crs:
65
+ units = get_crs_units(crs)
66
+
67
+ if units == "metre":
68
+ df[name] = df[name] * 3.6
69
+
70
+ return df.drop(columns=[timedelta_colname, distance_colname])
71
+
72
+
73
+ def add_distance(
74
+ df: pd.DataFrame,
75
+ *,
76
+ crs: str | None = None,
77
+ name: str = Column.DISTANCE,
78
+ overwrite: bool = False,
79
+ ) -> pd.DataFrame:
80
+ """
81
+ Adds a column to the DataFrame with the distance between consecutive points.
82
+ Units are based on the CRS. If CRS is not specified, the CRS will be guessed based on the coordinates (WGS84 by default).
83
+
84
+ Args:
85
+ df (pd.DataFrame): The input DataFrame.
86
+ crs (str | None, optional): The coordinate reference system of the DataFrame. Defaults to None.
87
+ name (str, optional): The name of the new dataframe.Column. Defaults to "distance".
88
+ overwrite (bool, optional): Whether to overwrite the existing column with the same name. Defaults to False.
89
+
90
+ Returns:
91
+ pd.DataFrame: The DataFrame with the added distance dataframe.Column.
92
+
93
+ Raises:
94
+ ValueError: If the column with the specified name already exists in the DataFrame and overwrite is False.
95
+ """
96
+
97
+ columns_exists(df, [Column.LATITUDE, Column.LONGITUDE])
98
+ columns_not_exists(df, [name], overwrite=overwrite)
99
+
100
+ df = df.copy()
101
+ gdf = convert_to_geodataframe(df, crs=crs)
102
+ if gdf.crs is None:
103
+ crs = get_crs(gdf)
104
+ gdf = gdf.set_crs(crs)
105
+
106
+ df[name] = gdf.distance(gdf.shift(1)) # type: ignore
107
+
108
+ return df
109
+
110
+
111
+ def add_acceleration(
112
+ df: pd.DataFrame,
113
+ *,
114
+ crs: str | None = None,
115
+ name: str = Column.ACCELERATION,
116
+ overwrite: bool = False,
117
+ ) -> pd.DataFrame:
118
+ """
119
+ Adds a column to the DataFrame with the acceleration between consecutive points.
120
+ Acceleration is calculated based on the change in speed over time.
121
+ Units are based on the CRS. If CRS is not specified, the CRS will be guessed based on the coordinates (WGS84 by default).
122
+
123
+ Args:
124
+ df (pd.DataFrame): The input DataFrame.
125
+ crs (str | None, optional): The coordinate reference system of the DataFrame. Defaults to None.
126
+ name (str, optional): The name of the new dataframe.Column. Defaults to "acceleration".
127
+ overwrite (bool, optional): Whether to overwrite the existing column with the same name. Defaults to False.
128
+
129
+ Returns:
130
+ pd.DataFrame: The DataFrame with the added acceleration dataframe.Column.
131
+
132
+ Raises:
133
+ ValueError: If the column with the specified name already exists in the DataFrame and overwrite is False.
134
+ """
135
+
136
+ # TODO: Needs to be tested.
137
+ columns_not_exists(df, [name], overwrite=overwrite)
138
+
139
+ df = df.copy()
140
+
141
+ speed_colname = get_unique_column_name(df)
142
+ df = add_speed(df, crs=crs, name=speed_colname)
143
+
144
+ timedelta_colname = get_unique_column_name(df)
145
+ df = add_timedelta(df, name=timedelta_colname)
146
+
147
+ df[name] = (df[speed_colname] - df[speed_colname].shift()) / df[
148
+ timedelta_colname
149
+ ].dt.total_seconds()
150
+
151
+ return df.drop(columns=[speed_colname, timedelta_colname])
152
+
153
+
154
+ def add_direction(
155
+ df: pd.DataFrame,
156
+ *,
157
+ name: str = Column.DIRECTION,
158
+ overwrite: bool = False,
159
+ ) -> pd.DataFrame:
160
+ """
161
+ Adds a column to the DataFrame with the direction between consecutive points.
162
+ Direction is calculated based on the change in longitude and latitude.
163
+
164
+ Args:
165
+ df (pd.DataFrame): The input DataFrame.
166
+ name (str, optional): The name of the new dataframe.Column. Defaults to "direction".
167
+ overwrite (bool, optional): Whether to overwrite the existing column with the same name. Defaults to False.
168
+
169
+ Returns:
170
+ pd.DataFrame: The DataFrame with the added direction dataframe.Column.
171
+
172
+ Raises:
173
+ ValueError: If the column with the specified name already exists in the DataFrame and overwrite is False.
174
+ """
175
+
176
+ # TODO: Needs to be tested.
177
+ columns_exists(df, [Column.LATITUDE, Column.LONGITUDE])
178
+ columns_not_exists(df, [name], overwrite=overwrite)
179
+
180
+ df = df.copy()
181
+
182
+ # Convert to radians
183
+ lon1, lat1 = np.radians(df[Column.LONGITUDE]), np.radians(df[Column.LATITUDE])
184
+ lon2, lat2 = (
185
+ np.radians(df[Column.LONGITUDE].shift()),
186
+ np.radians(df[Column.LATITUDE].shift()),
187
+ )
188
+
189
+ dlon = lon2 - lon1
190
+
191
+ x = np.cos(lat2) * np.sin(dlon)
192
+ y = np.cos(lat1) * np.sin(lat2) - np.sin(lat1) * np.cos(lat2) * np.cos(dlon)
193
+
194
+ direction = np.arctan2(x, y)
195
+ direction = np.degrees(direction)
196
+ direction = (direction + 360) % 360
197
+
198
+ df[name] = direction
199
+
200
+ return df
201
+
202
+
203
+ def add_elevation(
204
+ df: pd.DataFrame,
205
+ *,
206
+ name: str = Column.ELEVATION,
207
+ overwrite: bool = False,
208
+ ) -> pd.DataFrame:
209
+ # TODO: Add elevation.
210
+
211
+ columns_not_exists(df, [name], overwrite=overwrite)
212
+
213
+ raise NotImplementedError("This method needs to be implemented.")
214
+
215
+
216
+ # def add_weather(
217
+ # df: pd.DataFrame,
218
+ # *,
219
+ # name: str = "weather",
220
+ # overwrite: bool = False,
221
+ # ) -> pd.DataFrame:
222
+ # # TODO: https://open-meteo.com/en/docs/historical-weather-api/
223
+ # # TODO: Add weather.
224
+
225
+ # if not overwrite:
226
+ # check_columns_existence(df, [name], exists=False)
227
+
228
+ # raise NotImplementedError("This method needs to be implemented.")
229
+
230
+
231
+ # def add_address(
232
+ # df: pd.DataFrame,
233
+ # *,
234
+ # name: str = "address",
235
+ # overwrite: bool = False,
236
+ # ) -> pd.DataFrame:
237
+ # # TODO: Add address.
238
+
239
+ # if not overwrite:
240
+ # check_columns_existence(df, [name], exists=False)
241
+
242
+ # raise NotImplementedError("This method needs to be implemented.")
@@ -0,0 +1,4 @@
1
+ from . import gadgetbridge as Gadgetbridge
2
+ from . import qstarz as Qstarz
3
+ from . import actigraph as Actigraph
4
+ from .bulk import bulk_parser
@@ -0,0 +1,232 @@
1
+ from datetime import datetime
2
+ from pathlib import Path
3
+ from typing import Any
4
+
5
+ import pandas as pd
6
+ from dateutil.parser import parse as parse_dt
7
+
8
+ from ..structure.subject import Metadata, Sensor, Subject, Vendor
9
+ from ..structure.validation.subject import SCHEMA, Column
10
+ from ..utils import get_sampling_frequency
11
+
12
+ # TODO: Add param for column mapping if there is no header.
13
+ # TODO: It is not perfect, but it is a start and it should be able to parse automatically almost all ActiGraph files from SDU.
14
+
15
+
16
+ def filter_datetime(
17
+ df: pd.DataFrame,
18
+ start: datetime | None = None,
19
+ end: datetime | None = None,
20
+ ) -> pd.DataFrame:
21
+ if start and end:
22
+ if end < start:
23
+ raise ValueError("The end time must be after the start time.")
24
+
25
+ if start:
26
+ df = df[df[Column.DATETIME] >= start]
27
+
28
+ if end:
29
+ df = df[df[Column.DATETIME] <= end]
30
+
31
+ return df
32
+
33
+
34
+ def get_metadata(metadata: list[str]) -> dict[str, Any]:
35
+ model = metadata[0].split("ActiGraph")[-1].split()[0].strip()
36
+ firmware = metadata[0].split("Firmware")[-1].split()[0].strip()
37
+ serial_number = metadata[1].split()[-1].strip()
38
+ start_time = metadata[2].split()[-1].strip()
39
+ start_date = metadata[3].split()[-1].strip()
40
+ start_datetime = parse_dt(start_date + " " + start_time)
41
+ sampling_frequency = pd.to_timedelta(
42
+ metadata[4].split()[-1].strip()
43
+ ).total_seconds()
44
+
45
+ return {
46
+ "model": model,
47
+ "firmware": firmware,
48
+ "serial_number": serial_number,
49
+ "start_datetime": start_datetime,
50
+ "sampling_frequency": sampling_frequency,
51
+ }
52
+
53
+
54
+ def handle_dataframe_header(data: list[str]) -> pd.DataFrame:
55
+ data_header = data[0].split(",")
56
+ data = data[1:]
57
+ df = pd.DataFrame(data)
58
+ df = df[0].str.split(",", expand=True)
59
+ df.columns = data_header
60
+ df.columns = df.columns.str.strip().str.lower()
61
+
62
+ column_mapping = {
63
+ "epoch": "time",
64
+ "axis1": Column.VERTICAL_COUNTS,
65
+ "axis2": Column.HORIZONTAL_COUNTS,
66
+ "axis3": Column.PERPENDICULAR_COUNTS,
67
+ "activity": Column.VERTICAL_COUNTS,
68
+ "activity (horizontal)": Column.HORIZONTAL_COUNTS,
69
+ "3rd axis": Column.PERPENDICULAR_COUNTS,
70
+ "vector magnitude": Column.VM_COUNTS,
71
+ "vm": Column.VM_COUNTS,
72
+ "steps": Column.STEPS,
73
+ "lux": Column.LUX,
74
+ "inclinometer off": "non-wear",
75
+ "inclinometer standing": "standing",
76
+ "inclinometer sitting": "sitting",
77
+ "inclinometer lying": "lying",
78
+ }
79
+
80
+ for old_column, new_column in column_mapping.items():
81
+ if old_column in df.columns:
82
+ df.rename(columns={old_column: new_column}, inplace=True)
83
+
84
+ if Column.VM_COUNTS in df.columns and df[Column.VM_COUNTS].dtype == object:
85
+ df[Column.VM_COUNTS] = df[Column.VM_COUNTS].str.replace('"', "")
86
+
87
+ return df
88
+
89
+
90
+ def handle_inclinometer(df: pd.DataFrame) -> pd.DataFrame:
91
+ df = df.copy()
92
+ columns = ["non-wear", "standing", "sitting", "lying"]
93
+ exists_columns = []
94
+
95
+ for column in columns:
96
+ if column in df.columns:
97
+ exists_columns.append(column)
98
+
99
+ if exists_columns:
100
+ df[Column.POSITION] = df[exists_columns].idxmax(axis=1)
101
+
102
+ if "non-wear" in exists_columns:
103
+ df[Column.WEAR] = True
104
+ df.loc[df[Column.POSITION] == "non-wear", Column.WEAR] = False
105
+ df.loc[df[Column.WEAR] == False, Column.POSITION] = pd.NA
106
+
107
+ df.drop(columns=exists_columns, inplace=True)
108
+
109
+ return df
110
+
111
+
112
+ def handle_datetime(
113
+ df: pd.DataFrame,
114
+ metadata: dict[str, Any],
115
+ dt_format: str | None = None,
116
+ ) -> pd.DataFrame:
117
+ df = df.copy()
118
+
119
+ if "date" in df.columns and "time" in df.columns:
120
+ df[Column.DATETIME] = pd.to_datetime(
121
+ df["date"] + " " + df["time"], format=dt_format
122
+ )
123
+ df.drop(columns=["date", "time"], inplace=True)
124
+ elif metadata.get("start_datetime") and metadata.get("sampling_frequency"):
125
+ start_datetime = metadata["start_datetime"]
126
+ sampling_frequency = metadata["sampling_frequency"]
127
+ df[Column.DATETIME] = start_datetime + pd.to_timedelta(
128
+ df.index * sampling_frequency, unit="s"
129
+ )
130
+
131
+ return df
132
+
133
+
134
+ def from_csv(
135
+ path: str | Path,
136
+ line: int = 10,
137
+ metadata: bool = True,
138
+ *,
139
+ subject_id: str | None = None,
140
+ sensor_id: str | None = None,
141
+ serial_number: str | None = None,
142
+ model: str | None = None,
143
+ vendor: Vendor = Vendor.ACTIGRAPH,
144
+ firmware_version: str | None = None,
145
+ start: datetime | None = None,
146
+ end: datetime | None = None,
147
+ timezone: str | None = None,
148
+ dt_format: str | None = None,
149
+ ) -> Subject:
150
+ if isinstance(path, str):
151
+ path = Path(path)
152
+
153
+ if not path.is_file():
154
+ raise ValueError(f"Invalid file path: {path}")
155
+
156
+ if path.suffix != ".csv":
157
+ raise ValueError(f"Invalid file extension: {path.suffix}")
158
+
159
+ with path.open("r") as f:
160
+ file = f.read()
161
+
162
+ lines = file.splitlines()
163
+
164
+ if metadata:
165
+ parsed_metadata = get_metadata(lines[0:line])
166
+ else:
167
+ parsed_metadata = {}
168
+
169
+ data = lines[line:]
170
+
171
+ if any(char.isalpha() for char in data[0]):
172
+ df = handle_dataframe_header(data)
173
+ df = handle_inclinometer(df)
174
+ else:
175
+ df = pd.DataFrame(
176
+ data,
177
+ )
178
+ df = df[0].str.split(",", expand=True)
179
+ df = df.iloc[:, :3]
180
+ df.columns = [
181
+ Column.VERTICAL_COUNTS,
182
+ Column.HORIZONTAL_COUNTS,
183
+ Column.PERPENDICULAR_COUNTS,
184
+ ]
185
+
186
+ df = handle_datetime(df, parsed_metadata, dt_format)
187
+ df = filter_datetime(df, start, end)
188
+
189
+ df.set_index(Column.DATETIME, inplace=True)
190
+ sampling_frequency = parsed_metadata.get("sampling_frequency")
191
+
192
+ df.dropna(how="all", inplace=True)
193
+
194
+ if not sampling_frequency:
195
+ sampling_frequency = get_sampling_frequency(df)
196
+ df = df.resample(f"{sampling_frequency}s").nearest()
197
+
198
+ columns = [col.value for col in Column]
199
+ ordered_columns = [col for col in columns if col in df.columns]
200
+ df = df[ordered_columns]
201
+
202
+ df = SCHEMA.validate(df)
203
+
204
+ sensor_id = (
205
+ sensor_id if sensor_id else parsed_metadata.get("serial_number") or path.stem
206
+ ) # type: str
207
+ serial_number = (
208
+ serial_number if serial_number else parsed_metadata.get("serial_number")
209
+ )
210
+ model = model if model else parsed_metadata.get("model")
211
+ firmware_version = (
212
+ firmware_version if firmware_version else parsed_metadata.get("firmware")
213
+ )
214
+
215
+ subject_id = subject_id or path.stem
216
+
217
+ sensor = Sensor(
218
+ id=sensor_id,
219
+ serial_number=serial_number,
220
+ model=model,
221
+ vendor=vendor,
222
+ firmware_version=firmware_version,
223
+ ) # type: ignore
224
+
225
+ metadata = Metadata(
226
+ id=subject_id,
227
+ sensor=[sensor],
228
+ sampling_frequency=sampling_frequency,
229
+ timezone=timezone,
230
+ ) # type: ignore
231
+
232
+ return Subject(metadata=metadata, df=df) # type: ignore
labda/parsers/bulk.py ADDED
@@ -0,0 +1,31 @@
1
+ from pathlib import Path
2
+ from typing import Callable
3
+
4
+ from labda.structure import Collection
5
+
6
+ # TODO: Add bulk parsing with linkage file
7
+ # TODO: Add parallel processing of files.
8
+
9
+
10
+ def bulk_parser(folder: str | Path, parser: Callable, id: str | None = None, **kwargs):
11
+ if isinstance(folder, str):
12
+ folder = Path(folder)
13
+
14
+ if not folder.is_dir():
15
+ raise ValueError(f"'{folder}' is not a valid folder.")
16
+
17
+ files = list(Path(folder).glob("*"))
18
+
19
+ if id is None:
20
+ id = folder.name
21
+
22
+ collection = Collection(id=id)
23
+
24
+ for file in files:
25
+ try:
26
+ subject = parser(file, **kwargs)
27
+ collection.add_subject(subject)
28
+ except Exception as e:
29
+ print(f"Error while parsing | {file} | {e}") # TODO: Log error
30
+
31
+ return collection