diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 80c5e04b..d39656dd 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -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), diff --git a/backend/app/services/site_finder/saturation.py b/backend/app/services/site_finder/saturation.py new file mode 100644 index 00000000..4e6d4a0a --- /dev/null +++ b/backend/app/services/site_finder/saturation.py @@ -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, + } diff --git a/backend/tests/test_saturation.py b/backend/tests/test_saturation.py new file mode 100644 index 00000000..eba19e2f --- /dev/null +++ b/backend/tests/test_saturation.py @@ -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) diff --git a/data/sql/154_ekb_district_demographics.sql b/data/sql/154_ekb_district_demographics.sql new file mode 100644 index 00000000..52b847da --- /dev/null +++ b/data/sql/154_ekb_district_demographics.sql @@ -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;