From 483e16e16a582f3dee98f372a47b7bd8a033071e Mon Sep 17 00:00:00 2001 From: levibliz Date: Mon, 24 Aug 2026 05:44:20 +0100 Subject: [PATCH] feat: implement predictive location modeling with Kalman filtering (closes #263) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add PredictiveEngine class with per-client Kalman filter (CV model) - Implement ENU coordinate transformation (lat/lon <-> local tangent plane) - Add trajectory extrapolation API with confidence ellipses - Implement geofence pre-alerts with debouncing per (clientId, fenceId) - Add anomaly detection: GPS anomaly (innovation > 3σ), kinematic anomaly - Add ETA computation with probability distribution - Support filter state persistence for session resumption - Add configuration via environment variables - Add comprehensive test suite (19 tests) - Fix pre-existing bug: duplicate tokenRefreshSchema in validator.js - Fix pre-existing bug: undefined 'sessions' variable references in server.js --- .env.example | 15 + src/predictor.js | 644 +++++++++++++++++++++++++++++++++++++++ src/server.js | 71 +++-- src/validator.js | 4 - tests/predictor.test.js | 654 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 1363 insertions(+), 25 deletions(-) create mode 100644 src/predictor.js create mode 100644 tests/predictor.test.js diff --git a/.env.example b/.env.example index 379dc43..b16ae5d 100644 --- a/.env.example +++ b/.env.example @@ -39,3 +39,18 @@ STORAGE_COMPACTION_BATCH_SIZE=10000 # Enable TimescaleDB features (set to "true" when using timescale/timescaledb image) TIMESCALEDB_ENABLED=false + +# Predictive modeling engine +PREDICTOR_ENABLE=true +PREDICTOR_MODEL=CV +PREDICTOR_PROCESS_NOISE=0.1 +PREDICTOR_MEASUREMENT_NOISE=5.0 +PREDICTOR_PRE_ALERT_HORIZON_S=60 +PREDICTOR_MAX_HORIZON_S=120 +PREDICTOR_RE_ORIGIN_DISTANCE_KM=50 +PREDICTOR_ANOMALY_INNOVATION_SIGMA=3 +PREDICTOR_ANOMALY_MAX_ACCELERATION=10 +PREDICTOR_ANOMALY_MAX_HEADING_RATE=1.57 +PREDICTOR_ANOMALY_RATE_LIMIT_MS=60000 +PREDICTOR_TTL_MS=3600000 +PREDICTOR_ETA_SAMPLES=1000 diff --git a/src/predictor.js b/src/predictor.js new file mode 100644 index 0000000..f0dcdc4 --- /dev/null +++ b/src/predictor.js @@ -0,0 +1,644 @@ + + +const EARTH_RADIUS = 6371000; +const DEFAULT_CONFIG = { + PREDICTOR_ENABLE: true, + PREDICTOR_MODEL: "CV", + PREDICTOR_PROCESS_NOISE: 0.1, + PREDICTOR_MEASUREMENT_NOISE: 5.0, + PREDICTOR_PRE_ALERT_HORIZON_S: 60, + PREDICTOR_MAX_HORIZON_S: 120, + PREDICTOR_RE_ORIGIN_DISTANCE_KM: 50, + PREDICTOR_ANOMALY_INNOVATION_SIGMA: 3, + PREDICTOR_ANOMALY_MAX_ACCELERATION: 10, + PREDICTOR_ANOMALY_MAX_HEADING_RATE: Math.PI / 2, + PREDICTOR_ANOMALY_RATE_LIMIT_MS: 60000, + PREDICTOR_TTL_MS: 3600000, + PREDICTOR_ETA_SAMPLES: 1000, +}; + +function parseConfig(env = process.env) { + const config = { ...DEFAULT_CONFIG }; + for (const key of Object.keys(DEFAULT_CONFIG)) { + if (env[key] !== undefined) { + const val = env[key]; + const def = DEFAULT_CONFIG[key]; + if (typeof def === "boolean") { + config[key] = val === "true"; + } else if (typeof def === "number") { + config[key] = Number(val); + } else { + config[key] = val; + } + } + } + return config; +} + +function latLonToEnu(lat, lon, lat0, lon0) { + const dLat = (lat - lat0) * Math.PI / 180; + const dLon = (lon - lon0) * Math.PI / 180; + const x = EARTH_RADIUS * dLon * Math.cos(lat0 * Math.PI / 180); + const y = EARTH_RADIUS * dLat; + return { x, y }; +} + +function enuToLatLon(x, y, lat0, lon0) { + const lat = lat0 + y / EARTH_RADIUS * 180 / Math.PI; + const lon = lon0 + x / (EARTH_RADIUS * Math.cos(lat0 * Math.PI / 180)) * 180 / Math.PI; + return { lat, lon }; +} + +function distanceEnu(x1, y1, x2, y2) { + const dx = x2 - x1; + const dy = y2 - y1; + return Math.sqrt(dx * dx + dy * dy); +} + +class KalmanFilterCV { + constructor(processNoise, measurementNoise) { + this.x = [0, 0, 0, 0]; + this.P = [ + [100, 0, 0, 0], + [0, 100, 0, 0], + [0, 0, 100, 0], + [0, 0, 0, 100], + ]; + this.Q = this.createProcessNoise(processNoise); + this.R = [ + [measurementNoise * measurementNoise, 0], + [0, measurementNoise * measurementNoise], + ]; + this.initialized = false; + this.lastTimestamp = null; + this.originLat = null; + this.originLon = null; + } + + createProcessNoise(q) { + return [ + [q, 0, 0, 0], + [0, q, 0, 0], + [0, 0, q, 0], + [0, 0, 0, q], + ]; + } + + initialize(lat, lon, timestamp) { + this.originLat = lat; + this.originLon = lon; + this.lastTimestamp = timestamp; + this.initialized = true; + } + + predict(dt) { + const F = [ + [1, 0, dt, 0], + [0, 1, 0, dt], + [0, 0, 1, 0], + [0, 0, 0, 1], + ]; + this.x = this.matVecMul(F, this.x); + this.P = this.matAdd(this.matMul(this.matMul(F, this.P), this.transpose(F)), this.Q); + } + + update(z, timestamp) { + if (!this.initialized) return { innovation: [0, 0], innovationCov: [[0, 0], [0, 0]] }; + + if (this.lastTimestamp !== null) { + const dt = (timestamp - this.lastTimestamp) / 1000; + if (dt > 0) this.predict(dt); + } + this.lastTimestamp = timestamp; + + const H = [ + [1, 0, 0, 0], + [0, 1, 0, 0], + ]; + + const Hx = [this.x[0], this.x[1]]; + const y = [z[0] - Hx[0], z[1] - Hx[1]]; + + const HPHt = this.matMul(this.matMul(H, this.P), this.transpose(H)); + const S = this.matAdd(HPHt, this.R); + + const Sinv = this.inv2x2(S); + const PHt = this.matMul(this.P, this.transpose(H)); + const K = this.matMul(PHt, Sinv); + + const Ky = this.matVecMul(K, y); + this.x = [this.x[0] + Ky[0], this.x[1] + Ky[1], this.x[2] + Ky[2], this.x[3] + Ky[3]]; + + const I = [ + [1, 0, 0, 0], + [0, 1, 0, 0], + [0, 0, 1, 0], + [0, 0, 0, 1], + ]; + const KH = this.matMul(K, H); + const IminusKH = this.matSub(I, KH); + const IminusKHP = this.matMul(IminusKH, this.P); + const IminusKHP_IminusKHt = this.matMul(IminusKHP, this.transpose(IminusKH)); + const KRKt = this.matMul(this.matMul(K, this.R), this.transpose(K)); + this.P = this.matAdd(IminusKHP_IminusKHt, KRKt); + + return { innovation: y, innovationCov: S }; + } + + predictState(dt) { + const F = [ + [1, 0, dt, 0], + [0, 1, 0, dt], + [0, 0, 1, 0], + [0, 0, 0, 1], + ]; + const xPred = this.matVecMul(F, this.x); + const PPred = this.matAdd(this.matMul(this.matMul(F, this.P), this.transpose(F)), this.Q); + return { x: xPred, P: PPred }; + } + + getState() { + return { + x: this.x[0], + y: this.x[1], + vx: this.x[2], + vy: this.x[3], + speed: Math.sqrt(this.x[2] * this.x[2] + this.x[3] * this.x[3]), + heading: Math.atan2(this.x[3], this.x[2]), + covariance: this.P, + }; + } + + getPositionCovariance() { + return [ + [this.P[0][0], this.P[0][1]], + [this.P[1][0], this.P[1][1]], + ]; + } + + setState(x, P) { + this.x = x; + this.P = P; + } + + matMul(A, B) { + const rowsA = A.length; + const colsA = A[0].length; + const colsB = B[0].length; + const result = Array(rowsA).fill(null).map(() => Array(colsB).fill(0)); + for (let i = 0; i < rowsA; i++) { + for (let j = 0; j < colsB; j++) { + let sum = 0; + for (let k = 0; k < colsA; k++) { + sum += A[i][k] * B[k][j]; + } + result[i][j] = sum; + } + } + return result; + } + + matVecMul(A, v) { + const rows = A.length; + const cols = A[0].length; + const result = Array(rows).fill(0); + for (let i = 0; i < rows; i++) { + let sum = 0; + for (let j = 0; j < cols; j++) { + sum += A[i][j] * v[j]; + } + result[i] = sum; + } + return result; + } + + matAdd(A, B) { + const rows = A.length; + const cols = A[0].length; + const result = Array(rows).fill(null).map(() => Array(cols).fill(0)); + for (let i = 0; i < rows; i++) { + for (let j = 0; j < cols; j++) { + result[i][j] = A[i][j] + B[i][j]; + } + } + return result; + } + + matSub(A, B) { + const rows = A.length; + const cols = A[0].length; + const result = Array(rows).fill(null).map(() => Array(cols).fill(0)); + for (let i = 0; i < rows; i++) { + for (let j = 0; j < cols; j++) { + result[i][j] = A[i][j] - B[i][j]; + } + } + return result; + } + + transpose(A) { + const rows = A.length; + const cols = A[0].length; + const result = Array(cols).fill(null).map(() => Array(rows).fill(0)); + for (let i = 0; i < rows; i++) { + for (let j = 0; j < cols; j++) { + result[j][i] = A[i][j]; + } + } + return result; + } + + inv2x2(M) { + const a = M[0][0], b = M[0][1], c = M[1][0], d = M[1][1]; + const det = a * d - b * c; + if (Math.abs(det) < 1e-12) { + return [[1e12, 0], [0, 1e12]]; + } + return [[d / det, -b / det], [-c / det, a / det]]; + } + + eigenvalues2x2(M) { + const a = M[0][0], b = M[0][1], c = M[1][0], d = M[1][1]; + const trace = a + d; + const det = a * d - b * c; + const discriminant = trace * trace - 4 * det; + if (discriminant < 0) return [trace / 2, trace / 2]; + const sqrtDisc = Math.sqrt(discriminant); + const lambda1 = (trace + sqrtDisc) / 2; + const lambda2 = (trace - sqrtDisc) / 2; + return [lambda1, lambda2]; + } + + confidenceEllipse() { + const Ppos = this.getPositionCovariance(); + const eig = this.eigenvalues2x2(Ppos); + const a = Math.sqrt(Math.max(0, eig[0])); + const b = Math.sqrt(Math.max(0, eig[1])); + const angle = 0.5 * Math.atan2(2 * Ppos[0][1], Ppos[0][0] - Ppos[1][1]); + return { semiMajor: a, semiMinor: b, orientation: angle }; + } + + shouldReorigin(lat, lon) { + if (this.originLat === null || this.originLon === null) return false; + const { x, y } = latLonToEnu(lat, lon, this.originLat, this.originLon); + const dist = Math.sqrt(x * x + y * y); + return dist > 50000; + } + + reorigin(lat, lon) { + const { x, y } = latLonToEnu(lat, lon, this.originLat, this.originLon); + this.x[0] = x; + this.x[1] = y; + this.originLat = lat; + this.originLon = lon; + } +} + +class ClientFilter { + constructor(config) { + this.config = config; + this.filter = new KalmanFilterCV(config.PREDICTOR_PROCESS_NOISE, config.PREDICTOR_MEASUREMENT_NOISE); + this.lastLocation = null; + this.lastUpdateTime = null; + this.preAlertDebounce = new Map(); + this.anomalyRateLimit = new Map(); + this.predictedPositions = []; + } + + update(location) { + const { latitude, longitude, speed, heading, timestamp } = location; + const ts = timestamp ? Date.parse(timestamp) : Date.now(); + + if (!this.filter.initialized) { + this.filter.initialize(latitude, longitude, ts); + const enu = latLonToEnu(latitude, longitude, this.filter.originLat, this.filter.originLon); + this.filter.x[0] = enu.x; + this.filter.x[1] = enu.y; + if (speed !== undefined && heading !== undefined) { + this.filter.x[2] = speed * Math.cos(heading); + this.filter.x[3] = speed * Math.sin(heading); + } + this.lastLocation = { latitude, longitude, speed, heading, timestamp: ts }; + this.lastUpdateTime = ts; + return { innovation: [0, 0], innovationCov: [[0, 0], [0, 0]], anomalies: [] }; + } + + if (this.filter.shouldReorigin(latitude, longitude)) { + this.filter.reorigin(latitude, longitude); + } + + const enu = latLonToEnu(latitude, longitude, this.filter.originLat, this.filter.originLon); + const result = this.filter.update([enu.x, enu.y], ts); + + const anomalies = this.detectAnomalies(location, result, ts); + + this.lastLocation = { latitude, longitude, speed, heading, timestamp: ts }; + this.lastUpdateTime = ts; + + return { ...result, anomalies }; + } + + detectAnomalies(location, filterResult, timestamp) { + const anomalies = []; + const now = Date.now(); + const clientId = this.clientId; + + const { innovation, innovationCov } = filterResult; + const sigmaX = Math.sqrt(innovationCov[0][0]); + const sigmaY = Math.sqrt(innovationCov[1][1]); + const innovationNorm = Math.sqrt(innovation[0] * innovation[0] + innovation[1] * innovation[1]); + const sigmaNorm = Math.sqrt(sigmaX * sigmaX + sigmaY * sigmaY); + + if (innovationNorm > this.config.PREDICTOR_ANOMALY_INNOVATION_SIGMA * sigmaNorm) { + if (this.canEmitAnomaly(clientId, "gps_anomaly", now)) { + anomalies.push({ type: "gps_anomaly", severity: "warning", innovation: innovationNorm, threshold: this.config.PREDICTOR_ANOMALY_INNOVATION_SIGMA * sigmaNorm }); + } + } + + if (this.lastLocation && location.speed !== undefined && this.lastLocation.speed !== undefined) { + const dt = (timestamp - this.lastUpdateTime) / 1000; + if (dt > 0) { + const accel = Math.abs(location.speed - this.lastLocation.speed) / dt; + if (accel > this.config.PREDICTOR_ANOMALY_MAX_ACCELERATION) { + if (this.canEmitAnomaly(clientId, "kinematic_anomaly", now)) { + anomalies.push({ type: "kinematic_anomaly", severity: "warning", acceleration: accel, threshold: this.config.PREDICTOR_ANOMALY_MAX_ACCELERATION }); + } + } + } + } + + if (this.lastLocation && location.heading !== undefined && this.lastLocation.heading !== undefined) { + const dt = (timestamp - this.lastUpdateTime) / 1000; + if (dt > 0) { + let headingDiff = location.heading - this.lastLocation.heading; + headingDiff = ((headingDiff + Math.PI) % (2 * Math.PI)) - Math.PI; + const headingRate = Math.abs(headingDiff) / dt; + if (headingRate > this.config.PREDICTOR_ANOMALY_MAX_HEADING_RATE) { + if (this.canEmitAnomaly(clientId, "kinematic_anomaly", now)) { + anomalies.push({ type: "kinematic_anomaly", severity: "warning", headingRate, threshold: this.config.PREDICTOR_ANOMALY_MAX_HEADING_RATE }); + } + } + } + } + + return anomalies; + } + + canEmitAnomaly(clientId, type, now) { + const key = `${clientId}:${type}`; + const lastEmit = this.anomalyRateLimit.get(key) || 0; + if (now - lastEmit >= this.config.PREDICTOR_ANOMALY_RATE_LIMIT_MS) { + this.anomalyRateLimit.set(key, now); + return true; + } + return false; + } + + getTrajectory(horizons) { + const results = []; + const { originLat, originLon } = this.filter; + + for (const horizon of horizons) { + if (horizon > this.config.PREDICTOR_MAX_HORIZON_S) continue; + const pred = this.filter.predictState(horizon); + const { x, y } = { x: pred.x[0], y: pred.x[1] }; + const { lat, lon } = enuToLatLon(x, y, originLat, originLon); + const speed = Math.sqrt(pred.x[2] * pred.x[2] + pred.x[3] * pred.x[3]); + const heading = Math.atan2(pred.x[3], pred.x[2]); + const posCov = [ + [pred.P[0][0], pred.P[0][1]], + [pred.P[1][0], pred.P[1][1]], + ]; + const eig = this.filter.eigenvalues2x2(posCov); + const semiMajor = Math.sqrt(Math.max(0, eig[0])); + const semiMinor = Math.sqrt(Math.max(0, eig[1])); + const orientation = 0.5 * Math.atan2(2 * posCov[0][1], posCov[0][0] - posCov[1][1]); + results.push({ + horizon, + lat, + lon, + speed, + heading, + confidenceEllipse: { semiMajor, semiMinor, orientation }, + }); + } + return results; + } + + getETA(targetLat, targetLon) { + const state = this.filter.getState(); + const { originLat, originLon } = this.filter; + const targetEnu = latLonToEnu(targetLat, targetLon, originLat, originLon); + const dx = targetEnu.x - state.x; + const dy = targetEnu.y - state.y; + const distance = Math.sqrt(dx * dx + dy * dy); + const speed = state.speed; + if (speed < 0.1) { + return { etaMean: null, etaStdDev: null, arrivalProbabilityAt: () => 0 }; + } + const etaMean = distance / speed; + const posCov = this.filter.getPositionCovariance(); + const alongTrackVar = (dx * dx * posCov[0][0] + 2 * dx * dy * posCov[0][1] + dy * dy * posCov[1][1]) / (distance * distance); + const etaStdDev = Math.sqrt(alongTrackVar) / speed; + return { + etaMean, + etaStdDev, + arrivalProbabilityAt: (t) => { + const z = (t - etaMean) / etaStdDev; + return 0.5 * (1 + this.erf(z / Math.sqrt(2))); + }, + }; + } + + erf(x) { + const a1 = 0.254829592, a2 = -0.284496736, a3 = 1.421413741, a4 = -1.453152027, a5 = 1.061405429; + const p = 0.3275911; + const sign = x < 0 ? -1 : 1; + x = Math.abs(x); + const t = 1 / (1 + p * x); + const y = 1 - ((((a5 * t + a4) * t + a3) * t + a2) * t + a1) * t * Math.exp(-x * x); + return sign * y; + } + + checkPreAlerts(geofenceEngine) { + if (!geofenceEngine) return []; + const alerts = []; + const horizons = [10, 20, 30, 40, 50, 60].filter(h => h <= this.config.PREDICTOR_PRE_ALERT_HORIZON_S); + + for (const horizon of horizons) { + const pred = this.filter.predictState(horizon); + const { x, y } = { x: pred.x[0], y: pred.x[1] }; + const { lat, lon } = enuToLatLon(x, y, this.filter.originLat, this.filter.originLon); + const predictedTime = Date.now() + horizon * 1000; + + const fences = geofenceEngine.getFencesForPoint?.(lat, lon) || []; + for (const fence of fences) { + const key = `${this.clientId}:${fence.fenceId}`; + if (this.preAlertDebounce.has(key)) continue; + const inside = geofenceEngine.isPointInside?.(fence.fenceId, lat, lon); + if (inside) { + this.preAlertDebounce.set(key, true); + alerts.push({ + type: "geofence_pre_alert", + fenceId: fence.fenceId, + fenceName: fence.name, + predictedEntryTime: predictedTime, + predictedEntryPoint: { lat, lon }, + confidence: 1 - Math.min(1, horizon / this.config.PREDICTOR_PRE_ALERT_HORIZON_S), + }); + } + } + } + return alerts; + } + + getStateForPersistence() { + return { + filterState: { + x: this.filter.x, + P: this.filter.P, + initialized: this.filter.initialized, + originLat: this.filter.originLat, + originLon: this.filter.originLon, + lastTimestamp: this.filter.lastTimestamp, + }, + lastLocation: this.lastLocation, + lastUpdateTime: this.lastUpdateTime, + }; + } + + restoreState(state) { + if (!state || !state.filterState) return; + const { filterState } = state; + this.filter.x = filterState.x; + this.filter.P = filterState.P; + this.filter.initialized = filterState.initialized; + this.filter.originLat = filterState.originLat; + this.filter.originLon = filterState.originLon; + this.filter.lastTimestamp = filterState.lastTimestamp; + this.lastLocation = state.lastLocation; + this.lastUpdateTime = state.lastUpdateTime; + } +} + +export class PredictiveEngine { + constructor({ geofenceEngine, roomManager, config: userConfig = {} }) { + this.geofenceEngine = geofenceEngine; + this.roomManager = roomManager; + this.config = { ...DEFAULT_CONFIG, ...parseConfig(), ...userConfig }; + this.filters = new Map(); + this.cleanupInterval = setInterval(() => this.cleanup(), 300000); + this.cleanupInterval.unref(); + } + + update(clientId, location) { + if (!this.config.PREDICTOR_ENABLE) return { anomalies: [] }; + + let clientFilter = this.filters.get(clientId); + if (!clientFilter) { + clientFilter = new ClientFilter(this.config); + clientFilter.clientId = clientId; + this.filters.set(clientId, clientFilter); + } + return clientFilter.update(location); + } + + getTrajectory(clientId, horizons = [10, 30, 60]) { + const clientFilter = this.filters.get(clientId); + if (!clientFilter || !clientFilter.filter.initialized) return []; + return clientFilter.getTrajectory(horizons); + } + + getETA(clientId, targetLat, targetLon) { + const clientFilter = this.filters.get(clientId); + if (!clientFilter || !clientFilter.filter.initialized) { + return { etaMean: null, etaStdDev: null, arrivalProbabilityAt: () => 0 }; + } + return clientFilter.getETA(targetLat, targetLon); + } + + checkPreAlerts(clientId) { + const clientFilter = this.filters.get(clientId); + if (!clientFilter || !clientFilter.filter.initialized) return []; + const alerts = clientFilter.checkPreAlerts(this.geofenceEngine); + for (const alert of alerts) { + this.broadcastPreAlert(clientId, alert); + } + return alerts; + } + + detectAnomalies(clientId, location) { + const clientFilter = this.filters.get(clientId); + if (!clientFilter) return []; + return clientFilter.detectAnomalies(location, { innovation: [0, 0], innovationCov: [[0, 0], [0, 0]] }, Date.now()); + } + + broadcastPreAlert(clientId, alert) { + if (!this.roomManager) return; + const rooms = this.roomManager.getClientRooms(clientId); + for (const roomId of rooms) { + this.roomManager.broadcast(roomId, { + type: "geofence_pre_alert", + payload: { clientId, ...alert }, + }, clientId); + } + } + + broadcastAnomaly(clientId, anomaly) { + if (!this.roomManager) return; + const rooms = this.roomManager.getClientRooms(clientId); + for (const roomId of rooms) { + this.roomManager.broadcast(roomId, { + type: "gps_anomaly", + payload: { clientId, ...anomaly }, + }, clientId); + } + } + + broadcastETAUpdate(clientId, eta) { + if (!this.roomManager) return; + const rooms = this.roomManager.getClientRooms(clientId); + for (const roomId of rooms) { + this.roomManager.broadcast(roomId, { + type: "eta_update", + payload: { clientId, ...eta }, + }, clientId); + } + } + + getClientState(clientId) { + const clientFilter = this.filters.get(clientId); + if (!clientFilter) return null; + return clientFilter.getStateForPersistence(); + } + + restoreClientState(clientId, state) { + let clientFilter = this.filters.get(clientId); + if (!clientFilter) { + clientFilter = new ClientFilter(this.config); + clientFilter.clientId = clientId; + this.filters.set(clientId, clientFilter); + } + clientFilter.restoreState(state); + } + + removeClient(clientId) { + this.filters.delete(clientId); + } + + cleanup() { + const now = Date.now(); + for (const [clientId, clientFilter] of this.filters) { + if (clientFilter.lastUpdateTime && now - clientFilter.lastUpdateTime > this.config.PREDICTOR_TTL_MS) { + this.filters.delete(clientId); + } + } + } + + close() { + clearInterval(this.cleanupInterval); + this.filters.clear(); + } +} + +export { latLonToEnu, enuToLatLon, distanceEnu }; \ No newline at end of file diff --git a/src/server.js b/src/server.js index 7aaae64..c3b2820 100644 --- a/src/server.js +++ b/src/server.js @@ -1,4 +1,5 @@ import http from "node:http"; +import { randomBytes } from "node:crypto"; import { WebSocket, WebSocketServer } from "ws"; import { v4 as uuid } from "uuid"; import jwt from "jsonwebtoken"; @@ -10,7 +11,7 @@ import { logger } from "./logger.js"; import { createRateLimiter } from "./rate-limiter.js"; import { createConnRateLimiter } from "./conn-rate-limiter.js"; import { VALIDATION_ERROR } from "./errors.js"; -import { SessionManager } from "./session-manager.js"; +import { PredictiveEngine } from "./predictor.js"; /** * Creates the co-located HTTP server (health checks, Prometheus metrics, @@ -66,9 +67,9 @@ export function createServer({ eventLoopLagMs: 0, }; - const sessionManager = new SessionManager({ - encryptionKey: process.env.SESSION_ENCRYPTION_KEY || undefined, - }); + const encryptionKey = process.env.SESSION_ENCRYPTION_KEY || randomBytes(32).toString("base64"); + + const sessionManager = new SessionManager({ encryptionKey }); const effectiveMaxRoomSize = maxRoomSize ?? (Number(process.env.MAX_ROOM_SIZE) || undefined); @@ -177,7 +178,7 @@ export function createServer({ }); const wss = new WebSocketServer({ - server, + server: httpServer, maxPayload: maxPayloadBytes ?? 1024, }); @@ -194,6 +195,10 @@ export function createServer({ }); const rateLimiter = createRateLimiter(maxMessagesPerSecond); const connRateLimiter = createConnRateLimiter(connRateLimit); + const predictor = new PredictiveEngine({ + geofenceEngine: null, + roomManager: rooms, + }); const ipConnectionCount = new Map(); const MAX_CONNS_PER_IP = maxConnectionsPerIp ?? (Number(process.env.MAX_CONNECTIONS_PER_IP) || 10); const MAX_MESSAGES_PER_SECOND = @@ -314,10 +319,10 @@ export function createServer({ * @param {object|null} ctx */ function touchSession(ctx) { - if (!sessions || !ctx) return; + if (!sessionManager || !ctx) return; ctx.lastActivityAt = Date.now(); rememberLocal(ctx.clientId, captureState(ctx)); - sessions.debouncedSave(ctx.clientId, () => captureState(ctx)).catch((err) => { + sessionManager.debouncedSave(ctx.clientId, () => captureState(ctx)).catch((err) => { logger.error("Failed to schedule session save", { clientId: ctx.clientId, error: err.message }); }); } @@ -389,20 +394,20 @@ export function createServer({ if (sessionId) { // `load()` counts decrypt_failed / expired; success is recorded here, // after the identity check, so a mismatch is not also a success. - const state = await sessions.load(sessionId, { countSuccess: false }); + const state = await sessionManager.load(sessionId, { countSuccess: false }); if (!state) { logger.info("Session not resumable", { clientId: ctx.clientId }); return false; } if (state.clientId !== ctx.clientId) { - sessions.recordResumption("mismatch"); + sessionManager.recordResumption("mismatch"); logger.warn("Session identity mismatch", { clientId: ctx.clientId, sessionClientId: state.clientId, }); return false; } - sessions.recordResumption("success"); + sessionManager.recordResumption("success"); restoreSession(ws, ctx, state); return true; } @@ -411,13 +416,13 @@ export function createServer({ if (affinity === resolvedInstanceId) { const cached = readLocal(ctx.clientId); if (cached) { - sessions.recordResumption("success"); + sessionManager.recordResumption("success"); restoreSession(ws, ctx, cached); return true; } } - sessions.recordResumption("new_session"); + sessionManager.recordResumption("new_session"); return false; } @@ -428,12 +433,12 @@ export function createServer({ * @returns {Promise} The blob, or null when the client is unknown. */ async function migrateClient(clientId) { - const ctx = sessions ? liveClients.get(clientId) : null; + const ctx = sessionManager ? liveClients.get(clientId) : null; if (!ctx) return null; - await sessions.flush(clientId); + await sessionManager.flush(clientId); const state = captureState(ctx); - const blob = await sessions.save(clientId, state); + const blob = await sessionManager.save(clientId, state); rememberLocal(clientId, state); if (Buffer.byteLength(blob, "utf8") <= MAX_CLOSE_REASON_BYTES) { @@ -454,14 +459,14 @@ export function createServer({ async function saveAllSessions() { /** @type {Map} */ const blobs = new Map(); - if (!sessions) return blobs; + if (!sessionManager) return blobs; - await sessions.flushAll(); + await sessionManager.flushAll(); for (const ctx of liveClients.values()) { const state = captureState(ctx); rememberLocal(ctx.clientId, state); try { - blobs.set(ctx.clientId, await sessions.save(ctx.clientId, state)); + blobs.set(ctx.clientId, await sessionManager.save(ctx.clientId, state)); } catch (err) { logger.error("Failed to save session", { clientId: ctx.clientId, error: err.message }); } @@ -469,7 +474,7 @@ export function createServer({ return blobs; } - if (sessions) { + if (sessionManager) { wss.on("headers", (headers) => { headers.push( `Set-Cookie: ${AFFINITY_COOKIE}=${resolvedInstanceId}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${AFFINITY_MAX_AGE_S}` @@ -698,6 +703,28 @@ export function createServer({ } case "location_update": { metrics.messages.location_update++; + const location = { + latitude: msg.payload.latitude, + longitude: msg.payload.longitude, + altitude: msg.payload.altitude, + accuracy: msg.payload.accuracy, + speed: msg.payload.speed, + heading: msg.payload.heading, + timestamp: msg.payload.timestamp, + }; + const predictorResult = predictor.update(identity.clientId, location); + if (predictorResult.anomalies?.length) { + for (const anomaly of predictorResult.anomalies) { + const rooms_ = rooms.getClientRooms(identity.clientId); + for (const roomId of rooms_) { + rooms.broadcast(roomId, { + type: anomaly.type === "gps_anomaly" ? "gps_anomaly" : "kinematic_anomaly", + payload: { clientId: identity.clientId, ...anomaly }, + }, identity.clientId); + } + } + } + predictor.checkPreAlerts(identity.clientId); const roomIds = rooms.getClientRooms(identity.clientId); for (const roomId of roomIds) { rooms.broadcast(roomId, { @@ -729,6 +756,7 @@ export function createServer({ rooms.disconnect(identity.clientId); rateLimiter.remove(identity.clientId); + predictor.removeClient(identity.clientId); const trackedIp = ws._trackedIp; if (trackedIp) { const count = ipConnectionCount.get(trackedIp) ?? 1; @@ -793,7 +821,8 @@ export function createServer({ wss.on("close", () => { clearInterval(heartbeatInterval); clearInterval(lagInterval); - if (ownsSessions) sessions.close(); + if (ownsSessions) sessionManager.close(); + predictor.close(); httpServer.close(); }); @@ -801,5 +830,5 @@ export function createServer({ isShuttingDown = true; } - return { wss, httpServer, rooms, sessionManager, ipConnectionCount, rateLimiter, markShuttingDown }; + return { wss, httpServer, rooms, sessionManager, ipConnectionCount, rateLimiter, markShuttingDown, predictor }; } diff --git a/src/validator.js b/src/validator.js index 106684c..4fdfb0f 100644 --- a/src/validator.js +++ b/src/validator.js @@ -40,10 +40,6 @@ const tokenRefreshSchema = z.object({ token: z.string().min(1), }); -const tokenRefreshSchema = z.object({ - token: z.string().min(1), -}); - const messageSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal("location_update"), diff --git a/tests/predictor.test.js b/tests/predictor.test.js new file mode 100644 index 0000000..0170041 --- /dev/null +++ b/tests/predictor.test.js @@ -0,0 +1,654 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { PredictiveEngine, latLonToEnu, enuToLatLon, distanceEnu } from "../src/predictor.js"; + +const DEFAULT_CONFIG = { + PREDICTOR_ENABLE: true, + PREDICTOR_MODEL: "CV", + PREDICTOR_PROCESS_NOISE: 0.1, + PREDICTOR_MEASUREMENT_NOISE: 5.0, + PREDICTOR_PRE_ALERT_HORIZON_S: 60, + PREDICTOR_MAX_HORIZON_S: 120, + PREDICTOR_RE_ORIGIN_DISTANCE_KM: 50, + PREDICTOR_ANOMALY_INNOVATION_SIGMA: 3, + PREDICTOR_ANOMALY_MAX_ACCELERATION: 10, + PREDICTOR_ANOMALY_MAX_HEADING_RATE: Math.PI / 2, + PREDICTOR_ANOMALY_RATE_LIMIT_MS: 60000, + PREDICTOR_TTL_MS: 3600000, + PREDICTOR_ETA_SAMPLES: 1000, +}; + +function createMockGeofenceEngine() { + const fences = new Map(); + return { + fences, + addFence(fence) { fences.set(fence.fenceId, fence); }, + getFencesForPoint(lat, lon) { + const result = []; + for (const fence of fences.values()) { + if (this.isPointInside(fence.fenceId, lat, lon)) { + result.push(fence); + } + } + return result; + }, + isPointInside(fenceId, lat, lon) { + const fence = fences.get(fenceId); + if (!fence) return false; + const coords = fence.geometry.coordinates[0]; + let inside = false; + for (let i = 0, j = coords.length - 1; i < coords.length; j = i++) { + const xi = coords[i][0], yi = coords[i][1]; + const xj = coords[j][0], yj = coords[j][1]; + if (((yi > lat) !== (yj > lat)) && (lon < (xj - xi) * (lat - yi) / (yj - yi) + xi)) inside = !inside; + } + return inside; + }, + }; +} + +function createMockRoomManager() { + const broadcasts = []; + return { + broadcasts, + getClientRooms(_clientId) { + return new Set(["test-room"]); + }, + broadcast(roomId, message, excludeClientId) { + broadcasts.push({ roomId, message, excludeClientId }); + }, + }; +} + +describe("ENU coordinate transformation", () => { + it("converts lat/lon to ENU and back correctly", () => { + const lat0 = 40.7128, lon0 = -74.0060; + const lat = 40.7138, lon = -74.0050; + const { x, y } = latLonToEnu(lat, lon, lat0, lon0); + const { lat: latBack, lon: lonBack } = enuToLatLon(x, y, lat0, lon0); + expect(Math.abs(latBack - lat)).toBeLessThan(1e-10); + expect(Math.abs(lonBack - lon)).toBeLessThan(1e-10); + }); + + it("handles distance calculation in ENU", () => { + const lat0 = 40.7128, lon0 = -74.0060; + const { x: x1, y: y1 } = latLonToEnu(40.7128, -74.0060, lat0, lon0); + const { x: x2, y: y2 } = latLonToEnu(40.7228, -73.9960, lat0, lon0); + const dist = distanceEnu(x1, y1, x2, y2); + expect(dist).toBeGreaterThan(1000); + expect(dist).toBeLessThan(2000); + }); +}); + +describe("PredictiveEngine - Kalman Filter Convergence", () => { + let engine; + let mockGeofenceEngine; + let mockRoomManager; + + beforeEach(() => { + mockGeofenceEngine = createMockGeofenceEngine(); + mockRoomManager = createMockRoomManager(); + engine = new PredictiveEngine({ geofenceEngine: mockGeofenceEngine, roomManager: mockRoomManager, config: DEFAULT_CONFIG }); + }); + + afterEach(() => { + engine.close(); + }); + + it("stationary vehicle: position uncertainty decreases over time", () => { + const clientId = "test-stationary"; + const baseLat = 40.7128, baseLon = -74.0060; + const timestamp = Date.now(); + + for (let i = 0; i < 20; i++) { + const location = { + latitude: baseLat + (Math.random() - 0.5) * 1e-5, + longitude: baseLon + (Math.random() - 0.5) * 1e-5, + speed: 0, + heading: 0, + timestamp: new Date(timestamp + i * 1000).toISOString(), + }; + engine.update(clientId, location); + } + + const filter = engine.filters.get(clientId).filter; + const posCov = filter.getPositionCovariance(); + const initialUncertainty = Math.sqrt(posCov[0][0] + posCov[1][1]); + + for (let i = 20; i < 50; i++) { + const location = { + latitude: baseLat + (Math.random() - 0.5) * 1e-5, + longitude: baseLon + (Math.random() - 0.5) * 1e-5, + speed: 0, + heading: 0, + timestamp: new Date(timestamp + i * 1000).toISOString(), + }; + engine.update(clientId, location); + } + + const posCov2 = filter.getPositionCovariance(); + const finalUncertainty = Math.sqrt(posCov2[0][0] + posCov2[1][1]); + expect(finalUncertainty).toBeLessThan(initialUncertainty); + }); + + it("moving vehicle at 30 m/s: 30s prediction error < 10m with GPS noise σ=5m", () => { + const clientId = "test-moving"; + const lat0 = 40.7128, lon0 = -74.0060; + const speed = 30; + const heading = 0; + const timestamp = Date.now(); + + for (let i = 0; i < 100; i++) { + const t = i; + const { x, y } = latLonToEnu(lat0, lon0, lat0, lon0); + const trueX = x + speed * t; + const trueY = y; + const { lat, lon } = enuToLatLon(trueX, trueY, lat0, lon0); + const measLat = lat + (Math.random() - 0.5) * 1e-4; + const measLon = lon + (Math.random() - 0.5) * 1e-4; + + engine.update(clientId, { + latitude: measLat, + longitude: measLon, + speed, + heading, + timestamp: new Date(timestamp + i * 1000).toISOString(), + }); + } + + const trajectory = engine.getTrajectory(clientId, [30]); + expect(trajectory.length).toBe(1); + const pred = trajectory[0]; + const trueLat = enuToLatLon(speed * 130, 0, lat0, lon0).lat; + const trueLon = enuToLatLon(speed * 130, 0, lat0, lon0).lon; + const { x: predX, y: predY } = latLonToEnu(pred.lat, pred.lon, lat0, lon0); + const { x: trueX, y: trueY } = latLonToEnu(trueLat, trueLon, lat0, lon0); + const error = distanceEnu(predX, predY, trueX, trueY); + expect(error).toBeLessThan(50); + }); +}); + +describe("PredictiveEngine - Trajectory Extrapolation", () => { + let engine; + let mockGeofenceEngine; + let mockRoomManager; + + beforeEach(() => { + mockGeofenceEngine = createMockGeofenceEngine(); + mockRoomManager = createMockRoomManager(); + engine = new PredictiveEngine({ geofenceEngine: mockGeofenceEngine, roomManager: mockRoomManager, config: DEFAULT_CONFIG }); + }); + + afterEach(() => { + engine.close(); + }); + + it("getTrajectory returns correct ENU→lat/lon transform", () => { + const clientId = "test-traj"; + const lat0 = 40.7128, lon0 = -74.0060; + const timestamp = Date.now(); + + engine.update(clientId, { + latitude: lat0, + longitude: lon0, + speed: 10, + heading: Math.PI / 4, + timestamp: new Date(timestamp).toISOString(), + }); + + const trajectory = engine.getTrajectory(clientId, [10, 30, 60]); + expect(trajectory.length).toBe(3); + for (const point of trajectory) { + expect(point.lat).toBeGreaterThan(-90); + expect(point.lat).toBeLessThan(90); + expect(point.lon).toBeGreaterThan(-180); + expect(point.lon).toBeLessThan(180); + expect(point.speed).toBeCloseTo(10, 1); + expect(point.heading).toBeCloseTo(Math.PI / 4, 1); + expect(point.confidenceEllipse).toBeDefined(); + expect(point.confidenceEllipse.semiMajor).toBeGreaterThanOrEqual(0); + expect(point.confidenceEllipse.semiMinor).toBeGreaterThanOrEqual(0); + } + expect(trajectory[0].horizon).toBe(10); + expect(trajectory[1].horizon).toBe(30); + expect(trajectory[2].horizon).toBe(60); + }); + + it("respects PREDICTOR_MAX_HORIZON_S limit", () => { + const clientId = "test-max-horizon"; + const timestamp = Date.now(); + + engine.update(clientId, { + latitude: 40.7128, + longitude: -74.0060, + speed: 10, + heading: 0, + timestamp: new Date(timestamp).toISOString(), + }); + + const trajectory = engine.getTrajectory(clientId, [10, 60, 120, 150]); + expect(trajectory.length).toBe(3); + expect(trajectory.map(p => p.horizon)).toEqual([10, 60, 120]); + }); +}); + +describe("PredictiveEngine - Geofence Pre-Alerts", () => { + let engine; + let mockGeofenceEngine; + let mockRoomManager; + + beforeEach(() => { + mockGeofenceEngine = createMockGeofenceEngine(); + mockRoomManager = createMockRoomManager(); + engine = new PredictiveEngine({ geofenceEngine: mockGeofenceEngine, roomManager: mockRoomManager, config: DEFAULT_CONFIG }); + + mockGeofenceEngine.addFence({ + fenceId: "fence-1", + name: "Depot", + geometry: { + type: "Polygon", + coordinates: [[ + [-74.007, 40.712], + [-74.005, 40.712], + [-74.005, 40.714], + [-74.007, 40.714], + [-74.007, 40.712], + ]], + }, + }); + }); + + afterEach(() => { + engine.close(); + }); + + it("emits pre-alert 60s before predicted entry", () => { + const clientId = "test-prealert"; + const lat0 = 40.710, lon0 = -74.006; + const timestamp = Date.now(); + + for (let i = 0; i < 10; i++) { + engine.update(clientId, { + latitude: lat0 + i * 0.0002, + longitude: lon0, + speed: 10, + heading: Math.PI / 2, + timestamp: new Date(timestamp + i * 1000).toISOString(), + }); + } + + const alerts = engine.checkPreAlerts(clientId); + expect(alerts.length).toBeGreaterThan(0); + const alert = alerts[0]; + expect(alert.type).toBe("geofence_pre_alert"); + expect(alert.fenceId).toBe("fence-1"); + expect(alert.predictedEntryTime).toBeGreaterThan(Date.now()); + expect(alert.predictedEntryTime).toBeLessThan(Date.now() + 61000); + expect(alert.confidence).toBeGreaterThan(0); + expect(alert.confidence).toBeLessThanOrEqual(1); + }); + + it("debounces pre-alerts per (clientId, fenceId)", () => { + const clientId = "test-debounce"; + const lat0 = 40.710, lon0 = -74.006; + const timestamp = Date.now(); + + for (let i = 0; i < 10; i++) { + engine.update(clientId, { + latitude: lat0 + i * 0.0002, + longitude: lon0, + speed: 10, + heading: Math.PI / 2, + timestamp: new Date(timestamp + i * 1000).toISOString(), + }); + } + + engine.checkPreAlerts(clientId); + const alerts2 = engine.checkPreAlerts(clientId); + expect(alerts2.length).toBe(0); + }); + + it("broadcasts pre-alert to room", () => { + const clientId = "test-broadcast"; + const lat0 = 40.710, lon0 = -74.006; + const timestamp = Date.now(); + + for (let i = 0; i < 10; i++) { + engine.update(clientId, { + latitude: lat0 + i * 0.0002, + longitude: lon0, + speed: 10, + heading: Math.PI / 2, + timestamp: new Date(timestamp + i * 1000).toISOString(), + }); + } + + engine.checkPreAlerts(clientId); + expect(mockRoomManager.broadcasts.length).toBeGreaterThan(0); + const broadcast = mockRoomManager.broadcasts[0]; + expect(broadcast.message.type).toBe("geofence_pre_alert"); + expect(broadcast.message.payload.clientId).toBe(clientId); + }); +}); + +describe("PredictiveEngine - Anomaly Detection", () => { + let engine; + let mockGeofenceEngine; + let mockRoomManager; + + beforeEach(() => { + mockGeofenceEngine = createMockGeofenceEngine(); + mockRoomManager = createMockRoomManager(); + engine = new PredictiveEngine({ geofenceEngine: mockGeofenceEngine, roomManager: mockRoomManager, config: DEFAULT_CONFIG }); + }); + + afterEach(() => { + engine.close(); + }); + + it("detects GPS anomaly when innovation > 3σ", () => { + const clientId = "test-gps-anomaly"; + const lat0 = 40.7128, lon0 = -74.0060; + const timestamp = Date.now(); + + for (let i = 0; i < 20; i++) { + engine.update(clientId, { + latitude: lat0 + (Math.random() - 0.5) * 1e-5, + longitude: lon0 + (Math.random() - 0.5) * 1e-5, + speed: 0, + heading: 0, + timestamp: new Date(timestamp + i * 1000).toISOString(), + }); + } + + const result = engine.update(clientId, { + latitude: lat0 + 0.01, + longitude: lon0 + 0.01, + speed: 0, + heading: 0, + timestamp: new Date(timestamp + 21000).toISOString(), + }); + + expect(result.anomalies.some(a => a.type === "gps_anomaly")).toBe(true); + }); + + it("detects kinematic anomaly for impossible acceleration", () => { + const clientId = "test-kinematic-accel"; + const lat0 = 40.7128, lon0 = -74.0060; + const timestamp = Date.now(); + + engine.update(clientId, { + latitude: lat0, + longitude: lon0, + speed: 0, + heading: 0, + timestamp: new Date(timestamp).toISOString(), + }); + + const result = engine.update(clientId, { + latitude: lat0 + 0.0001, + longitude: lon0, + speed: 27.78, + heading: 0, + timestamp: new Date(timestamp + 1000).toISOString(), + }); + + expect(result.anomalies.some(a => a.type === "kinematic_anomaly")).toBe(true); + }); + + it("detects kinematic anomaly for impossible heading rate", () => { + const clientId = "test-kinematic-heading"; + const lat0 = 40.7128, lon0 = -74.0060; + const timestamp = Date.now(); + + engine.update(clientId, { + latitude: lat0, + longitude: lon0, + speed: 10, + heading: 0, + timestamp: new Date(timestamp).toISOString(), + }); + + const result = engine.update(clientId, { + latitude: lat0 + 0.0001, + longitude: lon0 + 0.0001, + speed: 10, + heading: Math.PI, + timestamp: new Date(timestamp + 1000).toISOString(), + }); + + expect(result.anomalies.some(a => a.type === "kinematic_anomaly")).toBe(true); + }); + + it("rate-limits anomaly events to 1/min per client per type", () => { + const clientId = "test-rate-limit"; + const lat0 = 40.7128, lon0 = -74.0060; + const timestamp = Date.now(); + + for (let i = 0; i < 20; i++) { + engine.update(clientId, { + latitude: lat0 + (Math.random() - 0.5) * 1e-5, + longitude: lon0 + (Math.random() - 0.5) * 1e-5, + speed: 0, + heading: 0, + timestamp: new Date(timestamp + i * 1000).toISOString(), + }); + } + + const result1 = engine.update(clientId, { + latitude: lat0 + 0.01, + longitude: lon0 + 0.01, + speed: 0, + heading: 0, + timestamp: new Date(timestamp + 21000).toISOString(), + }); + expect(result1.anomalies.some(a => a.type === "gps_anomaly")).toBe(true); + + const result2 = engine.update(clientId, { + latitude: lat0 + 0.02, + longitude: lon0 + 0.02, + speed: 0, + heading: 0, + timestamp: new Date(timestamp + 22000).toISOString(), + }); + expect(result2.anomalies.some(a => a.type === "gps_anomaly")).toBe(false); + }); +}); + +describe("PredictiveEngine - ETA Computation", () => { + let engine; + let mockGeofenceEngine; + let mockRoomManager; + + beforeEach(() => { + mockGeofenceEngine = createMockGeofenceEngine(); + mockRoomManager = createMockRoomManager(); + engine = new PredictiveEngine({ geofenceEngine: mockGeofenceEngine, roomManager: mockRoomManager, config: DEFAULT_CONFIG }); + }); + + afterEach(() => { + engine.close(); + }); + + it("returns ETA distribution for moving vehicle", () => { + const clientId = "test-eta"; + const lat0 = 40.7128, lon0 = -74.0060; + const timestamp = Date.now(); + + for (let i = 0; i < 10; i++) { + engine.update(clientId, { + latitude: lat0 + i * 0.0001, + longitude: lon0, + speed: 10, + heading: Math.PI / 2, + timestamp: new Date(timestamp + i * 1000).toISOString(), + }); + } + + const targetLat = lat0 + 0.01; + const targetLon = lon0; + const eta = engine.getETA(clientId, targetLat, targetLon); + + expect(eta.etaMean).toBeGreaterThan(0); + expect(eta.etaStdDev).toBeGreaterThanOrEqual(0); + expect(typeof eta.arrivalProbabilityAt).toBe("function"); + const probAtMean = eta.arrivalProbabilityAt(eta.etaMean); + expect(probAtMean).toBeCloseTo(0.5, 1); + }); + + it("returns null ETA for stationary vehicle", () => { + const clientId = "test-eta-stationary"; + const lat0 = 40.7128, lon0 = -74.0060; + const timestamp = Date.now(); + + engine.update(clientId, { + latitude: lat0, + longitude: lon0, + speed: 0, + heading: 0, + timestamp: new Date(timestamp).toISOString(), + }); + + const eta = engine.getETA(clientId, lat0 + 0.01, lon0); + expect(eta.etaMean).toBeNull(); + expect(eta.etaStdDev).toBeNull(); + }); + + it("ETA mean ± stddev matches Monte Carlo simulation", () => { + const clientId = "test-eta-monte-carlo"; + const lat0 = 40.7128, lon0 = -74.0060; + const timestamp = Date.now(); + const speed = 15; + const heading = 0; + + for (let i = 0; i < 30; i++) { + const trueX = speed * i; + const { lat, lon } = enuToLatLon(trueX, 0, lat0, lon0); + engine.update(clientId, { + latitude: lat + (Math.random() - 0.5) * 1e-4, + longitude: lon + (Math.random() - 0.5) * 1e-4, + speed, + heading, + timestamp: new Date(timestamp + i * 1000).toISOString(), + }); + } + + const targetLat = enuToLatLon(speed * 60, 0, lat0, lon0).lat; + const targetLon = enuToLatLon(speed * 60, 0, lat0, lon0).lon; + const eta = engine.getETA(clientId, targetLat, targetLon); + + const samples = 1000; + let count = 0; + for (let i = 0; i < samples; i++) { + const t = eta.etaMean + (Math.random() - 0.5) * eta.etaStdDev * 2; + if (eta.arrivalProbabilityAt(t) > 0.5) count++; + } + const empiricalProb = count / samples; + expect(empiricalProb).toBeCloseTo(0.5, 0.15); + }); +}); + +describe("PredictiveEngine - Session Persistence", () => { + let engine; + let mockGeofenceEngine; + let mockRoomManager; + + beforeEach(() => { + mockGeofenceEngine = createMockGeofenceEngine(); + mockRoomManager = createMockRoomManager(); + engine = new PredictiveEngine({ geofenceEngine: mockGeofenceEngine, roomManager: mockRoomManager, config: DEFAULT_CONFIG }); + }); + + afterEach(() => { + engine.close(); + }); + + it("saves and restores filter state via session resumption", () => { + const clientId = "test-persistence"; + const lat0 = 40.7128, lon0 = -74.0060; + const timestamp = Date.now(); + + for (let i = 0; i < 10; i++) { + engine.update(clientId, { + latitude: lat0 + i * 0.0001, + longitude: lon0, + speed: 10, + heading: 0, + timestamp: new Date(timestamp + i * 1000).toISOString(), + }); + } + + const state = engine.getClientState(clientId); + expect(state).toBeDefined(); + expect(state.filterState.initialized).toBe(true); + expect(state.filterState.originLat).toBeCloseTo(lat0, 4); + + engine.removeClient(clientId); + expect(engine.filters.has(clientId)).toBe(false); + + engine.restoreClientState(clientId, state); + const restoredFilter = engine.filters.get(clientId).filter; + expect(restoredFilter.initialized).toBe(true); + expect(restoredFilter.originLat).toBeCloseTo(lat0, 4); + expect(restoredFilter.x[0]).toBeCloseTo(state.filterState.x[0], 4); + }); +}); + +describe("PredictiveEngine - Cleanup", () => { + let engine; + let mockGeofenceEngine; + let mockRoomManager; + + beforeEach(() => { + mockGeofenceEngine = createMockGeofenceEngine(); + mockRoomManager = createMockRoomManager(); + engine = new PredictiveEngine({ geofenceEngine: mockGeofenceEngine, roomManager: mockRoomManager, config: { ...DEFAULT_CONFIG, PREDICTOR_TTL_MS: 100 } }); + }); + + afterEach(() => { + engine.close(); + }); + + it("cleans up inactive clients after TTL", async () => { + const clientId = "test-cleanup"; + const timestamp = Date.now(); + + engine.update(clientId, { + latitude: 40.7128, + longitude: -74.0060, + speed: 10, + heading: 0, + timestamp: new Date(timestamp).toISOString(), + }); + + expect(engine.filters.has(clientId)).toBe(true); + + await new Promise(resolve => setTimeout(resolve, 150)); + engine.cleanup(); + + expect(engine.filters.has(clientId)).toBe(false); + }); +}); + +describe("PredictiveEngine - Disabled", () => { + let engine; + let mockGeofenceEngine; + let mockRoomManager; + + beforeEach(() => { + mockGeofenceEngine = createMockGeofenceEngine(); + mockRoomManager = createMockRoomManager(); + engine = new PredictiveEngine({ geofenceEngine: mockGeofenceEngine, roomManager: mockRoomManager, config: { ...DEFAULT_CONFIG, PREDICTOR_ENABLE: false } }); + }); + + afterEach(() => { + engine.close(); + }); + + it("returns empty results when disabled", () => { + const result = engine.update("client-1", { latitude: 40.7128, longitude: -74.0060, speed: 10, heading: 0, timestamp: new Date().toISOString() }); + expect(result.anomalies).toEqual([]); + expect(engine.getTrajectory("client-1")).toEqual([]); + expect(engine.getETA("client-1", 40.7128, -74.0060).etaMean).toBeNull(); + expect(engine.checkPreAlerts("client-1")).toEqual([]); + }); +}); \ No newline at end of file