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
@@ -0,0 +1,232 @@
1
+ import secrets
2
+ from datetime import datetime
3
+ from pathlib import Path
4
+
5
+ import pandas as pd
6
+ import pytz
7
+ from sqlalchemy import Engine, MetaData, Table, create_engine, select
8
+
9
+ from ..structure.subject import Metadata, Sensor, Subject, Vendor
10
+ from ..structure.validation.subject import SCHEMA, Column
11
+ from ..utils import get_sampling_frequency
12
+
13
+ # TODO: This should be refactored, so it gets subject one by one from database based on datetime range.
14
+ # TODO: Add docstrings
15
+
16
+
17
+ def _get_xiaomi_records(
18
+ engine: Engine,
19
+ *,
20
+ start: datetime | None = None,
21
+ end: datetime | None = None,
22
+ ids: list[str] | None = None,
23
+ ) -> pd.DataFrame:
24
+ metadata = MetaData()
25
+ band_activity = Table("MI_BAND_ACTIVITY_SAMPLE", metadata, autoload_with=engine) # type: ignore
26
+
27
+ # RAW_KIND explained: https://codeberg.org/Freeyourgadget/Gadgetbridge/issues/686
28
+ query = select(
29
+ band_activity.c.TIMESTAMP,
30
+ band_activity.c.DEVICE_ID,
31
+ band_activity.c.STEPS,
32
+ band_activity.c.HEART_RATE,
33
+ )
34
+
35
+ if ids:
36
+ query = query.where(band_activity.c.DEVICE_ID.in_(ids))
37
+
38
+ if start:
39
+ query = query.where(band_activity.c.TIMESTAMP >= start.timestamp())
40
+
41
+ if end:
42
+ query = query.where(band_activity.c.TIMESTAMP <= end.timestamp())
43
+
44
+ if start and end:
45
+ if end < start:
46
+ raise ValueError("The end time must be after the start time.")
47
+
48
+ records = pd.read_sql_query(
49
+ query,
50
+ engine,
51
+ parse_dates=["TIMESTAMP"],
52
+ dtype={"DEVICE_ID": "UInt16", "STEPS": "UInt8", "HEART_RATE": "UInt8"},
53
+ )
54
+ records.rename(
55
+ columns={
56
+ "TIMESTAMP": Column.DATETIME,
57
+ "DEVICE_ID": "device_id",
58
+ "STEPS": Column.STEPS,
59
+ "HEART_RATE": Column.HEART_RATE,
60
+ },
61
+ inplace=True,
62
+ )
63
+
64
+ records.loc[records[Column.HEART_RATE] == 255, Column.HEART_RATE] = None
65
+
66
+ return records
67
+
68
+
69
+ def _get_xiaomi_devices(
70
+ engine: Engine, *, linkage: pd.DataFrame | None = None
71
+ ) -> pd.DataFrame:
72
+ metadata = MetaData()
73
+ device = Table("DEVICE", metadata, autoload_with=engine) # type: ignore
74
+ query = select(
75
+ device.c._id, device.c.NAME, device.c.IDENTIFIER, device.c.ALIAS, device.c.MODEL
76
+ )
77
+
78
+ if isinstance(linkage, pd.DataFrame) and not linkage.empty:
79
+ mac = linkage["sensor_id"].to_list() # type: ignore
80
+ query = query.where(device.c.IDENTIFIER.in_(mac))
81
+
82
+ devices = pd.read_sql_query(
83
+ query,
84
+ engine,
85
+ dtype={
86
+ "_id": "UInt16",
87
+ "NAME": "string",
88
+ "IDENTIFIER": "string",
89
+ "ALIAS": "string",
90
+ "IDENTIFIER": "string",
91
+ },
92
+ )
93
+ devices.rename(
94
+ columns={
95
+ "_id": "device_id",
96
+ "NAME": "model",
97
+ "IDENTIFIER": "serial_number",
98
+ "MODEL": "firmware_version",
99
+ "ALIAS": "alias",
100
+ },
101
+ inplace=True,
102
+ )
103
+
104
+ if isinstance(linkage, pd.DataFrame) and not linkage.empty:
105
+ devices = devices.merge(
106
+ linkage, left_on="serial_number", right_on="sensor_id", how="left"
107
+ )
108
+ devices.drop(columns=["sensor_id"], inplace=True)
109
+ else:
110
+ devices[Column.SUBJECT_ID] = devices["serial_number"]
111
+
112
+ return devices
113
+
114
+
115
+ def _get_wear_by_hr(df: pd.DataFrame) -> pd.DataFrame:
116
+ # FIXME: Rework this function.
117
+ df = df.copy().reset_index(drop=True)
118
+ df[Column.WEAR] = pd.NA
119
+ indices = df[Column.HEART_RATE].dropna().index
120
+ intervals = indices.to_series().diff()
121
+
122
+ if not intervals.isna().all():
123
+ interval = int(intervals.mode().iloc[0]) - 1
124
+ df.loc[df[Column.HEART_RATE].notna(), Column.WEAR] = True
125
+
126
+ if not interval == 0:
127
+ df[Column.WEAR] = df[Column.WEAR].bfill(limit=interval)
128
+ df.loc[df[Column.WEAR].isna(), Column.WEAR] = False
129
+
130
+ return df
131
+
132
+
133
+ def _get_xiaomi(
134
+ engine: Engine,
135
+ *,
136
+ start: datetime | None,
137
+ end: datetime | None,
138
+ linkage: pd.DataFrame | None,
139
+ ) -> tuple[pd.DataFrame, pd.DataFrame]:
140
+ devices = _get_xiaomi_devices(engine, linkage=linkage)
141
+
142
+ ids = devices["device_id"].to_list() if not devices.empty else None
143
+ records = _get_xiaomi_records(engine, start=start, end=end, ids=ids)
144
+ records = records.merge(
145
+ devices[[Column.SUBJECT_ID, "device_id"]], on="device_id", how="left"
146
+ )
147
+ return (
148
+ records[
149
+ [
150
+ Column.DATETIME,
151
+ Column.SUBJECT_ID,
152
+ Column.STEPS,
153
+ Column.HEART_RATE,
154
+ ]
155
+ ],
156
+ devices,
157
+ )
158
+
159
+
160
+ def _generate_records(
161
+ records: pd.DataFrame,
162
+ devices: pd.DataFrame,
163
+ ) -> list[Subject]:
164
+ # TODO: Add docstring.
165
+ # TODO: Maybe force resampling for making sure that all records have the same frequency.
166
+ records = records.groupby(Column.SUBJECT_ID, as_index=False) # type: ignore
167
+ objs = []
168
+ for record in records:
169
+ id = record[0] # type: ignore
170
+ df = record[1] # type: ignore
171
+ df = _get_wear_by_hr(df)
172
+ df.set_index(Column.DATETIME, inplace=True)
173
+ df = SCHEMA.validate(df)
174
+
175
+ sampling_frequency = get_sampling_frequency(df)
176
+
177
+ device = devices.loc[devices[Column.SUBJECT_ID] == id].iloc[0].to_dict()
178
+ sensor = Sensor(id=device["serial_number"], **device, vendor=Vendor.XIAOMI)
179
+ metadata = Metadata(
180
+ id=id, # type: ignore
181
+ sensor=[sensor],
182
+ sampling_frequency=sampling_frequency,
183
+ )
184
+
185
+ objs.append(Subject(metadata=metadata, df=df))
186
+
187
+ return objs
188
+
189
+
190
+ def from_xiaomi(
191
+ path: str | Path,
192
+ *,
193
+ start: datetime | None = None,
194
+ end: datetime | None = None,
195
+ linkage: pd.DataFrame | None = None,
196
+ timezone: str = "UTC",
197
+ ) -> list[Subject]:
198
+ # TODO: Rewrite docstring, different return type.
199
+ # TODO: Something to check timezone - if start-end is TZ aware, then no need to guess timezone, if not - guess it.
200
+ if isinstance(path, str):
201
+ path = Path(path)
202
+
203
+ if not path.is_file():
204
+ raise ValueError(f"Invalid file path: {path}")
205
+
206
+ engine = create_engine(f"sqlite:///{path.as_posix()}")
207
+
208
+ utc = pytz.timezone("UTC")
209
+ timezone = pytz.timezone(timezone) # type: ignore
210
+
211
+ if start:
212
+ start = timezone.localize(start).astimezone(utc) # type: ignore
213
+
214
+ if end:
215
+ end = timezone.localize(end).astimezone(utc) # type: ignore
216
+
217
+ records, devices = _get_xiaomi(
218
+ engine,
219
+ start=start,
220
+ end=end,
221
+ linkage=linkage,
222
+ )
223
+
224
+ records[Column.DATETIME] = (
225
+ records[Column.DATETIME]
226
+ .dt.tz_localize(pytz.timezone("UTC"))
227
+ .dt.tz_convert(timezone)
228
+ ).dt.tz_localize(None)
229
+
230
+ subjects = _generate_records(records, devices)
231
+
232
+ return subjects
@@ -0,0 +1,89 @@
1
+ import xml.etree.ElementTree as ET
2
+ from pathlib import Path
3
+ from typing import Any
4
+
5
+ import pandas as pd
6
+
7
+
8
+ # TODO:Fix this.
9
+ # TODO: Edit so it will return Records object.
10
+ def get_value(
11
+ parent: ET.Element, child: str, ns: dict[str, str], name: str | None = None
12
+ ) -> dict[str, Any]:
13
+ value = parent.find(child, ns)
14
+ name = name or child
15
+ value = value.text if isinstance(value, ET.Element) else None
16
+
17
+ return {name: value}
18
+
19
+
20
+ def get_atrib(
21
+ element: ET.Element, atrib: str, name: str | None = None
22
+ ) -> dict[str, Any]:
23
+ value = element.attrib.get(atrib)
24
+ name = name or atrib
25
+
26
+ return {name: value}
27
+
28
+
29
+ def from_gpx(path: str | Path, vendor: str) -> pd.DataFrame:
30
+ if isinstance(path, str):
31
+ path = Path(path)
32
+
33
+ tree = ET.parse(path)
34
+ root = tree.getroot()
35
+
36
+ match vendor:
37
+ case "garmin":
38
+ df = garmin_gpx(root)
39
+ case _:
40
+ raise ValueError(f"Unsupported vendor: {vendor}.")
41
+
42
+ return df
43
+
44
+
45
+ def garmin_gpx(root: ET.Element) -> pd.DataFrame:
46
+ ns = {
47
+ "gpx": "http://www.topografix.com/GPX/1/1",
48
+ "ext": "http://www.garmin.com/xmlschemas/TrackPointExtension/v1",
49
+ }
50
+
51
+ points = []
52
+ for trackpoint in root.findall(".//gpx:trkpt", ns):
53
+ lat = get_atrib(trackpoint, "lat")
54
+ lon = get_atrib(trackpoint, "lon")
55
+ elevation = get_value(trackpoint, "gpx:ele", ns, "elevation")
56
+ datetime = get_value(trackpoint, "gpx:time", ns, "datetime")
57
+
58
+ point = {
59
+ **datetime,
60
+ **lat,
61
+ **lon,
62
+ **elevation,
63
+ }
64
+
65
+ extensions = trackpoint.find("gpx:extensions", ns)
66
+ if isinstance(extensions, ET.Element):
67
+ hr = get_value(extensions, ".//ext:hr", ns, "hr")
68
+ cad = get_value(extensions, ".//ext:cad", ns, "cad")
69
+ point.update(**hr, **cad)
70
+ else:
71
+ point["hr"] = None
72
+ point["cad"] = None
73
+
74
+ points.append(point)
75
+
76
+ df = pd.DataFrame.from_records(points)
77
+ df = df.astype(
78
+ {
79
+ "datetime": "datetime64[ns, UTC]",
80
+ "lat": "Float64",
81
+ "lon": "Float64",
82
+ "elevation": "Float64",
83
+ "hr": "Int64",
84
+ "cad": "Int64",
85
+ } # type: ignore
86
+ )
87
+ df["datetime"] = df["datetime"].dt.tz_localize(None)
88
+
89
+ return df
@@ -0,0 +1,366 @@
1
+ from datetime import datetime
2
+ from pathlib import Path
3
+
4
+ import pandas as pd
5
+
6
+ from ..structure.subject import Metadata, Sensor, Subject, Vendor
7
+ from ..structure.validation.subject import SCHEMA, Column
8
+ from ..utils import (
9
+ align_datetimes,
10
+ columns_exists,
11
+ convert_to_dataframe,
12
+ convert_to_geodataframe,
13
+ get_crs,
14
+ get_sampling_frequency,
15
+ get_timezone,
16
+ )
17
+
18
+ # TODO: Can be refactored.,
19
+ # TODO: Remove all spaces from the column names, so there is no need to check if there is space or not.
20
+
21
+
22
+ def parse_snr(df: pd.DataFrame) -> pd.DataFrame:
23
+ df = df.copy()
24
+
25
+ sat_info_4 = "SAT INFO (SID-ELE-AZI-SNR)"
26
+ sat_info_2 = "SAT INFO (SID-SNR)"
27
+ snr = "SNR"
28
+
29
+ column = None
30
+
31
+ if sat_info_2 in df.columns:
32
+ column = sat_info_2
33
+ elif sat_info_4 in df.columns:
34
+ column = sat_info_4
35
+ elif snr in df.columns:
36
+ column = snr
37
+
38
+ if column:
39
+ df.loc[df[column].str.contains("/"), column] = pd.NA
40
+ temp_df = df[df[column].notna()].copy()
41
+ temp_df[column] = temp_df[column].str.split(";")
42
+ temp_df[column] = temp_df[column].apply(
43
+ lambda row: [int(signal.split("-")[-1]) for signal in row]
44
+ )
45
+ temp_df[Column.SNR_VIEWED] = temp_df[column].apply(lambda row: sum(row))
46
+ columns = [Column.SNR_VIEWED]
47
+
48
+ if Column.NSAT_USED in temp_df.columns:
49
+ temp_df[Column.SNR_USED] = temp_df.apply(
50
+ lambda row: sum(row[column][: row[Column.NSAT_USED]]), axis=1
51
+ )
52
+ columns.append(Column.SNR_USED)
53
+
54
+ if Column.NSAT_VIEWED not in temp_df.columns:
55
+ temp_df[Column.NSAT_VIEWED] = temp_df[column].apply(lambda row: len(row))
56
+ columns.append(Column.NSAT_VIEWED)
57
+
58
+ df = df.join(temp_df[columns])
59
+
60
+ return df
61
+
62
+
63
+ def parse_nsat(df: pd.DataFrame) -> pd.DataFrame:
64
+ df = df.copy()
65
+
66
+ possible_columns = ["NSAT (USED/VIEW)", "NSAT(USED/VIEW)", "NSAT(USED/VIEWED)"]
67
+
68
+ for column in possible_columns:
69
+ if column in df.columns:
70
+ nsat_uv = "NSAT (USED/VIEW)"
71
+ df.rename(columns={column: nsat_uv}, inplace=True)
72
+ break
73
+
74
+ nsat = "NSAT"
75
+
76
+ if nsat_uv in df.columns:
77
+ if df[nsat_uv].str.contains("/").any():
78
+ df[nsat_uv] = df[nsat_uv].str.split("/")
79
+ elif df[nsat_uv].str.contains("\(").any():
80
+ df[nsat_uv] = df[nsat_uv].str.replace(")", "").str.split("(")
81
+ df[Column.NSAT_USED] = pd.to_numeric(df[nsat_uv].str[0])
82
+ df[Column.NSAT_VIEWED] = pd.to_numeric(df[nsat_uv].str[1])
83
+ elif nsat in df.columns:
84
+ df.rename(columns={nsat: Column.NSAT_USED}, inplace=True)
85
+ df[Column.NSAT_USED] = pd.to_numeric(df[Column.NSAT_USED])
86
+
87
+ return df
88
+
89
+
90
+ def parse_dop(df: pd.DataFrame) -> pd.DataFrame:
91
+ df = df.copy()
92
+
93
+ column_mapping = {
94
+ "HDOP": Column.HDOP,
95
+ "VDOP": Column.VDOP,
96
+ "PDOP": Column.PDOP,
97
+ }
98
+
99
+ for old_column, new_column in column_mapping.items():
100
+ if old_column in df.columns:
101
+ df.rename(columns={old_column: new_column}, inplace=True)
102
+
103
+ return df
104
+
105
+
106
+ def parse_speed(df: pd.DataFrame) -> pd.DataFrame:
107
+ df = df.copy()
108
+
109
+ column = None
110
+ speed_kmh = "SPEED(km/h)"
111
+ speed = "SPEED"
112
+
113
+ if speed in df.columns:
114
+ column = speed
115
+ elif speed_kmh in df.columns:
116
+ column = speed_kmh
117
+
118
+ if column:
119
+ if df[column].dtype == "object":
120
+ df[column] = df[column].str.strip("km/h")
121
+ df[column] = pd.to_numeric(df[column])
122
+ df[column] = (df[column] / 3.6).round(2)
123
+ df.rename(columns={column: Column.SPEED}, inplace=True)
124
+
125
+ return df
126
+
127
+
128
+ def parse_distance(df: pd.DataFrame) -> pd.DataFrame:
129
+ df = df.copy()
130
+
131
+ column = None
132
+ distance_km = "DISTANCE(m)"
133
+
134
+ if distance_km in df.columns:
135
+ column = distance_km
136
+
137
+ if column:
138
+ df[column] = pd.to_numeric(df[column])
139
+ df[column] = df[column].round(2)
140
+ df.rename(columns={column: Column.DISTANCE}, inplace=True)
141
+
142
+ return df
143
+
144
+
145
+ def parse_elevation(df: pd.DataFrame) -> pd.DataFrame:
146
+ df = df.copy()
147
+
148
+ column = None
149
+ height_m = "HEIGHT(m)"
150
+ height = "HEIGHT"
151
+
152
+ if height_m in df.columns:
153
+ column = height_m
154
+ elif height in df.columns:
155
+ column = height
156
+
157
+ if df[column].dtype == "object":
158
+ df[column] = df[column].str.strip("M")
159
+ df[column] = pd.to_numeric(df[column])
160
+ df[column] = df[column].round(2)
161
+ df.rename(columns={column: Column.ELEVATION}, inplace=True)
162
+ return df
163
+
164
+
165
+ def filter_datetime(
166
+ df: pd.DataFrame,
167
+ start: datetime | None = None,
168
+ end: datetime | None = None,
169
+ ) -> pd.DataFrame:
170
+ if start and end:
171
+ if end < start:
172
+ raise ValueError("The end time must be after the start time.")
173
+
174
+ if start:
175
+ df = df[df[Column.DATETIME] >= start]
176
+
177
+ if end:
178
+ df = df[df[Column.DATETIME] <= end]
179
+
180
+ return df
181
+
182
+
183
+ def handle_crs(df: pd.DataFrame, crs: str) -> pd.DataFrame:
184
+ gdf = convert_to_geodataframe(df, crs="WGS84")
185
+ gdf.to_crs(crs, inplace=True)
186
+ df = convert_to_dataframe(gdf)
187
+
188
+ return df
189
+
190
+
191
+ def parse_datetime(df: pd.DataFrame, dt_format: str | None = None) -> pd.DataFrame:
192
+ df = df.copy()
193
+ column = None
194
+
195
+ # FIXME: Fix this...
196
+ if "DATE" in df.columns and "TIME" in df.columns:
197
+ date = "DATE"
198
+ time = "TIME"
199
+ elif "LOCAL DATE" in df.columns and "LOCAL TIME" in df.columns:
200
+ date = "LOCAL DATE"
201
+ time = "LOCAL TIME"
202
+ elif "LOCAL TIME" in df.columns:
203
+ column = "LOCAL TIME"
204
+
205
+ else:
206
+ raise ValueError("The date and time columns are missing.")
207
+
208
+ if column:
209
+ df.rename(columns={column: Column.DATETIME}, inplace=True)
210
+ else:
211
+ df = df.astype({date: "string", time: "string"})
212
+ df[Column.DATETIME] = df[date] + " " + df[time]
213
+ df.drop(columns=[date, time], inplace=True)
214
+
215
+ df[Column.DATETIME] = pd.to_datetime(df[Column.DATETIME], format=dt_format)
216
+
217
+ return df
218
+
219
+
220
+ def parse_coordinates(df: pd.DataFrame) -> pd.DataFrame:
221
+ df = df.copy()
222
+ columns_exists(df, ["LATITUDE", "LONGITUDE", "N/S", "E/W"])
223
+ df.rename(
224
+ columns={"LATITUDE": Column.LATITUDE, "LONGITUDE": Column.LONGITUDE},
225
+ inplace=True,
226
+ )
227
+ df[[Column.LATITUDE, Column.LONGITUDE]] = df[
228
+ [Column.LATITUDE, Column.LONGITUDE]
229
+ ].apply(pd.to_numeric)
230
+
231
+ df.loc[df["N/S"] == "S", Column.LATITUDE] = -df[Column.LATITUDE]
232
+ df.loc[df["E/W"] == "W", Column.LONGITUDE] = -df[Column.LONGITUDE]
233
+
234
+ return df
235
+
236
+
237
+ def drop_empty_columns(df: pd.DataFrame) -> None:
238
+ """
239
+ Drops the columns with empty names from the DataFrame.
240
+
241
+ This function identifies the columns with empty names in the DataFrame and drops them in-place.
242
+
243
+ Args:
244
+ df (pd.DataFrame): The DataFrame from which to drop the columns with empty names.
245
+
246
+ Returns:
247
+ None
248
+ """
249
+ columns_to_drop = df.columns[(df.columns == "")]
250
+ df.drop(columns=columns_to_drop, inplace=True)
251
+
252
+
253
+ def remove_repeated_headers(df: pd.DataFrame) -> None:
254
+ """
255
+ Removes the rows from the DataFrame that are identical to the header.
256
+
257
+ This function identifies the rows that are identical to the header in the DataFrame and removes them in-place.
258
+
259
+ Args:
260
+ df (pd.DataFrame): The DataFrame from which to remove the rows identical to the header.
261
+
262
+ Returns:
263
+ None
264
+ """
265
+ header = pd.Series(df.columns, df.columns)
266
+ same_as_header = df[df.eq(header).all(axis=1)].index
267
+ df.drop(same_as_header, inplace=True)
268
+
269
+
270
+ def from_csv(
271
+ path: str | Path,
272
+ *,
273
+ subject_id: str | None = None,
274
+ sensor_id: str | None = None,
275
+ serial_number: str | None = None,
276
+ model: str | None = None,
277
+ vendor: Vendor = Vendor.QSTARZ,
278
+ firmware_version: str | None = None,
279
+ start: datetime | None = None,
280
+ end: datetime | None = None,
281
+ sampling_frequency: float | None = None,
282
+ crs: str | None = None,
283
+ timezone: str | None = None,
284
+ dt_format: str | None = None,
285
+ ) -> Subject:
286
+ if isinstance(path, str):
287
+ path = Path(path)
288
+
289
+ if not path.is_file():
290
+ raise ValueError(f"Invalid file path: {path}")
291
+
292
+ if path.suffix != ".csv":
293
+ raise ValueError(f"Invalid file extension: {path.suffix}")
294
+
295
+ df = pd.read_csv(path, engine="pyarrow")
296
+ drop_empty_columns(df)
297
+ df.columns = df.columns.str.strip()
298
+
299
+ remove_repeated_headers(df)
300
+
301
+ df = parse_coordinates(df)
302
+ df = parse_datetime(df, dt_format)
303
+
304
+ if not crs:
305
+ crs = get_crs(df)
306
+
307
+ df = handle_crs(df, crs)
308
+
309
+ if not timezone:
310
+ timezone = get_timezone(df)
311
+
312
+ df.drop_duplicates(
313
+ subset=[Column.DATETIME], inplace=True
314
+ ) # If there are duplicates, keep only the first one - because later with resample, datetime needs to be unique.
315
+
316
+ # Remove rows with "Estimated (dead reckoning)" in the "VALID" column.
317
+ if "VALID" in df.columns:
318
+ df = df[~(df["VALID"] == "Estimated (dead reckoning)")]
319
+
320
+ df = filter_datetime(df, start, end)
321
+ df = parse_distance(df)
322
+ df = parse_speed(df)
323
+ df = parse_elevation(df)
324
+ df = parse_dop(df)
325
+ df = parse_nsat(df)
326
+ df = parse_snr(df)
327
+
328
+ if Column.NSAT_USED in df.columns and Column.NSAT_VIEWED in df.columns:
329
+ df[Column.NSAT_RATIO] = (
330
+ (df[Column.NSAT_USED] * 100) / df[Column.NSAT_VIEWED]
331
+ ).round(2)
332
+
333
+ df.set_index(Column.DATETIME, inplace=True)
334
+
335
+ if not sampling_frequency:
336
+ sampling_frequency = get_sampling_frequency(df)
337
+
338
+ df = align_datetimes(df, sampling_frequency)
339
+
340
+ # Order columns as defined in Column, remove extra columns
341
+ records_columns = [col.value for col in Column]
342
+ ordered_columns = [col for col in records_columns if col in df.columns]
343
+ df = df[ordered_columns]
344
+
345
+ df = SCHEMA.validate(df)
346
+
347
+ subject_id = subject_id or path.stem
348
+ sensor_id = sensor_id or path.stem
349
+
350
+ sensor = Sensor(
351
+ id=sensor_id,
352
+ serial_number=serial_number,
353
+ model=model,
354
+ vendor=vendor,
355
+ firmware_version=firmware_version,
356
+ )
357
+
358
+ metadata = Metadata(
359
+ id=subject_id,
360
+ sensor=[sensor],
361
+ sampling_frequency=sampling_frequency,
362
+ crs=crs,
363
+ timezone=timezone,
364
+ )
365
+
366
+ return Subject(metadata=metadata, df=df)
@@ -0,0 +1 @@
1
+ from .domains import detect_domains
@@ -0,0 +1,2 @@
1
+ from .activity_intensity import detect_activity_intensity
2
+ from .wear import detect_wear