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>;
57 schedule?: BackendSchedulePeriod[];
58 rcs?: { type: 'isotropic' | 'file'; value?: number; filename?: string };
59 model?: { type: 'constant' | 'chisquare' | 'gamma'; k?: number };
62interface BackendPositionWaypoint {
69interface BackendRotationWaypoint {
75interface BackendPlatform {
79 interpolation: 'static' | 'linear' | 'cubic';
80 positionwaypoints?: BackendPositionWaypoint[];
84 startelevation: number;
86 elevationrate: number;
89 interpolation: 'static' | 'linear' | 'cubic';
90 rotationwaypoints?: BackendRotationWaypoint[];
92 components?: Record<string, BackendPlatformComponentData>[];
95interface BackendWaveform {
99 carrier_frequency: number;
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;
119 chirp_bandwidth: number;
120 chirp_duration: number;
121 start_frequency_offset?: number | null;
122 triangle_count?: number | null;
124 stepped_frequency?: {
125 start_frequency_offset: number;
130 sweep_count?: number | null;
134type HydratedScenarioState = Pick<
142 | 'selectedComponentId'
144 | 'antennaPreviewErrors'
148type HydrationOptions = {
150 preserveSelection?: boolean;
151 preserveCurrentTime?: boolean;
154function hasScenarioItem(scenarioData: ScenarioData, itemId: string): boolean {
155 if (itemId === 'global-parameters') {
160 ...scenarioData.waveforms,
161 ...scenarioData.timings,
162 ...scenarioData.antennas,
163 ...scenarioData.platforms,
164 ].some((item) => item.id === itemId);
167function resolveSelection(
168 currentState: ScenarioState,
169 scenarioData: ScenarioData,
170 preserveSelection: boolean
171): Pick<HydratedScenarioState, 'selectedItemId' | 'selectedComponentId'> {
172 if (!preserveSelection) {
174 selectedItemId: null,
175 selectedComponentId: null,
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
187 selectedItemId: platform.id,
188 selectedComponentId: component.id,
194 if (selectedItemId && hasScenarioItem(scenarioData, selectedItemId)) {
197 selectedComponentId: null,
202 selectedItemId: null,
203 selectedComponentId: null,
207function clampCurrentTime(
209 globalParameters: GlobalParameters
212 globalParameters.start,
213 Math.min(globalParameters.end, currentTime)
217export function buildHydratedScenarioState(
218 currentState: ScenarioState,
219 scenarioData: ScenarioData,
220 options: HydrationOptions
221): HydratedScenarioState {
222 const selection = resolveSelection(
225 options.preserveSelection ?? false
231 isDirty: options.isDirty,
232 antennaPreviewErrors: {},
233 currentTime: options.preserveCurrentTime
235 currentState.currentTime,
236 scenarioData.globalParameters
238 : scenarioData.globalParameters.start,
242export function parseScenarioData(backendData: unknown): ScenarioData | null {
244 if (typeof backendData !== 'object' || backendData === null) {
245 console.error('Invalid backend data format: not an object.');
250 'simulation' in backendData &&
251 typeof (backendData as { simulation: unknown }).simulation ===
253 (backendData as { simulation: unknown }).simulation !== null
254 ? ((backendData as { simulation: object }).simulation as Record<
258 : (backendData as Record<string, unknown>);
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;
268 const params = (data.parameters as Record<string, unknown>) || {};
269 const globalParameters: GlobalParameters = {
270 id: 'global-parameters',
271 type: 'GlobalParameters',
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,
285 ((params.origin as Record<string, number>)
286 ?.latitude as number) ?? -33.957652,
288 ((params.origin as Record<string, number>)
289 ?.longitude as number) ?? 18.4611991,
291 ((params.origin as Record<string, number>)
292 ?.altitude as number) ?? 111.01,
296 ((params.coordinatesystem as Record<string, string>)
297 ?.frame as GlobalParameters['coordinateSystem']['frame']) ??
299 zone: (params.coordinatesystem as Record<string, number>)?.zone,
301 params.coordinatesystem as Record<string, 'N' | 'S'>
306 const waveforms: Waveform[] = (
307 (data.waveforms as BackendWaveform[]) || []
310 normalizeIdOrNull(w.id) ?? generateSimId('Waveform');
311 const commonWaveform = {
313 type: 'Waveform' as const,
316 carrier_frequency: w.carrier_frequency,
318 let waveform: Waveform;
319 if (w.fmcw_from_file) {
322 waveformType: 'fmcw_from_file',
323 filename: w.fmcw_from_file.filename ?? '',
325 } else if (w.fmcw_linear_chirp) {
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,
337 } else if (w.fmcw_triangle) {
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,
347 } else if (w.stepped_frequency) {
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,
359 } else if (w.cw_from_file) {
362 waveformType: 'cw_from_file',
363 filename: w.cw_from_file.filename ?? '',
370 } else if (w.pulsed_from_file) {
373 waveformType: 'pulsed_from_file',
374 filename: w.pulsed_from_file.filename ?? '',
377 throw new Error(`Unsupported waveform type for '${w.name}'.`);
379 nameToIdMap.set(waveform.name, waveform.id);
380 reserveSimId(waveformId);
384 const timings: Timing[] = (
385 (data.timings as BackendObjectWithName[]) || []
388 normalizeIdOrNull((t as { id?: unknown }).id) ??
389 generateSimId('Timing');
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) => ({
403 id: generateSimId('Timing'),
407 timing.noiseEntries.forEach((entry) => reserveSimId(entry.id));
408 nameToIdMap.set(timing.name, timing.id);
409 reserveSimId(timingId);
410 return timing as Timing;
413 const antennas: Antenna[] = (
414 (data.antennas as BackendObjectWithName[]) || []
417 normalizeIdOrNull((a as { id?: unknown }).id) ??
418 generateSimId('Antenna');
422 type: 'Antenna' as const,
424 nameToIdMap.set(antenna.name, antenna.id);
425 reserveSimId(antennaId);
426 return antenna as Antenna;
429 const platforms: Platform[] = (
430 (data.platforms as BackendPlatform[]) || []
431 ).map((p): Platform => {
433 normalizeIdOrNull(p.id) ?? generateSimId('Platform');
434 reserveSimId(platformId);
436 const motionPath: MotionPath = {
437 interpolation: p.motionpath?.interpolation ?? 'static',
438 waypoints: (p.motionpath?.positionwaypoints ?? []).map(
441 id: generateSimId('Platform'),
445 motionPath.waypoints.forEach((wp) => reserveSimId(wp.id));
447 let rotation: FixedRotation | RotationPath;
448 if (p.fixedrotation) {
451 startAzimuth: p.fixedrotation.startazimuth,
452 startElevation: p.fixedrotation.startelevation,
453 azimuthRate: p.fixedrotation.azimuthrate,
454 elevationRate: p.fixedrotation.elevationrate,
456 } else if (p.rotationpath) {
459 interpolation: p.rotationpath.interpolation ?? 'static',
460 waypoints: (p.rotationpath.rotationwaypoints || []).map(
463 id: generateSimId('Platform'),
467 rotation.waypoints.forEach((wp) => reserveSimId(wp.id));
469 rotation = createDefaultPlatform().rotation as
474 const components: PlatformComponent[] = [];
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];
481 normalizeIdOrNull(cData.id) ??
482 (cType === 'transmitter'
483 ? generateSimId('Transmitter')
484 : cType === 'receiver'
485 ? generateSimId('Receiver')
487 ? generateSimId('Target')
488 : generateSimId('Transmitter'));
490 normalizeIdOrNull(cData.tx_id) ??
491 (cType === 'monostatic'
492 ? generateSimId('Transmitter')
495 normalizeIdOrNull(cData.rx_id) ??
496 (cType === 'monostatic'
497 ? generateSimId('Receiver')
500 const radarType = cData.pulsed_mode
509 const pulsed = cData.pulsed_mode;
510 const fmcwModeConfig =
511 radarType === 'fmcw' && cData.fmcw_mode
516 normalizeRefId(cData.antenna) ??
517 nameToIdMap.get(String(cData.antenna ?? '')) ??
520 normalizeRefId(cData.timing) ??
521 nameToIdMap.get(String(cData.timing ?? '')) ??
524 normalizeRefId(cData.waveform) ??
525 nameToIdMap.get(String(cData.waveform ?? '')) ??
528 const commonRadar = {
531 schedule: cData.schedule ?? [],
533 const commonReceiver = {
534 noiseTemperature: cData.noise_temp ?? null,
535 noDirectPaths: cData.nodirect ?? false,
536 noPropagationLoss: cData.nopropagationloss ?? false,
539 let newComp: PlatformComponent | null = null;
546 txId: txId ?? componentId,
547 rxId: rxId ?? generateSimId('Receiver'),
550 window_skip: pulsed?.window_skip ?? null,
551 window_length: pulsed?.window_length ?? null,
552 prf: pulsed?.prf ?? null,
565 prf: pulsed?.prf ?? null,
576 window_skip: pulsed?.window_skip ?? null,
577 window_length: pulsed?.window_length ?? null,
578 prf: pulsed?.prf ?? null,
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,
599 components.push(newComp);
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]
626 const transformedScenario: ScenarioData = {
634 const result = ScenarioDataSchema.safeParse(transformedScenario);
635 if (!result.success) {
637 'Data validation failed after loading from backend:',
638 result.error.flatten()
646 'An unexpected error occurred while loading the scenario:',