FERS 0.1.0
The Flexible Extensible Radar Simulator
Loading...
Searching...
No Matches
scenarioSchema.ts
Go to the documentation of this file.
1import { z } from 'zod';
2
3// Base numeric type for zod schemas - handles empty strings from forms
4const nullableNumber = z.preprocess(
5 (val) => (val === '' ? null : val),
6 z.number().nullable()
7);
8
9// --- SCHEMA DEFINITIONS ---
10
11export const GlobalParametersSchema = z.object({
12 id: z.literal('global-parameters'),
13 type: z.literal('GlobalParameters'),
14 rotationAngleUnit: z.enum(['deg', 'rad']),
15 simulation_name: z.string().min(1, 'Simulation name cannot be empty.'),
16 start: z.number(),
17 end: z.number(),
18 rate: z.number().positive('Rate must be positive.'),
19 simSamplingRate: nullableNumber.refine((val) => val === null || val > 0, {
20 message: 'Sim Sampling Rate must be positive if specified.',
21 }),
22 c: z.number().positive('Speed of light must be positive.'),
23 random_seed: nullableNumber.pipe(z.number().int().nullable()),
24 adc_bits: z.number().int().min(0, 'ADC bits cannot be negative.'),
25 oversample_ratio: z
26 .number()
27 .int()
28 .min(1, 'Oversample ratio must be at least 1.'),
29 origin: z.object({
30 latitude: z.number().min(-90).max(90),
31 longitude: z.number().min(-180).max(180),
32 altitude: z.number(),
33 }),
34 coordinateSystem: z
35 .object({
36 frame: z.enum(['ENU', 'UTM', 'ECEF']),
37 zone: z.number().int().optional(),
38 hemisphere: z.enum(['N', 'S']).optional(),
39 })
40 .refine(
41 (data) => {
42 if (data.frame === 'UTM') {
43 return (
44 data.zone !== undefined && data.hemisphere !== undefined
45 );
46 }
47 return true;
48 },
49 { message: 'UTM frame requires a zone and hemisphere.' }
50 ),
51});
52
53const SimIdSchema = z.string().regex(/^\d+$/, 'ID must be a numeric string.');
54
55const BaseWaveformSchema = z.object({
56 id: SimIdSchema,
57 type: z.literal('Waveform'),
58 name: z.string().min(1, 'Waveform name cannot be empty.'),
59 power: z.number().min(0, 'Power cannot be negative.'),
60 carrier_frequency: z
61 .number()
62 .positive('Carrier frequency must be positive.'),
63});
64
65export const WaveformSchema = z
66 .discriminatedUnion('waveformType', [
67 BaseWaveformSchema.extend({
68 waveformType: z.literal('pulsed_from_file'),
69 filename: z
70 .string()
71 .min(1, 'A filename is required for this waveform type.'),
72 }),
73 BaseWaveformSchema.extend({
74 waveformType: z.literal('cw_from_file'),
75 filename: z
76 .string()
77 .min(1, 'A filename is required for this waveform type.')
78 .regex(
79 /\.h5$/i,
80 'CW file waveforms require an HDF5 (.h5) file.'
81 ),
82 }),
83 BaseWaveformSchema.extend({
84 waveformType: z.literal('fmcw_from_file'),
85 filename: z
86 .string()
87 .min(1, 'A filename is required for this waveform type.')
88 .regex(
89 /\.h5$/i,
90 'FMCW file waveforms require an HDF5 (.h5) file.'
91 ),
92 }),
93 BaseWaveformSchema.extend({
94 waveformType: z.literal('cw'),
95 }),
96 BaseWaveformSchema.extend({
97 waveformType: z.literal('fmcw_linear_chirp'),
98 direction: z.enum(['up', 'down']),
99 chirp_bandwidth: z
100 .number()
101 .positive('Chirp bandwidth must be positive.'),
102 chirp_duration: z
103 .number()
104 .positive('Chirp duration must be positive.'),
105 chirp_period: z.number().positive('Chirp period must be positive.'),
106 start_frequency_offset: nullableNumber.pipe(
107 z.number().finite().nullable()
108 ),
109 chirp_count: nullableNumber.pipe(
110 z.number().int().positive().nullable()
111 ),
112 }),
113 BaseWaveformSchema.extend({
114 waveformType: z.literal('fmcw_triangle'),
115 chirp_bandwidth: z
116 .number()
117 .positive('Chirp bandwidth must be positive.'),
118 chirp_duration: z
119 .number()
120 .positive('Chirp duration must be positive.'),
121 start_frequency_offset: nullableNumber.pipe(
122 z.number().finite().nullable()
123 ),
124 triangle_count: nullableNumber.pipe(
125 z.number().int().positive().nullable()
126 ),
127 }),
128 BaseWaveformSchema.extend({
129 waveformType: z.literal('stepped_frequency'),
130 start_frequency_offset: z.number().finite(),
131 step_size: z
132 .number()
133 .finite()
134 .refine((val) => val !== 0, {
135 message: 'Step size cannot be zero.',
136 }),
137 step_count: z
138 .number()
139 .int()
140 .positive('Step count must be positive.'),
141 dwell_time: z.number().positive('Dwell time must be positive.'),
142 step_period: z.number().positive('Step period must be positive.'),
143 sweep_count: nullableNumber.pipe(
144 z.number().int().positive().nullable()
145 ),
146 }),
147 ])
148 .superRefine((data, ctx) => {
149 if (
150 data.waveformType === 'fmcw_linear_chirp' &&
151 data.chirp_period < data.chirp_duration
152 ) {
153 ctx.addIssue({
154 code: 'custom',
155 message:
156 'Chirp period must be greater than or equal to chirp duration.',
157 path: ['chirp_period'],
158 });
159 }
160 if (
161 data.waveformType === 'stepped_frequency' &&
162 data.step_period < data.dwell_time
163 ) {
164 ctx.addIssue({
165 code: 'custom',
166 message:
167 'Step period must be greater than or equal to dwell time.',
168 path: ['step_period'],
169 });
170 }
171 });
172
173export const NoiseEntrySchema = z.object({
174 id: SimIdSchema,
175 alpha: z.number(),
176 weight: z.number(),
177});
178
179export const TimingSchema = z.object({
180 id: SimIdSchema,
181 type: z.literal('Timing'),
182 name: z.string().min(1, 'Timing name cannot be empty.'),
183 frequency: z.number().positive('Frequency must be positive.'),
184 freqOffset: nullableNumber,
185 randomFreqOffsetStdev: nullableNumber.pipe(z.number().min(0).nullable()),
186 phaseOffset: nullableNumber,
187 randomPhaseOffsetStdev: nullableNumber.pipe(z.number().min(0).nullable()),
188 noiseEntries: z.array(NoiseEntrySchema),
189});
190
191const BaseAntennaSchema = z.object({
192 id: SimIdSchema,
193 type: z.literal('Antenna'),
194 name: z.string().min(1, 'Antenna name cannot be empty.'),
195 efficiency: nullableNumber.pipe(z.number().min(0).max(1).nullable()),
196 meshScale: nullableNumber.pipe(z.number().positive().nullable()).optional(),
197 design_frequency: nullableNumber
198 .pipe(z.number().positive().nullable())
199 .optional(),
200});
201
202export const AntennaSchema = z.discriminatedUnion('pattern', [
203 BaseAntennaSchema.extend({ pattern: z.literal('isotropic') }),
204 BaseAntennaSchema.extend({
205 pattern: z.literal('sinc'),
206 alpha: nullableNumber.pipe(z.number().nullable()),
207 beta: nullableNumber.pipe(z.number().nullable()),
208 gamma: nullableNumber.pipe(z.number().nullable()),
209 }),
210 BaseAntennaSchema.extend({
211 pattern: z.literal('gaussian'),
212 azscale: nullableNumber.pipe(z.number().nullable()),
213 elscale: nullableNumber.pipe(z.number().nullable()),
214 }),
215 BaseAntennaSchema.extend({
216 pattern: z.literal('squarehorn'),
217 diameter: nullableNumber.pipe(z.number().positive().nullable()),
218 }),
219 BaseAntennaSchema.extend({
220 pattern: z.literal('parabolic'),
221 diameter: nullableNumber.pipe(z.number().positive().nullable()),
222 }),
223 BaseAntennaSchema.extend({
224 pattern: z.literal('xml'),
225 filename: z
226 .string()
227 .min(1, 'Filename is required for XML pattern.')
228 .optional(),
229 }),
230 BaseAntennaSchema.extend({
231 pattern: z.literal('file'),
232 filename: z
233 .string()
234 .min(1, 'Filename is required for file pattern.')
235 .optional(),
236 }),
237]);
238
239export const PositionWaypointSchema = z.object({
240 id: SimIdSchema,
241 x: z.number(),
242 y: z.number(),
243 altitude: z.number(),
244 time: z.number().min(0, 'Time cannot be negative.'),
245});
246
247export const MotionPathSchema = z.object({
248 interpolation: z.enum(['static', 'linear', 'cubic']),
249 waypoints: z
250 .array(PositionWaypointSchema)
251 .min(1, 'At least one waypoint is required.'),
252});
253
254export const FixedRotationSchema = z.object({
255 type: z.literal('fixed'),
256 startAzimuth: z.number(),
257 startElevation: z.number(),
258 azimuthRate: z.number(),
259 elevationRate: z.number(),
260});
261
262export const RotationWaypointSchema = z.object({
263 id: SimIdSchema,
264 azimuth: z.number(),
265 elevation: z.number(),
266 time: z.number().min(0, 'Time cannot be negative.'),
267});
268
269export const RotationPathSchema = z.object({
270 type: z.literal('path'),
271 interpolation: z.enum(['static', 'linear', 'cubic']),
272 waypoints: z
273 .array(RotationWaypointSchema)
274 .min(1, 'At least one waypoint is required.'),
275});
276
277export const SchedulePeriodSchema = z.object({
278 start: z.number().min(0, 'Start time cannot be negative.'),
279 end: z.number().min(0, 'End time cannot be negative.'),
280});
281
282const DechirpReferenceSchema = z.object({
283 source: z.enum(['attached', 'transmitter', 'custom']),
284 transmitter_name: z.string().optional(),
285 waveform_name: z.string().optional(),
286});
287
288const FmcwModeConfigSchema = z
289 .object({
290 dechirp_mode: z.enum(['none', 'physical', 'ideal']).optional(),
291 dechirp_reference: DechirpReferenceSchema.optional(),
292 if_sample_rate: z.number().optional(),
293 if_filter_bandwidth: z.number().optional(),
294 if_filter_transition_width: z.number().optional(),
295 })
296 .optional();
297
298const MonostaticComponentSchema = z.object({
299 id: SimIdSchema,
300 type: z.literal('monostatic'),
301 name: z.string().min(1),
302 txId: SimIdSchema,
303 rxId: SimIdSchema,
304 radarType: z.enum(['pulsed', 'cw', 'fmcw', 'sfcw']),
305 window_skip: nullableNumber,
306 window_length: nullableNumber,
307 prf: nullableNumber,
308 antennaId: SimIdSchema.nullable(),
309 waveformId: SimIdSchema.nullable(),
310 timingId: SimIdSchema.nullable(),
311 noiseTemperature: nullableNumber.pipe(z.number().min(0).nullable()),
312 noDirectPaths: z.boolean(),
313 noPropagationLoss: z.boolean(),
314 fmcwModeConfig: FmcwModeConfigSchema,
315 schedule: z.array(SchedulePeriodSchema).default([]),
316});
317
318const TransmitterComponentSchema = z.object({
319 id: SimIdSchema,
320 type: z.literal('transmitter'),
321 name: z.string().min(1),
322 radarType: z.enum(['pulsed', 'cw', 'fmcw', 'sfcw']),
323 prf: nullableNumber,
324 antennaId: SimIdSchema.nullable(),
325 waveformId: SimIdSchema.nullable(),
326 timingId: SimIdSchema.nullable(),
327 schedule: z.array(SchedulePeriodSchema).default([]),
328});
329
330const ReceiverComponentSchema = z.object({
331 id: SimIdSchema,
332 type: z.literal('receiver'),
333 name: z.string().min(1),
334 radarType: z.enum(['pulsed', 'cw', 'fmcw', 'sfcw']),
335 window_skip: nullableNumber,
336 window_length: nullableNumber,
337 prf: nullableNumber,
338 antennaId: SimIdSchema.nullable(),
339 timingId: SimIdSchema.nullable(),
340 noiseTemperature: nullableNumber.pipe(z.number().min(0).nullable()),
341 noDirectPaths: z.boolean(),
342 noPropagationLoss: z.boolean(),
343 fmcwModeConfig: FmcwModeConfigSchema,
344 schedule: z.array(SchedulePeriodSchema).default([]),
345});
346
347const TargetComponentSchema = z.object({
348 id: SimIdSchema,
349 type: z.literal('target'),
350 name: z.string().min(1),
351 rcs_type: z.enum(['isotropic', 'file']),
352 rcs_value: z.number().optional(),
353 rcs_filename: z.string().optional(),
354 rcs_model: z.enum(['constant', 'chisquare', 'gamma']),
355 rcs_k: z.number().optional(),
356});
357
358export const PlatformComponentSchema = z.discriminatedUnion('type', [
359 MonostaticComponentSchema,
360 TransmitterComponentSchema,
361 ReceiverComponentSchema,
362 TargetComponentSchema,
363]);
364
365export const PlatformSchema = z.object({
366 id: SimIdSchema,
367 type: z.literal('Platform'),
368 name: z.string().min(1, 'Platform name cannot be empty.'),
369 motionPath: MotionPathSchema,
370 rotation: z.union([FixedRotationSchema, RotationPathSchema]),
371 components: z.array(PlatformComponentSchema),
372});
373
374export const ScenarioDataSchema = z.object({
375 globalParameters: GlobalParametersSchema,
376 waveforms: z.array(WaveformSchema),
377 timings: z.array(TimingSchema),
378 antennas: z.array(AntennaSchema),
379 platforms: z.array(PlatformSchema),
380});