feat(gps): траекторный фильтр трека (медианный якорь из 10 точек + lookahead-подтверждение переезда); скорость у выживших LBS-точек с «~»
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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/уведомления.
|
||||
|
||||
Reference in New Issue
Block a user