feat(networks): человеко-язык типов на карте §3 + реальные имена НСПД-точек (#1953)
All checks were successful
CI / changes (pull_request) Successful in 8s
CI / frontend-tests (pull_request) Successful in 1m17s
CI / openapi-codegen-check (pull_request) Successful in 2m14s
CI / backend-tests (pull_request) Successful in 14m41s

Round-3 task ONE (был разобран, но не внедрён — попап показывал «шляпу»).

OSM-попап: вместо сырого «Тип: power=substation» — человеческая подпись
(humanOsmType по source_tag: power=substation→Электроподстанция, power=tower→
Опора ЛЭП, power=line→ЛЭП, man_made=water_tower→Водонапорная башня и т.д.;
pipeline/tower уточняются по виду ресурса). Неизвестный тег → строку «Тип» не
показываем (без сырого кода).

НСПД-точки подключения: реальные атрибуты дампа cat 36328 лежат в
props["options"] (params_name с напряжением/типом «КЛ 10кВ ТП…», params_purpose,
object_type_value, адрес, год/протяжённость/собственность), а
_get_engineering_structures_by_boundary читал НЕсуществующие top-level ключи →
попап показывал «Объект». Теперь читаем options.* + собираем строку
характеристик (год · протяжённость · собственность). EngineeringStructure
получает additive-поле characteristics (api-types регенерирован, @/types/nspd
зеркалит). НСПД-попап показывает имя/назначение/характеристики/адрес.

Тесты: humanOsmType (5 кейсов) + _clean_struct_str/_build_structure_characteristics (3).
This commit is contained in:
Light1YT 2026-06-30 16:18:33 +05:00
parent fe85cbb7ae
commit 40b041b379
8 changed files with 221 additions and 17 deletions

View file

@ -39,6 +39,9 @@ class EngineeringStructure(BaseModel):
distance_to_boundary_m: float
geometry_geojson: dict[str, Any]
readable_address: str | None
# Доп. характеристики из НСПД-дампа (год постройки / длина / собственность),
# собранные в одну читаемую строку. None — данных нет. ADDITIVE (default None).
characteristics: str | None = None
raw_props: dict[str, Any]
source: str

View file

@ -1300,14 +1300,43 @@ def _get_engineering_structures_by_boundary(
except (ValueError, json.JSONDecodeError):
geom_dict = {}
# Реальные атрибуты НСПД-дампа (cat 36328) лежат в props["options"]:
# params_name — имя с типом/напряжением («КЛ 10кВ ТП 1077-ТП 5419»)
# params_purpose — назначение («1.1. Сооружения электроэнергетики»)
# object_type_value — «Сооружение»
# address_readable_address, cad_number, год/протяжённость/собственность
# Top-level props их НЕ содержит (только опаковые category/subcategory),
# поэтому раньше попап показывал generic «Объект». Читаем options.* с
# fallback на top-level/legacy ключи.
opts: dict[str, Any] = props["options"] if isinstance(props.get("options"), dict) else {}
name = (
opts.get("params_name")
or props.get("name")
or props.get("object_name")
or opts.get("object_type_value")
)
ptype = (
opts.get("params_purpose")
or opts.get("object_type_value")
or props.get("purpose")
or props.get("object_type")
or props.get("type_zone")
)
cad = opts.get("cad_number") or props.get("cad_num") or props.get("cadastral_number")
address = (
opts.get("address_readable_address")
or props.get("readable_address")
or props.get("address")
)
result.append(
{
"name": props.get("name") or props.get("object_name"),
"type": props.get("purpose") or props.get("object_type") or props.get("type_zone"),
"cad_num": props.get("cad_num") or props.get("cadastral_number"),
"name": _clean_struct_str(name),
"type": _clean_struct_str(ptype),
"cad_num": _clean_struct_str(cad),
"distance_to_boundary_m": round(distance_m, 1),
"geometry_geojson": geom_dict,
"readable_address": props.get("readable_address") or props.get("address"),
"readable_address": _clean_struct_str(address),
"characteristics": _build_structure_characteristics(opts),
"raw_props": props,
"source": "nspd_36328",
}
@ -1315,6 +1344,44 @@ def _get_engineering_structures_by_boundary(
return result
def _clean_struct_str(value: Any) -> str | None:
"""Нормализовать строковый атрибут НСПД: strip, None для пустого / «-»."""
if value is None:
return None
s = str(value).strip()
return s if s and s != "-" else None
def _build_structure_characteristics(opts: dict[str, Any]) -> str | None:
"""Собрать читаемую строку характеристик из options НСПД-дампа (cat 36328).
Год постройки · протяжённость · форма собственности то, что реально есть в
дампе. Чисто презентационная агрегация; None, если ничего нет.
"""
if not isinstance(opts, dict):
return None
parts: list[str] = []
year = _clean_struct_str(opts.get("params_year_built"))
if year:
parts.append(f"{year} г.")
ext = opts.get("params_extension")
try:
ext_val = float(ext) if ext not in (None, "") else 0.0
except (TypeError, ValueError):
ext_val = 0.0
if ext_val > 0:
parts.append(f"протяжённость {round(ext_val)} м")
ownership = _clean_struct_str(opts.get("ownership_type"))
if ownership:
low = ownership.lower()
parts.append(
f"{low} собственность"
if low in ("частная", "государственная", "муниципальная", "смешанная")
else ownership
)
return " · ".join(parts) if parts else None
def _get_zouit_engineering_overlaps(
db: Session,
quarter: str,

View file

@ -19,6 +19,8 @@ import pytest
from app.services.site_finder.quarter_dump_lookup import (
EMPTY_DUMP_RESULT,
_build_structure_characteristics,
_clean_struct_str,
_extract_features_by_layer,
_get_cad_zouit_overlaps,
_get_opportunity_parcels,
@ -743,3 +745,39 @@ def test_nspd_path_used_when_zouit_count_nonzero() -> None:
assert overlaps[0]["coverage_pct"] == 0.33
# dump.available=True т.к. свежий дамп
assert result["nspd_dump"]["available"] is True
# ── Engineering-structure options.* extraction (cat 36328, network popups) ──────
def test_clean_struct_str_normalizes_and_drops_empty() -> None:
assert _clean_struct_str(" КЛ 10кВ ") == "КЛ 10кВ"
assert _clean_struct_str("-") is None
assert _clean_struct_str("") is None
assert _clean_struct_str(None) is None
assert _clean_struct_str(2006) == "2006"
def test_build_structure_characteristics_full() -> None:
# Год · протяжённость · собственность из options НСПД-дампа.
opts = {
"params_year_built": "2006",
"params_extension": 192,
"ownership_type": "Частная",
}
assert (
_build_structure_characteristics(opts)
== "2006 г. · протяжённость 192 м · частная собственность"
)
def test_build_structure_characteristics_partial_and_empty() -> None:
# Только год → одна часть; нулевая протяжённость и пустые поля отбрасываются.
assert _build_structure_characteristics({"params_year_built": "1971"}) == "1971 г."
assert (
_build_structure_characteristics(
{"params_extension": 0, "ownership_type": "-", "params_year_built": ""}
)
is None
)
assert _build_structure_characteristics({}) is None

View file

@ -239,10 +239,12 @@ export function ConnectionPointsLayer({
weight: 2,
}}
>
{/* Попап точки подключения (НСПД). Рендерим только имеющиеся
атрибуты категория, название/тип, адрес, расстояние до
границы, источник. Тех-характеристик (напряжение/диаметр)
в НСПД-снимке нет таких полей тут нет (не выдумываем). */}
{/* Попап точки подключения (НСПД). Атрибуты из options дампа:
категория, название (params_name, напр. «КЛ 10кВ ТП»),
назначение (params_purpose), характеристики (год /
протяжённость / собственность), адрес, расстояние, источник.
Отдельных полей напряжения/диаметра нет если есть, они в
названии (params_name); не выдумываем. */}
<Popup>
<div
style={{ fontSize: 12, lineHeight: 1.55, minWidth: 180 }}
@ -285,6 +287,11 @@ export function ConnectionPointsLayer({
Тип: {s.type}
</div>
)}
{s.characteristics && (
<div style={{ color: "#6b7280", marginBottom: 2 }}>
{s.characteristics}
</div>
)}
{s.readable_address && (
<div style={{ color: "#374151", marginBottom: 2 }}>
{s.readable_address}

View file

@ -82,6 +82,55 @@ export function classifyUtilityKind(
}
}
// ---------------------------------------------------------------------------
// Human-readable OSM type labels (#1746 follow-up)
// ---------------------------------------------------------------------------
//
// Попап раньше показывал сырой OSM-тег («Тип: power=substation») — нечитаемо.
// Маппим source_tag → человеческую подпись. Покрывает реальный вокабуляр
// osm_utility_infrastructure_ekb (power=substation/tower/line/minor_line,
// man_made=water_tower/water_works/wastewater_plant; pipeline/tower уточняются
// по виду ресурса). Неизвестный тег → null (строку «Тип» не показываем — лучше,
// чем сырой код или дубль вида).
const OSM_TYPE_LABELS: Record<string, string> = {
"power=substation": "Электроподстанция",
"power=tower": "Опора ЛЭП",
"power=line": "ЛЭП (высоковольтная линия)",
"power=minor_line": "Распределительная ЛЭП",
"power=pole": "Опора электросети",
"power=portal": "Портал ЛЭП",
"man_made=water_tower": "Водонапорная башня",
"man_made=water_works": "Водопроводная станция (водозабор)",
"man_made=wastewater_plant": "Очистные сооружения",
"man_made=storage_tank": "Резервуар",
};
/**
* Человеческая подпись типа OSM-объекта по сырому тегу + виду ресурса.
* pipeline/tower контекстны (зависят от kind). Неизвестный тег null.
*/
export function humanOsmType(
rawKind: string | null | undefined,
sourceTag: string | null | undefined,
): string | null {
const tag = sourceTag?.trim() || null;
if (!tag) return null;
const kind = classifyUtilityKind(rawKind);
if (tag.endsWith("=pipeline")) {
if (kind === "gas") return "Газопровод";
if (kind === "water") return "Водопровод";
if (kind === "heat") return "Теплотрасса";
if (kind === "sewage") return "Канализационный коллектор";
return "Трубопровод";
}
if (tag === "man_made=tower") {
return kind === "communication" ? "Вышка / мачта связи" : "Башня / вышка";
}
return OSM_TYPE_LABELS[tag] ?? null;
}
// ---------------------------------------------------------------------------
// Geometry parsing
// ---------------------------------------------------------------------------
@ -171,10 +220,10 @@ export function groupUtilityByKind(
/**
* Попап объекта инж. инфраструктуры OSM. Показываем ТОЛЬКО имеющиеся атрибуты
* вид (UTILITY_KIND_STYLES.label) + сырой infrastructure_kind / source_tag (то,
* что реально есть в osm_utility_infrastructure_ekb), название, расстояние до
* границы участка, источник (OSM osm_type/osm_id). Напряжения/диаметра в OSM нет
* соответствующих полей нет в контракте и тут не выдумываются.
* вид (UTILITY_KIND_STYLES.label) + человеческую подпись типа (humanOsmType по
* source_tag, без сырого «power=substation»), название, расстояние до границы
* участка, источник (OSM osm_type/osm_id). Напряжения/диаметра в OSM нет
* соответствующих полей нет в контракте и тут не выдумываются.
*/
function UtilityPopup({
feature,
@ -183,11 +232,8 @@ function UtilityPopup({
feature: UtilityInfrastructureFeature;
style: UtilityKindStyle;
}) {
// Сырой вид/тег OSM показываем как уточнение, если он добавляет информацию
// сверх человеческой подписи (не дублирует kind).
const rawKind = feature.infrastructure_kind?.trim() || null;
const tag = feature.source_tag?.trim() || null;
const detail = tag ?? rawKind;
// Человеческая подпись типа объекта (НЕ сырой тег «power=substation»).
const detail = humanOsmType(feature.infrastructure_kind, feature.source_tag);
return (
<Popup>

View file

@ -12,6 +12,7 @@ import type { UtilityInfrastructureFeature } from "@/hooks/useUtilityInfrastruct
import {
classifyUtilityKind,
groupUtilityByKind,
humanOsmType,
parseUtilityGeometry,
} from "../UtilityInfrastructureLayer";
@ -150,3 +151,41 @@ describe("groupUtilityByKind", () => {
expect(grouped.get("other")).toEqual([]);
});
});
describe("humanOsmType", () => {
it("maps known power tags to human labels", () => {
expect(humanOsmType("power", "power=substation")).toBe("Электроподстанция");
expect(humanOsmType("power", "power=tower")).toBe("Опора ЛЭП");
expect(humanOsmType("power", "power=line")).toBe(
"ЛЭП (высоковольтная линия)",
);
});
it("resolves pipeline by resource kind", () => {
expect(humanOsmType("gas", "man_made=pipeline")).toBe("Газопровод");
expect(humanOsmType("water", "man_made=pipeline")).toBe("Водопровод");
expect(humanOsmType("other", "man_made=pipeline")).toBe("Трубопровод");
});
it("resolves man_made=tower by kind (communication vs generic)", () => {
expect(humanOsmType("communication", "man_made=tower")).toBe(
"Вышка / мачта связи",
);
expect(humanOsmType("other", "man_made=tower")).toBe("Башня / вышка");
});
it("maps water man_made tags", () => {
expect(humanOsmType("water", "man_made=water_tower")).toBe(
"Водонапорная башня",
);
expect(humanOsmType("sewage", "man_made=wastewater_plant")).toBe(
"Очистные сооружения",
);
});
it("returns null for empty / unknown tags (no raw code shown)", () => {
expect(humanOsmType("power", null)).toBeNull();
expect(humanOsmType("power", " ")).toBeNull();
expect(humanOsmType("power", "power=unknown_thing")).toBeNull();
});
});

View file

@ -3617,6 +3617,8 @@ export interface components {
};
/** Readable Address */
readable_address: string | null;
/** Characteristics */
characteristics?: string | null;
/** Raw Props */
raw_props: {
[key: string]: unknown;

View file

@ -107,6 +107,8 @@ export interface EngineeringStructure {
distance_to_boundary_m: number;
geometry_geojson: Record<string, unknown>;
readable_address: string | null;
/** Год / протяжённость / собственность из НСПД-дампа одной строкой. */
characteristics?: string | null;
raw_props: Record<string, unknown>;
source: string;
}