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