gendesign/site-finder/10_score_v2.py
bot-backend 9e5086890f
Some checks failed
CI / changes (pull_request) Has been cancelled
CI / backend-tests (pull_request) Has been cancelled
CI / frontend-tests (pull_request) Has been cancelled
CI / openapi-codegen-check (pull_request) Has been cancelled
CI / changes (push) Successful in 8s
CI / backend-tests (push) Has been cancelled
CI / frontend-tests (push) Has been cancelled
CI / openapi-codegen-check (push) Has been cancelled
fix(site-finder): remove dead sat_factor (computed+written, never applied) (#1509)
sat_factor = 1 + ((sold_pct-50)/100)*0.30 was computed in 09_macro_and_trend.py,
written to district_economics.sat_factor, and fetched in server.py and 10_score_v2.py
— but never multiplied into any score. The live market sub-score uses a separate
sat_score = min(100, sold_pct*100/70) directly, so sat_factor was dead code that
would double-count absorption if ever wired in.

- 09_macro_and_trend.py: remove sat_factor computation, ALTER TABLE column, UPDATE
  binding, and debug print column
- 10_score_v2.py: remove sat_factor from SELECT and unpacking
- server.py: remove sat_factor variable assignment and from macro_factors response
- static/index.html: remove sat_factor documentation row
- data/sql/162_drop_district_economics_sat_factor.sql: DROP COLUMN IF EXISTS
2026-06-17 21:08:54 +03:00

191 lines
9.3 KiB
Python
Raw Permalink 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.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, 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; 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()