gendesign/data/sql/56_fetch_ekb_districts.py
lekss361 17ad0e18f5 feat(geo): ЖК → district mapping через PostGIS-полигоны OSM (Limit_District_Geometry closed)
- 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 ЖК района — все реалистичные.
2026-04-29 08:03:38 +03:00

242 lines
8.5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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())