gendesign/backend/tests/test_saturation.py
Light1YT 1b77f479e7
All checks were successful
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (push) Successful in 1m57s
CI / openapi-codegen-check (pull_request) Successful in 1m51s
CI / backend-tests (push) Successful in 8m52s
CI / backend-tests (pull_request) Successful in 8m50s
feat(site-finder): POI saturation per capita по районам ЕКБ (#42)
Демография 8 районов ЕКБ (Росстат 01.01.2025) + обеспеченность
социнфраструктурой на 1000 чел. целевой когорты vs норматив СП 42.13330.

- migration 154: ekb_district_demographics (население/площадь/ОКАТО факт,
  возрастные когорты — региональная оценка, помечены) + backfill
  ekb_districts.population
- saturation.py: provision_ratio (школа/детсад/поликлиника), severity,
  score_multiplier (x1.2 дефицит / x0.5 профицит)
- analyze: infra.saturation блок (best-effort, SAVEPOINT-изолирован)
- tests: 18 (вкл. acceptance — дефицитный район -> school x1.2)

Refs #42
2026-06-14 19:51:42 +05:00

241 lines
9.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Tests for POI saturation per capita (#42).
Pure-функции (severity / multiplier / provision_ratio) — без БД.
DB-слой compute_district_saturation — через минимальный mock Session
(тот же приём, что в test_poi_score.py).
"""
from datetime import date
import pytest
from app.services.site_finder.saturation import (
_MULT_DEFICIT,
_MULT_NEUTRAL,
_MULT_SURPLUS,
classify_severity,
compute_district_saturation,
compute_provision_ratio,
saturation_multiplier,
)
# ── classify_severity ─────────────────────────────────────────────────────────
def test_severity_none_is_none():
"""Нет данных (None) → None, НЕ «острый дефицит»."""
assert classify_severity(None) is None
def test_severity_buckets():
assert classify_severity(0.2) == "острый дефицит"
assert classify_severity(0.49) == "острый дефицит"
assert classify_severity(0.5) == "дефицит"
assert classify_severity(0.84) == "дефицит"
assert classify_severity(0.85) == "норма"
assert classify_severity(1.0) == "норма"
assert classify_severity(1.3) == "норма"
assert classify_severity(1.31) == "профицит"
assert classify_severity(3.0) == "профицит"
# ── saturation_multiplier (acceptance #42) ─────────────────────────────────────
def test_multiplier_deficit_boosts():
"""Дефицитный район → ×1.2 (incentive строить school-adjacent)."""
assert saturation_multiplier("school", 0.6) == _MULT_DEFICIT
def test_multiplier_surplus_penalises():
"""Перенасыщенный район → ×0.5."""
assert saturation_multiplier("school", 2.0) == _MULT_SURPLUS
def test_multiplier_norm_neutral():
assert saturation_multiplier("school", 1.0) == _MULT_NEUTRAL
def test_multiplier_none_and_unknown_category_neutral():
"""Нет данных или категория без норматива → нейтрально (×1.0)."""
assert saturation_multiplier("school", None) == _MULT_NEUTRAL
assert saturation_multiplier("metro_stop", 0.1) == _MULT_NEUTRAL
# ── compute_provision_ratio ─────────────────────────────────────────────────────
def test_provision_unknown_category_none():
assert (
compute_provision_ratio(
poi_count=5, population=100000, age_share=0.1, category="park"
)
is None
)
def test_provision_zero_population_none():
assert (
compute_provision_ratio(
poi_count=5, population=0, age_share=0.1, category="school"
)
is None
)
def test_provision_missing_age_share_none():
"""Школа/детсад без age_share → None (когорту не оценить)."""
assert (
compute_provision_ratio(
poi_count=5, population=100000, age_share=None, category="school"
)
is None
)
def test_provision_hospital_ignores_age_share():
"""Поликлиника нормируется на ВСЁ население — age_share не нужен."""
# 100000 жителей, норматив 0.1 учр./1000 = 10 учреждений эталон.
# 10 объектов × capacity 1.0 / (100000/1000) = 10/100 = 0.1 на 1000 = ровно норматив.
ratio = compute_provision_ratio(
poi_count=10, population=100000, age_share=None, category="hospital"
)
assert ratio == pytest.approx(1.0)
def test_provision_more_poi_higher_ratio():
"""Больше объектов при той же когорте → выше обеспеченность (монотонность)."""
few = compute_provision_ratio(
poi_count=2, population=100000, age_share=0.115, category="school"
)
many = compute_provision_ratio(
poi_count=10, population=100000, age_share=0.115, category="school"
)
assert few is not None and many is not None
assert many > few
def test_provision_smaller_cohort_higher_ratio():
"""#42 суть: та же школа на МЕНЬШЕЕ число детей → выше обеспеченность per capita."""
big_cohort = compute_provision_ratio(
poi_count=3, population=300000, age_share=0.115, category="school"
)
small_cohort = compute_provision_ratio(
poi_count=3, population=100000, age_share=0.115, category="school"
)
assert big_cohort is not None and small_cohort is not None
assert small_cohort > big_cohort
# ── compute_district_saturation (mock DB) ────────────────────────────────────
class _MockMappings:
def __init__(self, row: dict | None) -> None:
self._row = row
def first(self) -> dict | None:
return self._row
class _MockResult:
def __init__(self, row: dict | None) -> None:
self._row = row
def mappings(self) -> "_MockMappings":
return _MockMappings(self._row)
class _MockDb:
"""Минимальный мок SQLAlchemy Session (как в test_poi_score.py)."""
def __init__(self, row: dict | None, *, raise_on_execute: bool = False) -> None:
self._row = row
self._raise = raise_on_execute
def execute(self, *_args: object, **_kwargs: object) -> _MockResult:
if self._raise:
raise RuntimeError("simulated DB failure")
return _MockResult(self._row)
def _chkalovsky_row(n_school: int = 12) -> dict:
"""Чкаловский — крупнейший район ЕКБ (286277 чел.), типичный дефицит школ."""
return {
"population": 286277,
"area_km2": 389.81,
"age_share_preschool": 0.08,
"age_share_school": 0.115,
"age_share_elderly": 0.16,
"age_cohorts_estimated": True,
"source": "Росстат 2025-01-01",
"as_of_date": date(2025, 1, 1),
"n_school": n_school,
"n_kindergarten": 20,
"n_hospital": 5,
}
def test_saturation_no_demographics_returns_none():
"""Район без строки демографии (population NULL) → None."""
assert compute_district_saturation(_MockDb(None), "Неизвестный") is None
def test_saturation_db_error_returns_none():
"""Ошибка БД не роняет analyze — graceful None."""
db = _MockDb(_chkalovsky_row(), raise_on_execute=True)
assert compute_district_saturation(db, "Чкаловский") is None
def test_saturation_shape_and_flags():
db = _MockDb(_chkalovsky_row())
out = compute_district_saturation(db, "Чкаловский")
assert out is not None
assert out["district"] == "Чкаловский"
assert out["population"] == 286277
assert out["as_of_date"] == "2025-01-01"
assert out["cohorts_estimated"] is True
assert set(out["categories"]) == {"school", "kindergarten", "hospital"}
school = out["categories"]["school"]
# Школьная когорта — оценка (региональная доля), помечена флагом.
assert school["cohort_estimated"] is True
assert school["cohort_population"] == int(286277 * 0.115)
assert school["poi_count"] == 12
assert school["norm"]["capacity_per_1000"] == 92.0
def test_saturation_hospital_cohort_is_factual():
"""Поликлиника нормируется на всё население → cohort НЕ оценка (факт)."""
out = compute_district_saturation(_MockDb(_chkalovsky_row()), "Чкаловский")
assert out is not None
hospital = out["categories"]["hospital"]
assert hospital["cohort_estimated"] is False
assert hospital["cohort_population"] == 286277
def test_saturation_deficit_district_school_gets_boost():
"""#42 acceptance: дефицитный по школам район → school POI score_multiplier=1.2.
Чкаловский, 12 школ, школьники ≈ 32922 чел. (286277×0.115).
Эталон-места = 32922/1000 × 92 ≈ 3029. Факт = 12 × 600 = 7200…
при таком capacity это профицит. Сужаем когорту: мало школ на много детей →
проверяем boundary через малое число объектов.
"""
# 1 школа на крупный район → острый дефицит → ×1.2.
out = compute_district_saturation(_MockDb(_chkalovsky_row(n_school=1)), "Чкаловский")
assert out is not None
school = out["categories"]["school"]
assert school["severity"] in ("дефицит", "острый дефицит")
assert school["score_multiplier"] == _MULT_DEFICIT
def test_saturation_ratio_matches_pure_helper():
"""DB-слой использует ту же чистую формулу, что и compute_provision_ratio."""
row = _chkalovsky_row(n_school=4)
out = compute_district_saturation(_MockDb(row), "Чкаловский")
assert out is not None
expected = compute_provision_ratio(
poi_count=4, population=286277, age_share=0.115, category="school"
)
assert expected is not None
assert out["categories"]["school"]["provision_ratio"] == round(expected, 3)