Coverage for python / aubellhop / readers.py: 89%
611 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-24 14:11 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-24 14:11 +0000
1"""Reader functions for aubellhop.
3This file provides a collection of reading methods for both input
4and output Bellhop files.
6Reading the environment file has substantial logic when relies on
7the state of the environment information it is passed. Therefore, this is
8managed by class `EnvironmentReader()`, used internally by the `Environment`
9class via its class method `Environment.from_file()`.
11Subsequent files (ssp, bty, ati, brc, trc) are read automatically, but public
12interfaces are provided for these for the purposes of mix and matching data
13if desired.
15Finally, readers are also provides for output files for rays, arrivals, and
16binary shd files. Again, these are used automatically by `aubellhop` after
17executing a computation, but public methods are provides to help with
18testing and verification.
20"""
22from __future__ import annotations
24import os
25from struct import unpack as _unpack
26from pathlib import Path
27from typing import Any, TextIO, cast
28from numpy.typing import NDArray
30import numpy as np
31import pandas as pd
32from aubellhop.constants import BHStrings, FlagMaps, FileExt, MiscDefaults
33from aubellhop.environment import Environment
36class EnvironmentReader:
37 """Read and parse Bellhop environment files.
39 Although this class is only used for one task,
40 the use of a class provides the clearest code interface compared
41 to nested functions, which either implicitly set
42 dict parameters, or have many repeated and superfluous
43 arguments as dicts are passed in and returned at
44 each stage.
45 """
47 def __init__(self, env: Environment, fname: str):
48 """Initialize reader with filename.
50 Args:
51 fname: Path to .env file (with or without extension)
52 """
53 self.env = env
54 self.fname, self.fname_base = _prepare_filename(fname, FileExt.env, "Environment")
56 def read(self) -> Environment:
57 """Do the reading..."""
58 with open(self.fname, 'r') as f:
59 self._read_header(f)
60 self._read_top_boundary(f)
61 next_line = self._read_sound_speed_profile(f)
62 self._read_bottom_boundary(f, next_line)
63 self._read_sources_receivers_task(f)
64 self._read_beams_limits(f)
65 self._read_gaussian_params(f)
66 return self.env
68 def _read_header(self, f: TextIO) -> None:
69 """Read environment file header"""
70 self.env['name'] = self._unquote_string(_read_next_valid_line(f))
71 self.env['frequency'] = _parse_vector(_read_next_valid_line(f))
72 self.env["_num_media"] = _parse_line_int(_read_next_valid_line(f))
74 def _read_top_boundary(self, f: TextIO) -> None:
75 """Read environment file top boundary options (multiple lines)"""
77 # Line 4: Top boundary options
78 topopt_line = _read_next_valid_line(f)
79 topopt = self._unquote_string(topopt_line) + " "
80 self.env["soundspeed_interp"] = self._opt_lookup("Interpolation", topopt[0], FlagMaps.soundspeed_interp)
81 self.env["surface_boundary_condition"] = self._opt_lookup("Top boundary condition", topopt[1], FlagMaps.surface_boundary_condition)
82 self.env["attenuation_units"] = self._opt_lookup("Attenuation units", topopt[2], FlagMaps.attenuation_units)
83 self.env["volume_attenuation"] = self._opt_lookup("Volume attenuation", topopt[3], FlagMaps.volume_attenuation)
84 self.env["_altimetry"] = self._opt_lookup("Altimetry", topopt[4], FlagMaps._altimetry)
85 self.env["_single_beam"] = self._opt_lookup("Single beam", topopt[5], FlagMaps._single_beam)
86 if self.env["_altimetry"] == BHStrings.from_file:
87 self.env["surface_depth"], self.env["surface_interp"] = read_ati(self.fname_base)
89 if self.env["volume_attenuation"] == BHStrings.francois_garrison:
90 fg_spec_line = _read_next_valid_line(f)
91 fg_parts = _parse_line(fg_spec_line)
92 self.env["_fg_salinity"] = _float(fg_parts[0])
93 self.env["_fg_temperature"] = _float(fg_parts[1])
94 self.env["_fg_pH"] = _float(fg_parts[2])
95 self.env["_fg_depth"] = _float(fg_parts[3])
97 # Line 4a: Boundary condition params
98 if self.env["surface_boundary_condition"] == BHStrings.acousto_elastic:
99 surface_props_line = _read_next_valid_line(f)
100 surface_props = _parse_line(surface_props_line, none_pad=6)
101 self.env['_surface_min'] = _float(surface_props[0])
102 self.env['surface_soundspeed'] = _float(surface_props[1])
103 self.env['_surface_soundspeed_shear'] = _float(surface_props[2])
104 self.env['surface_density'] = _float(surface_props[3], scale=1000) # convert from g/cm³ to kg/m³
105 self.env['surface_attenuation'] = _float(surface_props[4])
106 self.env['_surface_attenuation_shear'] = _float(surface_props[5])
108 # Line 4b: Biological layer properties
109 if self.env["volume_attenuation"] == BHStrings.biological:
110 self.env['biological_layer_parameters'] = self._read_biological_layers(f)
112 def _read_biological_layers(self, f: TextIO) -> pd.DataFrame:
113 """Read biological layer parameters for attenuation due to fish."""
114 next_line = _read_next_valid_line(f)
115 npoints = int(next_line)
116 z1 = []
117 z2 = []
118 f0 = []
119 QQ = []
120 a0 = []
121 for i in range(npoints):
122 line = _read_next_valid_line(f)
123 parts = _parse_line(line)
124 if len(parts) == 5:
125 z1.append(_float(parts[0]))
126 z2.append(_float(parts[1]))
127 f0.append(_float(parts[2]))
128 QQ.append(_float(parts[3]))
129 a0.append(_float(parts[4]))
130 if len(z1) != npoints:
131 raise ValueError(f"Expected {npoints} points, but found {len(z1)}")
132 return pd.DataFrame({"z1": z1, "z2": z2, "f0": f0, "Q": QQ, "a0": a0})
134 def _read_sound_speed_profile(self, f: TextIO) -> str:
135 """Read environment file sound speed profile"""
137 # SSP depth specification
138 ssp_spec_line = _read_next_valid_line(f)
139 ssp_spec_line = ssp_spec_line.replace(",", " ")
140 ssp_parts = _parse_line(ssp_spec_line, none_pad=3)
141 self.env['_mesh_npts'] = _int(ssp_parts[0])
142 self.env['_depth_sigma'] = _float(ssp_parts[1])
143 self.env['_depth_max'] = _float(ssp_parts[2])
144 self.env['bottom_depth'] = self.env['_depth_max'] # TODO: interrogate this, looks unsafe
146 # Read SSP points and from file if applicable
147 ssp_lines, next_line = self._read_until_quote(f)
148 self.env['soundspeed'] = self._read_ssp_points(ssp_lines)
149 if self.env["soundspeed_interp"] == BHStrings.quadrilateral:
150 self.env['soundspeed'] = read_ssp(self.fname_base, self.env['soundspeed'].index)
151 elif self.env["soundspeed_interp"] == BHStrings.hexahedral:
152 self.env['soundspeed'] = read_ssp_3d(self.fname_base)
153 return next_line
155 def _read_until_quote(self, f: TextIO) -> tuple[list[str],str]:
156 """Read lines until one starts with ' character."""
157 lines: list[str] = []
158 while True:
159 line = f.readline()
160 if not line:
161 raise EOFError("File ended during env file reading of SSP points.")
162 line = line.strip()
163 if not line: # completely empty line
164 continue
165 if line.startswith("'"): # Check if this is a bottom boundary line (starts with quote)
166 return lines, line
167 lines.append(line)
169 def _read_ssp_points(self, lines: list[str]) -> pd.DataFrame:
170 """Read sound speed profile points until we find the bottom boundary line
172 Default values are according to 'EnvironmentalFile.htm'."""
174 ssp_depth: list[float] = []
175 ssp_speed: list[float] = []
176 ssp_shear: list[float] = []
177 ssp_density: list[float] = []
178 ssp_atten: list[float] = []
179 ssp_att_shear: list[float] = []
180 ssp = dict(depth=0.0, speed=MiscDefaults.sound_speed, speed_shear=0.0, density=MiscDefaults.density, atten=0.0, att_shear=0.0)
182 MAX_SSP_COLS: int = 6
183 sound_speed_param_count: int = 0
184 for line in lines:
185 raw_parts = _parse_line(line)
186 parts = (raw_parts + [None] * MAX_SSP_COLS)[:MAX_SSP_COLS]
187 if parts[0] is None: # empty line after stripping comments
188 continue
189 sound_speed_param_count = max(len(raw_parts), sound_speed_param_count)
190 for k, v in zip(ssp, parts):
191 if v is not None:
192 ssp[k] = cast(float, _float(v)) # "fill in the blanks" if empty entries on any lines
193 ssp_depth.append(ssp["depth"])
194 ssp_speed.append(ssp["speed"])
195 ssp_shear.append(ssp["speed_shear"])
196 ssp_density.append(ssp["density"] * 1000.0) # units scaling
197 ssp_atten.append(ssp["atten"])
198 ssp_att_shear.append(ssp["att_shear"])
200 if len(ssp_speed) == 0:
201 raise ValueError("No SSP points were found in the env file.")
202 elif len(ssp_speed) == 1:
203 raise ValueError("Only one SSP point found but at least two required (top and bottom)")
205 df = pd.DataFrame({
206 "speed": ssp_speed,
207 "shear_speed": ssp_shear,
208 "density": ssp_density,
209 "attenuation": ssp_atten,
210 "shear_attenuation": ssp_att_shear
211 }, index=pd.Index(ssp_depth))
212 df = df.iloc[:, :(sound_speed_param_count-1)] # Keep only the max number of columns read
213 df.index.name = "depth"
214 return df
216 def _read_bottom_boundary(self, f: TextIO, bottom_line: str) -> None:
217 """Read environment file bottom boundary condition"""
218 bottom_parts = _parse_line(bottom_line,none_pad=3)
219 botopt = self._unquote_string(cast(str,bottom_parts[0])) + " " # cast() => I promise this is a str :)
220 self.env["bottom_boundary_condition"] = self._opt_lookup("Bottom boundary condition", botopt[0], FlagMaps.bottom_boundary_condition)
221 self.env["_bathymetry"] = self._opt_lookup("Bathymetry", botopt[1], FlagMaps._bathymetry)
222 self.env['bottom_roughness'] = _float(bottom_parts[1])
223 self.env['bottom_beta'] = _float(bottom_parts[2])
224 self.env['bottom_transition_freq'] = _float(bottom_parts[3])
225 if self.env["_bathymetry"] == BHStrings.from_file:
226 self.env["bottom_depth"], self.env["bottom_interp"] = read_bty(self.fname_base)
228 # Bottom properties (depth, sound_speed, density, absorption)
229 if self.env["bottom_boundary_condition"] == BHStrings.acousto_elastic:
230 bottom_props_line = _read_next_valid_line(f)
231 bottom_props = _parse_line(bottom_props_line,none_pad=6)
232 self.env['_bottom_depth'] = _float(bottom_props[0])
233 self.env['bottom_soundspeed'] = _float(bottom_props[1])
234 self.env['_bottom_soundspeed_shear'] = _float(bottom_props[2])
235 self.env['bottom_density'] = _float(bottom_props[3], 1000) # convert from g/cm³ to kg/m³
236 self.env['bottom_attenuation'] = _float(bottom_props[4])
237 self.env['_bottom_attenuation_shear'] = _float(bottom_props[5])
238 elif self.env["bottom_boundary_condition"] == BHStrings.grain:
239 bottom_props_line = _read_next_valid_line(f)
240 bottom_props = _parse_line(bottom_props_line, none_pad=6)
241 self.env['_bottom_depth'] = _float(bottom_props[0])
242 self.env['bottom_grain_size'] = _float(bottom_props[1])
244 def _read_sources_receivers_task(self, f: TextIO) -> None:
245 """Read environment file sources, receivers, and task.
247 Bellhop and Bellhop3D have different numbers of variables specified before
248 the task line. Luckily we can detect that reliably by looking for a line which
249 starts with `'` to mark the definition of the tasks."""
251 sr_lines = []
252 while True:
253 next_line = _read_next_valid_line(f)
254 if next_line.startswith("'"):
255 break
256 sr_lines.append(next_line)
258 self._parse_src_rcv(sr_lines)
259 self._parse_task(next_line)
261 def _parse_src_rcv(self, sr_lines: list[str]) -> None:
262 """Parse the N lines read defining sources and receivers and assign the corresponding variables."""
263 nlines = len(sr_lines)
264 if nlines == 3: # sometimes see a shorthand version like: ['1 25.0 /', '10 100.0 1000.0 /', '1, 80.0']
265 self.env['_dimension'] = 2
266 val0 = np.asarray(_parse_vector(sr_lines[0]))
267 val1 = np.asarray(_parse_vector(sr_lines[1]))
268 val2 = np.asarray(_parse_vector(sr_lines[2]))
269 self.env['source_ndepth'] = int(val0[0])
270 self.env['receiver_nrange'] = int(val1[0])
271 self.env['receiver_ndepth'] = int(val2[0])
272 self.env['source_depth'] = val0[1:]
273 self.env['receiver_depth'] = val1[1:]
274 self.env['receiver_range'] = val2[1:] * 1000.0 # convert km to m
275 elif nlines == 6:
276 self.env['_dimension'] = 2
277 self.env['source_ndepth'] = _parse_line_int(sr_lines[0])
278 self.env['receiver_ndepth'] = _parse_line_int(sr_lines[2])
279 self.env['receiver_nrange'] = _parse_line_int(sr_lines[4])
280 self.env['source_depth'] = _parse_vector(sr_lines[1])
281 self.env['receiver_depth'] = _parse_vector(sr_lines[3])
282 self.env['receiver_range'] = _parse_vector(sr_lines[5]) * 1000.0 # convert km to m
283 elif nlines == 12:
284 self.env['_dimension'] = 3
285 self.env['source_nrange'] = _parse_line_int(sr_lines[0])
286 self.env['source_ncrossrange'] = _parse_line_int(sr_lines[2])
287 self.env['source_ndepth'] = _parse_line_int(sr_lines[4])
288 self.env['receiver_ndepth'] = _parse_line_int(sr_lines[6])
289 self.env['receiver_nrange'] = _parse_line_int(sr_lines[8])
290 self.env['receiver_nbearing'] = _parse_line_int(sr_lines[10])
291 self.env['source_range'] = _parse_vector(sr_lines[1]) * 1000.0 # convert km to m
292 self.env['source_cross_range'] = _parse_vector(sr_lines[3]) * 1000.0 # convert km to m
293 self.env['source_depth'] = _parse_vector(sr_lines[5])
294 self.env['receiver_depth'] = _parse_vector(sr_lines[7])
295 self.env['receiver_range'] = _parse_vector(sr_lines[9]) * 1000.0 # convert km to m
296 self.env['receiver_bearing'] = _parse_vector(sr_lines[11])
297 else:
298 print("SCANNED SRC/RCV LINES:")
299 print(sr_lines)
300 raise RuntimeError(
301 "The python parsing of Bellhop's so-called 'list-directed IO' is not robust."
302 f"Expected to read 6 or 12 lines (2D or 3D cases); found: {nlines}")
304 def _parse_task(self, task_line: str) -> None:
305 """Parse the 'task' line."""
306 task_code = self._unquote_string(task_line) + " "
307 self.env['task'] = FlagMaps.task.get(task_code[0])
308 self.env['beam_type'] = FlagMaps.beam_type.get(task_code[1])
309 self.env['_sbp_file'] = FlagMaps._sbp_file.get(task_code[2])
310 self.env['source_type'] = FlagMaps.source_type.get(task_code[3])
311 self.env['grid_type'] = FlagMaps.grid_type.get(task_code[4])
312 if self.env['_dimension'] == 2:
313 self.env['dimension'] = BHStrings.two_d
314 else:
315 self.env['dimension'] = FlagMaps.dimension.get(task_code[5])
317 if self.env["_sbp_file"] == BHStrings.from_file:
318 self.env["source_directionality"] = read_sbp(self.fname_base)
320 def _read_beams_limits(self, f: TextIO) -> None:
321 """Read environment file beams and limits"""
323 # Number of beams
324 beam_num_line = _read_next_valid_line(f)
325 beam_num_parts = _parse_line(beam_num_line, none_pad=1)
326 self.env['beam_num'] = int(beam_num_parts[0] or 0)
327 if self.env["_single_beam"]: # defensive in case there is a spurious value in here
328 self.env['single_beam_index'] = _int(beam_num_parts[1])
330 # Beam angles (beam_angle_min, beam_angle_max)
331 angles_line = _read_next_valid_line(f)
332 angle_parts = _parse_line(angles_line, none_pad=2)
333 self.env['beam_angle_min'] = _float(angle_parts[0])
334 self.env['beam_angle_max'] = _float(angle_parts[1])
336 if self.env['_dimension'] == 3:
337 # Beam bearing fan
338 beam_num_line = _read_next_valid_line(f)
339 beam_num_parts = _parse_line(beam_num_line, none_pad=1)
340 self.env['beam_angle_num'] = int(beam_num_parts[0] or 0)
341 angles_line = _read_next_valid_line(f)
342 angle_parts = _parse_line(angles_line, none_pad=2)
343 self.env['beam_bearing_min'] = _float(angle_parts[0])
344 self.env['beam_bearing_max'] = _float(angle_parts[1])
346 # Ray tracing limits (step, max_depth, max_range) - last line
347 limits_line = _read_next_valid_line(f)
348 limits_parts = _parse_line(limits_line)
349 self.env['step_size'] = _float(limits_parts[0])
350 if self.env['_dimension'] == 2:
351 self.env['simulation_depth'] = _float(limits_parts[1])
352 self.env['simulation_range'] = _float(limits_parts[2], 1000.0) # convert km to m
353 else:
354 self.env['simulation_range'] = _float(limits_parts[1], 1000.0) # convert km to m
355 self.env['simulation_cross_range'] = _float(limits_parts[2], 1000.0) # convert km to m
356 self.env['simulation_depth'] = _float(limits_parts[3])
358 def _read_gaussian_params(self, f: TextIO) -> None:
359 """Read parameters for Cerveny Gaussian Beams, if applicable"""
360 if self.env['beam_type'] not in (BHStrings.gaussian_simple, BHStrings.ray):
361 return None
362 line = _read_next_valid_line(f)
363 parts = _parse_line(line, none_pad=3)
364 assert isinstance(parts[0],str)
365 self.env['beam_width_type'] = self._unquote_string(parts[0])
366 self.env['beam_epsilon_multipler'] = _float(parts[1])
367 self.env['beam_range_loop'] = _float(parts[2],1000)
369 line = _read_next_valid_line(f)
370 parts = _parse_line(line, none_pad=3)
371 self.env['beam_images_num'] = _int(parts[0])
372 self.env['beam_window'] = _int(parts[1])
373 self.env['beam_component'] = self._unquote_string(parts[2]) if parts[2] is not None else " "
375 def _opt_lookup(self, name: str, opt: str, _map: dict[str, BHStrings]) -> str | None:
376 opt_str = _map.get(opt)
377 if opt_str is None:
378 raise ValueError(f"{name} option {opt!r} not available")
379 return opt_str
381 def _unquote_string(self, line: str) -> str:
382 """Extract string from within single quotes, possibly with commas too."""
383 return line.strip().strip(",'")
385########################################
387def read_ssp(fname: str,
388 depths: list[float] | NDArray[np.float64] | pd.DataFrame | None = None
389 ) -> NDArray[np.float64] | pd.DataFrame:
390 """Read a 2D sound speed profile (.ssp) file used by BELLHOP.
392 This function reads BELLHOP's .ssp files which contain range-dependent
393 sound speed profiles. The file format is:
394 - Line 1: Number of range profiles (NPROFILES)
395 - Line 2: Range coordinates in km (space-separated)
396 - Line 3+: Sound speed values, one line per depth point across all ranges
398 Parameters
399 ----------
400 fname : str
401 Path to .ssp file (with or without .ssp extension)
403 Returns
404 -------
405 numpy.ndarray or pandas.DataFrame
406 For single-profile files: numpy array with [depth, soundspeed] pairs;
407 for multi-profile files: pandas DataFrame with range-dependent sound speed data
409 Notes
410 -----
411 **Return format:**
413 - **Single-profile files (1 range)**: Returns a 2D numpy array with [depth, soundspeed] pairs,
414 compatible with the `Environment()` soundspeed parameter.
416 - **Multi-profile files (>1 ranges)**: Returns a pandas DataFrame where:
418 - **Columns**: Range coordinates (in meters, converted from km in file)
419 - **Index**: Depth indices (0, 1, 2, ... for each depth level in the file)
420 - **Values**: Sound speeds (m/s)
422 This DataFrame can be directly assigned to the `Environment()` soundspeed parameter
423 for range-dependent acoustic modeling.
425 **Note on depths**: For multi-profile files, depth indices are used (0, 1, 2, ...)
426 since the actual depth coordinates come from the associated BELLHOP .env file.
427 Users can modify the DataFrame index if actual depth values are known.
429 Examples
430 --------
431 >>> import aubellhop as bh
432 >>> # Single-profile file
433 >>> ssp1 = bh.read_ssp("single_profile.ssp") # Returns numpy array
434 >>> env = bh.Environment()
435 >>> env["soundspeed"] = ssp1
436 >>>
437 >>> # Multi-profile file
438 >>> ssp2 = bh.read_ssp("tests/MunkB_geo_rot/MunkB_geo_rot.ssp") # Returns DataFrame
439 >>> env = bh.Environment()
440 >>> env["soundspeed"] = ssp2 # Range-dependent sound speed
442 **File format example:**
444 ::
446 30
447 -50 -5 -1 -.8 -.75 -.6 -.4 -.2 0 0.2 0.4 0.6 0.8 1.0 1.2 1.4 1.6 1.8 2.0 2.2 2.4 2.6 2.8 3.0 3.2 3.4 3.6 3.8 4.0 10.0
448 1500 1500 1548.52 1530.29 1526.69 1517.78 1509.49 1504.30 1501.38 1500.14 1500.12 1501.02 1502.57 1504.62 1507.02 1509.69 1512.55 1515.56 1518.67 1521.85 1525.10 1528.38 1531.70 1535.04 1538.39 1541.76 1545.14 1548.52 1551.91 1551.91
449 1500 1500 1548.52 1530.29 1526.69 1517.78 1509.49 1504.30 1501.38 1500.14 1500.12 1501.02 1502.57 1504.62 1507.02 1509.69 1512.55 1515.56 1518.67 1521.85 1525.10 1528.38 1531.70 1535.04 1538.39 1541.76 1545.14 1548.52 1551.91 1551.91
450 """
452 fname, _ = _prepare_filename(fname, FileExt.ssp, "SSP")
453 with open(fname, 'r') as f:
454 nranges = int(_read_next_valid_line(f))
455 range_line = _read_next_valid_line(f)
456 ranges = np.array([_float(x) for x in _parse_line(range_line)])
457 ranges_m = ranges * 1000 # Convert ranges from km to meters (as expected by Environment())
459 if len(ranges) != nranges:
460 raise ValueError(f"Expected {nranges} ranges, but found {len(ranges)}")
462 # Read sound speed data - read all remaining lines as a matrix
463 ssp_data = []
464 line_num = 0
465 for line in f:
466 line_num += 1
467 line = line.replace(","," ").strip()
468 if line: # Skip empty lines
469 values = [_float(x) for x in line.split()]
470 if len(values) != nranges:
471 raise ValueError(f"SSP line {line_num} has {len(values)} range values, expected {nranges}")
472 ssp_data.append(values)
474 ssp_array = np.array(ssp_data)
475 ndepths = ssp_array.shape[0]
477 # Create depth indices (actual depths would normally come from associated .env file)
478 if depths is None:
479 depths = np.arange(ndepths, dtype=float)
481 if ndepths == 0 or len(depths) != ndepths:
482 raise ValueError("Wrong number of depths found in sound speed data file"
483 f" (expected {ndepths}, found {ssp_array.shape[0]})")
485 df = pd.DataFrame(ssp_array, index=pd.Index(depths), columns=ranges_m)
486 df.index.name = "depth"
487 return df
489def read_ssp_3d(fname: str) -> dict[str, np.ndarray]:
490 """Read a 3D sound speed profile (.ssp) file used by BELLHOP3D and return a 3D Numpy array.
492 This function reads BELLHOP's .ssp files which contain range-dependent
493 sound speed profiles. The file format is:
494 - Line 1: Nx: Number of coordinates in the range (x) direction
495 - Line 2: Range coordinates in km (space-separated)
496 - Line 3: Ny: Number of coordinates in the crossrange (y) direction
497 - Line 4: Crossrange coordinates in km (space-separated)
498 - Line 5: Nz: Number of coordinates in the depth (z) direction
499 - Line 6: Depth coordinates in km (space-separated)
500 - Line 7+: Sound speed values, one line per crossrange and depth across all ranges
501 (i.e., each line has Nx number of values and there are Ny*Nz lines)
502 """
504 fname, _ = _prepare_filename(fname, FileExt.ssp, "SSP")
505 with open(fname, 'r') as f:
507 nranges = int(_read_next_valid_line(f))
508 range_line = _read_next_valid_line(f)
509 ncrossranges = int(_read_next_valid_line(f))
510 crossrange_line = _read_next_valid_line(f)
511 ndepths = int(_read_next_valid_line(f))
512 depth_line = _read_next_valid_line(f)
514 ranges = np.array([_float(x) for x in _parse_line(range_line)])
515 crossranges = np.array([_float(x) for x in _parse_line(crossrange_line)])
516 depths = np.array([_float(x) for x in _parse_line(depth_line)])
518 # Convert ranges from km to meters (as expected by Environment())
519 ranges_m = ranges * 1000
520 crossranges_m = crossranges * 1000
521 depths_m = depths * 1000
523 if len(ranges) != nranges:
524 raise ValueError(f"Expected {nranges} ranges, but found {len(ranges)}")
525 if len(crossranges) != ncrossranges:
526 raise ValueError(f"Expected {ncrossranges} ranges, but found {len(crossranges)}")
527 if len(depths) != ndepths:
528 raise ValueError(f"Expected {ndepths} ranges, but found {len(depths)}")
530 # Read sound speed data - read all remaining lines as a matrix
531 ssp_data = []
532 line_num = 0
533 for line in f:
534 line_num += 1
535 line = line.replace(","," ").strip()
536 if line: # Skip empty lines
537 values = [_float(x) for x in line.split()]
538 if len(values) != nranges:
539 raise ValueError(f"SSP line {line_num} has {len(values)} range values, expected {nranges}")
540 ssp_data.append(values)
542 ssp_array = np.array(ssp_data)
543 narray = ssp_array.shape[0]
544 if narray != ncrossranges * ndepths:
545 raise ValueError("Wrong number of entries found in sound speed data file"
546 f" (expected {ncrossranges} * {ndepths} = {ncrossranges * ndepths} lines, found {narray})")
548 ssp_3d = ssp_array.reshape(ndepths, ncrossranges, nranges, order="C")
549 ssp_xyz = np.transpose(ssp_3d, (2, 1, 0))
551 return {"ssp": ssp_xyz, "ranges": ranges_m, "crossranges": crossranges_m, "depths": depths_m}
553def read_ati(fname: str) -> tuple[NDArray[np.float64], str]:
554 """Read an altimetry file used by Bellhop."""
555 fname, _ = _prepare_filename(fname, FileExt.ati, "ATI")
556 return _read_ati_bty(fname)
558def read_bty(fname: str) -> tuple[NDArray[np.float64], str]:
559 """Read a bathymetry file used by Bellhop."""
560 fname, _ = _prepare_filename(fname, FileExt.bty, "BTY")
561 return _read_ati_bty(fname)
563def _read_ati_bty(fname: str) -> tuple[NDArray[np.float64], str]:
564 """Read an altimetry (.ati) or bathymetry (.bty) file used by BELLHOP.
566 This function reads BELLHOP's .bty files which define the bottom depth
567 profile. The file format is:
568 - Line 1: Interpolation type ('L' for linear, 'C' for curvilinear)
569 - Line 2: Number of points
570 - Line 3+: Range (km) and depth (m) pairs
572 Parameters
573 ----------
574 fname : str
575 Path to .ati/.bty file (with extension)
577 Returns
578 -------
579 numpy.ndarray
580 Numpy array with [range, depth] pairs compatible with Environment()
582 Notes
583 -----
584 The returned array can be assigned to env["bottom_depth"] for range-dependent bathymetry.
586 **Examples:**
588 >>> import aubellhop as bh
589 >>> bty,bty_interp = bh.read_bty("tests/MunkB_geo_rot/MunkB_geo_rot.bty")
590 >>> env = bh.Environment()
591 >>> env["bottom_depth"] = bty
592 >>> env["bottom_interp"] = bty_interp
593 >>> arrivals = bh.calculate_arrivals(env)
595 **File format example:**
597 ::
599 'L'
600 5
601 0 3000
602 10 3000
603 20 500
604 30 3000
605 100 3000
606 """
608 with open(fname, 'r') as f:
609 # Read interpolation type ('L' or 'C')
610 interp_type = _read_next_valid_line(f).strip("'\"")
611 nvalues = 2
612 if len(interp_type) > 1:
613 nvalues = 7 if interp_type[1] == "L" else 2
614 interp_type = interp_type[0]
615 npoints = int(_read_next_valid_line(f))
616 ranges = []
617 depths = []
618 wave_speed = []
619 wave_attenuation = []
620 density = []
621 shear_speed = []
622 shear_attenuation = []
623 for i in range(npoints):
624 try:
625 line = _read_next_valid_line(f)
626 except EOFError:
627 break
628 parts = _parse_line(line)
629 if nvalues == 2 and len(parts) >= 2:
630 ranges.append(_float(parts[0])) # Range in km
631 depths.append(_float(parts[1])) # Depth in m
632 elif nvalues == 7 and len(parts) == 7:
633 ranges.append(_float(parts[0])) # Range in km
634 depths.append(_float(parts[1])) # Depth in m
635 wave_speed.append(_float(parts[2])) #
636 wave_attenuation.append(_float(parts[3])) #
637 density.append(_float(parts[4])) #
638 shear_speed.append(_float(parts[5])) #
639 shear_attenuation.append(_float(parts[6])) #
641 if len(ranges) != npoints:
642 raise ValueError(f"Expected {npoints} altimetry/bathymetry points, but found {len(ranges)}")
644 # Convert ranges from km to m for consistency with bellhop env structure
645 ranges_m = np.array(ranges) * 1000
646 depths_array = np.array(depths)
647 if nvalues == 2:
648 val_array = [ranges_m, depths_array]
649 elif nvalues == 7:
650 wave_speed_array = np.array(wave_speed)
651 wave_attenuation_array = np.array(wave_attenuation)
652 density_array = np.array(density)
653 shear_speed_array = np.array(shear_speed)
654 shear_attenuation_array = np.array(shear_attenuation)
655 val_array = [
656 ranges_m,
657 depths_array,
658 wave_speed_array,
659 wave_attenuation_array,
660 density_array,
661 shear_speed_array,
662 shear_attenuation_array,
663 ]
664 return np.column_stack(val_array), FlagMaps.bottom_interp[interp_type]
666def read_ati_3d(fname: str) -> dict[str, np.ndarray]:
667 """Read an altimetry file used by Bellhop."""
668 fname, _ = _prepare_filename(fname, FileExt.ati, "ATI")
669 return _read_ati_bty3d(fname)
671def read_bty_3d(fname: str) -> dict[str, np.ndarray]:
672 """Read a bathymetry file used by Bellhop."""
673 fname, _ = _prepare_filename(fname, FileExt.bty, "BTY")
674 return _read_ati_bty3d(fname)
676def _read_ati_bty3d(fname: str) -> dict[str, NDArray[np.float64]]:
677 """Read an altimetry (.ati) or bathymetry (.bty) file used by BELLHOP.
679 This function reads BELLHOP's .bty files which define the bottom depth
680 profile. The file format is:
681 - Line 1: Interpolation type ('R' always for Bellhop3D)
682 - Line 2: Nx: Number of range points
683 - Line 3: Range coordinates (x)
684 - Line 4: Ny: Number of crossrange points
685 - Line 5: Crossrange points (y)
686 - Line 6+: 2D array of depths. Ny lines, one per crossrange, with Nx depths per line (space separated)
688 Parameters
689 ----------
690 fname : str
691 Path to .ati/.bty file (with extension)
693 Returns
694 -------
695 dict
696 Dictionary of ranges, crossranges, and 2D depth data
698 Notes
699 -----
700 The returned array can be assigned to env["bottom_depth"] for range-dependent bathymetry.
702 """
704 with open(fname, 'r') as f:
705 interp_type = _read_next_valid_line(f).strip("'\"")
706 if interp_type != "R":
707 raise ValueError("3D depth files (.ati/.bty) can only have 'R' = regular grid as the first line")
709 nranges = int(_read_next_valid_line(f))
710 range_line = _read_next_valid_line(f)
711 ncrossranges = int(_read_next_valid_line(f))
712 crossrange_line = _read_next_valid_line(f)
714 ranges = np.array([_float(x) for x in _parse_line(range_line)])
715 crossranges = np.array([_float(x) for x in _parse_line(crossrange_line)])
717 # Convert ranges from km to meters (as expected by Environment())
718 ranges_m = ranges * 1000
719 crossranges_m = crossranges * 1000
721 depth_data = []
722 line_num = 0
723 for line in f:
724 line_num += 1
725 line = line.replace(","," ").strip()
726 if line: # Skip empty lines
727 values = [_float(x) for x in line.split()]
728 if len(values) != nranges:
729 raise ValueError(f"Depth line {line_num} has {len(values)} depth values, expected {nranges}")
730 depth_data.append(values)
732 depths = np.array(depth_data).transpose()
733 if depths.shape[0] != nranges:
734 raise ValueError("Wrong number of range entries found in depth file"
735 f" (expected {nranges}, found {depths.shape[0]})")
736 if depths.shape[1] != ncrossranges:
737 raise ValueError("Wrong number of crossrange entries found in depth file"
738 f" (expected {ncrossranges}, found {depths.shape[1]})")
740 return {"depths": depths, "ranges": ranges_m, "crossranges": crossranges_m}
743def read_sbp(fname: str) -> NDArray[np.float64]:
744 """Read an source beam patterm (.sbp) file used by BELLHOP.
746 The file format is:
747 - Line 1: Number of points
748 - Line 2+: Angle (deg) and power (dB) pairs
750 Parameters
751 ----------
752 fname : str
753 Path to .sbp file (with or without extension)
755 Returns
756 -------
757 numpy.ndarray
758 Numpy array with [angle, power] pairs
759 """
761 fname, _ = _prepare_filename(fname, FileExt.sbp, "SBP")
762 with open(fname, 'r') as f:
764 # Read number of points
765 npoints = int(_read_next_valid_line(f))
767 # Read range,depth pairs
768 angles = []
769 powers = []
771 for i in range(npoints):
772 try:
773 line = _read_next_valid_line(f)
774 except EOFError:
775 break
776 parts = _parse_line(line)
777 assert isinstance(parts[0],str)
778 assert isinstance(parts[1],str)
779 if len(parts) >= 2:
780 angles.append(float(parts[0])) # Range in km
781 powers.append(float(parts[1])) # Depth in m
783 if len(angles) != npoints:
784 raise ValueError(f"Expected {npoints} points, but found {len(angles)}")
786 # Return as [range, depth] pairs
787 return np.column_stack([angles, powers])
789def read_brc(fname: str) -> NDArray[np.float64]:
790 """Read a BRC file and return array of reflection coefficients.
793 This function reads BELLHOP's .brc files which define the reflection coefficient
794 data. The file format is:
795 - Line 1: Number of points
796 - Line 2+: THETA(j) RMAG(j) RPHASE(j)
798 Where:
799 - THETA(): Angle (degrees)
800 - RMAG(): Magnitude of reflection coefficient
801 - RPHASE(): Phase of reflection coefficient (degrees)
803 Parameters
804 ----------
805 fname : str
806 Path to .brc/.trc file (extension required)
808 Returns
809 -------
810 numpy.ndarray
811 Numpy array with [theta, rmag, rphase] triplets compatible with Environment()
813 Notes
814 -----
815 The returned array can be assigned to `env["bottom_reflection_coefficient"]`.
816 The equivalent `read_trc()` data would be assigned to `env["surface_reflection_coefficient"]`.
818 File format example
819 -------------------
820 ::
822 3
823 0.0 1.00 180.0
824 45.0 0.95 175.0
825 90.0 0.90 170.0
827 Example
828 -------
829 >>> import aubellhop as bh
830 >>> import aubellhop.readers as bhr
831 >>> brc = bhr.read_refl_coeff("tests/MunkB_geo_rot/MunkB_geo_rot.brc")
832 >>> env = bh.Environment()
833 >>> env["bottom_reflection_coefficient"] = brc
834 >>> arrivals = bh.calculate_arrivals(env)
836 """
837 fname, _ = _prepare_filename(fname, FileExt.brc, "BRC")
838 return _read_refl_coeff(fname)
840def read_trc(fname: str) -> NDArray[np.float64]:
841 """Read a TRC file and return array of reflection coefficients.
843 See equivalent `read_brc` function for documentation."""
844 fname, _ = _prepare_filename(fname, FileExt.trc, "TRC")
845 return _read_refl_coeff(fname)
847def _read_refl_coeff(fname: str) -> NDArray[np.float64]:
848 """Read a reflection coefficient (.brc/.trc) file used by BELLHOP."""
850 with open(fname, 'r') as f:
852 # Read number of points
853 npoints = int(_read_next_valid_line(f))
855 # Read range,depth pairs
856 theta = []
857 rmagn = []
858 rphas = []
860 for i in range(npoints):
861 try:
862 line = _read_next_valid_line(f)
863 except EOFError:
864 break
865 parts = _parse_line(line)
866 if len(parts) != 3:
867 raise ValueError(f"Expected 3 reflection coefficient points, but found {len(parts)}")
868 assert isinstance(parts[0],str)
869 assert isinstance(parts[1],str)
870 assert isinstance(parts[2],str)
871 if len(parts) == 3:
872 theta.append(float(parts[0]))
873 rmagn.append(float(parts[1]))
874 rphas.append(float(parts[2]))
876 if len(theta) != npoints:
877 raise ValueError(f"Expected {npoints} reflection coefficient points, but found {len(theta)}")
879 # Return as [range, depth] pairs
880 return np.column_stack([theta, rmagn, rphas])
882def _read_next_valid_line(f: TextIO) -> str:
883 """Read the next valid text line of an input file, discarding empty content.
885 Parameters
886 ----------
887 f : File handle to read from
889 Returns
890 -------
891 Non-empty line with comments and whitespace removed
893 Raises
894 ------
895 EOFError: If end of file reached without finding valid content
896 """
897 while True:
898 raw_line = f.readline()
899 if not raw_line: # EOF
900 raise EOFError("End of file reached before finding a valid line")
901 line = raw_line.split('!', 1)[0].strip()
902 if line:
903 return line
905def _parse_line(line: str, none_pad: int = 0) -> list[str | None]:
906 """Parse a line, removing comments, /, and whitespace, and return the parts in a list"""
907 line = line.split("!", 1)[0].split('/', 1)[0].strip()
908 line = line.replace(","," ")
909 return [*line.split(), *([None] * none_pad)]
911def _parse_line_int(line: str) -> int | None:
912 """Parse an integer on a line by itself. Strip spurious comma(s)."""
913 parts = _parse_line(line)
914 if parts[0] is None:
915 return None
916 return int(parts[0])
918def _parse_vector(line: str) -> NDArray[np.float64] | float:
919 """Parse a vector of floats with unknown number of values. Strip commas if necessary."""
920 parts = _parse_line(line)
921 val = [float(str(p).strip(",")) for p in parts]
922 valout = np.array(val) if len(val) > 1 else val[0]
923 return valout
925def _float(x: str | None, scale: float = 1) -> float | None:
926 """Permissive float-enator with unit scaling"""
927 if x is None:
928 return None
929 return float(x.strip(",")) * scale
931def _int(x: Any) -> int | None:
932 """Permissive int-enator"""
933 return None if x is None else int(x.strip(","))
935def _prepare_filename(fname: str, ext: str, name: str) -> tuple[str,str]:
936 """Checks filename is present and file exists."""
937 if fname.endswith(ext):
938 nchar = len(ext)
939 fname_base = fname[:-nchar]
940 else:
941 fname_base = fname
942 fname = fname + ext
944 if not os.path.exists(fname):
945 raise FileNotFoundError(f"{name} file not found: {fname}")
947 return fname, fname_base
949################################
951def read_arrivals(fname: str) -> pd.DataFrame:
952 """Read Bellhop arrivals file and parse data into a high level data structure"""
953 reader = BellhopOutputReader(fname)
954 return reader.read_arrivals()
956def read_rays(fname: str, **kwargs) -> pd.DataFrame:
957 """Read Bellhop rays file and parse data into a high level data structure"""
958 reader = BellhopOutputReader(fname)
959 return reader.read_rays(**kwargs)
961def read_shd(fname: str) -> pd.DataFrame:
962 """Read Bellhop shd file and parse data into a high level data structure"""
963 reader = BellhopOutputReader(fname)
964 return reader.read_shd()
966class BellhopOutputReader:
967 """Read and parse Bellhop output files."""
969 def __init__(self, filename: str):
970 """Initialize reader with filename.
972 Parameters
973 ----------
974 filename: Path to file (with extension)
975 """
976 self.filename = filename
977 self.filepath = self._ensure_file_exists(filename)
979 def read_arrivals(self) -> pd.DataFrame:
980 """Read Bellhop arrivals file and parse data into a high level data structure"""
981 with self.filepath.open('rt') as f:
982 hdr = f.readline()
983 if hdr.find('2D') >= 0:
984 freq = self._read_array(f, (float,))
985 source_depth_info = self._read_array(f, (int,), float)
986 source_depth_count = source_depth_info[0]
987 source_depth = source_depth_info[1:]
988 assert source_depth_count == len(source_depth)
989 receiver_depth_info = self._read_array(f, (int,), float)
990 receiver_depth_count = receiver_depth_info[0]
991 receiver_depth = receiver_depth_info[1:]
992 assert receiver_depth_count == len(receiver_depth)
993 receiver_range_info = self._read_array(f, (int,), float)
994 receiver_range_count = receiver_range_info[0]
995 receiver_range = receiver_range_info[1:]
996 assert receiver_range_count == len(receiver_range)
997 # else: # worry about 3D later
998 # freq, source_depth_count, receiver_depth_count, receiver_range_count = _read_array(hdr, (float, int, int, int))
999 # source_depth = _read_array(f, (float,)*source_depth_count)
1000 # receiver_depth = _read_array(f, (float,)*receiver_depth_count)
1001 # receiver_range = _read_array(f, (float,)*receiver_range_count)
1002 arrivals: list[pd.DataFrame] = []
1003 for j in range(source_depth_count):
1004 f.readline()
1005 for k in range(receiver_depth_count):
1006 for m in range(receiver_range_count):
1007 count = int(f.readline())
1008 for n in range(count):
1009 data = self._read_array(f, (float, float, float, float, float, float, int, int))
1010 arrivals.append(pd.DataFrame({
1011 'source_depth_ndx': [j],
1012 'receiver_depth_ndx': [k],
1013 'receiver_range_ndx': [m],
1014 'source_depth': [source_depth[j]],
1015 'receiver_depth': [receiver_depth[k]],
1016 'receiver_range': [receiver_range[m]],
1017 'arrival_number': [n],
1018 # 'arrival_amplitude': [data[0]*np.exp(1j * data[1]* np.pi/180)],
1019 'arrival_amplitude': [data[0] * np.exp( -1j * (np.deg2rad(data[1]) + freq[0] * 2 * np.pi * (data[3] * 1j + data[2])))],
1020 'time_of_arrival': [data[2]],
1021 'complex_time_of_arrival': [data[2] + 1j*data[3]],
1022 'angle_of_departure': [data[4]],
1023 'angle_of_arrival': [data[5]],
1024 'surface_bounces': [data[6]],
1025 'bottom_bounces': [data[7]]
1026 }, index=pd.Index([len(arrivals)+1])))
1027 return pd.concat(arrivals)
1029 def read_shd(self) -> pd.DataFrame:
1030 """Read Bellhop shd file and parse data into a high level data structure"""
1031 with self.filepath.open('rb') as f:
1032 recl, = _unpack('i', f.read(4))
1033 # _title = str(f.read(80))
1034 f.seek(4*recl, 0)
1035 ptype = f.read(10).decode('utf8').strip()
1036 assert ptype == 'rectilin', f'Invalid file format (expecting {ptype} == "rectilin")'
1037 f.seek(8*recl, 0)
1038 nfreq, ntheta, nsx, nsy, nsd, nrd, nrr, atten = _unpack('iiiiiiif', f.read(32))
1039 assert nfreq == 1, 'Invalid file format (expecting nfreq == 1)'
1040 assert ntheta == 1, 'Invalid file format (expecting ntheta == 1)'
1041 assert nsd == 1, 'Invalid file format (expecting nsd == 1)'
1042 f.seek(32*recl, 0)
1043 pos_r_depth = _unpack('f'*nrd, f.read(4*nrd))
1044 f.seek(36*recl, 0)
1045 pos_r_range = _unpack('f'*nrr, f.read(4*nrr))
1046 pressure = np.zeros((nrd, nrr), dtype=np.complex128)
1047 for ird in range(nrd):
1048 recnum = 10 + ird
1049 f.seek(recnum*4*recl, 0)
1050 temp = np.array(_unpack('f'*2*nrr, f.read(2*nrr*4)))
1051 pressure[ird,:] = temp[::2] + 1j*temp[1::2]
1052 return pd.DataFrame(pressure, index=pd.Index(pos_r_depth), columns=pd.Index(pos_r_range))
1054 def read_rays(self, dim: int | None = None) -> pd.DataFrame:
1055 """
1056 Read Bellhop rays file and parse data into a high level data structure
1058 Parameters
1059 ----------
1060 dim : {2, 3} or None
1061 Spatial dimension. If None, inferred from the file header.
1063 Raises
1064 ------
1065 ValueError: if is_2d is not provided and parsing file header fails.
1066 """
1067 if dim not in (None, 2, 3):
1068 raise ValueError(f"Invalid dim={dim}; expected 2 or 3")
1070 with self.filepath.open('rt') as f:
1071 hdr = f.readline()
1072 if dim is None:
1073 if 'BELLHOP-' in hdr:
1074 dim = 2
1075 elif 'BELLHOP3D-' in hdr:
1076 dim = 3
1077 else:
1078 raise ValueError("Unable to infer dimension from Bellhop header in .ray file. Use `dim=2` or `dim=3` accordingly to specify explicitly.")
1080 f.readline() # freq
1081 f.readline() # 1 1 1
1082 f.readline() # 50 50
1083 f.readline() # 0.0
1084 f.readline() # 25.0
1085 f.readline() # 'xyz'
1086 rays = []
1087 while True:
1088 s = f.readline()
1089 if s is None or len(s.strip()) == 0:
1090 break
1091 a = float(s)
1092 pts, sb, bb = self._read_array(f, (int, int, int))
1093 ray = np.empty((pts, dim))
1094 for k in range(pts):
1095 ray[k,:] = self._read_array(f, (float,))
1096 rays.append(pd.DataFrame({
1097 'angle_of_departure': [a],
1098 'surface_bounces': [sb],
1099 'bottom_bounces': [bb],
1100 'ray': [ray]
1101 }))
1102 return pd.concat(rays)
1104 def _ensure_file_exists(self, filename: str) -> Path:
1105 path = Path(filename)
1106 if not path.exists():
1107 raise RuntimeError(f"Bellhop did not generate expected output file: {path}")
1108 return path
1110 def _read_array(self, f: TextIO, types: tuple[Any, ...], dtype: type = str) -> tuple[Any, ...]:
1111 """Wrapper around readline() to read in a 1D array of data"""
1112 p = f.readline().split()
1113 for j in range(len(p)):
1114 if len(types) > j:
1115 p[j] = types[j](p[j])
1116 else:
1117 p[j] = dtype(p[j])
1118 return tuple(p)