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,274 @@
1
+ # Jul-2026, Claude and Pat Welch, pat@mousebrains.com
2
+ """CODAS/UHDAS gridded shipboard-ADCP reader and finescale shear.
3
+
4
+ Reads the gridded NetCDF product a UHDAS installation writes under
5
+ ``proc/<sonar>/contour/<sonar>.nc`` (e.g. ``wh300.nc``, ``os75nb.nc``) and
6
+ derives the finescale vertical shear squared
7
+
8
+ ``S^2 = (du/dz)^2 + (dv/dz)^2`` [s^-2]
9
+
10
+ needed for gradient Richardson numbers ``Ri_g = N^2 / S^2`` on a
11
+ microstructure grid (Lewin et al. 2025, section 2b;
12
+ https://doi.org/10.1175/JPO-D-25-0012.1).
13
+
14
+ Contract and caveats
15
+ --------------------
16
+ - The CODAS *contour* product is already edit-masked: velocities are NaN
17
+ wherever the editing flags rejected them, and finite cells carry
18
+ ``pflag == 0``. The reader still applies the ``pflag`` mask (belt and
19
+ suspenders for files processed differently) and an optional
20
+ percent-good floor.
21
+ - ``depth(time, depth_cell)`` is per-ensemble (instrument configuration
22
+ can change mid-cruise); all depths are meters, positive down.
23
+ - Shear is first-differenced between *adjacent* cells only — a masked
24
+ cell NaNs both differences that touch it, so a data gap never silently
25
+ widens the differencing scale. Ensembles within the query's time
26
+ tolerance are averaged *before* differencing, which reduces (but does
27
+ not remove) the single-ping noise floor ``~2*sigma_u^2/dz^2``; with
28
+ cm/s ensemble noise over 2-m bins that floor is order 1e-4 s^-2,
29
+ comparable to weak thermocline shear — treat small ``S^2`` (large
30
+ ``Ri_g``) as an upper bound on shear, not a measurement.
31
+ - Different sonars (2-m wh300 vs 16-m os75) measure *different* shear:
32
+ coarser bins systematically underestimate ``S^2``. Callers must keep
33
+ per-sonar results as separate labeled populations, never blend them.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ from pathlib import Path
39
+ from typing import NamedTuple
40
+
41
+ import numpy as np
42
+ import numpy.typing as npt
43
+
44
+ # Package-internal reuse of hotel.py's NaT-safe datetime64 conversion (a
45
+ # module-private name, but duplicating its NaT-sentinel handling is worse).
46
+ from odas_tpw.perturb.hotel import _dt64_to_epoch_s
47
+
48
+ # Ensembles within this many seconds of a query time contribute to its
49
+ # averaged velocity profile. wh300 writes ~120-s ensembles, so ±300 s
50
+ # averages ~5 ensembles over a VMP cast — comparable smoothing to the
51
+ # 20-30-s averages of Lewin et al. (2025) with their faster pings.
52
+ DEFAULT_TIME_TOLERANCE = 300.0
53
+
54
+ # A query depth is only interpolated when its bracketing finite shear
55
+ # mid-depths are within this factor of the sonar's median cell spacing —
56
+ # beyond that the "interpolation" would bridge a masked gap at a scale
57
+ # the differencing never measured.
58
+ DEFAULT_MAX_GAP_FACTOR = 2.5
59
+
60
+
61
+ class AdcpData(NamedTuple):
62
+ """One CODAS gridded sonar product, masked and in epoch time."""
63
+
64
+ name: str # sonar label, e.g. "wh300" (defaults to the file stem)
65
+ time: np.ndarray # epoch seconds, shape (n_t,)
66
+ depth: np.ndarray # meters positive down, shape (n_t, n_cell)
67
+ u: np.ndarray # east velocity [m/s], NaN where rejected
68
+ v: np.ndarray # north velocity [m/s], NaN where rejected
69
+ path: Path # source file
70
+
71
+
72
+ class WindowShear(NamedTuple):
73
+ """Per-query shear-squared estimates from :func:`window_shear`."""
74
+
75
+ S2: np.ndarray # shear squared [s^-2]; NaN where unresolvable
76
+ n_ens: np.ndarray # ensembles averaged per query (int)
77
+
78
+
79
+ def read_codas(
80
+ path: str | Path,
81
+ *,
82
+ min_pg: float | None = None,
83
+ name: str | None = None,
84
+ ) -> AdcpData:
85
+ """Read a CODAS gridded NetCDF (``contour/<sonar>.nc``).
86
+
87
+ Parameters
88
+ ----------
89
+ path : str or Path
90
+ The gridded product, e.g. ``.../proc/wh300/contour/wh300.nc``.
91
+ min_pg : float or None
92
+ Optional percent-good floor (``pg >= min_pg``). The contour
93
+ product is already edited (CODAS applies its own threshold), so
94
+ the default is no additional cut.
95
+ name : str or None
96
+ Label for this sonar; defaults to the file stem (``wh300``).
97
+
98
+ Returns
99
+ -------
100
+ AdcpData
101
+ Time as epoch seconds, per-ensemble depths [m], and u/v [m/s]
102
+ with rejected cells (non-finite, ``pflag != 0``, or below
103
+ ``min_pg``) set to NaN.
104
+ """
105
+ import xarray as xr
106
+
107
+ path = Path(path)
108
+ with xr.open_dataset(path) as ds:
109
+ time = _dt64_to_epoch_s(ds["time"].values)
110
+ depth = np.asarray(ds["depth"].values, dtype=np.float64)
111
+ u = np.asarray(ds["u"].values, dtype=np.float64)
112
+ v = np.asarray(ds["v"].values, dtype=np.float64)
113
+ bad = ~np.isfinite(u) | ~np.isfinite(v)
114
+ if "pflag" in ds.variables:
115
+ bad |= np.asarray(ds["pflag"].values) != 0
116
+ if min_pg is not None:
117
+ if "pg" not in ds.variables:
118
+ # Silently skipping a requested QC floor would report
119
+ # uncut data as if it passed the cut.
120
+ raise ValueError(
121
+ f"{path}: min_pg={min_pg} requested but the file has "
122
+ "no 'pg' (percent-good) variable"
123
+ )
124
+ pg = np.asarray(ds["pg"].values, dtype=np.float64)
125
+ bad |= ~(pg >= min_pg)
126
+ u = np.where(bad, np.nan, u)
127
+ v = np.where(bad, np.nan, v)
128
+ # CODAS writes finite, strictly increasing time and shallow-to-deep
129
+ # cell depths; violations mean a corrupt or concatenated file and
130
+ # would silently break window_shear's searchsorted assumptions.
131
+ if not (np.all(np.isfinite(time)) and np.all(np.diff(time) > 0)):
132
+ raise ValueError(f"{path}: ADCP time axis is not finite and strictly increasing")
133
+ if np.any(np.diff(depth, axis=-1) <= 0): # NaN pairs compare False: skipped
134
+ raise ValueError(f"{path}: ADCP cell depths are not shallow-to-deep ascending")
135
+ return AdcpData(
136
+ name=name if name is not None else path.stem,
137
+ time=time,
138
+ depth=depth,
139
+ u=u,
140
+ v=v,
141
+ path=path,
142
+ )
143
+
144
+
145
+ def shear_squared(
146
+ u: npt.ArrayLike,
147
+ v: npt.ArrayLike,
148
+ depth: npt.ArrayLike,
149
+ ) -> tuple[np.ndarray, np.ndarray]:
150
+ """First-difference shear squared between adjacent depth cells.
151
+
152
+ Works on a single profile ``(n_cell,)`` or a time-depth block
153
+ ``(n_t, n_cell)``; differencing is along the last (cell) axis.
154
+
155
+ Returns
156
+ -------
157
+ (S2, z_mid) : tuple of ndarray
158
+ ``S2 = (du^2 + dv^2) / dz^2`` [s^-2] and the mid-cell depths
159
+ [m], one element shorter than the input along the cell axis.
160
+ NaN wherever either bounding cell is masked (no differencing
161
+ across gaps) or the cell spacing is not positive.
162
+ """
163
+ u_arr = np.asarray(u, dtype=np.float64)
164
+ v_arr = np.asarray(v, dtype=np.float64)
165
+ z_arr = np.asarray(depth, dtype=np.float64)
166
+ du = np.diff(u_arr, axis=-1)
167
+ dv = np.diff(v_arr, axis=-1)
168
+ dz = np.diff(z_arr, axis=-1)
169
+ z_mid = 0.5 * (z_arr[..., 1:] + z_arr[..., :-1])
170
+ with np.errstate(divide="ignore", invalid="ignore"):
171
+ s2 = np.where(dz > 0, (du**2 + dv**2) / dz**2, np.nan)
172
+ return s2, z_mid
173
+
174
+
175
+ def _nanmean_axis0(a: np.ndarray) -> np.ndarray:
176
+ """NaN-aware column means without np.nanmean's empty-slice warning."""
177
+ m = np.isfinite(a)
178
+ cnt = m.sum(axis=0)
179
+ tot = np.where(m, a, 0.0).sum(axis=0)
180
+ with np.errstate(invalid="ignore", divide="ignore"):
181
+ return np.where(cnt > 0, tot / np.where(cnt > 0, cnt, 1), np.nan)
182
+
183
+
184
+ def window_shear(
185
+ adcp: AdcpData,
186
+ times: npt.ArrayLike,
187
+ depths: npt.ArrayLike,
188
+ *,
189
+ time_tolerance: float = DEFAULT_TIME_TOLERANCE,
190
+ max_gap: float | None = None,
191
+ ) -> WindowShear:
192
+ """Shear squared at (time, depth) query points (e.g. chi windows).
193
+
194
+ For each query, ensembles within ``time_tolerance`` seconds are
195
+ averaged per cell (NaN-aware) *before* differencing — averaging
196
+ velocities first suppresses uncorrelated ensemble noise that would
197
+ otherwise rectify into a spurious ``S^2`` floor — then ``S^2`` is
198
+ linearly interpolated from the mid-cell depths to the query depth.
199
+
200
+ Parameters
201
+ ----------
202
+ adcp : AdcpData
203
+ A sonar product from :func:`read_codas`.
204
+ times, depths : array_like, shape (n,)
205
+ Query epoch times [s] and depths [m, positive down].
206
+ time_tolerance : float
207
+ Half-width [s] of the ensemble-selection window.
208
+ max_gap : float or None
209
+ Maximum separation [m] between the bracketing finite shear
210
+ estimates for interpolation to proceed; ``None`` uses
211
+ ``DEFAULT_MAX_GAP_FACTOR`` times the sonar's median cell
212
+ spacing. Queries outside the finite shear column, or spanned
213
+ by a wider gap, return NaN.
214
+
215
+ Returns
216
+ -------
217
+ WindowShear
218
+ ``S2`` [s^-2] and the ensemble count per query.
219
+ """
220
+ q_times = np.atleast_1d(np.asarray(times, dtype=np.float64))
221
+ q_depths = np.atleast_1d(np.asarray(depths, dtype=np.float64))
222
+ if q_times.ndim > 1 or q_depths.ndim > 1:
223
+ raise ValueError("times and depths must be 1-D (or scalar)")
224
+ if q_times.shape != q_depths.shape:
225
+ raise ValueError("times and depths must have the same shape")
226
+ if not (np.isfinite(time_tolerance) and time_tolerance > 0):
227
+ raise ValueError(f"time_tolerance must be positive, got {time_tolerance}")
228
+ n = q_times.size
229
+ s2_out = np.full(n, np.nan)
230
+ n_ens = np.zeros(n, dtype=np.intp)
231
+
232
+ if max_gap is None:
233
+ cell_dz = np.diff(adcp.depth, axis=-1)
234
+ med_dz = float(np.nanmedian(cell_dz)) if cell_dz.size else np.nan
235
+ if not (np.isfinite(med_dz) and med_dz > 0):
236
+ # < 2 cells (or no finite spacing): the sonar cannot produce a
237
+ # first-difference shear at all — fail loudly, not with NaNs.
238
+ raise ValueError(f"{adcp.name}: no usable cell spacing for shear differencing")
239
+ max_gap = DEFAULT_MAX_GAP_FACTOR * med_dz
240
+
241
+ t_adcp = adcp.time
242
+ for i in range(n):
243
+ if not (np.isfinite(q_times[i]) and np.isfinite(q_depths[i])):
244
+ continue
245
+ lo = int(np.searchsorted(t_adcp, q_times[i] - time_tolerance, "left"))
246
+ hi = int(np.searchsorted(t_adcp, q_times[i] + time_tolerance, "right"))
247
+ if hi <= lo:
248
+ continue
249
+ n_ens[i] = hi - lo
250
+ # NaN-aware per-cell ensemble means. A cell present in only some
251
+ # ensembles still contributes — the mask varies ping to ping.
252
+ u_bar = _nanmean_axis0(adcp.u[lo:hi])
253
+ v_bar = _nanmean_axis0(adcp.v[lo:hi])
254
+ z_bar = _nanmean_axis0(adcp.depth[lo:hi])
255
+ s2, z_mid = shear_squared(u_bar, v_bar, z_bar)
256
+ good = np.isfinite(s2) & np.isfinite(z_mid)
257
+ if not np.any(good):
258
+ continue
259
+ zg = z_mid[good]
260
+ sg = s2[good]
261
+ # zg inherits the cell ordering (shallow -> deep) from the file.
262
+ k = int(np.searchsorted(zg, q_depths[i], side="right"))
263
+ if k == 0:
264
+ continue # query above the finite shear column
265
+ if k == zg.size:
266
+ if q_depths[i] == zg[-1]: # exactly on the deepest estimate
267
+ s2_out[i] = sg[-1]
268
+ continue # else: below the finite shear column
269
+ if zg[k] - zg[k - 1] > max_gap:
270
+ continue # bracketing estimates straddle a masked gap
271
+ frac = (q_depths[i] - zg[k - 1]) / (zg[k] - zg[k - 1])
272
+ s2_out[i] = sg[k - 1] + frac * (sg[k] - sg[k - 1])
273
+
274
+ return WindowShear(S2=s2_out, n_ens=n_ens)
@@ -0,0 +1,47 @@
1
+ # Jul-2026, Claude and Pat Welch, pat@mousebrains.com
2
+ """Crash-atomic NetCDF writes for perturb products.
3
+
4
+ A direct ``ds.to_netcdf(path)`` (or a low-level ``netCDF4.Dataset(path, "w")``)
5
+ interrupted mid-payload — ENOSPC, an SMB/network drop on SeaChest, Ctrl-C, or any
6
+ raised exception — leaves a READABLE but PARTIAL NetCDF at the live path. The
7
+ perturb bin/combo manifest keys on the SOURCE ``.p`` cache keys, not the product
8
+ content, so a clean retry (identical filenames -> identical manifest) skips
9
+ re-assembly and permanently publishes the truncated file with no error.
10
+
11
+ Both helpers here write to a sibling ``.{name}.{pid}.tmp`` on the SAME filesystem
12
+ and ``os.replace`` it into place only after a clean write (``os.replace`` is
13
+ atomic within a filesystem), so a partial write never becomes the live file. The
14
+ temp is unlinked on any ``BaseException``.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import contextlib
20
+ import os
21
+ from pathlib import Path
22
+ from typing import TYPE_CHECKING, Any
23
+
24
+ if TYPE_CHECKING:
25
+ import xarray as xr
26
+
27
+
28
+ def tmp_sibling(out_path: Path) -> Path:
29
+ """A sibling temp path on the SAME filesystem as *out_path* (so ``os.replace``
30
+ into place is atomic). PID-suffixed so concurrent workers never collide."""
31
+ return out_path.with_name(f".{out_path.name}.{os.getpid()}.tmp")
32
+
33
+
34
+ def atomic_to_netcdf(ds: xr.Dataset, out_path: Path, **to_netcdf_kwargs: Any) -> None:
35
+ """Write *ds* to *out_path* atomically (temp file + ``os.replace``).
36
+
37
+ ``**to_netcdf_kwargs`` are forwarded to :meth:`xarray.Dataset.to_netcdf`
38
+ (e.g. ``encoding=``). A partial or failed write never becomes the live file.
39
+ """
40
+ tmp = tmp_sibling(out_path)
41
+ try:
42
+ ds.to_netcdf(tmp, **to_netcdf_kwargs)
43
+ os.replace(tmp, out_path)
44
+ except BaseException:
45
+ with contextlib.suppress(OSError):
46
+ os.unlink(tmp)
47
+ raise