Coverage for python/aubellhop/environment.py: 98%
372 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-19 06:49 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-19 06:49 +0000
1"""Environment configuration for aubellhop.
3This module provides dataclass-based environment configuration with automatic validation,
4replacing manual option checking with field validators.
5"""
7from __future__ import annotations
9from collections.abc import MutableMapping
10from dataclasses import dataclass, fields
11from typing import Any, Iterator, Self, Callable
13from pprint import pformat
14import warnings
15from itertools import product
17import numpy as np
18from numpy.typing import NDArray
19import pandas as pd
21from .constants import BHStrings, FlagMaps, EnvDefaults, MiscDefaults
24@dataclass
25class Environment(MutableMapping[str, Any]):
26 """Dataclass for underwater acoustic environment configuration.
28 This class provides automatic validation of environment parameters,
29 eliminating the need for manual checking of option validity.
31 These entries are either intended to be set or edited by the user, or with `_` prefix are
32 internal state read from a .env file or inferred by other data. Some others are ignored.
34 Parameters
35 ----------
36 **kv : dict
37 Keyword arguments for environment configuration.
39 Returns
40 -------
41 env : dict
42 A new underwater environment dictionary.
44 Raises
45 ------
46 ValueError
47 If any parameter value is invalid according to BELLHOP constraints.
49 Example
50 -------
52 To see all the parameters available and their default values:
54 >>> import aubellhop as bh
55 >>> env = bh.Environment()
56 >>> print(env)
58 The environment parameters may be changed by passing keyword arguments
59 or modified later using dictionary notation:
61 >>> import aubellhop as bh
62 >>> env = bh.Environment(bottom_depth=40, soundspeed=1540)
63 >>> print(env)
64 >>> env.bottom_depth = 25
65 >>> env.bottom_soundspeed = 1800
66 >>> print(env)
68 The default environment has a constant sound speed.
69 A depth dependent sound speed profile be provided as a Nx2 array of (depth, sound speed):
71 >>> import aubellhop as bh
72 >>> env = bh.Environment(bottom_depth=20,
73 >>> soundspeed=[[0,1540], [5,1535], [10,1535], [20,1530]])
75 A range-and-depth dependent sound speed profile can be provided as a Pandas frame:
77 >>> import aubellhop as bh
78 >>> import pandas as pd
79 >>> ssp2 = pd.DataFrame({
80 >>> 0: [1540, 1530, 1532, 1533], # profile at 0 m range
81 >>> 100: [1540, 1535, 1530, 1533], # profile at 100 m range
82 >>> 200: [1530, 1520, 1522, 1525] }, # profile at 200 m range
83 >>> index=[0, 10, 20, 30]) # depths of the profile entries in m
84 >>> env = bh.Environment(bottom_depth=20, soundspeed=ssp2)
86 The default environment has a constant water depth. A range dependent bathymetry
87 can be provided as a Nx2 array of (range, water depth):
89 >>> import aubellhop as bh
90 >>> env = bh.Environment(bottom_depth=[[0,20], [300,10], [500,18], [1000,15]])
91 """
93 # Basic environment properties
94 name: str = 'bellhop/python default'
95 _from_file: str | None = None
96 dimension: str = EnvDefaults.dimension
97 _dimension: int = EnvDefaults._dimension
98 frequency: float = EnvDefaults.frequency
99 _num_media: int = 1 # must always = 1 in bellhop
101 # Sound speed parameters
102 soundspeed: float | Any = MiscDefaults.sound_speed # m/s
103 soundspeed_interp: str = EnvDefaults.soundspeed_interp
105 # Depth parameters
106 _mesh_npts: int = 0 # ignored by bellhop
107 _depth_sigma: float = 0.0 # ignored by bellhop
108 _depth_max: float | None = None # m
109 _range_max: float | None = None # m -- not used in the environment file
111 # Flags to read/write from separate files
112 _bathymetry: str = BHStrings.flat # set to "from-file" if multiple bottom depths
113 _altimetry: str = BHStrings.flat # set to "from-file" if multiple surface heights
114 _sbp_file: str = BHStrings.default # set to "from-file" if source_directionality defined
116 # Bottom parameters
117 bottom_depth: float | Any = 25.0 # m
118 bottom_interp: str = EnvDefaults.bottom_interp
119 _bottom_depth: float | None = None # m
120 bottom_soundspeed: float = MiscDefaults.sound_speed # m/s
121 _bottom_soundspeed_shear: float = 0.0 # m/s (ignored)
122 bottom_density: float = MiscDefaults.density # kg/m^3
123 bottom_attenuation: float | None = None # dB/wavelength
124 _bottom_attenuation_shear: float | None = None # dB/wavelength (ignored)
125 bottom_roughness: float = 0.0 # m (rms)
126 bottom_beta: float | None = None
127 bottom_transition_freq: float | None = None # Hz
128 bottom_boundary_condition: str = BHStrings.acousto_elastic
129 bottom_reflection_coefficient: Any | None = None
130 bottom_grain_size: float | None = None
132 # Surface parameters
133 surface_depth: Any | None = None # surface profile
134 surface_interp: str = EnvDefaults.surface_interp # curvilinear/linear
135 surface_boundary_condition: str = BHStrings.vacuum
136 surface_reflection_coefficient: Any | None = None
137 surface_soundspeed: float = MiscDefaults.sound_speed # m/s
138 _surface_soundspeed_shear: float = 0.0 # m/s (ignored)
139 surface_density: float = MiscDefaults.density # kg/m^3
140 surface_attenuation: float | None = None # dB/wavelength
141 _surface_attenuation_shear: float | None = None # dB/wavelength (ignored)
142 _surface_min: float | None = None
143 surface_min: float | None = None
145 # Source parameters
146 source_type: str = BHStrings.default
147 source_range: float | Any = 0.0
148 source_cross_range: float | Any = 0.0
149 source_depth: float | Any = 5.0 # m - Any allows for np.ndarray
150 source_ndepth: int | None = None
151 source_nrange: int | None = None
152 source_ncrossrange: int | None = None
153 source_directionality: Any | None = None # [(deg, dB)...]
154 _source_num: int = 0
156 # Receiver parameters
157 receiver_depth: float | Any = 10.0 # m - Any allows for np.ndarray
158 receiver_range: float | Any = 1000.0 # m - Any allows for np.ndarray
159 receiver_bearing: float | Any = 0.0 # deg - Any allows for np.ndarray
160 receiver_ndepth: int | None = None
161 receiver_nrange: int | None = None
162 receiver_nbearing: int | None = None
163 _receiver_num: int = 0
165 # Beam settings
166 beam_type: str = BHStrings.default
167 beam_angle_min: float | None = None # deg
168 beam_angle_max: float | None = None # deg
169 beam_bearing_min: float | None = None # deg
170 beam_bearing_max: float | None = None # deg
171 beam_num: int = 0 # (0 = auto)
172 beam_bearing_num: int = 0
173 single_beam_index: int | None = None
174 _single_beam: str = BHStrings.default # value inferred from `single_beam_index`
176 # Cerveny Gaussian Beams
177 beam_width_type: str | None = None
178 beam_reflection_curvature_change: str | None = None
179 beam_reflection_shift: str | None = None
180 beam_epsilon_multipler: float | None = None
181 beam_range_loop: float | None = None # km in env file
182 beam_images_num: int | None = None
183 beam_window: int | None = None
184 beam_component: str | None = None
186 # Simulation extent
187 simulation_depth: float | None = None
188 simulation_range: float | None = None
189 simulation_cross_range: float | None = None
190 simulation_depth_scale: float | None = None
191 simulation_range_scale: float | None = None
192 simulation_cross_range_scale: float | None = None
193 simulation_cross_range_min: float | None = None
195 # Solution parameters
196 step_size: float | None = 0.0 # (0 = auto)
197 grid_type: str = BHStrings.default
198 task: str | None = None
199 interference_mode: str | None = None # subset of `task` for providing TL interface
201 # Attenuation parameters
202 volume_attenuation: str = EnvDefaults.volume_attenuation
203 attenuation_units: str = EnvDefaults.attenuation_units
204 biological_layer_parameters: Any | None = None
206 # Francois-Garrison volume attenuation parameters (has setter `.set_fg_attenuation(...)`)
207 _fg_salinity: float | None = None
208 _fg_temperature: float | None = None
209 _fg_pH: float | None = None
210 _fg_depth: float | None = None
212 comment_pad: int = EnvDefaults.comment_pad
214 ############# CLASS METHODS ################
216 @classmethod
217 def from_file(cls, fname: str) -> "Environment":
218 """Create an Environment from an .env file."""
219 from aubellhop.readers import EnvironmentReader
220 env = EnvironmentReader(cls(), fname).read()
221 env._from_file = fname
222 return env
225 @classmethod
226 def from_dict(cls, data: dict[str, Any]) -> "Environment":
227 """Create Environment from dictionary.
229 Unlike `Environment(**data)`, unknown fields are ignored (with a warning message)."""
230 valid_fields = {f.name for f in fields(cls)}
231 invalid = set(data.keys()) - valid_fields
232 if invalid:
233 warnings.warn(f"{cls.__name__}.from_dict: ignoring unknown fields: {invalid}")
234 filtered_data = {k: v for k, v in data.items() if k in valid_fields}
235 return cls(**filtered_data)
237 ############# WRITING ################
239 def to_file(self, fname: str, task: str | None = None) -> str:
240 """Write a complete .env file for specifying a Bellhop simulation.
242 This is the user-facing file writer. It infers the file basename and
243 resolves the task from the environment unless overridden.
245 Parameters
246 ----------
247 fname : str
248 Filename or filename base for the .env file. If no extension is
249 provided, `.env` is appended.
250 task : str, optional
251 Task string which defines the computation to run (e.g. "rays",
252 "eigenrays", "arrivals", "coherent", "incoherent", "semicoherent").
253 If not provided, this is inferred from `env.task` or
254 `env.interference_mode`.
256 Returns
257 -------
258 str
259 The filename base (no extension) of the written file.
260 """
261 from pathlib import Path
262 from aubellhop.constants import FileExt, FlagMaps, EnvDefaults
263 from aubellhop.writers import EnvironmentWriter
265 path = Path(fname)
266 if path.suffix == "":
267 path = path.with_suffix(FileExt.env)
269 # Resolve task
270 task_val = task or self.get("task") or self.get("interference_mode") or EnvDefaults.interference_mode
271 if task_val is None:
272 raise ValueError("Task must be specified via argument or env.task/interference_mode")
274 if isinstance(task_val, str):
275 task_val = BHStrings(task_val) # avoid strict type issue
277 taskcode = FlagMaps.task_rev[task_val]
278 fname_base = str(path.with_suffix(""))
280 path.parent.mkdir(parents=True, exist_ok=True)
281 with open(path, "w") as fh:
282 EnvironmentWriter(self, fh, fname_base, taskcode).write()
284 return fname_base
286 ############# SMALL METHODS ################
288 def reset(self) -> Self:
289 """Delete values for all user-facing parameters."""
290 for k in self.keys():
291 if not k.startswith("_"):
292 self[k] = None
293 return self
295 def defaults(self) -> Self:
296 """Applies default values if not already set."""
297 for f in fields(EnvDefaults):
298 if getattr(self, f.name) is None:
299 setattr(self, f.name, getattr(EnvDefaults(), f.name))
300 return self
302 def to_dict(self) -> dict[str,Any]:
303 """Return a dictionary representation of the environment."""
304 from dataclasses import asdict
305 return asdict(self)
307 def copy(self) -> "Environment":
308 """Return a shallow copy of the environment."""
309 # Copy all fields
310 data = {f.name: getattr(self, f.name) for f in fields(self)}
311 # Return a new instance
312 new_env = type(self)(**data)
313 return new_env
315 def unwrap(self, *keys: str) -> list[Environment]:
316 """Return a list of Environment copies expanded over the given keys.
318 If multiple keys are provided, all combinations are produced.
319 Each unwrapped Environment gets a unique `.name` derived from the
320 parent name and the expanded field values.
321 """
323 # Ensure keys are valid
324 for k in keys:
325 if k not in self:
326 raise KeyError(f"Environment has no field '{k}'")
328 # Prepare value lists (convert scalars → singletons)
329 values: list[Any] = []
330 for k in keys:
331 v = self[k]
332 if isinstance(v, (list, tuple, np.ndarray)):
333 values.append(v)
334 else:
335 values.append([v])
337 combos = product(*values)
338 envs = []
340 base_name = str(self.get("name", "env"))
342 for combo in combos:
343 env_i = self.copy()
344 name_parts = [base_name]
345 for k, v in zip(keys, combo):
346 env_i[k] = v
347 # Replace disallowed chars and truncate floats nicely
348 if isinstance(v, float):
349 v_str = f"{v:g}"
350 else:
351 v_str = str(v)
352 name_parts.append(f"{k}{v_str}")
353 env_i["name"] = "-".join(name_parts)
354 envs.append(env_i)
356 return envs
358 ############## SETTERS ###############
360 def set_fg_attenuation(self,
361 salinity: float,
362 temperature: float,
363 pH: float,
364 depth: float
365 ) -> Self:
366 """Interface to set Francois-Garrison volume attenuation parameters."""
367 self.volume_attenuation = BHStrings.francois_garrison
368 self._fg_salinity = salinity
369 self._fg_temperature = temperature
370 self._fg_pH = pH
371 self._fg_depth = depth
372 return self
374 ############## CHECKING ###############
376 def check(self) -> Self:
377 """Finalise environment parameters and perform assertion checks."""
378 self._finalise()
379 try:
380 self._check_env_header()
381 self._check_env_surface()
382 self._check_env_depth()
383 self._check_env_ssp()
384 self._check_env_source()
385 self._check_env_beam()
386 return self
387 except AssertionError as e:
388 raise ValueError(f"Env check error: {str(e)}") from None
390 def _finalise(self) -> Self:
391 """Reviews the data within an environment and updates settings for consistency.
393 This function is run as the first step of `.check()`.
394 """
396 if self.dimension == BHStrings.two_d:
397 self._dimension = 2
398 elif self.dimension == BHStrings.two_half_d or self.dimension == BHStrings.three_d:
399 self._dimension = 3
401 if np.size(self['bottom_depth']) > 1:
402 self["_bathymetry"] = BHStrings.from_file
403 if self["surface_depth"] is not None and np.size(self["surface_depth"]) > 1:
404 self["_altimetry"] = BHStrings.from_file
405 if self["bottom_reflection_coefficient"] is not None:
406 self["bottom_boundary_condition"] = BHStrings.from_file
407 if self["surface_reflection_coefficient"] is not None:
408 self["surface_boundary_condition"] = BHStrings.from_file
410 self.surface_depth = self.surface_depth if self.surface_depth is not None else EnvDefaults.surface_depth
411 def _extremum(
412 expl: float | None,
413 vec: float | NDArray[np.float64],
414 fn: Callable[[NDArray[np.float64]], float]
415 ) -> float:
416 if expl is not None:
417 return float(expl)
418 if np.size(vec) == 1:
419 return float(vec)
420 if isinstance(vec, np.ndarray):
421 arr = np.asarray(vec)
422 if arr.ndim == 1:
423 return float(fn(arr))
424 if arr.ndim == 2:
425 return float(fn(arr[:, 1]))
426 raise TypeError(f"Unexpected type for _extremum argument: {type(vec)}")
428 self._depth_max = _extremum(self._depth_max, self['bottom_depth'], np.max)
429 self._surface_min = _extremum(self.surface_min, self['surface_depth'], np.min)
431 if not isinstance(self['soundspeed'], pd.DataFrame):
432 if np.size(self['soundspeed']) == 1:
433 speed = np.array([float(self["soundspeed"]), float(self["soundspeed"])])
434 depth = np.array([self._surface_min, self._depth_max])
435 self["soundspeed"] = pd.DataFrame(speed, columns=pd.Index(["speed"]), index=depth)
436 self["soundspeed"].index.name = "depth"
437 elif self['soundspeed'].shape[0] == 1 and self['soundspeed'].shape[1] == 2:
438 # only one depth/soundspeed pair specified -- does this happen??
439 speed = [float(self["soundspeed"][0,1]), float(self["soundspeed"][0,1])]
440 d1 = float(min([self._surface_min, self["soundspeed"][0,0]]))
441 d2 = float(max([self["soundspeed"][0,0], self._depth_max]))
442 self["soundspeed"] = pd.DataFrame(speed, columns=pd.Index(["speed"]), index=pd.Index([d1, d2]))
443 self["soundspeed"].index.name = "depth"
444 elif self['soundspeed'].ndim == 2 and self['soundspeed'].shape[1] == 2:
445 depth = self['soundspeed'][:,0]
446 speed = self['soundspeed'][:,1]
447 self["soundspeed"] = pd.DataFrame(speed, columns=pd.Index(["speed"]), index=depth)
448 self["soundspeed"].index.name = "depth"
449 else:
450 raise TypeError("For an NDArray, soundspeed must be defined as a Nx2 array of [depth,soundspeed]. Use a DataFrame with 'depth' index for a 2D soundspeed profile.")
452 if "depth" in self["soundspeed"].columns:
453 self["soundspeed"] = self["soundspeed"].set_index("depth")
455 if len(self['soundspeed'].columns) > 1:
456 self['soundspeed_interp'] == BHStrings.quadrilateral
458 self.bottom_attenuation = self._float_or_default('bottom_attenuation', EnvDefaults.bottom_attenuation)
460 self.source_ndepth = self._reconcile_count(self.source_ndepth, self.source_depth, 'source_ndepth', 'source_depth')
461 self.source_nrange = self._reconcile_count(self.source_nrange, self.source_range, 'source_nrange', 'source_range')
462 self.source_ncrossrange = self._reconcile_count(self.source_ncrossrange, self.source_cross_range,
463 'source_ncrossrange', 'source_cross_range')
464 self._source_num = self.source_ndepth * self.source_nrange * self.source_ncrossrange
466 self.receiver_ndepth = self._reconcile_count(self.receiver_ndepth, self.receiver_depth,
467 'receiver_ndepth', 'receiver_depth')
468 self.receiver_nrange = self._reconcile_count(self.receiver_nrange, self.receiver_range,
469 'receiver_nrange', 'receiver_range')
470 self.receiver_nbearing = self._reconcile_count(self.receiver_nbearing, self.receiver_bearing,
471 'receiver_nbearing', 'receiver_bearing')
472 self._receiver_num = self.receiver_ndepth * self.receiver_nrange * self.receiver_nbearing
474 # Beam angle ranges default to half-space if source is left-most, otherwise full-space:
475 if self['beam_angle_min'] is None:
476 if np.min(self['receiver_range']) < 0:
477 self['beam_angle_min'] = - MiscDefaults.beam_angle_fullspace
478 else:
479 self['beam_angle_min'] = - MiscDefaults.beam_angle_halfspace
480 if self['beam_angle_max'] is None:
481 if np.min(self['receiver_range']) < 0:
482 self['beam_angle_max'] = MiscDefaults.beam_angle_fullspace
483 else:
484 self['beam_angle_max'] = MiscDefaults.beam_angle_halfspace
486 # Identical logic for bearing angles
487 if np.min(self['receiver_range']) < 0:
488 angle_min = -MiscDefaults.beam_bearing_fullspace
489 angle_max = +MiscDefaults.beam_bearing_fullspace
490 else:
491 angle_min = -MiscDefaults.beam_bearing_halfspace
492 angle_max = +MiscDefaults.beam_bearing_halfspace
494 self.beam_bearing_min = self._float_or_default('beam_bearing_min', angle_min)
495 self.beam_bearing_max = self._float_or_default('beam_bearing_max', angle_max)
497 self.simulation_depth_scale = self._float_or_default('simulation_depth_scale', EnvDefaults.simulation_depth_scale)
498 self.simulation_range_scale = self._float_or_default('simulation_range_scale', EnvDefaults.simulation_range_scale)
499 self.simulation_cross_range_scale = self._float_or_default('simulation_cross_range_scale', EnvDefaults.simulation_cross_range_scale)
500 self.simulation_cross_range_min = self._float_or_default('simulation_cross_range_min', EnvDefaults.simulation_cross_range_min)
502 self._range_max = np.abs(self['receiver_range']).max()
503 bearing_absmax = np.abs([self['beam_bearing_max'], self['beam_bearing_min']]).max()
504 cross_range_max = self._range_max * np.sin(np.deg2rad(bearing_absmax))
506 self.simulation_depth = self._float_or_default('simulation_depth', self.simulation_depth_scale * self._depth_max)
507 self.simulation_range = self._float_or_default('simulation_range', self.simulation_range_scale * self._range_max)
508 self.simulation_cross_range = self._float_or_default('simulation_cross_range',
509 np.max([self.simulation_cross_range_min, self.simulation_cross_range_scale * cross_range_max]))
511 return self
513 def _reconcile_count(self, nn: int | None, arr: Any, nn_name: str, arr_name: str) -> int:
514 """Reconcile a stored point count against the array it describes.
516 A count differing from the array size is only meaningful for the Bellhop
517 first/last shorthand, where up to two values are given and the count tells
518 Bellhop how many points to interpolate between them. Any other mismatch
519 (typically a count cached by a previous `check()` before the array was
520 reassigned) is stale, so the array size wins.
521 """
522 na = int(np.size(arr))
523 if nn is not None and (nn == na or (na <= 2 and nn > na)):
524 return nn
525 return na
527 def _float_or_default(self, key: str, default: float) -> float:
528 """Return the current value if not None, otherwise return and set a default."""
529 val = getattr(self, key, None)
530 if val is None:
531 setattr(self, key, default)
532 val = default
533 return val
535 def _check_env_header(self) -> None:
536 assert self["_num_media"] == 1, f"BELLHOP only supports 1 medium, found {self['_num_media']}"
538 def _check_env_surface(self) -> None:
539 assert self['surface_depth'] is not None, 'surface must be defined or initialised'
540 if np.size(self['surface_depth']) > 1:
541 assert self['surface_depth'].ndim == 2, 'surface must be a scalar or an Nx2 array'
542 assert self['surface_depth'].shape[1] == 2, 'surface must be a scalar or an Nx2 array'
543 assert self['surface_depth'][0,0] <= 0, 'First range in surface array must be 0 m'
544 assert self['surface_depth'][-1,0] >= self._range_max, 'Last range in surface array must be beyond maximum range: '+str(self._range_max)+' m'
545 assert np.all(np.diff(self['surface_depth'][:,0]) > 0), 'surface array must be strictly monotonic in range'
546 if self["surface_reflection_coefficient"] is not None:
547 assert self["surface_boundary_condition"] == BHStrings.from_file, "TRC values need to be read from file"
549 def _check_env_depth(self) -> None:
550 assert self['bottom_depth'] is not None, 'depth must be defined or initialised'
551 if np.size(self['bottom_depth']) > 1:
552 assert self['bottom_depth'].ndim == 2, 'depth must be a scalar or an Nx2 array [ranges, depths]'
553 assert self['bottom_depth'].shape[1] == 2, 'depth must be a scalar or an Nx2 array [ranges, depths]'
554 assert self['bottom_depth'][-1,0] >= self._range_max, 'Last range in depth array must be beyond maximum range: '+str(self._range_max)+' m'
555 assert np.all(np.diff(self['bottom_depth'][:,0]) > 0), 'Depth array must be strictly monotonic in range'
556 assert self["_bathymetry"] == BHStrings.from_file, 'len(depth)>1 requires BTY file'
557 if self["bottom_reflection_coefficient"] is not None:
558 assert self["bottom_boundary_condition"] == BHStrings.from_file, "BRC values need to be read from file"
559 assert np.max(self['source_depth']) <= self['_depth_max'], f'source_depth {self.source_depth} cannot exceed water depth: {str(self._depth_max)}'
560 #assert np.max(self['receiver_depth']) <= self['_depth_max'], f'receiver_depth {self.receiver_depth} cannot exceed water depth: {str(self._depth_max)}'
562 def _check_env_ssp(self) -> None:
563 assert isinstance(self['soundspeed'], pd.DataFrame), 'Soundspeed should always be a DataFrame by this point'
564 assert self['soundspeed'].size > 1, "Soundspeed DataFrame should have been constructed internally to be two elements"
565 assert not (self._dimension == 3 and self['soundspeed_interp'] in (BHStrings.pchip, BHStrings.quadrilateral)), \
566 f"soundspeed_interp={self['soundspeed_interp']!r} is not supported by BELLHOP3D (only linear, nlinear, spline, hexahedral, or analytic)"
567 if self['soundspeed_interp'] == BHStrings.spline:
568 assert self['soundspeed'].shape[0] > 3, 'soundspeed profile must have at least 4 points for spline interpolation'
569 else:
570 assert self['soundspeed'].shape[0] > 1, 'soundspeed profile must have at least 2 points'
571 assert self['soundspeed'].index[0] <= self._surface_min, 'First depth in soundspeed array must be ≤ to minimum surface depth'
572 assert np.all(np.diff(self['soundspeed'].index) > 0), 'Soundspeed array must be strictly monotonic in depth'
573 if self['_depth_max'] != self['soundspeed'].index[-1]:
574 indlarger = np.argwhere(self['soundspeed'].index > self['_depth_max'])[0][0]
575 prev_ind = self['soundspeed'].index[:indlarger].tolist()
576 insert_ss_val = [
577 np.interp(self['_depth_max'],
578 self['soundspeed'].index,
579 self['soundspeed'].iloc[:, i])
580 for i in range(self['soundspeed'].shape[1])
581 ]
582 new_row = pd.DataFrame([insert_ss_val], columns=self['soundspeed'].columns)
583 new_row.index = [self._depth_max]
584 self['soundspeed'] = pd.concat([
585 self['soundspeed'].iloc[:indlarger], # rows before insertion
586 new_row, # new row
587 ])
588 self['soundspeed'].index = prev_ind + [self['_depth_max']]
589 warnings.warn("aubellhop has used linear interpolation to ensure the sound speed profile ends at the max depth. Ensure this is what you want.", UserWarning)
590 # TODO: check soundspeed range limits
592 def _check_env_source(self) -> None:
593 if self._dimension == 2:
594 assert self.source_range == 0.0, "Bellhop2D does not support non-zero source range."
595 assert self.source_cross_range == 0.0, "Bellhop2D does not support non-zero source cross range."
596 if self['source_directionality'] is not None:
597 assert np.size(self['source_directionality']) > 1, 'source_directionality must be an Nx2 array'
598 assert self['source_directionality'].ndim == 2, 'source_directionality must be an Nx2 array'
599 assert self['source_directionality'].shape[1] == 2, 'source_directionality must be an Nx2 array'
600 assert np.all(self['source_directionality'][:,0] >= -180) and np.all(self['source_directionality'][:,0] <= 180), 'source_directionality angles must be in (-180, 180]'
602 def _check_env_beam(self) -> None:
603 assert (self._dimension == 2) or (self._dimension == 3 and self.source_type in (BHStrings.point, BHStrings.default)), "Can only have point source in 3D (line or point in 2D)"
604 assert self['beam_angle_min'] >= -180 and self['beam_angle_min'] <= 180, 'beam_angle_min must be in range [-180, 180]'
605 assert self['beam_angle_max'] >= -180 and self['beam_angle_max'] <= 180, 'beam_angle_max must be in range [-180, 180]'
606 if self['_single_beam'] == BHStrings.single_beam:
607 assert self['single_beam_index'] is not None, 'Single beam was requested with option I but no index was provided in NBeam line'
610 ############# STANDARD INTERFACES ###############
612 def __getitem__(self, key: str) -> Any:
613 if not hasattr(self, key):
614 raise KeyError(key)
615 return getattr(self, key)
617 def __setitem__(self, key: str, value: Any) -> None:
618 self.__setattr__(key, value)
620 def __setattr__(self, key: str, value: Any) -> None:
621 if not hasattr(self, key):
622 raise KeyError(f"Unknown environment configuration parameter: {key!r}")
623 allowed = getattr(FlagMaps, key, None)
624 if allowed is not None and value is not None and value not in set(allowed.values()):
625 raise ValueError(f"Invalid value for {key!r}: {value}. Allowed: {set(allowed.values())}")
626 if not (
627 value is None
628 or isinstance(value, pd.DataFrame)
629 or np.isscalar(value)
630 ):
631 if not isinstance(value[0], str):
632 value = np.asarray(value, dtype=np.float64)
633 object.__setattr__(self, key, value)
635 def __delitem__(self, key: str) -> None:
636 raise KeyError("Environment parameters cannot be deleted")
638 def __iter__(self) -> Iterator[str]:
639 return (f.name for f in fields(self))
641 def __len__(self) -> int:
642 return len(fields(self))
644 def __repr__(self) -> str:
645 return pformat(self.to_dict())