gendesign/backend/app/services/site_finder/connection_capacity_lookup.py
bot-backend 5839680998
All checks were successful
Deploy / changes (push) Successful in 8s
Deploy / build-backend (push) Successful in 2m2s
Deploy / build-worker (push) Successful in 3m17s
Deploy / build-frontend (push) Successful in 3m57s
Deploy / deploy (push) Successful in 1m31s
feat(site-finder): газ — свободная мощность ГРС + пометка ЕЭСК в §3 (#2119 Фаза B1) (#2123)
2026-07-02 12:54:05 +00:00

261 lines
11 KiB
Python
Raw 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.

"""Резолвер «точки подключения СО СВОБОДНОЙ МОЩНОСТЬЮ» для ПТИЦА §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]