Coverage for python/bellhop/main.py: 95%

133 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-10-22 12:04 +0000

1############################################################################## 

2# 

3# Copyright (c) 2025-, Will Robertson 

4# Copyright (c) 2018-2025, Mandar Chitre 

5# 

6# This file was originally part of arlpy, released under Simplified BSD License. 

7# It has been relicensed in this repository to be compatible with the Bellhop licence (GPL). 

8# 

9############################################################################## 

10 

11"""Underwater acoustic propagation modeling toolbox. 

12 

13This toolbox uses the Bellhop acoustic propagation model. For this model 

14to work, the complete bellhop.py package must be built and installed 

15and `bellhop.exe` should be in your PATH. 

16""" 

17 

18from typing import Any, List, Optional, Union, Tuple 

19 

20import numpy as _np 

21import pandas as _pd 

22 

23from bellhop.constants import _Strings, Defaults 

24 

25# this format to explicitly mark the functions as public: 

26from bellhop.readers import read_env as read_env 

27from bellhop.readers import read_ssp as read_ssp 

28from bellhop.readers import read_ati as read_ati 

29from bellhop.readers import read_bty as read_bty 

30from bellhop.readers import read_sbp as read_sbp 

31from bellhop.readers import read_trc as read_trc 

32from bellhop.readers import read_brc as read_brc 

33 

34from bellhop.readers import read_shd as read_shd 

35from bellhop.readers import read_rays as read_rays 

36from bellhop.readers import read_arrivals as read_arrivals 

37 

38from bellhop.environment import Environment 

39from bellhop.bellhop import Bellhop 

40 

41_models: List[Bellhop] = [] 

42 

43def new_model(name: str, **kwargs: Any) -> Bellhop: 

44 """Instantiate a new Bellhop model and add it to the list of models. 

45 

46 Creates a Bellhop instance with the specified parameters and 

47 adds it to the internal registry of models for later access. 

48 

49 Parameters 

50 ---------- 

51 name : str 

52 Descriptive name for this model instance, must be unique 

53 

54 **kwargs 

55 Keyword arguments passed directly to the Bellhop constructor. 

56 Common parameters include: 

57 - exe : str 

58 Filename of the Bellhop executable 

59 

60 Returns 

61 ------- 

62 Bellhop 

63 The newly created Bellhop model instance. 

64 

65 Examples 

66 -------- 

67 >>> bh.models() # there is always a default model 

68 ['bellhop'] 

69 >>> bh.new_model(name="bellhop-at", exe="bellhop_at.exe") 

70 >>> bh.models() 

71 ['bellhop', 'bellhop-at'] 

72 """ 

73 for m in _models: 

74 if name == m.name: 

75 raise ValueError(f"Bellhop model with this name ('{name}') already exists.") 

76 model = Bellhop(name=name, **kwargs) 

77 _models.append(model) 

78 return model 

79 

80new_model(name=Defaults.model_name) 

81 

82def models(env: Optional[Environment] = None, task: Optional[str] = None) -> List[str]: 

83 """List available models. 

84 

85 Parameters 

86 ---------- 

87 env : dict, optional 

88 Environment to model 

89 task : str, optional 

90 Task type: arrivals/eigenrays/rays/coherent/incoherent/semicoherent 

91 

92 Returns 

93 ------- 

94 list of str 

95 List of models that can be used 

96 

97 Examples 

98 -------- 

99 >>> import bellhop as bh 

100 >>> bh.models() 

101 ['bellhop'] 

102 >>> env = bh.create_env() 

103 >>> bh.models(env, task="coherent") 

104 ['bellhop'] 

105 """ 

106 if env is not None: 

107 env = check_env(env) 

108 if (env is None and task is not None) or (env is not None and task is None): 

109 raise ValueError('env and task should be both specified together') 

110 rv: List[str] = [] 

111 for m in _models: 

112 if m.supports(env, task): 

113 rv.append(m.name) 

114 return rv 

115 

116def create_env2d(**kv: Any) -> Environment: 

117 """Backwards compatibility for create_env""" 

118 return create_env(**kv) 

119 

120def create_env(**kv: Any) -> Environment: 

121 """Create a new underwater environment. 

122 

123 Parameters 

124 ---------- 

125 **kv : dict 

126 Keyword arguments for environment configuration. 

127 

128 Returns 

129 ------- 

130 env : dict 

131 A new underwater environment dictionary. 

132 

133 Raises 

134 ------ 

135 ValueError 

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

137 

138 Example 

139 ------- 

140 

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

142 

143 >>> import bellhop as bh 

144 >>> env = bh.create_env() 

145 >>> print(env) 

146 

147 The environment parameters may be changed by passing keyword arguments 

148 or modified later using dictionary notation: 

149 

150 >>> import bellhop as bh 

151 >>> env = bh.create_env(depth=40, soundspeed=1540) 

152 >>> print(env) 

153 >>> env['depth'] = 25 

154 >>> env['bottom_soundspeed'] = 1800 

155 >>> print(env) 

156 

157 The default environment has a constant sound speed. 

158 A depth dependent sound speed profile be provided as a Nx2 array of (depth, sound speed): 

159 

160 >>> import bellhop as bh 

161 >>> env = bh.create_env(depth=20, 

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

163 

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

165 

166 >>> import bellhop as bh 

167 >>> import pandas as pd 

168 >>> ssp2 = pd.DataFrame({ 

169 0: [1540, 1530, 1532, 1533], # profile at 0 m range 

170 100: [1540, 1535, 1530, 1533], # profile at 100 m range 

171 200: [1530, 1520, 1522, 1525] }, # profile at 200 m range 

172 index=[0, 10, 20, 30]) # depths of the profile entries in m 

173 >>> env = bh.create_env(depth=20, soundspeed=ssp2) 

174 

175 The default environment has a constant water depth. A range dependent bathymetry 

176 can be provided as a Nx2 array of (range, water depth): 

177 

178 >>> import bellhop as bh 

179 >>> env = bh.create_env(depth=[[0,20], [300,10], [500,18], [1000,15]]) 

180 """ 

181 env = Environment() 

182 

183 # Apply user-provided values to environment 

184 for k, v in kv.items(): 

185 if k not in env.keys(): 

186 raise KeyError('Unknown key: '+k) 

187 

188 # Convert everything to ndarray except DataFrames and scalars 

189 if isinstance(v, _pd.DataFrame): 

190 env[k] = v 

191 elif _np.isscalar(v): 

192 env[k] = v 

193 else: 

194 env[k] = _np.asarray(v, dtype=_np.float64) 

195 

196 return env 

197 

198 

199 

200def check_env(env: Environment) -> Environment: 

201 """Check the validity of a underwater environment definition. 

202 

203 This function is automatically executed before any of the compute_ functions, 

204 but must be called manually after setting environment parameters if you need to 

205 query against defaults that may be affected. 

206 

207 Parameters 

208 ---------- 

209 env : dict 

210 Environment definition 

211 

212 Returns 

213 ------- 

214 dict 

215 Updated environment definition 

216 

217 Raises 

218 ------ 

219 ValueError 

220 If the environment is invalid 

221 

222 Examples 

223 -------- 

224 >>> import bellhop as bh 

225 >>> env = bh.create_env() 

226 >>> env = check_env(env) 

227 """ 

228 

229 env._finalise() 

230 return env.check() 

231 

232 

233def check_env2d(env: Environment) -> Environment: 

234 """Backwards compatibility for check_env""" 

235 return check_env(env=env) 

236 

237def compute( 

238 env: Union[Environment,List[Environment]], 

239 model: Optional[Any] = None, 

240 task: Optional[Any] = None, 

241 debug: bool = False, 

242 fname_base: Optional[str] = None 

243 ) -> Union[ Any, 

244 Environment, 

245 Tuple[List[Environment], _pd.DataFrame] 

246 ]: 

247 """Compute Bellhop task(s) for given model(s) and environment(s). 

248 

249 Parameters 

250 ---------- 

251 env : dict or list of dict 

252 Environment definition (which includes the task specification) 

253 model : str, optional 

254 Propagation model to use (None to auto-select) 

255 task : str or list of str, optional 

256 Optional task or list of tasks ("arrivals", etc.) 

257 debug : bool, default=False 

258 Generate debug information for propagation model 

259 fname_base : str, optional 

260 Base file name for Bellhop working files, default (None), creates a temporary file 

261 

262 Returns 

263 ------- 

264 dict 

265 Single run result (and associated metadata) if only one computation is performed. 

266 tuple of (list of dict, pandas.DataFrame) 

267 List of results and an index DataFrame if multiple computations are performed. 

268 

269 Notes 

270 ----- 

271 If any of env, model, and/or task are lists then multiple runs are performed 

272 with a list of dictionary outputs returned. The ordering is based on loop iteration 

273 but might not be deterministic; use the index DataFrame to extract and filter the 

274 output logically. 

275 

276 Examples 

277 -------- 

278 Single task based on reading a complete `.env` file: 

279 >>> import bellhop as bh 

280 >>> env = bh.read_env("...") 

281 >>> output = bh.compute(env) 

282 >>> assert output['task'] == "arrivals" 

283 >>> bh.plot_arrivals(output['results']) 

284 

285 Multiple tasks: 

286 >>> import bellhop as bh 

287 >>> env = bh.create_env() 

288 >>> output, ind_df = bh.compute(env,task=["arrivals", "eigenrays"]) 

289 >>> bh.plot_arrivals(output[0]['results']) 

290 """ 

291 envs = env if isinstance(env, list) else [env] 

292 models = model if isinstance(model, list) else [model] 

293 tasks = task if isinstance(task, list) else [task] 

294 results: List[Any] = [] 

295 for this_env in envs: 

296 debug and print(f"Using environment: {this_env['name']}") 

297 for this_model in models: 

298 debug and print(f"Using model: {'[None] (default)' if this_model is None else this_model.get('name')}") 

299 for this_task in tasks: 

300 debug and print(f"Using task: {this_task}") 

301 env_chk = check_env(this_env) 

302 this_task = this_task or env_chk.get('task') 

303 if this_task is None: 

304 raise ValueError("Task must be specified in env or as parameter") 

305 model_fn = _select_model(env_chk, this_task, this_model, debug) 

306 results.append({ 

307 "name": env_chk["name"], 

308 "model": this_model, 

309 "task": this_task, 

310 "results": model_fn.run(env_chk, this_task, debug, fname_base), 

311 }) 

312 assert len(results) > 0, "No results generated" 

313 index_df = _pd.DataFrame([ 

314 { 

315 "i": i, 

316 "name": r["name"], 

317 "model": getattr(r["model"], "name", str(r["model"])) if r["model"] is not None else None, 

318 "task": r["task"], 

319 } 

320 for i, r in enumerate(results) 

321 ]) 

322 index_df.set_index("i", inplace=True) 

323 if len(results) > 1: 

324 return results, index_df 

325 else: 

326 return results[0] 

327 

328def _select_model(env: Environment, 

329 task: str, 

330 model: Optional[str] = None, 

331 debug: bool = False 

332 ) -> Any: 

333 """Finds a model to use, or if a model is requested validate it. 

334 

335 Parameters 

336 ---------- 

337 env : dict 

338 The environment dictionary 

339 task : str 

340 The task to be computed 

341 model : str, optional 

342 Specified model to use 

343 debug : bool, default=False 

344 Whether to print diagnostics 

345 

346 Returns 

347 ------- 

348 Bellhop 

349 The model function to evaluate its `.run()` method 

350 

351 Notes 

352 ----- 

353 The intention of this function is to allow multiple models to be "loaded" and the 

354 first appropriate model found is used for the computation. 

355 

356 This is likely to be more useful once we extend the code to handle things like 3D 

357 bellhop models, GPU bellhop models, and so on. 

358 """ 

359 if model is not None: 

360 for m in _models: 

361 if m.name == model: 

362 debug and print(f'Model selected: {m.name}') 

363 return m 

364 raise ValueError(f"Unknown model: '{model}'") 

365 

366 debug and print("Searching for propagation model:") 

367 for mm in _models: 

368 if mm.supports(env, task): 

369 debug and print(f'Model found: {mm.name}') 

370 return mm 

371 raise ValueError('No suitable propagation model available') 

372 

373def compute_arrivals(env: Environment, model: Optional[Any] = None, debug: bool = False, fname_base: Optional[str] = None) -> Any: 

374 """Compute arrivals between each transmitter and receiver. 

375 

376 Parameters 

377 ---------- 

378 env : dict 

379 Environment definition 

380 model : str, optional 

381 Propagation model to use (None to auto-select) 

382 debug : bool, default=False 

383 Generate debug information for propagation model 

384 fname_base : str, optional 

385 Base file name for Bellhop working files, default (None), creates a temporary file 

386 

387 Returns 

388 ------- 

389 pandas.DataFrame 

390 Arrival times and coefficients for all transmitter-receiver combinations 

391 

392 Examples 

393 -------- 

394 >>> import bellhop as bh 

395 >>> env = bh.create_env() 

396 >>> arrivals = bh.compute_arrivals(env) 

397 >>> bh.plot_arrivals(arrivals) 

398 """ 

399 output = compute(env, model, _Strings.arrivals, debug, fname_base) 

400 assert isinstance(output, dict), "Single env should return single result" 

401 return output['results'] 

402 

403def compute_eigenrays(env: Environment, source_depth_ndx: int = 0, receiver_depth_ndx: int = 0, receiver_range_ndx: int = 0, model: Optional[Any] = None, debug: bool = False, fname_base: Optional[str] = None) -> Any: 

404 """Compute eigenrays between a given transmitter and receiver. 

405 

406 Parameters 

407 ---------- 

408 env : dict 

409 Environment definition 

410 source_depth_ndx : int, default=0 

411 Transmitter depth index 

412 receiver_depth_ndx : int, default=0 

413 Receiver depth index 

414 receiver_range_ndx : int, default=0 

415 Receiver range index 

416 model : str, optional 

417 Propagation model to use (None to auto-select) 

418 debug : bool, default=False 

419 Generate debug information for propagation model 

420 fname_base : str, optional 

421 Base file name for Bellhop working files, default (None), creates a temporary file 

422 

423 Returns 

424 ------- 

425 pandas.DataFrame 

426 Eigenrays paths 

427 

428 Examples 

429 -------- 

430 >>> import bellhop as bh 

431 >>> env = bh.create_env() 

432 >>> rays = bh.compute_eigenrays(env) 

433 >>> bh.plot_rays(rays, width=1000) 

434 """ 

435 env = check_env(env) 

436 env = env.copy() 

437 if _np.size(env['source_depth']) > 1: 

438 env['source_depth'] = env['source_depth'][source_depth_ndx] 

439 if _np.size(env['receiver_depth']) > 1: 

440 env['receiver_depth'] = env['receiver_depth'][receiver_depth_ndx] 

441 if _np.size(env['receiver_range']) > 1: 

442 env['receiver_range'] = env['receiver_range'][receiver_range_ndx] 

443 output = compute(env, model, _Strings.eigenrays, debug, fname_base) 

444 assert isinstance(output, dict), "Single env should return single result" 

445 return output['results'] 

446 

447def compute_rays(env: Environment, source_depth_ndx: int = 0, model: Optional[Any] = None, debug: bool = False, fname_base: Optional[str] = None) -> Any: 

448 """Compute rays from a given transmitter. 

449 

450 Parameters 

451 ---------- 

452 env : dict 

453 Environment definition 

454 source_depth_ndx : int, default=0 

455 Transmitter depth index 

456 model : str, optional 

457 Propagation model to use (None to auto-select) 

458 debug : bool, default=False 

459 Generate debug information for propagation model 

460 fname_base : str, optional 

461 Base file name for Bellhop working files, default (None), creates a temporary file 

462 

463 Returns 

464 ------- 

465 pandas.DataFrame 

466 Ray paths 

467 

468 Examples 

469 -------- 

470 >>> import bellhop as bh 

471 >>> env = bh.create_env() 

472 >>> rays = bh.compute_rays(env) 

473 >>> bh.plot_rays(rays, width=1000) 

474 """ 

