diff --git a/kwave/data.py b/kwave/data.py index 9e320445..98cfc7ed 100644 --- a/kwave/data.py +++ b/kwave/data.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Any +from typing import Any, Optional import numpy as np @@ -119,3 +119,118 @@ def append(self, val): assert len(self.data) <= 2 self.data.append(val) return self + + +@dataclass +class SimulationResult: + """ + Structured return type for kWave simulation results. + + Contains all possible fields that can be returned by the kWave C++ binaries. + Fields are populated based on the sensor.record configuration. + """ + + # Grid information (always present) + Nx: int + Ny: int + Nz: int + Nt: int + pml_x_size: int + pml_y_size: int + pml_z_size: int + axisymmetric_flag: bool + + # Pressure fields (optional - based on sensor.record) + p_raw: Optional[np.ndarray] = None + p_max: Optional[np.ndarray] = None + p_min: Optional[np.ndarray] = None + p_rms: Optional[np.ndarray] = None + p_max_all: Optional[np.ndarray] = None + p_min_all: Optional[np.ndarray] = None + p_final: Optional[np.ndarray] = None + + # Velocity fields (optional - based on sensor.record) + u_raw: Optional[np.ndarray] = None + u_max: Optional[np.ndarray] = None + u_min: Optional[np.ndarray] = None + u_rms: Optional[np.ndarray] = None + u_max_all: Optional[np.ndarray] = None + u_min_all: Optional[np.ndarray] = None + u_final: Optional[np.ndarray] = None + u_non_staggered_raw: Optional[np.ndarray] = None + + # Intensity fields (optional - based on sensor.record) + I_avg: Optional[np.ndarray] = None + I: Optional[np.ndarray] = None + + def __getitem__(self, key: str): + """ + Enable dictionary-style access for backward compatibility. + + Args: + key: Field name to access + + Returns: + Value of the field + + Raises: + KeyError: If the field does not exist + """ + if hasattr(self, key): + return getattr(self, key) + raise KeyError(f"'{key}' field not found in SimulationResult") + + def __contains__(self, key: str) -> bool: + """ + Enable dictionary-style membership testing for backward compatibility. + + Args: + key: Field name to check + + Returns: + True if the field exists, False otherwise + """ + return hasattr(self, key) + + @classmethod + def from_dotdict(cls, data: dict) -> "SimulationResult": + """ + Create SimulationResult from dotdict returned by parse_executable_output. + + Args: + data: Dictionary containing simulation results from HDF5 file + + Returns: + SimulationResult instance with all available fields populated + """ + return cls( + # Grid information + Nx=int(data.get("Nx", 0)), + Ny=int(data.get("Ny", 0)), + Nz=int(data.get("Nz", 0)), + Nt=int(data.get("Nt", 0)), + pml_x_size=int(data.get("pml_x_size", 0)), + pml_y_size=int(data.get("pml_y_size", 0)), + pml_z_size=int(data.get("pml_z_size", 0)), + axisymmetric_flag=bool(data.get("axisymmetric_flag", False)), + # Pressure fields + p_raw=data.get("p_raw"), + p_max=data.get("p_max"), + p_min=data.get("p_min"), + p_rms=data.get("p_rms"), + p_max_all=data.get("p_max_all"), + p_min_all=data.get("p_min_all"), + p_final=data.get("p_final"), + # Velocity fields + u_raw=data.get("u_raw"), + u_max=data.get("u_max"), + u_min=data.get("u_min"), + u_rms=data.get("u_rms"), + u_max_all=data.get("u_max_all"), + u_min_all=data.get("u_min_all"), + u_final=data.get("u_final"), + u_non_staggered_raw=data.get("u_non_staggered_raw"), + # Intensity fields + I_avg=data.get("I_avg"), + I=data.get("I"), + ) diff --git a/kwave/executor.py b/kwave/executor.py index 77e10803..9c20961d 100644 --- a/kwave/executor.py +++ b/kwave/executor.py @@ -6,6 +6,7 @@ import h5py import kwave +from kwave.data import SimulationResult from kwave.options.simulation_execution_options import SimulationExecutionOptions from kwave.utils.dotdictionary import dotdict @@ -32,7 +33,7 @@ def _make_binary_executable(self): raise FileNotFoundError(f"Binary not found at {binary_path}") binary_path.chmod(binary_path.stat().st_mode | stat.S_IEXEC) - def run_simulation(self, input_filename: str, output_filename: str, options: list[str]) -> dotdict: + def run_simulation(self, input_filename: str, output_filename: str, options: list[str]) -> SimulationResult: command = [str(self.execution_options.binary_path), "-i", input_filename, "-o", output_filename] + options try: @@ -61,7 +62,7 @@ def run_simulation(self, input_filename: str, output_filename: str, options: lis if not self.simulation_options.pml_inside: self._crop_pml(sensor_data) - return sensor_data + return SimulationResult.from_dotdict(sensor_data) def _crop_pml(self, sensor_data: dotdict): Nx = sensor_data["Nx"].item() diff --git a/kwave/kspaceFirstOrder2D.py b/kwave/kspaceFirstOrder2D.py index 3806e6cc..477b429f 100644 --- a/kwave/kspaceFirstOrder2D.py +++ b/kwave/kspaceFirstOrder2D.py @@ -1,8 +1,9 @@ -from typing import Union +from typing import Optional, Union import numpy as np from beartype import beartype as typechecker +from kwave.data import SimulationResult from kwave.executor import Executor from kwave.kgrid import kWaveGrid from kwave.kmedium import kWaveMedium @@ -85,7 +86,7 @@ def kspaceFirstOrder2DC( medium: kWaveMedium, simulation_options: SimulationOptions, execution_options: SimulationExecutionOptions, -): +) -> Optional[SimulationResult]: """ 2D time-domain simulation of wave propagation using C++ code. @@ -145,7 +146,7 @@ def kspaceFirstOrder2D( medium: kWaveMedium, simulation_options: SimulationOptions, execution_options: SimulationExecutionOptions, -): +) -> Optional[SimulationResult]: """ 2D time-domain simulation of wave propagation using k-space pseudospectral method. diff --git a/kwave/kspaceFirstOrder3D.py b/kwave/kspaceFirstOrder3D.py index be6c904a..b3ef7103 100644 --- a/kwave/kspaceFirstOrder3D.py +++ b/kwave/kspaceFirstOrder3D.py @@ -1,8 +1,8 @@ -from typing import Union +from typing import Optional, Union import numpy as np -from deprecated import deprecated +from kwave.data import SimulationResult from kwave.executor import Executor from kwave.kgrid import kWaveGrid from kwave.kmedium import kWaveMedium @@ -26,7 +26,7 @@ def kspaceFirstOrder3DG( medium: kWaveMedium, simulation_options: SimulationOptions, execution_options: SimulationExecutionOptions, -) -> Union[np.ndarray, dict]: +) -> Optional[SimulationResult]: """ 3D time-domain simulation of wave propagation on a GPU using C++ CUDA code. @@ -80,7 +80,7 @@ def kspaceFirstOrder3DC( medium: kWaveMedium, simulation_options: SimulationOptions, execution_options: SimulationExecutionOptions, -): +) -> Optional[SimulationResult]: """ 3D time-domain simulation of wave propagation using C++ code. @@ -137,7 +137,7 @@ def kspaceFirstOrder3D( simulation_options: SimulationOptions, execution_options: SimulationExecutionOptions, time_rev: bool = False, # deprecated parameter -): +) -> Optional[SimulationResult]: """ 3D time-domain simulation of wave propagation using k-space pseudospectral method. diff --git a/kwave/kspaceFirstOrderAS.py b/kwave/kspaceFirstOrderAS.py index a0c26a61..64e216bb 100644 --- a/kwave/kspaceFirstOrderAS.py +++ b/kwave/kspaceFirstOrderAS.py @@ -1,9 +1,10 @@ import logging -from typing import Union +from typing import Optional, Union import numpy as np from numpy.fft import ifftshift +from kwave.data import SimulationResult from kwave.enums import DiscreteCosine from kwave.executor import Executor from kwave.kgrid import kWaveGrid @@ -30,7 +31,7 @@ def kspaceFirstOrderASC( medium: kWaveMedium, simulation_options: SimulationOptions, execution_options: SimulationExecutionOptions, -): +) -> Optional[SimulationResult]: """ Axisymmetric time-domain simulation of wave propagation using C++ code. @@ -89,7 +90,7 @@ def kspaceFirstOrderAS( medium: kWaveMedium, simulation_options: SimulationOptions, execution_options: SimulationExecutionOptions, -): +) -> Optional[SimulationResult]: """ Axisymmetric time-domain simulation of wave propagation. @@ -158,7 +159,7 @@ def kspaceFirstOrderAS( if simulation_options.simulation_type is not SimulationType.AXISYMMETRIC: logging.log( logging.WARN, - "simulation type is not set to axisymmetric while using kSapceFirstOrderAS. " "Setting simulation type to axisymmetric.", + "simulation type is not set to axisymmetric while using kSapceFirstOrderAS. Setting simulation type to axisymmetric.", ) simulation_options.simulation_type = SimulationType.AXISYMMETRIC @@ -296,7 +297,7 @@ def kspaceFirstOrderAS( # option to run simulations without the spatial staggered grid is not # supported for the axisymmetric code - assert options.use_sg, "Optional input " "UseSG" " is not supported for axisymmetric simulations." + assert options.use_sg, "Optional input UseSG is not supported for axisymmetric simulations." # ========================================================================= # SAVE DATA TO DISK FOR RUNNING SIMULATION EXTERNAL TO MATLAB diff --git a/kwave/options/simulation_options.py b/kwave/options/simulation_options.py index 15da21b1..2bcb0505 100644 --- a/kwave/options/simulation_options.py +++ b/kwave/options/simulation_options.py @@ -47,10 +47,6 @@ class SimulationOptions(object): """ Args: axisymmetric: Flag that indicates whether axisymmetric simulation is used - cart_interp: Interpolation mode used to extract the pressure when a Cartesian sensor mask is given. - If set to 'nearest' and more than one Cartesian point maps to the same grid point, - duplicated data points are discarded and sensor_data will be returned - with less points than that specified by sensor.mask (default = 'linear'). pml_inside: put the PML inside the grid defined by the user pml_alpha: Absorption within the perfectly matched layer in Nepers per grid point (default = 2). save_to_disk: save the input data to a HDF5 file @@ -85,7 +81,6 @@ class SimulationOptions(object): """ simulation_type: SimulationType = SimulationType.FLUID - cart_interp: str = "linear" pml_inside: bool = True pml_alpha: float = 2.0 save_to_disk: bool = False @@ -203,9 +198,6 @@ def option_factory(kgrid: "kWaveGrid", options: SimulationOptions): elastic_code: Flag that indicates whether elastic simulation is used **kwargs: Dictionary that holds following optional simulation properties: - * cart_interp: Interpolation mode used to extract the pressure when a Cartesian sensor mask is given. - If set to 'nearest', duplicated data points are discarded and sensor_data - will be returned with fewer points than specified by sensor.mask (default = 'linear'). * create_log: Boolean controlling whether the command line output is saved using the diary function with a date and time stamped filename (default = false). * data_cast: String input of the data type that variables are cast to before computation. diff --git a/tests/test_executor.py b/tests/test_executor.py index afb4aa81..6c806f3a 100644 --- a/tests/test_executor.py +++ b/tests/test_executor.py @@ -9,6 +9,7 @@ import numpy as np import pytest +from kwave.data import SimulationResult from kwave.executor import Executor from kwave.utils.dotdictionary import dotdict @@ -62,9 +63,8 @@ def setUp(self): "p_final": two_d_output, "p_max_all": three_d_output, } - self.mock_dict = MagicMock() - self.mock_dict.__getitem__.side_effect = self.mock_dict_values.__getitem__ - self.mock_dict.__contains__.side_effect = self.mock_dict_values.__contains__ + # Use a real dictionary instead of a mock + self.mock_dict = self.mock_dict_values.copy() def tearDown(self): # Stop patchers @@ -108,7 +108,7 @@ def mock_stdout_gen(): mock_print.assert_has_calls(expected_calls, any_order=False) # Check that sensor_data is returned correctly - self.assertEqual(sensor_data, dotdict()) + self.assertIsInstance(sensor_data, SimulationResult) def test_run_simulation_success(self): """Test running the simulation successfully.""" @@ -128,7 +128,7 @@ def test_run_simulation_success(self): expected_command, env=self.execution_options.env_vars, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True ) self.mock_proc.communicate.assert_called_once() - self.assertEqual(sensor_data, dotdict()) + self.assertIsInstance(sensor_data, SimulationResult) def test_run_simulation_failure(self): """Test handling a simulation failure.""" @@ -179,6 +179,16 @@ def test_sensor_data_cropping_with_pml_outside(self): self.mock_proc.returncode = 0 self.simulation_options.pml_inside = False + # Create a mock that tracks calls before being replaced + original_two_d_output = MagicMock() + original_two_d_output.ndim = 2 + original_three_d_output = MagicMock() + original_three_d_output.ndim = 3 + + # Update the mock dict to use these tracking mocks + self.mock_dict["p_final"] = original_two_d_output + self.mock_dict["p_max_all"] = original_three_d_output + # Instantiate the Executor executor = Executor(self.execution_options, self.simulation_options) @@ -186,21 +196,32 @@ def test_sensor_data_cropping_with_pml_outside(self): with patch.object(executor, "parse_executable_output", return_value=self.mock_dict): sensor_data = executor.run_simulation("input.h5", "output.h5", ["options"]) - # because pml is outside, the output should be cropped - two_d_output = sensor_data["p_final"] - two_d_output.__getitem__.assert_called_once_with((slice(2, 18), slice(2, 18))) - three_d_output = sensor_data["p_max_all"] - three_d_output.__getitem__.assert_called_once_with((slice(2, 18), slice(2, 18), slice(2, 18))) + # Verify that sensor_data is a SimulationResult + self.assertIsInstance(sensor_data, SimulationResult) + + # Verify that the original mock objects were called for cropping + original_two_d_output.__getitem__.assert_called_once_with((slice(2, 18), slice(2, 18))) + original_three_d_output.__getitem__.assert_called_once_with((slice(2, 18), slice(2, 18), slice(2, 18))) - # check that the other fields are changed - for field in self.mock_dict_values.keys(): + # check that the other fields are unchanged + for field in self.mock_dict.keys(): if field not in ["p_final", "p_max_all"]: - self.assertEqual(sensor_data[field], self.mock_dict_values[field]) + self.assertEqual(sensor_data[field], self.mock_dict[field]) def test_sensor_data_cropping_with_pml_inside(self): """If pml is inside, no field should be cropped.""" self.mock_proc.returncode = 0 + # Create a mock that tracks calls before being replaced + original_two_d_output = MagicMock() + original_two_d_output.ndim = 2 + original_three_d_output = MagicMock() + original_three_d_output.ndim = 3 + + # Update the mock dict to use these tracking mocks + self.mock_dict["p_final"] = original_two_d_output + self.mock_dict["p_max_all"] = original_three_d_output + # Instantiate the Executor executor = Executor(self.execution_options, self.simulation_options) @@ -208,16 +229,18 @@ def test_sensor_data_cropping_with_pml_inside(self): with patch.object(executor, "parse_executable_output", return_value=self.mock_dict): sensor_data = executor.run_simulation("input.h5", "output.h5", ["options"]) + # Verify that sensor_data is a SimulationResult + self.assertIsInstance(sensor_data, SimulationResult) + # because pml is inside, the output should not be cropped - two_d_output = sensor_data["p_final"] - two_d_output.__getitem__.assert_not_called() - three_d_output = sensor_data["p_max_all"] - three_d_output.__getitem__.assert_not_called() + # The mock objects should not have been called for cropping + original_two_d_output.__getitem__.assert_not_called() + original_three_d_output.__getitem__.assert_not_called() - # check that the other fields are changed - for field in self.mock_dict_values.keys(): + # check that the other fields are unchanged + for field in self.mock_dict.keys(): if field not in ["p_final", "p_max_all"]: - self.assertEqual(sensor_data[field], self.mock_dict_values[field]) + self.assertEqual(sensor_data[field], self.mock_dict[field]) def test_executor_file_not_found_on_non_darwin(self): # Configure the mock path object diff --git a/tests/test_ivp_3D_simulation.py b/tests/test_ivp_3D_simulation.py index 7349c078..740500c1 100644 --- a/tests/test_ivp_3D_simulation.py +++ b/tests/test_ivp_3D_simulation.py @@ -1,11 +1,12 @@ """ - Using An Ultrasound Transducer As A Sensor Example +Using An Ultrasound Transducer As A Sensor Example - This example shows how an ultrasound transducer can be used as a detector - by substituting a transducer object for the normal sensor input - structure. It builds on the Defining An Ultrasound Transducer and - Simulating Ultrasound Beam Patterns examples. +This example shows how an ultrasound transducer can be used as a detector +by substituting a transducer object for the normal sensor input +structure. It builds on the Defining An Ultrasound Transducer and +Simulating Ultrasound Beam Patterns examples. """ + import os from tempfile import gettempdir @@ -69,7 +70,7 @@ def test_ivp_3D_simulation(): input_file_full_path = os.path.join(pathname, input_filename) simulation_options = SimulationOptions( data_cast="single", - cart_interp="nearest", + cartesian_interp="nearest", save_to_disk=True, input_filename=input_filename, save_to_disk_exit=True, diff --git a/tests/test_tvsp_3D_simulation.py b/tests/test_tvsp_3D_simulation.py index f1f8be60..8545a6b7 100644 --- a/tests/test_tvsp_3D_simulation.py +++ b/tests/test_tvsp_3D_simulation.py @@ -1,11 +1,12 @@ """ - Using An Ultrasound Transducer As A Sensor Example +Using An Ultrasound Transducer As A Sensor Example - This example shows how an ultrasound transducer can be used as a detector - by substituting a transducer object for the normal sensor input - structure. It builds on the Defining An Ultrasound Transducer and - Simulating Ultrasound Beam Patterns examples. +This example shows how an ultrasound transducer can be used as a detector +by substituting a transducer object for the normal sensor input +structure. It builds on the Defining An Ultrasound Transducer and +Simulating Ultrasound Beam Patterns examples. """ + import os from tempfile import gettempdir @@ -71,7 +72,7 @@ def test_tvsp_3D_simulation(): # input arguments simulation_options = SimulationOptions( data_cast="single", - cart_interp="nearest", + cartesian_interp="nearest", save_to_disk=True, input_filename=input_filename, save_to_disk_exit=True,