oceanarray 0.1.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.
- oceanarray/__init__.py +20 -0
- oceanarray/_version.py +1 -0
- oceanarray/cli.py +1343 -0
- oceanarray/clock_offset.py +675 -0
- oceanarray/config/OS1_sensor_attrs.yaml +49 -0
- oceanarray/config/OS1_var_names.yaml +5 -0
- oceanarray/config/OS1_vocab_attrs.yaml +84 -0
- oceanarray/config/legacy/project_RAPID.yaml +69 -0
- oceanarray/config/legacy/rodb_keys.txt +206 -0
- oceanarray/config/legacy/rodb_keys.yaml +1012 -0
- oceanarray/config/logging.yaml +28 -0
- oceanarray/legacy/__init__.py +47 -0
- oceanarray/legacy/caldip.py +753 -0
- oceanarray/legacy/convertOS.py +527 -0
- oceanarray/legacy/find_deployment.py +489 -0
- oceanarray/legacy/mooring_rodb.py +429 -0
- oceanarray/legacy/process_rodb.py +369 -0
- oceanarray/legacy/rodb.py +330 -0
- oceanarray/logger.py +192 -0
- oceanarray/logsheet/__init__.py +60 -0
- oceanarray/logsheet/_columns.py +97 -0
- oceanarray/logsheet/_config.py +245 -0
- oceanarray/logsheet/_firmware.py +43 -0
- oceanarray/logsheet/_latex.py +128 -0
- oceanarray/logsheet/_render.py +126 -0
- oceanarray/logsheet/builders/__init__.py +13 -0
- oceanarray/logsheet/builders/caldip_download.py +195 -0
- oceanarray/logsheet/builders/caldip_setup.py +188 -0
- oceanarray/logsheet/builders/deployment_setup.py +218 -0
- oceanarray/logsheet/builders/mooring_download.py +243 -0
- oceanarray/logsheet/builders/recovery.py +157 -0
- oceanarray/logsheet/data/column_defs.yaml +686 -0
- oceanarray/logsheet/data/latex_templates/_base_instrument.tex.j2 +42 -0
- oceanarray/logsheet/data/latex_templates/caldip_download.tex.j2 +47 -0
- oceanarray/logsheet/data/latex_templates/caldip_setup.tex.j2 +70 -0
- oceanarray/logsheet/data/latex_templates/deployment_setup.tex.j2 +97 -0
- oceanarray/logsheet/data/latex_templates/mooring_download.tex.j2 +54 -0
- oceanarray/logsheet/data/latex_templates/recovery.tex.j2 +103 -0
- oceanarray/mooring_level.py +1368 -0
- oceanarray/oceanarray.mplstyle +18 -0
- oceanarray/parameters.py +270 -0
- oceanarray/plotter.py +942 -0
- oceanarray/plotters/__init__.py +97 -0
- oceanarray/plotters/_animation.py +261 -0
- oceanarray/plotters/_current.py +705 -0
- oceanarray/plotters/_diagnostic.py +667 -0
- oceanarray/plotters/_helpers.py +57 -0
- oceanarray/plotters/_primitives.py +101 -0
- oceanarray/plotters/_section.py +16 -0
- oceanarray/plotters/_timeseries.py +11 -0
- oceanarray/rapid_interp.py +739 -0
- oceanarray/read_rbr_hex.py +541 -0
- oceanarray/readers.py +303 -0
- oceanarray/report/__init__.py +5 -0
- oceanarray/report/_array.py +386 -0
- oceanarray/report/_grid.py +539 -0
- oceanarray/report/_html_helpers.py +1004 -0
- oceanarray/report/_instrument.py +853 -0
- oceanarray/report/_mooring.py +1385 -0
- oceanarray/report/_plots.py +4284 -0
- oceanarray/report/_stack.py +941 -0
- oceanarray/stage1.py +1959 -0
- oceanarray/stage2.py +1160 -0
- oceanarray/stage3.py +2494 -0
- oceanarray/time_gridding.py +958 -0
- oceanarray/tools.py +400 -0
- oceanarray/transports.py +1 -0
- oceanarray/utilities.py +538 -0
- oceanarray/validation.py +389 -0
- oceanarray/writers.py +99 -0
- oceanarray-0.1.0.dist-info/METADATA +503 -0
- oceanarray-0.1.0.dist-info/RECORD +76 -0
- oceanarray-0.1.0.dist-info/WHEEL +5 -0
- oceanarray-0.1.0.dist-info/entry_points.txt +2 -0
- oceanarray-0.1.0.dist-info/licenses/LICENSE +21 -0
- oceanarray-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pandas as pd
|
|
5
|
+
|
|
6
|
+
# Initialize logging
|
|
7
|
+
_log = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _first_sustained(mask, min_len):
|
|
11
|
+
for s, e in _runs_of_true(mask):
|
|
12
|
+
if (e - s + 1) >= min_len:
|
|
13
|
+
return s, e
|
|
14
|
+
return None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _last_sustained(mask, min_len):
|
|
18
|
+
runs = [(s, e) for (s, e) in _runs_of_true(mask) if (e - s + 1) >= min_len]
|
|
19
|
+
return runs[-1] if runs else None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _rolling_median(y: np.ndarray, win: int) -> np.ndarray:
|
|
23
|
+
if win <= 1:
|
|
24
|
+
return np.asarray(y, float)
|
|
25
|
+
return pd.Series(y).rolling(win, center=True, min_periods=1).median().to_numpy()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _sampling_seconds(time):
|
|
29
|
+
t = pd.to_datetime(time).to_numpy()
|
|
30
|
+
if t.size < 2:
|
|
31
|
+
return np.nan
|
|
32
|
+
d = np.diff(t).astype("timedelta64[s]").astype(float)
|
|
33
|
+
d = d[np.isfinite(d) & (d > 0)]
|
|
34
|
+
return float(np.nanmedian(d)) if d.size else np.nan
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _runs_of_true(mask: np.ndarray) -> list[tuple[int, int]]:
|
|
38
|
+
idx = np.flatnonzero(mask)
|
|
39
|
+
if idx.size == 0:
|
|
40
|
+
return []
|
|
41
|
+
gaps = np.diff(idx) > 1
|
|
42
|
+
starts = np.r_[idx[0], idx[1:][gaps]]
|
|
43
|
+
ends = np.r_[idx[:-1][gaps], idx[-1]]
|
|
44
|
+
return [(int(s), int(e)) for s, e in zip(starts, ends)]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _best_step_change(y: np.ndarray, min_seg: int = 1) -> tuple[int, float, float]:
|
|
48
|
+
"""One change-point (piecewise constant) via SSE minimization.
|
|
49
|
+
Returns (k, mu_left, mu_right) with 0 < k < n (split after k-1).
|
|
50
|
+
Enforces min_seg samples on each side.
|
|
51
|
+
"""
|
|
52
|
+
y = np.asarray(y, float)
|
|
53
|
+
n = y.size
|
|
54
|
+
if n < 2 * min_seg + 1:
|
|
55
|
+
# too short; no split
|
|
56
|
+
mu = float(np.nanmean(y))
|
|
57
|
+
return max(min_seg, n // 2), mu, mu
|
|
58
|
+
|
|
59
|
+
# cumulative sums
|
|
60
|
+
c1 = np.nancumsum(np.where(np.isfinite(y), y, 0.0))
|
|
61
|
+
c2 = np.nancumsum(np.where(np.isfinite(y), y * y, 0.0))
|
|
62
|
+
cnt = np.nancumsum(np.isfinite(y).astype(float))
|
|
63
|
+
|
|
64
|
+
best_k, best_sse = None, np.inf
|
|
65
|
+
best_mu1, best_mu2 = np.nan, np.nan
|
|
66
|
+
|
|
67
|
+
for k in range(min_seg, n - min_seg + 1):
|
|
68
|
+
n1 = cnt[k - 1]
|
|
69
|
+
n2 = cnt[-1] - cnt[k - 1]
|
|
70
|
+
if n1 < min_seg or n2 < min_seg:
|
|
71
|
+
continue
|
|
72
|
+
s1 = c1[k - 1]
|
|
73
|
+
s2 = c1[-1] - c1[k - 1]
|
|
74
|
+
q1 = c2[k - 1]
|
|
75
|
+
q2 = c2[-1] - c2[k - 1]
|
|
76
|
+
mu1 = s1 / n1
|
|
77
|
+
mu2 = s2 / n2
|
|
78
|
+
sse = (q1 - n1 * mu1 * mu1) + (q2 - n2 * mu2 * mu2)
|
|
79
|
+
if sse < best_sse:
|
|
80
|
+
best_sse = sse
|
|
81
|
+
best_k = k
|
|
82
|
+
best_mu1 = float(mu1)
|
|
83
|
+
best_mu2 = float(mu2)
|
|
84
|
+
|
|
85
|
+
if best_k is None:
|
|
86
|
+
mu = float(np.nanmean(y))
|
|
87
|
+
return max(min_seg, n // 2), mu, mu
|
|
88
|
+
return best_k, best_mu1, best_mu2
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ---------- helpers you can customise ----------
|
|
92
|
+
def likely_at_bottom_window(
|
|
93
|
+
time: np.ndarray,
|
|
94
|
+
strategy: str = "fixed_hours",
|
|
95
|
+
*,
|
|
96
|
+
bottom_margin_hours: float = 24.0,
|
|
97
|
+
bottom_inner_fraction: float = 0.25,
|
|
98
|
+
deployment_time: np.datetime64 | None = None,
|
|
99
|
+
recovery_time: np.datetime64 | None = None,
|
|
100
|
+
deployment_margin_hours: float = 2.0,
|
|
101
|
+
) -> tuple[np.datetime64, np.datetime64]:
|
|
102
|
+
"""Return (bot_start, bot_end): the window where the instrument is almost surely at depth.
|
|
103
|
+
Strategies:
|
|
104
|
+
- 'fixed_hours': [tmin+margin, tmax-margin]
|
|
105
|
+
- 'percent_span': [tmin+f, tmax-f] using inner fraction (e.g. 25%)
|
|
106
|
+
- 'deployment_bounds': [deploy+2h, recover-2h]
|
|
107
|
+
"""
|
|
108
|
+
t = pd.to_datetime(time)
|
|
109
|
+
tmin, tmax = t[0], t[-1] # <-- was .iloc[0], .iloc[-1]
|
|
110
|
+
print(f"My start is {tmin} and end is {tmax}")
|
|
111
|
+
|
|
112
|
+
if strategy == "fixed_hours":
|
|
113
|
+
start = tmin + pd.Timedelta(hours=bottom_margin_hours)
|
|
114
|
+
end = tmax - pd.Timedelta(hours=bottom_margin_hours)
|
|
115
|
+
elif (
|
|
116
|
+
strategy == "deployment_bounds"
|
|
117
|
+
and (deployment_time is not None)
|
|
118
|
+
and (recovery_time is not None)
|
|
119
|
+
):
|
|
120
|
+
start = pd.to_datetime(deployment_time) + pd.Timedelta(
|
|
121
|
+
hours=deployment_margin_hours
|
|
122
|
+
)
|
|
123
|
+
end = pd.to_datetime(recovery_time) - pd.Timedelta(
|
|
124
|
+
hours=deployment_margin_hours
|
|
125
|
+
)
|
|
126
|
+
else: # 'percent_span' default
|
|
127
|
+
span = tmax - tmin
|
|
128
|
+
pad = pd.to_timedelta(bottom_inner_fraction, unit="D") * (
|
|
129
|
+
span / pd.Timedelta(days=1)
|
|
130
|
+
)
|
|
131
|
+
start = tmin + pad
|
|
132
|
+
end = tmax - pad
|
|
133
|
+
|
|
134
|
+
# clip to available range
|
|
135
|
+
start = max(start, tmin)
|
|
136
|
+
end = min(end, tmax)
|
|
137
|
+
if end <= start:
|
|
138
|
+
# fallback: middle third
|
|
139
|
+
start = tmin + (tmax - tmin) * 0.33
|
|
140
|
+
end = tmin + (tmax - tmin) * 0.66
|
|
141
|
+
|
|
142
|
+
return np.datetime64(start.to_datetime64()), np.datetime64(end.to_datetime64())
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def compute_threshold_sigma_band(
|
|
146
|
+
time,
|
|
147
|
+
temp,
|
|
148
|
+
sea_mean,
|
|
149
|
+
sea_std,
|
|
150
|
+
*,
|
|
151
|
+
band_sigma=3.0,
|
|
152
|
+
min_band_halfwidth=0.10, # °C
|
|
153
|
+
warm_side="auto", # "high", "low", or "auto"
|
|
154
|
+
hours=24.0,
|
|
155
|
+
smooth_window=9,
|
|
156
|
+
warm_percentile=90,
|
|
157
|
+
):
|
|
158
|
+
"""Decide a single threshold using sigma-band logic.
|
|
159
|
+
|
|
160
|
+
- Band half-width = max(band_sigma*sea_std, min_band_halfwidth).
|
|
161
|
+
- If warm_side == "high": threshold = sea_mean + half_width (deep is below threshold)
|
|
162
|
+
If warm_side == "low" : threshold = sea_mean - half_width (deep is above threshold)
|
|
163
|
+
If warm_side == "auto": infer 'high' vs 'low' from early/late warm percentiles.
|
|
164
|
+
|
|
165
|
+
Returns
|
|
166
|
+
-------
|
|
167
|
+
threshold : float
|
|
168
|
+
deep_is_below : bool
|
|
169
|
+
True if 'deep' should be considered <= threshold, False if >= threshold.
|
|
170
|
+
|
|
171
|
+
"""
|
|
172
|
+
halfw = max(float(band_sigma) * float(sea_std), float(min_band_halfwidth))
|
|
173
|
+
|
|
174
|
+
# decide warm side
|
|
175
|
+
side = warm_side
|
|
176
|
+
if side == "auto":
|
|
177
|
+
t = pd.to_datetime(time)
|
|
178
|
+
y = np.asarray(temp, float)
|
|
179
|
+
ys = (
|
|
180
|
+
pd.Series(y)
|
|
181
|
+
.rolling(smooth_window, center=True, min_periods=1)
|
|
182
|
+
.median()
|
|
183
|
+
.to_numpy()
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
tmin, tmax = t[0], t[-1]
|
|
187
|
+
early = t <= (tmin + pd.Timedelta(hours=hours))
|
|
188
|
+
late = t >= (tmax - pd.Timedelta(hours=hours))
|
|
189
|
+
|
|
190
|
+
warm_samples = []
|
|
191
|
+
if np.any(early):
|
|
192
|
+
warm_samples.append(np.nanpercentile(ys[early], warm_percentile))
|
|
193
|
+
if np.any(late):
|
|
194
|
+
warm_samples.append(np.nanpercentile(ys[late], warm_percentile))
|
|
195
|
+
|
|
196
|
+
if len(warm_samples):
|
|
197
|
+
warm_est = float(np.nanmedian(warm_samples))
|
|
198
|
+
side = "high" if warm_est >= sea_mean else "low"
|
|
199
|
+
else:
|
|
200
|
+
side = "high" # sensible default for temperature
|
|
201
|
+
|
|
202
|
+
if side == "low":
|
|
203
|
+
threshold = float(sea_mean - halfw)
|
|
204
|
+
deep_is_below = False # deep temps are >= threshold (colder is "higher" only if variable inversed; for temp, 'low' warm side is rare)
|
|
205
|
+
else:
|
|
206
|
+
threshold = float(sea_mean + halfw)
|
|
207
|
+
deep_is_below = True # deep temps <= threshold (usual case for temperature)
|
|
208
|
+
|
|
209
|
+
return threshold, deep_is_below
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def determine_sea_values(
|
|
213
|
+
time: np.ndarray, temp: np.ndarray, bot_start: np.datetime64, bot_end: np.datetime64
|
|
214
|
+
) -> tuple[float, float]:
|
|
215
|
+
"""Return (sea_mean, sea_std) computed over the bottom window [bot_start, bot_end].
|
|
216
|
+
If the mask is empty, fall back to whole-series robust stats.
|
|
217
|
+
"""
|
|
218
|
+
t = pd.to_datetime(time)
|
|
219
|
+
y = np.asarray(temp, float)
|
|
220
|
+
m = (t >= pd.to_datetime(bot_start)) & (t <= pd.to_datetime(bot_end))
|
|
221
|
+
if np.any(m):
|
|
222
|
+
sea_mean = float(np.nanmean(y[m]))
|
|
223
|
+
sea_std = (
|
|
224
|
+
float(np.nanstd(y[m], ddof=1)) if np.sum(np.isfinite(y[m])) > 1 else 0.0
|
|
225
|
+
)
|
|
226
|
+
else:
|
|
227
|
+
# fallback: middle 50% as proxy
|
|
228
|
+
q25, q75 = np.nanpercentile(y, [25, 75])
|
|
229
|
+
m2 = (y >= q25) & (y <= q75)
|
|
230
|
+
sea_mean = float(np.nanmean(y[m2])) if np.any(m2) else float(np.nanmean(y))
|
|
231
|
+
sea_std = (
|
|
232
|
+
float(np.nanstd(y[m2], ddof=1))
|
|
233
|
+
if np.sum(np.isfinite(y[m2])) > 1
|
|
234
|
+
else float(np.nanstd(y, ddof=1))
|
|
235
|
+
)
|
|
236
|
+
return sea_mean, sea_std
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def likely_at_surface(
|
|
240
|
+
time: np.ndarray,
|
|
241
|
+
temp: np.ndarray,
|
|
242
|
+
sea_mean: float,
|
|
243
|
+
sea_std: float,
|
|
244
|
+
*,
|
|
245
|
+
hours: float = 24.0,
|
|
246
|
+
smooth_window: int = 9,
|
|
247
|
+
dwell_seconds: int = 1800,
|
|
248
|
+
tol_sigma: float = 2.0,
|
|
249
|
+
) -> tuple[np.datetime64 | None, np.datetime64 | None, float]:
|
|
250
|
+
"""Return (sink_time, float_time, threshold).
|
|
251
|
+
- sink_time: first sustained entry into 'sea' regime found in the early window.
|
|
252
|
+
- float_time: last sustained exit from 'sea' regime found in the late window.
|
|
253
|
+
- threshold: separator between warm and sea (midpoint between sea_mean and estimated warm_mean).
|
|
254
|
+
"""
|
|
255
|
+
t = pd.to_datetime(time)
|
|
256
|
+
y = np.asarray(temp, float)
|
|
257
|
+
ys = _rolling_median(y, smooth_window)
|
|
258
|
+
|
|
259
|
+
tmin, tmax = t[0], t[-1]
|
|
260
|
+
early_mask = t <= (tmin + pd.Timedelta(hours=hours))
|
|
261
|
+
late_mask = t >= (tmax - pd.Timedelta(hours=hours))
|
|
262
|
+
|
|
263
|
+
te, ye = t[early_mask], ys[early_mask]
|
|
264
|
+
tl, yl = t[late_mask], ys[late_mask]
|
|
265
|
+
|
|
266
|
+
dt = _sampling_seconds(time)
|
|
267
|
+
min_len = int(np.ceil(dwell_seconds / dt)) if np.isfinite(dt) and dt > 0 else 1
|
|
268
|
+
|
|
269
|
+
# "cold" test relative to sea_mean
|
|
270
|
+
cold_limit = sea_mean + tol_sigma * sea_std
|
|
271
|
+
is_cold_e = ye <= cold_limit
|
|
272
|
+
is_cold_l = yl <= cold_limit
|
|
273
|
+
|
|
274
|
+
sink_time = None
|
|
275
|
+
float_time = None
|
|
276
|
+
warm_means = []
|
|
277
|
+
|
|
278
|
+
# ---- EARLY window: warm -> cold
|
|
279
|
+
if te.size >= 2 * min_len + 1:
|
|
280
|
+
k_e, mu_e1, mu_e2 = _best_step_change(ye, min_seg=min_len)
|
|
281
|
+
# prefer splits where later segment looks like sea
|
|
282
|
+
if (mu_e2 < mu_e1) and (abs(mu_e2 - sea_mean) <= abs(mu_e1 - sea_mean)):
|
|
283
|
+
# first sustained cold run AFTER k_e
|
|
284
|
+
post_mask = np.zeros_like(is_cold_e, dtype=bool)
|
|
285
|
+
post_mask[k_e:] = True
|
|
286
|
+
runs = _runs_of_true(is_cold_e & post_mask)
|
|
287
|
+
runs = [(s, e) for (s, e) in runs if (e - s + 1) >= min_len]
|
|
288
|
+
if runs:
|
|
289
|
+
s0, _ = runs[0]
|
|
290
|
+
sink_time = np.datetime64(te[s0].to_datetime64())
|
|
291
|
+
warm_means.append(mu_e1) # early warm
|
|
292
|
+
else:
|
|
293
|
+
# fallback: first sustained cold run in early window
|
|
294
|
+
runs = _runs_of_true(is_cold_e)
|
|
295
|
+
runs = [(s, e) for (s, e) in runs if (e - s + 1) >= min_len]
|
|
296
|
+
if runs:
|
|
297
|
+
s0, _ = runs[0]
|
|
298
|
+
sink_time = np.datetime64(te[s0].to_datetime64())
|
|
299
|
+
# estimate warm as mean before first cold run
|
|
300
|
+
if runs and runs[0][0] > 0:
|
|
301
|
+
warm_means.append(float(np.nanmean(ye[: runs[0][0]])))
|
|
302
|
+
else:
|
|
303
|
+
# too short: simple sustained cold
|
|
304
|
+
runs = _runs_of_true(is_cold_e)
|
|
305
|
+
runs = [(s, e) for (s, e) in runs if (e - s + 1) >= min_len]
|
|
306
|
+
if runs:
|
|
307
|
+
s0, _ = runs[0]
|
|
308
|
+
sink_time = np.datetime64(te[s0].to_datetime64())
|
|
309
|
+
if runs and runs[0][0] > 0:
|
|
310
|
+
warm_means.append(float(np.nanmean(ye[: runs[0][0]])))
|
|
311
|
+
|
|
312
|
+
# ---- LATE window: cold -> warm
|
|
313
|
+
if tl.size >= 2 * min_len + 1:
|
|
314
|
+
k_l, mu_l1, mu_l2 = _best_step_change(yl, min_seg=min_len)
|
|
315
|
+
# prefer splits where earlier segment looks like sea
|
|
316
|
+
if (mu_l1 < mu_l2) and (abs(mu_l1 - sea_mean) <= abs(mu_l2 - sea_mean)):
|
|
317
|
+
# last sustained cold run BEFORE k_l
|
|
318
|
+
pre_mask = np.zeros_like(is_cold_l, dtype=bool)
|
|
319
|
+
pre_mask[:k_l] = True
|
|
320
|
+
runs = _runs_of_true(is_cold_l & pre_mask)
|
|
321
|
+
runs = [(s, e) for (s, e) in runs if (e - s + 1) >= min_len]
|
|
322
|
+
if runs:
|
|
323
|
+
_, eL = runs[-1]
|
|
324
|
+
float_time = np.datetime64(tl[eL].to_datetime64())
|
|
325
|
+
warm_means.append(mu_l2) # late warm
|
|
326
|
+
else:
|
|
327
|
+
# fallback: last sustained cold run in late window
|
|
328
|
+
runs = _runs_of_true(is_cold_l)
|
|
329
|
+
runs = [(s, e) for (s, e) in runs if (e - s + 1) >= min_len]
|
|
330
|
+
if runs:
|
|
331
|
+
_, eL = runs[-1]
|
|
332
|
+
float_time = np.datetime64(tl[eL].to_datetime64())
|
|
333
|
+
# estimate warm as mean after last cold run
|
|
334
|
+
if runs and runs[-1][1] < (yl.size - 1):
|
|
335
|
+
warm_means.append(float(np.nanmean(yl[runs[-1][1] + 1 :])))
|
|
336
|
+
else:
|
|
337
|
+
runs = _runs_of_true(is_cold_l)
|
|
338
|
+
runs = [(s, e) for (s, e) in runs if (e - s + 1) >= min_len]
|
|
339
|
+
if runs:
|
|
340
|
+
_, eL = runs[-1]
|
|
341
|
+
float_time = np.datetime64(tl[eL].to_datetime64())
|
|
342
|
+
if runs and runs[-1][1] < (yl.size - 1):
|
|
343
|
+
warm_means.append(float(np.nanmean(yl[runs[-1][1] + 1 :])))
|
|
344
|
+
|
|
345
|
+
# ---- threshold: midpoint between sea_mean and an estimated warm mean
|
|
346
|
+
if warm_means:
|
|
347
|
+
warm_mean = float(np.nanmedian(warm_means))
|
|
348
|
+
threshold = 0.5 * (sea_mean + warm_mean)
|
|
349
|
+
else:
|
|
350
|
+
# fallback if we never saw warm segments: small offset above sea
|
|
351
|
+
threshold = sea_mean + max(0.5, 2.0 * sea_std)
|
|
352
|
+
|
|
353
|
+
return sink_time, float_time, float(threshold)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def find_entry_exit_from_threshold(time, temp, threshold):
|
|
357
|
+
"""Return (sink_time, float_time) where temp is below threshold.
|
|
358
|
+
No smoothing, no dwell time — just first/last index where condition holds.
|
|
359
|
+
"""
|
|
360
|
+
t = pd.to_datetime(time).to_numpy() # datetime64[ns] array
|
|
361
|
+
y = np.asarray(temp, float)
|
|
362
|
+
|
|
363
|
+
finite = np.isfinite(y)
|
|
364
|
+
below = finite & (y < threshold)
|
|
365
|
+
|
|
366
|
+
idx = np.where(below)[0]
|
|
367
|
+
if idx.size:
|
|
368
|
+
sink_time = np.datetime64(t[idx[0]])
|
|
369
|
+
float_time = np.datetime64(t[idx[-1]])
|
|
370
|
+
else:
|
|
371
|
+
sink_time = np.datetime64("NaT", "ns")
|
|
372
|
+
float_time = np.datetime64("NaT", "ns")
|
|
373
|
+
|
|
374
|
+
return sink_time, float_time
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
# ---------- main, function-only API ----------
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def find_deployment(
|
|
381
|
+
ds,
|
|
382
|
+
var_name="temperature",
|
|
383
|
+
*,
|
|
384
|
+
bottom_strategy="percent_span",
|
|
385
|
+
bottom_margin_hours=24.0,
|
|
386
|
+
bottom_inner_fraction=0.25,
|
|
387
|
+
deployment_time=None,
|
|
388
|
+
recovery_time=None,
|
|
389
|
+
deployment_margin_hours=2.0,
|
|
390
|
+
surface_window_hours=24.0, # noqa: ARG001 — reserved for future surface-window logic
|
|
391
|
+
smooth_window=9, # noqa: ARG001 — reserved for future smoothing step
|
|
392
|
+
method="sigma_band", # noqa: ARG001 — reserved for future method dispatch
|
|
393
|
+
band_sigma=3.0, # noqa: ARG001 — reserved for future sigma_band method
|
|
394
|
+
dwell_seconds=1800, # noqa: ARG001 — reserved for future dwell filter
|
|
395
|
+
):
|
|
396
|
+
"""Populate ds with:
|
|
397
|
+
- start_time (N_LEVELS) : first deep time (when temps settle cold)
|
|
398
|
+
- end_time (N_LEVELS) : last deep time (before temps warm)
|
|
399
|
+
- split_value(N_LEVELS) : threshold separating surface vs sea regimes
|
|
400
|
+
|
|
401
|
+
Returns ds with the variables added/updated.
|
|
402
|
+
"""
|
|
403
|
+
if var_name not in ds:
|
|
404
|
+
raise ValueError(f"{var_name!r} not found in dataset.") # noqa: TRY003
|
|
405
|
+
if ds[var_name].dims != ("time", "N_LEVELS"):
|
|
406
|
+
raise ValueError( # noqa: TRY003
|
|
407
|
+
f"{var_name!r} must have dims ('time','N_LEVELS'). Got {ds[var_name].dims}"
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
nlev = ds.sizes.get("N_LEVELS", None)
|
|
411
|
+
if nlev is None:
|
|
412
|
+
raise ValueError("Dimension 'N_LEVELS' not found in dataset.") # noqa: TRY003
|
|
413
|
+
|
|
414
|
+
# ensure outputs exist with correct dtype/shape
|
|
415
|
+
if "start_time" not in ds:
|
|
416
|
+
ds["start_time"] = ("N_LEVELS", np.full(nlev, np.datetime64("NaT", "ns")))
|
|
417
|
+
if "end_time" not in ds:
|
|
418
|
+
ds["end_time"] = ("N_LEVELS", np.full(nlev, np.datetime64("NaT", "ns")))
|
|
419
|
+
if "split_value" not in ds:
|
|
420
|
+
ds["split_value"] = ("N_LEVELS", np.full(nlev, np.nan))
|
|
421
|
+
|
|
422
|
+
time = ds["time"].values
|
|
423
|
+
print(f"My time starts at {time[0]}")
|
|
424
|
+
|
|
425
|
+
for i in range(nlev):
|
|
426
|
+
data1 = ds[var_name][:, i].values
|
|
427
|
+
deployment_time = ds["time"][0].values
|
|
428
|
+
recovery_time = ds["time"][-1].values
|
|
429
|
+
print(f"recovery time is {recovery_time}")
|
|
430
|
+
|
|
431
|
+
# 1) pick bottom window using your chosen strategy
|
|
432
|
+
bot_start, bot_end = likely_at_bottom_window(
|
|
433
|
+
time,
|
|
434
|
+
strategy=bottom_strategy,
|
|
435
|
+
bottom_margin_hours=bottom_margin_hours,
|
|
436
|
+
bottom_inner_fraction=bottom_inner_fraction,
|
|
437
|
+
deployment_time=deployment_time,
|
|
438
|
+
recovery_time=recovery_time,
|
|
439
|
+
deployment_margin_hours=deployment_margin_hours,
|
|
440
|
+
)
|
|
441
|
+
print(f"My bot_start is {bot_start} and bot_end is {bot_end}")
|
|
442
|
+
# 2) sea (cold) representative value from bottom window
|
|
443
|
+
sea_mean, sea_std = determine_sea_values(time, data1, bot_start, bot_end)
|
|
444
|
+
print(f"My sea_mean is {sea_mean} and std is {sea_std}")
|
|
445
|
+
|
|
446
|
+
# 2) threshold (separate step)
|
|
447
|
+
thr, deep_is_below = compute_threshold_sigma_band(
|
|
448
|
+
time,
|
|
449
|
+
data1,
|
|
450
|
+
sea_mean,
|
|
451
|
+
sea_std,
|
|
452
|
+
band_sigma=1.5,
|
|
453
|
+
min_band_halfwidth=0.10,
|
|
454
|
+
warm_side="auto", # or "high"
|
|
455
|
+
hours=24.0,
|
|
456
|
+
smooth_window=9,
|
|
457
|
+
warm_percentile=90,
|
|
458
|
+
)
|
|
459
|
+
# 3) entry/exit from threshold (sustained crossings)
|
|
460
|
+
sink_time, float_time = find_entry_exit_from_threshold(time, data1, thr)
|
|
461
|
+
|
|
462
|
+
# 4) assign
|
|
463
|
+
ds["start_time"][i] = (
|
|
464
|
+
np.datetime64(sink_time)
|
|
465
|
+
if sink_time is not None
|
|
466
|
+
else np.datetime64("NaT", "ns")
|
|
467
|
+
)
|
|
468
|
+
ds["end_time"][i] = (
|
|
469
|
+
np.datetime64(float_time)
|
|
470
|
+
if float_time is not None
|
|
471
|
+
else np.datetime64("NaT", "ns")
|
|
472
|
+
)
|
|
473
|
+
ds["split_value"][i] = float(thr) if np.isfinite(thr) else np.nan
|
|
474
|
+
|
|
475
|
+
# 5) print summary (same style as you had)
|
|
476
|
+
serial = str(ds["serial_number"].values[i]) if "serial_number" in ds else None
|
|
477
|
+
inst = str(ds["instrument"].values[i]) if "instrument" in ds else None
|
|
478
|
+
label = f"{i}"
|
|
479
|
+
if serial:
|
|
480
|
+
label += f"/{serial}"
|
|
481
|
+
if inst:
|
|
482
|
+
label += f":{inst}"
|
|
483
|
+
print(
|
|
484
|
+
f"{label}: Split at {ds['split_value'][i].item():.2f}. "
|
|
485
|
+
f"Start after {ds['start_time'][i].values}. "
|
|
486
|
+
f"End with {ds['end_time'][i].values}."
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
return ds
|