microstructure-tpw 0.3.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.
- microstructure_tpw-0.3.0.dist-info/METADATA +234 -0
- microstructure_tpw-0.3.0.dist-info/RECORD +119 -0
- microstructure_tpw-0.3.0.dist-info/WHEEL +5 -0
- microstructure_tpw-0.3.0.dist-info/entry_points.txt +7 -0
- microstructure_tpw-0.3.0.dist-info/licenses/LICENSE +674 -0
- microstructure_tpw-0.3.0.dist-info/top_level.txt +1 -0
- odas_tpw/__init__.py +18 -0
- odas_tpw/chi/__init__.py +48 -0
- odas_tpw/chi/batchelor.py +189 -0
- odas_tpw/chi/chi.py +857 -0
- odas_tpw/chi/fp07.py +649 -0
- odas_tpw/chi/l2_chi.py +153 -0
- odas_tpw/chi/l3_chi.py +388 -0
- odas_tpw/chi/l4_chi.py +391 -0
- odas_tpw/config_base.py +423 -0
- odas_tpw/perturb/__init__.py +18 -0
- odas_tpw/perturb/adcp.py +274 -0
- odas_tpw/perturb/atomic_io.py +47 -0
- odas_tpw/perturb/binning.py +761 -0
- odas_tpw/perturb/cli.py +591 -0
- odas_tpw/perturb/combo.py +346 -0
- odas_tpw/perturb/config.py +1046 -0
- odas_tpw/perturb/ctd.py +424 -0
- odas_tpw/perturb/diag/__init__.py +12 -0
- odas_tpw/perturb/diag/_common.py +140 -0
- odas_tpw/perturb/diag/chi.py +42 -0
- odas_tpw/perturb/diag/cli.py +67 -0
- odas_tpw/perturb/diag/data.py +662 -0
- odas_tpw/perturb/diag/epsilon.py +45 -0
- odas_tpw/perturb/diag/inspector.py +342 -0
- odas_tpw/perturb/diag/mixing.py +94 -0
- odas_tpw/perturb/diag/render.py +543 -0
- odas_tpw/perturb/discover.py +40 -0
- odas_tpw/perturb/fp07_cal.py +354 -0
- odas_tpw/perturb/gps.py +458 -0
- odas_tpw/perturb/hotel.py +572 -0
- odas_tpw/perturb/logging_setup.py +181 -0
- odas_tpw/perturb/merge.py +320 -0
- odas_tpw/perturb/mk_sections.py +310 -0
- odas_tpw/perturb/netcdf_schema.py +520 -0
- odas_tpw/perturb/pipeline.py +2654 -0
- odas_tpw/perturb/plot/__init__.py +15 -0
- odas_tpw/perturb/plot/cli.py +107 -0
- odas_tpw/perturb/plot/diagnostics.py +195 -0
- odas_tpw/perturb/plot/eps_chi.py +524 -0
- odas_tpw/perturb/plot/figure.py +679 -0
- odas_tpw/perturb/plot/gamma_scaling.py +1097 -0
- odas_tpw/perturb/plot/grid.py +112 -0
- odas_tpw/perturb/plot/layout.py +361 -0
- odas_tpw/perturb/plot/overview.py +547 -0
- odas_tpw/perturb/plot/presets/eps-chi.yaml +16 -0
- odas_tpw/perturb/plot/presets/profiles.yaml +32 -0
- odas_tpw/perturb/plot/presets/scalar.yaml +21 -0
- odas_tpw/perturb/plot/profiles.py +652 -0
- odas_tpw/perturb/plot/scalar.py +391 -0
- odas_tpw/perturb/plot/sections.py +506 -0
- odas_tpw/perturb/plot/xaxis.py +396 -0
- odas_tpw/perturb/qc_gate.py +177 -0
- odas_tpw/perturb/qc_rules.py +373 -0
- odas_tpw/perturb/resolve.py +306 -0
- odas_tpw/perturb/seawater.py +76 -0
- odas_tpw/perturb/speed.py +14 -0
- odas_tpw/perturb/trim.py +313 -0
- odas_tpw/processing/__init__.py +62 -0
- odas_tpw/processing/bottom.py +215 -0
- odas_tpw/processing/chi_combine.py +300 -0
- odas_tpw/processing/ct_align.py +172 -0
- odas_tpw/processing/epsilon_combine.py +210 -0
- odas_tpw/processing/mixing.py +630 -0
- odas_tpw/processing/thorpe.py +458 -0
- odas_tpw/processing/top_trim.py +274 -0
- odas_tpw/pyturb/__init__.py +3 -0
- odas_tpw/pyturb/__main__.py +5 -0
- odas_tpw/pyturb/_compat.py +204 -0
- odas_tpw/pyturb/_profind.py +124 -0
- odas_tpw/pyturb/bin.py +113 -0
- odas_tpw/pyturb/cli.py +175 -0
- odas_tpw/pyturb/eps.py +353 -0
- odas_tpw/pyturb/merge.py +101 -0
- odas_tpw/pyturb/p2nc.py +76 -0
- odas_tpw/rsi/__init__.py +31 -0
- odas_tpw/rsi/adapter.py +314 -0
- odas_tpw/rsi/bench.py +904 -0
- odas_tpw/rsi/binning.py +193 -0
- odas_tpw/rsi/channels.py +376 -0
- odas_tpw/rsi/chi_io.py +980 -0
- odas_tpw/rsi/cli.py +1411 -0
- odas_tpw/rsi/combine.py +120 -0
- odas_tpw/rsi/config.py +126 -0
- odas_tpw/rsi/config_patch.py +790 -0
- odas_tpw/rsi/convert.py +651 -0
- odas_tpw/rsi/deconvolve.py +119 -0
- odas_tpw/rsi/diss_look.py +343 -0
- odas_tpw/rsi/dissipation.py +669 -0
- odas_tpw/rsi/helpers.py +527 -0
- odas_tpw/rsi/mixing_look.py +509 -0
- odas_tpw/rsi/p_file.py +771 -0
- odas_tpw/rsi/pipeline.py +924 -0
- odas_tpw/rsi/profile.py +624 -0
- odas_tpw/rsi/quick_look.py +758 -0
- odas_tpw/rsi/sensor_inventory.py +684 -0
- odas_tpw/rsi/shear_noise.py +128 -0
- odas_tpw/rsi/speed.py +221 -0
- odas_tpw/rsi/vehicle.py +74 -0
- odas_tpw/rsi/viewer_base.py +1071 -0
- odas_tpw/rsi/window.py +459 -0
- odas_tpw/scor160/__init__.py +36 -0
- odas_tpw/scor160/cli.py +242 -0
- odas_tpw/scor160/compare.py +484 -0
- odas_tpw/scor160/despike.py +227 -0
- odas_tpw/scor160/goodman.py +288 -0
- odas_tpw/scor160/io.py +690 -0
- odas_tpw/scor160/l2.py +311 -0
- odas_tpw/scor160/l3.py +279 -0
- odas_tpw/scor160/l4.py +850 -0
- odas_tpw/scor160/nasmyth.py +208 -0
- odas_tpw/scor160/ocean.py +287 -0
- odas_tpw/scor160/profile.py +231 -0
- odas_tpw/scor160/spectral.py +482 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# Mar-2026, Claude and Pat Welch, pat@mousebrains.com
|
|
2
|
+
"""Profile detection for vertical profilers.
|
|
3
|
+
|
|
4
|
+
General-purpose algorithms for detecting profiling segments in pressure
|
|
5
|
+
time series. Not instrument-specific — works with any pressure/fall-rate
|
|
6
|
+
data.
|
|
7
|
+
|
|
8
|
+
Port of get_profile.m from the ODAS MATLAB library.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
import numpy.typing as npt
|
|
15
|
+
from scipy.signal import butter, filtfilt
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _safe_filtfilt(b: np.ndarray, a: np.ndarray, x: np.ndarray) -> np.ndarray:
|
|
19
|
+
"""filtfilt that no-ops on inputs too short for its default padlen.
|
|
20
|
+
|
|
21
|
+
A 1st-order Butterworth gives padlen = 3*max(len(a), len(b)) = 6, so
|
|
22
|
+
filtfilt raises ``ValueError`` for len(x) <= 6. For such short series there
|
|
23
|
+
is nothing meaningful to zero-phase smooth, so return the input unchanged
|
|
24
|
+
rather than crashing the whole profile/file.
|
|
25
|
+
"""
|
|
26
|
+
x = np.asarray(x)
|
|
27
|
+
padlen = 3 * max(len(a), len(b))
|
|
28
|
+
if x.shape[0] <= padlen:
|
|
29
|
+
return x
|
|
30
|
+
return np.asarray(filtfilt(b, a, x))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def smooth_fall_rate(P: np.ndarray, fs: float, tau: float = 1.5) -> np.ndarray:
|
|
34
|
+
"""Compute smoothed fall rate from pressure.
|
|
35
|
+
|
|
36
|
+
Matches ODAS odas_p2mat.m lines 699-701: central-difference gradient
|
|
37
|
+
followed by a zero-phase first-order Butterworth low-pass filter at
|
|
38
|
+
cutoff frequency ``0.68 / tau``.
|
|
39
|
+
|
|
40
|
+
Parameters
|
|
41
|
+
----------
|
|
42
|
+
P : ndarray
|
|
43
|
+
Pressure [dbar].
|
|
44
|
+
fs : float
|
|
45
|
+
Sampling rate [Hz].
|
|
46
|
+
tau : float
|
|
47
|
+
Smoothing time constant [s]. Default: 1.5 (VMP).
|
|
48
|
+
|
|
49
|
+
Returns
|
|
50
|
+
-------
|
|
51
|
+
W : ndarray
|
|
52
|
+
Smoothed fall rate [dbar/s].
|
|
53
|
+
"""
|
|
54
|
+
P = P.astype(np.float64)
|
|
55
|
+
if P.shape[0] < 2:
|
|
56
|
+
# np.gradient needs >= 2 samples; a single pressure sample has no rate.
|
|
57
|
+
return np.zeros_like(P)
|
|
58
|
+
W = np.gradient(P, 1.0 / fs)
|
|
59
|
+
f_c = 0.68 / tau
|
|
60
|
+
b, a = butter(1, f_c / (fs / 2.0))
|
|
61
|
+
return _safe_filtfilt(b, a, W)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def compute_speed_fast(
|
|
65
|
+
P_slow: np.ndarray,
|
|
66
|
+
t_fast: np.ndarray,
|
|
67
|
+
t_slow: np.ndarray,
|
|
68
|
+
fs_fast: float,
|
|
69
|
+
fs_slow: float,
|
|
70
|
+
tau: float = 1.5,
|
|
71
|
+
speed_min: float = 0.05,
|
|
72
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
73
|
+
"""Compute profiling speed from pressure, interpolated to fast rate.
|
|
74
|
+
|
|
75
|
+
Steps:
|
|
76
|
+
1. W = gradient(P_slow) zero-phase filtered with Butterworth at
|
|
77
|
+
0.68/tau (= ODAS odas_p2mat.m fall-rate smoothing)
|
|
78
|
+
2. speed = abs(W), interpolated to the fast time grid
|
|
79
|
+
3. Second zero-phase smoothing pass at 0.68/tau on the fast grid
|
|
80
|
+
4. Clamped to speed_min
|
|
81
|
+
|
|
82
|
+
Note: ODAS computes the fast-rate speed directly from gradient(P_fast)
|
|
83
|
+
in a single smoothing pass (odas_p2mat.m:699-707); the slow-grid
|
|
84
|
+
interpolation plus second pass used here gives a slightly smoother
|
|
85
|
+
speed but is not identical.
|
|
86
|
+
|
|
87
|
+
Parameters
|
|
88
|
+
----------
|
|
89
|
+
P_slow : ndarray
|
|
90
|
+
Pressure [dbar] at slow rate.
|
|
91
|
+
t_fast, t_slow : ndarray
|
|
92
|
+
Time vectors for fast and slow rates.
|
|
93
|
+
fs_fast, fs_slow : float
|
|
94
|
+
Sampling rates [Hz].
|
|
95
|
+
tau : float
|
|
96
|
+
Smoothing time constant [s].
|
|
97
|
+
speed_min : float
|
|
98
|
+
Minimum profiling speed [m/s].
|
|
99
|
+
|
|
100
|
+
Returns
|
|
101
|
+
-------
|
|
102
|
+
speed_fast : ndarray
|
|
103
|
+
Profiling speed at fast rate [dbar/s, numerically treated as m/s].
|
|
104
|
+
W_slow : ndarray
|
|
105
|
+
Smoothed fall rate at slow rate [dbar/s].
|
|
106
|
+
"""
|
|
107
|
+
W_slow = smooth_fall_rate(P_slow, fs_slow, tau=tau)
|
|
108
|
+
speed_fast = np.interp(t_fast, t_slow, np.abs(W_slow))
|
|
109
|
+
|
|
110
|
+
f_c = 0.68 / tau
|
|
111
|
+
b_f, a_f = butter(1, f_c / (fs_fast / 2.0))
|
|
112
|
+
speed_fast = _safe_filtfilt(b_f, a_f, speed_fast)
|
|
113
|
+
|
|
114
|
+
speed_fast = np.maximum(speed_fast, speed_min)
|
|
115
|
+
|
|
116
|
+
return speed_fast, W_slow
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def smooth_speed_interp(
|
|
120
|
+
speed_slow: np.ndarray,
|
|
121
|
+
t_fast: np.ndarray,
|
|
122
|
+
t_slow: np.ndarray,
|
|
123
|
+
fs_fast: float,
|
|
124
|
+
tau: float,
|
|
125
|
+
speed_min: float = 0.05,
|
|
126
|
+
) -> np.ndarray:
|
|
127
|
+
"""Interpolate and smooth profiling speed from slow to fast rate.
|
|
128
|
+
|
|
129
|
+
Applies a first-order Butterworth low-pass filter at cutoff
|
|
130
|
+
``0.68 / tau`` to the interpolated speed, then clamps to speed_min.
|
|
131
|
+
|
|
132
|
+
Parameters
|
|
133
|
+
----------
|
|
134
|
+
speed_slow : ndarray
|
|
135
|
+
Absolute profiling speed at slow rate [m/s].
|
|
136
|
+
t_fast, t_slow : ndarray
|
|
137
|
+
Time vectors for fast and slow rates.
|
|
138
|
+
fs_fast : float
|
|
139
|
+
Fast sampling rate [Hz].
|
|
140
|
+
tau : float
|
|
141
|
+
Smoothing time constant [s].
|
|
142
|
+
speed_min : float
|
|
143
|
+
Minimum profiling speed [m/s].
|
|
144
|
+
|
|
145
|
+
Returns
|
|
146
|
+
-------
|
|
147
|
+
speed_fast : ndarray
|
|
148
|
+
Smoothed profiling speed at fast rate [m/s].
|
|
149
|
+
"""
|
|
150
|
+
pspd_rel = np.interp(t_fast, t_slow, speed_slow)
|
|
151
|
+
f_c = 0.68 / tau
|
|
152
|
+
b_f, a_f = butter(1, f_c / (fs_fast / 2.0))
|
|
153
|
+
pspd_rel = _safe_filtfilt(b_f, a_f, pspd_rel)
|
|
154
|
+
return np.asarray(np.maximum(pspd_rel, speed_min))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def get_profiles(
|
|
158
|
+
P: npt.ArrayLike,
|
|
159
|
+
W: npt.ArrayLike,
|
|
160
|
+
fs: float,
|
|
161
|
+
P_min: float = 0.5,
|
|
162
|
+
W_min: float = 0.3,
|
|
163
|
+
direction: str = "down",
|
|
164
|
+
min_duration: float = 7.0,
|
|
165
|
+
) -> list[tuple[int, int]]:
|
|
166
|
+
"""Find profiling segments in pressure data.
|
|
167
|
+
|
|
168
|
+
Parameters
|
|
169
|
+
----------
|
|
170
|
+
P : array_like
|
|
171
|
+
Pressure vector [dbar].
|
|
172
|
+
W : array_like
|
|
173
|
+
Rate of change of pressure [dbar/s] (positive = downward).
|
|
174
|
+
fs : float
|
|
175
|
+
Sampling rate of P and W [Hz].
|
|
176
|
+
P_min : float
|
|
177
|
+
Minimum pressure for a valid profile [dbar].
|
|
178
|
+
W_min : float
|
|
179
|
+
Minimum fall/rise rate magnitude [dbar/s].
|
|
180
|
+
direction : str
|
|
181
|
+
``'down'``, ``'up'``, ``'glide'`` (both up and down), or
|
|
182
|
+
``'horizontal'`` (either sign, for towed/AUV instruments).
|
|
183
|
+
min_duration : float
|
|
184
|
+
Minimum profile duration [s].
|
|
185
|
+
|
|
186
|
+
Returns
|
|
187
|
+
-------
|
|
188
|
+
list of (int, int)
|
|
189
|
+
Start and end indices (inclusive) of each detected profile.
|
|
190
|
+
"""
|
|
191
|
+
d = direction.lower()
|
|
192
|
+
|
|
193
|
+
if d == "glide":
|
|
194
|
+
# Detect both up and down segments, merge and sort by start index
|
|
195
|
+
down = get_profiles(P, W, fs, P_min, W_min, "down", min_duration)
|
|
196
|
+
up = get_profiles(P, W, fs, P_min, W_min, "up", min_duration)
|
|
197
|
+
merged = sorted(down + up, key=lambda t: t[0])
|
|
198
|
+
return merged
|
|
199
|
+
|
|
200
|
+
P = np.asarray(P, dtype=np.float64).ravel()
|
|
201
|
+
W = np.asarray(W, dtype=np.float64).ravel()
|
|
202
|
+
min_samples = int(min_duration * fs)
|
|
203
|
+
|
|
204
|
+
if d == "up":
|
|
205
|
+
W = -W
|
|
206
|
+
elif d == "horizontal":
|
|
207
|
+
W = np.abs(W)
|
|
208
|
+
|
|
209
|
+
# Find valid samples
|
|
210
|
+
mask = (P_min < P) & (W_min <= W)
|
|
211
|
+
n = np.where(mask)[0]
|
|
212
|
+
|
|
213
|
+
if len(n) < min_samples:
|
|
214
|
+
return []
|
|
215
|
+
|
|
216
|
+
# Find breaks between contiguous segments
|
|
217
|
+
dn = np.diff(n)
|
|
218
|
+
breaks = np.where(dn > 1)[0]
|
|
219
|
+
|
|
220
|
+
if len(breaks) == 0:
|
|
221
|
+
profiles = [(int(n[0]), int(n[-1]))]
|
|
222
|
+
else:
|
|
223
|
+
profiles = []
|
|
224
|
+
profiles.append((int(n[0]), int(n[breaks[0]])))
|
|
225
|
+
for i in range(1, len(breaks)):
|
|
226
|
+
profiles.append((int(n[breaks[i - 1] + 1]), int(n[breaks[i]])))
|
|
227
|
+
profiles.append((int(n[breaks[-1] + 1]), int(n[-1])))
|
|
228
|
+
|
|
229
|
+
# Filter by minimum duration
|
|
230
|
+
profiles = [(s, e) for s, e in profiles if (e - s) >= min_samples]
|
|
231
|
+
return profiles
|
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
# Mar-2026, Claude and Pat Welch, pat@mousebrains.com
|
|
2
|
+
"""Cross-spectral density estimation.
|
|
3
|
+
|
|
4
|
+
Port of csd_odas.m and csd_matrix_odas.m from the ODAS MATLAB library.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import NamedTuple
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
import numpy.typing as npt
|
|
11
|
+
from scipy import signal
|
|
12
|
+
from scipy.fft import rfft as _rfft
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CSDResult(NamedTuple):
|
|
16
|
+
"""Cross-spectral density result."""
|
|
17
|
+
|
|
18
|
+
Cxy: np.ndarray
|
|
19
|
+
F: np.ndarray
|
|
20
|
+
Cxx: np.ndarray | None
|
|
21
|
+
Cyy: np.ndarray | None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _cosine_window(n: int) -> np.ndarray:
|
|
25
|
+
"""ODAS cosine window, normalized to RMS = 1.
|
|
26
|
+
|
|
27
|
+
Window = 1 + cos(pi * (-1 + 2*i/n)) for i = 0..n-1.
|
|
28
|
+
This is the Hann window.
|
|
29
|
+
"""
|
|
30
|
+
w = 1.0 + np.cos(np.pi * (-1.0 + 2.0 * np.arange(n) / n))
|
|
31
|
+
w /= np.sqrt(np.mean(w**2))
|
|
32
|
+
return w
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
_window_cache: dict[int, np.ndarray] = {}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _get_window(nfft: int) -> np.ndarray:
|
|
39
|
+
"""Cached cosine window — avoids recomputing for repeated nfft values."""
|
|
40
|
+
if nfft not in _window_cache:
|
|
41
|
+
_window_cache[nfft] = _cosine_window(nfft)
|
|
42
|
+
return _window_cache[nfft]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _detrend_segment(seg: np.ndarray, method: str, ramp: np.ndarray) -> np.ndarray:
|
|
46
|
+
"""Detrend a single FFT segment.
|
|
47
|
+
|
|
48
|
+
NaN/inf values are replaced with zero before detrending (so scipy's
|
|
49
|
+
detrend / polyfit do not raise) and restored afterward, so the FFT yields
|
|
50
|
+
NaN for corrupted windows -- mirroring :func:`_detrend_batch`. Without this
|
|
51
|
+
a single NaN sample made the caller (Goodman cleaning) mis-report an
|
|
52
|
+
"insufficient FFT segments" error and silently skip cleaning.
|
|
53
|
+
"""
|
|
54
|
+
if method == "none":
|
|
55
|
+
return seg
|
|
56
|
+
bad = ~np.isfinite(seg)
|
|
57
|
+
has_bad = bool(np.any(bad))
|
|
58
|
+
if has_bad:
|
|
59
|
+
seg = seg.copy()
|
|
60
|
+
seg[bad] = 0.0
|
|
61
|
+
if method == "constant":
|
|
62
|
+
result = seg - np.mean(seg)
|
|
63
|
+
elif method == "linear":
|
|
64
|
+
result = signal.detrend(seg, type="linear")
|
|
65
|
+
else:
|
|
66
|
+
# parabolic or cubic
|
|
67
|
+
order = {"parabolic": 2, "cubic": 3}[method]
|
|
68
|
+
p = np.polyfit(ramp, seg, order)
|
|
69
|
+
result = seg - np.polyval(p, ramp)
|
|
70
|
+
if has_bad:
|
|
71
|
+
result = np.array(result, dtype=np.float64)
|
|
72
|
+
result[bad] = np.nan
|
|
73
|
+
return result
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def csd_odas(
|
|
77
|
+
x: npt.ArrayLike,
|
|
78
|
+
y: npt.ArrayLike | None,
|
|
79
|
+
nfft: int,
|
|
80
|
+
rate: float,
|
|
81
|
+
window: npt.ArrayLike | None = None,
|
|
82
|
+
overlap: int | None = None,
|
|
83
|
+
detrend: str = "linear",
|
|
84
|
+
) -> CSDResult:
|
|
85
|
+
"""Cross-spectral density using Welch's method with cosine window.
|
|
86
|
+
|
|
87
|
+
Thin wrapper around :func:`csd_matrix` for 1-D signals.
|
|
88
|
+
|
|
89
|
+
Parameters
|
|
90
|
+
----------
|
|
91
|
+
x : array_like
|
|
92
|
+
First signal.
|
|
93
|
+
y : array_like or None
|
|
94
|
+
Second signal. If None, compute auto-spectrum only.
|
|
95
|
+
nfft : int
|
|
96
|
+
FFT segment length.
|
|
97
|
+
rate : float
|
|
98
|
+
Sampling rate [Hz].
|
|
99
|
+
window : array_like or None
|
|
100
|
+
Window of length nfft. Default: cosine window normalized to RMS=1.
|
|
101
|
+
overlap : int or None
|
|
102
|
+
Overlap in samples. Default: nfft // 2.
|
|
103
|
+
detrend : str
|
|
104
|
+
Detrending: 'none', 'constant', 'linear', 'parabolic', 'cubic'.
|
|
105
|
+
|
|
106
|
+
Returns
|
|
107
|
+
-------
|
|
108
|
+
Cxy : ndarray
|
|
109
|
+
Cross-spectrum (complex) or auto-spectrum (real).
|
|
110
|
+
F : ndarray
|
|
111
|
+
Frequency vector [Hz], 0 to Nyquist.
|
|
112
|
+
Cxx : ndarray or None
|
|
113
|
+
Auto-spectrum of x (only if cross-spectrum computed).
|
|
114
|
+
Cyy : ndarray or None
|
|
115
|
+
Auto-spectrum of y (only if cross-spectrum computed).
|
|
116
|
+
"""
|
|
117
|
+
x = np.asarray(x, dtype=np.float64).ravel()
|
|
118
|
+
if y is not None:
|
|
119
|
+
y_arr = np.asarray(y, dtype=np.float64).ravel()
|
|
120
|
+
if len(x) != len(y_arr):
|
|
121
|
+
raise ValueError("x and y must have same length")
|
|
122
|
+
y_arr = None if np.array_equal(x, y_arr) else y_arr[:, np.newaxis]
|
|
123
|
+
else:
|
|
124
|
+
y_arr = None
|
|
125
|
+
|
|
126
|
+
x_2d = x[:, np.newaxis] # (N, 1)
|
|
127
|
+
Cxy_m, F, Cxx_m, Cyy_m = csd_matrix(
|
|
128
|
+
x_2d,
|
|
129
|
+
y_arr,
|
|
130
|
+
nfft,
|
|
131
|
+
rate,
|
|
132
|
+
window=window,
|
|
133
|
+
overlap=overlap,
|
|
134
|
+
detrend=detrend,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
if Cxx_m is None:
|
|
138
|
+
# Auto-spectrum: Cxy_m is (n_freq, 1, 1) complex → squeeze to real 1-D
|
|
139
|
+
return CSDResult(np.real(Cxy_m[:, 0, 0]), F, None, None)
|
|
140
|
+
|
|
141
|
+
# Cross-spectrum: squeeze matrix dims
|
|
142
|
+
return CSDResult(Cxy_m[:, 0, 0], F, np.real(Cxx_m[:, 0, 0]), np.real(Cyy_m[:, 0, 0]))
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def csd_matrix(
|
|
146
|
+
x: np.ndarray,
|
|
147
|
+
y: np.ndarray | None,
|
|
148
|
+
nfft: int,
|
|
149
|
+
rate: float,
|
|
150
|
+
window: npt.ArrayLike | None = None,
|
|
151
|
+
overlap: int | None = None,
|
|
152
|
+
detrend: str = "linear",
|
|
153
|
+
) -> CSDResult:
|
|
154
|
+
"""Multi-channel cross-spectral density matrix.
|
|
155
|
+
|
|
156
|
+
Parameters
|
|
157
|
+
----------
|
|
158
|
+
x : ndarray, shape (N, n_x)
|
|
159
|
+
Matrix of signals, one per column.
|
|
160
|
+
y : ndarray or None, shape (N, n_y)
|
|
161
|
+
Second matrix. If None, compute auto-spectral matrix of x only.
|
|
162
|
+
nfft : int
|
|
163
|
+
FFT segment length.
|
|
164
|
+
rate : float
|
|
165
|
+
Sampling rate [Hz].
|
|
166
|
+
window, overlap, detrend : as in csd_odas.
|
|
167
|
+
|
|
168
|
+
Returns
|
|
169
|
+
-------
|
|
170
|
+
Cxy : ndarray, shape (n_freq, n_x, n_y) or (n_freq, n_x, n_x)
|
|
171
|
+
Cross-spectral matrix. If y is None, auto-spectral matrix of x.
|
|
172
|
+
F : ndarray
|
|
173
|
+
Frequency vector [Hz].
|
|
174
|
+
Cxx : ndarray or None
|
|
175
|
+
Auto-spectral matrix of x (only if y is not None).
|
|
176
|
+
Cyy : ndarray or None
|
|
177
|
+
Auto-spectral matrix of y (only if y is not None).
|
|
178
|
+
"""
|
|
179
|
+
x = np.atleast_2d(np.asarray(x, dtype=np.float64))
|
|
180
|
+
# Port-faithfulness: csd_matrix_odas.m:99-100 forces only row vectors (1xN)
|
|
181
|
+
# to column orientation via isrow(). The previous `shape[0] < shape[1]`
|
|
182
|
+
# heuristic also silently transposed genuine wide MxN matrices; instead we
|
|
183
|
+
# require samples-major 2-D input and let the 2*nfft check below reject
|
|
184
|
+
# mis-oriented matrices.
|
|
185
|
+
if x.ndim == 2 and x.shape[0] == 1:
|
|
186
|
+
x = x.T
|
|
187
|
+
if x.shape[0] < 2 * nfft:
|
|
188
|
+
raise ValueError(f"Input length ({x.shape[0]}) must be at least 2*nfft ({2 * nfft})")
|
|
189
|
+
n_x = x.shape[1]
|
|
190
|
+
|
|
191
|
+
auto = y is None
|
|
192
|
+
if not auto:
|
|
193
|
+
y = np.atleast_2d(np.asarray(y, dtype=np.float64))
|
|
194
|
+
# Same isrow-only orientation rule as for x (see above).
|
|
195
|
+
if y.ndim == 2 and y.shape[0] == 1:
|
|
196
|
+
y = y.T
|
|
197
|
+
n_y = y.shape[1]
|
|
198
|
+
if x.shape[0] != y.shape[0]:
|
|
199
|
+
raise ValueError("x and y must have same number of rows")
|
|
200
|
+
|
|
201
|
+
if overlap is None:
|
|
202
|
+
overlap = nfft // 2
|
|
203
|
+
elif overlap < 0:
|
|
204
|
+
# Port-faithfulness: csd_matrix_odas.m:132-133 maps a negative overlap to
|
|
205
|
+
# 0 (non-overlapping segments), not to nfft/2.
|
|
206
|
+
overlap = 0
|
|
207
|
+
if overlap > 0.9 * nfft:
|
|
208
|
+
# csd_matrix_odas.m:134-135 clamps excessive overlap to nfft/2;
|
|
209
|
+
# overlap >= nfft would give step <= 0 (infinite loop / no segments)
|
|
210
|
+
overlap = nfft // 2
|
|
211
|
+
if window is None:
|
|
212
|
+
window = _get_window(nfft)
|
|
213
|
+
else:
|
|
214
|
+
window = np.asarray(window, dtype=np.float64)
|
|
215
|
+
# Robustness: csd_matrix_odas.m:124-126 errors unless the window length
|
|
216
|
+
# equals nfft. Without this, a length-1/scalar window broadcasts
|
|
217
|
+
# silently to a rectangular window, yielding a wrong (un-windowed)
|
|
218
|
+
# spectrum instead of an error.
|
|
219
|
+
if window.ndim != 1 or window.shape[0] != nfft:
|
|
220
|
+
raise ValueError(
|
|
221
|
+
f"window must be a 1-D array of length nfft={nfft}, got shape {window.shape}"
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
n_freq = nfft // 2 + 1
|
|
225
|
+
step = nfft - overlap
|
|
226
|
+
n_seg = (x.shape[0] - overlap) // step
|
|
227
|
+
ramp = np.arange(nfft, dtype=np.float64)
|
|
228
|
+
|
|
229
|
+
if auto:
|
|
230
|
+
Cxy = np.zeros((n_freq, n_x, n_x), dtype=np.complex128)
|
|
231
|
+
for i in range(n_seg):
|
|
232
|
+
s = i * step
|
|
233
|
+
seg = np.empty((nfft, n_x))
|
|
234
|
+
for c in range(n_x):
|
|
235
|
+
seg[:, c] = _detrend_segment(x[s : s + nfft, c], detrend, ramp) * window
|
|
236
|
+
fft_x = _rfft(seg, n=nfft, axis=0) # (n_freq, n_x)
|
|
237
|
+
# Cxy[f, i, j] = fft_x[f, j] * conj(fft_x[f, i])
|
|
238
|
+
Cxy += np.conj(fft_x[:, :, np.newaxis]) * fft_x[:, np.newaxis, :]
|
|
239
|
+
Cxy /= n_seg
|
|
240
|
+
Cxy /= nfft * rate / 2
|
|
241
|
+
Cxy[0] /= 2
|
|
242
|
+
Cxy[-1] /= 2
|
|
243
|
+
F = np.arange(n_freq) * rate / nfft
|
|
244
|
+
return CSDResult(Cxy, F, None, None)
|
|
245
|
+
|
|
246
|
+
# Cross case
|
|
247
|
+
Cxx = np.zeros((n_freq, n_x, n_x), dtype=np.complex128)
|
|
248
|
+
Cyy = np.zeros((n_freq, n_y, n_y), dtype=np.complex128)
|
|
249
|
+
Cxy = np.zeros((n_freq, n_x, n_y), dtype=np.complex128)
|
|
250
|
+
|
|
251
|
+
for i in range(n_seg):
|
|
252
|
+
s = i * step
|
|
253
|
+
seg_x = np.empty((nfft, n_x))
|
|
254
|
+
seg_y = np.empty((nfft, n_y))
|
|
255
|
+
for c in range(n_x):
|
|
256
|
+
seg_x[:, c] = _detrend_segment(x[s : s + nfft, c], detrend, ramp) * window
|
|
257
|
+
for c in range(n_y):
|
|
258
|
+
seg_y[:, c] = _detrend_segment(y[s : s + nfft, c], detrend, ramp) * window
|
|
259
|
+
fft_x = _rfft(seg_x, n=nfft, axis=0)
|
|
260
|
+
fft_y = _rfft(seg_y, n=nfft, axis=0)
|
|
261
|
+
# Cxx[f,i,j] = fft_x[f,j] * conj(fft_x[f,i])
|
|
262
|
+
Cxx += np.conj(fft_x[:, :, np.newaxis]) * fft_x[:, np.newaxis, :]
|
|
263
|
+
Cyy += np.conj(fft_y[:, :, np.newaxis]) * fft_y[:, np.newaxis, :]
|
|
264
|
+
# Cxy[f,i,j] = conj(fft_x[f,i]) * fft_y[f,j]
|
|
265
|
+
Cxy += np.conj(fft_x[:, :, np.newaxis]) * fft_y[:, np.newaxis, :]
|
|
266
|
+
|
|
267
|
+
norm = n_seg * nfft * rate / 2
|
|
268
|
+
for arr in (Cxx, Cyy, Cxy):
|
|
269
|
+
arr /= norm
|
|
270
|
+
# DC (f=0) and Nyquist bins are not doubled when folding to a one-sided
|
|
271
|
+
# spectrum (they have no negative-frequency twin), so halve them back
|
|
272
|
+
# (#76; matches csd_matrix_odas.m's one-sided normalization).
|
|
273
|
+
arr[0] /= 2
|
|
274
|
+
arr[-1] /= 2
|
|
275
|
+
F = np.arange(n_freq) * rate / nfft
|
|
276
|
+
return CSDResult(Cxy, F, Cxx, Cyy)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _detrend_batch(segments: np.ndarray, method: str, axis: int) -> np.ndarray:
|
|
280
|
+
"""Detrend an array of FFT segments along *axis* (the sample axis).
|
|
281
|
+
|
|
282
|
+
NaN/inf values are replaced with zero before detrending (so that
|
|
283
|
+
scipy.signal.detrend does not raise) and restored afterward, so that
|
|
284
|
+
downstream FFT produces NaN for corrupted windows.
|
|
285
|
+
|
|
286
|
+
Parameters
|
|
287
|
+
----------
|
|
288
|
+
segments : ndarray
|
|
289
|
+
Array whose *axis* dimension has length nfft.
|
|
290
|
+
method : str
|
|
291
|
+
One of 'none', 'constant', 'linear', 'parabolic', 'cubic'.
|
|
292
|
+
axis : int
|
|
293
|
+
Axis corresponding to the nfft (sample) dimension.
|
|
294
|
+
|
|
295
|
+
Returns
|
|
296
|
+
-------
|
|
297
|
+
ndarray — detrended copy (or the original, for 'none').
|
|
298
|
+
"""
|
|
299
|
+
if method == "none":
|
|
300
|
+
return segments
|
|
301
|
+
|
|
302
|
+
# Guard against NaN/inf — scipy.signal.detrend raises on non-finite
|
|
303
|
+
bad = ~np.isfinite(segments)
|
|
304
|
+
has_bad = np.any(bad)
|
|
305
|
+
if has_bad:
|
|
306
|
+
segments = segments.copy()
|
|
307
|
+
segments[bad] = 0.0
|
|
308
|
+
|
|
309
|
+
if method == "constant":
|
|
310
|
+
result = segments - np.mean(segments, axis=axis, keepdims=True)
|
|
311
|
+
elif method == "linear":
|
|
312
|
+
result = signal.detrend(segments, axis=axis, type="linear")
|
|
313
|
+
else:
|
|
314
|
+
# parabolic / cubic — vectorised polynomial fit along *axis*
|
|
315
|
+
nfft = segments.shape[axis]
|
|
316
|
+
order = {"parabolic": 2, "cubic": 3}[method]
|
|
317
|
+
ramp = np.arange(nfft, dtype=np.float64)
|
|
318
|
+
moved = np.moveaxis(segments, axis, -1) # (..., nfft)
|
|
319
|
+
flat = moved.reshape(-1, nfft) # (M, nfft)
|
|
320
|
+
coeffs = np.polynomial.polynomial.polyfit(ramp, flat.T, order)
|
|
321
|
+
trend = np.polynomial.polynomial.polyval(ramp, coeffs)
|
|
322
|
+
detrended = flat - trend
|
|
323
|
+
result = np.moveaxis(detrended.reshape(moved.shape), -1, axis)
|
|
324
|
+
|
|
325
|
+
if has_bad:
|
|
326
|
+
result[bad] = np.nan
|
|
327
|
+
|
|
328
|
+
return result
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def csd_matrix_batch(
|
|
332
|
+
x_windows: np.ndarray,
|
|
333
|
+
y_windows: np.ndarray | None,
|
|
334
|
+
nfft: int,
|
|
335
|
+
rate: float,
|
|
336
|
+
overlap: int | None = None,
|
|
337
|
+
detrend: str = "linear",
|
|
338
|
+
) -> CSDResult:
|
|
339
|
+
"""Batched cross-spectral density for multiple dissipation windows.
|
|
340
|
+
|
|
341
|
+
Computes the same result as calling :func:`csd_matrix` independently on
|
|
342
|
+
each window, but extracts all FFT segments, detrends, windows, and
|
|
343
|
+
transforms them in one vectorised pass — eliminating per-window Python
|
|
344
|
+
loops.
|
|
345
|
+
|
|
346
|
+
Parameters
|
|
347
|
+
----------
|
|
348
|
+
x_windows : ndarray, shape (n_windows, diss_length, n_x)
|
|
349
|
+
Stacked dissipation windows for the *x* channels.
|
|
350
|
+
y_windows : ndarray or None, shape (n_windows, diss_length, n_y)
|
|
351
|
+
Stacked dissipation windows for the *y* channels.
|
|
352
|
+
If None, only auto-spectral matrices of *x* are computed.
|
|
353
|
+
nfft : int
|
|
354
|
+
FFT segment length (e.g. 256).
|
|
355
|
+
rate : float
|
|
356
|
+
Sampling rate [Hz].
|
|
357
|
+
overlap : int or None
|
|
358
|
+
Overlap in samples. Default: ``nfft // 2``.
|
|
359
|
+
detrend : str
|
|
360
|
+
Detrending method: 'none', 'constant', 'linear', 'parabolic',
|
|
361
|
+
'cubic'.
|
|
362
|
+
|
|
363
|
+
Returns
|
|
364
|
+
-------
|
|
365
|
+
Cxy : ndarray, shape (n_windows, n_freq, n_x, n_y)
|
|
366
|
+
Cross-spectral matrix between *x* and *y* channels.
|
|
367
|
+
If *y_windows* is None, this is the auto-spectral matrix of *x*
|
|
368
|
+
with shape (n_windows, n_freq, n_x, n_x).
|
|
369
|
+
F : ndarray, shape (n_freq,)
|
|
370
|
+
Frequency vector [Hz], from 0 to Nyquist.
|
|
371
|
+
Cxx : ndarray or None
|
|
372
|
+
Auto-spectral matrix of *x*, shape (n_windows, n_freq, n_x, n_x).
|
|
373
|
+
Returned only when *y_windows* is not None.
|
|
374
|
+
Cyy : ndarray or None
|
|
375
|
+
Auto-spectral matrix of *y*, shape (n_windows, n_freq, n_y, n_y).
|
|
376
|
+
Returned only when *y_windows* is not None.
|
|
377
|
+
"""
|
|
378
|
+
x_windows = np.asarray(x_windows, dtype=np.float64)
|
|
379
|
+
if x_windows.ndim != 3:
|
|
380
|
+
raise ValueError(
|
|
381
|
+
f"x_windows must be 3-D (n_windows, diss_length, n_x), got shape {x_windows.shape}"
|
|
382
|
+
)
|
|
383
|
+
n_windows, diss_length, _n_x = x_windows.shape
|
|
384
|
+
|
|
385
|
+
# Port-faithfulness/robustness: enforce the same minimum length as the
|
|
386
|
+
# non-batch csd_matrix and csd_matrix_odas.m (>=2 segments). A window
|
|
387
|
+
# between nfft and 2*nfft would otherwise yield a single FFT segment and a
|
|
388
|
+
# statistically-weak spectrum.
|
|
389
|
+
if diss_length < 2 * nfft:
|
|
390
|
+
raise ValueError(f"diss_length ({diss_length}) must be at least 2*nfft ({2 * nfft})")
|
|
391
|
+
|
|
392
|
+
# Clamp overlap to a valid range (mirrors the non-batch csd_matrix): a
|
|
393
|
+
# negative overlap maps to 0 (non-overlapping segments, per
|
|
394
|
+
# csd_matrix_odas.m:132-133), and an overlap == nfft gives step == 0 and a
|
|
395
|
+
# ZeroDivisionError below while overlap > nfft makes step negative.
|
|
396
|
+
if overlap is None:
|
|
397
|
+
overlap = nfft // 2
|
|
398
|
+
elif overlap < 0:
|
|
399
|
+
overlap = 0
|
|
400
|
+
if overlap > 0.9 * nfft:
|
|
401
|
+
overlap = nfft // 2
|
|
402
|
+
step = nfft - overlap
|
|
403
|
+
n_freq = nfft // 2 + 1
|
|
404
|
+
|
|
405
|
+
# --- Segment extraction ------------------------------------------------
|
|
406
|
+
# Segment start indices within each window
|
|
407
|
+
n_seg = (diss_length - overlap) // step
|
|
408
|
+
if n_seg < 1:
|
|
409
|
+
raise ValueError(
|
|
410
|
+
f"diss_length ({diss_length}) too short for nfft={nfft}, overlap={overlap}"
|
|
411
|
+
)
|
|
412
|
+
seg_starts = np.arange(n_seg) * step # (n_seg,)
|
|
413
|
+
|
|
414
|
+
# Build a (nfft,) index offset and broadcast to extract all segments.
|
|
415
|
+
# indices shape: (n_seg, nfft)
|
|
416
|
+
indices = seg_starts[:, np.newaxis] + np.arange(nfft)[np.newaxis, :]
|
|
417
|
+
|
|
418
|
+
# x_windows[:, indices, :] → (n_windows, n_seg, nfft, n_x)
|
|
419
|
+
x_segs = x_windows[:, indices, :]
|
|
420
|
+
|
|
421
|
+
# --- Detrend along the nfft axis (axis=2) ------------------------------
|
|
422
|
+
x_segs = _detrend_batch(x_segs, detrend, axis=2)
|
|
423
|
+
|
|
424
|
+
# --- Apply cosine window (broadcast over windows, segments, channels) --
|
|
425
|
+
win = _get_window(nfft) # (nfft,)
|
|
426
|
+
x_segs *= win[np.newaxis, np.newaxis, :, np.newaxis]
|
|
427
|
+
|
|
428
|
+
# --- FFT along the nfft axis -------------------------------------------
|
|
429
|
+
# fft_x shape: (n_windows, n_seg, n_freq, n_x)
|
|
430
|
+
fft_x = _rfft(x_segs, n=nfft, axis=2)
|
|
431
|
+
|
|
432
|
+
# Free memory — segments no longer needed
|
|
433
|
+
del x_segs
|
|
434
|
+
|
|
435
|
+
# --- Normalization constant --------------------------------------------
|
|
436
|
+
norm = nfft * rate / 2.0
|
|
437
|
+
|
|
438
|
+
auto = y_windows is None
|
|
439
|
+
|
|
440
|
+
if auto:
|
|
441
|
+
# Auto-spectral matrix: Cxy[w, f, i, j] = <conj(X_i) * X_j>
|
|
442
|
+
# Average over segments, then normalize.
|
|
443
|
+
# einsum: for each (w, f), outer product over channel dimension
|
|
444
|
+
# fft_x: (n_windows, n_seg, n_freq, n_x)
|
|
445
|
+
Cxy = np.einsum("wsfi,wsfj->wfij", np.conj(fft_x), fft_x) # (n_windows, n_freq, n_x, n_x)
|
|
446
|
+
Cxy /= n_seg
|
|
447
|
+
Cxy /= norm
|
|
448
|
+
Cxy[:, 0, :, :] /= 2
|
|
449
|
+
Cxy[:, -1, :, :] /= 2
|
|
450
|
+
F = np.arange(n_freq) * rate / nfft
|
|
451
|
+
return CSDResult(Cxy, F, None, None)
|
|
452
|
+
|
|
453
|
+
# --- Cross-spectral case -----------------------------------------------
|
|
454
|
+
y_windows = np.asarray(y_windows, dtype=np.float64)
|
|
455
|
+
if y_windows.ndim != 3:
|
|
456
|
+
raise ValueError(
|
|
457
|
+
f"y_windows must be 3-D (n_windows, diss_length, n_y), got shape {y_windows.shape}"
|
|
458
|
+
)
|
|
459
|
+
if y_windows.shape[0] != n_windows or y_windows.shape[1] != diss_length:
|
|
460
|
+
raise ValueError(
|
|
461
|
+
f"y_windows shape {y_windows.shape} incompatible with x_windows shape {x_windows.shape}"
|
|
462
|
+
)
|
|
463
|
+
y_segs = y_windows[:, indices, :] # (n_windows, n_seg, nfft, n_y)
|
|
464
|
+
y_segs = _detrend_batch(y_segs, detrend, axis=2)
|
|
465
|
+
y_segs *= win[np.newaxis, np.newaxis, :, np.newaxis]
|
|
466
|
+
fft_y = _rfft(y_segs, n=nfft, axis=2)
|
|
467
|
+
del y_segs
|
|
468
|
+
|
|
469
|
+
# Cxx[w,f,i,j] = <conj(Xi) * Xj>
|
|
470
|
+
Cxx = np.einsum("wsfi,wsfj->wfij", np.conj(fft_x), fft_x)
|
|
471
|
+
# Cyy[w,f,i,j] = <conj(Yi) * Yj>
|
|
472
|
+
Cyy = np.einsum("wsfi,wsfj->wfij", np.conj(fft_y), fft_y)
|
|
473
|
+
# Cxy[w,f,i,j] = <conj(Xi) * Yj>
|
|
474
|
+
Cxy = np.einsum("wsfi,wsfj->wfij", np.conj(fft_x), fft_y)
|
|
475
|
+
|
|
476
|
+
for arr in (Cxx, Cyy, Cxy):
|
|
477
|
+
arr /= n_seg * norm
|
|
478
|
+
arr[:, 0, :, :] /= 2
|
|
479
|
+
arr[:, -1, :, :] /= 2
|
|
480
|
+
|
|
481
|
+
F = np.arange(n_freq) * rate / nfft
|
|
482
|
+
return CSDResult(Cxy, F, Cxx, Cyy)
|