1// SPDX-License-Identifier: GPL-2.0-only
2// Copyright (c) 2026-present FERS Contributors (see AUTHORS.md).
4import { ScenarioDataSchema } from '../scenarioSchema';
5import { createDefaultPlatform } from './defaults';
26interface BackendObjectWithName {
28 [key: string]: unknown;
31interface BackendPulsedMode {
34 window_length?: number;
37interface BackendSchedulePeriod {
42interface BackendPlatformComponentData {
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;
52 nopropagationloss?: boolean;
53 pulsed_mode?: BackendPulsedMode;
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 };
61interface BackendPositionWaypoint {
68interface BackendRotationWaypoint {
74interface BackendPlatform {
78 interpolation: 'static' | 'linear' | 'cubic';
79 positionwaypoints?: BackendPositionWaypoint[];
83 startelevation: number;
85 elevationrate: number;
88 interpolation: 'static' | 'linear' | 'cubic';
89 rotationwaypoints?: BackendRotationWaypoint[];
91 components?: Record<string, BackendPlatformComponentData>[];
94interface BackendWaveform {
98 carrier_frequency: number;
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;
112 chirp_bandwidth: number;
113 chirp_duration: number;
114 start_frequency_offset?: number | null;
115 triangle_count?: number | null;
119type HydratedScenarioState = Pick<
127 | 'selectedComponentId'
129 | 'antennaPreviewErrors'
133type HydrationOptions = {
135 preserveSelection?: boolean;
136 preserveCurrentTime?: boolean;
139function hasScenarioItem(scenarioData: ScenarioData, itemId: string): boolean {
140 if (itemId === 'global-parameters') {
145 ...scenarioData.waveforms,
146 ...scenarioData.timings,
147 ...scenarioData.antennas,
148 ...scenarioData.platforms,
149 ].some((item) => item.id === itemId);
152function resolveSelection(
153 currentState: ScenarioState,
154 scenarioData: ScenarioData,
155 preserveSelection: boolean
156): Pick<HydratedScenarioState, 'selectedItemId' | 'selectedComponentId'> {
157 if (!preserveSelection) {
159 selectedItemId: null,
160 selectedComponentId: null,
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
172 selectedItemId: platform.id,
173 selectedComponentId: component.id,
179 if (selectedItemId && hasScenarioItem(scenarioData, selectedItemId)) {
182 selectedComponentId: null,
187 selectedItemId: null,
188 selectedComponentId: null,
192function clampCurrentTime(
194 globalParameters: GlobalParameters
197 globalParameters.start,
198 Math.min(globalParameters.end, currentTime)
202export function buildHydratedScenarioState(
203 currentState: ScenarioState,
204 scenarioData: ScenarioData,
205 options: HydrationOptions
206): HydratedScenarioState {
207 const selection = resolveSelection(
210 options.preserveSelection ?? false
216 isDirty: options.isDirty,
217 antennaPreviewErrors: {},
218 currentTime: options.preserveCurrentTime
220 currentState.currentTime,
221 scenarioData.globalParameters
223 : scenarioData.globalParameters.start,
227export function parseScenarioData(backendData: unknown): ScenarioData | null {
229 if (typeof backendData !== 'object' || backendData === null) {
230 console.error('Invalid backend data format: not an object.');
235 'simulation' in backendData &&
236 typeof (backendData as { simulation: unknown }).simulation ===
238 (backendData as { simulation: unknown }).simulation !== null
239 ? ((backendData as { simulation: object }).simulation as Record<
243 : (backendData as Record<string, unknown>);
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;
253 const params = (data.parameters as Record<string, unknown>) || {};
254 const globalParameters: GlobalParameters = {
255 id: 'global-parameters',
256 type: 'GlobalParameters',
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,
270 ((params.origin as Record<string, number>)
271 ?.latitude as number) ?? -33.957652,
273 ((params.origin as Record<string, number>)
274 ?.longitude as number) ?? 18.4611991,
276 ((params.origin as Record<string, number>)
277 ?.altitude as number) ?? 111.01,
281 ((params.coordinatesystem as Record<string, string>)
282 ?.frame as GlobalParameters['coordinateSystem']['frame']) ??
284 zone: (params.coordinatesystem as Record<string, number>)?.zone,
286 params.coordinatesystem as Record<string, 'N' | 'S'>
291 const waveforms: Waveform[] = (
292 (data.waveforms as BackendWaveform[]) || []
295 normalizeIdOrNull(w.id) ?? generateSimId('Waveform');
296 const commonWaveform = {
298 type: 'Waveform' as const,
301 carrier_frequency: w.carrier_frequency,
303 let waveform: Waveform;
304 if (w.fmcw_linear_chirp) {
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,
316 } else if (w.fmcw_triangle) {
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,
331 } else if (w.pulsed_from_file) {
334 waveformType: 'pulsed_from_file',
335 filename: w.pulsed_from_file.filename ?? '',
338 throw new Error(`Unsupported waveform type for '${w.name}'.`);
340 nameToIdMap.set(waveform.name, waveform.id);
341 reserveSimId(waveformId);
345 const timings: Timing[] = (
346 (data.timings as BackendObjectWithName[]) || []
349 normalizeIdOrNull((t as { id?: unknown }).id) ??
350 generateSimId('Timing');
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) => ({
364 id: generateSimId('Timing'),
368 timing.noiseEntries.forEach((entry) => reserveSimId(entry.id));
369 nameToIdMap.set(timing.name, timing.id);
370 reserveSimId(timingId);
371 return timing as Timing;
374 const antennas: Antenna[] = (
375 (data.antennas as BackendObjectWithName[]) || []
378 normalizeIdOrNull((a as { id?: unknown }).id) ??
379 generateSimId('Antenna');
383 type: 'Antenna' as const,
385 nameToIdMap.set(antenna.name, antenna.id);
386 reserveSimId(antennaId);
387 return antenna as Antenna;
390 const platforms: Platform[] = (
391 (data.platforms as BackendPlatform[]) || []
392 ).map((p): Platform => {
394 normalizeIdOrNull(p.id) ?? generateSimId('Platform');
395 reserveSimId(platformId);
397 const motionPath: MotionPath = {
398 interpolation: p.motionpath?.interpolation ?? 'static',
399 waypoints: (p.motionpath?.positionwaypoints ?? []).map(
402 id: generateSimId('Platform'),
406 motionPath.waypoints.forEach((wp) => reserveSimId(wp.id));
408 let rotation: FixedRotation | RotationPath;
409 if (p.fixedrotation) {
412 startAzimuth: p.fixedrotation.startazimuth,
413 startElevation: p.fixedrotation.startelevation,
414 azimuthRate: p.fixedrotation.azimuthrate,
415 elevationRate: p.fixedrotation.elevationrate,
417 } else if (p.rotationpath) {
420 interpolation: p.rotationpath.interpolation ?? 'static',
421 waypoints: (p.rotationpath.rotationwaypoints || []).map(
424 id: generateSimId('Platform'),
428 rotation.waypoints.forEach((wp) => reserveSimId(wp.id));
430 rotation = createDefaultPlatform().rotation as
435 const components: PlatformComponent[] = [];
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];
442 normalizeIdOrNull(cData.id) ??
443 (cType === 'transmitter'
444 ? generateSimId('Transmitter')
445 : cType === 'receiver'
446 ? generateSimId('Receiver')
448 ? generateSimId('Target')
449 : generateSimId('Transmitter'));
451 normalizeIdOrNull(cData.tx_id) ??
452 (cType === 'monostatic'
453 ? generateSimId('Transmitter')
456 normalizeIdOrNull(cData.rx_id) ??
457 (cType === 'monostatic'
458 ? generateSimId('Receiver')
461 const radarType = cData.pulsed_mode
468 const pulsed = cData.pulsed_mode;
469 const fmcwModeConfig =
470 radarType === 'fmcw' && cData.fmcw_mode
475 normalizeRefId(cData.antenna) ??
476 nameToIdMap.get(String(cData.antenna ?? '')) ??
479 normalizeRefId(cData.timing) ??
480 nameToIdMap.get(String(cData.timing ?? '')) ??
483 normalizeRefId(cData.waveform) ??
484 nameToIdMap.get(String(cData.waveform ?? '')) ??
487 const commonRadar = {
490 schedule: cData.schedule ?? [],
492 const commonReceiver = {
493 noiseTemperature: cData.noise_temp ?? null,
494 noDirectPaths: cData.nodirect ?? false,
495 noPropagationLoss: cData.nopropagationloss ?? false,
498 let newComp: PlatformComponent | null = null;
505 txId: txId ?? componentId,
506 rxId: rxId ?? generateSimId('Receiver'),
509 window_skip: pulsed?.window_skip ?? null,
510 window_length: pulsed?.window_length ?? null,
511 prf: pulsed?.prf ?? null,
524 prf: pulsed?.prf ?? null,
535 window_skip: pulsed?.window_skip ?? null,
536 window_length: pulsed?.window_length ?? null,
537 prf: pulsed?.prf ?? null,
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,
558 components.push(newComp);
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]
585 const transformedScenario: ScenarioData = {
593 const result = ScenarioDataSchema.safeParse(transformedScenario);
594 if (!result.success) {
596 'Data validation failed after loading from backend:',
597 result.error.flatten()
605 'An unexpected error occurred while loading the scenario:',