634 lines
34 KiB
Python
634 lines
34 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.
|
||
- nearby_network_zones: ПОЗИТИВ-разрез — сетевые охранные зоны из cad_zouit в радиусе
|
||
300 м от участка (там физически ПРОХОДИТ сеть → «теплотрасса в 24 м, врезка
|
||
дешёвая»). До 2 ближайших зон НА КАЖДЫЙ net_kind (thermal/electricity/gas/…), с
|
||
гуманизированной подписью (кад.номер трубопровода / напряжение ВЛ + ТП). #2119 C2/C3.
|
||
|
||
Источник координат участка — общий ``_get_parcel_wkt`` (cad_parcels_geom → квартал
|
||
fallback). Радиус по умолчанию 3000 м (cap 10000).
|
||
"""
|
||
|
||
import logging
|
||
|
||
from sqlalchemy import text
|
||
from sqlalchemy.exc import SQLAlchemyError
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.services.site_finder.network_zone_humanize import human_label, refine_net_kind
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
_DEFAULT_RADIUS_M = 3000
|
||
_MAX_RADIUS_M = 10000
|
||
|
||
# Радиус позитив-разреза сетевых охранных зон (cad_zouit): фиксированный 300 м — «сеть
|
||
# рядом с участком». Не зависит от radius_m ЦП (тот про магистральные ЦП за км).
|
||
# Degree-эквивалент для GIST-префильтра: 300 м / ~60.8 км/° долготы у ЕКБ (56.8° N) ≈
|
||
# 0.0050° — берём с запасом 0.006°, точный отсев делает geography ST_DWithin поверх. C2.
|
||
_NETWORK_ZONE_RADIUS_M = 300
|
||
_NETWORK_ZONE_RADIUS_DEG = 0.006
|
||
# До скольких ближайших зон отдаём НА КАЖДЫЙ net_kind (thermal/electricity/gas/…). C2.
|
||
_NETWORK_ZONE_PER_KIND = 2
|
||
# Предохранитель: запрос GIST-индексирован (~70 мс на плотном центре ЕКБ, EXPLAIN прод
|
||
# 2026-07-03), но эндпоинт синхронный — таймаут-guard как у offer_trend (parcels.py).
|
||
_NETWORK_ZONE_TIMEOUT_MS = 4000
|
||
|
||
# Радиус для гео-точек выхода газосети (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,
|
||
"nearby_network_zones": [...]}
|
||
|
||
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)
|
||
nearby_network_zones = _query_nearby_network_zones(db, parcel_wkt)
|
||
|
||
return {
|
||
"power_points": power_points,
|
||
"power_summary": power_summary,
|
||
"water": water,
|
||
"gas": gas,
|
||
"gas_outlet_points": gas_outlet_points,
|
||
"heat": heat,
|
||
"nearby_network_zones": nearby_network_zones,
|
||
}
|
||
|
||
|
||
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:
|
||
# SAVEPOINT — db shared с caller (GET .../connection-capacity, Depends(get_db)).
|
||
# Голый db.rollback() orphan'ит outer SessionTransaction (см. velocity.py:170) —
|
||
# begin_nested откатывает только эту savepoint, outer tx остаётся usable.
|
||
with db.begin_nested():
|
||
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:
|
||
# SAVEPOINT — db shared с caller (см. _query_gas_city_grs выше).
|
||
with db.begin_nested():
|
||
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:
|
||
# SAVEPOINT — db shared с caller (см. _query_gas_city_grs выше).
|
||
with db.begin_nested():
|
||
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:
|
||
# SAVEPOINT — db shared с caller (см. _query_gas_city_grs выше).
|
||
with db.begin_nested():
|
||
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 _query_nearby_network_zones(db: Session, parcel_wkt: str) -> list[dict]:
|
||
"""Сетевые охранные зоны cad_zouit в радиусе 300 м от участка — позитив-развёртка §3.
|
||
|
||
Охранная зона сети = коридор, где физически ПРОХОДИТ сеть. «Теплотрасса в 24 м →
|
||
врезка дешёвая» — решающий сигнал для финдиректора (особенно heat, у которого в §3
|
||
нет своей гео-привязки). Зоны, ПЕРЕСЕКАЮЩИЕ участок, включаем с distance_m=0.
|
||
|
||
План (EXPLAIN прод 2026-07-03, cad_zouit_geom_gist существует): GIST-префильтр
|
||
``ST_DWithin(geom, parcel, 0.006°)`` + точный ``ST_DWithin(geom::geography,
|
||
parcel::geography, 300)`` — Index Scan, ~70 мс на плотном центре ЕКБ. cad_zouit.geom
|
||
= geometry(MultiPolygon,4326); geography-каст приклеен к ``)``, не к bind-имени
|
||
(psycopg3-safe, как ``ARRAY[...]::int[]`` / power_points-запрос).
|
||
|
||
#1957: одна физическая зона хранится 2× (одинаковый reg_numb_border, разный
|
||
category_name) → DISTINCT ON (reg_numb_border) схлопывает, оставляя ближайшую строку.
|
||
|
||
Гуманизация (C3, ``network_zone_humanize``): net_kind дочищается по name_by_doc
|
||
(general «инженерные коммуникации» на проде часто РЕАЛЬНО теплосеть/газопровод),
|
||
human_label вытаскивает кад.номер трубопровода / напряжение ВЛ + ТП. Итог — до
|
||
_NETWORK_ZONE_PER_KIND ближайших зон НА КАЖДЫЙ net_kind (сорт по distance ASC).
|
||
|
||
Таймаут-guard в savepoint: запрос лёгкий, но эндпоинт синхронный — при аномально
|
||
медленном скане (QueryCanceled) деградируем в [], не роняя весь эндпоинт. Блок
|
||
аддитивный (позитив-сигнал), отсутствие данных читается как «сеть рядом не найдена».
|
||
"""
|
||
try:
|
||
with db.begin_nested():
|
||
# SET не принимает bind-параметры (extended protocol → syntax error
|
||
# at or near "$1") — только литерал, как у offer_trend (parcels.py).
|
||
db.execute(text(f"SET LOCAL statement_timeout = '{_NETWORK_ZONE_TIMEOUT_MS:d}'"))
|
||
rows = (
|
||
db.execute(
|
||
text("""
|
||
SELECT DISTINCT ON (z.reg_numb_border)
|
||
z.type_zone,
|
||
z.name_by_doc,
|
||
z.reg_numb_border,
|
||
round(
|
||
ST_Distance(
|
||
z.geom::geography,
|
||
ST_GeomFromText(:wkt, 4326)::geography
|
||
)::numeric
|
||
) AS distance_m
|
||
FROM cad_zouit z
|
||
WHERE z.geom IS NOT NULL
|
||
AND z.type_zone IN (
|
||
'Охранная зона тепловых сетей',
|
||
'Охранная зона инженерных коммуникаций',
|
||
'Охранная зона объектов электроэнергетики'
|
||
' (объектов электросетевого хозяйства и объектов по'
|
||
' производству электрической энергии)',
|
||
'Охранная зона трубопроводов (газопроводов,'
|
||
' нефтепроводов и нефтепродуктопроводов, аммиакопроводов)',
|
||
'Охранная зона линий и сооружений связи'
|
||
)
|
||
AND ST_DWithin(
|
||
z.geom,
|
||
ST_GeomFromText(:wkt, 4326),
|
||
CAST(:radius_deg AS float)
|
||
)
|
||
AND ST_DWithin(
|
||
z.geom::geography,
|
||
ST_GeomFromText(:wkt, 4326)::geography,
|
||
CAST(:radius_m AS float)
|
||
)
|
||
ORDER BY z.reg_numb_border, distance_m ASC
|
||
"""),
|
||
{
|
||
"wkt": parcel_wkt,
|
||
"radius_deg": _NETWORK_ZONE_RADIUS_DEG,
|
||
"radius_m": _NETWORK_ZONE_RADIUS_M,
|
||
},
|
||
)
|
||
.mappings()
|
||
.all()
|
||
)
|
||
# SET LOCAL переживает RELEASE SAVEPOINT (transaction-scoped) —
|
||
# сбрасываем явно, как в offer_trend (parcels.py), чтобы таймаут
|
||
# не утёк на последующие запросы этой транзакции.
|
||
db.execute(text("SET LOCAL statement_timeout = 0"))
|
||
except SQLAlchemyError as e:
|
||
# QueryCanceled (таймаут) / отсутствие таблицы / прочие SQL-сбои — блок аддитивный
|
||
# (позитив-сигнал), деградируем в пустой, не роняя весь эндпоинт connection-capacity.
|
||
logger.warning("nearby_network_zones query failed: %s", e)
|
||
return []
|
||
|
||
return _shape_network_zones(rows)
|
||
|
||
|
||
def _shape_network_zones(rows: list) -> list[dict]:
|
||
"""DISTINCT-строки cad_zouit → гуманизированные зоны, до N ближайших per net_kind.
|
||
|
||
Классификацию net_kind (refine) и подпись (human_label) считаем в Python (regex по
|
||
name_by_doc), затем сортируем по distance ASC и берём _NETWORK_ZONE_PER_KIND на вид.
|
||
Возвращаемый список отсортирован по (distance_m, net_kind) для стабильного порядка.
|
||
"""
|
||
per_kind: dict[str, list[dict]] = {}
|
||
for r in rows:
|
||
type_zone = r["type_zone"] or ""
|
||
name_by_doc = r["name_by_doc"]
|
||
net_kind = refine_net_kind(type_zone, name_by_doc)
|
||
if net_kind is None:
|
||
# type_zone из белого списка, но не классифицировался — пропускаем (не должно).
|
||
continue
|
||
distance_m = int(r["distance_m"]) if r["distance_m"] is not None else 0
|
||
per_kind.setdefault(net_kind, []).append(
|
||
{
|
||
"net_kind": net_kind,
|
||
"distance_m": distance_m,
|
||
"name_by_doc": name_by_doc,
|
||
"human_label": human_label(net_kind, name_by_doc),
|
||
}
|
||
)
|
||
|
||
result: list[dict] = []
|
||
for kind_rows in per_kind.values():
|
||
kind_rows.sort(key=lambda z: z["distance_m"])
|
||
result.extend(kind_rows[:_NETWORK_ZONE_PER_KIND])
|
||
# Стабильный внешний порядок: ближайшее сверху, затем по виду сети.
|
||
result.sort(key=lambda z: (z["distance_m"], z["net_kind"]))
|
||
return result
|
||
|
||
|
||
def _num(value: object) -> float | None:
|
||
"""NUMERIC (Decimal) из БД → float | None."""
|
||
return float(value) if value is not None else None # type: ignore[arg-type]
|