- 56_fetch_ekb_districts.py: Overpass query (admin_level=9 в bbox ЕКБ), shapely-ассемблинг рваных OSM-веев в MultiPolygon, кэш в data/raw/. - 56_schema_ekb_districts_geom.sql: CREATE TABLE с GIST. - 57_kn_objects_district.sql: ALTER + backfill UPDATE через ST_Contains. 3032 ЖК Свердл проставили district_name (208 в Академическом, 158 в Чкаловском, и т.д.), 454 за bbox ЕКБ остались NULL. - domrf_kn.py: _backfill_district_names() после Phase A — каждый sweep автоматически проставляет district_name свежезаписанным ЖК. - analytics_queries.py: 4 места addr ILIKE → district_name = :dn (_velocity_baseline, _elasticity_coef, _active_competitors_count, comparable). - analytics_queries.py: graceful obj_class-degrade — если в районе все obj_class=NULL (kn-API quirk), фильтр по target_class опускается на уровне geo-запросов с warning. class_multiplier на ценах продолжает работать. Smoke (Академический, Comfort, 50K м²): velocity_source=sale_graph (104 ЖК / 1872 точек), competitors=208 в районе, headline «3400 млн ₽ · 112 мес · ликвидность 21/100», 5 comparable ЖК района — все реалистичные.
242 lines
8.5 KiB
Python
242 lines
8.5 KiB
Python
"""Fetch ЕКБ district polygons from OSM Overpass and load into PostGIS.
|
||
|
||
Usage:
|
||
cd backend && uv run python ../data/sql/56_fetch_ekb_districts.py [--force-refetch]
|
||
|
||
Without --force-refetch использует кэш `data/raw/ekb_districts.geojson` если он
|
||
существует. Иначе дёргает Overpass API один раз и сохраняет.
|
||
|
||
После Overpass fetch:
|
||
1. Переводит OSM relation members в GeoJSON MultiPolygon (через shapely).
|
||
2. UPSERT в `ekb_districts_geom (district_name, geom, osm_id, fetched_at)`.
|
||
|
||
OSM-to-our naming:
|
||
"Верх-Исетский район" → "Верх-Исетский"
|
||
"Академический район" → "Академический"
|
||
...
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import logging
|
||
import sys
|
||
import urllib.parse
|
||
import urllib.request
|
||
from pathlib import Path
|
||
|
||
# Add backend to path so we can import app.* in standalone CLI mode.
|
||
ROOT = Path(__file__).resolve().parent.parent.parent
|
||
sys.path.insert(0, str(ROOT / "backend"))
|
||
|
||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||
log = logging.getLogger(__name__)
|
||
|
||
# Cache lives at repo root, committed to git.
|
||
GEOJSON_CACHE = ROOT / "data" / "raw" / "ekb_districts.geojson"
|
||
|
||
# OSM admin_level=9 in RU = city districts. ЕКБ has 8 of them.
|
||
# Bbox covers all of Ekaterinburg city + adjacent.
|
||
OVERPASS_QUERY = """
|
||
[out:json][timeout:90];
|
||
(
|
||
relation['boundary'='administrative']['admin_level'='9']['name'~'район']
|
||
(56.71,60.42,57.00,60.95);
|
||
);
|
||
out geom;
|
||
"""
|
||
|
||
OVERPASS_URL = "https://overpass-api.de/api/interpreter"
|
||
|
||
# OSM display name → ekb_districts.district_name (exact match required).
|
||
DISTRICT_NAME_MAP = {
|
||
"Академический район": "Академический",
|
||
"Верх-Исетский район": "Верх-Исетский",
|
||
"Железнодорожный район": "Железнодорожный",
|
||
"Кировский район": "Кировский",
|
||
"Ленинский район": "Ленинский",
|
||
"Октябрьский район": "Октябрьский",
|
||
"Орджоникидзевский район": "Орджоникидзевский",
|
||
"Чкаловский район": "Чкаловский",
|
||
}
|
||
|
||
|
||
def fetch_overpass() -> dict:
|
||
"""Hit Overpass API; return raw JSON response."""
|
||
log.info("Querying Overpass...")
|
||
req = urllib.request.Request(
|
||
OVERPASS_URL,
|
||
data=urllib.parse.urlencode({"data": OVERPASS_QUERY}).encode(),
|
||
headers={"User-Agent": "gendesign-recommend/1.0 (district mapping)"},
|
||
)
|
||
with urllib.request.urlopen(req, timeout=120) as resp:
|
||
return json.loads(resp.read().decode())
|
||
|
||
|
||
def osm_relation_to_geojson(relation: dict) -> dict | None:
|
||
"""Convert one Overpass relation (way-members + tags) to a GeoJSON
|
||
MultiPolygon Feature. Outer + inner rings are assembled into polygons.
|
||
|
||
OSM relations of type=multipolygon have 'members' which are 'way'-objects
|
||
each carrying their own 'geometry' (list of {lat, lon}). We build outer
|
||
rings, inner rings (holes), and assemble Polygon parts → MultiPolygon.
|
||
"""
|
||
from shapely.geometry import MultiPolygon, Polygon, mapping
|
||
from shapely.ops import polygonize, unary_union
|
||
|
||
members = relation.get("members") or []
|
||
outer_lines: list[list[tuple[float, float]]] = []
|
||
inner_lines: list[list[tuple[float, float]]] = []
|
||
for m in members:
|
||
if m.get("type") != "way":
|
||
continue
|
||
coords = [(pt["lon"], pt["lat"]) for pt in (m.get("geometry") or [])]
|
||
if len(coords) < 2:
|
||
continue
|
||
role = m.get("role") or "outer"
|
||
(inner_lines if role == "inner" else outer_lines).append(coords)
|
||
|
||
if not outer_lines:
|
||
log.warning("Relation %s has no outer members", relation.get("id"))
|
||
return None
|
||
|
||
# Use shapely.polygonize to stitch open way-segments into closed rings.
|
||
# This handles real-world OSM cases where a single ring is split across
|
||
# multiple way-members at admin-boundary intersections.
|
||
from shapely.geometry import LineString
|
||
|
||
outer_polys = list(polygonize([LineString(seg) for seg in outer_lines]))
|
||
inner_polys = list(polygonize([LineString(seg) for seg in inner_lines]))
|
||
|
||
if not outer_polys:
|
||
log.warning(
|
||
"Relation %s outer members did not form closed rings", relation.get("id")
|
||
)
|
||
return None
|
||
|
||
# Subtract holes from outers (best-effort).
|
||
if inner_polys:
|
||
holes_union = unary_union(inner_polys)
|
||
outer_polys = [p.difference(holes_union) for p in outer_polys]
|
||
|
||
multipoly = MultiPolygon(
|
||
[p for poly in outer_polys for p in (poly.geoms if poly.geom_type == "MultiPolygon" else [poly])]
|
||
if any(p.geom_type == "MultiPolygon" for p in outer_polys)
|
||
else outer_polys
|
||
)
|
||
|
||
# Ensure all geometry is valid; buffer(0) is the standard fix for
|
||
# self-intersections that OSM data sometimes carries.
|
||
if not multipoly.is_valid:
|
||
multipoly = multipoly.buffer(0)
|
||
if multipoly.geom_type == "Polygon":
|
||
multipoly = MultiPolygon([multipoly])
|
||
|
||
return {
|
||
"type": "Feature",
|
||
"properties": {
|
||
"name": relation.get("tags", {}).get("name"),
|
||
"osm_id": relation.get("id"),
|
||
"admin_level": relation.get("tags", {}).get("admin_level"),
|
||
},
|
||
"geometry": mapping(multipoly),
|
||
}
|
||
|
||
|
||
def build_geojson(overpass_data: dict) -> dict:
|
||
"""Build a FeatureCollection from raw Overpass response."""
|
||
features: list[dict] = []
|
||
for el in overpass_data.get("elements", []):
|
||
if el.get("type") != "relation":
|
||
continue
|
||
feature = osm_relation_to_geojson(el)
|
||
if feature:
|
||
features.append(feature)
|
||
return {"type": "FeatureCollection", "features": features}
|
||
|
||
|
||
def load_or_fetch(force: bool = False) -> dict:
|
||
if GEOJSON_CACHE.exists() and not force:
|
||
log.info("Using cached GeoJSON: %s", GEOJSON_CACHE)
|
||
return json.loads(GEOJSON_CACHE.read_text(encoding="utf-8"))
|
||
raw = fetch_overpass()
|
||
geojson = build_geojson(raw)
|
||
GEOJSON_CACHE.parent.mkdir(parents=True, exist_ok=True)
|
||
GEOJSON_CACHE.write_text(
|
||
json.dumps(geojson, ensure_ascii=False, indent=2), encoding="utf-8"
|
||
)
|
||
log.info("Cached %d features → %s", len(geojson["features"]), GEOJSON_CACHE)
|
||
return geojson
|
||
|
||
|
||
def upsert_into_db(geojson: dict) -> int:
|
||
from sqlalchemy import text
|
||
|
||
from app.core.db import SessionLocal
|
||
|
||
db = SessionLocal()
|
||
try:
|
||
loaded = 0
|
||
for feat in geojson.get("features", []):
|
||
osm_name = feat["properties"].get("name")
|
||
our_name = DISTRICT_NAME_MAP.get(osm_name)
|
||
if not our_name:
|
||
log.warning("Skipping unmapped OSM district: %s", osm_name)
|
||
continue
|
||
geom_text = json.dumps(feat["geometry"])
|
||
db.execute(
|
||
text(
|
||
"""
|
||
INSERT INTO ekb_districts_geom (district_name, geom, osm_id, fetched_at)
|
||
VALUES (
|
||
:dn,
|
||
ST_Multi(ST_GeomFromGeoJSON(:gj))::geometry(MultiPolygon, 4326),
|
||
:osm,
|
||
NOW()
|
||
)
|
||
ON CONFLICT (district_name) DO UPDATE SET
|
||
geom = EXCLUDED.geom,
|
||
osm_id = EXCLUDED.osm_id,
|
||
fetched_at = NOW()
|
||
"""
|
||
),
|
||
{
|
||
"dn": our_name,
|
||
"gj": geom_text,
|
||
"osm": feat["properties"].get("osm_id"),
|
||
},
|
||
)
|
||
loaded += 1
|
||
log.info(" ✓ %s (osm_id=%s)", our_name, feat["properties"].get("osm_id"))
|
||
db.commit()
|
||
return loaded
|
||
finally:
|
||
db.close()
|
||
|
||
|
||
def main() -> int:
|
||
p = argparse.ArgumentParser(description="Fetch+load EKB district polygons")
|
||
p.add_argument(
|
||
"--force-refetch",
|
||
action="store_true",
|
||
help="Re-hit Overpass even if cache exists",
|
||
)
|
||
p.add_argument(
|
||
"--dry-run",
|
||
action="store_true",
|
||
help="Fetch+parse only, skip DB upsert",
|
||
)
|
||
args = p.parse_args()
|
||
|
||
geojson = load_or_fetch(force=args.force_refetch)
|
||
log.info("Have %d features in GeoJSON", len(geojson["features"]))
|
||
if args.dry_run:
|
||
return 0
|
||
n = upsert_into_db(geojson)
|
||
log.info("Loaded %d districts into ekb_districts_geom", n)
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|