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,300 @@
|
|
|
1
|
+
# May-2026, Claude and Pat Welch, pat@mousebrains.com
|
|
2
|
+
"""Multi-probe chi combining (mk_chi_mean).
|
|
3
|
+
|
|
4
|
+
Mirrors :func:`odas_tpw.processing.epsilon_combine.mk_epsilon_mean` for
|
|
5
|
+
chi: splits the 2-D ``chi(probe, time)`` from get_chi into per-probe
|
|
6
|
+
1-D ``chi_1``, ``chi_2``, ... variables, then combines them into
|
|
7
|
+
``chiMean`` (geometric mean of surviving probes) and ``chiLnSigma``
|
|
8
|
+
(expected ``sigma_ln(chi)``) using the same variance / 95% CI
|
|
9
|
+
machinery as the epsilon path. The 1-D split is what lets the depth
|
|
10
|
+
binner pick chi up; without it chi vanishes between chi_NN/ and
|
|
11
|
+
chi_combo_NN/.
|
|
12
|
+
|
|
13
|
+
The variance model is Lueck-style ``5.5 / (1 + (L_hat/4)^(7/9))``
|
|
14
|
+
borrowed from the dissipation pipeline. For chi this is a reasonable
|
|
15
|
+
first-order estimate when the FFT segments are long compared to the
|
|
16
|
+
Batchelor scale, but the chi-specific theoretical variance differs
|
|
17
|
+
from the shear case; refine here if a chi-tuned model becomes
|
|
18
|
+
available. The eq (18) spectral-truncation input, however, IS
|
|
19
|
+
chi-specific: ``L_hat`` is multiplied by ``V_f**0.75`` using the
|
|
20
|
+
Batchelor gradient-variance-resolved fraction stored as ``var_resolved``
|
|
21
|
+
(chi.py::_batchelor_resolved_fraction), not the Nasmyth shear V_f
|
|
22
|
+
(issue #104 U4-F1).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import warnings
|
|
26
|
+
|
|
27
|
+
import numpy as np
|
|
28
|
+
import xarray as xr
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _probe_sort_key(name: str) -> tuple[int, str]:
|
|
32
|
+
"""Order probe variables by their trailing integer so chi_2 sorts before
|
|
33
|
+
chi_10 (plain lexicographic order puts chi_10 first). Names without a
|
|
34
|
+
numeric suffix sort last, by name (#24)."""
|
|
35
|
+
suffix = name.rsplit("_", 1)[-1]
|
|
36
|
+
return (int(suffix), "") if suffix.isdigit() else (2**31, name)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _stack_probe_var(ds: xr.Dataset, var: str, n_probe: int) -> np.ndarray | None:
|
|
40
|
+
"""Stack a per-probe ``(probe, time)`` variable into ``(time, n_probe)``,
|
|
41
|
+
column-aligned with the chi probes; ``None`` when the variable or a matching
|
|
42
|
+
``probe`` dim is absent.
|
|
43
|
+
|
|
44
|
+
The perturb pipeline always feeds ``chi(probe, time)`` (no pre-existing
|
|
45
|
+
``chi_N``), so column ``i`` corresponds to probe index ``i`` (both from the
|
|
46
|
+
same ``probe`` dim). Older / hand-built datasets that supply per-probe
|
|
47
|
+
``chi_N`` as 1-D vars WITHOUT a matching ``probe`` dim return ``None`` here,
|
|
48
|
+
so the spectral QC is skipped rather than misaligned.
|
|
49
|
+
"""
|
|
50
|
+
if var in ds and "probe" in ds[var].dims and ds.sizes.get("probe", 0) == n_probe:
|
|
51
|
+
return np.column_stack([ds[var].isel(probe=i).values for i in range(n_probe)])
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def mk_chi_mean(
|
|
56
|
+
ds: xr.Dataset,
|
|
57
|
+
chi_minimum: float = 1e-13,
|
|
58
|
+
fom_limit: float | None = None,
|
|
59
|
+
k_max_ratio_min: float | None = None,
|
|
60
|
+
) -> xr.Dataset:
|
|
61
|
+
"""Combine per-probe chi into a mean estimate with CI filtering.
|
|
62
|
+
|
|
63
|
+
Steps (mirroring :func:`mk_epsilon_mean`):
|
|
64
|
+
|
|
65
|
+
1. If only ``chi(probe, time)`` is present, split into 1-D
|
|
66
|
+
``chi_1``, ``chi_2``, ...
|
|
67
|
+
2. Floor small values (``<= chi_minimum``) to NaN.
|
|
68
|
+
3. Kolmogorov length ``L_K = (nu^3 / epsilon_T)^(1/4)`` when
|
|
69
|
+
``epsilon_T`` is available, else fall back to the chi values
|
|
70
|
+
themselves so the CI machinery still has something to work
|
|
71
|
+
with.
|
|
72
|
+
4. Physical segment length ``L = speed * diss_length / fs``.
|
|
73
|
+
5. ``var_ln_chi = 5.5 / (1 + (L_hat/4)^(7/9))``; same Lueck-style
|
|
74
|
+
expression used for epsilon.
|
|
75
|
+
6. 95% CI range ``1.96 * sqrt(2) * mean(sigma_ln_chi)``.
|
|
76
|
+
7. Iteratively remove the probe FURTHEST from the cross-probe
|
|
77
|
+
ln-mean (symmetric — a low outlier is dropped as readily as a
|
|
78
|
+
high one) on rows whose min/max log-spread exceeds the CI range;
|
|
79
|
+
requires >= 3 probes and never prunes below 2 survivors.
|
|
80
|
+
8. ``chiMean`` = geometric mean of surviving probes; ``chiLnSigma``
|
|
81
|
+
= mean ``sigma_ln_chi`` across probes.
|
|
82
|
+
|
|
83
|
+
Parameters
|
|
84
|
+
----------
|
|
85
|
+
ds : xr.Dataset
|
|
86
|
+
Dataset from ``get_chi``. Should contain ``chi(probe, time)`` or
|
|
87
|
+
per-probe ``chi_1``, ``chi_2``, ... 1-D vars; ``speed`` and
|
|
88
|
+
``nu`` along ``time``; ``diss_length`` / ``fs_fast`` as scalar
|
|
89
|
+
attributes or variables; optionally ``epsilon_T(probe, time)``
|
|
90
|
+
for the Kolmogorov length term.
|
|
91
|
+
chi_minimum : float
|
|
92
|
+
Floor — values at or below are set to NaN.
|
|
93
|
+
fom_limit : float or None
|
|
94
|
+
Two-sided figure-of-merit band ``[1/fom_limit, fom_limit]`` for the soft
|
|
95
|
+
spectral QC. ``None`` (default) disables the QC entirely (backward
|
|
96
|
+
compatible). Supply together with ``k_max_ratio_min``.
|
|
97
|
+
k_max_ratio_min : float or None
|
|
98
|
+
Minimum ``K_max/kB`` spectral-resolution ratio for the soft spectral QC.
|
|
99
|
+
``None`` disables the QC. When both limits are set and per-probe
|
|
100
|
+
``fom`` / ``K_max_ratio`` are present, probes failing the band or the
|
|
101
|
+
resolution floor are dropped from ``chiMean`` *unless* no probe in the
|
|
102
|
+
window passes, in which case all are kept (no window is dropped).
|
|
103
|
+
|
|
104
|
+
Returns
|
|
105
|
+
-------
|
|
106
|
+
xr.Dataset
|
|
107
|
+
Input dataset with ``chi_1``, ``chi_2``, ... promoted to 1-D
|
|
108
|
+
vars (when only ``chi`` was 2-D), plus ``chiMean`` and
|
|
109
|
+
``chiLnSigma`` added.
|
|
110
|
+
"""
|
|
111
|
+
ds = ds.copy()
|
|
112
|
+
|
|
113
|
+
probe_names = sorted(
|
|
114
|
+
(str(k) for k in ds.data_vars if str(k).startswith("chi_")),
|
|
115
|
+
key=_probe_sort_key,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
if not probe_names and "chi" in ds and "probe" in ds.dims:
|
|
119
|
+
for i in range(ds.sizes["probe"]):
|
|
120
|
+
name = f"chi_{i + 1}"
|
|
121
|
+
ds[name] = ds["chi"].isel(probe=i)
|
|
122
|
+
probe_names.append(name)
|
|
123
|
+
|
|
124
|
+
if not probe_names:
|
|
125
|
+
warnings.warn("No per-probe chi variables (chi_1, chi_2, ...) found")
|
|
126
|
+
return ds
|
|
127
|
+
|
|
128
|
+
n_time = ds.sizes.get("time", 0)
|
|
129
|
+
if n_time == 0:
|
|
130
|
+
return ds
|
|
131
|
+
|
|
132
|
+
chi = np.column_stack([ds[name].values.copy() for name in probe_names])
|
|
133
|
+
|
|
134
|
+
chi[chi <= chi_minimum] = np.nan
|
|
135
|
+
|
|
136
|
+
# Soft spectral QC (issue #104 U3-C2): when fom/K_max_ratio limits are supplied
|
|
137
|
+
# and the per-probe fom / K_max_ratio arrays are present, prefer the probes
|
|
138
|
+
# inside the two-sided fom band with sufficient spectral resolution — the SAME
|
|
139
|
+
# thresholds `_compute_chi_final` uses for the rsi chi_final, so the perturb
|
|
140
|
+
# chiMean (and the K_T / Gamma built from it) is filtered consistently with the
|
|
141
|
+
# rsi pipeline. It is SOFT: if no probe in a window passes, that window's
|
|
142
|
+
# probes are all restored (no window is ever dropped), mirroring
|
|
143
|
+
# `_compute_chi_final`'s fallback. For 2-probe Method-1 data the K_max_ratio
|
|
144
|
+
# floor is largely inert (both probes share the epsilon-derived kB, so they
|
|
145
|
+
# pass/fail together and the fallback keeps both) — the QC only re-weights
|
|
146
|
+
# windows where the probes genuinely disagree in fit quality.
|
|
147
|
+
qc_comment: str | None = None
|
|
148
|
+
if fom_limit is not None and k_max_ratio_min is not None:
|
|
149
|
+
n_probe = chi.shape[1]
|
|
150
|
+
fom = _stack_probe_var(ds, "fom", n_probe)
|
|
151
|
+
kmr = _stack_probe_var(ds, "K_max_ratio", n_probe)
|
|
152
|
+
if fom is not None and kmr is not None:
|
|
153
|
+
with np.errstate(invalid="ignore"):
|
|
154
|
+
passes = (
|
|
155
|
+
np.isfinite(fom)
|
|
156
|
+
& (fom <= fom_limit)
|
|
157
|
+
& (fom >= 1.0 / fom_limit)
|
|
158
|
+
& np.isfinite(kmr)
|
|
159
|
+
& (kmr >= k_max_ratio_min)
|
|
160
|
+
)
|
|
161
|
+
finite_chi = np.isfinite(chi)
|
|
162
|
+
# Restrict the drop to windows where at least one probe passes QC AND
|
|
163
|
+
# still has a finite chi, so a window is never emptied.
|
|
164
|
+
any_pass = np.any(passes & finite_chi, axis=1)
|
|
165
|
+
drop = (~passes) & finite_chi & any_pass[:, np.newaxis]
|
|
166
|
+
chi[drop] = np.nan
|
|
167
|
+
qc_comment = (
|
|
168
|
+
f"soft spectral QC applied: probes preferred inside fom band "
|
|
169
|
+
f"[{1.0 / fom_limit:.4g}, {fom_limit:.4g}] with K_max_ratio >= "
|
|
170
|
+
f"{k_max_ratio_min:.4g}; windows with no passing probe keep all "
|
|
171
|
+
f"probes (no window dropped)"
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
# Lueck (2022) eq (18) truncation-correction input, mirroring mk_epsilon_mean
|
|
175
|
+
# (issue #104 U4-F1). The chi diss product now stores a genuine Batchelor
|
|
176
|
+
# variance-resolved fraction (chi.py::_batchelor_resolved_fraction, written to
|
|
177
|
+
# `var_resolved` by chi_io) — the fraction of the model temperature-gradient
|
|
178
|
+
# variance within [K_min, K_max], WITHOUT the FP07 |H|^2 (the chi-side analog
|
|
179
|
+
# of the Nasmyth V_f, NOT the shear value). When present it corrects
|
|
180
|
+
# chiLnSigma for spectral truncation; older products lacking it keep the plain
|
|
181
|
+
# L_hat (guarded no-op / backward compatible).
|
|
182
|
+
var_resolved = _stack_probe_var(ds, "var_resolved", chi.shape[1])
|
|
183
|
+
|
|
184
|
+
speed = ds["speed"].values if "speed" in ds else np.ones(n_time)
|
|
185
|
+
nu = ds["nu"].values if "nu" in ds else np.full(n_time, 1e-6)
|
|
186
|
+
|
|
187
|
+
if "diss_length" in ds.attrs:
|
|
188
|
+
diss_length = float(ds.attrs["diss_length"])
|
|
189
|
+
elif "diss_length" in ds:
|
|
190
|
+
diss_length = float(ds["diss_length"].values)
|
|
191
|
+
else:
|
|
192
|
+
diss_length = 512.0
|
|
193
|
+
|
|
194
|
+
if "fs_fast" in ds.attrs:
|
|
195
|
+
fs = float(ds.attrs["fs_fast"])
|
|
196
|
+
elif "fs_fast" in ds:
|
|
197
|
+
fs = float(ds["fs_fast"].values)
|
|
198
|
+
else:
|
|
199
|
+
fs = 512.0
|
|
200
|
+
|
|
201
|
+
# Kolmogorov length: use epsilon_T(probe, time) when available; otherwise
|
|
202
|
+
# fall back to chi itself so L_hat / sigma still vary smoothly with the
|
|
203
|
+
# data. The fallback is dimensionally invalid (chi is K^2/s, not W/kg),
|
|
204
|
+
# so the resulting sigma magnitudes are only nominal — in that case the
|
|
205
|
+
# CI-based probe removal below is DISABLED rather than discarding probes
|
|
206
|
+
# on a meaningless threshold.
|
|
207
|
+
have_eps_T = "epsilon_T" in ds and "probe" in ds["epsilon_T"].dims
|
|
208
|
+
if have_eps_T:
|
|
209
|
+
eps_T = np.column_stack(
|
|
210
|
+
[ds["epsilon_T"].isel(probe=i).values for i in range(ds.sizes["probe"])]
|
|
211
|
+
)
|
|
212
|
+
else:
|
|
213
|
+
eps_T = chi
|
|
214
|
+
|
|
215
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
216
|
+
L_K = (nu[:, np.newaxis] ** 3 / eps_T) ** 0.25
|
|
217
|
+
|
|
218
|
+
L = speed * diss_length / fs
|
|
219
|
+
L = L[:, np.newaxis]
|
|
220
|
+
|
|
221
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
222
|
+
L_hat = L / L_K
|
|
223
|
+
if var_resolved is not None:
|
|
224
|
+
# Lueck (2022) eq (18) L_hat_f = L_hat * V_f**0.75, using the Batchelor
|
|
225
|
+
# variance-resolved fraction stored by the chi path (see note above).
|
|
226
|
+
L_hat = L_hat * np.clip(var_resolved, 1e-6, 1.0) ** 0.75
|
|
227
|
+
|
|
228
|
+
with np.errstate(invalid="ignore"):
|
|
229
|
+
var_ln_chi = 5.5 / (1.0 + (L_hat / 4.0) ** (7.0 / 9.0))
|
|
230
|
+
sigma_ln_chi = np.sqrt(var_ln_chi)
|
|
231
|
+
|
|
232
|
+
mu_sigma = np.nanmean(sigma_ln_chi, axis=1)
|
|
233
|
+
CF95_range = 1.96 * np.sqrt(2.0) * mu_sigma
|
|
234
|
+
|
|
235
|
+
n_probes = chi.shape[1]
|
|
236
|
+
if not have_eps_T:
|
|
237
|
+
n_probes = 1 # skip the CI removal loop; sigma is only nominal
|
|
238
|
+
warnings.warn(
|
|
239
|
+
"epsilon_T not available: chiLnSigma is nominal (Kolmogorov "
|
|
240
|
+
"length fallback uses chi, which is dimensionally invalid); "
|
|
241
|
+
"CI-based probe removal disabled — all probes enter chiMean",
|
|
242
|
+
stacklevel=2,
|
|
243
|
+
)
|
|
244
|
+
# Drop the probe FURTHEST from the cross-probe ln-mean on each outside row
|
|
245
|
+
# (symmetric outlier rejection), and only with >= 3 probes where an outlier
|
|
246
|
+
# is identifiable -- mirrors the epsilon side. The old code always dropped
|
|
247
|
+
# the maximum, so a low junk probe survived and biased chiMean low; with 2
|
|
248
|
+
# probes it now keeps both (unbiased) rather than coin-flipping a drop.
|
|
249
|
+
if n_probes >= 3:
|
|
250
|
+
for _ in range(n_probes - 1):
|
|
251
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
252
|
+
ln_c = np.log(chi)
|
|
253
|
+
min_c = np.nanmin(chi, axis=1)
|
|
254
|
+
max_c = np.nanmax(chi, axis=1)
|
|
255
|
+
ratio = np.abs(np.log(max_c) - np.log(min_c))
|
|
256
|
+
|
|
257
|
+
# Skip rows already all-NaN (nanargmax raises on them). Also never
|
|
258
|
+
# prune below 2 surviving probes: with 2 left the pair is
|
|
259
|
+
# equidistant from its mean (no identifiable outlier) and the
|
|
260
|
+
# geometric mean is unbiased, so honor the documented "keep both"
|
|
261
|
+
# rule per-row, not just at the dataset level (#23/#49).
|
|
262
|
+
finite_count = np.sum(np.isfinite(chi), axis=1)
|
|
263
|
+
any_finite = finite_count > 0
|
|
264
|
+
outside = (ratio > CF95_range) & (finite_count > 2)
|
|
265
|
+
if not np.any(outside):
|
|
266
|
+
break
|
|
267
|
+
|
|
268
|
+
with np.errstate(invalid="ignore"):
|
|
269
|
+
ln_mean = np.nanmean(ln_c, axis=1, keepdims=True)
|
|
270
|
+
dev = np.abs(ln_c - ln_mean)
|
|
271
|
+
worst_idx = np.full(chi.shape[0], -1, dtype=np.int64)
|
|
272
|
+
worst_idx[any_finite] = np.nanargmax(dev[any_finite], axis=1)
|
|
273
|
+
for i in np.where(outside)[0]:
|
|
274
|
+
chi[i, worst_idx[i]] = np.nan
|
|
275
|
+
|
|
276
|
+
with np.errstate(invalid="ignore"):
|
|
277
|
+
chi_mean = np.exp(np.nanmean(np.log(chi), axis=1))
|
|
278
|
+
|
|
279
|
+
chi_mean_attrs = {"long_name": "combined chi (geometric mean)", "units": "K2 s-1"}
|
|
280
|
+
if qc_comment is not None:
|
|
281
|
+
chi_mean_attrs["comment"] = qc_comment
|
|
282
|
+
ds["chiMean"] = xr.DataArray(
|
|
283
|
+
chi_mean,
|
|
284
|
+
dims=["time"],
|
|
285
|
+
attrs=chi_mean_attrs,
|
|
286
|
+
)
|
|
287
|
+
sigma_attrs = {"long_name": "sigma of ln(chi)", "units": "1"}
|
|
288
|
+
if not have_eps_T:
|
|
289
|
+
sigma_attrs["comment"] = (
|
|
290
|
+
"NOMINAL: computed with chi substituted for epsilon_T in the "
|
|
291
|
+
"Kolmogorov length (dimensionally invalid); magnitudes are not "
|
|
292
|
+
"quantitative and were not used for probe selection"
|
|
293
|
+
)
|
|
294
|
+
ds["chiLnSigma"] = xr.DataArray(
|
|
295
|
+
mu_sigma,
|
|
296
|
+
dims=["time"],
|
|
297
|
+
attrs=sigma_attrs,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
return ds
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# Mar-2026, Claude and Pat Welch, pat@mousebrains.com
|
|
2
|
+
"""CT sensor alignment via cross-correlation.
|
|
3
|
+
|
|
4
|
+
Aligns conductivity to temperature by finding the lag of maximum
|
|
5
|
+
cross-correlation between diff(T) and diff(C), using a weighted median
|
|
6
|
+
across profiles.
|
|
7
|
+
|
|
8
|
+
Reference: Code/CT_align.m (70 lines)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
from scipy.signal import butter, correlate, lfilter
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def shift_edge_hold(x: np.ndarray, shift: int) -> np.ndarray:
|
|
16
|
+
"""Shift *x* by *shift* samples, holding the edge value.
|
|
17
|
+
|
|
18
|
+
Unlike ``np.roll``, samples shifted past the array ends are filled
|
|
19
|
+
with the first/last value instead of wrapping around — wrapping
|
|
20
|
+
would splice one end of the record into the other.
|
|
21
|
+
"""
|
|
22
|
+
if shift == 0:
|
|
23
|
+
return x.copy()
|
|
24
|
+
out = np.empty_like(x)
|
|
25
|
+
if shift > 0:
|
|
26
|
+
out[:shift] = x[0]
|
|
27
|
+
out[shift:] = x[:-shift]
|
|
28
|
+
else:
|
|
29
|
+
out[shift:] = x[-1]
|
|
30
|
+
out[:shift] = x[-shift:]
|
|
31
|
+
return out
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _repair_nonfinite(
|
|
35
|
+
seg: np.ndarray,
|
|
36
|
+
min_finite: int = 10,
|
|
37
|
+
max_nonfinite_frac: float = 0.25,
|
|
38
|
+
) -> np.ndarray | None:
|
|
39
|
+
"""Linearly interpolate isolated non-finite samples in a profile segment.
|
|
40
|
+
|
|
41
|
+
Returns a repaired copy, or ``None`` when too little finite data remains
|
|
42
|
+
(fewer than *min_finite* finite samples, or the non-finite fraction exceeds
|
|
43
|
+
*max_nonfinite_frac*) to carry useful alignment information. A fully-finite
|
|
44
|
+
segment is returned unchanged.
|
|
45
|
+
"""
|
|
46
|
+
finite = np.isfinite(seg)
|
|
47
|
+
if finite.all():
|
|
48
|
+
return seg
|
|
49
|
+
n_finite = int(finite.sum())
|
|
50
|
+
if n_finite < min_finite or (len(seg) - n_finite) > max_nonfinite_frac * len(seg):
|
|
51
|
+
return None
|
|
52
|
+
idx = np.arange(len(seg))
|
|
53
|
+
repaired = seg.copy()
|
|
54
|
+
# np.interp clamps to the edge finite values for leading/trailing gaps.
|
|
55
|
+
repaired[~finite] = np.interp(idx[~finite], idx[finite], seg[finite])
|
|
56
|
+
return repaired
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def ct_align(
|
|
60
|
+
T: np.ndarray,
|
|
61
|
+
C: np.ndarray,
|
|
62
|
+
fs: float,
|
|
63
|
+
profiles: list[tuple[int, int]],
|
|
64
|
+
max_lag_seconds: float = 5.0,
|
|
65
|
+
) -> tuple[np.ndarray, float]:
|
|
66
|
+
"""Align conductivity to temperature by cross-correlation.
|
|
67
|
+
|
|
68
|
+
Parameters
|
|
69
|
+
----------
|
|
70
|
+
T : ndarray
|
|
71
|
+
Temperature array (slow rate).
|
|
72
|
+
C : ndarray
|
|
73
|
+
Conductivity array (slow rate).
|
|
74
|
+
fs : float
|
|
75
|
+
Sampling rate [Hz].
|
|
76
|
+
profiles : list of (start, end) tuples
|
|
77
|
+
Profile indices into slow-rate arrays.
|
|
78
|
+
max_lag_seconds : float
|
|
79
|
+
Maximum lag to search [s].
|
|
80
|
+
|
|
81
|
+
Returns
|
|
82
|
+
-------
|
|
83
|
+
C_aligned : ndarray
|
|
84
|
+
Shifted conductivity array.
|
|
85
|
+
lag_seconds : float
|
|
86
|
+
Applied lag in seconds.
|
|
87
|
+
"""
|
|
88
|
+
if not profiles:
|
|
89
|
+
return C.copy(), 0.0
|
|
90
|
+
|
|
91
|
+
max_lag_samples = round(max_lag_seconds * fs)
|
|
92
|
+
fc = min(4.0, fs / 2.0 * 0.99)
|
|
93
|
+
bb, aa = butter(2, fc / (fs / 2.0))
|
|
94
|
+
|
|
95
|
+
per_profile = []
|
|
96
|
+
for s, e in profiles:
|
|
97
|
+
seg_T = T[s : e + 1]
|
|
98
|
+
seg_C = C[s : e + 1]
|
|
99
|
+
if len(seg_T) < 10:
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
# Audit over-discard fix: convert_jac_c emits NaN for *isolated* corrupt
|
|
103
|
+
# conductivity samples (v_part==0 bit errors), not whole-segment
|
|
104
|
+
# corruption. The earlier guard skipped the ENTIRE profile on any single
|
|
105
|
+
# non-finite sample, silently forfeiting that cast's alignment whenever
|
|
106
|
+
# every profile carried one transient bad sample. Instead, linearly
|
|
107
|
+
# interpolate the few non-finite points (so the IIR lfilter cannot smear
|
|
108
|
+
# a NaN across the segment and drive a maximally-wrong lag), and only
|
|
109
|
+
# skip when too little finite data remains to carry alignment info.
|
|
110
|
+
rep_T = _repair_nonfinite(seg_T)
|
|
111
|
+
rep_C = _repair_nonfinite(seg_C)
|
|
112
|
+
if rep_T is None or rep_C is None:
|
|
113
|
+
continue
|
|
114
|
+
seg_T, seg_C = rep_T, rep_C
|
|
115
|
+
|
|
116
|
+
dx = lfilter(bb, aa, np.diff(seg_T) - np.mean(np.diff(seg_T)))
|
|
117
|
+
dy = lfilter(bb, aa, np.diff(seg_C) - np.mean(np.diff(seg_C)))
|
|
118
|
+
|
|
119
|
+
corr = correlate(dx, dy, mode="full")
|
|
120
|
+
norm = np.sqrt(np.sum(dx**2) * np.sum(dy**2))
|
|
121
|
+
if not np.isfinite(norm) or norm <= 0:
|
|
122
|
+
# A flatlined/constant T or C segment (norm == 0) carries no
|
|
123
|
+
# alignment information. Skip it: leaving it in would make
|
|
124
|
+
# argmax(|corr|) of an all-zero correlation pick index 0, i.e. the
|
|
125
|
+
# most-negative lag (-max_lag_seconds), a maximally-wrong estimate.
|
|
126
|
+
continue
|
|
127
|
+
corr = corr / norm
|
|
128
|
+
|
|
129
|
+
n = len(dx)
|
|
130
|
+
lags = np.arange(-(n - 1), n)
|
|
131
|
+
|
|
132
|
+
mask = np.abs(lags) <= max_lag_samples
|
|
133
|
+
corr = corr[mask]
|
|
134
|
+
lags = lags[mask]
|
|
135
|
+
|
|
136
|
+
idx = np.argmax(np.abs(corr))
|
|
137
|
+
lag = lags[idx]
|
|
138
|
+
max_corr = np.abs(corr[idx])
|
|
139
|
+
n_samples = e - s + 1
|
|
140
|
+
|
|
141
|
+
per_profile.append(
|
|
142
|
+
{
|
|
143
|
+
"lag": lag / fs,
|
|
144
|
+
"max_corr": max_corr,
|
|
145
|
+
"n_samples": n_samples,
|
|
146
|
+
}
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
if not per_profile:
|
|
150
|
+
return C.copy(), 0.0
|
|
151
|
+
|
|
152
|
+
# Weighted median: smallest lag whose cumulative weight reaches half the
|
|
153
|
+
# total. searchsorted gives the true (lower) weighted median; the old
|
|
154
|
+
# argmin(|cum_w - total/2|) picked the entry *closest* to the midpoint,
|
|
155
|
+
# which is biased toward the pre-crossing entry for skewed weights.
|
|
156
|
+
per_profile.sort(key=lambda x: x["lag"])
|
|
157
|
+
weights = np.array([p["max_corr"] * p["n_samples"] for p in per_profile])
|
|
158
|
+
cum_w = np.cumsum(weights)
|
|
159
|
+
total = float(cum_w[-1])
|
|
160
|
+
if not np.isfinite(total) or total <= 0:
|
|
161
|
+
# No usable correlation across any profile -> apply no shift.
|
|
162
|
+
return C.copy(), 0.0
|
|
163
|
+
mid_idx = int(np.searchsorted(cum_w, total / 2.0))
|
|
164
|
+
mid_idx = min(mid_idx, len(per_profile) - 1)
|
|
165
|
+
median_lag = per_profile[mid_idx]["lag"]
|
|
166
|
+
|
|
167
|
+
i_shift = round(median_lag * fs)
|
|
168
|
+
# Edge-hold instead of np.roll: wrapping would splice the start of
|
|
169
|
+
# the record into the end (ODAS trims instead).
|
|
170
|
+
C_aligned = shift_edge_hold(C, i_shift)
|
|
171
|
+
|
|
172
|
+
return C_aligned, median_lag
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# Mar-2026, Claude and Pat Welch, pat@mousebrains.com
|
|
2
|
+
"""Multi-probe epsilon combining (mk_epsilon_mean).
|
|
3
|
+
|
|
4
|
+
Combines dissipation estimates from multiple shear probes using
|
|
5
|
+
the expected variance of ln(epsilon), iteratively removing probes
|
|
6
|
+
outside the 95% confidence interval, then computing the geometric mean.
|
|
7
|
+
|
|
8
|
+
Reference: Code/calc_diss_shear.m lines 238-299
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import warnings
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
import xarray as xr
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _probe_sort_key(name: str) -> tuple[int, str]:
|
|
18
|
+
"""Order probe variables by their trailing integer so e_2 sorts before e_10
|
|
19
|
+
(plain lexicographic order puts e_10 first). Names without a numeric suffix
|
|
20
|
+
sort last, by name (#24)."""
|
|
21
|
+
suffix = name.rsplit("_", 1)[-1]
|
|
22
|
+
return (int(suffix), "") if suffix.isdigit() else (2**31, name)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def mk_epsilon_mean(
|
|
26
|
+
ds: xr.Dataset,
|
|
27
|
+
epsilon_minimum: float = 1e-13,
|
|
28
|
+
) -> xr.Dataset:
|
|
29
|
+
"""Combine per-probe epsilon into a mean estimate with CI filtering.
|
|
30
|
+
|
|
31
|
+
Port of Matlab ``mk_epsilon_mean``:
|
|
32
|
+
1. Floor small values to NaN
|
|
33
|
+
2. Kolmogorov length: L_K = (nu^3 / epsilon)^(1/4)
|
|
34
|
+
3. Physical length: L = speed * diss_length / fs
|
|
35
|
+
4. Normalized: L_hat = L / L_K
|
|
36
|
+
5. var_ln_epsilon = 5.5 / (1 + (L_hat/4)^(7/9))
|
|
37
|
+
6. 95% CI range: 1.96 * sqrt(2) * mean(sigma_ln_epsilon)
|
|
38
|
+
7. Iteratively remove probes outside CI
|
|
39
|
+
8. Geometric mean of surviving probes
|
|
40
|
+
|
|
41
|
+
Parameters
|
|
42
|
+
----------
|
|
43
|
+
ds : xr.Dataset
|
|
44
|
+
Dataset from ``get_diss``. Must contain:
|
|
45
|
+
- ``e_1``, ``e_2``, ... (per-probe epsilon, 1D along 'time')
|
|
46
|
+
- ``speed`` (1D along 'time')
|
|
47
|
+
- ``nu`` (1D along 'time')
|
|
48
|
+
- ``diss_length`` (scalar attribute or variable)
|
|
49
|
+
- ``fs_fast`` (scalar attribute)
|
|
50
|
+
epsilon_minimum : float
|
|
51
|
+
Floor: values <= this are set to NaN.
|
|
52
|
+
|
|
53
|
+
Returns
|
|
54
|
+
-------
|
|
55
|
+
xr.Dataset
|
|
56
|
+
Input dataset with ``epsilonMean`` and ``epsilonLnSigma`` added.
|
|
57
|
+
"""
|
|
58
|
+
ds = ds.copy()
|
|
59
|
+
|
|
60
|
+
# Find epsilon probe variables — either separate e_1/e_2/... or 2D epsilon(probe, time)
|
|
61
|
+
probe_names = sorted(
|
|
62
|
+
(str(k) for k in ds.data_vars if str(k).startswith("e_")),
|
|
63
|
+
key=_probe_sort_key,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
if not probe_names and "epsilon" in ds and "probe" in ds.dims:
|
|
67
|
+
# Split 2D epsilon(probe, time) into per-probe variables
|
|
68
|
+
for i in range(ds.sizes["probe"]):
|
|
69
|
+
name = f"e_{i + 1}"
|
|
70
|
+
ds[name] = ds["epsilon"].isel(probe=i)
|
|
71
|
+
probe_names.append(name)
|
|
72
|
+
|
|
73
|
+
if not probe_names:
|
|
74
|
+
warnings.warn("No per-probe epsilon variables (e_1, e_2, ...) found")
|
|
75
|
+
return ds
|
|
76
|
+
|
|
77
|
+
n_time = ds.sizes.get("time", 0)
|
|
78
|
+
if n_time == 0:
|
|
79
|
+
return ds
|
|
80
|
+
|
|
81
|
+
# Stack epsilon into 2D array [time x probes]
|
|
82
|
+
epsilon = np.column_stack([ds[name].values.copy() for name in probe_names])
|
|
83
|
+
|
|
84
|
+
# Floor small values
|
|
85
|
+
epsilon[epsilon <= epsilon_minimum] = np.nan
|
|
86
|
+
|
|
87
|
+
# Lueck (2022) eq (18) spectral-truncation correction input: the fraction of
|
|
88
|
+
# Nasmyth variance resolved below K_max, per probe (issue #104 U4-F1). Written
|
|
89
|
+
# by the diss build as var_resolved(probe, time); older diss NetCDFs lack it,
|
|
90
|
+
# in which case we fall back to the plain L_hat below.
|
|
91
|
+
var_resolved = None
|
|
92
|
+
if (
|
|
93
|
+
"var_resolved" in ds
|
|
94
|
+
and "probe" in ds["var_resolved"].dims
|
|
95
|
+
and ds.sizes.get("probe", 0) == len(probe_names)
|
|
96
|
+
):
|
|
97
|
+
var_resolved = np.column_stack(
|
|
98
|
+
[ds["var_resolved"].isel(probe=i).values for i in range(len(probe_names))]
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# Get parameters
|
|
102
|
+
speed = ds["speed"].values if "speed" in ds else np.ones(n_time)
|
|
103
|
+
nu = ds["nu"].values if "nu" in ds else np.full(n_time, 1e-6)
|
|
104
|
+
|
|
105
|
+
if "diss_length" in ds.attrs:
|
|
106
|
+
diss_length = float(ds.attrs["diss_length"])
|
|
107
|
+
elif "diss_length" in ds:
|
|
108
|
+
diss_length = float(ds["diss_length"].values)
|
|
109
|
+
else:
|
|
110
|
+
diss_length = 512.0
|
|
111
|
+
|
|
112
|
+
if "fs_fast" in ds.attrs:
|
|
113
|
+
fs = float(ds.attrs["fs_fast"])
|
|
114
|
+
elif "fs_fast" in ds:
|
|
115
|
+
fs = float(ds["fs_fast"].values)
|
|
116
|
+
else:
|
|
117
|
+
fs = 512.0
|
|
118
|
+
|
|
119
|
+
# Kolmogorov length
|
|
120
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
121
|
+
L_K = (nu[:, np.newaxis] ** 3 / epsilon) ** 0.25
|
|
122
|
+
|
|
123
|
+
# Physical length of the data segment
|
|
124
|
+
L = speed * diss_length / fs # [time]
|
|
125
|
+
L = L[:, np.newaxis] # [time x 1]
|
|
126
|
+
|
|
127
|
+
# Normalized length
|
|
128
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
129
|
+
L_hat = L / L_K
|
|
130
|
+
if var_resolved is not None:
|
|
131
|
+
# Lueck (2022) Part I eq (18): truncating the spectrum at K_max
|
|
132
|
+
# reduces the dof by V_f**(3/4), so reduce the nondimensional
|
|
133
|
+
# averaging length accordingly (L_hat_f = L_hat * V_f**0.75).
|
|
134
|
+
# Clip the floor so a zero/near-zero V_f cannot collapse L_hat.
|
|
135
|
+
L_hat = L_hat * np.clip(var_resolved, 1e-6, 1.0) ** 0.75
|
|
136
|
+
|
|
137
|
+
# Variance of ln(epsilon)
|
|
138
|
+
with np.errstate(invalid="ignore"):
|
|
139
|
+
var_ln_epsilon = 5.5 / (1.0 + (L_hat / 4.0) ** (7.0 / 9.0))
|
|
140
|
+
sigma_ln_epsilon = np.sqrt(var_ln_epsilon)
|
|
141
|
+
|
|
142
|
+
# Mean sigma across probes
|
|
143
|
+
mu_sigma = np.nanmean(sigma_ln_epsilon, axis=1) # [time]
|
|
144
|
+
CF95_range = 1.96 * np.sqrt(2.0) * mu_sigma # [time]
|
|
145
|
+
|
|
146
|
+
# Iterative removal of probes outside the 95% CI. With >= 3 probes we can
|
|
147
|
+
# identify an outlier and drop the probe whose ln(epsilon) is FURTHEST from
|
|
148
|
+
# the cross-probe ln-mean (the Lueck/Rockland mk_diss_mean rule), rejecting
|
|
149
|
+
# low and high outliers symmetrically. The old code unconditionally NaN'd
|
|
150
|
+
# the MAXIMUM probe, so a low junk probe always survived and biased
|
|
151
|
+
# epsilonMean low. With only 2 disagreeing probes there is no identifiable
|
|
152
|
+
# outlier (both are equidistant from their mean), so we KEEP BOTH rather
|
|
153
|
+
# than coin-flip a drop -- the geometric mean of the pair is unbiased,
|
|
154
|
+
# whereas always-drop-max systematically kept the lower probe.
|
|
155
|
+
n_probes = epsilon.shape[1]
|
|
156
|
+
if n_probes >= 3:
|
|
157
|
+
for _ in range(n_probes - 1):
|
|
158
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
159
|
+
ln_e = np.log(epsilon)
|
|
160
|
+
min_e = np.nanmin(epsilon, axis=1)
|
|
161
|
+
max_e = np.nanmax(epsilon, axis=1)
|
|
162
|
+
ratio = np.abs(np.log(max_e) - np.log(min_e))
|
|
163
|
+
|
|
164
|
+
# Skip rows already all-NaN (nanargmax raises on them). Also never
|
|
165
|
+
# prune below 2 surviving probes: with 2 left the pair is
|
|
166
|
+
# equidistant from its mean (no identifiable outlier) and the
|
|
167
|
+
# geometric mean is unbiased, so honor the documented "keep both"
|
|
168
|
+
# rule per-row, not just at the dataset level (#23/#49).
|
|
169
|
+
finite_count = np.sum(np.isfinite(epsilon), axis=1)
|
|
170
|
+
any_finite = finite_count > 0
|
|
171
|
+
outside = (ratio > CF95_range) & (finite_count > 2)
|
|
172
|
+
if not np.any(outside):
|
|
173
|
+
break
|
|
174
|
+
|
|
175
|
+
# Drop the probe furthest from the cross-probe ln-mean on each
|
|
176
|
+
# outside row (symmetric: a low outlier is as removable as a high).
|
|
177
|
+
with np.errstate(invalid="ignore"):
|
|
178
|
+
ln_mean = np.nanmean(ln_e, axis=1, keepdims=True)
|
|
179
|
+
dev = np.abs(ln_e - ln_mean)
|
|
180
|
+
worst_idx = np.full(epsilon.shape[0], -1, dtype=np.int64)
|
|
181
|
+
worst_idx[any_finite] = np.nanargmax(dev[any_finite], axis=1)
|
|
182
|
+
for i in np.where(outside)[0]:
|
|
183
|
+
epsilon[i, worst_idx[i]] = np.nan
|
|
184
|
+
|
|
185
|
+
# Geometric mean of surviving probes
|
|
186
|
+
with np.errstate(invalid="ignore"):
|
|
187
|
+
epsilon_mean = np.exp(np.nanmean(np.log(epsilon), axis=1))
|
|
188
|
+
|
|
189
|
+
ds["epsilonMean"] = xr.DataArray(
|
|
190
|
+
epsilon_mean,
|
|
191
|
+
dims=["time"],
|
|
192
|
+
attrs={"long_name": "combined epsilon (geometric mean)", "units": "W/kg"},
|
|
193
|
+
)
|
|
194
|
+
ds["epsilonLnSigma"] = xr.DataArray(
|
|
195
|
+
mu_sigma,
|
|
196
|
+
dims=["time"],
|
|
197
|
+
attrs={
|
|
198
|
+
"long_name": "sigma of ln(epsilon)",
|
|
199
|
+
"units": "1",
|
|
200
|
+
"comment": (
|
|
201
|
+
"Expected single-probe sigma_ln(epsilon) (Lueck 2022 "
|
|
202
|
+
"variance model eq 11), averaged across probes — NOT the sigma "
|
|
203
|
+
"of the combined epsilonMean (no sqrt(n) reduction applied). "
|
|
204
|
+
"When var_resolved (V_f) is present the eq (18) spectral-"
|
|
205
|
+
"truncation correction L_hat_f = L_hat * V_f**0.75 is applied."
|
|
206
|
+
),
|
|
207
|
+
},
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
return ds
|