fix(gps): гистерезис подтверждения jump-кандидата — latch-up на LBS, реальные переезды принимаются после 3 точек

This commit is contained in:
2026-08-25 18:06:43 +03:00
parent 3d24a15fb3
commit a4b508f70e
2 changed files with 142 additions and 46 deletions

View File

@@ -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()) {
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;
}
/** Обновление настроек «на лету» (для долгоживущих 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 (settings.maxJumpSpeedKmh > 0) {
const dtSec = (point.recordedAt.getTime() - prev.recordedAt.getTime()) / 1000;
const dist = haversineMeters(prev.lat, prev.lng, point.lat, point.lng);
if (dtSec <= 0) {
if (dist > settings.jitterMeters) return "jump";
} else {
const impliedKmh = dist / 1000 / (dtSec / 3600);
if (impliedKmh > settings.maxJumpSpeedKmh) return "jump";
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";
}
}
return "accept";
// Точка далеко и от кандидата — заменяем кандидата
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) {
// Защита (после 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 {
filteredCount++;
}
}
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(

View File

@@ -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;