feat(site-finder): classify OSM heat pipelines into utility layer (#2119)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI / changes (pull_request) Successful in 11s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 3m17s
CI / backend-tests (pull_request) Successful in 16m44s

man_made=pipeline теперь разводится по substance: gas/natural_gas/cng → gas,
heat/hot_water/steam → heat (теплотрассы, ~135 way в ЕКБ — единственный
гео-источник тепла для §3). Труба без известного substance по-прежнему
скипается, gas-путь не тронут. Попап heat-сети показывает теплоноситель
(«горячая вода»/«пар»); kind=heat уже читается фронтом как «Теплотрасса».
This commit is contained in:
bot-backend 2026-07-03 12:47:34 +05:00
parent 68429967ab
commit e0323de7ee
3 changed files with 213 additions and 25 deletions

View file

@ -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)

View file

@ -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

View file

@ -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()