feat(mera): слой ДТП (dtp-stat/ГИБДД) в PostGIS + радиусные агрегаты #3428

Merged
lekss361 merged 2 commits from feat/mera-dtp-layer into main 2026-09-08 22:10:57 +00:00
10 changed files with 1122 additions and 0 deletions

View file

@ -0,0 +1,157 @@
"""Радиусные агрегаты ДТП вокруг точки — `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),
)

View file

@ -0,0 +1,281 @@
"""dtp-stat.ru loader: слой ДТП по Свердловской обл. в `dtp_incidents` (#3410).
CONTEXT: карточка района МЕРЫ хочет "фактор безопасности" сколько ДТП/тяжёлых/
погибших/раненых было рядом за последние годы. dtp-stat.ru агрегирует открытые данные
ГИБДД в GeoJSON по регионам, без auth.
ИСТОЧНИК: https://dtp-stat.ru/media/opendata/sverdlovskaia-oblast.geojson.zip ZIP
(~5 МБ) с одним файлом `sverdlovskaia-oblast.geojson` (~71 МБ распакованного,
FeatureCollection, ~31k Point-фич). Проверено живьём 08.09.2026: HTTP 200,
5 047 685 байт. Домен обычный (не RU-gov минцифровский сертификат) verify=False
НЕ ставим, лишний.
МОНИТОРИНГ ПРОТУХАНИЯ (не реализован в этом PR зафиксировано, чтобы следующий не
считал пустую дельту багом): на момент проверки 08.09.2026 дамп источника заморожен,
Last-Modified 26.02.2026. TRUNCATE+INSERT переливает те же ~31k строк каждый рефреш
это ожидаемо, а не признак сломанного парсинга. Если нужен freshness-монитор см.
паттерн `recon_macro.md` (СберИндекс), здесь не сделан осознанно (вне скоупа #3410).
СТРИМИНГ (обязательно, файл большой): `json.load()` целиком держал бы ~71 МБ + объектный
граф в памяти разом. Используем `ijson.items(stream, "features.item")` поверх
файлового объекта из `zipfile.ZipFile.open(name)` постоянная память вне зависимости
от размера файла, каждая feature обрабатывается и отбрасывается по одной.
ПДн НЕ СОХРАНЯЕМ (осознанное решение, зафиксировано и в 294_dtp_incidents.sql):
`properties.vehicles[].participants[]` источника несёт пол/роль/нарушения физлиц
это персональные данные конкретных людей, продукту не нужны и не разрешены. Парсер
(`_parse_feature`) НИКОГДА не читает ключи `vehicles`/`participants` ни в raw-jsonb,
ни отдельной колонкой. Из `properties` берём только неперсональные агрегаты и атрибуты
происшествия (see DtpIncidentRow).
Идемпотентность: TRUNCATE + bulk INSERT в одной транзакции (тот же паттерн, что
`app/tasks/osm_poi_ekb_refresh.py` полная замена, не upsert; повторный прогон с тем
же дампом даёт тот же результат).
psycopg v3: SQL через `text(...)` использует `CAST(:x AS type)`, НИКОГДА `:x::type`.
Массивы (`weather`, `nearby`, тип `text[]`) psycopg v3 адаптирует Python `list[str]`
в `text[]` напрямую через `CAST(:x AS text[])` (тот же идиом, что
`app/tasks/deactivate_stale_avito.py`).
"""
from __future__ import annotations
import io
import logging
import zipfile
from collections.abc import Iterator
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import IO, Any
import httpx
import ijson
from sqlalchemy import text
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
SRC_URL = "https://dtp-stat.ru/media/opendata/sverdlovskaia-oblast.geojson.zip"
DOWNLOAD_TIMEOUT_SEC = 180
INSERT_CHUNK_SIZE = 2000
# properties.datetime — "2015-01-01 03:00:00", без явной таймзоны в источнике.
# Храним как naive → колонка timestamptz получит его в timezone сессии БД (обычно UTC).
# Источник не публикует TZ, поэтому точная привязка к МСК не гарантирована — для
# радиусных агрегатов по годам (dtp_index.py) это несущественно.
_DT_FMT = "%Y-%m-%d %H:%M:%S"
@dataclass(slots=True)
class DtpIncidentRow:
"""Одна строка ДТП — ТОЛЬКО неперсональные атрибуты (см. докстринг модуля)."""
source_id: str
dtp_at: datetime | None
category: str | None
severity: str | None
dead: int | None
injured: int | None
address: str | None
light: str | None
weather: list[str] | None
nearby: list[str] | None
lat: float
lon: float
def _parse_datetime(raw: Any) -> datetime | None:
if not isinstance(raw, str) or not raw.strip():
return None
try:
return datetime.strptime(raw.strip(), _DT_FMT)
except ValueError:
logger.warning("dtp_stat_loader: не распарсен datetime %r", raw)
return None
def _parse_str_list(raw: Any) -> list[str] | None:
if not isinstance(raw, list) or not raw:
return None
return [str(item) for item in raw if item is not None]
def _parse_int(raw: Any) -> int | None:
if isinstance(raw, bool):
return None
if isinstance(raw, int):
return raw
return None
# Санити-рамка координат. Выгрузка dtp-stat по Свердловской области содержит битые
# точки: на реальном дампе 2026-02-26 из 31 253 записей 74 лежат вне широт региона,
# в том числе 10 ровно в (0.0, 0.0) и такие значения, как (1.0, 1.0) и (41.0, 47.0).
# Пускать их в geo-таблицу нельзя: слой кормит радиусные агрегаты локации.
# Рамка намеренно шире официальных границ области (~56.0-62.3 N, ~57.2-66.2 E),
# чтобы не срезать легитимные приграничные точки.
REGION_LAT_RANGE = (55.0, 63.5)
REGION_LON_RANGE = (56.0, 67.5)
def _coords_plausible(lat: float, lon: float) -> bool:
"""Точка похожа на реальное ДТП в регионе выгрузки, а не на мусор источника."""
if lat == 0.0 and lon == 0.0:
return False
if not (REGION_LAT_RANGE[0] <= lat <= REGION_LAT_RANGE[1]):
return False
return REGION_LON_RANGE[0] <= lon <= REGION_LON_RANGE[1]
def _parse_feature(feature: dict[str, Any]) -> DtpIncidentRow | None:
"""Чистая функция: одна GeoJSON Feature -> DtpIncidentRow, либо None (пропуск).
НЕ читает `properties.vehicles` / `properties.participants` намеренно (ПДн,
см. докстринг модуля). Пропускает фичи без валидной Point-геометрии/id, а также
с координатами вне санити-рамки региона (см. `_coords_plausible`).
"""
geometry = feature.get("geometry") or {}
if geometry.get("type") != "Point":
return None
coords = geometry.get("coordinates")
if not isinstance(coords, list) or len(coords) < 2:
return None
try:
lon, lat = float(coords[0]), float(coords[1])
except (TypeError, ValueError):
return None
if not _coords_plausible(lat, lon):
return None
props = feature.get("properties") or {}
source_id = props.get("id")
if source_id is None:
return None
return DtpIncidentRow(
source_id=str(source_id),
dtp_at=_parse_datetime(props.get("datetime")),
category=props.get("category") if isinstance(props.get("category"), str) else None,
severity=props.get("severity") if isinstance(props.get("severity"), str) else None,
dead=_parse_int(props.get("dead_count")),
injured=_parse_int(props.get("injured_count")),
address=props.get("address") if isinstance(props.get("address"), str) else None,
light=props.get("light") if isinstance(props.get("light"), str) else None,
weather=_parse_str_list(props.get("weather")),
nearby=_parse_str_list(props.get("nearby")),
lat=lat,
lon=lon,
)
def iter_incidents(stream: IO[bytes]) -> Iterator[DtpIncidentRow]:
"""Потоковый парс GeoJSON FeatureCollection -> DtpIncidentRow, по одной фиче за раз.
`stream` бинарный файловый объект (из `zipfile.ZipFile.open()` или обычного
`open(path, "rb")`). Использует `ijson.items(..., "features.item")` постоянная
память, НЕ читает файл целиком.
"""
for feature in ijson.items(stream, "features.item"):
row = _parse_feature(feature)
if row is not None:
yield row
def _open_geojson_in_zip(zf: zipfile.ZipFile) -> IO[bytes]:
names = [n for n in zf.namelist() if n.lower().endswith(".geojson")]
if not names:
raise ValueError(f"zip не содержит .geojson записей: {zf.namelist()!r}")
return zf.open(names[0])
def download_dtp_zip(*, client: httpx.Client) -> bytes:
"""Скачать ZIP dtp-stat.ru. Открытые данные, без auth/PII — стандартный verify."""
resp = client.get(SRC_URL, timeout=DOWNLOAD_TIMEOUT_SEC)
resp.raise_for_status()
return resp.content
_TRUNCATE_SQL = text("TRUNCATE dtp_incidents")
_INSERT_SQL = text(
"""
INSERT INTO dtp_incidents
(source_id, dtp_at, category, severity, dead, injured, address, light,
weather, nearby, lat, lon, geom)
VALUES
(CAST(:source_id AS text), CAST(:dtp_at AS timestamptz),
CAST(:category AS text), CAST(:severity AS text),
CAST(:dead AS integer), CAST(:injured AS integer),
CAST(:address AS text), CAST(:light AS text),
CAST(:weather AS text[]), CAST(:nearby AS text[]),
CAST(:lat AS double precision), CAST(:lon AS double precision),
ST_SetSRID(ST_MakePoint(CAST(:lon AS double precision), CAST(:lat AS double precision)),
4326))
ON CONFLICT (source_id) DO NOTHING
"""
)
def _row_params(row: DtpIncidentRow) -> dict[str, Any]:
return {
"source_id": row.source_id,
"dtp_at": row.dtp_at,
"category": row.category,
"severity": row.severity,
"dead": row.dead,
"injured": row.injured,
"address": row.address,
"light": row.light,
"weather": row.weather,
"nearby": row.nearby,
"lat": row.lat,
"lon": row.lon,
}
def load_dtp_incidents(
db: Session,
*,
src_path: str | Path | None = None,
client: httpx.Client | None = None,
chunk_size: int = INSERT_CHUNK_SIZE,
dry_run: bool = False,
) -> dict[str, int]:
"""TRUNCATE dtp_incidents; потоковый парс + bulk INSERT из ZIP dtp-stat.ru.
`src_path` локальный ZIP (тесты/ручной прогон); если None качаем `SRC_URL`
через `client` (обязателен параметром, чтобы был чем подменить в тестах;
создаётся временный `httpx.Client()` если не передан).
`dry_run=True` парсит и считает строки, НЕ трогает БД (ни TRUNCATE, ни INSERT).
Не коммитит коммитит caller (app/tasks/dtp_stat_refresh.py).
"""
owns_client = client is None
client = client or httpx.Client()
try:
if src_path is not None:
with zipfile.ZipFile(src_path) as zf, _open_geojson_in_zip(zf) as stream:
rows = list(iter_incidents(stream))
else:
data = download_dtp_zip(client=client)
with zipfile.ZipFile(io.BytesIO(data)) as zf, _open_geojson_in_zip(zf) as stream:
rows = list(iter_incidents(stream))
finally:
if owns_client:
client.close()
parsed = len(rows)
inserted = 0
if not dry_run:
db.execute(_TRUNCATE_SQL)
for i in range(0, len(rows), chunk_size):
chunk = rows[i : i + chunk_size]
result = db.execute(_INSERT_SQL, [_row_params(r) for r in chunk])
inserted += result.rowcount or 0
result_counts = {"parsed": parsed, "inserted": inserted}
logger.info("dtp_stat_loader load DONE (dry_run=%s): %s", dry_run, result_counts)
return result_counts

View file

@ -544,6 +544,19 @@ async def _job_osm_poi_ekb_refresh(
)
# ── dtp_stat_refresh — sync ZIP-скачивание+парс ДТП в executor ────────────────
async def _job_dtp_stat_refresh(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None:
from app.tasks.dtp_stat_refresh import run_dtp_stat_refresh
loop = asyncio.get_event_loop()
await loop.run_in_executor(
None,
lambda: run_dtp_stat_refresh(db, run_id=run_id, params=params),
)
# ── house_imv_backfill — async Avito-IMV с heartbeat, lifecycle в job ─────────
async def _job_house_imv_backfill(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
@ -910,6 +923,7 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]:
"domclick_city_sweep": Handler(_job_domclick_city_sweep, "domclick_city_sweep"),
"cadastral_geo_match": Handler(_job_cadastral_geo_match, "cadastral_geo_match"),
"osm_poi_ekb_refresh": Handler(_job_osm_poi_ekb_refresh, "osm_poi_ekb_refresh"),
"dtp_stat_refresh": Handler(_job_dtp_stat_refresh, "dtp_stat_refresh"),
"house_imv_backfill": Handler(_job_house_imv_backfill, "house_imv_backfill"),
"house_dedup_merge": Handler(_job_house_dedup_merge, "house_dedup_merge"),
"domrf_kapremont_load": Handler(_job_domrf_kapremont_load, "domrf_kapremont_load"),

View file

@ -0,0 +1,119 @@
"""ДТП (dtp-stat.ru) refresh — CLI + scheduler run-lifecycle wrapper (#3410).
Наполняет `dtp_incidents` (пустая при деплое, миграция 294) через
`app/services/dtp_stat_loader.load_dtp_incidents` (скачивание ZIP + потоковый парс +
TRUNCATE+INSERT). Scheduler source='dtp_stat_refresh' (seed 293, enabled=false
источник заморожен, см. докстринг loader'а; первый прогон ручной).
Запуск из контейнера:
python -m app.tasks.dtp_stat_refresh
python -m app.tasks.dtp_stat_refresh --dry-run
python -m app.tasks.dtp_stat_refresh --src-path /path/to/local.zip
psycopg v3: `CAST(:x AS type)`, никогда `:x::type`.
"""
from __future__ import annotations
import argparse
import logging
import time
from dataclasses import dataclass, field
from sqlalchemy.orm import Session
from app.services import scrape_runs as runs_mod
from app.services.dtp_stat_loader import load_dtp_incidents
logger = logging.getLogger(__name__)
@dataclass
class DtpStatRefreshResult:
parsed: int = 0
inserted: int = 0
duration_sec: float = field(default=0.0)
def to_counters(self) -> dict[str, int]:
return {
"parsed": self.parsed,
"inserted": self.inserted,
"duration_sec": int(self.duration_sec),
}
def run_dtp_stat_refresh(db: Session, *, run_id: int, params: dict) -> DtpStatRefreshResult:
"""Run-lifecycle wrapper для scheduler'а (source='dtp_stat_refresh') и ручного прогона.
params:
dry_run bool, по умолчанию False. True: парсит и считает, БД не трогает.
src_path str, локальный путь к ZIP (для отладки/тестов, минуя скачивание).
Финализирует scrape_runs (mark_done / mark_failed) со счётчиками.
"""
dry_run = bool(params.get("dry_run", False))
src_path = params.get("src_path")
counters: dict[str, int] = {"parsed": 0, "inserted": 0}
start = time.monotonic()
try:
runs_mod.update_heartbeat(db, run_id, counters)
load_counts = load_dtp_incidents(db, src_path=src_path, dry_run=dry_run)
if not dry_run:
db.commit()
result = DtpStatRefreshResult(
parsed=load_counts["parsed"],
inserted=load_counts["inserted"],
duration_sec=time.monotonic() - start,
)
counters = result.to_counters()
runs_mod.mark_done(db, run_id, counters)
logger.info(
"run_dtp_stat_refresh: run_id=%d DONE dry_run=%s parsed=%d inserted=%d duration=%.1fs",
run_id,
dry_run,
result.parsed,
result.inserted,
result.duration_sec,
)
return result
except Exception as exc:
logger.exception("run_dtp_stat_refresh: run_id=%d FAILED", run_id)
try:
db.rollback()
except Exception:
pass
runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters)
raise
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--src-path", default=None, help="Локальный ZIP вместо скачивания SRC_URL")
parser.add_argument("--dry-run", action="store_true", help="Парсить и считать, не писать в БД")
return parser
def main() -> None:
logging.basicConfig(
level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s"
)
args = build_parser().parse_args()
from app.core.db import SessionLocal
db = SessionLocal()
try:
counts = load_dtp_incidents(db, src_path=args.src_path, dry_run=args.dry_run)
if not args.dry_run:
db.commit()
logger.info("dtp_stat_refresh CLI DONE: dry_run=%s %s", args.dry_run, counts)
finally:
db.close()
if __name__ == "__main__":
main()

View file

@ -0,0 +1,79 @@
-- 294_dtp_incidents.sql
-- ДТП (dtp-stat.ru, первоисточник — открытые данные ГИБДД), Свердловская обл. (#3410).
--
-- WHY: слой ДТП нужен как радиусный "фактор безопасности" района (по образцу
-- osm_poi_ekb_local / 169_osm_poi_ekb_local.sql — та же архитектура: пустая таблица +
-- индексы на деплое, наполнение отдельным рефреш-джобом, никакого чтения внешнего
-- источника при прогоне миграций).
--
-- ИСТОЧНИК: https://dtp-stat.ru/media/opendata/sverdlovskaia-oblast.geojson.zip — открытый
-- агрегатор данных ГИБДД, без auth. Дамп заморожен (Last-Modified 26.02.2026 на момент
-- проверки 08.09.2026) — см. докстринг app/services/dtp_stat_loader.py, пустая дельта на
-- рефреше это НЕ баг, а факт источника.
--
-- ПДн (важно): properties.vehicles[].participants[] источника содержит пол/роль/нарушения
-- физлиц-участников ДТП. Эта таблица их СОЗНАТЕЛЬНО НЕ хранит — ни колонкой, ни в jsonb.
-- Загрузчик (dtp_stat_loader.py) участников/транспорт из парсинга просто не извлекает.
-- Берём только агрегаты (dead/injured count) и не-персональные атрибуты происшествия.
--
-- ИНДЕКСЫ (урок 270_houses_geog_gist_idx.sql / 134_listings_geom_geography_gist.sql): весь
-- код МЕРЫ ходит радиусными запросами через ST_DWithin(geom::geography, …) — обычный GIST
-- по geometry планировщик под такой предикат не берёт (прод-замер houses: 70.7 мс на
-- bitmap-фильтр всех строк; после функционального индекса по geography — 38 мс на listings).
-- Поэтому сразу оба индекса: geometry (на будущее/для прочих операторов) и функциональный
-- по (geom::geography) — под фактический предикат dtp_index.py.
--
-- Идемпотентно: CREATE TABLE/INDEX IF NOT EXISTS. Безопасно перезапускать.
BEGIN;
SET LOCAL lock_timeout = '5s';
CREATE TABLE IF NOT EXISTS dtp_incidents (
id bigserial PRIMARY KEY,
source_id text UNIQUE,
dtp_at timestamptz,
category text,
severity text,
dead integer,
injured integer,
address text,
light text,
weather text[],
nearby text[],
lat double precision,
lon double precision,
geom geometry(Point, 4326),
loaded_at timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE dtp_incidents IS
'ДТП по Свердловской обл. (dtp-stat.ru, первоисточник — открытые данные ГИБДД, #3410). '
'Пишет app/tasks/dtp_stat_refresh.py (TRUNCATE+INSERT, scheduler source=dtp_stat_refresh, '
'seed 293, enabled=false — включение вручную). Читает app/services/dtp_index.py — '
'радиусные агрегаты (число ДТП/тяжёлых/погибших/раненых) для карточки района. '
'ПДн участников (vehicles/participants: пол, роль, нарушения физлиц) осознанно НЕ '
'хранятся — ни колонкой, ни в jsonb; источник даёт их построчно на транспорт/участника, '
'загрузчик их не извлекает вовсе.';
COMMENT ON COLUMN dtp_incidents.source_id IS 'properties.id из dtp-stat GeoJSON — ключ идемпотентности (UNIQUE).';
COMMENT ON COLUMN dtp_incidents.dtp_at IS 'properties.datetime, распарсено в timestamptz.';
COMMENT ON COLUMN dtp_incidents.category IS 'properties.category — тип ДТП ("Столкновение" и т.п.).';
COMMENT ON COLUMN dtp_incidents.severity IS 'properties.severity — тяжесть ("Тяжёлый"/"Лёгкий"/…).';
COMMENT ON COLUMN dtp_incidents.dead IS 'properties.dead_count — погибшие, агрегат, без ФИО/пола участников.';
COMMENT ON COLUMN dtp_incidents.injured IS 'properties.injured_count — раненые, агрегат.';
COMMENT ON COLUMN dtp_incidents.weather IS 'properties.weather — список погодных условий на момент ДТП.';
COMMENT ON COLUMN dtp_incidents.nearby IS 'properties.nearby — список объектов УДС/притяжения рядом с местом ДТП.';
COMMENT ON COLUMN dtp_incidents.geom IS 'ST_SetSRID(ST_MakePoint(lon, lat), 4326) — строится явно в загрузчике, без триггера (как osm_poi_ekb_local).';
-- GIST по geometry — на будущее / прочие geometry-операторы.
CREATE INDEX IF NOT EXISTS dtp_incidents_geom_idx
ON dtp_incidents USING GIST (geom);
-- Функциональный GIST по (geom::geography) — под фактический предикат
-- ST_DWithin(geom::geography, …) в app/services/dtp_index.py (см. WHY выше).
CREATE INDEX IF NOT EXISTS dtp_incidents_geog_idx
ON dtp_incidents USING GIST ((geom::geography));
CREATE INDEX IF NOT EXISTS dtp_incidents_dtp_at_idx
ON dtp_incidents (dtp_at);
COMMIT;

View file

@ -0,0 +1,52 @@
-- 295_scrape_schedules_seed_dtp_stat_refresh.sql
-- Scheduler seed for the ДТП local-mirror refresh job (#3410).
--
-- WHAT (source='dtp_stat_refresh'):
-- trigger_dtp_stat_refresh_run (scheduler.py) → run_dtp_stat_refresh
-- (app/tasks/dtp_stat_refresh.py) → app/services/dtp_stat_loader.py: download ZIP
-- (dtp-stat.ru), потоковый парс GeoJSON, TRUNCATE dtp_incidents; INSERT — построчно
-- строим geom из lon/lat явно (ST_SetSRID(ST_MakePoint(...))).
--
-- ПОЧЕМУ enabled=false (по образцу house_dedup_merge, seed 135): источник — открытый
-- годовой дамп, а не живой поток; на момент проверки 08.09.2026 Last-Modified
-- 26.02.2026 — дельта между рефрешами пустая по построению, пустая дельта НЕ баг
-- (см. докстринг app/services/dtp_stat_loader.py). Первый прогон — вручную/через
-- orchestrator, включение регулярного расписания — отдельное решение продукта.
--
-- interval_days=30 (default_params, НЕ отдельная колонка — compute_next_run_at читает
-- именно default_params.interval_days, ровно как в 135/212): месячный дамп двигается
-- редко, ежемесячная проверка — с запасом.
--
-- Window 11:00-12:00 UTC (14:00-15:00 МСК): после osm_poi_ekb_refresh (10:00-11:00
-- UTC, 170), не пересекает другие DB-only/сетевые джобы. Внешний HTTP на dtp-stat.ru,
-- но разовый один ZIP-запрос ~5 МБ — не конкурирует за прокси-пул (обычные площадки
-- не трогает).
--
-- next_run_at = завтра 11:00 UTC (NOT NULL — конвенция сидов; безвредно при
-- enabled=false, т.к. get_due_schedules сначала фильтрует enabled=true).
--
-- DEPENDENCIES: 052_scrape_schedules.sql (table + UNIQUE(source)), 294_dtp_incidents.sql.
-- Idempotent: ON CONFLICT (source) DO NOTHING.
BEGIN;
INSERT INTO scrape_schedules (
source,
enabled,
window_start_hour,
window_end_hour,
next_run_at,
default_params
)
VALUES
(
'dtp_stat_refresh',
false,
11,
12,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 11)) AT TIME ZONE 'UTC',
'{"interval_days": 30}'::jsonb
)
ON CONFLICT (source) DO NOTHING;
COMMIT;

View file

@ -32,6 +32,8 @@ dependencies = [
"playwright>=1.45", # Playwright client для connect к tradein-browser (#905)
"prometheus-client>=0.21.0", # /metrics — экспозиция и process-коллекторы (#3078)
"openpyxl>=3.1.0", # ЦБ РФ XLSX-дашборды ипотеки (app/services/cbr_macro.py)
"ijson>=3.3.0", # потоковый парс ~71 МБ GeoJSON dtp-stat.ru —
# без json.load() целиком в память
"scraper-kit", # internal workspace-package (#2137) — общие утилиты скрапперов;
# резолвится из workspace (см. [tool.uv.sources]), не с PyPI.
# Docker-context = tradein-mvp root, uv sync ставит editable.

View file

@ -0,0 +1,88 @@
"""Тесты радиусных агрегатов ДТП (app/services/dtp_index.py, мигр. 294, #3410).
Coverage:
- _bbox_from_point: геометрически разумный bbox (south<north, west<east, точка внутри).
- compute_dtp_stats: graceful fallback при пустой dtp_incidents (status="unavailable",
БЕЗ сфабрикованных нулей-как-"ok") MagicMock db.
- compute_dtp_stats: "ok" путь считает агрегаты из mapping-строки (MagicMock db).
- Статические asserts по _DTP_STATS_SQL: ST_DWithin, geom::geography, CAST(...),
make_interval (не bare :years::interval), нет bare :x:: кастов параметров.
"""
from __future__ import annotations
import os
import re
from unittest.mock import MagicMock
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.services import dtp_index as di
def test_bbox_from_point_contains_center() -> None:
lat, lon, radius_m = 56.8389, 60.6057, 1000
south, north, west, east = di._bbox_from_point(lat, lon, radius_m)
assert south < lat < north
assert west < lon < east
# Порядок величины: ~1000м ~ 0.009° по широте — не на порядок больше/меньше.
assert 0.001 < (north - south) < 0.05
assert 0.001 < (east - west) < 0.1
def test_compute_dtp_stats_unavailable_on_empty_table() -> None:
db = MagicMock()
db.execute.return_value.scalar.return_value = 0
result = di.compute_dtp_stats(db, 56.8389, 60.6057)
assert result.status == "unavailable"
assert result.incidents_count == 0
assert result.dead == 0
assert result.injured == 0
# Только один execute — count(*) проверка; агрегатный запрос не выполняется.
assert db.execute.call_count == 1
def test_compute_dtp_stats_ok_path() -> None:
db = MagicMock()
count_result = MagicMock()
count_result.scalar.return_value = 31253
stats_row = {
"incidents_count": 12,
"severe_count": 3,
"dead_total": 1,
"injured_total": 9,
}
stats_result = MagicMock()
stats_result.mappings.return_value.first.return_value = stats_row
db.execute.side_effect = [count_result, stats_result]
result = di.compute_dtp_stats(db, 56.8389, 60.6057, radius_m=800, years=3)
assert result.status == "ok"
assert result.radius_m == 800
assert result.years == 3
assert result.incidents_count == 12
assert result.severe_count == 3
assert result.dead == 1
assert result.injured == 9
stats_call = db.execute.call_args_list[1]
assert stats_call.args[0] is di._DTP_STATS_SQL
params = stats_call.args[1]
assert params["radius_m"] == 800
assert params["years"] == 3
def test_dtp_stats_sql_static_shape() -> None:
sql = re.sub(r"\s+", " ", str(di._DTP_STATS_SQL.text))
assert "ST_DWithin(" in sql
assert "geom::geography" in sql
assert "CAST(:radius_m AS double precision)" in sql
assert "CAST(:bbox_south AS double precision)" in sql
assert "make_interval(years => CAST(:years AS integer))" in sql
# psycopg v3 trap: bind-параметр не должен иметь :name::type сразу за собой.
assert not re.search(r":[a-zA-Z_]+::[a-zA-Z]", sql)

View file

@ -0,0 +1,272 @@
"""Тесты dtp-stat.ru loader'а (app/services/dtp_stat_loader.py, мигр. 294, #3410).
Coverage:
- Чистые парс-хелперы: _parse_datetime / _parse_str_list / _parse_int.
- _parse_feature на РЕАЛЬНОЙ первой записи sverdlovskaia-oblast.geojson (id=74, "ул
Победы, 31") — обязательный тест, что vehicles/participants (ПДн физлиц) НЕ
попадают в результат парсинга ни в каком виде.
- iter_incidents потоковый парс (ijson) мини-FeatureCollection, пропуск невалидных
фич (нет geometry.Point, нет id).
- download_dtp_zip / load_dtp_incidents(src_path=...) HTTP замокан httpx.MockTransport,
zip без .geojson entry -> ValueError.
- load_dtp_incidents dry_run: TRUNCATE/INSERT не вызываются, счётчик parsed верный.
- load_dtp_incidents real-run: TRUNCATE вызван, INSERT чанками, rowcount суммируется
(MagicMock db).
- Статические asserts по SQL: _INSERT_SQL (ON CONFLICT DO NOTHING, CAST(...), geom
строится явно ST_SetSRID/ST_MakePoint, нет bare :x:: кастов).
- Гейт: `load_dtp_incidents` не коммитит (коммитит caller app/tasks/dtp_stat_refresh.py).
"""
from __future__ import annotations
import inspect
import io
import json
import os
import re
import zipfile
from pathlib import Path
from unittest.mock import MagicMock
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import httpx
import pytest
from app.services import dtp_stat_loader as dsl
# ─────────────────────────────────────────────────────────────────────────────
# Фикстура: РЕАЛЬНАЯ первая запись sverdlovskaia-oblast.geojson (проверено 08.09.2026,
# id=74, "г Екатеринбург, ул Победы, 31") — включает vehicles/participants (ПДн) НАРОЧНО,
# чтобы доказать, что парсер их не трогает.
# ─────────────────────────────────────────────────────────────────────────────
_FEATURE_74 = json.loads(
'{"type": "Feature", "geometry": {"type": "Point", "coordinates": [60.603333, 56.896944]}, '
'"properties": {"id": 74, "tags": ["Дорожно-транспортные происшествия"], '
'"light": "В темное время суток, освещение включено", '
'"point": {"lat": 56.896944, "long": 60.603333}, '
'"nearby": ["Отсутствие в непосредственной близости объектов УДС и объектов притяжения", '
'"Перегон (нет объектов на месте ДТП)"], "region": "Свердловская область", '
'"scheme": "200", "address": "г Екатеринбург, ул Победы, 31", "weather": ["Снегопад"], '
'"category": "Столкновение", "datetime": "2015-01-01 03:00:00", "severity": "Тяжёлый", '
'"vehicles": [{"year": 2006, "brand": "ВАЗ", "color": "Иные цвета", '
'"model": "Нива ВАЗ-2121 и модификации", "category": "D-класс (средний) до 4,6 м", '
'"participants": [{"id": 1649862, "role": "Водитель", "gender": "Мужской", '
'"violations": ["Выезд на полосу встречного движения"], '
'"health_status": "Раненый, находящийся (находившийся) на стационарном лечении", '
'"years_of_driving_experience": 6}]}, {"year": 2013, "brand": "DAEWOO", "color": "Белый", '
'"model": "Pathfinder", "category": "D-класс (средний) до 4,6 м", "participants": '
'[{"id": 1649863, "role": "Водитель", "gender": "Мужской", "violations": ["Нет нарушений"], '
'"health_status": "Не пострадал", "years_of_driving_experience": 15}, '
'{"id": 1649864, "role": "Пассажир", "gender": "Женский", "violations": ["Нет нарушений"], '
'"health_status": "Раненый, находящийся (находившийся) на стационарном лечении", '
'"years_of_driving_experience": null}, {"id": 1649865, "role": "Пассажир", '
'"gender": "Мужской", "violations": ["Нет нарушений"], '
'"health_status": "Раненый, находящийся (находившийся) на стационарном лечении", '
'"years_of_driving_experience": null}]}], "dead_count": 0, "gibdd_number": "157467063", '
'"participants": [], "injured_count": 3, "parent_region": null, '
'"road_conditions": ["Не установлены"], "participants_count": 4, '
'"participant_categories": ["Все участники"]}}'
)
# ─────────────────────────────────────────────────────────────────────────────
# Чистые парс-хелперы
# ─────────────────────────────────────────────────────────────────────────────
def test_parse_datetime_happy_and_garbage() -> None:
dt = dsl._parse_datetime("2015-01-01 03:00:00")
assert dt is not None
assert (dt.year, dt.month, dt.day, dt.hour) == (2015, 1, 1, 3)
assert dsl._parse_datetime(None) is None
assert dsl._parse_datetime("") is None
assert dsl._parse_datetime("не дата") is None
def test_parse_str_list() -> None:
assert dsl._parse_str_list(["a", "b"]) == ["a", "b"]
assert dsl._parse_str_list([]) is None
assert dsl._parse_str_list(None) is None
assert dsl._parse_str_list("не список") is None
def test_parse_int() -> None:
assert dsl._parse_int(3) == 3
assert dsl._parse_int(0) == 0
assert dsl._parse_int(None) is None
assert dsl._parse_int("3") is None
assert dsl._parse_int(True) is None
# ─────────────────────────────────────────────────────────────────────────────
# _parse_feature — ПДн-гейт
# ─────────────────────────────────────────────────────────────────────────────
def test_parse_feature_real_record_no_pii() -> None:
row = dsl._parse_feature(_FEATURE_74)
assert row is not None
assert row.source_id == "74"
assert row.address == "г Екатеринбург, ул Победы, 31"
assert row.category == "Столкновение"
assert row.severity == "Тяжёлый"
assert row.dead == 0
assert row.injured == 3
assert row.weather == ["Снегопад"]
assert len(row.nearby) == 2
assert row.lat == pytest.approx(56.896944)
assert row.lon == pytest.approx(60.603333)
assert (row.dtp_at.year, row.dtp_at.month, row.dtp_at.day) == (2015, 1, 1)
# ПДн-гейт: dataclass не несёт ни vehicles, ни participants ни в каком поле.
field_names = {f for f in dsl.DtpIncidentRow.__dataclass_fields__}
assert "vehicles" not in field_names
assert "participants" not in field_names
row_values = repr(row)
assert "Водитель" not in row_values
assert "Мужской" not in row_values
assert "1649862" not in row_values
def test_parse_feature_skips_non_point_geometry() -> None:
feature = {"geometry": {"type": "Polygon", "coordinates": []}, "properties": {"id": 1}}
assert dsl._parse_feature(feature) is None
def test_parse_feature_skips_missing_id() -> None:
feature = {
"geometry": {"type": "Point", "coordinates": [60.0, 56.0]},
"properties": {},
}
assert dsl._parse_feature(feature) is None
# ─────────────────────────────────────────────────────────────────────────────
# iter_incidents — потоковый парс
# ─────────────────────────────────────────────────────────────────────────────
def _feature_collection_bytes(features: list[dict]) -> bytes:
return json.dumps({"type": "FeatureCollection", "features": features}).encode("utf-8")
def test_iter_incidents_streams_and_skips_invalid() -> None:
garbage = {
"type": "Feature",
"geometry": {"type": "Point", "coordinates": []},
"properties": {},
}
data = _feature_collection_bytes([_FEATURE_74, garbage])
rows = list(dsl.iter_incidents(io.BytesIO(data)))
assert len(rows) == 1
assert rows[0].source_id == "74"
# ─────────────────────────────────────────────────────────────────────────────
# download_dtp_zip / load_dtp_incidents — HTTP замокан
# ─────────────────────────────────────────────────────────────────────────────
def _zip_bytes(inner_name: str, payload: bytes) -> bytes:
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr(inner_name, payload)
return buf.getvalue()
def test_load_dtp_incidents_dry_run_downloads_and_counts() -> None:
data = _feature_collection_bytes([_FEATURE_74])
zip_bytes = _zip_bytes("sverdlovskaia-oblast.geojson", data)
def handler(request: httpx.Request) -> httpx.Response:
assert str(request.url) == dsl.SRC_URL
return httpx.Response(200, content=zip_bytes)
client = httpx.Client(transport=httpx.MockTransport(handler))
db = MagicMock()
counts = dsl.load_dtp_incidents(db, client=client, dry_run=True)
assert counts == {"parsed": 1, "inserted": 0}
db.execute.assert_not_called()
def test_load_dtp_incidents_real_run_truncates_and_inserts(tmp_path: Path) -> None:
data = _feature_collection_bytes([_FEATURE_74])
zip_path = tmp_path / "local.zip"
zip_path.write_bytes(_zip_bytes("sverdlovskaia-oblast.geojson", data))
db = MagicMock()
db.execute.return_value.rowcount = 1
counts = dsl.load_dtp_incidents(db, src_path=zip_path, dry_run=False)
assert counts == {"parsed": 1, "inserted": 1}
calls = db.execute.call_args_list
assert calls[0].args[0] is dsl._TRUNCATE_SQL
assert calls[1].args[0] is dsl._INSERT_SQL
inserted_rows = calls[1].args[1]
assert len(inserted_rows) == 1
assert inserted_rows[0]["source_id"] == "74"
assert "vehicles" not in inserted_rows[0]
assert "participants" not in inserted_rows[0]
def test_zip_without_geojson_raises() -> None:
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("readme.txt", "not geojson")
with zipfile.ZipFile(buf) as zf, pytest.raises(ValueError, match="geojson"):
dsl._open_geojson_in_zip(zf)
# ─────────────────────────────────────────────────────────────────────────────
# Статические asserts по SQL + гейт "сервис не коммитит"
# ─────────────────────────────────────────────────────────────────────────────
def test_insert_sql_static_shape() -> None:
sql = re.sub(r"\s+", " ", str(dsl._INSERT_SQL.text))
assert "ON CONFLICT (source_id) DO NOTHING" in sql
assert "CAST(:source_id AS text)" in sql
assert "CAST(:weather AS text[])" in sql
assert "CAST(:nearby AS text[])" in sql
assert "ST_SetSRID(ST_MakePoint(" in sql
# psycopg v3 trap: bind-параметр не должен иметь :name::type сразу за собой.
assert not re.search(r":[a-zA-Z_]+::[a-zA-Z]", sql)
def test_truncate_sql_static_shape() -> None:
assert str(dsl._TRUNCATE_SQL.text).strip() == "TRUNCATE dtp_incidents"
def test_load_dtp_incidents_does_not_commit() -> None:
src = inspect.getsource(dsl.load_dtp_incidents)
assert "db.commit()" not in src
def _feature(lon: float, lat: float, source_id: int = 1) -> dict:
return {
"type": "Feature",
"geometry": {"type": "Point", "coordinates": [lon, lat]},
"properties": {"id": source_id, "datetime": "2020-05-05 12:00:00"},
}
@pytest.mark.parametrize(
("lon", "lat"),
[
(0.0, 0.0), # null island — 10 таких в реальном дампе
(1.0, 1.0),
(47.0, 41.0),
(55.3, 22.183),
(59.852, 5.8),
],
)
def test_parse_feature_drops_broken_coordinates(lon: float, lat: float) -> None:
"""Битые координаты источника не должны попадать в geo-слой.
Реальный дамп по Свердловской области (2026-02-26): из 31 253 записей 86 имеют
координаты вне региона, в т.ч. 10 ровно в (0,0). Слой кормит радиусные агрегаты
локации, поэтому мусор режем на парсе.
"""
assert dsl._parse_feature(_feature(lon, lat)) is None
@pytest.mark.parametrize(("lon", "lat"), [(60.6055, 56.8386), (56.94, 63.283)])
def test_parse_feature_keeps_region_points_including_northern_tip(lon: float, lat: float) -> None:
"""Рамка шире официальных границ — север области (~63°) не срезается."""
row = dsl._parse_feature(_feature(lon, lat))
assert row is not None
assert row.lat == lat and row.lon == lon

58
tradein-mvp/uv.lock generated
View file

@ -616,6 +616,62 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
]
[[package]]
name = "ijson"
version = "3.5.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3a/06/b31f040a8764336a11152e474a7abcb3782fedb0d1cdf78f442b82878c56/ijson-3.5.1.tar.gz", hash = "sha256:af40bd1a85f55db0b8b30715c858761306bd92d5590148636f75c3309e6e76bd", size = 69913, upload-time = "2026-07-06T17:37:42.923Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5b/6e/f3ded1ebb85ccc89a30f7b10a0076f30db70ae1d1e0b6423ff93c57b7539/ijson-3.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ee60c7741012671867678eae71c51872cac938b76f3d4ca40a778e6c361774d2", size = 88643, upload-time = "2026-07-06T17:36:28.529Z" },
{ url = "https://files.pythonhosted.org/packages/ee/f2/18f14a1d79ef4898e746b4f50dcdbe60abab317cc2bd8390f043b9553c4e/ijson-3.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:11c1d7d36a13054b5872ecd5d745dc4009d9abdbcba2312de69e66c2f92a46d2", size = 60611, upload-time = "2026-07-06T17:36:29.597Z" },
{ url = "https://files.pythonhosted.org/packages/30/c7/6e3e591324fd4c7a7a9e1bc23548bacbd84c0d91766b71f09f13e945e7e9/ijson-3.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b9517efbe6604bce16f3e50d49b0cd1bdc58917f98cf2eab026599c5c0422991", size = 60447, upload-time = "2026-07-06T17:36:30.747Z" },
{ url = "https://files.pythonhosted.org/packages/4d/a5/9af7be670381ddac26dd55107ed0110b50f5161673b053311db67f510dcc/ijson-3.5.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ea4fd7bec203a600b1cc88a492dfe6b75ce4b1b87488a66adcd5406022213f64", size = 139092, upload-time = "2026-07-06T17:36:31.749Z" },
{ url = "https://files.pythonhosted.org/packages/41/fb/f9c1664d75467453e6bd4e5f9cd2211b730b09e049445ab64cbac68cc6a3/ijson-3.5.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:350caea815e53151994b597abc80cf669454276b5ac6aadcec69ef6d48f7e90b", size = 149921, upload-time = "2026-07-06T17:36:32.912Z" },
{ url = "https://files.pythonhosted.org/packages/43/80/d20b1c49c4aa7cc6644131e2e57192b45346ef4816566ed1cd9fd05bae38/ijson-3.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e4fcebfe1685bb7ba06a8255a5d428ea6b4b895d7acf979cb637d8bbc9db2f47", size = 149848, upload-time = "2026-07-06T17:36:34.032Z" },
{ url = "https://files.pythonhosted.org/packages/fd/fc/5baa710869f5ab939e6233583ced1546889b55c35f35b844c518ac10abc3/ijson-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d78f362f51c8691798758a9e6ac3c9d385ee1228cb82987c91562a2fae235cd3", size = 150810, upload-time = "2026-07-06T17:36:35.19Z" },
{ url = "https://files.pythonhosted.org/packages/54/16/a12b3d987a5c1677b04557c6f9b9feb7e04b7d4171e9a344856cb9136e9b/ijson-3.5.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0b184180d45f85fd4479659582749b109e49f4a29c21ac700ccc9c2280fe015e", size = 142989, upload-time = "2026-07-06T17:36:36.23Z" },
{ url = "https://files.pythonhosted.org/packages/ed/63/1026c535671fc334fc85aeb78f0945c825e7a338575edc753c0f455459ae/ijson-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e353891d33a2e6aa5caf72c2a5fbadd7a46f5f9b32dcfd0c84113b2444c255b8", size = 151702, upload-time = "2026-07-06T17:36:37.296Z" },
{ url = "https://files.pythonhosted.org/packages/cb/af/b58aa3a2bf4d31c388ea78b49826605f60932891ce97e404d196766b4ea3/ijson-3.5.1-cp312-cp312-win32.whl", hash = "sha256:936f28671f018f8ac4d3f003ae9fa01d0467ab4ef4cfd0c97f23beda485b61c6", size = 52613, upload-time = "2026-07-06T17:36:38.345Z" },
{ url = "https://files.pythonhosted.org/packages/04/66/ce70a92949c2a753dad91fdd5761dc14f3a44517e80cfc3c26612982ed61/ijson-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:322c783f3ee0c6b383bbd4db88370b10172168808cc2a0bf811f1253f7435602", size = 54729, upload-time = "2026-07-06T17:36:39.337Z" },
{ url = "https://files.pythonhosted.org/packages/a5/ff/e17784240c9cf1d58de2f2853ebaf9cc54f6bce117a1f12a6150bbb4a5aa/ijson-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:e2ac204b59f09e38e16d277f906240e9fd38780e42076599419265af183dc4b4", size = 53714, upload-time = "2026-07-06T17:36:40.308Z" },
{ url = "https://files.pythonhosted.org/packages/fd/c0/5384ccf4fc497ae3dc79a5a28561b05518b503ade29daf3898168d640406/ijson-3.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:3c0556d628443d3e871f414855313b2ae6cd9faa0104de3316bd8db03aab1589", size = 88652, upload-time = "2026-07-06T17:36:41.278Z" },
{ url = "https://files.pythonhosted.org/packages/8e/42/58769b8b6d614adb15c2c938c77bcdbfadfba8b1d21a98b5b09cb8961adc/ijson-3.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:12aa7fcf46f0fdc8e9e7cf37541e1dc20ac3f9243a23f4d346ab5395f72b0fe2", size = 60607, upload-time = "2026-07-06T17:36:42.697Z" },
{ url = "https://files.pythonhosted.org/packages/db/4a/8322c2824c24184880587bbca45531127a21a4b3bfc897f13427fea02424/ijson-3.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a96066d8c12a18ce2fa90579f2bbf991377cb71725874932e4a5d855226c162a", size = 60447, upload-time = "2026-07-06T17:36:43.791Z" },
{ url = "https://files.pythonhosted.org/packages/f4/43/7bdca8f733c45ce97f61a64fadd3e51d255c4c9b467345cbf71ccc7bb368/ijson-3.5.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a19413a092d458a57aaa574fec08e265851d3b5c6e018377f426cd5e70b91280", size = 138889, upload-time = "2026-07-06T17:36:45.081Z" },
{ url = "https://files.pythonhosted.org/packages/e7/dc/e8a2e63700ab1d63aaf3fa38c454f8178eaa5b80a6d7c019d1d61b490a6c/ijson-3.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65974568748678165d7e90e3e7ce2f7c233cfe4de6c37fbb0760941c97e14632", size = 149933, upload-time = "2026-07-06T17:36:46.312Z" },
{ url = "https://files.pythonhosted.org/packages/d9/56/640a4d980f7f2c11e399a7fd5ccb9e3d3c9e1dec3a1d5a10024570697c25/ijson-3.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bad5d55c99c89de8cd0a4cded51f86427ba3353c4dccca37ec2e32e06f26b437", size = 149857, upload-time = "2026-07-06T17:36:47.309Z" },
{ url = "https://files.pythonhosted.org/packages/3d/a1/c953e22c83992b69ae538a83b3678d28768f1a48042fc7794733423a5ce7/ijson-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1a38d503ce343952e88edfd9a27296a4ec96af7073a9db58b3df6233367f75fc", size = 151141, upload-time = "2026-07-06T17:36:48.405Z" },
{ url = "https://files.pythonhosted.org/packages/9e/ab/8fe5b7269b140e6e5f8837a33ce980fd9b67c70d0f8114289ed1cea4dace/ijson-3.5.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2f41982c73896acab4a2a14faa14e152e444bd69f37c3139204429fd3fe65a10", size = 143112, upload-time = "2026-07-06T17:36:50.353Z" },
{ url = "https://files.pythonhosted.org/packages/78/f3/23d1284edcde50ba337ddfba5b5d59f8273084d98b28af94715e73dd2b64/ijson-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3321fede2b638d400de0036889a3a25c3bb689feb8df45e70a393346aad6194f", size = 152184, upload-time = "2026-07-06T17:36:51.536Z" },
{ url = "https://files.pythonhosted.org/packages/82/4e/df61be89dd295e4da722ec96ba03b1765bcb2becdaaaede9c96a7d2365b6/ijson-3.5.1-cp313-cp313-win32.whl", hash = "sha256:af6ddbd10ac9bce87a835f2de3ec61455ec435c54e7e0ba7b17c31c66de6f164", size = 52607, upload-time = "2026-07-06T17:36:52.596Z" },
{ url = "https://files.pythonhosted.org/packages/4a/d9/03e5dbd3ef7e0cee06fbef0f87b91d7ce1c07fae9b5a1b0ca8b895de62c4/ijson-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:1de3de278b0ffb40338374ad2a730e1c56f933e0706b1815ebeb07b82239b1a3", size = 54730, upload-time = "2026-07-06T17:36:53.526Z" },
{ url = "https://files.pythonhosted.org/packages/38/30/4f37076c88a96a1a5e44df38b59fade4f59eaef87ef8b5162d55b2d426d5/ijson-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:c8a36a19b92cb7172c6448ab94f446033cfa3129dc4894aebe205f96b3fabf42", size = 53719, upload-time = "2026-07-06T17:36:54.592Z" },
{ url = "https://files.pythonhosted.org/packages/f9/17/54f9180c0da9a9e96e5b3791bc74093f029a2344678b4da218c2699465bf/ijson-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:21e1a250b254edba2f0dd7272a4c56f0a879aabe328d9e306dd1fc115f560e74", size = 89223, upload-time = "2026-07-06T17:36:55.534Z" },
{ url = "https://files.pythonhosted.org/packages/09/70/0ee0d2627c534174455a745ca25284797e71b0d6e2b2a1b31cc914e7b462/ijson-3.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e01f95433725e2df62d682ff88e4a57bb694385ff2362bc364adec961167ae04", size = 60831, upload-time = "2026-07-06T17:36:56.554Z" },
{ url = "https://files.pythonhosted.org/packages/8d/e6/56f64ba7a3e7a25d9a9fbbeb4c30597d6b76c1094cc2041d11a3224b562c/ijson-3.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:539e8d6cca079bcbb68c390e55148f908e0a943a34f7dd321248637c6272adca", size = 60752, upload-time = "2026-07-06T17:36:57.826Z" },
{ url = "https://files.pythonhosted.org/packages/3e/2b/5a55db881f1b043cd6d5716578937a60ac16348be1a3afbf846b21cf4b44/ijson-3.5.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:32f64051be2f990d8ae7b614b5abdf4a7bead510ce3666568d7403c6c46ce4d8", size = 140783, upload-time = "2026-07-06T17:36:58.984Z" },
{ url = "https://files.pythonhosted.org/packages/2e/61/f7783cc18672dc31544141139efd187fb34795d24e573fed6abea6b776c7/ijson-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cd0dfc5a788d0b0c2f1eab258b9dabdeefc631ca8ef87644a999f633b0b2555a", size = 149976, upload-time = "2026-07-06T17:37:00.235Z" },
{ url = "https://files.pythonhosted.org/packages/5f/d6/4182dd63b6b70eae4f5208c53558a050895a40734dff283463033c153742/ijson-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42bfda7858d99ee9777ec28cb6d347928249eefeb577f9b0a67503c18f7ebb6a", size = 149317, upload-time = "2026-07-06T17:37:01.476Z" },
{ url = "https://files.pythonhosted.org/packages/01/b1/a675e4a9b428a0ef556e7d718bf0e6885e3e5543042248a1a7030899a3d4/ijson-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c4b9a28e9719d1aebebe93ad8dc2ba87f4e2d9035043b196c1c07ef8530b44cc", size = 150555, upload-time = "2026-07-06T17:37:02.676Z" },
{ url = "https://files.pythonhosted.org/packages/b5/69/52686f56b44af63a93c3dc3f5bcfa07f87427d9aea4d2cbe3e1c94188c74/ijson-3.5.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9a0b25c750a6bde14a0b31f1dcbfc86368e50767e3eaa73bb138e54128055edd", size = 144485, upload-time = "2026-07-06T17:37:03.779Z" },
{ url = "https://files.pythonhosted.org/packages/f0/46/10554e817dde56300a8414e52c0f5a44a29f3440327cd6d829ece57759b3/ijson-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bd756f7b22df745ac14b7bc2ab9ed7c190a222e4c8e1bef26ef1162af8e54d0f", size = 151470, upload-time = "2026-07-06T17:37:04.901Z" },
{ url = "https://files.pythonhosted.org/packages/91/82/f37cbb110b48abdb623d169d0e196f2f6e064e2c20fa789ecde6e69b0440/ijson-3.5.1-cp314-cp314-win32.whl", hash = "sha256:e035cdfb2a1446b13881f0dfc0eecd1541cbb17a27a938ded2160ae6ce25051b", size = 53219, upload-time = "2026-07-06T17:37:06.254Z" },
{ url = "https://files.pythonhosted.org/packages/00/58/792df8f001c246c8ff28f860de81d35ea0d797c0d3276c22a2af83089656/ijson-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:eeb2fb2daa5dd30326f93db465d0855b34aa6b1f52a7c0ff94522aec5ad57dfb", size = 55485, upload-time = "2026-07-06T17:37:07.242Z" },
{ url = "https://files.pythonhosted.org/packages/c0/3c/db3ccc22c09ed4738787e8d82fff76101aa81ec8de7eaf6572e065e012d3/ijson-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:a96ab35d7ce2129dfde49c4c807596443410e260d7f7a4ca8fe4d0035553b589", size = 54390, upload-time = "2026-07-06T17:37:08.497Z" },
{ url = "https://files.pythonhosted.org/packages/26/59/eefa5d9488250c03f24152576804205ae40e29cac0dc65cbbc5f3d422008/ijson-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:77b68e91f95fb16ac2e7819903cd545db6cffa308c28833cc34911e6b21e91dd", size = 93177, upload-time = "2026-07-06T17:37:09.71Z" },
{ url = "https://files.pythonhosted.org/packages/88/db/6329eb7bb9f1906c1906fc10e7074b8f08bf39b7d50baa58f1b597d48898/ijson-3.5.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:94a95065b1ac67602af0cec852b07505abc37b77e3774d1c801d935d05e48f82", size = 62891, upload-time = "2026-07-06T17:37:10.735Z" },
{ url = "https://files.pythonhosted.org/packages/fc/d0/b3beddb96eef0b20bb9902c36e4de30f145be06d7e5e1d780e1a1689d0ce/ijson-3.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b70b5da6b0571da8f601a437c4fba2d35bc27739637d85f3acdc8f88916ce68e", size = 62575, upload-time = "2026-07-06T17:37:11.681Z" },
{ url = "https://files.pythonhosted.org/packages/5b/01/95f3a7c27d25bb917954ef0c8e86d0e60f585b9db675cbd05d355f54cce8/ijson-3.5.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0ade373dd765b057b1dec05d7711bfeb5a36f1e825259466d9f545cfd8ef3ba3", size = 200568, upload-time = "2026-07-06T17:37:12.743Z" },
{ url = "https://files.pythonhosted.org/packages/77/61/c94ee4ea1f22318aab9a49b35d0ce8ac87dd24d508ea4c77dcbde362ba5e/ijson-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:882bc0bdd25d41eae90a15695cd50707edde0978b8b72a2532e30442dd8fd04c", size = 217956, upload-time = "2026-07-06T17:37:14.041Z" },
{ url = "https://files.pythonhosted.org/packages/1a/82/43e8d225aea5ee00eef7998c8ce41f344f7ba451329dfa9e92f4700813af/ijson-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:451901c36e12fa87cbb1cafe661bd25c08c6bd7900cc738279614f71cea07048", size = 208403, upload-time = "2026-07-06T17:37:15.201Z" },
{ url = "https://files.pythonhosted.org/packages/cf/6f/375f67fad76677aca9bc0817b2b18fdd231d309fe24e26b19a5556ef6cdd/ijson-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3c5f660658f2ebfba5d4dfe4bafe8cd3a0defcda410ec08d2205fe08c398940", size = 211967, upload-time = "2026-07-06T17:37:16.484Z" },
{ url = "https://files.pythonhosted.org/packages/dc/53/4c754c3ba18ec70b7086b91a4abd368358fc47cc9b3871afd50deef4fea1/ijson-3.5.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:29eb8f0c77a296a10843a1714ad4a5d561e604cda3c88585e9012cf2c1729b0a", size = 201020, upload-time = "2026-07-06T17:37:18.017Z" },
{ url = "https://files.pythonhosted.org/packages/26/2d/3e7191b3222a31c378b827565b4fa64676a293441279f84db3d971720bf5/ijson-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85997568d6b304cfa59d5c3f2b04f95b92e9a8c7f57d312343a7989cf8dfff85", size = 205584, upload-time = "2026-07-06T17:37:19.343Z" },
{ url = "https://files.pythonhosted.org/packages/24/11/55ae9c915e68f37c8698f8b09355071dc808ced5e9d4abf8238dc363f500/ijson-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:c2e2509dc7f2fa5a2ac9ba7d15dd901f4093bd36b0784f65e04b681b7956651c", size = 54438, upload-time = "2026-07-06T17:37:20.656Z" },
{ url = "https://files.pythonhosted.org/packages/96/df/5bf2656447f14a923d25a0401b1cd628ca05c23041d3a4c116ae8d44dc39/ijson-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2699e838099d056818c5f8e4ba702b345d0304e58847bdc79c5c1616d5d750a5", size = 56467, upload-time = "2026-07-06T17:37:21.615Z" },
{ url = "https://files.pythonhosted.org/packages/4e/e4/dec06e84fac704039625039c6b116a44f17ad72fda48b8f88a2493364b77/ijson-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:c388f85cbb9eec022b2bdedd23ffacfe7ab100c1200b1f47bee6e6ea2c3309fa", size = 55774, upload-time = "2026-07-06T17:37:22.958Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
@ -1710,6 +1766,7 @@ dependencies = [
{ name = "fastapi" },
{ name = "geoalchemy2" },
{ name = "httpx", extra = ["socks"] },
{ name = "ijson" },
{ name = "jinja2" },
{ name = "lxml" },
{ name = "matplotlib" },
@ -1748,6 +1805,7 @@ requires-dist = [
{ name = "fastapi", specifier = ">=0.115.0" },
{ name = "geoalchemy2", specifier = ">=0.15.0" },
{ name = "httpx", extras = ["socks"], specifier = ">=0.27.0" },
{ name = "ijson", specifier = ">=3.3.0" },
{ name = "jinja2", specifier = ">=3.1.0" },
{ name = "lxml", specifier = ">=5.0.0" },
{ name = "matplotlib", specifier = ">=3.9.0" },