gendesign/site-finder/04_report.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

274 lines
14 KiB
Python
Raw 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.

"""Generate JSON + HTML comparison report."""
import sqlite3, pathlib, json, statistics
DB = pathlib.Path(__file__).parent / "analysis.db"
OUT = pathlib.Path(__file__).parent / "reports"
OUT.mkdir(exist_ok=True)
PARCEL_ID = "parcel:66:41:0204016:10"
def fetch_economics(conn, site_id):
row = conn.execute("""SELECT sd.district, sd.method, sd.nearest_jk_dist_m,
de.n_projects, de.weighted_price_m2, de.median_price_m2,
de.deals_per_month_avg, de.months_to_sellout,
de.real_n_lots, de.real_n_sold, de.real_sold_pct,
de.real_median_price_m2, de.real_p25_price_m2, de.real_p75_price_m2,
de.real_avg_area_sold, de.real_velocity_per_month,
de.real_avg_readiness_pct
FROM site_district sd
LEFT JOIN district_economics de USING (district)
WHERE sd.site_id=?""", (site_id,)).fetchone()
if not row: return None
cols = ["district","district_method","district_dist_m",
"n_projects","weighted_price_m2_corp_sum","median_price_m2_corp_sum",
"deals_per_month_corp_sum","months_to_sellout",
"n_lots","n_sold","sold_pct",
"median_price_m2","p25_price_m2","p75_price_m2",
"avg_area_sold_m2","velocity_per_month","avg_readiness_pct"]
return dict(zip(cols, row))
def fetch_site_full(conn, site_id):
s = dict(zip(
[c[0] for c in conn.execute("PRAGMA table_info(sites)").fetchall()],
conn.execute("SELECT * FROM sites WHERE site_id=?", (site_id,)).fetchone()
)) if False else None
cur = conn.execute("SELECT * FROM sites WHERE site_id=?", (site_id,))
cols = [c[0] for c in cur.description]
s = dict(zip(cols, cur.fetchone()))
s["features"] = dict(conn.execute(
"SELECT feature,value FROM features WHERE site_id=?", (site_id,)).fetchall())
s["scores"] = dict(conn.execute(
"SELECT component,score_0_100 FROM scores WHERE site_id=?", (site_id,)).fetchall())
tot = conn.execute(
"SELECT weighted,rank_overall,rank_district FROM scores_total WHERE site_id=?",
(site_id,)).fetchone()
s["weighted_total"] = tot[0]
s["rank_overall"] = tot[1]
s["rank_district"] = tot[2]
s["economics"] = fetch_economics(conn, site_id)
s["nearest_pois"] = {}
for cat in ["kindergarten","school","university","pharmacy","clinic","hospital",
"shop_big","shop_med","shop_small","bus_stop","tram_stop","metro",
"park","playground","sports"]:
row = conn.execute(
"""SELECT name, distance_m, lat, lon FROM pois
WHERE site_id=? AND category=? ORDER BY distance_m LIMIT 5""",
(site_id, cat)).fetchall()
s["nearest_pois"][cat] = [
{"name": r[0] or "", "distance_m": round(r[1], 1), "lat": r[2], "lon": r[3]}
for r in row
]
return s
def main():
conn = sqlite3.connect(DB)
parcel = fetch_site_full(conn, PARCEL_ID)
n_total = conn.execute("SELECT count(*) FROM sites").fetchone()[0]
# Top-10 best-scoring ЖК + parcel position context
rows = conn.execute("""SELECT s.site_id, s.name, s.district, s.developer, s.obj_class,
s.flat_count, s.lat, s.lon,
st.weighted, st.rank_overall
FROM sites s JOIN scores_total st USING (site_id)
WHERE s.kind='jk' ORDER BY st.weighted DESC""").fetchall()
top10 = [dict(zip(['site_id','name','district','developer','obj_class','flat_count',
'lat','lon','weighted','rank'], r)) for r in rows[:10]]
# Distribution stats
all_w = [r[8] for r in rows]
stats = {
"n_jk": len(all_w),
"mean": round(statistics.mean(all_w), 1),
"median": round(statistics.median(all_w), 1),
"p25": round(statistics.quantiles(all_w, n=4)[0], 1),
"p75": round(statistics.quantiles(all_w, n=4)[2], 1),
"min": round(min(all_w), 1),
"max": round(max(all_w), 1),
}
# Component-wise comparison
parcel_comp = parcel["scores"]
comp_stats = {}
for c in parcel_comp:
vals = [v for (v,) in conn.execute(
"SELECT score_0_100 FROM scores s JOIN sites si USING (site_id) WHERE component=? AND si.kind='jk'",
(c,)).fetchall()]
comp_stats[c] = {
"parcel": round(parcel_comp[c], 1),
"median_jk": round(statistics.median(vals), 1),
"p75_jk": round(statistics.quantiles(vals, n=4)[2], 1),
}
# Closest comparable ЖК (by geographic proximity to parcel)
plat, plon = parcel["lat"], parcel["lon"]
nearby = []
for r in conn.execute("""SELECT s.site_id, s.name, s.district, s.developer, s.obj_class,
s.lat, s.lon, st.weighted, st.rank_overall
FROM sites s JOIN scores_total st USING (site_id)
WHERE s.kind='jk'""").fetchall():
import math
R=6371000; p1,p2=math.radians(plat),math.radians(r[5])
dp=math.radians(r[5]-plat); dl=math.radians(r[6]-plon)
a=math.sin(dp/2)**2+math.cos(p1)*math.cos(p2)*math.sin(dl/2)**2
d=2*R*math.asin(math.sqrt(a))
nearby.append((d, r))
nearby.sort()
closest = [
{"distance_m": round(d, 0), "site_id": r[0], "name": r[1], "district": r[2],
"developer": r[3], "obj_class": r[4], "weighted": round(r[7], 1), "rank": r[8]}
for d, r in nearby[:10]
]
out = {
"generated_at": __import__("datetime").datetime.now().isoformat(timespec="seconds"),
"parcel": parcel,
"n_compared_jk": stats["n_jk"],
"weighted_score_distribution": stats,
"component_comparison": comp_stats,
"weights_used": {"education":0.30,"health":0.15,"retail":0.20,"transit":0.20,"leisure":0.15},
"top10_best_jk_ekb": top10,
"10_closest_jk_to_parcel": closest,
}
json_path = OUT / "parcel_66_41_0204016_10.json"
with open(json_path, "w") as f:
json.dump(out, f, ensure_ascii=False, indent=2, default=str)
print(f"JSON: {json_path}")
# Build HTML
html = build_html(out)
html_path = OUT / "parcel_66_41_0204016_10.html"
with open(html_path, "w") as f:
f.write(html)
print(f"HTML: {html_path}")
conn.close()
def econ_block(e):
if not e:
return "<p class=muted>Нет данных Объектива для этого района.</p>"
f = lambda x, suf="": (f"{x:.1f}{suf}" if isinstance(x,(int,float)) else "")
method_note = "по совпадению имени ЖК" if e["district_method"]=="name_match" \
else f"по голосованию 5 ближайших ЖК (расст. до репрезентанта {e['district_dist_m']:.0f} м)"
return f"""
<table>
<tr><th>Район (Объектив)</th><td><b>{e["district"]}</b> <span class=muted>({method_note})</span></td></tr>
<tr><th>Проектов в районе</th><td>{e["n_projects"]}</td></tr>
<tr><th>Лотов всего / продано</th><td>{e["n_lots"] or 0:,} / <b>{e["n_sold"] or 0:,}</b> ({f(e["sold_pct"], '%')})</td></tr>
<tr><th>Цена за м² (медиана)</th><td><b>{f(e["median_price_m2"])} тыс ₽</b> · P25={f(e["p25_price_m2"])} · P75={f(e["p75_price_m2"])}</td></tr>
<tr><th>Средняя площадь сделки</th><td>{f(e["avg_area_sold_m2"])} м²</td></tr>
<tr><th>Скорость продаж (real)</th><td><b>{f(e["velocity_per_month"])}</b> зарег. ДДУ/корпус/мес <span class=muted>(по 12 мес)</span></td></tr>
<tr><th>Средняя готовность</th><td>{f(e["avg_readiness_pct"], '%')}</td></tr>
<tr><th>Цена corp_sum (взвеш.)</th><td class=muted>{f(e["weighted_price_m2_corp_sum"])} тыс ₽/м² · скорость {f(e["deals_per_month_corp_sum"])}</td></tr>
<tr><th>Распродажа стока (corp_sum)</th><td class=muted>{f(e["months_to_sellout"], ' мес')}</td></tr>
</table>
<p class=muted><b>real_*</b> рассчитаны по {e["n_lots"] or 0:,} лотам из Поквартирные/Лоты (303 677 квартир Екб). Это per-flat, основной источник правды.</p>
"""
def build_html(d):
p = d["parcel"]
cs = d["component_comparison"]
poi_table_rows = []
for cat, items in p["nearest_pois"].items():
nearest = items[0] if items else None
poi_table_rows.append(
f"<tr><td>{cat}</td>"
f"<td>{nearest['name'] if nearest else ''}</td>"
f"<td class='r'>{('%.0f м' % nearest['distance_m']) if nearest else ''}</td>"
f"<td class='r'>{p['features'].get(f'{cat}_count_500m','')}</td>"
f"<td class='r'>{p['features'].get(f'{cat}_count_1km','')}</td></tr>"
)
comp_rows = []
for c, v in cs.items():
delta = v["parcel"] - v["median_jk"]
cls = "g" if delta > 0 else ("r" if delta < 0 else "")
comp_rows.append(
f"<tr><td>{c}</td><td class='r'>{v['parcel']}</td>"
f"<td class='r'>{v['median_jk']}</td><td class='r'>{v['p75_jk']}</td>"
f"<td class='r {cls}'>{'+' if delta>=0 else ''}{delta:.1f}</td></tr>"
)
closest_rows = "".join(
f"<tr><td class='r'>{x['distance_m']:.0f} м</td>"
f"<td>{x['name'] or ''}</td><td>{x['district'] or ''}</td>"
f"<td>{x['developer'] or ''}</td><td>{x['obj_class'] or ''}</td>"
f"<td class='r'>{x['weighted']}</td><td class='r'>#{x['rank']}</td></tr>"
for x in d["10_closest_jk_to_parcel"]
)
top_rows = "".join(
f"<tr><td class='r'>#{x['rank']}</td><td>{x['name'] or ''}</td>"
f"<td>{x['district'] or ''}</td><td>{x['developer'] or ''}</td>"
f"<td>{x['obj_class'] or ''}</td><td class='r'>{x['weighted']:.1f}</td></tr>"
for x in d["top10_best_jk_ekb"]
)
return f"""<!doctype html>
<html lang=ru><head><meta charset=utf-8>
<title>Анализ участка {p['name']}</title>
<style>
body{{font-family:-apple-system,Segoe UI,sans-serif;max-width:980px;margin:32px auto;padding:0 16px;color:#222;line-height:1.5}}
h1{{font-size:24px;margin:0 0 8px}}
h2{{font-size:18px;margin-top:32px;border-bottom:1px solid #eee;padding-bottom:6px}}
.kpi{{display:grid;grid-template-columns:repeat(5,1fr);gap:12px;margin:18px 0}}
.kpi div{{background:#f5f7fa;border-radius:8px;padding:14px}}
.kpi b{{display:block;font-size:11px;color:#666;text-transform:uppercase;margin-bottom:4px}}
.kpi span{{font-size:24px;font-weight:600}}
.big{{font-size:42px;font-weight:700;color:#0a6}}
table{{border-collapse:collapse;width:100%;margin:8px 0;font-size:13px}}
th,td{{border:1px solid #ddd;padding:6px 10px;text-align:left}}
th{{background:#f5f7fa;font-weight:600}}
.r{{text-align:right}}
.g{{color:#0a6;font-weight:600}}
.r.r{{color:#c33;font-weight:600}}
.muted{{color:#888;font-size:12px}}
.note{{background:#fffbe6;border-left:4px solid #f0c000;padding:10px 14px;border-radius:4px;margin:12px 0}}
</style></head><body>
<h1>Анализ участка <code>66:41:0204016:10</code></h1>
<div class=muted>Сгенерировано {d["generated_at"]} · Сравнение с {d["n_compared_jk"]} строящимися ЖК Екатеринбурга</div>
<div class="note">
Координаты участка получены из ссылки на NSPD-карту (EPSG:3857 → WGS84):
<b>{p['lat']}, {p['lon']}</b>. Район — центрально-северная часть ЕКБ (Пионерский / Втузгородок).
</div>
<div class=kpi>
<div><b>Итоговый балл</b><span class=big>{p["weighted_total"]:.1f}</span><span class=muted>из 100</span></div>
<div><b>Ранг по ЕКБ</b><span>#{p["rank_overall"]} / {d["n_compared_jk"]+1}</span></div>
<div><b>Перцентиль</b><span>{(1 - (p["rank_overall"]-1)/(d["n_compared_jk"]+1))*100:.0f}%</span></div>
<div><b>Медиана ЖК</b><span>{d["weighted_score_distribution"]["median"]}</span></div>
<div><b>Топ-25% ЖК ≥</b><span>{d["weighted_score_distribution"]["p75"]}</span></div>
</div>
<h2>Компоненты (взвешенные)</h2>
<table><thead><tr><th>Компонент</th><th class=r>Участок</th><th class=r>Медиана ЖК</th><th class=r>P75 ЖК</th><th class=r>Δ vs медиана</th></tr></thead>
<tbody>{"".join(comp_rows)}</tbody></table>
<div class=muted>Веса: образование 20% · здоровье 10% · ритейл 15% · транспорт 15% · досуг 10% · экономика 30%</div>
<h2>Экономика района (Объектив API, последние 90 дней)</h2>
{econ_block(p.get("economics"))}
<h2>Ближайшие POI вокруг участка</h2>
<table><thead><tr><th>Категория</th><th>Ближайший</th><th class=r>До него</th><th class=r>В 500 м</th><th class=r>В 1 км</th></tr></thead>
<tbody>{"".join(poi_table_rows)}</tbody></table>
<h2>10 ближайших ЖК (для прямого бенчмарка)</h2>
<table><thead><tr><th class=r>Расст.</th><th>Название</th><th>Район</th><th>Девелопер</th><th>Класс</th><th class=r>Балл</th><th class=r>Ранг</th></tr></thead>
<tbody>{closest_rows}</tbody></table>
<h2>Топ-10 ЖК ЕКБ по локационной привлекательности</h2>
<table><thead><tr><th class=r>Ранг</th><th>Название</th><th>Район</th><th>Девелопер</th><th>Класс</th><th class=r>Балл</th></tr></thead>
<tbody>{top_rows}</tbody></table>
<h2>Методика</h2>
<p><b>Источник POI:</b> OpenStreetMap (Overpass API), bbox по всем 381 ЖК Свердл.</p>
<p><b>Логика:</b> для каждой категории — расстояние-в-балл (piecewise linear от <i>ideal_m</i> к <i>max_m</i>),
далее агрегация в 5 компонент с весами (max-pool там, где категории альтернативны: ритейл = max(big, 0.7×med); транспорт = max(metro, 0.85×tram, 0.7×bus)).
Финальный балл — взвешенная сумма компонент.</p>
<p><b>База данных:</b> локальная SQLite <code>analysis.db</code> (sites/pois/features/scores), под-выборка строящихся ЖК ЕКБ из прода <code>domrf_kn_objects</code>.</p>
</body></html>"""
if __name__ == "__main__":
main()