feat(site-finder): POI saturation per capita по районам ЕКБ (#42) #1329
4 changed files with 668 additions and 0 deletions
|
|
@ -76,6 +76,7 @@ from app.services.site_finder.quarter_dump_lookup import (
|
|||
get_quarter_dump_data,
|
||||
make_empty_result,
|
||||
)
|
||||
from app.services.site_finder.saturation import compute_district_saturation
|
||||
from app.services.site_finder.velocity import compute_velocity
|
||||
from app.services.site_finder.weight_profiles import (
|
||||
_SYSTEM_POI_WEIGHTS as _POI_WEIGHTS,
|
||||
|
|
@ -2691,11 +2692,33 @@ def analyze_parcel(
|
|||
"top_sellers": _top_sellers_list,
|
||||
}
|
||||
|
||||
# #42: POI saturation per capita района (обеспеченность школа/детсад/поликлиника
|
||||
# на 1000 чел. целевой когорты vs норматив СП 42.13330). Best-effort: считается
|
||||
# только если у участка резолвится район; ошибка/нет демографии → None (не роняет
|
||||
# analyze). SAVEPOINT изолирует от outer-сессии (как success_recommendation выше).
|
||||
saturation_block: dict[str, Any] | None = None
|
||||
if district_row and district_row["district_name"]:
|
||||
try:
|
||||
with db.begin_nested():
|
||||
saturation_block = compute_district_saturation(
|
||||
db, district_row["district_name"]
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"saturation block failed for %s (%s): %s",
|
||||
cad_num,
|
||||
district_row["district_name"],
|
||||
e,
|
||||
)
|
||||
|
||||
result_payload: dict[str, Any] = {
|
||||
"cad_num": cad_num,
|
||||
"source": source,
|
||||
"geom_geojson": json.loads(geom_geojson) if geom_geojson else None,
|
||||
"district": dict(district_row) if district_row else None,
|
||||
# #42: per-capita обеспеченность соцобъектами района (saturation) + влияние
|
||||
# на POI-score (score_multiplier). None если район не резолвится / нет демографии.
|
||||
"infra": {"saturation": saturation_block},
|
||||
"score": round(score_final, 2),
|
||||
"score_without_center": round(score, 2),
|
||||
"score_label": _score_label(score_final),
|
||||
|
|
|
|||
297
backend/app/services/site_finder/saturation.py
Normal file
297
backend/app/services/site_finder/saturation.py
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
"""POI saturation per capita — обеспеченность района социнфраструктурой (#42).
|
||||
|
||||
Идея (Issue #42 / Limit_POI_Factor_Tiny): «10 школ vs 1 школа на 5000 детей» в
|
||||
straight-count POI-scoring дают одинаковый сигнал — это неверно. Насыщенность
|
||||
*на душу целевой когорты* важнее абсолютного числа объектов.
|
||||
|
||||
Что считаем для района (одного из 8 админ-районов ЕКБ):
|
||||
• фактическое число объектов категории (школа / детсад / поликлиника),
|
||||
привязанных к району spatial-join'ом `ST_Contains(district.geom, poi.geom)`
|
||||
по таблице `osm_poi_ekb` (нормализованные категории);
|
||||
• население целевой когорты = population × age_share (детсад → 0-7,
|
||||
школа → 7-18, поликлиника → всё население);
|
||||
• норматив СП 42.13330 (мест/учреждений на 1000 чел. когорты);
|
||||
• provision_ratio = факт / норматив → severity (дефицит / норма / профицит).
|
||||
|
||||
ВАЖНО про данные:
|
||||
• population/area района — ФАКТ (Росстат 01.01.2025, миграция 154).
|
||||
• Возрастные когорты — ОЦЕНКА (региональные доли, age_cohorts_estimated=TRUE).
|
||||
Поэтому в ответе всегда `cohort_estimated`-флаг: число «детей школьного
|
||||
возраста» — производная оценка, НЕ район-факт переписи.
|
||||
|
||||
Влияние на POI-score (acceptance #42): saturation_multiplier(category, district):
|
||||
дефицитный район → ×1.2 (incentive строить school-adjacent), перенасыщенный
|
||||
→ ×0.5. Чистая функция — drop-in для poi_score без изменения его сигнатур.
|
||||
|
||||
psycopg v3: все binds через CAST(:name AS type), а не postfix double-colon. Read-only SELECT.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Нормативы СП 42.13330.2016 (Свод правил «Градостроительство») ──────────────
|
||||
# Источник значений — issue #42 + vault A3. Знаменатель когорты различается:
|
||||
# - школа/детсад нормируются на 1000 ДЕТЕЙ соответствующего возраста,
|
||||
# - поликлиника — на 10000 ВСЕХ жителей.
|
||||
# capacity_per_1000 — мест (школа/сад) либо учреждений (поликлиника) на 1000 чел.
|
||||
# cohort — какую возрастную долю населения брать как знаменатель.
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Norm:
|
||||
"""Норматив обеспеченности одной POI-категории по СП 42.13330."""
|
||||
|
||||
cohort: str # 'preschool' | 'school' | 'all'
|
||||
capacity_per_1000: float # мест/учреждений на 1000 чел. когорты
|
||||
unit: str # человекочитаемая единица норматива
|
||||
|
||||
|
||||
# poi_loader-категории (osm_poi_ekb.category) → норматив.
|
||||
_NORMS: dict[str, _Norm] = {
|
||||
# Школа: 92 места на 1000 детей школьного возраста (7-18).
|
||||
"school": _Norm(cohort="school", capacity_per_1000=92.0, unit="мест/1000 школьников"),
|
||||
# Детсад: 35 мест на 1000 детей дошкольного возраста (0-7).
|
||||
"kindergarten": _Norm(
|
||||
cohort="preschool", capacity_per_1000=35.0, unit="мест/1000 дошкольников"
|
||||
),
|
||||
# Поликлиника: 1 учреждение на 10000 жителей = 0.1 на 1000 всех жителей.
|
||||
"hospital": _Norm(cohort="all", capacity_per_1000=0.1, unit="учр./1000 жителей"),
|
||||
}
|
||||
|
||||
# Сколько «мест» условно даёт один объект OSM. У OSM нет вместимости (только точка),
|
||||
# поэтому считаем число ОБЪЕКТОВ и переводим в условные «места» множителем-капасити.
|
||||
# Для поликлиник 1 объект = 1 учреждение. Для школ/садов — усреднённая вместимость
|
||||
# типового объекта (оценка, vault A3); меняем здесь, если появится OSM `capacity`.
|
||||
_OBJECT_CAPACITY: dict[str, float] = {
|
||||
"school": 600.0, # мест в типовой школе (оценка)
|
||||
"kindergarten": 200.0, # мест в типовом детсаду (оценка)
|
||||
"hospital": 1.0, # 1 объект = 1 учреждение
|
||||
}
|
||||
|
||||
# ── Severity-пороги по provision_ratio (факт/норматив) ───────────────────────
|
||||
# < 0.5 — острый дефицит | 0.5–0.85 — дефицит | 0.85–1.3 — норма | > 1.3 — профицит.
|
||||
_SEVERITY_ACUTE_DEFICIT = 0.5
|
||||
_SEVERITY_DEFICIT = 0.85
|
||||
_SEVERITY_SURPLUS = 1.3
|
||||
|
||||
# ── POI-score множитель (acceptance #42): дефицит → boost, профицит → штраф ───
|
||||
_MULT_DEFICIT = 1.2 # дефицитный район: incentive строить такую соцобъект-adjacent
|
||||
_MULT_SURPLUS = 0.5 # перенасыщенный район: сигнал «ещё одна школа мало что даёт»
|
||||
_MULT_NEUTRAL = 1.0
|
||||
|
||||
|
||||
def classify_severity(provision_ratio: float | None) -> str | None:
|
||||
"""provision_ratio (факт/норматив) → ярлык severity. PURE.
|
||||
|
||||
None → None (нет данных: нет когорты / нет норматива — НЕ «острый дефицит»).
|
||||
"""
|
||||
if provision_ratio is None:
|
||||
return None
|
||||
if provision_ratio < _SEVERITY_ACUTE_DEFICIT:
|
||||
return "острый дефицит"
|
||||
if provision_ratio < _SEVERITY_DEFICIT:
|
||||
return "дефицит"
|
||||
if provision_ratio <= _SEVERITY_SURPLUS:
|
||||
return "норма"
|
||||
return "профицит"
|
||||
|
||||
|
||||
def saturation_multiplier(category: str, provision_ratio: float | None) -> float:
|
||||
"""Множитель для POI-score по обеспеченности района категорией. PURE.
|
||||
|
||||
Acceptance #42: дефицитный район → ×1.2 (стимул строить школо-adjacent),
|
||||
перенасыщенный → ×0.5. Норма / нет данных / категория без норматива → ×1.0
|
||||
(нейтрально: не наказываем за отсутствие сигнала).
|
||||
"""
|
||||
if category not in _NORMS or provision_ratio is None:
|
||||
return _MULT_NEUTRAL
|
||||
if provision_ratio < _SEVERITY_DEFICIT:
|
||||
return _MULT_DEFICIT
|
||||
if provision_ratio > _SEVERITY_SURPLUS:
|
||||
return _MULT_SURPLUS
|
||||
return _MULT_NEUTRAL
|
||||
|
||||
|
||||
def compute_provision_ratio(
|
||||
*,
|
||||
poi_count: int,
|
||||
population: int,
|
||||
age_share: float | None,
|
||||
category: str,
|
||||
) -> float | None:
|
||||
"""Коэффициент обеспеченности района категорией = факт / норматив. PURE.
|
||||
|
||||
факт_мест_на_1000 = poi_count × object_capacity / (cohort_pop / 1000)
|
||||
норматив_мест_на_1000 = _NORMS[category].capacity_per_1000
|
||||
provision_ratio = факт / норматив
|
||||
|
||||
cohort_pop = population × age_share (для 'all' — age_share=1.0).
|
||||
None если: неизвестная категория / нет населения / нулевая когорта.
|
||||
"""
|
||||
norm = _NORMS.get(category)
|
||||
if norm is None or population <= 0:
|
||||
return None
|
||||
|
||||
share = 1.0 if norm.cohort == "all" else age_share
|
||||
if share is None or share <= 0:
|
||||
return None
|
||||
|
||||
cohort_pop = population * share
|
||||
if cohort_pop <= 0:
|
||||
return None
|
||||
|
||||
object_capacity = _OBJECT_CAPACITY.get(category, 1.0)
|
||||
actual_per_1000 = (poi_count * object_capacity) / (cohort_pop / 1000.0)
|
||||
if norm.capacity_per_1000 <= 0:
|
||||
return None
|
||||
return actual_per_1000 / norm.capacity_per_1000
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# DB-зависимый слой: spatial POI-count по району + join демографии.
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# POI-count категорий в полигоне района + демография района. ekb_districts.geom —
|
||||
# полигон (миграция 56/74), osm_poi_ekb.geom — Point. ST_Contains(poly, point).
|
||||
# LEFT JOIN osm_poi_ekb, чтобы район без POI давал 0, а не отсутствие строки.
|
||||
_DISTRICT_SATURATION_SQL = text(
|
||||
"""
|
||||
SELECT
|
||||
d.population,
|
||||
d.area_km2,
|
||||
d.age_share_preschool,
|
||||
d.age_share_school,
|
||||
d.age_share_elderly,
|
||||
d.age_cohorts_estimated,
|
||||
d.source,
|
||||
d.as_of_date,
|
||||
COUNT(*) FILTER (WHERE p.category = 'school') AS n_school,
|
||||
COUNT(*) FILTER (WHERE p.category = 'kindergarten') AS n_kindergarten,
|
||||
COUNT(*) FILTER (WHERE p.category = 'hospital') AS n_hospital
|
||||
FROM ekb_district_demographics d
|
||||
JOIN ekb_districts g
|
||||
ON g.district_name = d.district_name
|
||||
AND g.geom IS NOT NULL
|
||||
LEFT JOIN osm_poi_ekb p
|
||||
ON p.category IN ('school', 'kindergarten', 'hospital')
|
||||
AND ST_Contains(g.geom, p.geom)
|
||||
WHERE d.district_name = CAST(:dn AS text)
|
||||
GROUP BY
|
||||
d.population, d.area_km2, d.age_share_preschool, d.age_share_school,
|
||||
d.age_share_elderly, d.age_cohorts_estimated, d.source, d.as_of_date
|
||||
"""
|
||||
)
|
||||
|
||||
# Какую возрастную долю брать для категории (для отчёта по когорте).
|
||||
_CATEGORY_SHARE_COLUMN: dict[str, str] = {
|
||||
"school": "age_share_school",
|
||||
"kindergarten": "age_share_preschool",
|
||||
"hospital": "age_share_elderly", # информативно; норматив hospital — на всё население
|
||||
}
|
||||
_CATEGORY_COUNT_COLUMN: dict[str, str] = {
|
||||
"school": "n_school",
|
||||
"kindergarten": "n_kindergarten",
|
||||
"hospital": "n_hospital",
|
||||
}
|
||||
|
||||
|
||||
def _round_or_none(value: float | None, digits: int = 3) -> float | None:
|
||||
return round(value, digits) if value is not None else None
|
||||
|
||||
|
||||
def compute_district_saturation(db: Session, district_name: str) -> dict[str, Any] | None:
|
||||
"""Saturation per capita района — блок для analyze (`infra.saturation`).
|
||||
|
||||
Возвращает dict (JSON-готовый) либо None, если у района нет демографии
|
||||
(нет строки в ekb_district_demographics / нет geom-полигона).
|
||||
|
||||
Структура:
|
||||
{
|
||||
"district": "Чкаловский",
|
||||
"population": 286277,
|
||||
"as_of_date": "2025-01-01",
|
||||
"source": "...",
|
||||
"cohorts_estimated": true, # возрастные доли — оценка, НЕ факт
|
||||
"categories": {
|
||||
"school": {
|
||||
"poi_count": 12,
|
||||
"cohort_population": 32922, # оценка (population × age_share)
|
||||
"cohort_estimated": true,
|
||||
"provision_ratio": 0.68, # факт/норматив
|
||||
"severity": "дефицит",
|
||||
"score_multiplier": 1.2, # влияние на POI-score (#42)
|
||||
"norm": {"capacity_per_1000": 92.0, "unit": "мест/1000 школьников"}
|
||||
}, ...
|
||||
}
|
||||
}
|
||||
|
||||
Graceful: на ошибке БД логирует и возвращает None (не роняет analyze).
|
||||
psycopg v3: bind через CAST(:dn AS text). Read-only.
|
||||
"""
|
||||
try:
|
||||
row = db.execute(_DISTRICT_SATURATION_SQL, {"dn": district_name}).mappings().first()
|
||||
except Exception:
|
||||
logger.exception("saturation: query failed (district=%s)", district_name)
|
||||
return None
|
||||
|
||||
if row is None or row["population"] is None:
|
||||
return None
|
||||
|
||||
population = int(row["population"])
|
||||
cohorts_estimated = bool(row["age_cohorts_estimated"])
|
||||
|
||||
categories: dict[str, Any] = {}
|
||||
for category, norm in _NORMS.items():
|
||||
poi_count = int(row[_CATEGORY_COUNT_COLUMN[category]] or 0)
|
||||
share_col = _CATEGORY_SHARE_COLUMN[category]
|
||||
raw_share = row[share_col]
|
||||
age_share = float(raw_share) if raw_share is not None else None
|
||||
|
||||
provision = compute_provision_ratio(
|
||||
poi_count=poi_count,
|
||||
population=population,
|
||||
age_share=age_share,
|
||||
category=category,
|
||||
)
|
||||
|
||||
# Когорта-знаменатель для отчёта: для hospital норматив на всё население.
|
||||
if norm.cohort == "all":
|
||||
cohort_pop: float | None = float(population)
|
||||
cohort_is_estimate = False # всё население — факт
|
||||
elif age_share is not None:
|
||||
cohort_pop = population * age_share
|
||||
cohort_is_estimate = cohorts_estimated
|
||||
else:
|
||||
cohort_pop = None
|
||||
cohort_is_estimate = cohorts_estimated
|
||||
|
||||
categories[category] = {
|
||||
"poi_count": poi_count,
|
||||
"cohort_population": int(cohort_pop) if cohort_pop is not None else None,
|
||||
"cohort_estimated": cohort_is_estimate,
|
||||
"provision_ratio": _round_or_none(provision),
|
||||
"severity": classify_severity(provision),
|
||||
"score_multiplier": saturation_multiplier(category, provision),
|
||||
"norm": {
|
||||
"capacity_per_1000": norm.capacity_per_1000,
|
||||
"unit": norm.unit,
|
||||
},
|
||||
}
|
||||
|
||||
as_of = row["as_of_date"]
|
||||
return {
|
||||
"district": district_name,
|
||||
"population": population,
|
||||
"as_of_date": as_of.isoformat() if as_of is not None else None,
|
||||
"source": row["source"],
|
||||
"cohorts_estimated": cohorts_estimated,
|
||||
"categories": categories,
|
||||
}
|
||||
241
backend/tests/test_saturation.py
Normal file
241
backend/tests/test_saturation.py
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
"""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)
|
||||
107
data/sql/154_ekb_district_demographics.sql
Normal file
107
data/sql/154_ekb_district_demographics.sql
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
-- Контекст: Issue #42 (Infra I1) — POI saturation per capita. Чтобы считать
|
||||
-- обеспеченность инфраструктурой района (школа/детсад/поликлиника на
|
||||
-- 1000 чел. целевой когорты vs норматив СП 42.13330), нужна демография
|
||||
-- по 8 внутригородским районам ЕКБ.
|
||||
--
|
||||
-- Что делает:
|
||||
-- 1. Заполняет ekb_districts.population (была NULL) точными числами Росстата.
|
||||
-- 2. Создаёт таблицу-справочник ekb_district_demographics — насел./площадь/ОКАТО
|
||||
-- + ДОКУМЕНТИРОВАННЫЙ источник/дата + ОЦЕНОЧНЫЕ возрастные когорты (помечены).
|
||||
--
|
||||
-- ИСТОЧНИК населения (источник истины):
|
||||
-- Wikipedia «Административно-территориальное деление Екатеринбурга» →
|
||||
-- первоисточник «Численность постоянного населения РФ по муниципальным
|
||||
-- образованиям на 1 января 2025», Росстат. as_of_date = 2025-01-01.
|
||||
--
|
||||
-- ВОЗРАСТНЫЕ КОГОРТЫ — ОЦЕНКА, НЕ ФАКТ. На уровне внутригородского района Росстат
|
||||
-- публичных половозрастных срезов НЕ даёт. Применены РЕГИОНАЛЬНЫЕ доли (РФ/Свердл.
|
||||
-- обл., перепись-2020 / EMISS, порядок величин):
|
||||
-- • дошкольники 0-7 ≈ 8.0 % (age_share_preschool)
|
||||
-- • школьники 7-18 ≈ 11.5 % (age_share_school)
|
||||
-- • пожилые 65+ ≈ 16.0 % (age_share_elderly)
|
||||
-- Это ГРУБАЯ оценка для saturation-расчёта (estimate-флаг в API). Реальные
|
||||
-- район-уровневые доли потребуют отдельного источника (см. issue #42 risks).
|
||||
--
|
||||
-- Deploy order: после 56_schema_ekb_districts_geom (FK на ekb_districts) — стоит.
|
||||
-- Idempotent: CREATE TABLE IF NOT EXISTS + INSERT … ON CONFLICT DO UPDATE
|
||||
-- (re-apply перезаписывает данными из этой миграции — single source).
|
||||
-- Deploy: auto-applied by deploy.yml через _schema_migrations tracking (ровно один раз по NN).
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ── 1. Справочник демографии района ──────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS ekb_district_demographics (
|
||||
district_name TEXT PRIMARY KEY
|
||||
REFERENCES ekb_districts(district_name),
|
||||
population INTEGER NOT NULL, -- чел., Росстат 2025-01-01
|
||||
area_km2 NUMERIC(8, 2) NOT NULL, -- км²
|
||||
okato TEXT, -- код ОКАТО района
|
||||
-- Возрастные доли — ОЦЕНКА (региональный fallback), а не район-факт.
|
||||
age_share_preschool NUMERIC(5, 4), -- доля 0-7 лет (детсад)
|
||||
age_share_school NUMERIC(5, 4), -- доля 7-18 лет (школа)
|
||||
age_share_elderly NUMERIC(5, 4), -- доля 65+ лет (поликлиника)
|
||||
age_cohorts_estimated BOOLEAN NOT NULL DEFAULT TRUE, -- TRUE = когорты оценочные
|
||||
source TEXT NOT NULL, -- человекочитаемый источник
|
||||
as_of_date DATE NOT NULL, -- дата актуальности населения
|
||||
loaded_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE ekb_district_demographics IS
|
||||
'Демография 8 внутригородских районов ЕКБ для POI saturation per capita (#42). '
|
||||
'population/area — Росстат 2025-01-01 (факт). Возрастные когорты — ОЦЕНКА '
|
||||
'(региональные доли РФ/Свердл.), см. age_cohorts_estimated.';
|
||||
COMMENT ON COLUMN ekb_district_demographics.age_cohorts_estimated IS
|
||||
'TRUE: age_share_* — оценка региональными долями, НЕ район-факт Росстата (#42).';
|
||||
|
||||
-- ── 2. Seed: 8 районов (Росстат на 1 января 2025) ────────────────────────────
|
||||
-- population/area/okato — ФАКТ. age_share_* — единая региональная оценка (см. шапку).
|
||||
INSERT INTO ekb_district_demographics
|
||||
(district_name, population, area_km2, okato,
|
||||
age_share_preschool, age_share_school, age_share_elderly,
|
||||
age_cohorts_estimated, source, as_of_date)
|
||||
VALUES
|
||||
('Академический', 125000, 46.01, '65401362', 0.0800, 0.1150, 0.1600, TRUE,
|
||||
'Росстат, численность населения МО на 01.01.2025 (via Wikipedia АТД Екатеринбурга)',
|
||||
DATE '2025-01-01'),
|
||||
('Верх-Исетский', 240822, 219.79, '65401364', 0.0800, 0.1150, 0.1600, TRUE,
|
||||
'Росстат, численность населения МО на 01.01.2025 (via Wikipedia АТД Екатеринбурга)',
|
||||
DATE '2025-01-01'),
|
||||
('Железнодорожный', 158675, 125.65, '65401368', 0.0800, 0.1150, 0.1600, TRUE,
|
||||
'Росстат, численность населения МО на 01.01.2025 (via Wikipedia АТД Екатеринбурга)',
|
||||
DATE '2025-01-01'),
|
||||
('Кировский', 220749, 86.25, '65401373', 0.0800, 0.1150, 0.1600, TRUE,
|
||||
'Росстат, численность населения МО на 01.01.2025 (via Wikipedia АТД Екатеринбурга)',
|
||||
DATE '2025-01-01'),
|
||||
('Ленинский', 222258, 22.19, '65401377', 0.0800, 0.1150, 0.1600, TRUE,
|
||||
'Росстат, численность населения МО на 01.01.2025 (via Wikipedia АТД Екатеринбурга)',
|
||||
DATE '2025-01-01'),
|
||||
('Октябрьский', 151775, 158.60, '65401380', 0.0800, 0.1150, 0.1600, TRUE,
|
||||
'Росстат, численность населения МО на 01.01.2025 (via Wikipedia АТД Екатеринбурга)',
|
||||
DATE '2025-01-01'),
|
||||
('Орджоникидзевский', 263820, 99.30, '65401385', 0.0800, 0.1150, 0.1600, TRUE,
|
||||
'Росстат, численность населения МО на 01.01.2025 (via Wikipedia АТД Екатеринбурга)',
|
||||
DATE '2025-01-01'),
|
||||
('Чкаловский', 286277, 389.81, '65401390', 0.0800, 0.1150, 0.1600, TRUE,
|
||||
'Росстат, численность населения МО на 01.01.2025 (via Wikipedia АТД Екатеринбурга)',
|
||||
DATE '2025-01-01')
|
||||
ON CONFLICT (district_name) DO UPDATE SET
|
||||
population = EXCLUDED.population,
|
||||
area_km2 = EXCLUDED.area_km2,
|
||||
okato = EXCLUDED.okato,
|
||||
age_share_preschool = EXCLUDED.age_share_preschool,
|
||||
age_share_school = EXCLUDED.age_share_school,
|
||||
age_share_elderly = EXCLUDED.age_share_elderly,
|
||||
age_cohorts_estimated = EXCLUDED.age_cohorts_estimated,
|
||||
source = EXCLUDED.source,
|
||||
as_of_date = EXCLUDED.as_of_date,
|
||||
loaded_at = now();
|
||||
|
||||
-- ── 3. Backfill ekb_districts.population (была NULL) — по name-match ──────────
|
||||
-- ekb_districts.area_m2 уже есть в схеме (NUMERIC) — конвертируем км² → м².
|
||||
UPDATE ekb_districts d
|
||||
SET population = s.population,
|
||||
area_m2 = COALESCE(d.area_m2, s.area_km2 * 1000000.0)
|
||||
FROM ekb_district_demographics s
|
||||
WHERE d.district_name = s.district_name;
|
||||
|
||||
COMMIT;
|
||||
Loading…
Add table
Reference in a new issue