From e0323de7ee1dfcb2882ec4de2f33b96820d854d8 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 3 Jul 2026 12:47:34 +0500 Subject: [PATCH] feat(site-finder): classify OSM heat pipelines into utility layer (#2119) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit man_made=pipeline теперь разводится по substance: gas/natural_gas/cng → gas, heat/hot_water/steam → heat (теплотрассы, ~135 way в ЕКБ — единственный гео-источник тепла для §3). Труба без известного substance по-прежнему скипается, gas-путь не тронут. Попап heat-сети показывает теплоноситель («горячая вода»/«пар»); kind=heat уже читается фронтом как «Теплотрасса». --- .../utility_infrastructure_loader.py | 109 ++++++++++++++---- .../services/test_utility_characteristics.py | 27 +++++ .../test_utility_infrastructure_loader.py | 102 +++++++++++++++- 3 files changed, 213 insertions(+), 25 deletions(-) diff --git a/backend/app/services/site_finder/utility_infrastructure_loader.py b/backend/app/services/site_finder/utility_infrastructure_loader.py index d292237f..d739e4f6 100644 --- a/backend/app/services/site_finder/utility_infrastructure_loader.py +++ b/backend/app/services/site_finder/utility_infrastructure_loader.py @@ -1,7 +1,7 @@ """Загрузчик OSM инженерных сетей («инженерные сети») из Overpass API (#1746). -No-B2B open-data путь: тянет ЛЭП / подстанции / трубопроводы (газ/вода) / ЦТП / -вышки связи через Overpass для bbox ЕКБ и UPSERT-ит в +No-B2B open-data путь: тянет ЛЭП / подстанции / трубопроводы (газ/вода/теплотрассы) / +ЦТП / вышки связи через Overpass для bbox ЕКБ и UPSERT-ит в ``osm_utility_infrastructure_ekb``. Живой тест дал 3081+ utility-элементов ЕКБ. Структура зеркалит ``noise_loader.py``: httpx-клиент с явным UA + таймаутом, @@ -73,8 +73,8 @@ _HEADERS = { # Описание запросов: (overpass_key, overpass_value, el_type, infrastructure_kind). # el_type: 'way' (out geom) | 'node' (out body) | 'nwr' (out geom, node+way). -# substance: для pipeline-веток газ/вода дополнительно фильтруем по substance, -# чтобы не смешивать водопровод с газопроводом — см. _SUBSTANCE_FILTER. +# substance: generic man_made=pipeline фильтруем+классифицируем по substance — +# газопровод vs теплотрасса, см. _SUBSTANCE_FILTER / _PIPELINE_SUBSTANCE_KIND. # Виды сети (infrastructure_kind): # power | water | gas | heat | communication | sewage _UTILITY_QUERIES: list[tuple[str, str, str, str]] = [ @@ -89,8 +89,13 @@ _UTILITY_QUERIES: list[tuple[str, str, str, str]] = [ ("pipeline", "water", "way", "water"), # gas — магистральные газопроводы (way), pipeline substance=gas ("pipeline", "gas", "way", "gas"), - ("man_made", "pipeline", "way", "gas"), # substance=gas фильтруется ниже - # heat — ЦТП / теплопункты (sparse — это нормально) + # generic man_made=pipeline тянет ВСЕ трубы; substance разводит их на + # gas / heat (см. _PIPELINE_SUBSTANCE_KIND). kind ниже — дефолтный для строки + # (gas), реальный вид переопределяется по substance в _upsert_elements. + ("man_made", "pipeline", "way", "gas"), + # heat — теплотрассы (man_made=pipeline substance∈{heat,hot_water,steam}, + # разводятся из строки выше) + ЦТП / теплопункты (man_made=heat_substation, + # в ЕКБ sparse/0 — это нормально; линии-теплотрассы = единственный гео-источник §3). ("man_made", "heat_substation", "nwr", "heat"), # communication — вышки связи (точка/площадка) tower:type=communication ("man_made", "tower", "nwr", "communication"), @@ -99,13 +104,46 @@ _UTILITY_QUERIES: list[tuple[str, str, str, str]] = [ ("man_made", "wastewater_plant", "nwr", "sewage"), ] -# Для generic man_made=pipeline нужно различать вещество по тегу `substance` -# (или legacy `type`): иначе под газ попадут все трубы. Ключ — (key, value), -# значение — ожидаемый substance. Элементы без совпадения substance пропускаем. -_SUBSTANCE_FILTER: dict[tuple[str, str], frozenset[str]] = { - ("man_made", "pipeline"): frozenset({"gas", "natural_gas", "cng"}), +# Для generic man_made=pipeline вещество (тег `substance`, или legacy `type`) +# определяет ВИД сети: газопровод (gas) vs теплотрасса (heat). Раньше брали только +# газ, остальное скипали — теплотрассы (единственный гео-источник §3, ~135 way в +# ЕКБ) терялись. Теперь substance → kind. Труба без известного substance +# по-прежнему пропускается (не под газ), чтобы нефтепроводы/химию не примешивать. +# gas — газоснабжение: gas / natural_gas / cng +# heat — теплоснабжение: heat / hot_water / steam +_PIPELINE_SUBSTANCE_KIND: dict[str, str] = { + "gas": "gas", + "natural_gas": "gas", + "cng": "gas", + "heat": "heat", + "hot_water": "heat", + "steam": "heat", } +# Substance-фильтр для generic man_made=pipeline: пропускаем трубу только если её +# substance ведёт в известный вид сети (_PIPELINE_SUBSTANCE_KIND). Ключ — (key, +# value), значение — допустимые substance. Собирается из маппинга выше, чтобы +# фильтр и классификация не разошлись. +_SUBSTANCE_FILTER: dict[tuple[str, str], frozenset[str]] = { + ("man_made", "pipeline"): frozenset(_PIPELINE_SUBSTANCE_KIND), +} + + +def _substance_of(tags: dict) -> str: + """Вещество трубы: тег `substance` (или legacy `type`), lower-case, «» если нет.""" + return (tags.get("substance") or tags.get("type") or "").lower() + + +def _pipeline_kind_from_substance(tags: dict, default_kind: str) -> str: + """Вид сети для generic man_made=pipeline по substance. + + substance∈{gas,natural_gas,cng} → gas; {heat,hot_water,steam} → heat. + Неизвестный/пустой substance → ``default_kind`` (труба уже прошла + _passes_tag_filters, до сюда без совпадения не доходит). + """ + return _PIPELINE_SUBSTANCE_KIND.get(_substance_of(tags), default_kind) + + # tower:type=communication — для man_made=tower оставляем только вышки связи # (иначе под communication попадут смотровые/водонапорные башни). _TOWER_TYPE_REQUIRED: dict[tuple[str, str], frozenset[str]] = { @@ -122,23 +160,23 @@ def _build_overpass_query(key: str, value: str, el_type: str) -> str: south, west, north, east = EKB_BBOX bbox = f"({south},{west},{north},{east})" if el_type == "nwr": - return f"[out:json][timeout:30];" f'nwr["{key}"="{value}"]{bbox};' f"out geom;" + return f'[out:json][timeout:30];nwr["{key}"="{value}"]{bbox};out geom;' if el_type == "way": - return f"[out:json][timeout:30];" f'way["{key}"="{value}"]{bbox};' f"out geom;" - return f"[out:json][timeout:30];" f'node["{key}"="{value}"]{bbox};' f"out body;" + return f'[out:json][timeout:30];way["{key}"="{value}"]{bbox};out geom;' + return f'[out:json][timeout:30];node["{key}"="{value}"]{bbox};out body;' def _passes_tag_filters(key: str, value: str, tags: dict) -> bool: """Доп-фильтр по тегам элемента (substance / tower:type). - Для generic ``man_made=pipeline`` оставляем только газовые трубы (substance), - для ``man_made=tower`` — только вышки связи (tower:type). Остальные ключи - проходят без фильтра. + Для generic ``man_made=pipeline`` оставляем только трубы с известным веществом + (газ или теплоноситель — см. _PIPELINE_SUBSTANCE_KIND); нефтепроводы/химию и + трубы без substance пропускаем. Для ``man_made=tower`` — только вышки связи + (tower:type). Остальные ключи проходят без фильтра. """ substance_ok = _SUBSTANCE_FILTER.get((key, value)) if substance_ok is not None: - substance = (tags.get("substance") or tags.get("type") or "").lower() - if substance not in substance_ok: + if _substance_of(tags) not in substance_ok: return False tower_ok = _TOWER_TYPE_REQUIRED.get((key, value)) if tower_ok is not None: @@ -331,6 +369,10 @@ def _upsert_elements(elements: list[dict]) -> dict[str, int]: osm_id: int = el["id"] osm_type: str = el["type"] # 'way' | 'node' kind: str = el.get("_infrastructure_kind", "power") + # generic man_made=pipeline: реальный вид (gas vs heat) по substance — + # одна overpass-строка тянет и газопроводы, и теплотрассы (#2119). + if (key, value) == ("man_made", "pipeline"): + kind = _pipeline_kind_from_substance(tags, kind) source_tag: str | None = el.get("_source_tag") name: str | None = tags.get("name") @@ -511,24 +553,49 @@ def _format_diameter(raw: str) -> str | None: return f"⌀ {m.group()} мм" if m else None +# Теплоноситель теплотрассы (OSM substance) → RU для попапа. «heat» — обобщённая +# тепловая сеть (без уточнения носителя) — подписи не даём (kind=heat уже читается +# фронтом как «Теплотрасса»), чтобы не дублировать. hot_water / steam — уточняют. +_HEAT_SUBSTANCE_RU: dict[str, str] = { + "hot_water": "горячая вода", + "steam": "пар", +} + + +def _heat_substance_label(tags: dict) -> str | None: + """Теплоноситель теплотрассы (hot_water→«горячая вода», steam→«пар»). + + None для substance=heat (обобщённо — не уточняем) или неизвестного — попап и + так знает, что это теплотрасса (kind=heat → «Теплотрасса» на фронте). + """ + return _HEAT_SUBSTANCE_RU.get(_substance_of(tags)) + + def _utility_characteristics(kind: str, tags: dict | None) -> str | None: """Одна гуманизированная RU-строка ключевых характеристик OSM-сети. Стиль совпадает с connection-point ``_build_structure_characteristics`` (quarter_dump_lookup): « · »-join непустых частей. Включает (при наличии): - напряжение (multi «/»), оператор, диаметр (⌀ мм), материал, кол-во цепей. - None — если ничего пригодного. + теплоноситель (для heat), напряжение (multi «/»), оператор, диаметр (⌀ мм), + материал, кол-во цепей. None — если ничего пригодного. Examples: power voltage=110000;10000 operator=«МРСК Урала» → «110 кВ/10 кВ · МРСК Урала» gas diameter=500mm material=steel → «⌀ 500 мм · сталь» + heat substance=hot_water diameter=200 → «горячая вода · ⌀ 200 мм» voltage=400 → «400 В»; voltage=yes → None """ if not isinstance(tags, dict): return None parts: list[str] = [] + # Теплоноситель — только для теплотрасс (kind=heat), первым: «горячая вода» / «пар». + if kind == "heat": + heat_label = _heat_substance_label(tags) + if heat_label: + parts.append(heat_label) + voltage = _clean_utility_tag(tags.get("voltage")) if voltage: vstr = _format_voltage(voltage) diff --git a/backend/tests/services/test_utility_characteristics.py b/backend/tests/services/test_utility_characteristics.py index 390bb885..4058c276 100644 --- a/backend/tests/services/test_utility_characteristics.py +++ b/backend/tests/services/test_utility_characteristics.py @@ -143,3 +143,30 @@ def test_characteristics_full_order() -> None: }, ) assert out == "10 кВ · Облкоммунэнерго · ⌀ 200 мм · медь · 2 цеп." + + +# ── теплотрассы (kind=heat, #2119) ──────────────────────────────────────────── + + +def test_characteristics_heat_hot_water_substance() -> None: + # Теплоноситель — первым: «горячая вода», затем диаметр. + out = _utility_characteristics("heat", {"substance": "hot_water", "diameter": "200"}) + assert out == "горячая вода · ⌀ 200 мм" + + +def test_characteristics_heat_steam_substance() -> None: + out = _utility_characteristics("heat", {"substance": "steam"}) + assert out == "пар" + + +def test_characteristics_heat_generic_substance_no_label() -> None: + # substance=heat (обобщённо) не уточняем — фронт и так покажет «Теплотрасса». + # Диаметр остаётся, теплоноситель-подписи нет. + assert _utility_characteristics("heat", {"substance": "heat", "diameter": "300"}) == "⌀ 300 мм" + # heat без каких-либо характеристик → None (одного вида довольно для попапа). + assert _utility_characteristics("heat", {"substance": "heat"}) is None + + +def test_characteristics_heat_substance_only_on_heat_kind() -> None: + # substance-подпись только для kind=heat; для gas/water теплоноситель не лезет. + assert _utility_characteristics("gas", {"substance": "hot_water"}) is None diff --git a/backend/tests/test_utility_infrastructure_loader.py b/backend/tests/test_utility_infrastructure_loader.py index 93bd32b6..1e354b85 100644 --- a/backend/tests/test_utility_infrastructure_loader.py +++ b/backend/tests/test_utility_infrastructure_loader.py @@ -82,6 +82,31 @@ def test_pipeline_legacy_type_tag_fallback() -> None: assert ul._passes_tag_filters("man_made", "pipeline", {"type": "gas"}) +def test_pipeline_heat_substances_pass_filter() -> None: + """man_made=pipeline substance∈{heat,hot_water,steam} проходит фильтр (теплотрасса, #2119).""" + for substance in ("heat", "hot_water", "steam"): + assert ul._passes_tag_filters("man_made", "pipeline", {"substance": substance}), substance + + +def test_pipeline_without_substance_still_skipped() -> None: + """man_made=pipeline без substance по-прежнему НЕ проходит (не примешиваем неизвестное).""" + assert not ul._passes_tag_filters("man_made", "pipeline", {}) + assert not ul._passes_tag_filters("man_made", "pipeline", {"name": "труба"}) + + +def test_pipeline_kind_from_substance_gas_and_heat() -> None: + """substance → вид сети: газ→gas, теплоноситель→heat, неизвестное→дефолт.""" + assert ul._pipeline_kind_from_substance({"substance": "gas"}, "gas") == "gas" + assert ul._pipeline_kind_from_substance({"substance": "natural_gas"}, "gas") == "gas" + assert ul._pipeline_kind_from_substance({"substance": "heat"}, "gas") == "heat" + assert ul._pipeline_kind_from_substance({"substance": "hot_water"}, "gas") == "heat" + assert ul._pipeline_kind_from_substance({"substance": "steam"}, "gas") == "heat" + # legacy type-тег тоже работает + assert ul._pipeline_kind_from_substance({"type": "hot_water"}, "gas") == "heat" + # неизвестное вещество (не должно доходить сюда после фильтра) → дефолт + assert ul._pipeline_kind_from_substance({}, "gas") == "gas" + + def test_tower_communication_passes() -> None: assert ul._passes_tag_filters("man_made", "tower", {"tower:type": "communication"}) @@ -248,6 +273,75 @@ def test_upsert_substance_filter_skips_non_gas_pipeline(monkeypatch: Any) -> Non assert result["kind_gas"] == 1 +def test_upsert_pipeline_hot_water_classified_heat(monkeypatch: Any) -> None: + """man_made=pipeline substance=hot_water → kind переопределён на heat (#2119). + + Query-строка man_made=pipeline несёт дефолтный kind=gas, но реальный вид + берётся из substance — теплотрасса должна лечь в kind_heat, не kind_gas. + """ + fake_db = _FakeDB() + monkeypatch.setattr(ul, "SessionLocal", lambda: fake_db) + + heat = _way(1, "gas", "man_made", "pipeline", _TWO_NODES) # kind=gas из query-строки + heat["tags"] = {"substance": "hot_water", "name": "Теплотрасса №5"} + + result = ul._upsert_elements([heat]) + assert result["inserted"] == 1 + assert result["skipped"] == 0 + assert result["kind_heat"] == 1 + assert "kind_gas" not in result # газом НЕ засчиталось + _, params = fake_db.executed[0] + assert params is not None + assert params["infrastructure_kind"] == "heat" + + +def test_upsert_pipeline_gas_path_unbroken(monkeypatch: Any) -> None: + """Регресс-гард: man_made=pipeline substance=gas по-прежнему → kind=gas.""" + fake_db = _FakeDB() + monkeypatch.setattr(ul, "SessionLocal", lambda: fake_db) + + gas = _way(1, "gas", "man_made", "pipeline", _TWO_NODES) + gas["tags"] = {"substance": "gas"} + + result = ul._upsert_elements([gas]) + assert result["inserted"] == 1 + assert result["kind_gas"] == 1 + assert "kind_heat" not in result + _, params = fake_db.executed[0] + assert params is not None + assert params["infrastructure_kind"] == "gas" + + +def test_upsert_pipeline_without_substance_skipped(monkeypatch: Any) -> None: + """man_made=pipeline без substance пропускается (текущее поведение сохранено, #2119).""" + fake_db = _FakeDB() + monkeypatch.setattr(ul, "SessionLocal", lambda: fake_db) + + bare = _way(1, "gas", "man_made", "pipeline", _TWO_NODES) + bare["tags"] = {"name": "неизвестная труба"} # substance отсутствует + + result = ul._upsert_elements([bare]) + assert result["skipped"] == 1 + assert result["inserted"] == 0 + assert len(fake_db.executed) == 0 # до UPSERT не дошло + + +def test_upsert_mixed_gas_and_heat_pipeline(monkeypatch: Any) -> None: + """Один overpass-батч man_made=pipeline: газопровод и теплотрасса разводятся по kind.""" + fake_db = _FakeDB() + monkeypatch.setattr(ul, "SessionLocal", lambda: fake_db) + + gas = _way(1, "gas", "man_made", "pipeline", _TWO_NODES) + gas["tags"] = {"substance": "gas"} + heat = _way(2, "gas", "man_made", "pipeline", _TWO_NODES) + heat["tags"] = {"substance": "steam"} + + result = ul._upsert_elements([gas, heat]) + assert result["inserted"] == 2 + assert result["kind_gas"] == 1 + assert result["kind_heat"] == 1 + + def test_upsert_savepoint_isolates_bad_element(monkeypatch: Any) -> None: """Битый элемент (execute бросает) → SAVEPOINT откатывает только его, батч жив.""" fake_db = _FakeDB(fail_ids={2}) @@ -338,7 +432,9 @@ class _FakeResponse: def raise_for_status(self) -> None: if self.status_code >= 400: raise httpx.HTTPStatusError( - f"HTTP {self.status_code}", request=None, response=None # type: ignore[arg-type] + f"HTTP {self.status_code}", + request=None, + response=None, # type: ignore[arg-type] ) @@ -435,9 +531,7 @@ async def test_fetch_504_then_mirror(monkeypatch: Any) -> None: _no_sleep(monkeypatch) primary = ul.OVERPASS_ENDPOINTS[0] mirror = ul.OVERPASS_ENDPOINTS[1] - client = _ScriptedClient( - {primary: [_FakeResponse(504)], mirror: [_FakeResponse(200, [_EL])]} - ) + client = _ScriptedClient({primary: [_FakeResponse(504)], mirror: [_FakeResponse(200, [_EL])]}) monkeypatch.setattr(ul.httpx, "AsyncClient", lambda **_kw: client) elements = await ul.fetch_overpass_utility() -- 2.45.3