Coverage for python / aubellhop / environment.py: 98%

366 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-05-24 14:11 +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.source_ndepth or np.size(self.source_depth) 

461 self.source_nrange = self.source_nrange or np.size(self.source_range) 

462 self.source_ncrossrange = self.source_ncrossrange or np.size(self.source_cross_range) 

463 self._source_num = self.source_ndepth * self.source_nrange * self.source_ncrossrange 

464 

465 self.receiver_ndepth = self.receiver_ndepth or np.size(self.receiver_depth) 

466 self.receiver_nrange = self.receiver_nrange or np.size(self.receiver_range) 

467 self.receiver_nbearing = self.receiver_nbearing or np.size(self.receiver_bearing) 

468 self._receiver_num = self.receiver_ndepth * self.receiver_nrange * self.receiver_nbearing 

469 

470 # Beam angle ranges default to half-space if source is left-most, otherwise full-space: 

471 if self['beam_angle_min'] is None: 

472 if np.min(self['receiver_range']) < 0: 

473 self['beam_angle_min'] = - MiscDefaults.beam_angle_fullspace 

474 else: 

475 self['beam_angle_min'] = - MiscDefaults.beam_angle_halfspace 

476 if self['beam_angle_max'] is None: 

477 if np.min(self['receiver_range']) < 0: 

478 self['beam_angle_max'] = MiscDefaults.beam_angle_fullspace 

479 else: 

480 self['beam_angle_max'] = MiscDefaults.beam_angle_halfspace 

481 

482 # Identical logic for bearing angles 

483 if np.min(self['receiver_range']) < 0: 

484 angle_min = -MiscDefaults.beam_bearing_fullspace 

485 angle_max = +MiscDefaults.beam_bearing_fullspace 

486 else: 

487 angle_min = -MiscDefaults.beam_bearing_halfspace 

488 angle_max = +MiscDefaults.beam_bearing_halfspace 

489 

490 self.beam_bearing_min = self._float_or_default('beam_bearing_min', angle_min) 

491 self.beam_bearing_max = self._float_or_default('beam_bearing_max', angle_max) 

492 

493 self.simulation_depth_scale = self._float_or_default('simulation_depth_scale', EnvDefaults.simulation_depth_scale) 

494 self.simulation_range_scale = self._float_or_default('simulation_range_scale', EnvDefaults.simulation_range_scale) 

495 self.simulation_cross_range_scale = self._float_or_default('simulation_cross_range_scale', EnvDefaults.simulation_cross_range_scale) 

496 self.simulation_cross_range_min = self._float_or_default('simulation_cross_range_min', EnvDefaults.simulation_cross_range_min) 

497 

498 self._range_max = np.abs(self['receiver_range']).max() 

499 bearing_absmax = np.abs([self['beam_bearing_max'], self['beam_bearing_min']]).max() 

500 cross_range_max = self._range_max * np.sin(np.deg2rad(bearing_absmax)) 

501 

502 self.simulation_depth = self._float_or_default('simulation_depth', self.simulation_depth_scale * self._depth_max) 

503 self.simulation_range = self._float_or_default('simulation_range', self.simulation_range_scale * self._range_max) 

504 self.simulation_cross_range = self._float_or_default('simulation_cross_range', 

505 np.max([self.simulation_cross_range_min, self.simulation_cross_range_scale * cross_range_max])) 

506 

507 return self 

508 

509 def _float_or_default(self, key: str, default: float) -> float: 

510 """Return the current value if not None, otherwise return and set a default.""" 

511 val = getattr(self, key, None) 

512 if val is None: 

513 setattr(self, key, default) 

514 val = default 

515 return val 

516 

517 def _check_env_header(self) -> None: 

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

519 

520 def _check_env_surface(self) -> None: 

521 assert self['surface_depth'] is not None, 'surface must be defined or initialised' 

522 if np.size(self['surface_depth']) > 1: 

523 assert self['surface_depth'].ndim == 2, 'surface must be a scalar or an Nx2 array' 

524 assert self['surface_depth'].shape[1] == 2, 'surface must be a scalar or an Nx2 array' 

525 assert self['surface_depth'][0,0] <= 0, 'First range in surface array must be 0 m' 

526 assert self['surface_depth'][-1,0] >= self._range_max, 'Last range in surface array must be beyond maximum range: '+str(self._range_max)+' m' 

527 assert np.all(np.diff(self['surface_depth'][:,0]) > 0), 'surface array must be strictly monotonic in range' 

528 if self["surface_reflection_coefficient"] is not None: 

529 assert self["surface_boundary_condition"] == BHStrings.from_file, "TRC values need to be read from file" 

530 

531 def _check_env_depth(self) -> None: 

532 assert self['bottom_depth'] is not None, 'depth must be defined or initialised' 

533 if np.size(self['bottom_depth']) > 1: 

534 assert self['bottom_depth'].ndim == 2, 'depth must be a scalar or an Nx2 array [ranges, depths]' 

535 assert self['bottom_depth'].shape[1] == 2, 'depth must be a scalar or an Nx2 array [ranges, depths]' 

536 assert self['bottom_depth'][-1,0] >= self._range_max, 'Last range in depth array must be beyond maximum range: '+str(self._range_max)+' m' 

537 assert np.all(np.diff(self['bottom_depth'][:,0]) > 0), 'Depth array must be strictly monotonic in range' 

538 assert self["_bathymetry"] == BHStrings.from_file, 'len(depth)>1 requires BTY file' 

539 if self["bottom_reflection_coefficient"] is not None: 

540 assert self["bottom_boundary_condition"] == BHStrings.from_file, "BRC values need to be read from file" 

541 assert np.max(self['source_depth']) <= self['_depth_max'], f'source_depth {self.source_depth} cannot exceed water depth: {str(self._depth_max)}' 

542 #assert np.max(self['receiver_depth']) <= self['_depth_max'], f'receiver_depth {self.receiver_depth} cannot exceed water depth: {str(self._depth_max)}' 

543 

544 def _check_env_ssp(self) -> None: 

545 assert isinstance(self['soundspeed'], pd.DataFrame), 'Soundspeed should always be a DataFrame by this point' 

546 assert self['soundspeed'].size > 1, "Soundspeed DataFrame should have been constructed internally to be two elements" 

547 if self['soundspeed_interp'] == BHStrings.spline: 

548 assert self['soundspeed'].shape[0] > 3, 'soundspeed profile must have at least 4 points for spline interpolation' 

549 else: 

550 assert self['soundspeed'].shape[0] > 1, 'soundspeed profile must have at least 2 points' 

551 assert self['soundspeed'].index[0] <= self._surface_min, 'First depth in soundspeed array must be ≤ to minimum surface depth' 

552 assert np.all(np.diff(self['soundspeed'].index) > 0), 'Soundspeed array must be strictly monotonic in depth' 

553 if self['_depth_max'] != self['soundspeed'].index[-1]: 

554 indlarger = np.argwhere(self['soundspeed'].index > self['_depth_max'])[0][0] 

555 prev_ind = self['soundspeed'].index[:indlarger].tolist() 

556 insert_ss_val = [ 

557 np.interp(self['_depth_max'], 

558 self['soundspeed'].index, 

559 self['soundspeed'].iloc[:, i]) 

560 for i in range(self['soundspeed'].shape[1]) 

561 ] 

562 new_row = pd.DataFrame([insert_ss_val], columns=self['soundspeed'].columns) 

563 new_row.index = [self._depth_max] 

564 self['soundspeed'] = pd.concat([ 

565 self['soundspeed'].iloc[:indlarger], # rows before insertion 

566 new_row, # new row 

567 ]) 

568 self['soundspeed'].index = prev_ind + [self['_depth_max']] 

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

570 # TODO: check soundspeed range limits 

571 

572 def _check_env_source(self) -> None: 

573 if self._dimension == 2: 

574 assert self.source_range == 0.0, "Bellhop2D does not support non-zero source range." 

575 assert self.source_cross_range == 0.0, "Bellhop2D does not support non-zero source cross range." 

576 if self['source_directionality'] is not None: 

577 assert np.size(self['source_directionality']) > 1, 'source_directionality must be an Nx2 array' 

578 assert self['source_directionality'].ndim == 2, 'source_directionality must be an Nx2 array' 

579 assert self['source_directionality'].shape[1] == 2, 'source_directionality must be an Nx2 array' 

580 assert np.all(self['source_directionality'][:,0] >= -180) and np.all(self['source_directionality'][:,0] <= 180), 'source_directionality angles must be in (-180, 180]' 

581 

582 def _check_env_beam(self) -> None: 

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

584 assert self['beam_angle_min'] >= -180 and self['beam_angle_min'] <= 180, 'beam_angle_min must be in range [-180, 180]' 

585 assert self['beam_angle_max'] >= -180 and self['beam_angle_max'] <= 180, 'beam_angle_max must be in range [-180, 180]' 

586 if self['_single_beam'] == BHStrings.single_beam: 

587 assert self['single_beam_index'] is not None, 'Single beam was requested with option I but no index was provided in NBeam line' 

588 

589 

590 ############# STANDARD INTERFACES ############### 

591 

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

593 if not hasattr(self, key): 

594 raise KeyError(key) 

595 return getattr(self, key) 

596 

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

598 self.__setattr__(key, value) 

599 

600 def __setattr__(self, key: str, value: Any) -> None: 

601 if not hasattr(self, key): 

602 raise KeyError(f"Unknown environment configuration parameter: {key!r}") 

603 allowed = getattr(FlagMaps, key, None) 

604 if allowed is not None and value is not None and value not in set(allowed.values()): 

605 raise ValueError(f"Invalid value for {key!r}: {value}. Allowed: {set(allowed.values())}") 

606 if not ( 

607 value is None 

608 or isinstance(value, pd.DataFrame) 

609 or np.isscalar(value) 

610 ): 

611 if not isinstance(value[0], str): 

612 value = np.asarray(value, dtype=np.float64) 

613 object.__setattr__(self, key, value) 

614 

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

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

617 

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

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

620 

621 def __len__(self) -> int: 

622 return len(fields(self)) 

623 

624 def __repr__(self) -> str: 

625 return pformat(self.to_dict())