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.
Files changed (119) hide show
  1. microstructure_tpw-0.3.0.dist-info/METADATA +234 -0
  2. microstructure_tpw-0.3.0.dist-info/RECORD +119 -0
  3. microstructure_tpw-0.3.0.dist-info/WHEEL +5 -0
  4. microstructure_tpw-0.3.0.dist-info/entry_points.txt +7 -0
  5. microstructure_tpw-0.3.0.dist-info/licenses/LICENSE +674 -0
  6. microstructure_tpw-0.3.0.dist-info/top_level.txt +1 -0
  7. odas_tpw/__init__.py +18 -0
  8. odas_tpw/chi/__init__.py +48 -0
  9. odas_tpw/chi/batchelor.py +189 -0
  10. odas_tpw/chi/chi.py +857 -0
  11. odas_tpw/chi/fp07.py +649 -0
  12. odas_tpw/chi/l2_chi.py +153 -0
  13. odas_tpw/chi/l3_chi.py +388 -0
  14. odas_tpw/chi/l4_chi.py +391 -0
  15. odas_tpw/config_base.py +423 -0
  16. odas_tpw/perturb/__init__.py +18 -0
  17. odas_tpw/perturb/adcp.py +274 -0
  18. odas_tpw/perturb/atomic_io.py +47 -0
  19. odas_tpw/perturb/binning.py +761 -0
  20. odas_tpw/perturb/cli.py +591 -0
  21. odas_tpw/perturb/combo.py +346 -0
  22. odas_tpw/perturb/config.py +1046 -0
  23. odas_tpw/perturb/ctd.py +424 -0
  24. odas_tpw/perturb/diag/__init__.py +12 -0
  25. odas_tpw/perturb/diag/_common.py +140 -0
  26. odas_tpw/perturb/diag/chi.py +42 -0
  27. odas_tpw/perturb/diag/cli.py +67 -0
  28. odas_tpw/perturb/diag/data.py +662 -0
  29. odas_tpw/perturb/diag/epsilon.py +45 -0
  30. odas_tpw/perturb/diag/inspector.py +342 -0
  31. odas_tpw/perturb/diag/mixing.py +94 -0
  32. odas_tpw/perturb/diag/render.py +543 -0
  33. odas_tpw/perturb/discover.py +40 -0
  34. odas_tpw/perturb/fp07_cal.py +354 -0
  35. odas_tpw/perturb/gps.py +458 -0
  36. odas_tpw/perturb/hotel.py +572 -0
  37. odas_tpw/perturb/logging_setup.py +181 -0
  38. odas_tpw/perturb/merge.py +320 -0
  39. odas_tpw/perturb/mk_sections.py +310 -0
  40. odas_tpw/perturb/netcdf_schema.py +520 -0
  41. odas_tpw/perturb/pipeline.py +2654 -0
  42. odas_tpw/perturb/plot/__init__.py +15 -0
  43. odas_tpw/perturb/plot/cli.py +107 -0
  44. odas_tpw/perturb/plot/diagnostics.py +195 -0
  45. odas_tpw/perturb/plot/eps_chi.py +524 -0
  46. odas_tpw/perturb/plot/figure.py +679 -0
  47. odas_tpw/perturb/plot/gamma_scaling.py +1097 -0
  48. odas_tpw/perturb/plot/grid.py +112 -0
  49. odas_tpw/perturb/plot/layout.py +361 -0
  50. odas_tpw/perturb/plot/overview.py +547 -0
  51. odas_tpw/perturb/plot/presets/eps-chi.yaml +16 -0
  52. odas_tpw/perturb/plot/presets/profiles.yaml +32 -0
  53. odas_tpw/perturb/plot/presets/scalar.yaml +21 -0
  54. odas_tpw/perturb/plot/profiles.py +652 -0
  55. odas_tpw/perturb/plot/scalar.py +391 -0
  56. odas_tpw/perturb/plot/sections.py +506 -0
  57. odas_tpw/perturb/plot/xaxis.py +396 -0
  58. odas_tpw/perturb/qc_gate.py +177 -0
  59. odas_tpw/perturb/qc_rules.py +373 -0
  60. odas_tpw/perturb/resolve.py +306 -0
  61. odas_tpw/perturb/seawater.py +76 -0
  62. odas_tpw/perturb/speed.py +14 -0
  63. odas_tpw/perturb/trim.py +313 -0
  64. odas_tpw/processing/__init__.py +62 -0
  65. odas_tpw/processing/bottom.py +215 -0
  66. odas_tpw/processing/chi_combine.py +300 -0
  67. odas_tpw/processing/ct_align.py +172 -0
  68. odas_tpw/processing/epsilon_combine.py +210 -0
  69. odas_tpw/processing/mixing.py +630 -0
  70. odas_tpw/processing/thorpe.py +458 -0
  71. odas_tpw/processing/top_trim.py +274 -0
  72. odas_tpw/pyturb/__init__.py +3 -0
  73. odas_tpw/pyturb/__main__.py +5 -0
  74. odas_tpw/pyturb/_compat.py +204 -0
  75. odas_tpw/pyturb/_profind.py +124 -0
  76. odas_tpw/pyturb/bin.py +113 -0
  77. odas_tpw/pyturb/cli.py +175 -0
  78. odas_tpw/pyturb/eps.py +353 -0
  79. odas_tpw/pyturb/merge.py +101 -0
  80. odas_tpw/pyturb/p2nc.py +76 -0
  81. odas_tpw/rsi/__init__.py +31 -0
  82. odas_tpw/rsi/adapter.py +314 -0
  83. odas_tpw/rsi/bench.py +904 -0
  84. odas_tpw/rsi/binning.py +193 -0
  85. odas_tpw/rsi/channels.py +376 -0
  86. odas_tpw/rsi/chi_io.py +980 -0
  87. odas_tpw/rsi/cli.py +1411 -0
  88. odas_tpw/rsi/combine.py +120 -0
  89. odas_tpw/rsi/config.py +126 -0
  90. odas_tpw/rsi/config_patch.py +790 -0
  91. odas_tpw/rsi/convert.py +651 -0
  92. odas_tpw/rsi/deconvolve.py +119 -0
  93. odas_tpw/rsi/diss_look.py +343 -0
  94. odas_tpw/rsi/dissipation.py +669 -0
  95. odas_tpw/rsi/helpers.py +527 -0
  96. odas_tpw/rsi/mixing_look.py +509 -0
  97. odas_tpw/rsi/p_file.py +771 -0
  98. odas_tpw/rsi/pipeline.py +924 -0
  99. odas_tpw/rsi/profile.py +624 -0
  100. odas_tpw/rsi/quick_look.py +758 -0
  101. odas_tpw/rsi/sensor_inventory.py +684 -0
  102. odas_tpw/rsi/shear_noise.py +128 -0
  103. odas_tpw/rsi/speed.py +221 -0
  104. odas_tpw/rsi/vehicle.py +74 -0
  105. odas_tpw/rsi/viewer_base.py +1071 -0
  106. odas_tpw/rsi/window.py +459 -0
  107. odas_tpw/scor160/__init__.py +36 -0
  108. odas_tpw/scor160/cli.py +242 -0
  109. odas_tpw/scor160/compare.py +484 -0
  110. odas_tpw/scor160/despike.py +227 -0
  111. odas_tpw/scor160/goodman.py +288 -0
  112. odas_tpw/scor160/io.py +690 -0
  113. odas_tpw/scor160/l2.py +311 -0
  114. odas_tpw/scor160/l3.py +279 -0
  115. odas_tpw/scor160/l4.py +850 -0
  116. odas_tpw/scor160/nasmyth.py +208 -0
  117. odas_tpw/scor160/ocean.py +287 -0
  118. odas_tpw/scor160/profile.py +231 -0
  119. 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)