openstb-simulator 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. openstb/simulator/__init__.py +15 -0
  2. openstb/simulator/__main__.py +7 -0
  3. openstb/simulator/cli/__init__.py +17 -0
  4. openstb/simulator/cli/run.py +82 -0
  5. openstb/simulator/cluster/__init__.py +0 -0
  6. openstb/simulator/cluster/dask_local.py +108 -0
  7. openstb/simulator/cluster/dask_mpi.py +115 -0
  8. openstb/simulator/config_loader/__init__.py +0 -0
  9. openstb/simulator/config_loader/toml.py +182 -0
  10. openstb/simulator/distortion/__init__.py +0 -0
  11. openstb/simulator/distortion/beampattern.py +141 -0
  12. openstb/simulator/distortion/doppler.py +78 -0
  13. openstb/simulator/distortion/environmental.py +174 -0
  14. openstb/simulator/environment/__init__.py +0 -0
  15. openstb/simulator/environment/invariant.py +42 -0
  16. openstb/simulator/plugin/__init__.py +0 -0
  17. openstb/simulator/plugin/abc.py +809 -0
  18. openstb/simulator/plugin/loader.py +334 -0
  19. openstb/simulator/plugin/util.py +269 -0
  20. openstb/simulator/py.typed +0 -0
  21. openstb/simulator/result_converter/__init__.py +0 -0
  22. openstb/simulator/result_converter/matlab.py +87 -0
  23. openstb/simulator/result_converter/numpy.py +69 -0
  24. openstb/simulator/simulation/__init__.py +0 -0
  25. openstb/simulator/simulation/points.py +502 -0
  26. openstb/simulator/system/__init__.py +51 -0
  27. openstb/simulator/system/ping_times.py +112 -0
  28. openstb/simulator/system/signal.py +92 -0
  29. openstb/simulator/system/signal_windows.py +201 -0
  30. openstb/simulator/system/trajectory.py +128 -0
  31. openstb/simulator/system/transducer.py +63 -0
  32. openstb/simulator/target/__init__.py +0 -0
  33. openstb/simulator/target/points.py +169 -0
  34. openstb/simulator/travel_time/__init__.py +0 -0
  35. openstb/simulator/travel_time/iterative.py +146 -0
  36. openstb/simulator/travel_time/stop_and_hop.py +85 -0
  37. openstb/simulator/types.py +42 -0
  38. openstb/simulator/util.py +36 -0
  39. openstb_simulator-0.5.0.dist-info/METADATA +42 -0
  40. openstb_simulator-0.5.0.dist-info/RECORD +42 -0
  41. openstb_simulator-0.5.0.dist-info/WHEEL +4 -0
  42. openstb_simulator-0.5.0.dist-info/entry_points.txt +58 -0
