gendesign/backend/app/services/site_finder/connection_capacity_lookup.py
bot-backend e501dedf78
All checks were successful
Deploy / build-frontend (push) Successful in 4m38s
Deploy / deploy (push) Successful in 1m37s
Deploy / build-backend (push) Successful in 2m48s
Deploy / build-worker (push) Successful in 3m59s
Deploy / changes (push) Successful in 9s
feat(site-finder): ЕЭСК-резервы городских ПС/ТП + тепло из ФГИС ФАС (#2119 B2) (#2165)
2026-07-02 16:44:57 +00:00

328 lines
15 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.
- heat: резервы тепловой мощности систем теплоснабжения (heat_system_reserves)
за последний период ПО КАЖДОЙ организации + суммарный свободный объём. #2119 B2.
Источник координат участка — общий ``_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": [...],
"gas": {...}|None, "heat": {...}|None}
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)
heat = _query_heat_latest(db)
return {
"power_points": power_points,
"power_summary": power_summary,
"water": water,
"gas": gas,
"heat": heat,
}
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_unit, district, 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,
# Единица резерва: 'МВА' (Россети, default) или 'МВт' (ЕЭСК ПС 35-220).
# district — адм. район ЕКБ из xlsx ЕЭСК (NULL у Россети-строк). #2119 B2.
"reserve_unit": r["reserve_unit"],
"district": r["district"],
"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 _query_heat_latest(db: Session) -> dict:
"""Резервы тепловой мощности систем за ПОСЛЕДНИЙ период ПО КАЖДОЙ организации.
Строки heat_system_reserves за MAX(period) PER org (коррелированный подзапрос —
как в _query_water_latest: одна организация может опубликовать свежий квартал
раньше другой, глобальный MAX молча выкинул бы отстающую). period IS NULL
исключаем; MAX(period) лексикографически совпадает с хронологией для 'YYYY-Qn'.
Сортировка org, system_name. Graceful при отсутствии таблицы (как gas-блок).
Returns: {"systems": [{org, system_name, reserve_gcal_h, period}],
"total_reserve_gcal_h": float|None}.
"""
try:
rows = (
db.execute(
text("""
SELECT h.org, h.system_name, h.reserve_gcal_h, h.period
FROM heat_system_reserves h
WHERE h.period IS NOT NULL
AND h.period = (
SELECT MAX(h2.period) FROM heat_system_reserves h2
WHERE h2.period IS NOT NULL
AND h2.org = h.org
)
ORDER BY h.org, h.system_name
""")
)
.mappings()
.all()
)
except Exception as e:
# Таблица может не существовать до применения миграции 182 (schema-first).
# Блок аддитивный — деградируем в пустой, не роняя весь эндпоинт.
logger.warning("heat_system_reserves недоступна (миграция 182 не применена?): %s", e)
return {"systems": [], "total_reserve_gcal_h": None}
systems: list[dict] = []
total_reserve = 0.0
has_reserve = False
for r in rows:
reserve = _num(r["reserve_gcal_h"])
if reserve is not None:
total_reserve += reserve
has_reserve = True
systems.append(
{
"org": r["org"],
"system_name": r["system_name"],
"reserve_gcal_h": reserve,
"period": r["period"],
}
)
return {
"systems": systems,
"total_reserve_gcal_h": round(total_reserve, 3) if has_reserve 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]