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.
Files changed (76) hide show
  1. oceanarray/__init__.py +20 -0
  2. oceanarray/_version.py +1 -0
  3. oceanarray/cli.py +1343 -0
  4. oceanarray/clock_offset.py +675 -0
  5. oceanarray/config/OS1_sensor_attrs.yaml +49 -0
  6. oceanarray/config/OS1_var_names.yaml +5 -0
  7. oceanarray/config/OS1_vocab_attrs.yaml +84 -0
  8. oceanarray/config/legacy/project_RAPID.yaml +69 -0
  9. oceanarray/config/legacy/rodb_keys.txt +206 -0
  10. oceanarray/config/legacy/rodb_keys.yaml +1012 -0
  11. oceanarray/config/logging.yaml +28 -0
  12. oceanarray/legacy/__init__.py +47 -0
  13. oceanarray/legacy/caldip.py +753 -0
  14. oceanarray/legacy/convertOS.py +527 -0
  15. oceanarray/legacy/find_deployment.py +489 -0
  16. oceanarray/legacy/mooring_rodb.py +429 -0
  17. oceanarray/legacy/process_rodb.py +369 -0
  18. oceanarray/legacy/rodb.py +330 -0
  19. oceanarray/logger.py +192 -0
  20. oceanarray/logsheet/__init__.py +60 -0
  21. oceanarray/logsheet/_columns.py +97 -0
  22. oceanarray/logsheet/_config.py +245 -0
  23. oceanarray/logsheet/_firmware.py +43 -0
  24. oceanarray/logsheet/_latex.py +128 -0
  25. oceanarray/logsheet/_render.py +126 -0
  26. oceanarray/logsheet/builders/__init__.py +13 -0
  27. oceanarray/logsheet/builders/caldip_download.py +195 -0
  28. oceanarray/logsheet/builders/caldip_setup.py +188 -0
  29. oceanarray/logsheet/builders/deployment_setup.py +218 -0
  30. oceanarray/logsheet/builders/mooring_download.py +243 -0
  31. oceanarray/logsheet/builders/recovery.py +157 -0
  32. oceanarray/logsheet/data/column_defs.yaml +686 -0
  33. oceanarray/logsheet/data/latex_templates/_base_instrument.tex.j2 +42 -0
  34. oceanarray/logsheet/data/latex_templates/caldip_download.tex.j2 +47 -0
  35. oceanarray/logsheet/data/latex_templates/caldip_setup.tex.j2 +70 -0
  36. oceanarray/logsheet/data/latex_templates/deployment_setup.tex.j2 +97 -0
  37. oceanarray/logsheet/data/latex_templates/mooring_download.tex.j2 +54 -0
  38. oceanarray/logsheet/data/latex_templates/recovery.tex.j2 +103 -0
  39. oceanarray/mooring_level.py +1368 -0
  40. oceanarray/oceanarray.mplstyle +18 -0
  41. oceanarray/parameters.py +270 -0
  42. oceanarray/plotter.py +942 -0
  43. oceanarray/plotters/__init__.py +97 -0
  44. oceanarray/plotters/_animation.py +261 -0
  45. oceanarray/plotters/_current.py +705 -0
  46. oceanarray/plotters/_diagnostic.py +667 -0
  47. oceanarray/plotters/_helpers.py +57 -0
  48. oceanarray/plotters/_primitives.py +101 -0
  49. oceanarray/plotters/_section.py +16 -0
  50. oceanarray/plotters/_timeseries.py +11 -0
  51. oceanarray/rapid_interp.py +739 -0
  52. oceanarray/read_rbr_hex.py +541 -0
  53. oceanarray/readers.py +303 -0
  54. oceanarray/report/__init__.py +5 -0
  55. oceanarray/report/_array.py +386 -0
  56. oceanarray/report/_grid.py +539 -0
  57. oceanarray/report/_html_helpers.py +1004 -0
  58. oceanarray/report/_instrument.py +853 -0
  59. oceanarray/report/_mooring.py +1385 -0
  60. oceanarray/report/_plots.py +4284 -0
  61. oceanarray/report/_stack.py +941 -0
  62. oceanarray/stage1.py +1959 -0
  63. oceanarray/stage2.py +1160 -0
  64. oceanarray/stage3.py +2494 -0
  65. oceanarray/time_gridding.py +958 -0
  66. oceanarray/tools.py +400 -0
  67. oceanarray/transports.py +1 -0
  68. oceanarray/utilities.py +538 -0
  69. oceanarray/validation.py +389 -0
  70. oceanarray/writers.py +99 -0
  71. oceanarray-0.1.0.dist-info/METADATA +503 -0
  72. oceanarray-0.1.0.dist-info/RECORD +76 -0
  73. oceanarray-0.1.0.dist-info/WHEEL +5 -0
  74. oceanarray-0.1.0.dist-info/entry_points.txt +2 -0
  75. oceanarray-0.1.0.dist-info/licenses/LICENSE +21 -0
  76. 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