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.
Files changed (52) hide show
  1. {labda-0.0.2 → labda-0.0.4}/PKG-INFO +4 -2
  2. labda-0.0.4/README.md +27 -0
  3. {labda-0.0.2 → labda-0.0.4}/labda/__init__.py +3 -0
  4. {labda-0.0.2 → labda-0.0.4}/labda/assets/transportation_cut_points.py +1 -1
  5. labda-0.0.4/labda/logging.py +54 -0
  6. labda-0.0.4/labda/parallel.py +41 -0
  7. {labda-0.0.2 → labda-0.0.4}/labda/parsers/actigraph.py +23 -29
  8. labda-0.0.4/labda/parsers/bulk.py +39 -0
  9. {labda-0.0.2 → labda-0.0.4}/labda/parsers/gadgetbridge.py +7 -13
  10. {labda-0.0.2 → labda-0.0.4}/labda/parsers/qstarz.py +62 -48
  11. labda-0.0.4/labda/parsers/utils.py +60 -0
  12. {labda-0.0.2 → labda-0.0.4}/labda/processing/spatial/manipulations.py +1 -0
  13. {labda-0.0.2 → labda-0.0.4}/labda/processing/spatial/timeline.py +14 -3
  14. {labda-0.0.2 → labda-0.0.4}/labda/processing/spatial/transportation.py +3 -0
  15. {labda-0.0.2 → labda-0.0.4}/labda/processing/spatial/trips.py +123 -44
  16. {labda-0.0.2 → labda-0.0.4}/labda/structure/collection.py +65 -22
  17. {labda-0.0.2 → labda-0.0.4}/labda/structure/domains.py +1 -1
  18. {labda-0.0.2 → labda-0.0.4}/labda/structure/linkage.py +2 -3
  19. {labda-0.0.2 → labda-0.0.4}/labda/structure/merging.py +21 -5
  20. {labda-0.0.2 → labda-0.0.4}/labda/structure/subject.py +30 -14
  21. {labda-0.0.2 → labda-0.0.4}/labda/structure/validation/subject.py +2 -1
  22. {labda-0.0.2 → labda-0.0.4}/labda/utils.py +34 -0
  23. labda-0.0.4/labda/visualisation/spatial.py +127 -0
  24. {labda-0.0.2 → labda-0.0.4}/pyproject.toml +7 -17
  25. labda-0.0.2/README.md +0 -26
  26. labda-0.0.2/labda/parsers/bulk.py +0 -31
  27. labda-0.0.2/labda/processing/spatial/environment.py +0 -50
  28. {labda-0.0.2 → labda-0.0.4}/CHANGELOG.md +0 -0
  29. {labda-0.0.2 → labda-0.0.4}/labda/assets/__init__.py +0 -0
  30. {labda-0.0.2 → labda-0.0.4}/labda/assets/counts_cut_points.py +0 -0
  31. {labda-0.0.2 → labda-0.0.4}/labda/expanders/__init__.py +0 -0
  32. {labda-0.0.2 → labda-0.0.4}/labda/expanders/accelerometer.py +0 -0
  33. {labda-0.0.2 → labda-0.0.4}/labda/expanders/extras.py +0 -0
  34. {labda-0.0.2 → labda-0.0.4}/labda/expanders/spatial.py +0 -0
  35. {labda-0.0.2 → labda-0.0.4}/labda/parsers/__init__.py +0 -0
  36. {labda-0.0.2 → labda-0.0.4}/labda/parsers/garmin.py +0 -0
  37. {labda-0.0.2 → labda-0.0.4}/labda/processing/__init__.py +0 -0
  38. {labda-0.0.2 → labda-0.0.4}/labda/processing/accelerometer/__init__.py +0 -0
  39. {labda-0.0.2 → labda-0.0.4}/labda/processing/accelerometer/activity_intensity.py +0 -0
  40. {labda-0.0.2 → labda-0.0.4}/labda/processing/accelerometer/steps.py +0 -0
  41. {labda-0.0.2 → labda-0.0.4}/labda/processing/accelerometer/wear.py +0 -0
  42. {labda-0.0.2 → labda-0.0.4}/labda/processing/bouts.py +0 -0
  43. {labda-0.0.2 → labda-0.0.4}/labda/processing/domains.py +0 -0
  44. {labda-0.0.2 → labda-0.0.4}/labda/processing/manipulations.py +0 -0
  45. {labda-0.0.2 → labda-0.0.4}/labda/processing/spatial/__init__.py +0 -0
  46. {labda-0.0.2 → labda-0.0.4}/labda/processing/wear_validity.py +0 -0
  47. {labda-0.0.2 → labda-0.0.4}/labda/structure/__init__.py +0 -0
  48. {labda-0.0.2 → labda-0.0.4}/labda/structure/resampling.py +0 -0
  49. {labda-0.0.2 → labda-0.0.4}/labda/structure/validation/__init__.py +0 -0
  50. {labda-0.0.2 → labda-0.0.4}/labda/structure/validation/domains.py +0 -0
  51. {labda-0.0.2 → labda-0.0.4}/labda/structure/validation/linkage.py +0 -0
  52. {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.2
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
- <p xmlns:cc="http://creativecommons.org/ns#" xmlns:dct="http://purl.org/dc/terms/"><a property="dct:title" rel="cc:attributionURL" href="https://labda.josefheidler.cz/">Library for Advanced Behavioural Data Analysis</a> by <a rel="cc:attributionURL dct:creator" property="cc:attributionName" href="https://josefheidler.cz/">Josef Heidler</a> is licensed under <a href="http://creativecommons.org/licenses/by/4.0/?ref=chooser-v1" target="_blank" rel="license noopener noreferrer" style="display:inline-block;">CC BY 4.0<img style="height:22px!important;margin-left:3px;vertical-align:text-bottom;" src="https://mirrors.creativecommons.org/presskit/icons/cc.svg?ref=chooser-v1"><img style="height:22px!important;margin-left:3px;vertical-align:text-bottom;" src="https://mirrors.creativecommons.org/presskit/icons/by.svg?ref=chooser-v1"></a></p>
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..
@@ -1 +1,4 @@
1
+ from .logging import configure_logger
1
2
  from .structure import Collection, Column, Subject, merge_collections, merge_subjects
3
+
4
+ configure_logger("ERROR")
@@ -8,7 +8,7 @@ CUT_POINTS = [
8
8
  "required_data": Column.SPEED,
9
9
  "units": "km/h",
10
10
  "cut_points": [
11
- {"name": "walking/running", "max": 9},
11
+ {"name": "walk/run", "max": 9},
12
12
  {"name": "bicycle", "max": 35},
13
13
  {"name": "vehicle", "max": float("inf")},
14
14
  ],
@@ -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 get_sampling_frequency
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
- dt_format: str | None = None,
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, dt_format)
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
- sampling_frequency = parsed_metadata.get("sampling_frequency")
177
+ df, timezone = set_timezone(df, timezone)
191
178
 
192
- df.dropna(how="all", inplace=True)
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
- df = df.resample(f"{sampling_frequency}s").nearest()
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
- return Subject(metadata=metadata, df=df) # type: ignore
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
- # FIXME: Rework this function.
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
- indices = df[Column.HEART_RATE].dropna().index
120
- intervals = indices.to_series().diff()
117
+ df.loc[df[Column.HEART_RATE].notna(), Column.WEAR] = True
121
118
 
122
- if not intervals.isna().all():
123
- interval = int(intervals.mode().iloc[0]) - 1
124
- df.loc[df[Column.HEART_RATE].notna(), Column.WEAR] = True
119
+ if limit:
120
+ df[Column.WEAR] = df[Column.WEAR].bfill(limit=limit)
125
121
 
126
- if not interval == 0:
127
- df[Column.WEAR] = df[Column.WEAR].bfill(limit=interval)
128
- df.loc[df[Column.WEAR].isna(), Column.WEAR] = False
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
- convert_to_dataframe,
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
- # TODO: Remove all spaces from the column names, so there is no need to check if there is space or not.
20
+
21
+
22
+ def 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.loc[df["N/S"] == "S", Column.LATITUDE] = -df[Column.LATITUDE]
232
- df.loc[df["E/W"] == "W", Column.LONGITUDE] = -df[Column.LONGITUDE]
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
- dt_format: str | None = None,
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, dt_format)
303
-
304
- if not crs:
305
- crs = get_crs(df)
306
-
307
- df = handle_crs(df, crs)
308
-
309
- if not timezone:
310
- timezone = get_timezone(df)
311
+ 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
- return Subject(metadata=metadata, df=df)
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
@@ -41,6 +41,7 @@ def stop_splitter(
41
41
 
42
42
  columns_not_exists(df, [name], overwrite=overwrite)
43
43
 
44
+ # TODO: How to handle NA values, maybe just filter to have only valid points (with coordinates).
44
45
  df = df.copy()
45
46
  df[name] = False
46
47
 
@@ -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
- mode = df["trip_mode"].iloc[0]
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 = pd.concat([trips, stops], ignore_index=True).sort_values(["start", "end"])
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()