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

_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:
bot-backend 2026-07-04 01:56:58 +05:00
parent a8b504c3de
commit 22526aa331
2 changed files with 78 additions and 2 deletions

View file

@ -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:

View file

@ -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)])