All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m9s
Deploy Trade-In / build-backend (push) Successful in 1m54s
Deploy Trade-In / deploy (push) Successful in 1m51s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
157 lines
6.9 KiB
Python
157 lines
6.9 KiB
Python
"""Радиусные агрегаты ДТП вокруг точки — `dtp_incidents` (#3410).
|
||
|
||
Отдельный модуль, не расширение `location_index.py`: та модель считает ЦЕНОВОЙ индекс
|
||
(медиана ₽/м² локально vs по городу) с фиксированным bbox продукт-ядра города — другая
|
||
предметная область и другой контракт результата (status/coverage у location_index
|
||
завязан на `regions_mod`/`bbox_product_core`, ДТП покрывает всю область без city-bbox
|
||
ограничения источника). Общее — только сам паттерн запроса (bbox-префильтр +
|
||
`ST_DWithin(geom::geography, …)`) и dtp_incidents/osm_poi_ekb_local roднит один и тот же
|
||
"пустая таблица -> unavailable" graceful fallback (см. `_fetch_nearby_poi` в
|
||
location_index.py) — переиспользован здесь буквально.
|
||
|
||
Bbox-префильтр здесь, в отличие от location_index (там фиксированный bbox city-ядра),
|
||
строится ВОКРУГ ТОЧКИ по радиусу (`_bbox_from_point`) — dtp_incidents не ограничена
|
||
одним городом, фиксированного bbox покрытия нет. Дешёвый lat/lon BETWEEN перед
|
||
ST_DWithin — тот же приём, что в шаблоне recon_geo.md п.3.
|
||
|
||
psycopg v3: `CAST(:x AS type)`, никогда `:x::type`.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import math
|
||
from typing import Any
|
||
|
||
from pydantic import BaseModel
|
||
from sqlalchemy import text
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Радиус по умолчанию для "фактора безопасности" района — сопоставим по порядку
|
||
# величины с DEFAULT_POI_RADIUS_M=1200 в location_index.py (тот же масштаб "пешком от
|
||
# дома"), но не импортируется оттуда: разные предметные области, совпадение числа
|
||
# не должно создавать ложную связь между модулями.
|
||
DEFAULT_DTP_RADIUS_M = 1000
|
||
|
||
# Сколько лет ДТП назад учитывать по умолчанию — источник копит данные с 2015 (см.
|
||
# докстринг dtp_stat_loader.py), но "фактор безопасности" района должен отражать
|
||
# ТЕКУЩУЮ ситуацию, а не десятилетнюю историю.
|
||
DEFAULT_YEARS_LOOKBACK = 5
|
||
|
||
_METERS_PER_DEGREE_LAT = 111_320.0
|
||
|
||
|
||
def _bbox_from_point(lat: float, lon: float, radius_m: int) -> tuple[float, float, float, float]:
|
||
"""(south, north, west, east) — bbox вокруг точки радиусом radius_m (приближённо)."""
|
||
lat_delta = radius_m / _METERS_PER_DEGREE_LAT
|
||
lon_scale = math.cos(math.radians(lat))
|
||
# Защита от вырождения делителя у полюсов — для Свердловской обл. (~56-61° с.ш.)
|
||
# cos никогда не приближается к нулю, но защита дешёвая и делает функцию safe везде.
|
||
lon_delta = radius_m / (_METERS_PER_DEGREE_LAT * max(lon_scale, 0.01))
|
||
return (lat - lat_delta, lat + lat_delta, lon - lon_delta, lon + lon_delta)
|
||
|
||
|
||
class DtpAreaStats(BaseModel):
|
||
"""Результат compute_dtp_stats."""
|
||
|
||
status: str # "ok" | "unavailable" (dtp_incidents пуста — рефреш ещё не запускался)
|
||
radius_m: int
|
||
years: int
|
||
incidents_count: int
|
||
severe_count: int
|
||
dead: int
|
||
injured: int
|
||
|
||
|
||
_DTP_STATS_SQL = text(
|
||
"""
|
||
SELECT
|
||
count(*) AS incidents_count,
|
||
count(*) FILTER (WHERE severity = 'Тяжёлый') AS severe_count,
|
||
COALESCE(sum(dead), 0) AS dead_total,
|
||
COALESCE(sum(injured), 0) AS injured_total
|
||
FROM dtp_incidents
|
||
WHERE dtp_at >= NOW() - make_interval(years => CAST(:years AS integer))
|
||
AND lat BETWEEN CAST(:bbox_south AS double precision)
|
||
AND CAST(:bbox_north AS double precision)
|
||
AND lon BETWEEN CAST(:bbox_west AS double precision)
|
||
AND CAST(:bbox_east AS double precision)
|
||
AND ST_DWithin(
|
||
geom::geography,
|
||
ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)::geography,
|
||
CAST(:radius_m AS double precision)
|
||
)
|
||
"""
|
||
)
|
||
|
||
|
||
def compute_dtp_stats(
|
||
db: Any,
|
||
lat: float,
|
||
lon: float,
|
||
*,
|
||
radius_m: int = DEFAULT_DTP_RADIUS_M,
|
||
years: int = DEFAULT_YEARS_LOOKBACK,
|
||
) -> DtpAreaStats:
|
||
"""Посчитать агрегаты ДТП в радиусе `radius_m` вокруг точки за последние `years` лет.
|
||
|
||
Graceful fallback: `dtp_incidents` пуста (рефреш ещё не запускался на этом
|
||
окружении, source dormant по умолчанию — миграция 295) -> status="unavailable",
|
||
нули НЕ фабрикуются. Ноль инцидентов при непустой таблице — легитимный "ok"
|
||
результат (в радиусе правда ничего не было), отличается статусом от "нет данных".
|
||
"""
|
||
total = db.execute(text("SELECT count(*) FROM dtp_incidents")).scalar() or 0
|
||
if total == 0:
|
||
logger.warning(
|
||
"dtp_index: dtp_incidents пуста (рефреш ещё не запускался на этом "
|
||
"окружении) — unavailable, без сфабрикованных нулей"
|
||
)
|
||
return DtpAreaStats(
|
||
status="unavailable",
|
||
radius_m=radius_m,
|
||
years=years,
|
||
incidents_count=0,
|
||
severe_count=0,
|
||
dead=0,
|
||
injured=0,
|
||
)
|
||
|
||
bbox_south, bbox_north, bbox_west, bbox_east = _bbox_from_point(lat, lon, radius_m)
|
||
row = (
|
||
db.execute(
|
||
_DTP_STATS_SQL,
|
||
{
|
||
"lat": lat,
|
||
"lon": lon,
|
||
"radius_m": radius_m,
|
||
"years": years,
|
||
"bbox_south": bbox_south,
|
||
"bbox_north": bbox_north,
|
||
"bbox_west": bbox_west,
|
||
"bbox_east": bbox_east,
|
||
},
|
||
)
|
||
.mappings()
|
||
.first()
|
||
)
|
||
if row is None:
|
||
return DtpAreaStats(
|
||
status="ok",
|
||
radius_m=radius_m,
|
||
years=years,
|
||
incidents_count=0,
|
||
severe_count=0,
|
||
dead=0,
|
||
injured=0,
|
||
)
|
||
|
||
return DtpAreaStats(
|
||
status="ok",
|
||
radius_m=radius_m,
|
||
years=years,
|
||
incidents_count=int(row["incidents_count"] or 0),
|
||
severe_count=int(row["severe_count"] or 0),
|
||
dead=int(row["dead_total"] or 0),
|
||
injured=int(row["injured_total"] or 0),
|
||
)
|