FERS 0.1.0
The Flexible Extensible Radar Simulator
Loading...
Searching...
No Matches
hydration.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 { ScenarioDataSchema } from '../scenarioSchema';
5import { createDefaultPlatform } from './defaults';
6import {
7 generateSimId,
8 normalizeSimId,
9 reserveSimId,
10 seedSimIdCounters,
11} from './idUtils';
12import {
13 Antenna,
14 FixedRotation,
15 GlobalParameters,
16 MotionPath,
17 Platform,
18 PlatformComponent,
19 RotationPath,
20 ScenarioData,
21 ScenarioState,
22 Timing,
23 Waveform,
24} from './types';
25
26interface BackendObjectWithName {
27 name: string;
28 [key: string]: unknown;
29}
30
31interface BackendPulsedMode {
32 prf?: number;
33 window_skip?: number;
34 window_length?: number;
35}
36
37interface BackendSchedulePeriod {
38 start: number;
39 end: number;
40}
41
42interface BackendPlatformComponentData {
43 name: string;
44 id?: string | number;
45 tx_id?: string | number;
46 rx_id?: string | number;
47 antenna?: string | number;
48 timing?: string | number;
49 waveform?: string | number;
50 noise_temp?: number | null;
51 nodirect?: boolean;
52 nopropagationloss?: boolean;
53 pulsed_mode?: BackendPulsedMode;
54 cw_mode?: object;
55 fmcw_mode?: Record<string, unknown>;
56 sfcw_mode?: object;
57 schedule?: BackendSchedulePeriod[];
58 rcs?: { type: 'isotropic' | 'file'; value?: number; filename?: string };
59 model?: { type: 'constant' | 'chisquare' | 'gamma'; k?: number };
60}
61
62interface BackendPositionWaypoint {
63 x: number;
64 y: number;
65 altitude: number;
66 time: number;
67}
68
69interface BackendRotationWaypoint {
70 azimuth: number;
71 elevation: number;
72 time: number;
73}
74
75interface BackendPlatform {
76 name: string;
77 id?: string | number;
78 motionpath?: {
79 interpolation: 'static' | 'linear' | 'cubic';
80 positionwaypoints?: BackendPositionWaypoint[];
81 };
82 fixedrotation?: {
83 startazimuth: number;
84 startelevation: number;
85 azimuthrate: number;
86 elevationrate: number;
87 };
88 rotationpath?: {
89 interpolation: 'static' | 'linear' | 'cubic';
90 rotationwaypoints?: BackendRotationWaypoint[];
91 };
92 components?: Record<string, BackendPlatformComponentData>[];
93}
94
95interface BackendWaveform {
96 name: string;
97 id?: string | number;
98 power: number;
99 carrier_frequency: number;
100 cw?: object;
101 pulsed_from_file?: {
102 filename: string;
103 };
104 cw_from_file?: {
105 filename: string;
106 };
107 fmcw_from_file?: {
108 filename: string;
109 };
110 fmcw_linear_chirp?: {
111 direction: 'up' | 'down';
112 chirp_bandwidth: number;
113 chirp_duration: number;
114 chirp_period: number;
115 start_frequency_offset?: number | null;
116 chirp_count?: number | null;
117 };
118 fmcw_triangle?: {
119 chirp_bandwidth: number;
120 chirp_duration: number;
121 start_frequency_offset?: number | null;
122 triangle_count?: number | null;
123 };
124 stepped_frequency?: {
125 start_frequency_offset: number;
126 step_size: number;
127 step_count: number;
128 dwell_time: number;
129 step_period: number;
130 sweep_count?: number | null;
131 };
132}
133
134type HydratedScenarioState = Pick<
135 ScenarioState,
136 | 'globalParameters'
137 | 'waveforms'
138 | 'timings'
139 | 'antennas'
140 | 'platforms'
141 | 'selectedItemId'
142 | 'selectedComponentId'
143 | 'isDirty'
144 | 'antennaPreviewErrors'
145 | 'currentTime'
146>;
147
148type HydrationOptions = {
149 isDirty: boolean;
150 preserveSelection?: boolean;
151 preserveCurrentTime?: boolean;
152};
153
154function hasScenarioItem(scenarioData: ScenarioData, itemId: string): boolean {
155 if (itemId === 'global-parameters') {
156 return true;
157 }
158
159 return [
160 ...scenarioData.waveforms,
161 ...scenarioData.timings,
162 ...scenarioData.antennas,
163 ...scenarioData.platforms,
164 ].some((item) => item.id === itemId);
165}
166
167function resolveSelection(
168 currentState: ScenarioState,
169 scenarioData: ScenarioData,
170 preserveSelection: boolean
171): Pick<HydratedScenarioState, 'selectedItemId' | 'selectedComponentId'> {
172 if (!preserveSelection) {
173 return {
174 selectedItemId: null,
175 selectedComponentId: null,
176 };
177 }
178
179 const { selectedItemId, selectedComponentId } = currentState;
180 if (selectedComponentId) {
181 for (const platform of scenarioData.platforms) {
182 const component = platform.components.find(
183 (candidate) => candidate.id === selectedComponentId
184 );
185 if (component) {
186 return {
187 selectedItemId: platform.id,
188 selectedComponentId: component.id,
189 };
190 }
191 }
192 }
193
194 if (selectedItemId && hasScenarioItem(scenarioData, selectedItemId)) {
195 return {
196 selectedItemId,
197 selectedComponentId: null,
198 };
199 }
200
201 return {
202 selectedItemId: null,
203 selectedComponentId: null,
204 };
205}
206
207function clampCurrentTime(
208 currentTime: number,
209 globalParameters: GlobalParameters
210): number {
211 return Math.max(
212 globalParameters.start,
213 Math.min(globalParameters.end, currentTime)
214 );
215}
216
217export function buildHydratedScenarioState(
218 currentState: ScenarioState,
219 scenarioData: ScenarioData,
220 options: HydrationOptions
221): HydratedScenarioState {
222 const selection = resolveSelection(
223 currentState,
224 scenarioData,
225 options.preserveSelection ?? false
226 );
227
228 return {
229 ...scenarioData,
230 ...selection,
231 isDirty: options.isDirty,
232 antennaPreviewErrors: {},
233 currentTime: options.preserveCurrentTime
234 ? clampCurrentTime(
235 currentState.currentTime,
236 scenarioData.globalParameters
237 )
238 : scenarioData.globalParameters.start,
239 };
240}
241
242export function parseScenarioData(backendData: unknown): ScenarioData | null {
243 try {
244 if (typeof backendData !== 'object' || backendData === null) {
245 console.error('Invalid backend data format: not an object.');
246 return null;
247 }
248
249 const data =
250 'simulation' in backendData &&
251 typeof (backendData as { simulation: unknown }).simulation ===
252 'object' &&
253 (backendData as { simulation: unknown }).simulation !== null
254 ? ((backendData as { simulation: object }).simulation as Record<
255 string,
256 unknown
257 >)
258 : (backendData as Record<string, unknown>);
259
260 const nameToIdMap = new Map<string, string>();
261 const normalizeIdOrNull = (value: unknown) => normalizeSimId(value);
262 const normalizeRefId = (value: unknown) => {
263 const id = normalizeIdOrNull(value);
264 if (!id || id === '0') return null;
265 return id;
266 };
267
268 const params = (data.parameters as Record<string, unknown>) || {};
269 const globalParameters: GlobalParameters = {
270 id: 'global-parameters',
271 type: 'GlobalParameters',
272 rotationAngleUnit:
273 (params.rotationangleunit as 'deg' | 'rad') ?? 'deg',
274 simulation_name: (data.name as string) || 'FERS Simulation',
275 start: (params.starttime as number) ?? 0.0,
276 end: (params.endtime as number) ?? 10.0,
277 rate: (params.rate as number) ?? 10000.0,
278 simSamplingRate: (params.simSamplingRate as number) ?? null,
279 c: (params.c as number) ?? 299792458.0,
280 random_seed: (params.randomseed as number) ?? null,
281 adc_bits: (params.adc_bits as number) ?? 12,
282 oversample_ratio: (params.oversample as number) ?? 1,
283 origin: {
284 latitude:
285 ((params.origin as Record<string, number>)
286 ?.latitude as number) ?? -33.957652,
287 longitude:
288 ((params.origin as Record<string, number>)
289 ?.longitude as number) ?? 18.4611991,
290 altitude:
291 ((params.origin as Record<string, number>)
292 ?.altitude as number) ?? 111.01,
293 },
294 coordinateSystem: {
295 frame:
296 ((params.coordinatesystem as Record<string, string>)
297 ?.frame as GlobalParameters['coordinateSystem']['frame']) ??
298 'ENU',
299 zone: (params.coordinatesystem as Record<string, number>)?.zone,
300 hemisphere: (
301 params.coordinatesystem as Record<string, 'N' | 'S'>
302 )?.hemisphere,
303 },
304 };
305
306 const waveforms: Waveform[] = (
307 (data.waveforms as BackendWaveform[]) || []
308 ).map((w) => {
309 const waveformId =
310 normalizeIdOrNull(w.id) ?? generateSimId('Waveform');
311 const commonWaveform = {
312 id: waveformId,
313 type: 'Waveform' as const,
314 name: w.name,
315 power: w.power,
316 carrier_frequency: w.carrier_frequency,
317 };
318 let waveform: Waveform;
319 if (w.fmcw_from_file) {
320 waveform = {
321 ...commonWaveform,
322 waveformType: 'fmcw_from_file',
323 filename: w.fmcw_from_file.filename ?? '',
324 };
325 } else if (w.fmcw_linear_chirp) {
326 waveform = {
327 ...commonWaveform,
328 waveformType: 'fmcw_linear_chirp',
329 direction: w.fmcw_linear_chirp.direction,
330 chirp_bandwidth: w.fmcw_linear_chirp.chirp_bandwidth,
331 chirp_duration: w.fmcw_linear_chirp.chirp_duration,
332 chirp_period: w.fmcw_linear_chirp.chirp_period,
333 start_frequency_offset:
334 w.fmcw_linear_chirp.start_frequency_offset ?? null,
335 chirp_count: w.fmcw_linear_chirp.chirp_count ?? null,
336 };
337 } else if (w.fmcw_triangle) {
338 waveform = {
339 ...commonWaveform,
340 waveformType: 'fmcw_triangle',
341 chirp_bandwidth: w.fmcw_triangle.chirp_bandwidth,
342 chirp_duration: w.fmcw_triangle.chirp_duration,
343 start_frequency_offset:
344 w.fmcw_triangle.start_frequency_offset ?? null,
345 triangle_count: w.fmcw_triangle.triangle_count ?? null,
346 };
347 } else if (w.stepped_frequency) {
348 waveform = {
349 ...commonWaveform,
350 waveformType: 'stepped_frequency',
351 start_frequency_offset:
352 w.stepped_frequency.start_frequency_offset,
353 step_size: w.stepped_frequency.step_size,
354 step_count: w.stepped_frequency.step_count,
355 dwell_time: w.stepped_frequency.dwell_time,
356 step_period: w.stepped_frequency.step_period,
357 sweep_count: w.stepped_frequency.sweep_count ?? null,
358 };
359 } else if (w.cw_from_file) {
360 waveform = {
361 ...commonWaveform,
362 waveformType: 'cw_from_file',
363 filename: w.cw_from_file.filename ?? '',
364 };
365 } else if (w.cw) {
366 waveform = {
367 ...commonWaveform,
368 waveformType: 'cw',
369 };
370 } else if (w.pulsed_from_file) {
371 waveform = {
372 ...commonWaveform,
373 waveformType: 'pulsed_from_file',
374 filename: w.pulsed_from_file.filename ?? '',
375 };
376 } else {
377 throw new Error(`Unsupported waveform type for '${w.name}'.`);
378 }
379 nameToIdMap.set(waveform.name, waveform.id);
380 reserveSimId(waveformId);
381 return waveform;
382 });
383
384 const timings: Timing[] = (
385 (data.timings as BackendObjectWithName[]) || []
386 ).map((t) => {
387 const timingId =
388 normalizeIdOrNull((t as { id?: unknown }).id) ??
389 generateSimId('Timing');
390 const timing = {
391 ...t,
392 id: timingId,
393 type: 'Timing' as const,
394 freqOffset: (t.freq_offset as number) ?? null,
395 randomFreqOffsetStdev:
396 (t.random_freq_offset_stdev as number) ?? null,
397 phaseOffset: (t.phase_offset as number) ?? null,
398 randomPhaseOffsetStdev:
399 (t.random_phase_offset_stdev as number) ?? null,
400 noiseEntries: Array.isArray(t.noise_entries)
401 ? t.noise_entries.map((item) => ({
402 ...(item as object),
403 id: generateSimId('Timing'),
404 }))
405 : [],
406 };
407 timing.noiseEntries.forEach((entry) => reserveSimId(entry.id));
408 nameToIdMap.set(timing.name, timing.id);
409 reserveSimId(timingId);
410 return timing as Timing;
411 });
412
413 const antennas: Antenna[] = (
414 (data.antennas as BackendObjectWithName[]) || []
415 ).map((a) => {
416 const antennaId =
417 normalizeIdOrNull((a as { id?: unknown }).id) ??
418 generateSimId('Antenna');
419 const antenna = {
420 ...a,
421 id: antennaId,
422 type: 'Antenna' as const,
423 };
424 nameToIdMap.set(antenna.name, antenna.id);
425 reserveSimId(antennaId);
426 return antenna as Antenna;
427 });
428
429 const platforms: Platform[] = (
430 (data.platforms as BackendPlatform[]) || []
431 ).map((p): Platform => {
432 const platformId =
433 normalizeIdOrNull(p.id) ?? generateSimId('Platform');
434 reserveSimId(platformId);
435
436 const motionPath: MotionPath = {
437 interpolation: p.motionpath?.interpolation ?? 'static',
438 waypoints: (p.motionpath?.positionwaypoints ?? []).map(
439 (item) => ({
440 ...item,
441 id: generateSimId('Platform'),
442 })
443 ),
444 };
445 motionPath.waypoints.forEach((wp) => reserveSimId(wp.id));
446
447 let rotation: FixedRotation | RotationPath;
448 if (p.fixedrotation) {
449 rotation = {
450 type: 'fixed',
451 startAzimuth: p.fixedrotation.startazimuth,
452 startElevation: p.fixedrotation.startelevation,
453 azimuthRate: p.fixedrotation.azimuthrate,
454 elevationRate: p.fixedrotation.elevationrate,
455 };
456 } else if (p.rotationpath) {
457 rotation = {
458 type: 'path',
459 interpolation: p.rotationpath.interpolation ?? 'static',
460 waypoints: (p.rotationpath.rotationwaypoints || []).map(
461 (item) => ({
462 ...item,
463 id: generateSimId('Platform'),
464 })
465 ),
466 };
467 rotation.waypoints.forEach((wp) => reserveSimId(wp.id));
468 } else {
469 rotation = createDefaultPlatform().rotation as
470 | FixedRotation
471 | RotationPath;
472 }
473
474 const components: PlatformComponent[] = [];
475
476 if (p.components && Array.isArray(p.components)) {
477 p.components.forEach((compWrapper) => {
478 const cType = Object.keys(compWrapper)[0];
479 const cData = compWrapper[cType];
480 const componentId =
481 normalizeIdOrNull(cData.id) ??
482 (cType === 'transmitter'
483 ? generateSimId('Transmitter')
484 : cType === 'receiver'
485 ? generateSimId('Receiver')
486 : cType === 'target'
487 ? generateSimId('Target')
488 : generateSimId('Transmitter'));
489 const txId =
490 normalizeIdOrNull(cData.tx_id) ??
491 (cType === 'monostatic'
492 ? generateSimId('Transmitter')
493 : null);
494 const rxId =
495 normalizeIdOrNull(cData.rx_id) ??
496 (cType === 'monostatic'
497 ? generateSimId('Receiver')
498 : null);
499
500 const radarType = cData.pulsed_mode
501 ? 'pulsed'
502 : cData.cw_mode
503 ? 'cw'
504 : cData.fmcw_mode
505 ? 'fmcw'
506 : cData.sfcw_mode
507 ? 'sfcw'
508 : 'pulsed';
509 const pulsed = cData.pulsed_mode;
510 const fmcwModeConfig =
511 radarType === 'fmcw' && cData.fmcw_mode
512 ? cData.fmcw_mode
513 : undefined;
514
515 const antennaId =
516 normalizeRefId(cData.antenna) ??
517 nameToIdMap.get(String(cData.antenna ?? '')) ??
518 null;
519 const timingId =
520 normalizeRefId(cData.timing) ??
521 nameToIdMap.get(String(cData.timing ?? '')) ??
522 null;
523 const waveformId =
524 normalizeRefId(cData.waveform) ??
525 nameToIdMap.get(String(cData.waveform ?? '')) ??
526 null;
527
528 const commonRadar = {
529 antennaId,
530 timingId,
531 schedule: cData.schedule ?? [],
532 };
533 const commonReceiver = {
534 noiseTemperature: cData.noise_temp ?? null,
535 noDirectPaths: cData.nodirect ?? false,
536 noPropagationLoss: cData.nopropagationloss ?? false,
537 };
538
539 let newComp: PlatformComponent | null = null;
540
541 switch (cType) {
542 case 'monostatic':
543 newComp = {
544 id: componentId,
545 type: 'monostatic',
546 txId: txId ?? componentId,
547 rxId: rxId ?? generateSimId('Receiver'),
548 name: cData.name,
549 radarType,
550 window_skip: pulsed?.window_skip ?? null,
551 window_length: pulsed?.window_length ?? null,
552 prf: pulsed?.prf ?? null,
553 waveformId,
554 fmcwModeConfig,
555 ...commonRadar,
556 ...commonReceiver,
557 };
558 break;
559 case 'transmitter':
560 newComp = {
561 id: componentId,
562 type: 'transmitter',
563 name: cData.name,
564 radarType,
565 prf: pulsed?.prf ?? null,
566 waveformId,
567 ...commonRadar,
568 };
569 break;
570 case 'receiver':
571 newComp = {
572 id: componentId,
573 type: 'receiver',
574 name: cData.name,
575 radarType,
576 window_skip: pulsed?.window_skip ?? null,
577 window_length: pulsed?.window_length ?? null,
578 prf: pulsed?.prf ?? null,
579 fmcwModeConfig,
580 ...commonRadar,
581 ...commonReceiver,
582 };
583 break;
584 case 'target':
585 newComp = {
586 id: componentId,
587 type: 'target',
588 name: cData.name,
589 rcs_type: cData.rcs?.type ?? 'isotropic',
590 rcs_value: cData.rcs?.value,
591 rcs_filename: cData.rcs?.filename,
592 rcs_model: cData.model?.type ?? 'constant',
593 rcs_k: cData.model?.k,
594 };
595 break;
596 }
597
598 if (newComp) {
599 components.push(newComp);
600 }
601 });
602 }
603
604 return {
605 id: platformId,
606 type: 'Platform',
607 name: p.name,
608 motionPath,
609 rotation,
610 components,
611 };
612 });
613
614 seedSimIdCounters([
615 ...waveforms.map((w) => w.id),
616 ...timings.map((t) => t.id),
617 ...antennas.map((a) => a.id),
618 ...platforms.map((p) => p.id),
619 ...platforms.flatMap((p) =>
620 p.components.flatMap((c) =>
621 c.type === 'monostatic' ? [c.id, c.txId, c.rxId] : [c.id]
622 )
623 ),
624 ]);
625
626 const transformedScenario: ScenarioData = {
627 globalParameters,
628 waveforms,
629 timings,
630 antennas,
631 platforms,
632 };
633
634 const result = ScenarioDataSchema.safeParse(transformedScenario);
635 if (!result.success) {
636 console.error(
637 'Data validation failed after loading from backend:',
638 result.error.flatten()
639 );
640 return null;
641 }
642
643 return result.data;
644 } catch (error) {
645 console.error(
646 'An unexpected error occurred while loading the scenario:',
647 error
648 );
649 return null;
650 }
651}