1// SPDX-License-Identifier: GPL-2.0-only
2// Copyright (c) 2026-present FERS Contributors (see AUTHORS.md).
4import { omit } from '@/utils/typeUtils';
14type DistributiveOmit<T, K extends PropertyKey> = T extends unknown
15 ? Omit<T, Extract<keyof T, K>>
18type SerializedAntenna =
19 | DistributiveOmit<Antenna, 'type' | 'meshScale'>
22 name: Antenna['name'];
24 efficiency: Antenna['efficiency'];
27type TargetComponent = Extract<PlatformComponent, { type: 'target' }>;
30 * Recursively removes null or undefined values from an object or array.
32export const cleanObject = <T>(obj: T): T => {
33 if (Array.isArray(obj)) {
34 return obj.map(cleanObject) as unknown as T;
36 if (obj !== null && typeof obj === 'object') {
37 const newObj = {} as Record<string, unknown>;
38 for (const [key, value] of Object.entries(obj)) {
39 if (value !== null && value !== undefined) {
40 newObj[key] = cleanObject(value);
48const serializeReferenceId = (id: string | null | undefined): string | 0 =>
49 typeof id === 'string' && id.trim().length > 0 ? id : 0;
51const serializeTargetRcs = (component: TargetComponent) => {
52 const filename = component.rcs_filename;
54 component.rcs_type === 'file' &&
55 typeof filename === 'string' &&
56 filename.trim().length > 0
66 value: component.rcs_value ?? 1,
71 * Serializes the inner properties of a component (unwrapped).
72 * This is the exact format the C++ backend expects for granular updates.
74export const serializeComponentInner = (component: PlatformComponent) => {
76 if (!('radarType' in component)) {
80 switch (component.radarType) {
85 ...(component.type !== 'transmitter' && {
86 window_skip: component.window_skip,
87 window_length: component.window_length,
92 return { cw_mode: {} };
95 component.type === 'receiver' ||
96 component.type === 'monostatic'
98 return { fmcw_mode: component.fmcwModeConfig ?? {} };
100 return { fmcw_mode: {} };
102 return { sfcw_mode: {} };
106 switch (component.type) {
109 tx_id: component.txId,
110 rx_id: component.rxId,
111 name: component.name,
113 antenna: serializeReferenceId(component.antennaId),
114 waveform: serializeReferenceId(component.waveformId),
115 timing: serializeReferenceId(component.timingId),
116 noise_temp: component.noiseTemperature,
117 nodirect: component.noDirectPaths,
118 nopropagationloss: component.noPropagationLoss,
119 schedule: component.schedule,
124 name: component.name,
126 antenna: serializeReferenceId(component.antennaId),
127 waveform: serializeReferenceId(component.waveformId),
128 timing: serializeReferenceId(component.timingId),
129 schedule: component.schedule,
134 name: component.name,
136 antenna: serializeReferenceId(component.antennaId),
137 timing: serializeReferenceId(component.timingId),
138 noise_temp: component.noiseTemperature,
139 nodirect: component.noDirectPaths,
140 nopropagationloss: component.noPropagationLoss,
141 schedule: component.schedule,
144 const targetObj: Record<string, unknown> = {
146 name: component.name,
147 rcs: serializeTargetRcs(component),
149 if (component.rcs_model !== 'constant') {
151 type: component.rcs_model,
161 * Serializes a component wrapped in its type key (e.g., { transmitter: { ... } }).
162 * This is the format expected by the full scenario JSON array.
164export const serializeComponent = (component: PlatformComponent) => {
166 [component.type]: serializeComponentInner(component),
170export const serializePlatform = (p: Platform) => {
171 const { components, motionPath, rotation, ...rest } = p;
173 const backendComponents = components.map(serializeComponent);
175 const backendRotation: Record<string, unknown> = {};
176 if (rotation.type === 'fixed') {
177 const r = omit(rotation, 'type');
178 backendRotation.fixedrotation = {
179 interpolation: 'constant',
180 startazimuth: r.startAzimuth,
181 startelevation: r.startElevation,
182 azimuthrate: r.azimuthRate,
183 elevationrate: r.elevationRate,
186 const r = omit(rotation, 'type');
187 backendRotation.rotationpath = {
188 interpolation: r.interpolation,
189 rotationwaypoints: r.waypoints.map((wp) => omit(wp, 'id')),
197 interpolation: motionPath.interpolation,
198 positionwaypoints: motionPath.waypoints.map((wp) => omit(wp, 'id')),
201 components: backendComponents,
205export const serializeWaveform = (w: Waveform) => {
206 const waveformContent = (() => {
207 switch (w.waveformType) {
210 case 'pulsed_from_file':
211 return { pulsed_from_file: { filename: w.filename } };
213 return { cw_from_file: { filename: w.filename } };
214 case 'fmcw_from_file':
215 return { fmcw_from_file: { filename: w.filename } };
216 case 'fmcw_linear_chirp':
219 direction: w.direction,
220 chirp_bandwidth: w.chirp_bandwidth,
221 chirp_duration: w.chirp_duration,
222 chirp_period: w.chirp_period,
223 ...(w.start_frequency_offset
225 start_frequency_offset:
226 w.start_frequency_offset,
229 ...(w.chirp_count !== null
230 ? { chirp_count: w.chirp_count }
234 case 'fmcw_triangle':
237 chirp_bandwidth: w.chirp_bandwidth,
238 chirp_duration: w.chirp_duration,
239 ...(w.start_frequency_offset
241 start_frequency_offset:
242 w.start_frequency_offset,
245 ...(w.triangle_count !== null
246 ? { triangle_count: w.triangle_count }
250 case 'stepped_frequency':
253 start_frequency_offset: w.start_frequency_offset,
254 step_size: w.step_size,
255 step_count: w.step_count,
256 dwell_time: w.dwell_time,
257 step_period: w.step_period,
258 ...(w.sweep_count !== null
259 ? { sweep_count: w.sweep_count }
270 carrier_frequency: w.carrier_frequency,
275export const serializeTiming = (t: Timing) => {
276 const rest = omit(t, 'type');
280 freq_offset: t.freqOffset,
281 random_freq_offset_stdev: t.randomFreqOffsetStdev,
282 phase_offset: t.phaseOffset,
283 random_phase_offset_stdev: t.randomPhaseOffsetStdev,
284 noise_entries: t.noiseEntries.map((entry) => omit(entry, 'id')),
286 if (timingObj.noise_entries?.length === 0) {
287 delete (timingObj as Partial<typeof timingObj>).noise_entries;
289 return cleanObject(timingObj);
292export const isFileBackedAntennaPendingFile = (a: Antenna): boolean =>
293 (a.pattern === 'xml' || a.pattern === 'file') &&
294 (a.filename ?? '').trim().length === 0;
296export const serializeAntenna = (a: Antenna): SerializedAntenna => {
297 if (isFileBackedAntennaPendingFile(a)) {
301 pattern: 'isotropic',
302 efficiency: a.efficiency,
306 const rest = omit(a, 'type', 'meshScale');
307 return cleanObject(rest) as SerializedAntenna;
310export function deriveUtmZone(longitude: number): number {
311 if (!Number.isFinite(longitude)) {
314 const clampedLongitude = Math.max(-180, Math.min(180, longitude));
317 Math.min(60, Math.floor((clampedLongitude + 180) / 6) + 1)
321const serializeCoordinateSystem = (
323): GlobalParameters['coordinateSystem'] => {
324 if (gp.coordinateSystem.frame !== 'UTM') {
326 frame: gp.coordinateSystem.frame,
332 zone: gp.coordinateSystem.zone ?? deriveUtmZone(gp.origin.longitude),
334 gp.coordinateSystem.hemisphere ??
335 (gp.origin.latitude < 0 ? 'S' : 'N'),
339export const serializeGlobalParameters = (gp: GlobalParameters) => {
340 const { start, end, random_seed, oversample_ratio } = gp;
354 randomseed: random_seed,
355 oversample: oversample_ratio,
356 rotationangleunit: gp.rotationAngleUnit,
357 coordinatesystem: serializeCoordinateSystem(gp),