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/).
This commit is contained in:
parent
20625f78f3
commit
97b19a0b85
24 changed files with 8541 additions and 0 deletions
21
site-finder/.gitignore
vendored
Normal file
21
site-finder/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
# Heavy generated artefacts — regenerated by the 01_*..12_*.py pipeline
|
||||||
|
analysis.db
|
||||||
|
analysis.db-journal
|
||||||
|
analysis.db-wal
|
||||||
|
analysis.db-shm
|
||||||
|
|
||||||
|
# Heavy raw caches
|
||||||
|
cache/objective_raw/
|
||||||
|
cache/overpass_raw.json
|
||||||
|
cache/osm_buildings_all.geojson
|
||||||
|
|
||||||
|
# Local-only
|
||||||
|
tmp/
|
||||||
|
debug.log
|
||||||
|
*.log
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
75
site-finder/01_load_sites.py
Normal file
75
site-finder/01_load_sites.py
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
"""Load sites into local DB:
|
||||||
|
- target parcel 66:41:0204016:10 (approximated coords from neighbouring quarters)
|
||||||
|
- all under-construction ЖК in Ekb from prod (via SSH tunnel)
|
||||||
|
"""
|
||||||
|
import sqlite3, psycopg2, pathlib
|
||||||
|
|
||||||
|
DB = pathlib.Path(__file__).parent / "analysis.db"
|
||||||
|
|
||||||
|
PARCEL = {
|
||||||
|
"site_id": "parcel:66:41:0204016:10",
|
||||||
|
"kind": "parcel",
|
||||||
|
"name": "Участок 66:41:0204016:10",
|
||||||
|
"address": "г. Екатеринбург, ул. Маневровая, 31А (Старая Сортировка, Железнодорожный) · кад.№ 66:41:0204016:10 · 0.28 га · кад.ст. 23.7 млн ₽",
|
||||||
|
"district": None,
|
||||||
|
"obj_class": None,
|
||||||
|
"developer": None,
|
||||||
|
"flat_count": None,
|
||||||
|
"square_living": 2788.7, # parcel area m² (Rosreestr polygon)
|
||||||
|
"ready_dt": None,
|
||||||
|
"obj_status": "parcel",
|
||||||
|
"lat": 56.878730, # polygon centroid (rosreestr2coord)
|
||||||
|
"lon": 60.522677,
|
||||||
|
"obj_id": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
def main():
|
||||||
|
conn = sqlite3.connect(DB)
|
||||||
|
pg = psycopg2.connect(host="127.0.0.1", port=15432, user="gendesign",
|
||||||
|
password="2J2SBPMKuS998fiwhtQqDhMI", dbname="gendesign")
|
||||||
|
cur = pg.cursor()
|
||||||
|
|
||||||
|
# latest snapshot per obj_id
|
||||||
|
cur.execute("""
|
||||||
|
WITH latest AS (
|
||||||
|
SELECT obj_id, MAX(snapshot_date) AS d FROM domrf_kn_objects
|
||||||
|
WHERE region_cd='66' AND is_ekb=true
|
||||||
|
GROUP BY obj_id
|
||||||
|
)
|
||||||
|
SELECT o.obj_id, o.comm_name, o.addr, o.district_name, o.obj_class,
|
||||||
|
o.dev_name, o.flat_count, o.square_living::float, o.ready_dt,
|
||||||
|
o.obj_status, o.latitude::float, o.longitude::float
|
||||||
|
FROM domrf_kn_objects o
|
||||||
|
JOIN latest l USING (obj_id)
|
||||||
|
WHERE o.snapshot_date = l.d
|
||||||
|
AND o.latitude IS NOT NULL AND o.longitude IS NOT NULL
|
||||||
|
AND o.site_status='Строящиеся'
|
||||||
|
ORDER BY o.obj_id;
|
||||||
|
""")
|
||||||
|
rows = cur.fetchall()
|
||||||
|
print(f"Pulled {len(rows)} ЖК from prod")
|
||||||
|
|
||||||
|
conn.execute("DELETE FROM sites;")
|
||||||
|
conn.execute("""INSERT INTO sites(site_id,kind,name,address,district,obj_class,developer,
|
||||||
|
flat_count,square_living,ready_dt,obj_status,lat,lon,obj_id) VALUES (
|
||||||
|
:site_id,:kind,:name,:address,:district,:obj_class,:developer,
|
||||||
|
:flat_count,:square_living,:ready_dt,:obj_status,:lat,:lon,:obj_id)""", PARCEL)
|
||||||
|
|
||||||
|
for r in rows:
|
||||||
|
(obj_id, name, addr, district, klass, dev, flat_count, sq_living, ready_dt,
|
||||||
|
obj_status, lat, lon) = r
|
||||||
|
if not (50 < lat < 65 and 55 < lon < 70): # sanity for Sverdl
|
||||||
|
continue
|
||||||
|
conn.execute("""INSERT INTO sites(site_id,kind,name,address,district,obj_class,
|
||||||
|
developer,flat_count,square_living,ready_dt,obj_status,lat,lon,obj_id)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||||
|
(f"jk:{obj_id}", "jk", name, addr, district, klass, dev, flat_count,
|
||||||
|
sq_living, str(ready_dt) if ready_dt else None, obj_status, lat, lon, obj_id))
|
||||||
|
conn.commit()
|
||||||
|
n = conn.execute("SELECT count(*) FROM sites").fetchone()[0]
|
||||||
|
by_kind = dict(conn.execute("SELECT kind, count(*) FROM sites GROUP BY 1").fetchall())
|
||||||
|
print(f"Sites in local DB: {n} ({by_kind})")
|
||||||
|
conn.close(); pg.close()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
145
site-finder/02_fetch_pois.py
Normal file
145
site-finder/02_fetch_pois.py
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
"""Fetch POIs from OSM Overpass for all sites in one bulk query.
|
||||||
|
|
||||||
|
POI taxonomy (matches the user's spec):
|
||||||
|
EDUCATION
|
||||||
|
kindergarten amenity=kindergarten
|
||||||
|
school amenity=school
|
||||||
|
university amenity=university | college
|
||||||
|
HEALTH
|
||||||
|
pharmacy amenity=pharmacy
|
||||||
|
clinic amenity=clinic | doctors
|
||||||
|
hospital amenity=hospital
|
||||||
|
RETAIL
|
||||||
|
shop_big shop=mall | supermarket | department_store | hypermarket
|
||||||
|
shop_med shop=convenience | grocery | bakery
|
||||||
|
shop_small shop=kiosk | newsagent
|
||||||
|
TRANSIT
|
||||||
|
bus_stop highway=bus_stop | public_transport=platform
|
||||||
|
tram_stop railway=tram_stop
|
||||||
|
metro railway=station + station=subway
|
||||||
|
LEISURE
|
||||||
|
park leisure=park | garden
|
||||||
|
playground leisure=playground
|
||||||
|
sports leisure=sports_centre | fitness_centre | pitch
|
||||||
|
"""
|
||||||
|
import sqlite3, pathlib, requests, time, json, math, urllib3
|
||||||
|
urllib3.disable_warnings()
|
||||||
|
|
||||||
|
DB = pathlib.Path(__file__).parent / "analysis.db"
|
||||||
|
CACHE = pathlib.Path(__file__).parent / "cache" / "overpass_raw.json"
|
||||||
|
|
||||||
|
OVERPASS_ENDPOINTS = [
|
||||||
|
"https://overpass-api.de/api/interpreter",
|
||||||
|
"https://overpass.kumi.systems/api/interpreter",
|
||||||
|
"https://overpass.openstreetmap.ru/api/interpreter",
|
||||||
|
]
|
||||||
|
|
||||||
|
# (category, overpass filter)
|
||||||
|
QUERIES = [
|
||||||
|
("kindergarten", '["amenity"="kindergarten"]'),
|
||||||
|
("school", '["amenity"="school"]'),
|
||||||
|
("university", '["amenity"~"^(university|college)$"]'),
|
||||||
|
("pharmacy", '["amenity"="pharmacy"]'),
|
||||||
|
("clinic", '["amenity"~"^(clinic|doctors)$"]'),
|
||||||
|
("hospital", '["amenity"="hospital"]'),
|
||||||
|
("shop_big", '["shop"~"^(mall|supermarket|department_store|hypermarket)$"]'),
|
||||||
|
("shop_med", '["shop"~"^(convenience|grocery|bakery)$"]'),
|
||||||
|
("shop_small", '["shop"~"^(kiosk|newsagent)$"]'),
|
||||||
|
("bus_stop", '["highway"="bus_stop"]'),
|
||||||
|
("tram_stop", '["railway"="tram_stop"]'),
|
||||||
|
("metro", '["station"="subway"]'),
|
||||||
|
("park", '["leisure"~"^(park|garden)$"]'),
|
||||||
|
("playground", '["leisure"="playground"]'),
|
||||||
|
("sports", '["leisure"~"^(sports_centre|fitness_centre|pitch)$"]'),
|
||||||
|
]
|
||||||
|
|
||||||
|
def haversine_m(lat1, lon1, lat2, lon2):
|
||||||
|
R = 6371000
|
||||||
|
p1, p2 = math.radians(lat1), math.radians(lat2)
|
||||||
|
dp = math.radians(lat2-lat1); dl = math.radians(lon2-lon1)
|
||||||
|
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 bbox(rows, pad_deg=0.05):
|
||||||
|
lats=[r[0] for r in rows]; lons=[r[1] for r in rows]
|
||||||
|
return (min(lats)-pad_deg, min(lons)-pad_deg, max(lats)+pad_deg, max(lons)+pad_deg)
|
||||||
|
|
||||||
|
def overpass_query(filt, b):
|
||||||
|
q = f"""
|
||||||
|
[out:json][timeout:120];
|
||||||
|
(
|
||||||
|
node{filt}({b[0]},{b[1]},{b[2]},{b[3]});
|
||||||
|
way{filt}({b[0]},{b[1]},{b[2]},{b[3]});
|
||||||
|
);
|
||||||
|
out center tags;
|
||||||
|
"""
|
||||||
|
last_err = None
|
||||||
|
for ep in OVERPASS_ENDPOINTS:
|
||||||
|
try:
|
||||||
|
r = requests.post(ep, data={"data": q}, timeout=180, verify=False,
|
||||||
|
headers={"User-Agent":"gendesign-research/1.0"})
|
||||||
|
if r.status_code == 200:
|
||||||
|
return r.json()
|
||||||
|
last_err = f"HTTP {r.status_code} from {ep}"
|
||||||
|
time.sleep(2)
|
||||||
|
except Exception as e:
|
||||||
|
last_err = f"{ep}: {e}"
|
||||||
|
time.sleep(2)
|
||||||
|
raise RuntimeError(f"All Overpass endpoints failed: {last_err}")
|
||||||
|
|
||||||
|
def main():
|
||||||
|
conn = sqlite3.connect(DB)
|
||||||
|
sites = conn.execute("SELECT site_id, lat, lon FROM sites").fetchall()
|
||||||
|
print(f"Sites: {len(sites)}")
|
||||||
|
coords = [(s[1], s[2]) for s in sites]
|
||||||
|
b = bbox(coords)
|
||||||
|
print(f"BBox (S,W,N,E): {b}")
|
||||||
|
|
||||||
|
cache_data = {}
|
||||||
|
for cat, filt in QUERIES:
|
||||||
|
print(f" fetching {cat:<14} ...", end=" ", flush=True)
|
||||||
|
d = overpass_query(filt, b)
|
||||||
|
elements = d.get("elements", [])
|
||||||
|
cache_data[cat] = elements
|
||||||
|
print(f"{len(elements)} elements")
|
||||||
|
time.sleep(2) # be kind
|
||||||
|
|
||||||
|
CACHE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(CACHE, 'w') as f: json.dump(cache_data, f, ensure_ascii=False)
|
||||||
|
print(f"\nCache: {CACHE} ({CACHE.stat().st_size/1024:.0f} KB)")
|
||||||
|
|
||||||
|
# Compute nearest POI per site per category, plus all POIs within 1km
|
||||||
|
conn.execute("DELETE FROM pois")
|
||||||
|
n_pois = 0
|
||||||
|
for cat, elems in cache_data.items():
|
||||||
|
# extract (lat,lon,name,raw)
|
||||||
|
poi_list = []
|
||||||
|
for el in elems:
|
||||||
|
if el["type"] == "node":
|
||||||
|
lat, lon = el.get("lat"), el.get("lon")
|
||||||
|
else:
|
||||||
|
c = el.get("center") or {}
|
||||||
|
lat, lon = c.get("lat"), c.get("lon")
|
||||||
|
if lat is None or lon is None: continue
|
||||||
|
tags = el.get("tags") or {}
|
||||||
|
name = tags.get("name") or tags.get("operator") or ""
|
||||||
|
poi_list.append((lat, lon, name, el["type"], el["id"], json.dumps(tags, ensure_ascii=False)))
|
||||||
|
# for each site, find within 2 km (we'll bucket by distance later)
|
||||||
|
for s in sites:
|
||||||
|
site_id, slat, slon = s
|
||||||
|
for plat, plon, pname, ptype, pid, ptags in poi_list:
|
||||||
|
d = haversine_m(slat, slon, plat, plon)
|
||||||
|
if d <= 2000:
|
||||||
|
conn.execute("""INSERT INTO pois(site_id,category,osm_type,osm_id,name,lat,lon,distance_m,raw_tags)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?)""",
|
||||||
|
(site_id, cat, ptype, str(pid), pname, plat, plon, d, ptags))
|
||||||
|
n_pois += 1
|
||||||
|
conn.commit()
|
||||||
|
print(f"\nStored {n_pois} POI-site pairs (within 2 km)")
|
||||||
|
print("Per-category:")
|
||||||
|
for cat, n in conn.execute("SELECT category,count(*) FROM pois GROUP BY 1 ORDER BY 2 DESC").fetchall():
|
||||||
|
print(f" {cat:<14} {n}")
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
229
site-finder/03_score.py
Normal file
229
site-finder/03_score.py
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
"""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()
|
||||||
274
site-finder/04_report.py
Normal file
274
site-finder/04_report.py
Normal file
|
|
@ -0,0 +1,274 @@
|
||||||
|
"""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()
|
||||||
148
site-finder/05_objective_pull.py
Normal file
148
site-finder/05_objective_pull.py
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
"""Step 1.1: Pull Objective API and store in local DB.
|
||||||
|
|
||||||
|
We pull "Сводные/Корпуса" (corp_sum) for Ekb covering last 12 calendar months —
|
||||||
|
this is the primary source of:
|
||||||
|
- per-corpus monthly: deals (DDU+DKP), volume sold (m²), avg price/m², stock left
|
||||||
|
- готовность %, старт продаж, планируемая дата ввода
|
||||||
|
- район (Objective's classification)
|
||||||
|
|
||||||
|
Data lands in two tables:
|
||||||
|
objective_raw_reports — JSON payload archive (1 row per fetch)
|
||||||
|
objective_corp_month — flattened rows (one per month × project × corpus × room-type)
|
||||||
|
"""
|
||||||
|
import sqlite3, pathlib, requests, json, time, datetime as dt
|
||||||
|
|
||||||
|
DB = pathlib.Path(__file__).parent / "analysis.db"
|
||||||
|
API = "https://api.objctv.ru"
|
||||||
|
KEY = "623f6a57-0179-434b-8202-259525bdc77c"
|
||||||
|
|
||||||
|
SCHEMA = """
|
||||||
|
CREATE TABLE IF NOT EXISTS objective_raw_reports (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
fetched_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
report_kind TEXT,
|
||||||
|
group_name TEXT,
|
||||||
|
start_date TEXT,
|
||||||
|
end_date TEXT,
|
||||||
|
n_rows INTEGER,
|
||||||
|
payload TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS objective_corp_month (
|
||||||
|
month TEXT, -- 'YYYY-MM'
|
||||||
|
project TEXT,
|
||||||
|
developer TEXT,
|
||||||
|
district TEXT,
|
||||||
|
obj_class TEXT,
|
||||||
|
corpus TEXT,
|
||||||
|
sales_start TEXT,
|
||||||
|
plan_date TEXT,
|
||||||
|
fact_date TEXT,
|
||||||
|
months_in_sales INTEGER,
|
||||||
|
rooms_bucket TEXT, -- '1', '2', '3', '4+', 'студия', etc.
|
||||||
|
lots_pd INTEGER,
|
||||||
|
area_pd REAL,
|
||||||
|
deals_total INTEGER,
|
||||||
|
deals_priced INTEGER,
|
||||||
|
sold_volume_m2 REAL,
|
||||||
|
avg_price_m2 REAL,
|
||||||
|
avg_area_m2 REAL,
|
||||||
|
stock_lots INTEGER,
|
||||||
|
stock_m2 REAL,
|
||||||
|
stock_avg_price_m2 REAL,
|
||||||
|
PRIMARY KEY (month, project, corpus, rooms_bucket)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS oc_district ON objective_corp_month(district);
|
||||||
|
CREATE INDEX IF NOT EXISTS oc_project ON objective_corp_month(project);
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_token():
|
||||||
|
r = requests.get(f"{API}/Users/User/GetToken", params={"apiKey": KEY}, timeout=30)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()["token"]
|
||||||
|
|
||||||
|
def fetch_corp_sum(token, group, sd, ed):
|
||||||
|
r = requests.get(f"{API}/v2/Report/GetReport",
|
||||||
|
params={"Page":"Отчеты","ReportSection":"Объединенные данные",
|
||||||
|
"ReportType":"Сводные","ReportName":"Корпуса",
|
||||||
|
"GroupName":group,"StartDate":sd,"EndDate":ed,
|
||||||
|
"UseDdu":"true","UseDkp":"true"},
|
||||||
|
headers={"Authorization":f"Bearer {token}","Accept-Encoding":"br"},
|
||||||
|
timeout=120)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json().get("result", [])
|
||||||
|
|
||||||
|
# Map raw RU column names to our schema
|
||||||
|
COL_MAP = {
|
||||||
|
"month":"Месяц","project":"Проект","developer":"Девелопер","district":"Район",
|
||||||
|
"obj_class":"Класс","corpus":"Корпус","sales_start":"Старт продаж",
|
||||||
|
"plan_date":"Планируемая дата ввода","fact_date":"Фактическая дата ввода",
|
||||||
|
"months_in_sales":"Месяцев в реализации","rooms_bucket":"Количество комнат (Данные Объектива)",
|
||||||
|
"lots_pd":"Лотов по ПД, шт.","area_pd":"Площадь по ПД, м2.",
|
||||||
|
"deals_total":"Количество в сделках (всего), шт.",
|
||||||
|
"deals_priced":"Количество лотов в сделках (с ценами), шт.",
|
||||||
|
"sold_volume_m2":"Объем реализации (всего), м2.",
|
||||||
|
"avg_price_m2":"Средняя цена м2 лота в сделках, тыс.Р/м2",
|
||||||
|
"avg_area_m2":"Средняя площадь лота в сделках, м2",
|
||||||
|
"stock_lots":"Объем предложения, шт.","stock_m2":"Объем предложения, м2.",
|
||||||
|
"stock_avg_price_m2":"Средняя цена м2 лота в продаже, тыс.Р/м2",
|
||||||
|
}
|
||||||
|
|
||||||
|
RU_MONTHS = {"январь":1,"февраль":2,"март":3,"апрель":4,"май":5,"июнь":6,
|
||||||
|
"июль":7,"август":8,"сентябрь":9,"октябрь":10,"ноябрь":11,"декабрь":12}
|
||||||
|
|
||||||
|
def normalize_month(s):
|
||||||
|
# 'апрель-2026' → '2026-04'
|
||||||
|
if not s: return None
|
||||||
|
parts = str(s).lower().replace("—","-").split("-")
|
||||||
|
if len(parts) != 2: return s
|
||||||
|
m = RU_MONTHS.get(parts[0].strip())
|
||||||
|
y = parts[1].strip()
|
||||||
|
return f"{y}-{m:02d}" if m else s
|
||||||
|
|
||||||
|
def main():
|
||||||
|
conn = sqlite3.connect(DB)
|
||||||
|
conn.executescript(SCHEMA)
|
||||||
|
|
||||||
|
today = dt.date.today()
|
||||||
|
sd = (today.replace(day=1) - dt.timedelta(days=365)).strftime("%Y.%m.%d")
|
||||||
|
ed = today.strftime("%Y.%m.%d")
|
||||||
|
group = "Екатеринбург"
|
||||||
|
|
||||||
|
print(f"Fetching corp_sum {group} {sd}..{ed}")
|
||||||
|
token = get_token()
|
||||||
|
rows = fetch_corp_sum(token, group, sd, ed)
|
||||||
|
print(f" {len(rows)} rows")
|
||||||
|
|
||||||
|
conn.execute("INSERT INTO objective_raw_reports(report_kind,group_name,start_date,end_date,n_rows,payload) VALUES (?,?,?,?,?,?)",
|
||||||
|
("corp_sum_v2", group, sd, ed, len(rows), json.dumps(rows, ensure_ascii=False)))
|
||||||
|
|
||||||
|
conn.execute("DELETE FROM objective_corp_month")
|
||||||
|
inserted = skipped = 0
|
||||||
|
for r in rows:
|
||||||
|
try:
|
||||||
|
vals = {k: r.get(v) for k, v in COL_MAP.items()}
|
||||||
|
vals["month"] = normalize_month(vals["month"])
|
||||||
|
cols = list(vals.keys())
|
||||||
|
placeholders = ",".join(["?"] * len(cols))
|
||||||
|
conn.execute(f"INSERT OR REPLACE INTO objective_corp_month({','.join(cols)}) VALUES ({placeholders})",
|
||||||
|
[vals[c] for c in cols])
|
||||||
|
inserted += 1
|
||||||
|
except Exception as e:
|
||||||
|
skipped += 1
|
||||||
|
conn.commit()
|
||||||
|
print(f" inserted: {inserted}, skipped: {skipped}")
|
||||||
|
|
||||||
|
# Sanity
|
||||||
|
n = conn.execute("SELECT count(*) FROM objective_corp_month").fetchone()[0]
|
||||||
|
n_proj = conn.execute("SELECT count(DISTINCT project) FROM objective_corp_month").fetchone()[0]
|
||||||
|
n_dist = conn.execute("SELECT count(DISTINCT district) FROM objective_corp_month").fetchone()[0]
|
||||||
|
print(f"\nLocal: {n} rows, {n_proj} проекта, {n_dist} районов")
|
||||||
|
print("\nDistricts (Objective):")
|
||||||
|
for r in conn.execute("""SELECT district, count(*) c, count(DISTINCT project) np
|
||||||
|
FROM objective_corp_month GROUP BY 1 ORDER BY 3 DESC""").fetchall():
|
||||||
|
print(f" {r[0]:<30} rows={r[1]:>5} projects={r[2]}")
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
208
site-finder/06_match_economics.py
Normal file
208
site-finder/06_match_economics.py
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
"""Step 1.2: Match ЖК ↔ Objective projects by name; compute district economics.
|
||||||
|
|
||||||
|
Output tables:
|
||||||
|
jk_objective_match — site_id ↔ objective project name (best fuzzy match)
|
||||||
|
district_economics — per Objective-район aggregates over last 90 days
|
||||||
|
site_district — which Objective-район each site belongs to
|
||||||
|
"""
|
||||||
|
import sqlite3, pathlib, re, math
|
||||||
|
from difflib import SequenceMatcher
|
||||||
|
|
||||||
|
DB = pathlib.Path(__file__).parent / "analysis.db"
|
||||||
|
|
||||||
|
SCHEMA = """
|
||||||
|
CREATE TABLE IF NOT EXISTS jk_objective_match (
|
||||||
|
site_id TEXT PRIMARY KEY REFERENCES sites(site_id),
|
||||||
|
project TEXT,
|
||||||
|
score REAL,
|
||||||
|
method TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS district_economics (
|
||||||
|
district TEXT PRIMARY KEY,
|
||||||
|
n_projects INTEGER,
|
||||||
|
n_corpuses INTEGER,
|
||||||
|
median_price_m2 REAL, -- тыс.Р/м²
|
||||||
|
weighted_price_m2 REAL, -- weighted by sold m²
|
||||||
|
avg_price_m2_offer REAL, -- prices in stock right now
|
||||||
|
deals_per_month_avg REAL, -- avg per корпус
|
||||||
|
sold_volume_m2_90d REAL, -- last 90 days sold m²
|
||||||
|
stock_m2 REAL, -- current stock
|
||||||
|
stock_lots INTEGER,
|
||||||
|
avg_area_sold_m2 REAL, -- average flat size sold
|
||||||
|
months_to_sellout REAL -- stock_m2 / monthly_velocity
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS site_district (
|
||||||
|
site_id TEXT PRIMARY KEY REFERENCES sites(site_id),
|
||||||
|
district TEXT,
|
||||||
|
method TEXT, -- 'name_match' | 'nearest_jk'
|
||||||
|
nearest_jk_obj_id INTEGER,
|
||||||
|
nearest_jk_dist_m REAL
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
def normalize(s):
|
||||||
|
if not s: return ""
|
||||||
|
s = s.lower().strip()
|
||||||
|
s = re.sub(r'^(жк|жилой\s+комплекс|жилой\s+квартал|жилые\s+кварталы)\s+', '', s)
|
||||||
|
s = re.sub(r'["«»\'`]+', '', s)
|
||||||
|
s = re.sub(r'\s+', ' ', s)
|
||||||
|
return s.strip()
|
||||||
|
|
||||||
|
def fuzzy(a, b):
|
||||||
|
return SequenceMatcher(None, normalize(a), normalize(b)).ratio()
|
||||||
|
|
||||||
|
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)
|
||||||
|
conn.executescript(SCHEMA)
|
||||||
|
|
||||||
|
# 1. ЖК ↔ Objective project name match
|
||||||
|
sites = conn.execute("SELECT site_id, name FROM sites WHERE kind='jk' AND name IS NOT NULL").fetchall()
|
||||||
|
objective_projects = [r[0] for r in conn.execute("SELECT DISTINCT project FROM objective_corp_month").fetchall() if r[0]]
|
||||||
|
print(f"Sites: {len(sites)}, Objective projects: {len(objective_projects)}")
|
||||||
|
|
||||||
|
conn.execute("DELETE FROM jk_objective_match")
|
||||||
|
matched = 0
|
||||||
|
for site_id, name in sites:
|
||||||
|
best = (0.0, None)
|
||||||
|
for proj in objective_projects:
|
||||||
|
s = fuzzy(name, proj)
|
||||||
|
if s > best[0]: best = (s, proj)
|
||||||
|
if best[0] >= 0.80:
|
||||||
|
conn.execute("INSERT INTO jk_objective_match VALUES (?,?,?,?)",
|
||||||
|
(site_id, best[1], best[0], "fuzzy"))
|
||||||
|
matched += 1
|
||||||
|
print(f"Name-matched: {matched}/{len(sites)}")
|
||||||
|
|
||||||
|
# 2. district_economics — last 90 days
|
||||||
|
# Last 3 calendar months from data
|
||||||
|
last3 = [r[0] for r in conn.execute(
|
||||||
|
"SELECT DISTINCT month FROM objective_corp_month ORDER BY month DESC LIMIT 3").fetchall()]
|
||||||
|
print(f"Months window: {last3}")
|
||||||
|
|
||||||
|
conn.execute("DELETE FROM district_economics")
|
||||||
|
placeholders = ",".join(["?"]*len(last3))
|
||||||
|
|
||||||
|
# Latest stock per (project, corpus) — taken from the latest month each corpus appears
|
||||||
|
latest_stock = {}
|
||||||
|
for r in conn.execute(f"""
|
||||||
|
WITH ranked AS (
|
||||||
|
SELECT project, corpus, district, month, stock_m2, stock_lots,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY project, corpus ORDER BY month DESC) rn
|
||||||
|
FROM objective_corp_month
|
||||||
|
WHERE month IN ({placeholders})
|
||||||
|
)
|
||||||
|
SELECT project, corpus, district, stock_m2, stock_lots FROM ranked WHERE rn=1
|
||||||
|
""", last3).fetchall():
|
||||||
|
latest_stock[(r[0], r[1])] = (r[2], r[3] or 0, r[4] or 0)
|
||||||
|
|
||||||
|
# Per-district aggregates from window
|
||||||
|
rows = conn.execute(f"""
|
||||||
|
SELECT district, project, corpus, deals_total, deals_priced,
|
||||||
|
sold_volume_m2, avg_price_m2, avg_area_m2, stock_avg_price_m2
|
||||||
|
FROM objective_corp_month
|
||||||
|
WHERE month IN ({placeholders})
|
||||||
|
""", last3).fetchall()
|
||||||
|
|
||||||
|
by_dist = {}
|
||||||
|
for r in rows:
|
||||||
|
d = r[0]
|
||||||
|
if not d: continue
|
||||||
|
by_dist.setdefault(d, []).append(r)
|
||||||
|
|
||||||
|
for d, rs in by_dist.items():
|
||||||
|
projects = {r[1] for r in rs}
|
||||||
|
corpuses = {(r[1], r[2]) for r in rs}
|
||||||
|
# weighted price (by sold m²)
|
||||||
|
wn = sum((r[6] or 0) * (r[5] or 0) for r in rs)
|
||||||
|
wd = sum((r[5] or 0) for r in rs)
|
||||||
|
wp = wn / wd if wd else None
|
||||||
|
# median price (priced deals only)
|
||||||
|
prices = sorted([r[6] for r in rs if r[6] and (r[4] or 0) > 0])
|
||||||
|
med = prices[len(prices)//2] if prices else None
|
||||||
|
# offer prices
|
||||||
|
offers = [r[8] for r in rs if r[8] and r[8] > 0]
|
||||||
|
avg_off = sum(offers)/len(offers) if offers else None
|
||||||
|
# velocity (avg deals_total per row, where row = corpus×month)
|
||||||
|
vels = [r[3] or 0 for r in rs]
|
||||||
|
avg_v = sum(vels)/len(vels) if vels else None
|
||||||
|
# sold volume 90d
|
||||||
|
sv = sum(r[5] or 0 for r in rs)
|
||||||
|
# stock from latest_stock for this district
|
||||||
|
stock_m2 = sum(s[1] for k, s in latest_stock.items() if s[0] == d)
|
||||||
|
stock_lots = sum(s[2] for k, s in latest_stock.items() if s[0] == d)
|
||||||
|
# avg area
|
||||||
|
areas = [r[7] for r in rs if r[7] and r[7] > 0]
|
||||||
|
avg_a = sum(areas)/len(areas) if areas else None
|
||||||
|
# months to sellout
|
||||||
|
mts = (stock_m2 / (sv / 3.0)) if sv > 0 and stock_m2 else None
|
||||||
|
conn.execute("""INSERT INTO district_economics VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||||
|
(d, len(projects), len(corpuses), med, wp, avg_off, avg_v, sv,
|
||||||
|
stock_m2 or None, stock_lots or None, avg_a, mts))
|
||||||
|
conn.commit()
|
||||||
|
print("\nDistrict economics (last 90d):")
|
||||||
|
for r in conn.execute("""SELECT district, n_projects, ROUND(weighted_price_m2,1) wp,
|
||||||
|
ROUND(deals_per_month_avg,1) v, ROUND(months_to_sellout,1) mts
|
||||||
|
FROM district_economics ORDER BY wp DESC""").fetchall():
|
||||||
|
print(f" {r[0]:<25} projects={r[1]:>3} price={r[2] or '—':>7}тыс vel={r[3]:>5} to_sellout={r[4] or '—'} мес")
|
||||||
|
|
||||||
|
# 3. site_district: name-matched gets Objective district directly; others — nearest matched ЖК
|
||||||
|
conn.execute("DELETE FROM site_district")
|
||||||
|
# 3a. matched sites — pull district from Objective directly
|
||||||
|
rows = conn.execute("""
|
||||||
|
SELECT s.site_id, s.lat, s.lon, m.project,
|
||||||
|
(SELECT district FROM objective_corp_month
|
||||||
|
WHERE project=m.project ORDER BY month DESC LIMIT 1) AS district
|
||||||
|
FROM sites s LEFT JOIN jk_objective_match m USING (site_id)
|
||||||
|
""").fetchall()
|
||||||
|
matched_with_dist = [(sid, lat, lon, proj, dist) for sid, lat, lon, proj, dist in rows
|
||||||
|
if dist is not None]
|
||||||
|
matched_idx = {sid: (lat, lon, dist) for sid, lat, lon, _, dist in matched_with_dist}
|
||||||
|
|
||||||
|
from collections import Counter
|
||||||
|
for sid, lat, lon, proj, dist in rows:
|
||||||
|
if dist:
|
||||||
|
conn.execute("INSERT INTO site_district VALUES (?,?,?,?,?)",
|
||||||
|
(sid, dist, "name_match", None, 0))
|
||||||
|
else:
|
||||||
|
# vote among k=5 nearest matched ЖК within 2 km
|
||||||
|
cands = []
|
||||||
|
for sid2, (lat2, lon2, dist2) in matched_idx.items():
|
||||||
|
if sid2 == sid: continue
|
||||||
|
d = hav(lat, lon, lat2, lon2)
|
||||||
|
cands.append((d, sid2, dist2))
|
||||||
|
cands.sort()
|
||||||
|
top = [c for c in cands[:7] if c[0] <= 2500]
|
||||||
|
if top:
|
||||||
|
votes = Counter(c[2] for c in top)
|
||||||
|
# pick the most-voted district; tie-break: closest representative
|
||||||
|
top_dist, _ = votes.most_common(1)[0]
|
||||||
|
rep = next(c for c in top if c[2] == top_dist)
|
||||||
|
obj_id = None
|
||||||
|
if rep[1] and rep[1].startswith("jk:"):
|
||||||
|
try: obj_id = int(rep[1][3:])
|
||||||
|
except: pass
|
||||||
|
conn.execute("INSERT INTO site_district VALUES (?,?,?,?,?)",
|
||||||
|
(sid, top_dist, "knn_vote_5", obj_id, rep[0]))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
parcel_dist = conn.execute(
|
||||||
|
"SELECT district, method, nearest_jk_dist_m FROM site_district WHERE site_id='parcel:66:41:0204016:10'"
|
||||||
|
).fetchone()
|
||||||
|
print(f"\nParcel district: {parcel_dist}")
|
||||||
|
|
||||||
|
print("\nSites assigned per district:")
|
||||||
|
for r in conn.execute("SELECT district, count(*) FROM site_district GROUP BY 1 ORDER BY 2 DESC LIMIT 15").fetchall():
|
||||||
|
print(f" {r[0]:<25} {r[1]}")
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
264
site-finder/07_objective_full_pull.py
Normal file
264
site-finder/07_objective_full_pull.py
Normal file
|
|
@ -0,0 +1,264 @@
|
||||||
|
"""Pull every available Objective report combination for Ekb.
|
||||||
|
|
||||||
|
The API has 4 reports = ReportType × ReportName:
|
||||||
|
Сводные / Корпуса — already pulled in 05_*
|
||||||
|
Сводные / Лоты — probe
|
||||||
|
Поквартирные / Корпуса — probe
|
||||||
|
Поквартирные / Лоты — full per-flat data (most valuable)
|
||||||
|
|
||||||
|
Earlier server graph said 2 of 4 returned HTTP 500. We probe again,
|
||||||
|
then bulk-pull whatever returns 200.
|
||||||
|
"""
|
||||||
|
import sqlite3, pathlib, requests, time, json, datetime as dt, sys
|
||||||
|
|
||||||
|
DB = pathlib.Path(__file__).parent / "analysis.db"
|
||||||
|
RAW = pathlib.Path(__file__).parent / "cache" / "objective_raw"
|
||||||
|
RAW.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
API = "https://api.objctv.ru"
|
||||||
|
KEY = "623f6a57-0179-434b-8202-259525bdc77c"
|
||||||
|
GROUP = "Екатеринбург"
|
||||||
|
TODAY = dt.date.today()
|
||||||
|
SD = (TODAY.replace(day=1) - dt.timedelta(days=365)).strftime("%Y.%m.%d")
|
||||||
|
ED = TODAY.strftime("%Y.%m.%d")
|
||||||
|
|
||||||
|
EXTRA_SCHEMA = """
|
||||||
|
-- per-flat snapshot (Поквартирные/Лоты)
|
||||||
|
CREATE TABLE IF NOT EXISTS objective_lots (
|
||||||
|
lot_id INTEGER PRIMARY KEY, -- field "Id"
|
||||||
|
project_id INTEGER,
|
||||||
|
project TEXT,
|
||||||
|
developer TEXT,
|
||||||
|
city TEXT,
|
||||||
|
district TEXT,
|
||||||
|
corpus TEXT,
|
||||||
|
address TEXT,
|
||||||
|
obj_class TEXT,
|
||||||
|
sales_start TEXT,
|
||||||
|
plan_date TEXT,
|
||||||
|
fact_date TEXT,
|
||||||
|
readiness_pct REAL,
|
||||||
|
construction_stage TEXT,
|
||||||
|
finish_type TEXT,
|
||||||
|
section TEXT,
|
||||||
|
floor INTEGER,
|
||||||
|
lot_num TEXT,
|
||||||
|
room_kind TEXT,
|
||||||
|
status TEXT,
|
||||||
|
sold TEXT,
|
||||||
|
rooms_dev TEXT,
|
||||||
|
rooms_pd TEXT,
|
||||||
|
rooms_obj TEXT,
|
||||||
|
area_dev REAL,
|
||||||
|
area_pd REAL,
|
||||||
|
budget_rub REAL,
|
||||||
|
price_per_m2 REAL,
|
||||||
|
price_method TEXT,
|
||||||
|
price_set_date TEXT,
|
||||||
|
price_actual_date TEXT,
|
||||||
|
offer_price REAL,
|
||||||
|
delta_price_rub REAL,
|
||||||
|
delta_price_pct REAL,
|
||||||
|
contract_date TEXT,
|
||||||
|
register_date TEXT,
|
||||||
|
deal_type TEXT,
|
||||||
|
buyer_type TEXT,
|
||||||
|
register_num TEXT,
|
||||||
|
encumbrance TEXT,
|
||||||
|
bank TEXT,
|
||||||
|
encumbrance_start TEXT,
|
||||||
|
egrn_actual_date TEXT,
|
||||||
|
fetched_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS lots_proj ON objective_lots(project);
|
||||||
|
CREATE INDEX IF NOT EXISTS lots_dist ON objective_lots(district);
|
||||||
|
CREATE INDEX IF NOT EXISTS lots_bank ON objective_lots(bank);
|
||||||
|
CREATE INDEX IF NOT EXISTS lots_status ON objective_lots(status);
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Mapping for per-flat (Поквартирные/Лоты)
|
||||||
|
LOTS_MAP = {
|
||||||
|
"lot_id":"Id","project_id":"Id проекта","project":"Проект","developer":"Девелопер",
|
||||||
|
"city":"Город","district":"Район","corpus":"Корпус","address":"Адрес",
|
||||||
|
"obj_class":"Класс","sales_start":"Старт продаж",
|
||||||
|
"plan_date":"Планируемая дата ввода","fact_date":"Фактическая дата ввода",
|
||||||
|
"readiness_pct":"Готовность","construction_stage":"Стадия строительства",
|
||||||
|
"finish_type":"Отделка по корпусу","section":"Секция","floor":"Этаж",
|
||||||
|
"lot_num":"Номер лота","room_kind":"Вид помещения","status":"Статус",
|
||||||
|
"sold":"Продано","rooms_dev":"Количество комнат(Сайт девелопера)",
|
||||||
|
"rooms_pd":"Количество комнат(ПД)","rooms_obj":"Количество комнат(Данные объектива)",
|
||||||
|
"area_dev":"Площадь, м2(Сайт девелопера)","area_pd":"Площадь, м2(ПД)",
|
||||||
|
"budget_rub":"Расчетный бюджет лота, Р","price_per_m2":"Цена за м2, Р",
|
||||||
|
"price_method":"Способ определения цены","price_set_date":"Дата установки цены",
|
||||||
|
"price_actual_date":"Дата актуальности цены","offer_price":"Цена предложения, Р",
|
||||||
|
"delta_price_rub":"Дельта цена, Р","delta_price_pct":"Дельта цена, %",
|
||||||
|
"contract_date":"Дата договора","register_date":"Дата регистрации",
|
||||||
|
"deal_type":"Тип сделки","buyer_type":"Тип покупателя","register_num":"Номер регистрации",
|
||||||
|
"encumbrance":"Тип обременения","bank":"Банк","encumbrance_start":"Дата начала обременения",
|
||||||
|
"egrn_actual_date":"Дата актуальности данных из ЕГРН",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_token():
|
||||||
|
r = requests.get(f"{API}/Users/User/GetToken", params={"apiKey": KEY}, timeout=30)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()["token"]
|
||||||
|
|
||||||
|
|
||||||
|
def fetch(token, params, attempts=5):
|
||||||
|
"""Fetch with backoff for 429/5xx."""
|
||||||
|
last = None
|
||||||
|
for i in range(attempts):
|
||||||
|
r = requests.get(f"{API}/v2/Report/GetReport",
|
||||||
|
params=params,
|
||||||
|
headers={"Authorization": f"Bearer {token}", "Accept-Encoding": "br"},
|
||||||
|
timeout=180)
|
||||||
|
if r.ok:
|
||||||
|
return r
|
||||||
|
last = (r.status_code, r.text[:200])
|
||||||
|
# If 401 — refresh token
|
||||||
|
if r.status_code == 401:
|
||||||
|
token = get_token()
|
||||||
|
continue
|
||||||
|
wait = int(r.headers.get("Retry-After") or (10 * (2 ** i)))
|
||||||
|
print(f" HTTP {r.status_code} · ждём {wait}с (попытка {i+1}/{attempts})")
|
||||||
|
time.sleep(min(wait, 120))
|
||||||
|
raise RuntimeError(f"failed after {attempts}: {last}")
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_pct(s):
|
||||||
|
if s is None: return None
|
||||||
|
s = str(s).strip()
|
||||||
|
if s.endswith("%"): s = s[:-1]
|
||||||
|
try: return float(s)
|
||||||
|
except: return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_dec(v):
|
||||||
|
if v in (None, ""): return None
|
||||||
|
if isinstance(v, (int, float)): return v
|
||||||
|
try: return float(str(v).replace(" ","").replace(",","."))
|
||||||
|
except: return None
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
conn = sqlite3.connect(DB)
|
||||||
|
conn.executescript(EXTRA_SCHEMA)
|
||||||
|
|
||||||
|
token = get_token()
|
||||||
|
print(f"Group={GROUP} window={SD}..{ED}")
|
||||||
|
|
||||||
|
# -------- probe all four (without ComplexName, then with) --------
|
||||||
|
print("\n=== Probe all four reports ===")
|
||||||
|
for rt in ("Сводные", "Поквартирные"):
|
||||||
|
for rn in ("Корпуса", "Лоты"):
|
||||||
|
base = {"Page":"Отчеты","ReportSection":"Объединенные данные",
|
||||||
|
"ReportType":rt,"ReportName":rn,"GroupName":GROUP,
|
||||||
|
"UseDdu":"true","UseDkp":"true"}
|
||||||
|
# Сводные wants StartDate/EndDate
|
||||||
|
if rt == "Сводные":
|
||||||
|
base["StartDate"] = SD; base["EndDate"] = ED
|
||||||
|
try:
|
||||||
|
r = fetch(token, base, attempts=2)
|
||||||
|
rows = r.json().get("result", [])
|
||||||
|
rows_n = len(rows) if isinstance(rows, list) else "?"
|
||||||
|
print(f" {rt}/{rn:<7} (whole Ekb): rows={rows_n}, bytes={len(r.content)}")
|
||||||
|
ts = dt.datetime.now().strftime("%Y%m%dT%H%M%S")
|
||||||
|
fname = RAW / f"{rt}_{rn}_whole_{ts}.json"
|
||||||
|
fname.write_text(json.dumps(rows, ensure_ascii=False))
|
||||||
|
if isinstance(rows, list) and rows:
|
||||||
|
print(f" sample keys ({len(rows[0])}): {list(rows[0].keys())[:8]}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" {rt}/{rn} FAIL: {e}")
|
||||||
|
# try with ComplexName fallback
|
||||||
|
try:
|
||||||
|
base2 = dict(base, ComplexName="Парк Культуры")
|
||||||
|
r = fetch(token, base2, attempts=2)
|
||||||
|
rows = r.json().get("result", [])
|
||||||
|
rows_n = len(rows) if isinstance(rows, list) else "?"
|
||||||
|
print(f" +ComplexName=Парк Культуры → rows={rows_n}")
|
||||||
|
ts = dt.datetime.now().strftime("%Y%m%dT%H%M%S")
|
||||||
|
(RAW / f"{rt}_{rn}_pk_{ts}.json").write_text(json.dumps(rows, ensure_ascii=False))
|
||||||
|
except Exception as e2:
|
||||||
|
print(f" +ComplexName FAIL: {e2}")
|
||||||
|
time.sleep(15) # be nice — Objective rate-limits aggressively
|
||||||
|
|
||||||
|
# -------- bulk-pull Поквартирные/Лоты (the most valuable per-flat data) --------
|
||||||
|
# If whole-Ekb works, prefer that. Otherwise iterate over distinct projects from corp_month.
|
||||||
|
print("\n=== Bulk pull Поквартирные/Лоты ===")
|
||||||
|
pf_params = {"Page":"Отчеты","ReportSection":"Объединенные данные",
|
||||||
|
"ReportType":"Поквартирные","ReportName":"Лоты","GroupName":GROUP,
|
||||||
|
"UseDdu":"true","UseDkp":"true"}
|
||||||
|
rows = None
|
||||||
|
try:
|
||||||
|
r = fetch(token, pf_params, attempts=3)
|
||||||
|
rows = r.json().get("result", [])
|
||||||
|
print(f" whole Ekb: {len(rows)} flats, {len(r.content)/1024:.0f} KB")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" whole-Ekb fail: {e}; falling back to per-project loop")
|
||||||
|
|
||||||
|
if rows is None:
|
||||||
|
projects = [p[0] for p in conn.execute(
|
||||||
|
"SELECT DISTINCT project FROM objective_corp_month WHERE project IS NOT NULL ORDER BY project"
|
||||||
|
).fetchall()]
|
||||||
|
print(f" iterating {len(projects)} projects")
|
||||||
|
rows = []
|
||||||
|
for i, proj in enumerate(projects, 1):
|
||||||
|
try:
|
||||||
|
r = fetch(token, dict(pf_params, ComplexName=proj))
|
||||||
|
got = r.json().get("result", [])
|
||||||
|
rows.extend(got)
|
||||||
|
print(f" [{i}/{len(projects)}] {proj}: +{len(got)}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [{i}/{len(projects)}] {proj}: FAIL {e}")
|
||||||
|
time.sleep(10)
|
||||||
|
|
||||||
|
if rows:
|
||||||
|
ts = dt.datetime.now().strftime("%Y%m%dT%H%M%S")
|
||||||
|
(RAW / f"Поквартирные_Лоты_{ts}.json").write_text(json.dumps(rows, ensure_ascii=False))
|
||||||
|
# Insert
|
||||||
|
conn.execute("DELETE FROM objective_lots")
|
||||||
|
ins = 0; skip = 0
|
||||||
|
for r in rows:
|
||||||
|
try:
|
||||||
|
vals = {k: r.get(v) for k, v in LOTS_MAP.items()}
|
||||||
|
vals["readiness_pct"] = normalize_pct(vals["readiness_pct"])
|
||||||
|
for f in ("area_dev","area_pd","budget_rub","price_per_m2","offer_price",
|
||||||
|
"delta_price_rub","delta_price_pct","floor","lot_id","project_id"):
|
||||||
|
vals[f] = parse_dec(vals[f])
|
||||||
|
cols = list(vals.keys())
|
||||||
|
conn.execute(f"INSERT OR REPLACE INTO objective_lots({','.join(cols)}) VALUES ({','.join(['?']*len(cols))})",
|
||||||
|
[vals[c] for c in cols])
|
||||||
|
ins += 1
|
||||||
|
except Exception:
|
||||||
|
skip += 1
|
||||||
|
conn.commit()
|
||||||
|
print(f" inserted: {ins}, skipped: {skip}")
|
||||||
|
|
||||||
|
# Sanity stats
|
||||||
|
n = conn.execute("SELECT count(*) FROM objective_lots").fetchone()[0]
|
||||||
|
print(f"\n Total flats stored: {n}")
|
||||||
|
for line in conn.execute("""SELECT status, count(*) FROM objective_lots
|
||||||
|
GROUP BY 1 ORDER BY 2 DESC""").fetchall():
|
||||||
|
print(f" status={line[0]:<25} {line[1]}")
|
||||||
|
|
||||||
|
print("\n Top banks (mortgage encumbrance):")
|
||||||
|
for line in conn.execute("""SELECT bank, count(*) FROM objective_lots
|
||||||
|
WHERE bank IS NOT NULL AND bank!=''
|
||||||
|
GROUP BY 1 ORDER BY 2 DESC LIMIT 10""").fetchall():
|
||||||
|
print(f" {line[0]:<35} {line[1]}")
|
||||||
|
|
||||||
|
print("\n By district (priced lots):")
|
||||||
|
for line in conn.execute("""SELECT district, count(*) total,
|
||||||
|
SUM(CASE WHEN price_per_m2>0 THEN 1 ELSE 0 END) priced,
|
||||||
|
ROUND(AVG(price_per_m2)/1000,1) avg_kp
|
||||||
|
FROM objective_lots
|
||||||
|
WHERE district IS NOT NULL
|
||||||
|
GROUP BY 1 ORDER BY 2 DESC LIMIT 12""").fetchall():
|
||||||
|
print(f" {line[0]:<22} total={line[1]:>5} priced={line[2]:>5} avg={line[3]} тыс/м²")
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
print("\nDone.")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
132
site-finder/08_enrich_economics.py
Normal file
132
site-finder/08_enrich_economics.py
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
"""Recompute district economics using per-flat (Поквартирные/Лоты) data.
|
||||||
|
|
||||||
|
Per-flat is way richer than monthly Сводные:
|
||||||
|
- real velocity: registered DDU per month (last 12 mo)
|
||||||
|
- real median price: from individual sold lots
|
||||||
|
- real average area sold
|
||||||
|
- готовность distribution
|
||||||
|
- bank diversity (mortgage market attractiveness)
|
||||||
|
- sold-out ratio (продано / total in projects with sales started)
|
||||||
|
- sales freshness (median days since contract for last 90d)
|
||||||
|
|
||||||
|
Replaces values in district_economics with `*_real` columns.
|
||||||
|
"""
|
||||||
|
import sqlite3, pathlib, datetime as dt
|
||||||
|
DB = pathlib.Path(__file__).parent / "analysis.db"
|
||||||
|
|
||||||
|
EXTRA = """
|
||||||
|
ALTER TABLE district_economics ADD COLUMN real_n_lots INTEGER;
|
||||||
|
ALTER TABLE district_economics ADD COLUMN real_n_sold INTEGER;
|
||||||
|
ALTER TABLE district_economics ADD COLUMN real_sold_pct REAL;
|
||||||
|
ALTER TABLE district_economics ADD COLUMN real_median_price_m2 REAL;
|
||||||
|
ALTER TABLE district_economics ADD COLUMN real_p25_price_m2 REAL;
|
||||||
|
ALTER TABLE district_economics ADD COLUMN real_p75_price_m2 REAL;
|
||||||
|
ALTER TABLE district_economics ADD COLUMN real_avg_area_sold REAL;
|
||||||
|
ALTER TABLE district_economics ADD COLUMN real_velocity_per_month REAL;
|
||||||
|
ALTER TABLE district_economics ADD COLUMN real_n_banks INTEGER;
|
||||||
|
ALTER TABLE district_economics ADD COLUMN real_top_bank TEXT;
|
||||||
|
ALTER TABLE district_economics ADD COLUMN real_top_bank_share REAL;
|
||||||
|
ALTER TABLE district_economics ADD COLUMN real_avg_readiness_pct REAL;
|
||||||
|
"""
|
||||||
|
|
||||||
|
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 percentile(vals, p):
|
||||||
|
if not vals: return None
|
||||||
|
vals = sorted(vals)
|
||||||
|
k = (len(vals) - 1) * p
|
||||||
|
f = int(k); c = min(f+1, len(vals)-1)
|
||||||
|
return vals[f] + (vals[c]-vals[f]) * (k - f)
|
||||||
|
|
||||||
|
def main():
|
||||||
|
conn = sqlite3.connect(DB)
|
||||||
|
safe_alter(conn, EXTRA)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# n_lots, n_sold, prices, areas, banks, readiness — all per district
|
||||||
|
rows = conn.execute("""
|
||||||
|
SELECT district, status, sold, price_per_m2, area_pd, bank,
|
||||||
|
readiness_pct, register_date
|
||||||
|
FROM objective_lots
|
||||||
|
WHERE district IS NOT NULL AND district!=''
|
||||||
|
""").fetchall()
|
||||||
|
by_d = {}
|
||||||
|
for d, st, sold, price, area, bank, ready, reg in rows:
|
||||||
|
by_d.setdefault(d, []).append((st, sold, price, area, bank, ready, reg))
|
||||||
|
|
||||||
|
today = dt.date.today()
|
||||||
|
cutoff_12mo = today - dt.timedelta(days=365)
|
||||||
|
|
||||||
|
for d, items in by_d.items():
|
||||||
|
n_lots = len(items)
|
||||||
|
sold_items = [it for it in items if (it[1] or '').strip().lower() == 'да']
|
||||||
|
n_sold = len(sold_items)
|
||||||
|
sold_pct = 100.0 * n_sold / n_lots if n_lots else None
|
||||||
|
|
||||||
|
prices = [it[2] for it in sold_items if it[2] and it[2] > 0]
|
||||||
|
med = percentile(prices, 0.5) / 1000 if prices else None # → тыс ₽/м²
|
||||||
|
p25 = percentile(prices, 0.25) / 1000 if prices else None
|
||||||
|
p75 = percentile(prices, 0.75) / 1000 if prices else None
|
||||||
|
|
||||||
|
areas = [it[3] for it in sold_items if it[3] and it[3] > 0]
|
||||||
|
avg_area = sum(areas)/len(areas) if areas else None
|
||||||
|
|
||||||
|
# velocity: registered deals in last 12 mo / 12 / n_corpuses_in_district
|
||||||
|
reg_dates = []
|
||||||
|
for it in sold_items:
|
||||||
|
r = it[6]
|
||||||
|
if not r: continue
|
||||||
|
try:
|
||||||
|
rd = dt.date.fromisoformat(r[:10])
|
||||||
|
if rd >= cutoff_12mo: reg_dates.append(rd)
|
||||||
|
except: pass
|
||||||
|
# Distinct corpuses in district (from per-flat data)
|
||||||
|
n_corp = len({(it[0],) for it in items if it[0]}) # crude — but we want corpuses
|
||||||
|
n_corp_real = len({(it,) for it in conn.execute(
|
||||||
|
"SELECT DISTINCT project, corpus FROM objective_lots WHERE district=?", (d,)).fetchall()}) or 1
|
||||||
|
velocity = len(reg_dates) / 12.0 / n_corp_real if reg_dates else 0
|
||||||
|
|
||||||
|
banks = [it[4] for it in items if it[4] and it[4].strip()]
|
||||||
|
unique_banks = set(banks)
|
||||||
|
top_bank, top_share = None, None
|
||||||
|
if banks:
|
||||||
|
from collections import Counter
|
||||||
|
c = Counter(banks)
|
||||||
|
top_bank, top_n = c.most_common(1)[0]
|
||||||
|
top_share = top_n / len(banks)
|
||||||
|
|
||||||
|
ready_vals = [it[5] for it in items if it[5] is not None]
|
||||||
|
avg_ready = sum(ready_vals)/len(ready_vals) if ready_vals else None
|
||||||
|
|
||||||
|
conn.execute("""UPDATE district_economics SET
|
||||||
|
real_n_lots=?, real_n_sold=?, real_sold_pct=?,
|
||||||
|
real_median_price_m2=?, real_p25_price_m2=?, real_p75_price_m2=?,
|
||||||
|
real_avg_area_sold=?, real_velocity_per_month=?,
|
||||||
|
real_n_banks=?, real_top_bank=?, real_top_bank_share=?,
|
||||||
|
real_avg_readiness_pct=?
|
||||||
|
WHERE district=?""",
|
||||||
|
(n_lots, n_sold, sold_pct, med, p25, p75, avg_area, velocity,
|
||||||
|
len(unique_banks), top_bank, top_share, avg_ready, d))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
print(f"{'район':<22}{'лот':>6}{'прод':>7}{'sold%':>7}{'медцена':>9}{'площ':>6}{'vel':>6}{'банки':>6}{'top_bank':>22}{'%':>5}{'готовн':>7}")
|
||||||
|
for r in conn.execute("""SELECT district, real_n_lots, real_n_sold, real_sold_pct,
|
||||||
|
real_median_price_m2, real_avg_area_sold,
|
||||||
|
real_velocity_per_month, real_n_banks,
|
||||||
|
real_top_bank, real_top_bank_share, real_avg_readiness_pct
|
||||||
|
FROM district_economics
|
||||||
|
WHERE real_n_lots>0
|
||||||
|
ORDER BY real_median_price_m2 DESC NULLS LAST""").fetchall():
|
||||||
|
d, nl, ns, sp, mp, aa, v, nb, tb, ts, rd = r
|
||||||
|
print(f"{d:<22}{nl:>6}{ns:>7}{sp or 0:>6.1f}%{mp or 0:>9.1f}{aa or 0:>6.1f}{v:>6.2f}{nb or 0:>6}{(tb or '—')[:20]:>22}{(ts or 0)*100:>4.0f}%{rd or 0:>6.0f}%")
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
169
site-finder/09_macro_and_trend.py
Normal file
169
site-finder/09_macro_and_trend.py
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
"""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()
|
||||||
191
site-finder/10_score_v2.py
Normal file
191
site-finder/10_score_v2.py
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
"""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.sat_factor, 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, sat, 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; sat = 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()
|
||||||
114
site-finder/11_refetch_pois_extended.py
Normal file
114
site-finder/11_refetch_pois_extended.py
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
"""Refetch OSM POIs adding social/lifestyle categories.
|
||||||
|
|
||||||
|
New categories:
|
||||||
|
cafe amenity=cafe
|
||||||
|
restaurant amenity=restaurant | fast_food
|
||||||
|
fuel amenity=fuel
|
||||||
|
atm amenity=atm
|
||||||
|
post amenity=post_office
|
||||||
|
worship amenity=place_of_worship
|
||||||
|
trolley_stop highway=bus_stop + bus=yes; OR amenity=bus_station
|
||||||
|
police amenity=police
|
||||||
|
fire amenity=fire_station
|
||||||
|
library amenity=library
|
||||||
|
|
||||||
|
Existing 15 stay; only fetch new and append.
|
||||||
|
Recompute distances + features.
|
||||||
|
"""
|
||||||
|
import sqlite3, pathlib, requests, time, json, math, urllib3
|
||||||
|
urllib3.disable_warnings()
|
||||||
|
|
||||||
|
DB = pathlib.Path(__file__).parent / "analysis.db"
|
||||||
|
CACHE = pathlib.Path(__file__).parent / "cache" / "overpass_raw.json"
|
||||||
|
|
||||||
|
NEW_QUERIES = [
|
||||||
|
("cafe", '["amenity"="cafe"]'),
|
||||||
|
("restaurant", '["amenity"~"^(restaurant|fast_food)$"]'),
|
||||||
|
("fuel", '["amenity"="fuel"]'),
|
||||||
|
("atm", '["amenity"="atm"]'),
|
||||||
|
("post", '["amenity"="post_office"]'),
|
||||||
|
("worship", '["amenity"="place_of_worship"]'),
|
||||||
|
("library", '["amenity"="library"]'),
|
||||||
|
("police", '["amenity"="police"]'),
|
||||||
|
("fire", '["amenity"="fire_station"]'),
|
||||||
|
("bank", '["amenity"="bank"]'),
|
||||||
|
]
|
||||||
|
ENDPOINTS = [
|
||||||
|
"https://overpass-api.de/api/interpreter",
|
||||||
|
"https://overpass.kumi.systems/api/interpreter",
|
||||||
|
"https://overpass.openstreetmap.ru/api/interpreter",
|
||||||
|
]
|
||||||
|
|
||||||
|
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 overpass(filt, b):
|
||||||
|
q = f"[out:json][timeout:120];(node{filt}({b[0]},{b[1]},{b[2]},{b[3]});way{filt}({b[0]},{b[1]},{b[2]},{b[3]}););out center tags;"
|
||||||
|
last = None
|
||||||
|
for ep in ENDPOINTS:
|
||||||
|
try:
|
||||||
|
r = requests.post(ep, data={"data": q}, timeout=180, verify=False,
|
||||||
|
headers={"User-Agent":"gendesign/0.5"})
|
||||||
|
if r.ok: return r.json().get("elements", [])
|
||||||
|
last = f"{ep} {r.status_code}"
|
||||||
|
time.sleep(2)
|
||||||
|
except Exception as e:
|
||||||
|
last = f"{ep} {e}"; time.sleep(2)
|
||||||
|
raise RuntimeError(last)
|
||||||
|
|
||||||
|
def main():
|
||||||
|
cache = json.loads(CACHE.read_text()) if CACHE.exists() else {}
|
||||||
|
conn = sqlite3.connect(DB)
|
||||||
|
sites = conn.execute("SELECT site_id, lat, lon FROM sites").fetchall()
|
||||||
|
lats=[s[1] for s in sites]; lons=[s[2] for s in sites]
|
||||||
|
b = (min(lats)-0.05, min(lons)-0.05, max(lats)+0.05, max(lons)+0.05)
|
||||||
|
print(f"BBox: {b}")
|
||||||
|
|
||||||
|
for cat, filt in NEW_QUERIES:
|
||||||
|
if cat in cache:
|
||||||
|
print(f" {cat:<12} cached: {len(cache[cat])}")
|
||||||
|
continue
|
||||||
|
print(f" {cat:<12} fetching ...", end=" ", flush=True)
|
||||||
|
try:
|
||||||
|
elems = overpass(filt, b)
|
||||||
|
cache[cat] = elems
|
||||||
|
print(f"{len(elems)}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"FAIL {e}")
|
||||||
|
cache[cat] = []
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
CACHE.write_text(json.dumps(cache, ensure_ascii=False))
|
||||||
|
print(f"\nCache saved: {CACHE.stat().st_size/1024:.0f} KB ({len(cache)} categories)")
|
||||||
|
|
||||||
|
# Recompute pois table from full cache
|
||||||
|
conn.execute("DELETE FROM pois")
|
||||||
|
n=0
|
||||||
|
for cat, elems in cache.items():
|
||||||
|
for el in elems:
|
||||||
|
if el["type"]=="node":
|
||||||
|
la,lo=el.get("lat"),el.get("lon")
|
||||||
|
else:
|
||||||
|
c=el.get("center") or {}; la,lo=c.get("lat"),c.get("lon")
|
||||||
|
if la is None: continue
|
||||||
|
tags=el.get("tags") or {}
|
||||||
|
name = tags.get("name") or tags.get("operator") or ""
|
||||||
|
for sid,sla,slo in sites:
|
||||||
|
d = hav(sla,slo,la,lo)
|
||||||
|
if d <= 2000:
|
||||||
|
conn.execute("""INSERT INTO pois(site_id,category,osm_type,osm_id,name,lat,lon,distance_m,raw_tags)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?)""",
|
||||||
|
(sid,cat,el["type"],str(el["id"]),name,la,lo,d,json.dumps(tags,ensure_ascii=False)))
|
||||||
|
n+=1
|
||||||
|
conn.commit()
|
||||||
|
print(f"\nrecomputed {n} site-poi pairs")
|
||||||
|
print("\nPOI counts per category:")
|
||||||
|
for cat,c in conn.execute("SELECT category,count(*) FROM pois GROUP BY 1 ORDER BY 2 DESC").fetchall():
|
||||||
|
print(f" {cat:<14} {c}")
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
102
site-finder/12_more_pois.py
Normal file
102
site-finder/12_more_pois.py
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
"""Add more POI categories: parking, gym/fitness, theater, taxi, marketplaces, etc."""
|
||||||
|
import sqlite3, pathlib, requests, time, json, math, urllib3
|
||||||
|
urllib3.disable_warnings()
|
||||||
|
|
||||||
|
DB = pathlib.Path(__file__).parent / "analysis.db"
|
||||||
|
CACHE = pathlib.Path(__file__).parent / "cache" / "overpass_raw.json"
|
||||||
|
|
||||||
|
NEW_QUERIES = [
|
||||||
|
("parking", '["amenity"="parking"]'),
|
||||||
|
("gym", '["leisure"="fitness_centre"]'),
|
||||||
|
("theater", '["amenity"="theatre"]'),
|
||||||
|
("cinema", '["amenity"="cinema"]'),
|
||||||
|
("marketplace", '["amenity"="marketplace"]'),
|
||||||
|
("museum", '["tourism"="museum"]'),
|
||||||
|
("hotel", '["tourism"~"^(hotel|hostel|apartment)$"]'),
|
||||||
|
("nightclub", '["amenity"~"^(nightclub|bar|pub)$"]'),
|
||||||
|
("car_wash", '["amenity"="car_wash"]'),
|
||||||
|
("car_rental", '["amenity"~"^(car_rental|taxi)$"]'),
|
||||||
|
("vet", '["amenity"="veterinary"]'),
|
||||||
|
("courier", '["amenity"~"^(parcel_locker|post_box)$"]'),
|
||||||
|
("kindergarten_priv", '["amenity"="kindergarten"]["fee"="yes"]'), # platnyye
|
||||||
|
("dentist", '["amenity"="dentist"]'),
|
||||||
|
("childcare", '["amenity"="childcare"]'),
|
||||||
|
]
|
||||||
|
ENDPOINTS = [
|
||||||
|
"https://overpass-api.de/api/interpreter",
|
||||||
|
"https://overpass.kumi.systems/api/interpreter",
|
||||||
|
]
|
||||||
|
|
||||||
|
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 overpass(filt, b):
|
||||||
|
q = f'[out:json][timeout:120];(node{filt}({b[0]},{b[1]},{b[2]},{b[3]});way{filt}({b[0]},{b[1]},{b[2]},{b[3]}););out center tags;'
|
||||||
|
last = None
|
||||||
|
for ep in ENDPOINTS:
|
||||||
|
try:
|
||||||
|
r = requests.post(ep, data={"data": q}, timeout=180, verify=False,
|
||||||
|
headers={"User-Agent":"gendesign/0.7"})
|
||||||
|
if r.ok: return r.json().get("elements", [])
|
||||||
|
last = f"{ep} {r.status_code}"
|
||||||
|
time.sleep(2)
|
||||||
|
except Exception as e:
|
||||||
|
last = f"{ep} {e}"; time.sleep(2)
|
||||||
|
raise RuntimeError(last)
|
||||||
|
|
||||||
|
def main():
|
||||||
|
cache = json.loads(CACHE.read_text()) if CACHE.exists() else {}
|
||||||
|
conn = sqlite3.connect(DB)
|
||||||
|
sites = conn.execute("SELECT site_id, lat, lon FROM sites").fetchall()
|
||||||
|
lats=[s[1] for s in sites]; lons=[s[2] for s in sites]
|
||||||
|
b = (min(lats)-0.005, min(lons)-0.005, max(lats)+0.005, max(lons)+0.005)
|
||||||
|
|
||||||
|
for cat, filt in NEW_QUERIES:
|
||||||
|
if cat in cache:
|
||||||
|
print(f" {cat:<20} cached: {len(cache[cat])}")
|
||||||
|
continue
|
||||||
|
print(f" {cat:<20} fetching...", end=" ", flush=True)
|
||||||
|
try:
|
||||||
|
elems = overpass(filt, b)
|
||||||
|
cache[cat] = elems
|
||||||
|
print(f"{len(elems)}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"FAIL {e}")
|
||||||
|
cache[cat] = []
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
CACHE.write_text(json.dumps(cache, ensure_ascii=False))
|
||||||
|
print(f"\nCache: {len(cache)} categories, {CACHE.stat().st_size/1024:.0f} KB")
|
||||||
|
|
||||||
|
# rebuild pois table
|
||||||
|
conn.execute("DELETE FROM pois")
|
||||||
|
n = 0
|
||||||
|
for cat, elems in cache.items():
|
||||||
|
for el in elems:
|
||||||
|
if el["type"] == "node":
|
||||||
|
la, lo = el.get("lat"), el.get("lon")
|
||||||
|
else:
|
||||||
|
c = el.get("center") or {}
|
||||||
|
la, lo = c.get("lat"), c.get("lon")
|
||||||
|
if la is None: continue
|
||||||
|
tags = el.get("tags") or {}
|
||||||
|
name = tags.get("name") or tags.get("operator") or ""
|
||||||
|
for sid, sla, slo in sites:
|
||||||
|
d = hav(sla, slo, la, lo)
|
||||||
|
if d <= 2000:
|
||||||
|
conn.execute("""INSERT INTO pois(site_id,category,osm_type,osm_id,name,lat,lon,distance_m,raw_tags)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?)""",
|
||||||
|
(sid, cat, el["type"], str(el["id"]), name, la, lo, d, json.dumps(tags, ensure_ascii=False)))
|
||||||
|
n += 1
|
||||||
|
conn.commit()
|
||||||
|
print(f"recomputed {n} site-poi pairs")
|
||||||
|
print("\nTop categories:")
|
||||||
|
for c, n in conn.execute("SELECT category, count(*) FROM pois GROUP BY 1 ORDER BY 2 DESC LIMIT 25").fetchall():
|
||||||
|
print(f" {c:<20} {n}")
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
1
site-finder/cache/ekb_admin_districts.geojson
vendored
Normal file
1
site-finder/cache/ekb_admin_districts.geojson
vendored
Normal file
File diff suppressed because one or more lines are too long
1
site-finder/cache/geocode_cache.json
vendored
Normal file
1
site-finder/cache/geocode_cache.json
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{"56.87891,60.52262": {"display_name": "31А, Маневровая улица, Старая Сортировка, Железнодорожный район, Екатеринбург, городской округ Екатеринбург, Свердловская область, Уральский федеральный округ, 620050, Россия", "road": "Маневровая улица", "house_number": "31А", "suburb": "Старая Сортировка", "city_district": "Железнодорожный район", "city": "Екатеринбург"}, "56.87873,60.52268": {"display_name": "Магнит, Билимбаевская улица, Старая Сортировка, Железнодорожный район, Екатеринбург, городской округ Екатеринбург, Свердловская область, Уральский федеральный округ, 620050, Россия", "road": "Билимбаевская улица", "house_number": null, "suburb": "Старая Сортировка", "city_district": "Железнодорожный район", "city": "Екатеринбург"}, "56.88270,60.52680": {"display_name": "Маневровая улица, Старая Сортировка, Железнодорожный район, Екатеринбург, городской округ Екатеринбург, Свердловская область, Уральский федеральный округ, 620050, Россия", "road": "Маневровая улица", "house_number": null, "suburb": "Старая Сортировка", "city_district": "Железнодорожный район", "city": "Екатеринбург"}, "56.87867,60.52265": {"display_name": "31А, Маневровая улица, Старая Сортировка, Железнодорожный район, Екатеринбург, городской округ Екатеринбург, Свердловская область, Уральский федеральный округ, 620050, Россия", "road": "Маневровая улица", "house_number": "31А", "suburb": "Старая Сортировка", "city_district": "Железнодорожный район", "city": "Екатеринбург"}}
|
||||||
1
site-finder/cache/jk_polygons.geojson
vendored
Normal file
1
site-finder/cache/jk_polygons.geojson
vendored
Normal file
File diff suppressed because one or more lines are too long
162
site-finder/cache/parcel_polygons/66_41_0204016_10.geojson
vendored
Normal file
162
site-finder/cache/parcel_polygons/66_41_0204016_10.geojson
vendored
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
{
|
||||||
|
"type": "FeatureCollection",
|
||||||
|
"features": [
|
||||||
|
{
|
||||||
|
"id": 103660478,
|
||||||
|
"type": "Feature",
|
||||||
|
"geometry": {
|
||||||
|
"type": "Polygon",
|
||||||
|
"coordinates": [
|
||||||
|
[
|
||||||
|
[
|
||||||
|
60.5222936642305,
|
||||||
|
56.87886819649157
|
||||||
|
],
|
||||||
|
[
|
||||||
|
60.52228588780081,
|
||||||
|
56.87886283773699
|
||||||
|
],
|
||||||
|
[
|
||||||
|
60.52224469481928,
|
||||||
|
56.878834885257085
|
||||||
|
],
|
||||||
|
[
|
||||||
|
60.522052973741495,
|
||||||
|
56.87869300342362
|
||||||
|
],
|
||||||
|
[
|
||||||
|
60.52253110197689,
|
||||||
|
56.878512041850186
|
||||||
|
],
|
||||||
|
[
|
||||||
|
60.52259101161419,
|
||||||
|
56.87848945465676
|
||||||
|
],
|
||||||
|
[
|
||||||
|
60.522737599100324,
|
||||||
|
56.87843394151893
|
||||||
|
],
|
||||||
|
[
|
||||||
|
60.52278893631658,
|
||||||
|
56.878433926994376
|
||||||
|
],
|
||||||
|
[
|
||||||
|
60.522832402213155,
|
||||||
|
56.878434032020515
|
||||||
|
],
|
||||||
|
[
|
||||||
|
60.522886453650436,
|
||||||
|
56.87846714420006
|
||||||
|
],
|
||||||
|
[
|
||||||
|
60.52289738223943,
|
||||||
|
56.878462343346754
|
||||||
|
],
|
||||||
|
[
|
||||||
|
60.523191155978175,
|
||||||
|
56.87868465965369
|
||||||
|
],
|
||||||
|
[
|
||||||
|
60.52329525075883,
|
||||||
|
56.878758083028075
|
||||||
|
],
|
||||||
|
[
|
||||||
|
60.52313452537541,
|
||||||
|
56.87882406737451
|
||||||
|
],
|
||||||
|
[
|
||||||
|
60.52263537237766,
|
||||||
|
56.879028728703595
|
||||||
|
],
|
||||||
|
[
|
||||||
|
60.52257662924073,
|
||||||
|
56.87905283814238
|
||||||
|
],
|
||||||
|
[
|
||||||
|
60.5222936642305,
|
||||||
|
56.87886819649157
|
||||||
|
]
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"crs": {
|
||||||
|
"type": "name",
|
||||||
|
"properties": {
|
||||||
|
"name": "EPSG:4326"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"cadastralDistrictsCode": 66,
|
||||||
|
"category": 36368,
|
||||||
|
"categoryName": "Земельные участки ЕГРН",
|
||||||
|
"descr": "66:41:0204016:10",
|
||||||
|
"externalKey": "66:41:0204016:10",
|
||||||
|
"interactionId": 103594786,
|
||||||
|
"label": "66:41:0204016:10",
|
||||||
|
"options": {
|
||||||
|
"area": null,
|
||||||
|
"cad_num": "66:41:0204016:10",
|
||||||
|
"cost_application_date": "2023-01-01",
|
||||||
|
"cost_approvement_date": "",
|
||||||
|
"cost_determination_date": "2022-01-01",
|
||||||
|
"cost_index": 8497.03,
|
||||||
|
"cost_registration_date": "2023-01-03",
|
||||||
|
"cost_value": 23706713.7,
|
||||||
|
"declared_area": null,
|
||||||
|
"determination_couse": "Акт об утверждении результатов определения кадастровой стоимости, полученных в соответствии с положениями Закона 237-ФЗ\n\n\n\n",
|
||||||
|
"land_record_area": 2790,
|
||||||
|
"land_record_category_type": "Земли населенных пунктов",
|
||||||
|
"land_record_reg_date": "2006-03-06",
|
||||||
|
"land_record_subtype": "Землепользование",
|
||||||
|
"land_record_type": "Земельный участок",
|
||||||
|
"ownership_type": "Частная",
|
||||||
|
"permitted_use_established_by_document": "магазины (общей площадью до 5000 кв.м), общественное питание, бытовое обслуживание, деловое управление",
|
||||||
|
"previously_posted": "Ранее учтенный",
|
||||||
|
"quarter_cad_number": "66:41:0204016",
|
||||||
|
"readable_address": "Российская Федерация, Свердловская область, муниципальное образование \"город Екатеринбург\", город Екатеринбург, улица Билимбаевская, 3",
|
||||||
|
"right_type": "Собственность",
|
||||||
|
"specified_area": 2790,
|
||||||
|
"status": "Ранее учтенный"
|
||||||
|
},
|
||||||
|
"subcategory": 5,
|
||||||
|
"systemInfo": {
|
||||||
|
"inserted": "2023-11-07T18:40:05.722318",
|
||||||
|
"insertedBy": "7EF5CD0B-AC08-4097-AF2B-07CD85992A79",
|
||||||
|
"updated": "2026-02-03T04:59:16.064005",
|
||||||
|
"updatedBy": "7EF5CD0B-AC08-4097-AF2B-07CD85992A79"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"_centroid": {
|
||||||
|
"lat": 56.878729832837834,
|
||||||
|
"lon": 60.5226773243924
|
||||||
|
},
|
||||||
|
"_area_m2": 2788.7,
|
||||||
|
"_source": "rosreestr2coord",
|
||||||
|
"_props": {
|
||||||
|
"area": null,
|
||||||
|
"cad_num": "66:41:0204016:10",
|
||||||
|
"cost_application_date": "2023-01-01",
|
||||||
|
"cost_approvement_date": "",
|
||||||
|
"cost_determination_date": "2022-01-01",
|
||||||
|
"cost_index": 8497.03,
|
||||||
|
"cost_registration_date": "2023-01-03",
|
||||||
|
"cost_value": 23706713.7,
|
||||||
|
"declared_area": null,
|
||||||
|
"determination_couse": "Акт об утверждении результатов определения кадастровой стоимости, полученных в соответствии с положениями Закона 237-ФЗ\n\n\n\n",
|
||||||
|
"land_record_area": 2790,
|
||||||
|
"land_record_category_type": "Земли населенных пунктов",
|
||||||
|
"land_record_reg_date": "2006-03-06",
|
||||||
|
"land_record_subtype": "Землепользование",
|
||||||
|
"land_record_type": "Земельный участок",
|
||||||
|
"ownership_type": "Частная",
|
||||||
|
"permitted_use_established_by_document": "магазины (общей площадью до 5000 кв.м), общественное питание, бытовое обслуживание, деловое управление",
|
||||||
|
"previously_posted": "Ранее учтенный",
|
||||||
|
"quarter_cad_number": "66:41:0204016",
|
||||||
|
"readable_address": "Российская Федерация, Свердловская область, муниципальное образование \"город Екатеринбург\", город Екатеринбург, улица Билимбаевская, 3",
|
||||||
|
"right_type": "Собственность",
|
||||||
|
"specified_area": 2790,
|
||||||
|
"status": "Ранее учтенный"
|
||||||
|
}
|
||||||
|
}
|
||||||
11
site-finder/cache/parcel_polygons/README.md
vendored
Normal file
11
site-finder/cache/parcel_polygons/README.md
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
# Parcel polygons cache
|
||||||
|
|
||||||
|
Drop `<cad_number>.geojson` here when you have NSPD polygon for a parcel
|
||||||
|
(e.g., manually exported from nspd.gov.ru after trusting the Минцифры cert).
|
||||||
|
|
||||||
|
Format: standard GeoJSON Feature or FeatureCollection in EPSG:4326 (WGS84).
|
||||||
|
The pipeline (`01_load_sites.py`) will pick it up next run, store the
|
||||||
|
geometry, and use polygon centroid as the parcel point.
|
||||||
|
|
||||||
|
If a polygon is loaded, the JSON report adds a `geometry` field with
|
||||||
|
the polygon for map rendering.
|
||||||
87
site-finder/db_init.py
Normal file
87
site-finder/db_init.py
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
"""Local SQLite DB for parcel scoring analysis.
|
||||||
|
|
||||||
|
Mirrors the logic the prod gendesign server uses:
|
||||||
|
- domrf_kn_objects (subset: under-construction ЖК in Ekb with coords)
|
||||||
|
- domrf_kn_infrastructure (POI cache)
|
||||||
|
- ekb_districts (geometry, median price)
|
||||||
|
- recommend_mix output (per-parcel score)
|
||||||
|
|
||||||
|
We don't need the full ~3.2GB warehouse — only what the scorer touches.
|
||||||
|
"""
|
||||||
|
import sqlite3, pathlib
|
||||||
|
|
||||||
|
DB = pathlib.Path(__file__).parent / "analysis.db"
|
||||||
|
|
||||||
|
SCHEMA = """
|
||||||
|
CREATE TABLE IF NOT EXISTS sites (
|
||||||
|
site_id TEXT PRIMARY KEY, -- 'parcel:66:41:0204016:10' or 'obj:NNN'
|
||||||
|
kind TEXT NOT NULL, -- 'parcel' | 'jk'
|
||||||
|
name TEXT,
|
||||||
|
address TEXT,
|
||||||
|
district TEXT,
|
||||||
|
obj_class TEXT,
|
||||||
|
developer TEXT,
|
||||||
|
flat_count INTEGER,
|
||||||
|
square_living REAL,
|
||||||
|
ready_dt TEXT,
|
||||||
|
obj_status TEXT,
|
||||||
|
lat REAL NOT NULL,
|
||||||
|
lon REAL NOT NULL,
|
||||||
|
geom_geojson TEXT, -- nullable polygon as raw GeoJSON
|
||||||
|
obj_id INTEGER -- domrf_kn obj_id when kind='jk'
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS sites_kind_idx ON sites(kind);
|
||||||
|
CREATE INDEX IF NOT EXISTS sites_district_idx ON sites(district);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pois (
|
||||||
|
poi_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
site_id TEXT NOT NULL REFERENCES sites(site_id),
|
||||||
|
category TEXT NOT NULL, -- 'kindergarten','school','shop_supermarket', ...
|
||||||
|
osm_type TEXT,
|
||||||
|
osm_id TEXT,
|
||||||
|
name TEXT,
|
||||||
|
lat REAL NOT NULL,
|
||||||
|
lon REAL NOT NULL,
|
||||||
|
distance_m REAL NOT NULL,
|
||||||
|
raw_tags TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS pois_site_cat ON pois(site_id, category);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS features (
|
||||||
|
site_id TEXT NOT NULL REFERENCES sites(site_id),
|
||||||
|
feature TEXT NOT NULL, -- 'kindergarten_nearest_m','schools_in_1km','transit_500m', ...
|
||||||
|
value REAL,
|
||||||
|
PRIMARY KEY (site_id, feature)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS scores (
|
||||||
|
site_id TEXT NOT NULL REFERENCES sites(site_id),
|
||||||
|
component TEXT NOT NULL, -- 'education','retail','health','transit','leisure'
|
||||||
|
score_0_100 REAL NOT NULL,
|
||||||
|
PRIMARY KEY (site_id, component)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS scores_total (
|
||||||
|
site_id TEXT PRIMARY KEY REFERENCES sites(site_id),
|
||||||
|
weighted REAL NOT NULL,
|
||||||
|
rank_overall INTEGER,
|
||||||
|
rank_district INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS run_log (
|
||||||
|
run_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
started_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
finished_at TEXT,
|
||||||
|
notes TEXT
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
conn = sqlite3.connect(DB)
|
||||||
|
conn.executescript(SCHEMA)
|
||||||
|
conn.commit()
|
||||||
|
print(f"DB ready at {DB}")
|
||||||
|
for tbl in ['sites','pois','features','scores','scores_total','run_log']:
|
||||||
|
n = conn.execute(f"SELECT count(*) FROM {tbl}").fetchone()[0]
|
||||||
|
print(f" {tbl}: {n} rows")
|
||||||
|
conn.close()
|
||||||
77
site-finder/reports/parcel_66_41_0204016_10.html
Normal file
77
site-finder/reports/parcel_66_41_0204016_10.html
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang=ru><head><meta charset=utf-8>
|
||||||
|
<title>Анализ участка Участок 66:41:0204016:10</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>Сгенерировано 2026-05-08T00:34:04 · Сравнение с 380 строящимися ЖК Екатеринбурга</div>
|
||||||
|
|
||||||
|
<div class="note">
|
||||||
|
Координаты участка получены из ссылки на NSPD-карту (EPSG:3857 → WGS84):
|
||||||
|
<b>56.878913, 60.522615</b>. Район — центрально-северная часть ЕКБ (Пионерский / Втузгородок).
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class=kpi>
|
||||||
|
<div><b>Итоговый балл</b><span class=big>72.1</span><span class=muted>из 100</span></div>
|
||||||
|
<div><b>Ранг по ЕКБ</b><span>#183 / 381</span></div>
|
||||||
|
<div><b>Перцентиль</b><span>52%</span></div>
|
||||||
|
<div><b>Медиана ЖК</b><span>70.9</span></div>
|
||||||
|
<div><b>Топ-25% ЖК ≥</b><span>81.7</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><tr><td>economic</td><td class='r'>40.2</td><td class='r'>47.2</td><td class='r'>59.5</td><td class='r r'>-7.0</td></tr><tr><td>education</td><td class='r'>79.5</td><td class='r'>75.9</td><td class='r'>94.7</td><td class='r g'>+3.6</td></tr><tr><td>health</td><td class='r'>88.7</td><td class='r'>83.2</td><td class='r'>98.3</td><td class='r g'>+5.5</td></tr><tr><td>leisure</td><td class='r'>98.4</td><td class='r'>86.1</td><td class='r'>96.9</td><td class='r g'>+12.3</td></tr><tr><td>retail</td><td class='r'>100.0</td><td class='r'>100.0</td><td class='r'>100.0</td><td class='r '>+0.0</td></tr><tr><td>transit</td><td class='r'>69.7</td><td class='r'>70.0</td><td class='r'>85.0</td><td class='r r'>-0.3</td></tr></tbody></table>
|
||||||
|
<div class=muted>Веса: образование 20% · здоровье 10% · ритейл 15% · транспорт 15% · досуг 10% · экономика 30%</div>
|
||||||
|
|
||||||
|
<h2>Экономика района (Объектив API, последние 90 дней)</h2>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr><th>Район (Объектив)</th><td><b>Старая Сортировка</b> <span class=muted>(по голосованию 5 ближайших ЖК (расст. до репрезентанта 492 м))</span></td></tr>
|
||||||
|
<tr><th>Проектов в районе</th><td>6</td></tr>
|
||||||
|
<tr><th>Лотов всего / продано</th><td>8,356 / <b>4,078</b> (48.8%)</td></tr>
|
||||||
|
<tr><th>Цена за м² (медиана)</th><td><b>114.9 тыс ₽</b> · P25=96.1 · P75=132.7</td></tr>
|
||||||
|
<tr><th>Средняя площадь сделки</th><td>49.0 м²</td></tr>
|
||||||
|
<tr><th>Скорость продаж (real)</th><td><b>2.7</b> зарег. ДДУ/корпус/мес <span class=muted>(по 12 мес)</span></td></tr>
|
||||||
|
<tr><th>Средняя готовность</th><td>75.8%</td></tr>
|
||||||
|
<tr><th>Цена corp_sum (взвеш.)</th><td class=muted>128.3 тыс ₽/м² · скорость 0.5</td></tr>
|
||||||
|
<tr><th>Распродажа стока (corp_sum)</th><td class=muted>15.9 мес</td></tr>
|
||||||
|
</table>
|
||||||
|
<p class=muted><b>real_*</b> рассчитаны по 8,356 лотам из Поквартирные/Лоты (303 677 квартир Екб). Это per-flat, основной источник правды.</p>
|
||||||
|
|
||||||
|
|
||||||
|
<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><tr><td>kindergarten</td><td>Детский сад № 357</td><td class='r'>380 м</td><td class='r'>1.0</td><td class='r'>4.0</td></tr><tr><td>school</td><td>Школа № 122</td><td class='r'>792 м</td><td class='r'>0.0</td><td class='r'>3.0</td></tr><tr><td>university</td><td>Железнодорожный техникум</td><td class='r'>906 м</td><td class='r'>0.0</td><td class='r'>1.0</td></tr><tr><td>pharmacy</td><td>Вита Экспресс</td><td class='r'>471 м</td><td class='r'>1.0</td><td class='r'>8.0</td></tr><tr><td>clinic</td><td>Инвитро</td><td class='r'>450 м</td><td class='r'>1.0</td><td class='r'>6.0</td></tr><tr><td>hospital</td><td>Дорожная больница станции Екатеринбург-Пассажирский</td><td class='r'>1761 м</td><td class='r'>0.0</td><td class='r'>0.0</td></tr><tr><td>shop_big</td><td>Магнит</td><td class='r'>20 м</td><td class='r'>3.0</td><td class='r'>10.0</td></tr><tr><td>shop_med</td><td>Кировский</td><td class='r'>450 м</td><td class='r'>1.0</td><td class='r'>12.0</td></tr><tr><td>shop_small</td><td>—</td><td class='r'>—</td><td class='r'>0.0</td><td class='r'>0.0</td></tr><tr><td>bus_stop</td><td>Билимбаевская</td><td class='r'>320 м</td><td class='r'>4.0</td><td class='r'>20.0</td></tr><tr><td>tram_stop</td><td>Расточная улица</td><td class='r'>598 м</td><td class='r'>0.0</td><td class='r'>8.0</td></tr><tr><td>metro</td><td>—</td><td class='r'>—</td><td class='r'>0.0</td><td class='r'>0.0</td></tr><tr><td>park</td><td>—</td><td class='r'>566 м</td><td class='r'>0.0</td><td class='r'>3.0</td></tr><tr><td>playground</td><td>—</td><td class='r'>163 м</td><td class='r'>12.0</td><td class='r'>22.0</td></tr><tr><td>sports</td><td>—</td><td class='r'>243 м</td><td class='r'>7.0</td><td class='r'>16.0</td></tr></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><tr><td class='r'>492 м</td><td>Квартал "Траектория"</td><td>Железнодорожный</td><td>Брусника</td><td></td><td class='r'>66.6</td><td class='r'>#223</td></tr><tr><td class='r'>994 м</td><td>Квартал Депо</td><td>Железнодорожный</td><td>Брусника</td><td></td><td class='r'>69.5</td><td class='r'>#206</td></tr><tr><td class='r'>994 м</td><td>Квартал Депо</td><td>Железнодорожный</td><td>Брусника</td><td></td><td class='r'>73.3</td><td class='r'>#176</td></tr><tr><td class='r'>1036 м</td><td>Астон. Движение</td><td>Железнодорожный</td><td>Астон</td><td></td><td class='r'>64.3</td><td class='r'>#240</td></tr><tr><td class='r'>1211 м</td><td>Жилой Комплекс "Раута" </td><td>Железнодорожный</td><td>Эталон</td><td></td><td class='r'>72.0</td><td class='r'>#186</td></tr><tr><td class='r'>1312 м</td><td>Жилой Комплекс "Раута" </td><td>Железнодорожный</td><td>Эталон</td><td></td><td class='r'>74.4</td><td class='r'>#168</td></tr><tr><td class='r'>3065 м</td><td>ЖК Белая Башня</td><td>Орджоникидзевский</td><td>Ривьера Инвест Екатеринбург</td><td></td><td class='r'>81.2</td><td class='r'>#106</td></tr><tr><td class='r'>3596 м</td><td>ЖК "ПАЙЕР"</td><td>Железнодорожный</td><td>Самолет</td><td></td><td class='r'>58.8</td><td class='r'>#267</td></tr><tr><td class='r'>3612 м</td><td>ЖК ТЕМП</td><td>Орджоникидзевский</td><td>Практика</td><td></td><td class='r'>81.4</td><td class='r'>#104</td></tr><tr><td class='r'>4021 м</td><td>ЖК "Парк Победы"</td><td>Орджоникидзевский</td><td>PRINZIP</td><td></td><td class='r'>80.3</td><td class='r'>#118</td></tr></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><tr><td class='r'>#1</td><td>—</td><td>Железнодорожный</td><td>ТЭН</td><td></td><td class='r'>91.2</td></tr><tr><td class='r'>#2</td><td>Квартал «Лайв»</td><td>Ленинский</td><td>Атомстройкомплекс</td><td></td><td class='r'>91.0</td></tr><tr><td class='r'>#3</td><td>Клубный дом инженера Ятеса</td><td>Железнодорожный</td><td>ТЭН</td><td></td><td class='r'>91.0</td></tr><tr><td class='r'>#4</td><td>Клубный дом инженера Ятеса</td><td>Железнодорожный</td><td>ТЭН</td><td></td><td class='r'>90.7</td></tr><tr><td class='r'>#5</td><td>Квартал "На Некрасова"</td><td>Железнодорожный</td><td>Брусника</td><td></td><td class='r'>90.6</td></tr><tr><td class='r'>#6</td><td>ЖК "Азина 16"</td><td>Железнодорожный</td><td>ЛСР</td><td></td><td class='r'>90.4</td></tr><tr><td class='r'>#7</td><td>Жилой дом "ТЕТРО"</td><td>Железнодорожный</td><td>Девелоперская компания "Люди"</td><td></td><td class='r'>90.2</td></tr><tr><td class='r'>#8</td><td>Жилой квартал «Тихий центр» 1 оч. 1 этап</td><td>Железнодорожный</td><td>Синара-Девелопмент</td><td></td><td class='r'>90.1</td></tr><tr><td class='r'>#9</td><td>ЖК "Кайдзен"</td><td>Верх-Исетский</td><td>Астра</td><td></td><td class='r'>90.0</td></tr><tr><td class='r'>#10</td><td>ЖК "Азина 16"</td><td>Железнодорожный</td><td>ЛСР</td><td></td><td class='r'>89.7</td></tr></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>
|
||||||
748
site-finder/reports/parcel_66_41_0204016_10.json
Normal file
748
site-finder/reports/parcel_66_41_0204016_10.json
Normal file
|
|
@ -0,0 +1,748 @@
|
||||||
|
{
|
||||||
|
"generated_at": "2026-05-08T00:34:04",
|
||||||
|
"parcel": {
|
||||||
|
"site_id": "parcel:66:41:0204016:10",
|
||||||
|
"kind": "parcel",
|
||||||
|
"name": "Участок 66:41:0204016:10",
|
||||||
|
"address": "г. Екатеринбург, кад.№ 66:41:0204016:10 (NSPD coordinate_x=6737346.694, coordinate_y=7735409.767, EPSG:3857)",
|
||||||
|
"district": null,
|
||||||
|
"obj_class": null,
|
||||||
|
"developer": null,
|
||||||
|
"flat_count": null,
|
||||||
|
"square_living": null,
|
||||||
|
"ready_dt": null,
|
||||||
|
"obj_status": "parcel",
|
||||||
|
"lat": 56.878913,
|
||||||
|
"lon": 60.522615,
|
||||||
|
"obj_id": null,
|
||||||
|
"geom_geojson": null,
|
||||||
|
"features": {
|
||||||
|
"bus_stop_count_1km": 20.0,
|
||||||
|
"bus_stop_count_500m": 4.0,
|
||||||
|
"bus_stop_nearest_m": 319.8521044035619,
|
||||||
|
"clinic_count_1km": 6.0,
|
||||||
|
"clinic_count_500m": 1.0,
|
||||||
|
"clinic_nearest_m": 449.74705727658056,
|
||||||
|
"hospital_count_1km": 0.0,
|
||||||
|
"hospital_count_500m": 0.0,
|
||||||
|
"hospital_nearest_m": 1760.9567033538603,
|
||||||
|
"kindergarten_count_1km": 4.0,
|
||||||
|
"kindergarten_count_500m": 1.0,
|
||||||
|
"kindergarten_nearest_m": 379.95653221253724,
|
||||||
|
"metro_count_1km": 0.0,
|
||||||
|
"metro_count_500m": 0.0,
|
||||||
|
"metro_nearest_m": null,
|
||||||
|
"park_count_1km": 3.0,
|
||||||
|
"park_count_500m": 0.0,
|
||||||
|
"park_nearest_m": 565.4776423972781,
|
||||||
|
"pharmacy_count_1km": 8.0,
|
||||||
|
"pharmacy_count_500m": 1.0,
|
||||||
|
"pharmacy_nearest_m": 471.4293930876333,
|
||||||
|
"playground_count_1km": 22.0,
|
||||||
|
"playground_count_500m": 12.0,
|
||||||
|
"playground_nearest_m": 162.85791533155333,
|
||||||
|
"school_count_1km": 3.0,
|
||||||
|
"school_count_500m": 0.0,
|
||||||
|
"school_nearest_m": 792.4938391173765,
|
||||||
|
"shop_big_count_1km": 10.0,
|
||||||
|
"shop_big_count_500m": 3.0,
|
||||||
|
"shop_big_nearest_m": 20.462910702745955,
|
||||||
|
"shop_med_count_1km": 12.0,
|
||||||
|
"shop_med_count_500m": 1.0,
|
||||||
|
"shop_med_nearest_m": 449.68619544132474,
|
||||||
|
"shop_small_count_1km": 0.0,
|
||||||
|
"shop_small_count_500m": 0.0,
|
||||||
|
"shop_small_nearest_m": null,
|
||||||
|
"sports_count_1km": 16.0,
|
||||||
|
"sports_count_500m": 7.0,
|
||||||
|
"sports_nearest_m": 243.3529907625155,
|
||||||
|
"tram_stop_count_1km": 8.0,
|
||||||
|
"tram_stop_count_500m": 0.0,
|
||||||
|
"tram_stop_nearest_m": 598.2477400253116,
|
||||||
|
"university_count_1km": 1.0,
|
||||||
|
"university_count_500m": 0.0,
|
||||||
|
"university_nearest_m": 906.362284697722
|
||||||
|
},
|
||||||
|
"scores": {
|
||||||
|
"economic": 40.20396169545286,
|
||||||
|
"education": 79.52016528424876,
|
||||||
|
"health": 88.71285351868461,
|
||||||
|
"leisure": 98.38326808895609,
|
||||||
|
"retail": 100.0,
|
||||||
|
"transit": 69.68085645258955
|
||||||
|
},
|
||||||
|
"weighted_total": 72.12696219413812,
|
||||||
|
"rank_overall": 183,
|
||||||
|
"rank_district": 1,
|
||||||
|
"economics": {
|
||||||
|
"district": "Старая Сортировка",
|
||||||
|
"district_method": "knn_vote_5",
|
||||||
|
"district_dist_m": 491.9035185643666,
|
||||||
|
"n_projects": 6,
|
||||||
|
"weighted_price_m2_corp_sum": 128.29226026752744,
|
||||||
|
"median_price_m2_corp_sum": 132.71,
|
||||||
|
"deals_per_month_corp_sum": 0.4748201438848921,
|
||||||
|
"months_to_sellout": 15.931335954809471,
|
||||||
|
"n_lots": 8356,
|
||||||
|
"n_sold": 4078,
|
||||||
|
"sold_pct": 48.803255146002876,
|
||||||
|
"median_price_m2": 114.88988332384746,
|
||||||
|
"p25_price_m2": 96.10536852426868,
|
||||||
|
"p75_price_m2": 132.70871691061976,
|
||||||
|
"avg_area_sold_m2": 48.96138303089735,
|
||||||
|
"velocity_per_month": 2.663333333333333,
|
||||||
|
"avg_readiness_pct": 75.83018190521781
|
||||||
|
},
|
||||||
|
"nearest_pois": {
|
||||||
|
"kindergarten": [
|
||||||
|
{
|
||||||
|
"name": "Детский сад № 357",
|
||||||
|
"distance_m": 380.0,
|
||||||
|
"lat": 56.8755087,
|
||||||
|
"lon": 60.5220757
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Детский сад № 132",
|
||||||
|
"distance_m": 579.1,
|
||||||
|
"lat": 56.8838508,
|
||||||
|
"lon": 60.5195843
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Детский сад № 133",
|
||||||
|
"distance_m": 846.8,
|
||||||
|
"lat": 56.8805097,
|
||||||
|
"lon": 60.508987
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Детский сад № 131",
|
||||||
|
"distance_m": 867.4,
|
||||||
|
"lat": 56.8746007,
|
||||||
|
"lon": 60.5107185
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Детский сад № 131 АО \"РЖД\"",
|
||||||
|
"distance_m": 1116.5,
|
||||||
|
"lat": 56.8709899,
|
||||||
|
"lon": 60.5339025
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"school": [
|
||||||
|
{
|
||||||
|
"name": "Школа № 122",
|
||||||
|
"distance_m": 792.5,
|
||||||
|
"lat": 56.8851571,
|
||||||
|
"lon": 60.5163261
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Средняя школа № 172",
|
||||||
|
"distance_m": 857.4,
|
||||||
|
"lat": 56.8721029,
|
||||||
|
"lon": 60.5292316
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Средняя общеобразовательная школа № 129",
|
||||||
|
"distance_m": 871.0,
|
||||||
|
"lat": 56.8799875,
|
||||||
|
"lon": 60.508415
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Детская музыкальная школа № 7 им. С. В. Рахманинова",
|
||||||
|
"distance_m": 1038.8,
|
||||||
|
"lat": 56.8697152,
|
||||||
|
"lon": 60.5256114
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Средняя школа № 83",
|
||||||
|
"distance_m": 1147.0,
|
||||||
|
"lat": 56.8701653,
|
||||||
|
"lon": 60.5326188
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"university": [
|
||||||
|
{
|
||||||
|
"name": "Железнодорожный техникум",
|
||||||
|
"distance_m": 906.4,
|
||||||
|
"lat": 56.8848017,
|
||||||
|
"lon": 60.5122997
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Екатеринбургский техникум «Автоматика»",
|
||||||
|
"distance_m": 1445.6,
|
||||||
|
"lat": 56.8720305,
|
||||||
|
"lon": 60.5427979
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"pharmacy": [
|
||||||
|
{
|
||||||
|
"name": "Вита Экспресс",
|
||||||
|
"distance_m": 471.4,
|
||||||
|
"lat": 56.8812191,
|
||||||
|
"lon": 60.5161039
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Фармленд",
|
||||||
|
"distance_m": 656.4,
|
||||||
|
"lat": 56.8808007,
|
||||||
|
"lon": 60.5123788
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Фармленд",
|
||||||
|
"distance_m": 733.6,
|
||||||
|
"lat": 56.8724683,
|
||||||
|
"lon": 60.5251955
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Аптека апрель",
|
||||||
|
"distance_m": 796.5,
|
||||||
|
"lat": 56.871807,
|
||||||
|
"lon": 60.5242641
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Планета здоровья",
|
||||||
|
"distance_m": 798.6,
|
||||||
|
"lat": 56.8717854,
|
||||||
|
"lon": 60.520997
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"clinic": [
|
||||||
|
{
|
||||||
|
"name": "Инвитро",
|
||||||
|
"distance_m": 449.7,
|
||||||
|
"lat": 56.8811052,
|
||||||
|
"lon": 60.5163941
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Подстанция № 7",
|
||||||
|
"distance_m": 772.2,
|
||||||
|
"lat": 56.8731081,
|
||||||
|
"lon": 60.5295907
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ЦГБ №3, Поликлиника №2",
|
||||||
|
"distance_m": 830.1,
|
||||||
|
"lat": 56.871559,
|
||||||
|
"lon": 60.5249675
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Наркологическая клиника Спасение",
|
||||||
|
"distance_m": 852.3,
|
||||||
|
"lat": 56.8724152,
|
||||||
|
"lon": 60.5151737
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Лаборатория Helix",
|
||||||
|
"distance_m": 865.6,
|
||||||
|
"lat": 56.8711449,
|
||||||
|
"lon": 60.5216856
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"hospital": [
|
||||||
|
{
|
||||||
|
"name": "Дорожная больница станции Екатеринбург-Пассажирский",
|
||||||
|
"distance_m": 1761.0,
|
||||||
|
"lat": 56.8894988,
|
||||||
|
"lon": 60.5010552
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Свердловский областной противотуберкулезный диспансер",
|
||||||
|
"distance_m": 1832.1,
|
||||||
|
"lat": 56.8884867,
|
||||||
|
"lon": 60.4980714
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"shop_big": [
|
||||||
|
{
|
||||||
|
"name": "Магнит",
|
||||||
|
"distance_m": 20.5,
|
||||||
|
"lat": 56.8788911,
|
||||||
|
"lon": 60.5229494
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Пятёрочка",
|
||||||
|
"distance_m": 242.2,
|
||||||
|
"lat": 56.8768559,
|
||||||
|
"lon": 60.5213067
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "—",
|
||||||
|
"distance_m": 482.9,
|
||||||
|
"lat": 56.881596,
|
||||||
|
"lon": 60.5163645
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Пятёрочка",
|
||||||
|
"distance_m": 698.2,
|
||||||
|
"lat": 56.8728594,
|
||||||
|
"lon": 60.525668
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "АвтоХимия",
|
||||||
|
"distance_m": 724.9,
|
||||||
|
"lat": 56.8725274,
|
||||||
|
"lon": 60.5202109
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"shop_med": [
|
||||||
|
{
|
||||||
|
"name": "Кировский",
|
||||||
|
"distance_m": 449.7,
|
||||||
|
"lat": 56.8814023,
|
||||||
|
"lon": 60.5167818
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Магнит",
|
||||||
|
"distance_m": 515.3,
|
||||||
|
"lat": 56.8834352,
|
||||||
|
"lon": 60.5207634
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Монетка",
|
||||||
|
"distance_m": 565.3,
|
||||||
|
"lat": 56.8738314,
|
||||||
|
"lon": 60.5228848
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Молочный",
|
||||||
|
"distance_m": 623.8,
|
||||||
|
"lat": 56.8819988,
|
||||||
|
"lon": 60.5140412
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Пенный Лис",
|
||||||
|
"distance_m": 704.3,
|
||||||
|
"lat": 56.8726139,
|
||||||
|
"lon": 60.5214089
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"shop_small": [],
|
||||||
|
"bus_stop": [
|
||||||
|
{
|
||||||
|
"name": "Билимбаевская",
|
||||||
|
"distance_m": 319.9,
|
||||||
|
"lat": 56.8807605,
|
||||||
|
"lon": 60.5185799
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Билимбаевская",
|
||||||
|
"distance_m": 384.0,
|
||||||
|
"lat": 56.8807151,
|
||||||
|
"lon": 60.5172239
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Билимбаевская",
|
||||||
|
"distance_m": 426.4,
|
||||||
|
"lat": 56.8816219,
|
||||||
|
"lon": 60.5176479
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Расточная",
|
||||||
|
"distance_m": 430.6,
|
||||||
|
"lat": 56.8823567,
|
||||||
|
"lon": 60.5193744
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Расточная",
|
||||||
|
"distance_m": 503.8,
|
||||||
|
"lat": 56.8833908,
|
||||||
|
"lon": 60.5213477
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tram_stop": [
|
||||||
|
{
|
||||||
|
"name": "Расточная улица",
|
||||||
|
"distance_m": 598.2,
|
||||||
|
"lat": 56.877465,
|
||||||
|
"lon": 60.5131321
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Маневровая улица",
|
||||||
|
"distance_m": 617.7,
|
||||||
|
"lat": 56.8740237,
|
||||||
|
"lon": 60.5177905
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Расточная улица",
|
||||||
|
"distance_m": 620.2,
|
||||||
|
"lat": 56.8776237,
|
||||||
|
"lon": 60.5126846
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Маневровая улица",
|
||||||
|
"distance_m": 627.4,
|
||||||
|
"lat": 56.8737876,
|
||||||
|
"lon": 60.5182978
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Кишинёвская улица",
|
||||||
|
"distance_m": 793.2,
|
||||||
|
"lat": 56.8789338,
|
||||||
|
"lon": 60.5095593
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"metro": [],
|
||||||
|
"park": [
|
||||||
|
{
|
||||||
|
"name": "—",
|
||||||
|
"distance_m": 565.5,
|
||||||
|
"lat": 56.8822507,
|
||||||
|
"lon": 60.5155927
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "—",
|
||||||
|
"distance_m": 867.1,
|
||||||
|
"lat": 56.8711885,
|
||||||
|
"lon": 60.5206587
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "—",
|
||||||
|
"distance_m": 885.7,
|
||||||
|
"lat": 56.8713878,
|
||||||
|
"lon": 60.5273909
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Парк «Семь ключей»",
|
||||||
|
"distance_m": 1487.0,
|
||||||
|
"lat": 56.881542,
|
||||||
|
"lon": 60.4986181
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"playground": [
|
||||||
|
{
|
||||||
|
"name": "—",
|
||||||
|
"distance_m": 162.9,
|
||||||
|
"lat": 56.8788457,
|
||||||
|
"lon": 60.5199374
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "—",
|
||||||
|
"distance_m": 170.0,
|
||||||
|
"lat": 56.8775972,
|
||||||
|
"lon": 60.5240409
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "—",
|
||||||
|
"distance_m": 173.7,
|
||||||
|
"lat": 56.8796266,
|
||||||
|
"lon": 60.5200727
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "—",
|
||||||
|
"distance_m": 216.8,
|
||||||
|
"lat": 56.8771629,
|
||||||
|
"lon": 60.5241882
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "—",
|
||||||
|
"distance_m": 220.4,
|
||||||
|
"lat": 56.8808941,
|
||||||
|
"lon": 60.5227257
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sports": [
|
||||||
|
{
|
||||||
|
"name": "—",
|
||||||
|
"distance_m": 243.4,
|
||||||
|
"lat": 56.8768551,
|
||||||
|
"lon": 60.523978
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "—",
|
||||||
|
"distance_m": 302.1,
|
||||||
|
"lat": 56.8792764,
|
||||||
|
"lon": 60.5176882
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "—",
|
||||||
|
"distance_m": 313.3,
|
||||||
|
"lat": 56.8780706,
|
||||||
|
"lon": 60.5176941
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "—",
|
||||||
|
"distance_m": 339.3,
|
||||||
|
"lat": 56.8793803,
|
||||||
|
"lon": 60.5170968
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "—",
|
||||||
|
"distance_m": 389.8,
|
||||||
|
"lat": 56.8791157,
|
||||||
|
"lon": 60.5162107
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"n_compared_jk": 380,
|
||||||
|
"weighted_score_distribution": {
|
||||||
|
"n_jk": 380,
|
||||||
|
"mean": 63.9,
|
||||||
|
"median": 70.9,
|
||||||
|
"p25": 41.6,
|
||||||
|
"p75": 81.7,
|
||||||
|
"min": 15.9,
|
||||||
|
"max": 91.2
|
||||||
|
},
|
||||||
|
"component_comparison": {
|
||||||
|
"economic": {
|
||||||
|
"parcel": 40.2,
|
||||||
|
"median_jk": 47.2,
|
||||||
|
"p75_jk": 59.5
|
||||||
|
},
|
||||||
|
"education": {
|
||||||
|
"parcel": 79.5,
|
||||||
|
"median_jk": 75.9,
|
||||||
|
"p75_jk": 94.7
|
||||||
|
},
|
||||||
|
"health": {
|
||||||
|
"parcel": 88.7,
|
||||||
|
"median_jk": 83.2,
|
||||||
|
"p75_jk": 98.3
|
||||||
|
},
|
||||||
|
"leisure": {
|
||||||
|
"parcel": 98.4,
|
||||||
|
"median_jk": 86.1,
|
||||||
|
"p75_jk": 96.9
|
||||||
|
},
|
||||||
|
"retail": {
|
||||||
|
"parcel": 100.0,
|
||||||
|
"median_jk": 100.0,
|
||||||
|
"p75_jk": 100.0
|
||||||
|
},
|
||||||
|
"transit": {
|
||||||
|
"parcel": 69.7,
|
||||||
|
"median_jk": 70.0,
|
||||||
|
"p75_jk": 85.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"weights_used": {
|
||||||
|
"education": 0.3,
|
||||||
|
"health": 0.15,
|
||||||
|
"retail": 0.2,
|
||||||
|
"transit": 0.2,
|
||||||
|
"leisure": 0.15
|
||||||
|
},
|
||||||
|
"top10_best_jk_ekb": [
|
||||||
|
{
|
||||||
|
"site_id": "jk:70303",
|
||||||
|
"name": null,
|
||||||
|
"district": "Железнодорожный",
|
||||||
|
"developer": "ТЭН",
|
||||||
|
"obj_class": null,
|
||||||
|
"flat_count": 0,
|
||||||
|
"lat": 56.8514,
|
||||||
|
"lon": 60.6117,
|
||||||
|
"weighted": 91.20179727537686,
|
||||||
|
"rank": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"site_id": "jk:66704",
|
||||||
|
"name": "Квартал «Лайв»",
|
||||||
|
"district": "Ленинский",
|
||||||
|
"developer": "Атомстройкомплекс",
|
||||||
|
"obj_class": null,
|
||||||
|
"flat_count": 569,
|
||||||
|
"lat": 56.8036,
|
||||||
|
"lon": 60.5965,
|
||||||
|
"weighted": 91.04574249916632,
|
||||||
|
"rank": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"site_id": "jk:64409",
|
||||||
|
"name": "Клубный дом инженера Ятеса",
|
||||||
|
"district": "Железнодорожный",
|
||||||
|
"developer": "ТЭН",
|
||||||
|
"obj_class": null,
|
||||||
|
"flat_count": 0,
|
||||||
|
"lat": 56.851,
|
||||||
|
"lon": 60.6108,
|
||||||
|
"weighted": 90.96238151414164,
|
||||||
|
"rank": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"site_id": "jk:50617",
|
||||||
|
"name": "Клубный дом инженера Ятеса",
|
||||||
|
"district": "Железнодорожный",
|
||||||
|
"developer": "ТЭН",
|
||||||
|
"obj_class": null,
|
||||||
|
"flat_count": 80,
|
||||||
|
"lat": 56.8506,
|
||||||
|
"lon": 60.6108,
|
||||||
|
"weighted": 90.6835128392893,
|
||||||
|
"rank": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"site_id": "jk:60057",
|
||||||
|
"name": "Квартал \"На Некрасова\"",
|
||||||
|
"district": "Железнодорожный",
|
||||||
|
"developer": "Брусника",
|
||||||
|
"obj_class": null,
|
||||||
|
"flat_count": 193,
|
||||||
|
"lat": 56.8526,
|
||||||
|
"lon": 60.5886,
|
||||||
|
"weighted": 90.6443625125751,
|
||||||
|
"rank": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"site_id": "jk:67389",
|
||||||
|
"name": "ЖК \"Азина 16\"",
|
||||||
|
"district": "Железнодорожный",
|
||||||
|
"developer": "ЛСР",
|
||||||
|
"obj_class": null,
|
||||||
|
"flat_count": 326,
|
||||||
|
"lat": 56.8555,
|
||||||
|
"lon": 60.613,
|
||||||
|
"weighted": 90.36056856326462,
|
||||||
|
"rank": 6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"site_id": "jk:69469",
|
||||||
|
"name": "Жилой дом \"ТЕТРО\"",
|
||||||
|
"district": "Железнодорожный",
|
||||||
|
"developer": "Девелоперская компания \"Люди\"",
|
||||||
|
"obj_class": null,
|
||||||
|
"flat_count": 358,
|
||||||
|
"lat": 56.8549,
|
||||||
|
"lon": 60.5824,
|
||||||
|
"weighted": 90.19036465994816,
|
||||||
|
"rank": 7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"site_id": "jk:67548",
|
||||||
|
"name": "Жилой квартал «Тихий центр» 1 оч. 1 этап",
|
||||||
|
"district": "Железнодорожный",
|
||||||
|
"developer": "Синара-Девелопмент",
|
||||||
|
"obj_class": null,
|
||||||
|
"flat_count": 231,
|
||||||
|
"lat": 56.8539,
|
||||||
|
"lon": 60.595,
|
||||||
|
"weighted": 90.08874176451533,
|
||||||
|
"rank": 8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"site_id": "jk:69263",
|
||||||
|
"name": "ЖК \"Кайдзен\"",
|
||||||
|
"district": "Верх-Исетский",
|
||||||
|
"developer": "Астра",
|
||||||
|
"obj_class": null,
|
||||||
|
"flat_count": 196,
|
||||||
|
"lat": 56.8214,
|
||||||
|
"lon": 60.5721,
|
||||||
|
"weighted": 89.98009185666463,
|
||||||
|
"rank": 9
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"site_id": "jk:53537",
|
||||||
|
"name": "ЖК \"Азина 16\"",
|
||||||
|
"district": "Железнодорожный",
|
||||||
|
"developer": "ЛСР",
|
||||||
|
"obj_class": null,
|
||||||
|
"flat_count": 871,
|
||||||
|
"lat": 56.8567,
|
||||||
|
"lon": 60.6132,
|
||||||
|
"weighted": 89.66063320010375,
|
||||||
|
"rank": 10
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"10_closest_jk_to_parcel": [
|
||||||
|
{
|
||||||
|
"distance_m": 492.0,
|
||||||
|
"site_id": "jk:68027",
|
||||||
|
"name": "Квартал \"Траектория\"",
|
||||||
|
"district": "Железнодорожный",
|
||||||
|
"developer": "Брусника",
|
||||||
|
"obj_class": null,
|
||||||
|
"weighted": 66.6,
|
||||||
|
"rank": 223
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"distance_m": 994.0,
|
||||||
|
"site_id": "jk:63282",
|
||||||
|
"name": "Квартал Депо",
|
||||||
|
"district": "Железнодорожный",
|
||||||
|
"developer": "Брусника",
|
||||||
|
"obj_class": null,
|
||||||
|
"weighted": 69.5,
|
||||||
|
"rank": 206
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"distance_m": 994.0,
|
||||||
|
"site_id": "jk:63122",
|
||||||
|
"name": "Квартал Депо",
|
||||||
|
"district": "Железнодорожный",
|
||||||
|
"developer": "Брусника",
|
||||||
|
"obj_class": null,
|
||||||
|
"weighted": 73.3,
|
||||||
|
"rank": 176
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"distance_m": 1036.0,
|
||||||
|
"site_id": "jk:59643",
|
||||||
|
"name": "Астон. Движение",
|
||||||
|
"district": "Железнодорожный",
|
||||||
|
"developer": "Астон",
|
||||||
|
"obj_class": null,
|
||||||
|
"weighted": 64.3,
|
||||||
|
"rank": 240
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"distance_m": 1211.0,
|
||||||
|
"site_id": "jk:61215",
|
||||||
|
"name": "Жилой Комплекс \"Раута\" ",
|
||||||
|
"district": "Железнодорожный",
|
||||||
|
"developer": "Эталон",
|
||||||
|
"obj_class": null,
|
||||||
|
"weighted": 72.0,
|
||||||
|
"rank": 186
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"distance_m": 1312.0,
|
||||||
|
"site_id": "jk:65011",
|
||||||
|
"name": "Жилой Комплекс \"Раута\" ",
|
||||||
|
"district": "Железнодорожный",
|
||||||
|
"developer": "Эталон",
|
||||||
|
"obj_class": null,
|
||||||
|
"weighted": 74.4,
|
||||||
|
"rank": 168
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"distance_m": 3065.0,
|
||||||
|
"site_id": "jk:66867",
|
||||||
|
"name": "ЖК Белая Башня",
|
||||||
|
"district": "Орджоникидзевский",
|
||||||
|
"developer": "Ривьера Инвест Екатеринбург",
|
||||||
|
"obj_class": null,
|
||||||
|
"weighted": 81.2,
|
||||||
|
"rank": 106
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"distance_m": 3596.0,
|
||||||
|
"site_id": "jk:65542",
|
||||||
|
"name": "ЖК \"ПАЙЕР\"",
|
||||||
|
"district": "Железнодорожный",
|
||||||
|
"developer": "Самолет",
|
||||||
|
"obj_class": null,
|
||||||
|
"weighted": 58.8,
|
||||||
|
"rank": 267
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"distance_m": 3612.0,
|
||||||
|
"site_id": "jk:63123",
|
||||||
|
"name": "ЖК ТЕМП",
|
||||||
|
"district": "Орджоникидзевский",
|
||||||
|
"developer": "Практика",
|
||||||
|
"obj_class": null,
|
||||||
|
"weighted": 81.4,
|
||||||
|
"rank": 104
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"distance_m": 4021.0,
|
||||||
|
"site_id": "jk:60160",
|
||||||
|
"name": "ЖК \"Парк Победы\"",
|
||||||
|
"district": "Орджоникидзевский",
|
||||||
|
"developer": "PRINZIP",
|
||||||
|
"obj_class": null,
|
||||||
|
"weighted": 80.3,
|
||||||
|
"rank": 118
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
1801
site-finder/server.py
Normal file
1801
site-finder/server.py
Normal file
File diff suppressed because it is too large
Load diff
3513
site-finder/static/index.html
Normal file
3513
site-finder/static/index.html
Normal file
File diff suppressed because it is too large
Load diff
67
site-finder/static/sw.js
Normal file
67
site-finder/static/sw.js
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
// Service Worker for persistent tile + asset caching.
|
||||||
|
// Strategy: cache-first for tiles (fast repeat loads, offline-capable),
|
||||||
|
// network-first for HTML/JS (fresh code on each reload).
|
||||||
|
const VERSION = 'v18';
|
||||||
|
const TILE_CACHE = 'gendesign-tiles-' + VERSION;
|
||||||
|
const ASSET_CACHE = 'gendesign-assets-' + VERSION;
|
||||||
|
const TILE_HOSTS = [
|
||||||
|
'basemaps.cartocdn.com',
|
||||||
|
'tile.openstreetmap.org',
|
||||||
|
'tile.openstreetmap.fr',
|
||||||
|
'maps.2gis.com',
|
||||||
|
'server.arcgisonline.com',
|
||||||
|
'core-renderer-tiles.maps.yandex.net',
|
||||||
|
'core-sat.maps.yandex.net',
|
||||||
|
'unpkg.com',
|
||||||
|
'cdn.jsdelivr.net',
|
||||||
|
];
|
||||||
|
|
||||||
|
self.addEventListener('install', e => {
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', e => {
|
||||||
|
e.waitUntil((async () => {
|
||||||
|
const keys = await caches.keys();
|
||||||
|
await Promise.all(keys.filter(k => !k.endsWith(VERSION)).map(k => caches.delete(k)));
|
||||||
|
await self.clients.claim();
|
||||||
|
})());
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('fetch', e => {
|
||||||
|
const req = e.request;
|
||||||
|
if (req.method !== 'GET') return;
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const isTile = TILE_HOSTS.some(h => url.hostname.endsWith(h));
|
||||||
|
if (!isTile) return; // let same-origin (FastAPI) requests pass through normally
|
||||||
|
|
||||||
|
e.respondWith((async () => {
|
||||||
|
const cacheName = url.hostname.includes('basemaps') || url.hostname.endsWith('cdn.jsdelivr.net') || url.hostname === 'unpkg.com'
|
||||||
|
? ASSET_CACHE : TILE_CACHE;
|
||||||
|
const cache = await caches.open(cacheName);
|
||||||
|
const cached = await cache.match(req);
|
||||||
|
if (cached) {
|
||||||
|
// refresh in background (stale-while-revalidate)
|
||||||
|
fetch(req).then(r => { if (r.ok) cache.put(req, r.clone()); }).catch(()=>{});
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const resp = await fetch(req);
|
||||||
|
if (resp.ok && resp.status === 200) {
|
||||||
|
// size check — don't cache huge >1 MB blobs
|
||||||
|
const len = +resp.headers.get('content-length') || 0;
|
||||||
|
if (len < 2_000_000) cache.put(req, resp.clone());
|
||||||
|
}
|
||||||
|
return resp;
|
||||||
|
} catch (err) {
|
||||||
|
// offline: try fallback (e.g. blank tile) — for now just rethrow
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
})());
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('message', e => {
|
||||||
|
if (e.data === 'clear-cache') {
|
||||||
|
caches.keys().then(ks => Promise.all(ks.map(k => caches.delete(k))));
|
||||||
|
}
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue