Skip to content

Underwater propagation solvers

Standards: ISO 18405Key references: Jensen et al. 2011Munk 1974

The closed-form propagation loss of Underwater sound propagation knows nothing of the sound-speed profile, the seabed or the surface. When refraction and boundaries decide the answer, the field has to be computed: this guide covers the three numerical solvers of the underwater module, the physics each one discretises, and how to choose between them and the closed forms. All three assume a range-independent (horizontally stratified) ocean with a pressure-release surface, take the same profile as input, and follow Jensen, Kuperman, Porter & Schmidt, Computational Ocean Acoustics.

The range-depth waveguide all three solvers take as input: range increasing to the right and depth downward, a pressure-release sea surface, a bottom that is either pressure-release or rigid, the sound-speed profile drawn beside the column with its channel axis, a source at depth z_s, a receiver at depth z and range r, and one ray turning where the local sound speed satisfies Snell's condition, annotated with what each of the three solvers computes in that same frameThe range-depth waveguide all three solvers take as input: range increasing to the right and depth downward, a pressure-release sea surface, a bottom that is either pressure-release or rigid, the sound-speed profile drawn beside the column with its channel axis, a source at depth z_s, a receiver at depth z and range r, and one ray turning where the local sound speed satisfies Snell's condition, annotated with what each of the three solvers computes in that same frame
  • normal_modes solves the depth-separated Sturm-Liouville eigenvalue problem by finite differences and sums the propagating modes into the propagation loss. Validated against the ideal (pressure-release) waveguide’s exact modes.
  • ray_trace integrates the ray-trajectory equations (Runge-Kutta, vectorised over all rays at once) through a sound-speed profile, reflecting at the surface and bottom, and carries the travel time along each ray as a state of the same integration. Validated against the circular-arc paths of a linear gradient and the closed-form travel time along them.
  • parabolic_equation marches the standard (Tappert) PE with the split-step Fourier algorithm. Validated against free-field spherical spreading; it agrees with the normal-mode propagation loss in trend.
A Munk sound-speed profile, ray paths forming convergence zones, and normal-mode versus parabolic-equation propagation loss agreeing in trendA Munk sound-speed profile, ray paths forming convergence zones, and normal-mode versus parabolic-equation propagation loss agreeing in trend

Three panels, three different questions about the same water. Left, rays in a Munk profile whose minimum is 1500.0 m/s at 1271 m against 1548.5 m/s at the surface: a 48 m/s spread is enough to fold the ±12° fan back on itself into convergence zones, and the ±12° ray still sweeps from 143 m to 3997 m of depth. Centre, the same environment as a PE field, where the zones appear as interference rather than as lines. Right, the two solvers on a different, 200 m shallow waveguide: they track the same trend with a mean offset of 1.6 dB, but the per-range difference reaches 25 dB at the interference nulls, which is the honest statement of what “agree in trend” means.

Show the code for this figure
import matplotlib.pyplot as plt
import numpy as np
from phonometry import underwater
# A Munk deep-water sound-speed profile for the ray and PE panels.
z = np.linspace(0.0, 5000.0, 60)
eta = 2.0 * (z - 1300.0) / 1300.0
c = 1500.0 * (1.0 + 0.00737 * (eta - 1.0 + np.exp(-eta)))
fig, axes = plt.subplots(1, 3, figsize=(16, 5))
# (a) rays through the Munk profile, out to 100 km.
underwater.ray_trace(z, c, source_depth=1000.0,
launch_angles_deg=np.linspace(-12.0, 12.0, 13),
max_range=100e3, n_steps=6000).plot(ax=axes[0])
# (b) the split-step Fourier PE field over the same environment.
underwater.parabolic_equation(50.0, z, c, source_depth=1000.0,
max_range=100e3, range_step=25.0,
n_depth_points=1024).plot(ax=axes[1])
# (c) modes against PE in a *different*, shallow 200 m waveguide.
r = np.linspace(100.0, 20_000.0, 400)
nm = underwater.normal_modes(50.0, [0.0, 200.0], [1500.0, 1530.0],
source_depth=30.0, receiver_depth=120.0,
ranges_m=r, n_depth_points=800)
pe = underwater.parabolic_equation(50.0, [0.0, 200.0], [1500.0, 1530.0],
source_depth=30.0, max_range=20e3,
range_step=20.0, n_depth_points=512)
zi = int(np.argmin(np.abs(pe.depths - 120.0)))
axes[2].plot(nm.ranges / 1000.0, nm.propagation_loss, label="Normal modes")
axes[2].plot(pe.ranges / 1000.0, pe.propagation_loss[zi], label="PE")
axes[2].invert_yaxis()
axes[2].set(xlabel="Range [km]", ylabel="Propagation loss [dB]")
axes[2].legend()
plt.show()
import numpy as np
from phonometry import underwater
# A Munk deep-water profile.
z = np.linspace(0.0, 5000.0, 60)
eta = 2.0 * (z - 1300.0) / 1300.0
c = 1500.0 * (1.0 + 0.00737 * (eta - 1.0 + np.exp(-eta)))
rays = underwater.ray_trace(z, c, source_depth=1000.0,
launch_angles_deg=np.linspace(-12.0, 12.0, 21), max_range=100e3)
rays.plot() # ray paths / convergence zones (needs matplotlib)
# Shallow isovelocity waveguide: modes and PE.
modes = underwater.normal_modes(50.0, [0.0, 200.0], [1500.0, 1500.0],
source_depth=50.0, receiver_depth=100.0)
print(modes.wavenumbers.size, "propagating modes")
field = underwater.parabolic_equation(50.0, [0.0, 200.0], [1500.0, 1500.0],
source_depth=50.0, max_range=20e3)
field.plot() # PL field over range x depth (needs matplotlib)

normal_modes returns a NormalModeResult (wavenumbers, mode_functions, propagation_loss); ray_trace a RayTraceResult (ranges, depths and travel_times per ray); parabolic_equation a ParabolicEquationResult (the propagation_loss field). The three differ in what they approximate, not in what they model, so choosing between them is a regime question — the table in section 5 is the short answer.

2. Normal modes: the waveguide as a sum of standing waves

Section titled “2. Normal modes: the waveguide as a sum of standing waves”

In a horizontally stratified ocean the Helmholtz equation separates in cylindrical coordinates, , and the depth factor obeys a Sturm-Liouville eigenvalue problem (Jensen Eq. 5.3):

with a pressure-release surface at and, at the bottom , for a pressure-release bed or for a rigid one. Each eigenfunction is a standing wave in depth that travels in range as with its own horizontal wavenumber ; only the modes with real propagate, the rest are evanescent and die within a few water depths. The field is the modal sum (Eq. 5.14),

each mode weighted by its excitation at the source depth and its amplitude at the receiver depth , and the coherent propagation loss follows as (Eq. 5.15). normal_modes discretises the depth equation by finite differences (a symmetric tridiagonal eigenproblem) on a grid refined enough to keep the near-cutoff eigenvalues honest, and warns when a retained mode sits too close to its discretisation error band.

The mode count is the physics: an isovelocity channel of depth carries propagating modes, so a 200 m channel at 50 Hz carries 13 and the same channel at 5 kHz would carry over 1300 — which is the frequency at which rays become the economical description. Low frequency and shallow water mean few modes and a compact, essentially exact description. The ideal pressure-release waveguide is the validation oracle, with in closed form:

import numpy as np
from phonometry import underwater
# A 200 m isovelocity channel at 50 Hz: kD/pi = 2 f D / c = 13.3.
modes = underwater.normal_modes(50.0, [0.0, 200.0], [1500.0, 1500.0],
source_depth=50.0, receiver_depth=100.0)
print(modes.wavenumbers.size) # 13 propagating modes
k = 2 * np.pi * 50.0 / 1500.0
print(round(float(modes.wavenumbers[0]), 5)) # 0.20885 computed kr1
print(round(np.sqrt(k**2 - (np.pi / 200.0) ** 2), 5)) # 0.20885 exact
Left, the first four mode functions of a 200 metre isovelocity waveguide at 50 hertz drawn against depth, each labelled with its computed and exact horizontal wavenumber, with the 50 metre source depth marked; centre, the number of propagating modes against frequency from 10 to 200 hertz as a staircase following the kD over pi line; right, the modal propagation loss against range at the 100 metre receiver depthLeft, the first four mode functions of a 200 metre isovelocity waveguide at 50 hertz drawn against depth, each labelled with its computed and exact horizontal wavenumber, with the 50 metre source depth marked; centre, the number of propagating modes against frequency from 10 to 200 hertz as a staircase following the kD over pi line; right, the modal propagation loss against range at the 100 metre receiver depth

