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.
- {buildingdata-0.2.2 → buildingdata-0.4.0}/PKG-INFO +37 -1
- {buildingdata-0.2.2 → buildingdata-0.4.0}/README.md +33 -0
- {buildingdata-0.2.2 → buildingdata-0.4.0}/buildingdata/__init__.py +8 -1
- buildingdata-0.4.0/buildingdata/_cli.py +172 -0
- buildingdata-0.4.0/buildingdata/bulk.py +138 -0
- {buildingdata-0.2.2 → buildingdata-0.4.0}/buildingdata/config.py +34 -1
- buildingdata-0.4.0/buildingdata/reference/diagnosis.py +108 -0
- buildingdata-0.4.0/buildingdata/simulation/__init__.py +5 -0
- buildingdata-0.4.0/buildingdata/simulation/_epw.py +231 -0
- {buildingdata-0.2.2 → buildingdata-0.4.0}/buildingdata/simulation/bdtopo.py +120 -48
- buildingdata-0.4.0/buildingdata/simulation/bdtopo_bulk.py +581 -0
- buildingdata-0.4.0/buildingdata/simulation/era5.py +415 -0
- buildingdata-0.4.0/buildingdata/simulation/era5_bulk.py +447 -0
- buildingdata-0.4.0/buildingdata/tests/test_bulk.py +749 -0
- buildingdata-0.4.0/buildingdata/tests/test_pipeline_diagnosis.py +283 -0
- {buildingdata-0.2.2 → buildingdata-0.4.0}/buildingdata/tests/test_public_api.py +4 -0
- {buildingdata-0.2.2 → buildingdata-0.4.0}/buildingdata/tests/test_reference.py +26 -9
- {buildingdata-0.2.2 → buildingdata-0.4.0}/buildingdata.egg-info/PKG-INFO +37 -1
- {buildingdata-0.2.2 → buildingdata-0.4.0}/buildingdata.egg-info/SOURCES.txt +6 -0
- {buildingdata-0.2.2 → buildingdata-0.4.0}/buildingdata.egg-info/requires.txt +4 -0
- {buildingdata-0.2.2 → buildingdata-0.4.0}/pyproject.toml +5 -1
- buildingdata-0.2.2/buildingdata/_cli.py +0 -29
- buildingdata-0.2.2/buildingdata/reference/diagnosis.py +0 -74
- buildingdata-0.2.2/buildingdata/simulation/__init__.py +0 -3
- buildingdata-0.2.2/buildingdata/simulation/era5.py +0 -364
- {buildingdata-0.2.2 → buildingdata-0.4.0}/LICENSE +0 -0
- {buildingdata-0.2.2 → buildingdata-0.4.0}/buildingdata/cache.py +0 -0
- {buildingdata-0.2.2 → buildingdata-0.4.0}/buildingdata/exceptions.py +0 -0
- {buildingdata-0.2.2 → buildingdata-0.4.0}/buildingdata/gcs.py +0 -0
- {buildingdata-0.2.2 → buildingdata-0.4.0}/buildingdata/reference/__init__.py +0 -0
- {buildingdata-0.2.2 → buildingdata-0.4.0}/buildingdata/reference/census.py +0 -0
- {buildingdata-0.2.2 → buildingdata-0.4.0}/buildingdata/reference/districts.py +0 -0
- {buildingdata-0.2.2 → buildingdata-0.4.0}/buildingdata/reference/elmas.py +0 -0
- {buildingdata-0.2.2 → buildingdata-0.4.0}/buildingdata/reference/enedis.py +0 -0
- {buildingdata-0.2.2 → buildingdata-0.4.0}/buildingdata/reference/gas_network.py +0 -0
- {buildingdata-0.2.2 → buildingdata-0.4.0}/buildingdata/reference/occupant_diaries.py +0 -0
- {buildingdata-0.2.2 → buildingdata-0.4.0}/buildingdata/reference/ore.py +0 -0
- {buildingdata-0.2.2 → buildingdata-0.4.0}/buildingdata/tests/__init__.py +0 -0
- {buildingdata-0.2.2 → buildingdata-0.4.0}/buildingdata/tests/conftest.py +0 -0
- {buildingdata-0.2.2 → buildingdata-0.4.0}/buildingdata/tests/test_cache.py +0 -0
- {buildingdata-0.2.2 → buildingdata-0.4.0}/buildingdata/tests/test_config.py +0 -0
- {buildingdata-0.2.2 → buildingdata-0.4.0}/buildingdata/tests/test_simulation.py +0 -0
- {buildingdata-0.2.2 → buildingdata-0.4.0}/buildingdata/validation.py +0 -0
- {buildingdata-0.2.2 → buildingdata-0.4.0}/buildingdata.egg-info/dependency_links.txt +0 -0
- {buildingdata-0.2.2 → buildingdata-0.4.0}/buildingdata.egg-info/entry_points.txt +0 -0
- {buildingdata-0.2.2 → buildingdata-0.4.0}/buildingdata.egg-info/top_level.txt +0 -0
- {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.
|
|
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
|
|
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
|
|
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
|