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