buildingdata 0.4.0__tar.gz → 0.5.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 (46) hide show
  1. {buildingdata-0.4.0 → buildingdata-0.5.0}/PKG-INFO +17 -10
  2. {buildingdata-0.4.0 → buildingdata-0.5.0}/README.md +16 -9
  3. {buildingdata-0.4.0 → buildingdata-0.5.0}/buildingdata/__init__.py +1 -0
  4. {buildingdata-0.4.0 → buildingdata-0.5.0}/buildingdata/gcs.py +16 -1
  5. {buildingdata-0.4.0 → buildingdata-0.5.0}/buildingdata/reference/__init__.py +1 -0
  6. buildingdata-0.5.0/buildingdata/reference/elecdom.py +153 -0
  7. buildingdata-0.5.0/buildingdata/reference/occupant_diaries.py +257 -0
  8. buildingdata-0.5.0/buildingdata/tests/test_gcs.py +73 -0
  9. {buildingdata-0.4.0 → buildingdata-0.5.0}/buildingdata/tests/test_public_api.py +1 -0
  10. {buildingdata-0.4.0 → buildingdata-0.5.0}/buildingdata/tests/test_reference.py +236 -13
  11. {buildingdata-0.4.0 → buildingdata-0.5.0}/buildingdata/validation.py +52 -2
  12. {buildingdata-0.4.0 → buildingdata-0.5.0}/buildingdata.egg-info/PKG-INFO +17 -10
  13. {buildingdata-0.4.0 → buildingdata-0.5.0}/buildingdata.egg-info/SOURCES.txt +2 -0
  14. {buildingdata-0.4.0 → buildingdata-0.5.0}/pyproject.toml +1 -1
  15. buildingdata-0.4.0/buildingdata/reference/occupant_diaries.py +0 -41
  16. {buildingdata-0.4.0 → buildingdata-0.5.0}/LICENSE +0 -0
  17. {buildingdata-0.4.0 → buildingdata-0.5.0}/buildingdata/_cli.py +0 -0
  18. {buildingdata-0.4.0 → buildingdata-0.5.0}/buildingdata/bulk.py +0 -0
  19. {buildingdata-0.4.0 → buildingdata-0.5.0}/buildingdata/cache.py +0 -0
  20. {buildingdata-0.4.0 → buildingdata-0.5.0}/buildingdata/config.py +0 -0
  21. {buildingdata-0.4.0 → buildingdata-0.5.0}/buildingdata/exceptions.py +0 -0
  22. {buildingdata-0.4.0 → buildingdata-0.5.0}/buildingdata/reference/census.py +0 -0
  23. {buildingdata-0.4.0 → buildingdata-0.5.0}/buildingdata/reference/diagnosis.py +0 -0
  24. {buildingdata-0.4.0 → buildingdata-0.5.0}/buildingdata/reference/districts.py +0 -0
  25. {buildingdata-0.4.0 → buildingdata-0.5.0}/buildingdata/reference/elmas.py +0 -0
  26. {buildingdata-0.4.0 → buildingdata-0.5.0}/buildingdata/reference/enedis.py +0 -0
  27. {buildingdata-0.4.0 → buildingdata-0.5.0}/buildingdata/reference/gas_network.py +0 -0
  28. {buildingdata-0.4.0 → buildingdata-0.5.0}/buildingdata/reference/ore.py +0 -0
  29. {buildingdata-0.4.0 → buildingdata-0.5.0}/buildingdata/simulation/__init__.py +0 -0
  30. {buildingdata-0.4.0 → buildingdata-0.5.0}/buildingdata/simulation/_epw.py +0 -0
  31. {buildingdata-0.4.0 → buildingdata-0.5.0}/buildingdata/simulation/bdtopo.py +0 -0
  32. {buildingdata-0.4.0 → buildingdata-0.5.0}/buildingdata/simulation/bdtopo_bulk.py +0 -0
  33. {buildingdata-0.4.0 → buildingdata-0.5.0}/buildingdata/simulation/era5.py +0 -0
  34. {buildingdata-0.4.0 → buildingdata-0.5.0}/buildingdata/simulation/era5_bulk.py +0 -0
  35. {buildingdata-0.4.0 → buildingdata-0.5.0}/buildingdata/tests/__init__.py +0 -0
  36. {buildingdata-0.4.0 → buildingdata-0.5.0}/buildingdata/tests/conftest.py +0 -0
  37. {buildingdata-0.4.0 → buildingdata-0.5.0}/buildingdata/tests/test_bulk.py +0 -0
  38. {buildingdata-0.4.0 → buildingdata-0.5.0}/buildingdata/tests/test_cache.py +0 -0
  39. {buildingdata-0.4.0 → buildingdata-0.5.0}/buildingdata/tests/test_config.py +0 -0
  40. {buildingdata-0.4.0 → buildingdata-0.5.0}/buildingdata/tests/test_pipeline_diagnosis.py +0 -0
  41. {buildingdata-0.4.0 → buildingdata-0.5.0}/buildingdata/tests/test_simulation.py +0 -0
  42. {buildingdata-0.4.0 → buildingdata-0.5.0}/buildingdata.egg-info/dependency_links.txt +0 -0
  43. {buildingdata-0.4.0 → buildingdata-0.5.0}/buildingdata.egg-info/entry_points.txt +0 -0
  44. {buildingdata-0.4.0 → buildingdata-0.5.0}/buildingdata.egg-info/requires.txt +0 -0
  45. {buildingdata-0.4.0 → buildingdata-0.5.0}/buildingdata.egg-info/top_level.txt +0 -0
  46. {buildingdata-0.4.0 → buildingdata-0.5.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: buildingdata
3
- Version: 0.4.0
3
+ Version: 0.5.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
@@ -76,18 +76,25 @@ buildingdata configure --bucket my-bucket --cache-dir ~/.cache/buildingdata
76
76
 
77
77
  ## What it provides
78
78
 
79
- | Function | Source | Returns |
80
- | --- | --- | --- |
81
- | `get_census()` | INSEE census (GCS) | polars `DataFrame` |
82
- | `get_districts()` | IRIS geometries (GCS) | geopandas `GeoDataFrame` |
83
- | `get_diagnosis()` | ADEME energy performance diagnoses (GCS) | polars `DataFrame` |
84
- | `get_gas_network()` | GRDF gas network routes (GCS) | geopandas `GeoDataFrame` |
85
- | `get_bdtopo(iris_code)` | IGN Géoplateforme WFS (live) | geopandas `GeoDataFrame` |
86
- | `get_era5_climate(lat, lon, year)` | Copernicus CDS (live) | path to EPW file |
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 |
87
92
 
88
93
  Reference datasets are pulled from a public Google Cloud Storage bucket and
89
94
  cached locally with generation-based freshness checks. French geospatial data
90
- uses CRS **EPSG:2154 (Lambert-93)**.
95
+ uses CRS **EPSG:2154 (Lambert-93)**. The documentation page *Datasets*
96
+ describes each dataset in detail — provenance, schema, units — and how
97
+ `buildingmodel` / `building_eload` consume it.
91
98
 
92
99
  ## Bulk prefetch for large-scale simulations
93
100
 
@@ -40,18 +40,25 @@ buildingdata configure --bucket my-bucket --cache-dir ~/.cache/buildingdata
40
40
 
41
41
  ## What it provides
42
42
 
43
- | Function | Source | Returns |
44
- | --- | --- | --- |
45
- | `get_census()` | INSEE census (GCS) | polars `DataFrame` |
46
- | `get_districts()` | IRIS geometries (GCS) | geopandas `GeoDataFrame` |
47
- | `get_diagnosis()` | ADEME energy performance diagnoses (GCS) | polars `DataFrame` |
48
- | `get_gas_network()` | GRDF gas network routes (GCS) | geopandas `GeoDataFrame` |
49
- | `get_bdtopo(iris_code)` | IGN Géoplateforme WFS (live) | geopandas `GeoDataFrame` |
50
- | `get_era5_climate(lat, lon, year)` | Copernicus CDS (live) | path to EPW file |
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 |
51
56
 
52
57
  Reference datasets are pulled from a public Google Cloud Storage bucket and
53
58
  cached locally with generation-based freshness checks. French geospatial data
54
- uses CRS **EPSG:2154 (Lambert-93)**.
59
+ uses CRS **EPSG:2154 (Lambert-93)**. The documentation page *Datasets*
60
+ describes each dataset in detail — provenance, schema, units — and how
61
+ `buildingmodel` / `building_eload` consume it.
55
62
 
56
63
  ## Bulk prefetch for large-scale simulations
57
64
 
@@ -4,6 +4,7 @@ from .reference import (
4
4
  get_census,
5
5
  get_diagnosis,
6
6
  get_districts,
7
+ get_elecdom,
7
8
  get_elmas,
8
9
  get_enedis_national,
9
10
  get_enedis_regional,
@@ -27,16 +27,31 @@ def get_client():
27
27
  def get_blob(name):
28
28
  """Return the Blob object for a given name, or None if not found.
29
29
 
30
+ Returning None on a missing blob is the contract ``ensure_blob_cached``
31
+ relies on to raise a named
32
+ :class:`~buildingdata.exceptions.RemoteNotAvailableError` -- which is in
33
+ turn what every ``get_X`` accessor documents under ``Raises``. ``reload()``
34
+ is what populates the blob's metadata (``generation``, size) used by the
35
+ cache freshness check, but it raises ``NotFound`` on a missing object
36
+ *before* ``exists()`` is ever reached, so it is caught here. Without this,
37
+ an accessor whose blob has not been staged yet surfaced an opaque
38
+ ``google.api_core.exceptions.NotFound`` instead of the documented error.
39
+
30
40
  Args:
31
41
  name (str): blob name inside the configured bucket.
32
42
 
33
43
  Returns:
34
44
  google.cloud.storage.Blob or None.
35
45
  """
46
+ from google.api_core.exceptions import NotFound
47
+
36
48
  client = get_client()
37
49
  bucket = client.bucket(get_bucket())
38
50
  blob = bucket.blob(name)
39
- blob.reload()
51
+ try:
52
+ blob.reload()
53
+ except NotFound:
54
+ return None
40
55
  return blob if blob.exists() else None
41
56
 
42
57
 
@@ -2,6 +2,7 @@
2
2
  from .census import get_census
3
3
  from .diagnosis import get_diagnosis
4
4
  from .districts import get_districts
5
+ from .elecdom import get_elecdom
5
6
  from .elmas import get_elmas
6
7
  from .enedis import get_enedis_national, get_enedis_regional
7
8
  from .gas_network import get_gas_network
@@ -0,0 +1,153 @@
1
+ # -*- coding: utf-8 -*-
2
+ import polars as pl
3
+
4
+ from ..exceptions import SchemaValidationError
5
+ from ..gcs import download_blob, ensure_blob_cached, get_blob
6
+ from ..validation import require_columns, require_dtype, require_value_range
7
+
8
+ _BLOB_NAME = "elecdom_daily_load_curves_latest.parquet"
9
+
10
+ _DATASET = "Enedis panel Elecdom daily load curves"
11
+
12
+ # The tidy (long) shape the staged blob must have: one row per
13
+ # (period, end use, hour-of-day). See ``doc/pending_uploads.md`` section 5 for
14
+ # the exact transformation from the source CSV.
15
+ _REQUIRED_COLUMNS = (
16
+ "period_label",
17
+ "season",
18
+ "day_type",
19
+ "end_use",
20
+ "is_specific_electricity",
21
+ "hour",
22
+ "mean_power_w",
23
+ )
24
+
25
+ _STRING_COLUMNS = ("period_label", "season", "day_type", "end_use")
26
+
27
+ # 5 season blocks x 3 day types x 10 end uses x 24 hours.
28
+ _EXPECTED_ROWS = 5 * 3 * 10 * 24
29
+
30
+ _SEASONS = ("december_february", "march_may", "june_august", "september_november", "all_seasons")
31
+ _DAY_TYPES = ("weekday", "weekend", "all_days")
32
+
33
+ # Plausible band for an hourly panel-mean per-dwelling end-use load, in watts.
34
+ # The published curves peak around 410 W; 20 kW leaves ample headroom while
35
+ # still catching a W <-> kWh/Wh-per-day style rescale.
36
+ _MAX_PLAUSIBLE_W = 20_000.0
37
+ # A W -> kW rescale would leave every value below 1 W and would *not* trip the
38
+ # upper bound, so the lower end of the scale is asserted separately.
39
+ _MIN_PLAUSIBLE_PEAK_W = 1.0
40
+
41
+
42
+ def _validate(df):
43
+ """Assert the Elecdom load-curve blob matches its documented contract."""
44
+ require_columns(df, _REQUIRED_COLUMNS, _DATASET)
45
+ require_dtype(df, _STRING_COLUMNS, pl.String, _DATASET)
46
+ require_dtype(df, ("is_specific_electricity",), pl.Boolean, _DATASET)
47
+ require_dtype(df, ("hour",), pl.Int64, _DATASET)
48
+ require_dtype(df, ("mean_power_w",), pl.Float64, _DATASET)
49
+
50
+ if df.height != _EXPECTED_ROWS:
51
+ raise SchemaValidationError(
52
+ f"{_DATASET}: got {df.height} rows, expected exactly {_EXPECTED_ROWS} "
53
+ f"({len(_SEASONS)} season blocks x {len(_DAY_TYPES)} day types x 10 end "
54
+ f"uses x 24 hours). A short frame means the source CSV was partially "
55
+ f"read or a period/end-use block was dropped during staging."
56
+ )
57
+
58
+ hours = sorted(df["hour"].unique().to_list())
59
+ if hours != list(range(24)):
60
+ raise SchemaValidationError(
61
+ f"{_DATASET}: 'hour' must cover 0..23 exactly, got {hours}. The source "
62
+ f"publishes 24 left-closed hourly bins '[h,h+1['."
63
+ )
64
+ for column, expected in (("season", _SEASONS), ("day_type", _DAY_TYPES)):
65
+ observed = sorted(df[column].unique().to_list())
66
+ if observed != sorted(expected):
67
+ raise SchemaValidationError(
68
+ f"{_DATASET}: {column!r} must be exactly {sorted(expected)}, got "
69
+ f"{observed}. The 'Période' parsing at staging time drifted."
70
+ )
71
+
72
+ require_value_range(df, "mean_power_w", 0.0, _MAX_PLAUSIBLE_W, _DATASET)
73
+ peak = df["mean_power_w"].max()
74
+ if peak is None or peak < _MIN_PLAUSIBLE_PEAK_W:
75
+ raise SchemaValidationError(
76
+ f"{_DATASET}: 'mean_power_w' peaks at {peak}, below "
77
+ f"{_MIN_PLAUSIBLE_PEAK_W} W. The published curves peak in the hundreds "
78
+ f"of watts; this looks like a watt-to-kilowatt rescale, which the upper "
79
+ f"bound cannot catch."
80
+ )
81
+ return df
82
+
83
+
84
+ def get_elecdom(refresh=False):
85
+ """Return the Enedis "panel Elecdom" daily end-use load curves (polars).
86
+
87
+ **Provenance-only accessor.** Nothing in ``building_eload`` calls this at
88
+ runtime. The Elecdom study is the published source behind two hand-derived
89
+ literal constant sets in ``building_eload``
90
+ (``models/specific.py::ACTIVITY_ELECTRICITY_WEIGHT_TABLE`` and
91
+ ``models/cooking.py::COOKING_CONFIG``, paper Tab. 1 ref. [37]); those stay
92
+ the runtime source of truth. This accessor exists so the raw table those
93
+ constants were transcribed from is versioned, fetchable and schema-checked,
94
+ making an offline re-derivation reproducible. Its only intended consumer is
95
+ such a re-derivation script.
96
+
97
+ Provenance: Enedis "panel Elecdom" appliance / end-use load study, open
98
+ dataset *"Courbes de charge journalières horaires par usage"*
99
+ (`elecdom-courbes-de-charge-journalieres-horaires`), cited as paper Tab. 1
100
+ ref. [37]. The published table is a panel-mean per-dwelling daily load
101
+ curve, broken down by season block, day type and end use.
102
+
103
+ Shape: tidy/long, one row per (period, end use, hour) -- 5 season blocks
104
+ x 3 day types x 10 end uses x 24 hours = 3600 rows.
105
+
106
+ Columns:
107
+
108
+ * ``period_label`` (``str``) -- the verbatim source ``Période`` label, e.g.
109
+ ``"Mois de Décembre - Février (Jours ouvrées)"``. Kept so a row can be
110
+ traced back to the raw CSV without reversing the parsing.
111
+ * ``season`` (``str``) -- one of ``"december_february"``, ``"march_may"``,
112
+ ``"june_august"``, ``"september_november"``, ``"all_seasons"``.
113
+ * ``day_type`` (``str``) -- ``"weekday"`` (source *Jours ouvrées*),
114
+ ``"weekend"`` (*Week-end*), ``"all_days"`` (source's empty parentheses).
115
+ * ``end_use`` (``str``) -- the verbatim French ``Poste`` label, one of
116
+ ``Chauffage direct`` (direct electric heating), ``Chauffe eau`` (DHW),
117
+ ``Cuisson`` (cooking), ``Ventilation``, ``Froid`` (refrigeration),
118
+ ``Audiovisuel``, ``Informatique/Bureautique``, ``Lavage séchage``
119
+ (washing/drying), ``Eclairage`` (lighting), ``Autres``. Left untranslated
120
+ on purpose: this is a provenance artifact, and any English mapping would
121
+ be an editorial choice the re-derivation script should own.
122
+ * ``is_specific_electricity`` (``bool``) -- True for the seven end uses the
123
+ source also republishes under its ``"Electricité spécifique - "`` period
124
+ prefix (everything except direct heating, DHW and cooking). The prefixed
125
+ block is a byte-identical duplicate of the base rows, so it is collapsed
126
+ into this flag rather than duplicated (verified at staging; see
127
+ ``doc/pending_uploads.md``).
128
+ * ``hour`` (``Int64``) -- 0..23, the left edge of the source's left-closed
129
+ hourly bin ``[h, h+1[``.
130
+ * ``mean_power_w`` (``Float64``) -- **watts**: the panel-mean load over that
131
+ hour bin, equivalently Wh consumed during the hour. Published values run
132
+ from a few W to ~410 W.
133
+
134
+ Args:
135
+ refresh (bool): force re-download even if the cache is warm.
136
+ Defaults to False.
137
+
138
+ Returns:
139
+ polars.DataFrame: the 3600-row tidy Elecdom load-curve table.
140
+
141
+ Raises:
142
+ RemoteNotAvailableError: if the blob is not found in the GCS bucket.
143
+ The blob is **not staged yet** -- see ``doc/pending_uploads.md``
144
+ section 5 for the maintainer handoff -- so this is the expected
145
+ outcome until it is uploaded.
146
+ SchemaValidationError: if the fetched frame is missing a column, carries
147
+ a wrong dtype, is not the full 3600-row grid, has an unexpected
148
+ season/day-type/hour vocabulary, or has implausible watt values.
149
+ """
150
+ dest = ensure_blob_cached(
151
+ _BLOB_NAME, refresh=refresh, get_blob_fn=get_blob, download_blob_fn=download_blob
152
+ )
153
+ return _validate(pl.read_parquet(dest))
@@ -0,0 +1,257 @@
1
+ # -*- coding: utf-8 -*-
2
+ import re
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_dtype
9
+
10
+ _BLOB_NAME = "occupant_diaries_latest.parquet"
11
+
12
+ _DATASET = "occupant activity diaries"
13
+
14
+ # The timeline column. The staged blob was written from pandas, which round-trips
15
+ # an unnamed index through parquet as ``__index_level_0__``; that is the name the
16
+ # blob in the bucket actually carries today, and ``building_eload``'s
17
+ # ``load_activity_diaries`` renames it to ``datetime`` on read. Accept either, so
18
+ # a future re-staging that names the index properly keeps validating, but reject
19
+ # anything else -- that rename is load-bearing for the consumer.
20
+ _INDEX_COLUMN_NAMES = ("datetime", "__index_level_0__")
21
+
22
+ # Every non-timeline column must be an occupant column. ``DwellingProcessor``
23
+ # builds its occupant pool as ``[c for c in columns if c != "datetime"]``, so a
24
+ # stray extra column (a leftover id, a weekday flag) would silently be drawn as
25
+ # if it were an occupant's diary. Pinning the name shape makes that fail loud.
26
+ _OCCUPANT_COLUMN_RE = re.compile(r"^occupant_\d+$")
27
+
28
+ # The activity-code vocabulary. This mirrors ``building_eload``'s
29
+ # ``models/occupancy.py::DEFAULT_OCCUPANCY_PER_ACTIVITY`` (flattened) -- the only
30
+ # place the vocabulary is enumerated. It is transcribed rather than imported
31
+ # because the dependency runs the other way (``building_eload`` depends on
32
+ # ``buildingdata``), so keep the two in sync when the taxonomy changes.
33
+ #
34
+ # Validating it matters because both consumers of an unknown code fail
35
+ # *silently*: ``set_occupancy_labels`` maps anything unrecognised to
36
+ # ``"is_absent"`` (the occupant vanishes from the load), and ``specific.py``'s
37
+ # left join against ``ACTIVITY_ELECTRICITY_WEIGHT_TABLE`` yields a null weight.
38
+ # The check is a subset test, not equality: a smaller diary book that happens
39
+ # not to use every code is legitimate.
40
+ _ACTIVITY_CODES = frozenset(
41
+ {
42
+ # at home
43
+ "Hygiene",
44
+ "Other_at_home",
45
+ "Meal_at_home",
46
+ "Home_working",
47
+ "Cooking",
48
+ "Dish_washing",
49
+ "Housework",
50
+ "Clothes_washing",
51
+ "Ironing",
52
+ "Home_DIY",
53
+ "Gardening",
54
+ "Television",
55
+ "HiFi",
56
+ "Video_game",
57
+ "Computer",
58
+ "Electronic_Device",
59
+ # asleep
60
+ "Sleep",
61
+ # away
62
+ "Other_outside",
63
+ "Meal_outside",
64
+ "At_work",
65
+ "At_school",
66
+ "Shopping",
67
+ "Work_journey",
68
+ "Other_journey",
69
+ }
70
+ )
71
+
72
+ _SECONDS_PER_DAY = 24 * 60 * 60
73
+ _FULL_YEAR_DAYS = (365, 366)
74
+
75
+
76
+ def _index_column(df):
77
+ """Return the name of the diary's single Datetime timeline column."""
78
+ datetime_cols = [name for name, dtype in df.schema.items() if dtype == pl.Datetime]
79
+ if not datetime_cols:
80
+ raise SchemaValidationError(
81
+ f"{_DATASET}: no Datetime column found; expected exactly one timeline "
82
+ f"column named one of {list(_INDEX_COLUMN_NAMES)}. Got dtypes "
83
+ f"{ {name: str(t) for name, t in list(df.schema.items())[:5]} } (first 5 "
84
+ f"of {df.width}). Without a timeline the diaries cannot be aligned "
85
+ f"onto a simulation year."
86
+ )
87
+ if len(datetime_cols) > 1:
88
+ raise SchemaValidationError(
89
+ f"{_DATASET}: expected exactly one Datetime timeline column, found "
90
+ f"{datetime_cols}. Consumers treat every non-timeline column as an "
91
+ f"occupant diary, so a second temporal column would be drawn as one."
92
+ )
93
+ name = datetime_cols[0]
94
+ if name not in _INDEX_COLUMN_NAMES:
95
+ raise SchemaValidationError(
96
+ f"{_DATASET}: the timeline column is named {name!r}, expected one of "
97
+ f"{list(_INDEX_COLUMN_NAMES)}. ``building_eload`` renames "
98
+ f"``__index_level_0__`` to ``datetime`` on read and would raise on "
99
+ f"any other name."
100
+ )
101
+ return name
102
+
103
+
104
+ def _require_complete_timeline(df, index_col):
105
+ """Assert the timeline is a gap-free regular grid covering a whole year.
106
+
107
+ The ELMAS lesson applies here too: a diary book that is one step short, has
108
+ a duplicated timestamp, or covers ten months instead of twelve does not
109
+ raise anywhere downstream -- ``align_activity_diaries`` rolls and slices it
110
+ happily and produces a quietly wrong calendar.
111
+ """
112
+ series = df[index_col]
113
+ if series.null_count():
114
+ raise SchemaValidationError(
115
+ f"{_DATASET}: the {index_col!r} timeline has "
116
+ f"{series.null_count()} null timestamp(s)."
117
+ )
118
+ if not series.is_sorted():
119
+ raise SchemaValidationError(
120
+ f"{_DATASET}: the {index_col!r} timeline is not sorted ascending; "
121
+ f"consumers roll the frame positionally and assume calendar order."
122
+ )
123
+
124
+ steps = series.diff().drop_nulls().unique().to_list()
125
+ if len(steps) != 1:
126
+ raise SchemaValidationError(
127
+ f"{_DATASET}: the {index_col!r} timeline is not a regular grid; found "
128
+ f"{len(steps)} distinct steps {sorted(steps)[:5]} over {df.height} rows. "
129
+ f"A gap or a duplicated timestamp silently misaligns the calendar roll."
130
+ )
131
+ step = steps[0]
132
+ step_seconds = step.total_seconds()
133
+ if step_seconds <= 0 or _SECONDS_PER_DAY % step_seconds:
134
+ raise SchemaValidationError(
135
+ f"{_DATASET}: timeline step {step} does not divide a calendar day "
136
+ f"evenly; the diaries cannot be indexed by day-of-week."
137
+ )
138
+
139
+ steps_per_day = int(_SECONDS_PER_DAY // step_seconds)
140
+ days, remainder = divmod(df.height, steps_per_day)
141
+ if remainder or days not in _FULL_YEAR_DAYS:
142
+ raise SchemaValidationError(
143
+ f"{_DATASET}: {df.height} rows at a {step} step is {df.height / steps_per_day:.4f} "
144
+ f"days, not a whole year ({' or '.join(str(d) for d in _FULL_YEAR_DAYS)} days, "
145
+ f"i.e. {' or '.join(str(d * steps_per_day) for d in _FULL_YEAR_DAYS)} rows). "
146
+ f"A truncated diary book is the exact failure mode this check exists for: "
147
+ f"it raises nothing downstream, it just yields a wrong year."
148
+ )
149
+
150
+
151
+ def _require_known_activity_codes(df, occupant_cols):
152
+ """Assert every observed activity code is in the documented vocabulary."""
153
+ observed = set()
154
+ for column in occupant_cols:
155
+ observed.update(df[column].unique().drop_nulls().to_list())
156
+ unknown = sorted(observed - _ACTIVITY_CODES)
157
+ if unknown:
158
+ raise SchemaValidationError(
159
+ f"{_DATASET}: {len(unknown)} unknown activity code(s) {unknown[:10]}"
160
+ f"{'...' if len(unknown) > 10 else ''}. The documented vocabulary is "
161
+ f"{sorted(_ACTIVITY_CODES)}. Unknown codes do not raise downstream -- "
162
+ f"``set_occupancy_labels`` maps them to 'is_absent' and the specific-"
163
+ f"electricity weight join yields null -- so they are rejected here. If "
164
+ f"the taxonomy genuinely grew, extend both ``_ACTIVITY_CODES`` here and "
165
+ f"``building_eload``'s DEFAULT_OCCUPANCY_PER_ACTIVITY / "
166
+ f"ACTIVITY_ELECTRICITY_WEIGHT_TABLE."
167
+ )
168
+
169
+
170
+ def _validate(df):
171
+ """Run the full occupant-diary contract check on a loaded frame."""
172
+ index_col = _index_column(df)
173
+
174
+ occupant_cols = [c for c in df.columns if c != index_col]
175
+ if not occupant_cols:
176
+ raise SchemaValidationError(
177
+ f"{_DATASET}: no occupant column found next to the {index_col!r} "
178
+ f"timeline; the diary book is empty."
179
+ )
180
+ malformed = [c for c in occupant_cols if not _OCCUPANT_COLUMN_RE.match(c)]
181
+ if malformed:
182
+ raise SchemaValidationError(
183
+ f"{_DATASET}: {len(malformed)} non-timeline column(s) do not match "
184
+ f"'occupant_<n>': {malformed[:10]}{'...' if len(malformed) > 10 else ''}. "
185
+ f"``DwellingProcessor`` draws every non-timeline column as an occupant "
186
+ f"profile, so a stray column would be simulated as a person."
187
+ )
188
+
189
+ require_dtype(df, occupant_cols, pl.String, _DATASET)
190
+
191
+ null_counts = df.select(pl.col(occupant_cols).null_count()).row(0)
192
+ nulls = [c for c, n in zip(occupant_cols, null_counts) if n]
193
+ if nulls:
194
+ raise SchemaValidationError(
195
+ f"{_DATASET}: {len(nulls)} occupant column(s) contain null activity "
196
+ f"codes: {nulls[:10]}{'...' if len(nulls) > 10 else ''}. A null code is "
197
+ f"treated as 'is_absent' downstream without warning."
198
+ )
199
+
200
+ _require_complete_timeline(df, index_col)
201
+ _require_known_activity_codes(df, occupant_cols)
202
+ return df
203
+
204
+
205
+ def get_occupant_diaries(refresh=False):
206
+ """Return the occupant activity-diary calendar DataFrame (polars).
207
+
208
+ The occupant-diary "book" is a synthetic, time-indexed activity calendar:
209
+ one column per simulated occupant holding an activity-code string per
210
+ timestep, consumed by ``building_eload``'s ``DynamicSimulation`` as the base
211
+ pool of activity profiles that dwellings draw from. Downloaded from
212
+ ``occupant_diaries_latest.parquet`` on GCS on first call.
213
+
214
+ The canonical blob is the 1000-occupant book that ``DynamicParameters``
215
+ uses by default (``occupant_book_1000_pers_*.parquet``). Larger books
216
+ (10k/100k occupants) are separate artifacts staged by the maintainer and
217
+ passed explicitly via ``DynamicParameters.activity_file``; they are not
218
+ served here.
219
+
220
+ Contract enforced on every call (see ``Raises``):
221
+
222
+ * exactly one naive ``Datetime`` timeline column, named ``datetime`` or
223
+ ``__index_level_0__`` (the pandas-written index name the staged blob
224
+ currently carries; ``building_eload`` renames it to ``datetime``);
225
+ * at least one occupant column, and *every* non-timeline column named
226
+ ``occupant_<n>`` -- consumers draw all of them as occupant profiles;
227
+ * every occupant column ``String``-typed and null-free;
228
+ * the timeline a gap-free, ascending, regular grid whose step divides a
229
+ day, spanning exactly one whole calendar year (365 or 366 days);
230
+ * every observed activity code in the documented vocabulary (the flattened
231
+ ``building_eload`` ``DEFAULT_OCCUPANCY_PER_ACTIVITY`` taxonomy, 24 codes).
232
+ The check is a subset test -- a book that does not use every code passes.
233
+
234
+ Vintage note: the blob staged today is a **10-minute** book over leap year
235
+ 2024 (52 704 rows x 1000 occupants). The step is validated generically
236
+ rather than pinned to 10 minutes, but note that ``building_eload``'s
237
+ ``align_activity_diaries`` hardcodes ``intervals_per_day = 144``: restaging
238
+ at another resolution requires a matching change there.
239
+
240
+ Args:
241
+ refresh (bool): force re-download even if the cache is warm.
242
+ Defaults to False.
243
+
244
+ Returns:
245
+ polars.DataFrame: one ``occupant_<i>`` column per occupant (``str``
246
+ activity codes) plus a naive ``Datetime`` timeline column, over one
247
+ full representative calendar year.
248
+
249
+ Raises:
250
+ RemoteNotAvailableError: if the blob is not found in the GCS bucket.
251
+ SchemaValidationError: if the fetched frame violates any clause of the
252
+ contract above, naming the offending column(s) or code(s).
253
+ """
254
+ dest = ensure_blob_cached(
255
+ _BLOB_NAME, refresh=refresh, get_blob_fn=get_blob, download_blob_fn=download_blob
256
+ )
257
+ return _validate(pl.read_parquet(dest))
@@ -0,0 +1,73 @@
1
+ # -*- coding: utf-8 -*-
2
+ """GCS blob lookup: the None-on-missing contract ensure_blob_cached relies on.
3
+
4
+ No network: ``get_client`` is replaced by a fake client whose bucket hands back
5
+ a blob stub with a scripted ``reload()``.
6
+ """
7
+ from unittest.mock import Mock
8
+
9
+ import pytest
10
+ from google.api_core.exceptions import NotFound
11
+
12
+ from buildingdata import gcs
13
+ from buildingdata.exceptions import RemoteNotAvailableError
14
+
15
+
16
+ class _StubBlob:
17
+ def __init__(self, name, reload_error=None, exists=True):
18
+ self.name = name
19
+ self.generation = 7
20
+ self.size = 123
21
+ self._reload_error = reload_error
22
+ self._exists = exists
23
+
24
+ def reload(self):
25
+ if self._reload_error is not None:
26
+ raise self._reload_error
27
+
28
+ def exists(self):
29
+ return self._exists
30
+
31
+
32
+ def _stub_client(monkeypatch, blob):
33
+ bucket = Mock()
34
+ bucket.blob = Mock(return_value=blob)
35
+ client = Mock()
36
+ client.bucket = Mock(return_value=bucket)
37
+ monkeypatch.setattr(gcs, "get_client", lambda: client)
38
+
39
+
40
+ def test_get_blob_returns_the_blob_when_present(monkeypatch):
41
+ blob = _StubBlob("census_latest.parquet")
42
+ _stub_client(monkeypatch, blob)
43
+
44
+ assert gcs.get_blob("census_latest.parquet") is blob
45
+
46
+
47
+ def test_get_blob_returns_none_when_reload_raises_not_found(monkeypatch):
48
+ # Regression: ``reload()`` raises NotFound on a missing object *before*
49
+ # ``exists()`` is reached, so get_blob used to propagate an opaque
50
+ # google.api_core NotFound instead of honouring its documented
51
+ # "or None if not found" contract.
52
+ blob = _StubBlob("not_staged_yet.parquet", reload_error=NotFound("no such object"))
53
+ _stub_client(monkeypatch, blob)
54
+
55
+ assert gcs.get_blob("not_staged_yet.parquet") is None
56
+
57
+
58
+ def test_get_blob_returns_none_when_the_object_does_not_exist(monkeypatch):
59
+ blob = _StubBlob("gone.parquet", exists=False)
60
+ _stub_client(monkeypatch, blob)
61
+
62
+ assert gcs.get_blob("gone.parquet") is None
63
+
64
+
65
+ def test_unstaged_blob_surfaces_as_remote_not_available(monkeypatch):
66
+ # End-to-end consequence of the fix: an accessor whose blob has not been
67
+ # staged yet (get_elecdom, get_enedis_regional) raises the named error its
68
+ # docstring promises, not a raw GCS exception.
69
+ blob = _StubBlob("not_staged_yet.parquet", reload_error=NotFound("no such object"))
70
+ _stub_client(monkeypatch, blob)
71
+
72
+ with pytest.raises(RemoteNotAvailableError, match="not_staged_yet.parquet"):
73
+ gcs.ensure_blob_cached("not_staged_yet.parquet")
@@ -11,6 +11,7 @@ def test_public_callables_are_exposed():
11
11
  "get_diagnosis",
12
12
  "get_districts",
13
13
  "get_gas_network",
14
+ "get_elecdom",
14
15
  "get_elmas",
15
16
  "get_occupant_diaries",
16
17
  "get_ore",
@@ -5,6 +5,7 @@ GCS is never contacted: get_blob/download_blob are replaced per module. The
5
5
  download stub writes a small fixture file to the cache so the real read path
6
6
  (polars/geopandas) and sidecar bookkeeping still execute.
7
7
  """
8
+ from datetime import timedelta
8
9
  from unittest.mock import Mock
9
10
 
10
11
  import geopandas as gpd
@@ -17,6 +18,7 @@ from buildingdata.exceptions import RemoteNotAvailableError, SchemaValidationErr
17
18
  from buildingdata.reference import census as census_mod
18
19
  from buildingdata.reference import diagnosis as diagnosis_mod
19
20
  from buildingdata.reference import districts as districts_mod
21
+ from buildingdata.reference import elecdom as elecdom_mod
20
22
  from buildingdata.reference import elmas as elmas_mod
21
23
  from buildingdata.reference import enedis as enedis_mod
22
24
  from buildingdata.reference import gas_network as gas_mod
@@ -360,21 +362,133 @@ def test_get_elmas_raises_when_blob_absent(monkeypatch):
360
362
  elmas_mod.get_elmas(table="clusters")
361
363
 
362
364
 
365
+ # --- get_elecdom ------------------------------------------------------------
366
+ # Provenance-only accessor: the blob is not staged yet (doc/pending_uploads.md
367
+ # section 5), so these tests build the full documented 3600-row grid.
368
+
369
+ _ELECDOM_END_USES = (
370
+ "Chauffage direct",
371
+ "Ventilation",
372
+ "Froid",
373
+ "Audiovisuel",
374
+ "Informatique/Bureautique",
375
+ "Lavage séchage",
376
+ "Cuisson",
377
+ "Eclairage",
378
+ "Chauffe eau",
379
+ "Autres",
380
+ )
381
+ _ELECDOM_SPECIFIC = {
382
+ "Ventilation",
383
+ "Froid",
384
+ "Audiovisuel",
385
+ "Informatique/Bureautique",
386
+ "Lavage séchage",
387
+ "Eclairage",
388
+ "Autres",
389
+ }
390
+
391
+
392
+ def _elecdom_df():
393
+ rows = []
394
+ for season in elecdom_mod._SEASONS:
395
+ for day_type in elecdom_mod._DAY_TYPES:
396
+ for end_use in _ELECDOM_END_USES:
397
+ for hour in range(24):
398
+ rows.append(
399
+ {
400
+ "period_label": f"{season} ({day_type})",
401
+ "season": season,
402
+ "day_type": day_type,
403
+ "end_use": end_use,
404
+ "is_specific_electricity": end_use in _ELECDOM_SPECIFIC,
405
+ "hour": hour,
406
+ "mean_power_w": 50.0 + hour,
407
+ }
408
+ )
409
+ return pl.DataFrame(rows)
410
+
411
+
412
+ def test_get_elecdom_downloads_then_reads(monkeypatch):
413
+ blob = FakeBlob(elecdom_mod._BLOB_NAME, generation=1)
414
+ _stub_gcs(monkeypatch, elecdom_mod, blob, lambda dest: _elecdom_df().write_parquet(dest))
415
+
416
+ out = elecdom_mod.get_elecdom()
417
+
418
+ assert isinstance(out, pl.DataFrame)
419
+ assert out.height == elecdom_mod._EXPECTED_ROWS
420
+ assert set(out.columns) == set(elecdom_mod._REQUIRED_COLUMNS)
421
+ assert cache.is_cached(elecdom_mod._BLOB_NAME)
422
+
423
+
424
+ def test_get_elecdom_raises_when_blob_absent(monkeypatch):
425
+ # This is the expected outcome until the maintainer stages the blob.
426
+ monkeypatch.setattr(elecdom_mod, "get_blob", lambda name: None)
427
+ with pytest.raises(RemoteNotAvailableError):
428
+ elecdom_mod.get_elecdom()
429
+
430
+
431
+ def test_get_elecdom_raises_on_missing_required_column(monkeypatch):
432
+ bad = _elecdom_df().drop("is_specific_electricity")
433
+ blob = FakeBlob(elecdom_mod._BLOB_NAME, generation=2)
434
+ _stub_gcs(monkeypatch, elecdom_mod, blob, lambda dest: bad.write_parquet(dest))
435
+
436
+ with pytest.raises(SchemaValidationError, match="is_specific_electricity"):
437
+ elecdom_mod.get_elecdom()
438
+
439
+
440
+ def test_get_elecdom_raises_on_wrong_dtype(monkeypatch):
441
+ # French decimal commas surviving the CSV read leave the values as strings.
442
+ bad = _elecdom_df().with_columns(pl.col("mean_power_w").cast(pl.String))
443
+ blob = FakeBlob(elecdom_mod._BLOB_NAME, generation=3)
444
+ _stub_gcs(monkeypatch, elecdom_mod, blob, lambda dest: bad.write_parquet(dest))
445
+
446
+ with pytest.raises(SchemaValidationError, match="mean_power_w"):
447
+ elecdom_mod.get_elecdom()
448
+
449
+
450
+ def test_get_elecdom_raises_on_incomplete_grid(monkeypatch):
451
+ # A dropped period/end-use block is the silent-truncation failure mode.
452
+ bad = _elecdom_df().filter(pl.col("end_use") != "Cuisson")
453
+ blob = FakeBlob(elecdom_mod._BLOB_NAME, generation=4)
454
+ _stub_gcs(monkeypatch, elecdom_mod, blob, lambda dest: bad.write_parquet(dest))
455
+
456
+ with pytest.raises(SchemaValidationError, match="expected exactly 3600"):
457
+ elecdom_mod.get_elecdom()
458
+
459
+
460
+ def test_get_elecdom_raises_on_kilowatt_rescale(monkeypatch):
461
+ # W -> kW leaves every value under 1 and would sail past an upper bound.
462
+ bad = _elecdom_df().with_columns(pl.col("mean_power_w") / 1000.0)
463
+ blob = FakeBlob(elecdom_mod._BLOB_NAME, generation=5)
464
+ _stub_gcs(monkeypatch, elecdom_mod, blob, lambda dest: bad.write_parquet(dest))
465
+
466
+ with pytest.raises(SchemaValidationError, match="kilowatt rescale"):
467
+ elecdom_mod.get_elecdom()
468
+
469
+
363
470
  # --- get_occupant_diaries ---------------------------------------------------
471
+ # The fixture is a full non-leap year at an hourly step (8760 rows x 2
472
+ # occupants): the accessor asserts whole-year coverage, so a 3-row stub is not
473
+ # a valid diary book. The real blob is 10-minute over a leap year; the contract
474
+ # accepts any step that divides a day.
364
475
 
365
- def _occupant_df():
366
- return pl.DataFrame(
367
- {
368
- "occupant_0": ["sleep", "home", "away"],
369
- "occupant_1": ["home", "away", "sleep"],
370
- "datetime": pl.datetime_range(
371
- pl.datetime(2023, 1, 1),
372
- pl.datetime(2023, 1, 1, 2),
373
- interval="1h",
374
- eager=True,
375
- ),
376
- }
476
+ _OCCUPANT_HOURS = 365 * 24
477
+
478
+
479
+ def _occupant_df(index_name="datetime", n_occupants=2, hours=_OCCUPANT_HOURS):
480
+ codes = ["Sleep", "Meal_at_home", "At_work", "Television"]
481
+ data = {
482
+ f"occupant_{i}": [codes[(h + i) % len(codes)] for h in range(hours)]
483
+ for i in range(n_occupants)
484
+ }
485
+ data[index_name] = pl.datetime_range(
486
+ pl.datetime(2023, 1, 1),
487
+ pl.datetime(2023, 1, 1) + pl.duration(hours=hours - 1),
488
+ interval="1h",
489
+ eager=True,
377
490
  )
491
+ return pl.DataFrame(data)
378
492
 
379
493
 
380
494
  def test_get_occupant_diaries_downloads_then_reads(monkeypatch):
@@ -384,16 +498,125 @@ def test_get_occupant_diaries_downloads_then_reads(monkeypatch):
384
498
  out = occupant_mod.get_occupant_diaries()
385
499
 
386
500
  assert isinstance(out, pl.DataFrame)
387
- assert out.height == 3
501
+ assert out.height == _OCCUPANT_HOURS
388
502
  assert cache.is_cached(occupant_mod._BLOB_NAME)
389
503
 
390
504
 
505
+ def test_get_occupant_diaries_accepts_the_pandas_index_column_name(monkeypatch):
506
+ # The blob staged today carries the pandas-written ``__index_level_0__``
507
+ # index name (building_eload renames it to ``datetime`` on read). The
508
+ # validation must not reject the very data it protects.
509
+ blob = FakeBlob(occupant_mod._BLOB_NAME, generation=2)
510
+ _stub_gcs(
511
+ monkeypatch,
512
+ occupant_mod,
513
+ blob,
514
+ lambda dest: _occupant_df(index_name="__index_level_0__").write_parquet(dest),
515
+ )
516
+
517
+ assert occupant_mod.get_occupant_diaries().height == _OCCUPANT_HOURS
518
+
519
+
391
520
  def test_get_occupant_diaries_raises_when_blob_absent(monkeypatch):
392
521
  monkeypatch.setattr(occupant_mod, "get_blob", lambda name: None)
393
522
  with pytest.raises(RemoteNotAvailableError):
394
523
  occupant_mod.get_occupant_diaries()
395
524
 
396
525
 
526
+ def _occupant_case(monkeypatch, frame, generation):
527
+ blob = FakeBlob(occupant_mod._BLOB_NAME, generation=generation)
528
+ _stub_gcs(monkeypatch, occupant_mod, blob, lambda dest: frame.write_parquet(dest))
529
+ return occupant_mod.get_occupant_diaries
530
+
531
+
532
+ def test_get_occupant_diaries_raises_without_a_datetime_column(monkeypatch):
533
+ # Index written as an epoch integer: DwellingProcessor would treat it as an
534
+ # occupant column and align_activity_diaries would crash on .weekday().
535
+ bad = _occupant_df().with_columns(pl.col("datetime").dt.epoch("s"))
536
+ call = _occupant_case(monkeypatch, bad, 11)
537
+ with pytest.raises(SchemaValidationError, match="no Datetime column"):
538
+ call()
539
+
540
+
541
+ def test_get_occupant_diaries_raises_on_misnamed_datetime_column(monkeypatch):
542
+ bad = _occupant_df().rename({"datetime": "timestamp"})
543
+ call = _occupant_case(monkeypatch, bad, 12)
544
+ with pytest.raises(SchemaValidationError, match="timestamp"):
545
+ call()
546
+
547
+
548
+ def test_get_occupant_diaries_raises_on_non_string_occupant_column(monkeypatch):
549
+ # Activity codes arriving as a categorical *code* (int) rather than the
550
+ # string label: joins downstream would silently yield nulls.
551
+ bad = _occupant_df().with_columns(pl.lit(3, dtype=pl.Int64).alias("occupant_1"))
552
+ call = _occupant_case(monkeypatch, bad, 13)
553
+ with pytest.raises(SchemaValidationError, match="occupant_1"):
554
+ call()
555
+
556
+
557
+ def test_get_occupant_diaries_raises_on_stray_non_occupant_column(monkeypatch):
558
+ # Every non-timeline column is drawn as an occupant profile by
559
+ # DwellingProcessor, so a leftover helper column must fail loud.
560
+ bad = _occupant_df().with_columns(pl.lit("Mon").alias("weekday"))
561
+ call = _occupant_case(monkeypatch, bad, 14)
562
+ with pytest.raises(SchemaValidationError, match="weekday"):
563
+ call()
564
+
565
+
566
+ def test_get_occupant_diaries_raises_on_truncated_year(monkeypatch):
567
+ # The silent-incompleteness case: a book one day short still rolls and
568
+ # slices happily downstream, it just yields the wrong calendar.
569
+ bad = _occupant_df().head(_OCCUPANT_HOURS - 24)
570
+ call = _occupant_case(monkeypatch, bad, 15)
571
+ with pytest.raises(SchemaValidationError, match="not a whole year"):
572
+ call()
573
+
574
+
575
+ def test_get_occupant_diaries_raises_on_irregular_timeline(monkeypatch):
576
+ # A gap-then-catch-up keeps the row count right but breaks the grid.
577
+ df = _occupant_df()
578
+ shifted = df["datetime"].to_list()
579
+ shifted[10] = shifted[10] + timedelta(minutes=17)
580
+ bad = df.with_columns(pl.Series("datetime", shifted))
581
+ call = _occupant_case(monkeypatch, bad, 16)
582
+ with pytest.raises(SchemaValidationError, match="not a regular grid"):
583
+ call()
584
+
585
+
586
+ def test_get_occupant_diaries_raises_on_null_activity_code(monkeypatch):
587
+ bad = _occupant_df().with_columns(
588
+ pl.when(pl.int_range(pl.len()) == 5)
589
+ .then(None)
590
+ .otherwise(pl.col("occupant_0"))
591
+ .alias("occupant_0")
592
+ )
593
+ call = _occupant_case(monkeypatch, bad, 17)
594
+ with pytest.raises(SchemaValidationError, match="null activity codes"):
595
+ call()
596
+
597
+
598
+ def test_get_occupant_diaries_raises_on_unknown_activity_code(monkeypatch):
599
+ # An out-of-vocabulary code is mapped to 'is_absent' downstream without any
600
+ # warning, so it is rejected here instead.
601
+ bad = _occupant_df().with_columns(
602
+ pl.when(pl.int_range(pl.len()) == 5)
603
+ .then(pl.lit("Teleworking"))
604
+ .otherwise(pl.col("occupant_0"))
605
+ .alias("occupant_0")
606
+ )
607
+ call = _occupant_case(monkeypatch, bad, 18)
608
+ with pytest.raises(SchemaValidationError, match="Teleworking"):
609
+ call()
610
+
611
+
612
+ def test_get_occupant_diaries_accepts_a_partial_vocabulary(monkeypatch):
613
+ # A book that simply never uses every code is legitimate: the vocabulary
614
+ # check is a subset test, not equality.
615
+ ok = _occupant_df().with_columns(pl.lit("Sleep").alias("occupant_0"))
616
+ call = _occupant_case(monkeypatch, ok, 19)
617
+ assert call().height == _OCCUPANT_HOURS
618
+
619
+
397
620
  # --- get_ore ----------------------------------------------------------------
398
621
 
399
622
  def _ore_df():
@@ -9,8 +9,10 @@ rather than a silent NaN or wrong-scale value deep inside a simulation.
9
9
 
10
10
  The helpers are deliberately backend-agnostic: ``require_columns`` works on
11
11
  anything exposing a ``columns`` sequence (polars DataFrame, pandas/geopandas
12
- DataFrame); ``require_value_range`` targets polars frames, where the value
13
- checks that catch unit swaps live.
12
+ DataFrame); ``require_dtype`` works on anything exposing a ``schema`` mapping
13
+ (polars DataFrame, ``polars.Schema``); ``require_value_range`` targets polars
14
+ frames, where the value checks that catch unit swaps live. None of them import
15
+ polars: dtypes to compare against are supplied by the caller.
14
16
  """
15
17
 
16
18
  from .exceptions import SchemaValidationError
@@ -42,6 +44,54 @@ def require_columns(frame, required, dataset):
42
44
  )
43
45
 
44
46
 
47
+ def require_dtype(frame, columns, dtype, dataset, max_reported=10):
48
+ """Raise if any of ``columns`` does not carry the expected dtype.
49
+
50
+ A column that survives a rename but changes dtype is the quiet half of
51
+ schema drift: an activity code arriving as an integer index, or a float
52
+ measurement arriving as a string, does not raise until far downstream (or
53
+ never -- it just produces nulls after a join). This asserts the dtype at
54
+ load time instead.
55
+
56
+ Args:
57
+ frame: an object exposing a ``schema`` mapping (polars DataFrame or
58
+ ``polars.Schema``), or a name -> dtype mapping itself.
59
+ columns (iterable of str): column names to check.
60
+ dtype: the expected polars dtype, or a tuple of acceptable dtypes.
61
+ Dtype *classes* (``pl.Datetime``) match any parameterisation
62
+ (any time unit / time zone); dtype *instances*
63
+ (``pl.Datetime("us")``) match exactly.
64
+ dataset (str): human-readable dataset name for the error message.
65
+ max_reported (int): cap on how many offending columns are listed in the
66
+ message, so a 1000-column frame does not produce a 1000-entry error.
67
+
68
+ Raises:
69
+ SchemaValidationError: if a column is absent, or carries another dtype.
70
+ """
71
+ schema = getattr(frame, "schema", frame)
72
+ allowed = dtype if isinstance(dtype, tuple) else (dtype,)
73
+ columns = list(columns)
74
+
75
+ missing = [c for c in columns if c not in schema]
76
+ if missing:
77
+ raise SchemaValidationError(
78
+ f"{dataset}: cannot dtype-check absent column(s) {missing[:max_reported]}"
79
+ f"{'...' if len(missing) > max_reported else ''}."
80
+ )
81
+
82
+ bad = [(c, schema[c]) for c in columns if not any(schema[c] == d for d in allowed)]
83
+ if bad:
84
+ shown = ", ".join(f"{c}={t}" for c, t in bad[:max_reported])
85
+ more = f" (and {len(bad) - max_reported} more)" if len(bad) > max_reported else ""
86
+ expected = " or ".join(str(d) for d in allowed)
87
+ raise SchemaValidationError(
88
+ f"{dataset}: {len(bad)} column(s) have the wrong dtype; expected "
89
+ f"{expected}, got {shown}{more}. The upstream file's encoding "
90
+ f"changed (or the wrong vintage was fetched); downstream joins and "
91
+ f"casts on these columns would silently produce nulls."
92
+ )
93
+
94
+
45
95
  def require_value_range(frame, column, low, high, dataset, allow_null=True):
46
96
  """Raise if any value in ``frame[column]`` falls outside ``[low, high]``.
47
97
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: buildingdata
3
- Version: 0.4.0
3
+ Version: 0.5.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
@@ -76,18 +76,25 @@ buildingdata configure --bucket my-bucket --cache-dir ~/.cache/buildingdata
76
76
 
77
77
  ## What it provides
78
78
 
79
- | Function | Source | Returns |
80
- | --- | --- | --- |
81
- | `get_census()` | INSEE census (GCS) | polars `DataFrame` |
82
- | `get_districts()` | IRIS geometries (GCS) | geopandas `GeoDataFrame` |
83
- | `get_diagnosis()` | ADEME energy performance diagnoses (GCS) | polars `DataFrame` |
84
- | `get_gas_network()` | GRDF gas network routes (GCS) | geopandas `GeoDataFrame` |
85
- | `get_bdtopo(iris_code)` | IGN Géoplateforme WFS (live) | geopandas `GeoDataFrame` |
86
- | `get_era5_climate(lat, lon, year)` | Copernicus CDS (live) | path to EPW file |
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 |
87
92
 
88
93
  Reference datasets are pulled from a public Google Cloud Storage bucket and
89
94
  cached locally with generation-based freshness checks. French geospatial data
90
- uses CRS **EPSG:2154 (Lambert-93)**.
95
+ uses CRS **EPSG:2154 (Lambert-93)**. The documentation page *Datasets*
96
+ describes each dataset in detail — provenance, schema, units — and how
97
+ `buildingmodel` / `building_eload` consume it.
91
98
 
92
99
  ## Bulk prefetch for large-scale simulations
93
100
 
@@ -19,6 +19,7 @@ buildingdata/reference/__init__.py
19
19
  buildingdata/reference/census.py
20
20
  buildingdata/reference/diagnosis.py
21
21
  buildingdata/reference/districts.py
22
+ buildingdata/reference/elecdom.py
22
23
  buildingdata/reference/elmas.py
23
24
  buildingdata/reference/enedis.py
24
25
  buildingdata/reference/gas_network.py
@@ -35,6 +36,7 @@ buildingdata/tests/conftest.py
35
36
  buildingdata/tests/test_bulk.py
36
37
  buildingdata/tests/test_cache.py
37
38
  buildingdata/tests/test_config.py
39
+ buildingdata/tests/test_gcs.py
38
40
  buildingdata/tests/test_pipeline_diagnosis.py
39
41
  buildingdata/tests/test_public_api.py
40
42
  buildingdata/tests/test_reference.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "buildingdata"
7
- version = "0.4.0"
7
+ version = "0.5.0"
8
8
  description = "Data management layer for buildingmodel — reference data download, BDTOPO retrieval, ERA5 weather"
9
9
 
10
10
  readme = "README.md"
@@ -1,41 +0,0 @@
1
- # -*- coding: utf-8 -*-
2
- import polars as pl
3
-
4
- from ..gcs import download_blob, ensure_blob_cached, get_blob
5
-
6
- _BLOB_NAME = "occupant_diaries_latest.parquet"
7
-
8
-
9
- def get_occupant_diaries(refresh=False):
10
- """Return the occupant activity-diary calendar DataFrame (polars).
11
-
12
- The occupant-diary "book" is a synthetic, time-indexed activity calendar:
13
- one column per simulated occupant holding an hourly activity-code string,
14
- consumed by ``building_eload``'s ``DynamicSimulation`` as the base pool of
15
- activity profiles that dwellings draw from. Downloaded from
16
- ``occupant_diaries_latest.parquet`` on GCS on first call.
17
-
18
- The canonical blob is the 1000-occupant book that ``DynamicParameters``
19
- uses by default (``occupant_book_1000_pers_*.parquet``). Larger books
20
- (10k/100k occupants) are separate artifacts staged by the maintainer and
21
- passed explicitly via ``DynamicParameters.activity_file``; they are not
22
- served here.
23
-
24
- Args:
25
- refresh (bool): force re-download even if the cache is warm.
26
- Defaults to False.
27
-
28
- Returns:
29
- polars.DataFrame: one ``occupant_<i>`` column per occupant (``str``
30
- activity codes) plus a naive ``Datetime`` index column. Rows are
31
- hourly over one representative calendar year.
32
-
33
- Raises:
34
- RemoteNotAvailableError: if the blob is not found in the GCS bucket.
35
- """
36
- dest = ensure_blob_cached(
37
- _BLOB_NAME, refresh=refresh, get_blob_fn=get_blob, download_blob_fn=download_blob
38
- )
39
- return pl.read_parquet(dest)
40
-
41
-
File without changes
File without changes