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.
Files changed (37) hide show
  1. {buildingdata-0.2.1 → buildingdata-0.2.2}/PKG-INFO +1 -1
  2. {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata/simulation/bdtopo.py +98 -9
  3. {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata/tests/test_simulation.py +97 -0
  4. {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata.egg-info/PKG-INFO +1 -1
  5. {buildingdata-0.2.1 → buildingdata-0.2.2}/pyproject.toml +4 -1
  6. {buildingdata-0.2.1 → buildingdata-0.2.2}/LICENSE +0 -0
  7. {buildingdata-0.2.1 → buildingdata-0.2.2}/README.md +0 -0
  8. {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata/__init__.py +0 -0
  9. {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata/_cli.py +0 -0
  10. {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata/cache.py +0 -0
  11. {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata/config.py +0 -0
  12. {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata/exceptions.py +0 -0
  13. {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata/gcs.py +0 -0
  14. {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata/reference/__init__.py +0 -0
  15. {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata/reference/census.py +0 -0
  16. {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata/reference/diagnosis.py +0 -0
  17. {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata/reference/districts.py +0 -0
  18. {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata/reference/elmas.py +0 -0
  19. {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata/reference/enedis.py +0 -0
  20. {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata/reference/gas_network.py +0 -0
  21. {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata/reference/occupant_diaries.py +0 -0
  22. {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata/reference/ore.py +0 -0
  23. {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata/simulation/__init__.py +0 -0
  24. {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata/simulation/era5.py +0 -0
  25. {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata/tests/__init__.py +0 -0
  26. {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata/tests/conftest.py +0 -0
  27. {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata/tests/test_cache.py +0 -0
  28. {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata/tests/test_config.py +0 -0
  29. {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata/tests/test_public_api.py +0 -0
  30. {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata/tests/test_reference.py +0 -0
  31. {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata/validation.py +0 -0
  32. {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata.egg-info/SOURCES.txt +0 -0
  33. {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata.egg-info/dependency_links.txt +0 -0
  34. {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata.egg-info/entry_points.txt +0 -0
  35. {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata.egg-info/requires.txt +0 -0
  36. {buildingdata-0.2.1 → buildingdata-0.2.2}/buildingdata.egg-info/top_level.txt +0 -0
  37. {buildingdata-0.2.1 → buildingdata-0.2.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: buildingdata
3
- Version: 0.2.1
3
+ Version: 0.2.2
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
@@ -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
- url = _IGN_BASE_URL.format(minx=bbox[0], miny=bbox[1], maxx=bbox[2], maxy=bbox[3])
98
- response = requests.get(url, timeout=_TIMEOUT)
99
- if response.status_code != 200:
100
- raise RemoteNotAvailableError(
101
- f"IGN Géoplateforme returned HTTP {response.status_code} for IRIS {iris_code!r}."
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():
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: buildingdata
3
- Version: 0.2.1
3
+ Version: 0.2.2
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "buildingdata"
7
- version = "0.2.1"
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