feat(site-finder): позитив-разрез сетевых ЗОУИТ «сеть рядом с участком» (Фаза C2/C3) #2281

Merged
bot-backend merged 1 commit from feat/zouit-network-nearby into main 2026-07-03 08:18:25 +00:00
6 changed files with 620 additions and 1 deletions

View file

@ -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: радиус поиска ЦП, 10010000 м (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 [],
)

View file

@ -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) ───────────────────────────────

View file

@ -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]

View 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"]

View 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([]) == []

View file

@ -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: радиус поиска ЦП, 10010000 м (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 обновляет только переданные поля.