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 schedule?: BackendSchedulePeriod[];
56 rcs?: { type: 'isotropic' | 'file'; value?: number; filename?: string };
57 model?: { type: 'constant' | 'chisquare' | 'gamma'; k?: number };
60interface BackendPositionWaypoint {
67interface BackendRotationWaypoint {
73interface BackendPlatform {
77 interpolation: 'static' | 'linear' | 'cubic';
78 positionwaypoints?: BackendPositionWaypoint[];
82 startelevation: number;
84 elevationrate: number;
87 interpolation: 'static' | 'linear' | 'cubic';
88 rotationwaypoints?: BackendRotationWaypoint[];
90 components?: Record<string, BackendPlatformComponentData>[];
93interface BackendWaveform {
97 carrier_frequency: number;
104type HydratedScenarioState = Pick<
112 | 'selectedComponentId'
114 | 'antennaPreviewErrors'
118type HydrationOptions = {
120 preserveSelection?: boolean;
121 preserveCurrentTime?: boolean;
124function hasScenarioItem(scenarioData: ScenarioData, itemId: string): boolean {
125 if (itemId === 'global-parameters') {
130 ...scenarioData.waveforms,
131 ...scenarioData.timings,
132 ...scenarioData.antennas,
133 ...scenarioData.platforms,
134 ].some((item) => item.id === itemId);
137function resolveSelection(
138 currentState: ScenarioState,
139 scenarioData: ScenarioData,
140 preserveSelection: boolean
141): Pick<HydratedScenarioState, 'selectedItemId' | 'selectedComponentId'> {
142 if (!preserveSelection) {
144 selectedItemId: null,
145 selectedComponentId: null,
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
157 selectedItemId: platform.id,
158 selectedComponentId: component.id,
164 if (selectedItemId && hasScenarioItem(scenarioData, selectedItemId)) {
167 selectedComponentId: null,
172 selectedItemId: null,
173 selectedComponentId: null,
177function clampCurrentTime(
179 globalParameters: GlobalParameters
182 globalParameters.start,
183 Math.min(globalParameters.end, currentTime)
187export function buildHydratedScenarioState(
188 currentState: ScenarioState,
189 scenarioData: ScenarioData,
190 options: HydrationOptions
191): HydratedScenarioState {
192 const selection = resolveSelection(
195 options.preserveSelection ?? false
201 isDirty: options.isDirty,
202 antennaPreviewErrors: {},
203 currentTime: options.preserveCurrentTime
205 currentState.currentTime,
206 scenarioData.globalParameters
208 : scenarioData.globalParameters.start,
212export function parseScenarioData(backendData: unknown): ScenarioData | null {
214 if (typeof backendData !== 'object' || backendData === null) {
215 console.error('Invalid backend data format: not an object.');
220 'simulation' in backendData &&
221 typeof (backendData as { simulation: unknown }).simulation ===
223 (backendData as { simulation: unknown }).simulation !== null
224 ? ((backendData as { simulation: object }).simulation as Record<
228 : (backendData as Record<string, unknown>);
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;
238 const params = (data.parameters as Record<string, unknown>) || {};
239 const globalParameters: GlobalParameters = {
240 id: 'global-parameters',
241 type: 'GlobalParameters',
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,
255 ((params.origin as Record<string, number>)
256 ?.latitude as number) ?? -33.957652,
258 ((params.origin as Record<string, number>)
259 ?.longitude as number) ?? 18.4611991,
261 ((params.origin as Record<string, number>)
262 ?.altitude as number) ?? 111.01,
266 ((params.coordinatesystem as Record<string, string>)
267 ?.frame as GlobalParameters['coordinateSystem']['frame']) ??
269 zone: (params.coordinatesystem as Record<string, number>)?.zone,
271 params.coordinatesystem as Record<string, 'N' | 'S'>
276 const waveforms: Waveform[] = (
277 (data.waveforms as BackendWaveform[]) || []
279 const waveformType = w.cw
281 : ('pulsed_from_file' as const);
282 const filename = w.pulsed_from_file?.filename ?? '';
285 normalizeIdOrNull(w.id) ?? generateSimId('Waveform');
286 const waveform: Waveform = {
292 carrier_frequency: w.carrier_frequency,
295 nameToIdMap.set(waveform.name, waveform.id);
296 reserveSimId(waveformId);
300 const timings: Timing[] = (
301 (data.timings as BackendObjectWithName[]) || []
304 normalizeIdOrNull((t as { id?: unknown }).id) ??
305 generateSimId('Timing');
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) => ({
319 id: generateSimId('Timing'),
323 timing.noiseEntries.forEach((entry) => reserveSimId(entry.id));
324 nameToIdMap.set(timing.name, timing.id);
325 reserveSimId(timingId);
326 return timing as Timing;
329 const antennas: Antenna[] = (
330 (data.antennas as BackendObjectWithName[]) || []
333 normalizeIdOrNull((a as { id?: unknown }).id) ??
334 generateSimId('Antenna');
338 type: 'Antenna' as const,
340 nameToIdMap.set(antenna.name, antenna.id);
341 reserveSimId(antennaId);
342 return antenna as Antenna;
345 const platforms: Platform[] = (
346 (data.platforms as BackendPlatform[]) || []
347 ).map((p): Platform => {
349 normalizeIdOrNull(p.id) ?? generateSimId('Platform');
350 reserveSimId(platformId);
352 const motionPath: MotionPath = {
353 interpolation: p.motionpath?.interpolation ?? 'static',
354 waypoints: (p.motionpath?.positionwaypoints ?? []).map(
357 id: generateSimId('Platform'),
361 motionPath.waypoints.forEach((wp) => reserveSimId(wp.id));
363 let rotation: FixedRotation | RotationPath;
364 if (p.fixedrotation) {
367 startAzimuth: p.fixedrotation.startazimuth,
368 startElevation: p.fixedrotation.startelevation,
369 azimuthRate: p.fixedrotation.azimuthrate,
370 elevationRate: p.fixedrotation.elevationrate,
372 } else if (p.rotationpath) {
375 interpolation: p.rotationpath.interpolation ?? 'static',
376 waypoints: (p.rotationpath.rotationwaypoints || []).map(
379 id: generateSimId('Platform'),
383 rotation.waypoints.forEach((wp) => reserveSimId(wp.id));
385 rotation = createDefaultPlatform().rotation as
390 const components: PlatformComponent[] = [];
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];
397 normalizeIdOrNull(cData.id) ??
398 (cType === 'transmitter'
399 ? generateSimId('Transmitter')
400 : cType === 'receiver'
401 ? generateSimId('Receiver')
403 ? generateSimId('Target')
404 : generateSimId('Transmitter'));
406 normalizeIdOrNull(cData.tx_id) ??
407 (cType === 'monostatic'
408 ? generateSimId('Transmitter')
411 normalizeIdOrNull(cData.rx_id) ??
412 (cType === 'monostatic'
413 ? generateSimId('Receiver')
416 const radarType = cData.pulsed_mode
421 const pulsed = cData.pulsed_mode;
424 normalizeRefId(cData.antenna) ??
425 nameToIdMap.get(String(cData.antenna ?? '')) ??
428 normalizeRefId(cData.timing) ??
429 nameToIdMap.get(String(cData.timing ?? '')) ??
432 normalizeRefId(cData.waveform) ??
433 nameToIdMap.get(String(cData.waveform ?? '')) ??
436 const commonRadar = {
439 schedule: cData.schedule ?? [],
441 const commonReceiver = {
442 noiseTemperature: cData.noise_temp ?? null,
443 noDirectPaths: cData.nodirect ?? false,
444 noPropagationLoss: cData.nopropagationloss ?? false,
447 let newComp: PlatformComponent | null = null;
454 txId: txId ?? componentId,
455 rxId: rxId ?? generateSimId('Receiver'),
458 window_skip: pulsed?.window_skip ?? null,
459 window_length: pulsed?.window_length ?? null,
460 prf: pulsed?.prf ?? null,
472 prf: pulsed?.prf ?? null,
483 window_skip: pulsed?.window_skip ?? null,
484 window_length: pulsed?.window_length ?? null,
485 prf: pulsed?.prf ?? null,
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,
505 components.push(newComp);
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]
532 const transformedScenario: ScenarioData = {
540 const result = ScenarioDataSchema.safeParse(transformedScenario);
541 if (!result.success) {
543 'Data validation failed after loading from backend:',
544 result.error.flatten()
552 'An unexpected error occurred while loading the scenario:',