feat(site-finder): §3 «Сети и точки подключения» — попапы показывают характеристики (#2111) #2112

Merged
bot-backend merged 1 commit from feat/networks-max-info into main 2026-06-30 13:16:08 +00:00
10 changed files with 353 additions and 7 deletions

View file

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

View file

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

View file

@ -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 В «<kV> кВ», иначе «<V> В». 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 → «⌀ <n> мм». Берём ПЕРВЫЙ числовой 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 {},
}
)

View file

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

View file

@ -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 цеп."

View file

@ -287,6 +287,11 @@ export function ConnectionPointsLayer({
Тип: {s.type}
</div>
)}
{s.purpose && (
<div style={{ color: "#6b7280", marginBottom: 2 }}>
Назначение: {s.purpose}
</div>
)}
{s.characteristics && (
<div style={{ color: "#6b7280", marginBottom: 2 }}>
{s.characteristics}
@ -297,6 +302,17 @@ export function ConnectionPointsLayer({
{s.readable_address}
</div>
)}
{s.cad_num && (
<div
style={{
color: "#9ca3af",
marginBottom: 2,
fontSize: 11,
}}
>
ЕГРН: {s.cad_num}
</div>
)}
<div style={{ marginTop: 4, color: "#374151" }}>
До границы участка:{" "}
<strong>

View file

@ -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 && (
<div style={{ color: "#6b7280", marginBottom: 2 }}>Тип: {detail}</div>
)}
{feature.characteristics && (
<div style={{ color: "#6b7280", marginBottom: 2 }}>
Характеристики: {feature.characteristics}
</div>
)}
<div style={{ marginTop: 4, color: "#374151" }}>
До границы участка:{" "}
<strong>{Math.round(feature.distance_m)} м</strong>

View file

@ -256,6 +256,25 @@ export function ZouitLayer({ grouped, visibleSeverities }: LayerProps) {
{overlap.name}
</div>
)}
{overlap.reg_numb_border && (
<div
style={{
color: "#374151",
marginBottom: 2,
fontFamily: "monospace",
}}
>
Рег. : {overlap.reg_numb_border}
</div>
)}
{typeof overlap.coverage_pct === "number" && (
<div style={{ color: "#374151", marginBottom: 2 }}>
Перекрытие участка:{" "}
<strong>
{Math.round(overlap.coverage_pct * 100)}%
</strong>
</div>
)}
{overlap.source && (
<div
style={{

View file

@ -3609,6 +3609,8 @@ export interface components {
type: string | null;
/** Cad Num */
cad_num: string | null;
/** Purpose */
purpose?: string | null;
/** Distance To Boundary M */
distance_to_boundary_m: number;
/** Geometry Geojson */
@ -5017,6 +5019,8 @@ export interface components {
geometry_geojson: {
[key: string]: unknown;
};
/** Characteristics */
characteristics?: string | null;
};
/** UtilityInfrastructureResponse */
UtilityInfrastructureResponse: {

View file

@ -104,6 +104,12 @@ export interface EngineeringStructure {
name: string | null;
type: string | null;
cad_num: string | null;
/**
* #2111 назначение / класс сооружения (напр. «1.1. Сооружения
* электроэнергетики»). Backend нуллит поле, когда оно дублирует type в попапе
* рендерим только при наличии. Optional + nullable: nspd-dump path может не отдавать.
*/
purpose?: string | null;
distance_to_boundary_m: number;
geometry_geojson: Record<string, unknown>;
readable_address: string | null;