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,227 @@
1
+ # Mar-2026, Claude and Pat Welch, pat@mousebrains.com
2
+ """Spike removal for shear probe and micro-conductivity signals.
3
+
4
+ Port of despike.m from the ODAS MATLAB library.
5
+ """
6
+
7
+ import warnings
8
+ from typing import NamedTuple
9
+
10
+ import numpy as np
11
+ import numpy.typing as npt
12
+ from scipy.signal import butter, filtfilt
13
+
14
+
15
+ def _matlab_padlen(b: np.ndarray, a: np.ndarray) -> int:
16
+ """Return the ``filtfilt`` *padlen* matching MATLAB's convention.
17
+
18
+ MATLAB pads with ``3*(nfilt-1)`` reflected samples where
19
+ ``nfilt = max(len(a), len(b))``; scipy defaults to ``3*nfilt``.
20
+ For a 1st-order Butterworth (nfilt=2) this is padlen=3 vs scipy's 6.
21
+ Replicated locally (rather than imported from ``l2``) to avoid a
22
+ circular import, mirroring ``l2._matlab_padlen``.
23
+ """
24
+ return 3 * (max(len(a), len(b)) - 1)
25
+
26
+
27
+ class DespikeResult(NamedTuple):
28
+ """Result of iterative spike removal."""
29
+
30
+ y: np.ndarray
31
+ spike_indices: np.ndarray
32
+ n_passes: int
33
+ fraction: float
34
+
35
+
36
+ def despike(
37
+ signal_in: npt.ArrayLike,
38
+ fs: float,
39
+ thresh: float = 8,
40
+ smooth: float = 0.5,
41
+ N: int | None = None,
42
+ max_passes: int = 10,
43
+ ) -> DespikeResult:
44
+ """Iterative spike removal.
45
+
46
+ Identifies spikes by comparing the instantaneous rectified HP-filtered
47
+ signal against its smoothed envelope. Replaces spike neighborhoods with
48
+ local mean.
49
+
50
+ Parameters
51
+ ----------
52
+ signal_in : array_like
53
+ Signal to despike.
54
+ fs : float
55
+ Sampling rate [Hz].
56
+ thresh : float
57
+ Ratio threshold for spike detection. Default: 8.
58
+ smooth : float
59
+ Low-pass cutoff [Hz] for envelope smoothing. Default: 0.5.
60
+ N : int or None
61
+ Spike removal half-width [samples]. A total of ~1.5*N points are
62
+ removed around each spike. Default: round(0.04 * fs) (~40 ms).
63
+ max_passes : int
64
+ Maximum despiking iterations. Default: 10.
65
+
66
+ Returns
67
+ -------
68
+ y : ndarray
69
+ Despiked signal.
70
+ spike_indices : ndarray
71
+ Indices of detected spikes in original signal.
72
+ n_passes : int
73
+ Number of iterations performed.
74
+ fraction : float
75
+ Fraction of data replaced.
76
+ """
77
+ if N is None:
78
+ N = round(0.04 * fs)
79
+
80
+ y = np.array(signal_in, dtype=np.float64).ravel()
81
+ # Non-finite samples propagate through filtfilt and turn the entire
82
+ # detection ratio into NaN, silently disabling ALL spike removal so
83
+ # genuine spikes survive. ODAS removes/interpolates NaNs upstream; this
84
+ # port does not, so at minimum warn to make the silent no-op observable.
85
+ if y.size and not np.all(np.isfinite(y)):
86
+ warnings.warn(
87
+ "despike: input contains non-finite samples; filtfilt will "
88
+ "propagate them and spike detection is disabled for this array. "
89
+ "Remove or interpolate NaN/Inf before despiking.",
90
+ RuntimeWarning,
91
+ stacklevel=2,
92
+ )
93
+ original = y.copy()
94
+ all_spikes: set[int] = set()
95
+ n_passes = 0
96
+
97
+ for _ in range(max_passes):
98
+ y_new, spikes = _single_despike(y, thresh, smooth, fs, N)
99
+ if len(spikes) == 0:
100
+ break
101
+ y = y_new
102
+ all_spikes.update(spikes)
103
+ n_passes += 1
104
+
105
+ spike_indices = np.sort(np.array(list(all_spikes), dtype=np.intp))
106
+ # NaN-safe change count: ``y != original`` reports True at unchanged NaN
107
+ # positions (NaN != NaN), so a no-op pass on NaN-containing input used to
108
+ # report a spurious nonzero fraction. Exclude the NaN==NaN positions.
109
+ changed = (y != original) & ~(np.isnan(y) & np.isnan(original))
110
+ fraction = float(np.sum(changed)) / len(y) if len(y) else 0.0
111
+ return DespikeResult(y, spike_indices, n_passes, fraction)
112
+
113
+
114
+ def _single_despike(
115
+ dv: np.ndarray, thresh: float, smooth: float, fs: float, N: int
116
+ ) -> tuple[np.ndarray, np.ndarray]:
117
+ """Single pass of spike detection and removal."""
118
+ # Port note: MATLAB despike.m uses a float half-width (N/2) and rounds the
119
+ # resulting window boundaries, so for odd N its bad-window can differ from
120
+ # this floored N//2 by ~1 sample on each side. The physical effect is
121
+ # sub-sample and the default N=round(0.04*fs)=20 (fs=512) is even, so the
122
+ # default path matches MATLAB exactly; documented rather than changed.
123
+ N_half = N // 2
124
+ dv = dv.copy()
125
+ length = len(dv)
126
+
127
+ # Zero-pad with reflected ends to handle edge effects
128
+ pad_len = min(length, 2 * int(fs / smooth))
129
+ dv_padded = np.concatenate(
130
+ [
131
+ dv[pad_len - 1 :: -1],
132
+ dv,
133
+ dv[-1 : -pad_len - 1 : -1],
134
+ ]
135
+ )
136
+ rng = slice(pad_len, pad_len + length)
137
+
138
+ # High-pass filter at 0.5 Hz, rectify.
139
+ # Use MATLAB's padlen (3) not scipy's default (6): restores port
140
+ # faithfulness and avoids a ValueError crash on short (e.g. length-2,
141
+ # padded-to-6) sections where scipy requires len(x) > padlen. Guard the
142
+ # still-too-short remainder (e.g. length-1, padded-to-3) so despike
143
+ # degrades to a no-op instead of crashing.
144
+ b_hp, a_hp = butter(1, 0.5 / (fs / 2), btype="high")
145
+ scipy_min_len = _matlab_padlen(b_hp, a_hp) + 1
146
+ if len(dv_padded) < scipy_min_len:
147
+ return dv, np.array([], dtype=np.intp)
148
+ dv_hp = np.abs(filtfilt(b_hp, a_hp, dv_padded, padlen=_matlab_padlen(b_hp, a_hp)))
149
+
150
+ # Smooth envelope
151
+ b_lp, a_lp = butter(1, smooth / (fs / 2))
152
+ dv_lp = filtfilt(b_lp, a_lp, dv_hp, padlen=_matlab_padlen(b_lp, a_lp))
153
+
154
+ # Detect spikes (avoid divide-by-zero)
155
+ with np.errstate(divide="ignore", invalid="ignore"):
156
+ ratio = dv_hp / dv_lp
157
+ spike_idx = np.where(ratio > thresh)[0]
158
+
159
+ # Ignore spikes in padding
160
+ spike_idx = spike_idx[(spike_idx >= rng.start) & (spike_idx < rng.stop)]
161
+ if len(spike_idx) == 0:
162
+ return dv, np.array([], dtype=np.intp)
163
+
164
+ # Mark bad points: N_half before spike, 2*N_half after spike
165
+ good = np.ones(len(dv_padded), dtype=bool)
166
+ R = round(fs / (4 * smooth)) # averaging region size
167
+
168
+ for s in spike_idx:
169
+ lo = max(0, s - N_half)
170
+ hi = min(len(good), s + 2 * N_half + 1)
171
+ good[lo:hi] = False
172
+
173
+ # Find contiguous bad regions and replace with local mean
174
+ transitions = np.diff(good.astype(np.int8))
175
+ starts = np.where(transitions == -1)[0] + 1
176
+ ends = np.where(transitions == 1)[0] + 1
177
+
178
+ # Handle edge cases
179
+ if not good[0]:
180
+ starts = np.concatenate([[0], starts])
181
+ if not good[-1]:
182
+ ends = np.concatenate([ends, [len(good)]])
183
+
184
+ boundary_nan = False
185
+ for s_bad, e_bad in zip(starts, ends):
186
+ # Average from region R before start
187
+ before_idx = np.arange(max(0, s_bad - R), s_bad)
188
+ before_vals = (
189
+ dv_padded[before_idx[good[before_idx]]] if len(before_idx) > 0 else np.array([])
190
+ )
191
+
192
+ # Average from region R after end
193
+ after_idx = np.arange(e_bad, min(len(good), e_bad + R))
194
+ after_vals = dv_padded[after_idx[good[after_idx]]] if len(after_idx) > 0 else np.array([])
195
+
196
+ # Match MATLAB despike.m: start_value/stop_value are sum/length, so an
197
+ # empty region gives 0/0 = NaN and the average (NaN + x)/2 = NaN. The
198
+ # prior port fabricated 0.0 (and one-sided fallbacks), silently
199
+ # injecting plausible-looking data instead of a detectable NaN.
200
+ start_value = np.mean(before_vals) if len(before_vals) > 0 else np.nan
201
+ stop_value = np.mean(after_vals) if len(after_vals) > 0 else np.nan
202
+ replacement = (start_value + stop_value) / 2
203
+
204
+ # A spike region touching a boundary can have no clean samples on one
205
+ # side, so the MATLAB-parity replacement above is NaN. Keep that NaN
206
+ # (never fabricate), but make it observable rather than silent —
207
+ # mirroring the non-finite-input warning. Only reachable for very short
208
+ # sections; a full-length record never trips it.
209
+ if not np.isfinite(replacement):
210
+ boundary_nan = True
211
+
212
+ dv_padded[s_bad:e_bad] = replacement
213
+
214
+ if boundary_nan:
215
+ warnings.warn(
216
+ "despike: a spike region adjacent to the array boundary had no "
217
+ "clean neighboring samples on one side; those samples are set to "
218
+ "NaN (matching ODAS despike.m) rather than fabricated. This "
219
+ "typically affects only very short sections.",
220
+ RuntimeWarning,
221
+ stacklevel=2,
222
+ )
223
+
224
+ # Remove padding, return spike indices in original coordinates
225
+ result = dv_padded[rng]
226
+ spikes_orig = spike_idx - pad_len
227
+ return result, spikes_orig
@@ -0,0 +1,288 @@
1
+ # Mar-2026, Claude and Pat Welch, pat@mousebrains.com
2
+ """Goodman coherent noise removal for shear probe spectra.
3
+
4
+ Port of clean_shear_spec.m from the ODAS MATLAB library.
5
+ Removes vibration-coherent noise from shear spectra using
6
+ accelerometer cross-spectra.
7
+ """
8
+
9
+ from typing import NamedTuple
10
+
11
+ import numpy as np
12
+
13
+ from odas_tpw.scor160.spectral import csd_matrix, csd_matrix_batch
14
+
15
+
16
+ def _sanitize_autospectra(clean_UU: np.ndarray) -> np.ndarray:
17
+ """Sanitize cleaned auto-spectrum (diagonal) bins.
18
+
19
+ Two corrections, both on the diagonal auto-spectra:
20
+
21
+ * Non-finite -> NaN. A singular accelerometer matrix AA cannot be cleaned.
22
+ np.linalg.solve raises LinAlgError on some LAPACK builds (handled by the
23
+ per-frequency fallback, which NaNs the bin) but on others returns inf/NaN
24
+ WITHOUT raising -- so the cleaned bin is inf/NaN here instead. Map any
25
+ non-finite bin to NaN so l4's bad-bin handling excludes it *portably*,
26
+ rather than the result depending on whether solve happened to raise.
27
+ * Finite negative -> 0. An ill-conditioned (not exactly singular) AA can
28
+ over-subtract and drive a cleaned auto-spectrum slightly negative, which
29
+ is physically impossible (a PSD cannot be < 0). Floor to 0 (matching the
30
+ variance-method polyfit's zero-bin handling) so the epsilon integral / FOM
31
+ see consistent non-negative power.
32
+
33
+ Operates on the last two (n_sh, n_sh) axes, so it works for both the single
34
+ (n_freq, ...) and batched (n_win, n_freq, ...) shapes. Only the diagonal is
35
+ touched (and taken real — an auto-spectrum is real by definition), so a
36
+ complex ``clean_UU`` keeps its off-diagonal cleaned cross-spectra intact.
37
+ """
38
+ n_sh = clean_UU.shape[-1]
39
+ for i in range(n_sh):
40
+ diag_i = np.real(clean_UU[..., i, i])
41
+ clean_UU[..., i, i] = np.where(
42
+ ~np.isfinite(diag_i), np.nan, np.where(diag_i < 0, 0.0, diag_i)
43
+ )
44
+ return clean_UU
45
+
46
+
47
+ def _nan_singular_bins(clean_UU: np.ndarray, AA: np.ndarray) -> np.ndarray:
48
+ """NaN whole frequency bins whose accelerometer matrix AA is singular.
49
+
50
+ ``np.linalg.solve``'s output for a singular AA is platform-dependent -- it
51
+ raises ``LinAlgError`` on some LAPACK builds (handled by the per-frequency
52
+ fallback) but returns inf/NaN or even finite garbage on others. Detect the
53
+ singular bins portably via the condition number (cond == inf or astronomical
54
+ for an exactly rank-deficient AA, e.g. duplicate/collinear accelerometer
55
+ channels) and NaN those bins so the result does not depend on the solver's
56
+ behavior. Merely ill-conditioned bins (cond <= 1e13) are left to
57
+ :func:`_sanitize_autospectra`'s floor. clean_UU: (..., n_sh, n_sh); AA:
58
+ (..., n_ac, n_ac) with matching leading dims.
59
+ """
60
+ # A NaN/inf in AA (a vibration dropout flows into the accel auto-spectrum)
61
+ # makes np.linalg.cond's SVD raise LinAlgError ("SVD did not converge"),
62
+ # which the errstate does NOT catch -- it would abort the whole .p file.
63
+ # Exclude non-finite AA matrices from the cond/SVD and treat them as bad
64
+ # bins directly, preserving l4's graceful NaN-bin handling.
65
+ finite_AA = np.all(np.isfinite(AA), axis=(-2, -1))
66
+ cond = np.full(AA.shape[:-2], np.inf)
67
+ if np.any(finite_AA):
68
+ with np.errstate(invalid="ignore", divide="ignore"):
69
+ cond[finite_AA] = np.linalg.cond(AA[finite_AA])
70
+ bad = ~np.isfinite(cond) | (cond > 1.0e13)
71
+ if np.any(bad):
72
+ clean_UU[bad] = np.nan
73
+ return clean_UU
74
+
75
+
76
+ class CleanShearResult(NamedTuple):
77
+ """Result of Goodman coherent noise removal."""
78
+
79
+ clean_UU: np.ndarray
80
+ AA: np.ndarray
81
+ UU: np.ndarray
82
+ UA: np.ndarray
83
+ F: np.ndarray
84
+
85
+
86
+ def _bias_correction(n_samples: int, nfft: int, n_accel: int) -> float:
87
+ """Goodman bias correction factor (ODAS TN-061, Eq. 3).
88
+
89
+ Factor 1.02 accounts for effective degrees-of-freedom reduction
90
+ from overlapping FFT segments (Goodman 2006, ODAS TN-061).
91
+ """
92
+ fft_segments = 2 * n_samples // nfft - 1
93
+ if fft_segments <= 1.02 * n_accel:
94
+ import warnings
95
+
96
+ warnings.warn(
97
+ f"Insufficient FFT segments ({fft_segments}) for Goodman bias "
98
+ f"correction with {n_accel} accelerometers; skipping correction",
99
+ stacklevel=3,
100
+ )
101
+ return 1.0
102
+ return 1.0 / (1.0 - 1.02 * n_accel / fft_segments)
103
+
104
+
105
+ def clean_shear_spec(
106
+ accel: np.ndarray,
107
+ shear: np.ndarray,
108
+ nfft: int,
109
+ rate: float,
110
+ ) -> CleanShearResult:
111
+ """Remove acceleration-coherent noise from shear spectra.
112
+
113
+ Parameters
114
+ ----------
115
+ accel : ndarray, shape (N, n_accel)
116
+ Accelerometer signals (columns).
117
+ shear : ndarray, shape (N, n_shear)
118
+ Shear probe signals (columns).
119
+ nfft : int
120
+ FFT segment length.
121
+ rate : float
122
+ Sampling rate [Hz].
123
+
124
+ Returns
125
+ -------
126
+ clean_UU : ndarray, shape (n_freq, n_shear, n_shear)
127
+ Cleaned shear spectral matrix. Diagonal elements are the
128
+ cleaned auto-spectra.
129
+ AA : ndarray, shape (n_freq, n_accel, n_accel)
130
+ Accelerometer spectral matrix.
131
+ UU : ndarray, shape (n_freq, n_shear, n_shear)
132
+ Original (uncleaned) shear spectral matrix.
133
+ UA : ndarray, shape (n_freq, n_shear, n_accel)
134
+ Shear-accelerometer cross-spectral matrix.
135
+ F : ndarray
136
+ Frequency vector [Hz].
137
+ """
138
+ accel = np.atleast_2d(np.asarray(accel, dtype=np.float64))
139
+ shear = np.atleast_2d(np.asarray(shear, dtype=np.float64))
140
+ if accel.shape[0] < accel.shape[1]:
141
+ accel = accel.T
142
+ if shear.shape[0] < shear.shape[1]:
143
+ shear = shear.T
144
+ if accel.shape[0] != shear.shape[0]:
145
+ raise ValueError("accel and shear must have same number of rows")
146
+
147
+ n_accel = accel.shape[1]
148
+
149
+ # Compute all spectra in one call: x=shear, y=accel
150
+ # Returns Cxy=(n_freq, n_sh, n_ac), Cxx=(n_freq, n_sh, n_sh), Cyy=(n_freq, n_ac, n_ac)
151
+ try:
152
+ UA, F, UU, AA = csd_matrix(shear, accel, nfft, rate, overlap=nfft // 2, detrend="linear")
153
+ except ValueError:
154
+ # Signal too short for cross-spectrum — fall back to shear auto-spectrum
155
+ import warnings
156
+
157
+ warnings.warn(
158
+ f"Insufficient FFT segments for Goodman cleaning "
159
+ f"(signal length {shear.shape[0]} < 2*nfft {2 * nfft}); "
160
+ f"returning uncleaned spectra",
161
+ stacklevel=2,
162
+ )
163
+ n_sh = shear.shape[1]
164
+ n_ac = accel.shape[1]
165
+ try:
166
+ # Compute the uncleaned shear auto-spectrum so callers still
167
+ # get a usable spectrum. Subject to the same 2*nfft length
168
+ # check as the cross-spectrum, so this succeeds only when the
169
+ # failure above was not a length error.
170
+ UU, F, _, _ = csd_matrix(shear, None, nfft, rate, overlap=nfft // 2, detrend="linear")
171
+ except ValueError:
172
+ # Even auto-spectrum fails — return zeros
173
+ n_freq_fb = nfft // 2 + 1
174
+ F = np.arange(n_freq_fb) * rate / nfft
175
+ UU = np.zeros((n_freq_fb, n_sh, n_sh), dtype=np.complex128)
176
+ n_freq_fb = len(F)
177
+ AA = np.zeros((n_freq_fb, n_ac, n_ac), dtype=np.complex128)
178
+ UA = np.zeros((n_freq_fb, n_sh, n_ac), dtype=np.complex128)
179
+ # Keep the fallback consistent with the cleaned path (Gem-D-c): return a
180
+ # complex matrix with a sanitized real diagonal rather than np.real(UU),
181
+ # so clean_UU has one dtype across code paths and the off-diagonal is not
182
+ # silently dropped here. .copy() so we don't mutate the raw UU field.
183
+ return CleanShearResult(_sanitize_autospectra(UU.copy()), AA, UU, UA, F)
184
+ assert UU is not None and AA is not None # always returned when y is provided
185
+
186
+ # UU, AA, UA are complex; extract real diagonal for auto-spectra
187
+ n_freq = len(F)
188
+
189
+ # Batched linear solve: np.linalg.solve supports batch dimensions
190
+ # clean = UU - UA @ inv(AA) @ conj(UA).T
191
+ try:
192
+ ua_H = np.conj(np.swapaxes(UA, -2, -1)) # (n_freq, n_ac, n_sh)
193
+ solved = np.linalg.solve(AA, ua_H) # (n_freq, n_ac, n_sh)
194
+ clean_UU = UU - np.matmul(UA, solved) # (n_freq, n_sh, n_sh)
195
+ except np.linalg.LinAlgError:
196
+ # Fallback: per-frequency with singular handling. A singular AA[f]
197
+ # cannot be cleaned; NaN that bin so downstream treats it as bad rather
198
+ # than passing the UNCLEANED raw UU through (and then bias-correcting it
199
+ # as if it had been cleaned).
200
+ clean_UU = np.copy(UU)
201
+ for f in range(n_freq):
202
+ try:
203
+ clean_UU[f] = UU[f] - UA[f] @ np.linalg.solve(AA[f], np.conj(UA[f]).T)
204
+ except np.linalg.LinAlgError:
205
+ clean_UU[f] = np.nan
206
+
207
+ # Keep clean_UU complex: sanitize only the (real) diagonal auto-spectra, so
208
+ # the off-diagonal cleaned cross-spectra are preserved for callers that want
209
+ # them (e.g. cross-probe coherence). Current consumers take np.real of the
210
+ # diagonal, which is numerically unchanged. (2026-07-03 review, Gem-D-c.)
211
+ clean_UU = _sanitize_autospectra(clean_UU)
212
+ clean_UU = _nan_singular_bins(clean_UU, AA)
213
+
214
+ # Bias correction (ODAS Technical Note 61, Eq. 3)
215
+ clean_UU *= _bias_correction(shear.shape[0], nfft, n_accel)
216
+
217
+ return CleanShearResult(clean_UU, AA, UU, UA, F)
218
+
219
+
220
+ def clean_shear_spec_batch(
221
+ accel_windows: np.ndarray,
222
+ shear_windows: np.ndarray,
223
+ nfft: int,
224
+ rate: float,
225
+ ) -> tuple[np.ndarray, np.ndarray]:
226
+ """Batched Goodman noise removal for all dissipation windows at once.
227
+
228
+ Parameters
229
+ ----------
230
+ accel_windows : ndarray, shape (n_windows, diss_length, n_accel)
231
+ shear_windows : ndarray, shape (n_windows, diss_length, n_shear)
232
+ nfft : int
233
+ rate : float
234
+
235
+ Returns
236
+ -------
237
+ clean_UU : ndarray, shape (n_windows, n_freq, n_shear, n_shear)
238
+ Cleaned shear spectral matrices. Complex: the diagonal auto-spectra are
239
+ real (and non-negative), the off-diagonal cleaned cross-spectra retain
240
+ their imaginary part. Consumers of the auto-spectra take ``np.real`` of
241
+ the diagonal.
242
+ F : ndarray, shape (n_freq,)
243
+ Frequency vector [Hz].
244
+ """
245
+ n_accel = accel_windows.shape[2]
246
+
247
+ # Batched CSD: x=shear, y=accel
248
+ # UA: (n_win, n_freq, n_sh, n_ac)
249
+ # UU: (n_win, n_freq, n_sh, n_sh)
250
+ # AA: (n_win, n_freq, n_ac, n_ac)
251
+ UA, F, UU, AA = csd_matrix_batch(
252
+ shear_windows,
253
+ accel_windows,
254
+ nfft,
255
+ rate,
256
+ overlap=nfft // 2,
257
+ detrend="linear",
258
+ )
259
+ assert UU is not None and AA is not None # guaranteed when y is not None
260
+
261
+ # Goodman cleaning: clean = UU - UA @ inv(AA) @ conj(UA).T
262
+ # np.linalg.solve broadcasts over leading dims (n_win, n_freq)
263
+ ua_H = np.conj(np.swapaxes(UA, -2, -1)) # (n_win, n_freq, n_ac, n_sh)
264
+ try:
265
+ solved = np.linalg.solve(AA, ua_H) # (n_win, n_freq, n_ac, n_sh)
266
+ clean_UU = UU - np.matmul(UA, solved)
267
+ except np.linalg.LinAlgError:
268
+ clean_UU = UU.copy()
269
+ n_win, n_freq = UU.shape[:2]
270
+ for w in range(n_win):
271
+ for fi in range(n_freq):
272
+ try:
273
+ clean_UU[w, fi] = UU[w, fi] - UA[w, fi] @ np.linalg.solve(
274
+ AA[w, fi], np.conj(UA[w, fi]).T
275
+ )
276
+ except np.linalg.LinAlgError:
277
+ # Singular AA: NaN the bin rather than keep the raw UU.
278
+ clean_UU[w, fi] = np.nan
279
+
280
+ # Sanitize only the real diagonal; off-diagonal cleaned cross-spectra stay
281
+ # complex for future cross-probe consumers. (2026-07-03 review, Gem-D-c.)
282
+ clean_UU = _sanitize_autospectra(clean_UU)
283
+ clean_UU = _nan_singular_bins(clean_UU, AA)
284
+
285
+ # Bias correction
286
+ clean_UU *= _bias_correction(shear_windows.shape[1], nfft, n_accel)
287
+
288
+ return clean_UU, F