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

1"""Environment configuration for aubellhop. 

2 

3This module provides dataclass-based environment configuration with automatic validation, 

4replacing manual option checking with field validators. 

5""" 

6 

7from __future__ import annotations 

8 

9from collections.abc import MutableMapping 

10from dataclasses import dataclass, fields 

11from typing import Any, Iterator, Self, Callable 

12 

13from pprint import pformat 

14import warnings 

15from itertools import product 

16 

17import numpy as np 

18from numpy.typing import NDArray 

19import pandas as pd 

20 

21from .constants import BHStrings, FlagMaps, EnvDefaults, MiscDefaults 

22 

23 

24@dataclass 

25class Environment(MutableMapping[str, Any]): 

26 """Dataclass for underwater acoustic environment configuration. 

27 

28 This class provides automatic validation of environment parameters, 

29 eliminating the need for manual checking of option validity. 

30 

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. 

33 

34 Parameters 

35 ---------- 

36 **kv : dict 

37 Keyword arguments for environment configuration. 

38 

39 Returns 

40 ------- 

41 env : dict 

42 A new underwater environment dictionary. 

43 

44 Raises 

45 ------ 

46 ValueError 

47 If any parameter value is invalid according to BELLHOP constraints. 

48 

49 Example 

50 ------- 

51 

52 To see all the parameters available and their default values: 

53 

54 >>> import aubellhop as bh 

55 >>> env = bh.Environment() 

56 >>> print(env) 

57 

58 The environment parameters may be changed by passing keyword arguments 

59 or modified later using dictionary notation: 

60 

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) 

67 

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): 

70 

71 >>> import aubellhop as bh 

72 >>> env = bh.Environment(bottom_depth=20, 

73 >>> soundspeed=[[0,1540], [5,1535], [10,1535], [20,1530]]) 

74 

75 A range-and-depth dependent sound speed profile can be provided as a Pandas frame: 

76 

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) 

85 

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): 

88 

89 >>> import aubellhop as bh 

90 >>> env = bh.Environment(bottom_depth=[[0,20], [300,10], [500,18], [1000,15]]) 

91 """ 

92 

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 

100 

101 # Sound speed parameters 

102 soundspeed: float | Any = MiscDefaults.sound_speed # m/s 

103 soundspeed_interp: str = EnvDefaults.soundspeed_interp 

104 

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 

110 

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 

115 

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 

131 

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 

144 

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 

155 

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 

164 

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` 

175 

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 

185 

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 

194 

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 

200 

201 # Attenuation parameters 

202 volume_attenuation: str = EnvDefaults.volume_attenuation 

203 attenuation_units: str = EnvDefaults.attenuation_units 

204 biological_layer_parameters: Any | None = None 

205 

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 

211 

212 comment_pad: int = EnvDefaults.comment_pad 

213 

214 ############# CLASS METHODS ################ 

215 

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 

223 

224 

225 @classmethod 

226 def from_dict(cls, data: dict[str, Any]) -> "Environment": 

227 """Create Environment from dictionary. 

228 

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) 

236 

237 ############# WRITING ################ 

238 

239 def to_file(self, fname: str, task: str | None = None) -> str: 

240 """Write a complete .env file for specifying a Bellhop simulation. 

241 

242 This is the user-facing file writer. It infers the file basename and 

243 resolves the task from the environment unless overridden. 

244 

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`. 

255 

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 

264 

265 path = Path(fname) 

266 if path.suffix == "": 

267 path = path.with_suffix(FileExt.env) 

268 

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") 

273 

274 if isinstance(task_val, str): 

275 task_val = BHStrings(task_val) # avoid strict type issue 

276 

277 taskcode = FlagMaps.task_rev[task_val] 

278 fname_base = str(path.with_suffix("")) 

279 

280 path.parent.mkdir(parents=True, exist_ok=True) 

281 with open(path, "w") as fh: 

282 EnvironmentWriter(self, fh, fname_base, taskcode).write() 

283 

284 return fname_base 

285 

286 ############# SMALL METHODS ################ 

287 

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 

294 

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 

301 

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) 

306 

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 

314 

315 def unwrap(self, *keys: str) -> list[Environment]: 

316 """Return a list of Environment copies expanded over the given keys. 

317 

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 """ 

322 

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}'") 

327 

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]) 

336 

337 combos = product(*values) 

338 envs = [] 

339 

340 base_name = str(self.get("name", "env")) 

341 

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) 

355 

356 return envs 

357 

358 ############## SETTERS ############### 

359 

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 

373 

374 ############## CHECKING ############### 

375 

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 

389 

390 def _finalise(self) -> Self: 

391 """Reviews the data within an environment and updates settings for consistency. 

392 

393 This function is run as the first step of `.check()`. 

394 """ 

395 

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 

400 

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 

409 

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)}") 

427 

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) 

430 

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.") 

451 

452 if "depth" in self["soundspeed"].columns: 

453 self["soundspeed"] = self["soundspeed"].set_index("depth") 

454 

455 if len(self['soundspeed'].columns) > 1: 

456 self['soundspeed_interp'] == BHStrings.quadrilateral 

457 

458 self.bottom_attenuation = self._float_or_default('bottom_attenuation', EnvDefaults.bottom_attenuation) 

459 

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 

465 

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 

473 

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 

485 

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 

493 

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) 

496 

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) 

501 

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)) 

505 

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])) 

510 

511 return self 

512 

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. 

515 

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 

526 

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 

534 

535 def _check_env_header(self) -> None: 

536 assert self["_num_media"] == 1, f"BELLHOP only supports 1 medium, found {self['_num_media']}" 

537 

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" 

548 

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)}' 

561 

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 

591 

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]' 

601 

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' 

608 

609 

610 ############# STANDARD INTERFACES ############### 

611 

612 def __getitem__(self, key: str) -> Any: 

613 if not hasattr(self, key): 

614 raise KeyError(key) 

615 return getattr(self, key) 

616 

617 def __setitem__(self, key: str, value: Any) -> None: 

618 self.__setattr__(key, value) 

619 

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) 

634 

635 def __delitem__(self, key: str) -> None: 

636 raise KeyError("Environment parameters cannot be deleted") 

637 

638 def __iter__(self) -> Iterator[str]: 

639 return (f.name for f in fields(self)) 

640 

641 def __len__(self) -> int: 

642 return len(fields(self)) 

643 

644 def __repr__(self) -> str: 

645 return pformat(self.to_dict())