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,103 @@
|
|
|
1
|
+
import multiprocessing as mp
|
|
2
|
+
import secrets
|
|
3
|
+
from functools import partial
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
from .subject import Subject
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _process_subject(subject, func, **kwargs):
|
|
13
|
+
func(subject, **kwargs)
|
|
14
|
+
|
|
15
|
+
return subject
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Collection(BaseModel):
|
|
19
|
+
id: Optional[str] = Field(default_factory=lambda: secrets.token_hex(4))
|
|
20
|
+
subjects: list[Subject] = Field(default_factory=list)
|
|
21
|
+
|
|
22
|
+
def __repr__(self):
|
|
23
|
+
subjects_repr = ", ".join([p.metadata.id for p in self.subjects]) # type: ignore
|
|
24
|
+
return f"Collection(id={self.id}, subjects=[{subjects_repr}])"
|
|
25
|
+
|
|
26
|
+
def add_subject(self, subject: Subject):
|
|
27
|
+
subjects_ids = [s.metadata.id for s in self.subjects]
|
|
28
|
+
|
|
29
|
+
if subject.metadata.id in subjects_ids:
|
|
30
|
+
raise ValueError(
|
|
31
|
+
f"Subject with id '{subject.metadata.id}' already exists in collection."
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
subject.collection = self.id # type: ignore
|
|
35
|
+
self.subjects.append(subject) # type: ignore
|
|
36
|
+
|
|
37
|
+
def get_subject(self, id: str) -> Subject:
|
|
38
|
+
for subject in self.subjects:
|
|
39
|
+
if subject.metadata.id == id:
|
|
40
|
+
return subject
|
|
41
|
+
raise ValueError(f"Subject with id '{id}' not found.")
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def from_folder(cls, path: str | Path, id: str | None = None) -> "Collection":
|
|
45
|
+
if isinstance(path, str):
|
|
46
|
+
path = Path(path)
|
|
47
|
+
|
|
48
|
+
subjects = [Subject.from_parquet(file) for file in path.glob("*.parquet")]
|
|
49
|
+
return cls(id=id, subjects=subjects)
|
|
50
|
+
|
|
51
|
+
def to_folder(self, path: str | Path, overwrite: bool = False):
|
|
52
|
+
if isinstance(path, str):
|
|
53
|
+
path = Path(path)
|
|
54
|
+
|
|
55
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
for subject in self.subjects:
|
|
57
|
+
subject.to_parquet(
|
|
58
|
+
path / f"{subject.metadata.id}.parquet", overwrite=overwrite
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def detect_trips(self, **kwargs):
|
|
62
|
+
with mp.Pool(mp.cpu_count()) as pool:
|
|
63
|
+
self.subjects = pool.map(
|
|
64
|
+
partial(_process_subject, func=Subject.detect_trips, **kwargs),
|
|
65
|
+
self.subjects,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def detect_activity_intensity(self, **kwargs) -> None:
|
|
69
|
+
with mp.Pool(mp.cpu_count()) as pool:
|
|
70
|
+
self.subjects = pool.map(
|
|
71
|
+
partial(
|
|
72
|
+
_process_subject, func=Subject.detect_activity_intensity, **kwargs
|
|
73
|
+
),
|
|
74
|
+
self.subjects,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# TODO: Rework whole consistency check
|
|
78
|
+
# TODO: Add also check columns consistency
|
|
79
|
+
def _check_consistency(self, attribute, error_message):
|
|
80
|
+
# TODO: Add docstring, better name for method
|
|
81
|
+
values = [getattr(subject.metadata, attribute) for subject in self.subjects]
|
|
82
|
+
unique = set(values)
|
|
83
|
+
|
|
84
|
+
if len(unique) != 1:
|
|
85
|
+
print(f"{error_message}: {unique}")
|
|
86
|
+
|
|
87
|
+
def _check_consistent_sampling_frequencies(self):
|
|
88
|
+
self._check_consistency(
|
|
89
|
+
"sampling_frequency", "Sampling frequencies are not consistent"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def _check_consistent_crs(self):
|
|
93
|
+
self._check_consistency("crs", "CRS are not consistent")
|
|
94
|
+
|
|
95
|
+
def _check_consistent_timezones(self):
|
|
96
|
+
self._check_consistency("timezone", "Timezones are not consistent")
|
|
97
|
+
|
|
98
|
+
def check_consistency(self):
|
|
99
|
+
self._check_consistent_sampling_frequencies()
|
|
100
|
+
self._check_consistent_crs()
|
|
101
|
+
self._check_consistent_timezones()
|
|
102
|
+
|
|
103
|
+
print("Consistency check finished.")
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import pandas as pd
|
|
4
|
+
from shapely import wkt
|
|
5
|
+
|
|
6
|
+
from .validation.domains import SCHEMA
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def from_csv(path: str | Path) -> pd.DataFrame:
|
|
10
|
+
# TODO: Check datetime overlap.
|
|
11
|
+
# TODO: Add column for "importance" in case of overlapping domains.
|
|
12
|
+
if isinstance(path, str):
|
|
13
|
+
path = Path(path)
|
|
14
|
+
|
|
15
|
+
domains = pd.read_csv(
|
|
16
|
+
path,
|
|
17
|
+
dtype={
|
|
18
|
+
"subject_id": "string",
|
|
19
|
+
"domain": "string",
|
|
20
|
+
"geometry": "string",
|
|
21
|
+
},
|
|
22
|
+
delimiter=";",
|
|
23
|
+
parse_dates=["start", "end"],
|
|
24
|
+
date_format="%d-%m-%Y %H:%M:%S",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
domains["geometry"] = domains["geometry"].apply(wkt.loads)
|
|
28
|
+
|
|
29
|
+
return SCHEMA.validate(domains)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import pandas as pd
|
|
4
|
+
from shapely import wkt
|
|
5
|
+
|
|
6
|
+
from .validation.linkage import SCHEMA
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def from_csv(path: str | Path) -> pd.DataFrame:
|
|
10
|
+
if isinstance(path, str):
|
|
11
|
+
path = Path(path)
|
|
12
|
+
|
|
13
|
+
df = pd.read_csv(
|
|
14
|
+
path,
|
|
15
|
+
dtype={
|
|
16
|
+
"subject_id": "string",
|
|
17
|
+
"sensor_id": "string",
|
|
18
|
+
},
|
|
19
|
+
delimiter=";",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
if "start" in df.columns:
|
|
23
|
+
df["start"] = pd.to_datetime(df["start"])
|
|
24
|
+
|
|
25
|
+
if "end" in df.columns:
|
|
26
|
+
df["end"] = pd.to_datetime(df["end"])
|
|
27
|
+
|
|
28
|
+
return SCHEMA.validate(df)
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
|
|
3
|
+
from .collection import Collection
|
|
4
|
+
from .subject import Metadata, Subject
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _check_duplicate_cols(left: pd.DataFrame, right: pd.DataFrame) -> None:
|
|
8
|
+
duplicate_cols = set(left.columns) & set(right.columns)
|
|
9
|
+
if duplicate_cols:
|
|
10
|
+
raise ValueError(f"Before merging, remove duplicate columns: {duplicate_cols}")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _check_ids(left: Subject, right: Subject) -> str:
|
|
14
|
+
if left.metadata.id != right.metadata.id:
|
|
15
|
+
raise ValueError(f"IDs do not match: {left.metadata.id} != {right.metadata.id}")
|
|
16
|
+
|
|
17
|
+
return left.metadata.id
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _check_sampling_frequency(left: Subject, right: Subject) -> float:
|
|
21
|
+
if left.metadata.sampling_frequency != right.metadata.sampling_frequency:
|
|
22
|
+
raise ValueError(
|
|
23
|
+
f"Sampling frequency mismatch: {left.metadata.sampling_frequency} != {right.metadata.sampling_frequency}"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
return left.metadata.sampling_frequency
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _check_timezones(left: Subject, right: Subject) -> str | None:
|
|
30
|
+
if (
|
|
31
|
+
(left.metadata.timezone)
|
|
32
|
+
and (right.metadata.timezone)
|
|
33
|
+
and left.metadata.timezone != right.metadata.timezone
|
|
34
|
+
):
|
|
35
|
+
raise ValueError(
|
|
36
|
+
f"Timezone mismatch: {left.metadata.timezone} != {right.metadata.timezone}"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
return left.metadata.timezone or right.metadata.timezone
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _check_crs(left: Subject, right: Subject) -> str | None:
|
|
43
|
+
if (
|
|
44
|
+
(left.metadata.crs)
|
|
45
|
+
and (right.metadata.crs)
|
|
46
|
+
and left.metadata.crs != right.metadata.crs
|
|
47
|
+
):
|
|
48
|
+
raise ValueError(f"CRS mismatch: {left.metadata.crs} != {right.metadata.crs}")
|
|
49
|
+
|
|
50
|
+
return left.metadata.crs or right.metadata.crs
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _get_subjects(collection: Collection) -> list[str]:
|
|
54
|
+
return [subject.metadata.id for subject in collection.subjects]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def merge_subjects(left: Subject, right: Subject, **kwargs) -> Subject:
|
|
58
|
+
if not isinstance(left, Subject) and not isinstance(right, Subject):
|
|
59
|
+
raise ValueError(f"Unsupported types: {type(left)} and {type(right)}")
|
|
60
|
+
|
|
61
|
+
id = _check_ids(left, right)
|
|
62
|
+
sf = _check_sampling_frequency(left, right)
|
|
63
|
+
tz = _check_timezones(left, right)
|
|
64
|
+
crs = _check_crs(left, right)
|
|
65
|
+
|
|
66
|
+
metadata = Metadata(
|
|
67
|
+
id=id,
|
|
68
|
+
sensor=left.metadata.sensor + right.metadata.sensor,
|
|
69
|
+
sampling_frequency=sf,
|
|
70
|
+
timezone=tz,
|
|
71
|
+
crs=crs,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# FIXME: Add suffixes to columns, even if not duplicated
|
|
75
|
+
if not kwargs.get("suffixes"):
|
|
76
|
+
_check_duplicate_cols(left.df, right.df)
|
|
77
|
+
merged = pd.merge(left.df, right.df, left_index=True, right_index=True, **kwargs)
|
|
78
|
+
|
|
79
|
+
return Subject(metadata=metadata, df=merged)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def merge_collections(
|
|
83
|
+
left: Collection,
|
|
84
|
+
right: Collection,
|
|
85
|
+
*,
|
|
86
|
+
keep: bool = False,
|
|
87
|
+
**kwargs,
|
|
88
|
+
) -> Collection:
|
|
89
|
+
if not isinstance(left, Collection) and not isinstance(right, Collection):
|
|
90
|
+
raise ValueError(f"Unsupported types: {type(left)} and {type(right)}")
|
|
91
|
+
|
|
92
|
+
left_ids = _get_subjects(left)
|
|
93
|
+
right_ids = _get_subjects(right)
|
|
94
|
+
subjects = set(left_ids + right_ids)
|
|
95
|
+
|
|
96
|
+
collection = Collection()
|
|
97
|
+
|
|
98
|
+
for subject in subjects:
|
|
99
|
+
try:
|
|
100
|
+
left_subject = left.get_subject(subject)
|
|
101
|
+
except Exception:
|
|
102
|
+
left_subject = None
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
right_subject = right.get_subject(subject)
|
|
106
|
+
except Exception:
|
|
107
|
+
right_subject = None
|
|
108
|
+
|
|
109
|
+
if left_subject and right_subject:
|
|
110
|
+
merged_subject = merge_subjects(left_subject, right_subject, **kwargs)
|
|
111
|
+
collection.add_subject(merged_subject)
|
|
112
|
+
else:
|
|
113
|
+
print(
|
|
114
|
+
f"Error while merging | {subject} | Subject not found in both collections."
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
# FIXME: Fix, it is not working
|
|
118
|
+
# FIXME: Suffix to check if exists
|
|
119
|
+
# FIXME: Native pd.merge function adds suffixes to columns with the same name otherwise not (no suffixe and we don't know the origin of the column)
|
|
120
|
+
|
|
121
|
+
if keep:
|
|
122
|
+
if left_subject:
|
|
123
|
+
# suffix = kwargs.get("suffixes")
|
|
124
|
+
# if suffix:
|
|
125
|
+
# left_subject.df = left_subject.df.add_suffix(suffix[0])
|
|
126
|
+
collection.add_subject(left_subject)
|
|
127
|
+
elif right_subject:
|
|
128
|
+
# suffix = kwargs.get("suffixes")
|
|
129
|
+
# if suffix:
|
|
130
|
+
# right_subject.df = right_subject.df.add_suffix(suffix[1])
|
|
131
|
+
collection.add_subject(right_subject)
|
|
132
|
+
|
|
133
|
+
return collection
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
import pandas as pd
|
|
4
|
+
|
|
5
|
+
from ..structure.validation.subject import Column
|
|
6
|
+
|
|
7
|
+
# TODO: Create one big configuration with SCHEMA (validation) and RESAMPLING
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def mode(x):
|
|
11
|
+
vals = x.to_list()
|
|
12
|
+
return max(vals, key=vals.count)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
UPSAMPLING = [
|
|
16
|
+
{"column": Column.SUBJECT_ID, "method": "first"},
|
|
17
|
+
{"column": Column.WEAR, "method": mode},
|
|
18
|
+
{"column": Column.TIMEDELTA, "method": "sum"},
|
|
19
|
+
# ---
|
|
20
|
+
{"column": Column.VERTICAL_COUNTS, "method": "sum"},
|
|
21
|
+
{"column": Column.HORIZONTAL_COUNTS, "method": "sum"},
|
|
22
|
+
{"column": Column.PERPENDICULAR_COUNTS, "method": "sum"},
|
|
23
|
+
{"column": Column.VM_COUNTS, "method": "mean"},
|
|
24
|
+
{"column": Column.POSITION, "method": mode},
|
|
25
|
+
{"column": Column.STEPS, "method": "sum"},
|
|
26
|
+
# ---
|
|
27
|
+
{"column": Column.LATITUDE, "method": "first"},
|
|
28
|
+
{"column": Column.LONGITUDE, "method": "first"},
|
|
29
|
+
{"column": Column.NSAT_VIEWED, "method": "first"},
|
|
30
|
+
{"column": Column.NSAT_USED, "method": "first"},
|
|
31
|
+
{"column": Column.NSAT_RATIO, "method": "first"},
|
|
32
|
+
{"column": Column.SNR_VIEWED, "method": "first"},
|
|
33
|
+
{"column": Column.SNR_USED, "method": "first"},
|
|
34
|
+
{"column": Column.PDOP, "method": "first"},
|
|
35
|
+
{"column": Column.HDOP, "method": "first"},
|
|
36
|
+
{"column": Column.VDOP, "method": "first"},
|
|
37
|
+
# ---
|
|
38
|
+
{"column": Column.DISTANCE, "method": "sum"},
|
|
39
|
+
{"column": Column.ELEVATION, "method": "first"},
|
|
40
|
+
{"column": Column.SPEED, "method": "mean"},
|
|
41
|
+
{"column": Column.ACCELERATION, "method": "mean"},
|
|
42
|
+
{"column": Column.DIRECTION, "method": "first"},
|
|
43
|
+
{"column": Column.ENVIRONMENT, "method": mode},
|
|
44
|
+
# ---
|
|
45
|
+
{"column": Column.HEART_RATE, "method": "mean"},
|
|
46
|
+
{"column": Column.LUX, "method": "mean"},
|
|
47
|
+
{"column": Column.ACTIVITY_INTENSITY, "method": mode},
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def upsample(
|
|
52
|
+
df: pd.DataFrame,
|
|
53
|
+
from_sf: float,
|
|
54
|
+
to_sf: float,
|
|
55
|
+
mapper: list[dict[str, Any]] | None = None,
|
|
56
|
+
) -> pd.DataFrame:
|
|
57
|
+
if from_sf > to_sf:
|
|
58
|
+
raise ValueError(f"Downsampling is not supported | {from_sf} > {to_sf}")
|
|
59
|
+
elif from_sf == to_sf:
|
|
60
|
+
return df
|
|
61
|
+
|
|
62
|
+
df = df.copy()
|
|
63
|
+
|
|
64
|
+
if not mapper:
|
|
65
|
+
mapper = UPSAMPLING
|
|
66
|
+
|
|
67
|
+
resampling = {v["column"]: v["method"] for v in mapper if v["column"] in df.columns}
|
|
68
|
+
|
|
69
|
+
dropped = [
|
|
70
|
+
colname for colname in df.columns if colname not in resampling.keys()
|
|
71
|
+
] # TODO: Check that it works properly.
|
|
72
|
+
if dropped:
|
|
73
|
+
print(
|
|
74
|
+
f"Columns that are not in the resampling configuration will be dropped | Dropping: {dropped}"
|
|
75
|
+
) # TODO: Write warning what columns are dropped.
|
|
76
|
+
|
|
77
|
+
df = df.resample(f"{to_sf}s").agg(resampling) # type: ignore
|
|
78
|
+
df.dropna(
|
|
79
|
+
axis=0, how="all", inplace=True
|
|
80
|
+
) # Dropping rows with all NaN values - they were created by resampling.
|
|
81
|
+
|
|
82
|
+
return df
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
from datetime import timedelta
|
|
2
|
+
from enum import StrEnum
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import TYPE_CHECKING, Any, Optional
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
# This is a workaround to avoid circular imports.
|
|
8
|
+
# TODO: Is there a better way to do this?
|
|
9
|
+
from .collection import Collection
|
|
10
|
+
|
|
11
|
+
import pandas as pd
|
|
12
|
+
import pyarrow as pa
|
|
13
|
+
import pyarrow.parquet as pq
|
|
14
|
+
from pydantic import BaseModel, Field
|
|
15
|
+
|
|
16
|
+
from ..expanders import (
|
|
17
|
+
add_acceleration,
|
|
18
|
+
add_direction,
|
|
19
|
+
add_distance,
|
|
20
|
+
add_speed,
|
|
21
|
+
add_timedelta,
|
|
22
|
+
)
|
|
23
|
+
from ..processing import detect_domains
|
|
24
|
+
from ..processing.accelerometer import detect_activity_intensity, detect_wear
|
|
25
|
+
from ..processing.spatial import detect_transportation, detect_trips, get_timeline
|
|
26
|
+
from ..structure.resampling import upsample
|
|
27
|
+
from ..utils import columns_exists, columns_not_exists
|
|
28
|
+
from .validation.domains import SCHEMA as DOMAINS_SCHEMA
|
|
29
|
+
from .validation.subject import SCHEMA, Column
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Vendor(StrEnum):
|
|
33
|
+
ACTIGRAPH = "ActiGraph"
|
|
34
|
+
XIAOMI = "Xiaomi"
|
|
35
|
+
SENS = "SENS"
|
|
36
|
+
GARMIN = "Garmin"
|
|
37
|
+
QSTARZ = "Qstarz"
|
|
38
|
+
GGIR = "GGIR"
|
|
39
|
+
SENSECAP = "SenseCap"
|
|
40
|
+
ANDROID = "Android"
|
|
41
|
+
IOS = "iOS"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Sensor(BaseModel):
|
|
45
|
+
id: str
|
|
46
|
+
serial_number: str | None = None
|
|
47
|
+
model: str | None = None
|
|
48
|
+
vendor: Vendor | None = None
|
|
49
|
+
firmware_version: str | None = None
|
|
50
|
+
extra: dict[str, Any] | None = None
|
|
51
|
+
|
|
52
|
+
class Config:
|
|
53
|
+
coerce_numbers_to_str = True
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Metadata(BaseModel):
|
|
57
|
+
id: str
|
|
58
|
+
sensor: list[Sensor] = Field(default_factory=list)
|
|
59
|
+
sampling_frequency: float = Field(ge=0, description="Sampling frequency in seconds")
|
|
60
|
+
crs: str | None = None
|
|
61
|
+
timezone: str | None = None
|
|
62
|
+
|
|
63
|
+
class Config:
|
|
64
|
+
coerce_numbers_to_str = True
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class Subject(BaseModel):
|
|
68
|
+
metadata: Metadata
|
|
69
|
+
collection: Optional["Collection"] = None
|
|
70
|
+
df: pd.DataFrame
|
|
71
|
+
|
|
72
|
+
class Config:
|
|
73
|
+
arbitrary_types_allowed = True
|
|
74
|
+
|
|
75
|
+
def __str__(self):
|
|
76
|
+
return str(f"{self.metadata}\n{self.df}")
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def timeline(self):
|
|
80
|
+
if not self.metadata.crs:
|
|
81
|
+
raise ValueError("Records object does not have a crs defined.")
|
|
82
|
+
|
|
83
|
+
columns_exists(
|
|
84
|
+
self.df,
|
|
85
|
+
[Column.TRIP_ID, Column.TRIP_STATUS, Column.STATIONARY_ID],
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
return get_timeline(self.df, crs=self.metadata.crs)
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def domains(self):
|
|
92
|
+
# TODO: Implement this property.
|
|
93
|
+
raise NotImplementedError("This method is not implemented yet.")
|
|
94
|
+
|
|
95
|
+
def to_parquet(
|
|
96
|
+
self,
|
|
97
|
+
path: str | Path,
|
|
98
|
+
*,
|
|
99
|
+
overwrite: bool = False,
|
|
100
|
+
) -> None:
|
|
101
|
+
if isinstance(path, str):
|
|
102
|
+
path = Path(path)
|
|
103
|
+
|
|
104
|
+
if path.exists() and not overwrite:
|
|
105
|
+
raise FileExistsError(
|
|
106
|
+
f"The file '{path}' already exists. If you want to overwrite it, set the 'overwrite' argument to 'True'."
|
|
107
|
+
)
|
|
108
|
+
else:
|
|
109
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
110
|
+
|
|
111
|
+
custom_metadata = {"labda".encode(): self.metadata.model_dump_json().encode()}
|
|
112
|
+
self.validate()
|
|
113
|
+
table = pa.Table.from_pandas(self.df)
|
|
114
|
+
|
|
115
|
+
existing_metadata = table.schema.metadata
|
|
116
|
+
combined_meta = {**custom_metadata, **existing_metadata}
|
|
117
|
+
|
|
118
|
+
table = table.replace_schema_metadata(combined_meta)
|
|
119
|
+
pq.write_table(table, path)
|
|
120
|
+
|
|
121
|
+
@classmethod
|
|
122
|
+
def from_parquet(cls, path: str | Path) -> "Subject":
|
|
123
|
+
if isinstance(path, str):
|
|
124
|
+
path = Path(path)
|
|
125
|
+
|
|
126
|
+
table = pq.read_table(path)
|
|
127
|
+
df = table.to_pandas()
|
|
128
|
+
custom_metadata = Metadata.model_validate_json(
|
|
129
|
+
table.schema.metadata["labda".encode()]
|
|
130
|
+
)
|
|
131
|
+
cls = cls(metadata=custom_metadata, df=df)
|
|
132
|
+
cls.validate()
|
|
133
|
+
|
|
134
|
+
return cls
|
|
135
|
+
|
|
136
|
+
def validate(
|
|
137
|
+
self,
|
|
138
|
+
*,
|
|
139
|
+
extra_columns: bool = False,
|
|
140
|
+
):
|
|
141
|
+
self.df = SCHEMA.validate(self.df)
|
|
142
|
+
|
|
143
|
+
# Order columns as defined in Column
|
|
144
|
+
records_columns = [col.value for col in Column]
|
|
145
|
+
ordered_columns = [col for col in records_columns if col in self.df.columns]
|
|
146
|
+
|
|
147
|
+
# Append extra columns that are not in Column at the end, alphabetically
|
|
148
|
+
if extra_columns:
|
|
149
|
+
extra = sorted(set(self.df.columns) - set(records_columns))
|
|
150
|
+
ordered_columns.extend(extra)
|
|
151
|
+
|
|
152
|
+
self.df = self.df[ordered_columns]
|
|
153
|
+
|
|
154
|
+
def add_timedelta(
|
|
155
|
+
self,
|
|
156
|
+
*,
|
|
157
|
+
name: str = Column.TIMEDELTA,
|
|
158
|
+
overwrite: bool = False,
|
|
159
|
+
):
|
|
160
|
+
self.df = add_timedelta(self.df, name=name, overwrite=overwrite)
|
|
161
|
+
|
|
162
|
+
def add_distance(
|
|
163
|
+
self,
|
|
164
|
+
*,
|
|
165
|
+
name: str = Column.DISTANCE,
|
|
166
|
+
overwrite: bool = False,
|
|
167
|
+
):
|
|
168
|
+
self.df = add_distance(
|
|
169
|
+
self.df, crs=self.metadata.crs, name=name, overwrite=overwrite
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
def add_speed(
|
|
173
|
+
self,
|
|
174
|
+
*,
|
|
175
|
+
name: str = Column.SPEED,
|
|
176
|
+
overwrite: bool = False,
|
|
177
|
+
):
|
|
178
|
+
self.df = add_speed(
|
|
179
|
+
self.df, crs=self.metadata.crs, name=name, overwrite=overwrite
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
def add_acceleration(
|
|
183
|
+
self,
|
|
184
|
+
*,
|
|
185
|
+
name: str = Column.ACCELERATION,
|
|
186
|
+
overwrite: bool = False,
|
|
187
|
+
):
|
|
188
|
+
self.df = add_acceleration(
|
|
189
|
+
self.df, crs=self.metadata.crs, name=name, overwrite=overwrite
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
def add_direction(
|
|
193
|
+
self,
|
|
194
|
+
*,
|
|
195
|
+
name: str = Column.DIRECTION,
|
|
196
|
+
overwrite: bool = False,
|
|
197
|
+
):
|
|
198
|
+
self.df = add_direction(self.df, name=name, overwrite=overwrite)
|
|
199
|
+
|
|
200
|
+
def detect_trips(
|
|
201
|
+
self,
|
|
202
|
+
cut_points: dict[str, Any] | None = None,
|
|
203
|
+
*,
|
|
204
|
+
gap_duration: timedelta,
|
|
205
|
+
stop_radius: int | float,
|
|
206
|
+
stop_duration: timedelta,
|
|
207
|
+
window: int | None = 3,
|
|
208
|
+
pause_radius: int | float | None = None,
|
|
209
|
+
pause_duration: timedelta | None = None,
|
|
210
|
+
min_duration: timedelta | None = None,
|
|
211
|
+
min_length: int | float | None = None,
|
|
212
|
+
min_distance: int | float | None = None,
|
|
213
|
+
max_speed: int | float | None = None,
|
|
214
|
+
overwrite: bool = False,
|
|
215
|
+
) -> None:
|
|
216
|
+
if not self.metadata.crs:
|
|
217
|
+
raise ValueError("Records object does not have a CRS defined.")
|
|
218
|
+
|
|
219
|
+
self.df = detect_trips(
|
|
220
|
+
self.df,
|
|
221
|
+
crs=self.metadata.crs,
|
|
222
|
+
sampling_frequency=self.metadata.sampling_frequency,
|
|
223
|
+
overwrite=overwrite,
|
|
224
|
+
gap_duration=gap_duration,
|
|
225
|
+
stop_radius=stop_radius,
|
|
226
|
+
stop_duration=stop_duration,
|
|
227
|
+
pause_radius=pause_radius,
|
|
228
|
+
pause_duration=pause_duration,
|
|
229
|
+
min_duration=min_duration,
|
|
230
|
+
min_length=min_length,
|
|
231
|
+
min_distance=min_distance,
|
|
232
|
+
max_speed=max_speed,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
if cut_points:
|
|
236
|
+
self.df = detect_transportation(
|
|
237
|
+
self.df,
|
|
238
|
+
self.metadata.crs,
|
|
239
|
+
cut_points,
|
|
240
|
+
window=window,
|
|
241
|
+
overwrite=overwrite,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
def detect_domains(
|
|
245
|
+
self,
|
|
246
|
+
domains: pd.DataFrame,
|
|
247
|
+
*,
|
|
248
|
+
overwrite: bool = False,
|
|
249
|
+
) -> None:
|
|
250
|
+
domains = DOMAINS_SCHEMA.validate(domains)
|
|
251
|
+
subject_domains = domains[
|
|
252
|
+
(domains[Column.SUBJECT_ID] == self.metadata.id)
|
|
253
|
+
| (domains[Column.SUBJECT_ID].isna())
|
|
254
|
+
]
|
|
255
|
+
self.df = detect_domains(self.df, subject_domains, overwrite=overwrite)
|
|
256
|
+
|
|
257
|
+
def detect_activity_intensity(
|
|
258
|
+
self,
|
|
259
|
+
cut_points: dict[str, Any],
|
|
260
|
+
*,
|
|
261
|
+
overwrite: bool = False,
|
|
262
|
+
) -> None:
|
|
263
|
+
self.df = detect_activity_intensity(
|
|
264
|
+
self.df,
|
|
265
|
+
cut_points,
|
|
266
|
+
self.metadata.sampling_frequency,
|
|
267
|
+
overwrite=overwrite,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
def detect_wear(
|
|
271
|
+
self,
|
|
272
|
+
min_duration: timedelta,
|
|
273
|
+
interruption_duration: timedelta,
|
|
274
|
+
*,
|
|
275
|
+
overwrite: bool = False,
|
|
276
|
+
) -> None:
|
|
277
|
+
self.df = detect_wear(
|
|
278
|
+
self.df,
|
|
279
|
+
self.metadata.sampling_frequency,
|
|
280
|
+
min_duration,
|
|
281
|
+
interruption_duration=interruption_duration,
|
|
282
|
+
overwrite=overwrite,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
def upsample(
|
|
286
|
+
self,
|
|
287
|
+
sampling_frequency: float,
|
|
288
|
+
*,
|
|
289
|
+
mapper: list[dict[str, Any]] | None = None,
|
|
290
|
+
) -> None:
|
|
291
|
+
self.df = upsample(
|
|
292
|
+
self.df, self.metadata.sampling_frequency, sampling_frequency, mapper
|
|
293
|
+
)
|
|
294
|
+
self.metadata.sampling_frequency = sampling_frequency
|
|
File without changes
|