1// SPDX-License-Identifier: GPL-2.0-only
2// Copyright (c) 2025-present FERS Contributors (see AUTHORS.md).
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';
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';
40 normalizeSimulationOutputMetadata,
41 type RawSimulationOutputMetadata,
42 type SimulationOutputFileMetadata,
43 type SimulationProgressState,
44 useSimulationProgressStore,
45} from '@/stores/simulationProgressStore';
47 addSimulationProgressEvent,
48 getSimulationProgressPercent,
49 normalizeCompletedProgressSnapshot,
50} from './simulationProgress';
52export const SimulationView = React.memo(function SimulationView() {
53 const [metadataExportPath, setMetadataExportPath] = useState<string | null>(
56 const [expandedMetadataPaths, setExpandedMetadataPaths] = useState<
59 const isSimulating = useSimulationProgressStore(
60 (state) => state.isSimulating
62 const isGeneratingKml = useSimulationProgressStore(
63 (state) => state.isGeneratingKml
65 const setIsGeneratingKml = useSimulationProgressStore(
66 (state) => state.setIsGeneratingKml
68 const simulationProgress = useSimulationProgressStore(
69 (state) => state.simulationProgress
71 const simulationRunStatus = useSimulationProgressStore(
72 (state) => state.simulationRunStatus
74 const simulationRunError = useSimulationProgressStore(
75 (state) => state.simulationRunError
77 const simulationOutputMetadata = useSimulationProgressStore(
78 (state) => state.simulationOutputMetadata
80 const startSimulationRun = useSimulationProgressStore(
81 (state) => state.startSimulationRun
83 const setSimulationProgressSnapshot = useSimulationProgressStore(
84 (state) => state.setSimulationProgressSnapshot
86 const setSimulationOutputMetadata = useSimulationProgressStore(
87 (state) => state.setSimulationOutputMetadata
89 const completeSimulationRun = useSimulationProgressStore(
90 (state) => state.completeSimulationRun
92 const failSimulationRun = useSimulationProgressStore(
93 (state) => state.failSimulationRun
95 const showError = useScenarioStore((state) => state.showError);
96 const showSuccess = useScenarioStore((state) => state.showSuccess);
97 const scenarioFilePath = useScenarioStore(
98 (state) => state.scenarioFilePath
100 const outputDirectory = useScenarioStore((state) => state.outputDirectory);
101 const setOutputDirectory = useScenarioStore(
102 (state) => state.setOutputDirectory
105 // Use a Ref to store incoming data to avoid triggering re-renders on every event
106 const progressRef = useRef<Record<string, SimulationProgressState>>({});
109 let animationFrameId: number | undefined;
111 const flushProgress = () => {
112 setSimulationProgressSnapshot({ ...progressRef.current });
115 // The update loop synchronizes the Ref data to the State at screen refresh rate
116 const updateLoop = () => {
117 if (useSimulationProgressStore.getState().isSimulating) {
119 animationFrameId = requestAnimationFrame(updateLoop);
123 const unlistenSimComplete = listen<void>('simulation-complete', () => {
124 console.log('Simulation completed successfully.');
125 progressRef.current = normalizeCompletedProgressSnapshot(
129 completeSimulationRun();
130 if (animationFrameId !== undefined) {
131 cancelAnimationFrame(animationFrameId);
135 const unlistenSimError = listen<string>('simulation-error', (event) => {
136 const errorMessage = `Simulation failed: ${event.payload}`;
137 console.error(errorMessage);
138 showError(errorMessage);
140 failSimulationRun(errorMessage);
141 if (animationFrameId !== undefined) {
142 cancelAnimationFrame(animationFrameId);
146 const unlistenSimProgress = listen<SimulationProgressState>(
147 'simulation-progress',
149 progressRef.current = addSimulationProgressEvent(
156 const unlistenOutputMetadata = listen<string>(
157 'simulation-output-metadata',
160 setSimulationOutputMetadata(
161 normalizeSimulationOutputMetadata(
164 ) as RawSimulationOutputMetadata
169 err instanceof Error ? err.message : String(err);
171 `Failed to decode simulation metadata: ${errorMessage}`
177 const unlistenKmlComplete = listen<string>(
178 'kml-generation-complete',
180 console.log('KML generated successfully at:', event.payload);
181 setIsGeneratingKml(false);
185 const unlistenKmlError = listen<string>(
186 'kml-generation-error',
188 const errorMessage = `KML generation failed: ${event.payload}`;
189 console.error(errorMessage);
190 showError(errorMessage);
191 setIsGeneratingKml(false);
195 // Start the UI update loop if we are simulating
201 if (animationFrameId !== undefined) {
202 cancelAnimationFrame(animationFrameId);
208 unlistenOutputMetadata,
211 ]).then((unlisteners) => {
212 unlisteners.forEach((unlisten) => unlisten());
217 setSimulationProgressSnapshot,
218 setSimulationOutputMetadata,
219 completeSimulationRun,
225 const getEffectiveOutputDir = async () => {
226 if (outputDirectory) return outputDirectory;
227 if (scenarioFilePath) {
229 return await dirname(scenarioFilePath);
231 console.warn('Failed to get dirname of scenario file', e);
237 const handleSelectOutputDir = async () => {
239 const selected = await open({
242 defaultPath: await getEffectiveOutputDir(),
244 if (typeof selected === 'string') {
245 setOutputDirectory(selected);
248 console.error('Failed to open directory dialog:', err);
252 const handleRunSimulation = async () => {
253 const scenarioState = useScenarioStore.getState();
254 const validationMessage =
255 getBlockingFmcwValidationMessage(scenarioState);
256 if (validationMessage) {
257 showError(`FMCW validation failed: ${validationMessage}`);
261 progressRef.current = {};
262 setMetadataExportPath(null);
263 startSimulationRun();
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 });
269 await useScenarioStore.getState().syncBackend();
270 await invoke('run_simulation');
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}`);
280 const handleGenerateKml = async () => {
282 const scenarioState = useScenarioStore.getState();
283 const validationMessage =
284 getBlockingFmcwValidationMessage(scenarioState);
285 if (validationMessage) {
286 showError(`FMCW validation failed: ${validationMessage}`);
290 const effectiveDir = await getEffectiveOutputDir();
292 // 1. Get the simulation name from the store
294 scenarioState.globalParameters.simulation_name || 'scenario';
296 // 2. Sanitize the name and append extension
297 const suggestedFileName = `${simName.replace(/[^a-z0-9]/gi, '_')}.kml`;
299 // 3. Join the directory and filename to create the pre-fill path
300 const defaultPath = await join(effectiveDir, suggestedFileName);
302 await invoke('set_output_directory', { dir: effectiveDir });
304 const outputPath = await save({
305 title: 'Save KML File',
306 defaultPath: defaultPath,
307 filters: [{ name: 'KML File', extensions: ['kml'] }],
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 });
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
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)
342 const renderProgressDetails = (
343 details: SimulationProgressState['details']
345 if (!details || details.length === 0) {
350 <Box component="ul" sx={{ m: 0, mt: 1, pl: 2 }}>
351 {details.map((detail) => {
352 const detailPercent = getSimulationProgressPercent(detail);
354 detailPercent !== null
355 ? ` (${Math.round(detailPercent)}%)`
357 ? ` (Chunk ${detail.current})`
364 color="text.secondary"
375 const exportMetadataJson = async () => {
377 const outputPath = await invoke<string>(
378 'export_output_metadata_json'
380 setMetadataExportPath(outputPath);
381 showSuccess(`Metadata JSON saved to ${outputPath}`);
384 err instanceof Error ? err.message : String(err);
385 showError(`Failed to export metadata JSON: ${errorMessage}`);
388 const formatSampleRange = (start: number, end: number) =>
389 `[${start}, ${end})`;
390 const formatPulseLength = (
395 if (minSamples === 0 && maxSamples === 0) {
398 return uniform ? String(minSamples) : `${minSamples} - ${maxSamples}`;
400 const formatMetric = (value: number) =>
401 value.toLocaleString(undefined, { maximumSignificantDigits: 6 });
402 const formatMetadataSamplingRates = () => {
403 if (!simulationOutputMetadata) {
406 if (typeof simulationOutputMetadata.sampling_rate === 'number') {
407 return `${formatMetric(
408 simulationOutputMetadata.sampling_rate
411 return `${simulationOutputMetadata.sampling_rates?.length ?? 0} sample rates`;
413 const formatFmcwMetadata = (
414 fmcw: NonNullable<SimulationOutputFileMetadata['fmcw']>
416 if (fmcw.waveform_shape === 'triangle') {
417 return `triangle, B=${formatMetric(
419 )}, T_c=${formatMetric(
421 )}, T_tri=${formatMetric(fmcw.triangle_period ?? 0)}`;
424 if (fmcw.waveform_shape === 'file') {
425 return `file, duration=${formatMetric(fmcw.sampled_duration ?? 0)}, samples=${fmcw.sampled_count ?? 0}`;
428 return `${fmcw.chirp_direction ?? 'up'}, B=${formatMetric(
430 )}, T_c=${formatMetric(
432 )}, T_rep=${formatMetric(fmcw.chirp_period ?? 0)}`;
434 const formatSfcwMetadata = (
435 sfcw: NonNullable<SimulationOutputFileMetadata['sfcw']>
437 `steps=${sfcw.step_count}, df=${formatMetric(
439 )} Hz, dwell=${formatMetric(
441 )}, T_step=${formatMetric(sfcw.step_period)}`;
442 const formatPulseOrSegmentSummary = (
443 file: SimulationOutputFileMetadata
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
453 const segmentSummary = `${
454 file.streaming_segments.length
455 } streaming segments`;
456 if (file.mode === 'sfcw') {
458 return `${segmentSummary}, ${formatSfcwMetadata(file.sfcw)}`;
460 if (file.sfcw_sources.length > 0) {
461 return `${segmentSummary}, ${file.sfcw_sources.length} SFCW sources`;
463 return segmentSummary;
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`;
470 return segmentSummary;
473 return `${segmentSummary}, ${formatFmcwMetadata(file.fmcw)}`;
476 const toggleMetadataRow = (path: string) => {
477 setExpandedMetadataPaths((current) => {
478 const next = new Set(current);
479 if (next.has(path)) {
488 const renderFmcwDetails = (file: SimulationOutputFileMetadata) => {
489 if (file.mode !== 'fmcw') {
494 <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
495 <Typography variant="body2">
496 File sample rate: {formatMetric(file.sampling_rate)}{' '}
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'}
506 {file.fmcw_sources.length === 0 ? (
507 <Typography variant="body2" color="text.secondary">
508 No FMCW source metadata was emitted for this receiver.
511 file.fmcw_sources.map((source) => (
513 key={`${source.transmitter_id}:${source.waveform_id}`}
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)}
523 {source.chirp_rate_signed !== undefined
524 ? `, signed=${formatMetric(
525 source.chirp_rate_signed
528 {source.chirp_count !== undefined
529 ? `, chirps=${source.chirp_count}`
531 {source.triangle_count !== undefined
532 ? `, triangles=${source.triangle_count}`
535 {source.segments.map((segment) => (
537 key={`${segment.start_time}:${segment.end_time}`}
539 color="text.secondary"
540 sx={{ display: 'block' }}
542 [{formatMetric(segment.start_time)},{' '}
543 {formatMetric(segment.end_time)}]:{' '}
544 {segment.emitted_chirp_count !== undefined
545 ? `${segment.emitted_chirp_count} chirps`
547 {segment.emitted_triangle_count !==
549 ? `${segment.emitted_triangle_count} triangles`
560 const renderSfcwDetails = (file: SimulationOutputFileMetadata) => {
561 if (file.mode !== 'sfcw') {
566 <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
567 <Typography variant="body2">
568 File sample rate: {formatMetric(file.sampling_rate)}{' '}
571 {file.sfcw_sources.length === 0 ? (
572 <Typography variant="body2" color="text.secondary">
573 No SFCW source metadata was emitted for this receiver.
576 file.sfcw_sources.map((source) => (
578 key={`${source.transmitter_id}:${source.waveform_id}`}
581 <Typography variant="body2">
582 {source.transmitter_name} /{' '}
583 {source.waveform_name}:{' '}
584 {formatSfcwMetadata(source)}, B_eff=
585 {formatMetric(source.effective_bandwidth)} Hz,
587 {formatMetric(source.range_resolution)} m,
589 {formatMetric(source.unambiguous_range)} m
591 {source.segments.map((segment) => (
593 key={`${segment.start_time}:${segment.end_time}`}
595 color="text.secondary"
596 sx={{ display: 'block' }}
598 [{formatMetric(segment.start_time)},{' '}
599 {formatMetric(segment.end_time)}]:{' '}
600 {segment.emitted_step_count !== undefined
601 ? `${segment.emitted_step_count} steps`
614 sx={{ p: 4, height: '100%', overflowY: 'auto', contain: 'content' }}
616 <Typography variant="h4" gutterBottom>
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
624 <Card elevation={0} sx={{ mb: 4 }}>
626 <Typography variant="h6" gutterBottom>
631 color="text.secondary"
634 Simulation results (.h5 files) and default KML exports
637 <Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
639 label="Output Directory"
646 ? 'Default (Scenario Directory)'
647 : 'Default (Current Directory)')
657 onClick={handleSelectOutputDir}
658 sx={{ whiteSpace: 'nowrap' }}
662 {outputDirectory && (
666 onClick={() => setOutputDirectory(null)}
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%' }}>
680 <Typography variant="h5" component="div">
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.
690 <CardActions sx={{ p: 2 }}>
701 <PlayCircleOutlineIcon />
704 disabled={isSimulating || isGeneratingKml}
705 onClick={handleRunSimulation}
707 {isSimulating ? 'Running...' : 'Run Simulation'}
712 <Grid size={{ xs: 12, md: 6 }}>
713 <Card elevation={0} sx={{ height: '100%' }}>
715 <Typography variant="h5" component="div">
718 <Typography sx={{ mt: 1.5 }} color="text.secondary">
719 Creates a KML file from the scenario'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.
726 <CardActions sx={{ p: 2 }}>
740 disabled={isSimulating || isGeneratingKml}
741 onClick={handleGenerateKml}
752 {progressPanelVisible && (
757 backgroundColor: 'action.hover',
761 {/* Main Simulation Progress */}
764 sx={{ mb: 1, textAlign: 'center' }}
768 {simulationRunStatus === 'failed' && simulationRunError && (
772 sx={{ mb: 2, textAlign: 'center' }}
777 {mainProgressPercent !== null && (
781 alignItems: 'center',
786 <Box sx={{ width: '100%', mr: 1 }}>
788 variant="determinate"
789 value={mainProgressPercent}
792 <Box sx={{ minWidth: 40 }}>
795 color="text.secondary"
796 >{`${Math.round(mainProgressPercent)}%`}</Typography>
800 {renderProgressDetails(mainProgress?.details)}
802 {/* Finalizer Threads List */}
803 {otherProgresses.length > 0 && (
808 borderColor: 'divider',
814 color="text.secondary"
819 {otherProgresses.map(([key, prog]) => {
820 const progressPercent =
821 getSimulationProgressPercent(prog);
822 const showChunkLabel =
823 progressPercent === null &&
828 <Box sx={{ width: '100%' }}>
832 alignItems: 'center',
836 primary={prog.message}
847 color="text.secondary"
863 variant="determinate"
871 {renderProgressDetails(
884 {simulationOutputMetadata && (
885 <Card elevation={0} sx={{ mt: 4 }}>
890 justifyContent: 'space-between',
891 alignItems: 'center',
897 <Typography variant="h6">
902 color="text.secondary"
904 {simulationOutputMetadata.files.length} HDF5
906 {simulationOutputMetadata.files.length === 1
909 at {formatMetadataSamplingRates()}.
914 onClick={exportMetadataJson}
919 {metadataExportPath && (
922 color="text.secondary"
923 sx={{ mb: 2, overflowWrap: 'anywhere' }}
925 Metadata JSON saved to {metadataExportPath}
929 {simulationOutputMetadata.files.length === 0 ? (
930 <Typography color="text.secondary">
931 No HDF5 output files were generated for this
943 <TableCell>Receiver</TableCell>
944 <TableCell>Mode</TableCell>
945 <TableCell align="right">
948 <TableCell align="right">
951 <TableCell>Sample Range</TableCell>
952 <TableCell>Pulse/Segment</TableCell>
953 <TableCell>File</TableCell>
957 {simulationOutputMetadata.files.map(
960 expandedMetadataPaths.has(
964 file.mode === 'fmcw' ||
965 file.mode === 'sfcw';
982 <KeyboardArrowDownIcon fontSize="small" />
984 <KeyboardArrowRightIcon fontSize="small" />
997 <TableCell align="right">
1002 <TableCell align="right">
1010 file.sample_end_exclusive
1014 {formatPulseOrSegmentSummary(