FERS 0.1.0
The Flexible Extensible Radar Simulator
Loading...
Searching...
No Matches
SimulationView.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 KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
5import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRight';
6import MapIcon from '@mui/icons-material/Map';
7import PlayCircleOutlineIcon from '@mui/icons-material/PlayCircleOutline';
8import {
9 Box,
10 Button,
11 Card,
12 CardActions,
13 CardContent,
14 CircularProgress,
15 Collapse,
16 Grid,
17 IconButton,
18 LinearProgress,
19 List,
20 ListItem,
21 ListItemText,
22 Paper,
23 Table,
24 TableBody,
25 TableCell,
26 TableContainer,
27 TableHead,
28 TableRow,
29 TextField,
30 Typography,
31} from '@mui/material';
32import { invoke } from '@tauri-apps/api/core';
33import { listen } from '@tauri-apps/api/event';
34import { dirname, join } from '@tauri-apps/api/path';
35import { open, save } from '@tauri-apps/plugin-dialog';
36import React, { useEffect, useRef, useState } from 'react';
37import { useScenarioStore } from '@/stores/scenarioStore';
38import { getBlockingFmcwValidationMessage } from '@/stores/scenarioStore/fmcwValidation';
39import {
40 normalizeSimulationOutputMetadata,
41 type RawSimulationOutputMetadata,
42 type SimulationOutputFileMetadata,
43 type SimulationProgressState,
44 useSimulationProgressStore,
45} from '@/stores/simulationProgressStore';
46import {
47 addSimulationProgressEvent,
48 getSimulationProgressPercent,
49 normalizeCompletedProgressSnapshot,
50} from './simulationProgress';
51
52export const SimulationView = React.memo(function SimulationView() {
53 const [metadataExportPath, setMetadataExportPath] = useState<string | null>(
54 null
55 );
56 const [expandedMetadataPaths, setExpandedMetadataPaths] = useState<
57 Set<string>
58 >(() => new Set());
59 const isSimulating = useSimulationProgressStore(
60 (state) => state.isSimulating
61 );
62 const isGeneratingKml = useSimulationProgressStore(
63 (state) => state.isGeneratingKml
64 );
65 const setIsGeneratingKml = useSimulationProgressStore(
66 (state) => state.setIsGeneratingKml
67 );
68 const simulationProgress = useSimulationProgressStore(
69 (state) => state.simulationProgress
70 );
71 const simulationRunStatus = useSimulationProgressStore(
72 (state) => state.simulationRunStatus
73 );
74 const simulationRunError = useSimulationProgressStore(
75 (state) => state.simulationRunError
76 );
77 const simulationOutputMetadata = useSimulationProgressStore(
78 (state) => state.simulationOutputMetadata
79 );
80 const startSimulationRun = useSimulationProgressStore(
81 (state) => state.startSimulationRun
82 );
83 const setSimulationProgressSnapshot = useSimulationProgressStore(
84 (state) => state.setSimulationProgressSnapshot
85 );
86 const setSimulationOutputMetadata = useSimulationProgressStore(
87 (state) => state.setSimulationOutputMetadata
88 );
89 const completeSimulationRun = useSimulationProgressStore(
90 (state) => state.completeSimulationRun
91 );
92 const failSimulationRun = useSimulationProgressStore(
93 (state) => state.failSimulationRun
94 );
95 const showError = useScenarioStore((state) => state.showError);
96 const showSuccess = useScenarioStore((state) => state.showSuccess);
97 const scenarioFilePath = useScenarioStore(
98 (state) => state.scenarioFilePath
99 );
100 const outputDirectory = useScenarioStore((state) => state.outputDirectory);
101 const setOutputDirectory = useScenarioStore(
102 (state) => state.setOutputDirectory
103 );
104
105 // Use a Ref to store incoming data to avoid triggering re-renders on every event
106 const progressRef = useRef<Record<string, SimulationProgressState>>({});
107
108 useEffect(() => {
109 let animationFrameId: number | undefined;
110
111 const flushProgress = () => {
112 setSimulationProgressSnapshot({ ...progressRef.current });
113 };
114
115 // The update loop synchronizes the Ref data to the State at screen refresh rate
116 const updateLoop = () => {
117 if (useSimulationProgressStore.getState().isSimulating) {
118 flushProgress();
119 animationFrameId = requestAnimationFrame(updateLoop);
120 }
121 };
122
123 const unlistenSimComplete = listen<void>('simulation-complete', () => {
124 console.log('Simulation completed successfully.');
125 progressRef.current = normalizeCompletedProgressSnapshot(
126 progressRef.current
127 );
128 flushProgress();
129 completeSimulationRun();
130 if (animationFrameId !== undefined) {
131 cancelAnimationFrame(animationFrameId);
132 }
133 });
134
135 const unlistenSimError = listen<string>('simulation-error', (event) => {
136 const errorMessage = `Simulation failed: ${event.payload}`;
137 console.error(errorMessage);
138 showError(errorMessage);
139 flushProgress();
140 failSimulationRun(errorMessage);
141 if (animationFrameId !== undefined) {
142 cancelAnimationFrame(animationFrameId);
143 }
144 });
145
146 const unlistenSimProgress = listen<SimulationProgressState>(
147 'simulation-progress',
148 (event) => {
149 progressRef.current = addSimulationProgressEvent(
150 progressRef.current,
151 event.payload
152 );
153 }
154 );
155
156 const unlistenOutputMetadata = listen<string>(
157 'simulation-output-metadata',
158 (event) => {
159 try {
160 setSimulationOutputMetadata(
161 normalizeSimulationOutputMetadata(
162 JSON.parse(
163 event.payload
164 ) as RawSimulationOutputMetadata
165 )
166 );
167 } catch (err) {
168 const errorMessage =
169 err instanceof Error ? err.message : String(err);
170 showError(
171 `Failed to decode simulation metadata: ${errorMessage}`
172 );
173 }
174 }
175 );
176
177 const unlistenKmlComplete = listen<string>(
178 'kml-generation-complete',
179 (event) => {
180 console.log('KML generated successfully at:', event.payload);
181 setIsGeneratingKml(false);
182 }
183 );
184
185 const unlistenKmlError = listen<string>(
186 'kml-generation-error',
187 (event) => {
188 const errorMessage = `KML generation failed: ${event.payload}`;
189 console.error(errorMessage);
190 showError(errorMessage);
191 setIsGeneratingKml(false);
192 }
193 );
194
195 // Start the UI update loop if we are simulating
196 if (isSimulating) {
197 updateLoop();
198 }
199
200 return () => {
201 if (animationFrameId !== undefined) {
202 cancelAnimationFrame(animationFrameId);
203 }
204 Promise.all([
205 unlistenSimComplete,
206 unlistenSimError,
207 unlistenSimProgress,
208 unlistenOutputMetadata,
209 unlistenKmlComplete,
210 unlistenKmlError,
211 ]).then((unlisteners) => {
212 unlisteners.forEach((unlisten) => unlisten());
213 });
214 };
215 }, [
216 isSimulating,
217 setSimulationProgressSnapshot,
218 setSimulationOutputMetadata,
219 completeSimulationRun,
220 failSimulationRun,
221 setIsGeneratingKml,
222 showError,
223 ]);
224
225 const getEffectiveOutputDir = async () => {
226 if (outputDirectory) return outputDirectory;
227 if (scenarioFilePath) {
228 try {
229 return await dirname(scenarioFilePath);
230 } catch (e) {
231 console.warn('Failed to get dirname of scenario file', e);
232 }
233 }
234 return '.';
235 };
236
237 const handleSelectOutputDir = async () => {
238 try {
239 const selected = await open({
240 directory: true,
241 multiple: false,
242 defaultPath: await getEffectiveOutputDir(),
243 });
244 if (typeof selected === 'string') {
245 setOutputDirectory(selected);
246 }
247 } catch (err) {
248 console.error('Failed to open directory dialog:', err);
249 }
250 };
251
252 const handleRunSimulation = async () => {
253 const scenarioState = useScenarioStore.getState();
254 const validationMessage =
255 getBlockingFmcwValidationMessage(scenarioState);
256 if (validationMessage) {
257 showError(`FMCW validation failed: ${validationMessage}`);
258 return;
259 }
260
261 progressRef.current = {};
262 setMetadataExportPath(null);
263 startSimulationRun();
264 try {
265 // Ensure the C++ backend has the latest scenario from the UI
266 const effectiveDir = await getEffectiveOutputDir();
267 await invoke('set_output_directory', { dir: effectiveDir });
268
269 await useScenarioStore.getState().syncBackend();
270 await invoke('run_simulation');
271 } catch (err) {
272 const errorMessage =
273 err instanceof Error ? err.message : String(err);
274 console.error('Failed to invoke simulation:', errorMessage);
275 showError(`Failed to start simulation: ${errorMessage}`);
276 failSimulationRun(`Failed to start simulation: ${errorMessage}`);
277 }
278 };
279
280 const handleGenerateKml = async () => {
281 try {
282 const scenarioState = useScenarioStore.getState();
283 const validationMessage =
284 getBlockingFmcwValidationMessage(scenarioState);
285 if (validationMessage) {
286 showError(`FMCW validation failed: ${validationMessage}`);
287 return;
288 }
289
290 const effectiveDir = await getEffectiveOutputDir();
291
292 // 1. Get the simulation name from the store
293 const simName =
294 scenarioState.globalParameters.simulation_name || 'scenario';
295
296 // 2. Sanitize the name and append extension
297 const suggestedFileName = `${simName.replace(/[^a-z0-9]/gi, '_')}.kml`;
298
299 // 3. Join the directory and filename to create the pre-fill path
300 const defaultPath = await join(effectiveDir, suggestedFileName);
301
302 await invoke('set_output_directory', { dir: effectiveDir });
303
304 const outputPath = await save({
305 title: 'Save KML File',
306 defaultPath: defaultPath,
307 filters: [{ name: 'KML File', extensions: ['kml'] }],
308 });
309
310 if (outputPath) {
311 setIsGeneratingKml(true);
312 // Ensure the C++ backend has the latest scenario from the UI
313 await useScenarioStore.getState().syncBackend();
314 await invoke('generate_kml', { outputPath });
315 }
316 } catch (err) {
317 const errorMessage =
318 err instanceof Error ? err.message : String(err);
319 console.error('Failed to invoke KML generation:', errorMessage);
320 showError(`Failed to start KML generation: ${errorMessage}`);
321 setIsGeneratingKml(false); // Stop on invocation failure
322 }
323 };
324
325 const hasProgress = Object.keys(simulationProgress).length > 0;
326 const progressPanelVisible =
327 isSimulating || hasProgress || simulationRunStatus === 'failed';
328 const mainProgress = simulationProgress['main'];
329 const progressHeading = mainProgress
330 ? mainProgress.message
331 : simulationRunStatus === 'completed'
332 ? 'Simulation complete'
333 : simulationRunStatus === 'failed'
334 ? 'Simulation failed'
335 : 'Preparing simulation...';
336 const otherProgresses = Object.entries(simulationProgress)
337 .filter(([key]) => key !== 'main')
338 .sort((a, b) => a[0].localeCompare(b[0]));
339 const mainProgressPercent = mainProgress
340 ? getSimulationProgressPercent(mainProgress)
341 : null;
342 const renderProgressDetails = (
343 details: SimulationProgressState['details']
344 ) => {
345 if (!details || details.length === 0) {
346 return null;
347 }
348
349 return (
350 <Box component="ul" sx={{ m: 0, mt: 1, pl: 2 }}>
351 {details.map((detail) => {
352 const detailPercent = getSimulationProgressPercent(detail);
353 const detailSuffix =
354 detailPercent !== null
355 ? ` (${Math.round(detailPercent)}%)`
356 : detail.current > 0
357 ? ` (Chunk ${detail.current})`
358 : '';
359
360 return (
361 <Typography
362 component="li"
363 variant="caption"
364 color="text.secondary"
365 key={detail.id}
366 >
367 {detail.message}
368 {detailSuffix}
369 </Typography>
370 );
371 })}
372 </Box>
373 );
374 };
375 const exportMetadataJson = async () => {
376 try {
377 const outputPath = await invoke<string>(
378 'export_output_metadata_json'
379 );
380 setMetadataExportPath(outputPath);
381 showSuccess(`Metadata JSON saved to ${outputPath}`);
382 } catch (err) {
383 const errorMessage =
384 err instanceof Error ? err.message : String(err);
385 showError(`Failed to export metadata JSON: ${errorMessage}`);
386 }
387 };
388 const formatSampleRange = (start: number, end: number) =>
389 `[${start}, ${end})`;
390 const formatPulseLength = (
391 minSamples: number,
392 maxSamples: number,
393 uniform: boolean
394 ) => {
395 if (minSamples === 0 && maxSamples === 0) {
396 return '0';
397 }
398 return uniform ? String(minSamples) : `${minSamples} - ${maxSamples}`;
399 };
400 const formatMetric = (value: number) =>
401 value.toLocaleString(undefined, { maximumSignificantDigits: 6 });
402 const formatMetadataSamplingRates = () => {
403 if (!simulationOutputMetadata) {
404 return '';
405 }
406 if (typeof simulationOutputMetadata.sampling_rate === 'number') {
407 return `${formatMetric(
408 simulationOutputMetadata.sampling_rate
409 )} samples/s`;
410 }
411 return `${simulationOutputMetadata.sampling_rates?.length ?? 0} sample rates`;
412 };
413 const formatFmcwMetadata = (
414 fmcw: NonNullable<SimulationOutputFileMetadata['fmcw']>
415 ) => {
416 if (fmcw.waveform_shape === 'triangle') {
417 return `triangle, B=${formatMetric(
418 fmcw.chirp_bandwidth
419 )}, T_c=${formatMetric(
420 fmcw.chirp_duration
421 )}, T_tri=${formatMetric(fmcw.triangle_period ?? 0)}`;
422 }
423
424 return `${fmcw.chirp_direction ?? 'up'}, B=${formatMetric(
425 fmcw.chirp_bandwidth
426 )}, T_c=${formatMetric(
427 fmcw.chirp_duration
428 )}, T_rep=${formatMetric(fmcw.chirp_period ?? 0)}`;
429 };
430 const formatPulseOrSegmentSummary = (
431 file: SimulationOutputFileMetadata
432 ) => {
433 if (file.mode === 'pulsed') {
434 return `${file.pulse_count} pulses, ${formatPulseLength(
435 file.min_pulse_length_samples,
436 file.max_pulse_length_samples,
437 file.uniform_pulse_length
438 )} samples`;
439 }
440
441 const segmentSummary = `${
442 file.streaming_segments.length
443 } streaming segments`;
444 if (file.mode !== 'fmcw' || !file.fmcw) {
445 if (file.mode === 'fmcw' && file.fmcw_sources.length > 0) {
446 return `${segmentSummary}, ${file.fmcw_sources.length} FMCW sources`;
447 }
448 return segmentSummary;
449 }
450
451 return `${segmentSummary}, ${formatFmcwMetadata(file.fmcw)}`;
452 };
453
454 const toggleMetadataRow = (path: string) => {
455 setExpandedMetadataPaths((current) => {
456 const next = new Set(current);
457 if (next.has(path)) {
458 next.delete(path);
459 } else {
460 next.add(path);
461 }
462 return next;
463 });
464 };
465
466 const renderFmcwDetails = (file: SimulationOutputFileMetadata) => {
467 if (file.mode !== 'fmcw') {
468 return null;
469 }
470
471 return (
472 <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
473 <Typography variant="body2">
474 File sample rate: {formatMetric(file.sampling_rate)}{' '}
475 samples/s
476 </Typography>
477 {file.fmcw_dechirp_mode &&
478 file.fmcw_dechirp_mode !== 'none' && (
479 <Typography variant="body2">
480 Dechirp: {file.fmcw_dechirp_mode},{' '}
481 {file.fmcw_dechirp_reference_source ?? 'none'}
482 </Typography>
483 )}
484 {file.fmcw_sources.length === 0 ? (
485 <Typography variant="body2" color="text.secondary">
486 No FMCW source metadata was emitted for this receiver.
487 </Typography>
488 ) : (
489 file.fmcw_sources.map((source) => (
490 <Box
491 key={`${source.transmitter_id}:${source.waveform_id}`}
492 sx={{ pl: 2 }}
493 >
494 <Typography variant="body2">
495 {source.transmitter_name} /{' '}
496 {source.waveform_name}:{' '}
497 {formatFmcwMetadata(source)}, f0=
498 {formatMetric(source.start_frequency_offset)}{' '}
499 Hz, rate={formatMetric(source.chirp_rate)}
500 Hz/s
501 {source.chirp_rate_signed !== undefined
502 ? `, signed=${formatMetric(
503 source.chirp_rate_signed
504 )} Hz/s`
505 : ''}
506 {source.chirp_count !== undefined
507 ? `, chirps=${source.chirp_count}`
508 : ''}
509 {source.triangle_count !== undefined
510 ? `, triangles=${source.triangle_count}`
511 : ''}
512 </Typography>
513 {source.segments.map((segment) => (
514 <Typography
515 key={`${segment.start_time}:${segment.end_time}`}
516 variant="caption"
517 color="text.secondary"
518 sx={{ display: 'block' }}
519 >
520 [{formatMetric(segment.start_time)},{' '}
521 {formatMetric(segment.end_time)}]:{' '}
522 {segment.emitted_chirp_count !== undefined
523 ? `${segment.emitted_chirp_count} chirps`
524 : ''}
525 {segment.emitted_triangle_count !==
526 undefined
527 ? `${segment.emitted_triangle_count} triangles`
528 : ''}
529 </Typography>
530 ))}
531 </Box>
532 ))
533 )}
534 </Box>
535 );
536 };
537
538 return (
539 <Box
540 sx={{ p: 4, height: '100%', overflowY: 'auto', contain: 'content' }}
541 >
542 <Typography variant="h4" gutterBottom>
543 Simulation Runner
544 </Typography>
545 <Typography variant="body1" color="text.secondary" sx={{ mb: 4 }}>
546 Execute the configured scenario or generate a geographical
547 visualization. Ensure your scenario is fully configured before
548 proceeding.
549 </Typography>
550 <Card elevation={0} sx={{ mb: 4 }}>
551 <CardContent>
552 <Typography variant="h6" gutterBottom>
553 Output Settings
554 </Typography>
555 <Typography
556 variant="body2"
557 color="text.secondary"
558 sx={{ mb: 2 }}
559 >
560 Simulation results (.h5 files) and default KML exports
561 will be saved here.
562 </Typography>
563 <Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
564 <TextField
565 label="Output Directory"
566 variant="outlined"
567 size="small"
568 fullWidth
569 value={
570 outputDirectory ||
571 (scenarioFilePath
572 ? 'Default (Scenario Directory)'
573 : 'Default (Current Directory)')
574 }
575 slotProps={{
576 input: {
577 readOnly: true,
578 },
579 }}
580 />
581 <Button
582 variant="outlined"
583 onClick={handleSelectOutputDir}
584 sx={{ whiteSpace: 'nowrap' }}
585 >
586 Browse...
587 </Button>
588 {outputDirectory && (
589 <Button
590 variant="text"
591 color="error"
592 onClick={() => setOutputDirectory(null)}
593 >
594 Reset
595 </Button>
596 )}
597 </Box>
598 </CardContent>
599 </Card>
600
601 <Grid container spacing={4} sx={{ width: '100%' }}>
602 {/* ... existing Grid items for Run Simulation and Generate KML ... */}
603 <Grid size={{ xs: 12, md: 6 }}>
604 <Card elevation={0} sx={{ height: '100%' }}>
605 <CardContent>
606 <Typography variant="h5" component="div">
607 Run Full Simulation
608 </Typography>
609 <Typography sx={{ mt: 1.5 }} color="text.secondary">
610 Executes the entire simulation based on the
611 current scenario settings. This is a
612 computationally intensive process that will
613 generate output files.
614 </Typography>
615 </CardContent>
616 <CardActions sx={{ p: 2 }}>
617 <Button
618 variant="contained"
619 size="large"
620 startIcon={
621 isSimulating ? (
622 <CircularProgress
623 size={24}
624 color="inherit"
625 />
626 ) : (
627 <PlayCircleOutlineIcon />
628 )
629 }
630 disabled={isSimulating || isGeneratingKml}
631 onClick={handleRunSimulation}
632 >
633 {isSimulating ? 'Running...' : 'Run Simulation'}
634 </Button>
635 </CardActions>
636 </Card>
637 </Grid>
638 <Grid size={{ xs: 12, md: 6 }}>
639 <Card elevation={0} sx={{ height: '100%' }}>
640 <CardContent>
641 <Typography variant="h5" component="div">
642 Generate KML
643 </Typography>
644 <Typography sx={{ mt: 1.5 }} color="text.secondary">
645 Creates a KML file from the scenario&apos;s
646 platform motion paths and antenna pointings.
647 This allows for quick visualization in
648 applications like Google Earth without running
649 the full signal-level simulation.
650 </Typography>
651 </CardContent>
652 <CardActions sx={{ p: 2 }}>
653 <Button
654 variant="outlined"
655 size="large"
656 startIcon={
657 isGeneratingKml ? (
658 <CircularProgress
659 size={24}
660 color="inherit"
661 />
662 ) : (
663 <MapIcon />
664 )
665 }
666 disabled={isSimulating || isGeneratingKml}
667 onClick={handleGenerateKml}
668 >
669 {isGeneratingKml
670 ? 'Generating...'
671 : 'Generate KML'}
672 </Button>
673 </CardActions>
674 </Card>
675 </Grid>
676 </Grid>
677
678 {progressPanelVisible && (
679 <Box
680 sx={{
681 mt: 4,
682 p: 2,
683 backgroundColor: 'action.hover',
684 borderRadius: 1,
685 }}
686 >
687 {/* Main Simulation Progress */}
688 <Typography
689 variant="h6"
690 sx={{ mb: 1, textAlign: 'center' }}
691 >
692 {progressHeading}
693 </Typography>
694 {simulationRunStatus === 'failed' && simulationRunError && (
695 <Typography
696 variant="body2"
697 color="error"
698 sx={{ mb: 2, textAlign: 'center' }}
699 >
700 {simulationRunError}
701 </Typography>
702 )}
703 {mainProgressPercent !== null && (
704 <Box
705 sx={{
706 display: 'flex',
707 alignItems: 'center',
708 mt: 2,
709 mb: 2,
710 }}
711 >
712 <Box sx={{ width: '100%', mr: 1 }}>
713 <LinearProgress
714 variant="determinate"
715 value={mainProgressPercent}
716 />
717 </Box>
718 <Box sx={{ minWidth: 40 }}>
719 <Typography
720 variant="body2"
721 color="text.secondary"
722 >{`${Math.round(mainProgressPercent)}%`}</Typography>
723 </Box>
724 </Box>
725 )}
726 {renderProgressDetails(mainProgress?.details)}
727
728 {/* Finalizer Threads List */}
729 {otherProgresses.length > 0 && (
730 <Box
731 sx={{
732 mt: 2,
733 borderTop: 1,
734 borderColor: 'divider',
735 pt: 2,
736 }}
737 >
738 <Typography
739 variant="subtitle2"
740 color="text.secondary"
741 >
742 Exporting Data:
743 </Typography>
744 <List dense>
745 {otherProgresses.map(([key, prog]) => {
746 const progressPercent =
747 getSimulationProgressPercent(prog);
748 const showChunkLabel =
749 progressPercent === null &&
750 prog.current > 0;
751
752 return (
753 <ListItem key={key}>
754 <Box sx={{ width: '100%' }}>
755 <Box
756 sx={{
757 display: 'flex',
758 alignItems: 'center',
759 }}
760 >
761 <ListItemText
762 primary={prog.message}
763 />
764 {showChunkLabel && (
765 <Box
766 sx={{
767 width: '20%',
768 ml: 2,
769 }}
770 >
771 <Typography
772 variant="caption"
773 color="text.secondary"
774 >
775 Chunk{' '}
776 {prog.current}
777 </Typography>
778 </Box>
779 )}
780 {progressPercent !==
781 null && (
782 <Box
783 sx={{
784 width: '30%',
785 ml: 2,
786 }}
787 >
788 <LinearProgress
789 variant="determinate"
790 value={
791 progressPercent
792 }
793 />
794 </Box>
795 )}
796 </Box>
797 {renderProgressDetails(
798 prog.details
799 )}
800 </Box>
801 </ListItem>
802 );
803 })}
804 </List>
805 </Box>
806 )}
807 </Box>
808 )}
809
810 {simulationOutputMetadata && (
811 <Card elevation={0} sx={{ mt: 4 }}>
812 <CardContent>
813 <Box
814 sx={{
815 display: 'flex',
816 justifyContent: 'space-between',
817 alignItems: 'center',
818 gap: 2,
819 mb: 2,
820 }}
821 >
822 <Box>
823 <Typography variant="h6">
824 Output Data Metadata
825 </Typography>
826 <Typography
827 variant="body2"
828 color="text.secondary"
829 >
830 {simulationOutputMetadata.files.length} HDF5
831 output file
832 {simulationOutputMetadata.files.length === 1
833 ? ''
834 : 's'}{' '}
835 at {formatMetadataSamplingRates()}.
836 </Typography>
837 </Box>
838 <Button
839 variant="outlined"
840 onClick={exportMetadataJson}
841 >
842 Export JSON
843 </Button>
844 </Box>
845 {metadataExportPath && (
846 <Typography
847 variant="body2"
848 color="text.secondary"
849 sx={{ mb: 2, overflowWrap: 'anywhere' }}
850 >
851 Metadata JSON saved to {metadataExportPath}
852 </Typography>
853 )}
854
855 {simulationOutputMetadata.files.length === 0 ? (
856 <Typography color="text.secondary">
857 No HDF5 output files were generated for this
858 run.
859 </Typography>
860 ) : (
861 <TableContainer
862 component={Paper}
863 variant="outlined"
864 >
865 <Table size="small">
866 <TableHead>
867 <TableRow>
868 <TableCell />
869 <TableCell>Receiver</TableCell>
870 <TableCell>Mode</TableCell>
871 <TableCell align="right">
872 Rate
873 </TableCell>
874 <TableCell align="right">
875 Samples
876 </TableCell>
877 <TableCell>Sample Range</TableCell>
878 <TableCell>Pulse/Segment</TableCell>
879 <TableCell>File</TableCell>
880 </TableRow>
881 </TableHead>
882 <TableBody>
883 {simulationOutputMetadata.files.map(
884 (file) => {
885 const isExpanded =
886 expandedMetadataPaths.has(
887 file.path
888 );
889 const canExpand =
890 file.mode === 'fmcw';
891 return (
892 <React.Fragment
893 key={file.path}
894 >
895 <TableRow>
896 <TableCell>
897 {canExpand && (
898 <IconButton
899 size="small"
900 onClick={() =>
901 toggleMetadataRow(
902 file.path
903 )
904 }
905 >
906 {isExpanded ? (
907 <KeyboardArrowDownIcon fontSize="small" />
908 ) : (
909 <KeyboardArrowRightIcon fontSize="small" />
910 )}
911 </IconButton>
912 )}
913 </TableCell>
914 <TableCell>
915 {
916 file.receiver_name
917 }
918 </TableCell>
919 <TableCell>
920 {file.mode}
921 </TableCell>
922 <TableCell align="right">
923 {formatMetric(
924 file.sampling_rate
925 )}
926 </TableCell>
927 <TableCell align="right">
928 {
929 file.total_samples
930 }
931 </TableCell>
932 <TableCell>
933 {formatSampleRange(
934 file.sample_start,
935 file.sample_end_exclusive
936 )}
937 </TableCell>
938 <TableCell>
939 {formatPulseOrSegmentSummary(
940 file
941 )}
942 </TableCell>
943 <TableCell
944 sx={{
945 maxWidth: 360,
946 overflowWrap:
947 'anywhere',
948 }}
949 >
950 {file.path}
951 </TableCell>
952 </TableRow>
953 {canExpand && (
954 <TableRow>
955 <TableCell
956 colSpan={8}
957 sx={{
958 py: 0,
959 }}
960 >
961 <Collapse
962 in={
963 isExpanded
964 }
965 timeout="auto"
966 unmountOnExit
967 >
968 <Box
969 sx={{
970 py: 2,
971 }}
972 >
973 {renderFmcwDetails(
974 file
975 )}
976 </Box>
977 </Collapse>
978 </TableCell>
979 </TableRow>
980 )}
981 </React.Fragment>
982 );
983 }
984 )}
985 </TableBody>
986 </Table>
987 </TableContainer>
988 )}
989 </CardContent>
990 </Card>
991 )}
992 </Box>
993 );
994});