All checks were successful
Калибровка существующих tier-таблиц `build_gps_mvt` / `_simplify_coords` (ADR-016), чтобы при первом открытии карты пользователь видел общее покрытие сети треков, а не пустую подложку. Backend (src/api/gps_tracks/mvt.py): - build_gps_mvt: добавлены тиры z<=5 (min_length=10 км, limit=1500) и z=6 (5 км / 2000); z=7+ — без изменений (регрессия). - _simplify_coords: tolerance для z=6 = 0.018° (~2 км), для z<=5 = 0.04° (~4 км); z=7+ не меняется. Frontend: - GPS_TRACKS_MIN_ZOOM понижен с 8 до 5; vector-source.minzoom подхватывает константу автоматически. - line-width / halo получили stop на z=5 (0.8 / 1.8 CSS-px), чтобы линия была читаема на любом DPR. - Hint #public-tracks-zoom-hint: «Зум 8+» → «Зум 5+». Тесты: - 8 unit zoom-tier (UT-Z5/6/7/8/12) — REQ-F-09. - 10 unit simplify (UT-SIMP-*) — REQ-F-10. - 9 integration endpoint z5-z7 (IT-Z5/6/7, CACHE, REGRESS) — REQ-F-11/12. - 2 perf (PERF-Z5-01/02; avg ~64 ms, p95 ~89 ms при 500 треках — ниже бюджета 200/500 ms по M-6) — REQ-F-13. Маркер @pytest.mark.perf, не в основном CI-gate. Контракт API /api/gps-tracks* не меняется (REQ-F-15); localStorage-ключи и конфиги тоже (REQ-F-16, F-18). Refs: ET-012 ADR: docs/work-items/ET-012/06-adr/ADR-016-z5-tiling-policy.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
153 lines
5.5 KiB
Python
153 lines
5.5 KiB
Python
"""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"
|