Mode has interior nulls, and the source depth decides which modes exist in the field at all: the fourth mode has a null at exactly 50 m in a 200 m channel, so a source there does not excite it. The staircase in the middle is the mode count cutting on one mode at a time as frequency rises, tracking ; the right-hand panel is the interference of the 13 modes that survive at 50 Hz. A computed result draws all three with modes.plot().

Show the code for this figure
import matplotlib.pyplot as plt
import numpy as np
fig, axes = plt.subplots(1, 3, figsize=(16, 5.4))
r = np.linspace(100.0, 20_000.0, 500)
res = underwater.normal_modes(50.0, [0.0, 200.0], [1500.0, 1500.0],
source_depth=50.0, receiver_depth=100.0,
ranges_m=r, n_depth_points=800)
for m in range(4):
axes[0].plot(res.mode_functions[m], res.mode_depths, label=f"m = {m + 1}")
axes[0].axhline(50.0, linestyle="--")
axes[0].invert_yaxis()
axes[0].set(xlabel="Mode function Psi_m(z)", ylabel="Depth [m]")
axes[0].legend()
freqs = np.arange(10.0, 201.0, 1.0)
counts = [underwater.normal_modes(f, [0.0, 200.0], [1500.0, 1500.0],
source_depth=50.0, receiver_depth=100.0,
n_depth_points=1200).wavenumbers.size
for f in freqs]
axes[1].step(freqs, counts, where="post", label="modes returned")
axes[1].plot(freqs, 2.0 * freqs * 200.0 / 1500.0, "--", label="M = kD/pi")
axes[1].set(xlabel="Frequency [Hz]", ylabel="Propagating modes M")
axes[1].legend()
res.plot(ax=axes[2])
plt.show()

That ideal waveguide plays two roles, and they should not be confused. It is the exact oracle the solver is checked against — which is why it is used here — and it is also a physically extreme bottom: a pressure-release bed reflects with unit magnitude and a phase inversion, so it transmits nothing into the sediment and absorbs nothing. A modal or PE propagation loss computed over it therefore understates the loss of any real seabed, and the gap widens with range because it accumulates over bounces. The rigid option is the opposite extreme, offered for the same validation reason and not as a sediment model. For a real bottom in shallow water, price the seabed with the Weston regimes of Underwater sound propagation, whose critical angle and reflection-loss gradient encode exactly what these boundary conditions omit, and read the modal answer as an upper bound on the received level.

3. Ray tracing: turning points and travel times

Section titled “3. Ray tracing: turning points and travel times”

In the high-frequency limit the Helmholtz equation collapses to the eikonal equation, and its characteristics are rays: trajectories integrated from the first-order system (Jensen Eqs. 3.23-3.24)

with the arc length and the ray slowness. In a range-independent profile the horizontal slowness is conserved along each ray, which is Snell’s law in continuous form, : a ray bends toward lower sound speed, flattens as grows, and turns where . In a linear gradient the arcs are exactly circular with radius , the closed form the solver is validated against; in a deep-water profile the family of rays refocuses periodically into the convergence zones of the section 1 figure. ray_trace integrates all launch angles at once with a fixed-step fourth-order Runge-Kutta scheme, reflecting at the surface and the bottom, and carries the travel time along with them as a third state of the same step (, with the Snell invariant):

import numpy as np
from phonometry import underwater
# An isothermal deep layer: c rises 0.017 (m/s)/m with pressure, so a ray
# launched 6 degrees downward from 100 m turns back up where Snell gives
# c(z_t) = c(z_s)/cos(6 deg).
z = [0.0, 1000.0]
c = [1490.0, 1507.0] # linear gradient, g = 0.017 1/s
rays = underwater.ray_trace(z, c, source_depth=100.0,
launch_angles_deg=[6.0], max_range=40e3,
n_steps=20000)
z_turn = (1491.7 / np.cos(np.radians(6.0)) - 1490.0) / 0.017
print(round(z_turn, 1)) # 583.3 analytic turning depth
print(round(float(rays.depths.max()), 1)) # 583.3 traced
# The arc also fixes the time to that turn: the sound speed cancels and only
# the launch angle and the gradient are left,
# t = (1/g) ln[(1 + sin theta_0) / cos theta_0].
th = np.radians(6.0)
r_turn = np.sin(th) / (np.cos(th) / 1491.7 * 0.017) # 9222.6 m
t_turn = np.log((1.0 + np.sin(th)) / np.cos(th)) / 0.017
print(round(t_turn, 3)) # 6.171 analytic
traced = np.interp(r_turn, rays.ranges[0], rays.travel_times[0])
print(round(float(traced), 3)) # 6.171 traced
Left, a linear sound-speed gradient rising from 1490 metres per second at the surface; right, five rays launched at 2, 4, 6, 8 and 10 degrees downward from a source at 100 metres, each turning at its own analytic turning depth marked with a cross, with the exact circular arc of the linear gradient overdrawn dashed on the 6 degree ray and its analytic and traced turning depths annotatedLeft, a linear sound-speed gradient rising from 1490 metres per second at the surface; right, five rays launched at 2, 4, 6, 8 and 10 degrees downward from a source at 100 metres, each turning at its own analytic turning depth marked with a cross, with the exact circular arc of the linear gradient overdrawn dashed on the 6 degree ray and its analytic and traced turning depths annotated

