Some checks failed
CI / changes (pull_request) Has been cancelled
CI / backend-tests (pull_request) Has been cancelled
CI / frontend-tests (pull_request) Has been cancelled
CI / openapi-codegen-check (pull_request) Has been cancelled
CI / changes (push) Successful in 8s
CI / backend-tests (push) Has been cancelled
CI / frontend-tests (push) Has been cancelled
CI / openapi-codegen-check (push) Has been cancelled
sat_factor = 1 + ((sold_pct-50)/100)*0.30 was computed in 09_macro_and_trend.py, written to district_economics.sat_factor, and fetched in server.py and 10_score_v2.py — but never multiplied into any score. The live market sub-score uses a separate sat_score = min(100, sold_pct*100/70) directly, so sat_factor was dead code that would double-count absorption if ever wired in. - 09_macro_and_trend.py: remove sat_factor computation, ALTER TABLE column, UPDATE binding, and debug print column - 10_score_v2.py: remove sat_factor from SELECT and unpacking - server.py: remove sat_factor variable assignment and from macro_factors response - static/index.html: remove sat_factor documentation row - data/sql/162_drop_district_economics_sat_factor.sql: DROP COLUMN IF EXISTS
1816 lines
84 KiB
Python
1816 lines
84 KiB
Python
"""FastAPI scoring service v2 — fast in-memory POI cache + audience profiles + heatmap.
|
||
|
||
Endpoints:
|
||
GET / — Leaflet UI
|
||
GET /healthz
|
||
GET /api/sites — all sites (parcels + ЖК)
|
||
GET /api/site/{id} — full record
|
||
GET /api/districts — district economics + admin polygons
|
||
GET /api/district-polygons — admin districts as GeoJSON FeatureCollection
|
||
GET /api/macro — mortgage rate, city avg POI, city median price
|
||
GET /api/audiences — preset weight profiles
|
||
GET /api/district-velocity-trend/{district} — 12-month series
|
||
POST /api/analyze — score arbitrary point
|
||
|
||
Key speedup: when point falls inside Ekb bbox, reuse 9479 cached OSM POIs
|
||
instead of hitting Overpass (~30s → <100ms).
|
||
"""
|
||
from __future__ import annotations
|
||
import sqlite3, json, math, pathlib, sys, time
|
||
from typing import Optional
|
||
from fastapi import FastAPI, HTTPException
|
||
from fastapi.staticfiles import StaticFiles
|
||
from fastapi.responses import FileResponse
|
||
from pydantic import BaseModel
|
||
|
||
ROOT = pathlib.Path(__file__).parent
|
||
DB = ROOT / "analysis.db"
|
||
STATIC = ROOT / "static"
|
||
CACHE_OSM = ROOT / "cache" / "overpass_raw.json"
|
||
ADMIN_DISTRICTS = ROOT / "cache" / "ekb_admin_districts.geojson"
|
||
|
||
app = FastAPI(title="GenDesign Parcel Scoring v2", version="0.2")
|
||
|
||
# ---------- Geometry helpers ----------
|
||
|
||
def hav(la1, lo1, la2, lo2):
|
||
R = 6371000
|
||
p1, p2 = math.radians(la1), math.radians(la2)
|
||
dp = math.radians(la2 - la1); dl = math.radians(lo2 - lo1)
|
||
a = math.sin(dp/2)**2 + math.cos(p1)*math.cos(p2)*math.sin(dl/2)**2
|
||
return 2 * R * math.asin(math.sqrt(a))
|
||
|
||
|
||
def point_in_polygon(lat, lon, ring):
|
||
"""Ray casting. ring is list of [lon, lat]."""
|
||
inside = False
|
||
n = len(ring)
|
||
j = n - 1
|
||
for i in range(n):
|
||
xi, yi = ring[i][0], ring[i][1]
|
||
xj, yj = ring[j][0], ring[j][1]
|
||
if ((yi > lat) != (yj > lat)) and (lon < (xj - xi) * (lat - yi) / (yj - yi + 1e-12) + xi):
|
||
inside = not inside
|
||
j = i
|
||
return inside
|
||
|
||
|
||
def point_in_geometry(lat, lon, geom):
|
||
if geom["type"] == "Polygon":
|
||
rings = geom["coordinates"]
|
||
if not point_in_polygon(lat, lon, rings[0]): return False
|
||
for hole in rings[1:]:
|
||
if point_in_polygon(lat, lon, hole): return False
|
||
return True
|
||
if geom["type"] == "MultiPolygon":
|
||
return any(point_in_geometry(lat, lon, {"type":"Polygon","coordinates":poly})
|
||
for poly in geom["coordinates"])
|
||
return False
|
||
|
||
# ---------- Pre-load OSM cache + bbox ----------
|
||
|
||
print("loading osm cache…")
|
||
_t = time.time()
|
||
_osm_data = json.loads(CACHE_OSM.read_text()) if CACHE_OSM.exists() else {}
|
||
# Flatten to list of (cat, lat, lon, name, tags_json)
|
||
_OSM_INDEX = [] # global flat list
|
||
for cat, elems in _osm_data.items():
|
||
for el in elems:
|
||
if el["type"] == "node":
|
||
la, lo = el.get("lat"), el.get("lon")
|
||
else:
|
||
c = el.get("center") or {}; la, lo = c.get("lat"), c.get("lon")
|
||
if la is None: continue
|
||
tags = el.get("tags") or {}
|
||
_OSM_INDEX.append((cat, la, lo, tags.get("name") or tags.get("operator") or "",
|
||
el["type"], el["id"]))
|
||
print(f" loaded {len(_OSM_INDEX)} POIs in {time.time()-_t:.2f}s")
|
||
|
||
# Bbox of cache (for routing decisions)
|
||
_lats = [r[1] for r in _OSM_INDEX]
|
||
_lons = [r[2] for r in _OSM_INDEX]
|
||
EKB_BBOX = (min(_lats), min(_lons), max(_lats), max(_lons)) if _lats else (0,0,0,0)
|
||
print(f" bbox: {EKB_BBOX}")
|
||
|
||
# Admin districts polygons
|
||
ADMIN_GEOM = json.loads(ADMIN_DISTRICTS.read_text()) if ADMIN_DISTRICTS.exists() else {"features":[]}
|
||
print(f" admin districts: {len(ADMIN_GEOM.get('features',[]))}")
|
||
|
||
|
||
def find_admin_district(lat, lon):
|
||
for f in ADMIN_GEOM.get("features", []):
|
||
if point_in_geometry(lat, lon, f["geometry"]):
|
||
return f["properties"]["name"]
|
||
return None
|
||
|
||
# ---------- DB ----------
|
||
|
||
def _conn():
|
||
c = sqlite3.connect(DB); c.row_factory = sqlite3.Row; return c
|
||
|
||
# ---------- Audience profiles ----------
|
||
|
||
AUDIENCES = {
|
||
"balanced": {"label": "Сбалансированный",
|
||
"education": 0.18, "health": 0.10, "retail": 0.13,
|
||
"transit": 0.15, "leisure": 0.09, "economic": 0.30, "market": 0.05},
|
||
"family": {"label": "Семейный (садики, школы, парки)",
|
||
"education": 0.30, "health": 0.12, "retail": 0.10,
|
||
"transit": 0.10, "leisure": 0.13, "economic": 0.20, "market": 0.05},
|
||
"premium": {"label": "Премиум (цена, топ-локация)",
|
||
"education": 0.10, "health": 0.10, "retail": 0.10,
|
||
"transit": 0.10, "leisure": 0.15, "economic": 0.40, "market": 0.05},
|
||
"economy": {"label": "Эконом (транспорт, базовое)",
|
||
"education": 0.15, "health": 0.10, "retail": 0.20,
|
||
"transit": 0.25, "leisure": 0.05, "economic": 0.20, "market": 0.05},
|
||
"investor": {"label": "Инвестор (скорость, ликвидность)",
|
||
"education": 0.10, "health": 0.08, "retail": 0.08,
|
||
"transit": 0.12, "leisure": 0.05, "economic": 0.45, "market": 0.12},
|
||
}
|
||
|
||
# ---------- Scoring ----------
|
||
|
||
EDU = [("kindergarten", 300, 1000, 1.0), ("school", 400, 1500, 1.0), ("university", 1000, 5000, 0.3)]
|
||
HEALTH = [("pharmacy", 300, 1000, 1.0), ("clinic", 500, 2000, 1.0), ("hospital", 1500, 5000, 0.5)]
|
||
TRANSIT_M = [("metro", 1000, 3000, 1.0)]
|
||
TRANSIT_T = [("tram_stop", 400, 1500, 1.0)]
|
||
TRANSIT_B = [("bus_stop", 200, 800, 1.0)]
|
||
LEISURE = [("park", 500, 2000, 1.0), ("playground", 200, 700, 1.0), ("sports", 500, 2000, 0.7)]
|
||
|
||
|
||
def dist_score(d_m, ideal, mx):
|
||
if d_m is None: return 0.0
|
||
if d_m <= ideal: return 100.0
|
||
if d_m >= mx: return 0.0
|
||
return 100.0 * (mx - d_m) / (mx - ideal)
|
||
|
||
|
||
def comp_score(distances_by_cat, cats):
|
||
tw = sum(c[3] for c in cats); s = 0
|
||
for cat, ideal, mx, w in cats:
|
||
d = distances_by_cat.get(cat)
|
||
s += w * dist_score(d, ideal, mx)
|
||
return s / tw if tw else 0
|
||
|
||
|
||
def in_bbox(lat, lon, padding=0.005):
|
||
return (EKB_BBOX[0] - padding <= lat <= EKB_BBOX[2] + padding and
|
||
EKB_BBOX[1] - padding <= lon <= EKB_BBOX[3] + padding)
|
||
|
||
|
||
def fetch_pois_local(lat, lon, radius_m=2500):
|
||
"""Fast: scan in-memory OSM cache, return dict cat -> list of (dist, lat, lon, name)."""
|
||
by_cat = {}
|
||
for cat, la, lo, name, *_ in _OSM_INDEX:
|
||
d = hav(lat, lon, la, lo)
|
||
if d <= radius_m:
|
||
by_cat.setdefault(cat, []).append((d, la, lo, name))
|
||
for cat in by_cat:
|
||
by_cat[cat].sort()
|
||
return by_cat
|
||
|
||
|
||
def fetch_pois_overpass(lat, lon, radius_m=2500):
|
||
"""Fallback: live Overpass query (slow, used outside Ekb bbox)."""
|
||
import requests
|
||
POI_QUERIES = [
|
||
("kindergarten", '["amenity"="kindergarten"]'),
|
||
("school", '["amenity"="school"]'),
|
||
("university", '["amenity"~"^(university|college)$"]'),
|
||
("pharmacy", '["amenity"="pharmacy"]'),
|
||
("clinic", '["amenity"~"^(clinic|doctors)$"]'),
|
||
("hospital", '["amenity"="hospital"]'),
|
||
("shop_big", '["shop"~"^(mall|supermarket|department_store|hypermarket)$"]'),
|
||
("shop_med", '["shop"~"^(convenience|grocery|bakery)$"]'),
|
||
("shop_small", '["shop"~"^(kiosk|newsagent)$"]'),
|
||
("bus_stop", '["highway"="bus_stop"]'),
|
||
("tram_stop", '["railway"="tram_stop"]'),
|
||
("metro", '["station"="subway"]'),
|
||
("park", '["leisure"~"^(park|garden)$"]'),
|
||
("playground", '["leisure"="playground"]'),
|
||
("sports", '["leisure"~"^(sports_centre|fitness_centre|pitch)$"]'),
|
||
("cafe", '["amenity"="cafe"]'),
|
||
("restaurant", '["amenity"~"^(restaurant|fast_food)$"]'),
|
||
("fuel", '["amenity"="fuel"]'),
|
||
("atm", '["amenity"="atm"]'),
|
||
("post", '["amenity"="post_office"]'),
|
||
("worship", '["amenity"="place_of_worship"]'),
|
||
("library", '["amenity"="library"]'),
|
||
("police", '["amenity"="police"]'),
|
||
("fire", '["amenity"="fire_station"]'),
|
||
("bank", '["amenity"="bank"]'),
|
||
]
|
||
parts = []
|
||
for _, filt in POI_QUERIES:
|
||
parts.append(f"node{filt}(around:{radius_m},{lat},{lon});")
|
||
parts.append(f"way{filt}(around:{radius_m},{lat},{lon});")
|
||
q = f"[out:json][timeout:60];({''.join(parts)});out center tags;"
|
||
last = None
|
||
by_cat = {}
|
||
cat_lookup = {filt: cat for cat, filt in POI_QUERIES}
|
||
for ep in ("https://overpass-api.de/api/interpreter",
|
||
"https://overpass.kumi.systems/api/interpreter"):
|
||
try:
|
||
r = requests.post(ep, data={"data": q}, timeout=120,
|
||
headers={"User-Agent":"gendesign/1.0"})
|
||
if not r.ok:
|
||
last = f"{ep}: {r.status_code}"; continue
|
||
for el in r.json().get("elements", []):
|
||
tags = el.get("tags") or {}
|
||
cat = _categorize_osm_tags(tags)
|
||
if not cat: continue
|
||
if el["type"] == "node":
|
||
la, lo = el.get("lat"), el.get("lon")
|
||
else:
|
||
c = el.get("center") or {}; la, lo = c.get("lat"), c.get("lon")
|
||
if la is None: continue
|
||
d = hav(lat, lon, la, lo)
|
||
by_cat.setdefault(cat, []).append((d, la, lo, tags.get("name") or ""))
|
||
for c in by_cat: by_cat[c].sort()
|
||
return by_cat
|
||
except Exception as e:
|
||
last = f"{ep}: {e}"
|
||
raise HTTPException(502, f"Overpass failed: {last}")
|
||
|
||
|
||
def _categorize_osm_tags(tags):
|
||
a = tags.get("amenity"); s = tags.get("shop"); l = tags.get("leisure")
|
||
h = tags.get("highway"); r = tags.get("railway"); st = tags.get("station")
|
||
if a == "kindergarten": return "kindergarten"
|
||
if a == "school": return "school"
|
||
if a in ("university", "college"): return "university"
|
||
if a == "pharmacy": return "pharmacy"
|
||
if a in ("clinic","doctors"): return "clinic"
|
||
if a == "hospital": return "hospital"
|
||
if a == "cafe": return "cafe"
|
||
if a in ("restaurant","fast_food"): return "restaurant"
|
||
if a == "fuel": return "fuel"
|
||
if a == "atm": return "atm"
|
||
if a == "bank": return "bank"
|
||
if a == "post_office": return "post"
|
||
if a == "place_of_worship": return "worship"
|
||
if a == "library": return "library"
|
||
if a == "police": return "police"
|
||
if a == "fire_station": return "fire"
|
||
if a == "parking": return "parking"
|
||
if l == "fitness_centre": return "gym"
|
||
if a == "theatre": return "theater"
|
||
if a == "cinema": return "cinema"
|
||
if a == "marketplace": return "marketplace"
|
||
if tags.get("tourism") == "museum": return "museum"
|
||
if tags.get("tourism") in ("hotel","hostel","apartment"): return "hotel"
|
||
if a in ("nightclub","bar","pub"): return "nightclub"
|
||
if a == "car_wash": return "car_wash"
|
||
if a in ("car_rental","taxi"): return "car_rental"
|
||
if a == "veterinary": return "vet"
|
||
if a in ("parcel_locker","post_box"): return "courier"
|
||
if a == "dentist": return "dentist"
|
||
if a == "childcare": return "childcare"
|
||
if s in ("mall","supermarket","department_store","hypermarket"): return "shop_big"
|
||
if s in ("convenience","grocery","bakery"): return "shop_med"
|
||
if s in ("kiosk","newsagent"): return "shop_small"
|
||
if h == "bus_stop": return "bus_stop"
|
||
if r == "tram_stop": return "tram_stop"
|
||
if st == "subway": return "metro"
|
||
if l in ("park","garden"): return "park"
|
||
if l == "playground": return "playground"
|
||
if l in ("sports_centre","fitness_centre","pitch"): return "sports"
|
||
return None
|
||
|
||
|
||
def assign_district(lat, lon, conn):
|
||
"""Two-tier: admin (PostGIS polygon) + objective-микрорайон (kNN voted)."""
|
||
admin = find_admin_district(lat, lon)
|
||
matched = conn.execute("""
|
||
SELECT s.lat, s.lon, sd.district FROM sites s
|
||
JOIN site_district sd USING (site_id)
|
||
JOIN jk_objective_match m USING (site_id)""").fetchall()
|
||
cands = sorted([(hav(lat, lon, m["lat"], m["lon"]), m["district"]) for m in matched])
|
||
top = [c[1] for c in cands[:7] if c[0] <= 2500]
|
||
from collections import Counter
|
||
obj_district = Counter(top).most_common(1)[0][0] if top else None
|
||
return admin, obj_district
|
||
|
||
|
||
def score_point(lat, lon, weights, what_if=None, remove_cats=None):
|
||
"""Run full scoring for a point. Returns dict ready for API.
|
||
|
||
what_if: optional list of {cat, lat, lon, name?} — hypothetical POIs added
|
||
before scoring (e.g., "what if there was a metro 500m away?")
|
||
remove_cats: optional list of categories to suppress (proxy for "no metro")
|
||
"""
|
||
t0 = time.time()
|
||
if in_bbox(lat, lon):
|
||
by_cat = fetch_pois_local(lat, lon)
|
||
poi_source = "cache"
|
||
else:
|
||
by_cat = fetch_pois_overpass(lat, lon)
|
||
poi_source = "overpass_live"
|
||
# apply what-if mutations
|
||
for w in (what_if or []):
|
||
cat = w.get("cat")
|
||
if not cat: continue
|
||
d = hav(lat, lon, w["lat"], w["lon"])
|
||
by_cat.setdefault(cat, []).append((d, w["lat"], w["lon"], w.get("name") or "Hypothetical"))
|
||
by_cat[cat].sort()
|
||
for cat in (remove_cats or []):
|
||
by_cat[cat] = []
|
||
t_poi = time.time() - t0
|
||
|
||
# 2. Build features
|
||
feats = {}
|
||
cats = ["kindergarten","school","university","pharmacy","clinic","hospital",
|
||
"shop_big","shop_med","shop_small","bus_stop","tram_stop","metro",
|
||
"park","playground","sports",
|
||
# Extended (informational; not in scoring weights)
|
||
"cafe","restaurant","fuel","atm","bank","post","worship","library","police","fire",
|
||
"parking","gym","theater","cinema","marketplace","museum","hotel",
|
||
"nightclub","car_wash","car_rental","vet","courier","dentist","childcare"]
|
||
nearest = {}
|
||
for cat in cats:
|
||
items = by_cat.get(cat, [])
|
||
if items:
|
||
nearest[cat] = items[0][0]
|
||
feats[f"{cat}_nearest_m"] = items[0][0]
|
||
feats[f"{cat}_count_500m"] = sum(1 for it in items if it[0] <= 500)
|
||
feats[f"{cat}_count_1km"] = sum(1 for it in items if it[0] <= 1000)
|
||
feats[f"{cat}_top5"] = [
|
||
{"name": it[3], "distance_m": round(it[0],1), "lat": it[1], "lon": it[2]}
|
||
for it in items[:5]
|
||
]
|
||
else:
|
||
feats[f"{cat}_nearest_m"] = None
|
||
feats[f"{cat}_count_500m"] = 0
|
||
feats[f"{cat}_count_1km"] = 0
|
||
feats[f"{cat}_top5"] = []
|
||
|
||
edu = comp_score(nearest, EDU)
|
||
health = comp_score(nearest, HEALTH)
|
||
big = comp_score(nearest, [("shop_big", 500, 2000, 1.0)])
|
||
med = comp_score(nearest, [("shop_med", 300, 1000, 1.0)])
|
||
retail = max(big, 0.7 * med)
|
||
tr_m = comp_score(nearest, TRANSIT_M)
|
||
tr_t = comp_score(nearest, TRANSIT_T)
|
||
tr_b = comp_score(nearest, TRANSIT_B)
|
||
transit = max(tr_m, 0.85 * tr_t, 0.7 * tr_b)
|
||
leisure = comp_score(nearest, LEISURE)
|
||
|
||
# 3. District + economics
|
||
with _conn() as c:
|
||
admin, obj_district = assign_district(lat, lon, c)
|
||
economics = None
|
||
trend_factor = 1.0
|
||
if obj_district:
|
||
row = c.execute("SELECT * FROM district_economics WHERE district=?",
|
||
(obj_district,)).fetchone()
|
||
economics = dict(row) if row else None
|
||
if economics:
|
||
trend_factor = economics.get("trend_factor") or 1.0
|
||
|
||
# bounds for normalization
|
||
prices = sorted([r[0] for r in c.execute(
|
||
"SELECT real_median_price_m2 FROM district_economics "
|
||
"WHERE real_median_price_m2 IS NOT NULL").fetchall()])
|
||
pmin, pmax = (prices[0], prices[-1]) if prices else (100, 200)
|
||
if pmax <= pmin: pmax = pmin + 1
|
||
vels = sorted([r[0] for r in c.execute(
|
||
"SELECT real_velocity_6mo FROM district_economics "
|
||
"WHERE real_velocity_6mo IS NOT NULL").fetchall()])
|
||
vmax = vels[int(len(vels) * 0.9)] if vels else 8
|
||
|
||
all_jk = c.execute("SELECT lat, lon FROM sites WHERE kind='jk'").fetchall()
|
||
n_jk_1km = sum(1 for r in all_jk if hav(lat, lon, r[0], r[1]) <= 1000)
|
||
feats["jk_count_1km"] = n_jk_1km
|
||
|
||
# 3b. Walkability — sum of POI weights × decay 1/(1+d/200) within 1.5km
|
||
walk_w = {
|
||
# daily-need POIs weighted higher
|
||
"shop_med": 1.5, "shop_small": 1.0, "shop_big": 0.6,
|
||
"kindergarten": 1.5, "school": 1.5,
|
||
"pharmacy": 1.0, "clinic": 0.8, "hospital": 0.4,
|
||
"cafe": 0.6, "restaurant": 0.4,
|
||
"park": 1.2, "playground": 1.0, "sports": 0.5,
|
||
"bus_stop": 0.8, "tram_stop": 1.0, "metro": 1.5,
|
||
"atm": 0.4, "bank": 0.3, "post": 0.4, "library": 0.3,
|
||
}
|
||
walk_score_raw = 0
|
||
for cat, w in walk_w.items():
|
||
for d, *_ in by_cat.get(cat, [])[:10]:
|
||
if d <= 1500:
|
||
walk_score_raw += w / (1 + d/200)
|
||
# normalize: 30 = "average" → 50, 60+ = "excellent" → 100
|
||
walkability = min(100, walk_score_raw * 100 / 60)
|
||
feats["walkability_score"] = round(walkability, 1)
|
||
|
||
# 4. Economic score
|
||
if economics:
|
||
price = economics.get("real_median_price_m2")
|
||
v_rec = economics.get("real_velocity_6mo")
|
||
mts = economics.get("months_to_sellout")
|
||
p_score = max(0, min(100, ((price or 0) - pmin) * 100 / (pmax - pmin))) if price else 0
|
||
v_score_raw = max(0, min(100, (v_rec or 0) * 100 / vmax)) if v_rec else 0
|
||
tf_clamped = max(0.7, min(2.0, trend_factor))
|
||
v_score = v_score_raw * (0.5 + 0.5 * (tf_clamped / 2.0))
|
||
liq = max(0, 100 - min(mts or 100, 24) * 100 / 24) if mts else 50
|
||
econ = 0.50 * p_score + 0.25 * v_score + 0.25 * liq
|
||
else:
|
||
econ = 0; price = v_rec = None
|
||
|
||
# 5. Market score — FIXED: don't penalize high sold_pct, treat as proven absorption
|
||
density_score = max(0, 100 - n_jk_1km * 100 / 15)
|
||
sold_pct = (economics or {}).get("real_sold_pct") or 50
|
||
# Sold% 0..70 = linear up (proven absorption). 70..100 = plateau (saturated; no penalty needed).
|
||
sat_score = min(100, sold_pct * 100 / 70)
|
||
market = 0.5 * density_score + 0.5 * sat_score
|
||
|
||
# 6. Aggregate
|
||
comps = {"education": edu, "health": health, "retail": retail,
|
||
"transit": transit, "leisure": leisure, "economic": econ, "market": market}
|
||
weighted = sum(weights.get(k, 0) * v for k, v in comps.items())
|
||
|
||
# 7. Rank vs all ЖК (using same weights)
|
||
with _conn() as c:
|
||
rows = c.execute("""SELECT s.site_id,
|
||
(SELECT score_0_100 FROM scores sc WHERE sc.site_id=s.site_id AND sc.component='education'),
|
||
(SELECT score_0_100 FROM scores sc WHERE sc.site_id=s.site_id AND sc.component='health'),
|
||
(SELECT score_0_100 FROM scores sc WHERE sc.site_id=s.site_id AND sc.component='retail'),
|
||
(SELECT score_0_100 FROM scores sc WHERE sc.site_id=s.site_id AND sc.component='transit'),
|
||
(SELECT score_0_100 FROM scores sc WHERE sc.site_id=s.site_id AND sc.component='leisure'),
|
||
(SELECT score_0_100 FROM scores sc WHERE sc.site_id=s.site_id AND sc.component='economic'),
|
||
(SELECT score_0_100 FROM scores sc WHERE sc.site_id=s.site_id AND sc.component='market')
|
||
FROM sites s WHERE s.kind='jk'""").fetchall()
|
||
weights_ord = ["education","health","retail","transit","leisure","economic","market"]
|
||
all_w = []
|
||
for r in rows:
|
||
w = sum(weights.get(weights_ord[i], 0) * (r[i+1] or 0) for i in range(7))
|
||
all_w.append(w)
|
||
all_w.sort(reverse=True)
|
||
rank = sum(1 for x in all_w if x > weighted) + 1
|
||
n_jk = len(all_w)
|
||
|
||
return {
|
||
"scores": comps,
|
||
"weighted": round(weighted, 2),
|
||
"rank_overall": rank,
|
||
"n_jk_compared": n_jk,
|
||
"percentile": round(100 * (1 - (rank - 1) / (n_jk + 1)), 1),
|
||
"weights": weights,
|
||
"admin_district": admin,
|
||
"district": obj_district,
|
||
"economics": economics,
|
||
"macro_factors": {"trend_factor": trend_factor,
|
||
"n_jk_1km": n_jk_1km, "city_pmin": pmin, "city_pmax": pmax,
|
||
"city_vmax": vmax},
|
||
"features": feats,
|
||
"_perf": {"poi_source": poi_source, "poi_ms": round(t_poi*1000, 1)},
|
||
}
|
||
|
||
# ---------- HTTP ----------
|
||
|
||
@app.get("/healthz")
|
||
def healthz():
|
||
with _conn() as c:
|
||
n_sites = c.execute("SELECT count(*) FROM sites").fetchone()[0]
|
||
n_pois = c.execute("SELECT count(*) FROM pois").fetchone()[0]
|
||
n_obj = c.execute("SELECT count(*) FROM objective_corp_month").fetchone()[0]
|
||
n_lots = c.execute("SELECT count(*) FROM objective_lots").fetchone()[0]
|
||
return {"status": "ok", "sites": n_sites, "pois_cached": n_pois,
|
||
"objective_corp_month": n_obj, "objective_lots": n_lots,
|
||
"osm_cache_pois": len(_OSM_INDEX), "ekb_bbox": EKB_BBOX,
|
||
"admin_districts": len(ADMIN_GEOM.get("features", []))}
|
||
|
||
|
||
@app.get("/api/sites")
|
||
def sites():
|
||
with _conn() as c:
|
||
rows = c.execute("""
|
||
SELECT s.site_id, s.kind, s.name, s.address, s.lat, s.lon,
|
||
s.obj_class, s.developer, s.flat_count, s.district,
|
||
sd.district AS obj_district,
|
||
st.weighted, st.rank_overall
|
||
FROM sites s
|
||
LEFT JOIN site_district sd USING (site_id)
|
||
LEFT JOIN scores_total st USING (site_id)""").fetchall()
|
||
return [dict(r) for r in rows]
|
||
|
||
|
||
@app.get("/api/jk-full/{site_id:path}")
|
||
def jk_full(site_id: str):
|
||
"""Full ЖК dossier: site row, scores, features, district economics,
|
||
POI counts/nearest, monthly velocity, per-flat sample, prod photos URL,
|
||
prod sale_graph data (if available via SSH tunnel).
|
||
"""
|
||
with _conn() as c:
|
||
s = c.execute("SELECT * FROM sites WHERE site_id=?", (site_id,)).fetchone()
|
||
if not s: raise HTTPException(404, "site not found")
|
||
s = dict(s)
|
||
s["scores"] = {r["component"]: r["score_0_100"] for r in
|
||
c.execute("SELECT * FROM scores WHERE site_id=?", (site_id,)).fetchall()}
|
||
st = c.execute("SELECT weighted,rank_overall,rank_district FROM scores_total WHERE site_id=?",
|
||
(site_id,)).fetchone()
|
||
if st: s.update({"weighted": st["weighted"], "rank_overall": st["rank_overall"],
|
||
"rank_district": st["rank_district"]})
|
||
|
||
# POIs near this site (15 categories with top-3 each)
|
||
s["pois"] = {}
|
||
for cat in ["kindergarten","school","university","pharmacy","clinic","hospital",
|
||
"shop_big","shop_med","shop_small","bus_stop","tram_stop","metro",
|
||
"park","playground","sports",
|
||
"cafe","restaurant","fuel","atm","bank","post","worship",
|
||
"library","police","fire",
|
||
"parking","gym","theater","cinema","marketplace","museum","hotel",
|
||
"nightclub","car_wash","car_rental","vet","courier","dentist","childcare"]:
|
||
rows = c.execute("""SELECT name, distance_m, lat, lon FROM pois
|
||
WHERE site_id=? AND category=?
|
||
ORDER BY distance_m LIMIT 3""", (site_id, cat)).fetchall()
|
||
nearest = c.execute("""SELECT MIN(distance_m), COUNT(CASE WHEN distance_m<=500 THEN 1 END),
|
||
COUNT(CASE WHEN distance_m<=1000 THEN 1 END)
|
||
FROM pois WHERE site_id=? AND category=?""",
|
||
(site_id, cat)).fetchone()
|
||
s["pois"][cat] = {
|
||
"nearest_m": nearest[0],
|
||
"count_500m": nearest[1],
|
||
"count_1km": nearest[2],
|
||
"top3": [{"name": r["name"] or "—", "distance_m": round(r["distance_m"],1),
|
||
"lat": r["lat"], "lon": r["lon"]} for r in rows]
|
||
}
|
||
|
||
# District economics
|
||
e = c.execute("""SELECT sd.district, sd.method, de.* FROM site_district sd
|
||
LEFT JOIN district_economics de USING (district)
|
||
WHERE sd.site_id=?""", (site_id,)).fetchone()
|
||
s["economics"] = dict(e) if e else None
|
||
|
||
# Match Objective project
|
||
m = c.execute("SELECT project, score FROM jk_objective_match WHERE site_id=?",
|
||
(site_id,)).fetchone()
|
||
s["objective_match"] = dict(m) if m else None
|
||
project = m["project"] if m else None
|
||
|
||
# Monthly registrations for this project (12 mo) + sample lots
|
||
s["monthly_velocity"] = []
|
||
s["lots_sample"] = []
|
||
s["lots_summary"] = None
|
||
if project:
|
||
import datetime as dt
|
||
today = dt.date.today().replace(day=1)
|
||
months = []
|
||
y, mn = today.year, today.month
|
||
for _ in range(12):
|
||
months.append(f"{y:04d}-{mn:02d}")
|
||
mn -= 1
|
||
if mn == 0: mn = 12; y -= 1
|
||
months.reverse()
|
||
rows = c.execute("""SELECT substr(register_date,1,7) AS m, COUNT(*) n
|
||
FROM objective_lots
|
||
WHERE project=? AND register_date IS NOT NULL
|
||
AND register_date >= ?
|
||
GROUP BY 1""", (project, months[0])).fetchall()
|
||
series = {r["m"]: r["n"] for r in rows}
|
||
s["monthly_velocity"] = [{"month": m, "n": series.get(m, 0)} for m in months]
|
||
|
||
# 10 currently in-sale lots with prices
|
||
lots = c.execute("""SELECT lot_id, corpus, section, floor, lot_num, room_kind,
|
||
rooms_obj, area_pd, price_per_m2, status, sold,
|
||
finish_type, plan_date, readiness_pct
|
||
FROM objective_lots
|
||
WHERE project=?
|
||
AND status IN ('в продаже','резерв')
|
||
AND price_per_m2 > 0
|
||
ORDER BY price_per_m2 ASC
|
||
LIMIT 10""", (project,)).fetchall()
|
||
s["lots_sample"] = [dict(r) for r in lots]
|
||
|
||
# Aggregate sample
|
||
agg = c.execute("""SELECT COUNT(*) total,
|
||
SUM(CASE WHEN sold='да' THEN 1 ELSE 0 END) sold_n,
|
||
ROUND(AVG(CASE WHEN price_per_m2>0 THEN price_per_m2 END)/1000,1) avg_price_kp,
|
||
ROUND(AVG(CASE WHEN area_pd>0 THEN area_pd END),1) avg_area,
|
||
ROUND(MIN(CASE WHEN price_per_m2>0 THEN price_per_m2 END)/1000,1) min_price_kp,
|
||
ROUND(MAX(CASE WHEN price_per_m2>0 THEN price_per_m2 END)/1000,1) max_price_kp,
|
||
COUNT(DISTINCT corpus) n_corpus,
|
||
COUNT(DISTINCT rooms_obj) n_room_types
|
||
FROM objective_lots WHERE project=?""", (project,)).fetchone()
|
||
s["lots_summary"] = dict(agg) if agg else None
|
||
|
||
# OSM building polygons attached to this ЖК
|
||
try:
|
||
jk_fc = _load_jk_polygons()
|
||
s["building_polygons"] = [
|
||
f for f in jk_fc.get("features", [])
|
||
if f["properties"].get("site_id") == site_id
|
||
]
|
||
except Exception:
|
||
s["building_polygons"] = []
|
||
|
||
# Optional: photos + sale_graph from prod via SSH tunnel
|
||
s["prod_extras"] = {"photos": [], "sale_graph": [], "sales_agg": []}
|
||
obj_id = s.get("obj_id")
|
||
if obj_id:
|
||
try:
|
||
import psycopg
|
||
pg = psycopg.connect(host="127.0.0.1", port=15432, user="gendesign",
|
||
password="2J2SBPMKuS998fiwhtQqDhMI",
|
||
dbname="gendesign", connect_timeout=2)
|
||
pcur = pg.cursor()
|
||
pcur.execute("""SELECT photo_url, photo_dttm, period_dt, photo_name, ready_desc, thumb_path, hidden
|
||
FROM domrf_kn_photos
|
||
WHERE obj_id=%s AND COALESCE(hidden,false)=false
|
||
ORDER BY period_dt DESC NULLS LAST LIMIT 16""", (obj_id,))
|
||
s["prod_extras"]["photos"] = [
|
||
{"url": r[0], "photo_dttm": str(r[1]) if r[1] else None,
|
||
"period_dt": str(r[2]) if r[2] else None,
|
||
"name": r[3], "ready_desc": r[4], "thumb_path": r[5]}
|
||
for r in pcur.fetchall()
|
||
]
|
||
pcur.execute("""SELECT report_month, type, realised, contracted, area_sq, price_avg
|
||
FROM domrf_kn_sale_graph WHERE obj_id=%s
|
||
ORDER BY report_month""", (obj_id,))
|
||
s["prod_extras"]["sale_graph"] = [
|
||
{"report_month": str(r[0]) if r[0] else None,
|
||
"type": r[1], "realised": r[2], "contracted": r[3],
|
||
"area_sq": float(r[4]) if r[4] is not None else None,
|
||
"price_avg": float(r[5]) if r[5] is not None else None}
|
||
for r in pcur.fetchall()
|
||
]
|
||
pcur.execute("""SELECT type, name, total, realised, perc
|
||
FROM domrf_kn_sales_agg WHERE obj_id=%s
|
||
AND snapshot_date=(SELECT MAX(snapshot_date) FROM domrf_kn_sales_agg WHERE obj_id=%s)""",
|
||
(obj_id, obj_id))
|
||
s["prod_extras"]["sales_agg"] = [
|
||
{"type": r[0], "name": r[1], "total": r[2], "realised": r[3],
|
||
"perc": float(r[4]) if r[4] is not None else None}
|
||
for r in pcur.fetchall()
|
||
]
|
||
pg.close()
|
||
except Exception as e:
|
||
s["prod_extras"]["error"] = str(e)
|
||
|
||
return s
|
||
|
||
|
||
@app.get("/api/site/{site_id:path}")
|
||
def site_detail(site_id: str):
|
||
with _conn() as c:
|
||
s = c.execute("SELECT * FROM sites WHERE site_id=?", (site_id,)).fetchone()
|
||
if not s: raise HTTPException(404, "site not found")
|
||
s = dict(s)
|
||
s["scores"] = {r["component"]: r["score_0_100"] for r in
|
||
c.execute("SELECT * FROM scores WHERE site_id=?", (site_id,)).fetchall()}
|
||
st = c.execute("SELECT weighted,rank_overall,rank_district FROM scores_total WHERE site_id=?",
|
||
(site_id,)).fetchone()
|
||
if st: s.update({"weighted": st["weighted"], "rank_overall": st["rank_overall"],
|
||
"rank_district": st["rank_district"]})
|
||
s["features"] = {r["feature"]: r["value"]
|
||
for r in c.execute("SELECT * FROM features WHERE site_id=?",
|
||
(site_id,)).fetchall()}
|
||
d = c.execute("""SELECT sd.district, de.* FROM site_district sd
|
||
LEFT JOIN district_economics de USING (district)
|
||
WHERE sd.site_id=?""", (site_id,)).fetchone()
|
||
s["economics"] = dict(d) if d else None
|
||
return s
|
||
|
||
|
||
@app.get("/api/districts")
|
||
def districts():
|
||
with _conn() as c:
|
||
rows = c.execute("SELECT * FROM district_economics ORDER BY real_median_price_m2 DESC").fetchall()
|
||
return [dict(r) for r in rows]
|
||
|
||
|
||
@app.get("/api/district-polygons")
|
||
def district_polygons():
|
||
"""Return admin districts (8) as GeoJSON FeatureCollection.
|
||
Properties enriched with ЖК count + average score per district."""
|
||
with _conn() as c:
|
||
rows = c.execute("""SELECT s.district, COUNT(*) n,
|
||
ROUND(AVG(st.weighted), 1) avg_score
|
||
FROM sites s
|
||
LEFT JOIN scores_total st USING (site_id)
|
||
WHERE s.kind='jk' AND s.district IS NOT NULL
|
||
GROUP BY 1""").fetchall()
|
||
enrich = {r["district"]: dict(r) for r in rows}
|
||
fc = json.loads(ADMIN_DISTRICTS.read_text())
|
||
for f in fc["features"]:
|
||
nm = f["properties"]["name"]
|
||
e = enrich.get(nm, {})
|
||
f["properties"].update({"jk_count": e.get("n", 0), "avg_score": e.get("avg_score")})
|
||
return fc
|
||
|
||
|
||
@app.get("/api/macro")
|
||
def macro():
|
||
with _conn() as c:
|
||
rows = c.execute("SELECT key,value,label,period FROM macro_context").fetchall()
|
||
out = []
|
||
for r in rows:
|
||
d = dict(r)
|
||
d["unit"] = "%" if d["key"] == "mortgage_rate_sverdl" else \
|
||
" тыс/м²" if d["key"] == "city_med_price_m2" else ""
|
||
out.append(d)
|
||
return out
|
||
|
||
|
||
@app.get("/api/audiences")
|
||
def audiences():
|
||
return AUDIENCES
|
||
|
||
|
||
@app.get("/api/reverse-geocode")
|
||
def reverse_geocode(lat: float, lon: float):
|
||
"""Resolve human-readable address via Nominatim (rate-limited; cache locally)."""
|
||
import requests
|
||
cache_path = ROOT / "cache" / "geocode_cache.json"
|
||
cache = {}
|
||
if cache_path.exists():
|
||
try: cache = json.loads(cache_path.read_text())
|
||
except: cache = {}
|
||
key = f"{lat:.5f},{lon:.5f}"
|
||
if key in cache:
|
||
return cache[key]
|
||
try:
|
||
r = requests.get("https://nominatim.openstreetmap.org/reverse",
|
||
params={"lat":lat,"lon":lon,"format":"json","accept-language":"ru","zoom":18},
|
||
headers={"User-Agent":"gendesign-research/0.2"}, timeout=10)
|
||
r.raise_for_status()
|
||
d = r.json()
|
||
addr = d.get("address", {}) or {}
|
||
out = {
|
||
"display_name": d.get("display_name"),
|
||
"road": addr.get("road"),
|
||
"house_number": addr.get("house_number"),
|
||
"suburb": addr.get("suburb") or addr.get("neighbourhood"),
|
||
"city_district": addr.get("city_district"),
|
||
"city": addr.get("city") or addr.get("town"),
|
||
}
|
||
except Exception as e:
|
||
out = {"error": str(e), "display_name": None}
|
||
cache[key] = out
|
||
# Cache is best-effort — the volume may be mounted read-only (prod docker setup),
|
||
# in which case we just skip persistence and rely on the per-process in-memory dict.
|
||
try:
|
||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||
cache_path.write_text(json.dumps(cache, ensure_ascii=False))
|
||
except OSError:
|
||
pass
|
||
return out
|
||
|
||
|
||
@app.get("/api/district-heatmap")
|
||
def district_heatmap(metric: str = "score"):
|
||
"""Admin districts (8) coloured by chosen metric.
|
||
|
||
metric ∈ {'score','price','velocity','sold_pct','jk_count'}
|
||
"""
|
||
fc = json.loads(ADMIN_DISTRICTS.read_text())
|
||
with _conn() as c:
|
||
# Map admin district → aggregated metric
|
||
rows = c.execute("""
|
||
SELECT s.district AS admin, COUNT(*) AS jk_count,
|
||
AVG(st.weighted) AS avg_score
|
||
FROM sites s LEFT JOIN scores_total st USING (site_id)
|
||
WHERE s.kind='jk' AND s.district IS NOT NULL
|
||
GROUP BY 1""").fetchall()
|
||
admin_score = {r["admin"]: r["avg_score"] for r in rows}
|
||
admin_count = {r["admin"]: r["jk_count"] for r in rows}
|
||
# Objective metrics aggregated by admin via site_district mapping
|
||
eco = c.execute("""
|
||
SELECT s.district AS admin,
|
||
AVG(de.real_median_price_m2) AS price,
|
||
AVG(de.real_velocity_6mo) AS vel,
|
||
AVG(de.real_sold_pct) AS sold
|
||
FROM sites s
|
||
JOIN site_district sd USING (site_id)
|
||
JOIN district_economics de USING (district)
|
||
WHERE s.kind='jk' AND s.district IS NOT NULL
|
||
GROUP BY 1""").fetchall()
|
||
admin_eco = {r["admin"]: dict(r) for r in eco}
|
||
|
||
metric_map = {
|
||
"score": lambda nm: admin_score.get(nm),
|
||
"price": lambda nm: (admin_eco.get(nm) or {}).get("price"),
|
||
"velocity": lambda nm: (admin_eco.get(nm) or {}).get("vel"),
|
||
"sold_pct": lambda nm: (admin_eco.get(nm) or {}).get("sold"),
|
||
"jk_count": lambda nm: admin_count.get(nm),
|
||
}
|
||
fn = metric_map.get(metric, metric_map["score"])
|
||
for f in fc["features"]:
|
||
nm = f["properties"]["name"]
|
||
f["properties"]["value"] = fn(nm)
|
||
f["properties"]["jk_count"] = admin_count.get(nm, 0)
|
||
f["properties"]["avg_score"] = admin_score.get(nm)
|
||
return fc
|
||
|
||
|
||
@app.get("/api/developer/{developer_name:path}")
|
||
def developer_track_record(developer_name: str):
|
||
"""Aggregate developer's portfolio: ЖК count, avg score, avg price, total flats,
|
||
velocity, sellout history. Pulled from local DB via name match.
|
||
"""
|
||
with _conn() as c:
|
||
rows = c.execute("""SELECT s.site_id, s.name, s.flat_count, s.obj_class,
|
||
sd.district AS obj_district,
|
||
st.weighted, st.rank_overall,
|
||
m.project
|
||
FROM sites s
|
||
LEFT JOIN site_district sd USING (site_id)
|
||
LEFT JOIN scores_total st USING (site_id)
|
||
LEFT JOIN jk_objective_match m USING (site_id)
|
||
WHERE s.kind='jk' AND s.developer = ?""", (developer_name,)).fetchall()
|
||
ojects = [dict(r) for r in rows]
|
||
if not ojects:
|
||
raise HTTPException(404, "developer not found")
|
||
|
||
# Aggregate per-project lots stats
|
||
projects = [o["project"] for o in ojects if o["project"]]
|
||
if projects:
|
||
placeholders = ",".join(["?"]*len(projects))
|
||
agg = c.execute(f"""SELECT project, COUNT(*) total,
|
||
SUM(CASE WHEN sold='да' THEN 1 ELSE 0 END) sold_n,
|
||
AVG(CASE WHEN price_per_m2>0 THEN price_per_m2/1000 END) avg_price,
|
||
AVG(CASE WHEN area_pd>0 THEN area_pd END) avg_area,
|
||
MIN(register_date) first_deal,
|
||
MAX(register_date) last_deal,
|
||
COUNT(CASE WHEN register_date >= date('now','-6 months') THEN 1 END)/6.0 vel_6mo
|
||
FROM objective_lots WHERE project IN ({placeholders})
|
||
GROUP BY 1""", projects).fetchall()
|
||
proj_data = {r["project"]: dict(r) for r in agg}
|
||
else:
|
||
proj_data = {}
|
||
|
||
# roll up
|
||
n_jk = len(ojects)
|
||
avg_score = sum(o["weighted"] or 0 for o in ojects if o["weighted"]) / max(sum(1 for o in ojects if o["weighted"]), 1)
|
||
total_flats = sum(o["flat_count"] or 0 for o in ojects)
|
||
matched = [o for o in ojects if o["project"] and proj_data.get(o["project"])]
|
||
# Dedup by project: multiple sites can match the same Objective project,
|
||
# so sum lot stats over unique projects to avoid double-counting.
|
||
matched_projects = sorted({o["project"] for o in matched})
|
||
total_lots = sum(proj_data[p]["total"] for p in matched_projects)
|
||
sold_lots = sum(proj_data[p]["sold_n"] for p in matched_projects)
|
||
avg_price = (sum(proj_data[p]["avg_price"] or 0 for p in matched_projects) / len(matched_projects)) if matched_projects else None
|
||
total_vel = sum(proj_data[p]["vel_6mo"] or 0 for p in matched_projects)
|
||
|
||
portfolio = []
|
||
for o in ojects:
|
||
p = proj_data.get(o["project"]) if o["project"] else None
|
||
portfolio.append({**o, "project_stats": p})
|
||
portfolio.sort(key=lambda x: -(x["weighted"] or 0))
|
||
|
||
return {
|
||
"developer": developer_name,
|
||
"summary": {
|
||
"n_projects": n_jk,
|
||
"n_matched_objective": len(matched),
|
||
"avg_score": round(avg_score, 1),
|
||
"best_score": max((o["weighted"] or 0) for o in ojects),
|
||
"worst_score": min((o["weighted"] or 0) for o in ojects if o["weighted"]),
|
||
"total_flats": total_flats,
|
||
"total_lots_objective": total_lots,
|
||
"sold_lots_objective": sold_lots,
|
||
"sold_pct": round(100*sold_lots/total_lots, 1) if total_lots else None,
|
||
"avg_price_m2_kr": round(avg_price, 1) if avg_price else None,
|
||
"total_velocity_6mo": round(total_vel, 2),
|
||
"districts": list({o["obj_district"] for o in ojects if o["obj_district"]}),
|
||
},
|
||
"portfolio": portfolio,
|
||
}
|
||
|
||
|
||
class TEPInput(BaseModel):
|
||
parcel_area_m2: float
|
||
obj_class: Optional[str] = None # 'комфорт' | 'бизнес' | 'эконом' | 'премиум'
|
||
far: float = 1.5 # floor-area ratio (общая GBA / parcel area)
|
||
saleable_share: float = 0.65 # доля квартир в общей GBA (typical 0.55-0.75)
|
||
avg_unit_area_m2: float = 45 # среднее по сделке
|
||
district: Optional[str] = None # для подстановки price/velocity
|
||
construction_cost_per_m2: int = 75000 # ₽/м² GBA
|
||
soft_cost_pct: float = 0.10 # проектные/маркетинг как доля от выручки
|
||
discount_rate: float = 0.18 # годовая для NPV/IRR
|
||
months_to_complete: int = 30
|
||
|
||
|
||
@app.post("/api/tep-calc")
|
||
def tep_calc(payload: TEPInput):
|
||
"""Простой generative-TЭП от параметров участка.
|
||
|
||
Берёт (по района) реальную median price/m² и velocity. Считает выручку,
|
||
себестоимость, NPV/IRR в простой модели «равномерные продажи всё время стройки».
|
||
"""
|
||
with _conn() as c:
|
||
district = payload.district
|
||
if district:
|
||
row = c.execute("""SELECT real_median_price_m2, real_velocity_6mo
|
||
FROM district_economics WHERE district=?""",
|
||
(district,)).fetchone()
|
||
else:
|
||
row = None
|
||
if row:
|
||
price_kr = row["real_median_price_m2"] or 130
|
||
velocity_per_corp = row["real_velocity_6mo"] or 2.0
|
||
else:
|
||
price_kr = 130
|
||
velocity_per_corp = 2.0
|
||
|
||
gba = payload.parcel_area_m2 * payload.far # total built m²
|
||
saleable_m2 = gba * payload.saleable_share
|
||
n_units = round(saleable_m2 / payload.avg_unit_area_m2)
|
||
price_per_m2 = price_kr * 1000
|
||
revenue = saleable_m2 * price_per_m2
|
||
construction = gba * payload.construction_cost_per_m2
|
||
soft = revenue * payload.soft_cost_pct
|
||
profit = revenue - construction - soft
|
||
|
||
# Sales velocity — single corpus assumption; cap at saleable
|
||
months_to_sellout = n_units / max(velocity_per_corp, 0.1)
|
||
cashflow_months = max(payload.months_to_complete, months_to_sellout)
|
||
|
||
# NPV: monthly equal cashflows
|
||
monthly_revenue = revenue / cashflow_months if cashflow_months else 0
|
||
monthly_cost = construction / payload.months_to_complete
|
||
r_m = (1 + payload.discount_rate) ** (1/12) - 1
|
||
npv = -construction * 0.20 # initial 20% upfront
|
||
for i in range(1, int(cashflow_months) + 1):
|
||
cost_i = monthly_cost * (1 if i <= payload.months_to_complete else 0)
|
||
rev_i = monthly_revenue
|
||
npv += (rev_i - cost_i) / ((1 + r_m) ** i)
|
||
# crude IRR via bisection on monthly
|
||
def npv_at(r):
|
||
s = -construction * 0.20
|
||
rmm = (1+r)**(1/12) - 1
|
||
for i in range(1, int(cashflow_months)+1):
|
||
ci = monthly_cost * (1 if i <= payload.months_to_complete else 0)
|
||
s += (monthly_revenue - ci) / ((1+rmm)**i)
|
||
return s
|
||
lo, hi = 0.0, 5.0
|
||
irr = None
|
||
if npv_at(lo) > 0 and npv_at(hi) < 0:
|
||
for _ in range(40):
|
||
mid = (lo+hi)/2
|
||
v = npv_at(mid)
|
||
if v > 0: lo = mid
|
||
else: hi = mid
|
||
irr = (lo+hi)/2
|
||
|
||
return {
|
||
"input": payload.dict(),
|
||
"district_used": district,
|
||
"assumptions": {"price_kr_per_m2": price_kr, "velocity_per_corp_6mo": velocity_per_corp},
|
||
"outputs": {
|
||
"gba_m2": round(gba, 1),
|
||
"saleable_m2": round(saleable_m2, 1),
|
||
"n_units": n_units,
|
||
"revenue_total": round(revenue),
|
||
"revenue_mln": round(revenue/1e6, 1),
|
||
"construction_cost_total": round(construction),
|
||
"construction_mln": round(construction/1e6, 1),
|
||
"soft_cost_total": round(soft),
|
||
"profit_total": round(profit),
|
||
"profit_mln": round(profit/1e6, 1),
|
||
"profit_margin_pct": round(100*profit/revenue, 1) if revenue else None,
|
||
"months_to_sellout": round(months_to_sellout, 1),
|
||
"cashflow_months": round(cashflow_months, 1),
|
||
"npv": round(npv),
|
||
"npv_mln": round(npv/1e6, 1),
|
||
"irr_annual": round(irr*100, 1) if irr is not None else None,
|
||
}
|
||
}
|
||
|
||
|
||
@app.get("/api/price-distribution/{district}")
|
||
def price_distribution(district: str):
|
||
"""Boxplot data per ЖК class for a district."""
|
||
with _conn() as c:
|
||
rows = c.execute("""
|
||
SELECT s.obj_class AS class_name, ol.price_per_m2/1000.0 AS price
|
||
FROM objective_lots ol
|
||
JOIN jk_objective_match m ON m.project = ol.project
|
||
JOIN sites s USING (site_id)
|
||
WHERE ol.district = ? AND ol.price_per_m2 > 0 AND s.obj_class IS NOT NULL""",
|
||
(district,)).fetchall()
|
||
by_class = {}
|
||
for r in rows:
|
||
by_class.setdefault(r["class_name"], []).append(r["price"])
|
||
out = {}
|
||
for k, vals in by_class.items():
|
||
vals.sort()
|
||
if not vals: continue
|
||
n = len(vals)
|
||
def q(p):
|
||
# Linear-interpolated quantile at position p*(n-1) (avoids upward bias on small n).
|
||
pos = p * (n - 1)
|
||
lo = int(pos)
|
||
hi = min(lo + 1, n - 1)
|
||
frac = pos - lo
|
||
return vals[lo] + (vals[hi] - vals[lo]) * frac
|
||
out[k] = {"n": n, "min": vals[0], "p25": q(0.25), "p50": q(0.5),
|
||
"p75": q(0.75), "max": vals[-1]}
|
||
return {"district": district, "by_class": out}
|
||
|
||
|
||
@app.get("/api/district-time-machine/{district}")
|
||
def district_time_machine(district: str):
|
||
"""12-month series of: deals_count, median_price, sold_volume, distinct_corpuses."""
|
||
import datetime as dt
|
||
today = dt.date.today().replace(day=1)
|
||
months = []
|
||
y, m = today.year, today.month
|
||
for _ in range(12):
|
||
months.append(f"{y:04d}-{m:02d}")
|
||
m -= 1
|
||
if m == 0: m = 12; y -= 1
|
||
months.reverse()
|
||
series = []
|
||
import statistics
|
||
with _conn() as c:
|
||
for mo in months:
|
||
row = c.execute("""
|
||
SELECT COUNT(*) AS deals,
|
||
SUM(CASE WHEN area_pd>0 THEN area_pd END) AS volume_m2,
|
||
COUNT(DISTINCT project||'·'||corpus) AS corpuses
|
||
FROM objective_lots
|
||
WHERE district=? AND substr(register_date,1,7)=?
|
||
""", (district, mo)).fetchone()
|
||
# True median price (SQLite has no MEDIAN aggregate) — compute in Python.
|
||
prices = [r["p"] for r in c.execute("""
|
||
SELECT price_per_m2/1000.0 AS p
|
||
FROM objective_lots
|
||
WHERE district=? AND substr(register_date,1,7)=? AND price_per_m2>0
|
||
""", (district, mo)).fetchall()]
|
||
median_price = statistics.median(prices) if prices else None
|
||
series.append({
|
||
"month": mo,
|
||
"deals": row["deals"] or 0,
|
||
"median_price_kr": round(median_price, 1) if median_price is not None else None,
|
||
"volume_m2": round(row["volume_m2"], 1) if row["volume_m2"] else 0,
|
||
"active_corpuses": row["corpuses"] or 0,
|
||
})
|
||
return {"district": district, "series": series}
|
||
|
||
|
||
@app.get("/api/smart-suggestions")
|
||
def smart_suggestions(lat: float, lon: float, audience: str = "balanced",
|
||
radius_km: float = 3.0, top_n: int = 5):
|
||
"""Top-N ЖК within radius_km that score higher than this point under selected audience.
|
||
Useful as 'nearby alternatives'."""
|
||
weights = AUDIENCES.get(audience, AUDIENCES["balanced"])
|
||
weights = {k: v for k, v in weights.items() if k != "label"}
|
||
with _conn() as c:
|
||
rows = c.execute("""
|
||
SELECT s.site_id, s.name, s.developer, s.obj_class, s.lat, s.lon,
|
||
sd.district AS obj_district,
|
||
(SELECT score_0_100 FROM scores WHERE site_id=s.site_id AND component='education') AS edu,
|
||
(SELECT score_0_100 FROM scores WHERE site_id=s.site_id AND component='health') AS health,
|
||
(SELECT score_0_100 FROM scores WHERE site_id=s.site_id AND component='retail') AS retail,
|
||
(SELECT score_0_100 FROM scores WHERE site_id=s.site_id AND component='transit') AS transit,
|
||
(SELECT score_0_100 FROM scores WHERE site_id=s.site_id AND component='leisure') AS leisure,
|
||
(SELECT score_0_100 FROM scores WHERE site_id=s.site_id AND component='economic') AS economic,
|
||
(SELECT score_0_100 FROM scores WHERE site_id=s.site_id AND component='market') AS market
|
||
FROM sites s
|
||
LEFT JOIN site_district sd USING (site_id)
|
||
WHERE s.kind='jk'""").fetchall()
|
||
out = []
|
||
for r in rows:
|
||
d = hav(lat, lon, r["lat"], r["lon"])
|
||
if d > radius_km*1000: continue
|
||
comps = {"education": r["edu"] or 0, "health": r["health"] or 0,
|
||
"retail": r["retail"] or 0, "transit": r["transit"] or 0,
|
||
"leisure": r["leisure"] or 0, "economic": r["economic"] or 0,
|
||
"market": r["market"] or 0}
|
||
weighted = sum(weights.get(k,0)*v for k,v in comps.items())
|
||
out.append({
|
||
"site_id": r["site_id"], "name": r["name"], "developer": r["developer"],
|
||
"obj_class": r["obj_class"], "obj_district": r["obj_district"],
|
||
"lat": r["lat"], "lon": r["lon"],
|
||
"distance_m": round(d), "weighted": round(weighted, 1),
|
||
})
|
||
out.sort(key=lambda x: -x["weighted"])
|
||
return {"audience": audience, "radius_km": radius_km, "results": out[:top_n]}
|
||
|
||
|
||
@app.get("/api/export/geojson")
|
||
def export_geojson(audience: str = "balanced", min_score: float = 0):
|
||
"""All ЖК as GeoJSON FeatureCollection with audience-weighted score in props."""
|
||
weights = {k: v for k, v in AUDIENCES.get(audience, AUDIENCES["balanced"]).items() if k != "label"}
|
||
with _conn() as c:
|
||
rows = c.execute("""
|
||
SELECT s.site_id, s.name, s.developer, s.obj_class, s.lat, s.lon,
|
||
sd.district,
|
||
(SELECT score_0_100 FROM scores WHERE site_id=s.site_id AND component='education') edu,
|
||
(SELECT score_0_100 FROM scores WHERE site_id=s.site_id AND component='health') health,
|
||
(SELECT score_0_100 FROM scores WHERE site_id=s.site_id AND component='retail') retail,
|
||
(SELECT score_0_100 FROM scores WHERE site_id=s.site_id AND component='transit') transit,
|
||
(SELECT score_0_100 FROM scores WHERE site_id=s.site_id AND component='leisure') leisure,
|
||
(SELECT score_0_100 FROM scores WHERE site_id=s.site_id AND component='economic') economic,
|
||
(SELECT score_0_100 FROM scores WHERE site_id=s.site_id AND component='market') market
|
||
FROM sites s LEFT JOIN site_district sd USING (site_id)
|
||
WHERE s.kind='jk'""").fetchall()
|
||
feats = []
|
||
for r in rows:
|
||
comps = {"education":r["edu"] or 0, "health":r["health"] or 0,
|
||
"retail":r["retail"] or 0, "transit":r["transit"] or 0,
|
||
"leisure":r["leisure"] or 0, "economic":r["economic"] or 0,
|
||
"market":r["market"] or 0}
|
||
w = sum(weights.get(k,0)*v for k,v in comps.items())
|
||
if w < min_score: continue
|
||
feats.append({"type":"Feature",
|
||
"geometry":{"type":"Point","coordinates":[r["lon"], r["lat"]]},
|
||
"properties":{"site_id":r["site_id"],"name":r["name"],
|
||
"developer":r["developer"],"obj_class":r["obj_class"],
|
||
"district":r["district"],
|
||
"weighted_score":round(w,2),
|
||
**{f"score_{k}": round(v,1) for k,v in comps.items()}}})
|
||
return {"type":"FeatureCollection","features":feats,
|
||
"_metadata":{"audience":audience,"weights":weights,"count":len(feats)}}
|
||
|
||
|
||
@app.get("/api/export/csv")
|
||
def export_csv(audience: str = "balanced"):
|
||
"""Same data as flat CSV for Excel/Google Sheets."""
|
||
import io, csv
|
||
weights = {k: v for k, v in AUDIENCES.get(audience, AUDIENCES["balanced"]).items() if k != "label"}
|
||
with _conn() as c:
|
||
rows = c.execute("""
|
||
SELECT s.site_id, s.name, s.developer, s.obj_class, s.lat, s.lon, s.flat_count,
|
||
sd.district,
|
||
de.real_median_price_m2, de.real_velocity_6mo, de.real_sold_pct,
|
||
(SELECT score_0_100 FROM scores WHERE site_id=s.site_id AND component='education') edu,
|
||
(SELECT score_0_100 FROM scores WHERE site_id=s.site_id AND component='health') health,
|
||
(SELECT score_0_100 FROM scores WHERE site_id=s.site_id AND component='retail') retail,
|
||
(SELECT score_0_100 FROM scores WHERE site_id=s.site_id AND component='transit') transit,
|
||
(SELECT score_0_100 FROM scores WHERE site_id=s.site_id AND component='leisure') leisure,
|
||
(SELECT score_0_100 FROM scores WHERE site_id=s.site_id AND component='economic') economic,
|
||
(SELECT score_0_100 FROM scores WHERE site_id=s.site_id AND component='market') market
|
||
FROM sites s
|
||
LEFT JOIN site_district sd USING (site_id)
|
||
LEFT JOIN district_economics de USING (district)
|
||
WHERE s.kind='jk'""").fetchall()
|
||
buf = io.StringIO()
|
||
buf.write("") # BOM for Excel
|
||
w = csv.writer(buf, delimiter=";")
|
||
cols = ["site_id","name","developer","obj_class","district","lat","lon","flat_count",
|
||
"median_price_kr","velocity_6mo","sold_pct",
|
||
"edu","health","retail","transit","leisure","economic","market","weighted"]
|
||
w.writerow(cols)
|
||
for r in rows:
|
||
comps = {k: r[k] or 0 for k in ("edu","health","retail","transit","leisure","economic","market")}
|
||
comp_full = {"education":comps["edu"],"health":comps["health"],"retail":comps["retail"],
|
||
"transit":comps["transit"],"leisure":comps["leisure"],
|
||
"economic":comps["economic"],"market":comps["market"]}
|
||
weighted = sum(weights.get(k,0)*v for k,v in comp_full.items())
|
||
w.writerow([r["site_id"],r["name"],r["developer"],r["obj_class"],r["district"],
|
||
r["lat"],r["lon"],r["flat_count"],
|
||
r["real_median_price_m2"], r["real_velocity_6mo"], r["real_sold_pct"],
|
||
*[comps[k] for k in ("edu","health","retail","transit","leisure","economic","market")],
|
||
round(weighted,2)])
|
||
from fastapi.responses import Response
|
||
return Response(content=buf.getvalue(), media_type="text/csv; charset=utf-8",
|
||
headers={"Content-Disposition": f'attachment; filename="gendesign_jk_{audience}.csv"'})
|
||
|
||
|
||
@app.get("/api/poi-vs-district/{site_id:path}")
|
||
def poi_vs_district(site_id: str):
|
||
"""Compare POI counts of one site vs district average."""
|
||
with _conn() as c:
|
||
s = c.execute("""SELECT s.lat, s.lon, sd.district FROM sites s
|
||
LEFT JOIN site_district sd USING (site_id)
|
||
WHERE s.site_id=?""", (site_id,)).fetchone()
|
||
if not s: raise HTTPException(404)
|
||
district = s["district"]
|
||
if not district:
|
||
return {"site_id": site_id, "district": None, "comparison": []}
|
||
|
||
peers = [r["site_id"] for r in c.execute(
|
||
"SELECT site_id FROM site_district WHERE district=? AND site_id!=?",
|
||
(district, site_id)).fetchall()]
|
||
if not peers:
|
||
return {"site_id": site_id, "district": district, "comparison": []}
|
||
|
||
cats = ["kindergarten","school","pharmacy","clinic","shop_big","shop_med",
|
||
"bus_stop","tram_stop","park","playground","sports","cafe","restaurant",
|
||
"atm","post"]
|
||
out = []
|
||
for cat in cats:
|
||
mine = c.execute(
|
||
"SELECT COUNT(*) FROM pois WHERE site_id=? AND category=? AND distance_m<=1000",
|
||
(site_id, cat)).fetchone()[0]
|
||
placeholders = ",".join("?"*len(peers))
|
||
avg = c.execute(
|
||
f"SELECT AVG(c) FROM (SELECT COUNT(*) c FROM pois WHERE site_id IN ({placeholders}) AND category=? AND distance_m<=1000 GROUP BY site_id)",
|
||
(*peers, cat)).fetchone()[0] or 0
|
||
out.append({"category": cat, "mine": mine, "district_avg": round(avg, 1),
|
||
"delta_pct": round(100*(mine-avg)/avg, 1) if avg else None})
|
||
return {"site_id": site_id, "district": district, "comparison": out}
|
||
|
||
|
||
@app.get("/api/district-compare")
|
||
def district_compare(districts: str):
|
||
"""Compare 2-3 districts on key metrics. Body: 'A,B,C'."""
|
||
names = [n.strip() for n in districts.split(",") if n.strip()][:5]
|
||
out = []
|
||
with _conn() as c:
|
||
for name in names:
|
||
r = c.execute("""SELECT * FROM district_economics WHERE district=?""", (name,)).fetchone()
|
||
if r: out.append(dict(r))
|
||
return {"districts": names, "data": out}
|
||
|
||
|
||
@app.get("/api/district-roi-ranking")
|
||
def district_roi_ranking(obj_class: str = "комфорт", far: float = 2.0,
|
||
parcel_area_m2: float = 3000):
|
||
"""For a hypothetical parcel size + class, rank ALL districts by IRR/margin.
|
||
Useful for investor scouting — 'where to look for parcels'."""
|
||
CLASSES = {
|
||
"эконом": {"price_baseline": 100, "construction": 60000, "saleable": 0.65, "avg_unit": 38},
|
||
"комфорт": {"price_baseline": 130, "construction": 75000, "saleable": 0.62, "avg_unit": 48},
|
||
"бизнес": {"price_baseline": 200, "construction": 110000, "saleable": 0.55, "avg_unit": 65},
|
||
"премиум": {"price_baseline": 320, "construction": 170000, "saleable": 0.50, "avg_unit": 95},
|
||
}
|
||
cfg = CLASSES.get(obj_class, CLASSES["комфорт"])
|
||
out = []
|
||
with _conn() as c:
|
||
rows = c.execute("""SELECT district, real_median_price_m2, real_velocity_6mo,
|
||
real_sold_pct, n_projects
|
||
FROM district_economics
|
||
WHERE real_median_price_m2 IS NOT NULL""").fetchall()
|
||
for r in rows:
|
||
# adjust class price by district base ratio
|
||
ratio = (r["real_median_price_m2"] or 130) / 130
|
||
price_kr = cfg["price_baseline"] * ratio
|
||
gba = parcel_area_m2 * far
|
||
saleable = gba * cfg["saleable"]
|
||
n_units = round(saleable / cfg["avg_unit"])
|
||
revenue = saleable * price_kr * 1000
|
||
construction = gba * cfg["construction"]
|
||
soft = revenue * 0.10
|
||
profit = revenue - construction - soft
|
||
margin = 100 * profit / revenue if revenue else 0
|
||
# crude ROI-per-month: profit / (construction × months) × 12
|
||
# assume sellout = n_units / district_velocity_per_corp (capped at 30 mo)
|
||
v = r["real_velocity_6mo"] or 1.0
|
||
months = max(18, min(60, n_units / max(v, 0.5)))
|
||
roi_annual = (profit / construction) * 12 / months * 100 if construction else 0
|
||
out.append({
|
||
"district": r["district"],
|
||
"median_price_kr": round(r["real_median_price_m2"], 1),
|
||
"velocity_6mo": round(v, 2),
|
||
"sold_pct": round(r["real_sold_pct"] or 0, 1),
|
||
"n_projects": r["n_projects"],
|
||
"price_kr_class": round(price_kr, 1),
|
||
"n_units": n_units,
|
||
"revenue_mln": round(revenue/1e6, 1),
|
||
"profit_mln": round(profit/1e6, 1),
|
||
"margin_pct": round(margin, 1),
|
||
"months_to_sellout": round(months, 1),
|
||
"roi_annual_pct": round(roi_annual, 1),
|
||
})
|
||
out.sort(key=lambda x: -x["roi_annual_pct"])
|
||
return {"obj_class": obj_class, "far": far, "parcel_area_m2": parcel_area_m2,
|
||
"results": out}
|
||
|
||
|
||
@app.get("/api/risk-score")
|
||
def risk_score(lat: float, lon: float):
|
||
"""Risk: distance to railways, highways, industry — closer = higher noise/dust."""
|
||
import math
|
||
risks = {
|
||
"railway": {"items": [], "ideal_m": 200, "max_m": 800, "weight": 0.30},
|
||
"highway": {"items": [], "ideal_m": 80, "max_m": 400, "weight": 0.30},
|
||
"industry": {"items": [], "ideal_m": 200, "max_m": 800, "weight": 0.25},
|
||
"fuel": {"items": [], "ideal_m": 80, "max_m": 300, "weight": 0.05},
|
||
"fire": {"items": [], "ideal_m": 500, "max_m": 2000,"weight": 0.10},
|
||
}
|
||
# Use cached OSM raw — railway / highway data lives in osm_buildings_all only as buildings,
|
||
# so for risk we sample fuel + fire from main cache (already there).
|
||
for cat in ("fuel", "fire"):
|
||
for el in _osm_data.get(cat, []):
|
||
if el["type"] == "node":
|
||
la, lo = el.get("lat"), el.get("lon")
|
||
else:
|
||
c = el.get("center") or {}; la, lo = c.get("lat"), c.get("lon")
|
||
if la is None: continue
|
||
d = hav(lat, lon, la, lo)
|
||
if d <= risks[cat]["max_m"] * 2:
|
||
risks[cat]["items"].append({"distance_m": round(d, 1)})
|
||
|
||
# Railway/highway from OSM Overpass live — small bbox (1km around point)
|
||
try:
|
||
import requests
|
||
bbox = (lat - 0.012, lon - 0.020, lat + 0.012, lon + 0.020) # ~1.5km
|
||
q = f'[out:json][timeout:30];(way["railway"]({bbox[0]},{bbox[1]},{bbox[2]},{bbox[3]});way["highway"~"^(motorway|trunk|primary|secondary)$"]({bbox[0]},{bbox[1]},{bbox[2]},{bbox[3]}););out geom;'
|
||
r = requests.post("https://overpass-api.de/api/interpreter", data={"data": q},
|
||
timeout=40, headers={"User-Agent":"gendesign/0.7"})
|
||
if r.ok:
|
||
for el in r.json().get("elements", []):
|
||
if not el.get("geometry"): continue
|
||
cat = "railway" if el.get("tags",{}).get("railway") else "highway"
|
||
# nearest point of way
|
||
min_d = min(hav(lat, lon, p["lat"], p["lon"]) for p in el["geometry"])
|
||
risks[cat]["items"].append({"distance_m": round(min_d, 1),
|
||
"name": el.get("tags",{}).get("name","")})
|
||
except Exception as e:
|
||
risks["_overpass_err"] = str(e)[:120]
|
||
|
||
# Score per category — closer to ideal = high penalty
|
||
out = {"lat": lat, "lon": lon, "risks": {}}
|
||
total_penalty = 0
|
||
for cat, cfg in risks.items():
|
||
if cat.startswith("_"): continue
|
||
if not cfg["items"]:
|
||
out["risks"][cat] = {"min_distance_m": None, "n_within_buffer": 0, "penalty_pct": 0, "weight": cfg["weight"]}
|
||
continue
|
||
nearest = min(cfg["items"], key=lambda x: x["distance_m"])
|
||
d = nearest["distance_m"]
|
||
if d <= cfg["ideal_m"]:
|
||
penalty = cfg["weight"] * 100
|
||
elif d >= cfg["max_m"]:
|
||
penalty = 0
|
||
else:
|
||
penalty = cfg["weight"] * 100 * (cfg["max_m"] - d) / (cfg["max_m"] - cfg["ideal_m"])
|
||
out["risks"][cat] = {
|
||
"min_distance_m": d,
|
||
"n_within_buffer": sum(1 for x in cfg["items"] if x["distance_m"] <= cfg["max_m"]),
|
||
"penalty_pct": round(penalty, 1),
|
||
"weight": cfg["weight"],
|
||
}
|
||
total_penalty += penalty
|
||
out["total_penalty"] = round(total_penalty, 1)
|
||
out["risk_score"] = round(max(0, 100 - total_penalty), 1) # 100 = no risks
|
||
return out
|
||
|
||
|
||
@app.post("/api/tep-multiclass")
|
||
def tep_multiclass(payload: dict):
|
||
"""Compare TEP across 3 classes (econom/comfort/business) for same parcel."""
|
||
base_district = payload.get("district")
|
||
parcel_area = payload.get("parcel_area_m2", 2789)
|
||
far = payload.get("far", 2.0)
|
||
|
||
CLASSES = {
|
||
"эконом": {"price_kr": 100, "construction": 60000, "saleable": 0.65, "avg_unit": 38},
|
||
"комфорт": {"price_kr": 130, "construction": 75000, "saleable": 0.62, "avg_unit": 48},
|
||
"бизнес": {"price_kr": 200, "construction": 110000, "saleable": 0.55, "avg_unit": 65},
|
||
"премиум": {"price_kr": 320, "construction": 170000, "saleable": 0.50, "avg_unit": 95},
|
||
}
|
||
# If we have real district median, use class-relative multipliers
|
||
if base_district:
|
||
with _conn() as c:
|
||
row = c.execute("SELECT real_median_price_m2 FROM district_economics WHERE district=?",
|
||
(base_district,)).fetchone()
|
||
if row and row["real_median_price_m2"]:
|
||
base = row["real_median_price_m2"]
|
||
# adjust class prices proportionally to district base
|
||
ratio = base / 130 # comfort baseline
|
||
for cls in CLASSES.values():
|
||
cls["price_kr"] = round(cls["price_kr"] * ratio, 1)
|
||
|
||
results = []
|
||
for cls_name, cfg in CLASSES.items():
|
||
gba = parcel_area * far
|
||
saleable = gba * cfg["saleable"]
|
||
n_units = round(saleable / cfg["avg_unit"])
|
||
revenue = saleable * cfg["price_kr"] * 1000
|
||
construction = gba * cfg["construction"]
|
||
soft = revenue * 0.10
|
||
profit = revenue - construction - soft
|
||
margin = 100 * profit / revenue if revenue else 0
|
||
results.append({
|
||
"class": cls_name,
|
||
"price_kr_per_m2": cfg["price_kr"],
|
||
"construction_per_m2": cfg["construction"],
|
||
"n_units": n_units,
|
||
"revenue_mln": round(revenue/1e6, 1),
|
||
"cost_mln": round(construction/1e6, 1),
|
||
"profit_mln": round(profit/1e6, 1),
|
||
"margin_pct": round(margin, 1),
|
||
})
|
||
results.sort(key=lambda x: -x["margin_pct"])
|
||
return {"district": base_district, "parcel_area_m2": parcel_area, "far": far,
|
||
"results": results, "best_class": results[0]["class"]}
|
||
|
||
|
||
@app.get("/api/insolation")
|
||
def insolation(lat: float, lon: float, radius_m: int = 100):
|
||
"""Approximate insolation analysis for a parcel point.
|
||
|
||
Returns: density of OSM buildings around the point per octant (N/NE/E/SE/.../NW),
|
||
so we can identify which sides have shadow risk (dense) vs open (sun).
|
||
"""
|
||
# Use full OSM building centroids (61k+) cached at cache/osm_buildings_all.geojson
|
||
fc_path = ROOT / "cache" / "osm_buildings_all.geojson"
|
||
nearby = []
|
||
if fc_path.exists():
|
||
fc = json.loads(fc_path.read_text())
|
||
import math
|
||
for feat in fc.get("features", []):
|
||
coords = feat.get("geometry", {}).get("coordinates")
|
||
if not coords: continue
|
||
clon, clat = coords[0], coords[1]
|
||
d = hav(lat, lon, clat, clon)
|
||
if d <= radius_m * 5:
|
||
dlon = math.radians(clon - lon)
|
||
la1, la2 = math.radians(lat), math.radians(clat)
|
||
y = math.sin(dlon) * math.cos(la2)
|
||
x = math.cos(la1)*math.sin(la2) - math.sin(la1)*math.cos(la2)*math.cos(dlon)
|
||
bearing = (math.degrees(math.atan2(y, x)) + 360) % 360
|
||
nearby.append({"distance_m": round(d), "bearing_deg": round(bearing, 1)})
|
||
# Aggregate per octant + south exposure score
|
||
octants = ["N","NE","E","SE","S","SW","W","NW"]
|
||
counts = {o: 0 for o in octants}
|
||
weighted_shadow_S = 0 # higher = more buildings to the South (BAD for insolation)
|
||
for b in nearby:
|
||
idx = int(((b["bearing_deg"] + 22.5) % 360) / 45)
|
||
counts[octants[idx]] += 1
|
||
# Building is south of parcel if bearing in (135°, 225°)
|
||
if 135 < b["bearing_deg"] < 225:
|
||
weighted_shadow_S += 1 / (1 + b["distance_m"]/50)
|
||
# Insolation score: 100 = wide open south, 0 = dense south wall
|
||
insolation_score = max(0, 100 - weighted_shadow_S * 10)
|
||
return {
|
||
"lat": lat, "lon": lon,
|
||
"n_buildings_500m": len(nearby),
|
||
"octants": counts,
|
||
"insolation_score": round(insolation_score, 1),
|
||
"shadow_pressure_S": round(weighted_shadow_S, 2),
|
||
"nearby_sample": sorted(nearby, key=lambda x: x["distance_m"])[:8],
|
||
}
|
||
|
||
|
||
@app.get("/api/poi-points")
|
||
def poi_points(category: str = "*"):
|
||
"""Returns flat array of [lat, lon, weight] for heatmap rendering.
|
||
category in {*, education, health, retail, transit, leisure, cafe}."""
|
||
GROUPS = {
|
||
"education": ["kindergarten","school","university"],
|
||
"health": ["pharmacy","clinic","hospital"],
|
||
"retail": ["shop_big","shop_med","shop_small"],
|
||
"transit": ["bus_stop","tram_stop","metro"],
|
||
"leisure": ["park","playground","sports"],
|
||
"cafe": ["cafe","restaurant"],
|
||
}
|
||
target_cats = GROUPS.get(category) # None means all
|
||
pts = []
|
||
seen = set()
|
||
for cat, elems in _osm_data.items():
|
||
if target_cats and cat not in target_cats: continue
|
||
for el in elems:
|
||
if el["type"] == "node":
|
||
la, lo = el.get("lat"), el.get("lon")
|
||
else:
|
||
c = el.get("center") or {}; la, lo = c.get("lat"), c.get("lon")
|
||
if la is None: continue
|
||
key = (round(la, 5), round(lo, 5))
|
||
if key in seen: continue
|
||
seen.add(key)
|
||
pts.append([la, lo, 0.5])
|
||
return {"points": pts, "count": len(pts), "category": category}
|
||
|
||
|
||
@app.get("/api/leaderboard")
|
||
def leaderboard(audience: str = "balanced", limit: int = 30,
|
||
obj_class: Optional[str] = None, district: Optional[str] = None,
|
||
min_score: float = 0):
|
||
"""Top-N ЖК ranked by chosen audience profile.
|
||
|
||
Audience profile re-weights the per-component scores already cached in `scores`.
|
||
"""
|
||
weights = AUDIENCES.get(audience, AUDIENCES["balanced"])
|
||
weights = {k: v for k, v in weights.items() if k != "label"}
|
||
with _conn() as c:
|
||
rows = c.execute("""
|
||
SELECT s.site_id, s.name, s.developer, s.obj_class, s.flat_count,
|
||
s.lat, s.lon, sd.district AS obj_district,
|
||
(SELECT score_0_100 FROM scores WHERE site_id=s.site_id AND component='education') AS edu,
|
||
(SELECT score_0_100 FROM scores WHERE site_id=s.site_id AND component='health') AS health,
|
||
(SELECT score_0_100 FROM scores WHERE site_id=s.site_id AND component='retail') AS retail,
|
||
(SELECT score_0_100 FROM scores WHERE site_id=s.site_id AND component='transit') AS transit,
|
||
(SELECT score_0_100 FROM scores WHERE site_id=s.site_id AND component='leisure') AS leisure,
|
||
(SELECT score_0_100 FROM scores WHERE site_id=s.site_id AND component='economic') AS economic,
|
||
(SELECT score_0_100 FROM scores WHERE site_id=s.site_id AND component='market') AS market,
|
||
m.project,
|
||
(SELECT real_median_price_m2 FROM district_economics
|
||
WHERE district=sd.district) AS price,
|
||
(SELECT real_velocity_6mo FROM district_economics
|
||
WHERE district=sd.district) AS vel
|
||
FROM sites s
|
||
LEFT JOIN site_district sd USING (site_id)
|
||
LEFT JOIN jk_objective_match m USING (site_id)
|
||
WHERE s.kind='jk'
|
||
""").fetchall()
|
||
out = []
|
||
for r in rows:
|
||
if obj_class and r["obj_class"] != obj_class: continue
|
||
if district and r["obj_district"] != district: continue
|
||
comps = {"education": r["edu"] or 0, "health": r["health"] or 0,
|
||
"retail": r["retail"] or 0, "transit": r["transit"] or 0,
|
||
"leisure": r["leisure"] or 0, "economic": r["economic"] or 0,
|
||
"market": r["market"] or 0}
|
||
weighted = sum(weights.get(k, 0) * v for k, v in comps.items())
|
||
if weighted < min_score: continue
|
||
out.append({
|
||
"site_id": r["site_id"], "name": r["name"], "developer": r["developer"],
|
||
"obj_class": r["obj_class"], "flat_count": r["flat_count"],
|
||
"obj_district": r["obj_district"], "lat": r["lat"], "lon": r["lon"],
|
||
"weighted": round(weighted, 2),
|
||
"matched_objective": bool(r["project"]),
|
||
"price_m2_kr": round(r["price"], 1) if r["price"] else None,
|
||
"velocity_6mo": round(r["vel"], 2) if r["vel"] else None,
|
||
"components": {k: round(v, 1) for k, v in comps.items()},
|
||
})
|
||
out.sort(key=lambda x: -x["weighted"])
|
||
return {"audience": audience, "weights": weights, "total_matched": len(out),
|
||
"results": out[:limit]}
|
||
|
||
|
||
@app.post("/api/bulk-analyze")
|
||
def bulk_analyze(payload: dict):
|
||
"""Analyze a batch of {cad,lat,lon} entries.
|
||
Body: {points: [{cad?,lat,lon,name?}], audience: 'family'}
|
||
"""
|
||
points = payload.get("points") or []
|
||
audience = payload.get("audience") or "balanced"
|
||
weights = AUDIENCES.get(audience, AUDIENCES["balanced"])
|
||
weights = {k: v for k, v in weights.items() if k != "label"}
|
||
results = []
|
||
for p in points[:50]: # cap 50
|
||
try:
|
||
res = score_point(p.get("lat"), p.get("lon"), weights)
|
||
results.append({
|
||
"cad": p.get("cad"), "name": p.get("name"),
|
||
"lat": p.get("lat"), "lon": p.get("lon"),
|
||
"weighted": res["weighted"], "rank": res["rank_overall"],
|
||
"district": res["district"], "scores": res["scores"],
|
||
})
|
||
except Exception as e:
|
||
results.append({"cad": p.get("cad"), "error": str(e)[:120]})
|
||
results.sort(key=lambda x: -(x.get("weighted") or 0))
|
||
return {"audience": audience, "n": len(results), "results": results}
|
||
|
||
|
||
@app.get("/api/jk-sellout/{site_id:path}")
|
||
def jk_sellout(site_id: str):
|
||
"""Months-to-sellout for a specific ЖК: current stock / 6mo velocity."""
|
||
with _conn() as c:
|
||
m = c.execute("SELECT project FROM jk_objective_match WHERE site_id=?",
|
||
(site_id,)).fetchone()
|
||
if not m:
|
||
return {"site_id": site_id, "matched": False}
|
||
project = m["project"]
|
||
agg = c.execute("""SELECT
|
||
COUNT(CASE WHEN status IN ('в продаже','резерв') THEN 1 END) AS stock_lots,
|
||
COUNT(CASE WHEN status IN ('в продаже','резерв') THEN 1 END)
|
||
* AVG(CASE WHEN area_pd>0 THEN area_pd END) AS stock_m2,
|
||
COUNT(CASE WHEN register_date >= date('now','-6 months') THEN 1 END)/6.0 AS vel_6mo,
|
||
COUNT(*) AS total,
|
||
SUM(CASE WHEN sold='да' THEN 1 ELSE 0 END) AS sold_n
|
||
FROM objective_lots WHERE project=?""", (project,)).fetchone()
|
||
d = dict(agg) if agg else {}
|
||
d["site_id"] = site_id
|
||
d["project"] = project
|
||
d["matched"] = True
|
||
d["months_to_sellout"] = (d["stock_lots"] / d["vel_6mo"]) if d.get("vel_6mo") and d["vel_6mo"] > 0 else None
|
||
return d
|
||
|
||
|
||
@app.get("/api/nearby-jk-velocity")
|
||
def nearby_jk_velocity(lat: float, lon: float, radius_m: int = 1500):
|
||
"""Per-complex velocity for ЖК within radius_m of point.
|
||
|
||
Returns list with: site_id, name, distance_m, velocity_6mo (deals/month),
|
||
n_lots_district, sold_pct_district, weighted_score.
|
||
Useful for direct-competitor analysis.
|
||
"""
|
||
with _conn() as c:
|
||
sites = c.execute("""SELECT s.site_id, s.name, s.lat, s.lon, s.developer, s.obj_class,
|
||
sd.district AS obj_district,
|
||
st.weighted, st.rank_overall,
|
||
m.project
|
||
FROM sites s
|
||
LEFT JOIN site_district sd USING (site_id)
|
||
LEFT JOIN scores_total st USING (site_id)
|
||
LEFT JOIN jk_objective_match m USING (site_id)
|
||
WHERE s.kind='jk'""").fetchall()
|
||
# per-project velocity from objective_lots
|
||
proj_vel = {r["project"]: (r["v"], r["sold"], r["nlots"]) for r in c.execute("""
|
||
SELECT project,
|
||
COUNT(CASE WHEN register_date >= date('now','-6 months') THEN 1 END)/6.0 AS v,
|
||
ROUND(100.0 * SUM(CASE WHEN sold='да' THEN 1 ELSE 0 END) / COUNT(*), 1) AS sold,
|
||
COUNT(*) AS nlots
|
||
FROM objective_lots
|
||
WHERE project IS NOT NULL
|
||
GROUP BY 1""").fetchall()}
|
||
out = []
|
||
for s in sites:
|
||
d = hav(lat, lon, s["lat"], s["lon"])
|
||
if d <= radius_m:
|
||
vel, sold, nlots = proj_vel.get(s["project"], (None, None, None))
|
||
out.append({
|
||
"site_id": s["site_id"], "name": s["name"], "lat": s["lat"], "lon": s["lon"],
|
||
"distance_m": round(d, 1), "developer": s["developer"], "obj_class": s["obj_class"],
|
||
"obj_district": s["obj_district"], "weighted": s["weighted"], "rank": s["rank_overall"],
|
||
"project_in_objective": s["project"],
|
||
"velocity_6mo": vel, "sold_pct": sold, "n_lots": nlots,
|
||
})
|
||
out.sort(key=lambda x: x["distance_m"])
|
||
return out
|
||
|
||
|
||
@app.post("/api/fetch-polygon/{cad}")
|
||
def fetch_polygon(cad: str):
|
||
"""Pull parcel polygon from Rosreestr via rosreestr2coord lib (bypasses NSPD WAF).
|
||
|
||
Caches to cache/parcel_polygons/<cad>.geojson and returns the FeatureCollection.
|
||
Workaround for NSPD WAF — the lib uses a different upstream that still works.
|
||
"""
|
||
try:
|
||
from rosreestr2coord.parser import Area
|
||
import shapely.geometry as sg
|
||
import pyproj
|
||
except ImportError as e:
|
||
raise HTTPException(500, f"Missing dependency: {e}. Install rosreestr2coord shapely pyproj.")
|
||
|
||
a = Area(cad, area_type=1, with_log=False, use_cache=False, timeout=20)
|
||
if not a.feature:
|
||
raise HTTPException(404, f"No polygon found for {cad}")
|
||
|
||
feature = dict(a.feature)
|
||
# rosreestr2coord returns WGS84 already despite metadata
|
||
feature["geometry"]["crs"] = {"type":"name","properties":{"name":"EPSG:4326"}}
|
||
poly = sg.shape(feature["geometry"])
|
||
centroid = poly.centroid
|
||
geod = pyproj.Geod(ellps="WGS84")
|
||
area_m2 = abs(geod.geometry_area_perimeter(poly)[0])
|
||
|
||
fc = {
|
||
"type":"FeatureCollection",
|
||
"features":[feature],
|
||
"_centroid": {"lat": centroid.y, "lon": centroid.x},
|
||
"_area_m2": round(area_m2, 1),
|
||
"_source": "rosreestr2coord",
|
||
"_props": feature["properties"].get("options", {}),
|
||
}
|
||
fname = (ROOT / "cache" / "parcel_polygons" / f"{cad.replace(':','_')}.geojson")
|
||
try:
|
||
fname.parent.mkdir(parents=True, exist_ok=True)
|
||
fname.write_text(json.dumps(fc, ensure_ascii=False, indent=2))
|
||
except OSError:
|
||
pass # cache mount is RO in prod — fc is still returned to caller
|
||
return fc
|
||
|
||
|
||
@app.post("/api/upload-polygon")
|
||
def upload_polygon(payload: dict):
|
||
"""Manually upload parcel polygon (e.g., copied from NSPD).
|
||
|
||
Body: {cad: '66:41:0204016:10', geojson: {...}}
|
||
Stored at cache/parcel_polygons/<cad>.geojson; subsequent /api/analyze
|
||
will use polygon centroid AND min-distance from polygon edges.
|
||
"""
|
||
cad = (payload.get("cad") or "").strip()
|
||
gj = payload.get("geojson")
|
||
if not cad or not gj:
|
||
raise HTTPException(400, "cad and geojson required")
|
||
fname = (ROOT / "cache" / "parcel_polygons" / f"{cad.replace(':','_')}.geojson")
|
||
try:
|
||
fname.parent.mkdir(parents=True, exist_ok=True)
|
||
fname.write_text(json.dumps(gj, ensure_ascii=False))
|
||
except OSError as e:
|
||
raise HTTPException(503, f"cache mount is read-only: {e}")
|
||
return {"saved": str(fname.relative_to(ROOT)), "size": fname.stat().st_size}
|
||
|
||
|
||
@app.get("/api/parcel-polygon/{cad}")
|
||
def get_polygon(cad: str):
|
||
fname = (ROOT / "cache" / "parcel_polygons" / f"{cad.replace(':','_')}.geojson")
|
||
if not fname.exists():
|
||
raise HTTPException(404, f"no polygon cached for {cad}")
|
||
return json.loads(fname.read_text())
|
||
|
||
|
||
# Static cache for jk polygons FeatureCollection
|
||
_JK_POLYGONS = None
|
||
def _load_jk_polygons():
|
||
global _JK_POLYGONS
|
||
if _JK_POLYGONS is None:
|
||
path = ROOT / "cache" / "jk_polygons.geojson"
|
||
if path.exists():
|
||
_JK_POLYGONS = json.loads(path.read_text())
|
||
# enrich features with site weighted+name from local DB
|
||
with _conn() as c:
|
||
site_meta = {r["site_id"]: dict(r) for r in c.execute(
|
||
"""SELECT s.site_id, s.name, s.developer, s.obj_class, s.district,
|
||
sd.district AS obj_district, st.weighted, st.rank_overall
|
||
FROM sites s
|
||
LEFT JOIN site_district sd USING (site_id)
|
||
LEFT JOIN scores_total st USING (site_id)
|
||
WHERE s.kind='jk'""").fetchall()}
|
||
for f in _JK_POLYGONS["features"]:
|
||
meta = site_meta.get(f["properties"]["site_id"], {})
|
||
f["properties"].update(meta)
|
||
else:
|
||
_JK_POLYGONS = {"type":"FeatureCollection","features":[]}
|
||
return _JK_POLYGONS
|
||
|
||
@app.get("/api/jk-polygons")
|
||
def jk_polygons():
|
||
"""Building footprint polygons matched to ЖК via spatial join (within 80m).
|
||
Returns ~314 buildings for 111 of 380 ЖК (29% coverage).
|
||
Properties enriched with site name, developer, score, rank."""
|
||
return _load_jk_polygons()
|
||
|
||
|
||
@app.get("/api/district-velocity-trend/{district}")
|
||
def district_velocity_trend(district: str):
|
||
"""Monthly registrations count for a district over last 12 months.
|
||
Source: objective_lots.register_date.
|
||
"""
|
||
with _conn() as c:
|
||
rows = c.execute("""
|
||
SELECT substr(register_date, 1, 7) AS m, COUNT(*) n
|
||
FROM objective_lots
|
||
WHERE district = ?
|
||
AND register_date IS NOT NULL
|
||
AND register_date >= date('now', '-12 months')
|
||
GROUP BY 1 ORDER BY 1""", (district,)).fetchall()
|
||
# Fill empty months
|
||
import datetime as dt
|
||
today = dt.date.today().replace(day=1)
|
||
months = []
|
||
y, m = today.year, today.month
|
||
for _ in range(12):
|
||
months.append(f"{y:04d}-{m:02d}")
|
||
m -= 1
|
||
if m == 0: m = 12; y -= 1
|
||
months.reverse()
|
||
series = {r["m"]: r["n"] for r in rows}
|
||
return [{"month": m, "n": series.get(m, 0)} for m in months]
|
||
|
||
|
||
class WhatIfPOI(BaseModel):
|
||
cat: str
|
||
lat: float
|
||
lon: float
|
||
name: Optional[str] = None
|
||
|
||
|
||
class AnalyzeIn(BaseModel):
|
||
cad: Optional[str] = None
|
||
lat: Optional[float] = None
|
||
lon: Optional[float] = None
|
||
name: Optional[str] = None
|
||
audience: Optional[str] = "balanced"
|
||
what_if: Optional[list] = None # [{cat, lat, lon, name?}]
|
||
remove_cats: Optional[list] = None # ["metro"]
|
||
|
||
|
||
@app.post("/api/analyze")
|
||
def analyze(payload: AnalyzeIn):
|
||
lat, lon = payload.lat, payload.lon
|
||
|
||
# If only cad given, try cached polygon
|
||
if (lat is None or lon is None) and payload.cad:
|
||
fname = (ROOT / "cache" / "parcel_polygons" /
|
||
f"{payload.cad.replace(':','_')}.geojson")
|
||
if fname.exists():
|
||
gj = json.loads(fname.read_text())
|
||
geom = gj.get("geometry") or (gj.get("features",[{}])[0].get("geometry")
|
||
if gj.get("type")=="FeatureCollection" else None)
|
||
if geom and geom.get("type") in ("Polygon","MultiPolygon"):
|
||
pts = geom["coordinates"][0] if geom["type"]=="Polygon" else geom["coordinates"][0][0]
|
||
lon = sum(p[0] for p in pts) / len(pts)
|
||
lat = sum(p[1] for p in pts) / len(pts)
|
||
|
||
if lat is None or lon is None:
|
||
raise HTTPException(400, "lat+lon required (or upload polygon for cad)")
|
||
|
||
weights = AUDIENCES.get(payload.audience or "balanced", AUDIENCES["balanced"])
|
||
weights = {k: v for k, v in weights.items() if k != "label"}
|
||
result = score_point(lat, lon, weights,
|
||
what_if=payload.what_if,
|
||
remove_cats=payload.remove_cats)
|
||
result["input"] = {"cad": payload.cad, "lat": lat, "lon": lon,
|
||
"name": payload.name, "audience": payload.audience or "balanced",
|
||
"what_if": payload.what_if, "remove_cats": payload.remove_cats}
|
||
# Include cached polygon if present
|
||
if payload.cad:
|
||
fname = (ROOT / "cache" / "parcel_polygons" /
|
||
f"{payload.cad.replace(':','_')}.geojson")
|
||
if fname.exists():
|
||
try: result["polygon"] = json.loads(fname.read_text())
|
||
except: pass
|
||
return result
|
||
|
||
|
||
# Service Worker must be served from root scope so it can intercept all tile requests
|
||
@app.get("/sw.js")
|
||
def sw():
|
||
sw_path = STATIC / "sw.js"
|
||
if not sw_path.exists():
|
||
raise HTTPException(404)
|
||
return FileResponse(sw_path, media_type="application/javascript",
|
||
headers={"Service-Worker-Allowed": "/", "Cache-Control": "no-cache"})
|
||
|
||
|
||
# Static UI (mount last)
|
||
if STATIC.exists():
|
||
app.mount("/", StaticFiles(directory=STATIC, html=True), name="static")
|