buildingdata 0.2.2__tar.gz → 0.4.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 (47) hide show
  1. {buildingdata-0.2.2 → buildingdata-0.4.0}/PKG-INFO +37 -1
  2. {buildingdata-0.2.2 → buildingdata-0.4.0}/README.md +33 -0
  3. {buildingdata-0.2.2 → buildingdata-0.4.0}/buildingdata/__init__.py +8 -1
  4. buildingdata-0.4.0/buildingdata/_cli.py +172 -0
  5. buildingdata-0.4.0/buildingdata/bulk.py +138 -0
  6. {buildingdata-0.2.2 → buildingdata-0.4.0}/buildingdata/config.py +34 -1
  7. buildingdata-0.4.0/buildingdata/reference/diagnosis.py +108 -0
  8. buildingdata-0.4.0/buildingdata/simulation/__init__.py +5 -0
  9. buildingdata-0.4.0/buildingdata/simulation/_epw.py +231 -0
  10. {buildingdata-0.2.2 → buildingdata-0.4.0}/buildingdata/simulation/bdtopo.py +120 -48
  11. buildingdata-0.4.0/buildingdata/simulation/bdtopo_bulk.py +581 -0
  12. buildingdata-0.4.0/buildingdata/simulation/era5.py +415 -0
  13. buildingdata-0.4.0/buildingdata/simulation/era5_bulk.py +447 -0
  14. buildingdata-0.4.0/buildingdata/tests/test_bulk.py +749 -0
  15. buildingdata-0.4.0/buildingdata/tests/test_pipeline_diagnosis.py +283 -0
  16. {buildingdata-0.2.2 → buildingdata-0.4.0}/buildingdata/tests/test_public_api.py +4 -0
  17. {buildingdata-0.2.2 → buildingdata-0.4.0}/buildingdata/tests/test_reference.py +26 -9
  18. {buildingdata-0.2.2 → buildingdata-0.4.0}/buildingdata.egg-info/PKG-INFO +37 -1
  19. {buildingdata-0.2.2 → buildingdata-0.4.0}/buildingdata.egg-info/SOURCES.txt +6 -0
  20. {buildingdata-0.2.2 → buildingdata-0.4.0}/buildingdata.egg-info/requires.txt +4 -0
  21. {buildingdata-0.2.2 → buildingdata-0.4.0}/pyproject.toml +5 -1
  22. buildingdata-0.2.2/buildingdata/_cli.py +0 -29
  23. buildingdata-0.2.2/buildingdata/reference/diagnosis.py +0 -74
  24. buildingdata-0.2.2/buildingdata/simulation/__init__.py +0 -3
  25. buildingdata-0.2.2/buildingdata/simulation/era5.py +0 -364
  26. {buildingdata-0.2.2 → buildingdata-0.4.0}/LICENSE +0 -0
  27. {buildingdata-0.2.2 → buildingdata-0.4.0}/buildingdata/cache.py +0 -0
  28. {buildingdata-0.2.2 → buildingdata-0.4.0}/buildingdata/exceptions.py +0 -0
  29. {buildingdata-0.2.2 → buildingdata-0.4.0}/buildingdata/gcs.py +0 -0
  30. {buildingdata-0.2.2 → buildingdata-0.4.0}/buildingdata/reference/__init__.py +0 -0
  31. {buildingdata-0.2.2 → buildingdata-0.4.0}/buildingdata/reference/census.py +0 -0
  32. {buildingdata-0.2.2 → buildingdata-0.4.0}/buildingdata/reference/districts.py +0 -0
  33. {buildingdata-0.2.2 → buildingdata-0.4.0}/buildingdata/reference/elmas.py +0 -0
  34. {buildingdata-0.2.2 → buildingdata-0.4.0}/buildingdata/reference/enedis.py +0 -0
  35. {buildingdata-0.2.2 → buildingdata-0.4.0}/buildingdata/reference/gas_network.py +0 -0
  36. {buildingdata-0.2.2 → buildingdata-0.4.0}/buildingdata/reference/occupant_diaries.py +0 -0
  37. {buildingdata-0.2.2 → buildingdata-0.4.0}/buildingdata/reference/ore.py +0 -0
  38. {buildingdata-0.2.2 → buildingdata-0.4.0}/buildingdata/tests/__init__.py +0 -0
  39. {buildingdata-0.2.2 → buildingdata-0.4.0}/buildingdata/tests/conftest.py +0 -0
  40. {buildingdata-0.2.2 → buildingdata-0.4.0}/buildingdata/tests/test_cache.py +0 -0
  41. {buildingdata-0.2.2 → buildingdata-0.4.0}/buildingdata/tests/test_config.py +0 -0
  42. {buildingdata-0.2.2 → buildingdata-0.4.0}/buildingdata/tests/test_simulation.py +0 -0
  43. {buildingdata-0.2.2 → buildingdata-0.4.0}/buildingdata/validation.py +0 -0
  44. {buildingdata-0.2.2 → buildingdata-0.4.0}/buildingdata.egg-info/dependency_links.txt +0 -0
  45. {buildingdata-0.2.2 → buildingdata-0.4.0}/buildingdata.egg-info/entry_points.txt +0 -0
  46. {buildingdata-0.2.2 → buildingdata-0.4.0}/buildingdata.egg-info/top_level.txt +0 -0
  47. {buildingdata-0.2.2 → buildingdata-0.4.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: buildingdata
3
- Version: 0.2.2
3
+ Version: 0.4.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
@@ -19,6 +19,9 @@ Requires-Dist: xarray>=2023.0; extra == "era5"
19
19
  Requires-Dist: pvlib>=0.10; extra == "era5"
20
20
  Requires-Dist: netcdf4>=1.6; extra == "era5"
21
21
  Requires-Dist: zarr>=2.18; extra == "era5"
22
+ Requires-Dist: dask>=2024.1; extra == "era5"
23
+ Provides-Extra: bulk
24
+ Requires-Dist: py7zr>=0.20; extra == "bulk"
22
25
  Provides-Extra: pipeline
23
26
  Requires-Dist: snakemake>=8.0; extra == "pipeline"
24
27
  Requires-Dist: openpyxl>=3.0; extra == "pipeline"
@@ -86,6 +89,38 @@ Reference datasets are pulled from a public Google Cloud Storage bucket and
86
89
  cached locally with generation-based freshness checks. French geospatial data
87
90
  uses CRS **EPSG:2154 (Lambert-93)**.
88
91
 
92
+ ## Bulk prefetch for large-scale simulations
93
+
94
+ The live APIs fetch one IRIS or one grid point at a time. For campaigns over
95
+ thousands of IRIS, prefetch whole years and départements into the local cache
96
+ once, then read them lock-free from any number of parallel workers:
97
+
98
+ ```bash
99
+ buildingdata configure --cds-key YOUR-CDS-KEY # ERA5 needs Copernicus CDS credentials
100
+ buildingdata prefetch era5 --years 2019 # ~1.5 GB/year; one Zarr store over metropolitan France
101
+ buildingdata prefetch bdtopo --departments 75 92 # IGN 7z -> per-département GeoParquet
102
+ buildingdata cache info # sizes + completed bulk partitions
103
+ ```
104
+
105
+ ```python
106
+ import buildingdata as bd
107
+
108
+ # After the prefetch, the same functions read the bulk cache automatically:
109
+ buildings = bd.get_bdtopo("751010101") # source="auto" by default
110
+ frame = bd.get_era5_frame(48.85, 2.35, year=2019) # EPW-format DataFrame, no EPW file
111
+
112
+ # Many IRIS at once — one partition scan + spatial join per département
113
+ frames = bd.get_bdtopo_bulk(["751010101", "751010102", "920020101"])
114
+
115
+ # Or force the local cache (fails fast instead of hitting the network):
116
+ buildings = bd.get_bdtopo("751010101", source="bulk")
117
+ ```
118
+
119
+ BDTOPO bulk prefetch needs the `bulk` extra (`pip install "buildingdata[bulk]"`),
120
+ ERA5 the `era5` extra. See the documentation page *Bulk prefetch for
121
+ large-scale simulations* for source selection (`"auto"`/`"bulk"`/`"wfs"`/`"cds"`),
122
+ disk sizes, concurrency guarantees and BDTOPO vintage notes.
123
+
89
124
  ## Configuration
90
125
 
91
126
  Settings are resolved in the following precedence order:
@@ -119,6 +154,7 @@ Because `~/.config/buildingdata/config.ini` is stored in your home directory, co
119
154
 
120
155
  ```bash
121
156
  pip install "buildingdata[era5]" # ERA5 weather (cdsapi, xarray, pvlib, ...)
157
+ pip install "buildingdata[bulk]" # BDTOPO bulk prefetch (py7zr)
122
158
  pip install "buildingdata[docs]" # build the Sphinx documentation
123
159
  ```
124
160
 
@@ -53,6 +53,38 @@ Reference datasets are pulled from a public Google Cloud Storage bucket and
53
53
  cached locally with generation-based freshness checks. French geospatial data
54
54
  uses CRS **EPSG:2154 (Lambert-93)**.
55
55
 
56
+ ## Bulk prefetch for large-scale simulations
57
+
58
+ The live APIs fetch one IRIS or one grid point at a time. For campaigns over
59
+ thousands of IRIS, prefetch whole years and départements into the local cache
60
+ once, then read them lock-free from any number of parallel workers:
61
+
62
+ ```bash
63
+ buildingdata configure --cds-key YOUR-CDS-KEY # ERA5 needs Copernicus CDS credentials
64
+ buildingdata prefetch era5 --years 2019 # ~1.5 GB/year; one Zarr store over metropolitan France
65
+ buildingdata prefetch bdtopo --departments 75 92 # IGN 7z -> per-département GeoParquet
66
+ buildingdata cache info # sizes + completed bulk partitions
67
+ ```
68
+
69
+ ```python
70
+ import buildingdata as bd
71
+
72
+ # After the prefetch, the same functions read the bulk cache automatically:
73
+ buildings = bd.get_bdtopo("751010101") # source="auto" by default
74
+ frame = bd.get_era5_frame(48.85, 2.35, year=2019) # EPW-format DataFrame, no EPW file
75
+
76
+ # Many IRIS at once — one partition scan + spatial join per département
77
+ frames = bd.get_bdtopo_bulk(["751010101", "751010102", "920020101"])
78
+
79
+ # Or force the local cache (fails fast instead of hitting the network):
80
+ buildings = bd.get_bdtopo("751010101", source="bulk")
81
+ ```
82
+
83
+ BDTOPO bulk prefetch needs the `bulk` extra (`pip install "buildingdata[bulk]"`),
84
+ ERA5 the `era5` extra. See the documentation page *Bulk prefetch for
85
+ large-scale simulations* for source selection (`"auto"`/`"bulk"`/`"wfs"`/`"cds"`),
86
+ disk sizes, concurrency guarantees and BDTOPO vintage notes.
87
+
56
88
  ## Configuration
57
89
 
58
90
  Settings are resolved in the following precedence order:
@@ -86,6 +118,7 @@ Because `~/.config/buildingdata/config.ini` is stored in your home directory, co
86
118
 
87
119
  ```bash
88
120
  pip install "buildingdata[era5]" # ERA5 weather (cdsapi, xarray, pvlib, ...)
121
+ pip install "buildingdata[bulk]" # BDTOPO bulk prefetch (py7zr)
89
122
  pip install "buildingdata[docs]" # build the Sphinx documentation
90
123
  ```
91
124
 
@@ -11,7 +11,14 @@ from .reference import (
11
11
  get_occupant_diaries,
12
12
  get_ore,
13
13
  )
14
- from .simulation import get_bdtopo, get_era5_climate
14
+ from .simulation import (
15
+ get_bdtopo,
16
+ get_bdtopo_bulk,
17
+ get_era5_climate,
18
+ get_era5_frame,
19
+ prefetch_bdtopo,
20
+ prefetch_era5,
21
+ )
15
22
 
16
23
 
17
24
  def configure(bucket=None, cache_dir=None, credentials=None):
@@ -0,0 +1,172 @@
1
+ # -*- coding: utf-8 -*-
2
+ import argparse
3
+
4
+ from .config import write_config
5
+
6
+ # Heavy modules (geopandas, xarray, ...) are imported lazily inside the
7
+ # subcommand handlers, so `buildingdata configure` works even without the
8
+ # era5/bulk extras installed.
9
+
10
+
11
+ def _human_size(num_bytes):
12
+ """Return a human-readable size string for a byte count."""
13
+ size = float(num_bytes)
14
+ for unit in ("B", "KiB", "MiB", "GiB"):
15
+ if size < 1024:
16
+ return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}"
17
+ size /= 1024
18
+ return f"{size:.1f} TiB"
19
+
20
+
21
+ def _dir_size(path):
22
+ """Return the total size in bytes of the files under ``path``."""
23
+ if not path.is_dir():
24
+ return 0
25
+ return sum(p.stat().st_size for p in path.rglob("*") if p.is_file())
26
+
27
+
28
+ def _print_prefetch_summary(results):
29
+ """Print the batch summary of a prefetch run and return the exit code.
30
+
31
+ Args:
32
+ results (dict[str, str]): partition key -> "done" | "skipped" |
33
+ "failed: <error>" as returned by the prefetch functions.
34
+
35
+ Returns:
36
+ int: 1 if any partition failed, else 0.
37
+ """
38
+ done = sum(1 for s in results.values() if s == "done")
39
+ skipped = sum(1 for s in results.values() if s == "skipped")
40
+ failed = {k: v for k, v in results.items() if v.startswith("failed")}
41
+ print(f"Summary: {done} done, {skipped} skipped, {len(failed)} failed")
42
+ for key, message in failed.items():
43
+ print(f" {key}: {message}")
44
+ return 1 if failed else 0
45
+
46
+
47
+ def _cmd_prefetch_era5(args):
48
+ from .simulation.era5_bulk import prefetch_era5
49
+
50
+ results = prefetch_era5(args.years, force=args.force)
51
+ return _print_prefetch_summary(results)
52
+
53
+
54
+ def _cmd_prefetch_bdtopo(args):
55
+ from .simulation.bdtopo_bulk import prefetch_bdtopo
56
+
57
+ departments = None if args.all_departments else args.departments
58
+ results = prefetch_bdtopo(
59
+ departments=departments,
60
+ force=args.force,
61
+ keep_archives=args.keep_archives,
62
+ )
63
+ return _print_prefetch_summary(results)
64
+
65
+
66
+ def _cmd_cache_info():
67
+ from .bulk import read_manifest
68
+ from .config import get_cache_dir
69
+
70
+ cache_dir = get_cache_dir()
71
+ print(f"Cache directory: {cache_dir}")
72
+ print("Disk usage:")
73
+ for subdir in ("era5", "era5/bulk", "bdtopo", "bdtopo/bulk"):
74
+ print(f" {subdir}: {_human_size(_dir_size(cache_dir / subdir))}")
75
+
76
+ era5_years = sorted(
77
+ key
78
+ for key, entry in read_manifest("era5").items()
79
+ if isinstance(entry, dict) and entry.get("status") == "complete"
80
+ )
81
+ print(f"Bulk ERA5 years complete: {', '.join(era5_years) if era5_years else 'none'}")
82
+
83
+ bdtopo_entries = {
84
+ key: entry
85
+ for key, entry in read_manifest("bdtopo").items()
86
+ if isinstance(entry, dict) and entry.get("status") == "complete"
87
+ }
88
+ if bdtopo_entries:
89
+ print(f"Bulk BDTOPO départements complete ({len(bdtopo_entries)}):")
90
+ for dep in sorted(bdtopo_entries):
91
+ vintage = bdtopo_entries[dep].get("vintage", "unknown vintage")
92
+ print(f" {dep}: {vintage}")
93
+ else:
94
+ print("Bulk BDTOPO départements complete: none")
95
+ return 0
96
+
97
+
98
+ def main():
99
+ parser = argparse.ArgumentParser(
100
+ prog="buildingdata",
101
+ description="Configure the buildingdata package and manage its local cache.",
102
+ )
103
+ sub = parser.add_subparsers(dest="command")
104
+
105
+ cfg_parser = sub.add_parser("configure", help="Write ~/.config/buildingdata/config.ini")
106
+ cfg_parser.add_argument("--bucket", default=None, help="GCS bucket name")
107
+ cfg_parser.add_argument("--cache-dir", default=None, dest="cache_dir", help="Local cache directory")
108
+ cfg_parser.add_argument("--credentials", default=None, help="Path to GCS service account JSON")
109
+ cfg_parser.add_argument("--cds-key", default=None, dest="cds_key", help="Copernicus CDS API key")
110
+ cfg_parser.add_argument("--cds-url", default=None, dest="cds_url", help="Copernicus CDS API URL")
111
+
112
+ prefetch_parser = sub.add_parser(
113
+ "prefetch", help="Bulk-download data into the local cache for large-scale simulations"
114
+ )
115
+ prefetch_sub = prefetch_parser.add_subparsers(dest="target")
116
+
117
+ era5_parser = prefetch_sub.add_parser(
118
+ "era5", help="Prefetch France-wide yearly ERA5 stores from the Copernicus CDS"
119
+ )
120
+ era5_parser.add_argument(
121
+ "--years", nargs="+", type=int, required=True, metavar="YEAR", help="Calendar years to prefetch"
122
+ )
123
+ era5_parser.add_argument("--force", action="store_true", help="Rebuild years already complete")
124
+
125
+ bdtopo_parser = prefetch_sub.add_parser(
126
+ "bdtopo", help="Prefetch per-département BDTOPO building partitions from IGN"
127
+ )
128
+ scope = bdtopo_parser.add_mutually_exclusive_group(required=True)
129
+ scope.add_argument(
130
+ "--departments", nargs="+", default=None, metavar="DEP",
131
+ help='Département codes, e.g. 75 92 2A',
132
+ )
133
+ scope.add_argument(
134
+ "--all", action="store_true", dest="all_departments",
135
+ help="All metropolitan départements",
136
+ )
137
+ bdtopo_parser.add_argument("--force", action="store_true", help="Rebuild départements already complete")
138
+ bdtopo_parser.add_argument(
139
+ "--keep-archives", action="store_true", dest="keep_archives",
140
+ help="Keep the downloaded 7z archives after conversion",
141
+ )
142
+
143
+ cache_parser = sub.add_parser("cache", help="Inspect the local cache")
144
+ cache_sub = cache_parser.add_subparsers(dest="action")
145
+ cache_sub.add_parser("info", help="Print cache location, disk usage and bulk partition status")
146
+
147
+ args = parser.parse_args()
148
+
149
+ if args.command == "configure":
150
+ write_config(
151
+ bucket=args.bucket,
152
+ cache_dir=args.cache_dir,
153
+ credentials=args.credentials,
154
+ cds_key=args.cds_key,
155
+ cds_url=args.cds_url,
156
+ )
157
+ print("Configuration saved.")
158
+ return 0
159
+ if args.command == "prefetch":
160
+ if args.target == "era5":
161
+ return _cmd_prefetch_era5(args)
162
+ if args.target == "bdtopo":
163
+ return _cmd_prefetch_bdtopo(args)
164
+ prefetch_parser.print_help()
165
+ return 2
166
+ if args.command == "cache":
167
+ if args.action == "info":
168
+ return _cmd_cache_info()
169
+ cache_parser.print_help()
170
+ return 2
171
+ parser.print_help()
172
+ return 0
@@ -0,0 +1,138 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Shared per-domain JSON manifests for bulk-prefetched cache partitions.
3
+
4
+ Each bulk domain (``era5``, ``bdtopo``) keeps a single manifest file at
5
+ ``<cache>/<domain>/bulk/manifest.json`` describing its finished partitions
6
+ (one entry per year or per departement). The concurrency contract is:
7
+
8
+ - **Reads are lock-free.** The manifest is always published with an atomic
9
+ ``Path.replace``, so any reader sees a consistent snapshot without taking
10
+ a lock.
11
+ - **Writes are serialized** with :func:`buildingdata.cache.file_lock` on a
12
+ ``manifest.lock`` sibling (writers are rare: prefetch runs only).
13
+ - **Ordering rule:** a data partition is finalized (atomic rename) *before*
14
+ its manifest entry is written. A crash between the two only causes
15
+ redundant re-work on the next prefetch, never a partial read.
16
+ """
17
+ import json
18
+ import os
19
+ import uuid
20
+
21
+ from .cache import ensure_subdir, file_lock
22
+
23
+ _SCHEMA_VERSION = 1
24
+
25
+
26
+ def manifest_path(domain):
27
+ """Return the manifest file path for a bulk domain.
28
+
29
+ Creates the ``<cache>/<domain>/bulk`` directory if absent; the manifest
30
+ file itself is only created by :func:`update_manifest`.
31
+
32
+ Args:
33
+ domain (str): bulk domain name, e.g. "era5" or "bdtopo".
34
+
35
+ Returns:
36
+ pathlib.Path: absolute path to ``<cache>/<domain>/bulk/manifest.json``.
37
+ """
38
+ return ensure_subdir(f"{domain}/bulk") / "manifest.json"
39
+
40
+
41
+ def read_manifest(domain):
42
+ """Return the partition entries of a domain manifest, lock-free.
43
+
44
+ Safe to call concurrently with writers: the manifest is only ever
45
+ published via atomic replace, so this either sees the previous or the
46
+ new complete snapshot. A missing or unparsable manifest reads as empty
47
+ (the caller then treats every partition as absent).
48
+
49
+ Args:
50
+ domain (str): bulk domain name, e.g. "era5" or "bdtopo".
51
+
52
+ Returns:
53
+ dict: mapping of partition key (year string, departement code) to
54
+ its entry dict; ``{}`` if the manifest is absent or unparsable.
55
+ """
56
+ path = manifest_path(domain)
57
+ try:
58
+ with open(path) as f:
59
+ data = json.load(f)
60
+ except (OSError, ValueError):
61
+ return {}
62
+ partitions = data.get("partitions") if isinstance(data, dict) else None
63
+ return partitions if isinstance(partitions, dict) else {}
64
+
65
+
66
+ def _write_manifest(path, partitions):
67
+ """Atomically write the manifest file (caller must hold the lock)."""
68
+ payload = {"schema_version": _SCHEMA_VERSION, "partitions": partitions}
69
+ pid_suffix = f".tmp.{os.getpid()}_{uuid.uuid4().hex[:6]}"
70
+ tmp_path = path.with_name(path.name + pid_suffix)
71
+ try:
72
+ with open(tmp_path, "w") as f:
73
+ json.dump(payload, f, indent=2, sort_keys=True)
74
+ tmp_path.replace(path)
75
+ finally:
76
+ if tmp_path.exists():
77
+ tmp_path.unlink(missing_ok=True)
78
+
79
+
80
+ def update_manifest(domain, key, entry):
81
+ """Insert or replace one partition entry in a domain manifest.
82
+
83
+ Takes the domain's ``manifest.lock``, re-reads the manifest under the
84
+ lock, merges the entry and publishes the result atomically, so
85
+ concurrent prefetch processes never lose each other's entries.
86
+
87
+ Per the ordering rule, call this only *after* the partition's data has
88
+ been atomically published.
89
+
90
+ Args:
91
+ domain (str): bulk domain name, e.g. "era5" or "bdtopo".
92
+ key (str): partition key (year string, departement code).
93
+ entry (dict): manifest entry, e.g. ``{"status": "complete", ...}``.
94
+ """
95
+ path = manifest_path(domain)
96
+ lock_file = path.with_name("manifest.lock")
97
+ with file_lock(lock_file):
98
+ partitions = read_manifest(domain)
99
+ partitions[str(key)] = entry
100
+ _write_manifest(path, partitions)
101
+
102
+
103
+ def remove_manifest_entry(domain, key):
104
+ """Remove one partition entry from a domain manifest, if present.
105
+
106
+ Used before rebuilding a partition (``force=True``) so readers never see
107
+ a "complete" entry pointing at data that is being replaced. No-op when
108
+ the key is absent.
109
+
110
+ Args:
111
+ domain (str): bulk domain name, e.g. "era5" or "bdtopo".
112
+ key (str): partition key (year string, departement code).
113
+ """
114
+ path = manifest_path(domain)
115
+ lock_file = path.with_name("manifest.lock")
116
+ with file_lock(lock_file):
117
+ partitions = read_manifest(domain)
118
+ if str(key) not in partitions:
119
+ return
120
+ del partitions[str(key)]
121
+ _write_manifest(path, partitions)
122
+
123
+
124
+ def partition_complete(domain, key):
125
+ """Return True when a partition's manifest entry reports it complete.
126
+
127
+ Lock-free (see :func:`read_manifest`). Callers that need the data itself
128
+ should additionally check that the partition's files exist on disk.
129
+
130
+ Args:
131
+ domain (str): bulk domain name, e.g. "era5" or "bdtopo".
132
+ key (str): partition key (year string, departement code).
133
+
134
+ Returns:
135
+ bool: True if the entry exists with ``"status": "complete"``.
136
+ """
137
+ entry = read_manifest(domain).get(str(key))
138
+ return isinstance(entry, dict) and entry.get("status") == "complete"
@@ -94,14 +94,40 @@ def get_credentials_file():
94
94
  return Path(raw) if raw else None
95
95
 
96
96
 
97
- def write_config(bucket=None, cache_dir=None, credentials=None):
97
+ def get_cds_credentials():
98
+ """Return Copernicus CDS API credentials as a (url, key) tuple.
99
+
100
+ Either element is None when not configured; a (None, None) result means
101
+ cdsapi should fall back to its own ~/.cdsapirc file.
102
+
103
+ Precedence: CDS_API_KEY / CDS_API_URL env vars → config file → None.
104
+
105
+ Returns:
106
+ tuple: (url or None, key or None) for building a cdsapi.Client.
107
+ """
108
+ key = os.environ.get("CDS_API_KEY")
109
+ url = os.environ.get("CDS_API_URL")
110
+ cfg = _read_config()
111
+ if key is None:
112
+ key = cfg.get("cds", "key", fallback=None)
113
+ if url is None:
114
+ url = cfg.get("cds", "url", fallback=None)
115
+ return url, key
116
+
117
+
118
+ def write_config(bucket=None, cache_dir=None, credentials=None, cds_key=None, cds_url=None):
98
119
  """Write or update ~/.config/buildingdata/config.ini.
99
120
 
121
+ Existing sections and values not addressed by the given arguments are
122
+ preserved.
123
+
100
124
  Args:
101
125
  bucket (str, optional): GCS bucket name.
102
126
  cache_dir (str or pathlib.Path, optional): local cache directory.
103
127
  credentials (str or pathlib.Path, optional): path to GCS service
104
128
  account JSON. Pass None to leave unchanged.
129
+ cds_key (str, optional): Copernicus CDS API key ([cds] key).
130
+ cds_url (str, optional): Copernicus CDS API URL ([cds] url).
105
131
  """
106
132
  _CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
107
133
  cfg = _read_config()
@@ -115,5 +141,12 @@ def write_config(bucket=None, cache_dir=None, credentials=None):
115
141
  cfg.set("gcs", "credentials", str(credentials))
116
142
  if cache_dir is not None:
117
143
  cfg.set("cache", "dir", str(cache_dir))
144
+ if cds_key is not None or cds_url is not None:
145
+ if not cfg.has_section("cds"):
146
+ cfg.add_section("cds")
147
+ if cds_key is not None:
148
+ cfg.set("cds", "key", cds_key)
149
+ if cds_url is not None:
150
+ cfg.set("cds", "url", cds_url)
118
151
  with open(_CONFIG_FILE, "w") as f:
119
152
  cfg.write(f)
@@ -0,0 +1,108 @@
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
6
+
7
+
8
+ _BLOB_NAME = "energy_performance_diagnosis_latest.parquet"
9
+
10
+
11
+ # Heating/DHW energies excluded from inference (no meaningful DPE data for coal)
12
+ _EXCLUDED_ENERGIES = ["Charbon"]
13
+
14
+ # Columns this accessor operates on directly (coal filter + dtype casts). If any
15
+ # is absent the raw polars error is opaque; validating up front names the DPE
16
+ # schema drift explicitly. ``heating_system`` + ``region`` are the join keys
17
+ # ``buildingmodel``'s energy-system inference keys on; ``backup_heating_energy``
18
+ # / ``dhw_energy`` carry the fuel labels the coal filter reads.
19
+ _REQUIRED_COLUMNS = (
20
+ "heating_system",
21
+ "region",
22
+ "backup_heating_energy",
23
+ "dhw_energy",
24
+ )
25
+
26
+
27
+ def get_diagnosis(refresh=False):
28
+ """Return the cleaned DPE energy performance diagnosis DataFrame.
29
+
30
+ Downloads energy_performance_diagnosis_latest.parquet from GCS on first
31
+ call. Applies the filtering and type casts that previously lived in
32
+ buildingmodel/io/diagnosis.py so that buildingmodel receives a clean frame.
33
+
34
+ One row is one post-reform DPE record (issued on or after 1 July 2022), not
35
+ one dwelling of the stock: records are matched to buildings by
36
+ ``buildingmodel``, so every column is intensive — a ratio, a U-value, a rate
37
+ or a per-m² quantity — and carries over regardless of the building's size.
38
+
39
+ Args:
40
+ refresh (bool): force re-download even if the cache is warm.
41
+ Defaults to False.
42
+
43
+ Returns:
44
+ polars.DataFrame: DPE records. Beyond the matching keys
45
+ (``construction_year_class``, ``residential_type``,
46
+ ``heating_system``, and the ``district``/``city``/``city_group``/
47
+ ``department``/``region`` geography) the columns group as:
48
+
49
+ * envelope — ``wall_u_value``, ``roof_u_value``, ``floor_u_value``,
50
+ ``wall_window_u_value``, ``wall_window_share``,
51
+ ``envelope_u_value`` (whole-envelope Ubat),
52
+ ``thermal_bridge_linear_loss``, ``thermal_bridge_loss_share``,
53
+ ``air_change_rate``, ``air_permeability``, ``storey_height``,
54
+ ``inertia_class``, ``{wall,roof,floor}_insulation_type``;
55
+ * systems — ``main_heating_energy``, ``backup_heating_energy``,
56
+ ``dhw_energy``, ``heating_mode``/``dhw_mode`` (individual /
57
+ collective / mixed), the ``*_efficiency`` and ``*_scop`` pairs,
58
+ ``intermittency_factor``, ``backup_heating_share``,
59
+ ``dhw_storage_volume``;
60
+ * observed performance — ``energy_class``, ``ghg_class`` and the
61
+ ``annual_*_per_area`` intensities, for calibrating simulated
62
+ output against the diagnosis itself.
63
+
64
+ ``main_heating_system_efficiency`` is a combustion efficiency (≤ 1)
65
+ and is **null for heat pumps**, which have no such value; their
66
+ seasonal performance is in ``main_heating_system_scop`` instead
67
+ (null for every other generator). The same split applies to the
68
+ ``backup_heating_*`` pair. Treating a SCOP as an efficiency
69
+ understates heat-pump performance roughly threefold, hence the two
70
+ columns.
71
+
72
+ Raises:
73
+ RemoteNotAvailableError: if the blob is not found in the GCS bucket.
74
+ SchemaValidationError: if the fetched frame is missing one of the
75
+ columns this accessor operates on (see ``_REQUIRED_COLUMNS``).
76
+ """
77
+ dest = ensure_blob_cached(
78
+ _BLOB_NAME, refresh=refresh, get_blob_fn=get_blob, download_blob_fn=download_blob
79
+ )
80
+
81
+
82
+ df = pl.read_parquet(dest)
83
+
84
+
85
+ require_columns(df, _REQUIRED_COLUMNS, "DPE/EPC diagnosis")
86
+
87
+ # Remove records with coal heating/DHW — no useful inference data.
88
+ # ``fill_null(False)`` is load-bearing: ``backup_heating_energy`` is null for
89
+ # the ~76% of records with no secondary generator, ``is_in`` returns null for
90
+ # those, and ``filter`` drops null rows. Without it this filter keeps only
91
+ # dwellings that happen to own a backup system — a heavily biased subsample —
92
+ # instead of dropping the few hundred coal records it is meant to remove.
93
+ df = df.filter(
94
+ ~pl.col("backup_heating_energy").is_in(_EXCLUDED_ENERGIES).fill_null(False)
95
+ & ~pl.col("dhw_energy").is_in(_EXCLUDED_ENERGIES).fill_null(False)
96
+ )
97
+
98
+ df = df.with_columns([
99
+ pl.col("heating_system").cast(pl.Categorical),
100
+ pl.col("region").cast(pl.Int64),
101
+ ])
102
+
103
+ if "living_area" in df.columns:
104
+ df = df.drop(["living_area"])
105
+ if "living_area_class" in df.columns:
106
+ df = df.drop(["living_area_class"])
107
+
108
+ return df
@@ -0,0 +1,5 @@
1
+ # -*- coding: utf-8 -*-
2
+ from .bdtopo import get_bdtopo
3
+ from .bdtopo_bulk import get_bdtopo_bulk, prefetch_bdtopo
4
+ from .era5 import get_era5_climate, get_era5_frame
5
+ from .era5_bulk import prefetch_era5