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 return `${fmcw.chirp_direction ?? 'up'}, B=${formatMetric(
426 )}, T_c=${formatMetric(
428 )}, T_rep=${formatMetric(fmcw.chirp_period ?? 0)}`;
430 const formatPulseOrSegmentSummary = (
431 file: SimulationOutputFileMetadata
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
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`;
448 return segmentSummary;
451 return `${segmentSummary}, ${formatFmcwMetadata(file.fmcw)}`;
454 const toggleMetadataRow = (path: string) => {
455 setExpandedMetadataPaths((current) => {
456 const next = new Set(current);
457 if (next.has(path)) {
466 const renderFmcwDetails = (file: SimulationOutputFileMetadata) => {
467 if (file.mode !== 'fmcw') {
472 <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
473 <Typography variant="body2">
474 File sample rate: {formatMetric(file.sampling_rate)}{' '}
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'}
484 {file.fmcw_sources.length === 0 ? (
485 <Typography variant="body2" color="text.secondary">
486 No FMCW source metadata was emitted for this receiver.
489 file.fmcw_sources.map((source) => (
491 key={`${source.transmitter_id}:${source.waveform_id}`}
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)}
501 {source.chirp_rate_signed !== undefined
502 ? `, signed=${formatMetric(
503 source.chirp_rate_signed
506 {source.chirp_count !== undefined
507 ? `, chirps=${source.chirp_count}`
509 {source.triangle_count !== undefined
510 ? `, triangles=${source.triangle_count}`
513 {source.segments.map((segment) => (
515 key={`${segment.start_time}:${segment.end_time}`}
517 color="text.secondary"
518 sx={{ display: 'block' }}
520 [{formatMetric(segment.start_time)},{' '}
521 {formatMetric(segment.end_time)}]:{' '}
522 {segment.emitted_chirp_count !== undefined
523 ? `${segment.emitted_chirp_count} chirps`
525 {segment.emitted_triangle_count !==
527 ? `${segment.emitted_triangle_count} triangles`
540 sx={{ p: 4, height: '100%', overflowY: 'auto', contain: 'content' }}
542 <Typography variant="h4" gutterBottom>
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
550 <Card elevation={0} sx={{ mb: 4 }}>
552 <Typography variant="h6" gutterBottom>
557 color="text.secondary"
560 Simulation results (.h5 files) and default KML exports
563 <Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
565 label="Output Directory"
572 ? 'Default (Scenario Directory)'
573 : 'Default (Current Directory)')
583 onClick={handleSelectOutputDir}
584 sx={{ whiteSpace: 'nowrap' }}
588 {outputDirectory && (
592 onClick={() => setOutputDirectory(null)}
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%' }}>
606 <Typography variant="h5" component="div">
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.
616 <CardActions sx={{ p: 2 }}>
627 <PlayCircleOutlineIcon />
630 disabled={isSimulating || isGeneratingKml}
631 onClick={handleRunSimulation}
633 {isSimulating ? 'Running...' : 'Run Simulation'}
638 <Grid size={{ xs: 12, md: 6 }}>
639 <Card elevation={0} sx={{ height: '100%' }}>
641 <Typography variant="h5" component="div">
644 <Typography sx={{ mt: 1.5 }} color="text.secondary">
645 Creates a KML file from the scenario'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.
652 <CardActions sx={{ p: 2 }}>
666 disabled={isSimulating || isGeneratingKml}
667 onClick={handleGenerateKml}
678 {progressPanelVisible && (
683 backgroundColor: 'action.hover',
687 {/* Main Simulation Progress */}
690 sx={{ mb: 1, textAlign: 'center' }}
694 {simulationRunStatus === 'failed' && simulationRunError && (
698 sx={{ mb: 2, textAlign: 'center' }}
703 {mainProgressPercent !== null && (
707 alignItems: 'center',
712 <Box sx={{ width: '100%', mr: 1 }}>
714 variant="determinate"
715 value={mainProgressPercent}
718 <Box sx={{ minWidth: 40 }}>
721 color="text.secondary"
722 >{`${Math.round(mainProgressPercent)}%`}</Typography>
726 {renderProgressDetails(mainProgress?.details)}
728 {/* Finalizer Threads List */}
729 {otherProgresses.length > 0 && (
734 borderColor: 'divider',
740 color="text.secondary"
745 {otherProgresses.map(([key, prog]) => {
746 const progressPercent =
747 getSimulationProgressPercent(prog);
748 const showChunkLabel =
749 progressPercent === null &&
754 <Box sx={{ width: '100%' }}>
758 alignItems: 'center',
762 primary={prog.message}
773 color="text.secondary"
789 variant="determinate"
797 {renderProgressDetails(
810 {simulationOutputMetadata && (
811 <Card elevation={0} sx={{ mt: 4 }}>
816 justifyContent: 'space-between',
817 alignItems: 'center',
823 <Typography variant="h6">
828 color="text.secondary"
830 {simulationOutputMetadata.files.length} HDF5
832 {simulationOutputMetadata.files.length === 1
835 at {formatMetadataSamplingRates()}.
840 onClick={exportMetadataJson}
845 {metadataExportPath && (
848 color="text.secondary"
849 sx={{ mb: 2, overflowWrap: 'anywhere' }}
851 Metadata JSON saved to {metadataExportPath}
855 {simulationOutputMetadata.files.length === 0 ? (
856 <Typography color="text.secondary">
857 No HDF5 output files were generated for this
869 <TableCell>Receiver</TableCell>
870 <TableCell>Mode</TableCell>
871 <TableCell align="right">
874 <TableCell align="right">
877 <TableCell>Sample Range</TableCell>
878 <TableCell>Pulse/Segment</TableCell>
879 <TableCell>File</TableCell>
883 {simulationOutputMetadata.files.map(
886 expandedMetadataPaths.has(
890 file.mode === 'fmcw';
907 <KeyboardArrowDownIcon fontSize="small" />
909 <KeyboardArrowRightIcon fontSize="small" />
922 <TableCell align="right">
927 <TableCell align="right">
935 file.sample_end_exclusive
939 {formatPulseOrSegmentSummary(