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 if (fmcw.waveform_shape === 'file') {
425 return `file, duration=${formatMetric(fmcw.sampled_duration ?? 0)}, samples=${fmcw.sampled_count ?? 0}`;
426 }
427
428 return `${fmcw.chirp_direction ?? 'up'}, B=${formatMetric(
429 fmcw.chirp_bandwidth
430 )}, T_c=${formatMetric(
431 fmcw.chirp_duration
432 )}, T_rep=${formatMetric(fmcw.chirp_period ?? 0)}`;
433 };
434 const formatSfcwMetadata = (
435 sfcw: NonNullable<SimulationOutputFileMetadata['sfcw']>
436 ) =>
437 `steps=${sfcw.step_count}, df=${formatMetric(
438 sfcw.step_size
439 )} Hz, dwell=${formatMetric(
440 sfcw.dwell_time
441 )}, T_step=${formatMetric(sfcw.step_period)}`;
442 const formatPulseOrSegmentSummary = (
443 file: SimulationOutputFileMetadata
444 ) => {
445 if (file.mode === 'pulsed') {
446 return `${file.pulse_count} pulses, ${formatPulseLength(
447 file.min_pulse_length_samples,
448 file.max_pulse_length_samples,
449 file.uniform_pulse_length
450 )} samples`;
451 }
452
453 const segmentSummary = `${
454 file.streaming_segments.length
455 } streaming segments`;
456 if (file.mode === 'sfcw') {
457 if (file.sfcw) {
458 return `${segmentSummary}, ${formatSfcwMetadata(file.sfcw)}`;
459 }
460 if (file.sfcw_sources.length > 0) {
461 return `${segmentSummary}, ${file.sfcw_sources.length} SFCW sources`;
462 }
463 return segmentSummary;
464 }
465
466 if (file.mode !== 'fmcw' || !file.fmcw) {
467 if (file.mode === 'fmcw' && file.fmcw_sources.length > 0) {
468 return `${segmentSummary}, ${file.fmcw_sources.length} FMCW sources`;
469 }
470 return segmentSummary;
471 }
472
473 return `${segmentSummary}, ${formatFmcwMetadata(file.fmcw)}`;
474 };
475
476 const toggleMetadataRow = (path: string) => {
477 setExpandedMetadataPaths((current) => {
478 const next = new Set(current);
479 if (next.has(path)) {
480 next.delete(path);
481 } else {
482 next.add(path);
483 }
484 return next;
485 });
486 };
487
488 const renderFmcwDetails = (file: SimulationOutputFileMetadata) => {
489 if (file.mode !== 'fmcw') {
490 return null;
491 }
492
493 return (
494 <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
495 <Typography variant="body2">
496 File sample rate: {formatMetric(file.sampling_rate)}{' '}
497 samples/s
498 </Typography>
499 {file.fmcw_dechirp_mode &&
500 file.fmcw_dechirp_mode !== 'none' && (
501 <Typography variant="body2">
502 Dechirp: {file.fmcw_dechirp_mode},{' '}
503 {file.fmcw_dechirp_reference_source ?? 'none'}
504 </Typography>
505 )}
506 {file.fmcw_sources.length === 0 ? (
507 <Typography variant="body2" color="text.secondary">
508 No FMCW source metadata was emitted for this receiver.
509 </Typography>
510 ) : (
511 file.fmcw_sources.map((source) => (
512 <Box
513 key={`${source.transmitter_id}:${source.waveform_id}`}
514 sx={{ pl: 2 }}
515 >
516 <Typography variant="body2">
517 {source.transmitter_name} /{' '}
518 {source.waveform_name}:{' '}
519 {formatFmcwMetadata(source)}, f0=
520 {formatMetric(source.start_frequency_offset)}{' '}
521 Hz, rate={formatMetric(source.chirp_rate)}
522 Hz/s
523 {source.chirp_rate_signed !== undefined
524 ? `, signed=${formatMetric(
525 source.chirp_rate_signed
526 )} Hz/s`
527 : ''}
528 {source.chirp_count !== undefined
529 ? `, chirps=${source.chirp_count}`
530 : ''}
531 {source.triangle_count !== undefined
532 ? `, triangles=${source.triangle_count}`
533 : ''}
534 </Typography>
535 {source.segments.map((segment) => (
536 <Typography
537 key={`${segment.start_time}:${segment.end_time}`}
538 variant="caption"
539 color="text.secondary"
540 sx={{ display: 'block' }}
541 >
542 [{formatMetric(segment.start_time)},{' '}
543 {formatMetric(segment.end_time)}]:{' '}
544 {segment.emitted_chirp_count !== undefined
545 ? `${segment.emitted_chirp_count} chirps`
546 : ''}
547 {segment.emitted_triangle_count !==
548 undefined
549 ? `${segment.emitted_triangle_count} triangles`
550 : ''}
551 </Typography>
552 ))}
553 </Box>
554 ))
555 )}
556 </Box>
557 );
558 };
559
560 const renderSfcwDetails = (file: SimulationOutputFileMetadata) => {
561 if (file.mode !== 'sfcw') {
562 return null;
563 }
564
565 return (
566 <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
567 <Typography variant="body2">
568 File sample rate: {formatMetric(file.sampling_rate)}{' '}
569 samples/s
570 </Typography>
571 {file.sfcw_sources.length === 0 ? (
572 <Typography variant="body2" color="text.secondary">
573 No SFCW source metadata was emitted for this receiver.
574 </Typography>
575 ) : (
576 file.sfcw_sources.map((source) => (
577 <Box
578 key={`${source.transmitter_id}:${source.waveform_id}`}
579 sx={{ pl: 2 }}
580 >
581 <Typography variant="body2">
582 {source.transmitter_name} /{' '}
583 {source.waveform_name}:{' '}
584 {formatSfcwMetadata(source)}, B_eff=
585 {formatMetric(source.effective_bandwidth)} Hz,
586 R_res=
587 {formatMetric(source.range_resolution)} m,
588 R_amb=
589 {formatMetric(source.unambiguous_range)} m
590 </Typography>
591 {source.segments.map((segment) => (
592 <Typography
593 key={`${segment.start_time}:${segment.end_time}`}
594 variant="caption"
595 color="text.secondary"
596 sx={{ display: 'block' }}
597 >
598 [{formatMetric(segment.start_time)},{' '}
599 {formatMetric(segment.end_time)}]:{' '}
600 {segment.emitted_step_count !== undefined
601 ? `${segment.emitted_step_count} steps`
602 : ''}
603 </Typography>
604 ))}
605 </Box>
606 ))
607 )}
608 </Box>
609 );
610 };
611
612 return (
613 <Box
614 sx={{ p: 4, height: '100%', overflowY: 'auto', contain: 'content' }}
615 >
616 <Typography variant="h4" gutterBottom>
617 Simulation Runner
618 </Typography>
619 <Typography variant="body1" color="text.secondary" sx={{ mb: 4 }}>
620 Execute the configured scenario or generate a geographical
621 visualization. Ensure your scenario is fully configured before
622 proceeding.
623 </Typography>
624 <Card elevation={0} sx={{ mb: 4 }}>
625 <CardContent>
626 <Typography variant="h6" gutterBottom>
627 Output Settings
628 </Typography>
629 <Typography
630 variant="body2"
631 color="text.secondary"
632 sx={{ mb: 2 }}
633 >
634 Simulation results (.h5 files) and default KML exports
635 will be saved here.
636 </Typography>
637 <Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
638 <TextField
639 label="Output Directory"
640 variant="outlined"
641 size="small"
642 fullWidth
643 value={
644 outputDirectory ||
645 (scenarioFilePath
646 ? 'Default (Scenario Directory)'
647 : 'Default (Current Directory)')
648 }
649 slotProps={{
650 input: {
651 readOnly: true,
652 },
653 }}
654 />
655 <Button
656 variant="outlined"
657 onClick={handleSelectOutputDir}
658 sx={{ whiteSpace: 'nowrap' }}
659 >
660 Browse...
661 </Button>
662 {outputDirectory && (
663 <Button
664 variant="text"
665 color="error"
666 onClick={() => setOutputDirectory(null)}
667 >
668 Reset
669 </Button>
670 )}
671 </Box>
672 </CardContent>
673 </Card>
674
675 <Grid container spacing={4} sx={{ width: '100%' }}>
676 {/* ... existing Grid items for Run Simulation and Generate KML ... */}
677 <Grid size={{ xs: 12, md: 6 }}>
678 <Card elevation={0} sx={{ height: '100%' }}>
679 <CardContent>
680 <Typography variant="h5" component="div">
681 Run Full Simulation
682 </Typography>
683 <Typography sx={{ mt: 1.5 }} color="text.secondary">
684 Executes the entire simulation based on the
685 current scenario settings. This is a
686 computationally intensive process that will
687 generate output files.
688 </Typography>
689 </CardContent>
690 <CardActions sx={{ p: 2 }}>
691 <Button
692 variant="contained"
693 size="large"
694 startIcon={
695 isSimulating ? (
696 <CircularProgress
697 size={24}
698 color="inherit"
699 />
700 ) : (
701 <PlayCircleOutlineIcon />
702 )
703 }
704 disabled={isSimulating || isGeneratingKml}
705 onClick={handleRunSimulation}
706 >
707 {isSimulating ? 'Running...' : 'Run Simulation'}
708 </Button>
709 </CardActions>
710 </Card>
711 </Grid>
712 <Grid size={{ xs: 12, md: 6 }}>
713 <Card elevation={0} sx={{ height: '100%' }}>
714 <CardContent>
715 <Typography variant="h5" component="div">
716 Generate KML
717 </Typography>
718 <Typography sx={{ mt: 1.5 }} color="text.secondary">
719 Creates a KML file from the scenario&apos;s
720 platform motion paths and antenna pointings.
721 This allows for quick visualization in
722 applications like Google Earth without running
723 the full signal-level simulation.
724 </Typography>
725 </CardContent>
726 <CardActions sx={{ p: 2 }}>
727 <Button
728 variant="outlined"
729 size="large"
730 startIcon={
731 isGeneratingKml ? (
732 <CircularProgress
733 size={24}
734 color="inherit"
735 />
736 ) : (
737 <MapIcon />
738 )
739 }
740 disabled={isSimulating || isGeneratingKml}
741 onClick={handleGenerateKml}
742 >
743 {isGeneratingKml
744 ? 'Generating...'
745 : 'Generate KML'}
746 </Button>
747 </CardActions>
748 </Card>
749 </Grid>
750 </Grid>
751
752 {progressPanelVisible && (
753 <Box
754 sx={{
755 mt: 4,
756 p: 2,
757 backgroundColor: 'action.hover',
758 borderRadius: 1,
759 }}
760 >
761 {/* Main Simulation Progress */}
762 <Typography
763 variant="h6"
764 sx={{ mb: 1, textAlign: 'center' }}
765 >
766 {progressHeading}
767 </Typography>
768 {simulationRunStatus === 'failed' && simulationRunError && (
769 <Typography
770 variant="body2"
771 color="error"
772 sx={{ mb: 2, textAlign: 'center' }}
773 >
774 {simulationRunError}
775 </Typography>
776 )}
777 {mainProgressPercent !== null && (
778 <Box
779 sx={{
780 display: 'flex',
781 alignItems: 'center',
782 mt: 2,
783 mb: 2,
784 }}
785 >
786 <Box sx={{ width: '100%', mr: 1 }}>
787 <LinearProgress
788 variant="determinate"
789 value={mainProgressPercent}
790 />
791 </Box>
792 <Box sx={{ minWidth: 40 }}>
793 <Typography
794 variant="body2"
795 color="text.secondary"
796 >{`${Math.round(mainProgressPercent)}%`}</Typography>
797 </Box>
798 </Box>
799 )}
800 {renderProgressDetails(mainProgress?.details)}
801
802 {/* Finalizer Threads List */}
803 {otherProgresses.length > 0 && (
804 <Box
805 sx={{
806 mt: 2,
807 borderTop: 1,
808 borderColor: 'divider',
809 pt: 2,
810 }}
811 >
812 <Typography
813 variant="subtitle2"
814 color="text.secondary"
815 >
816 Exporting Data:
817 </Typography>
818 <List dense>
819 {otherProgresses.map(([key, prog]) => {
820 const progressPercent =
821 getSimulationProgressPercent(prog);
822 const showChunkLabel =
823 progressPercent === null &&
824 prog.current > 0;
825
826 return (
827 <ListItem key={key}>
828 <Box sx={{ width: '100%' }}>
829 <Box
830 sx={{
831 display: 'flex',
832 alignItems: 'center',
833 }}
834 >
835 <ListItemText
836 primary={prog.message}
837 />
838 {showChunkLabel && (
839 <Box
840 sx={{
841 width: '20%',
842 ml: 2,
843 }}
844 >
845 <Typography
846 variant="caption"
847 color="text.secondary"
848 >
849 Chunk{' '}
850 {prog.current}
851 </Typography>
852 </Box>
853 )}
854 {progressPercent !==
855 null && (
856 <Box
857 sx={{
858 width: '30%',
859 ml: 2,
860 }}
861 >
862 <LinearProgress
863 variant="determinate"
864 value={
865 progressPercent
866 }
867 />
868 </Box>
869 )}
870 </Box>
871 {renderProgressDetails(
872 prog.details
873 )}
874 </Box>
875 </ListItem>
876 );
877 })}
878 </List>
879 </Box>
880 )}
881 </Box>
882 )}
883
884 {simulationOutputMetadata && (
885 <Card elevation={0} sx={{ mt: 4 }}>
886 <CardContent>
887 <Box
888 sx={{
889 display: 'flex',
890 justifyContent: 'space-between',
891 alignItems: 'center',
892 gap: 2,
893 mb: 2,
894 }}
895 >
896 <Box>
897 <Typography variant="h6">
898 Output Data Metadata
899 </Typography>
900 <Typography
901 variant="body2"
902 color="text.secondary"
903 >
904 {simulationOutputMetadata.files.length} HDF5
905 output file
906 {simulationOutputMetadata.files.length === 1
907 ? ''
908 : 's'}{' '}
909 at {formatMetadataSamplingRates()}.
910 </Typography>
911 </Box>
912 <Button
913 variant="outlined"
914 onClick={exportMetadataJson}
915 >
916 Export JSON
917 </Button>
918 </Box>
919 {metadataExportPath && (
920 <Typography
921 variant="body2"
922 color="text.secondary"
923 sx={{ mb: 2, overflowWrap: 'anywhere' }}
924 >
925 Metadata JSON saved to {metadataExportPath}
926 </Typography>
927 )}
928
929 {simulationOutputMetadata.files.length === 0 ? (
930 <Typography color="text.secondary">
931 No HDF5 output files were generated for this
932 run.
933 </Typography>
934 ) : (
935 <TableContainer
936 component={Paper}
937 variant="outlined"
938 >
939 <Table size="small">
940 <TableHead>
941 <TableRow>
942 <TableCell />
943 <TableCell>Receiver</TableCell>
944 <TableCell>Mode</TableCell>
945 <TableCell align="right">
946 Rate
947 </TableCell>
948 <TableCell align="right">
949 Samples
950 </TableCell>
951 <TableCell>Sample Range</TableCell>
952 <TableCell>Pulse/Segment</TableCell>
953 <TableCell>File</TableCell>
954 </TableRow>
955 </TableHead>
956 <TableBody>
957 {simulationOutputMetadata.files.map(
958 (file) => {
959 const isExpanded =
960 expandedMetadataPaths.has(
961 file.path
962 );
963 const canExpand =
964 file.mode === 'fmcw' ||
965 file.mode === 'sfcw';
966 return (
967 <React.Fragment
968 key={file.path}
969 >
970 <TableRow>
971 <TableCell>
972 {canExpand && (
973 <IconButton
974 size="small"
975 onClick={() =>
976 toggleMetadataRow(
977 file.path
978 )
979 }
980 >
981 {isExpanded ? (
982 <KeyboardArrowDownIcon fontSize="small" />
983 ) : (
984 <KeyboardArrowRightIcon fontSize="small" />
985 )}
986 </IconButton>
987 )}
988 </TableCell>
989 <TableCell>
990 {
991 file.receiver_name
992 }
993 </TableCell>
994 <TableCell>
995 {file.mode}
996 </TableCell>
997 <TableCell align="right">
998 {formatMetric(
999 file.sampling_rate
1000 )}
1001 </TableCell>
1002 <TableCell align="right">
1003 {
1004 file.total_samples
1005 }
1006 </TableCell>
1007 <TableCell>
1008 {formatSampleRange(
1009 file.sample_start,
1010 file.sample_end_exclusive
1011 )}
1012 </TableCell>
1013 <TableCell>
1014 {formatPulseOrSegmentSummary(
1015 file
1016 )}
1017 </TableCell>
1018 <TableCell
1019 sx={{
1020 maxWidth: 360,
1021 overflowWrap:
1022 'anywhere',
1023 }}
1024 >
1025 {file.path}
1026 </TableCell>
1027 </TableRow>
1028 {canExpand && (
1029 <TableRow>
1030 <TableCell
1031 colSpan={8}
1032 sx={{
1033 py: 0,
1034 }}
1035 >
1036 <Collapse
1037 in={
1038 isExpanded
1039 }
1040 timeout="auto"
1041 unmountOnExit
1042 >
1043 <Box
1044 sx={{
1045 py: 2,
1046 }}
1047 >
1048 {renderFmcwDetails(
1049 file
1050 )}
1051 {renderSfcwDetails(
1052 file
1053 )}
1054 </Box>
1055 </Collapse>
1056 </TableCell>
1057 </TableRow>
1058 )}
1059 </React.Fragment>
1060 );
1061 }
1062 )}
1063 </TableBody>
1064 </Table>
1065 </TableContainer>
1066 )}
1067 </CardContent>
1068 </Card>
1069 )}
1070 </Box>
1071 );
1072});