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