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,551 @@
|
|
|
1
|
+
from datetime import timedelta
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pandas as pd
|
|
5
|
+
|
|
6
|
+
from ...expanders import add_distance
|
|
7
|
+
from ...structure.validation.subject import SCHEMA
|
|
8
|
+
from ...utils import columns_exists, columns_not_exists, convert_distance_parameter
|
|
9
|
+
from ..manipulations import gap_splitter
|
|
10
|
+
from .manipulations import max_speed_filter, min_distance_filter, stop_splitter
|
|
11
|
+
|
|
12
|
+
# TODO: Fix docstring.
|
|
13
|
+
# TODO: Test params.
|
|
14
|
+
# TODO: Change column names to constants (Column).
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_trips(
|
|
18
|
+
df: pd.DataFrame,
|
|
19
|
+
*,
|
|
20
|
+
crs: str,
|
|
21
|
+
stop_radius: int | float,
|
|
22
|
+
stop_duration: timedelta,
|
|
23
|
+
) -> pd.DataFrame:
|
|
24
|
+
"""
|
|
25
|
+
Processes the given DataFrame to identify and label trips based on stops.
|
|
26
|
+
|
|
27
|
+
This function applies a stop splitter to the DataFrame based on the given maximum radius and minimum stop duration.
|
|
28
|
+
It then assigns a unique ID to each stop and labels the trip status as either 'stationary' or 'transport'.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
df (pd.DataFrame): The input DataFrame to process.
|
|
32
|
+
crs (str): The coordinate reference system to use when processing the DataFrame. The latitude and longitude columns must be in this CRS, otherwise the results will be incorrect.
|
|
33
|
+
max_radius (int | float): The maximum radius to use when splitting stops.
|
|
34
|
+
stop_duration (timedelta): The minimum duration to consider a stop.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
pd.DataFrame: The processed DataFrame with added 'stop_id' and 'trip_status' columns.
|
|
38
|
+
"""
|
|
39
|
+
# Returns a new DataFrame with a 'stop' column.
|
|
40
|
+
df = stop_splitter(df, max_radius=stop_radius, min_duration=stop_duration, crs=crs)
|
|
41
|
+
|
|
42
|
+
# Unique ID for each stop.
|
|
43
|
+
df["stop_id"] = (df["stop"].diff() != 0).cumsum()
|
|
44
|
+
|
|
45
|
+
# Set the trip status to 'stationary' if the point is a stop, otherwise 'transport'.
|
|
46
|
+
df["trip_status"] = np.where(df["stop"], "stationary", "transport")
|
|
47
|
+
|
|
48
|
+
return df.astype({"stop_id": "UInt16", "trip_status": "category"})
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def get_pauses(
|
|
52
|
+
df: pd.DataFrame,
|
|
53
|
+
*,
|
|
54
|
+
crs: str,
|
|
55
|
+
pause_radius: int | float,
|
|
56
|
+
pause_duration: timedelta,
|
|
57
|
+
) -> pd.Series:
|
|
58
|
+
"""
|
|
59
|
+
Identifies and labels pauses within trips in the given DataFrame.
|
|
60
|
+
|
|
61
|
+
This function applies a stop splitter to the DataFrame based on the given maximum radius and minimum pause duration.
|
|
62
|
+
It then assigns a unique ID to each pause and labels the pause status as either 'pause' or 'transport'.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
df (pd.DataFrame): The input DataFrame to process.
|
|
66
|
+
crs (str): The coordinate reference system to use when processing the DataFrame. The latitude and longitude columns must be in this CRS, otherwise the results will be incorrect.
|
|
67
|
+
max_radius (int | float): The maximum radius to use when splitting pauses.
|
|
68
|
+
pause_duration (timedelta): The minimum duration to consider a pause.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
pd.Series: A series containing the pause status for each pause point.
|
|
72
|
+
"""
|
|
73
|
+
# Select only the rows that are not stops.
|
|
74
|
+
partials = df[~df["stop"]].groupby("stop_id", as_index=False)
|
|
75
|
+
|
|
76
|
+
# Returns a new DataFrame with a 'pause' column.
|
|
77
|
+
partials = partials.apply(
|
|
78
|
+
lambda x: stop_splitter(
|
|
79
|
+
x,
|
|
80
|
+
max_radius=pause_radius,
|
|
81
|
+
min_duration=pause_duration,
|
|
82
|
+
crs=crs,
|
|
83
|
+
name="pause",
|
|
84
|
+
)
|
|
85
|
+
).reset_index(level=0, drop=True)
|
|
86
|
+
|
|
87
|
+
# If there are no pauses, return an empty Series.
|
|
88
|
+
if partials.empty:
|
|
89
|
+
return pd.Series(name="partial_status")
|
|
90
|
+
|
|
91
|
+
# Unique ID for each pause.
|
|
92
|
+
partials["partial_id"] = (partials["pause"].diff() != 0).cumsum().astype("UInt16")
|
|
93
|
+
|
|
94
|
+
# Set the pause status to 'pause' if the point is a pause, otherwise 'transport'.
|
|
95
|
+
partials["partial_status"] = np.where(partials["pause"], "pause", "transport")
|
|
96
|
+
|
|
97
|
+
# Select only the rows that are pauses.
|
|
98
|
+
pauses = partials[partials["pause"] == True]
|
|
99
|
+
|
|
100
|
+
# Return the pause status for merging (combining).
|
|
101
|
+
return pauses["partial_status"]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def add_boundaries(
|
|
105
|
+
df: pd.DataFrame,
|
|
106
|
+
*,
|
|
107
|
+
id_col: str,
|
|
108
|
+
status_col: str,
|
|
109
|
+
extremities: bool,
|
|
110
|
+
) -> None:
|
|
111
|
+
"""
|
|
112
|
+
Adds 'start' and 'end' statuses to the trip segments in the DataFrame.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
df (pd.DataFrame): The input DataFrame to process.
|
|
116
|
+
id_col (str): The name of the column in df that contains the unique ID for each trip.
|
|
117
|
+
status_col (str): The name of the column in df that contains the trip status.
|
|
118
|
+
extremities (bool): If True, the function will add 'start' and 'end' statuses to the first and last trips, respectively.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
None. The function modifies df in-place.
|
|
122
|
+
"""
|
|
123
|
+
df[status_col] = df[status_col].cat.add_categories(["start", "end", "start-end"])
|
|
124
|
+
|
|
125
|
+
# If 'transport' is at the start or end of the DataFrame, change it to 'start' or 'end' respectively.
|
|
126
|
+
if extremities:
|
|
127
|
+
if df.iloc[0][status_col] == "transport":
|
|
128
|
+
df.iloc[0, df.columns.get_loc(status_col)] = "start"
|
|
129
|
+
if df.iloc[-1][status_col] == "transport":
|
|
130
|
+
df.iloc[-1, df.columns.get_loc(status_col)] = "end"
|
|
131
|
+
|
|
132
|
+
# Create a temporary DataFrame that shifts the trip status by 1 row forward and backward.
|
|
133
|
+
temp_df_prev = df.shift(1)
|
|
134
|
+
temp_df_next = df.shift(-1)
|
|
135
|
+
|
|
136
|
+
# Change the points immediately after a "transport" sequence to "end".
|
|
137
|
+
df.loc[
|
|
138
|
+
(
|
|
139
|
+
(temp_df_prev[status_col] == "start")
|
|
140
|
+
| (temp_df_prev[status_col] == "transport")
|
|
141
|
+
)
|
|
142
|
+
& ((df[status_col] == "stationary") | (df[status_col] == "pause")),
|
|
143
|
+
status_col,
|
|
144
|
+
] = "end"
|
|
145
|
+
|
|
146
|
+
# Change the points immediately before a "transport" sequence to "start"
|
|
147
|
+
df.loc[
|
|
148
|
+
(temp_df_next[status_col] == "transport")
|
|
149
|
+
& ((df[status_col] == "stationary") | (df[status_col] == "pause")),
|
|
150
|
+
status_col,
|
|
151
|
+
] = "start"
|
|
152
|
+
|
|
153
|
+
df["group"] = (df[status_col] == "start").cumsum().astype("UInt16")
|
|
154
|
+
df.loc[df[status_col] == "stationary", "group"] = pd.NA
|
|
155
|
+
df[id_col] = df.groupby("group")[id_col].transform("first")
|
|
156
|
+
df.drop(columns=["group"], inplace=True)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def adjust_stationary_statutes(df: pd.DataFrame):
|
|
160
|
+
"""
|
|
161
|
+
Adjusts the 'trip_status' of 'pause' groups in the DataFrame.
|
|
162
|
+
|
|
163
|
+
This function modifies the input DataFrame to change the 'trip_status' of 'pause' groups to 'stationary' under certain conditions.
|
|
164
|
+
If the first or last group is 'pause', it changes the group to 'stationary'.
|
|
165
|
+
It also changes 'pause' groups to 'stationary' if either the previous group or the next group is 'stationary'.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
df (pd.DataFrame): The input DataFrame to process. It must contain a 'trip_status' column and a 'group' column.
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
None. The function modifies df in-place.
|
|
172
|
+
"""
|
|
173
|
+
# Create a new column 'group' that increments by 1 whenever the 'trip_status' changes
|
|
174
|
+
df["group"] = (df["trip_status"] != df["trip_status"].shift()).cumsum()
|
|
175
|
+
|
|
176
|
+
# Get the minimum group ID.
|
|
177
|
+
min_id = df["group"].min()
|
|
178
|
+
|
|
179
|
+
# Get the maximum group ID.
|
|
180
|
+
max_id = df["group"].max()
|
|
181
|
+
|
|
182
|
+
# If the first or last group is 'pause', change the group to 'stationary', so the trip starts and ends with 'stationary'.
|
|
183
|
+
if df["trip_status"].iloc[0] == "pause":
|
|
184
|
+
df.loc[df["group"] == min_id, "trip_status"] = "stationary"
|
|
185
|
+
|
|
186
|
+
if df["trip_status"].iloc[-1] == "pause":
|
|
187
|
+
df.loc[df["group"] == max_id, "trip_status"] = "stationary"
|
|
188
|
+
|
|
189
|
+
def adjust_group(group, df):
|
|
190
|
+
# Function to apply to each group.
|
|
191
|
+
# If the group is 'pause' and either the previous group or the next group is 'stationary', change the group to 'stationary'.
|
|
192
|
+
if group["trip_status"].iloc[0] == "pause":
|
|
193
|
+
prev_group_status = (
|
|
194
|
+
df.loc[df["group"] == group.name - 1, "trip_status"].values[-1]
|
|
195
|
+
if group.name > min_id
|
|
196
|
+
else None
|
|
197
|
+
)
|
|
198
|
+
next_group_status = (
|
|
199
|
+
df.loc[df["group"] == group.name + 1, "trip_status"].values[0]
|
|
200
|
+
if group.name < max_id
|
|
201
|
+
else None
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
if prev_group_status == "stationary" or next_group_status == "stationary":
|
|
205
|
+
group["trip_status"] = "stationary"
|
|
206
|
+
|
|
207
|
+
return group
|
|
208
|
+
|
|
209
|
+
df = (
|
|
210
|
+
df.groupby("group", as_index=False)
|
|
211
|
+
.apply(lambda x: adjust_group(x, df))
|
|
212
|
+
.reset_index(level=0, drop=True)
|
|
213
|
+
) # type: ignore
|
|
214
|
+
|
|
215
|
+
df.drop("group", axis=1, inplace=True)
|
|
216
|
+
|
|
217
|
+
return df.astype({"trip_status": "category"})
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def add_ids(
|
|
221
|
+
df: pd.DataFrame, column: str, target_values: list[str], id_column: str
|
|
222
|
+
) -> None:
|
|
223
|
+
"""
|
|
224
|
+
Adds a new column to the DataFrame with unique IDs for each group of consecutive rows that match the target values.
|
|
225
|
+
|
|
226
|
+
This function modifies the input DataFrame to add a new column with unique IDs. Each group of consecutive rows where the value in the specified column matches one of the target values is given a unique ID. Rows that don't match any of the target values are given an ID of NA.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
df (pd.DataFrame): The input DataFrame to process.
|
|
230
|
+
column (str): The name of the column in df to check for target values.
|
|
231
|
+
target_values (list[str]): The values to check for in the specified column.
|
|
232
|
+
id_column (str): The name of the new column to add to df. This column will contain the unique IDs.
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
None. The function modifies df in-place.
|
|
236
|
+
"""
|
|
237
|
+
# Create a mask that changes value every time one of the target values appears after a different value
|
|
238
|
+
mask = df[column].isin(target_values) & (~df[column].shift().isin(target_values))
|
|
239
|
+
|
|
240
|
+
# Use cumsum to generate the ID
|
|
241
|
+
df[id_column] = mask.cumsum().astype("UInt16")
|
|
242
|
+
|
|
243
|
+
# Set ID to NA for rows that don't match any of the target values
|
|
244
|
+
df.loc[~df[column].isin(target_values), id_column] = pd.NA
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def get_distance(df: pd.DataFrame, crs: str) -> float:
|
|
248
|
+
"""
|
|
249
|
+
Calculates the total distance traveled in a DataFrame.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
df (pd.DataFrame): The DataFrame containing the trip data.
|
|
253
|
+
crs (str): The coordinate reference system of the data.
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
float: The total distance traveled, rounded to 2 decimal places.
|
|
257
|
+
"""
|
|
258
|
+
df = add_distance(df, crs=crs)
|
|
259
|
+
return df["distance"].sum().round(2)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def remove_trips_by_duration(df: pd.DataFrame, min_duration: timedelta) -> None:
|
|
263
|
+
"""
|
|
264
|
+
Removes trips from the DataFrame that are shorter than a specified minimum duration.
|
|
265
|
+
|
|
266
|
+
Args:
|
|
267
|
+
df (pd.DataFrame): The DataFrame containing the trips.
|
|
268
|
+
min_duration (timedelta): The minimum duration for a trip to be kept in the DataFrame.
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
None: The function modifies the DataFrame in-place, and does not return anything.
|
|
272
|
+
"""
|
|
273
|
+
trips = df[
|
|
274
|
+
df["trip_id"].notna()
|
|
275
|
+
& df["trip_status"].isin(["start", "transport", "end", "start-end"])
|
|
276
|
+
].copy()
|
|
277
|
+
|
|
278
|
+
# Calculate the duration for each trip.
|
|
279
|
+
trips = trips.groupby(["segment_id", "trip_id"], as_index=False).apply(
|
|
280
|
+
lambda x: x.index.max() - x.index.min()
|
|
281
|
+
)
|
|
282
|
+
trips.rename(columns={None: "duration"}, inplace=True) # type: ignore
|
|
283
|
+
|
|
284
|
+
# Select only the trips that are shorter than the minimum duration.
|
|
285
|
+
removed_trips = trips[trips["duration"] < min_duration][["segment_id", "trip_id"]]
|
|
286
|
+
|
|
287
|
+
# Change rows based on both segment_id and trip_id and set the trip_id and trip_status to NA.
|
|
288
|
+
mask = df.set_index(["segment_id", "trip_id"]).index.isin(
|
|
289
|
+
removed_trips.set_index(["segment_id", "trip_id"]).index
|
|
290
|
+
)
|
|
291
|
+
df.loc[mask, "trip_id"] = pd.NA
|
|
292
|
+
# df.loc[mask, "trip_status"] = (
|
|
293
|
+
# pd.NA # HACK: Possibly set this to "stationary" if removed trips should be part of locations, but then need to adjust the stationary_id as well.
|
|
294
|
+
# )
|
|
295
|
+
df.loc[mask, "trip_status"] = "stationary"
|
|
296
|
+
df.loc[mask, "stationary_id"] = df.loc[mask, "stationary_id"].ffill()
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def remove_trips_by_length(df: pd.DataFrame, min_length: int | float, crs: str) -> None:
|
|
300
|
+
"""
|
|
301
|
+
Removes trips from a DataFrame based on their length.
|
|
302
|
+
|
|
303
|
+
Args:
|
|
304
|
+
df (pd.DataFrame): The DataFrame containing the trips.
|
|
305
|
+
min_length (int | float): The minimum length for a trip to be kept in the DataFrame.
|
|
306
|
+
crs (str): The coordinate reference system of the trips.
|
|
307
|
+
|
|
308
|
+
Returns:
|
|
309
|
+
None: The function modifies the DataFrame in-place, and does not return anything.
|
|
310
|
+
"""
|
|
311
|
+
# Select only the rows that are part of a trip.
|
|
312
|
+
trips = df[
|
|
313
|
+
df["trip_id"].notna()
|
|
314
|
+
& df["trip_status"].isin(["start", "transport", "end", "start-end"])
|
|
315
|
+
].copy()
|
|
316
|
+
|
|
317
|
+
# Calculate the distance traveled for each trip.
|
|
318
|
+
trips = trips.groupby(["segment_id", "trip_id"], as_index=False).apply(
|
|
319
|
+
lambda x: add_distance(x, crs=crs)["distance"].sum()
|
|
320
|
+
)
|
|
321
|
+
trips.rename(columns={None: "length"}, inplace=True) # type: ignore
|
|
322
|
+
|
|
323
|
+
# Select only the trips that are shorter than the minimum length.
|
|
324
|
+
removed_trips = trips[trips["length"] < min_length][["segment_id", "trip_id"]]
|
|
325
|
+
|
|
326
|
+
# Change rows based on both segment_id and trip_id and set the trip_id and trip_status to NA.
|
|
327
|
+
mask = df.set_index(["segment_id", "trip_id"]).index.isin(
|
|
328
|
+
removed_trips.set_index(["segment_id", "trip_id"]).index
|
|
329
|
+
)
|
|
330
|
+
df.loc[mask, "trip_id"] = pd.NA
|
|
331
|
+
# df.loc[mask, "trip_status"] = (
|
|
332
|
+
# pd.NA # HACK: Possibly set this to "stationary" if removed trips should be part of locations, but then need to adjust the stationary_id as well.
|
|
333
|
+
# )
|
|
334
|
+
df.loc[mask, "trip_status"] = "stationary"
|
|
335
|
+
df.loc[mask, "stationary_id"] = df.loc[mask, "stationary_id"].ffill()
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def get_segment_trips(
|
|
339
|
+
df: pd.DataFrame,
|
|
340
|
+
crs: str,
|
|
341
|
+
sampling_frequency: float,
|
|
342
|
+
stop_radius: int | float,
|
|
343
|
+
stop_duration: timedelta,
|
|
344
|
+
pause_radius: int | float | None = None,
|
|
345
|
+
pause_duration: timedelta | None = None,
|
|
346
|
+
min_distance: int | float | None = None,
|
|
347
|
+
max_speed: int | float | None = None,
|
|
348
|
+
):
|
|
349
|
+
"""
|
|
350
|
+
Processes the given DataFrame to identify and label trips based on various parameters.
|
|
351
|
+
|
|
352
|
+
This function applies several filters to the DataFrame, including a minimum distance filter and a maximum speed filter.
|
|
353
|
+
It then identifies and labels trips based on the given maximum radius, stop duration, and pause duration.
|
|
354
|
+
|
|
355
|
+
Args:
|
|
356
|
+
df (pd.DataFrame): The input DataFrame to process.
|
|
357
|
+
crs (str): The coordinate reference system to use when processing the DataFrame. The latitude and longitude columns must be in this CRS, otherwise the results will be incorrect.
|
|
358
|
+
sampling_frequency (float): The sampling frequency of the data in seconds. This is used to convert the minimum distance to the correct units.
|
|
359
|
+
max_radius (int | float): The maximum radius to use when splitting stops.
|
|
360
|
+
stop_duration (timedelta): The minimum duration to consider a stop.
|
|
361
|
+
pause_duration (timedelta): The minimum duration to consider a pause.
|
|
362
|
+
min_distance (int | float | None, optional): The minimum distance between points. Points that are closer than this distance are filtered out. If None, no minimum distance filter is applied.
|
|
363
|
+
max_speed (int | float | None, optional): The maximum speed. Points that are faster than this speed are filtered out. If None, no maximum speed filter is applied.
|
|
364
|
+
|
|
365
|
+
Returns:
|
|
366
|
+
pd.DataFrame: The processed DataFrame with added 'trip_id', 'trip_status' columns.
|
|
367
|
+
"""
|
|
368
|
+
origin = df
|
|
369
|
+
|
|
370
|
+
# Filter out points that are too close to each other.
|
|
371
|
+
if min_distance is not None:
|
|
372
|
+
min_distance = convert_distance_parameter(min_distance, sampling_frequency)
|
|
373
|
+
df = min_distance_filter(df, min_distance=min_distance, crs=crs)
|
|
374
|
+
df = df[df["min_distance"]] # type: ignore
|
|
375
|
+
|
|
376
|
+
# Filter out points that are too fast.
|
|
377
|
+
if max_speed is not None:
|
|
378
|
+
df = max_speed_filter(df, max_speed=max_speed, crs=crs)
|
|
379
|
+
df = df[df["max_speed"]] # type: ignore
|
|
380
|
+
|
|
381
|
+
# Get the trips from the DataFrame.
|
|
382
|
+
df = get_trips(df, crs=crs, stop_radius=stop_radius, stop_duration=stop_duration)
|
|
383
|
+
|
|
384
|
+
# Get the pauses from the DataFrame.
|
|
385
|
+
if not pause_radius and not pause_duration:
|
|
386
|
+
pauses = pd.Series(name="partial_status")
|
|
387
|
+
else:
|
|
388
|
+
if not pause_radius:
|
|
389
|
+
pause_radius = stop_radius
|
|
390
|
+
|
|
391
|
+
if not pause_duration:
|
|
392
|
+
pause_duration = stop_duration
|
|
393
|
+
|
|
394
|
+
pauses = get_pauses(
|
|
395
|
+
df, crs=crs, pause_radius=pause_radius, pause_duration=pause_duration
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
# Merge the pauses with the trips if there are any.
|
|
399
|
+
if not pauses.empty:
|
|
400
|
+
df = df.join(
|
|
401
|
+
pauses,
|
|
402
|
+
how="left",
|
|
403
|
+
)
|
|
404
|
+
df["trip_status"] = df["partial_status"].combine_first(df["trip_status"])
|
|
405
|
+
df.drop(columns=["partial_status"], inplace=True)
|
|
406
|
+
|
|
407
|
+
# Change the pause status to 'stationary' if necessary, so if pause follows a stationary point, it is also stationary.
|
|
408
|
+
df = adjust_stationary_statutes(df)
|
|
409
|
+
|
|
410
|
+
# Fix the start and end of trips and locations.
|
|
411
|
+
add_ids(df, "trip_status", ["pause", "transport"], "trip_id")
|
|
412
|
+
add_ids(
|
|
413
|
+
df,
|
|
414
|
+
"trip_status",
|
|
415
|
+
["pause", "stationary"],
|
|
416
|
+
"stationary_id",
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
# Add start and end boundaries to trips.
|
|
420
|
+
add_boundaries(df, id_col="trip_id", status_col="trip_status", extremities=True)
|
|
421
|
+
|
|
422
|
+
# Merge the processed DataFrame with the original DataFrame.
|
|
423
|
+
origin = origin.join(
|
|
424
|
+
df[["trip_status", "trip_id", "stationary_id"]],
|
|
425
|
+
how="left",
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
return origin
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
# TODO: Fix docstring.
|
|
432
|
+
def detect_trips(
|
|
433
|
+
df: pd.DataFrame,
|
|
434
|
+
*,
|
|
435
|
+
crs: str,
|
|
436
|
+
sampling_frequency: float,
|
|
437
|
+
gap_duration: timedelta,
|
|
438
|
+
stop_radius: int | float,
|
|
439
|
+
stop_duration: timedelta,
|
|
440
|
+
pause_radius: int | float | None = None,
|
|
441
|
+
pause_duration: timedelta | None = None,
|
|
442
|
+
min_duration: timedelta | None = None,
|
|
443
|
+
min_length: int | float | None = None,
|
|
444
|
+
min_distance: int | float | None = None,
|
|
445
|
+
max_speed: int | float | None = None,
|
|
446
|
+
overwrite: bool = False,
|
|
447
|
+
) -> pd.DataFrame:
|
|
448
|
+
"""
|
|
449
|
+
Detects trips in the given DataFrame based on various parameters.
|
|
450
|
+
|
|
451
|
+
This function applies several filters to the DataFrame, including a minimum length filter, a minimum distance filter, and a maximum speed filter.
|
|
452
|
+
It then identifies and labels trips based on the given maximum radius, stop duration, and pause duration.
|
|
453
|
+
It also splits the DataFrame into segments based on the given gap duration.
|
|
454
|
+
|
|
455
|
+
Args:
|
|
456
|
+
df (pd.DataFrame): The input DataFrame to process. It must not contain 'segment_id', 'trip_id', and 'trip_status' columns.
|
|
457
|
+
crs (str): The coordinate reference system to use when processing the DataFrame. The latitude and longitude columns must be in this CRS, otherwise the results will be incorrect.
|
|
458
|
+
sampling_frequency (float): The sampling frequency of the data in seconds.
|
|
459
|
+
gap_duration (timedelta): The minimum duration to consider a gap, i.e. if there is missing data for longer than this duration, the data is split into a new segment.
|
|
460
|
+
max_radius (int | float): The maximum radius that subject needs to stay in to be considered a stop or pause.
|
|
461
|
+
stop_duration (timedelta): The minimum duration to subject need to stay in specified radius to be considered a stop.
|
|
462
|
+
pause_duration (timedelta): The minimum duration to subject need to stay in specified radius to be considered a pause.
|
|
463
|
+
min_duration (int | float | None, optional): The minimum duration of a trip. Trips that are shorter than this duration are filtered out. If None, filter is not applied.
|
|
464
|
+
min_length (int | float | None, optional): The minimum length of a trip. Trips that are shorter than this length are filtered out. If None, filter is not applied.
|
|
465
|
+
min_distance (int | float | None, optional): The minimum distance between points. Points that are closer than this distance are filtered out. If None, filter is not applied.
|
|
466
|
+
max_speed (int | float | None, optional): The maximum speed. Speed between points that are faster than this speed are filtered out. If None, filter is not applied.
|
|
467
|
+
|
|
468
|
+
Returns:
|
|
469
|
+
pd.DataFrame: The processed DataFrame with updated 'trip_id' and 'trip_status' columns.
|
|
470
|
+
"""
|
|
471
|
+
|
|
472
|
+
columns_not_exists(
|
|
473
|
+
df,
|
|
474
|
+
["segment_id", "trip_id", "trip_status", "stationary_id"],
|
|
475
|
+
overwrite=overwrite,
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
# Keep a copy of the original DataFrame.
|
|
479
|
+
temp_df = df.copy()
|
|
480
|
+
|
|
481
|
+
# Get the segments from the DataFrame.
|
|
482
|
+
segments = gap_splitter(
|
|
483
|
+
temp_df[["latitude", "longitude"]], name="segment_id", min_duration=gap_duration
|
|
484
|
+
)
|
|
485
|
+
df = df.join(
|
|
486
|
+
segments[["segment_id"]],
|
|
487
|
+
how="left",
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
temp_df = (
|
|
491
|
+
segments.groupby("segment_id")
|
|
492
|
+
.apply(
|
|
493
|
+
lambda x: get_segment_trips(
|
|
494
|
+
x,
|
|
495
|
+
crs=crs,
|
|
496
|
+
sampling_frequency=sampling_frequency,
|
|
497
|
+
stop_radius=stop_radius,
|
|
498
|
+
stop_duration=stop_duration,
|
|
499
|
+
pause_radius=pause_radius,
|
|
500
|
+
pause_duration=pause_duration,
|
|
501
|
+
min_distance=min_distance,
|
|
502
|
+
max_speed=max_speed,
|
|
503
|
+
)
|
|
504
|
+
)
|
|
505
|
+
.reset_index(level=0, drop=True)
|
|
506
|
+
) # type: ignore
|
|
507
|
+
|
|
508
|
+
# Remove trips that are too short (duration).
|
|
509
|
+
if min_duration:
|
|
510
|
+
remove_trips_by_duration(temp_df, min_duration=min_duration)
|
|
511
|
+
|
|
512
|
+
# Remove trips that are too short (length).
|
|
513
|
+
if min_length:
|
|
514
|
+
remove_trips_by_length(temp_df, min_length=min_length, crs=crs)
|
|
515
|
+
|
|
516
|
+
temp_df = temp_df[temp_df["trip_status"].notna()]
|
|
517
|
+
trip_mask = temp_df["trip_id"].notna()
|
|
518
|
+
stationary_mask = temp_df["stationary_id"].notna()
|
|
519
|
+
|
|
520
|
+
# Renumbers the trip IDs to be unique across all segments.
|
|
521
|
+
temp_df.loc[trip_mask, "trip_id"] = (
|
|
522
|
+
pd.factorize(
|
|
523
|
+
temp_df.loc[trip_mask, "segment_id"].astype(str)
|
|
524
|
+
+ temp_df.loc[trip_mask, "trip_id"].astype(str)
|
|
525
|
+
)[0]
|
|
526
|
+
+ 1
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
# Renumbers the stationary IDs to be unique across all segments.
|
|
530
|
+
temp_df["stationary_id"] = (
|
|
531
|
+
temp_df["stationary_id"].isna().astype(int).diff().ne(0).cumsum()
|
|
532
|
+
).where(stationary_mask)
|
|
533
|
+
|
|
534
|
+
temp_df.loc[stationary_mask, "stationary_id"] = (
|
|
535
|
+
pd.factorize(
|
|
536
|
+
temp_df.loc[stationary_mask, "segment_id"].astype(str)
|
|
537
|
+
+ temp_df.loc[stationary_mask, "stationary_id"].astype(str)
|
|
538
|
+
)[0]
|
|
539
|
+
+ 1
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
# Merge the processed DataFrame with the original DataFrame.
|
|
543
|
+
df = df.join(
|
|
544
|
+
temp_df[["trip_status", "trip_id", "stationary_id"]],
|
|
545
|
+
how="left",
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
# Validate the output DataFrame.
|
|
549
|
+
df = SCHEMA.validate(df)
|
|
550
|
+
|
|
551
|
+
return df
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from datetime import timedelta
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import pandas as pd
|
|
5
|
+
|
|
6
|
+
from ..structure.validation.subject import SCHEMA
|
|
7
|
+
|
|
8
|
+
# TODO: Need to be tested, probably refactored!
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_wear_time(df: pd.DataFrame, sampling_frequency: float) -> pd.Series:
|
|
12
|
+
series = df.groupby(pd.Grouper(level="datetime", freq="D"))["wear"].sum()
|
|
13
|
+
series = series.rename("wear_time")
|
|
14
|
+
series = series.apply(lambda x: timedelta(seconds=(x * sampling_frequency)))
|
|
15
|
+
return series
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_wear_days(
|
|
19
|
+
df: pd.DataFrame, sampling_frequency: float, min_time: timedelta
|
|
20
|
+
) -> pd.DataFrame:
|
|
21
|
+
days = get_wear_time(df, sampling_frequency).to_frame()
|
|
22
|
+
|
|
23
|
+
days["valid"] = days["wear_time"] >= min_time
|
|
24
|
+
days["day"] = days.index.day_of_week # type: ignore
|
|
25
|
+
days["day_type"] = days["day"].apply(lambda x: "weekend" if x > 4 else "weekday")
|
|
26
|
+
|
|
27
|
+
return days.drop(columns=["day"])
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_wear_validity(
|
|
31
|
+
df: pd.DataFrame,
|
|
32
|
+
sampling_frequency: float,
|
|
33
|
+
min_time: timedelta,
|
|
34
|
+
weekday: int = 0,
|
|
35
|
+
weekend: int = 0,
|
|
36
|
+
) -> tuple[dict[str, Any], pd.DataFrame]:
|
|
37
|
+
if weekday == 0 and weekend == 0:
|
|
38
|
+
raise ValueError(
|
|
39
|
+
"Parameters weekday and weekend cannot both be 0. Please set at least one of them to a positive number of days."
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
days = get_wear_days(df, sampling_frequency, min_time)
|
|
43
|
+
day_types = days.groupby("day_type")["valid"].value_counts().reset_index()
|
|
44
|
+
|
|
45
|
+
weekday_count = day_types.loc[
|
|
46
|
+
(day_types["day_type"] == "weekday") & (day_types["valid"] == True)
|
|
47
|
+
]
|
|
48
|
+
if not weekday_count.empty:
|
|
49
|
+
weekday_count = weekday_count["count"].values[0]
|
|
50
|
+
else:
|
|
51
|
+
weekday_count = 0
|
|
52
|
+
|
|
53
|
+
weekend_count = day_types.loc[
|
|
54
|
+
(day_types["day_type"] == "weekend") & (day_types["valid"] == True)
|
|
55
|
+
]
|
|
56
|
+
if not weekend_count.empty:
|
|
57
|
+
weekend_count = weekend_count["count"].values[0]
|
|
58
|
+
else:
|
|
59
|
+
weekend_count = 0
|
|
60
|
+
|
|
61
|
+
weekday_valid = True if weekday_count >= weekday else False
|
|
62
|
+
weekend_valid = True if weekend_count >= weekend else False
|
|
63
|
+
valid = weekday_valid and weekend_valid
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
"weekday": {"valid": weekday_valid, "count": weekday_count},
|
|
67
|
+
"weekend": {"valid": weekend_valid, "count": weekend_count},
|
|
68
|
+
"valid": valid,
|
|
69
|
+
}, days
|