feat(site-finder): позитив-разрез сетевых ЗОУИТ «сеть рядом с участком» (#2119 C2/C3)
All checks were successful
CI / changes (pull_request) Successful in 21s
CI Trade-In / changes (pull_request) Successful in 22s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 2m52s
CI / openapi-codegen-check (pull_request) Successful in 3m44s
CI / backend-tests (pull_request) Successful in 15m40s
All checks were successful
CI / changes (pull_request) Successful in 21s
CI Trade-In / changes (pull_request) Successful in 22s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 2m52s
CI / openapi-codegen-check (pull_request) Successful in 3m44s
CI / backend-tests (pull_request) Successful in 15m40s
Охранная зона инженерной сети = коридор, где физически проходит сеть → «теплотрасса в 24 м, врезка дешёвая» — решающий сигнал §3 (особенно heat, у которого не было гео-привязки). Новое поле nearby_network_zones в connection-capacity: до 2 ближайших сетевых охранных зон cad_zouit на net_kind в радиусе 300 м (GIST-префильтр в градусах + точный geography, ~70 мс, statement_timeout 4s с явным сбросом), гуманизированные подписи (кад.номер трубопровода / напряжение ВЛ + ТП, ≤1 кВ помечен как last-mile). general «инженерные коммуникации» дочищается по name_by_doc (на проде это реально тепло/газ). Пересекающие участок зоны — distance_m=0.
This commit is contained in:
parent
dfc1ee8be0
commit
5ae0fcc4f5
6 changed files with 620 additions and 1 deletions
|
|
@ -4000,6 +4000,10 @@ def get_parcel_connection_capacity(
|
|||
|
||||
power_points — ЦП в радиусе от центроида участка (ST_DWithin geography).
|
||||
water — все строки за ПОСЛЕДНИЙ период (город-уровень, показывается целиком).
|
||||
nearby_network_zones — позитив-разрез (#2119 C2/C3): сетевые охранные зоны cad_zouit
|
||||
в радиусе 300 м (где физически проходит сеть) — до 2 ближайших на net_kind,
|
||||
гуманизированы; пересекающие участок отдаются с distance_m=0. Радиус 300 м —
|
||||
независимый от radius_m ЦП.
|
||||
|
||||
Query params:
|
||||
radius_m: радиус поиска ЦП, 100–10000 м (default 3000).
|
||||
|
|
@ -4018,6 +4022,7 @@ def get_parcel_connection_capacity(
|
|||
gas=data.get("gas"),
|
||||
gas_outlet_points=data.get("gas_outlet_points") or [],
|
||||
heat=data.get("heat"),
|
||||
nearby_network_zones=data.get("nearby_network_zones") or [],
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -221,6 +221,23 @@ class HeatCapacityBlock(BaseModel):
|
|||
total_reserve_gcal_h: float | None
|
||||
|
||||
|
||||
class NearbyNetworkZone(BaseModel):
|
||||
"""Сетевая охранная зона cad_zouit рядом с участком — позитив-сигнал §3. #2119 C2/C3.
|
||||
|
||||
Охранная зона сети = коридор, где физически проходит сеть → «теплотрасса в 24 м,
|
||||
врезка дешёвая». Зона, ПЕРЕСЕКАЮЩАЯ участок, отдаётся с distance_m=0.
|
||||
"""
|
||||
|
||||
# Вид сети: thermal/electricity/gas/water/communication/pipeline/general.
|
||||
# general дочищен по name_by_doc (инж.коммуникации на проде часто реально тепло/газ).
|
||||
net_kind: str
|
||||
distance_m: int # расстояние от участка до охранной зоны, м (0 = пересекает участок)
|
||||
name_by_doc: str | None # сырое детальное имя из cad_zouit (для тултипа/дебага)
|
||||
# Гуманизированная подпись (C3): «Тепловая сеть (по охранной зоне), кад. № …» /
|
||||
# «Ввод 10 кВ от ТП 3164 (по охранной зоне)» / «Инженерные коммуникации (вид не уточнён)».
|
||||
human_label: str
|
||||
|
||||
|
||||
class ConnectionCapacityResponse(BaseModel):
|
||||
power_points: list[PowerConnectionPoint]
|
||||
power_summary: PowerCapacitySummary
|
||||
|
|
@ -232,6 +249,10 @@ class ConnectionCapacityResponse(BaseModel):
|
|||
gas_outlet_points: list[GasOutletPoint] = []
|
||||
# Тепловой блок аддитивен (Phase B2): None если ключ 'heat' не собран.
|
||||
heat: HeatCapacityBlock | None = None
|
||||
# Позитив-разрез (Phase C2/C3): сетевые охранные зоны cad_zouit в радиусе 300 м —
|
||||
# до 2 ближайших на net_kind, гуманизированы. ADDITIVE (default []): [] если сеть
|
||||
# рядом не найдена / нет geom участка.
|
||||
nearby_network_zones: list[NearbyNetworkZone] = []
|
||||
|
||||
|
||||
# ── NSPD Risk Zones schemas (issue #94 TIER 3) ───────────────────────────────
|
||||
|
|
|
|||
|
|
@ -17,6 +17,10 @@
|
|||
на карте §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).
|
||||
|
|
@ -25,13 +29,28 @@ 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 по расстоянию,
|
||||
|
|
@ -58,7 +77,8 @@ def get_connection_capacity(
|
|||
|
||||
Returns:
|
||||
{"power_points": [...], "power_summary": {...}, "water": [...],
|
||||
"gas": {...}|None, "gas_outlet_points": [...], "heat": {...}|None}
|
||||
"gas": {...}|None, "gas_outlet_points": [...], "heat": {...}|None,
|
||||
"nearby_network_zones": [...]}
|
||||
|
||||
Raises:
|
||||
ValueError: участок не найден в БД (нет geom).
|
||||
|
|
@ -78,6 +98,7 @@ def get_connection_capacity(
|
|||
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,
|
||||
|
|
@ -86,6 +107,7 @@ def get_connection_capacity(
|
|||
"gas": gas,
|
||||
"gas_outlet_points": gas_outlet_points,
|
||||
"heat": heat,
|
||||
"nearby_network_zones": nearby_network_zones,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -474,6 +496,130 @@ def _query_heat_latest(db: Session) -> dict:
|
|||
}
|
||||
|
||||
|
||||
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():
|
||||
db.execute(
|
||||
text("SET LOCAL statement_timeout = :ms"),
|
||||
{"ms": str(_NETWORK_ZONE_TIMEOUT_MS)},
|
||||
)
|
||||
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]
|
||||
|
|
|
|||
168
backend/app/services/site_finder/network_zone_humanize.py
Normal file
168
backend/app/services/site_finder/network_zone_humanize.py
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
"""Гуманизация «сетевых охранных зон» ЗОУИТ для §3 ПТИЦА (#2119 C3).
|
||||
|
||||
Позитив-разрез охранных зон инженерных сетей: рядом с участком физически ПРОХОДИТ
|
||||
сеть (охранная зона = коридор сети) → «теплотрасса в 24 м — врезка дешёвая». Pure /
|
||||
без БД: на вход ``type_zone`` + ``name_by_doc`` (детальные имена из cad_zouit), на
|
||||
выход — уточнённый ``net_kind`` + человекочитаемый ``human_label`` для финдиректора.
|
||||
|
||||
Зачем отдельно от ``network_obremenenie.classify_network_zone``:
|
||||
- ``classify_network_zone`` смотрит ТОЛЬКО на ``type_zone`` (грубый вид: thermal /
|
||||
general / …). На проде (2026-07-03) большинство «Охранная зона инженерных
|
||||
коммуникаций» (``general``) — по ``name_by_doc`` РЕАЛЬНО теплосети/газопроводы
|
||||
(«Трубопровод (тепловые сети) с кадастровым номером 66:41:…», «Газопровод
|
||||
низкого давления…»). Оставить их ``general`` = потерять решающий тепло-сигнал.
|
||||
- Поэтому здесь ``refine_net_kind`` дочищает ``general`` по ``name_by_doc``, а
|
||||
``human_label`` вытаскивает кад.номер трубопровода / напряжение ВЛ / номер ТП
|
||||
regex-ами. Классификатор type_zone НЕ дублируем — переиспользуем как базу.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from app.services.site_finder.network_obremenenie import NetworkKind, classify_network_zone
|
||||
|
||||
# Кад.номер РФ: 2:2:до7цифр:до-длинная последняя секция (напр. 66:41:0108079:76).
|
||||
# Последняя секция бывает с ведущими нулями («0036») — берём как есть, не нормализуем.
|
||||
_CAD_NUM_RE = re.compile(r"\b\d{2}:\d{2}:\d{6,7}:\d+\b")
|
||||
|
||||
# «ВЛ 0,4 кВ» / «КЛ 10кВ» / «ВЛИ-0,4 кВ» — тип линии + напряжение. Запятая-десятичный
|
||||
# разделитель (0,4) и точка (0.4) оба валидны; пробел перед «кВ» опционален.
|
||||
_VL_KV_RE = re.compile(
|
||||
r"\b(ВЛИ|ВЛЗ|ВЛ|КЛ|КВЛ)\b[\s-]*?(\d+(?:[.,]\d+)?)\s*кВ",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Номер ТП/РП/КТП: «ТП 3164», «ТП 32505», «ТП 3736-3747», «КТП №589». Берём первый
|
||||
# «якорный» ТП (источник питания ввода). Номер — цифры, опционально с дефисом-диапазоном.
|
||||
_TP_RE = re.compile(
|
||||
r"\b(ТП|РП|КТП|КТПН|БКТП|РТП)\b\s*(?:п/?№|№|N)?\s*(\d+(?:-\d+)?)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Маркеры «на самом деле тепло» в name_by_doc для general-зон (инженерные коммуникации).
|
||||
_THERMAL_NEEDLES: tuple[str, ...] = (
|
||||
"теплоснабж",
|
||||
"тепловая сеть",
|
||||
"тепловые сети",
|
||||
"тепловых сетей",
|
||||
"теплосет",
|
||||
"теплотрасс",
|
||||
)
|
||||
# Маркеры «на самом деле газ» в name_by_doc для general-зон.
|
||||
_GAS_NEEDLES: tuple[str, ...] = (
|
||||
"газопров",
|
||||
"газораспредел",
|
||||
"газоснаб",
|
||||
"догазифик",
|
||||
)
|
||||
# Маркеры электро (ВЛ/КЛ/электросет/подстанция) в name_by_doc для general-зон.
|
||||
# «трансформаторная подстанция» / «распределительный пункт» — объект электросети без
|
||||
# явного «ВЛ N кВ» (прод-кейс 66:41:0404019:333). ТП здесь = трансформаторная (электро),
|
||||
# не путать с тепловой ТК (тепловая камера) — та ловится _THERMAL_NEEDLES раньше.
|
||||
_ELECTRICITY_NEEDLES: tuple[str, ...] = (
|
||||
"электросет",
|
||||
"электроснабж",
|
||||
"электросетевого",
|
||||
"кабельн",
|
||||
"трансформаторн",
|
||||
"распределительн",
|
||||
)
|
||||
|
||||
|
||||
def refine_net_kind(type_zone: str | None, name_by_doc: str | None) -> NetworkKind | None:
|
||||
"""Уточнить вид сети: база по ``type_zone``, дочистка ``general`` по ``name_by_doc``.
|
||||
|
||||
Возвращает ``None``, если зона вообще не сетевая (как ``classify_network_zone``).
|
||||
Для ``general`` («инженерные коммуникации») пытается распознать реальный вид по
|
||||
детальному имени: тепло → thermal, газ → gas, электро → electricity. Если вид не
|
||||
распознан — остаётся ``general`` («вид не уточнён»).
|
||||
|
||||
Специфичные type_zone (thermal / electricity / gas / …) НЕ переопределяем именем —
|
||||
для них type_zone уже точный, а name_by_doc лишь обогащает подпись.
|
||||
"""
|
||||
base = classify_network_zone(type_zone)
|
||||
if base != "general":
|
||||
return base
|
||||
name = (name_by_doc or "").lower()
|
||||
if any(n in name for n in _THERMAL_NEEDLES):
|
||||
return "thermal"
|
||||
if any(n in name for n in _GAS_NEEDLES):
|
||||
return "gas"
|
||||
# ВЛ/КЛ N кВ в имени → электро (regex поймает даже без слова «электросеть»).
|
||||
if any(n in name for n in _ELECTRICITY_NEEDLES) or _VL_KV_RE.search(name_by_doc or ""):
|
||||
return "electricity"
|
||||
return "general"
|
||||
|
||||
|
||||
def _fmt_voltage(raw: str) -> str:
|
||||
"""«0,4» / «0.4» / «10» → нормализованная строка напряжения («0,4», «10»)."""
|
||||
return raw.replace(".", ",")
|
||||
|
||||
|
||||
def _is_last_mile_voltage(raw: str) -> bool:
|
||||
"""Напряжение ≤1 кВ (0,4 кВ и т.п.) — last-mile ввод, не магистраль."""
|
||||
try:
|
||||
return float(raw.replace(",", ".")) <= 1.0
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def human_label(net_kind: NetworkKind | None, name_by_doc: str | None) -> str:
|
||||
"""Человекочитаемая подпись сетевой зоны для §3 (финдиректор-facing).
|
||||
|
||||
thermal → «Тепловая сеть (по охранной зоне)» + кад.номер трубопровода, если есть.
|
||||
electricity → «Ввод N кВ от ТП M (по охранной зоне)» (regex ВЛ/КЛ + ТП);
|
||||
напряжение ≤1 кВ подаём как «(ввод 0,4 кВ, last-mile)» — второстепенно.
|
||||
gas/pipeline → «Трубопровод (газо-/нефтепровод) по охранной зоне».
|
||||
general → «Инженерные коммуникации (вид не уточнён)».
|
||||
None → «Охранная зона» (fallback, не должно доходить для сетевых).
|
||||
"""
|
||||
name = name_by_doc or ""
|
||||
|
||||
if net_kind == "thermal":
|
||||
cad = _CAD_NUM_RE.search(name)
|
||||
base = "Тепловая сеть (по охранной зоне)"
|
||||
if cad:
|
||||
return f"{base}, кад. № {cad.group(0)}"
|
||||
return base
|
||||
|
||||
if net_kind == "electricity":
|
||||
vl = _VL_KV_RE.search(name)
|
||||
tp = _TP_RE.search(name)
|
||||
if vl:
|
||||
voltage = _fmt_voltage(vl.group(2))
|
||||
last_mile = _is_last_mile_voltage(vl.group(2))
|
||||
if tp:
|
||||
tp_num = tp.group(2)
|
||||
if last_mile:
|
||||
# ≤1 кВ — last-mile, подаём как второстепенный факт (уточнение в скобках).
|
||||
return f"Ввод от ТП {tp_num} (по охранной зоне, {voltage} кВ — last-mile)"
|
||||
return f"Ввод {voltage} кВ от ТП {tp_num} (по охранной зоне)"
|
||||
if last_mile:
|
||||
return f"Электроввод {voltage} кВ — last-mile (по охранной зоне)"
|
||||
return f"Ввод {voltage} кВ (по охранной зоне)"
|
||||
if tp:
|
||||
return f"Электросеть от ТП {tp.group(2)} (по охранной зоне)"
|
||||
return "Объект электросетевого хозяйства (по охранной зоне)"
|
||||
|
||||
if net_kind in ("gas", "pipeline"):
|
||||
cad = _CAD_NUM_RE.search(name)
|
||||
base = "Трубопровод (газо-/нефтепровод) по охранной зоне"
|
||||
if cad:
|
||||
return f"{base}, кад. № {cad.group(0)}"
|
||||
return base
|
||||
|
||||
if net_kind == "water":
|
||||
return "Сеть водоснабжения/водоотведения (по охранной зоне)"
|
||||
|
||||
if net_kind == "communication":
|
||||
return "Линия связи (по охранной зоне)"
|
||||
|
||||
if net_kind == "general":
|
||||
return "Инженерные коммуникации (вид не уточнён)"
|
||||
|
||||
return "Охранная зона"
|
||||
|
||||
|
||||
__all__ = ["human_label", "refine_net_kind"]
|
||||
253
backend/tests/test_network_zone_humanize.py
Normal file
253
backend/tests/test_network_zone_humanize.py
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
"""Unit-тесты гуманизации сетевых охранных зон §3 (#2119 C2/C3).
|
||||
|
||||
Pure / без БД. Покрывает:
|
||||
- refine_net_kind: type_zone-база + дочистка general по name_by_doc (тепло/газ/электро).
|
||||
- human_label: regex-кейсы (ВЛ 0,4 кВ, КЛ 10 кВ, теплосеть с кад.номером, газопровод,
|
||||
нераспознанное), last-mile-подача ≤1 кВ, извлечение ТП.
|
||||
- _shape_network_zones: сортировка по distance + лимит _NETWORK_ZONE_PER_KIND per-kind.
|
||||
|
||||
Строки name_by_doc — реальные образцы с прода (cad_zouit, 2026-07-03).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.services.site_finder import connection_capacity_lookup as ccl
|
||||
from app.services.site_finder.network_zone_humanize import human_label, refine_net_kind
|
||||
|
||||
# type_zone-константы (как в проде / SQL-белом списке).
|
||||
_TZ_THERMAL = "Охранная зона тепловых сетей"
|
||||
_TZ_GENERAL = "Охранная зона инженерных коммуникаций"
|
||||
_TZ_ELECTRIC = (
|
||||
"Охранная зона объектов электроэнергетики (объектов электросетевого хозяйства "
|
||||
"и объектов по производству электрической энергии)"
|
||||
)
|
||||
_TZ_PIPELINE = (
|
||||
"Охранная зона трубопроводов (газопроводов, нефтепроводов и "
|
||||
"нефтепродуктопроводов, аммиакопроводов)"
|
||||
)
|
||||
_TZ_COMMS = "Охранная зона линий и сооружений связи"
|
||||
|
||||
|
||||
# ── refine_net_kind: база по type_zone ────────────────────────────────────────
|
||||
|
||||
|
||||
def test_refine_thermal_type_zone_stays_thermal() -> None:
|
||||
"""Специфичный type_zone «тепловых сетей» → thermal независимо от name."""
|
||||
assert refine_net_kind(_TZ_THERMAL, "Тепловая сеть от ЦТП Баумана") == "thermal"
|
||||
|
||||
|
||||
def test_refine_electric_type_zone_stays_electricity() -> None:
|
||||
assert refine_net_kind(_TZ_ELECTRIC, "Охранная зона ВЛ 0,4кВ ТП 3164") == "electricity"
|
||||
|
||||
|
||||
def test_refine_pipeline_type_zone_stays_pipeline_or_gas() -> None:
|
||||
"""type_zone «трубопроводов (газопроводов…)» c газом в имени → gas."""
|
||||
assert refine_net_kind(_TZ_PIPELINE, "Охранная зона газопровода высокого давления") == "gas"
|
||||
|
||||
|
||||
def test_refine_comms_type_zone_stays_communication() -> None:
|
||||
assert refine_net_kind(_TZ_COMMS, "Охранная зона сооружения связи ООО «ВЕРТИКАЛЬ-УРАЛ»") == (
|
||||
"communication"
|
||||
)
|
||||
|
||||
|
||||
def test_refine_non_network_returns_none() -> None:
|
||||
assert refine_net_kind("Санитарно-защитная зона", "что угодно") is None
|
||||
assert refine_net_kind(None, None) is None
|
||||
|
||||
|
||||
# ── refine_net_kind: дочистка general по name_by_doc ───────────────────────────
|
||||
|
||||
|
||||
def test_refine_general_thermal_by_name() -> None:
|
||||
"""«Охранная зона инженерных коммуникаций» с теплосетью в name → thermal (прод-кейс)."""
|
||||
name = (
|
||||
"Охранная зона объекта теплоснабжения «Трубопровод (тепловые сети) "
|
||||
"с кадастровым номером 66:41:0108079:76"
|
||||
)
|
||||
assert refine_net_kind(_TZ_GENERAL, name) == "thermal"
|
||||
|
||||
|
||||
def test_refine_general_gas_by_name() -> None:
|
||||
"""general с газопроводом в name → gas (прод-кейс догазификации)."""
|
||||
name = (
|
||||
"Охранная зона газораспределительной сети в отношении объекта: Газопровод низкого давления"
|
||||
)
|
||||
assert refine_net_kind(_TZ_GENERAL, name) == "gas"
|
||||
|
||||
|
||||
def test_refine_general_electricity_by_vl_regex() -> None:
|
||||
"""general с «ВЛ 0,4 кВ» в name → electricity (даже без слова электросеть)."""
|
||||
assert refine_net_kind(_TZ_GENERAL, "Охранная зона ВЛ 0,4 кВ от ТП 71796") == "electricity"
|
||||
|
||||
|
||||
def test_refine_general_electricity_by_transformer() -> None:
|
||||
"""general «Трансформаторная подстанция» без «ВЛ N кВ» → electricity (прод-кейс)."""
|
||||
name = (
|
||||
'Охранная зона объекта "Трансформаторная подстанция", кадастровый номер 66:41:0404019:333'
|
||||
)
|
||||
assert refine_net_kind(_TZ_GENERAL, name) == "electricity"
|
||||
|
||||
|
||||
def test_refine_general_gas_distribution_not_electricity() -> None:
|
||||
"""«газораспределительн» → gas раньше, «распределительн»-электро не перехватывает."""
|
||||
assert refine_net_kind(_TZ_GENERAL, "Охранная зона газораспределительной сети") == "gas"
|
||||
|
||||
|
||||
def test_refine_general_unrecognized_stays_general() -> None:
|
||||
"""general без распознанного вида → остаётся general («вид не уточнён»)."""
|
||||
assert refine_net_kind(_TZ_GENERAL, "Охранная зона неизвестного объекта") == "general"
|
||||
assert refine_net_kind(_TZ_GENERAL, None) == "general"
|
||||
|
||||
|
||||
# ── human_label: thermal ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_label_thermal_with_cad_num() -> None:
|
||||
"""thermal с кад.номером трубопровода в name → подпись включает кад.№."""
|
||||
name = (
|
||||
"Охранная зона объекта теплоснабжения с кадастровым номером "
|
||||
"66:41:0106021:815 (Тепловая сеть)"
|
||||
)
|
||||
assert human_label("thermal", name) == (
|
||||
"Тепловая сеть (по охранной зоне), кад. № 66:41:0106021:815"
|
||||
)
|
||||
|
||||
|
||||
def test_label_thermal_without_cad_num() -> None:
|
||||
name = "Охранная зона объекта теплоснабжения «Тепловая сеть от ЦТП Баумана, 7а»"
|
||||
assert human_label("thermal", name) == "Тепловая сеть (по охранной зоне)"
|
||||
|
||||
|
||||
# ── human_label: electricity (ВЛ/КЛ N кВ + ТП) ─────────────────────────────────
|
||||
|
||||
|
||||
def test_label_electricity_vl_04kv_last_mile() -> None:
|
||||
"""ВЛ 0,4 кВ (≤1 кВ) — last-mile: напряжение как второстепенный факт, ТП — якорь."""
|
||||
name = "Охранная зона ВЛ 0,4кВ ТП 3164 руб.№16 от оп. 3 до уч. 66:41:0106128:17"
|
||||
assert human_label("electricity", name) == (
|
||||
"Ввод от ТП 3164 (по охранной зоне, 0,4 кВ — last-mile)"
|
||||
)
|
||||
|
||||
|
||||
def test_label_electricity_kl_10kv_magistral() -> None:
|
||||
"""КЛ 10 кВ (>1 кВ) — магистральный ввод: напряжение в основной подписи."""
|
||||
name = "Охранная зона КЛ 10 кВ от ТП 5521 до РП 12"
|
||||
assert human_label("electricity", name) == "Ввод 10 кВ от ТП 5521 (по охранной зоне)"
|
||||
|
||||
|
||||
def test_label_electricity_vl_6kv_with_ktpn() -> None:
|
||||
"""ВЛ 6 кВ + КТПН — берём первый якорный ТП/КТПН, напряжение магистральное."""
|
||||
name = (
|
||||
"Охранная зона Линейное ответвление от ВЛ 6 кВ ТП 3736-3747 "
|
||||
"с отпайками, ТП 32505 (КТПН п/№ 589)"
|
||||
)
|
||||
assert human_label("electricity", name) == "Ввод 6 кВ от ТП 3736-3747 (по охранной зоне)"
|
||||
|
||||
|
||||
def test_label_electricity_no_voltage_but_tp() -> None:
|
||||
"""Нет напряжения, но есть ТП → «Электросеть от ТП M»."""
|
||||
assert human_label("electricity", "Охранная зона объекта от ТП 999") == (
|
||||
"Электросеть от ТП 999 (по охранной зоне)"
|
||||
)
|
||||
|
||||
|
||||
def test_label_electricity_bare() -> None:
|
||||
"""Нет ни напряжения, ни ТП → generic электро-подпись."""
|
||||
assert human_label("electricity", "Охранная зона объекта электросетевого хозяйства") == (
|
||||
"Объект электросетевого хозяйства (по охранной зоне)"
|
||||
)
|
||||
|
||||
|
||||
# ── human_label: gas / pipeline / general / прочие ─────────────────────────────
|
||||
|
||||
|
||||
def test_label_gas_with_cad_num() -> None:
|
||||
name = "Охранная зона газопровода высокого давления, кадастровый номер 66:41:0110003:7983"
|
||||
assert human_label("gas", name) == (
|
||||
"Трубопровод (газо-/нефтепровод) по охранной зоне, кад. № 66:41:0110003:7983"
|
||||
)
|
||||
|
||||
|
||||
def test_label_pipeline_without_cad_num() -> None:
|
||||
assert human_label("pipeline", "Охранная зона нефтепровода") == (
|
||||
"Трубопровод (газо-/нефтепровод) по охранной зоне"
|
||||
)
|
||||
|
||||
|
||||
def test_label_general_unrecognized() -> None:
|
||||
assert human_label("general", "Охранная зона инженерных коммуникаций") == (
|
||||
"Инженерные коммуникации (вид не уточнён)"
|
||||
)
|
||||
|
||||
|
||||
def test_label_communication_and_water() -> None:
|
||||
assert human_label("communication", "Охранная зона сооружения связи") == (
|
||||
"Линия связи (по охранной зоне)"
|
||||
)
|
||||
assert human_label("water", "Охранная зона водопровода") == (
|
||||
"Сеть водоснабжения/водоотведения (по охранной зоне)"
|
||||
)
|
||||
|
||||
|
||||
def test_label_none_fallback() -> None:
|
||||
assert human_label(None, "что угодно") == "Охранная зона"
|
||||
|
||||
|
||||
# ── _shape_network_zones: сортировка + лимит per-kind ──────────────────────────
|
||||
|
||||
|
||||
def _row(type_zone: str, name: str, dist: int, reg: str = "reg") -> dict:
|
||||
"""Имитация .mappings()-строки cad_zouit (dict доступ по ключам)."""
|
||||
return {
|
||||
"type_zone": type_zone,
|
||||
"name_by_doc": name,
|
||||
"reg_numb_border": reg,
|
||||
"distance_m": dist,
|
||||
}
|
||||
|
||||
|
||||
def test_shape_limits_two_per_kind_sorted() -> None:
|
||||
"""До 2 ближайших НА net_kind; в пределах вида — по distance ASC."""
|
||||
rows = [
|
||||
_row(_TZ_THERMAL, "Тепловая сеть A", 200, "r1"),
|
||||
_row(_TZ_THERMAL, "Тепловая сеть B", 50, "r2"),
|
||||
_row(_TZ_THERMAL, "Тепловая сеть C", 120, "r3"), # третья thermal — отбрасывается
|
||||
_row(_TZ_ELECTRIC, "Охранная зона ВЛ 10 кВ ТП 5", 80, "r4"),
|
||||
]
|
||||
out = ccl._shape_network_zones(rows)
|
||||
thermal = [z for z in out if z["net_kind"] == "thermal"]
|
||||
electric = [z for z in out if z["net_kind"] == "electricity"]
|
||||
assert len(thermal) == 2 # лимит 2 из 3
|
||||
assert [z["distance_m"] for z in thermal] == [50, 120] # ближайшие две, отсортированы
|
||||
assert len(electric) == 1
|
||||
|
||||
|
||||
def test_shape_intersecting_zone_distance_zero() -> None:
|
||||
"""distance_m=0 (зона пересекает участок) сохраняется и идёт первой."""
|
||||
rows = [
|
||||
_row(_TZ_THERMAL, "Тепловая сеть далеко", 250, "r1"),
|
||||
_row(_TZ_GENERAL, "объект теплоснабжения кад. 66:41:0108079:76", 0, "r2"),
|
||||
]
|
||||
out = ccl._shape_network_zones(rows)
|
||||
assert out[0]["distance_m"] == 0
|
||||
# general с теплом в name дочистился до thermal.
|
||||
assert out[0]["net_kind"] == "thermal"
|
||||
|
||||
|
||||
def test_shape_outer_sort_by_distance_then_kind() -> None:
|
||||
"""Внешний порядок: distance ASC, затем net_kind (стабильно)."""
|
||||
rows = [
|
||||
_row(_TZ_ELECTRIC, "ВЛ 10 кВ ТП 1", 100, "r1"),
|
||||
_row(_TZ_THERMAL, "Тепловая сеть", 100, "r2"),
|
||||
_row(_TZ_GENERAL, "Газопровод низкого давления", 30, "r3"),
|
||||
]
|
||||
out = ccl._shape_network_zones(rows)
|
||||
assert out[0]["distance_m"] == 30 # gas ближе всех
|
||||
assert out[0]["net_kind"] == "gas"
|
||||
# при равном distance=100 — electricity раньше thermal (алфавит).
|
||||
assert [z["net_kind"] for z in out[1:]] == ["electricity", "thermal"]
|
||||
|
||||
|
||||
def test_shape_empty() -> None:
|
||||
assert ccl._shape_network_zones([]) == []
|
||||
|
|
@ -454,6 +454,10 @@ export interface paths {
|
|||
*
|
||||
* power_points — ЦП в радиусе от центроида участка (ST_DWithin geography).
|
||||
* water — все строки за ПОСЛЕДНИЙ период (город-уровень, показывается целиком).
|
||||
* nearby_network_zones — позитив-разрез (#2119 C2/C3): сетевые охранные зоны cad_zouit
|
||||
* в радиусе 300 м (где физически проходит сеть) — до 2 ближайших на net_kind,
|
||||
* гуманизированы; пересекающие участок отдаются с distance_m=0. Радиус 300 м —
|
||||
* независимый от radius_m ЦП.
|
||||
*
|
||||
* Query params:
|
||||
* radius_m: радиус поиска ЦП, 100–10000 м (default 3000).
|
||||
|
|
@ -3613,6 +3617,11 @@ export interface components {
|
|||
*/
|
||||
gas_outlet_points: components["schemas"]["GasOutletPoint"][];
|
||||
heat?: components["schemas"]["HeatCapacityBlock"] | null;
|
||||
/**
|
||||
* Nearby Network Zones
|
||||
* @default []
|
||||
*/
|
||||
nearby_network_zones: components["schemas"]["NearbyNetworkZone"][];
|
||||
};
|
||||
/** ConnectionPointsResponse */
|
||||
ConnectionPointsResponse: {
|
||||
|
|
@ -4634,6 +4643,23 @@ export interface components {
|
|||
teap: components["schemas"]["TEAP"];
|
||||
financial: components["schemas"]["FinancialModel"];
|
||||
};
|
||||
/**
|
||||
* NearbyNetworkZone
|
||||
* @description Сетевая охранная зона cad_zouit рядом с участком — позитив-сигнал §3. #2119 C2/C3.
|
||||
*
|
||||
* Охранная зона сети = коридор, где физически проходит сеть → «теплотрасса в 24 м,
|
||||
* врезка дешёвая». Зона, ПЕРЕСЕКАЮЩАЯ участок, отдаётся с distance_m=0.
|
||||
*/
|
||||
NearbyNetworkZone: {
|
||||
/** Net Kind */
|
||||
net_kind: string;
|
||||
/** Distance M */
|
||||
distance_m: number;
|
||||
/** Name By Doc */
|
||||
name_by_doc: string | null;
|
||||
/** Human Label */
|
||||
human_label: string;
|
||||
};
|
||||
/**
|
||||
* ObjectiveConfigUpdateRequest
|
||||
* @description PATCH-style — обновляет только переданные поля.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue