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 FmcwWaveform = Extract<
30 Waveform,
31 { waveformType: 'fmcw_linear_chirp' | 'fmcw_triangle' }
32>;
33
34type FmcwEmitterComponent = Extract<
35 PlatformComponent,
36 { type: 'transmitter' | 'monostatic' }
37>;
38
39const TRIANGLE_EPSILON = 1e-12;
40const IF_CHAIN_FIELD_KEYS = [
41 'if_sample_rate',
42 'if_filter_bandwidth',
43 'if_filter_transition_width',
44] as const;
45
46const isFmcwWaveform = (
47 waveform: Waveform | undefined
48): waveform is FmcwWaveform =>
49 waveform?.waveformType === 'fmcw_linear_chirp' ||
50 waveform?.waveformType === 'fmcw_triangle';
51
52const formatNumber = (value: number): string =>
53 value.toLocaleString(undefined, { maximumSignificantDigits: 6 });
54
55function pushIssue(
56 issues: FmcwValidationIssue[],
57 issue: FmcwValidationIssue
58): void {
59 issues.push(issue);
60}
61
62function hasIfChainFields(config: unknown): boolean {
63 return (
64 isRecord(config) &&
65 IF_CHAIN_FIELD_KEYS.some((key) => Object.hasOwn(config, key))
66 );
67}
68
69function getValidIfChainNumber(
70 config: unknown,
71 key: (typeof IF_CHAIN_FIELD_KEYS)[number],
72 label: string,
73 component: Pick<PlatformComponent, 'id' | 'name'>,
74 issues: FmcwValidationIssue[]
75): number | undefined {
76 if (!isRecord(config) || !Object.hasOwn(config, key)) {
77 return undefined;
78 }
79
80 const value = config[key];
81 if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
82 pushIssue(issues, {
83 severity: 'error',
84 itemId: component.id,
85 componentId: component.id,
86 field: 'fmcwModeConfig',
87 message: `${component.name} ${label} must be a finite positive value.`,
88 });
89 return undefined;
90 }
91 return value;
92}
93
94function effectiveSchedule(
95 schedule: SchedulePeriod[],
96 globalParameters: GlobalParameters
97): SchedulePeriod[] {
98 return schedule.length > 0
99 ? schedule
100 : [{ start: globalParameters.start, end: globalParameters.end }];
101}
102
103export function validateFmcwWaveform(
104 waveform: Waveform,
105 globalParameters: GlobalParameters
106): FmcwValidationIssue[] {
107 if (!isFmcwWaveform(waveform)) {
108 return [];
109 }
110
111 const issues: FmcwValidationIssue[] = [];
112 const sweepStart = waveform.start_frequency_offset ?? 0;
113 const sweepEnd =
114 waveform.waveformType === 'fmcw_linear_chirp' &&
115 waveform.direction === 'down'
116 ? sweepStart - waveform.chirp_bandwidth
117 : sweepStart + waveform.chirp_bandwidth;
118 const fLow = Math.min(sweepStart, sweepEnd);
119 const fHigh = Math.max(sweepStart, sweepEnd);
120 const maxBaseband = Math.max(Math.abs(fLow), Math.abs(fHigh));
121 const effectiveRate =
122 globalParameters.rate * globalParameters.oversample_ratio;
123
124 if (
125 waveform.waveformType === 'fmcw_linear_chirp' &&
126 waveform.chirp_period < waveform.chirp_duration
127 ) {
128 pushIssue(issues, {
129 severity: 'error',
130 itemId: waveform.id,
131 waveformId: waveform.id,
132 field: 'chirp_period',
133 message:
134 'Chirp period must be greater than or equal to chirp duration.',
135 });
136 }
137
138 if (effectiveRate <= maxBaseband) {
139 pushIssue(issues, {
140 severity: 'error',
141 itemId: waveform.id,
142 waveformId: waveform.id,
143 message: `Effective sample rate ${formatNumber(
144 effectiveRate
145 )} Hz must exceed FMCW sweep baseband ${formatNumber(
146 maxBaseband
147 )} Hz.`,
148 });
149 } else if (maxBaseband > 0 && effectiveRate < 1.1 * maxBaseband) {
150 pushIssue(issues, {
151 severity: 'warning',
152 itemId: waveform.id,
153 waveformId: waveform.id,
154 message: `Effective sample rate ${formatNumber(
155 effectiveRate
156 )} Hz is within 10% of the FMCW aliasing limit ${formatNumber(
157 maxBaseband
158 )} Hz.`,
159 });
160 }
161
162 if (waveform.carrier_frequency + fLow <= 0) {
163 pushIssue(issues, {
164 severity: 'error',
165 itemId: waveform.id,
166 waveformId: waveform.id,
167 message:
168 'Carrier frequency plus the lower sweep edge must stay positive.',
169 });
170 }
171
172 return issues;
173}
174
175function validateFmcwEmitterSchedule(
176 component: FmcwEmitterComponent,
177 waveform: FmcwWaveform,
178 globalParameters: GlobalParameters
179): FmcwValidationIssue[] {
180 const issues: FmcwValidationIssue[] = [];
181 const schedule = effectiveSchedule(component.schedule, globalParameters);
182
183 for (const period of schedule) {
184 const duration = period.end - period.start;
185 if (waveform.waveformType === 'fmcw_linear_chirp') {
186 if (duration < waveform.chirp_duration) {
187 pushIssue(issues, {
188 severity: 'error',
189 itemId: component.id,
190 componentId: component.id,
191 waveformId: waveform.id,
192 field: 'schedule',
193 message: `${component.name} has schedule duration ${formatNumber(
194 duration
195 )} s shorter than FMCW chirp duration ${formatNumber(
196 waveform.chirp_duration
197 )} s.`,
198 });
199 } else if (duration < waveform.chirp_period) {
200 pushIssue(issues, {
201 severity: 'warning',
202 itemId: component.id,
203 componentId: component.id,
204 waveformId: waveform.id,
205 field: 'schedule',
206 message: `${component.name} has schedule duration ${formatNumber(
207 duration
208 )} s shorter than FMCW chirp period ${formatNumber(
209 waveform.chirp_period
210 )} s.`,
211 });
212 }
213 continue;
214 }
215
216 const trianglePeriod = 2 * waveform.chirp_duration;
217 if (duration < trianglePeriod) {
218 pushIssue(issues, {
219 severity: 'error',
220 itemId: component.id,
221 componentId: component.id,
222 waveformId: waveform.id,
223 field: 'schedule',
224 message: `${component.name} has schedule duration ${formatNumber(
225 duration
226 )} s shorter than FMCW triangle period ${formatNumber(
227 trianglePeriod
228 )} s.`,
229 });
230 continue;
231 }
232
233 const fullTriangles = Math.floor(duration / trianglePeriod);
234 const leftover = duration - fullTriangles * trianglePeriod;
235 if (leftover > TRIANGLE_EPSILON) {
236 pushIssue(issues, {
237 severity: 'warning',
238 itemId: component.id,
239 componentId: component.id,
240 waveformId: waveform.id,
241 field: 'schedule',
242 message: `${component.name} schedule leaves ${formatNumber(
243 leftover
244 )} s silent after the last complete FMCW triangle.`,
245 });
246 }
247 }
248
249 return issues;
250}
251
252function validateFmcwReceiverDechirpConfig(
253 component: Extract<PlatformComponent, { type: 'monostatic' | 'receiver' }>,
254 fmcwEmitterNames: ReadonlySet<string>,
255 fmcwWaveformNames: ReadonlySet<string>,
256 globalParameters: GlobalParameters
257): FmcwValidationIssue[] {
258 const issues: FmcwValidationIssue[] = [];
259
260 if (component.radarType !== 'fmcw') {
261 return issues;
262 }
263
264 const config = component.fmcwModeConfig;
265 const mode = getDechirpMode(config);
266 const reference =
267 isRecord(config) && isRecord(config.dechirp_reference)
268 ? config.dechirp_reference
269 : null;
270
271 if (mode === 'none') {
272 if (reference) {
273 pushIssue(issues, {
274 severity: 'error',
275 itemId: component.id,
276 componentId: component.id,
277 field: 'fmcwModeConfig',
278 message: `${component.name} declares a dechirp reference while dechirp mode is none.`,
279 });
280 }
281 if (hasIfChainFields(config)) {
282 pushIssue(issues, {
283 severity: 'error',
284 itemId: component.id,
285 componentId: component.id,
286 field: 'fmcwModeConfig',
287 message: `${component.name} declares IF-chain settings while dechirp mode is none.`,
288 });
289 }
290 return issues;
291 }
292
293 const ifSampleRate = getValidIfChainNumber(
294 config,
295 'if_sample_rate',
296 'IF sample rate',
297 component,
298 issues
299 );
300 const ifFilterBandwidth = getValidIfChainNumber(
301 config,
302 'if_filter_bandwidth',
303 'IF filter bandwidth',
304 component,
305 issues
306 );
307 getValidIfChainNumber(
308 config,
309 'if_filter_transition_width',
310 'IF transition width',
311 component,
312 issues
313 );
314 if (
315 ifSampleRate === undefined &&
316 (ifFilterBandwidth !== undefined ||
317 (isRecord(config) &&
318 Object.hasOwn(config, 'if_filter_transition_width')))
319 ) {
320 pushIssue(issues, {
321 severity: 'error',
322 itemId: component.id,
323 componentId: component.id,
324 field: 'fmcwModeConfig',
325 message: `${component.name} IF filter settings require an IF sample rate.`,
326 });
327 }
328 if (
329 ifSampleRate !== undefined &&
330 ifFilterBandwidth !== undefined &&
331 ifFilterBandwidth >= ifSampleRate / 2
332 ) {
333 pushIssue(issues, {
334 severity: 'error',
335 itemId: component.id,
336 componentId: component.id,
337 field: 'fmcwModeConfig',
338 message: `${component.name} IF filter bandwidth must be less than half the IF sample rate.`,
339 });
340 }
341 if (
342 ifSampleRate !== undefined &&
343 ifSampleRate > globalParameters.rate * globalParameters.oversample_ratio
344 ) {
345 pushIssue(issues, {
346 severity: 'error',
347 itemId: component.id,
348 componentId: component.id,
349 field: 'fmcwModeConfig',
350 message: `${component.name} IF sample rate must be less than or equal to the effective simulation sample rate.`,
351 });
352 }
353
354 if (!reference) {
355 pushIssue(issues, {
356 severity: 'error',
357 itemId: component.id,
358 componentId: component.id,
359 field: 'fmcwModeConfig',
360 message: `${component.name} enables ${mode} dechirping but does not declare a dechirp reference.`,
361 });
362 return issues;
363 }
364
365 if (!isDechirpReferenceSource(reference.source)) {
366 pushIssue(issues, {
367 severity: 'error',
368 itemId: component.id,
369 componentId: component.id,
370 field: 'fmcwModeConfig',
371 message: `${component.name} dechirp reference source must be attached, transmitter, or custom.`,
372 });
373 return issues;
374 }
375
376 switch (reference.source) {
377 case 'attached':
378 if (component.type !== 'monostatic') {
379 pushIssue(issues, {
380 severity: 'error',
381 itemId: component.id,
382 componentId: component.id,
383 field: 'fmcwModeConfig',
384 message: `${component.name} uses an attached dechirp reference, but only monostatic receivers have an attached transmitter.`,
385 });
386 }
387 if (
388 'transmitter_name' in reference ||
389 'waveform_name' in reference
390 ) {
391 pushIssue(issues, {
392 severity: 'error',
393 itemId: component.id,
394 componentId: component.id,
395 field: 'fmcwModeConfig',
396 message: `${component.name} attached dechirp reference must not set transmitter or waveform names.`,
397 });
398 }
399 break;
400 case 'transmitter': {
401 const transmitterName =
402 typeof reference.transmitter_name === 'string'
403 ? reference.transmitter_name
404 : '';
405 if (transmitterName.trim().length === 0) {
406 pushIssue(issues, {
407 severity: 'error',
408 itemId: component.id,
409 componentId: component.id,
410 field: 'fmcwModeConfig',
411 message: `${component.name} transmitter dechirp reference requires a transmitter name.`,
412 });
413 break;
414 }
415 if ('waveform_name' in reference) {
416 pushIssue(issues, {
417 severity: 'error',
418 itemId: component.id,
419 componentId: component.id,
420 field: 'fmcwModeConfig',
421 message: `${component.name} transmitter dechirp reference must not set a waveform name.`,
422 });
423 }
424 if (!fmcwEmitterNames.has(transmitterName)) {
425 pushIssue(issues, {
426 severity: 'error',
427 itemId: component.id,
428 componentId: component.id,
429 field: 'fmcwModeConfig',
430 message: `${component.name} dechirp reference transmitter '${transmitterName}' must be an FMCW transmitter with an FMCW waveform.`,
431 });
432 }
433 break;
434 }
435 case 'custom': {
436 const waveformName =
437 typeof reference.waveform_name === 'string'
438 ? reference.waveform_name
439 : '';
440 if (waveformName.trim().length === 0) {
441 pushIssue(issues, {
442 severity: 'error',
443 itemId: component.id,
444 componentId: component.id,
445 field: 'fmcwModeConfig',
446 message: `${component.name} custom dechirp reference requires a waveform name.`,
447 });
448 break;
449 }
450 if ('transmitter_name' in reference) {
451 pushIssue(issues, {
452 severity: 'error',
453 itemId: component.id,
454 componentId: component.id,
455 field: 'fmcwModeConfig',
456 message: `${component.name} custom dechirp reference must not set a transmitter name.`,
457 });
458 }
459 if (!fmcwWaveformNames.has(waveformName)) {
460 pushIssue(issues, {
461 severity: 'error',
462 itemId: component.id,
463 componentId: component.id,
464 field: 'fmcwModeConfig',
465 message: `${component.name} custom dechirp reference waveform '${waveformName}' must be a top-level FMCW waveform.`,
466 });
467 }
468 break;
469 }
470 }
471
472 return issues;
473}
474
475export function validateFmcwScenario(
476 scenario: Pick<ScenarioData, 'globalParameters' | 'waveforms' | 'platforms'>
477): FmcwValidationIssue[] {
478 const issues = scenario.waveforms.flatMap((waveform) =>
479 validateFmcwWaveform(waveform, scenario.globalParameters)
480 );
481 const waveformsById = new Map(
482 scenario.waveforms.map((waveform) => [waveform.id, waveform])
483 );
484 const fmcwWaveformNames = new Set(
485 scenario.waveforms
486 .filter((waveform) => isFmcwWaveformType(waveform.waveformType))
487 .map((waveform) => waveform.name)
488 );
489 const fmcwEmitterNames = new Set(
490 scenario.platforms.flatMap((platform) =>
491 platform.components.flatMap((component) => {
492 if (
493 component.type !== 'transmitter' &&
494 component.type !== 'monostatic'
495 ) {
496 return [];
497 }
498 const waveform = component.waveformId
499 ? waveformsById.get(component.waveformId)
500 : undefined;
501 return component.radarType === 'fmcw' &&
502 isFmcwWaveform(waveform)
503 ? [component.name]
504 : [];
505 })
506 )
507 );
508
509 for (const platform of scenario.platforms) {
510 for (const component of platform.components) {
511 if (
512 component.type === 'receiver' ||
513 component.type === 'monostatic'
514 ) {
515 issues.push(
516 ...validateFmcwReceiverDechirpConfig(
517 component,
518 fmcwEmitterNames,
519 fmcwWaveformNames,
520 scenario.globalParameters
521 )
522 );
523 }
524
525 if (
526 component.type !== 'transmitter' &&
527 component.type !== 'monostatic'
528 ) {
529 continue;
530 }
531
532 if (component.radarType !== 'fmcw' || !component.waveformId) {
533 continue;
534 }
535
536 const waveform = waveformsById.get(component.waveformId);
537 if (!isFmcwWaveform(waveform)) {
538 pushIssue(issues, {
539 severity: 'error',
540 itemId: component.id,
541 componentId: component.id,
542 waveformId: component.waveformId,
543 message: `${component.name} is FMCW but does not reference an FMCW waveform.`,
544 });
545 continue;
546 }
547
548 issues.push(
549 ...validateFmcwEmitterSchedule(
550 component,
551 waveform,
552 scenario.globalParameters
553 )
554 );
555 }
556 }
557
558 return issues;
559}
560
561export function getBlockingFmcwValidationMessage(
562 scenario: Pick<ScenarioData, 'globalParameters' | 'waveforms' | 'platforms'>
563): string | null {
564 const firstError = validateFmcwScenario(scenario).find(
565 (issue) => issue.severity === 'error'
566 );
567 return firstError?.message ?? null;
568}