buildingdata 0.2.1__tar.gz → 0.2.2__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.1 → buildingdata-0.2.2}/PKG-INFO +1 -1
- {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata/simulation/bdtopo.py +98 -9
- {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata/tests/test_simulation.py +97 -0
- {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata.egg-info/PKG-INFO +1 -1
- {buildingdata-0.2.1 → buildingdata-0.2.2}/pyproject.toml +4 -1
- {buildingdata-0.2.1 → buildingdata-0.2.2}/LICENSE +0 -0
- {buildingdata-0.2.1 → buildingdata-0.2.2}/README.md +0 -0
- {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata/__init__.py +0 -0
- {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata/_cli.py +0 -0
- {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata/cache.py +0 -0
- {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata/config.py +0 -0
- {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata/exceptions.py +0 -0
- {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata/gcs.py +0 -0
- {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata/reference/__init__.py +0 -0
- {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata/reference/census.py +0 -0
- {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata/reference/diagnosis.py +0 -0
- {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata/reference/districts.py +0 -0
- {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata/reference/elmas.py +0 -0
- {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata/reference/enedis.py +0 -0
- {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata/reference/gas_network.py +0 -0
- {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata/reference/occupant_diaries.py +0 -0
- {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata/reference/ore.py +0 -0
- {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata/simulation/__init__.py +0 -0
- {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata/simulation/era5.py +0 -0
- {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata/tests/__init__.py +0 -0
- {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata/tests/conftest.py +0 -0
- {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata/tests/test_cache.py +0 -0
- {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata/tests/test_config.py +0 -0
- {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata/tests/test_public_api.py +0 -0
- {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata/tests/test_reference.py +0 -0
- {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata/validation.py +0 -0
- {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata.egg-info/SOURCES.txt +0 -0
- {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata.egg-info/dependency_links.txt +0 -0
- {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata.egg-info/entry_points.txt +0 -0
- {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata.egg-info/requires.txt +0 -0
- {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata.egg-info/top_level.txt +0 -0
- {buildingdata-0.2.1 → buildingdata-0.2.2}/setup.cfg +0 -0
|
@@ -24,7 +24,19 @@ _REQUIRED_COLUMNS = (
|
|
|
24
24
|
"usage_1",
|
|
25
25
|
)
|
|
26
26
|
|
|
27
|
-
# IGN Géoplateforme WFS endpoint for BDTOPO buildings
|
|
27
|
+
# IGN Géoplateforme WFS endpoint for BDTOPO buildings.
|
|
28
|
+
#
|
|
29
|
+
# WFS 2.0.0 paging contract (confirmed empirically against the live server on
|
|
30
|
+
# 2026-08-04, feature type BDTOPO_V3:batiment, OUTPUTFORMAT json):
|
|
31
|
+
# * The FeatureCollection response carries top-level integer attributes
|
|
32
|
+
# ``numberMatched`` (total features matching the query/BBOX) and
|
|
33
|
+
# ``numberReturned`` (features in *this* page).
|
|
34
|
+
# * With no ``COUNT`` the server silently caps the page at 5000 features
|
|
35
|
+
# (numberReturned=5000 while numberMatched can be far larger) -- the exact
|
|
36
|
+
# silent-truncation trap this loader now pages around.
|
|
37
|
+
# * ``COUNT`` (page size) and ``STARTINDEX`` (0-based offset) are both
|
|
38
|
+
# honoured; consecutive 5000-feature pages have zero id overlap.
|
|
39
|
+
# 5000 is the server's maximum page size, so we page in 5000-feature strides.
|
|
28
40
|
_IGN_BASE_URL = (
|
|
29
41
|
"https://data.geopf.fr/wfs/ows"
|
|
30
42
|
"?SERVICE=WFS"
|
|
@@ -34,10 +46,88 @@ _IGN_BASE_URL = (
|
|
|
34
46
|
"&OUTPUTFORMAT=application/json"
|
|
35
47
|
"&SRSNAME=EPSG:2154"
|
|
36
48
|
"&BBOX={minx},{miny},{maxx},{maxy},urn:ogc:def:crs:EPSG::2154"
|
|
49
|
+
"&COUNT={count}"
|
|
50
|
+
"&STARTINDEX={startindex}"
|
|
37
51
|
)
|
|
38
52
|
|
|
39
53
|
_TIMEOUT = 120 # seconds
|
|
40
54
|
|
|
55
|
+
# Server-side maximum WFS page size. Requesting more is silently capped here.
|
|
56
|
+
_PAGE_SIZE = 5000
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _fetch_all_wfs_features(base_url, iris_code):
|
|
60
|
+
"""Page a BDTOPO WFS GetFeature query to completion.
|
|
61
|
+
|
|
62
|
+
Issues ``GetFeature`` requests with ``COUNT``/``STARTINDEX`` and walks the
|
|
63
|
+
result set until every matching feature has been collected, so a BBOX with
|
|
64
|
+
more than one page (>5000) of buildings is no longer silently truncated.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
base_url (str): URL template already formatted with the BBOX; must still
|
|
68
|
+
contain ``{count}`` and ``{startindex}`` placeholders.
|
|
69
|
+
iris_code (str): IRIS code, for error messages only.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
list[dict]: all GeoJSON features across every page.
|
|
73
|
+
|
|
74
|
+
Raises:
|
|
75
|
+
RemoteNotAvailableError: on a non-200 status, or if the server reports
|
|
76
|
+
more matches than we could retrieve (paging could not complete) --
|
|
77
|
+
truncation must never again pass silently.
|
|
78
|
+
"""
|
|
79
|
+
features = []
|
|
80
|
+
startindex = 0
|
|
81
|
+
number_matched = None
|
|
82
|
+
|
|
83
|
+
# Hard iteration ceiling so a WFS that ignores STARTINDEX (and would return
|
|
84
|
+
# the same first page forever) fails fast instead of looping indefinitely.
|
|
85
|
+
max_pages = 10_000
|
|
86
|
+
for _ in range(max_pages):
|
|
87
|
+
url = base_url.format(count=_PAGE_SIZE, startindex=startindex)
|
|
88
|
+
response = requests.get(url, timeout=_TIMEOUT)
|
|
89
|
+
if response.status_code != 200:
|
|
90
|
+
raise RemoteNotAvailableError(
|
|
91
|
+
f"IGN Géoplateforme returned HTTP {response.status_code} "
|
|
92
|
+
f"for IRIS {iris_code!r} (startindex={startindex})."
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
payload = response.json()
|
|
96
|
+
page = payload.get("features", [])
|
|
97
|
+
features.extend(page)
|
|
98
|
+
|
|
99
|
+
# numberReturned/numberMatched are the WFS 2.0 paging cursor. Fall back
|
|
100
|
+
# to the page length when a (mocked/legacy) response omits them.
|
|
101
|
+
number_returned = payload.get("numberReturned", len(page))
|
|
102
|
+
if payload.get("numberMatched") is not None:
|
|
103
|
+
number_matched = payload["numberMatched"]
|
|
104
|
+
|
|
105
|
+
# Stop once we've drained the result set: a short page, an empty page,
|
|
106
|
+
# or having collected everything the server said it had.
|
|
107
|
+
if number_returned < _PAGE_SIZE or number_returned == 0:
|
|
108
|
+
break
|
|
109
|
+
if number_matched is not None and len(features) >= number_matched:
|
|
110
|
+
break
|
|
111
|
+
|
|
112
|
+
startindex += _PAGE_SIZE
|
|
113
|
+
else:
|
|
114
|
+
raise RemoteNotAvailableError(
|
|
115
|
+
f"IGN Géoplateforme paging for IRIS {iris_code!r} exceeded "
|
|
116
|
+
f"{max_pages} pages; the WFS may be ignoring STARTINDEX."
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
# Defensive truncation guard: if the server advertised more matches than we
|
|
120
|
+
# actually retrieved, surface it loudly rather than returning a partial,
|
|
121
|
+
# under-populated building stock (the original silent-drop bug).
|
|
122
|
+
if number_matched is not None and len(features) < number_matched:
|
|
123
|
+
raise RemoteNotAvailableError(
|
|
124
|
+
f"IGN Géoplateforme reported numberMatched={number_matched} for "
|
|
125
|
+
f"IRIS {iris_code!r} but paging only retrieved {len(features)} "
|
|
126
|
+
f"features -- refusing to return a truncated building stock."
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
return features
|
|
130
|
+
|
|
41
131
|
|
|
42
132
|
def _cache_path(iris_code):
|
|
43
133
|
subdir = ensure_subdir("bdtopo")
|
|
@@ -94,14 +184,13 @@ def get_bdtopo(iris_code, refresh=False):
|
|
|
94
184
|
district_geom = iris_geom.iloc[0].geometry
|
|
95
185
|
bbox = district_geom.bounds # (minx, miny, maxx, maxy)
|
|
96
186
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
features = response.json()["features"]
|
|
187
|
+
# Note ``{count}``/``{startindex}`` are left unformatted here; the
|
|
188
|
+
# paginator fills them in per page.
|
|
189
|
+
base_url = _IGN_BASE_URL.format(
|
|
190
|
+
minx=bbox[0], miny=bbox[1], maxx=bbox[2], maxy=bbox[3],
|
|
191
|
+
count="{count}", startindex="{startindex}",
|
|
192
|
+
)
|
|
193
|
+
features = _fetch_all_wfs_features(base_url, iris_code)
|
|
105
194
|
if not features:
|
|
106
195
|
raise RemoteNotAvailableError(
|
|
107
196
|
f"IGN Géoplateforme returned no buildings for IRIS {iris_code!r}."
|
|
@@ -157,6 +157,103 @@ def test_get_bdtopo_raises_when_no_features(monkeypatch):
|
|
|
157
157
|
bdtopo_mod.get_bdtopo(iris_code)
|
|
158
158
|
|
|
159
159
|
|
|
160
|
+
def test_get_bdtopo_paginates_past_the_5000_feature_cap(monkeypatch):
|
|
161
|
+
"""A dense BBOX returns every page, not the WFS's silent first-page cap.
|
|
162
|
+
|
|
163
|
+
Regression for the silent-truncation bug: the IGN WFS caps an un-paged
|
|
164
|
+
``GetFeature`` at 5000 features while reporting the true total in
|
|
165
|
+
``numberMatched``. We simulate a 12 001-feature result over four pages and
|
|
166
|
+
assert the loader stitches them all together instead of stopping at 5000.
|
|
167
|
+
"""
|
|
168
|
+
iris_code = "641320000"
|
|
169
|
+
total = 12_001 # spans 3 full 5000-pages + a 2001-feature tail
|
|
170
|
+
page_size = bdtopo_mod._PAGE_SIZE
|
|
171
|
+
|
|
172
|
+
def _page(startindex):
|
|
173
|
+
n = max(0, min(page_size, total - startindex))
|
|
174
|
+
feats = [
|
|
175
|
+
{
|
|
176
|
+
"type": "Feature",
|
|
177
|
+
# Inside the _mock_districts box so it survives the
|
|
178
|
+
# downstream polygon-intersection filter.
|
|
179
|
+
"geometry": {"type": "Point", "coordinates": [652000.0, 6862200.0]},
|
|
180
|
+
"properties": {
|
|
181
|
+
"hauteur": 9.0,
|
|
182
|
+
"nombre_de_logements": 4,
|
|
183
|
+
"usage_1": "Résidentiel",
|
|
184
|
+
},
|
|
185
|
+
}
|
|
186
|
+
for _ in range(n)
|
|
187
|
+
]
|
|
188
|
+
return {"numberMatched": total, "numberReturned": n, "features": feats}
|
|
189
|
+
|
|
190
|
+
calls = {"startindices": []}
|
|
191
|
+
|
|
192
|
+
def fake_get(url, timeout=None):
|
|
193
|
+
# STARTINDEX is the last query param in the template.
|
|
194
|
+
startindex = int(url.rsplit("STARTINDEX=", 1)[1])
|
|
195
|
+
calls["startindices"].append(startindex)
|
|
196
|
+
return FakeResponse(200, _page(startindex))
|
|
197
|
+
|
|
198
|
+
monkeypatch.setattr(
|
|
199
|
+
"buildingdata.simulation.bdtopo.get_districts",
|
|
200
|
+
lambda: _mock_districts(iris_code),
|
|
201
|
+
)
|
|
202
|
+
monkeypatch.setattr(bdtopo_mod.requests, "get", fake_get)
|
|
203
|
+
|
|
204
|
+
gdf = bdtopo_mod.get_bdtopo(iris_code)
|
|
205
|
+
|
|
206
|
+
# All pages were walked (offsets 0, 5000, 10000) and every feature kept.
|
|
207
|
+
assert calls["startindices"] == [0, 5000, 10000]
|
|
208
|
+
assert len(gdf) == total # not truncated to page_size
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def test_fetch_all_wfs_features_raises_when_paging_cannot_complete(monkeypatch):
|
|
212
|
+
"""If the server advertises more than we can retrieve, fail loud."""
|
|
213
|
+
def short_page(url, timeout=None):
|
|
214
|
+
# Claims 8000 matches but only ever yields a 5000-feature page, and a
|
|
215
|
+
# short second page -- i.e. paging is broken and truncated.
|
|
216
|
+
startindex = int(url.rsplit("STARTINDEX=", 1)[1])
|
|
217
|
+
n = 5000 if startindex == 0 else 100
|
|
218
|
+
return FakeResponse(
|
|
219
|
+
200,
|
|
220
|
+
{"numberMatched": 8000, "numberReturned": n, "features": [{}] * n},
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
monkeypatch.setattr(bdtopo_mod.requests, "get", short_page)
|
|
224
|
+
with pytest.raises(RemoteNotAvailableError, match="truncated building stock"):
|
|
225
|
+
bdtopo_mod._fetch_all_wfs_features(
|
|
226
|
+
"http://x?BBOX=0,0,1,1&COUNT={count}&STARTINDEX={startindex}", "641320000"
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@pytest.mark.network
|
|
231
|
+
def test_get_bdtopo_live_wfs_returns_full_numbermatched():
|
|
232
|
+
"""Live IGN WFS: a dense IRIS BBOX must return its full ``numberMatched``.
|
|
233
|
+
|
|
234
|
+
Opt-in (needs network): run with ``-m network``. Probes the true match
|
|
235
|
+
count with a COUNT=1 request, then asserts the paginator retrieves all of
|
|
236
|
+
them (>5000, i.e. past the silent cap) for the same BBOX.
|
|
237
|
+
"""
|
|
238
|
+
# A dense BBOX in Lambert-93 (EPSG:2154) known to exceed one 5000-page.
|
|
239
|
+
bbox = "417000,6420000,420000,6423000"
|
|
240
|
+
base = (
|
|
241
|
+
"https://data.geopf.fr/wfs/ows?SERVICE=WFS&VERSION=2.0.0"
|
|
242
|
+
"&REQUEST=GetFeature&TYPENAMES=BDTOPO_V3:batiment"
|
|
243
|
+
"&OUTPUTFORMAT=application/json&SRSNAME=EPSG:2154"
|
|
244
|
+
f"&BBOX={bbox},urn:ogc:def:crs:EPSG::2154"
|
|
245
|
+
)
|
|
246
|
+
import requests as _rq
|
|
247
|
+
|
|
248
|
+
probe = _rq.get(base + "&COUNT=1&STARTINDEX=0", timeout=120).json()
|
|
249
|
+
number_matched = probe["numberMatched"]
|
|
250
|
+
assert number_matched > bdtopo_mod._PAGE_SIZE, "pick a denser BBOX"
|
|
251
|
+
|
|
252
|
+
tmpl = base + "&COUNT={count}&STARTINDEX={startindex}"
|
|
253
|
+
features = bdtopo_mod._fetch_all_wfs_features(tmpl, "dense-bbox")
|
|
254
|
+
assert len(features) == number_matched
|
|
255
|
+
|
|
256
|
+
|
|
160
257
|
# --- get_era5_climate -------------------------------------------------------
|
|
161
258
|
|
|
162
259
|
def test_era5_snap_to_grid_and_coordinate_formatting():
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "buildingdata"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.2"
|
|
8
8
|
description = "Data management layer for buildingmodel — reference data download, BDTOPO retrieval, ERA5 weather"
|
|
9
9
|
|
|
10
10
|
readme = "README.md"
|
|
@@ -53,3 +53,6 @@ exclude = ["pipeline*"]
|
|
|
53
53
|
|
|
54
54
|
[tool.pytest.ini_options]
|
|
55
55
|
testpaths = ["buildingdata/tests"]
|
|
56
|
+
markers = [
|
|
57
|
+
"network: test reaches a live external endpoint (e.g. the IGN WFS); opt-in, deselect with -m 'not network'.",
|
|
58
|
+
]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|