Skip to content

Commit 480b8bd

Browse files
add crosstalk simulation
#430
1 parent 8122163 commit 480b8bd

8 files changed

Lines changed: 163 additions & 75 deletions

File tree

examples/tutorials/03_Solution_Generation_and_Analysis.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,8 @@
113113
# With the `target`, `transducer`, and `protocol` defined, we can now calculate the `Solution`.
114114
# The `calc_solution` method returns:
115115
# * `solution`: The `Solution` object containing delays, apodizations, voltage, etc.
116-
# * `sim_res`: A `SimResult` object if `simulate=True`.
117-
# * `analysis`: A `SolutionAnalysis` object if `simulate=True` and `scale=False` (or `analysis` if `scale=True`).
116+
# * `sim_res`: An `xa.Dataset` simulation result if `simulate=True`, aggregated over the focal points.
117+
# * `analysis`: A `SolutionAnalysis` object if `simulate=True`.
118118
#
119119
# Setting `simulate=True` will run an acoustic simulation.
120120
# Setting `scale=True` will attempt to scale the output pressure to match `target_pressure` defined in the focal pattern or protocol, and returns a `scaled_analysis`.
@@ -162,7 +162,7 @@
162162
)
163163
solution2.apodizations[0,:128] = 0
164164
params = protocol1.sim_setup.setup_sim_scene(protocol1.seg_method)
165-
simulation_result = solution2.simulate(params=params, use_gpu=True)
165+
simulation_result = solution2.simulate(params=params)
166166

167167
# %%
168168
simulation_result['p_min'].sel(focal_point_index=0).sel(y=0).plot.imshow()

src/openlifu/plan/protocol.py

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222
from openlifu.plan.solution_analysis import SolutionAnalysis, SolutionAnalysisOptions
2323
from openlifu.plan.target_constraints import TargetConstraints
2424
from openlifu.util.annotations import OpenLIFUFieldData
25-
from openlifu.util.checkgpu import gpu_available
2625
from openlifu.util.json import PYFUSEncoder
2726
from openlifu.virtual_fit import VirtualFitOptions
2827
from openlifu.xdc import Transducer
@@ -250,8 +249,8 @@ def calc_solution(
250249
sim_options: sim.SimSetup | None = None,
251250
analysis_options: SolutionAnalysisOptions | None = None,
252251
on_pulse_mismatch: OnPulseMismatchAction = OnPulseMismatchAction.ERROR,
253-
use_gpu: bool | None = None,
254-
voltage: float = 1.0
252+
voltage: float = 1.0,
253+
_force_cpu: bool = False
255254
) -> Tuple[Solution, xa.DataArray, SolutionAnalysis]:
256255
"""Calculate the solution and aggregated k-wave simulation outputs.
257256
@@ -267,8 +266,18 @@ def calc_solution(
267266
The subject scan (Default: None).
268267
It is expected to be in the simulation grid coordinates (lat, ele, ax).
269268
If None, a default simulation grid will be used.
270-
session: db.Session
271-
A session used to define solution_id (Default: None).
269+
params: xa.Dataset | None = None
270+
The simulation parameters to use for the k-wave simulation (Default: None, which uses sim_options.setup_sim_scene on the volume).
271+
If the params are provided, the volume is not required.
272+
Params should include xa.DataArrays for the following:
273+
'sound_speed': speed of sound in the medium (m/s)
274+
'density': density of the medium (kg/m^3)
275+
'attenuation': attenuation coefficient of the medium (dB/cm/MHz)
276+
and can also include (though they aren't used in the simulation)
277+
'specific_heat': specific heat capacity of the medium (J/kg/K)
278+
'thermal_conductivity': thermal conductivity of the medium (W/m/K)
279+
session: Session | None = None
280+
the current session object (used for assigning a matching solution ID only)
272281
simulate: bool
273282
Enable solution simulation (Default: true).
274283
scale: bool
@@ -291,9 +300,6 @@ def calc_solution(
291300
scaled_solution_analysis: SolutionAnalysis
292301
This is the resulting rescaled analysis, if scale is enabled.
293302
"""
294-
if use_gpu is None:
295-
use_gpu = gpu_available()
296-
297303
if sim_options is None:
298304
sim_options = self.sim_setup
299305
if analysis_options is None:
@@ -346,7 +352,7 @@ def calc_solution(
346352
simulation_result = solution.simulate(
347353
params=params,
348354
sim_options=sim_options,
349-
use_gpu=use_gpu)
355+
_force_cpu=_force_cpu)
350356
solution.simulation_result = simulation_result
351357