Steeper launch means deeper turn: in the same water the 2° ray turns at 154 m and the 10° ray at 1454 m. The 6° ray carries the page’s own validation, the 583.3 m analytic turning depth, which the trace reproduces to the tenth of a metre it prints, and the dashed overlay is the exact circular arc of radius the solver is checked against. A result draws its own fan with rays.plot().

Show the code for this figure
import matplotlib.pyplot as plt
import numpy as np
c_top, grad, z_source = 1490.0, 0.017, 100.0
c_source = c_top + grad * z_source
fig, (ax_c, ax_r) = plt.subplots(1, 2, figsize=(13, 6),
gridspec_kw={"width_ratios": [1, 3.4]})
zz = np.linspace(0.0, 2000.0, 200)
ax_c.plot(c_top + grad * zz, zz)
ax_c.invert_yaxis()
ax_c.set(xlabel="c(z) [m/s]", ylabel="Depth [m]")
for angle in (2.0, 4.0, 6.0, 8.0, 10.0):
rays = underwater.ray_trace([0.0, 2000.0], [c_top, c_top + grad * 2000.0],
source_depth=z_source,
launch_angles_deg=[angle], max_range=32e3,
n_steps=20000)
ax_r.plot(rays.ranges[0] / 1000.0, rays.depths[0], label=f"{angle:.0f} deg")
z_turn = (c_source / np.cos(np.radians(angle)) - c_top) / grad
print(f"{angle:4.0f} deg -> analytic {z_turn:7.1f} m, "
f"traced {float(rays.depths.max()):7.1f} m")
ax_r.set_ylim(1760.0, -70.0)
ax_r.set(xlabel="Range [km]", ylabel="Depth [m]")
ax_r.legend()
plt.show()

Rays buy geometry and timing: paths, turning depths, convergence-zone ranges and the travel time along every one of them, at a cost independent of frequency. Because the time rides the same four Runge-Kutta stages as the trajectory, and takes its sound speed from the interpolation those stages already do, it describes the path actually returned rather than a second reading of it, and it matches the closed form for a constant gradient (Medwin & Clay 1998, Eq. (3.3.20)) to about s. What rays do not carry here is a full amplitude: the geometric ray-tube intensity diverges at caustics, so the result object leaves the level to the modal or PE field.

4. The parabolic equation: a one-way field, marched in range

Section titled “4. The parabolic equation: a one-way field, marched in range”

The parabolic equation trades the boundary-value Helmholtz problem for an initial-value problem in range. Factor out the fast outgoing oscillation, with a reference wavenumber , and for energy travelling within a small angle of the horizontal the envelope obeys the standard (Tappert) PE (Jensen §6.2):

The split-step Fourier algorithm marches it by operator splitting, alternating two individually exact half-physics steps: diffraction is a multiplication by in the vertical-wavenumber domain, and refraction a phase screen back in depth, with one transform pair per range step. parabolic_equation starts from a Gaussian field matched to the point source and uses a discrete sine transform in depth, which enforces the pressure-release surface and bottom by construction. The price is the paraxial approximation: the standard PE is accurate within roughly ±15-20° of the horizontal, and steeper energy carries a phase error that shows at short range in shallow waveguides (Jensen §6.2). The free-field calibration is the oracle: with no gradient at all, the marched field must reproduce spherical spreading,

