fix(generative): parse MultiPolygon parcels by largest contour (#2295)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m7s
CI / backend-tests (pull_request) Successful in 14m41s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m7s
CI / backend-tests (pull_request) Successful in 14m41s
_parse_polygon бросал ParcelGeometryError на MultiPolygon → POST /concepts отдавал 422 на многоконтурных участках (интерактивный §7 «Пересчитать» не работал; таких участков в ЕГРН немало — подвид «Многоконтурный»). Нормализуем в одном месте: крупнейший контур max(geoms, key=area) — паттерн, который буфер-стадия ниже уже использует; сравнение площадей в градусах на этом этапе монотонно (один масштаб-фактор на участок), итоговые площади считаются после репроекции. Пустой MultiPolygon и прочие не-Polygon типы падают как раньше. Polygon-путь байт-в-байт (тест эквивалентности single-contour MultiPolygon == Polygon, 1e-9). Refs #2295
This commit is contained in:
parent
a8b504c3de
commit
22526aa331
2 changed files with 78 additions and 2 deletions
|
|
@ -30,7 +30,7 @@ from dataclasses import dataclass
|
|||
from typing import Any
|
||||
|
||||
from pyproj import CRS, Transformer
|
||||
from shapely.geometry import Polygon, mapping, shape
|
||||
from shapely.geometry import MultiPolygon, Polygon, mapping, shape
|
||||
from shapely.geometry.base import BaseGeometry
|
||||
from shapely.ops import transform as shapely_transform
|
||||
|
||||
|
|
@ -142,6 +142,19 @@ def _parse_polygon(parcel_geojson: dict[str, Any]) -> Polygon:
|
|||
except (KeyError, TypeError, ValueError, AttributeError) as exc:
|
||||
raise ParcelGeometryError(f"cannot parse GeoJSON geometry: {exc}") from exc
|
||||
|
||||
# Многоконтурный участок (MultiPolygon): берём крупнейший контур. У некоторых
|
||||
# КН-участков в Росреестре несколько разрозненных полигонов — концепцию строим
|
||||
# по основному (наибольшему по площади) контуру; остальные обычно вкрапления.
|
||||
if isinstance(geom, MultiPolygon):
|
||||
if geom.is_empty or not geom.geoms:
|
||||
raise ParcelGeometryError("parcel polygon is empty")
|
||||
n_contours = len(geom.geoms)
|
||||
geom = max(geom.geoms, key=lambda g: g.area)
|
||||
logger.info(
|
||||
"parse_parcel: MultiPolygon → крупнейший контур из %d",
|
||||
n_contours,
|
||||
)
|
||||
|
||||
if geom.geom_type != "Polygon":
|
||||
raise ParcelGeometryError(f"expected Polygon, got {geom.geom_type}")
|
||||
if geom.is_empty:
|
||||
|
|
|
|||
|
|
@ -100,13 +100,76 @@ def test_tiny_parcel_rejected_after_setback() -> None:
|
|||
|
||||
|
||||
def test_non_polygon_rejected() -> None:
|
||||
payload = _payload(parcel_geojson={"type": "Point", "coordinates": [60.60, 56.83]})
|
||||
with pytest.raises(ParcelGeometryError):
|
||||
geometry.parse_parcel(payload)
|
||||
|
||||
|
||||
def test_linestring_rejected() -> None:
|
||||
# LineString не имеет площади — прежняя ошибка сохраняется.
|
||||
payload = _payload(
|
||||
parcel_geojson={"type": "Point", "coordinates": [60.60, 56.83]}
|
||||
parcel_geojson={
|
||||
"type": "LineString",
|
||||
"coordinates": [[60.60, 56.830], [60.6045, 56.8328]],
|
||||
}
|
||||
)
|
||||
with pytest.raises(ParcelGeometryError):
|
||||
geometry.parse_parcel(payload)
|
||||
|
||||
|
||||
# Второй, заведомо меньший контур (~90 m x 45 m) поодаль от основного участка.
|
||||
_SMALL_CONTOUR = [
|
||||
[60.610, 56.840],
|
||||
[60.6112, 56.840],
|
||||
[60.6112, 56.8404],
|
||||
[60.610, 56.8404],
|
||||
[60.610, 56.840],
|
||||
]
|
||||
|
||||
|
||||
def test_multipolygon_picks_largest_contour() -> None:
|
||||
# Многоконтурный участок: основной прямоугольник + мелкое вкрапление поодаль.
|
||||
# Берётся крупнейший контур → площадь == площади одиночного основного полигона.
|
||||
multi = _payload(
|
||||
parcel_geojson={
|
||||
"type": "MultiPolygon",
|
||||
"coordinates": [[_PARCEL_COORDS], [_SMALL_CONTOUR]],
|
||||
}
|
||||
)
|
||||
single = _payload(parcel_geojson={"type": "Polygon", "coordinates": [_PARCEL_COORDS]})
|
||||
parcel_multi = geometry.parse_parcel(multi)
|
||||
parcel_single = geometry.parse_parcel(single)
|
||||
# Результат многоконтурного == основному контуру (крупнейший выбран).
|
||||
assert math.isclose(parcel_multi.site_area_sqm, parcel_single.site_area_sqm, rel_tol=1e-6)
|
||||
# Крупнейший контур на масштабе квартала, мелкое вкрапление отброшено.
|
||||
assert parcel_multi.site_area_sqm > 50_000
|
||||
|
||||
|
||||
def test_multipolygon_single_contour_equals_polygon() -> None:
|
||||
# MultiPolygon с одним контуром эквивалентен голому Polygon.
|
||||
multi = _payload(
|
||||
parcel_geojson={
|
||||
"type": "MultiPolygon",
|
||||
"coordinates": [[_PARCEL_COORDS]],
|
||||
}
|
||||
)
|
||||
single = _payload(parcel_geojson={"type": "Polygon", "coordinates": [_PARCEL_COORDS]})
|
||||
parcel_multi = geometry.parse_parcel(multi)
|
||||
parcel_single = geometry.parse_parcel(single)
|
||||
assert math.isclose(parcel_multi.site_area_sqm, parcel_single.site_area_sqm, rel_tol=1e-9)
|
||||
assert math.isclose(
|
||||
parcel_multi.buildable_area_sqm,
|
||||
parcel_single.buildable_area_sqm,
|
||||
rel_tol=1e-9,
|
||||
)
|
||||
|
||||
|
||||
def test_empty_multipolygon_rejected() -> None:
|
||||
payload = _payload(parcel_geojson={"type": "MultiPolygon", "coordinates": []})
|
||||
with pytest.raises(ParcelGeometryError):
|
||||
geometry.parse_parcel(payload)
|
||||
|
||||
|
||||
def test_build_placement_grid_anchors_are_step_aligned() -> None:
|
||||
# Простой метрический квадрат 30x30 м, шаг 10 -> 3x3 = 9 ячеек.
|
||||
square = Polygon([(0, 0), (30, 0), (30, 30), (0, 30)])
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue