feat(gps): траекторный фильтр трека (медианный якорь из 10 точек + lookahead-подтверждение переезда); скорость у выживших LBS-точек с «~»
This commit is contained in:
@@ -525,7 +525,7 @@ export function GpsRoutesTab() {
|
|||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 && (
|
{selectedPoint.isLbs && (
|
||||||
<div className="text-amber-600 dark:text-amber-400">Примерная позиция (по вышкам)</div>
|
<div className="text-amber-600 dark:text-amber-400">Примерная позиция (по вышкам)</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -77,6 +77,15 @@ const CANDIDATE_TTL_MS = 30 * 60 * 1000;
|
|||||||
// принятая точка «в движении» (speed > 10 км/ч) была меньше 5 минут назад
|
// принятая точка «в движении» (speed > 10 км/ч) была меньше 5 минут назад
|
||||||
const ECHO_WINDOW_MS = 5 * 60 * 1000;
|
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 {
|
interface JumpCandidate {
|
||||||
lat: number;
|
lat: number;
|
||||||
lng: number;
|
lng: number;
|
||||||
@@ -246,7 +255,8 @@ export class JumpFilterWalker {
|
|||||||
/**
|
/**
|
||||||
* Фильтрация сохранённого трека при выдаче (без удаления из БД):
|
* Фильтрация сохранённого трека при выдаче (без удаления из БД):
|
||||||
* те же правила stale/jump/echo с подтверждением кандидата, что и при приёме,
|
* те же правила stale/jump/echo с подтверждением кандидата, что и при приёме,
|
||||||
* плюс spike-фильтр с lookahead (эхо стоянки между движущимися точками).
|
* затем траекторный фильтр по скользящему окну («мяч» вышек) и
|
||||||
|
* spike-фильтр с lookahead (эхо стоянки между движущимися точками).
|
||||||
*/
|
*/
|
||||||
export function filterTrackPoints<T extends { lat: number; lng: number; recordedAt: Date; speed?: number | null }>(
|
export function filterTrackPoints<T extends { lat: number; lng: number; recordedAt: Date; speed?: number | null }>(
|
||||||
points: T[],
|
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: точка-«выброс» к координатам стоянки между
|
// Spike-фильтр с lookahead: точка-«выброс» к координатам стоянки между
|
||||||
// движущимися точками (dist(prev,cur) большая, а следующая точка снова
|
// движущимися точками (dist(prev,cur) большая, а следующая точка снова
|
||||||
// рядом с prev, сама точка почти стоит, соседи едут)
|
// рядом с prev, сама точка почти стоит, соседи едут)
|
||||||
const out: T[] = [];
|
const out: T[] = [];
|
||||||
for (let i = 0; i < accepted.length; i++) {
|
for (let i = 0; i < trajectory.length; i++) {
|
||||||
const cur = accepted[i];
|
const cur = trajectory[i];
|
||||||
const prev = out[out.length - 1];
|
const prev = out[out.length - 1];
|
||||||
const next = accepted[i + 1];
|
const next = trajectory[i + 1];
|
||||||
if (prev && next) {
|
if (prev && next) {
|
||||||
const dPrevCur = haversineMeters(prev.lat, prev.lng, cur.lat, cur.lng);
|
const dPrevCur = haversineMeters(prev.lat, prev.lng, cur.lat, cur.lng);
|
||||||
const dPrevNext = haversineMeters(prev.lat, prev.lng, next.lat, next.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 };
|
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_* объекта,
|
* Основная точка входа: сохраняет позицию, обновляет last_* объекта,
|
||||||
* проверяет геозоны и рассылает SSE/уведомления.
|
* проверяет геозоны и рассылает SSE/уведомления.
|
||||||
|
|||||||
Reference in New Issue
Block a user