buildingdata 0.6.0__tar.gz → 0.7.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.6.0 → buildingdata-0.7.0}/PKG-INFO +1 -1
- {buildingdata-0.6.0 → buildingdata-0.7.0}/buildingdata/simulation/bdtopo.py +53 -7
- {buildingdata-0.6.0 → buildingdata-0.7.0}/buildingdata/simulation/bdtopo_bulk.py +13 -12
- {buildingdata-0.6.0 → buildingdata-0.7.0}/buildingdata/tests/test_bulk.py +77 -1
- {buildingdata-0.6.0 → buildingdata-0.7.0}/buildingdata.egg-info/PKG-INFO +1 -1
- {buildingdata-0.6.0 → buildingdata-0.7.0}/pyproject.toml +1 -1
- {buildingdata-0.6.0 → buildingdata-0.7.0}/LICENSE +0 -0
- {buildingdata-0.6.0 → buildingdata-0.7.0}/README.md +0 -0
- {buildingdata-0.6.0 → buildingdata-0.7.0}/buildingdata/__init__.py +0 -0
- {buildingdata-0.6.0 → buildingdata-0.7.0}/buildingdata/_cli.py +0 -0
- {buildingdata-0.6.0 → buildingdata-0.7.0}/buildingdata/bulk.py +0 -0
- {buildingdata-0.6.0 → buildingdata-0.7.0}/buildingdata/cache.py +0 -0
- {buildingdata-0.6.0 → buildingdata-0.7.0}/buildingdata/config.py +0 -0
- {buildingdata-0.6.0 → buildingdata-0.7.0}/buildingdata/exceptions.py +0 -0
- {buildingdata-0.6.0 → buildingdata-0.7.0}/buildingdata/gcs.py +0 -0
- {buildingdata-0.6.0 → buildingdata-0.7.0}/buildingdata/reference/__init__.py +0 -0
- {buildingdata-0.6.0 → buildingdata-0.7.0}/buildingdata/reference/census.py +0 -0
- {buildingdata-0.6.0 → buildingdata-0.7.0}/buildingdata/reference/diagnosis.py +0 -0
- {buildingdata-0.6.0 → buildingdata-0.7.0}/buildingdata/reference/districts.py +0 -0
- {buildingdata-0.6.0 → buildingdata-0.7.0}/buildingdata/reference/elecdom.py +0 -0
- {buildingdata-0.6.0 → buildingdata-0.7.0}/buildingdata/reference/elmas.py +0 -0
- {buildingdata-0.6.0 → buildingdata-0.7.0}/buildingdata/reference/enedis.py +0 -0
- {buildingdata-0.6.0 → buildingdata-0.7.0}/buildingdata/reference/gas_network.py +0 -0
- {buildingdata-0.6.0 → buildingdata-0.7.0}/buildingdata/reference/occupant_diaries.py +0 -0
- {buildingdata-0.6.0 → buildingdata-0.7.0}/buildingdata/reference/ore.py +0 -0
- {buildingdata-0.6.0 → buildingdata-0.7.0}/buildingdata/simulation/__init__.py +0 -0
- {buildingdata-0.6.0 → buildingdata-0.7.0}/buildingdata/simulation/_epw.py +0 -0
- {buildingdata-0.6.0 → buildingdata-0.7.0}/buildingdata/simulation/era5.py +0 -0
- {buildingdata-0.6.0 → buildingdata-0.7.0}/buildingdata/simulation/era5_bulk.py +0 -0
- {buildingdata-0.6.0 → buildingdata-0.7.0}/buildingdata/tests/__init__.py +0 -0
- {buildingdata-0.6.0 → buildingdata-0.7.0}/buildingdata/tests/conftest.py +0 -0
- {buildingdata-0.6.0 → buildingdata-0.7.0}/buildingdata/tests/test_cache.py +0 -0
- {buildingdata-0.6.0 → buildingdata-0.7.0}/buildingdata/tests/test_config.py +0 -0
- {buildingdata-0.6.0 → buildingdata-0.7.0}/buildingdata/tests/test_gcs.py +0 -0
- {buildingdata-0.6.0 → buildingdata-0.7.0}/buildingdata/tests/test_pipeline_diagnosis.py +0 -0
- {buildingdata-0.6.0 → buildingdata-0.7.0}/buildingdata/tests/test_public_api.py +0 -0
- {buildingdata-0.6.0 → buildingdata-0.7.0}/buildingdata/tests/test_reference.py +0 -0
- {buildingdata-0.6.0 → buildingdata-0.7.0}/buildingdata/tests/test_simulation.py +0 -0
- {buildingdata-0.6.0 → buildingdata-0.7.0}/buildingdata/validation.py +0 -0
- {buildingdata-0.6.0 → buildingdata-0.7.0}/buildingdata.egg-info/SOURCES.txt +0 -0
- {buildingdata-0.6.0 → buildingdata-0.7.0}/buildingdata.egg-info/dependency_links.txt +0 -0
- {buildingdata-0.6.0 → buildingdata-0.7.0}/buildingdata.egg-info/entry_points.txt +0 -0
- {buildingdata-0.6.0 → buildingdata-0.7.0}/buildingdata.egg-info/requires.txt +0 -0
- {buildingdata-0.6.0 → buildingdata-0.7.0}/buildingdata.egg-info/top_level.txt +0 -0
- {buildingdata-0.6.0 → buildingdata-0.7.0}/setup.cfg +0 -0
|
@@ -55,6 +55,47 @@ _TIMEOUT = 120 # seconds
|
|
|
55
55
|
# Server-side maximum WFS page size. Requesting more is silently capped here.
|
|
56
56
|
_PAGE_SIZE = 5000
|
|
57
57
|
|
|
58
|
+
# Minimum share of a footprint's area that must fall inside the IRIS polygon for
|
|
59
|
+
# the footprint to belong to that IRIS. At 0.5 a footprint belongs to whichever
|
|
60
|
+
# IRIS holds most of it -- and, in the measure-zero case of an exactly even
|
|
61
|
+
# split, to both, which is the pre-existing behaviour for a shared border
|
|
62
|
+
# building and the safe side of the tie (a strict > 0.5 would drop it from
|
|
63
|
+
# every IRIS).
|
|
64
|
+
#
|
|
65
|
+
# The BBOX query returns everything around the IRIS, so the result has to be
|
|
66
|
+
# filtered down to the IRIS itself. Filtering on `intersects` keeps every
|
|
67
|
+
# footprint straddling the boundary, and a consumer that attributes each
|
|
68
|
+
# footprint to the district holding most of its area then sees a handful of
|
|
69
|
+
# *neighbouring* district codes in what is meant to be one IRIS's stock --
|
|
70
|
+
# enough, in buildingmodel, for one straddling footprint to import a whole
|
|
71
|
+
# neighbouring IRIS's census housing stock (buildingmodel#55). A footprint
|
|
72
|
+
# belongs to the IRIS holding at least half of it, which is the same rule
|
|
73
|
+
# buildingcalibration's BuildingLoader applies downstream.
|
|
74
|
+
_MEMBERSHIP_THRESHOLD = 0.5
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _filter_to_district(gdf, district_geom):
|
|
78
|
+
"""Keep the footprints the IRIS polygon holds at least half of.
|
|
79
|
+
|
|
80
|
+
Zero-area geometries (points, lines -- test doubles and the occasional
|
|
81
|
+
degenerate BDTOPO record) have no area share to compute, so they fall back
|
|
82
|
+
to plain intersection.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
gdf (geopandas.GeoDataFrame): candidate buildings, EPSG:2154.
|
|
86
|
+
district_geom (shapely geometry): the IRIS polygon, EPSG:2154.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
geopandas.GeoDataFrame: the subset belonging to the IRIS.
|
|
90
|
+
"""
|
|
91
|
+
area = gdf.geometry.area
|
|
92
|
+
inside = gdf.geometry.intersection(district_geom)
|
|
93
|
+
share = inside.area.where(area > 0.0, 0.0) / area.where(area > 0.0, 1.0)
|
|
94
|
+
belongs = (share >= _MEMBERSHIP_THRESHOLD) | (
|
|
95
|
+
(area <= 0.0) & gdf.geometry.intersects(district_geom)
|
|
96
|
+
)
|
|
97
|
+
return gdf[belongs]
|
|
98
|
+
|
|
58
99
|
|
|
59
100
|
def _fetch_all_wfs_features(base_url, iris_code):
|
|
60
101
|
"""Page a BDTOPO WFS GetFeature query to completion.
|
|
@@ -157,8 +198,8 @@ def _fetch_wfs(iris_code, district_geom):
|
|
|
157
198
|
district_geom (shapely geometry): the IRIS polygon (EPSG:2154).
|
|
158
199
|
|
|
159
200
|
Returns:
|
|
160
|
-
geopandas.GeoDataFrame: buildings
|
|
161
|
-
EPSG:2154, raw French attribute names.
|
|
201
|
+
geopandas.GeoDataFrame: the buildings the district polygon holds at
|
|
202
|
+
least half of, EPSG:2154, raw French attribute names.
|
|
162
203
|
|
|
163
204
|
Raises:
|
|
164
205
|
RemoteNotAvailableError: on a non-200 WFS status or an empty result.
|
|
@@ -182,13 +223,18 @@ def _fetch_wfs(iris_code, district_geom):
|
|
|
182
223
|
|
|
183
224
|
require_columns(gdf, _REQUIRED_COLUMNS, f"BDTOPO buildings (IRIS {iris_code})")
|
|
184
225
|
|
|
185
|
-
|
|
186
|
-
return gdf[gdf.geometry.intersects(district_geom)].copy()
|
|
226
|
+
return _filter_to_district(gdf, district_geom).copy()
|
|
187
227
|
|
|
188
228
|
|
|
189
229
|
def get_bdtopo(iris_code, refresh=False, source="auto"):
|
|
190
230
|
"""Return BDTOPO buildings for one IRIS, from the bulk cache or the WFS.
|
|
191
231
|
|
|
232
|
+
A footprint belongs to the IRIS holding at least half of its area, so a
|
|
233
|
+
footprint straddling the boundary no longer carries a neighbouring
|
|
234
|
+
district code into the result. Note this tightened in 0.7.0 (it was a
|
|
235
|
+
plain ``intersects`` before): a cache written by an earlier version still
|
|
236
|
+
holds the straddlers, pass ``refresh=True`` once to rewrite it.
|
|
237
|
+
|
|
192
238
|
The GeoDataFrame is cached as a parquet file under
|
|
193
239
|
~/.cache/buildingdata/bdtopo/bdtopo_iris_<iris_code>.parquet — the same
|
|
194
240
|
cache file whichever source produced it. Column names are the raw French
|
|
@@ -212,8 +258,8 @@ def get_bdtopo(iris_code, refresh=False, source="auto"):
|
|
|
212
258
|
``nombre_de_logements`` a dwelling count.
|
|
213
259
|
|
|
214
260
|
Returns:
|
|
215
|
-
geopandas.GeoDataFrame: BDTOPO buildings
|
|
216
|
-
attribute names preserved.
|
|
261
|
+
geopandas.GeoDataFrame: the BDTOPO buildings the IRIS holds at least
|
|
262
|
+
half of, EPSG:2154, raw French attribute names preserved.
|
|
217
263
|
|
|
218
264
|
Raises:
|
|
219
265
|
ValueError: if ``source`` is not one of "auto", "bulk", "wfs".
|
|
@@ -267,7 +313,7 @@ def get_bdtopo(iris_code, refresh=False, source="auto"):
|
|
|
267
313
|
# Lock-free bulk read; read_department raises CacheError with the
|
|
268
314
|
# prefetch hint when source="bulk" and the partition is missing.
|
|
269
315
|
gdf = bdtopo_bulk.read_department(department, bbox=district_geom.bounds)
|
|
270
|
-
gdf = gdf
|
|
316
|
+
gdf = _filter_to_district(gdf, district_geom).copy().reset_index(drop=True)
|
|
271
317
|
require_columns(gdf, _REQUIRED_COLUMNS, f"BDTOPO buildings (IRIS {iris_code})")
|
|
272
318
|
|
|
273
319
|
with file_lock(lock_file):
|
|
@@ -44,7 +44,7 @@ from ..exceptions import (
|
|
|
44
44
|
RemoteNotAvailableError,
|
|
45
45
|
)
|
|
46
46
|
from ..validation import require_columns
|
|
47
|
-
from .bdtopo import _REQUIRED_COLUMNS
|
|
47
|
+
from .bdtopo import _REQUIRED_COLUMNS, _filter_to_district
|
|
48
48
|
|
|
49
49
|
# IGN Géoplateforme téléchargement endpoints (confirmed live 2026-08-06):
|
|
50
50
|
# * catalog: Atom feed at <resource>?zone=D0XX&page=N, paged via the
|
|
@@ -523,9 +523,11 @@ def get_bdtopo_bulk(iris_codes):
|
|
|
523
523
|
Groups the requested IRIS by département, then per département performs a
|
|
524
524
|
single bbox-pushdown partition read over the codes' combined bounds and a
|
|
525
525
|
single spatial join against all the IRIS polygons — sharing the (costly)
|
|
526
|
-
file scan across the IRIS instead of paying it once per IRIS. A
|
|
527
|
-
|
|
528
|
-
|
|
526
|
+
file scan across the IRIS instead of paying it once per IRIS. A footprint
|
|
527
|
+
belongs to the IRIS holding at least half of its area, so a building
|
|
528
|
+
straddling two requested IRIS appears in the one holding most of it, and
|
|
529
|
+
in both only when the split is exactly even (same semantics as
|
|
530
|
+
:func:`~.bdtopo.get_bdtopo`).
|
|
529
531
|
|
|
530
532
|
Source: local GeoParquet partitions built by :func:`prefetch_bdtopo` from
|
|
531
533
|
the IGN départemental editions (vintage recorded in the bulk manifest);
|
|
@@ -536,9 +538,9 @@ def get_bdtopo_bulk(iris_codes):
|
|
|
536
538
|
départements.
|
|
537
539
|
|
|
538
540
|
Returns:
|
|
539
|
-
dict[str, geopandas.GeoDataFrame]: IRIS code -> buildings
|
|
540
|
-
|
|
541
|
-
WFS path (no join helper columns).
|
|
541
|
+
dict[str, geopandas.GeoDataFrame]: IRIS code -> the buildings that IRIS
|
|
542
|
+
holds at least half of, EPSG:2154, raw French attribute names, same
|
|
543
|
+
schema as the WFS path (no join helper columns).
|
|
542
544
|
|
|
543
545
|
Raises:
|
|
544
546
|
BuildingDataError: if an IRIS code is unknown to the districts layer.
|
|
@@ -569,11 +571,10 @@ def get_bdtopo_bulk(iris_codes):
|
|
|
569
571
|
joined = gpd.sjoin(gdf, join_frame, how="inner", predicate="intersects")
|
|
570
572
|
drop = [c for c in ("_iris", "index_right") if c in joined.columns]
|
|
571
573
|
for code in dep_codes:
|
|
572
|
-
sub = (
|
|
573
|
-
|
|
574
|
-
.
|
|
575
|
-
|
|
576
|
-
)
|
|
574
|
+
sub = joined[joined["_iris"] == code].drop(columns=drop)
|
|
575
|
+
sub = _filter_to_district(
|
|
576
|
+
sub, dep_sel[dep_sel["district"] == code].geometry.iloc[0]
|
|
577
|
+
).reset_index(drop=True)
|
|
577
578
|
require_columns(
|
|
578
579
|
sub, _REQUIRED_COLUMNS, f"BDTOPO bulk buildings (IRIS {code})"
|
|
579
580
|
)
|
|
@@ -530,7 +530,8 @@ def test_get_bdtopo_bulk_splits_multi_iris_and_shares_border_buildings(
|
|
|
530
530
|
assert set(results) == {IRIS_A, IRIS_B}
|
|
531
531
|
assert sorted(results[IRIS_A]["hauteur"]) == [9.0, 12.0]
|
|
532
532
|
assert sorted(results[IRIS_B]["hauteur"]) == [6.0, 12.0]
|
|
533
|
-
# The border building (hauteur=12)
|
|
533
|
+
# The border building (hauteur=12) is split exactly evenly between the two
|
|
534
|
+
# IRIS, so the >= 0.5 membership rule leaves it in both frames.
|
|
534
535
|
for frame in results.values():
|
|
535
536
|
assert 12.0 in set(frame["hauteur"])
|
|
536
537
|
# sjoin helper columns must not leak into the results.
|
|
@@ -539,6 +540,39 @@ def test_get_bdtopo_bulk_splits_multi_iris_and_shares_border_buildings(
|
|
|
539
540
|
assert frame.crs.to_epsg() == 2154
|
|
540
541
|
|
|
541
542
|
|
|
543
|
+
def test_get_bdtopo_bulk_gives_a_border_building_to_the_iris_holding_most_of_it(
|
|
544
|
+
monkeypatch, tmp_path
|
|
545
|
+
):
|
|
546
|
+
"""A footprint 70 % inside IRIS_A must not appear in IRIS_B's stock.
|
|
547
|
+
|
|
548
|
+
Filtering on `intersects` used to hand every straddling footprint to both
|
|
549
|
+
IRIS, which lets a consumer read a neighbouring district code out of what
|
|
550
|
+
is meant to be one IRIS's building stock (buildingmodel#55).
|
|
551
|
+
"""
|
|
552
|
+
frame = gpd.GeoDataFrame(
|
|
553
|
+
{
|
|
554
|
+
"hauteur": [9.0],
|
|
555
|
+
"nombre_de_logements": [4],
|
|
556
|
+
"usage_1": ["Résidentiel"],
|
|
557
|
+
},
|
|
558
|
+
# 100 m wide, 70 m of it on the IRIS_A side of x = 652000.
|
|
559
|
+
geometry=[box(651930, 6860400, 652030, 6860500)],
|
|
560
|
+
crs="EPSG:2154",
|
|
561
|
+
)
|
|
562
|
+
frame.to_parquet(bdtopo_bulk.partition_path(DEP), write_covering_bbox=True)
|
|
563
|
+
bulk.update_manifest(
|
|
564
|
+
"bdtopo", DEP, {"status": "complete", "vintage": "BDTOPO_TEST_EDITION"}
|
|
565
|
+
)
|
|
566
|
+
monkeypatch.setattr(
|
|
567
|
+
"buildingdata.reference.districts.get_districts", _bulk_districts
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
results = bdtopo_bulk.get_bdtopo_bulk([IRIS_A, IRIS_B])
|
|
571
|
+
|
|
572
|
+
assert sorted(results[IRIS_A]["hauteur"]) == [9.0]
|
|
573
|
+
assert results[IRIS_B].empty
|
|
574
|
+
|
|
575
|
+
|
|
542
576
|
def test_get_bdtopo_bulk_rejects_unknown_iris(monkeypatch):
|
|
543
577
|
monkeypatch.setattr(
|
|
544
578
|
"buildingdata.reference.districts.get_districts", _bulk_districts
|
|
@@ -581,6 +615,48 @@ def test_bdtopo_auto_falls_back_to_wfs_without_a_partition(monkeypatch):
|
|
|
581
615
|
assert len(gdf) == 1
|
|
582
616
|
|
|
583
617
|
|
|
618
|
+
def test_bdtopo_wfs_drops_a_footprint_mostly_outside_the_iris(monkeypatch):
|
|
619
|
+
"""The BBOX query reaches past the IRIS; only its own footprints come back.
|
|
620
|
+
|
|
621
|
+
A footprint whose majority lies in the neighbouring IRIS used to be kept by
|
|
622
|
+
the `intersects` filter, and downstream carried that neighbour's district
|
|
623
|
+
code into the frame (buildingmodel#55).
|
|
624
|
+
"""
|
|
625
|
+
monkeypatch.setattr(bdtopo_mod, "get_districts", _bulk_districts)
|
|
626
|
+
collection = {
|
|
627
|
+
"features": [
|
|
628
|
+
{
|
|
629
|
+
"type": "Feature",
|
|
630
|
+
"geometry": {
|
|
631
|
+
"type": "Polygon",
|
|
632
|
+
# 100 m wide, only 30 m of it on the IRIS_A side.
|
|
633
|
+
"coordinates": [
|
|
634
|
+
[
|
|
635
|
+
[651970.0, 6860400.0],
|
|
636
|
+
[652070.0, 6860400.0],
|
|
637
|
+
[652070.0, 6860500.0],
|
|
638
|
+
[651970.0, 6860500.0],
|
|
639
|
+
[651970.0, 6860400.0],
|
|
640
|
+
]
|
|
641
|
+
],
|
|
642
|
+
},
|
|
643
|
+
"properties": {
|
|
644
|
+
"hauteur": 9.0,
|
|
645
|
+
"nombre_de_logements": 4,
|
|
646
|
+
"usage_1": "Résidentiel",
|
|
647
|
+
},
|
|
648
|
+
}
|
|
649
|
+
]
|
|
650
|
+
}
|
|
651
|
+
monkeypatch.setattr(
|
|
652
|
+
bdtopo_mod.requests, "get", Mock(return_value=FakeResponse(200, collection))
|
|
653
|
+
)
|
|
654
|
+
|
|
655
|
+
gdf = bdtopo_mod.get_bdtopo(IRIS_A, source="wfs")
|
|
656
|
+
|
|
657
|
+
assert gdf.empty
|
|
658
|
+
|
|
659
|
+
|
|
584
660
|
def test_bdtopo_source_bulk_without_partition_raises_with_prefetch_hint(monkeypatch):
|
|
585
661
|
monkeypatch.setattr(bdtopo_mod, "get_districts", _bulk_districts)
|
|
586
662
|
with pytest.raises(CacheError, match="prefetch bdtopo"):
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|