From 53cfd3c3b8f11d3cd0da8409be3a1baa31b5d9b7 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Tue, 30 Jun 2026 11:34:29 +0000 Subject: [PATCH] =?UTF-8?q?feat(networks):=20=D1=87=D0=B5=D0=BB=D0=BE?= =?UTF-8?q?=D0=B2=D0=B5=D0=BA=D0=BE-=D1=8F=D0=B7=D1=8B=D0=BA=20=D1=82?= =?UTF-8?q?=D0=B8=D0=BF=D0=BE=D0=B2=20=C2=A73=20+=20=D1=80=D0=B5=D0=B0?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D1=8B=D0=B5=20=D0=B8=D0=BC=D0=B5=D0=BD=D0=B0?= =?UTF-8?q?=20=D0=9D=D0=A1=D0=9F=D0=94-=D1=82=D0=BE=D1=87=D0=B5=D0=BA=20(#?= =?UTF-8?q?1953)=20(#2107)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/schemas/parcel.py | 3 + .../site_finder/quarter_dump_lookup.py | 75 ++++++++++++++++++- .../services/test_quarter_dump_lookup.py | 38 ++++++++++ .../site-finder/ConnectionPointsLayer.tsx | 15 +++- .../UtilityInfrastructureLayer.tsx | 64 +++++++++++++--- .../UtilityInfrastructureLayer.test.ts | 39 ++++++++++ frontend/src/lib/api-types.ts | 2 + frontend/src/types/nspd.ts | 2 + 8 files changed, 221 insertions(+), 17 deletions(-) diff --git a/backend/app/schemas/parcel.py b/backend/app/schemas/parcel.py index ca78cb7e..d1758fb7 100644 --- a/backend/app/schemas/parcel.py +++ b/backend/app/schemas/parcel.py @@ -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 diff --git a/backend/app/services/site_finder/quarter_dump_lookup.py b/backend/app/services/site_finder/quarter_dump_lookup.py index dd493801..62c37ba0 100644 --- a/backend/app/services/site_finder/quarter_dump_lookup.py +++ b/backend/app/services/site_finder/quarter_dump_lookup.py @@ -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, diff --git a/backend/tests/services/test_quarter_dump_lookup.py b/backend/tests/services/test_quarter_dump_lookup.py index 43ef1aae..2ef57ba1 100644 --- a/backend/tests/services/test_quarter_dump_lookup.py +++ b/backend/tests/services/test_quarter_dump_lookup.py @@ -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 diff --git a/frontend/src/components/site-finder/ConnectionPointsLayer.tsx b/frontend/src/components/site-finder/ConnectionPointsLayer.tsx index 250b9fa0..0ddd5619 100644 --- a/frontend/src/components/site-finder/ConnectionPointsLayer.tsx +++ b/frontend/src/components/site-finder/ConnectionPointsLayer.tsx @@ -239,10 +239,12 @@ export function ConnectionPointsLayer({ weight: 2, }} > - {/* Попап точки подключения (НСПД). Рендерим только имеющиеся - атрибуты — категория, название/тип, адрес, расстояние до - границы, источник. Тех-характеристик (напряжение/диаметр) - в НСПД-снимке нет → таких полей тут нет (не выдумываем). */} + {/* Попап точки подключения (НСПД). Атрибуты из options дампа: + категория, название (params_name, напр. «КЛ 10кВ ТП…»), + назначение (params_purpose), характеристики (год / + протяжённость / собственность), адрес, расстояние, источник. + Отдельных полей напряжения/диаметра нет — если есть, они в + названии (params_name); не выдумываем. */}
)} + {s.characteristics && ( +
+ {s.characteristics} +
+ )} {s.readable_address && (
{s.readable_address} diff --git a/frontend/src/components/site-finder/UtilityInfrastructureLayer.tsx b/frontend/src/components/site-finder/UtilityInfrastructureLayer.tsx index 95b31461..1f22232a 100644 --- a/frontend/src/components/site-finder/UtilityInfrastructureLayer.tsx +++ b/frontend/src/components/site-finder/UtilityInfrastructureLayer.tsx @@ -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 = { + "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 ( diff --git a/frontend/src/components/site-finder/__tests__/UtilityInfrastructureLayer.test.ts b/frontend/src/components/site-finder/__tests__/UtilityInfrastructureLayer.test.ts index 769b21ca..e3f44ced 100644 --- a/frontend/src/components/site-finder/__tests__/UtilityInfrastructureLayer.test.ts +++ b/frontend/src/components/site-finder/__tests__/UtilityInfrastructureLayer.test.ts @@ -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(); + }); +}); diff --git a/frontend/src/lib/api-types.ts b/frontend/src/lib/api-types.ts index fa921bce..0b72d88c 100644 --- a/frontend/src/lib/api-types.ts +++ b/frontend/src/lib/api-types.ts @@ -3617,6 +3617,8 @@ export interface components { }; /** Readable Address */ readable_address: string | null; + /** Characteristics */ + characteristics?: string | null; /** Raw Props */ raw_props: { [key: string]: unknown; diff --git a/frontend/src/types/nspd.ts b/frontend/src/types/nspd.ts index 96223f7f..37307fff 100644 --- a/frontend/src/types/nspd.ts +++ b/frontend/src/types/nspd.ts @@ -107,6 +107,8 @@ export interface EngineeringStructure { distance_to_boundary_m: number; geometry_geojson: Record; readable_address: string | null; + /** Год / протяжённость / собственность из НСПД-дампа одной строкой. */ + characteristics?: string | null; raw_props: Record; source: string; }