feat(site-finder): POI saturation per capita по районам ЕКБ (#42) #1329

Merged
bot-backend merged 1 commit from feat/poi-saturation-percapita into main 2026-06-14 15:05:23 +00:00
4 changed files with 668 additions and 0 deletions

View file

@ -76,6 +76,7 @@ from app.services.site_finder.quarter_dump_lookup import (
get_quarter_dump_data, get_quarter_dump_data,
make_empty_result, 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.velocity import compute_velocity
from app.services.site_finder.weight_profiles import ( from app.services.site_finder.weight_profiles import (
_SYSTEM_POI_WEIGHTS as _POI_WEIGHTS, _SYSTEM_POI_WEIGHTS as _POI_WEIGHTS,
@ -2691,11 +2692,33 @@ def analyze_parcel(
"top_sellers": _top_sellers_list, "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] = { result_payload: dict[str, Any] = {
"cad_num": cad_num, "cad_num": cad_num,
"source": source, "source": source,
"geom_geojson": json.loads(geom_geojson) if geom_geojson else None, "geom_geojson": json.loads(geom_geojson) if geom_geojson else None,
"district": dict(district_row) if district_row 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": round(score_final, 2),
"score_without_center": round(score, 2), "score_without_center": round(score, 2),
"score_label": _score_label(score_final), "score_label": _score_label(score_final),

View 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.50.85 — дефицит | 0.851.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,
}

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

View 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;