@ingissa/navcore-simulator
v1.0.4
Published
Headless GPS simulator for NavCore. Works in Node.js, React Native, and CI.
Readme
@ingissa/navcore-simulator
Headless GPS simulation engine for the NavCore ecosystem. Works in Node.js (CI), React Native, and any JavaScript environment.
Contents
- Installation
- Quick Start
- Core Classes
- React Native
- Noise Profiles
- Scenario Types
- GPX Round-Trip Loop
- CI / Regression Testing
- Migration from useGpsSimulator
Installation
# Inside the go4exams monorepo — already available via workspace
# Import directly from the source tree:
import { SimulatorEngine, NoiseEngine } from '@ingissa/navcore-simulator'Peer dependencies: @ingissa/navcore-core (already in monorepo). React Native hooks additionally require react and react-native, and are exported from @ingissa/navcore-simulator/react-native so Node.js CI never loads React Native runtime files.
Quick Start
import { SimulatorEngine, NoiseEngine, ScenarioInjector } from '@ingissa/navcore-simulator';
const engine = new SimulatorEngine({
routeGeometry: myRoute, // Coordinate[] from @ingissa/navcore-core
speedKmh: 50,
speedMultiplier: 4, // 4× real-time
noiseProfile: NoiseEngine.STANDARD,
loop: false,
onPositionUpdate: (pos) => {
console.log(pos.coord, pos.bearing, pos.speed);
},
onRouteComplete: () => console.log('Route complete'),
});
engine.addScenario(ScenarioInjector.gpsBlackout(42, 5000));
engine.startRecording();
engine.start();Core Classes
SimulatorEngine
The main orchestrator. Headless — no React, no Expo APIs. Runs in Node.js CI.
const engine = new SimulatorEngine(config: SimulatorConfig);
// Playback
engine.start()
engine.stop()
engine.pause()
engine.resume()
engine.seek(geometryIndex: number) // jump to any route index, recalculates bearing
engine.setSpeed(1 | 2 | 4 | 8)
// Scenarios
engine.addScenario(scenario: Scenario)
engine.removeScenario(name: string)
engine.clearScenarios()
// State
engine.state // 'idle' | 'running' | 'paused' | 'complete'
engine.position // SimPosition | null
engine.progress // 0–1
// Recording
engine.startRecording(sessionId?: string)
engine.stopRecording(): SimSession
// Cleanup
engine.destroy() // clears all timers — no leaksTimer internals: setInterval at 1000ms / speedMultiplier. Always cleaned up on destroy() and on natural completion.
Callback safety: All callbacks (onPositionUpdate, onRouteComplete, onScenarioStart, onScenarioEnd) are wrapped in try/catch — a throwing callback never crashes the engine.
RouteWalker
Manages geometry traversal. Extracted from the engine so it's independently testable.
const walker = new RouteWalker(geometry: Coordinate[], startIndex?: number);
walker.advance(steps: number): WalkerPosition // fractional steps supported
walker.seek(index: number): WalkerPosition
walker.interpolate(index: number, fraction: number): Coordinate
walker.bearingAt(index: number): number // forward-looking bearing
walker.totalDistanceM // total route length in metres
walker.totalPoints // geometry.length
walker.isComplete // true when at last point
walker.currentIndex // current floating-point indexNoiseEngine
GPS noise simulation using the Box-Muller transform. Zero external dependencies.
const engine = new NoiseEngine(profile: NoiseProfile);
engine.apply(coord: Coordinate): Coordinate // returns noisy coord (never mutates input)
engine.setProfile(profile: NoiseProfile) // hot-swap mid-simulation
// Static presets
NoiseEngine.NONE // stdDevM: 0 — exact coordinates
NoiseEngine.CLEAN // stdDevM: 3 — good urban GPS
NoiseEngine.STANDARD // stdDevM: 12 — matches the original Go4Exams ±0.00011°
NoiseEngine.URBAN // stdDevM: 25 — city canyon
NoiseEngine.DEGRADED // stdDevM: 50 — bad conditionsSeeded mode: Add seed: number to any profile for deterministic output — same seed + same route = identical noise trace (critical for regression tests).
const deterministicProfile = { ...NoiseEngine.STANDARD, seed: 42 };ScenarioInjector
Registers scenarios to fire at geometry indices. Called internally by SimulatorEngine on every tick.
const injector = new ScenarioInjector(onStart?, onEnd?);
injector.register(scenario: Scenario)
injector.remove(name: string)
injector.clear()
injector.tick(currentIndex: number): string | undefined // returns active scenario name
// Static factories
ScenarioInjector.gpsBlackout(atIndex, durationMs)
ScenarioInjector.wrongSideRoad(atIndex, offsetM)
ScenarioInjector.uTurn(atIndex)
ScenarioInjector.speedSpike(atIndex, spikeKmh, durationMs?)
ScenarioInjector.accuracyDrop(atIndex, newAccuracyM, durationMs)No double-fire: Each scenario fires exactly once per simulation run, even if tick() is called with the same index multiple times.
GpxParser
Zero-dependency GPX 1.1 parser. Pure string/regex — no DOMParser, no xml2js.
// Parse GPX string
const result = GpxParser.parse(gpxString: string): GpxParseResult
// Load from file (Node.js only)
const result = GpxParser.fromFile('/path/to/route.gpx')
// Load from base64 (React Native file picker)
const result = GpxParser.fromBase64(base64String)interface GpxParseResult {
coordinates: Coordinate[]; // [lng, lat] — NavCore convention
timestamps: number[]; // ms since epoch per point
metadata: {
name?: string;
totalPoints: number;
durationMs: number;
distanceM: number;
avgSpeedKmh: number;
};
}Supports both <trkpt> (track points) and <wpt> (waypoints).
SessionRecorder
Records and exports simulation sessions.
const recorder = new SessionRecorder();
recorder.start(sessionId?: string)
recorder.stop(existing?: SimSession): SimSession
// Export formats
recorder.export('json') // full SimSession as JSON
recorder.export('gpx') // standard GPX 1.1 — re-importable by GpxParser
recorder.export('csv') // timestamp,lng,lat,bearing,speed,accuracyThe GPX export is critical: a recorded session → GPX → GpxParser → SimulatorEngine creates a full regression test loop.
FleetSimulator (Phase 2)
const fleet = FleetSimulator.spawn(5, {
routeGeometry: myRoute,
speedKmh: 50,
noiseProfile: NoiseEngine.STANDARD,
profiles: [
{ label: 'urban', noiseProfile: NoiseEngine.URBAN },
{ label: 'clean', noiseProfile: NoiseEngine.CLEAN },
],
});
fleet.start();
// ... wait for completion
const report = fleet.report();
// report.comparison.avgScoreByProfile
// report.comparison.deviationsBySegment (heatmap data)@experimental — fully typed but not tested in Phase 1.
React Native
useSimulator
Drop-in replacement for useGpsSimulator:
import {
NoiseEngine,
ScenarioInjector,
useSimulator,
} from '@ingissa/navcore-simulator/react-native';
// BEFORE
const simGps = useGpsSimulator(route, isNavigating, speedMultiplier);
// AFTER
const { position, controls, session, isRecording } = useSimulator({
routeGeometry: route,
active: isNavigating,
config: {
speedKmh: 50,
speedMultiplier: 1,
noiseProfile: NoiseEngine.STANDARD,
loop: false,
},
scenarios: [
ScenarioInjector.gpsBlackout(42, 5000),
ScenarioInjector.wrongSideRoad(78, 18),
],
onScenarioFired: (s) => console.log('Scenario:', s.name),
});
// position has the exact same shape as the old SimPosition
// controls: { pause, resume, seek, setSpeed }
// session: { start, stop } — for recordingThe engine is recreated only when routeGeometry changes (identity comparison). Destroyed automatically on unmount.
SimulatorControls
Self-contained control panel. Drop it into any screen:
import { SimulatorControls } from '@ingissa/navcore-simulator/react-native';
<SimulatorControls
simulator={controls}
session={session}
style="minimal" // 'minimal' | 'full' | 'floating'
showScenarioLog={true}
showSessionRecorder={true}
currentSpeed={1}
engineState="running"
isRecording={isRecording}
onExportSession={(s) => shareFile(JSON.stringify(s))}
/>| Style | Description |
|---|---|
| minimal | Horizontal bar: speed selector (1×/2×/4×/8×) + pause/resume |
| full | Card: speed, pause/resume, scenario log, session recorder |
| floating | Collapsible bottom sheet — does not obscure the map |
Uses Klaroways colour tokens (#081525, #00BF76, etc.) to match NavCoreTestScreen.
Noise Profiles
| Profile | stdDevM | Use case |
|---|---|---|
| NONE | 0 | Exact coordinates — CI tests |
| CLEAN | 3 m | Good open-sky GPS |
| STANDARD | 12 m | Matches original Go4Exams ±0.00011° |
| URBAN | 25 m | Dense city canyon |
| DEGRADED | 50 m | Tunnel exit / bad conditions |
| CUSTOM | any | Provide customFn(index) → [lngOff, latOff] |
Scenario Types
| Type | Factory | Effect |
|---|---|---|
| GPS_BLACKOUT | gpsBlackout(idx, ms) | Pauses noise application (dead reckoning stress test) |
| WRONG_SIDE_ROAD | wrongSideRoad(idx, offsetM) | Position offset perpendicular to route |
| UTURN | uTurn(idx) | Fires at index — engine handles turn logic |
| SPEED_SPIKE | speedSpike(idx, kmh) | Temporarily reports higher speed |
| ACCURACY_DROP | accuracyDrop(idx, m, ms) | GPS accuracy degrades to m metres for ms |
| CUSTOM | — | Provide params manually |
GPX Round-Trip Loop
real GPS trace
↓
.gpx file
↓
GpxParser.parse()
↓
SimulatorEngine (replay with noise + scenarios)
↓
SessionRecorder.export('gpx')
↓
GpxParser.parse() ←── CI assertion: reparsed.coordinates.length === session.positions.length
↓
Next regression runThis loop is the foundation of the CI strategy. Every format change to SessionRecorder._toGpx() that breaks GpxParser.parse() will fail the integration test immediately.
CI / Regression Testing
cd packages/navcore-sdk
npx vitest run --reporter=verboseTest files:
packages/simulator/src/core/__tests__/RouteWalker.test.tspackages/simulator/src/core/__tests__/NoiseEngine.test.tspackages/simulator/src/core/__tests__/SimulatorEngine.test.tspackages/simulator/src/scenarios/__tests__/ScenarioInjector.test.tspackages/simulator/src/replay/__tests__/GpxParser.test.tspackages/simulator/src/__tests__/integration/simulator-pipeline.test.ts
All tests use vi.useFakeTimers() — no real waiting, no flakiness.
Migration from useGpsSimulator
The migration is a search-and-replace, not a rewrite.
1. Remove the inline hook and local type
Delete useGpsSimulator function (lines 114–174 in NavCoreTestScreen) and the local SimPosition interface.
2. Add import
import {
useSimulator,
SimulatorControls,
NoiseEngine,
ScenarioInjector,
} from '../../../../packages/navcore-sdk/packages/simulator/src';3. Replace the hook call
// Before
const simGps = useGpsSimulator(route, isNavigating, speed);
// After
const { position: simGps, controls } = useSimulator({
routeGeometry: route,
active: isNavigating,
config: { speedKmh: 50, noiseProfile: NoiseEngine.STANDARD },
});4. Replace the speed UI
// Before: 40 lines of inline speedRow JSX + speed state
// After:
<SimulatorControls simulator={controls} style="minimal" />The position object shape is identical to the original SimPosition — coord, bearing, speed, accuracy are all present.