import numpy as np
from phonometry import underwater
# Free field: in a 5000 m isovelocity column, before any boundary is felt,
# the PE must reproduce spherical spreading, PL = 20 lg R.
field = underwater.parabolic_equation(50.0, [0.0, 5000.0], [1500.0, 1500.0],
source_depth=2500.0, max_range=2000.0,
range_step=10.0)
iz = np.argmin(np.abs(field.depths - 2500.0))
ir = np.argmin(np.abs(field.ranges - 1000.0))
print(round(float(field.propagation_loss[iz, ir]), 2)) # 60.0 = 20 lg 1000

and it does so to about dB at the default range step. That is the check the PE cannot fail: free field, no boundaries, no steep energy. The paraxial limit is only visible where those three are absent.

Left, the propagation loss against range at a 60 metre receiver in a 100 metre waveguide at 50 hertz, computed with normal modes and with the parabolic equation, both drawn faintly as fine structure and boldly as range averages, with the parabolic equation running 1.6 decibels lossier at every range; right, the modal grazing angles of the six propagating modes as bars against the plus or minus 20 degree paraxial band, four of them outside itLeft, the propagation loss against range at a 60 metre receiver in a 100 metre waveguide at 50 hertz, computed with normal modes and with the parabolic equation, both drawn faintly as fine structure and boldly as range averages, with the parabolic equation running 1.6 decibels lossier at every range; right, the modal grazing angles of the six propagating modes as bars against the plus or minus 20 degree paraxial band, four of them outside it

In a 100 m waveguide at 50 Hz, four of the six propagating modes leave the source at 27°, 37°, 49° and 64° — outside the band the standard PE is written for. The consequence is on the left: the range-averaged PE loss sits 1.6 dB above the modal reference, at 300 m and still at 5.7 km. The offset does not wash out with range, because an ideal waveguide is lossless and strips nothing away; in a real seabed the steep modes attenuate first and the gap does close. This is the failure mode to look for in your own output — a level that is uniformly a little too low, not a wrong shape.

Show the code for this figure
import matplotlib.pyplot as plt
import numpy as np
r = np.linspace(50.0, 6000.0, 700)
modes = underwater.normal_modes(50.0, [0.0, 100.0], [1500.0, 1500.0],
source_depth=30.0, receiver_depth=60.0,
ranges_m=r, n_depth_points=800)
pe_field = underwater.parabolic_equation(50.0, [0.0, 100.0], [1500.0, 1500.0],
source_depth=30.0, max_range=6000.0,
range_step=5.0, n_depth_points=1024)
iz2 = int(np.argmin(np.abs(pe_field.depths - 60.0)))
pe_pl = np.interp(r, pe_field.ranges, pe_field.propagation_loss[iz2])
def range_average(pl, width=61): # incoherent, in the energy domain
return -10.0 * np.log10(np.convolve(10.0 ** (-pl / 10.0),
np.ones(width) / width, mode="same"))
inner = (r > 300.0) & (r < 5700.0)
sm_nm = range_average(np.asarray(modes.propagation_loss))
sm_pe = range_average(pe_pl)
print(round(float(np.mean(sm_pe[inner] - sm_nm[inner])), 2)) # 1.64 dB
fig, (ax_pl, ax_ang) = plt.subplots(1, 2, figsize=(13.5, 5.6),
gridspec_kw={"width_ratios": [2, 1]})
ax_pl.plot(r / 1000.0, modes.propagation_loss, alpha=0.35)
ax_pl.plot(r / 1000.0, pe_pl, alpha=0.35)
ax_pl.plot(r[inner] / 1000.0, sm_nm[inner], label="Normal modes, averaged")
ax_pl.plot(r[inner] / 1000.0, sm_pe[inner], label="PE, averaged")
ax_pl.set_ylim(30.0, 92.0)
ax_pl.invert_yaxis()
ax_pl.set(xlabel="Range [km]", ylabel="Propagation loss [dB]")
ax_pl.legend()
k0 = 2.0 * np.pi * 50.0 / 1500.0
grazing = np.degrees(np.arccos(np.clip(modes.wavenumbers / k0, -1.0, 1.0)))
ax_ang.bar(np.arange(1, grazing.size + 1), grazing)
ax_ang.axhline(20.0, linestyle="--")
ax_ang.set(xlabel="Mode index m", ylabel="Grazing angle arccos(k_rm/k) [deg]")
plt.show()

