gendesign/site-finder/03_score.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

229 lines
9.8 KiB
Python

"""Compute features and weighted parcel score.
Per-site feature vector:
edu_kindergarten_nearest_m, edu_school_nearest_m, edu_university_nearest_m
health_pharmacy_nearest_m, health_clinic_nearest_m, health_hospital_nearest_m
retail_big_nearest_m, retail_med_nearest_m, retail_small_nearest_m
transit_bus_nearest_m, transit_tram_nearest_m, transit_metro_nearest_m
leisure_park_nearest_m, leisure_playground_nearest_m, leisure_sports_nearest_m
+ counts within 500m / 1000m for each category
Component scores 0..100 (higher = better):
education = avg(score(kindergarten,500m), score(school,800m))
health = avg(score(pharmacy,500m), score(clinic,1000m))
retail = max(score(shop_big,1000m), 0.7*score(shop_med,500m))
transit = max(score(metro,1500m), 0.8*score(tram,500m), 0.6*score(bus,300m))
leisure = avg(score(park,800m), score(playground,400m), score(sports,800m))
Distance-to-score: piecewise linear,
0..ideal_m → 100
ideal_m..max_m → 100..0
>max_m → 0
Total weighted (sums to 1.0):
education 0.30 (садики/школы — главное для семейных, ядро ЦА девелопера)
health 0.15
retail 0.20
transit 0.20
leisure 0.15
"""
import sqlite3, pathlib
DB = pathlib.Path(__file__).parent / "analysis.db"
# (category, ideal_m, max_m, weight_in_component)
EDU = [("kindergarten", 300, 1000, 1.0),
("school", 400, 1500, 1.0),
("university", 1000, 5000, 0.3)] # nice-to-have
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_MAIN = [("metro", 1000, 3000, 1.0)]
TRANSIT_TRAM = [("tram_stop", 400, 1500, 1.0)]
TRANSIT_BUS = [("bus_stop", 200, 800, 1.0)]
LEISURE = [("park", 500, 2000, 1.0),
("playground", 200, 700, 1.0),
("sports", 500, 2000, 0.7)]
WEIGHTS = {
"education": 0.20,
"health": 0.10,
"retail": 0.15,
"transit": 0.15,
"leisure": 0.10,
"economic": 0.30,
}
# Economic sub-weights (sum to 1, used inside `economic` component)
ECON_WEIGHTS = {"price": 0.50, "velocity": 0.25, "liquidity": 0.25}
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 component_score(conn, site_id, cats):
"""Average distance-score across the categories listed (with weights)."""
total_w = sum(c[3] for c in cats)
s = 0.0
for cat, ideal, mx, w in cats:
nearest = conn.execute(
"SELECT MIN(distance_m) FROM pois WHERE site_id=? AND category=?",
(site_id, cat)
).fetchone()[0]
s += w * dist_score(nearest, ideal, mx)
return s / total_w if total_w else 0.0
def features(conn, site_id):
"""Materialize feature vector — nearest distance + counts within 500/1000m."""
feats = {}
cats = ["kindergarten","school","university","pharmacy","clinic","hospital",
"shop_big","shop_med","shop_small","bus_stop","tram_stop","metro",
"park","playground","sports"]
for cat in cats:
nearest = conn.execute(
"SELECT MIN(distance_m) FROM pois WHERE site_id=? AND category=?",
(site_id, cat)).fetchone()[0]
c500 = conn.execute(
"SELECT count(*) FROM pois WHERE site_id=? AND category=? AND distance_m<=500",
(site_id, cat)).fetchone()[0]
c1000 = conn.execute(
"SELECT count(*) FROM pois WHERE site_id=? AND category=? AND distance_m<=1000",
(site_id, cat)).fetchone()[0]
feats[f"{cat}_nearest_m"] = nearest
feats[f"{cat}_count_500m"] = c500
feats[f"{cat}_count_1km"] = c1000
return feats
def economic_score(conn, site_id, econ_bounds):
"""Returns (component_0_100, breakdown dict) using REAL per-flat district aggregates.
Prefers real_* columns from objective_lots (Поквартирные/Лоты, 303k lots);
falls back to weighted_price_m2 + deals_per_month_avg from corp_sum if missing.
"""
row = conn.execute("""SELECT
COALESCE(de.real_median_price_m2, de.weighted_price_m2) AS price,
COALESCE(de.real_velocity_per_month, de.deals_per_month_avg) AS v,
de.months_to_sellout, de.real_sold_pct
FROM site_district sd
JOIN district_economics de USING (district)
WHERE sd.site_id=?""", (site_id,)).fetchone()
if not row:
return 0.0, {}
price, v, mts, sold_pct = row
pmin, pmax, vmax, mts_cap = econ_bounds
p_score = max(0.0, min(100.0, (price - pmin) * 100.0 / (pmax - pmin))) if price else 0.0
v_score = max(0.0, min(100.0, (v or 0) * 100.0 / vmax))
if mts is None:
liq_score = 0.0
else:
liq_score = max(0.0, 100.0 - min(mts, mts_cap) * 100.0 / mts_cap)
total = (ECON_WEIGHTS["price"] * p_score
+ ECON_WEIGHTS["velocity"] * v_score
+ ECON_WEIGHTS["liquidity"] * liq_score)
return total, {"price_score": p_score, "velocity_score": v_score,
"liquidity_score": liq_score, "median_price_m2": price,
"velocity": v, "months_to_sellout": mts,
"district_sold_pct": sold_pct}
def main():
conn = sqlite3.connect(DB)
sites = conn.execute("SELECT site_id, kind FROM sites").fetchall()
conn.execute("DELETE FROM features")
conn.execute("DELETE FROM scores")
conn.execute("DELETE FROM scores_total")
# Compute econ bounds from district_economics — prefer real_*
prices = sorted([r[0] for r in conn.execute(
"SELECT COALESCE(real_median_price_m2, weighted_price_m2) FROM district_economics "
"WHERE COALESCE(real_median_price_m2, weighted_price_m2) IS NOT NULL").fetchall()])
if not prices: prices = [100, 200]
pmin, pmax = prices[0], prices[-1]
if pmax <= pmin: pmax = pmin + 1
vels = sorted([r[0] for r in conn.execute(
"SELECT COALESCE(real_velocity_per_month, deals_per_month_avg) FROM district_economics "
"WHERE COALESCE(real_velocity_per_month, deals_per_month_avg) IS NOT NULL").fetchall()])
vmax = vels[int(len(vels) * 0.9)] if vels else 1.0
if vmax <= 0: vmax = 1.0
econ_bounds = (pmin, pmax, vmax, 24.0)
print(f"Econ bounds (real): price [{pmin:.1f}, {pmax:.1f}] тыс, vel_p90={vmax:.2f}, mts_cap=24 мес")
for site_id, kind in sites:
# features
for k, v in features(conn, site_id).items():
conn.execute("INSERT INTO features(site_id,feature,value) VALUES (?,?,?)",
(site_id, k, float(v) if v is not None else None))
# component scores
edu = component_score(conn, site_id, EDU)
health = component_score(conn, site_id, HEALTH)
# retail = max(big, 0.7*med). med alone is enough for daily needs.
big = component_score(conn, site_id, RETAIL_BIG)
med = component_score(conn, site_id, RETAIL_MED)
retail = max(big, 0.7 * med)
# transit: best of metro / tram / bus
metro = component_score(conn, site_id, TRANSIT_MAIN)
tram = component_score(conn, site_id, TRANSIT_TRAM)
bus = component_score(conn, site_id, TRANSIT_BUS)
transit = max(metro, 0.85 * tram, 0.7 * bus)
leisure = component_score(conn, site_id, LEISURE)
econ, _ = economic_score(conn, site_id, econ_bounds)
comps = {"education": edu, "health": health, "retail": retail,
"transit": transit, "leisure": leisure, "economic": econ}
for c, v in comps.items():
conn.execute("INSERT INTO scores(site_id,component,score_0_100) VALUES (?,?,?)",
(site_id, c, v))
weighted = sum(WEIGHTS[c] * v for c, v in comps.items())
conn.execute("INSERT INTO scores_total(site_id,weighted) VALUES (?,?)",
(site_id, weighted))
conn.commit()
# Compute ranks
rows = conn.execute("""SELECT s.site_id, s.kind, s.district, st.weighted
FROM sites s JOIN scores_total st USING (site_id)
ORDER BY st.weighted DESC""").fetchall()
for rank, (sid, kind, _, _) in enumerate(rows, 1):
conn.execute("UPDATE scores_total SET rank_overall=? WHERE site_id=?", (rank, sid))
# district rank
by_dist = {}
for sid, kind, dist, _ in rows:
by_dist.setdefault(dist or "", []).append(sid)
for dist, 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()
# Summary
parcel_id = "parcel:66:41:0204016:10"
p = conn.execute("SELECT weighted, rank_overall FROM scores_total WHERE site_id=?",
(parcel_id,)).fetchone()
n = conn.execute("SELECT count(*) FROM sites").fetchone()[0]
print(f"\n=== Parcel score ===")
print(f" weighted: {p[0]:.1f}/100 rank: {p[1]}/{n}")
print("\n Components:")
for c, v in conn.execute("SELECT component, score_0_100 FROM scores WHERE site_id=?",
(parcel_id,)).fetchall():
print(f" {c:<10} {v:.1f}")
print("\n Nearest POIs (m):")
for k, v in conn.execute(
"SELECT feature, value FROM features WHERE site_id=? AND feature LIKE '%_nearest_m'",
(parcel_id,)).fetchall():
print(f" {k:<35} {('%.0f m'%v) if v is not None else ''}")
print("\n=== Top-5 ЖК in Ekb ===")
for r in conn.execute("""SELECT s.name, s.district, st.weighted, st.rank_overall
FROM sites s JOIN scores_total st USING (site_id)
WHERE s.kind='jk' ORDER BY st.weighted DESC LIMIT 5""").fetchall():
print(f" #{r[3]:>3} {r[2]:>5.1f} [{(r[1] or ''):<25}] {r[0]}")
conn.close()
if __name__ == "__main__":
main()