fix(gps): гистерезис подтверждения jump-кандидата — latch-up на LBS, реальные переезды принимаются после 3 точек
This commit is contained in:
@@ -66,40 +66,129 @@ interface PrevPoint {
|
|||||||
recordedAt: Date;
|
recordedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Сколько точек подряд около новой локации нужно, чтобы подтвердить смену реальности
|
||||||
* Классификация новой точки относительно предыдущей ПРИНЯТОЙ.
|
const CANDIDATE_CONFIRM_COUNT = 3;
|
||||||
* Используется и в processPosition (при приёме), и при отдаче трека.
|
// Время жизни кандидата на смену локации (по времени точек)
|
||||||
*
|
const CANDIDATE_TTL_MS = 30 * 60 * 1000;
|
||||||
* - 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";
|
|
||||||
|
|
||||||
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";
|
return "stale";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (settings.maxJumpSpeedKmh > 0) {
|
if (!this.isJump(point)) {
|
||||||
const dtSec = (point.recordedAt.getTime() - prev.recordedAt.getTime()) / 1000;
|
// Обычный путь — кандидат не нужен
|
||||||
const dist = haversineMeters(prev.lat, prev.lng, point.lat, point.lng);
|
this.candidate = null;
|
||||||
if (dtSec <= 0) {
|
this.prev = point;
|
||||||
if (dist > settings.jitterMeters) return "jump";
|
return "accept";
|
||||||
} else {
|
}
|
||||||
const impliedKmh = dist / 1000 / (dtSec / 3600);
|
|
||||||
if (impliedKmh > settings.maxJumpSpeedKmh) return "jump";
|
// 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 — устаревшая копия/дубликат по времени устройства;
|
* 1. stale — устаревшая копия/дубликат по времени устройства;
|
||||||
* 2. jump — нереалистичный скачок скорости (LBS-позиции);
|
* 2. jump — нереалистичный скачок скорости (LBS-позиции), с гистерезисом:
|
||||||
|
* подряд идущие точки около новой локации подтверждают смену реальности;
|
||||||
* 3. jitter — мелкое перемещение с малой скоростью.
|
* 3. jitter — мелкое перемещение с малой скоростью.
|
||||||
* Отброшенная точка не пишется в gps_positions, координаты и геозоны не
|
* Отброшенная точка не пишется в gps_positions, координаты и геозоны не
|
||||||
* трогаются, но last_seen_at/is_online обновляются (устройство живо).
|
* трогаются, но 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(
|
export async function processPosition(
|
||||||
assetId: number,
|
assetId: number,
|
||||||
organizationId: number,
|
organizationId: number,
|
||||||
@@ -130,14 +236,15 @@ export async function processPosition(
|
|||||||
|
|
||||||
const settings = await gpsStorage.getSettings(organizationId);
|
const settings = await gpsStorage.getSettings(organizationId);
|
||||||
|
|
||||||
// Предыдущая принятая точка объекта (если есть)
|
// Предыдущая принятая точка объекта (если есть) — для инициализации walker'а
|
||||||
const prev: PrevPoint | null =
|
const prev: PrevPoint | null =
|
||||||
asset.lastLat !== null && asset.lastLng !== null && asset.lastRecordedAt !== null
|
asset.lastLat !== null && asset.lastLng !== null && asset.lastRecordedAt !== null
|
||||||
? { lat: asset.lastLat, lng: asset.lastLng, recordedAt: asset.lastRecordedAt }
|
? { lat: asset.lastLat, lng: asset.lastLng, recordedAt: asset.lastRecordedAt }
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Фильтры stale/jump (устаревшие копии и LBS-скачки)
|
// Фильтры stale/jump с гистерезисом подтверждения (устаревшие копии и LBS-скачки)
|
||||||
const verdict = classifyPoint(prev, { lat: input.lat, lng: input.lng, recordedAt }, settings);
|
const walker = getAssetWalker(assetId, prev, settings);
|
||||||
|
const verdict = walker.check({ lat: input.lat, lng: input.lng, recordedAt });
|
||||||
if (verdict !== "accept") {
|
if (verdict !== "accept") {
|
||||||
if (verdict === "stale") {
|
if (verdict === "stale") {
|
||||||
console.log(
|
console.log(
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Router } from "express";
|
|||||||
import type { Express, Response } from "express";
|
import type { Express, Response } from "express";
|
||||||
import { authenticateToken, type AuthenticatedRequest } from "../middleware/auth.middleware";
|
import { authenticateToken, type AuthenticatedRequest } from "../middleware/auth.middleware";
|
||||||
import { gpsStorage } from "./storage";
|
import { gpsStorage } from "./storage";
|
||||||
import { processPosition, classifyPoint } from "./geozone.service";
|
import { processPosition, filterTrackPoints } from "./geozone.service";
|
||||||
import { ensureDevice, deleteDevice } from "./traccar.client";
|
import { ensureDevice, deleteDevice } from "./traccar.client";
|
||||||
import { storage } from "../storage";
|
import { storage } from "../storage";
|
||||||
|
|
||||||
@@ -319,21 +319,10 @@ export function registerGpsRoutes(app: Express) {
|
|||||||
const total = all.length;
|
const total = all.length;
|
||||||
|
|
||||||
// Фильтрация сохранённых данных при выдаче (без удаления из БД):
|
// Фильтрация сохранённых данных при выдаче (без удаления из БД):
|
||||||
// пропускаем stale (устаревшие копии) и jump (LBS-скачки) — те же правила,
|
// пропускаем stale (устаревшие копии) и jump (LBS-скачки) — те же правила
|
||||||
// что при приёме, сравнение с предыдущей принятой точкой
|
// с гистерезисом подтверждения, что при приёме (общий walker по точкам)
|
||||||
const settings = await gpsStorage.getSettings(orgId);
|
const settings = await gpsStorage.getSettings(orgId);
|
||||||
const filtered: typeof all = [];
|
const { points: filtered, filteredCount } = filterTrackPoints(all, settings);
|
||||||
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++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let points = filtered;
|
let points = filtered;
|
||||||
let sampled = false;
|
let sampled = false;
|
||||||
|
|||||||
Reference in New Issue
Block a user