gendesign/backend/app/services/site_finder/connection_capacity_lookup.py
Light1YT e285cbe33a
All checks were successful
CI / changes (pull_request) Successful in 8s
CI / frontend-tests (pull_request) Successful in 1m17s
CI / openapi-codegen-check (pull_request) Successful in 2m6s
CI / backend-tests (pull_request) Successful in 14m28s
feat(site-finder): резервы мощности для ТП — электро+вода на карте §3 (#2119 Фаза A)
Наполнение карты точками подключения с ХАРАКТЕРИСТИКАМИ (свободная мощность)
из верифицированных источников (research #2119). Все источники гео-блокируют
не-RU IP — лоадеры выполняются на проде (Celery weekly + ручной docker exec).

Backend:
- Миграция 180: power_supply_centers (ПС 35-220кВ, точки + резерв МВА),
  power_tp_rp_reserves (10.7k ТП/РП 6-10кВ, геокод — Фаза B),
  water_supply_reserves (ЦСВ/ЦСК Водоканала). UNIQUE NULLS NOT DISTINCT на
  nullable-ключах (грабли #140).
- rosseti_wfs_loader: открытый WFS портал-тп.рф (punycode) → 488 ПС Свердл обл,
  индекс загрузки 256184/6/8 → open/limited/closed, voltage из имени.
- rosseti_reserve_loader: раскрытие ПП№24 (xlsx) — ЦП 35-110кВ (строка 8+)
  матчится к WFS-точкам по нормализованному имени; ТП/РП <35кВ (строка 9+),
  кВА-санити (>2.5 МВА для ТП → ÷1000), «н/д»→NULL, SAVEPOINT per-row.
- vodokanal_reserve_loader: DOCX-раскрытие ПП№6 (stdlib parse, vMerge
  forward-fill), отрицательные резервы (город: дефицит) сохраняются со знаком.
- Endpoint GET /{cad}/connection-capacity: ПС в радиусе (ST_DWithin geography)
  + summary + вода за последний период per-system_kind. Celery: вт 04:00/04:30.

Frontend (§3):
- Карточка «Ресурсные резервы»: ближайший ЦП с резервом МВА + статус-бейдж;
  вода/канализация с красным «дефицит» при отрицательном резерве + примечание.
- Слой «Центры питания (резерв)» на карте: цвет по индексу загрузки
  (зелёный/янтарь/красный), попап с напряжением/мощностью/загрузкой/резервом,
  легенда под картой. Числа через toFiniteNumber (Decimal→str coercion).

api-types.ts перегенерён офлайн (+131/-0, аддитивно). 39 тестов зелёные.
Deep-review: ⚠️ MINOR → все 3 замечания исправлены (NULLS NOT DISTINCT,
per-kind MAX(period), settlement-комментарий).
2026-07-02 12:45:55 +05:00

173 lines
6.8 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) с классом напряжения / индексом загрузки /
резервом свободной мощности.
- power_summary: ближайший ЦП с резервом + count по load_index.
- water: все строки water_supply_reserves за ПОСЛЕДНИЙ period (city-level).
Источник координат участка — общий ``_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
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)
return {
"power_points": power_points,
"power_summary": power_summary,
"water": water,
}
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:
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": _num(r["reserve_mva"]),
"reserve_asof": r["reserve_asof"].isoformat() if r["reserve_asof"] else None,
"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 _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 _num(value: object) -> float | None:
"""NUMERIC (Decimal) из БД → float | None."""
return float(value) if value is not None else None # type: ignore[arg-type]