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,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
@@ -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)