From a4b508f70e838f38ad93ff720b0497802a9c9db0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D1=8C=D1=8F=D1=81=20=D0=A1=D1=83=D0=BB=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=B2?= Date: Tue, 25 Aug 2026 18:06:43 +0300 Subject: [PATCH] =?UTF-8?q?fix(gps):=20=D0=B3=D0=B8=D1=81=D1=82=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=B7=D0=B8=D1=81=20=D0=BF=D0=BE=D0=B4=D1=82=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D1=8F=20jump-=D0=BA?= =?UTF-8?q?=D0=B0=D0=BD=D0=B4=D0=B8=D0=B4=D0=B0=D1=82=D0=B0=20=E2=80=94=20?= =?UTF-8?q?latch-up=20=D0=BD=D0=B0=20LBS,=20=D1=80=D0=B5=D0=B0=D0=BB=D1=8C?= =?UTF-8?q?=D0=BD=D1=8B=D0=B5=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B5=D0=B7=D0=B4?= =?UTF-8?q?=D1=8B=20=D0=BF=D1=80=D0=B8=D0=BD=D0=B8=D0=BC=D0=B0=D1=8E=D1=82?= =?UTF-8?q?=D1=81=D1=8F=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=203=20=D1=82?= =?UTF-8?q?=D0=BE=D1=87=D0=B5=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/gps/geozone.service.ts | 169 +++++++++++++++++++++++++++------- server/gps/routes.ts | 19 +--- 2 files changed, 142 insertions(+), 46 deletions(-) diff --git a/server/gps/geozone.service.ts b/server/gps/geozone.service.ts index 5efd5e5..a6f93fb 100644 --- a/server/gps/geozone.service.ts +++ b/server/gps/geozone.service.ts @@ -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( + 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(); + +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( diff --git a/server/gps/routes.ts b/server/gps/routes.ts index 594019e..f2b64dc 100644 --- a/server/gps/routes.ts +++ b/server/gps/routes.ts @@ -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;