From 5ae0fcc4f584a5c5ab797dd02e251a30aa40d698 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 3 Jul 2026 13:01:23 +0500 Subject: [PATCH] =?UTF-8?q?feat(site-finder):=20=D0=BF=D0=BE=D0=B7=D0=B8?= =?UTF-8?q?=D1=82=D0=B8=D0=B2-=D1=80=D0=B0=D0=B7=D1=80=D0=B5=D0=B7=20?= =?UTF-8?q?=D1=81=D0=B5=D1=82=D0=B5=D0=B2=D1=8B=D1=85=20=D0=97=D0=9E=D0=A3?= =?UTF-8?q?=D0=98=D0=A2=20=C2=AB=D1=81=D0=B5=D1=82=D1=8C=20=D1=80=D1=8F?= =?UTF-8?q?=D0=B4=D0=BE=D0=BC=20=D1=81=20=D1=83=D1=87=D0=B0=D1=81=D1=82?= =?UTF-8?q?=D0=BA=D0=BE=D0=BC=C2=BB=20(#2119=20C2/C3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Охранная зона инженерной сети = коридор, где физически проходит сеть → «теплотрасса в 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. --- backend/app/api/v1/parcels.py | 5 + backend/app/schemas/parcel.py | 21 ++ .../site_finder/connection_capacity_lookup.py | 148 +++++++++- .../site_finder/network_zone_humanize.py | 168 ++++++++++++ backend/tests/test_network_zone_humanize.py | 253 ++++++++++++++++++ frontend/src/lib/api-types.ts | 26 ++ 6 files changed, 620 insertions(+), 1 deletion(-) create mode 100644 backend/app/services/site_finder/network_zone_humanize.py create mode 100644 backend/tests/test_network_zone_humanize.py diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index a189c916..01179732 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -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 [], ) diff --git a/backend/app/schemas/parcel.py b/backend/app/schemas/parcel.py index c0492037..347a8fbc 100644 --- a/backend/app/schemas/parcel.py +++ b/backend/app/schemas/parcel.py @@ -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) ─────────────────────────────── diff --git a/backend/app/services/site_finder/connection_capacity_lookup.py b/backend/app/services/site_finder/connection_capacity_lookup.py index 5fd57e08..60e98369 100644 --- a/backend/app/services/site_finder/connection_capacity_lookup.py +++ b/backend/app/services/site_finder/connection_capacity_lookup.py @@ -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] diff --git a/backend/app/services/site_finder/network_zone_humanize.py b/backend/app/services/site_finder/network_zone_humanize.py new file mode 100644 index 00000000..515d692b --- /dev/null +++ b/backend/app/services/site_finder/network_zone_humanize.py @@ -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"] diff --git a/backend/tests/test_network_zone_humanize.py b/backend/tests/test_network_zone_humanize.py new file mode 100644 index 00000000..9bc17b73 --- /dev/null +++ b/backend/tests/test_network_zone_humanize.py @@ -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([]) == [] diff --git a/frontend/src/lib/api-types.ts b/frontend/src/lib/api-types.ts index 46de747e..3f3d4188 100644 --- a/frontend/src/lib/api-types.ts +++ b/frontend/src/lib/api-types.ts @@ -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 — обновляет только переданные поля. -- 2.45.3