The three solvers are configured by the caller, and every snippet above sets a grid without saying why. The rules are short.

The depth grid. The sine transform of the PE, and the finite-difference eigenproblem of the modes, both alias a spectrum that is steep in depth, so the grid is sized in wavelengths. At 50 Hz, m, and a grid near over 5000 m of water means a few thousand interior points — which is why the section 1 snippet’s n_depth_points=512 is labelled the coarse, fast setting rather than the right one. Refine and re-run before trusting a number.

The split-step range step. The refraction half-step is a phase screen , so it should turn the phase by well under a radian per step, and be tightened wherever the profile has strong gradients. The honest test is the same one: halve and see whether the field moves.

When normal_modes warns. The warning says a retained mode sits inside the finite-difference error band. The remedy is to raise n_depth_points: the eigenvalue error is , and near-cutoff modes are its first casualties — which are exactly the shallow-angle modes that carry the field to long range, so ignoring the warning quietly costs the answer at the ranges you care about.

The ray fan and the ray step. The launch angles are not a free choice either: pick them from Snell’s turning condition so the fan spans the rays the channel traps, rather than the round of the snippet above. The number of steps has to keep the arc length per step small against the vertical scale of the profile, or a turning point is stepped straight over.

The convergence discipline. Refine, re-run, compare; only then compare solvers. Agreement between the modes and the PE, which section 5 calls the practical convergence test, is worth nothing if neither of them has converged on its own grid.

Every propagation function of the underwater module answers the same question, “how much level survives the path”, at a different price in physics. Terminology throughout follows ISO 18405:2017 (propagation loss, source level, levels re 1 µPa). The sound-speed and absorption models named below are implemented and referenced in Underwater sound propagation.

Sound speed. The four equations agree to within about 1 m/s inside the domain they share — 0.98 m/s at 25 °C, 35 ppt and 1000 m, and 0.52 m/s over the upper kilometre of a full profile, as the model comparison on the propagation page shows — so the choice is about validity range, not accuracy. The default UNESCO / Chen-Millero form (as recast by Wong & Zhu 1995) covers 0–40 °C, 0–40 ppt and 0–1000 bar, the widest envelope, and is the international standard. Del Grosso (1974) is restricted to 0–30 °C and 30–40 ppt but is preferred by some authors for deep-ocean work inside that domain (much of the SOFAR-channel literature uses it). Mackenzie (1981) trades pressure for depth directly (2–30 °C, 25–40 ppt, 0–8000 m), which makes it the convenient choice when you have an echo-sounder depth rather than a CTD pressure; the other two convert depth to pressure through Leroy & Parthiot (1998) internally. Medwin (1975) is a six-term simplification valid to about 1000 m in shallow, warm water; it is the coarsest of the four and the one behind the rules of thumb m/s per °C and m/s per metre, so keep it for mental arithmetic and hand checks, not for a budget.

Absorption. Francois–Garrison (1982) is the reference and the default: it carries the boric-acid, magnesium-sulfate and pure-water relaxations with their full temperature, salinity, depth and pH-implicit dependences, and is trusted from about 100 Hz to 1 MHz. Ainslie–McColm (1998) is a deliberate simplification of the same physics that stays within about 10 % of it across that range; use it when a legible formula matters more than the last percent. Thorp (1967) depends on frequency only (it bakes in 4 °C water near 1000 m) and predates both; keep it for quick low-frequency estimates below a few tens of kHz and for comparison with older literature that used it.

Spreading law. Spherical spreading () describes a wavefront that expands freely in three dimensions, before any boundary confines it; cylindrical spreading () describes energy trapped between the surface and the bottom (or in the SOFAR channel) that can only expand in range. The "practical" law splices the two at a transition range , which is physically of the order of the water (or channel) depth: spherical while the wavefront has not yet filled the duct, cylindrical once it has. In the 10 kHz example of the propagation-loss section the choice is not cosmetic: against the same figure of merit of 87 dB, spherical-only spreading predicts detection out to about 8.7 km while the practical law with m stretches it to about 15.8 km. When the spreading law is the biggest uncertainty in the budget, that is the cue to stop using a closed form and compute the field.

Closed form or solver. The closed-form propagation loss knows nothing of the sound-speed profile, the seabed or the surface; it is honest for short, direct, boundary-free paths and for first-cut sonar budgets. When refraction and boundaries decide the answer, pick the solver by frequency and geometry (Jensen et al. 2011, Ch. 1):

SolverNatural regimeWhat it buys you
ray_traceHigh frequency (water depth ≫ λ), deep waterRay-path geometry, turning depths, travel times, convergence zones; cost independent of frequency
normal_modesLow frequency, shallow water, range-independentFinite-difference modal sum with few propagating modes (); the reference solution for its regime, validated against the ideal waveguide’s exact modes
parabolic_equationLow frequency, long one-way pathsFull-field PL(,) with refraction, marched in range over the range-independent all three solvers assume

The boundaries blur in practice: rays remain usable at surprisingly low frequencies for travel-time work, and the PE remains the workhorse well above its formal small-angle regime. When two of the three agree on a case, as the modes and the PE do in the section 1 figure, that agreement is the practical convergence test.

Chain the pieces end to end: a 140 dB re 1 µPa²/Hz source at 10 kHz, a 60 dB ambient spectrum level, a 15 dB array gain and an 8 dB detection threshold give the figure of merit dB, computed by passive_sonar_equation in the sonar-equation section of the propagation guide. Every term then moves the crossing of the propagation-loss curve of that guide’s propagation-loss section (10 °C, 35 ppt, 100 m), and that crossing is the predicted detection range.

import numpy as np
env = {"temperature": 10.0, "salinity": 35.0, "depth": 100.0}
se = underwater.passive_sonar_equation(
source_level=140.0, propagation_loss=np.linspace(40.0, 120.0, 400),
noise_level=60.0, directivity_index=15.0, detection_threshold=8.0)
print(se.figure_of_merit) # 87.0 dB
for law, r0 in (("spherical", None), ("practical", 1000.0)):
res = underwater.detection_range(se.figure_of_merit, 10e3, law=law,
transition_range=r0, **env)
print(law, round(float(res.detection_range) / 1000.0, 1)) # 8.7 / 15.8 km
# Two ways to lose it: half the array gain, or double the frequency.
trim = underwater.passive_sonar_equation(
source_level=140.0, propagation_loss=np.linspace(40.0, 120.0, 400),
noise_level=60.0, directivity_index=7.5, detection_threshold=8.0)
print(trim.figure_of_merit) # 79.5 dB
print(round(float(underwater.detection_range(
trim.figure_of_merit, 10e3, law="practical",
transition_range=1000.0, **env).detection_range) / 1000.0, 1)) # 10.0 km
print(round(float(underwater.detection_range(
se.figure_of_merit, 20e3, law="practical",
transition_range=1000.0, **env).detection_range) / 1000.0, 1)) # 5.9 km
Propagation loss against range for three cases — 10 kilohertz with spherical spreading only, 10 kilohertz with the practical law and a one kilometre transition range, and 20 kilohertz with the same practical law — crossed by two horizontal figure-of-merit lines at 87 and 79.5 decibels, with each of the six crossings dropped to the range axis and labelled, and an inset listing the source level, noise level, directivity index and detection thresholdPropagation loss against range for three cases — 10 kilohertz with spherical spreading only, 10 kilohertz with the practical law and a one kilometre transition range, and 20 kilohertz with the same practical law — crossed by two horizontal figure-of-merit lines at 87 and 79.5 decibels, with each of the six crossings dropped to the range axis and labelled, and an inset listing the source level, noise level, directivity index and detection threshold

The same source and the same sea, six answers between 4.1 and 15.8 km. The spreading law is the biggest lever: choosing spherical-only over the practical law with km costs 7.1 km at 10 kHz. Halving the array gain costs 5.8 km; doubling the frequency costs 9.9 km, because goes from 0.95 to 3.30 dB/km. When the spreading law is the biggest uncertainty in a budget, that is the cue to stop using a closed form and compute the field.

Show the code for this figure
import matplotlib.pyplot as plt
ranges = np.linspace(50.0, 30_000.0, 800)
fig, ax = plt.subplots(figsize=(11, 6.4))
cases = (("10 kHz, spherical only", 10e3, "spherical", None),
("10 kHz, practical R0 = 1 km", 10e3, "practical", 1000.0),
("20 kHz, practical R0 = 1 km", 20e3, "practical", 1000.0))
for label, freq, law, r0 in cases:
ax.plot(ranges / 1000.0,
underwater.propagation_loss(ranges, freq, law=law,
transition_range=r0, **env).pl,
label=label)
for fom in (se.figure_of_merit, trim.figure_of_merit):
ax.axhline(fom, linewidth=1.4, label=f"FOM = {fom:.1f} dB")
for _label, freq, law, r0 in cases:
r50 = float(underwater.detection_range(fom, freq, law=law,
transition_range=r0,
**env).detection_range)
ax.plot([r50 / 1000.0], [fom], "o")
ax.set_ylim(40.0, 126.0)
ax.invert_yaxis()
ax.set(xlabel="Range [km]", ylabel="Propagation loss [dB]")
ax.legend()
plt.show()

