"""Резолвер «точки подключения СО СВОБОДНОЙ МОЩНОСТЬЮ» для ПТИЦА §3 (#2119). Собирает по участку: - power_points: центры питания (power_supply_centers) в радиусе от центроида участка (ST_DWithin geography) с классом напряжения / индексом загрузки / резервом свободной мощности. - power_summary: ближайший ЦП с резервом + count по load_index. - water: все строки water_supply_reserves за ПОСЛЕДНИЙ period (city-level). Источник координат участка — общий ``_get_parcel_wkt`` (cad_parcels_geom → квартал fallback). Радиус по умолчанию 3000 м (cap 10000). """ import logging from sqlalchemy import text from sqlalchemy.orm import Session logger = logging.getLogger(__name__) _DEFAULT_RADIUS_M = 3000 _MAX_RADIUS_M = 10000 def get_connection_capacity( db: Session, cad_num: str, radius_m: int = _DEFAULT_RADIUS_M, ) -> dict: """Точки подключения со свободной мощностью вблизи участка. Args: db: SQLAlchemy session. cad_num: кадастровый номер участка. radius_m: радиус поиска ЦП, м (caller клампит 1..10000). Returns: {"power_points": [...], "power_summary": {...}, "water": [...]} Raises: ValueError: участок не найден в БД (нет geom). """ # Lazy import — избегаем циклического импорта на module-load. from app.services.site_finder.quarter_dump_lookup import _get_parcel_wkt radius_m = max(1, min(int(radius_m), _MAX_RADIUS_M)) parcel_wkt = _get_parcel_wkt(db, cad_num) if parcel_wkt is None: raise ValueError(f"Участок {cad_num!r} не найден в БД") power_points = _query_power_points(db, parcel_wkt, radius_m) power_summary = _build_power_summary(power_points) water = _query_water_latest(db) return { "power_points": power_points, "power_summary": power_summary, "water": water, } def _query_power_points(db: Session, parcel_wkt: str, radius_m: int) -> list[dict]: """Центры питания в радиусе от центроида участка (ST_DWithin geography).""" rows = ( db.execute( text(""" SELECT sc_name, dzo_name, voltage_class, load_index, installed_capacity_mva, current_load_mva, reserve_mva, reserve_asof, ST_Distance( geom::geography, ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography ) AS distance_m, ST_Y(geom) AS lat, ST_X(geom) AS lon FROM power_supply_centers WHERE geom IS NOT NULL AND ST_DWithin( geom::geography, ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography, CAST(:radius_m AS float) ) ORDER BY distance_m ASC """), {"wkt": parcel_wkt, "radius_m": radius_m}, ) .mappings() .all() ) points: list[dict] = [] for r in rows: points.append( { "name": r["sc_name"], "dzo_name": r["dzo_name"], "voltage_class": r["voltage_class"], "load_index": r["load_index"], "installed_capacity_mva": _num(r["installed_capacity_mva"]), "current_load_mva": _num(r["current_load_mva"]), "reserve_mva": _num(r["reserve_mva"]), "reserve_asof": r["reserve_asof"].isoformat() if r["reserve_asof"] else None, "distance_m": round(float(r["distance_m"]), 1), "lat": round(float(r["lat"]), 6) if r["lat"] is not None else None, "lon": round(float(r["lon"]), 6) if r["lon"] is not None else None, } ) return points def _build_power_summary(points: list[dict]) -> dict: """Сводка: ближайший ЦП с положительным резервом + count по load_index.""" by_load_index: dict[str, int] = {} for p in points: li = p["load_index"] or "unknown" by_load_index[li] = by_load_index.get(li, 0) + 1 # points уже отсортированы по distance ASC → первый с reserve_mva>0 = ближайший. nearest_with_reserve = next( (p for p in points if p["reserve_mva"] is not None and p["reserve_mva"] > 0), None, ) return { "total_power_points": len(points), "by_load_index": by_load_index, "nearest_with_reserve": nearest_with_reserve, } def _query_water_latest(db: Session) -> list[dict]: """Все строки water_supply_reserves за ПОСЛЕДНИЙ period ПО КАЖДОМУ system_kind. «Последний» — max(period) лексикографически ('2026-Q1' < '2026-Q2' < …), что для формата 'YYYY-Qn' совпадает с хронологией. period IS NULL исключаем. MAX(period) берём per-system_kind (коррелированный подзапрос): Водоканал может опубликовать водоотведение на квартал позже водоснабжения — глобальный MAX молча выкинул бы отстающий вид целиком. """ rows = ( db.execute( text(""" SELECT w.system_kind, w.system_name, w.reserve_thousand_m3_day, w.note, w.period FROM water_supply_reserves w WHERE w.period IS NOT NULL AND w.period = ( SELECT MAX(w2.period) FROM water_supply_reserves w2 WHERE w2.period IS NOT NULL AND w2.system_kind = w.system_kind ) ORDER BY w.system_kind, w.system_name """) ) .mappings() .all() ) return [ { "system_kind": r["system_kind"], "system_name": r["system_name"], "reserve_thousand_m3_day": _num(r["reserve_thousand_m3_day"]), "note": r["note"], "period": r["period"], } for r in rows ] def _num(value: object) -> float | None: """NUMERIC (Decimal) из БД → float | None.""" return float(value) if value is not None else None # type: ignore[arg-type]