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,59 @@
|
|
|
1
|
+
"""Per-slot activity probability matrices (144 x activities)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import polars as pl
|
|
7
|
+
|
|
8
|
+
from ..taxonomy import get_level
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def activity_probability_matrix(
|
|
12
|
+
codes: np.ndarray, level: str = "detailed", weights: np.ndarray | None = None
|
|
13
|
+
) -> np.ndarray:
|
|
14
|
+
"""Share of (weighted) days in each activity at each slot; rows sum to 1.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
codes: ``uint8`` matrix ``(n_days, n_slots)`` of detailed codes.
|
|
18
|
+
level: taxonomy level of the output columns.
|
|
19
|
+
weights: optional per-day weights.
|
|
20
|
+
"""
|
|
21
|
+
lvl = get_level(level)
|
|
22
|
+
projected = lvl.project(np.asarray(codes, dtype=np.uint8))
|
|
23
|
+
n_days, n_slots = projected.shape
|
|
24
|
+
w = np.ones(n_days) if weights is None else np.asarray(weights, dtype=float)
|
|
25
|
+
matrix = np.zeros((n_slots, lvl.n_activities))
|
|
26
|
+
for a in range(lvl.n_activities):
|
|
27
|
+
matrix[:, a] = ((projected == a) * w[:, None]).sum(axis=0)
|
|
28
|
+
totals = matrix.sum(axis=1, keepdims=True)
|
|
29
|
+
totals[totals == 0] = 1.0
|
|
30
|
+
return matrix / totals
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def profile_frame(
|
|
34
|
+
matrix: np.ndarray, level: str = "detailed", slot_minutes: int = 10
|
|
35
|
+
) -> pl.DataFrame:
|
|
36
|
+
"""Long frame ``slot``, ``hour``, ``activity``, ``share`` from a probability matrix."""
|
|
37
|
+
lvl = get_level(level)
|
|
38
|
+
n_slots = matrix.shape[0]
|
|
39
|
+
return pl.DataFrame(
|
|
40
|
+
{
|
|
41
|
+
"slot": np.repeat(np.arange(n_slots), lvl.n_activities),
|
|
42
|
+
"hour": np.repeat(np.arange(n_slots) * slot_minutes / 60, lvl.n_activities),
|
|
43
|
+
"activity": np.tile(np.array(lvl.activities, dtype=object), n_slots),
|
|
44
|
+
"share": matrix.ravel(),
|
|
45
|
+
}
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def calendar_profile(calendar, day_type: str | None = None, level: str = "detailed") -> np.ndarray:
|
|
50
|
+
"""Mean daily profile of an :class:`~activitymodel.calendar.AnnualCalendar`.
|
|
51
|
+
|
|
52
|
+
Every (occupant, date) pair counts once; ``day_type`` restricts the dates.
|
|
53
|
+
"""
|
|
54
|
+
from ..calendar import day_types_for_dates
|
|
55
|
+
|
|
56
|
+
idx = calendar.day_index
|
|
57
|
+
if day_type is not None:
|
|
58
|
+
idx = idx[:, day_types_for_dates(calendar.dates) == day_type]
|
|
59
|
+
return activity_probability_matrix(calendar.library.codes[idx.ravel()], level=level)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Typed exceptions so callers can tell a bad input from a broken contract."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ActivityModelError(Exception):
|
|
5
|
+
"""Base class for every error raised by activitymodel."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class VocabularyError(ActivityModelError):
|
|
9
|
+
"""A frame carries a value outside the shared socio-demographic vocabulary."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TaxonomyError(ActivityModelError):
|
|
13
|
+
"""An activity code or activity name is unknown to the taxonomy."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LibraryValidationError(ActivityModelError):
|
|
17
|
+
"""A diary library breaks the published dataset contract."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class MatchingError(ActivityModelError):
|
|
21
|
+
"""Occupants could not be assigned a cluster even at the coarsest ladder level."""
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""Map the survey and the census onto the shared vocabulary.
|
|
2
|
+
|
|
3
|
+
Two public functions, one per source, both *adding* the harmonised columns of
|
|
4
|
+
:data:`activitymodel.vocabulary.HARMONISED_COLUMNS` to the frame they receive so
|
|
5
|
+
that identifiers and passthrough columns survive:
|
|
6
|
+
|
|
7
|
+
* :func:`harmonise_survey_individuals` - Enquete Emploi du temps ``individu``
|
|
8
|
+
(+ ``menage`` for the household size).
|
|
9
|
+
* :func:`harmonise_census_individuals` - INSEE RP ``indcvi`` individual file
|
|
10
|
+
(the "Fichier detail individus localises au canton-ou-ville").
|
|
11
|
+
|
|
12
|
+
Both are total: an input code outside the documented code list is an error,
|
|
13
|
+
not a null, because a null on either side silently removes the person from
|
|
14
|
+
every matching level but the last.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
|
|
21
|
+
import polars as pl
|
|
22
|
+
|
|
23
|
+
from .exceptions import VocabularyError
|
|
24
|
+
from .vocabulary import (
|
|
25
|
+
CSP_GROUP,
|
|
26
|
+
DEFAULT_AGE_CLASSES,
|
|
27
|
+
AgeClasses,
|
|
28
|
+
check_vocabulary,
|
|
29
|
+
employment_status_expr,
|
|
30
|
+
household_size_class_expr,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# --------------------------------------------------------------------------- #
|
|
34
|
+
# Survey side (EDT 2009-2010)
|
|
35
|
+
# --------------------------------------------------------------------------- #
|
|
36
|
+
|
|
37
|
+
#: ``situa`` -> activity status (codebook: 1 employed, 2 apprentice, 3 student
|
|
38
|
+
#: or unpaid intern, 4 unemployed, 5 retired, 6 homemaker, 7 other).
|
|
39
|
+
_SITUA_TO_STATUS = {
|
|
40
|
+
"1": "employed",
|
|
41
|
+
"2": "employed",
|
|
42
|
+
"3": "student",
|
|
43
|
+
"4": "unemployed",
|
|
44
|
+
"5": "retired",
|
|
45
|
+
"6": "homemaker",
|
|
46
|
+
"7": "other_inactive",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
#: First digit of ``cs24`` -> socio-professional group of the employed.
|
|
50
|
+
_CS_DIGIT_TO_GROUP = {str(i + 1): g for i, g in enumerate(CSP_GROUP)}
|
|
51
|
+
|
|
52
|
+
#: ``lienpref`` -> household position (00 reference person, 01 partner,
|
|
53
|
+
#: 02 child; every other code is another relative or unrelated member).
|
|
54
|
+
_LIENPREF_TO_POSITION = {"00": "reference_person", "01": "partner", "02": "child"}
|
|
55
|
+
|
|
56
|
+
#: Census age below which a person is "under 14" (TACT 23). Applied to the
|
|
57
|
+
#: survey so the two ``child`` classes mean the same thing.
|
|
58
|
+
CHILD_AGE_LIMIT = 14
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _strip(column: str) -> pl.Expr:
|
|
62
|
+
return pl.col(column).cast(pl.String).str.strip_chars()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def harmonise_survey_individuals(
|
|
66
|
+
individuals: pl.DataFrame,
|
|
67
|
+
households: pl.DataFrame,
|
|
68
|
+
age_classes: AgeClasses = DEFAULT_AGE_CLASSES,
|
|
69
|
+
) -> pl.DataFrame:
|
|
70
|
+
"""Add the shared vocabulary columns to the survey ``individu`` table.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
individuals: the ``individu`` table with at least ``idind``, ``idmen``,
|
|
74
|
+
``sexe``, ``age``, ``situa``, ``cs24``, ``couple``, ``lienpref``
|
|
75
|
+
and ``pond_qi``.
|
|
76
|
+
households: the ``menage`` table with ``idmen`` and ``npers``.
|
|
77
|
+
age_classes: age bands to use.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
``individuals`` plus ``person_id``, ``household_id``, ``weight`` and
|
|
81
|
+
the harmonised columns.
|
|
82
|
+
|
|
83
|
+
Raises:
|
|
84
|
+
VocabularyError: a required column is missing or a code is unknown.
|
|
85
|
+
"""
|
|
86
|
+
required = {"idind", "idmen", "sexe", "age", "situa", "cs24", "couple", "lienpref"}
|
|
87
|
+
missing = required - set(individuals.columns)
|
|
88
|
+
if missing:
|
|
89
|
+
raise VocabularyError(f"survey individuals are missing columns {sorted(missing)}")
|
|
90
|
+
if not {"idmen", "npers"} <= set(households.columns):
|
|
91
|
+
raise VocabularyError("survey households need columns 'idmen' and 'npers'")
|
|
92
|
+
|
|
93
|
+
sizes = households.select(
|
|
94
|
+
pl.col("idmen").cast(pl.String), pl.col("npers").cast(pl.Int64).alias("household_size")
|
|
95
|
+
).unique("idmen")
|
|
96
|
+
weight = pl.col("pond_qi").cast(pl.Float64) if "pond_qi" in individuals.columns else pl.lit(1.0)
|
|
97
|
+
|
|
98
|
+
out = (
|
|
99
|
+
individuals.with_columns(pl.col("idmen").cast(pl.String))
|
|
100
|
+
.join(sizes, on="idmen", how="left")
|
|
101
|
+
.with_columns(
|
|
102
|
+
person_id=pl.col("idind").cast(pl.String),
|
|
103
|
+
household_id=pl.col("idmen"),
|
|
104
|
+
weight=weight,
|
|
105
|
+
sex=_strip("sexe").replace_strict({"1": "male", "2": "female"}, default=None),
|
|
106
|
+
age=pl.col("age").cast(pl.Float64).round(0).cast(pl.Int64),
|
|
107
|
+
activity_status=_strip("situa").replace_strict(_SITUA_TO_STATUS, default=None),
|
|
108
|
+
csp_group=_strip("cs24")
|
|
109
|
+
.str.slice(0, 1)
|
|
110
|
+
.replace_strict(_CS_DIGIT_TO_GROUP, default=None),
|
|
111
|
+
couple_status=pl.when(_strip("couple") == "1")
|
|
112
|
+
.then(pl.lit("in_couple"))
|
|
113
|
+
.otherwise(pl.lit("not_in_couple")),
|
|
114
|
+
household_position=_strip("lienpref").replace_strict(
|
|
115
|
+
_LIENPREF_TO_POSITION, default="other"
|
|
116
|
+
),
|
|
117
|
+
)
|
|
118
|
+
.with_columns(
|
|
119
|
+
activity_status=pl.when(pl.col("age") < CHILD_AGE_LIMIT)
|
|
120
|
+
.then(pl.lit("child"))
|
|
121
|
+
.otherwise(pl.col("activity_status")),
|
|
122
|
+
age_class=age_classes.expr("age"),
|
|
123
|
+
household_size_class=household_size_class_expr("household_size"),
|
|
124
|
+
)
|
|
125
|
+
.with_columns(employment_status=employment_status_expr())
|
|
126
|
+
)
|
|
127
|
+
check_vocabulary(
|
|
128
|
+
out,
|
|
129
|
+
columns=(
|
|
130
|
+
"sex",
|
|
131
|
+
"age_class",
|
|
132
|
+
"activity_status",
|
|
133
|
+
"csp_group",
|
|
134
|
+
"employment_status",
|
|
135
|
+
"couple_status",
|
|
136
|
+
"household_position",
|
|
137
|
+
"household_size_class",
|
|
138
|
+
),
|
|
139
|
+
age_classes=age_classes,
|
|
140
|
+
allow_null=("csp_group",),
|
|
141
|
+
name="survey individuals",
|
|
142
|
+
)
|
|
143
|
+
return out
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# --------------------------------------------------------------------------- #
|
|
147
|
+
# Census side (INSEE RP indcvi)
|
|
148
|
+
# --------------------------------------------------------------------------- #
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@dataclass(frozen=True)
|
|
152
|
+
class CensusColumns:
|
|
153
|
+
"""Column names of the individual census file (INSEE vintage-dependent)."""
|
|
154
|
+
|
|
155
|
+
sex: str = "SEXE"
|
|
156
|
+
age: str = "AGED"
|
|
157
|
+
activity: str = "TACT"
|
|
158
|
+
csp: str = "CS1"
|
|
159
|
+
couple: str = "COUPLE"
|
|
160
|
+
position: str = "LPRM"
|
|
161
|
+
household_size: str = "INPER"
|
|
162
|
+
weight: str = "IPONDI"
|
|
163
|
+
|
|
164
|
+
def required(self) -> tuple[str, ...]:
|
|
165
|
+
return (
|
|
166
|
+
self.sex,
|
|
167
|
+
self.age,
|
|
168
|
+
self.activity,
|
|
169
|
+
self.csp,
|
|
170
|
+
self.couple,
|
|
171
|
+
self.position,
|
|
172
|
+
self.household_size,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
DEFAULT_CENSUS_COLUMNS = CensusColumns()
|
|
177
|
+
|
|
178
|
+
#: ``TACT`` -> activity status (11 employed, 12 unemployed, 21 retired,
|
|
179
|
+
#: 22 pupils/students 14+, 23 under 14, 24 homemakers, 25 other inactive).
|
|
180
|
+
_TACT_TO_STATUS = {
|
|
181
|
+
"11": "employed",
|
|
182
|
+
"12": "unemployed",
|
|
183
|
+
"21": "retired",
|
|
184
|
+
"22": "student",
|
|
185
|
+
"23": "child",
|
|
186
|
+
"24": "homemaker",
|
|
187
|
+
"25": "other_inactive",
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
#: ``LPRM`` -> household position (1 reference person, 2 partner, 3 child,
|
|
191
|
+
#: 4-8 other relatives and non relatives). ``Z`` = not in an ordinary
|
|
192
|
+
#: household (communities), dropped by the caller or here.
|
|
193
|
+
_LPRM_TO_POSITION = {"1": "reference_person", "2": "partner", "3": "child"}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def harmonise_census_individuals(
|
|
197
|
+
census: pl.DataFrame,
|
|
198
|
+
age_classes: AgeClasses = DEFAULT_AGE_CLASSES,
|
|
199
|
+
columns: CensusColumns = DEFAULT_CENSUS_COLUMNS,
|
|
200
|
+
drop_out_of_household: bool = True,
|
|
201
|
+
) -> pl.DataFrame:
|
|
202
|
+
"""Add the shared vocabulary columns to an INSEE ``indcvi`` frame.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
census: individual census rows (any subset of columns is kept).
|
|
206
|
+
age_classes: age bands to use.
|
|
207
|
+
columns: source column names, see :class:`CensusColumns`.
|
|
208
|
+
drop_out_of_household: drop people living in communities (``LPRM == Z``),
|
|
209
|
+
who have no dwelling to be matched to.
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
``census`` plus ``weight`` and the harmonised columns.
|
|
213
|
+
|
|
214
|
+
Raises:
|
|
215
|
+
VocabularyError: a required column is missing or a code is unknown.
|
|
216
|
+
"""
|
|
217
|
+
missing = set(columns.required()) - set(census.columns)
|
|
218
|
+
if missing:
|
|
219
|
+
raise VocabularyError(f"census individuals are missing columns {sorted(missing)}")
|
|
220
|
+
|
|
221
|
+
out = census
|
|
222
|
+
if drop_out_of_household:
|
|
223
|
+
out = out.filter(_strip(columns.position) != "Z")
|
|
224
|
+
weight = (
|
|
225
|
+
pl.col(columns.weight).cast(pl.Float64) if columns.weight in out.columns else pl.lit(1.0)
|
|
226
|
+
)
|
|
227
|
+
out = (
|
|
228
|
+
out.with_columns(
|
|
229
|
+
weight=weight,
|
|
230
|
+
sex=_strip(columns.sex).replace_strict({"1": "male", "2": "female"}, default=None),
|
|
231
|
+
age=_strip(columns.age).cast(pl.Int64, strict=False),
|
|
232
|
+
activity_status=_strip(columns.activity).replace_strict(_TACT_TO_STATUS, default=None),
|
|
233
|
+
csp_group=_strip(columns.csp).replace_strict(_CS_DIGIT_TO_GROUP, default=None),
|
|
234
|
+
couple_status=pl.when(_strip(columns.couple) == "1")
|
|
235
|
+
.then(pl.lit("in_couple"))
|
|
236
|
+
.otherwise(pl.lit("not_in_couple")),
|
|
237
|
+
household_position=_strip(columns.position).replace_strict(
|
|
238
|
+
_LPRM_TO_POSITION, default="other"
|
|
239
|
+
),
|
|
240
|
+
household_size=_strip(columns.household_size).cast(pl.Int64, strict=False),
|
|
241
|
+
)
|
|
242
|
+
.with_columns(
|
|
243
|
+
age_class=age_classes.expr("age"),
|
|
244
|
+
household_size_class=household_size_class_expr("household_size"),
|
|
245
|
+
)
|
|
246
|
+
.with_columns(employment_status=employment_status_expr())
|
|
247
|
+
)
|
|
248
|
+
if out.get_column("age").null_count():
|
|
249
|
+
raise VocabularyError(f"census column {columns.age!r} holds non-numeric ages")
|
|
250
|
+
check_vocabulary(
|
|
251
|
+
out,
|
|
252
|
+
columns=(
|
|
253
|
+
"sex",
|
|
254
|
+
"age_class",
|
|
255
|
+
"activity_status",
|
|
256
|
+
"csp_group",
|
|
257
|
+
"employment_status",
|
|
258
|
+
"couple_status",
|
|
259
|
+
"household_position",
|
|
260
|
+
"household_size_class",
|
|
261
|
+
),
|
|
262
|
+
age_classes=age_classes,
|
|
263
|
+
# Household size is unknown for people outside ordinary households
|
|
264
|
+
# (kept only on request); a null key falls through to the coarser
|
|
265
|
+
# ladder levels instead of failing the harmonisation.
|
|
266
|
+
allow_null=("csp_group", "household_size_class"),
|
|
267
|
+
name="census individuals",
|
|
268
|
+
)
|
|
269
|
+
return out
|
activitymodel/library.py
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""The diary library: the dataset activitymodel publishes and consumes.
|
|
2
|
+
|
|
3
|
+
A :class:`DiaryLibrary` is what the buildingdata pipeline builds from the
|
|
4
|
+
survey and what building_eload downloads. It is made of three tables and a
|
|
5
|
+
manifest, each stored as its own parquet blob (buildingdata's one-blob-per-table
|
|
6
|
+
convention):
|
|
7
|
+
|
|
8
|
+
``days``
|
|
9
|
+
One row per survey day kept in the pool: ``day_id``, ``day_type``,
|
|
10
|
+
``cluster_id``, ``weight`` and the 144 ``slot_000..slot_143`` ``UInt8``
|
|
11
|
+
detailed activity codes. No survey identifier and no socio-demographic
|
|
12
|
+
attribute is attached to a day.
|
|
13
|
+
``clusters``
|
|
14
|
+
One row per cluster: ``cluster_id``, ``day_type``, ``cluster_index``,
|
|
15
|
+
``n_days``, ``weight_sum``.
|
|
16
|
+
``patterns``
|
|
17
|
+
The aggregate link between people and clusters: one row per combination of
|
|
18
|
+
matching-key values and per-day-type cluster triple, with the summed person
|
|
19
|
+
weight and person count. Coarser matching levels are obtained by summing
|
|
20
|
+
rows, so only the finest level is stored.
|
|
21
|
+
``manifest``
|
|
22
|
+
Key/value table (JSON-encoded values) recording the taxonomy, the day
|
|
23
|
+
types, the matching keys, the clustering parameters and the provenance.
|
|
24
|
+
|
|
25
|
+
The three tables together contain no row of survey microdata: the days are
|
|
26
|
+
detached from the people, the people are aggregated into cells.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
import numpy as np
|
|
36
|
+
import polars as pl
|
|
37
|
+
|
|
38
|
+
from .exceptions import LibraryValidationError
|
|
39
|
+
from .taxonomy import DETAILED_ACTIVITIES, N_DETAILED, get_level
|
|
40
|
+
from .vocabulary import AgeClasses
|
|
41
|
+
|
|
42
|
+
SLOTS_PER_DAY = 144
|
|
43
|
+
SLOT_MINUTES = 10
|
|
44
|
+
#: Clock the slot columns are expressed in: the survey records local time.
|
|
45
|
+
CLOCK = "Europe/Paris"
|
|
46
|
+
|
|
47
|
+
SLOT_COLUMNS: tuple[str, ...] = tuple(f"slot_{i:03d}" for i in range(SLOTS_PER_DAY))
|
|
48
|
+
|
|
49
|
+
DAY_META_SCHEMA = {
|
|
50
|
+
"day_id": pl.UInt32,
|
|
51
|
+
"day_type": pl.String,
|
|
52
|
+
"cluster_id": pl.Int16,
|
|
53
|
+
"weight": pl.Float64,
|
|
54
|
+
}
|
|
55
|
+
CLUSTER_SCHEMA = {
|
|
56
|
+
"cluster_id": pl.Int16,
|
|
57
|
+
"day_type": pl.String,
|
|
58
|
+
"cluster_index": pl.Int16,
|
|
59
|
+
"n_days": pl.UInt32,
|
|
60
|
+
"weight_sum": pl.Float64,
|
|
61
|
+
}
|
|
62
|
+
PATTERN_VALUE_SCHEMA = {"weight": pl.Float64, "n_persons": pl.UInt32}
|
|
63
|
+
|
|
64
|
+
TABLE_NAMES = ("days", "clusters", "patterns", "manifest")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def cluster_column(day_type: str) -> str:
|
|
68
|
+
return f"cluster_{day_type}"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class DiaryLibrary:
|
|
73
|
+
"""Clustered diary pool plus the aggregate socio-demographic link to it."""
|
|
74
|
+
|
|
75
|
+
days: pl.DataFrame
|
|
76
|
+
clusters: pl.DataFrame
|
|
77
|
+
patterns: pl.DataFrame
|
|
78
|
+
manifest: dict
|
|
79
|
+
_codes: np.ndarray | None = field(default=None, repr=False, compare=False)
|
|
80
|
+
|
|
81
|
+
# ------------------------------------------------------------------ #
|
|
82
|
+
# Manifest-backed properties
|
|
83
|
+
# ------------------------------------------------------------------ #
|
|
84
|
+
@property
|
|
85
|
+
def day_types(self) -> tuple[str, ...]:
|
|
86
|
+
return tuple(self.manifest["day_types"])
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def matching_keys(self) -> tuple[str, ...]:
|
|
90
|
+
return tuple(self.manifest["matching_keys"])
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def activities(self) -> tuple[str, ...]:
|
|
94
|
+
return tuple(self.manifest["activities"])
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def age_classes(self) -> AgeClasses:
|
|
98
|
+
return AgeClasses(bounds=tuple(self.manifest["age_class_bounds"]))
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def cluster_columns(self) -> list[str]:
|
|
102
|
+
return [cluster_column(d) for d in self.day_types]
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def codes(self) -> np.ndarray:
|
|
106
|
+
"""``uint8`` matrix ``(n_days, 144)`` of detailed activity codes (cached)."""
|
|
107
|
+
if self._codes is None:
|
|
108
|
+
self._codes = np.ascontiguousarray(
|
|
109
|
+
self.days.select(list(SLOT_COLUMNS)).to_numpy().astype(np.uint8)
|
|
110
|
+
)
|
|
111
|
+
return self._codes
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def n_days(self) -> int:
|
|
115
|
+
return self.days.height
|
|
116
|
+
|
|
117
|
+
# ------------------------------------------------------------------ #
|
|
118
|
+
# Construction and IO
|
|
119
|
+
# ------------------------------------------------------------------ #
|
|
120
|
+
@classmethod
|
|
121
|
+
def from_frames(
|
|
122
|
+
cls,
|
|
123
|
+
days: pl.DataFrame,
|
|
124
|
+
clusters: pl.DataFrame,
|
|
125
|
+
patterns: pl.DataFrame,
|
|
126
|
+
manifest: pl.DataFrame | dict,
|
|
127
|
+
validate: bool = True,
|
|
128
|
+
) -> DiaryLibrary:
|
|
129
|
+
"""Assemble a library from its tables (as read from buildingdata)."""
|
|
130
|
+
if isinstance(manifest, pl.DataFrame):
|
|
131
|
+
manifest = manifest_from_frame(manifest)
|
|
132
|
+
library = cls(days=days, clusters=clusters, patterns=patterns, manifest=dict(manifest))
|
|
133
|
+
if validate:
|
|
134
|
+
library.validate()
|
|
135
|
+
return library
|
|
136
|
+
|
|
137
|
+
def to_frames(self) -> dict[str, pl.DataFrame]:
|
|
138
|
+
"""The four tables keyed by :data:`TABLE_NAMES`, manifest as a key/value frame."""
|
|
139
|
+
return {
|
|
140
|
+
"days": self.days,
|
|
141
|
+
"clusters": self.clusters,
|
|
142
|
+
"patterns": self.patterns,
|
|
143
|
+
"manifest": manifest_to_frame(self.manifest),
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
def write(self, directory: str | Path) -> dict[str, Path]:
|
|
147
|
+
"""Write ``<table>.parquet`` for each table plus a readable ``manifest.json``."""
|
|
148
|
+
directory = Path(directory)
|
|
149
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
150
|
+
paths = {}
|
|
151
|
+
for name, frame in self.to_frames().items():
|
|
152
|
+
paths[name] = directory / f"{name}.parquet"
|
|
153
|
+
frame.write_parquet(paths[name])
|
|
154
|
+
(directory / "manifest.json").write_text(
|
|
155
|
+
json.dumps(self.manifest, indent=2, sort_keys=True)
|
|
156
|
+
)
|
|
157
|
+
return paths
|
|
158
|
+
|
|
159
|
+
@classmethod
|
|
160
|
+
def read(cls, directory: str | Path, validate: bool = True) -> DiaryLibrary:
|
|
161
|
+
"""Read a library written by :meth:`write`."""
|
|
162
|
+
directory = Path(directory)
|
|
163
|
+
frames = {name: pl.read_parquet(directory / f"{name}.parquet") for name in TABLE_NAMES}
|
|
164
|
+
return cls.from_frames(validate=validate, **frames)
|
|
165
|
+
|
|
166
|
+
# ------------------------------------------------------------------ #
|
|
167
|
+
# Contract
|
|
168
|
+
# ------------------------------------------------------------------ #
|
|
169
|
+
def validate(self) -> None:
|
|
170
|
+
"""Check the published contract; raise :class:`LibraryValidationError`.
|
|
171
|
+
|
|
172
|
+
Every clause names the silent downstream failure it prevents.
|
|
173
|
+
"""
|
|
174
|
+
m = self.manifest
|
|
175
|
+
for key in ("activities", "day_types", "matching_keys", "age_class_bounds", "slot_minutes"):
|
|
176
|
+
if key not in m:
|
|
177
|
+
raise LibraryValidationError(f"manifest lacks {key!r}")
|
|
178
|
+
if tuple(m["activities"]) != DETAILED_ACTIVITIES:
|
|
179
|
+
# A library built with another taxonomy would be decoded with the
|
|
180
|
+
# wrong names by every consumer.
|
|
181
|
+
raise LibraryValidationError(
|
|
182
|
+
"manifest activities differ from this activitymodel taxonomy; "
|
|
183
|
+
"rebuild the library or upgrade activitymodel"
|
|
184
|
+
)
|
|
185
|
+
if int(m["slot_minutes"]) != SLOT_MINUTES:
|
|
186
|
+
raise LibraryValidationError(
|
|
187
|
+
"only 10-minute slots are supported (building_eload hardcodes 144/day)"
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
_require_schema(self.days, DAY_META_SCHEMA, "days")
|
|
191
|
+
missing = [c for c in SLOT_COLUMNS if c not in self.days.columns]
|
|
192
|
+
if missing:
|
|
193
|
+
raise LibraryValidationError(
|
|
194
|
+
f"days lacks {len(missing)} slot columns (first {missing[:3]})"
|
|
195
|
+
)
|
|
196
|
+
slot_dtypes = {self.days.schema[c] for c in SLOT_COLUMNS}
|
|
197
|
+
if slot_dtypes != {pl.UInt8}:
|
|
198
|
+
raise LibraryValidationError(f"slot columns must be UInt8, found {slot_dtypes}")
|
|
199
|
+
if self.days.height == 0:
|
|
200
|
+
raise LibraryValidationError("days is empty")
|
|
201
|
+
if self.days.get_column("day_id").n_unique() != self.days.height:
|
|
202
|
+
raise LibraryValidationError("day_id is not unique")
|
|
203
|
+
if (
|
|
204
|
+
self.days.select(
|
|
205
|
+
pl.any_horizontal(
|
|
206
|
+
[pl.col(c).is_null() for c in list(DAY_META_SCHEMA) + list(SLOT_COLUMNS)]
|
|
207
|
+
)
|
|
208
|
+
)
|
|
209
|
+
.to_series()
|
|
210
|
+
.any()
|
|
211
|
+
):
|
|
212
|
+
raise LibraryValidationError(
|
|
213
|
+
"days holds nulls; a null slot would become an unknown activity"
|
|
214
|
+
)
|
|
215
|
+
if (
|
|
216
|
+
int(self.days.select(pl.max_horizontal([pl.col(c).max() for c in SLOT_COLUMNS])).item())
|
|
217
|
+
>= N_DETAILED
|
|
218
|
+
):
|
|
219
|
+
# Codes outside the taxonomy decode to garbage names downstream.
|
|
220
|
+
raise LibraryValidationError(f"slot codes must be < {N_DETAILED}")
|
|
221
|
+
if (self.days.get_column("weight") <= 0).any():
|
|
222
|
+
raise LibraryValidationError(
|
|
223
|
+
"day weights must be strictly positive (they drive the draw)"
|
|
224
|
+
)
|
|
225
|
+
bad_types = set(self.days.get_column("day_type").unique().to_list()) - set(self.day_types)
|
|
226
|
+
if bad_types:
|
|
227
|
+
raise LibraryValidationError(
|
|
228
|
+
f"days holds day types outside the manifest: {sorted(bad_types)}"
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
_require_schema(self.clusters, CLUSTER_SCHEMA, "clusters")
|
|
232
|
+
if self.clusters.get_column("cluster_id").n_unique() != self.clusters.height:
|
|
233
|
+
raise LibraryValidationError("cluster_id is not unique")
|
|
234
|
+
day_clusters = self.days.select("cluster_id", "day_type").unique()
|
|
235
|
+
joined = day_clusters.join(self.clusters, on="cluster_id", how="left", suffix="_c")
|
|
236
|
+
if joined.get_column("day_type_c").null_count():
|
|
237
|
+
raise LibraryValidationError("days reference cluster_ids absent from clusters")
|
|
238
|
+
if (joined.get_column("day_type") != joined.get_column("day_type_c")).any():
|
|
239
|
+
raise LibraryValidationError("a day's day_type differs from its cluster's day_type")
|
|
240
|
+
for day_type in self.day_types:
|
|
241
|
+
if self.clusters.filter(pl.col("day_type") == day_type).height == 0:
|
|
242
|
+
# Occupants would have no pool to draw that day type from.
|
|
243
|
+
raise LibraryValidationError(f"no cluster for day type {day_type!r}")
|
|
244
|
+
|
|
245
|
+
for key in self.matching_keys:
|
|
246
|
+
if key not in self.patterns.columns:
|
|
247
|
+
raise LibraryValidationError(f"patterns lacks matching key {key!r}")
|
|
248
|
+
if self.patterns.schema[key] != pl.String:
|
|
249
|
+
raise LibraryValidationError(f"patterns.{key} must be String")
|
|
250
|
+
_require_schema(self.patterns, PATTERN_VALUE_SCHEMA, "patterns")
|
|
251
|
+
for column in self.cluster_columns:
|
|
252
|
+
if column not in self.patterns.columns:
|
|
253
|
+
raise LibraryValidationError(f"patterns lacks {column!r}")
|
|
254
|
+
if self.patterns.schema[column] != pl.Int16:
|
|
255
|
+
raise LibraryValidationError(f"patterns.{column} must be Int16")
|
|
256
|
+
referenced = set(self.patterns.get_column(column).drop_nulls().unique().to_list())
|
|
257
|
+
known = set(self.clusters.get_column("cluster_id").to_list())
|
|
258
|
+
if referenced - known:
|
|
259
|
+
raise LibraryValidationError(f"patterns.{column} references unknown clusters")
|
|
260
|
+
if (
|
|
261
|
+
self.patterns.select(
|
|
262
|
+
pl.all_horizontal([pl.col(c).is_null() for c in self.cluster_columns])
|
|
263
|
+
)
|
|
264
|
+
.to_series()
|
|
265
|
+
.any()
|
|
266
|
+
):
|
|
267
|
+
raise LibraryValidationError("a pattern row has no cluster for any day type")
|
|
268
|
+
if (self.patterns.get_column("weight") <= 0).any():
|
|
269
|
+
raise LibraryValidationError("pattern weights must be strictly positive")
|
|
270
|
+
|
|
271
|
+
# ------------------------------------------------------------------ #
|
|
272
|
+
# Convenience
|
|
273
|
+
# ------------------------------------------------------------------ #
|
|
274
|
+
def cluster_rows(self, cluster_id: int) -> np.ndarray:
|
|
275
|
+
"""Row positions in :attr:`codes` of the days of ``cluster_id``."""
|
|
276
|
+
return np.flatnonzero(self.days.get_column("cluster_id").to_numpy() == cluster_id)
|
|
277
|
+
|
|
278
|
+
def cluster_profile(self, cluster_id: int, level: str = "detailed") -> np.ndarray:
|
|
279
|
+
"""Weighted activity-probability matrix ``(144, activities)`` of a cluster."""
|
|
280
|
+
from .diagnostics.profiles import activity_probability_matrix
|
|
281
|
+
|
|
282
|
+
rows = self.cluster_rows(cluster_id)
|
|
283
|
+
weights = self.days.get_column("weight").to_numpy()[rows]
|
|
284
|
+
return activity_probability_matrix(self.codes[rows], level=level, weights=weights)
|
|
285
|
+
|
|
286
|
+
def summary(self) -> str:
|
|
287
|
+
lines = [
|
|
288
|
+
f"DiaryLibrary: {self.n_days} days, {self.clusters.height} clusters, "
|
|
289
|
+
f"{self.patterns.height} pattern rows",
|
|
290
|
+
f" day types : {', '.join(self.day_types)}",
|
|
291
|
+
f" matching keys : {', '.join(self.matching_keys)}",
|
|
292
|
+
f" source : {self.manifest.get('source', {}).get('name', '?')}",
|
|
293
|
+
]
|
|
294
|
+
for row in self.clusters.group_by("day_type").agg(pl.len()).sort("day_type").iter_rows():
|
|
295
|
+
lines.append(f" clusters[{row[0]}] = {row[1]}")
|
|
296
|
+
return "\n".join(lines)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _require_schema(frame: pl.DataFrame, schema: dict, name: str) -> None:
|
|
300
|
+
for column, dtype in schema.items():
|
|
301
|
+
if column not in frame.columns:
|
|
302
|
+
raise LibraryValidationError(f"{name} lacks column {column!r}")
|
|
303
|
+
if frame.schema[column] != dtype:
|
|
304
|
+
raise LibraryValidationError(
|
|
305
|
+
f"{name}.{column} must be {dtype}, found {frame.schema[column]}"
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def manifest_to_frame(manifest: dict) -> pl.DataFrame:
|
|
310
|
+
keys = sorted(manifest)
|
|
311
|
+
return pl.DataFrame(
|
|
312
|
+
{"key": keys, "value": [json.dumps(manifest[k], sort_keys=True) for k in keys]}
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def manifest_from_frame(frame: pl.DataFrame) -> dict:
|
|
317
|
+
return {
|
|
318
|
+
k: json.loads(v)
|
|
319
|
+
for k, v in zip(frame.get_column("key"), frame.get_column("value"), strict=True)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def project_codes(codes: np.ndarray, level: str) -> np.ndarray:
|
|
324
|
+
"""Project detailed ``uint8`` codes to another taxonomy level."""
|
|
325
|
+
return get_level(level).project(codes)
|