FERS 1.0.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 MapIcon from '@mui/icons-material/Map';
5import PlayCircleOutlineIcon from '@mui/icons-material/PlayCircleOutline';
6import {
7 Box,
8 Button,
9 Card,
10 CardActions,
11 CardContent,
12 CircularProgress,
13 Grid,
14 LinearProgress,
15 List,
16 ListItem,
17 ListItemText,
18 Paper,
19 Table,
20 TableBody,
21 TableCell,
22 TableContainer,
23 TableHead,
24 TableRow,
25 TextField,
26 Typography,
27} from '@mui/material';
28import { invoke } from '@tauri-apps/api/core';
29import { listen } from '@tauri-apps/api/event';
30import { dirname, join } from '@tauri-apps/api/path';
31import { open, save } from '@tauri-apps/plugin-dialog';
32import React, { useEffect, useRef, useState } from 'react';
33import { useScenarioStore } from '@/stores/scenarioStore';
34import {
35 type SimulationOutputMetadata,
36 type SimulationProgressState,
37 useSimulationProgressStore,
38} from '@/stores/simulationProgressStore';
39import {
40 addSimulationProgressEvent,
41 getSimulationProgressPercent,
42 normalizeCompletedProgressSnapshot,
43} from './simulationProgress';
44
45export const SimulationView = React.memo(function SimulationView() {
46 const [metadataExportPath, setMetadataExportPath] = useState<string | null>(
47 null
48 );
49 const isSimulating = useSimulationProgressStore(
50 (state) => state.isSimulating
51 );
52 const isGeneratingKml = useSimulationProgressStore(
53 (state) => state.isGeneratingKml
54 );
55 const setIsGeneratingKml = useSimulationProgressStore(
56 (state) => state.setIsGeneratingKml
57 );
58 const simulationProgress = useSimulationProgressStore(
59 (state) => state.simulationProgress
60 );
61 const simulationRunStatus = useSimulationProgressStore(
62 (state) => state.simulationRunStatus
63 );
64 const simulationRunError = useSimulationProgressStore(
65 (state) => state.simulationRunError
66 );
67 const simulationOutputMetadata = useSimulationProgressStore(
68 (state) => state.simulationOutputMetadata
69 );
70 const startSimulationRun = useSimulationProgressStore(
71 (state) => state.startSimulationRun
72 );
73 const setSimulationProgressSnapshot = useSimulationProgressStore(
74 (state) => state.setSimulationProgressSnapshot
75 );
76 const setSimulationOutputMetadata = useSimulationProgressStore(
77 (state) => state.setSimulationOutputMetadata
78 );
79 const completeSimulationRun = useSimulationProgressStore(
80 (state) => state.completeSimulationRun
81 );
82 const failSimulationRun = useSimulationProgressStore(
83 (state) => state.failSimulationRun
84 );
85 const showError = useScenarioStore((state) => state.showError);
86 const showSuccess = useScenarioStore((state) => state.showSuccess);
87 const scenarioFilePath = useScenarioStore(
88 (state) => state.scenarioFilePath
89 );
90 const outputDirectory = useScenarioStore((state) => state.outputDirectory);
91 const setOutputDirectory = useScenarioStore(
92 (state) => state.setOutputDirectory
93 );
94
95 // Use a Ref to store incoming data to avoid triggering re-renders on every event
96 const progressRef = useRef<Record<string, SimulationProgressState>>({});
97
98 useEffect(() => {
99 let animationFrameId: number | undefined;
100
101 const flushProgress = () => {
102 setSimulationProgressSnapshot({ ...progressRef.current });
103 };
104
105 // The update loop synchronizes the Ref data to the State at screen refresh rate
106 const updateLoop = () => {
107 if (useSimulationProgressStore.getState().isSimulating) {
108 flushProgress();
109 animationFrameId = requestAnimationFrame(updateLoop);
110 }
111 };
112
113 const unlistenSimComplete = listen<void>('simulation-complete', () => {
114 console.log('Simulation completed successfully.');
115 progressRef.current = normalizeCompletedProgressSnapshot(
116 progressRef.current
117 );
118 flushProgress();
119 completeSimulationRun();
120 if (animationFrameId !== undefined) {
121 cancelAnimationFrame(animationFrameId);
122 }
123 });
124
125 const unlistenSimError = listen<string>('simulation-error', (event) => {
126 const errorMessage = `Simulation failed: ${event.payload}`;
127 console.error(errorMessage);
128 showError(errorMessage);
129 flushProgress();
130 failSimulationRun(errorMessage);
131 if (animationFrameId !== undefined) {
132 cancelAnimationFrame(animationFrameId);
133 }
134 });
135
136 const unlistenSimProgress = listen<SimulationProgressState>(
137 'simulation-progress',
138 (event) => {
139 progressRef.current = addSimulationProgressEvent(
140 progressRef.current,
141 event.payload
142 );
143 }
144 );
145
146 const unlistenOutputMetadata = listen<string>(
147 'simulation-output-metadata',
148 (event) => {
149 try {
150 setSimulationOutputMetadata(
151 JSON.parse(event.payload) as SimulationOutputMetadata
152 );
153 } catch (err) {
154 const errorMessage =
155 err instanceof Error ? err.message : String(err);
156 showError(
157 `Failed to decode simulation metadata: ${errorMessage}`
158 );
159 }
160 }
161 );
162
163 const unlistenKmlComplete = listen<string>(
164 'kml-generation-complete',
165 (event) => {
166 console.log('KML generated successfully at:', event.payload);
167 setIsGeneratingKml(false);
168 }
169 );
170
171 const unlistenKmlError = listen<string>(
172 'kml-generation-error',
173 (event) => {
174 const errorMessage = `KML generation failed: ${event.payload}`;
175 console.error(errorMessage);
176 showError(errorMessage);
177 setIsGeneratingKml(false);
178 }
179 );
180
181 // Start the UI update loop if we are simulating
182 if (isSimulating) {
183 updateLoop();
184 }
185
186 return () => {
187 if (animationFrameId !== undefined) {
188 cancelAnimationFrame(animationFrameId);
189 }
190 Promise.all([
191 unlistenSimComplete,
192 unlistenSimError,
193 unlistenSimProgress,
194 unlistenOutputMetadata,
195 unlistenKmlComplete,
196 unlistenKmlError,
197 ]).then((unlisteners) => {
198 unlisteners.forEach((unlisten) => unlisten());
199 });
200 };
201 }, [
202 isSimulating,
203 setSimulationProgressSnapshot,
204 setSimulationOutputMetadata,
205 completeSimulationRun,
206 failSimulationRun,
207 setIsGeneratingKml,
208 showError,
209 ]);
210
211 const getEffectiveOutputDir = async () => {
212 if (outputDirectory) return outputDirectory;
213 if (scenarioFilePath) {
214 try {
215 return await dirname(scenarioFilePath);
216 } catch (e) {
217 console.warn('Failed to get dirname of scenario file', e);
218 }
219 }
220 return '.';
221 };
222
223 const handleSelectOutputDir = async () => {
224 try {
225 const selected = await open({
226 directory: true,
227 multiple: false,
228 defaultPath: await getEffectiveOutputDir(),
229 });
230 if (typeof selected === 'string') {
231 setOutputDirectory(selected);
232 }
233 } catch (err) {
234 console.error('Failed to open directory dialog:', err);
235 }
236 };
237
238 const handleRunSimulation = async () => {
239 progressRef.current = {};
240 setMetadataExportPath(null);
241 startSimulationRun();
242 try {
243 // Ensure the C++ backend has the latest scenario from the UI
244 const effectiveDir = await getEffectiveOutputDir();
245 await invoke('set_output_directory', { dir: effectiveDir });
246
247 await useScenarioStore.getState().syncBackend();
248 await invoke('run_simulation');
249 } catch (err) {
250 const errorMessage =
251 err instanceof Error ? err.message : String(err);
252 console.error('Failed to invoke simulation:', errorMessage);
253 showError(`Failed to start simulation: ${errorMessage}`);
254 failSimulationRun(`Failed to start simulation: ${errorMessage}`);
255 }
256 };
257
258 const handleGenerateKml = async () => {
259 try {
260 const effectiveDir = await getEffectiveOutputDir();
261
262 // 1. Get the simulation name from the store
263 const simName =
264 useScenarioStore.getState().globalParameters.simulation_name ||
265 'scenario';
266
267 // 2. Sanitize the name and append extension
268 const suggestedFileName = `${simName.replace(/[^a-z0-9]/gi, '_')}.kml`;
269
270 // 3. Join the directory and filename to create the pre-fill path
271 const defaultPath = await join(effectiveDir, suggestedFileName);
272
273 await invoke('set_output_directory', { dir: effectiveDir });
274
275 const outputPath = await save({
276 title: 'Save KML File',
277 defaultPath: defaultPath,
278 filters: [{ name: 'KML File', extensions: ['kml'] }],
279 });
280
281 if (outputPath) {
282 setIsGeneratingKml(true);
283 // Ensure the C++ backend has the latest scenario from the UI
284 await useScenarioStore.getState().syncBackend();
285 await invoke('generate_kml', { outputPath });
286 }
287 } catch (err) {
288 const errorMessage =
289 err instanceof Error ? err.message : String(err);
290 console.error('Failed to invoke KML generation:', errorMessage);
291 showError(`Failed to start KML generation: ${errorMessage}`);
292 setIsGeneratingKml(false); // Stop on invocation failure
293 }
294 };
295
296 const hasProgress = Object.keys(simulationProgress).length > 0;
297 const progressPanelVisible =
298 isSimulating || hasProgress || simulationRunStatus === 'failed';
299 const mainProgress = simulationProgress['main'];
300 const progressHeading = mainProgress
301 ? mainProgress.message
302 : simulationRunStatus === 'completed'
303 ? 'Simulation complete'
304 : simulationRunStatus === 'failed'
305 ? 'Simulation failed'
306 : 'Preparing simulation...';
307 const otherProgresses = Object.entries(simulationProgress)
308 .filter(([key]) => key !== 'main')
309 .sort((a, b) => a[0].localeCompare(b[0]));
310 const mainProgressPercent = mainProgress
311 ? getSimulationProgressPercent(mainProgress)
312 : null;
313 const renderProgressDetails = (
314 details: SimulationProgressState['details']
315 ) => {
316 if (!details || details.length === 0) {
317 return null;
318 }
319
320 return (
321 <Box component="ul" sx={{ m: 0, mt: 1, pl: 2 }}>
322 {details.map((detail) => {
323 const detailPercent = getSimulationProgressPercent(detail);
324 const detailSuffix =
325 detailPercent !== null
326 ? ` (${Math.round(detailPercent)}%)`
327 : detail.current > 0
328 ? ` (Chunk ${detail.current})`
329 : '';
330
331 return (
332 <Typography
333 component="li"
334 variant="caption"
335 color="text.secondary"
336 key={detail.id}
337 >
338 {detail.message}
339 {detailSuffix}
340 </Typography>
341 );
342 })}
343 </Box>
344 );
345 };
346 const exportMetadataJson = async () => {
347 try {
348 const outputPath = await invoke<string>(
349 'export_output_metadata_json'
350 );
351 setMetadataExportPath(outputPath);
352 showSuccess(`Metadata JSON saved to ${outputPath}`);
353 } catch (err) {
354 const errorMessage =
355 err instanceof Error ? err.message : String(err);
356 showError(`Failed to export metadata JSON: ${errorMessage}`);
357 }
358 };
359 const formatSampleRange = (start: number, end: number) =>
360 `[${start}, ${end})`;
361 const formatPulseLength = (
362 minSamples: number,
363 maxSamples: number,
364 uniform: boolean
365 ) => {
366 if (minSamples === 0 && maxSamples === 0) {
367 return '0';
368 }
369 return uniform ? String(minSamples) : `${minSamples} - ${maxSamples}`;
370 };
371
372 return (
373 <Box
374 sx={{ p: 4, height: '100%', overflowY: 'auto', contain: 'content' }}
375 >
376 <Typography variant="h4" gutterBottom>
377 Simulation Runner
378 </Typography>
379 <Typography variant="body1" color="text.secondary" sx={{ mb: 4 }}>
380 Execute the configured scenario or generate a geographical
381 visualization. Ensure your scenario is fully configured before
382 proceeding.
383 </Typography>
384 <Card elevation={0} sx={{ mb: 4 }}>
385 <CardContent>
386 <Typography variant="h6" gutterBottom>
387 Output Settings
388 </Typography>
389 <Typography
390 variant="body2"
391 color="text.secondary"
392 sx={{ mb: 2 }}
393 >
394 Simulation results (.h5 files) and default KML exports
395 will be saved here.
396 </Typography>
397 <Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
398 <TextField
399 label="Output Directory"
400 variant="outlined"
401 size="small"
402 fullWidth
403 value={
404 outputDirectory ||
405 (scenarioFilePath
406 ? 'Default (Scenario Directory)'
407 : 'Default (Current Directory)')
408 }
409 slotProps={{
410 input: {
411 readOnly: true,
412 },
413 }}
414 />
415 <Button
416 variant="outlined"
417 onClick={handleSelectOutputDir}
418 sx={{ whiteSpace: 'nowrap' }}
419 >
420 Browse...
421 </Button>
422 {outputDirectory && (
423 <Button
424 variant="text"
425 color="error"
426 onClick={() => setOutputDirectory(null)}
427 >
428 Reset
429 </Button>
430 )}
431 </Box>
432 </CardContent>
433 </Card>
434
435 <Grid container spacing={4} sx={{ width: '100%' }}>
436 {/* ... existing Grid items for Run Simulation and Generate KML ... */}
437 <Grid size={{ xs: 12, md: 6 }}>
438 <Card elevation={0} sx={{ height: '100%' }}>
439 <CardContent>
440 <Typography variant="h5" component="div">
441 Run Full Simulation
442 </Typography>
443 <Typography sx={{ mt: 1.5 }} color="text.secondary">
444 Executes the entire simulation based on the
445 current scenario settings. This is a
446 computationally intensive process that will
447 generate output files.
448 </Typography>
449 </CardContent>
450 <CardActions sx={{ p: 2 }}>
451 <Button
452 variant="contained"
453 size="large"
454 startIcon={
455 isSimulating ? (
456 <CircularProgress
457 size={24}
458 color="inherit"
459 />
460 ) : (
461 <PlayCircleOutlineIcon />
462 )
463 }
464 disabled={isSimulating || isGeneratingKml}
465 onClick={handleRunSimulation}
466 >
467 {isSimulating ? 'Running...' : 'Run Simulation'}
468 </Button>
469 </CardActions>
470 </Card>
471 </Grid>
472 <Grid size={{ xs: 12, md: 6 }}>
473 <Card elevation={0} sx={{ height: '100%' }}>
474 <CardContent>
475 <Typography variant="h5" component="div">
476 Generate KML
477 </Typography>
478 <Typography sx={{ mt: 1.5 }} color="text.secondary">
479 Creates a KML file from the scenario&apos;s
480 platform motion paths and antenna pointings.
481 This allows for quick visualization in
482 applications like Google Earth without running
483 the full signal-level simulation.
484 </Typography>
485 </CardContent>
486 <CardActions sx={{ p: 2 }}>
487 <Button
488 variant="outlined"
489 size="large"
490 startIcon={
491 isGeneratingKml ? (
492 <CircularProgress
493 size={24}
494 color="inherit"
495 />
496 ) : (
497 <MapIcon />
498 )
499 }
500 disabled={isSimulating || isGeneratingKml}
501 onClick={handleGenerateKml}
502 >
503 {isGeneratingKml
504 ? 'Generating...'
505 : 'Generate KML'}
506 </Button>
507 </CardActions>
508 </Card>
509 </Grid>
510 </Grid>
511
512 {progressPanelVisible && (
513 <Box
514 sx={{
515 mt: 4,
516 p: 2,
517 backgroundColor: 'action.hover',
518 borderRadius: 1,
519 }}
520 >
521 {/* Main Simulation Progress */}
522 <Typography
523 variant="h6"
524 sx={{ mb: 1, textAlign: 'center' }}
525 >
526 {progressHeading}
527 </Typography>
528 {simulationRunStatus === 'failed' && simulationRunError && (
529 <Typography
530 variant="body2"
531 color="error"
532 sx={{ mb: 2, textAlign: 'center' }}
533 >
534 {simulationRunError}
535 </Typography>
536 )}
537 {mainProgressPercent !== null && (
538 <Box
539 sx={{
540 display: 'flex',
541 alignItems: 'center',
542 mt: 2,
543 mb: 2,
544 }}
545 >
546 <Box sx={{ width: '100%', mr: 1 }}>
547 <LinearProgress
548 variant="determinate"
549 value={mainProgressPercent}
550 />
551 </Box>
552 <Box sx={{ minWidth: 40 }}>
553 <Typography
554 variant="body2"
555 color="text.secondary"
556 >{`${Math.round(mainProgressPercent)}%`}</Typography>
557 </Box>
558 </Box>
559 )}
560 {renderProgressDetails(mainProgress?.details)}
561
562 {/* Finalizer Threads List */}
563 {otherProgresses.length > 0 && (
564 <Box
565 sx={{
566 mt: 2,
567 borderTop: 1,
568 borderColor: 'divider',
569 pt: 2,
570 }}
571 >
572 <Typography
573 variant="subtitle2"
574 color="text.secondary"
575 >
576 Exporting Data:
577 </Typography>
578 <List dense>
579 {otherProgresses.map(([key, prog]) => {
580 const progressPercent =
581 getSimulationProgressPercent(prog);
582 const showChunkLabel =
583 progressPercent === null &&
584 prog.current > 0;
585
586 return (
587 <ListItem key={key}>
588 <Box sx={{ width: '100%' }}>
589 <Box
590 sx={{
591 display: 'flex',
592 alignItems: 'center',
593 }}
594 >
595 <ListItemText
596 primary={prog.message}
597 secondary={
598 progressPercent !==
599 null &&
600 progressPercent <
601 100
602 ? 'Processing...'
603 : ''
604 }
605 />
606 {showChunkLabel && (
607 <Box
608 sx={{
609 width: '20%',
610 ml: 2,
611 }}
612 >
613 <Typography
614 variant="caption"
615 color="text.secondary"
616 >
617 Chunk{' '}
618 {prog.current}
619 </Typography>
620 </Box>
621 )}
622 {progressPercent !==
623 null && (
624 <Box
625 sx={{
626 width: '30%',
627 ml: 2,
628 }}
629 >
630 <LinearProgress
631 variant="determinate"
632 value={
633 progressPercent
634 }
635 />
636 </Box>
637 )}
638 </Box>
639 {renderProgressDetails(
640 prog.details
641 )}
642 </Box>
643 </ListItem>
644 );
645 })}
646 </List>
647 </Box>
648 )}
649 </Box>
650 )}
651
652 {simulationOutputMetadata && (
653 <Card elevation={0} sx={{ mt: 4 }}>
654 <CardContent>
655 <Box
656 sx={{
657 display: 'flex',
658 justifyContent: 'space-between',
659 alignItems: 'center',
660 gap: 2,
661 mb: 2,
662 }}
663 >
664 <Box>
665 <Typography variant="h6">
666 Output Data Metadata
667 </Typography>
668 <Typography
669 variant="body2"
670 color="text.secondary"
671 >
672 {simulationOutputMetadata.files.length} HDF5
673 output file
674 {simulationOutputMetadata.files.length === 1
675 ? ''
676 : 's'}{' '}
677 at {simulationOutputMetadata.sampling_rate}{' '}
678 samples/s.
679 </Typography>
680 </Box>
681 <Button
682 variant="outlined"
683 onClick={exportMetadataJson}
684 >
685 Export JSON
686 </Button>
687 </Box>
688 {metadataExportPath && (
689 <Typography
690 variant="body2"
691 color="text.secondary"
692 sx={{ mb: 2, overflowWrap: 'anywhere' }}
693 >
694 Metadata JSON saved to {metadataExportPath}
695 </Typography>
696 )}
697
698 {simulationOutputMetadata.files.length === 0 ? (
699 <Typography color="text.secondary">
700 No HDF5 output files were generated for this
701 run.
702 </Typography>
703 ) : (
704 <TableContainer
705 component={Paper}
706 variant="outlined"
707 >
708 <Table size="small">
709 <TableHead>
710 <TableRow>
711 <TableCell>Receiver</TableCell>
712 <TableCell>Mode</TableCell>
713 <TableCell align="right">
714 Samples
715 </TableCell>
716 <TableCell>Sample Range</TableCell>
717 <TableCell>Pulse/Segment</TableCell>
718 <TableCell>File</TableCell>
719 </TableRow>
720 </TableHead>
721 <TableBody>
722 {simulationOutputMetadata.files.map(
723 (file) => (
724 <TableRow key={file.path}>
725 <TableCell>
726 {file.receiver_name}
727 </TableCell>
728 <TableCell>
729 {file.mode}
730 </TableCell>
731 <TableCell align="right">
732 {file.total_samples}
733 </TableCell>
734 <TableCell>
735 {formatSampleRange(
736 file.sample_start,
737 file.sample_end_exclusive
738 )}
739 </TableCell>
740 <TableCell>
741 {file.mode === 'pulsed'
742 ? `${file.pulse_count} pulses, ${formatPulseLength(file.min_pulse_length_samples, file.max_pulse_length_samples, file.uniform_pulse_length)} samples`
743 : `${file.cw_segments.length} CW segments`}
744 </TableCell>
745 <TableCell
746 sx={{
747 maxWidth: 360,
748 overflowWrap:
749 'anywhere',
750 }}
751 >
752 {file.path}
753 </TableCell>
754 </TableRow>
755 )
756 )}
757 </TableBody>
758 </Table>
759 </TableContainer>
760 )}
761 </CardContent>
762 </Card>
763 )}
764 </Box>
765 );
766});