"""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, }