From c9502a1268ab0c4a9a3b74d65f70fea44d5df493 Mon Sep 17 00:00:00 2001 From: charles Date: Fri, 28 Jul 2023 09:47:39 -0700 Subject: [PATCH] Time-reverse simulation for phased-array delay design (#105) * Add PointSources that have no direction or aperture * Draft time-reverse simulation to calculate delays * Update comment on point source * Enable math latex rendering with Arithmatex * Update docs/examples/plot_time_reverse.py Co-authored-by: Diogo de Lucena <90583560+d-lucena@users.noreply.github.com> * Fix up documentation - address PR comments * Include target error calculation in plot_time_reverse.py * lint/spellcheck * Consolidate flags for source rendering type into a single Enum * Add Enum to dictionary * Fix spellcheck error / typo * Add thought exercise for time reverse simulation * Rename SourceType to SourceRenderType to be more specific --------- Co-authored-by: Diogo de Lucena <90583560+d-lucena@users.noreply.github.com> --- docs/examples/plot_time_reverse.py | 209 ++++++++++++++++++++++ docs/javascript/mathjax.js | 17 ++ mkdocs.yml | 5 + src/neurotechdevkit/rendering/__init__.py | 4 +- src/neurotechdevkit/rendering/_source.py | 123 +++++++++++-- src/neurotechdevkit/results/_results.py | 20 +-- src/neurotechdevkit/scenarios/_base.py | 10 +- src/neurotechdevkit/sources.py | 88 ++++++++- tests/neurotechdevkit/test_sources.py | 54 ++++++ whitelist.txt | 1 + 10 files changed, 486 insertions(+), 45 deletions(-) create mode 100644 docs/examples/plot_time_reverse.py create mode 100644 docs/javascript/mathjax.js diff --git a/docs/examples/plot_time_reverse.py b/docs/examples/plot_time_reverse.py new file mode 100644 index 00000000..69b0e568 --- /dev/null +++ b/docs/examples/plot_time_reverse.py @@ -0,0 +1,209 @@ +# -*- coding: utf-8 -*- +""" +Time-reverse simulation for phased array +==================================================================== + +The skull adds aberrations to the beam propagation; phased arrays can compensate +for those by having different delays for each element, but estimating these +delays can be challenging. +One method to estimate the delays is a "time reverse" simulation: +https://koreascience.kr/article/JAKO200612242715181.pdf +This notebook demonstrates the "time reverse" method to estimate the delays. The +notebook sets up a scenario with a phased array source and a target and then +runs a simulation with the source and target reversed to calculate the delays. +Finally, it uses the calculated delays to perform a forward-time simulation. + +Note: In this notebook, we refer to the "true" target as the eventual brain +region we would like to stimulate, and the "true" source as the placement of +the ultrasound probes. We refer to the "reversed" or "simulated" target and +point-source as the values defined in our simulation, which are reversed from +the physical setup to help calculate values. +""" + +# %% + +import matplotlib.pyplot as plt +import numpy as np + +import neurotechdevkit as ndk + +# Parameters +SCENARIO_NAME = "scenario-2-2d-v0" +NUM_ELEMENTS = 20 +ELEMENT_WIDTH = 1.2e-3 + + +# %% +# Helper function to make the scenario with a PhasedArraySource +def make_scenario(element_delays=None) -> ndk.scenarios.Scenario: + true_scenario = ndk.make(SCENARIO_NAME) + + # define a phased-array source + default_source = true_scenario.get_default_source() + true_source = ndk.sources.PhasedArraySource2D( + element_delays=element_delays, + position=default_source.position, + direction=default_source.unit_direction, + num_elements=NUM_ELEMENTS, + pitch=default_source.aperture / NUM_ELEMENTS, + element_width=ELEMENT_WIDTH, + num_points=1000, + ) + + true_scenario.add_source(true_source) + return true_scenario + + +# %% +# ## Set up and visualize the forward scenario +true_scenario = make_scenario() +assert isinstance(true_scenario, ndk.scenarios.Scenario2D) +true_scenario.render_layout() + + +# %% +# ## Simulate the time-reverse scenario +# Place a point source at the true target, and simulate a pulse. +# The point source is visualized as a gray dot. + +# Reinitialize the scenario +reversed_scenario = ndk.make(SCENARIO_NAME) +# and reverse the source +point_source = ndk.sources.PointSource2D( + position=true_scenario.target.center, +) +reversed_scenario.add_source(point_source) + +assert isinstance(reversed_scenario, ndk.scenarios.Scenario2D) +reversed_scenario.render_layout() + + +# %% +result = reversed_scenario.simulate_pulse() +assert isinstance(result, ndk.results.PulsedResult2D) +result.render_pulsed_simulation_animation() + + +# %% Calculate the time-reverse delays +# We calculate how long it took for the point-source pulse to reach each of +# the true array elements. Here, we coarsely approximate these delays by +# finding the pressure argmax at each element's nearest-neighbor coordinates. + +# Map array elements onto the nearest pixels in our simulation +def map_coordinates_to_indices(coordinates, origin, dx): + indices = np.round((coordinates - origin) / dx).astype(int) + return indices + + +# Get the pressure time-series of these elements +[true_source] = true_scenario.sources +assert isinstance(true_source, ndk.sources.PhasedArraySource2D) +element_indices = map_coordinates_to_indices( + true_source.element_positions, + reversed_scenario.origin, + reversed_scenario.dx, +) +pressure_at_elements = result.wavefield[element_indices[:, 0], element_indices[:, 1]] + +# Calculate the time of arrival for each element +element_reverse_delays = np.argmax(pressure_at_elements, axis=1) * result.effective_dt +plt.plot(element_reverse_delays, marker="o") +plt.xlabel("element index") +plt.ylabel("delay [s]") + + +# %% +# Visually inspecting the earlier scenario layout, these results seem reasonable. +# The expected delay \(t_d\) is approximately: +# +# $$ +# t_d \approx \frac{||x_{source} - x_{target}||_2}{c_{water}} \approx +# \frac{0.07 \text{ m}}{1500 \text{ m/s}} \approx 47 \mu s +# $$ +# + + +# %% +# ## Use delays in forward-time simulation +# Next, let's validate these delays by using them in a normal forward-time +# simulation. +# We simulate the original scenario, setting the pulse delays as calculated. + +# Elements that took longer to reach should now be pulsed first, +# so we invert the values +element_delays = element_reverse_delays.max() - element_reverse_delays + +true_scenario = make_scenario(element_delays=element_delays) +result = true_scenario.simulate_pulse() +assert isinstance(result, ndk.results.PulsedResult2D) +result.render_pulsed_simulation_animation() + + +# %% +# The pulse should focus on the true target. + + +# %% +# ### Simulate steady-state +# Another way to visualize the simulation is to check that the steady-state +# pressure (within the skull) peaks near the target. + +# Re-initialize scenario to clear previous simulation +true_scenario = make_scenario(element_delays=element_delays) +steady_state_result = true_scenario.simulate_steady_state() +assert isinstance(steady_state_result, ndk.results.SteadyStateResult2D) +steady_state_result.render_steady_state_amplitudes() + + +# %% +# We want to visualize and find the maximum pressure within the brain, so let's +# mask out everything else. +steady_state_pressure = steady_state_result.get_steady_state() +# Only consider the brain region +steady_state_pressure[~true_scenario.get_layer_mask("brain")] = np.nan +steady_state_result.steady_state = steady_state_pressure + +steady_state_result.render_steady_state_amplitudes() + + +# %% +# We can also calculate how far the "time reverse" estimate is from the true +# target. +max_pressure_flat_idx = np.nanargmax(steady_state_pressure) +max_pressure_idx = np.unravel_index(max_pressure_flat_idx, steady_state_pressure.shape) +max_pressure_idx + +grid = steady_state_result.traces.grid.space.grid +focal_point = np.array( + [ + grid[0][max_pressure_idx[0]], + grid[1][max_pressure_idx[1]], + ] +) +# The backend grid is in different coordinates from the scenario grid, so we +# need to shift it. +focal_point += true_scenario.origin + +print("target center:", true_scenario.target.center) +print("beam focal point:", focal_point) +error_distance = np.linalg.norm(true_scenario.target.center - focal_point) +print("error [m]:", error_distance) +print("error [mm]:", error_distance * 1000) + + +# %% +# ## Reasons for target mismatch +# The time-reverse simulation is not an exact solution for the forward-time +# design. Other factors, like the angle of incidence at the boundary of two +# materials, will be different in the time reverse vs forward-time. +# +# ### Exercise +# Do you think the time-reverse simulation will work better or worse for deeper +# targets? How about if the transducer was positioned next to a different part +# of the skull that is flatter? + + +# %% +# ### Acknowledgments +# Thanks to Sergio Jiménez-Gambín and Samuel Blackman for pointing us to the +# "time reverse" simulation method. diff --git a/docs/javascript/mathjax.js b/docs/javascript/mathjax.js new file mode 100644 index 00000000..60eda3e2 --- /dev/null +++ b/docs/javascript/mathjax.js @@ -0,0 +1,17 @@ +window.MathJax = { + tex: { + inlineMath: [["\\(", "\\)"]], + displayMath: [["\\[", "\\]"]], + processEscapes: true, + processEnvironments: true + }, + options: { + ignoreHtmlClass: ".*|", + processHtmlClass: "arithmatex" + } +}; + +document$.subscribe(() => { + MathJax.typesetPromise() +}) + diff --git a/mkdocs.yml b/mkdocs.yml index 9bc0b0cf..3a701814 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -47,6 +47,11 @@ nav: extra_css: - css/mkdocstrings.css +extra_javascript: + - javascripts/mathjax.js + - https://polyfill.io/v3/polyfill.min.js?features=es6 + - https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js + markdown_extensions: - pymdownx.highlight: anchor_linenums: true diff --git a/src/neurotechdevkit/rendering/__init__.py b/src/neurotechdevkit/rendering/__init__.py index 3d98989d..47f65248 100644 --- a/src/neurotechdevkit/rendering/__init__.py +++ b/src/neurotechdevkit/rendering/__init__.py @@ -6,7 +6,7 @@ save_animation, video_only_output, ) -from ._source import source_should_be_flat +from ._source import SourceRenderType from .layers import ( SourceDrawingParams, draw_material_outlines, @@ -41,7 +41,7 @@ "make_animation", "video_only_output", "display_video_file", - "source_should_be_flat", + "SourceRenderType", "configure_matplotlib_for_embedded_animation", "save_animation", ] diff --git a/src/neurotechdevkit/rendering/_source.py b/src/neurotechdevkit/rendering/_source.py index 7be8692d..66416b1c 100644 --- a/src/neurotechdevkit/rendering/_source.py +++ b/src/neurotechdevkit/rendering/_source.py @@ -1,3 +1,4 @@ +import enum import pathlib from typing import NamedTuple @@ -9,7 +10,13 @@ from matplotlib.image import BboxImage from matplotlib.transforms import Bbox, Transform, TransformedBbox -from neurotechdevkit.sources import PhasedArraySource, Source +from neurotechdevkit.sources import ( + FocusedSource2D, + FocusedSource3D, + PhasedArraySource, + PointSource, + Source, +) _COMPONENT_DIR = pathlib.Path(__file__).parent / "components" @@ -27,6 +34,33 @@ } +@enum.unique +class SourceRenderType(enum.Enum): + """Enum for the type of source to render. + + For examples: + https://support.leapmotion.com/hc/en-us/articles/360004369058-The-Science-of-Phased-Arrays + """ + + POINT = enum.auto() + LINEAR = enum.auto() + CONCAVE = enum.auto() + + @classmethod + def from_source(cls, source: Source) -> "SourceRenderType": + """Initialize SourceRenderType from neurotechdevkit.Source instance.""" + if isinstance(source, PointSource): + return cls.POINT + elif source_should_be_flat(source): + return cls.LINEAR + elif isinstance(source, (FocusedSource2D, FocusedSource3D)): + return cls.CONCAVE + else: + raise NotImplementedError( + f"SourceRenderType inference for {type(source)} not implemented" + ) + + class SourceDrawingParams(NamedTuple): """A container for the parameters needed to draw a source. @@ -35,14 +69,25 @@ class SourceDrawingParams(NamedTuple): direction: a 2D vector indicating the direction the source is pointing. aperture: the aperture (in meters) of the source. focal_length: the focal length (in meters) of the source. - source_is_flat: whether the source should be rendered as a flat object. + type: how the source should be rendered. """ position: npt.NDArray[np.float_] direction: npt.NDArray[np.float_] aperture: float focal_length: float - source_is_flat: bool + source_type: SourceRenderType + + @classmethod + def from_source(cls, source: Source) -> "SourceDrawingParams": + """Initialize SourceRenderType from neurotechdevkit.Source instance.""" + return cls( + position=source.position, + direction=source.unit_direction, + aperture=source.aperture, + focal_length=source.focal_length, + source_type=SourceRenderType.from_source(source), + ) def create_source_drawing_artist( @@ -61,19 +106,28 @@ def create_source_drawing_artist( Returns: A matplotlib artist containing the rendered source. """ + if source_params.source_type == SourceRenderType.POINT: + return create_point_source_artist(source_params, transform) + raw_img = _load_most_similar_source_image( - source_params.aperture, source_params.focal_length, source_params.source_is_flat + source_params.aperture, + source_params.focal_length, + source_type=source_params.source_type, ) transformed_img = _translate_and_rotate(raw_img, source_params.direction) # now the center of the img corresponds to the source position, and it is # rotated in the correct direction - if source_params.source_is_flat: + if source_params.source_type == SourceRenderType.LINEAR: data_width = ( source_params.aperture * transformed_img.shape[1] / raw_img.shape[1] ) - else: + elif source_params.source_type == SourceRenderType.CONCAVE: data_width = source_params.focal_length * transformed_img.shape[0] / 360 + else: + raise NotImplementedError( + f"Source type not supported: {source_params.source_type}" + ) hw = data_width / 2 position = np.flip(source_params.position) # into plot data coordinates upper_left = position - hw @@ -89,6 +143,35 @@ def create_source_drawing_artist( return img_box +def create_point_source_artist( + source_params: SourceDrawingParams, transform: Transform +) -> matplotlib.artist.Artist: + """Create a matplotlib artist for a PointSource2D rendered inside a scenario. + + Note that the source coordinates are in scenario coordinates, and not plot + coordinates. + + Args: + source_params: the SourceDrawingParams that describe the source. + transform: A Transform function which maps from plot data coordinates into + display coordinates. + + Returns: + A matplotlib artist containing the rendered source. + """ + marker_size = 30 + marker_style = "o" + artist = plt.scatter( + # plot x/y is flipped + source_params.position[1], + source_params.position[0], + s=marker_size, + marker=marker_style, + c="gray", + ) + return artist + + def create_source_legend_artist( loc: npt.NDArray[np.float_], width: float, transform: Transform ) -> matplotlib.artist.Artist: @@ -108,7 +191,7 @@ def create_source_legend_artist( Returns: A matplotlib artist containing the source icon for the legend. """ - raw_img = _load_most_similar_source_image(0.7, 1.0, False) # 40° + raw_img = _load_most_similar_source_image(0.7, 1.0, SourceRenderType.CONCAVE) # 40° # we don't need to do any rotation here, just crop it appropriately cropped_img = raw_img[260:] @@ -132,7 +215,7 @@ def create_source_legend_artist( def _load_most_similar_source_image( aperture: float, focal_length: float, - source_is_flat: bool, + source_type: SourceRenderType, ) -> npt.NDArray[np.float_]: """Load the source image which best matches the specified aperture and focus. @@ -140,12 +223,12 @@ def _load_most_similar_source_image( aperture: the aperture of the source (in meters). focal_length: the focal length of the source (in meters). For planar sources, this value should equal np.inf. - source_is_flat: A boolean indicating that the source should be represented flat. + source_type: SourceRenderType indicating how the source should be represented. Returns: A numpy array containing the source image data. """ - src_file = _select_image_file(aperture, focal_length, source_is_flat) + src_file = _select_image_file(aperture, focal_length, source_type=source_type) src_img = plt.imread(src_file) if src_file.name == "Angle=180°.png": @@ -179,7 +262,7 @@ def source_should_be_flat(source: Source) -> bool: def _select_image_file( - aperture: float, focal_length: float, source_is_flat: bool + aperture: float, focal_length: float, source_type: SourceRenderType ) -> pathlib.Path: """Select the image file to load based on aperture and focal length. @@ -190,19 +273,25 @@ def _select_image_file( aperture: the aperture of the source (in meters). focal_length: the focal length of the source (in meters). For planar sources, this value should equal np.inf. - source_is_flat: A boolean indicating if a source should be represented flat. + source_type: SourceRenderType indicating how the source should be represented. Returns: The path to the file containing the selected image. """ - if source_is_flat: + if source_type == SourceRenderType.LINEAR: return _COMPONENT_DIR / "Angle=Flat.png" - angle_subtended = 2 * np.arcsin(aperture / (2 * focal_length)) * 180 / np.pi + elif source_type == SourceRenderType.CONCAVE: + angle_subtended = 2 * np.arcsin(aperture / (2 * focal_length)) * 180 / np.pi - options = list(_ANGLE_OPTION_FILENAMES.keys()) - closest_angle = _choose_nearest(angle_subtended, options) - return _COMPONENT_DIR / _ANGLE_OPTION_FILENAMES[closest_angle] + options = list(_ANGLE_OPTION_FILENAMES.keys()) + closest_angle = _choose_nearest(angle_subtended, options) + return _COMPONENT_DIR / _ANGLE_OPTION_FILENAMES[closest_angle] + + else: + raise NotImplementedError( + f"No source image available for source type: {source_type}" + ) def _choose_nearest(desired: float, options: list[int]) -> int: diff --git a/src/neurotechdevkit/results/_results.py b/src/neurotechdevkit/results/_results.py index b0a5b574..6d9920a3 100644 --- a/src/neurotechdevkit/results/_results.py +++ b/src/neurotechdevkit/results/_results.py @@ -275,13 +275,7 @@ def render_steady_state_amplitudes( ) if show_sources: for source in self.scenario.sources: - drawing_params = rendering.SourceDrawingParams( - position=source.position, - direction=source.unit_direction, - aperture=source.aperture, - focal_length=source.focal_length, - source_is_flat=rendering.source_should_be_flat(source), - ) + drawing_params = rendering.SourceDrawingParams.from_source(source) rendering.draw_source(ax, drawing_params) rendering.configure_result_plot( @@ -381,7 +375,7 @@ def render_steady_state_amplitudes( direction=drop_element(source.unit_direction, slice_axis), aperture=source.aperture, focal_length=source.focal_length, - source_is_flat=rendering.source_should_be_flat(source), + source_type=rendering.SourceRenderType.from_source(source), ) rendering.draw_source(ax, drawing_params) @@ -774,13 +768,7 @@ def _build_animation( if show_sources: for source in self.scenario.sources: - drawing_params = rendering.SourceDrawingParams( - position=source.position, - direction=source.unit_direction, - aperture=source.aperture, - focal_length=source.focal_length, - source_is_flat=rendering.source_should_be_flat(source), - ) + drawing_params = rendering.SourceDrawingParams.from_source(source) rendering.draw_source(ax, drawing_params) rendering.configure_result_plot( @@ -1068,7 +1056,7 @@ def _build_animation( direction=drop_element(source.unit_direction, slice_axis), aperture=source.aperture, focal_length=source.focal_length, - source_is_flat=rendering.source_should_be_flat(source), + source_type=rendering.SourceRenderType.from_source(source), ) rendering.draw_source(ax, drawing_params) diff --git a/src/neurotechdevkit/scenarios/_base.py b/src/neurotechdevkit/scenarios/_base.py index e20cfb69..604b2cda 100644 --- a/src/neurotechdevkit/scenarios/_base.py +++ b/src/neurotechdevkit/scenarios/_base.py @@ -915,13 +915,7 @@ def render_layout( if show_sources: self._ensure_source() for source in self.sources: - drawing_params = rendering.SourceDrawingParams( - position=source.position, - direction=source.unit_direction, - aperture=source.aperture, - focal_length=source.focal_length, - source_is_flat=rendering.source_should_be_flat(source), - ) + drawing_params = rendering.SourceDrawingParams.from_source(source) rendering.draw_source(ax, drawing_params) rendering.configure_layout_plot( @@ -1100,7 +1094,7 @@ def render_layout( direction=drop_element(source.unit_direction, slice_axis), aperture=source.aperture, focal_length=source.focal_length, - source_is_flat=rendering.source_should_be_flat(source), + source_type=rendering.SourceRenderType.from_source(source), ) rendering.draw_source(ax, drawing_params) diff --git a/src/neurotechdevkit/sources.py b/src/neurotechdevkit/sources.py index c085c791..af8f7525 100644 --- a/src/neurotechdevkit/sources.py +++ b/src/neurotechdevkit/sources.py @@ -153,6 +153,90 @@ def calculate_waveform_scale(self, dx: float) -> float: pass +class PointSource(Source): + """Theoretical point source. + + Automatically sets `aperture`, `focal_length`, and `direction`, and `num_points`. + """ + + def __init__( + self, + *, + position: npt.NDArray[np.float_], + num_dim: int = 2, + delay: float = 0.0, + ) -> None: + """Initialize a new point source.""" + super().__init__( + position=position, + direction=np.full(shape=num_dim, fill_value=np.nan), + aperture=0.0, + focal_length=0.0, + num_points=1, + delay=delay, + ) + + def _calculate_coordinates(self) -> npt.NDArray[np.float_]: + """Calculate the coordinates of the point source cloud for the 2D source. + + Returns: + An array containing the coordinates (in meters) of the point source. + """ + return np.array([self.position]) + + def calculate_waveform_scale(self, dx: float) -> float: + """Calculate the scale factor to apply to waveforms from point source. + + The scale is equal to the ratio between the density of source points + and the density of grid points (2D: 1 / dx; 3D: 1 / dx**2). + Because the aperture is technically zero, we approximate the source + density as the smallest grid (2D: 1 / dx; 3D: 1 / dx**2). + + Args: + dx: the separation between gridpoints (in meters). Assumed to be the same in + both directions. + Unused. + + Returns: + The scale factor to apply to the waveform. + """ + return 1 + + +class PointSource2D(PointSource): + """A point source in 2D.""" + + def __init__( + self, + *, + position: npt.NDArray[np.float_], + delay: float = 0.0, + ) -> None: + """Initialize a new 2-D point source.""" + super().__init__( + position=position, + num_dim=2, + delay=delay, + ) + + +class PointSource3D(PointSource): + """A point source in 3D.""" + + def __init__( + self, + *, + position: npt.NDArray[np.float_], + delay: float = 0.0, + ) -> None: + """Initialize a new 2-D point source.""" + super().__init__( + position=position, + num_dim=3, + delay=delay, + ) + + class FocusedSource2D(Source): """A focused source in 2D. @@ -393,8 +477,8 @@ def _calculate_rotation_parameters( return axis, theta -class UnfocusedSource(Source): - """A base class for unfocused sources. +class UnfocusedSource(Source, abc.ABC): + """An abstract base class for unfocused sources. Automatically sets `focal_length` to `np.inf` """ diff --git a/tests/neurotechdevkit/test_sources.py b/tests/neurotechdevkit/test_sources.py index 4031a155..1423caca 100644 --- a/tests/neurotechdevkit/test_sources.py +++ b/tests/neurotechdevkit/test_sources.py @@ -12,6 +12,8 @@ PhasedArraySource3D, PlanarSource2D, PlanarSource3D, + PointSource2D, + PointSource3D, _rotate_2d, _rotate_3d, ) @@ -1719,3 +1721,55 @@ def test_waveform_scale_is_correct_not_even_number(self): grid_density = 1 / dx**2 point_density = quotient * num_elements / (length * height) np.testing.assert_allclose(scale, grid_density / point_density) + + +class TestPointSource2D: + def test_init_properties(self): + """Verify that .position and .delay match the value received in the + constructor. + """ + position = np.array([-2.0, 3.0]) + delay = 123 + source = PointSource2D(position=position, delay=delay) + np.testing.assert_allclose(source.position, position) + assert source.delay == delay + + def test_calculate_coordinates(self): + """Verify that the calculated coordinates match the specified position.""" + position = np.array([-2.0, 3.0]) + source = PointSource2D(position=position) + np.testing.assert_allclose( + source._calculate_coordinates(), np.array([position]) + ) + + def test_calculate_waveform_scale(self): + """Verify that calculate_waveform_scale returns 1.""" + dx = 0.01 + source = PointSource2D(position=np.array([-2.0, 3.0])) + assert source.calculate_waveform_scale(dx=dx) == 1 + + +class TestPointSource3D: + def test_init_properties(self): + """Verify that .position and .delay match the value received in the + constructor. + """ + position = np.array([1.0, -2.0, 3.0]) + delay = 123 + source = PointSource3D(position=position, delay=delay) + np.testing.assert_allclose(source.position, position) + assert source.delay == delay + + def test_calculate_coordinates(self): + """Verify that the calculated coordinates match the specified position.""" + position = np.array([1.0, -2.0, 3.0]) + source = PointSource3D(position=position) + np.testing.assert_allclose( + source._calculate_coordinates(), np.array([position]) + ) + + def test_calculate_waveform_scale(self): + """Verify that calculate_waveform_scale returns 1.""" + dx = 0.01 + source = PointSource3D(position=np.array([1.0, -2.0, 3.0])) + assert source.calculate_waveform_scale(dx=dx) == 1 diff --git a/whitelist.txt b/whitelist.txt index 84f3b8f6..e12815ea 100644 --- a/whitelist.txt +++ b/whitelist.txt @@ -25,6 +25,7 @@ downsampling dt dx eg +Enum et ffmpeg fft