1"""Classes for modeling and compensating propagation of sound.
2
3Main propagation models
4-----------------------
5.. autosummary::
6 :toctree: generated
7
8 MlogR
9 SmoothLloydMirror
10 SeabedCriticalAngle
11
12Utilites
13--------
14.. autosummary::
15 :toctree: generated
16
17 cutoff_frequency
18 perkins_cutoff
19 read_valeport_data
20 seabed_properties
21
22Implementation interfaces
23-------------------------
24.. autosummary::
25 :toctree: generated
26
27 PropagationModel
28 NonlocalPropagationModel
29
30"""
31
32import numpy as np
33import abc
34from . import _core, spectral
35import xarray as xr
36
37
[docs]
38class PropagationModel(abc.ABC):
39 """Base class for all propagation models with compensation."""
40
[docs]
41 @abc.abstractmethod
42 def compensate_propagation(self, received_power, receiver, source):
43 """Compensate the propagation of measured power.
44
45 This takes a sound power measured at a receiver and compensates
46 the propagation from a source.
47 The ``received_power``, ``receiver``, and ``source`` can all have
48 a time axis, but then they should be aligned.
49
50 Parameters
51 ----------
52 received_power : `~uwacan.FrequencyData`
53 The measured power.
54 receiver : `~uwacan.positional.Coordinates`
55 The placement of the receiver. Has latitude, longitude, and optionally depth.
56 source : `~uwacan.positional.Coordinates`
57 The placement of the source. Has latitude, longitude, and optionally depth.
58
59 Returns
60 -------
61 source_power : `~uwacan.FrequencyData`
62 The source power.
63
64 Notes
65 -----
66 Subclasses should implement this function to apply the compensation.
67 """
68
69
[docs]
70class NonlocalPropagationModel(PropagationModel):
71 """Class for propagation models and only depend on relative coordinates."""
72
[docs]
73 @abc.abstractmethod
74 def power_propagation(self, distance, frequency, receiver_depth, source_depth):
75 """Compute the propagation factor.
76
77 The propagation factor is the ratio of received power to sent power.
78 Typically this is a value smaller than one, and is decreasing with
79 increasing distances.
80
81 Parameters
82 ----------
83 distance : `xarray.DataArray`
84 The horizontal distance between source and receiver.
85 frequency : `xarray.DataArray`
86 The frequency to evaluate at.
87 receiver_depth : `xarray.DataArray`
88 The depth of the receiver.
89 source_depth : `xarray.DataArray`
90 The depth of the source.
91
92 Returns
93 -------
94 F : `xarray.DataArray`
95 The evaluated propagation factor.
96
97 Notes
98 -----
99 Subclasses must implement this method to compute the propagation factor.
100 The method has to accept all the arguments, but can ignore some of them
101 if they are not relevant.
102 """
103 return 1
104
[docs]
105 @staticmethod
106 def slant_range(horizontal_distance, receiver_depth, source_depth=None):
107 """Compute the slant range from source to receiver.
108
109 Parameters
110 ----------
111 horizontal_distance : array_like
112 The horizontal distance between source and receiver.
113 receiver_depth : array_like
114 The depth below surface of the receiver.
115 source_depth : array_like
116 The depth below surface of the source.
117 If the source depth is None, it defaults to 0.
118
119 Returns
120 -------
121 slant_range : array_like or None
122 The computed slant range if the receiver depth is not None,
123 otherwise None.
124 """
125 if receiver_depth is None:
126 return None
127 # Optionally used to calculate the distance between source and receiver, instead of source surface to receiver.
128 source_depth = source_depth or 0
129 return (horizontal_distance**2 + (receiver_depth - source_depth) ** 2) ** 0.5
130
[docs]
131 def compensate_propagation(self, received_power, receiver, source): # noqa: D102, takes the docstring from the superclass
132 distance = receiver.distance_to(source)
133 try:
134 receiver_depth = receiver.depth
135 except AttributeError:
136 try:
137 receiver_depth = receiver["depth"]
138 except KeyError:
139 receiver_depth = None
140
141 try:
142 source_depth = source.depth
143 except AttributeError:
144 try:
145 source_depth = source["depth"]
146 except KeyError:
147 source_depth = None
148
149 try:
150 frequency = received_power.frequency
151 except AttributeError:
152 frequency = None
153
154 power_loss = self.power_propagation(
155 distance=distance,
156 frequency=frequency,
157 source_depth=source_depth,
158 receiver_depth=receiver_depth,
159 )
160 return received_power / power_loss
161
162
[docs]
163class MlogR(NonlocalPropagationModel):
164 """Geometrical spreading loss model.
165
166 This implements a simple ``m log(r) + offset`` model
167
168 Parameters
169 ----------
170 m : numeric, default 20
171 The spreading factor.
172 ``m=20`` gives spherical spreading, ``m=10`` gives cylindrical spreading.
173 offset : numeric, default 0
174 The offset to the propagation model.
175
176 Notes
177 -----
178 This models calculates the fraction of power lost due
179 to geometrical spreading of the energy, i.e::
180
181 F = distance**(-m / 10) * 10 ** (-offset / 10)
182
183 The distance is the slant range if a receiver depth is
184 given, and the horizontal range otherwise.
185 """
186
187 def __init__(self, m=20, offset=0, **kwargs):
188 super().__init__(**kwargs)
189 self.m = m
190 self.offset = offset
191
[docs]
192 def power_propagation(self, distance, receiver_depth=None, **kwargs): # noqa: D417, kwargs not documented
193 """Calculate simple geometrical spreading.
194
195 Parameters
196 ----------
197 distance : array_like
198 The horizontal distance between source and receiver.
199 receiver_depth : array_like, optional
200 An optional receiver depth. If given, the spreading is
201 evaluated on the slant range instead of the horizontal distance.
202
203 Returns
204 -------
205 F : `xarray.DataArray`
206 The evaluated propagation factor.
207 """
208 if receiver_depth is not None:
209 distance = self.slant_range(distance, receiver_depth)
210 return distance ** (-self.m / 10) * 10 ** (-self.offset / 10)
211
212
[docs]
213class SmoothLloydMirror(MlogR):
214 """Geometrical spreading and average Lloyd mirror reflection loss model.
215
216 This model compensates geometrical spreading as well as source interaction with the water surface.
217
218 Parameters
219 ----------
220 m : int, default 20
221 The spreading factor.
222 ``m=20`` gives spherical spreading, ``m=10`` gives cylindrical spreading.
223 speed_of_sound : numeric, default 1500
224 The speed of sound in the water, used to calculate wave numbers.
225
226 Notes
227 -----
228 For high frequencies the surface interaction is a plain factor ``hf = 2``,
229 since we are interested in the average value. I.e., the surface boosts the
230 radiation output.
231 For low frequencies, the interaction factor is::
232
233 lf = (2kd sin(θ))**2
234
235 where θ is the grazing angle, measured to the receiver from the surface above the source.
236 The low and high frequencies are mixed as::
237
238 F_s = 1 / (1 / lf + 1 / hf).
239
240 This surface factor F_s is then multiplied with the geometrical spreading::
241
242 F_g = distance**(-m / 10) * 10 ** (-offset / 10)
243
244 with the distance evaluated using the slant range.
245 """
246
247 def __init__(self, m=20, speed_of_sound=1500, **kwargs):
248 super().__init__(m=m, **kwargs)
249 self.speed_of_sound = speed_of_sound
250
[docs]
251 def power_propagation(self, distance, frequency, receiver_depth, source_depth, **kwargs): # noqa: D417, ignored kwargs doc
252 """Calculate surface interactions and geometrical spreading.
253
254 Parameters
255 ----------
256 distance : array_like
257 The horizontal distance between source and receiver.
258 frequency : array_like
259 The frequencies at which to evaluate.
260 receiver_depth : array_like
261 The receiver depth. Used to compute grazing angles and slant ranges.
262 source_depth : array_like
263 The source depth. Used to compute the ``kd`` term.
264
265 Returns
266 -------
267 F : `xarray.DataArray`
268 The evaluated propagation factor.
269 """
270 geometric_spreading = super().power_propagation(
271 distance=distance, frequency=frequency, receiver_depth=receiver_depth, source_depth=source_depth, **kwargs
272 )
273
274 kd = 2 * np.pi * frequency * source_depth / self.speed_of_sound
275 slant_range = self.slant_range(distance, receiver_depth)
276 mirror_lf = 4 * kd**2 * (receiver_depth / slant_range) ** 2
277 mirror_hf = 2
278 mirror_reduction = 1 / (1 / mirror_lf + 1 / mirror_hf)
279
280 return geometric_spreading * mirror_reduction
281
282
[docs]
283class SeabedCriticalAngle(SmoothLloydMirror):
284 """The seabed critical angle propagation model.
285
286 This model accounts for geometrical spreading, surface interactions, and simple bottom interactions.
287
288 Parameters
289 ----------
290 water_depth : numeric
291 The water depth to use for the cylindrical spreading.
292 n : numeric, default 10
293 The geometrical spreading factor to use for the cylindrical spreading.
294 m : numeric, default 20
295 The geometrical spreading factor to use for the spherical spreading.
296 speed_of_sound : numeric, default 1500
297 The speed of sound in the water. Used to calculate wave numbers and the critical angle.
298 substrate_compressional_speed, numeric, default 1500
299 The speed of sound in the water. Used to calculate the critical angle.
300
301 Notes
302 -----
303 The model is split in two parts, one spherical and one cylindrical. The spherical part is
304 identical to the `SmoothLloydMirror` model, and gives the propagation factor::
305
306 F_sphere = SmoothLloydMirror(...).power_propagation(...)
307
308 The general idea for the cylindrical part is that power radiated towards the bottom will either
309 stay in the water column, and thus arrive at the receiver at some point,
310 or get transmitted into the substrate.
311 The grazing angle below which power will be contained is the critical angle ψ.
312 For high frequencies, the bottom retains an average factor::
313
314 hf = 2ψ
315
316 of the energy.
317 For low frequencies, we have a retention of::
318
319 lf = 2 (kd)**2 (ψ - sin(ψ) cos(ψ))
320
321 The low-high frequency mixing and the distance propagation is then done as::
322
323 F_cylindrical = 1 / (rH) / (1 / lf + 1 / hf)
324
325 where we use the same low-high frequency mixing as for smooth Lloyd mirror,
326 but 1 / (rH) to accommodate the cylindrical domain.
327 The final propagation factor is the sum of the spherical and cylindrical energy::
328
329 F = F_spherical + F_cylindrical
330
331 """
332
333 def __init__(self, water_depth, n=10, substrate_compressional_speed=1500, **kwargs):
334 super().__init__(**kwargs)
335 self.n = n
336 self.substrate_compressional_speed = substrate_compressional_speed
337 self.water_depth = water_depth
338
[docs]
339 def power_propagation(self, distance, frequency, receiver_depth, source_depth, **kwargs): # noqa: D417, no docs for kwargs
340 """Calculate geometrical spreading and interactions with the surface and the bottom.
341
342 Parameters
343 ----------
344 distance : array_like
345 The horizontal distance between source and receiver.
346 frequency : array_like
347 The frequencies at which to evaluate.
348 receiver_depth : array_like
349 The receiver depth. Used to compute grazing angles and slant ranges.
350 source_depth : array_like
351 The source depth. Used to compute the ``kd`` term.
352
353 Returns
354 -------
355 F : `xarray.DataArray`
356 The evaluated propagation factor.
357 """
358 surface_effect = super().power_propagation(
359 distance=distance, frequency=frequency, receiver_depth=receiver_depth, source_depth=source_depth, **kwargs
360 )
361
362 slant_range = self.slant_range(distance, receiver_depth)
363 critical_angle = np.arccos(self.speed_of_sound / self.substrate_compressional_speed)
364 kd = 2 * np.pi * frequency * source_depth / self.speed_of_sound
365 lf_approx = 2 * kd**2 * (critical_angle - np.sin(critical_angle) * np.cos(critical_angle))
366 hf_approx = 2 * critical_angle
367 cylindrical_spreading = 1 / (self.water_depth * slant_range ** (self.n / 10))
368 bottom_effect = 1 / (1 / lf_approx + 1 / hf_approx)
369
370 return surface_effect + bottom_effect * cylindrical_spreading
371
372
[docs]
373def cutoff_frequency(water_depth, substrate_compressional_speed=np.inf, speed_of_sound=1500):
374 """Calculate cutoff frequency for shallow waters.
375
376 For shallow waters, the long distance cutoff describes
377 a frequency below which there will be no waveguide.
378
379 Parameters
380 ----------
381 water_depth : array_like
382 The water depth, in meters.
383 substrate_compressional_speed : array_like
384 The speed of sound in the seabed, in m/s.
385 speed_of_sound : array_like, default=1500
386 The speed of sound in the water, in m/s.
387
388 Notes
389 -----
390 This is evaluated as [1]_::
391
392 speed_ratio = speed_of_sound / substrate_compressional_speed
393 speed_of_sound / (water_depth * 4 * (1 - speed_ratio**2)**0.5)
394
395 This means that the cutoff frequency goes up for softer substrates,
396 and up for shallower depths.
397
398 References
399 ----------
400 .. [1] F. B. Jensen, Wi. A. Kuperman, M. B. Porter, and H. Schmidt,
401 "Computational Ocean Acoustics", 2nd ed. Springer New York, 2011.
402 Eq. (1.38).
403 """
404 speed_ratio = speed_of_sound / substrate_compressional_speed
405 return speed_of_sound / (water_depth * 4 * (1 - speed_ratio**2) ** 0.5)
406
407
[docs]
408def perkins_cutoff(water_depth, substrate_compressional_speed=np.inf, speed_of_sound=1500, mode_order=1):
409 """Calculate modal cutoff for shallow waters.
410
411 For shallow waters, the long distance cutoff describes
412 a frequency below which a specific mode cannot propagate.
413
414 Parameters
415 ----------
416 water_depth : array_like
417 The water depth, in meters.
418 substrate_compressional_speed : array_like
419 The speed of sound in the seabed, in m/s.
420 speed_of_sound : array_like, default=1500
421 The speed of sound in the water, in m/s.
422 mode_order : array_like, default=1
423 Which mode order to evaluate.
424 A mode order of 1 yields the same as `cutoff_frequency`.
425
426 Notes
427 -----
428 This is evaluated as [1]_::
429
430 speed_ratio = speed_of_sound / substrate_compressional_speed
431 (mode_order - 0.5) * speed_of_sound / (2 * water_depth * (1 - speed_ratio**2)**0.5)
432
433 This means that the cutoff frequency goes up for softer substrates,
434 and up for shallower depths, and is higher for higher modes.
435
436 References
437 ----------
438 .. [1] F. B. Jensen, Wi. A. Kuperman, M. B. Porter, and H. Schmidt,
439 "Computational Ocean Acoustics", 2nd ed. Springer New York, 2011.
440 Eq. (2.191).
441 """
442 speed_ratio = speed_of_sound / substrate_compressional_speed
443 return (mode_order - 0.5) * speed_of_sound / (2 * water_depth * (1 - speed_ratio**2) ** 0.5)
444
445
446seabed_properties = {
447 "very coarse sand": {
448 "grain size": -0.5,
449 "speed of sound": 1500 * 1.307,
450 },
451 "coarse sand": {
452 "grain size": 0.5,
453 "speed of sound": 1500 * 1.250,
454 },
455 "medium sand": {
456 "grain size": 1.5,
457 "speed of sound": 1500 * 1.198,
458 },
459 "fine sand": {
460 "grain size": 2.5,
461 "speed of sound": 1500 * 1.152,
462 },
463 "very fine sand": {
464 "grain size": 3.5,
465 "speed of sound": 1500 * 1.112,
466 },
467 "coarse silt": {
468 "grain size": 4.5,
469 "speed of sound": 1500 * 1.077,
470 },
471 "medium silt": {
472 "grain size": 5.5,
473 "speed of sound": 1500 * 1.048,
474 },
475 "fine silt": {
476 "grain size": 6.5,
477 "speed of sound": 1500 * 1.024,
478 },
479 "very fine silt": {
480 "grain size": 7.5,
481 "speed of sound": 1500 * 1.005,
482 },
483}
484"""Dict with seabed properties.
485
486Properties included are grain size and speed of sound (compressional).
487Included substrates are
488
489- very coarse sand
490- coarse sand
491- medium sand
492- fine sand
493- very fine sand
494- coarse silt
495- medium silt
496- fine silt
497- very fine silt
498
499These substrates are the keys to the dict.
500
501Based on Ainslie, M.A. Principles of Sonar Performance Modeling, Springer-Verlag Berlin Heidelberg, 2010.
502"""
503
504
[docs]
505def read_valeport_data(filepath):
506 """Read Valeport swift data.
507
508 This reads data stored in the ``.vp2`` format.
509 """
510 from io import StringIO
511 import pandas
512 import re
513 import xarray as xr
514
515 with open(filepath, "r") as file:
516 contents = file.read()
517
518 lat = float(re.search(r"Latitude=([\d.]*)", contents).groups()[0])
519 lon = float(re.search(r"Longitude=([\d.]*)", contents).groups()[0])
520
521 data_idx = contents.find("[DATA]")
522 data = contents[data_idx:].splitlines()
523 data_stream = StringIO("\n".join([data[1].strip()] + data[3:]))
524 df = pandas.read_csv(data_stream, delimiter="\t", parse_dates=["Date/Time"])
525 ds = xr.Dataset.from_dataframe(df).set_coords("Depth").swap_dims(index="Depth").drop_vars("index")
526 return ds.assign(latitude=lat, longitude=lon)
527
528
529class Sweep:
530 @staticmethod
531 def modified_sweep_duration(duration, lower, upper):
532 k = round(lower * duration / np.log(upper / lower))
533 return k * np.log(upper / lower) / lower
534
535 def __init__(self, duration, lower, upper, fade_in=None, fade_out=None, modify_duration=True):
536 if modify_duration:
537 duration = self.modified_sweep_duration(duration=duration, lower=lower, upper=upper)
538 self.duration = duration
539 self.lower = lower
540 self.upper = upper
541 self.fade_in = fade_in
542 self.fade_out = fade_out
543
544 @property
545 def phase_rate(self):
546 return self.duration / np.log(self.upper / self.lower)
547
548 def make_signal(self, samplerate, prepad=0, postpad=0):
549 num_samples = int(self.duration * samplerate)
550 t = np.arange(num_samples) / samplerate
551 sweep = np.sin(2 * np.pi * self.lower * self.phase_rate * np.exp(t / self.phase_rate))
552
553 if self.fade_in:
554 fade_samples = int(self.fade_in * samplerate)
555 fade = np.sin(np.linspace(0, np.pi / 2, fade_samples))**2
556 sweep[:fade_samples] *= fade
557 if self.fade_out:
558 fade_samples = int(self.fade_out * samplerate)
559 fade = np.sin(np.linspace(np.pi / 2, 0, fade_samples))**2
560 sweep[-fade_samples:] *= fade
561
562 prepad = np.zeros(round(prepad * samplerate))
563 postpad = np.zeros(round(postpad * samplerate))
564 sweep = np.concatenate([prepad, sweep, postpad])
565 return sweep
566
567 def deconvolve(self, response, nfft=None, samplerate=None):
568 if isinstance(response, _core.TimeData):
569 return _core.FrequencyData(
570 self.deconvolve(response.data, nfft=nfft),
571 )
572 if isinstance(response, xr.DataArray):
573 nfft = nfft or response.time.size
574 samplerate = samplerate or response.time.rate
575 freq_data = xr.apply_ufunc(
576 self.deconvolve,
577 response,
578 input_core_dims=[["time"]],
579 output_core_dims=[["frequency"]],
580 kwargs={"nfft": nfft, "samplerate": samplerate},
581 )
582 freq_data.coords["frequency"] = np.fft.rfftfreq(nfft, 1 / samplerate)
583 center_offset = np.timedelta64(round(response.time.size / 2 / samplerate * 1e9), "ns")
584 freq_data.coords["time"] = response.time[0] + center_offset
585 freq_data.coords["start_time"] = response.time[0]
586 return freq_data
587
588 nfft = nfft or response.shape[-1]
589 if samplerate is None:
590 raise ValueError("Must supply samplerate to perform deconvolution")
591
592 # Sweep starts at the beginning of recording, nfft > sweep.size so it will be padded at the end.
593 # This means the conjugate (time-reversal) has the inverse sweep at the end.
594 # Now, the cyclic nature of the fft will effectively wrap the response to the beginning of the sweep,
595 # to that the pulse gets compressed to the start of the received signal.
596 sweep = self.make_signal(samplerate=samplerate, prepad=0, postpad=0)
597 sweep_spectrum = np.fft.rfft(sweep, n=nfft) / samplerate
598 response_spectrum = np.fft.rfft(response, n=nfft) / samplerate
599
600 # The sweep magnitude is (phase_rate / f)**0.5 / (2 * samplerate)
601 # It's in both the sent and received, so we divide by the square.
602 f = np.fft.rfftfreq(nfft, 1 / samplerate)
603 with np.errstate(divide="ignore"):
604 normalization = (self.phase_rate / f)**0.5 / 2
605
606 frequency_response = response_spectrum * sweep_spectrum.conjugate() / normalization**2
607 return frequency_response
608
609 def trim_frequency_response(self, frequency_response, pretrim, posttrim, sweep_start=None, fade_in=0, fade_out=0):
610 if isinstance(frequency_response, _core.FrequencyData):
611 return _core.FrequencyData(
612 self.trim_frequency_response(
613 frequency_response.data,
614 pretrim=pretrim, posttrim=posttrim,
615 sweep_start=sweep_start,
616 fade_in=fade_in, fade_out=fade_out,
617 ),
618 )
619
620 h = spectral.ifft(frequency_response)
621 h_trim = self.trim_impulse_response(
622 h,
623 pretrim=pretrim, posttrim=posttrim,
624 sweep_start=sweep_start,
625 fade_in=fade_in, fade_out=fade_out,
626 )
627 return spectral.fft(h_trim)
628
629 def trim_impulse_response(self, impulse_response, pretrim, posttrim, sweep_start=None, fade_in=0, fade_out=0):
630 if isinstance(impulse_response, _core.TimeData):
631 return _core.TimeData(
632 self.trim_impulse_response(
633 impulse_response.data,
634 pretrim=pretrim, posttrim=posttrim,
635 sweep_start=sweep_start,
636 fade_in=fade_in, fade_out=fade_out,
637 ),
638 )
639 if isinstance(impulse_response, xr.DataArray):
640 if sweep_start is None:
641 sweep_start = int(np.abs(impulse_response).argmax())
642 else:
643 sweep_start = _core.time_to_np(sweep_start)
644 sweep_start = int(abs(impulse_response.time - sweep_start).argmin())
645 pretrim = int(pretrim * impulse_response.time.rate)
646 posttrim = int(posttrim * impulse_response.time.rate)
647 fade_in = int(fade_in * impulse_response.time.rate)
648 fade_out = int(fade_out * impulse_response.time.rate)
649
650 if sweep_start is None:
651 sweep_start = np.argmax(np.abs(impulse_response))
652
653 start_idx = max(sweep_start - pretrim, 0)
654 stop_idx = sweep_start + posttrim
655
656 if isinstance(impulse_response, xr.DataArray):
657 trimmed = impulse_response.isel(time=slice(start_idx, stop_idx))
658 else:
659 trimmed = impulse_response[..., start_idx:stop_idx]
660
661 if fade_in or fade_out:
662 if isinstance(impulse_response, xr.DataArray):
663 window = np.ones(trimmed.time.size)
664 else:
665 window = np.ones(trimmed.shape[-1])
666 if fade_in:
667 window[:fade_in] = np.sin(np.linspace(0, np.pi / 2, fade_in))**2
668 if fade_out:
669 window[-fade_out:] = np.sin(np.linspace(np.pi / 2, 0, fade_out))**2
670 trimmed = trimmed * window
671 return trimmed
672
673 def frequency_over_time(self, t0, f0, t=None):
674 t0 = _core.time_to_np(t0)
675 def f(t):
676 dt = (t - t0) / np.timedelta64(1, "s")
677 f = f0 * np.exp(dt / self.phase_rate)
678 f = f[(self.lower<=f) & (f<=self.upper)]
679 return f
680 if t is not None:
681 return f(t)
682 return f
683
684 def average_in_bands(self, frequency_response, bands_per_decade):
685 if isinstance(frequency_response, _core.FrequencyData):
686 return _core.FrequencyData(self.average_in_bands(frequency_response.data, bands_per_decade))
687 # Bands that allow just a little outside the swept range, due to numerical issues with exact band edges.
688 centers, (lowers, uppers) = spectral.nth_decade_band_edges(
689 bands_per_decade=bands_per_decade,
690 min_frequency=self.lower * 0.99,
691 max_frequency=self.upper / 0.99,
692 limit_to="strict"
693 )
694 if not isinstance(frequency_response, xr.DataArray):
695 raise TypeError(f"Cannot compute the band average of frequency response of type `{frequency_response.__class__.__name__}`")
696 banded = spectral.linear_to_banded(
697 np.abs(frequency_response.data)**2,
698 lowers, uppers,
699 spectral_resolution=frequency_response.frequency[1].item(),
700 axis=frequency_response.get_axis_num("frequency"),
701 )
702 return xr.DataArray(
703 banded ** 0.5,
704 coords={
705 "frequency": centers,
706 "bandwidth": ("frequency", uppers - lowers)
707 },
708 dims=frequency_response.dims,
709 )