1// SPDX-License-Identifier: GPL-2.0-only
2// Copyright (c) 2025-present FERS Contributors (see AUTHORS.md).
4import { StateCreator } from 'zustand';
5import { v4 as uuidv4 } from 'uuid';
20import { createDefaultPlatform, defaultGlobalParameters } from '../defaults';
21import { setPropertyByPath } from '../utils';
22import { ScenarioDataSchema } from '../../scenarioSchema';
24// Define interfaces for the expected backend data structure.
25interface BackendObjectWithName {
27 [key: string]: unknown;
30interface BackendPulsedMode {
33 window_length?: number;
36interface BackendSchedulePeriod {
41interface BackendPlatformComponentData {
46 noise_temp?: number | null;
48 nopropagationloss?: boolean;
49 pulsed_mode?: BackendPulsedMode;
51 schedule?: BackendSchedulePeriod[];
52 rcs?: { type: 'isotropic' | 'file'; value?: number; filename?: string };
53 model?: { type: 'constant' | 'chisquare' | 'gamma'; k?: number };
56// Backend waypoint types (frontend type minus 'id')
57interface BackendPositionWaypoint {
64interface BackendRotationWaypoint {
70interface BackendPlatform {
73 interpolation: 'static' | 'linear' | 'cubic';
74 positionwaypoints?: BackendPositionWaypoint[];
78 startelevation: number;
80 elevationrate: number;
83 interpolation: 'static' | 'linear' | 'cubic';
84 rotationwaypoints?: BackendRotationWaypoint[];
86 components?: Record<string, BackendPlatformComponentData>[];
89interface BackendWaveform {
92 carrier_frequency: number;
99export const createScenarioSlice: StateCreator<
101 [['zustand/immer', never]],
105 selectItem: (itemId) => set({ selectedItemId: itemId }),
106 updateItem: (itemId, propertyPath, value) =>
108 if (itemId === 'global-parameters') {
109 setPropertyByPath(state.globalParameters, propertyPath, value);
111 // Ensure currentTime remains within valid bounds if start/end parameters are updated
112 if (propertyPath === 'start' && typeof value === 'number') {
113 state.currentTime = Math.max(state.currentTime, value);
115 propertyPath === 'end' &&
116 typeof value === 'number'
118 state.currentTime = Math.min(state.currentTime, value);
121 state.isDirty = true;
124 const collections = [
130 for (const key of collections) {
131 const item = state[key].find(
132 (i: { id: string }) => i.id === itemId
135 setPropertyByPath(item, propertyPath, value);
136 state.isDirty = true;
141 removeItem: (itemId) =>
144 const collections = [
150 for (const key of collections) {
151 const index = state[key].findIndex(
152 (item: { id: string }) => item.id === itemId
155 state[key].splice(index, 1);
156 if (state.selectedItemId === itemId) {
157 state.selectedItemId = null;
159 state.isDirty = true;
166 globalParameters: defaultGlobalParameters,
171 selectedItemId: null,
173 currentTime: defaultGlobalParameters.start,
175 loadScenario: (backendData: unknown) => {
177 if (typeof backendData !== 'object' || backendData === null) {
178 console.error('Invalid backend data format: not an object.');
183 'simulation' in backendData &&
184 typeof (backendData as { simulation: unknown }).simulation ===
186 (backendData as { simulation: unknown }).simulation !== null
187 ? ((backendData as { simulation: object })
188 .simulation as Record<string, unknown>)
189 : (backendData as Record<string, unknown>);
191 const nameToIdMap = new Map<string, string>();
192 const assignId = <T extends object>(item: T) => ({
198 const params = (data.parameters as Record<string, unknown>) || {};
199 const globalParameters: GlobalParameters = {
200 id: 'global-parameters',
201 type: 'GlobalParameters',
202 simulation_name: (data.name as string) || 'FERS Simulation',
203 start: (params.starttime as number) ?? 0.0,
204 end: (params.endtime as number) ?? 10.0,
205 rate: (params.rate as number) ?? 10000.0,
206 simSamplingRate: (params.simSamplingRate as number) ?? null,
207 c: (params.c as number) ?? 299792458.0,
208 random_seed: (params.randomseed as number) ?? null,
209 adc_bits: (params.adc_bits as number) ?? 12,
210 oversample_ratio: (params.oversample as number) ?? 1,
213 ((params.origin as Record<string, number>)
214 ?.latitude as number) ?? -33.957652,
216 ((params.origin as Record<string, number>)
217 ?.longitude as number) ?? 18.4611991,
219 ((params.origin as Record<string, number>)
220 ?.altitude as number) ?? 111.01,
224 ((params.coordinatesystem as Record<string, string>)
225 ?.frame as GlobalParameters['coordinateSystem']['frame']) ??
227 zone: (params.coordinatesystem as Record<string, number>)
230 params.coordinatesystem as Record<string, 'N' | 'S'>
235 // 2. Assets (and build name-to-id map)
236 const waveforms: Waveform[] = (
237 (data.waveforms as BackendWaveform[]) || []
239 const waveformType = w.cw
241 : ('pulsed_from_file' as const);
242 const filename = w.pulsed_from_file?.filename ?? '';
244 const waveform: Waveform = {
250 carrier_frequency: w.carrier_frequency,
253 nameToIdMap.set(waveform.name, waveform.id);
257 const timings: Timing[] = (
258 (data.timings as BackendObjectWithName[]) || []
262 type: 'Timing' as const,
263 freqOffset: (t.freq_offset as number) ?? null,
264 randomFreqOffsetStdev:
265 (t.random_freq_offset_stdev as number) ?? null,
266 phaseOffset: (t.phase_offset as number) ?? null,
267 randomPhaseOffsetStdev:
268 (t.random_phase_offset_stdev as number) ?? null,
269 noiseEntries: Array.isArray(t.noise_entries)
270 ? t.noise_entries.map((item) =>
271 assignId(item as object)
275 nameToIdMap.set(timing.name, timing.id);
276 return timing as Timing;
279 const antennas: Antenna[] = (
280 (data.antennas as BackendObjectWithName[]) || []
284 type: 'Antenna' as const,
286 nameToIdMap.set(antenna.name, antenna.id);
287 return antenna as Antenna;
291 const platforms: Platform[] = (
292 (data.platforms as BackendPlatform[]) || []
293 ).map((p): Platform => {
294 const motionPath: MotionPath = {
295 interpolation: p.motionpath?.interpolation ?? 'static',
296 waypoints: (p.motionpath?.positionwaypoints ?? []).map(
301 let rotation: FixedRotation | RotationPath;
302 if (p.fixedrotation) {
305 startAzimuth: p.fixedrotation.startazimuth,
306 startElevation: p.fixedrotation.startelevation,
307 azimuthRate: p.fixedrotation.azimuthrate,
308 elevationRate: p.fixedrotation.elevationrate,
310 } else if (p.rotationpath) {
313 interpolation: p.rotationpath.interpolation ?? 'static',
314 waypoints: (p.rotationpath.rotationwaypoints || []).map(
319 rotation = createDefaultPlatform().rotation as
324 const components: PlatformComponent[] = [];
326 if (p.components && Array.isArray(p.components)) {
327 p.components.forEach((compWrapper) => {
328 const cType = Object.keys(compWrapper)[0];
329 const cData = compWrapper[cType];
332 const radarType = cData.pulsed_mode
337 const pulsed = cData.pulsed_mode;
339 const commonRadar = {
341 nameToIdMap.get(cData.antenna ?? '') ?? null,
343 nameToIdMap.get(cData.timing ?? '') ?? null,
344 schedule: cData.schedule ?? [],
346 const commonReceiver = {
347 noiseTemperature: cData.noise_temp ?? null,
348 noDirectPaths: cData.nodirect ?? false,
349 noPropagationLoss: cData.nopropagationloss ?? false,
352 let newComp: PlatformComponent | null = null;
361 window_skip: pulsed?.window_skip ?? null,
363 pulsed?.window_length ?? null,
364 prf: pulsed?.prf ?? null,
366 nameToIdMap.get(cData.waveform ?? '') ??
378 prf: pulsed?.prf ?? null,
380 nameToIdMap.get(cData.waveform ?? '') ??
391 window_skip: pulsed?.window_skip ?? null,
393 pulsed?.window_length ?? null,
394 prf: pulsed?.prf ?? null,
404 rcs_type: cData.rcs?.type ?? 'isotropic',
405 rcs_value: cData.rcs?.value,
406 rcs_filename: cData.rcs?.filename,
407 rcs_model: cData.model?.type ?? 'constant',
408 rcs_k: cData.model?.k,
414 components.push(newComp);
429 const transformedScenario: ScenarioData = {
437 // --- Validate and Commit ---
438 const result = ScenarioDataSchema.safeParse(transformedScenario);
440 if (!result.success) {
442 'Data validation failed after loading from backend:',
443 result.error.flatten()
448 // Update state with the validated and parsed data.
451 selectedItemId: null,
453 currentTime: result.data.globalParameters.start,
457 'An unexpected error occurred while loading the scenario:',