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
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:
parent
68429967ab
commit
e0323de7ee
3 changed files with 213 additions and 25 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue