diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 13bac11e..babb03f7 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -5,6 +5,8 @@ from typing import Annotated, Any import httpx from fastapi import APIRouter, Depends, HTTPException, Query +from shapely import wkt as _shp_wkt +from shapely.geometry import Polygon from sqlalchemy import text from sqlalchemy.orm import Session @@ -289,6 +291,165 @@ GEOTECH_BY_REGION: dict[int, dict[str, Any]] = { } +# P1 (#45) — constants for polygon suitability (строительные нормы Свердл/общие +# для ЖК; будут править — храним в одном месте) +_GEOM_MIN_AREA_HA = 0.2 # ниже → area_subscore = 0 (физический минимум) +_GEOM_AREA_COMFORT_HA = 0.3 # рекомендуемая комфортная площадь МКД (recommendation) +_GEOM_AREA_SCORE_FULL_HA = 0.5 # ≥ → area_subscore = 1.0 (premium) +_GEOM_ASPECT_PENALTY_THRESHOLD = 5.0 # выше → вытянутый +_GEOM_ASPECT_PENALTY = 0.3 +_GEOM_CONVEX_PENALTY_THRESHOLD = 0.65 # ниже → изрезанный +_GEOM_CONVEX_PENALTY = 0.3 +# Строительный минимум — physical possibility (под penalty) +_GEOM_MIN_WIDTH_PHYSICAL_M = 30 +_GEOM_NARROW_PENALTY = 0.5 +# Комфорт МКД — recommendation level (помещается типовой корпус 12-16 эт) +_GEOM_MIN_WIDTH_COMFORT_M = 40 +_GEOM_LABEL_MICRO_HA = 0.05 # ниже → label "микро" (комбинируется с penalties) +_GEOM_LABEL_GOOD = 0.7 +_GEOM_LABEL_MEDIUM = 0.4 + + +def _polygon_suitability(geom_wkt: str) -> dict[str, Any]: + """P1 (#45) — physical suitability участка по метрикам shape. + + Метрики: + - area_ha — площадь в гектарах (locally-projected metres via cos(lat)) + - perimeter_m — периметр + - aspect_ratio — длина / ширина минимального ограничивающего прямоугольника + - convex_hull_ratio — площадь / площадь выпуклой оболочки (1.0 = выпуклый, <0.7 изрезанный) + - min_inscribed_rect_dim_m — длина короткой стороны MABR + + Suitability score 0..1 — composite (пороги — см. _GEOM_* константы): + - area_subscore: <_GEOM_MIN_AREA_HA → 0.0, ≥_GEOM_AREA_SCORE_FULL_HA → 1.0, linear + - −_GEOM_ASPECT_PENALTY если aspect_ratio > _GEOM_ASPECT_PENALTY_THRESHOLD + - −_GEOM_CONVEX_PENALTY если convex_hull_ratio < _GEOM_CONVEX_PENALTY_THRESHOLD + - −_GEOM_NARROW_PENALTY если short_side < _GEOM_MIN_WIDTH_PHYSICAL_M + + UI label: микро / подходящий / сложная форма / слабо подходит. Label "микро" + комбинируется с penalties — "микро · узкий" — чтобы пользователь увидел + обе проблемы сразу. + """ + try: + # Парсим WGS84 polygon (shapely imports теперь module-level) + poly = _shp_wkt.loads(geom_wkt) + if poly.is_empty or poly.geom_type not in ("Polygon", "MultiPolygon"): + return {"data_available": False, "note": "Геометрия не Polygon/MultiPolygon"} + + # Берём наибольший компонент для MultiPolygon + if poly.geom_type == "MultiPolygon": + poly = max(poly.geoms, key=lambda g: g.area) + assert isinstance(poly, Polygon) + + # Equirectangular-projection в метры через centroid-anchor. + # На широте ~57° деформация <1% в радиусе 50км (parcel-scale OK). + centroid = poly.centroid + lat_rad = math.radians(centroid.y) + m_per_deg_lon = 111_320.0 * math.cos(lat_rad) + m_per_deg_lat = 110_540.0 + ext = list(poly.exterior.coords) + ext_m = [ + ( + (x - centroid.x) * m_per_deg_lon, + (y - centroid.y) * m_per_deg_lat, + ) + for x, y in ext + ] + poly_m = Polygon(ext_m) + area_m2 = poly_m.area + area_ha = area_m2 / 10_000.0 + perimeter_m = poly_m.length + + # Convex hull ratio + hull = poly_m.convex_hull + convex_hull_ratio = area_m2 / hull.area if hull.area > 0 else 1.0 + + # MABR (minimum area bounding rectangle) → aspect_ratio + short side + try: + mabr = poly_m.minimum_rotated_rectangle + mabr_coords = list(mabr.exterior.coords) + # 4 уникальные точки в MABR (closed ring → 5 points) → две стороны + side_lens: list[float] = [] + for i in range(4): + p1 = mabr_coords[i] + p2 = mabr_coords[i + 1] + side_lens.append(math.hypot(p2[0] - p1[0], p2[1] - p1[1])) + short_side = min(side_lens) + long_side = max(side_lens) + aspect_ratio = long_side / short_side if short_side > 0 else 1.0 + except Exception as mabr_err: + logger.debug("MABR computation failed, falling back to sqrt(area): %s", mabr_err) + short_side = math.sqrt(area_m2) + aspect_ratio = 1.0 + + # Suitability score composite + if area_ha >= _GEOM_AREA_SCORE_FULL_HA: + area_subscore = 1.0 + elif area_ha <= _GEOM_MIN_AREA_HA: + area_subscore = 0.0 + else: + # linear: _GEOM_MIN_AREA_HA → 0, _GEOM_AREA_SCORE_FULL_HA → 1.0 + area_subscore = (area_ha - _GEOM_MIN_AREA_HA) / ( + _GEOM_AREA_SCORE_FULL_HA - _GEOM_MIN_AREA_HA + ) + + suitability = area_subscore + penalties: list[str] = [] + if aspect_ratio > _GEOM_ASPECT_PENALTY_THRESHOLD: + suitability -= _GEOM_ASPECT_PENALTY + penalties.append(f"вытянутый (aspect>{_GEOM_ASPECT_PENALTY_THRESHOLD:.0f})") + if convex_hull_ratio < _GEOM_CONVEX_PENALTY_THRESHOLD: + suitability -= _GEOM_CONVEX_PENALTY + penalties.append(f"изрезанный (convex<{_GEOM_CONVEX_PENALTY_THRESHOLD})") + if short_side < _GEOM_MIN_WIDTH_PHYSICAL_M: + suitability -= _GEOM_NARROW_PENALTY + penalties.append(f"узкий (короткая сторона {short_side:.0f}м)") + suitability = max(0.0, min(1.0, suitability)) + + # Label — combine "микро" с penalties чтобы UI видел всё + is_micro = area_ha < _GEOM_LABEL_MICRO_HA + if suitability >= _GEOM_LABEL_GOOD and not is_micro: + label = "подходящий" + elif is_micro: + # combine с penalties: "микро" + первая penalty (для краткости) + if penalties: + label = f"микро, {penalties[0].split(' (')[0]}" + else: + label = "микро" + elif suitability >= _GEOM_LABEL_MEDIUM: + label = "сложная форма" + else: + label = "слабо подходит" + + return { + "data_available": True, + "area_ha": round(area_ha, 3), + "area_m2": round(area_m2), + "perimeter_m": round(perimeter_m), + "aspect_ratio": round(aspect_ratio, 2), + "convex_hull_ratio": round(convex_hull_ratio, 2), + "min_inscribed_rect_dim_m": round(short_side), + "suitability_score": round(suitability, 2), + "label": label, + "penalties": penalties, + "recommendation": ( + f"Строительный минимум короткой стороны — {_GEOM_MIN_WIDTH_PHYSICAL_M}м, " + f"комфорт типового МКД 12-16 этажей — от {_GEOM_MIN_WIDTH_COMFORT_M}м " + f"и площадь от {_GEOM_AREA_COMFORT_HA} га." + ), + "note": ( + "Оценка по форме участка (Shapely). Учитывает площадь, " + "вытянутость, изрезанность, минимальную ширину MABR." + ), + } + except Exception as e: + logger.warning("polygon suitability failed: %s", e) + return { + "data_available": False, + "note": f"Не удалось проанализировать геометрию: {e}", + } + + # P2 (#46) cost-per-m² sanity filter — кадастровая стоимость иногда # содержит 0/None или экстремальные значения (миллиарды). Пороги выбраны # эмпирически для ЕКБ. @@ -1245,6 +1406,8 @@ def analyze_parcel( "hydrology": hydrology, "utilities": utilities, "geotech_risk": _geotech_risk(66, db, geom_wkt), + # P1 (#45) — physical suitability участка + "geometry_suitability": _polygon_suitability(geom_wkt), # P2 (#46) — соседи-здания + overlap check "neighbors_summary": _neighbors_summary(db, geom_wkt, cad_num), "market_trend": market_trend, diff --git a/frontend/src/components/site-finder/GeometrySuitabilityBlock.tsx b/frontend/src/components/site-finder/GeometrySuitabilityBlock.tsx new file mode 100644 index 00000000..a61103c0 --- /dev/null +++ b/frontend/src/components/site-finder/GeometrySuitabilityBlock.tsx @@ -0,0 +1,196 @@ +"use client"; + +import type { + GeometrySuitability, + GeometrySuitabilityBaseLabel, +} from "@/types/site-finder"; + +interface Props { + data: GeometrySuitability; +} + +const LABEL_COLOR: Record< + GeometrySuitabilityBaseLabel, + { bg: string; fg: string; border: string } +> = { + подходящий: { bg: "#dcfce7", fg: "#15803d", border: "#86efac" }, + "сложная форма": { bg: "#fef9c3", fg: "#a16207", border: "#fde68a" }, + "слабо подходит": { bg: "#fee2e2", fg: "#b91c1c", border: "#fca5a5" }, + микро: { bg: "#fee2e2", fg: "#b91c1c", border: "#fca5a5" }, +}; + +// label может быть combo "микро, узкий" — берём первую часть как ключ для цвета. +function colorForLabel(label: string | undefined) { + if (!label) return LABEL_COLOR["сложная форма"]; + const base = label.split(",")[0].trim() as GeometrySuitabilityBaseLabel; + return LABEL_COLOR[base] ?? LABEL_COLOR["сложная форма"]; +} + +function fmtArea(ha: number | undefined, m2: number | undefined): string { + if (ha !== undefined && ha >= 0.1) { + return `${ha.toFixed(2)} га`; + } + if (m2 !== undefined) { + return `${m2.toLocaleString("ru-RU")} м²`; + } + return "—"; +} + +export function GeometrySuitabilityBlock({ data }: Props) { + if (!data.data_available) { + return ( +