feat(site-finder): distance to EKB center + success quartirography recommendation
This commit is contained in:
parent
c31da62e8d
commit
ab0647e4d5
1 changed files with 86 additions and 4 deletions
|
|
@ -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),
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue