From 6d6e0ce0ca3093ebf10e5c1db9a49c3399397e7a Mon Sep 17 00:00:00 2001 From: bot-backend Date: Tue, 30 Jun 2026 13:16:07 +0000 Subject: [PATCH] =?UTF-8?q?feat(site-finder):=20=C2=A73=20=C2=AB=D0=A1?= =?UTF-8?q?=D0=B5=D1=82=D0=B8=20=D0=B8=20=D1=82=D0=BE=D1=87=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B4=D0=BA=D0=BB=D1=8E=D1=87=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=C2=BB=20=E2=80=94=20=D0=BF=D0=BE=D0=BF=D0=B0=D0=BF=D1=8B?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=BA=D0=B0=D0=B7=D1=8B=D0=B2=D0=B0=D1=8E=D1=82?= =?UTF-8?q?=20=D1=85=D0=B0=D1=80=D0=B0=D0=BA=D1=82=D0=B5=D1=80=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D0=B8=D0=BA=D0=B8=20(#2111)=20(#2112)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/schemas/parcel.py | 8 + .../site_finder/quarter_dump_lookup.py | 10 +- .../utility_infrastructure_loader.py | 98 ++++++++++++- .../services/test_quarter_dump_lookup.py | 50 +++++++ .../services/test_utility_characteristics.py | 137 ++++++++++++++++++ .../site-finder/ConnectionPointsLayer.tsx | 16 ++ .../UtilityInfrastructureLayer.tsx | 12 +- .../src/components/site-finder/ZouitLayer.tsx | 19 +++ frontend/src/lib/api-types.ts | 4 + frontend/src/types/nspd.ts | 6 + 10 files changed, 353 insertions(+), 7 deletions(-) create mode 100644 backend/tests/services/test_utility_characteristics.py diff --git a/backend/app/schemas/parcel.py b/backend/app/schemas/parcel.py index d1758fb7..5f81c2d5 100644 --- a/backend/app/schemas/parcel.py +++ b/backend/app/schemas/parcel.py @@ -36,6 +36,10 @@ class EngineeringStructure(BaseModel): name: str | None type: str | None cad_num: str | None + # Назначение сооружения из НСПД-дампа (params_purpose / object_type_value), + # напр. «1.1. Сооружения электроэнергетики». Заполняется ТОЛЬКО когда отличается + # от `type` (иначе дубль-шум). ADDITIVE (default None). + purpose: str | None = None distance_to_boundary_m: float geometry_geojson: dict[str, Any] readable_address: str | None @@ -85,6 +89,10 @@ class UtilityInfrastructureFeature(BaseModel): source_tag: str | None distance_m: float geometry_geojson: dict[str, Any] + # Гуманизированные характеристики сети из OSM tags (напряжение / оператор / + # диаметр / материал), собранные в одну строку. None — данных нет. + # ADDITIVE (default None) → не ломает существующих потребителей. + characteristics: str | None = None class UtilityInfrastructureSummary(BaseModel): diff --git a/backend/app/services/site_finder/quarter_dump_lookup.py b/backend/app/services/site_finder/quarter_dump_lookup.py index 62c37ba0..561ab858 100644 --- a/backend/app/services/site_finder/quarter_dump_lookup.py +++ b/backend/app/services/site_finder/quarter_dump_lookup.py @@ -1328,11 +1328,19 @@ def _get_engineering_structures_by_boundary( or props.get("readable_address") or props.get("address") ) + clean_type = _clean_struct_str(ptype) + # purpose («1.1. Сооружения электроэнергетики») — первый непустой из + # params_purpose / object_type_value. Показываем ТОЛЬКО когда отличается от + # уже вычисленного type (иначе дубль-шум в попапе → оставляем None). + purpose_raw = opts.get("params_purpose") or opts.get("object_type_value") + clean_purpose = _clean_struct_str(purpose_raw) + purpose = clean_purpose if clean_purpose and clean_purpose != clean_type else None result.append( { "name": _clean_struct_str(name), - "type": _clean_struct_str(ptype), + "type": clean_type, "cad_num": _clean_struct_str(cad), + "purpose": purpose, "distance_to_boundary_m": round(distance_m, 1), "geometry_geojson": geom_dict, "readable_address": _clean_struct_str(address), diff --git a/backend/app/services/site_finder/utility_infrastructure_loader.py b/backend/app/services/site_finder/utility_infrastructure_loader.py index 62463100..ab1ef205 100644 --- a/backend/app/services/site_finder/utility_infrastructure_loader.py +++ b/backend/app/services/site_finder/utility_infrastructure_loader.py @@ -22,6 +22,7 @@ import asyncio import json import logging import random +import re import httpx from sqlalchemy import text @@ -178,9 +179,7 @@ def _backoff_delay(attempt_idx: int, retry_after: float | None) -> float: return base + random.uniform(0.0, _RETRY_JITTER_SECONDS) -async def _fetch_one_query( - client: httpx.AsyncClient, query: str, label: str -) -> list[dict] | None: +async def _fetch_one_query(client: httpx.AsyncClient, query: str, label: str) -> list[dict] | None: """Один Overpass-запрос с retry-backoff и фолбэком на зеркала (#1746). На каждом endpoint из ``OVERPASS_ENDPOINTS`` делаем до @@ -443,6 +442,97 @@ async def load_utility_infrastructure(db: Session | None = None) -> dict[str, in return _upsert_elements(elements) +# Junk-значения OSM tags, которые не несут смысла для попапа. +_JUNK_TAG_VALUES = frozenset({"", "yes", "no", "fixme", "unknown", "-"}) + + +def _clean_utility_tag(value: object) -> str | None: + """Нормализовать значение OSM tag: strip, None для пустого / junk.""" + if value is None: + return None + s = str(value).strip() + return s if s and s.lower() not in _JUNK_TAG_VALUES else None + + +def _format_voltage(raw: str) -> str | None: + """OSM voltage (вольты, возможно multi «110000;10000») → «110 кВ/10 кВ». + + ≥1000 В → « кВ», иначе « В». Multi-токены через «;» join'ятся «/». + Нечисловые токены пропускаются. None — если ни одного валидного. + """ + parts: list[str] = [] + for token in raw.split(";"): + tok = token.strip() + if not tok: + continue + try: + volts = int(float(tok)) + except (TypeError, ValueError): + continue # нечисловой мусор — пропускаем + if volts >= 1000: + kv = volts / 1000 + kv_str = f"{kv:g}" # 110.0 → «110», 0.4 → «0.4» + parts.append(f"{kv_str} кВ") + else: + parts.append(f"{volts} В") + return "/".join(parts) if parts else None + + +def _format_diameter(raw: str) -> str | None: + """Трубопровод diameter → «⌀ мм». Берём ПЕРВЫЙ числовой run (отбрасываем + суффиксы «mm»/«мм» и не склеиваем мульти-размеры «500x300» → «500»). None — если + цифр нет. + """ + m = re.search(r"\d+", raw) + return f"⌀ {m.group()} мм" if m else None + + +def _utility_characteristics(kind: str, tags: dict | None) -> str | None: + """Одна гуманизированная RU-строка ключевых характеристик OSM-сети. + + Стиль совпадает с connection-point ``_build_structure_characteristics`` + (quarter_dump_lookup): « · »-join непустых частей. Включает (при наличии): + напряжение (multi «/»), оператор, диаметр (⌀ мм), материал, кол-во цепей. + None — если ничего пригодного. + + Examples: + power voltage=110000;10000 operator=«МРСК Урала» → + «110 кВ/10 кВ · МРСК Урала» + gas diameter=500mm material=steel → «⌀ 500 мм · steel» + voltage=400 → «400 В»; voltage=yes → None + """ + if not isinstance(tags, dict): + return None + parts: list[str] = [] + + voltage = _clean_utility_tag(tags.get("voltage")) + if voltage: + vstr = _format_voltage(voltage) + if vstr: + parts.append(vstr) + + operator = _clean_utility_tag(tags.get("operator")) + if operator: + parts.append(operator) + + diameter = _clean_utility_tag(tags.get("diameter")) + if diameter: + dstr = _format_diameter(diameter) + if dstr: + parts.append(dstr) + + material = _clean_utility_tag(tags.get("material")) + if material: + parts.append(material) + + # Кол-во цепей/кабелей ЛЭП — только если значение чисто числовое. + circuits = _clean_utility_tag(tags.get("circuits")) or _clean_utility_tag(tags.get("cables")) + if circuits and circuits.isdigit(): + parts.append(f"{circuits} цеп.") + + return " · ".join(parts) if parts else None + + def get_nearby_utility_infrastructure( db: Session, cad_num: str, @@ -480,6 +570,7 @@ def get_nearby_utility_infrastructure( db.execute( text(""" SELECT osm_id, osm_type, infrastructure_kind, name, source_tag, + u.tags AS tags, ST_Distance( u.geom::geography, ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography @@ -519,6 +610,7 @@ def get_nearby_utility_infrastructure( "name": r["name"], "source_tag": r["source_tag"], "distance_m": dist, + "characteristics": _utility_characteristics(kind, r["tags"]), "geometry_geojson": json.loads(r["geojson"]) if r["geojson"] else {}, } ) diff --git a/backend/tests/services/test_quarter_dump_lookup.py b/backend/tests/services/test_quarter_dump_lookup.py index 2ef57ba1..7fb28da1 100644 --- a/backend/tests/services/test_quarter_dump_lookup.py +++ b/backend/tests/services/test_quarter_dump_lookup.py @@ -23,6 +23,7 @@ from app.services.site_finder.quarter_dump_lookup import ( _clean_struct_str, _extract_features_by_layer, _get_cad_zouit_overlaps, + _get_engineering_structures_by_boundary, _get_opportunity_parcels, _get_red_lines, _get_risk_zones, @@ -781,3 +782,52 @@ def test_build_structure_characteristics_partial_and_empty() -> None: is None ) assert _build_structure_characteristics({}) is None + + +# ── #2111: engineering-structure purpose (dedup-vs-type) ─────────────────────── + + +def _eng_structures(opts: dict[str, Any]) -> list[dict[str, Any]]: + """Прогнать _get_engineering_structures_by_boundary на одной строке с данными opts.""" + geom = '{"type":"Point","coordinates":[0,0]}' + db = _make_mock_db_with_rows([({"options": opts}, geom, 12.3)]) + return _get_engineering_structures_by_boundary( + db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))", 500 + ) + + +def test_engineering_purpose_set_when_differs_from_type() -> None: + # params_name → type; params_purpose отличается → purpose проброшен. + result = _eng_structures( + { + "params_name": "КЛ 10кВ ТП 1077", + "params_purpose": "1.1. Сооружения электроэнергетики", + "object_type_value": "Сооружение", + } + ) + assert len(result) == 1 + s = result[0] + # type берётся из params_purpose (первый в fallback-цепочке ptype). + assert s["type"] == "1.1. Сооружения электроэнергетики" + # purpose == type → дедуп → None (purpose первым тоже params_purpose). + assert s["purpose"] is None + + +def test_engineering_purpose_dedup_when_equal_to_type() -> None: + # Нет params_name → type = params_purpose; purpose тоже = params_purpose → дедуп None. + result = _eng_structures({"params_purpose": "Сооружение", "object_type_value": "Сооружение"}) + assert result[0]["type"] == "Сооружение" + assert result[0]["purpose"] is None + + +def test_engineering_purpose_from_object_type_value_distinct() -> None: + # Нет params_purpose → type = object_type_value; purpose тоже object_type_value → дедуп. + result = _eng_structures({"params_name": "ВЛ-110", "object_type_value": "Сооружение"}) + s = result[0] + assert s["type"] == "Сооружение" # object_type_value (params_purpose отсутствует) + assert s["purpose"] is None # purpose == type → дедуп + + +def test_engineering_purpose_none_when_no_options() -> None: + result = _eng_structures({}) + assert result[0]["purpose"] is None diff --git a/backend/tests/services/test_utility_characteristics.py b/backend/tests/services/test_utility_characteristics.py new file mode 100644 index 00000000..13dc2d81 --- /dev/null +++ b/backend/tests/services/test_utility_characteristics.py @@ -0,0 +1,137 @@ +"""Тесты для _utility_characteristics (#2111 §3 «Сети и точки подключения»). + +Покрывает гуманизацию OSM tags инженерных сетей в одну RU-строку для попапов: +напряжение (multi/кВ/В), оператор, диаметр трубопровода, материал, цепи, фильтр +junk-значений. Хелпер module-private — импортируем напрямую. +""" + +from __future__ import annotations + +from app.services.site_finder.utility_infrastructure_loader import ( + _format_diameter, + _format_voltage, + _utility_characteristics, +) + +# ── _format_voltage ─────────────────────────────────────────────────────────── + + +def test_format_voltage_kv_single() -> None: + assert _format_voltage("110000") == "110 кВ" + + +def test_format_voltage_volts_below_1000() -> None: + assert _format_voltage("400") == "400 В" + + +def test_format_voltage_multi_mixed() -> None: + # «110000;10000» → оба ≥1000 → кВ, join через «/». + assert _format_voltage("110000;10000") == "110 кВ/10 кВ" + + +def test_format_voltage_multi_kv_and_volts() -> None: + assert _format_voltage("10000;400") == "10 кВ/400 В" + + +def test_format_voltage_fractional_kv() -> None: + # 400 В → «400 В»; 6600 → «6.6 кВ» (без хвостового нуля). + assert _format_voltage("6600") == "6.6 кВ" + + +def test_format_voltage_skips_non_numeric() -> None: + assert _format_voltage("110000;junk;10000") == "110 кВ/10 кВ" + + +def test_format_voltage_all_junk_returns_none() -> None: + assert _format_voltage("abc;;def") is None + + +# ── _format_diameter ────────────────────────────────────────────────────────── + + +def test_format_diameter_plain_digits() -> None: + assert _format_diameter("500") == "⌀ 500 мм" + + +def test_format_diameter_strips_mm_suffix() -> None: + assert _format_diameter("500mm") == "⌀ 500 мм" + assert _format_diameter("300 мм") == "⌀ 300 мм" + + +def test_format_diameter_no_digits_returns_none() -> None: + assert _format_diameter("large") is None + + +def test_format_diameter_multidim_takes_first_run() -> None: + # «500x300» (прямоугольный короб) → берём первый числовой run, не склеиваем. + assert _format_diameter("500x300") == "⌀ 500 мм" + assert _format_diameter("DN500") == "⌀ 500 мм" + + +# ── _utility_characteristics ────────────────────────────────────────────────── + + +def test_characteristics_power_voltage_and_operator() -> None: + out = _utility_characteristics("power", {"voltage": "110000;10000", "operator": "МРСК Урала"}) + assert out == "110 кВ/10 кВ · МРСК Урала" + + +def test_characteristics_pipeline_diameter_and_material() -> None: + out = _utility_characteristics("gas", {"diameter": "500mm", "material": "steel"}) + assert out == "⌀ 500 мм · steel" + + +def test_characteristics_single_voltage_volts() -> None: + assert _utility_characteristics("power", {"voltage": "400"}) == "400 В" + + +def test_characteristics_circuits_when_numeric() -> None: + out = _utility_characteristics("power", {"voltage": "10000", "circuits": "2"}) + assert out == "10 кВ · 2 цеп." + + +def test_characteristics_cables_fallback_for_circuits() -> None: + out = _utility_characteristics("power", {"cables": "3"}) + assert out == "3 цеп." + + +def test_characteristics_non_numeric_circuits_skipped() -> None: + # circuits=«single» не число → пропускаем; остаётся только напряжение. + assert _utility_characteristics("power", {"voltage": "400", "circuits": "single"}) == "400 В" + + +def test_characteristics_junk_voltage_filtered() -> None: + # voltage=yes (junk) → пропуск; ничего больше → None. + assert _utility_characteristics("power", {"voltage": "yes"}) is None + + +def test_characteristics_filters_empty_and_no() -> None: + assert _utility_characteristics("water", {"operator": " ", "material": "no"}) is None + + +def test_characteristics_none_tags_returns_none() -> None: + assert _utility_characteristics("power", None) is None + + +def test_characteristics_empty_dict_returns_none() -> None: + assert _utility_characteristics("power", {}) is None + + +def test_characteristics_not_a_dict_returns_none() -> None: + # Защитный guard: tags из БД мог прийти не dict (broken JSONB) → None, не падаем. + assert _utility_characteristics("power", "not-a-dict") is None # type: ignore[arg-type] + + +def test_characteristics_full_order() -> None: + # Порядок частей: напряжение · оператор · диаметр · материал · цепи. + out = _utility_characteristics( + "power", + { + "voltage": "10000", + "operator": "Облкоммунэнерго", + "diameter": "200", + "material": "медь", + "circuits": "2", + }, + ) + assert out == "10 кВ · Облкоммунэнерго · ⌀ 200 мм · медь · 2 цеп." diff --git a/frontend/src/components/site-finder/ConnectionPointsLayer.tsx b/frontend/src/components/site-finder/ConnectionPointsLayer.tsx index 0ddd5619..df405b84 100644 --- a/frontend/src/components/site-finder/ConnectionPointsLayer.tsx +++ b/frontend/src/components/site-finder/ConnectionPointsLayer.tsx @@ -287,6 +287,11 @@ export function ConnectionPointsLayer({ Тип: {s.type} )} + {s.purpose && ( +
+ Назначение: {s.purpose} +
+ )} {s.characteristics && (
{s.characteristics} @@ -297,6 +302,17 @@ export function ConnectionPointsLayer({ {s.readable_address}
)} + {s.cad_num && ( +
+ № ЕГРН: {s.cad_num} +
+ )}
До границы участка:{" "} diff --git a/frontend/src/components/site-finder/UtilityInfrastructureLayer.tsx b/frontend/src/components/site-finder/UtilityInfrastructureLayer.tsx index 1f22232a..8d5db393 100644 --- a/frontend/src/components/site-finder/UtilityInfrastructureLayer.tsx +++ b/frontend/src/components/site-finder/UtilityInfrastructureLayer.tsx @@ -221,9 +221,10 @@ export function groupUtilityByKind( /** * Попап объекта инж. инфраструктуры OSM. Показываем ТОЛЬКО имеющиеся атрибуты — * вид (UTILITY_KIND_STYLES.label) + человеческую подпись типа (humanOsmType по - * source_tag, без сырого «power=substation»), название, расстояние до границы - * участка, источник (OSM osm_type/osm_id). Напряжения/диаметра в OSM нет — - * соответствующих полей нет в контракте и тут не выдумываются. + * source_tag, без сырого «power=substation»), название, характеристики + * (#2111 — pre-humanized строка с бэка: напряжение/диаметр/материал/оператор, + * напр. «110 кВ/10 кВ · МРСК Урала», только при наличии), расстояние до границы + * участка, источник (OSM osm_type/osm_id). */ function UtilityPopup({ feature, @@ -270,6 +271,11 @@ function UtilityPopup({ {detail && (
Тип: {detail}
)} + {feature.characteristics && ( +
+ Характеристики: {feature.characteristics} +
+ )}
До границы участка:{" "} {Math.round(feature.distance_m)} м diff --git a/frontend/src/components/site-finder/ZouitLayer.tsx b/frontend/src/components/site-finder/ZouitLayer.tsx index 44344e92..706bdd13 100644 --- a/frontend/src/components/site-finder/ZouitLayer.tsx +++ b/frontend/src/components/site-finder/ZouitLayer.tsx @@ -256,6 +256,25 @@ export function ZouitLayer({ grouped, visibleSeverities }: LayerProps) { {overlap.name}
)} + {overlap.reg_numb_border && ( +
+ Рег. №: {overlap.reg_numb_border} +
+ )} + {typeof overlap.coverage_pct === "number" && ( +
+ Перекрытие участка:{" "} + + {Math.round(overlap.coverage_pct * 100)}% + +
+ )} {overlap.source && (
; readable_address: string | null;