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 FmcwWaveform = Extract<
31 { waveformType: 'fmcw_linear_chirp' | 'fmcw_triangle' }
34type FmcwEmitterComponent = Extract<
36 { type: 'transmitter' | 'monostatic' }
39const TRIANGLE_EPSILON = 1e-12;
40const IF_CHAIN_FIELD_KEYS = [
42 'if_filter_bandwidth',
43 'if_filter_transition_width',
46const isFmcwWaveform = (
47 waveform: Waveform | undefined
48): waveform is FmcwWaveform =>
49 waveform?.waveformType === 'fmcw_linear_chirp' ||
50 waveform?.waveformType === 'fmcw_triangle';
52const formatNumber = (value: number): string =>
53 value.toLocaleString(undefined, { maximumSignificantDigits: 6 });
56 issues: FmcwValidationIssue[],
57 issue: FmcwValidationIssue
62function hasIfChainFields(config: unknown): boolean {
65 IF_CHAIN_FIELD_KEYS.some((key) => Object.hasOwn(config, key))
69function getValidIfChainNumber(
71 key: (typeof IF_CHAIN_FIELD_KEYS)[number],
73 component: Pick<PlatformComponent, 'id' | 'name'>,
74 issues: FmcwValidationIssue[]
75): number | undefined {
76 if (!isRecord(config) || !Object.hasOwn(config, key)) {
80 const value = config[key];
81 if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
85 componentId: component.id,
86 field: 'fmcwModeConfig',
87 message: `${component.name} ${label} must be a finite positive value.`,
94function effectiveSchedule(
95 schedule: SchedulePeriod[],
96 globalParameters: GlobalParameters
98 return schedule.length > 0
100 : [{ start: globalParameters.start, end: globalParameters.end }];
103export function validateFmcwWaveform(
105 globalParameters: GlobalParameters
106): FmcwValidationIssue[] {
107 if (!isFmcwWaveform(waveform)) {
111 const issues: FmcwValidationIssue[] = [];
112 const sweepStart = waveform.start_frequency_offset ?? 0;
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;
125 waveform.waveformType === 'fmcw_linear_chirp' &&
126 waveform.chirp_period < waveform.chirp_duration
131 waveformId: waveform.id,
132 field: 'chirp_period',
134 'Chirp period must be greater than or equal to chirp duration.',
138 if (effectiveRate <= maxBaseband) {
142 waveformId: waveform.id,
143 message: `Effective sample rate ${formatNumber(
145 )} Hz must exceed FMCW sweep baseband ${formatNumber(
149 } else if (maxBaseband > 0 && effectiveRate < 1.1 * maxBaseband) {
153 waveformId: waveform.id,
154 message: `Effective sample rate ${formatNumber(
156 )} Hz is within 10% of the FMCW aliasing limit ${formatNumber(
162 if (waveform.carrier_frequency + fLow <= 0) {
166 waveformId: waveform.id,
168 'Carrier frequency plus the lower sweep edge must stay positive.',
175function validateFmcwEmitterSchedule(
176 component: FmcwEmitterComponent,
177 waveform: FmcwWaveform,
178 globalParameters: GlobalParameters
179): FmcwValidationIssue[] {
180 const issues: FmcwValidationIssue[] = [];
181 const schedule = effectiveSchedule(component.schedule, globalParameters);
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) {
189 itemId: component.id,
190 componentId: component.id,
191 waveformId: waveform.id,
193 message: `${component.name} has schedule duration ${formatNumber(
195 )} s shorter than FMCW chirp duration ${formatNumber(
196 waveform.chirp_duration
199 } else if (duration < waveform.chirp_period) {
202 itemId: component.id,
203 componentId: component.id,
204 waveformId: waveform.id,
206 message: `${component.name} has schedule duration ${formatNumber(
208 )} s shorter than FMCW chirp period ${formatNumber(
209 waveform.chirp_period
216 const trianglePeriod = 2 * waveform.chirp_duration;
217 if (duration < trianglePeriod) {
220 itemId: component.id,
221 componentId: component.id,
222 waveformId: waveform.id,
224 message: `${component.name} has schedule duration ${formatNumber(
226 )} s shorter than FMCW triangle period ${formatNumber(
233 const fullTriangles = Math.floor(duration / trianglePeriod);
234 const leftover = duration - fullTriangles * trianglePeriod;
235 if (leftover > TRIANGLE_EPSILON) {
238 itemId: component.id,
239 componentId: component.id,
240 waveformId: waveform.id,
242 message: `${component.name} schedule leaves ${formatNumber(
244 )} s silent after the last complete FMCW triangle.`,
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[] = [];
260 if (component.radarType !== 'fmcw') {
264 const config = component.fmcwModeConfig;
265 const mode = getDechirpMode(config);
267 isRecord(config) && isRecord(config.dechirp_reference)
268 ? config.dechirp_reference
271 if (mode === 'none') {
275 itemId: component.id,
276 componentId: component.id,
277 field: 'fmcwModeConfig',
278 message: `${component.name} declares a dechirp reference while dechirp mode is none.`,
281 if (hasIfChainFields(config)) {
284 itemId: component.id,
285 componentId: component.id,
286 field: 'fmcwModeConfig',
287 message: `${component.name} declares IF-chain settings while dechirp mode is none.`,
293 const ifSampleRate = getValidIfChainNumber(
300 const ifFilterBandwidth = getValidIfChainNumber(
302 'if_filter_bandwidth',
303 'IF filter bandwidth',
307 getValidIfChainNumber(
309 'if_filter_transition_width',
310 'IF transition width',
315 ifSampleRate === undefined &&
316 (ifFilterBandwidth !== undefined ||
318 Object.hasOwn(config, 'if_filter_transition_width')))
322 itemId: component.id,
323 componentId: component.id,
324 field: 'fmcwModeConfig',
325 message: `${component.name} IF filter settings require an IF sample rate.`,
329 ifSampleRate !== undefined &&
330 ifFilterBandwidth !== undefined &&
331 ifFilterBandwidth >= ifSampleRate / 2
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.`,
342 ifSampleRate !== undefined &&
343 ifSampleRate > globalParameters.rate * globalParameters.oversample_ratio
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.`,
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.`,
365 if (!isDechirpReferenceSource(reference.source)) {
368 itemId: component.id,
369 componentId: component.id,
370 field: 'fmcwModeConfig',
371 message: `${component.name} dechirp reference source must be attached, transmitter, or custom.`,
376 switch (reference.source) {
378 if (component.type !== 'monostatic') {
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.`,
388 'transmitter_name' in reference ||
389 'waveform_name' in reference
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.`,
400 case 'transmitter': {
401 const transmitterName =
402 typeof reference.transmitter_name === 'string'
403 ? reference.transmitter_name
405 if (transmitterName.trim().length === 0) {
408 itemId: component.id,
409 componentId: component.id,
410 field: 'fmcwModeConfig',
411 message: `${component.name} transmitter dechirp reference requires a transmitter name.`,
415 if ('waveform_name' in reference) {
418 itemId: component.id,
419 componentId: component.id,
420 field: 'fmcwModeConfig',
421 message: `${component.name} transmitter dechirp reference must not set a waveform name.`,
424 if (!fmcwEmitterNames.has(transmitterName)) {
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.`,
437 typeof reference.waveform_name === 'string'
438 ? reference.waveform_name
440 if (waveformName.trim().length === 0) {
443 itemId: component.id,
444 componentId: component.id,
445 field: 'fmcwModeConfig',
446 message: `${component.name} custom dechirp reference requires a waveform name.`,
450 if ('transmitter_name' in reference) {
453 itemId: component.id,
454 componentId: component.id,
455 field: 'fmcwModeConfig',
456 message: `${component.name} custom dechirp reference must not set a transmitter name.`,
459 if (!fmcwWaveformNames.has(waveformName)) {
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.`,
475export function validateFmcwScenario(
476 scenario: Pick<ScenarioData, 'globalParameters' | 'waveforms' | 'platforms'>
477): FmcwValidationIssue[] {
478 const issues = scenario.waveforms.flatMap((waveform) =>
479 validateFmcwWaveform(waveform, scenario.globalParameters)
481 const waveformsById = new Map(
482 scenario.waveforms.map((waveform) => [waveform.id, waveform])
484 const fmcwWaveformNames = new Set(
486 .filter((waveform) => isFmcwWaveformType(waveform.waveformType))
487 .map((waveform) => waveform.name)
489 const fmcwEmitterNames = new Set(
490 scenario.platforms.flatMap((platform) =>
491 platform.components.flatMap((component) => {
493 component.type !== 'transmitter' &&
494 component.type !== 'monostatic'
498 const waveform = component.waveformId
499 ? waveformsById.get(component.waveformId)
501 return component.radarType === 'fmcw' &&
502 isFmcwWaveform(waveform)
509 for (const platform of scenario.platforms) {
510 for (const component of platform.components) {
512 component.type === 'receiver' ||
513 component.type === 'monostatic'
516 ...validateFmcwReceiverDechirpConfig(
520 scenario.globalParameters
526 component.type !== 'transmitter' &&
527 component.type !== 'monostatic'
532 if (component.radarType !== 'fmcw' || !component.waveformId) {
536 const waveform = waveformsById.get(component.waveformId);
537 if (!isFmcwWaveform(waveform)) {
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.`,
549 ...validateFmcwEmitterSchedule(
552 scenario.globalParameters
561export function getBlockingFmcwValidationMessage(
562 scenario: Pick<ScenarioData, 'globalParameters' | 'waveforms' | 'platforms'>
564 const firstError = validateFmcwScenario(scenario).find(
565 (issue) => issue.severity === 'error'
567 return firstError?.message ?? null;