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_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;
70export type Vita49StreamStatsEvent = {
71 mode: 'vita49_udp' | 'hdf5';
72 epoch_unix_nanoseconds: string | null;
73 streams: Vita49StreamCounter[];
76export type Vita49PacketTraceEvent = {
78 event: 'data' | 'context' | 'drop' | 'failure' | string;
82 first_sample_time: number;
83 timestamp: Vita49Timestamp | null;
85 context_packet: boolean;
91export type Vita49TelemetryPoll = {
92 stats: Vita49StreamStatsEvent | null;
93 packets: Vita49PacketTraceEvent[];
94 omitted_packet_trace_events: number;
98export type Vita49StreamRow = {
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;
121export type Vita49StreamMode = 'pulsed' | 'cw' | 'fmcw' | 'unknown';
123export const DEFAULT_VITA49_CONFIG: Vita49RuntimeConfig = {
128 epochUnixNanoseconds: '',
132 packetTraceRingSize: 500,
135const MAX_VRT_EPOCH_NS = 4_294_967_295_999_999_999n;
136const MAX_VITA49_QUEUE_DEPTH = 4_294_967_295;
138const isPositiveFinite = (value: number) => Number.isFinite(value) && value > 0;
140const isIntegerInRange = (value: number, min: number, max: number) =>
141 Number.isInteger(value) && value >= min && value <= max;
143export const validateVita49Config = (config: Vita49RuntimeConfig): string[] => {
144 const errors: string[] = [];
146 if (config.host.trim().length === 0) {
147 errors.push('Host is required.');
149 if (!isIntegerInRange(config.port, 1, 65535)) {
150 errors.push('Port must be an integer from 1 to 65535.');
152 if (!isPositiveFinite(config.fullscale)) {
153 errors.push('Full-scale must be a positive finite number.');
155 if (!isIntegerInRange(config.maxUdpPayload, 64, 65507)) {
156 errors.push('Max UDP payload must be an integer from 64 to 65507.');
158 if (!isIntegerInRange(config.queueDepth, 1, MAX_VITA49_QUEUE_DEPTH)) {
159 errors.push('Queue depth must be an integer from 1 to 4294967295.');
163 config.packetTraceRingSize,
165 Number.MAX_SAFE_INTEGER
168 errors.push('Packet trace ring size must be a positive integer.');
170 if (config.epochMode === 'fixed') {
172 if (!/^\d+$/.test(config.epochUnixNanoseconds.trim())) {
173 throw new Error('not an integer');
175 const epoch = BigInt(config.epochUnixNanoseconds.trim());
176 if (epoch > MAX_VRT_EPOCH_NS) {
178 'Fixed epoch must fit the VRT 32-bit UTC seconds field.'
182 errors.push('Fixed epoch must be a Unix nanosecond integer.');
189export const toVita49BackendConfig = (
190 config: Vita49RuntimeConfig
191): Vita49BackendConfig => {
192 const errors = validateVita49Config(config);
193 if (errors.length > 0) {
194 throw new Error(errors.join(' '));
198 host: config.host.trim(),
200 fullscale: config.fullscale,
201 epoch_unix_nanoseconds:
202 config.epochMode === 'fixed'
203 ? config.epochUnixNanoseconds.trim()
205 max_udp_payload: config.maxUdpPayload,
206 queue_depth: config.queueDepth,
207 trace_enabled: config.traceEnabled,
208 packet_trace_ring_size: config.packetTraceRingSize,
212const normalizeStreamMode = (
213 mode: string | null | undefined
214): Vita49StreamMode =>
215 mode === 'pulsed' || mode === 'cw' || mode === 'fmcw' ? mode : 'unknown';
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])
223 const rows: Vita49StreamRow[] = [];
225 for (const platform of scenario.platforms) {
226 for (const component of platform.components) {
228 component.type !== 'receiver' &&
229 component.type !== 'monostatic'
234 component.type === 'monostatic' && component.waveformId
235 ? waveformById.get(component.waveformId)
238 component.type === 'monostatic'
239 ? Number(component.rxId)
240 : Number(component.id);
243 key: `${receiverId}:${component.radarType}`,
245 receiverName: component.name,
246 platformName: platform.name,
247 mode: normalizeStreamMode(component.radarType),
250 component.fmcwModeConfig?.if_sample_rate ??
251 scenario.globalParameters.rate,
252 referenceFrequency: waveform?.carrier_frequency ?? null,
260 firstSampleTime: null,
262 firstTimestamp: null,
264 backendObserved: false,
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,
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,
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,
296export const mergeVita49StreamRows = (
297 expectedRows: Vita49StreamRow[],
298 stats: Vita49StreamStatsEvent | null
299): Vita49StreamRow[] => {
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) ?? []),
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}`
319 if (exact) return exact;
321 const receiverRows = expectedByReceiver.get(counter.receiver_id) ?? [];
322 return receiverRows.length === 1
323 ? rowsByKey.get(receiverRows[0].key)
327 const rowsByResolvedKey = new Map(
328 expectedRows.map((row) => [row.key, { ...row }])
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 || '',
340 modeFromBackend !== 'unknown'
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,
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,
362 const modeOrder: Record<Vita49StreamMode, number> = {
368 return Array.from(rowsByResolvedKey.values()).sort(
370 a.receiverId - b.receiverId ||
371 modeOrder[a.mode] - modeOrder[b.mode] ||
372 (a.streamId ?? 0) - (b.streamId ?? 0)
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;
392 packets: Vita49PacketTraceEvent[],
393 omittedPacketTraceEvents?: number
395 completeRun: (metadata: SimulationOutputMetadata | null) => void;
396 cancelRun: (metadata: SimulationOutputMetadata | null) => void;
397 failRun: (error: string) => void;
398 resetTrace: () => void;
401export const useVita49StreamingStore = create<Vita49StreamingStore>()(
404 config: DEFAULT_VITA49_CONFIG,
409 omittedPacketTraceEvents: 0,
411 finalVita49Metadata: null,
414 setConfig: (config) =>
415 set((state) => ({ config: { ...state.config, ...config } })),
416 startRun: (expectedStreams) =>
422 omittedPacketTraceEvents: 0,
424 finalVita49Metadata: null,
430 state.runState === 'running'
437 state.runState === 'running' ||
438 state.runState === 'stopping'
442 setStreamStats: (streamStats) => set({ streamStats }),
443 appendPacketBatch: (packets, omittedPacketTraceEvents = 0) =>
445 const ringSize = state.config.packetTraceRingSize;
446 const combined = [...state.packetTrace, ...packets];
447 const overflow = Math.max(0, combined.length - ringSize);
450 overflow > 0 ? combined.slice(overflow) : combined,
451 omittedPacketTraceEvents:
452 state.omittedPacketTraceEvents +
453 omittedPacketTraceEvents +
457 completeRun: (metadata) =>
459 runState: 'completed',
460 finalMetadata: metadata,
461 finalVita49Metadata: metadata?.vita49 ?? null,
464 cancelRun: (metadata) =>
466 runState: 'cancelled',
467 finalMetadata: metadata,
468 finalVita49Metadata: metadata?.vita49 ?? null,
477 set({ packetTrace: [], omittedPacketTraceEvents: 0 }),
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'>
493 ...DEFAULT_VITA49_CONFIG,
494 ...persistedState.config,