1// SPDX-License-Identifier: GPL-2.0-only
2// Copyright (c) 2026-present FERS Contributors (see AUTHORS.md).
6 isDechirpReferenceSource,
9} from './fmcwModeConfig';
18export type FmcwValidationSeverity = 'error' | 'warning';
20export type FmcwValidationIssue = {
21 severity: FmcwValidationSeverity;
29type AnalyticFmcwWaveform = Extract<
31 { waveformType: 'fmcw_linear_chirp' | 'fmcw_triangle' }
34type SfcwWaveform = Extract<Waveform, { waveformType: 'stepped_frequency' }>;
36type FmcwEmitterComponent = Extract<
38 { type: 'transmitter' | 'monostatic' }
41const TRIANGLE_EPSILON = 1e-12;
42const IF_CHAIN_FIELD_KEYS = [
44 'if_filter_bandwidth',
45 'if_filter_transition_width',
48const isAnalyticFmcwWaveform = (
49 waveform: Waveform | undefined
50): waveform is AnalyticFmcwWaveform =>
51 waveform?.waveformType === 'fmcw_linear_chirp' ||
52 waveform?.waveformType === 'fmcw_triangle';
54const isFmcwWaveform = (waveform: Waveform | undefined): boolean =>
55 isFmcwWaveformType(waveform?.waveformType);
57const isSfcwWaveform = (
58 waveform: Waveform | undefined
59): waveform is SfcwWaveform => waveform?.waveformType === 'stepped_frequency';
61const formatNumber = (value: number): string =>
62 value.toLocaleString(undefined, { maximumSignificantDigits: 6 });
65 issues: FmcwValidationIssue[],
66 issue: FmcwValidationIssue
71function hasIfChainFields(config: unknown): boolean {
74 IF_CHAIN_FIELD_KEYS.some((key) => Object.hasOwn(config, key))
78function getValidIfChainNumber(
80 key: (typeof IF_CHAIN_FIELD_KEYS)[number],
82 component: Pick<PlatformComponent, 'id' | 'name'>,
83 issues: FmcwValidationIssue[]
84): number | undefined {
85 if (!isRecord(config) || !Object.hasOwn(config, key)) {
89 const value = config[key];
90 if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
94 componentId: component.id,
95 field: 'fmcwModeConfig',
96 message: `${component.name} ${label} must be a finite positive value.`,
103function effectiveSchedule(
104 schedule: SchedulePeriod[],
105 globalParameters: GlobalParameters
107 return schedule.length > 0
109 : [{ start: globalParameters.start, end: globalParameters.end }];
112export function validateFmcwWaveform(
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) {
127 waveformId: waveform.id,
129 message: 'SFCW step size cannot be zero.',
132 if (waveform.step_period < waveform.dwell_time) {
136 waveformId: waveform.id,
137 field: 'step_period',
139 'Step period must be greater than or equal to dwell time.',
142 if (globalParameters.rate * waveform.dwell_time < 1) {
146 waveformId: waveform.id,
147 message: `${waveform.name} has fewer than one output sample per SFCW dwell.`,
150 if (lowerFrequency <= 0) {
154 waveformId: waveform.id,
156 'Carrier frequency plus the lowest SFCW step frequency must stay positive.',
162 if (!isAnalyticFmcwWaveform(waveform)) {
166 const issues: FmcwValidationIssue[] = [];
167 const sweepStart = waveform.start_frequency_offset ?? 0;
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;
180 waveform.waveformType === 'fmcw_linear_chirp' &&
181 waveform.chirp_period < waveform.chirp_duration
186 waveformId: waveform.id,
187 field: 'chirp_period',
189 'Chirp period must be greater than or equal to chirp duration.',
193 if (effectiveRate <= maxBaseband) {
197 waveformId: waveform.id,
198 message: `Effective sample rate ${formatNumber(
200 )} Hz must exceed FMCW sweep baseband ${formatNumber(
204 } else if (maxBaseband > 0 && effectiveRate < 1.1 * maxBaseband) {
208 waveformId: waveform.id,
209 message: `Effective sample rate ${formatNumber(
211 )} Hz is within 10% of the FMCW aliasing limit ${formatNumber(
217 if (waveform.carrier_frequency + fLow <= 0) {
221 waveformId: waveform.id,
223 'Carrier frequency plus the lower sweep edge must stay positive.',
230function validateFmcwEmitterSchedule(
231 component: FmcwEmitterComponent,
232 waveform: AnalyticFmcwWaveform,
233 globalParameters: GlobalParameters
234): FmcwValidationIssue[] {
235 const issues: FmcwValidationIssue[] = [];
236 const schedule = effectiveSchedule(component.schedule, globalParameters);
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) {
244 itemId: component.id,
245 componentId: component.id,
246 waveformId: waveform.id,
248 message: `${component.name} has schedule duration ${formatNumber(
250 )} s shorter than FMCW chirp duration ${formatNumber(
251 waveform.chirp_duration
254 } else if (duration < waveform.chirp_period) {
257 itemId: component.id,
258 componentId: component.id,
259 waveformId: waveform.id,
261 message: `${component.name} has schedule duration ${formatNumber(
263 )} s shorter than FMCW chirp period ${formatNumber(
264 waveform.chirp_period
271 const trianglePeriod = 2 * waveform.chirp_duration;
272 if (duration < trianglePeriod) {
275 itemId: component.id,
276 componentId: component.id,
277 waveformId: waveform.id,
279 message: `${component.name} has schedule duration ${formatNumber(
281 )} s shorter than FMCW triangle period ${formatNumber(
288 const fullTriangles = Math.floor(duration / trianglePeriod);
289 const leftover = duration - fullTriangles * trianglePeriod;
290 if (leftover > TRIANGLE_EPSILON) {
293 itemId: component.id,
294 componentId: component.id,
295 waveformId: waveform.id,
297 message: `${component.name} schedule leaves ${formatNumber(
299 )} s silent after the last complete FMCW triangle.`,
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;
316 for (const period of schedule) {
317 const duration = period.end - period.start;
318 if (duration < waveform.dwell_time) {
321 itemId: component.id,
322 componentId: component.id,
323 waveformId: waveform.id,
325 message: `${component.name} has schedule duration ${formatNumber(
327 )} s shorter than SFCW dwell time ${formatNumber(
331 } else if (duration < sweepPeriod) {
334 itemId: component.id,
335 componentId: component.id,
336 waveformId: waveform.id,
338 message: `${component.name} has schedule duration ${formatNumber(
340 )} s shorter than SFCW sweep period ${formatNumber(
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[] = [];
358 if (component.radarType !== 'fmcw') {
362 const config = component.fmcwModeConfig;
363 const mode = getDechirpMode(config);
365 isRecord(config) && isRecord(config.dechirp_reference)
366 ? config.dechirp_reference
369 if (mode === 'none') {
373 itemId: component.id,
374 componentId: component.id,
375 field: 'fmcwModeConfig',
376 message: `${component.name} declares a dechirp reference while dechirp mode is none.`,
379 if (hasIfChainFields(config)) {
382 itemId: component.id,
383 componentId: component.id,
384 field: 'fmcwModeConfig',
385 message: `${component.name} declares IF-chain settings while dechirp mode is none.`,
391 const ifSampleRate = getValidIfChainNumber(
398 const ifFilterBandwidth = getValidIfChainNumber(
400 'if_filter_bandwidth',
401 'IF filter bandwidth',
405 getValidIfChainNumber(
407 'if_filter_transition_width',
408 'IF transition width',
413 ifSampleRate === undefined &&
414 (ifFilterBandwidth !== undefined ||
416 Object.hasOwn(config, 'if_filter_transition_width')))
420 itemId: component.id,
421 componentId: component.id,
422 field: 'fmcwModeConfig',
423 message: `${component.name} IF filter settings require an IF sample rate.`,
427 ifSampleRate !== undefined &&
428 ifFilterBandwidth !== undefined &&
429 ifFilterBandwidth >= ifSampleRate / 2
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.`,
440 ifSampleRate !== undefined &&
441 ifSampleRate > globalParameters.rate * globalParameters.oversample_ratio
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.`,
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.`,
463 if (!isDechirpReferenceSource(reference.source)) {
466 itemId: component.id,
467 componentId: component.id,
468 field: 'fmcwModeConfig',
469 message: `${component.name} dechirp reference source must be attached, transmitter, or custom.`,
474 switch (reference.source) {
476 if (component.type !== 'monostatic') {
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.`,
486 'transmitter_name' in reference ||
487 'waveform_name' in reference
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.`,
498 case 'transmitter': {
499 const transmitterName =
500 typeof reference.transmitter_name === 'string'
501 ? reference.transmitter_name
503 if (transmitterName.trim().length === 0) {
506 itemId: component.id,
507 componentId: component.id,
508 field: 'fmcwModeConfig',
509 message: `${component.name} transmitter dechirp reference requires a transmitter name.`,
513 if ('waveform_name' in reference) {
516 itemId: component.id,
517 componentId: component.id,
518 field: 'fmcwModeConfig',
519 message: `${component.name} transmitter dechirp reference must not set a waveform name.`,
522 if (!fmcwEmitterNames.has(transmitterName)) {
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.`,
535 typeof reference.waveform_name === 'string'
536 ? reference.waveform_name
538 if (waveformName.trim().length === 0) {
541 itemId: component.id,
542 componentId: component.id,
543 field: 'fmcwModeConfig',
544 message: `${component.name} custom dechirp reference requires a waveform name.`,
548 if ('transmitter_name' in reference) {
551 itemId: component.id,
552 componentId: component.id,
553 field: 'fmcwModeConfig',
554 message: `${component.name} custom dechirp reference must not set a transmitter name.`,
557 if (!fmcwWaveformNames.has(waveformName)) {
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.`,
573export function validateFmcwScenario(
574 scenario: Pick<ScenarioData, 'globalParameters' | 'waveforms' | 'platforms'>
575): FmcwValidationIssue[] {
576 const issues = scenario.waveforms.flatMap((waveform) =>
577 validateFmcwWaveform(waveform, scenario.globalParameters)
579 const waveformsById = new Map(
580 scenario.waveforms.map((waveform) => [waveform.id, waveform])
582 const fmcwWaveformNames = new Set(
584 .filter((waveform) => isFmcwWaveformType(waveform.waveformType))
585 .map((waveform) => waveform.name)
587 const fmcwEmitterNames = new Set(
588 scenario.platforms.flatMap((platform) =>
589 platform.components.flatMap((component) => {
591 component.type !== 'transmitter' &&
592 component.type !== 'monostatic'
596 const waveform = component.waveformId
597 ? waveformsById.get(component.waveformId)
599 return component.radarType === 'fmcw' &&
600 isFmcwWaveform(waveform)
607 for (const platform of scenario.platforms) {
608 for (const component of platform.components) {
610 component.type === 'receiver' ||
611 component.type === 'monostatic'
614 ...validateFmcwReceiverDechirpConfig(
618 scenario.globalParameters
624 component.type !== 'transmitter' &&
625 component.type !== 'monostatic'
630 if (component.radarType !== 'fmcw' || !component.waveformId) {
631 if (component.radarType !== 'sfcw' || !component.waveformId) {
635 const waveform = waveformsById.get(component.waveformId);
636 if (!isSfcwWaveform(waveform)) {
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.`,
648 ...validateSfcwEmitterSchedule(
651 scenario.globalParameters
657 const waveform = waveformsById.get(component.waveformId);
658 if (!isFmcwWaveform(waveform)) {
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.`,
669 if (isAnalyticFmcwWaveform(waveform)) {
671 ...validateFmcwEmitterSchedule(
674 scenario.globalParameters
684export function getBlockingFmcwValidationMessage(
685 scenario: Pick<ScenarioData, 'globalParameters' | 'waveforms' | 'platforms'>
687 const firstError = validateFmcwScenario(scenario).find(
688 (issue) => issue.severity === 'error'
690 return firstError?.message ?? null;