479 lines
24 KiB
Python
479 lines
24 KiB
Python
"""Резолвер «точки подключения СО СВОБОДНОЙ МОЩНОСТЬЮ» для ПТИЦА §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.
|
||
+ счётчики точек выхода (gas_grs_outlet_points, форма 6 ФАС №960 АО
|
||
«Екатеринбурггаз») последнего period_month: всего / дефицитных / «нужен
|
||
гидрорасчёт». #2119 B2 PR-4.
|
||
- gas_outlet_points: гео-точки выхода газосети (gas_grs_outlet_points) В РАДИУСЕ от
|
||
участка (ST_DWithin geom ~2000 м, latest period, LIMIT 40 по расстоянию) — для слоя
|
||
на карте §3. Свободная мощность в млн.куб.м/МЕС (НЕ тыс.м³/ч как у ГРС!). #2119 B2.
|
||
- 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
|
||
|
||
# Радиус для гео-точек выхода газосети (gas_grs_outlet_points): фиксированный ~2000 м —
|
||
# точки привязаны к конкретным объектам (котельные/промплощадки), не к магистральным
|
||
# ЦП, поэтому свой узкий радиус независимо от radius_m ЦП. LIMIT — топ-N по расстоянию,
|
||
# чтобы слой на карте §3 не разбухал (у ~19% строк есть geom после геокода). #2119 B2.
|
||
_GAS_OUTLET_RADIUS_M = 2000
|
||
_GAS_OUTLET_LIMIT = 40
|
||
|
||
# Пометка для ПС АО «ЕЭСК» (городская сеть ЕКБ) без раскрытого резерва: свободная
|
||
# мощность у ЕЭСК не публикуется в 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, "gas_outlet_points": [...], "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)
|
||
gas_outlet_points = _query_gas_outlet_points(db, parcel_wkt)
|
||
heat = _query_heat_latest(db)
|
||
|
||
return {
|
||
"power_points": power_points,
|
||
"power_summary": power_summary,
|
||
"water": water,
|
||
"gas": gas,
|
||
"gas_outlet_points": gas_outlet_points,
|
||
"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).
|
||
|
||
Дополнительно (#2119 B2 PR-4): счётчики точек выхода газосети из
|
||
gas_grs_outlet_points (форма 6 ФАС №960 АО «Екатеринбурггаз») за последний
|
||
period_month — outlets_total / outlets_deficit (free<0) / outlets_needs_calc.
|
||
См. _query_gas_outlet_counts для обоснования «по ВСЕМ строкам без разбивки по ГРС».
|
||
|
||
Returns: {"city_grs": [...], "total_free_th_m3_h": float|None,
|
||
"outlets_total": int, "outlets_deficit": int, "outlets_needs_calc": int}.
|
||
Размерности: свободная мощность ГРС — тыс. м³/ЧАС; свободная мощность точек выхода
|
||
(в счётчиках/gas_outlet_points) — млн. м³/МЕС. НЕ путать при агрегации.
|
||
"""
|
||
outlet_counts = _query_gas_outlet_counts(db)
|
||
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).
|
||
# Блок аддитивный — деградируем в пустой, не роняя весь эндпоинт. Счётчики
|
||
# точек выхода (миграция 184, независимая) — оставляем, что удалось собрать.
|
||
logger.warning("gas_grs_capacity недоступна (миграция 181 не применена?): %s", e)
|
||
return {"city_grs": [], "total_free_th_m3_h": None, **outlet_counts}
|
||
|
||
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,
|
||
**outlet_counts,
|
||
}
|
||
|
||
|
||
def _query_gas_outlet_counts(db: Session) -> dict:
|
||
"""Счётчики точек выхода газосети (gas_grs_outlet_points) за последний period_month.
|
||
|
||
По ВСЕМ строкам последнего среза БЕЗ разбивки по ГРС (обоснование): grs_name_norm в
|
||
форме 6 АО «Екатеринбурггаз» — это block-marker «Точка входа» (5 блоков ГТЕ вроде
|
||
«грс-1 свердловск»), НЕ строгий ключ городской связки gas_grs_capacity (ГТЕ, другой
|
||
источник). Матч outlet→ГРС по grs_name_norm без FK ненадёжен на per-набор уровне
|
||
(имена ГРС в двух раскрытиях расходятся) → честнее агрегировать счётчики ПО ГОРОДУ
|
||
целиком, чем молча терять строки на непопавшем join. Это city-level индикатор §3
|
||
«сколько точек выхода в дефиците / требуют гидрорасчёта», не per-ГРС.
|
||
|
||
Считаем за MAX(period_month) (свежайший помесячный срез); is_forecast не фильтруем —
|
||
в проде факт/прогноз разведены, но для счётчика важен объём точек последнего среза.
|
||
|
||
Returns: {"outlets_total": int, "outlets_deficit": int, "outlets_needs_calc": int}.
|
||
- outlets_deficit — free_capacity_mln_m3 < 0 (дефицит со знаком, млн.куб.м/МЕС);
|
||
- outlets_needs_calc — free_capacity_needs_calc = TRUE («0.000*», нужен гидрорасчёт).
|
||
Таблицы может не быть (миграция 184 не применена) → все нули (graceful, аддитивно).
|
||
"""
|
||
try:
|
||
row = (
|
||
db.execute(
|
||
text("""
|
||
SELECT COUNT(*) AS outlets_total,
|
||
COUNT(*) FILTER (
|
||
WHERE free_capacity_mln_m3 < 0
|
||
) AS outlets_deficit,
|
||
COUNT(*) FILTER (
|
||
WHERE free_capacity_needs_calc
|
||
) AS outlets_needs_calc
|
||
FROM gas_grs_outlet_points
|
||
WHERE period_month = (
|
||
SELECT MAX(period_month) FROM gas_grs_outlet_points
|
||
)
|
||
""")
|
||
)
|
||
.mappings()
|
||
.first()
|
||
)
|
||
except Exception as e:
|
||
# Таблица может не существовать до применения миграции 184 (schema-first).
|
||
# Счётчики аддитивны — деградируем в нули, не роняя весь эндпоинт.
|
||
logger.warning("gas_grs_outlet_points недоступна (миграция 184 не применена?): %s", e)
|
||
return {"outlets_total": 0, "outlets_deficit": 0, "outlets_needs_calc": 0}
|
||
|
||
if row is None:
|
||
return {"outlets_total": 0, "outlets_deficit": 0, "outlets_needs_calc": 0}
|
||
return {
|
||
"outlets_total": int(row["outlets_total"] or 0),
|
||
"outlets_deficit": int(row["outlets_deficit"] or 0),
|
||
"outlets_needs_calc": int(row["outlets_needs_calc"] or 0),
|
||
}
|
||
|
||
|
||
def _query_gas_outlet_points(db: Session, parcel_wkt: str) -> list[dict]:
|
||
"""Гео-точки выхода газосети (gas_grs_outlet_points) В РАДИУСЕ от участка — слой §3.
|
||
|
||
ST_DWithin(geom::geography, центроид::geography, _GAS_OUTLET_RADIUS_M) за последний
|
||
period_month, ORDER BY distance ASC, LIMIT _GAS_OUTLET_LIMIT. geom есть лишь у ~19%
|
||
строк (геокод DaData, PR-3) → в радиус попадает малое подмножество. Размерность
|
||
free_capacity_mln_m3 — млн. м³/МЕС (НЕ тыс. м³/ч как у ГРС!). Таблицы может не быть
|
||
(миграция 184) → [] (graceful, блок аддитивный).
|
||
|
||
План (EXPLAIN прод 2026-07-03): Seq Scan ~13 мс — geography-каст НЕ использует
|
||
geometry-GIST; при 1037 строках это норм. Вырастет 10-50× → функциональный GIST
|
||
по (CAST(geom AS geography)) или ST_DWithin по geometry с градусным радиусом.
|
||
|
||
Returns: [{outlet_name, consumer_type, free_capacity_mln_m3, needs_calc,
|
||
distance_m, lat, lon}] — отсортировано по distance ASC.
|
||
"""
|
||
try:
|
||
rows = (
|
||
db.execute(
|
||
text("""
|
||
SELECT outlet_name, consumer_type, free_capacity_mln_m3,
|
||
free_capacity_needs_calc AS needs_calc,
|
||
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 gas_grs_outlet_points
|
||
WHERE geom IS NOT NULL
|
||
AND period_month = (
|
||
SELECT MAX(period_month) FROM gas_grs_outlet_points
|
||
)
|
||
AND ST_DWithin(
|
||
geom::geography,
|
||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
|
||
CAST(:radius_m AS float)
|
||
)
|
||
ORDER BY distance_m ASC
|
||
LIMIT CAST(:limit AS int)
|
||
"""),
|
||
{
|
||
"wkt": parcel_wkt,
|
||
"radius_m": _GAS_OUTLET_RADIUS_M,
|
||
"limit": _GAS_OUTLET_LIMIT,
|
||
},
|
||
)
|
||
.mappings()
|
||
.all()
|
||
)
|
||
except Exception as e:
|
||
# Таблица может не существовать до применения миграции 184 (schema-first).
|
||
# Слой аддитивный — деградируем в пустой, не роняя весь эндпоинт.
|
||
logger.warning("gas_grs_outlet_points (радиус) недоступна (миграция 184?): %s", e)
|
||
return []
|
||
|
||
return [
|
||
{
|
||
"outlet_name": r["outlet_name"],
|
||
"consumer_type": r["consumer_type"],
|
||
# млн. м³/МЕС (НЕ тыс. м³/ч как у ГРС!); может быть <0 — дефицит со знаком.
|
||
"free_capacity_mln_m3": _num(r["free_capacity_mln_m3"]),
|
||
"needs_calc": bool(r["needs_calc"]),
|
||
"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,
|
||
}
|
||
for r in rows
|
||
]
|
||
|
||
|
||
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]
|