activitymodel 0.1.0__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.
- activitymodel/__init__.py +45 -0
- activitymodel/calendar.py +286 -0
- activitymodel/clustering.py +220 -0
- activitymodel/diagnostics/__init__.py +14 -0
- activitymodel/diagnostics/association.py +93 -0
- activitymodel/diagnostics/distance.py +33 -0
- activitymodel/diagnostics/profiles.py +59 -0
- activitymodel/exceptions.py +21 -0
- activitymodel/harmonise.py +269 -0
- activitymodel/library.py +325 -0
- activitymodel/matching.py +189 -0
- activitymodel/pipeline.py +239 -0
- activitymodel/survey/__init__.py +18 -0
- activitymodel/survey/diaries.py +285 -0
- activitymodel/survey/io.py +98 -0
- activitymodel/taxonomy.py +397 -0
- activitymodel/vocabulary.py +196 -0
- activitymodel-0.1.0.dist-info/METADATA +153 -0
- activitymodel-0.1.0.dist-info/RECORD +22 -0
- activitymodel-0.1.0.dist-info/WHEEL +5 -0
- activitymodel-0.1.0.dist-info/licenses/LICENCE.md +21 -0
- activitymodel-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""Turn raw diary rows into the coded diary table.
|
|
2
|
+
|
|
3
|
+
A processed diary is:
|
|
4
|
+
|
|
5
|
+
* one metadata row - ``diary_id``, ``person_id``, ``household_id``,
|
|
6
|
+
``day_type``, ``survey_day_kind``, ``weight``, ``quality`` plus the
|
|
7
|
+
harmonised person attributes; and
|
|
8
|
+
* one ``uint8`` vector of 144 detailed activity codes (10-minute slots from
|
|
9
|
+
00:00, survey local time).
|
|
10
|
+
|
|
11
|
+
Two things the prototypes ignored are used here:
|
|
12
|
+
|
|
13
|
+
* ``LIEUDET1-144`` records *where* each slot happened. Presence at home is
|
|
14
|
+
what the building model needs, so activities are reconciled with the
|
|
15
|
+
location (working "at work" while at home becomes ``Home_working``, watching
|
|
16
|
+
television at a friend's becomes ``Other_outside``).
|
|
17
|
+
* ``TYPJ`` records the kind of day. A public holiday falling on a weekday is
|
|
18
|
+
a Sunday for the building, and days of leave are excluded by default
|
|
19
|
+
because building_eload models absences with its own vacation schedule.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
|
|
26
|
+
import numpy as np
|
|
27
|
+
import polars as pl
|
|
28
|
+
|
|
29
|
+
from ..exceptions import VocabularyError
|
|
30
|
+
from ..harmonise import harmonise_survey_individuals
|
|
31
|
+
from ..taxonomy import DETAILED_ACTIVITIES, DETAILED_INDEX, UNKNOWN_CODE, encode_actpr
|
|
32
|
+
from ..vocabulary import DEFAULT_AGE_CLASSES, HARMONISED_COLUMNS, AgeClasses
|
|
33
|
+
|
|
34
|
+
SLOTS_PER_DAY = 144
|
|
35
|
+
SLOT_MINUTES = 10
|
|
36
|
+
|
|
37
|
+
#: Per-slot column families read from ``carnet``.
|
|
38
|
+
SLOT_COLUMN_FAMILIES = ("actpr", "lieudet")
|
|
39
|
+
|
|
40
|
+
#: Scalar diary columns read from ``carnet``.
|
|
41
|
+
DIARY_COLUMNS = ("idcar", "idind", "idmen", "jour", "typj", "pond_carnet", "qualicar", "imput_act")
|
|
42
|
+
|
|
43
|
+
DAY_TYPES = ("weekday", "saturday", "sunday")
|
|
44
|
+
|
|
45
|
+
_JOUR_TO_DAY_TYPE = {
|
|
46
|
+
"lundi": "weekday",
|
|
47
|
+
"mardi": "weekday",
|
|
48
|
+
"mercredi": "weekday",
|
|
49
|
+
"jeudi": "weekday",
|
|
50
|
+
"vendredi": "weekday",
|
|
51
|
+
"samedi": "saturday",
|
|
52
|
+
"dimanche": "sunday",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
#: ``TYPJ`` codebook labels, kept on the diary row as ``survey_day_kind``.
|
|
56
|
+
SURVEY_DAY_KINDS = {
|
|
57
|
+
"1": "working_day",
|
|
58
|
+
"2": "half_working_day",
|
|
59
|
+
"3": "public_holiday_not_worked",
|
|
60
|
+
"4": "public_holiday_worked",
|
|
61
|
+
"5": "sick_leave",
|
|
62
|
+
"6": "leave",
|
|
63
|
+
"7": "not_in_employment",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
#: ``LIEUDET`` code for "at home (or secondary residence)".
|
|
67
|
+
LIEUDET_HOME = "11"
|
|
68
|
+
#: ``LIEUDET`` codes with no usable location.
|
|
69
|
+
LIEUDET_UNKNOWN = ("", "19", "99")
|
|
70
|
+
#: ``LIEUDET`` code for the workplace or place of study.
|
|
71
|
+
LIEUDET_WORK = "12"
|
|
72
|
+
|
|
73
|
+
#: Activity reported while the person is at home although the activity is an
|
|
74
|
+
#: out-of-home one -> what it becomes.
|
|
75
|
+
AT_HOME_RECODE = {
|
|
76
|
+
"At_work": "Home_working",
|
|
77
|
+
"At_school": "Other_at_home",
|
|
78
|
+
"Shopping": "Other_at_home",
|
|
79
|
+
"Other_outside": "Other_at_home",
|
|
80
|
+
"Meal_outside": "Meal_at_home",
|
|
81
|
+
"Work_journey": "Other_at_home",
|
|
82
|
+
"Other_journey": "Other_at_home",
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
#: Activity reported at a known location that is not home although the
|
|
86
|
+
#: activity is a home one -> what it becomes. Sleeping elsewhere is an absence
|
|
87
|
+
#: for the dwelling, so it is recoded too.
|
|
88
|
+
AWAY_RECODE = {
|
|
89
|
+
name: "Other_outside"
|
|
90
|
+
for name in DETAILED_ACTIVITIES
|
|
91
|
+
if name
|
|
92
|
+
not in (
|
|
93
|
+
"Other_outside",
|
|
94
|
+
"Meal_outside",
|
|
95
|
+
"At_work",
|
|
96
|
+
"At_school",
|
|
97
|
+
"Shopping",
|
|
98
|
+
"Work_journey",
|
|
99
|
+
"Other_journey",
|
|
100
|
+
"Meal_at_home",
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
AWAY_RECODE["Meal_at_home"] = "Meal_outside"
|
|
104
|
+
#: ...except home working at the workplace, which is plain work.
|
|
105
|
+
AWAY_RECODE_AT_WORK = {"Home_working": "At_work"}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True)
|
|
109
|
+
class DiaryOptions:
|
|
110
|
+
"""Filters and recodes applied when building the diary table."""
|
|
111
|
+
|
|
112
|
+
#: Drop diaries whose activity description is worse than this ``QUALICAR``
|
|
113
|
+
#: grade (1 very precise ... 4 unusable).
|
|
114
|
+
max_quality: int = 3
|
|
115
|
+
#: Drop diaries where at least ten minutes had to be imputed.
|
|
116
|
+
drop_imputed: bool = False
|
|
117
|
+
#: ``TYPJ`` kinds excluded from the pool. Leave is excluded because the
|
|
118
|
+
#: building model schedules vacations itself.
|
|
119
|
+
excluded_day_kinds: tuple[str, ...] = ("leave",)
|
|
120
|
+
#: Day type given to a public holiday not worked that falls on a weekday
|
|
121
|
+
#: (``None`` keeps the calendar day type).
|
|
122
|
+
holiday_day_type: str | None = "sunday"
|
|
123
|
+
#: Reconcile activities with the recorded location.
|
|
124
|
+
reconcile_location: bool = True
|
|
125
|
+
#: Detailed activity given to slots with an empty or unknown code.
|
|
126
|
+
unknown_activity: str = "Other_at_home"
|
|
127
|
+
age_classes: AgeClasses = field(default=DEFAULT_AGE_CLASSES)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass
|
|
131
|
+
class DiaryTable:
|
|
132
|
+
"""Coded diaries: metadata frame + aligned ``uint8`` code matrix."""
|
|
133
|
+
|
|
134
|
+
frame: pl.DataFrame
|
|
135
|
+
codes: np.ndarray
|
|
136
|
+
|
|
137
|
+
def __post_init__(self):
|
|
138
|
+
if self.codes.shape != (self.frame.height, SLOTS_PER_DAY):
|
|
139
|
+
raise ValueError(
|
|
140
|
+
f"codes shape {self.codes.shape} does not match {self.frame.height} diaries x {SLOTS_PER_DAY}"
|
|
141
|
+
)
|
|
142
|
+
if self.codes.dtype != np.uint8:
|
|
143
|
+
raise ValueError("codes must be uint8")
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def n_diaries(self) -> int:
|
|
147
|
+
return self.frame.height
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _slot_matrix(diaries: pl.DataFrame, family: str, dtype) -> np.ndarray:
|
|
151
|
+
columns = [f"{family}{i}" for i in range(1, SLOTS_PER_DAY + 1)]
|
|
152
|
+
missing = [c for c in columns if c not in diaries.columns]
|
|
153
|
+
if missing:
|
|
154
|
+
raise VocabularyError(f"diary table is missing {len(missing)} {family!r} slot columns")
|
|
155
|
+
return diaries.select(columns).to_numpy().astype(dtype)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def decode_activity_codes(diaries: pl.DataFrame, unknown_activity: str) -> np.ndarray:
|
|
159
|
+
"""``actpr1..144`` (strings or ints) -> detailed ``uint8`` matrix."""
|
|
160
|
+
raw = diaries.select(
|
|
161
|
+
[
|
|
162
|
+
pl.col(f"actpr{i}").cast(pl.String).str.strip_chars().cast(pl.Int64, strict=False)
|
|
163
|
+
for i in range(1, SLOTS_PER_DAY + 1)
|
|
164
|
+
]
|
|
165
|
+
).fill_null(-1)
|
|
166
|
+
return encode_actpr(raw.to_numpy(), unknown=unknown_activity)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def reconcile_location(codes: np.ndarray, location: np.ndarray) -> np.ndarray:
|
|
170
|
+
"""Apply the location recodes; ``location`` is the ``lieudet`` string matrix."""
|
|
171
|
+
out = codes.copy()
|
|
172
|
+
at_home = location == LIEUDET_HOME
|
|
173
|
+
at_work = location == LIEUDET_WORK
|
|
174
|
+
known_away = ~at_home & ~np.isin(location, LIEUDET_UNKNOWN)
|
|
175
|
+
for source, target in AT_HOME_RECODE.items():
|
|
176
|
+
out[at_home & (codes == DETAILED_INDEX[source])] = DETAILED_INDEX[target]
|
|
177
|
+
for source, target in AWAY_RECODE_AT_WORK.items():
|
|
178
|
+
out[at_work & (codes == DETAILED_INDEX[source])] = DETAILED_INDEX[target]
|
|
179
|
+
for source, target in AWAY_RECODE.items():
|
|
180
|
+
if source in AWAY_RECODE_AT_WORK:
|
|
181
|
+
mask = known_away & ~at_work
|
|
182
|
+
else:
|
|
183
|
+
mask = known_away
|
|
184
|
+
out[mask & (codes == DETAILED_INDEX[source])] = DETAILED_INDEX[target]
|
|
185
|
+
return out
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def build_diaries(
|
|
189
|
+
diaries: pl.DataFrame,
|
|
190
|
+
individuals: pl.DataFrame,
|
|
191
|
+
households: pl.DataFrame,
|
|
192
|
+
options: DiaryOptions | None = None,
|
|
193
|
+
) -> DiaryTable:
|
|
194
|
+
"""Build the coded diary table from the three raw survey tables.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
diaries: ``carnet`` rows with :data:`DIARY_COLUMNS` and the
|
|
198
|
+
``actpr``/``lieudet`` slot columns.
|
|
199
|
+
individuals: ``individu`` rows (see
|
|
200
|
+
:func:`~activitymodel.harmonise.harmonise_survey_individuals`).
|
|
201
|
+
households: ``menage`` rows.
|
|
202
|
+
options: filters and recodes (defaults to :class:`DiaryOptions`).
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
A :class:`DiaryTable`. Rows are sorted by ``diary_id`` so the build is
|
|
206
|
+
reproducible whatever the input order.
|
|
207
|
+
"""
|
|
208
|
+
options = DiaryOptions() if options is None else options
|
|
209
|
+
people = harmonise_survey_individuals(individuals, households, options.age_classes)
|
|
210
|
+
person_columns = ["person_id", "household_id", "weight"] + list(HARMONISED_COLUMNS)
|
|
211
|
+
people = people.select(person_columns).rename({"weight": "person_weight"}).unique("person_id")
|
|
212
|
+
|
|
213
|
+
meta = (
|
|
214
|
+
diaries.select(list(DIARY_COLUMNS))
|
|
215
|
+
.with_columns(
|
|
216
|
+
diary_id=pl.col("idcar").cast(pl.String),
|
|
217
|
+
person_id=pl.col("idind").cast(pl.String),
|
|
218
|
+
household_id=pl.col("idmen").cast(pl.String),
|
|
219
|
+
weight=pl.col("pond_carnet").cast(pl.Float64),
|
|
220
|
+
quality=pl.col("qualicar")
|
|
221
|
+
.cast(pl.String)
|
|
222
|
+
.str.strip_chars()
|
|
223
|
+
.cast(pl.Int64, strict=False),
|
|
224
|
+
imputed=pl.col("imput_act").cast(pl.Float64).fill_null(0) > 0,
|
|
225
|
+
survey_day_kind=pl.col("typj")
|
|
226
|
+
.cast(pl.String)
|
|
227
|
+
.str.strip_chars()
|
|
228
|
+
.replace_strict(SURVEY_DAY_KINDS, default="unknown"),
|
|
229
|
+
calendar_day_type=pl.col("jour")
|
|
230
|
+
.cast(pl.String)
|
|
231
|
+
.str.strip_chars()
|
|
232
|
+
.str.to_lowercase()
|
|
233
|
+
.replace_strict(_JOUR_TO_DAY_TYPE, default=None),
|
|
234
|
+
)
|
|
235
|
+
.with_row_index("_row")
|
|
236
|
+
)
|
|
237
|
+
if meta.get_column("calendar_day_type").null_count():
|
|
238
|
+
raise VocabularyError("diary column 'jour' holds values other than the French day names")
|
|
239
|
+
|
|
240
|
+
day_type = pl.col("calendar_day_type")
|
|
241
|
+
if options.holiday_day_type is not None:
|
|
242
|
+
day_type = (
|
|
243
|
+
pl.when(
|
|
244
|
+
(pl.col("survey_day_kind") == "public_holiday_not_worked")
|
|
245
|
+
& (pl.col("calendar_day_type") == "weekday")
|
|
246
|
+
)
|
|
247
|
+
.then(pl.lit(options.holiday_day_type))
|
|
248
|
+
.otherwise(day_type)
|
|
249
|
+
)
|
|
250
|
+
meta = meta.with_columns(day_type=day_type)
|
|
251
|
+
|
|
252
|
+
keep = (
|
|
253
|
+
(pl.col("quality").fill_null(options.max_quality) <= options.max_quality)
|
|
254
|
+
& ~pl.col("survey_day_kind").is_in(list(options.excluded_day_kinds))
|
|
255
|
+
& pl.col("weight").is_not_null()
|
|
256
|
+
)
|
|
257
|
+
if options.drop_imputed:
|
|
258
|
+
keep = keep & ~pl.col("imputed")
|
|
259
|
+
meta = meta.filter(keep)
|
|
260
|
+
|
|
261
|
+
meta = meta.join(people, on="person_id", how="inner")
|
|
262
|
+
meta = meta.sort("diary_id")
|
|
263
|
+
rows = meta.get_column("_row").to_numpy()
|
|
264
|
+
|
|
265
|
+
codes = decode_activity_codes(diaries, options.unknown_activity)
|
|
266
|
+
if options.reconcile_location:
|
|
267
|
+
location = _slot_matrix(
|
|
268
|
+
diaries.select(
|
|
269
|
+
[
|
|
270
|
+
pl.col(f"lieudet{i}").cast(pl.String).str.strip_chars()
|
|
271
|
+
for i in range(1, SLOTS_PER_DAY + 1)
|
|
272
|
+
]
|
|
273
|
+
),
|
|
274
|
+
"lieudet",
|
|
275
|
+
object,
|
|
276
|
+
)
|
|
277
|
+
codes = reconcile_location(codes, location)
|
|
278
|
+
codes = np.ascontiguousarray(codes[rows])
|
|
279
|
+
if (codes == UNKNOWN_CODE).any():
|
|
280
|
+
raise VocabularyError("unknown activity codes survived decoding")
|
|
281
|
+
|
|
282
|
+
frame = meta.drop(
|
|
283
|
+
["_row", "idcar", "idind", "idmen", "jour", "typj", "pond_carnet", "qualicar", "imput_act"]
|
|
284
|
+
)
|
|
285
|
+
return DiaryTable(frame=frame, codes=codes)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Locate and read the ADISP delivery of the survey.
|
|
2
|
+
|
|
3
|
+
The delivery (doi:10.13144/lil-0695, access on request, not redistributable)
|
|
4
|
+
ships Stata files: ``carnet.dta`` (one row per diary day), ``individu.dta``
|
|
5
|
+
(one row per respondent) and ``menage.dta`` (one row per household). Parquet
|
|
6
|
+
conversions with the same stem are accepted so a converted copy can be reused.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import polars as pl
|
|
15
|
+
|
|
16
|
+
_STEMS = {"diaries": "carnet", "individuals": "individu", "households": "menage"}
|
|
17
|
+
_SUFFIXES = (".parquet", ".dta")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class SurveyFiles:
|
|
22
|
+
"""Paths of the three survey tables."""
|
|
23
|
+
|
|
24
|
+
diaries: Path
|
|
25
|
+
individuals: Path
|
|
26
|
+
households: Path
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def from_directory(cls, directory: str | Path) -> SurveyFiles:
|
|
30
|
+
"""Find ``carnet``, ``individu`` and ``menage`` (parquet preferred, else dta).
|
|
31
|
+
|
|
32
|
+
Raises:
|
|
33
|
+
FileNotFoundError: a table is missing; the message names the directory
|
|
34
|
+
and the file stems expected, which is the operator's cue to copy
|
|
35
|
+
the ADISP delivery there.
|
|
36
|
+
"""
|
|
37
|
+
directory = Path(directory)
|
|
38
|
+
found = {}
|
|
39
|
+
for key, stem in _STEMS.items():
|
|
40
|
+
for suffix in _SUFFIXES:
|
|
41
|
+
candidate = directory / f"{stem}{suffix}"
|
|
42
|
+
if candidate.is_file():
|
|
43
|
+
found[key] = candidate
|
|
44
|
+
break
|
|
45
|
+
else:
|
|
46
|
+
raise FileNotFoundError(
|
|
47
|
+
f"survey table {stem!r} not found in {directory} "
|
|
48
|
+
f"(expected {stem}.parquet or {stem}.dta from the ADISP delivery)"
|
|
49
|
+
)
|
|
50
|
+
return cls(**found)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def read_table(path: str | Path, columns: list[str] | None = None) -> pl.DataFrame:
|
|
54
|
+
"""Read a survey table as a polars frame.
|
|
55
|
+
|
|
56
|
+
Stata files are read through pandas (``survey`` extra) with value labels
|
|
57
|
+
left as codes, because the codes are what the harmonisation maps.
|
|
58
|
+
"""
|
|
59
|
+
path = Path(path)
|
|
60
|
+
if path.suffix == ".parquet":
|
|
61
|
+
return pl.read_parquet(path, columns=columns)
|
|
62
|
+
if path.suffix == ".dta":
|
|
63
|
+
import pandas as pd # optional dependency, pipeline only
|
|
64
|
+
|
|
65
|
+
frame = pd.read_stata(path, columns=columns, convert_categoricals=False)
|
|
66
|
+
return pl.from_pandas(frame)
|
|
67
|
+
raise ValueError(f"unsupported survey table format {path.suffix!r} for {path}")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def load_survey_tables(files: SurveyFiles):
|
|
71
|
+
"""Read the three tables, restricted to the columns the pipeline uses.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
``(diaries, individuals, households)`` polars frames.
|
|
75
|
+
"""
|
|
76
|
+
from .diaries import DIARY_COLUMNS, SLOT_COLUMN_FAMILIES
|
|
77
|
+
|
|
78
|
+
diary_columns = list(DIARY_COLUMNS) + [
|
|
79
|
+
f"{family}{i}" for family in SLOT_COLUMN_FAMILIES for i in range(1, 145)
|
|
80
|
+
]
|
|
81
|
+
diaries = read_table(files.diaries, columns=diary_columns)
|
|
82
|
+
individuals = read_table(
|
|
83
|
+
files.individuals,
|
|
84
|
+
columns=[
|
|
85
|
+
"idind",
|
|
86
|
+
"idmen",
|
|
87
|
+
"sexe",
|
|
88
|
+
"age",
|
|
89
|
+
"situa",
|
|
90
|
+
"cs24",
|
|
91
|
+
"couple",
|
|
92
|
+
"lienpref",
|
|
93
|
+
"pond_qi",
|
|
94
|
+
"nb_carnet_rempli",
|
|
95
|
+
],
|
|
96
|
+
)
|
|
97
|
+
households = read_table(files.households, columns=["idmen", "npers", "typmen5", "tuu", "zau"])
|
|
98
|
+
return diaries, individuals, households
|