@@ -0,0 +1,15 @@
1
+ # SPDX-FileCopyrightText: openSTB contributors
2
+ # SPDX-License-Identifier: BSD-2-Clause-Patent
3
+
4
+ import typing
5
+
6
+ # The underlying array type of a quaternionic array is not fixed (it can be different
7
+ # dtypes, or even other array classes like a SymPy array for symbolic quaternion
8
+ # analysis). As such, the types are dynamically created. For static typing, we have a
9
+ # stub (see utils/typing_stubs/quaternionic.pyi) specifying the pieces of the interface
10
+ # we use. This uses a shorthand type QArray; we need to make that available in the
11
+ # runtime interface so normal interpreters can access it.
12
+ if not typing.TYPE_CHECKING: # pragma:no cover
13
+ import quaternionic
14
+
15
+ quaternionic.QArray = quaternionic.array
@@ -0,0 +1,7 @@
1
+ # SPDX-FileCopyrightText: openSTB contributors
2
+ # SPDX-License-Identifier: BSD-2-Clause-Patent
3
+
4
+ from openstb.simulator.cli import openstb_sim
5
+
6
+ if __name__ == "__main__":
7
+ openstb_sim()
@@ -0,0 +1,17 @@
1
+ # SPDX-FileCopyrightText: openSTB contributors
2
+ # SPDX-License-Identifier: BSD-2-Clause-Patent
3
+
4
+ import click
5
+
6
+ from openstb.simulator.cli.run import run
7
+
8
+
9
+ @click.group(
10
+ context_settings=dict(help_option_names=("-h", "--help"), show_default=True)
11
+ )
12
+ def openstb_sim():
13
+ """The openSTB sonar simulator."""
14
+ pass
15
+
16
+
17
+ openstb_sim.add_command(run)
@@ -0,0 +1,82 @@
1
+ # SPDX-FileCopyrightText: openSTB contributors
2
+ # SPDX-License-Identifier: BSD-2-Clause-Patent
3
+
4
+ import click
5
+
6
+ from openstb.simulator.plugin import loader, util
7
+
8
+
9
+ @click.command
10
+ @click.argument("config_source", nargs=1, type=str)
11
+ @click.option(
12
+ "-c",
13
+ "--config-plugin",
14
+ type=str,
15
+ default=None,
16
+ metavar="PLUGIN",
17
+ help=(
18
+ "The name of the configuration loading plugin needed to parse the "
19
+ "configuration source. This can be the name of a registered plugin, a "
20
+ "'ClassName:package.module' reference to a class in an installed module, or a "
21
+ "'ClassName:path/to/file.py' reference to a class in a Python file."
22
+ ),
23
+ )
24
+ @click.option(
25
+ "--dask-worker",
26
+ type=str,
27
+ default=None,
28
+ metavar="PLUGIN",
29
+ help=(
30
+ "For use with Dask cluster plugins which support independently starting the "
31
+ "workers, such as clusters using MPI. This allows the workers to wait for "
32
+ "configuration details from the main process instead of each worker parsing "
33
+ "the configuration. Not all Dask cluster plugins will support this. The value "
34
+ "can be the name of a registered plugin, a 'ClassName:package.module' "
35
+ "reference to a class in an installed module, or a 'ClassName:path/to/file.py' "
36
+ "reference to a class in a Python file."
37
+ ),
38
+ )
39
+ def run(config_plugin, config_source, dask_worker):
40
+ """Run a simulation.
41
+
42
+ The configuration of the simulation is loaded from the source given by
43
+ CONFIG_SOURCE. This may be the path to a configuration file, or another type of
44
+ source handled by a suitable configuration loading plugin.
45
+
46
+ """
47
+ loader_cls = None
48
+
49
+ # Given a Dask cluster plugin which supports independent worker spawning.
50
+ if dask_worker is not None:
51
+ cls = loader.load_plugin_class("openstb.simulator.dask_cluster", dask_worker)
52
+ if not cls.initialise_worker():
53
+ return
54
+
55
+ # Loader plugin specified; get it.
56
+ if config_plugin is not None:
57
+ loader_cls = loader.load_plugin_class(
58
+ "openstb.simulator.config_loader", config_plugin
59
+ )
60
+
61
+ # Not specified; try to guess it.
62
+ else:
63
+ name, loader_cls = util.find_config_loader(config_source)
64
+ if loader_cls is None:
65
+ raise click.ClickException(
66
+ "could not automatically determine the config loader; please specify "
67
+ "the appropriate loader."
68
+ )
69
+
70
+ # Create an instance and get the configuration dictionary.
71
+ config_loader = loader_cls(config_source)
72
+ config = config_loader.load()
73
+
74
+ # Get the simulation plugin.
75
+ simulation = loader.simulation(config.pop("simulation"))
76
+
77
+ # Load all the other plugins, and check it conforms to the configuration required by
78
+ # the simulation plugin.
79
+ util.load_config_plugins(simulation.config_class, config)
80
+
81
+ # And then we can run the simulation.
82
+ simulation.run(config)
File without changes
@@ -0,0 +1,108 @@
1
+ # SPDX-FileCopyrightText: openSTB contributors
2
+ # SPDX-License-Identifier: BSD-2-Clause-Patent
3
+
4
+ from dask.system import CPU_COUNT
5
+ import distributed
6
+ from distributed.system import MEMORY_LIMIT
7
+ import numpy as np
8
+
9
+ from openstb.i18n.support import translations
10
+ from openstb.simulator.plugin.abc import DaskCluster
11
+
12
+ _ = translations.load("openstb.simulator").gettext
13
+
14
+
15
+ class DaskLocalCluster(DaskCluster):
16
+ """A Dask cluster running on the local machine."""
17
+
18
+ def __init__(
19
+ self,
20
+ workers: int | float,
21
+ worker_memory: int | float | None = None,
22
+ total_memory: int | float | None = None,
23
+ security: bool = True,
24
+ dashboard_address: str | None = None,
25
+ ):
26
+ """
27
+ Parameters
28
+ ----------
29
+ workers : int, float
30
+ Number of workers to add to the cluster. If a float, this is interpreted as
31
+ a fraction of the available CPUs. If -1, use all available CPUs. Any other
32
+ value is taken to be the number of CPUs to use.
33
+ worker_memory, total_memory : int, float
34
+ The desired memory limit for the cluster. This can be specified per worker
35
+ or for all workers; only one may be given (and one must be given). A float
36
+ indicates a fraction of the total system memory, and an integer the number
37
+ of bytes. Note that this limit is enforced on a best-effort basis and some
38
+ workers may exceed it.
39
+ security : boolean
40
+ If True, self-signed temporary credentials are used to secure communications
41
+ within the cluster. If False, communications are unencrypted.
42
+ dashboard_address : str, optional
43
+ Address the Dask diagnostic dashboard server will listen on, e.g.,
44
+ "localhost:8787" or "0.0.0.0:8787". If not given, the server will be
45
+ disabled. Note that the logs may still print a dashboard address if
46
+ disabled, but there will be nothing at that address. This is a known bug in
47
+ dask.distributed: https://github.com/dask/distributed/issues/7994
48
+
49
+ """
50
+ if isinstance(workers, float):
51
+ self.workers = int(np.fix(workers * CPU_COUNT))
52
+ elif workers == -1:
53
+ self.workers = CPU_COUNT
54
+ else:
55
+ self.workers = workers
56
+
57
+ # The distributed LocalCluster takes memory per worker as an integer number of
58
+ # bytes. Convert our inputs.
59
+ if worker_memory is not None and total_memory is not None:
60
+ raise ValueError(
61
+ _("only one of worker_memory and total_memory can be given")
62
+ )
63
+ elif worker_memory is not None:
64
+ if isinstance(worker_memory, float):
65
+ worker_memory = int(np.fix(worker_memory * MEMORY_LIMIT))
66
+ self.memory = worker_memory
67
+ elif total_memory is not None:
68
+ if isinstance(total_memory, float):
69
+ total_memory = int(np.fix(total_memory * MEMORY_LIMIT))
70
+ self.memory = total_memory // self.workers
71
+ else:
72
+ raise ValueError(_("worker_memory or total_memory must be given"))
73
+
74
+ self.security = security
75
+ self.dashboard_address = dashboard_address
76
+
77
+ self._cluster: distributed.LocalCluster | None = None
78
+ self._client: distributed.Client | None = None
79
+
80
+ def initialise(self):
81
+ if self._cluster is not None:
82
+ return
83
+
84
+ self._cluster = distributed.LocalCluster(
85
+ n_workers=self.workers,
86
+ processes=True,
87
+ threads_per_worker=1,
88
+ memory_limit=self.memory,
89
+ dashboard_address=self.dashboard_address,
90
+ security=self.security,
91
+ )
92
+
93
+ @property
94
+ def client(self) -> distributed.Client:
95
+ if self._cluster is None:
96
+ raise RuntimeError(_("must initialise the cluster before getting a client"))
97
+
98
+ if self._client is None:
99
+ self._client = self._cluster.get_client()
100
+ return self._client
101
+
102
+ def terminate(self):
103
+ if self._client is not None:
104
+ self._client.shutdown()
105
+ self._client = None
106
+ if self._cluster is not None:
107
+ self._cluster.close()
108
+ self._cluster = None
@@ -0,0 +1,115 @@
1
+ # SPDX-FileCopyrightText: openSTB contributors
2
+ # SPDX-License-Identifier: BSD-2-Clause-Patent
3
+
4
+ import dask_mpi
5
+ import distributed
6
+ from mpi4py import MPI
7
+
8
+ from openstb.i18n.support import translations
9
+ from openstb.simulator.plugin.abc import DaskCluster
10
+
11
+ _ = translations.load("openstb.simulator").gettext
12
+
13
+
14
+ class DaskMPICluster(DaskCluster):
15
+ """A cluster of Dask nodes communicating via MPI.
16
+
17
+ This requires the ``dask_mpi`` package to be installed. This uses the ``mpi4py``
18
+ library to communicate via MPI. The process running with MPI rank 0 is used for the
19
+ Dask scheduler, and the process running with MPI rank 1 is used for the main
20
+ simulation controller. All other processes are used as computation workers.
21
+
22
+ """
23
+
24
+ def __init__(
25
+ self,
26
+ interface: str | None = None,
27
+ dashboard_address: str | None = None,
28
+ separate_workers: bool = True,
29
+ local_directory: str | None = None,
30
+ ):
31
+ """
32
+ Parameters
33
+ ----------
34
+ interface : str
35
+ The network interface to use for communication, e.g., "eth0" or "ib0". If
36
+ not specified, the scheduler will attempt to determine the appropriate
37
+ interface.
38
+ dashboard_address : str, optional
39
+ Address the Dask diagnostic dashboard server will listen on, e.g.,
40
+ "localhost:8787" or "0.0.0.0:8787". If not given, the server will be
41
+ disabled.
42
+ separate_workers : boolean, default True
43
+ If True, the worker processes (all processes with a rank other than 1) will
44
+ use the initialise_workers() method. If False, all processes will read the
45
+ configuration and proceed as normal. Setting this to True is recommended so
46
+ that only the main controller process will have to read and parse the
47
+ configuration.
48
+ local_directory : str, optional
49
+ The path to a local scratch directory for Dask to use. This should be local
50
+ to each node, not on a network drive. If not given, Dask will fall back to
51
+ an internal default path.
52
+
53
+ """
54
+ self.interface = interface
55
+ self.dashboard_address = dashboard_address
56
+ self._initialised = False
57
+ self._client: distributed.Client | None = None
58
+ self.separate_workers = separate_workers
59
+ self.local_directory = local_directory
60
+
61
+ def initialise(self):
62
+ if self._initialised:
63
+ return
64
+
65
+ # Settings for dask_mpi.initialize().
66
+ settings = dict(
67
+ interface=self.interface,
68
+ dashboard=self.dashboard_address is not None,
69
+ dashboard_address=self.dashboard_address or "",
70
+ local_directory=self.local_directory,
71
+ )
72
+
73
+ if self.separate_workers:
74
+ comm = MPI.COMM_WORLD
75
+
76
+ # Avoid a misconfiguration with a clear error message.
77
+ rank = comm.Get_rank()
78
+ if rank != 1:
79
+ raise RuntimeError(
80
+ _(
81
+ "when using separate workers, the simulation controller should "
82
+ "only be run on MPI rank 1"
83
+ )
84
+ )
85
+
86
+ # Broadcast the settings to the scheduler and worker processes. Note that
87
+ # MPI inserts a synchronisation barrier with a broadcast.
88
+ comm.bcast(settings, root=1)
89
+
90
+ dask_mpi.initialize(**settings)
91
+ self._initialised = True
92
+
93
+ @classmethod
94
+ def initialise_worker(cls):
95
+ comm = MPI.COMM_WORLD
96
+
97
+ # Avoid misconfigurations with a clear error message.
98
+ rank = comm.Get_rank()
99
+ if rank == 1:
100
+ return True
101
+
102
+ # The controller will broadcast the settings when available (after the
103
+ # configuration has been loaded and parsed, and the main DaskCluster plugin is
104
+ # initialised).
105
+ settings = comm.bcast(None, root=1)
106
+ dask_mpi.initialize(**settings)
107
+ return False
108
+
109
+ @property
110
+ def client(self) -> distributed.Client:
111
+ if not self._initialised:
112
+ raise RuntimeError(_("must initialise the cluster before getting a client"))
113
+ if self._client is None:
114
+ self._client = distributed.Client()
115
+ return self._client
File without changes
@@ -0,0 +1,182 @@
1
+ # SPDX-FileCopyrightText: openSTB contributors
2
+ # SPDX-License-Identifier: BSD-2-Clause-Patent
3
+
4
+ import os
5
+ from pathlib import Path
6
+ import tomllib
7
+
8
+ from openstb.i18n.support import translations
9
+ from openstb.simulator.plugin.abc import ConfigLoader
10
+ from openstb.simulator.types import PluginSpec
11
+
12
+ _ = translations.load("openstb.simulator").gettext
13
+
14
+
15
+ class TOMLLoader(ConfigLoader):
16
+ """Load simulation configuration from a TOML file.
17
+
18
+ The file may contain an entry ``include giving a list of other filenames to
19
+ parse and merge into its configuration. This include behaviour is nested, i.e., any
20
+ included file may also specify an `include` list. Any relative filenames are
21
+ evaluated from the directory containing the file currently being processed.
22
+
23
+ Included files may not overwrite values that have already been set. An exception is
24
+ made for values from an array of tables; in this case, included tables are appended
25
+ to the end of the current array.
26
+
27
+ """
28
+
29
+ def __init__(self, filename: os.PathLike[str] | str):
30
+ """
31
+ Parameters
32
+ ----------
33
+ filename
34
+ The path to the configuration file.
35
+
36
+ """
37
+ self.filename = Path(filename)
38
+ self._who_defined: dict[str, Path] = {}
39
+
40
+ @classmethod
41
+ def could_handle(cls, source: str) -> bool:
42
+ # Assume any file ending with .toml could be loadable.
43
+ return source.endswith(".toml")
44
+
45
+ def load(self) -> dict:
46
+ self._who_defined = {}
47
+ return self._load_file(self.filename)
48
+
49
+ def _load_file(self, filename: Path, current: dict | None = None) -> dict:
50
+ """Load a file and update the current configuration.
51
+
52
+ Parameters
53
+ ----------
54
+ filename : Path
55
+ The path to the file to load.
56
+ current : dict, optional
57
+ The current configuration to update. If None, a new configuration will be
58
+ started.
59
+
60
+ Returns
61
+ -------
62
+ dict
63
+ The current configuration after the file was loaded. If `current` was given
64
+ to the method, it is both modified in-place and returned.
65
+
66
+ """
67
+ if current is None:
68
+ self._who_defined = {}
69
+ current = {}
70
+
71
+ # Load this file.
72
+ with filename.open("rb") as fp:
73
+ config = tomllib.load(fp)
74
+
75
+ # Extract any includes.
76
+ includes = config.pop("include", [])
77
+ if not isinstance(includes, list):
78
+ raise ValueError(
79
+ _("{filename}: value of include must be a list").format(
80
+ filename=str(filename)
81
+ )
82
+ )
83
+
84
+ # Try to merge our config into the current.
85
+ for entry, values in config.items():
86
+ # We don't expect any other top-level entries. All values should be tables
87
+ # (dicts) or arrays (lists) of tables. Collect the names and parameters into
88
+ # a proper PluginSpec dictionary.
89
+ if isinstance(values, dict):
90
+ values = self._collect_parameters(filename, entry, values)
91
+ elif isinstance(values, list):
92
+ values = [
93
+ self._collect_parameters(filename, f"{entry}[{i}]", value)
94
+ for i, value in enumerate(values)
95
+ ]
96
+ else:
97
+ raise ValueError(
98
+ _("{filename}: unexpected top-level entry {name}").format(
99
+ filename=filename, name=entry
100
+ )
101
+ )
102
+
103
+ # New key => easy.
104
+ if entry not in current:
105
+ current[entry] = values
106
+ continue
107
+
108
+ # Existing key is a list. We allow another list or a single table.
109
+ if isinstance(current[entry], list):
110
+ if isinstance(values, list):
111
+ current[entry].extend(values)
112
+ elif isinstance(values, dict):
113
+ current[entry].append(values)
114
+ else:
115
+ raise ValueError(
116
+ _("{filename}: {name} must be a table").format(
117
+ filename=filename, name=entry
118
+ )
119
+ )
120
+ continue
121
+
122
+ # Don't allow overwriting.
123
+ msg = _("{filename} tried to overwrite value of {table} set by {original}")
124
+ raise ValueError(
125
+ msg.format(
126
+ filename=filename, original=self._who_defined[entry], table=entry
127
+ )
128
+ )
129
+
130
+ # Update which tables came from which files. This will overwrite the source of
131
+ # lists, but we don't need those.
132
+ for entry in config.keys():
133
+ self._who_defined[entry] = filename
134
+
135
+ # Process any includes.
136
+ for include in includes:
137
+ includefn = Path(include)
138
+ if not includefn.is_absolute():
139
+ includefn = filename.parent / includefn
140
+ self._load_file(includefn, current)
141
+
142
+ return current
143
+
144
+ def _collect_parameters(self, filename: Path, entry: str, spec: dict) -> PluginSpec:
145
+ """Collect names and parameters into a PluginSpec dictionary.
146
+
147
+ Parameters
148
+ ----------
149
+ filename : Path
150
+ Filename the entry was loaded from. Used for error reporting and to set the
151
+ source of the PluginSpec.
152
+ entry : str
153
+ Name of the table. Used for error reporting.
154
+ spec : dict
155
+ The values of the table loaded from the file.
156
+
157
+ Returns
158
+ -------
159
+ dict
160
+ A PluginSpec dictionary. The ``name`` entry from ``spec`` will be copied,
161
+ and all other items will be placed into a dictionary under the
162
+ ``parameters`` key.
163
+
164
+ """
165
+ name = spec.pop("name", None)
166
+ if name is None:
167
+ raise ValueError(
168
+ _("{filename}: no plugin name given for entry {name}").format(
169
+ filename=filename, name=entry
170
+ )
171
+ )
172
+
173
+ # Handle nested plugin specs (e.g., transducer beampatterns, signal windows).
174
+ for k in spec.keys():
175
+ if isinstance(spec[k], dict):
176
+ spec[k] = self._collect_parameters(filename, f"{entry}.{k}", spec[k])
177
+
178
+ return {
179
+ "name": name,
180
+ "parameters": spec,
181
+ "spec_source": filename,
182
+ }
File without changes
@@ -0,0 +1,141 @@
1
+ # SPDX-FileCopyrightText: openSTB contributors
2
+ # SPDX-License-Identifier: BSD-2-Clause-Patent
3
+
4
+ import numpy as np
5
+ from numpy.typing import ArrayLike
6
+
7
+ from openstb.i18n.support import translations
8
+ from openstb.simulator.plugin.abc import Distortion, Environment, TravelTimeResult
9
+ from openstb.simulator.util import rotate_elementwise
10
+
11
+ _ = translations.load("openstb.simulator").gettext
12
+
13
+
14
+ class RectangularBeampattern(Distortion):
15
+ """Scaling due to the beampattern of an ideal rectangular aperture."""
16
+
17
+ def __init__(
18
+ self,
19
+ width: float,
20
+ height: float,
21
+ transmit: bool,
22
+ receive: bool,
23
+ frequency: str,
24
+ horizontal: bool = True,
25
+ vertical: bool = True,
26
+ ):
27
+ """
28
+ Parameters
29
+ ----------
30
+ width, height : float
31
+ The size of the aperture in metres.
32
+ transmit, receive: Boolean
33
+ Whether to apply this for the transmited and/or received signal. At least
34
+ one of these must be True.
35
+ frequency : {"min", "centre", "max", "all}
36
+ Which frequency or frequencies to calculate the attenuation at. If "min" or
37
+ "max", use the minimum or maximum frequency in the signal, respectively.
38
+ When "centre", use the centre frequency, i.e., (min + max) / 2. If "all",
39
+ calculate the attenuation at each frequency being sampled in the simulation.
40
+ horizontal, vertical : Boolean, default True
41
+ Whether to include the horizontal and/or vertical components of the
42
+ beampattern in the scaling. At least one of these must be true.
43
+
44
+
45
+ """
46
+ # Check the size.
47
+ if not width > 0:
48
+ raise ValueError(_("width of aperture must be positive"))
49
+ if not height > 0:
50
+ raise ValueError(_("height of aperture must be positive"))
51
+ self.width = width
52
+ self.height = height
53
+
54
+ # Check when to apply.
55
+ self.transmit = transmit
56
+ self.receive = receive
57
+ if not self.transmit and not self.receive:
58
+ raise ValueError(_("at least one of transmit and receive must be true"))
59
+
60
+ # Which frequency to evaluate at.
61
+ if frequency == "min":
62
+ self._freqmode = 0
63
+ elif frequency == "max":
64
+ self._freqmode = 1
65
+ elif frequency == "centre":
66
+ self._freqmode = 2
67
+ elif frequency == "all":
68
+ self._freqmode = 3
69
+ else:
70
+ raise ValueError(
71
+ _("unknown value for frequency: {value}").format(value=frequency)
72
+ )
73
+
74
+ # Components to include.
75
+ self.horizontal = horizontal
76
+ self.vertical = vertical
77
+ if not self.horizontal and not self.vertical:
78
+ raise ValueError(_("at least one of horizontal and vertical must be true"))
79
+
80
+ def apply(
81
+ self,
82
+ ping_time: float,
83
+ f: ArrayLike,
84
+ S: ArrayLike,
85
+ baseband_frequency: float,
86
+ environment: Environment,
87
+ signal_frequency_bounds: tuple[float, float],
88
+ tt_result: TravelTimeResult,
89
+ ) -> np.ndarray:
90
+ # Use the sound speed at transmit.
91
+ sound_speed = environment.sound_speed(ping_time, tt_result.tx_position)
92
+ sound_speed = np.array(sound_speed).reshape(1, 1, 1)
93
+
94
+ # Generate a wavelength array depending on the frequency mode.
95
+ if self._freqmode == 0:
96
+ # Minimum signal frequency.
97
+ wl = sound_speed / signal_frequency_bounds[0]
98
+ elif self._freqmode == 1:
99
+ # Maximum signal frequency:
100
+ wl = sound_speed / signal_frequency_bounds[1]
101
+ elif self._freqmode == 2:
102
+ # Centre signal frequency
103
+ wl = sound_speed / (np.sum(signal_frequency_bounds) / 2)
104
+ else:
105
+ # All frequencies being sampled.
106
+ wl = sound_speed / np.array(f)[np.newaxis, :, np.newaxis]
107
+
108
+ # In the following, we rotate the vectors back into the transducer coordinate
109
+ # system (so x is the transducer normal). In the case of the receiver, we also
110
+ # negate the components: the vector points into the transducer in this case, and
111
+ # we want it to point away from it.
112
+
113
+ distorted = np.array(S)
114
+
115
+ # Evaluate for the transmitter.
116
+ if self.transmit:
117
+ tx_vector = rotate_elementwise(
118
+ ~tt_result.tx_orientation,
119
+ tt_result.tx_vector[np.newaxis, np.newaxis, :],
120
+ )
121
+ distorted = distorted * self._eval(wl, tx_vector)
122
+
123
+ # And for the receivers.
124
+ if self.receive:
125
+ rx_vector = rotate_elementwise(
126
+ ~tt_result.rx_orientation, -tt_result.rx_vector[:, np.newaxis, ...]
127
+ )
128
+ distorted = distorted * self._eval(wl, rx_vector)
129
+
130
+ return distorted
131
+
132
+ def _eval(self, wavelength: np.ndarray, direction: np.ndarray) -> np.ndarray:
133
+ if self.horizontal and self.vertical:
134
+ return np.sinc(self.width * direction[..., 1] / wavelength) * np.sinc(
135
+ self.height * direction[..., 2] / wavelength
136
+ )
137
+
138
+ if self.horizontal:
139
+ return np.sinc(self.width * direction[..., 1] / wavelength)
140
+
141
+ return np.sinc(self.height * direction[..., 2] / wavelength)