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,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
|