FERS 1.0.0
The Flexible Extensible Radar Simulator
Loading...
Searching...
No Matches
scenarioSlice.ts
Go to the documentation of this file.
1// SPDX-License-Identifier: GPL-2.0-only
2// Copyright (c) 2025-present FERS Contributors (see AUTHORS.md).
3
4import { StateCreator } from 'zustand';
5import { v4 as uuidv4 } from 'uuid';
6import {
7 ScenarioStore,
8 ScenarioActions,
9 GlobalParameters,
10 Waveform,
11 Timing,
12 Antenna,
13 Platform,
14 MotionPath,
15 FixedRotation,
16 RotationPath,
17 PlatformComponent,
18 ScenarioData,
19} from '../types';
20import { createDefaultPlatform, defaultGlobalParameters } from '../defaults';
21import { setPropertyByPath } from '../utils';
22import { ScenarioDataSchema } from '../../scenarioSchema';
23
24// Define interfaces for the expected backend data structure.
25interface BackendObjectWithName {
26 name: string;
27 [key: string]: unknown;
28}
29
30interface BackendPulsedMode {
31 prf?: number;
32 window_skip?: number;
33 window_length?: number;
34}
35
36interface BackendSchedulePeriod {
37 start: number;
38 end: number;
39}
40
41interface BackendPlatformComponentData {
42 name: string;
43 antenna?: string;
44 timing?: string;
45 waveform?: string;
46 noise_temp?: number | null;
47 nodirect?: boolean;
48 nopropagationloss?: boolean;
49 pulsed_mode?: BackendPulsedMode;
50 cw_mode?: object;
51 schedule?: BackendSchedulePeriod[];
52 rcs?: { type: 'isotropic' | 'file'; value?: number; filename?: string };
53 model?: { type: 'constant' | 'chisquare' | 'gamma'; k?: number };
54}
55
56// Backend waypoint types (frontend type minus 'id')
57interface BackendPositionWaypoint {
58 x: number;
59 y: number;
60 altitude: number;
61 time: number;
62}
63
64interface BackendRotationWaypoint {
65 azimuth: number;
66 elevation: number;
67 time: number;
68}
69
70interface BackendPlatform {
71 name: string;
72 motionpath?: {
73 interpolation: 'static' | 'linear' | 'cubic';
74 positionwaypoints?: BackendPositionWaypoint[];
75 };
76 fixedrotation?: {
77 startazimuth: number;
78 startelevation: number;
79 azimuthrate: number;
80 elevationrate: number;
81 };
82 rotationpath?: {
83 interpolation: 'static' | 'linear' | 'cubic';
84 rotationwaypoints?: BackendRotationWaypoint[];
85 };
86 components?: Record<string, BackendPlatformComponentData>[];
87}
88
89interface BackendWaveform {
90 name: string;
91 power: number;
92 carrier_frequency: number;
93 cw?: object;
94 pulsed_from_file?: {
95 filename: string;
96 };
97}
98
99export const createScenarioSlice: StateCreator<
100 ScenarioStore,
101 [['zustand/immer', never]],
102 [],
103 ScenarioActions
104> = (set) => ({
105 selectItem: (itemId) => set({ selectedItemId: itemId }),
106 updateItem: (itemId, propertyPath, value) =>
107 set((state) => {
108 if (itemId === 'global-parameters') {
109 setPropertyByPath(state.globalParameters, propertyPath, value);
110
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);
114 } else if (
115 propertyPath === 'end' &&
116 typeof value === 'number'
117 ) {
118 state.currentTime = Math.min(state.currentTime, value);
119 }
120
121 state.isDirty = true;
122 return;
123 }
124 const collections = [
125 'waveforms',
126 'timings',
127 'antennas',
128 'platforms',
129 ] as const;
130 for (const key of collections) {
131 const item = state[key].find(
132 (i: { id: string }) => i.id === itemId
133 );
134 if (item) {
135 setPropertyByPath(item, propertyPath, value);
136 state.isDirty = true;
137 return;
138 }
139 }
140 }),
141 removeItem: (itemId) =>
142 set((state) => {
143 if (!itemId) return;
144 const collections = [
145 'waveforms',
146 'timings',
147 'antennas',
148 'platforms',
149 ] as const;
150 for (const key of collections) {
151 const index = state[key].findIndex(
152 (item: { id: string }) => item.id === itemId
153 );
154 if (index > -1) {
155 state[key].splice(index, 1);
156 if (state.selectedItemId === itemId) {
157 state.selectedItemId = null;
158 }
159 state.isDirty = true;
160 return;
161 }
162 }
163 }),
164 resetScenario: () =>
165 set({
166 globalParameters: defaultGlobalParameters,
167 waveforms: [],
168 timings: [],
169 antennas: [],
170 platforms: [],
171 selectedItemId: null,
172 isDirty: false,
173 currentTime: defaultGlobalParameters.start,
174 }),
175 loadScenario: (backendData: unknown) => {
176 try {
177 if (typeof backendData !== 'object' || backendData === null) {
178 console.error('Invalid backend data format: not an object.');
179 return;
180 }
181
182 const data =
183 'simulation' in backendData &&
184 typeof (backendData as { simulation: unknown }).simulation ===
185 'object' &&
186 (backendData as { simulation: unknown }).simulation !== null
187 ? ((backendData as { simulation: object })
188 .simulation as Record<string, unknown>)
189 : (backendData as Record<string, unknown>);
190
191 const nameToIdMap = new Map<string, string>();
192 const assignId = <T extends object>(item: T) => ({
193 ...item,
194 id: uuidv4(),
195 });
196
197 // 1. Parameters
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,
211 origin: {
212 latitude:
213 ((params.origin as Record<string, number>)
214 ?.latitude as number) ?? -33.957652,
215 longitude:
216 ((params.origin as Record<string, number>)
217 ?.longitude as number) ?? 18.4611991,
218 altitude:
219 ((params.origin as Record<string, number>)
220 ?.altitude as number) ?? 111.01,
221 },
222 coordinateSystem: {
223 frame:
224 ((params.coordinatesystem as Record<string, string>)
225 ?.frame as GlobalParameters['coordinateSystem']['frame']) ??
226 'ENU',
227 zone: (params.coordinatesystem as Record<string, number>)
228 ?.zone,
229 hemisphere: (
230 params.coordinatesystem as Record<string, 'N' | 'S'>
231 )?.hemisphere,
232 },
233 };
234
235 // 2. Assets (and build name-to-id map)
236 const waveforms: Waveform[] = (
237 (data.waveforms as BackendWaveform[]) || []
238 ).map((w) => {
239 const waveformType = w.cw
240 ? ('cw' as const)
241 : ('pulsed_from_file' as const);
242 const filename = w.pulsed_from_file?.filename ?? '';
243
244 const waveform: Waveform = {
245 id: uuidv4(),
246 type: 'Waveform',
247 name: w.name,
248 waveformType,
249 power: w.power,
250 carrier_frequency: w.carrier_frequency,
251 filename,
252 };
253 nameToIdMap.set(waveform.name, waveform.id);
254 return waveform;
255 });
256
257 const timings: Timing[] = (
258 (data.timings as BackendObjectWithName[]) || []
259 ).map((t) => {
260 const timing = {
261 ...assignId(t),
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)
272 )
273 : [],
274 };
275 nameToIdMap.set(timing.name, timing.id);
276 return timing as Timing;
277 });
278
279 const antennas: Antenna[] = (
280 (data.antennas as BackendObjectWithName[]) || []
281 ).map((a) => {
282 const antenna = {
283 ...assignId(a),
284 type: 'Antenna' as const,
285 };
286 nameToIdMap.set(antenna.name, antenna.id);
287 return antenna as Antenna;
288 });
289
290 // 3. Platforms
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(
297 assignId
298 ),
299 };
300
301 let rotation: FixedRotation | RotationPath;
302 if (p.fixedrotation) {
303 rotation = {
304 type: 'fixed',
305 startAzimuth: p.fixedrotation.startazimuth,
306 startElevation: p.fixedrotation.startelevation,
307 azimuthRate: p.fixedrotation.azimuthrate,
308 elevationRate: p.fixedrotation.elevationrate,
309 };
310 } else if (p.rotationpath) {
311 rotation = {
312 type: 'path',
313 interpolation: p.rotationpath.interpolation ?? 'static',
314 waypoints: (p.rotationpath.rotationwaypoints || []).map(
315 assignId
316 ),
317 };
318 } else {
319 rotation = createDefaultPlatform().rotation as
320 | FixedRotation
321 | RotationPath;
322 }
323
324 const components: PlatformComponent[] = [];
325
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];
330 const id = uuidv4();
331
332 const radarType = cData.pulsed_mode
333 ? 'pulsed'
334 : cData.cw_mode
335 ? 'cw'
336 : 'pulsed';
337 const pulsed = cData.pulsed_mode;
338
339 const commonRadar = {
340 antennaId:
341 nameToIdMap.get(cData.antenna ?? '') ?? null,
342 timingId:
343 nameToIdMap.get(cData.timing ?? '') ?? null,
344 schedule: cData.schedule ?? [],
345 };
346 const commonReceiver = {
347 noiseTemperature: cData.noise_temp ?? null,
348 noDirectPaths: cData.nodirect ?? false,
349 noPropagationLoss: cData.nopropagationloss ?? false,
350 };
351
352 let newComp: PlatformComponent | null = null;
353
354 switch (cType) {
355 case 'monostatic':
356 newComp = {
357 id,
358 type: 'monostatic',
359 name: cData.name,
360 radarType,
361 window_skip: pulsed?.window_skip ?? null,
362 window_length:
363 pulsed?.window_length ?? null,
364 prf: pulsed?.prf ?? null,
365 waveformId:
366 nameToIdMap.get(cData.waveform ?? '') ??
367 null,
368 ...commonRadar,
369 ...commonReceiver,
370 };
371 break;
372 case 'transmitter':
373 newComp = {
374 id,
375 type: 'transmitter',
376 name: cData.name,
377 radarType,
378 prf: pulsed?.prf ?? null,
379 waveformId:
380 nameToIdMap.get(cData.waveform ?? '') ??
381 null,
382 ...commonRadar,
383 };
384 break;
385 case 'receiver':
386 newComp = {
387 id,
388 type: 'receiver',
389 name: cData.name,
390 radarType,
391 window_skip: pulsed?.window_skip ?? null,
392 window_length:
393 pulsed?.window_length ?? null,
394 prf: pulsed?.prf ?? null,
395 ...commonRadar,
396 ...commonReceiver,
397 };
398 break;
399 case 'target':
400 newComp = {
401 id,
402 type: 'target',
403 name: cData.name,
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,
409 };
410 break;
411 }
412
413 if (newComp) {
414 components.push(newComp);
415 }
416 });
417 }
418
419 return {
420 id: uuidv4(),
421 type: 'Platform',
422 name: p.name,
423 motionPath,
424 rotation,
425 components,
426 };
427 });
428
429 const transformedScenario: ScenarioData = {
430 globalParameters,
431 waveforms,
432 timings,
433 antennas,
434 platforms,
435 };
436
437 // --- Validate and Commit ---
438 const result = ScenarioDataSchema.safeParse(transformedScenario);
439
440 if (!result.success) {
441 console.error(
442 'Data validation failed after loading from backend:',
443 result.error.flatten()
444 );
445 return;
446 }
447
448 // Update state with the validated and parsed data.
449 set({
450 ...result.data,
451 selectedItemId: null,
452 isDirty: true,
453 currentTime: result.data.globalParameters.start,
454 });
455 } catch (error) {
456 console.error(
457 'An unexpected error occurred while loading the scenario:',
458 error
459 );
460 }
461 },
462});