gendesign/site-finder/server.py
lekss361 c514b992ee
Some checks are pending
Deploy / changes (push) Waiting to run
Deploy / build-backend (push) Blocked by required conditions
Deploy / build-worker (push) Blocked by required conditions
Deploy / build-frontend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
Merge pull request 'fix(site-finder): remove dead sat_factor (computed+written, never applied) (#1509)' (#1698) from fix/score-sat-factor-1509 into main
2026-06-17 18:28:45 +00:00

1824 lines
84 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 ----------
# Эмпирические пороги score для ЕКБ: средний диапазон 15-30, max редко >40.
# Зеркало SCORE_THRESHOLDS из backend/app/api/v1/parcels.py — менять синхронно.
SCORE_THRESHOLDS: dict[str, float] = {"плохо": 5.0, "средне": 15.0, "хорошо": 25.0, "отлично": 40.0}
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'. Only parcels with weighted score >= SCORE_THRESHOLDS['хорошо']
(25.0) are surfaced — below that threshold the suggestion has no meaningful quality signal."""
weights = AUDIENCES.get(audience, AUDIENCES["balanced"])
weights = {k: v for k, v in weights.items() if k != "label"}
min_weighted = SCORE_THRESHOLDS["хорошо"]
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())
if weighted < min_weighted:
continue
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")