easypyram 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
easypyram/PyRAM.py ADDED
@@ -0,0 +1,1178 @@
1
+ """
2
+ PyRAM: Python adaptation of the Range-dependent Acoustic Model (RAM).
3
+ RAM was created by Michael D Collins at the US Naval Research Laboratory.
4
+ This adaptation is of RAM v1.5, available from the Ocean Acoustics Library at
5
+ https://oalib-acoustics.org/models-and-software/parabolic-equation
6
+
7
+ The purpose of PyRAM is to provide a version of RAM which can be used within a
8
+ Python interpreter environment (e.g. Spyder or the Jupyter notebook) and is
9
+ easier to understand, extend and integrate into other applications than the
10
+ Fortran version. It is written in pure Python and achieves speeds comparable to
11
+ native code by using the Numba library for JIT compilation.
12
+
13
+ The PyRAM class contains methods which largely correspond to the original
14
+ Fortran subroutines and functions (including retaining the same names). The
15
+ variable names are also mostly the same. However some of the original code
16
+ (e.g. subroutine zread) is unnecessary when the same purpose can be achieved
17
+ using available Python library functions (e.g. from NumPy or SciPy) and has
18
+ therefore been replaced.
19
+
20
+ A difference in functionality is that sound speed profile updates with range
21
+ are decoupled from seabed parameter updates, which provides more flexibility
22
+ in specifying the environment (e.g. if the data comes from different sources).
23
+
24
+ PyRAM also provides various conveniences, e.g. automatic calculation of range
25
+ and depth steps (though these can be overridden using keyword arguments).
26
+ """
27
+
28
+ import warnings
29
+ from time import process_time
30
+ from typing import Any, NamedTuple
31
+
32
+ import numpy as np
33
+ from numpy.typing import ArrayLike, NDArray
34
+
35
+ from easypyram.matrc import matrc
36
+ from easypyram.outpt import outpt
37
+ from easypyram.solve import solve
38
+
39
+ __all__ = (
40
+ "arctic_profile",
41
+ "munk_profile",
42
+ "PyRAM",
43
+ "PyRAMResults",
44
+ )
45
+
46
+
47
+ class PyRAMResults(NamedTuple):
48
+ """
49
+ Results returned by PyRAM.run().
50
+
51
+ Fields
52
+ ------
53
+ ranges : ndarray
54
+ Calculation ranges [m].
55
+ depths : ndarray
56
+ Calculation depths [m].
57
+ loss_grid : ndarray
58
+ Transmission loss [dB] over depth and range.
59
+ loss_line : ndarray
60
+ Transmission loss [dB] at receiver depth.
61
+ pressure_grid : ndarray
62
+ Complex pressure field.
63
+ pressure_line : ndarray
64
+ Complex pressure at receiver depth.
65
+ c0 : float
66
+ Reference sound speed [m/s].
67
+ proc_time : float
68
+ Processing time [s].
69
+ id : int
70
+ Run identifier.
71
+ """
72
+
73
+ ranges: NDArray[np.float64]
74
+ depths: NDArray[np.float64]
75
+ loss_grid: NDArray[np.float64]
76
+ loss_line: NDArray[np.float64]
77
+ pressure_grid: NDArray[np.complex128]
78
+ pressure_line: NDArray[np.complex128]
79
+ c0: float
80
+ proc_time: float
81
+ id: int
82
+
83
+
84
+ def arctic_profile(
85
+ z: ArrayLike,
86
+ c0: float = 1440.0,
87
+ gradient: float = 0.02,
88
+ ) -> NDArray[np.float64]:
89
+ """
90
+ Simple Arctic sound-speed profile.
91
+
92
+ Parameters
93
+ ----------
94
+ z : array_like
95
+ Depth [m], positive downward.
96
+ c0 : float
97
+ Surface sound speed [m/s].
98
+ gradient : float
99
+ Vertical sound-speed gradient [m/s/m].
100
+ gradient = 0.02 corresponds to an increase of
101
+ approximately 20 m/s per km depth.
102
+
103
+ Returns
104
+ -------
105
+ ndarray
106
+ Sound speed [m/s].
107
+
108
+ Notes
109
+ -----
110
+ This is not a standard published sound-speed profile.
111
+ It is a simple first-order approximation motivated by the
112
+ approximately monotonic increase of sound speed with depth
113
+ observed in many Arctic and Antarctic water columns.
114
+
115
+ References
116
+ ----------
117
+ Munk, W., Worcester, P., and Wunsch, C.
118
+ Ocean Acoustic Tomography.
119
+ Cambridge University Press, 1995.
120
+ """
121
+ if c0 <= 0:
122
+ raise ValueError("c0 must be positive")
123
+ if gradient < 0:
124
+ warnings.warn(
125
+ "Negative gradient produces decreasing sound speed with depth.",
126
+ stacklevel=2,
127
+ )
128
+ z = np.asarray(z, dtype=float)
129
+
130
+ return np.asarray(c0 + gradient * z, dtype=np.float64)
131
+
132
+
133
+ def munk_profile(
134
+ z: ArrayLike,
135
+ c0: float = 1500.0,
136
+ epsilon: float = 0.00737,
137
+ z_axis: float = 1300.0,
138
+ scale_depth: float = 1300.0,
139
+ ) -> NDArray[np.float64]:
140
+ """
141
+ Canonical Munk sound-speed profile.
142
+
143
+ Parameters
144
+ ----------
145
+ z : array_like
146
+ Depth [m], positive downward.
147
+ c0 : float, optional
148
+ Reference sound speed [m/s].
149
+ epsilon : float, optional
150
+ Dimensionless profile-strength parameter.
151
+ z_axis : float, optional
152
+ Sound-channel axis depth [m].
153
+ scale_depth : float, optional
154
+ Scale depth [m].
155
+
156
+ Returns
157
+ -------
158
+ ndarray
159
+ Sound speed [m/s].
160
+
161
+ Notes
162
+ -----
163
+ The Munk profile is defined as
164
+
165
+ c(z) = c0 * [1 + ε * (η + exp(-η) - 1)]
166
+
167
+ where
168
+
169
+ η = 2 * (z - z_axis) / scale_depth.
170
+
171
+ References
172
+ ----------
173
+ Munk, W.
174
+ "Sound channel in an exponentially stratified ocean,
175
+ with application to SOFAR."
176
+ Journal of the Acoustical Society of America,
177
+ 55(2), 1974.
178
+
179
+ Munk, W., Worcester, P., and Wunsch, C.
180
+ Ocean Acoustic Tomography.
181
+ Cambridge University Press, 1995.
182
+ """
183
+ if scale_depth <= 0:
184
+ raise ValueError("scale_depth must be positive")
185
+ if c0 <= 0:
186
+ raise ValueError("c0 must be positive")
187
+ if z_axis < 0:
188
+ raise ValueError("z_axis must be non-negative")
189
+ if epsilon < 0:
190
+ warnings.warn(
191
+ "Negative epsilon produces an inverted Munk profile.",
192
+ stacklevel=2,
193
+ )
194
+
195
+ z = np.asarray(z, dtype=float)
196
+
197
+ eta = 2.0 * (z - z_axis) / scale_depth
198
+
199
+ return np.asarray(c0 * (1.0 + epsilon * (eta + np.expm1(-eta))), dtype=np.float64)
200
+
201
+
202
+ class PyRAM:
203
+ """
204
+ Range-dependent Acoustic Model (RAM)
205
+
206
+ Parameters
207
+ ----------
208
+ freq : float
209
+ Acoustic frequency [Hz].
210
+ zs : float
211
+ Source depth [m].
212
+ zr : float
213
+ Receiver depth [m].
214
+ z_ss : ndarray
215
+ Depths [m] corresponding to water sound-speed values.
216
+ rp_ss : ndarray
217
+ Ranges [m] corresponding to water sound-speed values.
218
+ cw : ndarray
219
+ Water sound-speed values [m/s], with shape
220
+ ``(z_ss.size, rp_ss.size)``.
221
+ z_sb : ndarray
222
+ Depths [m] corresponding to seabed property values.
223
+ rp_sb : ndarray
224
+ Ranges [m] corresponding to seabed property values.
225
+ cb : ndarray
226
+ Seabed sound-speed values [m/s], with shape
227
+ ``(z_sb.size, rp_sb.size)``.
228
+ rhob : ndarray
229
+ Seabed density values [g/cm³], with the same shape as
230
+ ``cb``.
231
+ attn : ndarray
232
+ Seabed attenuation values [dB/wavelength], with the same
233
+ shape as ``cb``.
234
+ rbzb : ndarray
235
+ Bathymetry array [m], with columns containing range and
236
+ depth pairs.
237
+
238
+ Other Parameters
239
+ ----------------
240
+ np : int, optional
241
+ Number of Padé approximation terms. Defaults to ``_np_default`` (8).
242
+ c0 : float, optional
243
+ Reference sound speed [m/s]. Defaults to the mean of the
244
+ first water sound-speed profile.
245
+ dr : float, optional
246
+ Calculation range step [m]. Defaults to ``0.5`` times the
247
+ acoustic wavelength.
248
+ dz : float, optional
249
+ Calculation depth step [m]. Defaults to
250
+ ``0.05`` times the acoustic wavelength.
251
+ ndr : int, optional
252
+ Number of range steps between outputs. Defaults to
253
+ ``_ndr_default`` (1).
254
+ ndz : int, optional
255
+ Number of depth steps between outputs. Defaults to
256
+ ``_ndz_default`` (1).
257
+ zmplt : float, optional
258
+ Maximum output depth [m]. Defaults to the maximum depth in
259
+ ``rbzb``.
260
+ rmax : float, optional
261
+ Maximum calculation range [m]. Defaults to the maximum
262
+ range in ``rp_ss``, ``rp_sb``, or ``rbzb``.
263
+ ns : int, optional
264
+ Number of stability constraints. Defaults to
265
+ ``_ns_default`` (1).
266
+ rs : float, optional
267
+ Maximum range [m] over which stability constraints are
268
+ applied. Defaults to ``rmax``.
269
+ lyrw : float, optional
270
+ Width of the absorbing layer [wavelengths]. Defaults to
271
+ ``_lyrw_default`` (20).
272
+ id : int, optional
273
+ Integer identifier for the model instance. Defaults to
274
+ ``_id_default`` (0).
275
+
276
+ Notes
277
+ -----
278
+ RAM is intended primarily for low-frequency acoustic propagation
279
+ (typically below approximately 500 Hz) in range-dependent environments
280
+ consisting of fluid layers and neglecting seabed shear waves.
281
+
282
+ The numerical accuracy is primarily controlled by ``np``, ``dr``, and ``dz``.
283
+ Increasing the number of Padé terms generally improves accuracy at
284
+ the expense of increased computational cost. The number of stability
285
+ constraints (``ns``) can be increased if the solution exhibits
286
+ numerical instability for any combination of ``np``, ``dr`` and ``dz``.
287
+
288
+ General rules for using RAM:
289
+
290
+ As a rule of thumb use:
291
+
292
+ ``dr`` <= 0.66 * wavelength (1)
293
+ ``dz`` <= 0.066 * wavelength (2)
294
+
295
+ The default values of ``dr`` and ``dz`` satisfy these recommendations.
296
+ The allowable range-step size is limited by the degree of range
297
+ dependence in the environment. The RAM PE solver permits arbitrarily
298
+ large range steps of many wavelengths for range-independent regions
299
+ and dense range sampling. For an environment with small range variations,
300
+ a solution computed using the range-step specifications defined by
301
+ Eq. (1) and sampled on a fine scale, is barely distinguishable from a
302
+ solution computed with large range steps (``dr = 50 * wavelength``),
303
+ although sampled more sparsely. Calculations with a range step size that
304
+ satisfies Eq. (1) are computationally inefficient for environments with
305
+ small variations, but will handle a greater range of environments without
306
+ the hassle of having to evaluate the rate of range dependency and can
307
+ provide a solution that is sampled on a fine scale.
308
+
309
+ Occasionally you will see output that looks like the computation stopped
310
+ halfway or the data will be blank. This likely means there was an
311
+ instability in the calculation. The usual remedy is to vary ``dr`` until
312
+ a stable solution is obtained. If varying ``dr`` does not eliminate
313
+ the instability, try increasing ``ns`` above 1.
314
+
315
+ It is good to refine your grid (make ``dr`` and ``dz`` smaller) and increase
316
+ the number of Padé terms to ensure that the solution has converged.
317
+ Experience will guide you in deciding numerical parameters given a frequency
318
+ and geoacoustic environment.
319
+
320
+ The RAM model approximates a semi-infinite bottom half-space by
321
+ appending an artificial high-attenuation layer, also called a sponge layer,
322
+ at the lower boundary of the physical domain in order to prevent spurious
323
+ reflections from the lower computational boundary. The thickness of this
324
+ layer is controlled by ``lyrw``, which defaults to 20 wavelengths.
325
+
326
+ References
327
+ ----------
328
+ [1] M. D. Collins (1993), "A split-step Padé solution for the parabolic equation method",
329
+ J. Acoust. Soc. Am., vol. 93, pp. 1736-1742
330
+ [2] M. D. Collins (2015), "User's Guide for RAM Version 1.0 and 1.0p",
331
+ Naval Research Laboratory.
332
+ [Online]. Available:
333
+ http://staff.washington.edu/dushaw/AcousticsCode/ram.pdf
334
+ [3] D. Calvo (2006), "Quick introduction to using the Naval Research Laboratory RAM parabolic
335
+ equation (PE) code that includes bottom loss",
336
+ Naval Research Laboratory.
337
+ [Online]. Available:
338
+ http://oalib.hlsresearch.com/PE/ramsurf/readme.orig
339
+ [4] M. D. Collins (1994), "Generalization of the split-step Padé solution",
340
+ J. Acoust. Soc. Am., vol. 96, pp. 382-385
341
+ """
342
+
343
+ _np_default = 8
344
+ _ndr_default = 1
345
+ _ndz_default = 1
346
+ _ns_default = 1
347
+ _lyrw_default = 20.0
348
+ _id_default = 0
349
+
350
+ def __init__(
351
+ self,
352
+ freq: float | int,
353
+ zs: float | int,
354
+ zr: float | int,
355
+ z_ss: ArrayLike,
356
+ rp_ss: ArrayLike,
357
+ cw: ArrayLike,
358
+ z_sb: ArrayLike,
359
+ rp_sb: ArrayLike,
360
+ cb: ArrayLike,
361
+ rhob: ArrayLike,
362
+ attn: ArrayLike,
363
+ rbzb: ArrayLike,
364
+ **kwargs: Any,
365
+ ) -> None:
366
+ self._freq: float = float(freq)
367
+ self._zs: float = float(zs)
368
+ self._zr: float = float(zr)
369
+
370
+ # Store copies to avoid modifying caller-owned arrays
371
+ self._z_ss = np.array(z_ss, dtype=float, copy=True)
372
+ self._rp_ss = np.array(rp_ss, dtype=float, copy=True)
373
+ self._cw = np.array(cw, dtype=float, copy=True)
374
+
375
+ self._z_sb = np.array(z_sb, dtype=float, copy=True)
376
+ self._rp_sb = np.array(rp_sb, dtype=float, copy=True)
377
+ self._cb = np.array(cb, dtype=float, copy=True)
378
+ self._rhob = np.array(rhob, dtype=float, copy=True)
379
+ self._attn = np.array(attn, dtype=float, copy=True)
380
+
381
+ self._rbzb = np.array(rbzb, dtype=float, copy=True)
382
+ self._validate_inputs()
383
+
384
+ # Range-dependence flags
385
+ self.rd_ss = self._rp_ss.size > 1
386
+ self.rd_sb = self._rp_sb.size > 1
387
+ self.rd_bt = self._rbzb.shape[0] > 1
388
+
389
+ self.proc_time: float | None = None
390
+
391
+ # Work variables defined in setup
392
+ self.u: NDArray[np.complex128]
393
+ self.v: NDArray[np.complex128]
394
+
395
+ self.tll: NDArray[np.float64]
396
+ self.tlg: NDArray[np.float64]
397
+
398
+ self.cpl: NDArray[np.complex128]
399
+ self.cpg: NDArray[np.complex128]
400
+
401
+ self.s1: NDArray[np.complex128]
402
+ self.s2: NDArray[np.complex128]
403
+ self.s3: NDArray[np.complex128]
404
+
405
+ self.r1: NDArray[np.complex128]
406
+ self.r2: NDArray[np.complex128]
407
+ self.r3: NDArray[np.complex128]
408
+
409
+ self.vr: NDArray[np.float64]
410
+ self.vz: NDArray[np.float64]
411
+
412
+ self.iz: int
413
+ self.nz: int
414
+ self.ir: int
415
+ self.mdr: int
416
+ self.tlc: int
417
+
418
+ self.alpw: NDArray[np.float64]
419
+ self.alpb: NDArray[np.float64]
420
+
421
+ self.f1: NDArray[np.float64]
422
+ self.f2: NDArray[np.float64]
423
+ self.f3: NDArray[np.float64]
424
+
425
+ self.ksqw: NDArray[np.float64]
426
+
427
+ self.pd1: NDArray[np.complex128]
428
+ self.pd2: NDArray[np.complex128]
429
+
430
+ self.ksq: NDArray[np.complex128]
431
+ self.ksqb: NDArray[np.complex128]
432
+
433
+ self._initialize_params(**kwargs)
434
+
435
+ def run(self) -> PyRAMResults:
436
+ """
437
+ Run the acoustic propagation model.
438
+
439
+ Returns
440
+ -------
441
+ PyRAMResults
442
+ Model output containing ranges, depths, transmission loss,
443
+ complex pressure, reference sound speed, processing time,
444
+ and run identifier.
445
+ """
446
+
447
+ t0 = process_time()
448
+
449
+ self.setup()
450
+
451
+ nr = int(np.round(self._rmax / self._dr)) - 1
452
+
453
+ for rn in range(nr):
454
+ self.updat()
455
+
456
+ solve(
457
+ self.u,
458
+ self.v,
459
+ self.s1,
460
+ self.s2,
461
+ self.s3,
462
+ self.r1,
463
+ self.r2,
464
+ self.r3,
465
+ self.iz,
466
+ self.nz,
467
+ self._np,
468
+ )
469
+
470
+ self.r = (rn + 2) * self._dr
471
+
472
+ self.mdr, self.tlc = outpt(
473
+ self.r,
474
+ self.mdr,
475
+ self._ndr,
476
+ self._ndz,
477
+ self.tlc,
478
+ self.f3,
479
+ self.u,
480
+ self.dir,
481
+ self.ir,
482
+ self.tll,
483
+ self.tlg,
484
+ self.cpl,
485
+ self.cpg,
486
+ )[:]
487
+
488
+ proc_time = process_time() - t0
489
+ self.proc_time = proc_time
490
+
491
+ return PyRAMResults(
492
+ ranges=self.vr,
493
+ depths=self.vz,
494
+ loss_grid=self.tlg,
495
+ loss_line=self.tll,
496
+ pressure_grid=self.cpg,
497
+ pressure_line=self.cpl,
498
+ c0=self._c0,
499
+ proc_time=proc_time,
500
+ id=self._id,
501
+ )
502
+
503
+ def _validate_inputs(self) -> None:
504
+ """Validate inputs."""
505
+
506
+ # Source and receiver depths
507
+ if not self._z_ss[0] <= self._zs <= self._z_ss[-1]:
508
+ raise ValueError("Source depth outside sound speed depths")
509
+
510
+ if not self._z_ss[0] <= self._zr <= self._z_ss[-1]:
511
+ raise ValueError("Receiver depth outside sound speed depths")
512
+
513
+ # Water sound-speed profiles
514
+ if self._cw.shape != (self._z_ss.size, self._rp_ss.size):
515
+ raise ValueError("Dimensions of z_ss, rp_ss, and cw must be consistent.")
516
+
517
+ # Seabed profiles
518
+ expected_shape = (self._z_sb.size, self._rp_sb.size)
519
+
520
+ for _name, profile in (
521
+ ("cb", self._cb),
522
+ ("rhob", self._rhob),
523
+ ("attn", self._attn),
524
+ ):
525
+ if profile.shape != expected_shape:
526
+ raise ValueError("Dimensions of z_sb, rp_sb, cb, rhob, and attn must be consistent.")
527
+
528
+ # Bathymetry
529
+ if self._rbzb[:, 1].max() > self._z_ss[-1]:
530
+ raise ValueError("Deepest sound speed point must be at or below deepest bathymetry point.")
531
+
532
+ def _initialize_params(self, **kwargs: Any) -> None:
533
+ """Initialize model parameters from inputs and keyword arguments."""
534
+
535
+ self._np: int = int(kwargs.get("np", PyRAM._np_default))
536
+
537
+ c0 = np.mean(self._cw[:, 0]) if len(self._cw.shape) > 1 else np.mean(self._cw)
538
+ self._c0: float = float(kwargs.get("c0", c0))
539
+ lambda0: float = self._c0 / self._freq
540
+ self._lambda = lambda0
541
+
542
+ # dr and dz are based on c0 to get sensible output steps
543
+ self._dr: float = float(kwargs.get("dr", 0.5 * lambda0))
544
+ self._dz: float = float(kwargs.get("dz", 0.05 * lambda0))
545
+
546
+ self._ndr: int = int(kwargs.get("ndr", PyRAM._ndr_default))
547
+ self._ndz: int = int(kwargs.get("ndz", PyRAM._ndz_default))
548
+
549
+ self._zmplt: float = float(kwargs.get("zmplt", self._rbzb[:, 1].max()))
550
+
551
+ self._rmax: float = float(
552
+ kwargs.get("rmax", np.max([self._rp_ss.max(), self._rp_sb.max(), self._rbzb[:, 0].max()]))
553
+ )
554
+
555
+ self._ns: int = int(kwargs.get("ns", PyRAM._ns_default))
556
+ self._rs: float = float(kwargs.get("rs", self._rmax + self._dr))
557
+
558
+ self._lyrw: float = float(kwargs.get("lyrw", PyRAM._lyrw_default))
559
+
560
+ self._id: int = int(kwargs.get("id", PyRAM._id_default))
561
+
562
+ def setup(self) -> None:
563
+ """Initialise the parameters, acoustic field, and matrices"""
564
+
565
+ if self._rbzb[-1, 0] < self._rmax:
566
+ self._rbzb = np.append(self._rbzb, np.array([[self._rmax, self._rbzb[-1, 1]]]), axis=0)
567
+
568
+ self.eta = 1 / (40 * np.pi * np.log10(np.exp(1)))
569
+ self.ib = 0 # Bathymetry pair index
570
+ self.mdr = 0 # Output range counter
571
+ self.r = self._dr
572
+ self.omega = 2 * np.pi * self._freq
573
+ ri = self._zr / self._dz
574
+ self.ir = int(np.floor(ri)) # Receiver depth index
575
+ self.dir = ri - self.ir # Offset
576
+ self.k0 = self.omega / self._c0
577
+ self._z_sb += self._z_ss[-1] # Make seabed profiles relative to deepest water profile point
578
+ self._zmax = self._z_sb.max() + self._lyrw * self._lambda
579
+ self.nz = int(np.floor(self._zmax / self._dz)) - 1 # Number of depth grid points - 2
580
+ self.nzplt = int(np.floor(self._zmplt / self._dz)) # Deepest output grid point
581
+ self.iz = int(np.floor(self._rbzb[0, 1] / self._dz)) # First index below seabed
582
+ self.iz = max(1, self.iz)
583
+ self.iz = min(self.nz - 1, self.iz)
584
+
585
+ self.u = np.zeros(self.nz + 2, dtype=np.complex128)
586
+ self.v = np.zeros(self.nz + 2, dtype=np.complex128)
587
+ self.ksq = np.zeros(self.nz + 2, dtype=np.complex128)
588
+ self.ksqb = np.zeros(self.nz + 2, dtype=np.complex128)
589
+ self.r1 = np.zeros([self.nz + 2, self._np], dtype=np.complex128)
590
+ self.r2 = np.zeros([self.nz + 2, self._np], dtype=np.complex128)
591
+ self.r3 = np.zeros([self.nz + 2, self._np], dtype=np.complex128)
592
+ self.s1 = np.zeros([self.nz + 2, self._np], dtype=np.complex128)
593
+ self.s2 = np.zeros([self.nz + 2, self._np], dtype=np.complex128)
594
+ self.s3 = np.zeros([self.nz + 2, self._np], dtype=np.complex128)
595
+ self.pd1 = np.zeros(self._np, dtype=np.complex128)
596
+ self.pd2 = np.zeros(self._np, dtype=np.complex128)
597
+
598
+ self.alpw = np.zeros(self.nz + 2, dtype=np.float64)
599
+ self.alpb = np.zeros(self.nz + 2, dtype=np.float64)
600
+ self.f1 = np.zeros(self.nz + 2, dtype=np.float64)
601
+ self.f2 = np.zeros(self.nz + 2, dtype=np.float64)
602
+ self.f3 = np.zeros(self.nz + 2, dtype=np.float64)
603
+ self.ksqw = np.zeros(self.nz + 2, dtype=np.float64)
604
+ nvr = int(np.floor(self._rmax / (self._dr * self._ndr)))
605
+ self._rmax = nvr * self._dr * self._ndr
606
+ nvz = int(np.floor(self.nzplt / self._ndz))
607
+ self.vr = np.asarray(np.arange(1, nvr + 1) * self._dr * self._ndr, dtype=np.float64)
608
+ self.vz = np.array(np.arange(1, nvz + 1) * self._dz * self._ndz, dtype=np.float64)
609
+ self.tll = np.zeros(nvr, dtype=np.float64)
610
+ self.tlg = np.zeros([nvz, nvr], dtype=np.float64)
611
+ self.cpl = np.zeros(nvr, dtype=np.complex128)
612
+ self.cpg = np.zeros((nvz, nvr), dtype=np.complex128)
613
+ self.tlc = -1 # TL output range counter
614
+
615
+ self.ss_ind = 0 # Sound speed profile range index
616
+ self.sb_ind = 0 # Seabed parameters range index
617
+ self.bt_ind = 0 # Bathymetry range index
618
+
619
+ # The initial profiles and starting field
620
+ self.profl()
621
+ self.selfs()
622
+ self.mdr, self.tlc = outpt(
623
+ self.r,
624
+ self.mdr,
625
+ self._ndr,
626
+ self._ndz,
627
+ self.tlc,
628
+ self.f3,
629
+ self.u,
630
+ self.dir,
631
+ self.ir,
632
+ self.tll,
633
+ self.tlg,
634
+ self.cpl,
635
+ self.cpg,
636
+ )[:]
637
+
638
+ # The propagation matrices
639
+ self.epade()
640
+ matrc(
641
+ self.k0,
642
+ self._dz,
643
+ self.iz,
644
+ self.iz,
645
+ self.nz,
646
+ self._np,
647
+ self.f1,
648
+ self.f2,
649
+ self.f3,
650
+ self.ksq,
651
+ self.alpw,
652
+ self.alpb,
653
+ self.ksqw,
654
+ self.ksqb,
655
+ self.rhob,
656
+ self.r1,
657
+ self.r2,
658
+ self.r3,
659
+ self.s1,
660
+ self.s2,
661
+ self.s3,
662
+ self.pd1,
663
+ self.pd2,
664
+ )
665
+
666
+ def profl(self) -> None:
667
+ """Set up the profiles"""
668
+
669
+ attnf = 10 # 10dB/wavelength at floor
670
+
671
+ z = np.linspace(0, self._zmax, self.nz + 2)
672
+ self.cw = np.interp(
673
+ z,
674
+ self._z_ss,
675
+ self._cw[:, self.ss_ind],
676
+ left=self._cw[0, self.ss_ind],
677
+ right=self._cw[-1, self.ss_ind],
678
+ )
679
+ self.cb = np.interp(
680
+ z,
681
+ self._z_sb,
682
+ self._cb[:, self.sb_ind],
683
+ left=self._cb[0, self.sb_ind],
684
+ right=self._cb[-1, self.sb_ind],
685
+ )
686
+ self.rhob = np.interp(
687
+ z,
688
+ self._z_sb,
689
+ self._rhob[:, self.sb_ind],
690
+ left=self._rhob[0, self.sb_ind],
691
+ right=self._rhob[-1, self.sb_ind],
692
+ )
693
+ attnlyr = np.concatenate((self._attn[:, self.sb_ind], [self._attn[-1, self.sb_ind], attnf]))
694
+ zlyr = np.concatenate(
695
+ (
696
+ self._z_sb,
697
+ [
698
+ self._z_sb[-1] + 0.75 * self._lyrw * self._lambda,
699
+ self._z_sb[-1] + self._lyrw * self._lambda,
700
+ ],
701
+ )
702
+ )
703
+ self.attn = np.interp(z, zlyr, attnlyr, left=self._attn[0, self.sb_ind], right=attnf)
704
+
705
+ self.ksqw = (self.omega / self.cw) ** 2 - self.k0**2
706
+ self.ksqb = ((self.omega / self.cb) * (1 + 1j * self.eta * self.attn)) ** 2 - self.k0**2
707
+ self.alpw = np.asarray(
708
+ np.sqrt(self.cw / self._c0),
709
+ dtype=np.float64,
710
+ )
711
+
712
+ self.alpb = np.asarray(
713
+ np.sqrt(self.rhob * self.cb / self._c0),
714
+ dtype=np.float64,
715
+ )
716
+
717
+ def updat(self) -> None:
718
+ """Matrix updates"""
719
+
720
+ # Varying bathymetry
721
+ if self.rd_bt:
722
+ npt = self._rbzb.shape[0]
723
+ while (self.bt_ind < npt - 1) and (self.r >= self._rbzb[self.bt_ind + 1, 0]):
724
+ self.bt_ind += 1
725
+ jz = self.iz
726
+ z = self._rbzb[self.bt_ind, 1] + (self.r + 0.5 * self._dr - self._rbzb[self.bt_ind, 0]) * (
727
+ self._rbzb[self.bt_ind + 1, 1] - self._rbzb[self.bt_ind, 1]
728
+ ) / (self._rbzb[self.bt_ind + 1, 0] - self._rbzb[self.bt_ind, 0])
729
+ self.iz = int(np.floor(z / self._dz)) # First index below seabed
730
+ self.iz = max(1, self.iz)
731
+ self.iz = min(self.nz - 1, self.iz)
732
+ if self.iz != jz:
733
+ matrc(
734
+ self.k0,
735
+ self._dz,
736
+ self.iz,
737
+ jz,
738
+ self.nz,
739
+ self._np,
740
+ self.f1,
741
+ self.f2,
742
+ self.f3,
743
+ self.ksq,
744
+ self.alpw,
745
+ self.alpb,
746
+ self.ksqw,
747
+ self.ksqb,
748
+ self.rhob,
749
+ self.r1,
750
+ self.r2,
751
+ self.r3,
752
+ self.s1,
753
+ self.s2,
754
+ self.s3,
755
+ self.pd1,
756
+ self.pd2,
757
+ )
758
+
759
+ # Varying sound speed profile
760
+ if self.rd_ss:
761
+ npt = self._rp_ss.size
762
+ ss_ind_o = self.ss_ind
763
+ while (self.ss_ind < npt - 1) and (self.r >= self._rp_ss[self.ss_ind + 1]):
764
+ self.ss_ind += 1
765
+ if self.ss_ind != ss_ind_o:
766
+ self.profl()
767
+ matrc(
768
+ self.k0,
769
+ self._dz,
770
+ self.iz,
771
+ self.iz,
772
+ self.nz,
773
+ self._np,
774
+ self.f1,
775
+ self.f2,
776
+ self.f3,
777
+ self.ksq,
778
+ self.alpw,
779
+ self.alpb,
780
+ self.ksqw,
781
+ self.ksqb,
782
+ self.rhob,
783
+ self.r1,
784
+ self.r2,
785
+ self.r3,
786
+ self.s1,
787
+ self.s2,
788
+ self.s3,
789
+ self.pd1,
790
+ self.pd2,
791
+ )
792
+
793
+ # Varying seabed profile
794
+ if self.rd_sb:
795
+ npt = self._rp_sb.size
796
+ sb_ind_o = self.sb_ind
797
+ while (self.sb_ind < npt - 1) and (self.r >= self._rp_sb[self.sb_ind + 1]):
798
+ self.sb_ind += 1
799
+ if self.sb_ind != sb_ind_o:
800
+ self.profl()
801
+ matrc(
802
+ self.k0,
803
+ self._dz,
804
+ self.iz,
805
+ self.iz,
806
+ self.nz,
807
+ self._np,
808
+ self.f1,
809
+ self.f2,
810
+ self.f3,
811
+ self.ksq,
812
+ self.alpw,
813
+ self.alpb,
814
+ self.ksqw,
815
+ self.ksqb,
816
+ self.rhob,
817
+ self.r1,
818
+ self.r2,
819
+ self.r3,
820
+ self.s1,
821
+ self.s2,
822
+ self.s3,
823
+ self.pd1,
824
+ self.pd2,
825
+ )
826
+
827
+ # Turn off the stability constraints
828
+ if self.r >= self._rs:
829
+ self._ns = 0
830
+ self._rs = self._rmax + self._dr
831
+ self.epade()
832
+ matrc(
833
+ self.k0,
834
+ self._dz,
835
+ self.iz,
836
+ self.iz,
837
+ self.nz,
838
+ self._np,
839
+ self.f1,
840
+ self.f2,
841
+ self.f3,
842
+ self.ksq,
843
+ self.alpw,
844
+ self.alpb,
845
+ self.ksqw,
846
+ self.ksqb,
847
+ self.rhob,
848
+ self.r1,
849
+ self.r2,
850
+ self.r3,
851
+ self.s1,
852
+ self.s2,
853
+ self.s3,
854
+ self.pd1,
855
+ self.pd2,
856
+ )
857
+
858
+ def selfs(self) -> None:
859
+ """Set up the initial field. The self-starter"""
860
+
861
+ # Conditions for the delta function
862
+
863
+ si = self._zs / self._dz
864
+ _is = int(np.floor(si)) # Source depth index
865
+ dis = si - _is # Offset
866
+
867
+ self.u[_is] = (1 - dis) * np.sqrt(2 * np.pi / self.k0) / (self._dz * self.alpw[_is])
868
+ self.u[_is + 1] = dis * np.sqrt(2 * np.pi / self.k0) / (self._dz * self.alpw[_is])
869
+
870
+ # Divide the delta function by (1-X)**2 to get a smooth rhs
871
+
872
+ self.pd1[0] = 0
873
+ self.pd2[0] = -1
874
+
875
+ matrc(
876
+ self.k0,
877
+ self._dz,
878
+ self.iz,
879
+ self.iz,
880
+ self.nz,
881
+ 1,
882
+ self.f1,
883
+ self.f2,
884
+ self.f3,
885
+ self.ksq,
886
+ self.alpw,
887
+ self.alpb,
888
+ self.ksqw,
889
+ self.ksqb,
890
+ self.rhob,
891
+ self.r1,
892
+ self.r2,
893
+ self.r3,
894
+ self.s1,
895
+ self.s2,
896
+ self.s3,
897
+ self.pd1,
898
+ self.pd2,
899
+ )
900
+ for _ in range(2):
901
+ solve(self.u, self.v, self.s1, self.s2, self.s3, self.r1, self.r2, self.r3, self.iz, self.nz, 1)
902
+
903
+ # Apply the operator (1-X)**2*(1+X)**(-1/4)*exp(ci*k0*r*sqrt(1+X))
904
+
905
+ self.epade(ip=2)
906
+ matrc(
907
+ self.k0,
908
+ self._dz,
909
+ self.iz,
910
+ self.iz,
911
+ self.nz,
912
+ self._np,
913
+ self.f1,
914
+ self.f2,
915
+ self.f3,
916
+ self.ksq,
917
+ self.alpw,
918
+ self.alpb,
919
+ self.ksqw,
920
+ self.ksqb,
921
+ self.rhob,
922
+ self.r1,
923
+ self.r2,
924
+ self.r3,
925
+ self.s1,
926
+ self.s2,
927
+ self.s3,
928
+ self.pd1,
929
+ self.pd2,
930
+ )
931
+ solve(
932
+ self.u, self.v, self.s1, self.s2, self.s3, self.r1, self.r2, self.r3, self.iz, self.nz, self._np
933
+ )
934
+
935
+ def epade(self, ip: int = 1) -> None:
936
+ """Set the coefficients of the rational approximation"""
937
+
938
+ n = 2 * self._np
939
+ _bin = np.zeros([n + 1, n + 1])
940
+ a = np.zeros([n + 1, n + 1], dtype=np.complex128)
941
+ b = np.zeros(n, dtype=np.complex128)
942
+ dg = np.zeros(n + 1, dtype=np.complex128)
943
+ dh1 = np.zeros(n, dtype=np.complex128)
944
+ dh2 = np.zeros(n, dtype=np.complex128)
945
+ dh3 = np.zeros(n, dtype=np.complex128)
946
+ fact = np.zeros(n + 1)
947
+ sig = self.k0 * self._dr
948
+
949
+ nu: int
950
+ alp: float
951
+
952
+ if ip == 1:
953
+ nu, alp = 0, 0.0
954
+ else:
955
+ nu, alp = 1, -0.25
956
+
957
+ # The factorials
958
+ fact[0] = 1
959
+ for i in range(1, n):
960
+ fact[i] = (i + 1) * fact[i - 1]
961
+
962
+ # The binomial coefficients
963
+ for i in range(n + 1):
964
+ _bin[i, 0] = 1
965
+ _bin[i, i] = 1
966
+ for i in range(2, n + 1):
967
+ for j in range(1, i):
968
+ _bin[i, j] = _bin[i - 1, j - 1] + _bin[i - 1, j]
969
+
970
+ # The accuracy constraints
971
+ dg, dh1, dh2, dh3 = self.deriv(n, sig, alp, dg, dh1, dh2, dh3, _bin, nu)
972
+ for i in range(n):
973
+ b[i] = dg[i + 1]
974
+ for i in range(n):
975
+ if 2 * i <= n - 1:
976
+ a[i, 2 * i] = fact[i]
977
+ for j in range(i + 1):
978
+ if 2 * j + 1 <= n - 1:
979
+ a[i, 2 * j + 1] = -_bin[i + 1, j + 1] * fact[j] * dg[i - j]
980
+
981
+ # The stability constraints
982
+
983
+ if self._ns >= 1:
984
+ z1 = -3 + 0j
985
+ b[n - 1] = -1
986
+ for j in range(self._np):
987
+ a[n - 1, 2 * j] = z1 ** (j + 1)
988
+ a[n - 1, 2 * j + 1] = 0
989
+
990
+ if self._ns >= 2:
991
+ z1 = -1.5 + 0j
992
+ b[n - 2] = -1
993
+ for j in range(self._np):
994
+ a[n - 2, 2 * j] = z1 ** (j + 1)
995
+ a[n - 2, 2 * j + 1] = 0
996
+
997
+ a, b = self.gauss(n, a, b, self.pivot)
998
+
999
+ dh1[0] = 1
1000
+ for j in range(self._np):
1001
+ dh1[j + 1] = b[2 * j]
1002
+ dh1, dh2 = self.fndrt(dh1, self._np, dh2, self.guerre)
1003
+ for j in range(self._np):
1004
+ self.pd1[j] = -1 / dh2[j]
1005
+
1006
+ dh1[0] = 1
1007
+ for j in range(self._np):
1008
+ dh1[j + 1] = b[2 * j + 1]
1009
+ dh1, dh2 = self.fndrt(dh1, self._np, dh2, self.guerre)
1010
+ for j in range(self._np):
1011
+ self.pd2[j] = -1 / dh2[j]
1012
+
1013
+ @staticmethod
1014
+ def deriv(
1015
+ n: int, sig: Any, alp: Any, dg: Any, dh1: Any, dh2: Any, dh3: Any, _bin: Any, nu: Any
1016
+ ) -> tuple[Any, Any, Any, Any]:
1017
+ """Return the derivatives of the operator function at x=0"""
1018
+
1019
+ dh1[0] = 0.5 * 1j * sig
1020
+ exp1 = -0.5
1021
+ dh2[0] = alp
1022
+ exp2 = -1
1023
+ dh3[0] = -2 * nu
1024
+ exp3 = -1
1025
+ for i in range(1, n):
1026
+ dh1[i] = dh1[i - 1] * exp1
1027
+ exp1 -= 1
1028
+ dh2[i] = dh2[i - 1] * exp2
1029
+ exp2 -= 1
1030
+ dh3[i] = -nu * dh3[i - 1] * exp3
1031
+ exp3 -= 1
1032
+
1033
+ dg[0] = 1
1034
+ dg[1] = dh1[0] + dh2[0] + dh3[0]
1035
+ for i in range(1, n):
1036
+ dg[i + 1] = dh1[i] + dh2[i] + dh3[i]
1037
+ for j in range(i):
1038
+ dg[i + 1] += _bin[i, j] * (dh1[j] + dh2[j] + dh3[j]) * dg[i - j]
1039
+
1040
+ return dg, dh1, dh2, dh3
1041
+
1042
+ @staticmethod
1043
+ def gauss(n: int, a: Any, b: Any, pivot: Any) -> tuple[Any, Any]:
1044
+ """
1045
+ Gaussian elimination
1046
+ """
1047
+
1048
+ # Downward elimination
1049
+ for i in range(n):
1050
+ if i < n - 1:
1051
+ a, b = pivot(n, i, a, b)
1052
+ a[i, i] = 1 / a[i, i]
1053
+ b[i] *= a[i, i]
1054
+ if i < n - 1:
1055
+ for j in range(i + 1, n + 1):
1056
+ a[i, j] *= a[i, i]
1057
+ for k in range(i + 1, n):
1058
+ b[k] -= a[k, i] * b[i]
1059
+ for j in range(i + 1, n):
1060
+ a[k, j] -= a[k, i] * a[i, j]
1061
+
1062
+ # Back substitution
1063
+ for i in range(n - 2, -1, -1):
1064
+ for j in range(i + 1, n):
1065
+ b[i] -= a[i, j] * b[j]
1066
+
1067
+ return a, b
1068
+
1069
+ @staticmethod
1070
+ def pivot(n: int, i: int, a: Any, b: Any) -> tuple[Any, Any]:
1071
+ """
1072
+ Rows are interchanged for stability
1073
+ """
1074
+
1075
+ i0 = i
1076
+ amp0 = np.abs(a[i, i])
1077
+ for j in range(i + 1, n):
1078
+ amp = np.abs(a[j, i])
1079
+ if amp > amp0:
1080
+ i0 = j
1081
+ amp0 = amp
1082
+
1083
+ if i0 != i:
1084
+ b[i0], b[i] = b[i], b[i0]
1085
+ for j in range(i, n + 1):
1086
+ a[i0, j], a[i, j] = a[i, j], a[i0, j]
1087
+
1088
+ return a, b
1089
+
1090
+ @staticmethod
1091
+ def fndrt(a: Any, n: int, z: Any, guerre: Any) -> tuple[Any, Any]:
1092
+ """Find the roots of polynomial a"""
1093
+
1094
+ if n == 1:
1095
+ z[0] = -a[0] / a[1]
1096
+ return a, z
1097
+
1098
+ if n != 2:
1099
+ for k in range(n - 1, 1, -1):
1100
+ # Obtain an approximate root
1101
+ root = 0
1102
+ err = 1e-12
1103
+ a, root, err = guerre(a, k + 1, root, err, 1000)
1104
+ # Refine the root by iterating five more times
1105
+ err = 0
1106
+ a, root, err = guerre(a, k + 1, root, err, 5)
1107
+ z[k] = root
1108
+ # Divide out the factor (z-root).
1109
+ for i in range(k, -1, -1):
1110
+ a[i] += root * a[i + 1]
1111
+ for i in range(k + 1):
1112
+ a[i] = a[i + 1]
1113
+
1114
+ z[1] = 0.5 * (-a[1] + np.sqrt(a[1] ** 2 - 4 * a[0] * a[2])) / a[2]
1115
+ z[0] = 0.5 * (-a[1] - np.sqrt(a[1] ** 2 - 4 * a[0] * a[2])) / a[2]
1116
+
1117
+ return a, z
1118
+
1119
+ @staticmethod
1120
+ def guerre(a: Any, n: int, z: Any, err: Any, nter: int) -> tuple[Any, Any, Any]:
1121
+ """
1122
+ Return the root of a polynomial of degree n > 2 by Laguerre's method
1123
+ """
1124
+
1125
+ az = np.zeros(n, dtype=np.complex128)
1126
+ azz = np.zeros(n - 1, dtype=np.complex128)
1127
+
1128
+ eps = 1e-20
1129
+ # The coefficients of p'(z) and p''(z)
1130
+ for i in range(n):
1131
+ az[i] = (i + 1) * a[i + 1]
1132
+ for i in range(n - 1):
1133
+ azz[i] = (i + 1) * az[i + 1]
1134
+
1135
+ _iter = 0
1136
+ jter = 0 # Missing from original code - assume this is correct
1137
+ dz = np.inf
1138
+
1139
+ while (np.abs(dz) > err) and (_iter < nter - 1):
1140
+ p = a[n - 1] + a[n] * z
1141
+ for i in range(n - 2, -1, -1):
1142
+ p = a[i] + z * p
1143
+ if np.abs(p) < eps:
1144
+ return a, z, err
1145
+
1146
+ pz = az[n - 2] + az[n - 1] * z
1147
+ for i in range(n - 3, -1, -1):
1148
+ pz = az[i] + z * pz
1149
+
1150
+ pzz = azz[n - 3] + azz[n - 2] * z
1151
+ for i in range(n - 4, -1, -1):
1152
+ pzz = azz[i] + z * pzz
1153
+
1154
+ # The Laguerre perturbation
1155
+ f = pz / p
1156
+ g = f**2 - pzz / p
1157
+ h = np.sqrt((n - 1) * (n * g - f**2))
1158
+ amp1 = np.abs(f + h)
1159
+ amp2 = np.abs(f - h)
1160
+ if amp1 > amp2:
1161
+ dz = -n / (f + h)
1162
+ else:
1163
+ dz = -n / (f - h)
1164
+
1165
+ _iter += 1
1166
+
1167
+ # Rotate by 90 degrees to avoid limit cycles
1168
+
1169
+ jter += 1
1170
+ if jter == 9:
1171
+ jter = 0
1172
+ dz *= 1j
1173
+ z += dz
1174
+
1175
+ if _iter == 100:
1176
+ raise ValueError("Laguerre method not converging. Try a different combination of DR and NP.")
1177
+
1178
+ return a, z, err