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.
@@ -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