buildingdata 0.7.0__tar.gz → 0.9.0__tar.gz

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.
Files changed (49) hide show
  1. {buildingdata-0.7.0 → buildingdata-0.9.0}/PKG-INFO +34 -14
  2. {buildingdata-0.7.0 → buildingdata-0.9.0}/README.md +30 -13
  3. {buildingdata-0.7.0 → buildingdata-0.9.0}/buildingdata/__init__.py +2 -0
  4. {buildingdata-0.7.0 → buildingdata-0.9.0}/buildingdata/reference/__init__.py +2 -0
  5. buildingdata-0.9.0/buildingdata/reference/activity_library.py +241 -0
  6. buildingdata-0.9.0/buildingdata/reference/census_individuals.py +118 -0
  7. buildingdata-0.9.0/buildingdata/simulation/_epw.py +470 -0
  8. {buildingdata-0.7.0 → buildingdata-0.9.0}/buildingdata/simulation/era5.py +99 -19
  9. {buildingdata-0.7.0 → buildingdata-0.9.0}/buildingdata/simulation/era5_bulk.py +13 -2
  10. {buildingdata-0.7.0 → buildingdata-0.9.0}/buildingdata/tests/test_bulk.py +89 -0
  11. buildingdata-0.9.0/buildingdata/tests/test_pipeline_census_individuals.py +219 -0
  12. {buildingdata-0.7.0 → buildingdata-0.9.0}/buildingdata/tests/test_pipeline_diagnosis.py +7 -5
  13. {buildingdata-0.7.0 → buildingdata-0.9.0}/buildingdata/tests/test_public_api.py +2 -0
  14. {buildingdata-0.7.0 → buildingdata-0.9.0}/buildingdata/tests/test_reference.py +415 -0
  15. {buildingdata-0.7.0 → buildingdata-0.9.0}/buildingdata/tests/test_simulation.py +119 -0
  16. {buildingdata-0.7.0 → buildingdata-0.9.0}/buildingdata.egg-info/PKG-INFO +34 -14
  17. {buildingdata-0.7.0 → buildingdata-0.9.0}/buildingdata.egg-info/SOURCES.txt +3 -0
  18. {buildingdata-0.7.0 → buildingdata-0.9.0}/buildingdata.egg-info/requires.txt +4 -0
  19. {buildingdata-0.7.0 → buildingdata-0.9.0}/pyproject.toml +8 -1
  20. buildingdata-0.7.0/buildingdata/simulation/_epw.py +0 -231
  21. {buildingdata-0.7.0 → buildingdata-0.9.0}/LICENSE +0 -0
  22. {buildingdata-0.7.0 → buildingdata-0.9.0}/buildingdata/_cli.py +0 -0
  23. {buildingdata-0.7.0 → buildingdata-0.9.0}/buildingdata/bulk.py +0 -0
  24. {buildingdata-0.7.0 → buildingdata-0.9.0}/buildingdata/cache.py +0 -0
  25. {buildingdata-0.7.0 → buildingdata-0.9.0}/buildingdata/config.py +0 -0
  26. {buildingdata-0.7.0 → buildingdata-0.9.0}/buildingdata/exceptions.py +0 -0
  27. {buildingdata-0.7.0 → buildingdata-0.9.0}/buildingdata/gcs.py +0 -0
  28. {buildingdata-0.7.0 → buildingdata-0.9.0}/buildingdata/reference/census.py +0 -0
  29. {buildingdata-0.7.0 → buildingdata-0.9.0}/buildingdata/reference/diagnosis.py +0 -0
  30. {buildingdata-0.7.0 → buildingdata-0.9.0}/buildingdata/reference/districts.py +0 -0
  31. {buildingdata-0.7.0 → buildingdata-0.9.0}/buildingdata/reference/elecdom.py +0 -0
  32. {buildingdata-0.7.0 → buildingdata-0.9.0}/buildingdata/reference/elmas.py +0 -0
  33. {buildingdata-0.7.0 → buildingdata-0.9.0}/buildingdata/reference/enedis.py +0 -0
  34. {buildingdata-0.7.0 → buildingdata-0.9.0}/buildingdata/reference/gas_network.py +0 -0
  35. {buildingdata-0.7.0 → buildingdata-0.9.0}/buildingdata/reference/occupant_diaries.py +0 -0
  36. {buildingdata-0.7.0 → buildingdata-0.9.0}/buildingdata/reference/ore.py +0 -0
  37. {buildingdata-0.7.0 → buildingdata-0.9.0}/buildingdata/simulation/__init__.py +0 -0
  38. {buildingdata-0.7.0 → buildingdata-0.9.0}/buildingdata/simulation/bdtopo.py +0 -0
  39. {buildingdata-0.7.0 → buildingdata-0.9.0}/buildingdata/simulation/bdtopo_bulk.py +0 -0
  40. {buildingdata-0.7.0 → buildingdata-0.9.0}/buildingdata/tests/__init__.py +0 -0
  41. {buildingdata-0.7.0 → buildingdata-0.9.0}/buildingdata/tests/conftest.py +0 -0
  42. {buildingdata-0.7.0 → buildingdata-0.9.0}/buildingdata/tests/test_cache.py +0 -0
  43. {buildingdata-0.7.0 → buildingdata-0.9.0}/buildingdata/tests/test_config.py +0 -0
  44. {buildingdata-0.7.0 → buildingdata-0.9.0}/buildingdata/tests/test_gcs.py +0 -0
  45. {buildingdata-0.7.0 → buildingdata-0.9.0}/buildingdata/validation.py +0 -0
  46. {buildingdata-0.7.0 → buildingdata-0.9.0}/buildingdata.egg-info/dependency_links.txt +0 -0
  47. {buildingdata-0.7.0 → buildingdata-0.9.0}/buildingdata.egg-info/entry_points.txt +0 -0
  48. {buildingdata-0.7.0 → buildingdata-0.9.0}/buildingdata.egg-info/top_level.txt +0 -0
  49. {buildingdata-0.7.0 → buildingdata-0.9.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: buildingdata
3
- Version: 0.7.0
3
+ Version: 0.9.0
4
4
  Summary: Data management layer for buildingmodel — reference data download, BDTOPO retrieval, ERA5 weather
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.10
@@ -22,9 +22,12 @@ Requires-Dist: zarr>=2.18; extra == "era5"
22
22
  Requires-Dist: dask>=2024.1; extra == "era5"
23
23
  Provides-Extra: bulk
24
24
  Requires-Dist: py7zr>=0.20; extra == "bulk"
25
+ Provides-Extra: timezone
26
+ Requires-Dist: timezonefinder>=6.0; extra == "timezone"
25
27
  Provides-Extra: pipeline
26
28
  Requires-Dist: snakemake>=8.0; extra == "pipeline"
27
29
  Requires-Dist: openpyxl>=3.0; extra == "pipeline"
30
+ Requires-Dist: fastexcel>=0.11; extra == "pipeline"
28
31
  Requires-Dist: py7zr>=0.20; extra == "pipeline"
29
32
  Provides-Extra: docs
30
33
  Requires-Dist: sphinx>=7.0; extra == "docs"
@@ -66,6 +69,9 @@ gas = bd.get_gas_network() # GRDF gas network routes -> geopandas GeoDat
66
69
  # On-demand datasets (fetched live from public APIs)
67
70
  buildings = bd.get_bdtopo("751010101") # per-IRIS building geometry (IGN WFS)
68
71
  epw = bd.get_era5_climate(48.85, 2.35, 2020) # ERA5 weather -> synthetic EPW
72
+ # hour axis is UTC; pass
73
+ # timezone="Europe/Paris"
74
+ # for local standard time
69
75
  ```
70
76
 
71
77
  You can also configure from the command line:
@@ -76,19 +82,32 @@ buildingdata configure --bucket my-bucket --cache-dir ~/.cache/buildingdata
76
82
 
77
83
  ## What it provides
78
84
 
79
- | Function | Source | Returns | Consumed by |
80
- | --- | --- | --- | --- |
81
- | `get_census()` | INSEE census (GCS) | polars `DataFrame` | buildingmodel — dwelling inference |
82
- | `get_districts()` | IRIS geometries (GCS) | geopandas `GeoDataFrame` | buildingmodel — geocoding; building_eload — district lookup |
83
- | `get_diagnosis()` | ADEME energy performance diagnoses (GCS) | polars `DataFrame` | buildingmodel — envelope & system inference |
84
- | `get_gas_network()` | GRDF gas network routes (GCS) | geopandas `GeoDataFrame` | buildingmodel — gas-connection inference |
85
- | `get_ore()` | Agence ORE annual consumption per IRIS (GCS) | polars `DataFrame` | building_eload — calibration target |
86
- | `get_enedis_national()` / `get_enedis_regional()` | Enedis conso-inf36 measured consumption (GCS) | polars `DataFrame` | building_eload — validation reference |
87
- | `get_elmas(table)` | ELMAS non-residential load curves (GCS) | polars `DataFrame` | building_eload — non-residential model |
88
- | `get_occupant_diaries()` | synthetic occupant activity calendar (GCS) | polars `DataFrame` | building_eload — occupancy models |
89
- | `get_elecdom()` | Enedis panel Elecdom end-use load curves (GCS) | polars `DataFrame` | provenance only — source of building_eload constants |
90
- | `get_bdtopo(iris_code)` | IGN Géoplateforme WFS (live) or bulk cache | geopandas `GeoDataFrame` | building_eload → buildingmodel — input geometry |
91
- | `get_era5_climate(lat, lon, year)` | Copernicus CDS (live) or bulk cache | path to EPW file | buildingmodel — weather boundary condition |
85
+ | Function | Source | Licence | Returns | Consumed by |
86
+ | --- | --- | --- | --- | --- |
87
+ | `get_census()` | INSEE census (GCS) | Licence Ouverte 2.0 | polars `DataFrame` | buildingmodel — dwelling inference |
88
+ | `get_census_individuals()` | INSEE census individuals, activitymodel vocabulary (GCS) | Licence Ouverte 2.0 | polars `DataFrame` | buildingmodel — occupant inference (planned) |
89
+ | `get_districts()` | IRIS geometries (GCS) | Licence Ouverte 2.0 | geopandas `GeoDataFrame` | buildingmodel — geocoding; building_eload — district lookup |
90
+ | `get_diagnosis()` | ADEME energy performance diagnoses (GCS) | Licence Ouverte 2.0 | polars `DataFrame` | buildingmodel — envelope & system inference |
91
+ | `get_gas_network()` | GRDF gas network routes (GCS) | Licence Ouverte (Etalab) | geopandas `GeoDataFrame` | buildingmodel — gas-connection inference |
92
+ | `get_ore()` | Agence ORE annual consumption per IRIS (GCS) | Licence Ouverte 2.0 | polars `DataFrame` | building_eload — calibration target |
93
+ | `get_enedis_national()` / `get_enedis_regional()` | Enedis conso-inf36 measured consumption (GCS) | Licence Ouverte 2.0 | polars `DataFrame` | building_eload — validation reference |
94
+ | `get_elmas(table)` | ELMAS non-residential load curves (GCS) | **CC BY 4.0** | polars `DataFrame` | building_eload — non-residential model |
95
+ | `get_occupant_diaries()` | synthetic occupant activity calendar (GCS) | maintainers' synthetic artifact; derived from a **restricted-access** survey | polars `DataFrame` | building_eload — occupancy models |
96
+ | `get_activity_library(table)` | activitymodel diary library from the INSEE time-use survey (GCS) | maintainers' derived artifact, no survey identifier; derived from a **restricted-access** survey | polars `DataFrame` | building_eload — occupant calendars via activitymodel (planned) |
97
+ | `get_elecdom()` | Enedis panel Elecdom end-use load curves (GCS) | Licence Ouverte 2.0 | polars `DataFrame` | provenance only — source of building_eload constants |
98
+ | `get_bdtopo(iris_code)` | IGN Géoplateforme WFS (live) or bulk cache | Licence Ouverte 2.0 | geopandas `GeoDataFrame` | building_eload → buildingmodel — input geometry |
99
+ | `get_era5_climate(lat, lon, year)` | Copernicus CDS (live) or bulk cache | **CC BY 4.0** (CDS account required) | path to EPW file (hour axis **UTC** by default, see `timezone=`) | buildingmodel — weather boundary condition |
100
+
101
+ The MIT `LICENSE` covers the **code only**. Each dataset keeps its upstream
102
+ licence, and every derived blob inherits the licence of the source it came
103
+ from — there is no separate "buildingdata data licence". Two rows are not
104
+ Licence Ouverte, and both matter for a publication: ELMAS is CC BY 4.0 (cite the
105
+ paper), and ERA5 is CC BY 4.0 behind a personal Copernicus account, so an
106
+ ERA5-dependent workflow is not anonymously reproducible. The occupant diaries
107
+ and the activity library derive from the INSEE *Enquête Emploi du temps
108
+ 2009-2010*, which is obtained on request and is not redistributed here. The *Datasets* documentation
109
+ page carries the per-dataset attribution string to reproduce, the exact upstream
110
+ vintage, and the verification date.
92
111
 
93
112
  Reference datasets are pulled from a public Google Cloud Storage bucket and
94
113
  cached locally with generation-based freshness checks. French geospatial data
@@ -162,6 +181,7 @@ Because `~/.config/buildingdata/config.ini` is stored in your home directory, co
162
181
  ```bash
163
182
  pip install "buildingdata[era5]" # ERA5 weather (cdsapi, xarray, pvlib, ...)
164
183
  pip install "buildingdata[bulk]" # BDTOPO bulk prefetch (py7zr)
184
+ pip install "buildingdata[timezone]" # ERA5 timezone="local_standard" (timezonefinder)
165
185
  pip install "buildingdata[docs]" # build the Sphinx documentation
166
186
  ```
167
187
 
@@ -30,6 +30,9 @@ gas = bd.get_gas_network() # GRDF gas network routes -> geopandas GeoDat
30
30
  # On-demand datasets (fetched live from public APIs)
31
31
  buildings = bd.get_bdtopo("751010101") # per-IRIS building geometry (IGN WFS)
32
32
  epw = bd.get_era5_climate(48.85, 2.35, 2020) # ERA5 weather -> synthetic EPW
33
+ # hour axis is UTC; pass
34
+ # timezone="Europe/Paris"
35
+ # for local standard time
33
36
  ```
34
37
 
35
38
  You can also configure from the command line:
@@ -40,19 +43,32 @@ buildingdata configure --bucket my-bucket --cache-dir ~/.cache/buildingdata
40
43
 
41
44
  ## What it provides
42
45
 
43
- | Function | Source | Returns | Consumed by |
44
- | --- | --- | --- | --- |
45
- | `get_census()` | INSEE census (GCS) | polars `DataFrame` | buildingmodel — dwelling inference |
46
- | `get_districts()` | IRIS geometries (GCS) | geopandas `GeoDataFrame` | buildingmodel — geocoding; building_eload — district lookup |
47
- | `get_diagnosis()` | ADEME energy performance diagnoses (GCS) | polars `DataFrame` | buildingmodel — envelope & system inference |
48
- | `get_gas_network()` | GRDF gas network routes (GCS) | geopandas `GeoDataFrame` | buildingmodel — gas-connection inference |
49
- | `get_ore()` | Agence ORE annual consumption per IRIS (GCS) | polars `DataFrame` | building_eload — calibration target |
50
- | `get_enedis_national()` / `get_enedis_regional()` | Enedis conso-inf36 measured consumption (GCS) | polars `DataFrame` | building_eload — validation reference |
51
- | `get_elmas(table)` | ELMAS non-residential load curves (GCS) | polars `DataFrame` | building_eload — non-residential model |
52
- | `get_occupant_diaries()` | synthetic occupant activity calendar (GCS) | polars `DataFrame` | building_eload — occupancy models |
53
- | `get_elecdom()` | Enedis panel Elecdom end-use load curves (GCS) | polars `DataFrame` | provenance only — source of building_eload constants |
54
- | `get_bdtopo(iris_code)` | IGN Géoplateforme WFS (live) or bulk cache | geopandas `GeoDataFrame` | building_eload → buildingmodel — input geometry |
55
- | `get_era5_climate(lat, lon, year)` | Copernicus CDS (live) or bulk cache | path to EPW file | buildingmodel — weather boundary condition |
46
+ | Function | Source | Licence | Returns | Consumed by |
47
+ | --- | --- | --- | --- | --- |
48
+ | `get_census()` | INSEE census (GCS) | Licence Ouverte 2.0 | polars `DataFrame` | buildingmodel — dwelling inference |
49
+ | `get_census_individuals()` | INSEE census individuals, activitymodel vocabulary (GCS) | Licence Ouverte 2.0 | polars `DataFrame` | buildingmodel — occupant inference (planned) |
50
+ | `get_districts()` | IRIS geometries (GCS) | Licence Ouverte 2.0 | geopandas `GeoDataFrame` | buildingmodel — geocoding; building_eload — district lookup |
51
+ | `get_diagnosis()` | ADEME energy performance diagnoses (GCS) | Licence Ouverte 2.0 | polars `DataFrame` | buildingmodel — envelope & system inference |
52
+ | `get_gas_network()` | GRDF gas network routes (GCS) | Licence Ouverte (Etalab) | geopandas `GeoDataFrame` | buildingmodel — gas-connection inference |
53
+ | `get_ore()` | Agence ORE annual consumption per IRIS (GCS) | Licence Ouverte 2.0 | polars `DataFrame` | building_eload — calibration target |
54
+ | `get_enedis_national()` / `get_enedis_regional()` | Enedis conso-inf36 measured consumption (GCS) | Licence Ouverte 2.0 | polars `DataFrame` | building_eload — validation reference |
55
+ | `get_elmas(table)` | ELMAS non-residential load curves (GCS) | **CC BY 4.0** | polars `DataFrame` | building_eload — non-residential model |
56
+ | `get_occupant_diaries()` | synthetic occupant activity calendar (GCS) | maintainers' synthetic artifact; derived from a **restricted-access** survey | polars `DataFrame` | building_eload — occupancy models |
57
+ | `get_activity_library(table)` | activitymodel diary library from the INSEE time-use survey (GCS) | maintainers' derived artifact, no survey identifier; derived from a **restricted-access** survey | polars `DataFrame` | building_eload — occupant calendars via activitymodel (planned) |
58
+ | `get_elecdom()` | Enedis panel Elecdom end-use load curves (GCS) | Licence Ouverte 2.0 | polars `DataFrame` | provenance only — source of building_eload constants |
59
+ | `get_bdtopo(iris_code)` | IGN Géoplateforme WFS (live) or bulk cache | Licence Ouverte 2.0 | geopandas `GeoDataFrame` | building_eload → buildingmodel — input geometry |
60
+ | `get_era5_climate(lat, lon, year)` | Copernicus CDS (live) or bulk cache | **CC BY 4.0** (CDS account required) | path to EPW file (hour axis **UTC** by default, see `timezone=`) | buildingmodel — weather boundary condition |
61
+
62
+ The MIT `LICENSE` covers the **code only**. Each dataset keeps its upstream
63
+ licence, and every derived blob inherits the licence of the source it came
64
+ from — there is no separate "buildingdata data licence". Two rows are not
65
+ Licence Ouverte, and both matter for a publication: ELMAS is CC BY 4.0 (cite the
66
+ paper), and ERA5 is CC BY 4.0 behind a personal Copernicus account, so an
67
+ ERA5-dependent workflow is not anonymously reproducible. The occupant diaries
68
+ and the activity library derive from the INSEE *Enquête Emploi du temps
69
+ 2009-2010*, which is obtained on request and is not redistributed here. The *Datasets* documentation
70
+ page carries the per-dataset attribution string to reproduce, the exact upstream
71
+ vintage, and the verification date.
56
72
 
57
73
  Reference datasets are pulled from a public Google Cloud Storage bucket and
58
74
  cached locally with generation-based freshness checks. French geospatial data
@@ -126,6 +142,7 @@ Because `~/.config/buildingdata/config.ini` is stored in your home directory, co
126
142
  ```bash
127
143
  pip install "buildingdata[era5]" # ERA5 weather (cdsapi, xarray, pvlib, ...)
128
144
  pip install "buildingdata[bulk]" # BDTOPO bulk prefetch (py7zr)
145
+ pip install "buildingdata[timezone]" # ERA5 timezone="local_standard" (timezonefinder)
129
146
  pip install "buildingdata[docs]" # build the Sphinx documentation
130
147
  ```
131
148
 
@@ -1,7 +1,9 @@
1
1
  # -*- coding: utf-8 -*-
2
2
  from .config import write_config
3
3
  from .reference import (
4
+ get_activity_library,
4
5
  get_census,
6
+ get_census_individuals,
5
7
  get_diagnosis,
6
8
  get_districts,
7
9
  get_elecdom,
@@ -1,5 +1,7 @@
1
1
  # -*- coding: utf-8 -*-
2
+ from .activity_library import get_activity_library
2
3
  from .census import get_census
4
+ from .census_individuals import get_census_individuals
3
5
  from .diagnosis import get_diagnosis
4
6
  from .districts import get_districts
5
7
  from .elecdom import get_elecdom
@@ -0,0 +1,241 @@
1
+ # -*- coding: utf-8 -*-
2
+ import json
3
+
4
+ import polars as pl
5
+
6
+ from ..exceptions import SchemaValidationError
7
+ from ..gcs import download_blob, ensure_blob_cached, get_blob
8
+ from ..validation import require_columns, require_dtype
9
+
10
+ # The activity diary library built by ``activitymodel`` from the INSEE Enquete
11
+ # Emploi du temps 2009-2010 ships as four tables, one parquet blob each (the
12
+ # ELMAS pattern). Consumers assemble them with
13
+ # ``activitymodel.DiaryLibrary.from_frames``.
14
+ _BLOB_NAMES = {
15
+ "days": "activity_days_latest.parquet",
16
+ "clusters": "activity_clusters_latest.parquet",
17
+ "patterns": "activity_patterns_latest.parquet",
18
+ "manifest": "activity_manifest_latest.parquet",
19
+ }
20
+
21
+ _DATASET = "activity diary library"
22
+
23
+ # Per-table contract, transcribed from activitymodel 0.1's ``library`` module
24
+ # (DAY_META_SCHEMA, CLUSTER_SCHEMA, PATTERN_VALUE_SCHEMA and the manifest
25
+ # key/value frame). Transcribed rather than imported because activitymodel is
26
+ # optional here. The library schema is activitymodel's API: a change bumps its
27
+ # minor version and must be mirrored here in the same release cycle.
28
+ _SCHEMAS = {
29
+ "days": {
30
+ "day_id": pl.UInt32,
31
+ "day_type": pl.String,
32
+ "cluster_id": pl.Int16,
33
+ "weight": pl.Float64,
34
+ },
35
+ "clusters": {
36
+ "cluster_id": pl.Int16,
37
+ "day_type": pl.String,
38
+ "cluster_index": pl.Int16,
39
+ "n_days": pl.UInt32,
40
+ "weight_sum": pl.Float64,
41
+ },
42
+ "patterns": {"weight": pl.Float64, "n_persons": pl.UInt32},
43
+ "manifest": {"key": pl.String, "value": pl.String},
44
+ }
45
+
46
+ # One detailed activity code per 10-minute slot from 00:00, Europe/Paris wall
47
+ # clock. building_eload hardcodes 144 steps per day.
48
+ _SLOT_COLUMNS = tuple(f"slot_{i:03d}" for i in range(144))
49
+
50
+ # ``patterns`` links each socio-demographic cell to one cluster per day type,
51
+ # in columns ``cluster_<day_type>``.
52
+ _PATTERN_CLUSTER_PREFIX = "cluster_"
53
+
54
+
55
+ def _table_dataset(table):
56
+ return f"{_DATASET} ({table!r} table)"
57
+
58
+
59
+ def _check_days(df, dataset):
60
+ slots = {c for c in df.columns if c.startswith("slot_")}
61
+ missing = [c for c in _SLOT_COLUMNS if c not in slots]
62
+ unexpected = sorted(slots - set(_SLOT_COLUMNS))
63
+ if missing or unexpected:
64
+ raise SchemaValidationError(
65
+ f"{dataset}: expected the 144 slot columns slot_000..slot_143, found "
66
+ f"{len(slots)}; missing {missing[:5]}, unexpected {unexpected[:5]}. "
67
+ f"building_eload hardcodes 144 ten-minute steps per day, so a day of "
68
+ f"any other length would be silently misplaced on the timeline."
69
+ )
70
+ require_dtype(df, _SLOT_COLUMNS, pl.UInt8, dataset)
71
+
72
+ # The contract lists every column of ``days`` so that it provably holds no
73
+ # survey microdata: a survey identifier or a socio-demographic attribute
74
+ # next to the activity sequence would re-attach the day to a person.
75
+ extra = [c for c in df.columns if c not in _SCHEMAS["days"] and c not in slots]
76
+ if extra:
77
+ raise SchemaValidationError(
78
+ f"{dataset}: column(s) {extra[:10]} are not part of the contract. The "
79
+ f"published pool is activity sequences detached from any person or "
80
+ f"household; an identifier or socio-demographic column must not be "
81
+ f"published, and no consumer may rely on one."
82
+ )
83
+
84
+
85
+ def _check_patterns(df, dataset):
86
+ cluster_columns = [c for c in df.columns if c.startswith(_PATTERN_CLUSTER_PREFIX)]
87
+ if not cluster_columns:
88
+ raise SchemaValidationError(
89
+ f"{dataset}: no '{_PATTERN_CLUSTER_PREFIX}<day_type>' column found in "
90
+ f"{df.columns}; without them the patterns link people to no cluster."
91
+ )
92
+ require_dtype(df, cluster_columns, pl.Int16, dataset)
93
+
94
+
95
+ def _check_manifest(df, dataset):
96
+ duplicated = df.filter(pl.col("key").is_duplicated())["key"].unique().sort().to_list()
97
+ if duplicated:
98
+ raise SchemaValidationError(
99
+ f"{dataset}: duplicated key(s) {duplicated}. The manifest is read into a "
100
+ f"dict, where the last duplicate would silently win."
101
+ )
102
+ for key, value in df.select("key", "value").iter_rows():
103
+ try:
104
+ json.loads(value)
105
+ except (TypeError, ValueError):
106
+ raise SchemaValidationError(
107
+ f"{dataset}: the value of {key!r} is not JSON-encoded: {value!r:.80}."
108
+ ) from None
109
+
110
+
111
+ def _check_table(table, df):
112
+ """Assert one table carries its documented columns and dtypes."""
113
+ dataset = _table_dataset(table)
114
+ schema = _SCHEMAS[table]
115
+ require_columns(df, schema, dataset)
116
+ for column, dtype in schema.items():
117
+ require_dtype(df, (column,), dtype, dataset)
118
+ if table == "days":
119
+ _check_days(df, dataset)
120
+ elif table == "patterns":
121
+ _check_patterns(df, dataset)
122
+ elif table == "manifest":
123
+ _check_manifest(df, dataset)
124
+
125
+
126
+ def _load_activitymodel():
127
+ """Return ``(activitymodel, LibraryValidationError)``, or None when not installed."""
128
+ try:
129
+ import activitymodel
130
+ from activitymodel.exceptions import LibraryValidationError
131
+ except ModuleNotFoundError as exc:
132
+ # Only a missing activitymodel means "not installed": an installed one
133
+ # whose own import fails is broken and must say so.
134
+ if exc.name != "activitymodel":
135
+ raise
136
+ return None
137
+ return activitymodel, LibraryValidationError
138
+
139
+
140
+ def _read(table, refresh):
141
+ dest = ensure_blob_cached(
142
+ _BLOB_NAMES[table], refresh=refresh, get_blob_fn=get_blob, download_blob_fn=download_blob
143
+ )
144
+ return pl.read_parquet(dest)
145
+
146
+
147
+ def _validate(table, df, refresh):
148
+ """Check ``table``; with activitymodel installed, check the whole library too."""
149
+ _check_table(table, df)
150
+
151
+ loaded = _load_activitymodel()
152
+ if loaded is None:
153
+ return df
154
+ activitymodel, library_validation_error = loaded
155
+
156
+ # DiaryLibrary.validate() is cross-table (a day's cluster must exist and
157
+ # share its day type, the manifest taxonomy must match the installed
158
+ # activitymodel's), so the sibling tables are read too.
159
+ frames = {name: df if name == table else _read(name, refresh) for name in _BLOB_NAMES}
160
+ for name, frame in frames.items():
161
+ if name != table:
162
+ _check_table(name, frame)
163
+ try:
164
+ activitymodel.DiaryLibrary.from_frames(**frames, validate=True)
165
+ except library_validation_error as exc:
166
+ raise SchemaValidationError(
167
+ f"{_DATASET}: the four tables fail activitymodel "
168
+ f"{activitymodel.__version__}'s DiaryLibrary.validate(): {exc}. If the "
169
+ f"blobs were restaged, call again with refresh=True so every table is "
170
+ f"re-read at its live generation; otherwise the library and the "
171
+ f"installed activitymodel disagree on the contract."
172
+ ) from exc
173
+ return df
174
+
175
+
176
+ def get_activity_library(table="days", refresh=False):
177
+ """Return one table of the activity diary library (polars).
178
+
179
+ The library is built by ``activitymodel`` from the INSEE *Enquête Emploi du
180
+ temps 2009-2010* (PROGEDO-ADISP) and holds no survey microdata: ``days``
181
+ are activity sequences detached from any person, ``patterns`` are weighted
182
+ counts over socio-demographic cells. Each table is its own GCS blob;
183
+ ``building_eload`` assembles all four with::
184
+
185
+ from activitymodel import DiaryLibrary
186
+ from activitymodel.library import TABLE_NAMES
187
+
188
+ library = DiaryLibrary.from_frames(
189
+ **{name: get_activity_library(name) for name in TABLE_NAMES}
190
+ )
191
+
192
+ * ``"days"`` (default) -> ``activity_days_latest.parquet``
193
+ The pool of survey days: ``day_id`` (``UInt32``), ``day_type``
194
+ (``weekday`` / ``saturday`` / ``sunday``), ``cluster_id`` (``Int16``),
195
+ ``weight`` (``Float64``, survey diary weight) and ``slot_000`` ..
196
+ ``slot_143`` (``UInt8``), the detailed activity code of each 10-minute
197
+ slot from 00:00, Europe/Paris wall clock. Codes index the manifest's
198
+ ``activities``. No other column.
199
+ * ``"clusters"`` -> ``activity_clusters_latest.parquet``
200
+ ``cluster_id``, ``day_type``, ``cluster_index`` (``Int16``, 0..k-1 by
201
+ decreasing size within the day type), ``n_days`` (``UInt32``),
202
+ ``weight_sum`` (``Float64``).
203
+ * ``"patterns"`` -> ``activity_patterns_latest.parquet``
204
+ One ``String`` column per matching key (``sex``, ``age_class``, ...),
205
+ one ``Int16`` ``cluster_<day_type>`` column per day type (null when the
206
+ cell kept no diary of that day type), ``weight`` (``Float64``, summed
207
+ person weight) and ``n_persons`` (``UInt32``).
208
+ * ``"manifest"`` -> ``activity_manifest_latest.parquet``
209
+ ``key`` / ``value`` strings, values JSON-encoded: taxonomy, day types,
210
+ matching keys, clustering parameters, provenance.
211
+
212
+ Every call checks the requested table's columns and dtypes, including the
213
+ exact 144 ``UInt8`` slot columns and no column beyond the contract in
214
+ ``days``. When ``activitymodel`` is installed, the other three tables are
215
+ read as well and the four are handed to ``DiaryLibrary.validate()``, which
216
+ adds the cross-table and value checks (cluster references, day types,
217
+ code range, weights, taxonomy of the installed version).
218
+
219
+ Args:
220
+ table (str): which table to return, one of ``"days"``, ``"clusters"``,
221
+ ``"patterns"``, ``"manifest"``. Defaults to ``"days"``.
222
+ refresh (bool): force re-download even if the cache is warm. Also
223
+ applies to the sibling tables read for the cross-table check, so a
224
+ refreshed table is never checked against a stale one. Defaults to
225
+ False.
226
+
227
+ Returns:
228
+ polars.DataFrame: the requested table.
229
+
230
+ Raises:
231
+ ValueError: if ``table`` is not a library table.
232
+ RemoteNotAvailableError: if the blob (or, with activitymodel
233
+ installed, a sibling blob) is not found in the GCS bucket.
234
+ SchemaValidationError: if the table, or the library as a whole,
235
+ breaks the contract.
236
+ """
237
+ if table not in _BLOB_NAMES:
238
+ raise ValueError(
239
+ f"Unknown activity library table {table!r}; expected one of {sorted(_BLOB_NAMES)}."
240
+ )
241
+ return _validate(table, _read(table, refresh), refresh)
@@ -0,0 +1,118 @@
1
+ # -*- coding: utf-8 -*-
2
+ import polars as pl
3
+
4
+ from ..gcs import download_blob, ensure_blob_cached, get_blob
5
+ from ..validation import require_columns, require_dtype
6
+
7
+ _BLOB_NAME = "census_individuals_latest.parquet"
8
+
9
+ _DATASET = "INSEE census individuals"
10
+
11
+ # The published schema, mirrored from the pipeline's
12
+ # ``process_census_individuals.py::OUTPUT_SCHEMA``. The person columns are
13
+ # activitymodel's harmonised vocabulary; the dwelling columns use
14
+ # census_latest's labels, so buildingmodel can place a household on a dwelling
15
+ # by joining on them. Every categorical column is a plain ``String``.
16
+ _SCHEMA = {
17
+ # geography: district/city are null for people the census locates only to
18
+ # their canton-ou-ville; city_group is null when that canton spans several
19
+ "district": pl.String,
20
+ "city": pl.String,
21
+ "canton_ou_ville": pl.String,
22
+ "city_group": pl.String,
23
+ "city_group_candidates": pl.List(pl.String),
24
+ "department": pl.String,
25
+ # household
26
+ "household_key": pl.String,
27
+ "household_position": pl.String,
28
+ "ipondi": pl.Float64,
29
+ # person (activitymodel vocabulary)
30
+ "sex": pl.String,
31
+ "age": pl.Int64,
32
+ "age_class": pl.String,
33
+ "activity_status": pl.String,
34
+ "csp_group": pl.String,
35
+ "employment_status": pl.String,
36
+ "couple_status": pl.String,
37
+ "household_size": pl.Int64,
38
+ "household_size_class": pl.String,
39
+ # dwelling (census_latest vocabulary)
40
+ "residential_type": pl.String,
41
+ "construction_year_class_simplified": pl.String,
42
+ "heating_system": pl.String,
43
+ "occupancy_type": pl.String,
44
+ "occupant_count": pl.Int32,
45
+ "living_area_class": pl.String,
46
+ }
47
+
48
+
49
+ def get_census_individuals(city_group_list=None, refresh=False):
50
+ """Return the INSEE census individuals (polars), optionally pre-filtered.
51
+
52
+ One row per person of an ordinary household living in a house or an
53
+ apartment in metropolitan France, from the RP2022 *Individus localisés au
54
+ canton-ou-ville* file (~16.5 M rows nationally), harmonised to the
55
+ activitymodel vocabulary. It is the input of
56
+ buildingmodel's occupant inference, which draws one census household per
57
+ dwelling. Downloaded from ``census_individuals_latest.parquet`` on GCS on
58
+ first call. Filtering uses a polars lazy scan, so only the requested rows
59
+ are materialised.
60
+
61
+ Columns:
62
+
63
+ * geography: ``district`` (IRIS), ``city``, ``canton_ou_ville`` (INSEE
64
+ pseudo-canton), ``city_group`` (EPCI, in ``get_districts``'s coding),
65
+ ``city_group_candidates`` (list) and ``department``. The census locates a
66
+ person to the IRIS only in IRIS-split communes. Everyone else (41.6 % of
67
+ the weighted population) has a null ``district``/``city``, and
68
+ ``city_group_candidates`` lists the city groups of their canton's
69
+ unsplit communes; ``city_group`` is set only when there is exactly one
70
+ (null for 27.4 % of the population).
71
+ * household: ``household_key`` (canton-ou-ville + INSEE household number),
72
+ ``household_position`` (``reference_person``, ``partner``, ``child``,
73
+ ``other``), ``ipondi`` (the INSEE person weight).
74
+ * person, activitymodel vocabulary: ``sex``, ``age``, ``age_class``,
75
+ ``activity_status``, ``csp_group`` (null outside employment),
76
+ ``employment_status``, ``couple_status``, ``household_size``,
77
+ ``household_size_class``.
78
+ * dwelling, census_latest vocabulary: ``residential_type``,
79
+ ``construction_year_class_simplified`` (the census file only has the
80
+ regrouped construction period, whose 2006+ classes do not split at 2012
81
+ like ``construction_year_class``), ``heating_system``,
82
+ ``occupancy_type`` (always ``primary residence``), ``occupant_count``
83
+ (equal to ``household_size``) and ``living_area_class``.
84
+
85
+ Args:
86
+ city_group_list (list of str, optional): EPCI codes to filter on. A
87
+ person is kept when any of their ``city_group_candidates`` is in the
88
+ list: someone located only to a canton that straddles two EPCIs is
89
+ returned for both. Filter on ``district`` being non-null to keep
90
+ IRIS-located people only. If None, the full national table is
91
+ returned. Defaults to None.
92
+ refresh (bool): force re-download even if the cache is warm.
93
+ Defaults to False.
94
+
95
+ Returns:
96
+ polars.DataFrame: one row per person, columns as listed above.
97
+
98
+ Raises:
99
+ RemoteNotAvailableError: if the blob is not found in the GCS bucket.
100
+ SchemaValidationError: if the blob lacks a documented column or one
101
+ has another dtype.
102
+ """
103
+ dest = ensure_blob_cached(
104
+ _BLOB_NAME, refresh=refresh, get_blob_fn=get_blob, download_blob_fn=download_blob
105
+ )
106
+
107
+ lf = pl.scan_parquet(dest)
108
+ schema = lf.collect_schema()
109
+ require_columns(schema, _SCHEMA, _DATASET)
110
+ for column, dtype in _SCHEMA.items():
111
+ require_dtype(schema, (column,), dtype, _DATASET)
112
+
113
+ if city_group_list is not None:
114
+ codes = [str(code) for code in city_group_list]
115
+ lf = lf.filter(
116
+ pl.col("city_group_candidates").list.eval(pl.element().is_in(codes)).list.any()
117
+ )
118
+ return lf.collect()