"""Performance-тест PERF-Z5-01 (ET-012, ADR-016, REQ-F-13). Запускается отдельным джобом:: pytest -m perf tests/performance/test_gps_mvt_z5_perf.py -v В обычный CI-gate не входит (см. pyproject.toml addopts: '-m "not perf"'). Цель: проверить, что после понижения GPS_TRACKS_MIN_ZOOM до 5 ``build_gps_mvt(rows, 5, x, y)`` укладывается в бюджеты по NFR-01 / M-6: * avg ≤ 200 мс на 500 треках, * p95 ≤ 500 мс на 500 треках. Замечание о масштабе: CI-runner у gitea-actions ≈ 2 vCPU; на dev-машине показатели обычно лучше. Если на CI-runner avg/p95 не сходятся — ужесточить ``limit`` в ``build_gps_mvt`` (TRZ §3 REQ-F-03, ADR-016 §T). """ import json import math import time import pytest from shapely import wkb from shapely.geometry import LineString from src.api.gps_tracks.mvt import build_gps_mvt pytestmark = pytest.mark.perf def _make_row(track_id: int, length_m: float, lon_center: float, lat_center: float): """Создаёт mock-row с 30-точечной геометрией ~10 км.""" n_points = 30 coords = [] for i in range(n_points): # Линия наискосок, ~0.1° по диагонали ≈ 8-11 км. t = i / (n_points - 1) coords.append( (lon_center - 0.05 + t * 0.1, lat_center - 0.05 + t * 0.1) ) geom = wkb.dumps(LineString(coords)) class _Row(dict): def __getitem__(self, key): return super().__getitem__(key) return _Row({ "id": track_id, "activity_type": "enduro", "sources_json": json.dumps(["osm"]), "external_urls_json": json.dumps([]), "length_m": length_m, "name": f"Track {track_id}", "geom": geom, }) def _tile_for(z: int, lon: float, lat: float) -> tuple[int, int]: n = 2 ** z x = int((lon + 180.0) / 360.0 * n) y_rad = math.radians(lat) y = int((1 - math.asinh(math.tan(y_rad)) / math.pi) / 2 * n) return max(0, min(n - 1, x)), max(0, min(n - 1, y)) def _percentile(values: list[float], pct: float) -> float: """Простая реализация перцентиля (linear interpolation).""" if not values: return 0.0 sorted_vals = sorted(values) k = (len(sorted_vals) - 1) * pct f = int(k) c = min(f + 1, len(sorted_vals) - 1) if f == c: return sorted_vals[f] return sorted_vals[f] + (sorted_vals[c] - sorted_vals[f]) * (k - f) # ─── PERF-Z5-01: build_gps_mvt z=5 на 500 треках ──────────────────────────── def test_perf_z5_01_500_tracks_under_budget(): """REQ-F-13 / PERF-Z5-01: avg <= 200 мс, p95 <= 500 мс на 500 треках.""" lon, lat = 37.6, 55.7 x, y = _tile_for(5, lon, lat) # 500 треков длиной 12-25 км по ЦФО (все проходят min_length=10 км). rows = [] for i in range(500): length_m = 12000 + (i * 137 % 13000) # 12000..25000 # Лёгкое смещение центра, чтобы DB-row были не идентичными. dlon = ((i * 13) % 100 - 50) / 1000.0 # ±0.05° dlat = ((i * 23) % 100 - 50) / 1000.0 rows.append(_make_row(i, length_m, lon + dlon, lat + dlat)) # Прогрев — один холодный прогон, не учитываем в статистике. build_gps_mvt(rows, z=5, x=x, y=y) timings = [] for _ in range(10): t0 = time.perf_counter() build_gps_mvt(rows, z=5, x=x, y=y) timings.append((time.perf_counter() - t0) * 1000.0) # мс avg = sum(timings) / len(timings) p95 = _percentile(timings, 0.95) # Прикрепляем замеры в отчёт (видно при pytest -v -s). print( f"\nPERF-Z5-01: avg={avg:.1f}ms, p95={p95:.1f}ms, " f"min={min(timings):.1f}ms, max={max(timings):.1f}ms" ) assert avg <= 200, f"avg {avg:.1f}ms > 200ms (M-6 нарушена)" assert p95 <= 500, f"p95 {p95:.1f}ms > 500ms (M-6 нарушена)" # ─── PERF-Z5-02: 5000 треков (стресс) ─────────────────────────────────────── def test_perf_z5_02_5000_tracks_stress(): """REQ-F-13 / PERF-Z5-02: p95 <= 1500 мс при БД 5000 треков (стресс). Симулирует прогноз роста БД до 5k треков. Если не проходит — нужно рассматривать смену стратегии (pre-rendering / heat-map; см. ADR-016 §P). """ lon, lat = 37.6, 55.7 x, y = _tile_for(5, lon, lat) rows = [] for i in range(5000): length_m = 1000 + (i * 137 % 50000) # 1..50 км, разные длины dlon = ((i * 13) % 100 - 50) / 100.0 # ±0.5° dlat = ((i * 23) % 100 - 50) / 100.0 rows.append(_make_row(i, length_m, lon + dlon, lat + dlat)) # Прогрев build_gps_mvt(rows, z=5, x=x, y=y) timings = [] for _ in range(5): t0 = time.perf_counter() build_gps_mvt(rows, z=5, x=x, y=y) timings.append((time.perf_counter() - t0) * 1000.0) p95 = _percentile(timings, 0.95) print( f"\nPERF-Z5-02: p95={p95:.1f}ms, " f"min={min(timings):.1f}ms, max={max(timings):.1f}ms" ) assert p95 <= 1500, f"p95 {p95:.1f}ms > 1500ms"