labda 0.0.2__tar.gz → 0.0.4__tar.gz
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.
- {labda-0.0.2 → labda-0.0.4}/PKG-INFO +4 -2
- labda-0.0.4/README.md +27 -0
- {labda-0.0.2 → labda-0.0.4}/labda/__init__.py +3 -0
- {labda-0.0.2 → labda-0.0.4}/labda/assets/transportation_cut_points.py +1 -1
- labda-0.0.4/labda/logging.py +54 -0
- labda-0.0.4/labda/parallel.py +41 -0
- {labda-0.0.2 → labda-0.0.4}/labda/parsers/actigraph.py +23 -29
- labda-0.0.4/labda/parsers/bulk.py +39 -0
- {labda-0.0.2 → labda-0.0.4}/labda/parsers/gadgetbridge.py +7 -13
- {labda-0.0.2 → labda-0.0.4}/labda/parsers/qstarz.py +62 -48
- labda-0.0.4/labda/parsers/utils.py +60 -0
- {labda-0.0.2 → labda-0.0.4}/labda/processing/spatial/manipulations.py +1 -0
- {labda-0.0.2 → labda-0.0.4}/labda/processing/spatial/timeline.py +14 -3
- {labda-0.0.2 → labda-0.0.4}/labda/processing/spatial/transportation.py +3 -0
- {labda-0.0.2 → labda-0.0.4}/labda/processing/spatial/trips.py +123 -44
- {labda-0.0.2 → labda-0.0.4}/labda/structure/collection.py +65 -22
- {labda-0.0.2 → labda-0.0.4}/labda/structure/domains.py +1 -1
- {labda-0.0.2 → labda-0.0.4}/labda/structure/linkage.py +2 -3
- {labda-0.0.2 → labda-0.0.4}/labda/structure/merging.py +21 -5
- {labda-0.0.2 → labda-0.0.4}/labda/structure/subject.py +30 -14
- {labda-0.0.2 → labda-0.0.4}/labda/structure/validation/subject.py +2 -1
- {labda-0.0.2 → labda-0.0.4}/labda/utils.py +34 -0
- labda-0.0.4/labda/visualisation/spatial.py +127 -0
- {labda-0.0.2 → labda-0.0.4}/pyproject.toml +7 -17
- labda-0.0.2/README.md +0 -26
- labda-0.0.2/labda/parsers/bulk.py +0 -31
- labda-0.0.2/labda/processing/spatial/environment.py +0 -50
- {labda-0.0.2 → labda-0.0.4}/CHANGELOG.md +0 -0
- {labda-0.0.2 → labda-0.0.4}/labda/assets/__init__.py +0 -0
- {labda-0.0.2 → labda-0.0.4}/labda/assets/counts_cut_points.py +0 -0
- {labda-0.0.2 → labda-0.0.4}/labda/expanders/__init__.py +0 -0
- {labda-0.0.2 → labda-0.0.4}/labda/expanders/accelerometer.py +0 -0
- {labda-0.0.2 → labda-0.0.4}/labda/expanders/extras.py +0 -0
- {labda-0.0.2 → labda-0.0.4}/labda/expanders/spatial.py +0 -0
- {labda-0.0.2 → labda-0.0.4}/labda/parsers/__init__.py +0 -0
- {labda-0.0.2 → labda-0.0.4}/labda/parsers/garmin.py +0 -0
- {labda-0.0.2 → labda-0.0.4}/labda/processing/__init__.py +0 -0
- {labda-0.0.2 → labda-0.0.4}/labda/processing/accelerometer/__init__.py +0 -0
- {labda-0.0.2 → labda-0.0.4}/labda/processing/accelerometer/activity_intensity.py +0 -0
- {labda-0.0.2 → labda-0.0.4}/labda/processing/accelerometer/steps.py +0 -0
- {labda-0.0.2 → labda-0.0.4}/labda/processing/accelerometer/wear.py +0 -0
- {labda-0.0.2 → labda-0.0.4}/labda/processing/bouts.py +0 -0
- {labda-0.0.2 → labda-0.0.4}/labda/processing/domains.py +0 -0
- {labda-0.0.2 → labda-0.0.4}/labda/processing/manipulations.py +0 -0
- {labda-0.0.2 → labda-0.0.4}/labda/processing/spatial/__init__.py +0 -0
- {labda-0.0.2 → labda-0.0.4}/labda/processing/wear_validity.py +0 -0
- {labda-0.0.2 → labda-0.0.4}/labda/structure/__init__.py +0 -0
- {labda-0.0.2 → labda-0.0.4}/labda/structure/resampling.py +0 -0
- {labda-0.0.2 → labda-0.0.4}/labda/structure/validation/__init__.py +0 -0
- {labda-0.0.2 → labda-0.0.4}/labda/structure/validation/domains.py +0 -0
- {labda-0.0.2 → labda-0.0.4}/labda/structure/validation/linkage.py +0 -0
- {labda-0.0.2 → labda-0.0.4}/labda/visualisation/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: labda
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.4
|
|
4
4
|
Summary: Library for Advanced Behavioural Data Analysis
|
|
5
5
|
Home-page: https://labda.josefheidler.cz
|
|
6
6
|
License: CC-BY-SA-4.0
|
|
@@ -20,6 +20,7 @@ Classifier: Programming Language :: Python :: 3
|
|
|
20
20
|
Classifier: Programming Language :: Python :: 3.11
|
|
21
21
|
Classifier: Topic :: Scientific/Engineering
|
|
22
22
|
Requires-Dist: geopandas (>=0.14.2,<0.15.0)
|
|
23
|
+
Requires-Dist: loguru (>=0.7.2,<0.8.0)
|
|
23
24
|
Requires-Dist: openpyxl (>=3.1.2,<4.0.0)
|
|
24
25
|
Requires-Dist: pandas (>=2.2.0,<3.0.0)
|
|
25
26
|
Requires-Dist: pandera (>=0.18.0,<0.19.0)
|
|
@@ -44,7 +45,8 @@ TO-DO...
|
|
|
44
45
|
TO-DO...
|
|
45
46
|
|
|
46
47
|
## License
|
|
47
|
-
|
|
48
|
+
|
|
49
|
+
[Library for Advanced Behavioural Data Analysis](https://labda.josefheidler.cz/) by [Josef Heidler](https://labda.josefheidler.cz/) is licensed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/).
|
|
48
50
|
|
|
49
51
|
## Documentation
|
|
50
52
|
|
labda-0.0.4/README.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# LABDA: Library for Advanced Behavioural Data Analysis
|
|
2
|
+
|
|
3
|
+
**LABDA is currently under development and may undergo significant API changes that could result in breaking changes.**
|
|
4
|
+
|
|
5
|
+
## What is it?
|
|
6
|
+
|
|
7
|
+
TO-DO...
|
|
8
|
+
|
|
9
|
+
## Main Features
|
|
10
|
+
|
|
11
|
+
TO-DO...
|
|
12
|
+
|
|
13
|
+
## License
|
|
14
|
+
|
|
15
|
+
[Library for Advanced Behavioural Data Analysis](https://labda.josefheidler.cz/) by [Josef Heidler](https://labda.josefheidler.cz/) is licensed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/).
|
|
16
|
+
|
|
17
|
+
## Documentation
|
|
18
|
+
|
|
19
|
+
TO-DO...
|
|
20
|
+
|
|
21
|
+
## Getting Help, Discussion and Development
|
|
22
|
+
|
|
23
|
+
TO-DO...
|
|
24
|
+
|
|
25
|
+
## Contributing to labda
|
|
26
|
+
|
|
27
|
+
TO-DO..
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from loguru import logger
|
|
5
|
+
|
|
6
|
+
from .structure.subject import Subject
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def color_level(record):
|
|
10
|
+
match record["level"].name:
|
|
11
|
+
case "ERROR":
|
|
12
|
+
color = "red"
|
|
13
|
+
case "SUCCESS":
|
|
14
|
+
color = "green"
|
|
15
|
+
case _:
|
|
16
|
+
color = "white"
|
|
17
|
+
|
|
18
|
+
return color
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def formatter(record):
|
|
22
|
+
color = color_level(record)
|
|
23
|
+
return (
|
|
24
|
+
f"<{color}>"
|
|
25
|
+
+ "{time:YYYY-MM-DD HH:mm:ss} | {level} | {message}\n"
|
|
26
|
+
+ f"</{color}>"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def configure_logger(level: str):
|
|
31
|
+
logger.remove()
|
|
32
|
+
logger.add(
|
|
33
|
+
sys.stderr,
|
|
34
|
+
format=formatter,
|
|
35
|
+
colorize=True,
|
|
36
|
+
level=level,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def success_parsing_log(
|
|
41
|
+
subject: Subject,
|
|
42
|
+
path: Path,
|
|
43
|
+
func: str,
|
|
44
|
+
):
|
|
45
|
+
meta = subject.metadata
|
|
46
|
+
sampling_frequency = (
|
|
47
|
+
f"SF: {meta.sampling_frequency}s" if meta.sampling_frequency else ""
|
|
48
|
+
)
|
|
49
|
+
timezone = f", TZ: {meta.timezone}" if meta.timezone else ""
|
|
50
|
+
crs = f", CRS: {meta.crs}" if meta.crs else ""
|
|
51
|
+
|
|
52
|
+
logger.success(
|
|
53
|
+
f"{func} | {path} | Parsed {len(subject.df)} records ({sampling_frequency}{timezone}{crs})"
|
|
54
|
+
)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import multiprocessing as mp
|
|
2
|
+
from functools import partial
|
|
3
|
+
from typing import Any, Callable
|
|
4
|
+
|
|
5
|
+
from .logging import logger
|
|
6
|
+
from .structure.subject import Subject
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def process_obj(obj: Any, func: Callable, **kwargs) -> Any | None:
|
|
10
|
+
try:
|
|
11
|
+
if isinstance(obj, Subject):
|
|
12
|
+
func(obj, **kwargs)
|
|
13
|
+
return obj
|
|
14
|
+
|
|
15
|
+
return func(obj, **kwargs)
|
|
16
|
+
except Exception as e:
|
|
17
|
+
logger.error(f"{func.__module__}:{func.__name__} | {obj} | {e}")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def parallel_processing(
|
|
21
|
+
func: Callable,
|
|
22
|
+
objs: list[Any],
|
|
23
|
+
n_cores: int | str = "max",
|
|
24
|
+
**kwargs,
|
|
25
|
+
) -> list[Any]:
|
|
26
|
+
max_cores = mp.cpu_count()
|
|
27
|
+
|
|
28
|
+
if n_cores == "max":
|
|
29
|
+
n_cores = max_cores
|
|
30
|
+
elif isinstance(n_cores, int) and n_cores > max_cores:
|
|
31
|
+
raise ValueError(f"Maximum number of cores is {max_cores}. Received {n_cores}.")
|
|
32
|
+
else:
|
|
33
|
+
raise ValueError(f"Invalid value for n_cores: {n_cores}.")
|
|
34
|
+
|
|
35
|
+
with mp.Pool(n_cores) as pool:
|
|
36
|
+
results = pool.map(
|
|
37
|
+
partial(process_obj, func=func, **kwargs),
|
|
38
|
+
objs,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
return results
|
|
@@ -1,36 +1,24 @@
|
|
|
1
1
|
from datetime import datetime
|
|
2
2
|
from pathlib import Path
|
|
3
|
-
from typing import Any
|
|
3
|
+
from typing import Any, Tuple
|
|
4
4
|
|
|
5
5
|
import pandas as pd
|
|
6
6
|
from dateutil.parser import parse as parse_dt
|
|
7
7
|
|
|
8
|
+
from ..logging import success_parsing_log
|
|
8
9
|
from ..structure.subject import Metadata, Sensor, Subject, Vendor
|
|
9
10
|
from ..structure.validation.subject import SCHEMA, Column
|
|
10
|
-
from ..utils import
|
|
11
|
+
from ..utils import (
|
|
12
|
+
align_datetimes,
|
|
13
|
+
filter_datetime,
|
|
14
|
+
get_sampling_frequency,
|
|
15
|
+
)
|
|
16
|
+
from .utils import set_timezone
|
|
11
17
|
|
|
12
18
|
# TODO: Add param for column mapping if there is no header.
|
|
13
19
|
# 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
20
|
|
|
15
21
|
|
|
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
22
|
def get_metadata(metadata: list[str]) -> dict[str, Any]:
|
|
35
23
|
model = metadata[0].split("ActiGraph")[-1].split()[0].strip()
|
|
36
24
|
firmware = metadata[0].split("Firmware")[-1].split()[0].strip()
|
|
@@ -137,15 +125,15 @@ def from_csv(
|
|
|
137
125
|
metadata: bool = True,
|
|
138
126
|
*,
|
|
139
127
|
subject_id: str | None = None,
|
|
140
|
-
sensor_id: str | None = None,
|
|
128
|
+
sensor_id: str | None = None, # type: ignore
|
|
141
129
|
serial_number: str | None = None,
|
|
142
130
|
model: str | None = None,
|
|
143
131
|
vendor: Vendor = Vendor.ACTIGRAPH,
|
|
144
132
|
firmware_version: str | None = None,
|
|
145
133
|
start: datetime | None = None,
|
|
146
134
|
end: datetime | None = None,
|
|
147
|
-
timezone: str | None = None,
|
|
148
|
-
|
|
135
|
+
timezone: str | None | Tuple[str, str] = None,
|
|
136
|
+
datetime_format: str | None = None,
|
|
149
137
|
) -> Subject:
|
|
150
138
|
if isinstance(path, str):
|
|
151
139
|
path = Path(path)
|
|
@@ -183,17 +171,18 @@ def from_csv(
|
|
|
183
171
|
Column.PERPENDICULAR_COUNTS,
|
|
184
172
|
]
|
|
185
173
|
|
|
186
|
-
df = handle_datetime(df, parsed_metadata,
|
|
187
|
-
df = filter_datetime(df, start, end)
|
|
174
|
+
df = handle_datetime(df, parsed_metadata, datetime_format)
|
|
188
175
|
|
|
189
176
|
df.set_index(Column.DATETIME, inplace=True)
|
|
190
|
-
|
|
177
|
+
df, timezone = set_timezone(df, timezone)
|
|
191
178
|
|
|
192
|
-
df
|
|
179
|
+
df = filter_datetime(df, start, end)
|
|
193
180
|
|
|
181
|
+
sampling_frequency = parsed_metadata.get("sampling_frequency")
|
|
194
182
|
if not sampling_frequency:
|
|
195
183
|
sampling_frequency = get_sampling_frequency(df)
|
|
196
|
-
|
|
184
|
+
|
|
185
|
+
df = align_datetimes(df, sampling_frequency)
|
|
197
186
|
|
|
198
187
|
columns = [col.value for col in Column]
|
|
199
188
|
ordered_columns = [col for col in columns if col in df.columns]
|
|
@@ -229,4 +218,9 @@ def from_csv(
|
|
|
229
218
|
timezone=timezone,
|
|
230
219
|
) # type: ignore
|
|
231
220
|
|
|
232
|
-
|
|
221
|
+
subject = Subject(metadata=metadata, df=df) # type: ignore
|
|
222
|
+
|
|
223
|
+
func = f"{__name__}:from_csv"
|
|
224
|
+
success_parsing_log(subject, path, func)
|
|
225
|
+
|
|
226
|
+
return subject
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Callable
|
|
3
|
+
|
|
4
|
+
from ..parallel import parallel_processing, process_obj
|
|
5
|
+
from ..structure import Collection
|
|
6
|
+
|
|
7
|
+
# TODO: Add bulk parsing with linkage file
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def bulk_parser(
|
|
11
|
+
folder: str | Path,
|
|
12
|
+
parser: Callable,
|
|
13
|
+
id: str | None = None,
|
|
14
|
+
parallel: bool = True,
|
|
15
|
+
n_cores: int | str = "max",
|
|
16
|
+
**kwargs,
|
|
17
|
+
):
|
|
18
|
+
if isinstance(folder, str):
|
|
19
|
+
folder = Path(folder)
|
|
20
|
+
|
|
21
|
+
if not folder.is_dir():
|
|
22
|
+
raise ValueError(f"'{folder}' is not a valid folder.")
|
|
23
|
+
|
|
24
|
+
files = list(Path(folder).glob("*"))
|
|
25
|
+
|
|
26
|
+
if id is None:
|
|
27
|
+
id = folder.name
|
|
28
|
+
|
|
29
|
+
if parallel:
|
|
30
|
+
results = parallel_processing(parser, files, n_cores, **kwargs)
|
|
31
|
+
else:
|
|
32
|
+
results = [process_obj(file, parser, **kwargs) for file in files]
|
|
33
|
+
|
|
34
|
+
# Remove None values from results
|
|
35
|
+
results = list(filter(None, results))
|
|
36
|
+
|
|
37
|
+
collection = Collection(id=id, subjects=results)
|
|
38
|
+
|
|
39
|
+
return collection
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import secrets
|
|
2
1
|
from datetime import datetime
|
|
3
2
|
from pathlib import Path
|
|
4
3
|
|
|
@@ -112,20 +111,15 @@ def _get_xiaomi_devices(
|
|
|
112
111
|
return devices
|
|
113
112
|
|
|
114
113
|
|
|
115
|
-
def _get_wear_by_hr(df: pd.DataFrame) -> pd.DataFrame:
|
|
116
|
-
|
|
117
|
-
df = df.copy().reset_index(drop=True)
|
|
114
|
+
def _get_wear_by_hr(df: pd.DataFrame, limit: int | None = None) -> pd.DataFrame:
|
|
115
|
+
df = df.copy()
|
|
118
116
|
df[Column.WEAR] = pd.NA
|
|
119
|
-
|
|
120
|
-
intervals = indices.to_series().diff()
|
|
117
|
+
df.loc[df[Column.HEART_RATE].notna(), Column.WEAR] = True
|
|
121
118
|
|
|
122
|
-
if
|
|
123
|
-
|
|
124
|
-
df.loc[df[Column.HEART_RATE].notna(), Column.WEAR] = True
|
|
119
|
+
if limit:
|
|
120
|
+
df[Column.WEAR] = df[Column.WEAR].bfill(limit=limit)
|
|
125
121
|
|
|
126
|
-
|
|
127
|
-
df[Column.WEAR] = df[Column.WEAR].bfill(limit=interval)
|
|
128
|
-
df.loc[df[Column.WEAR].isna(), Column.WEAR] = False
|
|
122
|
+
df.loc[df[Column.WEAR].isna(), Column.WEAR] = False
|
|
129
123
|
|
|
130
124
|
return df
|
|
131
125
|
|
|
@@ -168,8 +162,8 @@ def _generate_records(
|
|
|
168
162
|
for record in records:
|
|
169
163
|
id = record[0] # type: ignore
|
|
170
164
|
df = record[1] # type: ignore
|
|
171
|
-
df = _get_wear_by_hr(df)
|
|
172
165
|
df.set_index(Column.DATETIME, inplace=True)
|
|
166
|
+
df = _get_wear_by_hr(df, 4)
|
|
173
167
|
df = SCHEMA.validate(df)
|
|
174
168
|
|
|
175
169
|
sampling_frequency = get_sampling_frequency(df)
|
|
@@ -1,22 +1,49 @@
|
|
|
1
1
|
from datetime import datetime
|
|
2
2
|
from pathlib import Path
|
|
3
|
+
from typing import Tuple
|
|
3
4
|
|
|
5
|
+
import numpy as np
|
|
4
6
|
import pandas as pd
|
|
5
7
|
|
|
8
|
+
from ..logging import success_parsing_log
|
|
6
9
|
from ..structure.subject import Metadata, Sensor, Subject, Vendor
|
|
7
10
|
from ..structure.validation.subject import SCHEMA, Column
|
|
8
11
|
from ..utils import (
|
|
9
12
|
align_datetimes,
|
|
10
13
|
columns_exists,
|
|
11
|
-
|
|
12
|
-
convert_to_geodataframe,
|
|
13
|
-
get_crs,
|
|
14
|
+
filter_datetime,
|
|
14
15
|
get_sampling_frequency,
|
|
15
|
-
get_timezone,
|
|
16
16
|
)
|
|
17
|
+
from .utils import set_crs, set_timezone
|
|
17
18
|
|
|
18
19
|
# TODO: Can be refactored.,
|
|
19
|
-
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def detect_environment_from_gps_signal(
|
|
23
|
+
df: pd.DataFrame,
|
|
24
|
+
method: str,
|
|
25
|
+
limit: int | float,
|
|
26
|
+
) -> pd.DataFrame:
|
|
27
|
+
df = df.copy()
|
|
28
|
+
|
|
29
|
+
methods = [
|
|
30
|
+
Column.SNR_USED, # 225 (F1: 46,80)
|
|
31
|
+
Column.SNR_VIEWED, # 260 (F1: 71,18)
|
|
32
|
+
Column.NSAT_USED, # 7 (F1: 48,86)
|
|
33
|
+
Column.NSAT_VIEWED, # 9 (F1: 42,93)
|
|
34
|
+
Column.NSAT_RATIO, # 70 (F1: 46,40)
|
|
35
|
+
]
|
|
36
|
+
if method not in methods:
|
|
37
|
+
raise ValueError(f"Method muset be one of {methods}")
|
|
38
|
+
|
|
39
|
+
if method not in df.columns:
|
|
40
|
+
raise ValueError(f"Column '{method}' does not exist in dataframe")
|
|
41
|
+
|
|
42
|
+
mask = df[method].notna()
|
|
43
|
+
df.loc[mask, Column.ENVIRONMENT] = "outdoor"
|
|
44
|
+
df.loc[mask & (df[method] <= limit), Column.ENVIRONMENT] = "indoor"
|
|
45
|
+
|
|
46
|
+
return df
|
|
20
47
|
|
|
21
48
|
|
|
22
49
|
def parse_snr(df: pd.DataFrame) -> pd.DataFrame:
|
|
@@ -162,37 +189,10 @@ def parse_elevation(df: pd.DataFrame) -> pd.DataFrame:
|
|
|
162
189
|
return df
|
|
163
190
|
|
|
164
191
|
|
|
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
192
|
def parse_datetime(df: pd.DataFrame, dt_format: str | None = None) -> pd.DataFrame:
|
|
192
193
|
df = df.copy()
|
|
193
194
|
column = None
|
|
194
195
|
|
|
195
|
-
# FIXME: Fix this...
|
|
196
196
|
if "DATE" in df.columns and "TIME" in df.columns:
|
|
197
197
|
date = "DATE"
|
|
198
198
|
time = "TIME"
|
|
@@ -228,8 +228,15 @@ def parse_coordinates(df: pd.DataFrame) -> pd.DataFrame:
|
|
|
228
228
|
[Column.LATITUDE, Column.LONGITUDE]
|
|
229
229
|
].apply(pd.to_numeric)
|
|
230
230
|
|
|
231
|
-
df
|
|
232
|
-
df
|
|
231
|
+
df["N/S"] = df["N/S"].str.strip()
|
|
232
|
+
df[Column.LATITUDE] = np.where(
|
|
233
|
+
df["N/S"] == "S", -df[Column.LATITUDE], df[Column.LATITUDE]
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
df["E/W"] = df["E/W"].str.strip()
|
|
237
|
+
df[Column.LONGITUDE] = np.where(
|
|
238
|
+
df["E/W"] == "W", -df[Column.LONGITUDE], df[Column.LONGITUDE]
|
|
239
|
+
)
|
|
233
240
|
|
|
234
241
|
return df
|
|
235
242
|
|
|
@@ -279,9 +286,11 @@ def from_csv(
|
|
|
279
286
|
start: datetime | None = None,
|
|
280
287
|
end: datetime | None = None,
|
|
281
288
|
sampling_frequency: float | None = None,
|
|
282
|
-
crs: str | None = None,
|
|
283
|
-
timezone: str | None = None,
|
|
284
|
-
|
|
289
|
+
crs: str | None | Tuple[str, str] = None,
|
|
290
|
+
timezone: str | None | Tuple[str, str] = None,
|
|
291
|
+
datetime_format: str | None = None,
|
|
292
|
+
nsat_ratio: float = 70,
|
|
293
|
+
snr_viewed: int = 260,
|
|
285
294
|
) -> Subject:
|
|
286
295
|
if isinstance(path, str):
|
|
287
296
|
path = Path(path)
|
|
@@ -299,15 +308,7 @@ def from_csv(
|
|
|
299
308
|
remove_repeated_headers(df)
|
|
300
309
|
|
|
301
310
|
df = parse_coordinates(df)
|
|
302
|
-
df = parse_datetime(df,
|
|
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
|
+
df = parse_datetime(df, datetime_format)
|
|
311
312
|
|
|
312
313
|
df.drop_duplicates(
|
|
313
314
|
subset=[Column.DATETIME], inplace=True
|
|
@@ -317,7 +318,6 @@ def from_csv(
|
|
|
317
318
|
if "VALID" in df.columns:
|
|
318
319
|
df = df[~(df["VALID"] == "Estimated (dead reckoning)")]
|
|
319
320
|
|
|
320
|
-
df = filter_datetime(df, start, end)
|
|
321
321
|
df = parse_distance(df)
|
|
322
322
|
df = parse_speed(df)
|
|
323
323
|
df = parse_elevation(df)
|
|
@@ -330,7 +330,16 @@ def from_csv(
|
|
|
330
330
|
(df[Column.NSAT_USED] * 100) / df[Column.NSAT_VIEWED]
|
|
331
331
|
).round(2)
|
|
332
332
|
|
|
333
|
+
if Column.SNR_VIEWED in df.columns and snr_viewed:
|
|
334
|
+
df = detect_environment_from_gps_signal(df, Column.SNR_VIEWED, snr_viewed)
|
|
335
|
+
elif Column.NSAT_RATIO in df.columns and nsat_ratio:
|
|
336
|
+
df = detect_environment_from_gps_signal(df, Column.NSAT_RATIO, nsat_ratio)
|
|
337
|
+
|
|
333
338
|
df.set_index(Column.DATETIME, inplace=True)
|
|
339
|
+
df, timezone = set_timezone(df, timezone)
|
|
340
|
+
df, crs = set_crs(df, crs)
|
|
341
|
+
|
|
342
|
+
df = filter_datetime(df, start, end)
|
|
334
343
|
|
|
335
344
|
if not sampling_frequency:
|
|
336
345
|
sampling_frequency = get_sampling_frequency(df)
|
|
@@ -363,4 +372,9 @@ def from_csv(
|
|
|
363
372
|
timezone=timezone,
|
|
364
373
|
)
|
|
365
374
|
|
|
366
|
-
|
|
375
|
+
subject = Subject(metadata=metadata, df=df)
|
|
376
|
+
|
|
377
|
+
func = f"{__name__}:from_csv"
|
|
378
|
+
success_parsing_log(subject, path, func)
|
|
379
|
+
|
|
380
|
+
return subject
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from typing import Tuple
|
|
2
|
+
|
|
3
|
+
import pandas as pd
|
|
4
|
+
|
|
5
|
+
from ..logging import logger
|
|
6
|
+
from ..utils import change_crs, change_timezone, get_crs, get_timezone
|
|
7
|
+
|
|
8
|
+
DEFAULT_CRS_SENSOR = "EPSG:4326"
|
|
9
|
+
|
|
10
|
+
# TODO: Maybe those functions should be refactored, i.e. if (None, timezone) -> timezone would be guessed from the data and then changed to the target timezone. Same for CRS.
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def set_timezone(
|
|
14
|
+
df: pd.DataFrame,
|
|
15
|
+
timezone: str | None | Tuple[str, str] = None,
|
|
16
|
+
) -> Tuple[pd.DataFrame, str | None]:
|
|
17
|
+
if (
|
|
18
|
+
isinstance(timezone, tuple)
|
|
19
|
+
and len(timezone) == 2
|
|
20
|
+
and all(isinstance(tz, str) for tz in timezone)
|
|
21
|
+
):
|
|
22
|
+
source_tz = timezone[0]
|
|
23
|
+
target_tz = timezone[1]
|
|
24
|
+
df = change_timezone(df, source_tz, target_tz)
|
|
25
|
+
timezone = target_tz
|
|
26
|
+
elif isinstance(timezone, str):
|
|
27
|
+
pass
|
|
28
|
+
elif not timezone:
|
|
29
|
+
if "latitude" in df.columns and "longitude" in df.columns:
|
|
30
|
+
timezone = get_timezone(df)
|
|
31
|
+
else:
|
|
32
|
+
timezone = None
|
|
33
|
+
else:
|
|
34
|
+
raise ValueError(f"Invalid value for timezone: {timezone}")
|
|
35
|
+
|
|
36
|
+
return df, timezone
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def set_crs(
|
|
40
|
+
df: pd.DataFrame,
|
|
41
|
+
crs: str | None | Tuple[str, str] = None,
|
|
42
|
+
) -> Tuple[pd.DataFrame, str]:
|
|
43
|
+
if (
|
|
44
|
+
isinstance(crs, tuple)
|
|
45
|
+
and len(crs) == 2
|
|
46
|
+
and all(isinstance(tz, str) for tz in crs)
|
|
47
|
+
):
|
|
48
|
+
source_crs = crs[0]
|
|
49
|
+
target_crs = crs[1]
|
|
50
|
+
df = change_crs(df, source_crs, target_crs)
|
|
51
|
+
crs = target_crs
|
|
52
|
+
elif isinstance(crs, str):
|
|
53
|
+
df = change_crs(df, DEFAULT_CRS_SENSOR, crs)
|
|
54
|
+
elif not crs:
|
|
55
|
+
crs = get_crs(df)
|
|
56
|
+
df = change_crs(df, DEFAULT_CRS_SENSOR, crs)
|
|
57
|
+
else:
|
|
58
|
+
raise ValueError(f"Invalid value for crs: {crs}")
|
|
59
|
+
|
|
60
|
+
return df, crs
|
|
@@ -11,6 +11,7 @@ def get_information(df: pd.DataFrame, crs: str) -> pd.Series:
|
|
|
11
11
|
start = df.index.min()
|
|
12
12
|
end = df.index.max()
|
|
13
13
|
duration = end - start
|
|
14
|
+
segment_id = df["segment_id"].iloc[0]
|
|
14
15
|
|
|
15
16
|
distance_col = get_unique_column_name(df)
|
|
16
17
|
df = add_distance(df, crs=crs, name=distance_col)
|
|
@@ -28,7 +29,11 @@ def get_information(df: pd.DataFrame, crs: str) -> pd.Series:
|
|
|
28
29
|
location_id = df["stationary_id"].iloc[0]
|
|
29
30
|
else:
|
|
30
31
|
status = "transport"
|
|
31
|
-
|
|
32
|
+
|
|
33
|
+
if Column.TRIP_MODE in df.columns:
|
|
34
|
+
mode = df[Column.TRIP_MODE].iloc[0]
|
|
35
|
+
else:
|
|
36
|
+
mode = pd.NA
|
|
32
37
|
location_id = pd.NA
|
|
33
38
|
else:
|
|
34
39
|
status = "stationary"
|
|
@@ -44,6 +49,7 @@ def get_information(df: pd.DataFrame, crs: str) -> pd.Series:
|
|
|
44
49
|
|
|
45
50
|
return pd.Series(
|
|
46
51
|
{
|
|
52
|
+
"segment_id": segment_id,
|
|
47
53
|
"trip_id": trip_id,
|
|
48
54
|
"stationary_id": location_id,
|
|
49
55
|
"start": start,
|
|
@@ -74,7 +80,8 @@ def get_transport_information(df: pd.DataFrame, crs: str):
|
|
|
74
80
|
|
|
75
81
|
|
|
76
82
|
def get_timeline(df: pd.DataFrame, crs: str) -> pd.DataFrame:
|
|
77
|
-
# TODO: Add a check for specific trip columns
|
|
83
|
+
# TODO: Add a check for specific trip columns (that they are exists, so timeline can be calculated)
|
|
84
|
+
# TODO: Add indoor/outdoor trip detection, same one as in removal of indoor trips inside trips.py (it should mark whole trip if it was inside or not - maybe?)
|
|
78
85
|
df = df.copy()
|
|
79
86
|
|
|
80
87
|
trips = (
|
|
@@ -87,7 +94,11 @@ def get_timeline(df: pd.DataFrame, crs: str) -> pd.DataFrame:
|
|
|
87
94
|
lambda x: get_information(x, crs=crs)
|
|
88
95
|
)
|
|
89
96
|
|
|
90
|
-
summary =
|
|
97
|
+
summary = (
|
|
98
|
+
pd.concat([trips, stops], ignore_index=True)
|
|
99
|
+
.sort_values(["start", "end"])
|
|
100
|
+
.reset_index(drop=True)
|
|
101
|
+
)
|
|
91
102
|
|
|
92
103
|
return summary.astype(
|
|
93
104
|
{
|
|
@@ -7,6 +7,9 @@ from ...expanders.spatial import add_speed
|
|
|
7
7
|
from ...structure.validation.subject import Column
|
|
8
8
|
from ...utils import columns_exists, columns_not_exists, get_unique_column_name
|
|
9
9
|
|
|
10
|
+
# TODO: Add something, so even pause has a mode.
|
|
11
|
+
# TODO: Option: pause_mode = True/False, before, after, mixed?
|
|
12
|
+
|
|
10
13
|
|
|
11
14
|
def _set_trip_mode(df: pd.DataFrame, name: str):
|
|
12
15
|
modes = df[name].value_counts()
|