@ingissa/navcore-simulator 1.0.3 → 1.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +3 -0
- package/dist/core/src/NavCore.d.ts +142 -0
- package/dist/core/src/NavCore.d.ts.map +1 -0
- package/dist/core/src/NavCore.js +390 -0
- package/dist/core/src/bearing/BearingEngine.d.ts +24 -0
- package/dist/core/src/bearing/BearingEngine.d.ts.map +1 -0
- package/dist/core/src/bearing/BearingEngine.js +50 -0
- package/dist/core/src/constants.d.ts +34 -0
- package/dist/core/src/constants.d.ts.map +1 -0
- package/dist/core/src/constants.js +41 -0
- package/dist/core/src/corridor/DeviationDetector.d.ts +33 -0
- package/dist/core/src/corridor/DeviationDetector.d.ts.map +1 -0
- package/dist/core/src/corridor/DeviationDetector.js +83 -0
- package/dist/core/src/corridor/DynamicCorridor.d.ts +13 -0
- package/dist/core/src/corridor/DynamicCorridor.d.ts.map +1 -0
- package/dist/core/src/corridor/DynamicCorridor.js +26 -0
- package/dist/core/src/dead-reckoning/DeadReckoning.d.ts +35 -0
- package/dist/core/src/dead-reckoning/DeadReckoning.d.ts.map +1 -0
- package/dist/core/src/dead-reckoning/DeadReckoning.js +49 -0
- package/dist/core/src/directions/DirectionsProvider.d.ts +70 -0
- package/dist/core/src/directions/DirectionsProvider.d.ts.map +1 -0
- package/dist/core/src/directions/DirectionsProvider.js +7 -0
- package/dist/core/src/directions/OSRMDirectionsProvider.d.ts +26 -0
- package/dist/core/src/directions/OSRMDirectionsProvider.d.ts.map +1 -0
- package/dist/core/src/directions/OSRMDirectionsProvider.js +90 -0
- package/dist/core/src/directions/OpenRouteServiceProvider.d.ts +30 -0
- package/dist/core/src/directions/OpenRouteServiceProvider.d.ts.map +1 -0
- package/dist/core/src/directions/OpenRouteServiceProvider.js +111 -0
- package/dist/core/src/directions/ValhallaDirectionsProvider.d.ts +33 -0
- package/dist/core/src/directions/ValhallaDirectionsProvider.d.ts.map +1 -0
- package/dist/core/src/directions/ValhallaDirectionsProvider.js +155 -0
- package/dist/core/src/eta/ETAEngine.d.ts +61 -0
- package/dist/core/src/eta/ETAEngine.d.ts.map +1 -0
- package/dist/core/src/eta/ETAEngine.js +74 -0
- package/dist/core/src/features.d.ts +76 -0
- package/dist/core/src/features.d.ts.map +1 -0
- package/dist/core/src/features.js +76 -0
- package/dist/core/src/geo/bearing.d.ts +11 -0
- package/dist/core/src/geo/bearing.d.ts.map +1 -0
- package/dist/core/src/geo/bearing.js +25 -0
- package/dist/core/src/geo/cumulative.d.ts +9 -0
- package/dist/core/src/geo/cumulative.d.ts.map +1 -0
- package/dist/core/src/geo/cumulative.js +20 -0
- package/dist/core/src/geo/haversine.d.ts +13 -0
- package/dist/core/src/geo/haversine.d.ts.map +1 -0
- package/dist/core/src/geo/haversine.js +39 -0
- package/dist/core/src/geo/projection.d.ts +7 -0
- package/dist/core/src/geo/projection.d.ts.map +1 -0
- package/dist/core/src/geo/projection.js +17 -0
- package/dist/core/src/geofencing/GeofencingEngine.d.ts +93 -0
- package/dist/core/src/geofencing/GeofencingEngine.d.ts.map +1 -0
- package/dist/core/src/geofencing/GeofencingEngine.js +154 -0
- package/dist/core/src/index.d.ts +66 -0
- package/dist/core/src/index.d.ts.map +1 -0
- package/dist/core/src/index.js +62 -0
- package/dist/core/src/kalman/KalmanFilter2D.d.ts +46 -0
- package/dist/core/src/kalman/KalmanFilter2D.d.ts.map +1 -0
- package/dist/core/src/kalman/KalmanFilter2D.js +77 -0
- package/dist/core/src/license/LicenseManager.d.ts +40 -0
- package/dist/core/src/license/LicenseManager.d.ts.map +1 -0
- package/dist/core/src/license/LicenseManager.js +119 -0
- package/dist/core/src/license/verifyP256Signature.d.ts +2 -0
- package/dist/core/src/license/verifyP256Signature.d.ts.map +1 -0
- package/dist/core/src/license/verifyP256Signature.js +26 -0
- package/dist/core/src/off-route/NavigationFSM.d.ts +42 -0
- package/dist/core/src/off-route/NavigationFSM.d.ts.map +1 -0
- package/dist/core/src/off-route/NavigationFSM.js +56 -0
- package/dist/core/src/off-route/OffRouteFSM.d.ts +70 -0
- package/dist/core/src/off-route/OffRouteFSM.d.ts.map +1 -0
- package/dist/core/src/off-route/OffRouteFSM.js +151 -0
- package/dist/core/src/predictor/RouteOffsetPredictor.d.ts +24 -0
- package/dist/core/src/predictor/RouteOffsetPredictor.d.ts.map +1 -0
- package/dist/core/src/predictor/RouteOffsetPredictor.js +55 -0
- package/dist/core/src/progress/InstructionEditor.d.ts +77 -0
- package/dist/core/src/progress/InstructionEditor.d.ts.map +1 -0
- package/dist/core/src/progress/InstructionEditor.js +151 -0
- package/dist/core/src/progress/InstructionResolver.d.ts +25 -0
- package/dist/core/src/progress/InstructionResolver.d.ts.map +1 -0
- package/dist/core/src/progress/InstructionResolver.js +78 -0
- package/dist/core/src/progress/ProgressTracker.d.ts +12 -0
- package/dist/core/src/progress/ProgressTracker.d.ts.map +1 -0
- package/dist/core/src/progress/ProgressTracker.js +22 -0
- package/dist/core/src/renderer/MapRendererAdapter.d.ts +72 -0
- package/dist/core/src/renderer/MapRendererAdapter.d.ts.map +1 -0
- package/dist/core/src/renderer/MapRendererAdapter.js +19 -0
- package/dist/core/src/route-builder/CustomRouteBuilder.d.ts +83 -0
- package/dist/core/src/route-builder/CustomRouteBuilder.d.ts.map +1 -0
- package/dist/core/src/route-builder/CustomRouteBuilder.js +160 -0
- package/dist/core/src/route-plan/RoutePlan.d.ts +38 -0
- package/dist/core/src/route-plan/RoutePlan.d.ts.map +1 -0
- package/dist/core/src/route-plan/RoutePlan.js +102 -0
- package/dist/core/src/session/NavigationSession.d.ts +81 -0
- package/dist/core/src/session/NavigationSession.d.ts.map +1 -0
- package/dist/core/src/session/NavigationSession.js +115 -0
- package/dist/core/src/snapper/ParallelRoadResolver.d.ts +86 -0
- package/dist/core/src/snapper/ParallelRoadResolver.d.ts.map +1 -0
- package/dist/core/src/snapper/ParallelRoadResolver.js +83 -0
- package/dist/core/src/snapper/RouteSnapper.d.ts +38 -0
- package/dist/core/src/snapper/RouteSnapper.d.ts.map +1 -0
- package/dist/core/src/snapper/RouteSnapper.js +80 -0
- package/dist/core/src/voice/VoiceTriggerEngine.d.ts +80 -0
- package/dist/core/src/voice/VoiceTriggerEngine.d.ts.map +1 -0
- package/dist/core/src/voice/VoiceTriggerEngine.js +108 -0
- package/dist/{core → simulator/src/core}/NoiseEngine.d.ts +1 -1
- package/dist/simulator/src/core/NoiseEngine.d.ts.map +1 -0
- package/dist/{core → simulator/src/core}/RouteWalker.d.ts +4 -1
- package/dist/simulator/src/core/RouteWalker.d.ts.map +1 -0
- package/dist/{core → simulator/src/core}/RouteWalker.js +3 -0
- package/dist/simulator/src/core/SimulatorEngine.d.ts.map +1 -0
- package/dist/{core → simulator/src/core}/SimulatorEngine.js +23 -8
- package/dist/{core → simulator/src/core}/types.d.ts +30 -2
- package/dist/simulator/src/core/types.d.ts.map +1 -0
- package/dist/simulator/src/fleet/FleetSimulator.d.ts.map +1 -0
- package/dist/{index.d.ts → simulator/src/index.d.ts} +1 -1
- package/dist/simulator/src/index.d.ts.map +1 -0
- package/dist/{node.d.ts → simulator/src/node.d.ts} +1 -1
- package/dist/simulator/src/node.d.ts.map +1 -0
- package/dist/simulator/src/react-native/SimulatorControls.d.ts.map +1 -0
- package/dist/{react-native → simulator/src/react-native}/useSimulator.d.ts +6 -2
- package/dist/simulator/src/react-native/useSimulator.d.ts.map +1 -0
- package/dist/{react-native → simulator/src/react-native}/useSimulator.js +3 -0
- package/dist/{replay → simulator/src/replay}/GpxParser.d.ts +1 -1
- package/dist/simulator/src/replay/GpxParser.d.ts.map +1 -0
- package/dist/simulator/src/replay/SessionRecorder.d.ts.map +1 -0
- package/dist/simulator/src/scenarios/ScenarioInjector.d.ts.map +1 -0
- package/package.json +20 -10
- package/src/__tests__/integration/simulator-pipeline.test.ts +233 -0
- package/src/core/NoiseEngine.ts +137 -0
- package/src/core/RouteWalker.ts +227 -0
- package/src/core/SimulatorEngine.ts +364 -0
- package/src/core/__tests__/NoiseEngine.test.ts +131 -0
- package/src/core/__tests__/RouteWalker.test.ts +139 -0
- package/src/core/__tests__/SimulatorEngine.test.ts +317 -0
- package/src/core/types.ts +191 -0
- package/src/fleet/FleetSimulator.ts +162 -0
- package/src/index.ts +54 -0
- package/src/node.ts +48 -0
- package/src/react-native/SimulatorControls.tsx +473 -0
- package/src/react-native/useSimulator.ts +155 -0
- package/src/replay/GpxParser.ts +122 -0
- package/src/replay/SessionRecorder.ts +138 -0
- package/src/replay/__tests__/GpxParser.test.ts +136 -0
- package/src/scenarios/ScenarioInjector.ts +170 -0
- package/src/scenarios/__tests__/ScenarioInjector.test.ts +171 -0
- package/dist/core/NoiseEngine.d.ts.map +0 -1
- package/dist/core/RouteWalker.d.ts.map +0 -1
- package/dist/core/SimulatorEngine.d.ts.map +0 -1
- package/dist/core/types.d.ts.map +0 -1
- package/dist/fleet/FleetSimulator.d.ts.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/node.d.ts.map +0 -1
- package/dist/react-native/SimulatorControls.d.ts.map +0 -1
- package/dist/react-native/useSimulator.d.ts.map +0 -1
- package/dist/replay/GpxParser.d.ts.map +0 -1
- package/dist/replay/SessionRecorder.d.ts.map +0 -1
- package/dist/scenarios/ScenarioInjector.d.ts.map +0 -1
- /package/dist/{core → simulator/src/core}/NoiseEngine.js +0 -0
- /package/dist/{core → simulator/src/core}/SimulatorEngine.d.ts +0 -0
- /package/dist/{core → simulator/src/core}/types.js +0 -0
- /package/dist/{fleet → simulator/src/fleet}/FleetSimulator.d.ts +0 -0
- /package/dist/{fleet → simulator/src/fleet}/FleetSimulator.js +0 -0
- /package/dist/{index.js → simulator/src/index.js} +0 -0
- /package/dist/{node.js → simulator/src/node.js} +0 -0
- /package/dist/{react-native → simulator/src/react-native}/SimulatorControls.d.ts +0 -0
- /package/dist/{react-native → simulator/src/react-native}/SimulatorControls.js +0 -0
- /package/dist/{replay → simulator/src/replay}/GpxParser.js +0 -0
- /package/dist/{replay → simulator/src/replay}/SessionRecorder.d.ts +0 -0
- /package/dist/{replay → simulator/src/replay}/SessionRecorder.js +0 -0
- /package/dist/{scenarios → simulator/src/scenarios}/ScenarioInjector.d.ts +0 -0
- /package/dist/{scenarios → simulator/src/scenarios}/ScenarioInjector.js +0 -0
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SimulatorEngine — The headless GPS simulation core.
|
|
3
|
+
*
|
|
4
|
+
* Works in Node.js for CI tests. NO React, NO Expo APIs.
|
|
5
|
+
* All timers are cleaned up on destroy(). No memory leaks.
|
|
6
|
+
*
|
|
7
|
+
* State machine: idle → running ⇄ paused → complete
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { NoiseEngine } from './NoiseEngine';
|
|
11
|
+
import { RouteWalker } from './RouteWalker';
|
|
12
|
+
import { ScenarioInjector } from '../scenarios/ScenarioInjector';
|
|
13
|
+
import type { SimPosition, SimulatorConfig, SimSession, Scenario } from './types';
|
|
14
|
+
|
|
15
|
+
/** Generate a UUID v4 without external dependencies */
|
|
16
|
+
function uuidV4(): string {
|
|
17
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
18
|
+
const r = (Math.random() * 16) | 0;
|
|
19
|
+
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
|
20
|
+
return v.toString(16);
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type EngineState = 'idle' | 'running' | 'paused' | 'complete';
|
|
25
|
+
|
|
26
|
+
export class SimulatorEngine {
|
|
27
|
+
// ── Config ──────────────────────────────────────────────────────────────────
|
|
28
|
+
private readonly config: Required<
|
|
29
|
+
Omit<SimulatorConfig, 'onPositionUpdate' | 'onRouteComplete' | 'onScenarioStart' | 'onScenarioEnd'>
|
|
30
|
+
> & Pick<SimulatorConfig, 'onPositionUpdate' | 'onRouteComplete' | 'onScenarioStart' | 'onScenarioEnd'>;
|
|
31
|
+
|
|
32
|
+
// ── Internal components ──────────────────────────────────────────────────────
|
|
33
|
+
private walker: RouteWalker;
|
|
34
|
+
private noise: NoiseEngine;
|
|
35
|
+
private injector: ScenarioInjector;
|
|
36
|
+
|
|
37
|
+
// ── State ────────────────────────────────────────────────────────────────────
|
|
38
|
+
private _state: EngineState = 'idle';
|
|
39
|
+
private _position: SimPosition | null = null;
|
|
40
|
+
private _speedMultiplier: 1 | 2 | 4 | 8;
|
|
41
|
+
private _timer: ReturnType<typeof setInterval> | null = null;
|
|
42
|
+
private _lastTick: number = 0;
|
|
43
|
+
|
|
44
|
+
private static readonly TICK_MS = 50; // 20 FPS for smooth movement
|
|
45
|
+
|
|
46
|
+
// ── Recording ────────────────────────────────────────────────────────────────
|
|
47
|
+
private _recording = false;
|
|
48
|
+
private _recordedPositions: SimPosition[] = [];
|
|
49
|
+
private _recordedScenarios: Array<{ scenario: Scenario; firedAt: number }> = [];
|
|
50
|
+
private _sessionId: string | null = null;
|
|
51
|
+
private _sessionStart: number | null = null;
|
|
52
|
+
private _noiseSamplesAdded = 0;
|
|
53
|
+
|
|
54
|
+
constructor(config: SimulatorConfig) {
|
|
55
|
+
const geometry = config.routeGeometry;
|
|
56
|
+
if (!geometry || geometry.length < 2) {
|
|
57
|
+
throw new Error('SimulatorEngine: routeGeometry must have at least 2 points');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
this._speedMultiplier = config.speedMultiplier ?? 1;
|
|
61
|
+
|
|
62
|
+
this.config = {
|
|
63
|
+
routeGeometry: geometry,
|
|
64
|
+
speedKmh: config.speedKmh ?? 50,
|
|
65
|
+
speedMultiplier: this._speedMultiplier,
|
|
66
|
+
noiseProfile: config.noiseProfile ?? NoiseEngine.STANDARD,
|
|
67
|
+
loop: config.loop ?? false,
|
|
68
|
+
startIndex: config.startIndex ?? 0,
|
|
69
|
+
autoStart: config.autoStart ?? false,
|
|
70
|
+
onPositionUpdate: config.onPositionUpdate,
|
|
71
|
+
onRouteComplete: config.onRouteComplete,
|
|
72
|
+
onScenarioStart: config.onScenarioStart,
|
|
73
|
+
onScenarioEnd: config.onScenarioEnd,
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
this.walker = new RouteWalker(geometry, this.config.startIndex);
|
|
77
|
+
this.noise = new NoiseEngine(this.config.noiseProfile);
|
|
78
|
+
|
|
79
|
+
this.injector = new ScenarioInjector(
|
|
80
|
+
(s) => {
|
|
81
|
+
if (this._recording) {
|
|
82
|
+
this._recordedScenarios.push({ scenario: s, firedAt: Date.now() });
|
|
83
|
+
}
|
|
84
|
+
try { this.config.onScenarioStart?.(s); } catch (_) { /* never throw */ }
|
|
85
|
+
},
|
|
86
|
+
(s) => {
|
|
87
|
+
try { this.config.onScenarioEnd?.(s); } catch (_) { /* never throw */ }
|
|
88
|
+
},
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
if (this.config.autoStart) {
|
|
92
|
+
this.start();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── Playback controls ────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
start(): void {
|
|
99
|
+
if (this._state === 'running') return;
|
|
100
|
+
if (this._state === 'complete') return;
|
|
101
|
+
this._state = 'running';
|
|
102
|
+
this._startTimer();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
stop(): void {
|
|
106
|
+
this._stopTimer();
|
|
107
|
+
this._state = 'idle';
|
|
108
|
+
this._position = null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
pause(): void {
|
|
112
|
+
if (this._state !== 'running') return;
|
|
113
|
+
this._stopTimer();
|
|
114
|
+
this._state = 'paused';
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
resume(): void {
|
|
118
|
+
if (this._state !== 'paused') return;
|
|
119
|
+
this._state = 'running';
|
|
120
|
+
this._startTimer();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Jump to any geometry index.
|
|
125
|
+
* Bearing is recalculated from the new segment — seek() is always bearing-safe.
|
|
126
|
+
*/
|
|
127
|
+
seek(geometryIndex: number): void {
|
|
128
|
+
const wasRunning = this._state === 'running';
|
|
129
|
+
if (wasRunning) this._stopTimer();
|
|
130
|
+
|
|
131
|
+
const pos = this.walker.seek(geometryIndex);
|
|
132
|
+
this._buildAndEmitPosition(pos.coord, pos.bearing, pos.distanceTravelled, false);
|
|
133
|
+
|
|
134
|
+
// Reset the completion flag so simulation can continue from the new index
|
|
135
|
+
if (this._state === 'complete' && !pos.isComplete) {
|
|
136
|
+
this._state = 'idle';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (wasRunning && !pos.isComplete) {
|
|
140
|
+
this._state = 'running';
|
|
141
|
+
this._startTimer();
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
setSpeed(multiplier: 1 | 2 | 4 | 8): void {
|
|
146
|
+
this._speedMultiplier = multiplier;
|
|
147
|
+
if (this._state === 'running') {
|
|
148
|
+
this._stopTimer();
|
|
149
|
+
this._startTimer();
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── Scenario management ──────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
addScenario(scenario: Scenario): void {
|
|
156
|
+
this.injector.register(scenario);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
removeScenario(name: string): void {
|
|
160
|
+
this.injector.remove(name);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
clearScenarios(): void {
|
|
164
|
+
this.injector.clear();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── State accessors ──────────────────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
get state(): EngineState {
|
|
170
|
+
return this._state;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
get position(): SimPosition | null {
|
|
174
|
+
return this._position;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
get progress(): number {
|
|
178
|
+
if (this.walker.totalPoints <= 1) return 0;
|
|
179
|
+
return Math.min(1, this.walker.currentIndex / (this.walker.totalPoints - 1));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ── Session recording ────────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
startRecording(sessionId?: string): void {
|
|
185
|
+
this._recording = true;
|
|
186
|
+
this._recordedPositions = [];
|
|
187
|
+
this._recordedScenarios = [];
|
|
188
|
+
this._sessionId = sessionId ?? uuidV4();
|
|
189
|
+
this._sessionStart = Date.now();
|
|
190
|
+
this._noiseSamplesAdded = 0;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
stopRecording(): SimSession {
|
|
194
|
+
this._recording = false;
|
|
195
|
+
const endedAt = Date.now();
|
|
196
|
+
const startedAt = this._sessionStart ?? endedAt;
|
|
197
|
+
|
|
198
|
+
const positions = this._recordedPositions;
|
|
199
|
+
const durationMs = endedAt - startedAt;
|
|
200
|
+
|
|
201
|
+
let distanceTravelledM = 0;
|
|
202
|
+
if (positions.length > 0) {
|
|
203
|
+
distanceTravelledM = positions[positions.length - 1]?.metadata?.distanceTravelled ?? 0;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const avgSpeedKmh =
|
|
207
|
+
durationMs > 0
|
|
208
|
+
? ((distanceTravelledM / (durationMs / 1000)) * 3.6)
|
|
209
|
+
: 0;
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
id: this._sessionId ?? uuidV4(),
|
|
213
|
+
startedAt: new Date(startedAt).toISOString(),
|
|
214
|
+
endedAt: new Date(endedAt).toISOString(),
|
|
215
|
+
route: this.config.routeGeometry,
|
|
216
|
+
positions,
|
|
217
|
+
scenarios: this._recordedScenarios,
|
|
218
|
+
stats: {
|
|
219
|
+
durationMs,
|
|
220
|
+
distanceTravelledM,
|
|
221
|
+
avgSpeedKmh,
|
|
222
|
+
noiseSamplesAdded: this._noiseSamplesAdded,
|
|
223
|
+
scenariosFired: this._recordedScenarios.length,
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ── Destroy ──────────────────────────────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
destroy(): void {
|
|
231
|
+
this._stopTimer();
|
|
232
|
+
this.injector.destroy();
|
|
233
|
+
this._state = 'idle';
|
|
234
|
+
this._position = null;
|
|
235
|
+
this._recording = false;
|
|
236
|
+
this._recordedPositions = [];
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ── Private ──────────────────────────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
private _startTimer(): void {
|
|
242
|
+
this._lastTick = Date.now();
|
|
243
|
+
const intervalMs = Math.round(1000 / this._speedMultiplier);
|
|
244
|
+
this._timer = setInterval(() => this._tick(), intervalMs);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private _stopTimer(): void {
|
|
248
|
+
if (this._timer !== null) {
|
|
249
|
+
clearInterval(this._timer);
|
|
250
|
+
this._timer = null;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private _tick(): void {
|
|
255
|
+
if (this._state !== 'running') return;
|
|
256
|
+
|
|
257
|
+
const now = Date.now();
|
|
258
|
+
this._lastTick = now;
|
|
259
|
+
|
|
260
|
+
const speedMs = (this.config.speedKmh / 3.6);
|
|
261
|
+
const walkerPos = this.walker.advance(1);
|
|
262
|
+
|
|
263
|
+
// Fire any scenarios at this index
|
|
264
|
+
const activeScenarioName = this.injector.tick(walkerPos.index);
|
|
265
|
+
|
|
266
|
+
// Apply noise (skip if GPS_BLACKOUT scenario is active)
|
|
267
|
+
const activeScenario = this.injector.activeScenario;
|
|
268
|
+
let coord = walkerPos.coord;
|
|
269
|
+
let isNoisy = false;
|
|
270
|
+
|
|
271
|
+
if (activeScenario?.type !== 'GPS_BLACKOUT') {
|
|
272
|
+
coord = this.noise.apply(walkerPos.coord);
|
|
273
|
+
isNoisy = this.noise.currentProfile.stdDevM > 0;
|
|
274
|
+
if (isNoisy) this._noiseSamplesAdded++;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Compute speed — apply speed spike if active
|
|
278
|
+
//let speedMs = (this.config.speedKmh / 3.6);
|
|
279
|
+
if (activeScenario?.type === 'SPEED_SPIKE') {
|
|
280
|
+
const spikeKmh = (activeScenario.params?.['spikeKmh'] as number | undefined) ?? this.config.speedKmh;
|
|
281
|
+
//speedMs = spikeKmh / 3.6;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Compute accuracy — degrade if ACCURACY_DROP scenario is active
|
|
285
|
+
let accuracy = 8 + Math.random() * 6;
|
|
286
|
+
if (activeScenario?.type === 'ACCURACY_DROP') {
|
|
287
|
+
accuracy = (activeScenario.params?.['newAccuracyM'] as number | undefined) ?? accuracy;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
this._buildAndEmitPosition(coord, walkerPos.bearing, walkerPos.distanceTravelled, isNoisy, {
|
|
291
|
+
speedMs,
|
|
292
|
+
accuracy,
|
|
293
|
+
activeScenarioName,
|
|
294
|
+
segmentIndex: Math.floor(walkerPos.index),
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
if (walkerPos.isComplete) {
|
|
298
|
+
this._stopTimer();
|
|
299
|
+
|
|
300
|
+
if (this.config.loop) {
|
|
301
|
+
// Restart from the beginning
|
|
302
|
+
this.walker = new RouteWalker(this.config.routeGeometry, 0);
|
|
303
|
+
this._startTimer();
|
|
304
|
+
} else {
|
|
305
|
+
this._state = 'complete';
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
try { this.config.onRouteComplete?.(); } catch (_) { /* never throw */ }
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private _buildAndEmitPosition(
|
|
313
|
+
coord: [number, number],
|
|
314
|
+
bearing: number,
|
|
315
|
+
distanceTravelled: number,
|
|
316
|
+
isNoisy: boolean,
|
|
317
|
+
opts?: {
|
|
318
|
+
speedMs?: number;
|
|
319
|
+
accuracy?: number;
|
|
320
|
+
activeScenarioName?: string;
|
|
321
|
+
segmentIndex?: number;
|
|
322
|
+
},
|
|
323
|
+
): void {
|
|
324
|
+
const speedMs = opts?.speedMs ?? (this.config.speedKmh / 3.6);
|
|
325
|
+
const accuracy = opts?.accuracy ?? 8;
|
|
326
|
+
const ts = Date.now();
|
|
327
|
+
const locationCoords = {
|
|
328
|
+
latitude: coord[1],
|
|
329
|
+
longitude: coord[0],
|
|
330
|
+
altitude: 0,
|
|
331
|
+
accuracy,
|
|
332
|
+
altitudeAccuracy: null,
|
|
333
|
+
heading: bearing,
|
|
334
|
+
speed: speedMs,
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
const pos: SimPosition = {
|
|
338
|
+
coord: coord as [number, number],
|
|
339
|
+
bearing,
|
|
340
|
+
speed: speedMs,
|
|
341
|
+
accuracy,
|
|
342
|
+
timestamp: ts,
|
|
343
|
+
coords: locationCoords,
|
|
344
|
+
location: {
|
|
345
|
+
coords: locationCoords,
|
|
346
|
+
timestamp: ts,
|
|
347
|
+
},
|
|
348
|
+
metadata: {
|
|
349
|
+
segmentIndex: opts?.segmentIndex ?? Math.floor(this.walker.currentIndex),
|
|
350
|
+
distanceTravelled,
|
|
351
|
+
isNoisy,
|
|
352
|
+
scenarioActive: opts?.activeScenarioName,
|
|
353
|
+
},
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
this._position = pos;
|
|
357
|
+
|
|
358
|
+
if (this._recording) {
|
|
359
|
+
this._recordedPositions.push(pos);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
try { this.config.onPositionUpdate?.(pos); } catch (_) { /* never throw */ }
|
|
363
|
+
}
|
|
364
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NoiseEngine unit tests
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect } from 'vitest';
|
|
5
|
+
import { NoiseEngine } from '@ingissa/navcore-simulator';
|
|
6
|
+
import type { Coordinate } from '@ingissa/navcore-core';
|
|
7
|
+
|
|
8
|
+
const ORIGIN: Coordinate = [0.0, 0.0];
|
|
9
|
+
const RUNS = 500; // statistical test runs
|
|
10
|
+
|
|
11
|
+
/** Sample standard deviation of an array */
|
|
12
|
+
function stdDev(values: number[]): number {
|
|
13
|
+
const mean = values.reduce((a, b) => a + b, 0) / values.length;
|
|
14
|
+
const variance = values.reduce((sum, v) => sum + (v - mean) ** 2, 0) / values.length;
|
|
15
|
+
return Math.sqrt(variance);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('NoiseEngine', () => {
|
|
19
|
+
describe('NONE profile', () => {
|
|
20
|
+
it('returns the exact same coordinate', () => {
|
|
21
|
+
const engine = new NoiseEngine(NoiseEngine.NONE);
|
|
22
|
+
const result = engine.apply(ORIGIN);
|
|
23
|
+
expect(result[0]).toBe(ORIGIN[0]);
|
|
24
|
+
expect(result[1]).toBe(ORIGIN[1]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('never mutates the input coordinate', () => {
|
|
28
|
+
const engine = new NoiseEngine(NoiseEngine.NONE);
|
|
29
|
+
const coord: Coordinate = [1.234, 5.678];
|
|
30
|
+
engine.apply(coord);
|
|
31
|
+
expect(coord[0]).toBe(1.234);
|
|
32
|
+
expect(coord[1]).toBe(5.678);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('STANDARD profile (12m stddev)', () => {
|
|
37
|
+
it('applies noise that shifts the coordinate', () => {
|
|
38
|
+
const engine = new NoiseEngine(NoiseEngine.STANDARD);
|
|
39
|
+
const result = engine.apply(ORIGIN);
|
|
40
|
+
// There's a tiny probability of hitting exactly 0 — this test is still valid
|
|
41
|
+
const changed = result[0] !== ORIGIN[0] || result[1] !== ORIGIN[1];
|
|
42
|
+
expect(changed).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('stddev of longitude offsets is within expected range (~12m ± 50%)', () => {
|
|
46
|
+
const engine = new NoiseEngine(NoiseEngine.STANDARD);
|
|
47
|
+
// 1° longitude ≈ 111320m
|
|
48
|
+
const lngOffsets: number[] = [];
|
|
49
|
+
for (let i = 0; i < RUNS; i++) {
|
|
50
|
+
const r = engine.apply(ORIGIN);
|
|
51
|
+
lngOffsets.push((r[0] - ORIGIN[0]) * 111320);
|
|
52
|
+
}
|
|
53
|
+
const sd = stdDev(lngOffsets);
|
|
54
|
+
// Should be roughly 12m (allow 50% tolerance for statistical variation)
|
|
55
|
+
expect(sd).toBeGreaterThan(6);
|
|
56
|
+
expect(sd).toBeLessThan(24);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('Seed determinism', () => {
|
|
61
|
+
it('same seed produces identical sequence', () => {
|
|
62
|
+
const profile = { ...NoiseEngine.STANDARD, seed: 42 };
|
|
63
|
+
const engineA = new NoiseEngine(profile);
|
|
64
|
+
const engineB = new NoiseEngine(profile);
|
|
65
|
+
|
|
66
|
+
for (let i = 0; i < 20; i++) {
|
|
67
|
+
const a = engineA.apply(ORIGIN);
|
|
68
|
+
const b = engineB.apply(ORIGIN);
|
|
69
|
+
expect(a[0]).toBe(b[0]);
|
|
70
|
+
expect(a[1]).toBe(b[1]);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('different seeds produce different sequences', () => {
|
|
75
|
+
const engineA = new NoiseEngine({ ...NoiseEngine.STANDARD, seed: 1 });
|
|
76
|
+
const engineB = new NoiseEngine({ ...NoiseEngine.STANDARD, seed: 2 });
|
|
77
|
+
|
|
78
|
+
const resultsA = Array.from({ length: 10 }, () => engineA.apply(ORIGIN));
|
|
79
|
+
const resultsB = Array.from({ length: 10 }, () => engineB.apply(ORIGIN));
|
|
80
|
+
|
|
81
|
+
// At least one result should differ
|
|
82
|
+
const anyDiff = resultsA.some((a, i) => a[0] !== resultsB[i]![0]);
|
|
83
|
+
expect(anyDiff).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('CUSTOM profile', () => {
|
|
88
|
+
it('delegates to customFn', () => {
|
|
89
|
+
let callCount = 0;
|
|
90
|
+
const engine = new NoiseEngine({
|
|
91
|
+
type: 'CUSTOM',
|
|
92
|
+
stdDevM: 0,
|
|
93
|
+
customFn: (idx) => {
|
|
94
|
+
callCount++;
|
|
95
|
+
return [idx * 0.001, idx * 0.0005];
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const r0 = engine.apply(ORIGIN);
|
|
100
|
+
const r1 = engine.apply(ORIGIN);
|
|
101
|
+
|
|
102
|
+
expect(callCount).toBe(2);
|
|
103
|
+
expect(r0[0]).toBeCloseTo(0 * 0.001, 6);
|
|
104
|
+
expect(r1[0]).toBeCloseTo(1 * 0.001, 6);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('returns original coord if customFn is missing', () => {
|
|
108
|
+
const engine = new NoiseEngine({ type: 'CUSTOM', stdDevM: 0 });
|
|
109
|
+
const result = engine.apply(ORIGIN);
|
|
110
|
+
expect(result[0]).toBe(ORIGIN[0]);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('setProfile', () => {
|
|
115
|
+
it('hot-swaps from STANDARD to NONE mid-simulation', () => {
|
|
116
|
+
const engine = new NoiseEngine(NoiseEngine.STANDARD);
|
|
117
|
+
engine.setProfile(NoiseEngine.NONE);
|
|
118
|
+
const result = engine.apply([1.0, 2.0]);
|
|
119
|
+
expect(result[0]).toBe(1.0);
|
|
120
|
+
expect(result[1]).toBe(2.0);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('Static presets', () => {
|
|
125
|
+
it('CLEAN has stdDevM: 3', () => expect(NoiseEngine.CLEAN.stdDevM).toBe(3));
|
|
126
|
+
it('STANDARD has stdDevM: 12', () => expect(NoiseEngine.STANDARD.stdDevM).toBe(12));
|
|
127
|
+
it('URBAN has stdDevM: 25', () => expect(NoiseEngine.URBAN.stdDevM).toBe(25));
|
|
128
|
+
it('DEGRADED has stdDevM: 50', () => expect(NoiseEngine.DEGRADED.stdDevM).toBe(50));
|
|
129
|
+
it('NONE has stdDevM: 0', () => expect(NoiseEngine.NONE.stdDevM).toBe(0));
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RouteWalker unit tests
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
5
|
+
import { RouteWalker } from '@ingissa/navcore-simulator';
|
|
6
|
+
import type { Coordinate } from '@ingissa/navcore-core';
|
|
7
|
+
|
|
8
|
+
// Simple 5-point straight line going east
|
|
9
|
+
// Each point is ~111m apart (1 second at 400 km/h, but fine for geometry tests)
|
|
10
|
+
const STRAIGHT: Coordinate[] = [
|
|
11
|
+
[0.000, 0.000],
|
|
12
|
+
[0.001, 0.000],
|
|
13
|
+
[0.002, 0.000],
|
|
14
|
+
[0.003, 0.000],
|
|
15
|
+
[0.004, 0.000],
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
describe('RouteWalker', () => {
|
|
19
|
+
let walker: RouteWalker;
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
walker = new RouteWalker(STRAIGHT);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('throws with fewer than 2 points', () => {
|
|
26
|
+
expect(() => new RouteWalker([[0, 0]])).toThrow();
|
|
27
|
+
expect(() => new RouteWalker([])).toThrow();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('initialises at index 0 and is not complete', () => {
|
|
31
|
+
expect(walker.currentIndex).toBe(0);
|
|
32
|
+
expect(walker.isComplete).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('advance(1) moves to index 1', () => {
|
|
36
|
+
const pos = walker.advance(1);
|
|
37
|
+
expect(pos.index).toBe(1);
|
|
38
|
+
expect(walker.currentIndex).toBe(1);
|
|
39
|
+
expect(pos.isComplete).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('advance(N) reaches the last point and marks complete', () => {
|
|
43
|
+
const pos = walker.advance(STRAIGHT.length - 1);
|
|
44
|
+
expect(pos.isComplete).toBe(true);
|
|
45
|
+
expect(walker.isComplete).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('advance beyond end clamps to last point', () => {
|
|
49
|
+
walker.advance(100);
|
|
50
|
+
expect(walker.currentIndex).toBe(STRAIGHT.length - 1);
|
|
51
|
+
expect(walker.isComplete).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('advance does nothing after completion', () => {
|
|
55
|
+
walker.advance(100);
|
|
56
|
+
const pos = walker.advance(1);
|
|
57
|
+
expect(pos.index).toBe(STRAIGHT.length - 1);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('interpolate returns midpoint correctly', () => {
|
|
61
|
+
const mid = walker.interpolate(0, 0.5);
|
|
62
|
+
expect(mid[0]).toBeCloseTo(0.0005, 5);
|
|
63
|
+
expect(mid[1]).toBeCloseTo(0.0, 5);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('interpolate clamps fraction to [0, 1]', () => {
|
|
67
|
+
const start = walker.interpolate(0, -0.5);
|
|
68
|
+
const startExpected = STRAIGHT[0]!;
|
|
69
|
+
expect(start[0]).toBeCloseTo(startExpected[0], 5);
|
|
70
|
+
|
|
71
|
+
const end = walker.interpolate(0, 1.5);
|
|
72
|
+
const endExpected = STRAIGHT[1]!;
|
|
73
|
+
expect(end[0]).toBeCloseTo(endExpected[0], 5);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('bearingAt returns ~90° for eastward segment', () => {
|
|
77
|
+
const bearing = walker.bearingAt(0);
|
|
78
|
+
// Should be roughly east (90°)
|
|
79
|
+
expect(bearing).toBeGreaterThan(85);
|
|
80
|
+
expect(bearing).toBeLessThan(95);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('bearingAt on last index returns bearing of last segment', () => {
|
|
84
|
+
const bearing = walker.bearingAt(STRAIGHT.length - 1);
|
|
85
|
+
expect(bearing).toBeGreaterThan(85);
|
|
86
|
+
expect(bearing).toBeLessThan(95);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('distanceToIndex(0) is 0', () => {
|
|
90
|
+
expect(walker.distanceToIndex(0)).toBe(0);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('distanceToIndex increases monotonically', () => {
|
|
94
|
+
for (let i = 1; i < STRAIGHT.length; i++) {
|
|
95
|
+
expect(walker.distanceToIndex(i)).toBeGreaterThan(walker.distanceToIndex(i - 1));
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('totalDistanceM is positive and equals distanceToIndex(last)', () => {
|
|
100
|
+
const total = walker.totalDistanceM;
|
|
101
|
+
expect(total).toBeGreaterThan(0);
|
|
102
|
+
expect(total).toBeCloseTo(walker.distanceToIndex(STRAIGHT.length - 1), 1);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('totalPoints equals geometry length', () => {
|
|
106
|
+
expect(walker.totalPoints).toBe(STRAIGHT.length);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('seek jumps to the correct index', () => {
|
|
110
|
+
const pos = walker.seek(3);
|
|
111
|
+
expect(pos.index).toBe(3);
|
|
112
|
+
expect(walker.currentIndex).toBe(3);
|
|
113
|
+
expect(pos.isComplete).toBe(false);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('seek recalculates bearing at new position', () => {
|
|
117
|
+
const pos = walker.seek(2);
|
|
118
|
+
// Eastward segment → bearing ~90°
|
|
119
|
+
expect(pos.bearing).toBeGreaterThan(85);
|
|
120
|
+
expect(pos.bearing).toBeLessThan(95);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('seek to last index marks complete', () => {
|
|
124
|
+
const pos = walker.seek(STRAIGHT.length - 1);
|
|
125
|
+
expect(pos.isComplete).toBe(true);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('startIndex constructor param positions correctly', () => {
|
|
129
|
+
const w = new RouteWalker(STRAIGHT, 2);
|
|
130
|
+
expect(w.currentIndex).toBe(2);
|
|
131
|
+
expect(w.distanceToIndex(2)).toBeGreaterThan(0);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('advance with fractional steps interpolates coord', () => {
|
|
135
|
+
const pos = walker.advance(0.5);
|
|
136
|
+
// Should be halfway between point 0 and 1
|
|
137
|
+
expect(pos.coord[0]).toBeCloseTo(0.0005, 4);
|
|
138
|
+
});
|
|
139
|
+
});
|