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/).
229 lines
9.8 KiB
Python
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()
|