475 env = check_env(env) 

476 if _np.size(env['source_depth']) > 1: 

477 env = env.copy() 

478 env['source_depth'] = env['source_depth'][source_depth_ndx] 

479 output = compute(env, model, _Strings.rays, debug, fname_base) 

480 assert isinstance(output, dict), "Single env should return single result" 

481 return output['results'] 

482 

483def compute_transmission_loss(env: Environment, source_depth_ndx: int = 0, mode: Optional[str] = None, model: Optional[Any] = None, debug: bool = False, fname_base: Optional[str] = None) -> Any: 

484 """Compute transmission loss from a given transmitter to all receviers. 

485 

486 Parameters 

487 ---------- 

488 env : dict 

489 Environment definition 

490 source_depth_ndx : int, default=0 

491 Transmitter depth index 

492 mode : str, optional 

493 Coherent, incoherent or semicoherent 

494 model : str, optional 

495 Propagation model to use (None to auto-select) 

496 debug : bool, default=False 

497 Generate debug information for propagation model 

498 fname_base : str, optional 

499 Base file name for Bellhop working files, default (None), creates a temporary file 

500 

501 Returns 

502 ------- 

503 numpy.ndarray 

504 Complex transmission loss at each receiver depth and range 

505 

506 Examples 

507 -------- 

508 >>> import bellhop as bh 

509 >>> env = bh.create_env() 

510 >>> tloss = bh.compute_transmission_loss(env, mode=bh.incoherent) 

511 >>> bh.plot_transmission_loss(tloss, width=1000) 

512 """ 

513 env = env.copy() 

514 task = mode or env.get("interference_mode") or Defaults.interference_mode 

515 env['interference_mode'] = task 

516 debug and print(f" {task=}") 

517 env = check_env(env) 

518 if _np.size(env['source_depth']) > 1: 

519 env['source_depth'] = env['source_depth'][source_depth_ndx] 

520 output = compute(env, model, task, debug, fname_base) 

521 assert isinstance(output, dict), "Single env should return single result" 

522 return output['results'] 

523 

524def arrivals_to_impulse_response(arrivals: Any, fs: float, abs_time: bool = False) -> Any: 

525 """Convert arrival times and coefficients to an impulse response. 

526 

527 Parameters 

528 ---------- 

529 arrivals : pandas.DataFrame 

530 Arrivals times (s) and coefficients 

531 fs : float 

532 Sampling rate (Hz) 

533 abs_time : bool, default=False 

534 Absolute time (True) or relative time (False) 

535 

536 Returns 

537 ------- 

538 numpy.ndarray 

539 Impulse response 

540 

541 Notes 

542 ----- 

543 If `abs_time` is set to True, the impulse response is placed such that 

544 the zero time corresponds to the time of transmission of signal. 

545 

546 Examples 

547 -------- 

548 >>> import bellhop as bh 

549 >>> env = bh.create_env() 

550 >>> arrivals = bh.compute_arrivals(env) 

551 >>> ir = bh.arrivals_to_impulse_response(arrivals, fs=192000) 

552 """ 

553 t0 = 0 if abs_time else min(arrivals.time_of_arrival) 

554 irlen = int(_np.ceil((max(arrivals.time_of_arrival)-t0)*fs))+1 

555 ir = _np.zeros(irlen, dtype=_np.complex128) 

556 for _, row in arrivals.iterrows(): 

557 ndx = int(_np.round((row.time_of_arrival.real-t0)*fs)) 

558 ir[ndx] = row.arrival_amplitude 

559 return ir 

560 

561### Export module names for auto-importing in __init__.py 

562 

563__all__ = [ 

564 name for name in globals() if not name.startswith("_") # ignore private names 

565]