FERS 0.1.0
The Flexible Extensible Radar Simulator
Loading...
Searching...
No Matches
fmcwValidation.ts
Go to the documentation of this file.
1// SPDX-License-Identifier: GPL-2.0-only
2// Copyright (c) 2026-present FERS Contributors (see AUTHORS.md).
3
4import {
5 getDechirpMode,
6 isDechirpReferenceSource,
7 isFmcwWaveformType,
8 isRecord,
9} from './fmcwModeConfig';
10import type {
11 GlobalParameters,
12 PlatformComponent,
13 ScenarioData,
14 SchedulePeriod,
15 Waveform,
16} from './types';
17
18export type FmcwValidationSeverity = 'error' | 'warning';
19
20export type FmcwValidationIssue = {
21 severity: FmcwValidationSeverity;
22 message: string;
23 itemId?: string;
24 componentId?: string;
25 waveformId?: string;
26 field?: string;
27};
28
29type AnalyticFmcwWaveform = Extract<
30 Waveform,
31 { waveformType: 'fmcw_linear_chirp' | 'fmcw_triangle' }
32>;
33
34type SfcwWaveform = Extract<Waveform, { waveformType: 'stepped_frequency' }>;
35
36type FmcwEmitterComponent = Extract<
37 PlatformComponent,
38 { type: 'transmitter' | 'monostatic' }
39>;
40
41const TRIANGLE_EPSILON = 1e-12;
42const IF_CHAIN_FIELD_KEYS = [
43 'if_sample_rate',
44 'if_filter_bandwidth',
45 'if_filter_transition_width',
46] as const;
47
48const isAnalyticFmcwWaveform = (
49 waveform: Waveform | undefined
50): waveform is AnalyticFmcwWaveform =>
51 waveform?.waveformType === 'fmcw_linear_chirp' ||
52 waveform?.waveformType === 'fmcw_triangle';
53
54const isFmcwWaveform = (waveform: Waveform | undefined): boolean =>
55 isFmcwWaveformType(waveform?.waveformType);
56
57const isSfcwWaveform = (
58 waveform: Waveform | undefined
59): waveform is SfcwWaveform => waveform?.waveformType === 'stepped_frequency';
60
61const formatNumber = (value: number): string =>
62 value.toLocaleString(undefined, { maximumSignificantDigits: 6 });
63
64function pushIssue(
65 issues: FmcwValidationIssue[],
66 issue: FmcwValidationIssue
67): void {
68 issues.push(issue);
69}
70
71function hasIfChainFields(config: unknown): boolean {
72 return (
73 isRecord(config) &&
74 IF_CHAIN_FIELD_KEYS.some((key) => Object.hasOwn(config, key))
75 );
76}
77
78function getValidIfChainNumber(
79 config: unknown,
80 key: (typeof IF_CHAIN_FIELD_KEYS)[number],
81 label: string,
82 component: Pick<PlatformComponent, 'id' | 'name'>,
83 issues: FmcwValidationIssue[]
84): number | undefined {
85 if (!isRecord(config) || !Object.hasOwn(config, key)) {
86 return undefined;
87 }
88
89 const value = config[key];
90 if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
91 pushIssue(issues, {
92 severity: 'error',
93 itemId: component.id,
94 componentId: component.id,
95 field: 'fmcwModeConfig',
96 message: `${component.name} ${label} must be a finite positive value.`,
97 });
98 return undefined;
99 }
100 return value;
101}
102
103function effectiveSchedule(
104 schedule: SchedulePeriod[],
105 globalParameters: GlobalParameters
106): SchedulePeriod[] {
107 return schedule.length > 0
108 ? schedule
109 : [{ start: globalParameters.start, end: globalParameters.end }];
110}
111
112export function validateFmcwWaveform(
113 waveform: Waveform,
114 globalParameters: GlobalParameters
115): FmcwValidationIssue[] {
116 if (isSfcwWaveform(waveform)) {
117 const issues: FmcwValidationIssue[] = [];
118 const firstFrequency =
119 waveform.carrier_frequency + waveform.start_frequency_offset;
120 const lastFrequency =
121 firstFrequency + (waveform.step_count - 1) * waveform.step_size;
122 const lowerFrequency = Math.min(firstFrequency, lastFrequency);
123 if (waveform.step_size === 0) {
124 pushIssue(issues, {
125 severity: 'error',
126 itemId: waveform.id,
127 waveformId: waveform.id,
128 field: 'step_size',
129 message: 'SFCW step size cannot be zero.',
130 });
131 }
132 if (waveform.step_period < waveform.dwell_time) {
133 pushIssue(issues, {
134 severity: 'error',
135 itemId: waveform.id,
136 waveformId: waveform.id,
137 field: 'step_period',
138 message:
139 'Step period must be greater than or equal to dwell time.',
140 });
141 }
142 if (globalParameters.rate * waveform.dwell_time < 1) {
143 pushIssue(issues, {
144 severity: 'warning',
145 itemId: waveform.id,
146 waveformId: waveform.id,
147 message: `${waveform.name} has fewer than one output sample per SFCW dwell.`,
148 });
149 }
150 if (lowerFrequency <= 0) {
151 pushIssue(issues, {
152 severity: 'error',
153 itemId: waveform.id,
154 waveformId: waveform.id,
155 message:
156 'Carrier frequency plus the lowest SFCW step frequency must stay positive.',
157 });
158 }
159 return issues;
160 }
161
162 if (!isAnalyticFmcwWaveform(waveform)) {
163 return [];
164 }
165
166 const issues: FmcwValidationIssue[] = [];
167 const sweepStart = waveform.start_frequency_offset ?? 0;
168 const sweepEnd =
169 waveform.waveformType === 'fmcw_linear_chirp' &&
170 waveform.direction === 'down'
171 ? sweepStart - waveform.chirp_bandwidth
172 : sweepStart + waveform.chirp_bandwidth;
173 const fLow = Math.min(sweepStart, sweepEnd);
174 const fHigh = Math.max(sweepStart, sweepEnd);
175 const maxBaseband = Math.max(Math.abs(fLow), Math.abs(fHigh));
176 const effectiveRate =
177 globalParameters.rate * globalParameters.oversample_ratio;
178
179 if (
180 waveform.waveformType === 'fmcw_linear_chirp' &&
181 waveform.chirp_period < waveform.chirp_duration
182 ) {
183 pushIssue(issues, {
184 severity: 'error',
185 itemId: waveform.id,
186 waveformId: waveform.id,
187 field: 'chirp_period',
188 message:
189 'Chirp period must be greater than or equal to chirp duration.',
190 });
191 }
192
193 if (effectiveRate <= maxBaseband) {
194 pushIssue(issues, {
195 severity: 'error',
196 itemId: waveform.id,
197 waveformId: waveform.id,
198 message: `Effective sample rate ${formatNumber(
199 effectiveRate
200 )} Hz must exceed FMCW sweep baseband ${formatNumber(
201 maxBaseband
202 )} Hz.`,
203 });
204 } else if (maxBaseband > 0 && effectiveRate < 1.1 * maxBaseband) {
205 pushIssue(issues, {
206 severity: 'warning',
207 itemId: waveform.id,
208 waveformId: waveform.id,
209 message: `Effective sample rate ${formatNumber(
210 effectiveRate
211 )} Hz is within 10% of the FMCW aliasing limit ${formatNumber(
212 maxBaseband
213 )} Hz.`,
214 });
215 }
216
217 if (waveform.carrier_frequency + fLow <= 0) {
218 pushIssue(issues, {
219 severity: 'error',
220 itemId: waveform.id,
221 waveformId: waveform.id,
222 message:
223 'Carrier frequency plus the lower sweep edge must stay positive.',
224 });
225 }
226
227 return issues;
228}
229
230function validateFmcwEmitterSchedule(
231 component: FmcwEmitterComponent,
232 waveform: AnalyticFmcwWaveform,
233 globalParameters: GlobalParameters
234): FmcwValidationIssue[] {
235 const issues: FmcwValidationIssue[] = [];
236 const schedule = effectiveSchedule(component.schedule, globalParameters);
237
238 for (const period of schedule) {
239 const duration = period.end - period.start;
240 if (waveform.waveformType === 'fmcw_linear_chirp') {
241 if (duration < waveform.chirp_duration) {
242 pushIssue(issues, {
243 severity: 'error',
244 itemId: component.id,
245 componentId: component.id,
246 waveformId: waveform.id,
247 field: 'schedule',
248 message: `${component.name} has schedule duration ${formatNumber(
249 duration
250 )} s shorter than FMCW chirp duration ${formatNumber(
251 waveform.chirp_duration
252 )} s.`,
253 });
254 } else if (duration < waveform.chirp_period) {
255 pushIssue(issues, {
256 severity: 'warning',
257 itemId: component.id,
258 componentId: component.id,
259 waveformId: waveform.id,
260 field: 'schedule',
261 message: `${component.name} has schedule duration ${formatNumber(
262 duration
263 )} s shorter than FMCW chirp period ${formatNumber(
264 waveform.chirp_period
265 )} s.`,
266 });
267 }
268 continue;
269 }
270
271 const trianglePeriod = 2 * waveform.chirp_duration;
272 if (duration < trianglePeriod) {
273 pushIssue(issues, {
274 severity: 'error',
275 itemId: component.id,
276 componentId: component.id,
277 waveformId: waveform.id,
278 field: 'schedule',
279 message: `${component.name} has schedule duration ${formatNumber(
280 duration
281 )} s shorter than FMCW triangle period ${formatNumber(
282 trianglePeriod
283 )} s.`,
284 });
285 continue;
286 }
287
288 const fullTriangles = Math.floor(duration / trianglePeriod);
289 const leftover = duration - fullTriangles * trianglePeriod;
290 if (leftover > TRIANGLE_EPSILON) {
291 pushIssue(issues, {
292 severity: 'warning',
293 itemId: component.id,
294 componentId: component.id,
295 waveformId: waveform.id,
296 field: 'schedule',
297 message: `${component.name} schedule leaves ${formatNumber(
298 leftover
299 )} s silent after the last complete FMCW triangle.`,
300 });
301 }
302 }
303
304 return issues;
305}
306
307function validateSfcwEmitterSchedule(
308 component: FmcwEmitterComponent,
309 waveform: SfcwWaveform,
310 globalParameters: GlobalParameters
311): FmcwValidationIssue[] {
312 const issues: FmcwValidationIssue[] = [];
313 const schedule = effectiveSchedule(component.schedule, globalParameters);
314 const sweepPeriod = waveform.step_count * waveform.step_period;
315
316 for (const period of schedule) {
317 const duration = period.end - period.start;
318 if (duration < waveform.dwell_time) {
319 pushIssue(issues, {
320 severity: 'error',
321 itemId: component.id,
322 componentId: component.id,
323 waveformId: waveform.id,
324 field: 'schedule',
325 message: `${component.name} has schedule duration ${formatNumber(
326 duration
327 )} s shorter than SFCW dwell time ${formatNumber(
328 waveform.dwell_time
329 )} s.`,
330 });
331 } else if (duration < sweepPeriod) {
332 pushIssue(issues, {
333 severity: 'warning',
334 itemId: component.id,
335 componentId: component.id,
336 waveformId: waveform.id,
337 field: 'schedule',
338 message: `${component.name} has schedule duration ${formatNumber(
339 duration
340 )} s shorter than SFCW sweep period ${formatNumber(
341 sweepPeriod
342 )} s.`,
343 });
344 }
345 }
346
347 return issues;
348}
349
350function validateFmcwReceiverDechirpConfig(
351 component: Extract<PlatformComponent, { type: 'monostatic' | 'receiver' }>,
352 fmcwEmitterNames: ReadonlySet<string>,
353 fmcwWaveformNames: ReadonlySet<string>,
354 globalParameters: GlobalParameters
355): FmcwValidationIssue[] {
356 const issues: FmcwValidationIssue[] = [];
357
358 if (component.radarType !== 'fmcw') {
359 return issues;
360 }
361
362 const config = component.fmcwModeConfig;
363 const mode = getDechirpMode(config);
364 const reference =
365 isRecord(config) && isRecord(config.dechirp_reference)
366 ? config.dechirp_reference
367 : null;
368
369 if (mode === 'none') {
370 if (reference) {
371 pushIssue(issues, {
372 severity: 'error',
373 itemId: component.id,
374 componentId: component.id,
375 field: 'fmcwModeConfig',
376 message: `${component.name} declares a dechirp reference while dechirp mode is none.`,
377 });
378 }
379 if (hasIfChainFields(config)) {
380 pushIssue(issues, {
381 severity: 'error',
382 itemId: component.id,
383 componentId: component.id,
384 field: 'fmcwModeConfig',
385 message: `${component.name} declares IF-chain settings while dechirp mode is none.`,
386 });
387 }
388 return issues;
389 }
390
391 const ifSampleRate = getValidIfChainNumber(
392 config,
393 'if_sample_rate',
394 'IF sample rate',
395 component,
396 issues
397 );
398 const ifFilterBandwidth = getValidIfChainNumber(
399 config,
400 'if_filter_bandwidth',
401 'IF filter bandwidth',
402 component,
403 issues
404 );
405 getValidIfChainNumber(
406 config,
407 'if_filter_transition_width',
408 'IF transition width',
409 component,
410 issues
411 );
412 if (
413 ifSampleRate === undefined &&
414 (ifFilterBandwidth !== undefined ||
415 (isRecord(config) &&
416 Object.hasOwn(config, 'if_filter_transition_width')))
417 ) {
418 pushIssue(issues, {
419 severity: 'error',
420 itemId: component.id,
421 componentId: component.id,
422 field: 'fmcwModeConfig',
423 message: `${component.name} IF filter settings require an IF sample rate.`,
424 });
425 }
426 if (
427 ifSampleRate !== undefined &&
428 ifFilterBandwidth !== undefined &&
429 ifFilterBandwidth >= ifSampleRate / 2
430 ) {
431 pushIssue(issues, {
432 severity: 'error',
433 itemId: component.id,
434 componentId: component.id,
435 field: 'fmcwModeConfig',
436 message: `${component.name} IF filter bandwidth must be less than half the IF sample rate.`,
437 });
438 }
439 if (
440 ifSampleRate !== undefined &&
441 ifSampleRate > globalParameters.rate * globalParameters.oversample_ratio
442 ) {
443 pushIssue(issues, {
444 severity: 'error',
445 itemId: component.id,
446 componentId: component.id,
447 field: 'fmcwModeConfig',
448 message: `${component.name} IF sample rate must be less than or equal to the effective simulation sample rate.`,
449 });
450 }
451
452 if (!reference) {
453 pushIssue(issues, {
454 severity: 'error',
455 itemId: component.id,
456 componentId: component.id,
457 field: 'fmcwModeConfig',
458 message: `${component.name} enables ${mode} dechirping but does not declare a dechirp reference.`,
459 });
460 return issues;
461 }
462
463 if (!isDechirpReferenceSource(reference.source)) {
464 pushIssue(issues, {
465 severity: 'error',
466 itemId: component.id,
467 componentId: component.id,
468 field: 'fmcwModeConfig',
469 message: `${component.name} dechirp reference source must be attached, transmitter, or custom.`,
470 });
471 return issues;
472 }
473
474 switch (reference.source) {
475 case 'attached':
476 if (component.type !== 'monostatic') {
477 pushIssue(issues, {
478 severity: 'error',
479 itemId: component.id,
480 componentId: component.id,
481 field: 'fmcwModeConfig',
482 message: `${component.name} uses an attached dechirp reference, but only monostatic receivers have an attached transmitter.`,
483 });
484 }
485 if (
486 'transmitter_name' in reference ||
487 'waveform_name' in reference
488 ) {
489 pushIssue(issues, {
490 severity: 'error',
491 itemId: component.id,
492 componentId: component.id,
493 field: 'fmcwModeConfig',
494 message: `${component.name} attached dechirp reference must not set transmitter or waveform names.`,
495 });
496 }
497 break;
498 case 'transmitter': {
499 const transmitterName =
500 typeof reference.transmitter_name === 'string'
501 ? reference.transmitter_name
502 : '';
503 if (transmitterName.trim().length === 0) {
504 pushIssue(issues, {
505 severity: 'error',
506 itemId: component.id,
507 componentId: component.id,
508 field: 'fmcwModeConfig',
509 message: `${component.name} transmitter dechirp reference requires a transmitter name.`,
510 });
511 break;
512 }
513 if ('waveform_name' in reference) {
514 pushIssue(issues, {
515 severity: 'error',
516 itemId: component.id,
517 componentId: component.id,
518 field: 'fmcwModeConfig',
519 message: `${component.name} transmitter dechirp reference must not set a waveform name.`,
520 });
521 }
522 if (!fmcwEmitterNames.has(transmitterName)) {
523 pushIssue(issues, {
524 severity: 'error',
525 itemId: component.id,
526 componentId: component.id,
527 field: 'fmcwModeConfig',
528 message: `${component.name} dechirp reference transmitter '${transmitterName}' must be an FMCW transmitter with an FMCW waveform.`,
529 });
530 }
531 break;
532 }
533 case 'custom': {
534 const waveformName =
535 typeof reference.waveform_name === 'string'
536 ? reference.waveform_name
537 : '';
538 if (waveformName.trim().length === 0) {
539 pushIssue(issues, {
540 severity: 'error',
541 itemId: component.id,
542 componentId: component.id,
543 field: 'fmcwModeConfig',
544 message: `${component.name} custom dechirp reference requires a waveform name.`,
545 });
546 break;
547 }
548 if ('transmitter_name' in reference) {
549 pushIssue(issues, {
550 severity: 'error',
551 itemId: component.id,
552 componentId: component.id,
553 field: 'fmcwModeConfig',
554 message: `${component.name} custom dechirp reference must not set a transmitter name.`,
555 });
556 }
557 if (!fmcwWaveformNames.has(waveformName)) {
558 pushIssue(issues, {
559 severity: 'error',
560 itemId: component.id,
561 componentId: component.id,
562 field: 'fmcwModeConfig',
563 message: `${component.name} custom dechirp reference waveform '${waveformName}' must be a top-level FMCW waveform.`,
564 });
565 }
566 break;
567 }
568 }
569
570 return issues;
571}
572
573export function validateFmcwScenario(
574 scenario: Pick<ScenarioData, 'globalParameters' | 'waveforms' | 'platforms'>
575): FmcwValidationIssue[] {
576 const issues = scenario.waveforms.flatMap((waveform) =>
577 validateFmcwWaveform(waveform, scenario.globalParameters)
578 );
579 const waveformsById = new Map(
580 scenario.waveforms.map((waveform) => [waveform.id, waveform])
581 );
582 const fmcwWaveformNames = new Set(
583 scenario.waveforms
584 .filter((waveform) => isFmcwWaveformType(waveform.waveformType))
585 .map((waveform) => waveform.name)
586 );
587 const fmcwEmitterNames = new Set(
588 scenario.platforms.flatMap((platform) =>
589 platform.components.flatMap((component) => {
590 if (
591 component.type !== 'transmitter' &&
592 component.type !== 'monostatic'
593 ) {
594 return [];
595 }
596 const waveform = component.waveformId
597 ? waveformsById.get(component.waveformId)
598 : undefined;
599 return component.radarType === 'fmcw' &&
600 isFmcwWaveform(waveform)
601 ? [component.name]
602 : [];
603 })
604 )
605 );
606
607 for (const platform of scenario.platforms) {
608 for (const component of platform.components) {
609 if (
610 component.type === 'receiver' ||
611 component.type === 'monostatic'
612 ) {
613 issues.push(
614 ...validateFmcwReceiverDechirpConfig(
615 component,
616 fmcwEmitterNames,
617 fmcwWaveformNames,
618 scenario.globalParameters
619 )
620 );
621 }
622
623 if (
624 component.type !== 'transmitter' &&
625 component.type !== 'monostatic'
626 ) {
627 continue;
628 }
629
630 if (component.radarType !== 'fmcw' || !component.waveformId) {
631 if (component.radarType !== 'sfcw' || !component.waveformId) {
632 continue;
633 }
634
635 const waveform = waveformsById.get(component.waveformId);
636 if (!isSfcwWaveform(waveform)) {
637 pushIssue(issues, {
638 severity: 'error',
639 itemId: component.id,
640 componentId: component.id,
641 waveformId: component.waveformId,
642 message: `${component.name} is SFCW but does not reference an SFCW waveform.`,
643 });
644 continue;
645 }
646
647 issues.push(
648 ...validateSfcwEmitterSchedule(
649 component,
650 waveform,
651 scenario.globalParameters
652 )
653 );
654 continue;
655 }
656
657 const waveform = waveformsById.get(component.waveformId);
658 if (!isFmcwWaveform(waveform)) {
659 pushIssue(issues, {
660 severity: 'error',
661 itemId: component.id,
662 componentId: component.id,
663 waveformId: component.waveformId,
664 message: `${component.name} is FMCW but does not reference an FMCW waveform.`,
665 });
666 continue;
667 }
668
669 if (isAnalyticFmcwWaveform(waveform)) {
670 issues.push(
671 ...validateFmcwEmitterSchedule(
672 component,
673 waveform,
674 scenario.globalParameters
675 )
676 );
677 }
678 }
679 }
680
681 return issues;
682}
683
684export function getBlockingFmcwValidationMessage(
685 scenario: Pick<ScenarioData, 'globalParameters' | 'waveforms' | 'platforms'>
686): string | null {
687 const firstError = validateFmcwScenario(scenario).find(
688 (issue) => issue.severity === 'error'
689 );
690 return firstError?.message ?? null;
691}