This commit is contained in:
parent
4ffda3299c
commit
53cfd3c3b8
8 changed files with 221 additions and 17 deletions
|
|
@ -39,6 +39,9 @@ class EngineeringStructure(BaseModel):
|
||||||
distance_to_boundary_m: float
|
distance_to_boundary_m: float
|
||||||
geometry_geojson: dict[str, Any]
|
geometry_geojson: dict[str, Any]
|
||||||
readable_address: str | None
|
readable_address: str | None
|
||||||
|
# Доп. характеристики из НСПД-дампа (год постройки / длина / собственность),
|
||||||
|
# собранные в одну читаемую строку. None — данных нет. ADDITIVE (default None).
|
||||||
|
characteristics: str | None = None
|
||||||
raw_props: dict[str, Any]
|
raw_props: dict[str, Any]
|
||||||
source: str
|
source: str
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1300,14 +1300,43 @@ def _get_engineering_structures_by_boundary(
|
||||||
except (ValueError, json.JSONDecodeError):
|
except (ValueError, json.JSONDecodeError):
|
||||||
geom_dict = {}
|
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(
|
result.append(
|
||||||
{
|
{
|
||||||
"name": props.get("name") or props.get("object_name"),
|
"name": _clean_struct_str(name),
|
||||||
"type": props.get("purpose") or props.get("object_type") or props.get("type_zone"),
|
"type": _clean_struct_str(ptype),
|
||||||
"cad_num": props.get("cad_num") or props.get("cadastral_number"),
|
"cad_num": _clean_struct_str(cad),
|
||||||
"distance_to_boundary_m": round(distance_m, 1),
|
"distance_to_boundary_m": round(distance_m, 1),
|
||||||
"geometry_geojson": geom_dict,
|
"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,
|
"raw_props": props,
|
||||||
"source": "nspd_36328",
|
"source": "nspd_36328",
|
||||||
}
|
}
|
||||||
|
|
@ -1315,6 +1344,44 @@ def _get_engineering_structures_by_boundary(
|
||||||
return result
|
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(
|
def _get_zouit_engineering_overlaps(
|
||||||
db: Session,
|
db: Session,
|
||||||
quarter: str,
|
quarter: str,
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ import pytest
|
||||||
|
|
||||||
from app.services.site_finder.quarter_dump_lookup import (
|
from app.services.site_finder.quarter_dump_lookup import (
|
||||||
EMPTY_DUMP_RESULT,
|
EMPTY_DUMP_RESULT,
|
||||||
|
_build_structure_characteristics,
|
||||||
|
_clean_struct_str,
|
||||||
_extract_features_by_layer,
|
_extract_features_by_layer,
|
||||||
_get_cad_zouit_overlaps,
|
_get_cad_zouit_overlaps,
|
||||||
_get_opportunity_parcels,
|
_get_opportunity_parcels,
|
||||||
|
|
@ -743,3 +745,39 @@ def test_nspd_path_used_when_zouit_count_nonzero() -> None:
|
||||||
assert overlaps[0]["coverage_pct"] == 0.33
|
assert overlaps[0]["coverage_pct"] == 0.33
|
||||||
# dump.available=True т.к. свежий дамп
|
# dump.available=True т.к. свежий дамп
|
||||||
assert result["nspd_dump"]["available"] is 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,
|
weight: 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Попап точки подключения (НСПД). Рендерим только имеющиеся
|
{/* Попап точки подключения (НСПД). Атрибуты из options дампа:
|
||||||
атрибуты — категория, название/тип, адрес, расстояние до
|
категория, название (params_name, напр. «КЛ 10кВ ТП…»),
|
||||||
границы, источник. Тех-характеристик (напряжение/диаметр)
|
назначение (params_purpose), характеристики (год /
|
||||||
в НСПД-снимке нет → таких полей тут нет (не выдумываем). */}
|
протяжённость / собственность), адрес, расстояние, источник.
|
||||||
|
Отдельных полей напряжения/диаметра нет — если есть, они в
|
||||||
|
названии (params_name); не выдумываем. */}
|
||||||
<Popup>
|
<Popup>
|
||||||
<div
|
<div
|
||||||
style={{ fontSize: 12, lineHeight: 1.55, minWidth: 180 }}
|
style={{ fontSize: 12, lineHeight: 1.55, minWidth: 180 }}
|
||||||
|
|
@ -285,6 +287,11 @@ export function ConnectionPointsLayer({
|
||||||
Тип: {s.type}
|
Тип: {s.type}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{s.characteristics && (
|
||||||
|
<div style={{ color: "#6b7280", marginBottom: 2 }}>
|
||||||
|
{s.characteristics}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{s.readable_address && (
|
{s.readable_address && (
|
||||||
<div style={{ color: "#374151", marginBottom: 2 }}>
|
<div style={{ color: "#374151", marginBottom: 2 }}>
|
||||||
{s.readable_address}
|
{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
|
// Geometry parsing
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -171,10 +220,10 @@ export function groupUtilityByKind(
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Попап объекта инж. инфраструктуры OSM. Показываем ТОЛЬКО имеющиеся атрибуты —
|
* Попап объекта инж. инфраструктуры OSM. Показываем ТОЛЬКО имеющиеся атрибуты —
|
||||||
* вид (UTILITY_KIND_STYLES.label) + сырой infrastructure_kind / source_tag (то,
|
* вид (UTILITY_KIND_STYLES.label) + человеческую подпись типа (humanOsmType по
|
||||||
* что реально есть в osm_utility_infrastructure_ekb), название, расстояние до
|
* source_tag, без сырого «power=substation»), название, расстояние до границы
|
||||||
* границы участка, источник (OSM osm_type/osm_id). Напряжения/диаметра в OSM нет
|
* участка, источник (OSM osm_type/osm_id). Напряжения/диаметра в OSM нет —
|
||||||
* — соответствующих полей нет в контракте и тут не выдумываются.
|
* соответствующих полей нет в контракте и тут не выдумываются.
|
||||||
*/
|
*/
|
||||||
function UtilityPopup({
|
function UtilityPopup({
|
||||||
feature,
|
feature,
|
||||||
|
|
@ -183,11 +232,8 @@ function UtilityPopup({
|
||||||
feature: UtilityInfrastructureFeature;
|
feature: UtilityInfrastructureFeature;
|
||||||
style: UtilityKindStyle;
|
style: UtilityKindStyle;
|
||||||
}) {
|
}) {
|
||||||
// Сырой вид/тег OSM показываем как уточнение, если он добавляет информацию
|
// Человеческая подпись типа объекта (НЕ сырой тег «power=substation»).
|
||||||
// сверх человеческой подписи (не дублирует kind).
|
const detail = humanOsmType(feature.infrastructure_kind, feature.source_tag);
|
||||||
const rawKind = feature.infrastructure_kind?.trim() || null;
|
|
||||||
const tag = feature.source_tag?.trim() || null;
|
|
||||||
const detail = tag ?? rawKind;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popup>
|
<Popup>
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import type { UtilityInfrastructureFeature } from "@/hooks/useUtilityInfrastruct
|
||||||
import {
|
import {
|
||||||
classifyUtilityKind,
|
classifyUtilityKind,
|
||||||
groupUtilityByKind,
|
groupUtilityByKind,
|
||||||
|
humanOsmType,
|
||||||
parseUtilityGeometry,
|
parseUtilityGeometry,
|
||||||
} from "../UtilityInfrastructureLayer";
|
} from "../UtilityInfrastructureLayer";
|
||||||
|
|
||||||
|
|
@ -150,3 +151,41 @@ describe("groupUtilityByKind", () => {
|
||||||
expect(grouped.get("other")).toEqual([]);
|
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 */
|
||||||
readable_address: string | null;
|
readable_address: string | null;
|
||||||
|
/** Characteristics */
|
||||||
|
characteristics?: string | null;
|
||||||
/** Raw Props */
|
/** Raw Props */
|
||||||
raw_props: {
|
raw_props: {
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,8 @@ export interface EngineeringStructure {
|
||||||
distance_to_boundary_m: number;
|
distance_to_boundary_m: number;
|
||||||
geometry_geojson: Record<string, unknown>;
|
geometry_geojson: Record<string, unknown>;
|
||||||
readable_address: string | null;
|
readable_address: string | null;
|
||||||
|
/** Год / протяжённость / собственность из НСПД-дампа одной строкой. */
|
||||||
|
characteristics?: string | null;
|
||||||
raw_props: Record<string, unknown>;
|
raw_props: Record<string, unknown>;
|
||||||
source: string;
|
source: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue