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,739 @@
1
+ """Physics-informed vertical interpolation scheme for sparse ocean profiles.
2
+
3
+ This module implements the core algorithm used in the RAPID array, which
4
+ reconstructs full-depth temperature and salinity profiles using climatological
5
+ vertical gradients.
6
+
7
+ Functions
8
+ ---------
9
+ - spacing : Generate evenly spaced pressure levels from start to end.
10
+ - save_climatology : Save climatology fields as a NetCDF dataset.
11
+ - smooth_climatology : Apply running mean smoothing to climatology fields.
12
+ - interpolate_internal : Interpolate between two sparse profile points using gradient fields.
13
+ - extrapolate_boundary : Extrapolate into unsampled regions near profile boundaries.
14
+
15
+ References
16
+ ----------
17
+ - Vertical interpolation based on climatological gradients (Johns et al., 2001).
18
+ - Adapted and translated from Matlab routines by T. Kanzow (2000).
19
+
20
+ """
21
+
22
+ import pathlib
23
+ from datetime import datetime
24
+ from typing import Tuple, Union
25
+
26
+ import gsw
27
+ import numpy as np
28
+ import pandas as pd
29
+ import xarray as xr
30
+ from scipy.integrate import cumulative_trapezoid
31
+ from scipy.interpolate import interp1d
32
+
33
+ from oceanarray import utilities
34
+ from oceanarray.logger import log_info
35
+
36
+
37
+ def spacing(p_start: float, p_end: float, step: float) -> np.ndarray:
38
+ """Create an array of pressures from `p_start` to `p_end` using approximately uniform spacing.
39
+
40
+ This helper function generates a 1D pressure array using either increasing or decreasing
41
+ steps depending on the ordering of `p_start` and `p_end`. It ensures that the end point
42
+ is included.
43
+
44
+ Parameters
45
+ ----------
46
+ p_start : float
47
+ Starting pressure [dbar].
48
+ p_end : float
49
+ Ending pressure [dbar].
50
+ step : float
51
+ Approximate pressure increment [dbar].
52
+
53
+ Returns
54
+ -------
55
+ np.ndarray
56
+ 1D array of pressure values from `p_start` to `p_end` (inclusive).
57
+
58
+ Notes
59
+ -----
60
+ This function is used internally by the vertical interpolation routines to construct
61
+ pressure grids between adjacent observations.
62
+
63
+ See Also
64
+ --------
65
+ verticalnn.rapid_interp.interpolate_internal : Uses this function to build vertical grids.
66
+
67
+ """
68
+ if p_start < p_end:
69
+ return np.arange(p_start, p_end + step, step)
70
+ else:
71
+ return np.arange(p_start, p_end - step, -step)
72
+
73
+
74
+ def save_climatology(
75
+ clim_ds: xr.Dataset, output_path: Union[str, pathlib.Path]
76
+ ) -> None:
77
+ """Save a climatology dataset containing vertical gradients to a NetCDF file.
78
+
79
+ This function writes the input dataset—typically containing monthly fields
80
+ of dT/dP and dS/dP as a function of temperature—to disk, adding metadata.
81
+
82
+ Parameters
83
+ ----------
84
+ clim_ds : xr.Dataset
85
+ Dataset containing climatological temperature and salinity gradients.
86
+ Should include variables like 'dTdp', 'dSdp', and coordinate 'TEMP', 'month'.
87
+ output_path : str or pathlib.Path
88
+ Destination file path for the NetCDF file.
89
+
90
+ Notes
91
+ -----
92
+ Adds metadata to the saved dataset:
93
+ - `description`: Short description of contents.
94
+ - `generated_by`: Marks source module (`verticalnn.rapid_interp`).
95
+ - `created_on`: ISO-formatted timestamp of when the file was written.
96
+
97
+ See Also
98
+ --------
99
+ verticalnn.rapid_interp.smooth_climatology : Smooths this dataset along the temperature axis.
100
+ verticalnn.rapid_interp.build_climatology : Creates the dataset to be saved.
101
+
102
+ """
103
+ output_path = pathlib.Path(output_path)
104
+
105
+ # Make sure parent folder exists
106
+ output_path.parent.mkdir(parents=True, exist_ok=True)
107
+
108
+ # Add some metadata
109
+ clim_ds.attrs.update(
110
+ {
111
+ "description": "Monthly climatology of dT/dP and dS/dP vs temperature.",
112
+ "generated_by": "verticalnn rapid_interp module",
113
+ "created_on": datetime.now().isoformat(timespec="seconds"),
114
+ }
115
+ )
116
+
117
+ # Save
118
+ clim_ds.to_netcdf(output_path)
119
+
120
+ log_info(f"✅ Saved climatology to {output_path}")
121
+
122
+
123
+ def smooth_climatology(clim_ds: xr.Dataset, window: int = 3) -> xr.Dataset:
124
+ """Apply rolling smoothing to vertical gradient climatology along the temperature axis.
125
+
126
+ This function smooths the dT/dP and dS/dP climatology fields using a centered
127
+ moving average along the temperature bins, applied independently for each month.
128
+
129
+ Parameters
130
+ ----------
131
+ clim_ds : xr.Dataset
132
+ Input climatology dataset with variables 'dTdp' and 'dSdp' on (month, TEMP) grid.
133
+ window : int, optional
134
+ Rolling window size (number of temperature bins), by default 3.
135
+
136
+ Returns
137
+ -------
138
+ xr.Dataset
139
+ Smoothed climatology dataset with the same coordinates and attributes.
140
+
141
+ Notes
142
+ -----
143
+ Rolling averages are computed with `center=True` and `min_periods=1`, preserving edge values.
144
+ The smoothing is purely horizontal (along TEMP), not temporal.
145
+
146
+ See Also
147
+ --------
148
+ verticalnn.rapid_interp.save_climatology : saves smoothed output to disk.
149
+
150
+ """
151
+ dTdp_smoothed = (
152
+ clim_ds["dTdp"].rolling(TEMP=window, center=True, min_periods=1).mean()
153
+ )
154
+ dSdp_smoothed = (
155
+ clim_ds["dSdp"].rolling(TEMP=window, center=True, min_periods=1).mean()
156
+ )
157
+
158
+ clim_ds_smoothed = xr.Dataset(
159
+ {
160
+ "dTdp": dTdp_smoothed,
161
+ "dSdp": dSdp_smoothed,
162
+ },
163
+ coords=clim_ds.coords,
164
+ attrs=clim_ds.attrs,
165
+ )
166
+
167
+ return clim_ds_smoothed
168
+
169
+
170
+ def build_climatology(
171
+ ds: xr.Dataset,
172
+ standard_pressures: np.ndarray,
173
+ temp_key: str = "CT",
174
+ salt_key: str = "SA",
175
+ pres_key: str = "PRESSURE",
176
+ time_key: str = "TIME",
177
+ min_profiles_per_bin: int = 5,
178
+ temp_bins: np.ndarray | None = None,
179
+ ) -> xr.Dataset:
180
+ """Build a seasonal climatology of dT/dP and dS/dP as a function of temperature.
181
+
182
+ This function processes hydrographic profiles to compute monthly climatologies
183
+ of temperature and salinity vertical gradients. It bins gradients by temperature
184
+ and month, and returns an xarray.Dataset with `dTdp` and `dSdp` fields indexed by
185
+ temperature and time of year.
186
+
187
+ Parameters
188
+ ----------
189
+ ds : xr.Dataset
190
+ Dataset containing temperature, salinity, pressure, and time variables.
191
+ standard_pressures : np.ndarray
192
+ Vertical pressure grid [dbar] to interpolate profiles before gradient calculation.
193
+ temp_key : str, optional
194
+ Variable name for Conservative Temperature, by default "CT".
195
+ salt_key : str, optional
196
+ Variable name for Absolute Salinity, by default "SA".
197
+ pres_key : str, optional
198
+ Variable name for Pressure, by default "PRESSURE".
199
+ time_key : str, optional
200
+ Variable name for time coordinate, by default "TIME".
201
+ min_profiles_per_bin : int, optional
202
+ Minimum number of valid profiles per (month, temp) bin to accept, by default 5.
203
+ temp_bins : np.ndarray, optional
204
+ Temperature bin edges for grouping (°C). If None, defaults to -2 to 35.5°C in 0.5°C bins.
205
+
206
+ Returns
207
+ -------
208
+ xr.Dataset
209
+ Climatology dataset with 'dTdp' and 'dSdp' indexed by ('month', 'TEMP').
210
+
211
+ Notes
212
+ -----
213
+ - Interpolates all input profiles to `standard_pressures` before calculating gradients.
214
+ - Based on the physics-informed interpolation approach described in Johns et al. (2001).
215
+ - Original implementation adapted from Matlab code by T. Kanzow (2000).
216
+
217
+ See Also
218
+ --------
219
+ verticalnn.rapid_interp.smooth_climatology : Applies smoothing to output climatology.
220
+ verticalnn.rapid_interp.save_climatology : Saves climatology dataset to disk.
221
+ verticalnn.rapid_interp.interpolate_profiles : Uses this climatology for profile interpolation.
222
+
223
+ """
224
+ if temp_bins is None:
225
+ temp_bins = np.arange(-2, 35.5, 0.5) # typical ocean range
226
+
227
+ # Step 1: Downsample each profile to standard pressures
228
+ pres_key, time_key, pres_dim, time_dim = utilities.get_dims(ds)
229
+ profile_dim = time_dim
230
+ ntime = ds.sizes[profile_dim]
231
+
232
+ # Reshape pressure array to match the number of profiles
233
+ if ds[pres_key].ndim == 2:
234
+ pressure_array = ds[pres_key].values
235
+ else:
236
+ pressure_array = np.tile(ds[pres_key].values, (ntime, 1))
237
+
238
+ temp_std = np.full((ntime, len(standard_pressures)), np.nan)
239
+ salt_std = np.full((ntime, len(standard_pressures)), np.nan)
240
+
241
+ for i in range(ntime):
242
+ t_prof = ds[temp_key].isel({profile_dim: i}).values
243
+ s_prof = ds[salt_key].isel({profile_dim: i}).values
244
+ p_prof = pressure_array[i, :]
245
+
246
+ mask = ~np.isnan(t_prof) & ~np.isnan(p_prof)
247
+
248
+ if np.sum(mask) < 2:
249
+ continue # skip bad profiles
250
+
251
+ temp_std[i, :] = np.interp(
252
+ standard_pressures, p_prof[mask], t_prof[mask], left=np.nan, right=np.nan
253
+ )
254
+ salt_std[i, :] = np.interp(
255
+ standard_pressures, p_prof[mask], s_prof[mask], left=np.nan, right=np.nan
256
+ )
257
+
258
+ # Step 2: Assign months
259
+ months = pd.to_datetime(ds[time_key].values).month # array of months (1–12)
260
+
261
+ # Step 3: Compute gradients dT/dP and dS/dP
262
+ dTdp = np.gradient(temp_std, standard_pressures, axis=1)
263
+ dSdp = np.gradient(salt_std, standard_pressures, axis=1)
264
+
265
+ # Step 4: Flatten arrays
266
+ flat_temp = temp_std.flatten()
267
+ flat_dTdp = dTdp.flatten()
268
+ flat_dSdp = dSdp.flatten()
269
+ flat_month = np.repeat(months, len(standard_pressures))
270
+
271
+ # Step 5: Bin by temperature and month
272
+ temp_bin_centers = 0.5 * (temp_bins[:-1] + temp_bins[1:])
273
+ n_temp_bins = len(temp_bin_centers)
274
+
275
+ dTdp_clim = np.full((12, n_temp_bins), np.nan)
276
+ dSdp_clim = np.full((12, n_temp_bins), np.nan)
277
+
278
+ for month in range(1, 13):
279
+ mask_month = flat_month == month
280
+
281
+ for j, (tmin, tmax) in enumerate(zip(temp_bins[:-1], temp_bins[1:])):
282
+ mask_temp = (flat_temp >= tmin) & (flat_temp < tmax)
283
+
284
+ combined_mask = mask_month & mask_temp
285
+
286
+ if np.sum(combined_mask) >= min_profiles_per_bin:
287
+ dTdp_clim[month - 1, j] = np.nanmean(flat_dTdp[combined_mask])
288
+ dSdp_clim[month - 1, j] = np.nanmean(flat_dSdp[combined_mask])
289
+
290
+ # Step 6: Build climatology xarray.Dataset
291
+ clim_ds = xr.Dataset(
292
+ {
293
+ "dTdp": (("month", "TEMP"), dTdp_clim),
294
+ "dSdp": (("month", "TEMP"), dSdp_clim),
295
+ },
296
+ coords={
297
+ "month": np.arange(1, 13),
298
+ "TEMP": temp_bin_centers,
299
+ },
300
+ attrs={
301
+ "method": "seasonal climatology built from downsampled CTD profiles",
302
+ "temp_bin_width": temp_bins[1] - temp_bins[0],
303
+ },
304
+ )
305
+
306
+ return clim_ds
307
+
308
+
309
+ def extrapolate_boundary(
310
+ T: float,
311
+ S: float,
312
+ P: float,
313
+ p_bound: float,
314
+ dtdp_func,
315
+ dsdp_func,
316
+ int_step: float = 20.0,
317
+ ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
318
+ """Extrapolate temperature and salinity profiles from a boundary point using climatological gradients.
319
+
320
+ This function integrates vertical gradients of temperature (dT/dP) and salinity (dS/dP)
321
+ downward or upward from a single observed point to reach a specified pressure boundary.
322
+
323
+ Parameters
324
+ ----------
325
+ T : float
326
+ Starting temperature [°C].
327
+ S : float
328
+ Starting salinity [g/kg].
329
+ P : float
330
+ Starting pressure [dbar].
331
+ p_bound : float
332
+ Target pressure to extrapolate to [dbar].
333
+ dtdp_func : function
334
+ Callable that returns dT/dP as a function of T.
335
+ dsdp_func : function
336
+ Callable that returns dS/dP as a function of T.
337
+ int_step : float, optional
338
+ Integration step size [dbar], by default 20.0.
339
+
340
+ Returns
341
+ -------
342
+ T_profile : np.ndarray
343
+ Extrapolated temperature profile.
344
+ S_profile : np.ndarray
345
+ Extrapolated salinity profile.
346
+ P_profile : np.ndarray
347
+ Corresponding pressure levels.
348
+
349
+ Notes
350
+ -----
351
+ This function integrates stepwise from the boundary point toward the pressure bound.
352
+ The step direction is automatically inferred from the sign of `(p_bound - P)`.
353
+
354
+ Based on the original Matlab routine `t_bound0.m` by T. Kanzow (2000).
355
+ Method described in Johns et al. (2001).
356
+
357
+ See Also
358
+ --------
359
+ verticalnn.rapid_interp.interpolate_internal : Used for interpolation between data points.
360
+ verticalnn.rapid_interp.interpolate_profiles : Combines extrapolation and interpolation.
361
+
362
+ """
363
+ # Handle degenerate case early
364
+ if P == p_bound:
365
+ return np.array([T]), np.array([S]), np.array([P])
366
+
367
+ # Ensure consistent stepping direction
368
+ step = np.sign(p_bound - P) * abs(int_step)
369
+
370
+ # Create pressure steps from P to p_bound (inclusive)
371
+ inc = np.arange(P, p_bound, step)
372
+ if inc.size == 0 or inc[-1] != p_bound:
373
+ inc = np.append(inc, p_bound)
374
+
375
+ # Evaluate gradients at each pressure step
376
+ T_values = np.full_like(inc, np.nan)
377
+ T_values[0] = T
378
+
379
+ for i in range(1, len(inc)):
380
+ T_values[i] = T_values[i - 1] + dtdp_func(T_values[i - 1]) * (
381
+ inc[i] - inc[i - 1]
382
+ )
383
+
384
+ # Now compute gradients based on T_values
385
+ dTdp_vals = dtdp_func(T_values)
386
+ dSdp_vals = dsdp_func(T_values)
387
+
388
+ # Integrate gradients
389
+ T_profile = T + cumulative_trapezoid(dTdp_vals, inc, initial=0)
390
+ S_profile = S + cumulative_trapezoid(dSdp_vals, inc, initial=0)
391
+ P_profile = inc
392
+
393
+ # Flatten to 1D arrays
394
+ T_profile = T_profile.flatten()
395
+ S_profile = S_profile.flatten()
396
+ P_profile = P_profile.flatten()
397
+
398
+ # Ensure output is sorted increasing in pressure
399
+ if P_profile[0] > P_profile[-1]:
400
+ T_profile = np.flip(T_profile)
401
+ S_profile = np.flip(S_profile)
402
+ P_profile = np.flip(P_profile)
403
+
404
+ return T_profile, S_profile, P_profile
405
+
406
+
407
+ def interpolate_internal(
408
+ T: np.ndarray,
409
+ S: np.ndarray,
410
+ P: np.ndarray,
411
+ dtdp_func,
412
+ dsdp_func,
413
+ int_step: float = 20.0,
414
+ ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
415
+ """Interpolate between observed data points using climatological vertical gradients.
416
+
417
+ This function fills gaps between observed pressure levels by integrating temperature
418
+ and salinity gradients from both the upper and lower sensors and blending the results.
419
+ It implements the logic of the original `t_int0.m` routine by Kanzow (2000).
420
+
421
+ Parameters
422
+ ----------
423
+ T : np.ndarray
424
+ Observed temperature values [°C].
425
+ S : np.ndarray
426
+ Observed salinity values [g/kg].
427
+ P : np.ndarray
428
+ Observed pressure values [dbar].
429
+ dtdp_func : callable
430
+ Function that returns dT/dP given temperature.
431
+ dsdp_func : callable
432
+ Function that returns dS/dP given temperature.
433
+ int_step : float, optional
434
+ Pressure increment for integration [dbar], by default 20.0.
435
+
436
+ Returns
437
+ -------
438
+ np.ndarray
439
+ Interpolated temperature values between observations.
440
+ np.ndarray
441
+ Interpolated salinity values between observations.
442
+ np.ndarray
443
+ Corresponding pressure values.
444
+
445
+ Notes
446
+ -----
447
+ This function performs dual integration (from above and below) and blends the two
448
+ estimates using linear weighting. Duplicate pressure values are removed in the final output.
449
+ - Adapted from original Matlab function `t_int0.m`.
450
+ - Author: T. Kanzow, 4 April 2000.
451
+ - Part of the vertical interpolation scheme described in Johns et al. (2001):
452
+ "The Kuroshio east of Taiwan: Moored transport observations from the WOCE PCM-1 Array."
453
+ This version translated to Python and adapted for TEOS-10 Conservative Temperature and Absolute Salinity.
454
+
455
+ See Also
456
+ --------
457
+ verticalnn.rapid_interp.extrapolate_boundary : For extrapolation above or below the data range.
458
+ verticalnn.rapid_interp.interpolate_profiles : Applies this interpolation to all profiles in a dataset.
459
+
460
+ """
461
+ innerT = []
462
+ innerS = []
463
+ innerP = []
464
+
465
+ ni = len(T) - 1 # number of separate gaps to fill
466
+
467
+ for i in range(ni):
468
+ p_start = P[i]
469
+ p_end = P[i + 1]
470
+ t_start = T[i]
471
+ t_end = T[i + 1]
472
+ s_start = S[i]
473
+ s_end = S[i + 1]
474
+
475
+ # Build pressure grid between points
476
+ inc = np.arange(p_start, p_end, np.sign(p_end - p_start) * int_step)
477
+ inc = np.append(inc, p_end)
478
+
479
+ if len(inc) < 2:
480
+ continue
481
+
482
+ dinc_up = np.diff(inc)
483
+ dinc_down = np.diff(inc[::-1])
484
+
485
+ # Upward integration (from top sensor)
486
+ tgrad1 = (
487
+ dtdp_func(t_start)
488
+ if np.isscalar(t_start)
489
+ else dtdp_func(np.array([t_start]))[0]
490
+ )
491
+ sgrad1 = (
492
+ dsdp_func(t_start)
493
+ if np.isscalar(t_start)
494
+ else dsdp_func(np.array([t_start]))[0]
495
+ )
496
+ T1 = t_start + cumulative_trapezoid(np.full_like(inc, tgrad1), inc, initial=0)
497
+ S1 = s_start + cumulative_trapezoid(np.full_like(inc, sgrad1), inc, initial=0)
498
+
499
+ # Downward integration (from bottom sensor)
500
+ tgrad2 = (
501
+ dtdp_func(t_end) if np.isscalar(t_end) else dtdp_func(np.array([t_end]))[0]
502
+ )
503
+ sgrad2 = (
504
+ dsdp_func(t_end) if np.isscalar(t_end) else dsdp_func(np.array([t_end]))[0]
505
+ )
506
+ T2 = t_end + cumulative_trapezoid(
507
+ np.full_like(inc[::-1], tgrad2), inc[::-1], initial=0
508
+ )
509
+ S2 = s_end + cumulative_trapezoid(
510
+ np.full_like(inc[::-1], sgrad2), inc[::-1], initial=0
511
+ )
512
+
513
+ T2 = T2[::-1]
514
+ S2 = S2[::-1]
515
+
516
+ # Weights
517
+ w1 = 1 - np.abs(inc - p_start) / np.abs(p_end - p_start)
518
+ w2 = 1 - np.abs(inc - p_end) / np.abs(p_end - p_start)
519
+
520
+ # Weighted average
521
+ T_comb = w1 * T1 + w2 * T2
522
+ S_comb = w1 * S1 + w2 * S2
523
+
524
+ innerT.append(T_comb)
525
+ innerS.append(S_comb)
526
+ innerP.append(inc)
527
+
528
+ if not innerT:
529
+ return np.array([]), np.array([]), np.array([])
530
+
531
+ innerT = np.concatenate(innerT)
532
+ innerS = np.concatenate(innerS)
533
+ innerP = np.concatenate(innerP)
534
+
535
+ # Remove duplicate pressures
536
+ _, unique_idx = np.unique(innerP, return_index=True)
537
+ innerT = innerT[unique_idx]
538
+ innerS = innerS[unique_idx]
539
+ innerP = innerP[unique_idx]
540
+
541
+ return innerT, innerS, innerP
542
+
543
+
544
+ def interpolate_profiles(
545
+ ds: xr.Dataset,
546
+ clim_ds: xr.Dataset,
547
+ temp_key: str = "CT",
548
+ salt_key: str = "SA",
549
+ pres_key: str = "PRES",
550
+ time_key: str = "TIME",
551
+ p_grid: np.ndarray | None = None,
552
+ int_step: float = 20.0,
553
+ extrapolate: bool = True,
554
+ ) -> xr.Dataset:
555
+ """Interpolate sparse moored profiles onto a regular vertical grid using climatological gradients.
556
+
557
+ This function performs both interpolation between observed levels and extrapolation
558
+ beyond observed bounds using seasonal climatologies of dT/dP and dS/dP, following
559
+ the physics-informed method described by Johns et al. (2001) and originally implemented
560
+ in Matlab by T. Kanzow (2000).
561
+
562
+ Parameters
563
+ ----------
564
+ ds : xr.Dataset
565
+ Input dataset containing sparse temperature, salinity, and pressure profiles.
566
+ clim_ds : xr.Dataset
567
+ Climatology dataset with vertical gradients (variables: 'dTdp', 'dSdp') indexed by TEMP and month.
568
+ temp_key : str, optional
569
+ Variable name for Conservative Temperature, by default "CT".
570
+ salt_key : str, optional
571
+ Variable name for Absolute Salinity, by default "SA".
572
+ pres_key : str, optional
573
+ Variable name for pressure, by default "PRES".
574
+ time_key : str, optional
575
+ Variable name for profile time coordinate, by default "TIME".
576
+ p_grid : np.ndarray, optional
577
+ Target vertical grid for output profiles [dbar], by default `np.arange(0, 5000, 20)`.
578
+ int_step : float, optional
579
+ Step size for vertical integration [dbar], by default 20.
580
+ extrapolate : bool, optional
581
+ Whether to extrapolate above and below observed pressures, by default True.
582
+
583
+ Returns
584
+ -------
585
+ xr.Dataset
586
+ Interpolated dataset with gridded CT, SA, and SIGMA0 on the pressure grid.
587
+
588
+ Notes
589
+ -----
590
+ - Uses temperature-dependent vertical gradients from `clim_ds`.
591
+ - Combines logic of `con_tprof0.m`, `t_int0.m`, and `t_bound0.m` (Kanzow 2000).
592
+ - Final dataset includes derived potential density (SIGMA0) using TEOS-10.
593
+
594
+ See Also
595
+ --------
596
+ verticalnn.rapid_interp.build_climatology : Generates the dT/dP and dS/dP climatology.
597
+ verticalnn.rapid_interp.interpolate_internal : Fills gaps between sensors.
598
+ verticalnn.rapid_interp.extrapolate_boundary : Fills above/below observed range.
599
+
600
+ """
601
+ # Check if the climatology dataset has the required variables
602
+ utilities.check_necessary_variables(clim_ds, vars=["dTdp", "dSdp"])
603
+ utilities.check_necessary_variables(
604
+ ds, vars=[temp_key, salt_key, "LATITUDE", "LONGITUDE"]
605
+ )
606
+
607
+ pres_key, time_key1, pres_dim, time_dim = utilities.get_dims(ds)
608
+ if time_key1 != time_key:
609
+ raise ValueError(f"Time key mismatch: {time_key1} != {time_key}")
610
+
611
+ if p_grid is None:
612
+ p_grid = np.arange(0, 5000, 20)
613
+
614
+ profile_dim = time_dim
615
+ ntime = ds.sizes[profile_dim]
616
+
617
+ t_con = np.full((ntime, len(p_grid)), np.nan)
618
+ s_con = np.full((ntime, len(p_grid)), np.nan)
619
+
620
+ TEMP = clim_ds["TEMP"].values
621
+
622
+ # Reshape pressure array to match the number of profiles
623
+ if ds[pres_key].ndim == 2:
624
+ pressure_array = ds[pres_key].values
625
+ else:
626
+ pressure_array = np.tile(ds[pres_key].values, (ntime, 1))
627
+
628
+ for ti in range(ntime):
629
+ t_prof = ds[temp_key].isel({time_dim: ti}).values
630
+ s_prof = ds[salt_key].isel({time_dim: ti}).values
631
+ p_prof = pressure_array[ti, :]
632
+
633
+ mask = ~np.isnan(t_prof) & ~np.isnan(p_prof)
634
+
635
+ if np.sum(mask) < 2:
636
+ continue # Not enough data to interpolate
637
+
638
+ t_good = t_prof[mask]
639
+ s_good = s_prof[mask]
640
+ p_good = p_prof[mask]
641
+
642
+ # --- Select climatology for the month ---
643
+ month_i = pd.to_datetime(ds[time_key].isel({time_dim: ti}).values).month
644
+
645
+ cTg_month = clim_ds["dTdp"].sel(month=month_i).values
646
+ cSg_month = clim_ds["dSdp"].sel(month=month_i).values
647
+
648
+ dtdp_func = interp1d(
649
+ TEMP,
650
+ cTg_month,
651
+ bounds_error=False,
652
+ fill_value=(cTg_month[0], cTg_month[-1]),
653
+ )
654
+ dsdp_func = interp1d(
655
+ TEMP,
656
+ cSg_month,
657
+ bounds_error=False,
658
+ fill_value=(cSg_month[0], cSg_month[-1]),
659
+ )
660
+
661
+ segments = []
662
+
663
+ # --- Extrapolate upward if needed
664
+ if extrapolate and p_good[0] > p_grid.min():
665
+ upT, upS, upP = extrapolate_boundary(
666
+ T=t_good[0],
667
+ S=s_good[0],
668
+ P=p_good[0],
669
+ p_bound=p_grid.min(),
670
+ dtdp_func=dtdp_func,
671
+ dsdp_func=dsdp_func,
672
+ int_step=int_step,
673
+ )
674
+ mask_up = upP < p_good[0]
675
+ segments.append((upT[mask_up], upS[mask_up], upP[mask_up]))
676
+
677
+ # --- Interpolate internal points
678
+ innerT, innerS, innerP = interpolate_internal(
679
+ t_good,
680
+ s_good,
681
+ p_good,
682
+ dtdp_func=dtdp_func,
683
+ dsdp_func=dsdp_func,
684
+ int_step=int_step,
685
+ )
686
+ segments.append((innerT, innerS, innerP))
687
+
688
+ # --- Extrapolate downward if needed
689
+ if extrapolate and p_good[-1] < p_grid.max():
690
+ lowT, lowS, lowP = extrapolate_boundary(
691
+ T=t_good[-1],
692
+ S=s_good[-1],
693
+ P=p_good[-1],
694
+ p_bound=p_grid.max(),
695
+ dtdp_func=dtdp_func,
696
+ dsdp_func=dsdp_func,
697
+ int_step=int_step,
698
+ )
699
+ mask_low = lowP > p_good[-1]
700
+ segments.append((lowT[mask_low], lowS[mask_low], lowP[mask_low]))
701
+
702
+ # --- Stitch segments together
703
+ full_T = np.concatenate([seg[0] for seg in segments])
704
+ full_S = np.concatenate([seg[1] for seg in segments])
705
+ full_P = np.concatenate([seg[2] for seg in segments])
706
+
707
+ # Sort by pressure
708
+ sort_idx = np.argsort(full_P)
709
+ full_T = full_T[sort_idx]
710
+ full_S = full_S[sort_idx]
711
+ full_P = full_P[sort_idx]
712
+
713
+ # Interpolate onto regular p_grid
714
+ t_interp = np.interp(p_grid, full_P, full_T, left=np.nan, right=np.nan)
715
+ s_interp = np.interp(p_grid, full_P, full_S, left=np.nan, right=np.nan)
716
+
717
+ t_con[ti, :] = t_interp
718
+ s_con[ti, :] = s_interp
719
+
720
+ sigma_con = gsw.sigma0(s_con, t_con)
721
+
722
+ out_ds = xr.Dataset(
723
+ {
724
+ temp_key: ((time_dim, pres_dim), t_con),
725
+ salt_key: ((time_dim, pres_dim), s_con),
726
+ "SIGMA0": ((time_dim, pres_dim), sigma_con),
727
+ },
728
+ coords={
729
+ pres_key: (pres_dim, p_grid),
730
+ time_key: (time_dim, ds[time_key].values),
731
+ "LATITUDE": (time_dim, ds["LATITUDE"].values),
732
+ "LONGITUDE": (time_dim, ds["LONGITUDE"].values),
733
+ },
734
+ attrs={
735
+ "method": "vertical interpolation using climatological gradients with seasonal adjustment"
736
+ },
737
+ )
738
+
739
+ return out_ds