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
odas_tpw/perturb/adcp.py
ADDED
|
@@ -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
|