fix(gps): гистерезис подтверждения jump-кандидата — latch-up на LBS, реальные переезды принимаются после 3 точек
This commit is contained in:
@@ -66,40 +66,129 @@ interface PrevPoint {
|
||||
recordedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Классификация новой точки относительно предыдущей ПРИНЯТОЙ.
|
||||
* Используется и в processPosition (при приёме), и при отдаче трека.
|
||||
*
|
||||
* - stale: recordedAt <= prev.recordedAt — устаревшая копия/дубликат
|
||||
* (трекеры без GPS-фикса пересылают старый фикс со старым fixTime).
|
||||
* - jump: impliedKmh = dist/dt > maxJumpSpeedKmh — нереалистичный скачок
|
||||
* (LBS-позиции в десятках км от реальных); 0 = фильтр выключен.
|
||||
* Защита от dt <= 0 (после stale-проверки недостижимо, но на всякий случай):
|
||||
* считаем jump'ом, если dist > jitterMeters.
|
||||
*/
|
||||
export function classifyPoint(
|
||||
prev: PrevPoint | null,
|
||||
point: PrevPoint,
|
||||
settings: GpsFilterSettings
|
||||
): PointVerdict {
|
||||
if (!prev) return "accept";
|
||||
// Сколько точек подряд около новой локации нужно, чтобы подтвердить смену реальности
|
||||
const CANDIDATE_CONFIRM_COUNT = 3;
|
||||
// Время жизни кандидата на смену локации (по времени точек)
|
||||
const CANDIDATE_TTL_MS = 30 * 60 * 1000;
|
||||
|
||||
if (point.recordedAt.getTime() <= prev.recordedAt.getTime()) {
|
||||
return "stale";
|
||||
interface JumpCandidate {
|
||||
lat: number;
|
||||
lng: number;
|
||||
count: number;
|
||||
firstAt: number; // recordedAt первой точки кандидата, ms
|
||||
}
|
||||
|
||||
/**
|
||||
* Stateful-фильтр потока точек (stale/jump) с гистерезисом подтверждения.
|
||||
*
|
||||
* Проблема latch-up: если последняя принятая точка — стабильный LBS
|
||||
* (идёт каждую минуту с одной вышки), то при восстановлении GPS реальные
|
||||
* точки вечно выглядят jump'ом и отбрасываются. Решение — кандидат:
|
||||
* jump-точки запоминаются; если CANDIDATE_CONFIRM_COUNT точек подряд
|
||||
* ложатся около одной новой локации (в пределах max(jitterMeters, 100) м) —
|
||||
* это новая реальность (GPS ожил / объект реально уехал), точка принимается.
|
||||
* Прыжки между разными вышками (туда-сюда) никогда не подтверждаются.
|
||||
* Кандидат живёт CANDIDATE_TTL_MS с первой точки, потом сбрасывается.
|
||||
*
|
||||
* Используется и при приёме (processPosition, per-asset in-memory),
|
||||
* и при отдаче трека (filterTrackPoints).
|
||||
*/
|
||||
export class JumpFilterWalker {
|
||||
private prev: PrevPoint | null;
|
||||
private candidate: JumpCandidate | null = null;
|
||||
|
||||
constructor(
|
||||
private settings: GpsFilterSettings,
|
||||
initialPrev: PrevPoint | null = null
|
||||
) {
|
||||
this.prev = initialPrev;
|
||||
}
|
||||
|
||||
if (settings.maxJumpSpeedKmh > 0) {
|
||||
const dtSec = (point.recordedAt.getTime() - prev.recordedAt.getTime()) / 1000;
|
||||
const dist = haversineMeters(prev.lat, prev.lng, point.lat, point.lng);
|
||||
/** Обновление настроек «на лету» (для долгоживущих per-asset walker'ов) */
|
||||
setSettings(settings: GpsFilterSettings): void {
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка очередной точки. При 'accept' walker обновляет prev —
|
||||
* вызывающий код должен обработать точку как валидную.
|
||||
*/
|
||||
check(point: PrevPoint): PointVerdict {
|
||||
// stale: устаревшая копия/дубликат по времени устройства
|
||||
if (this.prev && point.recordedAt.getTime() <= this.prev.recordedAt.getTime()) {
|
||||
return "stale";
|
||||
}
|
||||
|
||||
if (!this.isJump(point)) {
|
||||
// Обычный путь — кандидат не нужен
|
||||
this.candidate = null;
|
||||
this.prev = point;
|
||||
return "accept";
|
||||
}
|
||||
|
||||
// jump: логика кандидата на смену локации
|
||||
const now = point.recordedAt.getTime();
|
||||
if (this.candidate && now - this.candidate.firstAt > CANDIDATE_TTL_MS) {
|
||||
this.candidate = null; // старый кандидат протух — не мешает новым
|
||||
}
|
||||
|
||||
if (this.candidate) {
|
||||
const distToCandidate = haversineMeters(
|
||||
this.candidate.lat,
|
||||
this.candidate.lng,
|
||||
point.lat,
|
||||
point.lng
|
||||
);
|
||||
if (distToCandidate <= Math.max(this.settings.jitterMeters, 100)) {
|
||||
this.candidate.count++;
|
||||
if (this.candidate.count >= CANDIDATE_CONFIRM_COUNT) {
|
||||
// Три подряд точки около новой локации — новая реальность, принимаем
|
||||
this.candidate = null;
|
||||
this.prev = point;
|
||||
return "accept";
|
||||
}
|
||||
return "jump";
|
||||
}
|
||||
}
|
||||
|
||||
// Точка далеко и от кандидата — заменяем кандидата
|
||||
this.candidate = { lat: point.lat, lng: point.lng, count: 1, firstAt: now };
|
||||
return "jump";
|
||||
}
|
||||
|
||||
/** Нереалистичный скачок скорости относительно prev (LBS-позиции) */
|
||||
private isJump(point: PrevPoint): boolean {
|
||||
if (!this.prev || this.settings.maxJumpSpeedKmh <= 0) return false;
|
||||
const dtSec = (point.recordedAt.getTime() - this.prev.recordedAt.getTime()) / 1000;
|
||||
const dist = haversineMeters(this.prev.lat, this.prev.lng, point.lat, point.lng);
|
||||
if (dtSec <= 0) {
|
||||
if (dist > settings.jitterMeters) return "jump";
|
||||
// Защита (после stale-проверки недостижимо, но на всякий случай)
|
||||
return dist > this.settings.jitterMeters;
|
||||
}
|
||||
const impliedKmh = dist / 1000 / (dtSec / 3600);
|
||||
return impliedKmh > this.settings.maxJumpSpeedKmh;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Фильтрация сохранённого трека при выдаче (без удаления из БД):
|
||||
* те же правила stale/jump с подтверждением кандидата, что и при приёме.
|
||||
*/
|
||||
export function filterTrackPoints<T extends { lat: number; lng: number; recordedAt: Date }>(
|
||||
points: T[],
|
||||
settings: GpsFilterSettings
|
||||
): { points: T[]; filteredCount: number } {
|
||||
const walker = new JumpFilterWalker(settings);
|
||||
const accepted: T[] = [];
|
||||
let filteredCount = 0;
|
||||
for (const p of points) {
|
||||
if (walker.check({ lat: p.lat, lng: p.lng, recordedAt: p.recordedAt }) === "accept") {
|
||||
accepted.push(p);
|
||||
} else {
|
||||
const impliedKmh = dist / 1000 / (dtSec / 3600);
|
||||
if (impliedKmh > settings.maxJumpSpeedKmh) return "jump";
|
||||
filteredCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return "accept";
|
||||
return { points: accepted, filteredCount };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -113,11 +202,28 @@ export function classifyPoint(
|
||||
*
|
||||
* Цепочка фильтров (сравнение всегда с последней принятой точкой):
|
||||
* 1. stale — устаревшая копия/дубликат по времени устройства;
|
||||
* 2. jump — нереалистичный скачок скорости (LBS-позиции);
|
||||
* 2. jump — нереалистичный скачок скорости (LBS-позиции), с гистерезисом:
|
||||
* подряд идущие точки около новой локации подтверждают смену реальности;
|
||||
* 3. jitter — мелкое перемещение с малой скоростью.
|
||||
* Отброшенная точка не пишется в gps_positions, координаты и геозоны не
|
||||
* трогаются, но last_seen_at/is_online обновляются (устройство живо).
|
||||
*/
|
||||
|
||||
// In-memory фильтры по объектам (процесс один; при рестарте состояние
|
||||
// восстанавливается из last_* объекта в БД)
|
||||
const assetWalkers = new Map<number, JumpFilterWalker>();
|
||||
|
||||
function getAssetWalker(assetId: number, prev: PrevPoint | null, settings: GpsFilterSettings): JumpFilterWalker {
|
||||
let walker = assetWalkers.get(assetId);
|
||||
if (!walker) {
|
||||
walker = new JumpFilterWalker(settings, prev);
|
||||
assetWalkers.set(assetId, walker);
|
||||
} else {
|
||||
walker.setSettings(settings);
|
||||
}
|
||||
return walker;
|
||||
}
|
||||
|
||||
export async function processPosition(
|
||||
assetId: number,
|
||||
organizationId: number,
|
||||
@@ -130,14 +236,15 @@ export async function processPosition(
|
||||
|
||||
const settings = await gpsStorage.getSettings(organizationId);
|
||||
|
||||
// Предыдущая принятая точка объекта (если есть)
|
||||
// Предыдущая принятая точка объекта (если есть) — для инициализации walker'а
|
||||
const prev: PrevPoint | null =
|
||||
asset.lastLat !== null && asset.lastLng !== null && asset.lastRecordedAt !== null
|
||||
? { lat: asset.lastLat, lng: asset.lastLng, recordedAt: asset.lastRecordedAt }
|
||||
: null;
|
||||
|
||||
// Фильтры stale/jump (устаревшие копии и LBS-скачки)
|
||||
const verdict = classifyPoint(prev, { lat: input.lat, lng: input.lng, recordedAt }, settings);
|
||||
// Фильтры stale/jump с гистерезисом подтверждения (устаревшие копии и LBS-скачки)
|
||||
const walker = getAssetWalker(assetId, prev, settings);
|
||||
const verdict = walker.check({ lat: input.lat, lng: input.lng, recordedAt });
|
||||
if (verdict !== "accept") {
|
||||
if (verdict === "stale") {
|
||||
console.log(
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Router } from "express";
|
||||
import type { Express, Response } from "express";
|
||||
import { authenticateToken, type AuthenticatedRequest } from "../middleware/auth.middleware";
|
||||
import { gpsStorage } from "./storage";
|
||||
import { processPosition, classifyPoint } from "./geozone.service";
|
||||
import { processPosition, filterTrackPoints } from "./geozone.service";
|
||||
import { ensureDevice, deleteDevice } from "./traccar.client";
|
||||
import { storage } from "../storage";
|
||||
|
||||
@@ -319,21 +319,10 @@ export function registerGpsRoutes(app: Express) {
|
||||
const total = all.length;
|
||||
|
||||
// Фильтрация сохранённых данных при выдаче (без удаления из БД):
|
||||
// пропускаем stale (устаревшие копии) и jump (LBS-скачки) — те же правила,
|
||||
// что при приёме, сравнение с предыдущей принятой точкой
|
||||
// пропускаем stale (устаревшие копии) и jump (LBS-скачки) — те же правила
|
||||
// с гистерезисом подтверждения, что при приёме (общий walker по точкам)
|
||||
const settings = await gpsStorage.getSettings(orgId);
|
||||
const filtered: typeof all = [];
|
||||
let filteredCount = 0;
|
||||
let prevAccepted: { lat: number; lng: number; recordedAt: Date } | null = null;
|
||||
for (const point of all) {
|
||||
const current = { lat: point.lat, lng: point.lng, recordedAt: point.recordedAt };
|
||||
if (classifyPoint(prevAccepted, current, settings) === "accept") {
|
||||
filtered.push(point);
|
||||
prevAccepted = current;
|
||||
} else {
|
||||
filteredCount++;
|
||||
}
|
||||
}
|
||||
const { points: filtered, filteredCount } = filterTrackPoints(all, settings);
|
||||
|
||||
let points = filtered;
|
||||
let sampled = false;
|
||||
|
||||
Reference in New Issue
Block a user