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: {} };
104 switch (component.type) {
107 tx_id: component.txId,
108 rx_id: component.rxId,
109 name: component.name,
111 antenna: serializeReferenceId(component.antennaId),
112 waveform: serializeReferenceId(component.waveformId),
113 timing: serializeReferenceId(component.timingId),
114 noise_temp: component.noiseTemperature,
115 nodirect: component.noDirectPaths,
116 nopropagationloss: component.noPropagationLoss,
117 schedule: component.schedule,
122 name: component.name,
124 antenna: serializeReferenceId(component.antennaId),
125 waveform: serializeReferenceId(component.waveformId),
126 timing: serializeReferenceId(component.timingId),
127 schedule: component.schedule,
132 name: component.name,
134 antenna: serializeReferenceId(component.antennaId),
135 timing: serializeReferenceId(component.timingId),
136 noise_temp: component.noiseTemperature,
137 nodirect: component.noDirectPaths,
138 nopropagationloss: component.noPropagationLoss,
139 schedule: component.schedule,
142 const targetObj: Record<string, unknown> = {
144 name: component.name,
145 rcs: serializeTargetRcs(component),
147 if (component.rcs_model !== 'constant') {
149 type: component.rcs_model,
159 * Serializes a component wrapped in its type key (e.g., { transmitter: { ... } }).
160 * This is the format expected by the full scenario JSON array.
162export const serializeComponent = (component: PlatformComponent) => {
164 [component.type]: serializeComponentInner(component),
168export const serializePlatform = (p: Platform) => {
169 const { components, motionPath, rotation, ...rest } = p;
171 const backendComponents = components.map(serializeComponent);
173 const backendRotation: Record<string, unknown> = {};
174 if (rotation.type === 'fixed') {
175 const r = omit(rotation, 'type');
176 backendRotation.fixedrotation = {
177 interpolation: 'constant',
178 startazimuth: r.startAzimuth,
179 startelevation: r.startElevation,
180 azimuthrate: r.azimuthRate,
181 elevationrate: r.elevationRate,
184 const r = omit(rotation, 'type');
185 backendRotation.rotationpath = {
186 interpolation: r.interpolation,
187 rotationwaypoints: r.waypoints.map((wp) => omit(wp, 'id')),
195 interpolation: motionPath.interpolation,
196 positionwaypoints: motionPath.waypoints.map((wp) => omit(wp, 'id')),
199 components: backendComponents,
203export const serializeWaveform = (w: Waveform) => {
204 const waveformContent = (() => {
205 switch (w.waveformType) {
208 case 'pulsed_from_file':
209 return { pulsed_from_file: { filename: w.filename } };
210 case 'fmcw_linear_chirp':
213 direction: w.direction,
214 chirp_bandwidth: w.chirp_bandwidth,
215 chirp_duration: w.chirp_duration,
216 chirp_period: w.chirp_period,
217 ...(w.start_frequency_offset
219 start_frequency_offset:
220 w.start_frequency_offset,
223 ...(w.chirp_count !== null
224 ? { chirp_count: w.chirp_count }
228 case 'fmcw_triangle':
231 chirp_bandwidth: w.chirp_bandwidth,
232 chirp_duration: w.chirp_duration,
233 ...(w.start_frequency_offset
235 start_frequency_offset:
236 w.start_frequency_offset,
239 ...(w.triangle_count !== null
240 ? { triangle_count: w.triangle_count }
251 carrier_frequency: w.carrier_frequency,
256export const serializeTiming = (t: Timing) => {
257 const rest = omit(t, 'type');
261 freq_offset: t.freqOffset,
262 random_freq_offset_stdev: t.randomFreqOffsetStdev,
263 phase_offset: t.phaseOffset,
264 random_phase_offset_stdev: t.randomPhaseOffsetStdev,
265 noise_entries: t.noiseEntries.map((entry) => omit(entry, 'id')),
267 if (timingObj.noise_entries?.length === 0) {
268 delete (timingObj as Partial<typeof timingObj>).noise_entries;
270 return cleanObject(timingObj);
273export const isFileBackedAntennaPendingFile = (a: Antenna): boolean =>
274 (a.pattern === 'xml' || a.pattern === 'file') &&
275 (a.filename ?? '').trim().length === 0;
277export const serializeAntenna = (a: Antenna): SerializedAntenna => {
278 if (isFileBackedAntennaPendingFile(a)) {
282 pattern: 'isotropic',
283 efficiency: a.efficiency,
287 const rest = omit(a, 'type', 'meshScale');
288 return cleanObject(rest) as SerializedAntenna;
291export function deriveUtmZone(longitude: number): number {
292 if (!Number.isFinite(longitude)) {
295 const clampedLongitude = Math.max(-180, Math.min(180, longitude));
298 Math.min(60, Math.floor((clampedLongitude + 180) / 6) + 1)
302const serializeCoordinateSystem = (
304): GlobalParameters['coordinateSystem'] => {
305 if (gp.coordinateSystem.frame !== 'UTM') {
307 frame: gp.coordinateSystem.frame,
313 zone: gp.coordinateSystem.zone ?? deriveUtmZone(gp.origin.longitude),
315 gp.coordinateSystem.hemisphere ??
316 (gp.origin.latitude < 0 ? 'S' : 'N'),
320export const serializeGlobalParameters = (gp: GlobalParameters) => {
321 const { start, end, random_seed, oversample_ratio } = gp;
335 randomseed: random_seed,
336 oversample: oversample_ratio,
337 rotationangleunit: gp.rotationAngleUnit,
338 coordinatesystem: serializeCoordinateSystem(gp),