One number in that budget deserves a second look. A 60 dB ambient spectrum level at 10 kHz is well above what the sea alone produces: ocean_ambient_noise gives 42 dB re 1 µPa²/Hz there in a 15 knot wind. Sixty decibels therefore describes a noisy site or a self-noise-limited receiver, not a quiet ocean — and noticing that is the difference between a budget and an arithmetic exercise. This coupling between the absorption model, the spreading law and the sonar equation is why they all live in one module.

  • Covered

    normal_modes, ray_trace and parabolic_equation implement the Jensen et al. (2011) numerical solvers for a range-independent stratified ocean: the finite-difference Sturm-Liouville modal sum (Ch. 5, Eqs. 5.3-5.17), the Runge-Kutta ray-trajectory integration (Ch. 3, Eqs. 3.23-3.24) and the split-step Fourier standard PE (Ch. 6), with ISO 18405:2017 terminology throughout. ray_trace also returns the travel time along each ray, integrated as a third state of the same Runge-Kutta step. Each is validated against an exact closed form: the ideal pressure-release waveguide’s modes, the circular-arc rays of a linear gradient together with the published travel time along them (Medwin & Clay 1998, Eq. (3.3.20)), and free-field spherical spreading, plus the mutual agreement of the modal and PE propagation loss.

  • Not covered

    All three solvers assume a range-independent water column with a pressure-release (or, for the modes, optionally rigid) boundary: there is no absorbing or elastic bottom, no sediment attenuation and no real bathymetry, so range-dependent problems are out of scope. The ray solver returns paths and travel times, not amplitudes: ray-tube intensity with caustic corrections is not computed, and nothing searches for the eigenrays that join a given source and receiver. The PE is the standard small-angle (Tappert) form, not a wide-angle Padé variant. For the elastic seabed physics these fluid solvers leave out, see Elastic waves and fluid-solid coupling.

Which underwater propagation solver should I use?

Section titled “Which underwater propagation solver should I use?”

Pick by frequency and geometry (Jensen et al. 2011, Ch. 1): rays for high frequency and deep water (ray-path geometry, travel times and convergence zones at a cost independent of frequency), normal modes for low frequency in shallow water (few propagating modes, , the reference solution for its regime) and the parabolic equation for low-frequency, long one-way paths (the full PL(,) field with refraction). When two solvers agree on a case, that agreement is the practical convergence test.

When is a closed-form propagation loss no longer enough?

Section titled “When is a closed-form propagation loss no longer enough?”

When refraction or boundaries decide the answer: a sound-speed minimum that traps energy (the SOFAR channel), surface and bottom reflections in shallow water, or a detection range that swings with the choice of spreading law. The closed form is honest for short, direct, boundary-free paths and first-cut sonar budgets; beyond that, compute the field.

  • International Organization for Standardization. (2017). Underwater acoustics — Terminology (ISO 18405:2017). The standardized definitions (propagation loss, source level, sound pressure level re 1 µPa) behind the quantities of this page.
  • Jensen, F. B., Kuperman, W. A., Porter, M. B., & Schmidt, H. (2011). Computational ocean acoustics (2nd ed.). Springer. https://doi.org/10.1007/978-1-4419-8678-8The reference monograph implemented here: the modal derivation of section 2 (Ch. 5, Eqs. 5.3-5.17), the ray equations of section 3 (Ch. 3, Eqs. 3.23-3.24), the split-step Fourier parabolic equation of section 4 (Ch. 6) and the model-selection guidance of section 5 (Ch. 1).
  • Munk, W. H. (1974). Sound channel in an exponentially stratified ocean, with application to SOFAR. The Journal of the Acoustical Society of America, 55(2), 220-226. https://doi.org/10.1121/1.1914492The canonical deep-water sound-speed profile used by the section 1 figure and snippets.