1// SPDX-License-Identifier: GPL-2.0-only
2// Copyright (c) 2026-present FERS Contributors (see AUTHORS.md).
4import { create } from 'zustand';
5import { persist } from 'zustand/middleware';
6import type { ScenarioData } from './scenarioStore';
8 SimulationOutputMetadata,
9 SimulationOutputVita49Metadata,
10 SimulationOutputVita49Timestamp,
11} from './simulationProgressStore';
13export type Vita49Timestamp = SimulationOutputVita49Timestamp;
15export type Vita49RunState =
24export type Vita49EpochMode = 'auto' | 'fixed';
26export type Vita49RuntimeConfig = {
30 epochMode: Vita49EpochMode;
31 epochUnixNanoseconds: string;
32 maxUdpPayload: number;
34 traceEnabled: boolean;
35 packetTraceRingSize: number;
38export type Vita49BackendConfig = {
42 epoch_unix_nanoseconds: string | null;
43 max_udp_payload: number;
45 trace_enabled: boolean;
46 packet_trace_ring_size: number;
49export type Vita49StreamCounter = {
51 receiver_name: string;
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;
71export type Vita49StreamStatsEvent = {
72 mode: 'vita49_udp' | 'hdf5';
73 epoch_unix_nanoseconds: string | null;
74 streams: Vita49StreamCounter[];
77export type Vita49PacketTraceEvent = {
79 event: 'data' | 'context' | 'drop' | 'failure' | string;
83 first_sample_time: number;
84 timestamp: Vita49Timestamp | null;
86 context_packet: boolean;
92export type Vita49TelemetryPoll = {
93 stats: Vita49StreamStatsEvent | null;
94 packets: Vita49PacketTraceEvent[];
95 omitted_packet_trace_events: number;
99export type Vita49StreamRow = {
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;
123export type Vita49StreamMode = 'pulsed' | 'cw' | 'fmcw' | 'sfcw' | 'unknown';
125export const DEFAULT_VITA49_CONFIG: Vita49RuntimeConfig = {
130 epochUnixNanoseconds: '',
134 packetTraceRingSize: 500,
137const MAX_VRT_EPOCH_NS = 4_294_967_295_999_999_999n;
138const MAX_VITA49_QUEUE_DEPTH = 4_294_967_295;
140const isPositiveFinite = (value: number) => Number.isFinite(value) && value > 0;
142const isIntegerInRange = (value: number, min: number, max: number) =>
143 Number.isInteger(value) && value >= min && value <= max;
145export const validateVita49Config = (config: Vita49RuntimeConfig): string[] => {
146 const errors: string[] = [];
148 if (config.host.trim().length === 0) {
149 errors.push('Host is required.');
151 if (!isIntegerInRange(config.port, 1, 65535)) {
152 errors.push('Port must be an integer from 1 to 65535.');
154 if (!isPositiveFinite(config.fullscale)) {
155 errors.push('Full-scale must be a positive finite number.');
157 if (!isIntegerInRange(config.maxUdpPayload, 64, 65507)) {
158 errors.push('Max UDP payload must be an integer from 64 to 65507.');
160 if (!isIntegerInRange(config.queueDepth, 1, MAX_VITA49_QUEUE_DEPTH)) {
161 errors.push('Queue depth must be an integer from 1 to 4294967295.');
165 config.packetTraceRingSize,
167 Number.MAX_SAFE_INTEGER
170 errors.push('Packet trace ring size must be a positive integer.');
172 if (config.epochMode === 'fixed') {
174 if (!/^\d+$/.test(config.epochUnixNanoseconds.trim())) {
175 throw new Error('not an integer');
177 const epoch = BigInt(config.epochUnixNanoseconds.trim());
178 if (epoch > MAX_VRT_EPOCH_NS) {
180 'Fixed epoch must fit the VRT 32-bit UTC seconds field.'
184 errors.push('Fixed epoch must be a Unix nanosecond integer.');
191export const toVita49BackendConfig = (
192 config: Vita49RuntimeConfig
193): Vita49BackendConfig => {
194 const errors = validateVita49Config(config);
195 if (errors.length > 0) {
196 throw new Error(errors.join(' '));
200 host: config.host.trim(),
202 fullscale: config.fullscale,
203 epoch_unix_nanoseconds:
204 config.epochMode === 'fixed'
205 ? config.epochUnixNanoseconds.trim()
207 max_udp_payload: config.maxUdpPayload,
208 queue_depth: config.queueDepth,
209 trace_enabled: config.traceEnabled,
210 packet_trace_ring_size: config.packetTraceRingSize,
214const normalizeStreamMode = (
215 mode: string | null | undefined
216): Vita49StreamMode =>
217 mode === 'pulsed' || mode === 'cw' || mode === 'fmcw' || mode === 'sfcw'
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])
227 const rows: Vita49StreamRow[] = [];
229 for (const platform of scenario.platforms) {
230 for (const component of platform.components) {
232 component.type !== 'receiver' &&
233 component.type !== 'monostatic'
238 component.type === 'monostatic' && component.waveformId
239 ? waveformById.get(component.waveformId)
242 component.type === 'monostatic'
243 ? Number(component.rxId)
244 : Number(component.id);
247 key: `${receiverId}:${component.radarType}`,
249 receiverName: component.name,
250 platformName: platform.name,
251 mode: normalizeStreamMode(component.radarType),
254 component.fmcwModeConfig?.if_sample_rate ??
255 scenario.globalParameters.rate,
256 referenceFrequency: waveform?.carrier_frequency ?? null,
263 lateDataPacketCount: 0,
264 lateContextPacketCount: 0,
265 firstSampleTime: null,
267 firstTimestamp: null,
269 backendObserved: false,
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,
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,
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,
302export const mergeVita49StreamRows = (
303 expectedRows: Vita49StreamRow[],
304 stats: Vita49StreamStatsEvent | null
305): Vita49StreamRow[] => {
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) ?? []),
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}`
325 if (exact) return exact;
327 const receiverRows = expectedByReceiver.get(counter.receiver_id) ?? [];
328 return receiverRows.length === 1
329 ? rowsByKey.get(receiverRows[0].key)
333 const rowsByResolvedKey = new Map(
334 expectedRows.map((row) => [row.key, { ...row }])
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 || '',
346 modeFromBackend !== 'unknown'
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,
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,
369 const modeOrder: Record<Vita49StreamMode, number> = {
376 return Array.from(rowsByResolvedKey.values()).sort(
378 a.receiverId - b.receiverId ||
379 modeOrder[a.mode] - modeOrder[b.mode] ||
380 (a.streamId ?? 0) - (b.streamId ?? 0)
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;
400 packets: Vita49PacketTraceEvent[],
401 omittedPacketTraceEvents?: number
403 completeRun: (metadata: SimulationOutputMetadata | null) => void;
404 cancelRun: (metadata: SimulationOutputMetadata | null) => void;
405 failRun: (error: string) => void;
406 resetTrace: () => void;
409export const useVita49StreamingStore = create<Vita49StreamingStore>()(
412 config: DEFAULT_VITA49_CONFIG,
417 omittedPacketTraceEvents: 0,
419 finalVita49Metadata: null,
422 setConfig: (config) =>
423 set((state) => ({ config: { ...state.config, ...config } })),
424 startRun: (expectedStreams) =>
430 omittedPacketTraceEvents: 0,
432 finalVita49Metadata: null,
438 state.runState === 'running'
445 state.runState === 'running' ||
446 state.runState === 'stopping'
450 setStreamStats: (streamStats) => set({ streamStats }),
451 appendPacketBatch: (packets, omittedPacketTraceEvents = 0) =>
453 const ringSize = state.config.packetTraceRingSize;
454 const combined = [...state.packetTrace, ...packets];
455 const overflow = Math.max(0, combined.length - ringSize);
458 overflow > 0 ? combined.slice(overflow) : combined,
459 omittedPacketTraceEvents:
460 state.omittedPacketTraceEvents +
461 omittedPacketTraceEvents +
465 completeRun: (metadata) =>
467 runState: 'completed',
468 finalMetadata: metadata,
469 finalVita49Metadata: metadata?.vita49 ?? null,
472 cancelRun: (metadata) =>
474 runState: 'cancelled',
475 finalMetadata: metadata,
476 finalVita49Metadata: metadata?.vita49 ?? null,
485 set({ packetTrace: [], omittedPacketTraceEvents: 0 }),
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'>
501 ...DEFAULT_VITA49_CONFIG,
502 ...persistedState.config,