1// SPDX-License-Identifier: GPL-2.0-only
2// Copyright (c) 2026-present FERS Contributors (see AUTHORS.md).
4import { describe, expect, test } from 'bun:test';
5import { createWaveformForType, defaultGlobalParameters } from './defaults';
6import { validateFmcwScenario, validateFmcwWaveform } from './fmcwValidation';
7import type { ScenarioData, Waveform } from './types';
9const makeLinearWaveform = (
10 overrides: Partial<Extract<Waveform, { waveformType: 'fmcw_linear_chirp' }>>
11): Extract<Waveform, { waveformType: 'fmcw_linear_chirp' }> => ({
12 ...createWaveformForType('fmcw_linear_chirp'),
18const makeTriangleWaveform = (
19 overrides: Partial<Extract<Waveform, { waveformType: 'fmcw_triangle' }>>
20): Extract<Waveform, { waveformType: 'fmcw_triangle' }> => ({
21 ...createWaveformForType('fmcw_triangle'),
27const makeScenario = (waveform: Waveform): ScenarioData => ({
28 globalParameters: defaultGlobalParameters,
29 waveforms: [waveform],
38 interpolation: 'static',
64 waveformId: waveform.id,
73describe('FMCW validation', () => {
74 test('accepts file-backed FMCW without applying analytic chirp constraints', () => {
75 const waveform: Waveform = {
76 ...createWaveformForType('fmcw_from_file'),
82 expect(validateFmcwWaveform(waveform, defaultGlobalParameters)).toEqual(
85 expect(validateFmcwScenario(makeScenario(waveform))).toEqual([]);
88 test('matches backend baseband and RF sweep checks', () => {
89 const aliasIssues = validateFmcwWaveform(
90 makeLinearWaveform({ chirp_bandwidth: 20e3 }),
91 defaultGlobalParameters
93 expect(aliasIssues.some((issue) => issue.severity === 'error')).toBe(
97 const rfIssues = validateFmcwWaveform(
99 carrier_frequency: 100,
100 start_frequency_offset: -200,
102 defaultGlobalParameters
105 rfIssues.some((issue) => issue.message.includes('lower sweep edge'))
109 test('reports linear schedule errors and warnings', () => {
110 const scenario = makeScenario(
112 chirp_duration: 1e-3,
116 const component = scenario.platforms[0].components[0];
117 if (component.type !== 'transmitter') {
118 throw new Error('Expected transmitter fixture');
120 component.schedule = [
121 { start: 0, end: 0.5e-3 },
122 { start: 1, end: 1.0015 },
125 const issues = validateFmcwScenario(scenario);
126 expect(issues.some((issue) => issue.severity === 'error')).toBe(true);
127 expect(issues.some((issue) => issue.severity === 'warning')).toBe(true);
130 test('reports triangle period errors and silent-tail warnings', () => {
131 const scenario = makeScenario(
132 makeTriangleWaveform({ chirp_duration: 1e-3 })
134 const component = scenario.platforms[0].components[0];
135 if (component.type !== 'transmitter') {
136 throw new Error('Expected transmitter fixture');
138 component.schedule = [
139 { start: 0, end: 1e-3 },
140 { start: 1, end: 1.0025 },
143 const issues = validateFmcwScenario(scenario);
145 issues.some((issue) => issue.message.includes('triangle period'))
147 expect(issues.some((issue) => issue.message.includes('silent'))).toBe(
152 test('reports receiver dechirp mode/reference mismatches', () => {
153 const scenario = makeScenario(makeLinearWaveform({}));
154 scenario.platforms[0].components.push({
164 noiseTemperature: null,
165 noDirectPaths: false,
166 noPropagationLoss: false,
168 dechirp_mode: 'none',
169 dechirp_reference: { source: 'attached' },
175 validateFmcwScenario(scenario).some((issue) =>
176 issue.message.includes('while dechirp mode is none')
180 const receiver = scenario.platforms[0].components[1];
181 if (receiver.type !== 'receiver') {
182 throw new Error('Expected receiver fixture');
184 receiver.fmcwModeConfig = { dechirp_mode: 'physical' };
186 validateFmcwScenario(scenario).some((issue) =>
187 issue.message.includes('does not declare a dechirp reference')
191 receiver.fmcwModeConfig = {
192 dechirp_mode: 'ideal',
193 dechirp_reference: { source: 'attached' },
196 validateFmcwScenario(scenario).some((issue) =>
197 issue.message.includes('only monostatic receivers')
202 test('validates receiver IF-chain settings', () => {
203 const scenario = makeScenario(makeLinearWaveform({}));
204 scenario.platforms[0].components.push({
214 noiseTemperature: null,
215 noDirectPaths: false,
216 noPropagationLoss: false,
218 dechirp_mode: 'physical',
220 source: 'transmitter',
221 transmitter_name: 'FMCW Tx',
224 if_filter_bandwidth: 1e3,
225 if_filter_transition_width: 500,
231 validateFmcwScenario(scenario).some(
232 (issue) => issue.field === 'fmcwModeConfig'
236 const receiver = scenario.platforms[0].components[1];
237 if (receiver.type !== 'receiver') {
238 throw new Error('Expected receiver fixture');
241 receiver.fmcwModeConfig = {
242 dechirp_mode: 'none',
246 validateFmcwScenario(scenario).some((issue) =>
247 issue.message.includes('IF-chain settings')
251 receiver.fmcwModeConfig = {
252 dechirp_mode: 'physical',
254 source: 'transmitter',
255 transmitter_name: 'FMCW Tx',
260 validateFmcwScenario(scenario).some((issue) =>
261 issue.message.includes('IF sample rate')
265 receiver.fmcwModeConfig = {
266 dechirp_mode: 'physical',
268 source: 'transmitter',
269 transmitter_name: 'FMCW Tx',
272 if_filter_bandwidth: 2e3,
275 validateFmcwScenario(scenario).some((issue) =>
276 issue.message.includes('less than half')
280 receiver.fmcwModeConfig = {
281 dechirp_mode: 'physical',
283 source: 'transmitter',
284 transmitter_name: 'FMCW Tx',
287 defaultGlobalParameters.rate *
288 defaultGlobalParameters.oversample_ratio +
292 validateFmcwScenario(scenario).some((issue) =>
293 issue.message.includes('effective simulation sample rate')
298 test('validates named transmitter and custom waveform dechirp references', () => {
299 const waveform = makeLinearWaveform({ id: 'wave-1', name: 'FMCW LO' });
300 const scenario = makeScenario(waveform);
301 scenario.waveforms.push({
307 carrier_frequency: 1e9,
309 scenario.platforms[0].components.push({
319 noiseTemperature: null,
320 noDirectPaths: false,
321 noPropagationLoss: false,
323 dechirp_mode: 'physical',
325 source: 'transmitter',
326 transmitter_name: 'FMCW Tx',
333 validateFmcwScenario(scenario).some(
334 (issue) => issue.field === 'fmcwModeConfig'
338 const receiver = scenario.platforms[0].components[1];
339 if (receiver.type !== 'receiver') {
340 throw new Error('Expected receiver fixture');
343 receiver.fmcwModeConfig = {
344 dechirp_mode: 'physical',
346 source: 'transmitter',
347 transmitter_name: 'Missing TX',
351 validateFmcwScenario(scenario).some((issue) =>
352 issue.message.includes('must be an FMCW transmitter')
356 receiver.fmcwModeConfig = {
357 dechirp_mode: 'ideal',
360 waveform_name: 'FMCW LO',
364 validateFmcwScenario(scenario).some(
365 (issue) => issue.field === 'fmcwModeConfig'
369 receiver.fmcwModeConfig = {
370 dechirp_mode: 'ideal',
373 waveform_name: 'CW Tone',
377 validateFmcwScenario(scenario).some((issue) =>
378 issue.message.includes('must be a top-level FMCW waveform')