fix(ptica): шумовой скорер перестаёт считать водоёмы источниками шума (#2464-G)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-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 2m4s
CI / backend-tests (pull_request) Successful in 16m57s

Запрос шума брал из osm_noise_sources_ekb ВСЕ типы. В таблице лежат и нешумовые
слои — water (870 строк) и utility (1487), их отдельно читает блок гидрологии.
Для скорера они мусор: ключа в NOISE_L_BASE нет, поэтому .get(key, 50.0) выдавал
им ровно 50 дБ — значение, совпадающее с порогом попадания в список источников.

Замер 19.08 на 1000 участков (детерминированная выборка по cad_num): 19755
занятых слотов LIMIT 30, из них 8797 (44.5%) — вода и коммуникации; 562 участка
теряли из-за этого хотя бы один настоящий источник.

Честно про эффект: сегодня пользователь этого почти не видит. Все вытесненные
источники оказались тише порога 50 дБ (участков, теряющих ВИДИМЫЙ источник: 0 из
729), максимум дБ не меняется ни у одного. Видимый дефект — только водоём ближе
10 м, попадающий в список как источник на 50 дБ. Правка убирает не столько
видимую поломку, сколько скрытый потолок: почти половина бюджета LIMIT уходила
на строки, которые скорер не умеет оценивать.

Отдельный запрос на покрытие шумовой карты — не педантизм, а условие
корректности. _compute_confidence спрашивает «загружена ли карта» (при нуле пишет
«Шумовая карта не загружена — noise score = stub»), а у 345 участков из 1000 в
радиусе 2 км нет НИ ОДНОГО шумового источника при наличии воды. Наивный вариант
фикса (фильтр + len(noise_rows)) соврал бы этой трети. До правки верный ответ там
получался случайно — ровно потому, что в счёт шли нешумовые строки. Параметр
переименован в noise_map_rows_nearby, чтобы смысл не приходилось угадывать.

Тест на эту ловушку зелёный по обе стороны, но краснеет на наивном варианте —
проверено подстановкой.

Мок в test_analyze_parcel_meta.py переведён с позиционной нумерации db.execute на
матчинг по сигнатуре SQL: добавление любого запроса выше по коду ломало его
(следы в истории — #29 G2, #2464 cluster B), давая красный CI без отношения к
предмету теста. Тот же приём уже применён в test_analyze_market_price.py.

Переформатирование assert-сообщения — от pre-commit ruff 0.7.4 (#2864), не моё.
This commit is contained in:
bot-backend 2026-08-19 17:08:40 +05:00
parent 3c9505c8b0
commit 959246a6ec
3 changed files with 241 additions and 64 deletions

View file

@ -1084,7 +1084,7 @@ def _compute_confidence(
poi_rows: list[dict[str, Any]],
district_row: dict[str, Any] | None,
competitor_rows: list[dict[str, Any]],
noise_sources_count: int,
noise_map_rows_nearby: int,
air_q: dict[str, Any] | None,
weather: dict[str, Any] | None,
market_trend: dict[str, Any] | None,
@ -1164,9 +1164,14 @@ def _compute_confidence(
caveats.append("Нет конкурентов-ЖК в 3км — низкая урбанизация / окраина")
# 6) Environmental data freshness
env_ok = sum([bool(noise_sources_count > 0), bool(air_q), bool(weather)])
# #2464-G: считаем строки шумовой КАРТЫ в радиусе (любого типа, включая
# water/utility), а не отфильтрованные источники для скоринга. Вопрос здесь —
# «есть ли у нас данные по этой точке», и ноль означает непокрытие карты.
# Отфильтрованный список дал бы 0 у трети участков, где рядом просто тихо, и
# оговорка ниже утверждала бы неправду.
env_ok = sum([bool(noise_map_rows_nearby > 0), bool(air_q), bool(weather)])
subscores["environment"] = env_ok / 3.0
if noise_sources_count == 0:
if noise_map_rows_nearby == 0:
caveats.append("Шумовая карта не загружена — noise score = stub")
if not air_q:
caveats.append("Air Quality API недоступен — exposure unknown")
@ -2531,7 +2536,24 @@ def analyze_parcel(
}
)
# 7) Noise score — шумовые источники в радиусе 2 км
# 7) Noise score — шумовые источники в радиусе 2 км.
#
# #2464-G: фильтр по source_type обязателен. Таблица osm_noise_sources_ekb
# держит и НЕшумовые слои — 'water' (870 строк) и 'utility' (1 487), их
# отдельно читает гидрология в 9c ниже. Для скорера они мусор: ключа в
# NOISE_L_BASE у них нет, поэтому `.get(key, 50.0)` выдавал им ровно 50 дБ —
# значение, совпадающее с порогом попадания в список источников.
#
# Главное — `LIMIT 30` берётся ПО БЛИЗОСТИ, поэтому вода вытесняла настоящие
# источники. Замер 19.08 на 1 000 участков (детерминированная выборка по
# cad_num): 19 755 занятых слотов, из них 8 797 (44.5%) — вода и коммуникации;
# 562 участка теряли хотя бы один настоящий источник.
#
# Честно про эффект: сегодня пользователь этого не видит — все вытесненные
# источники оказались тише порога 50 дБ (участков, теряющих ВИДИМЫЙ источник:
# 0 из 729), и максимум дБ не меняется ни у одного. Правка убирает не видимую
# поломку, а скрытый потолок: почти половина бюджета LIMIT уходила на строки,
# которые скорер не умеет оценивать.
noise_rows = (
db.execute(
text("""
@ -2541,7 +2563,8 @@ def analyze_parcel(
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
) AS distance_m
FROM osm_noise_sources_ekb n
WHERE ST_DWithin(
WHERE n.source_type IN ('highway', 'railway', 'industrial', 'aerodrome')
AND ST_DWithin(
n.geom::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
2000
@ -2555,6 +2578,30 @@ def analyze_parcel(
.all()
)
# Покрытие шумовой карты — ОТДЕЛЬНО от списка источников, и это не педантизм.
# _compute_confidence спрашивает «загружена ли шумовая карта», а не «шумно ли
# тут»: при нуле она пишет «Шумовая карта не загружена — noise score = stub».
# У 345 участков из 1 000 в радиусе 2 км нет НИ ОДНОГО шумового источника, но
# вода/коммуникации есть. Передай туда len(noise_rows) после фильтра — и треть
# участков получит утверждение о незагруженной карте, которое неверно: карта
# загружена, просто рядом тихо. До этой правки верный ответ получался
# случайно — ровно потому, что в счёт шли и нешумовые строки.
noise_map_rows_nearby: int = (
db.execute(
text("""
SELECT COUNT(*)
FROM osm_noise_sources_ekb n
WHERE ST_DWithin(
n.geom::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
2000
)
"""),
{"wkt": geom_wkt},
).scalar()
or 0
)
noise_db_max = 0.0
nearby_noise_sources: list[dict[str, Any]] = []
for nr in noise_rows:
@ -3802,7 +3849,7 @@ def analyze_parcel(
poi_rows=[dict(p) for p in poi_rows],
district_row=dict(district_row) if district_row else None,
competitor_rows=[dict(c) for c in competitor_rows],
noise_sources_count=len(noise_rows),
noise_map_rows_nearby=noise_map_rows_nearby,
air_q=air_q,
weather=weather,
market_trend=market_trend,

View file

@ -0,0 +1,156 @@
"""#2464-G: запрос шумовых источников обязан отсеивать нешумовые слои.
`osm_noise_sources_ekb` держит не только источники шума. Замер на проде 19.08:
highway 5 616 есть в NOISE_L_BASE
railway 1 994 есть в NOISE_L_BASE
industrial 585 есть в NOISE_L_BASE
utility 1 487 -> дефолт 50 дБ
water 870 -> дефолт 50 дБ
`water`/`utility` предмет отдельного блока гидрологии (9c), для шумового скорера
это мусор: ключа в NOISE_L_BASE у них нет, и `.get(key, 50.0)` выдаёт им ровно 50 дБ,
что совпадает с порогом попадания в список источников. При этом `LIMIT 30` берётся
ПО БЛИЗОСТИ, поэтому вода вытесняла настоящие источники: 8 797 занятых слотов из
19 755 (44.5%) на выборке в 1 000 участков.
Ловушка, ради которой написан второй тест
Наивная правка просто добавить фильтр и оставить `noise_sources_count=len(noise_rows)`
ломает треть участков. `_compute_confidence` спрашивает «загружена ли шумовая карта»
(при нуле пишет «Шумовая карта не загружена noise score = stub»), а у 345 участков
из 1 000 в радиусе 2 км нет НИ ОДНОГО шумового источника при наличии воды. После
наивной правки они получили бы утверждение о незагруженной карте неверное.
До правки верный ответ там выходил СЛУЧАЙНО: в счёт шли и нешумовые строки.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock
from fastapi.testclient import TestClient
from app.main import app
from tests.api.v1.test_analyze_market_price import (
_GEOJSON,
_WKT,
_make_mapping,
_override_db,
_start_patches,
_stop_patches,
)
_CAD = "66:41:0204016:10"
# Строки «шумовой» таблицы вокруг участка: настоящих источников нет, есть вода
# вплотную (10 м → base 50 дБ 20·log10(1) = ровно 50, порог списка) и коммуникации.
_TABLE_ROWS = [
{"source_type": "water", "road_class": None, "name": "Пруд", "distance_m": 10.0},
{"source_type": "utility", "road_class": None, "name": "ЛЭП", "distance_m": 900.0},
]
def _make_db(rows: list[dict[str, Any]]) -> MagicMock:
"""Mock Session, ведущий себя как БД: применяет предикат source_type запроса.
Мок здесь стоит вместо Postgres, поэтому обязан УВАЖАТЬ WHERE иначе тест
проверял бы не фильтрацию, а то, что мы отдали в мок.
"""
db = MagicMock()
def _execute(*args: Any, **kwargs: Any) -> MagicMock:
sql = " ".join(str(args[0]).split()) if args else ""
first_val: Any = None
all_val: list[Any] = []
scalar_val: Any = 0
if "AS geom_geojson" in sql:
first_val = _make_mapping(
{"geom_geojson": _GEOJSON, "geom_wkb": None, "source": "cad_quarter"}
)
elif "AS wkt" in sql:
first_val = _make_mapping({"wkt": _WKT})
elif "AS ekb_reference_median" in sql and "district_name" in sql:
first_val = _make_mapping(
{
"district_name": "Октябрьский",
"ekb_reference_median": 120000,
"quarter_median_12m": None,
"quarter_deals_count": 0,
"dist_to_center": 1500.0,
}
)
elif "AS lon" in sql and "AS lat" in sql:
first_val = _make_mapping({"lat": 56.84, "lon": 60.605})
elif "osm_noise_sources_ekb" in sql:
visible = rows
# Уважаем предикат так же, как это сделал бы Postgres.
if "source_type IN (" in sql:
visible = [r for r in rows if r["source_type"] not in ("water", "utility")]
elif "source_type = 'water'" in sql:
visible = [r for r in rows if r["source_type"] == "water"]
elif "source_type = 'industrial'" in sql:
visible = [r for r in rows if r["source_type"] == "industrial"]
if "COUNT(*)" in sql:
scalar_val = len(visible)
else:
all_val = [_make_mapping(r) for r in visible]
r = MagicMock()
r.mappings.return_value.first.return_value = first_val
r.mappings.return_value.all.return_value = all_val
r.scalar.return_value = scalar_val
return r
db.execute.side_effect = _execute
ctx = MagicMock()
ctx.__enter__ = MagicMock(return_value=ctx)
ctx.__exit__ = MagicMock(return_value=False)
db.begin_nested.return_value = ctx
return db
def _analyze(rows: list[dict[str, Any]]) -> dict[str, Any]:
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(_make_db(rows))
_start_patches()
try:
resp = TestClient(app).post(f"/api/v1/parcels/{_CAD}/analyze")
assert resp.status_code == 200, resp.text
return resp.json()
finally:
app.dependency_overrides.clear()
_stop_patches()
def test_water_not_reported_as_noise_source() -> None:
"""Пруд в 10 м не должен числиться источником шума.
На main фильтра нет, водоём получает дефолтные 50 дБ (= порог) и попадает в
список источников с именем «Пруд».
"""
body = _analyze(_TABLE_ROWS)
noise = body.get("noise") or {}
sources = noise.get("nearby_sources") or noise.get("sources") or []
types = {s.get("source_type") for s in sources}
assert (
"water" not in types and "utility" not in types
), f"нешумовой слой попал в источники шума: {sources}"
def test_no_false_map_not_loaded_caveat_when_only_water_nearby() -> None:
"""Ловушка наивной правки: карта ЗАГРУЖЕНА, рядом просто тихо.
Зелёный и на main, и с правкой но красный, если считать покрытие карты по
отфильтрованному списку источников. Так себя вёл бы «очевидный» вариант фикса,
и он соврал бы 345 участкам из 1 000.
"""
body = _analyze(_TABLE_ROWS)
caveats = " ".join(body.get("confidence_caveats") or [])
assert "Шумовая карта не загружена" not in caveats, (
"оговорка о незагруженной карте при загруженной карте: "
"покрытие посчитано по отфильтрованному списку, а не по строкам карты"
)

View file

@ -7,26 +7,14 @@
Стратегия mock: аналогична test_analyze_market_price.py DB mock через
dependency_overrides, тяжёлые сервисы патчим через unittest.mock.patch.
Порядок db.execute calls в analyze_parcel (с #29 G2, #2464 cluster B):
0. UNION ALL geom + source .mappings().first()
1. WKT query .mappings().first()
2. District .mappings().first()
3. POI rows .mappings().all()
4. Competitor rows .mappings().all()
5. competitors_total honest COUNT(*) .scalar() NEW #2464 cluster B
6. Pipeline rows .mappings().all()
7. Centroid lat/lon .mappings().first()
8. Noise rows .mappings().all()
9. Hydrology .mappings().all()
10. Utilities .mappings().all()
11. parcel_meta (cad_parcels) .mappings().first() NEW #29 G2
12. Market trend .mappings().first()
13. Zoning (begin_nested) .mappings().first()
14. Success recommendation (begin_nested) .mappings().all()
15. Market price (begin_nested) .mappings().first()
16. Recent permits (begin_nested) .mappings().all()
17. _geotech_risk (industrial count) .scalar()
18. _neighbors_summary (single statement, neighbors+overlap+total) .mappings().first()
Диспетчеризация мока ПО СИГНАТУРЕ SQL, а не по порядковому номеру вызова.
Позиционный вариант ломался при каждом добавлении запроса в analyze_parcel
(следы в истории: #29 G2, #2464 cluster B, затем #2464-G с запросом покрытия
шумовой карты). Такое падение говорит не о parcel_meta, ради которого написан
файл, а о том, что кто-то добавил блок выше по коду красный CI без отношения
к предмету теста. Матчинг по сигнатуре к порядку нечувствителен; всё, что не
распознано, отдаёт пустой результат (обработчик это переживает прочие блоки
обёрнуты в try/except SAVEPOINT).
"""
from __future__ import annotations
@ -75,46 +63,32 @@ def _make_db_for_analyze(
pm_mock = _make_mapping(parcel_meta_row) if parcel_meta_row is not None else None
call_idx = [0]
responses: list[Any] = [
("first", geom_row), # 0: geom UNION ALL
("first", wkt_row), # 1: WKT
("first", district_row), # 2: district
("all", []), # 3: POI rows
("all", []), # 4: competitor rows
("scalar", 0), # 5: competitors_total honest COUNT(*) ← NEW #2464 cluster B
("all", []), # 6: pipeline rows
("first", centroid_row), # 7: centroid
("all", []), # 8: noise rows
("all", []), # 9: hydrology rows
("all", []), # 10: utilities rows
("first", pm_mock), # 11: parcel_meta ← #29 G2
("first", None), # 12: market trend
("first", None), # 13: zoning (begin_nested)
("all", []), # 14: success recommendation (begin_nested)
("first", None), # 15: market price (begin_nested)
("all", []), # 16: recent permits (begin_nested)
("scalar", 0), # 17: geotech_risk
# 18: _neighbors_summary — ОДИН statement (neighbors + overlap + total через
# json_agg + neighbors_total CTE, PR #1130 / #2464 cluster B) → .mappings().first()
# возвращает ОДНУ строку с этими тремя ключами.
("first", {"neighbors": [], "overlap_rows": [], "neighbors_total_count": 0}),
]
def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock:
idx = call_idx[0]
call_idx[0] += 1
if idx >= len(responses):
r = MagicMock()
r.mappings.return_value.first.return_value = None
r.mappings.return_value.all.return_value = []
r.scalar.return_value = 0
return r
kind, data = responses[idx]
sql = " ".join(str(args[0]).split()) if args else ""
first_val: Any = None
all_val: list[Any] = []
if "AS geom_geojson" in sql:
first_val = geom_row
elif "AS wkt" in sql:
first_val = wkt_row
elif "AS ekb_reference_median" in sql and "district_name" in sql:
first_val = district_row
elif "AS lon" in sql and "AS lat" in sql:
first_val = centroid_row
# Предмет файла: parcel_meta из cad_parcels (#29 G2).
elif "AS permitted_use" in sql and "FROM cad_parcels" in sql:
first_val = pm_mock
# _neighbors_summary — ОДИН statement (neighbors + overlap + total через
# json_agg + neighbors_total CTE, PR #1130 / #2464 cluster B).
elif "neighbors_total_count" in sql or "json_agg" in sql:
first_val = {"neighbors": [], "overlap_rows": [], "neighbors_total_count": 0}
r = MagicMock()
r.mappings.return_value.first.return_value = data
r.mappings.return_value.all.return_value = data if isinstance(data, list) else []
r.scalar.return_value = data if kind == "scalar" else 0
r.mappings.return_value.first.return_value = first_val
r.mappings.return_value.all.return_value = all_val
r.scalar.return_value = 0
return r
db.execute.side_effect = _execute_side_effect