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