FERS 0.1.0
The Flexible Extensible Radar Simulator
Loading...
Searching...
No Matches
WaveformInspector.tsx
Go to the documentation of this file.
1// SPDX-License-Identifier: GPL-2.0-only
2// Copyright (c) 2025-present FERS Contributors (see AUTHORS.md).
3
4import {
5 Alert,
6 Box,
7 FormControl,
8 InputLabel,
9 MenuItem,
10 Select,
11} from '@mui/material';
12import { useScenarioStore, Waveform } from '@/stores/scenarioStore';
13import { createWaveformForType as createWaveformDefaultsForType } from '@/stores/scenarioStore/defaults';
14import { validateFmcwWaveform } from '@/stores/scenarioStore/fmcwValidation';
15import { BufferedTextField, FileInput, NumberField } from './InspectorControls';
16
17export type WaveformType =
18 | 'pulsed_from_file'
19 | 'cw'
20 | 'fmcw_linear_chirp'
21 | 'fmcw_triangle';
22
23type FmcwLinearChirpFields = {
24 direction: 'up' | 'down';
25 chirp_bandwidth: number;
26 chirp_duration: number;
27 chirp_period: number;
28 start_frequency_offset: number | null;
29 chirp_count: number | null;
30};
31
32type FmcwTriangleFields = {
33 chirp_bandwidth: number;
34 chirp_duration: number;
35 start_frequency_offset: number | null;
36 triangle_count: number | null;
37};
38
39type AuthorableWaveform = Omit<Waveform, 'waveformType'> &
40 Partial<FmcwLinearChirpFields> &
41 Partial<FmcwTriangleFields> & {
42 waveformType: WaveformType;
43 filename?: string;
44 };
45
46export const WAVEFORM_TYPE_OPTIONS: ReadonlyArray<{
47 value: WaveformType;
48 label: string;
49}> = [
50 { value: 'pulsed_from_file', label: 'Pulse File' },
51 { value: 'cw', label: 'CW' },
52 { value: 'fmcw_linear_chirp', label: 'FMCW Linear Chirp' },
53 { value: 'fmcw_triangle', label: 'FMCW Triangle' },
54];
55
56const DEFAULT_FMCW_LINEAR_CHIRP_FIELDS =
57 createWaveformDefaultsForType('fmcw_linear_chirp');
58const DEFAULT_FMCW_TRIANGLE_FIELDS =
59 createWaveformDefaultsForType('fmcw_triangle');
60
61interface WaveformInspectorProps {
62 item: Waveform;
63}
64
65const asNumberOrDefault = (value: unknown, fallback: number): number =>
66 typeof value === 'number' && Number.isFinite(value) ? value : fallback;
67
68const asNullableNumber = (value: unknown): number | null =>
69 typeof value === 'number' && Number.isFinite(value) ? value : null;
70
71const asNullableNumberOrDefault = (
72 value: unknown,
73 fallback: number | null
74): number | null =>
75 typeof value === 'number' && Number.isFinite(value) ? value : fallback;
76
77const asNullableInteger = (value: unknown): number | null =>
78 typeof value === 'number' && Number.isFinite(value)
79 ? Math.trunc(value)
80 : null;
81
82export function createWaveformForType(
83 waveform: AuthorableWaveform,
84 waveformType: WaveformType
85): AuthorableWaveform {
86 const nextWaveform = {
87 ...waveform,
88 waveformType,
89 };
90
91 if (waveformType === 'pulsed_from_file') {
92 return {
93 ...nextWaveform,
94 filename:
95 typeof waveform.filename === 'string' ? waveform.filename : '',
96 };
97 }
98
99 if (waveformType === 'fmcw_linear_chirp') {
100 return {
101 ...nextWaveform,
102 direction: waveform.direction === 'down' ? 'down' : 'up',
103 chirp_bandwidth: asNumberOrDefault(
104 waveform.chirp_bandwidth,
105 DEFAULT_FMCW_LINEAR_CHIRP_FIELDS.chirp_bandwidth
106 ),
107 chirp_duration: asNumberOrDefault(
108 waveform.chirp_duration,
109 DEFAULT_FMCW_LINEAR_CHIRP_FIELDS.chirp_duration
110 ),
111 chirp_period: asNumberOrDefault(
112 waveform.chirp_period,
113 DEFAULT_FMCW_LINEAR_CHIRP_FIELDS.chirp_period
114 ),
115 start_frequency_offset: asNullableNumberOrDefault(
116 waveform.start_frequency_offset,
117 DEFAULT_FMCW_LINEAR_CHIRP_FIELDS.start_frequency_offset
118 ),
119 chirp_count: asNullableInteger(waveform.chirp_count),
120 };
121 }
122
123 if (waveformType === 'fmcw_triangle') {
124 return {
125 ...nextWaveform,
126 chirp_bandwidth: asNumberOrDefault(
127 waveform.chirp_bandwidth,
128 DEFAULT_FMCW_TRIANGLE_FIELDS.chirp_bandwidth
129 ),
130 chirp_duration: asNumberOrDefault(
131 waveform.chirp_duration,
132 DEFAULT_FMCW_TRIANGLE_FIELDS.chirp_duration
133 ),
134 start_frequency_offset: asNullableNumberOrDefault(
135 waveform.start_frequency_offset,
136 DEFAULT_FMCW_TRIANGLE_FIELDS.start_frequency_offset
137 ),
138 triangle_count: asNullableInteger(waveform.triangle_count),
139 };
140 }
141
142 return nextWaveform;
143}
144
145export function getVisibleWaveformFieldLabels(
146 waveformType: WaveformType
147): string[] {
148 if (waveformType === 'pulsed_from_file') {
149 return ['Waveform File (.csv, .h5)'];
150 }
151
152 if (waveformType === 'fmcw_linear_chirp') {
153 return [
154 'Direction',
155 'Chirp Bandwidth (Hz)',
156 'Chirp Duration (s)',
157 'Chirp Period (s)',
158 'Start Frequency Offset (Hz)',
159 'Chirp Count',
160 ];
161 }
162
163 if (waveformType === 'fmcw_triangle') {
164 return [
165 'Chirp Bandwidth (Hz)',
166 'Chirp Duration (s)',
167 'Start Frequency Offset (Hz)',
168 'Triangle Count',
169 ];
170 }
171
172 return [];
173}
174
175export function WaveformInspector({ item }: WaveformInspectorProps) {
176 const { updateItem, globalParameters } = useScenarioStore.getState();
177 const waveform = item as AuthorableWaveform;
178 const fmcwIssues = validateFmcwWaveform(item, globalParameters);
179 const getFieldIssue = (field: string) =>
180 fmcwIssues.find((issue) => issue.field === field);
181 const globalFmcwIssues = fmcwIssues.filter((issue) => !issue.field);
182 const handleChange = (path: string, value: unknown) =>
183 updateItem(item.id, path, value);
184 const handleTypeChange = (waveformType: WaveformType) => {
185 const nextWaveform = createWaveformForType(waveform, waveformType);
186 const currentValues = waveform as Record<string, unknown>;
187 const nextEntries = Object.entries(nextWaveform).filter(
188 ([key]) => key !== 'id' && key !== 'type'
189 );
190
191 for (const [key, value] of nextEntries.filter(
192 ([key]) => key !== 'waveformType'
193 )) {
194 if (!Object.is(currentValues[key], value)) {
195 handleChange(key, value);
196 }
197 }
198
199 if (waveform.waveformType !== waveformType) {
200 handleChange('waveformType', waveformType);
201 }
202 };
203
204 return (
205 <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
206 <BufferedTextField
207 label="Name"
208 variant="outlined"
209 size="small"
210 fullWidth
211 value={item.name}
212 allowEmpty={false}
213 onChange={(v) => handleChange('name', v)}
214 />
215 <FormControl fullWidth size="small">
216 <InputLabel>Type</InputLabel>
217 <Select
218 label="Type"
219 value={waveform.waveformType}
220 onChange={(e) =>
221 handleTypeChange(e.target.value as WaveformType)
222 }
223 >
224 {WAVEFORM_TYPE_OPTIONS.map((option) => (
225 <MenuItem key={option.value} value={option.value}>
226 {option.label}
227 </MenuItem>
228 ))}
229 </Select>
230 </FormControl>
231 <NumberField
232 label="Power (W)"
233 value={item.power}
234 emptyBehavior="revert"
235 onChange={(v) => handleChange('power', v)}
236 />
237 <NumberField
238 label="Carrier Frequency (Hz)"
239 value={item.carrier_frequency}
240 emptyBehavior="revert"
241 onChange={(v) => handleChange('carrier_frequency', v)}
242 />
243 {globalFmcwIssues.map((issue) => (
244 <Alert
245 key={issue.message}
246 severity={issue.severity}
247 variant="outlined"
248 >
249 {issue.message}
250 </Alert>
251 ))}
252 {waveform.waveformType === 'pulsed_from_file' && (
253 <FileInput
254 label="Waveform File (.csv, .h5)"
255 value={waveform.filename}
256 onChange={(v) => handleChange('filename', v)}
257 filters={[
258 { name: 'Waveform', extensions: ['csv', 'h5'] },
259 { name: 'All Files', extensions: ['*'] },
260 ]}
261 />
262 )}
263 {waveform.waveformType === 'fmcw_linear_chirp' && (
264 <>
265 <FormControl fullWidth size="small">
266 <InputLabel>Direction</InputLabel>
267 <Select
268 label="Direction"
269 value={waveform.direction ?? 'up'}
270 onChange={(e) =>
271 handleChange('direction', e.target.value)
272 }
273 >
274 <MenuItem value="up">Up</MenuItem>
275 <MenuItem value="down">Down</MenuItem>
276 </Select>
277 </FormControl>
278 <NumberField
279 label="Chirp Bandwidth (Hz)"
280 value={asNumberOrDefault(
281 waveform.chirp_bandwidth,
282 DEFAULT_FMCW_LINEAR_CHIRP_FIELDS.chirp_bandwidth
283 )}
284 emptyBehavior="revert"
285 onChange={(v) => handleChange('chirp_bandwidth', v)}
286 />
287 <NumberField
288 label="Chirp Duration (s)"
289 value={asNumberOrDefault(
290 waveform.chirp_duration,
291 DEFAULT_FMCW_LINEAR_CHIRP_FIELDS.chirp_duration
292 )}
293 emptyBehavior="revert"
294 onChange={(v) => handleChange('chirp_duration', v)}
295 />
296 <NumberField
297 label="Chirp Period (s)"
298 value={asNumberOrDefault(
299 waveform.chirp_period,
300 DEFAULT_FMCW_LINEAR_CHIRP_FIELDS.chirp_period
301 )}
302 emptyBehavior="revert"
303 helperText={getFieldIssue('chirp_period')?.message}
304 externalError={
305 getFieldIssue('chirp_period')?.severity === 'error'
306 }
307 onChange={(v) => handleChange('chirp_period', v)}
308 />
309 <NumberField
310 label="Start Frequency Offset (Hz)"
311 value={asNullableNumber(
312 waveform.start_frequency_offset
313 )}
314 emptyBehavior="null"
315 onChange={(v) =>
316 handleChange('start_frequency_offset', v)
317 }
318 />
319 <NumberField
320 label="Chirp Count"
321 value={asNullableInteger(waveform.chirp_count)}
322 emptyBehavior="null"
323 onChange={(v) =>
324 handleChange(
325 'chirp_count',
326 v === null ? null : Math.trunc(v)
327 )
328 }
329 />
330 </>
331 )}
332 {waveform.waveformType === 'fmcw_triangle' && (
333 <>
334 <NumberField
335 label="Chirp Bandwidth (Hz)"
336 value={asNumberOrDefault(
337 waveform.chirp_bandwidth,
338 DEFAULT_FMCW_TRIANGLE_FIELDS.chirp_bandwidth
339 )}
340 emptyBehavior="revert"
341 onChange={(v) => handleChange('chirp_bandwidth', v)}
342 />
343 <NumberField
344 label="Chirp Duration (s)"
345 value={asNumberOrDefault(
346 waveform.chirp_duration,
347 DEFAULT_FMCW_TRIANGLE_FIELDS.chirp_duration
348 )}
349 emptyBehavior="revert"
350 onChange={(v) => handleChange('chirp_duration', v)}
351 />
352 <NumberField
353 label="Start Frequency Offset (Hz)"
354 value={asNullableNumber(
355 waveform.start_frequency_offset
356 )}
357 emptyBehavior="null"
358 onChange={(v) =>
359 handleChange('start_frequency_offset', v)
360 }
361 />
362 <NumberField
363 label="Triangle Count"
364 value={asNullableInteger(waveform.triangle_count)}
365 emptyBehavior="null"
366 onChange={(v) =>
367 handleChange(
368 'triangle_count',
369 v === null ? null : Math.trunc(v)
370 )
371 }
372 />
373 </>
374 )}
375 </Box>
376 );
377}