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.
- openstb/simulator/__init__.py +15 -0
- openstb/simulator/__main__.py +7 -0
- openstb/simulator/cli/__init__.py +17 -0
- openstb/simulator/cli/run.py +82 -0
- openstb/simulator/cluster/__init__.py +0 -0
- openstb/simulator/cluster/dask_local.py +108 -0
- openstb/simulator/cluster/dask_mpi.py +115 -0
- openstb/simulator/config_loader/__init__.py +0 -0
- openstb/simulator/config_loader/toml.py +182 -0
- openstb/simulator/distortion/__init__.py +0 -0
- openstb/simulator/distortion/beampattern.py +141 -0
- openstb/simulator/distortion/doppler.py +78 -0
- openstb/simulator/distortion/environmental.py +174 -0
- openstb/simulator/environment/__init__.py +0 -0
- openstb/simulator/environment/invariant.py +42 -0
- openstb/simulator/plugin/__init__.py +0 -0
- openstb/simulator/plugin/abc.py +809 -0
- openstb/simulator/plugin/loader.py +334 -0
- openstb/simulator/plugin/util.py +269 -0
- openstb/simulator/py.typed +0 -0
- openstb/simulator/result_converter/__init__.py +0 -0
- openstb/simulator/result_converter/matlab.py +87 -0
- openstb/simulator/result_converter/numpy.py +69 -0
- openstb/simulator/simulation/__init__.py +0 -0
- openstb/simulator/simulation/points.py +502 -0
- openstb/simulator/system/__init__.py +51 -0
- openstb/simulator/system/ping_times.py +112 -0
- openstb/simulator/system/signal.py +92 -0
- openstb/simulator/system/signal_windows.py +201 -0
- openstb/simulator/system/trajectory.py +128 -0
- openstb/simulator/system/transducer.py +63 -0
- openstb/simulator/target/__init__.py +0 -0
- openstb/simulator/target/points.py +169 -0
- openstb/simulator/travel_time/__init__.py +0 -0
- openstb/simulator/travel_time/iterative.py +146 -0
- openstb/simulator/travel_time/stop_and_hop.py +85 -0
- openstb/simulator/types.py +42 -0
- openstb/simulator/util.py +36 -0
- openstb_simulator-0.5.0.dist-info/METADATA +42 -0
- openstb_simulator-0.5.0.dist-info/RECORD +42 -0
- openstb_simulator-0.5.0.dist-info/WHEEL +4 -0
- 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,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)
|