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,57 @@
|
|
|
1
|
+
import copy
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import pandas as pd
|
|
5
|
+
|
|
6
|
+
from ...structure.validation.subject import Column
|
|
7
|
+
from ...utils import columns_exists, columns_not_exists
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def scale_cut_points(
|
|
11
|
+
cut_points: dict[str, Any], sampling_frequency: float
|
|
12
|
+
) -> dict[str, Any]:
|
|
13
|
+
cut_points = copy.deepcopy(cut_points)
|
|
14
|
+
from_sf = cut_points["sampling_frequency"]
|
|
15
|
+
for cp in cut_points["cut_points"]:
|
|
16
|
+
if cp.get("max") is not None:
|
|
17
|
+
cp["max"] = cp["max"] / (from_sf / sampling_frequency)
|
|
18
|
+
|
|
19
|
+
return cut_points
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def apply_cut_points(
|
|
23
|
+
df: pd.DataFrame, on: Column | str, cut_points: dict[str, Any], name: str
|
|
24
|
+
):
|
|
25
|
+
# Extract min and max values from cut_points
|
|
26
|
+
bins = [-float("inf")]
|
|
27
|
+
bins += [cp["max"] for cp in cut_points["cut_points"]]
|
|
28
|
+
|
|
29
|
+
# Extract names from cut_points
|
|
30
|
+
labels = [cp["name"] for cp in cut_points["cut_points"]]
|
|
31
|
+
|
|
32
|
+
# Apply cut points
|
|
33
|
+
df[name] = pd.cut(df[on], bins=bins, labels=labels)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def detect_activity_intensity(
|
|
37
|
+
df: pd.DataFrame,
|
|
38
|
+
cut_points: dict[str, Any],
|
|
39
|
+
sampling_frequency: float,
|
|
40
|
+
*,
|
|
41
|
+
name: str = Column.ACTIVITY_INTENSITY,
|
|
42
|
+
overwrite: bool = False,
|
|
43
|
+
):
|
|
44
|
+
columns_not_exists(df, [name], overwrite=overwrite)
|
|
45
|
+
|
|
46
|
+
required_column = cut_points["required_data"]
|
|
47
|
+
columns_exists(df, [required_column])
|
|
48
|
+
|
|
49
|
+
df = df.copy()
|
|
50
|
+
|
|
51
|
+
# Check sampling frequency if needed scale cut points
|
|
52
|
+
if cut_points["sampling_frequency"] != sampling_frequency:
|
|
53
|
+
cut_points = scale_cut_points(cut_points, sampling_frequency)
|
|
54
|
+
|
|
55
|
+
apply_cut_points(df, required_column, cut_points, name)
|
|
56
|
+
|
|
57
|
+
return df
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
|
|
3
|
+
from ...structure.validation.subject import Column
|
|
4
|
+
from ...utils import columns_exists, columns_not_exists
|
|
5
|
+
|
|
6
|
+
# TODO: Refactor...
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def detect_mvpa_from_steps(
|
|
10
|
+
df: pd.DataFrame,
|
|
11
|
+
sampling_frequency: float,
|
|
12
|
+
name: str = Column.ACTIVITY_INTENSITY,
|
|
13
|
+
overwrite: bool = False,
|
|
14
|
+
) -> pd.DataFrame:
|
|
15
|
+
columns_exists(df, [Column.STEPS])
|
|
16
|
+
columns_not_exists(df, [name], overwrite=overwrite)
|
|
17
|
+
|
|
18
|
+
df = df.copy()
|
|
19
|
+
cut_off = (sampling_frequency / 60) * 100
|
|
20
|
+
|
|
21
|
+
df[name] = pd.NA
|
|
22
|
+
df.loc[df[Column.STEPS] >= cut_off, name] = "moderate-vigorous"
|
|
23
|
+
|
|
24
|
+
return df.astype({name: "category"})
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from datetime import timedelta
|
|
2
|
+
|
|
3
|
+
import pandas as pd
|
|
4
|
+
|
|
5
|
+
from ...expanders.accelerometer import add_counts_vector_magnitude
|
|
6
|
+
from ...processing.bouts import get_bouts
|
|
7
|
+
from ...structure.validation.subject import Column
|
|
8
|
+
from ...utils import columns_not_exists
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def detect_wear(
|
|
12
|
+
df: pd.DataFrame,
|
|
13
|
+
sampling_frequency: float,
|
|
14
|
+
min_duration: timedelta,
|
|
15
|
+
*,
|
|
16
|
+
interruption_duration: timedelta | None = None,
|
|
17
|
+
name: str = Column.WEAR,
|
|
18
|
+
overwrite: bool = False,
|
|
19
|
+
) -> pd.DataFrame:
|
|
20
|
+
columns_not_exists(df, [name], overwrite=overwrite)
|
|
21
|
+
|
|
22
|
+
if Column.VM_COUNTS not in df.columns:
|
|
23
|
+
df = add_counts_vector_magnitude(df)
|
|
24
|
+
|
|
25
|
+
df = get_bouts(
|
|
26
|
+
df,
|
|
27
|
+
Column.VM_COUNTS,
|
|
28
|
+
sampling_frequency,
|
|
29
|
+
min_duration,
|
|
30
|
+
0,
|
|
31
|
+
0,
|
|
32
|
+
interruption_duration=interruption_duration,
|
|
33
|
+
name=name,
|
|
34
|
+
overwrite=overwrite,
|
|
35
|
+
)
|
|
36
|
+
df[name] = ~df[name]
|
|
37
|
+
|
|
38
|
+
return df
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from datetime import timedelta
|
|
2
|
+
|
|
3
|
+
import pandas as pd
|
|
4
|
+
|
|
5
|
+
from ..utils import columns_not_exists, get_unique_column_name
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _get_duration(g):
|
|
9
|
+
return g.index[-1] - g.index[0]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_bouts(
|
|
13
|
+
df: pd.DataFrame,
|
|
14
|
+
column: str,
|
|
15
|
+
sampling_frequency: float,
|
|
16
|
+
min_duration: timedelta,
|
|
17
|
+
min_value: float,
|
|
18
|
+
max_value: float,
|
|
19
|
+
*,
|
|
20
|
+
interruption_duration: timedelta | None = None,
|
|
21
|
+
name: str = "bout",
|
|
22
|
+
overwrite: bool = False,
|
|
23
|
+
) -> pd.DataFrame:
|
|
24
|
+
columns_not_exists(df, [name], overwrite=overwrite)
|
|
25
|
+
|
|
26
|
+
if interruption_duration and min_duration <= interruption_duration:
|
|
27
|
+
raise ValueError(
|
|
28
|
+
"The interruption duration must be less than the bout duration"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
bout = get_unique_column_name(df)
|
|
32
|
+
bout_id = get_unique_column_name(df)
|
|
33
|
+
possibly_bout = get_unique_column_name(df)
|
|
34
|
+
|
|
35
|
+
temp_df = df[[column]].copy()
|
|
36
|
+
temp_df = temp_df.resample(f"{sampling_frequency}s").asfreq()
|
|
37
|
+
# TODO: Bouts should be more based on column with True/False values only, so user needs to preprocess the data.
|
|
38
|
+
# TODO: So it should be this column as input, not the min/max values.
|
|
39
|
+
temp_df[bout] = (temp_df[column] >= min_value) & (temp_df[column] <= max_value)
|
|
40
|
+
|
|
41
|
+
temp_df[bout_id] = (temp_df[bout] != temp_df[bout].shift(1)).cumsum()
|
|
42
|
+
|
|
43
|
+
if interruption_duration:
|
|
44
|
+
# All rows are possible bouts
|
|
45
|
+
temp_df[possibly_bout] = True
|
|
46
|
+
|
|
47
|
+
# Calculate the duration of the interruptions
|
|
48
|
+
interruption = (
|
|
49
|
+
temp_df[temp_df[bout] == False]
|
|
50
|
+
.groupby(bout_id)
|
|
51
|
+
.apply(_get_duration, include_groups=False)
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# Get consecutive interruptions where the duration is over the specific time
|
|
55
|
+
interruption_rows = interruption[interruption >= interruption_duration].index
|
|
56
|
+
|
|
57
|
+
# Set possibility of bout to False because interruption is too long
|
|
58
|
+
temp_df.loc[temp_df[bout_id].isin(interruption_rows), possibly_bout] = False
|
|
59
|
+
|
|
60
|
+
# Recreate the bout_id with the interruptions
|
|
61
|
+
temp_df[bout_id] = (
|
|
62
|
+
temp_df[possibly_bout] != temp_df[possibly_bout].shift(1)
|
|
63
|
+
).cumsum()
|
|
64
|
+
else:
|
|
65
|
+
temp_df[possibly_bout] = temp_df[bout]
|
|
66
|
+
|
|
67
|
+
# Create the final column with the bouts. Default to False
|
|
68
|
+
temp_df[name] = False
|
|
69
|
+
|
|
70
|
+
# Calculate the duration of the bouts
|
|
71
|
+
bout_duration = (
|
|
72
|
+
temp_df[temp_df[possibly_bout]]
|
|
73
|
+
.groupby(bout_id)
|
|
74
|
+
.apply(_get_duration, include_groups=False)
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# Get consecutive bouts where the duration is over the specific time
|
|
78
|
+
bout_rows = bout_duration[bout_duration >= min_duration].index
|
|
79
|
+
|
|
80
|
+
# Set bout to True for the rows where the duration is over the specific time
|
|
81
|
+
temp_df.loc[temp_df[bout_id].isin(bout_rows), name] = True
|
|
82
|
+
|
|
83
|
+
df = df.join(temp_df[[name]], how="left")
|
|
84
|
+
|
|
85
|
+
return df
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
|
|
3
|
+
from ..utils import columns_not_exists
|
|
4
|
+
|
|
5
|
+
# TODO: Possibility to specify if point should be INSIDE or OUTSIDE the domain. Maybe?
|
|
6
|
+
# TODO: Get domains
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def detect_domains(
|
|
10
|
+
df: pd.DataFrame,
|
|
11
|
+
domains: pd.DataFrame,
|
|
12
|
+
*,
|
|
13
|
+
overwrite: bool = False,
|
|
14
|
+
) -> pd.DataFrame:
|
|
15
|
+
df = df.copy()
|
|
16
|
+
unique_domains = domains.groupby("domain")
|
|
17
|
+
|
|
18
|
+
for domain in unique_domains:
|
|
19
|
+
name = f"domain_{domain[0]}"
|
|
20
|
+
|
|
21
|
+
columns_not_exists(df, [name], overwrite=overwrite)
|
|
22
|
+
|
|
23
|
+
domain_df = domain[1]
|
|
24
|
+
df[name] = False
|
|
25
|
+
|
|
26
|
+
for interval in domain_df.itertuples():
|
|
27
|
+
df.loc[
|
|
28
|
+
(df.index >= interval.start) & (df.index <= interval.end),
|
|
29
|
+
name,
|
|
30
|
+
] = True
|
|
31
|
+
|
|
32
|
+
return df
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from datetime import timedelta
|
|
2
|
+
|
|
3
|
+
import pandas as pd
|
|
4
|
+
|
|
5
|
+
from ..utils import columns_not_exists
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def gap_splitter(
|
|
9
|
+
df: pd.DataFrame,
|
|
10
|
+
*,
|
|
11
|
+
min_duration: timedelta,
|
|
12
|
+
name: str = "gap",
|
|
13
|
+
overwrite: bool = False,
|
|
14
|
+
) -> pd.DataFrame:
|
|
15
|
+
columns_not_exists(df, [name], overwrite=overwrite)
|
|
16
|
+
|
|
17
|
+
df = df.copy()
|
|
18
|
+
|
|
19
|
+
df[name] = df.index.diff() > min_duration # type: ignore
|
|
20
|
+
df[name] = df[name].apply(lambda x: 1 if x else 0).cumsum() + 1
|
|
21
|
+
|
|
22
|
+
return df.astype({name: "UInt16"})
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
|
|
3
|
+
# TODO: Refactor...
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def detect_environment(
|
|
7
|
+
df: pd.DataFrame,
|
|
8
|
+
algorithm: str = "palms",
|
|
9
|
+
*,
|
|
10
|
+
name: str = "environment",
|
|
11
|
+
overwrite: bool = False,
|
|
12
|
+
**kwargs,
|
|
13
|
+
) -> pd.DataFrame:
|
|
14
|
+
if name in df.columns and not overwrite:
|
|
15
|
+
raise ValueError(f"Column '{name}' already exists in dataframe")
|
|
16
|
+
|
|
17
|
+
df = df.copy()
|
|
18
|
+
algorithms = {
|
|
19
|
+
"palms": palms_environment_detection,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if algorithm not in algorithms:
|
|
23
|
+
raise ValueError(f"Method must be one of {list(algorithms.keys())}.")
|
|
24
|
+
|
|
25
|
+
df = algorithms[algorithm](df, name, **kwargs)
|
|
26
|
+
|
|
27
|
+
return df.astype({name: "category"})
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def palms_environment_detection(
|
|
31
|
+
df: pd.DataFrame,
|
|
32
|
+
name: str,
|
|
33
|
+
method: str,
|
|
34
|
+
limit: int | float | None = None,
|
|
35
|
+
) -> pd.DataFrame:
|
|
36
|
+
if not limit:
|
|
37
|
+
raise ValueError("Limit must be specified.")
|
|
38
|
+
|
|
39
|
+
methods = ["snr_total", "nsat_ratio"]
|
|
40
|
+
if method not in methods:
|
|
41
|
+
raise ValueError(f"Method must be one of {methods}.")
|
|
42
|
+
|
|
43
|
+
if method not in df.columns:
|
|
44
|
+
raise ValueError(f"Column '{method}' not found in DataFrame.")
|
|
45
|
+
|
|
46
|
+
df = df[df[method].notna()]
|
|
47
|
+
df[name] = "outdoor"
|
|
48
|
+
df.loc[df[method] <= limit, name] = "indoor"
|
|
49
|
+
|
|
50
|
+
return df
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
from collections import deque
|
|
2
|
+
from datetime import timedelta
|
|
3
|
+
|
|
4
|
+
import pandas as pd
|
|
5
|
+
|
|
6
|
+
from ...expanders import add_speed
|
|
7
|
+
from ...utils import (
|
|
8
|
+
columns_exists,
|
|
9
|
+
columns_not_exists,
|
|
10
|
+
convert_to_geodataframe,
|
|
11
|
+
get_crs,
|
|
12
|
+
get_unique_column_name,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def stop_splitter(
|
|
17
|
+
df: pd.DataFrame,
|
|
18
|
+
*,
|
|
19
|
+
max_radius: int | float,
|
|
20
|
+
min_duration: timedelta,
|
|
21
|
+
crs: str | None = None,
|
|
22
|
+
name: str = "stop",
|
|
23
|
+
overwrite: bool = False,
|
|
24
|
+
) -> pd.DataFrame:
|
|
25
|
+
"""
|
|
26
|
+
Splits a DataFrame into stop segments based on a maximum radius and minimum duration.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
df (pd.DataFrame): The input DataFrame.
|
|
30
|
+
max_radius (int | float): The maximum radius within which points are considered part of a stop segment.
|
|
31
|
+
min_duration (timedelta): The minimum duration required for a stop segment.
|
|
32
|
+
crs (str | None, optional): The coordinate reference system of the input DataFrame. Defaults to None.
|
|
33
|
+
name (str, optional): The name of the column to be added to the DataFrame to indicate stop segments. Defaults to "stop".
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
pd.DataFrame: The modified DataFrame with the added stop column. The stop column is True for stop segments and False otherwise.
|
|
37
|
+
|
|
38
|
+
Raises:
|
|
39
|
+
ValueError: If the specified column name already exists in the DataFrame and overwrite is set to False.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
columns_not_exists(df, [name], overwrite=overwrite)
|
|
43
|
+
|
|
44
|
+
df = df.copy()
|
|
45
|
+
df[name] = False
|
|
46
|
+
|
|
47
|
+
gdf = convert_to_geodataframe(df, crs=crs)
|
|
48
|
+
if gdf.crs is None:
|
|
49
|
+
crs = get_crs(gdf)
|
|
50
|
+
gdf = gdf.set_crs(crs)
|
|
51
|
+
|
|
52
|
+
buffer = deque()
|
|
53
|
+
for dt in gdf.index:
|
|
54
|
+
buffer.append(dt)
|
|
55
|
+
if dt - buffer[0] >= min_duration:
|
|
56
|
+
selected_rows = gdf.loc[buffer[0] : dt]
|
|
57
|
+
centroid = selected_rows.geometry.unary_union.centroid
|
|
58
|
+
|
|
59
|
+
for row in selected_rows.geometry:
|
|
60
|
+
distance = centroid.distance(row)
|
|
61
|
+
if distance > max_radius:
|
|
62
|
+
break
|
|
63
|
+
else:
|
|
64
|
+
df.loc[selected_rows.index, name] = True
|
|
65
|
+
|
|
66
|
+
buffer.popleft()
|
|
67
|
+
|
|
68
|
+
return df.astype({name: "boolean"})
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def min_distance_filter(
|
|
72
|
+
df: pd.DataFrame,
|
|
73
|
+
*,
|
|
74
|
+
min_distance: int | float,
|
|
75
|
+
crs: str | None = None,
|
|
76
|
+
name: str = "min_distance",
|
|
77
|
+
overwrite: bool = False,
|
|
78
|
+
) -> pd.DataFrame:
|
|
79
|
+
"""
|
|
80
|
+
Filters a DataFrame based on the minimum distance between consecutive points.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
df (pd.DataFrame): The input DataFrame.
|
|
84
|
+
min_distance (int | float): The minimum distance threshold.
|
|
85
|
+
crs (str | None, optional): The coordinate reference system of the DataFrame. Defaults to None.
|
|
86
|
+
name (str, optional): The name of the new column to be added. Defaults to "min_distance".
|
|
87
|
+
overwrite (bool, optional): Whether to overwrite the existing column with the same name. Defaults to False.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
pd.DataFrame: The filtered DataFrame with an additional column indicating whether each point meets the minimum distance requirement. The column is True if the point meets the requirement and False otherwise.
|
|
91
|
+
|
|
92
|
+
Raises:
|
|
93
|
+
ValueError: If the specified column name already exists in the DataFrame and overwrite is set to False.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
columns_not_exists(df, [name], overwrite=overwrite)
|
|
97
|
+
|
|
98
|
+
gdf = convert_to_geodataframe(df, crs=crs)
|
|
99
|
+
if gdf.crs is None:
|
|
100
|
+
crs = get_crs(gdf)
|
|
101
|
+
gdf = gdf.set_crs(crs)
|
|
102
|
+
|
|
103
|
+
keep_pts = [gdf.index[0]] # Keep first point, always.
|
|
104
|
+
prev_pt = gdf.geometry.iloc[0]
|
|
105
|
+
|
|
106
|
+
for idx, pt in gdf.geometry.items():
|
|
107
|
+
dist = pt.distance(prev_pt)
|
|
108
|
+
if dist >= min_distance:
|
|
109
|
+
keep_pts.append(idx)
|
|
110
|
+
prev_pt = pt
|
|
111
|
+
|
|
112
|
+
keep_pts.append(gdf.index[-1])
|
|
113
|
+
|
|
114
|
+
df = df.copy()
|
|
115
|
+
df[name] = False
|
|
116
|
+
df.loc[keep_pts, name] = True
|
|
117
|
+
|
|
118
|
+
return df.astype({name: "boolean"})
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def max_speed_filter(
|
|
122
|
+
df: pd.DataFrame,
|
|
123
|
+
*,
|
|
124
|
+
max_speed: int | float | None = None,
|
|
125
|
+
crs: str | None = None,
|
|
126
|
+
name: str = "max_speed",
|
|
127
|
+
overwrite: bool = False,
|
|
128
|
+
) -> pd.DataFrame:
|
|
129
|
+
"""
|
|
130
|
+
Filters the DataFrame based on the maximum speed value.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
df (pd.DataFrame): The input DataFrame.
|
|
134
|
+
max_speed (int | float): The maximum speed value to filter by.
|
|
135
|
+
crs (str | None, optional): The coordinate reference system. Defaults to None.
|
|
136
|
+
name (str, optional): The name of the new column to store the filter result. Defaults to "max_speed".
|
|
137
|
+
overwrite (bool, optional): Whether to overwrite the existing column with the same name. Defaults to False.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
pd.DataFrame: The filtered DataFrame with the new column added. The column is True if the point meets the requirement and False otherwise.
|
|
141
|
+
|
|
142
|
+
Raises:
|
|
143
|
+
ValueError: If the specified column name already exists in the DataFrame and overwrite is set to False.
|
|
144
|
+
"""
|
|
145
|
+
columns_not_exists(df, [name], overwrite=overwrite)
|
|
146
|
+
|
|
147
|
+
if max_speed is None:
|
|
148
|
+
# TODO: Should be 95th percentile of the speed.
|
|
149
|
+
raise NotImplementedError("max_speed=None is not yet implemented.")
|
|
150
|
+
|
|
151
|
+
gdf = convert_to_geodataframe(df, crs=crs)
|
|
152
|
+
if gdf.crs is None:
|
|
153
|
+
crs = get_crs(gdf)
|
|
154
|
+
gdf = gdf.set_crs(crs)
|
|
155
|
+
|
|
156
|
+
prev = gdf.iloc[0]
|
|
157
|
+
prev = (prev.name, prev.geometry)
|
|
158
|
+
|
|
159
|
+
# TODO: The invalid point logic is working that if the point is invalid, and the next point is the same, then it should be skipped as well.
|
|
160
|
+
# invalid_pt = None
|
|
161
|
+
|
|
162
|
+
keep_idx = [prev[0]] # Keep first point, always.
|
|
163
|
+
|
|
164
|
+
for idx, pt in gdf.geometry.iloc[1:].items():
|
|
165
|
+
dist = pt.distance(prev[1])
|
|
166
|
+
|
|
167
|
+
# if invalid_pt and invalid_pt.equals(pt):
|
|
168
|
+
# continue
|
|
169
|
+
|
|
170
|
+
delta_dt = (idx - prev[0]).total_seconds() # type: ignore
|
|
171
|
+
speed = (dist / delta_dt) * 3.6
|
|
172
|
+
|
|
173
|
+
if speed <= max_speed:
|
|
174
|
+
keep_idx.append(idx)
|
|
175
|
+
prev = (idx, pt)
|
|
176
|
+
# invalid_pt = None
|
|
177
|
+
# else:
|
|
178
|
+
# invalid_pt = pt
|
|
179
|
+
|
|
180
|
+
keep_idx.append(gdf.index[-1])
|
|
181
|
+
|
|
182
|
+
df = df.copy()
|
|
183
|
+
df[name] = False
|
|
184
|
+
df.loc[keep_idx, name] = True
|
|
185
|
+
|
|
186
|
+
return df.astype({name: "boolean"})
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
from shapely.geometry import LineString
|
|
3
|
+
|
|
4
|
+
from ...expanders import add_distance
|
|
5
|
+
from ...structure.validation.subject import Column
|
|
6
|
+
from ...utils import convert_to_geodataframe, get_unique_column_name
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_information(df: pd.DataFrame, crs: str) -> pd.Series:
|
|
10
|
+
df = df.copy()
|
|
11
|
+
start = df.index.min()
|
|
12
|
+
end = df.index.max()
|
|
13
|
+
duration = end - start
|
|
14
|
+
|
|
15
|
+
distance_col = get_unique_column_name(df)
|
|
16
|
+
df = add_distance(df, crs=crs, name=distance_col)
|
|
17
|
+
distance = df[distance_col].sum()
|
|
18
|
+
|
|
19
|
+
gdf = convert_to_geodataframe(df[[Column.LATITUDE, Column.LONGITUDE]], crs=crs)
|
|
20
|
+
geometry = gdf["geometry"]
|
|
21
|
+
|
|
22
|
+
# Add status and IDs based on trip_id and location_id.
|
|
23
|
+
if df[Column.TRIP_ID].notna().all():
|
|
24
|
+
trip_id = df[Column.TRIP_ID].iloc[0]
|
|
25
|
+
if df["stationary_id"].notna().all():
|
|
26
|
+
status = "pause"
|
|
27
|
+
mode = pd.NA
|
|
28
|
+
location_id = df["stationary_id"].iloc[0]
|
|
29
|
+
else:
|
|
30
|
+
status = "transport"
|
|
31
|
+
mode = df["trip_mode"].iloc[0]
|
|
32
|
+
location_id = pd.NA
|
|
33
|
+
else:
|
|
34
|
+
status = "stationary"
|
|
35
|
+
trip_id = pd.NA
|
|
36
|
+
mode = pd.NA
|
|
37
|
+
location_id = df["stationary_id"].iloc[0]
|
|
38
|
+
|
|
39
|
+
if status == "transport":
|
|
40
|
+
geometry = LineString(geometry.tolist()) # type: ignore
|
|
41
|
+
|
|
42
|
+
else:
|
|
43
|
+
geometry = geometry.unary_union # type: ignore
|
|
44
|
+
|
|
45
|
+
return pd.Series(
|
|
46
|
+
{
|
|
47
|
+
"trip_id": trip_id,
|
|
48
|
+
"stationary_id": location_id,
|
|
49
|
+
"start": start,
|
|
50
|
+
"end": end,
|
|
51
|
+
"duration": duration,
|
|
52
|
+
"distance": distance,
|
|
53
|
+
"status": status,
|
|
54
|
+
"mode": mode,
|
|
55
|
+
"geometry": geometry,
|
|
56
|
+
}
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def get_transport_information(df: pd.DataFrame, crs: str):
|
|
61
|
+
df = df.copy()
|
|
62
|
+
helper_id_col = get_unique_column_name(df)
|
|
63
|
+
df.loc[~df["trip_status"].isin(["stationary", "pause"]), helper_id_col] = (
|
|
64
|
+
df["trip_status"] == "start"
|
|
65
|
+
).cumsum()
|
|
66
|
+
|
|
67
|
+
partial_trip_summaries = (
|
|
68
|
+
df.groupby(helper_id_col)
|
|
69
|
+
.apply(lambda x: get_information(x, crs=crs))
|
|
70
|
+
.reset_index(drop=True)
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
return partial_trip_summaries
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def get_timeline(df: pd.DataFrame, crs: str) -> pd.DataFrame:
|
|
77
|
+
# TODO: Add a check for specific trip columns.
|
|
78
|
+
df = df.copy()
|
|
79
|
+
|
|
80
|
+
trips = (
|
|
81
|
+
df.groupby("trip_id")
|
|
82
|
+
.apply(lambda x: get_transport_information(x, crs=crs))
|
|
83
|
+
.reset_index(drop=True)
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
stops = df.groupby("stationary_id", as_index=False).apply(
|
|
87
|
+
lambda x: get_information(x, crs=crs)
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
summary = pd.concat([trips, stops], ignore_index=True).sort_values(["start", "end"])
|
|
91
|
+
|
|
92
|
+
return summary.astype(
|
|
93
|
+
{
|
|
94
|
+
"status": "category",
|
|
95
|
+
"mode": "category",
|
|
96
|
+
"trip_id": "UInt16",
|
|
97
|
+
"stationary_id": "UInt16",
|
|
98
|
+
}
|
|
99
|
+
)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
import pandas as pd
|
|
4
|
+
from pandas.core.groupby import DataFrameGroupBy
|
|
5
|
+
|
|
6
|
+
from ...expanders.spatial import add_speed
|
|
7
|
+
from ...structure.validation.subject import Column
|
|
8
|
+
from ...utils import columns_exists, columns_not_exists, get_unique_column_name
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _set_trip_mode(df: pd.DataFrame, name: str):
|
|
12
|
+
modes = df[name].value_counts()
|
|
13
|
+
dominant_mode = modes.idxmax()
|
|
14
|
+
df[name] = dominant_mode
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _get_partial_trips(df: pd.DataFrame) -> DataFrameGroupBy:
|
|
18
|
+
helper_column = get_unique_column_name(df)
|
|
19
|
+
df.loc[~df["trip_status"].isin(["stationary", "pause"]), helper_column] = (
|
|
20
|
+
df["trip_status"] == "start"
|
|
21
|
+
).cumsum()
|
|
22
|
+
|
|
23
|
+
return df.groupby(["trip_id", helper_column])
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _detect_partial_transport(
|
|
27
|
+
df: pd.DataFrame,
|
|
28
|
+
on: Column | str,
|
|
29
|
+
cut_points: dict[str, Any],
|
|
30
|
+
window: int | None = None,
|
|
31
|
+
name: str = "trip_mode",
|
|
32
|
+
) -> pd.DataFrame:
|
|
33
|
+
if window:
|
|
34
|
+
df[on] = df[on].rolling(window=window, min_periods=1).mean()
|
|
35
|
+
|
|
36
|
+
# Extract max values from cut_points
|
|
37
|
+
bins = [-float("inf")]
|
|
38
|
+
bins += [cp["max"] for cp in cut_points["cut_points"]]
|
|
39
|
+
|
|
40
|
+
# Extract names from cut_points
|
|
41
|
+
labels = [cp["name"] for cp in cut_points["cut_points"]]
|
|
42
|
+
|
|
43
|
+
# Apply cut points
|
|
44
|
+
df[name] = pd.cut(df[on], bins=bins, labels=labels)
|
|
45
|
+
|
|
46
|
+
_set_trip_mode(df, name)
|
|
47
|
+
|
|
48
|
+
return df
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def detect_transportation(
|
|
52
|
+
df: pd.DataFrame,
|
|
53
|
+
crs: str,
|
|
54
|
+
cut_points: dict[str, Any],
|
|
55
|
+
*,
|
|
56
|
+
window: int | None = None,
|
|
57
|
+
name: str = Column.TRIP_MODE,
|
|
58
|
+
overwrite: bool = False,
|
|
59
|
+
) -> pd.DataFrame:
|
|
60
|
+
columns_not_exists(df, [name], overwrite=overwrite)
|
|
61
|
+
|
|
62
|
+
required_column = cut_points["required_data"]
|
|
63
|
+
columns_exists(df, [Column.TRIP_ID, Column.TRIP_STATUS, required_column])
|
|
64
|
+
|
|
65
|
+
temp_df = df.copy()
|
|
66
|
+
|
|
67
|
+
if required_column == Column.SPEED:
|
|
68
|
+
temp_df = add_speed(temp_df, crs=crs, overwrite=True)
|
|
69
|
+
|
|
70
|
+
partials = _get_partial_trips(temp_df)
|
|
71
|
+
partials = partials.apply(
|
|
72
|
+
lambda x: _detect_partial_transport(
|
|
73
|
+
x, required_column, cut_points, window, name
|
|
74
|
+
),
|
|
75
|
+
include_groups=False,
|
|
76
|
+
).reset_index(level=[0, 1])
|
|
77
|
+
|
|
78
|
+
df = df.merge(partials[name], on=Column.DATETIME, how="left")
|
|
79
|
+
|
|
80
|
+
return df
|