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,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,4 @@
1
+ from .manipulations import max_speed_filter, min_distance_filter, stop_splitter
2
+ from .timeline import get_timeline
3
+ from .transportation import detect_transportation
4
+ from .trips import detect_trips
@@ -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