FERS 0.1.0
The Flexible Extensible Radar Simulator
Loading...
Searching...
No Matches
serializers.test.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 { describe, expect, test } from 'bun:test';
5import { PlatformComponentSchema, WaveformSchema } from '../scenarioSchema';
6import { createWaveformForType, defaultGlobalParameters } from './defaults';
7import {
8 serializeAntenna,
9 serializeComponentInner,
10 serializeGlobalParameters,
11 serializeWaveform,
12} from './serializers';
13import type { Antenna, PlatformComponent, Waveform } from './types';
14
15describe('serializeAntenna', () => {
16 test('uses an isotropic backend placeholder while an H5 antenna has no filename', () => {
17 const antenna: Antenna = {
18 id: '1',
19 type: 'Antenna',
20 name: 'Draft H5',
21 pattern: 'file',
22 filename: ' ',
23 efficiency: 0.8,
24 meshScale: 1,
25 design_frequency: null,
26 };
27
28 expect(serializeAntenna(antenna)).toEqual({
29 id: '1',
30 name: 'Draft H5',
31 pattern: 'isotropic',
32 efficiency: 0.8,
33 });
34 });
35
36 test('preserves H5 antenna payload once a filename is present', () => {
37 const antenna: Antenna = {
38 id: '2',
39 type: 'Antenna',
40 name: 'Loaded H5',
41 pattern: 'file',
42 filename: '/tmp/pattern.h5',
43 efficiency: 1,
44 meshScale: 1,
45 design_frequency: null,
46 };
47
48 expect(serializeAntenna(antenna)).toEqual({
49 id: '2',
50 name: 'Loaded H5',
51 pattern: 'file',
52 filename: '/tmp/pattern.h5',
53 efficiency: 1,
54 });
55 });
56});
57
58describe('serializeGlobalParameters', () => {
59 test('fills UTM zone and hemisphere defaults before backend sync', () => {
60 expect(
61 serializeGlobalParameters({
62 ...defaultGlobalParameters,
63 coordinateSystem: { frame: 'UTM' },
64 })
65 ).toMatchObject({
66 coordinatesystem: {
67 frame: 'UTM',
68 zone: 34,
69 hemisphere: 'S',
70 },
71 });
72 });
73});
74
75describe('FMCW schema', () => {
76 const validWaveform: Extract<
77 Waveform,
78 { waveformType: 'fmcw_linear_chirp' }
79 > = {
80 ...createWaveformForType('fmcw_linear_chirp'),
81 id: '10',
82 name: 'FMCW',
83 };
84
85 test('accepts valid FMCW waveform fields', () => {
86 expect(WaveformSchema.safeParse(validWaveform).success).toBe(true);
87 expect(
88 WaveformSchema.safeParse({
89 ...createWaveformForType('fmcw_triangle'),
90 id: '11',
91 name: 'Triangle',
92 }).success
93 ).toBe(true);
94 });
95
96 test('keeps generated FMCW defaults within the backend aliasing limit', () => {
97 const sweepStart = validWaveform.start_frequency_offset ?? 0;
98 const sweepEnd =
99 sweepStart +
100 (validWaveform.direction === 'down' ? -1 : 1) *
101 validWaveform.chirp_bandwidth;
102 const maxBaseband = Math.max(Math.abs(sweepStart), Math.abs(sweepEnd));
103 const effectiveRate =
104 defaultGlobalParameters.rate *
105 defaultGlobalParameters.oversample_ratio;
106
107 expect(effectiveRate).toBeGreaterThan(maxBaseband);
108 });
109
110 test('rejects invalid FMCW waveform fields', () => {
111 const invalidWaveforms = [
112 { ...validWaveform, chirp_bandwidth: 0 },
113 { ...validWaveform, chirp_duration: 0 },
114 {
115 ...validWaveform,
116 chirp_duration: 2e-6,
117 chirp_period: 1e-6,
118 },
119 { ...validWaveform, chirp_count: 0 },
120 { ...validWaveform, chirp_count: 1.5 },
121 {
122 ...validWaveform,
123 start_frequency_offset: Number.POSITIVE_INFINITY,
124 },
125 ];
126
127 for (const waveform of invalidWaveforms) {
128 expect(WaveformSchema.safeParse(waveform).success).toBe(false);
129 }
130
131 const invalidTriangles = [
132 {
133 ...createWaveformForType('fmcw_triangle'),
134 id: '12',
135 name: 'Bad Triangle',
136 chirp_bandwidth: 0,
137 },
138 {
139 ...createWaveformForType('fmcw_triangle'),
140 id: '13',
141 name: 'Bad Triangle Count',
142 triangle_count: 1.5,
143 },
144 ];
145 for (const waveform of invalidTriangles) {
146 expect(WaveformSchema.safeParse(waveform).success).toBe(false);
147 }
148 });
149
150 test('accepts FMCW radar mode on radar components', () => {
151 const component: PlatformComponent = {
152 id: '20',
153 type: 'transmitter',
154 name: 'FMCW TX',
155 radarType: 'fmcw',
156 prf: null,
157 antennaId: null,
158 waveformId: null,
159 timingId: null,
160 schedule: [],
161 };
162
163 expect(PlatformComponentSchema.safeParse(component).success).toBe(true);
164 });
165});
166
167describe('file waveform schema', () => {
168 test('requires a non-empty filename for CW and FMCW file variants', () => {
169 const waveforms = [
170 {
171 ...createWaveformForType('cw_from_file'),
172 id: '11',
173 name: 'CW File',
174 },
175 {
176 ...createWaveformForType('fmcw_from_file'),
177 id: '12',
178 name: 'FMCW File',
179 },
180 ];
181 for (const waveform of waveforms) {
182 expect(WaveformSchema.safeParse(waveform).success).toBe(false);
183 expect(
184 WaveformSchema.safeParse({
185 ...waveform,
186 filename: `${waveform.waveformType}.h5`,
187 }).success
188 ).toBe(true);
189 expect(
190 WaveformSchema.safeParse({
191 ...waveform,
192 filename: `${waveform.waveformType}.csv`,
193 }).success
194 ).toBe(false);
195 }
196 });
197});
198
199describe('SFCW schema', () => {
200 const validWaveform: Extract<
201 Waveform,
202 { waveformType: 'stepped_frequency' }
203 > = {
204 ...createWaveformForType('stepped_frequency'),
205 id: '14',
206 name: 'SFCW',
207 };
208
209 test('accepts valid SFCW waveform fields and radar mode', () => {
210 expect(WaveformSchema.safeParse(validWaveform).success).toBe(true);
211 const component: PlatformComponent = {
212 id: '21',
213 type: 'transmitter',
214 name: 'SFCW TX',
215 radarType: 'sfcw',
216 prf: null,
217 antennaId: null,
218 waveformId: null,
219 timingId: null,
220 schedule: [],
221 };
222
223 expect(PlatformComponentSchema.safeParse(component).success).toBe(true);
224 });
225
226 test('rejects invalid SFCW waveform fields', () => {
227 const invalidWaveforms = [
228 { ...validWaveform, step_size: 0 },
229 { ...validWaveform, step_count: 0 },
230 { ...validWaveform, step_count: 1.5 },
231 { ...validWaveform, dwell_time: 0 },
232 { ...validWaveform, step_period: 0 },
233 {
234 ...validWaveform,
235 dwell_time: 2e-3,
236 step_period: 1e-3,
237 },
238 { ...validWaveform, sweep_count: 0 },
239 { ...validWaveform, sweep_count: 2.5 },
240 ];
241
242 for (const waveform of invalidWaveforms) {
243 expect(WaveformSchema.safeParse(waveform).success).toBe(false);
244 }
245 });
246});
247
248describe('serializeWaveform', () => {
249 test('serializes CW and FMCW file waveform payloads', () => {
250 const cw: Waveform = {
251 ...createWaveformForType('cw_from_file'),
252 id: 'file-cw',
253 name: 'CW File',
254 filename: 'waveforms/cw.h5',
255 };
256 const fmcw: Waveform = {
257 ...createWaveformForType('fmcw_from_file'),
258 id: 'file-fmcw',
259 name: 'FMCW File',
260 filename: 'waveforms/fmcw.h5',
261 };
262
263 expect(serializeWaveform(cw)).toMatchObject({
264 cw_from_file: { filename: 'waveforms/cw.h5' },
265 });
266 expect(serializeWaveform(fmcw)).toMatchObject({
267 fmcw_from_file: { filename: 'waveforms/fmcw.h5' },
268 });
269 });
270
271 test('serializes FMCW waveform payload and omits unset optional fields', () => {
272 const waveform: Waveform = {
273 ...createWaveformForType('fmcw_linear_chirp'),
274 id: '30',
275 name: 'FMCW',
276 };
277
278 expect(serializeWaveform(waveform)).toEqual({
279 id: '30',
280 name: 'FMCW',
281 power: 1000,
282 carrier_frequency: 1e9,
283 fmcw_linear_chirp: {
284 direction: 'up',
285 chirp_bandwidth: 4e3,
286 chirp_duration: 1e-3,
287 chirp_period: 1e-3,
288 },
289 });
290 });
291
292 test('serializes FMCW optional fields when set', () => {
293 const waveform: Waveform = {
294 ...createWaveformForType('fmcw_linear_chirp'),
295 id: '31',
296 name: 'FMCW Offset',
297 start_frequency_offset: -1000,
298 chirp_count: 4,
299 };
300
301 expect(serializeWaveform(waveform)).toMatchObject({
302 fmcw_linear_chirp: {
303 direction: 'up',
304 start_frequency_offset: -1000,
305 chirp_count: 4,
306 },
307 });
308 });
309
310 test('serializes FMCW triangle waveform payload', () => {
311 const waveform: Waveform = {
312 ...createWaveformForType('fmcw_triangle'),
313 id: '32',
314 name: 'Triangle',
315 start_frequency_offset: -1000,
316 triangle_count: 4,
317 };
318
319 expect(serializeWaveform(waveform)).toEqual({
320 id: '32',
321 name: 'Triangle',
322 power: 1000,
323 carrier_frequency: 1e9,
324 fmcw_triangle: {
325 chirp_bandwidth: 4e3,
326 chirp_duration: 1e-3,
327 start_frequency_offset: -1000,
328 triangle_count: 4,
329 },
330 });
331 });
332
333 test('serializes SFCW waveform payload', () => {
334 const waveform: Waveform = {
335 ...createWaveformForType('stepped_frequency'),
336 id: '33',
337 name: 'SFCW',
338 start_frequency_offset: 0,
339 step_size: 2e5,
340 step_count: 8,
341 dwell_time: 1e-4,
342 step_period: 2e-4,
343 sweep_count: 4,
344 };
345
346 expect(serializeWaveform(waveform)).toEqual({
347 id: '33',
348 name: 'SFCW',
349 power: 1000,
350 carrier_frequency: 1e9,
351 stepped_frequency: {
352 start_frequency_offset: 0,
353 step_size: 2e5,
354 step_count: 8,
355 dwell_time: 1e-4,
356 step_period: 2e-4,
357 sweep_count: 4,
358 },
359 });
360 });
361});
362
363describe('serializeComponentInner', () => {
364 test('serializes empty component references as backend placeholders', () => {
365 const component: PlatformComponent = {
366 id: '41',
367 type: 'transmitter',
368 name: 'Draft Tx',
369 radarType: 'pulsed',
370 prf: 1000,
371 antennaId: '',
372 waveformId: '',
373 timingId: '',
374 schedule: [],
375 };
376
377 expect(serializeComponentInner(component)).toMatchObject({
378 antenna: 0,
379 waveform: 0,
380 timing: 0,
381 });
382 });
383
384 test('serializes FMCW mode without pulsed fields', () => {
385 const component: PlatformComponent = {
386 id: '40',
387 type: 'monostatic',
388 name: 'FMCW Mono',
389 txId: '41',
390 rxId: '42',
391 radarType: 'fmcw',
392 window_skip: 1,
393 window_length: 2,
394 prf: 3,
395 antennaId: null,
396 waveformId: null,
397 timingId: null,
398 noiseTemperature: null,
399 noDirectPaths: false,
400 noPropagationLoss: false,
401 schedule: [],
402 };
403
404 expect(serializeComponentInner(component)).toEqual({
405 tx_id: '41',
406 rx_id: '42',
407 name: 'FMCW Mono',
408 fmcw_mode: {},
409 antenna: 0,
410 waveform: 0,
411 timing: 0,
412 noise_temp: null,
413 nodirect: false,
414 nopropagationloss: false,
415 schedule: [],
416 });
417 });
418
419 test('serializes SFCW mode without pulsed or dechirp fields', () => {
420 const component: PlatformComponent = {
421 id: '44',
422 type: 'transmitter',
423 name: 'SFCW Tx',
424 radarType: 'sfcw',
425 prf: 3,
426 antennaId: null,
427 waveformId: null,
428 timingId: null,
429 schedule: [],
430 };
431
432 expect(serializeComponentInner(component)).toEqual({
433 id: '44',
434 name: 'SFCW Tx',
435 sfcw_mode: {},
436 antenna: 0,
437 waveform: 0,
438 timing: 0,
439 schedule: [],
440 });
441 });
442
443 test('preserves receiver FMCW attached dechirp mode fields', () => {
444 const component: PlatformComponent = {
445 id: '42',
446 type: 'receiver',
447 name: 'FMCW Rx',
448 radarType: 'fmcw',
449 window_skip: null,
450 window_length: null,
451 prf: null,
452 antennaId: null,
453 timingId: null,
454 noiseTemperature: null,
455 noDirectPaths: false,
456 noPropagationLoss: false,
457 fmcwModeConfig: {
458 dechirp_mode: 'physical',
459 dechirp_reference: { source: 'attached' },
460 if_sample_rate: 1e6,
461 if_filter_bandwidth: 4e5,
462 if_filter_transition_width: 1e5,
463 },
464 schedule: [],
465 };
466
467 expect(serializeComponentInner(component)).toMatchObject({
468 fmcw_mode: {
469 dechirp_mode: 'physical',
470 dechirp_reference: { source: 'attached' },
471 if_sample_rate: 1e6,
472 if_filter_bandwidth: 4e5,
473 if_filter_transition_width: 1e5,
474 },
475 });
476 });
477
478 test('preserves receiver FMCW transmitter dechirp reference name', () => {
479 const component: PlatformComponent = {
480 id: '43',
481 type: 'receiver',
482 name: 'FMCW Rx',
483 radarType: 'fmcw',
484 window_skip: null,
485 window_length: null,
486 prf: null,
487 antennaId: null,
488 timingId: null,
489 noiseTemperature: null,
490 noDirectPaths: false,
491 noPropagationLoss: false,
492 fmcwModeConfig: {
493 dechirp_mode: 'ideal',
494 dechirp_reference: {
495 source: 'transmitter',
496 transmitter_name: 'Reference TX',
497 },
498 },
499 schedule: [],
500 };
501
502 expect(serializeComponentInner(component)).toMatchObject({
503 fmcw_mode: {
504 dechirp_mode: 'ideal',
505 dechirp_reference: {
506 source: 'transmitter',
507 transmitter_name: 'Reference TX',
508 },
509 },
510 });
511 });
512
513 test('preserves monostatic FMCW custom dechirp reference waveform name', () => {
514 const component: PlatformComponent = {
515 id: '44',
516 type: 'monostatic',
517 name: 'FMCW Mono',
518 txId: '45',
519 rxId: '46',
520 radarType: 'fmcw',
521 window_skip: null,
522 window_length: null,
523 prf: null,
524 antennaId: null,
525 waveformId: null,
526 timingId: null,
527 noiseTemperature: null,
528 noDirectPaths: false,
529 noPropagationLoss: false,
530 fmcwModeConfig: {
531 dechirp_mode: 'physical',
532 dechirp_reference: {
533 source: 'custom',
534 waveform_name: 'Reference Waveform',
535 },
536 },
537 schedule: [],
538 };
539
540 expect(serializeComponentInner(component)).toMatchObject({
541 fmcw_mode: {
542 dechirp_mode: 'physical',
543 dechirp_reference: {
544 source: 'custom',
545 waveform_name: 'Reference Waveform',
546 },
547 },
548 });
549 });
550
551 test('uses an isotropic backend placeholder while a file target has no filename', () => {
552 const component: PlatformComponent = {
553 id: '50',
554 type: 'target',
555 name: 'Draft Target',
556 rcs_type: 'file',
557 rcs_value: 3,
558 rcs_model: 'constant',
559 };
560
561 expect(serializeComponentInner(component)).toEqual({
562 id: '50',
563 name: 'Draft Target',
564 rcs: {
565 type: 'isotropic',
566 value: 3,
567 },
568 });
569 });
570
571 test('serializes file target RCS once a filename is present', () => {
572 const component: PlatformComponent = {
573 id: '51',
574 type: 'target',
575 name: 'Loaded Target',
576 rcs_type: 'file',
577 rcs_filename: '/tmp/target.xml',
578 rcs_model: 'constant',
579 };
580
581 expect(serializeComponentInner(component)).toEqual({
582 id: '51',
583 name: 'Loaded Target',
584 rcs: {
585 type: 'file',
586 filename: '/tmp/target.xml',
587 },
588 });
589 });
590});