feat(gps): траекторный фильтр трека (медианный якорь из 10 точек + lookahead-подтверждение переезда); скорость у выживших LBS-точек с «~»

This commit is contained in:
2026-08-30 20:26:36 +03:00
parent 0386005463
commit bec3cc054d
2 changed files with 97 additions and 5 deletions

View File

@@ -525,7 +525,7 @@ export function GpsRoutesTab() {
✕
</button>
</div>
<div>Скорость: <b>{selectedPoint.speed != null ? `${Math.round(selectedPoint.speed)} км/ч` : '—'}</b></div>
<div>Скорость: <b>{selectedPoint.speed != null ? `${selectedPoint.isLbs ? '~' : ''}${Math.round(selectedPoint.speed)} км/ч${selectedPoint.isLbs ? ' (по вышкам)' : ''}` : '—'}</b></div>
{selectedPoint.isLbs && (
<div className="text-amber-600 dark:text-amber-400">Примерная позиция (по вышкам)</div>
)}

View File

@@ -77,6 +77,15 @@ const CANDIDATE_TTL_MS = 30 * 60 * 1000;
// принятая точка «в движении» (speed > 10 км/ч) была меньше 5 минут назад
const ECHO_WINDOW_MS = 5 * 60 * 1000;
// Траекторный фильтр при выдаче (проход после walker'а, до spike-фильтра):
// ловит «мяч» вышек — LBS-точки, скачущие по области десятки км, где каждая
// по отдельности в пределах off_road_meters от какой-нибудь дороги.
const TRAJ_WINDOW_SIZE = 10; // скользящее окно последних принятых точек
const TRAJ_ANCHOR_DIST_M = 1500; // дальше этого от якоря (медиана окна) — подозрительная
const TRAJ_CONFIRM_DIST_M = 800; // ближе этого к подозрительной — подтверждение переезда
const TRAJ_LOOKAHEAD = 5; // сколько следующих точек потока смотрим для подтверждения
const TRAJ_MIN_HISTORY = 3; // меньше точек в окне — фильтр ещё не работает
interface JumpCandidate {
lat: number;
lng: number;
@@ -246,7 +255,8 @@ export class JumpFilterWalker {
/**
* Фильтрация сохранённого трека при выдаче (без удаления из БД):
* те же правила stale/jump/echo с подтверждением кандидата, что и при приёме,
* плюс spike-фильтр с lookahead (эхо стоянки между движущимися точками).
* затем траекторный фильтр по скользящему окну («мяч» вышек) и
* spike-фильтр с lookahead (эхо стоянки между движущимися точками).
*/
export function filterTrackPoints<T extends { lat: number; lng: number; recordedAt: Date; speed?: number | null }>(
points: T[],
@@ -263,14 +273,80 @@ export function filterTrackPoints<T extends { lat: number; lng: number; recorded
}
}
// Траекторный фильтр по окну: якорь = медианы lat/lng последних 10 принятых
// точек. Точка дальше 1500 м от якоря — подозрительная: складывается в
// pending и ждёт lookahead. Если среди следующих 5 точек потока ≥ 2 лежат
// в пределах 800 м от неё — реальный переезд в новый район: pending
// принимаются, якорь съезжает. Если поток вернулся к якорю — pending
// отбрасываются как мусор (считаются в filteredCount).
// Применяется ко ВСЕМ точкам (и GPS, и LBS): GPS-выбросы тоже случаются.
// O(n·window) — для ~4000 точек нормально.
const trajectory: T[] = [];
const window: T[] = [];
let pending: T[] = [];
const pushToWindow = (p: T) => {
window.push(p);
if (window.length > TRAJ_WINDOW_SIZE) window.shift();
};
for (let i = 0; i < accepted.length; i++) {
const p = accepted[i];
// Истории мало — окно ещё не работает, принимаем всё
if (window.length < TRAJ_MIN_HISTORY) {
trajectory.push(p);
pushToWindow(p);
continue;
}
const anchor = medianAnchor(window);
if (haversineMeters(anchor.lat, anchor.lng, p.lat, p.lng) <= TRAJ_ANCHOR_DIST_M) {
// Точка логически вписывается в траекторию
if (pending.length > 0) {
filteredCount += pending.length;
pending = [];
}
trajectory.push(p);
pushToWindow(p);
continue;
}
// Подозрительная (улетела от якоря) — смотрим вперёд по потоку
let confirm = 0;
const lookaheadEnd = Math.min(i + 1 + TRAJ_LOOKAHEAD, accepted.length);
for (let j = i + 1; j < lookaheadEnd; j++) {
if (haversineMeters(p.lat, p.lng, accepted[j].lat, accepted[j].lng) <= TRAJ_CONFIRM_DIST_M) {
confirm++;
}
}
if (confirm >= 2) {
// Реальный переезд: принимаем накопленный pending и текущую точку,
// окно скользит на новый район
for (const q of pending) {
trajectory.push(q);
pushToWindow(q);
}
pending = [];
trajectory.push(p);
pushToWindow(p);
} else {
// Ждём: либо переезд подтвердится следующими точками, либо поток
// вернётся к якорю и pending будет отброшен
pending.push(p);
}
}
// Хвост pending в конце потока — мусор (к якорю так и не вернулись)
filteredCount += pending.length;
// Spike-фильтр с lookahead: точка-«выброс» к координатам стоянки между
// движущимися точками (dist(prev,cur) большая, а следующая точка снова
// рядом с prev, сама точка почти стоит, соседи едут)
const out: T[] = [];
for (let i = 0; i < accepted.length; i++) {
const cur = accepted[i];
for (let i = 0; i < trajectory.length; i++) {
const cur = trajectory[i];
const prev = out[out.length - 1];
const next = accepted[i + 1];
const next = trajectory[i + 1];
if (prev && next) {
const dPrevCur = haversineMeters(prev.lat, prev.lng, cur.lat, cur.lng);
const dPrevNext = haversineMeters(prev.lat, prev.lng, next.lat, next.lng);
@@ -290,6 +366,22 @@ export function filterTrackPoints<T extends { lat: number; lng: number; recorded
return { points: out, filteredCount };
}
/**
* Якорь траектории: медиана lat и медиана lng окна (по отдельности).
* Медиана устойчива к одиночным выбросам внутри окна, в отличие от среднего.
*/
function medianAnchor(window: Array<{ lat: number; lng: number }>): { lat: number; lng: number } {
const mid = Math.floor(window.length / 2);
const median = (values: number[]) => {
const sorted = [...values].sort((a, b) => a - b);
return sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
};
return {
lat: median(window.map((p) => p.lat)),
lng: median(window.map((p) => p.lng)),
};
}
/**
* Основная точка входа: сохраняет позицию, обновляет last_* объекта,
* проверяет геозоны и рассылает SSE/уведомления.