feat(networks): человеко-язык типов §3 + реальные имена НСПД-точек (#1953) #2107
8 changed files with 221 additions and 17 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3617,6 +3617,8 @@ export interface components {
|
|||
};
|
||||
/** Readable Address */
|
||||
readable_address: string | null;
|
||||
/** Characteristics */
|
||||
characteristics?: string | null;
|
||||
/** Raw Props */
|
||||
raw_props: {
|
||||
[key: string]: unknown;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue