FERS 0.1.0
The Flexible Extensible Radar Simulator
Loading...
Searching...
No Matches
vita49StreamingStore.ts
Go to the documentation of this file.
1// SPDX-License-Identifier: GPL-2.0-only
2// Copyright (c) 2026-present FERS Contributors (see AUTHORS.md).
3
4import { create } from 'zustand';
5import { persist } from 'zustand/middleware';
6import type { ScenarioData } from './scenarioStore';
7import type {
8 SimulationOutputMetadata,
9 SimulationOutputVita49Metadata,
10 SimulationOutputVita49Timestamp,
11} from './simulationProgressStore';
12
13export type Vita49Timestamp = SimulationOutputVita49Timestamp;
14
15export type Vita49RunState =
16 | 'idle'
17 | 'running'
18 | 'stopping'
19 | 'draining'
20 | 'completed'
21 | 'failed'
22 | 'cancelled';
23
24export type Vita49EpochMode = 'auto' | 'fixed';
25
26export type Vita49RuntimeConfig = {
27 host: string;
28 port: number;
29 fullscale: number;
30 epochMode: Vita49EpochMode;
31 epochUnixNanoseconds: string;
32 maxUdpPayload: number;
33 queueDepth: number;
34 traceEnabled: boolean;
35 packetTraceRingSize: number;
36};
37
38export type Vita49BackendConfig = {
39 host: string;
40 port: number;
41 fullscale: number;
42 epoch_unix_nanoseconds: string | null;
43 max_udp_payload: number;
44 queue_depth: number;
45 trace_enabled: boolean;
46 packet_trace_ring_size: number;
47};
48
49export type Vita49StreamCounter = {
50 receiver_id: number;
51 receiver_name: string;
52 stream_id: number;
53 mode?: string | null;
54 sample_rate: number;
55 reference_frequency: number;
56 packets_emitted: number;
57 context_packets?: number;
58 context_packet_count?: number;
59 samples_emitted: number;
60 packets_dropped: number;
61 samples_dropped: number;
62 over_range_count: number;
63 late_packet_count: number;
64 first_sample_time: number | null;
65 end_sample_time: number | null;
66 first_timestamp: Vita49Timestamp | null;
67 end_timestamp: Vita49Timestamp | null;
68};
69
70export type Vita49StreamStatsEvent = {
71 mode: 'vita49_udp' | 'hdf5';
72 epoch_unix_nanoseconds: string | null;
73 streams: Vita49StreamCounter[];
74};
75
76export type Vita49PacketTraceEvent = {
77 sequence: number;
78 event: 'data' | 'context' | 'drop' | 'failure' | string;
79 stream_id: number;
80 byte_count: number;
81 sample_count: number;
82 first_sample_time: number;
83 timestamp: Vita49Timestamp | null;
84 data_packet: boolean;
85 context_packet: boolean;
86 dropped: boolean;
87 over_range: boolean;
88 sample_loss: boolean;
89};
90
91export type Vita49TelemetryPoll = {
92 stats: Vita49StreamStatsEvent | null;
93 packets: Vita49PacketTraceEvent[];
94 omitted_packet_trace_events: number;
95 has_more: boolean;
96};
97
98export type Vita49StreamRow = {
99 key: string;
100 receiverId: number;
101 receiverName: string;
102 platformName: string;
103 mode: Vita49StreamMode;
104 streamId: number | null;
105 sampleRate: number | null;
106 referenceFrequency: number | null;
107 packetsEmitted: number;
108 contextPackets: number;
109 samplesEmitted: number;
110 packetsDropped: number;
111 samplesDropped: number;
112 overRangeCount: number;
113 latePacketCount: number;
114 firstSampleTime: number | null;
115 endSampleTime: number | null;
116 firstTimestamp: Vita49Timestamp | null;
117 endTimestamp: Vita49Timestamp | null;
118 backendObserved: boolean;
119};
120
121export type Vita49StreamMode = 'pulsed' | 'cw' | 'fmcw' | 'unknown';
122
123export const DEFAULT_VITA49_CONFIG: Vita49RuntimeConfig = {
124 host: '127.0.0.1',
125 port: 4991,
126 fullscale: 1.0,
127 epochMode: 'auto',
128 epochUnixNanoseconds: '',
129 maxUdpPayload: 1400,
130 queueDepth: 1024,
131 traceEnabled: true,
132 packetTraceRingSize: 500,
133};
134
135const MAX_VRT_EPOCH_NS = 4_294_967_295_999_999_999n;
136const MAX_VITA49_QUEUE_DEPTH = 4_294_967_295;
137
138const isPositiveFinite = (value: number) => Number.isFinite(value) && value > 0;
139
140const isIntegerInRange = (value: number, min: number, max: number) =>
141 Number.isInteger(value) && value >= min && value <= max;
142
143export const validateVita49Config = (config: Vita49RuntimeConfig): string[] => {
144 const errors: string[] = [];
145
146 if (config.host.trim().length === 0) {
147 errors.push('Host is required.');
148 }
149 if (!isIntegerInRange(config.port, 1, 65535)) {
150 errors.push('Port must be an integer from 1 to 65535.');
151 }
152 if (!isPositiveFinite(config.fullscale)) {
153 errors.push('Full-scale must be a positive finite number.');
154 }
155 if (!isIntegerInRange(config.maxUdpPayload, 64, 65507)) {
156 errors.push('Max UDP payload must be an integer from 64 to 65507.');
157 }
158 if (!isIntegerInRange(config.queueDepth, 1, MAX_VITA49_QUEUE_DEPTH)) {
159 errors.push('Queue depth must be an integer from 1 to 4294967295.');
160 }
161 if (
162 !isIntegerInRange(
163 config.packetTraceRingSize,
164 1,
165 Number.MAX_SAFE_INTEGER
166 )
167 ) {
168 errors.push('Packet trace ring size must be a positive integer.');
169 }
170 if (config.epochMode === 'fixed') {
171 try {
172 if (!/^\d+$/.test(config.epochUnixNanoseconds.trim())) {
173 throw new Error('not an integer');
174 }
175 const epoch = BigInt(config.epochUnixNanoseconds.trim());
176 if (epoch > MAX_VRT_EPOCH_NS) {
177 errors.push(
178 'Fixed epoch must fit the VRT 32-bit UTC seconds field.'
179 );
180 }
181 } catch {
182 errors.push('Fixed epoch must be a Unix nanosecond integer.');
183 }
184 }
185
186 return errors;
187};
188
189export const toVita49BackendConfig = (
190 config: Vita49RuntimeConfig
191): Vita49BackendConfig => {
192 const errors = validateVita49Config(config);
193 if (errors.length > 0) {
194 throw new Error(errors.join(' '));
195 }
196
197 return {
198 host: config.host.trim(),
199 port: config.port,
200 fullscale: config.fullscale,
201 epoch_unix_nanoseconds:
202 config.epochMode === 'fixed'
203 ? config.epochUnixNanoseconds.trim()
204 : null,
205 max_udp_payload: config.maxUdpPayload,
206 queue_depth: config.queueDepth,
207 trace_enabled: config.traceEnabled,
208 packet_trace_ring_size: config.packetTraceRingSize,
209 };
210};
211
212const normalizeStreamMode = (
213 mode: string | null | undefined
214): Vita49StreamMode =>
215 mode === 'pulsed' || mode === 'cw' || mode === 'fmcw' ? mode : 'unknown';
216
217export const deriveExpectedVita49Streams = (
218 scenario: Pick<ScenarioData, 'globalParameters' | 'platforms' | 'waveforms'>
219): Vita49StreamRow[] => {
220 const waveformById = new Map(
221 scenario.waveforms.map((waveform) => [waveform.id, waveform])
222 );
223 const rows: Vita49StreamRow[] = [];
224
225 for (const platform of scenario.platforms) {
226 for (const component of platform.components) {
227 if (
228 component.type !== 'receiver' &&
229 component.type !== 'monostatic'
230 ) {
231 continue;
232 }
233 const waveform =
234 component.type === 'monostatic' && component.waveformId
235 ? waveformById.get(component.waveformId)
236 : undefined;
237 const receiverId =
238 component.type === 'monostatic'
239 ? Number(component.rxId)
240 : Number(component.id);
241
242 rows.push({
243 key: `${receiverId}:${component.radarType}`,
244 receiverId,
245 receiverName: component.name,
246 platformName: platform.name,
247 mode: normalizeStreamMode(component.radarType),
248 streamId: null,
249 sampleRate:
250 component.fmcwModeConfig?.if_sample_rate ??
251 scenario.globalParameters.rate,
252 referenceFrequency: waveform?.carrier_frequency ?? null,
253 packetsEmitted: 0,
254 contextPackets: 0,
255 samplesEmitted: 0,
256 packetsDropped: 0,
257 samplesDropped: 0,
258 overRangeCount: 0,
259 latePacketCount: 0,
260 firstSampleTime: null,
261 endSampleTime: null,
262 firstTimestamp: null,
263 endTimestamp: null,
264 backendObserved: false,
265 });
266 }
267 }
268
269 return rows;
270};
271
272const rowFromCounter = (counter: Vita49StreamCounter): Vita49StreamRow => ({
273 key: `${counter.receiver_id}:${normalizeStreamMode(counter.mode)}:${counter.stream_id}`,
274 receiverId: counter.receiver_id,
275 receiverName: counter.receiver_name,
276 platformName: '',
277 mode: normalizeStreamMode(counter.mode),
278 streamId: counter.stream_id,
279 sampleRate: counter.sample_rate,
280 referenceFrequency: counter.reference_frequency,
281 packetsEmitted: counter.packets_emitted,
282 contextPackets:
283 counter.context_packets ?? counter.context_packet_count ?? 0,
284 samplesEmitted: counter.samples_emitted,
285 packetsDropped: counter.packets_dropped,
286 samplesDropped: counter.samples_dropped,
287 overRangeCount: counter.over_range_count,
288 latePacketCount: counter.late_packet_count,
289 firstSampleTime: counter.first_sample_time,
290 endSampleTime: counter.end_sample_time,
291 firstTimestamp: counter.first_timestamp,
292 endTimestamp: counter.end_timestamp,
293 backendObserved: true,
294});
295
296export const mergeVita49StreamRows = (
297 expectedRows: Vita49StreamRow[],
298 stats: Vita49StreamStatsEvent | null
299): Vita49StreamRow[] => {
300 if (!stats) {
301 return expectedRows;
302 }
303
304 const rowsByKey = new Map(expectedRows.map((row) => [row.key, { ...row }]));
305 const expectedByReceiver = new Map<number, Vita49StreamRow[]>();
306 for (const row of expectedRows) {
307 expectedByReceiver.set(row.receiverId, [
308 ...(expectedByReceiver.get(row.receiverId) ?? []),
309 row,
310 ]);
311 }
312
313 const findExpectedRow = (counter: Vita49StreamCounter) => {
314 const observedMode = normalizeStreamMode(counter.mode);
315 if (observedMode !== 'unknown') {
316 const exact = rowsByKey.get(
317 `${counter.receiver_id}:${observedMode}`
318 );
319 if (exact) return exact;
320 }
321 const receiverRows = expectedByReceiver.get(counter.receiver_id) ?? [];
322 return receiverRows.length === 1
323 ? rowsByKey.get(receiverRows[0].key)
324 : undefined;
325 };
326
327 const rowsByResolvedKey = new Map(
328 expectedRows.map((row) => [row.key, { ...row }])
329 );
330
331 for (const counter of stats.streams) {
332 const existing = findExpectedRow(counter);
333 const observed = rowFromCounter(counter);
334 const modeFromBackend = normalizeStreamMode(counter.mode);
335 const key = existing?.key ?? observed.key;
336 rowsByResolvedKey.set(key, {
337 ...(existing ?? observed),
338 receiverName: counter.receiver_name || existing?.receiverName || '',
339 mode:
340 modeFromBackend !== 'unknown'
341 ? modeFromBackend
342 : (existing?.mode ?? observed.mode),
343 streamId: counter.stream_id,
344 sampleRate: counter.sample_rate,
345 referenceFrequency: counter.reference_frequency,
346 packetsEmitted: counter.packets_emitted,
347 contextPackets:
348 counter.context_packets ?? counter.context_packet_count ?? 0,
349 samplesEmitted: counter.samples_emitted,
350 packetsDropped: counter.packets_dropped,
351 samplesDropped: counter.samples_dropped,
352 overRangeCount: counter.over_range_count,
353 latePacketCount: counter.late_packet_count,
354 firstSampleTime: counter.first_sample_time,
355 endSampleTime: counter.end_sample_time,
356 firstTimestamp: counter.first_timestamp,
357 endTimestamp: counter.end_timestamp,
358 backendObserved: true,
359 });
360 }
361
362 const modeOrder: Record<Vita49StreamMode, number> = {
363 pulsed: 0,
364 cw: 1,
365 fmcw: 2,
366 unknown: 3,
367 };
368 return Array.from(rowsByResolvedKey.values()).sort(
369 (a, b) =>
370 a.receiverId - b.receiverId ||
371 modeOrder[a.mode] - modeOrder[b.mode] ||
372 (a.streamId ?? 0) - (b.streamId ?? 0)
373 );
374};
375
376type Vita49StreamingStore = {
377 config: Vita49RuntimeConfig;
378 runState: Vita49RunState;
379 expectedStreams: Vita49StreamRow[];
380 streamStats: Vita49StreamStatsEvent | null;
381 packetTrace: Vita49PacketTraceEvent[];
382 omittedPacketTraceEvents: number;
383 finalMetadata: SimulationOutputMetadata | null;
384 finalVita49Metadata: SimulationOutputVita49Metadata | null;
385 error: string | null;
386 setConfig: (config: Partial<Vita49RuntimeConfig>) => void;
387 startRun: (expectedStreams: Vita49StreamRow[]) => void;
388 markStopping: () => void;
389 markDraining: () => void;
390 setStreamStats: (stats: Vita49StreamStatsEvent) => void;
391 appendPacketBatch: (
392 packets: Vita49PacketTraceEvent[],
393 omittedPacketTraceEvents?: number
394 ) => void;
395 completeRun: (metadata: SimulationOutputMetadata | null) => void;
396 cancelRun: (metadata: SimulationOutputMetadata | null) => void;
397 failRun: (error: string) => void;
398 resetTrace: () => void;
399};
400
401export const useVita49StreamingStore = create<Vita49StreamingStore>()(
402 persist(
403 (set, get) => ({
404 config: DEFAULT_VITA49_CONFIG,
405 runState: 'idle',
406 expectedStreams: [],
407 streamStats: null,
408 packetTrace: [],
409 omittedPacketTraceEvents: 0,
410 finalMetadata: null,
411 finalVita49Metadata: null,
412 error: null,
413
414 setConfig: (config) =>
415 set((state) => ({ config: { ...state.config, ...config } })),
416 startRun: (expectedStreams) =>
417 set({
418 runState: 'running',
419 expectedStreams,
420 streamStats: null,
421 packetTrace: [],
422 omittedPacketTraceEvents: 0,
423 finalMetadata: null,
424 finalVita49Metadata: null,
425 error: null,
426 }),
427 markStopping: () =>
428 set((state) => ({
429 runState:
430 state.runState === 'running'
431 ? 'stopping'
432 : state.runState,
433 })),
434 markDraining: () =>
435 set((state) => ({
436 runState:
437 state.runState === 'running' ||
438 state.runState === 'stopping'
439 ? 'draining'
440 : state.runState,
441 })),
442 setStreamStats: (streamStats) => set({ streamStats }),
443 appendPacketBatch: (packets, omittedPacketTraceEvents = 0) =>
444 set((state) => {
445 const ringSize = state.config.packetTraceRingSize;
446 const combined = [...state.packetTrace, ...packets];
447 const overflow = Math.max(0, combined.length - ringSize);
448 return {
449 packetTrace:
450 overflow > 0 ? combined.slice(overflow) : combined,
451 omittedPacketTraceEvents:
452 state.omittedPacketTraceEvents +
453 omittedPacketTraceEvents +
454 overflow,
455 };
456 }),
457 completeRun: (metadata) =>
458 set({
459 runState: 'completed',
460 finalMetadata: metadata,
461 finalVita49Metadata: metadata?.vita49 ?? null,
462 error: null,
463 }),
464 cancelRun: (metadata) =>
465 set({
466 runState: 'cancelled',
467 finalMetadata: metadata,
468 finalVita49Metadata: metadata?.vita49 ?? null,
469 error: null,
470 }),
471 failRun: (error) =>
472 set({
473 runState: 'failed',
474 error,
475 }),
476 resetTrace: () =>
477 set({ packetTrace: [], omittedPacketTraceEvents: 0 }),
478 }),
479 {
480 name: 'fers-vita49-streaming',
481 partialize: (state) => ({ config: state.config }),
482 merge: (persisted, current) => {
483 const persistedState =
484 persisted && typeof persisted === 'object'
485 ? (persisted as Partial<
486 Pick<Vita49StreamingStore, 'config'>
487 >)
488 : {};
489 return {
490 ...current,
491 ...persistedState,
492 config: {
493 ...DEFAULT_VITA49_CONFIG,
494 ...persistedState.config,
495 },
496 };
497 },
498 }
499 )
500);