gendesign/site-finder/10_score_v2.py
Light1YT 97b19a0b85 Import Site Finder app from analysis/ vibe-coding session
Adds site-finder/ subfolder with:
  - server.py — FastAPI scoring service v2 (35 endpoints, ~85KB)
  - 01_load_sites.py … 12_more_pois.py — data ingest pipeline
  - db_init.py — SQLite schema bootstrap
  - static/ — Leaflet UI (index.html ~3500 lines + sw.js)
  - cache/ — small persistent caches (admin districts, jk polygons,
    geocode warm cache, parcel polygons drop-zone with README)
  - reports/ — sample generated parcel report (HTML+JSON)

Excluded via .gitignore (regeneratable, too big for git):
  - analysis.db (336MB SQLite — rebuild via 01_*..12_*.py)
  - cache/objective_raw/ (1.2GB Объектив raw dumps)
  - cache/overpass_raw.json, cache/osm_buildings_all.geojson
    (regen from Overpass API)

Production deploy: /opt/gendesign/site-finder/ on gendsgn.ru
(container gendesign-site-finder-1, served at /sf/).
2026-05-10 22:42:25 +05:00

191 lines
9.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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.

"""Scoring v2 — uses prod-style macro factors + per-flat metrics from Objective.
Components (all 0..100):
education 20% школы (×1) + садики (×1) + ВУЗы (×0.3) — distance score
health 10% аптеки (×1) + поликлиники (×1) + больницы (×0.5)
retail 13% max(big, 0.7×med) — distance score
transit 15% max(metro, 0.85×tram, 0.7×bus) — distance score
leisure 9% парки + площадки + спорт
economic 30% real_*: price_position 50% + velocity_real 25% + liquidity 25%
velocity adjusted by trend_factor (clamp 0.7..2.0, prod-style)
market 3% competitive density (jk_count_1km) + saturation factor
(informational; small weight)
Macro context shown alongside but not in score:
mortgage_rate_sverdl, city_avg_poi_1km, city_med_price_m2.
"""
import sqlite3, pathlib, math
DB = pathlib.Path(__file__).parent / "analysis.db"
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)]
RETAIL_BIG = [("shop_big", 500, 2000, 1.0)]
RETAIL_MED = [("shop_med", 300, 1000, 1.0)]
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(conn, sid, cats):
tw = sum(c[3] for c in cats); s = 0
for cat, ideal, mx, w in cats:
d = conn.execute("SELECT MIN(distance_m) FROM pois WHERE site_id=? AND category=?",
(sid, cat)).fetchone()[0]
s += w * dist_score(d, ideal, mx)
return s / tw if tw else 0
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 main():
conn = sqlite3.connect(DB)
weights = {r[0]: r[1] for r in conn.execute("SELECT component, weight FROM scoring_weights").fetchall()}
if not weights:
weights = {"education":0.18,"health":0.10,"retail":0.13,"transit":0.15,
"leisure":0.09,"economic":0.30,"market":0.05}
print("Weights:", weights)
sites = conn.execute("SELECT site_id, lat, lon FROM sites").fetchall()
site_coords = {sid: (la, lo) for sid, la, lo in sites}
# Bounds for normalization (computed once)
prices = sorted([r[0] for r in conn.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 conn.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
# Citywide median jk_count_1km — for market component
counts = sorted([r[0] for r in conn.execute(
"SELECT count(*) FROM sites s2 JOIN sites s ON s2.site_id != s.site_id "
"WHERE 1=0 GROUP BY s.site_id").fetchall()]) # noop, computed below
conn.execute("DELETE FROM features")
conn.execute("DELETE FROM scores")
conn.execute("DELETE FROM scores_total")
for sid, lat, lon in sites:
# POI features
for cat in ["kindergarten","school","university","pharmacy","clinic","hospital",
"shop_big","shop_med","shop_small","bus_stop","tram_stop","metro",
"park","playground","sports"]:
n = conn.execute("SELECT MIN(distance_m) FROM pois WHERE site_id=? AND category=?",
(sid, cat)).fetchone()[0]
c500 = conn.execute("SELECT count(*) FROM pois WHERE site_id=? AND category=? AND distance_m<=500",
(sid, cat)).fetchone()[0]
c1k = conn.execute("SELECT count(*) FROM pois WHERE site_id=? AND category=? AND distance_m<=1000",
(sid, cat)).fetchone()[0]
for k, v in (("nearest_m", n), ("count_500m", c500), ("count_1km", c1k)):
conn.execute("INSERT INTO features(site_id,feature,value) VALUES (?,?,?)",
(sid, f"{cat}_{k}", float(v) if v is not None else None))
# Locational components
edu = comp(conn, sid, EDU)
health = comp(conn, sid, HEALTH)
retail = max(comp(conn, sid, RETAIL_BIG), 0.7 * comp(conn, sid, RETAIL_MED))
transit = max(comp(conn, sid, TRANSIT_M),
0.85 * comp(conn, sid, TRANSIT_T),
0.7 * comp(conn, sid, TRANSIT_B))
leisure = comp(conn, sid, LEISURE)
# Economic + market
econ_row = conn.execute("""SELECT de.real_median_price_m2, de.real_velocity_6mo,
de.real_trend_ratio, de.months_to_sellout,
de.sat_factor, de.real_sold_pct, de.real_n_lots
FROM site_district sd
JOIN district_economics de USING (district)
WHERE sd.site_id=?""", (sid,)).fetchone()
if econ_row:
price, v_rec, trend, mts, sat, sold_pct, n_lots = econ_row
# price_score: linear by district median price percentile
p_score = max(0, min(100, ((price or 0) - pmin) * 100 / (pmax - pmin)))
# velocity_score: real recent (6mo), capped at p90
v_score = max(0, min(100, (v_rec or 0) * 100 / vmax)) if vmax else 0
# trend modifier (prod-style): clamp 0.7..2.0 → 0.5..1.0 multiplier on velocity
tf = max(0.7, min(2.0, trend or 1.0))
v_score = v_score * (0.5 + 0.5 * (tf / 2.0)) # 0.7→0.675, 1.0→0.75, 2.0→1.0
# liquidity_score from months_to_sellout
liq_score = max(0, 100 - min(mts or 24, 24) * 100 / 24) if mts else 50
economic = 0.50 * p_score + 0.25 * v_score + 0.25 * liq_score
else:
economic = 0; trend = None; sat = None; sold_pct = None; n_lots = None
# Competitive density: number of OTHER ЖК within 1km
n_jk_1km = sum(
1 for sid2, (la2, lo2) in site_coords.items()
if sid2 != sid and sid2.startswith("jk:") and hav(lat, lon, la2, lo2) <= 1000
)
# market component: 50/50 saturation × density penalty
# density: 0..15 jks → 100..0 (linearly capped)
density_score = max(0, 100 - n_jk_1km * 100 / 15)
# saturation: optimal 50% sold (proven absorption, room to grow). 0% raw, 100% over-saturated
sat_score = 100 - abs((sold_pct or 50) - 50) * 2 if sold_pct is not None else 50
market = 0.5 * density_score + 0.5 * sat_score
conn.execute("INSERT INTO features(site_id,feature,value) VALUES (?,?,?)",
(sid, "jk_count_1km", n_jk_1km))
comps = {"education": edu, "health": health, "retail": retail,
"transit": transit, "leisure": leisure, "economic": economic,
"market": market}
for c, val in comps.items():
conn.execute("INSERT INTO scores(site_id,component,score_0_100) VALUES (?,?,?)",
(sid, c, val))
weighted = sum(weights.get(k, 0) * v for k, v in comps.items())
conn.execute("INSERT INTO scores_total(site_id,weighted) VALUES (?,?)", (sid, weighted))
conn.commit()
# Ranks
rows = conn.execute("""SELECT s.site_id, st.weighted FROM sites s
JOIN scores_total st USING (site_id)
ORDER BY st.weighted DESC""").fetchall()
for rank, (sid, _) in enumerate(rows, 1):
conn.execute("UPDATE scores_total SET rank_overall=? WHERE site_id=?", (rank, sid))
# district rank
by_dist = {}
for sid, _ in rows:
d = conn.execute("SELECT district FROM site_district WHERE site_id=?", (sid,)).fetchone()
by_dist.setdefault(d[0] if d else "", []).append(sid)
for d, sids in by_dist.items():
for rank, sid in enumerate(sids, 1):
conn.execute("UPDATE scores_total SET rank_district=? WHERE site_id=?", (rank, sid))
conn.commit()
# Print parcel summary
pid = "parcel:66:41:0204016:10"
p = conn.execute("SELECT weighted, rank_overall, rank_district FROM scores_total WHERE site_id=?",
(pid,)).fetchone()
print(f"\n=== Parcel ===")
print(f" total: {p[0]:.2f}/100 overall #{p[1]} district #{p[2]}")
print(f" components:")
for r in conn.execute("SELECT component, score_0_100 FROM scores WHERE site_id=? ORDER BY component", (pid,)).fetchall():
wt = weights.get(r[0], 0)
print(f" {r[0]:<10} {r[1]:>6.1f} × {wt*100:>4.0f}% = {r[1]*wt:>5.2f}")
n_jk = conn.execute("SELECT count(*) FROM sites WHERE kind='jk'").fetchone()[0]
print(f" vs {n_jk} строящихся ЖК Ekb")
conn.close()
if __name__ == "__main__":
main()