diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index babb03f7..7743f5cb 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -1,3 +1,4 @@ +import datetime as _dt import json import logging import math @@ -5,8 +6,6 @@ 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 @@ -272,6 +271,55 @@ _POI_WEIGHTS: dict[str, float] = { "tram_stop": -0.5, # негативный вес — шум / вибрация } +# Человеко-читаемые имена категорий для verbal breakdown (X1). +_POI_CATEGORY_RU: dict[str, str] = { + "school": "Школа", + "kindergarten": "Детсад", + "pharmacy": "Аптека", + "hospital": "Больница", + "shop_mall": "ТЦ", + "shop_supermarket": "Супермаркет", + "shop_small": "Магазин", + "park": "Парк", + "bus_stop": "Автобус", + "metro_stop": "Метро", + "tram_stop": "Трамвай", +} + +# Группировка POI по тематическим эшелонам — для stacked-bar % contribution +# (X1 score breakdown). Расширяй по мере добавления новых категорий. +_POI_GROUP: dict[str, str] = { + "school": "Социалка", + "kindergarten": "Социалка", + "pharmacy": "Социалка", + "hospital": "Социалка", + "shop_mall": "Торговля", + "shop_supermarket": "Торговля", + "shop_small": "Торговля", + "park": "Парки", + "bus_stop": "Транспорт", + "metro_stop": "Транспорт", + "tram_stop": "Шум/трамвай", +} + + +def _verbal_for_poi( + cat: str, + name: str | None, + distance_m: float, + contribution: float, +) -> str: + """Сгенерировать verbal explain для одного POI-вклада. + + Пример: "Школа №125 в 400м — +0.90 баллов". + Для отрицательного вклада (трамваи): "Трамвай Ленина в 80м — −0.46 баллов". + """ + label = _POI_CATEGORY_RU.get(cat, cat) + safe_name = (name or "").strip() + name_part = f" «{safe_name}»" if safe_name and safe_name != "—" else "" + sign = "+" if contribution >= 0 else "−" + return f"{label}{name_part} в {round(distance_m)}м — {sign}{abs(contribution):.2f} баллов" + # Сейсмика по ОСР-2016 карта B (среднее повторяемое за 500 лет). # Добавляй регионы по мере расширения географии продукта. @@ -291,165 +339,6 @@ 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 или экстремальные значения (миллиарды). Пороги выбраны # эмпирически для ЕКБ. @@ -691,8 +580,6 @@ def _compute_confidence( доступны на main. Композитный балл = avg of subscore'ов; caveats — list конкретных проблем для UI ("Нет данных N, score K ненадёжен"). """ - import datetime as _dt - caveats: list[str] = [] subscores: dict[str, float] = {} @@ -728,9 +615,15 @@ def _compute_confidence( if not district_row: caveats.append("Район не определён (вне границ ЕКБ?) — медианные цены недоступны") - # 4) Market trend — есть ли rosreestr_deals - if market_trend and market_trend.get("recent_deals_count"): - n_recent = int(market_trend["recent_deals_count"]) + # 4) Market trend — есть ли rosreestr_deals. + # Guard `int(... or 0)` — recent_deals_count иногда приходит как non-numeric + # из external/legacy paths; без guard int() крашнет 500. + n_recent_raw = (market_trend or {}).get("recent_deals_count") + try: + n_recent = int(n_recent_raw) if n_recent_raw is not None else 0 + except (ValueError, TypeError): + n_recent = 0 + if n_recent > 0: # порог 5 сделок за 6 мес — достаточно для тренда subscores["market_trend"] = min(1.0, n_recent / 10.0) if n_recent < 5: @@ -912,16 +805,20 @@ def analyze_parcel( # 4) Scoring: weighted sum с distance decay score = 0.0 by_category: dict[str, list[dict[str, Any]]] = {} - for p in poi_rows: + # X1 (#47): per-POI breakdown с verbal explain для UI + factors_detailed: list[dict[str, Any]] = [] + for idx, p in enumerate(poi_rows): cat: str = p["category"] w = _POI_WEIGHTS.get(cat, 0.0) # distance decay: 1.0 на 0м, 0.5 на ~500м, ~0 на 1000м - decay = max(0.0, 1.0 - float(p["distance_m"]) / 1000.0) - score += w * decay + distance_m = float(p["distance_m"]) + decay = max(0.0, 1.0 - distance_m / 1000.0) + contribution = w * decay + score += contribution by_category.setdefault(cat, []).append( { "name": p["name"], - "distance_m": round(float(p["distance_m"])), + "distance_m": round(distance_m), "lat": float(p["lat"]) if p["lat"] is not None else None, "lon": float(p["lon"]) if p["lon"] is not None else None, "last_edit": ( @@ -929,6 +826,26 @@ def analyze_parcel( ), } ) + # Skip факторы с нулевым вкладом (POI дальше 1км) — UI шуму не нужен. + if abs(contribution) < 0.01: + continue + factors_detailed.append( + { + # Include idx чтобы избежать React key collision: два POI одной + # категории на одинаково округлённом расстоянии иначе дали бы + # дубль (например, two аптеки 450м в плотном районе). + "factor": f"{cat}_{round(distance_m)}m_{idx}", + "category": cat, + "category_ru": _POI_CATEGORY_RU.get(cat, cat), + "group": _POI_GROUP.get(cat, "Прочее"), + "value": round(distance_m, 1), + "weight": w, + "contribution": round(contribution, 2), + "verbal": _verbal_for_poi(cat, p["name"], distance_m, contribution), + "lat": float(p["lat"]) if p["lat"] is not None else None, + "lon": float(p["lon"]) if p["lon"] is not None else None, + } + ) # 5) Конкуренты в радиусе 3 км из DOM.РФ. # NB: domrf_kn_objects имеет ~3 snapshot per obj_id → DISTINCT ON по @@ -993,6 +910,28 @@ def analyze_parcel( else: center_bonus = 0.0 + # X1 (#47): centrality как отдельный synthetic factor в breakdown. + # NB: для centrality decay не применяется (bonus IS the value), поэтому + # weight=1.0 семантически — "no decay multiplier"; contribution = center_bonus. + if center_bonus > 0: + factors_detailed.append( + { + "factor": f"center_bonus_{round(dist_to_center_km)}km", + "category": "centrality", + "category_ru": "Центральность", + "group": "Локация", + "value": round(dist_to_center_km, 2), + "weight": 1.0, + "contribution": round(center_bonus, 2), + "verbal": ( + f"Близость к центру ЕКБ ({dist_to_center_km:.1f}км) — " + f"+{center_bonus:.2f} баллов" + ), + "lat": None, + "lon": None, + } + ) + # 7) Noise score — шумовые источники в радиусе 2 км noise_rows = ( db.execute( @@ -1357,6 +1296,41 @@ def analyze_parcel( score_final = score + center_bonus + # X1 (#47): расчёт contribution_pct + top-3 / by-group для UI. + # Базис для процентов — сумма абсолютных значений всех факторов; это даёт + # стабильное соотношение независимо от знака и не делится на 0. + abs_total = sum(abs(f["contribution"]) for f in factors_detailed) or 1.0 + for f in factors_detailed: + f["contribution_pct"] = round(100.0 * abs(f["contribution"]) / abs_total, 1) + + factors_sorted = sorted(factors_detailed, key=lambda x: x["contribution"], reverse=True) + # Convention: оба top-list'а отсортированы "dominant first": + # positives → most-positive first (factors_sorted desc → [:3]) + # negatives → most-negative first (sort negatives asc → [:3]) + # Раньше использовался trick [-3:][::-1] на desc-sorted — это давало тот же + # результат для N>=3 negatives, но был неинтуитивно читать; explicit sort asc + # короче и не зависит от тонкостей slicing. + score_top_3_positives = [f for f in factors_sorted if f["contribution"] > 0][:3] + negatives_only = [f for f in factors_sorted if f["contribution"] < 0] + score_top_3_negatives = sorted(negatives_only, key=lambda x: x["contribution"])[:3] + + # By-group totals — для stacked-bar в UI. count это int, contribution* — float. + group_totals: dict[str, dict[str, float | int]] = {} + for f in factors_detailed: + g = group_totals.setdefault( + f["group"], {"contribution": 0.0, "count": 0, "contribution_pct": 0.0} + ) + g["contribution"] += f["contribution"] + g["count"] += 1 + group_abs_total = sum(abs(g["contribution"]) for g in group_totals.values()) or 1.0 + for g_val in group_totals.values(): + g_val["contribution"] = round(g_val["contribution"], 2) + g_val["contribution_pct"] = round(100.0 * abs(g_val["contribution"]) / group_abs_total, 1) + score_by_group = [ + {"group": k, **v} + for k, v in sorted(group_totals.items(), key=lambda kv: -abs(kv[1]["contribution"])) + ] + # X2 (#48): composite confidence + caveats confidence_info = _compute_confidence( source=source, @@ -1384,6 +1358,11 @@ def analyze_parcel( ">40 = редко, типичный город. центр 15-30." ), "score_breakdown": by_category, + # X1 (#47): per-factor контрибуции с verbal explain + top-3 / by-group. + "score_breakdown_detailed": factors_sorted, + "score_top_3_positives": score_top_3_positives, + "score_top_3_negatives": score_top_3_negatives, + "score_by_group": score_by_group, "poi_count": len(poi_rows), "location": { "distance_to_center_km": round(dist_to_center_km, 2), @@ -1406,8 +1385,6 @@ 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 deleted file mode 100644 index a61103c0..00000000 --- a/frontend/src/components/site-finder/GeometrySuitabilityBlock.tsx +++ /dev/null @@ -1,196 +0,0 @@ -"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 ( -
| + Фактор + | ++ Вклад + | ++ % + | +
|---|---|---|
| + {f.verbal} + | += 0 ? "#16a34a" : "#dc2626", + fontVariantNumeric: "tabular-nums", + fontWeight: 600, + }} + > + {fmtContribution(f.contribution)} + | ++ {f.contribution_pct.toFixed(1)}% + | +