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/).
169 lines
7.7 KiB
Python
169 lines
7.7 KiB
Python
"""Macro context + per-district velocity trend.
|
||
|
||
Adds these tables/columns:
|
||
macro_context — single-row mortgage rate, city avg price, etc.
|
||
district_economics + (real_velocity_6mo, real_velocity_prior_6mo, real_trend_ratio,
|
||
sat_factor, trend_factor, price_factor)
|
||
scoring_weights — single-row config (so we can change weights from UI later)
|
||
|
||
Source for trend: per-flat register_date (objective_lots).
|
||
Source for mortgage rate: prod cbr_mortgage_series (Sverdl region, latest "ставка ипотечная").
|
||
"""
|
||
import sqlite3, pathlib, psycopg2, datetime as dt, statistics
|
||
DB = pathlib.Path(__file__).parent / "analysis.db"
|
||
|
||
EXTRA = """
|
||
CREATE TABLE IF NOT EXISTS macro_context (
|
||
key TEXT PRIMARY KEY,
|
||
value REAL,
|
||
label TEXT,
|
||
period TEXT,
|
||
fetched_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||
);
|
||
|
||
ALTER TABLE district_economics ADD COLUMN real_velocity_6mo REAL;
|
||
ALTER TABLE district_economics ADD COLUMN real_velocity_prior_6mo REAL;
|
||
ALTER TABLE district_economics ADD COLUMN real_trend_ratio REAL;
|
||
ALTER TABLE district_economics ADD COLUMN sat_factor REAL;
|
||
ALTER TABLE district_economics ADD COLUMN trend_factor REAL;
|
||
ALTER TABLE district_economics ADD COLUMN price_factor REAL;
|
||
|
||
CREATE TABLE IF NOT EXISTS scoring_weights (
|
||
component TEXT PRIMARY KEY,
|
||
weight REAL NOT NULL,
|
||
note TEXT
|
||
);
|
||
"""
|
||
|
||
def safe_alter(conn, sql):
|
||
for stmt in sql.strip().split(";"):
|
||
s = stmt.strip()
|
||
if not s: continue
|
||
try: conn.execute(s)
|
||
except sqlite3.OperationalError as e:
|
||
if "duplicate column" not in str(e): raise
|
||
|
||
def main():
|
||
local = sqlite3.connect(DB)
|
||
safe_alter(local, EXTRA)
|
||
local.commit()
|
||
|
||
# ---- 1. Pull macro from prod ----
|
||
pg = psycopg2.connect(host="127.0.0.1", port=15432, user="gendesign",
|
||
password="2J2SBPMKuS998fiwhtQqDhMI", dbname="gendesign")
|
||
cur = pg.cursor()
|
||
|
||
# Mortgage rate (Sverdl, latest weighted average)
|
||
cur.execute("""SELECT period, value FROM cbr_mortgage_series
|
||
WHERE region='sverdl' AND title ILIKE '%ставка%ипотечн%'
|
||
ORDER BY period DESC LIMIT 1""")
|
||
r = cur.fetchone()
|
||
if r:
|
||
local.execute("""INSERT OR REPLACE INTO macro_context(key,value,label,period)
|
||
VALUES ('mortgage_rate_sverdl', ?,
|
||
'Средневзв. ставка по ипотеке (Свердл, %)', ?)""",
|
||
(float(r[1]), r[0]))
|
||
print(f"Mortgage rate Sverdl ({r[0]}): {r[1]}%")
|
||
|
||
# City average POI density per домрф_kn (для prod-стиля POI factor)
|
||
cur.execute("""SELECT count(*)::float / (SELECT count(DISTINCT obj_id) FROM domrf_kn_infrastructure)
|
||
FROM domrf_kn_infrastructure
|
||
WHERE distance_m <= 1000""")
|
||
avg_poi = cur.fetchone()[0]
|
||
local.execute("""INSERT OR REPLACE INTO macro_context(key,value,label,period)
|
||
VALUES ('city_avg_poi_1km', ?, 'Средний POI/ЖК в 1км (Ekb)', '2026-05')""",
|
||
(avg_poi,))
|
||
print(f"City avg POI 1km: {avg_poi:.1f}")
|
||
|
||
pg.close()
|
||
|
||
# ---- 2. Velocity trend per district (6mo vs prior 6mo) ----
|
||
today = dt.date.today()
|
||
cut_recent = (today - dt.timedelta(days=180)).isoformat()
|
||
cut_prior = (today - dt.timedelta(days=360)).isoformat()
|
||
|
||
rows = local.execute("""
|
||
SELECT district, register_date, project, corpus
|
||
FROM objective_lots
|
||
WHERE district IS NOT NULL AND register_date IS NOT NULL""").fetchall()
|
||
by_d = {}
|
||
for d, r, p, c in rows:
|
||
try: rd = dt.date.fromisoformat(r[:10])
|
||
except: continue
|
||
bucket = "recent" if r >= cut_recent else ("prior" if r >= cut_prior else None)
|
||
if not bucket: continue
|
||
by_d.setdefault(d, {"recent":[], "prior":[], "corpuses":set()})
|
||
by_d[d][bucket].append(rd)
|
||
by_d[d]["corpuses"].add((p, c))
|
||
|
||
for d, info in by_d.items():
|
||
n_corp = max(len(info["corpuses"]), 1)
|
||
v_rec = len(info["recent"]) / 6 / n_corp
|
||
v_prior = len(info["prior"]) / 6 / n_corp
|
||
ratio = (v_rec / v_prior) if v_prior > 0 else (None if v_rec == 0 else 2.0)
|
||
# Prod-style clamping
|
||
trend_factor = max(0.7, min(2.0, ratio)) if ratio else 1.0
|
||
# sat_factor: sold_pct in district. >50% = mature market (prod logic +30%
|
||
# multiplier on velocity for mature districts since absorption is proven)
|
||
sold_pct = local.execute(
|
||
"SELECT real_sold_pct FROM district_economics WHERE district=?", (d,)).fetchone()
|
||
sat_factor = 1 + ((sold_pct[0] or 50) - 50) / 100 * 0.30 if sold_pct and sold_pct[0] else 1.0
|
||
local.execute("""UPDATE district_economics
|
||
SET real_velocity_6mo=?, real_velocity_prior_6mo=?,
|
||
real_trend_ratio=?, sat_factor=?, trend_factor=?
|
||
WHERE district=?""",
|
||
(v_rec, v_prior, ratio, sat_factor, trend_factor, d))
|
||
|
||
# price_factor = district median / city median
|
||
city_med = local.execute(
|
||
"SELECT real_median_price_m2 FROM district_economics ORDER BY real_n_lots DESC LIMIT 1"
|
||
).fetchone()
|
||
# actually weighted by lots
|
||
weighted_rows = local.execute(
|
||
"SELECT real_median_price_m2, real_n_lots FROM district_economics WHERE real_median_price_m2 IS NOT NULL"
|
||
).fetchall()
|
||
if weighted_rows:
|
||
s = sum((p or 0) * (n or 0) for p, n in weighted_rows)
|
||
wn = sum(n or 0 for _, n in weighted_rows)
|
||
city_med = s / wn if wn else 100
|
||
else:
|
||
city_med = 100
|
||
local.execute("""INSERT OR REPLACE INTO macro_context(key,value,label,period)
|
||
VALUES ('city_med_price_m2', ?, 'Средневзв. цена м² Ekb (тыс ₽)', '2026-05')""",
|
||
(city_med,))
|
||
print(f"City weighted median price: {city_med:.1f} тыс ₽/м²")
|
||
|
||
local.execute("""UPDATE district_economics
|
||
SET price_factor = real_median_price_m2 / ?
|
||
WHERE real_median_price_m2 IS NOT NULL""", (city_med,))
|
||
local.commit()
|
||
|
||
# ---- 3. Default weights ----
|
||
weights = [
|
||
("education", 0.18, "Школы, садики, ВУЗы"),
|
||
("health", 0.10, "Аптеки, поликлиники, больницы"),
|
||
("retail", 0.13, "Магазины (большие/средние/малые)"),
|
||
("transit", 0.15, "Метро, трамвай, автобус"),
|
||
("leisure", 0.09, "Парки, площадки, спорт"),
|
||
("economic", 0.30, "Цена, скорость продаж, тренд (Объектив)"),
|
||
("market", 0.05, "Конкурентная плотность, sat/trend факторы"),
|
||
]
|
||
local.execute("DELETE FROM scoring_weights")
|
||
for c, w, n in weights:
|
||
local.execute("INSERT INTO scoring_weights VALUES (?,?,?)", (c, w, n))
|
||
local.commit()
|
||
|
||
print("\nDistrict trend (top 10 by recent velocity):")
|
||
print(f"{'район':<22}{'price_f':>8}{'sat':>6}{'trend':>6}{'v_rec':>6}{'v_pr':>6}{'ratio':>6}")
|
||
for r in local.execute("""SELECT district, price_factor, sat_factor, trend_factor,
|
||
real_velocity_6mo, real_velocity_prior_6mo, real_trend_ratio
|
||
FROM district_economics
|
||
WHERE real_velocity_6mo IS NOT NULL
|
||
ORDER BY real_velocity_6mo DESC LIMIT 10""").fetchall():
|
||
d,pf,sf,tf,vr,vp,rr = r
|
||
print(f"{d:<22}{pf or 0:>8.2f}{sf or 0:>6.2f}{tf or 0:>6.2f}{vr or 0:>6.2f}{vp or 0:>6.2f}{rr or 0:>6.2f}")
|
||
|
||
local.close()
|
||
|
||
if __name__ == "__main__":
|
||
main()
|