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.
- CHANGELOG.md +16 -0
- labda/__init__.py +1 -0
- labda/assets/__init__.py +2 -0
- labda/assets/counts_cut_points.py +35 -0
- labda/assets/transportation_cut_points.py +16 -0
- labda/expanders/__init__.py +8 -0
- labda/expanders/accelerometer.py +29 -0
- labda/expanders/extras.py +33 -0
- labda/expanders/spatial.py +242 -0
- labda/parsers/__init__.py +4 -0
- labda/parsers/actigraph.py +232 -0
- labda/parsers/bulk.py +31 -0
- labda/parsers/gadgetbridge.py +232 -0
- labda/parsers/garmin.py +89 -0
- labda/parsers/qstarz.py +366 -0
- labda/processing/__init__.py +1 -0
- labda/processing/accelerometer/__init__.py +2 -0
- labda/processing/accelerometer/activity_intensity.py +57 -0
- labda/processing/accelerometer/steps.py +24 -0
- labda/processing/accelerometer/wear.py +38 -0
- labda/processing/bouts.py +85 -0
- labda/processing/domains.py +32 -0
- labda/processing/manipulations.py +22 -0
- labda/processing/spatial/__init__.py +4 -0
- labda/processing/spatial/environment.py +50 -0
- labda/processing/spatial/manipulations.py +186 -0
- labda/processing/spatial/timeline.py +99 -0
- labda/processing/spatial/transportation.py +80 -0
- labda/processing/spatial/trips.py +551 -0
- labda/processing/wear_validity.py +69 -0
- labda/structure/__init__.py +6 -0
- labda/structure/collection.py +103 -0
- labda/structure/domains.py +29 -0
- labda/structure/linkage.py +28 -0
- labda/structure/merging.py +133 -0
- labda/structure/resampling.py +82 -0
- labda/structure/subject.py +294 -0
- labda/structure/validation/__init__.py +0 -0
- labda/structure/validation/domains.py +37 -0
- labda/structure/validation/linkage.py +28 -0
- labda/structure/validation/subject.py +323 -0
- labda/utils.py +240 -0
- labda/visualisation/__init__.py +0 -0
- labda-0.0.2.dist-info/METADATA +59 -0
- labda-0.0.2.dist-info/RECORD +46 -0
- 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
|
labda/parsers/garmin.py
ADDED
|
@@ -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
|
labda/parsers/qstarz.py
ADDED
|
@@ -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
|