352358
# optionally scale the solution with simulation result

src/openlifu/plan/solution.py

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -139,22 +139,32 @@ def num_foci(self) -> int:
139139
def simulate(self,
140140
params: xa.Dataset,
141141
sim_options: SimSetup | None = None,
142-
use_gpu: bool | None = None) -> xa.Dataset:
142+
_force_cpu: bool = False) -> xa.Dataset:
143+
"""Run a simulation for this solution and store the result in the `simulation_result` attribute.
143144
144-
if use_gpu is None:
145+
Args:
146+
params: The simulation parameters as an xarray Dataset.
147+
sim_options: Optional simulation setup options.
148+
_force_cpu: Whether to force the simulation to run on the CPU. If True, the GPU will not be used even if available.
149+
150+
Returns:
151+
The simulation result as an xarray Dataset.
152+
"""
153+
154+
if _force_cpu:
155+
use_gpu = False
156+
else:
145157
use_gpu = gpu_available()
146158

147159
if sim_options is None:
148160
sim_options = SimSetup()
149-
# check before if target is within bounds
150161
simulation_outputs_to_stack: List[xa.Dataset] = []
151162
simulation_cycles = np.round(self.pulse.duration * self.pulse.frequency)
152163
for focidx, focus in enumerate(self.foci):
153-
self.logger.info(f"Beamform for focus {focus}...")
154164
delays = self.delays[focidx, :]
155165
apodization = self.apodizations[focidx, :]
156166
simulation_output_xarray = None
157-
self.logger.info(f"Simulate for focus {focus}...")
167+
self.logger.info(f"Running k-Wave simulation for focus {focus}...")
158168
run_simulation_kwargs = {
159169
"arr": self.transducer,
160170
"params": params,
@@ -422,15 +432,14 @@ def get_ita(self, intensity: xa.DataArray | None = None, units: str = "mW/cm^2")
422432
Calculate the intensity-time-area product for a treatment solution.
423433
424434
Args:
425-
output: A struct for simulation results from the treatment.
426435
intensity: xa.DataArray | None
427436
If provided, use this intensity data array instead of the one from the simulation result.
428437
units: str
429438
Target units. Default "mW/cm^2".
430439
431-
432440
Returns:
433-
A Solution instance with the calculated intensity value.
441+
xa.DataArray
442+
The intensity-time-area product as an xarray DataArray.
434443
"""
435444
if intensity is not None:
436445
intensity_scaled = rescale_data_arr(intensity, units)

src/openlifu/sim/kwave_if.py

Lines changed: 62 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,43 @@ def run_simulation(arr: xdc.Transducer,
9595
return_kwave_outputs: bool = False,
9696
return_kwave_inputs: bool = False,
9797
sensor_record: List[str] = ['p_max', 'p_min'],
98-
_source: kSource|None = None,
99-
_sensor: kSensor|None = None
98+
_source = None,
99+
_sensor = None
100100
):
101+
""" Run a k-wave simulation for the given transducer array and parameters.
102+
Args:
103+
arr: The transducer array to simulate.
104+
params: The simulation parameters as an xarray Dataset. Must include 'sound_speed', '
105+
density', and 'attenuation' variables with appropriate units.
106+
delays: Optional array of time delays for each element in the transducer array, in seconds. If None, no delays will be applied.
107+
apod: Optional array of apodization values for each
108+
element in the transducer array. If None, no apodization will be applied.
109+
freq: The frequency of the input signal in Hz. Default is 1 MHz.
110+
cycles: The number of cycles in the input signal. Default is 20.
111+
amplitude: The amplitude of the input signal. Default is 1.
112+
dt: The time step for the simulation in seconds. If 0, it will be automatically calculated based on the CFL condition.
113+
t_end: The total time for the simulation in seconds. If 0, it will be automatically calculated based on the input signal duration and the CFL condition.
114+
cfl: The Courant-Friedrichs-Lewy (CFL) number for the simulation. Default is 0.5.
115+
bli_tolerance: The tolerance for the boundary layer integral method used in k-wave. Default
116+
is 0.05.
117+
upsampling_rate: The upsampling rate for the boundary layer integral method used in k-wave
118+
Default is 5.
119+
gpu: Whether to use GPU for the simulation. Default is True. If False, the simulation will run on the CPU. Note that running on CPU may be very slow for large simulations.
120+
ref_values_only: Whether to use only the reference values for the medium properties (sound speed, density, attenuation) instead of the full spatial maps. Default is False. Setting this to True can significantly speed up the simulation, but will not capture any spatial variations in the medium properties.
121+
return_kwave_outputs: Whether to return the raw outputs from k-wave in addition to the processed xarray Dataset. Default is False.
122+
return_kwave_inputs: Whether to return the inputs to k-wave (kgrid, source, sensor, medium) in addition to the processed xarray Dataset. Default is False.
123+
sensor_record: List of strings specifying which k-wave sensor outputs to record. Can include '
124+
p_max', 'p_min', and 'p'. Default is ['p_max', 'p_min'].
125+
126+
Additional Args:
127+
_source: Optional kSource object to use for the simulation. If None, a source will be created based on the transducer array and input signal.
128+
_sensor: Optional kSensor object to use for the simulation. If None, a sensor
129+
130+
Returns:
131+
An xarray Dataset containing the simulation results, with variables corresponding to the requested sensor outputs and
132+
coordinates corresponding to the spatial dimensions of the simulation. If return_kwave_outputs is True, also returns a dictionary containing the raw outputs from k-wave. If return_kwave_inputs is True, also returns a dictionary containing the inputs to k-wave.
133+
134+
"""
101135
from kwave.kspaceFirstOrder3D import kspaceFirstOrder3D
102136
from kwave.options.simulation_execution_options import SimulationExecutionOptions
103137
from kwave.options.simulation_options import SimulationOptions
@@ -111,10 +145,7 @@ def run_simulation(arr: xdc.Transducer,
111145
raise ValueError("All dimensions must have the same units")
112146
scl = getunitconversion(units[0], 'm')
113147
array_offset =[-float(coord.mean())*scl for coord in params.coords.values()]
114-
karray = get_karray(arr,
115-
translation=array_offset,
116-
bli_tolerance=bli_tolerance,
117-
upsampling_rate=upsampling_rate)
148+
118149
medium = get_medium(params, ref_values_only=ref_values_only)
119150
if _sensor is not None:
120151
sensor = _sensor
@@ -126,7 +157,31 @@ def run_simulation(arr: xdc.Transducer,
126157
source = _source
127158
else:
128159
source_mat = arr.calc_output(input_signal, kgrid.dt, delays, apod)
129-
source = get_source(kgrid, karray, source_mat)
160+
if arr.crosstalk_frac != 0:
161+
# Simulate crosstalk by adding additional elements to the array for each element that
162+
# is within the crosstalk distance, with the signal scaled by the crosstalk fraction.
163+
# This is a simple model of crosstalk and may not capture all the complexities of real
164+
# crosstalk, but it can be useful for testing and simulation purposes.
165+
crosstalk_arr = arr.copy()
166+
crosstalk_mat = source_mat
167+
positions = arr.get_positions(units="m")
168+
for src_idx in range(arr.numelements()):
169+
for dst_idx in range(arr.numelements()):
170+
if src_idx == dst_idx:
171+
continue
172+
src_pos = np.array(positions[src_idx])
173+
dst_pos = np.array(positions[dst_idx])
174+
dist = np.linalg.norm(src_pos - dst_pos)
175+
if dist <= arr.crosstalk_dist:
176+
crosstalk_arr.elements += [arr.elements[dst_idx].copy()]
177+
crosstalk_mat = np.vstack((crosstalk_mat, arr.crosstalk_frac*source_mat[src_idx,:]))
178+
arr = crosstalk_arr
179+
source_mat = crosstalk_mat
180+
karray = get_karray(arr,
181+
translation=array_offset,
182+
bli_tolerance=bli_tolerance,
183+
upsampling_rate=upsampling_rate)
184+
source = get_source(kgrid, karray, source_mat)
130185
logging.info("Running simulation")
131186
simulation_options = SimulationOptions(
132187
pml_auto=True,

src/openlifu/sim/sim_setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ class SimSetup(DictMixin):
4545
c0: Annotated[float, OpenLIFUFieldData("Speed of Sound (m/s)", "Reference speed of sound for converting distance to time")] = 1500.0
4646
"""Reference speed of sound for converting distance to time"""
4747

48-
cfl: Annotated[float, OpenLIFUFieldData("CFL number", "Courant-Friedrichs-Lewy number")] = 0.5
48+
cfl: Annotated[float, OpenLIFUFieldData("CFL number", "Courant-Friedrichs-Lewy number")] = 0.3
4949
"""Courant-Friedrichs-Lewy number"""
5050

5151
options: Annotated[dict[str, str], OpenLIFUFieldData("Simulation options", "Additional simulation options")] = field(default_factory=dict)

src/openlifu/xdc/transducer.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,12 @@ class Transducer:
5858
sensitivity: Annotated[float | None, OpenLIFUFieldData("Sensitivity", "Sensitivity of the element (Pa/V)")] = None
5959
"""Sensitivity of the element (Pa/V)"""
6060

61+
crosstalk_frac: Annotated[float, OpenLIFUFieldData("Crosstalk fraction", "Fraction of the signal that leaks into other elements due to crosstalk")] = 0.0
62+
"""Fraction of the signal that leaks into other elements due to crosstalk"""
63+
64+
crosstalk_dist: Annotated[float, OpenLIFUFieldData("Crosstalk distance", "Distance within which elements experience crosstalk")] = 0.0
65+
"""Distance within which elements experience crosstalk"""
66+
6167
impulse_response: Annotated[np.ndarray | None, OpenLIFUFieldData("Impulse response", "Impulse response of the element")] = None
6268
"""Impulse response of the element, can be a single value or an array of values. If an array, `impulse_dt` must be set to the time step of the impulse response. Is convolved with the input signal."""
6369

tests/test_protocol.py

Lines changed: 1 addition & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,9 @@
33
import logging
44
from pathlib import Path
55

6-
import numpy as np
76
import pytest
8-
import xarray as xa
9-
from pytest_mock import MockerFixture
107

11-
from openlifu import Point, Protocol, Transducer
8+
from openlifu import Protocol, Transducer
129
from openlifu.bf.focal_patterns import Wheel
1310
from openlifu.db import Session
1411
from openlifu.plan.protocol import OnPulseMismatchAction
@@ -109,46 +106,3 @@ def test_fix_pulse_mismatch(
109106
assert example_protocol.sequence.pulse_count == 2*num_foci
110107
elif on_pulse_mismatch is OnPulseMismatchAction.ROUNDDOWN:
111108
assert example_protocol.sequence.pulse_count == num_foci
112-
113-
@pytest.mark.parametrize("use_gpu", [True, False, None])
114-
@pytest.mark.parametrize("gpu_is_available", [True, False])
115-
def test_calc_solution_use_gpu(
116-
mocker:MockerFixture,
117-
example_protocol:Protocol,
118-
example_transducer:Transducer,
119-
use_gpu:bool | None,
120-
gpu_is_available:bool,
121-
):
122-
"""Test that the correct value of use_gpu is passed to the simulation runner"""
123-
example_simulation_output = xa.Dataset(
124-
{
125-
'p_min': xa.DataArray(data=np.empty((3, 2, 3)), dims=["x", "y", "z"], attrs={'units': "Pa"}),
126-
'p_max': xa.DataArray(data=np.empty((3, 2, 3)),dims=["x", "y", "z"],attrs={'units': "Pa"}),
127-
'intensity': xa.DataArray(data=np.empty((3, 2, 3)),dims=["x", "y", "z"],attrs={'units': "W/cm^2"}),
128-
},
129-
coords={
130-
'x': xa.DataArray(dims=["x"], data=np.linspace(0, 1, 3), attrs={'units': "m"}),
131-
'y': xa.DataArray(dims=["y"], data=np.linspace(0, 1, 2), attrs={'units': "m"}),
132-
'z': xa.DataArray(dims=["z"], data=np.linspace(0, 1, 3), attrs={'units': "m"}),
133-
},
134-
)
135-
mocker.patch(
136-
"openlifu.plan.protocol.gpu_available",
137-
return_value = gpu_is_available,
138-
)
139-
run_simulation_mock = mocker.patch(
140-
"openlifu.plan.solution.run_simulation",
141-
return_value = example_simulation_output
142-
)
143-
example_protocol.calc_solution(
144-
target = Point(),
145-
transducer = example_transducer,
146-
simulate = True,
147-
scale = False,
148-
use_gpu=use_gpu,
149-
)
150-
args, kwargs = run_simulation_mock.call_args
151-
if use_gpu is None:
152-
assert kwargs['gpu'] == gpu_is_available
153-
else:
154-
assert kwargs['gpu'] == use_gpu

tests/test_solution.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import pytest
99
import xarray as xa
1010
from helpers import dataclasses_are_equal
11+
from pytest_mock import MockerFixture
1112

1213
from openlifu import Point, Pulse, Sequence, Solution, Transducer
1314
from openlifu.bf.focal_patterns import SinglePoint
@@ -318,3 +319,60 @@ def test_solution_analyze_ratios(example_solution: Solution):
318319
assert analysis_case7.mainlobe_isppa_Wcm2[0] == 0.0
319320
assert analysis_case7.sidelobe_isppa_Wcm2[0] == 0.0
320321
assert np.isnan(analysis_case7.sidelobe_to_mainlobe_intensity_ratio[0]) # 0 / 0 == nan
322+
323+
324+
@pytest.mark.parametrize("force_cpu", [True, False])
325+
@pytest.mark.parametrize("gpu_is_available", [True, False])
326+
def test_solution_simulate_gpu_handling(
327+
mocker: MockerFixture,
328+
example_solution: Solution,
329+
force_cpu: bool,
330+
gpu_is_available: bool,
331+
):
332+
"""Test that the correct GPU flag is passed to run_simulation in solution.simulate"""
333+
example_simulation_output = xa.Dataset(
334+
{
335+
'p_min': xa.DataArray(data=np.empty((3, 2, 3)), dims=["x", "y", "z"], attrs={'units': "Pa"}),
336+
'p_max': xa.DataArray(data=np.empty((3, 2, 3)), dims=["x", "y", "z"], attrs={'units': "Pa"}),
337+
'intensity': xa.DataArray(data=np.empty((3, 2, 3)), dims=["x", "y", "z"], attrs={'units': "W/cm^2"}),
338+
},
339+
coords={
340+
'x': xa.DataArray(dims=["x"], data=np.linspace(0, 1, 3), attrs={'units': "m"}),
341+
'y': xa.DataArray(dims=["y"], data=np.linspace(0, 1, 2), attrs={'units': "m"}),
342+
'z': xa.DataArray(dims=["z"], data=np.linspace(0, 1, 3), attrs={'units': "m"}),
343+
},
344+
)
345+
mocker.patch(
346+
"openlifu.plan.solution.gpu_available",
347+
return_value=gpu_is_available,
348+
)
349+
run_simulation_mock = mocker.patch(
350+
"openlifu.plan.solution.run_simulation",
351+
return_value=example_simulation_output
352+
)
353+
354+
# Create params dataset for simulation
355+
params = xa.Dataset(
356+
{
357+
'density': xa.DataArray(data=np.ones((3, 2, 3)), dims=["x", "y", "z"], attrs={'units': "kg/m^3"}),
358+
'sound_speed': xa.DataArray(data=np.ones((3, 2, 3)) * 1500, dims=["x", "y", "z"], attrs={'units': "m/s"}),
359+
},
360+
coords={
361+
'x': xa.DataArray(dims=["x"], data=np.linspace(0, 1, 3), attrs={'units': "m"}),
362+
'y': xa.DataArray(dims=["y"], data=np.linspace(0, 1, 2), attrs={'units': "m"}),
363+
'z': xa.DataArray(dims=["z"], data=np.linspace(0, 1, 3), attrs={'units': "m"}),
364+
},
365+
)
366+
367+
example_solution.simulate(
368+
params=params,
369+
_force_cpu=force_cpu,
370+
)
371+
372+
args, kwargs = run_simulation_mock.call_args
373+
# If force_cpu is True, GPU should always be False
374+
# Otherwise, GPU should match gpu_available()
375+
if force_cpu:
376+
assert not(kwargs['gpu'])
377+
else:
378+
assert kwargs['gpu'] == gpu_is_available

0 commit comments

Comments
 (0)