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
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
191 lines
9.3 KiB
Python
191 lines
9.3 KiB
Python
"""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()
|