From ab0647e4d5bb9e879d8f2674aec6ed3e02639e54 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Mon, 11 May 2026 22:35:21 +0300 Subject: [PATCH] feat(site-finder): distance to EKB center + success quartirography recommendation --- backend/app/api/v1/parcels.py | 90 +++++++++++++++++++++++++++++++++-- 1 file changed, 86 insertions(+), 4 deletions(-) diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index abcfa6df..a996bb30 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -214,11 +214,25 @@ def _fetch_weather_sync(lat: float, lon: float) -> dict | None: return None +# Координаты центра ЕКБ — Площадь 1905 года +EKB_CENTER_LAT: float = 56.838011 +EKB_CENTER_LON: float = 60.597474 + # Эмпирические пороги score для ЕКБ: средний диапазон 15-30, max редко >40. SCORE_THRESHOLDS: dict[str, float] = {"плохо": 5.0, "средне": 15.0, "хорошо": 25.0, "отлично": 40.0} SCORE_MAX_REFERENCE: float = 40.0 +def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + """Расстояние по формуле гаверсинуса между двумя точками (км).""" + earth_r = 6371.0 + phi1, phi2 = math.radians(lat1), math.radians(lat2) + dphi = math.radians(lat2 - lat1) + dlam = math.radians(lon2 - lon1) + a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlam / 2) ** 2 + return 2 * earth_r * math.atan2(math.sqrt(a), math.sqrt(1 - a)) + + def _score_label(s: float) -> str: """Текстовая интерпретация POI-score по эмпирическим порогам ЕКБ.""" if s < SCORE_THRESHOLDS["средне"]: @@ -517,6 +531,17 @@ def analyze_parcel( centroid_lat: float = float(centroid_row["lat"]) if centroid_row else 56.838 centroid_lon: float = float(centroid_row["lon"]) if centroid_row else 60.605 + # 6b) Distance to EKB center + center bonus + dist_to_center_km = _haversine_km(centroid_lat, centroid_lon, EKB_CENTER_LAT, EKB_CENTER_LON) + if dist_to_center_km < 5: + center_bonus = 3.0 + elif dist_to_center_km < 10: + center_bonus = 1.5 + elif dist_to_center_km < 15: + center_bonus = 0.5 + else: + center_bonus = 0.0 + # 7) Noise score — шумовые источники в радиусе 2 км noise_rows = ( db.execute( @@ -789,7 +814,54 @@ def analyze_parcel( logger.warning("zoning query failed for %s: %s", cad_num, e) zoning = None - # 10c) Geology stub — реальные данные требуют ВСЕГЕИ-200/1000 шейпы в PostGIS + # 10c) Success recommendation — топ квартирография по district из v_bucket_success_score + success_recommendation: dict[str, Any] | None = None + if district_row: + try: + success_rows = ( + db.execute( + text(""" + SELECT bucket, success_score, n_deals, avg_price_per_m2, avg_area_m2, + velocity_z, price_z, area_z + FROM v_bucket_success_score + WHERE district_name = :dn + ORDER BY success_score DESC + LIMIT 5 + """), + {"dn": district_row["district_name"]}, + ) + .mappings() + .all() + ) + if success_rows: + success_recommendation = { + "district": district_row["district_name"], + "ranking": [ + { + "bucket": r["bucket"], + "success_score": round(float(r["success_score"]), 2), + "n_deals": int(r["n_deals"]), + "avg_price_per_m2": ( + int(r["avg_price_per_m2"]) if r["avg_price_per_m2"] else None + ), + "avg_area_m2": ( + round(float(r["avg_area_m2"]), 1) if r["avg_area_m2"] else None + ), + } + for r in success_rows + ], + "top_bucket": dict(success_rows[0]) if success_rows else None, + "note": ( + "Топ комнатность по 'успешности' = z-scores: velocity×0.5 + price×0.3 " + "- area×0.2. Min 30 сделок в группе за 24 мес. " + "Используй для квартирографии проекта." + ), + } + except Exception as e: + logger.warning("success_recommendation query failed for %s: %s", cad_num, e) + success_recommendation = None + + # 10d) Geology stub — реальные данные требуют ВСЕГЕИ-200/1000 шейпы в PostGIS karpinsky_url = ( f"https://www.karpinskyinstitute.ru/ru/gisatlas/web-gisatlas/" f"?lat={centroid_lat:.6f}&lon={centroid_lon:.6f}&zoom=12" @@ -811,20 +883,29 @@ def analyze_parcel( "lon": centroid_lon, } + score_final = score + center_bonus + return { "cad_num": cad_num, "source": source, "geom_geojson": json.loads(geom_geojson) if geom_geojson else None, "district": dict(district_row) if district_row else None, - "score": round(score, 2), - "score_label": _score_label(score), + "score": round(score_final, 2), + "score_without_center": round(score, 2), + "score_label": _score_label(score_final), "score_max_reference": SCORE_MAX_REFERENCE, "score_explanation": ( - "Сумма close-distance POI (школы/сады/парки +, трамваи -). " + "Сумма close-distance POI (школы/сады/парки +, трамваи -) + center_bonus. " ">40 = редко, типичный город. центр 15-30." ), "score_breakdown": by_category, "poi_count": len(poi_rows), + "location": { + "distance_to_center_km": round(dist_to_center_km, 2), + "center_bonus": center_bonus, + "ekb_center": {"lat": EKB_CENTER_LAT, "lon": EKB_CENTER_LON}, + "note": "Бонус к score: <5км +3.0, 5-10км +1.5, 10-15км +0.5, >15км 0", + }, "competitors": [dict(c) for c in competitor_rows], "noise": { "score": round(noise_score, 2), @@ -842,6 +923,7 @@ def analyze_parcel( "geotech_risk": _geotech_risk(66, db, geom_wkt), "market_trend": market_trend, "zoning": zoning, + "success_recommendation": success_recommendation, "isochrones_available": bool(settings.openrouteservice_api_key), }