feat(site-finder): §3 «Сети и точки подключения» — попапы показывают характеристики (#2111) #2112
10 changed files with 353 additions and 7 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 {},
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
137
backend/tests/services/test_utility_characteristics.py
Normal file
137
backend/tests/services/test_utility_characteristics.py
Normal 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 цеп."
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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={{
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue