"""Резолвер «точки подключения СО СВОБОДНОЙ МОЩНОСТЬЮ» для ПТИЦА §3 (#2119). Собирает по участку: - power_points: центры питания (power_supply_centers) в радиусе от центроида участка (ST_DWithin geography) с классом напряжения / индексом загрузки / резервом свободной мощности. Для ПС ЕЭСК без раскрытого резерва — reserve_note «резерв по запросу в АО «ЕЭСК» (город)». - power_summary: ближайший ЦП с резервом + count по load_index. - water: все строки water_supply_reserves за ПОСЛЕДНИЙ period (city-level). - gas: городские ГРС «Газпром трансгаз Екатеринбург» со свободной мощностью (gas_grs_capacity), агрегация по grs_name + суммарный свободный объём. #2119 B1. Источник координат участка — общий ``_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 # Пометка для ПС АО «ЕЭСК» (городская сеть ЕКБ) без раскрытого резерва: свободная # мощность у ЕЭСК не публикуется в WFS/xlsx — уточняется по запросу. #2119 B1. _EESK_RESERVE_NOTE = "Резерв — по запросу в АО «ЕЭСК» (город)" 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) gas = _query_gas_city_grs(db) return { "power_points": power_points, "power_summary": power_summary, "water": water, "gas": gas, } 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: reserve_mva = _num(r["reserve_mva"]) 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": reserve_mva, "reserve_asof": r["reserve_asof"].isoformat() if r["reserve_asof"] else None, "reserve_note": _reserve_note(r["dzo_name"], reserve_mva), "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 _reserve_note(dzo_name: str | None, reserve_mva: float | None) -> str | None: """Пометка резерва для ПС ЕЭСК без опубликованного резерва (#2119 B1). Для ПС АО «ЕЭСК» (городская сеть ЕКБ) свободная мощность не раскрывается в WFS/xlsx-источниках → reserve_mva IS NULL. Показываем честную пометку «резерв по запросу». Для ПС других ДЗО (или ЕЭСК с известным резервом) → None. """ if reserve_mva is not None: return None if dzo_name and "еэск" in dzo_name.lower(): return _EESK_RESERVE_NOTE return None 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 _query_gas_city_grs(db: Session) -> dict: """Городские ГРС ЕКБ со свободной мощностью (gas_grs_capacity), агрегация по ГРС. Фильтр «городских» ГРС по имени (ILIKE '%свердловск%' / '%екатеринбург%'). Агрегация по grs_name (у ГРС несколько строк-выходов): SUM(design), SUM(free), AVG(pct), MAX(upgrade_due), COUNT(*) outputs_count. Пустая/несуществующая таблица → city_grs=[] (не падаем — блок аддитивный, gas_grs_capacity может ещё не существовать до применения миграции 181). Returns: {"city_grs": [...], "total_free_th_m3_h": float|None}. """ try: rows = ( db.execute( text(""" SELECT grs_name, SUM(design_capacity_th_m3_h) AS design_sum, SUM(free_capacity_th_m3_h) AS free_sum, AVG(free_capacity_pct) AS pct_avg, MAX(upgrade_due) AS upgrade_due, COUNT(*) AS outputs_count FROM gas_grs_capacity WHERE grs_name ILIKE '%свердловск%' OR grs_name ILIKE '%екатеринбург%' GROUP BY grs_name ORDER BY grs_name """) ) .mappings() .all() ) except Exception as e: # Таблица может не существовать до применения миграции 181 (schema-first). # Блок аддитивный — деградируем в пустой, не роняя весь эндпоинт. logger.warning("gas_grs_capacity недоступна (миграция 181 не применена?): %s", e) return {"city_grs": [], "total_free_th_m3_h": None} city_grs: list[dict] = [] total_free = 0.0 has_free = False for r in rows: free = _num(r["free_sum"]) if free is not None: total_free += free has_free = True city_grs.append( { "grs_name": r["grs_name"], "design_capacity_th_m3_h": _num(r["design_sum"]), "free_capacity_th_m3_h": free, "free_capacity_pct": ( round(float(r["pct_avg"]), 1) if r["pct_avg"] is not None else None ), "upgrade_due": r["upgrade_due"], "outputs_count": int(r["outputs_count"]), } ) return { "city_grs": city_grs, "total_free_th_m3_h": round(total_free, 3) if has_free else None, } def _num(value: object) -> float | None: """NUMERIC (Decimal) из БД → float | None.""" return float(value) if value is not None else None # type: ignore[arg-type]