1import { z } from 'zod';
3// Base numeric type for zod schemas - handles empty strings from forms
4const nullableNumber = z.preprocess(
5 (val) => (val === '' ? null : val),
9// --- SCHEMA DEFINITIONS ---
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.'),
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.',
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.'),
28 .min(1, 'Oversample ratio must be at least 1.'),
30 latitude: z.number().min(-90).max(90),
31 longitude: z.number().min(-180).max(180),
36 frame: z.enum(['ENU', 'UTM', 'ECEF']),
37 zone: z.number().int().optional(),
38 hemisphere: z.enum(['N', 'S']).optional(),
42 if (data.frame === 'UTM') {
44 data.zone !== undefined && data.hemisphere !== undefined
49 { message: 'UTM frame requires a zone and hemisphere.' }
53const SimIdSchema = z.string().regex(/^\d+$/, 'ID must be a numeric string.');
55const BaseWaveformSchema = z.object({
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.'),
62 .positive('Carrier frequency must be positive.'),
65export const WaveformSchema = z
66 .discriminatedUnion('waveformType', [
67 BaseWaveformSchema.extend({
68 waveformType: z.literal('pulsed_from_file'),
71 .min(1, 'A filename is required for this waveform type.'),
73 BaseWaveformSchema.extend({
74 waveformType: z.literal('cw_from_file'),
77 .min(1, 'A filename is required for this waveform type.')
80 'CW file waveforms require an HDF5 (.h5) file.'
83 BaseWaveformSchema.extend({
84 waveformType: z.literal('fmcw_from_file'),
87 .min(1, 'A filename is required for this waveform type.')
90 'FMCW file waveforms require an HDF5 (.h5) file.'
93 BaseWaveformSchema.extend({
94 waveformType: z.literal('cw'),
96 BaseWaveformSchema.extend({
97 waveformType: z.literal('fmcw_linear_chirp'),
98 direction: z.enum(['up', 'down']),
101 .positive('Chirp bandwidth must be positive.'),
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()
109 chirp_count: nullableNumber.pipe(
110 z.number().int().positive().nullable()
113 BaseWaveformSchema.extend({
114 waveformType: z.literal('fmcw_triangle'),
117 .positive('Chirp bandwidth must be positive.'),
120 .positive('Chirp duration must be positive.'),
121 start_frequency_offset: nullableNumber.pipe(
122 z.number().finite().nullable()
124 triangle_count: nullableNumber.pipe(
125 z.number().int().positive().nullable()
128 BaseWaveformSchema.extend({
129 waveformType: z.literal('stepped_frequency'),
130 start_frequency_offset: z.number().finite(),
134 .refine((val) => val !== 0, {
135 message: 'Step size cannot be zero.',
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()
148 .superRefine((data, ctx) => {
150 data.waveformType === 'fmcw_linear_chirp' &&
151 data.chirp_period < data.chirp_duration
156 'Chirp period must be greater than or equal to chirp duration.',
157 path: ['chirp_period'],
161 data.waveformType === 'stepped_frequency' &&
162 data.step_period < data.dwell_time
167 'Step period must be greater than or equal to dwell time.',
168 path: ['step_period'],
173export const NoiseEntrySchema = z.object({
179export const TimingSchema = z.object({
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),
191const BaseAntennaSchema = z.object({
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())
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()),
210 BaseAntennaSchema.extend({
211 pattern: z.literal('gaussian'),
212 azscale: nullableNumber.pipe(z.number().nullable()),
213 elscale: nullableNumber.pipe(z.number().nullable()),
215 BaseAntennaSchema.extend({
216 pattern: z.literal('squarehorn'),
217 diameter: nullableNumber.pipe(z.number().positive().nullable()),
219 BaseAntennaSchema.extend({
220 pattern: z.literal('parabolic'),
221 diameter: nullableNumber.pipe(z.number().positive().nullable()),
223 BaseAntennaSchema.extend({
224 pattern: z.literal('xml'),
227 .min(1, 'Filename is required for XML pattern.')
230 BaseAntennaSchema.extend({
231 pattern: z.literal('file'),
234 .min(1, 'Filename is required for file pattern.')
239export const PositionWaypointSchema = z.object({
243 altitude: z.number(),
244 time: z.number().min(0, 'Time cannot be negative.'),
247export const MotionPathSchema = z.object({
248 interpolation: z.enum(['static', 'linear', 'cubic']),
250 .array(PositionWaypointSchema)
251 .min(1, 'At least one waypoint is required.'),
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(),
262export const RotationWaypointSchema = z.object({
265 elevation: z.number(),
266 time: z.number().min(0, 'Time cannot be negative.'),
269export const RotationPathSchema = z.object({
270 type: z.literal('path'),
271 interpolation: z.enum(['static', 'linear', 'cubic']),
273 .array(RotationWaypointSchema)
274 .min(1, 'At least one waypoint is required.'),
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.'),
282const DechirpReferenceSchema = z.object({
283 source: z.enum(['attached', 'transmitter', 'custom']),
284 transmitter_name: z.string().optional(),
285 waveform_name: z.string().optional(),
288const FmcwModeConfigSchema = z
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(),
298const MonostaticComponentSchema = z.object({
300 type: z.literal('monostatic'),
301 name: z.string().min(1),
304 radarType: z.enum(['pulsed', 'cw', 'fmcw', 'sfcw']),
305 window_skip: nullableNumber,
306 window_length: 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([]),
318const TransmitterComponentSchema = z.object({
320 type: z.literal('transmitter'),
321 name: z.string().min(1),
322 radarType: z.enum(['pulsed', 'cw', 'fmcw', 'sfcw']),
324 antennaId: SimIdSchema.nullable(),
325 waveformId: SimIdSchema.nullable(),
326 timingId: SimIdSchema.nullable(),
327 schedule: z.array(SchedulePeriodSchema).default([]),
330const ReceiverComponentSchema = z.object({
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,
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([]),
347const TargetComponentSchema = z.object({
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(),
358export const PlatformComponentSchema = z.discriminatedUnion('type', [
359 MonostaticComponentSchema,
360 TransmitterComponentSchema,
361 ReceiverComponentSchema,
362 TargetComponentSchema,
365export const PlatformSchema = z.object({
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),
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),